From 3ef3a16bc00d5f9d1371a254340b735a5054e003 Mon Sep 17 00:00:00 2001 From: "D. Rimron-Soutter" Date: Fri, 12 Dec 2025 16:11:12 +0000 Subject: [PATCH] Fix ZXDB pagination counters and navigation Implement URL-driven pagination and correct total counts across ZXDB: - Root /zxdb: SSR reads ?page; client syncs to SSR; Prev/Next as Links. - Sub-index pages (genres, languages, machinetypes): parse ?page on server; use SSR props in clients; Prev/Next via Links. - Labels browse (/zxdb/labels): dynamic SSR, reads ?q & ?page; typed count(*); client syncs to SSR; Prev/Next preserve q. - Label detail (/zxdb/labels/[id]): tab-aware Prev/Next Links; counters from server. - Repo: replace raw counts with typed Drizzle count(*) for reliable totals. Signed-off-by: Junie --- src/app/zxdb/ZxdbExplorer.tsx | 59 ++++++++----- src/app/zxdb/genres/[id]/GenreDetail.tsx | 30 +++++-- src/app/zxdb/genres/[id]/page.tsx | 10 ++- src/app/zxdb/labels/LabelsSearch.tsx | 82 ++++++++----------- src/app/zxdb/labels/[id]/LabelDetail.tsx | 33 ++++++-- src/app/zxdb/labels/[id]/page.tsx | 16 ++-- src/app/zxdb/labels/page.tsx | 13 +-- .../zxdb/languages/[id]/LanguageDetail.tsx | 29 +++++-- src/app/zxdb/languages/[id]/page.tsx | 10 ++- .../machinetypes/[id]/MachineTypeDetail.tsx | 29 +++++-- src/app/zxdb/machinetypes/[id]/page.tsx | 11 +-- src/app/zxdb/page.tsx | 12 ++- src/server/repo/zxdb.ts | 47 ++++++----- 13 files changed, 238 insertions(+), 143 deletions(-) diff --git a/src/app/zxdb/ZxdbExplorer.tsx b/src/app/zxdb/ZxdbExplorer.tsx index cf89fc7..118adc6 100644 --- a/src/app/zxdb/ZxdbExplorer.tsx +++ b/src/app/zxdb/ZxdbExplorer.tsx @@ -30,7 +30,7 @@ export default function ZxdbExplorer({ initialMachines?: { id: number; name: string }[]; }) { const [q, setQ] = useState(""); - const [page, setPage] = useState(1); + const [page, setPage] = useState(initial?.page ?? 1); const [loading, setLoading] = useState(false); const [data, setData] = useState | null>(initial ?? null); const [genres, setGenres] = useState<{ id: number; name: string }[]>(initialGenres ?? []); @@ -39,7 +39,7 @@ export default function ZxdbExplorer({ const [genreId, setGenreId] = useState(""); const [languageId, setLanguageId] = useState(""); const [machinetypeId, setMachinetypeId] = useState(""); - const [sort, setSort] = useState<"title" | "id_desc">("title"); + const [sort, setSort] = useState<"title" | "id_desc">("id_desc"); const pageSize = 20; const totalPages = useMemo(() => (data ? Math.max(1, Math.ceil(data.total / data.pageSize)) : 1), [data]); @@ -69,8 +69,27 @@ export default function ZxdbExplorer({ } useEffect(() => { - // Avoid immediate client fetch on first paint if server provided initial data - if (initial && page === 1 && q === "" && genreId === "" && languageId === "" && machinetypeId === "" && sort === "title") { + // When navigating via Next.js Links that change ?page=, SSR provides new `initial`. + // Sync local state from new SSR payload so the list and counter update immediately + // without an extra client fetch. + if (initial) { + setData(initial); + setPage(initial.page); + } + }, [initial]); + + useEffect(() => { + // Avoid immediate client fetch on first paint if server provided initial data for this exact state + const initialPage = initial?.page ?? 1; + if ( + initial && + page === initialPage && + q === "" && + genreId === "" && + languageId === "" && + machinetypeId === "" && + sort === "id_desc" + ) { return; } fetchData(q, page); @@ -185,23 +204,25 @@ export default function ZxdbExplorer({
- - Page {data?.page ?? page} / {totalPages} + Page {data?.page ?? 1} / {totalPages} - +
+ + Prev + + = totalPages) ? "disabled" : ""}`} + aria-disabled={!data || data.page >= totalPages} + href={`/zxdb?page=${Math.min(totalPages, (data?.page ?? 1) + 1)}`} + > + Next + +

diff --git a/src/app/zxdb/genres/[id]/GenreDetail.tsx b/src/app/zxdb/genres/[id]/GenreDetail.tsx index 7cebae1..f8b4e36 100644 --- a/src/app/zxdb/genres/[id]/GenreDetail.tsx +++ b/src/app/zxdb/genres/[id]/GenreDetail.tsx @@ -1,21 +1,19 @@ "use client"; import Link from "next/link"; -import { useMemo, useState } from "react"; +import { useMemo } 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, 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]); + const totalPages = useMemo(() => Math.max(1, Math.ceil(initial.total / initial.pageSize)), [initial]); return (

Genre #{id}

- {data && data.items.length === 0 &&
No entries.
} - {data && data.items.length > 0 && ( + {initial && initial.items.length === 0 &&
No entries.
} + {initial && initial.items.length > 0 && (
@@ -27,7 +25,7 @@ export default function GenreDetailClient({ id, initial }: { id: number; initial - {data.items.map((it) => ( + {initial.items.map((it) => ( @@ -41,7 +39,23 @@ export default function GenreDetailClient({ id, initial }: { id: number; initial )}
- Page {data.page} / {totalPages} + Page {initial.page} / {totalPages} +
+ + Prev + + = totalPages ? "disabled" : ""}`} + aria-disabled={initial.page >= totalPages} + href={`/zxdb/genres/${id}?page=${Math.min(totalPages, initial.page + 1)}`} + > + Next + +
); diff --git a/src/app/zxdb/genres/[id]/page.tsx b/src/app/zxdb/genres/[id]/page.tsx index 075f5e8..5d21e02 100644 --- a/src/app/zxdb/genres/[id]/page.tsx +++ b/src/app/zxdb/genres/[id]/page.tsx @@ -3,11 +3,13 @@ import { entriesByGenre } from "@/server/repo/zxdb"; export const metadata = { title: "ZXDB Genre" }; -export const revalidate = 3600; +// Depends on searchParams (?page=). Force dynamic so each page renders correctly. +export const dynamic = "force-dynamic"; -export default async function Page({ params }: { params: Promise<{ id: string }> }) { - const { id } = await params; +export default async function Page({ params, searchParams }: { params: Promise<{ id: string }>; searchParams: Promise<{ [key: string]: string | string[] | undefined }> }) { + const [{ id }, sp] = await Promise.all([params, searchParams]); const numericId = Number(id); - const initial = await entriesByGenre(numericId, 1, 20); + const page = Math.max(1, Number(Array.isArray(sp.page) ? sp.page[0] : sp.page) || 1); + const initial = await entriesByGenre(numericId, page, 20); return ; } diff --git a/src/app/zxdb/labels/LabelsSearch.tsx b/src/app/zxdb/labels/LabelsSearch.tsx index 5ce2501..29e5cda 100644 --- a/src/app/zxdb/labels/LabelsSearch.tsx +++ b/src/app/zxdb/labels/LabelsSearch.tsx @@ -1,51 +1,34 @@ "use client"; -import { useEffect, useMemo, useRef, useState } from "react"; +import { useEffect, useMemo, useState } from "react"; import Link from "next/link"; +import { useRouter } from "next/navigation"; type Label = { id: number; name: string; labeltypeId: string | null }; type Paged = { items: T[]; page: number; pageSize: number; total: number }; -export default function LabelsSearch({ initial }: { initial?: Paged
{it.id} {it.title}
@@ -26,7 +25,7 @@ export default function LanguageDetailClient({ id, initial }: { id: string; init - {data.items.map((it) => ( + {initial.items.map((it) => ( @@ -40,7 +39,23 @@ export default function LanguageDetailClient({ id, initial }: { id: string; init )}
- Page {data.page} / {totalPages} + Page {initial.page} / {totalPages} +
+ + Prev + + = totalPages ? "disabled" : ""}`} + aria-disabled={initial.page >= totalPages} + href={`/zxdb/languages/${id}?page=${Math.min(totalPages, initial.page + 1)}`} + > + Next + +
); diff --git a/src/app/zxdb/languages/[id]/page.tsx b/src/app/zxdb/languages/[id]/page.tsx index 25ebc5f..802dcca 100644 --- a/src/app/zxdb/languages/[id]/page.tsx +++ b/src/app/zxdb/languages/[id]/page.tsx @@ -3,10 +3,12 @@ import { entriesByLanguage } from "@/server/repo/zxdb"; export const metadata = { title: "ZXDB Language" }; -export const revalidate = 3600; +// Depends on searchParams (?page=). Force dynamic so each page renders correctly. +export const dynamic = "force-dynamic"; -export default async function Page({ params }: { params: Promise<{ id: string }> }) { - const { id } = await params; - const initial = await entriesByLanguage(id, 1, 20); +export default async function Page({ params, searchParams }: { params: Promise<{ id: string }>; searchParams: Promise<{ [key: string]: string | string[] | undefined }> }) { + const [{ id }, sp] = await Promise.all([params, searchParams]); + const page = Math.max(1, Number(Array.isArray(sp.page) ? sp.page[0] : sp.page) || 1); + const initial = await entriesByLanguage(id, page, 20); return ; } diff --git a/src/app/zxdb/machinetypes/[id]/MachineTypeDetail.tsx b/src/app/zxdb/machinetypes/[id]/MachineTypeDetail.tsx index 8399785..9aa5c20 100644 --- a/src/app/zxdb/machinetypes/[id]/MachineTypeDetail.tsx +++ b/src/app/zxdb/machinetypes/[id]/MachineTypeDetail.tsx @@ -1,20 +1,19 @@ "use client"; import Link from "next/link"; -import { useMemo, useState } from "react"; +import { useMemo } 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, initial }: { id: number; initial: Paged }) { - const [data] = useState>(initial); - const totalPages = useMemo(() => Math.max(1, Math.ceil(data.total / data.pageSize)), [data]); + const totalPages = useMemo(() => Math.max(1, Math.ceil(initial.total / initial.pageSize)), [initial]); return (

Machine Type #{id}

- {data && data.items.length === 0 &&
No entries.
} - {data && data.items.length > 0 && ( + {initial && initial.items.length === 0 &&
No entries.
} + {initial && initial.items.length > 0 && (
{it.id} {it.title}
@@ -26,7 +25,7 @@ export default function MachineTypeDetailClient({ id, initial }: { id: number; i - {data.items.map((it) => ( + {initial.items.map((it) => ( @@ -40,7 +39,23 @@ export default function MachineTypeDetailClient({ id, initial }: { id: number; i )}
- Page {data.page} / {totalPages} + Page {initial.page} / {totalPages} +
+ + Prev + + = totalPages ? "disabled" : ""}`} + aria-disabled={initial.page >= totalPages} + href={`/zxdb/machinetypes/${id}?page=${Math.min(totalPages, initial.page + 1)}`} + > + Next + +
); diff --git a/src/app/zxdb/machinetypes/[id]/page.tsx b/src/app/zxdb/machinetypes/[id]/page.tsx index 86c9e5a..61e20bd 100644 --- a/src/app/zxdb/machinetypes/[id]/page.tsx +++ b/src/app/zxdb/machinetypes/[id]/page.tsx @@ -2,12 +2,13 @@ import MachineTypeDetailClient from "./MachineTypeDetail"; import { entriesByMachinetype } from "@/server/repo/zxdb"; export const metadata = { title: "ZXDB Machine Type" }; +// Depends on searchParams (?page=). Force dynamic so each page renders correctly. +export const dynamic = "force-dynamic"; -export const revalidate = 3600; - -export default async function Page({ params }: { params: Promise<{ id: string }> }) { - const { id } = await params; +export default async function Page({ params, searchParams }: { params: Promise<{ id: string }>; searchParams: Promise<{ [key: string]: string | string[] | undefined }> }) { + const [{ id }, sp] = await Promise.all([params, searchParams]); const numericId = Number(id); - const initial = await entriesByMachinetype(numericId, 1, 20); + const page = Math.max(1, Number(Array.isArray(sp.page) ? sp.page[0] : sp.page) || 1); + const initial = await entriesByMachinetype(numericId, page, 20); return ; } diff --git a/src/app/zxdb/page.tsx b/src/app/zxdb/page.tsx index d869cdb..8bd368b 100644 --- a/src/app/zxdb/page.tsx +++ b/src/app/zxdb/page.tsx @@ -5,12 +5,16 @@ export const metadata = { title: "ZXDB Explorer", }; -export const revalidate = 3600; +// This page depends on searchParams (?page=, filters in future). Force dynamic +// rendering so ISR doesn’t cache a single HTML for all query strings. +export const dynamic = "force-dynamic"; -export default async function Page() { - // Server-render initial page (no query) to avoid first client fetch +export default async function Page({ searchParams }: { searchParams: Promise<{ [key: string]: string | string[] | undefined }> }) { + const sp = await searchParams; + const page = Math.max(1, Number(Array.isArray(sp.page) ? sp.page[0] : sp.page) || 1); + // Server-render initial page based on URL to avoid first client fetch and keep counter in sync const [initial, genres, langs, machines] = await Promise.all([ - searchEntries({ page: 1, pageSize: 20, sort: "id_desc" }), + searchEntries({ page, pageSize: 20, sort: "id_desc" }), listGenres(), listLanguages(), listMachinetypes(), diff --git a/src/server/repo/zxdb.ts b/src/server/repo/zxdb.ts index fad73e6..e2fa645 100644 --- a/src/server/repo/zxdb.ts +++ b/src/server/repo/zxdb.ts @@ -68,7 +68,7 @@ export async function searchEntries(params: SearchParams): Promise, + db + .select({ total: sql`count(*)` }) + .from(entries) + .where(whereExpr as any) as unknown as Promise<{ total: number }[]>, ]); - return { - items: items as any, - page, - pageSize, - total: Number((total as any) ?? 0), - }; + const total = Number(countRows?.[0]?.total ?? 0); + return { items: items as any, page, pageSize, total }; } const pattern = `%${q.toLowerCase().replace(/[^a-z0-9]+/g, "")}%`; @@ -201,11 +198,14 @@ export async function searchLabels(params: LabelSearchParams): Promise, + db + .select({ total: sql`count(*)` }) + .from(labels) as unknown as Promise<{ total: number }[]>, ]); - return { items: items as any, page, pageSize, total: Number(total ?? 0) }; + const total = Number(countRows?.[0]?.total ?? 0); + return { items: items as any, page, pageSize, total }; } // Using helper search_by_names for efficiency @@ -313,7 +313,10 @@ export async function listMachinetypes() { export async function entriesByGenre(genreId: number, page: number, pageSize: number): Promise> { const offset = (page - 1) * pageSize; - const [{ total }]: any = await db.execute(sql`select count(*) as total from ${entries} where ${entries.genretypeId} = ${genreId}`); + const countRows = (await db + .select({ total: sql`count(*)` }) + .from(entries) + .where(eq(entries.genretypeId, genreId as any))) as unknown as { total: number }[]; const items = await db .select({ id: entries.id, title: entries.title, isXrated: entries.isXrated, machinetypeId: entries.machinetypeId, languageId: entries.languageId }) .from(entries) @@ -321,12 +324,15 @@ export async function entriesByGenre(genreId: number, page: number, pageSize: nu .orderBy(entries.title) .limit(pageSize) .offset(offset); - return { items: items as any, page, pageSize, total: Number(total ?? 0) }; + return { items: items as any, page, pageSize, total: Number(countRows?.[0]?.total ?? 0) }; } export async function entriesByLanguage(langId: string, page: number, pageSize: number): Promise> { const offset = (page - 1) * pageSize; - const [{ total }]: any = await db.execute(sql`select count(*) as total from ${entries} where ${entries.languageId} = ${langId}`); + const countRows = (await db + .select({ total: sql`count(*)` }) + .from(entries) + .where(eq(entries.languageId, langId as any))) as unknown as { total: number }[]; const items = await db .select({ id: entries.id, title: entries.title, isXrated: entries.isXrated, machinetypeId: entries.machinetypeId, languageId: entries.languageId }) .from(entries) @@ -334,12 +340,15 @@ export async function entriesByLanguage(langId: string, page: number, pageSize: .orderBy(entries.title) .limit(pageSize) .offset(offset); - return { items: items as any, page, pageSize, total: Number(total ?? 0) }; + return { items: items as any, page, pageSize, total: Number(countRows?.[0]?.total ?? 0) }; } export async function entriesByMachinetype(mtId: number, page: number, pageSize: number): Promise> { const offset = (page - 1) * pageSize; - const [{ total }]: any = await db.execute(sql`select count(*) as total from ${entries} where ${entries.machinetypeId} = ${mtId}`); + const countRows = (await db + .select({ total: sql`count(*)` }) + .from(entries) + .where(eq(entries.machinetypeId, mtId as any))) as unknown as { total: number }[]; const items = await db .select({ id: entries.id, title: entries.title, isXrated: entries.isXrated, machinetypeId: entries.machinetypeId, languageId: entries.languageId }) .from(entries) @@ -347,7 +356,7 @@ export async function entriesByMachinetype(mtId: number, page: number, pageSize: .orderBy(entries.title) .limit(pageSize) .offset(offset); - return { items: items as any, page, pageSize, total: Number(total ?? 0) }; + return { items: items as any, page, pageSize, total: Number(countRows?.[0]?.total ?? 0) }; } // ----- Facets for search -----
{it.id} {it.title}