feat: implement FilePreviewModal to support image, video, and audio previews alongside PDFs

This commit is contained in:
estifanos
2026-08-28 10:45:06 +00:00
parent 3368c9bd3f
commit 32f5fb4590
7 changed files with 215 additions and 27 deletions

View File

@@ -60,7 +60,7 @@ function errorBody(err: unknown): PersonalDocumentError {
export function PersonalDocumentSlots({
onPreview,
}: {
onPreview: (preview: { url: string; title: string }) => void;
onPreview: (preview: { url: string; title: string; mimeType?: string | null }) => void;
}) {
const { t } = useTranslation();
const localized = useLocalized();
@@ -260,7 +260,12 @@ export function PersonalDocumentSlots({
c={file.url ? 'blue' : undefined}
truncate
onClick={() =>
file.url && onPreview({ url: file.url, title: file.originalName })
file.url &&
onPreview({
url: file.url,
title: file.originalName,
mimeType: file.mimeType,
})
}
>
{file.originalName}

View File

@@ -25,7 +25,7 @@ import {
} from '@tabler/icons-react';
import { useTranslation } from 'react-i18next';
import { useDateDisplayer } from '@ema-platform/shared';
import { notify, PdfPreviewModal } from '@ema-platform/ui';
import { FilePreviewModal, notify } from '@ema-platform/ui';
import {
extractErrorMessage,
useGetAttachmentsQuery,
@@ -41,7 +41,8 @@ import {
import { LicenseCard, useRenewLicense } from '../../licensing/components/LicenseCard';
import { PersonalDocumentSlots } from '../components/PersonalDocumentSlots';
type Preview = { url: string; title: string };
/** What the viewer needs: the link, a caption, and how to render it. */
type Preview = { url: string; title: string; mimeType?: string | null };
const RECORD_STATUS_COLOR: Record<SeafarerRecordStatus, string> = {
SUBMITTED: 'blue',
@@ -85,7 +86,13 @@ function RecordFiles({
component="button"
type="button"
fz="xs"
onClick={() => onPreview({ url: file.url as string, title: file.originalName })}
onClick={() =>
onPreview({
url: file.url as string,
title: file.originalName,
mimeType: file.mimeType,
})
}
>
{file.originalName}
</Anchor>
@@ -434,11 +441,17 @@ export function DocumentVaultPage() {
</Tabs>
</Stack>
<PdfPreviewModal
<FilePreviewModal
opened={Boolean(preview)}
onClose={() => setPreview(null)}
url={preview?.url ?? ''}
title={preview?.title}
mimeType={preview?.mimeType}
labels={{
unsupported: t('documents.preview.unsupported'),
openInNewTab: t('documents.preview.openInNewTab'),
close: t('documents.preview.close'),
}}
/>
</Container>
);

View File

@@ -1320,6 +1320,11 @@ export const am: Translations = {
files: {
none: 'ምንም የተያያዘ ፋይል የለም።',
},
preview: {
unsupported: 'ይህ የፋይል አይነት እዚህ ሊታይ አይችልም። ለማውረድ በአዲስ ትር ይክፈቱት።',
openInNewTab: 'በአዲስ ትር ክፈት',
close: 'ዝጋ',
},
empty: {
licenses: 'እስካሁን የተሰጠዎት የምስክር ወረቀት ወይም ፈቃድ የለም።',
medical: 'እስካሁን በመዝገብ ላይ የሕክምና የምስክር ወረቀት የለም።',

View File

@@ -1323,6 +1323,12 @@ export const en = {
files: {
none: 'No files attached.',
},
preview: {
unsupported:
'This file type cannot be shown here. Open it in a new tab to download it.',
openInNewTab: 'Open in a new tab',
close: 'Close',
},
empty: {
licenses: 'No certificates or licences have been issued to you yet.',
medical: 'No medical certificates on file yet.',

View File

@@ -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";

View 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>
);
}

View File

@@ -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} />
);
}