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 && ( +