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 <Junie@lucy.xalior.com>
This commit is contained in:
@@ -30,7 +30,7 @@ export default function ZxdbExplorer({
|
|||||||
initialMachines?: { id: number; name: string }[];
|
initialMachines?: { id: number; name: string }[];
|
||||||
}) {
|
}) {
|
||||||
const [q, setQ] = useState("");
|
const [q, setQ] = useState("");
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(initial?.page ?? 1);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [data, setData] = useState<Paged<Item> | null>(initial ?? null);
|
const [data, setData] = useState<Paged<Item> | null>(initial ?? null);
|
||||||
const [genres, setGenres] = useState<{ id: number; name: string }[]>(initialGenres ?? []);
|
const [genres, setGenres] = useState<{ id: number; name: string }[]>(initialGenres ?? []);
|
||||||
@@ -39,7 +39,7 @@ export default function ZxdbExplorer({
|
|||||||
const [genreId, setGenreId] = useState<number | "">("");
|
const [genreId, setGenreId] = useState<number | "">("");
|
||||||
const [languageId, setLanguageId] = useState<string | "">("");
|
const [languageId, setLanguageId] = useState<string | "">("");
|
||||||
const [machinetypeId, setMachinetypeId] = useState<number | "">("");
|
const [machinetypeId, setMachinetypeId] = useState<number | "">("");
|
||||||
const [sort, setSort] = useState<"title" | "id_desc">("title");
|
const [sort, setSort] = useState<"title" | "id_desc">("id_desc");
|
||||||
|
|
||||||
const pageSize = 20;
|
const pageSize = 20;
|
||||||
const totalPages = useMemo(() => (data ? Math.max(1, Math.ceil(data.total / data.pageSize)) : 1), [data]);
|
const totalPages = useMemo(() => (data ? Math.max(1, Math.ceil(data.total / data.pageSize)) : 1), [data]);
|
||||||
@@ -69,8 +69,27 @@ export default function ZxdbExplorer({
|
|||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Avoid immediate client fetch on first paint if server provided initial data
|
// When navigating via Next.js Links that change ?page=, SSR provides new `initial`.
|
||||||
if (initial && page === 1 && q === "" && genreId === "" && languageId === "" && machinetypeId === "" && sort === "title") {
|
// 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;
|
return;
|
||||||
}
|
}
|
||||||
fetchData(q, page);
|
fetchData(q, page);
|
||||||
@@ -185,23 +204,25 @@ export default function ZxdbExplorer({
|
|||||||
</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>
|
<span>
|
||||||
Page {data?.page ?? page} / {totalPages}
|
Page {data?.page ?? 1} / {totalPages}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<div className="ms-auto d-flex gap-2">
|
||||||
className="btn btn-outline-secondary"
|
<Link
|
||||||
onClick={() => setPage((p) => p + 1)}
|
className={`btn btn-outline-secondary ${!data || (data.page <= 1) ? "disabled" : ""}`}
|
||||||
disabled={loading || (data ? data.page >= totalPages : false)}
|
aria-disabled={!data || data.page <= 1}
|
||||||
>
|
href={`/zxdb?page=${Math.max(1, (data?.page ?? 1) - 1)}`}
|
||||||
Next
|
>
|
||||||
</button>
|
Prev
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
className={`btn btn-outline-secondary ${!data || (data.page >= totalPages) ? "disabled" : ""}`}
|
||||||
|
aria-disabled={!data || data.page >= totalPages}
|
||||||
|
href={`/zxdb?page=${Math.min(totalPages, (data?.page ?? 1) + 1)}`}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<hr />
|
<hr />
|
||||||
|
|||||||
@@ -1,21 +1,19 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
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 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, initial }: { id: number; initial: Paged<Item> }) {
|
export default function GenreDetailClient({ id, initial }: { id: number; initial: Paged<Item> }) {
|
||||||
const [data] = useState<Paged<Item>>(initial);
|
const totalPages = useMemo(() => Math.max(1, Math.ceil(initial.total / initial.pageSize)), [initial]);
|
||||||
const [page] = useState(1);
|
|
||||||
const totalPages = useMemo(() => Math.max(1, Math.ceil(data.total / data.pageSize)), [data]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container">
|
<div className="container">
|
||||||
<h1>Genre #{id}</h1>
|
<h1>Genre #{id}</h1>
|
||||||
{data && data.items.length === 0 && <div className="alert alert-warning">No entries.</div>}
|
{initial && initial.items.length === 0 && <div className="alert alert-warning">No entries.</div>}
|
||||||
{data && data.items.length > 0 && (
|
{initial && initial.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">
|
||||||
<thead>
|
<thead>
|
||||||
@@ -27,7 +25,7 @@ export default function GenreDetailClient({ id, initial }: { id: number; initial
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{data.items.map((it) => (
|
{initial.items.map((it) => (
|
||||||
<tr key={it.id}>
|
<tr key={it.id}>
|
||||||
<td>{it.id}</td>
|
<td>{it.id}</td>
|
||||||
<td><Link href={`/zxdb/entries/${it.id}`}>{it.title}</Link></td>
|
<td><Link href={`/zxdb/entries/${it.id}`}>{it.title}</Link></td>
|
||||||
@@ -41,7 +39,23 @@ export default function GenreDetailClient({ id, initial }: { id: number; initial
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="d-flex align-items-center gap-2 mt-2">
|
<div className="d-flex align-items-center gap-2 mt-2">
|
||||||
<span>Page {data.page} / {totalPages}</span>
|
<span>Page {initial.page} / {totalPages}</span>
|
||||||
|
<div className="ms-auto d-flex gap-2">
|
||||||
|
<Link
|
||||||
|
className={`btn btn-sm btn-outline-secondary ${initial.page <= 1 ? "disabled" : ""}`}
|
||||||
|
aria-disabled={initial.page <= 1}
|
||||||
|
href={`/zxdb/genres/${id}?page=${Math.max(1, initial.page - 1)}`}
|
||||||
|
>
|
||||||
|
Prev
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
className={`btn btn-sm btn-outline-secondary ${initial.page >= totalPages ? "disabled" : ""}`}
|
||||||
|
aria-disabled={initial.page >= totalPages}
|
||||||
|
href={`/zxdb/genres/${id}?page=${Math.min(totalPages, initial.page + 1)}`}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,11 +3,13 @@ import { entriesByGenre } from "@/server/repo/zxdb";
|
|||||||
|
|
||||||
export const metadata = { title: "ZXDB Genre" };
|
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 }> }) {
|
export default async function Page({ params, searchParams }: { params: Promise<{ id: string }>; searchParams: Promise<{ [key: string]: string | string[] | undefined }> }) {
|
||||||
const { id } = await params;
|
const [{ id }, sp] = await Promise.all([params, searchParams]);
|
||||||
const numericId = Number(id);
|
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 <GenreDetailClient id={numericId} initial={initial as any} />;
|
return <GenreDetailClient id={numericId} initial={initial as any} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,51 +1,34 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { useRouter } from "next/navigation";
|
||||||
|
|
||||||
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 };
|
||||||
|
|
||||||
export default function LabelsSearch({ initial }: { initial?: Paged<Label> }) {
|
export default function LabelsSearch({ initial, initialQ }: { initial?: Paged<Label>; initialQ?: string }) {
|
||||||
const [q, setQ] = useState("");
|
const router = useRouter();
|
||||||
const [page, setPage] = useState(1);
|
const [q, setQ] = useState(initialQ ?? "");
|
||||||
const [data, setData] = useState<Paged<Label> | null>(initial ?? null);
|
const [data, setData] = useState<Paged<Label> | null>(initial ?? null);
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const firstLoadSkipped = useRef(false);
|
|
||||||
const pageSize = 20;
|
|
||||||
const totalPages = useMemo(() => (data ? Math.max(1, Math.ceil(data.total / data.pageSize)) : 1), [data]);
|
const totalPages = useMemo(() => (data ? Math.max(1, Math.ceil(data.total / data.pageSize)) : 1), [data]);
|
||||||
|
|
||||||
async function load() {
|
// Sync incoming SSR payload on navigation (e.g., when clicking Prev/Next Links)
|
||||||
setLoading(true);
|
|
||||||
try {
|
|
||||||
const params = new URLSearchParams();
|
|
||||||
if (q) params.set("q", q);
|
|
||||||
params.set("page", String(page));
|
|
||||||
params.set("pageSize", String(pageSize));
|
|
||||||
const res = await fetch(`/api/zxdb/labels/search?${params.toString()}`, { cache: "no-store" });
|
|
||||||
const json = (await res.json()) as Paged<Label>;
|
|
||||||
setData(json);
|
|
||||||
} catch (e) {
|
|
||||||
setData({ items: [], page: 1, pageSize, total: 0 });
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// If server provided initial data for first page of empty search, skip first fetch
|
if (initial) setData(initial);
|
||||||
if (!firstLoadSkipped.current && initial && !q && page === 1) {
|
}, [initial]);
|
||||||
firstLoadSkipped.current = true;
|
|
||||||
return;
|
// Keep input in sync with URL q on navigation
|
||||||
}
|
useEffect(() => {
|
||||||
load();
|
setQ(initialQ ?? "");
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
}, [initialQ]);
|
||||||
}, [page]);
|
|
||||||
|
|
||||||
function submit(e: React.FormEvent) {
|
function submit(e: React.FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setPage(1);
|
const params = new URLSearchParams();
|
||||||
load();
|
if (q) params.set("q", q);
|
||||||
|
params.set("page", "1");
|
||||||
|
router.push(`/zxdb/labels?${params.toString()}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -56,13 +39,12 @@ export default function LabelsSearch({ initial }: { initial?: Paged<Label> }) {
|
|||||||
<input className="form-control" placeholder="Search labels…" value={q} onChange={(e) => setQ(e.target.value)} />
|
<input className="form-control" placeholder="Search labels…" value={q} onChange={(e) => setQ(e.target.value)} />
|
||||||
</div>
|
</div>
|
||||||
<div className="col-auto">
|
<div className="col-auto">
|
||||||
<button className="btn btn-primary" disabled={loading}>Search</button>
|
<button className="btn btn-primary">Search</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div className="mt-3">
|
<div className="mt-3">
|
||||||
{loading && <div>Loading…</div>}
|
{data && data.items.length === 0 && <div className="alert alert-warning">No labels found.</div>}
|
||||||
{data && data.items.length === 0 && !loading && <div className="alert alert-warning">No labels found.</div>}
|
|
||||||
{data && data.items.length > 0 && (
|
{data && data.items.length > 0 && (
|
||||||
<ul className="list-group">
|
<ul className="list-group">
|
||||||
{data.items.map((l) => (
|
{data.items.map((l) => (
|
||||||
@@ -76,15 +58,23 @@ export default function LabelsSearch({ initial }: { initial?: Paged<Label> }) {
|
|||||||
</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}>
|
<span>Page {data?.page ?? 1} / {totalPages}</span>
|
||||||
Prev
|
<div className="ms-auto d-flex gap-2">
|
||||||
</button>
|
<Link
|
||||||
<span>
|
className={`btn btn-outline-secondary ${!data || (data.page <= 1) ? "disabled" : ""}`}
|
||||||
Page {data?.page ?? page} / {totalPages}
|
aria-disabled={!data || data.page <= 1}
|
||||||
</span>
|
href={`/zxdb/labels?${(() => { const p = new URLSearchParams(); if (q) p.set("q", q); p.set("page", String(Math.max(1, (data?.page ?? 1) - 1))); return p.toString(); })()}`}
|
||||||
<button className="btn btn-outline-secondary" onClick={() => setPage((p) => p + 1)} disabled={loading || (data ? data.page >= totalPages : false)}>
|
>
|
||||||
Next
|
Prev
|
||||||
</button>
|
</Link>
|
||||||
|
<Link
|
||||||
|
className={`btn btn-outline-secondary ${!data || (data.page >= totalPages) ? "disabled" : ""}`}
|
||||||
|
aria-disabled={!data || data.page >= totalPages}
|
||||||
|
href={`/zxdb/labels?${(() => { const p = new URLSearchParams(); if (q) p.set("q", q); p.set("page", String(Math.min(totalPages, (data?.page ?? 1) + 1))); return p.toString(); })()}`}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -9,22 +9,21 @@ type Paged<T> = { items: T[]; page: number; pageSize: number; total: number };
|
|||||||
|
|
||||||
type Payload = { label: Label | null; authored: Paged<Item>; published: Paged<Item> };
|
type Payload = { label: Label | null; authored: Paged<Item>; published: Paged<Item> };
|
||||||
|
|
||||||
export default function LabelDetailClient({ id, initial }: { id: number; initial: Payload }) {
|
export default function LabelDetailClient({ id, initial, initialTab }: { id: number; initial: Payload; initialTab?: "authored" | "published" }) {
|
||||||
const [data] = useState<Payload>(initial);
|
// Keep only interactive UI state (tab). Data should come directly from SSR props so it updates on navigation.
|
||||||
const [tab, setTab] = useState<"authored" | "published">("authored");
|
const [tab, setTab] = useState<"authored" | "published">(initialTab ?? "authored");
|
||||||
const [page] = useState(1);
|
|
||||||
|
|
||||||
if (!data || !data.label) return <div className="alert alert-warning">Not found</div>;
|
if (!initial || !initial.label) return <div className="alert alert-warning">Not found</div>;
|
||||||
|
|
||||||
const current = useMemo(() => (tab === "authored" ? data.authored : data.published), [data, tab]);
|
const current = useMemo(() => (tab === "authored" ? initial.authored : initial.published), [initial, tab]);
|
||||||
const totalPages = useMemo(() => Math.max(1, Math.ceil(current.total / current.pageSize)), [current]);
|
const totalPages = useMemo(() => Math.max(1, Math.ceil(current.total / current.pageSize)), [current]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container">
|
<div className="container">
|
||||||
<div className="d-flex align-items-center justify-content-between flex-wrap gap-2">
|
<div className="d-flex align-items-center justify-content-between flex-wrap gap-2">
|
||||||
<h1 className="mb-0">{data.label.name}</h1>
|
<h1 className="mb-0">{initial.label.name}</h1>
|
||||||
<div>
|
<div>
|
||||||
<span className="badge text-bg-light">{data.label.labeltypeId ?? "?"}</span>
|
<span className="badge text-bg-light">{initial.label.labeltypeId ?? "?"}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -66,7 +65,23 @@ export default function LabelDetailClient({ id, initial }: { id: number; initial
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="d-flex align-items-center gap-2 mt-2">
|
<div className="d-flex align-items-center gap-2 mt-2">
|
||||||
<span>Page {page} / {totalPages}</span>
|
<span>Page {current.page} / {totalPages}</span>
|
||||||
|
<div className="ms-auto d-flex gap-2">
|
||||||
|
<Link
|
||||||
|
className={`btn btn-sm btn-outline-secondary ${current.page <= 1 ? "disabled" : ""}`}
|
||||||
|
aria-disabled={current.page <= 1}
|
||||||
|
href={`/zxdb/labels/${id}?tab=${tab}&page=${Math.max(1, current.page - 1)}`}
|
||||||
|
>
|
||||||
|
Prev
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
className={`btn btn-sm btn-outline-secondary ${current.page >= totalPages ? "disabled" : ""}`}
|
||||||
|
aria-disabled={current.page >= totalPages}
|
||||||
|
href={`/zxdb/labels/${id}?tab=${tab}&page=${Math.min(totalPages, current.page + 1)}`}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,17 +3,21 @@ import { getLabelById, getLabelAuthoredEntries, getLabelPublishedEntries } from
|
|||||||
|
|
||||||
export const metadata = { title: "ZXDB Label" };
|
export const metadata = { title: "ZXDB Label" };
|
||||||
|
|
||||||
export const revalidate = 3600;
|
// Depends on searchParams (?page=, ?tab=). Force dynamic so each request renders correctly.
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export default async function Page({ params }: { params: Promise<{ id: string }> }) {
|
export default async function Page({ params, searchParams }: { params: Promise<{ id: string }>;
|
||||||
const { id } = await params;
|
searchParams: Promise<{ [key: string]: string | string[] | undefined }> }) {
|
||||||
|
const [{ id }, sp] = await Promise.all([params, searchParams]);
|
||||||
const numericId = Number(id);
|
const numericId = Number(id);
|
||||||
|
const page = Math.max(1, Number(Array.isArray(sp.page) ? sp.page[0] : sp.page) || 1);
|
||||||
|
const tab = (Array.isArray(sp.tab) ? sp.tab[0] : sp.tab) as "authored" | "published" | undefined;
|
||||||
const [label, authored, published] = await Promise.all([
|
const [label, authored, published] = await Promise.all([
|
||||||
getLabelById(numericId),
|
getLabelById(numericId),
|
||||||
getLabelAuthoredEntries(numericId, { page: 1, pageSize: 20 }),
|
getLabelAuthoredEntries(numericId, { page, pageSize: 20 }),
|
||||||
getLabelPublishedEntries(numericId, { page: 1, pageSize: 20 }),
|
getLabelPublishedEntries(numericId, { page, pageSize: 20 }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Let the client component handle the "not found" simple state
|
// 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 }} />;
|
return <LabelDetailClient id={numericId} initial={{ label: label as any, authored: authored as any, published: published as any }} initialTab={tab} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,10 +3,13 @@ import { searchLabels } from "@/server/repo/zxdb";
|
|||||||
|
|
||||||
export const metadata = { title: "ZXDB Labels" };
|
export const metadata = { title: "ZXDB Labels" };
|
||||||
|
|
||||||
export const revalidate = 3600;
|
// Depends on searchParams (?q=, ?page=). Force dynamic so each request renders correctly.
|
||||||
|
export const dynamic = "force-dynamic";
|
||||||
|
|
||||||
export default async function Page() {
|
export default async function Page({ searchParams }: { searchParams: Promise<{ [key: string]: string | string[] | undefined }> }) {
|
||||||
// Server-render first page of empty search for instant content
|
const sp = await searchParams;
|
||||||
const initial = await searchLabels({ page: 1, pageSize: 20 });
|
const q = (Array.isArray(sp.q) ? sp.q[0] : sp.q) ?? "";
|
||||||
return <LabelsSearch initial={initial as any} />;
|
const page = Math.max(1, Number(Array.isArray(sp.page) ? sp.page[0] : sp.page) || 1);
|
||||||
|
const initial = await searchLabels({ q, page, pageSize: 20 });
|
||||||
|
return <LabelsSearch initial={initial as any} initialQ={q} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,19 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
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 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, initial }: { id: string; initial: Paged<Item> }) {
|
export default function LanguageDetailClient({ id, initial }: { id: string; initial: Paged<Item> }) {
|
||||||
const [data] = useState<Paged<Item>>(initial);
|
const totalPages = useMemo(() => Math.max(1, Math.ceil(initial.total / initial.pageSize)), [initial]);
|
||||||
const totalPages = useMemo(() => Math.max(1, Math.ceil(data.total / data.pageSize)), [data]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container">
|
<div className="container">
|
||||||
<h1>Language {id}</h1>
|
<h1>Language {id}</h1>
|
||||||
{data && data.items.length === 0 && <div className="alert alert-warning">No entries.</div>}
|
{initial && initial.items.length === 0 && <div className="alert alert-warning">No entries.</div>}
|
||||||
{data && data.items.length > 0 && (
|
{initial && initial.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">
|
||||||
<thead>
|
<thead>
|
||||||
@@ -26,7 +25,7 @@ export default function LanguageDetailClient({ id, initial }: { id: string; init
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{data.items.map((it) => (
|
{initial.items.map((it) => (
|
||||||
<tr key={it.id}>
|
<tr key={it.id}>
|
||||||
<td>{it.id}</td>
|
<td>{it.id}</td>
|
||||||
<td><Link href={`/zxdb/entries/${it.id}`}>{it.title}</Link></td>
|
<td><Link href={`/zxdb/entries/${it.id}`}>{it.title}</Link></td>
|
||||||
@@ -40,7 +39,23 @@ export default function LanguageDetailClient({ id, initial }: { id: string; init
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="d-flex align-items-center gap-2 mt-2">
|
<div className="d-flex align-items-center gap-2 mt-2">
|
||||||
<span>Page {data.page} / {totalPages}</span>
|
<span>Page {initial.page} / {totalPages}</span>
|
||||||
|
<div className="ms-auto d-flex gap-2">
|
||||||
|
<Link
|
||||||
|
className={`btn btn-sm btn-outline-secondary ${initial.page <= 1 ? "disabled" : ""}`}
|
||||||
|
aria-disabled={initial.page <= 1}
|
||||||
|
href={`/zxdb/languages/${id}?page=${Math.max(1, initial.page - 1)}`}
|
||||||
|
>
|
||||||
|
Prev
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
className={`btn btn-sm btn-outline-secondary ${initial.page >= totalPages ? "disabled" : ""}`}
|
||||||
|
aria-disabled={initial.page >= totalPages}
|
||||||
|
href={`/zxdb/languages/${id}?page=${Math.min(totalPages, initial.page + 1)}`}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,10 +3,12 @@ import { entriesByLanguage } from "@/server/repo/zxdb";
|
|||||||
|
|
||||||
export const metadata = { title: "ZXDB Language" };
|
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 }> }) {
|
export default async function Page({ params, searchParams }: { params: Promise<{ id: string }>; searchParams: Promise<{ [key: string]: string | string[] | undefined }> }) {
|
||||||
const { id } = await params;
|
const [{ id }, sp] = await Promise.all([params, searchParams]);
|
||||||
const initial = await entriesByLanguage(id, 1, 20);
|
const page = Math.max(1, Number(Array.isArray(sp.page) ? sp.page[0] : sp.page) || 1);
|
||||||
|
const initial = await entriesByLanguage(id, page, 20);
|
||||||
return <LanguageDetailClient id={id} initial={initial as any} />;
|
return <LanguageDetailClient id={id} initial={initial as any} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,20 +1,19 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
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 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, initial }: { id: number; initial: Paged<Item> }) {
|
export default function MachineTypeDetailClient({ id, initial }: { id: number; initial: Paged<Item> }) {
|
||||||
const [data] = useState<Paged<Item>>(initial);
|
const totalPages = useMemo(() => Math.max(1, Math.ceil(initial.total / initial.pageSize)), [initial]);
|
||||||
const totalPages = useMemo(() => Math.max(1, Math.ceil(data.total / data.pageSize)), [data]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container">
|
<div className="container">
|
||||||
<h1>Machine Type #{id}</h1>
|
<h1>Machine Type #{id}</h1>
|
||||||
{data && data.items.length === 0 && <div className="alert alert-warning">No entries.</div>}
|
{initial && initial.items.length === 0 && <div className="alert alert-warning">No entries.</div>}
|
||||||
{data && data.items.length > 0 && (
|
{initial && initial.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">
|
||||||
<thead>
|
<thead>
|
||||||
@@ -26,7 +25,7 @@ export default function MachineTypeDetailClient({ id, initial }: { id: number; i
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
{data.items.map((it) => (
|
{initial.items.map((it) => (
|
||||||
<tr key={it.id}>
|
<tr key={it.id}>
|
||||||
<td>{it.id}</td>
|
<td>{it.id}</td>
|
||||||
<td><Link href={`/zxdb/entries/${it.id}`}>{it.title}</Link></td>
|
<td><Link href={`/zxdb/entries/${it.id}`}>{it.title}</Link></td>
|
||||||
@@ -40,7 +39,23 @@ export default function MachineTypeDetailClient({ id, initial }: { id: number; i
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="d-flex align-items-center gap-2 mt-2">
|
<div className="d-flex align-items-center gap-2 mt-2">
|
||||||
<span>Page {data.page} / {totalPages}</span>
|
<span>Page {initial.page} / {totalPages}</span>
|
||||||
|
<div className="ms-auto d-flex gap-2">
|
||||||
|
<Link
|
||||||
|
className={`btn btn-sm btn-outline-secondary ${initial.page <= 1 ? "disabled" : ""}`}
|
||||||
|
aria-disabled={initial.page <= 1}
|
||||||
|
href={`/zxdb/machinetypes/${id}?page=${Math.max(1, initial.page - 1)}`}
|
||||||
|
>
|
||||||
|
Prev
|
||||||
|
</Link>
|
||||||
|
<Link
|
||||||
|
className={`btn btn-sm btn-outline-secondary ${initial.page >= totalPages ? "disabled" : ""}`}
|
||||||
|
aria-disabled={initial.page >= totalPages}
|
||||||
|
href={`/zxdb/machinetypes/${id}?page=${Math.min(totalPages, initial.page + 1)}`}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -2,12 +2,13 @@ import MachineTypeDetailClient from "./MachineTypeDetail";
|
|||||||
import { entriesByMachinetype } from "@/server/repo/zxdb";
|
import { entriesByMachinetype } from "@/server/repo/zxdb";
|
||||||
|
|
||||||
export const metadata = { title: "ZXDB Machine Type" };
|
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, searchParams }: { params: Promise<{ id: string }>; searchParams: Promise<{ [key: string]: string | string[] | undefined }> }) {
|
||||||
|
const [{ id }, sp] = await Promise.all([params, searchParams]);
|
||||||
export default async function Page({ params }: { params: Promise<{ id: string }> }) {
|
|
||||||
const { id } = await params;
|
|
||||||
const numericId = Number(id);
|
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 <MachineTypeDetailClient id={numericId} initial={initial as any} />;
|
return <MachineTypeDetailClient id={numericId} initial={initial as any} />;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,12 +5,16 @@ export const metadata = {
|
|||||||
title: "ZXDB Explorer",
|
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() {
|
export default async function Page({ searchParams }: { searchParams: Promise<{ [key: string]: string | string[] | undefined }> }) {
|
||||||
// Server-render initial page (no query) to avoid first client fetch
|
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([
|
const [initial, genres, langs, machines] = await Promise.all([
|
||||||
searchEntries({ page: 1, pageSize: 20, sort: "id_desc" }),
|
searchEntries({ page, pageSize: 20, sort: "id_desc" }),
|
||||||
listGenres(),
|
listGenres(),
|
||||||
listLanguages(),
|
listLanguages(),
|
||||||
listMachinetypes(),
|
listMachinetypes(),
|
||||||
|
|||||||
@@ -68,7 +68,7 @@ export async function searchEntries(params: SearchParams): Promise<PagedResult<S
|
|||||||
|
|
||||||
const whereExpr = whereClauses.length ? and(...whereClauses) : undefined;
|
const whereExpr = whereClauses.length ? and(...whereClauses) : undefined;
|
||||||
|
|
||||||
const [items, [{ total }]] = await Promise.all([
|
const [items, countRows] = await Promise.all([
|
||||||
db
|
db
|
||||||
.select()
|
.select()
|
||||||
.from(entries)
|
.from(entries)
|
||||||
@@ -76,16 +76,13 @@ export async function searchEntries(params: SearchParams): Promise<PagedResult<S
|
|||||||
.orderBy(sort === "id_desc" ? desc(entries.id) : entries.title)
|
.orderBy(sort === "id_desc" ? desc(entries.id) : entries.title)
|
||||||
.limit(pageSize)
|
.limit(pageSize)
|
||||||
.offset(offset),
|
.offset(offset),
|
||||||
db.execute(
|
db
|
||||||
sql`select count(*) as total from ${entries} ${whereExpr ? sql`where ${whereExpr}` : sql``}`
|
.select({ total: sql<number>`count(*)` })
|
||||||
) as Promise<any>,
|
.from(entries)
|
||||||
|
.where(whereExpr as any) as unknown as Promise<{ total: number }[]>,
|
||||||
]);
|
]);
|
||||||
return {
|
const total = Number(countRows?.[0]?.total ?? 0);
|
||||||
items: items as any,
|
return { items: items as any, page, pageSize, total };
|
||||||
page,
|
|
||||||
pageSize,
|
|
||||||
total: Number((total as any) ?? 0),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const pattern = `%${q.toLowerCase().replace(/[^a-z0-9]+/g, "")}%`;
|
const pattern = `%${q.toLowerCase().replace(/[^a-z0-9]+/g, "")}%`;
|
||||||
@@ -201,11 +198,14 @@ export async function searchLabels(params: LabelSearchParams): Promise<PagedResu
|
|||||||
const offset = (page - 1) * pageSize;
|
const offset = (page - 1) * pageSize;
|
||||||
|
|
||||||
if (!q) {
|
if (!q) {
|
||||||
const [items, [{ total }]] = await Promise.all([
|
const [items, countRows] = await Promise.all([
|
||||||
db.select().from(labels).orderBy(labels.name).limit(pageSize).offset(offset),
|
db.select().from(labels).orderBy(labels.name).limit(pageSize).offset(offset),
|
||||||
db.execute(sql`select count(*) as total from ${labels}`) as Promise<any>,
|
db
|
||||||
|
.select({ total: sql<number>`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
|
// 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<PagedResult<SearchResultItem>> {
|
export async function entriesByGenre(genreId: number, page: number, pageSize: number): Promise<PagedResult<SearchResultItem>> {
|
||||||
const offset = (page - 1) * pageSize;
|
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<number>`count(*)` })
|
||||||
|
.from(entries)
|
||||||
|
.where(eq(entries.genretypeId, genreId as any))) as unknown as { total: number }[];
|
||||||
const items = await db
|
const items = await db
|
||||||
.select({ id: entries.id, title: entries.title, isXrated: entries.isXrated, machinetypeId: entries.machinetypeId, languageId: entries.languageId })
|
.select({ id: entries.id, title: entries.title, isXrated: entries.isXrated, machinetypeId: entries.machinetypeId, languageId: entries.languageId })
|
||||||
.from(entries)
|
.from(entries)
|
||||||
@@ -321,12 +324,15 @@ export async function entriesByGenre(genreId: number, page: number, pageSize: nu
|
|||||||
.orderBy(entries.title)
|
.orderBy(entries.title)
|
||||||
.limit(pageSize)
|
.limit(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(countRows?.[0]?.total ?? 0) };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function entriesByLanguage(langId: string, page: number, pageSize: number): Promise<PagedResult<SearchResultItem>> {
|
export async function entriesByLanguage(langId: string, page: number, pageSize: number): Promise<PagedResult<SearchResultItem>> {
|
||||||
const offset = (page - 1) * pageSize;
|
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<number>`count(*)` })
|
||||||
|
.from(entries)
|
||||||
|
.where(eq(entries.languageId, langId as any))) as unknown as { total: number }[];
|
||||||
const items = await db
|
const items = await db
|
||||||
.select({ id: entries.id, title: entries.title, isXrated: entries.isXrated, machinetypeId: entries.machinetypeId, languageId: entries.languageId })
|
.select({ id: entries.id, title: entries.title, isXrated: entries.isXrated, machinetypeId: entries.machinetypeId, languageId: entries.languageId })
|
||||||
.from(entries)
|
.from(entries)
|
||||||
@@ -334,12 +340,15 @@ export async function entriesByLanguage(langId: string, page: number, pageSize:
|
|||||||
.orderBy(entries.title)
|
.orderBy(entries.title)
|
||||||
.limit(pageSize)
|
.limit(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(countRows?.[0]?.total ?? 0) };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function entriesByMachinetype(mtId: number, page: number, pageSize: number): Promise<PagedResult<SearchResultItem>> {
|
export async function entriesByMachinetype(mtId: number, page: number, pageSize: number): Promise<PagedResult<SearchResultItem>> {
|
||||||
const offset = (page - 1) * pageSize;
|
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<number>`count(*)` })
|
||||||
|
.from(entries)
|
||||||
|
.where(eq(entries.machinetypeId, mtId as any))) as unknown as { total: number }[];
|
||||||
const items = await db
|
const items = await db
|
||||||
.select({ id: entries.id, title: entries.title, isXrated: entries.isXrated, machinetypeId: entries.machinetypeId, languageId: entries.languageId })
|
.select({ id: entries.id, title: entries.title, isXrated: entries.isXrated, machinetypeId: entries.machinetypeId, languageId: entries.languageId })
|
||||||
.from(entries)
|
.from(entries)
|
||||||
@@ -347,7 +356,7 @@ export async function entriesByMachinetype(mtId: number, page: number, pageSize:
|
|||||||
.orderBy(entries.title)
|
.orderBy(entries.title)
|
||||||
.limit(pageSize)
|
.limit(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(countRows?.[0]?.total ?? 0) };
|
||||||
}
|
}
|
||||||
|
|
||||||
// ----- Facets for search -----
|
// ----- Facets for search -----
|
||||||
|
|||||||
Reference in New Issue
Block a user