Files
2026-03-28 19:15:39 +01:00

1137 lines
46 KiB
JavaScript

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: '',
isMature: false,
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,
is_mature: state.metadata.isMature,
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 (
<section className="min-h-[calc(100vh-4rem)] bg-[#07111c] text-slate-100">
<div className="relative isolate overflow-hidden">
<div className="pointer-events-none absolute inset-x-0 top-0 -z-10 h-[420px] bg-[radial-gradient(circle_at_top_left,_rgba(56,189,248,0.22),_transparent_32%),radial-gradient(circle_at_top_right,_rgba(251,146,60,0.16),_transparent_30%),linear-gradient(180deg,_rgba(8,17,28,0.98),_rgba(7,17,28,1))]" />
<div className="mx-auto max-w-7xl px-4 py-6 sm:px-6 lg:px-8 lg:py-8">
{/* ── Wizard ─────────────────────────────────────────────────────── */}
<div className="overflow-hidden rounded-[32px] border border-white/10 bg-[#08111c]/92 shadow-[0_30px_120px_rgba(2,8,23,0.38)]">
<div className="px-4 py-5 sm:px-6 lg:px-8 lg:py-8">
<UploadWizard
initialDraftId={draftId ?? null}
chunkSize={chunkSize}
contentTypes={Array.isArray(props?.content_types) ? props.content_types : []}
suggestedTags={Array.isArray(props?.suggested_tags) ? props.suggested_tags : []}
/>
</div>
</div>
{/* ── Help / info section ─────────────────────────────────────────── */}
<div className="mt-8 grid gap-8 lg:grid-cols-[1.45fr_0.85fr] lg:items-start lg:gap-10">
<div>
<p className="text-[11px] uppercase tracking-[0.28em] text-sky-200/50">Skinbase Upload Studio</p>
<h2 className="mt-3 max-w-3xl text-xl font-semibold tracking-tight text-white/70 sm:text-2xl">
Upload artwork with less friction and better control.
</h2>
<p className="mt-3 max-w-2xl text-sm leading-7 text-slate-400">
The upload flow now stays focused on three steps: add the file, finish the metadata, then publish with confidence. The interface is simpler, but the secure processing pipeline stays intact.
</p>
<div className="mt-5 grid gap-3 sm:grid-cols-3">
{[
{
title: 'Fast onboarding',
description: 'Clearer file requirements and a friendlier first step.',
},
{
title: 'Safer publishing',
description: 'Processing state, rights, and readiness stay visible the whole time.',
},
{
title: 'Cleaner review',
description: 'Metadata and publish options are easier to scan before going live.',
},
].map((item) => (
<div key={item.title} className="rounded-2xl border border-white/6 bg-white/[0.02] p-4">
<p className="text-sm font-semibold text-white/70">{item.title}</p>
<p className="mt-2 text-sm leading-6 text-slate-500">{item.description}</p>
</div>
))}
</div>
</div>
<aside className="rounded-[28px] border border-white/6 bg-white/[0.02] p-5">
<p className="text-[11px] uppercase tracking-[0.24em] text-sky-200/50">Before you start</p>
<div className="mt-4 space-y-4">
{[
'Choose the final file you actually want published. Replacing after upload requires a reset.',
'ZIP, RAR, and 7Z packs still need at least one screenshot for preview generation.',
'You will confirm rights and visibility before the final publish step.',
].map((item, index) => (
<div key={item} className="flex items-start gap-3">
<span className="inline-flex h-6 w-6 shrink-0 items-center justify-center rounded-full border border-white/10 bg-white/5 text-xs font-semibold text-slate-400">
{index + 1}
</span>
<p className="text-sm leading-6 text-slate-500">{item}</p>
</div>
))}
</div>
</aside>
</div>
</div>
</div>
</section>
)
}
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 (
<section className="min-h-[calc(100vh-4rem)] bg-gradient-to-b from-[#0d0f1b] via-[#111827] to-[#0b0c10] py-10 px-4">
<div className="max-w-6xl mx-auto">
{state.notices.length > 0 && (
<div className="mb-6 space-y-2">
{state.notices.map((notice) => (
<div
key={notice.id}
role="alert"
aria-live="polite"
className={`rounded-xl border px-4 py-3 text-sm ${notice.type === 'error'
? 'border-red-500/40 bg-red-500/10 text-red-100'
: notice.type === 'warning'
? 'border-amber-400/40 bg-amber-400/10 text-amber-100'
: 'border-emerald-400/40 bg-emerald-400/10 text-emerald-100'}`}
>
{notice.message}
</div>
))}
</div>
)}
<div className="grid gap-8 lg:grid-cols-[1.1fr,0.9fr]">
<div className="space-y-6">
<div className="rounded-2xl border border-white/10 bg-white/5 p-6 shadow-[0_0_50px_rgba(59,130,246,0.15)]">
<div className="flex items-center justify-between">
<h1 className="text-3xl font-semibold tracking-tight">Upload artwork</h1>
<span className="text-xs uppercase tracking-[0.25em] text-sky-300">Secure pipeline</span>
</div>
<p className="mt-2 text-sm text-white/70">All uploads are scanned, re-encoded, and published through the Skinbase pipeline.</p>
<div
className="mt-6 rounded-2xl border border-dashed border-white/30 bg-white/5 p-6 text-center transition hover:border-sky-400/60"
onDragOver={(e) => e.preventDefault()}
onDrop={onDrop}
>
<div className="mx-auto h-14 w-14 rounded-full bg-sky-500/20 grid place-items-center text-sky-200">
<i className="fa-solid fa-cloud-arrow-up text-xl" aria-hidden="true"></i>
</div>
<h2 className="mt-3 text-lg font-medium">Drag & drop your file</h2>
<p className="text-sm text-white/60">JPG, PNG, or WebP. Up to 50MB.</p>
<button type="button" onClick={onBrowse} className="mt-4 inline-flex items-center gap-2 rounded-full bg-sky-500 px-4 py-2 text-sm font-semibold text-white shadow-lg shadow-sky-500/30">
Browse files
</button>
<input ref={fileInputRef} type="file" accept="image/jpeg,image/png,image/webp" className="hidden" onChange={onChange} />
</div>
{state.file && (
<div className="mt-6 flex items-center gap-4">
<div className="h-20 w-20 overflow-hidden rounded-xl border border-white/10">
<img src={previewUrl} alt="Preview" className="h-full w-full object-cover" />
</div>
<div>
<div className="text-sm font-semibold">{state.file.name}</div>
<div className="text-xs text-white/60">{(state.file.size / 1024 / 1024).toFixed(2)} MB</div>
</div>
</div>
)}
<div className="mt-6 grid gap-4 sm:grid-cols-2">
<label className="text-sm sm:col-span-2">
<span className="text-white/80">Title</span>
<input
value={state.metadata.title}
onChange={(e) => dispatch({ type: 'SET_METADATA', payload: { title: e.target.value } })}
className="mt-2 w-full rounded-xl border border-white/10 bg-white/10 px-3 py-2 text-white focus:border-sky-400 focus:outline-none"
placeholder="Name your artwork"
/>
</label>
</div>
<div className="mt-6 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">Choose a type</div>
<div className="text-xs text-white/60">Step 1: Pick what kind of artwork this is.</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>
{availableTypes.length === 0 ? (
<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>
) : (
<div className="mt-4 grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
{availableTypes.map((ct) => {
const active = String(ct.id) === String(state.metadata.type)
const iconKey = getTypeKey(ct)
const iconPath = `/gfx/mascot_${iconKey}.webp`
return (
<button
key={ct.id}
type="button"
onClick={() => dispatch({ type: 'SET_METADATA', payload: { type: String(ct.id), category: '' } })}
className={`group rounded-2xl border px-4 py-3 text-left transition ${active
? 'border-emerald-400/60 bg-emerald-500/10 shadow-[0_0_25px_rgba(16,185,129,0.25)]'
: 'border-white/10 bg-white/5 hover:border-sky-400/40 hover:bg-white/10'}`}
aria-pressed={active}
>
<div className="flex items-center gap-3">
<div className={`h-14 w-10 rounded-xl text-sm font-semibold overflow-hidden relative ${active ? '' : ''}`}>
<img
src={iconPath}
alt={`${ct.name} icon`}
className="absolute inset-0 h-full w-full object-contain object-center z-0"
onLoad={(e) => {
const letter = document.getElementById(`type-letter-${ct.id}`)
if (letter) letter.style.display = 'none'
}}
onError={(e) => {
e.currentTarget.style.display = 'none'
const letter = document.getElementById(`type-letter-${ct.id}`)
if (letter) letter.style.display = 'grid'
}}
/>
<div id={`type-letter-${ct.id}`} className={`relative z-10 grid h-14 w-10 place-items-center ${active ? 'text-emerald-200' : 'text-white/70'}`}>
{ct.name?.slice(0, 1)?.toUpperCase() || '?'}
</div>
</div>
<div>
<div className="text-sm font-semibold text-white">{ct.name}</div>
<div className="text-xs text-white/60">Select to reveal categories</div>
</div>
</div>
<div className={`mt-3 h-1 w-full rounded-full ${active ? 'bg-emerald-400/80' : 'bg-white/10 group-hover:bg-sky-400/40'}`}></div>
</button>
)
})}
</div>
)}
</div>
<div className="mt-4 rounded-2xl border border-white/10 bg-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">Choose a category</div>
<div className="text-xs text-white/60">Step 2: Pick a subcategory inside the type.</div>
</div>
<span className="rounded-full border border-purple-400/40 bg-purple-400/10 px-3 py-1 text-[11px] uppercase tracking-[0.2em] text-purple-200">Category</span>
</div>
{!selectedType ? (
<div className="mt-4 rounded-xl border border-white/10 bg-white/5 px-4 py-3 text-sm text-white/60">
Select a type first to see categories.
</div>
) : categoryOptions.length === 0 ? (
<div className="mt-4 rounded-xl border border-white/10 bg-white/5 px-4 py-3 text-sm text-white/60">
No categories available for {selectedType.name}.
</div>
) : (
<div className="mt-4">
<div className="flex flex-wrap gap-2">
{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 (
<button
key={cat.id}
type="button"
onClick={() => {
if (hasChildren) {
setSelectedParentCategory(String(cat.id))
dispatch({ type: 'SET_METADATA', payload: { category: '' } })
return
}
setSelectedParentCategory(null)
dispatch({ type: 'SET_METADATA', payload: { category: String(cat.id) } })
}}
className={`rounded-full border px-4 py-2 text-sm transition ${isSelected || isExpanded
? 'border-purple-300/60 bg-purple-400/20 text-purple-100'
: 'border-white/10 bg-white/5 text-white/70 hover:border-purple-300/50 hover:bg-purple-400/10'}`}
aria-pressed={isSelected || isExpanded}
>
{cat.name}
</button>
)
})}
</div>
{selectedParentCategory && (() => {
const parent = categoryOptions.find((c) => String(c.id) === String(selectedParentCategory))
if (!parent || !Array.isArray(parent.children) || parent.children.length === 0) return null
return (
<div className="mt-4 rounded-xl border border-white/10 bg-white/5 p-4">
<div className="flex items-center justify-between">
<div>
<div className="text-sm font-semibold text-white">Subcategories for {parent.name}</div>
<div className="text-xs text-white/60">Choose a subcategory below</div>
</div>
<button type="button" onClick={() => setSelectedParentCategory(null)} className="text-sm text-white/60">Back</button>
</div>
<div className="mt-3 flex flex-wrap gap-2">
{parent.children.map((child) => {
const activeChild = String(child.id) === String(state.metadata.category)
return (
<button
key={child.id}
type="button"
onClick={() => {
// Select child but keep parent expanded so the panel remains visible
dispatch({ type: 'SET_METADATA', payload: { category: String(child.id) } })
}}
className={`rounded-full border px-4 py-2 text-sm transition ${activeChild
? 'border-purple-300/60 bg-purple-400/20 text-purple-100'
: 'border-white/10 bg-white/5 text-white/70 hover:border-purple-300/50 hover:bg-purple-400/10'}`}
aria-pressed={activeChild}
>
{child.name}
</button>
)
})}
</div>
</div>
)
})()}
</div>
)}
</div>
<div className="mt-4">
<span className="text-sm text-white/80">Tags</span>
<div className="mt-2">
<TagInput
value={state.metadata.tags}
onChange={(nextTags) => {
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)"
/>
</div>
</div>
<label className="mt-4 block text-sm">
<span className="text-white/80">Description</span>
<textarea
value={state.metadata.description}
onChange={(e) => dispatch({ type: 'SET_METADATA', payload: { description: e.target.value } })}
className="mt-2 w-full rounded-xl border border-white/10 bg-white/10 px-3 py-2 text-white focus:border-sky-400 focus:outline-none"
rows={4}
placeholder="Tell the story behind this artwork."
/>
</label>
<div className="mt-4">
<Checkbox
checked={state.metadata.isMature}
onChange={(e) => dispatch({ type: 'SET_METADATA', payload: { isMature: e.target.checked } })}
size={16}
variant="accent"
label="Mark this artwork as mature content."
/>
</div>
<div className="mt-4">
<Checkbox
checked={state.metadata.licenseAccepted}
onChange={(e) => dispatch({ type: 'SET_METADATA', payload: { licenseAccepted: e.target.checked } })}
size={16}
variant="emerald"
label="I confirm I own the rights to this artwork."
/>
</div>
{state.cancelledAt && (
<div role="alert" aria-live="polite" className="mt-4 rounded-xl border border-amber-400/40 bg-amber-400/10 px-4 py-3 text-sm text-amber-100">
Upload cancelled.
</div>
)}
{state.error && (
<div className="mt-4 rounded-xl border border-red-500/40 bg-red-500/10 px-4 py-3 text-sm text-red-100">
{state.error}
</div>
)}
<div className="mt-6 flex flex-wrap items-center gap-3">
<button
type="button"
onClick={startUpload}
disabled={
!state.file ||
!state.metadata.title.trim() ||
!state.metadata.type ||
!state.metadata.category ||
!hasAtLeastOneTag ||
!state.metadata.description.trim() ||
!state.metadata.licenseAccepted
}
className={`inline-flex items-center gap-2 rounded-full px-5 py-2 text-sm font-semibold text-white ${(!state.file || !state.metadata.title.trim() || !state.metadata.type || !state.metadata.category || !hasAtLeastOneTag || !state.metadata.description.trim() || !state.metadata.licenseAccepted) ? 'bg-white/10 cursor-not-allowed' : 'bg-emerald-500 shadow-lg shadow-emerald-500/30'}`}
>
<i className="fa-solid fa-rocket" aria-hidden="true"></i>
Start upload
</button>
<button
type="button"
onClick={() => {
if (!confirmCancel) {
setConfirmCancel(true)
return
}
setConfirmCancel(false)
cancelUpload()
}}
className="inline-flex items-center gap-2 rounded-full border border-red-400/40 px-4 py-2 text-sm text-red-100"
disabled={!state.sessionId || state.phase === phases.success}
aria-pressed={confirmCancel}
>
<i className="fa-solid fa-ban" aria-hidden="true"></i>
{confirmCancel ? 'Confirm cancel' : 'Cancel'}
</button>
<button
type="button"
onClick={() => {
if (userId) {
clearStoredSession(userId)
}
dispatch({ type: 'RESET' })
}}
className="inline-flex items-center gap-2 rounded-full border border-white/20 px-4 py-2 text-sm text-white/70"
>
Reset
</button>
</div>
</div>
</div>
<div className="space-y-6">
<div className="rounded-2xl border border-white/10 bg-white/5 p-6">
<h3 className="text-lg font-semibold">Pipeline status</h3>
<p className="mt-2 text-sm text-white/60">Stage: <span className="text-white">{statusLabel}</span></p>
<div className="mt-4 h-2 w-full overflow-hidden rounded-full bg-white/10">
<div className="h-full bg-sky-400 transition-all" style={{ width: `${state.progress}%` }}></div>
</div>
{state.failureReason && (
<div className="mt-3 text-sm text-red-200">Failure: {state.failureReason}</div>
)}
{state.previewUrl && state.phase === phases.success && (
<div className="mt-6">
<h4 className="text-sm font-semibold text-white/80">CDN preview</h4>
<div className="mt-2 overflow-hidden rounded-xl border border-white/10">
<img src={state.previewUrl} alt="CDN preview" className="h-56 w-full object-cover" />
</div>
</div>
)}
<div className="mt-6 rounded-xl border border-white/10 bg-white/5 px-4 py-3 text-xs text-white/60">
Session: {state.sessionId ?? '—'}
</div>
</div>
<div className="rounded-2xl border border-white/10 bg-white/5 p-6">
<h3 className="text-lg font-semibold">Draft resume</h3>
<p className="mt-2 text-sm text-white/60">Use the draft link to resume an interrupted upload.</p>
{draftId ? (
<div className="mt-4 text-sm text-white/80">Draft ID: {draftId}</div>
) : (
<div className="mt-4 text-sm text-white/50">No draft loaded.</div>
)}
</div>
</div>
</div>
</div>
</section>
)
}