Files
SkinbaseNova/resources/js/components/upload/UploadWizard.jsx
Gregor Klevze 1266f81d35 feat: upload wizard refactor + vision AI tags + artwork versioning
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)
2026-03-01 14:56:46 +01:00

504 lines
21 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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>
)
}