Add 3MF viewer and enable PDF thumbnails
All checks were successful
Automated Container Build / build-and-push (push) Successful in 1m12s

This commit is contained in:
Elijah 2026-05-31 21:28:47 -07:00
parent cd540499ca
commit 72b1de2937
5 changed files with 571 additions and 17 deletions

View file

@ -11,6 +11,7 @@ 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: {
@ -86,7 +87,7 @@ export default function FilePreview({ file, onClose, downloadToken }: FilePrevie
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 === 'audio') ? 'w-fit' : 'w-full'
(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'
}`}
@ -171,6 +172,16 @@ export default function FilePreview({ file, onClose, downloadToken }: FilePrevie
)
)}
{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 === 'text' || previewType === 'code') && (
<div className="absolute inset-0 p-4">
{loading ? (
@ -245,13 +256,14 @@ export default function FilePreview({ file, onClose, downloadToken }: FilePrevie
// --- Helpers ---
type PreviewType = 'image' | 'video' | 'audio' | 'text' | 'code' | 'pdf' | 'stl' | 'unsupported';
type PreviewType = 'image' | 'video' | 'audio' | 'text' | 'code' | 'pdf' | 'stl' | '3mf' | '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 (['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';

View file

@ -0,0 +1,85 @@
import React, { useState, useEffect } from 'react';
import { Canvas } from '@react-three/fiber';
import { OrbitControls, Stage } from '@react-three/drei';
import { ThreeMFLoader } from 'three-stdlib';
import { Loader2 } from 'lucide-react';
import * as THREE from 'three';
interface ThreeMfPreviewProps {
url: string;
}
function Model({ url, onLoad, onError }: { url: string; onLoad: () => void; onError: (e: any) => void }) {
const [model, setModel] = useState<THREE.Group | null>(null);
useEffect(() => {
const loader = new ThreeMFLoader();
loader.load(
url,
(group) => {
// Adjust materials for shadow casting
group.traverse((child) => {
if ((child as THREE.Mesh).isMesh) {
const mesh = child as THREE.Mesh;
mesh.castShadow = true;
mesh.receiveShadow = true;
}
});
setModel(group);
onLoad();
},
undefined,
(err) => {
onError(err);
}
);
}, [url, onLoad, onError]);
if (!model) return null;
return <primitive object={model} />;
}
export default function ThreeMfPreview({ url }: ThreeMfPreviewProps) {
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
return (
<div className="w-full h-full relative" style={{ backgroundColor: 'var(--color-bg-primary)' }}>
{loading && !error && (
<div className="absolute inset-0 flex flex-col items-center justify-center z-10" style={{ backgroundColor: 'var(--color-bg-primary)' }}>
<Loader2 className="w-8 h-8 animate-spin mb-4" style={{ color: 'var(--color-accent)' }} />
<span className="text-sm font-medium" style={{ color: 'var(--color-text-secondary)' }}>Loading 3D Model...</span>
</div>
)}
{error && (
<div className="absolute inset-0 flex flex-col items-center justify-center z-10" style={{ backgroundColor: 'var(--color-bg-primary)' }}>
<svg className="w-12 h-12 mb-4 text-red-500" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4m0 4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span className="text-sm font-medium text-red-500">Failed to load 3MF file</span>
<span className="text-xs mt-2" style={{ color: 'var(--color-text-tertiary)' }}>{error.message}</span>
</div>
)}
<div className={`w-full h-full transition-opacity duration-300 ${loading ? 'opacity-0' : 'opacity-100'}`}>
<Canvas shadows dpr={[1, 2]} camera={{ position: [0, 0, 150], fov: 50 }}>
<React.Suspense fallback={null}>
<Stage environment="city" intensity={0.6} adjustCamera>
<Model
url={url}
onLoad={() => setLoading(false)}
onError={(err) => {
console.error('3MF Load Error:', err);
setError(err);
setLoading(false);
}}
/>
</Stage>
</React.Suspense>
<OrbitControls makeDefault />
</Canvas>
</div>
</div>
);
}