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:
129
resources/js/components/upload/steps/Step1FileUpload.jsx
Normal file
129
resources/js/components/upload/steps/Step1FileUpload.jsx
Normal 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
|
||||
<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>
|
||||
)
|
||||
}
|
||||
163
resources/js/components/upload/steps/Step2Details.jsx
Normal file
163
resources/js/components/upload/steps/Step2Details.jsx
Normal file
@@ -0,0 +1,163 @@
|
||||
import React from 'react'
|
||||
import ContentTypeSelector from '../ContentTypeSelector'
|
||||
import CategorySelector from '../CategorySelector'
|
||||
import UploadSidebar from '../UploadSidebar'
|
||||
|
||||
/**
|
||||
* Step2Details
|
||||
*
|
||||
* Step 2 of the upload wizard: artwork metadata.
|
||||
* Shows uploaded-asset summary, content type selector,
|
||||
* category/subcategory selectors, tags, description, and rights.
|
||||
*/
|
||||
export default function Step2Details({
|
||||
headingRef,
|
||||
// Asset summary
|
||||
primaryFile,
|
||||
primaryPreviewUrl,
|
||||
isArchive,
|
||||
fileMetadata,
|
||||
screenshots,
|
||||
// Content type + category
|
||||
contentTypes,
|
||||
metadata,
|
||||
metadataErrors,
|
||||
filteredCategoryTree,
|
||||
allRootCategoryOptions,
|
||||
requiresSubCategory,
|
||||
onContentTypeChange,
|
||||
onRootCategoryChange,
|
||||
onSubCategoryChange,
|
||||
// Sidebar (title / tags / description / rights)
|
||||
suggestedTags,
|
||||
onChangeTitle,
|
||||
onChangeTags,
|
||||
onChangeDescription,
|
||||
onToggleRights,
|
||||
}) {
|
||||
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"
|
||||
>
|
||||
Artwork details
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-white/60">
|
||||
Complete required metadata and rights confirmation before publishing.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Uploaded asset summary */}
|
||||
<div className="rounded-xl ring-1 ring-white/8 bg-white/[0.025] p-4">
|
||||
<p className="mb-3 text-[11px] uppercase tracking-wide text-white/45">Uploaded asset</p>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
{/* Thumbnail / Archive icon */}
|
||||
{primaryPreviewUrl && !isArchive ? (
|
||||
<div className="flex h-[120px] w-[120px] items-center justify-center overflow-hidden rounded-xl ring-1 ring-white/10 bg-black/30 shrink-0">
|
||||
<img
|
||||
src={primaryPreviewUrl}
|
||||
alt="Uploaded artwork thumbnail"
|
||||
className="max-h-full max-w-full object-contain"
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
width={120}
|
||||
height={120}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid h-[120px] w-[120px] place-items-center rounded-xl ring-1 ring-white/10 bg-white/5 shrink-0">
|
||||
<svg className="h-8 w-8 text-white/30" 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>
|
||||
)}
|
||||
|
||||
{/* File metadata */}
|
||||
<div className="min-w-0 space-y-1">
|
||||
<p className="truncate text-sm font-medium text-white">
|
||||
{primaryFile?.name || 'Primary file'}
|
||||
</p>
|
||||
<p className="text-xs text-white/50">
|
||||
{isArchive
|
||||
? `Archive · ${screenshots.length} screenshot${screenshots.length !== 1 ? 's' : ''}`
|
||||
: fileMetadata.resolution !== '—'
|
||||
? `${fileMetadata.resolution} · ${fileMetadata.size}`
|
||||
: fileMetadata.size}
|
||||
</p>
|
||||
<span className={`inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] ${isArchive ? 'border-amber-400/40 bg-amber-400/10 text-amber-200' : 'border-sky-400/40 bg-sky-400/10 text-sky-200'}`}>
|
||||
{isArchive ? 'Archive' : 'Image'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Content type selector */}
|
||||
<section className="rounded-xl ring-1 ring-white/10 bg-gradient-to-br from-white/[0.04] to-white/[0.01] p-4 sm:p-5">
|
||||
<div className="mb-4 flex flex-wrap items-center justify-between gap-2">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-white">Content type</h3>
|
||||
<p className="mt-0.5 text-xs text-white/55">Choose what kind of artwork this is.</p>
|
||||
</div>
|
||||
<span className="rounded-full border border-sky-400/35 bg-sky-400/10 px-2.5 py-0.5 text-[11px] uppercase tracking-widest text-sky-300">
|
||||
Step 2a
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<ContentTypeSelector
|
||||
contentTypes={contentTypes}
|
||||
selected={metadata.contentType}
|
||||
error={metadataErrors.contentType}
|
||||
onChange={onContentTypeChange}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* Category selector */}
|
||||
<section className="rounded-xl ring-1 ring-white/10 bg-gradient-to-br from-white/[0.04] to-white/[0.01] p-4 sm:p-5">
|
||||
<div className="mb-4 flex flex-wrap items-center justify-between gap-2">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-white">Category</h3>
|
||||
<p className="mt-0.5 text-xs text-white/55">
|
||||
{requiresSubCategory ? 'Select a category, then a subcategory.' : 'Select a category.'}
|
||||
</p>
|
||||
</div>
|
||||
<span className="rounded-full border border-violet-400/35 bg-violet-400/10 px-2.5 py-0.5 text-[11px] uppercase tracking-widest text-violet-300">
|
||||
Step 2b
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<CategorySelector
|
||||
categories={filteredCategoryTree}
|
||||
rootCategoryId={metadata.rootCategoryId}
|
||||
subCategoryId={metadata.subCategoryId}
|
||||
hasContentType={Boolean(metadata.contentType)}
|
||||
error={metadataErrors.category}
|
||||
onRootChange={onRootCategoryChange}
|
||||
onSubChange={onSubCategoryChange}
|
||||
allRoots={allRootCategoryOptions}
|
||||
onRootChangeAll={(rootId, contentTypeValue) => {
|
||||
if (contentTypeValue) {
|
||||
onContentTypeChange(contentTypeValue)
|
||||
}
|
||||
onRootCategoryChange(rootId)
|
||||
}}
|
||||
/>
|
||||
</section>
|
||||
|
||||
{/* Title, tags, description, rights */}
|
||||
<UploadSidebar
|
||||
showHeader={false}
|
||||
metadata={metadata}
|
||||
suggestedTags={suggestedTags}
|
||||
errors={metadataErrors}
|
||||
onChangeTitle={onChangeTitle}
|
||||
onChangeTags={onChangeTags}
|
||||
onChangeDescription={onChangeDescription}
|
||||
onToggleRights={onToggleRights}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
159
resources/js/components/upload/steps/Step3Publish.jsx
Normal file
159
resources/js/components/upload/steps/Step3Publish.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user