Use react-bootstrap throughout, improve entry detail

Switch sidebar components (FilterSection, FilterSidebar, Pagination)
and both explorer pages to use react-bootstrap: Card, Table, Badge,
Button, Alert, Form.Control, Form.Select, InputGroup, Collapse,
Spinner. Use react-bootstrap-icons for Search, ChevronDown, Download,
BoxArrowUpRight, etc.

Entry detail page: remove MD5 columns from Downloads and Files tables.
Hide empty sections entirely instead of showing placeholder cards.
Human-readable file sizes (KB/MB). Web links shown as compact list
with external-link icons. Notes rendered as badge+text instead of
table rows. Scores and web links moved to sidebar.

No-results alert now shows active machine filter names and offers to
search all machines via Alert.Link.

Update CLAUDE.md with react-bootstrap design conventions and remove
stale ZxdbExplorer.tsx references.

claude-opus-4-6@McFiver
This commit is contained in:
2026-02-17 18:17:58 +00:00
parent fe1dfa4170
commit 65de62deaf
14 changed files with 1453 additions and 1595 deletions

View File

@@ -67,7 +67,7 @@ next-explorer/
- `RegisterDetail.tsx`: Client Component that renders a single registers details, including modes, notes, and source modal. - `RegisterDetail.tsx`: Client Component that renders a single registers details, including modes, notes, and source modal.
- `[hex]/page.tsx`: Dynamic route that renders details for a specific register by hex address. - `[hex]/page.tsx`: Dynamic route that renders details for a specific register by hex address.
- `src/app/zxdb/`: ZXDB Explorer routes and client components. - `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). - `entries/[id]/page.tsx` + `EntryDetail.tsx`: Entry details (SSR initial data).
- `releases/page.tsx` + `ReleasesExplorer.tsx`: Releases search + filters. - `releases/page.tsx` + `ReleasesExplorer.tsx`: Releases search + filters.
- `labels/page.tsx`, `labels/[id]/page.tsx` + client: Labels search and detail. - `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. - Use `type` for interfaces.
- No `enum`. - 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 `<Table size="sm" striped>` for data tables. Human-readable sizes (KB/MB) over raw bytes. Omit columns that add noise without value (e.g. MD5 hashes).
- **Alerts** — use `<Alert variant="warning">` for "no results" states with actionable suggestions (e.g. offering to broaden filters).
### React / Next.js Patterns ### React / Next.js Patterns
- **Server Components**: - **Server Components**:
@@ -122,7 +141,7 @@ Comment what the code does, not what the agent has done. The documentation's pur
- `RegisterDetail.tsx`: - `RegisterDetail.tsx`:
- Marked with `'use client'`. - Marked with `'use client'`.
- Renders a single register with tabs for different access modes. - 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**: - **Dynamic Routing**:
- Pages and API routes must await dynamic params in Next.js 15: - Pages and API routes must await dynamic params in Next.js 15:

View File

