import React, { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react' import UploadDropzone from './UploadDropzone' import ScreenshotUploader from './ScreenshotUploader' import UploadSidebar from './UploadSidebar' import UploadProgress from './UploadProgress' import UploadActions from './UploadActions' import UploadStepper from './UploadStepper' import { emitUploadEvent } from '../../lib/uploadAnalytics' import * as uploadEndpoints from '../../lib/uploadEndpoints' import { AnimatePresence, motion, useReducedMotion } from 'framer-motion' const IMAGE_EXTENSIONS = new Set(['jpg', 'jpeg', 'png', 'webp']) const IMAGE_MIME = new Set(['image/jpeg', 'image/png', 'image/webp']) const ARCHIVE_EXTENSIONS = new Set(['zip', 'rar', '7z', 'tar', 'gz']) const ARCHIVE_MIME = new Set([ 'application/zip', 'application/x-zip-compressed', 'application/x-rar-compressed', 'application/vnd.rar', 'application/x-7z-compressed', 'application/x-tar', 'application/gzip', 'application/x-gzip', 'application/octet-stream', ]) const PRIMARY_IMAGE_MAX_BYTES = 50 * 1024 * 1024 const PRIMARY_ARCHIVE_MAX_BYTES = 200 * 1024 * 1024 const SCREENSHOT_MAX_BYTES = 10 * 1024 * 1024 const DEFAULT_CHUNK_SIZE_BYTES = 5 * 1024 * 1024 const POLL_INTERVAL_MS = 2000 const wizardSteps = [ { key: 'upload', label: 'Upload' }, { key: 'details', label: 'Details' }, { key: 'publish', label: 'Publish' }, ] function getExtension(fileName = '') { const parts = String(fileName).toLowerCase().split('.') return parts.length > 1 ? parts.pop() : '' } function detectFileType(file) { if (!file) return 'unknown' const extension = getExtension(file.name) const mime = String(file.type || '').toLowerCase() if (IMAGE_MIME.has(mime) || IMAGE_EXTENSIONS.has(extension)) return 'image' if (ARCHIVE_MIME.has(mime) || ARCHIVE_EXTENSIONS.has(extension)) return 'archive' return 'unsupported' } function formatBytes(bytes) { if (!Number.isFinite(bytes) || bytes <= 0) return '—' if (bytes < 1024) return `${bytes} B` const kb = bytes / 1024 if (kb < 1024) return `${kb.toFixed(1)} KB` const mb = kb / 1024 if (mb < 1024) return `${mb.toFixed(1)} MB` return `${(mb / 1024).toFixed(2)} GB` } function readImageDimensions(file) { return new Promise((resolve, reject) => { const blobUrl = URL.createObjectURL(file) const image = new Image() image.onload = () => { resolve({ width: image.naturalWidth, height: image.naturalHeight }) URL.revokeObjectURL(blobUrl) } image.onerror = () => { reject(new Error('image_read_failed')) URL.revokeObjectURL(blobUrl) } image.src = blobUrl }) } function buildCategoryTree(contentTypes = []) { const rootsById = new Map() contentTypes.forEach((type) => { const categories = Array.isArray(type?.categories) ? type.categories : [] categories.forEach((category) => { if (!category?.id) return if (!rootsById.has(String(category.id))) { rootsById.set(String(category.id), { id: String(category.id), name: category.name || `Category ${category.id}`, children: [], }) } const root = rootsById.get(String(category.id)) const children = Array.isArray(category?.children) ? category.children : [] children.forEach((child) => { if (!child?.id) return const exists = root.children.some((item) => String(item.id) === String(child.id)) if (!exists) { root.children.push({ id: String(child.id), name: child.name || `Subcategory ${child.id}` }) } }) }) }) return Array.from(rootsById.values()) } function getContentTypeValue(type) { if (!type) return '' return String(type.id ?? type.key ?? type.slug ?? type.name ?? '') } function getContentTypeVisualKey(type) { const raw = String(type?.slug || type?.name || type?.key || '').toLowerCase() const normalized = raw.replace(/\s+/g, '_').replace(/[^a-z0-9_]/g, '') if (normalized.includes('wallpaper')) return 'wallpapers' if (normalized.includes('skin')) return 'skins' if (normalized.includes('photo')) return 'photography' return 'other' } export async function validatePrimaryFile(file) { const errors = [] const warnings = [] const type = detectFileType(file) if (!file) { return { type: 'unknown', errors: [], warnings, metadata: { resolution: '—', size: '—', type: '—' }, previewUrl: '', } } const metadata = { resolution: '—', size: formatBytes(file.size), type: file.type || getExtension(file.name) || 'unknown', } if (type === 'unsupported') { errors.push('Unsupported file type. Use image (jpg/jpeg/png/webp) or archive (zip/rar/7z/tar/gz).') } if (type === 'image') { if (file.size > PRIMARY_IMAGE_MAX_BYTES) { errors.push('Image exceeds 50MB maximum size.') } try { const dimensions = await readImageDimensions(file) metadata.resolution = `${dimensions.width} × ${dimensions.height}` if (dimensions.width < 800 || dimensions.height < 600) { errors.push('Image resolution must be at least 800×600.') } } catch { errors.push('Unable to read image resolution.') } } if (type === 'archive') { metadata.resolution = 'n/a' if (file.size > PRIMARY_ARCHIVE_MAX_BYTES) { errors.push('Archive exceeds 200MB maximum size.') } warnings.push('Archive upload requires at least one valid screenshot.') } const previewUrl = type === 'image' ? URL.createObjectURL(file) : '' return { type, errors, warnings, metadata, previewUrl, } } export async function validateScreenshots(files, isArchive) { if (!isArchive) { return { errors: [], perFileErrors: [] } } const errors = [] const perFileErrors = Array.from({ length: files.length }, () => '') if (files.length < 1) { errors.push('At least one screenshot is required for archives.') } if (files.length > 5) { errors.push('Maximum 5 screenshots are allowed.') } await Promise.all(files.map(async (file, index) => { const typeErrors = [] const extension = getExtension(file.name) const mime = String(file.type || '').toLowerCase() if (!IMAGE_MIME.has(mime) && !IMAGE_EXTENSIONS.has(extension)) { typeErrors.push('Must be JPG, PNG, or WEBP.') } if (file.size > SCREENSHOT_MAX_BYTES) { typeErrors.push('Must be 10MB or less.') } if (typeErrors.length === 0) { try { const dimensions = await readImageDimensions(file) if (dimensions.width < 1280 || dimensions.height < 720) { typeErrors.push('Minimum resolution is 1280×720.') } } catch { typeErrors.push('Could not read screenshot resolution.') } } if (typeErrors.length > 0) { perFileErrors[index] = typeErrors.join(' ') } })) if (perFileErrors.some(Boolean)) { errors.push('One or more screenshots are invalid.') } return { errors, perFileErrors } } 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 } } 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() if (direct) return direct return 'processing' } function isReadyToPublishStatus(status) { const normalized = String(status || '').toLowerCase() return ['ready', 'processed', 'publish_ready', 'published', 'complete'].includes(normalized) } function getProcessingTransparencyLabel(processingStatus, machineState) { if (!['processing', 'finishing', 'publishing'].includes(machineState)) return '' const normalized = String(processingStatus || '').toLowerCase() if (normalized === 'generating_preview') return 'Generating preview' if (['processed', 'ready', 'published', 'queued', 'publish_ready'].includes(normalized)) { return 'Preparing for publish' } return 'Analyzing content' } export default function UploadWizard({ onValidationStateChange, initialDraftId = null, chunkSize = DEFAULT_CHUNK_SIZE_BYTES, contentTypes = [], suggestedTags = [], }) { const [activeStep, setActiveStep] = useState(1) const [showRestoredBanner, setShowRestoredBanner] = useState(Boolean(initialDraftId)) const [primaryFile, setPrimaryFile] = useState(null) const [primaryPreviewUrl, setPrimaryPreviewUrl] = useState('') const [primaryType, setPrimaryType] = useState('unknown') const [primaryWarnings, setPrimaryWarnings] = useState([]) const [primaryErrors, setPrimaryErrors] = useState([]) const [fileMetadata, setFileMetadata] = useState({ resolution: '—', size: '—', type: '—' }) const [screenshots, setScreenshots] = useState([]) const [screenshotErrors, setScreenshotErrors] = useState([]) const [screenshotPerFileErrors, setScreenshotPerFileErrors] = useState([]) const [metadata, setMetadata] = useState({ title: '', rootCategoryId: '', subCategoryId: '', tags: [], description: '', rightsAccepted: false, contentType: '', }) const [isUploadLocked, setIsUploadLocked] = useState(false) const [resolvedArtworkId, setResolvedArtworkId] = useState(() => { const parsed = Number(initialDraftId) return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : null }) const [machine, dispatchMachine] = useReducer(machineReducer, initialMachineState) const prefersReducedMotion = useReducedMotion() const stepContentRef = useRef(null) const stepHeadingRef = useRef(null) const primaryValidationRunRef = useRef(0) const screenshotValidationRunRef = useRef(0) const pollingTimerRef = useRef(null) const requestControllersRef = useRef(new Set()) const publishLockRef = useRef(false) const hasAutoAdvancedRef = useRef(false) const effectiveChunkSize = useMemo(() => { const parsed = Number(chunkSize) if (!Number.isFinite(parsed) || parsed <= 0) return DEFAULT_CHUNK_SIZE_BYTES return Math.floor(parsed) }, [chunkSize]) const quickTransition = prefersReducedMotion ? { duration: 0 } : { duration: 0.2, ease: 'easeOut' } const categoryTreeByType = useMemo(() => { const next = {} const list = Array.isArray(contentTypes) ? contentTypes : [] list.forEach((type) => { const value = getContentTypeValue(type) if (!value) return next[value] = buildCategoryTree([type]) }) return next }, [contentTypes]) const filteredCategoryTree = useMemo(() => { const selected = String(metadata.contentType || '') if (!selected) return [] return categoryTreeByType[selected] || [] }, [categoryTreeByType, metadata.contentType]) const selectedRootCategory = useMemo(() => { return filteredCategoryTree.find((root) => String(root.id) === String(metadata.rootCategoryId || '')) || null }, [filteredCategoryTree, metadata.rootCategoryId]) const requiresSubCategory = Boolean(selectedRootCategory && Array.isArray(selectedRootCategory.children) && selectedRootCategory.children.length > 0) const stepProgressPercent = useMemo(() => { if (activeStep === 1) return 33 if (activeStep === 2) return 66 return 100 }, [activeStep]) const isArchive = primaryType === 'archive' const processingTransparencyLabel = useMemo(() => getProcessingTransparencyLabel(machine.processingStatus, machine.state), [machine.processingStatus, machine.state]) const uploadReady = useMemo(() => { return machine.state === machineStates.ready_to_publish || machine.processingStatus === 'ready' || machine.state === machineStates.complete }, [machine.state, machine.processingStatus]) const metadataErrors = useMemo(() => { const next = {} if (!String(metadata.title || '').trim()) next.title = 'Title is required.' if (!metadata.contentType) next.contentType = 'Content type is required.' if (!metadata.rootCategoryId) next.category = 'Root category is required.' if (metadata.rootCategoryId && requiresSubCategory && !metadata.subCategoryId) next.category = 'Subcategory is required for the selected root category.' if (!metadata.rightsAccepted) next.rights = 'Rights confirmation is required to continue.' return next }, [metadata, requiresSubCategory]) const detailsValid = Object.keys(metadataErrors).length === 0 const highestUnlockedStep = uploadReady ? (detailsValid ? 3 : 2) : 1 const showProgress = machine.state !== machineStates.idle && machine.state !== machineStates.cancelled const canStartUpload = isValidForUpload(primaryFile, primaryErrors, isArchive, screenshotErrors) const canPublish = useMemo(() => { return uploadReady && metadata.rightsAccepted && machine.state !== machineStates.publishing }, [uploadReady, metadata.rightsAccepted, machine.state]) const fileSelectionLocked = isUploadLocked useEffect(() => { if (uploadReady && activeStep === 1 && !hasAutoAdvancedRef.current) { hasAutoAdvancedRef.current = true setIsUploadLocked(true) setActiveStep(2) } }, [uploadReady, activeStep]) useEffect(() => { if (uploadReady) { setIsUploadLocked(true) } }, [uploadReady]) useEffect(() => { if (!stepContentRef.current) return if (typeof stepContentRef.current.scrollIntoView === 'function') { stepContentRef.current.scrollIntoView({ behavior: prefersReducedMotion ? 'auto' : 'smooth', block: 'start' }) } window.setTimeout(() => { if (stepHeadingRef.current && typeof stepHeadingRef.current.focus === 'function') { stepHeadingRef.current.focus({ preventScroll: true }) } }, 0) }, [activeStep, prefersReducedMotion]) useEffect(() => { return () => { if (primaryPreviewUrl) URL.revokeObjectURL(primaryPreviewUrl) requestControllersRef.current.forEach((controller) => controller.abort()) requestControllersRef.current.clear() if (pollingTimerRef.current) { window.clearInterval(pollingTimerRef.current) pollingTimerRef.current = null } } }, [primaryPreviewUrl]) 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((controller) => controller.abort()) requestControllersRef.current.clear() }, []) const clearPolling = useCallback(() => { if (pollingTimerRef.current) { window.clearInterval(pollingTimerRef.current) pollingTimerRef.current = null } }, []) useEffect(() => { let cancelled = false primaryValidationRunRef.current += 1 const runId = primaryValidationRunRef.current ;(async () => { const result = await validatePrimaryFile(primaryFile) if (cancelled || runId !== primaryValidationRunRef.current) return setPrimaryType(result.type) setPrimaryWarnings(result.warnings) setPrimaryErrors(result.errors) setFileMetadata(result.metadata) setPrimaryPreviewUrl((current) => { if (current) URL.revokeObjectURL(current) return result.previewUrl }) })() return () => { cancelled = true } }, [primaryFile]) useEffect(() => { let cancelled = false screenshotValidationRunRef.current += 1 const runId = screenshotValidationRunRef.current ;(async () => { const result = await validateScreenshots(screenshots, isArchive) if (cancelled || runId !== screenshotValidationRunRef.current) return setScreenshotErrors(result.errors) setScreenshotPerFileErrors(result.perFileErrors) })() return () => { cancelled = true } }, [screenshots, isArchive]) useEffect(() => { if (!isArchive) { setScreenshots([]) setScreenshotErrors([]) setScreenshotPerFileErrors([]) } }, [isArchive]) const validationErrors = useMemo(() => [...primaryErrors, ...screenshotErrors], [primaryErrors, screenshotErrors]) useEffect(() => { if (typeof onValidationStateChange === 'function') { onValidationStateChange({ isValid: canStartUpload, validationErrors, isArchive, }) } }, [canStartUpload, validationErrors, isArchive, onValidationStateChange]) 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 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 { let artworkIdForUpload = resolvedArtworkId 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) setResolvedArtworkId(artworkIdForUpload) } 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' }) 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 }) } 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' }) } 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, resolvedArtworkId, metadata, registerController, unregisterController, clearPolling, effectiveChunkSize]) const pollProcessing = useCallback(async () => { if (!machine.sessionId) return try { const statusController = registerController() const payload = await fetchProcessingStatus(machine.sessionId, machine.uploadToken, statusController.signal) unregisterController(statusController) const processingValue = getProcessingValue(payload) dispatchMachine({ type: 'PROCESSING_STATUS', processingStatus: processingValue }) if (isReadyToPublishStatus(processingValue)) { dispatchMachine({ type: 'READY_TO_PUBLISH' }) } 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 }) } } 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 }) } }, [machine.sessionId, machine.uploadToken, fetchProcessingStatus, registerController, unregisterController]) useEffect(() => { if (machine.state !== machineStates.processing) { clearPolling() return } pollProcessing() clearPolling() pollingTimerRef.current = window.setInterval(() => { pollProcessing() }, POLL_INTERVAL_MS) return () => { clearPolling() } }, [machine.state, pollProcessing, clearPolling]) const handleCancel = useCallback(async () => { dispatchMachine({ type: 'CANCEL_START' }) clearPolling() abortAllRequests() try { if (machine.sessionId) { await window.axios.post(uploadEndpoints.cancel(), { session_id: machine.sessionId, upload_token: machine.uploadToken, }, { headers: machine.uploadToken ? { 'X-Upload-Token': machine.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.sessionId, machine.uploadToken, abortAllRequests, clearPolling]) const handlePublish = useCallback(async () => { if (!canPublish || publishLockRef.current) return publishLockRef.current = true dispatchMachine({ type: 'PUBLISH_START' }) try { const publishTargetId = machine.sessionId || initialDraftId || resolvedArtworkId if (!machine.sessionId) { if (!publishTargetId) throw new Error('Missing publish id.') const publishController = registerController() await window.axios.post(uploadEndpoints.publish(publishTargetId), {}, { 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 } }, [canPublish, machine.sessionId, initialDraftId, resolvedArtworkId, registerController, unregisterController]) const handleReset = useCallback(() => { clearPolling() abortAllRequests() setPrimaryFile(null) setScreenshots([]) setMetadata({ title: '', rootCategoryId: '', subCategoryId: '', tags: [], description: '', rightsAccepted: false, contentType: '', }) setResolvedArtworkId(() => { const parsed = Number(initialDraftId) return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : null }) setIsUploadLocked(false) hasAutoAdvancedRef.current = false setActiveStep(1) dispatchMachine({ type: 'RESET_MACHINE' }) }, [abortAllRequests, clearPolling]) const handleRetry = useCallback(() => { clearPolling() abortAllRequests() if (machine.lastAction === 'publish') { handlePublish() return } runUploadFlow() }, [machine.lastAction, handlePublish, runUploadFlow, clearPolling, abortAllRequests]) const goToStep = useCallback((step) => { if (step < 1 || step > highestUnlockedStep) return setActiveStep(step) }, [highestUnlockedStep]) const statusBadges = [ { label: 'Scan OK', ok: uploadReady }, { label: 'Preview OK', ok: Boolean(primaryPreviewUrl) || (isArchive && screenshots.length > 0) }, { label: 'AI tags OK', ok: uploadReady }, ] const renderStepContent = () => { if (machine.state === machineStates.complete) { return ( Your artwork is live 🎉

