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",
"version": "0.1.0",
"version": "0.2.0",
"private": true,
"scripts": {
"dev": "PORT=4000 next dev --turbopack",

View File

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

View File

@@ -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 <div className="alert alert-warning">Not found</div>;
return (
<div className="container">
<div>
<div className="d-flex align-items-center gap-2 flex-wrap">
<h1 className="mb-0">{data.title}</h1>
{data.genre.name && (
@@ -41,6 +47,101 @@ export default function EntryDetailClient({ data }: { data: EntryDetailData }) {
<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="col-lg-6">
<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";
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 };
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]);
return (
<div className="container">
<h1>Genre #{id}</h1>
<div>
<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="table-responsive">
@@ -20,8 +31,8 @@ export default function GenreDetailClient({ id, initial }: { id: number; initial
<tr>
<th style={{ width: 80 }}>ID</th>
<th>Title</th>
<th style={{ width: 120 }}>Machine</th>
<th style={{ width: 80 }}>Lang</th>
<th style={{ width: 160 }}>Machine</th>
<th style={{ width: 120 }}>Language</th>
</tr>
</thead>
<tbody>
@@ -29,8 +40,28 @@ export default function GenreDetailClient({ id, initial }: { id: number; initial
<tr key={it.id}>
<td>{it.id}</td>
<td><Link href={`/zxdb/entries/${it.id}`}>{it.title}</Link></td>
<td>{it.machinetypeId ?? "-"}</td>
<td>{it.languageId ?? "-"}</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>
@@ -44,14 +75,14 @@ export default function GenreDetailClient({ id, initial }: { id: number; initial
<Link
className={`btn btn-sm btn-outline-secondary ${initial.page <= 1 ? "disabled" : ""}`}
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
</Link>
<Link
className={`btn btn-sm btn-outline-secondary ${initial.page >= 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
</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 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 <GenreDetailClient id={numericId} initial={initial as any} />;
const q = (Array.isArray(sp.q) ? sp.q[0] : sp.q) ?? "";
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">
{data && data.items.length === 0 && <div className="alert alert-warning">No labels found.</div>}
{data && data.items.length > 0 && (
<ul className="list-group">
<div className="table-responsive">
<table className="table table-striped table-hover align-middle">
<thead>
<tr>
<th style={{ width: 100 }}>ID</th>
<th>Name</th>
<th style={{ width: 120 }}>Type</th>
</tr>
</thead>
<tbody>
{data.items.map((l) => (
<li key={l.id} className="list-group-item d-flex justify-content-between align-items-center">
<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>
</li>
</td>
</tr>
))}
</ul>
</tbody>
</table>
</div>
)}
</div>

View File

@@ -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<T> = { items: T[]; page: number; pageSize: number; total: number };
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.
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>;
@@ -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 (
<div className="container">
<div>
<div className="d-flex align-items-center justify-content-between flex-wrap gap-2">
<h1 className="mb-0">{initial.label.name}</h1>
<div>
@@ -36,6 +40,15 @@ export default function LabelDetailClient({ id, initial, initialTab }: { id: num
</li>
</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">
{current && current.items.length === 0 && <div className="alert alert-warning">No entries.</div>}
{current && current.items.length > 0 && (
@@ -45,8 +58,8 @@ export default function LabelDetailClient({ id, initial, initialTab }: { id: num
<tr>
<th style={{ width: 80 }}>ID</th>
<th>Title</th>
<th style={{ width: 120 }}>Machine</th>
<th style={{ width: 80 }}>Lang</th>
<th style={{ width: 160 }}>Machine</th>
<th style={{ width: 120 }}>Language</th>
</tr>
</thead>
<tbody>
@@ -54,8 +67,28 @@ export default function LabelDetailClient({ id, initial, initialTab }: { id: num
<tr key={it.id}>
<td>{it.id}</td>
<td><Link href={`/zxdb/entries/${it.id}`}>{it.title}</Link></td>
<td>{it.machinetypeId ?? "-"}</td>
<td>{it.languageId ?? "-"}</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>
@@ -70,14 +103,14 @@ export default function LabelDetailClient({ id, initial, initialTab }: { id: num
<Link
className={`btn btn-sm btn-outline-secondary ${current.page <= 1 ? "disabled" : ""}`}
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
</Link>
<Link
className={`btn btn-sm btn-outline-secondary ${current.page >= 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
</Link>

View File

@@ -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 <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">
{data && data.items.length === 0 && <div className="alert alert-warning">No languages found.</div>}
{data && data.items.length > 0 && (
<ul className="list-group">
<div className="table-responsive">
<table className="table table-striped table-hover align-middle">
<thead>
<tr>
<th style={{ width: 120 }}>Code</th>
<th>Name</th>
</tr>
</thead>
<tbody>
{data.items.map((l) => (
<li key={l.id} className="list-group-item d-flex justify-content-between align-items-center">
<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>
<span className="badge text-bg-light">{l.id}</span>
</li>
</td>
</tr>
))}
</ul>
</tbody>
</table>
</div>
)}
</div>

View File

@@ -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<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]);
return (
<div className="container">
<h1>Language {id}</h1>
<div>
<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="table-responsive">
@@ -20,8 +31,8 @@ export default function LanguageDetailClient({ id, initial }: { id: string; init
<tr>
<th style={{ width: 80 }}>ID</th>
<th>Title</th>
<th style={{ width: 120 }}>Machine</th>
<th style={{ width: 80 }}>Lang</th>
<th style={{ width: 160 }}>Machine</th>
<th style={{ width: 120 }}>Language</th>
</tr>
</thead>
<tbody>
@@ -29,8 +40,28 @@ export default function LanguageDetailClient({ id, initial }: { id: string; init
<tr key={it.id}>
<td>{it.id}</td>
<td><Link href={`/zxdb/entries/${it.id}`}>{it.title}</Link></td>
<td>{it.machinetypeId ?? "-"}</td>
<td>{it.languageId ?? "-"}</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>
@@ -44,14 +75,14 @@ export default function LanguageDetailClient({ id, initial }: { id: string; init
<Link
className={`btn btn-sm btn-outline-secondary ${initial.page <= 1 ? "disabled" : ""}`}
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
</Link>
<Link
className={`btn btn-sm btn-outline-secondary ${initial.page >= 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
</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 }> }) {
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 <LanguageDetailClient id={id} initial={initial as any} />;
const q = (Array.isArray(sp.q) ? sp.q[0] : sp.q) ?? "";
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,17 +1,40 @@
"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<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 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 (
<div className="container">
<h1>Machine Type #{id}</h1>
<div>
<h1 className="mb-0">{machineName ?? "Machine Type"} <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/machinetypes/${id}?${p.toString()}`); }}>
<div className="col-sm-8 col-md-6 col-lg-4">
<input className="form-control" placeholder="Search within this machine type…" 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="table-responsive">
@@ -20,8 +43,8 @@ export default function MachineTypeDetailClient({ id, initial }: { id: number; i
<tr>
<th style={{ width: 80 }}>ID</th>
<th>Title</th>
<th style={{ width: 120 }}>Machine</th>
<th style={{ width: 80 }}>Lang</th>
<th style={{ width: 160 }}>Machine</th>
<th style={{ width: 120 }}>Language</th>
</tr>
</thead>
<tbody>
@@ -29,8 +52,28 @@ export default function MachineTypeDetailClient({ id, initial }: { id: number; i
<tr key={it.id}>
<td>{it.id}</td>
<td><Link href={`/zxdb/entries/${it.id}`}>{it.title}</Link></td>
<td>{it.machinetypeId ?? "-"}</td>
<td>{it.languageId ?? "-"}</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>
@@ -44,14 +87,14 @@ export default function MachineTypeDetailClient({ id, initial }: { id: number; i
<Link
className={`btn btn-sm btn-outline-secondary ${initial.page <= 1 ? "disabled" : ""}`}
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
</Link>
<Link
className={`btn btn-sm btn-outline-secondary ${initial.page >= totalPages ? "disabled" : ""}`}
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
</Link>

View File

@@ -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 <MachineTypeDetailClient id={numericId} initial={initial as any} />;
const q = (Array.isArray(sp.q) ? sp.q[0] : sp.q) ?? "";
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 { 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 <MachineTypeList items={items as any} />;
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 <MachineTypesSearch initial={initial as any} initialQ={q} />;
}

View File

@@ -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<T> {
@@ -70,8 +72,18 @@ export async function searchEntries(params: SearchParams): Promise<PagedResult<S
const [items, countRows] = await Promise.all([
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)
.leftJoin(machinetypes, eq(machinetypes.id, entries.machinetypeId as any))
.leftJoin(languages, eq(languages.id, entries.languageId as any))
.where(whereExpr as any)
.orderBy(sort === "id_desc" ? desc(entries.id) : entries.title)
.limit(pageSize)
@@ -102,10 +114,14 @@ export async function searchEntries(params: SearchParams): Promise<PagedResult<S
title: entries.title,
isXrated: entries.isXrated,
machinetypeId: entries.machinetypeId,
machinetypeName: machinetypes.name,
languageId: entries.languageId,
languageName: languages.name,
})
.from(searchByTitles)
.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))
.groupBy(entries.id)
.orderBy(sort === "id_desc" ? desc(entries.id) : entries.title)
@@ -130,6 +146,12 @@ export interface EntryDetail {
genre: { id: number | null; name: string | null };
authors: 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> {
@@ -146,6 +168,11 @@ export async function getEntryById(id: number): Promise<EntryDetail | null> {
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<EntryDetail | null> {
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,13 +269,16 @@ export async function getLabelById(id: number): Promise<LabelDetail | null> {
export interface LabelContribsParams {
page?: number;
pageSize?: number;
q?: string; // optional title filter
}
export async function getLabelAuthoredEntries(labelId: number, params: LabelContribsParams): Promise<PagedResult<SearchResultItem>> {
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<number>`count(distinct ${authors.entryId})` })
.from(authors)
@@ -256,10 +291,14 @@ export async function getLabelAuthoredEntries(labelId: number, params: LabelCont
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)
@@ -269,11 +308,43 @@ export async function getLabelAuthoredEntries(labelId: number, params: LabelCont
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<number>`count(distinct ${entries.id})` })
.from(authors)
.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))
.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)
.offset(offset);
return { items: items as any, page, pageSize, total };
}
export async function getLabelPublishedEntries(labelId: number, params: LabelContribsParams): Promise<PagedResult<SearchResultItem>> {
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<number>`count(distinct ${publishers.entryId})` })
.from(publishers)
@@ -286,10 +357,14 @@ export async function getLabelPublishedEntries(labelId: number, params: LabelCon
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)
@@ -299,6 +374,36 @@ export async function getLabelPublishedEntries(labelId: number, params: LabelCon
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<number>`count(distinct ${entries.id})` })
.from(publishers)
.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))
.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)
.offset(offset);
return { items: items as any, page, pageSize, total };
}
// ----- Lookups lists and category browsing -----
export async function listGenres() {
@@ -408,15 +513,33 @@ 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<PagedResult<SearchResultItem>> {
export async function entriesByGenre(
genreId: number,
page: number,
pageSize: number,
q?: string
): Promise<PagedResult<SearchResultItem>> {
const offset = (page - 1) * pageSize;
const hasQ = !!(q && q.trim());
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, 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)
.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)
@@ -424,15 +547,60 @@ export async function entriesByGenre(genreId: number, page: number, pageSize: nu
return { items: items as any, page, pageSize, total: Number(countRows?.[0]?.total ?? 0) };
}
export async function entriesByLanguage(langId: string, page: number, pageSize: number): Promise<PagedResult<SearchResultItem>> {
const pattern = `%${q!.toLowerCase().replace(/[^a-z0-9]+/g, "")}%`;
const countRows = await db
.select({ total: sql<number>`count(distinct ${entries.id})` })
.from(entries)
.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,
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(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 };
}
export async function entriesByLanguage(
langId: string,
page: number,
pageSize: number,
q?: string
): Promise<PagedResult<SearchResultItem>> {
const offset = (page - 1) * pageSize;
const hasQ = !!(q && q.trim());
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, 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)
.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)
@@ -440,15 +608,60 @@ export async function entriesByLanguage(langId: string, page: number, pageSize:
return { items: items as any, page, pageSize, total: Number(countRows?.[0]?.total ?? 0) };
}
export async function entriesByMachinetype(mtId: number, page: number, pageSize: number): Promise<PagedResult<SearchResultItem>> {
const pattern = `%${q!.toLowerCase().replace(/[^a-z0-9]+/g, "")}%`;
const countRows = await db
.select({ total: sql<number>`count(distinct ${entries.id})` })
.from(entries)
.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,
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(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 };
}
export async function entriesByMachinetype(
mtId: number,
page: number,
pageSize: number,
q?: string
): Promise<PagedResult<SearchResultItem>> {
const offset = (page - 1) * pageSize;
const hasQ = !!(q && q.trim());
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, 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)
.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)
@@ -456,6 +669,33 @@ export async function entriesByMachinetype(mtId: number, page: number, pageSize:
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)
.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,
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(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 };
}
// ----- Facets for search -----
export async function getEntryFacets(params: SearchParams): Promise<EntryFacets> {

View File

@@ -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