@@ -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<T> = {
items: T[];
page: number;
pageSize: number;
total: number;
};
export default function ZxdbExplorer({
initial,
initialGenres,
initialLanguages,
initialMachines,
}: {
initial?: Paged<Item>;
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<Paged<Item> | 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<number | "">("");
const [languageId, setLanguageId] = useState<string | "">("");
const [machinetypeId, setMachinetypeId] = useState<number | "">("");
const [year, setYear] = useState<string>("");
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<Item> = 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 (
<div>
<h1 className="mb-3">ZXDB Explorer</h1>
<form className="row gy-2 gx-2 align-items-center" onSubmit={onSubmit}>
<div className="col-sm-8 col-md-6 col-lg-4">
<input
type="text"
className="form-control"
placeholder="Search titles..."
value={q}
onChange={(e) => setQ(e.target.value)}
/>
</div>
<div className="col-auto">
<button className="btn btn-primary" type="submit" disabled={loading}>Search</button>
</div>
<div className="col-auto">
<select className="form-select" value={genreId} onChange={(e) => setGenreId(e.target.value === "" ? "" : Number(e.target.value))}>
<option value="">Genre</option>
{genres.map((g) => (
<option key={g.id} value={g.id}>{g.name}</option>
))}
</select>
</div>
<div className="col-auto">
<select className="form-select" value={languageId} onChange={(e) => setLanguageId(e.target.value)}>
<option value="">Language</option>
{languages.map((l) => (
<option key={l.id} value={l.id}>{l.name}</option>
))}
</select>
</div>
<div className="col-auto">
<select className="form-select" value={machinetypeId} onChange={(e) => setMachinetypeId(e.target.value === "" ? "" : Number(e.target.value))}>
<option value="">Machine</option>
{machines.map((m) => (
<option key={m.id} value={m.id}>{m.name}</option>
))}
</select>
</div>
<div className="col-auto">
<input
type="number"
className="form-control"
style={{ width: 100 }}
placeholder="Year"
value={year}
onChange={(e) => setYear(e.target.value)}
/>
</div>
<div className="col-auto">
<select className="form-select" value={sort} onChange={(e) => setSort(e.target.value as "title" | "id_desc")}>
<option value="title">Sort: Title</option>
<option value="id_desc">Sort: Newest</option>
</select>
</div>
{loading && (
<div className="col-auto text-secondary">Loading...</div>
)}
</form>
<div className="mt-3">
{data && data.items.length === 0 && !loading && (
<div className="alert alert-warning">No results.</div>
)}
{data && data.items.length > 0 && (
<div className="table-responsive">
<table className="table table-striped table-hover align-middle">
<thead>
<tr>
<th style={{width: 80}}>ID</th>
<th>Title</th>
<th style={{width: 160}}>Machine</th>
<th style={{width: 120}}>Language</th>
</tr>
</thead>
<tbody>
{data.items.map((it) => (
<tr key={it.id}>
<td>{it.id}</td>
<td>
<Link href={`/zxdb/entries/${it.id}`}>{it.title}</Link>
</td>
<td>
{it.machinetypeId != null ? (
it.machinetypeName ? (
<Link href={`/zxdb/machinetypes/${it.machinetypeId}`}>{it.machinetypeName}</Link>
) : (
<span>{it.machinetypeId}</span>
)
) : (
<span className="text-secondary">-</span>
)}
</td>
<td>
{it.languageId ? (
it.languageName ? (
<Link href={`/zxdb/languages/${it.languageId}`}>{it.languageName}</Link>
) : (
<span>{it.languageId}</span>
)
) : (
<span className="text-secondary">-</span>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
<div className="d-flex align-items-center gap-2 mt-2">
<span>
Page {data?.page ?? 1} / {totalPages}
</span>
<div className="ms-auto d-flex gap-2">
<Link
className={`btn btn-outline-secondary ${!data || (data.page <= 1) ? "disabled" : ""}`}
aria-disabled={!data || data.page <= 1}
href={`/zxdb?page=${Math.max(1, (data?.page ?? 1) - 1)}`}
>
Prev
</Link>
<Link
className={`btn btn-outline-secondary ${!data || (data.page >= totalPages) ? "disabled" : ""}`}
aria-disabled={!data || data.page >= totalPages}
href={`/zxdb?page=${Math.min(totalPages, (data?.page ?? 1) + 1)}`}
>
Next
</Link>
</div>
</div>
<hr />
<div className="d-flex flex-wrap gap-2">
<Link className="btn btn-sm btn-outline-secondary" href="/zxdb/labels">Browse Labels</Link>
<Link className="btn btn-sm btn-outline-secondary" href="/zxdb/genres">Browse Genres</Link>
<Link className="btn btn-sm btn-outline-secondary" href="/zxdb/languages">Browse Languages</Link>
<Link className="btn btn-sm btn-outline-secondary" href="/zxdb/machinetypes">Browse Machines</Link>
</div>
</div>
);
}

View File

@@ -1,15 +1,21 @@
"use client"; "use client";
import { useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import Link from "next/link"; 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 EntryLink from "../components/EntryLink";
import { usePathname, useRouter } from "next/navigation"; import { usePathname, useRouter } from "next/navigation";
import ZxdbBreadcrumbs from "@/app/zxdb/components/ZxdbBreadcrumbs"; import ZxdbBreadcrumbs from "@/app/zxdb/components/ZxdbBreadcrumbs";
import ExplorerLayout from "@/components/explorer/ExplorerLayout"; import ExplorerLayout from "@/components/explorer/ExplorerLayout";
import FilterSidebar from "@/components/explorer/FilterSidebar"; import FilterSidebar from "@/components/explorer/FilterSidebar";
import FilterSection from "@/components/explorer/FilterSection";
import MultiSelectChips from "@/components/explorer/MultiSelectChips"; import MultiSelectChips from "@/components/explorer/MultiSelectChips";
import Pagination from "@/components/explorer/Pagination";
import useSearchFetch from "@/hooks/useSearchFetch";
const preferredMachineIds = [27, 26, 8, 9]; const preferredMachineIds = [27, 26, 8, 9];
const PAGE_SIZE = 20;
type Item = { type Item = {
id: number; id: number;
@@ -23,8 +29,6 @@ type Item = {
languageName?: string | null; languageName?: string | null;
}; };
type SearchScope = "title" | "title_aliases" | "title_aliases_origins";
type Paged<T> = { type Paged<T> = {
items: T[]; items: T[];
page: number; page: number;
@@ -32,6 +36,8 @@ type Paged<T> = {
total: number; total: number;
}; };
type SearchScope = "title" | "title_aliases" | "title_aliases_origins";
type EntryFacets = { type EntryFacets = {
genres: { id: number; name: string; count: number }[]; genres: { id: number; name: string; count: number }[];
languages: { id: string; name: string; count: number }[]; languages: { id: string; name: string; count: number }[];
@@ -39,6 +45,15 @@ type EntryFacets = {
flags: { hasAliases: number; hasOrigins: number }; 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({ export default function EntriesExplorer({
initial, initial,
initialGenres, initialGenres,
@@ -62,26 +77,13 @@ export default function EntriesExplorer({
scope?: SearchScope; 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 router = useRouter();
const pathname = usePathname(); const pathname = usePathname();
// -- Search state --
const [q, setQ] = useState(initialUrlState?.q ?? ""); const [q, setQ] = useState(initialUrlState?.q ?? "");
const [appliedQ, setAppliedQ] = useState(initialUrlState?.q ?? ""); const [appliedQ, setAppliedQ] = useState(initialUrlState?.q ?? "");
const [page, setPage] = useState(initial?.page ?? initialUrlState?.page ?? 1); const [page, setPage] = useState(initial?.page ?? initialUrlState?.page ?? 1);
const [loading, setLoading] = useState(false);
const [data, setData] = useState<Paged<Item> | 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<number | "">( const [genreId, setGenreId] = useState<number | "">(
initialUrlState?.genreId === "" ? "" : initialUrlState?.genreId ? Number(initialUrlState.genreId) : "" 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 [sort, setSort] = useState<"title" | "id_desc">(initialUrlState?.sort ?? "id_desc");
const [scope, setScope] = useState<SearchScope>(initialUrlState?.scope ?? "title"); const [scope, setScope] = useState<SearchScope>(initialUrlState?.scope ?? "title");
const [facets, setFacets] = useState<EntryFacets | null>(initialFacets ?? null); const [facets, setFacets] = useState<EntryFacets | null>(initialFacets ?? null);
const preferredMachineNames = useMemo(() => {
if (!machines.length) return preferredMachineIds.map((id) => `#${id}`); // -- Filter lists --
return preferredMachineIds.map((id) => machines.find((m) => m.id === id)?.name ?? `#${id}`); const [genres, setGenres] = useState<{ id: number; name: string }[]>(initialGenres ?? []);
}, [machines]); 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<string, unknown>) => {
if (json.facets) setFacets(json.facets as EntryFacets);
}, []);
// -- Fetch with abort control --
const { data, loading, error, fetch: doFetch, syncData } = useSearchFetch<Item>(
"/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 orderedMachines = useMemo(() => {
const seen = new Set(preferredMachineIds); const seen = new Set(preferredMachineIds);
const preferred = preferredMachineIds.map((id) => machines.find((m) => m.id === id)).filter(Boolean) as { id: number; name: string }[]; 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)); const rest = machines.filter((m) => !seen.has(m.id));
return [...preferred, ...rest]; return [...preferred, ...rest];
}, [machines]); }, [machines]);
const machineOptions = useMemo(() => orderedMachines.map((m) => ({ id: m.id, label: m.name })), [orderedMachines]);
const pageSize = 20; const machineOptions = useMemo(
const totalPages = useMemo(() => (data ? Math.max(1, Math.ceil(data.total / data.pageSize)) : 1), [data]); () => orderedMachines.map((m) => ({ id: m.id, label: m.name })),
const activeFilters = useMemo(() => { [orderedMachines],
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]);
function updateUrl(nextPage = page) { const hasNonDefaultMachineFilter = machinetypeIds.join(",") !== preferredMachineIds.join(",") ||
const params = new URLSearchParams(); machinetypeIds.length !== machines.length;
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);
}
async function fetchData(query: string, p: number, withFacets: boolean) { // -- Fetch + URL sync on filter/page changes --
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
useEffect(() => { useEffect(() => {
if (initial) { if (isFirstRender.current) {
setData(initial); isFirstRender.current = false;
setPage(initial.page); router.replace(buildHref(page), { scroll: false });
}
}, [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);
return; 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 // eslint-disable-next-line react-hooks/exhaustive-deps
}, [page, genreId, languageId, machinetypeIds, sort, scope, appliedQ]); }, [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(() => { useEffect(() => {
if (initialGenres && initialLanguages && initialMachines) return; if (initialGenres && initialLanguages && initialMachines) return;
async function loadLists() { async function loadLists() {
@@ -208,7 +193,7 @@ export default function EntriesExplorer({
setGenres(g.items ?? []); setGenres(g.items ?? []);
setLanguages(l.items ?? []); setLanguages(l.items ?? []);
setMachines(m.items ?? []); setMachines(m.items ?? []);
} catch {} } catch { /* filter lists are non-critical */ }
} }
loadLists(); loadLists();
}, [initialGenres, initialLanguages, initialMachines]); }, [initialGenres, initialLanguages, initialMachines]);
@@ -219,6 +204,11 @@ export default function EntriesExplorer({
setPage(1); setPage(1);
} }
function searchAllMachines() {
setMachinetypeIds(machineOptions.map((m) => m.id));
setPage(1);
}
function resetFilters() { function resetFilters() {
setQ(""); setQ("");
setAppliedQ(""); setAppliedQ("");
@@ -230,30 +220,6 @@ export default function EntriesExplorer({
setPage(1); 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 ( return (
<div> <div>
<ZxdbBreadcrumbs <ZxdbBreadcrumbs
@@ -266,47 +232,48 @@ export default function EntriesExplorer({
<ExplorerLayout <ExplorerLayout
title="Entries" title="Entries"
subtitle={data ? `${data.total.toLocaleString()} results` : "Loading results..."} subtitle={data ? `${data.total.toLocaleString()} results` : "Loading results..."}
chips={activeFilters}
onClearChips={resetFilters}
sidebar={( sidebar={(
<FilterSidebar> <FilterSidebar onReset={resetFilters} loading={loading}>
<form className="d-flex flex-column gap-2" onSubmit={onSubmit}> <Form onSubmit={onSubmit} className="d-flex flex-column gap-2">
<div> <InputGroup>
<label className="form-label small text-secondary">Search</label> <Form.Control
<input
type="text" type="text"
className="form-control"
placeholder="Search titles..." placeholder="Search titles..."
value={q} value={q}
onChange={(e) => setQ(e.target.value)} onChange={(e) => setQ(e.target.value)}
/> />
</div> <Button variant="primary" type="submit" disabled={loading}>
<div className="d-grid"> <Search size={14} />
<button className="btn btn-primary" type="submit" disabled={loading}>Search</button> </Button>
</div> </InputGroup>
<div>
<label className="form-label small text-secondary">Genre</label> <FilterSection label="Genre" badge={genreId !== "" ? genres.find((g) => g.id === Number(genreId))?.name : undefined}>
<select className="form-select" value={genreId} onChange={(e) => { setGenreId(e.target.value === "" ? "" : Number(e.target.value)); setPage(1); }}> <Form.Select size="sm" value={genreId} onChange={(e) => { setGenreId(e.target.value === "" ? "" : Number(e.target.value)); setPage(1); }}>
<option value="">All genres</option> <option value="">All genres</option>
{genres.map((g) => ( {genres.map((g) => (
<option key={g.id} value={g.id}>{g.name}</option> <option key={g.id} value={g.id}>{g.name}</option>
))} ))}
</select> </Form.Select>
</div> </FilterSection>
<div>
<label className="form-label small text-secondary">Language</label> <FilterSection label="Language" badge={languageId !== "" ? languages.find((l) => l.id === languageId)?.name : undefined}>
<select className="form-select" value={languageId} onChange={(e) => { setLanguageId(e.target.value); setPage(1); }}> <Form.Select size="sm" value={languageId} onChange={(e) => { setLanguageId(e.target.value); setPage(1); }}>
<option value="">All languages</option> <option value="">All languages</option>
{languages.map((l) => ( {languages.map((l) => (
<option key={l.id} value={l.id}>{l.name}</option> <option key={l.id} value={l.id}>{l.name}</option>
))} ))}
</select> </Form.Select>
</div> </FilterSection>
<div>
<label className="form-label small text-secondary">Machine</label> <FilterSection
label="Machine"
badge={`${machinetypeIds.length} selected`}
defaultOpen={false}
>
<MultiSelectChips <MultiSelectChips
options={machineOptions} options={machineOptions}
selected={machinetypeIds} selected={machinetypeIds}
collapsible
onToggle={(id) => { onToggle={(id) => {
setMachinetypeIds((current) => { setMachinetypeIds((current) => {
const next = new Set(current); const next = new Set(current);
@@ -316,148 +283,158 @@ export default function EntriesExplorer({
next.add(id); next.add(id);
} }
const order = machineOptions.map((item) => item.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); setPage(1);
}} }}
/> />
<div className="form-text">Preferred: {preferredMachineNames.join(", ")}</div> <div className="d-flex gap-2 mt-1">
</div> <Button
<div> variant="outline-secondary"
<label className="form-label small text-secondary">Sort</label> size="sm"
<select className="form-select" value={sort} onChange={(e) => { setSort(e.target.value as "title" | "id_desc"); setPage(1); }}> onClick={() => { setMachinetypeIds(machineOptions.map((m) => m.id)); setPage(1); }}
<option value="title">Title (AZ)</option> disabled={machinetypeIds.length === machineOptions.length}
>
All
</Button>
<Button
variant="outline-secondary"
size="sm"
onClick={() => { setMachinetypeIds(preferredMachineIds.slice()); setPage(1); }}
disabled={machinetypeIds.join(",") === preferredMachineIds.join(",")}
>
Default
</Button>
</div>
</FilterSection>
<FilterSection label="Sort &amp; Scope">
<Form.Select size="sm" className="mb-2" value={sort} onChange={(e) => { setSort(e.target.value as "title" | "id_desc"); setPage(1); }}>
<option value="title">Title (A-Z)</option>
<option value="id_desc">Newest</option> <option value="id_desc">Newest</option>
</select> </Form.Select>
</div> <Form.Select size="sm" value={scope} onChange={(e) => { setScope(e.target.value as SearchScope); setPage(1); }}>
<div> <option value="title">Titles only</option>
<label className="form-label small text-secondary">Search scope</label>
<select className="form-select" value={scope} onChange={(e) => { setScope(e.target.value as SearchScope); setPage(1); }}>
<option value="title">Titles</option>
<option value="title_aliases">Titles + Aliases</option> <option value="title_aliases">Titles + Aliases</option>
<option value="title_aliases_origins">Titles + Aliases + Origins</option> <option value="title_aliases_origins">Titles + Aliases + Origins</option>
</select> </Form.Select>
</div> </FilterSection>
{facets && (
<div> {facets && (facets.flags.hasAliases > 0 || facets.flags.hasOrigins > 0) && (
<div className="text-secondary small mb-1">Facets</div> <FilterSection label="Facets">
<div className="d-flex flex-wrap gap-2"> <div className="d-flex flex-wrap gap-1">
<button <Button
type="button" size="sm"
className={`btn btn-sm ${scope === "title_aliases" ? "btn-primary" : "btn-outline-secondary"}`} variant={scope === "title_aliases" ? "primary" : "outline-secondary"}
onClick={() => { setScope("title_aliases"); setPage(1); }} onClick={() => { setScope("title_aliases"); setPage(1); }}
disabled={facets.flags.hasAliases === 0} disabled={facets.flags.hasAliases === 0}
title="Show results that match aliases"
> >
Has aliases ({facets.flags.hasAliases}) Aliases <Badge bg="light" text="dark" className="ms-1">{facets.flags.hasAliases}</Badge>
</button> </Button>
<button <Button
type="button" size="sm"
className={`btn btn-sm ${scope === "title_aliases_origins" ? "btn-primary" : "btn-outline-secondary"}`} variant={scope === "title_aliases_origins" ? "primary" : "outline-secondary"}
onClick={() => { setScope("title_aliases_origins"); setPage(1); }} onClick={() => { setScope("title_aliases_origins"); setPage(1); }}
disabled={facets.flags.hasOrigins === 0} disabled={facets.flags.hasOrigins === 0}
title="Show results that match origins"
> >
Has origins ({facets.flags.hasOrigins}) Origins <Badge bg="light" text="dark" className="ms-1">{facets.flags.hasOrigins}</Badge>
</button> </Button>
</div> </div>
</div> </FilterSection>
)} )}
{loading && <div className="text-secondary small">Loading...</div>}
</form> {error && <Alert variant="danger" className="py-1 px-2 small mb-0">{error}</Alert>}
</Form>
</FilterSidebar> </FilterSidebar>
)} )}
> >
{data && data.items.length === 0 && !loading && ( <div className={loading ? "opacity-50" : ""} style={{ transition: "opacity 0.15s" }}>
<div className="alert alert-warning">No results.</div> {data && data.items.length === 0 && !loading && (
)} <Alert variant="warning">
{data && data.items.length > 0 && ( No results found.
<div className="table-responsive"> {hasNonDefaultMachineFilter && (
<table className="table table-striped table-hover align-middle"> <span>
<thead> {" "}Filtering by{" "}
<tr> <strong>
<th style={{ width: 80 }}>ID</th> {machinetypeIds
<th>Title</th> .map((id) => machines.find((m) => m.id === id)?.name ?? `#${id}`)
<th style={{ width: 160 }}>Genre</th> .join(", ")}
<th style={{ width: 160 }}>Machine</th> </strong>
<th style={{ width: 120 }}>Language</th> {" "}&mdash;{" "}
</tr> <Alert.Link onClick={searchAllMachines}>
</thead> search all machines
<tbody> </Alert.Link>?
{data.items.map((it) => ( </span>
<tr key={it.id}> )}
<td><EntryLink id={it.id} /></td> </Alert>
<td><EntryLink id={it.id} title={it.title} /></td> )}
<td> {data && data.items.length > 0 && (
{it.genreId != null ? ( <div className="table-responsive">
it.genreName ? ( <Table striped hover className="align-middle">
<Link href={`/zxdb/genres/${it.genreId}`}>{it.genreName}</Link> <thead>
) : ( <tr>
<span>{it.genreId}</span> <th style={{ width: 80 }}>ID</th>
) <th>Title</th>
) : ( <th style={{ width: 160 }}>Genre</th>
<span className="text-secondary">-</span> <th style={{ width: 160 }}>Machine</th>
)} <th style={{ width: 120 }}>Language</th>
</td>
<td>
{it.machinetypeId != null ? (
it.machinetypeName ? (
<Link href={`/zxdb/machinetypes/${it.machinetypeId}`}>{it.machinetypeName}</Link>
) : (
<span>{it.machinetypeId}</span>
)
) : (
<span className="text-secondary">-</span>
)}
</td>
<td>
{it.languageId ? (
it.languageName ? (
<Link href={`/zxdb/languages/${it.languageId}`}>{it.languageName}</Link>
) : (
<span>{it.languageId}</span>
)
) : (
<span className="text-secondary">-</span>
)}
</td>
</tr> </tr>
))} </thead>
</tbody> <tbody>
</table> {data.items.map((it) => (
</div> <tr key={it.id}>
)} <td><EntryLink id={it.id} /></td>
<td><EntryLink id={it.id} title={it.title} /></td>
<td>
{it.genreId != null ? (
it.genreName ? (
<Link href={`/zxdb/genres/${it.genreId}`}>{it.genreName}</Link>
) : (
<span>{it.genreId}</span>
)
) : (
<span className="text-secondary">-</span>
)}
</td>
<td>
{it.machinetypeId != null ? (
it.machinetypeName ? (
<Link href={`/zxdb/machinetypes/${it.machinetypeId}`}>{it.machinetypeName}</Link>
) : (
<span>{it.machinetypeId}</span>
)
) : (
<span className="text-secondary">-</span>
)}
</td>
<td>
{it.languageId ? (
it.languageName ? (
<Link href={`/zxdb/languages/${it.languageId}`}>{it.languageName}</Link>
) : (
<span>{it.languageId}</span>
)
) : (
<span className="text-secondary">-</span>
)}
</td>
</tr>
))}
</tbody>
</Table>
</div>
)}
</div>
</ExplorerLayout> </ExplorerLayout>
<div className="d-flex align-items-center gap-2 mt-4"> <Pagination
<span>Page {data?.page ?? 1} / {totalPages}</span> page={data?.page ?? page}
<div className="ms-auto d-flex gap-2"> totalPages={totalPages}
<Link loading={loading}
className={`btn btn-outline-secondary ${!data || (data.page <= 1) ? "disabled" : ""}`} buildHref={buildHref}
aria-disabled={!data || data.page <= 1} onPageChange={setPage}
href={prevHref} />
onClick={(e) => {
if (!data || data.page <= 1) return;
e.preventDefault();
setPage((p) => Math.max(1, p - 1));
}}
>
Prev
</Link>
<Link
className={`btn btn-outline-secondary ${!data || (data.page >= 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
</Link>
</div>
</div>
<hr /> <hr />
<div className="d-flex flex-wrap gap-2"> <div className="d-flex flex-wrap gap-2">

File diff suppressed because it is too large Load Diff

View File

@@ -1,9 +1,10 @@
"use client"; "use client";
import { useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import ZxdbBreadcrumbs from "@/app/zxdb/components/ZxdbBreadcrumbs"; import ZxdbBreadcrumbs from "@/app/zxdb/components/ZxdbBreadcrumbs";
import Pagination from "@/components/explorer/Pagination";
type Genre = { id: number; name: string }; type Genre = { id: number; name: string };
type Paged<T> = { items: T[]; page: number; pageSize: number; total: number }; type Paged<T> = { items: T[]; page: number; pageSize: number; total: number };
@@ -30,6 +31,13 @@ export default function GenresSearch({ initial, initialQ }: { initial?: Paged<Ge
router.push(`/zxdb/genres?${params.toString()}`); router.push(`/zxdb/genres?${params.toString()}`);
} }
const buildHref = useCallback((p: number) => {
const params = new URLSearchParams();
if (q) params.set("q", q);
params.set("page", String(p));
return `/zxdb/genres?${params.toString()}`;
}, [q]);
return ( return (
<div> <div>
<ZxdbBreadcrumbs <ZxdbBreadcrumbs
@@ -53,7 +61,7 @@ export default function GenresSearch({ initial, initialQ }: { initial?: Paged<Ge
<form className="d-flex flex-column gap-2" onSubmit={submit}> <form className="d-flex flex-column gap-2" onSubmit={submit}>
<div> <div>
<label className="form-label small text-secondary">Search</label> <label className="form-label small text-secondary">Search</label>
<input className="form-control" placeholder="Search genres" value={q} onChange={(e) => setQ(e.target.value)} /> <input className="form-control" placeholder="Search genres..." value={q} onChange={(e) => setQ(e.target.value)} />
</div> </div>
<div className="d-grid"> <div className="d-grid">
<button className="btn btn-primary">Search</button> <button className="btn btn-primary">Search</button>
@@ -90,25 +98,12 @@ export default function GenresSearch({ initial, initialQ }: { initial?: Paged<Ge
</div> </div>
</div> </div>
<div className="d-flex align-items-center gap-2 mt-2"> <Pagination
<span>Page {data?.page ?? 1} / {totalPages}</span> page={data?.page ?? 1}
<div className="ms-auto d-flex gap-2"> totalPages={totalPages}
<Link buildHref={buildHref}
className={`btn btn-outline-secondary ${!data || (data.page <= 1) ? "disabled" : ""}`} onPageChange={(p) => router.push(buildHref(p))}
aria-disabled={!data || data.page <= 1} />
href={`/zxdb/genres?${(() => { 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
</Link>
<Link
className={`btn btn-outline-secondary ${!data || (data.page >= 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
</Link>
</div>
</div>
</div> </div>
); );
} }

View File

@@ -1,9 +1,10 @@
"use client"; "use client";
import { useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import ZxdbBreadcrumbs from "@/app/zxdb/components/ZxdbBreadcrumbs"; import ZxdbBreadcrumbs from "@/app/zxdb/components/ZxdbBreadcrumbs";
import Pagination from "@/components/explorer/Pagination";
type Label = { id: number; name: string; labeltypeId: string | null }; type Label = { id: number; name: string; labeltypeId: string | null };
type Paged<T> = { items: T[]; page: number; pageSize: number; total: number }; type Paged<T> = { items: T[]; page: number; pageSize: number; total: number };
@@ -14,12 +15,10 @@ export default function LabelsSearch({ initial, initialQ }: { initial?: Paged<La
const [data, setData] = useState<Paged<Label> | null>(initial ?? null); const [data, setData] = useState<Paged<Label> | null>(initial ?? null);
const totalPages = useMemo(() => (data ? Math.max(1, Math.ceil(data.total / data.pageSize)) : 1), [data]); 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(() => { useEffect(() => {
if (initial) setData(initial); if (initial) setData(initial);
}, [initial]); }, [initial]);
// Keep input in sync with URL q on navigation
useEffect(() => { useEffect(() => {
setQ(initialQ ?? ""); setQ(initialQ ?? "");
}, [initialQ]); }, [initialQ]);
@@ -32,6 +31,13 @@ export default function LabelsSearch({ initial, initialQ }: { initial?: Paged<La
router.push(`/zxdb/labels?${params.toString()}`); router.push(`/zxdb/labels?${params.toString()}`);
} }
const buildHref = useCallback((p: number) => {
const params = new URLSearchParams();
if (q) params.set("q", q);
params.set("page", String(p));
return `/zxdb/labels?${params.toString()}`;
}, [q]);
return ( return (
<div> <div>
<ZxdbBreadcrumbs <ZxdbBreadcrumbs
@@ -55,7 +61,7 @@ export default function LabelsSearch({ initial, initialQ }: { initial?: Paged<La
<form className="d-flex flex-column gap-2" onSubmit={submit}> <form className="d-flex flex-column gap-2" onSubmit={submit}>
<div> <div>
<label className="form-label small text-secondary">Search</label> <label className="form-label small text-secondary">Search</label>
<input className="form-control" placeholder="Search labels" value={q} onChange={(e) => setQ(e.target.value)} /> <input className="form-control" placeholder="Search labels..." value={q} onChange={(e) => setQ(e.target.value)} />
</div> </div>
<div className="d-grid"> <div className="d-grid">
<button className="btn btn-primary">Search</button> <button className="btn btn-primary">Search</button>
@@ -96,25 +102,12 @@ export default function LabelsSearch({ initial, initialQ }: { initial?: Paged<La
</div> </div>
</div> </div>
<div className="d-flex align-items-center gap-2 mt-2"> <Pagination
<span>Page {data?.page ?? 1} / {totalPages}</span> page={data?.page ?? 1}
<div className="ms-auto d-flex gap-2"> totalPages={totalPages}
<Link buildHref={buildHref}
className={`btn btn-outline-secondary ${!data || (data.page <= 1) ? "disabled" : ""}`} onPageChange={(p) => router.push(buildHref(p))}
aria-disabled={!data || data.page <= 1} />
href={`/zxdb/labels?${(() => { 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
</Link>
<Link
className={`btn btn-outline-secondary ${!data || (data.page >= 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
</Link>
</div>
</div>
</div> </div>
); );
} }

View File

@@ -1,9 +1,10 @@
"use client"; "use client";
import { useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import ZxdbBreadcrumbs from "@/app/zxdb/components/ZxdbBreadcrumbs"; import ZxdbBreadcrumbs from "@/app/zxdb/components/ZxdbBreadcrumbs";
import Pagination from "@/components/explorer/Pagination";
type Language = { id: string; name: string }; type Language = { id: string; name: string };
type Paged<T> = { items: T[]; page: number; pageSize: number; total: number }; type Paged<T> = { 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()}`); 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 ( return (
<div> <div>
<ZxdbBreadcrumbs <ZxdbBreadcrumbs
@@ -53,7 +61,7 @@ export default function LanguagesSearch({ initial, initialQ }: { initial?: Paged
<form className="d-flex flex-column gap-2" onSubmit={submit}> <form className="d-flex flex-column gap-2" onSubmit={submit}>
<div> <div>
<label className="form-label small text-secondary">Search</label> <label className="form-label small text-secondary">Search</label>
<input className="form-control" placeholder="Search languages" value={q} onChange={(e) => setQ(e.target.value)} /> <input className="form-control" placeholder="Search languages..." value={q} onChange={(e) => setQ(e.target.value)} />
</div> </div>
<div className="d-grid"> <div className="d-grid">
<button className="btn btn-primary">Search</button> <button className="btn btn-primary">Search</button>
@@ -90,25 +98,12 @@ export default function LanguagesSearch({ initial, initialQ }: { initial?: Paged
</div> </div>
</div> </div>
<div className="d-flex align-items-center gap-2 mt-2"> <Pagination
<span>Page {data?.page ?? 1} / {totalPages}</span> page={data?.page ?? 1}
<div className="ms-auto d-flex gap-2"> totalPages={totalPages}
<Link buildHref={buildHref}
className={`btn btn-outline-secondary ${!data || (data.page <= 1) ? "disabled" : ""}`} onPageChange={(p) => router.push(buildHref(p))}
aria-disabled={!data || data.page <= 1} />
href={`/zxdb/languages?${(() => { 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
</Link>
<Link
className={`btn btn-outline-secondary ${!data || (data.page >= 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
</Link>
</div>
</div>
</div> </div>
); );
} }

View File

@@ -1,9 +1,10 @@
"use client"; "use client";
import { useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import ZxdbBreadcrumbs from "@/app/zxdb/components/ZxdbBreadcrumbs"; import ZxdbBreadcrumbs from "@/app/zxdb/components/ZxdbBreadcrumbs";
import Pagination from "@/components/explorer/Pagination";
type MT = { id: number; name: string }; type MT = { id: number; name: string };
type Paged<T> = { items: T[]; page: number; pageSize: number; total: number }; type Paged<T> = { items: T[]; page: number; pageSize: number; total: number };
@@ -14,12 +15,10 @@ export default function MachineTypesSearch({ initial, initialQ }: { initial?: Pa
const [data, setData] = useState<Paged<MT> | null>(initial ?? null); const [data, setData] = useState<Paged<MT> | null>(initial ?? null);
const totalPages = useMemo(() => (data ? Math.max(1, Math.ceil(data.total / data.pageSize)) : 1), [data]); 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(() => { useEffect(() => {
if (initial) setData(initial); if (initial) setData(initial);
}, [initial]); }, [initial]);
// Keep input in sync with URL q on navigation
useEffect(() => { useEffect(() => {
setQ(initialQ ?? ""); setQ(initialQ ?? "");
}, [initialQ]); }, [initialQ]);
@@ -32,6 +31,13 @@ export default function MachineTypesSearch({ initial, initialQ }: { initial?: Pa
router.push(`/zxdb/machinetypes?${params.toString()}`); 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 ( return (
<div> <div>
<ZxdbBreadcrumbs <ZxdbBreadcrumbs
@@ -55,7 +61,7 @@ export default function MachineTypesSearch({ initial, initialQ }: { initial?: Pa
<form className="d-flex flex-column gap-2" onSubmit={submit}> <form className="d-flex flex-column gap-2" onSubmit={submit}>
<div> <div>
<label className="form-label small text-secondary">Search</label> <label className="form-label small text-secondary">Search</label>
<input className="form-control" placeholder="Search machine types" value={q} onChange={(e) => setQ(e.target.value)} /> <input className="form-control" placeholder="Search machine types..." value={q} onChange={(e) => setQ(e.target.value)} />
</div> </div>
<div className="d-grid"> <div className="d-grid">
<button className="btn btn-primary">Search</button> <button className="btn btn-primary">Search</button>
@@ -92,25 +98,12 @@ export default function MachineTypesSearch({ initial, initialQ }: { initial?: Pa
</div> </div>
</div> </div>
<div className="d-flex align-items-center gap-2 mt-2"> <Pagination
<span>Page {data?.page ?? 1} / {totalPages}</span> page={data?.page ?? 1}
<div className="ms-auto d-flex gap-2"> totalPages={totalPages}
<Link buildHref={buildHref}
className={`btn btn-outline-secondary ${!data || (data.page <= 1) ? "disabled" : ""}`} onPageChange={(p) => router.push(buildHref(p))}
aria-disabled={!data || data.page <= 1} />
href={`/zxdb/machinetypes?${(() => { 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
</Link>
<Link
className={`btn btn-outline-secondary ${!data || (data.page >= 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
</Link>
</div>
</div>
</div> </div>
); );
} }

View File

@@ -2,14 +2,20 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import Link from "next/link"; 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 EntryLink from "../components/EntryLink";
import { usePathname, useRouter } from "next/navigation"; import { usePathname, useRouter } from "next/navigation";
import ZxdbBreadcrumbs from "@/app/zxdb/components/ZxdbBreadcrumbs"; import ZxdbBreadcrumbs from "@/app/zxdb/components/ZxdbBreadcrumbs";
import ExplorerLayout from "@/components/explorer/ExplorerLayout"; import ExplorerLayout from "@/components/explorer/ExplorerLayout";
import FilterSidebar from "@/components/explorer/FilterSidebar"; import FilterSidebar from "@/components/explorer/FilterSidebar";
import FilterSection from "@/components/explorer/FilterSection";
import MultiSelectChips from "@/components/explorer/MultiSelectChips"; import MultiSelectChips from "@/components/explorer/MultiSelectChips";
import Pagination from "@/components/explorer/Pagination";
import useSearchFetch from "@/hooks/useSearchFetch";
const preferredMachineIds = [27, 26, 8, 9]; const preferredMachineIds = [27, 26, 8, 9];
const PAGE_SIZE = 20;
function parseMachineIds(value?: string) { function parseMachineIds(value?: string) {
if (!value) return preferredMachineIds.slice(); if (!value) return preferredMachineIds.slice();
@@ -38,7 +44,6 @@ type Paged<T> = {
export default function ReleasesExplorer({ export default function ReleasesExplorer({
initial, initial,
initialUrlState, initialUrlState,
initialUrlHasParams,
initialLists, initialLists,
}: { }: {
initial?: Paged<Item>; initial?: Paged<Item>;
@@ -48,12 +53,12 @@ export default function ReleasesExplorer({
year: string; year: string;
sort: "year_desc" | "year_asc" | "title" | "entry_id_desc"; sort: "year_desc" | "year_asc" | "title" | "entry_id_desc";
dLanguageId?: string; dLanguageId?: string;
dMachinetypeId?: string; // keep as string for URL/state consistency dMachinetypeId?: string;
filetypeId?: string; filetypeId?: string;
schemetypeId?: string; schemetypeId?: string;
sourcetypeId?: string; sourcetypeId?: string;
casetypeId?: string; casetypeId?: string;
isDemo?: string; // "1" or "true" isDemo?: string;
}; };
initialUrlHasParams?: boolean; initialUrlHasParams?: boolean;
initialLists?: { initialLists?: {
@@ -68,15 +73,12 @@ export default function ReleasesExplorer({
const router = useRouter(); const router = useRouter();
const pathname = usePathname(); const pathname = usePathname();
// -- Search state --
const [q, setQ] = useState(initialUrlState?.q ?? ""); const [q, setQ] = useState(initialUrlState?.q ?? "");
const [appliedQ, setAppliedQ] = useState(initialUrlState?.q ?? ""); const [appliedQ, setAppliedQ] = useState(initialUrlState?.q ?? "");
const [page, setPage] = useState(initial?.page ?? initialUrlState?.page ?? 1); const [page, setPage] = useState(initial?.page ?? initialUrlState?.page ?? 1);
const [loading, setLoading] = useState(false);
const [data, setData] = useState<Paged<Item> | null>(initial ?? null);
const [year, setYear] = useState<string>(initialUrlState?.year ?? ""); const [year, setYear] = useState<string>(initialUrlState?.year ?? "");
const [sort, setSort] = useState<"year_desc" | "year_asc" | "title" | "entry_id_desc">(initialUrlState?.sort ?? "year_desc"); 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<string>(initialUrlState?.dLanguageId ?? ""); const [dLanguageId, setDLanguageId] = useState<string>(initialUrlState?.dLanguageId ?? "");
const [dMachinetypeIds, setDMachinetypeIds] = useState<number[]>(parseMachineIds(initialUrlState?.dMachinetypeId)); const [dMachinetypeIds, setDMachinetypeIds] = useState<number[]>(parseMachineIds(initialUrlState?.dMachinetypeId));
const [filetypeId, setFiletypeId] = useState<string>(initialUrlState?.filetypeId ?? ""); const [filetypeId, setFiletypeId] = useState<string>(initialUrlState?.filetypeId ?? "");
@@ -85,53 +87,52 @@ export default function ReleasesExplorer({
const [casetypeId, setCasetypeId] = useState<string>(initialUrlState?.casetypeId ?? ""); const [casetypeId, setCasetypeId] = useState<string>(initialUrlState?.casetypeId ?? "");
const [isDemo, setIsDemo] = useState<boolean>(!!(initialUrlState?.isDemo && (initialUrlState.isDemo === "1" || initialUrlState.isDemo === "true"))); const [isDemo, setIsDemo] = useState<boolean>(!!(initialUrlState?.isDemo && (initialUrlState.isDemo === "1" || initialUrlState.isDemo === "true")));
// -- Filter lists --
const [langs, setLangs] = useState<{ id: string; name: string }[]>(initialLists?.languages ?? []); const [langs, setLangs] = useState<{ id: string; name: string }[]>(initialLists?.languages ?? []);
const [machines, setMachines] = useState<{ id: number; name: string }[]>(initialLists?.machinetypes ?? []); const [machines, setMachines] = useState<{ id: number; name: string }[]>(initialLists?.machinetypes ?? []);
const [filetypes, setFiletypes] = useState<{ id: number; name: string }[]>(initialLists?.filetypes ?? []); const [filetypes, setFiletypes] = useState<{ id: number; name: string }[]>(initialLists?.filetypes ?? []);
const [schemes, setSchemes] = useState<{ id: string; name: string }[]>(initialLists?.schemetypes ?? []); const [schemes, setSchemes] = useState<{ id: string; name: string }[]>(initialLists?.schemetypes ?? []);
const [sources, setSources] = useState<{ id: string; name: string }[]>(initialLists?.sourcetypes ?? []); const [sources, setSources] = useState<{ id: string; name: string }[]>(initialLists?.sourcetypes ?? []);
const [cases, setCases] = useState<{ id: string; name: string }[]>(initialLists?.casetypes ?? []); const [cases, setCases] = useState<{ id: string; name: string }[]>(initialLists?.casetypes ?? []);
const initialLoad = useRef(true);
const preferredMachineNames = useMemo(() => { // -- Fetch with abort control --
if (!machines.length) return preferredMachineIds.map((id) => `#${id}`); const { data, loading, error, fetch: doFetch, syncData } = useSearchFetch<Item>(
return preferredMachineIds.map((id) => machines.find((m) => m.id === id)?.name ?? `#${id}`); "/api/zxdb/releases/search",
}, [machines]); initial ?? null,
);
const isFirstRender = useRef(true);
// Debounce timer for year input changes
const yearDebounce = useRef<ReturnType<typeof setTimeout>>(undefined);
const totalPages = useMemo(
() => (data ? Math.max(1, Math.ceil(data.total / data.pageSize)) : 1),
[data],
);
const orderedMachines = useMemo(() => { const orderedMachines = useMemo(() => {
const seen = new Set(preferredMachineIds); const seen = new Set(preferredMachineIds);
const preferred = preferredMachineIds.map((id) => machines.find((m) => m.id === id)).filter(Boolean) as { id: number; name: string }[]; 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)); const rest = machines.filter((m) => !seen.has(m.id));
return [...preferred, ...rest]; return [...preferred, ...rest];
}, [machines]); }, [machines]);
const machineOptions = useMemo(() => orderedMachines.map((m) => ({ id: m.id, label: m.name })), [orderedMachines]);
const pageSize = 20; const machineOptions = useMemo(
const totalPages = useMemo(() => (data ? Math.max(1, Math.ceil(data.total / data.pageSize)) : 1), [data]); () => orderedMachines.map((m) => ({ id: m.id, label: m.name })),
[orderedMachines],
);
const updateUrl = useCallback((nextPage = page) => { const hasNonDefaultMachineFilter = dMachinetypeIds.join(",") !== preferredMachineIds.join(",") ||
const params = new URLSearchParams(); dMachinetypeIds.length !== machines.length;
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 fetchData = useCallback(async (query: string, p: number) => { // -- URL helpers --
setLoading(true); const buildParams = useCallback(
try { (p: number) => {
const params = new URLSearchParams(); const params = new URLSearchParams();
if (query) params.set("q", query); if (appliedQ) params.set("q", appliedQ);
params.set("page", String(p)); params.set("page", String(p));
params.set("pageSize", String(pageSize)); if (year) params.set("year", year);
if (year) params.set("year", String(Number(year)));
if (sort) params.set("sort", sort); if (sort) params.set("sort", sort);
if (dLanguageId) params.set("dLanguageId", dLanguageId); if (dLanguageId) params.set("dLanguageId", dLanguageId);
if (dMachinetypeIds.length > 0) params.set("dMachinetypeId", dMachinetypeIds.join(",")); if (dMachinetypeIds.length > 0) params.set("dMachinetypeId", dMachinetypeIds.join(","));
@@ -140,79 +141,44 @@ export default function ReleasesExplorer({
if (sourcetypeId) params.set("sourcetypeId", sourcetypeId); if (sourcetypeId) params.set("sourcetypeId", sourcetypeId);
if (casetypeId) params.set("casetypeId", casetypeId); if (casetypeId) params.set("casetypeId", casetypeId);
if (isDemo) params.set("isDemo", "1"); if (isDemo) params.set("isDemo", "1");
const res = await fetch(`/api/zxdb/releases/search?${params.toString()}`); return params;
if (!res.ok) throw new Error(`Failed: ${res.status}`); },
const json: Paged<Item> = await res.json(); [appliedQ, year, sort, dLanguageId, dMachinetypeIds, filetypeId, schemetypeId, sourcetypeId, casetypeId, isDemo],
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]);
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(() => { useEffect(() => {
if (initial) { if (isFirstRender.current) {
setData(initial); isFirstRender.current = false;
setPage(initial.page); router.replace(buildHref(page), { scroll: false });
}
}, [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);
return; 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) { router.replace(buildHref(page), { scroll: false });
e.preventDefault();
setAppliedQ(q);
setPage(1);
}
// 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(() => { 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() { async function loadLists() {
if (langs.length || machines.length || filetypes.length || schemes.length || sources.length || cases.length) return;
try { try {
const [l, m, ft, sc, so, ca] = await Promise.all([ const [l, m, ft, sc, so, ca] = await Promise.all([
fetch("/api/zxdb/languages", { cache: "force-cache" }).then((r) => r.json()), fetch("/api/zxdb/languages", { cache: "force-cache" }).then((r) => r.json()),
@@ -228,44 +194,42 @@ export default function ReleasesExplorer({
setSchemes(sc.items ?? []); setSchemes(sc.items ?? []);
setSources(so.items ?? []); setSources(so.items ?? []);
setCases(ca.items ?? []); setCases(ca.items ?? []);
} catch { } catch { /* filter lists are non-critical */ }
// ignore
}
} }
loadLists(); loadLists();
}, [cases.length, filetypes.length, langs.length, machines.length, schemes.length, sources.length]); }, [cases.length, filetypes.length, langs.length, machines.length, schemes.length, sources.length]);
const prevHref = useMemo(() => { function onSubmit(e: React.FormEvent) {
const params = new URLSearchParams(); e.preventDefault();
if (appliedQ) params.set("q", appliedQ); setAppliedQ(q);
params.set("page", String(Math.max(1, (data?.page ?? 1) - 1))); setPage(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]);
const nextHref = useMemo(() => { function onYearChange(value: string) {
const params = new URLSearchParams(); setYear(value);
if (appliedQ) params.set("q", appliedQ); clearTimeout(yearDebounce.current);
params.set("page", String(Math.max(1, (data?.page ?? 1) + 1))); yearDebounce.current = setTimeout(() => setPage(1), 400);
if (year) params.set("year", year); }
if (sort) params.set("sort", sort);
if (dLanguageId) params.set("dLanguageId", dLanguageId); function searchAllMachines() {
if (dMachinetypeIds.length > 0) params.set("dMachinetypeId", dMachinetypeIds.join(",")); setDMachinetypeIds(machineOptions.map((m) => m.id));
if (filetypeId) params.set("filetypeId", filetypeId); setPage(1);
if (schemetypeId) params.set("schemetypeId", schemetypeId); }
if (sourcetypeId) params.set("sourcetypeId", sourcetypeId);
if (casetypeId) params.set("casetypeId", casetypeId); function resetFilters() {
if (isDemo) params.set("isDemo", "1"); setQ("");
return `/zxdb/releases?${params.toString()}`; setAppliedQ("");
}, [appliedQ, data?.page, year, sort, dLanguageId, dMachinetypeIds, filetypeId, schemetypeId, sourcetypeId, casetypeId, isDemo]); setYear("");
setSort("year_desc");
setDLanguageId("");
setDMachinetypeIds(preferredMachineIds.slice());
setFiletypeId("");
setSchemetypeId("");
setSourcetypeId("");
setCasetypeId("");
setIsDemo(false);
setPage(1);
}
return ( return (
<div> <div>
@@ -280,121 +244,159 @@ export default function ReleasesExplorer({
title="Releases" title="Releases"
subtitle={data ? `${data.total.toLocaleString()} results` : "Loading results..."} subtitle={data ? `${data.total.toLocaleString()} results` : "Loading results..."}
sidebar={( sidebar={(
<FilterSidebar> <FilterSidebar onReset={resetFilters} loading={loading}>
<form className="d-flex flex-column gap-2" onSubmit={onSubmit}> <Form onSubmit={onSubmit} className="d-flex flex-column gap-2">
<div> <InputGroup>
<label className="form-label small text-secondary">Search title</label> <Form.Control
<input type="text"
type="text" placeholder="Search titles..."
className="form-control" value={q}
placeholder="Filter by entry title..." onChange={(e) => setQ(e.target.value)}
value={q} />
onChange={(e) => setQ(e.target.value)} <Button variant="primary" type="submit" disabled={loading}>
/> <Search size={14} />
</Button>
</InputGroup>
<FilterSection label="Year" badge={year || undefined}>
<Form.Control
type="number"
size="sm"
placeholder="Any"
value={year}
onChange={(e) => onYearChange(e.target.value)}
/>
</FilterSection>
<FilterSection label="Language" badge={dLanguageId ? langs.find((l) => l.id === dLanguageId)?.name : undefined}>
<Form.Select size="sm" value={dLanguageId} onChange={(e) => { setDLanguageId(e.target.value); setPage(1); }}>
<option value="">All languages</option>
{langs.map((l) => (
<option key={l.id} value={l.id}>{l.name}</option>
))}
</Form.Select>
</FilterSection>
<FilterSection
label="Machine"
badge={`${dMachinetypeIds.length} selected`}
defaultOpen={false}
>
<MultiSelectChips
options={machineOptions}
selected={dMachinetypeIds}
collapsible
onToggle={(id) => {
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);
}}
/>
<div className="d-flex gap-2 mt-1">
<Button
variant="outline-secondary"
size="sm"
onClick={() => { setDMachinetypeIds(machineOptions.map((m) => m.id)); setPage(1); }}
disabled={dMachinetypeIds.length === machineOptions.length}
>
All
</Button>
<Button
variant="outline-secondary"
size="sm"
onClick={() => { setDMachinetypeIds(preferredMachineIds.slice()); setPage(1); }}
disabled={dMachinetypeIds.join(",") === preferredMachineIds.join(",")}
>
Default
</Button>
</div> </div>
<div className="d-grid"> </FilterSection>
<button className="btn btn-primary" type="submit" disabled={loading}>Search</button>
</div> <FilterSection label="Download filters" defaultOpen={false} badge={
<div> [filetypeId, schemetypeId, sourcetypeId, casetypeId, isDemo ? "demo" : ""].filter(Boolean).length
<label className="form-label small text-secondary">Year</label> ? `${[filetypeId, schemetypeId, sourcetypeId, casetypeId, isDemo ? "demo" : ""].filter(Boolean).length} active`
<input : undefined
type="number" }>
className="form-control" <div className="d-flex flex-column gap-2">
placeholder="Any" <Form.Select size="sm" value={filetypeId} onChange={(e) => { setFiletypeId(e.target.value); setPage(1); }}>
value={year}
onChange={(e) => { setYear(e.target.value); setPage(1); }}
/>
</div>
<div>
<label className="form-label small text-secondary">DL Language</label>
<select className="form-select" value={dLanguageId} onChange={(e) => { setDLanguageId(e.target.value); setPage(1); }}>
<option value="">All languages</option>
{langs.map((l) => (
<option key={l.id} value={l.id}>{l.name}</option>
))}
</select>
</div>
<div>
<label className="form-label small text-secondary">DL Machine</label>
<MultiSelectChips
options={machineOptions}
selected={dMachinetypeIds}
onToggle={(id) => {
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);
}}
/>
<div className="form-text">Preferred: {preferredMachineNames.join(", ")}</div>
</div>
<div>
<label className="form-label small text-secondary">File type</label>
<select className="form-select" value={filetypeId} onChange={(e) => { setFiletypeId(e.target.value); setPage(1); }}>
<option value="">All file types</option> <option value="">All file types</option>
{filetypes.map((ft) => ( {filetypes.map((ft) => (
<option key={ft.id} value={ft.id}>{ft.name}</option> <option key={ft.id} value={ft.id}>{ft.name}</option>
))} ))}
</select> </Form.Select>
</div> <Form.Select size="sm" value={schemetypeId} onChange={(e) => { setSchemetypeId(e.target.value); setPage(1); }}>
<div>
<label className="form-label small text-secondary">Scheme</label>
<select className="form-select" value={schemetypeId} onChange={(e) => { setSchemetypeId(e.target.value); setPage(1); }}>
<option value="">All schemes</option> <option value="">All schemes</option>
{schemes.map((s) => ( {schemes.map((s) => (
<option key={s.id} value={s.id}>{s.name}</option> <option key={s.id} value={s.id}>{s.name}</option>
))} ))}
</select> </Form.Select>
</div> <Form.Select size="sm" value={sourcetypeId} onChange={(e) => { setSourcetypeId(e.target.value); setPage(1); }}>
<div>
<label className="form-label small text-secondary">Source</label>
<select className="form-select" value={sourcetypeId} onChange={(e) => { setSourcetypeId(e.target.value); setPage(1); }}>
<option value="">All sources</option> <option value="">All sources</option>
{sources.map((s) => ( {sources.map((s) => (
<option key={s.id} value={s.id}>{s.name}</option> <option key={s.id} value={s.id}>{s.name}</option>
))} ))}
</select> </Form.Select>
</div> <Form.Select size="sm" value={casetypeId} onChange={(e) => { setCasetypeId(e.target.value); setPage(1); }}>
<div>
<label className="form-label small text-secondary">Case</label>
<select className="form-select" value={casetypeId} onChange={(e) => { setCasetypeId(e.target.value); setPage(1); }}>
<option value="">All cases</option> <option value="">All cases</option>
{cases.map((c) => ( {cases.map((c) => (
<option key={c.id} value={c.id}>{c.name}</option> <option key={c.id} value={c.id}>{c.name}</option>
))} ))}
</select> </Form.Select>
<Form.Check
id="demoCheck"
label="Demo only"
checked={isDemo}
onChange={(e) => { setIsDemo(e.target.checked); setPage(1); }}
/>
</div> </div>
<div className="form-check"> </FilterSection>
<input id="demoCheck" className="form-check-input" type="checkbox" checked={isDemo} onChange={(e) => { setIsDemo(e.target.checked); setPage(1); }} />
<label className="form-check-label" htmlFor="demoCheck">Demo only</label> <FilterSection label="Sort">
</div> <Form.Select size="sm" value={sort} onChange={(e) => { setSort(e.target.value as typeof sort); setPage(1); }}>
<div> <option value="year_desc">Newest</option>
<label className="form-label small text-secondary">Sort</label> <option value="year_asc">Oldest</option>
<select className="form-select" value={sort} onChange={(e) => { setSort(e.target.value as typeof sort); setPage(1); }}> <option value="title">Title</option>
<option value="year_desc">Newest</option> <option value="entry_id_desc">Entry ID</option>
<option value="year_asc">Oldest</option> </Form.Select>
<option value="title">Title</option> </FilterSection>
<option value="entry_id_desc">Entry ID</option>
</select> {error && <Alert variant="danger" className="py-1 px-2 small mb-0">{error}</Alert>}
</div> </Form>
{loading && <div className="text-secondary small">Loading...</div>} </FilterSidebar>
</form> )}
</FilterSidebar> >
)} <div className={loading ? "opacity-50" : ""} style={{ transition: "opacity 0.15s" }}>
>
{data && data.items.length === 0 && !loading && ( {data && data.items.length === 0 && !loading && (
<div className="alert alert-warning">No results.</div> <Alert variant="warning">
No results found.
{hasNonDefaultMachineFilter && (
<span>
{" "}Filtering by{" "}
<strong>
{dMachinetypeIds
.map((id) => machines.find((m) => m.id === id)?.name ?? `#${id}`)
.join(", ")}
</strong>
{" "}&mdash;{" "}
<Alert.Link onClick={searchAllMachines}>
search all machines
</Alert.Link>?
</span>
)}
</Alert>
)} )}
{data && data.items.length > 0 && ( {data && data.items.length > 0 && (
<div className="table-responsive"> <div className="table-responsive">
<table className="table table-striped table-hover align-middle"> <Table striped hover className="align-middle">
<thead> <thead>
<tr> <tr>
<th style={{ width: 80 }}>Entry ID</th> <th style={{ width: 80 }}>Entry ID</th>
@@ -411,11 +413,9 @@ export default function ReleasesExplorer({
<EntryLink id={it.entryId} /> <EntryLink id={it.entryId} />
</td> </td>
<td> <td>
<div className="d-flex flex-column gap-1"> <Link href={`/zxdb/releases/${it.entryId}/${it.releaseSeq}`} className="link-underline link-underline-opacity-0">
<Link href={`/zxdb/releases/${it.entryId}/${it.releaseSeq}`} className="link-underline link-underline-opacity-0"> {it.entryTitle || `Entry #${it.entryId}`}
{it.entryTitle || `Entry #${it.entryId}`} </Link>
</Link>
</div>
</td> </td>
<td> <td>
<Link href={`/zxdb/releases/${it.entryId}/${it.releaseSeq}`}> <Link href={`/zxdb/releases/${it.entryId}/${it.releaseSeq}`}>
@@ -433,40 +433,19 @@ export default function ReleasesExplorer({
</tr> </tr>
))} ))}
</tbody> </tbody>
</table> </Table>
</div> </div>
)} )}
</div>
</ExplorerLayout> </ExplorerLayout>
<div className="d-flex align-items-center gap-2 mt-4"> <Pagination
<span>Page {data?.page ?? 1} / {totalPages}</span> page={data?.page ?? page}
<div className="ms-auto d-flex gap-2"> totalPages={totalPages}
<Link loading={loading}
className={`btn btn-outline-secondary ${!data || (data.page <= 1) ? "disabled" : ""}`} buildHref={buildHref}
aria-disabled={!data || data.page <= 1} onPageChange={setPage}
href={prevHref} />
onClick={(e) => {
if (!data || data.page <= 1) return;
e.preventDefault();
setPage((p) => Math.max(1, p - 1));
}}
>
Prev
</Link>
<Link
className={`btn btn-outline-secondary ${!data || (data.page >= 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
</Link>
</div>
</div>
</div> </div>
); );
} }

View File

@@ -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 (
<div className="border-bottom pb-2">
<button
type="button"
className="btn btn-sm w-100 d-flex align-items-center justify-content-between p-0 text-start"
onClick={() => setOpen((v) => !v)}
aria-expanded={open}
>
<span className="form-label small text-secondary mb-0 fw-semibold">{label}</span>
<span className="d-flex align-items-center gap-1">
{!open && badge && (
<span className="badge text-bg-primary rounded-pill" style={{ fontSize: "0.7rem" }}>
{badge}
</span>
)}
<ChevronDown
size={10}
className="text-secondary"
style={{
transition: "transform 0.2s",
transform: open ? "rotate(180deg)" : "rotate(0deg)",
}}
/>
</span>
</button>
<Collapse in={open}>
<div>
<div className="mt-1">{children}</div>
</div>
</Collapse>
</div>
);
}

View File

@@ -1,13 +1,31 @@
import { ReactNode } from "react"; import { ReactNode } from "react";
import { Card, Button } from "react-bootstrap";
type FilterSidebarProps = { type FilterSidebarProps = {
children: ReactNode; children: ReactNode;
onReset?: () => void;
loading?: boolean;
}; };
export default function FilterSidebar({ children }: FilterSidebarProps) { export default function FilterSidebar({ children, onReset, loading }: FilterSidebarProps) {
return ( return (
<div className="card shadow-sm"> <Card className="shadow-sm">
<div className="card-body">{children}</div> <Card.Body className="d-flex flex-column gap-2">
</div> {children}
{onReset && (
<div className="border-top pt-2 mt-1">
<Button
variant="outline-secondary"
size="sm"
className="w-100"
onClick={onReset}
disabled={loading}
>
Reset all filters
</Button>
</div>
)}
</Card.Body>
</Card>
); );
} }

View File

@@ -1,3 +1,7 @@
"use client";
import { useState } from "react";
type ChipOption<T extends number | string> = { type ChipOption<T extends number | string> = {
id: T; id: T;
label: string; label: string;
@@ -8,6 +12,10 @@ type MultiSelectChipsProps<T extends number | string> = {
selected: T[]; selected: T[];
onToggle: (id: T) => void; onToggle: (id: T) => void;
size?: "sm" | "md"; 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<T extends number | string>({ export default function MultiSelectChips<T extends number | string>({
@@ -15,23 +23,60 @@ export default function MultiSelectChips<T extends number | string>({
selected, selected,
onToggle, onToggle,
size = "sm", size = "sm",
collapsible = false,
collapsedMax = 3,
}: MultiSelectChipsProps<T>) { }: MultiSelectChipsProps<T>) {
const [expanded, setExpanded] = useState(!collapsible);
const btnSize = size === "sm" ? "btn-sm" : ""; 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 (
<button
type="button"
className="btn btn-sm btn-outline-secondary w-100 text-start d-flex justify-content-between align-items-center"
onClick={() => setExpanded(true)}
>
<span className="text-truncate">{summary}</span>
<span className="badge text-bg-primary rounded-pill ms-2">{selected.length}</span>
</button>
);
}
return ( return (
<div className="d-flex flex-wrap gap-2"> <div>
{options.map((option) => { <div className="d-flex flex-wrap gap-1">
const active = selected.includes(option.id); {options.map((option) => {
return ( const active = selected.includes(option.id);
<button return (
key={String(option.id)} <button
type="button" key={String(option.id)}
className={`btn ${btnSize} ${active ? "btn-primary" : "btn-outline-secondary"}`} type="button"
onClick={() => onToggle(option.id)} className={`btn ${btnSize} ${active ? "btn-primary" : "btn-outline-secondary"}`}
> onClick={() => onToggle(option.id)}
{option.label} >
</button> {option.label}
); </button>
})} );
})}
</div>
{collapsible && (
<button
type="button"
className="btn btn-link btn-sm p-0 mt-1 text-secondary"
onClick={() => setExpanded(false)}
>
Collapse
</button>
)}
</div> </div>
); );
} }

View File

@@ -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 (
<div className="d-flex align-items-center gap-2 mt-4">
<span className={loading ? "text-secondary" : ""}>
Page {page} / {totalPages}
</span>
{loading && (
<Spinner animation="border" size="sm" variant="secondary" role="status" />
)}
<div className="ms-auto d-flex gap-2">
<Button
as="a"
variant="outline-secondary"
href={prevHref}
disabled={!canPrev}
onClick={(e: React.MouseEvent) => {
if (!canPrev) return;
e.preventDefault();
onPageChange(page - 1);
}}
>
<ChevronLeft size={14} /> Prev
</Button>
<Button
as="a"
variant="outline-secondary"
href={nextHref}
disabled={!canNext}
onClick={(e: React.MouseEvent) => {
if (!canNext) return;
e.preventDefault();
onPageChange(page + 1);
}}
>
Next <ChevronRight size={14} />
</Button>
</div>
</div>
);
}

View File

@@ -0,0 +1,86 @@
"use client";
import { useCallback, useRef, useState } from "react";
type Paged<T> = {
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<T>(
endpoint: string,
initialData: Paged<T> | null = null,
onExtra?: (json: Record<string, unknown>) => void,
) {
const [data, setData] = useState<Paged<T> | null>(initialData);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const abortRef = useRef<AbortController | null>(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<T>) => {
abortRef.current?.abort();
setData(d);
setLoading(false);
setError(null);
}, []);
return { data, loading, error, fetch: fetch_, syncData };
}