From e2f6aac8561e5b149dd9951f90b6947c89269517 Mon Sep 17 00:00:00 2001 From: "D. Rimron-Soutter" Date: Sat, 10 Jan 2026 18:04:04 +0000 Subject: [PATCH] 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 --- .gitignore | 1 + src/app/api/zxdb/search/route.ts | 2 + src/app/zxdb/entries/EntriesExplorer.tsx | 24 ++- src/app/zxdb/entries/[id]/EntryDetail.tsx | 81 +++++++++ src/app/zxdb/entries/page.tsx | 7 +- src/app/zxdb/labels/[id]/LabelDetail.tsx | 90 +++++++++- src/app/zxdb/page.tsx | 16 ++ src/server/repo/zxdb.ts | 210 +++++++++++++++++++++- 8 files changed, 422 insertions(+), 9 deletions(-) diff --git a/.gitignore b/.gitignore index 846792d..e61bbc7 100644 --- a/.gitignore +++ b/.gitignore @@ -45,3 +45,4 @@ next-env.d.ts .pnpm .pnpm-store ZXDB/ZXDB_mysql_STRUCTURE_ONLY.sql +bin/sync-downloads.mjs diff --git a/src/app/api/zxdb/search/route.ts b/src/app/api/zxdb/search/route.ts index cb40db7..8d982e2 100644 --- a/src/app/api/zxdb/search/route.ts +++ b/src/app/api/zxdb/search/route.ts @@ -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) { diff --git a/src/app/zxdb/entries/EntriesExplorer.tsx b/src/app/zxdb/entries/EntriesExplorer.tsx index 1ccebaa..5255002 100644 --- a/src/app/zxdb/entries/EntriesExplorer.tsx +++ b/src/app/zxdb/entries/EntriesExplorer.tsx @@ -16,6 +16,8 @@ type Item = { languageName?: string | null; }; +type SearchScope = "title" | "title_aliases" | "title_aliases_origins"; + type Paged = { 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(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 = 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 (
@@ -230,6 +239,13 @@ export default function EntriesExplorer({
+
+ +
{loading && (
Loading...
)} diff --git a/src/app/zxdb/entries/[id]/EntryDetail.tsx b/src/app/zxdb/entries/[id]/EntryDetail.tsx index 81cd217..f6b685a 100644 --- a/src/app/zxdb/entries/[id]/EntryDetail.tsx +++ b/src/app/zxdb/entries/[id]/EntryDetail.tsx @@ -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
+
+
Releases
+ {(!data.releases || data.releases.length === 0) &&
No releases recorded
} + {data.releases && data.releases.length > 0 && ( +
+ + + + + + + + + + {data.releases.map((r) => ( + + + + + + ))} + +
Release #YearDownloads
+ #{r.releaseSeq} + {r.year ?? -}{r.downloads.length}
+
+ )} +
+ +
+ {/* Aliases (alternative titles) */}
Aliases
@@ -332,6 +372,47 @@ export default function EntryDetailClient({ data }: { data: EntryDetailData | nu
+
+
Licenses
+ {(!data.licenses || data.licenses.length === 0) &&
No licenses linked
} + {data.licenses && data.licenses.length > 0 && ( +
+ + + + + + + + + + + {data.licenses.map((l) => ( + + + + + + + ))} + +
NameTypeOfficialLinks
{l.name}{l.type.name ?? l.type.id}{l.isOfficial ? "Yes" : "No"} +
+ {l.linkWikipedia && ( + Wikipedia + )} + {l.linkSite && ( + Site + )} + {!l.linkWikipedia && !l.linkSite && -} +
+
+
+ )} +
+ +
+ {/* Web links (external references) */}
Web links
diff --git a/src/app/zxdb/entries/page.tsx b/src/app/zxdb/entries/page.tsx index 64b9fd3..9b546c8 100644 --- a/src/app/zxdb/entries/page.tsx +++ b/src/app/zxdb/entries/page.tsx @@ -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 }} /> ); } diff --git a/src/app/zxdb/labels/[id]/LabelDetail.tsx b/src/app/zxdb/labels/[id]/LabelDetail.tsx index 3e187bd..bc95b70 100644 --- a/src/app/zxdb/labels/[id]/LabelDetail.tsx +++ b/src/app/zxdb/labels/[id]/LabelDetail.tsx @@ -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 = { items: T[]; page: number; pageSize: number; total: number }; @@ -36,6 +53,77 @@ export default function LabelDetailClient({ id, initial, initialTab, initialQ }:
+
+
+
Permissions
+ {initial.label.permissions.length === 0 &&
No permissions recorded
} + {initial.label.permissions.length > 0 && ( +
+ + + + + + + + + + {initial.label.permissions.map((p, idx) => ( + + + + + + ))} + +
WebsiteTypeNotes
+ {p.website.link ? ( + {p.website.name} + ) : ( + {p.website.name} + )} + {p.type.name ?? p.type.id}{p.text ?? ""}
+
+ )} +
+
+
Licenses
+ {initial.label.licenses.length === 0 &&
No licenses linked
} + {initial.label.licenses.length > 0 && ( +
+ + + + + + + + + + {initial.label.licenses.map((l) => ( + + + + + + ))} + +
NameTypeLinks
{l.name}{l.type.name ?? l.type.id} +
+ {l.linkWikipedia && ( + Wikipedia + )} + {l.linkSite && ( + Site + )} + {!l.linkWikipedia && !l.linkSite && -} +
+
+
+ )} +
+
+
  • diff --git a/src/app/zxdb/page.tsx b/src/app/zxdb/page.tsx index a7b1724..0af1e1b 100644 --- a/src/app/zxdb/page.tsx +++ b/src/app/zxdb/page.tsx @@ -12,6 +12,22 @@ export default async function Page() {

    ZXDB Explorer

    Choose what you want to explore.

    +
    +
    + +
    +
    + +
    +
    + +
    +
    +
    diff --git a/src/server/repo/zxdb.ts b/src/server/repo/zxdb.ts index 1bb1541..ab2e8c2 100644 --- a/src/server/repo/zxdb.ts +++ b/src/server/repo/zxdb.ts @@ -5,6 +5,8 @@ import { db } from "@/server/db"; import { entries, searchByTitles, + searchByAliases, + searchByOrigins, labels, authors, publishers, @@ -23,6 +25,12 @@ import { currencies, roletypes, aliases, + relatedlicenses, + licenses, + licensetypes, + licensors, + permissions, + permissiontypes, webrefs, websites, magazines, @@ -32,6 +40,8 @@ import { referencetypes, } from "@/server/schema/zxdb"; +export type EntrySearchScope = "title" | "title_aliases" | "title_aliases_origins"; + export interface SearchParams { q?: string; page?: number; // 1-based @@ -42,6 +52,8 @@ export interface SearchParams { machinetypeId?: number; // Sorting sort?: "title" | "id_desc"; + // Search scope (defaults to titles only) + scope?: EntrySearchScope; } export interface SearchResultItem { @@ -73,6 +85,19 @@ export interface EntryFacets { machinetypes: FacetItem[]; } +function buildEntrySearchUnion(pattern: string, scope: EntrySearchScope) { + const parts: Array> = [ + sql`select ${searchByTitles.entryId} as entry_id from ${searchByTitles} where ${searchByTitles.entryTitle} like ${pattern}`, + ]; + if (scope !== "title") { + parts.push(sql`select ${searchByAliases.entryId} as entry_id from ${searchByAliases} where ${searchByAliases.libraryTitle} like ${pattern}`); + } + if (scope === "title_aliases_origins") { + parts.push(sql`select ${searchByOrigins.entryId} as entry_id from ${searchByOrigins} where ${searchByOrigins.libraryTitle} like ${pattern}`); + } + return sql.join(parts, sql` union `); +} + export interface MagazineListItem { id: number; title: string; @@ -131,6 +156,7 @@ export async function searchEntries(params: SearchParams): Promise`count(distinct ${searchByTitles.entryId})` }) @@ -227,6 +288,15 @@ export interface EntryDetail { genre: { id: number | null; name: string | null }; authors: LabelSummary[]; publishers: LabelSummary[]; + licenses?: { + id: number; + name: string; + type: { id: string; name: string | null }; + isOfficial: boolean; + linkWikipedia?: string | null; + linkSite?: string | null; + comments?: string | null; + }[]; // Additional entry fields for richer details maxPlayers?: number; availabletypeId?: string | null; @@ -479,6 +549,16 @@ export async function getEntryById(id: number): Promise { // Fetch extra relationships in parallel let aliasRows: { releaseSeq: number | string; languageId: string; title: string }[] = []; let webrefRows: { link: string; languageId: string; websiteId: number | string; websiteName: string; websiteLink: string | null }[] = []; + let licenseRows: { + id: number | string; + name: string; + licensetypeId: string; + licensetypeName: string | null; + isOfficial: number | boolean; + linkWikipedia: string | null; + linkSite: string | null; + comments: string | null; + }[] = []; try { aliasRows = await db .select({ releaseSeq: aliases.releaseSeq, languageId: aliases.languageId, title: aliases.title }) @@ -493,6 +573,24 @@ export async function getEntryById(id: number): Promise { .where(eq(webrefs.entryId, id)); webrefRows = rows as typeof webrefRows; } catch {} + try { + const rows = await db + .select({ + id: licenses.id, + name: licenses.name, + licensetypeId: licenses.licensetypeId, + licensetypeName: licensetypes.name, + isOfficial: relatedlicenses.isOfficial, + linkWikipedia: licenses.linkWikipedia, + linkSite: licenses.linkSite, + comments: licenses.comments, + }) + .from(relatedlicenses) + .innerJoin(licenses, eq(licenses.id, relatedlicenses.licenseId)) + .leftJoin(licensetypes, eq(licensetypes.id, licenses.licensetypeId)) + .where(eq(relatedlicenses.entryId, id)); + licenseRows = rows as typeof licenseRows; + } catch {} return { id: base.id, @@ -503,6 +601,15 @@ export async function getEntryById(id: number): Promise { genre: { id: (base.genreId) ?? null, name: (base.genreName) ?? null }, authors: authorRows, publishers: publisherRows, + licenses: licenseRows.map((l) => ({ + id: Number(l.id), + name: l.name, + type: { id: l.licensetypeId, name: l.licensetypeName ?? null }, + isOfficial: !!l.isOfficial, + linkWikipedia: l.linkWikipedia ?? null, + linkSite: l.linkSite ?? null, + comments: l.comments ?? null, + })), maxPlayers: (base.maxPlayers) ?? undefined, availabletypeId: (base.availabletypeId) ?? undefined, withoutLoadScreen: (base.withoutLoadScreen) ?? undefined, @@ -543,7 +650,21 @@ export async function getEntryById(id: number): Promise { // ----- Labels ----- -export type LabelDetail = LabelSummary; +export interface LabelDetail extends LabelSummary { + 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; + }[]; +} export interface LabelSearchParams { q?: string; @@ -589,7 +710,80 @@ export async function searchLabels(params: LabelSearchParams): Promise { const rows = await db.select().from(labels).where(eq(labels.id, id)).limit(1); - return (rows[0]) ?? null; + const base = rows[0]; + if (!base) return null; + + let permissionRows: { + websiteId: number | string; + websiteName: string; + websiteLink: string | null; + permissiontypeId: string; + permissiontypeName: string | null; + text: string | null; + }[] = []; + let licenseRows: { + id: number | string; + name: string; + licensetypeId: string; + licensetypeName: string | null; + linkWikipedia: string | null; + linkSite: string | null; + comments: string | null; + }[] = []; + + try { + const rowsPerm = await db + .select({ + websiteId: websites.id, + websiteName: websites.name, + websiteLink: websites.link, + permissiontypeId: permissiontypes.id, + permissiontypeName: permissiontypes.name, + text: permissions.text, + }) + .from(permissions) + .innerJoin(websites, eq(websites.id, permissions.websiteId)) + .leftJoin(permissiontypes, eq(permissiontypes.id, permissions.permissiontypeId)) + .where(eq(permissions.labelId, id)); + permissionRows = rowsPerm as typeof permissionRows; + } catch {} + + try { + const rowsLic = await db + .select({ + id: licenses.id, + name: licenses.name, + licensetypeId: licenses.licensetypeId, + licensetypeName: licensetypes.name, + linkWikipedia: licenses.linkWikipedia, + linkSite: licenses.linkSite, + comments: licenses.comments, + }) + .from(licensors) + .innerJoin(licenses, eq(licenses.id, licensors.licenseId)) + .leftJoin(licensetypes, eq(licensetypes.id, licenses.licensetypeId)) + .where(eq(licensors.labelId, id)); + licenseRows = rowsLic as typeof licenseRows; + } catch {} + + return { + id: base.id, + name: base.name, + labeltypeId: base.labeltypeId, + permissions: permissionRows.map((p) => ({ + website: { id: Number(p.websiteId), name: p.websiteName, link: p.websiteLink ?? null }, + type: { id: p.permissiontypeId, name: p.permissiontypeName ?? null }, + text: p.text ?? null, + })), + licenses: licenseRows.map((l) => ({ + id: Number(l.id), + name: l.name, + type: { id: l.licensetypeId, name: l.licensetypeName ?? null }, + linkWikipedia: l.linkWikipedia ?? null, + linkSite: l.linkSite ?? null, + comments: l.comments ?? null, + })), + }; } export interface LabelContribsParams { @@ -1024,11 +1218,21 @@ export async function entriesByMachinetype( export async function getEntryFacets(params: SearchParams): Promise { const q = (params.q ?? "").trim(); const pattern = q ? `%${q.toLowerCase().replace(/[^a-z0-9]+/g, "")}%` : null; + const scope: EntrySearchScope = params.scope ?? "title"; // Build base WHERE SQL snippet considering q + filters const whereParts: Array> = []; if (pattern) { - whereParts.push(sql`id in (select entry_id from ${searchByTitles} where ${searchByTitles.entryTitle} like ${pattern})`); + if (scope !== "title") { + try { + const union = buildEntrySearchUnion(pattern, scope); + whereParts.push(sql`id in (select entry_id from (${union}) as matches)`); + } catch { + whereParts.push(sql`id in (select entry_id from ${searchByTitles} where ${searchByTitles.entryTitle} like ${pattern})`); + } + } else { + whereParts.push(sql`id in (select entry_id from ${searchByTitles} where ${searchByTitles.entryTitle} like ${pattern})`); + } } if (params.genreId) whereParts.push(sql`${entries.genretypeId} = ${params.genreId}`); if (params.languageId) whereParts.push(sql`${entries.languageId} = ${params.languageId}`);