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
This commit is contained in:
2025-12-12 16:58:50 +00:00
parent ddbf72ea52
commit 240936a850
18 changed files with 873 additions and 147 deletions

View File

@@ -1,6 +1,6 @@
{ {
"name": "next-explorer", "name": "next-explorer",
"version": "0.1.0", "version": "0.2.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "PORT=4000 next dev --turbopack", "dev": "PORT=4000 next dev --turbopack",

View File

@@ -8,7 +8,9 @@ type Item = {
title: string; title: string;
isXrated: number; isXrated: number;
machinetypeId: number | null; machinetypeId: number | null;
machinetypeName?: string | null;
languageId: string | null; languageId: string | null;
languageName?: string | null;
}; };
type Paged<T> = { type Paged<T> = {
@@ -182,8 +184,8 @@ export default function ZxdbExplorer({
<tr> <tr>
<th style={{width: 80}}>ID</th> <th style={{width: 80}}>ID</th>
<th>Title</th> <th>Title</th>
<th style={{width: 120}}>Machine</th> <th style={{width: 160}}>Machine</th>
<th style={{width: 80}}>Lang</th> <th style={{width: 120}}>Language</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -193,8 +195,28 @@ export default function ZxdbExplorer({
<td> <td>
<Link href={`/zxdb/entries/${it.id}`}>{it.title}</Link> <Link href={`/zxdb/entries/${it.id}`}>{it.title}</Link>
</td> </td>
<td>{it.machinetypeId ?? "-"}</td> <td>
<td>{it.languageId ?? "-"}</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> </tr>
))} ))}
</tbody> </tbody>

View File

@@ -12,13 +12,19 @@ export type EntryDetailData = {
genre: { id: number | null; name: string | null }; genre: { id: number | null; name: string | null };
authors: Label[]; authors: Label[];
publishers: 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 }) { export default function EntryDetailClient({ data }: { data: EntryDetailData }) {
if (!data) return <div className="alert alert-warning">Not found</div>; if (!data) return <div className="alert alert-warning">Not found</div>;
return ( return (
<div className="container"> <div>
<div className="d-flex align-items-center gap-2 flex-wrap"> <div className="d-flex align-items-center gap-2 flex-wrap">
<h1 className="mb-0">{data.title}</h1> <h1 className="mb-0">{data.title}</h1>
{data.genre.name && ( {data.genre.name && (
@@ -41,6 +47,101 @@ export default function EntryDetailClient({ data }: { data: EntryDetailData }) {
<hr /> <hr />
<div className="table-responsive">
<table className="table table-striped table-hover align-middle">
<thead>
<tr>
<th style={{ width: 220 }}>Field</th>
<th>Value</th>
</tr>
</thead>
<tbody>
<tr>
<td>ID</td>
<td>{data.id}</td>
</tr>
<tr>
<td>Title</td>
<td>{data.title}</td>
</tr>
<tr>
<td>Machine</td>
<td>
{data.machinetype.id != null ? (
data.machinetype.name ? (
<Link href={`/zxdb/machinetypes/${data.machinetype.id}`}>{data.machinetype.name}</Link>
) : (
<span>#{data.machinetype.id}</span>
)
) : (
<span className="text-secondary">-</span>
)}
</td>
</tr>
<tr>
<td>Language</td>
<td>
{data.language.id ? (
data.language.name ? (
<Link href={`/zxdb/languages/${data.language.id}`}>{data.language.name}</Link>
) : (
<span>{data.language.id}</span>
)
) : (
<span className="text-secondary">-</span>
)}
</td>
</tr>
<tr>
<td>Genre</td>
<td>
{data.genre.id ? (
data.genre.name ? (
<Link href={`/zxdb/genres/${data.genre.id}`}>{data.genre.name}</Link>
) : (
<span>#{data.genre.id}</span>
)
) : (
<span className="text-secondary">-</span>
)}
</td>
</tr>
{typeof data.maxPlayers !== "undefined" && (
<tr>
<td>Max Players</td>
<td>{data.maxPlayers}</td>
</tr>
)}
{typeof data.availabletypeId !== "undefined" && (
<tr>
<td>Available Type</td>
<td>{data.availabletypeId ?? <span className="text-secondary">-</span>}</td>
</tr>
)}
{typeof data.withoutLoadScreen !== "undefined" && (
<tr>
<td>Without Load Screen</td>
<td>{data.withoutLoadScreen ? "Yes" : "No"}</td>
</tr>
)}
{typeof data.withoutInlay !== "undefined" && (
<tr>
<td>Without Inlay</td>
<td>{data.withoutInlay ? "Yes" : "No"}</td>
</tr>
)}
{typeof data.issueId !== "undefined" && (
<tr>
<td>Issue</td>
<td>{data.issueId ? <span>#{data.issueId}</span> : <span className="text-secondary">-</span>}</td>
</tr>
)}
</tbody>
</table>
</div>
<hr />
<div className="row g-4"> <div className="row g-4">
<div className="col-lg-6"> <div className="col-lg-6">
<h5>Authors</h5> <h5>Authors</h5>

View File

@@ -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<T> = { items: T[]; page: number; pageSize: number; total: number };
export default function GenresSearch({ initial, initialQ }: { initial?: Paged<Genre>; initialQ?: string }) {
const router = useRouter();
const [q, setQ] = useState(initialQ ?? "");
const [data, setData] = useState<Paged<Genre> | 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 (
<div>
<h1>Genres</h1>
<form className="row gy-2 gx-2 align-items-center" onSubmit={submit}>
<div className="col-sm-8 col-md-6 col-lg-4">
<input className="form-control" placeholder="Search genres…" value={q} onChange={(e) => setQ(e.target.value)} />
</div>
<div className="col-auto">
<button className="btn btn-primary">Search</button>
</div>
</form>
<div className="mt-3">
{data && data.items.length === 0 && <div className="alert alert-warning">No genres found.</div>}
{data && data.items.length > 0 && (
<div className="table-responsive">
<table className="table table-striped table-hover align-middle">
<thead>
<tr>
<th style={{ width: 120 }}>ID</th>
<th>Name</th>
</tr>
</thead>
<tbody>
{data.items.map((g) => (
<tr key={g.id}>
<td><span className="badge text-bg-light">#{g.id}</span></td>
<td>
<Link href={`/zxdb/genres/${g.id}`}>{g.name}</Link>
</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={`/zxdb/genres?${(() => { 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
</Link>
<Link
className={`btn btn-outline-secondary ${!data || (data.page >= 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
</Link>
</div>
</div>
</div>
);
}

View File

@@ -1,17 +1,28 @@
"use client"; "use client";
import Link from "next/link"; 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<T> = { items: T[]; page: number; pageSize: number; total: number }; type Paged<T> = { items: T[]; page: number; pageSize: number; total: number };
export default function GenreDetailClient({ id, initial }: { id: number; initial: Paged<Item> }) { export default function GenreDetailClient({ id, initial, initialQ }: { id: number; initial: Paged<Item>; initialQ?: string }) {
const router = useRouter();
const [q, setQ] = useState(initialQ ?? "");
const totalPages = useMemo(() => Math.max(1, Math.ceil(initial.total / initial.pageSize)), [initial]); const totalPages = useMemo(() => Math.max(1, Math.ceil(initial.total / initial.pageSize)), [initial]);
return ( return (
<div className="container"> <div>
<h1>Genre #{id}</h1> <h1 className="mb-0">Genre <span className="badge text-bg-light">#{id}</span></h1>
<form className="row gy-2 gx-2 align-items-center mt-2" onSubmit={(e) => { e.preventDefault(); const p = new URLSearchParams(); if (q) p.set("q", q); p.set("page", "1"); router.push(`/zxdb/genres/${id}?${p.toString()}`); }}>
<div className="col-sm-8 col-md-6 col-lg-4">
<input className="form-control" placeholder="Search within this genre…" value={q} onChange={(e) => setQ(e.target.value)} />
</div>
<div className="col-auto">
<button className="btn btn-primary">Search</button>
</div>
</form>
{initial && initial.items.length === 0 && <div className="alert alert-warning">No entries.</div>} {initial && initial.items.length === 0 && <div className="alert alert-warning">No entries.</div>}
{initial && initial.items.length > 0 && ( {initial && initial.items.length > 0 && (
<div className="table-responsive"> <div className="table-responsive">
@@ -20,8 +31,8 @@ export default function GenreDetailClient({ id, initial }: { id: number; initial
<tr> <tr>
<th style={{ width: 80 }}>ID</th> <th style={{ width: 80 }}>ID</th>
<th>Title</th> <th>Title</th>
<th style={{ width: 120 }}>Machine</th> <th style={{ width: 160 }}>Machine</th>
<th style={{ width: 80 }}>Lang</th> <th style={{ width: 120 }}>Language</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -29,8 +40,28 @@ export default function GenreDetailClient({ id, initial }: { id: number; initial
<tr key={it.id}> <tr key={it.id}>
<td>{it.id}</td> <td>{it.id}</td>
<td><Link href={`/zxdb/entries/${it.id}`}>{it.title}</Link></td> <td><Link href={`/zxdb/entries/${it.id}`}>{it.title}</Link></td>
<td>{it.machinetypeId ?? "-"}</td> <td>
<td>{it.languageId ?? "-"}</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> </tr>
))} ))}
</tbody> </tbody>
@@ -44,14 +75,14 @@ export default function GenreDetailClient({ id, initial }: { id: number; initial
<Link <Link
className={`btn btn-sm btn-outline-secondary ${initial.page <= 1 ? "disabled" : ""}`} className={`btn btn-sm btn-outline-secondary ${initial.page <= 1 ? "disabled" : ""}`}
aria-disabled={initial.page <= 1} aria-disabled={initial.page <= 1}
href={`/zxdb/genres/${id}?page=${Math.max(1, initial.page - 1)}`} href={`/zxdb/genres/${id}?${(() => { const p = new URLSearchParams(); if (q) p.set("q", q); p.set("page", String(Math.max(1, initial.page - 1))); return p.toString(); })()}`}
> >
Prev Prev
</Link> </Link>
<Link <Link
className={`btn btn-sm btn-outline-secondary ${initial.page >= totalPages ? "disabled" : ""}`} className={`btn btn-sm btn-outline-secondary ${initial.page >= totalPages ? "disabled" : ""}`}
aria-disabled={initial.page >= totalPages} 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 Next
</Link> </Link>

View File

@@ -10,6 +10,7 @@ export default async function Page({ params, searchParams }: { params: Promise<{
const [{ id }, sp] = await Promise.all([params, searchParams]); const [{ id }, sp] = await Promise.all([params, searchParams]);
const numericId = Number(id); const numericId = Number(id);
const page = Math.max(1, Number(Array.isArray(sp.page) ? sp.page[0] : sp.page) || 1); const page = Math.max(1, Number(Array.isArray(sp.page) ? sp.page[0] : sp.page) || 1);
const initial = await entriesByGenre(numericId, page, 20); const q = (Array.isArray(sp.q) ? sp.q[0] : sp.q) ?? "";
return <GenreDetailClient id={numericId} initial={initial as any} />; const initial = await entriesByGenre(numericId, page, 20, q || undefined);
return <GenreDetailClient id={numericId} initial={initial as any} initialQ={q} />;
} }

View File

@@ -46,14 +46,30 @@ export default function LabelsSearch({ initial, initialQ }: { initial?: Paged<La
<div className="mt-3"> <div className="mt-3">
{data && data.items.length === 0 && <div className="alert alert-warning">No labels found.</div>} {data && data.items.length === 0 && <div className="alert alert-warning">No labels found.</div>}
{data && data.items.length > 0 && ( {data && data.items.length > 0 && (
<ul className="list-group"> <div className="table-responsive">
{data.items.map((l) => ( <table className="table table-striped table-hover align-middle">
<li key={l.id} className="list-group-item d-flex justify-content-between align-items-center"> <thead>
<Link href={`/zxdb/labels/${l.id}`}>{l.name}</Link> <tr>
<span className="badge text-bg-light">{l.labeltypeId ?? "?"}</span> <th style={{ width: 100 }}>ID</th>
</li> <th>Name</th>
))} <th style={{ width: 120 }}>Type</th>
</ul> </tr>
</thead>
<tbody>
{data.items.map((l) => (
<tr key={l.id}>
<td>#{l.id}</td>
<td>
<Link href={`/zxdb/labels/${l.id}`}>{l.name}</Link>
</td>
<td>
<span className="badge text-bg-light">{l.labeltypeId ?? "?"}</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
)} )}
</div> </div>

View File

@@ -2,16 +2,20 @@
import Link from "next/link"; import Link from "next/link";
import { useMemo, useState } from "react"; 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 };
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<T> = { items: T[]; page: number; pageSize: number; total: number }; type Paged<T> = { items: T[]; page: number; pageSize: number; total: number };
type Payload = { label: Label | null; authored: Paged<Item>; published: Paged<Item> }; type Payload = { label: Label | null; authored: Paged<Item>; published: Paged<Item> };
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. // 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 [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 <div className="alert alert-warning">Not found</div>; if (!initial || !initial.label) return <div className="alert alert-warning">Not found</div>;
@@ -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]); const totalPages = useMemo(() => Math.max(1, Math.ceil(current.total / current.pageSize)), [current]);
return ( return (
<div className="container"> <div>
<div className="d-flex align-items-center justify-content-between flex-wrap gap-2"> <div className="d-flex align-items-center justify-content-between flex-wrap gap-2">
<h1 className="mb-0">{initial.label.name}</h1> <h1 className="mb-0">{initial.label.name}</h1>
<div> <div>
@@ -36,6 +40,15 @@ export default function LabelDetailClient({ id, initial, initialTab }: { id: num
</li> </li>
</ul> </ul>
<form className="row gy-2 gx-2 align-items-center mt-2" onSubmit={(e) => { 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()}`); }}>
<div className="col-sm-8 col-md-6 col-lg-4">
<input className="form-control" placeholder={`Search within ${tab}`} value={q} onChange={(e) => setQ(e.target.value)} />
</div>
<div className="col-auto">
<button className="btn btn-primary">Search</button>
</div>
</form>
<div className="mt-3"> <div className="mt-3">
{current && current.items.length === 0 && <div className="alert alert-warning">No entries.</div>} {current && current.items.length === 0 && <div className="alert alert-warning">No entries.</div>}
{current && current.items.length > 0 && ( {current && current.items.length > 0 && (
@@ -45,8 +58,8 @@ export default function LabelDetailClient({ id, initial, initialTab }: { id: num
<tr> <tr>
<th style={{ width: 80 }}>ID</th> <th style={{ width: 80 }}>ID</th>
<th>Title</th> <th>Title</th>
<th style={{ width: 120 }}>Machine</th> <th style={{ width: 160 }}>Machine</th>
<th style={{ width: 80 }}>Lang</th> <th style={{ width: 120 }}>Language</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -54,8 +67,28 @@ export default function LabelDetailClient({ id, initial, initialTab }: { id: num
<tr key={it.id}> <tr key={it.id}>
<td>{it.id}</td> <td>{it.id}</td>
<td><Link href={`/zxdb/entries/${it.id}`}>{it.title}</Link></td> <td><Link href={`/zxdb/entries/${it.id}`}>{it.title}</Link></td>
<td>{it.machinetypeId ?? "-"}</td> <td>
<td>{it.languageId ?? "-"}</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> </tr>
))} ))}
</tbody> </tbody>
@@ -70,14 +103,14 @@ export default function LabelDetailClient({ id, initial, initialTab }: { id: num
<Link <Link
className={`btn btn-sm btn-outline-secondary ${current.page <= 1 ? "disabled" : ""}`} className={`btn btn-sm btn-outline-secondary ${current.page <= 1 ? "disabled" : ""}`}
aria-disabled={current.page <= 1} aria-disabled={current.page <= 1}
href={`/zxdb/labels/${id}?tab=${tab}&page=${Math.max(1, 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.max(1, current.page - 1))); return p.toString(); })()}`}
> >
Prev Prev
</Link> </Link>
<Link <Link
className={`btn btn-sm btn-outline-secondary ${current.page >= totalPages ? "disabled" : ""}`} className={`btn btn-sm btn-outline-secondary ${current.page >= totalPages ? "disabled" : ""}`}
aria-disabled={current.page >= totalPages} 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 Next
</Link> </Link>

View File

@@ -12,12 +12,13 @@ export default async function Page({ params, searchParams }: { params: Promise<{
const numericId = Number(id); const numericId = Number(id);
const page = Math.max(1, Number(Array.isArray(sp.page) ? sp.page[0] : sp.page) || 1); 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 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([ const [label, authored, published] = await Promise.all([
getLabelById(numericId), getLabelById(numericId),
getLabelAuthoredEntries(numericId, { page, pageSize: 20 }), getLabelAuthoredEntries(numericId, { page, pageSize: 20, q: q || undefined }),
getLabelPublishedEntries(numericId, { page, pageSize: 20 }), getLabelPublishedEntries(numericId, { page, pageSize: 20, q: q || undefined }),
]); ]);
// Let the client component handle the "not found" simple state // Let the client component handle the "not found" simple state
return <LabelDetailClient id={numericId} initial={{ label: label as any, authored: authored as any, published: published as any }} initialTab={tab} />; return <LabelDetailClient id={numericId} initial={{ label: label as any, authored: authored as any, published: published as any }} initialTab={tab} initialQ={q} />;
} }

View File

@@ -44,14 +44,26 @@ export default function LanguagesSearch({ initial, initialQ }: { initial?: Paged
<div className="mt-3"> <div className="mt-3">
{data && data.items.length === 0 && <div className="alert alert-warning">No languages found.</div>} {data && data.items.length === 0 && <div className="alert alert-warning">No languages found.</div>}
{data && data.items.length > 0 && ( {data && data.items.length > 0 && (
<ul className="list-group"> <div className="table-responsive">
{data.items.map((l) => ( <table className="table table-striped table-hover align-middle">
<li key={l.id} className="list-group-item d-flex justify-content-between align-items-center"> <thead>
<Link href={`/zxdb/languages/${l.id}`}>{l.name}</Link> <tr>
<span className="badge text-bg-light">{l.id}</span> <th style={{ width: 120 }}>Code</th>
</li> <th>Name</th>
))} </tr>
</ul> </thead>
<tbody>
{data.items.map((l) => (
<tr key={l.id}>
<td><span className="badge text-bg-light">{l.id}</span></td>
<td>
<Link href={`/zxdb/languages/${l.id}`}>{l.name}</Link>
</td>
</tr>
))}
</tbody>
</table>
</div>
)} )}
</div> </div>

View File

@@ -1,17 +1,28 @@
"use client"; "use client";
import Link from "next/link"; 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<T> = { items: T[]; page: number; pageSize: number; total: number }; type Paged<T> = { items: T[]; page: number; pageSize: number; total: number };
export default function LanguageDetailClient({ id, initial }: { id: string; initial: Paged<Item> }) { export default function LanguageDetailClient({ id, initial, initialQ }: { id: string; initial: Paged<Item>; initialQ?: string }) {
const router = useRouter();
const [q, setQ] = useState(initialQ ?? "");
const totalPages = useMemo(() => Math.max(1, Math.ceil(initial.total / initial.pageSize)), [initial]); const totalPages = useMemo(() => Math.max(1, Math.ceil(initial.total / initial.pageSize)), [initial]);
return ( return (
<div className="container"> <div>
<h1>Language {id}</h1> <h1 className="mb-0">Language <span className="badge text-bg-light">{id}</span></h1>
<form className="row gy-2 gx-2 align-items-center mt-2" onSubmit={(e) => { e.preventDefault(); const p = new URLSearchParams(); if (q) p.set("q", q); p.set("page", "1"); router.push(`/zxdb/languages/${id}?${p.toString()}`); }}>
<div className="col-sm-8 col-md-6 col-lg-4">
<input className="form-control" placeholder="Search within this language…" value={q} onChange={(e) => setQ(e.target.value)} />
</div>
<div className="col-auto">
<button className="btn btn-primary">Search</button>
</div>
</form>
{initial && initial.items.length === 0 && <div className="alert alert-warning">No entries.</div>} {initial && initial.items.length === 0 && <div className="alert alert-warning">No entries.</div>}
{initial && initial.items.length > 0 && ( {initial && initial.items.length > 0 && (
<div className="table-responsive"> <div className="table-responsive">
@@ -20,8 +31,8 @@ export default function LanguageDetailClient({ id, initial }: { id: string; init
<tr> <tr>
<th style={{ width: 80 }}>ID</th> <th style={{ width: 80 }}>ID</th>
<th>Title</th> <th>Title</th>
<th style={{ width: 120 }}>Machine</th> <th style={{ width: 160 }}>Machine</th>
<th style={{ width: 80 }}>Lang</th> <th style={{ width: 120 }}>Language</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -29,8 +40,28 @@ export default function LanguageDetailClient({ id, initial }: { id: string; init
<tr key={it.id}> <tr key={it.id}>
<td>{it.id}</td> <td>{it.id}</td>
<td><Link href={`/zxdb/entries/${it.id}`}>{it.title}</Link></td> <td><Link href={`/zxdb/entries/${it.id}`}>{it.title}</Link></td>
<td>{it.machinetypeId ?? "-"}</td> <td>
<td>{it.languageId ?? "-"}</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> </tr>
))} ))}
</tbody> </tbody>
@@ -44,14 +75,14 @@ export default function LanguageDetailClient({ id, initial }: { id: string; init
<Link <Link
className={`btn btn-sm btn-outline-secondary ${initial.page <= 1 ? "disabled" : ""}`} className={`btn btn-sm btn-outline-secondary ${initial.page <= 1 ? "disabled" : ""}`}
aria-disabled={initial.page <= 1} aria-disabled={initial.page <= 1}
href={`/zxdb/languages/${id}?page=${Math.max(1, initial.page - 1)}`} href={`/zxdb/languages/${id}?${(() => { const p = new URLSearchParams(); if (q) p.set("q", q); p.set("page", String(Math.max(1, initial.page - 1))); return p.toString(); })()}`}
> >
Prev Prev
</Link> </Link>
<Link <Link
className={`btn btn-sm btn-outline-secondary ${initial.page >= totalPages ? "disabled" : ""}`} className={`btn btn-sm btn-outline-secondary ${initial.page >= totalPages ? "disabled" : ""}`}
aria-disabled={initial.page >= totalPages} 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 Next
</Link> </Link>

View File

@@ -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 }> }) { 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 [{ id }, sp] = await Promise.all([params, searchParams]);
const page = Math.max(1, Number(Array.isArray(sp.page) ? sp.page[0] : sp.page) || 1); const page = Math.max(1, Number(Array.isArray(sp.page) ? sp.page[0] : sp.page) || 1);
const initial = await entriesByLanguage(id, page, 20); const q = (Array.isArray(sp.q) ? sp.q[0] : sp.q) ?? "";
return <LanguageDetailClient id={id} initial={initial as any} />; const initial = await entriesByLanguage(id, page, 20, q || undefined);
return <LanguageDetailClient id={id} initial={initial as any} initialQ={q} />;
} }

View File

@@ -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<T> = { items: T[]; page: number; pageSize: number; total: number };
export default function MachineTypesSearch({ initial, initialQ }: { initial?: Paged<MT>; initialQ?: string }) {
const router = useRouter();
const [q, setQ] = useState(initialQ ?? "");
const [data, setData] = useState<Paged<MT> | 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 (
<div>
<h1>Machine Types</h1>
<form className="row gy-2 gx-2 align-items-center" onSubmit={submit}>
<div className="col-sm-8 col-md-6 col-lg-4">
<input className="form-control" placeholder="Search machine types…" value={q} onChange={(e) => setQ(e.target.value)} />
</div>
<div className="col-auto">
<button className="btn btn-primary">Search</button>
</div>
</form>
<div className="mt-3">
{data && data.items.length === 0 && <div className="alert alert-warning">No machine types found.</div>}
{data && data.items.length > 0 && (
<div className="table-responsive">
<table className="table table-striped table-hover align-middle">
<thead>
<tr>
<th style={{ width: 120 }}>ID</th>
<th>Name</th>
</tr>
</thead>
<tbody>
{data.items.map((m) => (
<tr key={m.id}>
<td><span className="badge text-bg-light">#{m.id}</span></td>
<td>
<Link href={`/zxdb/machinetypes/${m.id}`}>{m.name}</Link>
</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={`/zxdb/machinetypes?${(() => { 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
</Link>
<Link
className={`btn btn-outline-secondary ${!data || (data.page >= 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
</Link>
</div>
</div>
</div>
);
}

View File

@@ -1,62 +1,105 @@
"use client"; "use client";
import Link from "next/link"; 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<T> = { items: T[]; page: number; pageSize: number; total: number }; type Paged<T> = { items: T[]; page: number; pageSize: number; total: number };
export default function MachineTypeDetailClient({ id, initial }: { id: number; initial: Paged<Item> }) { export default function MachineTypeDetailClient({ id, initial, initialQ }: { id: number; initial: Paged<Item>; initialQ?: string }) {
const router = useRouter();
const [q, setQ] = useState(initialQ ?? "");
const totalPages = useMemo(() => Math.max(1, Math.ceil(initial.total / initial.pageSize)), [initial]); 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 ( return (
<div className="container"> <div>
<h1>Machine Type #{id}</h1> <h1 className="mb-0">{machineName ?? "Machine Type"} <span className="badge text-bg-light">#{id}</span></h1>
{initial && initial.items.length === 0 && <div className="alert alert-warning">No entries.</div>} <form className="row gy-2 gx-2 align-items-center mt-2" onSubmit={(e) => { e.preventDefault(); const p = new URLSearchParams(); if (q) p.set("q", q); p.set("page", "1"); router.push(`/zxdb/machinetypes/${id}?${p.toString()}`); }}>
{initial && initial.items.length > 0 && ( <div className="col-sm-8 col-md-6 col-lg-4">
<div className="table-responsive"> <input className="form-control" placeholder="Search within this machine type…" value={q} onChange={(e) => setQ(e.target.value)} />
<table className="table table-striped table-hover align-middle"> </div>
<thead> <div className="col-auto">
<tr> <button className="btn btn-primary">Search</button>
<th style={{ width: 80 }}>ID</th> </div>
<th>Title</th> </form>
<th style={{ width: 120 }}>Machine</th> {initial && initial.items.length === 0 && <div className="alert alert-warning">No entries.</div>}
<th style={{ width: 80 }}>Lang</th> {initial && initial.items.length > 0 && (
</tr> <div className="table-responsive">
</thead> <table className="table table-striped table-hover align-middle">
<tbody> <thead>
{initial.items.map((it) => ( <tr>
<tr key={it.id}> <th style={{ width: 80 }}>ID</th>
<td>{it.id}</td> <th>Title</th>
<td><Link href={`/zxdb/entries/${it.id}`}>{it.title}</Link></td> <th style={{ width: 160 }}>Machine</th>
<td>{it.machinetypeId ?? "-"}</td> <th style={{ width: 120 }}>Language</th>
<td>{it.languageId ?? "-"}</td>
</tr> </tr>
))} </thead>
</tbody> <tbody>
</table> {initial.items.map((it) => (
</div> <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 className="d-flex align-items-center gap-2 mt-2"> <div className="d-flex align-items-center gap-2 mt-2">
<span>Page {initial.page} / {totalPages}</span> <span>Page {initial.page} / {totalPages}</span>
<div className="ms-auto d-flex gap-2"> <div className="ms-auto d-flex gap-2">
<Link <Link
className={`btn btn-sm btn-outline-secondary ${initial.page <= 1 ? "disabled" : ""}`} className={`btn btn-sm btn-outline-secondary ${initial.page <= 1 ? "disabled" : ""}`}
aria-disabled={initial.page <= 1} aria-disabled={initial.page <= 1}
href={`/zxdb/machinetypes/${id}?page=${Math.max(1, initial.page - 1)}`} href={`/zxdb/machinetypes/${id}?${(() => { const p = new URLSearchParams(); if (q) p.set("q", q); p.set("page", String(Math.max(1, initial.page - 1))); return p.toString(); })()}`}
> >
Prev Prev
</Link> </Link>
<Link <Link
className={`btn btn-sm btn-outline-secondary ${initial.page >= totalPages ? "disabled" : ""}`} className={`btn btn-sm btn-outline-secondary ${initial.page >= totalPages ? "disabled" : ""}`}
aria-disabled={initial.page >= totalPages} aria-disabled={initial.page >= totalPages}
href={`/zxdb/machinetypes/${id}?page=${Math.min(totalPages, initial.page + 1)}`} 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 Next
</Link> </Link>
</div>
</div> </div>
</div> </div>
</div>
); );
} }

View File

@@ -9,6 +9,7 @@ export default async function Page({ params, searchParams }: { params: Promise<{
const [{ id }, sp] = await Promise.all([params, searchParams]); const [{ id }, sp] = await Promise.all([params, searchParams]);
const numericId = Number(id); const numericId = Number(id);
const page = Math.max(1, Number(Array.isArray(sp.page) ? sp.page[0] : sp.page) || 1); const page = Math.max(1, Number(Array.isArray(sp.page) ? sp.page[0] : sp.page) || 1);
const initial = await entriesByMachinetype(numericId, page, 20); const q = (Array.isArray(sp.q) ? sp.q[0] : sp.q) ?? "";
return <MachineTypeDetailClient id={numericId} initial={initial as any} />; const initial = await entriesByMachinetype(numericId, page, 20, q || undefined);
return <MachineTypeDetailClient id={numericId} initial={initial as any} initialQ={q} />;
} }

View File

@@ -1,11 +1,14 @@
import MachineTypeList from "./MachineTypeList"; import MachineTypesSearch from "./MachineTypesSearch";
import { listMachinetypes } from "@/server/repo/zxdb"; import { searchMachinetypes } from "@/server/repo/zxdb";
export const metadata = { title: "ZXDB Machine Types" }; export const metadata = { title: "ZXDB Machine Types" };
export const revalidate = 3600; export const dynamic = "force-dynamic";
export default async function Page() { export default async function Page({ searchParams }: { searchParams: Promise<{ [key: string]: string | string[] | undefined }> }) {
const items = await listMachinetypes(); const sp = await searchParams;
return <MachineTypeList items={items as any} />; 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 <MachineTypesSearch initial={initial as any} initialQ={q} />;
} }

View File

@@ -28,7 +28,9 @@ export interface SearchResultItem {
title: string; title: string;
isXrated: number; isXrated: number;
machinetypeId: number | null; machinetypeId: number | null;
machinetypeName?: string | null;
languageId: string | null; languageId: string | null;
languageName?: string | null;
} }
export interface PagedResult<T> { export interface PagedResult<T> {
@@ -70,8 +72,18 @@ export async function searchEntries(params: SearchParams): Promise<PagedResult<S
const [items, countRows] = await Promise.all([ const [items, countRows] = await Promise.all([
db db
.select() .select({
id: entries.id,
title: entries.title,
isXrated: entries.isXrated,
machinetypeId: entries.machinetypeId,
machinetypeName: machinetypes.name,
languageId: entries.languageId,
languageName: languages.name,
})
.from(entries) .from(entries)
.leftJoin(machinetypes, eq(machinetypes.id, entries.machinetypeId as any))
.leftJoin(languages, eq(languages.id, entries.languageId as any))
.where(whereExpr as any) .where(whereExpr as any)
.orderBy(sort === "id_desc" ? desc(entries.id) : entries.title) .orderBy(sort === "id_desc" ? desc(entries.id) : entries.title)
.limit(pageSize) .limit(pageSize)
@@ -102,10 +114,14 @@ export async function searchEntries(params: SearchParams): Promise<PagedResult<S
title: entries.title, title: entries.title,
isXrated: entries.isXrated, isXrated: entries.isXrated,
machinetypeId: entries.machinetypeId, machinetypeId: entries.machinetypeId,
machinetypeName: machinetypes.name,
languageId: entries.languageId, languageId: entries.languageId,
languageName: languages.name,
}) })
.from(searchByTitles) .from(searchByTitles)
.innerJoin(entries, eq(entries.id, searchByTitles.entryId)) .innerJoin(entries, eq(entries.id, searchByTitles.entryId))
.leftJoin(machinetypes, eq(machinetypes.id, entries.machinetypeId as any))
.leftJoin(languages, eq(languages.id, entries.languageId as any))
.where(like(searchByTitles.entryTitle, pattern)) .where(like(searchByTitles.entryTitle, pattern))
.groupBy(entries.id) .groupBy(entries.id)
.orderBy(sort === "id_desc" ? desc(entries.id) : entries.title) .orderBy(sort === "id_desc" ? desc(entries.id) : entries.title)
@@ -130,6 +146,12 @@ export interface EntryDetail {
genre: { id: number | null; name: string | null }; genre: { id: number | null; name: string | null };
authors: LabelSummary[]; authors: LabelSummary[];
publishers: LabelSummary[]; publishers: LabelSummary[];
// Additional entry fields for richer details
maxPlayers?: number;
availabletypeId?: string | null;
withoutLoadScreen?: number;
withoutInlay?: number;
issueId?: number | null;
} }
export async function getEntryById(id: number): Promise<EntryDetail | null> { export async function getEntryById(id: number): Promise<EntryDetail | null> {
@@ -146,6 +168,11 @@ export async function getEntryById(id: number): Promise<EntryDetail | null> {
languageName: languages.name, languageName: languages.name,
genreId: entries.genretypeId, genreId: entries.genretypeId,
genreName: genretypes.name, genreName: genretypes.name,
maxPlayers: entries.maxPlayers,
availabletypeId: entries.availabletypeId,
withoutLoadScreen: entries.withoutLoadScreen,
withoutInlay: entries.withoutInlay,
issueId: entries.issueId,
}) })
.from(entries) .from(entries)
.leftJoin(machinetypes, eq(machinetypes.id, entries.machinetypeId as any)) .leftJoin(machinetypes, eq(machinetypes.id, entries.machinetypeId as any))
@@ -178,6 +205,11 @@ export async function getEntryById(id: number): Promise<EntryDetail | null> {
genre: { id: (base.genreId as any) ?? null, name: (base.genreName as any) ?? null }, genre: { id: (base.genreId as any) ?? null, name: (base.genreName as any) ?? null },
authors: authorRows as any, authors: authorRows as any,
publishers: publisherRows 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<LabelDetail | null> {
export interface LabelContribsParams { export interface LabelContribsParams {
page?: number; page?: number;
pageSize?: number; pageSize?: number;
q?: string; // optional title filter
} }
export async function getLabelAuthoredEntries(labelId: number, params: LabelContribsParams): Promise<PagedResult<SearchResultItem>> { export async function getLabelAuthoredEntries(labelId: number, params: LabelContribsParams): Promise<PagedResult<SearchResultItem>> {
const pageSize = Math.max(1, Math.min(params.pageSize ?? 20, 100)); const pageSize = Math.max(1, Math.min(params.pageSize ?? 20, 100));
const page = Math.max(1, params.page ?? 1); const page = Math.max(1, params.page ?? 1);
const offset = (page - 1) * pageSize; const offset = (page - 1) * pageSize;
const hasQ = !!(params.q && params.q.trim());
if (!hasQ) {
const countRows = await db
.select({ total: sql<number>`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 const countRows = await db
.select({ total: sql<number>`count(distinct ${authors.entryId})` }) .select({ total: sql<number>`count(distinct ${entries.id})` })
.from(authors) .from(authors)
.where(eq(authors.labelId, labelId)); .innerJoin(entries, eq(entries.id, authors.entryId))
const total = Number(countRows[0]?.total ?? 0); .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 const items = await db
.select({ .select({
id: entries.id, id: entries.id,
title: entries.title, title: entries.title,
isXrated: entries.isXrated, isXrated: entries.isXrated,
machinetypeId: entries.machinetypeId, machinetypeId: entries.machinetypeId,
machinetypeName: machinetypes.name,
languageId: entries.languageId, languageId: entries.languageId,
languageName: languages.name,
}) })
.from(authors) .from(authors)
.innerJoin(entries, eq(entries.id, authors.entryId)) .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) .groupBy(entries.id)
.orderBy(entries.title) .orderBy(entries.title)
.limit(pageSize) .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 pageSize = Math.max(1, Math.min(params.pageSize ?? 20, 100));
const page = Math.max(1, params.page ?? 1); const page = Math.max(1, params.page ?? 1);
const offset = (page - 1) * pageSize; const offset = (page - 1) * pageSize;
const hasQ = !!(params.q && params.q.trim());
if (!hasQ) {
const countRows = await db
.select({ total: sql<number>`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 const countRows = await db
.select({ total: sql<number>`count(distinct ${publishers.entryId})` }) .select({ total: sql<number>`count(distinct ${entries.id})` })
.from(publishers) .from(publishers)
.where(eq(publishers.labelId, labelId)); .innerJoin(entries, eq(entries.id, publishers.entryId))
const total = Number(countRows[0]?.total ?? 0); .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 const items = await db
.select({ .select({
id: entries.id, id: entries.id,
title: entries.title, title: entries.title,
isXrated: entries.isXrated, isXrated: entries.isXrated,
machinetypeId: entries.machinetypeId, machinetypeId: entries.machinetypeId,
machinetypeName: machinetypes.name,
languageId: entries.languageId, languageId: entries.languageId,
languageName: languages.name,
}) })
.from(publishers) .from(publishers)
.innerJoin(entries, eq(entries.id, publishers.entryId)) .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) .groupBy(entries.id)
.orderBy(entries.title) .orderBy(entries.title)
.limit(pageSize) .limit(pageSize)
@@ -408,52 +513,187 @@ export async function searchMachinetypes(params: SimpleSearchParams) {
return { items: items as any, page, pageSize, total }; return { items: items as any, page, pageSize, total };
} }
export async function entriesByGenre(genreId: number, page: number, pageSize: number): Promise<PagedResult<SearchResultItem>> { export async function entriesByGenre(
genreId: number,
page: number,
pageSize: number,
q?: string
): Promise<PagedResult<SearchResultItem>> {
const offset = (page - 1) * pageSize; const offset = (page - 1) * pageSize;
const countRows = (await db const hasQ = !!(q && q.trim());
.select({ total: sql<number>`count(*)` })
if (!hasQ) {
const countRows = (await db
.select({ total: sql<number>`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<number>`count(distinct ${entries.id})` })
.from(entries) .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 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) .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) .orderBy(entries.title)
.limit(pageSize) .limit(pageSize)
.offset(offset); .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<PagedResult<SearchResultItem>> { export async function entriesByLanguage(
langId: string,
page: number,
pageSize: number,
q?: string
): Promise<PagedResult<SearchResultItem>> {
const offset = (page - 1) * pageSize; const offset = (page - 1) * pageSize;
const countRows = (await db const hasQ = !!(q && q.trim());
.select({ total: sql<number>`count(*)` })
if (!hasQ) {
const countRows = (await db
.select({ total: sql<number>`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<number>`count(distinct ${entries.id})` })
.from(entries) .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 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) .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) .orderBy(entries.title)
.limit(pageSize) .limit(pageSize)
.offset(offset); .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<PagedResult<SearchResultItem>> { export async function entriesByMachinetype(
mtId: number,
page: number,
pageSize: number,
q?: string
): Promise<PagedResult<SearchResultItem>> {
const offset = (page - 1) * pageSize; const offset = (page - 1) * pageSize;
const countRows = (await db const hasQ = !!(q && q.trim());
.select({ total: sql<number>`count(*)` })
if (!hasQ) {
const countRows = (await db
.select({ total: sql<number>`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<number>`count(distinct ${entries.id})` })
.from(entries) .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 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) .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) .orderBy(entries.title)
.limit(pageSize) .limit(pageSize)
.offset(offset); .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 ----- // ----- Facets for search -----

View File

@@ -6,8 +6,14 @@ export const entries = mysqlTable("entries", {
title: varchar("title", { length: 250 }).notNull(), title: varchar("title", { length: 250 }).notNull(),
isXrated: tinyint("is_xrated").notNull(), isXrated: tinyint("is_xrated").notNull(),
machinetypeId: tinyint("machinetype_id"), machinetypeId: tinyint("machinetype_id"),
maxPlayers: tinyint("max_players").notNull().default(1),
languageId: char("language_id", { length: 2 }), languageId: char("language_id", { length: 2 }),
genretypeSpotId: tinyint("spot_genretype_id"),
genretypeId: tinyint("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 // Helper table created by ZXDB_help_search.sql