81 lines
3.2 KiB
TypeScript
81 lines
3.2 KiB
TypeScript
'use client';
|
|
import { FunctionComponent } from 'react';
|
|
import { useTranslations } from 'next-intl';
|
|
import { Box, Skeleton, Stack, Typography } from '@mui/material';
|
|
import type { VerificationDocument } from '@/services/verification/types';
|
|
import AppButton from '../common/AppButton';
|
|
import AppIcon from '../common/AppIcon';
|
|
|
|
export interface DocumentViewerProps {
|
|
document: VerificationDocument;
|
|
/** Refetches the parent case — there is no per-document re-sign route, so a reopen re-fetches the whole
|
|
* case to get a freshly-signed `document.url`. */
|
|
onReload: () => void;
|
|
/** True while the parent case is (re)loading — drives the skeleton. */
|
|
reloading: boolean;
|
|
}
|
|
|
|
/** Human-readable file size. */
|
|
function formatSize(bytes: number): string {
|
|
if (bytes < 1024) return `${bytes} B`;
|
|
if (bytes < 1024 * 1024) return `${Math.round(bytes / 1024)} KB`;
|
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
|
}
|
|
|
|
/**
|
|
* A verification-document viewer. `document.url` is the **short-lived signed GET URL the case detail
|
|
* already carries** — there is no separate per-document re-sign route (b6 gap), so "reload" refetches the
|
|
* whole case (`onReload`) rather than calling a document-specific endpoint. Handles the loading (parent
|
|
* case fetching) and loaded states (inline image preview for images, otherwise an "open in a new tab"
|
|
* affordance). PII → only the signed URL is ever surfaced, never a public asset (phase §5).
|
|
* @component DocumentViewer
|
|
*/
|
|
const DocumentViewer: FunctionComponent<DocumentViewerProps> = ({ document, onReload, reloading }) => {
|
|
const t = useTranslations('admin');
|
|
const isImage = document.contentType.startsWith('image/');
|
|
|
|
return (
|
|
<Box
|
|
data-document-id={document.id}
|
|
sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 'var(--bal-radius-md)', p: 1.5 }}
|
|
>
|
|
<Stack direction="row" sx={{ gap: 1, alignItems: 'center', mb: 1 }}>
|
|
<AppIcon icon="document" size={18} color="var(--bal-text-secondary)" />
|
|
<Typography variant="body2" sx={{ fontWeight: 500, flexGrow: 1, wordBreak: 'break-all' }}>
|
|
{t('doc_file_meta', { name: document.originalFileName ?? `#${document.id}`, size: formatSize(document.fileSizeBytes) })}
|
|
</Typography>
|
|
<AppButton
|
|
variant="text"
|
|
color="primary"
|
|
startIcon="refresh"
|
|
onClick={onReload}
|
|
disabled={reloading}
|
|
sx={{ minWidth: 0 }}
|
|
>
|
|
{t('doc_reload')}
|
|
</AppButton>
|
|
</Stack>
|
|
|
|
{reloading ? (
|
|
<Skeleton variant="rounded" height={isImage ? 180 : 44} />
|
|
) : document.url ? (
|
|
isImage ? (
|
|
// Signed, short-lived, cross-host URL (not a static asset) — a plain <img> via Box, not next/image.
|
|
<Box
|
|
component="img"
|
|
src={document.url}
|
|
alt={document.originalFileName ?? String(document.id)}
|
|
sx={{ maxWidth: '100%', maxHeight: 320, borderRadius: 'var(--bal-radius-sm)', display: 'block' }}
|
|
/>
|
|
) : (
|
|
<AppButton variant="outlined" color="primary" endIcon="external" href={document.url} openInNewTab>
|
|
{t('doc_open_new')}
|
|
</AppButton>
|
|
)
|
|
) : null}
|
|
</Box>
|
|
);
|
|
};
|
|
|
|
export default DocumentViewer;
|