From f445aabcb4d2de27b71ce2ea2b31d8ee0fe2bab2 Mon Sep 17 00:00:00 2001 From: "D. Rimron-Soutter" Date: Tue, 17 Feb 2026 12:50:58 +0000 Subject: [PATCH] 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 --- src/app/api/zxdb/download/route.ts | 22 +- src/app/zxdb/entries/[id]/EntryDetail.tsx | 169 ++++++---- .../[entryId]/[releaseSeq]/ReleaseDetail.tsx | 319 +++++++++++------- src/components/FileViewer.tsx | 90 +++++ 4 files changed, 404 insertions(+), 196 deletions(-) create mode 100644 src/components/FileViewer.tsx diff --git a/src/app/api/zxdb/download/route.ts b/src/app/api/zxdb/download/route.ts index 5951cb2..aa77d51 100644 --- a/src/app/api/zxdb/download/route.ts +++ b/src/app/api/zxdb/download/route.ts @@ -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(), }, }); diff --git a/src/app/zxdb/entries/[id]/EntryDetail.tsx b/src/app/zxdb/entries/[id]/EntryDetail.tsx index 2d9c11b..f115928 100644 --- a/src/app/zxdb/entries/[id]/EntryDetail.tsx +++ b/src/app/zxdb/entries/[id]/EntryDetail.tsx @@ -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(); + 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
Not found
; return ( @@ -372,73 +388,87 @@ export default function EntryDetailClient({ data }: { data: EntryDetailData | nu
Downloads
- {(!data.downloadsFlat || data.downloadsFlat.length === 0) &&
No downloads
} - {data.downloadsFlat && data.downloadsFlat.length > 0 && ( -
- - - - - - - - - - - - - - {data.downloadsFlat.map((d) => { - const isHttp = d.link.startsWith("http://") || d.link.startsWith("https://"); - return ( - - - - - - - - - - ); - })} - -
TypeLinkSizeMD5FlagsDetailsComments
{d.type.name} -
- {isHttp ? ( - {d.link} - ) : ( - {d.link} - )} - {d.localLink && ( - - Local Mirror - - )} -
-
{typeof d.size === "number" ? d.size.toLocaleString() : "-"}{d.md5 ?? "-"} -
- {d.isDemo ? Demo : null} - {d.scheme.name ? {d.scheme.name} : null} - {d.source.name ? {d.source.name} : null} - {d.case.name ? {d.case.name} : null} -
-
-
- {d.language.name && ( - {d.language.name} - )} - {d.machinetype.name && ( - {d.machinetype.name} - )} - {typeof d.year === "number" ? {d.year} : null} - - rel #{d.releaseSeq} - -
-
{d.comments ?? ""}
+ {groupedDownloads.length === 0 &&
No downloads
} + {groupedDownloads.map(([type, items]) => ( +
+
{type}
+
+ + + + + + + + + + + + + {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 ( + + + + + + + + + ); + })} + +
LinkSizeMD5FlagsDetailsComments
+
+
+ {isHttp ? ( + {d.link} + ) : ( + {d.link} + )} + {canPreview && ( + + )} +
+ {d.localLink && ( + + Local Mirror + + )} +
+
{typeof d.size === "number" ? d.size.toLocaleString() : "-"}{d.md5 ?? "-"} +
+ {d.isDemo ? Demo : null} + {d.scheme.name ? {d.scheme.name} : null} + {d.source.name ? {d.source.name} : null} + {d.case.name ? {d.case.name} : null} +
+
+
+ {d.language.name && ( + {d.language.name} + )} + {d.machinetype.name && ( + {d.machinetype.name} + )} + {typeof d.year === "number" ? {d.year} : null} + + rel #{d.releaseSeq} + +
+
{d.comments ?? ""}
+
- )} + ))}
@@ -885,6 +915,13 @@ export default function EntryDetailClient({ data }: { data: EntryDetailData | nu
+ {viewer && ( + setViewer(null)} + /> + )} ); } diff --git a/src/app/zxdb/releases/[entryId]/[releaseSeq]/ReleaseDetail.tsx b/src/app/zxdb/releases/[entryId]/[releaseSeq]/ReleaseDetail.tsx index e752b2a..352d9fe 100644 --- a/src/app/zxdb/releases/[entryId]/[releaseSeq]/ReleaseDetail.tsx +++ b/src/app/zxdb/releases/[entryId]/[releaseSeq]/ReleaseDetail.tsx @@ -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(); + 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(); + 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
Not found
; const magazineGroups = groupMagazineRefs(data.magazineRefs); @@ -356,142 +384,170 @@ export default function ReleaseDetailClient({ data }: { data: ReleaseDetailData
Downloads
- {data.downloads.length === 0 &&
No downloads
} - {data.downloads.length > 0 && ( -
- - - - - - - - - - - - - - {data.downloads.map((d) => { - const isHttp = d.link.startsWith("http://") || d.link.startsWith("https://"); - return ( - - - - - - - - - - ); - })} - -
TypeLinkSizeMD5FlagsDetailsComments
{d.type.name} -
- {isHttp ? ( - {d.link} - ) : ( - {d.link} - )} - {d.localLink && ( - - Local Mirror - - )} -
-
{typeof d.size === "number" ? d.size.toLocaleString() : "-"}{d.md5 ?? "-"} -
- {d.isDemo ? Demo : null} - {d.scheme.name ? {d.scheme.name} : null} - {d.source.name ? {d.source.name} : null} - {d.case.name ? {d.case.name} : null} -
-
-
- {d.language.name ? ( - {d.language.name} - ) : null} - {d.machinetype.name ? ( - {d.machinetype.name} - ) : null} - {typeof d.year === "number" ? {d.year} : null} -
-
{d.comments ?? ""}
+ {groupedDownloads.length === 0 &&
No downloads
} + {groupedDownloads.map(([type, items]) => ( +
+
{type}
+
+ + + + + + + + + + + + + {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 ( + + + + + + + + + ); + })} + +
LinkSizeMD5FlagsDetailsComments
+
+
+ {isHttp ? ( + {d.link} + ) : ( + {d.link} + )} + {canPreview && ( + + )} +
+ {d.localLink && ( + + Local Mirror + + )} +
+
{typeof d.size === "number" ? d.size.toLocaleString() : "-"}{d.md5 ?? "-"} +
+ {d.isDemo ? Demo : null} + {d.scheme.name ? {d.scheme.name} : null} + {d.source.name ? {d.source.name} : null} + {d.case.name ? {d.case.name} : null} +
+
+
+ {d.language.name ? ( + {d.language.name} + ) : null} + {d.machinetype.name ? ( + {d.machinetype.name} + ) : null} + {typeof d.year === "number" ? {d.year} : null} +
+
{d.comments ?? ""}
+
- )} + ))}
Scraps / Media
- {data.scraps.length === 0 &&
No scraps
} - {data.scraps.length > 0 && ( -
- - - - - - - - - - - - - {data.scraps.map((s) => { - const isHttp = s.link?.startsWith("http://") || s.link?.startsWith("https://"); - return ( - - - - - - - - - ); - })} - -
TypeLinkSizeFlagsDetailsRationale
{s.type.name} -
- {s.link ? ( - isHttp ? ( - {s.link} - ) : ( - {s.link} - ) - ) : ( - - - )} - {s.localLink && ( - - Local Mirror - - )} -
-
{typeof s.size === "number" ? s.size.toLocaleString() : "-"} -
- {s.isDemo ? Demo : null} - {s.scheme.name ? {s.scheme.name} : null} - {s.source.name ? {s.source.name} : null} - {s.case.name ? {s.case.name} : null} -
-
-
- {s.language.name ? ( - {s.language.name} - ) : null} - {s.machinetype.name ? ( - {s.machinetype.name} - ) : null} - {typeof s.year === "number" ? {s.year} : null} -
-
{s.rationale}
+ {groupedScraps.length === 0 &&
No scraps
} + {groupedScraps.map(([type, items]) => ( +
+
{type}
+
+ + + + + + + + + + + + {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 ( + + + + + + + + ); + })} + +
LinkSizeFlagsDetailsRationale
+
+
+ {s.link ? ( + isHttp ? ( + {s.link} + ) : ( + {s.link} + ) + ) : ( + - + )} + {canPreview && ( + + )} +
+ {s.localLink && ( + + Local Mirror + + )} +
+
{typeof s.size === "number" ? s.size.toLocaleString() : "-"} +
+ {s.isDemo ? Demo : null} + {s.scheme.name ? {s.scheme.name} : null} + {s.source.name ? {s.source.name} : null} + {s.case.name ? {s.case.name} : null} +
+
+
+ {s.language.name ? ( + {s.language.name} + ) : null} + {s.machinetype.name ? ( + {s.machinetype.name} + ) : null} + {typeof s.year === "number" ? {s.year} : null} +
+
{s.rationale}
+
- )} + ))}
@@ -543,6 +599,13 @@ export default function ReleaseDetailClient({ data }: { data: ReleaseDetailData Permalink Back to Releases
+ {viewer && ( + setViewer(null)} + /> + )}
); } diff --git a/src/components/FileViewer.tsx b/src/components/FileViewer.tsx new file mode 100644 index 0000000..ccf3b4f --- /dev/null +++ b/src/components/FileViewer.tsx @@ -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(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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 ( + + + {title} + + + {loading && ( +
+ +
+ )} + {error && ( +
+

{error}

+
+ )} + {!loading && !error && ( + <> + {isText && ( +
+                {content}
+              
+ )} + {isImage && ( +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + {title} +
+ )} + {isPdf && ( +