feat(preview modal added)
This commit is contained in:
229
apps/Frontend/src/components/documents/file-preview-modal.tsx
Normal file
229
apps/Frontend/src/components/documents/file-preview-modal.tsx
Normal file
@@ -0,0 +1,229 @@
|
|||||||
|
import React, { useEffect, useState } from "react";
|
||||||
|
import { apiRequest } from "@/lib/queryClient";
|
||||||
|
import { toast } from "@/hooks/use-toast";
|
||||||
|
import { Maximize2, Minimize2, Download, X } from "lucide-react";
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
fileId: number | null;
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
initialFileName?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function DocumentsFilePreviewModal({
|
||||||
|
fileId,
|
||||||
|
isOpen,
|
||||||
|
onClose,
|
||||||
|
initialFileName,
|
||||||
|
}: Props) {
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [mime, setMime] = useState<string | null>(null);
|
||||||
|
const [fileName, setFileName] = useState<string | null>(
|
||||||
|
initialFileName ?? null
|
||||||
|
);
|
||||||
|
const [blobUrl, setBlobUrl] = useState<string | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isOpen || !fileId) return;
|
||||||
|
let cancelled = false;
|
||||||
|
let createdUrl: string | null = null;
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
setMime(null);
|
||||||
|
setFileName(initialFileName ?? null);
|
||||||
|
setBlobUrl(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Fetch the file bytes using the Documents API endpoint (returns raw bytes)
|
||||||
|
const res = await apiRequest(
|
||||||
|
"GET",
|
||||||
|
`/api/documents/pdf-files/${fileId}`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
// try to parse error message from JSON body
|
||||||
|
let msg = `Preview request failed (${res.status})`;
|
||||||
|
try {
|
||||||
|
const j = await res.json();
|
||||||
|
msg = j?.message ?? msg;
|
||||||
|
} catch {}
|
||||||
|
throw new Error(msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
// try to infer MIME from headers; fallback to application/pdf
|
||||||
|
const contentType =
|
||||||
|
res.headers.get("content-type") ?? "application/pdf";
|
||||||
|
setMime(contentType);
|
||||||
|
// If server provided filename in headers (Content-Disposition), we could parse it here.
|
||||||
|
// Use initialFileName if provided, otherwise keep unset until download.
|
||||||
|
if (!fileName && initialFileName) setFileName(initialFileName);
|
||||||
|
|
||||||
|
const arrayBuffer = await res.arrayBuffer();
|
||||||
|
if (cancelled) return;
|
||||||
|
const blob = new Blob([arrayBuffer], { type: contentType });
|
||||||
|
createdUrl = URL.createObjectURL(blob);
|
||||||
|
setBlobUrl(createdUrl);
|
||||||
|
} catch (err: any) {
|
||||||
|
if (!cancelled) setError(err?.message ?? String(err));
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
load();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
if (createdUrl) URL.revokeObjectURL(createdUrl);
|
||||||
|
};
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [isOpen, fileId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
function onKey(e: KeyboardEvent) {
|
||||||
|
if (e.key === "Escape") onClose();
|
||||||
|
}
|
||||||
|
if (isOpen) {
|
||||||
|
window.addEventListener("keydown", onKey);
|
||||||
|
return () => window.removeEventListener("keydown", onKey);
|
||||||
|
}
|
||||||
|
}, [isOpen, onClose]);
|
||||||
|
|
||||||
|
if (!isOpen) return null;
|
||||||
|
|
||||||
|
async function handleDownload() {
|
||||||
|
if (!fileId) return;
|
||||||
|
try {
|
||||||
|
const res = await apiRequest("GET", `/api/documents/pdf-files/${fileId}`);
|
||||||
|
if (!res.ok) {
|
||||||
|
const j = await res.json().catch(() => ({}));
|
||||||
|
throw new Error(j?.message || `Download failed (${res.status})`);
|
||||||
|
}
|
||||||
|
const arrayBuffer = await res.arrayBuffer();
|
||||||
|
const blob = new Blob([arrayBuffer], {
|
||||||
|
type: mime ?? "application/octet-stream",
|
||||||
|
});
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = fileName ?? `file-${fileId}`;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
a.remove();
|
||||||
|
setTimeout(() => URL.revokeObjectURL(url), 5000);
|
||||||
|
} catch (err: any) {
|
||||||
|
toast({
|
||||||
|
title: "Download failed",
|
||||||
|
description: err?.message ?? String(err),
|
||||||
|
variant: "destructive",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const containerBase =
|
||||||
|
"bg-white rounded-md p-3 flex flex-col overflow-hidden shadow-xl";
|
||||||
|
const sizeClass = isFullscreen
|
||||||
|
? "w-[95vw] h-[95vh]"
|
||||||
|
: "w-[min(1200px,95vw)] h-[85vh]";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black bg-opacity-60 p-4">
|
||||||
|
<div className={`${containerBase} ${sizeClass} max-w-full max-h-full`}>
|
||||||
|
<div className="flex items-start justify-between gap-3 pb-2 border-b">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<h3 className="text-lg font-semibold truncate">
|
||||||
|
{fileName ?? `File #${fileId}`}
|
||||||
|
</h3>
|
||||||
|
<div className="text-sm text-gray-500 truncate">{mime ?? ""}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setIsFullscreen((s) => !s)}
|
||||||
|
title={isFullscreen ? "Exit fullscreen" : "Fullscreen"}
|
||||||
|
className="p-2 rounded hover:bg-gray-100"
|
||||||
|
>
|
||||||
|
{isFullscreen ? (
|
||||||
|
<Minimize2 className="w-4 h-4" />
|
||||||
|
) : (
|
||||||
|
<Maximize2 className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={handleDownload}
|
||||||
|
title="Download"
|
||||||
|
className="p-2 rounded hover:bg-gray-100"
|
||||||
|
>
|
||||||
|
<Download className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={onClose}
|
||||||
|
title="Close"
|
||||||
|
className="p-2 rounded hover:bg-gray-100"
|
||||||
|
>
|
||||||
|
<X className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-auto mt-3">
|
||||||
|
{loading && (
|
||||||
|
<div className="w-full h-full flex items-center justify-center">
|
||||||
|
Loading preview…
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{error && <div className="text-red-600">{error}</div>}
|
||||||
|
|
||||||
|
{!loading && !error && blobUrl && mime?.startsWith("image/") && (
|
||||||
|
<div className="flex items-center justify-center w-full h-full">
|
||||||
|
<img
|
||||||
|
src={blobUrl}
|
||||||
|
alt={fileName ?? ""}
|
||||||
|
className="max-w-full max-h-full object-contain"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading &&
|
||||||
|
!error &&
|
||||||
|
blobUrl &&
|
||||||
|
(mime === "application/pdf" || mime?.endsWith("/pdf")) && (
|
||||||
|
<div className="w-full h-full">
|
||||||
|
<iframe
|
||||||
|
src={blobUrl}
|
||||||
|
title={fileName ?? `PDF ${fileId}`}
|
||||||
|
className="w-full h-full border-0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading &&
|
||||||
|
!error &&
|
||||||
|
blobUrl &&
|
||||||
|
!mime?.startsWith("image/") &&
|
||||||
|
!mime?.includes("pdf") && (
|
||||||
|
<div className="p-4">
|
||||||
|
<p>Preview not available for this file type.</p>
|
||||||
|
<p className="mt-2">
|
||||||
|
<a
|
||||||
|
href={blobUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="underline"
|
||||||
|
>
|
||||||
|
Open raw
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
PaginationNext,
|
PaginationNext,
|
||||||
PaginationPrevious,
|
PaginationPrevious,
|
||||||
} from "@/components/ui/pagination";
|
} from "@/components/ui/pagination";
|
||||||
|
import DocumentsFilePreviewModal from "@/components/documents/file-preview-modal";
|
||||||
|
|
||||||
export default function DocumentsPage() {
|
export default function DocumentsPage() {
|
||||||
const [selectedPatient, setSelectedPatient] = useState<Patient | null>(null);
|
const [selectedPatient, setSelectedPatient] = useState<Patient | null>(null);
|
||||||
@@ -37,7 +38,11 @@ export default function DocumentsPage() {
|
|||||||
>(null);
|
>(null);
|
||||||
|
|
||||||
// PDF view state - viewing / downloading
|
// PDF view state - viewing / downloading
|
||||||
const [fileBlobUrl, setFileBlobUrl] = useState<string | null>(null);
|
const [previewFileId, setPreviewFileId] = useState<number | null>(null);
|
||||||
|
const [isPreviewOpen, setIsPreviewOpen] = useState(false);
|
||||||
|
const [previewFileNameHint, setPreviewFileNameHint] = useState<string | null>(
|
||||||
|
null
|
||||||
|
);
|
||||||
const [currentPdf, setCurrentPdf] = useState<PdfFile | null>(null);
|
const [currentPdf, setCurrentPdf] = useState<PdfFile | null>(null);
|
||||||
|
|
||||||
// Delete dialog
|
// Delete dialog
|
||||||
@@ -49,7 +54,11 @@ export default function DocumentsPage() {
|
|||||||
setLimit(5);
|
setLimit(5);
|
||||||
setCurrentPage(1);
|
setCurrentPage(1);
|
||||||
setTotalForExpandedGroup(null);
|
setTotalForExpandedGroup(null);
|
||||||
setFileBlobUrl(null);
|
|
||||||
|
// close the preview modal and clear any preview hints
|
||||||
|
setIsPreviewOpen(false);
|
||||||
|
setPreviewFileId(null);
|
||||||
|
setPreviewFileNameHint(null);
|
||||||
}, [selectedPatient]);
|
}, [selectedPatient]);
|
||||||
|
|
||||||
// FETCH GROUPS for patient (includes `category` on each group)
|
// FETCH GROUPS for patient (includes `category` on each group)
|
||||||
@@ -151,12 +160,10 @@ export default function DocumentsPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleViewPdf = async (pdfId: number) => {
|
const handleViewPdf = (pdfId: number, filename?: string) => {
|
||||||
const res = await apiRequest("GET", `/api/documents/pdf-files/${pdfId}`);
|
setPreviewFileNameHint(filename ?? null);
|
||||||
const arrayBuffer = await res.arrayBuffer();
|
setPreviewFileId(pdfId);
|
||||||
const blob = new Blob([arrayBuffer], { type: "application/pdf" });
|
setIsPreviewOpen(true);
|
||||||
const url = URL.createObjectURL(blob);
|
|
||||||
setFileBlobUrl(url);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDownloadPdf = async (pdfId: number, filename: string) => {
|
const handleDownloadPdf = async (pdfId: number, filename: string) => {
|
||||||
@@ -309,7 +316,10 @@ export default function DocumentsPage() {
|
|||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
handleViewPdf(Number(pdf.id))
|
handleViewPdf(
|
||||||
|
Number(pdf.id),
|
||||||
|
pdf.filename
|
||||||
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Eye className="w-4 h-4" />
|
<Eye className="w-4 h-4" />
|
||||||
@@ -437,29 +447,16 @@ export default function DocumentsPage() {
|
|||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{fileBlobUrl && (
|
<DocumentsFilePreviewModal
|
||||||
<Card>
|
fileId={previewFileId}
|
||||||
<CardHeader className="flex justify-between items-center">
|
isOpen={isPreviewOpen}
|
||||||
<CardTitle>Viewing PDF</CardTitle>
|
onClose={() => {
|
||||||
<Button
|
setIsPreviewOpen(false);
|
||||||
variant="outline"
|
setPreviewFileId(null);
|
||||||
className="ml-auto text-red-600 border-red-500 hover:bg-red-100 hover:border-red-600"
|
setPreviewFileNameHint(null);
|
||||||
onClick={() => {
|
}}
|
||||||
setFileBlobUrl(null);
|
initialFileName={previewFileNameHint}
|
||||||
}}
|
/>
|
||||||
>
|
|
||||||
✕ Close
|
|
||||||
</Button>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<iframe
|
|
||||||
src={fileBlobUrl}
|
|
||||||
className="w-full h-[80vh] border rounded"
|
|
||||||
title="PDF Viewer"
|
|
||||||
/>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
|
|||||||
Reference in New Issue
Block a user