Enhance ZXDB releases and caching

Signed-off-by: codex@lucy.xalior.com
This commit is contained in:
2026-01-10 17:05:37 +00:00
parent 208a06c351
commit 5d140a45a7
5 changed files with 519 additions and 41 deletions

View File

@@ -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<T> = {
export default function ReleasesExplorer({
initial,
initialUrlState,
initialUrlHasParams,
initialLists,
}: {
initial?: Paged<Item>;
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<string>(initialUrlState?.casetypeId ?? "");
const [isDemo, setIsDemo] = useState<boolean>(!!(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()),

View File

@@ -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 <div className="alert alert-warning">Not found</div>;
return (
<div>
<nav aria-label="breadcrumb">
<ol className="breadcrumb">
<li className="breadcrumb-item">
<Link href="/zxdb">ZXDB</Link>
</li>
<li className="breadcrumb-item">
<Link href="/zxdb/releases">Releases</Link>
</li>
<li className="breadcrumb-item">
<Link href={`/zxdb/entries/${data.entry.id}`}>{data.entry.title}</Link>
</li>
<li className="breadcrumb-item active" aria-current="page">Release #{data.release.releaseSeq}</li>
</ol>
</nav>
<div className="d-flex align-items-center gap-2 flex-wrap">
<h1 className="mb-0">Release #{data.release.releaseSeq}</h1>
<Link className="badge text-bg-secondary text-decoration-none" href={`/zxdb/entries/${data.entry.id}`}>
{data.entry.title}
</Link>
</div>
<hr />
<div className="table-responsive">
<table className="table table-striped table-hover align-middle">
<thead>
<tr>
<th style={{ width: 220 }}>Field</th>
<th>Value</th>
</tr>
</thead>
<tbody>
<tr>
<td>Entry</td>
<td>
<Link href={`/zxdb/entries/${data.entry.id}`}>#{data.entry.id}</Link>
</td>
</tr>
<tr>
<td>Release Sequence</td>
<td>#{data.release.releaseSeq}</td>
</tr>
<tr>
<td>Release Date</td>
<td>
{data.release.year != null ? (
<span>
{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")}` : ""}
</span>
) : (
<span className="text-secondary">-</span>
)}
</td>
</tr>
<tr>
<td>Currency</td>
<td>
{data.release.currency.id ? (
<span>{data.release.currency.id} {data.release.currency.name ? `(${data.release.currency.name})` : ""}</span>
) : (
<span className="text-secondary">-</span>
)}
</td>
</tr>
<tr>
<td>Price</td>
<td>{formatCurrency(data.release.prices.release, data.release.currency)}</td>
</tr>
<tr>
<td>Budget Price</td>
<td>{formatCurrency(data.release.prices.budget, data.release.currency)}</td>
</tr>
<tr>
<td>Microdrive Price</td>
<td>{formatCurrency(data.release.prices.microdrive, data.release.currency)}</td>
</tr>
<tr>
<td>Disk Price</td>
<td>{formatCurrency(data.release.prices.disk, data.release.currency)}</td>
</tr>
<tr>
<td>Cartridge Price</td>
<td>{formatCurrency(data.release.prices.cartridge, data.release.currency)}</td>
</tr>
<tr>
<td>Book ISBN</td>
<td>{data.release.book.isbn ?? <span className="text-secondary">-</span>}</td>
</tr>
<tr>
<td>Book Pages</td>
<td>{data.release.book.pages ?? <span className="text-secondary">-</span>}</td>
</tr>
</tbody>
</table>
</div>
<hr />
<div>
<h5>Magazine References</h5>
{data.magazineRefs.length === 0 && <div className="text-secondary">No magazine references</div>}
{data.magazineRefs.length > 0 && (
<div className="table-responsive">
<table className="table table-sm table-striped align-middle">
<thead>
<tr>
<th>Magazine</th>
<th>Issue</th>
<th style={{ width: 120 }}>Type</th>
<th style={{ width: 80 }}>Page</th>
<th style={{ width: 100 }}>Original</th>
<th>Notes</th>
</tr>
</thead>
<tbody>
{data.magazineRefs.map((m) => (
<tr key={m.id}>
<td>
{m.magazineId != null ? (
<Link href={`/zxdb/magazines/${m.magazineId}`}>{m.magazineName ?? `#${m.magazineId}`}</Link>
) : (
<span className="text-secondary">-</span>
)}
</td>
<td>
<Link href={`/zxdb/issues/${m.issueId}`}>#{m.issueId}</Link>
<div className="text-secondary small">{formatIssue(m.issue) || "-"}</div>
</td>
<td>{m.referencetypeName ?? `#${m.referencetypeId}`}</td>
<td>{m.page}</td>
<td>{m.isOriginal ? "Yes" : "No"}</td>
<td>{m.scoreGroup || "-"}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
<hr />
<div>
<h5>Downloads</h5>
{data.downloads.length === 0 && <div className="text-secondary">No downloads</div>}
{data.downloads.length > 0 && (
<div className="table-responsive">
<table className="table table-sm table-striped align-middle">
<thead>
<tr>
<th>Type</th>
<th>Link</th>
<th style={{ width: 120 }} className="text-end">Size</th>
<th style={{ width: 240 }}>MD5</th>
<th>Flags</th>
<th>Details</th>
<th>Comments</th>
</tr>
</thead>
<tbody>
{data.downloads.map((d) => {
const isHttp = d.link.startsWith("http://") || d.link.startsWith("https://");
return (
<tr key={d.id}>
<td><span className="badge text-bg-secondary">{d.type.name}</span></td>
<td>
{isHttp ? (
<a href={d.link} target="_blank" rel="noopener noreferrer">{d.link}</a>
) : (
<span>{d.link}</span>
)}
</td>
<td className="text-end">{typeof d.size === "number" ? d.size.toLocaleString() : "-"}</td>
<td><code>{d.md5 ?? "-"}</code></td>
<td>
<div className="d-flex gap-1 flex-wrap">
{d.isDemo ? <span className="badge text-bg-warning">Demo</span> : null}
{d.scheme.name ? <span className="badge text-bg-info">{d.scheme.name}</span> : null}
{d.source.name ? <span className="badge text-bg-light border">{d.source.name}</span> : null}
{d.case.name ? <span className="badge text-bg-secondary">{d.case.name}</span> : null}
</div>
</td>
<td>
<div className="d-flex gap-2 flex-wrap align-items-center">
{d.language.name ? (
<Link className="badge text-bg-info text-decoration-none" href={`/zxdb/languages/${d.language.id}`}>{d.language.name}</Link>
) : null}
{d.machinetype.name ? (
<Link className="badge text-bg-primary text-decoration-none" href={`/zxdb/machinetypes/${d.machinetype.id}`}>{d.machinetype.name}</Link>
) : null}
{typeof d.year === "number" ? <span className="badge text-bg-dark">{d.year}</span> : null}
</div>
</td>
<td>{d.comments ?? ""}</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
<hr />
<div>
<h5>Scraps / Media</h5>
{data.scraps.length === 0 && <div className="text-secondary">No scraps</div>}
{data.scraps.length > 0 && (
<div className="table-responsive">
<table className="table table-sm table-striped align-middle">
<thead>
<tr>
<th>Type</th>
<th>Link</th>
<th style={{ width: 120 }} className="text-end">Size</th>
<th>Flags</th>
<th>Details</th>
<th>Rationale</th>
</tr>
</thead>
<tbody>
{data.scraps.map((s) => {
const isHttp = s.link?.startsWith("http://") || s.link?.startsWith("https://");
return (
<tr key={s.id}>
<td><span className="badge text-bg-secondary">{s.type.name}</span></td>
<td>
{s.link ? (
isHttp ? (
<a href={s.link} target="_blank" rel="noopener noreferrer">{s.link}</a>
) : (
<span>{s.link}</span>
)
) : (
<span className="text-secondary">-</span>
)}
</td>
<td className="text-end">{typeof s.size === "number" ? s.size.toLocaleString() : "-"}</td>
<td>
<div className="d-flex gap-1 flex-wrap">
{s.isDemo ? <span className="badge text-bg-warning">Demo</span> : null}
{s.scheme.name ? <span className="badge text-bg-info">{s.scheme.name}</span> : null}
{s.source.name ? <span className="badge text-bg-light border">{s.source.name}</span> : null}
{s.case.name ? <span className="badge text-bg-secondary">{s.case.name}</span> : null}
</div>
</td>
<td>
<div className="d-flex gap-2 flex-wrap align-items-center">
{s.language.name ? (
<Link className="badge text-bg-info text-decoration-none" href={`/zxdb/languages/${s.language.id}`}>{s.language.name}</Link>
) : null}
{s.machinetype.name ? (
<Link className="badge text-bg-primary text-decoration-none" href={`/zxdb/machinetypes/${s.machinetype.id}`}>{s.machinetype.name}</Link>
) : null}
{typeof s.year === "number" ? <span className="badge text-bg-dark">{s.year}</span> : null}
</div>
</td>
<td>{s.rationale}</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
<hr />
<div>
<h5>Issue Files</h5>
{data.files.length === 0 && <div className="text-secondary">No files linked</div>}
{data.files.length > 0 && (
<div className="table-responsive">
<table className="table table-sm table-striped align-middle">
<thead>
<tr>
<th>Type</th>
<th>Link</th>
<th style={{ width: 120 }} className="text-end">Size</th>
<th style={{ width: 240 }}>MD5</th>
<th>Comments</th>
</tr>
</thead>
<tbody>
{data.files.map((f) => {
const isHttp = f.link.startsWith("http://") || f.link.startsWith("https://");
return (
<tr key={f.id}>
<td><span className="badge text-bg-secondary">{f.type.name}</span></td>
<td>
{isHttp ? (
<a href={f.link} target="_blank" rel="noopener noreferrer">{f.link}</a>
) : (
<span>{f.link}</span>
)}
</td>
<td className="text-end">{f.size != null ? new Intl.NumberFormat().format(f.size) : "-"}</td>
<td><code>{f.md5 ?? "-"}</code></td>
<td>{f.comments ?? ""}</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
<hr />
<div className="d-flex align-items-center gap-2">
<Link className="btn btn-sm btn-outline-secondary" href={`/zxdb/releases/${data.entry.id}/${data.release.releaseSeq}`}>Permalink</Link>
<Link className="btn btn-sm btn-outline-primary" href="/zxdb/releases">Back to Releases</Link>
</div>
</div>
);
}

View File

@@ -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 <ReleaseDetailClient data={data} />;
}

View File

@@ -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 (
<ReleasesExplorer
initial={initialPlain}
initialLists={{
languages: JSON.parse(JSON.stringify(langs)),
machinetypes: JSON.parse(JSON.stringify(machines)),
filetypes: JSON.parse(JSON.stringify(filetypes)),
schemetypes: JSON.parse(JSON.stringify(schemes)),
sourcetypes: JSON.parse(JSON.stringify(sources)),
casetypes: JSON.parse(JSON.stringify(cases)),
}}
initialUrlState={{ q, page, year: yearStr, sort, dLanguageId, dMachinetypeId: dMachinetypeIdStr, filetypeId: filetypeIdStr, schemetypeId, sourcetypeId, casetypeId, isDemo: isDemoStr }}
initialUrlHasParams={hasParams}
/>
);
}

View File

@@ -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<PagedResult<MagazineListItem>> {
const q = (params.q ?? "").trim();