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)
142 lines
6.3 KiB
JavaScript
142 lines
6.3 KiB
JavaScript
import React from 'react'
|
||
import { AnimatePresence, motion, useReducedMotion } from 'framer-motion'
|
||
|
||
export default function UploadProgress({
|
||
title = 'Upload Artwork',
|
||
description = 'Preload → Details → Publish',
|
||
progress = 24,
|
||
status = 'Idle',
|
||
state,
|
||
processingStatus,
|
||
processingLabel = '',
|
||
isCancelling = false,
|
||
error = '',
|
||
onRetry,
|
||
onReset,
|
||
}) {
|
||
const prefersReducedMotion = useReducedMotion()
|
||
|
||
const getRecoveryHint = () => {
|
||
const text = String(error || '').toLowerCase()
|
||
if (!text) return ''
|
||
if (text.includes('network') || text.includes('timeout') || text.includes('failed to fetch')) {
|
||
return 'Your connection may be unstable. Retry now or wait a moment and try again.'
|
||
}
|
||
if (text.includes('busy') || text.includes('unavailable') || text.includes('503') || text.includes('server')) {
|
||
return 'The server looks busy right now. Waiting 20–30 seconds before retrying can help.'
|
||
}
|
||
if (text.includes('validation') || text.includes('invalid') || text.includes('too large') || text.includes('format')) {
|
||
return 'Please review the file requirements, then update your selection and try again.'
|
||
}
|
||
return 'You can retry now, or reset this upload and start again with the same files.'
|
||
}
|
||
|
||
const recoveryHint = getRecoveryHint()
|
||
|
||
const resolvedStatus = (() => {
|
||
if (isCancelling) return 'Processing'
|
||
if (state === 'error') return 'Error'
|
||
if (processingStatus === 'ready') return 'Ready'
|
||
if (state === 'uploading') return 'Uploading'
|
||
if (state === 'processing' || state === 'finishing' || state === 'publishing') return 'Processing'
|
||
if (status) return status
|
||
return 'Idle'
|
||
})()
|
||
|
||
const statusTheme = {
|
||
Idle: 'border-slate-400/35 bg-slate-400/15 text-slate-200',
|
||
Uploading: 'border-sky-400/35 bg-sky-400/15 text-sky-100',
|
||
Processing: 'border-amber-400/35 bg-amber-400/15 text-amber-100',
|
||
Ready: 'border-emerald-400/35 bg-emerald-400/15 text-emerald-100',
|
||
Error: 'border-red-400/35 bg-red-400/15 text-red-100',
|
||
}
|
||
|
||
const quickTransition = prefersReducedMotion
|
||
? { duration: 0 }
|
||
: { duration: 0.2, ease: 'easeOut' }
|
||
|
||
const stepLabels = ['Preload', 'Details', 'Publish']
|
||
const stepIndex = progress >= 100 ? 2 : progress >= 34 ? 1 : 0
|
||
const progressValue = Math.max(0, Math.min(100, Number(progress) || 0))
|
||
|
||
return (
|
||
<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>
|
||
<h1 className="text-2xl font-semibold tracking-tight text-white sm:text-3xl">{title}</h1>
|
||
<p className="mt-1 text-sm text-white/65">{description}</p>
|
||
</div>
|
||
|
||
<span
|
||
className={`inline-flex items-center rounded-full border px-3 py-1 text-xs font-medium ${statusTheme[resolvedStatus] || statusTheme.Idle}`}
|
||
>
|
||
{resolvedStatus}
|
||
</span>
|
||
</div>
|
||
|
||
<div className="mt-4 flex items-center gap-2 overflow-x-auto">
|
||
{stepLabels.map((label, idx) => {
|
||
const active = idx <= stepIndex
|
||
return (
|
||
<div key={label} className="flex items-center gap-2">
|
||
<span className={`rounded-full border px-3 py-1 text-xs ${active ? 'border-emerald-400/40 bg-emerald-400/20 text-emerald-100' : 'border-white/15 bg-white/5 text-white/55'}`}>
|
||
{label}
|
||
</span>
|
||
{idx < stepLabels.length - 1 && <span className="text-white/30">→</span>}
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
|
||
<div className="mt-4">
|
||
<div className="h-2 overflow-hidden rounded-full bg-white/10">
|
||
<motion.div
|
||
className="h-full origin-left rounded-full bg-gradient-to-r from-sky-400/90 via-cyan-300/90 to-emerald-300/90"
|
||
initial={false}
|
||
animate={{ scaleX: progressValue / 100 }}
|
||
transition={quickTransition}
|
||
/>
|
||
</div>
|
||
<p className="mt-2 text-right text-xs text-white/55">{Math.round(progressValue)}%</p>
|
||
</div>
|
||
|
||
<AnimatePresence initial={false}>
|
||
{(state === 'processing' || state === 'finishing' || state === 'publishing' || isCancelling) && (
|
||
<motion.div
|
||
key="processing-note"
|
||
initial={prefersReducedMotion ? false : { opacity: 0, y: 4 }}
|
||
animate={prefersReducedMotion ? {} : { opacity: 1, y: 0 }}
|
||
exit={prefersReducedMotion ? {} : { opacity: 0, y: -4 }}
|
||
transition={quickTransition}
|
||
className="mt-3 rounded-lg border border-cyan-300/25 bg-cyan-500/10 px-3 py-2 text-xs text-cyan-100"
|
||
>
|
||
{processingLabel || 'Analyzing content'} — you can continue editing details while processing finishes.
|
||
</motion.div>
|
||
)}
|
||
</AnimatePresence>
|
||
|
||
<AnimatePresence initial={false}>
|
||
{error && (
|
||
<motion.div
|
||
key="progress-error"
|
||
initial={prefersReducedMotion ? false : { opacity: 0, y: 4 }}
|
||
animate={prefersReducedMotion ? {} : { opacity: 1, y: 0 }}
|
||
exit={prefersReducedMotion ? {} : { opacity: 0, y: -4 }}
|
||
transition={quickTransition}
|
||
className="mt-3 rounded-lg border border-rose-200/25 bg-rose-400/8 px-3 py-2"
|
||
>
|
||
<p className="text-sm font-medium text-rose-100">Something went wrong while uploading.</p>
|
||
<p className="mt-1 text-xs text-rose-100/90">You can retry safely. {error}</p>
|
||
{recoveryHint && <p className="mt-1 text-xs text-rose-100/80">{recoveryHint}</p>}
|
||
<div className="mt-2 flex flex-wrap gap-2">
|
||
<button type="button" onClick={onRetry} className="rounded-md border border-rose-200/35 bg-rose-400/10 px-2.5 py-1 text-xs text-rose-100 transition hover:bg-rose-400/15 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-rose-200/75">Retry</button>
|
||
<button type="button" onClick={onReset} className="rounded-md border border-white/25 bg-white/10 px-2.5 py-1 text-xs text-white transition hover:bg-white/20 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/60">Reset</button>
|
||
</div>
|
||
</motion.div>
|
||
)}
|
||
</AnimatePresence>
|
||
</header>
|
||
)
|
||
}
|