'use client'; import { useEffect, useState } from 'react'; import dynamic from 'next/dynamic'; import { Download, ExternalLink, FileWarning, ZoomIn } from 'lucide-react'; import { useQuery } from '@tanstack/react-query'; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, } from '@/components/ui/dialog'; import { Button } from '@/components/ui/button'; import { apiFetch } from '@/lib/api/client'; import { isWordDocx } from '@/lib/constants/file-validation'; // yet-another-react-lightbox is ~50kb, lazy-load it. const Lightbox = dynamic(() => import('yet-another-react-lightbox'), { ssr: false }); import 'yet-another-react-lightbox/styles.css'; // pdfjs-dist is ~150kb gzip - lazy-load so routes that never preview // PDFs don't ship it. ssr:false because the worker setup needs window. const PdfViewer = dynamic(() => import('./pdf-viewer').then((m) => ({ default: m.PdfViewer })), { ssr: false, loading: () => (
Loading PDF viewer…
), }); // docx-preview is lazy-loaded the same way — only .docx previews pull it in. const DocxViewer = dynamic(() => import('./docx-viewer').then((m) => ({ default: m.DocxViewer })), { ssr: false, loading: () => (
Loading document viewer…
), }); interface FilePreviewDialogProps { open: boolean; onOpenChange: (open: boolean) => void; fileId?: string; fileName?: string; mimeType?: string; } /** * Routes a file's mime type to one of seven preview surfaces. Order * matters - `application/pdf` is matched before the generic * "application/*" bucket so PDFs stay on the rich pdfjs viewer. */ type PreviewKind = | 'image' | 'pdf' | 'text' | 'audio' | 'video' | 'office' // .docx / .xlsx / .pptx / .odt / .ods | 'unknown'; function previewKindFor(mimeType: string | undefined, fileName: string | undefined): PreviewKind { const mt = mimeType ?? ''; const name = (fileName ?? '').toLowerCase(); if (mt.startsWith('image/')) return 'image'; if (mt === 'application/pdf' || name.endsWith('.pdf')) return 'pdf'; if (mt.startsWith('audio/') || /\.(mp3|wav|m4a|ogg|flac)$/.test(name)) return 'audio'; if (mt.startsWith('video/') || /\.(mp4|mov|webm|m4v|ogg)$/.test(name)) return 'video'; if ( mt.startsWith('text/') || mt === 'application/json' || mt === 'application/xml' || /\.(txt|md|csv|tsv|json|xml|log|yaml|yml|conf|ini|html?)$/.test(name) ) { return 'text'; } if ( mt.includes('officedocument') || mt === 'application/msword' || mt === 'application/vnd.ms-excel' || mt === 'application/vnd.ms-powerpoint' || mt === 'application/vnd.oasis.opendocument.text' || mt === 'application/vnd.oasis.opendocument.spreadsheet' || mt === 'application/vnd.oasis.opendocument.presentation' || /\.(docx?|xlsx?|pptx?|odt|ods|odp)$/.test(name) ) { return 'office'; } return 'unknown'; } export function FilePreviewDialog({ open, onOpenChange, fileId, fileName, mimeType, }: FilePreviewDialogProps) { const [lightboxOpen, setLightboxOpen] = useState(false); // useQuery replaces the prior useEffect(fetch+setState) pattern. The // request is gated on the dialog being open and a fileId being set. const previewQuery = useQuery<{ data: { url: string } }>({ queryKey: ['file-preview', fileId], queryFn: () => apiFetch(`/api/v1/files/${fileId}/preview`), enabled: open && !!fileId, }); const previewUrl = previewQuery.data?.data.url ?? null; const loading = previewQuery.isLoading; const error = previewQuery.error ? 'Failed to load preview' : null; const kind = previewKindFor(mimeType, fileName); return ( {fileName ?? 'Preview'} {fileId && ( )} {previewUrl && ( )} {/* A6: screen-reader description; visually hidden because the * title + preview surface tells sighted users what the dialog * contains. Skips the Radix "missing aria-describedby" warning. */} Inline preview of {fileName ?? 'the selected file'}.
{loading && (
Loading preview...
)} {error && (
{error}
)} {!loading && !error && previewUrl && kind === 'image' && ( )} {!loading && !error && previewUrl && kind === 'pdf' && ( )} {!loading && !error && previewUrl && kind === 'text' && } {!loading && !error && previewUrl && kind === 'audio' && (
{/* HTML5
)} {!loading && !error && previewUrl && kind === 'video' && (
)} {!loading && !error && previewUrl && kind === 'office' && // Word .docx renders in-browser via docx-preview (fetches the // bytes from our own storage — works with private MinIO/disk). // We do NOT use Microsoft's hosted Office viewer: it requires a // publicly-reachable URL, which our private object store isn't. // Legacy .doc + spreadsheet formats can't be rendered client- // side, so they fall through to a download CTA. (isWordDocx(mimeType, fileName) ? ( ) : (

In-browser preview isn't available

This Office format ({mimeType ?? 'unknown'}) can't be rendered in the browser. Download it to view locally.

))} {!loading && !error && previewUrl && kind === 'unknown' && (

Preview not supported for this file type

The file mime type ({mimeType ?? 'unknown'}) doesn't map to a built-in preview surface. Download to view it locally.

)}
{/* Lightbox renders OUTSIDE the parent Dialog so the dialog's own * bounds don't clip the fullscreen overlay. yet-another-react- * lightbox handles zoom/pan/keyboard nav out of the box. */} {previewUrl && kind === 'image' && ( setLightboxOpen(false)} slides={[{ src: previewUrl, alt: fileName ?? 'Preview' }]} controller={{ closeOnBackdropClick: true }} carousel={{ finite: true }} /> )}
); } /** * Plain-text preview pane - fetches the file body via the presigned * URL (no auth needed; the URL itself carries the access token) and * renders it as monospaced text. Caps the body at 1 MB so a huge log * file doesn't lock the browser; surfaces a "first 1 MB shown" notice * when the cap is hit. */ function TextPreview({ url }: { url: string }) { const [text, setText] = useState(null); const [error, setError] = useState(null); const [truncated, setTruncated] = useState(false); const MAX_BYTES = 1_000_000; // 1 MB useEffect(() => { let cancelled = false; async function load() { try { const res = await fetch(url); if (!res.ok) { if (!cancelled) setError(`Failed to load preview (${res.status})`); return; } const blob = await res.blob(); const slice = blob.slice(0, MAX_BYTES); const body = await slice.text(); if (cancelled) return; setText(body); setTruncated(blob.size > MAX_BYTES); } catch (err) { if (cancelled) return; setError(err instanceof Error ? err.message : 'Unknown error'); } } void load(); return () => { cancelled = true; }; }, [url]); if (error) { return (
{error}
); } if (text === null) { return (
Loading…
); } return (
{truncated ? (
Showing the first 1 MB. Download the full file to view the rest.
) : null}
        {text}
      
); }