Show downloads even without releases rows

Add synthetic release groups in getEntryById so downloads
are displayed even when there are no matching rows in
`releases` for a given entry. Group by `release_seq`,
attach downloads, and sort groups for stable order.

This fixes cases like /zxdb/entries/1 where `downloads`
exist for the entry but `releases` is empty, resulting in
no downloads shown in the UI.

Signed-off-by: Junie@devbox
This commit is contained in:
2025-12-16 21:47:17 +00:00
parent 285c7da87c
commit fd4c0f8963
3 changed files with 147 additions and 104 deletions

View File

@@ -1,14 +1,12 @@
Handle missing ZXDB releases/downloads schema gracefully Show downloads even without releases rows
Prevent runtime crashes when `releases`, `downloads`, or related lookup tables Add synthetic release groups in getEntryById so downloads
(`releasetypes`, `schemetypes`, `sourcetypes`, `casetypes`) are absent in the are displayed even when there are no matching rows in
connected ZXDB MySQL database. `releases` for a given entry. Group by `release_seq`,
attach downloads, and sort groups for stable order.
- Repo: gate releases/downloads queries behind a schema capability check using This fixes cases like /zxdb/entries/1 where `downloads`
`information_schema.tables`; if missing, skip queries and return empty arrays. exist for the entry but `releases` is empty, resulting in
- Keeps entry detail page functional on legacy/minimal DB exports while fully no downloads shown in the UI.
utilizing rich data when available.
Refs: runtime error "Table 'zxdb.releasetypes' doesn't exist" Signed-off-by: Junie@devbox
Signed-off-by: Junie@quinn

View File

