mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
277 lines
12 KiB
React
277 lines
12 KiB
React
/***********************************************************************************************************************************************************************
|
||
* 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; |