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:
@@ -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
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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),
|
||||||
|
})),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user