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
|
- Do not create new branches
|
||||||
- git commits:
|
- git commits:
|
||||||
- Create COMMIT_EDITMSG file, await any user edits, then commit using that
|
- Create COMMIT_EDITMSG file, await any user edits, then commit using that
|
||||||
commit note, and then delete the COMMIT_EDITMSG file.
|
commit note, and then delete the COMMIT_EDITMSG file. Remember to keep
|
||||||
|
the first line as the subject <50char
|
||||||
- git commit messages:
|
- git commit messages:
|
||||||
- Use imperative mood (e.g., "Add feature X", "Fix bug Y").
|
- Use imperative mood (e.g., "Add feature X", "Fix bug Y").
|
||||||
- Include relevant issue numbers if applicable.
|
- Include relevant issue numbers if applicable.
|
||||||
|
|||||||
@@ -1,32 +1,16 @@
|
|||||||
fix: await dynamic route params (Next 15) and correct ZXDB lookup column names
|
chore: commit pending ZXDB explorer changes prior to index perf work
|
||||||
|
|
||||||
Update dynamic Server Component pages to the Next.js 15+ async `params` API,
|
Context
|
||||||
and fix ZXDB lookup table schema to use `text` column (not `name`) to avoid
|
- Housekeeping commit to capture all current ZXDB Explorer work before index-page performance optimizations.
|
||||||
ER_BAD_FIELD_ERROR in entry detail endpoint.
|
|
||||||
This resolves the runtime warning/error:
|
|
||||||
"params should be awaited before using its properties" and prevents
|
|
||||||
sync-dynamic-apis violations when visiting deep ZXDB permalinks.
|
|
||||||
|
|
||||||
Changes
|
Includes
|
||||||
- /zxdb/entries/[id]/page.tsx: make Page async and `await params`, pass numeric id
|
- Server-rendered entry detail page with ISR and parallelized DB queries.
|
||||||
- /zxdb/labels/[id]/page.tsx: make Page async and `await params`, pass numeric id
|
- Node runtime for ZXDB API routes and params validation updates for Next 15.
|
||||||
- /zxdb/genres/[id]/page.tsx: make Page async and `await params`, pass numeric id
|
- ZXDB repository extensions (facets, label queries, category queries).
|
||||||
- /zxdb/languages/[id]/page.tsx: make Page async and `await params`, pass string id
|
- Cross-linking and Link-based prefetch across ZXDB UI.
|
||||||
- /registers/[hex]/page.tsx: make Page async and `await params`, decode hex safely
|
- Cache headers on low-churn list APIs.
|
||||||
- /api/zxdb/entries/[id]/route.ts: await `ctx.params` before validation
|
|
||||||
- src/server/schema/zxdb.ts: map `languages.text`, `machinetypes.text`,
|
|
||||||
and `genretypes.text` to `name` fields in Drizzle models
|
|
||||||
|
|
||||||
Why
|
|
||||||
- Next.js 15 changed dynamic route APIs such that `params` is now a Promise
|
|
||||||
in Server Components and must be awaited before property access.
|
|
||||||
- ZXDB schema defines display columns as `text` (not `name`) for languages,
|
|
||||||
machinetypes, and genretypes. Using `name` caused MySQL 1054 errors. The
|
|
||||||
Drizzle models now point to the correct columns while preserving `{ id, name }`
|
|
||||||
in our API/UI contracts.
|
|
||||||
|
|
||||||
Notes
|
Notes
|
||||||
- API route handlers under /api continue to use ctx.params synchronously; this
|
- Follow-up commit will focus specifically on speeding up index pages via SSR initial data and ISR.
|
||||||
change only affects App Router Page components.
|
|
||||||
|
|
||||||
Signed-off-by: Junie@lucy.xalior.com
|
Signed-off-by: Junie@lucy.xalior.com
|
||||||
@@ -22,7 +22,11 @@ export async function GET(_req: NextRequest, ctx: { params: Promise<{ id: string
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
return new Response(JSON.stringify(detail), {
|
return new Response(JSON.stringify(detail), {
|
||||||
headers: { "content-type": "application/json", "cache-control": "no-store" },
|
headers: {
|
||||||
|
"content-type": "application/json",
|
||||||
|
// Cache for 1h on CDN, allow stale while revalidating for a day
|
||||||
|
"cache-control": "public, max-age=0, s-maxage=3600, stale-while-revalidate=86400",
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,8 +8,9 @@ const querySchema = z.object({
|
|||||||
pageSize: z.coerce.number().int().positive().max(100).optional(),
|
pageSize: z.coerce.number().int().positive().max(100).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export async function GET(req: NextRequest, ctx: { params: { id: string } }) {
|
export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string }> }) {
|
||||||
const p = paramsSchema.safeParse(ctx.params);
|
const raw = await ctx.params;
|
||||||
|
const p = paramsSchema.safeParse(raw);
|
||||||
if (!p.success) {
|
if (!p.success) {
|
||||||
return new Response(JSON.stringify({ error: p.error.flatten() }), { status: 400 });
|
return new Response(JSON.stringify({ error: p.error.flatten() }), { status: 400 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,10 @@ import { listGenres } from "@/server/repo/zxdb";
|
|||||||
export async function GET() {
|
export async function GET() {
|
||||||
const data = await listGenres();
|
const data = await listGenres();
|
||||||
return new Response(JSON.stringify({ items: data }), {
|
return new Response(JSON.stringify({ items: data }), {
|
||||||
headers: { "content-type": "application/json", "cache-control": "no-store" },
|
headers: {
|
||||||
|
"content-type": "application/json",
|
||||||
|
"cache-control": "public, max-age=0, s-maxage=3600, stale-while-revalidate=86400",
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,8 +8,9 @@ const querySchema = z.object({
|
|||||||
pageSize: z.coerce.number().int().positive().max(100).optional(),
|
pageSize: z.coerce.number().int().positive().max(100).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export async function GET(req: NextRequest, ctx: { params: { id: string } }) {
|
export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string }> }) {
|
||||||
const p = paramsSchema.safeParse(ctx.params);
|
const raw = await ctx.params;
|
||||||
|
const p = paramsSchema.safeParse(raw);
|
||||||
if (!p.success) {
|
if (!p.success) {
|
||||||
return new Response(JSON.stringify({ error: p.error.flatten() }), {
|
return new Response(JSON.stringify({ error: p.error.flatten() }), {
|
||||||
status: 400,
|
status: 400,
|
||||||
|
|||||||
@@ -8,8 +8,9 @@ const querySchema = z.object({
|
|||||||
pageSize: z.coerce.number().int().positive().max(100).optional(),
|
pageSize: z.coerce.number().int().positive().max(100).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export async function GET(req: NextRequest, ctx: { params: { id: string } }) {
|
export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string }> }) {
|
||||||
const p = paramsSchema.safeParse(ctx.params);
|
const raw = await ctx.params;
|
||||||
|
const p = paramsSchema.safeParse(raw);
|
||||||
if (!p.success) {
|
if (!p.success) {
|
||||||
return new Response(JSON.stringify({ error: p.error.flatten() }), { status: 400 });
|
return new Response(JSON.stringify({ error: p.error.flatten() }), { status: 400 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,10 @@ import { listLanguages } from "@/server/repo/zxdb";
|
|||||||
export async function GET() {
|
export async function GET() {
|
||||||
const data = await listLanguages();
|
const data = await listLanguages();
|
||||||
return new Response(JSON.stringify({ items: data }), {
|
return new Response(JSON.stringify({ items: data }), {
|
||||||
headers: { "content-type": "application/json", "cache-control": "no-store" },
|
headers: {
|
||||||
|
"content-type": "application/json",
|
||||||
|
"cache-control": "public, max-age=0, s-maxage=3600, stale-while-revalidate=86400",
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -8,8 +8,9 @@ const querySchema = z.object({
|
|||||||
pageSize: z.coerce.number().int().positive().max(100).optional(),
|
pageSize: z.coerce.number().int().positive().max(100).optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export async function GET(req: NextRequest, ctx: { params: { id: string } }) {
|
export async function GET(req: NextRequest, ctx: { params: Promise<{ id: string }> }) {
|
||||||
const p = paramsSchema.safeParse(ctx.params);
|
const raw = await ctx.params;
|
||||||
|
const p = paramsSchema.safeParse(raw);
|
||||||
if (!p.success) {
|
if (!p.success) {
|
||||||
return new Response(JSON.stringify({ error: p.error.flatten() }), { status: 400 });
|
return new Response(JSON.stringify({ error: p.error.flatten() }), { status: 400 });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,10 @@ import { listMachinetypes } from "@/server/repo/zxdb";
|
|||||||
export async function GET() {
|
export async function GET() {
|
||||||
const data = await listMachinetypes();
|
const data = await listMachinetypes();
|
||||||
return new Response(JSON.stringify({ items: data }), {
|
return new Response(JSON.stringify({ items: data }), {
|
||||||
headers: { "content-type": "application/json", "cache-control": "no-store" },
|
headers: {
|
||||||
|
"content-type": "application/json",
|
||||||
|
"cache-control": "public, max-age=0, s-maxage=3600, stale-while-revalidate=86400",
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { NextRequest } from "next/server";
|
import { NextRequest } from "next/server";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { searchEntries } from "@/server/repo/zxdb";
|
import { searchEntries, getEntryFacets } from "@/server/repo/zxdb";
|
||||||
|
|
||||||
const querySchema = z.object({
|
const querySchema = z.object({
|
||||||
q: z.string().optional(),
|
q: z.string().optional(),
|
||||||
@@ -14,6 +14,7 @@ const querySchema = z.object({
|
|||||||
.optional(),
|
.optional(),
|
||||||
machinetypeId: z.coerce.number().int().positive().optional(),
|
machinetypeId: z.coerce.number().int().positive().optional(),
|
||||||
sort: z.enum(["title", "id_desc"]).optional(),
|
sort: z.enum(["title", "id_desc"]).optional(),
|
||||||
|
facets: z.coerce.boolean().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export async function GET(req: NextRequest) {
|
export async function GET(req: NextRequest) {
|
||||||
@@ -26,6 +27,7 @@ export async function GET(req: NextRequest) {
|
|||||||
languageId: searchParams.get("languageId") ?? undefined,
|
languageId: searchParams.get("languageId") ?? undefined,
|
||||||
machinetypeId: searchParams.get("machinetypeId") ?? undefined,
|
machinetypeId: searchParams.get("machinetypeId") ?? undefined,
|
||||||
sort: searchParams.get("sort") ?? undefined,
|
sort: searchParams.get("sort") ?? undefined,
|
||||||
|
facets: searchParams.get("facets") ?? undefined,
|
||||||
});
|
});
|
||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
return new Response(
|
return new Response(
|
||||||
@@ -34,7 +36,10 @@ export async function GET(req: NextRequest) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
const data = await searchEntries(parsed.data);
|
const data = await searchEntries(parsed.data);
|
||||||
return new Response(JSON.stringify(data), {
|
const body = parsed.data.facets
|
||||||
|
? { ...data, facets: await getEntryFacets(parsed.data) }
|
||||||
|
: data;
|
||||||
|
return new Response(JSON.stringify(body), {
|
||||||
headers: { "content-type": "application/json" },
|
headers: { "content-type": "application/json" },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
type Item = {
|
type Item = {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -156,7 +157,7 @@ export default function ZxdbExplorer() {
|
|||||||
<tr key={it.id}>
|
<tr key={it.id}>
|
||||||
<td>{it.id}</td>
|
<td>{it.id}</td>
|
||||||
<td>
|
<td>
|
||||||
<a href={`/zxdb/entries/${it.id}`}>{it.title}</a>
|
<Link href={`/zxdb/entries/${it.id}`}>{it.title}</Link>
|
||||||
</td>
|
</td>
|
||||||
<td>{it.machinetypeId ?? "-"}</td>
|
<td>{it.machinetypeId ?? "-"}</td>
|
||||||
<td>{it.languageId ?? "-"}</td>
|
<td>{it.languageId ?? "-"}</td>
|
||||||
@@ -190,10 +191,10 @@ export default function ZxdbExplorer() {
|
|||||||
|
|
||||||
<hr />
|
<hr />
|
||||||
<div className="d-flex flex-wrap gap-2">
|
<div className="d-flex flex-wrap gap-2">
|
||||||
<a className="btn btn-sm btn-outline-secondary" href="/zxdb/labels">Browse Labels</a>
|
<Link className="btn btn-sm btn-outline-secondary" href="/zxdb/labels">Browse Labels</Link>
|
||||||
<a className="btn btn-sm btn-outline-secondary" href="/zxdb/genres">Browse Genres</a>
|
<Link className="btn btn-sm btn-outline-secondary" href="/zxdb/genres">Browse Genres</Link>
|
||||||
<a className="btn btn-sm btn-outline-secondary" href="/zxdb/languages">Browse Languages</a>
|
<Link className="btn btn-sm btn-outline-secondary" href="/zxdb/languages">Browse Languages</Link>
|
||||||
<a className="btn btn-sm btn-outline-secondary" href="/zxdb/machinetypes">Browse Machines</a>
|
<Link className="btn btn-sm btn-outline-secondary" href="/zxdb/machinetypes">Browse Machines</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import Link from "next/link";
|
||||||
|
|
||||||
type Label = { id: number; name: string; labeltypeId: string | null };
|
type Label = { id: number; name: string; labeltypeId: string | null };
|
||||||
type EntryDetail = {
|
export type EntryDetailData = {
|
||||||
id: number;
|
id: number;
|
||||||
title: string;
|
title: string;
|
||||||
isXrated: number;
|
isXrated: number;
|
||||||
@@ -14,35 +14,7 @@ type EntryDetail = {
|
|||||||
publishers: Label[];
|
publishers: Label[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function EntryDetailClient({ id }: { id: number }) {
|
export default function EntryDetailClient({ data }: { data: EntryDetailData }) {
|
||||||
const [data, setData] = useState<EntryDetail | null>(null);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let aborted = false;
|
|
||||||
async function run() {
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
try {
|
|
||||||
const res = await fetch(`/api/zxdb/entries/${id}`, { cache: "no-store" });
|
|
||||||
if (!res.ok) throw new Error(`Failed: ${res.status}`);
|
|
||||||
const json: EntryDetail = await res.json();
|
|
||||||
if (!aborted) setData(json);
|
|
||||||
} catch (e: any) {
|
|
||||||
if (!aborted) setError(e?.message ?? "Failed to load");
|
|
||||||
} finally {
|
|
||||||
if (!aborted) setLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
run();
|
|
||||||
return () => {
|
|
||||||
aborted = true;
|
|
||||||
};
|
|
||||||
}, [id]);
|
|
||||||
|
|
||||||
if (loading) return <div>Loading…</div>;
|
|
||||||
if (error) return <div className="alert alert-danger">{error}</div>;
|
|
||||||
if (!data) return <div className="alert alert-warning">Not found</div>;
|
if (!data) return <div className="alert alert-warning">Not found</div>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -50,19 +22,19 @@ export default function EntryDetailClient({ id }: { id: number }) {
|
|||||||
<div className="d-flex align-items-center gap-2 flex-wrap">
|
<div className="d-flex align-items-center gap-2 flex-wrap">
|
||||||
<h1 className="mb-0">{data.title}</h1>
|
<h1 className="mb-0">{data.title}</h1>
|
||||||
{data.genre.name && (
|
{data.genre.name && (
|
||||||
<a className="badge text-bg-secondary text-decoration-none" href={`/zxdb/genres/${data.genre.id}`}>
|
<Link className="badge text-bg-secondary text-decoration-none" href={`/zxdb/genres/${data.genre.id}`}>
|
||||||
{data.genre.name}
|
{data.genre.name}
|
||||||
</a>
|
</Link>
|
||||||
)}
|
)}
|
||||||
{data.language.name && (
|
{data.language.name && (
|
||||||
<a className="badge text-bg-info text-decoration-none" href={`/zxdb/languages/${data.language.id}`}>
|
<Link className="badge text-bg-info text-decoration-none" href={`/zxdb/languages/${data.language.id}`}>
|
||||||
{data.language.name}
|
{data.language.name}
|
||||||
</a>
|
</Link>
|
||||||
)}
|
)}
|
||||||
{data.machinetype.name && (
|
{data.machinetype.name && (
|
||||||
<a className="badge text-bg-primary text-decoration-none" href={`/zxdb/machinetypes/${data.machinetype.id}`}>
|
<Link className="badge text-bg-primary text-decoration-none" href={`/zxdb/machinetypes/${data.machinetype.id}`}>
|
||||||
{data.machinetype.name}
|
{data.machinetype.name}
|
||||||
</a>
|
</Link>
|
||||||
)}
|
)}
|
||||||
{data.isXrated ? <span className="badge text-bg-danger">18+</span> : null}
|
{data.isXrated ? <span className="badge text-bg-danger">18+</span> : null}
|
||||||
</div>
|
</div>
|
||||||
@@ -77,7 +49,7 @@ export default function EntryDetailClient({ id }: { id: number }) {
|
|||||||
<ul className="list-unstyled mb-0">
|
<ul className="list-unstyled mb-0">
|
||||||
{data.authors.map((a) => (
|
{data.authors.map((a) => (
|
||||||
<li key={a.id}>
|
<li key={a.id}>
|
||||||
<a href={`/zxdb/labels/${a.id}`}>{a.name}</a>
|
<Link href={`/zxdb/labels/${a.id}`}>{a.name}</Link>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
@@ -90,7 +62,7 @@ export default function EntryDetailClient({ id }: { id: number }) {
|
|||||||
<ul className="list-unstyled mb-0">
|
<ul className="list-unstyled mb-0">
|
||||||
{data.publishers.map((p) => (
|
{data.publishers.map((p) => (
|
||||||
<li key={p.id}>
|
<li key={p.id}>
|
||||||
<a href={`/zxdb/labels/${p.id}`}>{p.name}</a>
|
<Link href={`/zxdb/labels/${p.id}`}>{p.name}</Link>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
@@ -101,8 +73,8 @@ export default function EntryDetailClient({ id }: { id: number }) {
|
|||||||
<hr />
|
<hr />
|
||||||
|
|
||||||
<div className="d-flex align-items-center gap-2">
|
<div className="d-flex align-items-center gap-2">
|
||||||
<a className="btn btn-sm btn-outline-secondary" href={`/zxdb/entries/${data.id}`}>Permalink</a>
|
<Link className="btn btn-sm btn-outline-secondary" href={`/zxdb/entries/${data.id}`}>Permalink</Link>
|
||||||
<a className="btn btn-sm btn-outline-primary" href="/zxdb">Back to Explorer</a>
|
<Link className="btn btn-sm btn-outline-primary" href="/zxdb">Back to Explorer</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
import EntryDetailClient from "./EntryDetail";
|
import EntryDetailClient from "./EntryDetail";
|
||||||
|
import { getEntryById } from "@/server/repo/zxdb";
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
title: "ZXDB Entry",
|
title: "ZXDB Entry",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const revalidate = 3600;
|
||||||
|
|
||||||
export default async function Page({ params }: { params: Promise<{ id: string }> }) {
|
export default async function Page({ params }: { params: Promise<{ id: string }> }) {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
return <EntryDetailClient id={Number(id)} />;
|
const numericId = Number(id);
|
||||||
|
const data = await getEntryById(numericId);
|
||||||
|
// For simplicity, let the client render a Not Found state if null
|
||||||
|
return <EntryDetailClient data={data as any} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
type Genre = { id: number; name: string };
|
type Genre = { id: number; name: string };
|
||||||
|
|
||||||
@@ -27,7 +28,7 @@ export default function GenreList() {
|
|||||||
<ul className="list-group">
|
<ul className="list-group">
|
||||||
{items.map((g) => (
|
{items.map((g) => (
|
||||||
<li key={g.id} className="list-group-item d-flex justify-content-between align-items-center">
|
<li key={g.id} className="list-group-item d-flex justify-content-between align-items-center">
|
||||||
<a href={`/zxdb/genres/${g.id}`}>{g.name}</a>
|
<Link href={`/zxdb/genres/${g.id}`}>{g.name}</Link>
|
||||||
<span className="badge text-bg-light">#{g.id}</span>
|
<span className="badge text-bg-light">#{g.id}</span>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,38 +1,20 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import Link from "next/link";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
|
||||||
type Item = { id: number; title: string; isXrated: number; machinetypeId: number | null; languageId: string | null };
|
type Item = { id: number; title: string; isXrated: number; machinetypeId: number | null; languageId: string | null };
|
||||||
type Paged<T> = { items: T[]; page: number; pageSize: number; total: number };
|
type Paged<T> = { items: T[]; page: number; pageSize: number; total: number };
|
||||||
|
|
||||||
export default function GenreDetailClient({ id }: { id: number }) {
|
export default function GenreDetailClient({ id, initial }: { id: number; initial: Paged<Item> }) {
|
||||||
const [data, setData] = useState<Paged<Item> | null>(null);
|
const [data] = useState<Paged<Item>>(initial);
|
||||||
const [loading, setLoading] = useState(false);
|
const [page] = useState(1);
|
||||||
const [page, setPage] = useState(1);
|
const totalPages = useMemo(() => Math.max(1, Math.ceil(data.total / data.pageSize)), [data]);
|
||||||
const pageSize = 20;
|
|
||||||
const totalPages = useMemo(() => (data ? Math.max(1, Math.ceil(data.total / data.pageSize)) : 1), [data]);
|
|
||||||
|
|
||||||
async function load(p: number) {
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
const res = await fetch(`/api/zxdb/genres/${id}?page=${p}&pageSize=${pageSize}`, { cache: "no-store" });
|
|
||||||
const json = (await res.json()) as Paged<Item>;
|
|
||||||
setData(json);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
load(page);
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [page]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container">
|
<div className="container">
|
||||||
<h1>Genre #{id}</h1>
|
<h1>Genre #{id}</h1>
|
||||||
{loading && <div>Loading…</div>}
|
{data && data.items.length === 0 && <div className="alert alert-warning">No entries.</div>}
|
||||||
{data && data.items.length === 0 && !loading && <div className="alert alert-warning">No entries.</div>}
|
|
||||||
{data && data.items.length > 0 && (
|
{data && data.items.length > 0 && (
|
||||||
<div className="table-responsive">
|
<div className="table-responsive">
|
||||||
<table className="table table-striped table-hover align-middle">
|
<table className="table table-striped table-hover align-middle">
|
||||||
@@ -48,7 +30,7 @@ export default function GenreDetailClient({ id }: { id: number }) {
|
|||||||
{data.items.map((it) => (
|
{data.items.map((it) => (
|
||||||
<tr key={it.id}>
|
<tr key={it.id}>
|
||||||
<td>{it.id}</td>
|
<td>{it.id}</td>
|
||||||
<td><a href={`/zxdb/entries/${it.id}`}>{it.title}</a></td>
|
<td><Link href={`/zxdb/entries/${it.id}`}>{it.title}</Link></td>
|
||||||
<td>{it.machinetypeId ?? "-"}</td>
|
<td>{it.machinetypeId ?? "-"}</td>
|
||||||
<td>{it.languageId ?? "-"}</td>
|
<td>{it.languageId ?? "-"}</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -59,9 +41,7 @@ export default function GenreDetailClient({ id }: { id: number }) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="d-flex align-items-center gap-2 mt-2">
|
<div className="d-flex align-items-center gap-2 mt-2">
|
||||||
<button className="btn btn-outline-secondary" onClick={() => setPage((p) => Math.max(1, p - 1))} disabled={loading || page <= 1}>Prev</button>
|
<span>Page {data.page} / {totalPages}</span>
|
||||||
<span>Page {data?.page ?? page} / {totalPages}</span>
|
|
||||||
<button className="btn btn-outline-secondary" onClick={() => setPage((p) => p + 1)} disabled={loading || (data ? data.page >= totalPages : false)}>Next</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
import GenreDetailClient from "./GenreDetail";
|
import GenreDetailClient from "./GenreDetail";
|
||||||
|
import { entriesByGenre } from "@/server/repo/zxdb";
|
||||||
|
|
||||||
export const metadata = { title: "ZXDB Genre" };
|
export const metadata = { title: "ZXDB Genre" };
|
||||||
|
|
||||||
|
export const revalidate = 3600;
|
||||||
|
|
||||||
export default async function Page({ params }: { params: Promise<{ id: string }> }) {
|
export default async function Page({ params }: { params: Promise<{ id: string }> }) {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
return <GenreDetailClient id={Number(id)} />;
|
const numericId = Number(id);
|
||||||
|
const initial = await entriesByGenre(numericId, 1, 20);
|
||||||
|
return <GenreDetailClient id={numericId} initial={initial as any} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
type Label = { id: number; name: string; labeltypeId: string | null };
|
type Label = { id: number; name: string; labeltypeId: string | null };
|
||||||
type Paged<T> = { items: T[]; page: number; pageSize: number; total: number };
|
type Paged<T> = { items: T[]; page: number; pageSize: number; total: number };
|
||||||
@@ -60,7 +61,7 @@ export default function LabelsSearch() {
|
|||||||
<ul className="list-group">
|
<ul className="list-group">
|
||||||
{data.items.map((l) => (
|
{data.items.map((l) => (
|
||||||
<li key={l.id} className="list-group-item d-flex justify-content-between align-items-center">
|
<li key={l.id} className="list-group-item d-flex justify-content-between align-items-center">
|
||||||
<a href={`/zxdb/labels/${l.id}`}>{l.name}</a>
|
<Link href={`/zxdb/labels/${l.id}`}>{l.name}</Link>
|
||||||
<span className="badge text-bg-light">{l.labeltypeId ?? "?"}</span>
|
<span className="badge text-bg-light">{l.labeltypeId ?? "?"}</span>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,40 +1,23 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import Link from "next/link";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
|
||||||
type Label = { id: number; name: string; labeltypeId: string | null };
|
type Label = { id: number; name: string; labeltypeId: string | null };
|
||||||
type Item = { id: number; title: string; isXrated: number; machinetypeId: number | null; languageId: string | null };
|
type Item = { id: number; title: string; isXrated: number; machinetypeId: number | null; languageId: string | null };
|
||||||
type Paged<T> = { items: T[]; page: number; pageSize: number; total: number };
|
type Paged<T> = { items: T[]; page: number; pageSize: number; total: number };
|
||||||
|
|
||||||
type Payload = { label: Label; authored: Paged<Item>; published: Paged<Item> };
|
type Payload = { label: Label | null; authored: Paged<Item>; published: Paged<Item> };
|
||||||
|
|
||||||
export default function LabelDetailClient({ id }: { id: number }) {
|
export default function LabelDetailClient({ id, initial }: { id: number; initial: Payload }) {
|
||||||
const [data, setData] = useState<Payload | null>(null);
|
const [data] = useState<Payload>(initial);
|
||||||
const [tab, setTab] = useState<"authored" | "published">("authored");
|
const [tab, setTab] = useState<"authored" | "published">("authored");
|
||||||
const [page, setPage] = useState(1);
|
const [page] = useState(1);
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
|
|
||||||
const current = useMemo(() => (data ? (tab === "authored" ? data.authored : data.published) : null), [data, tab]);
|
if (!data || !data.label) return <div className="alert alert-warning">Not found</div>;
|
||||||
const totalPages = useMemo(() => (current ? Math.max(1, Math.ceil(current.total / current.pageSize)) : 1), [current]);
|
|
||||||
|
|
||||||
async function load(p: number) {
|
const current = useMemo(() => (tab === "authored" ? data.authored : data.published), [data, tab]);
|
||||||
setLoading(true);
|
const totalPages = useMemo(() => Math.max(1, Math.ceil(current.total / current.pageSize)), [current]);
|
||||||
try {
|
|
||||||
const params = new URLSearchParams({ page: String(p), pageSize: "20" });
|
|
||||||
const res = await fetch(`/api/zxdb/labels/${id}?${params.toString()}`, { cache: "no-store" });
|
|
||||||
const json = (await res.json()) as Payload;
|
|
||||||
setData(json);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
load(page);
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [page]);
|
|
||||||
|
|
||||||
if (!data) return <div>{loading ? "Loading…" : "Not found"}</div>;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container">
|
<div className="container">
|
||||||
@@ -55,8 +38,7 @@ export default function LabelDetailClient({ id }: { id: number }) {
|
|||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<div className="mt-3">
|
<div className="mt-3">
|
||||||
{loading && <div>Loading…</div>}
|
{current && current.items.length === 0 && <div className="alert alert-warning">No entries.</div>}
|
||||||
{current && current.items.length === 0 && !loading && <div className="alert alert-warning">No entries.</div>}
|
|
||||||
{current && current.items.length > 0 && (
|
{current && current.items.length > 0 && (
|
||||||
<div className="table-responsive">
|
<div className="table-responsive">
|
||||||
<table className="table table-striped table-hover align-middle">
|
<table className="table table-striped table-hover align-middle">
|
||||||
@@ -72,7 +54,7 @@ export default function LabelDetailClient({ id }: { id: number }) {
|
|||||||
{current.items.map((it) => (
|
{current.items.map((it) => (
|
||||||
<tr key={it.id}>
|
<tr key={it.id}>
|
||||||
<td>{it.id}</td>
|
<td>{it.id}</td>
|
||||||
<td><a href={`/zxdb/entries/${it.id}`}>{it.title}</a></td>
|
<td><Link href={`/zxdb/entries/${it.id}`}>{it.title}</Link></td>
|
||||||
<td>{it.machinetypeId ?? "-"}</td>
|
<td>{it.machinetypeId ?? "-"}</td>
|
||||||
<td>{it.languageId ?? "-"}</td>
|
<td>{it.languageId ?? "-"}</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -84,9 +66,7 @@ export default function LabelDetailClient({ id }: { id: number }) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="d-flex align-items-center gap-2 mt-2">
|
<div className="d-flex align-items-center gap-2 mt-2">
|
||||||
<button className="btn btn-outline-secondary" onClick={() => setPage((p) => Math.max(1, p - 1))} disabled={loading || page <= 1}>Prev</button>
|
|
||||||
<span>Page {page} / {totalPages}</span>
|
<span>Page {page} / {totalPages}</span>
|
||||||
<button className="btn btn-outline-secondary" onClick={() => setPage((p) => p + 1)} disabled={loading || page >= totalPages}>Next</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,8 +1,19 @@
|
|||||||
import LabelDetailClient from "./LabelDetail";
|
import LabelDetailClient from "./LabelDetail";
|
||||||
|
import { getLabelById, getLabelAuthoredEntries, getLabelPublishedEntries } from "@/server/repo/zxdb";
|
||||||
|
|
||||||
export const metadata = { title: "ZXDB Label" };
|
export const metadata = { title: "ZXDB Label" };
|
||||||
|
|
||||||
|
export const revalidate = 3600;
|
||||||
|
|
||||||
export default async function Page({ params }: { params: Promise<{ id: string }> }) {
|
export default async function Page({ params }: { params: Promise<{ id: string }> }) {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
return <LabelDetailClient id={Number(id)} />;
|
const numericId = Number(id);
|
||||||
|
const [label, authored, published] = await Promise.all([
|
||||||
|
getLabelById(numericId),
|
||||||
|
getLabelAuthoredEntries(numericId, { page: 1, pageSize: 20 }),
|
||||||
|
getLabelPublishedEntries(numericId, { page: 1, pageSize: 20 }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Let the client component handle the "not found" simple state
|
||||||
|
return <LabelDetailClient id={numericId} initial={{ label: label as any, authored: authored as any, published: published as any }} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
type Language = { id: string; name: string };
|
type Language = { id: string; name: string };
|
||||||
|
|
||||||
@@ -27,7 +28,7 @@ export default function LanguageList() {
|
|||||||
<ul className="list-group">
|
<ul className="list-group">
|
||||||
{items.map((l) => (
|
{items.map((l) => (
|
||||||
<li key={l.id} className="list-group-item d-flex justify-content-between align-items-center">
|
<li key={l.id} className="list-group-item d-flex justify-content-between align-items-center">
|
||||||
<a href={`/zxdb/languages/${l.id}`}>{l.name}</a>
|
<Link href={`/zxdb/languages/${l.id}`}>{l.name}</Link>
|
||||||
<span className="badge text-bg-light">{l.id}</span>
|
<span className="badge text-bg-light">{l.id}</span>
|
||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,38 +1,19 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import Link from "next/link";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
|
||||||
type Item = { id: number; title: string; isXrated: number; machinetypeId: number | null; languageId: string | null };
|
type Item = { id: number; title: string; isXrated: number; machinetypeId: number | null; languageId: string | null };
|
||||||
type Paged<T> = { items: T[]; page: number; pageSize: number; total: number };
|
type Paged<T> = { items: T[]; page: number; pageSize: number; total: number };
|
||||||
|
|
||||||
export default function LanguageDetailClient({ id }: { id: string }) {
|
export default function LanguageDetailClient({ id, initial }: { id: string; initial: Paged<Item> }) {
|
||||||
const [data, setData] = useState<Paged<Item> | null>(null);
|
const [data] = useState<Paged<Item>>(initial);
|
||||||
const [loading, setLoading] = useState(false);
|
const totalPages = useMemo(() => Math.max(1, Math.ceil(data.total / data.pageSize)), [data]);
|
||||||
const [page, setPage] = useState(1);
|
|
||||||
const pageSize = 20;
|
|
||||||
const totalPages = useMemo(() => (data ? Math.max(1, Math.ceil(data.total / data.pageSize)) : 1), [data]);
|
|
||||||
|
|
||||||
async function load(p: number) {
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
const res = await fetch(`/api/zxdb/languages/${id}?page=${p}&pageSize=${pageSize}`, { cache: "no-store" });
|
|
||||||
const json = (await res.json()) as Paged<Item>;
|
|
||||||
setData(json);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
load(page);
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [page]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container">
|
<div className="container">
|
||||||
<h1>Language {id}</h1>
|
<h1>Language {id}</h1>
|
||||||
{loading && <div>Loading…</div>}
|
{data && data.items.length === 0 && <div className="alert alert-warning">No entries.</div>}
|
||||||
{data && data.items.length === 0 && !loading && <div className="alert alert-warning">No entries.</div>}
|
|
||||||
{data && data.items.length > 0 && (
|
{data && data.items.length > 0 && (
|
||||||
<div className="table-responsive">
|
<div className="table-responsive">
|
||||||
<table className="table table-striped table-hover align-middle">
|
<table className="table table-striped table-hover align-middle">
|
||||||
@@ -48,7 +29,7 @@ export default function LanguageDetailClient({ id }: { id: string }) {
|
|||||||
{data.items.map((it) => (
|
{data.items.map((it) => (
|
||||||
<tr key={it.id}>
|
<tr key={it.id}>
|
||||||
<td>{it.id}</td>
|
<td>{it.id}</td>
|
||||||
<td><a href={`/zxdb/entries/${it.id}`}>{it.title}</a></td>
|
<td><Link href={`/zxdb/entries/${it.id}`}>{it.title}</Link></td>
|
||||||
<td>{it.machinetypeId ?? "-"}</td>
|
<td>{it.machinetypeId ?? "-"}</td>
|
||||||
<td>{it.languageId ?? "-"}</td>
|
<td>{it.languageId ?? "-"}</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -59,9 +40,7 @@ export default function LanguageDetailClient({ id }: { id: string }) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="d-flex align-items-center gap-2 mt-2">
|
<div className="d-flex align-items-center gap-2 mt-2">
|
||||||
<button className="btn btn-outline-secondary" onClick={() => setPage((p) => Math.max(1, p - 1))} disabled={loading || page <= 1}>Prev</button>
|
<span>Page {data.page} / {totalPages}</span>
|
||||||
<span>Page {data?.page ?? page} / {totalPages}</span>
|
|
||||||
<button className="btn btn-outline-secondary" onClick={() => setPage((p) => p + 1)} disabled={loading || (data ? data.page >= totalPages : false)}>Next</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
import LanguageDetailClient from "./LanguageDetail";
|
import LanguageDetailClient from "./LanguageDetail";
|
||||||
|
import { entriesByLanguage } from "@/server/repo/zxdb";
|
||||||
|
|
||||||
export const metadata = { title: "ZXDB Language" };
|
export const metadata = { title: "ZXDB Language" };
|
||||||
|
|
||||||
|
export const revalidate = 3600;
|
||||||
|
|
||||||
export default async function Page({ params }: { params: Promise<{ id: string }> }) {
|
export default async function Page({ params }: { params: Promise<{ id: string }> }) {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
return <LanguageDetailClient id={id} />;
|
const initial = await entriesByLanguage(id, 1, 20);
|
||||||
|
return <LanguageDetailClient id={id} initial={initial as any} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,38 +1,19 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import Link from "next/link";
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
|
||||||
type Item = { id: number; title: string; isXrated: number; machinetypeId: number | null; languageId: string | null };
|
type Item = { id: number; title: string; isXrated: number; machinetypeId: number | null; languageId: string | null };
|
||||||
type Paged<T> = { items: T[]; page: number; pageSize: number; total: number };
|
type Paged<T> = { items: T[]; page: number; pageSize: number; total: number };
|
||||||
|
|
||||||
export default function MachineTypeDetailClient({ id }: { id: number }) {
|
export default function MachineTypeDetailClient({ id, initial }: { id: number; initial: Paged<Item> }) {
|
||||||
const [data, setData] = useState<Paged<Item> | null>(null);
|
const [data] = useState<Paged<Item>>(initial);
|
||||||
const [loading, setLoading] = useState(false);
|
const totalPages = useMemo(() => Math.max(1, Math.ceil(data.total / data.pageSize)), [data]);
|
||||||
const [page, setPage] = useState(1);
|
|
||||||
const pageSize = 20;
|
|
||||||
const totalPages = useMemo(() => (data ? Math.max(1, Math.ceil(data.total / data.pageSize)) : 1), [data]);
|
|
||||||
|
|
||||||
async function load(p: number) {
|
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
const res = await fetch(`/api/zxdb/machinetypes/${id}?page=${p}&pageSize=${pageSize}`, { cache: "no-store" });
|
|
||||||
const json = (await res.json()) as Paged<Item>;
|
|
||||||
setData(json);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
load(page);
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [page]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container">
|
<div className="container">
|
||||||
<h1>Machine Type #{id}</h1>
|
<h1>Machine Type #{id}</h1>
|
||||||
{loading && <div>Loading…</div>}
|
{data && data.items.length === 0 && <div className="alert alert-warning">No entries.</div>}
|
||||||
{data && data.items.length === 0 && !loading && <div className="alert alert-warning">No entries.</div>}
|
|
||||||
{data && data.items.length > 0 && (
|
{data && data.items.length > 0 && (
|
||||||
<div className="table-responsive">
|
<div className="table-responsive">
|
||||||
<table className="table table-striped table-hover align-middle">
|
<table className="table table-striped table-hover align-middle">
|
||||||
@@ -48,7 +29,7 @@ export default function MachineTypeDetailClient({ id }: { id: number }) {
|
|||||||
{data.items.map((it) => (
|
{data.items.map((it) => (
|
||||||
<tr key={it.id}>
|
<tr key={it.id}>
|
||||||
<td>{it.id}</td>
|
<td>{it.id}</td>
|
||||||
<td><a href={`/zxdb/entries/${it.id}`}>{it.title}</a></td>
|
<td><Link href={`/zxdb/entries/${it.id}`}>{it.title}</Link></td>
|
||||||
<td>{it.machinetypeId ?? "-"}</td>
|
<td>{it.machinetypeId ?? "-"}</td>
|
||||||
<td>{it.languageId ?? "-"}</td>
|
<td>{it.languageId ?? "-"}</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -59,9 +40,7 @@ export default function MachineTypeDetailClient({ id }: { id: number }) {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="d-flex align-items-center gap-2 mt-2">
|
<div className="d-flex align-items-center gap-2 mt-2">
|
||||||
<button className="btn btn-outline-secondary" onClick={() => setPage((p) => Math.max(1, p - 1))} disabled={loading || page <= 1}>Prev</button>
|
<span>Page {data.page} / {totalPages}</span>
|
||||||
<span>Page {data?.page ?? page} / {totalPages}</span>
|
|
||||||
<button className="btn btn-outline-secondary" onClick={() => setPage((p) => p + 1)} disabled={loading || (data ? data.page >= totalPages : false)}>Next</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
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 ZxdbExplorer from "./ZxdbExplorer";
|
||||||
|
import { searchEntries } from "@/server/repo/zxdb";
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
title: "ZXDB Explorer",
|
title: "ZXDB Explorer",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function Page() {
|
export const revalidate = 3600;
|
||||||
return <ZxdbExplorer />;
|
|
||||||
|
export default async function Page() {
|
||||||
|
// Server-render initial page (no query) to avoid first client fetch
|
||||||
|
const initial = await searchEntries({ page: 1, pageSize: 20, sort: "id_desc" });
|
||||||
|
return <ZxdbExplorer initial={initial as any} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -38,6 +38,18 @@ export interface PagedResult<T> {
|
|||||||
total: number;
|
total: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface FacetItem<T extends number | string> {
|
||||||
|
id: T;
|
||||||
|
name: string;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EntryFacets {
|
||||||
|
genres: FacetItem<number>[];
|
||||||
|
languages: FacetItem<string>[];
|
||||||
|
machinetypes: FacetItem<number>[];
|
||||||
|
}
|
||||||
|
|
||||||
export async function searchEntries(params: SearchParams): Promise<PagedResult<SearchResultItem>> {
|
export async function searchEntries(params: SearchParams): Promise<PagedResult<SearchResultItem>> {
|
||||||
const q = (params.q ?? "").trim();
|
const q = (params.q ?? "").trim();
|
||||||
const pageSize = Math.max(1, Math.min(params.pageSize ?? 20, 100));
|
const pageSize = Math.max(1, Math.min(params.pageSize ?? 20, 100));
|
||||||
@@ -124,44 +136,42 @@ export interface EntryDetail {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function getEntryById(id: number): Promise<EntryDetail | null> {
|
export async function getEntryById(id: number): Promise<EntryDetail | null> {
|
||||||
// Basic entry with lookups
|
// Run base row + contributors in parallel to reduce latency
|
||||||
const rows = await db
|
const [rows, authorRows, publisherRows] = await Promise.all([
|
||||||
.select({
|
db
|
||||||
id: entries.id,
|
.select({
|
||||||
title: entries.title,
|
id: entries.id,
|
||||||
isXrated: entries.isXrated,
|
title: entries.title,
|
||||||
machinetypeId: entries.machinetypeId,
|
isXrated: entries.isXrated,
|
||||||
machinetypeName: machinetypes.name,
|
machinetypeId: entries.machinetypeId,
|
||||||
languageId: entries.languageId,
|
machinetypeName: machinetypes.name,
|
||||||
languageName: languages.name,
|
languageId: entries.languageId,
|
||||||
genreId: entries.genretypeId,
|
languageName: languages.name,
|
||||||
genreName: genretypes.name,
|
genreId: entries.genretypeId,
|
||||||
})
|
genreName: genretypes.name,
|
||||||
.from(entries)
|
})
|
||||||
.leftJoin(machinetypes, eq(machinetypes.id, entries.machinetypeId as any))
|
.from(entries)
|
||||||
.leftJoin(languages, eq(languages.id, entries.languageId as any))
|
.leftJoin(machinetypes, eq(machinetypes.id, entries.machinetypeId as any))
|
||||||
.leftJoin(genretypes, eq(genretypes.id, entries.genretypeId as any))
|
.leftJoin(languages, eq(languages.id, entries.languageId as any))
|
||||||
.where(eq(entries.id, id));
|
.leftJoin(genretypes, eq(genretypes.id, entries.genretypeId as any))
|
||||||
|
.where(eq(entries.id, id)),
|
||||||
|
db
|
||||||
|
.select({ id: labels.id, name: labels.name, labeltypeId: labels.labeltypeId })
|
||||||
|
.from(authors)
|
||||||
|
.innerJoin(labels, eq(labels.id, authors.labelId))
|
||||||
|
.where(eq(authors.entryId, id))
|
||||||
|
.groupBy(labels.id),
|
||||||
|
db
|
||||||
|
.select({ id: labels.id, name: labels.name, labeltypeId: labels.labeltypeId })
|
||||||
|
.from(publishers)
|
||||||
|
.innerJoin(labels, eq(labels.id, publishers.labelId))
|
||||||
|
.where(eq(publishers.entryId, id))
|
||||||
|
.groupBy(labels.id),
|
||||||
|
]);
|
||||||
|
|
||||||
const base = rows[0];
|
const base = rows[0];
|
||||||
if (!base) return null;
|
if (!base) return null;
|
||||||
|
|
||||||
// Authors
|
|
||||||
const authorRows = await db
|
|
||||||
.select({ id: labels.id, name: labels.name, labeltypeId: labels.labeltypeId })
|
|
||||||
.from(authors)
|
|
||||||
.innerJoin(labels, eq(labels.id, authors.labelId))
|
|
||||||
.where(eq(authors.entryId, id))
|
|
||||||
.groupBy(labels.id);
|
|
||||||
|
|
||||||
// Publishers
|
|
||||||
const publisherRows = await db
|
|
||||||
.select({ id: labels.id, name: labels.name, labeltypeId: labels.labeltypeId })
|
|
||||||
.from(publishers)
|
|
||||||
.innerJoin(labels, eq(labels.id, publishers.labelId))
|
|
||||||
.where(eq(publishers.entryId, id))
|
|
||||||
.groupBy(labels.id);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: base.id,
|
id: base.id,
|
||||||
title: base.title,
|
title: base.title,
|
||||||
@@ -339,3 +349,57 @@ export async function entriesByMachinetype(mtId: number, page: number, pageSize:
|
|||||||
.offset(offset);
|
.offset(offset);
|
||||||
return { items: items as any, page, pageSize, total: Number(total ?? 0) };
|
return { items: items as any, page, pageSize, total: Number(total ?? 0) };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ----- Facets for search -----
|
||||||
|
|
||||||
|
export async function getEntryFacets(params: SearchParams): Promise<EntryFacets> {
|
||||||
|
const q = (params.q ?? "").trim();
|
||||||
|
const pattern = q ? `%${q.toLowerCase().replace(/[^a-z0-9]+/g, "")}%` : null;
|
||||||
|
|
||||||
|
// Build base WHERE SQL snippet considering q + filters
|
||||||
|
const whereParts: any[] = [];
|
||||||
|
if (pattern) {
|
||||||
|
whereParts.push(sql`id in (select entry_id from ${searchByTitles} where ${searchByTitles.entryTitle} like ${pattern})`);
|
||||||
|
}
|
||||||
|
if (params.genreId) whereParts.push(sql`${entries.genretypeId} = ${params.genreId}`);
|
||||||
|
if (params.languageId) whereParts.push(sql`${entries.languageId} = ${params.languageId}`);
|
||||||
|
if (params.machinetypeId) whereParts.push(sql`${entries.machinetypeId} = ${params.machinetypeId}`);
|
||||||
|
|
||||||
|
const whereSql = whereParts.length ? sql.join([sql`where `, sql.join(whereParts as any, sql` and `)], sql``) : sql``;
|
||||||
|
|
||||||
|
// Genres facet
|
||||||
|
const genresRows = await db.execute(sql`
|
||||||
|
select e.genretype_id as id, gt.text as name, count(*) as count
|
||||||
|
from ${entries} as e
|
||||||
|
left join ${genretypes} as gt on gt.id = e.genretype_id
|
||||||
|
${whereSql}
|
||||||
|
group by e.genretype_id, gt.text
|
||||||
|
order by count desc, name asc
|
||||||
|
`) as any;
|
||||||
|
|
||||||
|
// Languages facet
|
||||||
|
const langRows = await db.execute(sql`
|
||||||
|
select e.language_id as id, l.text as name, count(*) as count
|
||||||
|
from ${entries} as e
|
||||||
|
left join ${languages} as l on l.id = e.language_id
|
||||||
|
${whereSql}
|
||||||
|
group by e.language_id, l.text
|
||||||
|
order by count desc, name asc
|
||||||
|
`) as any;
|
||||||
|
|
||||||
|
// Machinetypes facet
|
||||||
|
const mtRows = await db.execute(sql`
|
||||||
|
select e.machinetype_id as id, m.text as name, count(*) as count
|
||||||
|
from ${entries} as e
|
||||||
|
left join ${machinetypes} as m on m.id = e.machinetype_id
|
||||||
|
${whereSql}
|
||||||
|
group by e.machinetype_id, m.text
|
||||||
|
order by count desc, name asc
|
||||||
|
`) as any;
|
||||||
|
|
||||||
|
return {
|
||||||
|
genres: (genresRows as any[]).map((r: any) => ({ id: Number(r.id), name: r.name ?? "(none)", count: Number(r.count) })).filter((r) => !!r.id),
|
||||||
|
languages: (langRows as any[]).map((r: any) => ({ id: String(r.id), name: r.name ?? "(none)", count: Number(r.count) })).filter((r) => !!r.id),
|
||||||
|
machinetypes: (mtRows as any[]).map((r: any) => ({ id: Number(r.id), name: r.name ?? "(none)", count: Number(r.count) })).filter((r) => !!r.id),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user