refactor: full codebase audit — extract modules, fix type safety, clean dead code

- Extract API helpers from UploadZone into lib/upload-api.ts (FormData builder, checkFolderDiffs, uploadDrive, uploadGit)
- Extract upload orchestration into hooks/useUploadOrchestrator.ts (UploadZone: 489 → 162 lines)
- Extract file diff classification into lib/diff-files.ts (from git route)
- Extract shared SVG icons into components/ui/icons.tsx (7 icons, 0 duplication)
- Extract shared modal wrapper into components/ui/Modal.tsx + ModalActions
- Extract DriveStatusLine sub-component from FolderCard
- Fix checkFolderDiffs silently swallowing auth/network errors (now throws)
- Fix type safety: remove as never casts, add isHttpError type guard, use discriminated union for validateFolder
- Fix nextcloud: cache getConfig, add max bound to findNextVersion, optimize mkdirRecursive (skip PROPFIND)
- Fix drive route: remove req.clone(), extend parseMultiUpload to return extra fields
- Fix commit message: model shown as unchanged with ↔️ on updates (not falsely marked as modified)
- Clean dead code: unused folderExists import, FileStatus/DriveStatus exports, ParsedFile.textureName, getConfig basePath
- Add security headers in next.config.ts (HSTS, X-Content-Type-Options, X-Frame-Options, etc.)
- Update README with new project structure
This commit is contained in:
Tom Boullay
2026-04-14 17:19:10 +02:00
parent 110d64ec33
commit 78f4aa83e0
26 changed files with 957 additions and 721 deletions
+15 -6
View File
@@ -144,11 +144,13 @@ update: upload-gltf add a new model -> farm/my-model
``` ```
update: upload-gltf update -> general/coffeetest update: upload-gltf update -> general/coffeetest
📦 Model
↔️ model.gltf (inchange)
🎨 Textures 🎨 Textures
🔄 metalness.jpg 🔄 metalness.jpg
``` ```
Symbols: `✅` new — `🔄` modified — `❌` missing or deleted Symbols: `✅` new — `🔄` modified — `↔️` unchanged (model always re-pushed) — `❌` missing or deleted
8. Orphan files (present on remote but not in the new upload) are deleted in the same commit 8. Orphan files (present on remote but not in the new upload) are deleted in the same commit
9. If Blender is unavailable, the original model is pushed as-is (graceful fallback) 9. If Blender is unavailable, the original model is pushed as-is (graceful fallback)
@@ -178,34 +180,41 @@ app/
├── layout.tsx # Root layout (next/font/google) ├── layout.tsx # Root layout (next/font/google)
└── page.tsx # Home page └── page.tsx # Home page
components/ components/
├── ui/
│ ├── icons.tsx # Shared SVG icon components
│ └── Modal.tsx # Shared modal wrapper + ModalActions
├── upload/ ├── upload/
│ ├── SecretInput.tsx # Access key input │ ├── SecretInput.tsx # Access key input
│ ├── DestinationPicker.tsx # Destination selector │ ├── DestinationPicker.tsx # Destination selector
│ ├── FolderDropzone.tsx # Folder drag & drop / picker │ ├── FolderDropzone.tsx # Folder drag & drop / picker
│ ├── FolderCard.tsx # Folder status card (Drive + Git) │ ├── FolderCard.tsx # Folder status card (Drive + Git)
│ ├── DriveStatusLine.tsx # Drive/Git status sub-line
│ ├── WarningBanner.tsx # Missing texture warnings │ ├── WarningBanner.tsx # Missing texture warnings
│ ├── OverwriteConfirmModal.tsx # Diff confirmation dialog │ ├── OverwriteConfirmModal.tsx # Diff confirmation dialog
│ ├── NoChangesModal.tsx # "No changes detected" dialog │ ├── NoChangesModal.tsx # "No changes detected" dialog
│ ├── DriveErrorModal.tsx # "Drive failed, continue?" dialog │ ├── DriveErrorModal.tsx # "Drive failed, continue?" dialog
│ └── ActionButtons.tsx # Upload / Cancel / Reset buttons │ └── ActionButtons.tsx # Upload / Cancel / Reset buttons
├── UploadZone.tsx # Main orchestrator (Drive → Git flow) ├── UploadZone.tsx # Main upload page (rendering only)
├── ModelViewer.tsx # Lazy wrapper for 3D viewer ├── ModelViewer.tsx # Lazy wrapper for 3D viewer
└── SceneViewer.tsx # Three.js Canvas └── SceneViewer.tsx # Three.js Canvas
hooks/ hooks/
├── useSecret.ts # Secret key state management ├── useSecret.ts # Secret key state management
── useFolderEntries.ts # Folder entries state management ── useFolderEntries.ts # Folder entries state management
└── useUploadOrchestrator.ts # Upload pipeline orchestration (Drive → Git)
lib/ lib/
├── constants.ts # Shared constants, destinations, extensions ├── constants.ts # Shared constants, destinations, extensions
├── types.ts # Server types (ParsedFile, FileDiff, etc.) ├── types.ts # Server types (ParsedFile, FileDiff, etc.)
├── client-types.ts # Client types (FolderEntry, DriveStatus, etc.) ├── client-types.ts # Client types (FolderEntry, DriveStatus, etc.)
├── upload-api.ts # Client-side API helpers (check, uploadDrive, uploadGit)
├── diff-files.ts # File diff classification (new/changed/unchanged/deleted)
├── sanitize.ts # Filename sanitization ├── sanitize.ts # Filename sanitization
├── auth.ts # Upload secret validation (timing-safe) ├── auth.ts # Upload secret validation (timing-safe)
├── github.ts # Octokit helpers (getRemoteFolder, pushAllToGitHub) ├── github.ts # Octokit helpers (getRemoteFolder, pushAllToGitHub)
├── nextcloud.ts # Nextcloud WebDAV client (native fetch) ├── nextcloud.ts # Nextcloud WebDAV client (native fetch, cached config)
├── blender.ts # Blender Draco compression ├── blender.ts # Blender Draco compression
├── commit-message.ts # Commit message builder ├── commit-message.ts # Commit message builder
├── parse-upload.ts # FormData parser + validation ├── parse-upload.ts # FormData parser + validation
├── validate-folder.ts # Client-side folder validation ├── validate-folder.ts # Client-side folder validation (discriminated union)
└── format-bytes.ts # Byte formatting utility └── format-bytes.ts # Byte formatting utility
scripts/ scripts/
└── compress.py # Blender Draco compression script └── compress.py # Blender Draco compression script
+1 -1
View File
@@ -23,7 +23,7 @@ export async function GET(req: NextRequest) {
return NextResponse.json({ success: false, error: 'Parametres manquants' }, { status: 400 }) return NextResponse.json({ success: false, error: 'Parametres manquants' }, { status: 400 })
} }
if (!VALID_DESTINATIONS.has(destination as never)) { if (!VALID_DESTINATIONS.has(destination)) {
return NextResponse.json({ success: false, error: 'Destination invalide' }, { status: 400 }) return NextResponse.json({ success: false, error: 'Destination invalide' }, { status: 400 })
} }
+7 -15
View File
@@ -2,7 +2,6 @@ import { NextRequest, NextResponse } from 'next/server'
import { validateUploadSecret } from '@/lib/auth' import { validateUploadSecret } from '@/lib/auth'
import { parseMultiUpload } from '@/lib/parse-upload' import { parseMultiUpload } from '@/lib/parse-upload'
import { import {
folderExists,
mkdirRecursive, mkdirRecursive,
moveFolder, moveFolder,
uploadFile, uploadFile,
@@ -22,11 +21,11 @@ export const dynamic = 'force-dynamic'
// - action: "new" | "replace" // - action: "new" | "replace"
// //
// Versioning logic: // Versioning logic:
// VF/{folderName} latest version // VF/{folderName} <- latest version
// V1/{folderName} first archive, V2/ second, etc. // V1/{folderName} <- first archive, V2/ second, etc.
// //
// action="new" just mkdir + upload into VF/ // action="new" -> just mkdir + upload into VF/
// action="replace" archive VF Vx, then re-upload all files into VF/ // action="replace" -> archive VF -> Vx, then re-upload all files into VF/
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export async function POST(req: NextRequest) { export async function POST(req: NextRequest) {
@@ -42,11 +41,7 @@ export async function POST(req: NextRequest) {
) )
} }
// --- Parse files --- // --- Parse files (includes extra fields like "action") ---
// Clone the request before parseMultiUpload consumes the body stream,
// so we can read the `action` field separately.
const cloned = req.clone()
let folderName: string let folderName: string
let parsedFiles: Awaited<ReturnType<typeof parseMultiUpload>>['files'] let parsedFiles: Awaited<ReturnType<typeof parseMultiUpload>>['files']
let action: string let action: string
@@ -55,10 +50,7 @@ export async function POST(req: NextRequest) {
const parsed = await parseMultiUpload(req) const parsed = await parseMultiUpload(req)
folderName = parsed.folderName folderName = parsed.folderName
parsedFiles = parsed.files parsedFiles = parsed.files
action = parsed.extra.action?.trim() || 'new'
// Read action from the cloned request (parseMultiUpload doesn't expose it)
const formData = await cloned.formData()
action = (formData.get('action') as string | null)?.trim() || 'new'
} 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 })
@@ -75,7 +67,7 @@ export async function POST(req: NextRequest) {
// 2. Ensure Vx/ exists // 2. Ensure Vx/ exists
await mkdirRecursive(`${basePath}/${nextVersion}`) await mkdirRecursive(`${basePath}/${nextVersion}`)
// 3. Move VF/{folderName} Vx/{folderName} // 3. Move VF/{folderName} -> Vx/{folderName}
await moveFolder(vfFolderPath, `${basePath}/${nextVersion}/${folderName}`) await moveFolder(vfFolderPath, `${basePath}/${nextVersion}/${folderName}`)
// 4. Re-create VF/{folderName} // 4. Re-create VF/{folderName}
+4 -46
View File
@@ -7,8 +7,8 @@ import { parseMultiUpload } from '@/lib/parse-upload'
import { compressWithBlender } from '@/lib/blender' import { compressWithBlender } from '@/lib/blender'
import { getRemoteFolder, pushAllToGitHub } from '@/lib/github' import { getRemoteFolder, pushAllToGitHub } from '@/lib/github'
import { buildCommitMessage } from '@/lib/commit-message' import { buildCommitMessage } from '@/lib/commit-message'
import { TMP_DIR, MODEL_EXTENSIONS } from '@/lib/constants' import { classifyFileChanges } from '@/lib/diff-files'
import type { FileChange } from '@/lib/types' import { TMP_DIR } from '@/lib/constants'
export const runtime = 'nodejs' export const runtime = 'nodejs'
export const dynamic = 'force-dynamic' export const dynamic = 'force-dynamic'
@@ -85,8 +85,6 @@ export async function POST(req: NextRequest) {
} }
// --- Detect existing files and classify changes --- // --- Detect existing files and classify changes ---
// Models: always re-push (compression changes size, can't compare with LFS remote)
// Textures: compare by size (not compressed, reliable)
const folderPath = `public/models/${destination}/${folderName}` const folderPath = `public/models/${destination}/${folderName}`
let remoteFileMap: Map<string, number> let remoteFileMap: Map<string, number>
@@ -99,48 +97,8 @@ export async function POST(req: NextRequest) {
const isReplace = remoteFileMap.size > 0 const isReplace = remoteFileMap.size > 0
const fileChanges = new Map<string, FileChange>() const { fileChanges, changedFilesToPush, deletedFileNames, deletePaths } =
const changedFilesToPush: { path: string; contentBase64: string }[] = [] classifyFileChanges(filesToPush, remoteFileMap, folderPath)
for (const f of filesToPush) {
const filename = f.path.split('/').pop() ?? ''
const ext = filename.slice(filename.lastIndexOf('.')).toLowerCase()
const isModel = MODEL_EXTENSIONS.has(ext)
if (isModel) {
// Model: always re-push since compression makes size comparison unreliable.
// Mark as 'unchanged' for the commit message when the folder already exists,
// because we can't know if the model really changed after Blender compression.
const remoteSize = remoteFileMap.get(filename.toLowerCase())
fileChanges.set(filename.toLowerCase(), remoteSize === undefined ? 'new' : 'unchanged')
changedFilesToPush.push(f)
} else {
// Texture: compare by size
const localSize = Buffer.from(f.contentBase64, 'base64').length
const remoteSize = remoteFileMap.get(filename.toLowerCase())
if (remoteSize === undefined) {
fileChanges.set(filename.toLowerCase(), 'new')
changedFilesToPush.push(f)
} else if (remoteSize !== localSize) {
fileChanges.set(filename.toLowerCase(), 'changed')
changedFilesToPush.push(f)
} else {
fileChanges.set(filename.toLowerCase(), 'unchanged')
}
}
}
// Files on remote not in the new upload → deleted (orphans)
const newFileNames = new Set(filesToPush.map((f) => (f.path.split('/').pop() ?? '').toLowerCase()))
const deletedFileNames: string[] = []
const deletePaths: string[] = []
for (const [name] of remoteFileMap) {
if (!newFileNames.has(name)) {
deletedFileNames.push(name)
deletePaths.push(`${folderPath}/${name}`)
}
}
// If nothing changed, don't create an empty commit // If nothing changed, don't create an empty commit
if (changedFilesToPush.length === 0 && deletePaths.length === 0) { if (changedFilesToPush.length === 0 && deletePaths.length === 0) {
+25 -352
View File
@@ -1,11 +1,9 @@
'use client' 'use client'
import { useState, useRef, useCallback } from 'react'
import type { Destination } from '@/lib/constants'
import type { FolderEntry } from '@/lib/client-types' import type { FolderEntry } from '@/lib/client-types'
import type { FileDiff } from '@/lib/types'
import { useSecret } from '@/hooks/useSecret' import { useSecret } from '@/hooks/useSecret'
import { useFolderEntries } from '@/hooks/useFolderEntries' import { useFolderEntries } from '@/hooks/useFolderEntries'
import { useUploadOrchestrator } from '@/hooks/useUploadOrchestrator'
import SecretInput from './upload/SecretInput' import SecretInput from './upload/SecretInput'
import DestinationPicker from './upload/DestinationPicker' import DestinationPicker from './upload/DestinationPicker'
import FolderDropzone from './upload/FolderDropzone' import FolderDropzone from './upload/FolderDropzone'
@@ -15,162 +13,6 @@ import OverwriteConfirmModal from './upload/OverwriteConfirmModal'
import NoChangesModal from './upload/NoChangesModal' import NoChangesModal from './upload/NoChangesModal'
import DriveErrorModal from './upload/DriveErrorModal' import DriveErrorModal from './upload/DriveErrorModal'
// ---------------------------------------------------------------------------
// API helpers
// ---------------------------------------------------------------------------
interface CheckResult {
exists: boolean
diffs: FileDiff[]
}
async function checkFolderDiffs(
folder: FolderEntry,
destination: string,
secret: string,
signal?: AbortSignal,
): Promise<CheckResult> {
try {
const params = new URLSearchParams({ folderName: folder.folderName, destination })
const res = await fetch(`/api/upload/check?${params}`, {
headers: { 'x-upload-secret': secret.trim() },
signal,
})
const data = await res.json()
if (!data.success || !data.exists) {
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<string>()
// 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 }
} catch {
return { exists: false, diffs: [] }
}
}
/** Upload original files to Nextcloud Drive (no Blender compression). */
async function uploadDrive(
folder: FolderEntry,
secret: string,
destination: string,
action: 'new' | 'replace',
signal?: AbortSignal,
): Promise<{ success: boolean; error?: string }> {
const formData = new FormData()
formData.append('folderName', folder.folderName)
formData.append('destination', destination)
formData.append('action', action)
formData.append('files', folder.modelFile)
formData.append('fileTypes', 'model')
formData.append('textureNames', '')
for (const tex of folder.textures) {
formData.append('files', tex.file)
formData.append('fileTypes', 'texture')
formData.append('textureNames', tex.name)
}
try {
const res = await fetch('/api/upload/drive', {
method: 'POST',
headers: { 'x-upload-secret': secret.trim() },
body: formData,
signal,
})
const data = await res.json()
if (!data.success) return { success: false, error: data.error }
return { success: true }
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') {
return { success: false, error: 'Upload annule' }
}
return { success: false, error: 'Erreur reseau (Drive)' }
}
}
/** Upload files to GitHub (with Blender compression). */
async function uploadGit(
folder: FolderEntry,
secret: string,
destination: string,
onProgress: (pct: number) => void,
signal?: AbortSignal,
): Promise<{ success: boolean; filename?: string; error?: string }> {
const formData = new FormData()
formData.append('folderName', folder.folderName)
formData.append('destination', destination)
formData.append('files', folder.modelFile)
formData.append('fileTypes', 'model')
formData.append('textureNames', '')
for (const tex of folder.textures) {
formData.append('files', tex.file)
formData.append('fileTypes', 'texture')
formData.append('textureNames', tex.name)
}
onProgress(10)
try {
const res = await fetch('/api/upload/git', {
method: 'POST',
headers: { 'x-upload-secret': secret.trim() },
body: formData,
signal,
})
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 (err) {
if (err instanceof DOMException && err.name === 'AbortError') {
return { success: false, error: 'Upload annule' }
}
return { success: false, error: 'Erreur reseau' }
}
}
// ---------------------------------------------------------------------------
// UploadZone — orchestrator
// ---------------------------------------------------------------------------
export default function UploadZone() { export default function UploadZone() {
const { const {
secret, secret,
@@ -192,27 +34,30 @@ export default function UploadZone() {
hasErrors, hasErrors,
} = useFolderEntries() } = useFolderEntries()
const [isUploading, setIsUploading] = useState(false) const {
const [globalError, setGlobalError] = useState<string | null>(null) isUploading,
const [destination, setDestination] = useState<Destination | null>(null) globalError,
const [overwriteConfirm, setOverwriteConfirm] = useState<{ setGlobalError,
folderName: string destination,
diffs: FileDiff[] setDestination,
} | null>(null) overwriteConfirm,
const [noChangesFolder, setNoChangesFolder] = useState<string | null>(null) setOverwriteConfirm,
noChangesFolder,
// Drive error modal state setNoChangesFolder,
const [driveError, setDriveError] = useState<{ driveError,
error: string handleUpload,
folderIndex: number handleDriveContinue,
} | null>(null) handleDriveCancel,
handleCancel,
const abortRef = useRef<AbortController | null>(null) handleReset,
proceedUpload,
// Tracks the check result so we know if it's "new" or "replace" for the Drive } = useUploadOrchestrator({
const checkResultRef = useRef<CheckResult>({ exists: false, diffs: [] }) secret,
setSecretError,
// -- Handlers -- entries,
updateEntry,
resetEntries,
})
const handleFolderSelected = (entry: FolderEntry) => { const handleFolderSelected = (entry: FolderEntry) => {
setGlobalError(null) setGlobalError(null)
@@ -226,180 +71,8 @@ export default function UploadZone() {
} }
} }
const handleUpload = async () => {
if (!secret.trim()) {
setSecretError("La cle d'acces est requise")
return
}
if (!destination) {
setGlobalError('Veuillez choisir une destination')
return
}
if (entries.length === 0) return
setSecretError(null)
setGlobalError(null)
const folder = entries[0]
const check = await checkFolderDiffs(folder, destination!, secret, abortRef.current?.signal)
checkResultRef.current = check
if (check.exists) {
if (check.diffs.length === 0) {
setNoChangesFolder(folder.folderName)
return
}
setOverwriteConfirm({ folderName: folder.folderName, diffs: check.diffs })
return
}
await proceedUpload()
}
/**
* Main upload flow: Drive first, then Git.
* If Drive fails, show DriveErrorModal so user can decide.
*/
const proceedUpload = async () => {
setOverwriteConfirm(null)
setIsUploading(true)
setGlobalError(null)
const controller = new AbortController()
abortRef.current = controller
for (let i = 0; i < entries.length; i++) {
if (entries[i].status === 'success') continue
if (controller.signal.aborted) break
const folderEntry = entries[i]
const driveAction = checkResultRef.current.exists ? 'replace' : 'new'
// ---- Step 1: Drive upload ----
updateEntry(i, {
status: 'uploading',
progress: 1,
error: undefined,
driveStatus: 'uploading',
driveError: undefined,
})
const driveResult = await uploadDrive(
folderEntry,
secret,
destination!,
driveAction as 'new' | 'replace',
controller.signal,
)
if (!driveResult.success) {
// Drive failed — pause and ask user
updateEntry(i, { driveStatus: 'error', driveError: driveResult.error })
setDriveError({ error: driveResult.error || 'Erreur inconnue', folderIndex: i })
// Stop here — the DriveErrorModal callbacks will resume or cancel
return
}
updateEntry(i, { driveStatus: 'success', progress: 50 })
// ---- Step 2: Git upload ----
await pushGit(i, controller.signal)
}
abortRef.current = null
setIsUploading(false)
}
/** Push a single folder to Git. Called after Drive succeeds or user skips Drive. */
const pushGit = async (index: number, signal?: AbortSignal) => {
const folderEntry = entries[index]
const gitResult = await uploadGit(
folderEntry,
secret,
destination!,
(pct) => updateEntry(index, { progress: 50 + Math.round(pct / 2) }),
signal,
)
updateEntry(index, {
status: gitResult.success ? 'success' : 'error',
progress: gitResult.success ? 100 : 0,
error: gitResult.success ? undefined : gitResult.error,
filename: gitResult.filename,
})
}
/** User chose "Continue without Drive" in DriveErrorModal. */
const handleDriveContinue = useCallback(async () => {
if (!driveError) return
const idx = driveError.folderIndex
setDriveError(null)
updateEntry(idx, { driveStatus: 'skipped' })
// Continue with Git
const signal = abortRef.current?.signal
await pushGit(idx, signal)
// Continue with remaining entries
for (let i = idx + 1; i < entries.length; i++) {
if (entries[i].status === 'success') continue
if (abortRef.current?.signal.aborted) break
// For subsequent entries after a Drive skip, also skip Drive
updateEntry(i, {
status: 'uploading',
progress: 0,
error: undefined,
driveStatus: 'skipped',
})
await pushGit(i, abortRef.current?.signal)
}
abortRef.current = null
setIsUploading(false)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [driveError, entries, secret, destination])
/** User chose "Cancel" in DriveErrorModal. */
const handleDriveCancel = useCallback(() => {
if (!driveError) return
const idx = driveError.folderIndex
setDriveError(null)
updateEntry(idx, {
status: 'error',
progress: 0,
error: 'Upload annule (Drive echoue)',
driveStatus: 'error',
})
abortRef.current?.abort()
abortRef.current = null
setIsUploading(false)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [driveError])
const handleCancel = () => {
abortRef.current?.abort()
abortRef.current = null
setIsUploading(false)
}
const handleReset = () => {
resetEntries()
setGlobalError(null)
setIsUploading(false)
setDriveError(null)
checkResultRef.current = { exists: false, diffs: [] }
}
const hasPendingOrErrors = entries.some((f) => f.status === 'pending' || f.status === 'error') const hasPendingOrErrors = entries.some((f) => f.status === 'pending' || f.status === 'error')
// -- Render --
return ( return (
<div className="w-full max-w-2xl space-y-4"> <div className="w-full max-w-2xl space-y-4">
<SecretInput <SecretInput
+65
View File
@@ -0,0 +1,65 @@
// ---------------------------------------------------------------------------
// Shared modal wrapper — handles overlay, centering, dialog role, aria
// ---------------------------------------------------------------------------
import type { ReactNode } from 'react'
interface ModalProps {
ariaLabelledBy: string
children: ReactNode
}
export default function Modal({ ariaLabelledBy, children }: ModalProps) {
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
role="dialog"
aria-modal="true"
aria-labelledby={ariaLabelledBy}
>
<div className="bg-black-900 border border-white/20 rounded-2xl p-6 max-w-md w-full mx-4 space-y-4">
{children}
</div>
</div>
)
}
// ---------------------------------------------------------------------------
// Shared modal footer with two buttons
// ---------------------------------------------------------------------------
interface ModalActionsProps {
cancelLabel: string
confirmLabel: string
onCancel: () => void
onConfirm: () => void
/** Tailwind classes for the confirm button (default: white bg) */
confirmClassName?: string
}
export function ModalActions({
cancelLabel,
confirmLabel,
onCancel,
onConfirm,
confirmClassName = 'bg-white text-[#000000] hover:bg-gray-200',
}: ModalActionsProps) {
return (
<div className="flex gap-3">
<button
onClick={onCancel}
className="flex-1 bg-black-700 text-gray-300 font-medium text-sm
py-2.5 px-4 rounded-xl border border-white/10 transition-colors duration-150
hover:bg-black-600"
>
{cancelLabel}
</button>
<button
onClick={onConfirm}
className={`flex-1 font-medium text-sm py-2.5 px-4 rounded-xl transition-colors duration-150 ${confirmClassName}`}
>
{confirmLabel}
</button>
</div>
)
}
+72
View File
@@ -0,0 +1,72 @@
// ---------------------------------------------------------------------------
// Shared SVG icon components
// ---------------------------------------------------------------------------
interface IconProps {
className?: string
}
export function SpinnerIcon({ className = 'w-4 h-4' }: IconProps) {
return (
<svg className={`${className} animate-spin`} fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
)
}
export function CheckIcon({ className = 'w-4 h-4' }: IconProps) {
return (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
)
}
export function XIcon({ className = 'w-4 h-4' }: IconProps) {
return (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
)
}
export function ChevronIcon({ className = 'w-4 h-4' }: IconProps) {
return (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
</svg>
)
}
export function WarningIcon({ className = 'w-5 h-5' }: IconProps) {
return (
<svg className={className} 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>
)
}
export function FolderIcon({ className = 'w-6 h-6' }: IconProps) {
return (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"
/>
</svg>
)
}
export function InfoIcon({ className = 'w-5 h-5' }: IconProps) {
return (
<svg className={className} fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v2m0 4h.01" />
</svg>
)
}
+30 -52
View File
@@ -1,3 +1,6 @@
import Modal, { ModalActions } from '@/components/ui/Modal'
import { WarningIcon } from '@/components/ui/icons'
interface DriveErrorModalProps { interface DriveErrorModalProps {
error: string error: string
onCancel: () => void onCancel: () => void
@@ -10,60 +13,35 @@ export default function DriveErrorModal({
onContinue, onContinue,
}: DriveErrorModalProps) { }: DriveErrorModalProps) {
return ( return (
<div <Modal ariaLabelledBy="drive-error-title">
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" <div className="flex items-center gap-3">
role="dialog" <div className="w-10 h-10 rounded-full bg-red-900/30 flex items-center justify-center shrink-0">
aria-modal="true" <WarningIcon className="w-5 h-5 text-red-400" />
aria-labelledby="drive-error-title"
>
<div className="bg-black-900 border border-white/20 rounded-2xl p-6 max-w-md w-full mx-4 space-y-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-red-900/30 flex items-center justify-center shrink-0">
<svg className="w-5 h-5 text-red-400" 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>
</div>
<div>
<h3 id="drive-error-title" className="text-sm font-semibold text-gray-100">
Erreur Drive
</h3>
<p className="text-xs text-gray-400 mt-0.5">
L&apos;archivage sur le Drive a echoue. Les fichiers n&apos;ont pas ete versionnes.
</p>
</div>
</div> </div>
<div>
<div className="bg-black-800 border border-white/10 rounded-xl p-3"> <h3 id="drive-error-title" className="text-sm font-semibold text-gray-100">
<p className="text-xs text-red-400 font-mono break-all">{error}</p> Erreur Drive
</div> </h3>
<p className="text-xs text-gray-400 mt-0.5">
<p className="text-xs text-gray-400"> L&apos;archivage sur le Drive a echoue. Les fichiers n&apos;ont pas ete versionnes.
Voulez-vous quand meme envoyer les fichiers aux devs via GitHub ? </p>
</p>
<div className="flex gap-3">
<button
onClick={onCancel}
className="flex-1 bg-black-700 text-gray-300 font-medium text-sm
py-2.5 px-4 rounded-xl border border-white/10 transition-colors duration-150
hover:bg-black-600"
>
Annuler
</button>
<button
onClick={onContinue}
className="flex-1 bg-white text-[#000000] font-medium text-sm
py-2.5 px-4 rounded-xl transition-colors duration-150
hover:bg-gray-200"
>
Envoyer sur Git seulement
</button>
</div> </div>
</div> </div>
</div>
<div className="bg-black-800 border border-white/10 rounded-xl p-3">
<p className="text-xs text-red-400 font-mono break-all">{error}</p>
</div>
<p className="text-xs text-gray-400">
Voulez-vous quand meme envoyer les fichiers aux devs via GitHub ?
</p>
<ModalActions
cancelLabel="Annuler"
confirmLabel="Envoyer sur Git seulement"
onCancel={onCancel}
onConfirm={onContinue}
/>
</Modal>
) )
} }
+44
View File
@@ -0,0 +1,44 @@
// ---------------------------------------------------------------------------
// Drive/Git status sub-line for FolderCard
// ---------------------------------------------------------------------------
import { SpinnerIcon, XIcon, InfoIcon } from '@/components/ui/icons'
import type { FolderEntry } from '@/lib/client-types'
interface DriveStatusLineProps {
driveStatus: NonNullable<FolderEntry['driveStatus']>
driveError?: string
}
export default function DriveStatusLine({ driveStatus, driveError }: DriveStatusLineProps) {
if (driveStatus === 'pending') return null
return (
<div className="flex items-center gap-1.5 mt-0.5">
{driveStatus === 'uploading' && (
<>
<SpinnerIcon className="w-3 h-3 text-gray-400" />
<span className="text-xs text-gray-400">Upload Drive en cours...</span>
</>
)}
{driveStatus === 'success' && (
<>
<SpinnerIcon className="w-3 h-3 text-gray-400" />
<span className="text-xs text-gray-400">Upload Git en cours...</span>
</>
)}
{driveStatus === 'error' && (
<>
<XIcon className="w-3 h-3 text-red-400" />
<span className="text-xs text-red-400 truncate">Drive echoue{driveError ? ` : ${driveError}` : ''}</span>
</>
)}
{driveStatus === 'skipped' && (
<>
<InfoIcon className="w-3 h-3 text-yellow-400" />
<span className="text-xs text-yellow-400">Drive ignore</span>
</>
)}
</div>
)
}
+9 -55
View File
@@ -1,6 +1,8 @@
import dynamic from 'next/dynamic' import dynamic from 'next/dynamic'
import type { FolderEntry } from '@/lib/client-types' import type { FolderEntry } from '@/lib/client-types'
import { formatBytes } from '@/lib/format-bytes' import { formatBytes } from '@/lib/format-bytes'
import { SpinnerIcon, CheckIcon, XIcon, ChevronIcon } from '@/components/ui/icons'
import DriveStatusLine from './DriveStatusLine'
import WarningBanner from './WarningBanner' import WarningBanner from './WarningBanner'
const ModelViewer = dynamic(() => import('../ModelViewer'), { ssr: false }) const ModelViewer = dynamic(() => import('../ModelViewer'), { ssr: false })
@@ -20,22 +22,15 @@ export default function FolderCard({ entry, index, onToggleViewer, onRemove }: F
<div className="shrink-0"> <div className="shrink-0">
{entry.status === 'success' ? ( {entry.status === 'success' ? (
<div className="w-8 h-8 rounded-full bg-green-900/30 flex items-center justify-center"> <div className="w-8 h-8 rounded-full bg-green-900/30 flex items-center justify-center">
<svg className="w-4 h-4 text-green-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}> <CheckIcon className="w-4 h-4 text-green-400" />
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
</div> </div>
) : entry.status === 'error' ? ( ) : entry.status === 'error' ? (
<div className="w-8 h-8 rounded-full bg-red-900/30 flex items-center justify-center"> <div className="w-8 h-8 rounded-full bg-red-900/30 flex items-center justify-center">
<svg className="w-4 h-4 text-red-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}> <XIcon className="w-4 h-4 text-red-400" />
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</div> </div>
) : entry.status === 'uploading' ? ( ) : entry.status === 'uploading' ? (
<div className="w-8 h-8 rounded-full bg-gray-700 flex items-center justify-center"> <div className="w-8 h-8 rounded-full bg-gray-700 flex items-center justify-center">
<svg className="w-4 h-4 text-gray-300 animate-spin" fill="none" viewBox="0 0 24 24"> <SpinnerIcon className="w-4 h-4 text-gray-300" />
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
</div> </div>
) : ( ) : (
<button <button
@@ -45,12 +40,7 @@ export default function FolderCard({ entry, index, onToggleViewer, onRemove }: F
entry.modelUrl ? 'bg-black-700 hover:bg-gray-700 cursor-pointer' : 'bg-black-700 cursor-default' entry.modelUrl ? 'bg-black-700 hover:bg-gray-700 cursor-pointer' : 'bg-black-700 cursor-default'
}`} }`}
> >
<svg <ChevronIcon className={`w-4 h-4 text-gray-500 transition-transform ${entry.viewerOpen ? 'rotate-180' : ''}`} />
className={`w-4 h-4 text-gray-500 transition-transform ${entry.viewerOpen ? 'rotate-180' : ''}`}
fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}
>
<path strokeLinecap="round" strokeLinejoin="round" d="M19 9l-7 7-7-7" />
</svg>
</button> </button>
)} )}
</div> </div>
@@ -73,43 +63,9 @@ export default function FolderCard({ entry, index, onToggleViewer, onRemove }: F
{/* Drive status sub-line (only during upload, not after success) */} {/* Drive status sub-line (only during upload, not after success) */}
{entry.status !== 'success' && entry.driveStatus && entry.driveStatus !== 'pending' && ( {entry.status !== 'success' && entry.driveStatus && entry.driveStatus !== 'pending' && (
<div className="flex items-center gap-1.5 mt-0.5"> <DriveStatusLine driveStatus={entry.driveStatus} driveError={entry.driveError} />
{entry.driveStatus === 'uploading' && (
<>
<svg className="w-3 h-3 text-gray-400 animate-spin" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
<span className="text-xs text-gray-400">Upload Drive en cours...</span>
</>
)}
{entry.driveStatus === 'success' && (
<>
<svg className="w-3 h-3 text-gray-400 animate-spin" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
<span className="text-xs text-gray-400">Upload Git en cours...</span>
</>
)}
{entry.driveStatus === 'error' && (
<>
<svg className="w-3 h-3 text-red-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2.5}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
<span className="text-xs text-red-400 truncate">Drive echoue{entry.driveError ? ` : ${entry.driveError}` : ''}</span>
</>
)}
{entry.driveStatus === 'skipped' && (
<>
<svg className="w-3 h-3 text-yellow-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path strokeLinecap="round" strokeLinejoin="round" d="M12 9v2m0 4h.01" />
</svg>
<span className="text-xs text-yellow-400">Drive ignore</span>
</>
)}
</div>
)} )}
{entry.status === 'uploading' && ( {entry.status === 'uploading' && (
<div className="mt-1.5 w-full h-1 bg-black-700 rounded-full overflow-hidden"> <div className="mt-1.5 w-full h-1 bg-black-700 rounded-full overflow-hidden">
<div <div
@@ -127,9 +83,7 @@ export default function FolderCard({ entry, index, onToggleViewer, onRemove }: F
aria-label="Supprimer le dossier" aria-label="Supprimer le dossier"
className="shrink-0 text-gray-600 hover:text-red-400 transition" className="shrink-0 text-gray-600 hover:text-red-400 transition"
> >
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <XIcon className="w-4 h-4" />
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
</button> </button>
)} )}
</div> </div>
+5 -16
View File
@@ -1,5 +1,6 @@
import type { FolderEntry } from '@/lib/client-types' import type { FolderEntry } from '@/lib/client-types'
import { validateFolder } from '@/lib/validate-folder' import { validateFolder } from '@/lib/validate-folder'
import { FolderIcon } from '@/components/ui/icons'
interface FolderDropzoneProps { interface FolderDropzoneProps {
isUploading: boolean isUploading: boolean
@@ -20,19 +21,19 @@ export default function FolderDropzone({
const folderName = fileArray[0].webkitRelativePath?.split('/')[0] || 'folder' const folderName = fileArray[0].webkitRelativePath?.split('/')[0] || 'folder'
const validation = validateFolder(fileArray) const validation = validateFolder(fileArray)
if (validation.errors.length > 0) { if (!validation.ok) {
onError(validation.errors.join(' | ')) onError(validation.errors.join(' | '))
return return
} }
const entry: FolderEntry = { const entry: FolderEntry = {
folderName, folderName,
modelFile: validation.model!, modelFile: validation.model,
textures: validation.textures, textures: validation.textures,
status: 'pending', status: 'pending',
progress: 0, progress: 0,
warnings: validation.warnings, warnings: validation.warnings,
modelUrl: URL.createObjectURL(validation.model!), modelUrl: URL.createObjectURL(validation.model),
viewerOpen: true, viewerOpen: true,
} }
onFolderSelected(entry) onFolderSelected(entry)
@@ -59,19 +60,7 @@ export default function FolderDropzone({
> >
<div className="flex justify-center mb-3"> <div className="flex justify-center mb-3">
<div className="w-12 h-12 rounded-full flex items-center justify-center bg-black-700"> <div className="w-12 h-12 rounded-full flex items-center justify-center bg-black-700">
<svg <FolderIcon className="w-6 h-6 text-gray-400" />
className="w-6 h-6 text-gray-400"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
strokeWidth={1.5}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z"
/>
</svg>
</div> </div>
</div> </div>
<p className="text-sm font-medium text-gray-300"> <p className="text-sm font-medium text-gray-300">
+22 -44
View File
@@ -1,3 +1,6 @@
import Modal, { ModalActions } from '@/components/ui/Modal'
import { CheckIcon } from '@/components/ui/icons'
interface NoChangesModalProps { interface NoChangesModalProps {
destination: string destination: string
folderName: string folderName: string
@@ -12,52 +15,27 @@ export default function NoChangesModal({
onModify, onModify,
}: NoChangesModalProps) { }: NoChangesModalProps) {
return ( return (
<div <Modal ariaLabelledBy="no-changes-title">
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" <div className="flex items-center gap-3">
role="dialog" <div className="w-10 h-10 rounded-full bg-gray-700/50 flex items-center justify-center shrink-0">
aria-modal="true" <CheckIcon className="w-5 h-5 text-gray-400" />
aria-labelledby="no-changes-title"
>
<div className="bg-black-900 border border-white/20 rounded-2xl p-6 max-w-md w-full mx-4 space-y-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-gray-700/50 flex items-center justify-center shrink-0">
<svg className="w-5 h-5 text-gray-400" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M5 13l4 4L19 7"
/>
</svg>
</div>
<div>
<h3 id="no-changes-title" className="text-sm font-semibold text-gray-100">
Aucun changement detecte
</h3>
<p className="text-xs text-gray-400 mt-0.5">
Le dossier <span className="font-mono text-gray-300">{destination}/{folderName}</span> est identique au contenu distant. Rien a envoyer.
</p>
</div>
</div> </div>
<div>
<div className="flex gap-3"> <h3 id="no-changes-title" className="text-sm font-semibold text-gray-100">
<button Aucun changement detecte
onClick={onCancel} </h3>
className="flex-1 bg-black-700 text-gray-300 font-medium text-sm <p className="text-xs text-gray-400 mt-0.5">
py-2.5 px-4 rounded-xl border border-white/10 transition-colors duration-150 Le dossier <span className="font-mono text-gray-300">{destination}/{folderName}</span> est identique au contenu distant. Rien a envoyer.
hover:bg-black-600" </p>
>
Annuler
</button>
<button
onClick={onModify}
className="flex-1 bg-white text-[#000000] font-medium text-sm
py-2.5 px-4 rounded-xl transition-colors duration-150
hover:bg-gray-200"
>
Modifier
</button>
</div> </div>
</div> </div>
</div>
<ModalActions
cancelLabel="Annuler"
confirmLabel="Modifier"
onCancel={onCancel}
onConfirm={onModify}
/>
</Modal>
) )
} }
+53 -75
View File
@@ -1,4 +1,6 @@
import type { FileDiff } from '@/lib/types' import type { FileDiff } from '@/lib/types'
import Modal, { ModalActions } from '@/components/ui/Modal'
import { WarningIcon } from '@/components/ui/icons'
interface OverwriteConfirmModalProps { interface OverwriteConfirmModalProps {
destination: string destination: string
@@ -16,83 +18,59 @@ export default function OverwriteConfirmModal({
onConfirm, onConfirm,
}: OverwriteConfirmModalProps) { }: OverwriteConfirmModalProps) {
return ( return (
<div <Modal ariaLabelledBy="overwrite-title">
className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm" <div className="flex items-center gap-3">
role="dialog" <div className="w-10 h-10 rounded-full bg-yellow-900/30 flex items-center justify-center shrink-0">
aria-modal="true" <WarningIcon className="w-5 h-5 text-yellow-400" />
aria-labelledby="overwrite-title"
>
<div className="bg-black-900 border border-white/20 rounded-2xl p-6 max-w-md w-full mx-4 space-y-4">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-full bg-yellow-900/30 flex items-center justify-center shrink-0">
<svg className="w-5 h-5 text-yellow-400" 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>
</div>
<div>
<h3 id="overwrite-title" className="text-sm font-semibold text-gray-100">
Dossier deja existant
</h3>
<p className="text-xs text-gray-400 mt-0.5">
<span className="font-mono text-yellow-400">{destination}/{folderName}</span> existe deja.
Les anciens fichiers seront archives sur le Drive, puis les nouveaux seront envoyes sur le Drive et Git.
</p>
</div>
</div> </div>
<div>
{diffs.length > 0 && ( <h3 id="overwrite-title" className="text-sm font-semibold text-gray-100">
<div className="bg-black-800 border border-white/10 rounded-xl p-3 max-h-40 overflow-y-auto"> Dossier deja existant
<p className="text-xs text-gray-500 mb-2">Modifications detectees :</p> </h3>
<ul className="space-y-1"> <p className="text-xs text-gray-400 mt-0.5">
{diffs.map((d) => ( <span className="font-mono text-yellow-400">{destination}/{folderName}</span> existe deja.
<li key={d.name} className="flex items-center gap-2 text-xs font-mono"> Les anciens fichiers seront archives sur le Drive, puis les nouveaux seront envoyes sur le Drive et Git.
{d.status === 'changed' && ( </p>
<>
<span className="text-yellow-400">🔄</span>
<span className="text-gray-300">{d.name}</span>
</>
)}
{d.status === 'new' && (
<>
<span className="text-green-400"></span>
<span className="text-gray-300">{d.name}</span>
</>
)}
{d.status === 'deleted' && (
<>
<span className="text-red-400"></span>
<span className="text-gray-500 line-through">{d.name}</span>
</>
)}
</li>
))}
</ul>
</div>
)}
<div className="flex gap-3">
<button
onClick={onCancel}
className="flex-1 bg-black-700 text-gray-300 font-medium text-sm
py-2.5 px-4 rounded-xl border border-white/10 transition-colors duration-150
hover:bg-black-600"
>
Annuler
</button>
<button
onClick={onConfirm}
className="flex-1 bg-yellow-600 text-[#000000] font-medium text-sm
py-2.5 px-4 rounded-xl transition-colors duration-150
hover:bg-yellow-500"
>
Remplacer
</button>
</div> </div>
</div> </div>
</div>
{diffs.length > 0 && (
<div className="bg-black-800 border border-white/10 rounded-xl p-3 max-h-40 overflow-y-auto">
<p className="text-xs text-gray-500 mb-2">Modifications detectees :</p>
<ul className="space-y-1">
{diffs.map((d) => (
<li key={d.name} className="flex items-center gap-2 text-xs font-mono">
{d.status === 'changed' && (
<>
<span className="text-yellow-400">🔄</span>
<span className="text-gray-300">{d.name}</span>
</>
)}
{d.status === 'new' && (
<>
<span className="text-green-400"></span>
<span className="text-gray-300">{d.name}</span>
</>
)}
{d.status === 'deleted' && (
<>
<span className="text-red-400"></span>
<span className="text-gray-500 line-through">{d.name}</span>
</>
)}
</li>
))}
</ul>
</div>
)}
<ModalActions
cancelLabel="Annuler"
confirmLabel="Remplacer"
onCancel={onCancel}
onConfirm={onConfirm}
confirmClassName="bg-yellow-600 text-[#000000] hover:bg-yellow-500"
/>
</Modal>
) )
} }
+3 -7
View File
@@ -1,3 +1,5 @@
import { WarningIcon } from '@/components/ui/icons'
interface WarningBannerProps { interface WarningBannerProps {
warnings: string[] warnings: string[]
} }
@@ -8,13 +10,7 @@ export default function WarningBanner({ warnings }: WarningBannerProps) {
return ( return (
<div className="mt-2 px-3 py-2 bg-yellow-900/20 border border-yellow-700/30 rounded-lg"> <div className="mt-2 px-3 py-2 bg-yellow-900/20 border border-yellow-700/30 rounded-lg">
<div className="flex items-center gap-2 text-xs text-yellow-400"> <div className="flex items-center gap-2 text-xs text-yellow-400">
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}> <WarningIcon className="w-4 h-4" />
<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 : {warnings.join(', ')}</span> <span>Textures manquantes : {warnings.join(', ')}</span>
</div> </div>
</div> </div>
+247
View File
@@ -0,0 +1,247 @@
'use client'
// ---------------------------------------------------------------------------
// Upload orchestration hook — manages the Drive→Git upload pipeline
// ---------------------------------------------------------------------------
import { useState, useRef, useCallback } from 'react'
import type { Destination } from '@/lib/constants'
import type { FolderEntry } from '@/lib/client-types'
import type { FileDiff } from '@/lib/types'
import { checkFolderDiffs, uploadDrive, uploadGit } from '@/lib/upload-api'
import type { CheckResult } from '@/lib/upload-api'
interface UseUploadOrchestratorParams {
secret: string
setSecretError: (err: string | null) => void
entries: FolderEntry[]
updateEntry: (index: number, patch: Partial<FolderEntry>) => void
resetEntries: () => void
}
export function useUploadOrchestrator({
secret,
setSecretError,
entries,
updateEntry,
resetEntries,
}: UseUploadOrchestratorParams) {
const [isUploading, setIsUploading] = useState(false)
const [globalError, setGlobalError] = useState<string | null>(null)
const [destination, setDestination] = useState<Destination | null>(null)
const [overwriteConfirm, setOverwriteConfirm] = useState<{
folderName: string
diffs: FileDiff[]
} | null>(null)
const [noChangesFolder, setNoChangesFolder] = useState<string | null>(null)
const [driveError, setDriveError] = useState<{
error: string
folderIndex: number
} | null>(null)
const abortRef = useRef<AbortController | null>(null)
const checkResultRef = useRef<CheckResult>({ exists: false, diffs: [] })
// Refs for values used inside callbacks to avoid stale closures
const secretRef = useRef(secret)
secretRef.current = secret
const destinationRef = useRef(destination)
destinationRef.current = destination
const entriesRef = useRef(entries)
entriesRef.current = entries
// ---- Internal: push a single folder to Git ----
const pushGit = useCallback(async (index: number, signal?: AbortSignal) => {
const folderEntry = entriesRef.current[index]
const dest = destinationRef.current
const gitResult = await uploadGit(
folderEntry,
secretRef.current,
dest!,
(pct) => updateEntry(index, { progress: 50 + Math.round(pct / 2) }),
signal,
)
updateEntry(index, {
status: gitResult.success ? 'success' : 'error',
progress: gitResult.success ? 100 : 0,
error: gitResult.success ? undefined : gitResult.error,
filename: gitResult.filename,
})
}, [updateEntry])
// ---- Main upload flow: Drive first, then Git ----
const proceedUpload = useCallback(async () => {
setOverwriteConfirm(null)
setIsUploading(true)
setGlobalError(null)
const controller = new AbortController()
abortRef.current = controller
const currentEntries = entriesRef.current
for (let i = 0; i < currentEntries.length; i++) {
if (currentEntries[i].status === 'success') continue
if (controller.signal.aborted) break
const folderEntry = currentEntries[i]
const driveAction = checkResultRef.current.exists ? 'replace' : 'new'
// ---- Step 1: Drive upload ----
updateEntry(i, {
status: 'uploading',
progress: 1,
error: undefined,
driveStatus: 'uploading',
driveError: undefined,
})
const driveResult = await uploadDrive(
folderEntry,
secretRef.current,
destinationRef.current!,
driveAction as 'new' | 'replace',
controller.signal,
)
if (!driveResult.success) {
updateEntry(i, { driveStatus: 'error', driveError: driveResult.error })
setDriveError({ error: driveResult.error || 'Erreur inconnue', folderIndex: i })
return
}
updateEntry(i, { driveStatus: 'success', progress: 50 })
// ---- Step 2: Git upload ----
await pushGit(i, controller.signal)
}
abortRef.current = null
setIsUploading(false)
}, [updateEntry, pushGit])
// ---- Handlers ----
const handleUpload = useCallback(async () => {
if (!secretRef.current.trim()) {
setSecretError("La cle d'acces est requise")
return
}
if (!destinationRef.current) {
setGlobalError('Veuillez choisir une destination')
return
}
if (entriesRef.current.length === 0) return
setSecretError(null)
setGlobalError(null)
const folder = entriesRef.current[0]
try {
const check = await checkFolderDiffs(
folder,
destinationRef.current,
secretRef.current,
abortRef.current?.signal,
)
checkResultRef.current = check
if (check.exists) {
if (check.diffs.length === 0) {
setNoChangesFolder(folder.folderName)
return
}
setOverwriteConfirm({ folderName: folder.folderName, diffs: check.diffs })
return
}
} catch (err) {
const message = err instanceof Error ? err.message : 'Erreur inconnue'
setGlobalError(message)
return
}
await proceedUpload()
}, [setSecretError, proceedUpload])
const handleDriveContinue = useCallback(async () => {
if (!driveError) return
const idx = driveError.folderIndex
setDriveError(null)
updateEntry(idx, { driveStatus: 'skipped' })
const signal = abortRef.current?.signal
await pushGit(idx, signal)
const currentEntries = entriesRef.current
for (let i = idx + 1; i < currentEntries.length; i++) {
if (currentEntries[i].status === 'success') continue
if (abortRef.current?.signal.aborted) break
updateEntry(i, {
status: 'uploading',
progress: 0,
error: undefined,
driveStatus: 'skipped',
})
await pushGit(i, abortRef.current?.signal)
}
abortRef.current = null
setIsUploading(false)
}, [driveError, updateEntry, pushGit])
const handleDriveCancel = useCallback(() => {
if (!driveError) return
const idx = driveError.folderIndex
setDriveError(null)
updateEntry(idx, {
status: 'error',
progress: 0,
error: 'Upload annule (Drive echoue)',
driveStatus: 'error',
})
abortRef.current?.abort()
abortRef.current = null
setIsUploading(false)
}, [driveError, updateEntry])
const handleCancel = useCallback(() => {
abortRef.current?.abort()
abortRef.current = null
setIsUploading(false)
}, [])
const handleReset = useCallback(() => {
resetEntries()
setGlobalError(null)
setIsUploading(false)
setDriveError(null)
checkResultRef.current = { exists: false, diffs: [] }
}, [resetEntries])
return {
isUploading,
globalError,
setGlobalError,
destination,
setDestination,
overwriteConfirm,
setOverwriteConfirm,
noChangesFolder,
setNoChangesFolder,
driveError,
handleUpload,
handleDriveContinue,
handleDriveCancel,
handleCancel,
handleReset,
proceedUpload,
}
}
+2 -2
View File
@@ -2,14 +2,14 @@
// Client-side types — used by components and hooks (no Node.js Buffer) // Client-side types — used by components and hooks (no Node.js Buffer)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export type FileStatus = 'pending' | 'uploading' | 'success' | 'error' type FileStatus = 'pending' | 'uploading' | 'success' | 'error'
export interface TextureFile { export interface TextureFile {
name: string name: string
file: File file: File
} }
export type DriveStatus = 'pending' | 'uploading' | 'success' | 'error' | 'skipped' type DriveStatus = 'pending' | 'uploading' | 'success' | 'error' | 'skipped'
export interface FolderEntry { export interface FolderEntry {
folderName: string folderName: string
+2 -2
View File
@@ -8,9 +8,9 @@ export const ALL_ALLOWED_EXTENSIONS = new Set([...MODEL_EXTENSIONS, ...TEXTURE_E
export const REQUIRED_TEXTURES = ['roughness', 'normal', 'metalness', 'color', 'displace'] as const export const REQUIRED_TEXTURES = ['roughness', 'normal', 'metalness', 'color', 'displace'] as const
export const VALID_DESTINATIONS = new Set([ export const VALID_DESTINATIONS = new Set<string>([
'farm', 'map', 'powergrid', 'workshop', 'general', 'environment', 'farm', 'map', 'powergrid', 'workshop', 'general', 'environment',
] as const) ])
export const DESTINATIONS = [ export const DESTINATIONS = [
{ value: 'farm', label: 'Farm' }, { value: 'farm', label: 'Farm' },
+83
View File
@@ -0,0 +1,83 @@
// ---------------------------------------------------------------------------
// File diff classification — compares local files against a remote file map
// ---------------------------------------------------------------------------
import { MODEL_EXTENSIONS } from './constants'
import type { FileChange } from './types'
interface PushFile {
path: string
contentBase64: string
}
export interface DiffResult {
/** Map of lowercase filename → change status (for commit message) */
fileChanges: Map<string, FileChange>
/** Files that actually need to be pushed (new or changed) */
changedFilesToPush: PushFile[]
/** Filenames that were on remote but not in the new upload */
deletedFileNames: string[]
/** Full paths for deletion on remote */
deletePaths: string[]
}
/**
* Classify each file as new, changed, or unchanged by comparing against
* the remote file map.
*
* Rules:
* - Models: always re-pushed (compression makes size comparison unreliable),
* but marked as 'unchanged' in the commit message when the folder already
* exists (we can't know if the model really changed after Blender).
* - Textures: compared by size (not compressed, reliable).
* - Orphan remote files: classified as deletions.
*/
export function classifyFileChanges(
filesToPush: PushFile[],
remoteFileMap: Map<string, number>,
folderPath: string,
): DiffResult {
const fileChanges = new Map<string, FileChange>()
const changedFilesToPush: PushFile[] = []
for (const f of filesToPush) {
const filename = f.path.split('/').pop() ?? ''
const ext = filename.slice(filename.lastIndexOf('.')).toLowerCase()
const isModel = MODEL_EXTENSIONS.has(ext)
if (isModel) {
// Model: always re-push since compression makes size comparison unreliable.
// Mark as 'unchanged' for the commit message when the folder already exists.
const remoteSize = remoteFileMap.get(filename.toLowerCase())
fileChanges.set(filename.toLowerCase(), remoteSize === undefined ? 'new' : 'unchanged')
changedFilesToPush.push(f)
} else {
// Texture: compare by size
const localSize = Buffer.from(f.contentBase64, 'base64').length
const remoteSize = remoteFileMap.get(filename.toLowerCase())
if (remoteSize === undefined) {
fileChanges.set(filename.toLowerCase(), 'new')
changedFilesToPush.push(f)
} else if (remoteSize !== localSize) {
fileChanges.set(filename.toLowerCase(), 'changed')
changedFilesToPush.push(f)
} else {
fileChanges.set(filename.toLowerCase(), 'unchanged')
}
}
}
// Files on remote not in the new upload → deleted (orphans)
const newFileNames = new Set(filesToPush.map((f) => (f.path.split('/').pop() ?? '').toLowerCase()))
const deletedFileNames: string[] = []
const deletePaths: string[] = []
for (const [name] of remoteFileMap) {
if (!newFileNames.has(name)) {
deletedFileNames.push(name)
deletePaths.push(`${folderPath}/${name}`)
}
}
return { fileChanges, changedFilesToPush, deletedFileNames, deletePaths }
}
+1 -1
View File
@@ -3,7 +3,7 @@
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export function formatBytes(bytes: number): string { export function formatBytes(bytes: number): string {
if (bytes === 0) return '0 B' if (bytes <= 0) return '0 B'
const k = 1024 const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB'] const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k)) const i = Math.floor(Math.log(bytes) / Math.log(k))
+5 -2
View File
@@ -5,6 +5,10 @@ import type { RemoteFile } from './types'
// Octokit helpers // Octokit helpers
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
function isHttpError(err: unknown): err is { status: number } {
return typeof err === 'object' && err !== null && 'status' in err && typeof (err as Record<string, unknown>).status === 'number'
}
function getOctokit(): Octokit { function getOctokit(): Octokit {
const token = process.env.GITHUB_TOKEN const token = process.env.GITHUB_TOKEN
if (!token) throw new Error('GITHUB_TOKEN non configure') if (!token) throw new Error('GITHUB_TOKEN non configure')
@@ -53,8 +57,7 @@ export async function getRemoteFolder(
return { exists: false, files: [] } return { exists: false, files: [] }
} catch (err: unknown) { } catch (err: unknown) {
const status = (err as { status?: number })?.status if (isHttpError(err) && err.status === 404) {
if (status === 404) {
return { exists: false, files: [] } return { exists: false, files: [] }
} }
throw err throw err
+17 -12
View File
@@ -3,11 +3,17 @@
// Uses native fetch — no npm package needed. // Uses native fetch — no npm package needed.
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
const MAX_VERSIONS = 1000
// Lazy-cached config to avoid recomputing on every request
let cachedConfig: { davBase: string; auth: string } | null = null
function getConfig() { function getConfig() {
if (cachedConfig) return cachedConfig
const url = process.env.NEXTCLOUD_URL const url = process.env.NEXTCLOUD_URL
const token = process.env.NEXTCLOUD_SHARE_TOKEN const token = process.env.NEXTCLOUD_SHARE_TOKEN
const password = process.env.NEXTCLOUD_SHARE_PASSWORD || '' const password = process.env.NEXTCLOUD_SHARE_PASSWORD || ''
const basePath = process.env.NEXTCLOUD_BASE_PATH || 'Models'
if (!url || !token) { if (!url || !token) {
throw new Error('Nextcloud non configure (NEXTCLOUD_URL, NEXTCLOUD_SHARE_TOKEN)') throw new Error('Nextcloud non configure (NEXTCLOUD_URL, NEXTCLOUD_SHARE_TOKEN)')
@@ -17,7 +23,8 @@ function getConfig() {
const davBase = `${url.replace(/\/+$/, '')}/public.php/webdav` const davBase = `${url.replace(/\/+$/, '')}/public.php/webdav`
const auth = 'Basic ' + Buffer.from(`${token}:${password}`).toString('base64') const auth = 'Basic ' + Buffer.from(`${token}:${password}`).toString('base64')
return { davBase, auth, basePath } cachedConfig = { davBase, auth }
return cachedConfig
} }
function davUrl(davBase: string, path: string): string { function davUrl(davBase: string, path: string): string {
@@ -66,7 +73,7 @@ export async function folderExists(path: string): Promise<boolean> {
/** /**
* Create a folder and all parent segments if they don't exist. * Create a folder and all parent segments if they don't exist.
* Like `mkdir -p`. * Like `mkdir -p`. Attempts MKCOL directly and handles 405 (already exists).
*/ */
export async function mkdirRecursive(path: string): Promise<void> { export async function mkdirRecursive(path: string): Promise<void> {
const segments = path.replace(/^\/+/, '').replace(/\/+$/, '').split('/') const segments = path.replace(/^\/+/, '').replace(/\/+$/, '').split('/')
@@ -74,14 +81,11 @@ export async function mkdirRecursive(path: string): Promise<void> {
for (const seg of segments) { for (const seg of segments) {
current += '/' + seg current += '/' + seg
const exists = await folderExists(current) const res = await davRequest('MKCOL', current + '/')
if (!exists) { if (res.status !== 201 && res.status !== 405) {
const res = await davRequest('MKCOL', current + '/') // 201 = created, 405 = already exists — both are fine
if (res.status !== 201 && res.status !== 405) { const text = await res.text().catch(() => '')
// 405 = already exists (race condition), that's fine throw new Error(`MKCOL ${current} failed (${res.status}): ${text.slice(0, 200)}`)
const text = await res.text().catch(() => '')
throw new Error(`MKCOL ${current} failed (${res.status}): ${text.slice(0, 200)}`)
}
} }
} }
} }
@@ -127,9 +131,10 @@ export async function findNextVersion(
basePath: string, basePath: string,
folderName: string, folderName: string,
): Promise<string> { ): Promise<string> {
for (let i = 1; ; i++) { for (let i = 1; i <= MAX_VERSIONS; i++) {
const versionPath = `${basePath}/V${i}/${folderName}` const versionPath = `${basePath}/V${i}/${folderName}`
const exists = await folderExists(versionPath) const exists = await folderExists(versionPath)
if (!exists) return `V${i}` if (!exists) return `V${i}`
} }
throw new Error(`Nombre maximum de versions atteint (V${MAX_VERSIONS})`)
} }
+24 -9
View File
@@ -4,21 +4,27 @@ import { sanitizeFilename } from './sanitize'
import { ALL_ALLOWED_EXTENSIONS, MODEL_EXTENSIONS, VALID_DESTINATIONS, MAX_FILE_SIZE } from './constants' import { ALL_ALLOWED_EXTENSIONS, MODEL_EXTENSIONS, VALID_DESTINATIONS, MAX_FILE_SIZE } from './constants'
import type { ParsedFile } from './types' import type { ParsedFile } from './types'
/** export interface ParsedUpload {
* Parse a multi-file FormData upload request.
* Validates destination, file extensions, file sizes, and returns parsed files.
*/
export async function parseMultiUpload(req: NextRequest): Promise<{
folderName: string folderName: string
destination: string destination: string
files: ParsedFile[] files: ParsedFile[]
}> { /** Any extra string fields from the FormData (e.g. "action") */
extra: Record<string, string>
}
/**
* Parse a multi-file FormData upload request.
* Validates destination, file extensions, file sizes, and returns parsed files.
* Extra string fields (beyond folderName, destination, files, fileTypes, textureNames)
* are returned in `extra`.
*/
export async function parseMultiUpload(req: NextRequest): Promise<ParsedUpload> {
const formData = await req.formData() const formData = await req.formData()
const folderName = (formData.get('folderName') as string | null)?.trim() || 'assets' const folderName = (formData.get('folderName') as string | null)?.trim() || 'assets'
const safeFolderName = sanitizeFilename(folderName).replace(/[^a-zA-Z0-9-_]/g, '-') const safeFolderName = sanitizeFilename(folderName).replace(/[^a-zA-Z0-9-_]/g, '-')
const rawDestination = (formData.get('destination') as string | null)?.trim() || 'general' const rawDestination = (formData.get('destination') as string | null)?.trim() || 'general'
if (!VALID_DESTINATIONS.has(rawDestination as never)) { if (!VALID_DESTINATIONS.has(rawDestination)) {
throw new Error(`Destination invalide: "${rawDestination}"`) throw new Error(`Destination invalide: "${rawDestination}"`)
} }
const destination = rawDestination const destination = rawDestination
@@ -27,6 +33,15 @@ export async function parseMultiUpload(req: NextRequest): Promise<{
const fileTypes = formData.getAll('fileTypes') as string[] const fileTypes = formData.getAll('fileTypes') as string[]
const textureNames = formData.getAll('textureNames') as string[] const textureNames = formData.getAll('textureNames') as string[]
// Collect extra string fields
const knownKeys = new Set(['folderName', 'destination', 'files', 'fileTypes', 'textureNames'])
const extra: Record<string, string> = {}
for (const [key, value] of formData.entries()) {
if (!knownKeys.has(key) && typeof value === 'string') {
extra[key] = value
}
}
// Runtime validation: ensure entries are actual File objects // Runtime validation: ensure entries are actual File objects
const fileEntries: File[] = [] const fileEntries: File[] = []
for (const entry of rawFiles) { for (const entry of rawFiles) {
@@ -73,8 +88,8 @@ export async function parseMultiUpload(req: NextRequest): Promise<{
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())
parsed.push({ filename, buffer, isModel, textureName: texName || undefined }) parsed.push({ filename, buffer, isModel })
} }
return { folderName: safeFolderName, destination, files: parsed } return { folderName: safeFolderName, destination, files: parsed, extra }
} }
-1
View File
@@ -6,7 +6,6 @@ export interface ParsedFile {
filename: string filename: string
buffer: Buffer buffer: Buffer
isModel: boolean isModel: boolean
textureName?: string
} }
export type FileChange = 'new' | 'changed' | 'unchanged' export type FileChange = 'new' | 'changed' | 'unchanged'
+182
View File
@@ -0,0 +1,182 @@
// ---------------------------------------------------------------------------
// Client-side API helpers for upload operations
// ---------------------------------------------------------------------------
import type { FolderEntry } from './client-types'
import type { FileDiff } from './types'
export interface CheckResult {
exists: boolean
diffs: FileDiff[]
}
// ---------------------------------------------------------------------------
// Shared FormData builder
// ---------------------------------------------------------------------------
function buildUploadFormData(
folder: FolderEntry,
destination: string,
extra?: Record<string, string>,
): FormData {
const formData = new FormData()
formData.append('folderName', folder.folderName)
formData.append('destination', destination)
if (extra) {
for (const [key, value] of Object.entries(extra)) {
formData.append(key, value)
}
}
formData.append('files', folder.modelFile)
formData.append('fileTypes', 'model')
formData.append('textureNames', '')
for (const tex of folder.textures) {
formData.append('files', tex.file)
formData.append('fileTypes', 'texture')
formData.append('textureNames', tex.name)
}
return formData
}
// ---------------------------------------------------------------------------
// Check folder diffs against remote (GitHub)
// ---------------------------------------------------------------------------
/**
* Check whether a folder already exists on the remote repo and compute diffs.
* Throws on auth/network errors so callers can surface them to the user.
*/
export async function checkFolderDiffs(
folder: FolderEntry,
destination: string,
secret: string,
signal?: AbortSignal,
): Promise<CheckResult> {
const params = new URLSearchParams({ folderName: folder.folderName, destination })
const res = await fetch(`/api/upload/check?${params}`, {
headers: { 'x-upload-secret': secret.trim() },
signal,
})
const data = await res.json()
// Surface auth/server errors to the caller
if (!res.ok) {
throw new Error(data.error || `Erreur serveur (${res.status})`)
}
if (!data.success || !data.exists) {
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<string>()
// 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 }
}
// ---------------------------------------------------------------------------
// Upload original files to Nextcloud Drive
// ---------------------------------------------------------------------------
/** Upload original files to Nextcloud Drive (no Blender compression). */
export async function uploadDrive(
folder: FolderEntry,
secret: string,
destination: string,
action: 'new' | 'replace',
signal?: AbortSignal,
): Promise<{ success: boolean; error?: string }> {
const formData = buildUploadFormData(folder, destination, { action })
try {
const res = await fetch('/api/upload/drive', {
method: 'POST',
headers: { 'x-upload-secret': secret.trim() },
body: formData,
signal,
})
const data = await res.json()
if (!data.success) return { success: false, error: data.error }
return { success: true }
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') {
return { success: false, error: 'Upload annule' }
}
return { success: false, error: 'Erreur reseau (Drive)' }
}
}
// ---------------------------------------------------------------------------
// Upload files to GitHub (with Blender compression)
// ---------------------------------------------------------------------------
/** Upload files to GitHub (with Blender compression). */
export async function uploadGit(
folder: FolderEntry,
secret: string,
destination: string,
onProgress: (pct: number) => void,
signal?: AbortSignal,
): Promise<{ success: boolean; filename?: string; error?: string }> {
const formData = buildUploadFormData(folder, destination)
onProgress(10)
try {
const res = await fetch('/api/upload/git', {
method: 'POST',
headers: { 'x-upload-secret': secret.trim() },
body: formData,
signal,
})
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 (err) {
if (err instanceof DOMException && err.name === 'AbortError') {
return { success: false, error: 'Upload annule' }
}
return { success: false, error: 'Erreur reseau' }
}
}
+18 -23
View File
@@ -13,22 +13,15 @@ function getTextureType(filename: string): string | null {
return null return null
} }
export function validateFolder(files: File[]): { /** Discriminated union: either valid (with model) or invalid (with errors). */
model?: File export type ValidationResult =
textures: TextureFile[] | { ok: true; model: File; textures: TextureFile[]; warnings: string[] }
errors: string[] | { ok: false; errors: string[] }
warnings: string[]
} { export function validateFolder(files: File[]): ValidationResult {
const result: { const textures: TextureFile[] = []
model?: File const warnings: string[] = []
textures: TextureFile[] const errors: string[] = []
errors: string[]
warnings: string[]
} = {
textures: [],
errors: [],
warnings: [],
}
const modelFiles = files.filter((f) => { const modelFiles = files.filter((f) => {
const name = f.name.toLowerCase() const name = f.name.toLowerCase()
@@ -36,9 +29,7 @@ export function validateFolder(files: File[]): {
}) })
if (modelFiles.length === 0) { if (modelFiles.length === 0) {
result.errors.push('model.glb ou model.gltf manquant (obligatoire)') return { ok: false, errors: ['model.glb ou model.gltf manquant (obligatoire)'] }
} else {
result.model = modelFiles[0]
} }
const textureFiles = files.filter((f) => { const textureFiles = files.filter((f) => {
@@ -47,17 +38,21 @@ export function validateFolder(files: File[]): {
}) })
for (const tf of textureFiles) { for (const tf of textureFiles) {
result.textures.push({ name: tf.name, file: tf }) textures.push({ name: tf.name, file: tf })
} }
const foundTextures = new Set( const foundTextures = new Set(
result.textures.map((t) => t.name.toLowerCase().replace(/\.[^.]+$/, '')), textures.map((t) => t.name.toLowerCase().replace(/\.[^.]+$/, '')),
) )
for (const req of REQUIRED_TEXTURES) { for (const req of REQUIRED_TEXTURES) {
if (!foundTextures.has(req)) { if (!foundTextures.has(req)) {
result.warnings.push(`${req}.webp/png/jpg manquant`) warnings.push(`${req}.webp/png/jpg manquant`)
} }
} }
return result if (errors.length > 0) {
return { ok: false, errors }
}
return { ok: true, model: modelFiles[0], textures, warnings }
} }
+21
View File
@@ -2,6 +2,27 @@ import type { NextConfig } from 'next'
const nextConfig: NextConfig = { const nextConfig: NextConfig = {
output: 'standalone', output: 'standalone',
async headers() {
return [
{
source: '/(.*)',
headers: [
{ key: 'X-Content-Type-Options', value: 'nosniff' },
{ key: 'X-Frame-Options', value: 'DENY' },
{ key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
{ key: 'X-DNS-Prefetch-Control', value: 'on' },
{
key: 'Strict-Transport-Security',
value: 'max-age=63072000; includeSubDomains; preload',
},
{
key: 'Permissions-Policy',
value: 'camera=(), microphone=(), geolocation=()',
},
],
},
]
},
} }
export default nextConfig export default nextConfig