Upload wizard:
- Refactored UploadWizard into modular steps (Step1FileUpload, Step2Details, Step3Publish)
- Extracted reusable hooks: useUploadMachine, useFileValidation, useVisionTags
- Extracted reusable components: CategorySelector, ContentTypeSelector
- Added TagPicker component (studio-style list picker with AI badge + new-tag insertion)
- Fixed TagInput auto-open bug (hasFocusedRef guard)
- Replaced TagInput with TagPicker in UploadSidebar
Vision AI tag suggestions:
- Add UploadVisionSuggestController: sync POST /api/uploads/{id}/vision-suggest
- Calls vision.klevze.net/analyze/all on upload completion (before step 2 opens)
- Two-phase useVisionTags: immediate gateway call + background DB polling
- Trigger fires on uploadReady (not step change) so tags arrive before user sees step 2
- Added vision.gateway config block with VISION_GATEWAY_URL env
Artwork versioning system:
- ArtworkVersion / ArtworkVersionEvent models
- ArtworkVersioningService: createNewVersion, restoreVersion, rate limiting, ranking decay
- Migrations: artwork_versions, artwork_version_events, versioning columns on artworks
- Studio API routes: GET versions, POST restore/{version_id}
- Feature tests: ArtworkVersioningTest (13 cases)
504 lines
21 KiB
JavaScript
504 lines
21 KiB
JavaScript
/**
|
||
* 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 UploadStepper from './UploadStepper'
|
||
import UploadActions from './UploadActions'
|
||
import Step1FileUpload from './steps/Step1FileUpload'
|
||
import Step2Details from './steps/Step2Details'
|
||
import Step3Publish from './steps/Step3Publish'
|
||
|
||
import {
|
||
buildCategoryTree,
|
||
getContentTypeValue,
|
||
} from '../../lib/uploadUtils'
|
||
|
||
// ─── Wizard step config ───────────────────────────────────────────────────────
|
||
const wizardSteps = [
|
||
{ key: 'upload', label: 'Upload' },
|
||
{ key: 'details', label: 'Details' },
|
||
{ key: 'publish', label: 'Publish' },
|
||
]
|
||
|
||
// ─── 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
|
||
}
|
||
|
||
const initialMetadata = {
|
||
title: '',
|
||
rootCategoryId: '',
|
||
subCategoryId: '',
|
||
tags: [],
|
||
description: '',
|
||
rightsAccepted: false,
|
||
contentType: '',
|
||
}
|
||
|
||
// ─── Component ────────────────────────────────────────────────────────────────
|
||
export default function UploadWizard({
|
||
onValidationStateChange,
|
||
initialDraftId = null,
|
||
chunkSize,
|
||
contentTypes = [],
|
||
suggestedTags = [],
|
||
}) {
|
||
// ── 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
|
||
})
|
||
|
||
// ── File + screenshot state ───────────────────────────────────────────────
|
||
const [primaryFile, setPrimaryFile] = useState(null)
|
||
const [screenshots, setScreenshots] = useState([])
|
||
|
||
// ── Metadata state ────────────────────────────────────────────────────────
|
||
const [metadata, setMetadata] = useState(initialMetadata)
|
||
|
||
// ── 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)
|
||
|
||
// ── Machine hook ──────────────────────────────────────────────────────────
|
||
const {
|
||
machine,
|
||
runUploadFlow,
|
||
handleCancel,
|
||
handlePublish,
|
||
handleRetry,
|
||
resetMachine,
|
||
abortAllRequests,
|
||
clearPolling,
|
||
} = useUploadMachine({
|
||
primaryFile,
|
||
canStartUpload,
|
||
primaryType,
|
||
isArchive,
|
||
initialDraftId,
|
||
metadata,
|
||
chunkSize,
|
||
onArtworkCreated: (id) => setResolvedArtworkId(id),
|
||
})
|
||
|
||
// ── 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 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
|
||
)
|
||
|
||
// ── 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 canPublish = useMemo(() => (
|
||
uploadReady &&
|
||
metadata.rightsAccepted &&
|
||
machine.state !== machineStates.publishing
|
||
), [uploadReady, metadata.rightsAccepted, machine.state])
|
||
|
||
const stepProgressPercent = useMemo(() => {
|
||
if (activeStep === 1) return 33
|
||
if (activeStep === 2) return 66
|
||
return 100
|
||
}, [activeStep])
|
||
|
||
// ── 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])
|
||
|
||
// ── Metadata helpers ──────────────────────────────────────────────────────
|
||
const setMeta = useCallback((patch) => setMetadata((prev) => ({ ...prev, ...patch })), [])
|
||
|
||
// ── Full reset ────────────────────────────────────────────────────────────
|
||
const handleReset = useCallback(() => {
|
||
resetMachine()
|
||
setPrimaryFile(null)
|
||
setScreenshots([])
|
||
setMetadata(initialMetadata)
|
||
setIsUploadLocked(false)
|
||
hasAutoAdvancedRef.current = false
|
||
setResolvedArtworkId(() => {
|
||
const parsed = Number(initialDraftId)
|
||
return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : null
|
||
})
|
||
setActiveStep(1)
|
||
}, [resetMachine, initialDraftId])
|
||
|
||
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) {
|
||
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 ring-1 ring-emerald-300/25 bg-emerald-500/8 p-8 text-center"
|
||
>
|
||
<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 ring-2 ring-emerald-300/40 bg-emerald-500/20 text-emerald-200"
|
||
>
|
||
<svg className="h-7 w-7" 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>
|
||
</motion.div>
|
||
|
||
<h3 className="text-xl font-semibold text-white">Your artwork is live 🎉</h3>
|
||
<p className="mt-2 text-sm text-emerald-100/75">
|
||
It has been published and is now visible to the community.
|
||
</p>
|
||
|
||
<div className="mt-6 flex flex-wrap justify-center gap-3">
|
||
<a
|
||
href={resolvedArtworkId ? `/artwork/${resolvedArtworkId}` : '/'}
|
||
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>
|
||
<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}
|
||
screenshotErrors={screenshotErrors}
|
||
screenshotPerFileErrors={screenshotPerFileErrors}
|
||
onScreenshotsChange={setScreenshots}
|
||
machine={machine}
|
||
showProgress={showProgress}
|
||
onRetry={() => handleRetry(canPublish)}
|
||
onReset={handleReset}
|
||
/>
|
||
)
|
||
}
|
||
|
||
if (activeStep === 2) {
|
||
return (
|
||
<Step2Details
|
||
headingRef={stepHeadingRef}
|
||
primaryFile={primaryFile}
|
||
primaryPreviewUrl={primaryPreviewUrl}
|
||
isArchive={isArchive}
|
||
fileMetadata={fileMetadata}
|
||
screenshots={screenshots}
|
||
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 })}
|
||
suggestedTags={mergedSuggestedTags}
|
||
onChangeTitle={(value) => setMeta({ title: value })}
|
||
onChangeTags={(value) => setMeta({ tags: value })}
|
||
onChangeDescription={(value) => setMeta({ description: value })}
|
||
onToggleRights={(value) => setMeta({ rightsAccepted: Boolean(value) })}
|
||
/>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<Step3Publish
|
||
headingRef={stepHeadingRef}
|
||
primaryFile={primaryFile}
|
||
primaryPreviewUrl={primaryPreviewUrl}
|
||
isArchive={isArchive}
|
||
screenshots={screenshots}
|
||
fileMetadata={fileMetadata}
|
||
metadata={metadata}
|
||
canPublish={canPublish}
|
||
uploadReady={uploadReady}
|
||
/>
|
||
)
|
||
}
|
||
|
||
// ── 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 || 'Complete required metadata.'
|
||
return machine.error || 'Publish is available when upload is ready and rights are confirmed.'
|
||
})()
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────
|
||
return (
|
||
<section
|
||
ref={stepContentRef}
|
||
className="space-y-6 pb-24 text-white lg:pb-0"
|
||
data-is-archive={isArchive ? 'true' : 'false'}
|
||
>
|
||
{/* Restored draft banner */}
|
||
{showRestoredBanner && (
|
||
<div className="rounded-xl ring-1 ring-sky-300/25 bg-sky-500/10 px-4 py-2.5 text-sm text-sky-100">
|
||
<div className="flex items-center justify-between gap-3">
|
||
<span>Draft restored. Continue from your previous upload session.</span>
|
||
<button
|
||
type="button"
|
||
onClick={() => setShowRestoredBanner(false)}
|
||
className="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>
|
||
)}
|
||
|
||
{/* Step indicator */}
|
||
<UploadStepper
|
||
steps={wizardSteps}
|
||
activeStep={activeStep}
|
||
highestUnlockedStep={highestUnlockedStep}
|
||
onStepClick={goToStep}
|
||
/>
|
||
|
||
{/* Thin progress bar */}
|
||
<div className="-mt-3 rounded-full bg-white/8 p-0.5">
|
||
<motion.div
|
||
className="h-1.5 rounded-full bg-gradient-to-r from-sky-400/90 via-cyan-300/90 to-emerald-300/90"
|
||
animate={{ width: `${stepProgressPercent}%` }}
|
||
transition={quickTransition}
|
||
/>
|
||
</div>
|
||
|
||
{/* Animated step content */}
|
||
<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}
|
||
>
|
||
<div className="max-w-4xl mx-auto">
|
||
{renderStepContent()}
|
||
</div>
|
||
</motion.div>
|
||
</AnimatePresence>
|
||
|
||
{/* Sticky action bar */}
|
||
<UploadActions
|
||
step={activeStep}
|
||
canStart={canStartUpload && [machineStates.idle, machineStates.error, machineStates.cancelled].includes(machine.state)}
|
||
canContinue={detailsValid}
|
||
canPublish={canPublish}
|
||
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)}
|
||
onBack={() => setActiveStep((s) => Math.max(1, s - 1))}
|
||
onCancel={handleCancel}
|
||
onReset={handleReset}
|
||
onRetry={() => handleRetry(canPublish)}
|
||
onSaveDraft={() => {}}
|
||
showSaveDraft={activeStep === 2}
|
||
resetLabel={isUploadLocked ? 'Reset upload' : 'Reset'}
|
||
mobileSticky
|
||
/>
|
||
</section>
|
||
)
|
||
}
|