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
|
||||
GIT_BRANCH=main
|
||||
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 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 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) {
|
||||
const formData = await req.formData()
|
||||
const file = formData.get('file') as File | null
|
||||
const folderName = (formData.get('folderName') as string | null)?.trim() || 'assets'
|
||||
const fileType = formData.get('fileType') as string | null
|
||||
const textureName = formData.get('textureName') as string | null
|
||||
function buildCommitMessage(
|
||||
folderName: string,
|
||||
modelFilename: string,
|
||||
textureNames: string[],
|
||||
compressed: boolean,
|
||||
): 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')
|
||||
}
|
||||
|
||||
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 ext = extname(originalSafe).toLowerCase()
|
||||
|
||||
@@ -104,11 +163,9 @@ async function parseUpload(req: NextRequest) {
|
||||
throw new Error(`Extension non autorisee: "${ext}"`)
|
||||
}
|
||||
|
||||
const safeFolderName = sanitizeFilename(folderName).replace(/[^a-zA-Z0-9-_]/g, '-')
|
||||
|
||||
let filename: string
|
||||
if (fileType === 'texture' && textureName) {
|
||||
filename = sanitizeFilename(textureName)
|
||||
if (fileType === 'texture' && texName) {
|
||||
filename = sanitizeFilename(texName)
|
||||
} else {
|
||||
filename = originalSafe
|
||||
}
|
||||
@@ -116,17 +173,20 @@ async function parseUpload(req: NextRequest) {
|
||||
const isModel = MODEL_EXTENSIONS.has(ext)
|
||||
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(
|
||||
filePath: string,
|
||||
contentBase64: string,
|
||||
folderName: string
|
||||
async function pushAllToGitHub(
|
||||
folderName: string,
|
||||
files: { path: string; contentBase64: string }[],
|
||||
commitMessage: string
|
||||
): Promise<{ commitUrl: string }> {
|
||||
const octokit = getOctokit()
|
||||
const { owner, repo } = parseRepoUrl()
|
||||
@@ -147,35 +207,36 @@ async function pushFileToGitHub(
|
||||
commit_sha: latestCommitSha,
|
||||
})
|
||||
|
||||
// 3. Create blob
|
||||
const { data: blob } = await octokit.git.createBlob({
|
||||
// 3. Create all blobs in parallel
|
||||
const blobResults = await Promise.all(
|
||||
files.map(f =>
|
||||
octokit.git.createBlob({
|
||||
owner,
|
||||
repo,
|
||||
content: contentBase64,
|
||||
content: f.contentBase64,
|
||||
encoding: 'base64',
|
||||
})
|
||||
)
|
||||
)
|
||||
|
||||
// 4. Create tree
|
||||
// 4. Create a single tree with all files
|
||||
const { data: newTree } = await octokit.git.createTree({
|
||||
owner,
|
||||
repo,
|
||||
base_tree: commit.tree.sha,
|
||||
tree: [
|
||||
{
|
||||
path: filePath,
|
||||
mode: '100644',
|
||||
type: 'blob',
|
||||
sha: blob.sha,
|
||||
},
|
||||
],
|
||||
tree: files.map((f, i) => ({
|
||||
path: f.path,
|
||||
mode: '100644' as const,
|
||||
type: 'blob' as const,
|
||||
sha: blobResults[i].data.sha,
|
||||
})),
|
||||
})
|
||||
|
||||
// 5. Create commit
|
||||
const timestamp = new Date().toISOString()
|
||||
// 5. Create a single commit
|
||||
const { data: newCommit } = await octokit.git.createCommit({
|
||||
owner,
|
||||
repo,
|
||||
message: `Design Update: ${folderName} [${timestamp}]`,
|
||||
message: commitMessage,
|
||||
tree: newTree.sha,
|
||||
parents: [latestCommitSha],
|
||||
})
|
||||
@@ -214,67 +275,75 @@ export async function POST(req: NextRequest) {
|
||||
)
|
||||
}
|
||||
|
||||
// --- Parse upload ---
|
||||
let filename: string
|
||||
let buffer: Buffer
|
||||
let safeFolderName: string
|
||||
let isModel: boolean
|
||||
// --- Parse all files ---
|
||||
let folderName: string
|
||||
let parsedFiles: ParsedFile[]
|
||||
|
||||
try {
|
||||
;({ filename, buffer, safeFolderName, isModel } = await parseUpload(req))
|
||||
;({ folderName, 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 })
|
||||
}
|
||||
|
||||
// --- Write to /tmp ---
|
||||
const tmpFolder = join(TMP_DIR, safeFolderName)
|
||||
await mkdir(tmpFolder, { recursive: true })
|
||||
const tmpFilePath = join(tmpFolder, filename)
|
||||
await writeFile(tmpFilePath, buffer)
|
||||
|
||||
let contentToUpload: Buffer = buffer
|
||||
// --- Process files (compress model if possible) ---
|
||||
const filesToPush: { path: string; contentBase64: string }[] = []
|
||||
let modelFilename = ''
|
||||
let compressed = false
|
||||
let compressionError: string | undefined
|
||||
const textureNames: string[] = []
|
||||
|
||||
// --- Compress model with Blender (if available) ---
|
||||
if (isModel) {
|
||||
const stem = filename.replace(/\.[^.]+$/, '')
|
||||
for (const pf of parsedFiles) {
|
||||
let content = pf.buffer
|
||||
|
||||
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 result = await compressWithBlender(tmpFilePath, compressedPath)
|
||||
|
||||
if (result.success && existsSync(compressedPath)) {
|
||||
contentToUpload = await readFile(compressedPath)
|
||||
content = await readFile(compressedPath)
|
||||
compressed = true
|
||||
// Clean up compressed temp file
|
||||
await unlink(compressedPath).catch(() => {})
|
||||
} else {
|
||||
// Blender not available or failed — push original
|
||||
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
|
||||
await unlink(tmpFilePath).catch(() => {})
|
||||
// Clean up folder if empty
|
||||
await rm(tmpFolder, { recursive: true, force: true }).catch(() => {})
|
||||
filesToPush.push({
|
||||
path: `public/assets/${folderName}/${pf.filename}`,
|
||||
contentBase64: content.toString('base64'),
|
||||
})
|
||||
}
|
||||
|
||||
// --- Push to GitHub ---
|
||||
const gitPath = `public/assets/${safeFolderName}/${filename}`
|
||||
const contentBase64 = contentToUpload.toString('base64')
|
||||
// --- Build commit message ---
|
||||
const commitMessage = buildCommitMessage(folderName, modelFilename, textureNames, compressed)
|
||||
|
||||
// --- Push all in one commit ---
|
||||
try {
|
||||
const { commitUrl } = await pushFileToGitHub(gitPath, contentBase64, safeFolderName)
|
||||
const { commitUrl } = await pushAllToGitHub(folderName, filesToPush, commitMessage)
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
filename,
|
||||
folderName,
|
||||
filesCount: filesToPush.length,
|
||||
compressed,
|
||||
compressionError: compressionError || undefined,
|
||||
message: compressed
|
||||
? `"${filename}" compresse avec Draco et pousse sur GitHub.`
|
||||
: `"${filename}" pousse sur GitHub${compressionError ? ' (sans compression: ' + compressionError + ')' : ''}.`,
|
||||
message: `${filesToPush.length} fichier(s) envoye(s) sur GitHub en un seul commit.`,
|
||||
commitUrl,
|
||||
})
|
||||
} catch (err) {
|
||||
|
||||
+1
-1
@@ -3,7 +3,7 @@ import './globals.css'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
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({
|
||||
|
||||
+4
-6
@@ -8,19 +8,17 @@ export default function Home() {
|
||||
Upload GLTF
|
||||
</h1>
|
||||
<p className="text-gray-400 text-base leading-relaxed">
|
||||
Drop your 3D files — they will be automatically versioned
|
||||
<br />and pushed to your GitHub repository via Git LFS.
|
||||
Deposez vos fichiers 3D — ils seront automatiquement versionnes
|
||||
<br />et envoyes sur votre depot GitHub.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<UploadZone />
|
||||
|
||||
<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>
|
||||
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>
|
||||
Textures : <span className="font-mono text-gray-400">.png · .jpg · .webp</span>
|
||||
</footer>
|
||||
</main>
|
||||
)
|
||||
|
||||
+76
-91
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useRef, useState } from 'react'
|
||||
import { useState } from 'react'
|
||||
import dynamic from 'next/dynamic'
|
||||
|
||||
const ModelViewer = dynamic(() => import('./ModelViewer'), { ssr: false })
|
||||
@@ -79,79 +79,48 @@ function validateFolder(files: File[]): { model?: File; textures: TextureFile[];
|
||||
return result
|
||||
}
|
||||
|
||||
function uploadFolder(
|
||||
async function uploadFolder(
|
||||
folder: FolderEntry,
|
||||
secret: string,
|
||||
onProgress: (pct: number) => void,
|
||||
xhrRef: { current: XMLHttpRequest | null }
|
||||
onProgress: (pct: number) => void
|
||||
): 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()
|
||||
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) {
|
||||
const texForm = new FormData()
|
||||
texForm.append('folderName', folder.folderName)
|
||||
texForm.append('file', tex.file)
|
||||
texForm.append('fileType', 'texture')
|
||||
texForm.append('textureName', tex.name)
|
||||
formData.append('files', tex.file)
|
||||
formData.append('fileTypes', 'texture')
|
||||
formData.append('textureNames', tex.name)
|
||||
}
|
||||
|
||||
const xhr = new XMLHttpRequest()
|
||||
xhrRef.current = xhr
|
||||
onProgress(10)
|
||||
|
||||
xhr.onload = () => {
|
||||
try {
|
||||
const res = JSON.parse(xhr.responseText)
|
||||
if (!res.success) {
|
||||
resolve({ success: false, error: `Texture ${tex.name} : ${res.error}` })
|
||||
} else {
|
||||
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)
|
||||
const res = await fetch('/api/upload', {
|
||||
method: 'POST',
|
||||
headers: { 'x-upload-secret': secret.trim() },
|
||||
body: 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() {
|
||||
@@ -160,19 +129,23 @@ export default function UploadZone() {
|
||||
const [secret, setSecret] = useState('')
|
||||
const [secretVisible, setSecretVisible] = useState(false)
|
||||
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>) => {
|
||||
setFiles((prev) => prev.map((f, i) => i === index ? { ...f, ...patch } : f))
|
||||
}
|
||||
|
||||
const handleUpload = useCallback(async () => {
|
||||
const handleUpload = async () => {
|
||||
if (!secret.trim()) {
|
||||
setGlobalError('Veuillez entrer la clé d\'accès avant d\'uploader.')
|
||||
setSecretError('La cle d\'acces est requise')
|
||||
return
|
||||
}
|
||||
if (files.length === 0) return
|
||||
|
||||
setSecretError(null)
|
||||
setIsUploading(true)
|
||||
setGlobalError(null)
|
||||
|
||||
@@ -184,8 +157,7 @@ export default function UploadZone() {
|
||||
const result = await uploadFolder(
|
||||
files[i],
|
||||
secret,
|
||||
(pct) => updateFile(i, { progress: pct }),
|
||||
xhrRef
|
||||
(pct) => updateFile(i, { progress: pct })
|
||||
)
|
||||
|
||||
updateFile(i, {
|
||||
@@ -197,9 +169,9 @@ export default function UploadZone() {
|
||||
}
|
||||
|
||||
setIsUploading(false)
|
||||
}, [files, secret])
|
||||
}
|
||||
|
||||
const handleCancel = () => { xhrRef.current?.abort() }
|
||||
const handleCancel = () => { abortController?.abort() }
|
||||
|
||||
const removeFile = (index: number) => {
|
||||
const file = files[index]
|
||||
@@ -222,18 +194,25 @@ export default function UploadZone() {
|
||||
return (
|
||||
<div className="w-full max-w-2xl space-y-4">
|
||||
<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">
|
||||
<input
|
||||
type={secretVisible ? 'text' : 'password'}
|
||||
value={secret}
|
||||
onChange={(e) => setSecret(e.target.value)}
|
||||
placeholder="Enter secret key..."
|
||||
onChange={(e) => {
|
||||
setSecret(e.target.value)
|
||||
if (secretError) setSecretError(null)
|
||||
}}
|
||||
placeholder="Entrez la cle secrete..."
|
||||
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
|
||||
focus:outline-none focus:ring-2 focus:ring-white/50 focus:border-white/50
|
||||
disabled:opacity-50 disabled:cursor-not-allowed transition"
|
||||
focus:outline-none focus:ring-2 focus:border-white/50
|
||||
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
|
||||
type="button"
|
||||
@@ -253,6 +232,9 @@ export default function UploadZone() {
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
{secretError && (
|
||||
<p className="text-xs text-red-400 mt-1">{secretError}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<input
|
||||
@@ -308,11 +290,11 @@ export default function UploadZone() {
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm font-medium text-gray-300">
|
||||
Drop ton dossier ici
|
||||
<span className="text-gray-500 font-normal"> ou click pour parcourir</span>
|
||||
Deposez votre dossier ici
|
||||
<span className="text-gray-500 font-normal"> ou cliquez pour parcourir</span>
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Contient: model.glb/gltf + textures (roughness, normal, metalness, color, displace)
|
||||
Contenu attendu : model.glb/gltf + textures (roughness, normal, metalness, color, displace)
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
@@ -366,10 +348,10 @@ export default function UploadZone() {
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<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 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 && (
|
||||
<span className="text-xs text-red-400 truncate">{entry.error}</span>
|
||||
)}
|
||||
@@ -405,15 +387,15 @@ export default function UploadZone() {
|
||||
<svg className="w-4 h-4" 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>
|
||||
<span>Textures manquantes: {entry.warnings.join(', ')}</span>
|
||||
<span>Textures manquantes : {entry.warnings.join(', ')}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{entry.modelUrl && entry.status !== 'success' && (
|
||||
<div
|
||||
className={`transition-all duration-300 ease-in-out ${
|
||||
entry.viewerOpen ? 'max-h-[500px] opacity-100 mt-2' : 'max-h-0 opacity-0'
|
||||
className={`transition-all duration-300 ease-in-out overflow-hidden ${
|
||||
entry.viewerOpen ? 'max-h-[500px] opacity-100 mt-2' : 'max-h-0 opacity-0 pointer-events-none'
|
||||
}`}
|
||||
>
|
||||
<ModelViewer
|
||||
@@ -432,12 +414,15 @@ export default function UploadZone() {
|
||||
{!isUploading && files.some((f) => f.status === 'pending' || f.status === 'error') && (
|
||||
<button
|
||||
onClick={handleUpload}
|
||||
className="flex-1 bg-white text-black 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"
|
||||
style={{ color: '#000000' }}
|
||||
disabled={isSecretEmpty}
|
||||
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
|
||||
${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>
|
||||
)}
|
||||
|
||||
@@ -448,7 +433,7 @@ export default function UploadZone() {
|
||||
py-2.5 px-6 rounded-xl border border-black-600 transition-colors duration-150
|
||||
hover:bg-black-600"
|
||||
>
|
||||
Cancel
|
||||
Annuler
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -459,7 +444,7 @@ export default function UploadZone() {
|
||||
py-2.5 px-6 rounded-xl border border-black-600 transition-colors duration-150
|
||||
hover:bg-black-600"
|
||||
>
|
||||
{allDone ? 'Clear All' : 'Retry'}
|
||||
{allDone ? 'Tout effacer' : 'Reessayer'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user