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)
This commit is contained in:
2026-03-01 14:56:46 +01:00
parent a875203482
commit 1266f81d35
33 changed files with 3710 additions and 1298 deletions

View File

@@ -0,0 +1,152 @@
import React from 'react'
/**
* CategorySelector
*
* Reusable pill-based category + subcategory selector.
* Renders root categories as pills; when a root with children is selected,
* subcategory pills appear in an animated block below.
*
* @param {object} props
* @param {Array} props.categories Flat list of root-category objects { id, name, children[] }
* @param {string} props.rootCategoryId Currently selected root id
* @param {string} props.subCategoryId Currently selected sub id
* @param {boolean} props.hasContentType Whether a content type is selected (gate)
* @param {string} [props.error] Validation error message
* @param {function} props.onRootChange Called with (rootId: string)
* @param {function} props.onSubChange Called with (subId: string)
* @param {Array} [props.allRoots] All root options (for the hidden accessible select)
* @param {function} [props.onRootChangeAll] Fallback handler with full cross-type info
*/
export default function CategorySelector({
categories = [],
rootCategoryId = '',
subCategoryId = '',
hasContentType = false,
error = '',
onRootChange,
onSubChange,
allRoots = [],
onRootChangeAll,
}) {
const selectedRoot = categories.find((c) => String(c.id) === String(rootCategoryId || '')) ?? null
const hasSubcategories = Boolean(
selectedRoot && Array.isArray(selectedRoot.children) && selectedRoot.children.length > 0
)
if (!hasContentType) {
return (
<div className="rounded-lg bg-white/[0.025] px-3 py-3 text-sm text-white/45 ring-1 ring-white/8">
Select a content type to load categories.
</div>
)
}
if (categories.length === 0) {
return (
<div className="rounded-lg bg-white/[0.025] px-3 py-3 text-sm text-white/45 ring-1 ring-white/8">
No categories available for this content type.
</div>
)
}
return (
<div className="space-y-3">
{/* Root categories */}
<div className="flex flex-wrap gap-2" role="group" aria-label="Category">
{categories.map((root) => {
const active = String(root.id) === String(rootCategoryId || '')
return (
<button
key={root.id}
type="button"
aria-pressed={active}
onClick={() => onRootChange?.(String(root.id))}
className={[
'rounded-full border px-3.5 py-1.5 text-sm transition-all',
active
? 'border-violet-500/70 bg-violet-600/25 text-white shadow-sm'
: 'border-white/10 bg-white/5 text-white/65 hover:border-violet-300/40 hover:bg-violet-400/10 hover:text-white/90',
].join(' ')}
>
{root.name}
</button>
)
})}
</div>
{/* Subcategories (shown when root has children) */}
{hasSubcategories && (
<div className="rounded-xl ring-1 ring-white/8 bg-white/[0.025] p-3">
<p className="mb-2 text-[11px] uppercase tracking-wide text-white/45">
Subcategory for <span className="text-white/70">{selectedRoot.name}</span>
</p>
<div className="flex flex-wrap gap-2" role="group" aria-label="Subcategory">
{selectedRoot.children.map((sub) => {
const active = String(sub.id) === String(subCategoryId || '')
return (
<button
key={sub.id}
type="button"
aria-pressed={active}
onClick={() => onSubChange?.(String(sub.id))}
className={[
'rounded-full border px-3 py-1 text-sm transition-all',
active
? 'border-cyan-500/70 bg-cyan-600/20 text-white shadow-sm'
: 'border-white/10 bg-white/5 text-white/60 hover:border-cyan-300/40 hover:bg-cyan-400/10 hover:text-white/85',
].join(' ')}
>
{sub.name}
</button>
)
})}
</div>
</div>
)}
{/* Accessible hidden select (screen readers / fallback) */}
<div className="sr-only">
<label htmlFor="category-root-select">Root category</label>
<select
id="category-root-select"
value={String(rootCategoryId || '')}
onChange={(e) => {
const nextRootId = String(e.target.value || '')
if (onRootChangeAll) {
const matched = allRoots.find((r) => String(r.id) === nextRootId)
onRootChangeAll(nextRootId, matched?.contentTypeValue ?? null)
} else {
onRootChange?.(nextRootId)
}
}}
>
<option value="">Select root category</option>
{allRoots.map((root) => (
<option key={root.id} value={String(root.id)}>{root.name}</option>
))}
</select>
{hasSubcategories && (
<>
<label htmlFor="category-sub-select">Subcategory</label>
<select
id="category-sub-select"
value={String(subCategoryId || '')}
onChange={(e) => onSubChange?.(String(e.target.value || ''))}
>
<option value="">Select subcategory</option>
{selectedRoot.children.map((sub) => (
<option key={sub.id} value={String(sub.id)}>{sub.name}</option>
))}
</select>
</>
)}
</div>
{error && (
<p className="mt-1 text-xs text-red-300" role="alert">{error}</p>
)}
</div>
)
}

