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:
@@ -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",
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
91
src/app/zxdb/genres/GenresSearch.tsx
Normal file
91
src/app/zxdb/genres/GenresSearch.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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} />;
|
||||||
}
|
}
|
||||||
|
|||||||
93
src/app/zxdb/machinetypes/MachineTypesSearch.tsx
Normal file
93
src/app/zxdb/machinetypes/MachineTypesSearch.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 -----
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user