- Aggregate magazine references from all releases on the Entry detail page. - Display country names and external links (Wikipedia/Website) on the Label detail page. - Add a year filter to the ZXDB Explorer to search entries by release year. Signed-off: junie@lucy.xalior.com
272 lines
9.7 KiB
TypeScript
272 lines
9.7 KiB
TypeScript
"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>
|
|
);
|
|
}
|