Files
starr-philproperties/src/modules/client/components/FilePreview.jsx
T

277 lines
12 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/***********************************************************************************************************************************************************************
* File Name : FilePreview.jsx
* Type : Component (Client)
* Description : Dialog-based file preview for task completion attachments.
* Renders by mime type:
* image/jpeg, image/png → FileZoomViewer (zoom/pan/fit)
* application/pdf → FileZoomViewer (pdf.js zoom/pan/fit)
* video/* → <video controls> (mp4, mov, webm, etc.)
* audio/* → centered audio player card (mp3, wav, etc.)
* other (incl. docx) → generic file card with download prompt
*
* Inline preview uses the blob-fetch pattern (same as
* VideoBlock/AudioBlock): authenticated GET via `api` →
* responseType 'blob' → URL.createObjectURL → set as src.
* This is required because <img>/<video>/<audio>/<iframe> src
* are native browser requests that don't carry the Authorization
* header attached by the axios interceptor.
*
* Props:
* file {object} – { file_id, file_url, file_name, file_size, mime_type, createdAt }
* open {boolean}
* onOpenChange {function}
* streamUrl {string} – proxy stream URL (inline preview, authenticated)
* downloadUrl {string} – proxy download URL (Content-Disposition: attachment)
***********************************************************************************************************************************************************************/
import { useState, useEffect } from 'react';
import {
Dialog, DialogContent, DialogHeader, DialogTitle,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Spinner } from '@/components/ui/spinner';
import {
Image, Video, Music, FileText, File, Download,
} from 'lucide-react';
import { formatDate } from '@/utils/table.util';
import { formatBytes } from './blocks/FileUpload';
import api from '@/utils/api.util';
import { saveBlob } from '@/utils/media.util';
import FileZoomViewer from '@/components/generic/FileZoomViewer';
// ─── Resolve preview kind from mime type ──────────────────────────────────────
const resolveKind = (mimeType = '', fileName = '') => {
if (mimeType.startsWith('image/')) return 'image';
if (mimeType.startsWith('video/')) return 'video';
if (mimeType.startsWith('audio/')) return 'audio';
if (mimeType === 'application/pdf') return 'pdf';
const ext = (fileName.split('.').pop() ?? '').toLowerCase();
if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg'].includes(ext)) return 'image';
if (['mp4', 'mov', 'mkv', 'webm', 'avi'].includes(ext)) return 'video';
if (['mp3', 'wav', 'm4a', 'ogg'].includes(ext)) return 'audio';
if (ext === 'pdf') return 'pdf';
return 'other';
};
const KIND_ICON = {
image: Image,
video: Video,
audio: Music,
pdf: FileText,
other: File,
};
// ─── useBlobUrl — fetches streamUrl via authenticated api, returns blob URL ────
const useBlobUrl = (streamUrl, enabled) => {
const [blobUrl, setBlobUrl] = useState(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(false);
useEffect(() => {
if (!enabled || !streamUrl) return;
let currentUrl = null;
let cancelled = false;
setLoading(true);
setError(false);
setBlobUrl(null);
api.get(streamUrl, { responseType: 'blob' })
.then((res) => {
if (cancelled) return;
currentUrl = URL.createObjectURL(res.data);
setBlobUrl(currentUrl);
})
.catch(() => {
if (!cancelled) setError(true);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
if (currentUrl) URL.revokeObjectURL(currentUrl);
};
}, [streamUrl, enabled]);
return { blobUrl, loading, error };
};
// ─── Loading / error placeholder ───────────────────────────────────────────────
const LoadingState = () => (
<div className="flex items-center justify-center py-16">
<Spinner className="size-6" />
</div>
);
const ErrorState = ({ onDownload, downloading, fileName }) => (
<div className="flex flex-col items-center gap-3 py-12 text-muted-foreground">
<File className="size-12" />
<p className="text-sm">Could not load preview.</p>
{onDownload && (
<Button variant="outline" size="sm" onClick={onDownload} disabled={downloading}>
<Download className="size-4" />
{downloading ? 'Downloading…' : 'Download file'}
</Button>
)}
</div>
);
// ─── Preview body per kind ─────────────────────────────────────────────────────
const PreviewBody = ({ file, kind, blobUrl, loading, error, downloadUrl, onDownload, downloading }) => {
if (kind === 'other') {
return (
<div className="flex flex-col items-center gap-3 py-12 text-muted-foreground">
<File className="size-12" />
<p className="text-sm">Preview not available for this file type.</p>
<Button variant="outline" size="sm" onClick={onDownload} disabled={downloading}>
<Download className="size-4" />
{downloading ? 'Downloading…' : 'Download file'}
</Button>
</div>
);
}
// ── Image / PDF — zoom/pan/fit viewer ───────────────────────────────────────
if (kind === 'image' || kind === 'pdf') {
if (error) return <ErrorState onDownload={onDownload} downloading={downloading} fileName={file.file_name} />;
return (
<FileZoomViewer
src={blobUrl}
mimeType={file.mime_type}
fileName={file.file_name}
loading={loading}
/>
);
}
if (loading) return <LoadingState />;
if (error || !blobUrl) return <ErrorState onDownload={onDownload} downloading={downloading} fileName={file.file_name} />;
switch (kind) {
case 'video':
return (
<div className="bg-black flex items-center justify-center aspect-video">
<video
src={blobUrl}
controls
controlsList="nodownload nofullscreen noremoteplayback"
disablePictureInPicture
onContextMenu={(e) => e.preventDefault()}
className="w-full h-full"
/>
</div>
);
case 'audio':
return (
<div className="flex flex-col items-center gap-4 py-8 px-6">
<div className="size-24 rounded-lg bg-muted flex items-center justify-center">
<Music className="size-9 text-muted-foreground" />
</div>
<div className="text-center">
<p className="text-sm font-medium truncate max-w-xs">{file.file_name}</p>
<p className="text-xs text-muted-foreground">Audio file</p>
</div>
<audio
src={blobUrl}
controls
controlsList="nodownload"
onContextMenu={(e) => e.preventDefault()}
className="w-full max-w-xs"
/>
</div>
);
default:
return null;
}
};
// ─── Main dialog ────────────────────────────────────────────────────────────────
const FilePreview = ({ file, open, onOpenChange, streamUrl, downloadUrl }) => {
const kind = file ? resolveKind(file.mime_type, file.file_name) : 'other';
const Icon = KIND_ICON[kind] ?? File;
// Only fetch the blob when the dialog is open and a previewable kind
const shouldFetch = open && !!file && kind !== 'other';
const { blobUrl, loading, error } = useBlobUrl(streamUrl, shouldFetch);
const [downloading, setDownloading] = useState(false);
if (!file) return null;
// ── Download handler — authenticated fetch via `api`, then save blob ──────
const handleDownload = async () => {
const url = downloadUrl || streamUrl;
if (!url) return;
setDownloading(true);
try {
const res = await api.get(url, { responseType: 'blob' });
saveBlob(res.data, file.file_name);
} catch {
// fall back to direct link if proxy fails (e.g. public file_url)
if (file.file_url) window.open(file.file_url, '_blank');
} finally {
setDownloading(false);
}
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-lg lg:max-w-4xl p-0 gap-0 overflow-hidden">
<DialogHeader className="px-4 py-3 pr-12 border-b flex-row items-center justify-between gap-3 space-y-0">
<div className="flex items-center gap-2 min-w-0 select-none">
<Icon className="size-4.5 text-muted-foreground shrink-0" />
<DialogTitle className="text-sm font-medium truncate max-w-54">
{file.file_name}
</DialogTitle>
<Button
variant="outline" size="sm"
onClick={handleDownload}
disabled={downloading}
aria-label="Download"
>
{downloading ? <Spinner className="size-4" /> : <Download className="size-4" />} Download
</Button>
</div>
</DialogHeader>
<PreviewBody
file={file}
kind={kind}
blobUrl={blobUrl}
loading={loading}
error={error}
downloadUrl={downloadUrl}
onDownload={handleDownload}
downloading={downloading}
/>
<div className="px-4 py-2.5 border-t flex items-center justify-between text-xs text-muted-foreground">
<div className="flex items-center gap-2">
{file.mime_type && (
<Badge variant="secondary" className="text-xs font-normal select-none">
{file.mime_type}
</Badge>
)}
{file.file_size != null && (
<Badge variant="secondary" className="text-xs font-normal select-none">
{formatBytes(file.file_size)}
</Badge>
)}
</div>
{file.createdAt && (
<span>Uploaded {formatDate(file.createdAt)}</span>
)}
</div>
</DialogContent>
</Dialog>
);
};
export default FilePreview;