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:
2025-12-16 23:00:38 +00:00
parent fd4c0f8963
commit f563b41792
16 changed files with 1147 additions and 62 deletions

View File

@@ -0,0 +1,10 @@
import { listAvailabletypes } from "@/server/repo/zxdb";
export async function GET() {
const items = await listAvailabletypes();
return new Response(JSON.stringify({ items }), {
headers: { "content-type": "application/json" },
});
}
export const runtime = "nodejs";

View File

@@ -0,0 +1,10 @@
import { listCasetypes } from "@/server/repo/zxdb";
export async function GET() {
const items = await listCasetypes();
return new Response(JSON.stringify({ items }), {
headers: { "content-type": "application/json" },
});
}
export const runtime = "nodejs";

View File

@@ -0,0 +1,10 @@
import { listCurrencies } from "@/server/repo/zxdb";
export async function GET() {
const items = await listCurrencies();
return new Response(JSON.stringify({ items }), {
headers: { "content-type": "application/json" },
});
}
export const runtime = "nodejs";

View File

@@ -0,0 +1,10 @@
import { listFiletypes } from "@/server/repo/zxdb";
export async function GET() {
const items = await listFiletypes();
return new Response(JSON.stringify({ items }), {
headers: { "content-type": "application/json" },
});
}
export const runtime = "nodejs";

View File

@@ -0,0 +1,48 @@
import { NextRequest } from "next/server";
import { z } from "zod";
import { searchReleases } from "@/server/repo/zxdb";
const querySchema = z.object({
q: z.string().optional(),
page: z.coerce.number().int().positive().optional(),
pageSize: z.coerce.number().int().positive().max(100).optional(),
year: z.coerce.number().int().optional(),
sort: z.enum(["year_desc", "year_asc", "title", "entry_id_desc"]).optional(),
dLanguageId: z.string().trim().length(2).optional(),
dMachinetypeId: z.coerce.number().int().positive().optional(),
filetypeId: z.coerce.number().int().positive().optional(),
schemetypeId: z.string().trim().length(2).optional(),
sourcetypeId: z.string().trim().length(1).optional(),
casetypeId: z.string().trim().length(1).optional(),
isDemo: z.coerce.boolean().optional(),
});
export async function GET(req: NextRequest) {
const { searchParams } = new URL(req.url);
const parsed = querySchema.safeParse({
q: searchParams.get("q") ?? undefined,
page: searchParams.get("page") ?? undefined,
pageSize: searchParams.get("pageSize") ?? undefined,
year: searchParams.get("year") ?? undefined,
sort: searchParams.get("sort") ?? undefined,
dLanguageId: searchParams.get("dLanguageId") ?? undefined,
dMachinetypeId: searchParams.get("dMachinetypeId") ?? undefined,
filetypeId: searchParams.get("filetypeId") ?? undefined,
schemetypeId: searchParams.get("schemetypeId") ?? undefined,
sourcetypeId: searchParams.get("sourcetypeId") ?? undefined,
casetypeId: searchParams.get("casetypeId") ?? undefined,
isDemo: searchParams.get("isDemo") ?? undefined,
});
if (!parsed.success) {
return new Response(JSON.stringify({ error: parsed.error.flatten() }), {
status: 400,
headers: { "content-type": "application/json" },
});
}
const data = await searchReleases(parsed.data);
return new Response(JSON.stringify(data), {
headers: { "content-type": "application/json" },
});
}
export const runtime = "nodejs";

View File

@@ -0,0 +1,10 @@
import { listRoletypes } from "@/server/repo/zxdb";
export async function GET() {
const items = await listRoletypes();
return new Response(JSON.stringify({ items }), {
headers: { "content-type": "application/json" },
});
}
export const runtime = "nodejs";

View File

@@ -0,0 +1,10 @@
import { listSchemetypes } from "@/server/repo/zxdb";
export async function GET() {
const items = await listSchemetypes();
return new Response(JSON.stringify({ items }), {
headers: { "content-type": "application/json" },
});
}
export const runtime = "nodejs";

View File

@@ -0,0 +1,10 @@
import { listSourcetypes } from "@/server/repo/zxdb";
export async function GET() {
const items = await listSourcetypes();
return new Response(JSON.stringify({ items }), {
headers: { "content-type": "application/json" },
});
}
export const runtime = "nodejs";

View 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>
);
}

View 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 }}
/>
);
}

View File

