update: add a gltf viewer
This commit is contained in:
@@ -0,0 +1,47 @@
|
||||
'use client'
|
||||
|
||||
import { Suspense } from 'react'
|
||||
import { Canvas } from '@react-three/fiber'
|
||||
import { Stage, OrbitControls, useGLTF } from '@react-three/drei'
|
||||
|
||||
function Model({ url }: { url: string }) {
|
||||
const { scene } = useGLTF(url)
|
||||
return <primitive object={scene} />
|
||||
}
|
||||
|
||||
function Loader() {
|
||||
return (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="w-6 h-6 border-2 border-gray-500 border-t-gray-300 rounded-full animate-spin" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface ModelViewerProps {
|
||||
url: string
|
||||
filename: string
|
||||
size: string
|
||||
}
|
||||
|
||||
export default function ModelViewer({ url, filename, size }: ModelViewerProps) {
|
||||
return (
|
||||
<div className="w-full h-[450px] bg-black-800 border border-black-700 rounded-xl overflow-hidden relative">
|
||||
<div className="absolute top-3 left-3 z-10 flex items-center gap-2">
|
||||
<span className="text-xs text-gray-400 font-mono bg-black-900/60 px-2 py-1 rounded">
|
||||
{filename}
|
||||
</span>
|
||||
<span className="text-xs text-gray-500 bg-black-900/60 px-2 py-1 rounded">
|
||||
{size}
|
||||
</span>
|
||||
</div>
|
||||
<Canvas shadows dpr={[1, 2]} camera={{ fov: 50 }}>
|
||||
<Suspense fallback={null}>
|
||||
<Stage environment="city" intensity={0.6} adjustCamera={1.2}>
|
||||
<Model url={url} />
|
||||
</Stage>
|
||||
</Suspense>
|
||||
<OrbitControls makeDefault autoRotate autoRotateSpeed={0.5} />
|
||||
</Canvas>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+37
-10
@@ -2,6 +2,9 @@
|
||||
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import { useDropzone, FileRejection } from 'react-dropzone'
|
||||
import dynamic from 'next/dynamic'
|
||||
|
||||
const ModelViewer = dynamic(() => import('./ModelViewer'), { ssr: false })
|
||||
|
||||
type FileStatus = 'pending' | 'uploading' | 'success' | 'error'
|
||||
|
||||
@@ -11,6 +14,7 @@ interface FileEntry {
|
||||
progress: number
|
||||
error?: string
|
||||
filename?: string
|
||||
previewUrl?: string
|
||||
}
|
||||
|
||||
interface UploadResult {
|
||||
@@ -25,15 +29,13 @@ interface UploadResult {
|
||||
const ACCEPTED_FORMATS = {
|
||||
'model/gltf-binary': ['.glb'],
|
||||
'model/gltf+json': ['.gltf'],
|
||||
'application/octet-stream': ['.fbx'],
|
||||
'image/png': ['.png'],
|
||||
'image/jpeg': ['.jpg', '.jpeg'],
|
||||
'image/webp': ['.webp'],
|
||||
'image/ktx2': ['.ktx2'],
|
||||
}
|
||||
|
||||
const MODEL_EXTENSIONS = ['.glb', '.gltf', '.fbx']
|
||||
const TEXTURE_EXTENSIONS = ['.png', '.jpg', '.jpeg', '.webp', '.ktx2']
|
||||
const MODEL_EXTENSIONS = ['.glb', '.gltf']
|
||||
const TEXTURE_EXTENSIONS = ['.png', '.jpg', '.jpeg', '.webp']
|
||||
const ALL_EXTENSIONS = [...MODEL_EXTENSIONS, ...TEXTURE_EXTENSIONS]
|
||||
|
||||
function formatBytes(bytes: number): string {
|
||||
@@ -137,10 +139,17 @@ export default function UploadZone() {
|
||||
const handleCancel = () => { xhrRef.current?.abort() }
|
||||
|
||||
const removeFile = (index: number) => {
|
||||
const file = files[index]
|
||||
if (file.previewUrl) {
|
||||
URL.revokeObjectURL(file.previewUrl)
|
||||
}
|
||||
setFiles((prev) => prev.filter((_, i) => i !== index))
|
||||
}
|
||||
|
||||
const handleReset = () => {
|
||||
files.forEach((f) => {
|
||||
if (f.previewUrl) URL.revokeObjectURL(f.previewUrl)
|
||||
})
|
||||
setFiles([])
|
||||
setGlobalError(null)
|
||||
setIsUploading(false)
|
||||
@@ -164,7 +173,14 @@ export default function UploadZone() {
|
||||
const existingNames = new Set(prev.map((f) => f.file.name))
|
||||
const newEntries: FileEntry[] = acceptedFiles
|
||||
.filter((f) => !existingNames.has(f.name))
|
||||
.map((file) => ({ file, status: 'pending', progress: 0 }))
|
||||
.map((file) => {
|
||||
const entry: FileEntry = { file, status: 'pending', progress: 0 }
|
||||
const type = getFileType(file.name)
|
||||
if (type === 'model') {
|
||||
entry.previewUrl = URL.createObjectURL(file)
|
||||
}
|
||||
return entry
|
||||
})
|
||||
return [...prev, ...newEntries]
|
||||
})
|
||||
}, [])
|
||||
@@ -268,7 +284,7 @@ export default function UploadZone() {
|
||||
<span className="text-gray-500 font-normal"> or click to browse</span>
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Models: .glb, .gltf, .fbx · Textures: .png, .jpg, .webp, .ktx2
|
||||
Models: .glb, .gltf · Textures: .png, .jpg, .webp
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
@@ -283,8 +299,8 @@ export default function UploadZone() {
|
||||
{files.map((entry, i) => {
|
||||
const type = getFileType(entry.file.name)
|
||||
return (
|
||||
<div key={`${entry.file.name}-${i}`}
|
||||
className="flex items-center gap-3 bg-black-800 border border-black-700 rounded-xl px-4 py-3">
|
||||
<div key={`${entry.file.name}-${i}`}>
|
||||
<div className="flex items-center gap-3 bg-black-800 border border-black-700 rounded-xl px-4 py-3">
|
||||
|
||||
<div className="shrink-0">
|
||||
{entry.status === 'success' ? (
|
||||
@@ -355,8 +371,19 @@ export default function UploadZone() {
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
|
||||
{entry.previewUrl && type === 'model' && entry.status !== 'success' && (
|
||||
<div className="mt-2">
|
||||
<ModelViewer
|
||||
url={entry.previewUrl}
|
||||
filename={entry.file.name}
|
||||
size={formatBytes(entry.file.size)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user