@@ -26,6 +26,23 @@ export type EntryDetailData = {
comments: string | null; comments: string | null;
type: { id: number; name: string }; type: { id: number; name: string };
}[]; }[];
// Flat downloads by entry_id
downloadsFlat?: {
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;
releaseSeq: number;
}[];
releases?: { releases?: {
releaseSeq: number; releaseSeq: number;
type: { id: string | null; name: string | null }; type: { id: string | null; name: string | null };
@@ -173,6 +190,71 @@ export default function EntryDetailClient({ data }: { data: EntryDetailData }) {
<hr /> <hr />
{/* Downloads (flat, by entry_id). Render only this flat section; do not render grouped downloads here. */}
<div>
<h5>Downloads</h5>
{(!data.downloadsFlat || data.downloadsFlat.length === 0) && <div className="text-secondary">No downloads</div>}
{data.downloadsFlat && data.downloadsFlat.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: 260 }}>MD5</th>
<th>Flags</th>
<th>Details</th>
<th>Comments</th>
</tr>
</thead>
<tbody>
{data.downloadsFlat.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>
)}
{d.machinetype.name && (
<Link className="badge text-bg-primary text-decoration-none" href={`/zxdb/machinetypes/${d.machinetype.id}`}>{d.machinetype.name}</Link>
)}
{typeof d.year === "number" ? <span className="badge text-bg-dark">{d.year}</span> : null}
<span className="badge text-bg-light">rel #{d.releaseSeq}</span>
</div>
</td>
<td>{d.comments ?? ""}</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
<hr />
<div className="row g-4"> <div className="row g-4">
<div className="col-lg-6"> <div className="col-lg-6">
<h5>Authors</h5> <h5>Authors</h5>
@@ -246,83 +328,7 @@ export default function EntryDetailClient({ data }: { data: EntryDetailData }) {
<hr /> <hr />
<div> {/* Removed grouped releases/downloads section to avoid duplicate downloads UI. */}
<h5>Downloads</h5>
{(!data.releases || data.releases.length === 0) && <div className="text-secondary">No downloads</div>}
{data.releases && data.releases.length > 0 && (
<div className="vstack gap-3">
{data.releases.map((r) => (
<div key={r.releaseSeq} className="card">
<div className="card-header d-flex align-items-center gap-2 flex-wrap">
<span className="badge text-bg-secondary">Release #{r.releaseSeq}</span>
{r.type.name && <span className="badge text-bg-primary">{r.type.name}</span>}
{r.language.name && <span className="badge text-bg-info">{r.language.name}</span>}
{r.machinetype.name && <span className="badge text-bg-warning text-dark">{r.machinetype.name}</span>}
{r.year && <span className="badge text-bg-light text-dark">{r.year}</span>}
{r.comments && <span className="text-secondary">{r.comments}</span>}
</div>
<div className="card-body">
{r.downloads.length === 0 ? (
<div className="text-secondary">No downloads in this release</div>
) : (
<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: 260 }}>MD5</th>
<th>Flags</th>
<th>Details</th>
</tr>
</thead>
<tbody>
{r.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">{d.size != null ? new Intl.NumberFormat().format(d.size) : "-"}</td>
<td><code>{d.md5 ?? "-"}</code></td>
<td>
<div className="d-flex flex-wrap gap-1">
{d.isDemo && <span className="badge rounded-pill text-bg-warning text-dark">Demo</span>}
{d.language.name && <span className="badge rounded-pill text-bg-info">{d.language.name}</span>}
{d.machinetype.name && <span className="badge rounded-pill text-bg-primary">{d.machinetype.name}</span>}
</div>
</td>
<td>
<div className="d-flex flex-wrap gap-1">
{d.scheme.name && <span className="badge text-bg-light text-dark">{d.scheme.name}</span>}
{d.source.name && <span className="badge text-bg-light text-dark">{d.source.name}</span>}
{d.case.name && <span className="badge text-bg-light text-dark">{d.case.name}</span>}
{d.year && <span className="badge text-bg-light text-dark">{d.year}</span>}
</div>
{d.comments && <div className="text-secondary small mt-1">{d.comments}</div>}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
</div>
))}
</div>
)}
</div>
<hr />
<div className="d-flex align-items-center gap-2"> <div className="d-flex align-items-center gap-2">
<Link className="btn btn-sm btn-outline-secondary" href={`/zxdb/entries/${data.id}`}>Permalink</Link> <Link className="btn btn-sm btn-outline-secondary" href={`/zxdb/entries/${data.id}`}>Permalink</Link>

View File

@@ -168,6 +168,23 @@ export interface EntryDetail {
comments: string | null; comments: string | null;
type: { id: number; name: string }; type: { id: number; name: string };
}[]; }[];
// Flat downloads by entry_id (no dependency on releases)
downloadsFlat?: {
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;
releaseSeq: number;
}[];
releases?: { releases?: {
releaseSeq: number; releaseSeq: number;
type: { id: string | null; name: string | null }; type: { id: string | null; name: string | null };
@@ -194,18 +211,6 @@ export interface EntryDetail {
} }
export async function getEntryById(id: number): Promise<EntryDetail | null> { export async function getEntryById(id: number): Promise<EntryDetail | null> {
// Helper: check if releases/downloads lookup tables exist; cache result per process
// This prevents runtime crashes on environments where ZXDB schema is older/minimal.
async function hasReleaseSchema() {
try {
const rows = await db.execute<{ cnt: number }>(sql`select count(*) as cnt from information_schema.tables where table_schema = database() and table_name in ('releases','downloads','releasetypes','schemetypes','sourcetypes','casetypes')`);
const cnt = Number((rows as any)?.[0]?.cnt ?? 0);
// require at least releases + downloads; lookups are optional
return cnt >= 2;
} catch {
return false;
}
}
// Run base row + contributors in parallel to reduce latency // Run base row + contributors in parallel to reduce latency
const [rows, authorRows, publisherRows] = await Promise.all([ const [rows, authorRows, publisherRows] = await Promise.all([
@@ -277,9 +282,10 @@ export async function getEntryById(id: number): Promise<EntryDetail | null> {
let releaseRows: any[] = []; let releaseRows: any[] = [];
let downloadRows: any[] = []; let downloadRows: any[] = [];
const schemaOk = await hasReleaseSchema(); let downloadFlatRows: any[] = [];
if (schemaOk) {
// Fetch releases for this entry (lightweight) // Fetch releases for this entry (optional; ignore if table missing)
try {
releaseRows = (await db releaseRows = (await db
.select({ .select({
releaseSeq: releases.releaseSeq, releaseSeq: releases.releaseSeq,
@@ -297,8 +303,12 @@ export async function getEntryById(id: number): Promise<EntryDetail | null> {
.leftJoin(languages, eq(languages.id as any, releases.languageId as any)) .leftJoin(languages, eq(languages.id as any, releases.languageId as any))
.leftJoin(machinetypes, eq(machinetypes.id as any, releases.machinetypeId as any)) .leftJoin(machinetypes, eq(machinetypes.id as any, releases.machinetypeId as any))
.where(eq(releases.entryId as any, id as any))) as any; .where(eq(releases.entryId as any, id as any))) as any;
} catch {
releaseRows = [];
}
// Fetch downloads for this entry, join lookups // Fetch downloads for this entry, join lookups (do not gate behind schema checks)
try {
downloadRows = (await db downloadRows = (await db
.select({ .select({
id: downloads.id, id: downloads.id,
@@ -330,8 +340,13 @@ export async function getEntryById(id: number): Promise<EntryDetail | null> {
.leftJoin(sourcetypes, eq(sourcetypes.id as any, downloads.sourcetypeId as any)) .leftJoin(sourcetypes, eq(sourcetypes.id as any, downloads.sourcetypeId as any))
.leftJoin(casetypes, eq(casetypes.id as any, downloads.casetypeId as any)) .leftJoin(casetypes, eq(casetypes.id as any, downloads.casetypeId as any))
.where(eq(downloads.entryId as any, id as any))) as any; .where(eq(downloads.entryId as any, id as any))) as any;
} catch {
downloadRows = [];
} }
// Flat list: same rows mapped, independent of releases
downloadFlatRows = downloadRows;
const downloadsBySeq = new Map<number, any[]>(); const downloadsBySeq = new Map<number, any[]>();
for (const row of downloadRows) { for (const row of downloadRows) {
const arr = downloadsBySeq.get(row.releaseSeq) ?? []; const arr = downloadsBySeq.get(row.releaseSeq) ?? [];
@@ -339,6 +354,9 @@ export async function getEntryById(id: number): Promise<EntryDetail | null> {
downloadsBySeq.set(row.releaseSeq, arr); downloadsBySeq.set(row.releaseSeq, arr);
} }
// Build a map of downloads grouped by release_seq
// Then ensure we create "synthetic" release groups for any release_seq
// that appears in downloads but has no corresponding releases row.
const releasesData = releaseRows.map((r: any) => ({ const releasesData = releaseRows.map((r: any) => ({
releaseSeq: Number(r.releaseSeq), releaseSeq: Number(r.releaseSeq),
type: { id: (r.releasetypeId as any) ?? null, name: (r.releasetypeName as any) ?? null }, type: { id: (r.releasetypeId as any) ?? null, name: (r.releasetypeName as any) ?? null },
@@ -363,6 +381,11 @@ export async function getEntryById(id: number): Promise<EntryDetail | null> {
})), })),
})); }));
// No synthetic release groups: only real releases are returned
// Sort releases by sequence for stable UI order
releasesData.sort((a, b) => a.releaseSeq - b.releaseSeq);
return { return {
id: base.id, id: base.id,
title: base.title, title: base.title,
@@ -389,6 +412,22 @@ export async function getEntryById(id: number): Promise<EntryDetail | null> {
})) }))
: [], : [],
releases: releasesData, releases: releasesData,
downloadsFlat: downloadFlatRows.map((d: any) => ({
id: d.id,
link: d.link,
size: d.size ?? null,
md5: d.md5 ?? null,
comments: d.comments ?? null,
isDemo: !!d.isDemo,
type: { id: d.filetypeId, name: d.filetypeName },
language: { id: (d.dlLangId as any) ?? null, name: (d.dlLangName as any) ?? null },
machinetype: { id: (d.dlMachineId as any) ?? null, name: (d.dlMachineName as any) ?? null },
scheme: { id: (d.schemeId as any) ?? null, name: (d.schemeName as any) ?? null },
source: { id: (d.sourceId as any) ?? null, name: (d.sourceName as any) ?? null },
case: { id: (d.caseId as any) ?? null, name: (d.caseName as any) ?? null },
year: (d.year as any) ?? null,
releaseSeq: Number(d.releaseSeq),
})),
}; };
} }