mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
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:
307
packages/ui-common/src/components/FileViewer/FileViewer.tsx
Normal file
307
packages/ui-common/src/components/FileViewer/FileViewer.tsx
Normal 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 can’t 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;
|
||||
7
packages/ui-common/src/components/FileViewer/index.ts
Normal file
7
packages/ui-common/src/components/FileViewer/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export {
|
||||
FileViewerModal,
|
||||
isViewable,
|
||||
resolveViewerKind,
|
||||
} from "./FileViewer";
|
||||
export type { FileViewerModalProps, ViewableFile } from "./FileViewer";
|
||||
export { default } from "./FileViewer";
|
||||
@@ -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";
|
||||
|
||||
|
||||
Reference in New Issue
Block a user