mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-30 15:48:12 +00:00
feat: implement FilePreviewModal to support image, video, and audio previews alongside PDFs
This commit is contained in:
@@ -2,6 +2,7 @@ export * from "./lib/input/BilingualInput";
|
||||
export * from "./lib/input/AmharicDatePicker";
|
||||
export * from "./lib/feedback/ConfirmModal";
|
||||
export * from "./lib/feedback/PdfPreviewModal";
|
||||
export * from "./lib/feedback/FilePreviewModal";
|
||||
export * from "./lib/feedback/ModalFooter";
|
||||
export * from "./lib/feedback/ApiErrorAlert";
|
||||
export * from "./lib/feedback/notify";
|
||||
|
||||
169
libs/ui/src/lib/feedback/FilePreviewModal.tsx
Normal file
169
libs/ui/src/lib/feedback/FilePreviewModal.tsx
Normal file
@@ -0,0 +1,169 @@
|
||||
import { Anchor, Button, Group, Modal, Stack, Text, ThemeIcon } from '@mantine/core';
|
||||
import { IconExternalLink, IconFileUnknown } from '@tabler/icons-react';
|
||||
|
||||
/** How a file is shown, once its type is known. */
|
||||
type PreviewKind = 'image' | 'video' | 'audio' | 'embed' | 'unsupported';
|
||||
|
||||
const IMAGE_EXTENSIONS = ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'avif', 'svg'];
|
||||
const VIDEO_EXTENSIONS = ['mp4', 'webm', 'ogv', 'mov', 'm4v'];
|
||||
const AUDIO_EXTENSIONS = ['mp3', 'wav', 'ogg', 'm4a'];
|
||||
const EMBED_EXTENSIONS = ['pdf', 'txt', 'csv', 'json', 'xml'];
|
||||
|
||||
/**
|
||||
* What the browser can actually render, decided from the mime type where there
|
||||
* is one and the URL's extension where there is not.
|
||||
*
|
||||
* Presigned links carry the storage key in the path, so the extension survives
|
||||
* even when the caller only has a URL. `image/tiff` and `image/heic` are
|
||||
* deliberately treated as images: Safari renders both, and everywhere else the
|
||||
* `<img>` fails visibly rather than an iframe offering a silent download.
|
||||
*/
|
||||
export function resolvePreviewKind(url: string, mimeType?: string | null): PreviewKind {
|
||||
const mime = mimeType?.toLowerCase() ?? '';
|
||||
if (mime.startsWith('image/')) return 'image';
|
||||
if (mime.startsWith('video/')) return 'video';
|
||||
if (mime.startsWith('audio/')) return 'audio';
|
||||
if (mime === 'application/pdf' || mime.startsWith('text/')) return 'embed';
|
||||
// Word, Excel and the rest: nothing renders them inline, and an iframe would
|
||||
// quietly start a download instead of previewing anything.
|
||||
if (mime) return 'unsupported';
|
||||
|
||||
const extension = extensionOf(url);
|
||||
if (!extension) return 'embed';
|
||||
if (IMAGE_EXTENSIONS.includes(extension)) return 'image';
|
||||
if (VIDEO_EXTENSIONS.includes(extension)) return 'video';
|
||||
if (AUDIO_EXTENSIONS.includes(extension)) return 'audio';
|
||||
if (EMBED_EXTENSIONS.includes(extension)) return 'embed';
|
||||
return 'unsupported';
|
||||
}
|
||||
|
||||
function extensionOf(url: string): string | null {
|
||||
// Presigned URLs carry a query string; the path is the part with the name.
|
||||
const path = url.split(/[?#]/)[0];
|
||||
const name = path.slice(path.lastIndexOf('/') + 1);
|
||||
const dot = name.lastIndexOf('.');
|
||||
return dot > 0 ? name.slice(dot + 1).toLowerCase() : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The one place a stored file gets opened anywhere in the app.
|
||||
*
|
||||
* Never `window.open` / `target="_blank"` a file that can be shown here —
|
||||
* route it through this modal so the reviewer never loses their place to a new
|
||||
* tab. What a slot accepts is configuration now, so this had to grow past the
|
||||
* PDF it started as: a national ID arrives as a photograph, evidence arrives
|
||||
* as video, and an academic record sometimes arrives as the Word file its
|
||||
* institution issued. The last of those genuinely cannot be rendered by a
|
||||
* browser, so it gets an honest panel and a link out rather than an iframe
|
||||
* that silently downloads it.
|
||||
*/
|
||||
export function FilePreviewModal({
|
||||
opened,
|
||||
onClose,
|
||||
url,
|
||||
title = 'Document',
|
||||
mimeType,
|
||||
/** Overrides the detected kind — for a blob URL with no extension. */
|
||||
kind,
|
||||
labels,
|
||||
}: {
|
||||
opened: boolean;
|
||||
onClose: () => void;
|
||||
url: string;
|
||||
title?: string;
|
||||
mimeType?: string | null;
|
||||
kind?: PreviewKind;
|
||||
/** Supplied by the app so this stays out of the i18n bundles. */
|
||||
labels?: { unsupported?: string; openInNewTab?: string; close?: string };
|
||||
}) {
|
||||
const resolved = kind ?? resolvePreviewKind(url, mimeType);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
title={title}
|
||||
size="80%"
|
||||
centered
|
||||
trapFocus
|
||||
returnFocus
|
||||
styles={
|
||||
resolved === 'image' || resolved === 'video'
|
||||
? // A photograph on a white sheet loses its own edges; the dark mat
|
||||
// is what tells the eye where the file ends.
|
||||
{ body: { background: 'var(--mantine-color-dark-8)', padding: 0 } }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{url && resolved === 'image' && (
|
||||
<img
|
||||
src={url}
|
||||
alt={title}
|
||||
style={{
|
||||
display: 'block',
|
||||
margin: '0 auto',
|
||||
maxWidth: '100%',
|
||||
maxHeight: '85vh',
|
||||
objectFit: 'contain',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{url && resolved === 'video' && (
|
||||
// Controls only, no autoplay: a review screen that starts making noise
|
||||
// on open is a review screen people mute and then miss the audio on.
|
||||
<video
|
||||
src={url}
|
||||
controls
|
||||
preload="metadata"
|
||||
style={{ display: 'block', width: '100%', maxHeight: '85vh' }}
|
||||
>
|
||||
<track kind="captions" />
|
||||
</video>
|
||||
)}
|
||||
|
||||
{url && resolved === 'audio' && (
|
||||
<Stack p="md">
|
||||
<audio src={url} controls style={{ width: '100%' }}>
|
||||
<track kind="captions" />
|
||||
</audio>
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{url && resolved === 'embed' && (
|
||||
<iframe
|
||||
src={url}
|
||||
title={title}
|
||||
style={{ width: '100%', height: '85vh', border: 'none' }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{url && resolved === 'unsupported' && (
|
||||
<Stack align="center" gap="sm" py="xl">
|
||||
<ThemeIcon size={56} radius="xl" variant="light" color="gray">
|
||||
<IconFileUnknown size={28} stroke={1.5} />
|
||||
</ThemeIcon>
|
||||
<Text fz="sm" c="dimmed" ta="center" maw={420}>
|
||||
{labels?.unsupported ??
|
||||
'This file type cannot be shown here. Open it in a new tab to download it.'}
|
||||
</Text>
|
||||
<Group>
|
||||
<Button
|
||||
component="a"
|
||||
href={url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
variant="light"
|
||||
leftSection={<IconExternalLink size={15} />}
|
||||
>
|
||||
{labels?.openInNewTab ?? 'Open in a new tab'}
|
||||
</Button>
|
||||
<Anchor component="button" type="button" fz="sm" onClick={onClose}>
|
||||
{labels?.close ?? 'Close'}
|
||||
</Anchor>
|
||||
</Group>
|
||||
</Stack>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Modal } from '@mantine/core';
|
||||
import { FilePreviewModal } from './FilePreviewModal';
|
||||
|
||||
interface PdfPreviewModalProps {
|
||||
opened: boolean;
|
||||
@@ -8,9 +8,14 @@ interface PdfPreviewModalProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* The one place a PDF gets opened anywhere in the app. Never `window.open` /
|
||||
* `target="_blank"` a PDF directly — route it through this modal instead, so
|
||||
* the reviewer never loses their place to a new tab.
|
||||
* A PDF viewer, kept as its own name because most callers only ever open a
|
||||
* PDF and say so at the call site.
|
||||
*
|
||||
* The rendering lives in {@link FilePreviewModal}, which also handles images,
|
||||
* video and the file types no browser can show. Callers that know the mime
|
||||
* type should use that directly; the ones here pass a URL alone and get the
|
||||
* same iframe they always had, since a link with no `.something` on the end
|
||||
* resolves to the embed view.
|
||||
*/
|
||||
export function PdfPreviewModal({
|
||||
opened,
|
||||
@@ -19,22 +24,6 @@ export function PdfPreviewModal({
|
||||
title = 'Document',
|
||||
}: PdfPreviewModalProps) {
|
||||
return (
|
||||
<Modal
|
||||
opened={opened}
|
||||
onClose={onClose}
|
||||
title={title}
|
||||
size="80%"
|
||||
centered
|
||||
trapFocus
|
||||
returnFocus
|
||||
>
|
||||
{url && (
|
||||
<iframe
|
||||
src={url}
|
||||
title={title}
|
||||
style={{ width: '100%', height: '85vh', border: 'none' }}
|
||||
/>
|
||||
)}
|
||||
</Modal>
|
||||
<FilePreviewModal opened={opened} onClose={onClose} url={url} title={title} />
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user