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

@@ -1,40 +1,23 @@
"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 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 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 }) {
const [data, setData] = useState<Payload | null>(null);
export default function LabelDetailClient({ id, initial }: { id: number; initial: Payload }) {
const [data] = useState<Payload>(initial);
const [tab, setTab] = useState<"authored" | "published">("authored");
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
const [page] = useState(1);
const current = useMemo(() => (data ? (tab === "authored" ? data.authored : data.published) : null), [data, tab]);
const totalPages = useMemo(() => (current ? Math.max(1, Math.ceil(current.total / current.pageSize)) : 1), [current]);
if (!data || !data.label) return <div className="alert alert-warning">Not found</div>;
async function load(p: number) {
setLoading(true);
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>;
const current = useMemo(() => (tab === "authored" ? data.authored : data.published), [data, tab]);
const totalPages = useMemo(() => Math.max(1, Math.ceil(current.total / current.pageSize)), [current]);
return (
<div className="container">
@@ -55,8 +38,7 @@ export default function LabelDetailClient({ id }: { id: number }) {
</ul>
<div className="mt-3">
{loading && <div>Loading</div>}
{current && current.items.length === 0 && !loading && <div className="alert alert-warning">No entries.</div>}
{current && current.items.length === 0 && <div className="alert alert-warning">No entries.</div>}
{current && current.items.length > 0 && (
<div className="table-responsive">
<table className="table table-striped table-hover align-middle">
@@ -72,7 +54,7 @@ export default function LabelDetailClient({ id }: { id: number }) {
{current.items.map((it) => (
<tr key={it.id}>
<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.languageId ?? "-"}</td>
</tr>
@@ -84,9 +66,7 @@ export default function LabelDetailClient({ id }: { id: number }) {
</div>
<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>
<button className="btn btn-outline-secondary" onClick={() => setPage((p) => p + 1)} disabled={loading || page >= totalPages}>Next</button>
</div>
</div>
);