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