import React, { useCallback, useEffect, useMemo, useReducer, useRef, useState } from 'react' import { usePage } from '@inertiajs/react' import TagInput from '../../components/tags/TagInput' import UploadWizard from '../../components/upload/UploadWizard' import Checkbox from '../../Components/ui/Checkbox' import { mapUploadErrorNotice, mapUploadResultNotice } from '../../lib/uploadNotices' const phases = { idle: 'idle', initializing: 'initializing', ready: 'ready', uploading: 'uploading', finishing: 'finishing', polling: 'polling', cancelling: 'cancelling', success: 'success', error: 'error', } const initialState = { phase: phases.idle, sessionId: null, uploadToken: null, file: null, filePreviewUrl: null, previewUrl: null, progress: 0, pipelineStatus: null, failureReason: null, error: null, cancelledAt: null, notices: [], artworkId: null, draftId: null, metadata: { title: '', type: '', category: '', tags: '', description: '', licenseAccepted: false, }, } const STORAGE_VERSION = 1 function storageKey(userId) { return `sb.upload.session.${userId}.v${STORAGE_VERSION}` } function readStoredSession(userId) { try { const raw = window.localStorage.getItem(storageKey(userId)) if (!raw) return null const parsed = JSON.parse(raw) if (!parsed || typeof parsed !== 'object') return null return parsed } catch (error) { return null } } function writeStoredSession(userId, payload) { try { window.localStorage.setItem(storageKey(userId), JSON.stringify(payload)) } catch (error) { // ignore write failures } } function clearStoredSession(userId) { try { window.localStorage.removeItem(storageKey(userId)) } catch (error) { // ignore removal failures } } function reducer(state, action) { switch (action.type) { case 'SET_DRAFT': return { ...state, draftId: action.draftId, artworkId: action.artworkId ?? state.artworkId } case 'SET_FILE': return { ...state, file: action.file, filePreviewUrl: action.previewUrl, error: null, cancelledAt: null } case 'SET_METADATA': return { ...state, metadata: { ...state.metadata, ...action.payload } } case 'INIT_START': return { ...state, phase: phases.initializing, error: null, cancelledAt: null } case 'INIT_SUCCESS': return { ...state, phase: phases.ready, sessionId: action.sessionId, uploadToken: action.uploadToken, pipelineStatus: action.status } case 'RESTORE_SESSION': return { ...state, phase: phases.ready, sessionId: action.sessionId, uploadToken: action.uploadToken, pipelineStatus: action.status ?? state.pipelineStatus } case 'RESUME_SESSION': return { ...state, phase: phases.polling, sessionId: action.sessionId, pipelineStatus: action.status ?? state.pipelineStatus } case 'INIT_ERROR': return { ...state, phase: phases.error, error: action.error } case 'UPLOAD_START': return { ...state, phase: phases.uploading, progress: 0, error: null } case 'UPLOAD_PROGRESS': return { ...state, progress: action.progress } case 'UPLOAD_ERROR': return { ...state, phase: phases.error, error: action.error } case 'FINISH_START': return { ...state, phase: phases.finishing, error: null } case 'FINISH_SUCCESS': return { ...state, phase: phases.polling, pipelineStatus: action.status, previewUrl: action.previewUrl ?? null } case 'FINISH_ERROR': return { ...state, phase: phases.error, error: action.error } case 'CANCEL_START': return { ...state, phase: phases.cancelling, error: null } case 'CANCEL_SUCCESS': return { ...state, phase: phases.idle, cancelledAt: Date.now() } case 'CLEAR_CANCELLED': return { ...state, cancelledAt: null } case 'CANCEL_ERROR': return { ...state, phase: phases.error, error: action.error } case 'STATUS_UPDATE': return { ...state, pipelineStatus: action.status, progress: action.progress ?? state.progress, failureReason: action.failureReason ?? null } case 'STATUS_ERROR': return { ...state, phase: phases.error, error: action.error } case 'SUCCESS': return { ...state, phase: phases.success } case 'RESET': return { ...initialState, draftId: state.draftId, cancelledAt: state.cancelledAt } case 'PUSH_NOTICE': return { ...state, notices: [...state.notices, action.notice] } case 'REMOVE_NOTICE': return { ...state, notices: state.notices.filter((notice) => notice.id !== action.id) } default: return state } } const MAX_CHUNK_RETRIES = 3 const RETRY_DELAY_MS = 900 function normalizeUiTag(rawTag) { const raw = String(rawTag ?? '').trim().toLowerCase() if (!raw) return '' return raw .replace(/\s+/g, '-') .replace(/[^a-z0-9_-]/g, '') .replace(/-+/g, '-') .replace(/_+/g, '_') .replace(/^[-_]+|[-_]+$/g, '') .slice(0, 32) } function parseUiTags(csvValue) { return String(csvValue ?? '') .split(/[\n,]+/) .map((item) => normalizeUiTag(item)) .filter(Boolean) } function getTypeKey(ct) { if (!ct) return 'default' if (ct.slug && typeof ct.slug === 'string') { return ct.slug.toLowerCase().replace(/\s+/g, '_').replace(/[^a-z0-9_]/g, '') } return String(ct.name || '').toLowerCase().replace(/\s+/g, '_').replace(/[^a-z0-9_]/g, '') } function useUploadMachine({ draftId, filesCdnUrl, chunkSize, userId }) { const [state, dispatch] = useReducer(reducer, { ...initialState, draftId }) const pollRef = useRef(null) const extractErrorMessage = useCallback((error, fallback) => { const message = error?.response?.data?.message if (message && typeof message === 'string') return message const errors = error?.response?.data?.errors if (errors && typeof errors === 'object') { const firstKey = Object.keys(errors)[0] if (firstKey && Array.isArray(errors[firstKey]) && errors[firstKey][0]) { return errors[firstKey][0] } } return fallback }, []) const pushNotice = useCallback((type, message) => { const normalizedType = ['success', 'warning', 'error'].includes(String(type || '').toLowerCase()) ? String(type).toLowerCase() : 'error' const id = `${Date.now()}-${Math.random().toString(16).slice(2)}` dispatch({ type: 'PUSH_NOTICE', notice: { id, type: normalizedType, message } }) window.setTimeout(() => { dispatch({ type: 'REMOVE_NOTICE', id }) }, 4500) }, []) const pushMappedNotice = useCallback((notice) => { if (!notice?.message) return pushNotice(notice.type || 'error', notice.message) }, [pushNotice]) const previewUrl = useMemo(() => { if (state.previewUrl) return state.previewUrl if (!state.filePreviewUrl) return null return state.filePreviewUrl }, [state.previewUrl, state.filePreviewUrl]) const stopPolling = useCallback(() => { if (pollRef.current) { window.clearInterval(pollRef.current) pollRef.current = null } }, []) const startPolling = useCallback(() => { if (!state.sessionId) return stopPolling() pollRef.current = window.setInterval(async () => { try { const res = await window.axios.get(`/api/uploads/status/${state.sessionId}`, { headers: state.uploadToken ? { 'X-Upload-Token': state.uploadToken } : undefined, params: state.uploadToken ? { upload_token: state.uploadToken } : undefined, }) const data = res.data || {} dispatch({ type: 'STATUS_UPDATE', status: data.status, progress: typeof data.progress === 'number' ? data.progress : state.progress, failureReason: data.failure_reason, }) if (data.status === 'processed') { dispatch({ type: 'SUCCESS' }) stopPolling() } if (data.failure_reason) { dispatch({ type: 'STATUS_ERROR', error: data.failure_reason }) stopPolling() } } catch (error) { dispatch({ type: 'STATUS_ERROR', error: 'Status check failed.' }) stopPolling() } }, 2500) }, [state.sessionId, state.uploadToken, state.progress, stopPolling]) useEffect(() => { if (draftId && !state.sessionId) { dispatch({ type: 'RESUME_SESSION', sessionId: draftId, status: 'resuming' }) } }, [draftId, state.sessionId]) useEffect(() => { if (state.phase === phases.polling) { startPolling() } return () => stopPolling() }, [state.phase, startPolling, stopPolling]) useEffect(() => { return () => { if (state.filePreviewUrl) { URL.revokeObjectURL(state.filePreviewUrl) } } }, [state.filePreviewUrl]) const initSession = useCallback(async () => { dispatch({ type: 'INIT_START' }) try { const res = await window.axios.post('/api/uploads/init', { client: 'web' }) const data = res.data || {} dispatch({ type: 'INIT_SUCCESS', sessionId: data.session_id, uploadToken: data.upload_token, status: data.status, }) if (userId && data.session_id && data.upload_token && state.file) { writeStoredSession(userId, { session_id: data.session_id, upload_token: data.upload_token, file_name: state.file.name, file_size: state.file.size, updated_at: Date.now(), }) } return { sessionId: data.session_id, uploadToken: data.upload_token } } catch (error) { const notice = mapUploadErrorNotice(error, 'Failed to initialize upload session.') dispatch({ type: 'INIT_ERROR', error: notice.message }) pushMappedNotice(notice) return null } }, [state.file, userId, pushMappedNotice]) const createDraft = useCallback(async () => { if (state.artworkId) return state.artworkId try { const res = await window.axios.post('/api/artworks', { title: state.metadata.title, category: state.metadata.category, tags: state.metadata.tags, description: state.metadata.description, license: state.metadata.licenseAccepted, }) const data = res.data || {} const artworkId = data.artwork_id ?? data.id if (artworkId) { dispatch({ type: 'SET_DRAFT', draftId: state.draftId, artworkId }) return artworkId } throw new Error('missing_artwork_id') } catch (error) { const notice = mapUploadErrorNotice(error, 'Unable to create draft metadata.') dispatch({ type: 'FINISH_ERROR', error: notice.message }) pushMappedNotice(notice) return null } }, [state.artworkId, state.metadata, state.draftId, pushMappedNotice]) const syncArtworkTags = useCallback(async (artworkId) => { const tags = Array.from(new Set(parseUiTags(state.metadata.tags))) if (tags.length === 0) { return true } try { await window.axios.put(`/api/artworks/${artworkId}/tags`, { tags }) return true } catch (error) { const notice = mapUploadErrorNotice(error, 'Tag sync failed. Upload will continue.') pushMappedNotice({ ...notice, type: 'warning' }) return false } }, [state.metadata.tags, pushMappedNotice]) const fetchStatus = useCallback(async (sessionId, uploadToken) => { const res = await window.axios.get(`/api/uploads/status/${sessionId}`, { headers: uploadToken ? { 'X-Upload-Token': uploadToken } : undefined, params: uploadToken ? { upload_token: uploadToken } : undefined, }) return res.data || {} }, []) const validateStoredSession = useCallback(async (file) => { if (!userId) return null const stored = readStoredSession(userId) if (!stored) return null if (stored.file_name !== file.name || stored.file_size !== file.size) { clearStoredSession(userId) return null } try { const data = await fetchStatus(stored.session_id, stored.upload_token) if (!data || !data.session_id) { clearStoredSession(userId) return null } dispatch({ type: 'RESTORE_SESSION', sessionId: stored.session_id, uploadToken: stored.upload_token, status: data.status }) return { sessionId: stored.session_id, uploadToken: stored.upload_token } } catch (error) { clearStoredSession(userId) return null } }, [fetchStatus, userId]) const uploadChunk = useCallback(async (sessionId, uploadToken, blob, offset, totalSize, attempt) => { const payload = new FormData() payload.append('session_id', sessionId) payload.append('offset', String(offset)) payload.append('chunk_size', String(blob.size)) payload.append('total_size', String(totalSize)) payload.append('chunk', blob) payload.append('upload_token', uploadToken) try { const res = await window.axios.post('/api/uploads/chunk', payload, { headers: uploadToken ? { 'X-Upload-Token': uploadToken } : undefined, }) const data = res.data || {} if (typeof data.progress === 'number') { dispatch({ type: 'UPLOAD_PROGRESS', progress: data.progress }) } return data } catch (error) { if (attempt < MAX_CHUNK_RETRIES) { await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY_MS * (attempt + 1))) return uploadChunk(sessionId, uploadToken, blob, offset, totalSize, attempt + 1) } throw error } }, []) const uploadFile = useCallback(async (sessionId, uploadToken, file) => { dispatch({ type: 'UPLOAD_START' }) let status try { status = await fetchStatus(sessionId, uploadToken) } catch (error) { const notice = mapUploadErrorNotice(error, 'Unable to resume upload.') dispatch({ type: 'UPLOAD_ERROR', error: notice.message }) pushMappedNotice(notice) return false } let offset = Number(status.received_bytes || 0) const totalSize = file.size if (offset > totalSize) offset = 0 while (offset < totalSize) { const nextOffset = Math.min(offset + chunkSize, totalSize) const chunk = file.slice(offset, nextOffset) try { const data = await uploadChunk(sessionId, uploadToken, chunk, offset, totalSize, 0) offset = Number(data.received_bytes ?? nextOffset) if (offset < nextOffset) { offset = nextOffset } } catch (error) { const notice = mapUploadErrorNotice(error, 'File upload failed. Please retry.') dispatch({ type: 'UPLOAD_ERROR', error: notice.message }) pushMappedNotice(notice) return false } } return true }, [chunkSize, fetchStatus, uploadChunk, pushMappedNotice]) const finishUpload = useCallback(async (sessionId, uploadToken, artworkId) => { dispatch({ type: 'FINISH_START' }) try { const res = await window.axios.post( '/api/uploads/finish', { session_id: sessionId, artwork_id: artworkId, upload_token: uploadToken, file_name: String(state.file?.name || ''), }, { headers: { 'X-Upload-Token': uploadToken } } ) const data = res.data || {} const previewPath = data.preview_path const previewUrl = previewPath ? `${filesCdnUrl}/${previewPath}` : null dispatch({ type: 'FINISH_SUCCESS', status: data.status, previewUrl }) const finishNotice = mapUploadResultNotice(data, { fallbackType: String(data.status || '').toLowerCase() === 'queued' ? 'warning' : 'success', fallbackMessage: String(data.status || '').toLowerCase() === 'queued' ? 'Upload received. Processing is queued.' : 'Upload finalized successfully.', }) pushMappedNotice(finishNotice) if (userId) { clearStoredSession(userId) } return true } catch (error) { const notice = mapUploadErrorNotice(error, 'Upload finalization failed.') dispatch({ type: 'FINISH_ERROR', error: notice.message }) pushMappedNotice(notice) return false } }, [filesCdnUrl, userId, pushMappedNotice]) const startUpload = useCallback(async () => { if (!state.file) { const message = 'Please select a file first.' dispatch({ type: 'UPLOAD_ERROR', error: message }) pushNotice('error', message) return } if (!state.metadata.title.trim()) { const message = 'Title is required to start the upload.' dispatch({ type: 'UPLOAD_ERROR', error: message }) pushNotice('error', message) return } if (!state.metadata.type) { const message = 'Please select a Type.' dispatch({ type: 'UPLOAD_ERROR', error: message }) pushNotice('error', message) return } if (!state.metadata.category) { const message = 'Please select a Category.' dispatch({ type: 'UPLOAD_ERROR', error: message }) pushNotice('error', message) return } if (parseUiTags(state.metadata.tags).length === 0) { const message = 'Please add at least one tag.' dispatch({ type: 'UPLOAD_ERROR', error: message }) pushNotice('error', message) return } if (!state.metadata.description.trim()) { const message = 'Please provide a description.' dispatch({ type: 'UPLOAD_ERROR', error: message }) pushNotice('error', message) return } if (!state.metadata.licenseAccepted) { const message = 'You must confirm ownership of the artwork.' dispatch({ type: 'UPLOAD_ERROR', error: message }) pushNotice('error', message) return } let init = null if (userId) { init = await validateStoredSession(state.file) } if (!init) { init = await initSession() } if (!init) return const artworkId = await createDraft() if (!artworkId) return await syncArtworkTags(artworkId) const ok = await uploadFile(init.sessionId, init.uploadToken, state.file) if (!ok) return await finishUpload(init.sessionId, init.uploadToken, artworkId) }, [state.file, state.metadata, initSession, createDraft, syncArtworkTags, uploadFile, finishUpload, validateStoredSession, userId, pushNotice]) const cancelUpload = useCallback(async () => { dispatch({ type: 'CANCEL_START' }) if (!state.sessionId || !state.uploadToken) { if (userId) { clearStoredSession(userId) } dispatch({ type: 'CANCEL_SUCCESS' }) dispatch({ type: 'RESET' }) pushNotice('warning', 'Upload cancelled.') return } try { await window.axios.post( '/api/uploads/cancel', { session_id: state.sessionId, upload_token: state.uploadToken }, { headers: { 'X-Upload-Token': state.uploadToken } } ) } catch (error) { // ignore error, always reset local state to avoid leaking session info } if (userId) { clearStoredSession(userId) } dispatch({ type: 'CANCEL_SUCCESS' }) dispatch({ type: 'RESET' }) pushNotice('warning', 'Upload cancelled.') }, [state.sessionId, state.uploadToken, userId, pushNotice]) return { state, dispatch, previewUrl, startUpload, initSession, cancelUpload, pushNotice, } } export default function UploadPage({ draftId, filesCdnUrl, chunkSize }) { const { props } = usePage() const windowFlags = window?.SKINBASE_FLAGS || {} const propFlagRaw = props?.feature_flags?.uploads_v2 const windowFlagRaw = (windowFlags?.uploads && windowFlags.uploads.v2) ?? windowFlags?.uploads_v2 const toBooleanFlag = (value) => { if (typeof value === 'boolean') return value if (typeof value === 'number') return value === 1 if (typeof value === 'string') { const normalized = value.trim().toLowerCase() return ['1', 'true', 'yes', 'on'].includes(normalized) } return false } const uploadsV2Enabled = toBooleanFlag(propFlagRaw) || toBooleanFlag(windowFlagRaw) if (uploadsV2Enabled) { return (
) } const userId = props?.auth?.user?.id ?? null const suggestedTags = Array.isArray(props?.suggested_tags) ? props.suggested_tags : [] const safeChunkSize = Math.max(1, Number(chunkSize || 0)) const { state, dispatch, previewUrl, startUpload, cancelUpload } = useUploadMachine({ draftId, filesCdnUrl, chunkSize: safeChunkSize, userId }) const fileInputRef = useRef(null) const [confirmCancel, setConfirmCancel] = useState(false) const [contentTypes, setContentTypes] = useState([]) const [selectedParentCategory, setSelectedParentCategory] = useState(null) const availableTypes = useMemo(() => (props?.content_types && Array.isArray(props.content_types)) ? props.content_types : contentTypes, [props, contentTypes]) const selectedType = useMemo( () => availableTypes.find((t) => String(t.id) === String(state.metadata.type)), [availableTypes, state.metadata.type] ) const categoryOptions = useMemo(() => selectedType?.categories || [], [selectedType]) const hasAtLeastOneTag = useMemo(() => parseUiTags(state.metadata.tags).length > 0, [state.metadata.tags]) useEffect(() => { // Prefer server-provided props, else try fetching from API endpoints if (props?.content_types && Array.isArray(props.content_types)) { setContentTypes(props.content_types) return } let mounted = true ;(async () => { try { const res = await window.axios.get('/api/content-types') if (!mounted) return setContentTypes(res.data || []) } catch (e) { // ignore, content types optional here } })() return () => { mounted = false } }, [props]) const onFileSelect = useCallback((file) => { if (!file) return const preview = URL.createObjectURL(file) dispatch({ type: 'SET_FILE', file, previewUrl: preview }) }, [dispatch]) const onDrop = useCallback((event) => { event.preventDefault() const file = event.dataTransfer.files?.[0] onFileSelect(file) }, [onFileSelect]) const onBrowse = useCallback(() => { fileInputRef.current?.click() }, []) const onChange = useCallback((event) => { const file = event.target.files?.[0] onFileSelect(file) }, [onFileSelect]) const statusLabel = state.pipelineStatus || state.phase useEffect(() => { if (!confirmCancel) return const timer = window.setTimeout(() => { setConfirmCancel(false) }, 5000) return () => window.clearTimeout(timer) }, [confirmCancel]) useEffect(() => { if (!state.cancelledAt) return const timer = window.setTimeout(() => { dispatch({ type: 'CLEAR_CANCELLED' }) }, 3500) return () => window.clearTimeout(timer) }, [state.cancelledAt, dispatch]) return (
{state.notices.length > 0 && (
{state.notices.map((notice) => (
{notice.message}
))}
)}

Upload artwork

Secure pipeline

All uploads are scanned, re-encoded, and published through the Skinbase pipeline.

e.preventDefault()} onDrop={onDrop} >

Drag & drop your file

JPG, PNG, or WebP. Up to 50MB.

{state.file && (
Preview
{state.file.name}
{(state.file.size / 1024 / 1024).toFixed(2)} MB
)}
Choose a type
Step 1: Pick what kind of artwork this is.
Type
{availableTypes.length === 0 ? (
No content types available.
) : (
{availableTypes.map((ct) => { const active = String(ct.id) === String(state.metadata.type) const iconKey = getTypeKey(ct) const iconPath = `/gfx/mascot_${iconKey}.webp` return ( ) })}
)}
Choose a category
Step 2: Pick a subcategory inside the type.
Category
{!selectedType ? (
Select a type first to see categories.
) : categoryOptions.length === 0 ? (
No categories available for {selectedType.name}.
) : (
{categoryOptions.map((cat) => { const isSelected = String(cat.id) === String(state.metadata.category) const isExpanded = String(cat.id) === String(selectedParentCategory) const hasChildren = Array.isArray(cat.children) && cat.children.length > 0 return ( ) })}
{selectedParentCategory && (() => { const parent = categoryOptions.find((c) => String(c.id) === String(selectedParentCategory)) if (!parent || !Array.isArray(parent.children) || parent.children.length === 0) return null return (
Subcategories for {parent.name}
Choose a subcategory below
{parent.children.map((child) => { const activeChild = String(child.id) === String(state.metadata.category) return ( ) })}
) })()}
)}
Tags
{ dispatch({ type: 'SET_METADATA', payload: { tags: nextTags.join(', ') } }) }} suggestedTags={suggestedTags} maxTags={15} minLength={2} maxLength={32} searchEndpoint="/api/tags/search" popularEndpoint="/api/tags/popular" placeholder="Type tags (e.g. cyberpunk, city)" />