import { useCallback, useReducer, useRef } from 'react' import { emitUploadEvent } from '../../lib/uploadAnalytics' import * as uploadEndpoints from '../../lib/uploadEndpoints' // ─── Constants ────────────────────────────────────────────────────────────── const DEFAULT_CHUNK_SIZE_BYTES = 5 * 1024 * 1024 const POLL_INTERVAL_MS = 2000 // ─── State machine ─────────────────────────────────────────────────────────── export const machineStates = { idle: 'idle', initializing: 'initializing', uploading: 'uploading', finishing: 'finishing', processing: 'processing', ready_to_publish: 'ready_to_publish', publishing: 'publishing', complete: 'complete', error: 'error', cancelled: 'cancelled', } const initialMachineState = { state: machineStates.idle, progress: 0, sessionId: null, uploadToken: null, processingStatus: null, isCancelling: false, error: '', lastAction: null, } function machineReducer(state, action) { switch (action.type) { case 'INIT_START': return { ...state, state: machineStates.initializing, progress: 0, error: '', isCancelling: false, lastAction: 'start' } case 'INIT_SUCCESS': return { ...state, sessionId: action.sessionId, uploadToken: action.uploadToken, error: '' } case 'UPLOAD_START': return { ...state, state: machineStates.uploading, progress: 1, error: '' } case 'UPLOAD_PROGRESS': return { ...state, progress: Math.max(1, Math.min(95, action.progress)), error: '' } case 'FINISH_START': return { ...state, state: machineStates.finishing, progress: Math.max(state.progress, 96), error: '' } case 'FINISH_SUCCESS': return { ...state, state: machineStates.processing, progress: 100, processingStatus: action.processingStatus ?? 'processing', error: '' } case 'PROCESSING_STATUS': return { ...state, processingStatus: action.processingStatus ?? state.processingStatus, error: '' } case 'READY_TO_PUBLISH': return { ...state, state: machineStates.ready_to_publish, processingStatus: 'ready', error: '' } case 'PUBLISH_START': return { ...state, state: machineStates.publishing, error: '', lastAction: 'publish' } case 'PUBLISH_SUCCESS': return { ...state, state: machineStates.complete, error: '' } case 'CANCEL_START': return { ...state, isCancelling: true, error: '', lastAction: 'cancel' } case 'CANCELLED': return { ...state, state: machineStates.cancelled, isCancelling: false, error: '' } case 'ERROR': return { ...state, state: machineStates.error, isCancelling: false, error: action.error || 'Upload failed.' } case 'RESET_MACHINE': return { ...initialMachineState } default: return state } } // ─── Helpers ───────────────────────────────────────────────────────────────── function toPercent(loaded, total) { if (!Number.isFinite(total) || total <= 0) return 0 return Math.max(0, Math.min(100, Math.round((loaded / total) * 100))) } function getProcessingValue(payload) { const direct = String(payload?.processing_state || payload?.status || '').toLowerCase() return direct || 'processing' } export function isReadyToPublishStatus(status) { const normalized = String(status || '').toLowerCase() return ['ready', 'processed', 'publish_ready', 'published', 'complete'].includes(normalized) } // ─── Hook ───────────────────────────────────────────────────────────────────── /** * useUploadMachine * * Manages the full upload state machine lifecycle: * init → chunk → finish → poll → publish * * @param {object} opts * @param {File|null} opts.primaryFile * @param {boolean} opts.canStartUpload * @param {string} opts.primaryType 'image' | 'archive' | 'unknown' * @param {boolean} opts.isArchive * @param {number|null} opts.initialDraftId * @param {object} opts.metadata { title, description, tags, rightsAccepted, ... } * @param {number} [opts.chunkSize] * @param {function} [opts.onArtworkCreated] called with artworkId after draft creation */ export default function useUploadMachine({ primaryFile, canStartUpload, primaryType, isArchive, initialDraftId = null, metadata, chunkSize = DEFAULT_CHUNK_SIZE_BYTES, onArtworkCreated, }) { const [machine, dispatchMachine] = useReducer(machineReducer, initialMachineState) const pollingTimerRef = useRef(null) const requestControllersRef = useRef(new Set()) const publishLockRef = useRef(false) // Resolved artwork id (draft) created at the start of the upload const resolvedArtworkIdRef = useRef( (() => { const parsed = Number(initialDraftId) return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : null })() ) const effectiveChunkSize = (() => { const parsed = Number(chunkSize) return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : DEFAULT_CHUNK_SIZE_BYTES })() // ── Controller registry ──────────────────────────────────────────────────── const registerController = useCallback(() => { const controller = new AbortController() requestControllersRef.current.add(controller) return controller }, []) const unregisterController = useCallback((controller) => { if (!controller) return requestControllersRef.current.delete(controller) }, []) const abortAllRequests = useCallback(() => { requestControllersRef.current.forEach((c) => c.abort()) requestControllersRef.current.clear() }, []) // ── Polling ──────────────────────────────────────────────────────────────── const clearPolling = useCallback(() => { if (pollingTimerRef.current) { window.clearInterval(pollingTimerRef.current) pollingTimerRef.current = null } }, []) const fetchProcessingStatus = useCallback(async (sessionId, uploadToken, signal) => { const response = await window.axios.get(uploadEndpoints.status(sessionId), { signal, headers: uploadToken ? { 'X-Upload-Token': uploadToken } : undefined, params: uploadToken ? { upload_token: uploadToken } : undefined, }) return response.data || {} }, []) const pollProcessing = useCallback(async (sessionId, uploadToken) => { if (!sessionId) return try { const statusController = registerController() const payload = await fetchProcessingStatus(sessionId, uploadToken, statusController.signal) unregisterController(statusController) const processingValue = getProcessingValue(payload) dispatchMachine({ type: 'PROCESSING_STATUS', processingStatus: processingValue }) if (isReadyToPublishStatus(processingValue)) { dispatchMachine({ type: 'READY_TO_PUBLISH' }) clearPolling() } else if (processingValue === 'rejected' || processingValue === 'error' || payload?.failure_reason) { const failureMessage = payload?.failure_reason || payload?.message || `Processing ended with status: ${processingValue}` dispatchMachine({ type: 'ERROR', error: failureMessage }) clearPolling() } } catch (error) { if (error?.name === 'CanceledError' || error?.code === 'ERR_CANCELED') return const message = error?.response?.data?.message || 'Processing status check failed.' dispatchMachine({ type: 'ERROR', error: message }) emitUploadEvent('upload_error', { stage: 'processing_poll', message }) clearPolling() } }, [fetchProcessingStatus, registerController, unregisterController, clearPolling]) const startPolling = useCallback((sessionId, uploadToken) => { clearPolling() pollProcessing(sessionId, uploadToken) pollingTimerRef.current = window.setInterval(() => { pollProcessing(sessionId, uploadToken) }, POLL_INTERVAL_MS) }, [clearPolling, pollProcessing]) // ── Core upload flow ─────────────────────────────────────────────────────── const runUploadFlow = useCallback(async () => { if (!primaryFile || !canStartUpload) return clearPolling() dispatchMachine({ type: 'INIT_START' }) emitUploadEvent('upload_start', { file_name: primaryFile.name, file_size: primaryFile.size, file_type: primaryType, is_archive: isArchive, }) try { // 1. Create or reuse the artwork draft let artworkIdForUpload = resolvedArtworkIdRef.current if (!artworkIdForUpload) { const derivedTitle = String(metadata.title || '').trim() || String(primaryFile.name || '').replace(/\.[^.]+$/, '') || 'Untitled upload' const draftResponse = await window.axios.post('/api/artworks', { title: derivedTitle, description: String(metadata.description || '').trim() || null, category: metadata.subCategoryId || metadata.rootCategoryId || null, tags: Array.isArray(metadata.tags) ? metadata.tags.join(', ') : '', license: Boolean(metadata.rightsAccepted), }) const draftIdCandidate = Number(draftResponse?.data?.artwork_id ?? draftResponse?.data?.id) if (!Number.isFinite(draftIdCandidate) || draftIdCandidate <= 0) { throw new Error('Unable to create upload draft before finishing upload.') } artworkIdForUpload = Math.floor(draftIdCandidate) resolvedArtworkIdRef.current = artworkIdForUpload onArtworkCreated?.(artworkIdForUpload) } // 2. Init upload session const initController = registerController() const initResponse = await window.axios.post( uploadEndpoints.init(), { client: 'web' }, { signal: initController.signal } ) unregisterController(initController) const sessionId = initResponse?.data?.session_id const uploadToken = initResponse?.data?.upload_token if (!sessionId || !uploadToken) { throw new Error('Upload session initialization returned an invalid payload.') } dispatchMachine({ type: 'INIT_SUCCESS', sessionId, uploadToken }) dispatchMachine({ type: 'UPLOAD_START' }) // 3. Chunked upload let uploaded = 0 const totalSize = primaryFile.size while (uploaded < totalSize) { const nextOffset = Math.min(uploaded + effectiveChunkSize, totalSize) const blob = primaryFile.slice(uploaded, nextOffset) const payload = new FormData() payload.append('session_id', sessionId) payload.append('offset', String(uploaded)) payload.append('chunk_size', String(blob.size)) payload.append('total_size', String(totalSize)) payload.append('upload_token', uploadToken) payload.append('chunk', blob) const chunkController = registerController() const chunkResponse = await window.axios.post(uploadEndpoints.chunk(), payload, { signal: chunkController.signal, headers: { 'X-Upload-Token': uploadToken }, }) unregisterController(chunkController) const receivedBytes = Number(chunkResponse?.data?.received_bytes ?? nextOffset) uploaded = Math.max(nextOffset, Number.isFinite(receivedBytes) ? receivedBytes : nextOffset) const progress = chunkResponse?.data?.progress ?? toPercent(uploaded, totalSize) dispatchMachine({ type: 'UPLOAD_PROGRESS', progress }) } // 4. Finish + start processing dispatchMachine({ type: 'FINISH_START' }) const finishController = registerController() const finishResponse = await window.axios.post( uploadEndpoints.finish(), { session_id: sessionId, upload_token: uploadToken, artwork_id: artworkIdForUpload }, { signal: finishController.signal, headers: { 'X-Upload-Token': uploadToken } } ) unregisterController(finishController) const finishStatus = getProcessingValue(finishResponse?.data || {}) dispatchMachine({ type: 'FINISH_SUCCESS', processingStatus: finishStatus }) if (isReadyToPublishStatus(finishStatus)) { dispatchMachine({ type: 'READY_TO_PUBLISH' }) } else { startPolling(sessionId, uploadToken) } emitUploadEvent('upload_complete', { session_id: sessionId, artwork_id: artworkIdForUpload }) } catch (error) { if (error?.name === 'CanceledError' || error?.code === 'ERR_CANCELED') return const message = error?.response?.data?.message || error?.message || 'Upload failed.' dispatchMachine({ type: 'ERROR', error: message }) emitUploadEvent('upload_error', { stage: 'upload_flow', message }) } }, [ primaryFile, canStartUpload, primaryType, isArchive, metadata, effectiveChunkSize, registerController, unregisterController, clearPolling, startPolling, onArtworkCreated, ]) // ── Cancel ───────────────────────────────────────────────────────────────── const handleCancel = useCallback(async () => { dispatchMachine({ type: 'CANCEL_START' }) clearPolling() abortAllRequests() try { const { sessionId, uploadToken } = machine if (sessionId) { await window.axios.post( uploadEndpoints.cancel(), { session_id: sessionId, upload_token: uploadToken }, { headers: uploadToken ? { 'X-Upload-Token': uploadToken } : undefined } ) } dispatchMachine({ type: 'CANCELLED' }) emitUploadEvent('upload_cancel', { session_id: machine.sessionId || null }) } catch (error) { const message = error?.response?.data?.message || 'Cancel failed.' dispatchMachine({ type: 'ERROR', error: message }) emitUploadEvent('upload_error', { stage: 'cancel', message }) } }, [machine, abortAllRequests, clearPolling]) // ── Publish ──────────────────────────────────────────────────────────────── const handlePublish = useCallback(async (canPublish) => { if (!canPublish || publishLockRef.current) return publishLockRef.current = true dispatchMachine({ type: 'PUBLISH_START' }) try { const publishTargetId = resolvedArtworkIdRef.current || initialDraftId || machine.sessionId if (resolvedArtworkIdRef.current && resolvedArtworkIdRef.current > 0) { dispatchMachine({ type: 'PUBLISH_SUCCESS' }) emitUploadEvent('upload_publish', { id: publishTargetId }) return } if (!publishTargetId) throw new Error('Missing publish id.') const publishController = registerController() await window.axios.post( uploadEndpoints.publish(publishTargetId), { title: String(metadata.title || '').trim() || undefined, description: String(metadata.description || '').trim() || null, }, { signal: publishController.signal } ) unregisterController(publishController) dispatchMachine({ type: 'PUBLISH_SUCCESS' }) emitUploadEvent('upload_publish', { id: publishTargetId }) } catch (error) { if (error?.name === 'CanceledError' || error?.code === 'ERR_CANCELED') return const message = error?.response?.data?.message || error?.message || 'Publish failed.' dispatchMachine({ type: 'ERROR', error: message }) emitUploadEvent('upload_error', { stage: 'publish', message }) } finally { publishLockRef.current = false } }, [machine, initialDraftId, metadata.title, metadata.description, registerController, unregisterController]) // ── Reset ────────────────────────────────────────────────────────────────── const resetMachine = useCallback(() => { clearPolling() abortAllRequests() resolvedArtworkIdRef.current = (() => { const parsed = Number(initialDraftId) return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : null })() publishLockRef.current = false dispatchMachine({ type: 'RESET_MACHINE' }) }, [clearPolling, abortAllRequests, initialDraftId]) // ── Retry ────────────────────────────────────────────────────────────────── const handleRetry = useCallback((canPublish) => { clearPolling() abortAllRequests() if (machine.lastAction === 'publish') { handlePublish(canPublish) return } runUploadFlow() }, [machine.lastAction, handlePublish, runUploadFlow, clearPolling, abortAllRequests]) // ── Cleanup on unmount ───────────────────────────────────────────────────── // (callers should call resetMachine or abortAllRequests on unmount if needed) return { machine, dispatchMachine, resolvedArtworkId: resolvedArtworkIdRef.current, runUploadFlow, handleCancel, handlePublish, handleRetry, resetMachine, clearPolling, abortAllRequests, startPolling, } }