From f9e15d5e1f0a56658f8eb7d7ae9663f3dc0af67b Mon Sep 17 00:00:00 2001 From: Tom Boullay Date: Tue, 14 Apr 2026 14:27:50 +0200 Subject: [PATCH] fin du refactp --- .dockerignore | 6 + app/globals.css | 5 +- app/layout.tsx | 17 +- components/UploadZone.tsx | 607 +++++--------------- components/upload/ActionButtons.tsx | 62 ++ components/upload/DestinationPicker.tsx | 38 ++ components/upload/FolderCard.tsx | 118 ++++ components/upload/FolderDropzone.tsx | 87 +++ components/upload/OverwriteConfirmModal.tsx | 97 ++++ components/upload/SecretInput.tsx | 61 ++ components/upload/WarningBanner.tsx | 22 + hooks/useFolderEntries.ts | 42 ++ hooks/useSecret.ts | 36 ++ lib/client-types.ts | 23 + lib/format-bytes.ts | 11 + lib/validate-folder.ts | 63 ++ package.json | 2 +- tailwind.config.ts | 5 +- 18 files changed, 826 insertions(+), 476 deletions(-) create mode 100644 .dockerignore create mode 100644 components/upload/ActionButtons.tsx create mode 100644 components/upload/DestinationPicker.tsx create mode 100644 components/upload/FolderCard.tsx create mode 100644 components/upload/FolderDropzone.tsx create mode 100644 components/upload/OverwriteConfirmModal.tsx create mode 100644 components/upload/SecretInput.tsx create mode 100644 components/upload/WarningBanner.tsx create mode 100644 hooks/useFolderEntries.ts create mode 100644 hooks/useSecret.ts create mode 100644 lib/client-types.ts create mode 100644 lib/format-bytes.ts create mode 100644 lib/validate-folder.ts diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..f3c1c62 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,6 @@ +node_modules +.next +.git +*.md +.env* +.DS_Store diff --git a/app/globals.css b/app/globals.css index 688a985..1069260 100644 --- a/app/globals.css +++ b/app/globals.css @@ -1,13 +1,10 @@ -@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap'); -@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500&display=swap'); - @tailwind base; @tailwind components; @tailwind utilities; @layer base { html { - font-family: 'Inter', system-ui, sans-serif; + font-family: var(--font-inter), system-ui, sans-serif; } body { diff --git a/app/layout.tsx b/app/layout.tsx index 1a9aa47..dfd8302 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,8 +1,21 @@ import type { Metadata } from 'next' +import { Inter, JetBrains_Mono } from 'next/font/google' import './globals.css' +const inter = Inter({ + subsets: ['latin'], + variable: '--font-inter', + display: 'swap', +}) + +const jetbrainsMono = JetBrains_Mono({ + subsets: ['latin'], + variable: '--font-jetbrains-mono', + display: 'swap', +}) + export const metadata: Metadata = { - title: ' Upload GLTF', + title: 'Upload GLTF', description: 'Interface de depot securise pour fichiers 3D (.glb, .gltf) avec versionnement automatique sur GitHub', } @@ -12,7 +25,7 @@ export default function RootLayout({ children: React.ReactNode }) { return ( - + {children} ) diff --git a/components/UploadZone.tsx b/components/UploadZone.tsx index 98fe6f0..3796fb7 100644 --- a/components/UploadZone.tsx +++ b/components/UploadZone.tsx @@ -1,94 +1,23 @@ 'use client' -import { useState } from 'react' -import dynamic from 'next/dynamic' +import { useState, useRef } from 'react' +import type { Destination } from '@/lib/constants' +import { DESTINATIONS } from '@/lib/constants' +import type { FolderEntry } from '@/lib/client-types' +import type { FileDiff } from '@/lib/types' +import { useSecret } from '@/hooks/useSecret' +import { useFolderEntries } from '@/hooks/useFolderEntries' +import SecretInput from './upload/SecretInput' +import DestinationPicker from './upload/DestinationPicker' +import FolderDropzone from './upload/FolderDropzone' +import FolderCard from './upload/FolderCard' +import ActionButtons from './upload/ActionButtons' +import OverwriteConfirmModal from './upload/OverwriteConfirmModal' -const ModelViewer = dynamic(() => import('./ModelViewer'), { ssr: false }) +// --------------------------------------------------------------------------- +// Client-side SHA computation (same as `git hash-object`) +// --------------------------------------------------------------------------- -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'] - -const DESTINATIONS = [ - { value: 'farm', label: 'Farm' }, - { value: 'map', label: 'Map' }, - { value: 'powergrid', label: 'Powergrid' }, - { value: 'workshop', label: 'Workshop' }, - { value: 'general', label: 'General' }, - { value: 'environment', label: 'Environment' }, -] as const - -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 -} - -/** Compute the git blob SHA1 for a file (same as `git hash-object`) */ async function computeGitBlobSha(file: File): Promise { const buffer = await file.arrayBuffer() const content = new Uint8Array(buffer) @@ -98,13 +27,12 @@ async function computeGitBlobSha(file: File): Promise { store.set(content, header.length) const hashBuffer = await crypto.subtle.digest('SHA-1', store) const hashArray = Array.from(new Uint8Array(hashBuffer)) - return hashArray.map(b => b.toString(16).padStart(2, '0')).join('') + return hashArray.map((b) => b.toString(16).padStart(2, '0')).join('') } -interface FileDiff { - name: string - status: 'changed' | 'new' | 'deleted' -} +// --------------------------------------------------------------------------- +// API helpers +// --------------------------------------------------------------------------- interface CheckResult { exists: boolean @@ -115,11 +43,13 @@ async function checkFolderDiffs( folder: FolderEntry, destination: string, secret: string, + signal?: AbortSignal, ): Promise { try { const params = new URLSearchParams({ folderName: folder.folderName, destination }) const res = await fetch(`/api/upload/check?${params}`, { headers: { 'x-upload-secret': secret.trim() }, + signal, }) const data = await res.json() if (!data.success || !data.exists) { @@ -127,9 +57,8 @@ async function checkFolderDiffs( } const remoteFiles: { name: string; sha: string }[] = data.files || [] - const remoteMap = new Map(remoteFiles.map(f => [f.name.toLowerCase(), f.sha])) + const remoteMap = new Map(remoteFiles.map((f) => [f.name.toLowerCase(), f.sha])) - // Compute SHA for all local files const localFiles: { name: string; sha: string }[] = [] localFiles.push({ name: folder.modelFile.name, @@ -154,10 +83,8 @@ async function checkFolderDiffs( } else if (remoteSha !== local.sha) { diffs.push({ name: local.name, status: 'changed' }) } - // unchanged → not in diffs } - // Files on remote but not in local → deleted for (const [name] of remoteMap) { if (!localNames.has(name)) { diffs.push({ name, status: 'deleted' }) @@ -174,18 +101,17 @@ async function uploadFolder( folder: FolderEntry, secret: string, destination: string, - onProgress: (pct: number) => void + onProgress: (pct: number) => void, + signal?: AbortSignal, ): Promise<{ success: boolean; filename?: string; error?: string }> { const formData = new FormData() formData.append('folderName', folder.folderName) formData.append('destination', destination) - // Model file formData.append('files', folder.modelFile) formData.append('fileTypes', 'model') formData.append('textureNames', '') - // Texture files for (const tex of folder.textures) { formData.append('files', tex.file) formData.append('fileTypes', 'texture') @@ -199,10 +125,10 @@ async function uploadFolder( method: 'POST', headers: { 'x-upload-secret': secret.trim() }, body: formData, + signal, }) onProgress(80) - const data = await res.json() if (!data.success) { @@ -211,44 +137,76 @@ async function uploadFolder( onProgress(100) return { success: true, filename: folder.folderName } - } catch { + } catch (err) { + if (err instanceof DOMException && err.name === 'AbortError') { + return { success: false, error: 'Upload annule' } + } return { success: false, error: 'Erreur reseau' } } } +// --------------------------------------------------------------------------- +// UploadZone — orchestrator +// --------------------------------------------------------------------------- + export default function UploadZone() { - const [files, setFiles] = useState([]) + const { + secret, + secretError, + secretVisible, + isSecretEmpty, + setSecretError, + handleSecretChange, + toggleSecretVisible, + } = useSecret() + + const { + entries, + setEntries, + updateEntry, + removeEntry, + resetEntries, + allDone, + hasErrors, + } = useFolderEntries() + const [isUploading, setIsUploading] = useState(false) - const [secret, setSecret] = useState('') - const [secretVisible, setSecretVisible] = useState(false) const [globalError, setGlobalError] = useState(null) - const [secretError, setSecretError] = useState(null) - const [destination, setDestination] = useState(DESTINATIONS[0].value) - const [abortController, setAbortController] = useState(null) - const [overwriteConfirm, setOverwriteConfirm] = useState<{ folderName: string; diffs: FileDiff[] } | null>(null) + const [destination, setDestination] = useState(DESTINATIONS[0].value) + const [overwriteConfirm, setOverwriteConfirm] = useState<{ + folderName: string + diffs: FileDiff[] + } | null>(null) + const abortRef = useRef(null) - const isSecretEmpty = !secret.trim() + // -- Handlers -- - const updateFile = (index: number, patch: Partial) => { - setFiles((prev) => prev.map((f, i) => i === index ? { ...f, ...patch } : f)) + const handleFolderSelected = (entry: FolderEntry) => { + setGlobalError(null) + setEntries([entry]) + } + + const handleToggleViewer = (index: number) => { + const entry = entries[index] + if (entry?.modelUrl) { + updateEntry(index, { viewerOpen: !entry.viewerOpen }) + } } const handleUpload = async () => { if (!secret.trim()) { - setSecretError('La cle d\'acces est requise') + setSecretError("La cle d'acces est requise") return } - if (files.length === 0) return + if (entries.length === 0) return setSecretError(null) setGlobalError(null) - // Check if folder already exists on remote and compute diffs - const folder = files[0] - const check = await checkFolderDiffs(folder, destination, secret) + const folder = entries[0] + const check = await checkFolderDiffs(folder, destination, secret, abortRef.current?.signal) if (check.exists) { if (check.diffs.length === 0) { - // Nothing changed at all setGlobalError('Aucun fichier modifie — le dossier distant est identique.') return } @@ -264,19 +222,24 @@ export default function UploadZone() { setIsUploading(true) setGlobalError(null) - for (let i = 0; i < files.length; i++) { - if (files[i].status === 'success') continue + const controller = new AbortController() + abortRef.current = controller - updateFile(i, { status: 'uploading', progress: 0, error: undefined }) + for (let i = 0; i < entries.length; i++) { + if (entries[i].status === 'success') continue + if (controller.signal.aborted) break + + updateEntry(i, { status: 'uploading', progress: 0, error: undefined }) const result = await uploadFolder( - files[i], + entries[i], secret, destination, - (pct) => updateFile(i, { progress: pct }) + (pct) => updateEntry(i, { progress: pct }), + controller.signal, ) - updateFile(i, { + updateEntry(i, { status: result.success ? 'success' : 'error', progress: result.success ? 100 : 0, error: result.success ? undefined : result.error, @@ -284,377 +247,89 @@ export default function UploadZone() { }) } + abortRef.current = null setIsUploading(false) } - const handleCancel = () => { abortController?.abort() } - - const removeFile = (index: number) => { - const file = files[index] - if (file.modelUrl) URL.revokeObjectURL(file.modelUrl) - setFiles((prev) => prev.filter((_, i) => i !== index)) + const handleCancel = () => { + abortRef.current?.abort() + abortRef.current = null + setIsUploading(false) } const handleReset = () => { - files.forEach((f) => { - if (f.modelUrl) URL.revokeObjectURL(f.modelUrl) - }) - setFiles([]) + resetEntries() setGlobalError(null) setIsUploading(false) } - const allDone = files.length > 0 && files.every((f) => f.status === 'success') - const hasErrors = files.some((f) => f.status === 'error') + const hasPendingOrErrors = entries.some((f) => f.status === 'pending' || f.status === 'error') + + // -- Render -- return (
-
- -
- { - setSecret(e.target.value) - if (secretError) setSecretError(null) - }} - placeholder="Entrez la cle secrete..." - disabled={isUploading} - className={`w-full bg-black-800 border rounded-xl px-4 py-2.5 pr-12 - text-gray-100 placeholder-gray-500 text-sm - focus:outline-none focus:ring-2 focus:border-white/50 - disabled:opacity-50 disabled:cursor-not-allowed transition - ${secretError - ? 'border-red-500/70 focus:ring-red-500/50' - : 'border-white/30 focus:ring-white/50' - }`} - /> - -
- {secretError && ( -

{secretError}

- )} -
- -
- -
- {DESTINATIONS.map((dest) => ( - - ))} -
-
- - )} - 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' : ''} - `} - > -
-
- - - -
-
-

