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 [visionSuggestedTags, setVisionSuggestedTags] = useState([])
const [visionDebug, setVisionDebug] = useState({
enabled: null,
queueConnection: '',
queuedJobs: 0,
failedJobs: 0,
triggered: false,
aiTagCount: 0,
totalTagCount: 0,
lastError: '',
})
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 lastVisionFetchAtRef = useRef(0)
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 allRootCategoryOptions = useMemo(() => {
const items = []
Object.entries(categoryTreeByType).forEach(([contentTypeValue, roots]) => {
roots.forEach((root) => {
items.push({
...root,
contentTypeValue,
})
})
})
return items
}, [categoryTreeByType])
const selectedRootFromAnyType = useMemo(() => {
const selectedId = String(metadata.rootCategoryId || '')
if (!selectedId) return null
return allRootCategoryOptions.find((root) => String(root.id) === selectedId) || null
}, [allRootCategoryOptions, metadata.rootCategoryId])
const selectedRootCategory = useMemo(() => {
return filteredCategoryTree.find((root) => String(root.id) === String(metadata.rootCategoryId || '')) || selectedRootFromAnyType || null
}, [filteredCategoryTree, metadata.rootCategoryId, selectedRootFromAnyType])
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 mergedSuggestedTags = useMemo(() => {
const normalized = new Map()
const addTag = (item) => {
if (!item) return
const key = String(item?.slug || item?.tag || item?.name || item).trim().toLowerCase()
if (!key) return
if (!normalized.has(key)) {
if (typeof item === 'string') {
normalized.set(key, item)
} else {
normalized.set(key, {
id: item.id ?? key,
name: item.name || item.tag || item.slug || key,
slug: item.slug || item.tag || key,
usage_count: Number(item.usage_count || 0),
is_ai: Boolean(item.is_ai || item.source === 'ai'),
source: item.source || (item.is_ai ? 'ai' : 'manual'),
})
}
}
}
;(Array.isArray(suggestedTags) ? suggestedTags : []).forEach(addTag)
;(Array.isArray(visionSuggestedTags) ? visionSuggestedTags : []).forEach(addTag)
return Array.from(normalized.values())
}, [suggestedTags, visionSuggestedTags])
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])
const fetchVisionSuggestedTags = useCallback(async () => {
if (!resolvedArtworkId || resolvedArtworkId <= 0) return
const now = Date.now()
if (now - lastVisionFetchAtRef.current < 3000) return
lastVisionFetchAtRef.current = now
try {
const response = await window.axios.get(`/api/artworks/${resolvedArtworkId}/tags`, {
params: { trigger: 1 },
})
const payload = response?.data || {}
if (payload?.vision_enabled === false) {
setVisionSuggestedTags([])
setVisionDebug((current) => ({
...current,
enabled: false,
lastError: '',
}))
return
}
const aiTags = Array.isArray(payload?.ai_tags) ? payload.ai_tags : []
setVisionSuggestedTags(aiTags)
const debug = payload?.debug || {}
setVisionDebug({
enabled: Boolean(payload?.vision_enabled),
queueConnection: String(debug?.queue_connection || ''),
queuedJobs: Number(debug?.queued_jobs || 0),
failedJobs: Number(debug?.failed_jobs || 0),
triggered: Boolean(debug?.triggered),
aiTagCount: Number(debug?.ai_tag_count || aiTags.length || 0),
totalTagCount: Number(debug?.total_tag_count || 0),
lastError: '',
})
if (typeof window !== 'undefined') {
window.console?.debug?.('[upload][vision-tags]', {
artworkId: resolvedArtworkId,
aiTags: aiTags.map((tag) => tag?.slug || tag?.name || tag),
debug,
})
}
} catch (error) {
if (error?.response?.status === 404 || error?.response?.status === 403) return
setVisionDebug((current) => ({
...current,
lastError: error?.response?.data?.message || error?.message || 'Vision tag fetch failed.',
}))
}
}, [resolvedArtworkId])
useEffect(() => {
if (!resolvedArtworkId || activeStep < 2) return
fetchVisionSuggestedTags()
const timer = window.setInterval(() => {
fetchVisionSuggestedTags()
}, 4000)
return () => window.clearInterval(timer)
}, [resolvedArtworkId, activeStep, fetchVisionSuggestedTags])
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 = resolvedArtworkId || initialDraftId || machine.sessionId
const publishPayload = {
title: String(metadata.title || '').trim() || undefined,
description: String(metadata.description || '').trim() || null,
}
if (resolvedArtworkId && resolvedArtworkId > 0) {
dispatchMachine({ type: 'PUBLISH_SUCCESS' })
emitUploadEvent('upload_publish', { id: publishTargetId })
return
}
if (!machine.sessionId) {
if (!publishTargetId) throw new Error('Missing publish id.')
const publishController = registerController()
await window.axios.post(uploadEndpoints.publish(publishTargetId), publishPayload, { signal: publishController.signal })
unregisterController(publishController)
} else {
if (!publishTargetId) throw new Error('Missing publish id.')
const publishController = registerController()
await window.axios.post(uploadEndpoints.publish(publishTargetId), publishPayload, { 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, metadata.title, metadata.description, registerController, unregisterController])
const handleReset = useCallback(() => {
clearPolling()
abortAllRequests()
setPrimaryFile(null)
setScreenshots([])
setMetadata({
title: '',
rootCategoryId: '',
subCategoryId: '',
tags: [],
description: '',
rightsAccepted: false,
contentType: '',
})
setVisionSuggestedTags([])
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 (
You can view it now or upload another item.
Select your file, satisfy requirements, and complete secure upload processing.
Complete required metadata and rights confirmation before publishing.
Uploaded asset
{primaryFile?.name || 'Primary file selected'}
{isArchive ? `${screenshots.length} screenshot(s)` : fileMetadata.resolution}
{metadataErrors.contentType}
}{metadataErrors.category}
}Review your submission and publish when all checks are ready.
{metadata.title || 'Untitled artwork'}
Category: {metadata.rootCategoryId || '—'} / {metadata.subCategoryId || '—'}
Tags: {(metadata.tags || []).length}
{metadata.description}
}