You can view it now or upload another item.

View artwork
) } if (activeStep === 1) { return (

Upload your artwork

Select your file, satisfy requirements, and complete secure upload processing.

{fileSelectionLocked && (
File is locked after upload. Reset to change.
)}
0} errors={primaryErrors} showLooksGood={Boolean(primaryFile) && primaryErrors.length === 0} looksGoodText="Looks good" locked={fileSelectionLocked} onPrimaryFileChange={(file) => { if (fileSelectionLocked) return setPrimaryFile(file || null) }} /> 0} showLooksGood={isArchive && screenshots.length > 0 && screenshotErrors.length === 0} looksGoodText="Looks good" onFilesChange={(nextFiles) => setScreenshots(nextFiles)} /> {showProgress && ( )}
) } if (activeStep === 2) { return (

Add details

Complete required metadata and rights confirmation before publishing.

Uploaded asset

{primaryPreviewUrl && !isArchive ? ( Uploaded artwork thumbnail ) : (
📦
)}

{primaryFile?.name || 'Primary file selected'}

{isArchive ? `${screenshots.length} screenshot(s)` : fileMetadata.resolution}

Content type, category & subcategory
Select in order: type → category → subcategory (when available).
Type
{Array.isArray(contentTypes) && contentTypes.length > 0 ? (
{contentTypes.map((ct) => { const typeValue = getContentTypeValue(ct) const active = String(typeValue) === String(metadata.contentType || '') const visualKey = getContentTypeVisualKey(ct) const iconPath = `/gfx/mascot_${visualKey}.webp` return ( ) })}
) : (
No content types available.
)} {metadataErrors.contentType &&

{metadataErrors.contentType}

}
Category
{!metadata.contentType ? (
Choose a content type to load categories.
) : filteredCategoryTree.length === 0 ? (
No categories available for this content type.
) : (
{filteredCategoryTree.map((root) => { const active = String(root.id) === String(metadata.rootCategoryId || '') return ( ) })}
)}
{requiresSubCategory && (
Subcategory
{selectedRootCategory.children.map((sub) => { const active = String(sub.id) === String(metadata.subCategoryId || '') return ( ) })}
)} {metadataErrors.category &&

{metadataErrors.category}

}
setMetadata((current) => ({ ...current, title: value }))} onChangeTags={(value) => setMetadata((current) => ({ ...current, tags: value }))} onChangeDescription={(value) => setMetadata((current) => ({ ...current, description: value }))} onToggleRights={(value) => setMetadata((current) => ({ ...current, rightsAccepted: Boolean(value) }))} />
) } return (

Review & publish

Review your submission and publish when all checks are ready.

{primaryPreviewUrl && !isArchive ? ( Review preview ) : (
Archive
)}

{metadata.title || 'Untitled artwork'}

Category: {metadata.rootCategoryId || '—'} / {metadata.subCategoryId || '—'}

Tags: {(metadata.tags || []).length}

{metadata.rightsAccepted ? '✓ Rights confirmed' : 'Rights not confirmed'} {canPublish ? 'Ready to publish' : 'Waiting for requirements'}
{metadata.description &&

{metadata.description}

}
{statusBadges.map((badge) => ( {badge.ok ? '✓' : '•'} {badge.label} ))}
) } return (
{showRestoredBanner && (
Draft restored. Continue from your previous upload session.
)}
{renderStepContent()} 1} canReset={Boolean(primaryFile || screenshots.length || metadata.title || metadata.description || metadata.tags.length)} canCancel={activeStep === 1 && [machineStates.initializing, machineStates.uploading, machineStates.finishing, machineStates.processing].includes(machine.state)} canRetry={machine.state === machineStates.error} isUploading={machine.state === machineStates.uploading || machine.state === machineStates.initializing} isProcessing={machine.state === machineStates.processing || machine.state === machineStates.finishing} isPublishing={machine.state === machineStates.publishing} isCancelling={machine.isCancelling} disableReason={activeStep === 1 ? (validationErrors[0] || machine.error || 'Complete upload requirements first.') : activeStep === 2 ? (metadataErrors.title || metadataErrors.contentType || metadataErrors.category || metadataErrors.rights || 'Complete required metadata.') : (machine.error || 'Publish is available when upload is ready and rights are confirmed.')} onStart={runUploadFlow} onContinue={() => detailsValid && setActiveStep(3)} onPublish={handlePublish} onBack={() => setActiveStep((current) => Math.max(1, current - 1))} onCancel={handleCancel} onReset={handleReset} onRetry={handleRetry} onSaveDraft={() => {}} showSaveDraft={activeStep === 2} resetLabel={fileSelectionLocked ? 'Reset upload' : 'Reset'} mobileSticky />
) } function isValidForUpload(primaryFile, primaryErrors, isArchive, screenshotErrors) { if (!primaryFile) return false if (primaryErrors.length > 0) return false if (isArchive && screenshotErrors.length > 0) return false return true }