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:
renato97
2026-07-23 15:58:28 -03:00
commit 25449aa5df
71 changed files with 11296 additions and 0 deletions
+95
View File
@@ -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>
)
}
+117
View File
@@ -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>
)
}
+48
View File
@@ -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>
)
}
+162
View File
@@ -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 &middot; {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>
)
}
+205
View File
@@ -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>
)
}
+10
View File
@@ -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>
)
}
+162
View File
@@ -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>
)
}
+14
View File
@@ -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>
)
}
+14
View File
@@ -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>
)
}
+28
View File
@@ -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 &middot; traducción automática de manga
</footer>
</>
)
}
+116
View File
@@ -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>
)
}
+38
View File
@@ -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>
)
}
+50
View File
@@ -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>
)
}
+22
View File
@@ -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 })
}
+9
View File
@@ -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 })
}
+34
View File
@@ -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",
},
})
}
+10
View File
@@ -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 })
}
}
+14
View File
@@ -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 })
}
+39
View File
@@ -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 })
}
+44
View File
@@ -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 })
}
}
+58
View File
@@ -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

+166
View File
@@ -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; }
}
+19
View File
@@ -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>
)
}
+74
View File
@@ -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 &middot; traducción automática de manga
</p>
</div>
</div>
)
}