diff --git a/.env.example b/.env.example index 09c38e5..80dd4c3 100644 --- a/.env.example +++ b/.env.example @@ -1,4 +1,5 @@ UPLOAD_SECRET_KEY=your-secret-key-here GITHUB_TOKEN=ghp_your-github-personal-access-token GIT_BRANCH=main -GIT_REPO_URL=https://github.com/your-org/your-repo.git \ No newline at end of file +GIT_REPO_URL=https://github.com/your-org/your-repo.git +BLENDER_PATH=/Applications/Blender.app/Contents/MacOS/Blender \ No newline at end of file diff --git a/README.md b/README.md index 08446c2..7f0eff7 100644 --- a/README.md +++ b/README.md @@ -28,17 +28,18 @@ UPLOAD_SECRET_KEY=your-secret-key-here GITHUB_TOKEN=ghp_your-github-personal-access-token GIT_BRANCH=main GIT_REPO_URL=https://github.com/your-org/your-repo.git +BLENDER_PATH=/Applications/Blender.app/Contents/MacOS/Blender ``` | Variable | Description | Required | |----------|-------------|----------| | `UPLOAD_SECRET_KEY` | Secret key for upload authentication | Yes | -| `GITHUB_TOKEN` | GitHub Personal Access Token (scope `repo`) | Yes | +| `GITHUB_TOKEN` | GitHub Personal Access Token (fine-grained, `Contents: Read and write`) | Yes | | `GIT_BRANCH` | Target branch (default: main) | No | | `GIT_REPO_URL` | Target GitHub repository URL | Yes | | `BLENDER_PATH` | Path to Blender binary (default: `blender`) | No | -> To create a token: GitHub > Settings > Developer settings > Personal access tokens > Generate new token (classic) with the `repo` scope. +> To create a token: GitHub > Settings > Developer settings > Fine-grained personal access tokens > select the target repo > Permissions > Contents: Read and write. ## Usage @@ -50,7 +51,7 @@ npm run dev Access the app at `http://localhost:3000` -> **Note:** Draco compression requires Blender installed locally. If Blender is not available, models are pushed to GitHub without compression. +> **Note:** Draco compression requires Blender installed locally. On macOS, Blender is typically at `/Applications/Blender.app/Contents/MacOS/Blender`. Set `BLENDER_PATH` in `.env.local` accordingly. If Blender is not available, models are pushed to GitHub without compression. ### Production (Coolify / Docker) @@ -68,25 +69,53 @@ The Dockerfile includes Blender headless for automatic Draco compression. ## How it works 1. The user enters their access key -2. They select a folder containing: +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) -3. The model is displayed in a 3D preview -4. On clicking "Upload & Push to GitHub", each file is sent to the `/api/upload` endpoint -5. For models: the file is written to `/tmp`, compressed with Blender Draco, then the compressed version is pushed to GitHub -6. For textures: pushed directly to GitHub without compression -7. If Blender is unavailable, the original model is pushed as-is (graceful fallback) +4. The model is displayed in a 3D preview +5. On clicking "Envoyer sur GitHub": + - The app checks if the folder already exists on the remote repo + - If it exists, a confirmation dialog is shown listing the existing files that will be replaced + - On confirmation (or if the folder is new), all files are sent to the `/api/upload` endpoint +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 +8. All files are pushed in a **single commit** with a formatted message: + ``` + update: upload-gltf add a new model -> farm/my-model + + 📦 Model + ✅ model.gltf (compressed) + 🎨 Textures + ✅ color.jpg + ❌ metalness (manquant) + ``` +9. If the folder already existed, orphan files (present in the old version but not in the new upload) are deleted in the same commit +10. If Blender is unavailable, the original model is pushed as-is (graceful fallback) + +## Destinations + +Uploaded models are pushed to `public/models///` 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 ``` app/ -├── api/upload/route.ts # API: validation + Draco compression + GitHub push +├── api/upload/route.ts # API: GET (check existence) + POST (compress + push) ├── globals.css # Tailwind + Google Fonts ├── layout.tsx # Root layout └── page.tsx # Home page components/ -├── UploadZone.tsx # UI: key input, folder picker, validation, upload +├── UploadZone.tsx # UI: key input, destination picker, folder picker, validation, overwrite confirmation, upload ├── ModelViewer.tsx # Lazy wrapper for the 3D viewer └── SceneViewer.tsx # Three.js Canvas scripts/ diff --git a/app/api/upload/route.ts b/app/api/upload/route.ts index 7f0b8d1..585e8df 100644 --- a/app/api/upload/route.ts +++ b/app/api/upload/route.ts @@ -15,6 +15,7 @@ const MODEL_EXTENSIONS = new Set(['.glb', '.gltf']) const TEXTURE_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.webp']) const ALL_ALLOWED_EXTENSIONS = new Set([...MODEL_EXTENSIONS, ...TEXTURE_EXTENSIONS]) const REQUIRED_TEXTURES = ['roughness', 'normal', 'metalness', 'color', 'displace'] +const VALID_DESTINATIONS = new Set(['farm', 'map', 'powergrid', 'workshop', 'general', 'environment']) const TMP_DIR = join('/tmp', 'assets') @@ -89,11 +90,12 @@ async function compressWithBlender( function buildCommitMessage( folderName: string, + destination: string, modelFilename: string, textureNames: string[], compressed: boolean, ): string { - const title = `update: from upload-gltf add a new model -> ${folderName}` + const title = `update: upload-gltf add a new model -> ${destination}/${folderName}` const foundTextures = new Set( textureNames.map(t => t.toLowerCase().replace(/\.[^.]+$/, '')) @@ -111,8 +113,7 @@ function buildCommitMessage( title, '', '📦 Model', - ` ✅ ${modelFilename}${compressed ? ' (Draco)' : ''}`, - '', + ` ✅ ${modelFilename}${compressed ? ' (compressed)' : ''}`, '🎨 Textures', ...textureLines, ] @@ -133,12 +134,19 @@ interface ParsedFile { async function parseMultiUpload(req: NextRequest): Promise<{ folderName: string + destination: string files: ParsedFile[] }> { const formData = await req.formData() 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 fileEntries = formData.getAll('files') as File[] const fileTypes = formData.getAll('fileTypes') as string[] const textureNames = formData.getAll('textureNames') as string[] @@ -176,7 +184,7 @@ async function parseMultiUpload(req: NextRequest): Promise<{ parsed.push({ filename, buffer, isModel, textureName: texName || undefined }) } - return { folderName: safeFolderName, files: parsed } + return { folderName: safeFolderName, destination, files: parsed } } // --------------------------------------------------------------------------- @@ -186,6 +194,7 @@ async function parseMultiUpload(req: NextRequest): Promise<{ async function pushAllToGitHub( folderName: string, files: { path: string; contentBase64: string }[], + deletePaths: string[], commitMessage: string ): Promise<{ commitUrl: string }> { const octokit = getOctokit() @@ -219,17 +228,30 @@ async function pushAllToGitHub( ) ) - // 4. Create a single tree with all files + // 4. Create a single tree with all files (add new + delete orphans) + const newFilePaths = new Set(files.map(f => f.path)) + const deleteEntries = deletePaths + .filter(p => !newFilePaths.has(p)) + .map(p => ({ + path: p, + mode: '100644' as const, + type: 'blob' as const, + sha: null, + })) + const { data: newTree } = await octokit.git.createTree({ owner, repo, base_tree: commit.tree.sha, - tree: files.map((f, i) => ({ - path: f.path, - mode: '100644' as const, - type: 'blob' as const, - sha: blobResults[i].data.sha, - })), + tree: [ + ...files.map((f, i) => ({ + path: f.path, + mode: '100644' as const, + type: 'blob' as const, + sha: blobResults[i].data.sha, + })), + ...deleteEntries, + ], }) // 5. Create a single commit @@ -252,6 +274,65 @@ async function pushAllToGitHub( return { commitUrl: newCommit.html_url } } +// --------------------------------------------------------------------------- +// GET handler — check if folder already exists on remote +// --------------------------------------------------------------------------- + +export async function GET(req: NextRequest) { + const { searchParams } = new URL(req.url) + const destination = searchParams.get('destination')?.trim() + const folderName = searchParams.get('folderName')?.trim() + const secret = req.headers.get('x-upload-secret') + const expectedSecret = process.env.UPLOAD_SECRET_KEY + + if (!expectedSecret || !secret || secret !== expectedSecret) { + return NextResponse.json({ success: false, error: 'Non autorise' }, { status: 401 }) + } + + if (!destination || !folderName) { + return NextResponse.json({ success: false, error: 'Parametres manquants' }, { status: 400 }) + } + + if (!VALID_DESTINATIONS.has(destination)) { + return NextResponse.json({ success: false, error: 'Destination invalide' }, { status: 400 }) + } + + const safeFolderName = sanitizeFilename(folderName).replace(/[^a-zA-Z0-9-_]/g, '-') + const folderPath = `public/models/${destination}/${safeFolderName}` + + try { + const octokit = getOctokit() + const { owner, repo } = parseRepoUrl() + const branch = process.env.GIT_BRANCH ?? 'main' + + const { data } = await octokit.repos.getContent({ + owner, + repo, + path: folderPath, + ref: branch, + }) + + if (Array.isArray(data)) { + const existingFiles = data.map(f => f.name) + return NextResponse.json({ + success: true, + exists: true, + path: folderPath, + files: existingFiles, + }) + } + + return NextResponse.json({ success: true, exists: false }) + } catch (err: unknown) { + const status = (err as { status?: number })?.status + if (status === 404) { + return NextResponse.json({ success: true, exists: false }) + } + const message = err instanceof Error ? err.message : 'Erreur inconnue' + return NextResponse.json({ success: false, error: message }, { status: 500 }) + } +} + // --------------------------------------------------------------------------- // POST handler // --------------------------------------------------------------------------- @@ -277,10 +358,11 @@ export async function POST(req: NextRequest) { // --- Parse all files --- let folderName: string + let destination: string let parsedFiles: ParsedFile[] try { - ;({ folderName, files: parsedFiles } = await parseMultiUpload(req)) + ;({ folderName, destination, files: parsedFiles } = await parseMultiUpload(req)) } catch (err) { const message = err instanceof Error ? err.message : 'Erreur inconnue' return NextResponse.json({ success: false, error: message }, { status: 400 }) @@ -325,17 +407,40 @@ export async function POST(req: NextRequest) { } filesToPush.push({ - path: `public/assets/${folderName}/${pf.filename}`, + path: `public/models/${destination}/${folderName}/${pf.filename}`, contentBase64: content.toString('base64'), }) } // --- Build commit message --- - const commitMessage = buildCommitMessage(folderName, modelFilename, textureNames, compressed) + const commitMessage = buildCommitMessage(folderName, destination, modelFilename, textureNames, compressed) + + // --- Detect existing files to clean up orphans --- + const folderPath = `public/models/${destination}/${folderName}` + let existingFilePaths: string[] = [] + + try { + const octokit = getOctokit() + const { owner, repo } = parseRepoUrl() + const branch = process.env.GIT_BRANCH ?? 'main' + + const { data } = await octokit.repos.getContent({ + owner, + repo, + path: folderPath, + ref: branch, + }) + + if (Array.isArray(data)) { + existingFilePaths = data.map(f => `${folderPath}/${f.name}`) + } + } catch { + // 404 = folder doesn't exist yet, no cleanup needed + } // --- Push all in one commit --- try { - const { commitUrl } = await pushAllToGitHub(folderName, filesToPush, commitMessage) + const { commitUrl } = await pushAllToGitHub(folderName, filesToPush, existingFilePaths, commitMessage) return NextResponse.json({ success: true, diff --git a/components/UploadZone.tsx b/components/UploadZone.tsx index 97a181f..89fc12f 100644 --- a/components/UploadZone.tsx +++ b/components/UploadZone.tsx @@ -28,6 +28,15 @@ interface FolderEntry { const REQUIRED_TEXTURES = ['roughness', 'normal', 'metalness', 'color', 'displace'] const TEXTURE_EXTENSIONS = ['.png', '.jpg', '.jpeg', '.webp'] +const DESTINATIONS = [ + { value: 'farm', label: 'Farm' }, + { value: 'map', label: 'Map' }, + { value: 'powergrid', label: 'Powergrid' }, + { value: 'workshop', label: 'Workshop' }, + { value: 'general', label: 'General' }, + { value: 'environment', label: 'Environment' }, +] as const + function formatBytes(bytes: number): string { if (bytes === 0) return '0 B' const k = 1024 @@ -79,13 +88,35 @@ function validateFolder(files: File[]): { model?: File; textures: TextureFile[]; return result } +async function checkFolderExists( + folderName: string, + destination: string, + secret: string, +): Promise<{ exists: boolean; files: string[] }> { + try { + const params = new URLSearchParams({ folderName, destination }) + const res = await fetch(`/api/upload?${params}`, { + headers: { 'x-upload-secret': secret.trim() }, + }) + const data = await res.json() + if (data.success && data.exists) { + return { exists: true, files: data.files || [] } + } + return { exists: false, files: [] } + } catch { + return { exists: false, files: [] } + } +} + async function uploadFolder( folder: FolderEntry, secret: string, + destination: string, onProgress: (pct: number) => void ): Promise<{ success: boolean; filename?: string; error?: string }> { const formData = new FormData() formData.append('folderName', folder.folderName) + formData.append('destination', destination) // Model file formData.append('files', folder.modelFile) @@ -130,7 +161,9 @@ export default function UploadZone() { const [secretVisible, setSecretVisible] = useState(false) const [globalError, setGlobalError] = useState(null) const [secretError, setSecretError] = useState(null) + const [destination, setDestination] = useState(DESTINATIONS[0].value) const [abortController, setAbortController] = useState(null) + const [overwriteConfirm, setOverwriteConfirm] = useState<{ folderName: string; files: string[] } | null>(null) const isSecretEmpty = !secret.trim() @@ -146,6 +179,21 @@ export default function UploadZone() { if (files.length === 0) return setSecretError(null) + setGlobalError(null) + + // Check if folder already exists on remote + const folder = files[0] + const check = await checkFolderExists(folder.folderName, destination, secret) + if (check.exists) { + setOverwriteConfirm({ folderName: folder.folderName, files: check.files }) + return + } + + await proceedUpload() + } + + const proceedUpload = async () => { + setOverwriteConfirm(null) setIsUploading(true) setGlobalError(null) @@ -157,6 +205,7 @@ export default function UploadZone() { const result = await uploadFolder( files[i], secret, + destination, (pct) => updateFile(i, { progress: pct }) ) @@ -237,6 +286,28 @@ export default function UploadZone() { )} +
+ +
+ {DESTINATIONS.map((dest) => ( + + ))} +
+
+ )} + + {overwriteConfirm && ( +
+
+
+
+ + + +
+
+

Dossier deja existant

+

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

+
+
+ + {overwriteConfirm.files.length > 0 && ( +
+

Fichiers existants qui seront remplaces :

+
    + {overwriteConfirm.files.map((f) => ( +
  • {f}
  • + ))} +
+
+ )} + +

+ Les anciens fichiers seront supprimes et remplaces par les nouveaux. Cette action est irreversible. +

+ +
+ + +
+
+
+ )} ) } \ No newline at end of file