UX: lector publico, panel admin escondido, paginacion feed, auth activa
This commit is contained in:
@@ -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
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -176,4 +176,4 @@ export default async function PostDetailPage({
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
+17
-1
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user