From 240936a8505ebde0f9427c35c68b93adb5bdc409 Mon Sep 17 00:00:00 2001 From: "D. Rimron-Soutter" Date: Fri, 12 Dec 2025 16:58:50 +0000 Subject: [PATCH] Standardize ZXDB UI; add SSR search/tables Unify the look and feel of all /zxdb pages and minimize client pop-in. - Make all /zxdb pages full-width to match /explorer - Convert Languages, Genres, Machine Types, and Labels lists to Bootstrap tables with table-striped and table-hover inside table-responsive wrappers - Replace raw FK IDs with linked names via SSR repository joins - Add scoped search boxes on detail pages (labels, genres, languages, machine types) with SSR filtering and pagination that preserves q/tab - Keep explorer results consistent: show Machine/Language names with links, no client lookups required This improves consistency, readability, and first paint stability across the ZXDB section while keeping navigation fast and discoverable. Signed-off-by: Junie@lucy.xalior.com --- package.json | 2 +- src/app/zxdb/ZxdbExplorer.tsx | 30 +- src/app/zxdb/entries/[id]/EntryDetail.tsx | 103 +++++- src/app/zxdb/genres/GenresSearch.tsx | 91 ++++++ src/app/zxdb/genres/[id]/GenreDetail.tsx | 53 ++- src/app/zxdb/genres/[id]/page.tsx | 5 +- src/app/zxdb/labels/LabelsSearch.tsx | 32 +- src/app/zxdb/labels/[id]/LabelDetail.tsx | 51 ++- src/app/zxdb/labels/[id]/page.tsx | 7 +- src/app/zxdb/languages/LanguagesSearch.tsx | 28 +- .../zxdb/languages/[id]/LanguageDetail.tsx | 53 ++- src/app/zxdb/languages/[id]/page.tsx | 5 +- .../zxdb/machinetypes/MachineTypesSearch.tsx | 93 ++++++ .../machinetypes/[id]/MachineTypeDetail.tsx | 137 +++++--- src/app/zxdb/machinetypes/[id]/page.tsx | 5 +- src/app/zxdb/machinetypes/page.tsx | 15 +- src/server/repo/zxdb.ts | 304 ++++++++++++++++-- src/server/schema/zxdb.ts | 6 + 18 files changed, 873 insertions(+), 147 deletions(-) create mode 100644 src/app/zxdb/genres/GenresSearch.tsx create mode 100644 src/app/zxdb/machinetypes/MachineTypesSearch.tsx diff --git a/package.json b/package.json index 8c2a99d..26082f3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "next-explorer", - "version": "0.1.0", + "version": "0.2.0", "private": true, "scripts": { "dev": "PORT=4000 next dev --turbopack", diff --git a/src/app/zxdb/ZxdbExplorer.tsx b/src/app/zxdb/ZxdbExplorer.tsx index fb3e68f..d877c0e 100644 --- a/src/app/zxdb/ZxdbExplorer.tsx +++ b/src/app/zxdb/ZxdbExplorer.tsx @@ -8,7 +8,9 @@ type Item = { title: string; isXrated: number; machinetypeId: number | null; + machinetypeName?: string | null; languageId: string | null; + languageName?: string | null; }; type Paged = { @@ -182,8 +184,8 @@ export default function ZxdbExplorer({ ID Title - Machine - Lang + Machine + Language @@ -193,8 +195,28 @@ export default function ZxdbExplorer({ {it.title} - {it.machinetypeId ?? "-"} - {it.languageId ?? "-"} + + {it.machinetypeId != null ? ( + it.machinetypeName ? ( + {it.machinetypeName} + ) : ( + {it.machinetypeId} + ) + ) : ( + - + )} + + + {it.languageId ? ( + it.languageName ? ( + {it.languageName} + ) : ( + {it.languageId} + ) + ) : ( + - + )} + ))} diff --git a/src/app/zxdb/entries/[id]/EntryDetail.tsx b/src/app/zxdb/entries/[id]/EntryDetail.tsx index fde2991..8ed4fe9 100644 --- a/src/app/zxdb/entries/[id]/EntryDetail.tsx +++ b/src/app/zxdb/entries/[id]/EntryDetail.tsx @@ -12,13 +12,19 @@ export type EntryDetailData = { genre: { id: number | null; name: string | null }; authors: Label[]; publishers: Label[]; + // extra fields for richer details + maxPlayers?: number; + availabletypeId?: string | null; + withoutLoadScreen?: number; + withoutInlay?: number; + issueId?: number | null; }; export default function EntryDetailClient({ data }: { data: EntryDetailData }) { if (!data) return
Not found
; return ( -
+

{data.title}

{data.genre.name && ( @@ -41,6 +47,101 @@ export default function EntryDetailClient({ data }: { data: EntryDetailData }) {
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + {typeof data.maxPlayers !== "undefined" && ( + + + + + )} + {typeof data.availabletypeId !== "undefined" && ( + + + + + )} + {typeof data.withoutLoadScreen !== "undefined" && ( + + + + + )} + {typeof data.withoutInlay !== "undefined" && ( + + + + + )} + {typeof data.issueId !== "undefined" && ( + + + + + )} + +
FieldValue
ID{data.id}
Title{data.title}
Machine + {data.machinetype.id != null ? ( + data.machinetype.name ? ( + {data.machinetype.name} + ) : ( + #{data.machinetype.id} + ) + ) : ( + - + )} +
Language + {data.language.id ? ( + data.language.name ? ( + {data.language.name} + ) : ( + {data.language.id} + ) + ) : ( + - + )} +
Genre + {data.genre.id ? ( + data.genre.name ? ( + {data.genre.name} + ) : ( + #{data.genre.id} + ) + ) : ( + - + )} +
Max Players{data.maxPlayers}
Available Type{data.availabletypeId ?? -}
Without Load Screen{data.withoutLoadScreen ? "Yes" : "No"}
Without Inlay{data.withoutInlay ? "Yes" : "No"}
Issue{data.issueId ? #{data.issueId} : -}
+
+ +
+
Authors
diff --git a/src/app/zxdb/genres/GenresSearch.tsx b/src/app/zxdb/genres/GenresSearch.tsx new file mode 100644 index 0000000..7afa726 --- /dev/null +++ b/src/app/zxdb/genres/GenresSearch.tsx @@ -0,0 +1,91 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; + +type Genre = { id: number; name: string }; +type Paged = { items: T[]; page: number; pageSize: number; total: number }; + +export default function GenresSearch({ initial, initialQ }: { initial?: Paged; initialQ?: string }) { + const router = useRouter(); + const [q, setQ] = useState(initialQ ?? ""); + const [data, setData] = useState | null>(initial ?? null); + const totalPages = useMemo(() => (data ? Math.max(1, Math.ceil(data.total / data.pageSize)) : 1), [data]); + + useEffect(() => { + if (initial) setData(initial); + }, [initial]); + + useEffect(() => { + setQ(initialQ ?? ""); + }, [initialQ]); + + function submit(e: React.FormEvent) { + e.preventDefault(); + const params = new URLSearchParams(); + if (q) params.set("q", q); + params.set("page", "1"); + router.push(`/zxdb/genres?${params.toString()}`); + } + + return ( +
+

Genres

+
+
+ setQ(e.target.value)} /> +
+
+ +
+
+ +
+ {data && data.items.length === 0 &&
No genres found.
} + {data && data.items.length > 0 && ( +
+ + + + + + + + + {data.items.map((g) => ( + + + + + ))} + +
IDName
#{g.id} + {g.name} +
+
+ )} +
+ +
+ Page {data?.page ?? 1} / {totalPages} +
+ { const p = new URLSearchParams(); if (q) p.set("q", q); p.set("page", String(Math.max(1, (data?.page ?? 1) - 1))); return p.toString(); })()}`} + > + Prev + + = totalPages) ? "disabled" : ""}`} + aria-disabled={!data || data.page >= totalPages} + href={`/zxdb/genres?${(() => { const p = new URLSearchParams(); if (q) p.set("q", q); p.set("page", String(Math.min(totalPages, (data?.page ?? 1) + 1))); return p.toString(); })()}`} + > + Next + +
+
+
+ ); +} diff --git a/src/app/zxdb/genres/[id]/GenreDetail.tsx b/src/app/zxdb/genres/[id]/GenreDetail.tsx index f8b4e36..3e94403 100644 --- a/src/app/zxdb/genres/[id]/GenreDetail.tsx +++ b/src/app/zxdb/genres/[id]/GenreDetail.tsx @@ -1,17 +1,28 @@ "use client"; import Link from "next/link"; -import { useMemo } from "react"; +import { useMemo, useState } from "react"; +import { useRouter } from "next/navigation"; -type Item = { id: number; title: string; isXrated: number; machinetypeId: number | null; languageId: 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 }; -export default function GenreDetailClient({ id, initial }: { id: number; initial: Paged }) { +export default function GenreDetailClient({ id, initial, initialQ }: { id: number; initial: Paged; initialQ?: string }) { + const router = useRouter(); + const [q, setQ] = useState(initialQ ?? ""); const totalPages = useMemo(() => Math.max(1, Math.ceil(initial.total / initial.pageSize)), [initial]); return ( -
-

Genre #{id}

+
+

Genre #{id}

+
{ e.preventDefault(); const p = new URLSearchParams(); if (q) p.set("q", q); p.set("page", "1"); router.push(`/zxdb/genres/${id}?${p.toString()}`); }}> +
+ setQ(e.target.value)} /> +
+
+ +
+
{initial && initial.items.length === 0 &&
No entries.
} {initial && initial.items.length > 0 && (
@@ -20,8 +31,8 @@ export default function GenreDetailClient({ id, initial }: { id: number; initial ID Title - Machine - Lang + Machine + Language @@ -29,8 +40,28 @@ export default function GenreDetailClient({ id, initial }: { id: number; initial {it.id} {it.title} - {it.machinetypeId ?? "-"} - {it.languageId ?? "-"} + + {it.machinetypeId != null ? ( + it.machinetypeName ? ( + {it.machinetypeName} + ) : ( + {it.machinetypeId} + ) + ) : ( + - + )} + + + {it.languageId ? ( + it.languageName ? ( + {it.languageName} + ) : ( + {it.languageId} + ) + ) : ( + - + )} + ))} @@ -44,14 +75,14 @@ export default function GenreDetailClient({ id, initial }: { id: number; initial { const p = new URLSearchParams(); if (q) p.set("q", q); p.set("page", String(Math.max(1, initial.page - 1))); return p.toString(); })()}`} > Prev = totalPages ? "disabled" : ""}`} aria-disabled={initial.page >= totalPages} - href={`/zxdb/genres/${id}?page=${Math.min(totalPages, initial.page + 1)}`} + href={`/zxdb/genres/${id}?${(() => { const p = new URLSearchParams(); if (q) p.set("q", q); p.set("page", String(Math.min(totalPages, initial.page + 1))); return p.toString(); })()}`} > Next diff --git a/src/app/zxdb/genres/[id]/page.tsx b/src/app/zxdb/genres/[id]/page.tsx index 5d21e02..0b071a4 100644 --- a/src/app/zxdb/genres/[id]/page.tsx +++ b/src/app/zxdb/genres/[id]/page.tsx @@ -10,6 +10,7 @@ export default async function Page({ params, searchParams }: { params: Promise<{ const [{ id }, sp] = await Promise.all([params, searchParams]); const numericId = Number(id); const page = Math.max(1, Number(Array.isArray(sp.page) ? sp.page[0] : sp.page) || 1); - const initial = await entriesByGenre(numericId, page, 20); - return ; + const q = (Array.isArray(sp.q) ? sp.q[0] : sp.q) ?? ""; + const initial = await entriesByGenre(numericId, page, 20, q || undefined); + return ; } diff --git a/src/app/zxdb/labels/LabelsSearch.tsx b/src/app/zxdb/labels/LabelsSearch.tsx index 29e5cda..9911fb6 100644 --- a/src/app/zxdb/labels/LabelsSearch.tsx +++ b/src/app/zxdb/labels/LabelsSearch.tsx @@ -46,14 +46,30 @@ export default function LabelsSearch({ initial, initialQ }: { initial?: Paged {data && data.items.length === 0 &&
No labels found.
} {data && data.items.length > 0 && ( -
    - {data.items.map((l) => ( -
  • - {l.name} - {l.labeltypeId ?? "?"} -
  • - ))} -
+
+ + + + + + + + + + {data.items.map((l) => ( + + + + + + ))} + +
IDNameType
#{l.id} + {l.name} + + {l.labeltypeId ?? "?"} +
+
)}
diff --git a/src/app/zxdb/labels/[id]/LabelDetail.tsx b/src/app/zxdb/labels/[id]/LabelDetail.tsx index 6793c1a..bb896f0 100644 --- a/src/app/zxdb/labels/[id]/LabelDetail.tsx +++ b/src/app/zxdb/labels/[id]/LabelDetail.tsx @@ -2,16 +2,20 @@ import Link from "next/link"; import { useMemo, useState } from "react"; +import { useRouter } from "next/navigation"; type Label = { id: number; name: string; labeltypeId: string | null }; -type Item = { id: number; title: string; isXrated: number; machinetypeId: number | null; languageId: 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 }; type Payload = { label: Label | null; authored: Paged; published: Paged }; -export default function LabelDetailClient({ id, initial, initialTab }: { id: number; initial: Payload; initialTab?: "authored" | "published" }) { +export default function LabelDetailClient({ id, initial, initialTab, initialQ }: { id: number; initial: Payload; initialTab?: "authored" | "published"; initialQ?: string }) { // Keep only interactive UI state (tab). Data should come directly from SSR props so it updates on navigation. const [tab, setTab] = useState<"authored" | "published">(initialTab ?? "authored"); + const [q, setQ] = useState(initialQ ?? ""); + const router = useRouter(); + // Names are now delivered by SSR payload to minimize pop-in. if (!initial || !initial.label) return
Not found
; @@ -19,7 +23,7 @@ export default function LabelDetailClient({ id, initial, initialTab }: { id: num const totalPages = useMemo(() => Math.max(1, Math.ceil(current.total / current.pageSize)), [current]); return ( -
+

{initial.label.name}

@@ -36,6 +40,15 @@ export default function LabelDetailClient({ id, initial, initialTab }: { id: num +
{ e.preventDefault(); const p = new URLSearchParams(); p.set("tab", tab); if (q) p.set("q", q); p.set("page", "1"); router.push(`/zxdb/labels/${id}?${p.toString()}`); }}> +
+ setQ(e.target.value)} /> +
+
+ +
+
+
{current && current.items.length === 0 &&
No entries.
} {current && current.items.length > 0 && ( @@ -45,8 +58,8 @@ export default function LabelDetailClient({ id, initial, initialTab }: { id: num ID Title - Machine - Lang + Machine + Language @@ -54,8 +67,28 @@ export default function LabelDetailClient({ id, initial, initialTab }: { id: num {it.id} {it.title} - {it.machinetypeId ?? "-"} - {it.languageId ?? "-"} + + {it.machinetypeId != null ? ( + it.machinetypeName ? ( + {it.machinetypeName} + ) : ( + {it.machinetypeId} + ) + ) : ( + - + )} + + + {it.languageId ? ( + it.languageName ? ( + {it.languageName} + ) : ( + {it.languageId} + ) + ) : ( + - + )} + ))} @@ -70,14 +103,14 @@ export default function LabelDetailClient({ id, initial, initialTab }: { id: num { const p = new URLSearchParams(); p.set("tab", tab); if (q) p.set("q", q); p.set("page", String(Math.max(1, current.page - 1))); return p.toString(); })()}`} > Prev = totalPages ? "disabled" : ""}`} aria-disabled={current.page >= totalPages} - href={`/zxdb/labels/${id}?tab=${tab}&page=${Math.min(totalPages, current.page + 1)}`} + href={`/zxdb/labels/${id}?${(() => { const p = new URLSearchParams(); p.set("tab", tab); if (q) p.set("q", q); p.set("page", String(Math.min(totalPages, current.page + 1))); return p.toString(); })()}`} > Next diff --git a/src/app/zxdb/labels/[id]/page.tsx b/src/app/zxdb/labels/[id]/page.tsx index 6e79b9a..7401f8a 100644 --- a/src/app/zxdb/labels/[id]/page.tsx +++ b/src/app/zxdb/labels/[id]/page.tsx @@ -12,12 +12,13 @@ export default async function Page({ params, searchParams }: { params: Promise<{ const numericId = Number(id); const page = Math.max(1, Number(Array.isArray(sp.page) ? sp.page[0] : sp.page) || 1); const tab = (Array.isArray(sp.tab) ? sp.tab[0] : sp.tab) as "authored" | "published" | undefined; + const q = (Array.isArray(sp.q) ? sp.q[0] : sp.q) ?? ""; const [label, authored, published] = await Promise.all([ getLabelById(numericId), - getLabelAuthoredEntries(numericId, { page, pageSize: 20 }), - getLabelPublishedEntries(numericId, { page, pageSize: 20 }), + getLabelAuthoredEntries(numericId, { page, pageSize: 20, q: q || undefined }), + getLabelPublishedEntries(numericId, { page, pageSize: 20, q: q || undefined }), ]); // Let the client component handle the "not found" simple state - return ; + return ; } diff --git a/src/app/zxdb/languages/LanguagesSearch.tsx b/src/app/zxdb/languages/LanguagesSearch.tsx index 232d5f0..1ec3043 100644 --- a/src/app/zxdb/languages/LanguagesSearch.tsx +++ b/src/app/zxdb/languages/LanguagesSearch.tsx @@ -44,14 +44,26 @@ export default function LanguagesSearch({ initial, initialQ }: { initial?: Paged
{data && data.items.length === 0 &&
No languages found.
} {data && data.items.length > 0 && ( -
    - {data.items.map((l) => ( -
  • - {l.name} - {l.id} -
  • - ))} -
+
+ + + + + + + + + {data.items.map((l) => ( + + + + + ))} + +
CodeName
{l.id} + {l.name} +
+
)}
diff --git a/src/app/zxdb/languages/[id]/LanguageDetail.tsx b/src/app/zxdb/languages/[id]/LanguageDetail.tsx index 8f1565c..6af7db6 100644 --- a/src/app/zxdb/languages/[id]/LanguageDetail.tsx +++ b/src/app/zxdb/languages/[id]/LanguageDetail.tsx @@ -1,17 +1,28 @@ "use client"; import Link from "next/link"; -import { useMemo } from "react"; +import { useMemo, useState } from "react"; +import { useRouter } from "next/navigation"; -type Item = { id: number; title: string; isXrated: number; machinetypeId: number | null; languageId: 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 }; -export default function LanguageDetailClient({ id, initial }: { id: string; initial: Paged }) { +export default function LanguageDetailClient({ id, initial, initialQ }: { id: string; initial: Paged; initialQ?: string }) { + const router = useRouter(); + const [q, setQ] = useState(initialQ ?? ""); const totalPages = useMemo(() => Math.max(1, Math.ceil(initial.total / initial.pageSize)), [initial]); return ( -
-

Language {id}

+
+

Language {id}

+
{ e.preventDefault(); const p = new URLSearchParams(); if (q) p.set("q", q); p.set("page", "1"); router.push(`/zxdb/languages/${id}?${p.toString()}`); }}> +
+ setQ(e.target.value)} /> +
+
+ +
+
{initial && initial.items.length === 0 &&
No entries.
} {initial && initial.items.length > 0 && (
@@ -20,8 +31,8 @@ export default function LanguageDetailClient({ id, initial }: { id: string; init ID Title - Machine - Lang + Machine + Language @@ -29,8 +40,28 @@ export default function LanguageDetailClient({ id, initial }: { id: string; init {it.id} {it.title} - {it.machinetypeId ?? "-"} - {it.languageId ?? "-"} + + {it.machinetypeId != null ? ( + it.machinetypeName ? ( + {it.machinetypeName} + ) : ( + {it.machinetypeId} + ) + ) : ( + - + )} + + + {it.languageId ? ( + it.languageName ? ( + {it.languageName} + ) : ( + {it.languageId} + ) + ) : ( + - + )} + ))} @@ -44,14 +75,14 @@ export default function LanguageDetailClient({ id, initial }: { id: string; init { const p = new URLSearchParams(); if (q) p.set("q", q); p.set("page", String(Math.max(1, initial.page - 1))); return p.toString(); })()}`} > Prev = totalPages ? "disabled" : ""}`} aria-disabled={initial.page >= totalPages} - href={`/zxdb/languages/${id}?page=${Math.min(totalPages, initial.page + 1)}`} + href={`/zxdb/languages/${id}?${(() => { const p = new URLSearchParams(); if (q) p.set("q", q); p.set("page", String(Math.min(totalPages, initial.page + 1))); return p.toString(); })()}`} > Next diff --git a/src/app/zxdb/languages/[id]/page.tsx b/src/app/zxdb/languages/[id]/page.tsx index 802dcca..e880b6b 100644 --- a/src/app/zxdb/languages/[id]/page.tsx +++ b/src/app/zxdb/languages/[id]/page.tsx @@ -9,6 +9,7 @@ export const dynamic = "force-dynamic"; export default async function Page({ params, searchParams }: { params: Promise<{ id: string }>; searchParams: Promise<{ [key: string]: string | string[] | undefined }> }) { const [{ id }, sp] = await Promise.all([params, searchParams]); const page = Math.max(1, Number(Array.isArray(sp.page) ? sp.page[0] : sp.page) || 1); - const initial = await entriesByLanguage(id, page, 20); - return ; + const q = (Array.isArray(sp.q) ? sp.q[0] : sp.q) ?? ""; + const initial = await entriesByLanguage(id, page, 20, q || undefined); + return ; } diff --git a/src/app/zxdb/machinetypes/MachineTypesSearch.tsx b/src/app/zxdb/machinetypes/MachineTypesSearch.tsx new file mode 100644 index 0000000..d865b9f --- /dev/null +++ b/src/app/zxdb/machinetypes/MachineTypesSearch.tsx @@ -0,0 +1,93 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; + +type MT = { id: number; name: string }; +type Paged = { items: T[]; page: number; pageSize: number; total: number }; + +export default function MachineTypesSearch({ initial, initialQ }: { initial?: Paged; initialQ?: string }) { + const router = useRouter(); + const [q, setQ] = useState(initialQ ?? ""); + const [data, setData] = useState | null>(initial ?? null); + const totalPages = useMemo(() => (data ? Math.max(1, Math.ceil(data.total / data.pageSize)) : 1), [data]); + + // Sync incoming SSR payload on navigation (e.g., when clicking Prev/Next Links) + useEffect(() => { + if (initial) setData(initial); + }, [initial]); + + // Keep input in sync with URL q on navigation + useEffect(() => { + setQ(initialQ ?? ""); + }, [initialQ]); + + function submit(e: React.FormEvent) { + e.preventDefault(); + const params = new URLSearchParams(); + if (q) params.set("q", q); + params.set("page", "1"); + router.push(`/zxdb/machinetypes?${params.toString()}`); + } + + return ( +
+

Machine Types

+
+
+ setQ(e.target.value)} /> +
+
+ +
+
+ +
+ {data && data.items.length === 0 &&
No machine types found.
} + {data && data.items.length > 0 && ( +
+ + + + + + + + + {data.items.map((m) => ( + + + + + ))} + +
IDName
#{m.id} + {m.name} +
+
+ )} +
+ +
+ Page {data?.page ?? 1} / {totalPages} +
+ { const p = new URLSearchParams(); if (q) p.set("q", q); p.set("page", String(Math.max(1, (data?.page ?? 1) - 1))); return p.toString(); })()}`} + > + Prev + + = totalPages) ? "disabled" : ""}`} + aria-disabled={!data || data.page >= totalPages} + href={`/zxdb/machinetypes?${(() => { const p = new URLSearchParams(); if (q) p.set("q", q); p.set("page", String(Math.min(totalPages, (data?.page ?? 1) + 1))); return p.toString(); })()}`} + > + Next + +
+
+
+ ); +} diff --git a/src/app/zxdb/machinetypes/[id]/MachineTypeDetail.tsx b/src/app/zxdb/machinetypes/[id]/MachineTypeDetail.tsx index 9aa5c20..1a51480 100644 --- a/src/app/zxdb/machinetypes/[id]/MachineTypeDetail.tsx +++ b/src/app/zxdb/machinetypes/[id]/MachineTypeDetail.tsx @@ -1,62 +1,105 @@ "use client"; import Link from "next/link"; -import { useMemo } from "react"; +import { useMemo, useState } from "react"; +import { useRouter } from "next/navigation"; -type Item = { id: number; title: string; isXrated: number; machinetypeId: number | null; languageId: 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 }; -export default function MachineTypeDetailClient({ id, initial }: { id: number; initial: Paged }) { +export default function MachineTypeDetailClient({ id, initial, initialQ }: { id: number; initial: Paged; initialQ?: string }) { + const router = useRouter(); + const [q, setQ] = useState(initialQ ?? ""); const totalPages = useMemo(() => Math.max(1, Math.ceil(initial.total / initial.pageSize)), [initial]); + const machineName = useMemo(() => { + // Prefer the name already provided by SSR items to avoid client pop-in + return initial.items.find((it) => it.machinetypeId != null && it.machinetypeName)?.machinetypeName ?? null; + }, [initial]); return ( -
-

Machine Type #{id}

- {initial && initial.items.length === 0 &&
No entries.
} - {initial && initial.items.length > 0 && ( -
- - - - - - - - - - - {initial.items.map((it) => ( - - - - - +
+

{machineName ?? "Machine Type"} #{id}

+
{ e.preventDefault(); const p = new URLSearchParams(); if (q) p.set("q", q); p.set("page", "1"); router.push(`/zxdb/machinetypes/${id}?${p.toString()}`); }}> +
+ setQ(e.target.value)} /> +
+
+ +
+ + {initial && initial.items.length === 0 &&
No entries.
} + {initial && initial.items.length > 0 && ( +
+
IDTitleMachineLang
{it.id}{it.title}{it.machinetypeId ?? "-"}{it.languageId ?? "-"}
+ + + + + + - ))} - -
IDTitleMachineLanguage
-
- )} + + + {initial.items.map((it) => ( + + {it.id} + {it.title} + + {it.machinetypeId != null ? ( + it.machinetypeName ? ( + {it.machinetypeName} + ) : ( + {it.machinetypeId} + ) + ) : ( + - + )} + + + {it.languageId ? ( + it.languageName ? ( + {it.languageName} + ) : ( + {it.languageId} + ) + ) : ( + - + )} + + + ))} + + +
+ )} -
- Page {initial.page} / {totalPages} -
- - Prev - - = totalPages ? "disabled" : ""}`} - aria-disabled={initial.page >= totalPages} - href={`/zxdb/machinetypes/${id}?page=${Math.min(totalPages, initial.page + 1)}`} - > - Next - +
+ Page {initial.page} / {totalPages} +
+ { const p = new URLSearchParams(); if (q) p.set("q", q); p.set("page", String(Math.max(1, initial.page - 1))); return p.toString(); })()}`} + > + Prev + + = totalPages ? "disabled" : ""}`} + aria-disabled={initial.page >= totalPages} + href={`/zxdb/machinetypes/${id}?${(() => { const p = new URLSearchParams(); if (q) p.set("q", q); p.set("page", String(Math.min(totalPages, initial.page + 1))); return p.toString(); })()}`} + > + Next + +
-
); } diff --git a/src/app/zxdb/machinetypes/[id]/page.tsx b/src/app/zxdb/machinetypes/[id]/page.tsx index 61e20bd..f3e36f9 100644 --- a/src/app/zxdb/machinetypes/[id]/page.tsx +++ b/src/app/zxdb/machinetypes/[id]/page.tsx @@ -9,6 +9,7 @@ export default async function Page({ params, searchParams }: { params: Promise<{ const [{ id }, sp] = await Promise.all([params, searchParams]); const numericId = Number(id); const page = Math.max(1, Number(Array.isArray(sp.page) ? sp.page[0] : sp.page) || 1); - const initial = await entriesByMachinetype(numericId, page, 20); - return ; + const q = (Array.isArray(sp.q) ? sp.q[0] : sp.q) ?? ""; + const initial = await entriesByMachinetype(numericId, page, 20, q || undefined); + return ; } diff --git a/src/app/zxdb/machinetypes/page.tsx b/src/app/zxdb/machinetypes/page.tsx index efe3501..f38057b 100644 --- a/src/app/zxdb/machinetypes/page.tsx +++ b/src/app/zxdb/machinetypes/page.tsx @@ -1,11 +1,14 @@ -import MachineTypeList from "./MachineTypeList"; -import { listMachinetypes } from "@/server/repo/zxdb"; +import MachineTypesSearch from "./MachineTypesSearch"; +import { searchMachinetypes } from "@/server/repo/zxdb"; export const metadata = { title: "ZXDB Machine Types" }; -export const revalidate = 3600; +export const dynamic = "force-dynamic"; -export default async function Page() { - const items = await listMachinetypes(); - return ; +export default async function Page({ searchParams }: { searchParams: Promise<{ [key: string]: string | string[] | undefined }> }) { + const sp = await searchParams; + const q = (Array.isArray(sp.q) ? sp.q[0] : sp.q) ?? ""; + const page = Math.max(1, Number(Array.isArray(sp.page) ? sp.page[0] : sp.page) || 1); + const initial = await searchMachinetypes({ q, page, pageSize: 20 }); + return ; } diff --git a/src/server/repo/zxdb.ts b/src/server/repo/zxdb.ts index 916747c..20541dc 100644 --- a/src/server/repo/zxdb.ts +++ b/src/server/repo/zxdb.ts @@ -28,7 +28,9 @@ export interface SearchResultItem { title: string; isXrated: number; machinetypeId: number | null; + machinetypeName?: string | null; languageId: string | null; + languageName?: string | null; } export interface PagedResult { @@ -70,8 +72,18 @@ export async function searchEntries(params: SearchParams): Promise { @@ -146,6 +168,11 @@ export async function getEntryById(id: number): Promise { languageName: languages.name, genreId: entries.genretypeId, genreName: genretypes.name, + maxPlayers: entries.maxPlayers, + availabletypeId: entries.availabletypeId, + withoutLoadScreen: entries.withoutLoadScreen, + withoutInlay: entries.withoutInlay, + issueId: entries.issueId, }) .from(entries) .leftJoin(machinetypes, eq(machinetypes.id, entries.machinetypeId as any)) @@ -178,6 +205,11 @@ export async function getEntryById(id: number): Promise { genre: { id: (base.genreId as any) ?? null, name: (base.genreName as any) ?? null }, authors: authorRows as any, publishers: publisherRows as any, + maxPlayers: (base.maxPlayers as any) ?? undefined, + availabletypeId: (base.availabletypeId as any) ?? undefined, + withoutLoadScreen: (base.withoutLoadScreen as any) ?? undefined, + withoutInlay: (base.withoutInlay as any) ?? undefined, + issueId: (base.issueId as any) ?? undefined, }; } @@ -237,30 +269,67 @@ export async function getLabelById(id: number): Promise { export interface LabelContribsParams { page?: number; pageSize?: number; + q?: string; // optional title filter } export async function getLabelAuthoredEntries(labelId: number, params: LabelContribsParams): Promise> { const pageSize = Math.max(1, Math.min(params.pageSize ?? 20, 100)); const page = Math.max(1, params.page ?? 1); const offset = (page - 1) * pageSize; + const hasQ = !!(params.q && params.q.trim()); + if (!hasQ) { + const countRows = await db + .select({ total: sql`count(distinct ${authors.entryId})` }) + .from(authors) + .where(eq(authors.labelId, labelId)); + const total = Number(countRows[0]?.total ?? 0); + + const items = await db + .select({ + id: entries.id, + title: entries.title, + isXrated: entries.isXrated, + machinetypeId: entries.machinetypeId, + machinetypeName: machinetypes.name, + languageId: entries.languageId, + languageName: languages.name, + }) + .from(authors) + .innerJoin(entries, eq(entries.id, authors.entryId)) + .leftJoin(machinetypes, eq(machinetypes.id, entries.machinetypeId as any)) + .leftJoin(languages, eq(languages.id, entries.languageId as any)) + .where(eq(authors.labelId, labelId)) + .groupBy(entries.id) + .orderBy(entries.title) + .limit(pageSize) + .offset(offset); + + return { items: items as any, page, pageSize, total }; + } + + const pattern = `%${params.q!.toLowerCase().replace(/[^a-z0-9]+/g, "")}%`; const countRows = await db - .select({ total: sql`count(distinct ${authors.entryId})` }) + .select({ total: sql`count(distinct ${entries.id})` }) .from(authors) - .where(eq(authors.labelId, labelId)); - const total = Number(countRows[0]?.total ?? 0); - + .innerJoin(entries, eq(entries.id, authors.entryId)) + .where(and(eq(authors.labelId, labelId), sql`${entries.id} in (select entry_id from ${searchByTitles} where ${searchByTitles.entryTitle} like ${pattern})`) as any); + const total = Number((countRows as any)[0]?.total ?? 0); const items = await db .select({ id: entries.id, title: entries.title, isXrated: entries.isXrated, machinetypeId: entries.machinetypeId, + machinetypeName: machinetypes.name, languageId: entries.languageId, + languageName: languages.name, }) .from(authors) .innerJoin(entries, eq(entries.id, authors.entryId)) - .where(eq(authors.labelId, labelId)) + .leftJoin(machinetypes, eq(machinetypes.id, entries.machinetypeId as any)) + .leftJoin(languages, eq(languages.id, entries.languageId as any)) + .where(and(eq(authors.labelId, labelId), sql`${entries.id} in (select entry_id from ${searchByTitles} where ${searchByTitles.entryTitle} like ${pattern})`) as any) .groupBy(entries.id) .orderBy(entries.title) .limit(pageSize) @@ -273,24 +342,60 @@ export async function getLabelPublishedEntries(labelId: number, params: LabelCon const pageSize = Math.max(1, Math.min(params.pageSize ?? 20, 100)); const page = Math.max(1, params.page ?? 1); const offset = (page - 1) * pageSize; + const hasQ = !!(params.q && params.q.trim()); + if (!hasQ) { + const countRows = await db + .select({ total: sql`count(distinct ${publishers.entryId})` }) + .from(publishers) + .where(eq(publishers.labelId, labelId)); + const total = Number(countRows[0]?.total ?? 0); + + const items = await db + .select({ + id: entries.id, + title: entries.title, + isXrated: entries.isXrated, + machinetypeId: entries.machinetypeId, + machinetypeName: machinetypes.name, + languageId: entries.languageId, + languageName: languages.name, + }) + .from(publishers) + .innerJoin(entries, eq(entries.id, publishers.entryId)) + .leftJoin(machinetypes, eq(machinetypes.id, entries.machinetypeId as any)) + .leftJoin(languages, eq(languages.id, entries.languageId as any)) + .where(eq(publishers.labelId, labelId)) + .groupBy(entries.id) + .orderBy(entries.title) + .limit(pageSize) + .offset(offset); + + return { items: items as any, page, pageSize, total }; + } + + const pattern = `%${params.q!.toLowerCase().replace(/[^a-z0-9]+/g, "")}%`; const countRows = await db - .select({ total: sql`count(distinct ${publishers.entryId})` }) + .select({ total: sql`count(distinct ${entries.id})` }) .from(publishers) - .where(eq(publishers.labelId, labelId)); - const total = Number(countRows[0]?.total ?? 0); - + .innerJoin(entries, eq(entries.id, publishers.entryId)) + .where(and(eq(publishers.labelId, labelId), sql`${entries.id} in (select entry_id from ${searchByTitles} where ${searchByTitles.entryTitle} like ${pattern})`) as any); + const total = Number((countRows as any)[0]?.total ?? 0); const items = await db .select({ id: entries.id, title: entries.title, isXrated: entries.isXrated, machinetypeId: entries.machinetypeId, + machinetypeName: machinetypes.name, languageId: entries.languageId, + languageName: languages.name, }) .from(publishers) .innerJoin(entries, eq(entries.id, publishers.entryId)) - .where(eq(publishers.labelId, labelId)) + .leftJoin(machinetypes, eq(machinetypes.id, entries.machinetypeId as any)) + .leftJoin(languages, eq(languages.id, entries.languageId as any)) + .where(and(eq(publishers.labelId, labelId), sql`${entries.id} in (select entry_id from ${searchByTitles} where ${searchByTitles.entryTitle} like ${pattern})`) as any) .groupBy(entries.id) .orderBy(entries.title) .limit(pageSize) @@ -408,52 +513,187 @@ export async function searchMachinetypes(params: SimpleSearchParams) { return { items: items as any, page, pageSize, total }; } -export async function entriesByGenre(genreId: number, page: number, pageSize: number): Promise> { +export async function entriesByGenre( + genreId: number, + page: number, + pageSize: number, + q?: string +): Promise> { const offset = (page - 1) * pageSize; - const countRows = (await db - .select({ total: sql`count(*)` }) + const hasQ = !!(q && q.trim()); + + if (!hasQ) { + const countRows = (await db + .select({ total: sql`count(*)` }) + .from(entries) + .where(eq(entries.genretypeId, genreId as any))) as unknown as { total: number }[]; + const items = await db + .select({ + id: entries.id, + title: entries.title, + isXrated: entries.isXrated, + machinetypeId: entries.machinetypeId, + machinetypeName: machinetypes.name, + languageId: entries.languageId, + languageName: languages.name, + }) + .from(entries) + .leftJoin(machinetypes, eq(machinetypes.id, entries.machinetypeId as any)) + .leftJoin(languages, eq(languages.id, entries.languageId as any)) + .where(eq(entries.genretypeId, genreId as any)) + .orderBy(entries.title) + .limit(pageSize) + .offset(offset); + return { items: items as any, page, pageSize, total: Number(countRows?.[0]?.total ?? 0) }; + } + + const pattern = `%${q!.toLowerCase().replace(/[^a-z0-9]+/g, "")}%`; + const countRows = await db + .select({ total: sql`count(distinct ${entries.id})` }) .from(entries) - .where(eq(entries.genretypeId, genreId as any))) as unknown as { total: number }[]; + .where(and(eq(entries.genretypeId, genreId as any), sql`id in (select entry_id from ${searchByTitles} where ${searchByTitles.entryTitle} like ${pattern})`) as any); + const total = Number((countRows as any)[0]?.total ?? 0); const items = await db - .select({ id: entries.id, title: entries.title, isXrated: entries.isXrated, machinetypeId: entries.machinetypeId, languageId: entries.languageId }) + .select({ + id: entries.id, + title: entries.title, + isXrated: entries.isXrated, + machinetypeId: entries.machinetypeId, + machinetypeName: machinetypes.name, + languageId: entries.languageId, + languageName: languages.name, + }) .from(entries) - .where(eq(entries.genretypeId, genreId as any)) + .leftJoin(machinetypes, eq(machinetypes.id, entries.machinetypeId as any)) + .leftJoin(languages, eq(languages.id, entries.languageId as any)) + .where(and(eq(entries.genretypeId, genreId as any), sql`id in (select entry_id from ${searchByTitles} where ${searchByTitles.entryTitle} like ${pattern})`) as any) + .groupBy(entries.id) .orderBy(entries.title) .limit(pageSize) .offset(offset); - return { items: items as any, page, pageSize, total: Number(countRows?.[0]?.total ?? 0) }; + return { items: items as any, page, pageSize, total }; } -export async function entriesByLanguage(langId: string, page: number, pageSize: number): Promise> { +export async function entriesByLanguage( + langId: string, + page: number, + pageSize: number, + q?: string +): Promise> { const offset = (page - 1) * pageSize; - const countRows = (await db - .select({ total: sql`count(*)` }) + const hasQ = !!(q && q.trim()); + + if (!hasQ) { + const countRows = (await db + .select({ total: sql`count(*)` }) + .from(entries) + .where(eq(entries.languageId, langId as any))) as unknown as { total: number }[]; + const items = await db + .select({ + id: entries.id, + title: entries.title, + isXrated: entries.isXrated, + machinetypeId: entries.machinetypeId, + machinetypeName: machinetypes.name, + languageId: entries.languageId, + languageName: languages.name, + }) + .from(entries) + .leftJoin(machinetypes, eq(machinetypes.id, entries.machinetypeId as any)) + .leftJoin(languages, eq(languages.id, entries.languageId as any)) + .where(eq(entries.languageId, langId as any)) + .orderBy(entries.title) + .limit(pageSize) + .offset(offset); + return { items: items as any, page, pageSize, total: Number(countRows?.[0]?.total ?? 0) }; + } + + const pattern = `%${q!.toLowerCase().replace(/[^a-z0-9]+/g, "")}%`; + const countRows = await db + .select({ total: sql`count(distinct ${entries.id})` }) .from(entries) - .where(eq(entries.languageId, langId as any))) as unknown as { total: number }[]; + .where(and(eq(entries.languageId, langId as any), sql`id in (select entry_id from ${searchByTitles} where ${searchByTitles.entryTitle} like ${pattern})`) as any); + const total = Number((countRows as any)[0]?.total ?? 0); const items = await db - .select({ id: entries.id, title: entries.title, isXrated: entries.isXrated, machinetypeId: entries.machinetypeId, languageId: entries.languageId }) + .select({ + id: entries.id, + title: entries.title, + isXrated: entries.isXrated, + machinetypeId: entries.machinetypeId, + machinetypeName: machinetypes.name, + languageId: entries.languageId, + languageName: languages.name, + }) .from(entries) - .where(eq(entries.languageId, langId as any)) + .leftJoin(machinetypes, eq(machinetypes.id, entries.machinetypeId as any)) + .leftJoin(languages, eq(languages.id, entries.languageId as any)) + .where(and(eq(entries.languageId, langId as any), sql`id in (select entry_id from ${searchByTitles} where ${searchByTitles.entryTitle} like ${pattern})`) as any) + .groupBy(entries.id) .orderBy(entries.title) .limit(pageSize) .offset(offset); - return { items: items as any, page, pageSize, total: Number(countRows?.[0]?.total ?? 0) }; + return { items: items as any, page, pageSize, total }; } -export async function entriesByMachinetype(mtId: number, page: number, pageSize: number): Promise> { +export async function entriesByMachinetype( + mtId: number, + page: number, + pageSize: number, + q?: string +): Promise> { const offset = (page - 1) * pageSize; - const countRows = (await db - .select({ total: sql`count(*)` }) + const hasQ = !!(q && q.trim()); + + if (!hasQ) { + const countRows = (await db + .select({ total: sql`count(*)` }) + .from(entries) + .where(eq(entries.machinetypeId, mtId as any))) as unknown as { total: number }[]; + const items = await db + .select({ + id: entries.id, + title: entries.title, + isXrated: entries.isXrated, + machinetypeId: entries.machinetypeId, + machinetypeName: machinetypes.name, + languageId: entries.languageId, + languageName: languages.name, + }) + .from(entries) + .leftJoin(machinetypes, eq(machinetypes.id, entries.machinetypeId as any)) + .leftJoin(languages, eq(languages.id, entries.languageId as any)) + .where(eq(entries.machinetypeId, mtId as any)) + .orderBy(entries.title) + .limit(pageSize) + .offset(offset); + return { items: items as any, page, pageSize, total: Number(countRows?.[0]?.total ?? 0) }; + } + + const pattern = `%${q!.toLowerCase().replace(/[^a-z0-9]+/g, "")}%`; + const countRows = await db + .select({ total: sql`count(distinct ${entries.id})` }) .from(entries) - .where(eq(entries.machinetypeId, mtId as any))) as unknown as { total: number }[]; + .where(and(eq(entries.machinetypeId, mtId as any), sql`id in (select entry_id from ${searchByTitles} where ${searchByTitles.entryTitle} like ${pattern})`) as any); + const total = Number((countRows as any)[0]?.total ?? 0); const items = await db - .select({ id: entries.id, title: entries.title, isXrated: entries.isXrated, machinetypeId: entries.machinetypeId, languageId: entries.languageId }) + .select({ + id: entries.id, + title: entries.title, + isXrated: entries.isXrated, + machinetypeId: entries.machinetypeId, + machinetypeName: machinetypes.name, + languageId: entries.languageId, + languageName: languages.name, + }) .from(entries) - .where(eq(entries.machinetypeId, mtId as any)) + .leftJoin(machinetypes, eq(machinetypes.id, entries.machinetypeId as any)) + .leftJoin(languages, eq(languages.id, entries.languageId as any)) + .where(and(eq(entries.machinetypeId, mtId as any), sql`id in (select entry_id from ${searchByTitles} where ${searchByTitles.entryTitle} like ${pattern})`) as any) + .groupBy(entries.id) .orderBy(entries.title) .limit(pageSize) .offset(offset); - return { items: items as any, page, pageSize, total: Number(countRows?.[0]?.total ?? 0) }; + return { items: items as any, page, pageSize, total }; } // ----- Facets for search ----- diff --git a/src/server/schema/zxdb.ts b/src/server/schema/zxdb.ts index 4b8ef52..fa6e021 100644 --- a/src/server/schema/zxdb.ts +++ b/src/server/schema/zxdb.ts @@ -6,8 +6,14 @@ export const entries = mysqlTable("entries", { title: varchar("title", { length: 250 }).notNull(), isXrated: tinyint("is_xrated").notNull(), machinetypeId: tinyint("machinetype_id"), + maxPlayers: tinyint("max_players").notNull().default(1), languageId: char("language_id", { length: 2 }), + genretypeSpotId: tinyint("spot_genretype_id"), genretypeId: tinyint("genretype_id"), + availabletypeId: char("availabletype_id", { length: 1 }), + withoutLoadScreen: tinyint("without_load_screen").notNull(), + withoutInlay: tinyint("without_inlay").notNull(), + issueId: int("issue_id"), }); // Helper table created by ZXDB_help_search.sql