From 5d140a45a778256b73fbfc4c56dfe471c42be1e9 Mon Sep 17 00:00:00 2001 From: "D. Rimron-Soutter" Date: Sat, 10 Jan 2026 17:05:37 +0000 Subject: [PATCH] Enhance ZXDB releases and caching Signed-off-by: codex@lucy.xalior.com --- src/app/zxdb/releases/ReleasesExplorer.tsx | 35 +- .../[entryId]/[releaseSeq]/ReleaseDetail.tsx | 443 ++++++++++++++++++ .../releases/[entryId]/[releaseSeq]/page.tsx | 16 + src/app/zxdb/releases/page.tsx | 20 +- src/server/repo/zxdb.ts | 46 +- 5 files changed, 519 insertions(+), 41 deletions(-) create mode 100644 src/app/zxdb/releases/[entryId]/[releaseSeq]/ReleaseDetail.tsx create mode 100644 src/app/zxdb/releases/[entryId]/[releaseSeq]/page.tsx diff --git a/src/app/zxdb/releases/ReleasesExplorer.tsx b/src/app/zxdb/releases/ReleasesExplorer.tsx index 6d15037..84b0356 100644 --- a/src/app/zxdb/releases/ReleasesExplorer.tsx +++ b/src/app/zxdb/releases/ReleasesExplorer.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import Link from "next/link"; import EntryLink from "../components/EntryLink"; import { usePathname, useRouter } from "next/navigation"; @@ -22,6 +22,8 @@ type Paged = { export default function ReleasesExplorer({ initial, initialUrlState, + initialUrlHasParams, + initialLists, }: { initial?: Paged; initialUrlState?: { @@ -37,6 +39,15 @@ export default function ReleasesExplorer({ casetypeId?: string; isDemo?: string; // "1" or "true" }; + initialUrlHasParams?: boolean; + initialLists?: { + languages: { id: string; name: string }[]; + machinetypes: { id: number; name: string }[]; + filetypes: { id: number; name: string }[]; + schemetypes: { id: string; name: string }[]; + sourcetypes: { id: string; name: string }[]; + casetypes: { id: string; name: string }[]; + }; }) { const router = useRouter(); const pathname = usePathname(); @@ -57,12 +68,13 @@ export default function ReleasesExplorer({ const [casetypeId, setCasetypeId] = useState(initialUrlState?.casetypeId ?? ""); const [isDemo, setIsDemo] = useState(!!(initialUrlState?.isDemo && (initialUrlState.isDemo === "1" || initialUrlState.isDemo === "true"))); - const [langs, setLangs] = useState<{ id: string; name: string }[]>([]); - const [machines, setMachines] = useState<{ id: number; name: string }[]>([]); - const [filetypes, setFiletypes] = useState<{ id: number; name: string }[]>([]); - const [schemes, setSchemes] = useState<{ id: string; name: string }[]>([]); - const [sources, setSources] = useState<{ id: string; name: string }[]>([]); - const [cases, setCases] = useState<{ id: string; name: string }[]>([]); + const [langs, setLangs] = useState<{ id: string; name: string }[]>(initialLists?.languages ?? []); + const [machines, setMachines] = useState<{ id: number; name: string }[]>(initialLists?.machinetypes ?? []); + const [filetypes, setFiletypes] = useState<{ id: number; name: string }[]>(initialLists?.filetypes ?? []); + const [schemes, setSchemes] = useState<{ id: string; name: string }[]>(initialLists?.schemetypes ?? []); + const [sources, setSources] = useState<{ id: string; name: string }[]>(initialLists?.sourcetypes ?? []); + const [cases, setCases] = useState<{ id: string; name: string }[]>(initialLists?.casetypes ?? []); + const initialLoad = useRef(true); const pageSize = 20; const totalPages = useMemo(() => (data ? Math.max(1, Math.ceil(data.total / data.pageSize)) : 1), [data]); @@ -135,9 +147,17 @@ export default function ReleasesExplorer({ (initialUrlState?.casetypeId ?? "") === casetypeId && (!!initialUrlState?.isDemo === isDemo) ) { + if (initialLoad.current) { + initialLoad.current = false; + return; + } updateUrl(page); return; } + if (initialLoad.current) { + initialLoad.current = false; + if (initial && !initialUrlHasParams) return; + } updateUrl(page); fetchData(q, page); }, [page, year, sort, dLanguageId, dMachinetypeId, filetypeId, schemetypeId, sourcetypeId, casetypeId, isDemo]); @@ -152,6 +172,7 @@ export default function ReleasesExplorer({ // Load filter option lists on mount useEffect(() => { async function loadLists() { + if (langs.length || machines.length || filetypes.length || schemes.length || sources.length || cases.length) return; try { const [l, m, ft, sc, so, ca] = await Promise.all([ fetch("/api/zxdb/languages", { cache: "force-cache" }).then((r) => r.json()), diff --git a/src/app/zxdb/releases/[entryId]/[releaseSeq]/ReleaseDetail.tsx b/src/app/zxdb/releases/[entryId]/[releaseSeq]/ReleaseDetail.tsx new file mode 100644 index 0000000..ed499de --- /dev/null +++ b/src/app/zxdb/releases/[entryId]/[releaseSeq]/ReleaseDetail.tsx @@ -0,0 +1,443 @@ +"use client"; + +import Link from "next/link"; + +type ReleaseDetailData = { + entry: { + id: number; + title: string; + issueId: number | null; + }; + release: { + entryId: number; + releaseSeq: number; + year: number | null; + month: number | null; + day: number | null; + currency: { id: string | null; name: string | null; symbol: string | null; prefix: number | null }; + prices: { + release: number | null; + budget: number | null; + microdrive: number | null; + disk: number | null; + cartridge: number | null; + }; + book: { isbn: string | null; pages: number | null }; + }; + downloads: Array<{ + id: number; + link: string; + size: number | null; + md5: string | null; + comments: string | null; + isDemo: boolean; + type: { id: number; name: string }; + language: { id: string | null; name: string | null }; + machinetype: { id: number | null; name: string | null }; + scheme: { id: string | null; name: string | null }; + source: { id: string | null; name: string | null }; + case: { id: string | null; name: string | null }; + year: number | null; + }>; + scraps: Array<{ + id: number; + link: string | null; + size: number | null; + comments: string | null; + rationale: string; + isDemo: boolean; + type: { id: number; name: string }; + language: { id: string | null; name: string | null }; + machinetype: { id: number | null; name: string | null }; + scheme: { id: string | null; name: string | null }; + source: { id: string | null; name: string | null }; + case: { id: string | null; name: string | null }; + year: number | null; + }>; + files: Array<{ + id: number; + link: string; + size: number | null; + md5: string | null; + comments: string | null; + type: { id: number; name: string }; + }>; + magazineRefs: Array<{ + id: number; + issueId: number; + magazineId: number | null; + magazineName: string | null; + referencetypeId: number; + referencetypeName: string | null; + page: number; + isOriginal: number; + scoreGroup: string; + issue: { + dateYear: number | null; + dateMonth: number | null; + dateDay: number | null; + volume: number | null; + number: number | null; + special: string | null; + supplement: string | null; + }; + }>; +}; + +function formatIssue(issue: ReleaseDetailData["magazineRefs"][number]["issue"]) { + const parts: string[] = []; + if (issue.volume != null) parts.push(`v.${issue.volume}`); + if (issue.number != null) parts.push(`#${issue.number}`); + if (issue.dateYear != null) { + let date = `${issue.dateYear}`; + if (issue.dateMonth != null) { + const mm = String(issue.dateMonth).padStart(2, "0"); + date += `/${mm}`; + if (issue.dateDay != null) { + const dd = String(issue.dateDay).padStart(2, "0"); + date += `/${dd}`; + } + } + parts.push(date); + } + if (issue.special) parts.push(`special "${issue.special}"`); + if (issue.supplement) parts.push(`supplement "${issue.supplement}"`); + return parts.join(" "); +} + +function formatCurrency(value: number | null, currency: ReleaseDetailData["release"]["currency"]) { + if (value == null) return "-"; + if (currency.symbol) { + return currency.prefix ? `${currency.symbol}${value}` : `${value}${currency.symbol}`; + } + if (currency.name) return `${value} ${currency.name}`; + return String(value); +} + +export default function ReleaseDetailClient({ data }: { data: ReleaseDetailData | null }) { + if (!data) return
Not found
; + + return ( +
+ + +
+

Release #{data.release.releaseSeq}

+ + {data.entry.title} + +
+ +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
FieldValue
Entry + #{data.entry.id} +
Release Sequence#{data.release.releaseSeq}
Release Date + {data.release.year != null ? ( + + {data.release.year} + {data.release.month != null ? `/${String(data.release.month).padStart(2, "0")}` : ""} + {data.release.day != null ? `/${String(data.release.day).padStart(2, "0")}` : ""} + + ) : ( + - + )} +
Currency + {data.release.currency.id ? ( + {data.release.currency.id} {data.release.currency.name ? `(${data.release.currency.name})` : ""} + ) : ( + - + )} +
Price{formatCurrency(data.release.prices.release, data.release.currency)}
Budget Price{formatCurrency(data.release.prices.budget, data.release.currency)}
Microdrive Price{formatCurrency(data.release.prices.microdrive, data.release.currency)}
Disk Price{formatCurrency(data.release.prices.disk, data.release.currency)}
Cartridge Price{formatCurrency(data.release.prices.cartridge, data.release.currency)}
Book ISBN{data.release.book.isbn ?? -}
Book Pages{data.release.book.pages ?? -}
+
+ +
+ +
+
Magazine References
+ {data.magazineRefs.length === 0 &&
No magazine references
} + {data.magazineRefs.length > 0 && ( +
+ + + + + + + + + + + + + {data.magazineRefs.map((m) => ( + + + + + + + + + ))} + +
MagazineIssueTypePageOriginalNotes
+ {m.magazineId != null ? ( + {m.magazineName ?? `#${m.magazineId}`} + ) : ( + - + )} + + #{m.issueId} +
{formatIssue(m.issue) || "-"}
+
{m.referencetypeName ?? `#${m.referencetypeId}`}{m.page}{m.isOriginal ? "Yes" : "No"}{m.scoreGroup || "-"}
+
+ )} +
+ +
+ +
+
Downloads
+ {data.downloads.length === 0 &&
No downloads
} + {data.downloads.length > 0 && ( +
+ + + + + + + + + + + + + + {data.downloads.map((d) => { + const isHttp = d.link.startsWith("http://") || d.link.startsWith("https://"); + return ( + + + + + + + + + + ); + })} + +
TypeLinkSizeMD5FlagsDetailsComments
{d.type.name} + {isHttp ? ( + {d.link} + ) : ( + {d.link} + )} + {typeof d.size === "number" ? d.size.toLocaleString() : "-"}{d.md5 ?? "-"} +
+ {d.isDemo ? Demo : null} + {d.scheme.name ? {d.scheme.name} : null} + {d.source.name ? {d.source.name} : null} + {d.case.name ? {d.case.name} : null} +
+
+
+ {d.language.name ? ( + {d.language.name} + ) : null} + {d.machinetype.name ? ( + {d.machinetype.name} + ) : null} + {typeof d.year === "number" ? {d.year} : null} +
+
{d.comments ?? ""}
+
+ )} +
+ +
+ +
+
Scraps / Media
+ {data.scraps.length === 0 &&
No scraps
} + {data.scraps.length > 0 && ( +
+ + + + + + + + + + + + + {data.scraps.map((s) => { + const isHttp = s.link?.startsWith("http://") || s.link?.startsWith("https://"); + return ( + + + + + + + + + ); + })} + +
TypeLinkSizeFlagsDetailsRationale
{s.type.name} + {s.link ? ( + isHttp ? ( + {s.link} + ) : ( + {s.link} + ) + ) : ( + - + )} + {typeof s.size === "number" ? s.size.toLocaleString() : "-"} +
+ {s.isDemo ? Demo : null} + {s.scheme.name ? {s.scheme.name} : null} + {s.source.name ? {s.source.name} : null} + {s.case.name ? {s.case.name} : null} +
+
+
+ {s.language.name ? ( + {s.language.name} + ) : null} + {s.machinetype.name ? ( + {s.machinetype.name} + ) : null} + {typeof s.year === "number" ? {s.year} : null} +
+
{s.rationale}
+
+ )} +
+ +
+ +
+
Issue Files
+ {data.files.length === 0 &&
No files linked
} + {data.files.length > 0 && ( +
+ + + + + + + + + + + + {data.files.map((f) => { + const isHttp = f.link.startsWith("http://") || f.link.startsWith("https://"); + return ( + + + + + + + + ); + })} + +
TypeLinkSizeMD5Comments
{f.type.name} + {isHttp ? ( + {f.link} + ) : ( + {f.link} + )} + {f.size != null ? new Intl.NumberFormat().format(f.size) : "-"}{f.md5 ?? "-"}{f.comments ?? ""}
+
+ )} +
+ +
+ +
+ Permalink + Back to Releases +
+
+ ); +} diff --git a/src/app/zxdb/releases/[entryId]/[releaseSeq]/page.tsx b/src/app/zxdb/releases/[entryId]/[releaseSeq]/page.tsx new file mode 100644 index 0000000..4c11778 --- /dev/null +++ b/src/app/zxdb/releases/[entryId]/[releaseSeq]/page.tsx @@ -0,0 +1,16 @@ +import ReleaseDetailClient from "./ReleaseDetail"; +import { getReleaseDetail } from "@/server/repo/zxdb"; + +export const metadata = { + title: "ZXDB Release", +}; + +export const revalidate = 3600; + +export default async function Page({ params }: { params: Promise<{ entryId: string; releaseSeq: string }> }) { + const { entryId, releaseSeq } = await params; + const entryIdNum = Number(entryId); + const releaseSeqNum = Number(releaseSeq); + const data = await getReleaseDetail(entryIdNum, releaseSeqNum); + return ; +} diff --git a/src/app/zxdb/releases/page.tsx b/src/app/zxdb/releases/page.tsx index a4f195d..6aad703 100644 --- a/src/app/zxdb/releases/page.tsx +++ b/src/app/zxdb/releases/page.tsx @@ -1,5 +1,5 @@ import ReleasesExplorer from "./ReleasesExplorer"; -import { searchReleases } from "@/server/repo/zxdb"; +import { listCasetypes, listFiletypes, listLanguages, listMachinetypes, listSchemetypes, listSourcetypes, searchReleases } from "@/server/repo/zxdb"; export const metadata = { title: "ZXDB Releases", @@ -9,6 +9,7 @@ export const dynamic = "force-dynamic"; export default async function Page({ searchParams }: { searchParams: Promise<{ [key: string]: string | string[] | undefined }> }) { const sp = await searchParams; + const hasParams = Object.values(sp).some((value) => value !== undefined); const page = Math.max(1, Number(Array.isArray(sp.page) ? sp.page[0] : sp.page) || 1); const q = (Array.isArray(sp.q) ? sp.q[0] : sp.q) ?? ""; const yearStr = (Array.isArray(sp.year) ? sp.year[0] : sp.year) ?? ""; @@ -25,8 +26,14 @@ export default async function Page({ searchParams }: { searchParams: Promise<{ [ const isDemoStr = (Array.isArray(sp.isDemo) ? sp.isDemo[0] : sp.isDemo) ?? ""; const isDemo = isDemoStr ? (isDemoStr === "true" || isDemoStr === "1") : undefined; - const [initial] = await Promise.all([ + const [initial, langs, machines, filetypes, schemes, sources, cases] = await Promise.all([ searchReleases({ page, pageSize: 20, q, year, sort, dLanguageId: dLanguageId || undefined, dMachinetypeId, filetypeId, schemetypeId: schemetypeId || undefined, sourcetypeId: sourcetypeId || undefined, casetypeId: casetypeId || undefined, isDemo }), + listLanguages(), + listMachinetypes(), + listFiletypes(), + listSchemetypes(), + listSourcetypes(), + listCasetypes(), ]); // Ensure the object passed to a Client Component is a plain JSON value @@ -35,7 +42,16 @@ export default async function Page({ searchParams }: { searchParams: Promise<{ [ return ( ); } diff --git a/src/server/repo/zxdb.ts b/src/server/repo/zxdb.ts index 0b43a14..e655914 100644 --- a/src/server/repo/zxdb.ts +++ b/src/server/repo/zxdb.ts @@ -1,4 +1,5 @@ import { and, desc, eq, like, sql, asc } from "drizzle-orm"; +import { cache } from "react"; // import { alias } from "drizzle-orm/mysql-core"; import { db } from "@/server/db"; import { @@ -731,15 +732,9 @@ export async function getLabelPublishedEntries(labelId: number, params: LabelCon // ----- Lookups lists and category browsing ----- -export async function listGenres() { - return db.select().from(genretypes).orderBy(genretypes.name); -} -export async function listLanguages() { - return db.select().from(languages).orderBy(languages.name); -} -export async function listMachinetypes() { - return db.select().from(machinetypes).orderBy(machinetypes.name); -} +export const listGenres = cache(async () => db.select().from(genretypes).orderBy(genretypes.name)); +export const listLanguages = cache(async () => db.select().from(languages).orderBy(languages.name)); +export const listMachinetypes = cache(async () => db.select().from(machinetypes).orderBy(machinetypes.name)); // Note: ZXDB structure in this project does not include a `releasetypes` table. // Do not attempt to query it here. @@ -1546,35 +1541,22 @@ export async function getReleaseDetail(entryId: number, releaseSeq: number): Pro } // ----- Download/lookups simple lists ----- -export async function listFiletypes() { - return db.select().from(filetypes).orderBy(filetypes.name); -} -export async function listSchemetypes() { - return db.select().from(schemetypes).orderBy(schemetypes.name); -} -export async function listSourcetypes() { - return db.select().from(sourcetypes).orderBy(sourcetypes.name); -} -export async function listCasetypes() { - return db.select().from(casetypes).orderBy(casetypes.name); -} +export const listFiletypes = cache(async () => db.select().from(filetypes).orderBy(filetypes.name)); +export const listSchemetypes = cache(async () => db.select().from(schemetypes).orderBy(schemetypes.name)); +export const listSourcetypes = cache(async () => db.select().from(sourcetypes).orderBy(sourcetypes.name)); +export const listCasetypes = cache(async () => db.select().from(casetypes).orderBy(casetypes.name)); // Newly exposed lookups -export async function listAvailabletypes() { - return db.select().from(availabletypes).orderBy(availabletypes.name); -} +export const listAvailabletypes = cache(async () => db.select().from(availabletypes).orderBy(availabletypes.name)); -export async function listCurrencies() { - // Preserve full fields for UI needs - return db +export const listCurrencies = cache(async () => + db .select({ id: currencies.id, name: currencies.name, symbol: currencies.symbol, prefix: currencies.prefix }) .from(currencies) - .orderBy(currencies.name); -} + .orderBy(currencies.name) +); -export async function listRoletypes() { - return db.select().from(roletypes).orderBy(roletypes.name); -} +export const listRoletypes = cache(async () => db.select().from(roletypes).orderBy(roletypes.name)); export async function listMagazines(params: { q?: string; page?: number; pageSize?: number }): Promise> { const q = (params.q ?? "").trim();