Files
baya-monorepo/client/src/components/admin/DocumentViewer.tsx
T
2026-07-10 20:28:06 +03:30

87 lines
3.5 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 { useVerificationDocumentUrl } from '@/services/verification';
import AppButton from '../common/AppButton';
import AppIcon from '../common/AppIcon';
export interface DocumentViewerProps {
document: VerificationDocument;
}
/** 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 that fetches its **signed URL on demand** (never the embedded one — those
* are short-lived) via `useVerificationDocumentUrl`. Handles the full lifecycle: **loading** the link,
* **error / expired → re-request** (the URL is short-lived, so a manual re-request re-signs it), and the
* loaded state (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 }) => {
const t = useTranslations('admin');
const signed = useVerificationDocumentUrl(document.id);
const isImage = document.contentType.startsWith('image/');
return (
<Box
data-document-id={document.id}
sx={{ border: '1px solid', borderColor: 'divider', borderRadius: 2, 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: 600, 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={() => signed.refetch()}
disabled={signed.isFetching}
sx={{ m: 0, minWidth: 0 }}
>
{t('doc_reload')}
</AppButton>
</Stack>
{signed.isLoading || signed.isFetching ? (
<Skeleton variant="rounded" height={isImage ? 180 : 44} />
) : signed.isError ? (
<Stack sx={{ gap: 1, alignItems: 'flex-start' }}>
<Typography variant="body2" sx={{ color: 'var(--bal-error)' }}>
{t('doc_error')}
</Typography>
<AppButton variant="outlined" color="primary" startIcon="refresh" onClick={() => signed.refetch()} sx={{ m: 0 }}>
{t('doc_reload')}
</AppButton>
</Stack>
) : signed.data?.url ? (
isImage ? (
// Signed, short-lived, cross-host URL (not a static asset) — a plain <img> via Box, not next/image.
<Box
component="img"
src={signed.data.url}
alt={document.originalFileName ?? String(document.id)}
sx={{ maxWidth: '100%', maxHeight: 320, borderRadius: 1, display: 'block' }}
/>
) : (
<AppButton variant="outlined" color="primary" endIcon="external" href={signed.data.url} openInNewTab sx={{ m: 0 }}>
{t('doc_open_new')}
</AppButton>
)
) : null}
</Box>
);
};
export default DocumentViewer;