upadte: clean code + add next cloud

This commit is contained in:
Tom Boullay
2026-04-14 16:21:37 +02:00
parent 3adcf9d30e
commit 3a7a5e2eea
20 changed files with 663 additions and 131 deletions
+6
View File
@@ -3,3 +3,9 @@ GITHUB_TOKEN=ghp_your-github-personal-access-token
GIT_BRANCH=main GIT_BRANCH=main
GIT_REPO_URL=https://github.com/your-org/your-repo.git GIT_REPO_URL=https://github.com/your-org/your-repo.git
BLENDER_PATH=/Applications/Blender.app/Contents/MacOS/Blender BLENDER_PATH=/Applications/Blender.app/Contents/MacOS/Blender
# Nextcloud Drive (WebDAV)
NEXTCLOUD_URL=https://cloud.example.com
NEXTCLOUD_USER=your-nextcloud-username
NEXTCLOUD_PASSWORD=your-nextcloud-password
NEXTCLOUD_BASE_PATH=Models
+92 -17
View File
@@ -1,6 +1,11 @@
# upload-GLTF # upload-GLTF
A secure web interface for uploading 3D assets (GLTF/GLB + textures) with automatic Draco compression and GitHub push. Built for La Fabrik Durable. A secure web interface for uploading 3D assets (GLTF/GLB + textures) to two destinations:
- **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.
Built for La Fabrik Durable.
## Stack ## Stack
@@ -8,6 +13,7 @@ A secure web interface for uploading 3D assets (GLTF/GLB + textures) with automa
- **Three.js** (@react-three/fiber + @react-three/drei) for 3D preview - **Three.js** (@react-three/fiber + @react-three/drei) for 3D preview
- **Tailwind CSS** for styling - **Tailwind CSS** for styling
- **Octokit** for pushing via the GitHub API - **Octokit** for pushing via the GitHub API
- **Nextcloud WebDAV** for Drive archiving with automatic versioning
- **Blender** (headless) for Draco mesh compression - **Blender** (headless) for Draco mesh compression
- **Coolify** (Docker) for hosting - **Coolify** (Docker) for hosting
@@ -29,6 +35,12 @@ GITHUB_TOKEN=ghp_your-github-personal-access-token
GIT_BRANCH=main GIT_BRANCH=main
GIT_REPO_URL=https://github.com/your-org/your-repo.git GIT_REPO_URL=https://github.com/your-org/your-repo.git
BLENDER_PATH=/Applications/Blender.app/Contents/MacOS/Blender BLENDER_PATH=/Applications/Blender.app/Contents/MacOS/Blender
# Nextcloud Drive (WebDAV)
NEXTCLOUD_URL=https://cloud.example.com
NEXTCLOUD_USER=your-nextcloud-username
NEXTCLOUD_PASSWORD=your-nextcloud-password
NEXTCLOUD_BASE_PATH=Models
``` ```
| Variable | Description | Required | | Variable | Description | Required |
@@ -38,8 +50,12 @@ BLENDER_PATH=/Applications/Blender.app/Contents/MacOS/Blender
| `GIT_BRANCH` | Target branch (default: main) | No | | `GIT_BRANCH` | Target branch (default: main) | No |
| `GIT_REPO_URL` | Target GitHub repository URL | Yes | | `GIT_REPO_URL` | Target GitHub repository URL | Yes |
| `BLENDER_PATH` | Path to Blender binary (default: `blender`) | No | | `BLENDER_PATH` | Path to Blender binary (default: `blender`) | No |
| `NEXTCLOUD_URL` | Nextcloud instance URL | Yes |
| `NEXTCLOUD_USER` | Nextcloud username (Basic auth) | Yes |
| `NEXTCLOUD_PASSWORD` | Nextcloud password (Basic auth) | Yes |
| `NEXTCLOUD_BASE_PATH` | Root folder on the Drive (default: `Models`) | No |
> To create a token: GitHub > Settings > Developer settings > Fine-grained personal access tokens > select the target repo > Permissions > Contents: Read and write. > To create a GitHub token: GitHub > Settings > Developer settings > Fine-grained personal access tokens > select the target repo > Permissions > Contents: Read and write.
## Usage ## Usage
@@ -61,6 +77,9 @@ docker run -p 3000:3000 \
-e UPLOAD_SECRET_KEY=your-key \ -e UPLOAD_SECRET_KEY=your-key \
-e GITHUB_TOKEN=ghp_xxx \ -e GITHUB_TOKEN=ghp_xxx \
-e GIT_REPO_URL=https://github.com/org/repo.git \ -e GIT_REPO_URL=https://github.com/org/repo.git \
-e NEXTCLOUD_URL=https://cloud.example.com \
-e NEXTCLOUD_USER=user \
-e NEXTCLOUD_PASSWORD=pass \
upload-gltf upload-gltf
``` ```
@@ -74,15 +93,42 @@ The Docker image includes Blender headless (installed once at build time). On st
- `model.glb` or `model.gltf` (**required**) - `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) - 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 sur GitHub": 5. On clicking "Envoyer":
- The app computes the git SHA of each local file and compares with the remote repo - The app checks the remote Git repo for existing files and computes diffs (textures by size, models always re-pushed)
- If the folder doesn't exist, upload proceeds directly - If the folder doesn't exist, upload proceeds directly
- If the folder exists and files differ, a confirmation dialog shows **only the actual changes** (modified, new, or deleted files) - If the folder exists and files differ, a confirmation dialog shows **only the actual changes**
- If the folder exists and nothing changed, the upload is skipped entirely ("Aucun fichier modifie") - If nothing changed, the upload is skipped entirely
6. For models: the file is written to `/tmp`, compressed with Blender Draco, then the compressed version is pushed
7. For textures: pushed directly without compression ### Upload flow: Drive first, then Git
8. **Only changed and new files are pushed** — unchanged files are skipped to save bandwidth and API calls
9. All changes are pushed in a **single commit** with a formatted message that only lists what changed: 6. **Drive upload (archiving)** — Original files (before Blender compression) are uploaded to the Nextcloud Drive with automatic versioning (see below). This serves as the artists' source of truth and version history. If the Drive upload fails, a modal asks the user whether to send to Git only or cancel entirely.
7. **Git upload (delivery to devs)** — Models are compressed with Blender Draco, then all changed files are pushed to GitHub in a single commit. This is what the dev team consumes in the application.
### Drive versioning (Nextcloud WebDAV)
The Drive uses a `VF` (version finale) / `Vx` (archived versions) structure:
```
Models/
VF/ ← latest version
coffeetest/
model.gltf
color.jpg
V1/ ← first archive
coffeetest/
V2/ ← second archive
coffeetest/
```
- **New folder** (doesn't exist in `VF/`): files are uploaded directly to `VF/{folderName}/`
- **Replace** (folder exists in `VF/` with diffs): `VF/{folderName}` is moved to `Vx/{folderName}` (next available version), then all files are re-uploaded to `VF/{folderName}/`
- **No changes**: nothing happens on the Drive
All files are uploaded to `VF/` (not just diffs), because the move operation empties the previous folder.
### Commit messages
All changes are pushed in a **single commit** with a formatted message:
**New folder:** **New folder:**
``` ```
@@ -105,8 +151,8 @@ The Docker image includes Blender headless (installed once at build time). On st
Symbols: `✅` new — `🔄` modified — `❌` missing or deleted Symbols: `✅` new — `🔄` modified — `❌` missing or deleted
10. 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
11. 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
@@ -125,14 +171,43 @@ Uploaded models are pushed to `public/models/<destination>/<folderName>/` in the
``` ```
app/ app/
├── api/upload/route.ts # API: GET (check + SHA diff) + POST (compress + smart push) ├── api/upload/
├── globals.css # Tailwind + Google Fonts │ ├── check/route.ts # GET: check remote folder + file sizes for diff
├── layout.tsx # Root layout │ ├── drive/route.ts # POST: upload originals to Nextcloud Drive (VF/Vx versioning)
│ └── git/route.ts # POST: compress with Blender + push to GitHub
├── globals.css # Tailwind + CSS variable fonts
├── layout.tsx # Root layout (next/font/google)
└── page.tsx # Home page └── page.tsx # Home page
components/ components/
├── UploadZone.tsx # UI: key input, destination picker, folder picker, SHA diff, overwrite confirmation, upload ├── upload/
├── ModelViewer.tsx # Lazy wrapper for the 3D viewer │ ├── SecretInput.tsx # Access key input
│ ├── DestinationPicker.tsx # Destination selector
│ ├── FolderDropzone.tsx # Folder drag & drop / picker
│ ├── FolderCard.tsx # Folder status card (Drive + Git)
│ ├── WarningBanner.tsx # Missing texture warnings
│ ├── OverwriteConfirmModal.tsx # Diff confirmation dialog
│ ├── NoChangesModal.tsx # "No changes detected" dialog
│ ├── DriveErrorModal.tsx # "Drive failed, continue?" dialog
│ └── ActionButtons.tsx # Upload / Cancel / Reset buttons
├── UploadZone.tsx # Main orchestrator (Drive → Git flow)
├── ModelViewer.tsx # Lazy wrapper for 3D viewer
└── SceneViewer.tsx # Three.js Canvas └── SceneViewer.tsx # Three.js Canvas
hooks/
├── useSecret.ts # Secret key state management
└── useFolderEntries.ts # Folder entries state management
lib/
├── constants.ts # Shared constants, destinations, extensions
├── types.ts # Server types (ParsedFile, FileDiff, etc.)
├── client-types.ts # Client types (FolderEntry, DriveStatus, etc.)
├── sanitize.ts # Filename sanitization
├── auth.ts # Upload secret validation (timing-safe)
├── github.ts # Octokit helpers (getRemoteFolder, pushAllToGitHub)
├── nextcloud.ts # Nextcloud WebDAV client (native fetch)
├── blender.ts # Blender Draco compression
├── commit-message.ts # Commit message builder
├── parse-upload.ts # FormData parser + validation
├── validate-folder.ts # Client-side folder validation
└── format-bytes.ts # Byte formatting utility
scripts/ scripts/
└── compress.py # Blender Draco compression script └── compress.py # Blender Draco compression script
Dockerfile # Multi-stage build: Node 20 slim + Blender headless + tini Dockerfile # Multi-stage build: Node 20 slim + Blender headless + tini
+95 -14
View File
@@ -1,26 +1,107 @@
import { NextResponse } from 'next/server' import { NextRequest, NextResponse } from 'next/server'
import { validateUploadSecret } from '@/lib/auth'
import { parseMultiUpload } from '@/lib/parse-upload'
import {
folderExists,
mkdirRecursive,
moveFolder,
uploadFile,
findNextVersion,
} from '@/lib/nextcloud'
export const runtime = 'nodejs' export const runtime = 'nodejs'
export const dynamic = 'force-dynamic' export const dynamic = 'force-dynamic'
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// TODO: POST /api/upload/drive // POST /api/upload/drive
// //
// Upload files and push them to Google Drive. // Upload **original** files (no Blender compression) to Nextcloud Drive.
// This route will share the same auth (lib/auth.ts), parsing (lib/parse-upload.ts),
// and Blender compression (lib/blender.ts) as the git route.
// //
// Implementation steps: // FormData fields:
// 1. Add `googleapis` package: npm install googleapis // - folderName, destination, files[], fileTypes[], textureNames[] (same as /api/upload/git)
// 2. Add env vars: GOOGLE_DRIVE_FOLDER_ID, GOOGLE_SERVICE_ACCOUNT_KEY (JSON) // - action: "new" | "replace"
// 3. Authenticate with Google Drive API using service account //
// 4. Upload files to the target folder (create subfolders as needed) // Versioning logic:
// 5. Return the Drive folder URL // VF/{folderName} ← latest version
// V1/{folderName} ← first archive, V2/ second, etc.
//
// action="new" → just mkdir + upload into VF/
// action="replace" → archive VF → Vx, then re-upload all files into VF/
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export async function POST() { export async function POST(req: NextRequest) {
// --- Auth ---
const authError = validateUploadSecret(req)
if (authError) return authError
// --- Check Nextcloud config ---
if (!process.env.NEXTCLOUD_URL || !process.env.NEXTCLOUD_USER || !process.env.NEXTCLOUD_PASSWORD) {
return NextResponse.json( return NextResponse.json(
{ success: false, error: 'Google Drive upload not implemented yet' }, { success: false, error: 'Nextcloud non configure sur le serveur' },
{ status: 501 }, { status: 500 },
) )
} }
// --- Parse files ---
// Clone the request before parseMultiUpload consumes the body stream,
// so we can read the `action` field separately.
const cloned = req.clone()
let folderName: string
let parsedFiles: Awaited<ReturnType<typeof parseMultiUpload>>['files']
let action: string
try {
const parsed = await parseMultiUpload(req)
folderName = parsed.folderName
parsedFiles = parsed.files
// Read action from the cloned request (parseMultiUpload doesn't expose it)
const formData = await cloned.formData()
action = (formData.get('action') as string | null)?.trim() || 'new'
} catch (err) {
const message = err instanceof Error ? err.message : 'Erreur inconnue'
return NextResponse.json({ success: false, error: message }, { status: 400 })
}
const basePath = process.env.NEXTCLOUD_BASE_PATH || 'Models'
const vfFolderPath = `${basePath}/VF/${folderName}`
try {
if (action === 'replace') {
// 1. Find the next available Vx
const nextVersion = await findNextVersion(basePath, folderName)
// 2. Ensure Vx/ exists
await mkdirRecursive(`${basePath}/${nextVersion}`)
// 3. Move VF/{folderName} → Vx/{folderName}
await moveFolder(vfFolderPath, `${basePath}/${nextVersion}/${folderName}`)
// 4. Re-create VF/{folderName}
await mkdirRecursive(vfFolderPath)
} else {
// action === 'new': just ensure VF/{folderName} exists
await mkdirRecursive(vfFolderPath)
}
// --- Upload all original files ---
for (const pf of parsedFiles) {
const remotePath = `${vfFolderPath}/${pf.filename}`
await uploadFile(remotePath, pf.buffer)
}
return NextResponse.json({
success: true,
folderName,
filesCount: parsedFiles.length,
message: `${parsedFiles.length} fichier(s) envoye(s) sur le Drive.`,
})
} catch (err) {
const message = err instanceof Error ? err.message : 'Erreur Nextcloud inconnue'
return NextResponse.json(
{ success: false, error: `Drive echoue: ${message}` },
{ status: 500 },
)
}
}
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

+2 -2
View File
@@ -8,8 +8,8 @@ export default function Home() {
Upload GLTF Upload GLTF
</h1> </h1>
<p className="text-gray-400 text-base leading-relaxed"> <p className="text-gray-400 text-base leading-relaxed">
Deposez vos fichiers 3D ils seront automatiquement versionnes Deposez vos fichiers 3D ils seront archives sur le Drive
<br />et envoyes sur votre depot GitHub. <br />avec versioning, puis envoyes aux devs via GitHub.
</p> </p>
</div> </div>
+183 -24
View File
@@ -1,8 +1,7 @@
'use client' 'use client'
import { useState, useRef } from 'react' import { useState, useRef, useCallback } from 'react'
import type { Destination } from '@/lib/constants' import type { Destination } from '@/lib/constants'
import { DESTINATIONS } 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 { useSecret } from '@/hooks/useSecret' import { useSecret } from '@/hooks/useSecret'
@@ -14,11 +13,11 @@ import FolderCard from './upload/FolderCard'
import ActionButtons from './upload/ActionButtons' import ActionButtons from './upload/ActionButtons'
import OverwriteConfirmModal from './upload/OverwriteConfirmModal' import OverwriteConfirmModal from './upload/OverwriteConfirmModal'
import NoChangesModal from './upload/NoChangesModal' import NoChangesModal from './upload/NoChangesModal'
import DriveErrorModal from './upload/DriveErrorModal'
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// API helpers // API helpers
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
interface CheckResult { interface CheckResult {
exists: boolean exists: boolean
diffs: FileDiff[] diffs: FileDiff[]
@@ -48,15 +47,13 @@ async function checkFolderDiffs(
const localNames = new Set<string>() const localNames = new Set<string>()
// Model: skip size comparison (compression changes the size). // Model: skip size comparison (compression changes the size).
// We only check if it exists on remote or not.
const modelKey = folder.modelFile.name.toLowerCase() const modelKey = folder.modelFile.name.toLowerCase()
localNames.add(modelKey) localNames.add(modelKey)
if (!remoteMap.has(modelKey)) { if (!remoteMap.has(modelKey)) {
diffs.push({ name: folder.modelFile.name, status: 'new' }) diffs.push({ name: folder.modelFile.name, status: 'new' })
} }
// If model exists on remote → don't add to diffs (we can't know if it changed)
// Textures: compare by size (not compressed, so size is reliable) // Textures: compare by size
for (const tex of folder.textures) { for (const tex of folder.textures) {
const key = tex.name.toLowerCase() const key = tex.name.toLowerCase()
localNames.add(key) localNames.add(key)
@@ -68,7 +65,7 @@ async function checkFolderDiffs(
} }
} }
// Files on remote but not in local → deleted // Deleted
for (const [name] of remoteMap) { for (const [name] of remoteMap) {
if (!localNames.has(name)) { if (!localNames.has(name)) {
diffs.push({ name, status: 'deleted' }) diffs.push({ name, status: 'deleted' })
@@ -81,7 +78,49 @@ async function checkFolderDiffs(
} }
} }
async function uploadFolder( /** Upload original files to Nextcloud Drive (no Blender compression). */
async function uploadDrive(
folder: FolderEntry,
secret: string,
destination: string,
action: 'new' | 'replace',
signal?: AbortSignal,
): Promise<{ success: boolean; error?: string }> {
const formData = new FormData()
formData.append('folderName', folder.folderName)
formData.append('destination', destination)
formData.append('action', action)
formData.append('files', folder.modelFile)
formData.append('fileTypes', 'model')
formData.append('textureNames', '')
for (const tex of folder.textures) {
formData.append('files', tex.file)
formData.append('fileTypes', 'texture')
formData.append('textureNames', tex.name)
}
try {
const res = await fetch('/api/upload/drive', {
method: 'POST',
headers: { 'x-upload-secret': secret.trim() },
body: formData,
signal,
})
const data = await res.json()
if (!data.success) return { success: false, error: data.error }
return { success: true }
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') {
return { success: false, error: 'Upload annule' }
}
return { success: false, error: 'Erreur reseau (Drive)' }
}
}
/** Upload files to GitHub (with Blender compression). */
async function uploadGit(
folder: FolderEntry, folder: FolderEntry,
secret: string, secret: string,
destination: string, destination: string,
@@ -132,7 +171,6 @@ async function uploadFolder(
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// UploadZone — orchestrator // UploadZone — orchestrator
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export default function UploadZone() { export default function UploadZone() {
const { const {
secret, secret,
@@ -156,14 +194,24 @@ export default function UploadZone() {
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>(DESTINATIONS[0].value) const [destination, setDestination] = useState<Destination | null>(null)
const [overwriteConfirm, setOverwriteConfirm] = useState<{ const [overwriteConfirm, setOverwriteConfirm] = useState<{
folderName: string folderName: string
diffs: FileDiff[] diffs: FileDiff[]
} | null>(null) } | null>(null)
const [noChangesFolder, setNoChangesFolder] = useState<string | null>(null) const [noChangesFolder, setNoChangesFolder] = useState<string | null>(null)
// Drive error modal state
const [driveError, setDriveError] = useState<{
error: string
folderIndex: number
} | null>(null)
const abortRef = useRef<AbortController | null>(null) const abortRef = useRef<AbortController | null>(null)
// Tracks the check result so we know if it's "new" or "replace" for the Drive
const checkResultRef = useRef<CheckResult>({ exists: false, diffs: [] })
// -- Handlers -- // -- Handlers --
const handleFolderSelected = (entry: FolderEntry) => { const handleFolderSelected = (entry: FolderEntry) => {
@@ -183,13 +231,19 @@ export default function UploadZone() {
setSecretError("La cle d'acces est requise") setSecretError("La cle d'acces est requise")
return return
} }
if (!destination) {
setGlobalError('Veuillez choisir une destination')
return
}
if (entries.length === 0) return if (entries.length === 0) return
setSecretError(null) setSecretError(null)
setGlobalError(null) setGlobalError(null)
const folder = entries[0] const folder = entries[0]
const check = await checkFolderDiffs(folder, destination, secret, abortRef.current?.signal) const check = await checkFolderDiffs(folder, destination!, secret, abortRef.current?.signal)
checkResultRef.current = check
if (check.exists) { if (check.exists) {
if (check.diffs.length === 0) { if (check.diffs.length === 0) {
setNoChangesFolder(folder.folderName) setNoChangesFolder(folder.folderName)
@@ -202,6 +256,10 @@ export default function UploadZone() {
await proceedUpload() await proceedUpload()
} }
/**
* Main upload flow: Drive first, then Git.
* If Drive fails, show DriveErrorModal so user can decide.
*/
const proceedUpload = async () => { const proceedUpload = async () => {
setOverwriteConfirm(null) setOverwriteConfirm(null)
setIsUploading(true) setIsUploading(true)
@@ -214,28 +272,118 @@ export default function UploadZone() {
if (entries[i].status === 'success') continue if (entries[i].status === 'success') continue
if (controller.signal.aborted) break if (controller.signal.aborted) break
updateEntry(i, { status: 'uploading', progress: 0, error: undefined }) const folderEntry = entries[i]
const driveAction = checkResultRef.current.exists ? 'replace' : 'new'
const result = await uploadFolder( // ---- Step 1: Drive upload ----
entries[i], updateEntry(i, {
status: 'uploading',
progress: 0,
error: undefined,
driveStatus: 'uploading',
driveError: undefined,
})
const driveResult = await uploadDrive(
folderEntry,
secret, secret,
destination, destination!,
(pct) => updateEntry(i, { progress: pct }), driveAction as 'new' | 'replace',
controller.signal, controller.signal,
) )
updateEntry(i, { if (!driveResult.success) {
status: result.success ? 'success' : 'error', // Drive failed — pause and ask user
progress: result.success ? 100 : 0, updateEntry(i, { driveStatus: 'error', driveError: driveResult.error })
error: result.success ? undefined : result.error, setDriveError({ error: driveResult.error || 'Erreur inconnue', folderIndex: i })
filename: result.filename, // Stop here — the DriveErrorModal callbacks will resume or cancel
}) return
}
updateEntry(i, { driveStatus: 'success' })
// ---- Step 2: Git upload ----
await pushGit(i, controller.signal)
} }
abortRef.current = null abortRef.current = null
setIsUploading(false) setIsUploading(false)
} }
/** Push a single folder to Git. Called after Drive succeeds or user skips Drive. */
const pushGit = async (index: number, signal?: AbortSignal) => {
const folderEntry = entries[index]
updateEntry(index, { progress: 5 })
const gitResult = await uploadGit(
folderEntry,
secret,
destination!,
(pct) => updateEntry(index, { progress: pct }),
signal,
)
updateEntry(index, {
status: gitResult.success ? 'success' : 'error',
progress: gitResult.success ? 100 : 0,
error: gitResult.success ? undefined : gitResult.error,
filename: gitResult.filename,
})
}
/** User chose "Continue without Drive" in DriveErrorModal. */
const handleDriveContinue = useCallback(async () => {
if (!driveError) return
const idx = driveError.folderIndex
setDriveError(null)
updateEntry(idx, { driveStatus: 'skipped' })
// Continue with Git
const signal = abortRef.current?.signal
await pushGit(idx, signal)
// Continue with remaining entries
for (let i = idx + 1; i < entries.length; i++) {
if (entries[i].status === 'success') continue
if (abortRef.current?.signal.aborted) break
// For subsequent entries after a Drive skip, also skip Drive
updateEntry(i, {
status: 'uploading',
progress: 0,
error: undefined,
driveStatus: 'skipped',
})
await pushGit(i, abortRef.current?.signal)
}
abortRef.current = null
setIsUploading(false)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [driveError, entries, secret, destination])
/** User chose "Cancel" in DriveErrorModal. */
const handleDriveCancel = useCallback(() => {
if (!driveError) return
const idx = driveError.folderIndex
setDriveError(null)
updateEntry(idx, {
status: 'error',
progress: 0,
error: 'Upload annule (Drive echoue)',
driveStatus: 'error',
})
abortRef.current?.abort()
abortRef.current = null
setIsUploading(false)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [driveError])
const handleCancel = () => { const handleCancel = () => {
abortRef.current?.abort() abortRef.current?.abort()
abortRef.current = null abortRef.current = null
@@ -246,6 +394,8 @@ export default function UploadZone() {
resetEntries() resetEntries()
setGlobalError(null) setGlobalError(null)
setIsUploading(false) setIsUploading(false)
setDriveError(null)
checkResultRef.current = { exists: false, diffs: [] }
} }
const hasPendingOrErrors = entries.some((f) => f.status === 'pending' || f.status === 'error') const hasPendingOrErrors = entries.some((f) => f.status === 'pending' || f.status === 'error')
@@ -298,6 +448,7 @@ 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}
@@ -308,7 +459,7 @@ export default function UploadZone() {
{overwriteConfirm && ( {overwriteConfirm && (
<OverwriteConfirmModal <OverwriteConfirmModal
destination={destination} destination={destination!}
folderName={overwriteConfirm.folderName} folderName={overwriteConfirm.folderName}
diffs={overwriteConfirm.diffs} diffs={overwriteConfirm.diffs}
onCancel={() => setOverwriteConfirm(null)} onCancel={() => setOverwriteConfirm(null)}
@@ -318,7 +469,7 @@ export default function UploadZone() {
{noChangesFolder && ( {noChangesFolder && (
<NoChangesModal <NoChangesModal
destination={destination} destination={destination!}
folderName={noChangesFolder} folderName={noChangesFolder}
onCancel={() => { onCancel={() => {
setNoChangesFolder(null) setNoChangesFolder(null)
@@ -327,6 +478,14 @@ export default function UploadZone() {
onModify={() => setNoChangesFolder(null)} onModify={() => setNoChangesFolder(null)}
/> />
)} )}
{driveError && (
<DriveErrorModal
error={driveError.error}
onCancel={handleDriveCancel}
onContinue={handleDriveContinue}
/>
)}
</div> </div>
) )
} }
+7 -3
View File
@@ -1,6 +1,7 @@
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
@@ -12,6 +13,7 @@ interface ActionButtonsProps {
export default function ActionButtons({ export default function ActionButtons({
isUploading, isUploading,
isSecretEmpty, isSecretEmpty,
noDestination,
hasPendingOrErrors, hasPendingOrErrors,
allDone, allDone,
hasErrors, hasErrors,
@@ -19,20 +21,22 @@ export default function ActionButtons({
onCancel, onCancel,
onReset, onReset,
}: ActionButtonsProps) { }: ActionButtonsProps) {
const cantUpload = isSecretEmpty || noDestination
return ( return (
<div className="flex gap-3"> <div className="flex gap-3">
{!isUploading && hasPendingOrErrors && ( {!isUploading && hasPendingOrErrors && (
<button <button
onClick={onUpload} onClick={onUpload}
disabled={isSecretEmpty} disabled={cantUpload}
className={`flex-1 font-medium text-sm py-2.5 px-6 rounded-xl transition-all duration-150 className={`flex-1 font-medium text-sm py-2.5 px-6 rounded-xl transition-all duration-150
focus:outline-none focus:ring-2 focus:ring-white/50 border border-white/20 focus:outline-none focus:ring-2 focus:ring-white/50 border border-white/20
${isSecretEmpty ${cantUpload
? 'bg-white/30 text-gray-500 cursor-not-allowed' ? 'bg-white/30 text-gray-500 cursor-not-allowed'
: 'bg-white text-[#000000] hover:bg-gray-200' : 'bg-white text-[#000000] hover:bg-gray-200'
}`} }`}
> >
Envoyer sur GitHub Envoyer
</button> </button>
)} )}
+1 -1
View File
@@ -2,7 +2,7 @@ import { DESTINATIONS } from '@/lib/constants'
import type { Destination } from '@/lib/constants' import type { Destination } from '@/lib/constants'
interface DestinationPickerProps { interface DestinationPickerProps {
destination: Destination destination: Destination | null
disabled: boolean disabled: boolean
onChange: (value: Destination) => void onChange: (value: Destination) => void
} }
+69
View File
@@ -0,0 +1,69 @@
interface DriveErrorModalProps {
error: string
onCancel: () => void
onContinue: () => void
}
export default function DriveErrorModal({
error,
onCancel,
onContinue,
}: DriveErrorModalProps) {
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
role="dialog"
aria-modal="true"
aria-labelledby="drive-error-title"
>
<div className="bg-black-900 border border-white/20 rounded-2xl p-6 max-w-md w-full mx-4 space-y-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-red-900/30 flex items-center justify-center shrink-0">
<svg className="w-5 h-5 text-red-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
</div>
<div>
<h3 id="drive-error-title" className="text-sm font-semibold text-gray-100">
Erreur Drive
</h3>
<p className="text-xs text-gray-400 mt-0.5">
L&apos;archivage sur le Drive a echoue. Les fichiers n&apos;ont pas ete versionnes.
</p>
</div>
</div>
<div className="bg-black-800 border border-white/10 rounded-xl p-3">
<p className="text-xs text-red-400 font-mono break-all">{error}</p>
</div>
<p className="text-xs text-gray-400">
Voulez-vous quand meme envoyer les fichiers aux devs via GitHub ?
</p>
<div className="flex gap-3">
<button
onClick={onCancel}
className="flex-1 bg-black-700 text-gray-300 font-medium text-sm
py-2.5 px-4 rounded-xl border border-white/10 transition-colors duration-150
hover:bg-black-600"
>
Annuler
</button>
<button
onClick={onContinue}
className="flex-1 bg-white text-[#000000] font-medium text-sm
py-2.5 px-4 rounded-xl transition-colors duration-150
hover:bg-gray-200"
>
Envoyer sur Git seulement
</button>
</div>
</div>
</div>
)
}
+40 -1
View File
@@ -67,9 +67,48 @@ export default function FolderCard({ entry, index, onToggleViewer, onRemove }: F
<span className="text-xs text-red-400 truncate">{entry.error}</span> <span className="text-xs text-red-400 truncate">{entry.error}</span>
)} )}
{entry.status === 'success' && entry.filename && ( {entry.status === 'success' && entry.filename && (
<span className="text-xs text-green-400 font-mono">{entry.filename}</span> <span className="text-xs text-green-400 font-mono">Drive + Git OK</span>
)} )}
</div> </div>
{/* Drive status sub-line */}
{entry.driveStatus && entry.driveStatus !== 'pending' && (
<div className="flex items-center gap-1.5 mt-0.5">
{entry.driveStatus === 'uploading' && (
<>
<svg className="w-3 h-3 text-gray-400 animate-spin" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
<span className="text-xs text-gray-400">Drive en cours...</span>
</>
)}
{entry.driveStatus === 'success' && (
<>
<svg className="w-3 h-3 text-green-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
<span className="text-xs text-green-400">Drive OK</span>
</>
)}
{entry.driveStatus === 'error' && (
<>
<svg className="w-3 h-3 text-red-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
<span className="text-xs text-red-400 truncate">Drive echoue{entry.driveError ? ` : ${entry.driveError}` : ''}</span>
</>
)}
{entry.driveStatus === 'skipped' && (
<>
<svg className="w-3 h-3 text-yellow-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v2m0 4h.01" />
</svg>
<span className="text-xs text-yellow-400">Drive ignore</span>
</>
)}
</div>
)}
{entry.status === 'uploading' && ( {entry.status === 'uploading' && (
<div className="mt-1.5 w-full h-1 bg-black-700 rounded-full overflow-hidden"> <div className="mt-1.5 w-full h-1 bg-black-700 rounded-full overflow-hidden">
<div <div
+1
View File
@@ -80,6 +80,7 @@ export default function FolderDropzone({
</p> </p>
<p className="text-xs text-gray-500 mt-1"> <p className="text-xs text-gray-500 mt-1">
Contenu attendu : model.glb/gltf + textures (roughness, normal, metalness, color, displace) 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> </p>
</div> </div>
</> </>
+1 -1
View File
@@ -34,7 +34,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. Le dossier <span className="font-mono text-gray-300">{destination}/{folderName}</span> est identique au contenu distant. Rien a envoyer.
</p> </p>
</div> </div>
</div> </div>
+2 -1
View File
@@ -38,7 +38,8 @@ 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 sur le repo <span className="font-mono text-yellow-400">{destination}/{folderName}</span> existe deja.
Les anciens fichiers seront archives sur le Drive, puis les nouveaux seront envoyes sur le Drive et Git.
</p> </p>
</div> </div>
</div> </div>
-6
View File
@@ -18,19 +18,13 @@ export function useSecret() {
setSecretVisible((v) => !v) setSecretVisible((v) => !v)
}, []) }, [])
const clearSecretError = useCallback(() => {
setSecretError(null)
}, [])
return { return {
secret, secret,
secretError, secretError,
secretVisible, secretVisible,
isSecretEmpty, isSecretEmpty,
setSecret,
setSecretError, setSecretError,
handleSecretChange, handleSecretChange,
toggleSecretVisible, toggleSecretVisible,
clearSecretError,
} }
} }
+4
View File
@@ -9,6 +9,8 @@ export interface TextureFile {
file: File file: File
} }
export type DriveStatus = 'pending' | 'uploading' | 'success' | 'error' | 'skipped'
export interface FolderEntry { export interface FolderEntry {
folderName: string folderName: string
modelFile: File modelFile: File
@@ -20,4 +22,6 @@ export interface FolderEntry {
modelUrl?: string modelUrl?: string
viewerOpen?: boolean viewerOpen?: boolean
warnings: string[] warnings: string[]
driveStatus?: DriveStatus
driveError?: string
} }
+3 -11
View File
@@ -1,18 +1,17 @@
import { Octokit } from '@octokit/rest' import { Octokit } from '@octokit/rest'
import { createHash } from 'crypto'
import type { RemoteFile } from './types' import type { RemoteFile } from './types'
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Octokit helpers // Octokit helpers
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export function getOctokit(): Octokit { function getOctokit(): Octokit {
const token = process.env.GITHUB_TOKEN const token = process.env.GITHUB_TOKEN
if (!token) throw new Error('GITHUB_TOKEN non configure') if (!token) throw new Error('GITHUB_TOKEN non configure')
return new Octokit({ auth: token }) return new Octokit({ auth: token })
} }
export function parseRepoUrl(): { owner: string; repo: string } { function parseRepoUrl(): { owner: string; repo: string } {
const url = process.env.GIT_REPO_URL const url = process.env.GIT_REPO_URL
if (!url) throw new Error('GIT_REPO_URL non configure') if (!url) throw new Error('GIT_REPO_URL non configure')
@@ -26,15 +25,8 @@ export function parseRepoUrl(): { owner: string; repo: string } {
return { owner: match[1], repo: match[2] } return { owner: match[1], repo: match[2] }
} }
/** Compute the SHA that Git would assign to a blob with this content */
export function computeGitBlobSha(content: Buffer): string {
const header = `blob ${content.length}\0`
const store = Buffer.concat([Buffer.from(header), content])
return createHash('sha1').update(store).digest('hex')
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Read remote folder contents (with SHA per file) // Read remote folder contents (with size per file)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export async function getRemoteFolder( export async function getRemoteFolder(
+135
View File
@@ -0,0 +1,135 @@
// ---------------------------------------------------------------------------
// Nextcloud WebDAV client
// Uses native fetch — no npm package needed.
// ---------------------------------------------------------------------------
function getConfig() {
const url = process.env.NEXTCLOUD_URL
const user = process.env.NEXTCLOUD_USER
const password = process.env.NEXTCLOUD_PASSWORD
const basePath = process.env.NEXTCLOUD_BASE_PATH || 'Models'
if (!url || !user || !password) {
throw new Error('Nextcloud non configure (NEXTCLOUD_URL, NEXTCLOUD_USER, NEXTCLOUD_PASSWORD)')
}
// WebDAV base: https://cloud.example.com/remote.php/dav/files/{user}/
const davBase = `${url.replace(/\/+$/, '')}/remote.php/dav/files/${encodeURIComponent(user)}`
const auth = 'Basic ' + Buffer.from(`${user}:${password}`).toString('base64')
return { davBase, auth, basePath }
}
function davUrl(davBase: string, path: string): string {
const clean = path.replace(/^\/+/, '').replace(/\/+$/, '')
return `${davBase}/${clean}`
}
// ---------------------------------------------------------------------------
// Low-level WebDAV helpers
// ---------------------------------------------------------------------------
async function davRequest(
method: string,
path: string,
body?: Buffer | string | null,
extraHeaders?: Record<string, string>,
): Promise<Response> {
const { davBase, auth } = getConfig()
const url = davUrl(davBase, path)
const res = await fetch(url, {
method,
headers: {
Authorization: auth,
...extraHeaders,
},
body: body ?? undefined,
})
return res
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/** Check if a folder exists on the Nextcloud instance. */
export async function folderExists(path: string): Promise<boolean> {
try {
const res = await davRequest('PROPFIND', path + '/', null, { Depth: '0' })
return res.status >= 200 && res.status < 300
} catch {
return false
}
}
/**
* Create a folder and all parent segments if they don't exist.
* Like `mkdir -p`.
*/
export async function mkdirRecursive(path: string): Promise<void> {
const segments = path.replace(/^\/+/, '').replace(/\/+$/, '').split('/')
let current = ''
for (const seg of segments) {
current += '/' + seg
const exists = await folderExists(current)
if (!exists) {
const res = await davRequest('MKCOL', current + '/')
if (res.status !== 201 && res.status !== 405) {
// 405 = already exists (race condition), that's fine
const text = await res.text().catch(() => '')
throw new Error(`MKCOL ${current} failed (${res.status}): ${text.slice(0, 200)}`)
}
}
}
}
/** Upload a file (overwrite if exists). */
export async function uploadFile(path: string, content: Buffer): Promise<void> {
const res = await davRequest('PUT', path, content, {
'Content-Type': 'application/octet-stream',
'Content-Length': String(content.length),
})
if (res.status < 200 || res.status >= 300) {
const text = await res.text().catch(() => '')
throw new Error(`PUT ${path} failed (${res.status}): ${text.slice(0, 200)}`)
}
}
/** Move (rename) a folder or file. */
export async function moveFolder(from: string, to: string): Promise<void> {
const { davBase } = getConfig()
const destination = davUrl(davBase, to) + '/'
const res = await davRequest('MOVE', from + '/', null, {
Destination: destination,
Overwrite: 'F',
})
if (res.status < 200 || res.status >= 300) {
const text = await res.text().catch(() => '')
throw new Error(`MOVE ${from} -> ${to} failed (${res.status}): ${text.slice(0, 200)}`)
}
}
// ---------------------------------------------------------------------------
// High-level: find next available version folder
// ---------------------------------------------------------------------------
/**
* Find the next available Vx folder for archiving.
* E.g. if V1/coffeetest exists but V2/coffeetest doesn't, returns "V2".
*/
export async function findNextVersion(
basePath: string,
folderName: string,
): Promise<string> {
for (let i = 1; ; i++) {
const versionPath = `${basePath}/V${i}/${folderName}`
const exists = await folderExists(versionPath)
if (!exists) return `V${i}`
}
}
-15
View File
@@ -20,18 +20,3 @@ export interface RemoteFile {
name: string name: string
size: number size: number
} }
export type UploadResponse =
| {
success: true
folderName: string
filesCount: number
compressed: boolean
compressionError?: string
message: string
commitUrl?: string
}
| {
success: false
error: string
}
+2 -2
View File
@@ -174,7 +174,7 @@ def process_file(input_path, output_path=None, draco_level=7,
if not quiet: if not quiet:
print("Importing mesh...") print("Importing mesh...")
import_output = import_mesh(input_path) import_mesh(input_path)
if len(bpy.data.objects) == 0: if len(bpy.data.objects) == 0:
raise RuntimeError(f"No objects imported from {input_path}") raise RuntimeError(f"No objects imported from {input_path}")
@@ -198,7 +198,7 @@ def process_file(input_path, output_path=None, draco_level=7,
if not quiet: if not quiet:
print(f"Exporting with Draco compression (level={draco_level})...") print(f"Exporting with Draco compression (level={draco_level})...")
export_output = export_mesh(output_path, draco_level, format_type) export_mesh(output_path, draco_level, format_type)
if not os.path.exists(output_path): if not os.path.exists(output_path):
raise RuntimeError(f"Export failed: {output_path} not created") raise RuntimeError(f"Export failed: {output_path} not created")
-13
View File
@@ -9,19 +9,12 @@ const config: Config = {
extend: { extend: {
colors: { colors: {
black: { black: {
50: '#F7F7F7',
100: '#E5E5E5',
200: '#CCCCCC',
300: '#999999',
400: '#666666',
500: '#333333',
600: '#1A1A1A', 600: '#1A1A1A',
700: '#0D0D0D', 700: '#0D0D0D',
800: '#050505', 800: '#050505',
900: '#000000', 900: '#000000',
}, },
gray: { gray: {
50: '#FAFAFA',
100: '#F5F5F5', 100: '#F5F5F5',
200: '#E5E5E5', 200: '#E5E5E5',
300: '#D4D4D4', 300: '#D4D4D4',
@@ -29,18 +22,12 @@ const config: Config = {
500: '#737373', 500: '#737373',
600: '#525252', 600: '#525252',
700: '#404040', 700: '#404040',
800: '#262626',
900: '#171717',
}, },
}, },
fontFamily: { fontFamily: {
sans: ['var(--font-inter)', 'system-ui', 'sans-serif'], sans: ['var(--font-inter)', 'system-ui', 'sans-serif'],
mono: ['var(--font-jetbrains-mono)', 'Fira Code', 'monospace'], mono: ['var(--font-jetbrains-mono)', 'Fira Code', 'monospace'],
}, },
boxShadow: {
soft: '0 2px 16px 0 rgba(0, 0, 0, 0.06)',
card: '0 4px 24px 0 rgba(0, 0, 0, 0.08)',
},
}, },
}, },
plugins: [], plugins: [],