'use client' import { useCallback, useRef, useState } from 'react' import { useDropzone, FileRejection } from 'react-dropzone' // ───────────────────────────────────────────── // Types // ───────────────────────────────────────────── type FileStatus = 'pending' | 'uploading' | 'success' | 'error' interface FileEntry { file: File status: FileStatus progress: number error?: string filename?: string } interface UploadResult { success: boolean filename?: string message?: string error?: string gitOutput?: string gitError?: string } // ───────────────────────────────────────────── // Formats acceptés // ───────────────────────────────────────────── 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 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: `Réponse inattendue (HTTP ${xhr.status})` }) } } xhr.onerror = () => resolve({ success: false, error: 'Erreur réseau.' }) xhr.onabort = () => resolve({ success: false, error: 'Annulé.' }) xhr.open('POST', '/api/upload') xhr.setRequestHeader('x-upload-secret', secret.trim()) xhr.send(formData) }) } // ───────────────────────────────────────────── // Composant principal // ───────────────────────────────────────────── 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)) } // ── Upload séquentiel de tous les fichiers ── const handleUpload = useCallback(async () => { if (!secret.trim()) { setGlobalError('Veuillez saisir 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 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]) // ── Annuler le fichier en cours ── const handleCancel = () => { xhrRef.current?.abort() } // ── Supprimer un fichier de la liste ── const removeFile = (index: number) => { setFiles((prev) => prev.filter((_, i) => i !== index)) } // ── Reset total ── const handleReset = () => { setFiles([]) setGlobalError(null) setIsUploading(false) } // ── react-dropzone ── 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') ? `Format non supporté. Utilisez : ${ALL_EXTENSIONS.join(', ')}` : codes.includes('file-too-large') ? 'Fichier trop volumineux (max 2 GB)' : `Fichier rejeté : ${codes}` ) return } setGlobalError(null) setFiles((prev) => { // Dédoublonner par nom 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 })) return [...prev, ...newEntries] }) }, []) const { getRootProps, getInputProps, isDragActive, isDragReject } = useDropzone({ onDrop, accept: ACCEPTED_FORMATS, maxSize: 2 * 1024 * 1024 * 1024, disabled: isUploading, multiple: true, }) const allDone = files.length > 0 && files.every((f) => f.status === 'success') const hasErrors = files.some((f) => f.status === 'error') // ───────────────────────────────────────────── // Render // ───────────────────────────────────────────── return (
{/* Clé d'accès */}
setSecret(e.target.value)} placeholder="Saisir la clé secrète…" disabled={isUploading} className="w-full bg-white border border-surface-border rounded-xl px-4 py-2.5 pr-12 text-gray-800 placeholder-gray-300 text-sm shadow-soft focus:outline-none focus:ring-2 focus:ring-brand-300 focus:border-brand-400 disabled:opacity-50 disabled:cursor-not-allowed transition" />
{/* Nom de l'asset */}
setAssetName(e.target.value)} placeholder="ex : clocher, sol_pierre, mur_brique…" disabled={isUploading} className="w-full bg-white border border-surface-border rounded-xl px-4 py-2.5 text-gray-800 placeholder-gray-300 text-sm font-mono shadow-soft focus:outline-none focus:ring-2 focus:ring-brand-300 focus:border-brand-400 disabled:opacity-50 disabled:cursor-not-allowed transition" />
{/* Dropzone */}
{isDragReject ? (

Format non supporté

) : isDragActive ? (

Relâchez pour ajouter

) : ( <>

Glissez vos fichiers ici ou cliquez pour parcourir

Modèles : .glb, .gltf, .fbx · Textures : .png, .jpg, .webp, .ktx2

)}
{/* Erreur globale */} {globalError && (

{globalError}

)} {/* Liste des fichiers */} {files.length > 0 && (
{files.map((entry, i) => { const type = getFileType(entry.file.name) return (
{/* Icône statut */}
{entry.status === 'success' ? (
) : entry.status === 'error' ? (
) : entry.status === 'uploading' ? (
) : (
)}
{/* Infos fichier */}
{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} )}
{/* Barre de progression individuelle */} {entry.status === 'uploading' && (
)}
{/* Supprimer */} {entry.status !== 'uploading' && ( )}
) })}
)} {/* Boutons d'action */}
{!isUploading && files.some((f) => f.status === 'pending' || f.status === 'error') && ( )} {isUploading && ( )} {(allDone || hasErrors) && !isUploading && ( )}
) }