feat(web): sync translated manga galleries, clean titles, synopsis integration, original source links, anthology badges, and mobile-first reader UI

This commit is contained in:
Renato
2026-07-24 01:59:40 +02:00
parent 269762557e
commit 72e503d7bc
22 changed files with 824 additions and 300 deletions
+299 -90
View File
@@ -1,39 +1,68 @@
"use client"
import { useEffect, useRef, useState, useCallback } from "react"
import { useParams } from "next/navigation"
import { ChevronLeft, ChevronRight, ZoomIn, ZoomOut, Loader2 } from "lucide-react"
import { useParams, useRouter } from "next/navigation"
import {
ArrowLeft,
ChevronLeft,
ChevronRight,
ZoomIn,
ZoomOut,
Loader2,
Rows,
BookOpen,
Maximize2,
Minimize2,
RotateCcw,
} from "lucide-react"
export default function ReaderPage() {
const { gid } = useParams<{ gid: string }>()
const router = useRouter()
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 [mode, setMode] = useState<"paged" | "webtoon">("paged")
const [showControls, setShowControls] = useState(true)
const hideTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
const [isFullscreen, setIsFullscreen] = useState(false)
const hideTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
const touchStartX = useRef<number | null>(null)
const touchStartY = useRef<number | null>(null)
const containerRef = useRef<HTMLDivElement>(null)
// Fetch page list
useEffect(() => {
async function load() {
setLoading(true)
setError("")
try {
const pagesRes = await fetch(`/api/proxy/galleries/${gid}/pages`)
if (pagesRes.ok) {
const pagesData = await pagesRes.json()
const total = pagesData?.data?.total_pages || 0
if (total > 0) {
const urls = Array.from({ length: total }, (_, i) => `/api/proxy/galleries/${gid}/page/${i + 1}`)
setPageUrls(urls)
setLoading(false)
return
}
}
} catch {}
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?.title) setTitle(summary.title)
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`)
}
if (summary?.num_pages && summary.num_pages > 0) {
const urls = Array.from({ length: summary.num_pages }, (_, i) => `/api/proxy/galleries/${gid}/page/${i + 1}`)
setPageUrls(urls)
setLoading(false)
return
@@ -45,7 +74,7 @@ export default function ReaderPage() {
const artData = await artRes.json()
const rendered = artData?.data?.files?.rendered
if (rendered?.length) {
const urls = rendered.map(() => `/api/proxy/galleries/${gid}/cover`)
const urls = Array.from({ length: rendered.length }, (_, i) => `/api/proxy/galleries/${gid}/page/${i + 1}`)
setPageUrls(urls)
setLoading(false)
return
@@ -61,7 +90,7 @@ export default function ReaderPage() {
}
} catch {}
setError("No hay imágenes disponibles. La API del pipeline solo expone la portada.")
setError("No hay imágenes disponibles para esta galería.")
setLoading(false)
}
@@ -69,47 +98,119 @@ export default function ReaderPage() {
}, [gid])
const totalPages = pageUrls.length
const goTo = useCallback((n: number) => setPage(Math.max(0, Math.min(n, totalPages - 1))), [totalPages])
const goTo = useCallback(
(n: number) => {
setPage((prev) => {
const next = Math.max(0, Math.min(n, totalPages - 1))
return next
})
},
[totalPages]
)
// Preload next 2 images
useEffect(() => {
if (mode === "paged" && pageUrls.length > 0) {
const next1 = pageUrls[page + 1]
const next2 = pageUrls[page + 2]
if (next1) { const img1 = new Image(); img1.src = next1 }
if (next2) { const img2 = new Image(); img2.src = next2 }
}
}, [page, pageUrls, mode])
// Keyboard navigation
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))
if (e.key === "0") setZoom(1)
if (e.key === "f" || e.key === "F") toggleFullscreen()
}
window.addEventListener("keydown", onKey)
return () => window.removeEventListener("keydown", onKey)
}, [page, goTo])
const handleMouseMove = useCallback(() => {
const handleUserActivity = useCallback(() => {
setShowControls(true)
if (hideTimer.current) clearTimeout(hideTimer.current)
hideTimer.current = setTimeout(() => setShowControls(false), 2000)
hideTimer.current = setTimeout(() => setShowControls(false), 3500)
}, [])
function toggleFullscreen() {
if (!document.fullscreenElement) {
document.documentElement.requestFullscreen().catch(() => {})
setIsFullscreen(true)
} else {
document.exitFullscreen().catch(() => {})
setIsFullscreen(false)
}
}
// Touch gesture handling
function handleTouchStart(e: React.TouchEvent) {
if (e.touches.length === 1) {
touchStartX.current = e.touches[0].clientX
touchStartY.current = e.touches[0].clientY
}
}
function handleTouchEnd(e: React.TouchEvent) {
if (touchStartX.current === null || touchStartY.current === null) return
const diffX = touchStartX.current - e.changedTouches[0].clientX
const diffY = touchStartY.current - e.changedTouches[0].clientY
// Swipe horizontal if X diff > Y diff and X diff > 40px
if (Math.abs(diffX) > Math.abs(diffY) && Math.abs(diffX) > 40) {
if (mode === "paged") {
if (diffX > 0) goTo(page + 1) // Swipe left -> Next page
else goTo(page - 1) // Swipe right -> Prev page
}
}
touchStartX.current = null
touchStartY.current = null
}
function handleScreenClick(e: React.MouseEvent<HTMLDivElement>) {
const width = window.innerWidth
const clickX = e.clientX
// Center 40% tap -> toggle controls
if (clickX >= width * 0.3 && clickX <= width * 0.7) {
setShowControls((s) => !s)
return
}
if (mode === "paged") {
if (clickX < width * 0.3) {
goTo(page - 1)
} else if (clickX > width * 0.7) {
goTo(page + 1)
}
}
}
if (loading) {
return (
<div className="flex items-center justify-center py-40 text-[var(--muted)]">
<Loader2 className="h-8 w-8 animate-spin" />
<div className="fixed inset-0 z-50 flex flex-col items-center justify-center bg-[#09090b] text-[var(--muted)]">
<Loader2 className="h-10 w-10 animate-spin text-[var(--accent)] mb-4" />
<p className="text-sm font-medium">Cargando manga...</p>
</div>
)
}
if (error) {
return (
<div className="mx-auto max-w-lg py-20 text-center">
<div className="card p-8">
<div className="fixed inset-0 z-50 flex items-center justify-center bg-[#09090b] p-4">
<div className="w-full max-w-sm card p-6 text-center">
<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>
<button onClick={() => router.back()} className="btn-primary w-full">
<ArrowLeft className="h-4 w-4" /> Volver
</button>
</div>
</div>
)
@@ -117,88 +218,196 @@ export default function ReaderPage() {
return (
<div
className="flex h-[calc(100vh-3rem)] flex-col bg-black/40"
onMouseMove={handleMouseMove}
className="fixed inset-0 z-50 flex flex-col bg-[#09090b] text-white select-none overflow-hidden touch-none"
onMouseMove={handleUserActivity}
onTouchStart={handleTouchStart}
onTouchEnd={handleTouchEnd}
>
<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"
{/* Floating Top Controls Header */}
<header
className={`fixed top-0 left-0 right-0 z-50 flex items-center justify-between border-b border-white/10 bg-[#09090b]/90 backdrop-blur-md px-3 py-2 transition-transform duration-300 ${
showControls ? "translate-y-0" : "-translate-y-full 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" />
<div className="flex items-center gap-2 max-w-[60%]">
<button
onClick={() => router.back()}
className="rounded-full p-2 hover:bg-white/10 text-white/80 transition-colors"
title="Volver"
>
<ArrowLeft className="h-5 w-5" />
</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>
)}
<span className="truncate text-xs md:text-sm font-medium text-white/90">
{title || `g/${gid}`}
</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 && (
<div className="flex items-center gap-1 sm:gap-2">
{/* Mode Switcher */}
<button
onClick={() => setMode((m) => (m === "paged" ? "webtoon" : "paged"))}
className="flex items-center gap-1.5 rounded-lg border border-white/15 bg-white/5 px-2.5 py-1.5 text-xs text-white/90 hover:bg-white/10 transition-colors"
title={mode === "paged" ? "Modo Cascada (Webtoon)" : "Modo Paginado"}
>
{mode === "paged" ? (
<>
<Rows className="h-3.5 w-3.5 text-[var(--accent)]" />
<span className="hidden sm:inline">Cascada</span>
</>
) : (
<>
<BookOpen className="h-3.5 w-3.5 text-[var(--accent)]" />
<span className="hidden sm:inline">Paginado</span>
</>
)}
</button>
{/* Zoom controls (paged mode) */}
{mode === "paged" && (
<div className="hidden md:flex items-center gap-1 border-l border-r border-white/10 px-2">
<button
onClick={() => setZoom((z) => Math.max(z - 0.25, 0.5))}
className="rounded p-1 hover:bg-white/10 text-white/70"
>
<ZoomOut className="h-4 w-4" />
</button>
<span className="w-10 text-center text-xs text-white/60">
{Math.round(zoom * 100)}%
</span>
<button
onClick={() => setZoom((z) => Math.min(z + 0.25, 3))}
className="rounded p-1 hover:bg-white/10 text-white/70"
>
<ZoomIn className="h-4 w-4" />
</button>
{zoom !== 1 && (
<button
onClick={() => setZoom(1)}
className="rounded p-1 hover:bg-white/10 text-white/70"
title="Restablecer Zoom"
>
<RotateCcw className="h-3.5 w-3.5" />
</button>
)}
</div>
)}
<button
onClick={toggleFullscreen}
className="hidden sm:flex rounded-full p-2 hover:bg-white/10 text-white/70"
title="Pantalla completa"
>
{isFullscreen ? <Minimize2 className="h-4 w-4" /> : <Maximize2 className="h-4 w-4" />}
</button>
</div>
</header>
{/* Main Image Viewer Container */}
<div
ref={containerRef}
onClick={handleScreenClick}
className="flex-1 w-full h-full overflow-auto flex items-center justify-center relative touch-pan-y"
>
{mode === "paged" ? (
/* Paged Mode Layout */
<div className="flex h-full w-full items-center justify-center p-2 sm:p-4">
<img
key={page}
src={pageUrls[page]}
alt={`Página ${page + 1}`}
style={{ transform: `scale(${zoom})` }}
className="max-h-full max-w-full origin-center object-contain transition-transform duration-150"
draggable={false}
/>
</div>
) : (
/* Webtoon / Vertical Continuous Scroll Mode */
<div className="w-full max-w-3xl mx-auto flex flex-col gap-1 py-12 px-0 overflow-y-auto">
{pageUrls.map((url, i) => (
<img
key={i}
src={url}
alt={`Página ${i + 1}`}
className="w-full h-auto object-contain block bg-[#18181b]/30"
loading={i < 4 ? "eager" : "lazy"}
/>
))}
</div>
)}
{/* Desktop Side Arrow Buttons */}
{mode === "paged" && totalPages > 1 && (
<>
<button
onClick={() => goTo(page - 1)}
onClick={(e) => {
e.stopPropagation()
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"
className={`hidden md:flex fixed left-4 top-1/2 -translate-y-1/2 rounded-full border border-white/15 bg-black/60 p-3 text-white backdrop-blur-md transition-all hover:scale-110 disabled:opacity-20 ${
showControls ? "opacity-100" : "opacity-0 pointer-events-none"
}`}
>
<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)}
onClick={(e) => {
e.stopPropagation()
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"
className={`hidden md:flex fixed right-4 top-1/2 -translate-y-1/2 rounded-full border border-white/15 bg-black/60 p-3 text-white backdrop-blur-md transition-all hover:scale-110 disabled:opacity-20 ${
showControls ? "opacity-100" : "opacity-0 pointer-events-none"
}`}
>
<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) => (
{/* Floating Bottom Navigation Bar */}
{mode === "paged" && totalPages > 1 && (
<footer
className={`fixed bottom-0 left-0 right-0 z-50 flex flex-col gap-2 border-t border-white/10 bg-[#09090b]/90 backdrop-blur-md px-4 py-2.5 transition-transform duration-300 ${
showControls ? "translate-y-0" : "translate-y-full pointer-events-none"
}`}
>
{/* Page Scrubber Slider */}
<div className="flex items-center gap-3 w-full max-w-xl mx-auto">
<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>
)}
onClick={() => goTo(page - 1)}
disabled={page === 0}
className="p-1 text-white/70 hover:text-white disabled:opacity-30"
>
<ChevronLeft className="h-5 w-5" />
</button>
{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>
<input
type="range"
min={0}
max={totalPages - 1}
value={page}
onChange={(e) => goTo(Number(e.target.value))}
className="flex-1 h-1.5 accent-[var(--accent)] bg-white/20 rounded-lg cursor-pointer appearance-none"
/>
<button
onClick={() => goTo(page + 1)}
disabled={page >= totalPages - 1}
className="p-1 text-white/70 hover:text-white disabled:opacity-30"
>
<ChevronRight className="h-5 w-5" />
</button>
</div>
{/* Page Counter Indicator */}
<div className="flex items-center justify-center gap-2 text-xs text-white/70">
<span className="font-semibold text-white">{page + 1}</span>
<span>/</span>
<span>{totalPages}</span>
</div>
</footer>
)}
</div>
)
+4 -2
View File
@@ -2,9 +2,11 @@ import { Sidebar } from "@/components/layout/sidebar"
export default function AppLayout({ children }: { children: React.ReactNode }) {
return (
<div className="flex min-h-screen">
<div className="min-h-screen bg-[var(--background)]">
<Sidebar />
<main className="ml-56 flex-1 p-6 lg:p-8">{children}</main>
<main className="w-full min-h-screen md:pl-56 p-0">
{children}
</main>
</div>
)
}
+21 -8
View File
@@ -1,26 +1,39 @@
import Link from "next/link"
import { Image } from "lucide-react"
import { Image, Search, Shield } 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">
<header className="sticky top-0 z-40 border-b border-[var(--border)] bg-[var(--background)]/85 backdrop-blur-md">
<div className="mx-auto flex h-14 max-w-5xl items-center justify-between px-3 sm: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
<nav className="flex items-center gap-2 sm:gap-4">
<Link
href="/search"
className="flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-xs text-[var(--muted)] hover:bg-[var(--surface)] hover:text-[var(--foreground)] transition-colors"
>
<Search className="h-4 w-4" />
<span className="hidden sm:inline">Buscar</span>
</Link>
<Link
href="/feed"
className="flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-xs text-[var(--muted)] hover:bg-[var(--surface)] hover:text-[var(--foreground)] transition-colors"
>
<Shield className="h-4 w-4" />
<span className="hidden sm:inline">Admin</span>
</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)]">
<main className="mx-auto max-w-5xl px-3 py-4 sm:px-4 sm:py-8">{children}</main>
<footer className="border-t border-[var(--border)] py-6 text-center text-xs text-[var(--muted)] mb-12 sm:mb-0">
worst-scan &middot; traducción automática de manga
</footer>
</>
+71 -15
View File
@@ -21,11 +21,24 @@ export default async function PostDetailPage({
.filter((p) => p.id !== post.id)
.slice(0, 4)
const isAnthology =
post.tags?.some((t) =>
["anthology", "compilation", "tankoubon"].some((k) => t.toLowerCase().includes(k))
) || post.num_pages >= 100
const originalUrl =
post.url ||
(post.source === "nhentai"
? `https://nhentai.net/g/${post.gid}/`
: post.source === "ehentai"
? `https://e-hentai.org/g/${post.gid}/`
: null)
return (
<div>
<div className="pb-16 md:pb-0">
<Link
href="/"
className="mb-6 inline-flex items-center gap-1.5 text-xs text-[var(--muted)] hover:text-[var(--foreground)] transition-colors"
className="mb-4 sm: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
@@ -33,23 +46,33 @@ export default async function PostDetailPage({
<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">
<div className="w-full md:w-80 flex-shrink-0 bg-[var(--surface)]">
<img
src={`/api/cover/${post.gid}`}
alt={post.title}
className="h-auto w-full object-cover md:h-full"
className="h-72 sm:h-96 w-full object-cover md:h-full"
/>
</div>
<div className="flex flex-col gap-4 p-6">
<div className="flex flex-col gap-4 p-4 sm:p-6">
<div>
<h1 className="text-xl font-bold leading-tight">{post.title}</h1>
<div className="flex flex-wrap items-center gap-2 mb-1.5">
{isAnthology && (
<span className="rounded bg-[var(--accent)]/10 px-2 py-0.5 text-[11px] font-semibold text-[var(--accent)] border border-[var(--accent)]/30">
Antología / Tomo Completo
</span>
)}
{post.num_pages > 0 && (
<span className="text-xs text-[var(--muted)]">{post.num_pages} páginas</span>
)}
</div>
<h1 className="text-lg sm: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>
<p className="mt-1 text-xs sm:text-sm text-[var(--muted)]">{post.title_jpn}</p>
)}
</div>
<div className="flex flex-wrap items-center gap-2 text-sm">
<div className="flex flex-wrap items-center gap-2 text-xs sm:text-sm">
{post.artist && (
<TagBadge tag={`artist:${post.artist}`} href={`/tag/${encodeURIComponent(`artist:${post.artist}`)}`} />
)}
@@ -64,7 +87,8 @@ export default async function PostDetailPage({
</div>
{post.summary && (
<div className="text-sm text-[var(--muted)] leading-relaxed whitespace-pre-line">
<div className="text-xs sm:text-sm text-[var(--muted)] leading-relaxed whitespace-pre-line bg-[var(--surface)]/50 p-3 sm:p-4 rounded-lg border border-[var(--border)]">
<h3 className="text-[10px] sm:text-xs font-semibold text-[var(--foreground)] uppercase tracking-wider mb-1.5">Sinopsis</h3>
{post.summary}
</div>
)}
@@ -77,21 +101,31 @@ export default async function PostDetailPage({
</div>
)}
<div className="mt-auto flex items-center gap-2">
<div className="hidden md:flex flex-wrap items-center gap-2 pt-2 mt-auto">
<Link
href={`/gallery/${post.gid}/read`}
className="btn-primary"
>
<BookOpen className="h-4 w-4" />
Leer
Leer Manga
</Link>
{originalUrl && (
<a
href={originalUrl}
target="_blank"
rel="noopener noreferrer"
className="btn-ghost border border-[var(--border)] hover:bg-[var(--surface)]"
>
<ExternalLink className="h-4 w-4" />
Ver en {post.source === "ehentai" ? "e-hentai" : "nhentai"}
</a>
)}
<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>
@@ -99,12 +133,34 @@ export default async function PostDetailPage({
</div>
</div>
{/* Sticky Bottom Action Bar for Mobile */}
<div className="md:hidden fixed bottom-0 left-0 right-0 z-40 flex items-center gap-2 border-t border-[var(--border)] bg-[var(--background)]/95 backdrop-blur-lg p-3 shadow-2xl">
<Link
href={`/gallery/${post.gid}/read`}
className="btn-primary flex-1 py-2.5 text-sm"
>
<BookOpen className="h-4 w-4" />
Leer Manga
</Link>
{originalUrl && (
<a
href={originalUrl}
target="_blank"
rel="noopener noreferrer"
className="btn-ghost border border-[var(--border)] p-2.5"
title={`Ver en ${post.source === "ehentai" ? "e-hentai" : "nhentai"}`}
>
<ExternalLink className="h-4 w-4" />
</a>
)}
</div>
{related.length > 0 && (
<section className="mt-12">
<h2 className="mb-4 text-sm font-semibold text-[var(--muted)] uppercase tracking-wider">
<section className="mt-8 sm:mt-12">
<h2 className="mb-4 text-xs sm: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">
<div className="grid grid-cols-2 gap-3 sm:gap-4 sm:grid-cols-4">
{related.map((p) => (
<PostCard key={p.id} post={p} compact />
))}
+4 -4
View File
@@ -21,14 +21,14 @@ export default function PublicHomePage() {
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)]">
<div className="mb-6 sm:mb-8">
<h1 className="text-xl sm:text-2xl font-bold tracking-tight">Publicaciones</h1>
<p className="mt-1 text-xs sm: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">
<div className="grid grid-cols-2 gap-3 sm:gap-4 sm:grid-cols-3 md:grid-cols-4 xl:grid-cols-5">
{posts.map((post) => (
<PostCard key={post.id} post={post} />
))}
+90
View File
@@ -0,0 +1,90 @@
import { NextRequest } from "next/server"
import crypto from "crypto"
import { createPost, getPostByGid, updatePost } from "@/lib/db"
import { cleanTitle, slugify } from "@/lib/slug"
import { cacheCover } from "@/lib/cover-cache"
function verifySignature(bodyText: string, signature: string, secret: string): boolean {
try {
const hmac = crypto.createHmac("sha256", secret)
hmac.update(bodyText)
const expected = hmac.digest("hex")
return crypto.timingSafeEqual(Buffer.from(expected), Buffer.from(signature))
} catch {
return false
}
}
export async function POST(req: NextRequest) {
try {
const rawBody = await req.text()
const secret = process.env.WEBHOOK_SECRET || process.env.FANSUB_WEBHOOK_SECRET || ""
if (secret) {
const signature = req.headers.get("x-signature-256") || req.headers.get("X-Signature-256") || ""
if (!signature || !verifySignature(rawBody, signature, secret)) {
return Response.json({ error: "Unauthorized: invalid signature" }, { status: 401 })
}
}
const payload = JSON.parse(rawBody)
const gid = payload.gid
const rawTitle = payload.title
if (!gid || !rawTitle) {
return Response.json({ error: "Invalid payload: gid and title required" }, { status: 400 })
}
const title = cleanTitle(rawTitle)
const tags = Array.isArray(payload.tags) ? payload.tags : []
const numPages = payload.num_pages || payload.pages || 0
const synopsis = payload.synopsis || ""
const sourceUrl = payload.url || (payload.source === "nhentai" ? `https://nhentai.net/g/${gid}/` : (payload.source === "ehentai" ? `https://e-hentai.org/g/${gid}/` : undefined))
const slug = slugify(title, gid)
let post = getPostByGid(gid)
if (!post) {
post = createPost({
gid,
title,
title_jpn: payload.title_jpn || undefined,
artist: payload.artist || undefined,
parody: payload.parody || undefined,
tags,
num_pages: numPages,
source: payload.source || undefined,
cover_url: payload.cover_url || undefined,
url: sourceUrl,
summary: synopsis || undefined,
slug,
published: 1,
})
} else {
post = updatePost(post.id, {
title,
title_jpn: payload.title_jpn || post.title_jpn || undefined,
artist: payload.artist || post.artist || undefined,
parody: payload.parody || post.parody || undefined,
tags: tags.length > 0 ? tags : post.tags,
num_pages: numPages || post.num_pages,
source: payload.source || post.source || undefined,
cover_url: payload.cover_url || post.cover_url || undefined,
url: sourceUrl || post.url || undefined,
summary: synopsis || post.summary || undefined,
})!
}
const port = process.env.PORT || "3000"
const proxyUrl = `http://127.0.0.1:${port}/api/proxy/galleries/${gid}/cover`
await cacheCover(gid, proxyUrl)
return Response.json({
success: true,
gid,
post,
})
} catch (err) {
return Response.json({ error: String(err) }, { status: 500 })
}
}
+55 -26
View File
@@ -21,44 +21,73 @@ export function Sidebar() {
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>
// Do not render sidebar / bottom bar if inside reader page
if (pathname.includes("/read")) {
return null
}
<nav className="flex flex-1 flex-col gap-0.5 p-3">
return (
<>
{/* Desktop Sidebar */}
<aside className="hidden md:flex fixed left-0 top-0 z-30 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">
<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>
</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>
{/* Mobile Bottom Navigation Bar */}
<nav className="md:hidden fixed bottom-0 left-0 right-0 z-40 flex h-14 items-center justify-around border-t border-[var(--border)] bg-[var(--background)]/90 backdrop-blur-lg px-2">
{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)]"
className={`flex flex-col items-center justify-center gap-1 py-1 px-3 text-[10px] font-medium transition-all ${
isActive ? "text-[var(--accent)]" : "text-[var(--muted)] hover:text-[var(--foreground)]"
}`}
>
<Icon className={`h-4 w-4 ${isActive ? "text-[var(--accent)]" : ""}`} />
{label}
<Icon className="h-5 w-5" />
<span>{label}</span>
</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>
</>
)
}
+14 -1
View File
@@ -4,6 +4,11 @@ import { formatDate } from "@/lib/utils"
import { TagBadge } from "@/components/tag-badge"
export function PostCard({ post, compact = false }: { post: Post; compact?: boolean }) {
const isAnthology =
post.tags?.some((t) =>
["anthology", "compilation", "tankoubon"].some((k) => t.toLowerCase().includes(k))
) || post.num_pages >= 100
if (compact) {
return (
<Link
@@ -24,6 +29,9 @@ export function PostCard({ post, compact = false }: { post: Post; compact?: bool
<p className="text-xs text-[var(--muted)]">{post.artist}</p>
)}
<div className="flex flex-1 items-end gap-2">
{isAnthology && (
<span className="text-[9px] font-semibold text-[var(--accent)] uppercase">Tomo</span>
)}
{post.source && (
<span className="text-[10px] uppercase text-[var(--muted)]">{post.source}</span>
)}
@@ -41,13 +49,18 @@ export function PostCard({ post, compact = false }: { post: Post; compact?: bool
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)]">
<div className="relative 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"
/>
{isAnthology && (
<span className="absolute top-2 left-2 rounded bg-black/80 backdrop-blur-sm px-1.5 py-0.5 text-[9px] font-bold text-[var(--accent)] border border-[var(--accent)]/40 shadow">
ANTOLOGÍA
</span>
)}
</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">
+3
View File
@@ -58,6 +58,9 @@ export const api = {
return fetchApi<ApiResponse<PaginatedResponse<GalleryItem>>>(`/galleries?${params}`)
},
translated: () =>
fetchApi<ApiResponse<{ data: { gid: string; title: string; raw_name: string; has_mobi: boolean; pages: number }[] }>>("/galleries/translated"),
get: (gid: string) =>
fetchApi<ApiResponse<GalleryDetail>>(`/galleries/${gid}`),
+1 -1
View File
@@ -39,7 +39,7 @@ export async function cacheCover(gid: string, proxyUrl: string): Promise<string
export function getCoverPath(gid: string): string | null {
ensureDir()
const files = fs.readdirSync(COVERS_DIR).filter((f) => f.startsWith(gid))
const files = fs.readdirSync(COVERS_DIR).filter((f) => f.startsWith(`${gid}.`))
if (files.length === 0) return null
return path.join(COVERS_DIR, files[0])
}
+11 -3
View File
@@ -30,6 +30,7 @@ function migrate(db: Database.Database) {
num_pages INTEGER DEFAULT 0,
source TEXT,
cover_url TEXT,
url TEXT,
summary TEXT,
slug TEXT UNIQUE NOT NULL,
published INTEGER DEFAULT 0,
@@ -43,6 +44,10 @@ function migrate(db: Database.Database) {
CREATE INDEX IF NOT EXISTS idx_posts_published ON posts(published);
CREATE INDEX IF NOT EXISTS idx_posts_created ON posts(created_at DESC);
`)
try {
db.exec("ALTER TABLE posts ADD COLUMN url TEXT")
} catch {}
}
export interface Post {
@@ -56,6 +61,7 @@ export interface Post {
num_pages: number
source: string | null
cover_url: string | null
url: string | null
summary: string | null
slug: string
published: number
@@ -74,6 +80,7 @@ export interface PostInput {
num_pages?: number
source?: string
cover_url?: string
url?: string
summary?: string
slug: string
published?: number
@@ -117,8 +124,8 @@ export function createPost(input: PostInput): Post {
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})
INSERT INTO posts (gid, title, title_jpn, artist, parody, tags, num_pages, source, cover_url, url, summary, slug, published, published_at)
VALUES (@gid, @title, @title_jpn, @artist, @parody, @tags, @num_pages, @source, @cover_url, @url, @summary, @slug, @published, ${publishedAt})
`)
const result = stmt.run({
gid: input.gid,
@@ -130,6 +137,7 @@ export function createPost(input: PostInput): Post {
num_pages: input.num_pages || 0,
source: input.source || null,
cover_url: input.cover_url || null,
url: input.url || null,
summary: input.summary || null,
slug: input.slug,
published,
@@ -157,7 +165,7 @@ export function updatePost(id: number, updates: Partial<PostInput & { published:
if (fields.length === 0) return getPostById(id)
fields.push("updated_at = datetime('now')")
d.prepare(`UPDATE posts SET ${fields.join(", ")} WHERE id = ?`).run(id)
d.prepare(`UPDATE posts SET ${fields.join(", ")} WHERE id = @id`).run(values)
return getPostById(id)
}
+61 -27
View File
@@ -1,7 +1,7 @@
import "server-only"
import { api } from "./api"
import { slugify } from "./slug"
import { createPost, getPostByGid } from "./db"
import { cleanTitle, slugify } from "./slug"
import { createPost, getPostByGid, updatePost } from "./db"
import { cacheCover } from "./cover-cache"
let intervalId: ReturnType<typeof setInterval> | null = null
@@ -24,38 +24,72 @@ export async function pollOnce(): Promise<{ newPosts: number }> {
let newPosts = 0
try {
const res = await api.galleries.list("completed", 1, 100)
const galleries = res.data?.data || res.data || []
const allGids = new Set<string>()
for (const g of galleries) {
try {
const res = await api.galleries.list("completed", 1, 100)
const galleries = res.data?.data || res.data || []
for (const g of galleries) {
if (g.gid) allGids.add(g.gid)
}
} catch {}
try {
const transRes = await api.galleries.translated()
const translated = (transRes as any)?.data?.data || (transRes as any)?.data || []
for (const t of translated) {
if (t.gid) allGids.add(t.gid)
}
} catch {}
for (const gid of allGids) {
try {
const existing = getPostByGid(g.gid)
if (existing) continue
const sumRes = await api.galleries.summary(g.gid)
const sumRes = await api.galleries.summary(gid)
const summary = sumRes?.data
if (!summary) continue
if (!summary || !summary.title) continue
const slug = slugify(summary.title, g.gid)
const cleanT = cleanTitle(summary.title)
const slug = slugify(cleanT, gid)
const finalSummary = summary.synopsis && summary.synopsis.trim() !== ""
? summary.synopsis
: generateSummary(summary)
const sourceUrl = summary.url || (summary.source === "nhentai" ? `https://nhentai.net/g/${gid}/` : (summary.source === "ehentai" ? `https://e-hentai.org/g/${gid}/` : undefined))
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,
})
const existing = getPostByGid(gid)
await cacheCover(g.gid, `/api/proxy/galleries/${g.gid}/cover`)
if (!existing) {
await createPost({
gid,
title: cleanT,
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,
url: sourceUrl,
summary: finalSummary,
slug,
published: 1,
})
newPosts++
} else {
updatePost(existing.id, {
title: cleanT,
title_jpn: summary.title_jpn || undefined,
artist: summary.artist || undefined,
parody: summary.parody || undefined,
tags: summary.tags,
num_pages: summary.num_pages,
source: summary.source || undefined,
url: sourceUrl || existing.url || undefined,
summary: finalSummary,
})
}
newPosts++
const port = process.env.PORT || "3000"
await cacheCover(gid, `http://127.0.0.1:${port}/api/proxy/galleries/${gid}/cover`)
} catch {
continue
}
+31 -5
View File
@@ -1,8 +1,34 @@
export function cleanTitle(raw: string): string {
if (!raw) return ""
let title = raw
// 1. Remove dual-language subtitle after '|'
if (title.includes("|")) {
title = title.split("|")[0].trim()
}
// 2. Remove language / format tags in brackets or braces
title = title.replace(/\s*[\(\[\{](?:spanish|english|chinese|japanese|korean|digital|decensored|uncensored|censored|colorized|colored|full color|doujins\.com|hennojin|sample|raw)[\)\]\}]/gi, "")
// 3. Remove leading circle / artist in brackets
title = title.replace(/^\s*\[[^\]]+\]\s*/, "")
// 4. Remove trailing parody in parentheses if present
title = title.replace(/\s*\([^)]+\)\s*$/, "")
// 5. Remove trailing numbers/ID in parens or brackets
title = title.replace(/\s*[\(\[]\d+[\)\]]\s*$/, "")
title = title.trim()
return title || raw
}
export function slugify(text: string, gid?: string): string {
let slug = text
const cleaned = cleanTitle(text)
let slug = cleaned
.toLowerCase()
.replace(/\[.*?\]/g, "")
.replace(/\(.*?\)/g, "")
.replace(/[^a-z0-9\s-]/g, "")
.trim()
.replace(/\s+/g, "-")
@@ -11,11 +37,11 @@ export function slugify(text: string, gid?: string): string {
.slice(0, 80)
if (!slug) {
slug = `gallery`
slug = "gallery"
}
if (gid) {
slug = `${slug}-${gid.slice(0, 8)}`
slug = `${slug}-${gid}`
}
return slug
+1
View File
@@ -27,6 +27,7 @@ export interface GallerySummary {
tags: string[]
artist: string
parody: string
synopsis?: string
cover: {
local_endpoint: string
local_available: boolean