- Deposez votre dossier ici - ou cliquez pour parcourir -

-

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

-
+ + + {entries.length === 0 && ( + )} {globalError && (

{globalError}

)} - {files.length > 0 && ( + {entries.length > 0 && (
- {files.map((entry, i) => ( -
-
-
- {entry.status === 'success' ? ( -
- - - -
- ) : entry.status === 'error' ? ( -
- - - -
- ) : entry.status === 'uploading' ? ( -
- - - - -
- ) : ( - - )} -
- -
-
- {entry.folderName}/ - Dossier -
-
- modele : {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' && ( -
- -
- )} -
+ {entries.map((entry, i) => ( + ))}
)} -
- {!isUploading && files.some((f) => f.status === 'pending' || f.status === 'error') && ( - - )} - - {isUploading && ( - - )} - - {(allDone || hasErrors) && !isUploading && ( - - )} -
+ {overwriteConfirm && ( -
-
-
-
- - - -
-
-

Dossier deja existant

-

- {destination}/{overwriteConfirm.folderName} existe deja sur le repo -

-
-
- - {overwriteConfirm.diffs.length > 0 && ( -
-

Modifications detectees :

-
    - {overwriteConfirm.diffs.map((d) => ( -
  • - {d.status === 'changed' && ( - <> - 🔄 - {d.name} - - )} - {d.status === 'new' && ( - <> - - {d.name} - - )} - {d.status === 'deleted' && ( - <> - - {d.name} - - )} -
  • - ))} -
-
- )} - -
- - -
-
-
+ setOverwriteConfirm(null)} + onConfirm={proceedUpload} + /> )}
) -} \ No newline at end of file +} diff --git a/components/upload/ActionButtons.tsx b/components/upload/ActionButtons.tsx new file mode 100644 index 0000000..da2e884 --- /dev/null +++ b/components/upload/ActionButtons.tsx @@ -0,0 +1,62 @@ +interface ActionButtonsProps { + isUploading: boolean + isSecretEmpty: boolean + hasPendingOrErrors: boolean + allDone: boolean + hasErrors: boolean + onUpload: () => void + onCancel: () => void + onReset: () => void +} + +export default function ActionButtons({ + isUploading, + isSecretEmpty, + hasPendingOrErrors, + allDone, + hasErrors, + onUpload, + onCancel, + onReset, +}: ActionButtonsProps) { + return ( +
+ {!isUploading && hasPendingOrErrors && ( + + )} + + {isUploading && ( + + )} + + {(allDone || hasErrors) && !isUploading && ( + + )} +
+ ) +} diff --git a/components/upload/DestinationPicker.tsx b/components/upload/DestinationPicker.tsx new file mode 100644 index 0000000..10317ea --- /dev/null +++ b/components/upload/DestinationPicker.tsx @@ -0,0 +1,38 @@ +import { DESTINATIONS } from '@/lib/constants' +import type { Destination } from '@/lib/constants' + +interface DestinationPickerProps { + destination: Destination + disabled: boolean + onChange: (value: Destination) => void +} + +export default function DestinationPicker({ + destination, + disabled, + onChange, +}: DestinationPickerProps) { + return ( +
+ +
+ {DESTINATIONS.map((dest) => ( + + ))} +
+
+ ) +} diff --git a/components/upload/FolderCard.tsx b/components/upload/FolderCard.tsx new file mode 100644 index 0000000..9daf032 --- /dev/null +++ b/components/upload/FolderCard.tsx @@ -0,0 +1,118 @@ +import dynamic from 'next/dynamic' +import type { FolderEntry } from '@/lib/client-types' +import { formatBytes } from '@/lib/format-bytes' +import WarningBanner from './WarningBanner' + +const ModelViewer = dynamic(() => import('../ModelViewer'), { ssr: false }) + +interface FolderCardProps { + entry: FolderEntry + index: number + onToggleViewer: (index: number) => void + onRemove: (index: number) => void +} + +export default function FolderCard({ entry, index, onToggleViewer, onRemove }: FolderCardProps) { + return ( +
+
+ {/* Status icon */} +
+ {entry.status === 'success' ? ( +
+ + + +
+ ) : entry.status === 'error' ? ( +
+ + + +
+ ) : entry.status === 'uploading' ? ( +
+ + + + +
+ ) : ( + + )} +
+ + {/* Info */} +
+
+ {entry.folderName}/ + Dossier +
+
+ modele : {entry.modelFile.name} + {entry.status === 'error' && entry.error && ( + {entry.error} + )} + {entry.status === 'success' && entry.filename && ( + {entry.filename} + )} +
+ {entry.status === 'uploading' && ( +
+
+
+ )} +
+ + {/* Remove button */} + {entry.status !== 'uploading' && ( + + )} +
+ + {/* Warning banner */} + {entry.status !== 'success' && ( + + )} + + {/* 3D preview */} + {entry.modelUrl && entry.status !== 'success' && ( +
+ +
+ )} +
+ ) +} diff --git a/components/upload/FolderDropzone.tsx b/components/upload/FolderDropzone.tsx new file mode 100644 index 0000000..6b9addc --- /dev/null +++ b/components/upload/FolderDropzone.tsx @@ -0,0 +1,87 @@ +import type { FolderEntry } from '@/lib/client-types' +import { validateFolder } from '@/lib/validate-folder' + +interface FolderDropzoneProps { + isUploading: boolean + onFolderSelected: (entry: FolderEntry) => void + onError: (message: string) => void +} + +export default function FolderDropzone({ + isUploading, + onFolderSelected, + onError, +}: FolderDropzoneProps) { + const handleChange = (e: React.ChangeEvent) => { + const selected = e.target.files + if (!selected || selected.length === 0) return + + const fileArray = Array.from(selected) + const folderName = fileArray[0].webkitRelativePath?.split('/')[0] || 'folder' + const validation = validateFolder(fileArray) + + if (validation.errors.length > 0) { + onError(validation.errors.join(' | ')) + return + } + + const entry: FolderEntry = { + folderName, + modelFile: validation.model!, + textures: validation.textures, + status: 'pending', + progress: 0, + warnings: validation.warnings, + modelUrl: URL.createObjectURL(validation.model!), + viewerOpen: true, + } + onFolderSelected(entry) + } + + return ( + <> + )} + multiple + className="hidden" + onChange={handleChange} + /> + +
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' : ''} + `} + > +
+
+ + + +
+
+

