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)
164 lines
6.2 KiB
JavaScript
164 lines
6.2 KiB
JavaScript
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>
|
|
)
|
|
}
|