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:
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 · traducción automática de manga
|
||||
</footer>
|
||||
</>
|
||||
|
||||
@@ -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 />
|
||||
))}
|
||||
|
||||
@@ -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} />
|
||||
))}
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user