+ Deposez votre dossier ici + ou cliquez pour parcourir +

+

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

+
+ + ) +} diff --git a/components/upload/OverwriteConfirmModal.tsx b/components/upload/OverwriteConfirmModal.tsx new file mode 100644 index 0000000..7695eeb --- /dev/null +++ b/components/upload/OverwriteConfirmModal.tsx @@ -0,0 +1,97 @@ +import type { FileDiff } from '@/lib/types' + +interface OverwriteConfirmModalProps { + destination: string + folderName: string + diffs: FileDiff[] + onCancel: () => void + onConfirm: () => void +} + +export default function OverwriteConfirmModal({ + destination, + folderName, + diffs, + onCancel, + onConfirm, +}: OverwriteConfirmModalProps) { + return ( +
+
+
+
+ + + +
+
+

+ Dossier deja existant +

+

+ {destination}/{folderName} existe deja sur le repo +

+
+
+ + {diffs.length > 0 && ( +
+

Modifications detectees :

+
    + {diffs.map((d) => ( +
  • + {d.status === 'changed' && ( + <> + 🔄 + {d.name} + + )} + {d.status === 'new' && ( + <> + + {d.name} + + )} + {d.status === 'deleted' && ( + <> + + {d.name} + + )} +
  • + ))} +
+
+ )} + +
+ + +
+
+
+ ) +} diff --git a/components/upload/SecretInput.tsx b/components/upload/SecretInput.tsx new file mode 100644 index 0000000..8216de6 --- /dev/null +++ b/components/upload/SecretInput.tsx @@ -0,0 +1,61 @@ +interface SecretInputProps { + secret: string + secretVisible: boolean + secretError: string | null + disabled: boolean + onChange: (value: string) => void + onToggleVisible: () => void +} + +export default function SecretInput({ + secret, + secretVisible, + secretError, + disabled, + onChange, + onToggleVisible, +}: SecretInputProps) { + return ( +
+ +
+ onChange(e.target.value)} + placeholder="Entrez la cle secrete..." + disabled={disabled} + className={`w-full bg-black-800 border rounded-xl px-4 py-2.5 pr-12 + text-gray-100 placeholder-gray-500 text-sm + focus:outline-none focus:ring-2 focus:border-white/50 + disabled:opacity-50 disabled:cursor-not-allowed transition + ${secretError + ? 'border-red-500/70 focus:ring-red-500/50' + : 'border-white/30 focus:ring-white/50' + }`} + /> + +
+ {secretError && ( +

{secretError}

+ )} +
+ ) +} diff --git a/components/upload/WarningBanner.tsx b/components/upload/WarningBanner.tsx new file mode 100644 index 0000000..22d06aa --- /dev/null +++ b/components/upload/WarningBanner.tsx @@ -0,0 +1,22 @@ +interface WarningBannerProps { + warnings: string[] +} + +export default function WarningBanner({ warnings }: WarningBannerProps) { + if (warnings.length === 0) return null + + return ( +
+
+ + + + Textures manquantes : {warnings.join(', ')} +
+
+ ) +} diff --git a/hooks/useFolderEntries.ts b/hooks/useFolderEntries.ts new file mode 100644 index 0000000..ddd89e9 --- /dev/null +++ b/hooks/useFolderEntries.ts @@ -0,0 +1,42 @@ +'use client' + +import { useState, useCallback } from 'react' +import type { FolderEntry } from '@/lib/client-types' + +export function useFolderEntries() { + const [entries, setEntries] = useState([]) + + const updateEntry = useCallback((index: number, patch: Partial) => { + setEntries((prev) => prev.map((f, i) => (i === index ? { ...f, ...patch } : f))) + }, []) + + const removeEntry = useCallback((index: number) => { + setEntries((prev) => { + const entry = prev[index] + if (entry?.modelUrl) URL.revokeObjectURL(entry.modelUrl) + return prev.filter((_, i) => i !== index) + }) + }, []) + + const resetEntries = useCallback(() => { + setEntries((prev) => { + prev.forEach((f) => { + if (f.modelUrl) URL.revokeObjectURL(f.modelUrl) + }) + return [] + }) + }, []) + + const allDone = entries.length > 0 && entries.every((f) => f.status === 'success') + const hasErrors = entries.some((f) => f.status === 'error') + + return { + entries, + setEntries, + updateEntry, + removeEntry, + resetEntries, + allDone, + hasErrors, + } +} diff --git a/hooks/useSecret.ts b/hooks/useSecret.ts new file mode 100644 index 0000000..e62d1af --- /dev/null +++ b/hooks/useSecret.ts @@ -0,0 +1,36 @@ +'use client' + +import { useState, useCallback } from 'react' + +export function useSecret() { + const [secret, setSecret] = useState('') + const [secretError, setSecretError] = useState(null) + const [secretVisible, setSecretVisible] = useState(false) + + const isSecretEmpty = !secret.trim() + + const handleSecretChange = useCallback((value: string) => { + setSecret(value) + if (secretError) setSecretError(null) + }, [secretError]) + + const toggleSecretVisible = useCallback(() => { + setSecretVisible((v) => !v) + }, []) + + const clearSecretError = useCallback(() => { + setSecretError(null) + }, []) + + return { + secret, + secretError, + secretVisible, + isSecretEmpty, + setSecret, + setSecretError, + handleSecretChange, + toggleSecretVisible, + clearSecretError, + } +} diff --git a/lib/client-types.ts b/lib/client-types.ts new file mode 100644 index 0000000..dbc6c48 --- /dev/null +++ b/lib/client-types.ts @@ -0,0 +1,23 @@ +// --------------------------------------------------------------------------- +// Client-side types — used by components and hooks (no Node.js Buffer) +// --------------------------------------------------------------------------- + +export type FileStatus = 'pending' | 'uploading' | 'success' | 'error' + +export interface TextureFile { + name: string + file: File +} + +export interface FolderEntry { + folderName: string + modelFile: File + textures: TextureFile[] + status: FileStatus + progress: number + error?: string + filename?: string + modelUrl?: string + viewerOpen?: boolean + warnings: string[] +} diff --git a/lib/format-bytes.ts b/lib/format-bytes.ts new file mode 100644 index 0000000..86cb07c --- /dev/null +++ b/lib/format-bytes.ts @@ -0,0 +1,11 @@ +// --------------------------------------------------------------------------- +// Format bytes to human-readable string +// --------------------------------------------------------------------------- + +export 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] +} diff --git a/lib/validate-folder.ts b/lib/validate-folder.ts new file mode 100644 index 0000000..49145be --- /dev/null +++ b/lib/validate-folder.ts @@ -0,0 +1,63 @@ +// --------------------------------------------------------------------------- +// Client-side folder validation +// --------------------------------------------------------------------------- + +import { REQUIRED_TEXTURES, TEXTURE_EXTENSIONS } from '@/lib/constants' +import type { TextureFile } from '@/lib/client-types' + +const TEXTURE_EXT_ARRAY = [...TEXTURE_EXTENSIONS] + +function getTextureType(filename: string): string | null { + const name = filename.toLowerCase().replace(/\.[^.]+$/, '') + if ((REQUIRED_TEXTURES as readonly string[]).includes(name)) return name + return null +} + +export 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_EXT_ARRAY.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 +} diff --git a/package.json b/package.json index 83dcdf7..5b5abc9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "upload-gltf", - "version": "0.0.2", + "version": "0.1.5", "private": true, "scripts": { "dev": "next dev", diff --git a/tailwind.config.ts b/tailwind.config.ts index 3267f37..7aa2883 100644 --- a/tailwind.config.ts +++ b/tailwind.config.ts @@ -2,7 +2,6 @@ import type { Config } from 'tailwindcss' const config: Config = { content: [ - './pages/**/*.{js,ts,jsx,tsx,mdx}', './components/**/*.{js,ts,jsx,tsx,mdx}', './app/**/*.{js,ts,jsx,tsx,mdx}', ], @@ -35,8 +34,8 @@ const config: Config = { }, }, fontFamily: { - sans: ['Inter', 'system-ui', 'sans-serif'], - mono: ['JetBrains Mono', 'Fira Code', 'monospace'], + sans: ['var(--font-inter)', 'system-ui', 'sans-serif'], + mono: ['var(--font-jetbrains-mono)', 'Fira Code', 'monospace'], }, boxShadow: { soft: '0 2px 16px 0 rgba(0, 0, 0, 0.06)',