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

@@ -63,6 +63,16 @@ export default function StudioArtworkEdit() {
width: artwork?.width || 0,
height: artwork?.height || 0,
})
const [versionCount, setVersionCount] = useState(artwork?.version_count ?? 1)
const [requiresReapproval, setRequiresReapproval] = useState(artwork?.requires_reapproval ?? false)
const [changeNote, setChangeNote] = useState('')
const [showChangeNote, setShowChangeNote] = useState(false)
// Version history modal state
const [showHistory, setShowHistory] = useState(false)
const [historyData, setHistoryData] = useState(null)
const [historyLoading, setHistoryLoading] = useState(false)
const [restoring, setRestoring] = useState(null) // version id being restored
// --- Tag search ---
const searchTags = useCallback(async (q) => {
@@ -158,6 +168,7 @@ export default function StudioArtworkEdit() {
try {
const fd = new FormData()
fd.append('file', file)
if (changeNote.trim()) fd.append('change_note', changeNote.trim())
const res = await fetch(`/api/studio/artworks/${artwork.id}/replace-file`, {
method: 'POST',
headers: { Accept: 'application/json', 'X-CSRF-TOKEN': getCsrfToken() },
@@ -168,8 +179,12 @@ export default function StudioArtworkEdit() {
if (res.ok && data.thumb_url) {
setThumbUrl(data.thumb_url)
setFileMeta({ name: file.name, size: file.size, width: data.width || 0, height: data.height || 0 })
if (data.version_number) setVersionCount(data.version_number)
if (typeof data.requires_reapproval !== 'undefined') setRequiresReapproval(data.requires_reapproval)
setChangeNote('')
setShowChangeNote(false)
} else {
console.error('File replace failed:', data)
alert(data.error || 'File replacement failed.')
}
} catch (err) {
console.error('File replace failed:', err)
@@ -179,6 +194,47 @@ export default function StudioArtworkEdit() {
}
}
const loadVersionHistory = async () => {
setHistoryLoading(true)
setShowHistory(true)
try {
const res = await fetch(`/api/studio/artworks/${artwork.id}/versions`, {
headers: { Accept: 'application/json', 'X-CSRF-TOKEN': getCsrfToken() },
credentials: 'same-origin',
})
const data = await res.json()
setHistoryData(data)
} catch (err) {
console.error('Failed to load version history:', err)
} finally {
setHistoryLoading(false)
}
}
const handleRestoreVersion = async (versionId) => {
if (!window.confirm('Restore this version? It will be cloned as the new current version.')) return
setRestoring(versionId)
try {
const res = await fetch(`/api/studio/artworks/${artwork.id}/restore/${versionId}`, {
method: 'POST',
headers: { Accept: 'application/json', 'X-CSRF-TOKEN': getCsrfToken() },
credentials: 'same-origin',
})
const data = await res.json()
if (res.ok && data.success) {
alert(data.message)
setVersionCount((n) => n + 1)
setShowHistory(false)
} else {
alert(data.error || 'Restore failed.')
}
} catch (err) {
console.error('Restore failed:', err)
} finally {
setRestoring(null)
}
}
// --- Render ---
return (
<StudioLayout title="Edit Artwork">
@@ -193,7 +249,28 @@ export default function StudioArtworkEdit() {
<div className="max-w-3xl space-y-8">
{/* ── Uploaded Asset ── */}
<section className="bg-nova-900/60 border border-white/10 rounded-2xl p-6">
<h3 className="text-xs font-semibold uppercase tracking-wider text-slate-400 mb-4">Uploaded Asset</h3>
<div className="flex items-center justify-between mb-4">
<h3 className="text-xs font-semibold uppercase tracking-wider text-slate-400">Uploaded Asset</h3>
<div className="flex items-center gap-2">
{requiresReapproval && (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-semibold bg-amber-500/20 text-amber-300 border border-amber-500/30">
<i className="fa-solid fa-triangle-exclamation" /> Under Review
</span>
)}
<span className="inline-flex items-center px-2 py-0.5 rounded-full text-[10px] font-bold bg-accent/20 text-accent border border-accent/30">
v{versionCount}
</span>
{versionCount > 1 && (
<button
type="button"
onClick={loadVersionHistory}
className="text-xs text-slate-400 hover:text-white transition-colors flex items-center gap-1"
>
<i className="fa-solid fa-clock-rotate-left text-[10px]" /> History
</button>
)}
</div>
</div>
<div className="flex items-start gap-5">
{thumbUrl ? (
<img src={thumbUrl} alt={title} className="w-32 h-32 rounded-xl object-cover bg-nova-800 flex-shrink-0" />
@@ -208,16 +285,41 @@ export default function StudioArtworkEdit() {
{fileMeta.width > 0 && (
<p className="text-xs text-slate-400">{fileMeta.width} × {fileMeta.height} px</p>
)}
{showChangeNote && (
<textarea
value={changeNote}
onChange={(e) => setChangeNote(e.target.value)}
rows={2}
maxLength={500}
placeholder="What changed? (optional)"
className="mt-2 w-full px-3 py-2 rounded-lg bg-white/5 border border-white/10 text-white text-xs focus:outline-none focus:ring-2 focus:ring-accent/50 resize-none"
/>
)}
<input ref={fileInputRef} type="file" accept="image/*" className="hidden" onChange={handleFileReplace} />
<button
type="button"
onClick={() => fileInputRef.current?.click()}
disabled={replacing}
className="mt-2 inline-flex items-center gap-1.5 text-xs text-accent hover:text-accent/80 transition-colors disabled:opacity-50"
>
<i className={replacing ? 'fa-solid fa-spinner fa-spin' : 'fa-solid fa-arrow-up-from-bracket'} />
{replacing ? 'Replacing…' : 'Replace file'}
</button>
<div className="flex items-center gap-3 mt-2">
<button
type="button"
onClick={() => {
setShowChangeNote((s) => !s)
if (!showChangeNote) fileInputRef.current?.click()
}}
disabled={replacing}
className="inline-flex items-center gap-1.5 text-xs text-accent hover:text-accent/80 transition-colors disabled:opacity-50"
>
<i className={replacing ? 'fa-solid fa-spinner fa-spin' : 'fa-solid fa-arrow-up-from-bracket'} />
{replacing ? 'Replacing…' : 'Replace file'}
</button>
{showChangeNote && (
<button
type="button"
onClick={() => fileInputRef.current?.click()}
disabled={replacing}
className="inline-flex items-center gap-1.5 text-xs bg-accent/20 hover:bg-accent/30 text-accent px-2.5 py-1 rounded-lg transition-colors disabled:opacity-50"
>
<i className="fa-solid fa-upload" /> Choose file
</button>
)}
</div>
</div>
</div>
</section>
@@ -450,6 +552,94 @@ export default function StudioArtworkEdit() {
</Link>
</div>
</div>
{/* ── Version History Modal ── */}
{showHistory && (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/70 backdrop-blur-sm p-4"
onClick={(e) => { if (e.target === e.currentTarget) setShowHistory(false) }}
>
<div className="bg-nova-900 border border-white/10 rounded-2xl shadow-2xl w-full max-w-lg max-h-[80vh] flex flex-col">
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-white/10">
<h2 className="text-sm font-semibold text-white flex items-center gap-2">
<i className="fa-solid fa-clock-rotate-left text-accent" />
Version History
</h2>
<button
onClick={() => setShowHistory(false)}
className="w-7 h-7 rounded-full hover:bg-white/10 flex items-center justify-center text-slate-400 hover:text-white transition-colors"
>
<i className="fa-solid fa-xmark text-xs" />
</button>
</div>
{/* Body */}
<div className="overflow-y-auto flex-1 sb-scrollbar p-4 space-y-3">
{historyLoading && (
<div className="flex items-center justify-center py-10">
<div className="w-6 h-6 border-2 border-accent/30 border-t-accent rounded-full animate-spin" />
</div>
)}
{!historyLoading && historyData && historyData.versions.map((v) => (
<div
key={v.id}
className={`rounded-xl border p-4 transition-all ${
v.is_current
? 'border-accent/40 bg-accent/10'
: 'border-white/10 bg-white/[0.03] hover:bg-white/[0.06]'
}`}
>
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="text-xs font-bold text-white">v{v.version_number}</span>
{v.is_current && (
<span className="text-[10px] font-semibold px-1.5 py-0.5 rounded-full bg-accent/20 text-accent border border-accent/30">Current</span>
)}
</div>
<p className="text-[11px] text-slate-400">
{v.created_at ? new Date(v.created_at).toLocaleString() : ''}
</p>
{v.width && (
<p className="text-[11px] text-slate-400">{v.width} × {v.height} px &middot; {formatBytes(v.file_size)}</p>
)}
{v.change_note && (
<p className="text-xs text-slate-300 mt-1 italic">&ldquo;{v.change_note}&rdquo;</p>
)}
</div>
{!v.is_current && (
<button
type="button"
disabled={restoring === v.id}
onClick={() => handleRestoreVersion(v.id)}
className="flex-shrink-0 inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-xs font-medium bg-white/5 hover:bg-accent/20 text-slate-300 hover:text-accent border border-white/10 hover:border-accent/30 transition-all disabled:opacity-50"
>
{restoring === v.id
? <><i className="fa-solid fa-spinner fa-spin" /> Restoring</>
: <><i className="fa-solid fa-rotate-left" /> Restore</>
}
</button>
)}
</div>
</div>
))}
{!historyLoading && historyData && historyData.versions.length === 0 && (
<p className="text-sm text-slate-500 text-center py-8">No version history yet.</p>
)}
</div>
{/* Footer */}
<div className="px-6 py-4 border-t border-white/10">
<p className="text-xs text-slate-500">
Older versions are preserved. Restoring creates a new versionnothing is deleted.
</p>
</div>
</div>
</div>
)}
</StudioLayout>
)
}