feat: worst-scan fansub web — initial release
Posts engine (SQLite + auto-publisher via poller), public feed with clickable tags, reader, admin panel, submit/search/queue tools. BFF proxy to pipeline API. Clean dark design. Docker-ready.
This commit is contained in:
@@ -0,0 +1,95 @@
|
||||
"use client"
|
||||
|
||||
import { useState, useEffect, FormEvent } from "react"
|
||||
import { useParams, useRouter } from "next/navigation"
|
||||
import Link from "next/link"
|
||||
import { ArrowLeft, Save } from "lucide-react"
|
||||
|
||||
export default function EditPostPage() {
|
||||
const { id } = useParams<{ id: string }>()
|
||||
const router = useRouter()
|
||||
|
||||
const [title, setTitle] = useState("")
|
||||
const [summary, setSummary] = useState("")
|
||||
const [slug, setSlug] = useState("")
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
try {
|
||||
const res = await fetch(`/api/posts/${id}`)
|
||||
const data = await res.json()
|
||||
const post = data?.data
|
||||
if (post) {
|
||||
setTitle(post.title)
|
||||
setSummary(post.summary || "")
|
||||
setSlug(post.slug)
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
setLoading(false)
|
||||
}
|
||||
load()
|
||||
}, [id])
|
||||
|
||||
async function handleSave(e: FormEvent) {
|
||||
e.preventDefault()
|
||||
setSaving(true)
|
||||
try {
|
||||
await fetch(`/api/posts/${id}`, {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ title, summary, slug }),
|
||||
})
|
||||
router.push("/admin/posts")
|
||||
} catch {
|
||||
}
|
||||
setSaving(false)
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <div className="py-20 text-center text-sm text-[var(--muted)]">Cargando...</div>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl">
|
||||
<Link
|
||||
href="/admin/posts"
|
||||
className="mb-6 inline-flex items-center gap-1.5 text-xs text-[var(--muted)] hover:text-[var(--foreground)] transition-colors"
|
||||
>
|
||||
<ArrowLeft className="h-3.5 w-3.5" />
|
||||
Volver a posts
|
||||
</Link>
|
||||
|
||||
<h1 className="mb-6 text-xl font-bold">Editar Post</h1>
|
||||
|
||||
<form onSubmit={handleSave} className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs font-medium text-[var(--muted)]">Título</label>
|
||||
<input value={title} onChange={(e) => setTitle(e.target.value)} className="input" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs font-medium text-[var(--muted)]">Slug</label>
|
||||
<input value={slug} onChange={(e) => setSlug(e.target.value)} className="input font-mono text-xs" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-xs font-medium text-[var(--muted)]">Resumen</label>
|
||||
<textarea
|
||||
value={summary}
|
||||
onChange={(e) => setSummary(e.target.value)}
|
||||
rows={8}
|
||||
className="input resize-y"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button type="submit" disabled={saving} className="btn-primary">
|
||||
<Save className="h-4 w-4" />
|
||||
{saving ? "Guardando..." : "Guardar"}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import useSWR from "swr"
|
||||
import Link from "next/link"
|
||||
import { RefreshCw, FileText, CheckCircle, XCircle, ExternalLink } from "lucide-react"
|
||||
import { EmptyState } from "@/components/empty-state"
|
||||
|
||||
const fetcher = (url: string) => fetch(url).then((r) => r.json())
|
||||
|
||||
export default function AdminPostsPage() {
|
||||
const { data, isLoading, mutate } = useSWR("/api/posts", fetcher, { refreshInterval: 15000 })
|
||||
const [publishing, setPublishing] = useState<Set<number>>(new Set())
|
||||
const [deleting, setDeleting] = useState<Set<number>>(new Set())
|
||||
|
||||
const posts = data?.data || []
|
||||
|
||||
async function handlePublish(id: number, current: number) {
|
||||
setPublishing((prev) => new Set(prev).add(id))
|
||||
await fetch(`/api/posts/${id}/publish`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ publish: current === 0 }),
|
||||
})
|
||||
mutate()
|
||||
setPublishing((prev) => {
|
||||
const next = new Set(prev)
|
||||
next.delete(id)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
async function handleDelete(id: number) {
|
||||
if (!confirm("¿Eliminar este post permanentemente?")) return
|
||||
setDeleting((prev) => new Set(prev).add(id))
|
||||
await fetch(`/api/posts/${id}`, { method: "DELETE" })
|
||||
mutate()
|
||||
setDeleting((prev) => {
|
||||
const next = new Set(prev)
|
||||
next.delete(id)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-bold">Posts</h1>
|
||||
<p className="text-sm text-[var(--muted)]">{posts.length} publicaciones</p>
|
||||
</div>
|
||||
<button onClick={() => mutate()} disabled={isLoading} className="btn-ghost">
|
||||
<RefreshCw className={`h-4 w-4 ${isLoading ? "animate-spin" : ""}`} />
|
||||
Actualizar
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{posts.length === 0 ? (
|
||||
<EmptyState message="No hay posts" icon={<FileText className="mb-3 h-10 w-10 text-[var(--muted)]" />} />
|
||||
) : (
|
||||
<div className="card divide-y divide-[var(--border)] overflow-hidden">
|
||||
{posts.map((post: {
|
||||
id: number
|
||||
gid: string
|
||||
title: string
|
||||
slug: string
|
||||
published: number
|
||||
artist: string | null
|
||||
source: string | null
|
||||
created_at: string
|
||||
}) => (
|
||||
<div key={post.id} className="flex items-center gap-3 px-4 py-3 text-sm">
|
||||
<button
|
||||
onClick={() => handlePublish(post.id, post.published)}
|
||||
disabled={publishing.has(post.id)}
|
||||
className="flex-shrink-0"
|
||||
title={post.published ? "Publicado" : "No publicado"}
|
||||
>
|
||||
{post.published ? (
|
||||
<CheckCircle className="h-5 w-5 text-[var(--success)]" />
|
||||
) : (
|
||||
<XCircle className="h-5 w-5 text-[var(--muted)]" />
|
||||
)}
|
||||
</button>
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<Link
|
||||
href={`/p/${post.slug}`}
|
||||
className="font-medium hover:text-[var(--accent)] transition-colors line-clamp-1"
|
||||
>
|
||||
{post.title}
|
||||
</Link>
|
||||
<div className="flex items-center gap-2 text-xs text-[var(--muted)]">
|
||||
<span>{post.gid}</span>
|
||||
{post.artist && <span>{post.artist}</span>}
|
||||
{post.source && <span>{post.source}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<Link href={`/gallery/${post.gid}`} className="btn-ghost p-1.5">
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
</Link>
|
||||
<button
|
||||
onClick={() => handleDelete(post.id)}
|
||||
disabled={deleting.has(post.id)}
|
||||
className="btn-ghost p-1.5 text-[var(--error)] hover:bg-[var(--error-subtle)]"
|
||||
>
|
||||
<XCircle className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import { useGalleries } from "@/hooks/use-galleries"
|
||||
import { GalleryGrid } from "@/components/gallery-grid"
|
||||
import { FilterBar } from "@/components/filter-bar"
|
||||
|
||||
export default function FeedPage() {
|
||||
const [status, setStatus] = useState("completed")
|
||||
const { galleries, isLoading, mutate } = useGalleries(status, 1)
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6">
|
||||
<h1 className="text-xl font-bold">Feed</h1>
|
||||
<p className="text-sm text-[var(--muted)]">
|
||||
{isLoading && galleries.length === 0
|
||||
? "Cargando galleries..."
|
||||
: `${galleries.length} galleries`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<FilterBar
|
||||
current={status}
|
||||
onChange={setStatus}
|
||||
onRefresh={() => mutate()}
|
||||
loading={isLoading}
|
||||
/>
|
||||
|
||||
{isLoading && galleries.length === 0 ? (
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div key={i} className="card flex gap-4 overflow-hidden p-0">
|
||||
<div className="skeleton h-36 w-24 flex-shrink-0" />
|
||||
<div className="flex flex-1 flex-col gap-2 py-3 pr-3">
|
||||
<div className="skeleton h-4 w-3/4" />
|
||||
<div className="skeleton h-3 w-1/4" />
|
||||
<div className="skeleton mt-auto h-3 w-1/3" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<GalleryGrid items={galleries} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import Link from "next/link"
|
||||
import { useParams } from "next/navigation"
|
||||
import useSWR from "swr"
|
||||
import { BookOpen, ExternalLink, Trash2 } from "lucide-react"
|
||||
import { CoverImage } from "@/components/cover-image"
|
||||
import { TagBadge } from "@/components/tag-badge"
|
||||
import type { GallerySummary, GalleryArtifacts } from "@/lib/types"
|
||||
|
||||
const fetcher = (url: string) => fetch(url).then((r) => r.json())
|
||||
|
||||
export default function GalleryDetailPage() {
|
||||
const { gid } = useParams<{ gid: string }>()
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
|
||||
const { data: summaryData, isLoading } = useSWR(
|
||||
`/api/proxy/galleries/${gid}/summary`,
|
||||
fetcher,
|
||||
)
|
||||
|
||||
const { data: artifactsData } = useSWR(
|
||||
`/api/proxy/galleries/${gid}/artifacts`,
|
||||
fetcher,
|
||||
)
|
||||
|
||||
const summary = summaryData?.data as GallerySummary | undefined
|
||||
const artifacts = artifactsData?.data as GalleryArtifacts | undefined
|
||||
|
||||
async function handleDelete() {
|
||||
if (!confirm("¿Eliminar esta gallery?")) return
|
||||
setDeleting(true)
|
||||
try {
|
||||
await fetch(`/api/proxy/galleries/${gid}`, { method: "DELETE" })
|
||||
window.location.href = "/feed"
|
||||
} catch {
|
||||
setDeleting(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="max-w-3xl">
|
||||
<div className="flex gap-6">
|
||||
<div className="skeleton h-56 w-40 flex-shrink-0 rounded-lg" />
|
||||
<div className="flex flex-1 flex-col gap-3">
|
||||
<div className="skeleton h-6 w-3/4" />
|
||||
<div className="skeleton h-4 w-1/2" />
|
||||
<div className="skeleton h-4 w-1/3" />
|
||||
<div className="skeleton mt-auto h-9 w-24" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!summary) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-20">
|
||||
<p className="text-sm text-[var(--error)]">Gallery no encontrada</p>
|
||||
<Link href="/feed" className="btn-ghost mt-4">
|
||||
Volver al feed
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-3xl">
|
||||
<div className="card mb-6 flex flex-col gap-6 overflow-hidden p-0 sm:flex-row">
|
||||
<CoverImage
|
||||
gid={gid}
|
||||
coverUrl={summary.cover.external_url}
|
||||
alt={summary.title}
|
||||
className="h-56 w-40 flex-shrink-0"
|
||||
/>
|
||||
|
||||
<div className="flex flex-col gap-2 px-6 pb-6 pt-0 sm:py-6 sm:pl-0">
|
||||
<h1 className="text-xl font-bold">{summary.title}</h1>
|
||||
{summary.title_jpn && (
|
||||
<p className="text-sm text-[var(--muted)]">{summary.title_jpn}</p>
|
||||
)}
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2 text-sm">
|
||||
{summary.artist && <TagBadge tag={`artist:${summary.artist}`} href={`/tag/${encodeURIComponent(`artist:${summary.artist}`)}`} />}
|
||||
{summary.parody && <TagBadge tag={`parody:${summary.parody}`} href={`/tag/${encodeURIComponent(`parody:${summary.parody}`)}`} />}
|
||||
{summary.is_spanish && (
|
||||
<span className="tag-pill bg-emerald-900/30 text-emerald-300 border border-emerald-800/40">
|
||||
ESP
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="text-sm text-[var(--muted)]">
|
||||
{summary.num_pages} páginas · {summary.source}
|
||||
</p>
|
||||
|
||||
<div className="mt-auto flex items-center gap-2">
|
||||
<Link href={`/gallery/${gid}/read`} className="btn-primary">
|
||||
<BookOpen className="h-4 w-4" />
|
||||
Leer
|
||||
</Link>
|
||||
{summary.url && (
|
||||
<a href={summary.url} target="_blank" rel="noopener noreferrer" className="btn-ghost">
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
Original
|
||||
</a>
|
||||
)}
|
||||
<button onClick={handleDelete} disabled={deleting} className="btn-danger">
|
||||
<Trash2 className="h-4 w-4" />
|
||||
{deleting ? "..." : "Eliminar"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{summary.tags && summary.tags.length > 0 && (
|
||||
<section className="mb-6">
|
||||
<h2 className="mb-3 text-xs font-semibold text-[var(--muted)] uppercase tracking-wider">Tags</h2>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{summary.tags.map((tag) => (
|
||||
<TagBadge key={tag} tag={tag} href={`/tag/${encodeURIComponent(tag)}`} />
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{artifacts && (
|
||||
<section>
|
||||
<h2 className="mb-3 text-xs font-semibold text-[var(--muted)] uppercase tracking-wider">
|
||||
Archivos ({artifacts.file_count} archivos, {(artifacts.total_size_mb).toFixed(1)} MB)
|
||||
</h2>
|
||||
<div className="card divide-y divide-[var(--border)] overflow-hidden">
|
||||
{Object.entries(artifacts.files).map(([dir, files]) =>
|
||||
Array.isArray(files) && files.length ? (
|
||||
<details key={dir} className="group">
|
||||
<summary className="flex cursor-pointer items-center gap-2 px-4 py-2.5 text-sm text-[var(--muted)] hover:text-[var(--foreground)] transition-colors">
|
||||
<span className="font-medium">{dir}</span>
|
||||
<span className="text-xs text-[var(--muted)]">({files.length})</span>
|
||||
</summary>
|
||||
<div className="border-t border-[var(--border)] px-4 py-2">
|
||||
<p className="mb-2 text-xs text-[var(--muted)]">
|
||||
{files.slice(0, 20).map((f) => (
|
||||
<span key={f} className="block py-0.5 font-mono text-[10px]">{f}</span>
|
||||
))}
|
||||
{files.length > 20 && (
|
||||
<span className="block py-1 text-[10px] text-[var(--muted)]">
|
||||
... y {files.length - 20} más
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</details>
|
||||
) : null,
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
"use client"
|
||||
|
||||
import { useEffect, useRef, useState, useCallback } from "react"
|
||||
import { useParams } from "next/navigation"
|
||||
import { ChevronLeft, ChevronRight, ZoomIn, ZoomOut, Loader2 } from "lucide-react"
|
||||
|
||||
export default function ReaderPage() {
|
||||
const { gid } = useParams<{ gid: string }>()
|
||||
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 [showControls, setShowControls] = useState(true)
|
||||
const hideTimer = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
|
||||
|
||||
useEffect(() => {
|
||||
async function load() {
|
||||
setLoading(true)
|
||||
setError("")
|
||||
|
||||
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) { 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`)
|
||||
}
|
||||
setPageUrls(urls)
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
} catch {}
|
||||
|
||||
try {
|
||||
const artRes = await fetch(`/api/proxy/galleries/${gid}/artifacts`)
|
||||
const artData = await artRes.json()
|
||||
const rendered = artData?.data?.files?.rendered
|
||||
if (rendered?.length) {
|
||||
const urls = rendered.map(() => `/api/proxy/galleries/${gid}/cover`)
|
||||
setPageUrls(urls)
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
} catch {}
|
||||
|
||||
try {
|
||||
const coverRes = await fetch(`/api/proxy/galleries/${gid}/cover`, { method: "HEAD" })
|
||||
if (coverRes.ok) {
|
||||
setPageUrls([`/api/proxy/galleries/${gid}/cover`])
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
} catch {}
|
||||
|
||||
setError("No hay imágenes disponibles. La API del pipeline solo expone la portada.")
|
||||
setLoading(false)
|
||||
}
|
||||
|
||||
load()
|
||||
}, [gid])
|
||||
|
||||
const totalPages = pageUrls.length
|
||||
const goTo = useCallback((n: number) => setPage(Math.max(0, Math.min(n, totalPages - 1))), [totalPages])
|
||||
|
||||
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))
|
||||
}
|
||||
window.addEventListener("keydown", onKey)
|
||||
return () => window.removeEventListener("keydown", onKey)
|
||||
}, [page, goTo])
|
||||
|
||||
const handleMouseMove = useCallback(() => {
|
||||
setShowControls(true)
|
||||
if (hideTimer.current) clearTimeout(hideTimer.current)
|
||||
hideTimer.current = setTimeout(() => setShowControls(false), 2000)
|
||||
}, [])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-40 text-[var(--muted)]">
|
||||
<Loader2 className="h-8 w-8 animate-spin" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="mx-auto max-w-lg py-20 text-center">
|
||||
<div className="card p-8">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="flex h-[calc(100vh-3rem)] flex-col bg-black/40"
|
||||
onMouseMove={handleMouseMove}
|
||||
>
|
||||
<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"
|
||||
}`}
|
||||
>
|
||||
<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" />
|
||||
</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>
|
||||
)}
|
||||
</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 && (
|
||||
<button
|
||||
onClick={() => 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"
|
||||
}`}
|
||||
>
|
||||
<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)}
|
||||
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"
|
||||
}`}
|
||||
>
|
||||
<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) => (
|
||||
<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>
|
||||
)}
|
||||
|
||||
{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>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Sidebar } from "@/components/layout/sidebar"
|
||||
|
||||
export default function AppLayout({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex min-h-screen">
|
||||
<Sidebar />
|
||||
<main className="ml-56 flex-1 p-6 lg:p-8">{children}</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
"use client"
|
||||
|
||||
import { useState } from "react"
|
||||
import useSWR from "swr"
|
||||
import { RefreshCw, RotateCcw } from "lucide-react"
|
||||
import { FilterBar } from "@/components/filter-bar"
|
||||
import { EmptyState } from "@/components/empty-state"
|
||||
import type { QueueItem, SystemStatus } from "@/lib/types"
|
||||
|
||||
const fetcher = (url: string) => fetch(url).then((r) => r.json())
|
||||
|
||||
function SlotGauge({ label, used, max }: { label: string; used: number; max: number }) {
|
||||
const pct = max > 0 ? (used / max) * 100 : 0
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span className="w-16 text-[var(--muted)]">{label}</span>
|
||||
<div className="h-1.5 flex-1 overflow-hidden rounded-full bg-[var(--border)]">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all duration-300 ${
|
||||
pct >= 100 ? "bg-[var(--error)]" : "bg-[var(--accent)]"
|
||||
}`}
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="w-10 text-right text-[var(--muted)]">
|
||||
{used}/{max}
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StatBox({ label, value, color }: { label: string; value: number; color: string }) {
|
||||
return (
|
||||
<div className="card p-4 text-center">
|
||||
<div className="text-2xl font-bold" style={{ color }}>{value}</div>
|
||||
<div className="text-xs text-[var(--muted)] mt-0.5">{label}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function QueuePage() {
|
||||
const [status, setStatus] = useState("")
|
||||
const [retrying, setRetrying] = useState<Set<string>>(new Set())
|
||||
|
||||
const { data: queueData, isLoading, mutate: mutateQueue } = useSWR(
|
||||
`/api/proxy/queue?status=${status}&page=1&per_page=100`,
|
||||
fetcher,
|
||||
{ refreshInterval: 10000 },
|
||||
)
|
||||
|
||||
const { data: statusData, mutate: mutateStatus } = useSWR(
|
||||
"/api/proxy/status",
|
||||
fetcher,
|
||||
{ refreshInterval: 10000 },
|
||||
)
|
||||
|
||||
const items = (queueData?.data || []) as QueueItem[]
|
||||
const sysStatus = statusData?.data as SystemStatus | undefined
|
||||
|
||||
async function handleRetry(gid: string) {
|
||||
setRetrying((prev) => new Set(prev).add(gid))
|
||||
try {
|
||||
await fetch(`/api/proxy/queue/${gid}/retry`, { method: "POST" })
|
||||
mutateQueue()
|
||||
} catch {
|
||||
}
|
||||
setRetrying((prev) => {
|
||||
const next = new Set(prev)
|
||||
next.delete(gid)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const slots = sysStatus?.slots || { download: { used: 0, max: 2 }, translate: { used: 0, max: 2 } }
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6">
|
||||
<h1 className="text-xl font-bold">Cola</h1>
|
||||
<p className="text-sm text-[var(--muted)]">
|
||||
Estado actual del pipeline ({items.length} items)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mb-4 flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-center gap-4">
|
||||
<SlotGauge label="Download" {...slots.download} />
|
||||
<SlotGauge label="Translate" {...slots.translate} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{sysStatus && (
|
||||
<div className="mb-4 grid grid-cols-4 gap-3">
|
||||
<StatBox label="Pendientes" value={sysStatus.queue.pending} color="var(--warning)" />
|
||||
<StatBox label="Procesando" value={sysStatus.queue.processing} color="var(--accent)" />
|
||||
<StatBox label="Completadas" value={sysStatus.queue.completed} color="var(--success)" />
|
||||
<StatBox label="Fallidas" value={sysStatus.queue.failed} color="var(--error)" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<FilterBar
|
||||
current={status}
|
||||
onChange={(s) => setStatus(s)}
|
||||
onRefresh={() => { mutateQueue(); mutateStatus() }}
|
||||
loading={isLoading}
|
||||
/>
|
||||
|
||||
{items.length === 0 ? (
|
||||
<EmptyState message="Cola vacía" />
|
||||
) : (
|
||||
<div className="card divide-y divide-[var(--border)] overflow-hidden">
|
||||
{items.map((item) => (
|
||||
<div
|
||||
key={item.gid}
|
||||
className="flex items-center gap-3 px-4 py-2.5 text-sm"
|
||||
>
|
||||
<a
|
||||
href={`/gallery/${item.gid}`}
|
||||
className="min-w-0 flex-1 truncate font-medium hover:text-[var(--accent)] transition-colors"
|
||||
>
|
||||
{item.title || item.gid}
|
||||
</a>
|
||||
|
||||
<span
|
||||
className={`w-20 text-center text-xs font-medium capitalize ${
|
||||
item.status === "completed"
|
||||
? "text-[var(--success)]"
|
||||
: item.status === "failed"
|
||||
? "text-[var(--error)]"
|
||||
: item.status === "processing"
|
||||
? "text-[var(--accent)]"
|
||||
: "text-[var(--warning)]"
|
||||
}`}
|
||||
>
|
||||
{item.status}
|
||||
</span>
|
||||
|
||||
<span className="w-12 text-center text-xs text-[var(--muted)]">
|
||||
{item.priority > 0 ? `P${item.priority}` : "-"}
|
||||
</span>
|
||||
|
||||
<span className="w-32 truncate text-xs text-[var(--muted)]">
|
||||
{item.error || "-"}
|
||||
</span>
|
||||
|
||||
{item.status === "failed" && (
|
||||
<button
|
||||
onClick={() => handleRetry(item.gid)}
|
||||
disabled={retrying.has(item.gid)}
|
||||
className="btn-ghost py-1 px-2 text-xs"
|
||||
>
|
||||
<RotateCcw className={`h-3 w-3 ${retrying.has(item.gid) ? "animate-spin" : ""}`} />
|
||||
Retry
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { SearchForm } from "@/components/search-form"
|
||||
import { Search } from "lucide-react"
|
||||
|
||||
export default function SearchPage() {
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6">
|
||||
<h1 className="text-xl font-bold">Buscar</h1>
|
||||
<p className="text-sm text-[var(--muted)]">Buscá en nhentai / e-hentai y encolá resultados</p>
|
||||
</div>
|
||||
<SearchForm />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { SubmitForm } from "@/components/submit-form"
|
||||
import { Send } from "lucide-react"
|
||||
|
||||
export default function SubmitPage() {
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6">
|
||||
<h1 className="text-xl font-bold">Enviar</h1>
|
||||
<p className="text-sm text-[var(--muted)]">Encolá URLs de nhentai / e-hentai para procesar</p>
|
||||
</div>
|
||||
<SubmitForm />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user