Files
SkinbaseNova/resources/js/components/upload/UploadWizard.jsx

1323 lines
53 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 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 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 (!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 (
<motion.div
initial={prefersReducedMotion ? false : { opacity: 0, y: 6 }}
animate={prefersReducedMotion ? {} : { opacity: 1, y: 0 }}
transition={quickTransition}
className="rounded-2xl border border-emerald-300/30 bg-emerald-500/8 p-7 text-emerald-100"
>
<motion.div
initial={prefersReducedMotion ? false : { scale: 0.8, opacity: 0 }}
animate={prefersReducedMotion ? {} : { scale: 1, opacity: 1 }}
transition={prefersReducedMotion ? { duration: 0 } : { duration: 0.24, ease: 'easeOut' }}
className="inline-flex items-center gap-2 rounded-full border border-emerald-300/45 bg-emerald-500/15 px-3 py-1 text-xs font-medium"
>
<span aria-hidden="true"></span>
<span>Your artwork is live 🎉</span>
</motion.div>
<p className="mt-3 text-sm text-emerald-100/90">You can view it now or upload another item.</p>
<div className="mt-4 flex flex-wrap gap-2">
<a href={resolvedArtworkId ? `/artwork/${resolvedArtworkId}` : '/'} className="rounded-lg border border-emerald-300/45 bg-emerald-400/20 px-3 py-2 text-sm font-medium text-emerald-50 hover:bg-emerald-400/30">View artwork</a>
<button type="button" onClick={handleReset} className="rounded-lg border border-white/25 bg-white/10 px-3 py-2 text-sm text-white hover:bg-white/20">Upload another</button>
</div>
</motion.div>
)
}
if (activeStep === 1) {
return (
<div className="max-w-4xl mx-auto px-4 py-6">
<div className="bg-panel/80 backdrop-blur rounded-2xl shadow-xl shadow-black/40 ring-1 ring-white/10 p-6 space-y-5">
<div className="rounded-xl bg-white/5 px-4 py-3 ring-1 ring-white/10">
<h2 ref={stepHeadingRef} tabIndex={-1} className="text-lg font-semibold text-white focus:outline-none">Upload your artwork</h2>
<p className="mt-1 text-sm text-white/65">Select your file, satisfy requirements, and complete secure upload processing.</p>
</div>
{fileSelectionLocked && (
<div className="rounded-lg bg-amber-500/10 px-3 py-2 text-xs text-amber-100 ring-1 ring-amber-300/35">
File is locked after upload. Reset to change.
</div>
)}
<div className="space-y-5">
<UploadDropzone
title="Upload your artwork file"
description="Drag and drop or click to browse. Validation runs immediately before upload starts."
fileName={primaryFile?.name || ''}
previewUrl={primaryPreviewUrl}
fileMeta={fileMetadata}
fileHint="Awaiting file selection"
invalid={primaryErrors.length > 0}
errors={primaryErrors}
showLooksGood={Boolean(primaryFile) && primaryErrors.length === 0}
looksGoodText="Looks good"
locked={fileSelectionLocked}
onPrimaryFileChange={(file) => {
if (fileSelectionLocked) return
setPrimaryFile(file || null)
}}
/>
<ScreenshotUploader
title="Archive screenshots"
description="We need at least 1 screenshot to generate thumbnails and analyze content."
visible={isArchive}
files={screenshots}
min={1}
max={5}
perFileErrors={screenshotPerFileErrors}
errors={screenshotErrors}
invalid={isArchive && screenshotErrors.length > 0}
showLooksGood={isArchive && screenshots.length > 0 && screenshotErrors.length === 0}
looksGoodText="Looks good"
onFilesChange={(nextFiles) => setScreenshots(nextFiles)}
/>
{showProgress && (
<UploadProgress
title="Upload progress"
description="Upload and processing status"
status={machine.state === machineStates.ready_to_publish ? 'Ready' : machine.state === machineStates.uploading ? 'Uploading' : machine.state === machineStates.processing ? 'Processing' : 'Idle'}
progress={machine.progress}
state={machine.state}
processingStatus={machine.processingStatus}
isCancelling={machine.isCancelling}
error={machine.error}
processingLabel={processingTransparencyLabel}
onRetry={handleRetry}
onReset={handleReset}
/>
)}
</div>
</div>
</div>
)
}
if (activeStep === 2) {
return (
<div className="max-w-4xl mx-auto px-4 py-6">
<div className="bg-panel/80 backdrop-blur rounded-2xl shadow-xl shadow-black/40 ring-1 ring-white/10 p-6 space-y-5">
<div className="rounded-xl bg-white/5 px-4 py-3 ring-1 ring-white/10">
<h2 ref={stepHeadingRef} tabIndex={-1} className="text-lg font-semibold text-white focus:outline-none">Add details</h2>
<p className="mt-1 text-sm text-white/65">Complete required metadata and rights confirmation before publishing.</p>
</div>
<div className="mb-6 rounded-xl border border-white/8 bg-white/4 p-4">
<p className="text-xs uppercase tracking-wide text-white/55">Uploaded asset</p>
<div className="mt-2 flex items-center gap-3">
{primaryPreviewUrl && !isArchive ? (
<img src={primaryPreviewUrl} alt="Uploaded artwork thumbnail" className="h-20 w-20 rounded-lg border border-white/50 object-cover" />
) : (
<div className="grid h-20 w-20 place-items-center rounded-lg border border-white/15 bg-white/5 text-white/60">📦</div>
)}
<div className="min-w-0">
<p className="truncate text-sm font-medium text-white">{primaryFile?.name || 'Primary file selected'}</p>
<p className="text-xs text-white/60">{isArchive ? `${screenshots.length} screenshot(s)` : fileMetadata.resolution}</p>
</div>
</div>
</div>
<div className="rounded-2xl border border-white/10 bg-gradient-to-br from-white/5 via-white/0 to-white/5 p-4 sm:p-5">
<div className="flex flex-wrap items-center justify-between gap-2">
<div>
<div className="text-sm font-semibold text-white">Content type, category & subcategory</div>
<div className="text-xs text-white/60">Select in order: type category subcategory (when available).</div>
</div>
<span className="rounded-full border border-sky-400/40 bg-sky-400/10 px-3 py-1 text-[11px] uppercase tracking-[0.2em] text-sky-200">Type</span>
</div>
{Array.isArray(contentTypes) && contentTypes.length > 0 ? (
<div className="mt-4">
<div className="flex gap-4 overflow-x-auto py-2 no-scrollbar">
{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 (
<button
key={typeValue || ct.name}
type="button"
onClick={() => {
setMetadata((current) => ({
...current,
contentType: String(typeValue),
rootCategoryId: '',
subCategoryId: '',
}))
}}
className={`group flex flex-col items-center gap-2 min-w-[104px] rounded-xl border px-3 py-2 transition transform ${active
? 'border-emerald-400/70 bg-emerald-400/15 shadow-lg shadow-emerald-900/30 scale-105'
: 'border-white/10 bg-white/[0.03] hover:border-white/25 hover:bg-white/[0.06] hover:-translate-y-1 hover:scale-105'} focus:outline-none focus:ring-2 focus:ring-emerald-400/30`}
aria-pressed={active}
>
<div className="h-20 w-20 rounded-full flex items-center justify-center overflow-hidden transform transition-transform duration-150 scale-110">
<img
src={iconPath}
alt={`${ct.name || 'Content type'} mascot`}
className={`h-full w-full object-contain transition-all duration-150 ${active ? 'grayscale-0 opacity-100' : 'grayscale opacity-40 group-hover:grayscale-0 group-hover:opacity-90'}`}
onError={(event) => {
if (event.currentTarget.src.includes('mascot_other.webp')) return
event.currentTarget.src = '/gfx/mascot_other.webp'
}}
/>
</div>
<div className={`text-sm font-semibold ${active ? 'text-white' : 'text-white/65'}`}>{ct.name || 'Type'}</div>
</button>
)
})}
</div>
</div>
) : (
<div className="mt-4 rounded-xl border border-white/10 bg-white/5 px-4 py-3 text-sm text-white/60">
No content types available.
</div>
)}
{metadataErrors.contentType && <p className="mt-2 text-xs text-red-200">{metadataErrors.contentType}</p>}
<div className="mt-5 rounded-xl border border-white/10 bg-white/5 p-4">
<div className="text-xs uppercase tracking-wide text-white/60">Category</div>
{!metadata.contentType ? (
<div className="mt-2 text-sm text-white/60">Choose a content type to load categories.</div>
) : filteredCategoryTree.length === 0 ? (
<div className="mt-2 text-sm text-white/60">No categories available for this content type.</div>
) : (
<div className="mt-3 flex flex-wrap gap-2">
{filteredCategoryTree.map((root) => {
const active = String(root.id) === String(metadata.rootCategoryId || '')
return (
<button
key={root.id}
type="button"
onClick={() => {
setMetadata((current) => ({
...current,
rootCategoryId: String(root.id),
subCategoryId: '',
}))
}}
className={`rounded-full border px-4 py-2 text-sm transition ${active
? 'border-purple-600/90 bg-purple-700/35 text-white shadow-lg'
: 'border-white/10 bg-white/5 text-white/70 hover:border-purple-300/50 hover:bg-purple-400/10'}`}
aria-pressed={active}
>
{root.name}
</button>
)
})}
</div>
)}
</div>
{requiresSubCategory && (
<div className="mt-4 rounded-xl border border-white/10 bg-white/5 p-4">
<div className="text-xs uppercase tracking-wide text-white/60">Subcategory</div>
<div className="mt-3 flex flex-wrap gap-2">
{selectedRootCategory.children.map((sub) => {
const active = String(sub.id) === String(metadata.subCategoryId || '')
return (
<button
key={sub.id}
type="button"
onClick={() => {
setMetadata((current) => ({
...current,
subCategoryId: String(sub.id),
}))
}}
className={`rounded-full border px-4 py-2 text-sm transition ${active
? 'border-cyan-600/90 bg-cyan-700/35 text-white shadow-lg'
: 'border-white/10 bg-white/5 text-white/70 hover:border-cyan-300/50 hover:bg-cyan-400/10'}`}
aria-pressed={active}
>
{sub.name}
</button>
)
})}
</div>
</div>
)}
{metadataErrors.category && <p className="mt-2 text-xs text-red-200">{metadataErrors.category}</p>}
</div>
<UploadSidebar
showHeader={false}
metadata={metadata}
suggestedTags={mergedSuggestedTags}
errors={metadataErrors}
onChangeTitle={(value) => 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) }))}
/>
{resolvedArtworkId ? (
<div className="rounded-xl border border-white/10 bg-white/[0.03] p-3 text-xs text-white/70">
<div className="font-medium text-white/85">Vision debug</div>
<div className="mt-1">
enabled: {String(visionDebug.enabled)} · queue: {visionDebug.queueConnection || 'n/a'} · ai tags: {visionDebug.aiTagCount} · total tags: {visionDebug.totalTagCount}
</div>
<div className="mt-1">
queued jobs: {visionDebug.queuedJobs} · failed jobs: {visionDebug.failedJobs} · trigger sent: {String(visionDebug.triggered)}
</div>
{visionDebug.lastError ? <div className="mt-1 text-red-300">error: {visionDebug.lastError}</div> : null}
</div>
) : null}
</div>
</div>
)
}
return (
<div className="rounded-2xl border border-white/8 bg-slate-900/70 p-6 sm:p-7">
<div className="mb-6 rounded-xl border border-white/50 bg-white/5 px-4 py-3">
<h2 ref={stepHeadingRef} tabIndex={-1} className="text-lg font-semibold text-white focus:outline-none">Review & publish</h2>
<p className="mt-1 text-sm text-white/65">Review your submission and publish when all checks are ready.</p>
</div>
<div className="rounded-xl border border-white/8 bg-white/4 p-5">
<div className="flex flex-col gap-4 sm:flex-row">
<div className="w-full sm:w-40">
{primaryPreviewUrl && !isArchive ? (
<img src={primaryPreviewUrl} alt="Review preview" className="h-32 w-full rounded-lg border border-white/50 object-cover" />
) : (
<div className="grid h-32 w-full place-items-center rounded-lg border border-white/50 bg-black/25 text-white/60">Archive</div>
)}
</div>
<div className="min-w-0 flex-1 space-y-2">
<p className="text-base font-semibold text-white">{metadata.title || 'Untitled artwork'}</p>
<p className="text-sm text-white/75">
Category: {metadata.rootCategoryId || '—'} / {metadata.subCategoryId || '—'}
</p>
<p className="text-sm text-white/75">Tags: {(metadata.tags || []).length}</p>
<div className="flex flex-wrap gap-2">
<span className={`rounded-full border px-2.5 py-1 text-xs ${metadata.rightsAccepted ? 'border-emerald-300/45 bg-emerald-500/15 text-emerald-100' : 'border-white/20 bg-white/5 text-white/70'}`}>
{metadata.rightsAccepted ? '✓ Rights confirmed' : 'Rights not confirmed'}
</span>
<span className={`rounded-full border px-2.5 py-1 text-xs ${canPublish ? 'border-emerald-300/45 bg-emerald-500/15 text-emerald-100' : 'border-white/20 bg-white/5 text-white/70'}`}>
{canPublish ? 'Ready to publish' : 'Waiting for requirements'}
</span>
</div>
{metadata.description && <p className="line-clamp-3 text-sm text-white/70">{metadata.description}</p>}
</div>
</div>
<div className="mt-4 flex flex-wrap gap-2">
{statusBadges.map((badge) => (
<span key={badge.label} className={`rounded-full border px-2.5 py-1 text-xs ${badge.ok ? 'border-emerald-300/45 bg-emerald-500/15 text-emerald-100' : 'border-white/20 bg-white/5 text-white/60'}`}>
{badge.ok ? '✓' : '•'} {badge.label}
</span>
))}
</div>
</div>
</div>
)
}
return (
<section ref={stepContentRef} className="space-y-8 text-white" data-is-archive={isArchive ? 'true' : 'false'}>
{showRestoredBanner && (
<div className="rounded-xl border border-sky-300/30 bg-sky-500/10 px-4 py-2 text-sm text-sky-100">
<div className="flex items-center justify-between gap-3">
<span>Draft restored. Continue from your previous upload session.</span>
<button
type="button"
onClick={() => setShowRestoredBanner(false)}
className="rounded-md border border-sky-200/40 bg-sky-500/15 px-2 py-1 text-xs text-sky-100 hover:bg-sky-500/25 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-300/70"
>
Dismiss
</button>
</div>
</div>
)}
<UploadStepper
steps={wizardSteps}
activeStep={activeStep}
highestUnlockedStep={highestUnlockedStep}
onStepClick={goToStep}
/>
<div className="-mt-4 rounded-full bg-white/8 p-0.5">
<motion.div
className="h-1.5 rounded-full bg-gradient-to-r from-sky-400/90 via-cyan-300/90 to-emerald-300/90"
animate={{ width: `${stepProgressPercent}%` }}
transition={prefersReducedMotion ? { duration: 0 } : { duration: 0.24, ease: 'easeOut' }}
/>
</div>
<AnimatePresence mode="wait" initial={false}>
<motion.div
key={`wizard-step-${activeStep}`}
initial={prefersReducedMotion ? false : { opacity: 0, y: 6 }}
animate={prefersReducedMotion ? {} : { opacity: 1, y: 0 }}
exit={prefersReducedMotion ? {} : { opacity: 0, y: -6 }}
transition={quickTransition}
>
{renderStepContent()}
</motion.div>
</AnimatePresence>
<UploadActions
step={activeStep}
canStart={canStartUpload && [machineStates.idle, machineStates.error, machineStates.cancelled].includes(machine.state)}
canContinue={detailsValid}
canPublish={canPublish}
canGoBack={activeStep > 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
/>
</section>
)
}
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
}