View File

@@ -0,0 +1,88 @@
import React from 'react'
import { getContentTypeValue, getContentTypeVisualKey } from '../../lib/uploadUtils'
/**
* ContentTypeSelector
*
* Reusable mascot-icon content-type picker.
* Displays each content type as a card with a mascot icon.
*
* @param {object} props
* @param {Array} props.contentTypes List of content type objects from API
* @param {string} props.selected Currently selected type value
* @param {string} [props.error] Validation error message
* @param {function} props.onChange Called with new type value string
*/
export default function ContentTypeSelector({ contentTypes = [], selected = '', error = '', onChange }) {
if (!Array.isArray(contentTypes) || contentTypes.length === 0) {
return (
<div className="rounded-xl ring-1 ring-white/10 bg-white/5 px-4 py-3 text-sm text-white/60">
No content types available.
</div>
)
}
return (
<div>
<div className="flex gap-3 overflow-x-auto py-2 no-scrollbar">
{contentTypes.map((ct) => {
const typeValue = getContentTypeValue(ct)
const active = String(typeValue) === String(selected || '')
const visualKey = getContentTypeVisualKey(ct)
const iconPath = `/gfx/mascot_${visualKey}.webp`
return (
<button
key={typeValue || ct.name}
type="button"
onClick={() => onChange?.(String(typeValue))}
aria-pressed={active}
className={[
'group flex flex-col items-center gap-2 min-w-[96px] rounded-xl border px-3 py-2.5',
'transition-all duration-150 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-400/50',
active
? 'border-emerald-400/70 bg-emerald-400/15 shadow-lg shadow-emerald-900/30 scale-105'
: 'border-white/10 bg-white/[0.03] hover:border-white/20 hover:bg-white/[0.06] hover:-translate-y-0.5 hover:scale-[1.03]',
].join(' ')}
>
{/* Mascot icon */}
<div className="h-16 w-16 rounded-full overflow-hidden flex items-center justify-center">
<img
src={iconPath}
alt={`${ct.name || 'Content type'} icon`}
loading="lazy"
decoding="async"
width={64}
height={64}
className={[
'h-full w-full object-contain transition-all duration-200',
active
? 'grayscale-0 opacity-100'
: 'grayscale opacity-40 group-hover:grayscale-0 group-hover:opacity-85',
].join(' ')}
onError={(e) => {
if (!e.currentTarget.src.includes('mascot_other.webp')) {
e.currentTarget.src = '/gfx/mascot_other.webp'
}
}}
/>
</div>
{/* Label */}
<span className={`text-xs font-semibold text-center leading-snug ${active ? 'text-white' : 'text-white/60 group-hover:text-white/85'}`}>
{ct.name || 'Type'}
</span>
{/* Active indicator bar */}
<div className={`h-0.5 w-8 rounded-full transition-all ${active ? 'bg-emerald-400/80' : 'bg-white/10 group-hover:bg-white/25'}`} />
</button>
)
})}
</div>
{error && (
<p className="mt-2 text-xs text-red-300" role="alert">{error}</p>
)}
</div>
)
}

