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:
@@ -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 · {formatBytes(v.file_size)}</p>
|
||||
)}
|
||||
{v.change_note && (
|
||||
<p className="text-xs text-slate-300 mt-1 italic">“{v.change_note}”</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 version—nothing is deleted.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</StudioLayout>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user