UX: lector publico, panel admin escondido, paginacion feed, auth activa

This commit is contained in:
Renato
2026-08-04 12:33:57 +08:00
parent fcff8b0a7b
commit 3fded1eae6
6 changed files with 108 additions and 21 deletions
+17
View File
@@ -0,0 +1,17 @@
# --- worst-scan-web ---
# Backend API (pipeline)
API_BASE_URL=http://194.163.191.200:8080/api/v1
API_KEY=
# Web auth (vacío = sin login)
WEB_PASSWORD=
# Webhook validation
WEBHOOK_SECRET=
# DB path
DB_PATH=/home/ren/web/worst-scan-web/data/worst-scan.db
# Covers dir
COVERS_DIR=/home/ren/web/worst-scan-web/data/covers
+52 -6
View File
@@ -4,30 +4,63 @@ import { useState } from "react"
import { useGalleries } from "@/hooks/use-galleries"
import { GalleryGrid } from "@/components/gallery-grid"
import { FilterBar } from "@/components/filter-bar"
import type { GalleryItem } from "@/lib/types"
export default function FeedPage() {
const [status, setStatus] = useState("completed")
const { galleries, isLoading, mutate } = useGalleries(status, 1)
const [page, setPage] = useState(1)
const [all, setAll] = useState<GalleryItem[]>([])
const { galleries, meta, isLoading, mutate } = useGalleries(status, page)
// Acumula galleries de la página actual + las páginas anteriores (dedup por gid).
const items: GalleryItem[] = []
const seen = new Set<string>()
for (const g of all) {
if (!seen.has(g.gid)) {
seen.add(g.gid)
items.push(g)
}
}
for (const g of galleries) {
if (!seen.has(g.gid)) {
seen.add(g.gid)
items.push(g)
}
}
const total = meta?.total ?? 0
const hasMore = items.length < total
const handleStatus = (next: string) => {
setStatus(next)
setPage(1)
setAll([])
}
const loadMore = () => {
setAll(items)
setPage((p) => p + 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
{isLoading && items.length === 0
? "Cargando galleries..."
: `${galleries.length} galleries`}
: `${items.length}${total > items.length ? " de " + total : ""} galleries`}
</p>
</div>
<FilterBar
current={status}
onChange={setStatus}
onChange={handleStatus}
onRefresh={() => mutate()}
loading={isLoading}
/>
{isLoading && galleries.length === 0 ? (
{isLoading && items.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">
@@ -41,7 +74,20 @@ export default function FeedPage() {
))}
</div>
) : (
<GalleryGrid items={galleries} />
<>
<GalleryGrid items={items} />
{hasMore && (
<div className="mt-6 flex justify-center">
<button
onClick={loadMore}
disabled={isLoading}
className="btn-ghost border border-[var(--border)] px-6 py-2.5 text-sm"
>
{isLoading ? "Cargando..." : "Cargar más"}
</button>
</div>
)}
</>
)}
</div>
)
+17 -3
View File
@@ -1,10 +1,24 @@
import { cookies } from "next/headers"
import { Sidebar } from "@/components/layout/sidebar"
import { verifySession } from "@/lib/auth"
export default async function AppLayout({ children }: { children: React.ReactNode }) {
// La sidebar es el panel admin: solo visible con sesión válida.
// Los visitantes anónimos (que leen mangas públicos) no la ven.
let isAuthed = false
try {
const cookieStore = await cookies()
const session = cookieStore.get("session")?.value
if (session) {
const payload = await verifySession(session)
isAuthed = !!payload?.authenticated
}
} catch {}
export default function AppLayout({ children }: { children: React.ReactNode }) {
return (
<div className="min-h-screen bg-[var(--background)]">
<Sidebar />
<main className="w-full min-h-screen md:pl-56 p-0">
{isAuthed && <Sidebar />}
<main className={`w-full min-h-screen p-0 pb-14 md:pb-0 ${isAuthed ? "md:pl-56" : ""}`}>
{children}
</main>
</div>
+4 -10
View File
@@ -1,6 +1,8 @@
import Link from "next/link"
import { Image, Search } from "lucide-react"
import { Image } from "lucide-react"
// Header del sitio público: sin links al panel admin (que queda escondido).
// Solo navegación pública (logo -> home).
export default function PublicLayout({ children }: { children: React.ReactNode }) {
return (
<>
@@ -12,15 +14,7 @@ export default function PublicLayout({ children }: { children: React.ReactNode }
</div>
<span className="text-sm font-bold tracking-tight">worst-scan</span>
</Link>
<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>
</nav>
<nav className="flex items-center gap-2 sm:gap-4"></nav>
</div>
</header>
+1 -1
View File
@@ -176,4 +176,4 @@ export default async function PostDetailPage({
)}
</div>
)
}
}
+17 -1
View File
@@ -21,12 +21,28 @@ const publicPrefixes = [
]
// Páginas públicas del sitio (server-side, sin login).
const publicPages = ["/", "/p/", "/tag/"]
// "/" es MATCH EXACTO (startsWith("/") matchearía TODO).
// "/gallery/" incluye el lector: cualquiera puede LEER (GET); las acciones
// del panel (eliminar, etc.) quedan protegidas por el middleware vía método.
const publicPages = ["/p/", "/tag/", "/gallery/"]
// GET de lectura del proxy hacia el pipeline: públicos para que el lector
// funcione sin login. POST/DELETE/PATCH (mutaciones) siempre requieren sesión.
function isPublicProxyRead(pathname: string, method: string): boolean {
if (method !== "GET" && method !== "HEAD") return false
return (
pathname.startsWith("/api/proxy/galleries/") ||
pathname === "/api/proxy/status" ||
pathname === "/api/proxy/queue"
)
}
function isPublicPath(pathname: string, method: string): boolean {
if (pathname === "/") return true
if (publicExact.has(pathname)) return true
if (publicPrefixes.some((p) => pathname.startsWith(p))) return true
if (publicPages.some((p) => pathname.startsWith(p))) return true
if (isPublicProxyRead(pathname, method)) return true
// /api/posts: GET de listado público; mutaciones requieren sesión.
if (pathname === "/api/posts" && method === "GET") return true