refactor: simplify upload rules and remove destination flow
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
@@ -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'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,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">
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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}`)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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 }
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+1947
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user