View File

@@ -116,9 +116,9 @@ export default function ScreenshotUploader({
animate={prefersReducedMotion ? {} : { opacity: 1, scale: 1 }}
exit={prefersReducedMotion ? {} : { opacity: 0, scale: 0.96 }}
transition={quickTransition}
className="rounded-lg border border-white/50 bg-white/5 p-2 text-xs"
className="rounded-lg ring-1 ring-white/10 bg-white/5 p-2 text-xs"
>
<div className="flex h-40 w-40 items-center justify-center overflow-hidden rounded-md border border-white/50 bg-black/25">
<div className="flex h-40 w-40 items-center justify-center overflow-hidden rounded-md ring-1 ring-white/10 bg-black/25">
<img
src={item.url}
alt={`Screenshot ${index + 1}`}

View File

@@ -67,7 +67,7 @@ export default function UploadDropzone({
}
return (
<section className={`rounded-xl border bg-gradient-to-br p-0 shadow-[0_12px_32px_rgba(0,0,0,0.35)] transition-colors ${invalid ? 'border-red-400/45 from-red-500/10 to-slate-900/45' : 'border-white/50 from-slate-900/80 to-slate-900/50'}`}>
<section className={`rounded-xl bg-gradient-to-br p-0 shadow-[0_12px_32px_rgba(0,0,0,0.35)] transition-colors ${invalid ? 'ring-1 ring-red-400/40 from-red-500/10 to-slate-900/45' : 'ring-1 ring-white/10 from-slate-900/80 to-slate-900/50'}`}>
{/* Intended props: file, dragState, accept, onDrop, onBrowse, onReset, disabled */}
<motion.div
data-testid="upload-dropzone"
@@ -100,7 +100,7 @@ export default function UploadDropzone({
}}
animate={prefersReducedMotion ? undefined : { scale: dragging ? 1.01 : 1 }}
transition={dragTransition}
className={`group rounded-xl border-2 border-dashed border-white/50 py-6 px-4 text-center transition hover:border-accent/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-300/70 ${locked ? 'cursor-default bg-white/5 opacity-75' : 'cursor-pointer'} ${invalid ? 'border-red-300/70 bg-red-500/10 shadow-[0_0_0_1px_rgba(248,113,113,0.2)]' : dragging ? 'border-cyan-300 bg-cyan-500/20 shadow-[0_0_0_1px_rgba(103,232,249,0.35)]' : locked ? 'bg-white/5' : 'bg-sky-500/5 hover:bg-sky-500/12'}`}
className={`group rounded-xl border-2 border-dashed border-white/15 py-6 px-4 text-center transition hover:border-accent/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-300/70 ${locked ? 'cursor-default bg-white/5 opacity-75' : 'cursor-pointer'} ${invalid ? 'border-red-300/70 bg-red-500/10 shadow-[0_0_0_1px_rgba(248,113,113,0.2)]' : dragging ? 'border-cyan-300 bg-cyan-500/20 shadow-[0_0_0_1px_rgba(103,232,249,0.35)]' : locked ? 'bg-white/5' : 'bg-sky-500/5 hover:bg-sky-500/12'}`}
>
<h3 className="mt-3 text-sm font-semibold text-white">{title}</h3>
<p className="mt-1 text-xs text-soft">{description}</p>
@@ -155,7 +155,7 @@ export default function UploadDropzone({
/>
{(previewUrl || (fileMeta && String(fileMeta.type || '').startsWith('image/'))) && (
<div className="mt-3 rounded-lg border border-white/50 bg-black/25 px-3 py-2 text-left text-xs text-white/80">
<div className="mt-3 rounded-lg ring-1 ring-white/10 bg-black/25 px-3 py-2 text-left text-xs text-white/80">
<div className="font-medium text-white/85">Selected file</div>
<div className="mt-1 truncate">{fileName || fileHint}</div>
{fileMeta && (

View File

@@ -15,9 +15,9 @@ export default function UploadPreview({
invalid = false,
}) {
return (
<section className={`rounded-xl border bg-gradient-to-br p-5 shadow-[0_12px_32px_rgba(0,0,0,0.35)] transition-colors sm:p-6 ${invalid ? 'border-red-400/45 from-red-500/10 to-slate-900/45' : 'border-white/50 from-slate-900/80 to-slate-900/45'}`}>
<section className={`rounded-xl bg-gradient-to-br p-5 shadow-[0_12px_32px_rgba(0,0,0,0.35)] transition-colors sm:p-6 ${invalid ? 'ring-1 ring-red-400/40 from-red-500/10 to-slate-900/45' : 'ring-1 ring-white/10 from-slate-900/80 to-slate-900/45'}`}>
{/* Intended props: file, previewUrl, isArchive, dimensions, fileSize, format, warning */}
<div className={`rounded-xl border p-4 transition-colors ${invalid ? 'border-red-300/45 bg-red-500/5' : 'border-white/50 bg-black/25'}`}>
<div className={`rounded-xl ring-1 p-4 transition-colors ${invalid ? 'ring-red-300/40 bg-red-500/5' : 'ring-white/8 bg-black/25'}`}>
<div className="flex items-center justify-between gap-3">
<h3 className="text-lg font-semibold text-white">{title}</h3>
<span className="rounded-full border border-white/15 bg-white/5 px-2.5 py-1 text-[11px] text-white/65">

View File

@@ -60,7 +60,7 @@ export default function UploadProgress({
const progressValue = Math.max(0, Math.min(100, Number(progress) || 0))
return (
<header className="rounded-xl border border-white/50 bg-gradient-to-br from-slate-900/80 to-slate-900/50 p-5 shadow-[0_12px_40px_rgba(0,0,0,0.35)] sm:p-6">
<header className="rounded-xl ring-1 ring-white/10 bg-gradient-to-br from-slate-900/80 to-slate-900/50 p-5 shadow-[0_12px_40px_rgba(0,0,0,0.35)] sm:p-6">
{/* Intended props: step, steps, phase, badge, progress, statusMessage */}
<div className="flex flex-wrap items-center justify-between gap-3">
<div>

View File

@@ -1,5 +1,5 @@
import React from 'react'
import TagInput from '../tags/TagInput'
import TagPicker from '../tags/TagPicker'
import Checkbox from '../../Components/ui/Checkbox'
export default function UploadSidebar({
@@ -62,16 +62,14 @@ export default function UploadSidebar({
<h4 className="text-sm font-semibold text-white">Tags</h4>
<p className="mt-1 text-xs text-white/60">Use keywords people would search for. Press Enter, comma, or Tab to add a tag.</p>
</div>
<TagInput
<TagPicker
value={metadata.tags}
onChange={(nextTags) => onChangeTags?.(nextTags)}
suggestedTags={suggestedTags}
maxTags={15}
minLength={2}
maxLength={32}
searchEndpoint="/api/tags/search"
popularEndpoint="/api/tags/popular"
placeholder="Type tags (e.g. cyberpunk, city)"
error={errors.tags}
/>
</section>

View File

@@ -4,7 +4,7 @@ export default function UploadStepper({ steps = [], activeStep = 1, highestUnloc
const safeActive = Math.max(1, Math.min(steps.length || 1, activeStep))
return (
<nav aria-label="Upload steps" className="rounded-xl border border-white/50 bg-slate-900/70 px-3 py-3 sm:px-4">
<nav aria-label="Upload steps" className="rounded-xl ring-1 ring-white/10 bg-slate-900/70 px-3 py-3 sm:px-4">
<ol className="flex flex-nowrap items-center gap-3 overflow-x-auto sm:gap-4">
{steps.map((step, index) => {
const number = index + 1
@@ -19,7 +19,7 @@ export default function UploadStepper({ steps = [], activeStep = 1, highestUnloc
: isComplete
? 'border-emerald-300/30 bg-emerald-500/15 text-emerald-100 hover:bg-emerald-500/25'
: isLocked
? 'cursor-default border-white/50 bg-white/5 text-white/40'
? 'cursor-default border-white/10 bg-white/5 text-white/40'
: 'border-white/10 bg-white/5 text-white/80 hover:bg-white/10'
const circleClass = isComplete

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,129 @@
import React from 'react'
import UploadDropzone from '../UploadDropzone'
import ScreenshotUploader from '../ScreenshotUploader'
import UploadProgress from '../UploadProgress'
import { machineStates } from '../../../hooks/upload/useUploadMachine'
import { getProcessingTransparencyLabel } from '../../../lib/uploadUtils'
/**
* Step1FileUpload
*
* Step 1 of the upload wizard: file selection + live upload progress.
* Shows the dropzone, optional screenshot uploader (archives),
* and the progress panel once an upload is in flight.
*/
export default function Step1FileUpload({
headingRef,
// File state
primaryFile,
primaryPreviewUrl,
primaryErrors,
primaryWarnings,
fileMetadata,
fileSelectionLocked,
onPrimaryFileChange,
// Archive screenshots
isArchive,
screenshots,
screenshotErrors,
screenshotPerFileErrors,
onScreenshotsChange,
// Machine state
machine,
showProgress,
onRetry,
onReset,
}) {
const processingTransparencyLabel = getProcessingTransparencyLabel(
machine.processingStatus,
machine.state
)
const progressStatus = (() => {
if (machine.state === machineStates.ready_to_publish) return 'Ready'
if (machine.state === machineStates.uploading) return 'Uploading'
if (machine.state === machineStates.processing || machine.state === machineStates.finishing) return 'Processing'
return 'Idle'
})()
return (
<div className="bg-panel/80 backdrop-blur rounded-2xl shadow-xl shadow-black/40 ring-1 ring-white/10 p-6 space-y-5">
{/* Step header */}
<div className="rounded-xl bg-white/[0.04] px-4 py-3 ring-1 ring-white/8">
<h2
ref={headingRef}
tabIndex={-1}
className="text-lg font-semibold text-white focus:outline-none"
>
Upload your artwork
</h2>
<p className="mt-1 text-sm text-white/60">
Drop or browse a file. Validation runs immediately. Upload starts when you click&nbsp;
<span className="text-white/80">Start upload</span>.
</p>
</div>
{/* Locked notice */}
{fileSelectionLocked && (
<div className="flex items-center gap-2 rounded-lg bg-amber-500/10 px-3 py-2 text-xs text-amber-100 ring-1 ring-amber-300/30">
<svg className="h-3.5 w-3.5 shrink-0" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path fillRule="evenodd" d="M10 1a4.5 4.5 0 00-4.5 4.5V9H5a2 2 0 00-2 2v6a2 2 0 002 2h10a2 2 0 002-2v-6a2 2 0 00-2-2h-.5V5.5A4.5 4.5 0 0010 1zm3 8V5.5a3 3 0 10-6 0V9h6z" clipRule="evenodd" />
</svg>
File is locked after upload. Reset to change.
</div>
)}
{/* Primary dropzone */}
<UploadDropzone
title="Upload your artwork file"
description="Drag & drop or click to browse. Accepted: JPG, PNG, WEBP, ZIP, RAR, 7Z."
fileName={primaryFile?.name || ''}
previewUrl={primaryPreviewUrl}
fileMeta={fileMetadata}
fileHint="No file selected"
invalid={primaryErrors.length > 0}
errors={primaryErrors}
showLooksGood={Boolean(primaryFile) && primaryErrors.length === 0}
looksGoodText="Looks good"
locked={fileSelectionLocked}
onPrimaryFileChange={(file) => {
if (fileSelectionLocked) return
onPrimaryFileChange(file || null)
}}
/>
{/* Screenshots (archives only) */}
<ScreenshotUploader
title="Archive screenshots"
description="We need at least 1 screenshot to generate thumbnails and analyze content."
visible={isArchive}
files={screenshots}
min={1}
max={5}
perFileErrors={screenshotPerFileErrors}
errors={screenshotErrors}
invalid={isArchive && screenshotErrors.length > 0}
showLooksGood={isArchive && screenshots.length > 0 && screenshotErrors.length === 0}
looksGoodText="Looks good"
onFilesChange={onScreenshotsChange}
/>
{/* Progress panel */}
{showProgress && (
<UploadProgress
title="Upload progress"
description="Upload and processing status"
status={progressStatus}
progress={machine.progress}
state={machine.state}
processingStatus={machine.processingStatus}
isCancelling={machine.isCancelling}
error={machine.error}
processingLabel={processingTransparencyLabel}
onRetry={onRetry}
onReset={onReset}
/>
)}
</div>
)
}

View File

@@ -0,0 +1,163 @@
import React from 'react'
import ContentTypeSelector from '../ContentTypeSelector'
import CategorySelector from '../CategorySelector'
import UploadSidebar from '../UploadSidebar'
/**
* Step2Details
*
* Step 2 of the upload wizard: artwork metadata.
* Shows uploaded-asset summary, content type selector,
* category/subcategory selectors, tags, description, and rights.
*/
export default function Step2Details({
headingRef,
// Asset summary
primaryFile,
primaryPreviewUrl,
isArchive,
fileMetadata,
screenshots,
// Content type + category
contentTypes,
metadata,
metadataErrors,
filteredCategoryTree,
allRootCategoryOptions,
requiresSubCategory,
onContentTypeChange,
onRootCategoryChange,
onSubCategoryChange,
// Sidebar (title / tags / description / rights)
suggestedTags,
onChangeTitle,
onChangeTags,
onChangeDescription,
onToggleRights,
}) {
return (
<div className="bg-panel/80 backdrop-blur rounded-2xl shadow-xl shadow-black/40 ring-1 ring-white/10 p-6 space-y-5">
{/* Step header */}
<div className="rounded-xl bg-white/[0.04] px-4 py-3 ring-1 ring-white/8">
<h2
ref={headingRef}
tabIndex={-1}
className="text-lg font-semibold text-white focus:outline-none"
>
Artwork details
</h2>
<p className="mt-1 text-sm text-white/60">
Complete required metadata and rights confirmation before publishing.
</p>
</div>
{/* Uploaded asset summary */}
<div className="rounded-xl ring-1 ring-white/8 bg-white/[0.025] p-4">
<p className="mb-3 text-[11px] uppercase tracking-wide text-white/45">Uploaded asset</p>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
{/* Thumbnail / Archive icon */}
{primaryPreviewUrl && !isArchive ? (
<div className="flex h-[120px] w-[120px] items-center justify-center overflow-hidden rounded-xl ring-1 ring-white/10 bg-black/30 shrink-0">
<img
src={primaryPreviewUrl}
alt="Uploaded artwork thumbnail"
className="max-h-full max-w-full object-contain"
loading="lazy"
decoding="async"
width={120}
height={120}
/>
</div>
) : (
<div className="grid h-[120px] w-[120px] place-items-center rounded-xl ring-1 ring-white/10 bg-white/5 shrink-0">
<svg className="h-8 w-8 text-white/30" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
</svg>
</div>
)}
{/* File metadata */}
<div className="min-w-0 space-y-1">
<p className="truncate text-sm font-medium text-white">
{primaryFile?.name || 'Primary file'}
</p>
<p className="text-xs text-white/50">
{isArchive
? `Archive · ${screenshots.length} screenshot${screenshots.length !== 1 ? 's' : ''}`
: fileMetadata.resolution !== '—'
? `${fileMetadata.resolution} · ${fileMetadata.size}`
: fileMetadata.size}
</p>
<span className={`inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] ${isArchive ? 'border-amber-400/40 bg-amber-400/10 text-amber-200' : 'border-sky-400/40 bg-sky-400/10 text-sky-200'}`}>
{isArchive ? 'Archive' : 'Image'}
</span>
</div>
</div>
</div>
{/* Content type selector */}
<section className="rounded-xl ring-1 ring-white/10 bg-gradient-to-br from-white/[0.04] to-white/[0.01] p-4 sm:p-5">
<div className="mb-4 flex flex-wrap items-center justify-between gap-2">
<div>
<h3 className="text-sm font-semibold text-white">Content type</h3>
<p className="mt-0.5 text-xs text-white/55">Choose what kind of artwork this is.</p>
</div>
<span className="rounded-full border border-sky-400/35 bg-sky-400/10 px-2.5 py-0.5 text-[11px] uppercase tracking-widest text-sky-300">
Step 2a
</span>
</div>
<ContentTypeSelector
contentTypes={contentTypes}
selected={metadata.contentType}
error={metadataErrors.contentType}
onChange={onContentTypeChange}
/>
</section>
{/* Category selector */}
<section className="rounded-xl ring-1 ring-white/10 bg-gradient-to-br from-white/[0.04] to-white/[0.01] p-4 sm:p-5">
<div className="mb-4 flex flex-wrap items-center justify-between gap-2">
<div>
<h3 className="text-sm font-semibold text-white">Category</h3>
<p className="mt-0.5 text-xs text-white/55">
{requiresSubCategory ? 'Select a category, then a subcategory.' : 'Select a category.'}
</p>
</div>
<span className="rounded-full border border-violet-400/35 bg-violet-400/10 px-2.5 py-0.5 text-[11px] uppercase tracking-widest text-violet-300">
Step 2b
</span>
</div>
<CategorySelector
categories={filteredCategoryTree}
rootCategoryId={metadata.rootCategoryId}
subCategoryId={metadata.subCategoryId}
hasContentType={Boolean(metadata.contentType)}
error={metadataErrors.category}
onRootChange={onRootCategoryChange}
onSubChange={onSubCategoryChange}
allRoots={allRootCategoryOptions}
onRootChangeAll={(rootId, contentTypeValue) => {
if (contentTypeValue) {
onContentTypeChange(contentTypeValue)
}
onRootCategoryChange(rootId)
}}
/>
</section>
{/* Title, tags, description, rights */}
<UploadSidebar
showHeader={false}
metadata={metadata}
suggestedTags={suggestedTags}
errors={metadataErrors}
onChangeTitle={onChangeTitle}
onChangeTags={onChangeTags}
onChangeDescription={onChangeDescription}
onToggleRights={onToggleRights}
/>
</div>
)
}

View File

@@ -0,0 +1,159 @@
import React from 'react'
import { motion, useReducedMotion } from 'framer-motion'
/**
* PublishCheckBadge a single status item for the review section
*/
function PublishCheckBadge({ label, ok }) {
return (
<span
className={[
'inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs',
ok
? 'border-emerald-300/40 bg-emerald-500/12 text-emerald-100'
: 'border-white/15 bg-white/5 text-white/55',
].join(' ')}
>
<span aria-hidden="true">{ok ? '✓' : '○'}</span>
{label}
</span>
)
}
/**
* Step3Publish
*
* Step 3 of the upload wizard: review summary and publish action.
* Shows a compact artwork preview, metadata summary, and readiness badges.
*/
export default function Step3Publish({
headingRef,
// Asset
primaryFile,
primaryPreviewUrl,
isArchive,
screenshots,
fileMetadata,
// Metadata
metadata,
// Readiness
canPublish,
uploadReady,
}) {
const prefersReducedMotion = useReducedMotion()
const quickTransition = prefersReducedMotion ? { duration: 0 } : { duration: 0.2, ease: 'easeOut' }
const hasPreview = Boolean(primaryPreviewUrl && !isArchive)
const checks = [
{ label: 'File uploaded', ok: uploadReady },
{ label: 'Scan passed', ok: uploadReady },
{ label: 'Preview ready', ok: hasPreview || (isArchive && screenshots.length > 0) },
{ label: 'Rights confirmed', ok: Boolean(metadata.rightsAccepted) },
]
return (
<div className="bg-panel/80 backdrop-blur rounded-2xl shadow-xl shadow-black/40 ring-1 ring-white/10 p-6 space-y-5">
{/* Step header */}
<div className="rounded-xl bg-white/[0.04] px-4 py-3 ring-1 ring-white/8">
<h2
ref={headingRef}
tabIndex={-1}
className="text-lg font-semibold text-white focus:outline-none"
>
Review & publish
</h2>
<p className="mt-1 text-sm text-white/60">
Everything looks good? Hit <span className="text-white/85">Publish</span> to make your artwork live.
</p>
</div>
{/* Preview + summary */}
<div className="rounded-xl ring-1 ring-white/8 bg-white/[0.025] p-4">
<div className="flex flex-col gap-4 sm:flex-row">
{/* Artwork thumbnail */}
<div className="shrink-0">
{hasPreview ? (
<div className="flex h-[140px] w-[140px] items-center justify-center overflow-hidden rounded-xl ring-1 ring-white/10 bg-black/30">
<img
src={primaryPreviewUrl}
alt="Artwork preview"
className="max-h-full max-w-full object-contain"
loading="lazy"
decoding="async"
width={140}
height={140}
/>
</div>
) : (
<div className="grid h-[140px] w-[140px] place-items-center rounded-xl ring-1 ring-white/10 bg-white/5 text-white/40">
<svg className="h-8 w-8" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
</svg>
</div>
)}
</div>
{/* Summary */}
<div className="min-w-0 flex-1 space-y-2.5">
<p className="text-base font-semibold text-white leading-snug">
{metadata.title || <span className="text-white/45 italic">Untitled artwork</span>}
</p>
<div className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-white/55">
{metadata.contentType && (
<span className="capitalize">Type: <span className="text-white/75">{metadata.contentType}</span></span>
)}
{metadata.rootCategoryId && (
<span>Category: <span className="text-white/75">{metadata.rootCategoryId}</span></span>
)}
{metadata.subCategoryId && (
<span>Sub: <span className="text-white/75">{metadata.subCategoryId}</span></span>
)}
</div>
<div className="flex flex-wrap gap-x-4 gap-y-1 text-xs text-white/55">
<span>Tags: <span className="text-white/75">{(metadata.tags || []).length}</span></span>
{!isArchive && fileMetadata?.resolution && fileMetadata.resolution !== '—' && (
<span>Resolution: <span className="text-white/75">{fileMetadata.resolution}</span></span>
)}
{isArchive && (
<span>Screenshots: <span className="text-white/75">{screenshots.length}</span></span>
)}
</div>
{metadata.description && (
<p className="line-clamp-2 text-xs text-white/50">{metadata.description}</p>
)}
</div>
</div>
</div>
{/* Readiness badges */}
<div>
<p className="mb-2.5 text-xs uppercase tracking-wide text-white/40">Readiness checks</p>
<div className="flex flex-wrap gap-2">
{checks.map((check) => (
<PublishCheckBadge key={check.label} label={check.label} ok={check.ok} />
))}
</div>
</div>
{/* Not-ready notice */}
{!canPublish && (
<motion.div
initial={prefersReducedMotion ? false : { opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
transition={quickTransition}
className="rounded-lg ring-1 ring-amber-300/25 bg-amber-500/8 px-4 py-3 text-sm text-amber-100/85"
>
{!uploadReady
? 'Waiting for upload processing to complete…'
: !metadata.rightsAccepted
? 'Please confirm rights in the Details step to enable publishing.'
: 'Complete all required fields to enable publishing.'}
</motion.div>
)}
</div>
)
}