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 { useGalleries } from "@/hooks/use-galleries"
|
||||||
import { GalleryGrid } from "@/components/gallery-grid"
|
import { GalleryGrid } from "@/components/gallery-grid"
|
||||||
import { FilterBar } from "@/components/filter-bar"
|
import { FilterBar } from "@/components/filter-bar"
|
||||||
|
import type { GalleryItem } from "@/lib/types"
|
||||||
|
|
||||||
export default function FeedPage() {
|
export default function FeedPage() {
|
||||||
const [status, setStatus] = useState("completed")
|
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 (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<h1 className="text-xl font-bold">Feed</h1>
|
<h1 className="text-xl font-bold">Feed</h1>
|
||||||
<p className="text-sm text-[var(--muted)]">
|
<p className="text-sm text-[var(--muted)]">
|
||||||
{isLoading && galleries.length === 0
|
{isLoading && items.length === 0
|
||||||
? "Cargando galleries..."
|
? "Cargando galleries..."
|
||||||
: `${galleries.length} galleries`}
|
: `${items.length}${total > items.length ? " de " + total : ""} galleries`}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<FilterBar
|
<FilterBar
|
||||||
current={status}
|
current={status}
|
||||||
onChange={setStatus}
|
onChange={handleStatus}
|
||||||
onRefresh={() => mutate()}
|
onRefresh={() => mutate()}
|
||||||
loading={isLoading}
|
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">
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||||
{Array.from({ length: 6 }).map((_, i) => (
|
{Array.from({ length: 6 }).map((_, i) => (
|
||||||
<div key={i} className="card flex gap-4 overflow-hidden p-0">
|
<div key={i} className="card flex gap-4 overflow-hidden p-0">
|
||||||
@@ -41,7 +74,20 @@ export default function FeedPage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,10 +1,24 @@
|
|||||||
|
import { cookies } from "next/headers"
|
||||||
import { Sidebar } from "@/components/layout/sidebar"
|
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 (
|
return (
|
||||||
<div className="min-h-screen bg-[var(--background)]">
|
<div className="min-h-screen bg-[var(--background)]">
|
||||||
<Sidebar />
|
{isAuthed && <Sidebar />}
|
||||||
<main className="w-full min-h-screen md:pl-56 p-0">
|
<main className={`w-full min-h-screen p-0 pb-14 md:pb-0 ${isAuthed ? "md:pl-56" : ""}`}>
|
||||||
{children}
|
{children}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import Link from "next/link"
|
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 }) {
|
export default function PublicLayout({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -12,15 +14,7 @@ export default function PublicLayout({ children }: { children: React.ReactNode }
|
|||||||
</div>
|
</div>
|
||||||
<span className="text-sm font-bold tracking-tight">worst-scan</span>
|
<span className="text-sm font-bold tracking-tight">worst-scan</span>
|
||||||
</Link>
|
</Link>
|
||||||
<nav className="flex items-center gap-2 sm:gap-4">
|
<nav className="flex items-center gap-2 sm:gap-4"></nav>
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
|
|||||||
@@ -176,4 +176,4 @@ export default async function PostDetailPage({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
+17
-1
@@ -21,12 +21,28 @@ const publicPrefixes = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
// Páginas públicas del sitio (server-side, sin login).
|
// 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 {
|
function isPublicPath(pathname: string, method: string): boolean {
|
||||||
|
if (pathname === "/") return true
|
||||||
if (publicExact.has(pathname)) return true
|
if (publicExact.has(pathname)) return true
|
||||||
if (publicPrefixes.some((p) => pathname.startsWith(p))) return true
|
if (publicPrefixes.some((p) => pathname.startsWith(p))) return true
|
||||||
if (publicPages.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.
|
// /api/posts: GET de listado público; mutaciones requieren sesión.
|
||||||
if (pathname === "/api/posts" && method === "GET") return true
|
if (pathname === "/api/posts" && method === "GET") return true
|
||||||
|
|||||||
Reference in New Issue
Block a user