diff --git a/AGENTS.md b/AGENTS.md
index cca2128..129b83f 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -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.
diff --git a/COMMIT_EDITMSG b/COMMIT_EDITMSG
index e9ff47f..fdfd1c9 100644
--- a/COMMIT_EDITMSG
+++ b/COMMIT_EDITMSG
@@ -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
\ No newline at end of file
diff --git a/src/app/api/zxdb/entries/[id]/route.ts b/src/app/api/zxdb/entries/[id]/route.ts
index dca9e02..e7b5ba1 100644
--- a/src/app/api/zxdb/entries/[id]/route.ts
+++ b/src/app/api/zxdb/entries/[id]/route.ts
@@ -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",
+ },
});
}
diff --git a/src/app/api/zxdb/genres/[id]/route.ts b/src/app/api/zxdb/genres/[id]/route.ts
index 6883bad..d3a643a 100644
--- a/src/app/api/zxdb/genres/[id]/route.ts
+++ b/src/app/api/zxdb/genres/[id]/route.ts
@@ -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 });
}
diff --git a/src/app/api/zxdb/genres/route.ts b/src/app/api/zxdb/genres/route.ts
index dac4631..855b6cb 100644
--- a/src/app/api/zxdb/genres/route.ts
+++ b/src/app/api/zxdb/genres/route.ts
@@ -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",
+ },
});
}
diff --git a/src/app/api/zxdb/labels/[id]/route.ts b/src/app/api/zxdb/labels/[id]/route.ts
index 77c5dec..58f749e 100644
--- a/src/app/api/zxdb/labels/[id]/route.ts
+++ b/src/app/api/zxdb/labels/[id]/route.ts
@@ -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,
diff --git a/src/app/api/zxdb/languages/[id]/route.ts b/src/app/api/zxdb/languages/[id]/route.ts
index 8fb9c74..a67565d 100644
--- a/src/app/api/zxdb/languages/[id]/route.ts
+++ b/src/app/api/zxdb/languages/[id]/route.ts
@@ -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 });
}
diff --git a/src/app/api/zxdb/languages/route.ts b/src/app/api/zxdb/languages/route.ts
index 0fce378..e1b47db 100644
--- a/src/app/api/zxdb/languages/route.ts
+++ b/src/app/api/zxdb/languages/route.ts
@@ -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",
+ },
});
}
diff --git a/src/app/api/zxdb/machinetypes/[id]/route.ts b/src/app/api/zxdb/machinetypes/[id]/route.ts
index 940ad2c..3d6803e 100644
--- a/src/app/api/zxdb/machinetypes/[id]/route.ts
+++ b/src/app/api/zxdb/machinetypes/[id]/route.ts
@@ -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 });
}
diff --git a/src/app/api/zxdb/machinetypes/route.ts b/src/app/api/zxdb/machinetypes/route.ts
index a559891..5800936 100644
--- a/src/app/api/zxdb/machinetypes/route.ts
+++ b/src/app/api/zxdb/machinetypes/route.ts
@@ -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",
+ },
});
}
diff --git a/src/app/api/zxdb/search/route.ts b/src/app/api/zxdb/search/route.ts
index d9e0f16..cb40db7 100644
--- a/src/app/api/zxdb/search/route.ts
+++ b/src/app/api/zxdb/search/route.ts
@@ -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" },
});
}
diff --git a/src/app/zxdb/ZxdbExplorer.tsx b/src/app/zxdb/ZxdbExplorer.tsx
index 49ac9f3..1ffcf54 100644
--- a/src/app/zxdb/ZxdbExplorer.tsx
+++ b/src/app/zxdb/ZxdbExplorer.tsx
@@ -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() {
| {it.id} |
- {it.title}
+ {it.title}
|
{it.machinetypeId ?? "-"} |
{it.languageId ?? "-"} |
@@ -190,10 +191,10 @@ export default function ZxdbExplorer() {
);
diff --git a/src/app/zxdb/entries/[id]/EntryDetail.tsx b/src/app/zxdb/entries/[id]/EntryDetail.tsx
index 62252f9..fde2991 100644
--- a/src/app/zxdb/entries/[id]/EntryDetail.tsx
+++ b/src/app/zxdb/entries/[id]/EntryDetail.tsx
@@ -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(null);
- const [loading, setLoading] = useState(true);
- const [error, setError] = useState(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 Loading…
;
- if (error) return {error}
;
+export default function EntryDetailClient({ data }: { data: EntryDetailData }) {
if (!data) return Not found
;
return (
@@ -50,19 +22,19 @@ export default function EntryDetailClient({ id }: { id: number }) {
@@ -77,7 +49,7 @@ export default function EntryDetailClient({ id }: { id: number }) {
{data.authors.map((a) => (
-
- {a.name}
+ {a.name}
))}
@@ -90,7 +62,7 @@ export default function EntryDetailClient({ id }: { id: number }) {
{data.publishers.map((p) => (
-
- {p.name}
+ {p.name}
))}
@@ -101,8 +73,8 @@ export default function EntryDetailClient({ id }: { id: number }) {
);
diff --git a/src/app/zxdb/entries/[id]/page.tsx b/src/app/zxdb/entries/[id]/page.tsx
index 9eadf23..926cdea 100644
--- a/src/app/zxdb/entries/[id]/page.tsx
+++ b/src/app/zxdb/entries/[id]/page.tsx
@@ -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 ;
+ const numericId = Number(id);
+ const data = await getEntryById(numericId);
+ // For simplicity, let the client render a Not Found state if null
+ return ;
}
diff --git a/src/app/zxdb/genres/GenreList.tsx b/src/app/zxdb/genres/GenreList.tsx
index 4e1c706..9af3383 100644
--- a/src/app/zxdb/genres/GenreList.tsx
+++ b/src/app/zxdb/genres/GenreList.tsx
@@ -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() {
{items.map((g) => (
-
- {g.name}
+ {g.name}
#{g.id}
))}
diff --git a/src/app/zxdb/genres/[id]/GenreDetail.tsx b/src/app/zxdb/genres/[id]/GenreDetail.tsx
index 8059480..7cebae1 100644
--- a/src/app/zxdb/genres/[id]/GenreDetail.tsx
+++ b/src/app/zxdb/genres/[id]/GenreDetail.tsx
@@ -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 = { items: T[]; page: number; pageSize: number; total: number };
-export default function GenreDetailClient({ id }: { id: number }) {
- const [data, setData] = useState | 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- ;
- 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
- }) {
+ const [data] = useState>(initial);
+ const [page] = useState(1);
+ const totalPages = useMemo(() => Math.max(1, Math.ceil(data.total / data.pageSize)), [data]);
return (
Genre #{id}
- {loading &&
Loading…
}
- {data && data.items.length === 0 && !loading &&
No entries.
}
+ {data && data.items.length === 0 &&
No entries.
}
{data && data.items.length > 0 && (
@@ -48,7 +30,7 @@ export default function GenreDetailClient({ id }: { id: number }) {
{data.items.map((it) => (
| {it.id} |
- {it.title} |
+ {it.title} |
{it.machinetypeId ?? "-"} |
{it.languageId ?? "-"} |
@@ -59,9 +41,7 @@ export default function GenreDetailClient({ id }: { id: number }) {
)}
-
- Page {data?.page ?? page} / {totalPages}
-
+ Page {data.page} / {totalPages}
);
diff --git a/src/app/zxdb/genres/[id]/page.tsx b/src/app/zxdb/genres/[id]/page.tsx
index 4bbb929..075f5e8 100644
--- a/src/app/zxdb/genres/[id]/page.tsx
+++ b/src/app/zxdb/genres/[id]/page.tsx
@@ -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 ;
+ const numericId = Number(id);
+ const initial = await entriesByGenre(numericId, 1, 20);
+ return ;
}
diff --git a/src/app/zxdb/labels/LabelsSearch.tsx b/src/app/zxdb/labels/LabelsSearch.tsx
index 06cc402..f496066 100644
--- a/src/app/zxdb/labels/LabelsSearch.tsx
+++ b/src/app/zxdb/labels/LabelsSearch.tsx
@@ -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 = { items: T[]; page: number; pageSize: number; total: number };
@@ -60,7 +61,7 @@ export default function LabelsSearch() {
{data.items.map((l) => (
-
- {l.name}
+ {l.name}
{l.labeltypeId ?? "?"}
))}
diff --git a/src/app/zxdb/labels/[id]/LabelDetail.tsx b/src/app/zxdb/labels/[id]/LabelDetail.tsx
index 4f1d3f8..f618150 100644
--- a/src/app/zxdb/labels/[id]/LabelDetail.tsx
+++ b/src/app/zxdb/labels/[id]/LabelDetail.tsx
@@ -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 = { items: T[]; page: number; pageSize: number; total: number };
-type Payload = { label: Label; authored: Paged- ; published: Paged
- };
+type Payload = { label: Label | null; authored: Paged
- ; published: Paged
- };
-export default function LabelDetailClient({ id }: { id: number }) {
- const [data, setData] = useState(null);
+export default function LabelDetailClient({ id, initial }: { id: number; initial: Payload }) {
+ const [data] = useState(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
Not found
;
- 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 {loading ? "Loading…" : "Not found"}
;
+ 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 (
@@ -55,8 +38,7 @@ export default function LabelDetailClient({ id }: { id: number }) {
- {loading &&
Loading…
}
- {current && current.items.length === 0 && !loading &&
No entries.
}
+ {current && current.items.length === 0 &&
No entries.
}
{current && current.items.length > 0 && (
@@ -72,7 +54,7 @@ export default function LabelDetailClient({ id }: { id: number }) {
{current.items.map((it) => (
| {it.id} |
- {it.title} |
+ {it.title} |
{it.machinetypeId ?? "-"} |
{it.languageId ?? "-"} |
@@ -84,9 +66,7 @@ export default function LabelDetailClient({ id }: { id: number }) {
-
Page {page} / {totalPages}
-
);
diff --git a/src/app/zxdb/labels/[id]/page.tsx b/src/app/zxdb/labels/[id]/page.tsx
index 3ffaaa8..db53f8f 100644
--- a/src/app/zxdb/labels/[id]/page.tsx
+++ b/src/app/zxdb/labels/[id]/page.tsx
@@ -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 ;
+ 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 ;
}
diff --git a/src/app/zxdb/languages/LanguageList.tsx b/src/app/zxdb/languages/LanguageList.tsx
index 8ac36cf..99e6237 100644
--- a/src/app/zxdb/languages/LanguageList.tsx
+++ b/src/app/zxdb/languages/LanguageList.tsx
@@ -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() {
{items.map((l) => (
-
- {l.name}
+ {l.name}
{l.id}
))}
diff --git a/src/app/zxdb/languages/[id]/LanguageDetail.tsx b/src/app/zxdb/languages/[id]/LanguageDetail.tsx
index 7612210..7149905 100644
--- a/src/app/zxdb/languages/[id]/LanguageDetail.tsx
+++ b/src/app/zxdb/languages/[id]/LanguageDetail.tsx
@@ -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 = { items: T[]; page: number; pageSize: number; total: number };
-export default function LanguageDetailClient({ id }: { id: string }) {
- const [data, setData] = useState | 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- ;
- 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
- }) {
+ const [data] = useState>(initial);
+ const totalPages = useMemo(() => Math.max(1, Math.ceil(data.total / data.pageSize)), [data]);
return (
Language {id}
- {loading &&
Loading…
}
- {data && data.items.length === 0 && !loading &&
No entries.
}
+ {data && data.items.length === 0 &&
No entries.
}
{data && data.items.length > 0 && (
@@ -48,7 +29,7 @@ export default function LanguageDetailClient({ id }: { id: string }) {
{data.items.map((it) => (
| {it.id} |
- {it.title} |
+ {it.title} |
{it.machinetypeId ?? "-"} |
{it.languageId ?? "-"} |
@@ -59,9 +40,7 @@ export default function LanguageDetailClient({ id }: { id: string }) {
)}
-
- Page {data?.page ?? page} / {totalPages}
-
+ Page {data.page} / {totalPages}
);
diff --git a/src/app/zxdb/languages/[id]/page.tsx b/src/app/zxdb/languages/[id]/page.tsx
index fd81c6b..25ebc5f 100644
--- a/src/app/zxdb/languages/[id]/page.tsx
+++ b/src/app/zxdb/languages/[id]/page.tsx
@@ -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 ;
+ const initial = await entriesByLanguage(id, 1, 20);
+ return ;
}
diff --git a/src/app/zxdb/machinetypes/[id]/MachineTypeDetail.tsx b/src/app/zxdb/machinetypes/[id]/MachineTypeDetail.tsx
index f420b29..8399785 100644
--- a/src/app/zxdb/machinetypes/[id]/MachineTypeDetail.tsx
+++ b/src/app/zxdb/machinetypes/[id]/MachineTypeDetail.tsx
@@ -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 = { items: T[]; page: number; pageSize: number; total: number };
-export default function MachineTypeDetailClient({ id }: { id: number }) {
- const [data, setData] = useState | 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- ;
- 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
- }) {
+ const [data] = useState>(initial);
+ const totalPages = useMemo(() => Math.max(1, Math.ceil(data.total / data.pageSize)), [data]);
return (
Machine Type #{id}
- {loading &&
Loading…
}
- {data && data.items.length === 0 && !loading &&
No entries.
}
+ {data && data.items.length === 0 &&
No entries.
}
{data && data.items.length > 0 && (
@@ -48,7 +29,7 @@ export default function MachineTypeDetailClient({ id }: { id: number }) {
{data.items.map((it) => (
| {it.id} |
- {it.title} |
+ {it.title} |
{it.machinetypeId ?? "-"} |
{it.languageId ?? "-"} |
@@ -59,9 +40,7 @@ export default function MachineTypeDetailClient({ id }: { id: number }) {
)}
-
- Page {data?.page ?? page} / {totalPages}
-
+ Page {data.page} / {totalPages}
);
diff --git a/src/app/zxdb/machinetypes/[id]/page.tsx b/src/app/zxdb/machinetypes/[id]/page.tsx
new file mode 100644
index 0000000..86c9e5a
--- /dev/null
+++ b/src/app/zxdb/machinetypes/[id]/page.tsx
@@ -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 ;
+}
diff --git a/src/app/zxdb/page.tsx b/src/app/zxdb/page.tsx
index b79cb68..38adb55 100644
--- a/src/app/zxdb/page.tsx
+++ b/src/app/zxdb/page.tsx
@@ -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 ;
+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 ;
}
diff --git a/src/server/repo/zxdb.ts b/src/server/repo/zxdb.ts
index a9e9333..fad73e6 100644
--- a/src/server/repo/zxdb.ts
+++ b/src/server/repo/zxdb.ts
@@ -38,6 +38,18 @@ export interface PagedResult {
total: number;
}
+export interface FacetItem {
+ id: T;
+ name: string;
+ count: number;
+}
+
+export interface EntryFacets {
+ genres: FacetItem[];
+ languages: FacetItem[];
+ machinetypes: FacetItem[];
+}
+
export async function searchEntries(params: SearchParams): Promise> {
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 {
- // 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 {
+ 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),
+ };
+}