diff --git a/app/api/upload/check/route.ts b/app/api/upload/check/route.ts index eb6125e..a8e5bc7 100644 --- a/app/api/upload/check/route.ts +++ b/app/api/upload/check/route.ts @@ -1,44 +1,60 @@ import { NextRequest, NextResponse } from 'next/server' import { validateUploadSecret } from '@/lib/auth' -import { sanitizeFilename } from '@/lib/sanitize' -import { VALID_DESTINATIONS } from '@/lib/constants' +import { parseMultiUpload } from '@/lib/parse-upload' import { getRemoteFolder } from '@/lib/github' +import { classifyFileChanges } from '@/lib/diff-files' +import { prepareGitAssets } from '@/lib/prepare-git-assets' export const runtime = 'nodejs' export const dynamic = 'force-dynamic' /** - * GET /api/upload/check?destination=...&folderName=... - * Check if a folder already exists on the remote repo and return file SHAs. + * POST /api/upload/check + * Build the final Git payload, then compare it with the remote folder. */ -export async function GET(req: NextRequest) { +export async function POST(req: NextRequest) { const authError = validateUploadSecret(req) if (authError) return authError - const { searchParams } = new URL(req.url) - const destination = searchParams.get('destination')?.trim() - const folderName = searchParams.get('folderName')?.trim() - - 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}` + let folderName: string + let destination: string + let parsedFiles: Awaited>['files'] try { + const parsed = await parseMultiUpload(req) + folderName = parsed.folderName + destination = parsed.destination + parsedFiles = parsed.files + } catch (err) { + const message = err instanceof Error ? err.message : 'Erreur inconnue' + return NextResponse.json({ success: false, error: message }, { status: 400 }) + } + + const folderPath = `public/models/${destination}/${folderName}` + + try { + const { filesToPush } = await prepareGitAssets({ folderName, destination, parsedFiles }) const { exists, files } = await getRemoteFolder(folderPath) if (exists) { + const remoteFileMap = new Map(files.map((file) => [file.name.toLowerCase(), file.size])) + const { fileChanges, deletedFileNames } = classifyFileChanges(filesToPush, remoteFileMap, folderPath) + + const diffs: Array<{ name: string; status: 'new' | 'changed' | 'deleted' }> = [] + + for (const [name, status] of fileChanges.entries()) { + if (status === 'new' || status === 'changed') { + diffs.push({ name, status }) + } + } + + diffs.push(...deletedFileNames.map((name) => ({ name, status: 'deleted' as const }))) + return NextResponse.json({ success: true, exists: true, path: folderPath, - files, + diffs, }) } diff --git a/app/api/upload/git/route.ts b/app/api/upload/git/route.ts index ef914aa..54e3c3c 100644 --- a/app/api/upload/git/route.ts +++ b/app/api/upload/git/route.ts @@ -1,14 +1,10 @@ import { NextRequest, NextResponse } from 'next/server' -import { join } from 'path' -import { mkdir, writeFile, readFile, unlink, rm } from 'fs/promises' -import { existsSync } from 'fs' import { validateUploadSecret } from '@/lib/auth' import { parseMultiUpload } from '@/lib/parse-upload' -import { compressWithBlender } from '@/lib/blender' import { getRemoteFolder, pushAllToGitHub } from '@/lib/github' import { buildCommitMessage } from '@/lib/commit-message' import { classifyFileChanges } from '@/lib/diff-files' -import { TMP_DIR } from '@/lib/constants' +import { prepareGitAssets } from '@/lib/prepare-git-assets' export const runtime = 'nodejs' export const dynamic = 'force-dynamic' @@ -37,52 +33,14 @@ export async function POST(req: NextRequest) { return NextResponse.json({ success: false, error: message }, { status: 400 }) } - // --- Process files (compress model if possible) --- - const filesToPush: { path: string; contentBase64: string }[] = [] - let modelFilename = '' - let compressed = false - let compressionError: string | undefined - const textureNames: string[] = [] - - 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`) - - try { - const result = await compressWithBlender(tmpFilePath, compressedPath) - - if (result.success && existsSync(compressedPath)) { - content = await readFile(compressedPath) - compressed = true - await unlink(compressedPath).catch(() => {}) - } else { - compressionError = result.error - } - } finally { - // Always cleanup temp files - await unlink(tmpFilePath).catch(() => {}) - await rm(tmpFolder, { recursive: true, force: true }).catch(() => {}) - } - } else { - textureNames.push(pf.filename) - } - - filesToPush.push({ - path: `public/models/${destination}/${folderName}/${pf.filename}`, - contentBase64: content.toString('base64'), - }) - } + // --- Process files (compress model + textures for Git) --- + const { + filesToPush, + modelFilename, + compressed, + compressionError, + textureNames, + } = await prepareGitAssets({ folderName, destination, parsedFiles }) // --- Detect existing files and classify changes --- const folderPath = `public/models/${destination}/${folderName}` diff --git a/components/upload/FolderDropzone.tsx b/components/upload/FolderDropzone.tsx index 18bf6a6..1c309b8 100644 --- a/components/upload/FolderDropzone.tsx +++ b/components/upload/FolderDropzone.tsx @@ -1,7 +1,40 @@ +import { useRef, useState } from 'react' import type { FolderEntry } from '@/lib/client-types' import { validateFolder } from '@/lib/validate-folder' import { FolderIcon } from '@/components/ui/icons' +function readDroppedFile(entry: FileSystemFileEntry) { + return new Promise((resolve, reject) => { + entry.file(resolve, reject) + }) +} + +function readDirectoryEntries(reader: FileSystemDirectoryReader) { + return new Promise((resolve, reject) => { + reader.readEntries(resolve, reject) + }) +} + +async function collectEntryFiles(entry: FileSystemEntry): Promise { + if (entry.isFile) { + return [await readDroppedFile(entry as FileSystemFileEntry)] + } + + const reader = (entry as FileSystemDirectoryEntry).createReader() + const files: File[] = [] + + while (true) { + const entries = await readDirectoryEntries(reader) + if (entries.length === 0) break + + for (const child of entries) { + files.push(...await collectEntryFiles(child)) + } + } + + return files +} + interface FolderDropzoneProps { isUploading: boolean onFolderSelected: (entry: FolderEntry) => void @@ -13,13 +46,14 @@ export default function FolderDropzone({ onFolderSelected, onError, }: FolderDropzoneProps) { - const handleChange = (e: React.ChangeEvent) => { - const selected = e.target.files - if (!selected || selected.length === 0) return + const inputRef = useRef(null) + const [isDragActive, setIsDragActive] = useState(false) - const fileArray = Array.from(selected) - const folderName = fileArray[0].webkitRelativePath?.split('/')[0] || 'folder' - const validation = validateFolder(fileArray) + const processFiles = (files: File[], fallbackFolderName = 'folder') => { + if (files.length === 0) return + + const folderName = files[0].webkitRelativePath?.split('/')[0] || fallbackFolderName + const validation = validateFolder(files) if (!validation.ok) { onError(validation.errors.join(' | ')) @@ -36,26 +70,95 @@ export default function FolderDropzone({ modelUrl: URL.createObjectURL(validation.model), viewerOpen: true, } + onFolderSelected(entry) } + const handleChange = (e: React.ChangeEvent) => { + const selected = e.target.files + if (!selected || selected.length === 0) return + + processFiles(Array.from(selected)) + e.target.value = '' + } + + const handleDragOver = (e: React.DragEvent) => { + e.preventDefault() + + if (isUploading) return + setIsDragActive(true) + } + + const handleDragLeave = (e: React.DragEvent) => { + if (!e.currentTarget.contains(e.relatedTarget as Node | null)) { + setIsDragActive(false) + } + } + + const handleDrop = async (e: React.DragEvent) => { + e.preventDefault() + setIsDragActive(false) + + if (isUploading) return + + try { + const items = Array.from(e.dataTransfer.items) + const rootEntries = items + .filter((item) => item.kind === 'file') + .map((item) => item.webkitGetAsEntry?.()) + .filter((entry): entry is FileSystemEntry => entry !== null) + + const directoryEntries = rootEntries.filter((entry) => entry.isDirectory) + + if (directoryEntries.length > 0) { + if (directoryEntries.length > 1) { + onError('Deposez un seul dossier a la fois') + return + } + + const rootEntry = directoryEntries[0] as FileSystemDirectoryEntry + const files = await collectEntryFiles(rootEntry) + processFiles(files, rootEntry.name) + return + } + + const droppedFiles = Array.from(e.dataTransfer.files) + if (droppedFiles.length === 0) return + + processFiles(droppedFiles) + } catch { + onError('Impossible de lire le dossier depose') + } + } + return ( <> )} multiple className="hidden" + disabled={isUploading} onChange={handleChange} />
document.getElementById('folder-input')?.click()} + onClick={() => { + if (isUploading) return + inputRef.current?.click() + }} + onDragOver={handleDragOver} + onDragLeave={handleDragLeave} + onDrop={(e) => { + void handleDrop(e) + }} className={` relative border-2 border-dashed rounded-2xl p-8 text-center cursor-pointer transition-all duration-200 bg-black-800 ${isUploading ? 'cursor-not-allowed opacity-60 border-white/20' : ''} ${!isUploading ? 'border-white/30 hover:border-white/50 hover:bg-black-700' : ''} + ${isDragActive ? 'border-white/70 bg-black-700' : ''} `} >
diff --git a/lib/prepare-git-assets.ts b/lib/prepare-git-assets.ts new file mode 100644 index 0000000..bac235b --- /dev/null +++ b/lib/prepare-git-assets.ts @@ -0,0 +1,91 @@ +import { join } from 'path' +import { existsSync } from 'fs' +import { mkdir, writeFile, readFile, unlink, rm } from 'fs/promises' +import { TMP_DIR } from '@/lib/constants' +import { compressWithBlender } from '@/lib/blender' +import { compressTextureBuffer } from '@/lib/texture-compression' +import type { ParsedFile } from '@/lib/types' + +interface PushFile { + path: string + contentBase64: string +} + +interface PrepareGitAssetsParams { + folderName: string + destination: string + parsedFiles: ParsedFile[] +} + +interface PrepareGitAssetsResult { + filesToPush: PushFile[] + modelFilename: string + textureNames: string[] + compressed: boolean + compressionError?: string +} + +export async function prepareGitAssets({ + folderName, + destination, + parsedFiles, +}: PrepareGitAssetsParams): Promise { + const filesToPush: PushFile[] = [] + const textureNames: string[] = [] + let modelFilename = '' + let compressed = false + let compressionError: string | undefined + + for (const pf of parsedFiles) { + let content = pf.buffer + + if (pf.isModel) { + modelFilename = pf.filename + + 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`) + + try { + const result = await compressWithBlender(tmpFilePath, compressedPath) + + if (result.success && existsSync(compressedPath)) { + content = await readFile(compressedPath) + compressed = true + await unlink(compressedPath).catch(() => {}) + } else { + compressionError = result.error + } + } finally { + await unlink(tmpFilePath).catch(() => {}) + await rm(tmpFolder, { recursive: true, force: true }).catch(() => {}) + } + } else { + textureNames.push(pf.filename) + + const textureResult = await compressTextureBuffer(pf.filename, pf.buffer) + content = textureResult.buffer + + if (textureResult.error && !compressionError) { + compressionError = textureResult.error + } + } + + filesToPush.push({ + path: `public/models/${destination}/${folderName}/${pf.filename}`, + contentBase64: content.toString('base64'), + }) + } + + return { + filesToPush, + modelFilename, + textureNames, + compressed, + compressionError, + } +} diff --git a/lib/texture-compression.ts b/lib/texture-compression.ts new file mode 100644 index 0000000..e13c92f --- /dev/null +++ b/lib/texture-compression.ts @@ -0,0 +1,47 @@ +import { extname } from 'path' +import sharp from 'sharp' + +interface TextureCompressionResult { + buffer: Buffer + compressed: boolean + error?: string +} + +export async function compressTextureBuffer( + filename: string, + buffer: Buffer, +): Promise { + const ext = extname(filename).toLowerCase() + + try { + if (ext === '.jpg' || ext === '.jpeg') { + return { + buffer: await sharp(buffer).jpeg({ quality: 82, mozjpeg: true }).toBuffer(), + compressed: true, + } + } + + if (ext === '.png') { + return { + buffer: await sharp(buffer).png({ compressionLevel: 9, adaptiveFiltering: true }).toBuffer(), + compressed: true, + } + } + + if (ext === '.webp') { + return { + buffer: await sharp(buffer).webp({ quality: 82 }).toBuffer(), + compressed: true, + } + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + return { + buffer, + compressed: false, + error: `Compression texture echouee pour ${filename}: ${message}`, + } + } + + return { buffer, compressed: false } +} diff --git a/lib/upload-api.ts b/lib/upload-api.ts index 88245aa..557073d 100644 --- a/lib/upload-api.ts +++ b/lib/upload-api.ts @@ -56,9 +56,11 @@ export async function checkFolderDiffs( secret: string, signal?: AbortSignal, ): Promise { - const params = new URLSearchParams({ folderName: folder.folderName, destination }) - const res = await fetch(`/api/upload/check?${params}`, { + const formData = buildUploadFormData(folder, destination) + const res = await fetch('/api/upload/check', { + method: 'POST', headers: { 'x-upload-secret': secret.trim() }, + body: formData, signal, }) @@ -73,39 +75,7 @@ export async function checkFolderDiffs( return { exists: false, diffs: [] } } - const remoteFiles: { name: string; size: number }[] = data.files || [] - const remoteMap = new Map(remoteFiles.map((f) => [f.name.toLowerCase(), f.size])) - - const diffs: FileDiff[] = [] - const localNames = new Set() - - // Model: skip size comparison (compression changes the size). - const modelKey = folder.modelFile.name.toLowerCase() - localNames.add(modelKey) - if (!remoteMap.has(modelKey)) { - diffs.push({ name: folder.modelFile.name, status: 'new' }) - } - - // Textures: compare by size - for (const tex of folder.textures) { - const key = tex.name.toLowerCase() - localNames.add(key) - const remoteSize = remoteMap.get(key) - if (remoteSize === undefined) { - diffs.push({ name: tex.name, status: 'new' }) - } else if (remoteSize !== tex.file.size) { - diffs.push({ name: tex.name, status: 'changed' }) - } - } - - // Deleted - for (const [name] of remoteMap) { - if (!localNames.has(name)) { - diffs.push({ name, status: 'deleted' }) - } - } - - return { exists: true, diffs } + return { exists: true, diffs: (data.diffs || []) as FileDiff[] } } // --------------------------------------------------------------------------- diff --git a/package-lock.json b/package-lock.json index 024bdce..f54f6f6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "upload-gltf", - "version": "0.0.2", + "version": "0.1.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "upload-gltf", - "version": "0.0.2", + "version": "0.1.5", "dependencies": { "@octokit/rest": "^22.0.1", "@react-three/drei": "^10.7.0", @@ -14,6 +14,7 @@ "next": "^16.2.1", "react": "^19.0.0", "react-dom": "^19.0.0", + "sharp": "^0.34.5", "three": "^0.183.0" }, "devDependencies": { @@ -70,7 +71,6 @@ "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", "license": "MIT", - "optional": true, "engines": { "node": ">=18" } @@ -1537,7 +1537,6 @@ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", "license": "Apache-2.0", - "optional": true, "engines": { "node": ">=8" } @@ -2481,7 +2480,6 @@ "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz", "integrity": "sha512-vFKC2IEtQnVhpT78h1Yp8wzwrf8CM+MzKMHGJZfBtzhZNycRFnXsHk6E5TxIkkMsgNS7mdX3AGB7x2QM2di4lA==", "license": "ISC", - "optional": true, "bin": { "semver": "bin/semver.js" }, @@ -2495,7 +2493,6 @@ "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", "hasInstallScript": true, "license": "Apache-2.0", - "optional": true, "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.2", diff --git a/package.json b/package.json index 5b5abc9..b833b11 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "next": "^16.2.1", "react": "^19.0.0", "react-dom": "^19.0.0", + "sharp": "^0.34.5", "three": "^0.183.0" }, "devDependencies": {