refactor: simplify upload rules and remove destination flow

This commit is contained in:
Tom Boullay
2026-04-24 16:23:02 +02:00
parent 61a0146545
commit 944959fc22
20 changed files with 2033 additions and 217 deletions
+14 -26
View File
@@ -1,6 +1,6 @@
# upload-GLTF
A secure web interface for uploading 3D assets (GLTF/GLB + textures) to two destinations:
A secure web interface for uploading 3D assets (GLTF/GLB + textures) with two outputs:
- **Nextcloud Drive** — Archives the original files with automatic versioning (VF/V1/V2...), so artists always have a history of past versions.
- **GitHub** — Delivers compressed models (Draco via Blender) to the dev team's repository, ready for integration.
@@ -87,10 +87,9 @@ The Docker image includes Blender headless (installed once at build time). On st
## How it works
1. The user enters their access key
2. They pick a **destination** (`farm`, `map`, `powergrid`, `workshop`, `general`, `environment`)
3. They select a folder containing:
- `model.glb` or `model.gltf` (**required**)
- Textures: `roughness`, `normal`, `metalness`, `color`, `displace` (`.png/.jpg/.webp`, **optional** — missing textures show a warning but don't block the upload)
2. They select a folder containing:
- `model.glb` (**required**)
- Any associated textures (`.png/.jpg/.jpeg/.webp`)
4. The model is displayed in a 3D preview
5. On clicking "Envoyer":
- The app checks the remote Git repo for existing files and computes diffs (textures by size, models always re-pushed)
@@ -111,7 +110,7 @@ The Drive uses a `VF` (version finale) / `Vx` (archived versions) structure:
Models/
VF/ ← latest version
coffeetest/
model.gltf
model.glb
color.jpg
V1/ ← first archive
coffeetest/
@@ -131,42 +130,32 @@ All changes are pushed in a **single commit** with a formatted message:
**New folder:**
```
update: upload-gltf add a new model -> farm/my-model
update: upload-gltf add a new model -> my-model
📦 Model
✅ model.gltf (compressed)
✅ model.glb (compressed)
🎨 Textures
✅ color.jpg
❌ metalness (manquant)
```
**Update (only metalness changed):**
**Update (only one texture changed):**
```
update: upload-gltf update -> general/coffeetest
update: upload-gltf update -> coffeetest
📦 Model
↔️ model.gltf (inchange)
↔️ model.glb (inchange)
🎨 Textures
🔄 metalness.jpg
🔄 color_tuyaux.jpg
```
Symbols: `✅` new — `🔄` modified — `↔️` unchanged (model always re-pushed) — `❌` missing or deleted
Symbols: `✅` new — `🔄` modified — `↔️` unchanged (model always re-pushed) — `❌` deleted
8. Orphan files (present on remote but not in the new upload) are deleted in the same commit
9. If Blender is unavailable, the original model is pushed as-is (graceful fallback)
## Destinations
Uploaded models are pushed to `public/models/<destination>/<folderName>/` in the target repo:
| Destination | Path |
|-------------|------|
| Farm | `public/models/farm/` |
| Map | `public/models/map/` |
| Powergrid | `public/models/powergrid/` |
| Workshop | `public/models/workshop/` |
| General | `public/models/general/` |
| Environment | `public/models/environment/` |
Uploaded models are pushed to `public/models/<folderName>/` in the target repo.
## Project Structure
@@ -185,7 +174,6 @@ components/
│ └── Modal.tsx # Shared modal wrapper + ModalActions
├── upload/
│ ├── SecretInput.tsx # Access key input
│ ├── DestinationPicker.tsx # Destination selector
│ ├── FolderDropzone.tsx # Folder drag & drop / picker
│ ├── FolderCard.tsx # Folder status card (Drive + Git)
│ ├── DriveStatusLine.tsx # Drive/Git status sub-line
@@ -202,7 +190,7 @@ hooks/
├── useFolderEntries.ts # Folder entries state management
└── useUploadOrchestrator.ts # Upload pipeline orchestration (Drive → Git)
lib/
├── constants.ts # Shared constants, destinations, extensions
├── constants.ts # Shared constants and extensions
├── types.ts # Server types (ParsedFile, FileDiff, etc.)
├── client-types.ts # Client types (FolderEntry, DriveStatus, etc.)
├── upload-api.ts # Client-side API helpers (check, uploadDrive, uploadGit)
+2 -4
View File
@@ -17,23 +17,21 @@ export async function POST(req: NextRequest) {
if (authError) return authError
let folderName: string
let destination: string
let parsedFiles: Awaited<ReturnType<typeof parseMultiUpload>>['files']
try {
const parsed = await parseMultiUpload(req)
folderName = parsed.folderName
destination = parsed.destination
parsedFiles = parsed.files
} catch (err) {
const message = err instanceof Error ? err.message : 'Erreur inconnue'
return NextResponse.json({ success: false, error: message }, { status: 400 })
}
const folderPath = `public/models/${destination}/${folderName}`
const folderPath = `public/models/${folderName}`
try {
const { filesToPush } = await prepareGitAssets({ folderName, destination, parsedFiles })
const { filesToPush } = await prepareGitAssets({ folderName, parsedFiles })
const { exists, files } = await getRemoteFolder(folderPath)
if (exists) {
+1 -1
View File
@@ -17,7 +17,7 @@ export const dynamic = 'force-dynamic'
// Upload **original** files (no Blender compression) to Nextcloud Drive.
//
// FormData fields:
// - folderName, destination, files[], fileTypes[], textureNames[] (same as /api/upload/git)
// - folderName, files[], fileTypes[], textureNames[] (same as /api/upload/git)
// - action: "new" | "replace"
//
// Versioning logic:
+3 -5
View File
@@ -20,13 +20,11 @@ export async function POST(req: NextRequest) {
// --- Parse all files ---
let folderName: string
let destination: string
let parsedFiles: Awaited<ReturnType<typeof parseMultiUpload>>['files']
try {
const parsed = await parseMultiUpload(req)
folderName = parsed.folderName
destination = parsed.destination
parsedFiles = parsed.files
} catch (err) {
const message = err instanceof Error ? err.message : 'Erreur inconnue'
@@ -40,10 +38,10 @@ export async function POST(req: NextRequest) {
compressed,
compressionError,
textureNames,
} = await prepareGitAssets({ folderName, destination, parsedFiles })
} = await prepareGitAssets({ folderName, parsedFiles })
// --- Detect existing files and classify changes ---
const folderPath = `public/models/${destination}/${folderName}`
const folderPath = `public/models/${folderName}`
let remoteFileMap: Map<string, number>
try {
@@ -72,7 +70,7 @@ export async function POST(req: NextRequest) {
// --- Build commit message ---
const commitMessage = buildCommitMessage(
folderName, destination, modelFilename, textureNames,
folderName, modelFilename, textureNames,
compressed, isReplace, fileChanges, deletedFileNames,
)
+16 -12
View File
@@ -5,7 +5,6 @@ import { useSecret } from '@/hooks/useSecret'
import { useFolderEntries } from '@/hooks/useFolderEntries'
import { useUploadOrchestrator } from '@/hooks/useUploadOrchestrator'
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'
@@ -38,8 +37,6 @@ export default function UploadZone() {
isUploading,
globalError,
setGlobalError,
destination,
setDestination,
overwriteConfirm,
setOverwriteConfirm,
noChangesFolder,
@@ -84,18 +81,28 @@ export default function UploadZone() {
onToggleVisible={toggleSecretVisible}
/>
<DestinationPicker
destination={destination}
disabled={isUploading}
onChange={setDestination}
/>
{entries.length === 0 && (
<div className="space-y-2">
<p className="text-xs text-gray-400 leading-relaxed text-center">
Deposez un dossier complet contenant votre modele 3D nomme
{' '}<span className="font-mono text-gray-200">model.glb</span>
{' '}ainsi que toutes les textures necessaires.
{' '}Les textures peuvent etre en
{' '}<span className="font-mono text-gray-200">.png</span>,
{' '}<span className="font-mono text-gray-200">.jpg</span>
{' '}ou <span className="font-mono text-gray-200">.webp</span>.
{' '}Utilisez un nom simple si la texture s&apos;applique au modele entier, et un nom detaille si elle correspond a une partie precise du modele,
{' '}par exemple <span className="font-mono text-gray-200">color_fenetre.jpg</span>,
{' '}<span className="font-mono text-gray-200">roughness_tuyaux.png</span>,
{' '}<span className="font-mono text-gray-200">normal_dashboard.webp</span>
{' '}ou <span className="font-mono text-gray-200">opacity_verre.png</span>.
</p>
<FolderDropzone
isUploading={isUploading}
onFolderSelected={handleFolderSelected}
onError={setGlobalError}
/>
</div>
)}
{globalError && (
@@ -119,7 +126,6 @@ export default function UploadZone() {
<ActionButtons
isUploading={isUploading}
isSecretEmpty={isSecretEmpty}
noDestination={!destination}
hasPendingOrErrors={hasPendingOrErrors}
allDone={allDone}
hasErrors={hasErrors}
@@ -130,7 +136,6 @@ export default function UploadZone() {
{overwriteConfirm && (
<OverwriteConfirmModal
destination={destination!}
folderName={overwriteConfirm.folderName}
diffs={overwriteConfirm.diffs}
onCancel={() => setOverwriteConfirm(null)}
@@ -140,7 +145,6 @@ export default function UploadZone() {
{noChangesFolder && (
<NoChangesModal
destination={destination!}
folderName={noChangesFolder}
onCancel={() => {
setNoChangesFolder(null)
+1 -3
View File
@@ -1,7 +1,6 @@
interface ActionButtonsProps {
isUploading: boolean
isSecretEmpty: boolean
noDestination: boolean
hasPendingOrErrors: boolean
allDone: boolean
hasErrors: boolean
@@ -13,7 +12,6 @@ interface ActionButtonsProps {
export default function ActionButtons({
isUploading,
isSecretEmpty,
noDestination,
hasPendingOrErrors,
allDone,
hasErrors,
@@ -21,7 +19,7 @@ export default function ActionButtons({
onCancel,
onReset,
}: ActionButtonsProps) {
const cantUpload = isSecretEmpty || noDestination
const cantUpload = isSecretEmpty
return (
<div className="flex gap-3">
-38
View File
@@ -1,38 +0,0 @@
import { DESTINATIONS } from '@/lib/constants'
import type { Destination } from '@/lib/constants'
interface DestinationPickerProps {
destination: Destination | null
disabled: boolean
onChange: (value: Destination) => void
}
export default function DestinationPicker({
destination,
disabled,
onChange,
}: DestinationPickerProps) {
return (
<div className="space-y-1.5">
<label className="block text-sm font-medium text-gray-300">Destination</label>
<div className="grid grid-cols-3 gap-2">
{DESTINATIONS.map((dest) => (
<button
key={dest.value}
type="button"
onClick={() => onChange(dest.value)}
disabled={disabled}
className={`px-3 py-2 rounded-xl text-sm font-medium transition-all duration-150 border
disabled:opacity-50 disabled:cursor-not-allowed
${destination === dest.value
? 'bg-white text-[#000000] border-white'
: 'bg-black-800 text-gray-400 border-white/20 hover:border-white/40 hover:text-gray-200'
}`}
>
{dest.label}
</button>
))}
</div>
</div>
)
}
-4
View File
@@ -170,10 +170,6 @@ export default function FolderDropzone({
Deposez votre dossier ici
<span className="text-gray-500 font-normal"> ou cliquez pour parcourir</span>
</p>
<p className="text-xs text-gray-500 mt-1">
Contenu attendu : model.glb/gltf + textures (roughness, normal, metalness, color, displace)
<br />Les originaux sont archives sur le Drive, les comprimes sont envoyes sur Git.
</p>
</div>
</>
)
+1 -3
View File
@@ -2,14 +2,12 @@ import Modal, { ModalActions } from '@/components/ui/Modal'
import { CheckIcon } from '@/components/ui/icons'
interface NoChangesModalProps {
destination: string
folderName: string
onCancel: () => void
onModify: () => void
}
export default function NoChangesModal({
destination,
folderName,
onCancel,
onModify,
@@ -25,7 +23,7 @@ export default function NoChangesModal({
Aucun changement detecte
</h3>
<p className="text-xs text-gray-400 mt-0.5">
Le dossier <span className="font-mono text-gray-300">{destination}/{folderName}</span> est identique au contenu distant. Rien a envoyer.
Le dossier <span className="font-mono text-gray-300">public/models/{folderName}</span> est identique au contenu distant. Rien a envoyer.
</p>
</div>
</div>
+1 -3
View File
@@ -3,7 +3,6 @@ import Modal, { ModalActions } from '@/components/ui/Modal'
import { WarningIcon } from '@/components/ui/icons'
interface OverwriteConfirmModalProps {
destination: string
folderName: string
diffs: FileDiff[]
onCancel: () => void
@@ -11,7 +10,6 @@ interface OverwriteConfirmModalProps {
}
export default function OverwriteConfirmModal({
destination,
folderName,
diffs,
onCancel,
@@ -28,7 +26,7 @@ export default function OverwriteConfirmModal({
Dossier deja existant
</h3>
<p className="text-xs text-gray-400 mt-0.5">
<span className="font-mono text-yellow-400">{destination}/{folderName}</span> existe deja.
<span className="font-mono text-yellow-400">public/models/{folderName}</span> existe deja.
Les anciens fichiers seront archives sur le Drive, puis les nouveaux seront envoyes sur le Drive et Git.
</p>
</div>
-14
View File
@@ -5,7 +5,6 @@
// ---------------------------------------------------------------------------
import { useState, useRef, useCallback } from 'react'
import type { Destination } from '@/lib/constants'
import type { FolderEntry } from '@/lib/client-types'
import type { FileDiff } from '@/lib/types'
import { checkFolderDiffs, uploadDrive, uploadGit } from '@/lib/upload-api'
@@ -28,7 +27,6 @@ export function useUploadOrchestrator({
}: UseUploadOrchestratorParams) {
const [isUploading, setIsUploading] = useState(false)
const [globalError, setGlobalError] = useState<string | null>(null)
const [destination, setDestination] = useState<Destination | null>(null)
const [overwriteConfirm, setOverwriteConfirm] = useState<{
folderName: string
diffs: FileDiff[]
@@ -45,20 +43,16 @@ export function useUploadOrchestrator({
// Refs for values used inside callbacks to avoid stale closures
const secretRef = useRef(secret)
secretRef.current = secret
const destinationRef = useRef(destination)
destinationRef.current = destination
const entriesRef = useRef(entries)
entriesRef.current = entries
// ---- Internal: push a single folder to Git ----
const pushGit = useCallback(async (index: number, signal?: AbortSignal) => {
const folderEntry = entriesRef.current[index]
const dest = destinationRef.current
const gitResult = await uploadGit(
folderEntry,
secretRef.current,
dest!,
(pct) => updateEntry(index, { progress: 50 + Math.round(pct / 2) }),
signal,
)
@@ -101,7 +95,6 @@ export function useUploadOrchestrator({
const driveResult = await uploadDrive(
folderEntry,
secretRef.current,
destinationRef.current!,
driveAction as 'new' | 'replace',
controller.signal,
)
@@ -129,10 +122,6 @@ export function useUploadOrchestrator({
setSecretError("La cle d'acces est requise")
return
}
if (!destinationRef.current) {
setGlobalError('Veuillez choisir une destination')
return
}
if (entriesRef.current.length === 0) return
setSecretError(null)
@@ -143,7 +132,6 @@ export function useUploadOrchestrator({
try {
const check = await checkFolderDiffs(
folder,
destinationRef.current,
secretRef.current,
abortRef.current?.signal,
)
@@ -230,8 +218,6 @@ export function useUploadOrchestrator({
isUploading,
globalError,
setGlobalError,
destination,
setDestination,
overwriteConfirm,
setOverwriteConfirm,
noChangesFolder,
+7 -21
View File
@@ -1,4 +1,3 @@
import { REQUIRED_TEXTURES } from './constants'
import type { FileChange } from './types'
/**
@@ -7,12 +6,11 @@ import type { FileChange } from './types'
* Symbols:
* - ✅ = new file
* - 🔄 = modified file
* - ❌ = missing texture (new upload) or deleted file (update)
* - ❌ = deleted file
* - Unchanged files are omitted entirely
*/
export function buildCommitMessage(
folderName: string,
destination: string,
modelFilename: string,
textureNames: string[],
compressed: boolean,
@@ -21,8 +19,8 @@ export function buildCommitMessage(
deletedFileNames: string[],
): string {
const title = isReplace
? `update: upload-gltf update -> ${destination}/${folderName}`
: `update: upload-gltf add a new model -> ${destination}/${folderName}`
? `update: upload-gltf update -> ${folderName}`
: `update: upload-gltf add a new model -> ${folderName}`
const lines: string[] = [title, '']
@@ -39,26 +37,14 @@ export function buildCommitMessage(
lines.push(` ↔️ ${modelFilename} (inchange)`)
}
// Textures section — only show lines that have changes
const foundTextures = new Set(
textureNames.map((t) => t.toLowerCase().replace(/\.[^.]+$/, '')),
)
const textureLines: string[] = []
for (const tex of REQUIRED_TEXTURES) {
if (foundTextures.has(tex)) {
const actual = textureNames.find(
(t) => t.toLowerCase().replace(/\.[^.]+$/, '') === tex,
)!
const change = fileChanges.get(actual.toLowerCase())
for (const textureName of textureNames) {
const change = fileChanges.get(textureName.toLowerCase())
if (change === 'new') {
textureLines.push(`${actual}`)
textureLines.push(`${textureName}`)
} else if (change === 'changed') {
textureLines.push(` 🔄 ${actual}`)
}
} else if (!isReplace) {
textureLines.push(`${tex} (manquant)`)
textureLines.push(` 🔄 ${textureName}`)
}
}
-17
View File
@@ -9,23 +9,6 @@ export const ALL_ALLOWED_EXTENSIONS = new Set([...MODEL_EXTENSIONS, ...TEXTURE_E
/** Extensions tracked by Git LFS (must match .gitattributes) */
export const LFS_EXTENSIONS = new Set(['.glb', '.gltf', '.png', '.jpg', '.jpeg', '.webp'])
export const REQUIRED_TEXTURES = ['roughness', 'normal', 'metalness', 'color', 'displace'] as const
export const VALID_DESTINATIONS = new Set<string>([
'farm', 'map', 'powergrid', 'workshop', 'general', 'environment',
])
export 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
export type Destination = typeof DESTINATIONS[number]['value']
export const TMP_DIR = '/tmp/assets'
/** Maximum file size in bytes (100 MB) */
+1 -1
View File
@@ -51,7 +51,7 @@ async function davRequest(
Authorization: auth,
...extraHeaders,
},
body: body ?? undefined,
body: body == null ? undefined : typeof body === 'string' ? body : new Uint8Array(body),
})
return res
+5 -12
View File
@@ -1,12 +1,11 @@
import { extname } from 'path'
import { NextRequest } from 'next/server'
import { sanitizeFilename } from './sanitize'
import { ALL_ALLOWED_EXTENSIONS, MODEL_EXTENSIONS, VALID_DESTINATIONS, MAX_FILE_SIZE } from './constants'
import { ALL_ALLOWED_EXTENSIONS, MODEL_EXTENSIONS, MAX_FILE_SIZE } from './constants'
import type { ParsedFile } from './types'
export interface ParsedUpload {
folderName: string
destination: string
files: ParsedFile[]
/** Any extra string fields from the FormData (e.g. "action") */
extra: Record<string, string>
@@ -14,8 +13,8 @@ export interface ParsedUpload {
/**
* Parse a multi-file FormData upload request.
* Validates destination, file extensions, file sizes, and returns parsed files.
* Extra string fields (beyond folderName, destination, files, fileTypes, textureNames)
* Validates file extensions, file sizes, and returns parsed files.
* Extra string fields (beyond folderName, files, fileTypes, textureNames)
* are returned in `extra`.
*/
export async function parseMultiUpload(req: NextRequest): Promise<ParsedUpload> {
@@ -23,18 +22,12 @@ export async function parseMultiUpload(req: NextRequest): Promise<ParsedUpload>
const folderName = (formData.get('folderName') as string | null)?.trim() || 'assets'
const safeFolderName = sanitizeFilename(folderName).replace(/[^a-zA-Z0-9-_]/g, '-')
const rawDestination = (formData.get('destination') as string | null)?.trim() || 'general'
if (!VALID_DESTINATIONS.has(rawDestination)) {
throw new Error(`Destination invalide: "${rawDestination}"`)
}
const destination = rawDestination
const rawFiles = formData.getAll('files')
const fileTypes = formData.getAll('fileTypes') as string[]
const textureNames = formData.getAll('textureNames') as string[]
// Collect extra string fields
const knownKeys = new Set(['folderName', 'destination', 'files', 'fileTypes', 'textureNames'])
const knownKeys = new Set(['folderName', 'files', 'fileTypes', 'textureNames'])
const extra: Record<string, string> = {}
for (const [key, value] of formData.entries()) {
if (!knownKeys.has(key) && typeof value === 'string') {
@@ -91,5 +84,5 @@ export async function parseMultiUpload(req: NextRequest): Promise<ParsedUpload>
parsed.push({ filename, buffer, isModel })
}
return { folderName: safeFolderName, destination, files: parsed, extra }
return { folderName: safeFolderName, files: parsed, extra }
}
+1 -3
View File
@@ -13,7 +13,6 @@ interface PushFile {
interface PrepareGitAssetsParams {
folderName: string
destination: string
parsedFiles: ParsedFile[]
}
@@ -27,7 +26,6 @@ interface PrepareGitAssetsResult {
export async function prepareGitAssets({
folderName,
destination,
parsedFiles,
}: PrepareGitAssetsParams): Promise<PrepareGitAssetsResult> {
const filesToPush: PushFile[] = []
@@ -76,7 +74,7 @@ export async function prepareGitAssets({
}
filesToPush.push({
path: `public/models/${destination}/${folderName}/${pf.filename}`,
path: `public/models/${folderName}/${pf.filename}`,
contentBase64: content.toString('base64'),
})
}
+3 -8
View File
@@ -16,12 +16,10 @@ export interface CheckResult {
function buildUploadFormData(
folder: FolderEntry,
destination: string,
extra?: Record<string, string>,
): FormData {
const formData = new FormData()
formData.append('folderName', folder.folderName)
formData.append('destination', destination)
if (extra) {
for (const [key, value] of Object.entries(extra)) {
@@ -52,11 +50,10 @@ function buildUploadFormData(
*/
export async function checkFolderDiffs(
folder: FolderEntry,
destination: string,
secret: string,
signal?: AbortSignal,
): Promise<CheckResult> {
const formData = buildUploadFormData(folder, destination)
const formData = buildUploadFormData(folder)
const res = await fetch('/api/upload/check', {
method: 'POST',
headers: { 'x-upload-secret': secret.trim() },
@@ -86,11 +83,10 @@ export async function checkFolderDiffs(
export async function uploadDrive(
folder: FolderEntry,
secret: string,
destination: string,
action: 'new' | 'replace',
signal?: AbortSignal,
): Promise<{ success: boolean; error?: string }> {
const formData = buildUploadFormData(folder, destination, { action })
const formData = buildUploadFormData(folder, { action })
try {
const res = await fetch('/api/upload/drive', {
@@ -118,11 +114,10 @@ export async function uploadDrive(
export async function uploadGit(
folder: FolderEntry,
secret: string,
destination: string,
onProgress: (pct: number) => void,
signal?: AbortSignal,
): Promise<{ success: boolean; filename?: string; error?: string }> {
const formData = buildUploadFormData(folder, destination)
const formData = buildUploadFormData(folder)
onProgress(10)
+9 -21
View File
@@ -2,17 +2,11 @@
// Client-side folder validation
// ---------------------------------------------------------------------------
import { REQUIRED_TEXTURES, TEXTURE_EXTENSIONS } from '@/lib/constants'
import { 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
}
/** Discriminated union: either valid (with model) or invalid (with errors). */
export type ValidationResult =
| { ok: true; model: File; textures: TextureFile[]; warnings: string[] }
@@ -20,39 +14,33 @@ export type ValidationResult =
export function validateFolder(files: File[]): ValidationResult {
const textures: TextureFile[] = []
const warnings: string[] = []
const errors: string[] = []
const modelFiles = files.filter((f) => {
const name = f.name.toLowerCase()
return name === 'model.glb' || name === 'model.gltf'
return name === 'model.glb'
})
if (modelFiles.length === 0) {
return { ok: false, errors: ['model.glb ou model.gltf manquant (obligatoire)'] }
return { ok: false, errors: ['model.glb manquant (obligatoire)'] }
}
if (modelFiles.length > 1) {
return { ok: false, errors: ['Un seul fichier model.glb est autorise'] }
}
const textureFiles = files.filter((f) => {
const ext = f.name.slice(f.name.lastIndexOf('.')).toLowerCase()
return TEXTURE_EXT_ARRAY.includes(ext) && getTextureType(f.name) !== null
return TEXTURE_EXT_ARRAY.includes(ext)
})
for (const tf of textureFiles) {
textures.push({ name: tf.name, file: tf })
}
const foundTextures = new Set(
textures.map((t) => t.name.toLowerCase().replace(/\.[^.]+$/, '')),
)
for (const req of REQUIRED_TEXTURES) {
if (!foundTextures.has(req)) {
warnings.push(`${req}.webp/png/jpg manquant`)
}
}
if (errors.length > 0) {
return { ok: false, errors }
}
return { ok: true, model: modelFiles[0], textures, warnings }
return { ok: true, model: modelFiles[0], textures, warnings: [] }
}
+14 -14
View File
@@ -10,22 +10,22 @@
},
"dependencies": {
"@octokit/rest": "^22.0.1",
"@react-three/drei": "^10.7.0",
"@react-three/fiber": "^9.5.0",
"next": "^16.2.1",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"@react-three/drei": "^10.7.7",
"@react-three/fiber": "^9.6.0",
"next": "^16.2.4",
"react": "^19.2.5",
"react-dom": "^19.2.5",
"sharp": "^0.34.5",
"three": "^0.183.0"
"three": "^0.183.2"
},
"devDependencies": {
"@types/node": "^22.13.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@types/three": "^0.183.0",
"autoprefixer": "^10.4.20",
"postcss": "^8.5.1",
"tailwindcss": "^3.4.17",
"typescript": "^5.7.3"
"@types/node": "^22.19.17",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@types/three": "^0.183.1",
"autoprefixer": "^10.5.0",
"postcss": "^8.5.10",
"tailwindcss": "^3.4.19",
"typescript": "^5.9.3"
}
}
+1947
View File
File diff suppressed because it is too large Load Diff