384 lines
11 KiB
TypeScript
384 lines
11 KiB
TypeScript
'use client'
|
|
|
|
import { useState, useRef, useCallback } from 'react'
|
|
import { getErrorMessage } from '@/lib/guards'
|
|
import type { FolderEntry } from '@/lib/client-types'
|
|
import type {
|
|
CheckUploadResult,
|
|
DriveAction,
|
|
FileDiff,
|
|
GitModelMode,
|
|
} from '@/lib/types'
|
|
import { checkFolderDiffs, stageUpload, uploadDrive, uploadGit } from '@/lib/upload-api'
|
|
|
|
type UploadLogDetails = Record<string, string | number | boolean | undefined>
|
|
|
|
function formatElapsed(startedAt: number) {
|
|
return `${((performance.now() - startedAt) / 1000).toFixed(1)}s`
|
|
}
|
|
|
|
function logUpload(level: 'INFO' | 'ERROR', step: string, action: string, startedAt: number, details?: UploadLogDetails) {
|
|
const log = level === 'ERROR' ? console.error : console.info
|
|
log(`[${level}] ${step} -> ${action} | Timer: ${formatElapsed(startedAt)}`, details || '')
|
|
}
|
|
|
|
function startTimedLog(step: string, action: string, details?: UploadLogDetails) {
|
|
const startedAt = performance.now()
|
|
logUpload('INFO', step, `${action} started`, startedAt, details)
|
|
|
|
const interval = window.setInterval(() => {
|
|
logUpload('INFO', step, `${action} running`, startedAt, details)
|
|
}, 10_000)
|
|
|
|
return (status: 'done' | 'failed' | 'cancelled' = 'done', extra?: UploadLogDetails) => {
|
|
window.clearInterval(interval)
|
|
logUpload(status === 'failed' ? 'ERROR' : 'INFO', step, `${action} ${status}`, startedAt, { ...details, ...extra })
|
|
}
|
|
}
|
|
|
|
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 [isChecking, setIsChecking] = useState(false)
|
|
const [isResolvingDriveError, setIsResolvingDriveError] = useState(false)
|
|
const [globalError, setGlobalError] = useState<string | 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<CheckUploadResult>({ exists: false, diffs: [] })
|
|
const uploadActionRef = useRef(false)
|
|
const stagingIdRef = useRef<string | null>(null)
|
|
|
|
const secretRef = useRef(secret)
|
|
secretRef.current = secret
|
|
const entriesRef = useRef(entries)
|
|
entriesRef.current = entries
|
|
|
|
const pushGit = useCallback(async (index: number, signal?: AbortSignal) => {
|
|
const stagingId = stagingIdRef.current
|
|
if (!stagingId) {
|
|
updateEntry(index, { status: 'error', error: 'Preparation serveur introuvable' })
|
|
return
|
|
}
|
|
|
|
const folderName = entriesRef.current[index]?.folderName
|
|
const endGitLog = startTimedLog('Git', 'Upload', { folderName, stagingId })
|
|
let gitResult: Awaited<ReturnType<typeof uploadGit>>
|
|
|
|
try {
|
|
gitResult = await uploadGit(
|
|
stagingId,
|
|
secretRef.current,
|
|
(pct) => updateEntry(index, { progress: 50 + Math.round(pct / 2) }),
|
|
signal,
|
|
)
|
|
endGitLog(gitResult.success ? 'done' : 'failed', { error: gitResult.error })
|
|
} catch (err) {
|
|
endGitLog(signal?.aborted ? 'cancelled' : 'failed', {
|
|
error: getErrorMessage(err),
|
|
})
|
|
throw err
|
|
}
|
|
|
|
updateEntry(index, {
|
|
status: gitResult.success ? 'success' : 'error',
|
|
progress: gitResult.success ? 100 : 0,
|
|
error: gitResult.success ? undefined : gitResult.error,
|
|
uploadWarning: gitResult.success ? gitResult.warning : undefined,
|
|
filename: gitResult.filename,
|
|
})
|
|
}, [updateEntry])
|
|
|
|
const proceedUpload = useCallback(async () => {
|
|
if (uploadActionRef.current) return
|
|
uploadActionRef.current = true
|
|
setOverwriteConfirm(null)
|
|
setIsChecking(false)
|
|
setIsUploading(true)
|
|
setGlobalError(null)
|
|
|
|
const controller = new AbortController()
|
|
abortRef.current = controller
|
|
|
|
try {
|
|
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: DriveAction = checkResultRef.current.exists ? 'replace' : 'new'
|
|
const stagingId = stagingIdRef.current
|
|
if (!stagingId) {
|
|
updateEntry(i, { status: 'error', error: 'Preparation serveur introuvable' })
|
|
return
|
|
}
|
|
|
|
updateEntry(i, {
|
|
status: 'uploading',
|
|
progress: 1,
|
|
error: undefined,
|
|
uploadWarning: undefined,
|
|
driveStatus: 'uploading',
|
|
driveError: undefined,
|
|
})
|
|
|
|
const endDriveLog = startTimedLog('Drive', 'Upload', {
|
|
folderName: folderEntry.folderName,
|
|
stagingId,
|
|
action: driveAction,
|
|
})
|
|
let driveResult: Awaited<ReturnType<typeof uploadDrive>>
|
|
|
|
try {
|
|
driveResult = await uploadDrive(
|
|
stagingId,
|
|
secretRef.current,
|
|
driveAction,
|
|
controller.signal,
|
|
)
|
|
endDriveLog(driveResult.success ? 'done' : 'failed', { error: driveResult.error })
|
|
} catch (err) {
|
|
endDriveLog(controller.signal.aborted ? 'cancelled' : 'failed', {
|
|
error: getErrorMessage(err),
|
|
})
|
|
throw err
|
|
}
|
|
|
|
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 })
|
|
|
|
await pushGit(i, controller.signal)
|
|
}
|
|
} finally {
|
|
abortRef.current = null
|
|
setIsUploading(false)
|
|
uploadActionRef.current = false
|
|
}
|
|
}, [updateEntry, pushGit])
|
|
|
|
const handleUpload = useCallback(async (gitModelMode: GitModelMode) => {
|
|
if (uploadActionRef.current || isChecking || isUploading) return
|
|
|
|
if (!secretRef.current.trim()) {
|
|
setSecretError("La cle d'acces est requise")
|
|
return
|
|
}
|
|
if (entriesRef.current.length === 0) return
|
|
|
|
uploadActionRef.current = true
|
|
setIsChecking(true)
|
|
setSecretError(null)
|
|
setGlobalError(null)
|
|
|
|
const folder = entriesRef.current[0]
|
|
const controller = new AbortController()
|
|
abortRef.current = controller
|
|
|
|
try {
|
|
const endStageLog = startTimedLog('Verification', 'Staging', {
|
|
folderName: folder.folderName,
|
|
files: 1 + folder.textures.length,
|
|
modelSize: folder.modelFile.size,
|
|
gitModelMode,
|
|
})
|
|
let staged: Awaited<ReturnType<typeof stageUpload>>
|
|
|
|
try {
|
|
staged = await stageUpload(folder, gitModelMode, secretRef.current, controller.signal)
|
|
endStageLog('done', { stagingId: staged.stagingId, filesCount: staged.filesCount })
|
|
} catch (err) {
|
|
endStageLog(controller.signal.aborted ? 'cancelled' : 'failed', {
|
|
error: getErrorMessage(err),
|
|
})
|
|
throw err
|
|
}
|
|
|
|
stagingIdRef.current = staged.stagingId
|
|
|
|
const endCheckLog = startTimedLog('Verification', 'Git diff', {
|
|
folderName: folder.folderName,
|
|
stagingId: staged.stagingId,
|
|
})
|
|
let check: CheckUploadResult
|
|
|
|
try {
|
|
check = await checkFolderDiffs(
|
|
staged.stagingId,
|
|
secretRef.current,
|
|
controller.signal,
|
|
)
|
|
endCheckLog('done', { exists: check.exists, diffs: check.diffs.length })
|
|
} catch (err) {
|
|
endCheckLog(controller.signal.aborted ? 'cancelled' : 'failed', {
|
|
error: getErrorMessage(err),
|
|
})
|
|
throw err
|
|
}
|
|
|
|
checkResultRef.current = check
|
|
updateEntry(0, { uploadWarning: check.warning })
|
|
|
|
if (check.exists) {
|
|
if (check.diffs.length === 0) {
|
|
setNoChangesFolder(folder.folderName)
|
|
uploadActionRef.current = false
|
|
setIsChecking(false)
|
|
abortRef.current = null
|
|
return
|
|
}
|
|
setOverwriteConfirm({ folderName: folder.folderName, diffs: check.diffs })
|
|
uploadActionRef.current = false
|
|
setIsChecking(false)
|
|
abortRef.current = null
|
|
return
|
|
}
|
|
} catch (err) {
|
|
const message = getErrorMessage(err)
|
|
setGlobalError(message)
|
|
uploadActionRef.current = false
|
|
setIsChecking(false)
|
|
abortRef.current = null
|
|
return
|
|
}
|
|
|
|
uploadActionRef.current = false
|
|
abortRef.current = null
|
|
await proceedUpload()
|
|
}, [setSecretError, proceedUpload, isChecking, isUploading])
|
|
|
|
const handleDriveContinue = useCallback(async () => {
|
|
if (!driveError || uploadActionRef.current) return
|
|
uploadActionRef.current = true
|
|
setIsResolvingDriveError(true)
|
|
const idx = driveError.folderIndex
|
|
setDriveError(null)
|
|
|
|
try {
|
|
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,
|
|
uploadWarning: undefined,
|
|
driveStatus: 'skipped',
|
|
})
|
|
|
|
await pushGit(i, abortRef.current?.signal)
|
|
}
|
|
} finally {
|
|
abortRef.current = null
|
|
setIsUploading(false)
|
|
setIsResolvingDriveError(false)
|
|
uploadActionRef.current = 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
|
|
uploadActionRef.current = false
|
|
setIsChecking(false)
|
|
setIsResolvingDriveError(false)
|
|
setIsUploading(false)
|
|
}, [driveError, updateEntry])
|
|
|
|
const handleCancel = useCallback(() => {
|
|
if (isChecking) {
|
|
abortRef.current?.abort()
|
|
abortRef.current = null
|
|
uploadActionRef.current = false
|
|
setIsChecking(false)
|
|
return
|
|
}
|
|
abortRef.current?.abort()
|
|
abortRef.current = null
|
|
uploadActionRef.current = false
|
|
setIsResolvingDriveError(false)
|
|
setIsUploading(false)
|
|
stagingIdRef.current = null
|
|
}, [isChecking])
|
|
|
|
const handleReset = useCallback(() => {
|
|
resetEntries()
|
|
setGlobalError(null)
|
|
setIsChecking(false)
|
|
setIsUploading(false)
|
|
setIsResolvingDriveError(false)
|
|
setDriveError(null)
|
|
checkResultRef.current = { exists: false, diffs: [] }
|
|
uploadActionRef.current = false
|
|
stagingIdRef.current = null
|
|
}, [resetEntries])
|
|
|
|
return {
|
|
isUploading,
|
|
isChecking,
|
|
isResolvingDriveError,
|
|
globalError,
|
|
setGlobalError,
|
|
overwriteConfirm,
|
|
noChangesFolder,
|
|
setNoChangesFolder,
|
|
driveError,
|
|
handleUpload,
|
|
handleOverwriteCancel: () => {
|
|
setOverwriteConfirm(null)
|
|
uploadActionRef.current = false
|
|
setIsChecking(false)
|
|
},
|
|
handleDriveContinue,
|
|
handleDriveCancel,
|
|
handleCancel,
|
|
handleReset,
|
|
proceedUpload,
|
|
}
|
|
}
|