1200 lines
48 KiB
JavaScript
1200 lines
48 KiB
JavaScript
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 (
|
||
<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={suggestedTags}
|
||
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) }))}
|
||
/>
|
||
</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
|
||
}
|