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