Save workspace changes

This commit is contained in:
2026-04-18 17:02:56 +02:00
parent f02ea9a711
commit 87d60af5a9
4220 changed files with 1388603 additions and 1554 deletions

View File

@@ -0,0 +1,944 @@
/**
* UploadWizard refactored orchestrator
*
* A 3-step upload wizard that delegates:
* - Machine state → useUploadMachine
* - File validation → useFileValidation
* - Vision AI tags → useVisionTags
* - Step rendering → Step1FileUpload / Step2Details / Step3Publish
* - Reusable UI → ContentTypeSelector / CategorySelector / UploadSidebar …
*/
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { AnimatePresence, motion, useReducedMotion } from 'framer-motion'
import useUploadMachine, { machineStates } from '../../hooks/upload/useUploadMachine'
import useFileValidation from '../../hooks/upload/useFileValidation'
import useVisionTags from '../../hooks/upload/useVisionTags'
import StudioStatusBar from './StudioStatusBar'
import UploadOverlay from './UploadOverlay'
import UploadActions from './UploadActions'
import PublishPanel from './PublishPanel'
import Step1FileUpload from './steps/Step1FileUpload'
import Step2Details from './steps/Step2Details'
import Step3Publish from './steps/Step3Publish'
import {
buildCategoryTree,
getContentTypeValue,
getProcessingTransparencyLabel,
} from '../../lib/uploadUtils'
// ─── Wizard step config ───────────────────────────────────────────────────────
const wizardSteps = [
{ key: 'upload', label: 'Upload' },
{ key: 'details', label: 'Details' },
{ key: 'publish', label: 'Publish' },
]
function createInitialMetadata(initialGroupSlug = '', currentUserId = null, contributorOptionsByGroup = {}) {
const normalizedGroupSlug = String(initialGroupSlug || '').trim()
const contributors = Array.isArray(contributorOptionsByGroup?.[normalizedGroupSlug])
? contributorOptionsByGroup[normalizedGroupSlug]
: []
const defaultPrimaryAuthor = contributors.some((user) => Number(user.id) === Number(currentUserId))
? Number(currentUserId)
: Number(contributors[0]?.id || 0) || null
return {
title: '',
rootCategoryId: '',
subCategoryId: '',
tags: [],
description: '',
isMature: false,
rightsAccepted: false,
contentType: '',
group: normalizedGroupSlug,
primaryAuthorUserId: defaultPrimaryAuthor,
contributorUserIds: [],
contributorCredits: {},
}
}
function normalizeContributorCredits(contributorIds = [], contributorCredits = {}) {
const normalized = {}
const ids = Array.isArray(contributorIds)
? contributorIds.map((id) => Number(id)).filter((id) => Number.isFinite(id) && id > 0)
: []
ids.forEach((id) => {
const current = contributorCredits?.[id] || contributorCredits?.[String(id)] || {}
normalized[id] = {
creditRole: typeof current.creditRole === 'string' ? current.creditRole : '',
isPrimary: Boolean(current.isPrimary),
}
})
const leadIds = Object.entries(normalized)
.filter(([, value]) => value.isPrimary)
.map(([id]) => Number(id))
if (leadIds.length > 1) {
leadIds.slice(1).forEach((id) => {
normalized[id] = {
...normalized[id],
isPrimary: false,
}
})
}
return normalized
}
// ─── Helpers ─────────────────────────────────────────────────────────────────
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
}
// ─── Component ────────────────────────────────────────────────────────────────
export default function UploadWizard({
onValidationStateChange,
initialDraftId = null,
chunkSize,
chunkRequestTimeoutMs,
contentTypes = [],
suggestedTags = [],
groupOptions = [],
contributorOptionsByGroup = {},
initialGroupSlug = '',
currentUserId = null,
}) {
const [notices, setNotices] = useState([])
// ── UI state ──────────────────────────────────────────────────────────────
const [activeStep, setActiveStep] = useState(1)
const [showRestoredBanner, setShowRestoredBanner] = useState(Boolean(initialDraftId))
const [isUploadLocked, setIsUploadLocked] = useState(false)
const [resolvedArtworkId, setResolvedArtworkId] = useState(() => {
const parsed = Number(initialDraftId)
return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : null
})
// ── Publish options (Studio) ──────────────────────────────────────────────
const [publishMode, setPublishMode] = useState('now') // 'now' | 'schedule'
const [scheduledAt, setScheduledAt] = useState(null) // UTC ISO or null
const [visibility, setVisibility] = useState('public') // 'public'|'unlisted'|'private'
const [showMobilePublishPanel, setShowMobilePublishPanel] = useState(false)
const userTimezone = useMemo(() => {
try { return Intl.DateTimeFormat().resolvedOptions().timeZone } catch { return 'UTC' }
}, [])
// ── File + screenshot state ───────────────────────────────────────────────
const [primaryFile, setPrimaryFile] = useState(null)
const [screenshots, setScreenshots] = useState([])
const [selectedScreenshotIndex, setSelectedScreenshotIndex] = useState(0)
// ── Metadata state ────────────────────────────────────────────────────────
const [metadata, setMetadata] = useState(() => createInitialMetadata(initialGroupSlug, currentUserId, contributorOptionsByGroup))
// ── Refs ──────────────────────────────────────────────────────────────────
const prefersReducedMotion = useReducedMotion()
const stepContentRef = useRef(null)
const stepHeadingRef = useRef(null)
const hasAutoAdvancedRef = useRef(false)
const quickTransition = prefersReducedMotion ? { duration: 0 } : { duration: 0.2, ease: 'easeOut' }
// ── File validation hook ──────────────────────────────────────────────────
const {
primaryType,
primaryErrors,
primaryWarnings,
fileMetadata,
primaryPreviewUrl,
screenshotErrors,
screenshotPerFileErrors,
} = useFileValidation(primaryFile, screenshots)
const isArchive = primaryType === 'archive'
const canStartUpload = isValidForUpload(primaryFile, primaryErrors, isArchive, screenshotErrors)
useEffect(() => {
if (!Array.isArray(screenshots) || screenshots.length === 0) {
setSelectedScreenshotIndex(0)
return
}
setSelectedScreenshotIndex((prev) => {
if (!Number.isFinite(prev) || prev < 0) return 0
return Math.min(prev, screenshots.length - 1)
})
}, [screenshots])
// ── Machine hook ──────────────────────────────────────────────────────────
const {
machine,
runUploadFlow,
handleCancel,
handlePublish,
handleRetry,
resetMachine,
abortAllRequests,
clearPolling,
} = useUploadMachine({
primaryFile,
screenshots,
selectedScreenshotIndex,
canStartUpload,
primaryType,
isArchive,
initialDraftId,
metadata,
chunkSize,
chunkRequestTimeoutMs,
onArtworkCreated: (id) => setResolvedArtworkId(id),
onNotice: (notice) => {
if (!notice?.message) return
const normalizedType = ['success', 'warning', 'error'].includes(String(notice.type || '').toLowerCase())
? String(notice.type).toLowerCase()
: 'error'
const id = `${Date.now()}-${Math.random().toString(16).slice(2)}`
setNotices((prev) => [...prev, { id, type: normalizedType, message: String(notice.message) }])
window.setTimeout(() => {
setNotices((prev) => prev.filter((item) => item.id !== id))
}, 4500)
},
})
// ── Upload-ready flag (needed before vision hook) ─────────────────────────
const uploadReady = (
machine.state === machineStates.ready_to_publish ||
machine.processingStatus === 'ready' ||
machine.state === machineStates.complete
)
// ── Vision tags hook fires on upload completion, not step change ──────────
// Starts fetching AI tag suggestions while the user is still on Step 1,
// so results are ready (or partially ready) by the time Step 2 opens.
const { visionSuggestedTags } = useVisionTags(resolvedArtworkId, uploadReady)
// ── Category tree computation ─────────────────────────────────────────────
const categoryTreeByType = useMemo(() => {
const result = {}
const list = Array.isArray(contentTypes) ? contentTypes : []
list.forEach((type) => {
const value = getContentTypeValue(type)
if (!value) return
result[value] = buildCategoryTree([type])
})
return result
}, [contentTypes])
const filteredCategoryTree = useMemo(() => {
const selected = String(metadata.contentType || '')
if (!selected) return []
return categoryTreeByType[selected] || []
}, [categoryTreeByType, metadata.contentType])
const selectedGroupOption = useMemo(() => {
const selectedSlug = String(metadata.group || '')
if (!selectedSlug) return null
return (Array.isArray(groupOptions) ? groupOptions : []).find((group) => String(group.slug || '') === selectedSlug) || null
}, [groupOptions, metadata.group])
const reviewSubmissionMode = Boolean(
selectedGroupOption &&
!selectedGroupOption?.permissions?.can_publish_artworks &&
selectedGroupOption?.permissions?.can_submit_artwork_for_review
)
const publishActionLabel = reviewSubmissionMode
? 'Submit for review'
: (publishMode === 'schedule' ? 'Schedule publish' : 'Publish now')
const currentContributorOptions = useMemo(() => {
const selectedSlug = String(metadata.group || '')
return Array.isArray(contributorOptionsByGroup?.[selectedSlug]) ? contributorOptionsByGroup[selectedSlug] : []
}, [contributorOptionsByGroup, metadata.group])
const allRootCategoryOptions = useMemo(() => {
const items = []
Object.entries(categoryTreeByType).forEach(([contentTypeValue, roots]) => {
roots.forEach((root) => items.push({ ...root, contentTypeValue }))
})
return items
}, [categoryTreeByType])
const selectedRootCategory = useMemo(() => {
const id = String(metadata.rootCategoryId || '')
if (!id) return null
return (
filteredCategoryTree.find((r) => String(r.id) === id) ||
allRootCategoryOptions.find((r) => String(r.id) === id) ||
null
)
}, [filteredCategoryTree, allRootCategoryOptions, metadata.rootCategoryId])
const requiresSubCategory = Boolean(
selectedRootCategory &&
Array.isArray(selectedRootCategory.children) &&
selectedRootCategory.children.length > 0
)
useEffect(() => {
const selectedSlug = String(metadata.group || '')
if (!selectedSlug) {
if (metadata.primaryAuthorUserId || metadata.contributorUserIds.length > 0 || Object.keys(metadata.contributorCredits || {}).length > 0) {
setMetadata((current) => ({ ...current, primaryAuthorUserId: null, contributorUserIds: [], contributorCredits: {} }))
}
return
}
const validGroup = (Array.isArray(groupOptions) ? groupOptions : []).some((group) => String(group.slug || '') === selectedSlug)
if (!validGroup) {
setMetadata((current) => ({ ...current, group: '', primaryAuthorUserId: null, contributorUserIds: [], contributorCredits: {} }))
return
}
const validContributorIds = currentContributorOptions.map((user) => Number(user.id)).filter((id) => Number.isFinite(id) && id > 0)
const nextPrimaryAuthorId = validContributorIds.includes(Number(metadata.primaryAuthorUserId))
? Number(metadata.primaryAuthorUserId)
: (validContributorIds.includes(Number(currentUserId)) ? Number(currentUserId) : (validContributorIds[0] || null))
const nextContributorIds = (Array.isArray(metadata.contributorUserIds) ? metadata.contributorUserIds : [])
.map((id) => Number(id))
.filter((id) => validContributorIds.includes(id) && id !== nextPrimaryAuthorId)
const nextContributorCredits = normalizeContributorCredits(nextContributorIds, metadata.contributorCredits)
const currentPrimary = metadata.primaryAuthorUserId ? Number(metadata.primaryAuthorUserId) : null
const currentContributors = (Array.isArray(metadata.contributorUserIds) ? metadata.contributorUserIds : []).map((id) => Number(id))
const contributorsChanged = nextContributorIds.length !== currentContributors.length || nextContributorIds.some((id, index) => id !== currentContributors[index])
const contributorCreditsChanged = JSON.stringify(nextContributorCredits) !== JSON.stringify(normalizeContributorCredits(currentContributors, metadata.contributorCredits))
if (currentPrimary !== nextPrimaryAuthorId || contributorsChanged || contributorCreditsChanged) {
setMetadata((current) => ({
...current,
primaryAuthorUserId: nextPrimaryAuthorId,
contributorUserIds: nextContributorIds,
contributorCredits: nextContributorCredits,
}))
}
}, [groupOptions, currentContributorOptions, currentUserId, metadata.group, metadata.primaryAuthorUserId, metadata.contributorUserIds, metadata.contributorCredits])
// ── Metadata validation ───────────────────────────────────────────────────
const metadataErrors = useMemo(() => {
const errors = {}
if (!String(metadata.title || '').trim()) errors.title = 'Title is required.'
if (!metadata.contentType) errors.contentType = 'Content type is required.'
if (!metadata.rootCategoryId) errors.category = 'Root category is required.'
if (metadata.rootCategoryId && requiresSubCategory && !metadata.subCategoryId) {
errors.category = 'Subcategory is required for the selected category.'
}
if (!metadata.rightsAccepted) errors.rights = 'Rights confirmation is required.'
return errors
}, [metadata, requiresSubCategory])
const detailsValid = Object.keys(metadataErrors).length === 0
// ── Merged AI + manual suggested tags ────────────────────────────────────
const mergedSuggestedTags = useMemo(() => {
const map = new Map()
const addTag = (item) => {
if (!item) return
const key = String(item?.slug || item?.tag || item?.name || item).trim().toLowerCase()
if (!key || map.has(key)) return
map.set(key, typeof item === 'string' ? item : {
id: item.id ?? key,
name: item.name || item.tag || item.slug || key,
slug: item.slug || item.tag || key,
usage_count: Number(item.usage_count || 0),
is_ai: Boolean(item.is_ai || item.source === 'ai'),
source: item.source || (item.is_ai ? 'ai' : 'manual'),
})
}
;(Array.isArray(suggestedTags) ? suggestedTags : []).forEach(addTag)
;(Array.isArray(visionSuggestedTags) ? visionSuggestedTags : []).forEach(addTag)
return Array.from(map.values())
}, [suggestedTags, visionSuggestedTags])
// ── Derived flags ─────────────────────────────────────────────────────────
const highestUnlockedStep = uploadReady ? (detailsValid ? 3 : 2) : 1
const showProgress = ![machineStates.idle, machineStates.cancelled].includes(machine.state)
const processingLabel = getProcessingTransparencyLabel(machine.processingStatus, machine.state)
const showOverlay = ['initializing', 'uploading', 'finishing', 'processing', 'error'].includes(machine.state)
const hasTitle = Boolean(String(metadata.title || '').trim())
const hasCompleteCategory = Boolean(
metadata.rootCategoryId && (!requiresSubCategory || metadata.subCategoryId)
)
const hasTag = Array.isArray(metadata.tags) && metadata.tags.length > 0
const hasRequiredScreenshot = !isArchive || screenshots.length > 0
const canPublish = useMemo(() => (
uploadReady &&
hasTitle &&
hasCompleteCategory &&
hasTag &&
hasRequiredScreenshot &&
metadata.rightsAccepted &&
machine.state !== machineStates.publishing
), [uploadReady, hasTitle, hasCompleteCategory, hasTag, hasRequiredScreenshot, metadata.rightsAccepted, machine.state])
const canScheduleSubmit = useMemo(() => {
if (!canPublish) return false
if (reviewSubmissionMode) return true
if (publishMode === 'schedule') return Boolean(scheduledAt)
return true
}, [canPublish, reviewSubmissionMode, publishMode, scheduledAt])
// ── Validation surface for parent ────────────────────────────────────────
const validationErrors = useMemo(
() => [...primaryErrors, ...screenshotErrors],
[primaryErrors, screenshotErrors]
)
useEffect(() => {
if (typeof onValidationStateChange === 'function') {
onValidationStateChange({ isValid: canStartUpload, validationErrors, isArchive })
}
}, [canStartUpload, validationErrors, isArchive, onValidationStateChange])
// ── Auto-advance to step 2 after upload complete ──────────────────────────
useEffect(() => {
if (uploadReady && activeStep === 1 && !hasAutoAdvancedRef.current) {
hasAutoAdvancedRef.current = true
setIsUploadLocked(true)
setActiveStep(2)
}
}, [uploadReady, activeStep])
useEffect(() => {
if (uploadReady) setIsUploadLocked(true)
}, [uploadReady])
// ── Step scroll + focus ───────────────────────────────────────────────────
useEffect(() => {
if (!stepContentRef.current) return
stepContentRef.current.scrollIntoView({
behavior: prefersReducedMotion ? 'auto' : 'smooth',
block: 'start',
})
window.setTimeout(() => {
stepHeadingRef.current?.focus?.({ preventScroll: true })
}, 0)
}, [activeStep, prefersReducedMotion])
// ── Cleanup ───────────────────────────────────────────────────────────────
useEffect(() => {
return () => {
abortAllRequests()
clearPolling()
}
}, [abortAllRequests, clearPolling])
// ── ESC key closes mobile drawer (spec §7) ─────────────────────────────
useEffect(() => {
if (!showMobilePublishPanel) return
const handler = (e) => { if (e.key === 'Escape') setShowMobilePublishPanel(false) }
document.addEventListener('keydown', handler)
return () => document.removeEventListener('keydown', handler)
}, [showMobilePublishPanel])
// ── Metadata helpers ──────────────────────────────────────────────────────
const setMeta = useCallback((patch) => setMetadata((prev) => ({ ...prev, ...patch })), [])
// ── Full reset ────────────────────────────────────────────────────────────
const handleReset = useCallback(() => {
resetMachine()
setPrimaryFile(null)
setScreenshots([])
setSelectedScreenshotIndex(0)
setMetadata(createInitialMetadata(initialGroupSlug, currentUserId, contributorOptionsByGroup))
setIsUploadLocked(false)
hasAutoAdvancedRef.current = false
setPublishMode('now')
setScheduledAt(null)
setVisibility('public')
setShowMobilePublishPanel(false)
setResolvedArtworkId(() => {
const parsed = Number(initialDraftId)
return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : null
})
setActiveStep(1)
}, [resetMachine, initialDraftId, initialGroupSlug, currentUserId, contributorOptionsByGroup])
const goToStep = useCallback((step) => {
if (step >= 1 && step <= highestUnlockedStep) setActiveStep(step)
}, [highestUnlockedStep])
// ── Step content renderer ─────────────────────────────────────────────────
const renderStepContent = () => {
// Complete / success screen
if (machine.state === machineStates.complete) {
const wasScheduled = machine.lastAction === 'schedule'
const studioArtworksUrl = '/studio/artworks'
const studioArtworkUrl = resolvedArtworkId
? `/studio/artworks/${resolvedArtworkId}/edit`
: studioArtworksUrl
return (
<motion.div
initial={prefersReducedMotion ? false : { opacity: 0, scale: 0.98 }}
animate={{ opacity: 1, scale: 1 }}
transition={prefersReducedMotion ? { duration: 0 } : { duration: 0.28, ease: 'easeOut' }}
className={`rounded-2xl p-8 text-center ${wasScheduled ? 'ring-1 ring-violet-300/25 bg-violet-500/8' : 'ring-1 ring-emerald-300/25 bg-emerald-500/8'}`}
>
<motion.div
initial={prefersReducedMotion ? false : { scale: 0.7, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={prefersReducedMotion ? { duration: 0 } : { delay: 0.1, duration: 0.26, ease: 'backOut' }}
className={`mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-full text-2xl`}
>
{wasScheduled ? '🕐' : '🎉'}
</motion.div>
<h3 className="text-xl font-semibold text-white">
{wasScheduled ? 'Artwork scheduled!' : 'Your artwork is live!'}
</h3>
<p className="mt-2 text-sm text-white/65">
{wasScheduled
? scheduledAt
? `Will publish on ${new Intl.DateTimeFormat('en-GB', { timeZone: userTimezone, weekday: 'short', day: 'numeric', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit', hour12: false }).format(new Date(scheduledAt))}`
: 'Your artwork is scheduled for future publishing.'
: 'It has been published and is now visible to the community.'}
</p>
<div className="mt-6 flex flex-wrap justify-center gap-3">
{!wasScheduled && (
<a
href={resolvedArtworkId
? `/art/${resolvedArtworkId}${machine.slug ? `/${machine.slug}` : ''}`
: '/'}
className="rounded-lg ring-1 ring-emerald-300/45 bg-emerald-400/20 px-4 py-2 text-sm font-medium text-emerald-50 hover:bg-emerald-400/30 transition"
>
View artwork
</a>
)}
<a
href={studioArtworksUrl}
className="rounded-lg ring-1 ring-sky-300/35 bg-sky-400/12 px-4 py-2 text-sm font-medium text-sky-50 hover:bg-sky-400/20 transition"
>
View in studio
</a>
<a
href={studioArtworkUrl}
className="rounded-lg ring-1 ring-white/20 bg-white/8 px-4 py-2 text-sm font-medium text-white hover:bg-white/15 transition"
>
Edit artwork in studio
</a>
<button
type="button"
onClick={handleReset}
className="rounded-lg ring-1 ring-white/20 bg-white/8 px-4 py-2 text-sm text-white hover:bg-white/15 transition"
>
Upload another
</button>
</div>
</motion.div>
)
}
if (activeStep === 1) {
return (
<Step1FileUpload
headingRef={stepHeadingRef}
primaryFile={primaryFile}
primaryPreviewUrl={primaryPreviewUrl}
primaryErrors={primaryErrors}
primaryWarnings={primaryWarnings}
fileMetadata={fileMetadata}
fileSelectionLocked={isUploadLocked}
onPrimaryFileChange={setPrimaryFile}
isArchive={isArchive}
screenshots={screenshots}
selectedScreenshotIndex={selectedScreenshotIndex}
screenshotErrors={screenshotErrors}
screenshotPerFileErrors={screenshotPerFileErrors}
onScreenshotsChange={setScreenshots}
onSelectedScreenshotChange={setSelectedScreenshotIndex}
machine={machine}
/>
)
}
if (activeStep === 2) {
return (
<Step2Details
headingRef={stepHeadingRef}
primaryFile={primaryFile}
primaryPreviewUrl={primaryPreviewUrl}
isArchive={isArchive}
fileMetadata={fileMetadata}
screenshots={screenshots}
selectedScreenshotIndex={selectedScreenshotIndex}
onSelectedScreenshotChange={setSelectedScreenshotIndex}
contentTypes={contentTypes}
metadata={metadata}
metadataErrors={metadataErrors}
filteredCategoryTree={filteredCategoryTree}
allRootCategoryOptions={allRootCategoryOptions}
requiresSubCategory={requiresSubCategory}
onContentTypeChange={(value) => setMeta({ contentType: value, rootCategoryId: '', subCategoryId: '' })}
onRootCategoryChange={(rootId) => setMeta({ rootCategoryId: rootId, subCategoryId: '' })}
onSubCategoryChange={(subId) => setMeta({ subCategoryId: subId })}
groupOptions={groupOptions}
currentContributorOptions={currentContributorOptions}
onGroupChange={(groupSlug) => setMeta({ group: groupSlug })}
onPrimaryAuthorChange={(authorId) => setMeta({ primaryAuthorUserId: authorId ? Number(authorId) : null })}
onContributorToggle={(contributorId) => setMetadata((current) => {
const normalizedId = Number(contributorId)
const nextIds = new Set((Array.isArray(current.contributorUserIds) ? current.contributorUserIds : []).map((id) => Number(id)).filter((id) => id !== Number(current.primaryAuthorUserId)))
if (nextIds.has(normalizedId)) {
nextIds.delete(normalizedId)
} else {
nextIds.add(normalizedId)
}
const contributorUserIds = Array.from(nextIds).filter((id) => id !== Number(current.primaryAuthorUserId))
return {
...current,
contributorUserIds,
contributorCredits: normalizeContributorCredits(contributorUserIds, current.contributorCredits),
}
})}
onContributorRoleChange={(contributorId, creditRole) => setMetadata((current) => {
const contributorUserIds = (Array.isArray(current.contributorUserIds) ? current.contributorUserIds : []).map((id) => Number(id))
if (!contributorUserIds.includes(Number(contributorId))) return current
const contributorCredits = normalizeContributorCredits(contributorUserIds, current.contributorCredits)
return {
...current,
contributorCredits: {
...contributorCredits,
[Number(contributorId)]: {
...(contributorCredits[Number(contributorId)] || { isPrimary: false }),
creditRole,
},
},
}
})}
onContributorPrimaryChange={(contributorId) => setMetadata((current) => {
const contributorUserIds = (Array.isArray(current.contributorUserIds) ? current.contributorUserIds : []).map((id) => Number(id))
const contributorCredits = normalizeContributorCredits(contributorUserIds, current.contributorCredits)
contributorUserIds.forEach((id) => {
contributorCredits[id] = {
...(contributorCredits[id] || { creditRole: '' }),
isPrimary: id === Number(contributorId),
}
})
return {
...current,
contributorCredits,
}
})}
suggestedTags={mergedSuggestedTags}
publishMode={publishMode}
scheduledAt={scheduledAt}
timezone={userTimezone}
onPublishModeChange={setPublishMode}
onScheduleAt={setScheduledAt}
onChangeTitle={(value) => setMeta({ title: value })}
onChangeTags={(value) => setMeta({ tags: value })}
onChangeDescription={(value) => setMeta({ description: value })}
onToggleMature={(value) => setMeta({ isMature: Boolean(value) })}
onToggleRights={(value) => setMeta({ rightsAccepted: Boolean(value) })}
/>
)
}
return (
<Step3Publish
headingRef={stepHeadingRef}
primaryFile={primaryFile}
primaryPreviewUrl={primaryPreviewUrl}
isArchive={isArchive}
screenshots={screenshots}
selectedScreenshotIndex={selectedScreenshotIndex}
onSelectedScreenshotChange={setSelectedScreenshotIndex}
fileMetadata={fileMetadata}
metadata={metadata}
canPublish={canPublish}
uploadReady={uploadReady}
publishMode={publishMode}
scheduledAt={scheduledAt}
timezone={userTimezone}
visibility={visibility}
actionLabel={publishActionLabel}
showScheduleSummary={!reviewSubmissionMode}
onVisibilityChange={setVisibility}
selectedGroup={selectedGroupOption}
currentContributorOptions={currentContributorOptions}
allRootCategoryOptions={allRootCategoryOptions}
filteredCategoryTree={filteredCategoryTree}
/>
)
}
// ── Action bar helpers ────────────────────────────────────────────────────
const disableReason = (() => {
if (activeStep === 1) return validationErrors[0] || machine.error || 'Complete upload requirements first.'
if (activeStep === 2) return metadataErrors.title || metadataErrors.contentType || metadataErrors.category || metadataErrors.rights || metadataErrors.tags || 'Complete required metadata.'
return machine.error || 'Publish is available when upload is ready and rights are confirmed.'
})()
// ─────────────────────────────────────────────────────────────────────────
return (
<section
ref={stepContentRef}
className="space-y-5 pb-32 text-white lg:pb-8"
data-is-archive={isArchive ? 'true' : 'false'}
>
{notices.length > 0 && (
<div className="fixed right-4 top-4 z-[70] w-[min(92vw,420px)] space-y-2">
{notices.map((notice) => (
<div
key={notice.id}
role="alert"
aria-live="polite"
className={[
'rounded-xl border px-4 py-3 text-sm shadow-lg backdrop-blur',
notice.type === 'success'
? 'border-emerald-400/45 bg-emerald-500/12 text-emerald-100'
: notice.type === 'warning'
? 'border-amber-400/45 bg-amber-500/12 text-amber-100'
: 'border-red-400/45 bg-red-500/12 text-red-100',
].join(' ')}
>
{notice.message}
</div>
))}
</div>
)}
{/* Restored draft banner */}
{showRestoredBanner && (
<div className="rounded-2xl border border-sky-300/20 bg-sky-500/10 px-4 py-3 text-sm text-sky-100 shadow-[0_14px_44px_rgba(14,165,233,0.10)]">
<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="shrink-0 rounded-md ring-1 ring-sky-200/35 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>
)}
{/* ── Studio Status Bar (sticky step header + progress) ────────────── */}
<StudioStatusBar
steps={wizardSteps}
activeStep={activeStep}
highestUnlockedStep={highestUnlockedStep}
machineState={machine.state}
progress={machine.progress}
showProgress={showProgress}
onStepClick={goToStep}
/>
{/* ── Main body: two-column on desktop ─────────────────────────────── */}
<div className="flex flex-col gap-5 lg:flex-row lg:items-start lg:gap-8">
{/* Left / main column: step content */}
<div className="min-w-0 flex-1">
{/* Step content + centered progress overlay */}
<div className="relative">
<AnimatePresence mode="wait" initial={false}>
<motion.div
key={`step-${activeStep}`}
initial={prefersReducedMotion ? false : { opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={prefersReducedMotion ? {} : { opacity: 0, y: -8 }}
transition={quickTransition}
>
{renderStepContent()}
</motion.div>
</AnimatePresence>
<UploadOverlay
machineState={machine.state}
progress={machine.progress}
processingLabel={processingLabel}
error={machine.error}
onRetry={() => handleRetry(canPublish, { mode: publishMode, publishAt: scheduledAt, timezone: userTimezone, visibility })}
onReset={handleReset}
/>
</div>
{/* Wizard action bar (nav: back/next/start/retry) */}
{machine.state !== machineStates.complete && (
<div className="mt-5">
<UploadActions
step={activeStep}
canStart={canStartUpload && [machineStates.idle, machineStates.error, machineStates.cancelled].includes(machine.state)}
canContinue={detailsValid}
canPublish={canScheduleSubmit}
canGoBack={activeStep > 1 && machine.state !== machineStates.complete}
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={[machineStates.uploading, machineStates.initializing].includes(machine.state)}
isProcessing={[machineStates.processing, machineStates.finishing].includes(machine.state)}
isPublishing={machine.state === machineStates.publishing}
isCancelling={machine.isCancelling}
disableReason={disableReason}
onStart={runUploadFlow}
onContinue={() => detailsValid && setActiveStep(3)}
onPublish={() => handlePublish(canPublish, { mode: reviewSubmissionMode ? 'now' : publishMode, publishAt: reviewSubmissionMode ? null : scheduledAt, timezone: userTimezone, visibility, action: reviewSubmissionMode ? 'submit_review' : 'publish' })}
onBack={() => setActiveStep((s) => Math.max(1, s - 1))}
onCancel={handleCancel}
onReset={handleReset}
onRetry={() => handleRetry(canPublish, { mode: reviewSubmissionMode ? 'now' : publishMode, publishAt: reviewSubmissionMode ? null : scheduledAt, timezone: userTimezone, visibility, action: reviewSubmissionMode ? 'submit_review' : 'publish' })}
onSaveDraft={() => {}}
showSaveDraft={activeStep === 2}
resetLabel={isUploadLocked ? 'Reset upload' : 'Reset'}
publishLabel={publishActionLabel}
mobileSticky
/>
</div>
)}
</div>
{/* Right column: PublishPanel (sticky sidebar on lg+, Step 2+ only) */}
{(primaryFile || resolvedArtworkId) && machine.state !== machineStates.complete && activeStep > 1 && (
<div className="hidden shrink-0 lg:block lg:w-80 xl:w-[22rem] lg:sticky lg:top-20 lg:self-start">
<PublishPanel
primaryPreviewUrl={primaryPreviewUrl}
isArchive={isArchive}
screenshots={screenshots}
selectedScreenshotIndex={selectedScreenshotIndex}
onSelectedScreenshotChange={setSelectedScreenshotIndex}
metadata={metadata}
machineState={machine.state}
uploadReady={uploadReady}
canPublish={canPublish}
isPublishing={machine.state === machineStates.publishing}
isArchiveRequiresScreenshot={isArchive}
publishMode={publishMode}
scheduledAt={scheduledAt}
timezone={userTimezone}
visibility={visibility}
actionLabel={publishActionLabel}
showScheduleControls={!reviewSubmissionMode}
showRightsConfirmation={activeStep === 3}
showVisibility={false}
onPublishModeChange={setPublishMode}
onScheduleAt={setScheduledAt}
onVisibilityChange={setVisibility}
onToggleRights={(checked) => setMeta({ rightsAccepted: Boolean(checked) })}
onPublish={() => handlePublish(canPublish, { mode: reviewSubmissionMode ? 'now' : publishMode, publishAt: reviewSubmissionMode ? null : scheduledAt, timezone: userTimezone, visibility, action: reviewSubmissionMode ? 'submit_review' : 'publish' })}
onCancel={handleCancel}
onGoToStep={goToStep}
allRootCategoryOptions={allRootCategoryOptions}
/>
</div>
)}
</div>
{/* ── Mobile: floating "Publish" button that opens bottom sheet ────── */}
{(primaryFile || resolvedArtworkId) && machine.state !== machineStates.complete && activeStep > 1 && (
<div className="fixed bottom-4 right-4 z-30 lg:hidden">
<button
type="button"
aria-label="Open publish panel"
onClick={() => setShowMobilePublishPanel((v) => !v)}
className="flex items-center gap-2 rounded-full border border-sky-300/20 bg-sky-500 px-4 py-2.5 text-sm font-semibold text-white shadow-[0_18px_50px_rgba(14,165,233,0.35)] transition hover:bg-sky-400 active:scale-95"
>
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
{reviewSubmissionMode ? 'Review' : 'Publish'}
{!canPublish && (
<span className="ml-1 rounded-full bg-white/20 px-1.5 text-[10px]">
{[
...(!uploadReady ? [1] : []),
...(hasTitle ? [] : [1]),
...(hasCompleteCategory ? [] : [1]),
...(hasTag ? [] : [1]),
...(hasRequiredScreenshot ? [] : [1]),
...(metadata.rightsAccepted ? [] : [1]),
].length}
</span>
)}
</button>
</div>
)}
{/* ── Mobile Publish panel bottom-sheet overlay ────────────────────── */}
<AnimatePresence>
{showMobilePublishPanel && (
<>
{/* Backdrop */}
<motion.div
key="mobile-panel-backdrop"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
className="fixed inset-0 z-40 bg-black/60 backdrop-blur-sm lg:hidden"
onClick={() => setShowMobilePublishPanel(false)}
/>
{/* Sheet */}
<motion.div
key="mobile-panel-sheet"
initial={{ y: '100%' }}
animate={{ y: 0 }}
exit={{ y: '100%' }}
transition={prefersReducedMotion ? { duration: 0 } : { type: 'spring', damping: 30, stiffness: 300 }}
className="fixed bottom-0 left-0 right-0 z-50 max-h-[80vh] overflow-y-auto rounded-t-2xl bg-slate-900 ring-1 ring-white/10 p-5 pb-8 lg:hidden"
>
<div className="mx-auto mb-4 h-1 w-12 rounded-full bg-white/20" aria-hidden="true" />
<PublishPanel
primaryPreviewUrl={primaryPreviewUrl}
isArchive={isArchive}
screenshots={screenshots}
selectedScreenshotIndex={selectedScreenshotIndex}
onSelectedScreenshotChange={setSelectedScreenshotIndex}
metadata={metadata}
machineState={machine.state}
uploadReady={uploadReady}
canPublish={canPublish}
isPublishing={machine.state === machineStates.publishing}
isArchiveRequiresScreenshot={isArchive}
publishMode={publishMode}
scheduledAt={scheduledAt}
timezone={userTimezone}
visibility={visibility}
actionLabel={publishActionLabel}
showScheduleControls={!reviewSubmissionMode}
showRightsConfirmation={activeStep === 3}
showVisibility={false}
onPublishModeChange={setPublishMode}
onScheduleAt={setScheduledAt}
onVisibilityChange={setVisibility}
onToggleRights={(checked) => setMeta({ rightsAccepted: Boolean(checked) })}
onPublish={() => {
setShowMobilePublishPanel(false)
handlePublish(canPublish, { mode: reviewSubmissionMode ? 'now' : publishMode, publishAt: reviewSubmissionMode ? null : scheduledAt, timezone: userTimezone, visibility, action: reviewSubmissionMode ? 'submit_review' : 'publish' })
}}
onCancel={() => {
setShowMobilePublishPanel(false)
handleCancel()
}}
onGoToStep={(s) => {
setShowMobilePublishPanel(false)
goToStep(s)
}}
allRootCategoryOptions={allRootCategoryOptions}
/>
</motion.div>
</>
)}
</AnimatePresence>
</section>
)
}