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:
152
resources/js/components/upload/CategorySelector.jsx
Normal file
152
resources/js/components/upload/CategorySelector.jsx
Normal file
@@ -0,0 +1,152 @@
|
||||
import React from 'react'
|
||||
|
||||
/**
|
||||
* CategorySelector
|
||||
*
|
||||
* Reusable pill-based category + subcategory selector.
|
||||
* Renders root categories as pills; when a root with children is selected,
|
||||
* subcategory pills appear in an animated block below.
|
||||
*
|
||||
* @param {object} props
|
||||
* @param {Array} props.categories Flat list of root-category objects { id, name, children[] }
|
||||
* @param {string} props.rootCategoryId Currently selected root id
|
||||
* @param {string} props.subCategoryId Currently selected sub id
|
||||
* @param {boolean} props.hasContentType Whether a content type is selected (gate)
|
||||
* @param {string} [props.error] Validation error message
|
||||
* @param {function} props.onRootChange Called with (rootId: string)
|
||||
* @param {function} props.onSubChange Called with (subId: string)
|
||||
* @param {Array} [props.allRoots] All root options (for the hidden accessible select)
|
||||
* @param {function} [props.onRootChangeAll] Fallback handler with full cross-type info
|
||||
*/
|
||||
export default function CategorySelector({
|
||||
categories = [],
|
||||
rootCategoryId = '',
|
||||
subCategoryId = '',
|
||||
hasContentType = false,
|
||||
error = '',
|
||||
onRootChange,
|
||||
onSubChange,
|
||||
allRoots = [],
|
||||
onRootChangeAll,
|
||||
}) {
|
||||
const selectedRoot = categories.find((c) => String(c.id) === String(rootCategoryId || '')) ?? null
|
||||
const hasSubcategories = Boolean(
|
||||
selectedRoot && Array.isArray(selectedRoot.children) && selectedRoot.children.length > 0
|
||||
)
|
||||
|
||||
if (!hasContentType) {
|
||||
return (
|
||||
<div className="rounded-lg bg-white/[0.025] px-3 py-3 text-sm text-white/45 ring-1 ring-white/8">
|
||||
Select a content type to load categories.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (categories.length === 0) {
|
||||
return (
|
||||
<div className="rounded-lg bg-white/[0.025] px-3 py-3 text-sm text-white/45 ring-1 ring-white/8">
|
||||
No categories available for this content type.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{/* Root categories */}
|
||||
<div className="flex flex-wrap gap-2" role="group" aria-label="Category">
|
||||
{categories.map((root) => {
|
||||
const active = String(root.id) === String(rootCategoryId || '')
|
||||
return (
|
||||
<button
|
||||
key={root.id}
|
||||
type="button"
|
||||
aria-pressed={active}
|
||||
onClick={() => onRootChange?.(String(root.id))}
|
||||
className={[
|
||||
'rounded-full border px-3.5 py-1.5 text-sm transition-all',
|
||||
active
|
||||
? 'border-violet-500/70 bg-violet-600/25 text-white shadow-sm'
|
||||
: 'border-white/10 bg-white/5 text-white/65 hover:border-violet-300/40 hover:bg-violet-400/10 hover:text-white/90',
|
||||
].join(' ')}
|
||||
>
|
||||
{root.name}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Subcategories (shown when root has children) */}
|
||||
{hasSubcategories && (
|
||||
<div className="rounded-xl ring-1 ring-white/8 bg-white/[0.025] p-3">
|
||||
<p className="mb-2 text-[11px] uppercase tracking-wide text-white/45">
|
||||
Subcategory for <span className="text-white/70">{selectedRoot.name}</span>
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2" role="group" aria-label="Subcategory">
|
||||
{selectedRoot.children.map((sub) => {
|
||||
const active = String(sub.id) === String(subCategoryId || '')
|
||||
return (
|
||||
<button
|
||||
key={sub.id}
|
||||
type="button"
|
||||
aria-pressed={active}
|
||||
onClick={() => onSubChange?.(String(sub.id))}
|
||||
className={[
|
||||
'rounded-full border px-3 py-1 text-sm transition-all',
|
||||
active
|
||||
? 'border-cyan-500/70 bg-cyan-600/20 text-white shadow-sm'
|
||||
: 'border-white/10 bg-white/5 text-white/60 hover:border-cyan-300/40 hover:bg-cyan-400/10 hover:text-white/85',
|
||||
].join(' ')}
|
||||
>
|
||||
{sub.name}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Accessible hidden select (screen readers / fallback) */}
|
||||
<div className="sr-only">
|
||||
<label htmlFor="category-root-select">Root category</label>
|
||||
<select
|
||||
id="category-root-select"
|
||||
value={String(rootCategoryId || '')}
|
||||
onChange={(e) => {
|
||||
const nextRootId = String(e.target.value || '')
|
||||
if (onRootChangeAll) {
|
||||
const matched = allRoots.find((r) => String(r.id) === nextRootId)
|
||||
onRootChangeAll(nextRootId, matched?.contentTypeValue ?? null)
|
||||
} else {
|
||||
onRootChange?.(nextRootId)
|
||||
}
|
||||
}}
|
||||
>
|
||||
<option value="">Select root category</option>
|
||||
{allRoots.map((root) => (
|
||||
<option key={root.id} value={String(root.id)}>{root.name}</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{hasSubcategories && (
|
||||
<>
|
||||
<label htmlFor="category-sub-select">Subcategory</label>
|
||||
<select
|
||||
id="category-sub-select"
|
||||
value={String(subCategoryId || '')}
|
||||
onChange={(e) => onSubChange?.(String(e.target.value || ''))}
|
||||
>
|
||||
<option value="">Select subcategory</option>
|
||||
{selectedRoot.children.map((sub) => (
|
||||
<option key={sub.id} value={String(sub.id)}>{sub.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="mt-1 text-xs text-red-300" role="alert">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user