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 fileBuffer = fs.readFileSync(absolutePath);
const fileName = path.basename(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, { return new NextResponse(fileBuffer, {
headers: { headers: {
"Content-Type": "application/octet-stream", "Content-Type": contentType,
"Content-Disposition": `attachment; filename="${fileName}"`, "Content-Disposition": `${disposition}; filename="${fileName}"`,
"Content-Length": stat.size.toString(), "Content-Length": stat.size.toString(),
}, },
}); });

View File

@@ -1,7 +1,9 @@
"use client"; "use client";
import { useState, useMemo } from "react";
import Link from "next/link"; import Link from "next/link";
import ZxdbBreadcrumbs from "@/app/zxdb/components/ZxdbBreadcrumbs"; import ZxdbBreadcrumbs from "@/app/zxdb/components/ZxdbBreadcrumbs";
import FileViewer from "@/components/FileViewer";
type Label = { id: number; name: string; labeltypeId: string | null }; type Label = { id: number; name: string; labeltypeId: string | null };
export type EntryDetailData = { export type EntryDetailData = {
@@ -155,6 +157,20 @@ export type EntryDetailData = {
}; };
export default function EntryDetailClient({ data }: { data: EntryDetailData | null }) { 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>; if (!data) return <div className="alert alert-warning">Not found</div>;
return ( return (
@@ -372,34 +388,47 @@ export default function EntryDetailClient({ data }: { data: EntryDetailData | nu
<div className="card shadow-sm mb-3"> <div className="card shadow-sm mb-3">
<div className="card-body"> <div className="card-body">
<h5 className="card-title">Downloads</h5> <h5 className="card-title">Downloads</h5>
{(!data.downloadsFlat || data.downloadsFlat.length === 0) && <div className="text-secondary">No downloads</div>} {groupedDownloads.length === 0 && <div className="text-secondary">No downloads</div>}
{data.downloadsFlat && data.downloadsFlat.length > 0 && ( {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"> <div className="table-responsive">
<table className="table table-sm table-striped align-middle"> <table className="table table-sm table-striped align-middle">
<thead> <thead>
<tr> <tr>
<th>Type</th>
<th>Link</th> <th>Link</th>
<th style={{ width: 120 }} className="text-end">Size</th> <th style={{ width: 100 }} className="text-end">Size</th>
<th style={{ width: 260 }}>MD5</th> <th style={{ width: 180 }}>MD5</th>
<th>Flags</th> <th>Flags</th>
<th>Details</th> <th>Details</th>
<th>Comments</th> <th>Comments</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{data.downloadsFlat.map((d) => { {items?.map((d) => {
const isHttp = d.link.startsWith("http://") || d.link.startsWith("https://"); 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 ( return (
<tr key={d.id}> <tr key={d.id}>
<td><span className="badge text-bg-secondary">{d.type.name}</span></td>
<td> <td>
<div className="d-flex flex-column gap-1"> <div className="d-flex flex-column gap-1">
<div className="d-flex align-items-center gap-2">
{isHttp ? ( {isHttp ? (
<a href={d.link} target="_blank" rel="noopener noreferrer" className="text-break small">{d.link}</a> <a href={d.link} target="_blank" rel="noopener noreferrer" className="text-break small">{d.link}</a>
) : ( ) : (
<span className="text-break small">{d.link}</span> <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 && ( {d.localLink && (
<a href={d.localLink} className="btn btn-xs btn-success py-0 px-1" style={{ fontSize: "0.7rem", width: "fit-content" }}> <a href={d.localLink} className="btn btn-xs btn-success py-0 px-1" style={{ fontSize: "0.7rem", width: "fit-content" }}>
Local Mirror Local Mirror
@@ -408,7 +437,7 @@ export default function EntryDetailClient({ data }: { data: EntryDetailData | nu
</div> </div>
</td> </td>
<td className="text-end">{typeof d.size === "number" ? d.size.toLocaleString() : "-"}</td> <td className="text-end">{typeof d.size === "number" ? d.size.toLocaleString() : "-"}</td>
<td><code>{d.md5 ?? "-"}</code></td> <td><code style={{ fontSize: "0.75rem" }}>{d.md5 ?? "-"}</code></td>
<td> <td>
<div className="d-flex gap-1 flex-wrap"> <div className="d-flex gap-1 flex-wrap">
{d.isDemo ? <span className="badge text-bg-warning">Demo</span> : null} {d.isDemo ? <span className="badge text-bg-warning">Demo</span> : null}
@@ -431,14 +460,15 @@ export default function EntryDetailClient({ data }: { data: EntryDetailData | nu
</Link> </Link>
</div> </div>
</td> </td>
<td>{d.comments ?? ""}</td> <td className="small">{d.comments ?? ""}</td>
</tr> </tr>
); );
})} })}
</tbody> </tbody>
</table> </table>
</div> </div>
)} </div>
))}
</div> </div>
</div> </div>
@@ -885,6 +915,13 @@ export default function EntryDetailClient({ data }: { data: EntryDetailData | nu
</div> </div>
</div> </div>
</div> </div>
{viewer && (
<FileViewer
url={viewer.url}
title={viewer.title}
onClose={() => setViewer(null)}
/>
)}
</div> </div>
); );
} }

View File

@@ -1,7 +1,9 @@
"use client"; "use client";
import { useState, useMemo } from "react";
import Link from "next/link"; import Link from "next/link";
import ZxdbBreadcrumbs from "@/app/zxdb/components/ZxdbBreadcrumbs"; import ZxdbBreadcrumbs from "@/app/zxdb/components/ZxdbBreadcrumbs";
import FileViewer from "@/components/FileViewer";
type ReleaseDetailData = { type ReleaseDetailData = {
entry: { entry: {
@@ -170,6 +172,32 @@ function groupIssueRefs(refs: ReleaseDetailData["magazineRefs"]) {
} }
export default function ReleaseDetailClient({ data }: { data: ReleaseDetailData | null }) { 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>; if (!data) return <div className="alert alert-warning">Not found</div>;
const magazineGroups = groupMagazineRefs(data.magazineRefs); const magazineGroups = groupMagazineRefs(data.magazineRefs);
@@ -356,34 +384,47 @@ export default function ReleaseDetailClient({ data }: { data: ReleaseDetailData
<div className="card shadow-sm mb-3"> <div className="card shadow-sm mb-3">
<div className="card-body"> <div className="card-body">
<h5 className="card-title">Downloads</h5> <h5 className="card-title">Downloads</h5>
{data.downloads.length === 0 && <div className="text-secondary">No downloads</div>} {groupedDownloads.length === 0 && <div className="text-secondary">No downloads</div>}
{data.downloads.length > 0 && ( {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"> <div className="table-responsive">
<table className="table table-sm table-striped align-middle"> <table className="table table-sm table-striped align-middle">
<thead> <thead>
<tr> <tr>
<th>Type</th>
<th>Link</th> <th>Link</th>
<th style={{ width: 120 }} className="text-end">Size</th> <th style={{ width: 100 }} className="text-end">Size</th>
<th style={{ width: 240 }}>MD5</th> <th style={{ width: 180 }}>MD5</th>
<th>Flags</th> <th>Flags</th>
<th>Details</th> <th>Details</th>
<th>Comments</th> <th>Comments</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{data.downloads.map((d) => { {items?.map((d) => {
const isHttp = d.link.startsWith("http://") || d.link.startsWith("https://"); 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 ( return (
<tr key={d.id}> <tr key={d.id}>
<td><span className="badge text-bg-secondary">{d.type.name}</span></td>
<td> <td>
<div className="d-flex flex-column gap-1"> <div className="d-flex flex-column gap-1">
<div className="d-flex align-items-center gap-2">
{isHttp ? ( {isHttp ? (
<a href={d.link} target="_blank" rel="noopener noreferrer" className="text-break small">{d.link}</a> <a href={d.link} target="_blank" rel="noopener noreferrer" className="text-break small">{d.link}</a>
) : ( ) : (
<span className="text-break small">{d.link}</span> <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 && ( {d.localLink && (
<a href={d.localLink} className="btn btn-xs btn-success py-0 px-1" style={{ fontSize: "0.7rem", width: "fit-content" }}> <a href={d.localLink} className="btn btn-xs btn-success py-0 px-1" style={{ fontSize: "0.7rem", width: "fit-content" }}>
Local Mirror Local Mirror
@@ -392,7 +433,7 @@ export default function ReleaseDetailClient({ data }: { data: ReleaseDetailData
</div> </div>
</td> </td>
<td className="text-end">{typeof d.size === "number" ? d.size.toLocaleString() : "-"}</td> <td className="text-end">{typeof d.size === "number" ? d.size.toLocaleString() : "-"}</td>
<td><code>{d.md5 ?? "-"}</code></td> <td><code style={{ fontSize: "0.75rem" }}>{d.md5 ?? "-"}</code></td>
<td> <td>
<div className="d-flex gap-1 flex-wrap"> <div className="d-flex gap-1 flex-wrap">
{d.isDemo ? <span className="badge text-bg-warning">Demo</span> : null} {d.isDemo ? <span className="badge text-bg-warning">Demo</span> : null}
@@ -412,42 +453,46 @@ export default function ReleaseDetailClient({ data }: { data: ReleaseDetailData
{typeof d.year === "number" ? <span className="badge text-bg-dark">{d.year}</span> : null} {typeof d.year === "number" ? <span className="badge text-bg-dark">{d.year}</span> : null}
</div> </div>
</td> </td>
<td>{d.comments ?? ""}</td> <td className="small">{d.comments ?? ""}</td>
</tr> </tr>
); );
})} })}
</tbody> </tbody>
</table> </table>
</div> </div>
)} </div>
))}
</div> </div>
</div> </div>
<div className="card shadow-sm mb-3"> <div className="card shadow-sm mb-3">
<div className="card-body"> <div className="card-body">
<h5 className="card-title">Scraps / Media</h5> <h5 className="card-title">Scraps / Media</h5>
{data.scraps.length === 0 && <div className="text-secondary">No scraps</div>} {groupedScraps.length === 0 && <div className="text-secondary">No scraps</div>}
{data.scraps.length > 0 && ( {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"> <div className="table-responsive">
<table className="table table-sm table-striped align-middle"> <table className="table table-sm table-striped align-middle">
<thead> <thead>
<tr> <tr>
<th>Type</th>
<th>Link</th> <th>Link</th>
<th style={{ width: 120 }} className="text-end">Size</th> <th style={{ width: 100 }} className="text-end">Size</th>
<th>Flags</th> <th>Flags</th>
<th>Details</th> <th>Details</th>
<th>Rationale</th> <th>Rationale</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{data.scraps.map((s) => { {items?.map((s) => {
const isHttp = s.link?.startsWith("http://") || s.link?.startsWith("https://"); 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 ( return (
<tr key={s.id}> <tr key={s.id}>
<td><span className="badge text-bg-secondary">{s.type.name}</span></td>
<td> <td>
<div className="d-flex flex-column gap-1"> <div className="d-flex flex-column gap-1">
<div className="d-flex align-items-center gap-2">
{s.link ? ( {s.link ? (
isHttp ? ( isHttp ? (
<a href={s.link} target="_blank" rel="noopener noreferrer" className="text-break small">{s.link}</a> <a href={s.link} target="_blank" rel="noopener noreferrer" className="text-break small">{s.link}</a>
@@ -457,6 +502,16 @@ export default function ReleaseDetailClient({ data }: { data: ReleaseDetailData
) : ( ) : (
<span className="text-secondary">-</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 && ( {s.localLink && (
<a href={s.localLink} className="btn btn-xs btn-success py-0 px-1" style={{ fontSize: "0.7rem", width: "fit-content" }}> <a href={s.localLink} className="btn btn-xs btn-success py-0 px-1" style={{ fontSize: "0.7rem", width: "fit-content" }}>
Local Mirror Local Mirror
@@ -484,14 +539,15 @@ export default function ReleaseDetailClient({ data }: { data: ReleaseDetailData
{typeof s.year === "number" ? <span className="badge text-bg-dark">{s.year}</span> : null} {typeof s.year === "number" ? <span className="badge text-bg-dark">{s.year}</span> : null}
</div> </div>
</td> </td>
<td>{s.rationale}</td> <td className="small">{s.rationale}</td>
</tr> </tr>
); );
})} })}
</tbody> </tbody>
</table> </table>
</div> </div>
)} </div>
))}
</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-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> <Link className="btn btn-sm btn-outline-primary" href="/zxdb/releases">Back to Releases</Link>
</div> </div>
{viewer && (
<FileViewer
url={viewer.url}
title={viewer.title}
onClose={() => setViewer(null)}
/>
)}
</div> </div>
); );
} }

View File

@@ -0,0 +1,90 @@
"use client";
import { useState } from "react";
import { Modal, Button, Spinner } from "react-bootstrap";
type FileViewerProps = {
url: string;
title: string;
onClose: () => void;
};
export default function FileViewer({ url, title, onClose }: FileViewerProps) {
const [content, setContent] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const isText = title.toLowerCase().endsWith(".txt") || title.toLowerCase().endsWith(".nfo");
const isImage = title.toLowerCase().match(/\.(png|jpg|jpeg|gif)$/);
const isPdf = title.toLowerCase().endsWith(".pdf");
const viewUrl = url.includes("?") ? `${url}&view=1` : `${url}?view=1`;
useState(() => {
if (isText) {
fetch(viewUrl)
.then((res) => {
if (!res.ok) throw new Error("Failed to load file");
return res.text();
})
.then((text) => {
setContent(text);
setLoading(false);
})
.catch((err) => {
setError(err.message);
setLoading(false);
});
} else {
setLoading(false);
}
});
return (
<Modal show size="xl" onHide={onClose} centered scrollable>
<Modal.Header closeButton>
<Modal.Title>{title}</Modal.Title>
</Modal.Header>
<Modal.Body className="p-0 bg-dark text-light" style={{ minHeight: "300px" }}>
{loading && (
<div className="d-flex justify-content-center align-items-center" style={{ minHeight: "300px" }}>
<Spinner animation="border" variant="light" />
</div>
)}
{error && (
<div className="p-4 text-center">
<p className="text-danger">{error}</p>
</div>
)}
{!loading && !error && (
<>
{isText && (
<pre className="p-3 m-0" style={{ whiteSpace: "pre-wrap", wordBreak: "break-all", fontSize: "0.9rem", color: "#ccc" }}>
{content}
</pre>
)}
{isImage && (
<div className="text-center p-3">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={viewUrl} alt={title} className="img-fluid" style={{ maxHeight: "80vh" }} />
</div>
)}
{isPdf && (
<iframe src={viewUrl} style={{ width: "100%", height: "80vh", border: "none" }} title={title} />
)}
{!isText && !isImage && !isPdf && (
<div className="p-4 text-center">
<p>Preview not available for this file type.</p>
<a href={url} className="btn btn-primary">Download File</a>
</div>
)}
</>
)}
</Modal.Body>
<Modal.Footer>
<Button variant="secondary" onClick={onClose}>Close</Button>
<a href={url} className="btn btn-success" download>Download</a>
</Modal.Footer>
</Modal>
);
}