ready to test

Testing

Signed-off-by: Kenneth Obsequio <k80308392@gmail.com>
This commit is contained in:
2026-06-22 10:07:20 +08:00
parent 56d984a26a
commit fbef7cb6e6
283 changed files with 25961 additions and 1072 deletions
@@ -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;