Improve download viewer with grouping and inline previews

Group downloads and scraps by type in Entry and Release details

Add FileViewer component for .txt, .nfo, image, and PDF previews

Update download API to support inline view with correct MIME types

Signed-off-by: Junie@lucy.xalior.com
This commit is contained in:
2026-02-17 12:50:58 +00:00
parent 32985c33b9
commit f445aabcb4
4 changed files with 404 additions and 196 deletions

View File

@@ -40,11 +40,29 @@ export async function GET(req: NextRequest) {
const fileBuffer = fs.readFileSync(absolutePath);
const fileName = path.basename(absolutePath);
const ext = path.extname(fileName).toLowerCase();
// Determine Content-Type
let contentType = "application/octet-stream";
if (ext === ".txt" || ext === ".nfo") {
contentType = "text/plain; charset=utf-8";
} else if (ext === ".png") {
contentType = "image/png";
} else if (ext === ".jpg" || ext === ".jpeg") {
contentType = "image/jpeg";
} else if (ext === ".gif") {
contentType = "image/gif";
} else if (ext === ".pdf") {
contentType = "application/pdf";
}
const isView = searchParams.get("view") === "1";
const disposition = isView ? "inline" : "attachment";
return new NextResponse(fileBuffer, {
headers: {
"Content-Type": "application/octet-stream",
"Content-Disposition": `attachment; filename="${fileName}"`,
"Content-Type": contentType,
"Content-Disposition": `${disposition}; filename="${fileName}"`,
"Content-Length": stat.size.toString(),
},
});

View File

@@ -1,7 +1,9 @@
"use client";
import { useState, useMemo } from "react";
import Link from "next/link";
import ZxdbBreadcrumbs from "@/app/zxdb/components/ZxdbBreadcrumbs";
import FileViewer from "@/components/FileViewer";
type Label = { id: number; name: string; labeltypeId: string | null };
export type EntryDetailData = {
@@ -155,6 +157,20 @@ export type EntryDetailData = {
};
export default function EntryDetailClient({ data }: { data: EntryDetailData | null }) {
const [viewer, setViewer] = useState<{ url: string; title: string } | null>(null);
const groupedDownloads = useMemo(() => {
if (!data?.downloadsFlat) return [];
const groups = new Map<string, EntryDetailData["downloadsFlat"]>();
for (const d of data.downloadsFlat) {
const type = d.type.name;
const arr = groups.get(type) ?? [];
arr.push(d);
groups.set(type, arr);
}
return Array.from(groups.entries()).sort((a, b) => a[0].localeCompare(b[0]));
}, [data?.downloadsFlat]);
if (!data) return <div className="alert alert-warning">Not found</div>;
return (
@@ -372,73 +388,87 @@ export default function EntryDetailClient({ data }: { data: EntryDetailData | nu
<div className="card shadow-sm mb-3">
<div className="card-body">
<h5 className="card-title">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>
<div className="d-flex flex-column gap-1">
{isHttp ? (
<a href={d.link} target="_blank" rel="noopener noreferrer" className="text-break small">{d.link}</a>
) : (
<span className="text-break small">{d.link}</span>
)}
{d.localLink && (
<a href={d.localLink} className="btn btn-xs btn-success py-0 px-1" style={{ fontSize: "0.7rem", width: "fit-content" }}>
Local Mirror
</a>
)}
</div>
</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}
<Link className="badge text-bg-light text-decoration-none" href={`/zxdb/releases/${data.id}/${d.releaseSeq}`}>
rel #{d.releaseSeq}
</Link>
</div>
</td>
<td>{d.comments ?? ""}</td>
</tr>
);
})}
</tbody>
</table>
{groupedDownloads.length === 0 && <div className="text-secondary">No downloads</div>}
{groupedDownloads.map(([type, items]) => (
<div key={type} className="mb-4">
<h6 className="text-primary border-bottom pb-1 mb-2">{type}</h6>
<div className="table-responsive">
<table className="table table-sm table-striped align-middle">
<thead>
<tr>
<th>Link</th>
<th style={{ width: 100 }} className="text-end">Size</th>
<th style={{ width: 180 }}>MD5</th>
<th>Flags</th>
<th>Details</th>
<th>Comments</th>
</tr>
</thead>
<tbody>
{items?.map((d) => {
const isHttp = d.link.startsWith("http://") || d.link.startsWith("https://");
const fileName = d.link.split("/").pop() || "file";
const canPreview = d.localLink && fileName.toLowerCase().match(/\.(txt|nfo|png|jpg|jpeg|gif|pdf)$/);
return (
<tr key={d.id}>
<td>
<div className="d-flex flex-column gap-1">
<div className="d-flex align-items-center gap-2">
{isHttp ? (
<a href={d.link} target="_blank" rel="noopener noreferrer" className="text-break small">{d.link}</a>
) : (
<span className="text-break small">{d.link}</span>
)}
{canPreview && (
<button
className="btn btn-xs btn-outline-info py-0 px-1"
style={{ fontSize: "0.6rem" }}
onClick={() => setViewer({ url: d.localLink!, title: fileName })}
>
Preview
</button>
)}
</div>
{d.localLink && (
<a href={d.localLink} className="btn btn-xs btn-success py-0 px-1" style={{ fontSize: "0.7rem", width: "fit-content" }}>
Local Mirror
</a>
)}
</div>
</td>
<td className="text-end">{typeof d.size === "number" ? d.size.toLocaleString() : "-"}</td>
<td><code style={{ fontSize: "0.75rem" }}>{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}
<Link className="badge text-bg-light text-decoration-none" href={`/zxdb/releases/${data.id}/${d.releaseSeq}`}>
rel #{d.releaseSeq}
</Link>
</div>
</td>
<td className="small">{d.comments ?? ""}</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
)}
))}
</div>
</div>
@@ -885,6 +915,13 @@ export default function EntryDetailClient({ data }: { data: EntryDetailData | nu
</div>
</div>
</div>
{viewer && (
<FileViewer
url={viewer.url}
title={viewer.title}
onClose={() => setViewer(null)}
/>
)}
</div>
);
}

View File

@@ -1,7 +1,9 @@
"use client";
import { useState, useMemo } from "react";
import Link from "next/link";
import ZxdbBreadcrumbs from "@/app/zxdb/components/ZxdbBreadcrumbs";
import FileViewer from "@/components/FileViewer";
type ReleaseDetailData = {
entry: {
@@ -170,6 +172,32 @@ function groupIssueRefs(refs: ReleaseDetailData["magazineRefs"]) {
}
export default function ReleaseDetailClient({ data }: { data: ReleaseDetailData | null }) {
const [viewer, setViewer] = useState<{ url: string; title: string } | null>(null);
const groupedDownloads = useMemo(() => {
if (!data?.downloads) return [];
const groups = new Map<string, ReleaseDetailData["downloads"]>();
for (const d of data.downloads) {
const type = d.type.name;
const arr = groups.get(type) ?? [];
arr.push(d);
groups.set(type, arr);
}
return Array.from(groups.entries()).sort((a, b) => a[0].localeCompare(b[0]));
}, [data?.downloads]);
const groupedScraps = useMemo(() => {
if (!data?.scraps) return [];
const groups = new Map<string, ReleaseDetailData["scraps"]>();
for (const s of data.scraps) {
const type = s.type.name;
const arr = groups.get(type) ?? [];
arr.push(s);
groups.set(type, arr);
}
return Array.from(groups.entries()).sort((a, b) => a[0].localeCompare(b[0]));
}, [data?.scraps]);
if (!data) return <div className="alert alert-warning">Not found</div>;
const magazineGroups = groupMagazineRefs(data.magazineRefs);
@@ -356,142 +384,170 @@ export default function ReleaseDetailClient({ data }: { data: ReleaseDetailData
<div className="card shadow-sm mb-3">
<div className="card-body">
<h5 className="card-title">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>
<div className="d-flex flex-column gap-1">
{isHttp ? (
<a href={d.link} target="_blank" rel="noopener noreferrer" className="text-break small">{d.link}</a>
) : (
<span className="text-break small">{d.link}</span>
)}
{d.localLink && (
<a href={d.localLink} className="btn btn-xs btn-success py-0 px-1" style={{ fontSize: "0.7rem", width: "fit-content" }}>
Local Mirror
</a>
)}
</div>
</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>
{groupedDownloads.length === 0 && <div className="text-secondary">No downloads</div>}
{groupedDownloads.map(([type, items]) => (
<div key={type} className="mb-4">
<h6 className="text-primary border-bottom pb-1 mb-2">{type}</h6>
<div className="table-responsive">
<table className="table table-sm table-striped align-middle">
<thead>
<tr>
<th>Link</th>
<th style={{ width: 100 }} className="text-end">Size</th>
<th style={{ width: 180 }}>MD5</th>
<th>Flags</th>
<th>Details</th>
<th>Comments</th>
</tr>
</thead>
<tbody>
{items?.map((d) => {
const isHttp = d.link.startsWith("http://") || d.link.startsWith("https://");
const fileName = d.link.split("/").pop() || "file";
const canPreview = d.localLink && fileName.toLowerCase().match(/\.(txt|nfo|png|jpg|jpeg|gif|pdf)$/);
return (
<tr key={d.id}>
<td>
<div className="d-flex flex-column gap-1">
<div className="d-flex align-items-center gap-2">
{isHttp ? (
<a href={d.link} target="_blank" rel="noopener noreferrer" className="text-break small">{d.link}</a>
) : (
<span className="text-break small">{d.link}</span>
)}
{canPreview && (
<button
className="btn btn-xs btn-outline-info py-0 px-1"
style={{ fontSize: "0.6rem" }}
onClick={() => setViewer({ url: d.localLink!, title: fileName })}
>
Preview
</button>
)}
</div>
{d.localLink && (
<a href={d.localLink} className="btn btn-xs btn-success py-0 px-1" style={{ fontSize: "0.7rem", width: "fit-content" }}>
Local Mirror
</a>
)}
</div>
</td>
<td className="text-end">{typeof d.size === "number" ? d.size.toLocaleString() : "-"}</td>
<td><code style={{ fontSize: "0.75rem" }}>{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 className="small">{d.comments ?? ""}</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
)}
))}
</div>
</div>
<div className="card shadow-sm mb-3">
<div className="card-body">
<h5 className="card-title">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>
<div className="d-flex flex-column gap-1">
{s.link ? (
isHttp ? (
<a href={s.link} target="_blank" rel="noopener noreferrer" className="text-break small">{s.link}</a>
) : (
<span className="text-break small">{s.link}</span>
)
) : (
<span className="text-secondary">-</span>
)}
{s.localLink && (
<a href={s.localLink} className="btn btn-xs btn-success py-0 px-1" style={{ fontSize: "0.7rem", width: "fit-content" }}>
Local Mirror
</a>
)}
</div>
</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>
{groupedScraps.length === 0 && <div className="text-secondary">No scraps</div>}
{groupedScraps.map(([type, items]) => (
<div key={type} className="mb-4">
<h6 className="text-primary border-bottom pb-1 mb-2">{type}</h6>
<div className="table-responsive">
<table className="table table-sm table-striped align-middle">
<thead>
<tr>
<th>Link</th>
<th style={{ width: 100 }} className="text-end">Size</th>
<th>Flags</th>
<th>Details</th>
<th>Rationale</th>
</tr>
</thead>
<tbody>
{items?.map((s) => {
const isHttp = s.link?.startsWith("http://") || s.link?.startsWith("https://");
const fileName = s.link?.split("/").pop() || "file";
const canPreview = s.localLink && fileName.toLowerCase().match(/\.(txt|nfo|png|jpg|jpeg|gif|pdf)$/);
return (
<tr key={s.id}>
<td>
<div className="d-flex flex-column gap-1">
<div className="d-flex align-items-center gap-2">
{s.link ? (
isHttp ? (
<a href={s.link} target="_blank" rel="noopener noreferrer" className="text-break small">{s.link}</a>
) : (
<span className="text-break small">{s.link}</span>
)
) : (
<span className="text-secondary">-</span>
)}
{canPreview && (
<button
className="btn btn-xs btn-outline-info py-0 px-1"
style={{ fontSize: "0.6rem" }}
onClick={() => setViewer({ url: s.localLink!, title: fileName })}
>
Preview
</button>
)}
</div>
{s.localLink && (
<a href={s.localLink} className="btn btn-xs btn-success py-0 px-1" style={{ fontSize: "0.7rem", width: "fit-content" }}>
Local Mirror
</a>
)}
</div>
</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 className="small">{s.rationale}</td>
</tr>
);
})}
</tbody>
</table>
</div>
</div>
)}
))}
</div>
</div>
@@ -543,6 +599,13 @@ export default function ReleaseDetailClient({ data }: { data: ReleaseDetailData
<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>
{viewer && (
<FileViewer
url={viewer.url}
title={viewer.title}
onClose={() => setViewer(null)}
/>
)}
</div>
);
}