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:
@@ -115,7 +115,8 @@ Comment what the code does, not what the agent has done. The documentation's pur
|
||||
- Do not create new branches
|
||||
- git commits:
|
||||
- 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:
|
||||
- Use imperative mood (e.g., "Add feature X", "Fix bug Y").
|
||||
- Include relevant issue numbers if applicable.
|
||||
|
||||
@@ -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,
|
||||
and fix ZXDB lookup table schema to use `text` column (not `name`) to avoid
|
||||
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.
|
||||
Context
|
||||
- Housekeeping commit to capture all current ZXDB Explorer work before index-page performance optimizations.
|
||||
|
||||
Changes
|
||||
- /zxdb/entries/[id]/page.tsx: make Page async and `await params`, pass numeric id
|
||||
- /zxdb/labels/[id]/page.tsx: make Page async and `await params`, pass numeric id
|
||||
- /zxdb/genres/[id]/page.tsx: make Page async and `await params`, pass numeric id
|
||||
- /zxdb/languages/[id]/page.tsx: make Page async and `await params`, pass string id
|
||||
- /registers/[hex]/page.tsx: make Page async and `await params`, decode hex safely
|
||||
- /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.
|
||||
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
|
||||
- API route handlers under /api continue to use ctx.params synchronously; this
|
||||
change only affects App Router Page components.
|
||||
- Follow-up commit will focus specifically on speeding up index pages via SSR initial data and ISR.
|
||||
|
||||
Signed-off-by: Junie@lucy.xalior.com
|
||||
Signed-off-by: Junie@lucy.xalior.com
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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" },
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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 }} />;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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} />;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
13
src/app/zxdb/machinetypes/[id]/page.tsx
Normal file
13
src/app/zxdb/machinetypes/[id]/page.tsx
Normal 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} />;
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
|
||||
@@ -38,6 +38,18 @@ export interface PagedResult<T> {
|
||||
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>> {
|
||||
const q = (params.q ?? "").trim();
|
||||
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> {
|
||||
// Basic entry with lookups
|
||||
const rows = await db
|
||||
.select({
|
||||
id: entries.id,
|
||||
title: entries.title,
|
||||
isXrated: entries.isXrated,
|
||||
machinetypeId: entries.machinetypeId,
|
||||
machinetypeName: machinetypes.name,
|
||||
languageId: entries.languageId,
|
||||
languageName: languages.name,
|
||||
genreId: entries.genretypeId,
|
||||
genreName: genretypes.name,
|
||||
})
|
||||
.from(entries)
|
||||
.leftJoin(machinetypes, eq(machinetypes.id, entries.machinetypeId as any))
|
||||
.leftJoin(languages, eq(languages.id, entries.languageId as any))
|
||||
.leftJoin(genretypes, eq(genretypes.id, entries.genretypeId as any))
|
||||
.where(eq(entries.id, id));
|
||||
// Run base row + contributors in parallel to reduce latency
|
||||
const [rows, authorRows, publisherRows] = await Promise.all([
|
||||
db
|
||||
.select({
|
||||
id: entries.id,
|
||||
title: entries.title,
|
||||
isXrated: entries.isXrated,
|
||||
machinetypeId: entries.machinetypeId,
|
||||
machinetypeName: machinetypes.name,
|
||||
languageId: entries.languageId,
|
||||
languageName: languages.name,
|
||||
genreId: entries.genretypeId,
|
||||
genreName: genretypes.name,
|
||||
})
|
||||
.from(entries)
|
||||
.leftJoin(machinetypes, eq(machinetypes.id, entries.machinetypeId as any))
|
||||
.leftJoin(languages, eq(languages.id, entries.languageId as any))
|
||||
.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];
|
||||
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 {
|
||||
id: base.id,
|
||||
title: base.title,
|
||||
@@ -339,3 +349,57 @@ export async function entriesByMachinetype(mtId: number, page: number, pageSize:
|
||||
.offset(offset);
|
||||
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),
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user