add file viewer functionality across various components

- Implemented a shared file viewer modal using `useFileViewer` hook to allow inline viewing of documents (images, PDFs, videos, etc.) across the application.
- Updated `ContractClearanceReviewSection`, `ContractRequestDetailPage`, `ContractViewPage`, and booking-related components to utilize the new file viewer for document previews.
- Added "Approve all" button in `ContractClearanceReviewSection` to bulk approve documents.
- Enhanced document action buttons to include view and download options based on file type.
- Introduced `isViewable` utility to determine if a file can be previewed inline.
- Created `FileViewer` component to handle rendering of various file types and added appropriate fallback for unsupported formats.
This commit is contained in:
Marshal
2026-06-27 19:18:43 +00:00
parent e977893888
commit 0ab553bf48
19 changed files with 893 additions and 116 deletions

View File

@@ -0,0 +1,307 @@
import { useMemo } from "react";
import {
Box,
Button,
Center,
Group,
Modal,
Stack,
Text,
ThemeIcon,
} from "@mantine/core";
import {
Download,
ExternalLink,
FileArchive,
FileQuestion,
} from "lucide-react";
/** The minimal file shape the viewer needs. */
export interface ViewableFile {
/** Display name (used for the title + extension fallback). */
name: string;
/** Direct URL to the file content. A signed URL is preferred when present. */
url: string;
/** MIME type when known (e.g. "application/pdf", "image/png"). */
mimeType?: string | null;
}
export interface FileViewerModalProps {
/** Whether the modal is open. */
open: boolean;
/** The file to display, or null when nothing is selected. */
file: ViewableFile | null;
/** Close handler. */
onClose: () => void;
}
type ViewerKind =
| "image"
| "video"
| "audio"
| "pdf"
| "office"
| "text"
| "unsupported";
const EXT_KIND: Record<string, ViewerKind> = {
// images
png: "image",
jpg: "image",
jpeg: "image",
gif: "image",
webp: "image",
bmp: "image",
svg: "image",
// video
mp4: "video",
webm: "video",
ogv: "video",
mov: "video",
m4v: "video",
// audio
mp3: "audio",
wav: "audio",
ogg: "audio",
m4a: "audio",
// documents
pdf: "pdf",
// office — rendered via the Microsoft Office online viewer
doc: "office",
docx: "office",
xls: "office",
xlsx: "office",
ppt: "office",
pptx: "office",
// text
txt: "text",
csv: "text",
json: "text",
log: "text",
md: "text",
};
/** Archives / binaries we deliberately do NOT try to render inline. */
const UNVIEWABLE_EXT = new Set([
"zip",
"rar",
"7z",
"tar",
"gz",
"bz2",
"exe",
"dmg",
"iso",
"bin",
]);
function extOf(name: string): string {
const dot = name.lastIndexOf(".");
return dot >= 0 ? name.slice(dot + 1).toLowerCase() : "";
}
/** Decide how to render a file from its MIME type, falling back to extension. */
export function resolveViewerKind(file: ViewableFile): ViewerKind {
const mime = (file.mimeType ?? "").toLowerCase();
const ext = extOf(file.name);
if (UNVIEWABLE_EXT.has(ext)) return "unsupported";
if (mime.startsWith("image/")) return "image";
if (mime.startsWith("video/")) return "video";
if (mime.startsWith("audio/")) return "audio";
if (mime === "application/pdf") return "pdf";
if (
mime.includes("word") ||
mime.includes("excel") ||
mime.includes("spreadsheet") ||
mime.includes("powerpoint") ||
mime.includes("presentation") ||
mime.includes("officedocument")
) {
return "office";
}
if (mime.startsWith("text/") || mime === "application/json") return "text";
// Fall back to the file extension when the MIME type is missing/generic.
return EXT_KIND[ext] ?? "unsupported";
}
/** True when a file can be previewed inline (not an archive/binary). */
export function isViewable(file: ViewableFile): boolean {
return resolveViewerKind(file) !== "unsupported";
}
/**
* A wide modal that renders the content of common document types inline —
* images, video, audio, PDFs, Office documents (via the Microsoft online
* viewer) and plain text. Archives and other binaries fall back to a download
* prompt. Use the {@link isViewable} / {@link resolveViewerKind} helpers to gate
* a "view" affordance in the caller.
*/
export function FileViewerModal({ open, file, onClose }: FileViewerModalProps) {
const kind = useMemo(
() => (file ? resolveViewerKind(file) : "unsupported"),
[file],
);
return (
<Modal
opened={open}
onClose={onClose}
title={
<Text fw={700} fz={15} truncate>
{file?.name ?? "Document"}
</Text>
}
size="90%"
radius="md"
centered
overlayProps={{ blur: 2, backgroundOpacity: 0.55 }}
styles={{
content: {
height: "90vh",
display: "flex",
flexDirection: "column",
},
body: { flex: 1, minHeight: 0, display: "flex", padding: 0 },
header: { paddingInline: 16 },
}}
>
{file && (
<Stack gap={0} style={{ flex: 1, minHeight: 0 }}>
<Group
justify="flex-end"
gap="xs"
px="md"
py={8}
style={{ borderBottom: "1px solid var(--mantine-color-gray-2)" }}
>
<Button
component="a"
href={file.url}
target="_blank"
rel="noopener noreferrer"
size="compact-sm"
variant="default"
leftSection={<ExternalLink size={14} />}
>
Open in new tab
</Button>
<Button
component="a"
href={file.url}
download={file.name}
size="compact-sm"
variant="light"
color="edr-green"
leftSection={<Download size={14} />}
>
Download
</Button>
</Group>
<Box style={{ flex: 1, minHeight: 0, overflow: "auto" }}>
<FileContent file={file} kind={kind} />
</Box>
</Stack>
)}
</Modal>
);
}
function FileContent({
file,
kind,
}: {
file: ViewableFile;
kind: ViewerKind;
}) {
switch (kind) {
case "image":
return (
<Center p="md" style={{ minHeight: "100%" }}>
<img
src={file.url}
alt={file.name}
style={{ maxWidth: "100%", maxHeight: "100%", objectFit: "contain" }}
/>
</Center>
);
case "video":
return (
<Center p="md" style={{ minHeight: "100%", background: "#000" }}>
<video
src={file.url}
controls
style={{ maxWidth: "100%", maxHeight: "100%" }}
/>
</Center>
);
case "audio":
return (
<Center p="xl" style={{ minHeight: "100%" }}>
<audio src={file.url} controls style={{ width: "100%", maxWidth: 480 }} />
</Center>
);
case "pdf":
return (
<iframe
src={file.url}
title={file.name}
style={{ width: "100%", height: "100%", border: "none" }}
/>
);
case "office":
return (
<iframe
// The Microsoft Office online viewer requires a publicly reachable URL.
src={`https://view.officeapps.live.com/op/embed.aspx?src=${encodeURIComponent(
file.url,
)}`}
title={file.name}
style={{ width: "100%", height: "100%", border: "none" }}
/>
);
case "text":
return (
<iframe
src={file.url}
title={file.name}
style={{ width: "100%", height: "100%", border: "none" }}
/>
);
default:
return <UnsupportedNotice file={file} />;
}
}
function UnsupportedNotice({ file }: { file: ViewableFile }) {
const isArchive = UNVIEWABLE_EXT.has(extOf(file.name));
return (
<Center p="xl" style={{ minHeight: "100%" }}>
<Stack align="center" gap="sm" maw={360} ta="center">
<ThemeIcon variant="light" color="gray" radius="xl" size={56}>
{isArchive ? <FileArchive size={26} /> : <FileQuestion size={26} />}
</ThemeIcon>
<Text fw={600}>This file type cant be previewed</Text>
<Text fz="sm" c="dimmed">
{isArchive
? "Archives need to be downloaded and extracted on your computer."
: "Download the file to open it in a compatible application."}
</Text>
<Button
component="a"
href={file.url}
download={file.name}
mt="xs"
color="edr-green"
leftSection={<Download size={16} />}
>
Download file
</Button>
</Stack>
</Center>
);
}
export default FileViewerModal;

View File

@@ -0,0 +1,7 @@
export {
FileViewerModal,
isViewable,
resolveViewerKind,
} from "./FileViewer";
export type { FileViewerModalProps, ViewableFile } from "./FileViewer";
export { default } from "./FileViewer";

View File

@@ -10,6 +10,16 @@ export type { SmartFileInputProps } from "./components/SmartFileInput";
export { default as Modal } from "./components/Modal";
export type { ModalProps } from "./components/Modal";
export {
FileViewerModal,
isViewable,
resolveViewerKind,
} from "./components/FileViewer";
export type {
FileViewerModalProps,
ViewableFile,
} from "./components/FileViewer";
export { Badge } from "./components/badge";
// export type { BadgeProps } from "./components/badge";