- Introduce reusable EntryLink component - Use EntryLink in Releases and Label detail tables - Link both ID and title to /zxdb/entries/[id] for consistency Signed-off-by: Junie@MacOS
367 lines
14 KiB
TypeScript
367 lines
14 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useMemo, useState } from "react";
|
|
import Link from "next/link";
|
|
import EntryLink from "../components/EntryLink";
|
|
import { usePathname, useRouter } from "next/navigation";
|
|
|
|
type Item = {
|
|
entryId: number;
|
|
releaseSeq: number;
|
|
entryTitle: string;
|
|
year: number | null;
|
|
};
|
|
|
|
type Paged<T> = {
|
|
items: T[];
|
|
page: number;
|
|
pageSize: number;
|
|
total: number;
|
|
};
|
|
|
|
export default function ReleasesExplorer({
|
|
initial,
|
|
initialUrlState,
|
|
}: {
|
|
initial?: Paged<Item>;
|
|
initialUrlState?: {
|
|
q: string;
|
|
page: number;
|
|
year: string;
|
|
sort: "year_desc" | "year_asc" | "title" | "entry_id_desc";
|
|
dLanguageId?: string;
|
|
dMachinetypeId?: string; // keep as string for URL/state consistency
|
|
filetypeId?: string;
|
|
schemetypeId?: string;
|
|
sourcetypeId?: string;
|
|
casetypeId?: string;
|
|
isDemo?: string; // "1" or "true"
|
|
};
|
|
}) {
|
|
const router = useRouter();
|
|
const pathname = usePathname();
|
|
|
|
const [q, setQ] = useState(initialUrlState?.q ?? "");
|
|
const [page, setPage] = useState(initial?.page ?? initialUrlState?.page ?? 1);
|
|
const [loading, setLoading] = useState(false);
|
|
const [data, setData] = useState<Paged<Item> | null>(initial ?? null);
|
|
const [year, setYear] = useState<string>(initialUrlState?.year ?? "");
|
|
const [sort, setSort] = useState<"year_desc" | "year_asc" | "title" | "entry_id_desc">(initialUrlState?.sort ?? "year_desc");
|
|
|
|
// Download-based filters and their option lists
|
|
const [dLanguageId, setDLanguageId] = useState<string>(initialUrlState?.dLanguageId ?? "");
|
|
const [dMachinetypeId, setDMachinetypeId] = useState<string>(initialUrlState?.dMachinetypeId ?? "");
|
|
const [filetypeId, setFiletypeId] = useState<string>(initialUrlState?.filetypeId ?? "");
|
|
const [schemetypeId, setSchemetypeId] = useState<string>(initialUrlState?.schemetypeId ?? "");
|
|
const [sourcetypeId, setSourcetypeId] = useState<string>(initialUrlState?.sourcetypeId ?? "");
|
|
const [casetypeId, setCasetypeId] = useState<string>(initialUrlState?.casetypeId ?? "");
|
|
const [isDemo, setIsDemo] = useState<boolean>(!!(initialUrlState?.isDemo && (initialUrlState.isDemo === "1" || initialUrlState.isDemo === "true")));
|
|
|
|
const [langs, setLangs] = useState<{ id: string; name: string }[]>([]);
|
|
const [machines, setMachines] = useState<{ id: number; name: string }[]>([]);
|
|
const [filetypes, setFiletypes] = useState<{ id: number; name: string }[]>([]);
|
|
const [schemes, setSchemes] = useState<{ id: string; name: string }[]>([]);
|
|
const [sources, setSources] = useState<{ id: string; name: string }[]>([]);
|
|
const [cases, setCases] = useState<{ id: string; name: string }[]>([]);
|
|
|
|
const pageSize = 20;
|
|
const totalPages = useMemo(() => (data ? Math.max(1, Math.ceil(data.total / data.pageSize)) : 1), [data]);
|
|
|
|
function updateUrl(nextPage = page) {
|
|
const params = new URLSearchParams();
|
|
if (q) params.set("q", q);
|
|
params.set("page", String(nextPage));
|
|
if (year) params.set("year", year);
|
|
if (sort) params.set("sort", sort);
|
|
if (dLanguageId) params.set("dLanguageId", dLanguageId);
|
|
if (dMachinetypeId) params.set("dMachinetypeId", dMachinetypeId);
|
|
if (filetypeId) params.set("filetypeId", filetypeId);
|
|
if (schemetypeId) params.set("schemetypeId", schemetypeId);
|
|
if (sourcetypeId) params.set("sourcetypeId", sourcetypeId);
|
|
if (casetypeId) params.set("casetypeId", casetypeId);
|
|
if (isDemo) params.set("isDemo", "1");
|
|
const qs = params.toString();
|
|
router.replace(qs ? `${pathname}?${qs}` : pathname);
|
|
}
|
|
|
|
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 (year) params.set("year", String(Number(year)));
|
|
if (sort) params.set("sort", sort);
|
|
if (dLanguageId) params.set("dLanguageId", dLanguageId);
|
|
if (dMachinetypeId) params.set("dMachinetypeId", dMachinetypeId);
|
|
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);
|
|
}
|
|
}
|
|
|
|
useEffect(() => {
|
|
if (initial) {
|
|
setData(initial);
|
|
setPage(initial.page);
|
|
}
|
|
}, [initial]);
|
|
|
|
useEffect(() => {
|
|
const initialPage = initial?.page ?? 1;
|
|
if (
|
|
initial &&
|
|
page === initialPage &&
|
|
(initialUrlState?.q ?? "") === q &&
|
|
(initialUrlState?.year ?? "") === (year ?? "") &&
|
|
sort === (initialUrlState?.sort ?? "year_desc") &&
|
|
(initialUrlState?.dLanguageId ?? "") === dLanguageId &&
|
|
(initialUrlState?.dMachinetypeId ?? "") === dMachinetypeId &&
|
|
(initialUrlState?.filetypeId ?? "") === filetypeId &&
|
|
(initialUrlState?.schemetypeId ?? "") === schemetypeId &&
|
|
(initialUrlState?.sourcetypeId ?? "") === sourcetypeId &&
|
|
(initialUrlState?.casetypeId ?? "") === casetypeId &&
|
|
(!!initialUrlState?.isDemo === isDemo)
|
|
) {
|
|
updateUrl(page);
|
|
return;
|
|
}
|
|
updateUrl(page);
|
|
fetchData(q, page);
|
|
}, [page, year, sort, dLanguageId, dMachinetypeId, filetypeId, schemetypeId, sourcetypeId, casetypeId, isDemo]);
|
|
|
|
function onSubmit(e: React.FormEvent) {
|
|
e.preventDefault();
|
|
setPage(1);
|
|
updateUrl(1);
|
|
fetchData(q, 1);
|
|
}
|
|
|
|
// Load filter option lists on mount
|
|
useEffect(() => {
|
|
async function loadLists() {
|
|
try {
|
|
const [l, m, ft, sc, so, ca] = await Promise.all([
|
|
fetch("/api/zxdb/languages", { cache: "force-cache" }).then((r) => r.json()),
|
|
fetch("/api/zxdb/machinetypes", { cache: "force-cache" }).then((r) => r.json()),
|
|
fetch("/api/zxdb/filetypes", { cache: "force-cache" }).then((r) => r.json()),
|
|
fetch("/api/zxdb/schemetypes", { cache: "force-cache" }).then((r) => r.json()),
|
|
fetch("/api/zxdb/sourcetypes", { cache: "force-cache" }).then((r) => r.json()),
|
|
fetch("/api/zxdb/casetypes", { cache: "force-cache" }).then((r) => r.json()),
|
|
]);
|
|
setLangs(l.items ?? []);
|
|
setMachines(m.items ?? []);
|
|
setFiletypes(ft.items ?? []);
|
|
setSchemes(sc.items ?? []);
|
|
setSources(so.items ?? []);
|
|
setCases(ca.items ?? []);
|
|
} catch {
|
|
// ignore
|
|
}
|
|
}
|
|
loadLists();
|
|
}, []);
|
|
|
|
const prevHref = useMemo(() => {
|
|
const params = new URLSearchParams();
|
|
if (q) params.set("q", q);
|
|
params.set("page", String(Math.max(1, (data?.page ?? 1) - 1)));
|
|
if (year) params.set("year", year);
|
|
if (sort) params.set("sort", sort);
|
|
if (dLanguageId) params.set("dLanguageId", dLanguageId);
|
|
if (dMachinetypeId) params.set("dMachinetypeId", dMachinetypeId);
|
|
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()}`;
|
|
}, [q, data?.page, year, sort, dLanguageId, dMachinetypeId, filetypeId, schemetypeId, sourcetypeId, casetypeId, isDemo]);
|
|
|
|
const nextHref = useMemo(() => {
|
|
const params = new URLSearchParams();
|
|
if (q) params.set("q", q);
|
|
params.set("page", String(Math.max(1, (data?.page ?? 1) + 1)));
|
|
if (year) params.set("year", year);
|
|
if (sort) params.set("sort", sort);
|
|
if (dLanguageId) params.set("dLanguageId", dLanguageId);
|
|
if (dMachinetypeId) params.set("dMachinetypeId", dMachinetypeId);
|
|
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()}`;
|
|
}, [q, data?.page, year, sort, dLanguageId, dMachinetypeId, filetypeId, schemetypeId, sourcetypeId, casetypeId, isDemo]);
|
|
|
|
return (
|
|
<div>
|
|
<h1 className="mb-3">Releases</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="Filter by entry title..."
|
|
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">
|
|
<input
|
|
type="number"
|
|
className="form-control"
|
|
placeholder="Year"
|
|
value={year}
|
|
onChange={(e) => { setYear(e.target.value); setPage(1); }}
|
|
/>
|
|
</div>
|
|
<div className="col-auto">
|
|
<select className="form-select" value={dLanguageId} onChange={(e) => { setDLanguageId(e.target.value); setPage(1); }}>
|
|
<option value="">DL Language</option>
|
|
{langs.map((l) => (
|
|
<option key={l.id} value={l.id}>{l.name}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div className="col-auto">
|
|
<select className="form-select" value={dMachinetypeId} onChange={(e) => { setDMachinetypeId(e.target.value); setPage(1); }}>
|
|
<option value="">DL Machine</option>
|
|
{machines.map((m) => (
|
|
<option key={m.id} value={m.id}>{m.name}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div className="col-auto">
|
|
<select className="form-select" value={filetypeId} onChange={(e) => { setFiletypeId(e.target.value); setPage(1); }}>
|
|
<option value="">File type</option>
|
|
{filetypes.map((ft) => (
|
|
<option key={ft.id} value={ft.id}>{ft.name}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div className="col-auto">
|
|
<select className="form-select" value={schemetypeId} onChange={(e) => { setSchemetypeId(e.target.value); setPage(1); }}>
|
|
<option value="">Scheme</option>
|
|
{schemes.map((s) => (
|
|
<option key={s.id} value={s.id}>{s.name}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div className="col-auto">
|
|
<select className="form-select" value={sourcetypeId} onChange={(e) => { setSourcetypeId(e.target.value); setPage(1); }}>
|
|
<option value="">Source</option>
|
|
{sources.map((s) => (
|
|
<option key={s.id} value={s.id}>{s.name}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div className="col-auto">
|
|
<select className="form-select" value={casetypeId} onChange={(e) => { setCasetypeId(e.target.value); setPage(1); }}>
|
|
<option value="">Case</option>
|
|
{cases.map((c) => (
|
|
<option key={c.id} value={c.id}>{c.name}</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
<div className="col-auto form-check ms-2">
|
|
<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>
|
|
</div>
|
|
<div className="col-auto">
|
|
<select className="form-select" value={sort} onChange={(e) => { setSort(e.target.value as typeof sort); setPage(1); }}>
|
|
<option value="year_desc">Sort: Newest</option>
|
|
<option value="year_asc">Sort: Oldest</option>
|
|
<option value="title">Sort: Title</option>
|
|
<option value="entry_id_desc">Sort: Entry ID</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}}>Entry ID</th>
|
|
<th>Title</th>
|
|
<th style={{width: 110}}>Release #</th>
|
|
<th style={{width: 100}}>Year</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{data.items.map((it) => (
|
|
<tr key={`${it.entryId}-${it.releaseSeq}`}>
|
|
<td>
|
|
<EntryLink id={it.entryId} />
|
|
</td>
|
|
<td>
|
|
<EntryLink id={it.entryId} title={it.entryTitle} />
|
|
</td>
|
|
<td>#{it.releaseSeq}</td>
|
|
<td>{it.year ?? <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={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>
|
|
);
|
|
}
|