mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-28 21:50:58 +00:00
feat: implement FilePreviewModal to support image, video, and audio previews alongside PDFs
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -1320,6 +1320,11 @@ export const am: Translations = {
|
||||
files: {
|
||||
none: 'ምንም የተያያዘ ፋይል የለም።',
|
||||
},
|
||||
preview: {
|
||||
unsupported: 'ይህ የፋይል አይነት እዚህ ሊታይ አይችልም። ለማውረድ በአዲስ ትር ይክፈቱት።',
|
||||
openInNewTab: 'በአዲስ ትር ክፈት',
|
||||
close: 'ዝጋ',
|
||||
},
|
||||
empty: {
|
||||
licenses: 'እስካሁን የተሰጠዎት የምስክር ወረቀት ወይም ፈቃድ የለም።',
|
||||
medical: 'እስካሁን በመዝገብ ላይ የሕክምና የምስክር ወረቀት የለም።',
|
||||
|
||||
@@ -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.',
|
||||
|
||||
@@ -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