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
+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>
)
}