fix: one commit

This commit is contained in:
Tom Boullay
2026-04-14 12:17:06 +02:00
parent 76d9c21929
commit c795082ca4
5 changed files with 247 additions and 198 deletions
-3
View File
@@ -2,6 +2,3 @@ UPLOAD_SECRET_KEY=your-secret-key-here
GITHUB_TOKEN=ghp_your-github-personal-access-token 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
# Optional: path to Blender binary (defaults to "blender" in PATH)
# BLENDER_PATH=/usr/bin/blender
+133 -64
View File
@@ -14,6 +14,7 @@ export const dynamic = 'force-dynamic'
const MODEL_EXTENSIONS = new Set(['.glb', '.gltf']) const MODEL_EXTENSIONS = new Set(['.glb', '.gltf'])
const TEXTURE_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.webp']) const TEXTURE_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.webp'])
const ALL_ALLOWED_EXTENSIONS = new Set([...MODEL_EXTENSIONS, ...TEXTURE_EXTENSIONS]) const ALL_ALLOWED_EXTENSIONS = new Set([...MODEL_EXTENSIONS, ...TEXTURE_EXTENSIONS])
const REQUIRED_TEXTURES = ['roughness', 'normal', 'metalness', 'color', 'displace']
const TMP_DIR = join('/tmp', 'assets') const TMP_DIR = join('/tmp', 'assets')
@@ -83,20 +84,78 @@ async function compressWithBlender(
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Parse uploaded file from FormData // Build commit message with checklist
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
async function parseUpload(req: NextRequest) { function buildCommitMessage(
const formData = await req.formData() folderName: string,
const file = formData.get('file') as File | null modelFilename: string,
const folderName = (formData.get('folderName') as string | null)?.trim() || 'assets' textureNames: string[],
const fileType = formData.get('fileType') as string | null compressed: boolean,
const textureName = formData.get('textureName') as string | null ): string {
const title = `update: from upload-gltf add a new model -> ${folderName}`
if (!file || file.size === 0) { const foundTextures = new Set(
textureNames.map(t => t.toLowerCase().replace(/\.[^.]+$/, ''))
)
const textureLines = REQUIRED_TEXTURES.map(tex => {
if (foundTextures.has(tex)) {
const actual = textureNames.find(t => t.toLowerCase().replace(/\.[^.]+$/, '') === tex)
return `${actual}`
}
return `${tex} (manquant)`
})
const lines = [
title,
'',
'📦 Model',
`${modelFilename}${compressed ? ' (Draco)' : ''}`,
'',
'🎨 Textures',
...textureLines,
]
return lines.join('\n')
}
// ---------------------------------------------------------------------------
// Parse all files from a multi-file FormData
// ---------------------------------------------------------------------------
interface ParsedFile {
filename: string
buffer: Buffer
isModel: boolean
textureName?: string
}
async function parseMultiUpload(req: NextRequest): Promise<{
folderName: 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 fileEntries = formData.getAll('files') as File[]
const fileTypes = formData.getAll('fileTypes') as string[]
const textureNames = formData.getAll('textureNames') as string[]
if (fileEntries.length === 0) {
throw new Error('Aucun fichier recu') throw new Error('Aucun fichier recu')
} }
const parsed: ParsedFile[] = []
for (let i = 0; i < fileEntries.length; i++) {
const file = fileEntries[i]
if (!file || file.size === 0) continue
const fileType = fileTypes[i] || 'model'
const texName = textureNames[i] || ''
const originalSafe = sanitizeFilename(file.name) const originalSafe = sanitizeFilename(file.name)
const ext = extname(originalSafe).toLowerCase() const ext = extname(originalSafe).toLowerCase()
@@ -104,11 +163,9 @@ async function parseUpload(req: NextRequest) {
throw new Error(`Extension non autorisee: "${ext}"`) throw new Error(`Extension non autorisee: "${ext}"`)
} }
const safeFolderName = sanitizeFilename(folderName).replace(/[^a-zA-Z0-9-_]/g, '-')
let filename: string let filename: string
if (fileType === 'texture' && textureName) { if (fileType === 'texture' && texName) {
filename = sanitizeFilename(textureName) filename = sanitizeFilename(texName)
} else { } else {
filename = originalSafe filename = originalSafe
} }
@@ -116,17 +173,20 @@ async function parseUpload(req: NextRequest) {
const isModel = MODEL_EXTENSIONS.has(ext) const isModel = MODEL_EXTENSIONS.has(ext)
const buffer = Buffer.from(await file.arrayBuffer()) const buffer = Buffer.from(await file.arrayBuffer())
return { filename, buffer, safeFolderName, isModel } parsed.push({ filename, buffer, isModel, textureName: texName || undefined })
}
return { folderName: safeFolderName, files: parsed }
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Push a single file to GitHub via the API // Push all files in a single commit
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
async function pushFileToGitHub( async function pushAllToGitHub(
filePath: string, folderName: string,
contentBase64: string, files: { path: string; contentBase64: string }[],
folderName: string commitMessage: string
): Promise<{ commitUrl: string }> { ): Promise<{ commitUrl: string }> {
const octokit = getOctokit() const octokit = getOctokit()
const { owner, repo } = parseRepoUrl() const { owner, repo } = parseRepoUrl()
@@ -147,35 +207,36 @@ async function pushFileToGitHub(
commit_sha: latestCommitSha, commit_sha: latestCommitSha,
}) })
// 3. Create blob // 3. Create all blobs in parallel
const { data: blob } = await octokit.git.createBlob({ const blobResults = await Promise.all(
files.map(f =>
octokit.git.createBlob({
owner, owner,
repo, repo,
content: contentBase64, content: f.contentBase64,
encoding: 'base64', encoding: 'base64',
}) })
)
)
// 4. Create tree // 4. Create a single tree with all files
const { data: newTree } = await octokit.git.createTree({ const { data: newTree } = await octokit.git.createTree({
owner, owner,
repo, repo,
base_tree: commit.tree.sha, base_tree: commit.tree.sha,
tree: [ tree: files.map((f, i) => ({
{ path: f.path,
path: filePath, mode: '100644' as const,
mode: '100644', type: 'blob' as const,
type: 'blob', sha: blobResults[i].data.sha,
sha: blob.sha, })),
},
],
}) })
// 5. Create commit // 5. Create a single commit
const timestamp = new Date().toISOString()
const { data: newCommit } = await octokit.git.createCommit({ const { data: newCommit } = await octokit.git.createCommit({
owner, owner,
repo, repo,
message: `Design Update: ${folderName} [${timestamp}]`, message: commitMessage,
tree: newTree.sha, tree: newTree.sha,
parents: [latestCommitSha], parents: [latestCommitSha],
}) })
@@ -214,67 +275,75 @@ export async function POST(req: NextRequest) {
) )
} }
// --- Parse upload --- // --- Parse all files ---
let filename: string let folderName: string
let buffer: Buffer let parsedFiles: ParsedFile[]
let safeFolderName: string
let isModel: boolean
try { try {
;({ filename, buffer, safeFolderName, isModel } = await parseUpload(req)) ;({ folderName, files: parsedFiles } = await parseMultiUpload(req))
} catch (err) { } catch (err) {
const message = err instanceof Error ? err.message : 'Erreur inconnue' const message = err instanceof Error ? err.message : 'Erreur inconnue'
return NextResponse.json({ success: false, error: message }, { status: 400 }) return NextResponse.json({ success: false, error: message }, { status: 400 })
} }
// --- Write to /tmp --- // --- Process files (compress model if possible) ---
const tmpFolder = join(TMP_DIR, safeFolderName) const filesToPush: { path: string; contentBase64: string }[] = []
await mkdir(tmpFolder, { recursive: true }) let modelFilename = ''
const tmpFilePath = join(tmpFolder, filename)
await writeFile(tmpFilePath, buffer)
let contentToUpload: Buffer = buffer
let compressed = false let compressed = false
let compressionError: string | undefined let compressionError: string | undefined
const textureNames: string[] = []
// --- Compress model with Blender (if available) --- for (const pf of parsedFiles) {
if (isModel) { let content = pf.buffer
const stem = filename.replace(/\.[^.]+$/, '')
if (pf.isModel) {
modelFilename = pf.filename
// Write to /tmp for Blender compression
const tmpFolder = join(TMP_DIR, folderName)
await mkdir(tmpFolder, { recursive: true })
const tmpFilePath = join(tmpFolder, pf.filename)
await writeFile(tmpFilePath, pf.buffer)
const stem = pf.filename.replace(/\.[^.]+$/, '')
const compressedPath = join(tmpFolder, `${stem}_compressed.glb`) const compressedPath = join(tmpFolder, `${stem}_compressed.glb`)
const result = await compressWithBlender(tmpFilePath, compressedPath) const result = await compressWithBlender(tmpFilePath, compressedPath)
if (result.success && existsSync(compressedPath)) { if (result.success && existsSync(compressedPath)) {
contentToUpload = await readFile(compressedPath) content = await readFile(compressedPath)
compressed = true compressed = true
// Clean up compressed temp file
await unlink(compressedPath).catch(() => {}) await unlink(compressedPath).catch(() => {})
} else { } else {
// Blender not available or failed — push original
compressionError = result.error compressionError = result.error
} }
await unlink(tmpFilePath).catch(() => {})
await rm(tmpFolder, { recursive: true, force: true }).catch(() => {})
} else {
textureNames.push(pf.filename)
} }
// Clean up original temp file filesToPush.push({
await unlink(tmpFilePath).catch(() => {}) path: `public/assets/${folderName}/${pf.filename}`,
// Clean up folder if empty contentBase64: content.toString('base64'),
await rm(tmpFolder, { recursive: true, force: true }).catch(() => {}) })
}
// --- Push to GitHub --- // --- Build commit message ---
const gitPath = `public/assets/${safeFolderName}/${filename}` const commitMessage = buildCommitMessage(folderName, modelFilename, textureNames, compressed)
const contentBase64 = contentToUpload.toString('base64')
// --- Push all in one commit ---
try { try {
const { commitUrl } = await pushFileToGitHub(gitPath, contentBase64, safeFolderName) const { commitUrl } = await pushAllToGitHub(folderName, filesToPush, commitMessage)
return NextResponse.json({ return NextResponse.json({
success: true, success: true,
filename, folderName,
filesCount: filesToPush.length,
compressed, compressed,
compressionError: compressionError || undefined, compressionError: compressionError || undefined,
message: compressed message: `${filesToPush.length} fichier(s) envoye(s) sur GitHub en un seul commit.`,
? `"${filename}" compresse avec Draco et pousse sur GitHub.`
: `"${filename}" pousse sur GitHub${compressionError ? ' (sans compression: ' + compressionError + ')' : ''}.`,
commitUrl, commitUrl,
}) })
} catch (err) { } catch (err) {
+1 -1
View File
@@ -3,7 +3,7 @@ import './globals.css'
export const metadata: Metadata = { export const metadata: Metadata = {
title: ' Upload GLTF', title: ' Upload GLTF',
description: 'Interface de dépôt sécurisé pour fichiers 3D (.glb, .gltf, .fbx) et de versionnement automatique', description: 'Interface de depot securise pour fichiers 3D (.glb, .gltf) avec versionnement automatique sur GitHub',
} }
export default function RootLayout({ export default function RootLayout({
+3 -5
View File
@@ -8,19 +8,17 @@ 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">
Drop your 3D files they will be automatically versioned Deposez vos fichiers 3D ils seront automatiquement versionnes
<br />and pushed to your GitHub repository via Git LFS. <br />et envoyes sur votre depot GitHub.
</p> </p>
</div> </div>
<UploadZone /> <UploadZone />
<footer className="mt-10 text-gray-500 text-xs text-center"> <footer className="mt-10 text-gray-500 text-xs text-center">
Models: <span className="font-mono text-gray-400">.glb · .gltf</span> Modeles : <span className="font-mono text-gray-400">.glb · .gltf</span>
<span className="mx-2">·</span> <span className="mx-2">·</span>
Textures : <span className="font-mono text-gray-400">.png · .jpg · .webp</span> Textures : <span className="font-mono text-gray-400">.png · .jpg · .webp</span>
<span className="mx-2">·</span>
Max size: <span className="font-mono text-gray-400">2 GB</span>
</footer> </footer>
</main> </main>
) )
+75 -90
View File
@@ -1,6 +1,6 @@
'use client' 'use client'
import { useCallback, useRef, useState } from 'react' import { useState } from 'react'
import dynamic from 'next/dynamic' import dynamic from 'next/dynamic'
const ModelViewer = dynamic(() => import('./ModelViewer'), { ssr: false }) const ModelViewer = dynamic(() => import('./ModelViewer'), { ssr: false })
@@ -79,79 +79,48 @@ function validateFolder(files: File[]): { model?: File; textures: TextureFile[];
return result return result
} }
function uploadFolder( async function uploadFolder(
folder: FolderEntry, folder: FolderEntry,
secret: string, secret: string,
onProgress: (pct: number) => void, onProgress: (pct: number) => void
xhrRef: { current: XMLHttpRequest | null }
): Promise<{ success: boolean; filename?: string; error?: string }> { ): Promise<{ success: boolean; filename?: string; error?: string }> {
return new Promise((resolve) => {
const totalFiles = 1 + folder.textures.length
let completed = 0
const checkDone = () => {
completed++
onProgress(Math.round((completed / totalFiles) * 100))
if (completed >= totalFiles) {
resolve({ success: true, filename: folder.folderName })
}
}
const formData = new FormData() const formData = new FormData()
formData.append('folderName', folder.folderName) formData.append('folderName', folder.folderName)
formData.append('file', folder.modelFile)
formData.append('fileType', 'model')
// Model file
formData.append('files', folder.modelFile)
formData.append('fileTypes', 'model')
formData.append('textureNames', '')
// Texture files
for (const tex of folder.textures) { for (const tex of folder.textures) {
const texForm = new FormData() formData.append('files', tex.file)
texForm.append('folderName', folder.folderName) formData.append('fileTypes', 'texture')
texForm.append('file', tex.file) formData.append('textureNames', tex.name)
texForm.append('fileType', 'texture') }
texForm.append('textureName', tex.name)
const xhr = new XMLHttpRequest() onProgress(10)
xhrRef.current = xhr
xhr.onload = () => {
try { try {
const res = JSON.parse(xhr.responseText) const res = await fetch('/api/upload', {
if (!res.success) { method: 'POST',
resolve({ success: false, error: `Texture ${tex.name} : ${res.error}` }) headers: { 'x-upload-secret': secret.trim() },
} else { body: formData,
checkDone()
}
} catch {
checkDone()
}
}
xhr.onerror = () => resolve({ success: false, error: `Erreur réseau: ${tex.name}` })
xhr.open('POST', '/api/upload')
xhr.setRequestHeader('x-upload-secret', secret.trim())
xhr.send(texForm)
}
const modelXhr = new XMLHttpRequest()
xhrRef.current = modelXhr
modelXhr.onload = () => {
try {
const res = JSON.parse(modelXhr.responseText)
if (!res.success) {
resolve({ success: false, error: res.error })
} else {
checkDone()
}
} catch {
checkDone()
}
}
modelXhr.onerror = () => resolve({ success: false, error: 'Erreur réseau: model' })
modelXhr.open('POST', '/api/upload')
modelXhr.setRequestHeader('x-upload-secret', secret.trim())
modelXhr.send(formData)
}) })
onProgress(80)
const data = await res.json()
if (!data.success) {
return { success: false, error: data.error }
}
onProgress(100)
return { success: true, filename: folder.folderName }
} catch {
return { success: false, error: 'Erreur reseau' }
}
} }
export default function UploadZone() { export default function UploadZone() {
@@ -160,19 +129,23 @@ export default function UploadZone() {
const [secret, setSecret] = useState('') const [secret, setSecret] = useState('')
const [secretVisible, setSecretVisible] = useState(false) const [secretVisible, setSecretVisible] = useState(false)
const [globalError, setGlobalError] = useState<string | null>(null) const [globalError, setGlobalError] = useState<string | null>(null)
const xhrRef = useRef<XMLHttpRequest | null>(null) const [secretError, setSecretError] = useState<string | null>(null)
const [abortController, setAbortController] = useState<AbortController | null>(null)
const isSecretEmpty = !secret.trim()
const updateFile = (index: number, patch: Partial<FolderEntry>) => { const updateFile = (index: number, patch: Partial<FolderEntry>) => {
setFiles((prev) => prev.map((f, i) => i === index ? { ...f, ...patch } : f)) setFiles((prev) => prev.map((f, i) => i === index ? { ...f, ...patch } : f))
} }
const handleUpload = useCallback(async () => { const handleUpload = async () => {
if (!secret.trim()) { if (!secret.trim()) {
setGlobalError('Veuillez entrer la clé d\'accès avant d\'uploader.') setSecretError('La cle d\'acces est requise')
return return
} }
if (files.length === 0) return if (files.length === 0) return
setSecretError(null)
setIsUploading(true) setIsUploading(true)
setGlobalError(null) setGlobalError(null)
@@ -184,8 +157,7 @@ export default function UploadZone() {
const result = await uploadFolder( const result = await uploadFolder(
files[i], files[i],
secret, secret,
(pct) => updateFile(i, { progress: pct }), (pct) => updateFile(i, { progress: pct })
xhrRef
) )
updateFile(i, { updateFile(i, {
@@ -197,9 +169,9 @@ export default function UploadZone() {
} }
setIsUploading(false) setIsUploading(false)
}, [files, secret]) }
const handleCancel = () => { xhrRef.current?.abort() } const handleCancel = () => { abortController?.abort() }
const removeFile = (index: number) => { const removeFile = (index: number) => {
const file = files[index] const file = files[index]
@@ -222,18 +194,25 @@ export default function UploadZone() {
return ( return (
<div className="w-full max-w-2xl space-y-4"> <div className="w-full max-w-2xl space-y-4">
<div className="space-y-1.5"> <div className="space-y-1.5">
<label className="block text-sm font-medium text-gray-300">Access Key</label> <label className="block text-sm font-medium text-gray-300">Cle d&apos;acces</label>
<div className="relative"> <div className="relative">
<input <input
type={secretVisible ? 'text' : 'password'} type={secretVisible ? 'text' : 'password'}
value={secret} value={secret}
onChange={(e) => setSecret(e.target.value)} onChange={(e) => {
placeholder="Enter secret key..." setSecret(e.target.value)
if (secretError) setSecretError(null)
}}
placeholder="Entrez la cle secrete..."
disabled={isUploading} disabled={isUploading}
className="w-full bg-black-800 border border-white/30 rounded-xl px-4 py-2.5 pr-12 className={`w-full bg-black-800 border rounded-xl px-4 py-2.5 pr-12
text-gray-100 placeholder-gray-500 text-sm text-gray-100 placeholder-gray-500 text-sm
focus:outline-none focus:ring-2 focus:ring-white/50 focus:border-white/50 focus:outline-none focus:ring-2 focus:border-white/50
disabled:opacity-50 disabled:cursor-not-allowed transition" disabled:opacity-50 disabled:cursor-not-allowed transition
${secretError
? 'border-red-500/70 focus:ring-red-500/50'
: 'border-white/30 focus:ring-white/50'
}`}
/> />
<button <button
type="button" type="button"
@@ -253,6 +232,9 @@ export default function UploadZone() {
)} )}
</button> </button>
</div> </div>
{secretError && (
<p className="text-xs text-red-400 mt-1">{secretError}</p>
)}
</div> </div>
<input <input
@@ -308,11 +290,11 @@ export default function UploadZone() {
</div> </div>
</div> </div>
<p className="text-sm font-medium text-gray-300"> <p className="text-sm font-medium text-gray-300">
Drop ton dossier ici Deposez votre dossier ici
<span className="text-gray-500 font-normal"> ou click pour parcourir</span> <span className="text-gray-500 font-normal"> ou cliquez pour parcourir</span>
</p> </p>
<p className="text-xs text-gray-500 mt-1"> <p className="text-xs text-gray-500 mt-1">
Contient: model.glb/gltf + textures (roughness, normal, metalness, color, displace) Contenu attendu : model.glb/gltf + textures (roughness, normal, metalness, color, displace)
</p> </p>
</div> </div>
)} )}
@@ -366,10 +348,10 @@ export default function UploadZone() {
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-sm text-gray-200 font-mono truncate">{entry.folderName}/</span> <span className="text-sm text-gray-200 font-mono truncate">{entry.folderName}/</span>
<span className="shrink-0 text-xs px-1.5 py-0.5 rounded-full bg-gray-700 text-gray-300">Folder</span> <span className="shrink-0 text-xs px-1.5 py-0.5 rounded-full bg-gray-700 text-gray-300">Dossier</span>
</div> </div>
<div className="flex items-center gap-2 mt-0.5"> <div className="flex items-center gap-2 mt-0.5">
<span className="text-xs text-gray-500">model: {entry.modelFile.name}</span> <span className="text-xs text-gray-500">modele : {entry.modelFile.name}</span>
{entry.status === 'error' && entry.error && ( {entry.status === 'error' && entry.error && (
<span className="text-xs text-red-400 truncate">{entry.error}</span> <span className="text-xs text-red-400 truncate">{entry.error}</span>
)} )}
@@ -412,8 +394,8 @@ export default function UploadZone() {
{entry.modelUrl && entry.status !== 'success' && ( {entry.modelUrl && entry.status !== 'success' && (
<div <div
className={`transition-all duration-300 ease-in-out ${ className={`transition-all duration-300 ease-in-out overflow-hidden ${
entry.viewerOpen ? 'max-h-[500px] opacity-100 mt-2' : 'max-h-0 opacity-0' entry.viewerOpen ? 'max-h-[500px] opacity-100 mt-2' : 'max-h-0 opacity-0 pointer-events-none'
}`} }`}
> >
<ModelViewer <ModelViewer
@@ -432,12 +414,15 @@ export default function UploadZone() {
{!isUploading && files.some((f) => f.status === 'pending' || f.status === 'error') && ( {!isUploading && files.some((f) => f.status === 'pending' || f.status === 'error') && (
<button <button
onClick={handleUpload} onClick={handleUpload}
className="flex-1 bg-white text-black font-medium text-sm disabled={isSecretEmpty}
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
hover:bg-gray-200 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
style={{ color: '#000000' }} ${isSecretEmpty
? 'bg-white/30 text-gray-500 cursor-not-allowed'
: 'bg-white text-[#000000] hover:bg-gray-200'
}`}
> >
Upload & Push to GitHub Envoyer sur GitHub
</button> </button>
)} )}
@@ -448,7 +433,7 @@ export default function UploadZone() {
py-2.5 px-6 rounded-xl border border-black-600 transition-colors duration-150 py-2.5 px-6 rounded-xl border border-black-600 transition-colors duration-150
hover:bg-black-600" hover:bg-black-600"
> >
Cancel Annuler
</button> </button>
)} )}
@@ -459,7 +444,7 @@ export default function UploadZone() {
py-2.5 px-6 rounded-xl border border-black-600 transition-colors duration-150 py-2.5 px-6 rounded-xl border border-black-600 transition-colors duration-150
hover:bg-black-600" hover:bg-black-600"
> >
{allDone ? 'Clear All' : 'Retry'} {allDone ? 'Tout effacer' : 'Reessayer'}
</button> </button>
)} )}
</div> </div>