'use client' import { useCallback, useRef, useState } from 'react' import dynamic from 'next/dynamic' const ModelViewer = dynamic(() => import('./ModelViewer'), { ssr: false }) type FileStatus = 'pending' | 'uploading' | 'success' | 'error' interface TextureFile { name: string file: File } interface FolderEntry { folderName: string modelFile: File textures: TextureFile[] status: FileStatus progress: number error?: string filename?: string modelUrl?: string viewerOpen?: boolean warnings: string[] } const REQUIRED_TEXTURES = ['roughness', 'normal', 'metalness', 'color', 'displace'] const TEXTURE_EXTENSIONS = ['.png', '.jpg', '.jpeg', '.webp'] 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 getTextureType(filename: string): string | null { const name = filename.toLowerCase().replace(/\.[^.]+$/, '') if (REQUIRED_TEXTURES.includes(name)) return name return null } function validateFolder(files: File[]): { model?: File; textures: TextureFile[]; errors: string[]; warnings: string[] } { const result: { model?: File; textures: TextureFile[]; errors: string[]; warnings: string[] } = { textures: [], errors: [], warnings: [] } const modelFiles = files.filter(f => { const name = f.name.toLowerCase() return name === 'model.glb' || name === 'model.gltf' }) if (modelFiles.length === 0) { result.errors.push('model.glb ou model.gltf manquant (obligatoire)') } else { result.model = modelFiles[0] } const textureFiles = files.filter(f => { const ext = f.name.slice(f.name.lastIndexOf('.')).toLowerCase() return TEXTURE_EXTENSIONS.includes(ext) && getTextureType(f.name) !== null }) for (const tf of textureFiles) { result.textures.push({ name: tf.name, file: tf }) } const foundTextures = new Set(result.textures.map(t => t.name.toLowerCase().replace(/\.[^.]+$/, ''))) for (const req of REQUIRED_TEXTURES) { if (!foundTextures.has(req)) { result.warnings.push(`${req}.webp/png/jpg manquant`) } } return result } function uploadFolder( folder: FolderEntry, secret: string, onProgress: (pct: number) => void, xhrRef: { current: XMLHttpRequest | null } ): Promise<{ success: boolean; filename?: string; error?: string }> { return new Promise((resolve) => { const totalFiles = 1 + folder.textures.length let completed = 0 const checkDone = () => { completed++ onProgress(Math.round((completed / totalFiles) * 100)) if (completed >= totalFiles) { resolve({ success: true, filename: folder.folderName }) } } const formData = new FormData() formData.append('folderName', folder.folderName) formData.append('file', folder.modelFile) formData.append('fileType', 'model') for (const tex of folder.textures) { const texForm = new FormData() texForm.append('folderName', folder.folderName) texForm.append('file', tex.file) texForm.append('fileType', 'texture') texForm.append('textureName', tex.name) const xhr = new XMLHttpRequest() xhrRef.current = xhr xhr.onload = () => { try { const res = JSON.parse(xhr.responseText) if (!res.success) { resolve({ success: false, error: `Texture ${tex.name} : ${res.error}` }) } else { checkDone() } } catch { checkDone() } } xhr.onerror = () => resolve({ success: false, error: `Erreur réseau: ${tex.name}` }) xhr.open('POST', '/api/upload') xhr.setRequestHeader('x-upload-secret', secret.trim()) xhr.send(texForm) } const modelXhr = new XMLHttpRequest() xhrRef.current = modelXhr modelXhr.onload = () => { try { const res = JSON.parse(modelXhr.responseText) if (!res.success) { resolve({ success: false, error: res.error }) } else { checkDone() } } catch { checkDone() } } modelXhr.onerror = () => resolve({ success: false, error: 'Erreur réseau: model' }) modelXhr.open('POST', '/api/upload') modelXhr.setRequestHeader('x-upload-secret', secret.trim()) modelXhr.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 [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('Veuillez entrer la clé d\'accès avant d\'uploader.') 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 uploadFolder( files[i], secret, (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]) const handleCancel = () => { xhrRef.current?.abort() } const removeFile = (index: number) => { const file = files[index] if (file.modelUrl) URL.revokeObjectURL(file.modelUrl) setFiles((prev) => prev.filter((_, i) => i !== index)) } const handleReset = () => { files.forEach((f) => { if (f.modelUrl) URL.revokeObjectURL(f.modelUrl) }) setFiles([]) setGlobalError(null) setIsUploading(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" />
)} multiple className="hidden" onChange={(e) => { const files = e.target.files if (files && files.length > 0) { const fileArray = Array.from(files) const folderName = fileArray[0].webkitRelativePath?.split('/')[0] || 'folder' const validation = validateFolder(fileArray) if (validation.errors.length > 0) { setGlobalError(validation.errors.join(' | ')) return } setGlobalError(null) const entry: FolderEntry = { folderName, modelFile: validation.model!, textures: validation.textures, status: 'pending', progress: 0, warnings: validation.warnings, modelUrl: URL.createObjectURL(validation.model!), viewerOpen: true, } setFiles([entry]) } }} /> {files.length === 0 && (
document.getElementById('folder-input')?.click()} className={` relative border-2 border-dashed rounded-2xl p-8 text-center cursor-pointer transition-all duration-200 bg-black-800 ${isUploading ? 'cursor-not-allowed opacity-60 border-white/20' : ''} ${!isUploading ? 'border-white/30 hover:border-white/50 hover:bg-black-700' : ''} `} >

Drop ton dossier ici ou click pour parcourir

Contient: model.glb/gltf + textures (roughness, normal, metalness, color, displace)

)} {globalError && (

{globalError}

)} {files.length > 0 && (
{files.map((entry, i) => (
{entry.status === 'success' ? (
) : entry.status === 'error' ? (
) : entry.status === 'uploading' ? (
) : ( )}
{entry.folderName}/ Folder
model: {entry.modelFile.name} {entry.status === 'error' && entry.error && ( {entry.error} )} {entry.status === 'success' && entry.filename && ( {entry.filename} )}
{entry.status === 'uploading' && (
)}
{entry.status !== 'uploading' && ( )}
{entry.warnings.length > 0 && entry.status !== 'success' && (
Textures manquantes: {entry.warnings.join(', ')}
)} {entry.modelUrl && entry.status !== 'success' && (
)}
))}
)}
{!isUploading && files.some((f) => f.status === 'pending' || f.status === 'error') && ( )} {isUploading && ( )} {(allDone || hasErrors) && !isUploading && ( )}
) }