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
+42
View File
@@ -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>
)
}
+10
View File
@@ -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>
)
}
+53
View File
@@ -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>
)
}
+62
View File
@@ -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>
)
}
+15
View File
@@ -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>
)
}
+64
View File
@@ -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>
)
}
+73
View File
@@ -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>
)
}
+157
View File
@@ -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>
)
}
+90
View File
@@ -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>
)
}
+30
View File
@@ -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>
}