fix: one commit
This commit is contained in:
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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'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>
|
||||||
|
|||||||
Reference in New Issue
Block a user