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

@@ -22,7 +22,11 @@ export async function GET(_req: NextRequest, ctx: { params: Promise<{ id: string
});
}
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(),
});
export async function GET(req: NextRequest, ctx: { params: { id: string } }) {
const p = paramsSchema.safeParse(ctx.params);
export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string }> }) {
const raw = await ctx.params;
const p = paramsSchema.safeParse(raw);
if (!p.success) {
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() {
const data = await listGenres();
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(),
});
export async function GET(req: NextRequest, ctx: { params: { id: string } }) {
const p = paramsSchema.safeParse(ctx.params);
export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string }> }) {
const raw = await ctx.params;
const p = paramsSchema.safeParse(raw);
if (!p.success) {
return new Response(JSON.stringify({ error: p.error.flatten() }), {
status: 400,

View File

@@ -8,8 +8,9 @@ const querySchema = z.object({
pageSize: z.coerce.number().int().positive().max(100).optional(),
});
export async function GET(req: NextRequest, ctx: { params: { id: string } }) {
const p = paramsSchema.safeParse(ctx.params);
export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string }> }) {
const raw = await ctx.params;
const p = paramsSchema.safeParse(raw);
if (!p.success) {
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() {
const data = await listLanguages();
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(),
});
export async function GET(req: NextRequest, ctx: { params: { id: string } }) {
const p = paramsSchema.safeParse(ctx.params);
export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string }> }) {
const raw = await ctx.params;
const p = paramsSchema.safeParse(raw);
if (!p.success) {
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() {
const data = await listMachinetypes();
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 { z } from "zod";
import { searchEntries } from "@/server/repo/zxdb";
import { searchEntries, getEntryFacets } from "@/server/repo/zxdb";
const querySchema = z.object({
q: z.string().optional(),
@@ -14,6 +14,7 @@ const querySchema = z.object({
.optional(),
machinetypeId: z.coerce.number().int().positive().optional(),
sort: z.enum(["title", "id_desc"]).optional(),
facets: z.coerce.boolean().optional(),
});
export async function GET(req: NextRequest) {
@@ -26,6 +27,7 @@ export async function GET(req: NextRequest) {
languageId: searchParams.get("languageId") ?? undefined,
machinetypeId: searchParams.get("machinetypeId") ?? undefined,
sort: searchParams.get("sort") ?? undefined,
facets: searchParams.get("facets") ?? undefined,
});
if (!parsed.success) {
return new Response(
@@ -34,7 +36,10 @@ export async function GET(req: NextRequest) {
);
}
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" },
});
}

View File

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

View File

@@ -1,9 +1,9 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
type Label = { id: number; name: string; labeltypeId: string | null };
type EntryDetail = {
export type EntryDetailData = {
id: number;
title: string;
isXrated: number;
@@ -14,35 +14,7 @@ type EntryDetail = {
publishers: Label[];
};
export default function EntryDetailClient({ id }: { id: number }) {
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>;
export default function EntryDetailClient({ data }: { data: EntryDetailData }) {
if (!data) return <div className="alert alert-warning">Not found</div>;
return (
@@ -50,19 +22,19 @@ export default function EntryDetailClient({ id }: { id: number }) {
<div className="d-flex align-items-center gap-2 flex-wrap">
<h1 className="mb-0">{data.title}</h1>
{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}
</a>
</Link>
)}
{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}
</a>
</Link>
)}
{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}
</a>
</Link>
)}
{data.isXrated ? <span className="badge text-bg-danger">18+</span> : null}
</div>
@@ -77,7 +49,7 @@ export default function EntryDetailClient({ id }: { id: number }) {
<ul className="list-unstyled mb-0">
{data.authors.map((a) => (
<li key={a.id}>
<a href={`/zxdb/labels/${a.id}`}>{a.name}</a>
<Link href={`/zxdb/labels/${a.id}`}>{a.name}</Link>
</li>
))}
</ul>
@@ -90,7 +62,7 @@ export default function EntryDetailClient({ id }: { id: number }) {
<ul className="list-unstyled mb-0">
{data.publishers.map((p) => (
<li key={p.id}>
<a href={`/zxdb/labels/${p.id}`}>{p.name}</a>
<Link href={`/zxdb/labels/${p.id}`}>{p.name}</Link>
</li>
))}
</ul>
@@ -101,8 +73,8 @@ export default function EntryDetailClient({ id }: { id: number }) {
<hr />
<div className="d-flex align-items-center gap-2">
<a className="btn btn-sm btn-outline-secondary" href={`/zxdb/entries/${data.id}`}>Permalink</a>
<a className="btn btn-sm btn-outline-primary" href="/zxdb">Back to Explorer</a>
<Link className="btn btn-sm btn-outline-secondary" href={`/zxdb/entries/${data.id}`}>Permalink</Link>
<Link className="btn btn-sm btn-outline-primary" href="/zxdb">Back to Explorer</Link>
</div>
</div>
);

View File

@@ -1,10 +1,16 @@
import EntryDetailClient from "./EntryDetail";
import { getEntryById } from "@/server/repo/zxdb";
export const metadata = {
title: "ZXDB Entry",
};
export const revalidate = 3600;
export default async function Page({ params }: { params: Promise<{ id: string }> }) {
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";
import { useEffect, useState } from "react";
import Link from "next/link";
type Genre = { id: number; name: string };
@@ -27,7 +28,7 @@ export default function GenreList() {
<ul className="list-group">
{items.map((g) => (
<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>
</li>
))}

View File

@@ -1,38 +1,20 @@
"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 Paged<T> = { items: T[]; page: number; pageSize: number; total: number };
export default function GenreDetailClient({ id }: { id: number }) {
const [data, setData] = useState<Paged<Item> | null>(null);
const [loading, setLoading] = useState(false);
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/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]);
export default function GenreDetailClient({ id, initial }: { id: number; initial: Paged<Item> }) {
const [data] = useState<Paged<Item>>(initial);
const [page] = useState(1);
const totalPages = useMemo(() => Math.max(1, Math.ceil(data.total / data.pageSize)), [data]);
return (
<div className="container">
<h1>Genre #{id}</h1>
{loading && <div>Loading</div>}
{data && data.items.length === 0 && !loading && <div className="alert alert-warning">No entries.</div>}
{data && data.items.length === 0 && <div className="alert alert-warning">No entries.</div>}
{data && data.items.length > 0 && (
<div className="table-responsive">
<table className="table table-striped table-hover align-middle">
@@ -48,7 +30,7 @@ export default function GenreDetailClient({ id }: { id: number }) {
{data.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>
@@ -59,9 +41,7 @@ export default function GenreDetailClient({ id }: { id: number }) {
)}
<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 ?? page} / {totalPages}</span>
<button className="btn btn-outline-secondary" onClick={() => setPage((p) => p + 1)} disabled={loading || (data ? data.page >= totalPages : false)}>Next</button>
<span>Page {data.page} / {totalPages}</span>
</div>
</div>
);

View File

@@ -1,8 +1,13 @@
import GenreDetailClient from "./GenreDetail";
import { entriesByGenre } from "@/server/repo/zxdb";
export const metadata = { title: "ZXDB Genre" };
export const revalidate = 3600;
export default async function Page({ params }: { params: Promise<{ id: string }> }) {
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";
import { useEffect, useMemo, useState } from "react";
import Link from "next/link";
type Label = { id: number; name: string; labeltypeId: string | null };
type Paged<T> = { items: T[]; page: number; pageSize: number; total: number };
@@ -60,7 +61,7 @@ export default function LabelsSearch() {
<ul className="list-group">
{data.items.map((l) => (
<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>
</li>
))}

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>
);

View File

@@ -1,8 +1,19 @@
import LabelDetailClient from "./LabelDetail";
import { getLabelById, getLabelAuthoredEntries, getLabelPublishedEntries } from "@/server/repo/zxdb";
export const metadata = { title: "ZXDB Label" };
export const revalidate = 3600;
export default async function Page({ params }: { params: Promise<{ id: string }> }) {
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";
import { useEffect, useState } from "react";
import Link from "next/link";
type Language = { id: string; name: string };
@@ -27,7 +28,7 @@ export default function LanguageList() {
<ul className="list-group">
{items.map((l) => (
<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>
</li>
))}

View File

@@ -1,38 +1,19 @@
"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 Paged<T> = { items: T[]; page: number; pageSize: number; total: number };
export default function LanguageDetailClient({ id }: { id: string }) {
const [data, setData] = useState<Paged<Item> | null>(null);
const [loading, setLoading] = useState(false);
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]);
export default function LanguageDetailClient({ id, initial }: { id: string; initial: Paged<Item> }) {
const [data] = useState<Paged<Item>>(initial);
const totalPages = useMemo(() => Math.max(1, Math.ceil(data.total / data.pageSize)), [data]);
return (
<div className="container">
<h1>Language {id}</h1>
{loading && <div>Loading</div>}
{data && data.items.length === 0 && !loading && <div className="alert alert-warning">No entries.</div>}
{data && data.items.length === 0 && <div className="alert alert-warning">No entries.</div>}
{data && data.items.length > 0 && (
<div className="table-responsive">
<table className="table table-striped table-hover align-middle">
@@ -48,7 +29,7 @@ export default function LanguageDetailClient({ id }: { id: string }) {
{data.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>
@@ -59,9 +40,7 @@ export default function LanguageDetailClient({ id }: { id: string }) {
)}
<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 ?? page} / {totalPages}</span>
<button className="btn btn-outline-secondary" onClick={() => setPage((p) => p + 1)} disabled={loading || (data ? data.page >= totalPages : false)}>Next</button>
<span>Page {data.page} / {totalPages}</span>
</div>
</div>
);

View File

@@ -1,8 +1,12 @@
import LanguageDetailClient from "./LanguageDetail";
import { entriesByLanguage } from "@/server/repo/zxdb";
export const metadata = { title: "ZXDB Language" };
export const revalidate = 3600;
export default async function Page({ params }: { params: Promise<{ id: string }> }) {
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";
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 Paged<T> = { items: T[]; page: number; pageSize: number; total: number };
export default function MachineTypeDetailClient({ id }: { id: number }) {
const [data, setData] = useState<Paged<Item> | null>(null);
const [loading, setLoading] = useState(false);
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]);
export default function MachineTypeDetailClient({ id, initial }: { id: number; initial: Paged<Item> }) {
const [data] = useState<Paged<Item>>(initial);
const totalPages = useMemo(() => Math.max(1, Math.ceil(data.total / data.pageSize)), [data]);
return (
<div className="container">
<h1>Machine Type #{id}</h1>
{loading && <div>Loading</div>}
{data && data.items.length === 0 && !loading && <div className="alert alert-warning">No entries.</div>}
{data && data.items.length === 0 && <div className="alert alert-warning">No entries.</div>}
{data && data.items.length > 0 && (
<div className="table-responsive">
<table className="table table-striped table-hover align-middle">
@@ -48,7 +29,7 @@ export default function MachineTypeDetailClient({ id }: { id: number }) {
{data.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>
@@ -59,9 +40,7 @@ export default function MachineTypeDetailClient({ id }: { id: number }) {
)}
<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 ?? page} / {totalPages}</span>
<button className="btn btn-outline-secondary" onClick={() => setPage((p) => p + 1)} disabled={loading || (data ? data.page >= totalPages : false)}>Next</button>
<span>Page {data.page} / {totalPages}</span>
</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 { searchEntries } from "@/server/repo/zxdb";
export const metadata = {
title: "ZXDB Explorer",
};
export default function Page() {
return <ZxdbExplorer />;
export const revalidate = 3600;
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} />;
}