Some checks failed
Automated Container Build / build-and-push (push) Failing after 32s
367 lines
17 KiB
TypeScript
367 lines
17 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useEffect } from 'react';
|
|
import { motion, AnimatePresence } from 'framer-motion';
|
|
import { X, Download, File, Image as ImageIcon, Film, Music, FileText, Code, Loader2, Archive } from 'lucide-react';
|
|
import api from '@/lib/api';
|
|
import dynamic from 'next/dynamic';
|
|
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
|
|
import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism';
|
|
import { formatSize, getFileDisplayName } from '@/lib/utils';
|
|
|
|
const DynamicPdfViewer = dynamic(() => import('./PdfViewer'), { ssr: false });
|
|
const DynamicStlPreview = dynamic(() => import('./StlPreview'), { ssr: false });
|
|
const DynamicThreeMfPreview = dynamic(() => import('./ThreeMfPreview'), { ssr: false });
|
|
|
|
interface FilePreviewProps {
|
|
file: {
|
|
name: string;
|
|
path: string;
|
|
size: number;
|
|
mime_type: string;
|
|
} | null;
|
|
onClose: () => void;
|
|
downloadToken?: string;
|
|
}
|
|
|
|
// getFileDisplayName imported from utils
|
|
|
|
export default function FilePreview({ file, onClose, downloadToken }: FilePreviewProps) {
|
|
const [content, setContent] = useState<string | null>(null);
|
|
const [archiveItems, setArchiveItems] = useState<{name: string, size: number, path: string}[] | null>(null);
|
|
const [loading, setLoading] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
useEffect(() => {
|
|
function handleKeyDown(e: KeyboardEvent) {
|
|
if (e.key === 'Escape') onClose();
|
|
}
|
|
if (file) {
|
|
window.addEventListener('keydown', handleKeyDown);
|
|
}
|
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
|
}, [file, onClose]);
|
|
|
|
useEffect(() => {
|
|
if (!file) return;
|
|
|
|
const type = getPreviewType(file);
|
|
if (type === 'text' || type === 'code') {
|
|
if (file.size > 2 * 1024 * 1024) { // 2MB limit for text preview
|
|
setError('File is too large to preview. Please download it.');
|
|
return;
|
|
}
|
|
|
|
setLoading(true);
|
|
setError(null);
|
|
if (!downloadToken) return;
|
|
fetch(api.getDownloadUrl(file.path, downloadToken))
|
|
.then((res) => {
|
|
if (!res.ok) throw new Error('Failed to load file content');
|
|
return res.text();
|
|
})
|
|
.then((text) => setContent(text))
|
|
.catch((err) => setError(err.message))
|
|
.finally(() => setLoading(false));
|
|
} else if (type === 'archive') {
|
|
setLoading(true);
|
|
setError(null);
|
|
api.getArchiveContents(file.path)
|
|
.then(data => {
|
|
if (data.error) throw new Error(data.error);
|
|
setArchiveItems(data.items || []);
|
|
})
|
|
.catch(err => setError(err.message))
|
|
.finally(() => setLoading(false));
|
|
}
|
|
}, [file, downloadToken]);
|
|
|
|
if (!file) return null;
|
|
|
|
const previewType = getPreviewType(file);
|
|
const downloadUrl = downloadToken ? api.getDownloadUrl(file.path, downloadToken) : '';
|
|
const forceDownloadUrl = downloadToken ? api.getDownloadUrl(file.path, downloadToken, true) : '';
|
|
|
|
return (
|
|
<AnimatePresence>
|
|
<motion.div
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
exit={{ opacity: 0 }}
|
|
className="fixed inset-0 z-[100] flex items-center justify-center p-2 sm:p-4"
|
|
style={{ backgroundColor: 'rgba(0, 0, 0, 0.8)', backdropFilter: 'blur(8px)' }}
|
|
onClick={onClose}
|
|
>
|
|
<motion.div
|
|
initial={{ scale: 0.95, opacity: 0, y: 20 }}
|
|
animate={{ scale: 1, opacity: 1, y: 0 }}
|
|
exit={{ scale: 0.95, opacity: 0, y: 20 }}
|
|
onClick={(e) => e.stopPropagation()}
|
|
className={`relative flex flex-col max-w-[95vw] max-h-[95vh] rounded-2xl overflow-hidden shadow-2xl ${
|
|
(previewType === 'image' || previewType === 'video' || previewType === 'pdf' || previewType === 'stl' || previewType === '3mf' || previewType === 'audio') ? 'w-fit' : 'w-full'
|
|
} ${
|
|
(previewType === 'image' || previewType === 'video' || previewType === 'audio') ? 'h-fit' : 'h-full'
|
|
}`}
|
|
style={{ backgroundColor: 'var(--color-bg-elevated)', border: '1px solid var(--color-border)' }}
|
|
>
|
|
{/* Header */}
|
|
<div className="w-0 min-w-full flex items-center justify-between px-4 py-3 border-b shrink-0" style={{ borderColor: 'var(--color-border-subtle)', backgroundColor: 'var(--color-bg-secondary)' }}>
|
|
<div className="flex items-center gap-3 min-w-0">
|
|
{getIcon(previewType)}
|
|
<span className="font-medium truncate" style={{ color: 'var(--color-text-primary)' }} title={getFileDisplayName(file.name)}>
|
|
{getFileDisplayName(file.name)}
|
|
</span>
|
|
<span className="text-xs px-2 py-1 rounded-md" style={{ backgroundColor: 'var(--color-bg-tertiary)', color: 'var(--color-text-tertiary)' }}>
|
|
{formatSize(file.size)}
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center gap-2 shrink-0">
|
|
<a
|
|
href={forceDownloadUrl}
|
|
download={file.name}
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
className="p-2 rounded-lg transition-colors hover:bg-white/10"
|
|
style={{ color: 'var(--color-text-secondary)' }}
|
|
title="Download"
|
|
>
|
|
<Download className="w-5 h-5" />
|
|
</a>
|
|
<button
|
|
onClick={onClose}
|
|
className="p-2 rounded-lg transition-colors hover:bg-white/10"
|
|
style={{ color: 'var(--color-text-secondary)' }}
|
|
title="Close (Esc)"
|
|
>
|
|
<X className="w-5 h-5" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Content Area */}
|
|
<div className={`flex-1 overflow-auto flex items-center justify-center relative ${previewType === 'image' || previewType === 'video' ? 'p-4' : ''}`} style={{ backgroundColor: 'var(--color-bg-primary)' }}>
|
|
{previewType === 'image' && (
|
|
<img
|
|
src={downloadUrl}
|
|
alt={file.name}
|
|
className="max-w-[calc(95vw-2rem)] max-h-[calc(95vh-6rem)] object-contain"
|
|
/>
|
|
)}
|
|
|
|
{previewType === 'video' && (
|
|
<video
|
|
src={downloadUrl}
|
|
controls
|
|
autoPlay
|
|
className="max-w-[calc(95vw-2rem)] max-h-[calc(95vh-6rem)] rounded-lg outline-none"
|
|
/>
|
|
)}
|
|
|
|
{previewType === 'audio' && (
|
|
<div className="w-[400px] sm:w-[500px] max-w-full p-6 rounded-xl text-center" style={{ backgroundColor: 'var(--color-bg-elevated)', border: '1px solid var(--color-border)' }}>
|
|
<Music className="w-16 h-16 mx-auto mb-6" style={{ color: 'var(--color-accent)' }} />
|
|
<h3 className="text-lg font-medium mb-4 truncate" style={{ color: 'var(--color-text-primary)' }}>{getFileDisplayName(file.name)}</h3>
|
|
<audio src={downloadUrl} controls autoPlay className="w-full outline-none" />
|
|
</div>
|
|
)}
|
|
|
|
{previewType === 'pdf' && (
|
|
!downloadToken ? (
|
|
<div className="flex items-center justify-center p-8"><Loader2 className="w-8 h-8 animate-spin text-accent" /></div>
|
|
) : (
|
|
<div className="h-[90vh] aspect-[8.5/11] max-w-[95vw]">
|
|
<DynamicPdfViewer url={downloadUrl} />
|
|
</div>
|
|
)
|
|
)}
|
|
|
|
{previewType === 'stl' && (
|
|
!downloadToken ? (
|
|
<div className="flex items-center justify-center p-8"><Loader2 className="w-8 h-8 animate-spin text-accent" /></div>
|
|
) : (
|
|
<div className="w-[80vw] h-[80vh] max-w-[1200px] max-h-[800px]">
|
|
<DynamicStlPreview url={downloadUrl} />
|
|
</div>
|
|
)
|
|
)}
|
|
|
|
{previewType === '3mf' && (
|
|
!downloadToken ? (
|
|
<div className="flex items-center justify-center p-8"><Loader2 className="w-8 h-8 animate-spin text-accent" /></div>
|
|
) : (
|
|
<div className="w-[80vw] h-[80vh] max-w-[1200px] max-h-[800px]">
|
|
<DynamicThreeMfPreview url={downloadUrl} />
|
|
</div>
|
|
)
|
|
)}
|
|
|
|
{previewType === 'archive' && (
|
|
<div className="absolute inset-0 p-4">
|
|
{loading ? (
|
|
<div className="flex items-center justify-center h-full">
|
|
<Loader2 className="w-8 h-8 animate-spin" style={{ color: 'var(--color-accent)' }} />
|
|
</div>
|
|
) : error ? (
|
|
<div className="flex flex-col items-center justify-center h-full gap-2 text-center">
|
|
<Archive className="w-12 h-12 mb-2" style={{ color: 'var(--color-danger)' }} />
|
|
<p style={{ color: 'var(--color-danger)' }}>{error}</p>
|
|
</div>
|
|
) : (
|
|
<div className="w-full h-full flex flex-col rounded-xl overflow-hidden" style={{ backgroundColor: 'var(--color-bg-secondary)', border: '1px solid var(--color-border-subtle)' }}>
|
|
<div className="px-4 py-3 border-b flex items-center justify-between" style={{ borderColor: 'var(--color-border-subtle)', backgroundColor: 'var(--color-bg-tertiary)' }}>
|
|
<span className="font-semibold" style={{ color: 'var(--color-text-primary)' }}>Archive Contents</span>
|
|
<span className="text-sm px-2 py-1 rounded-md" style={{ backgroundColor: 'var(--color-bg-elevated)', color: 'var(--color-text-secondary)' }}>
|
|
{archiveItems?.length || 0} items
|
|
</span>
|
|
</div>
|
|
<div className="flex-1 overflow-auto">
|
|
<table className="w-full text-sm text-left whitespace-nowrap">
|
|
<thead className="sticky top-0 shadow-sm" style={{ backgroundColor: 'var(--color-bg-elevated)', color: 'var(--color-text-secondary)' }}>
|
|
<tr>
|
|
<th className="px-4 py-2 font-medium">Name</th>
|
|
<th className="px-4 py-2 font-medium w-32 text-right">Size</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-gray-200 dark:divide-neutral-800">
|
|
{archiveItems?.map((item, idx) => {
|
|
const iconConfig = getIcon(getPreviewType({ name: item.name, mime_type: '' }));
|
|
return (
|
|
<tr key={idx} className="hover:bg-white/5 transition-colors" style={{ color: 'var(--color-text-primary)' }}>
|
|
<td className="px-4 py-2 flex items-center gap-2">
|
|
{iconConfig}
|
|
<span className="truncate max-w-[60vw]" title={item.name}>{item.name}</span>
|
|
</td>
|
|
<td className="px-4 py-2 text-right" style={{ color: 'var(--color-text-secondary)' }}>
|
|
{formatSize(item.size)}
|
|
</td>
|
|
</tr>
|
|
);
|
|
})}
|
|
{archiveItems?.length === 0 && (
|
|
<tr>
|
|
<td colSpan={2} className="px-4 py-8 text-center" style={{ color: 'var(--color-text-tertiary)' }}>
|
|
This archive is empty.
|
|
</td>
|
|
</tr>
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{(previewType === 'text' || previewType === 'code') && (
|
|
<div className="absolute inset-0 p-4">
|
|
{loading ? (
|
|
<div className="flex items-center justify-center h-full">
|
|
<Loader2 className="w-8 h-8 animate-spin" style={{ color: 'var(--color-accent)' }} />
|
|
</div>
|
|
) : error ? (
|
|
<div className="flex flex-col items-center justify-center h-full gap-2">
|
|
<p style={{ color: 'var(--color-danger)' }}>{error}</p>
|
|
<a
|
|
href={downloadUrl}
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
className="px-4 py-2 mt-2 rounded-lg text-sm font-medium transition-opacity hover:opacity-90"
|
|
style={{ backgroundColor: 'var(--color-accent)', color: 'white' }}
|
|
>
|
|
Download File
|
|
</a>
|
|
</div>
|
|
) : previewType === 'code' ? (
|
|
<div className="w-full h-full overflow-auto rounded-lg" style={{ backgroundColor: 'var(--color-bg-secondary)', border: '1px solid var(--color-border-subtle)' }}>
|
|
<SyntaxHighlighter
|
|
language={file.name.split('.').pop()?.toLowerCase() || 'text'}
|
|
style={vscDarkPlus}
|
|
customStyle={{ margin: 0, background: 'transparent', height: '100%', fontSize: '14px' }}
|
|
showLineNumbers={true}
|
|
>
|
|
{content || ''}
|
|
</SyntaxHighlighter>
|
|
</div>
|
|
) : (
|
|
<pre
|
|
className="w-full h-full p-4 overflow-auto text-sm rounded-lg"
|
|
style={{
|
|
fontFamily: 'var(--font-mono)',
|
|
backgroundColor: 'var(--color-bg-secondary)',
|
|
color: 'var(--color-text-primary)',
|
|
border: '1px solid var(--color-border-subtle)',
|
|
}}
|
|
>
|
|
{content}
|
|
</pre>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{previewType === 'unsupported' && (
|
|
<div className="flex flex-col items-center justify-center gap-4 text-center">
|
|
<File className="w-16 h-16" style={{ color: 'var(--color-text-tertiary)' }} />
|
|
<div>
|
|
<h3 className="text-lg font-medium mb-1" style={{ color: 'var(--color-text-primary)' }}>No Preview Available</h3>
|
|
<p className="text-sm" style={{ color: 'var(--color-text-secondary)' }}>This file type cannot be previewed in the browser.</p>
|
|
</div>
|
|
<a
|
|
href={downloadUrl}
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
className="flex items-center gap-2 px-6 py-2.5 mt-2 rounded-xl text-sm font-medium transition-opacity hover:opacity-90"
|
|
style={{ backgroundColor: 'var(--color-accent)', color: 'white', boxShadow: 'var(--shadow-glow)' }}
|
|
>
|
|
<Download className="w-4 h-4" />
|
|
Download File
|
|
</a>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</motion.div>
|
|
</motion.div>
|
|
</AnimatePresence>
|
|
);
|
|
}
|
|
|
|
// --- Helpers ---
|
|
|
|
type PreviewType = 'image' | 'video' | 'audio' | 'text' | 'code' | 'pdf' | 'stl' | '3mf' | 'archive' | 'unsupported';
|
|
|
|
function getPreviewType(file: { name: string; mime_type: string }): PreviewType {
|
|
const ext = file.name.split('.').pop()?.toLowerCase() || '';
|
|
|
|
if (ext === 'pdf' || file.mime_type === 'application/pdf') return 'pdf';
|
|
if (ext === 'stl' || file.mime_type === 'model/stl') return 'stl';
|
|
if (ext === '3mf' || file.mime_type === 'model/3mf') return '3mf';
|
|
if (['zip', '7z'].includes(ext)) return 'archive';
|
|
if (['jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp'].includes(ext)) return 'image';
|
|
if (['mp4', 'webm', 'ogg'].includes(ext)) return 'video'; // Only browser-supported videos
|
|
if (['mp3', 'wav', 'flac', 'm4a', 'aac'].includes(ext)) return 'audio';
|
|
if (['txt', 'md', 'csv', 'json', 'log'].includes(ext)) return 'text';
|
|
if (['js', 'ts', 'jsx', 'tsx', 'html', 'css', 'go', 'py', 'rs', 'c', 'cpp', 'java', 'sh', 'yml', 'yaml', 'xml'].includes(ext)) return 'code';
|
|
|
|
if (file.mime_type?.startsWith('image/')) return 'image';
|
|
if (file.mime_type?.startsWith('video/')) return 'video';
|
|
if (file.mime_type?.startsWith('audio/')) return 'audio';
|
|
if (file.mime_type?.startsWith('text/')) return 'text';
|
|
|
|
return 'unsupported';
|
|
}
|
|
|
|
function getIcon(type: PreviewType) {
|
|
const props = { className: "w-5 h-5", style: { color: 'var(--color-text-secondary)' } };
|
|
switch (type) {
|
|
case 'image': return <ImageIcon {...props} />;
|
|
case 'video': return <Film {...props} />;
|
|
case 'audio': return <Music {...props} />;
|
|
case 'text': return <FileText {...props} />;
|
|
case 'code': return <Code {...props} />;
|
|
case 'archive': return <Archive {...props} />;
|
|
default: return <File {...props} />;
|
|
}
|
|
}
|
|
|
|
// formatSize imported from utils
|
|
|
|
|