mirror of
https://github.com/rgrgogu/new_starr.git
synced 2026-09-27 00:12:54 +08:00
ready to test
Testing Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
This commit is contained in:
@@ -0,0 +1,288 @@
|
||||
/***********************************************************************************************************************************************************************
|
||||
* 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 FileZoomViewer from './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', '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
|
||||
blobUrl={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;
|
||||
}
|
||||
};
|
||||
|
||||
// ─── Trigger a browser download from a blob ────────────────────────────────────
|
||||
const downloadBlob = (blob, fileName) => {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = fileName;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
};
|
||||
|
||||
// ─── 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' });
|
||||
downloadBlob(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;
|
||||
Reference in New Issue
Block a user