'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 SecretInput from './upload/SecretInput' import DestinationPicker from './upload/DestinationPicker' import FolderDropzone from './upload/FolderDropzone' import FolderCard from './upload/FolderCard' import ActionButtons from './upload/ActionButtons' 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 { 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() // 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, secretError, secretVisible, isSecretEmpty, setSecretError, handleSecretChange, toggleSecretVisible, } = useSecret() const { entries, setEntries, updateEntry, removeEntry, resetEntries, allDone, hasErrors, } = useFolderEntries() const [isUploading, setIsUploading] = useState(false) const [globalError, setGlobalError] = useState(null) const [destination, setDestination] = useState(null) const [overwriteConfirm, setOverwriteConfirm] = useState<{ folderName: string diffs: FileDiff[] } | null>(null) const [noChangesFolder, setNoChangesFolder] = useState(null) // Drive error modal state const [driveError, setDriveError] = useState<{ error: string folderIndex: number } | null>(null) const abortRef = useRef(null) // Tracks the check result so we know if it's "new" or "replace" for the Drive const checkResultRef = useRef({ exists: false, diffs: [] }) // -- Handlers -- const handleFolderSelected = (entry: FolderEntry) => { setGlobalError(null) setEntries([entry]) } const handleToggleViewer = (index: number) => { const entry = entries[index] if (entry?.modelUrl) { updateEntry(index, { viewerOpen: !entry.viewerOpen }) } } 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 (
{entries.length === 0 && ( )} {globalError && (

{globalError}

)} {entries.length > 0 && (
{entries.map((entry, i) => ( ))}
)} {overwriteConfirm && ( setOverwriteConfirm(null)} onConfirm={proceedUpload} /> )} {noChangesFolder && ( { setNoChangesFolder(null) handleReset() }} onModify={() => setNoChangesFolder(null)} /> )} {driveError && ( )}
) }