Compare commits
2 Commits
feature/so
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 590b07147a | |||
| 65de62deaf |
23
AGENTS.md
23
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.
|
- `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.
|
- `[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:
|
||||||
|
|||||||
89
docs/changelog.md
Normal file
89
docs/changelog.md
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
# Changelog 📜
|
||||||
|
|
||||||
|
All notable changes to Next Explorer, in roughly chronological order.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚧 v0.3.0 — Tapes, Hashes & Downloads *(unreleased, Feb 2026)*
|
||||||
|
|
||||||
|
The great "what IS this .tap file?" release. ZXDB downloads become first-class citizens with local mirroring, grouping, inline previews, and now the ability to identify a tape by dropping it on the page.
|
||||||
|
|
||||||
|
### ✨ New
|
||||||
|
- **Tape Identifier** — drag-and-drop a `.tap`/`.tzx`/`.p`/`.o` file onto `/zxdb` and get instant ZXDB entry matches based on SHA-256 hash lookup 🎯
|
||||||
|
- **Software hashes database** — 32,960-row snapshot of SHA-256 hashes for known ZXDB downloads, with a pipeline script (`update-software-hashes.mjs`) to rebuild/extend it
|
||||||
|
- **Local ZXDB / WoS mirror support** — proxy downloads through the app's own API so self-hosted mirrors work seamlessly; inline previews rendered without leaving the page
|
||||||
|
- **Magazine reviews** — reviews now shown on magazine issue pages
|
||||||
|
- **Label detail pages** — full label view with releases, genre breakdown, and year filtering
|
||||||
|
- **Year filter** on releases/entries
|
||||||
|
|
||||||
|
### 🔧 Improved
|
||||||
|
- Download viewer reworked: grouped by format, inline previews, human-readable sizes
|
||||||
|
- Local file path resolution corrected for edge-cases
|
||||||
|
- Silently skip `/denied/` and other non-hosted prefixes during hash imports
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ v0.2.0 — ZXDB Explorer *(December 2025 – January 2026)*
|
||||||
|
|
||||||
|
The big one. A full cross-linked browser for the ZXDB software database, server-rendered for fast first paint, with deep links everywhere.
|
||||||
|
|
||||||
|
### ✨ New
|
||||||
|
- **ZXDB integration** — MySQL via `mysql2` + Drizzle ORM; Zod-validated env (`ZXDB_URL`)
|
||||||
|
- **Entries browser** — search, paginate, filter; deep-links to individual entry pages
|
||||||
|
- **Entry detail pages** — aliases, web references, relations, tags, ports, scores, origins, facets 🗂️
|
||||||
|
- **Releases browser** — filterable by machine type, genre, label, year; download links
|
||||||
|
- **Labels browser + detail** — label pages with linked releases
|
||||||
|
- **Genres, Languages, Machine Types** — category hubs with entry counts
|
||||||
|
- **Magazines + Issues** — stub magazine browser with issue listing
|
||||||
|
- **Cross-linked UI** — `EntryLink` component used everywhere; Next `Link` for prefetching
|
||||||
|
- **SSR + ISR** — index pages server-render initial data; `revalidate = 3600` on non-search pages for fast repeat visits
|
||||||
|
- **Multi-select machine type filter** with chip toggles; favouring Next hardware by default
|
||||||
|
- **Shared explorer components** — `ExplorerLayout`, `FilterSidebar`, `FilterSection`, `MultiSelectChips`, `Pagination` 🧩
|
||||||
|
- **Breadcrumbs** decoupled from search input
|
||||||
|
- **Landing page** for `/zxdb` with hub links and hero
|
||||||
|
- **Deploy helper** script (`bin/deploy.sh`)
|
||||||
|
- **OG images** for register pages (happy new year from Codex 🎉)
|
||||||
|
|
||||||
|
### 🔧 Improved
|
||||||
|
- ZXDB pagination counters fixed
|
||||||
|
- Facets filter aliasing corrected
|
||||||
|
- Case-insensitive search improvements
|
||||||
|
- Graceful handling of missing `releases` / `downloads` schema tables
|
||||||
|
- Homepage hero updated
|
||||||
|
- Registers sidebar refactored to share explorer components
|
||||||
|
|
||||||
|
### 🏗️ Infrastructure
|
||||||
|
- Zod env validation for all ZXDB config
|
||||||
|
- `information_schema.tables` check before querying optional tables
|
||||||
|
- API routes under `/api/zxdb/*` with Zod input validation, Node runtime
|
||||||
|
- ZXDB setup guide at `docs/ZXDB.md`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ✅ v0.1.0 — Registers Explorer *(October – November 2025)*
|
||||||
|
|
||||||
|
The origin story. Started from a Create Next App scaffold with a GPT-5 assist, then heavily hand-crafted into something actually useful for exploring Spectrum Next hardware registers.
|
||||||
|
|
||||||
|
### ✨ New
|
||||||
|
- **Register browser** — loads and parses `data/nextreg.txt`; real-time search/filter with results at a glance
|
||||||
|
- **Register detail pages** — per-mode bitfield views (read/write/common), notes, and source modal
|
||||||
|
- **Source viewer** — inline modal showing the raw `nextreg.txt` source for any register
|
||||||
|
- **Wikilink support** — links parsed from register definitions and rendered as external refs
|
||||||
|
- **Multi-parser architecture** — `reg_default.ts` for standard registers; `reg_f0.ts` for the exotic `0xF0` register; easy to extend 🔌
|
||||||
|
- **Multi-line footnote support** — parser handles footnotes that span multiple lines
|
||||||
|
- **Deep-linkable search** — `?q=` query param synced to the search box so searches can be bookmarked/shared 🔗
|
||||||
|
- **Dark mode** — cookie-based theme set server-side to eliminate flash-of-wrong-theme ☀️🌙
|
||||||
|
- **Bootswatch Pulse theme** — purple primary; react-bootstrap throughout
|
||||||
|
|
||||||
|
### 🔧 Improved
|
||||||
|
- iOS bitfield fix — prevent Safari turning hex values into tappable phone numbers 📱
|
||||||
|
- Parser on-demand (lazy load, not at startup)
|
||||||
|
- Robust case-insensitive search
|
||||||
|
- Linting and dead code removed; hallucination CSS cleaned up
|
||||||
|
- Dokku build pipeline stabilised (pnpm store-dir pinned)
|
||||||
|
- Next.js security bump (Dec 2025)
|
||||||
|
|
||||||
|
### 🏗️ Infrastructure
|
||||||
|
- Project self-documents via `CLAUDE.md` / `AGENT.md`
|
||||||
|
- Live `nextreg.txt` used (not bundled snapshot)
|
||||||
|
- Dev runner fixed for local development
|
||||||
@@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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,64 +283,95 @@ 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">
|
||||||
|
<Button
|
||||||
|
variant="outline-secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => { setMachinetypeIds(machineOptions.map((m) => m.id)); setPage(1); }}
|
||||||
|
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>
|
</div>
|
||||||
<div>
|
</FilterSection>
|
||||||
<label className="form-label small text-secondary">Sort</label>
|
|
||||||
<select className="form-select" value={sort} onChange={(e) => { setSort(e.target.value as "title" | "id_desc"); setPage(1); }}>
|
<FilterSection label="Sort & Scope">
|
||||||
<option value="title">Title (A–Z)</option>
|
<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>
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
<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>
|
||||||
|
{machinetypeIds
|
||||||
|
.map((id) => machines.find((m) => m.id === id)?.name ?? `#${id}`)
|
||||||
|
.join(", ")}
|
||||||
|
</strong>
|
||||||
|
{" "}—{" "}
|
||||||
|
<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 }}>ID</th>
|
<th style={{ width: 80 }}>ID</th>
|
||||||
@@ -424,40 +422,19 @@ export default function EntriesExplorer({
|
|||||||
</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>
|
|
||||||
|
|
||||||
<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
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,32 +87,51 @@ 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(",") ||
|
||||||
|
dMachinetypeIds.length !== machines.length;
|
||||||
|
|
||||||
|
// -- URL helpers --
|
||||||
|
const buildParams = useCallback(
|
||||||
|
(p: number) => {
|
||||||
const params = new URLSearchParams();
|
const params = new URLSearchParams();
|
||||||
if (appliedQ) params.set("q", appliedQ);
|
if (appliedQ) params.set("q", appliedQ);
|
||||||
params.set("page", String(nextPage));
|
params.set("page", String(p));
|
||||||
if (year) params.set("year", year);
|
if (year) params.set("year", 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);
|
||||||
@@ -120,99 +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 qs = params.toString();
|
return params;
|
||||||
router.replace(qs ? `${pathname}?${qs}` : pathname);
|
},
|
||||||
}, [appliedQ, casetypeId, dLanguageId, dMachinetypeIds, filetypeId, isDemo, page, pathname, router, schemetypeId, sort, sourcetypeId, year]);
|
[appliedQ, year, sort, dLanguageId, dMachinetypeIds, filetypeId, schemetypeId, sourcetypeId, casetypeId, isDemo],
|
||||||
|
);
|
||||||
|
|
||||||
const fetchData = useCallback(async (query: string, p: number) => {
|
const buildHref = useCallback(
|
||||||
setLoading(true);
|
(p: number) => {
|
||||||
try {
|
const qs = buildParams(p).toString();
|
||||||
const params = new URLSearchParams();
|
return qs ? `${pathname}?${qs}` : pathname;
|
||||||
if (query) params.set("q", query);
|
},
|
||||||
params.set("page", String(p));
|
[buildParams, pathname],
|
||||||
params.set("pageSize", String(pageSize));
|
);
|
||||||
if (year) params.set("year", String(Number(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 res = await fetch(`/api/zxdb/releases/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);
|
|
||||||
}
|
|
||||||
}, [casetypeId, dLanguageId, dMachinetypeIds, filetypeId, isDemo, pageSize, schemetypeId, sort, sourcetypeId, year]);
|
|
||||||
|
|
||||||
|
// -- 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;
|
return;
|
||||||
}
|
}
|
||||||
updateUrl(page);
|
|
||||||
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(() => {
|
||||||
|
if (initial) syncData(initial);
|
||||||
|
}, [initial, syncData]);
|
||||||
|
|
||||||
|
// Load filter lists on mount if not provided by server
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function loadLists() {
|
|
||||||
if (langs.length || machines.length || filetypes.length || schemes.length || sources.length || cases.length) return;
|
if (langs.length || machines.length || filetypes.length || schemes.length || sources.length || cases.length) return;
|
||||||
|
async function loadLists() {
|
||||||
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,45 +244,48 @@ 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"
|
||||||
className="form-control"
|
placeholder="Search titles..."
|
||||||
placeholder="Filter by entry title..."
|
|
||||||
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">Year</label>
|
<FilterSection label="Year" badge={year || undefined}>
|
||||||
<input
|
<Form.Control
|
||||||
type="number"
|
type="number"
|
||||||
className="form-control"
|
size="sm"
|
||||||
placeholder="Any"
|
placeholder="Any"
|
||||||
value={year}
|
value={year}
|
||||||
onChange={(e) => { setYear(e.target.value); setPage(1); }}
|
onChange={(e) => onYearChange(e.target.value)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</FilterSection>
|
||||||
<div>
|
|
||||||
<label className="form-label small text-secondary">DL Language</label>
|
<FilterSection label="Language" badge={dLanguageId ? langs.find((l) => l.id === dLanguageId)?.name : undefined}>
|
||||||
<select className="form-select" value={dLanguageId} onChange={(e) => { setDLanguageId(e.target.value); setPage(1); }}>
|
<Form.Select size="sm" value={dLanguageId} onChange={(e) => { setDLanguageId(e.target.value); setPage(1); }}>
|
||||||
<option value="">All languages</option>
|
<option value="">All languages</option>
|
||||||
{langs.map((l) => (
|
{langs.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">DL Machine</label>
|
<FilterSection
|
||||||
|
label="Machine"
|
||||||
|
badge={`${dMachinetypeIds.length} selected`}
|
||||||
|
defaultOpen={false}
|
||||||
|
>
|
||||||
<MultiSelectChips
|
<MultiSelectChips
|
||||||
options={machineOptions}
|
options={machineOptions}
|
||||||
selected={dMachinetypeIds}
|
selected={dMachinetypeIds}
|
||||||
|
collapsible
|
||||||
onToggle={(id) => {
|
onToggle={(id) => {
|
||||||
setDMachinetypeIds((current) => {
|
setDMachinetypeIds((current) => {
|
||||||
const next = new Set(current);
|
const next = new Set(current);
|
||||||
@@ -328,73 +295,108 @@ export default function ReleasesExplorer({
|
|||||||
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">
|
||||||
|
<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>
|
</FilterSection>
|
||||||
<label className="form-label small text-secondary">File type</label>
|
|
||||||
<select className="form-select" value={filetypeId} onChange={(e) => { setFiletypeId(e.target.value); setPage(1); }}>
|
<FilterSection label="Download filters" defaultOpen={false} badge={
|
||||||
|
[filetypeId, schemetypeId, sourcetypeId, casetypeId, isDemo ? "demo" : ""].filter(Boolean).length
|
||||||
|
? `${[filetypeId, schemetypeId, sourcetypeId, casetypeId, isDemo ? "demo" : ""].filter(Boolean).length} active`
|
||||||
|
: undefined
|
||||||
|
}>
|
||||||
|
<div className="d-flex flex-column gap-2">
|
||||||
|
<Form.Select size="sm" 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>
|
|
||||||
<label className="form-label small text-secondary">Sort</label>
|
|
||||||
<select className="form-select" value={sort} onChange={(e) => { setSort(e.target.value as typeof sort); setPage(1); }}>
|
|
||||||
<option value="year_desc">Newest</option>
|
<option value="year_desc">Newest</option>
|
||||||
<option value="year_asc">Oldest</option>
|
<option value="year_asc">Oldest</option>
|
||||||
<option value="title">Title</option>
|
<option value="title">Title</option>
|
||||||
<option value="entry_id_desc">Entry ID</option>
|
<option value="entry_id_desc">Entry ID</option>
|
||||||
</select>
|
</Form.Select>
|
||||||
</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>
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
<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>
|
||||||
|
{" "}—{" "}
|
||||||
|
<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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
54
src/components/explorer/FilterSection.tsx
Normal file
54
src/components/explorer/FilterSection.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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">
|
||||||
|
{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>
|
</div>
|
||||||
|
)}
|
||||||
|
</Card.Body>
|
||||||
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,10 +23,37 @@ 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 (
|
return (
|
||||||
<div className="d-flex flex-wrap gap-2">
|
<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 (
|
||||||
|
<div>
|
||||||
|
<div className="d-flex flex-wrap gap-1">
|
||||||
{options.map((option) => {
|
{options.map((option) => {
|
||||||
const active = selected.includes(option.id);
|
const active = selected.includes(option.id);
|
||||||
return (
|
return (
|
||||||
@@ -33,5 +68,15 @@ export default function MultiSelectChips<T extends number | string>({
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
{collapsible && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-link btn-sm p-0 mt-1 text-secondary"
|
||||||
|
onClick={() => setExpanded(false)}
|
||||||
|
>
|
||||||
|
Collapse
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
67
src/components/explorer/Pagination.tsx
Normal file
67
src/components/explorer/Pagination.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
86
src/hooks/useSearchFetch.ts
Normal file
86
src/hooks/useSearchFetch.ts
Normal 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 };
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user