From 32f5fb4590eeac50af80646130a2c552a090a6bb Mon Sep 17 00:00:00 2001 From: estifanos Date: Fri, 28 Aug 2026 10:45:06 +0000 Subject: [PATCH] feat: implement FilePreviewModal to support image, video, and audio previews alongside PDFs --- .../components/PersonalDocumentSlots.tsx | 9 +- .../documents/pages/DocumentVaultPage.tsx | 21 ++- apps/portal/src/app/i18n/locales/am.ts | 5 + apps/portal/src/app/i18n/locales/en.ts | 6 + libs/ui/src/index.ts | 1 + libs/ui/src/lib/feedback/FilePreviewModal.tsx | 169 ++++++++++++++++++ libs/ui/src/lib/feedback/PdfPreviewModal.tsx | 31 ++-- 7 files changed, 215 insertions(+), 27 deletions(-) create mode 100644 libs/ui/src/lib/feedback/FilePreviewModal.tsx diff --git a/apps/portal/src/app/features/documents/components/PersonalDocumentSlots.tsx b/apps/portal/src/app/features/documents/components/PersonalDocumentSlots.tsx index 7d7375da0..5634a977f 100644 --- a/apps/portal/src/app/features/documents/components/PersonalDocumentSlots.tsx +++ b/apps/portal/src/app/features/documents/components/PersonalDocumentSlots.tsx @@ -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} diff --git a/apps/portal/src/app/features/documents/pages/DocumentVaultPage.tsx b/apps/portal/src/app/features/documents/pages/DocumentVaultPage.tsx index 4ecfd2851..4994c2829 100644 --- a/apps/portal/src/app/features/documents/pages/DocumentVaultPage.tsx +++ b/apps/portal/src/app/features/documents/pages/DocumentVaultPage.tsx @@ -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 = { 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} @@ -434,11 +441,17 @@ export function DocumentVaultPage() { - 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'), + }} /> ); diff --git a/apps/portal/src/app/i18n/locales/am.ts b/apps/portal/src/app/i18n/locales/am.ts index 139d0463e..dc10076c6 100644 --- a/apps/portal/src/app/i18n/locales/am.ts +++ b/apps/portal/src/app/i18n/locales/am.ts @@ -1320,6 +1320,11 @@ export const am: Translations = { files: { none: 'ምንም የተያያዘ ፋይል የለም።', }, + preview: { + unsupported: 'ይህ የፋይል አይነት እዚህ ሊታይ አይችልም። ለማውረድ በአዲስ ትር ይክፈቱት።', + openInNewTab: 'በአዲስ ትር ክፈት', + close: 'ዝጋ', + }, empty: { licenses: 'እስካሁን የተሰጠዎት የምስክር ወረቀት ወይም ፈቃድ የለም።', medical: 'እስካሁን በመዝገብ ላይ የሕክምና የምስክር ወረቀት የለም።', diff --git a/apps/portal/src/app/i18n/locales/en.ts b/apps/portal/src/app/i18n/locales/en.ts index 3ed42954d..70d2f399f 100644 --- a/apps/portal/src/app/i18n/locales/en.ts +++ b/apps/portal/src/app/i18n/locales/en.ts @@ -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.', diff --git a/libs/ui/src/index.ts b/libs/ui/src/index.ts index 61f5825db..855da1313 100644 --- a/libs/ui/src/index.ts +++ b/libs/ui/src/index.ts @@ -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"; diff --git a/libs/ui/src/lib/feedback/FilePreviewModal.tsx b/libs/ui/src/lib/feedback/FilePreviewModal.tsx new file mode 100644 index 000000000..1e8bf8d30 --- /dev/null +++ b/libs/ui/src/lib/feedback/FilePreviewModal.tsx @@ -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 + * `` 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 ( + + {url && resolved === 'image' && ( + {title} + )} + + {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. + + )} + + {url && resolved === 'audio' && ( + + + + )} + + {url && resolved === 'embed' && ( +