diff --git a/AGENTS.md b/AGENTS.md index bd2fc0b..fc7433b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -67,7 +67,7 @@ next-explorer/ - `RegisterDetail.tsx`: Client Component that renders a single register’s details, including modes, notes, and source modal. - `[hex]/page.tsx`: Dynamic route that renders details for a specific register by hex address. - `src/app/zxdb/`: ZXDB Explorer routes and client components. - - `page.tsx` + `ZxdbExplorer.tsx`: Search + filters with server-rendered initial content and ISR. + - `page.tsx`: ZXDB hub page linking to entries, releases, labels, etc. - `entries/[id]/page.tsx` + `EntryDetail.tsx`: Entry details (SSR initial data). - `releases/page.tsx` + `ReleasesExplorer.tsx`: Releases search + filters. - `labels/page.tsx`, `labels/[id]/page.tsx` + client: Labels search and detail. @@ -108,6 +108,25 @@ Comment what the code does, not what the agent has done. The documentation's pur - Use `type` for interfaces. - No `enum`. +### UI / Bootstrap Patterns + +The project uses the **Bootswatch Pulse** theme (purple primary) with `react-bootstrap` and `react-bootstrap-icons`. + +- **Always use react-bootstrap components** over raw HTML+className for Bootstrap elements: + - `Card`, `Table`, `Badge`, `Button`, `Alert`, `Form.Control`, `Form.Select`, `Form.Check`, `InputGroup`, `Spinner`, `Collapse` etc. + - Icons from `react-bootstrap-icons` (e.g. `Search`, `ChevronDown`, `Download`, `BoxArrowUpRight`). +- **Match existing patterns** — see `RegisterBrowser.tsx` and `Navbar.tsx` for canonical react-bootstrap usage. +- **Shared explorer components** in `src/components/explorer/`: + - `ExplorerLayout` — two-column layout (sidebar + content). + - `FilterSidebar` — `Card` wrapper with optional "Reset all filters" button. + - `FilterSection` — collapsible filter group with label, badge, and `Collapse` animation. + - `MultiSelectChips` — chip-toggle selector with optional collapsed summary mode. + - `Pagination` — prev/next with page counter and loading spinner. +- **Stale-while-revalidate pattern** — show previous results at reduced opacity during loading (`className={loading ? "opacity-50" : ""}`), never blank the screen. +- **Empty states** — only show a section/card if it has data. Do not render empty cards with "No X recorded" placeholders; omit them entirely. +- **Tables** — use react-bootstrap `` for data tables. Human-readable sizes (KB/MB) over raw bytes. Omit columns that add noise without value (e.g. MD5 hashes). +- **Alerts** — use `` for "no results" states with actionable suggestions (e.g. offering to broaden filters). + ### React / Next.js Patterns - **Server Components**: @@ -122,7 +141,7 @@ Comment what the code does, not what the agent has done. The documentation's pur - `RegisterDetail.tsx`: - Marked with `'use client'`. - Renders a single register with tabs for different access modes. - - ZXDB client components (e.g., `ZxdbExplorer.tsx`, `EntryDetail.tsx`, `labels/*`) receive initial data from the server and keep interactions on the client without blocking the first paint. + - ZXDB client components (e.g., `EntriesExplorer.tsx`, `EntryDetail.tsx`, `labels/*`) receive initial data from the server and keep interactions on the client without blocking the first paint. - **Dynamic Routing**: - Pages and API routes must await dynamic params in Next.js 15: diff --git a/src/app/zxdb/ZxdbExplorer.tsx b/src/app/zxdb/ZxdbExplorer.tsx deleted file mode 100644 index 314c662..0000000 --- a/src/app/zxdb/ZxdbExplorer.tsx +++ /dev/null @@ -1,271 +0,0 @@ -"use client"; - -import { useEffect, useMemo, useState } from "react"; -import Link from "next/link"; - -type Item = { - id: number; - title: string; - isXrated: number; - machinetypeId: number | null; - machinetypeName?: string | null; - languageId: string | null; - languageName?: string | null; -}; - -type Paged = { - items: T[]; - page: number; - pageSize: number; - total: number; -}; - -export default function ZxdbExplorer({ - initial, - initialGenres, - initialLanguages, - initialMachines, -}: { - initial?: Paged; - initialGenres?: { id: number; name: string }[]; - initialLanguages?: { id: string; name: string }[]; - initialMachines?: { id: number; name: string }[]; -}) { - const [q, setQ] = useState(""); - const [page, setPage] = useState(initial?.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(""); - const [languageId, setLanguageId] = useState(""); - const [machinetypeId, setMachinetypeId] = useState(""); - const [year, setYear] = useState(""); - const [sort, setSort] = useState<"title" | "id_desc">("id_desc"); - - const pageSize = 20; - const totalPages = useMemo(() => (data ? Math.max(1, Math.ceil(data.total / data.pageSize)) : 1), [data]); - - async function fetchData(query: string, p: number) { - 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 (year !== "") params.set("year", year); - if (sort) params.set("sort", sort); - const res = await fetch(`/api/zxdb/search?${params.toString()}`); - if (!res.ok) throw new Error(`Failed: ${res.status}`); - const json: Paged = await res.json(); - setData(json); - } catch (e) { - console.error(e); - setData({ items: [], page: 1, pageSize, total: 0 }); - } finally { - setLoading(false); - } - } - - useEffect(() => { - // When navigating via Next.js Links that change ?page=, SSR provides new `initial`. - // Sync local state from new SSR payload so the list and counter update immediately - // without an extra client fetch. - if (initial) { - setData(initial); - setPage(initial.page); - } - }, [initial]); - - useEffect(() => { - // Avoid immediate client fetch on first paint if server provided initial data for this exact state - const initialPage = initial?.page ?? 1; - if ( - initial && - page === initialPage && - q === "" && - genreId === "" && - languageId === "" && - machinetypeId === "" && - year === "" && - sort === "id_desc" - ) { - return; - } - fetchData(q, page); - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [page, genreId, languageId, machinetypeId, year, sort]); - - // 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); - fetchData(q, 1); - } - - return ( -
-

ZXDB Explorer

-
-
- setQ(e.target.value)} - /> -
-
- -
-
- -
-
- -
-
- -
-
- setYear(e.target.value)} - /> -
-
- -
- {loading && ( -
Loading...
- )} - - -
- {data && data.items.length === 0 && !loading && ( -
No results.
- )} - {data && data.items.length > 0 && ( -
-
- - - - - - - - - - {data.items.map((it) => ( - - - - - - - ))} - -
IDTitleMachineLanguage
{it.id} - {it.title} - - {it.machinetypeId != null ? ( - it.machinetypeName ? ( - {it.machinetypeName} - ) : ( - {it.machinetypeId} - ) - ) : ( - - - )} - - {it.languageId ? ( - it.languageName ? ( - {it.languageName} - ) : ( - {it.languageId} - ) - ) : ( - - - )} -
- - )} - - -
- - Page {data?.page ?? 1} / {totalPages} - -
- - Prev - - = totalPages) ? "disabled" : ""}`} - aria-disabled={!data || data.page >= totalPages} - href={`/zxdb?page=${Math.min(totalPages, (data?.page ?? 1) + 1)}`} - > - Next - -
-
- -
-
- Browse Labels - Browse Genres - Browse Languages - Browse Machines -
- - ); -} diff --git a/src/app/zxdb/entries/EntriesExplorer.tsx b/src/app/zxdb/entries/EntriesExplorer.tsx index 3e548f5..86f14f7 100644 --- a/src/app/zxdb/entries/EntriesExplorer.tsx +++ b/src/app/zxdb/entries/EntriesExplorer.tsx @@ -1,15 +1,21 @@ "use client"; -import { useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import Link from "next/link"; +import { Form, InputGroup, Button, Table, Alert, Badge } from "react-bootstrap"; +import { Search } from "react-bootstrap-icons"; import EntryLink from "../components/EntryLink"; import { usePathname, useRouter } from "next/navigation"; import ZxdbBreadcrumbs from "@/app/zxdb/components/ZxdbBreadcrumbs"; import ExplorerLayout from "@/components/explorer/ExplorerLayout"; import FilterSidebar from "@/components/explorer/FilterSidebar"; +import FilterSection from "@/components/explorer/FilterSection"; import MultiSelectChips from "@/components/explorer/MultiSelectChips"; +import Pagination from "@/components/explorer/Pagination"; +import useSearchFetch from "@/hooks/useSearchFetch"; const preferredMachineIds = [27, 26, 8, 9]; +const PAGE_SIZE = 20; type Item = { id: number; @@ -23,8 +29,6 @@ type Item = { languageName?: string | null; }; -type SearchScope = "title" | "title_aliases" | "title_aliases_origins"; - type Paged = { items: T[]; page: number; @@ -32,6 +36,8 @@ type Paged = { total: number; }; +type SearchScope = "title" | "title_aliases" | "title_aliases_origins"; + type EntryFacets = { genres: { id: number; name: string; count: number }[]; languages: { id: string; name: string; count: number }[]; @@ -39,6 +45,15 @@ type EntryFacets = { flags: { hasAliases: number; hasOrigins: number }; }; +function parseMachineIds(value?: string) { + if (!value) return preferredMachineIds.slice(); + const ids = value + .split(",") + .map((id) => Number(id.trim())) + .filter((id) => Number.isFinite(id) && id > 0); + return ids.length ? ids : preferredMachineIds.slice(); +} + export default function EntriesExplorer({ initial, initialGenres, @@ -62,26 +77,13 @@ export default function EntriesExplorer({ scope?: SearchScope; }; }) { - const parseMachineIds = (value?: string) => { - if (!value) return preferredMachineIds.slice(); - const ids = value - .split(",") - .map((id) => Number(id.trim())) - .filter((id) => Number.isFinite(id) && id > 0); - return ids.length ? ids : preferredMachineIds.slice(); - }; - const router = useRouter(); const pathname = usePathname(); + // -- Search state -- const [q, setQ] = useState(initialUrlState?.q ?? ""); const [appliedQ, setAppliedQ] = 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) : "" ); @@ -90,112 +92,95 @@ export default function EntriesExplorer({ 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 preferredMachineNames = useMemo(() => { - if (!machines.length) return preferredMachineIds.map((id) => `#${id}`); - return preferredMachineIds.map((id) => machines.find((m) => m.id === id)?.name ?? `#${id}`); - }, [machines]); + + // -- Filter lists -- + 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 ?? []); + + // Capture facets from the API response alongside paged results + const handleExtra = useCallback((json: Record) => { + if (json.facets) setFacets(json.facets as EntryFacets); + }, []); + + // -- Fetch with abort control -- + const { data, loading, error, fetch: doFetch, syncData } = useSearchFetch( + "/api/zxdb/search", + initial ?? null, + handleExtra, + ); + + // Skip initial fetch when SSR data already matches + const isFirstRender = useRef(true); + + const totalPages = useMemo( + () => (data ? Math.max(1, Math.ceil(data.total / data.pageSize)) : 1), + [data], + ); + + // -- URL helpers -- + const buildParams = useCallback( + (p: number) => { + const params = new URLSearchParams(); + if (appliedQ) params.set("q", appliedQ); + params.set("page", String(p)); + if (genreId !== "") params.set("genreId", String(genreId)); + if (languageId !== "") params.set("languageId", String(languageId)); + if (machinetypeIds.length > 0) params.set("machinetypeId", machinetypeIds.join(",")); + if (sort) params.set("sort", sort); + if (scope !== "title") params.set("scope", scope); + return params; + }, + [appliedQ, genreId, languageId, machinetypeIds, sort, scope], + ); + + const buildHref = useCallback( + (p: number) => { + const qs = buildParams(p).toString(); + return qs ? `${pathname}?${qs}` : pathname; + }, + [buildParams, pathname], + ); + + // -- Derived -- const orderedMachines = useMemo(() => { const seen = new Set(preferredMachineIds); const preferred = preferredMachineIds.map((id) => machines.find((m) => m.id === id)).filter(Boolean) as { id: number; name: string }[]; const rest = machines.filter((m) => !seen.has(m.id)); return [...preferred, ...rest]; }, [machines]); - const machineOptions = useMemo(() => orderedMachines.map((m) => ({ id: m.id, label: m.name })), [orderedMachines]); - 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 (appliedQ) chips.push(`q: ${appliedQ}`); - 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 (machinetypeIds.length > 0) { - const names = machinetypeIds.map((id) => machines.find((m) => m.id === id)?.name ?? `#${id}`); - chips.push(`machine: ${names.join(", ")}`); - } - if (scope === "title_aliases") chips.push("scope: titles + aliases"); - if (scope === "title_aliases_origins") chips.push("scope: titles + aliases + origins"); - return chips; - }, [appliedQ, genreId, languageId, machinetypeIds, scope, genres, languages, machines]); + const machineOptions = useMemo( + () => orderedMachines.map((m) => ({ id: m.id, label: m.name })), + [orderedMachines], + ); - function updateUrl(nextPage = page) { - const params = new URLSearchParams(); - if (appliedQ) params.set("q", appliedQ); - params.set("page", String(nextPage)); - if (genreId !== "") params.set("genreId", String(genreId)); - if (languageId !== "") params.set("languageId", String(languageId)); - if (machinetypeIds.length > 0) params.set("machinetypeId", machinetypeIds.join(",")); - if (sort) params.set("sort", sort); - if (scope !== "title") params.set("scope", scope); - const qs = params.toString(); - router.replace(qs ? `${pathname}?${qs}` : pathname); - } + const hasNonDefaultMachineFilter = machinetypeIds.join(",") !== preferredMachineIds.join(",") || + machinetypeIds.length !== machines.length; - 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 (machinetypeIds.length > 0) params.set("machinetypeId", machinetypeIds.join(",")); - 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 + // -- Fetch + URL sync on filter/page changes -- 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 ?? "") === appliedQ && - (initialUrlState?.genreId === "" ? "" : Number(initialUrlState?.genreId ?? "")) === (genreId === "" ? "" : Number(genreId)) && - (initialUrlState?.languageId ?? "") === (languageId ?? "") && - parseMachineIds(initialUrlState?.machinetypeId).join(",") === machinetypeIds.join(",") && - sort === (initialUrlState?.sort ?? "id_desc") && - (initialUrlState?.scope ?? "title") === scope - ) { - updateUrl(page); + if (isFirstRender.current) { + isFirstRender.current = false; + router.replace(buildHref(page), { scroll: false }); return; } - updateUrl(page); - fetchData(appliedQ, page, true); + + router.replace(buildHref(page), { scroll: false }); + + const params = buildParams(page); + params.set("pageSize", String(PAGE_SIZE)); + params.set("facets", "true"); + doFetch(params); // eslint-disable-next-line react-hooks/exhaustive-deps }, [page, genreId, languageId, machinetypeIds, sort, scope, appliedQ]); - // Load filter lists on mount only if not provided by server + // Sync SSR data when navigating (browser back/forward) + useEffect(() => { + if (initial) syncData(initial); + }, [initial, syncData]); + + // Load filter lists on mount if not provided by server useEffect(() => { if (initialGenres && initialLanguages && initialMachines) return; async function loadLists() { @@ -208,7 +193,7 @@ export default function EntriesExplorer({ setGenres(g.items ?? []); setLanguages(l.items ?? []); setMachines(m.items ?? []); - } catch {} + } catch { /* filter lists are non-critical */ } } loadLists(); }, [initialGenres, initialLanguages, initialMachines]); @@ -219,6 +204,11 @@ export default function EntriesExplorer({ setPage(1); } + function searchAllMachines() { + setMachinetypeIds(machineOptions.map((m) => m.id)); + setPage(1); + } + function resetFilters() { setQ(""); setAppliedQ(""); @@ -230,30 +220,6 @@ export default function EntriesExplorer({ setPage(1); } - const prevHref = useMemo(() => { - const params = new URLSearchParams(); - if (appliedQ) params.set("q", appliedQ); - 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 (machinetypeIds.length > 0) params.set("machinetypeId", machinetypeIds.join(",")); - if (sort) params.set("sort", sort); - if (scope !== "title") params.set("scope", scope); - return `/zxdb/entries?${params.toString()}`; - }, [appliedQ, data?.page, genreId, languageId, machinetypeIds, sort, scope]); - - const nextHref = useMemo(() => { - const params = new URLSearchParams(); - if (appliedQ) params.set("q", appliedQ); - 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 (machinetypeIds.length > 0) params.set("machinetypeId", machinetypeIds.join(",")); - if (sort) params.set("sort", sort); - if (scope !== "title") params.set("scope", scope); - return `/zxdb/entries?${params.toString()}`; - }, [appliedQ, data?.page, genreId, languageId, machinetypeIds, sort, scope]); - return (
-
-
- - + + + setQ(e.target.value)} /> -
-
- -
-
- - -
-
- - -
-
- + + + + { setMachinetypeIds((current) => { const next = new Set(current); @@ -316,148 +283,158 @@ export default function EntriesExplorer({ next.add(id); } const order = machineOptions.map((item) => item.id); - return order.filter((value) => next.has(value)); + const filtered = order.filter((value) => next.has(value)); + return filtered.length ? filtered : preferredMachineIds.slice(); }); setPage(1); }} /> -
Preferred: {preferredMachineNames.join(", ")}
-
-
- - -
-
- - -
- {facets && ( -
-
Facets
-
- - + + Origins {facets.flags.hasOrigins} +
-
+ )} - {loading &&
Loading...
} -
+ + {error && {error}} + )} > - {data && data.items.length === 0 && !loading && ( -
No results.
- )} - {data && data.items.length > 0 && ( -
- - - - - - - - - - - - {data.items.map((it) => ( - - - - - - +
+ {data && data.items.length === 0 && !loading && ( + + No results found. + {hasNonDefaultMachineFilter && ( + + {" "}Filtering by{" "} + + {machinetypeIds + .map((id) => machines.find((m) => m.id === id)?.name ?? `#${id}`) + .join(", ")} + + {" "}—{" "} + + search all machines + ? + + )} + + )} + {data && data.items.length > 0 && ( +
+
IDTitleGenreMachineLanguage
- {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} - ) - ) : ( - - - )} -
+ + + + + + + - ))} - -
IDTitleGenreMachineLanguage
-
- )} + + + {data.items.map((it) => ( + + + + + {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 - -
-
+
diff --git a/src/app/zxdb/entries/[id]/EntryDetail.tsx b/src/app/zxdb/entries/[id]/EntryDetail.tsx index f115928..0628418 100644 --- a/src/app/zxdb/entries/[id]/EntryDetail.tsx +++ b/src/app/zxdb/entries/[id]/EntryDetail.tsx @@ -2,6 +2,8 @@ import { useState, useMemo } from "react"; import Link from "next/link"; +import { Card, Table, Badge } from "react-bootstrap"; +import { BoxArrowUpRight, Download, Eye } from "react-bootstrap-icons"; import ZxdbBreadcrumbs from "@/app/zxdb/components/ZxdbBreadcrumbs"; import FileViewer from "@/components/FileViewer"; @@ -75,7 +77,6 @@ export type EntryDetailData = { issue: { id: number; magazineId: number | null; magazineTitle: string | null } | null; date: { year: number | null; month: number | null; day: number | null }; }[]; - // extra fields for richer details maxPlayers?: number; availabletypeId?: string | null; withoutLoadScreen?: number; @@ -89,7 +90,6 @@ export type EntryDetailData = { comments: string | null; type: { id: number; name: string }; }[]; - // Flat downloads by entry_id downloadsFlat?: { id: number; link: string; @@ -131,7 +131,6 @@ export type EntryDetailData = { localLink?: string | null; }[]; }[]; - // Additional relationships aliases?: { releaseSeq: number; languageId: string; title: string }[]; webrefs?: { link: string; languageId: string; website: { id: number; name: string; link?: string | null } }[]; magazineRefs?: { @@ -156,6 +155,17 @@ export type EntryDetailData = { }[]; }; +function formatSize(bytes: number | null) { + if (bytes == null) return null; + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +function FileName({ link }: { link: string }) { + return {link.split("/").pop() || link}; +} + export default function EntryDetailClient({ data }: { data: EntryDetailData | null }) { const [viewer, setViewer] = useState<{ url: string; title: string } | null>(null); @@ -173,6 +183,21 @@ export default function EntryDetailClient({ data }: { data: EntryDetailData | nu if (!data) return
Not found
; + const hasDownloads = groupedDownloads.length > 0; + const hasReleases = data.releases && data.releases.length > 0; + const hasOrigins = data.origins && data.origins.length > 0; + const hasRelations = data.relations && data.relations.length > 0; + const hasTags = data.tags && data.tags.length > 0; + const hasPorts = data.ports && data.ports.length > 0; + const hasRemakes = data.remakes && data.remakes.length > 0; + const hasScores = data.scores && data.scores.length > 0; + const hasNotes = data.notes && data.notes.length > 0; + const hasAliases = data.aliases && data.aliases.length > 0; + const hasLicenses = data.licenses && data.licenses.length > 0; + const hasWebrefs = data.webrefs && data.webrefs.length > 0; + const hasMagRefs = data.magazineRefs && data.magazineRefs.length > 0; + const hasFiles = data.files && data.files.length > 0; + return (
-
+

{data.title}

{data.genre.name && ( @@ -200,719 +225,602 @@ export default function EntryDetailClient({ data }: { data: EntryDetailData | nu {data.machinetype.name} )} - {data.isXrated ? 18+ : null} + {data.isXrated ? 18+ : null}
-
+
+ {/* Left column: summary + metadata */}
-
-
-
Entry Summary
-
- - + + + Entry Summary +
+ + + + + + + + + + + + + + + + + + {typeof data.maxPlayers !== "undefined" && data.maxPlayers !== null && ( - - + + + )} + {typeof data.availabletypeId !== "undefined" && data.availabletypeId !== null && ( - - + + - - - - - - - - - - - - - {typeof data.maxPlayers !== "undefined" && ( - - - - - )} - {typeof data.availabletypeId !== "undefined" && ( - - - - - )} - {typeof data.withoutLoadScreen !== "undefined" && ( - - - - - )} - {typeof data.withoutInlay !== "undefined" && ( - - - - - )} - {typeof data.issueId !== "undefined" && ( - - - - - )} - -
ID{data.id}
Machine + {data.machinetype.id != null ? ( + data.machinetype.name ? ( + {data.machinetype.name} + ) : #{data.machinetype.id} + ) : -} +
Language + {data.language.id ? ( + data.language.name ? ( + {data.language.name} + ) : {data.language.id} + ) : -} +
Genre + {data.genre.id ? ( + data.genre.name ? ( + {data.genre.name} + ) : #{data.genre.id} + ) : -} +
ID{data.id}Max Players{data.maxPlayers}
Title{data.title}Available Type{data.availabletypeId}
Machine - {data.machinetype.id != null ? ( - data.machinetype.name ? ( - {data.machinetype.name} - ) : ( - #{data.machinetype.id} - ) - ) : ( - - - )} -
Language - {data.language.id ? ( - data.language.name ? ( - {data.language.name} - ) : ( - {data.language.id} - ) - ) : ( - - - )} -
Genre - {data.genre.id ? ( - data.genre.name ? ( - {data.genre.name} - ) : ( - #{data.genre.id} - ) - ) : ( - - - )} -
Max Players{data.maxPlayers}
Available Type{data.availabletypeId ?? -}
Without Load Screen{data.withoutLoadScreen ? "Yes" : "No"}
Without Inlay{data.withoutInlay ? "Yes" : "No"}
Issue{data.issueId ? #{data.issueId} : -}
-
-
-
+ )} + + + + -
-
-
People
-
-
+ + + People +
+
Authors
- {data.authors.length === 0 &&
Unknown
} - {data.authors.length > 0 && ( -
    - {data.authors.map((a) => ( -
  • - {a.name} -
  • - ))} -
- )} + {data.authors.length === 0 && Unknown} + {data.authors.map((a) => ( +
+ {a.name} +
+ ))}
-
+
Publishers
- {data.publishers.length === 0 &&
Unknown
} - {data.publishers.length > 0 && ( -
    - {data.publishers.map((p) => ( -
  • - {p.name} -
  • - ))} -
- )} + {data.publishers.length === 0 && Unknown} + {data.publishers.map((p) => ( +
+ {p.name} +
+ ))}
-
-
+ + -
-
-
Magazine References
- {(!data.magazineRefs || data.magazineRefs.length === 0) &&
No magazine references recorded
} - {data.magazineRefs && data.magazineRefs.length > 0 && ( -
- - - - - - - - + {hasMagRefs && ( + + + Magazine References +
MagazineIssueTypePageScore
+ + + + + + + + + + {data.magazineRefs!.map((m) => ( + + + + + - - - {data.magazineRefs.map((m) => ( - - - - - - - - ))} - -
MagazineIssueTypeScore
+ {m.magazineId ? ( + {m.magazineName} + ) : {m.magazineName}} + + + {m.issue.dateYear ? `${m.issue.dateYear} ` : ""} + {m.issue.number ? `#${m.issue.number}` : ""} + {m.issue.special ? ` (${m.issue.special})` : ""} + + {m.referencetypeName}{m.scoreGroup || "-"}
- {m.magazineId ? ( - {m.magazineName} - ) : ( - {m.magazineName} - )} - - - {m.issue.dateYear ? `${m.issue.dateYear} ` : ""} - {m.issue.number ? `#${m.issue.number}` : ""} - {m.issue.special ? ` (${m.issue.special})` : ""} - - {m.referencetypeName}{m.page > 0 ? m.page : "-"}{m.scoreGroup || "-"}
-
- )} -
-
+ ))} + + + + + )} -
-
+ {hasScores && ( + + + Scores + + + + + + + + + + {data.scores!.map((s, idx) => ( + + + + + + ))} + +
WebsiteScoreVotes
{s.website.name ?? `#${s.website.id}`}{s.score}{s.votes}
+
+
+ )} + + {hasWebrefs && ( + + + Web Links +
+ {data.webrefs!.map((w, idx) => ( +
+ + {w.website.name} + + {w.languageId && {w.languageId}} +
+ ))} +
+
+
+ )} + + + Permalink - Back to Explorer -
-
+ Back to Entries + +
+ {/* Right column: downloads + detail sections */}
-
-
-
Downloads
- {groupedDownloads.length === 0 &&
No downloads
} - {groupedDownloads.map(([type, items]) => ( -
-
{type}
-
- - - - - - - - - - - - - {items?.map((d) => { - const isHttp = d.link.startsWith("http://") || d.link.startsWith("https://"); - const fileName = d.link.split("/").pop() || "file"; - const canPreview = d.localLink && fileName.toLowerCase().match(/\.(txt|nfo|png|jpg|jpeg|gif|pdf)$/); - return ( - - + + + ); + })} + +
LinkSizeMD5FlagsDetailsComments
-
-
+ {hasDownloads && ( + + + Downloads + {groupedDownloads.map(([type, items]) => ( +
+
{type}
+
+ + + + + + + + + + + {items?.map((d) => { + const isHttp = d.link.startsWith("http://") || d.link.startsWith("https://"); + const fileName = d.link.split("/").pop() || "file"; + const canPreview = d.localLink && fileName.toLowerCase().match(/\.(txt|nfo|png|jpg|jpeg|gif|pdf)$/); + return ( + + - - - - - - - ); - })} - -
FileSizeTagsComments
+
{isHttp ? ( - {d.link} + + + ) : ( - {d.link} - )} - {canPreview && ( - + )} +
+ {d.localLink && ( + + Local + + )} + {canPreview && ( + + )} +
- {d.localLink && ( - - Local Mirror - - )} - -
{typeof d.size === "number" ? d.size.toLocaleString() : "-"}{d.md5 ?? "-"} -
- {d.isDemo ? Demo : null} - {d.scheme.name ? {d.scheme.name} : null} - {d.source.name ? {d.source.name} : null} - {d.case.name ? {d.case.name} : null} -
-
-
- {d.language.name && ( - {d.language.name} - )} - {d.machinetype.name && ( - {d.machinetype.name} - )} - {typeof d.year === "number" ? {d.year} : null} - - rel #{d.releaseSeq} - -
-
{d.comments ?? ""}
-
-
- ))} -
-
- -
-
-
Releases
- {(!data.releases || data.releases.length === 0) &&
No releases recorded
} - {data.releases && data.releases.length > 0 && ( -
- - - - - - - - - - {data.releases.map((r) => ( - - - - - - ))} - -
Release #YearDownloads
- #{r.releaseSeq} - {r.year ?? -}{r.downloads.length}
-
- )} -
-
- -
-
-
Origins
- {(!data.origins || data.origins.length === 0) &&
No origins recorded
} - {data.origins && data.origins.length > 0 && ( -
- - - - - - - - - - - - {data.origins.map((o, idx) => { - const dateParts = [o.date.year, o.date.month, o.date.day] - .filter((v) => typeof v === "number" && Number.isFinite(v)) - .map((v, i) => (i === 0 ? String(v) : String(v).padStart(2, "0"))); - const dateText = dateParts.length ? dateParts.join("/") : "-"; - return ( - - - - - + + - - - ); - })} - -
TypeTitlePublicationIssueDate
{o.type.name ?? o.type.id}{o.libraryTitle}{o.publication ?? -} - {o.issue ? ( -
- Issue #{o.issue.id} - {o.issue.magazineId != null && ( - - {o.issue.magazineTitle ?? `Magazine #${o.issue.magazineId}`} +
{formatSize(d.size) ?? "-"} +
+ {d.isDemo && Demo} + {d.scheme.name && {d.scheme.name}} + {d.source.name && {d.source.name}} + {d.case.name && {d.case.name}} + {d.language.name && ( + {d.language.name} + )} + {d.machinetype.name && ( + {d.machinetype.name} + )} + {typeof d.year === "number" && {d.year}} + + rel #{d.releaseSeq} - )} -
- ) : o.containerId ? ( - Container #{o.containerId} - ) : ( - - - )} -
{dateText}
-
- )} -
-
+ +
{d.comments ?? ""}
+
+
+ ))} + + + )} -
-
-
Relations
- {(!data.relations || data.relations.length === 0) &&
No relations recorded
} - {data.relations && data.relations.length > 0 && ( -
- - - - - - + {hasReleases && ( + + + Releases +
DirectionTypeEntry
+ + + + + + + + + + {data.releases!.map((r) => ( + + + + + - - - {data.relations.map((r, idx) => ( - - - + ))} + +
Release #YearDownloadsComments
+ #{r.releaseSeq} + {r.year ?? -}{r.downloads.length}{r.comments ?? ""}
{r.direction === "from" ? "From" : "To"}{r.type.name ?? r.type.id}
+ + + )} + + {hasOrigins && ( + + + Origins + + + + + + + + + + + + {data.origins!.map((o, idx) => { + const dateParts = [o.date.year, o.date.month, o.date.day] + .filter((v) => typeof v === "number" && Number.isFinite(v)) + .map((v, i) => (i === 0 ? String(v) : String(v).padStart(2, "0"))); + const dateText = dateParts.length ? dateParts.join("/") : "-"; + return ( + + + + + - ))} - -
TypeTitlePublicationIssueDate
{o.type.name ?? o.type.id}{o.libraryTitle}{o.publication ?? -} - - {r.entry.title ?? `Entry #${r.entry.id}`} - + {o.issue ? ( + + {o.issue.magazineTitle ?? `Issue #${o.issue.id}`} + + ) : o.containerId ? ( + Container #{o.containerId} + ) : -} {dateText}
-
- )} -
-
+ ); + })} + + + + + )} -
-
-
Tags / Members
- {(!data.tags || data.tags.length === 0) &&
No tags recorded
} - {data.tags && data.tags.length > 0 && ( -
- - - - - - - - + {hasRelations && ( + + + Relations +
TagTypeCategoryMember SeqLinks
+ + + + + + + + + {data.relations!.map((r, idx) => ( + + + + - - - {data.tags.map((t) => ( - - - - - - - - ))} - -
DirTypeEntry
{r.direction === "from" ? "From" : "To"}{r.type.name ?? r.type.id} + + {r.entry.title ?? `Entry #${r.entry.id}`} + +
{t.name}{t.type.name ?? t.type.id}{t.category.name ?? (t.category.id != null ? `#${t.category.id}` : "-")}{t.memberSeq ?? -} -
- {t.link && ( - Link - )} - {t.comments && {t.comments}} - {!t.link && !t.comments && -} -
-
-
- )} -
-
+ ))} + + + + + )} -
-
-
Ports
- {(!data.ports || data.ports.length === 0) &&
No ports recorded
} - {data.ports && data.ports.length > 0 && ( -
- - - - - - - + {hasTags && ( + + + Tags / Members +
TitlePlatformOfficialLink
+ + + + + + + + + + {data.tags!.map((t) => ( + + + + + - - - {data.ports.map((p) => ( - - - - - +
TagTypeCategoryLinks
{t.name}{t.type.name ?? t.type.id}{t.category.name ?? (t.category.id != null ? `#${t.category.id}` : "-")} + {t.link ? ( + + Link + + ) : t.comments ? ( + {t.comments} + ) : -} +
{p.title ?? -}{p.platform.name ?? `#${p.platform.id}`}{p.isOfficial ? "Yes" : "No"} - {p.linkSystem ? ( - Link - ) : ( - - + ))} +
+ + + )} + + {hasPorts && ( + + + Ports + + + + + + + + + + + {data.ports!.map((p) => ( + + + + + + + ))} + +
TitlePlatformOfficialLink
{p.title ?? -}{p.platform.name ?? `#${p.platform.id}`}{p.isOfficial ? "Yes" : "No"} + {p.linkSystem ? ( + + Link + + ) : -} +
+
+
+ )} + + {hasRemakes && ( + + + Remakes + + + + + + + + + + + {data.remakes!.map((r) => ( + + + + + + + ))} + +
TitlePlatformsYearsNotes
+ {r.fileLink ? ( + + {r.title} + + ) : r.title} + {r.platforms ?? -}{r.remakeYears ?? -}{r.remakeStatus ?? r.authors ?? -}
+
+
+ )} + + {hasNotes && ( + + + Notes + {data.notes!.map((n) => ( +
+ {n.type.name ?? n.type.id} + {n.text} +
+ ))} +
+
+ )} + + {hasAliases && ( + + + Aliases + + + + + + + + + + {data.aliases!.map((a, idx) => ( + + + + + + ))} + +
Release #LanguageTitle
+ #{a.releaseSeq} + {a.languageId}{a.title}
+
+
+ )} + + {hasLicenses && ( + + + Licenses + + + + + + + + + + + {data.licenses!.map((l) => ( + + + + + - - ))} - -
NameTypeOfficialLinks
{l.name}{l.type.name ?? l.type.id}{l.isOfficial ? "Yes" : "No"} +
+ {l.linkWikipedia && ( + Wikipedia )} -
-
- )} -
-
- -
-
-
Remakes
- {(!data.remakes || data.remakes.length === 0) &&
No remakes recorded
} - {data.remakes && data.remakes.length > 0 && ( -
- - - - - - - - - - - - {data.remakes.map((r) => ( - - - - - - - - ))} - -
TitlePlatformsYearsFileNotes
{r.title}{r.platforms ?? -}{r.remakeYears ?? -} - {r.fileLink ? ( - File - ) : ( - - + {l.linkSite && ( + Site )} - {r.remakeStatus ?? r.authors ?? -}
-
- )} -
-
- -
-
-
Scores
- {(!data.scores || data.scores.length === 0) &&
No scores recorded
} - {data.scores && data.scores.length > 0 && ( -
- - - - - - + {!l.linkWikipedia && !l.linkSite && -} + + - - - {data.scores.map((s, idx) => ( - - - - - - ))} - -
WebsiteScoreVotes
{s.website.name ?? `#${s.website.id}`}{s.score}{s.votes}
-
- )} -
-
+ ))} + + + + + )} -
-
-
Notes
- {(!data.notes || data.notes.length === 0) &&
No notes recorded
} - {data.notes && data.notes.length > 0 && ( -
- - - - - - - - - {data.notes.map((n) => ( - - - - - ))} - -
TypeText
{n.type.name ?? n.type.id}{n.text}
-
- )} -
-
- -
-
-
Aliases
- {(!data.aliases || data.aliases.length === 0) &&
No aliases
} - {data.aliases && data.aliases.length > 0 && ( -
- - - - - - - - - - {data.aliases.map((a, idx) => ( - + {hasFiles && ( + + + Files +
Release #LanguageTitle
+ + + + + + + + + + {data.files!.map((f) => { + const isHttp = f.link.startsWith("http://") || f.link.startsWith("https://"); + return ( + + - - + + - ))} - -
TypeLinkSizeComments
{f.type.name} - #{a.releaseSeq} + {isHttp ? ( + + + + ) : } {a.languageId}{a.title}{formatSize(f.size) ?? "-"}{f.comments ?? ""}
-
- )} -
-
+ ); + })} + + + + + )} -
-
-
Licenses
- {(!data.licenses || data.licenses.length === 0) &&
No licenses linked
} - {data.licenses && data.licenses.length > 0 && ( -
- - - - - - - - - - - {data.licenses.map((l) => ( - - - - - - - ))} - -
NameTypeOfficialLinks
{l.name}{l.type.name ?? l.type.id}{l.isOfficial ? "Yes" : "No"} -
- {l.linkWikipedia && ( - Wikipedia - )} - {l.linkSite && ( - Site - )} - {!l.linkWikipedia && !l.linkSite && -} -
-
-
- )} -
-
- -
-
-
Web links
- {(!data.webrefs || data.webrefs.length === 0) &&
No web links
} - {data.webrefs && data.webrefs.length > 0 && ( -
- - - - - - - - - - {data.webrefs.map((w, idx) => ( - - - - - - ))} - -
WebsiteLanguageURL
- {w.website.link ? ( - {w.website.name} - ) : ( - {w.website.name} - )} - {w.languageId} - {w.link} -
-
- )} -
-
- -
-
-
Files
- {(!data.files || data.files.length === 0) &&
No files linked
} - {data.files && data.files.length > 0 && ( -
- - - - - - - - - - - - {data.files.map((f) => { - const isHttp = f.link.startsWith("http://") || f.link.startsWith("https://"); - return ( - - - - - - - - ); - })} - -
TypeLinkSizeMD5Comments
{f.type.name} - {isHttp ? ( - {f.link} - ) : ( - {f.link} - )} - {f.size != null ? new Intl.NumberFormat().format(f.size) : "-"}{f.md5 ?? "-"}{f.comments ?? ""}
-
- )} -
-
+ {/* Show a summary when there's very little data */} + {!hasDownloads && !hasReleases && !hasOrigins && !hasRelations && ( + + + No downloads, releases, or related data found for this entry. + + + )}
{viewer && ( diff --git a/src/app/zxdb/genres/GenresSearch.tsx b/src/app/zxdb/genres/GenresSearch.tsx index 6f16bf9..50e338d 100644 --- a/src/app/zxdb/genres/GenresSearch.tsx +++ b/src/app/zxdb/genres/GenresSearch.tsx @@ -1,9 +1,10 @@ "use client"; -import { useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import Link from "next/link"; import { useRouter } from "next/navigation"; import ZxdbBreadcrumbs from "@/app/zxdb/components/ZxdbBreadcrumbs"; +import Pagination from "@/components/explorer/Pagination"; type Genre = { id: number; name: string }; type Paged = { items: T[]; page: number; pageSize: number; total: number }; @@ -30,6 +31,13 @@ export default function GenresSearch({ initial, initialQ }: { initial?: Paged { + const params = new URLSearchParams(); + if (q) params.set("q", q); + params.set("page", String(p)); + return `/zxdb/genres?${params.toString()}`; + }, [q]); + return (
- setQ(e.target.value)} /> + setQ(e.target.value)} />
@@ -90,25 +98,12 @@ export default function GenresSearch({ initial, initialQ }: { initial?: Paged
-
- Page {data?.page ?? 1} / {totalPages} -
- { const p = new URLSearchParams(); if (q) p.set("q", q); p.set("page", String(Math.max(1, (data?.page ?? 1) - 1))); return p.toString(); })()}`} - > - Prev - - = totalPages) ? "disabled" : ""}`} - aria-disabled={!data || data.page >= totalPages} - href={`/zxdb/genres?${(() => { const p = new URLSearchParams(); if (q) p.set("q", q); p.set("page", String(Math.min(totalPages, (data?.page ?? 1) + 1))); return p.toString(); })()}`} - > - Next - -
-
+ router.push(buildHref(p))} + />
); } diff --git a/src/app/zxdb/labels/LabelsSearch.tsx b/src/app/zxdb/labels/LabelsSearch.tsx index bb3cce4..39d6ca6 100644 --- a/src/app/zxdb/labels/LabelsSearch.tsx +++ b/src/app/zxdb/labels/LabelsSearch.tsx @@ -1,9 +1,10 @@ "use client"; -import { useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import Link from "next/link"; import { useRouter } from "next/navigation"; import ZxdbBreadcrumbs from "@/app/zxdb/components/ZxdbBreadcrumbs"; +import Pagination from "@/components/explorer/Pagination"; type Label = { id: number; name: string; labeltypeId: string | null }; type Paged = { items: T[]; page: number; pageSize: number; total: number }; @@ -14,12 +15,10 @@ export default function LabelsSearch({ initial, initialQ }: { initial?: Paged | null>(initial ?? null); const totalPages = useMemo(() => (data ? Math.max(1, Math.ceil(data.total / data.pageSize)) : 1), [data]); - // Sync incoming SSR payload on navigation (e.g., when clicking Prev/Next Links) useEffect(() => { if (initial) setData(initial); }, [initial]); - // Keep input in sync with URL q on navigation useEffect(() => { setQ(initialQ ?? ""); }, [initialQ]); @@ -32,6 +31,13 @@ export default function LabelsSearch({ initial, initialQ }: { initial?: Paged { + const params = new URLSearchParams(); + if (q) params.set("q", q); + params.set("page", String(p)); + return `/zxdb/labels?${params.toString()}`; + }, [q]); + return (
- setQ(e.target.value)} /> + setQ(e.target.value)} />
@@ -96,25 +102,12 @@ export default function LabelsSearch({ initial, initialQ }: { initial?: Paged
-
- Page {data?.page ?? 1} / {totalPages} -
- { const p = new URLSearchParams(); if (q) p.set("q", q); p.set("page", String(Math.max(1, (data?.page ?? 1) - 1))); return p.toString(); })()}`} - > - Prev - - = totalPages) ? "disabled" : ""}`} - aria-disabled={!data || data.page >= totalPages} - href={`/zxdb/labels?${(() => { const p = new URLSearchParams(); if (q) p.set("q", q); p.set("page", String(Math.min(totalPages, (data?.page ?? 1) + 1))); return p.toString(); })()}`} - > - Next - -
-
+ router.push(buildHref(p))} + />
); } diff --git a/src/app/zxdb/languages/LanguagesSearch.tsx b/src/app/zxdb/languages/LanguagesSearch.tsx index 9395dd8..e52af83 100644 --- a/src/app/zxdb/languages/LanguagesSearch.tsx +++ b/src/app/zxdb/languages/LanguagesSearch.tsx @@ -1,9 +1,10 @@ "use client"; -import { useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import Link from "next/link"; import { useRouter } from "next/navigation"; import ZxdbBreadcrumbs from "@/app/zxdb/components/ZxdbBreadcrumbs"; +import Pagination from "@/components/explorer/Pagination"; type Language = { id: string; name: string }; type Paged = { items: T[]; page: number; pageSize: number; total: number }; @@ -30,6 +31,13 @@ export default function LanguagesSearch({ initial, initialQ }: { initial?: Paged router.push(`/zxdb/languages?${params.toString()}`); } + const buildHref = useCallback((p: number) => { + const params = new URLSearchParams(); + if (q) params.set("q", q); + params.set("page", String(p)); + return `/zxdb/languages?${params.toString()}`; + }, [q]); + return (
- setQ(e.target.value)} /> + setQ(e.target.value)} />
@@ -90,25 +98,12 @@ export default function LanguagesSearch({ initial, initialQ }: { initial?: Paged
-
- Page {data?.page ?? 1} / {totalPages} -
- { const p = new URLSearchParams(); if (q) p.set("q", q); p.set("page", String(Math.max(1, (data?.page ?? 1) - 1))); return p.toString(); })()}`} - > - Prev - - = totalPages) ? "disabled" : ""}`} - aria-disabled={!data || data.page >= totalPages} - href={`/zxdb/languages?${(() => { const p = new URLSearchParams(); if (q) p.set("q", q); p.set("page", String(Math.min(totalPages, (data?.page ?? 1) + 1))); return p.toString(); })()}`} - > - Next - -
-
+ router.push(buildHref(p))} + />
); } diff --git a/src/app/zxdb/machinetypes/MachineTypesSearch.tsx b/src/app/zxdb/machinetypes/MachineTypesSearch.tsx index 7f5982f..6863cce 100644 --- a/src/app/zxdb/machinetypes/MachineTypesSearch.tsx +++ b/src/app/zxdb/machinetypes/MachineTypesSearch.tsx @@ -1,9 +1,10 @@ "use client"; -import { useEffect, useMemo, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import Link from "next/link"; import { useRouter } from "next/navigation"; import ZxdbBreadcrumbs from "@/app/zxdb/components/ZxdbBreadcrumbs"; +import Pagination from "@/components/explorer/Pagination"; type MT = { id: number; name: string }; type Paged = { items: T[]; page: number; pageSize: number; total: number }; @@ -14,12 +15,10 @@ export default function MachineTypesSearch({ initial, initialQ }: { initial?: Pa const [data, setData] = useState | null>(initial ?? null); const totalPages = useMemo(() => (data ? Math.max(1, Math.ceil(data.total / data.pageSize)) : 1), [data]); - // Sync incoming SSR payload on navigation (e.g., when clicking Prev/Next Links) useEffect(() => { if (initial) setData(initial); }, [initial]); - // Keep input in sync with URL q on navigation useEffect(() => { setQ(initialQ ?? ""); }, [initialQ]); @@ -32,6 +31,13 @@ export default function MachineTypesSearch({ initial, initialQ }: { initial?: Pa router.push(`/zxdb/machinetypes?${params.toString()}`); } + const buildHref = useCallback((p: number) => { + const params = new URLSearchParams(); + if (q) params.set("q", q); + params.set("page", String(p)); + return `/zxdb/machinetypes?${params.toString()}`; + }, [q]); + return (
- setQ(e.target.value)} /> + setQ(e.target.value)} />
@@ -92,25 +98,12 @@ export default function MachineTypesSearch({ initial, initialQ }: { initial?: Pa
-
- Page {data?.page ?? 1} / {totalPages} -
- { const p = new URLSearchParams(); if (q) p.set("q", q); p.set("page", String(Math.max(1, (data?.page ?? 1) - 1))); return p.toString(); })()}`} - > - Prev - - = totalPages) ? "disabled" : ""}`} - aria-disabled={!data || data.page >= totalPages} - href={`/zxdb/machinetypes?${(() => { const p = new URLSearchParams(); if (q) p.set("q", q); p.set("page", String(Math.min(totalPages, (data?.page ?? 1) + 1))); return p.toString(); })()}`} - > - Next - -
-
+ router.push(buildHref(p))} + />
); } diff --git a/src/app/zxdb/releases/ReleasesExplorer.tsx b/src/app/zxdb/releases/ReleasesExplorer.tsx index 8998308..f660d87 100644 --- a/src/app/zxdb/releases/ReleasesExplorer.tsx +++ b/src/app/zxdb/releases/ReleasesExplorer.tsx @@ -2,14 +2,20 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import Link from "next/link"; +import { Form, InputGroup, Button, Table, Alert } from "react-bootstrap"; +import { Search } from "react-bootstrap-icons"; import EntryLink from "../components/EntryLink"; import { usePathname, useRouter } from "next/navigation"; import ZxdbBreadcrumbs from "@/app/zxdb/components/ZxdbBreadcrumbs"; import ExplorerLayout from "@/components/explorer/ExplorerLayout"; import FilterSidebar from "@/components/explorer/FilterSidebar"; +import FilterSection from "@/components/explorer/FilterSection"; import MultiSelectChips from "@/components/explorer/MultiSelectChips"; +import Pagination from "@/components/explorer/Pagination"; +import useSearchFetch from "@/hooks/useSearchFetch"; const preferredMachineIds = [27, 26, 8, 9]; +const PAGE_SIZE = 20; function parseMachineIds(value?: string) { if (!value) return preferredMachineIds.slice(); @@ -38,7 +44,6 @@ type Paged = { export default function ReleasesExplorer({ initial, initialUrlState, - initialUrlHasParams, initialLists, }: { initial?: Paged; @@ -48,12 +53,12 @@ export default function ReleasesExplorer({ year: string; sort: "year_desc" | "year_asc" | "title" | "entry_id_desc"; dLanguageId?: string; - dMachinetypeId?: string; // keep as string for URL/state consistency + dMachinetypeId?: string; filetypeId?: string; schemetypeId?: string; sourcetypeId?: string; casetypeId?: string; - isDemo?: string; // "1" or "true" + isDemo?: string; }; initialUrlHasParams?: boolean; initialLists?: { @@ -68,15 +73,12 @@ export default function ReleasesExplorer({ const router = useRouter(); const pathname = usePathname(); + // -- Search state -- const [q, setQ] = useState(initialUrlState?.q ?? ""); const [appliedQ, setAppliedQ] = 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 [year, setYear] = useState(initialUrlState?.year ?? ""); const [sort, setSort] = useState<"year_desc" | "year_asc" | "title" | "entry_id_desc">(initialUrlState?.sort ?? "year_desc"); - - // Download-based filters and their option lists const [dLanguageId, setDLanguageId] = useState(initialUrlState?.dLanguageId ?? ""); const [dMachinetypeIds, setDMachinetypeIds] = useState(parseMachineIds(initialUrlState?.dMachinetypeId)); const [filetypeId, setFiletypeId] = useState(initialUrlState?.filetypeId ?? ""); @@ -85,53 +87,52 @@ export default function ReleasesExplorer({ const [casetypeId, setCasetypeId] = useState(initialUrlState?.casetypeId ?? ""); const [isDemo, setIsDemo] = useState(!!(initialUrlState?.isDemo && (initialUrlState.isDemo === "1" || initialUrlState.isDemo === "true"))); + // -- Filter lists -- const [langs, setLangs] = useState<{ id: string; name: string }[]>(initialLists?.languages ?? []); const [machines, setMachines] = useState<{ id: number; name: string }[]>(initialLists?.machinetypes ?? []); const [filetypes, setFiletypes] = useState<{ id: number; name: string }[]>(initialLists?.filetypes ?? []); const [schemes, setSchemes] = useState<{ id: string; name: string }[]>(initialLists?.schemetypes ?? []); const [sources, setSources] = useState<{ id: string; name: string }[]>(initialLists?.sourcetypes ?? []); const [cases, setCases] = useState<{ id: string; name: string }[]>(initialLists?.casetypes ?? []); - const initialLoad = useRef(true); - const preferredMachineNames = useMemo(() => { - if (!machines.length) return preferredMachineIds.map((id) => `#${id}`); - return preferredMachineIds.map((id) => machines.find((m) => m.id === id)?.name ?? `#${id}`); - }, [machines]); + + // -- Fetch with abort control -- + const { data, loading, error, fetch: doFetch, syncData } = useSearchFetch( + "/api/zxdb/releases/search", + initial ?? null, + ); + + const isFirstRender = useRef(true); + + // Debounce timer for year input changes + const yearDebounce = useRef>(undefined); + + const totalPages = useMemo( + () => (data ? Math.max(1, Math.ceil(data.total / data.pageSize)) : 1), + [data], + ); + const orderedMachines = useMemo(() => { const seen = new Set(preferredMachineIds); const preferred = preferredMachineIds.map((id) => machines.find((m) => m.id === id)).filter(Boolean) as { id: number; name: string }[]; const rest = machines.filter((m) => !seen.has(m.id)); return [...preferred, ...rest]; }, [machines]); - const machineOptions = useMemo(() => orderedMachines.map((m) => ({ id: m.id, label: m.name })), [orderedMachines]); - const pageSize = 20; - const totalPages = useMemo(() => (data ? Math.max(1, Math.ceil(data.total / data.pageSize)) : 1), [data]); + const machineOptions = useMemo( + () => orderedMachines.map((m) => ({ id: m.id, label: m.name })), + [orderedMachines], + ); - const updateUrl = useCallback((nextPage = page) => { - const params = new URLSearchParams(); - if (appliedQ) params.set("q", appliedQ); - params.set("page", String(nextPage)); - if (year) params.set("year", year); - if (sort) params.set("sort", sort); - if (dLanguageId) params.set("dLanguageId", dLanguageId); - if (dMachinetypeIds.length > 0) params.set("dMachinetypeId", dMachinetypeIds.join(",")); - if (filetypeId) params.set("filetypeId", filetypeId); - if (schemetypeId) params.set("schemetypeId", schemetypeId); - if (sourcetypeId) params.set("sourcetypeId", sourcetypeId); - if (casetypeId) params.set("casetypeId", casetypeId); - if (isDemo) params.set("isDemo", "1"); - const qs = params.toString(); - router.replace(qs ? `${pathname}?${qs}` : pathname); - }, [appliedQ, casetypeId, dLanguageId, dMachinetypeIds, filetypeId, isDemo, page, pathname, router, schemetypeId, sort, sourcetypeId, year]); + const hasNonDefaultMachineFilter = dMachinetypeIds.join(",") !== preferredMachineIds.join(",") || + dMachinetypeIds.length !== machines.length; - const fetchData = useCallback(async (query: string, p: number) => { - setLoading(true); - try { + // -- URL helpers -- + const buildParams = useCallback( + (p: number) => { const params = new URLSearchParams(); - if (query) params.set("q", query); + if (appliedQ) params.set("q", appliedQ); params.set("page", String(p)); - params.set("pageSize", String(pageSize)); - if (year) params.set("year", String(Number(year))); + if (year) params.set("year", year); if (sort) params.set("sort", sort); if (dLanguageId) params.set("dLanguageId", dLanguageId); if (dMachinetypeIds.length > 0) params.set("dMachinetypeId", dMachinetypeIds.join(",")); @@ -140,79 +141,44 @@ export default function ReleasesExplorer({ if (sourcetypeId) params.set("sourcetypeId", sourcetypeId); if (casetypeId) params.set("casetypeId", casetypeId); if (isDemo) params.set("isDemo", "1"); - const res = await fetch(`/api/zxdb/releases/search?${params.toString()}`); - if (!res.ok) throw new Error(`Failed: ${res.status}`); - const json: Paged = await res.json(); - setData(json); - } catch (e) { - console.error(e); - setData({ items: [], page: 1, pageSize, total: 0 }); - } finally { - setLoading(false); - } - }, [casetypeId, dLanguageId, dMachinetypeIds, filetypeId, isDemo, pageSize, schemetypeId, sort, sourcetypeId, year]); + return params; + }, + [appliedQ, year, sort, dLanguageId, dMachinetypeIds, filetypeId, schemetypeId, sourcetypeId, casetypeId, isDemo], + ); + const buildHref = useCallback( + (p: number) => { + const qs = buildParams(p).toString(); + return qs ? `${pathname}?${qs}` : pathname; + }, + [buildParams, pathname], + ); + + // -- Fetch + URL sync on filter/page changes -- useEffect(() => { - if (initial) { - setData(initial); - setPage(initial.page); - } - }, [initial]); - - const initialState = useMemo(() => ({ - q: initialUrlState?.q ?? "", - year: initialUrlState?.year ?? "", - sort: initialUrlState?.sort ?? "year_desc", - dLanguageId: initialUrlState?.dLanguageId ?? "", - dMachinetypeId: initialUrlState?.dMachinetypeId ?? "", - filetypeId: initialUrlState?.filetypeId ?? "", - schemetypeId: initialUrlState?.schemetypeId ?? "", - sourcetypeId: initialUrlState?.sourcetypeId ?? "", - casetypeId: initialUrlState?.casetypeId ?? "", - isDemo: initialUrlState?.isDemo, - }), [initialUrlState]); - - useEffect(() => { - const initialPage = initial?.page ?? 1; - if ( - initial && - page === initialPage && - initialState.q === appliedQ && - initialState.year === (year ?? "") && - sort === initialState.sort && - initialState.dLanguageId === dLanguageId && - parseMachineIds(initialState.dMachinetypeId).join(",") === dMachinetypeIds.join(",") && - initialState.filetypeId === filetypeId && - initialState.schemetypeId === schemetypeId && - initialState.sourcetypeId === sourcetypeId && - initialState.casetypeId === casetypeId && - (!!initialState.isDemo === isDemo) - ) { - if (initialLoad.current) { - initialLoad.current = false; - return; - } - updateUrl(page); + if (isFirstRender.current) { + isFirstRender.current = false; + router.replace(buildHref(page), { scroll: false }); return; } - if (initialLoad.current) { - initialLoad.current = false; - if (initial && !initialUrlHasParams) return; - } - updateUrl(page); - fetchData(appliedQ, page); - }, [appliedQ, casetypeId, dLanguageId, dMachinetypeIds, fetchData, filetypeId, initial, initialState, initialUrlHasParams, isDemo, page, schemetypeId, sort, sourcetypeId, updateUrl, year]); - function onSubmit(e: React.FormEvent) { - e.preventDefault(); - setAppliedQ(q); - setPage(1); - } + router.replace(buildHref(page), { scroll: false }); - // Load filter option lists on mount + const params = buildParams(page); + params.set("pageSize", String(PAGE_SIZE)); + doFetch(params); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [page, appliedQ, year, sort, dLanguageId, dMachinetypeIds, filetypeId, schemetypeId, sourcetypeId, casetypeId, isDemo]); + + // Sync SSR data when navigating (browser back/forward) useEffect(() => { + if (initial) syncData(initial); + }, [initial, syncData]); + + // Load filter lists on mount if not provided by server + useEffect(() => { + if (langs.length || machines.length || filetypes.length || schemes.length || sources.length || cases.length) return; async function loadLists() { - if (langs.length || machines.length || filetypes.length || schemes.length || sources.length || cases.length) return; try { const [l, m, ft, sc, so, ca] = await Promise.all([ fetch("/api/zxdb/languages", { cache: "force-cache" }).then((r) => r.json()), @@ -228,44 +194,42 @@ export default function ReleasesExplorer({ setSchemes(sc.items ?? []); setSources(so.items ?? []); setCases(ca.items ?? []); - } catch { - // ignore - } + } catch { /* filter lists are non-critical */ } } loadLists(); }, [cases.length, filetypes.length, langs.length, machines.length, schemes.length, sources.length]); - const prevHref = useMemo(() => { - const params = new URLSearchParams(); - if (appliedQ) params.set("q", appliedQ); - params.set("page", String(Math.max(1, (data?.page ?? 1) - 1))); - if (year) params.set("year", year); - if (sort) params.set("sort", sort); - if (dLanguageId) params.set("dLanguageId", dLanguageId); - if (dMachinetypeIds.length > 0) params.set("dMachinetypeId", dMachinetypeIds.join(",")); - if (filetypeId) params.set("filetypeId", filetypeId); - if (schemetypeId) params.set("schemetypeId", schemetypeId); - if (sourcetypeId) params.set("sourcetypeId", sourcetypeId); - if (casetypeId) params.set("casetypeId", casetypeId); - if (isDemo) params.set("isDemo", "1"); - return `/zxdb/releases?${params.toString()}`; - }, [appliedQ, data?.page, year, sort, dLanguageId, dMachinetypeIds, filetypeId, schemetypeId, sourcetypeId, casetypeId, isDemo]); + function onSubmit(e: React.FormEvent) { + e.preventDefault(); + setAppliedQ(q); + setPage(1); + } - const nextHref = useMemo(() => { - const params = new URLSearchParams(); - if (appliedQ) params.set("q", appliedQ); - params.set("page", String(Math.max(1, (data?.page ?? 1) + 1))); - if (year) params.set("year", year); - if (sort) params.set("sort", sort); - if (dLanguageId) params.set("dLanguageId", dLanguageId); - if (dMachinetypeIds.length > 0) params.set("dMachinetypeId", dMachinetypeIds.join(",")); - if (filetypeId) params.set("filetypeId", filetypeId); - if (schemetypeId) params.set("schemetypeId", schemetypeId); - if (sourcetypeId) params.set("sourcetypeId", sourcetypeId); - if (casetypeId) params.set("casetypeId", casetypeId); - if (isDemo) params.set("isDemo", "1"); - return `/zxdb/releases?${params.toString()}`; - }, [appliedQ, data?.page, year, sort, dLanguageId, dMachinetypeIds, filetypeId, schemetypeId, sourcetypeId, casetypeId, isDemo]); + function onYearChange(value: string) { + setYear(value); + clearTimeout(yearDebounce.current); + yearDebounce.current = setTimeout(() => setPage(1), 400); + } + + function searchAllMachines() { + setDMachinetypeIds(machineOptions.map((m) => m.id)); + setPage(1); + } + + function resetFilters() { + setQ(""); + setAppliedQ(""); + setYear(""); + setSort("year_desc"); + setDLanguageId(""); + setDMachinetypeIds(preferredMachineIds.slice()); + setFiletypeId(""); + setSchemetypeId(""); + setSourcetypeId(""); + setCasetypeId(""); + setIsDemo(false); + setPage(1); + } return (
@@ -280,121 +244,159 @@ export default function ReleasesExplorer({ title="Releases" subtitle={data ? `${data.total.toLocaleString()} results` : "Loading results..."} sidebar={( - -
-
- - setQ(e.target.value)} - /> + + + + setQ(e.target.value)} + /> + + + + + onYearChange(e.target.value)} + /> + + + l.id === dLanguageId)?.name : undefined}> + { setDLanguageId(e.target.value); setPage(1); }}> + + {langs.map((l) => ( + + ))} + + + + + { + setDMachinetypeIds((current) => { + const next = new Set(current); + if (next.has(id)) { + next.delete(id); + } else { + next.add(id); + } + const order = machineOptions.map((item) => item.id); + const filtered = order.filter((value) => next.has(value)); + return filtered.length ? filtered : preferredMachineIds.slice(); + }); + setPage(1); + }} + /> +
+ +
-
- -
-
- - { setYear(e.target.value); setPage(1); }} - /> -
-
- - -
-
- - { - setDMachinetypeIds((current) => { - const next = new Set(current); - if (next.has(id)) { - next.delete(id); - } else { - next.add(id); - } - const order = machineOptions.map((item) => item.id); - return order.filter((value) => next.has(value)); - }); - setPage(1); - }} - /> -
Preferred: {preferredMachineNames.join(", ")}
-
-
- - -
-
- - -
-
- - -
-
- - + + { setIsDemo(e.target.checked); setPage(1); }} + />
-
- { setIsDemo(e.target.checked); setPage(1); }} /> - -
-
- - -
- {loading &&
Loading...
} - -
- )} - > + + + + { setSort(e.target.value as typeof sort); setPage(1); }}> + + + + + + + + {error && {error}} + + + )} + > +
{data && data.items.length === 0 && !loading && ( -
No results.
+ + No results found. + {hasNonDefaultMachineFilter && ( + + {" "}Filtering by{" "} + + {dMachinetypeIds + .map((id) => machines.find((m) => m.id === id)?.name ?? `#${id}`) + .join(", ")} + + {" "}—{" "} + + search all machines + ? + + )} + )} {data && data.items.length > 0 && (
- +
@@ -411,11 +413,9 @@ export default function ReleasesExplorer({ ))} -
Entry ID -
- - {it.entryTitle || `Entry #${it.entryId}`} - -
+ + {it.entryTitle || `Entry #${it.entryId}`} +
@@ -433,40 +433,19 @@ export default function ReleasesExplorer({
+
)} +
-
- 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 - -
-
+
); } diff --git a/src/components/explorer/FilterSection.tsx b/src/components/explorer/FilterSection.tsx new file mode 100644 index 0000000..4fb54b5 --- /dev/null +++ b/src/components/explorer/FilterSection.tsx @@ -0,0 +1,54 @@ +"use client"; + +import { ReactNode, useState } from "react"; +import { Collapse } from "react-bootstrap"; +import { ChevronDown } from "react-bootstrap-icons"; + +type FilterSectionProps = { + label: string; + badge?: string; + defaultOpen?: boolean; + children: ReactNode; +}; + +export default function FilterSection({ + label, + badge, + defaultOpen = true, + children, +}: FilterSectionProps) { + const [open, setOpen] = useState(defaultOpen); + + return ( +
+ + +
+
{children}
+
+
+
+ ); +} diff --git a/src/components/explorer/FilterSidebar.tsx b/src/components/explorer/FilterSidebar.tsx index 159b574..0cb40af 100644 --- a/src/components/explorer/FilterSidebar.tsx +++ b/src/components/explorer/FilterSidebar.tsx @@ -1,13 +1,31 @@ import { ReactNode } from "react"; +import { Card, Button } from "react-bootstrap"; type FilterSidebarProps = { children: ReactNode; + onReset?: () => void; + loading?: boolean; }; -export default function FilterSidebar({ children }: FilterSidebarProps) { +export default function FilterSidebar({ children, onReset, loading }: FilterSidebarProps) { return ( -
-
{children}
-
+ + + {children} + {onReset && ( +
+ +
+ )} +
+
); } diff --git a/src/components/explorer/MultiSelectChips.tsx b/src/components/explorer/MultiSelectChips.tsx index 4541a5d..202ecde 100644 --- a/src/components/explorer/MultiSelectChips.tsx +++ b/src/components/explorer/MultiSelectChips.tsx @@ -1,3 +1,7 @@ +"use client"; + +import { useState } from "react"; + type ChipOption = { id: T; label: string; @@ -8,6 +12,10 @@ type MultiSelectChipsProps = { selected: T[]; onToggle: (id: T) => void; size?: "sm" | "md"; + /** When set, chips start collapsed showing just selected count + names */ + collapsible?: boolean; + /** Max selected labels to show in collapsed summary before truncating */ + collapsedMax?: number; }; export default function MultiSelectChips({ @@ -15,23 +23,60 @@ export default function MultiSelectChips({ selected, onToggle, size = "sm", + collapsible = false, + collapsedMax = 3, }: MultiSelectChipsProps) { + const [expanded, setExpanded] = useState(!collapsible); const btnSize = size === "sm" ? "btn-sm" : ""; + + if (!expanded) { + const selectedLabels = selected + .map((id) => options.find((o) => o.id === id)?.label) + .filter(Boolean) as string[]; + const shown = selectedLabels.slice(0, collapsedMax); + const extra = selectedLabels.length - shown.length; + const summary = shown.length + ? shown.join(", ") + (extra > 0 ? ` +${extra}` : "") + : "None"; + + return ( + + ); + } + return ( -
- {options.map((option) => { - const active = selected.includes(option.id); - return ( - - ); - })} +
+
+ {options.map((option) => { + const active = selected.includes(option.id); + return ( + + ); + })} +
+ {collapsible && ( + + )}
); } diff --git a/src/components/explorer/Pagination.tsx b/src/components/explorer/Pagination.tsx new file mode 100644 index 0000000..a6d7a24 --- /dev/null +++ b/src/components/explorer/Pagination.tsx @@ -0,0 +1,67 @@ +"use client"; + +import { useMemo } from "react"; +import { Button, Spinner } from "react-bootstrap"; +import { ChevronLeft, ChevronRight } from "react-bootstrap-icons"; + +type PaginationProps = { + page: number; + totalPages: number; + loading?: boolean; + /** Build href for a given page number (for SSR/link fallback) */ + buildHref: (p: number) => string; + onPageChange: (p: number) => void; +}; + +export default function Pagination({ + page, + totalPages, + loading, + buildHref, + onPageChange, +}: PaginationProps) { + const canPrev = page > 1; + const canNext = page < totalPages; + + const prevHref = useMemo(() => buildHref(Math.max(1, page - 1)), [buildHref, page]); + const nextHref = useMemo(() => buildHref(Math.min(totalPages, page + 1)), [buildHref, page, totalPages]); + + return ( +
+ + Page {page} / {totalPages} + + {loading && ( + + )} +
+ + +
+
+ ); +} diff --git a/src/hooks/useSearchFetch.ts b/src/hooks/useSearchFetch.ts new file mode 100644 index 0000000..1311579 --- /dev/null +++ b/src/hooks/useSearchFetch.ts @@ -0,0 +1,86 @@ +"use client"; + +import { useCallback, useRef, useState } from "react"; + +type Paged = { + items: T[]; + page: number; + pageSize: number; + total: number; +}; + +/** + * Manages API search fetching with automatic request cancellation + * to prevent race conditions from rapid filter/page changes. + * Keeps previous results visible while a new request is in flight. + * + * @param onExtra - optional callback to capture extra fields from the response + * (e.g., facets) that sit alongside the standard paged fields. + */ +export default function useSearchFetch( + endpoint: string, + initialData: Paged | null = null, + onExtra?: (json: Record) => void, +) { + const [data, setData] = useState | null>(initialData); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const abortRef = useRef(null); + const fetchIdRef = useRef(0); + + const fetch_ = useCallback( + async (params: URLSearchParams) => { + // Cancel any in-flight request + abortRef.current?.abort(); + const controller = new AbortController(); + abortRef.current = controller; + + const id = ++fetchIdRef.current; + setLoading(true); + setError(null); + + try { + const res = await globalThis.fetch( + `${endpoint}?${params.toString()}`, + { signal: controller.signal }, + ); + if (!res.ok) throw new Error(`Search failed (${res.status})`); + const json = await res.json(); + + // Only apply if this is still the latest request + if (id === fetchIdRef.current) { + setData({ + items: json.items, + page: json.page, + pageSize: json.pageSize, + total: json.total, + }); + onExtra?.(json); + } + } catch (e: unknown) { + if (e instanceof DOMException && e.name === "AbortError") return; + if (id === fetchIdRef.current) { + const msg = e instanceof Error ? e.message : "Search failed"; + console.error(msg); + setError(msg); + setData({ items: [] as T[], page: 1, pageSize: 20, total: 0 }); + } + } finally { + if (id === fetchIdRef.current) { + setLoading(false); + } + } + }, + [endpoint, onExtra], + ); + + // Allow syncing SSR data without a fetch + const syncData = useCallback((d: Paged) => { + abortRef.current?.abort(); + setData(d); + setLoading(false); + setError(null); + }, []); + + return { data, loading, error, fetch: fetch_, syncData }; +}