chore: commit pending ZXDB explorer changes prior to index perf work

Context
- Housekeeping commit to capture all current ZXDB Explorer work before index-page performance optimizations.

Includes
- Server-rendered entry detail page with ISR and parallelized DB queries.
- Node runtime for ZXDB API routes and params validation updates for Next 15.
- ZXDB repository extensions (facets, label queries, category queries).
- Cross-linking and Link-based prefetch across ZXDB UI.
- Cache headers on low-churn list APIs.

Notes
- Follow-up commit will focus specifically on speeding up index pages via SSR initial data and ISR.

Signed-off-by: Junie@lucy.xalior.com
This commit is contained in:
2025-12-12 15:25:35 +00:00
parent 3fe6f980c6
commit ad77b47117
27 changed files with 258 additions and 249 deletions

View File

@@ -115,7 +115,8 @@ Comment what the code does, not what the agent has done. The documentation's pur
- Do not create new branches - Do not create new branches
- git commits: - git commits:
- Create COMMIT_EDITMSG file, await any user edits, then commit using that - Create COMMIT_EDITMSG file, await any user edits, then commit using that
commit note, and then delete the COMMIT_EDITMSG file. commit note, and then delete the COMMIT_EDITMSG file. Remember to keep
the first line as the subject <50char
- git commit messages: - git commit messages:
- Use imperative mood (e.g., "Add feature X", "Fix bug Y"). - Use imperative mood (e.g., "Add feature X", "Fix bug Y").
- Include relevant issue numbers if applicable. - Include relevant issue numbers if applicable.

View File

@@ -1,32 +1,16 @@
fix: await dynamic route params (Next 15) and correct ZXDB lookup column names chore: commit pending ZXDB explorer changes prior to index perf work
Update dynamic Server Component pages to the Next.js 15+ async `params` API, Context
and fix ZXDB lookup table schema to use `text` column (not `name`) to avoid - Housekeeping commit to capture all current ZXDB Explorer work before index-page performance optimizations.
ER_BAD_FIELD_ERROR in entry detail endpoint.
This resolves the runtime warning/error:
"params should be awaited before using its properties" and prevents
sync-dynamic-apis violations when visiting deep ZXDB permalinks.
Changes Includes
- /zxdb/entries/[id]/page.tsx: make Page async and `await params`, pass numeric id - Server-rendered entry detail page with ISR and parallelized DB queries.
- /zxdb/labels/[id]/page.tsx: make Page async and `await params`, pass numeric id - Node runtime for ZXDB API routes and params validation updates for Next 15.
- /zxdb/genres/[id]/page.tsx: make Page async and `await params`, pass numeric id - ZXDB repository extensions (facets, label queries, category queries).
- /zxdb/languages/[id]/page.tsx: make Page async and `await params`, pass string id - Cross-linking and Link-based prefetch across ZXDB UI.
- /registers/[hex]/page.tsx: make Page async and `await params`, decode hex safely - Cache headers on low-churn list APIs.
- /api/zxdb/entries/[id]/route.ts: await `ctx.params` before validation
- src/server/schema/zxdb.ts: map `languages.text`, `machinetypes.text`,
and `genretypes.text` to `name` fields in Drizzle models
Why
- Next.js 15 changed dynamic route APIs such that `params` is now a Promise
in Server Components and must be awaited before property access.
- ZXDB schema defines display columns as `text` (not `name`) for languages,
machinetypes, and genretypes. Using `name` caused MySQL 1054 errors. The
Drizzle models now point to the correct columns while preserving `{ id, name }`
in our API/UI contracts.
Notes Notes
- API route handlers under /api continue to use ctx.params synchronously; this - Follow-up commit will focus specifically on speeding up index pages via SSR initial data and ISR.
change only affects App Router Page components.
Signed-off-by: Junie@lucy.xalior.com Signed-off-by: Junie@lucy.xalior.com

View File

@@ -22,7 +22,11 @@ export async function GET(_req: NextRequest, ctx: { params: Promise<{ id: string
}); });
} }
return new Response(JSON.stringify(detail), { return new Response(JSON.stringify(detail), {
headers: { "content-type": "application/json", "cache-control": "no-store" }, headers: {
"content-type": "application/json",
// Cache for 1h on CDN, allow stale while revalidating for a day
"cache-control": "public, max-age=0, s-maxage=3600, stale-while-revalidate=86400",
},
}); });
} }

View File

