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,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>
)
}