feat: worst-scan fansub web — initial release
Posts engine (SQLite + auto-publisher via poller), public feed with clickable tags, reader, admin panel, submit/search/queue tools. BFF proxy to pipeline API. Clean dark design. Docker-ready.
This commit is contained in:
@@ -0,0 +1,95 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect, FormEvent } from "react"
|
||||
import { useParams, useRouter } from "next/navigation"
|
||||
import Link from "next/link"
|
||||
import { ArrowLeft, Save } from "lucide-react"
|
||||
|
||||
export default function EditPostPage() {
|
||||
const { id } = useParams<{ id: string }>()
|
||||
const router = useRouter()
|
||||
|
||||
const [title, setTitle] = useState("")
|
||||
const [summary, setSummary] = useState("")
|
||||
const [slug, setSlug] = useState("")
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
try {
|
||||
const res = await fetch(`/api/posts/${id}`)
|
||||
const data = await res.json()
|
||||
const post = data?.data
|
||||
if (post) {
|
||||
setTitle(post.title)
|
||||
setSummary(post.summary || "")
|
||||
setSlug(post.slug)
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
setLoading(false)
|
||||
}
|
||||
load()
|
||||
}, [id])
|
||||
|
||||
async function handleSave(e: FormEvent) {
|
||||
e.preventDefault()
|
||||
setSaving(true)
|
||||
try {
|
||||
await fetch(`/api/posts/${id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ title, summary, slug }),
|
||||
})
|
||||
router.push("/admin/posts")
|
||||
} catch {
|
||||
}
|
||||
setSaving(false)
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <div className="py-20 text-center text-sm text-[var(--muted)]">Cargando...</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl">
|
||||
<Link
|
||||
href="/admin/posts"
|
||||
className="mb-6 inline-flex items-center gap-1.5 text-xs text-[var(--muted)] hover:text-[var(--foreground)] transition-colors"
|
||||
>
|
||||
<ArrowLeft className="h-3.5 w-3.5" />
|
||||
Volver a posts
|
||||
</Link>
|
||||
|
||||
<h1 className="mb-6 text-xl font-bold">Editar Post</h1>
|
||||
|
||||
<form onSubmit={handleSave} className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs font-medium text-[var(--muted)]">Título</label>
|
||||
<input value={title} onChange={(e) => setTitle(e.target.value)} className="input" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs font-medium text-[var(--muted)]">Slug</label>
|
||||
<input value={slug} onChange={(e) => setSlug(e.target.value)} className="input font-mono text-xs" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs font-medium text-[var(--muted)]">Resumen</label>
|
||||
<textarea
|
||||
value={summary}
|
||||
onChange={(e) => setSummary(e.target.value)}
|
||||
rows={8}
|
||||
className="input resize-y"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button type="submit" disabled={saving} className="btn-primary">
|
||||
<Save className="h-4 w-4" />
|
||||
{saving ? "Guardando..." : "Guardar"}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import useSWR from "swr"
|
||||
import Link from "next/link"
|
||||
import { RefreshCw, FileText, CheckCircle, XCircle, ExternalLink } from "lucide-react"
|
||||
import { EmptyState } from "@/components/empty-state"
|
||||
|
||||
const fetcher = (url: string) => fetch(url).then((r) => r.json())
|
||||
|
||||
export default function AdminPostsPage() {
|
||||
const { data, isLoading, mutate } = useSWR("/api/posts", fetcher, { refreshInterval: 15000 })
|
||||
const [publishing, setPublishing] = useState<Set<number>>(new Set())
|
||||
const [deleting, setDeleting] = useState<Set<number>>(new Set())
|
||||
|
||||
const posts = data?.data || []
|
||||
|
||||
async function handlePublish(id: number, current: number) {
|
||||
setPublishing((prev) => new Set(prev).add(id))
|
||||
await fetch(`/api/posts/${id}/publish`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ publish: current === 0 }),
|
||||
})
|
||||
mutate()
|
||||
setPublishing((prev) => {
|
||||
const next = new Set(prev)
|
||||
next.delete(id)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
async function handleDelete(id: number) {
|
||||
if (!confirm("¿Eliminar este post permanentemente?")) return
|
||||
setDeleting((prev) => new Set(prev).add(id))
|
||||
await fetch(`/api/posts/${id}`, { method: "DELETE" })
|
||||
mutate()
|
||||
setDeleting((prev) => {
|
||||
const next = new Set(prev)
|
||||
next.delete(id)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold">Posts</h1>
|
||||
<p className="text-sm text-[var(--muted)]">{posts.length} publicaciones</p>
|
||||
</div>
|
||||
<button onClick={() => mutate()} disabled={isLoading} className="btn-ghost">
|
||||
<RefreshCw className={`h-4 w-4 ${isLoading ? "animate-spin" : ""}`} />
|
||||
Actualizar
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{posts.length === 0 ? (
|
||||
<EmptyState message="No hay posts" icon={<FileText className="mb-3 h-10 w-10 text-[var(--muted)]" />} />
|
||||
) : (
|
||||
<div className="card divide-y divide-[var(--border)] overflow-hidden">
|
||||
{posts.map((post: {
|
||||
id: number
|
||||
gid: string
|
||||
title: string
|
||||
slug: string
|
||||
published: number
|
||||
artist: string | null
|
||||
source: string | null
|
||||
created_at: string
|
||||
}) => (
|
||||
<div key={post.id} className="flex items-center gap-3 px-4 py-3 text-sm">
|
||||
<button
|
||||
onClick={() => handlePublish(post.id, post.published)}
|
||||
disabled={publishing.has(post.id)}
|
||||
className="flex-shrink-0"
|
||||
title={post.published ? "Publicado" : "No publicado"}
|
||||
>
|
||||
{post.published ? (
|
||||
<CheckCircle className="h-5 w-5 text-[var(--success)]" />
|
||||
) : (
|
||||
<XCircle className="h-5 w-5 text-[var(--muted)]" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<Link
|
||||
href={`/p/${post.slug}`}
|
||||
className="font-medium hover:text-[var(--accent)] transition-colors line-clamp-1"
|
||||
>
|
||||
{post.title}
|
||||
</Link>
|
||||
<div className="flex items-center gap-2 text-xs text-[var(--muted)]">
|
||||
<span>{post.gid}</span>
|
||||
{post.artist && <span>{post.artist}</span>}
|
||||
{post.source && <span>{post.source}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<Link href={`/gallery/${post.gid}`} className="btn-ghost p-1.5">
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => handleDelete(post.id)}
|
||||
disabled={deleting.has(post.id)}
|
||||
className="btn-ghost p-1.5 text-[var(--error)] hover:bg-[var(--error-subtle)]"
|
||||
>
|
||||
<XCircle className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useGalleries } from "@/hooks/use-galleries"
|
||||
import { GalleryGrid } from "@/components/gallery-grid"
|
||||
import { FilterBar } from "@/components/filter-bar"
|
||||
|
||||
export default function FeedPage() {
|
||||
const [status, setStatus] = useState("completed")
|
||||
const { galleries, isLoading, mutate } = useGalleries(status, 1)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6">
|
||||
<h1 className="text-xl font-bold">Feed</h1>
|
||||
<p className="text-sm text-[var(--muted)]">
|
||||
{isLoading && galleries.length === 0
|
||||
? "Cargando galleries..."
|
||||
: `${galleries.length} galleries`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<FilterBar
|
||||
current={status}
|
||||
onChange={setStatus}
|
||||
onRefresh={() => mutate()}
|
||||
loading={isLoading}
|
||||
/>
|
||||
|
||||
{isLoading && galleries.length === 0 ? (
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div key={i} className="card flex gap-4 overflow-hidden p-0">
|
||||
<div className="skeleton h-36 w-24 flex-shrink-0" />
|
||||
<div className="flex flex-1 flex-col gap-2 py-3 pr-3">
|
||||
<div className="skeleton h-4 w-3/4" />
|
||||
<div className="skeleton h-3 w-1/4" />
|
||||
<div className="skeleton mt-auto h-3 w-1/3" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<GalleryGrid items={galleries} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { useParams } from "next/navigation"
|
||||
import useSWR from "swr"
|
||||
import { BookOpen, ExternalLink, Trash2 } from "lucide-react"
|
||||
import { CoverImage } from "@/components/cover-image"
|
||||
import { TagBadge } from "@/components/tag-badge"
|
||||
import type { GallerySummary, GalleryArtifacts } from "@/lib/types"
|
||||
|
||||
const fetcher = (url: string) => fetch(url).then((r) => r.json())
|
||||
|
||||
export default function GalleryDetailPage() {
|
||||
const { gid } = useParams<{ gid: string }>()
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
|
||||
const { data: summaryData, isLoading } = useSWR(
|
||||
`/api/proxy/galleries/${gid}/summary`,
|
||||
fetcher,
|
||||
)
|
||||
|
||||
const { data: artifactsData } = useSWR(
|
||||
`/api/proxy/galleries/${gid}/artifacts`,
|
||||
fetcher,
|
||||
)
|
||||
|
||||
const summary = summaryData?.data as GallerySummary | undefined
|
||||
const artifacts = artifactsData?.data as GalleryArtifacts | undefined
|
||||
|
||||
async function handleDelete() {
|
||||
if (!confirm("¿Eliminar esta gallery?")) return
|
||||
setDeleting(true)
|
||||
try {
|
||||
await fetch(`/api/proxy/galleries/${gid}`, { method: "DELETE" })
|
||||
window.location.href = "/feed"
|
||||
} catch {
|
||||
setDeleting(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="max-w-3xl">
|
||||
<div className="flex gap-6">
|
||||
<div className="skeleton h-56 w-40 flex-shrink-0 rounded-lg" />
|
||||
<div className="flex flex-1 flex-col gap-3">
|
||||
<div className="skeleton h-6 w-3/4" />
|
||||
<div className="skeleton h-4 w-1/2" />
|
||||
<div className="skeleton h-4 w-1/3" />
|
||||
<div className="skeleton mt-auto h-9 w-24" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!summary) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-20">
|
||||
<p className="text-sm text-[var(--error)]">Gallery no encontrada</p>
|
||||
<Link href="/feed" className="btn-ghost mt-4">
|
||||
Volver al feed
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl">
|
||||
<div className="card mb-6 flex flex-col gap-6 overflow-hidden p-0 sm:flex-row">
|
||||
<CoverImage
|
||||
gid={gid}
|
||||
coverUrl={summary.cover.external_url}
|
||||
alt={summary.title}
|
||||
className="h-56 w-40 flex-shrink-0"
|
||||
/>
|
||||
|
||||
<div className="flex flex-col gap-2 px-6 pb-6 pt-0 sm:py-6 sm:pl-0">
|
||||
<h1 className="text-xl font-bold">{summary.title}</h1>
|
||||
{summary.title_jpn && (
|
||||
<p className="text-sm text-[var(--muted)]">{summary.title_jpn}</p>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm">
|
||||
{summary.artist && <TagBadge tag={`artist:${summary.artist}`} href={`/tag/${encodeURIComponent(`artist:${summary.artist}`)}`} />}
|
||||
{summary.parody && <TagBadge tag={`parody:${summary.parody}`} href={`/tag/${encodeURIComponent(`parody:${summary.parody}`)}`} />}
|
||||
{summary.is_spanish && (
|
||||
<span className="tag-pill bg-emerald-900/30 text-emerald-300 border border-emerald-800/40">
|
||||
ESP
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-[var(--muted)]">
|
||||
{summary.num_pages} páginas · {summary.source}
|
||||
</p>
|
||||
|
||||
<div className="mt-auto flex items-center gap-2">
|
||||
<Link href={`/gallery/${gid}/read`} className="btn-primary">
|
||||
<BookOpen className="h-4 w-4" />
|
||||
Leer
|
||||
</Link>
|
||||
{summary.url && (
|
||||
<a href={summary.url} target="_blank" rel="noopener noreferrer" className="btn-ghost">
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
Original
|
||||
</a>
|
||||
)}
|
||||
<button onClick={handleDelete} disabled={deleting} className="btn-danger">
|
||||
<Trash2 className="h-4 w-4" />
|
||||
{deleting ? "..." : "Eliminar"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{summary.tags && summary.tags.length > 0 && (
|
||||
<section className="mb-6">
|
||||
<h2 className="mb-3 text-xs font-semibold text-[var(--muted)] uppercase tracking-wider">Tags</h2>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{summary.tags.map((tag) => (
|
||||
<TagBadge key={tag} tag={tag} href={`/tag/${encodeURIComponent(tag)}`} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{artifacts && (
|
||||
<section>
|
||||
<h2 className="mb-3 text-xs font-semibold text-[var(--muted)] uppercase tracking-wider">
|
||||
Archivos ({artifacts.file_count} archivos, {(artifacts.total_size_mb).toFixed(1)} MB)
|
||||
</h2>
|
||||
<div className="card divide-y divide-[var(--border)] overflow-hidden">
|
||||
{Object.entries(artifacts.files).map(([dir, files]) =>
|
||||
Array.isArray(files) && files.length ? (
|
||||
<details key={dir} className="group">
|
||||
<summary className="flex cursor-pointer items-center gap-2 px-4 py-2.5 text-sm text-[var(--muted)] hover:text-[var(--foreground)] transition-colors">
|
||||
<span className="font-medium">{dir}</span>
|
||||
<span className="text-xs text-[var(--muted)]">({files.length})</span>
|
||||
</summary>
|
||||
<div className="border-t border-[var(--border)] px-4 py-2">
|
||||
<p className="mb-2 text-xs text-[var(--muted)]">
|
||||
{files.slice(0, 20).map((f) => (
|
||||
<span key={f} className="block py-0.5 font-mono text-[10px]">{f}</span>
|
||||
))}
|
||||
{files.length > 20 && (
|
||||
<span className="block py-1 text-[10px] text-[var(--muted)]">
|
||||
... y {files.length - 20} más
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</details>
|
||||
) : null,
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useRef, useState, useCallback } from "react"
|
||||
import { useParams } from "next/navigation"
|
||||
import { ChevronLeft, ChevronRight, ZoomIn, ZoomOut, Loader2 } from "lucide-react"
|
||||
|
||||
export default function ReaderPage() {
|
||||
const { gid } = useParams<{ gid: string }>()
|
||||
const [page, setPage] = useState(0)
|
||||
const [pageUrls, setPageUrls] = useState<string[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [zoom, setZoom] = useState(1)
|
||||
const [error, setError] = useState("")
|
||||
const [title, setTitle] = useState("")
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const [showControls, setShowControls] = useState(true)
|
||||
const hideTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
setLoading(true)
|
||||
setError("")
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/proxy/galleries/${gid}/summary`)
|
||||
const data = await res.json()
|
||||
const summary = data?.data
|
||||
setTitle(summary?.title || `g/${gid}`)
|
||||
|
||||
if (!summary) { setError("Gallery no encontrada"); setLoading(false); return }
|
||||
|
||||
if (summary.num_pages && summary.num_pages > 0) {
|
||||
const urls: string[] = []
|
||||
for (let i = 0; i < summary.num_pages; i++) {
|
||||
urls.push(`/api/proxy/galleries/${gid}/cover`)
|
||||
}
|
||||
setPageUrls(urls)
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
} catch {}
|
||||
|
||||
try {
|
||||
const artRes = await fetch(`/api/proxy/galleries/${gid}/artifacts`)
|
||||
const artData = await artRes.json()
|
||||
const rendered = artData?.data?.files?.rendered
|
||||
if (rendered?.length) {
|
||||
const urls = rendered.map(() => `/api/proxy/galleries/${gid}/cover`)
|
||||
setPageUrls(urls)
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
} catch {}
|
||||
|
||||
try {
|
||||
const coverRes = await fetch(`/api/proxy/galleries/${gid}/cover`, { method: "HEAD" })
|
||||
if (coverRes.ok) {
|
||||
setPageUrls([`/api/proxy/galleries/${gid}/cover`])
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
} catch {}
|
||||
|
||||
setError("No hay imágenes disponibles. La API del pipeline solo expone la portada.")
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
load()
|
||||
}, [gid])
|
||||
|
||||
const totalPages = pageUrls.length
|
||||
const goTo = useCallback((n: number) => setPage(Math.max(0, Math.min(n, totalPages - 1))), [totalPages])
|
||||
|
||||
useEffect(() => {
|
||||
function onKey(e: KeyboardEvent) {
|
||||
if (e.key === "ArrowLeft") goTo(page - 1)
|
||||
if (e.key === "ArrowRight") goTo(page + 1)
|
||||
if (e.key === "+" || e.key === "=") setZoom((z) => Math.min(z + 0.25, 3))
|
||||
if (e.key === "-") setZoom((z) => Math.max(z - 0.25, 0.25))
|
||||
}
|
||||
window.addEventListener("keydown", onKey)
|
||||
return () => window.removeEventListener("keydown", onKey)
|
||||
}, [page, goTo])
|
||||
|
||||
const handleMouseMove = useCallback(() => {
|
||||
setShowControls(true)
|
||||
if (hideTimer.current) clearTimeout(hideTimer.current)
|
||||
hideTimer.current = setTimeout(() => setShowControls(false), 2000)
|
||||
}, [])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-40 text-[var(--muted)]">
|
||||
<Loader2 className="h-8 w-8 animate-spin" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="mx-auto max-w-lg py-20 text-center">
|
||||
<div className="card p-8">
|
||||
<p className="mb-4 text-sm text-[var(--error)]">{error}</p>
|
||||
<p className="text-xs text-[var(--muted)] mb-4">
|
||||
La API del pipeline solo sirve la página 1 vía <code className="text-[var(--accent)]">/galleries/{"{gid}"}/cover</code>.
|
||||
</p>
|
||||
<a
|
||||
href={`/gallery/${gid}`}
|
||||
className="btn-primary"
|
||||
>
|
||||
Volver al detalle
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex h-[calc(100vh-3rem)] flex-col bg-black/40"
|
||||
onMouseMove={handleMouseMove}
|
||||
>
|
||||
<div
|
||||
className={`flex items-center justify-between border-b border-[var(--border)] bg-[var(--background)]/90 backdrop-blur-sm px-4 py-2 transition-opacity duration-300 ${
|
||||
showControls ? "opacity-100" : "opacity-0 pointer-events-none"
|
||||
}`}
|
||||
>
|
||||
<span className="truncate text-sm text-[var(--muted)]">{title}</span>
|
||||
<div className="flex items-center gap-3">
|
||||
<button onClick={() => setZoom((z) => Math.max(z - 0.25, 0.25))} className="btn-ghost p-1">
|
||||
<ZoomOut className="h-4 w-4" />
|
||||
</button>
|
||||
<span className="w-10 text-center text-xs text-[var(--muted)]">{Math.round(zoom * 100)}%</span>
|
||||
<button onClick={() => setZoom((z) => Math.min(z + 0.25, 3))} className="btn-ghost p-1">
|
||||
<ZoomIn className="h-4 w-4" />
|
||||
</button>
|
||||
{totalPages > 1 && (
|
||||
<span className="text-xs text-[var(--muted)]">{page + 1} / {totalPages}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div ref={containerRef} className="flex flex-1 items-center justify-center overflow-auto">
|
||||
<div className="flex items-center gap-4 px-4">
|
||||
{totalPages > 1 && (
|
||||
<button
|
||||
onClick={() => goTo(page - 1)}
|
||||
disabled={page === 0}
|
||||
className={`rounded-full p-2 text-[var(--muted)] hover:bg-[var(--surface)] transition-all disabled:opacity-20 ${
|
||||
showControls ? "opacity-100" : "opacity-0"
|
||||
}`}
|
||||
>
|
||||
<ChevronLeft className="h-6 w-6" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
<img
|
||||
src={pageUrls[page]}
|
||||
alt={totalPages > 1 ? `Página ${page + 1}` : "Portada"}
|
||||
style={{ transform: `scale(${zoom})` }}
|
||||
className="max-h-[calc(100vh-8rem)] max-w-full origin-center object-contain transition-transform"
|
||||
draggable={false}
|
||||
/>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<button
|
||||
onClick={() => goTo(page + 1)}
|
||||
disabled={page >= totalPages - 1}
|
||||
className={`rounded-full p-2 text-[var(--muted)] hover:bg-[var(--surface)] transition-all disabled:opacity-20 ${
|
||||
showControls ? "opacity-100" : "opacity-0"
|
||||
}`}
|
||||
>
|
||||
<ChevronRight className="h-6 w-6" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{totalPages > 1 && (
|
||||
<div className={`flex justify-center gap-1.5 border-t border-[var(--border)] bg-[var(--background)]/90 backdrop-blur-sm px-4 py-2 transition-opacity duration-300 ${
|
||||
showControls ? "opacity-100" : "opacity-0 pointer-events-none"
|
||||
}`}>
|
||||
{Array.from({ length: Math.min(totalPages, 100) }).map((_, i) => (
|
||||
<button
|
||||
key={i}
|
||||
onClick={() => goTo(i)}
|
||||
className={`h-1.5 rounded-full transition-all ${
|
||||
i === page ? "w-6 bg-[var(--accent)]" : "w-1.5 bg-[var(--border)] hover:bg-[var(--muted)]"
|
||||
}`}
|
||||
/>
|
||||
))}
|
||||
{totalPages > 100 && (
|
||||
<span className="text-[10px] text-[var(--muted)] ml-1">+{totalPages - 100}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{totalPages === 1 && (
|
||||
<div className="border-t border-[var(--border)] bg-[var(--background)]/90 backdrop-blur-sm px-4 py-2 text-center text-xs text-[var(--muted)]">
|
||||
Solo portada disponible. El pipeline solo expone la página 1 vía /cover.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Sidebar } from "@/components/layout/sidebar"
|
||||
|
||||
export default function AppLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex min-h-screen">
|
||||
<Sidebar />
|
||||
<main className="ml-56 flex-1 p-6 lg:p-8">{children}</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import useSWR from "swr"
|
||||
import { RefreshCw, RotateCcw } from "lucide-react"
|
||||
import { FilterBar } from "@/components/filter-bar"
|
||||
import { EmptyState } from "@/components/empty-state"
|
||||
import type { QueueItem, SystemStatus } from "@/lib/types"
|
||||
|
||||
const fetcher = (url: string) => fetch(url).then((r) => r.json())
|
||||
|
||||
function SlotGauge({ label, used, max }: { label: string; used: number; max: number }) {
|
||||
const pct = max > 0 ? (used / max) * 100 : 0
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span className="w-16 text-[var(--muted)]">{label}</span>
|
||||
<div className="h-1.5 flex-1 overflow-hidden rounded-full bg-[var(--border)]">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all duration-300 ${
|
||||
pct >= 100 ? "bg-[var(--error)]" : "bg-[var(--accent)]"
|
||||
}`}
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="w-10 text-right text-[var(--muted)]">
|
||||
{used}/{max}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StatBox({ label, value, color }: { label: string; value: number; color: string }) {
|
||||
return (
|
||||
<div className="card p-4 text-center">
|
||||
<div className="text-2xl font-bold" style={{ color }}>{value}</div>
|
||||
<div className="text-xs text-[var(--muted)] mt-0.5">{label}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function QueuePage() {
|
||||
const [status, setStatus] = useState("")
|
||||
const [retrying, setRetrying] = useState<Set<string>>(new Set())
|
||||
|
||||
const { data: queueData, isLoading, mutate: mutateQueue } = useSWR(
|
||||
`/api/proxy/queue?status=${status}&page=1&per_page=100`,
|
||||
fetcher,
|
||||
{ refreshInterval: 10000 },
|
||||
)
|
||||
|
||||
const { data: statusData, mutate: mutateStatus } = useSWR(
|
||||
"/api/proxy/status",
|
||||
fetcher,
|
||||
{ refreshInterval: 10000 },
|
||||
)
|
||||
|
||||
const items = (queueData?.data || []) as QueueItem[]
|
||||
const sysStatus = statusData?.data as SystemStatus | undefined
|
||||
|
||||
async function handleRetry(gid: string) {
|
||||
setRetrying((prev) => new Set(prev).add(gid))
|
||||
try {
|
||||
await fetch(`/api/proxy/queue/${gid}/retry`, { method: "POST" })
|
||||
mutateQueue()
|
||||
} catch {
|
||||
}
|
||||
setRetrying((prev) => {
|
||||
const next = new Set(prev)
|
||||
next.delete(gid)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const slots = sysStatus?.slots || { download: { used: 0, max: 2 }, translate: { used: 0, max: 2 } }
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6">
|
||||
<h1 className="text-xl font-bold">Cola</h1>
|
||||
<p className="text-sm text-[var(--muted)]">
|
||||
Estado actual del pipeline ({items.length} items)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mb-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<SlotGauge label="Download" {...slots.download} />
|
||||
<SlotGauge label="Translate" {...slots.translate} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{sysStatus && (
|
||||
<div className="mb-4 grid grid-cols-4 gap-3">
|
||||
<StatBox label="Pendientes" value={sysStatus.queue.pending} color="var(--warning)" />
|
||||
<StatBox label="Procesando" value={sysStatus.queue.processing} color="var(--accent)" />
|
||||
<StatBox label="Completadas" value={sysStatus.queue.completed} color="var(--success)" />
|
||||
<StatBox label="Fallidas" value={sysStatus.queue.failed} color="var(--error)" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<FilterBar
|
||||
current={status}
|
||||
onChange={(s) => setStatus(s)}
|
||||
onRefresh={() => { mutateQueue(); mutateStatus() }}
|
||||
loading={isLoading}
|
||||
/>
|
||||
|
||||
{items.length === 0 ? (
|
||||
<EmptyState message="Cola vacía" />
|
||||
) : (
|
||||
<div className="card divide-y divide-[var(--border)] overflow-hidden">
|
||||
{items.map((item) => (
|
||||
<div
|
||||
key={item.gid}
|
||||
className="flex items-center gap-3 px-4 py-2.5 text-sm"
|
||||
>
|
||||
<a
|
||||
href={`/gallery/${item.gid}`}
|
||||
className="min-w-0 flex-1 truncate font-medium hover:text-[var(--accent)] transition-colors"
|
||||
>
|
||||
{item.title || item.gid}
|
||||
</a>
|
||||
|
||||
<span
|
||||
className={`w-20 text-center text-xs font-medium capitalize ${
|
||||
item.status === "completed"
|
||||
? "text-[var(--success)]"
|
||||
: item.status === "failed"
|
||||
? "text-[var(--error)]"
|
||||
: item.status === "processing"
|
||||
? "text-[var(--accent)]"
|
||||
: "text-[var(--warning)]"
|
||||
}`}
|
||||
>
|
||||
{item.status}
|
||||
</span>
|
||||
|
||||
<span className="w-12 text-center text-xs text-[var(--muted)]">
|
||||
{item.priority > 0 ? `P${item.priority}` : "-"}
|
||||
</span>
|
||||
|
||||
<span className="w-32 truncate text-xs text-[var(--muted)]">
|
||||
{item.error || "-"}
|
||||
</span>
|
||||
|
||||
{item.status === "failed" && (
|
||||
<button
|
||||
onClick={() => handleRetry(item.gid)}
|
||||
disabled={retrying.has(item.gid)}
|
||||
className="btn-ghost py-1 px-2 text-xs"
|
||||
>
|
||||
<RotateCcw className={`h-3 w-3 ${retrying.has(item.gid) ? "animate-spin" : ""}`} />
|
||||
Retry
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { SearchForm } from "@/components/search-form"
|
||||
import { Search } from "lucide-react"
|
||||
|
||||
export default function SearchPage() {
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6">
|
||||
<h1 className="text-xl font-bold">Buscar</h1>
|
||||
<p className="text-sm text-[var(--muted)]">Buscá en nhentai / e-hentai y encolá resultados</p>
|
||||
</div>
|
||||
<SearchForm />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { SubmitForm } from "@/components/submit-form"
|
||||
import { Send } from "lucide-react"
|
||||
|
||||
export default function SubmitPage() {
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6">
|
||||
<h1 className="text-xl font-bold">Enviar</h1>
|
||||
<p className="text-sm text-[var(--muted)]">Encolá URLs de nhentai / e-hentai para procesar</p>
|
||||
</div>
|
||||
<SubmitForm />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import Link from "next/link"
|
||||
import { Image } from "lucide-react"
|
||||
|
||||
export default function PublicLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<>
|
||||
<header className="sticky top-0 z-40 border-b border-[var(--border)] bg-[var(--background)]/80 backdrop-blur-lg">
|
||||
<div className="mx-auto flex h-14 max-w-5xl items-center justify-between px-4">
|
||||
<Link href="/" className="flex items-center gap-2.5">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-[var(--accent-subtle)]">
|
||||
<Image className="h-4 w-4 text-[var(--accent)]" />
|
||||
</div>
|
||||
<span className="text-sm font-bold tracking-tight">worst-scan</span>
|
||||
</Link>
|
||||
<nav className="flex items-center gap-4">
|
||||
<Link href="/feed" className="text-xs text-[var(--muted)] hover:text-[var(--foreground)] transition-colors">
|
||||
Admin
|
||||
</Link>
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
<main className="mx-auto max-w-5xl px-4 py-8">{children}</main>
|
||||
<footer className="border-t border-[var(--border)] py-6 text-center text-xs text-[var(--muted)]">
|
||||
worst-scan · traducción automática de manga
|
||||
</footer>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { notFound } from "next/navigation"
|
||||
import Link from "next/link"
|
||||
import { getPostBySlug, getAllPosts } from "@/lib/db"
|
||||
import { PostCard } from "@/components/post-card"
|
||||
import { TagBadge } from "@/components/tag-badge"
|
||||
import { ArrowLeft, BookOpen, ExternalLink } from "lucide-react"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export default async function PostDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ slug: string }>
|
||||
}) {
|
||||
const { slug } = await params
|
||||
const post = getPostBySlug(slug)
|
||||
|
||||
if (!post) notFound()
|
||||
|
||||
const related = getAllPosts(true)
|
||||
.filter((p) => p.id !== post.id)
|
||||
.slice(0, 4)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Link
|
||||
href="/"
|
||||
className="mb-6 inline-flex items-center gap-1.5 text-xs text-[var(--muted)] hover:text-[var(--foreground)] transition-colors"
|
||||
>
|
||||
<ArrowLeft className="h-3.5 w-3.5" />
|
||||
Volver
|
||||
</Link>
|
||||
|
||||
<div className="card overflow-hidden p-0">
|
||||
<div className="flex flex-col gap-0 md:flex-row">
|
||||
<div className="md:w-80 flex-shrink-0">
|
||||
<img
|
||||
src={`/api/cover/${post.gid}`}
|
||||
alt={post.title}
|
||||
className="h-auto w-full object-cover md:h-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-4 p-6">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold leading-tight">{post.title}</h1>
|
||||
{post.title_jpn && (
|
||||
<p className="mt-1 text-sm text-[var(--muted)]">{post.title_jpn}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm">
|
||||
{post.artist && (
|
||||
<TagBadge tag={`artist:${post.artist}`} href={`/tag/${encodeURIComponent(`artist:${post.artist}`)}`} />
|
||||
)}
|
||||
{post.parody && (
|
||||
<TagBadge tag={`parody:${post.parody}`} href={`/tag/${encodeURIComponent(`parody:${post.parody}`)}`} />
|
||||
)}
|
||||
{post.source && (
|
||||
<span className="tag-pill border border-[var(--border)] text-[var(--muted)]">
|
||||
{post.source}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{post.summary && (
|
||||
<div className="text-sm text-[var(--muted)] leading-relaxed whitespace-pre-line">
|
||||
{post.summary}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{post.tags && post.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{post.tags.map((tag: string) => (
|
||||
<TagBadge key={tag} tag={tag} href={`/tag/${encodeURIComponent(tag)}`} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-auto flex items-center gap-2">
|
||||
<Link
|
||||
href={`/gallery/${post.gid}/read`}
|
||||
className="btn-primary"
|
||||
>
|
||||
<BookOpen className="h-4 w-4" />
|
||||
Leer
|
||||
</Link>
|
||||
<a
|
||||
href={`/api/proxy/galleries/${post.gid}/artifacts`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="btn-ghost"
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
Archivos
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{related.length > 0 && (
|
||||
<section className="mt-12">
|
||||
<h2 className="mb-4 text-sm font-semibold text-[var(--muted)] uppercase tracking-wider">
|
||||
Más publicaciones
|
||||
</h2>
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-4">
|
||||
{related.map((p) => (
|
||||
<PostCard key={p.id} post={p} compact />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { getAllPosts } from "@/lib/db"
|
||||
import { PostCard } from "@/components/post-card"
|
||||
import { EmptyState } from "@/components/empty-state"
|
||||
import { BookOpen } from "lucide-react"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export default function PublicHomePage() {
|
||||
const posts = getAllPosts(true)
|
||||
|
||||
if (posts.length === 0) {
|
||||
return (
|
||||
<div className="py-20">
|
||||
<EmptyState message="Todavía no hay publicaciones" icon={<BookOpen className="mb-3 h-10 w-10 text-[var(--muted)]" />} />
|
||||
<p className="mt-4 text-center text-xs text-[var(--muted)]">
|
||||
Las publicaciones aparecen automáticamente cuando el pipeline completa traducciones.
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-8">
|
||||
<h1 className="text-2xl font-bold tracking-tight">Publicaciones</h1>
|
||||
<p className="mt-1 text-sm text-[var(--muted)]">
|
||||
Traducciones automáticas generadas por worst-scan
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 xl:grid-cols-5">
|
||||
{posts.map((post) => (
|
||||
<PostCard key={post.id} post={post} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
import { notFound } from "next/navigation"
|
||||
import Link from "next/link"
|
||||
import { getPostsByTag } from "@/lib/db"
|
||||
import { PostCard } from "@/components/post-card"
|
||||
import { EmptyState } from "@/components/empty-state"
|
||||
import { ArrowLeft, Tag } from "lucide-react"
|
||||
|
||||
export const dynamic = "force-dynamic"
|
||||
|
||||
export default async function TagPage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ tag: string }>
|
||||
}) {
|
||||
const { tag } = await params
|
||||
const decodedTag = decodeURIComponent(tag)
|
||||
const posts = getPostsByTag(decodedTag, true)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Link
|
||||
href="/"
|
||||
className="mb-6 inline-flex items-center gap-1.5 text-xs text-[var(--muted)] hover:text-[var(--foreground)] transition-colors"
|
||||
>
|
||||
<ArrowLeft className="h-3.5 w-3.5" />
|
||||
Todas las publicaciones
|
||||
</Link>
|
||||
|
||||
<div className="mb-8">
|
||||
<div className="flex items-center gap-2">
|
||||
<Tag className="h-4 w-4 text-[var(--accent)]" />
|
||||
<h1 className="text-lg font-bold tracking-tight">{decodedTag}</h1>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-[var(--muted)]">
|
||||
{posts.length} {posts.length === 1 ? "publicación" : "publicaciones"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{posts.length === 0 ? (
|
||||
<EmptyState message="No hay publicaciones con este tag" />
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 md:grid-cols-4 xl:grid-cols-5">
|
||||
{posts.map((post) => (
|
||||
<PostCard key={post.id} post={post} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { cookies } from "next/headers"
|
||||
import { createSession, sessionCookieOptions } from "@/lib/auth"
|
||||
|
||||
export async function POST(req: Request) {
|
||||
const webPassword = process.env.WEB_PASSWORD
|
||||
if (!webPassword) {
|
||||
const cookieStore = await cookies()
|
||||
const opts = sessionCookieOptions()
|
||||
cookieStore.set(opts.name, await createSession(), opts.options)
|
||||
return Response.json({ ok: true })
|
||||
}
|
||||
|
||||
const { password } = await req.json()
|
||||
if (password !== webPassword) {
|
||||
return Response.json({ error: "Invalid password" }, { status: 401 })
|
||||
}
|
||||
|
||||
const cookieStore = await cookies()
|
||||
const opts = sessionCookieOptions()
|
||||
cookieStore.set(opts.name, await createSession(), opts.options)
|
||||
return Response.json({ ok: true })
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { cookies } from "next/headers"
|
||||
import { sessionCookieOptions } from "@/lib/auth"
|
||||
|
||||
export async function POST() {
|
||||
const cookieStore = await cookies()
|
||||
const opts = sessionCookieOptions()
|
||||
cookieStore.delete(opts.name)
|
||||
return Response.json({ ok: true })
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { NextRequest } from "next/server"
|
||||
import { getCoverPath, getCoverContentType, cacheCover } from "@/lib/cover-cache"
|
||||
import fs from "fs"
|
||||
|
||||
export async function GET(
|
||||
_req: NextRequest,
|
||||
{ params }: { params: Promise<{ gid: string }> },
|
||||
) {
|
||||
const { gid } = await params
|
||||
|
||||
let coverPath = getCoverPath(gid)
|
||||
|
||||
if (!coverPath) {
|
||||
try {
|
||||
const proxyUrl = `http://127.0.0.1:${process.env.PORT || 3000}/api/proxy/galleries/${gid}/cover`
|
||||
coverPath = await cacheCover(gid, proxyUrl)
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
if (!coverPath || !fs.existsSync(coverPath)) {
|
||||
return Response.json({ error: "Cover not found" }, { status: 404 })
|
||||
}
|
||||
|
||||
const buffer = fs.readFileSync(coverPath)
|
||||
const contentType = getCoverContentType(gid)
|
||||
|
||||
return new Response(buffer, {
|
||||
headers: {
|
||||
"Content-Type": contentType,
|
||||
"Cache-Control": "public, max-age=86400",
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { pollOnce } from "@/lib/poller"
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const result = await pollOnce()
|
||||
return Response.json({ ok: true, newPosts: result.newPosts })
|
||||
} catch (e) {
|
||||
return Response.json({ ok: false, error: String(e) }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { NextRequest } from "next/server"
|
||||
import { publishPost } from "@/lib/db"
|
||||
|
||||
export async function POST(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
const { id } = await params
|
||||
const body = await req.json().catch(() => ({}))
|
||||
const publish = body.publish !== false
|
||||
const post = publishPost(Number(id), publish)
|
||||
if (!post) return Response.json({ error: "Post not found" }, { status: 404 })
|
||||
return Response.json({ data: post })
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { NextRequest } from "next/server"
|
||||
import { getPostById, updatePost, deletePost } from "@/lib/db"
|
||||
import { deleteCover } from "@/lib/cover-cache"
|
||||
|
||||
export async function GET(
|
||||
_req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
const { id } = await params
|
||||
const post = getPostById(Number(id))
|
||||
if (!post) return Response.json({ error: "Post not found" }, { status: 404 })
|
||||
return Response.json({ data: post })
|
||||
}
|
||||
|
||||
export async function PATCH(
|
||||
req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
const { id } = await params
|
||||
const body = await req.json()
|
||||
const post = updatePost(Number(id), body)
|
||||
if (!post) return Response.json({ error: "Post not found" }, { status: 404 })
|
||||
return Response.json({ data: post })
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
_req: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> },
|
||||
) {
|
||||
const { id } = await params
|
||||
const post = getPostById(Number(id))
|
||||
if (!post) return Response.json({ error: "Post not found" }, { status: 404 })
|
||||
|
||||
deleteCover(post.gid)
|
||||
const deleted = deletePost(Number(id))
|
||||
if (!deleted) return Response.json({ error: "Failed to delete" }, { status: 500 })
|
||||
|
||||
return Response.json({ ok: true }, { status: 200 })
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { NextRequest } from "next/server"
|
||||
import { getAllPosts, createPost, getPostByGid } from "@/lib/db"
|
||||
import { slugify } from "@/lib/slug"
|
||||
|
||||
export async function GET(req: NextRequest) {
|
||||
const publishedOnly = req.nextUrl.searchParams.get("published") === "1"
|
||||
const posts = getAllPosts(publishedOnly)
|
||||
return Response.json({ data: posts })
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const body = await req.json()
|
||||
|
||||
if (!body.gid || !body.title) {
|
||||
return Response.json({ error: "gid and title are required" }, { status: 400 })
|
||||
}
|
||||
|
||||
const existing = getPostByGid(body.gid)
|
||||
if (existing) {
|
||||
return Response.json({ error: "Post already exists", data: existing }, { status: 409 })
|
||||
}
|
||||
|
||||
const slug = body.slug || slugify(body.title, body.gid)
|
||||
|
||||
const post = createPost({
|
||||
gid: body.gid,
|
||||
title: body.title,
|
||||
title_jpn: body.title_jpn,
|
||||
artist: body.artist,
|
||||
parody: body.parody,
|
||||
tags: body.tags,
|
||||
num_pages: body.num_pages,
|
||||
source: body.source,
|
||||
cover_url: body.cover_url,
|
||||
summary: body.summary,
|
||||
slug,
|
||||
})
|
||||
|
||||
return Response.json({ data: post }, { status: 201 })
|
||||
} catch (e) {
|
||||
return Response.json({ error: String(e) }, { status: 500 })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
const API_BASE = process.env.API_BASE_URL || "http://127.0.0.1:8080/api/v1"
|
||||
const API_KEY = process.env.API_KEY || ""
|
||||
|
||||
async function proxy(
|
||||
request: Request,
|
||||
{ params }: { params: Promise<{ path: string[] }> },
|
||||
) {
|
||||
const { path } = await params
|
||||
const subpath = path.join("/")
|
||||
const url = new URL(request.url)
|
||||
const qs = url.search
|
||||
const target = `${API_BASE}/${subpath}${qs}`
|
||||
|
||||
const headers: Record<string, string> = {}
|
||||
if (API_KEY) {
|
||||
headers["X-API-Key"] = API_KEY
|
||||
}
|
||||
|
||||
const body = request.method !== "GET" && request.method !== "HEAD"
|
||||
? await request.blob()
|
||||
: undefined
|
||||
|
||||
if (body && request.headers.get("content-type")) {
|
||||
headers["Content-Type"] = request.headers.get("content-type")!
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(target, {
|
||||
method: request.method,
|
||||
headers,
|
||||
body,
|
||||
})
|
||||
|
||||
const responseHeaders = new Headers()
|
||||
for (const [k, v] of res.headers) {
|
||||
if (!["content-encoding", "content-length", "transfer-encoding"].includes(k.toLowerCase())) {
|
||||
responseHeaders.set(k, v)
|
||||
}
|
||||
}
|
||||
|
||||
return new Response(res.body, {
|
||||
status: res.status,
|
||||
statusText: res.statusText,
|
||||
headers: responseHeaders,
|
||||
})
|
||||
} catch (e) {
|
||||
return Response.json(
|
||||
{ error: { code: "proxy_error", message: String(e) } },
|
||||
{ status: 502 },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export const GET = proxy
|
||||
export const POST = proxy
|
||||
export const PUT = proxy
|
||||
export const DELETE = proxy
|
||||
export const PATCH = proxy
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
@@ -0,0 +1,166 @@
|
||||
@import "tailwindcss" source("../../src");
|
||||
|
||||
@font-face {
|
||||
font-family: "Inter";
|
||||
src: url("https://fonts.gstatic.com/s/inter/v18/UcC73FwrK3iLTeHuS_nVMrMxCp50SjIa2JL7SUc.woff2") format("woff2");
|
||||
font-weight: 100 900;
|
||||
font-display: swap;
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: "Inter Fallback";
|
||||
src: local("system-ui"), local("-apple-system"), local("sans-serif");
|
||||
}
|
||||
|
||||
:root {
|
||||
--background: #09090b;
|
||||
--surface: #18181b;
|
||||
--surface-hover: #1f1f23;
|
||||
--surface-active: #27272a;
|
||||
--border: #27272a;
|
||||
--border-hover: #3f3f46;
|
||||
--foreground: #fafafa;
|
||||
--muted: #a1a1aa;
|
||||
--muted-light: #d4d4d8;
|
||||
--accent: #6366f1;
|
||||
--accent-hover: #4f46e5;
|
||||
--accent-subtle: rgba(99, 102, 241, 0.1);
|
||||
--success: #22c55e;
|
||||
--success-subtle: rgba(34, 197, 94, 0.1);
|
||||
--warning: #eab308;
|
||||
--warning-subtle: rgba(234, 179, 8, 0.1);
|
||||
--error: #ef4444;
|
||||
--error-subtle: rgba(239, 68, 68, 0.1);
|
||||
--radius: 0.5rem;
|
||||
--radius-lg: 0.75rem;
|
||||
}
|
||||
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: #27272a transparent;
|
||||
}
|
||||
|
||||
html {
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: "Inter", "Inter Fallback", sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: var(--accent-subtle);
|
||||
color: var(--foreground);
|
||||
}
|
||||
|
||||
@utility card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-lg);
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
@utility card-hover {
|
||||
&:hover {
|
||||
border-color: var(--border-hover);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
@utility tag-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
border-radius: 9999px;
|
||||
padding: 0.125rem 0.625rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 450;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@utility btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.375rem;
|
||||
border-radius: var(--radius);
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
padding: 0.5rem 0.875rem;
|
||||
transition: all 0.15s;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
}
|
||||
|
||||
@utility btn-primary {
|
||||
@apply btn;
|
||||
background: var(--accent);
|
||||
color: white;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--accent-hover);
|
||||
}
|
||||
}
|
||||
|
||||
@utility btn-ghost {
|
||||
@apply btn;
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
border: 1px solid transparent;
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--surface-hover);
|
||||
color: var(--foreground);
|
||||
}
|
||||
}
|
||||
|
||||
@utility btn-danger {
|
||||
@apply btn;
|
||||
background: transparent;
|
||||
color: var(--error);
|
||||
border: 1px solid var(--error);
|
||||
|
||||
&:hover:not(:disabled) {
|
||||
background: var(--error-subtle);
|
||||
}
|
||||
}
|
||||
|
||||
@utility input {
|
||||
width: 100%;
|
||||
border-radius: var(--radius);
|
||||
border: 1px solid var(--border);
|
||||
background: var(--surface);
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
outline: none;
|
||||
transition: border-color 0.15s;
|
||||
|
||||
&::placeholder {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 1px var(--accent-subtle);
|
||||
}
|
||||
}
|
||||
|
||||
@utility skeleton {
|
||||
background: linear-gradient(90deg, var(--surface) 25%, var(--surface-hover) 50%, var(--surface) 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.5s infinite;
|
||||
border-radius: var(--radius);
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import type { Metadata } from "next"
|
||||
import "./globals.css"
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "worst-scan",
|
||||
description: "Traducción automática de manga — worst-scan fansub",
|
||||
}
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{ children: React.ReactNode }>) {
|
||||
return (
|
||||
<html lang="es">
|
||||
<body className="min-h-screen bg-[var(--background)] text-[var(--foreground)] antialiased">
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
"use client"
|
||||
|
||||
import { useState, FormEvent } from "react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { Image, LogIn } from "lucide-react"
|
||||
|
||||
export default function LoginPage() {
|
||||
const [password, setPassword] = useState("")
|
||||
const [error, setError] = useState("")
|
||||
const [loading, setLoading] = useState(false)
|
||||
const router = useRouter()
|
||||
|
||||
async function handleSubmit(e: FormEvent) {
|
||||
e.preventDefault()
|
||||
setLoading(true)
|
||||
setError("")
|
||||
|
||||
const res = await fetch("/api/auth/login", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ password }),
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const data = await res.json()
|
||||
setError(data.error || "Contraseña incorrecta")
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
router.push("/feed")
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-[var(--background)]">
|
||||
<div className="w-full max-w-sm">
|
||||
<div className="card p-8">
|
||||
<div className="mb-6 flex flex-col items-center text-center">
|
||||
<div className="mb-3 flex h-12 w-12 items-center justify-center rounded-xl bg-[var(--accent-subtle)]">
|
||||
<Image className="h-6 w-6 text-[var(--accent)]" />
|
||||
</div>
|
||||
<h1 className="text-xl font-bold tracking-tight">worst-scan</h1>
|
||||
<p className="mt-1 text-sm text-[var(--muted)]">Panel de control</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<input
|
||||
type="password"
|
||||
placeholder="Contraseña"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="input"
|
||||
autoFocus
|
||||
/>
|
||||
{error && (
|
||||
<p className="text-sm text-[var(--error)] bg-[var(--error-subtle)] rounded-lg px-3 py-2">{error}</p>
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="btn-primary w-full"
|
||||
>
|
||||
<LogIn className="h-4 w-4" />
|
||||
{loading ? "..." : "Entrar"}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<p className="mt-6 text-center text-xs text-[var(--muted)]">
|
||||
worst-scan · traducción automática de manga
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { ImageIcon } from "lucide-react"
|
||||
|
||||
export function CoverImage({
|
||||
gid,
|
||||
coverUrl,
|
||||
alt,
|
||||
className = "",
|
||||
}: {
|
||||
gid: string
|
||||
coverUrl?: string
|
||||
alt: string
|
||||
className?: string
|
||||
}) {
|
||||
const [error, setError] = useState(false)
|
||||
const [loaded, setLoaded] = useState(false)
|
||||
const src = coverUrl || `/api/proxy/galleries/${gid}/cover`
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className={`flex items-center justify-center bg-[var(--surface)] ${className}`}>
|
||||
<ImageIcon className="h-6 w-6 text-[var(--muted)]" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`relative overflow-hidden ${className}`}>
|
||||
{!loaded && <div className="skeleton absolute inset-0" />}
|
||||
<img
|
||||
src={src}
|
||||
alt={alt}
|
||||
className={`h-full w-full object-cover transition-opacity duration-300 ${loaded ? "opacity-100" : "opacity-0"}`}
|
||||
onError={() => setError(true)}
|
||||
onLoad={() => setLoaded(true)}
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Inbox } from "lucide-react"
|
||||
|
||||
export function EmptyState({ message = "No hay nada aquí", icon }: { message?: string; icon?: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center rounded-xl border border-dashed border-[var(--border)] py-20">
|
||||
{icon || <Inbox className="mb-3 h-10 w-10 text-[var(--muted)]" />}
|
||||
<p className="text-sm text-[var(--muted)]">{message}</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
"use client"
|
||||
|
||||
import { RefreshCw } from "lucide-react"
|
||||
|
||||
const statuses = ["completed", "processing", "pending", "failed", ""]
|
||||
|
||||
const labels: Record<string, string> = {
|
||||
"": "Todas",
|
||||
completed: "Completadas",
|
||||
processing: "Activas",
|
||||
pending: "Pendientes",
|
||||
failed: "Fallidas",
|
||||
}
|
||||
|
||||
export function FilterBar({
|
||||
current,
|
||||
onChange,
|
||||
onRefresh,
|
||||
loading,
|
||||
}: {
|
||||
current: string
|
||||
onChange: (s: string) => void
|
||||
onRefresh?: () => void
|
||||
loading?: boolean
|
||||
}) {
|
||||
return (
|
||||
<div className="mb-4 flex items-center gap-1.5">
|
||||
{statuses.map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => onChange(s)}
|
||||
className={`rounded-lg px-3 py-1.5 text-xs font-medium transition-all ${
|
||||
current === s
|
||||
? "bg-[var(--accent)] text-white shadow-sm"
|
||||
: "text-[var(--muted)] hover:bg-[var(--surface)] hover:text-[var(--foreground)] border border-transparent hover:border-[var(--border)]"
|
||||
}`}
|
||||
>
|
||||
{labels[s]}
|
||||
</button>
|
||||
))}
|
||||
<div className="flex-1" />
|
||||
{onRefresh && (
|
||||
<button
|
||||
onClick={onRefresh}
|
||||
disabled={loading}
|
||||
className="btn-ghost p-1.5"
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 ${loading ? "animate-spin" : ""}`} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
"use client"
|
||||
|
||||
import Link from "next/link"
|
||||
import { BookOpen } from "lucide-react"
|
||||
import { CoverImage } from "@/components/cover-image"
|
||||
import type { GalleryItem } from "@/lib/types"
|
||||
|
||||
export function GalleryCard({ item }: { item: GalleryItem }) {
|
||||
return (
|
||||
<div className="card card-hover group flex overflow-hidden p-0">
|
||||
<Link href={`/gallery/${item.gid}`} className="flex flex-1 gap-4">
|
||||
<CoverImage
|
||||
gid={item.gid}
|
||||
alt={item.title}
|
||||
className="h-36 w-24 flex-shrink-0"
|
||||
/>
|
||||
|
||||
<div className="flex flex-1 flex-col gap-1.5 py-3 pr-3">
|
||||
<h3 className="line-clamp-2 text-sm font-semibold leading-snug group-hover:text-[var(--accent)] transition-colors">
|
||||
{item.title}
|
||||
</h3>
|
||||
|
||||
{item.is_spanish && (
|
||||
<span className="tag-pill w-fit bg-emerald-900/30 text-emerald-300 border border-emerald-800/40">
|
||||
ESP
|
||||
</span>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-3 text-xs text-[var(--muted)]">
|
||||
<span>{item.pages} pág</span>
|
||||
<span
|
||||
className={`capitalize ${
|
||||
item.status === "completed"
|
||||
? "text-[var(--success)]"
|
||||
: item.status === "failed"
|
||||
? "text-[var(--error)]"
|
||||
: item.status === "processing"
|
||||
? "text-[var(--accent)]"
|
||||
: "text-[var(--warning)]"
|
||||
}`}
|
||||
>
|
||||
{item.status}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-auto flex items-center gap-2">
|
||||
<Link
|
||||
href={`/gallery/${item.gid}/read`}
|
||||
className="btn-primary text-xs py-1 px-2.5"
|
||||
>
|
||||
<BookOpen className="h-3 w-3" />
|
||||
Leer
|
||||
</Link>
|
||||
{item.status === "failed" && (
|
||||
<span className="text-[10px] text-[var(--error)]">{item.error || "error"}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { GalleryCard } from "@/components/gallery-card"
|
||||
import { EmptyState } from "@/components/empty-state"
|
||||
import type { GalleryItem } from "@/lib/types"
|
||||
|
||||
export function GalleryGrid({ items }: { items: GalleryItem[] }) {
|
||||
if (!items.length) return <EmptyState message="No se encontraron galleries" />
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{items.map((item) => (
|
||||
<GalleryCard key={item.gid} item={item} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
"use client"
|
||||
|
||||
import Link from "next/link"
|
||||
import { usePathname, useRouter } from "next/navigation"
|
||||
import { LayoutDashboard, Image, Search, Send, ListOrdered, FileText, LogOut } from "lucide-react"
|
||||
|
||||
const links = [
|
||||
{ href: "/feed", label: "Feed", icon: LayoutDashboard },
|
||||
{ href: "/submit", label: "Enviar", icon: Send },
|
||||
{ href: "/search", label: "Buscar", icon: Search },
|
||||
{ href: "/queue", label: "Cola", icon: ListOrdered },
|
||||
{ href: "/admin/posts", label: "Posts", icon: FileText },
|
||||
]
|
||||
|
||||
export function Sidebar() {
|
||||
const pathname = usePathname()
|
||||
const router = useRouter()
|
||||
|
||||
async function handleLogout() {
|
||||
await fetch("/api/auth/logout", { method: "POST" })
|
||||
router.push("/login")
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className="fixed left-0 top-0 z-30 flex h-screen w-56 flex-col border-r border-[var(--border)] bg-[var(--background)]">
|
||||
<div className="flex items-center gap-2.5 border-b border-[var(--border)] px-5 py-4">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-[var(--accent-subtle)]">
|
||||
<Image className="h-4 w-4 text-[var(--accent)]" />
|
||||
</div>
|
||||
<span className="text-sm font-bold tracking-tight">worst-scan</span>
|
||||
</div>
|
||||
|
||||
<nav className="flex flex-1 flex-col gap-0.5 p-3">
|
||||
{links.map(({ href, label, icon: Icon }) => {
|
||||
const isActive = pathname.startsWith(href)
|
||||
return (
|
||||
<Link
|
||||
key={href}
|
||||
href={href}
|
||||
className={`flex items-center gap-3 rounded-lg px-3 py-2 text-sm transition-all ${
|
||||
isActive
|
||||
? "bg-[var(--surface)] font-medium text-[var(--foreground)]"
|
||||
: "text-[var(--muted)] hover:bg-[var(--surface)] hover:text-[var(--foreground)]"
|
||||
}`}
|
||||
>
|
||||
<Icon className={`h-4 w-4 ${isActive ? "text-[var(--accent)]" : ""}`} />
|
||||
{label}
|
||||
</Link>
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
|
||||
<div className="border-t border-[var(--border)] p-3">
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="flex w-full items-center gap-3 rounded-lg px-3 py-2 text-sm text-[var(--muted)] transition-all hover:bg-[var(--surface)] hover:text-[var(--foreground)]"
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
Salir
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import Link from "next/link"
|
||||
import type { Post } from "@/lib/db"
|
||||
import { formatDate } from "@/lib/utils"
|
||||
import { TagBadge } from "@/components/tag-badge"
|
||||
|
||||
export function PostCard({ post, compact = false }: { post: Post; compact?: boolean }) {
|
||||
if (compact) {
|
||||
return (
|
||||
<Link
|
||||
href={`/p/${post.slug}`}
|
||||
className="card card-hover group flex gap-4 overflow-hidden p-0"
|
||||
>
|
||||
<img
|
||||
src={`/api/cover/${post.gid}`}
|
||||
alt={post.title}
|
||||
className="h-32 w-24 flex-shrink-0 object-cover"
|
||||
loading="lazy"
|
||||
/>
|
||||
<div className="flex flex-1 flex-col gap-1.5 py-3 pr-4">
|
||||
<h3 className="line-clamp-2 text-sm font-semibold leading-snug group-hover:text-[var(--accent)] transition-colors">
|
||||
{post.title}
|
||||
</h3>
|
||||
{post.artist && (
|
||||
<p className="text-xs text-[var(--muted)]">{post.artist}</p>
|
||||
)}
|
||||
<div className="flex flex-1 items-end gap-2">
|
||||
{post.source && (
|
||||
<span className="text-[10px] uppercase text-[var(--muted)]">{post.source}</span>
|
||||
)}
|
||||
{post.published_at && (
|
||||
<span className="text-[10px] text-[var(--muted)]">{formatDate(post.published_at)}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Link
|
||||
href={`/p/${post.slug}`}
|
||||
className="card card-hover group flex flex-col overflow-hidden p-0"
|
||||
>
|
||||
<div className="aspect-[3/4] overflow-hidden bg-[var(--surface)]">
|
||||
<img
|
||||
src={`/api/cover/${post.gid}`}
|
||||
alt={post.title}
|
||||
className="h-full w-full object-cover transition-transform duration-300 group-hover:scale-[1.03]"
|
||||
loading="lazy"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-1 flex-col gap-2 p-3">
|
||||
<h3 className="line-clamp-2 text-sm font-semibold leading-snug group-hover:text-[var(--accent)] transition-colors">
|
||||
{post.title}
|
||||
</h3>
|
||||
{post.artist && (
|
||||
<p className="text-xs text-[var(--muted)]">{post.artist}</p>
|
||||
)}
|
||||
{post.tags && post.tags.length > 0 && (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{post.tags.slice(0, 3).map((tag) => (
|
||||
<TagBadge key={tag} tag={tag} href={`/tag/${encodeURIComponent(tag)}`} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-auto flex items-center gap-2 text-[10px] text-[var(--muted)]">
|
||||
{post.num_pages > 0 && <span>{post.num_pages} pág</span>}
|
||||
{post.published_at && <span>{formatDate(post.published_at)}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { Search, RotateCcw } from "lucide-react"
|
||||
import { CoverImage } from "@/components/cover-image"
|
||||
import { TagBadge } from "@/components/tag-badge"
|
||||
import type { SearchResult } from "@/lib/types"
|
||||
|
||||
export function SearchForm() {
|
||||
const [query, setQuery] = useState("")
|
||||
const [source, setSource] = useState<"nhentai" | "ehentai">("nhentai")
|
||||
const [results, setResults] = useState<SearchResult[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [meta, setMeta] = useState<{ total: number; blocked: number; passed: number } | null>(null)
|
||||
const [queuing, setQueuing] = useState<Set<string>>(new Set())
|
||||
|
||||
async function handleSearch() {
|
||||
if (!query.trim()) return
|
||||
setLoading(true)
|
||||
setResults([])
|
||||
setMeta(null)
|
||||
|
||||
try {
|
||||
const res = await fetch("/api/proxy/search", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ query: query.trim(), source }),
|
||||
})
|
||||
const data = await res.json()
|
||||
setResults(data.data || [])
|
||||
setMeta(data.meta || null)
|
||||
} catch {
|
||||
setResults([])
|
||||
}
|
||||
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
async function handleQueue(gid: string, url: string) {
|
||||
setQueuing((prev) => new Set(prev).add(gid))
|
||||
try {
|
||||
await fetch("/api/proxy/galleries", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ url }),
|
||||
})
|
||||
} catch {
|
||||
}
|
||||
setQueuing((prev) => {
|
||||
const next = new Set(prev)
|
||||
next.delete(gid)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
async function handleQueueAll() {
|
||||
const valid = results.filter((r) => !r.blocked)
|
||||
for (const r of valid) {
|
||||
await handleQueue(r.gid, r.url)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-[var(--muted)]" />
|
||||
<input
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
|
||||
placeholder="Buscar en nhentai o e-hentai..."
|
||||
className="input pl-9"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
value={source}
|
||||
onChange={(e) => setSource(e.target.value as "nhentai" | "ehentai")}
|
||||
className="input w-32"
|
||||
>
|
||||
<option value="nhentai">nhentai</option>
|
||||
<option value="ehentai">e-hentai</option>
|
||||
</select>
|
||||
<button
|
||||
onClick={handleSearch}
|
||||
disabled={loading || !query.trim()}
|
||||
className="btn-primary"
|
||||
>
|
||||
{loading ? "..." : "Buscar"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{meta && (
|
||||
<div className="flex items-center gap-3 text-sm">
|
||||
<span className="text-[var(--muted)]">{meta.total} resultados</span>
|
||||
{meta.blocked > 0 && (
|
||||
<span className="text-[var(--warning)]">{meta.blocked} bloqueados</span>
|
||||
)}
|
||||
{results.filter((r) => !r.blocked).length > 0 && (
|
||||
<button
|
||||
onClick={handleQueueAll}
|
||||
className="btn-primary ml-auto bg-emerald-600 hover:bg-emerald-500"
|
||||
>
|
||||
Encolar todo ({results.filter((r) => !r.blocked).length})
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{results.length > 0 && (
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{results.map((r) => (
|
||||
<div
|
||||
key={r.gid}
|
||||
className={`card overflow-hidden p-3 ${
|
||||
r.blocked ? "opacity-50 border-red-900/50" : "card-hover"
|
||||
}`}
|
||||
>
|
||||
<div className="flex gap-3">
|
||||
<CoverImage
|
||||
gid={r.gid}
|
||||
alt={r.title}
|
||||
className="h-28 w-20 flex-shrink-0 rounded-lg"
|
||||
/>
|
||||
<div className="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<h3 className="truncate text-sm font-medium">{r.title}</h3>
|
||||
<span className="text-xs text-[var(--muted)]">{r.pages} páginas</span>
|
||||
{r.blocked && (
|
||||
<span className="text-xs text-[var(--error)]">{r.blocked_reason}</span>
|
||||
)}
|
||||
<div className="mt-auto flex flex-wrap gap-1">
|
||||
{r.tags.slice(0, 4).map((t) => (
|
||||
<TagBadge key={t} tag={t} />
|
||||
))}
|
||||
</div>
|
||||
{!r.blocked && (
|
||||
<button
|
||||
onClick={() => handleQueue(r.gid, r.url)}
|
||||
disabled={queuing.has(r.gid)}
|
||||
className="btn-primary mt-1 w-fit text-xs py-1 px-2.5"
|
||||
>
|
||||
{queuing.has(r.gid) ? (
|
||||
<RotateCcw className="h-3 w-3 animate-spin" />
|
||||
) : (
|
||||
"Encolar"
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { Send, CheckCircle, XCircle } from "lucide-react"
|
||||
|
||||
export function SubmitForm() {
|
||||
const [urls, setUrls] = useState("")
|
||||
const [results, setResults] = useState<{ gid: string; status: string; title?: string }[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
|
||||
async function handleSubmit() {
|
||||
setLoading(true)
|
||||
setResults([])
|
||||
|
||||
const lines = urls.split("\n").map((l) => l.trim()).filter(Boolean)
|
||||
const batch: { gid: string; status: string }[] = []
|
||||
|
||||
for (const url of lines) {
|
||||
try {
|
||||
const res = await fetch("/api/proxy/galleries", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ url }),
|
||||
})
|
||||
const data = await res.json()
|
||||
batch.push({
|
||||
gid: data?.data?.gid || "?",
|
||||
status: res.ok ? (data?.data?.status || "accepted") : (data?.error?.message || `HTTP ${res.status}`),
|
||||
})
|
||||
} catch (e) {
|
||||
batch.push({ gid: "?", status: String(e) })
|
||||
}
|
||||
}
|
||||
|
||||
setResults(batch)
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<textarea
|
||||
value={urls}
|
||||
onChange={(e) => setUrls(e.target.value)}
|
||||
placeholder="Pegá URLs de nhentai / e-hentai (una por línea)"
|
||||
rows={6}
|
||||
className="input resize-y min-h-[140px]"
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={loading || !urls.trim()}
|
||||
className="btn-primary"
|
||||
>
|
||||
<Send className="h-4 w-4" />
|
||||
{loading ? "Enviando..." : "Encolar"}
|
||||
</button>
|
||||
{results.length > 0 && (
|
||||
<span className="text-sm text-[var(--muted)]">
|
||||
{results.filter((r) => r.status === "accepted" || r.status !== "?").length} de {results.length} ok
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{results.length > 0 && (
|
||||
<div className="card divide-y divide-[var(--border)] overflow-hidden">
|
||||
{results.map((r, i) => {
|
||||
const ok = r.status === "accepted" || r.gid !== "?"
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className={`flex items-center gap-3 px-4 py-2.5 text-sm ${
|
||||
ok ? "" : "bg-[var(--error-subtle)]"
|
||||
}`}
|
||||
>
|
||||
{ok ? (
|
||||
<CheckCircle className="h-4 w-4 text-[var(--success)] flex-shrink-0" />
|
||||
) : (
|
||||
<XCircle className="h-4 w-4 text-[var(--error)] flex-shrink-0" />
|
||||
)}
|
||||
<span className="font-mono text-xs text-[var(--muted)]">{r.gid}</span>
|
||||
<span className={ok ? "text-[var(--muted)]" : "text-[var(--error)]"}>{r.status}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import Link from "next/link"
|
||||
|
||||
const colors: Record<string, string> = {
|
||||
artist: "bg-rose-900/30 text-rose-300 border-rose-800/40",
|
||||
parody: "bg-violet-900/30 text-violet-300 border-violet-800/40",
|
||||
language: "bg-emerald-900/30 text-emerald-300 border-emerald-800/40",
|
||||
category: "bg-amber-900/30 text-amber-300 border-amber-800/40",
|
||||
character: "bg-cyan-900/30 text-cyan-300 border-cyan-800/40",
|
||||
group: "bg-orange-900/30 text-orange-300 border-orange-800/40",
|
||||
tag: "bg-zinc-800 text-zinc-400 border-zinc-700/40",
|
||||
}
|
||||
|
||||
function tagColor(tag: string): string {
|
||||
const prefix = tag.split(":")[0]
|
||||
return colors[prefix] || colors.tag
|
||||
}
|
||||
|
||||
export function TagBadge({ tag, href }: { tag: string; href?: string }) {
|
||||
const cls = `tag-pill border ${tagColor(tag)}`
|
||||
|
||||
if (href) {
|
||||
return (
|
||||
<Link href={href} className={`${cls} hover:brightness-125 transition-all`}>
|
||||
{tag}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
return <span className={cls}>{tag}</span>
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
"use client"
|
||||
|
||||
import useSWR from "swr"
|
||||
import type { GalleryItem } from "@/lib/types"
|
||||
|
||||
const fetcher = (url: string) => fetch(url).then((r) => r.json())
|
||||
|
||||
export function useGalleries(status?: string, page = 1) {
|
||||
const params = new URLSearchParams({ page: String(page), per_page: "50" })
|
||||
if (status) params.set("status", status)
|
||||
|
||||
const { data, error, isLoading, mutate } = useSWR(
|
||||
`/api/proxy/galleries?${params}`,
|
||||
fetcher,
|
||||
{ refreshInterval: status === "completed" ? 30000 : 10000 },
|
||||
)
|
||||
|
||||
return {
|
||||
galleries: (data?.data || []) as GalleryItem[],
|
||||
meta: data?.meta as { total: number; page: number; per_page: number } | undefined,
|
||||
isLoading,
|
||||
isError: !!error,
|
||||
mutate,
|
||||
}
|
||||
}
|
||||
|
||||
export function useGallery(gid: string) {
|
||||
const { data, error, isLoading } = useSWR(
|
||||
gid ? `/api/proxy/galleries/${gid}/summary` : null,
|
||||
fetcher,
|
||||
{ refreshInterval: 5000 },
|
||||
)
|
||||
|
||||
return {
|
||||
summary: data?.data as import("@/lib/types").GallerySummary | undefined,
|
||||
isLoading,
|
||||
isError: !!error,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
"use client"
|
||||
|
||||
import useSWR from "swr"
|
||||
|
||||
const fetcher = (url: string) => fetch(url).then((r) => r.json())
|
||||
|
||||
export function useQueue(status?: string, page = 1) {
|
||||
const params = new URLSearchParams({ page: String(page), per_page: "50" })
|
||||
if (status) params.set("status", status)
|
||||
|
||||
const { data, error, isLoading, mutate } = useSWR(
|
||||
`/api/proxy/queue?${params}`,
|
||||
fetcher,
|
||||
{ refreshInterval: 10000 },
|
||||
)
|
||||
|
||||
const items = (data?.data || []) as import("@/lib/types").QueueItem[]
|
||||
const meta = data?.meta as { total: number; page: number; per_page: number } | undefined
|
||||
|
||||
return { items, meta, isLoading, isError: !!error, mutate }
|
||||
}
|
||||
|
||||
export function useQueueStats() {
|
||||
const { data, error, isLoading, mutate } = useSWR(
|
||||
"/api/proxy/queue/stats",
|
||||
fetcher,
|
||||
{ refreshInterval: 10000 },
|
||||
)
|
||||
|
||||
return {
|
||||
stats: data?.data as import("@/lib/types").QueueStats | undefined,
|
||||
isLoading,
|
||||
isError: !!error,
|
||||
mutate,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
"use client"
|
||||
|
||||
import useSWR from "swr"
|
||||
|
||||
const fetcher = (url: string) => fetch(url).then((r) => r.json())
|
||||
|
||||
export function useSystemStatus() {
|
||||
const { data, error, isLoading, mutate } = useSWR(
|
||||
"/api/proxy/status",
|
||||
fetcher,
|
||||
{ refreshInterval: 10000 },
|
||||
)
|
||||
|
||||
return {
|
||||
status: data?.data as import("@/lib/types").SystemStatus | undefined,
|
||||
isLoading,
|
||||
isError: !!error,
|
||||
mutate,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export async function register() {
|
||||
if (process.env.NEXT_RUNTIME === "nodejs") {
|
||||
const { startPoller } = await import("./lib/poller")
|
||||
startPoller()
|
||||
}
|
||||
}
|
||||
+143
@@ -0,0 +1,143 @@
|
||||
import type {
|
||||
ApiResponse,
|
||||
GalleryArtifacts,
|
||||
GalleryDetail,
|
||||
GalleryItem,
|
||||
GallerySummary,
|
||||
PaginatedResponse,
|
||||
QueueItem,
|
||||
QueueStats,
|
||||
SearchResult,
|
||||
SystemStatus,
|
||||
} from "./types"
|
||||
|
||||
const API_BASE = process.env.API_BASE_URL || "http://127.0.0.1:8080/api/v1"
|
||||
const API_KEY = process.env.API_KEY || ""
|
||||
|
||||
async function fetchApi<T>(
|
||||
path: string,
|
||||
init?: RequestInit,
|
||||
): Promise<T> {
|
||||
const url = `${API_BASE}${path}`
|
||||
const headers: Record<string, string> = {
|
||||
...(init?.headers as Record<string, string>),
|
||||
}
|
||||
if (API_KEY) {
|
||||
headers["X-API-Key"] = API_KEY
|
||||
}
|
||||
if (init?.body && typeof init.body === "string" && !(headers["Content-Type"])) {
|
||||
headers["Content-Type"] = "application/json"
|
||||
}
|
||||
|
||||
const res = await fetch(url, { ...init, headers })
|
||||
|
||||
if (!res.ok) {
|
||||
if (res.status === 404) throw new Error("Not found")
|
||||
if (res.status === 429) throw new Error("Rate limited")
|
||||
if (res.status === 409) throw new Error("Already processing")
|
||||
const body = await res.json().catch(() => ({}))
|
||||
throw new Error(body?.error?.message || `HTTP ${res.status}`)
|
||||
}
|
||||
|
||||
if (res.status === 204) return undefined as T
|
||||
|
||||
return res.json()
|
||||
}
|
||||
|
||||
export const api = {
|
||||
health: () =>
|
||||
fetchApi<ApiResponse<{ status: string; version: string; uptime_s: number }>>("/health"),
|
||||
|
||||
status: () =>
|
||||
fetchApi<ApiResponse<SystemStatus>>("/status"),
|
||||
|
||||
galleries: {
|
||||
list: (status?: string, page = 1, perPage = 20) => {
|
||||
const params = new URLSearchParams({ page: String(page), per_page: String(perPage) })
|
||||
if (status) params.set("status", status)
|
||||
return fetchApi<PaginatedResponse<GalleryItem>>(`/galleries?${params}`)
|
||||
},
|
||||
|
||||
get: (gid: string) =>
|
||||
fetchApi<ApiResponse<GalleryDetail>>(`/galleries/${gid}`),
|
||||
|
||||
summary: (gid: string) =>
|
||||
fetchApi<ApiResponse<GallerySummary>>(`/galleries/${gid}/summary`),
|
||||
|
||||
artifacts: (gid: string) =>
|
||||
fetchApi<ApiResponse<GalleryArtifacts>>(`/galleries/${gid}/artifacts`),
|
||||
|
||||
submit: (url: string, opts?: { skipTranslate?: boolean; skipMobi?: boolean; skipEsSearch?: boolean }) =>
|
||||
fetchApi<ApiResponse<{ gid: string; status: string; message: string }>>("/galleries", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
url,
|
||||
skip_translate: opts?.skipTranslate ?? false,
|
||||
skip_mobi: opts?.skipMobi ?? false,
|
||||
skip_es_search: opts?.skipEsSearch ?? false,
|
||||
}),
|
||||
}),
|
||||
|
||||
download: (url: string, opts?: { skipEsSearch?: boolean }) =>
|
||||
fetchApi<ApiResponse<{ gid: string; status: string }>>("/galleries/download", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
url,
|
||||
skip_es_search: opts?.skipEsSearch ?? false,
|
||||
}),
|
||||
}),
|
||||
|
||||
delete: (gid: string) =>
|
||||
fetchApi<void>(`/galleries/${gid}`, { method: "DELETE" }),
|
||||
},
|
||||
|
||||
search: {
|
||||
query: (query: string, source: "nhentai" | "ehentai" = "nhentai", filter = true, maxPages = 1) =>
|
||||
fetchApi<{ data: SearchResult[]; meta: { total: number; blocked: number; passed: number } }>("/search", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ query, source, filter, max_pages: maxPages }),
|
||||
}),
|
||||
|
||||
process: (
|
||||
query: string,
|
||||
source: "nhentai" | "ehentai" = "nhentai",
|
||||
opts?: { skipTranslate?: boolean; skipMobi?: boolean; maxGalleries?: number }
|
||||
) =>
|
||||
fetchApi<ApiResponse<{
|
||||
search_id: string
|
||||
total_found: number
|
||||
blocked: number
|
||||
duplicate: number
|
||||
queued: number
|
||||
galleries: { gid: string; status: string }[]
|
||||
}>>("/search/process", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({
|
||||
query,
|
||||
source,
|
||||
skip_translate: opts?.skipTranslate ?? false,
|
||||
skip_mobi: opts?.skipMobi ?? false,
|
||||
max_galleries: opts?.maxGalleries ?? 25,
|
||||
}),
|
||||
}),
|
||||
},
|
||||
|
||||
queue: {
|
||||
list: (status?: string, page = 1, perPage = 20) => {
|
||||
const params = new URLSearchParams({ page: String(page), per_page: String(perPage) })
|
||||
if (status) params.set("status", status)
|
||||
return fetchApi<PaginatedResponse<QueueItem>>(`/queue?${params}`)
|
||||
},
|
||||
|
||||
stats: () =>
|
||||
fetchApi<ApiResponse<QueueStats>>("/queue/stats"),
|
||||
|
||||
retry: (gid: string) =>
|
||||
fetchApi<ApiResponse<{ gid: string; status: string; message: string }>>(`/queue/${gid}/retry`, {
|
||||
method: "POST",
|
||||
}),
|
||||
|
||||
wipe: () =>
|
||||
fetchApi<void>("/queue", { method: "DELETE" }),
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { SignJWT, jwtVerify } from "jose"
|
||||
|
||||
const SECRET = new TextEncoder().encode(
|
||||
process.env.JWT_SECRET || process.env.WEB_PASSWORD || "worst-scan-web-dev-secret",
|
||||
)
|
||||
|
||||
const COOKIE_NAME = "session"
|
||||
|
||||
export interface SessionPayload {
|
||||
authenticated: boolean
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
export async function createSession(): Promise<string> {
|
||||
return new SignJWT({ authenticated: true, timestamp: Date.now() })
|
||||
.setProtectedHeader({ alg: "HS256" })
|
||||
.setExpirationTime("7d")
|
||||
.sign(SECRET)
|
||||
}
|
||||
|
||||
export async function verifySession(token: string): Promise<SessionPayload | null> {
|
||||
try {
|
||||
const { payload } = await jwtVerify(token, SECRET)
|
||||
return payload as unknown as SessionPayload
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function sessionCookieOptions(): { name: string; options: { httpOnly: boolean; secure: boolean; sameSite: "lax"; path: string; maxAge: number } } {
|
||||
return {
|
||||
name: COOKIE_NAME,
|
||||
options: {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
sameSite: "lax",
|
||||
path: "/",
|
||||
maxAge: 60 * 60 * 24 * 7,
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import JSZip from "jszip"
|
||||
|
||||
const imageExts = new Set([".jpg", ".jpeg", ".png", ".webp", ".gif", ".bmp"])
|
||||
|
||||
export async function loadPagesFromCbz(
|
||||
url: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<{ pages: string[]; pageCount: number }> {
|
||||
const response = await fetch(url, { signal })
|
||||
if (!response.ok) throw new Error(`Failed to fetch CBZ: ${response.status}`)
|
||||
|
||||
const blob = await response.blob()
|
||||
const zip = await JSZip.loadAsync(blob)
|
||||
|
||||
const imageEntries = Object.entries(zip.files)
|
||||
.filter(([name, file]) => {
|
||||
const ext = name.toLowerCase().slice(name.lastIndexOf("."))
|
||||
return !file.dir && imageExts.has(ext)
|
||||
})
|
||||
.sort(([a], [b]) => {
|
||||
const numA = parseInt(a.match(/(\d+)/)?.[1] || "0", 10)
|
||||
const numB = parseInt(b.match(/(\d+)/)?.[1] || "0", 10)
|
||||
return numA - numB
|
||||
})
|
||||
|
||||
const pageCount = imageEntries.length
|
||||
const pages: string[] = []
|
||||
|
||||
for (const [, file] of imageEntries) {
|
||||
const blob = await file.async("blob")
|
||||
pages.push(URL.createObjectURL(blob))
|
||||
}
|
||||
|
||||
return { pages, pageCount }
|
||||
}
|
||||
|
||||
export function revokePageUrls(urls: string[]) {
|
||||
for (const url of urls) {
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import fs from "fs"
|
||||
import path from "path"
|
||||
|
||||
const COVERS_DIR = process.env.COVERS_DIR || path.join(process.cwd(), "data", "covers")
|
||||
|
||||
function ensureDir() {
|
||||
fs.mkdirSync(COVERS_DIR, { recursive: true })
|
||||
}
|
||||
|
||||
function extFromContentType(ct: string | null): string {
|
||||
if (!ct) return ".jpg"
|
||||
const m = ct.match(/image\/(\w+)/)
|
||||
if (!m) return ".jpg"
|
||||
const exts: Record<string, string> = {
|
||||
jpeg: ".jpg",
|
||||
png: ".png",
|
||||
webp: ".webp",
|
||||
gif: ".gif",
|
||||
}
|
||||
return exts[m[1]] || ".jpg"
|
||||
}
|
||||
|
||||
export async function cacheCover(gid: string, proxyUrl: string): Promise<string | null> {
|
||||
ensureDir()
|
||||
try {
|
||||
const res = await fetch(proxyUrl, { signal: AbortSignal.timeout(15000) })
|
||||
if (!res.ok) return null
|
||||
|
||||
const buffer = Buffer.from(await res.arrayBuffer())
|
||||
const ext = extFromContentType(res.headers.get("content-type"))
|
||||
const filePath = path.join(COVERS_DIR, `${gid}${ext}`)
|
||||
|
||||
fs.writeFileSync(filePath, buffer)
|
||||
return filePath
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function getCoverPath(gid: string): string | null {
|
||||
ensureDir()
|
||||
const files = fs.readdirSync(COVERS_DIR).filter((f) => f.startsWith(gid))
|
||||
if (files.length === 0) return null
|
||||
return path.join(COVERS_DIR, files[0])
|
||||
}
|
||||
|
||||
export function getCoverContentType(gid: string): string {
|
||||
const fp = getCoverPath(gid)
|
||||
if (!fp) return "image/jpeg"
|
||||
const ext = path.extname(fp).toLowerCase()
|
||||
const m: Record<string, string> = {
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".png": "image/png",
|
||||
".webp": "image/webp",
|
||||
".gif": "image/gif",
|
||||
}
|
||||
return m[ext] || "image/jpeg"
|
||||
}
|
||||
|
||||
export function deleteCover(gid: string): void {
|
||||
const fp = getCoverPath(gid)
|
||||
if (fp) fs.unlinkSync(fp)
|
||||
}
|
||||
+196
@@ -0,0 +1,196 @@
|
||||
import Database from "better-sqlite3"
|
||||
import path from "path"
|
||||
import fs from "fs"
|
||||
|
||||
const DB_PATH = process.env.DB_PATH || path.join(process.cwd(), "data", "worst-scan.db")
|
||||
|
||||
let db: Database.Database | null = null
|
||||
|
||||
function getDb(): Database.Database {
|
||||
if (!db) {
|
||||
fs.mkdirSync(path.dirname(DB_PATH), { recursive: true })
|
||||
db = new Database(DB_PATH)
|
||||
db.pragma("journal_mode = WAL")
|
||||
db.pragma("foreign_keys = ON")
|
||||
migrate(db)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
function migrate(db: Database.Database) {
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS posts (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
gid TEXT NOT NULL UNIQUE,
|
||||
title TEXT NOT NULL,
|
||||
title_jpn TEXT,
|
||||
artist TEXT,
|
||||
parody TEXT,
|
||||
tags TEXT,
|
||||
num_pages INTEGER DEFAULT 0,
|
||||
source TEXT,
|
||||
cover_url TEXT,
|
||||
summary TEXT,
|
||||
slug TEXT UNIQUE NOT NULL,
|
||||
published INTEGER DEFAULT 0,
|
||||
created_at TEXT DEFAULT (datetime('now')),
|
||||
updated_at TEXT DEFAULT (datetime('now')),
|
||||
published_at TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_posts_gid ON posts(gid);
|
||||
CREATE INDEX IF NOT EXISTS idx_posts_slug ON posts(slug);
|
||||
CREATE INDEX IF NOT EXISTS idx_posts_published ON posts(published);
|
||||
CREATE INDEX IF NOT EXISTS idx_posts_created ON posts(created_at DESC);
|
||||
`)
|
||||
}
|
||||
|
||||
export interface Post {
|
||||
id: number
|
||||
gid: string
|
||||
title: string
|
||||
title_jpn: string | null
|
||||
artist: string | null
|
||||
parody: string | null
|
||||
tags: string[]
|
||||
num_pages: number
|
||||
source: string | null
|
||||
cover_url: string | null
|
||||
summary: string | null
|
||||
slug: string
|
||||
published: number
|
||||
created_at: string
|
||||
updated_at: string
|
||||
published_at: string | null
|
||||
}
|
||||
|
||||
export interface PostInput {
|
||||
gid: string
|
||||
title: string
|
||||
title_jpn?: string
|
||||
artist?: string
|
||||
parody?: string
|
||||
tags?: string[]
|
||||
num_pages?: number
|
||||
source?: string
|
||||
cover_url?: string
|
||||
summary?: string
|
||||
slug: string
|
||||
published?: number
|
||||
}
|
||||
|
||||
function rowToPost(row: Record<string, unknown>): Post {
|
||||
return {
|
||||
...row,
|
||||
tags: typeof row.tags === "string" ? JSON.parse(row.tags as string) : [],
|
||||
} as unknown as Post
|
||||
}
|
||||
|
||||
export function getAllPosts(publishedOnly = false): Post[] {
|
||||
const d = getDb()
|
||||
const q = publishedOnly
|
||||
? "SELECT * FROM posts WHERE published = 1 ORDER BY published_at DESC"
|
||||
: "SELECT * FROM posts ORDER BY created_at DESC"
|
||||
return (d.prepare(q).all() as Record<string, unknown>[]).map(rowToPost)
|
||||
}
|
||||
|
||||
export function getPostById(id: number): Post | null {
|
||||
const d = getDb()
|
||||
const row = d.prepare("SELECT * FROM posts WHERE id = ?").get(id) as Record<string, unknown> | undefined
|
||||
return row ? rowToPost(row) : null
|
||||
}
|
||||
|
||||
export function getPostByGid(gid: string): Post | null {
|
||||
const d = getDb()
|
||||
const row = d.prepare("SELECT * FROM posts WHERE gid = ?").get(gid) as Record<string, unknown> | undefined
|
||||
return row ? rowToPost(row) : null
|
||||
}
|
||||
|
||||
export function getPostBySlug(slug: string): Post | null {
|
||||
const d = getDb()
|
||||
const row = d.prepare("SELECT * FROM posts WHERE slug = ?").get(slug) as Record<string, unknown> | undefined
|
||||
return row ? rowToPost(row) : null
|
||||
}
|
||||
|
||||
export function createPost(input: PostInput): Post {
|
||||
const d = getDb()
|
||||
const published = input.published ?? 1
|
||||
const publishedAt = published ? "datetime('now')" : null
|
||||
const stmt = d.prepare(`
|
||||
INSERT INTO posts (gid, title, title_jpn, artist, parody, tags, num_pages, source, cover_url, summary, slug, published, published_at)
|
||||
VALUES (@gid, @title, @title_jpn, @artist, @parody, @tags, @num_pages, @source, @cover_url, @summary, @slug, @published, ${publishedAt})
|
||||
`)
|
||||
const result = stmt.run({
|
||||
gid: input.gid,
|
||||
title: input.title,
|
||||
title_jpn: input.title_jpn || null,
|
||||
artist: input.artist || null,
|
||||
parody: input.parody || null,
|
||||
tags: JSON.stringify(input.tags || []),
|
||||
num_pages: input.num_pages || 0,
|
||||
source: input.source || null,
|
||||
cover_url: input.cover_url || null,
|
||||
summary: input.summary || null,
|
||||
slug: input.slug,
|
||||
published,
|
||||
})
|
||||
return getPostById(result.lastInsertRowid as number)!
|
||||
}
|
||||
|
||||
export function updatePost(id: number, updates: Partial<PostInput & { published: number }>): Post | null {
|
||||
const d = getDb()
|
||||
const fields: string[] = []
|
||||
const values: Record<string, unknown> = { id }
|
||||
|
||||
for (const [k, v] of Object.entries(updates)) {
|
||||
if (v !== undefined) {
|
||||
if (k === "tags") {
|
||||
fields.push("tags = @tags")
|
||||
values.tags = JSON.stringify(v)
|
||||
} else {
|
||||
fields.push(`${k} = @${k}`)
|
||||
values[k] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (fields.length === 0) return getPostById(id)
|
||||
|
||||
fields.push("updated_at = datetime('now')")
|
||||
d.prepare(`UPDATE posts SET ${fields.join(", ")} WHERE id = ?`).run(id)
|
||||
return getPostById(id)
|
||||
}
|
||||
|
||||
export function publishPost(id: number, publish: boolean): Post | null {
|
||||
const d = getDb()
|
||||
if (publish) {
|
||||
d.prepare("UPDATE posts SET published = 1, published_at = datetime('now'), updated_at = datetime('now') WHERE id = ?").run(id)
|
||||
} else {
|
||||
d.prepare("UPDATE posts SET published = 0, published_at = NULL, updated_at = datetime('now') WHERE id = ?").run(id)
|
||||
}
|
||||
return getPostById(id)
|
||||
}
|
||||
|
||||
export function deletePost(id: number): boolean {
|
||||
const d = getDb()
|
||||
const result = d.prepare("DELETE FROM posts WHERE id = ?").run(id)
|
||||
return result.changes > 0
|
||||
}
|
||||
|
||||
export function getPostsByTag(tag: string, publishedOnly = true): Post[] {
|
||||
const d = getDb()
|
||||
const q = publishedOnly
|
||||
? "SELECT * FROM posts WHERE published = 1 AND EXISTS (SELECT 1 FROM json_each(posts.tags) WHERE json_each.value = ?) ORDER BY published_at DESC"
|
||||
: "SELECT * FROM posts WHERE EXISTS (SELECT 1 FROM json_each(posts.tags) WHERE json_each.value = ?) ORDER BY created_at DESC"
|
||||
return (d.prepare(q).all(tag) as Record<string, unknown>[]).map(rowToPost)
|
||||
}
|
||||
|
||||
export function getPostCount(): number {
|
||||
const d = getDb()
|
||||
const row = d.prepare("SELECT COUNT(*) as count FROM posts").get() as { count: number }
|
||||
return row.count
|
||||
}
|
||||
|
||||
export function getGidsNotInPosts(): string[] {
|
||||
return []
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import "server-only"
|
||||
import { api } from "./api"
|
||||
import { slugify } from "./slug"
|
||||
import { createPost, getPostByGid } from "./db"
|
||||
import { cacheCover } from "./cover-cache"
|
||||
|
||||
let intervalId: ReturnType<typeof setInterval> | null = null
|
||||
const POLL_INTERVAL_MS = 60_000
|
||||
|
||||
export function startPoller() {
|
||||
if (intervalId) return
|
||||
pollOnce()
|
||||
intervalId = setInterval(pollOnce, POLL_INTERVAL_MS)
|
||||
}
|
||||
|
||||
export function stopPoller() {
|
||||
if (intervalId) {
|
||||
clearInterval(intervalId)
|
||||
intervalId = null
|
||||
}
|
||||
}
|
||||
|
||||
export async function pollOnce(): Promise<{ newPosts: number }> {
|
||||
let newPosts = 0
|
||||
|
||||
try {
|
||||
const res = await api.galleries.list("completed", 1, 100)
|
||||
const galleries = res.data || []
|
||||
|
||||
for (const g of galleries) {
|
||||
try {
|
||||
const existing = getPostByGid(g.gid)
|
||||
if (existing) continue
|
||||
|
||||
const sumRes = await api.galleries.summary(g.gid)
|
||||
const summary = sumRes?.data
|
||||
if (!summary) continue
|
||||
|
||||
const slug = slugify(summary.title, g.gid)
|
||||
|
||||
await createPost({
|
||||
gid: g.gid,
|
||||
title: summary.title,
|
||||
title_jpn: summary.title_jpn,
|
||||
artist: summary.artist,
|
||||
parody: summary.parody,
|
||||
tags: summary.tags,
|
||||
num_pages: summary.num_pages,
|
||||
source: summary.source,
|
||||
cover_url: summary.cover?.external_url,
|
||||
summary: generateSummary(summary),
|
||||
slug,
|
||||
published: 1,
|
||||
})
|
||||
|
||||
await cacheCover(g.gid, `/api/proxy/galleries/${g.gid}/cover`)
|
||||
|
||||
newPosts++
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
|
||||
return { newPosts }
|
||||
}
|
||||
|
||||
function generateSummary(summary: {
|
||||
title: string
|
||||
title_jpn?: string
|
||||
artist?: string
|
||||
parody?: string
|
||||
tags?: string[]
|
||||
num_pages?: number
|
||||
source?: string
|
||||
}): string {
|
||||
const parts: string[] = []
|
||||
|
||||
if (summary.title) parts.push(`**${summary.title}**`)
|
||||
if (summary.title_jpn) parts.push(summary.title_jpn)
|
||||
if (summary.artist) parts.push(`Artista: ${summary.artist}`)
|
||||
if (summary.parody) parts.push(`Franquicia: ${summary.parody}`)
|
||||
if (summary.num_pages) parts.push(`${summary.num_pages} páginas`)
|
||||
if (summary.source) parts.push(`Fuente: ${summary.source}`)
|
||||
|
||||
return parts.join("\n\n") || "Sin resumen disponible."
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
export function slugify(text: string, gid?: string): string {
|
||||
let slug = text
|
||||
.toLowerCase()
|
||||
.replace(/\[.*?\]/g, "")
|
||||
.replace(/\(.*?\)/g, "")
|
||||
.replace(/[^a-z0-9\s-]/g, "")
|
||||
.trim()
|
||||
.replace(/\s+/g, "-")
|
||||
.replace(/-+/g, "-")
|
||||
.replace(/^-|-$/g, "")
|
||||
.slice(0, 80)
|
||||
|
||||
if (!slug) {
|
||||
slug = `gallery`
|
||||
}
|
||||
|
||||
if (gid) {
|
||||
slug = `${slug}-${gid.slice(0, 8)}`
|
||||
}
|
||||
|
||||
return slug
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
export interface GalleryItem {
|
||||
gid: string
|
||||
title: string
|
||||
url: string
|
||||
status: string
|
||||
phase: string
|
||||
done: number
|
||||
total: number
|
||||
is_spanish: boolean
|
||||
pages: number
|
||||
priority: number
|
||||
added_at: string
|
||||
retry_count: number
|
||||
error: string
|
||||
}
|
||||
|
||||
export interface GallerySummary {
|
||||
gid: string
|
||||
title: string
|
||||
title_jpn: string
|
||||
num_pages: number
|
||||
is_spanish: boolean
|
||||
url: string
|
||||
source: string
|
||||
status: string
|
||||
phase: string
|
||||
tags: string[]
|
||||
artist: string
|
||||
parody: string
|
||||
cover: {
|
||||
local_endpoint: string
|
||||
local_available: boolean
|
||||
external_url?: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface GalleryDetail {
|
||||
gid: string
|
||||
title: string
|
||||
url: string
|
||||
status: string
|
||||
phase: string
|
||||
done: number
|
||||
total: number
|
||||
is_spanish: boolean
|
||||
pages: number
|
||||
work_dir: string | null
|
||||
has_cbz: boolean
|
||||
cbz_path: string | null
|
||||
queued_at: string | null
|
||||
error: string | null
|
||||
}
|
||||
|
||||
export interface GalleryArtifacts {
|
||||
gid: string
|
||||
artifacts_dir: string
|
||||
files: {
|
||||
source_cbz: string
|
||||
originals: string[]
|
||||
images: string[]
|
||||
masks: string[]
|
||||
regions: string[]
|
||||
inpainted: string[]
|
||||
rendered: string[]
|
||||
}
|
||||
file_count: number
|
||||
total_size_mb: number
|
||||
}
|
||||
|
||||
export interface SlotInfo {
|
||||
max: number
|
||||
used: number
|
||||
free: number
|
||||
}
|
||||
|
||||
export interface SystemStatus {
|
||||
active_galleries: {
|
||||
gid: string
|
||||
phase: string
|
||||
done: number
|
||||
total: number
|
||||
title: string
|
||||
}[]
|
||||
active_count: number
|
||||
stats: {
|
||||
completed: number
|
||||
failed: number
|
||||
active: number
|
||||
}
|
||||
slots: {
|
||||
download: SlotInfo
|
||||
translate: SlotInfo
|
||||
}
|
||||
queue: {
|
||||
pending: number
|
||||
processing: number
|
||||
completed: number
|
||||
failed: number
|
||||
}
|
||||
failed_galleries: {
|
||||
url: string
|
||||
gid: string
|
||||
error: string
|
||||
timestamp: string
|
||||
}[]
|
||||
resources: {
|
||||
rss_mb: number
|
||||
cpu_load: number[]
|
||||
cpu_count: number
|
||||
}
|
||||
model: {
|
||||
current: string
|
||||
score: number
|
||||
proxy_health: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface QueueStats {
|
||||
pending: number
|
||||
processing: number
|
||||
completed: number
|
||||
failed: number
|
||||
total: number
|
||||
download_slots: SlotInfo
|
||||
translate_slots: SlotInfo
|
||||
}
|
||||
|
||||
export interface QueueItem {
|
||||
gid: string
|
||||
url: string
|
||||
title: string
|
||||
status: string
|
||||
priority: number
|
||||
added_at: string
|
||||
retry_count: number
|
||||
is_spanish: boolean
|
||||
error: string
|
||||
}
|
||||
|
||||
export interface SearchResult {
|
||||
gid: string
|
||||
title: string
|
||||
url: string
|
||||
pages: number
|
||||
tags: string[]
|
||||
blocked: boolean
|
||||
blocked_reason: string
|
||||
}
|
||||
|
||||
export interface PaginatedResponse<T> {
|
||||
data: T[]
|
||||
meta: {
|
||||
total: number
|
||||
page: number
|
||||
per_page: number
|
||||
}
|
||||
}
|
||||
|
||||
export interface ApiResponse<T> {
|
||||
data: T
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
export function cn(...classes: (string | boolean | undefined | null)[]): string {
|
||||
return classes.filter(Boolean).join(" ")
|
||||
}
|
||||
|
||||
export function formatDate(iso: string): string {
|
||||
if (!iso) return ""
|
||||
const d = new Date(iso)
|
||||
return d.toLocaleDateString("es-AR", {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
year: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})
|
||||
}
|
||||
|
||||
export function formatSize(mb: number): string {
|
||||
if (mb < 1) return `${Math.round(mb * 1024)} KB`
|
||||
return `${mb.toFixed(1)} MB`
|
||||
}
|
||||
|
||||
export function extractTag(tags: string[], prefix: string): string {
|
||||
for (const t of tags) {
|
||||
const lower = t.toLowerCase()
|
||||
if (lower.startsWith(prefix)) return t.slice(prefix.length).trim()
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { NextRequest, NextResponse } from "next/server"
|
||||
import { verifySession } from "@/lib/auth"
|
||||
|
||||
const publicPaths = [
|
||||
"/login",
|
||||
"/api/auth/login",
|
||||
"/api/auth/logout",
|
||||
"/api/proxy/health",
|
||||
"/api/posts",
|
||||
"/api/cover",
|
||||
"/api/cron",
|
||||
"/_next",
|
||||
"/favicon.ico",
|
||||
"/fonts",
|
||||
]
|
||||
|
||||
export async function middleware(req: NextRequest) {
|
||||
const { pathname } = req.nextUrl
|
||||
|
||||
const isPublic = publicPaths.some((p) => pathname.startsWith(p))
|
||||
if (isPublic) return NextResponse.next()
|
||||
|
||||
const isPublicPage = pathname === "/" || pathname.startsWith("/p/") || pathname.startsWith("/tag/")
|
||||
if (isPublicPage) return NextResponse.next()
|
||||
|
||||
const webPassword = process.env.WEB_PASSWORD
|
||||
if (!webPassword) return NextResponse.next()
|
||||
|
||||
const session = req.cookies.get("session")?.value
|
||||
if (!session) {
|
||||
return NextResponse.redirect(new URL("/login", req.url))
|
||||
}
|
||||
|
||||
const payload = await verifySession(session)
|
||||
if (!payload || !payload.authenticated) {
|
||||
return NextResponse.redirect(new URL("/login", req.url))
|
||||
}
|
||||
|
||||
return NextResponse.next()
|
||||
}
|
||||
|
||||
export const config = {
|
||||
matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
|
||||
}
|
||||
Reference in New Issue
Block a user