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
📦 Model
↔️ model.gltf (inchange)
🎨 Textures
🔄 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
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)
└── page.tsx # Home page
components/
├── ui/
│ ├── icons.tsx # Shared SVG icon components
│ └── Modal.tsx # Shared modal wrapper + ModalActions
├── upload/
│ ├── SecretInput.tsx # Access key input
│ ├── DestinationPicker.tsx # Destination selector
│ ├── FolderDropzone.tsx # Folder drag & drop / picker
│ ├── FolderCard.tsx # Folder status card (Drive + Git)
│ ├── DriveStatusLine.tsx # Drive/Git status sub-line
│ ├── WarningBanner.tsx # Missing texture warnings
│ ├── OverwriteConfirmModal.tsx # Diff confirmation dialog
│ ├── NoChangesModal.tsx # "No changes detected" dialog
│ ├── DriveErrorModal.tsx # "Drive failed, continue?" dialog
│ └── 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
└── SceneViewer.tsx # Three.js Canvas
hooks/
├── useSecret.ts # Secret key state management
── useFolderEntries.ts # Folder entries state management
├── useSecret.ts # Secret key state management
── useFolderEntries.ts # Folder entries state management
└── useUploadOrchestrator.ts # Upload pipeline orchestration (Drive → Git)
lib/
├── constants.ts # Shared constants, destinations, extensions
├── types.ts # Server types (ParsedFile, FileDiff, 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
├── auth.ts # Upload secret validation (timing-safe)
├── 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
├── commit-message.ts # Commit message builder
├── 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
scripts/
└── 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 })
}
if (!VALID_DESTINATIONS.has(destination as never)) {
if (!VALID_DESTINATIONS.has(destination)) {
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 { parseMultiUpload } from '@/lib/parse-upload'
import {
folderExists,
mkdirRecursive,
moveFolder,
uploadFile,
@@ -22,11 +21,11 @@ export const dynamic = 'force-dynamic'
// - action: "new" | "replace"
//
// Versioning logic:
// VF/{folderName} latest version
// V1/{folderName} first archive, V2/ second, etc.
// VF/{folderName} <- latest version
// V1/{folderName} <- first archive, V2/ second, etc.
//
// action="new" just mkdir + upload into VF/
// action="replace" archive VF Vx, then re-upload all files into VF/
// action="new" -> just mkdir + upload into VF/
// action="replace" -> archive VF -> Vx, then re-upload all files into VF/
// ---------------------------------------------------------------------------
export async function POST(req: NextRequest) {
@@ -42,11 +41,7 @@ export async function POST(req: NextRequest) {
)
}
// --- Parse files ---
// Clone the request before parseMultiUpload consumes the body stream,
// so we can read the `action` field separately.
const cloned = req.clone()
// --- Parse files (includes extra fields like "action") ---
let folderName: string
let parsedFiles: Awaited<ReturnType<typeof parseMultiUpload>>['files']
let action: string
@@ -55,10 +50,7 @@ export async function POST(req: NextRequest) {
const parsed = await parseMultiUpload(req)
folderName = parsed.folderName
parsedFiles = parsed.files
// 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'
action = parsed.extra.action?.trim() || 'new'
} catch (err) {
const message = err instanceof Error ? err.message : 'Erreur inconnue'
return NextResponse.json({ success: false, error: message }, { status: 400 })
@@ -75,7 +67,7 @@ export async function POST(req: NextRequest) {
// 2. Ensure Vx/ exists
await mkdirRecursive(`${basePath}/${nextVersion}`)
// 3. Move VF/{folderName} Vx/{folderName}
// 3. Move VF/{folderName} -> Vx/{folderName}
await moveFolder(vfFolderPath, `${basePath}/${nextVersion}/${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 { getRemoteFolder, pushAllToGitHub } from '@/lib/github'
import { buildCommitMessage } from '@/lib/commit-message'
import { TMP_DIR, MODEL_EXTENSIONS } from '@/lib/constants'
import type { FileChange } from '@/lib/types'
import { classifyFileChanges } from '@/lib/diff-files'
import { TMP_DIR } from '@/lib/constants'
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
@@ -85,8 +85,6 @@ export async function POST(req: NextRequest) {
}
// --- 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}`
let remoteFileMap: Map<string, number>
@@ -99,48 +97,8 @@ export async function POST(req: NextRequest) {
const isReplace = remoteFileMap.size > 0
const fileChanges = new Map<string, FileChange>()
const changedFilesToPush: { path: string; contentBase64: string }[] = []
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}`)
}
}
const { fileChanges, changedFilesToPush, deletedFileNames, deletePaths } =
classifyFileChanges(filesToPush, remoteFileMap, folderPath)
// If nothing changed, don't create an empty commit
if (changedFilesToPush.length === 0 && deletePaths.length === 0) {
+25 -352
View File
@@ -1,11 +1,9 @@
'use client'
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 { useSecret } from '@/hooks/useSecret'
import { useFolderEntries } from '@/hooks/useFolderEntries'
import { useUploadOrchestrator } from '@/hooks/useUploadOrchestrator'
import SecretInput from './upload/SecretInput'
import DestinationPicker from './upload/DestinationPicker'
import FolderDropzone from './upload/FolderDropzone'
@@ -15,162 +13,6 @@ import OverwriteConfirmModal from './upload/OverwriteConfirmModal'
import NoChangesModal from './upload/NoChangesModal'
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() {
const {
secret,
@@ -192,27 +34,30 @@ export default function UploadZone() {
hasErrors,
} = useFolderEntries()
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)
// Drive error modal state
const [driveError, setDriveError] = useState<{
error: string
folderIndex: number
} | null>(null)
const abortRef = useRef<AbortController | null>(null)
// Tracks the check result so we know if it's "new" or "replace" for the Drive
const checkResultRef = useRef<CheckResult>({ exists: false, diffs: [] })
// -- Handlers --
const {
isUploading,
globalError,
setGlobalError,
destination,
setDestination,
overwriteConfirm,
setOverwriteConfirm,
noChangesFolder,
setNoChangesFolder,
driveError,
handleUpload,
handleDriveContinue,
handleDriveCancel,
handleCancel,
handleReset,
proceedUpload,
} = useUploadOrchestrator({
secret,
setSecretError,
entries,
updateEntry,
resetEntries,
})
const handleFolderSelected = (entry: FolderEntry) => {
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')
// -- Render --
return (
<div className="w-full max-w-2xl space-y-4">
<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 {
error: string
onCancel: () => void
@@ -10,60 +13,35 @@ export default function DriveErrorModal({
onContinue,
}: DriveErrorModalProps) {
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="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>
<Modal ariaLabelledBy="drive-error-title">
<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">
<WarningIcon className="w-5 h-5 text-red-400" />
</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>
<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>
<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 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 type { FolderEntry } from '@/lib/client-types'
import { formatBytes } from '@/lib/format-bytes'
import { SpinnerIcon, CheckIcon, XIcon, ChevronIcon } from '@/components/ui/icons'
import DriveStatusLine from './DriveStatusLine'
import WarningBanner from './WarningBanner'
const ModelViewer = dynamic(() => import('../ModelViewer'), { ssr: false })
@@ -20,22 +22,15 @@ export default function FolderCard({ entry, index, onToggleViewer, onRemove }: F
<div className="shrink-0">
{entry.status === 'success' ? (
<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}>
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
<CheckIcon className="w-4 h-4 text-green-400" />
</div>
) : entry.status === 'error' ? (
<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}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
<XIcon className="w-4 h-4 text-red-400" />
</div>
) : entry.status === 'uploading' ? (
<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">
<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>
<SpinnerIcon className="w-4 h-4 text-gray-300" />
</div>
) : (
<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'
}`}
>
<svg
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>
<ChevronIcon className={`w-4 h-4 text-gray-500 transition-transform ${entry.viewerOpen ? 'rotate-180' : ''}`} />
</button>
)}
</div>
@@ -73,43 +63,9 @@ export default function FolderCard({ entry, index, onToggleViewer, onRemove }: F
{/* Drive status sub-line (only during upload, not after success) */}
{entry.status !== 'success' && entry.driveStatus && entry.driveStatus !== 'pending' && (
<div className="flex items-center gap-1.5 mt-0.5">
{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>
<DriveStatusLine driveStatus={entry.driveStatus} driveError={entry.driveError} />
)}
{entry.status === 'uploading' && (
<div className="mt-1.5 w-full h-1 bg-black-700 rounded-full overflow-hidden">
<div
@@ -127,9 +83,7 @@ export default function FolderCard({ entry, index, onToggleViewer, onRemove }: F
aria-label="Supprimer le dossier"
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}>
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
</svg>
<XIcon className="w-4 h-4" />
</button>
)}
</div>
+5 -16
View File
@@ -1,5 +1,6 @@
import type { FolderEntry } from '@/lib/client-types'
import { validateFolder } from '@/lib/validate-folder'
import { FolderIcon } from '@/components/ui/icons'
interface FolderDropzoneProps {
isUploading: boolean
@@ -20,19 +21,19 @@ export default function FolderDropzone({
const folderName = fileArray[0].webkitRelativePath?.split('/')[0] || 'folder'
const validation = validateFolder(fileArray)
if (validation.errors.length > 0) {
if (!validation.ok) {
onError(validation.errors.join(' | '))
return
}
const entry: FolderEntry = {
folderName,
modelFile: validation.model!,
modelFile: validation.model,
textures: validation.textures,
status: 'pending',
progress: 0,
warnings: validation.warnings,
modelUrl: URL.createObjectURL(validation.model!),
modelUrl: URL.createObjectURL(validation.model),
viewerOpen: true,
}
onFolderSelected(entry)
@@ -59,19 +60,7 @@ export default function FolderDropzone({
>
<div className="flex justify-center mb-3">
<div className="w-12 h-12 rounded-full flex items-center justify-center bg-black-700">
<svg
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>
<FolderIcon className="w-6 h-6 text-gray-400" />
</div>
</div>
<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 {
destination: string
folderName: string
@@ -12,52 +15,27 @@ export default function NoChangesModal({
onModify,
}: NoChangesModalProps) {
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="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>
<Modal ariaLabelledBy="no-changes-title">
<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">
<CheckIcon className="w-5 h-5 text-gray-400" />
</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={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>
<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>
<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 Modal, { ModalActions } from '@/components/ui/Modal'
import { WarningIcon } from '@/components/ui/icons'
interface OverwriteConfirmModalProps {
destination: string
@@ -16,83 +18,59 @@ export default function OverwriteConfirmModal({
onConfirm,
}: OverwriteConfirmModalProps) {
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="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>
<Modal ariaLabelledBy="overwrite-title">
<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">
<WarningIcon className="w-5 h-5 text-yellow-400" />
</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>
)}
<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>
<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>
{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 {
warnings: string[]
}
@@ -8,13 +10,7 @@ export default function WarningBanner({ warnings }: WarningBannerProps) {
return (
<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">
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
/>
</svg>
<WarningIcon className="w-4 h-4" />
<span>Textures manquantes : {warnings.join(', ')}</span>
</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)
// ---------------------------------------------------------------------------
export type FileStatus = 'pending' | 'uploading' | 'success' | 'error'
type FileStatus = 'pending' | 'uploading' | 'success' | 'error'
export interface TextureFile {
name: string
file: File
}
export type DriveStatus = 'pending' | 'uploading' | 'success' | 'error' | 'skipped'
type DriveStatus = 'pending' | 'uploading' | 'success' | 'error' | 'skipped'
export interface FolderEntry {
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 VALID_DESTINATIONS = new Set([
export const VALID_DESTINATIONS = new Set<string>([
'farm', 'map', 'powergrid', 'workshop', 'general', 'environment',
] as const)
])
export const DESTINATIONS = [
{ 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 {
if (bytes === 0) return '0 B'
if (bytes <= 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
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
// ---------------------------------------------------------------------------
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 {
const token = process.env.GITHUB_TOKEN
if (!token) throw new Error('GITHUB_TOKEN non configure')
@@ -53,8 +57,7 @@ export async function getRemoteFolder(
return { exists: false, files: [] }
} catch (err: unknown) {
const status = (err as { status?: number })?.status
if (status === 404) {
if (isHttpError(err) && err.status === 404) {
return { exists: false, files: [] }
}
throw err
+17 -12
View File
@@ -3,11 +3,17 @@
// 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() {
if (cachedConfig) return cachedConfig
const url = process.env.NEXTCLOUD_URL
const token = process.env.NEXTCLOUD_SHARE_TOKEN
const password = process.env.NEXTCLOUD_SHARE_PASSWORD || ''
const basePath = process.env.NEXTCLOUD_BASE_PATH || 'Models'
if (!url || !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 auth = 'Basic ' + Buffer.from(`${token}:${password}`).toString('base64')
return { davBase, auth, basePath }
cachedConfig = { davBase, auth }
return cachedConfig
}
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.
* Like `mkdir -p`.
* Like `mkdir -p`. Attempts MKCOL directly and handles 405 (already exists).
*/
export async function mkdirRecursive(path: string): Promise<void> {
const segments = path.replace(/^\/+/, '').replace(/\/+$/, '').split('/')
@@ -74,14 +81,11 @@ export async function mkdirRecursive(path: string): Promise<void> {
for (const seg of segments) {
current += '/' + seg
const exists = await folderExists(current)
if (!exists) {
const res = await davRequest('MKCOL', current + '/')
if (res.status !== 201 && res.status !== 405) {
// 405 = already exists (race condition), that's fine
const text = await res.text().catch(() => '')
throw new Error(`MKCOL ${current} failed (${res.status}): ${text.slice(0, 200)}`)
}
const res = await davRequest('MKCOL', current + '/')
if (res.status !== 201 && res.status !== 405) {
// 201 = created, 405 = already exists — both are fine
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,
folderName: string,
): Promise<string> {
for (let i = 1; ; i++) {
for (let i = 1; i <= MAX_VERSIONS; i++) {
const versionPath = `${basePath}/V${i}/${folderName}`
const exists = await folderExists(versionPath)
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 type { ParsedFile } from './types'
/**
* Parse a multi-file FormData upload request.
* Validates destination, file extensions, file sizes, and returns parsed files.
*/
export async function parseMultiUpload(req: NextRequest): Promise<{
export interface ParsedUpload {
folderName: string
destination: string
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 folderName = (formData.get('folderName') as string | null)?.trim() || 'assets'
const safeFolderName = sanitizeFilename(folderName).replace(/[^a-zA-Z0-9-_]/g, '-')
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}"`)
}
const destination = rawDestination
@@ -27,6 +33,15 @@ export async function parseMultiUpload(req: NextRequest): Promise<{
const fileTypes = formData.getAll('fileTypes') 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
const fileEntries: File[] = []
for (const entry of rawFiles) {
@@ -73,8 +88,8 @@ export async function parseMultiUpload(req: NextRequest): Promise<{
const isModel = MODEL_EXTENSIONS.has(ext)
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
buffer: Buffer
isModel: boolean
textureName?: string
}
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
}
export function validateFolder(files: File[]): {
model?: File
textures: TextureFile[]
errors: string[]
warnings: string[]
} {
const result: {
model?: File
textures: TextureFile[]
errors: string[]
warnings: string[]
} = {
textures: [],
errors: [],
warnings: [],
}
/** Discriminated union: either valid (with model) or invalid (with errors). */
export type ValidationResult =
| { ok: true; model: File; textures: TextureFile[]; warnings: string[] }
| { ok: false; errors: string[] }
export function validateFolder(files: File[]): ValidationResult {
const textures: TextureFile[] = []
const warnings: string[] = []
const errors: string[] = []
const modelFiles = files.filter((f) => {
const name = f.name.toLowerCase()
@@ -36,9 +29,7 @@ export function validateFolder(files: File[]): {
})
if (modelFiles.length === 0) {
result.errors.push('model.glb ou model.gltf manquant (obligatoire)')
} else {
result.model = modelFiles[0]
return { ok: false, errors: ['model.glb ou model.gltf manquant (obligatoire)'] }
}
const textureFiles = files.filter((f) => {
@@ -47,17 +38,21 @@ export function validateFolder(files: File[]): {
})
for (const tf of textureFiles) {
result.textures.push({ name: tf.name, file: tf })
textures.push({ name: tf.name, file: tf })
}
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) {
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 = {
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