"use client"; import { useEffect, useMemo, useState } from "react"; import Link from "next/link"; import EntryLink from "../components/EntryLink"; import { usePathname, useRouter } from "next/navigation"; import ZxdbBreadcrumbs from "@/app/zxdb/components/ZxdbBreadcrumbs"; type Item = { id: number; title: string; isXrated: number; genreId: number | null; genreName?: string | null; machinetypeId: number | null; machinetypeName?: string | null; languageId: string | null; languageName?: string | null; }; type SearchScope = "title" | "title_aliases" | "title_aliases_origins"; type Paged = { items: T[]; page: number; pageSize: number; total: number; }; type EntryFacets = { genres: { id: number; name: string; count: number }[]; languages: { id: string; name: string; count: number }[]; machinetypes: { id: number; name: string; count: number }[]; flags: { hasAliases: number; hasOrigins: number }; }; export default function EntriesExplorer({ initial, initialGenres, initialLanguages, initialMachines, initialFacets, initialUrlState, }: { initial?: Paged; initialGenres?: { id: number; name: string }[]; initialLanguages?: { id: string; name: string }[]; initialMachines?: { id: number; name: string }[]; initialFacets?: EntryFacets | null; initialUrlState?: { q: string; page: number; genreId: string | number | ""; languageId: string | ""; machinetypeId: string | number | ""; sort: "title" | "id_desc"; scope?: SearchScope; }; }) { const router = useRouter(); const pathname = usePathname(); const [q, setQ] = useState(initialUrlState?.q ?? ""); const [page, setPage] = useState(initial?.page ?? initialUrlState?.page ?? 1); const [loading, setLoading] = useState(false); const [data, setData] = useState | null>(initial ?? null); const [genres, setGenres] = useState<{ id: number; name: string }[]>(initialGenres ?? []); const [languages, setLanguages] = useState<{ id: string; name: string }[]>(initialLanguages ?? []); const [machines, setMachines] = useState<{ id: number; name: string }[]>(initialMachines ?? []); const [genreId, setGenreId] = useState( initialUrlState?.genreId === "" ? "" : initialUrlState?.genreId ? Number(initialUrlState.genreId) : "" ); const [languageId, setLanguageId] = useState(initialUrlState?.languageId ?? ""); const [machinetypeId, setMachinetypeId] = useState( initialUrlState?.machinetypeId === "" ? "" : initialUrlState?.machinetypeId ? Number(initialUrlState.machinetypeId) : "" ); const [sort, setSort] = useState<"title" | "id_desc">(initialUrlState?.sort ?? "id_desc"); const [scope, setScope] = useState(initialUrlState?.scope ?? "title"); const [facets, setFacets] = useState(initialFacets ?? null); const pageSize = 20; const totalPages = useMemo(() => (data ? Math.max(1, Math.ceil(data.total / data.pageSize)) : 1), [data]); const activeFilters = useMemo(() => { const chips: string[] = []; if (q) chips.push(`q: ${q}`); if (genreId !== "") { const name = genres.find((g) => g.id === Number(genreId))?.name ?? `#${genreId}`; chips.push(`genre: ${name}`); } if (languageId !== "") { const name = languages.find((l) => l.id === languageId)?.name ?? languageId; chips.push(`lang: ${name}`); } if (machinetypeId !== "") { const name = machines.find((m) => m.id === Number(machinetypeId))?.name ?? `#${machinetypeId}`; chips.push(`machine: ${name}`); } if (scope === "title_aliases") chips.push("scope: titles + aliases"); if (scope === "title_aliases_origins") chips.push("scope: titles + aliases + origins"); return chips; }, [q, genreId, languageId, machinetypeId, scope, genres, languages, machines]); function updateUrl(nextPage = page) { const params = new URLSearchParams(); if (q) params.set("q", q); params.set("page", String(nextPage)); if (genreId !== "") params.set("genreId", String(genreId)); if (languageId !== "") params.set("languageId", String(languageId)); if (machinetypeId !== "") params.set("machinetypeId", String(machinetypeId)); if (sort) params.set("sort", sort); if (scope !== "title") params.set("scope", scope); const qs = params.toString(); router.replace(qs ? `${pathname}?${qs}` : pathname); } async function fetchData(query: string, p: number, withFacets: boolean) { setLoading(true); try { const params = new URLSearchParams(); if (query) params.set("q", query); params.set("page", String(p)); params.set("pageSize", String(pageSize)); if (genreId !== "") params.set("genreId", String(genreId)); if (languageId !== "") params.set("languageId", String(languageId)); if (machinetypeId !== "") params.set("machinetypeId", String(machinetypeId)); if (sort) params.set("sort", sort); if (scope !== "title") params.set("scope", scope); if (withFacets) params.set("facets", "true"); const res = await fetch(`/api/zxdb/search?${params.toString()}`); if (!res.ok) throw new Error(`Failed: ${res.status}`); const json = await res.json(); setData(json); if (withFacets && json.facets) { setFacets(json.facets as EntryFacets); } } catch (e) { console.error(e); setData({ items: [], page: 1, pageSize, total: 0 }); } finally { setLoading(false); } } // Sync from SSR payload on navigation useEffect(() => { if (initial) { setData(initial); setPage(initial.page); } }, [initial]); // Client fetch when filters/paging/sort change; also keep URL in sync useEffect(() => { // Avoid extra fetch if SSR already matches this exact default state const initialPage = initial?.page ?? 1; if ( initial && page === initialPage && (initialUrlState?.q ?? "") === q && (initialUrlState?.genreId === "" ? "" : Number(initialUrlState?.genreId ?? "")) === (genreId === "" ? "" : Number(genreId)) && (initialUrlState?.languageId ?? "") === (languageId ?? "") && (initialUrlState?.machinetypeId === "" ? "" : Number(initialUrlState?.machinetypeId ?? "")) === (machinetypeId === "" ? "" : Number(machinetypeId)) && sort === (initialUrlState?.sort ?? "id_desc") && (initialUrlState?.scope ?? "title") === scope ) { updateUrl(page); return; } updateUrl(page); fetchData(q, page, true); // eslint-disable-next-line react-hooks/exhaustive-deps }, [page, genreId, languageId, machinetypeId, sort, scope]); // Load filter lists on mount only if not provided by server useEffect(() => { if (initialGenres && initialLanguages && initialMachines) return; async function loadLists() { try { const [g, l, m] = await Promise.all([ fetch("/api/zxdb/genres", { cache: "force-cache" }).then((r) => r.json()), fetch("/api/zxdb/languages", { cache: "force-cache" }).then((r) => r.json()), fetch("/api/zxdb/machinetypes", { cache: "force-cache" }).then((r) => r.json()), ]); setGenres(g.items ?? []); setLanguages(l.items ?? []); setMachines(m.items ?? []); } catch {} } loadLists(); }, [initialGenres, initialLanguages, initialMachines]); function onSubmit(e: React.FormEvent) { e.preventDefault(); setPage(1); updateUrl(1); fetchData(q, 1, true); } function resetFilters() { setQ(""); setGenreId(""); setLanguageId(""); setMachinetypeId(""); setSort("id_desc"); setScope("title"); setPage(1); } const prevHref = useMemo(() => { const params = new URLSearchParams(); if (q) params.set("q", q); params.set("page", String(Math.max(1, (data?.page ?? 1) - 1))); if (genreId !== "") params.set("genreId", String(genreId)); if (languageId !== "") params.set("languageId", String(languageId)); if (machinetypeId !== "") params.set("machinetypeId", String(machinetypeId)); if (sort) params.set("sort", sort); if (scope !== "title") params.set("scope", scope); return `/zxdb/entries?${params.toString()}`; }, [q, data?.page, genreId, languageId, machinetypeId, sort, scope]); const nextHref = useMemo(() => { const params = new URLSearchParams(); if (q) params.set("q", q); params.set("page", String(Math.max(1, (data?.page ?? 1) + 1))); if (genreId !== "") params.set("genreId", String(genreId)); if (languageId !== "") params.set("languageId", String(languageId)); if (machinetypeId !== "") params.set("machinetypeId", String(machinetypeId)); if (sort) params.set("sort", sort); if (scope !== "title") params.set("scope", scope); return `/zxdb/entries?${params.toString()}`; }, [q, data?.page, genreId, languageId, machinetypeId, sort, scope]); return (

Entries

{data ? `${data.total.toLocaleString()} results` : "Loading results..."}
{activeFilters.length > 0 && (
{activeFilters.map((chip) => ( {chip} ))}
)}
setQ(e.target.value)} />
{facets && (
Facets
)} {loading &&
Loading...
}
{data && data.items.length === 0 && !loading && (
No results.
)} {data && data.items.length > 0 && (
{data.items.map((it) => ( ))}
ID Title Genre Machine Language
{it.genreId != null ? ( it.genreName ? ( {it.genreName} ) : ( {it.genreId} ) ) : ( - )} {it.machinetypeId != null ? ( it.machinetypeName ? ( {it.machinetypeName} ) : ( {it.machinetypeId} ) ) : ( - )} {it.languageId ? ( it.languageName ? ( {it.languageName} ) : ( {it.languageId} ) ) : ( - )}
)}
Page {data?.page ?? 1} / {totalPages}
{ if (!data || data.page <= 1) return; e.preventDefault(); setPage((p) => Math.max(1, p - 1)); }} > Prev = totalPages) ? "disabled" : ""}`} aria-disabled={!data || data.page >= totalPages} href={nextHref} onClick={(e) => { if (!data || data.page >= totalPages) return; e.preventDefault(); setPage((p) => Math.min(totalPages, p + 1)); }} > Next

Browse Labels Browse Genres Browse Languages Browse Machines
); }