Expand ZXDB entry data and search
Add entry release/license sections, label permissions/licenses, expanded search scope (titles+aliases+origins), and home search. Also include ZXDB submodule and update gitignore. Signed-off-by: codex@lucy.xalior.com
This commit is contained in:
@@ -14,6 +14,7 @@ const querySchema = z.object({
|
||||
.optional(),
|
||||
machinetypeId: z.coerce.number().int().positive().optional(),
|
||||
sort: z.enum(["title", "id_desc"]).optional(),
|
||||
scope: z.enum(["title", "title_aliases", "title_aliases_origins"]).optional(),
|
||||
facets: z.coerce.boolean().optional(),
|
||||
});
|
||||
|
||||
@@ -27,6 +28,7 @@ export async function GET(req: NextRequest) {
|
||||
languageId: searchParams.get("languageId") ?? undefined,
|
||||
machinetypeId: searchParams.get("machinetypeId") ?? undefined,
|
||||
sort: searchParams.get("sort") ?? undefined,
|
||||
scope: searchParams.get("scope") ?? undefined,
|
||||
facets: searchParams.get("facets") ?? undefined,
|
||||
});
|
||||
if (!parsed.success) {
|
||||
|
||||
@@ -16,6 +16,8 @@ type Item = {
|
||||
languageName?: string | null;
|
||||
};
|
||||
|
||||
type SearchScope = "title" | "title_aliases" | "title_aliases_origins";
|
||||
|
||||
type Paged<T> = {
|
||||
items: T[];
|
||||
page: number;
|
||||
@@ -41,6 +43,7 @@ export default function EntriesExplorer({
|
||||
languageId: string | "";
|
||||
machinetypeId: string | number | "";
|
||||
sort: "title" | "id_desc";
|
||||
scope?: SearchScope;
|
||||
};
|
||||
}) {
|
||||
const router = useRouter();
|
||||
@@ -61,6 +64,7 @@ export default function EntriesExplorer({
|
||||
initialUrlState?.machinetypeId === "" ? "" : initialUrlState?.machinetypeId ? Number(initialUrlState.machinetypeId) : ""
|
||||
);
|
||||
const [sort, setSort] = useState<"title" | "id_desc">(initialUrlState?.sort ?? "id_desc");
|
||||
const [scope, setScope] = useState<SearchScope>(initialUrlState?.scope ?? "title");
|
||||
|
||||
const pageSize = 20;
|
||||
const totalPages = useMemo(() => (data ? Math.max(1, Math.ceil(data.total / data.pageSize)) : 1), [data]);
|
||||
@@ -73,6 +77,7 @@ export default function EntriesExplorer({
|
||||
if (languageId !== "") params.set("languageId", String(languageId));
|
||||
if (machinetypeId !== "") params.set("machinetypeId", String(machinetypeId));
|
||||
if (sort) params.set("sort", sort);
|
||||
if (scope !== "title") params.set("scope", scope);
|
||||
const qs = params.toString();
|
||||
router.replace(qs ? `${pathname}?${qs}` : pathname);
|
||||
}
|
||||
@@ -88,6 +93,7 @@ export default function EntriesExplorer({
|
||||
if (languageId !== "") params.set("languageId", String(languageId));
|
||||
if (machinetypeId !== "") params.set("machinetypeId", String(machinetypeId));
|
||||
if (sort) params.set("sort", sort);
|
||||
if (scope !== "title") params.set("scope", scope);
|
||||
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();
|
||||
@@ -120,7 +126,8 @@ export default function EntriesExplorer({
|
||||
(initialUrlState?.languageId ?? "") === (languageId ?? "") &&
|
||||
(initialUrlState?.machinetypeId === "" ? "" : Number(initialUrlState?.machinetypeId ?? "")) ===
|
||||
(machinetypeId === "" ? "" : Number(machinetypeId)) &&
|
||||
sort === (initialUrlState?.sort ?? "id_desc")
|
||||
sort === (initialUrlState?.sort ?? "id_desc") &&
|
||||
(initialUrlState?.scope ?? "title") === scope
|
||||
) {
|
||||
updateUrl(page);
|
||||
return;
|
||||
@@ -128,7 +135,7 @@ export default function EntriesExplorer({
|
||||
updateUrl(page);
|
||||
fetchData(q, page);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [page, genreId, languageId, machinetypeId, sort]);
|
||||
}, [page, genreId, languageId, machinetypeId, sort, scope]);
|
||||
|
||||
// Load filter lists on mount only if not provided by server
|
||||
useEffect(() => {
|
||||
@@ -163,8 +170,9 @@ export default function EntriesExplorer({
|
||||
if (languageId !== "") params.set("languageId", String(languageId));
|
||||
if (machinetypeId !== "") params.set("machinetypeId", String(machinetypeId));
|
||||
if (sort) params.set("sort", sort);
|
||||
if (scope !== "title") params.set("scope", scope);
|
||||
return `/zxdb/entries?${params.toString()}`;
|
||||
}, [q, data?.page, genreId, languageId, machinetypeId, sort]);
|
||||
}, [q, data?.page, genreId, languageId, machinetypeId, sort, scope]);
|
||||
|
||||
const nextHref = useMemo(() => {
|
||||
const params = new URLSearchParams();
|
||||
@@ -174,8 +182,9 @@ export default function EntriesExplorer({
|
||||
if (languageId !== "") params.set("languageId", String(languageId));
|
||||
if (machinetypeId !== "") params.set("machinetypeId", String(machinetypeId));
|
||||
if (sort) params.set("sort", sort);
|
||||
if (scope !== "title") params.set("scope", scope);
|
||||
return `/zxdb/entries?${params.toString()}`;
|
||||
}, [q, data?.page, genreId, languageId, machinetypeId, sort]);
|
||||
}, [q, data?.page, genreId, languageId, machinetypeId, sort, scope]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -230,6 +239,13 @@ export default function EntriesExplorer({
|
||||
<option value="id_desc">Sort: Newest</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="col-auto">
|
||||
<select className="form-select" value={scope} onChange={(e) => { setScope(e.target.value as SearchScope); setPage(1); }}>
|
||||
<option value="title">Search: Titles</option>
|
||||
<option value="title_aliases">Search: Titles + Aliases</option>
|
||||
<option value="title_aliases_origins">Search: Titles + Aliases + Origins</option>
|
||||
</select>
|
||||
</div>
|
||||
{loading && (
|
||||
<div className="col-auto text-secondary">Loading...</div>
|
||||
)}
|
||||
|
||||
@@ -13,6 +13,15 @@ export type EntryDetailData = {
|
||||
genre: { id: number | null; name: string | null };
|
||||
authors: Label[];
|
||||
publishers: Label[];
|
||||
licenses?: {
|
||||
id: number;
|
||||
name: string;
|
||||
type: { id: string; name: string | null };
|
||||
isOfficial: boolean;
|
||||
linkWikipedia?: string | null;
|
||||
linkSite?: string | null;
|
||||
comments?: string | null;
|
||||
}[];
|
||||
// extra fields for richer details
|
||||
maxPlayers?: number;
|
||||
availabletypeId?: string | null;
|
||||
@@ -300,6 +309,37 @@ export default function EntryDetailClient({ data }: { data: EntryDetailData | nu
|
||||
|
||||
<hr />
|
||||
|
||||
<div>
|
||||
<h5>Releases</h5>
|
||||
{(!data.releases || data.releases.length === 0) && <div className="text-secondary">No releases recorded</div>}
|
||||
{data.releases && data.releases.length > 0 && (
|
||||
<div className="table-responsive">
|
||||
<table className="table table-sm table-striped align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: 120 }}>Release #</th>
|
||||
<th style={{ width: 120 }}>Year</th>
|
||||
<th>Downloads</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.releases.map((r) => (
|
||||
<tr key={r.releaseSeq}>
|
||||
<td>
|
||||
<Link href={`/zxdb/releases/${data.id}/${r.releaseSeq}`}>#{r.releaseSeq}</Link>
|
||||
</td>
|
||||
<td>{r.year ?? <span className="text-secondary">-</span>}</td>
|
||||
<td>{r.downloads.length}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
|
||||
{/* Aliases (alternative titles) */}
|
||||
<div>
|
||||
<h5>Aliases</h5>
|
||||
@@ -332,6 +372,47 @@ export default function EntryDetailClient({ data }: { data: EntryDetailData | nu
|
||||
|
||||
<hr />
|
||||
|
||||
<div>
|
||||
<h5>Licenses</h5>
|
||||
{(!data.licenses || data.licenses.length === 0) && <div className="text-secondary">No licenses linked</div>}
|
||||
{data.licenses && data.licenses.length > 0 && (
|
||||
<div className="table-responsive">
|
||||
<table className="table table-sm table-striped align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th style={{ width: 140 }}>Type</th>
|
||||
<th style={{ width: 120 }}>Official</th>
|
||||
<th>Links</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.licenses.map((l) => (
|
||||
<tr key={l.id}>
|
||||
<td>{l.name}</td>
|
||||
<td>{l.type.name ?? l.type.id}</td>
|
||||
<td>{l.isOfficial ? "Yes" : "No"}</td>
|
||||
<td>
|
||||
<div className="d-flex gap-2 flex-wrap">
|
||||
{l.linkWikipedia && (
|
||||
<a href={l.linkWikipedia} target="_blank" rel="noreferrer">Wikipedia</a>
|
||||
)}
|
||||
{l.linkSite && (
|
||||
<a href={l.linkSite} target="_blank" rel="noreferrer">Site</a>
|
||||
)}
|
||||
{!l.linkWikipedia && !l.linkSite && <span className="text-secondary">-</span>}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<hr />
|
||||
|
||||
{/* Web links (external references) */}
|
||||
<div>
|
||||
<h5>Web links</h5>
|
||||
|
||||
@@ -15,6 +15,10 @@ export default async function Page({ searchParams }: { searchParams: Promise<{ [
|
||||
const machinetypeId = (Array.isArray(sp.machinetypeId) ? sp.machinetypeId[0] : sp.machinetypeId) ?? "";
|
||||
const sort = ((Array.isArray(sp.sort) ? sp.sort[0] : sp.sort) ?? "id_desc") as "title" | "id_desc";
|
||||
const q = (Array.isArray(sp.q) ? sp.q[0] : sp.q) ?? "";
|
||||
const scope = ((Array.isArray(sp.scope) ? sp.scope[0] : sp.scope) ?? "title") as
|
||||
| "title"
|
||||
| "title_aliases"
|
||||
| "title_aliases_origins";
|
||||
|
||||
const [initial, genres, langs, machines] = await Promise.all([
|
||||
searchEntries({
|
||||
@@ -22,6 +26,7 @@ export default async function Page({ searchParams }: { searchParams: Promise<{ [
|
||||
pageSize: 20,
|
||||
sort,
|
||||
q,
|
||||
scope,
|
||||
genreId: genreId ? Number(genreId) : undefined,
|
||||
languageId: languageId || undefined,
|
||||
machinetypeId: machinetypeId ? Number(machinetypeId) : undefined,
|
||||
@@ -37,7 +42,7 @@ export default async function Page({ searchParams }: { searchParams: Promise<{ [
|
||||
initialGenres={genres}
|
||||
initialLanguages={langs}
|
||||
initialMachines={machines}
|
||||
initialUrlState={{ q, page, genreId, languageId, machinetypeId, sort }}
|
||||
initialUrlState={{ q, page, genreId, languageId, machinetypeId, sort, scope }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -5,7 +5,24 @@ import EntryLink from "../../components/EntryLink";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
type Label = { id: number; name: string; labeltypeId: string | null };
|
||||
type Label = {
|
||||
id: number;
|
||||
name: string;
|
||||
labeltypeId: string | null;
|
||||
permissions: {
|
||||
website: { id: number; name: string; link?: string | null };
|
||||
type: { id: string; name: string | null };
|
||||
text: string | null;
|
||||
}[];
|
||||
licenses: {
|
||||
id: number;
|
||||
name: string;
|
||||
type: { id: string; name: string | null };
|
||||
linkWikipedia?: string | null;
|
||||
linkSite?: string | null;
|
||||
comments?: string | null;
|
||||
}[];
|
||||
};
|
||||
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 };
|
||||
|
||||
@@ -36,6 +53,77 @@ export default function LabelDetailClient({ id, initial, initialTab, initialQ }:
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="row g-4 mt-1">
|
||||
<div className="col-lg-6">
|
||||
<h5>Permissions</h5>
|
||||
{initial.label.permissions.length === 0 && <div className="text-secondary">No permissions recorded</div>}
|
||||
{initial.label.permissions.length > 0 && (
|
||||
<div className="table-responsive">
|
||||
<table className="table table-sm table-striped align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Website</th>
|
||||
<th style={{ width: 140 }}>Type</th>
|
||||
<th>Notes</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{initial.label.permissions.map((p, idx) => (
|
||||
<tr key={`${p.website.id}-${p.type.id}-${idx}`}>
|
||||
<td>
|
||||
{p.website.link ? (
|
||||
<a href={p.website.link} target="_blank" rel="noreferrer">{p.website.name}</a>
|
||||
) : (
|
||||
<span>{p.website.name}</span>
|
||||
)}
|
||||
</td>
|
||||
<td>{p.type.name ?? p.type.id}</td>
|
||||
<td>{p.text ?? ""}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="col-lg-6">
|
||||
<h5>Licenses</h5>
|
||||
{initial.label.licenses.length === 0 && <div className="text-secondary">No licenses linked</div>}
|
||||
{initial.label.licenses.length > 0 && (
|
||||
<div className="table-responsive">
|
||||
<table className="table table-sm table-striped align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th style={{ width: 140 }}>Type</th>
|
||||
<th>Links</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{initial.label.licenses.map((l) => (
|
||||
<tr key={l.id}>
|
||||
<td>{l.name}</td>
|
||||
<td>{l.type.name ?? l.type.id}</td>
|
||||
<td>
|
||||
<div className="d-flex gap-2 flex-wrap">
|
||||
{l.linkWikipedia && (
|
||||
<a href={l.linkWikipedia} target="_blank" rel="noreferrer">Wikipedia</a>
|
||||
)}
|
||||
{l.linkSite && (
|
||||
<a href={l.linkSite} target="_blank" rel="noreferrer">Site</a>
|
||||
)}
|
||||
{!l.linkWikipedia && !l.linkSite && <span className="text-secondary">-</span>}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ul className="nav nav-tabs mt-3">
|
||||
<li className="nav-item">
|
||||
<button className={`nav-link ${tab === "authored" ? "active" : ""}`} onClick={() => setTab("authored")}>Authored</button>
|
||||
|
||||
@@ -12,6 +12,22 @@ export default async function Page() {
|
||||
<h1 className="mb-3">ZXDB Explorer</h1>
|
||||
<p className="text-secondary">Choose what you want to explore.</p>
|
||||
|
||||
<form className="row gy-2 gx-2 align-items-center mb-4" method="get" action="/zxdb/entries">
|
||||
<div className="col-sm-8 col-md-6 col-lg-4">
|
||||
<input className="form-control" name="q" placeholder="Search entries..." />
|
||||
</div>
|
||||
<div className="col-auto">
|
||||
<select className="form-select" name="scope" defaultValue="title">
|
||||
<option value="title">Titles</option>
|
||||
<option value="title_aliases">Titles + Aliases</option>
|
||||
<option value="title_aliases_origins">Titles + Aliases + Origins</option>
|
||||
</select>
|
||||
</div>
|
||||
<div className="col-auto">
|
||||
<button className="btn btn-primary">Search</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div className="row g-3">
|
||||
<div className="col-sm-6 col-lg-4">
|
||||
<Link href="/zxdb/entries" className="text-decoration-none">
|
||||
|
||||
Reference in New Issue
Block a user