'use client' 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' interface FileEntry { file: File status: FileStatus progress: number error?: string filename?: string previewUrl?: string viewerOpen?: boolean } interface UploadResult { success: boolean filename?: string message?: string error?: string gitOutput?: string gitError?: string } const ACCEPTED_FORMATS = { 'model/gltf-binary': ['.glb'], 'model/gltf+json': ['.gltf'], 'image/png': ['.png'], 'image/jpeg': ['.jpg', '.jpeg'], 'image/webp': ['.webp'], } const MODEL_EXTENSIONS = ['.glb', '.gltf'] const TEXTURE_EXTENSIONS = ['.png', '.jpg', '.jpeg', '.webp'] const ALL_EXTENSIONS = [...MODEL_EXTENSIONS, ...TEXTURE_EXTENSIONS] function formatBytes(bytes: number): string { if (bytes === 0) return '0 B' const k = 1024 const sizes = ['B', 'KB', 'MB', 'GB'] const i = Math.floor(Math.log(bytes) / Math.log(k)) return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}` } function getFileType(filename: string): 'model' | 'texture' | null { const ext = filename.slice(filename.lastIndexOf('.')).toLowerCase() if (MODEL_EXTENSIONS.includes(ext)) return 'model' if (TEXTURE_EXTENSIONS.includes(ext)) return 'texture' return null } function uploadSingleFile( file: File, secret: string, assetName: string, onProgress: (pct: number) => void, xhrRef: { current: XMLHttpRequest | null } ): Promise { return new Promise((resolve) => { const formData = new FormData() formData.append('file', file) if (assetName.trim()) formData.append('assetName', assetName.trim()) const xhr = new XMLHttpRequest() xhrRef.current = xhr xhr.upload.onprogress = (e) => { if (e.lengthComputable) onProgress(Math.round((e.loaded / e.total) * 100)) } xhr.onload = () => { try { resolve(JSON.parse(xhr.responseText)) } catch { resolve({ success: false, error: `Unexpected response (HTTP ${xhr.status})` }) } } xhr.onerror = () => resolve({ success: false, error: 'Network error.' }) xhr.onabort = () => resolve({ success: false, error: 'Cancelled.' }) xhr.open('POST', '/api/upload') xhr.setRequestHeader('x-upload-secret', secret.trim()) xhr.send(formData) }) } export default function UploadZone() { const [files, setFiles] = useState([]) const [isUploading, setIsUploading] = useState(false) const [secret, setSecret] = useState('') const [secretVisible, setSecretVisible] = useState(false) const [assetName, setAssetName] = useState('') const [globalError, setGlobalError] = useState(null) const xhrRef = useRef(null) const updateFile = (index: number, patch: Partial) => { setFiles((prev) => prev.map((f, i) => i === index ? { ...f, ...patch } : f)) } const handleUpload = useCallback(async () => { if (!secret.trim()) { setGlobalError('Please enter the access key before uploading.') return } if (files.length === 0) return setIsUploading(true) setGlobalError(null) for (let i = 0; i < files.length; i++) { if (files[i].status === 'success') continue updateFile(i, { status: 'uploading', progress: 0, error: undefined }) const result = await uploadSingleFile( files[i].file, secret, assetName, (pct) => updateFile(i, { progress: pct }), xhrRef ) updateFile(i, { status: result.success ? 'success' : 'error', progress: result.success ? 100 : 0, error: result.success ? undefined : result.error, filename: result.filename, }) } setIsUploading(false) }, [files, secret, assetName]) 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) } const onDrop = useCallback((acceptedFiles: File[], rejectedFiles: FileRejection[]) => { if (rejectedFiles.length > 0) { const codes = rejectedFiles[0].errors.map((e) => e.code).join(', ') setGlobalError( codes.includes('file-invalid-type') ? `Unsupported format. Use: ${ALL_EXTENSIONS.join(', ')}` : codes.includes('file-too-large') ? 'File too large (max 2 GB)' : `File rejected: ${codes}` ) return } setGlobalError(null) setFiles((prev) => { const existingNames = new Set(prev.map((f) => f.file.name)) const newEntries: FileEntry[] = acceptedFiles .filter((f) => !existingNames.has(f.name)) .map((file) => { const entry: FileEntry = { file, status: 'pending', progress: 0, viewerOpen: true } const type = getFileType(file.name) if (type === 'model') { entry.previewUrl = URL.createObjectURL(file) } return entry }) return [...prev, ...newEntries] }) }, []) const { getRootProps, getInputProps, isDragActive, isDragReject } = useDropzone({ onDrop, accept: ACCEPTED_FORMATS, maxSize: 2 * 1024 * 1024 * 1024, disabled: isUploading, multiple: false, }) const allDone = files.length > 0 && files.every((f) => f.status === 'success') const hasErrors = files.some((f) => f.status === 'error') return (
setSecret(e.target.value)} placeholder="Enter secret key..." disabled={isUploading} className="w-full bg-black-800 border border-white/30 rounded-xl px-4 py-2.5 pr-12 text-gray-100 placeholder-gray-500 text-sm focus:outline-none focus:ring-2 focus:ring-white/50 focus:border-white/50 disabled:opacity-50 disabled:cursor-not-allowed transition" />
setAssetName(e.target.value)} placeholder="e.g., tower, stone_floor, brick_wall..." disabled={isUploading} className="w-full bg-black-800 border border-white/30 rounded-xl px-4 py-2.5 text-gray-100 placeholder-gray-500 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-white/50 focus:border-white/50 disabled:opacity-50 disabled:cursor-not-allowed transition" />
{files.length === 0 && (
{isDragReject ? (

Unsupported format

) : isDragActive ? (

Release to add

) : ( <>

Drag files here or click to browse

Models: .glb, .gltf · Textures: .png, .jpg, .webp

)}
)} {globalError && (

{globalError}

)} {files.length > 0 && (
{files.map((entry, i) => { const type = getFileType(entry.file.name) return (
{entry.status === 'success' ? (
) : entry.status === 'error' ? (
) : entry.status === 'uploading' ? (
) : ( )}
{entry.file.name} {type && ( {type === 'model' ? '3D' : 'Texture'} )}
{formatBytes(entry.file.size)} {entry.status === 'error' && entry.error && ( {entry.error} )} {entry.status === 'success' && entry.filename && ( {entry.filename} )}
{entry.status === 'uploading' && (
)}
{entry.status !== 'uploading' && ( )}
{entry.previewUrl && type === 'model' && entry.status !== 'success' && (
)}
); })}
)}
{!isUploading && files.some((f) => f.status === 'pending' || f.status === 'error') && ( )} {isUploading && ( )} {(allDone || hasErrors) && !isUploading && ( )}
) }