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,73 +388,87 @@ 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 className="table-responsive"> <div key={type} className="mb-4">
<table className="table table-sm table-striped align-middle"> <h6 className="text-primary border-bottom pb-1 mb-2">{type}</h6>
<thead> <div className="table-responsive">
<tr> <table className="table table-sm table-striped align-middle">
<th>Type</th> <thead>
<th>Link</th> <tr>
<th style={{ width: 120 }} className="text-end">Size</th> <th>Link</th>
<th style={{ width: 260 }}>MD5</th> <th style={{ width: 100 }} className="text-end">Size</th>
<th>Flags</th> <th style={{ width: 180 }}>MD5</th>
<th>Details</th> <th>Flags</th>
<th>Comments</th> <th>Details</th>
</tr> <th>Comments</th>
</thead> </tr>
<tbody> </thead>
{data.downloadsFlat.map((d) => { <tbody>
const isHttp = d.link.startsWith("http://") || d.link.startsWith("https://"); {items?.map((d) => {
return ( const isHttp = d.link.startsWith("http://") || d.link.startsWith("https://");
<tr key={d.id}> const fileName = d.link.split("/").pop() || "file";
<td><span className="badge text-bg-secondary">{d.type.name}</span></td> const canPreview = d.localLink && fileName.toLowerCase().match(/\.(txt|nfo|png|jpg|jpeg|gif|pdf)$/);
<td> return (
<div className="d-flex flex-column gap-1"> <tr key={d.id}>
{isHttp ? ( <td>
<a href={d.link} target="_blank" rel="noopener noreferrer" className="text-break small">{d.link}</a> <div className="d-flex flex-column gap-1">
) : ( <div className="d-flex align-items-center gap-2">
<span className="text-break small">{d.link}</span> {isHttp ? (
)} <a href={d.link} target="_blank" rel="noopener noreferrer" className="text-break small">{d.link}</a>
{d.localLink && ( ) : (
<a href={d.localLink} className="btn btn-xs btn-success py-0 px-1" style={{ fontSize: "0.7rem", width: "fit-content" }}> <span className="text-break small">{d.link}</span>
Local Mirror )}
</a> {canPreview && (
)} <button
</div> className="btn btn-xs btn-outline-info py-0 px-1"
</td> style={{ fontSize: "0.6rem" }}
<td className="text-end">{typeof d.size === "number" ? d.size.toLocaleString() : "-"}</td> onClick={() => setViewer({ url: d.localLink!, title: fileName })}
<td><code>{d.md5 ?? "-"}</code></td> >
<td> Preview
<div className="d-flex gap-1 flex-wrap"> </button>
{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} </div>
{d.source.name ? <span className="badge text-bg-light border">{d.source.name}</span> : null} {d.localLink && (
{d.case.name ? <span className="badge text-bg-secondary">{d.case.name}</span> : null} <a href={d.localLink} className="btn btn-xs btn-success py-0 px-1" style={{ fontSize: "0.7rem", width: "fit-content" }}>
</div> Local Mirror
</td> </a>
<td> )}
<div className="d-flex gap-2 flex-wrap align-items-center"> </div>
{d.language.name && ( </td>
<Link className="badge text-bg-info text-decoration-none" href={`/zxdb/languages/${d.language.id}`}>{d.language.name}</Link> <td className="text-end">{typeof d.size === "number" ? d.size.toLocaleString() : "-"}</td>
)} <td><code style={{ fontSize: "0.75rem" }}>{d.md5 ?? "-"}</code></td>
{d.machinetype.name && ( <td>
<Link className="badge text-bg-primary text-decoration-none" href={`/zxdb/machinetypes/${d.machinetype.id}`}>{d.machinetype.name}</Link> <div className="d-flex gap-1 flex-wrap">
)} {d.isDemo ? <span className="badge text-bg-warning">Demo</span> : null}
{typeof d.year === "number" ? <span className="badge text-bg-dark">{d.year}</span> : null} {d.scheme.name ? <span className="badge text-bg-info">{d.scheme.name}</span> : null}
<Link className="badge text-bg-light text-decoration-none" href={`/zxdb/releases/${data.id}/${d.releaseSeq}`}> {d.source.name ? <span className="badge text-bg-light border">{d.source.name}</span> : null}
rel #{d.releaseSeq} {d.case.name ? <span className="badge text-bg-secondary">{d.case.name}</span> : null}
</Link> </div>
</div> </td>
</td> <td>
<td>{d.comments ?? ""}</td> <div className="d-flex gap-2 flex-wrap align-items-center">
</tr> {d.language.name && (
); <Link className="badge text-bg-info text-decoration-none" href={`/zxdb/languages/${d.language.id}`}>{d.language.name}</Link>
})} )}
</tbody> {d.machinetype.name && (
</table> <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> </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,142 +384,170 @@ 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 className="table-responsive"> <div key={type} className="mb-4">
<table className="table table-sm table-striped align-middle"> <h6 className="text-primary border-bottom pb-1 mb-2">{type}</h6>
<thead> <div className="table-responsive">
<tr> <table className="table table-sm table-striped align-middle">
<th>Type</th> <thead>
<th>Link</th> <tr>
<th style={{ width: 120 }} className="text-end">Size</th> <th>Link</th>
<th style={{ width: 240 }}>MD5</th> <th style={{ width: 100 }} className="text-end">Size</th>
<th>Flags</th> <th style={{ width: 180 }}>MD5</th>
<th>Details</th> <th>Flags</th>
<th>Comments</th> <th>Details</th>
</tr> <th>Comments</th>
</thead> </tr>
<tbody> </thead>
{data.downloads.map((d) => { <tbody>
const isHttp = d.link.startsWith("http://") || d.link.startsWith("https://"); {items?.map((d) => {
return ( const isHttp = d.link.startsWith("http://") || d.link.startsWith("https://");
<tr key={d.id}> const fileName = d.link.split("/").pop() || "file";
<td><span className="badge text-bg-secondary">{d.type.name}</span></td> const canPreview = d.localLink && fileName.toLowerCase().match(/\.(txt|nfo|png|jpg|jpeg|gif|pdf)$/);
<td> return (
<div className="d-flex flex-column gap-1"> <tr key={d.id}>
{isHttp ? ( <td>
<a href={d.link} target="_blank" rel="noopener noreferrer" className="text-break small">{d.link}</a> <div className="d-flex flex-column gap-1">
) : ( <div className="d-flex align-items-center gap-2">
<span className="text-break small">{d.link}</span> {isHttp ? (
)} <a href={d.link} target="_blank" rel="noopener noreferrer" className="text-break small">{d.link}</a>
{d.localLink && ( ) : (
<a href={d.localLink} className="btn btn-xs btn-success py-0 px-1" style={{ fontSize: "0.7rem", width: "fit-content" }}> <span className="text-break small">{d.link}</span>
Local Mirror )}
</a> {canPreview && (
)} <button
</div> className="btn btn-xs btn-outline-info py-0 px-1"
</td> style={{ fontSize: "0.6rem" }}
<td className="text-end">{typeof d.size === "number" ? d.size.toLocaleString() : "-"}</td> onClick={() => setViewer({ url: d.localLink!, title: fileName })}
<td><code>{d.md5 ?? "-"}</code></td> >
<td> Preview
<div className="d-flex gap-1 flex-wrap"> </button>
{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} </div>
{d.source.name ? <span className="badge text-bg-light border">{d.source.name}</span> : null} {d.localLink && (
{d.case.name ? <span className="badge text-bg-secondary">{d.case.name}</span> : null} <a href={d.localLink} className="btn btn-xs btn-success py-0 px-1" style={{ fontSize: "0.7rem", width: "fit-content" }}>
</div> Local Mirror
</td> </a>
<td> )}
<div className="d-flex gap-2 flex-wrap align-items-center"> </div>
{d.language.name ? ( </td>
<Link className="badge text-bg-info text-decoration-none" href={`/zxdb/languages/${d.language.id}`}>{d.language.name}</Link> <td className="text-end">{typeof d.size === "number" ? d.size.toLocaleString() : "-"}</td>
) : null} <td><code style={{ fontSize: "0.75rem" }}>{d.md5 ?? "-"}</code></td>
{d.machinetype.name ? ( <td>
<Link className="badge text-bg-primary text-decoration-none" href={`/zxdb/machinetypes/${d.machinetype.id}`}>{d.machinetype.name}</Link> <div className="d-flex gap-1 flex-wrap">
) : null} {d.isDemo ? <span className="badge text-bg-warning">Demo</span> : null}
{typeof d.year === "number" ? <span className="badge text-bg-dark">{d.year}</span> : null} {d.scheme.name ? <span className="badge text-bg-info">{d.scheme.name}</span> : null}
</div> {d.source.name ? <span className="badge text-bg-light border">{d.source.name}</span> : null}
</td> {d.case.name ? <span className="badge text-bg-secondary">{d.case.name}</span> : null}
<td>{d.comments ?? ""}</td> </div>
</tr> </td>
); <td>
})} <div className="d-flex gap-2 flex-wrap align-items-center">
</tbody> {d.language.name ? (
</table> <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>
</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 className="table-responsive"> <div key={type} className="mb-4">
<table className="table table-sm table-striped align-middle"> <h6 className="text-primary border-bottom pb-1 mb-2">{type}</h6>
<thead> <div className="table-responsive">
<tr> <table className="table table-sm table-striped align-middle">
<th>Type</th> <thead>
<th>Link</th> <tr>
<th style={{ width: 120 }} className="text-end">Size</th> <th>Link</th>
<th>Flags</th> <th style={{ width: 100 }} className="text-end">Size</th>
<th>Details</th> <th>Flags</th>
<th>Rationale</th> <th>Details</th>
</tr> <th>Rationale</th>
</thead> </tr>
<tbody> </thead>
{data.scraps.map((s) => { <tbody>
const isHttp = s.link?.startsWith("http://") || s.link?.startsWith("https://"); {items?.map((s) => {
return ( const isHttp = s.link?.startsWith("http://") || s.link?.startsWith("https://");
<tr key={s.id}> const fileName = s.link?.split("/").pop() || "file";
<td><span className="badge text-bg-secondary">{s.type.name}</span></td> const canPreview = s.localLink && fileName.toLowerCase().match(/\.(txt|nfo|png|jpg|jpeg|gif|pdf)$/);
<td> return (
<div className="d-flex flex-column gap-1"> <tr key={s.id}>
{s.link ? ( <td>
isHttp ? ( <div className="d-flex flex-column gap-1">
<a href={s.link} target="_blank" rel="noopener noreferrer" className="text-break small">{s.link}</a> <div className="d-flex align-items-center gap-2">
) : ( {s.link ? (
<span className="text-break small">{s.link}</span> isHttp ? (
) <a href={s.link} target="_blank" rel="noopener noreferrer" className="text-break small">{s.link}</a>
) : ( ) : (
<span className="text-secondary">-</span> <span className="text-break small">{s.link}</span>
)} )
{s.localLink && ( ) : (
<a href={s.localLink} className="btn btn-xs btn-success py-0 px-1" style={{ fontSize: "0.7rem", width: "fit-content" }}> <span className="text-secondary">-</span>
Local Mirror )}
</a> {canPreview && (
)} <button
</div> className="btn btn-xs btn-outline-info py-0 px-1"
</td> style={{ fontSize: "0.6rem" }}
<td className="text-end">{typeof s.size === "number" ? s.size.toLocaleString() : "-"}</td> onClick={() => setViewer({ url: s.localLink!, title: fileName })}
<td> >
<div className="d-flex gap-1 flex-wrap"> Preview
{s.isDemo ? <span className="badge text-bg-warning">Demo</span> : null} </button>
{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} </div>
{s.case.name ? <span className="badge text-bg-secondary">{s.case.name}</span> : null} {s.localLink && (
</div> <a href={s.localLink} className="btn btn-xs btn-success py-0 px-1" style={{ fontSize: "0.7rem", width: "fit-content" }}>
</td> Local Mirror
<td> </a>
<div className="d-flex gap-2 flex-wrap align-items-center"> )}
{s.language.name ? ( </div>
<Link className="badge text-bg-info text-decoration-none" href={`/zxdb/languages/${s.language.id}`}>{s.language.name}</Link> </td>
) : null} <td className="text-end">{typeof s.size === "number" ? s.size.toLocaleString() : "-"}</td>
{s.machinetype.name ? ( <td>
<Link className="badge text-bg-primary text-decoration-none" href={`/zxdb/machinetypes/${s.machinetype.id}`}>{s.machinetype.name}</Link> <div className="d-flex gap-1 flex-wrap">
) : null} {s.isDemo ? <span className="badge text-bg-warning">Demo</span> : null}
{typeof s.year === "number" ? <span className="badge text-bg-dark">{s.year}</span> : null} {s.scheme.name ? <span className="badge text-bg-info">{s.scheme.name}</span> : null}
</div> {s.source.name ? <span className="badge text-bg-light border">{s.source.name}</span> : null}
</td> {s.case.name ? <span className="badge text-bg-secondary">{s.case.name}</span> : null}
<td>{s.rationale}</td> </div>
</tr> </td>
); <td>
})} <div className="d-flex gap-2 flex-wrap align-items-center">
</tbody> {s.language.name ? (
</table> <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> </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>
);
}