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 # 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. - **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. - **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 ## How it works
1. The user enters their access key 1. The user enters their access key
2. They pick a **destination** (`farm`, `map`, `powergrid`, `workshop`, `general`, `environment`) 2. They select a folder containing:
3. They select a folder containing: - `model.glb` (**required**)
- `model.glb` or `model.gltf` (**required**) - Any associated textures (`.png/.jpg/.jpeg/.webp`)
- Textures: `roughness`, `normal`, `metalness`, `color`, `displace` (`.png/.jpg/.webp`, **optional** — missing textures show a warning but don't block the upload)
4. The model is displayed in a 3D preview 4. The model is displayed in a 3D preview
5. On clicking "Envoyer": 5. On clicking "Envoyer":
- The app checks the remote Git repo for existing files and computes diffs (textures by size, models always re-pushed) - 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/ Models/
VF/ ← latest version VF/ ← latest version
coffeetest/ coffeetest/
model.gltf model.glb
color.jpg color.jpg
V1/ ← first archive V1/ ← first archive
coffeetest/ coffeetest/
@@ -131,42 +130,32 @@ All changes are pushed in a **single commit** with a formatted message:
**New folder:** **New folder:**
``` ```
update: upload-gltf add a new model -> farm/my-model update: upload-gltf add a new model -> my-model
📦 Model 📦 Model
✅ model.gltf (compressed) ✅ model.glb (compressed)
🎨 Textures 🎨 Textures
✅ color.jpg ✅ 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
↔️ model.gltf (inchange) ↔️ model.glb (inchange)
🎨 Textures 🎨 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 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) 9. If Blender is unavailable, the original model is pushed as-is (graceful fallback)
## Destinations ## Destinations
Uploaded models are pushed to `public/models/<destination>/<folderName>/` in the target repo: Uploaded models are pushed to `public/models/<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/` |
## Project Structure ## Project Structure
@@ -185,7 +174,6 @@ components/
│ └── Modal.tsx # Shared modal wrapper + ModalActions │ └── Modal.tsx # Shared modal wrapper + ModalActions
├── upload/ ├── upload/
│ ├── SecretInput.tsx # Access key input │ ├── SecretInput.tsx # Access key input
│ ├── DestinationPicker.tsx # Destination selector
│ ├── FolderDropzone.tsx # Folder drag & drop / picker │ ├── FolderDropzone.tsx # Folder drag & drop / picker
│ ├── FolderCard.tsx # Folder status card (Drive + Git) │ ├── FolderCard.tsx # Folder status card (Drive + Git)
│ ├── DriveStatusLine.tsx # Drive/Git status sub-line │ ├── DriveStatusLine.tsx # Drive/Git status sub-line
@@ -202,7 +190,7 @@ hooks/
├── useFolderEntries.ts # Folder entries state management ├── useFolderEntries.ts # Folder entries state management
└── useUploadOrchestrator.ts # Upload pipeline orchestration (Drive → Git) └── useUploadOrchestrator.ts # Upload pipeline orchestration (Drive → Git)
lib/ lib/
├── constants.ts # Shared constants, destinations, extensions ├── constants.ts # Shared constants and extensions
├── types.ts # Server types (ParsedFile, FileDiff, etc.) ├── types.ts # Server types (ParsedFile, FileDiff, etc.)
├── client-types.ts # Client types (FolderEntry, DriveStatus, etc.) ├── client-types.ts # Client types (FolderEntry, DriveStatus, etc.)
├── upload-api.ts # Client-side API helpers (check, uploadDrive, uploadGit) ├── 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 if (authError) return authError
let folderName: string let folderName: string
let destination: string
let parsedFiles: Awaited<ReturnType<typeof parseMultiUpload>>['files'] let parsedFiles: Awaited<ReturnType<typeof parseMultiUpload>>['files']
try { try {
const parsed = await parseMultiUpload(req) const parsed = await parseMultiUpload(req)
folderName = parsed.folderName folderName = parsed.folderName
destination = parsed.destination
parsedFiles = parsed.files parsedFiles = parsed.files
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : 'Erreur inconnue' const message = err instanceof Error ? err.message : 'Erreur inconnue'
return NextResponse.json({ success: false, error: message }, { status: 400 }) return NextResponse.json({ success: false, error: message }, { status: 400 })
} }
const folderPath = `public/models/${destination}/${folderName}` const folderPath = `public/models/${folderName}`
try { try {
const { filesToPush } = await prepareGitAssets({ folderName, destination, parsedFiles }) const { filesToPush } = await prepareGitAssets({ folderName, parsedFiles })
const { exists, files } = await getRemoteFolder(folderPath) const { exists, files } = await getRemoteFolder(folderPath)
if (exists) { if (exists) {
+1 -1
View File
@@ -17,7 +17,7 @@ export const dynamic = 'force-dynamic'
// Upload **original** files (no Blender compression) to Nextcloud Drive. // Upload **original** files (no Blender compression) to Nextcloud Drive.
// //
// FormData fields: // FormData fields:
// - folderName, destination, files[], fileTypes[], textureNames[] (same as /api/upload/git) // - folderName, files[], fileTypes[], textureNames[] (same as /api/upload/git)
// - action: "new" | "replace" // - action: "new" | "replace"
// //
// Versioning logic: // Versioning logic:
+3 -5
View File
@@ -20,13 +20,11 @@ export async function POST(req: NextRequest) {
// --- Parse all files --- // --- Parse all files ---
let folderName: string let folderName: string
let destination: string
let parsedFiles: Awaited<ReturnType<typeof parseMultiUpload>>['files'] let parsedFiles: Awaited<ReturnType<typeof parseMultiUpload>>['files']
try { try {
const parsed = await parseMultiUpload(req) const parsed = await parseMultiUpload(req)
folderName = parsed.folderName folderName = parsed.folderName
destination = parsed.destination
parsedFiles = parsed.files parsedFiles = parsed.files
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : 'Erreur inconnue' const message = err instanceof Error ? err.message : 'Erreur inconnue'
@@ -40,10 +38,10 @@ export async function POST(req: NextRequest) {
compressed, compressed,
compressionError, compressionError,
textureNames, textureNames,
} = await prepareGitAssets({ folderName, destination, parsedFiles }) } = await prepareGitAssets({ folderName, parsedFiles })
// --- Detect existing files and classify changes --- // --- Detect existing files and classify changes ---
const folderPath = `public/models/${destination}/${folderName}` const folderPath = `public/models/${folderName}`
let remoteFileMap: Map<string, number> let remoteFileMap: Map<string, number>
try { try {
@@ -72,7 +70,7 @@ export async function POST(req: NextRequest) {
// --- Build commit message --- // --- Build commit message ---
const commitMessage = buildCommitMessage( const commitMessage = buildCommitMessage(
folderName, destination, modelFilename, textureNames, folderName, modelFilename, textureNames,
compressed, isReplace, fileChanges, deletedFileNames, compressed, isReplace, fileChanges, deletedFileNames,
) )
+16 -12
View File
@@ -5,7 +5,6 @@ import { useSecret } from '@/hooks/useSecret'
import { useFolderEntries } from '@/hooks/useFolderEntries' import { useFolderEntries } from '@/hooks/useFolderEntries'
import { useUploadOrchestrator } from '@/hooks/useUploadOrchestrator' import { useUploadOrchestrator } from '@/hooks/useUploadOrchestrator'
import SecretInput from './upload/SecretInput' import SecretInput from './upload/SecretInput'
import DestinationPicker from './upload/DestinationPicker'
import FolderDropzone from './upload/FolderDropzone' import FolderDropzone from './upload/FolderDropzone'
import FolderCard from './upload/FolderCard' import FolderCard from './upload/FolderCard'
import ActionButtons from './upload/ActionButtons' import ActionButtons from './upload/ActionButtons'
@@ -38,8 +37,6 @@ export default function UploadZone() {
isUploading, isUploading,
globalError, globalError,
setGlobalError, setGlobalError,
destination,
setDestination,
overwriteConfirm, overwriteConfirm,
setOverwriteConfirm, setOverwriteConfirm,
noChangesFolder, noChangesFolder,
@@ -84,18 +81,28 @@ export default function UploadZone() {
onToggleVisible={toggleSecretVisible} onToggleVisible={toggleSecretVisible}
/> />
<DestinationPicker
destination={destination}
disabled={isUploading}
onChange={setDestination}
/>
{entries.length === 0 && ( {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 <FolderDropzone
isUploading={isUploading} isUploading={isUploading}
onFolderSelected={handleFolderSelected} onFolderSelected={handleFolderSelected}
onError={setGlobalError} onError={setGlobalError}
/> />
</div>
)} )}
{globalError && ( {globalError && (
@@ -119,7 +126,6 @@ export default function UploadZone() {
<ActionButtons <ActionButtons
isUploading={isUploading} isUploading={isUploading}
isSecretEmpty={isSecretEmpty} isSecretEmpty={isSecretEmpty}
noDestination={!destination}
hasPendingOrErrors={hasPendingOrErrors} hasPendingOrErrors={hasPendingOrErrors}
allDone={allDone} allDone={allDone}
hasErrors={hasErrors} hasErrors={hasErrors}
@@ -130,7 +136,6 @@ export default function UploadZone() {
{overwriteConfirm && ( {overwriteConfirm && (
<OverwriteConfirmModal <OverwriteConfirmModal
destination={destination!}
folderName={overwriteConfirm.folderName} folderName={overwriteConfirm.folderName}
diffs={overwriteConfirm.diffs} diffs={overwriteConfirm.diffs}
onCancel={() => setOverwriteConfirm(null)} onCancel={() => setOverwriteConfirm(null)}
@@ -140,7 +145,6 @@ export default function UploadZone() {
{noChangesFolder && ( {noChangesFolder && (
<NoChangesModal <NoChangesModal
destination={destination!}
folderName={noChangesFolder} folderName={noChangesFolder}
onCancel={() => { onCancel={() => {
setNoChangesFolder(null) setNoChangesFolder(null)
+1 -3
View File
@@ -1,7 +1,6 @@
interface ActionButtonsProps { interface ActionButtonsProps {
isUploading: boolean isUploading: boolean
isSecretEmpty: boolean isSecretEmpty: boolean
noDestination: boolean
hasPendingOrErrors: boolean hasPendingOrErrors: boolean
allDone: boolean allDone: boolean
hasErrors: boolean hasErrors: boolean
@@ -13,7 +12,6 @@ interface ActionButtonsProps {
export default function ActionButtons({ export default function ActionButtons({
isUploading, isUploading,
isSecretEmpty, isSecretEmpty,
noDestination,
hasPendingOrErrors, hasPendingOrErrors,
allDone, allDone,
hasErrors, hasErrors,
@@ -21,7 +19,7 @@ export default function ActionButtons({
onCancel, onCancel,
onReset, onReset,
}: ActionButtonsProps) { }: ActionButtonsProps) {
const cantUpload = isSecretEmpty || noDestination const cantUpload = isSecretEmpty
return ( return (
<div className="flex gap-3"> <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 Deposez votre dossier ici
<span className="text-gray-500 font-normal"> ou cliquez pour parcourir</span> <span className="text-gray-500 font-normal"> ou cliquez pour parcourir</span>
</p> </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> </div>
</> </>
) )
+1 -3
View File
@@ -2,14 +2,12 @@ import Modal, { ModalActions } from '@/components/ui/Modal'
import { CheckIcon } from '@/components/ui/icons' import { CheckIcon } from '@/components/ui/icons'
interface NoChangesModalProps { interface NoChangesModalProps {
destination: string
folderName: string folderName: string
onCancel: () => void onCancel: () => void
onModify: () => void onModify: () => void
} }
export default function NoChangesModal({ export default function NoChangesModal({
destination,
folderName, folderName,
onCancel, onCancel,
onModify, onModify,
@@ -25,7 +23,7 @@ export default function NoChangesModal({
Aucun changement detecte Aucun changement detecte
</h3> </h3>
<p className="text-xs text-gray-400 mt-0.5"> <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> </p>
</div> </div>
</div> </div>
+1 -3
View File
@@ -3,7 +3,6 @@ import Modal, { ModalActions } from '@/components/ui/Modal'
import { WarningIcon } from '@/components/ui/icons' import { WarningIcon } from '@/components/ui/icons'
interface OverwriteConfirmModalProps { interface OverwriteConfirmModalProps {
destination: string
folderName: string folderName: string
diffs: FileDiff[] diffs: FileDiff[]
onCancel: () => void onCancel: () => void
@@ -11,7 +10,6 @@ interface OverwriteConfirmModalProps {
} }
export default function OverwriteConfirmModal({ export default function OverwriteConfirmModal({
destination,
folderName, folderName,
diffs, diffs,
onCancel, onCancel,
@@ -28,7 +26,7 @@ export default function OverwriteConfirmModal({
Dossier deja existant Dossier deja existant
</h3> </h3>
<p className="text-xs text-gray-400 mt-0.5"> <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. Les anciens fichiers seront archives sur le Drive, puis les nouveaux seront envoyes sur le Drive et Git.
</p> </p>
</div> </div>
-14
View File
@@ -5,7 +5,6 @@
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
import { useState, useRef, useCallback } from 'react' import { useState, useRef, useCallback } from 'react'
import type { Destination } from '@/lib/constants'
import type { FolderEntry } from '@/lib/client-types' import type { FolderEntry } from '@/lib/client-types'
import type { FileDiff } from '@/lib/types' import type { FileDiff } from '@/lib/types'
import { checkFolderDiffs, uploadDrive, uploadGit } from '@/lib/upload-api' import { checkFolderDiffs, uploadDrive, uploadGit } from '@/lib/upload-api'
@@ -28,7 +27,6 @@ export function useUploadOrchestrator({
}: UseUploadOrchestratorParams) { }: UseUploadOrchestratorParams) {
const [isUploading, setIsUploading] = useState(false) const [isUploading, setIsUploading] = useState(false)
const [globalError, setGlobalError] = useState<string | null>(null) const [globalError, setGlobalError] = useState<string | null>(null)
const [destination, setDestination] = useState<Destination | null>(null)
const [overwriteConfirm, setOverwriteConfirm] = useState<{ const [overwriteConfirm, setOverwriteConfirm] = useState<{
folderName: string folderName: string
diffs: FileDiff[] diffs: FileDiff[]
@@ -45,20 +43,16 @@ export function useUploadOrchestrator({
// Refs for values used inside callbacks to avoid stale closures // Refs for values used inside callbacks to avoid stale closures
const secretRef = useRef(secret) const secretRef = useRef(secret)
secretRef.current = secret secretRef.current = secret
const destinationRef = useRef(destination)
destinationRef.current = destination
const entriesRef = useRef(entries) const entriesRef = useRef(entries)
entriesRef.current = entries entriesRef.current = entries
// ---- Internal: push a single folder to Git ---- // ---- Internal: push a single folder to Git ----
const pushGit = useCallback(async (index: number, signal?: AbortSignal) => { const pushGit = useCallback(async (index: number, signal?: AbortSignal) => {
const folderEntry = entriesRef.current[index] const folderEntry = entriesRef.current[index]
const dest = destinationRef.current
const gitResult = await uploadGit( const gitResult = await uploadGit(
folderEntry, folderEntry,
secretRef.current, secretRef.current,
dest!,
(pct) => updateEntry(index, { progress: 50 + Math.round(pct / 2) }), (pct) => updateEntry(index, { progress: 50 + Math.round(pct / 2) }),
signal, signal,
) )
@@ -101,7 +95,6 @@ export function useUploadOrchestrator({
const driveResult = await uploadDrive( const driveResult = await uploadDrive(
folderEntry, folderEntry,
secretRef.current, secretRef.current,
destinationRef.current!,
driveAction as 'new' | 'replace', driveAction as 'new' | 'replace',
controller.signal, controller.signal,
) )
@@ -129,10 +122,6 @@ export function useUploadOrchestrator({
setSecretError("La cle d'acces est requise") setSecretError("La cle d'acces est requise")
return return
} }
if (!destinationRef.current) {
setGlobalError('Veuillez choisir une destination')
return
}
if (entriesRef.current.length === 0) return if (entriesRef.current.length === 0) return
setSecretError(null) setSecretError(null)
@@ -143,7 +132,6 @@ export function useUploadOrchestrator({
try { try {
const check = await checkFolderDiffs( const check = await checkFolderDiffs(
folder, folder,
destinationRef.current,
secretRef.current, secretRef.current,
abortRef.current?.signal, abortRef.current?.signal,
) )
@@ -230,8 +218,6 @@ export function useUploadOrchestrator({
isUploading, isUploading,
globalError, globalError,
setGlobalError, setGlobalError,
destination,
setDestination,
overwriteConfirm, overwriteConfirm,
setOverwriteConfirm, setOverwriteConfirm,
noChangesFolder, noChangesFolder,
+7 -21
View File
@@ -1,4 +1,3 @@
import { REQUIRED_TEXTURES } from './constants'
import type { FileChange } from './types' import type { FileChange } from './types'
/** /**
@@ -7,12 +6,11 @@ import type { FileChange } from './types'
* Symbols: * Symbols:
* - ✅ = new file * - ✅ = new file
* - 🔄 = modified file * - 🔄 = modified file
* - ❌ = missing texture (new upload) or deleted file (update) * - ❌ = deleted file
* - Unchanged files are omitted entirely * - Unchanged files are omitted entirely
*/ */
export function buildCommitMessage( export function buildCommitMessage(
folderName: string, folderName: string,
destination: string,
modelFilename: string, modelFilename: string,
textureNames: string[], textureNames: string[],
compressed: boolean, compressed: boolean,
@@ -21,8 +19,8 @@ export function buildCommitMessage(
deletedFileNames: string[], deletedFileNames: string[],
): string { ): string {
const title = isReplace const title = isReplace
? `update: upload-gltf update -> ${destination}/${folderName}` ? `update: upload-gltf update -> ${folderName}`
: `update: upload-gltf add a new model -> ${destination}/${folderName}` : `update: upload-gltf add a new model -> ${folderName}`
const lines: string[] = [title, ''] const lines: string[] = [title, '']
@@ -39,26 +37,14 @@ export function buildCommitMessage(
lines.push(` ↔️ ${modelFilename} (inchange)`) 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[] = [] const textureLines: string[] = []
for (const tex of REQUIRED_TEXTURES) { for (const textureName of textureNames) {
if (foundTextures.has(tex)) { const change = fileChanges.get(textureName.toLowerCase())
const actual = textureNames.find(
(t) => t.toLowerCase().replace(/\.[^.]+$/, '') === tex,
)!
const change = fileChanges.get(actual.toLowerCase())
if (change === 'new') { if (change === 'new') {
textureLines.push(`${actual}`) textureLines.push(`${textureName}`)
} else if (change === 'changed') { } else if (change === 'changed') {
textureLines.push(` 🔄 ${actual}`) textureLines.push(` 🔄 ${textureName}`)
}
} else if (!isReplace) {
textureLines.push(`${tex} (manquant)`)
} }
} }
-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) */ /** Extensions tracked by Git LFS (must match .gitattributes) */
export const LFS_EXTENSIONS = new Set(['.glb', '.gltf', '.png', '.jpg', '.jpeg', '.webp']) 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' export const TMP_DIR = '/tmp/assets'
/** Maximum file size in bytes (100 MB) */ /** Maximum file size in bytes (100 MB) */
+1 -1
View File
@@ -51,7 +51,7 @@ async function davRequest(
Authorization: auth, Authorization: auth,
...extraHeaders, ...extraHeaders,
}, },
body: body ?? undefined, body: body == null ? undefined : typeof body === 'string' ? body : new Uint8Array(body),
}) })
return res return res
+5 -12
View File
@@ -1,12 +1,11 @@
import { extname } from 'path' import { extname } from 'path'
import { NextRequest } from 'next/server' import { NextRequest } from 'next/server'
import { sanitizeFilename } from './sanitize' 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' import type { ParsedFile } from './types'
export interface ParsedUpload { export interface ParsedUpload {
folderName: string folderName: string
destination: string
files: ParsedFile[] files: ParsedFile[]
/** Any extra string fields from the FormData (e.g. "action") */ /** Any extra string fields from the FormData (e.g. "action") */
extra: Record<string, string> extra: Record<string, string>
@@ -14,8 +13,8 @@ export interface ParsedUpload {
/** /**
* Parse a multi-file FormData upload request. * Parse a multi-file FormData upload request.
* Validates destination, file extensions, file sizes, and returns parsed files. * Validates file extensions, file sizes, and returns parsed files.
* Extra string fields (beyond folderName, destination, files, fileTypes, textureNames) * Extra string fields (beyond folderName, files, fileTypes, textureNames)
* are returned in `extra`. * are returned in `extra`.
*/ */
export async function parseMultiUpload(req: NextRequest): Promise<ParsedUpload> { 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 folderName = (formData.get('folderName') as string | null)?.trim() || 'assets'
const safeFolderName = sanitizeFilename(folderName).replace(/[^a-zA-Z0-9-_]/g, '-') 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 rawFiles = formData.getAll('files')
const fileTypes = formData.getAll('fileTypes') as string[] const fileTypes = formData.getAll('fileTypes') as string[]
const textureNames = formData.getAll('textureNames') as string[] const textureNames = formData.getAll('textureNames') as string[]
// Collect extra string fields // 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> = {} const extra: Record<string, string> = {}
for (const [key, value] of formData.entries()) { for (const [key, value] of formData.entries()) {
if (!knownKeys.has(key) && typeof value === 'string') { if (!knownKeys.has(key) && typeof value === 'string') {
@@ -91,5 +84,5 @@ export async function parseMultiUpload(req: NextRequest): Promise<ParsedUpload>
parsed.push({ filename, buffer, isModel }) 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 { interface PrepareGitAssetsParams {
folderName: string folderName: string
destination: string
parsedFiles: ParsedFile[] parsedFiles: ParsedFile[]
} }
@@ -27,7 +26,6 @@ interface PrepareGitAssetsResult {
export async function prepareGitAssets({ export async function prepareGitAssets({
folderName, folderName,
destination,
parsedFiles, parsedFiles,
}: PrepareGitAssetsParams): Promise<PrepareGitAssetsResult> { }: PrepareGitAssetsParams): Promise<PrepareGitAssetsResult> {
const filesToPush: PushFile[] = [] const filesToPush: PushFile[] = []
@@ -76,7 +74,7 @@ export async function prepareGitAssets({
} }
filesToPush.push({ filesToPush.push({
path: `public/models/${destination}/${folderName}/${pf.filename}`, path: `public/models/${folderName}/${pf.filename}`,
contentBase64: content.toString('base64'), contentBase64: content.toString('base64'),
}) })
} }
+3 -8
View File
@@ -16,12 +16,10 @@ export interface CheckResult {
function buildUploadFormData( function buildUploadFormData(
folder: FolderEntry, folder: FolderEntry,
destination: string,
extra?: Record<string, string>, extra?: Record<string, string>,
): FormData { ): FormData {
const formData = new FormData() const formData = new FormData()
formData.append('folderName', folder.folderName) formData.append('folderName', folder.folderName)
formData.append('destination', destination)
if (extra) { if (extra) {
for (const [key, value] of Object.entries(extra)) { for (const [key, value] of Object.entries(extra)) {
@@ -52,11 +50,10 @@ function buildUploadFormData(
*/ */
export async function checkFolderDiffs( export async function checkFolderDiffs(
folder: FolderEntry, folder: FolderEntry,
destination: string,
secret: string, secret: string,
signal?: AbortSignal, signal?: AbortSignal,
): Promise<CheckResult> { ): Promise<CheckResult> {
const formData = buildUploadFormData(folder, destination) const formData = buildUploadFormData(folder)
const res = await fetch('/api/upload/check', { const res = await fetch('/api/upload/check', {
method: 'POST', method: 'POST',
headers: { 'x-upload-secret': secret.trim() }, headers: { 'x-upload-secret': secret.trim() },
@@ -86,11 +83,10 @@ export async function checkFolderDiffs(
export async function uploadDrive( export async function uploadDrive(
folder: FolderEntry, folder: FolderEntry,
secret: string, secret: string,
destination: string,
action: 'new' | 'replace', action: 'new' | 'replace',
signal?: AbortSignal, signal?: AbortSignal,
): Promise<{ success: boolean; error?: string }> { ): Promise<{ success: boolean; error?: string }> {
const formData = buildUploadFormData(folder, destination, { action }) const formData = buildUploadFormData(folder, { action })
try { try {
const res = await fetch('/api/upload/drive', { const res = await fetch('/api/upload/drive', {
@@ -118,11 +114,10 @@ export async function uploadDrive(
export async function uploadGit( export async function uploadGit(
folder: FolderEntry, folder: FolderEntry,
secret: string, secret: string,
destination: string,
onProgress: (pct: number) => void, onProgress: (pct: number) => void,
signal?: AbortSignal, signal?: AbortSignal,
): Promise<{ success: boolean; filename?: string; error?: string }> { ): Promise<{ success: boolean; filename?: string; error?: string }> {
const formData = buildUploadFormData(folder, destination) const formData = buildUploadFormData(folder)
onProgress(10) onProgress(10)
+9 -21
View File
@@ -2,17 +2,11 @@
// Client-side folder validation // 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' import type { TextureFile } from '@/lib/client-types'
const TEXTURE_EXT_ARRAY = [...TEXTURE_EXTENSIONS] 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). */ /** Discriminated union: either valid (with model) or invalid (with errors). */
export type ValidationResult = export type ValidationResult =
| { ok: true; model: File; textures: TextureFile[]; warnings: string[] } | { ok: true; model: File; textures: TextureFile[]; warnings: string[] }
@@ -20,39 +14,33 @@ export type ValidationResult =
export function validateFolder(files: File[]): ValidationResult { export function validateFolder(files: File[]): ValidationResult {
const textures: TextureFile[] = [] const textures: TextureFile[] = []
const warnings: string[] = []
const errors: string[] = [] const errors: string[] = []
const modelFiles = files.filter((f) => { const modelFiles = files.filter((f) => {
const name = f.name.toLowerCase() const name = f.name.toLowerCase()
return name === 'model.glb' || name === 'model.gltf' return name === 'model.glb'
}) })
if (modelFiles.length === 0) { 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 textureFiles = files.filter((f) => {
const ext = f.name.slice(f.name.lastIndexOf('.')).toLowerCase() 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) { for (const tf of textureFiles) {
textures.push({ name: tf.name, file: tf }) 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) { if (errors.length > 0) {
return { ok: false, errors } 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": { "dependencies": {
"@octokit/rest": "^22.0.1", "@octokit/rest": "^22.0.1",
"@react-three/drei": "^10.7.0", "@react-three/drei": "^10.7.7",
"@react-three/fiber": "^9.5.0", "@react-three/fiber": "^9.6.0",
"next": "^16.2.1", "next": "^16.2.4",
"react": "^19.0.0", "react": "^19.2.5",
"react-dom": "^19.0.0", "react-dom": "^19.2.5",
"sharp": "^0.34.5", "sharp": "^0.34.5",
"three": "^0.183.0" "three": "^0.183.2"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "^22.13.0", "@types/node": "^22.19.17",
"@types/react": "^19.0.0", "@types/react": "^19.2.14",
"@types/react-dom": "^19.0.0", "@types/react-dom": "^19.2.3",
"@types/three": "^0.183.0", "@types/three": "^0.183.1",
"autoprefixer": "^10.4.20", "autoprefixer": "^10.5.0",
"postcss": "^8.5.1", "postcss": "^8.5.10",
"tailwindcss": "^3.4.17", "tailwindcss": "^3.4.19",
"typescript": "^5.7.3" "typescript": "^5.9.3"
} }
} }
+1947
View File
File diff suppressed because it is too large Load Diff