@@ -1,30 +1,60 @@
import ZxdbExplorer from "./ZxdbExplorer";
import { searchEntries, listGenres, listLanguages, listMachinetypes } from "@/server/repo/zxdb";
import Link from "next/link";
export const metadata = {
title: "ZXDB Explorer",
};
// This page depends on searchParams (?page=, filters in future). Force dynamic
// rendering so ISR doesnt cache a single HTML for all query strings.
export const dynamic = "force-dynamic";
export const revalidate = 3600;
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);
// Server-render initial page based on URL to avoid first client fetch and keep counter in sync
const [initial, genres, langs, machines] = await Promise.all([
searchEntries({ page, pageSize: 20, sort: "id_desc" }),
listGenres(),
listLanguages(),
listMachinetypes(),
]);
export default async function Page() {
return (
<ZxdbExplorer
initial={initial as any}
initialGenres={genres as any}
initialLanguages={langs as any}
initialMachines={machines as any}
/>
<div>
<h1 className="mb-3">ZXDB Explorer</h1>
<p className="text-secondary">Choose what you want to explore.</p>
<div className="row g-3">
<div className="col-sm-6 col-lg-4">
<Link href="/zxdb/entries" className="text-decoration-none">
<div className="card h-100 shadow-sm">
<div className="card-body d-flex align-items-center">
<div className="me-3" aria-hidden>
<span className="bi bi-collection" style={{ fontSize: 28 }} />
</div>
<div>
<h5 className="card-title mb-1">Entries</h5>
<div className="card-text text-secondary">Browse software entries with filters</div>
</div>
</div>
</div>
</Link>
</div>
<div className="col-sm-6 col-lg-4">
<Link href="/zxdb/releases" className="text-decoration-none">
<div className="card h-100 shadow-sm">
<div className="card-body d-flex align-items-center">
<div className="me-3" aria-hidden>
<span className="bi bi-box-arrow-down" style={{ fontSize: 28 }} />
</div>
<div>
<h5 className="card-title mb-1">Releases</h5>
<div className="card-text text-secondary">Drill into releases and downloads</div>
</div>
</div>
</div>
</Link>
</div>
</div>
<div className="mt-4">
<h2 className="h5 mb-2">Categories</h2>
<div className="d-flex flex-wrap gap-2">
<Link className="btn btn-outline-secondary btn-sm" href="/zxdb/genres">Genres</Link>
<Link className="btn btn-outline-secondary btn-sm" href="/zxdb/languages">Languages</Link>
<Link className="btn btn-outline-secondary btn-sm" href="/zxdb/machinetypes">Machine Types</Link>
<Link className="btn btn-outline-secondary btn-sm" href="/zxdb/labels">Labels</Link>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,366 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import Link from "next/link";
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) {
// eslint-disable-next-line no-console
console.error(e);
setData({ items: [], page: 1, pageSize, total: 0 });
} finally {
setLoading(false);
}
}
useEffect(() => {
if (initial) {
setData(initial);
setPage(initial.page);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [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);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [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 any); 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>{it.entryId}</td>
<td>
<Link href={`/zxdb/entries/${it.entryId}`}>{it.entryTitle}</Link>
</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>
);
}

View File

@@ -0,0 +1,41 @@
import ReleasesExplorer from "./ReleasesExplorer";
import { searchReleases } from "@/server/repo/zxdb";
export const metadata = {
title: "ZXDB Releases",
};
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 q = (Array.isArray(sp.q) ? sp.q[0] : sp.q) ?? "";
const yearStr = (Array.isArray(sp.year) ? sp.year[0] : sp.year) ?? "";
const year = yearStr ? Number(yearStr) : undefined;
const sort = ((Array.isArray(sp.sort) ? sp.sort[0] : sp.sort) as any) ?? "year_desc";
const dLanguageId = (Array.isArray(sp.dLanguageId) ? sp.dLanguageId[0] : sp.dLanguageId) ?? "";
const dMachinetypeIdStr = (Array.isArray(sp.dMachinetypeId) ? sp.dMachinetypeId[0] : sp.dMachinetypeId) ?? "";
const dMachinetypeId = dMachinetypeIdStr ? Number(dMachinetypeIdStr) : undefined;
const filetypeIdStr = (Array.isArray(sp.filetypeId) ? sp.filetypeId[0] : sp.filetypeId) ?? "";
const filetypeId = filetypeIdStr ? Number(filetypeIdStr) : undefined;
const schemetypeId = (Array.isArray(sp.schemetypeId) ? sp.schemetypeId[0] : sp.schemetypeId) ?? "";
const sourcetypeId = (Array.isArray(sp.sourcetypeId) ? sp.sourcetypeId[0] : sp.sourcetypeId) ?? "";
const casetypeId = (Array.isArray(sp.casetypeId) ? sp.casetypeId[0] : sp.casetypeId) ?? "";
const isDemoStr = (Array.isArray(sp.isDemo) ? sp.isDemo[0] : sp.isDemo) ?? "";
const isDemo = isDemoStr ? (isDemoStr === "true" || isDemoStr === "1") : undefined;
const [initial] = await Promise.all([
searchReleases({ page, pageSize: 20, q, year, sort, dLanguageId: dLanguageId || undefined, dMachinetypeId, filetypeId, schemetypeId: schemetypeId || undefined, sourcetypeId: sourcetypeId || undefined, casetypeId: casetypeId || undefined, isDemo }),
]);
// Ensure the object passed to a Client Component is a plain JSON value
const initialPlain = JSON.parse(JSON.stringify(initial));
return (
<ReleasesExplorer
initial={initialPlain as any}
initialUrlState={{ q, page, year: yearStr, sort, dLanguageId, dMachinetypeId: dMachinetypeIdStr, filetypeId: filetypeIdStr, schemetypeId, sourcetypeId, casetypeId, isDemo: isDemoStr }}
/>
);
}