@@ -8,8 +8,9 @@ const querySchema = z.object({
pageSize: z.coerce.number().int().positive().max(100).optional(), pageSize: z.coerce.number().int().positive().max(100).optional(),
}); });
export async function GET(req: NextRequest, ctx: { params: { id: string } }) { export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string }> }) {
const p = paramsSchema.safeParse(ctx.params); const raw = await ctx.params;
const p = paramsSchema.safeParse(raw);
if (!p.success) { if (!p.success) {
return new Response(JSON.stringify({ error: p.error.flatten() }), { status: 400 }); return new Response(JSON.stringify({ error: p.error.flatten() }), { status: 400 });
} }

View File

@@ -3,7 +3,10 @@ import { listGenres } from "@/server/repo/zxdb";
export async function GET() { export async function GET() {
const data = await listGenres(); const data = await listGenres();
return new Response(JSON.stringify({ items: data }), { return new Response(JSON.stringify({ items: data }), {
headers: { "content-type": "application/json", "cache-control": "no-store" }, headers: {
"content-type": "application/json",
"cache-control": "public, max-age=0, s-maxage=3600, stale-while-revalidate=86400",
},
}); });
} }

View File

@@ -8,8 +8,9 @@ const querySchema = z.object({
pageSize: z.coerce.number().int().positive().max(100).optional(), pageSize: z.coerce.number().int().positive().max(100).optional(),
}); });
export async function GET(req: NextRequest, ctx: { params: { id: string } }) { export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string }> }) {
const p = paramsSchema.safeParse(ctx.params); const raw = await ctx.params;
const p = paramsSchema.safeParse(raw);
if (!p.success) { if (!p.success) {
return new Response(JSON.stringify({ error: p.error.flatten() }), { return new Response(JSON.stringify({ error: p.error.flatten() }), {
status: 400, status: 400,

View File

@@ -8,8 +8,9 @@ const querySchema = z.object({
pageSize: z.coerce.number().int().positive().max(100).optional(), pageSize: z.coerce.number().int().positive().max(100).optional(),
}); });
export async function GET(req: NextRequest, ctx: { params: { id: string } }) { export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string }> }) {
const p = paramsSchema.safeParse(ctx.params); const raw = await ctx.params;
const p = paramsSchema.safeParse(raw);
if (!p.success) { if (!p.success) {
return new Response(JSON.stringify({ error: p.error.flatten() }), { status: 400 }); return new Response(JSON.stringify({ error: p.error.flatten() }), { status: 400 });
} }

View File

@@ -3,7 +3,10 @@ import { listLanguages } from "@/server/repo/zxdb";
export async function GET() { export async function GET() {
const data = await listLanguages(); const data = await listLanguages();
return new Response(JSON.stringify({ items: data }), { return new Response(JSON.stringify({ items: data }), {
headers: { "content-type": "application/json", "cache-control": "no-store" }, headers: {
"content-type": "application/json",
"cache-control": "public, max-age=0, s-maxage=3600, stale-while-revalidate=86400",
},
}); });
} }

View File

@@ -8,8 +8,9 @@ const querySchema = z.object({
pageSize: z.coerce.number().int().positive().max(100).optional(), pageSize: z.coerce.number().int().positive().max(100).optional(),
}); });
export async function GET(req: NextRequest, ctx: { params: { id: string } }) { export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string }> }) {
const p = paramsSchema.safeParse(ctx.params); const raw = await ctx.params;
const p = paramsSchema.safeParse(raw);
if (!p.success) { if (!p.success) {
return new Response(JSON.stringify({ error: p.error.flatten() }), { status: 400 }); return new Response(JSON.stringify({ error: p.error.flatten() }), { status: 400 });
} }

View File

@@ -3,7 +3,10 @@ import { listMachinetypes } from "@/server/repo/zxdb";
export async function GET() { export async function GET() {
const data = await listMachinetypes(); const data = await listMachinetypes();
return new Response(JSON.stringify({ items: data }), { return new Response(JSON.stringify({ items: data }), {
headers: { "content-type": "application/json", "cache-control": "no-store" }, headers: {
"content-type": "application/json",
"cache-control": "public, max-age=0, s-maxage=3600, stale-while-revalidate=86400",
},
}); });
} }

View File

@@ -1,6 +1,6 @@
import { NextRequest } from "next/server"; import { NextRequest } from "next/server";
import { z } from "zod"; import { z } from "zod";
import { searchEntries } from "@/server/repo/zxdb"; import { searchEntries, getEntryFacets } from "@/server/repo/zxdb";
const querySchema = z.object({ const querySchema = z.object({
q: z.string().optional(), q: z.string().optional(),
@@ -14,6 +14,7 @@ const querySchema = z.object({
.optional(), .optional(),
machinetypeId: z.coerce.number().int().positive().optional(), machinetypeId: z.coerce.number().int().positive().optional(),
sort: z.enum(["title", "id_desc"]).optional(), sort: z.enum(["title", "id_desc"]).optional(),
facets: z.coerce.boolean().optional(),
}); });
export async function GET(req: NextRequest) { export async function GET(req: NextRequest) {
@@ -26,6 +27,7 @@ export async function GET(req: NextRequest) {
languageId: searchParams.get("languageId") ?? undefined, languageId: searchParams.get("languageId") ?? undefined,
machinetypeId: searchParams.get("machinetypeId") ?? undefined, machinetypeId: searchParams.get("machinetypeId") ?? undefined,
sort: searchParams.get("sort") ?? undefined, sort: searchParams.get("sort") ?? undefined,
facets: searchParams.get("facets") ?? undefined,
}); });
if (!parsed.success) { if (!parsed.success) {
return new Response( return new Response(
@@ -34,7 +36,10 @@ export async function GET(req: NextRequest) {
); );
} }
const data = await searchEntries(parsed.data); const data = await searchEntries(parsed.data);
return new Response(JSON.stringify(data), { const body = parsed.data.facets
? { ...data, facets: await getEntryFacets(parsed.data) }
: data;
return new Response(JSON.stringify(body), {
headers: { "content-type": "application/json" }, headers: { "content-type": "application/json" },
}); });
} }

View File

@@ -1,6 +1,7 @@
"use client"; "use client";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import Link from "next/link";
type Item = { type Item = {
id: number; id: number;
@@ -156,7 +157,7 @@ export default function ZxdbExplorer() {
<tr key={it.id}> <tr key={it.id}>
<td>{it.id}</td> <td>{it.id}</td>
<td> <td>
<a href={`/zxdb/entries/${it.id}`}>{it.title}</a> <Link href={`/zxdb/entries/${it.id}`}>{it.title}</Link>
</td> </td>
<td>{it.machinetypeId ?? "-"}</td> <td>{it.machinetypeId ?? "-"}</td>
<td>{it.languageId ?? "-"}</td> <td>{it.languageId ?? "-"}</td>
@@ -190,10 +191,10 @@ export default function ZxdbExplorer() {
<hr /> <hr />
<div className="d-flex flex-wrap gap-2"> <div className="d-flex flex-wrap gap-2">
<a className="btn btn-sm btn-outline-secondary" href="/zxdb/labels">Browse Labels</a> <Link className="btn btn-sm btn-outline-secondary" href="/zxdb/labels">Browse Labels</Link>
<a className="btn btn-sm btn-outline-secondary" href="/zxdb/genres">Browse Genres</a> <Link className="btn btn-sm btn-outline-secondary" href="/zxdb/genres">Browse Genres</Link>
<a className="btn btn-sm btn-outline-secondary" href="/zxdb/languages">Browse Languages</a> <Link className="btn btn-sm btn-outline-secondary" href="/zxdb/languages">Browse Languages</Link>
<a className="btn btn-sm btn-outline-secondary" href="/zxdb/machinetypes">Browse Machines</a> <Link className="btn btn-sm btn-outline-secondary" href="/zxdb/machinetypes">Browse Machines</Link>
</div> </div>
</div> </div>
); );

View File

@@ -1,9 +1,9 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import Link from "next/link";
type Label = { id: number; name: string; labeltypeId: string | null }; type Label = { id: number; name: string; labeltypeId: string | null };
type EntryDetail = { export type EntryDetailData = {
id: number; id: number;
title: string; title: string;
isXrated: number; isXrated: number;
@@ -14,35 +14,7 @@ type EntryDetail = {
publishers: Label[]; publishers: Label[];
}; };
export default function EntryDetailClient({ id }: { id: number }) { export default function EntryDetailClient({ data }: { data: EntryDetailData }) {
const [data, setData] = useState<EntryDetail | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
let aborted = false;
async function run() {
setLoading(true);
setError(null);
try {
const res = await fetch(`/api/zxdb/entries/${id}`, { cache: "no-store" });
if (!res.ok) throw new Error(`Failed: ${res.status}`);
const json: EntryDetail = await res.json();
if (!aborted) setData(json);
} catch (e: any) {
if (!aborted) setError(e?.message ?? "Failed to load");
} finally {
if (!aborted) setLoading(false);
}
}
run();
return () => {
aborted = true;
};
}, [id]);
if (loading) return <div>Loading</div>;
if (error) return <div className="alert alert-danger">{error}</div>;
if (!data) return <div className="alert alert-warning">Not found</div>; if (!data) return <div className="alert alert-warning">Not found</div>;
return ( return (
@@ -50,19 +22,19 @@ export default function EntryDetailClient({ id }: { id: number }) {
<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 && (
<a className="badge text-bg-secondary text-decoration-none" href={`/zxdb/genres/${data.genre.id}`}> <Link className="badge text-bg-secondary text-decoration-none" href={`/zxdb/genres/${data.genre.id}`}>
{data.genre.name} {data.genre.name}
</a> </Link>
)} )}
{data.language.name && ( {data.language.name && (
<a className="badge text-bg-info text-decoration-none" href={`/zxdb/languages/${data.language.id}`}> <Link className="badge text-bg-info text-decoration-none" href={`/zxdb/languages/${data.language.id}`}>
{data.language.name} {data.language.name}
</a> </Link>
)} )}
{data.machinetype.name && ( {data.machinetype.name && (
<a className="badge text-bg-primary text-decoration-none" href={`/zxdb/machinetypes/${data.machinetype.id}`}> <Link className="badge text-bg-primary text-decoration-none" href={`/zxdb/machinetypes/${data.machinetype.id}`}>
{data.machinetype.name} {data.machinetype.name}
</a> </Link>
)} )}
{data.isXrated ? <span className="badge text-bg-danger">18+</span> : null} {data.isXrated ? <span className="badge text-bg-danger">18+</span> : null}
</div> </div>
@@ -77,7 +49,7 @@ export default function EntryDetailClient({ id }: { id: number }) {
<ul className="list-unstyled mb-0"> <ul className="list-unstyled mb-0">
{data.authors.map((a) => ( {data.authors.map((a) => (
<li key={a.id}> <li key={a.id}>
<a href={`/zxdb/labels/${a.id}`}>{a.name}</a> <Link href={`/zxdb/labels/${a.id}`}>{a.name}</Link>
</li> </li>
))} ))}
</ul> </ul>
@@ -90,7 +62,7 @@ export default function EntryDetailClient({ id }: { id: number }) {
<ul className="list-unstyled mb-0"> <ul className="list-unstyled mb-0">
{data.publishers.map((p) => ( {data.publishers.map((p) => (
<li key={p.id}> <li key={p.id}>
<a href={`/zxdb/labels/${p.id}`}>{p.name}</a> <Link href={`/zxdb/labels/${p.id}`}>{p.name}</Link>
</li> </li>
))} ))}
</ul> </ul>
@@ -101,8 +73,8 @@ export default function EntryDetailClient({ id }: { id: number }) {
<hr /> <hr />
<div className="d-flex align-items-center gap-2"> <div className="d-flex align-items-center gap-2">
<a className="btn btn-sm btn-outline-secondary" href={`/zxdb/entries/${data.id}`}>Permalink</a> <Link className="btn btn-sm btn-outline-secondary" href={`/zxdb/entries/${data.id}`}>Permalink</Link>
<a className="btn btn-sm btn-outline-primary" href="/zxdb">Back to Explorer</a> <Link className="btn btn-sm btn-outline-primary" href="/zxdb">Back to Explorer</Link>
</div> </div>
</div> </div>
); );

View File

@@ -1,10 +1,16 @@
import EntryDetailClient from "./EntryDetail"; import EntryDetailClient from "./EntryDetail";
import { getEntryById } from "@/server/repo/zxdb";
export const metadata = { export const metadata = {
title: "ZXDB Entry", title: "ZXDB Entry",
}; };
export const revalidate = 3600;
export default async function Page({ params }: { params: Promise<{ id: string }> }) { export default async function Page({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params; const { id } = await params;
return <EntryDetailClient id={Number(id)} />; const numericId = Number(id);
const data = await getEntryById(numericId);
// For simplicity, let the client render a Not Found state if null
return <EntryDetailClient data={data as any} />;
} }

View File

@@ -1,6 +1,7 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import Link from "next/link";
type Genre = { id: number; name: string }; type Genre = { id: number; name: string };
@@ -27,7 +28,7 @@ export default function GenreList() {
<ul className="list-group"> <ul className="list-group">
{items.map((g) => ( {items.map((g) => (
<li key={g.id} className="list-group-item d-flex justify-content-between align-items-center"> <li key={g.id} className="list-group-item d-flex justify-content-between align-items-center">
<a href={`/zxdb/genres/${g.id}`}>{g.name}</a> <Link href={`/zxdb/genres/${g.id}`}>{g.name}</Link>
<span className="badge text-bg-light">#{g.id}</span> <span className="badge text-bg-light">#{g.id}</span>
</li> </li>
))} ))}

View File

@@ -1,38 +1,20 @@
"use client"; "use client";
import { useEffect, useMemo, useState } from "react"; import Link from "next/link";
import { useMemo, useState } from "react";
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; languageId: 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 }: { id: number }) { export default function GenreDetailClient({ id, initial }: { id: number; initial: Paged<Item> }) {
const [data, setData] = useState<Paged<Item> | null>(null); const [data] = useState<Paged<Item>>(initial);
const [loading, setLoading] = useState(false); const [page] = useState(1);
const [page, setPage] = useState(1); const totalPages = useMemo(() => Math.max(1, Math.ceil(data.total / data.pageSize)), [data]);
const pageSize = 20;
const totalPages = useMemo(() => (data ? Math.max(1, Math.ceil(data.total / data.pageSize)) : 1), [data]);
async function load(p: number) {
setLoading(true);
try {
const res = await fetch(`/api/zxdb/genres/${id}?page=${p}&pageSize=${pageSize}`, { cache: "no-store" });
const json = (await res.json()) as Paged<Item>;
setData(json);
} finally {
setLoading(false);
}
}
useEffect(() => {
load(page);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [page]);
return ( return (
<div className="container"> <div className="container">
<h1>Genre #{id}</h1> <h1>Genre #{id}</h1>
{loading && <div>Loading</div>} {data && data.items.length === 0 && <div className="alert alert-warning">No entries.</div>}
{data && data.items.length === 0 && !loading && <div className="alert alert-warning">No entries.</div>}
{data && data.items.length > 0 && ( {data && data.items.length > 0 && (
<div className="table-responsive"> <div className="table-responsive">
<table className="table table-striped table-hover align-middle"> <table className="table table-striped table-hover align-middle">
@@ -48,7 +30,7 @@ export default function GenreDetailClient({ id }: { id: number }) {
{data.items.map((it) => ( {data.items.map((it) => (
<tr key={it.id}> <tr key={it.id}>
<td>{it.id}</td> <td>{it.id}</td>
<td><a href={`/zxdb/entries/${it.id}`}>{it.title}</a></td> <td><Link href={`/zxdb/entries/${it.id}`}>{it.title}</Link></td>
<td>{it.machinetypeId ?? "-"}</td> <td>{it.machinetypeId ?? "-"}</td>
<td>{it.languageId ?? "-"}</td> <td>{it.languageId ?? "-"}</td>
</tr> </tr>
@@ -59,9 +41,7 @@ export default function GenreDetailClient({ id }: { id: number }) {
)} )}
<div className="d-flex align-items-center gap-2 mt-2"> <div className="d-flex align-items-center gap-2 mt-2">
<button className="btn btn-outline-secondary" onClick={() => setPage((p) => Math.max(1, p - 1))} disabled={loading || page <= 1}>Prev</button> <span>Page {data.page} / {totalPages}</span>
<span>Page {data?.page ?? page} / {totalPages}</span>
<button className="btn btn-outline-secondary" onClick={() => setPage((p) => p + 1)} disabled={loading || (data ? data.page >= totalPages : false)}>Next</button>
</div> </div>
</div> </div>
); );

View File

@@ -1,8 +1,13 @@
import GenreDetailClient from "./GenreDetail"; import GenreDetailClient from "./GenreDetail";
import { entriesByGenre } from "@/server/repo/zxdb";
export const metadata = { title: "ZXDB Genre" }; export const metadata = { title: "ZXDB Genre" };
export const revalidate = 3600;
export default async function Page({ params }: { params: Promise<{ id: string }> }) { export default async function Page({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params; const { id } = await params;
return <GenreDetailClient id={Number(id)} />; const numericId = Number(id);
const initial = await entriesByGenre(numericId, 1, 20);
return <GenreDetailClient id={numericId} initial={initial as any} />;
} }

View File

@@ -1,6 +1,7 @@
"use client"; "use client";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import Link from "next/link";
type Label = { id: number; name: string; labeltypeId: string | null }; type Label = { id: number; name: string; labeltypeId: string | null };
type Paged<T> = { items: T[]; page: number; pageSize: number; total: number }; type Paged<T> = { items: T[]; page: number; pageSize: number; total: number };
@@ -60,7 +61,7 @@ export default function LabelsSearch() {
<ul className="list-group"> <ul className="list-group">
{data.items.map((l) => ( {data.items.map((l) => (
<li key={l.id} className="list-group-item d-flex justify-content-between align-items-center"> <li key={l.id} className="list-group-item d-flex justify-content-between align-items-center">
<a href={`/zxdb/labels/${l.id}`}>{l.name}</a> <Link href={`/zxdb/labels/${l.id}`}>{l.name}</Link>
<span className="badge text-bg-light">{l.labeltypeId ?? "?"}</span> <span className="badge text-bg-light">{l.labeltypeId ?? "?"}</span>
</li> </li>
))} ))}

View File

@@ -1,40 +1,23 @@
"use client"; "use client";
import { useEffect, useMemo, useState } from "react"; import Link from "next/link";
import { useMemo, useState } from "react";
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; languageId: 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; authored: Paged<Item>; published: Paged<Item> }; type Payload = { label: Label | null; authored: Paged<Item>; published: Paged<Item> };
export default function LabelDetailClient({ id }: { id: number }) { export default function LabelDetailClient({ id, initial }: { id: number; initial: Payload }) {
const [data, setData] = useState<Payload | null>(null); const [data] = useState<Payload>(initial);
const [tab, setTab] = useState<"authored" | "published">("authored"); const [tab, setTab] = useState<"authored" | "published">("authored");
const [page, setPage] = useState(1); const [page] = useState(1);
const [loading, setLoading] = useState(false);
const current = useMemo(() => (data ? (tab === "authored" ? data.authored : data.published) : null), [data, tab]); if (!data || !data.label) return <div className="alert alert-warning">Not found</div>;
const totalPages = useMemo(() => (current ? Math.max(1, Math.ceil(current.total / current.pageSize)) : 1), [current]);
async function load(p: number) { const current = useMemo(() => (tab === "authored" ? data.authored : data.published), [data, tab]);
setLoading(true); const totalPages = useMemo(() => Math.max(1, Math.ceil(current.total / current.pageSize)), [current]);
try {
const params = new URLSearchParams({ page: String(p), pageSize: "20" });
const res = await fetch(`/api/zxdb/labels/${id}?${params.toString()}`, { cache: "no-store" });
const json = (await res.json()) as Payload;
setData(json);
} finally {
setLoading(false);
}
}
useEffect(() => {
load(page);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [page]);
if (!data) return <div>{loading ? "Loading…" : "Not found"}</div>;
return ( return (
<div className="container"> <div className="container">
@@ -55,8 +38,7 @@ export default function LabelDetailClient({ id }: { id: number }) {
</ul> </ul>
<div className="mt-3"> <div className="mt-3">
{loading && <div>Loading</div>} {current && current.items.length === 0 && <div className="alert alert-warning">No entries.</div>}
{current && current.items.length === 0 && !loading && <div className="alert alert-warning">No entries.</div>}
{current && current.items.length > 0 && ( {current && current.items.length > 0 && (
<div className="table-responsive"> <div className="table-responsive">
<table className="table table-striped table-hover align-middle"> <table className="table table-striped table-hover align-middle">
@@ -72,7 +54,7 @@ export default function LabelDetailClient({ id }: { id: number }) {
{current.items.map((it) => ( {current.items.map((it) => (
<tr key={it.id}> <tr key={it.id}>
<td>{it.id}</td> <td>{it.id}</td>
<td><a href={`/zxdb/entries/${it.id}`}>{it.title}</a></td> <td><Link href={`/zxdb/entries/${it.id}`}>{it.title}</Link></td>
<td>{it.machinetypeId ?? "-"}</td> <td>{it.machinetypeId ?? "-"}</td>
<td>{it.languageId ?? "-"}</td> <td>{it.languageId ?? "-"}</td>
</tr> </tr>
@@ -84,9 +66,7 @@ export default function LabelDetailClient({ id }: { id: number }) {
</div> </div>
<div className="d-flex align-items-center gap-2 mt-2"> <div className="d-flex align-items-center gap-2 mt-2">
<button className="btn btn-outline-secondary" onClick={() => setPage((p) => Math.max(1, p - 1))} disabled={loading || page <= 1}>Prev</button>
<span>Page {page} / {totalPages}</span> <span>Page {page} / {totalPages}</span>
<button className="btn btn-outline-secondary" onClick={() => setPage((p) => p + 1)} disabled={loading || page >= totalPages}>Next</button>
</div> </div>
</div> </div>
); );

View File

@@ -1,8 +1,19 @@
import LabelDetailClient from "./LabelDetail"; import LabelDetailClient from "./LabelDetail";
import { getLabelById, getLabelAuthoredEntries, getLabelPublishedEntries } from "@/server/repo/zxdb";
export const metadata = { title: "ZXDB Label" }; export const metadata = { title: "ZXDB Label" };
export const revalidate = 3600;
export default async function Page({ params }: { params: Promise<{ id: string }> }) { export default async function Page({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params; const { id } = await params;
return <LabelDetailClient id={Number(id)} />; const numericId = Number(id);
const [label, authored, published] = await Promise.all([
getLabelById(numericId),
getLabelAuthoredEntries(numericId, { page: 1, pageSize: 20 }),
getLabelPublishedEntries(numericId, { page: 1, pageSize: 20 }),
]);
// 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 }} />;
} }

View File

@@ -1,6 +1,7 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import Link from "next/link";
type Language = { id: string; name: string }; type Language = { id: string; name: string };
@@ -27,7 +28,7 @@ export default function LanguageList() {
<ul className="list-group"> <ul className="list-group">
{items.map((l) => ( {items.map((l) => (
<li key={l.id} className="list-group-item d-flex justify-content-between align-items-center"> <li key={l.id} className="list-group-item d-flex justify-content-between align-items-center">
<a href={`/zxdb/languages/${l.id}`}>{l.name}</a> <Link href={`/zxdb/languages/${l.id}`}>{l.name}</Link>
<span className="badge text-bg-light">{l.id}</span> <span className="badge text-bg-light">{l.id}</span>
</li> </li>
))} ))}

View File

@@ -1,38 +1,19 @@
"use client"; "use client";
import { useEffect, useMemo, useState } from "react"; import Link from "next/link";
import { useMemo, useState } from "react";
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; languageId: 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 }: { id: string }) { export default function LanguageDetailClient({ id, initial }: { id: string; initial: Paged<Item> }) {
const [data, setData] = useState<Paged<Item> | null>(null); const [data] = useState<Paged<Item>>(initial);
const [loading, setLoading] = useState(false); const totalPages = useMemo(() => Math.max(1, Math.ceil(data.total / data.pageSize)), [data]);
const [page, setPage] = useState(1);
const pageSize = 20;
const totalPages = useMemo(() => (data ? Math.max(1, Math.ceil(data.total / data.pageSize)) : 1), [data]);
async function load(p: number) {
setLoading(true);
try {
const res = await fetch(`/api/zxdb/languages/${id}?page=${p}&pageSize=${pageSize}`, { cache: "no-store" });
const json = (await res.json()) as Paged<Item>;
setData(json);
} finally {
setLoading(false);
}
}
useEffect(() => {
load(page);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [page]);
return ( return (
<div className="container"> <div className="container">
<h1>Language {id}</h1> <h1>Language {id}</h1>
{loading && <div>Loading</div>} {data && data.items.length === 0 && <div className="alert alert-warning">No entries.</div>}
{data && data.items.length === 0 && !loading && <div className="alert alert-warning">No entries.</div>}
{data && data.items.length > 0 && ( {data && data.items.length > 0 && (
<div className="table-responsive"> <div className="table-responsive">
<table className="table table-striped table-hover align-middle"> <table className="table table-striped table-hover align-middle">
@@ -48,7 +29,7 @@ export default function LanguageDetailClient({ id }: { id: string }) {
{data.items.map((it) => ( {data.items.map((it) => (
<tr key={it.id}> <tr key={it.id}>
<td>{it.id}</td> <td>{it.id}</td>
<td><a href={`/zxdb/entries/${it.id}`}>{it.title}</a></td> <td><Link href={`/zxdb/entries/${it.id}`}>{it.title}</Link></td>
<td>{it.machinetypeId ?? "-"}</td> <td>{it.machinetypeId ?? "-"}</td>
<td>{it.languageId ?? "-"}</td> <td>{it.languageId ?? "-"}</td>
</tr> </tr>
@@ -59,9 +40,7 @@ export default function LanguageDetailClient({ id }: { id: string }) {
)} )}
<div className="d-flex align-items-center gap-2 mt-2"> <div className="d-flex align-items-center gap-2 mt-2">
<button className="btn btn-outline-secondary" onClick={() => setPage((p) => Math.max(1, p - 1))} disabled={loading || page <= 1}>Prev</button> <span>Page {data.page} / {totalPages}</span>
<span>Page {data?.page ?? page} / {totalPages}</span>
<button className="btn btn-outline-secondary" onClick={() => setPage((p) => p + 1)} disabled={loading || (data ? data.page >= totalPages : false)}>Next</button>
</div> </div>
</div> </div>
); );

View File

@@ -1,8 +1,12 @@
import LanguageDetailClient from "./LanguageDetail"; import LanguageDetailClient from "./LanguageDetail";
import { entriesByLanguage } from "@/server/repo/zxdb";
export const metadata = { title: "ZXDB Language" }; export const metadata = { title: "ZXDB Language" };
export const revalidate = 3600;
export default async function Page({ params }: { params: Promise<{ id: string }> }) { export default async function Page({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params; const { id } = await params;
return <LanguageDetailClient id={id} />; const initial = await entriesByLanguage(id, 1, 20);
return <LanguageDetailClient id={id} initial={initial as any} />;
} }

View File

@@ -1,38 +1,19 @@
"use client"; "use client";
import { useEffect, useMemo, useState } from "react"; import Link from "next/link";
import { useMemo, useState } from "react";
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; languageId: 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 }: { id: number }) { export default function MachineTypeDetailClient({ id, initial }: { id: number; initial: Paged<Item> }) {
const [data, setData] = useState<Paged<Item> | null>(null); const [data] = useState<Paged<Item>>(initial);
const [loading, setLoading] = useState(false); const totalPages = useMemo(() => Math.max(1, Math.ceil(data.total / data.pageSize)), [data]);
const [page, setPage] = useState(1);
const pageSize = 20;
const totalPages = useMemo(() => (data ? Math.max(1, Math.ceil(data.total / data.pageSize)) : 1), [data]);
async function load(p: number) {
setLoading(true);
try {
const res = await fetch(`/api/zxdb/machinetypes/${id}?page=${p}&pageSize=${pageSize}`, { cache: "no-store" });
const json = (await res.json()) as Paged<Item>;
setData(json);
} finally {
setLoading(false);
}
}
useEffect(() => {
load(page);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [page]);
return ( return (
<div className="container"> <div className="container">
<h1>Machine Type #{id}</h1> <h1>Machine Type #{id}</h1>
{loading && <div>Loading</div>} {data && data.items.length === 0 && <div className="alert alert-warning">No entries.</div>}
{data && data.items.length === 0 && !loading && <div className="alert alert-warning">No entries.</div>}
{data && data.items.length > 0 && ( {data && data.items.length > 0 && (
<div className="table-responsive"> <div className="table-responsive">
<table className="table table-striped table-hover align-middle"> <table className="table table-striped table-hover align-middle">
@@ -48,7 +29,7 @@ export default function MachineTypeDetailClient({ id }: { id: number }) {
{data.items.map((it) => ( {data.items.map((it) => (
<tr key={it.id}> <tr key={it.id}>
<td>{it.id}</td> <td>{it.id}</td>
<td><a href={`/zxdb/entries/${it.id}`}>{it.title}</a></td> <td><Link href={`/zxdb/entries/${it.id}`}>{it.title}</Link></td>
<td>{it.machinetypeId ?? "-"}</td> <td>{it.machinetypeId ?? "-"}</td>
<td>{it.languageId ?? "-"}</td> <td>{it.languageId ?? "-"}</td>
</tr> </tr>
@@ -59,9 +40,7 @@ export default function MachineTypeDetailClient({ id }: { id: number }) {
)} )}
<div className="d-flex align-items-center gap-2 mt-2"> <div className="d-flex align-items-center gap-2 mt-2">
<button className="btn btn-outline-secondary" onClick={() => setPage((p) => Math.max(1, p - 1))} disabled={loading || page <= 1}>Prev</button> <span>Page {data.page} / {totalPages}</span>
<span>Page {data?.page ?? page} / {totalPages}</span>
<button className="btn btn-outline-secondary" onClick={() => setPage((p) => p + 1)} disabled={loading || (data ? data.page >= totalPages : false)}>Next</button>
</div> </div>
</div> </div>
); );

View File

@@ -0,0 +1,13 @@
import MachineTypeDetailClient from "./MachineTypeDetail";
import { entriesByMachinetype } from "@/server/repo/zxdb";
export const metadata = { title: "ZXDB Machine Type" };
export const revalidate = 3600;
export default async function Page({ params }: { params: Promise<{ id: string }> }) {
const { id } = await params;
const numericId = Number(id);
const initial = await entriesByMachinetype(numericId, 1, 20);
return <MachineTypeDetailClient id={numericId} initial={initial as any} />;
}

View File

@@ -1,9 +1,14 @@
import ZxdbExplorer from "./ZxdbExplorer"; import ZxdbExplorer from "./ZxdbExplorer";
import { searchEntries } from "@/server/repo/zxdb";
export const metadata = { export const metadata = {
title: "ZXDB Explorer", title: "ZXDB Explorer",
}; };
export default function Page() { export const revalidate = 3600;
return <ZxdbExplorer />;
export default async function Page() {
// Server-render initial page (no query) to avoid first client fetch
const initial = await searchEntries({ page: 1, pageSize: 20, sort: "id_desc" });
return <ZxdbExplorer initial={initial as any} />;
} }

View File

@@ -38,6 +38,18 @@ export interface PagedResult<T> {
total: number; total: number;
} }
export interface FacetItem<T extends number | string> {
id: T;
name: string;
count: number;
}
export interface EntryFacets {
genres: FacetItem<number>[];
languages: FacetItem<string>[];
machinetypes: FacetItem<number>[];
}
export async function searchEntries(params: SearchParams): Promise<PagedResult<SearchResultItem>> { export async function searchEntries(params: SearchParams): Promise<PagedResult<SearchResultItem>> {
const q = (params.q ?? "").trim(); const q = (params.q ?? "").trim();
const pageSize = Math.max(1, Math.min(params.pageSize ?? 20, 100)); const pageSize = Math.max(1, Math.min(params.pageSize ?? 20, 100));
@@ -124,44 +136,42 @@ export interface EntryDetail {
} }
export async function getEntryById(id: number): Promise<EntryDetail | null> { export async function getEntryById(id: number): Promise<EntryDetail | null> {
// Basic entry with lookups // Run base row + contributors in parallel to reduce latency
const rows = await db const [rows, authorRows, publisherRows] = await Promise.all([
.select({ db
id: entries.id, .select({
title: entries.title, id: entries.id,
isXrated: entries.isXrated, title: entries.title,
machinetypeId: entries.machinetypeId, isXrated: entries.isXrated,
machinetypeName: machinetypes.name, machinetypeId: entries.machinetypeId,
languageId: entries.languageId, machinetypeName: machinetypes.name,
languageName: languages.name, languageId: entries.languageId,
genreId: entries.genretypeId, languageName: languages.name,
genreName: genretypes.name, genreId: entries.genretypeId,
}) genreName: genretypes.name,
.from(entries) })
.leftJoin(machinetypes, eq(machinetypes.id, entries.machinetypeId as any)) .from(entries)
.leftJoin(languages, eq(languages.id, entries.languageId as any)) .leftJoin(machinetypes, eq(machinetypes.id, entries.machinetypeId as any))
.leftJoin(genretypes, eq(genretypes.id, entries.genretypeId as any)) .leftJoin(languages, eq(languages.id, entries.languageId as any))
.where(eq(entries.id, id)); .leftJoin(genretypes, eq(genretypes.id, entries.genretypeId as any))
.where(eq(entries.id, id)),
db
.select({ id: labels.id, name: labels.name, labeltypeId: labels.labeltypeId })
.from(authors)
.innerJoin(labels, eq(labels.id, authors.labelId))
.where(eq(authors.entryId, id))
.groupBy(labels.id),
db
.select({ id: labels.id, name: labels.name, labeltypeId: labels.labeltypeId })
.from(publishers)
.innerJoin(labels, eq(labels.id, publishers.labelId))
.where(eq(publishers.entryId, id))
.groupBy(labels.id),
]);
const base = rows[0]; const base = rows[0];
if (!base) return null; if (!base) return null;
// Authors
const authorRows = await db
.select({ id: labels.id, name: labels.name, labeltypeId: labels.labeltypeId })
.from(authors)
.innerJoin(labels, eq(labels.id, authors.labelId))
.where(eq(authors.entryId, id))
.groupBy(labels.id);
// Publishers
const publisherRows = await db
.select({ id: labels.id, name: labels.name, labeltypeId: labels.labeltypeId })
.from(publishers)
.innerJoin(labels, eq(labels.id, publishers.labelId))
.where(eq(publishers.entryId, id))
.groupBy(labels.id);
return { return {
id: base.id, id: base.id,
title: base.title, title: base.title,
@@ -339,3 +349,57 @@ export async function entriesByMachinetype(mtId: number, page: number, pageSize:
.offset(offset); .offset(offset);
return { items: items as any, page, pageSize, total: Number(total ?? 0) }; return { items: items as any, page, pageSize, total: Number(total ?? 0) };
} }
// ----- Facets for search -----
export async function getEntryFacets(params: SearchParams): Promise<EntryFacets> {
const q = (params.q ?? "").trim();
const pattern = q ? `%${q.toLowerCase().replace(/[^a-z0-9]+/g, "")}%` : null;
// Build base WHERE SQL snippet considering q + filters
const whereParts: any[] = [];
if (pattern) {
whereParts.push(sql`id in (select entry_id from ${searchByTitles} where ${searchByTitles.entryTitle} like ${pattern})`);
}
if (params.genreId) whereParts.push(sql`${entries.genretypeId} = ${params.genreId}`);
if (params.languageId) whereParts.push(sql`${entries.languageId} = ${params.languageId}`);
if (params.machinetypeId) whereParts.push(sql`${entries.machinetypeId} = ${params.machinetypeId}`);
const whereSql = whereParts.length ? sql.join([sql`where `, sql.join(whereParts as any, sql` and `)], sql``) : sql``;
// Genres facet
const genresRows = await db.execute(sql`
select e.genretype_id as id, gt.text as name, count(*) as count
from ${entries} as e
left join ${genretypes} as gt on gt.id = e.genretype_id
${whereSql}
group by e.genretype_id, gt.text
order by count desc, name asc
`) as any;
// Languages facet
const langRows = await db.execute(sql`
select e.language_id as id, l.text as name, count(*) as count
from ${entries} as e
left join ${languages} as l on l.id = e.language_id
${whereSql}
group by e.language_id, l.text
order by count desc, name asc
`) as any;
// Machinetypes facet
const mtRows = await db.execute(sql`
select e.machinetype_id as id, m.text as name, count(*) as count
from ${entries} as e
left join ${machinetypes} as m on m.id = e.machinetype_id
${whereSql}
group by e.machinetype_id, m.text
order by count desc, name asc
`) as any;
return {
genres: (genresRows as any[]).map((r: any) => ({ id: Number(r.id), name: r.name ?? "(none)", count: Number(r.count) })).filter((r) => !!r.id),
languages: (langRows as any[]).map((r: any) => ({ id: String(r.id), name: r.name ?? "(none)", count: Number(r.count) })).filter((r) => !!r.id),
machinetypes: (mtRows as any[]).map((r: any) => ({ id: Number(r.id), name: r.name ?? "(none)", count: Number(r.count) })).filter((r) => !!r.id),
};
}