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,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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user