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",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "PORT=4000 next dev --turbopack",
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
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";
|
||||
|
||||
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>
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
|
||||
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,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>
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user