Files
SkinbaseNova/resources/js/hooks/upload/useUploadMachine.js
2026-03-28 19:15:39 +01:00

504 lines
21 KiB
JavaScript

import { useCallback, useReducer, useRef } from 'react'
import { emitUploadEvent } from '../../lib/uploadAnalytics'
import * as uploadEndpoints from '../../lib/uploadEndpoints'
import { mapUploadErrorNotice, mapUploadResultNotice } from '../../lib/uploadNotices'
// ─── 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,
slug: 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: '', slug: action.slug ?? state.slug }
case 'SCHEDULED':
return { ...state, state: machineStates.complete, error: '', lastAction: 'schedule', slug: action.slug ?? state.slug }
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,
onNotice,
}) {
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 })
onNotice?.({ type: 'error', message: failureMessage })
clearPolling()
}
} catch (error) {
if (error?.name === 'CanceledError' || error?.code === 'ERR_CANCELED') return
const notice = mapUploadErrorNotice(error, 'Processing status check failed.')
dispatchMachine({ type: 'ERROR', error: notice.message })
onNotice?.(notice)
emitUploadEvent('upload_error', { stage: 'processing_poll', message: notice.message })
clearPolling()
}
}, [fetchProcessingStatus, registerController, unregisterController, clearPolling, onNotice])
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),
is_mature: Boolean(metadata.isMature),
})
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,
file_name: String(primaryFile?.name || ''),
},
{ signal: finishController.signal, headers: { 'X-Upload-Token': uploadToken } }
)
unregisterController(finishController)
const finishStatus = getProcessingValue(finishResponse?.data || {})
dispatchMachine({ type: 'FINISH_SUCCESS', processingStatus: finishStatus })
const finishNotice = mapUploadResultNotice(finishResponse?.data || {}, {
fallbackType: finishStatus === 'queued' ? 'warning' : 'success',
fallbackMessage: finishStatus === 'queued'
? 'Upload received. Processing is queued.'
: 'Upload completed successfully.',
})
onNotice?.(finishNotice)
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 notice = mapUploadErrorNotice(error, 'Upload failed.')
dispatchMachine({ type: 'ERROR', error: notice.message })
onNotice?.(notice)
emitUploadEvent('upload_error', { stage: 'upload_flow', message: notice.message })
}
}, [
primaryFile,
canStartUpload,
primaryType,
isArchive,
metadata,
effectiveChunkSize,
registerController,
unregisterController,
clearPolling,
startPolling,
onArtworkCreated,
onNotice,
])
// ── 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' })
onNotice?.({ type: 'warning', message: 'Upload cancelled.' })
emitUploadEvent('upload_cancel', { session_id: machine.sessionId || null })
} catch (error) {
const notice = mapUploadErrorNotice(error, 'Cancel failed.')
dispatchMachine({ type: 'ERROR', error: notice.message })
onNotice?.(notice)
emitUploadEvent('upload_error', { stage: 'cancel', message: notice.message })
}
}, [machine, abortAllRequests, clearPolling, onNotice])
// ── Publish ────────────────────────────────────────────────────────────────
/**
* handlePublish
*
* @param {boolean} canPublish
* @param {{ mode?: 'now'|'schedule', publishAt?: string|null, timezone?: string, visibility?: string }} [opts]
*/
const handlePublish = useCallback(async (canPublish, opts = {}) => {
if (!canPublish || publishLockRef.current) return
publishLockRef.current = true
dispatchMachine({ type: 'PUBLISH_START' })
const { mode = 'now', publishAt = null, timezone = null, visibility = 'public' } = opts
const resolvedCategoryId = metadata.subCategoryId || metadata.rootCategoryId || null
const buildPayload = () => ({
title: String(metadata.title || '').trim() || undefined,
description: String(metadata.description || '').trim() || null,
category: resolvedCategoryId ? String(resolvedCategoryId) : null,
tags: Array.isArray(metadata.tags) ? metadata.tags : [],
is_mature: Boolean(metadata.isMature),
mode,
...(mode === 'schedule' && publishAt ? { publish_at: publishAt } : {}),
...(timezone ? { timezone } : {}),
visibility,
})
try {
const publishTargetId =
resolvedArtworkIdRef.current || initialDraftId || machine.sessionId
if (resolvedArtworkIdRef.current && resolvedArtworkIdRef.current > 0) {
const publishController = registerController()
const publishRes = await window.axios.post(
uploadEndpoints.publish(String(resolvedArtworkIdRef.current)),
buildPayload(),
{ signal: publishController.signal }
)
unregisterController(publishController)
const publishedSlug = publishRes?.data?.slug ?? null
dispatchMachine({ type: mode === 'schedule' ? 'SCHEDULED' : 'PUBLISH_SUCCESS', slug: publishedSlug })
onNotice?.(mapUploadResultNotice(publishRes?.data || {}, {
fallbackType: 'success',
fallbackMessage: mode === 'schedule' ? 'Artwork scheduled successfully.' : 'Artwork published successfully.',
}))
emitUploadEvent('upload_publish', { id: publishTargetId, mode })
return
}
if (!publishTargetId) throw new Error('Missing publish id.')
const publishController = registerController()
const publishRes2 = await window.axios.post(
uploadEndpoints.publish(publishTargetId),
buildPayload(),
{ signal: publishController.signal }
)
unregisterController(publishController)
const publishedSlug2 = publishRes2?.data?.slug ?? null
dispatchMachine({ type: mode === 'schedule' ? 'SCHEDULED' : 'PUBLISH_SUCCESS', slug: publishedSlug2 })
onNotice?.(mapUploadResultNotice(publishRes2?.data || {}, {
fallbackType: 'success',
fallbackMessage: mode === 'schedule' ? 'Artwork scheduled successfully.' : 'Artwork published successfully.',
}))
emitUploadEvent('upload_publish', { id: publishTargetId, mode })
} catch (error) {
if (error?.name === 'CanceledError' || error?.code === 'ERR_CANCELED') return
const notice = mapUploadErrorNotice(error, 'Publish failed.')
dispatchMachine({ type: 'ERROR', error: notice.message })
onNotice?.(notice)
emitUploadEvent('upload_error', { stage: 'publish', message: notice.message })
} finally {
publishLockRef.current = false
}
}, [machine, initialDraftId, metadata, registerController, unregisterController, onNotice])
// ── 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 ──────────────────────────────────────────────────────────────────
/**
* handleRetry
*
* Re-attempts the last action. When the last action was a publish/schedule,
* opts must be forwarded so scheduled-publish options are not lost on retry.
*
* @param {boolean} canPublish
* @param {{ mode?: string, publishAt?: string|null, timezone?: string, visibility?: string }} [opts]
*/
const handleRetry = useCallback((canPublish, opts = {}) => {
clearPolling()
abortAllRequests()
if (machine.lastAction === 'publish') {
handlePublish(canPublish, opts)
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,
}
}