ZXDB: Releases browser filters, schema lists, and fixes
- UI: Add /zxdb hub cards for Entries and Releases; implement Releases browser
with URL‑synced filters (q, year, sort, DL language/machine, file/scheme/source/case, demo)
and a paginated table (Entry ID, Title, Release #, Year).
- API: Add GET /api/zxdb/releases/search (Zod‑validated, Node runtime) supporting
title, year, sort, and downloads‑based filters; return paged JSON.
- Repo: Rewrite searchReleases to Drizzle QB; correct ORDER BY on releases.release_year;
implement EXISTS on downloads using explicit "from downloads as d"; return JSON‑safe rows.
- Schema: Align Drizzle models with ZXDB for releases/downloads; add lookups
availabletypes, currencies, roletypes, and roles relation.
- API (lookups): Add GET /api/zxdb/{availabletypes,currencies,roletypes} for dropdowns.
- Stability: JSON‑clone SSR payloads before passing to Client Components to avoid
RowDataPacket serialization errors.
Signed-off-by: Junie@lucy.xalior.com
This commit is contained in:
324
src/app/zxdb/entries/EntriesExplorer.tsx
Normal file
324
src/app/zxdb/entries/EntriesExplorer.tsx
Normal file
@@ -0,0 +1,324 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
|
||||
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 EntriesExplorer({
|
||||
initial,
|
||||
initialGenres,
|
||||
initialLanguages,
|
||||
initialMachines,
|
||||
initialUrlState,
|
||||
}: {
|
||||
initial?: Paged<Item>;
|
||||
initialGenres?: { id: number; name: string }[];
|
||||
initialLanguages?: { id: string; name: string }[];
|
||||
initialMachines?: { id: number; name: string }[];
|
||||
initialUrlState?: {
|
||||
q: string;
|
||||
page: number;
|
||||
genreId: string | number | "";
|
||||
languageId: string | "";
|
||||
machinetypeId: string | number | "";
|
||||
sort: "title" | "id_desc";
|
||||
};
|
||||
}) {
|
||||
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 [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 | "">(
|
||||
initialUrlState?.genreId === "" ? "" : initialUrlState?.genreId ? Number(initialUrlState.genreId) : ""
|
||||
);
|
||||
const [languageId, setLanguageId] = useState<string | "">(initialUrlState?.languageId ?? "");
|
||||
const [machinetypeId, setMachinetypeId] = useState<number | "">(
|
||||
initialUrlState?.machinetypeId === "" ? "" : initialUrlState?.machinetypeId ? Number(initialUrlState.machinetypeId) : ""
|
||||
);
|
||||
const [sort, setSort] = useState<"title" | "id_desc">(initialUrlState?.sort ?? "id_desc");
|
||||
|
||||
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 (genreId !== "") params.set("genreId", String(genreId));
|
||||
if (languageId !== "") params.set("languageId", String(languageId));
|
||||
if (machinetypeId !== "") params.set("machinetypeId", String(machinetypeId));
|
||||
if (sort) params.set("sort", sort);
|
||||
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 (genreId !== "") params.set("genreId", String(genreId));
|
||||
if (languageId !== "") params.set("languageId", String(languageId));
|
||||
if (machinetypeId !== "") params.set("machinetypeId", String(machinetypeId));
|
||||
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) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.error(e);
|
||||
setData({ items: [], page: 1, pageSize, total: 0 });
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Sync from SSR payload on navigation
|
||||
useEffect(() => {
|
||||
if (initial) {
|
||||
setData(initial);
|
||||
setPage(initial.page);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [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 ?? "") === q &&
|
||||
(initialUrlState?.genreId === "" ? "" : Number(initialUrlState?.genreId ?? "")) === (genreId === "" ? "" : Number(genreId)) &&
|
||||
(initialUrlState?.languageId ?? "") === (languageId ?? "") &&
|
||||
(initialUrlState?.machinetypeId === "" ? "" : Number(initialUrlState?.machinetypeId ?? "")) ===
|
||||
(machinetypeId === "" ? "" : Number(machinetypeId)) &&
|
||||
sort === (initialUrlState?.sort ?? "id_desc")
|
||||
) {
|
||||
updateUrl(page);
|
||||
return;
|
||||
}
|
||||
updateUrl(page);
|
||||
fetchData(q, page);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [page, genreId, languageId, machinetypeId, 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);
|
||||
updateUrl(1);
|
||||
fetchData(q, 1);
|
||||
}
|
||||
|
||||
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 (genreId !== "") params.set("genreId", String(genreId));
|
||||
if (languageId !== "") params.set("languageId", String(languageId));
|
||||
if (machinetypeId !== "") params.set("machinetypeId", String(machinetypeId));
|
||||
if (sort) params.set("sort", sort);
|
||||
return `/zxdb/entries?${params.toString()}`;
|
||||
}, [q, data?.page, genreId, languageId, machinetypeId, sort]);
|
||||
|
||||
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 (genreId !== "") params.set("genreId", String(genreId));
|
||||
if (languageId !== "") params.set("languageId", String(languageId));
|
||||
if (machinetypeId !== "") params.set("machinetypeId", String(machinetypeId));
|
||||
if (sort) params.set("sort", sort);
|
||||
return `/zxdb/entries?${params.toString()}`;
|
||||
}, [q, data?.page, genreId, languageId, machinetypeId, sort]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<h1 className="mb-3">Entries</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 as any} onChange={(e) => { setGenreId(e.target.value === "" ? "" : Number(e.target.value)); setPage(1); }}>
|
||||
<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 as any} onChange={(e) => { setLanguageId(e.target.value); setPage(1); }}>
|
||||
<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 as any} onChange={(e) => { setMachinetypeId(e.target.value === "" ? "" : Number(e.target.value)); setPage(1); }}>
|
||||
<option value="">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={sort} onChange={(e) => { setSort(e.target.value as any); setPage(1); }}>
|
||||
<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={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 />
|
||||
<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>
|
||||
);
|
||||
}
|
||||
43
src/app/zxdb/entries/page.tsx
Normal file
43
src/app/zxdb/entries/page.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
import EntriesExplorer from "./EntriesExplorer";
|
||||
import { listGenres, listLanguages, listMachinetypes, searchEntries } from "@/server/repo/zxdb";
|
||||
|
||||
export const metadata = {
|
||||
title: "ZXDB Entries",
|
||||
};
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default async function Page({ searchParams }: { searchParams: Promise<{ [key: string]: string | string[] | undefined }> }) {
|
||||
const sp = await searchParams;
|
||||
const page = Math.max(1, Number(Array.isArray(sp.page) ? sp.page[0] : sp.page) || 1);
|
||||
const genreId = (Array.isArray(sp.genreId) ? sp.genreId[0] : sp.genreId) ?? "";
|
||||
const languageId = (Array.isArray(sp.languageId) ? sp.languageId[0] : sp.languageId) ?? "";
|
||||
const machinetypeId = (Array.isArray(sp.machinetypeId) ? sp.machinetypeId[0] : sp.machinetypeId) ?? "";
|
||||
const sort = ((Array.isArray(sp.sort) ? sp.sort[0] : sp.sort) as any) ?? "id_desc";
|
||||
const q = (Array.isArray(sp.q) ? sp.q[0] : sp.q) ?? "";
|
||||
|
||||
const [initial, genres, langs, machines] = await Promise.all([
|
||||
searchEntries({
|
||||
page,
|
||||
pageSize: 20,
|
||||
sort,
|
||||
q,
|
||||
genreId: genreId ? Number(genreId) : undefined,
|
||||
languageId: languageId || undefined,
|
||||
machinetypeId: machinetypeId ? Number(machinetypeId) : undefined,
|
||||
}),
|
||||
listGenres(),
|
||||
listLanguages(),
|
||||
listMachinetypes(),
|
||||
]);
|
||||
|
||||
return (
|
||||
<EntriesExplorer
|
||||
initial={initial as any}
|
||||
initialGenres={genres as any}
|
||||
initialLanguages={langs as any}
|
||||
initialMachines={machines as any}
|
||||
initialUrlState={{ q, page, genreId, languageId, machinetypeId, sort }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user