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:
@@ -157,7 +157,7 @@ function SuggestionDropdown({
|
||||
if (!isOpen) return null
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-white/10 bg-slate-950/95">
|
||||
<div className="overflow-hidden rounded-xl ring-1 ring-white/10 bg-slate-950/98 shadow-xl shadow-black/50">
|
||||
<ul id={listboxId} role="listbox" className="max-h-56 overflow-auto py-1">
|
||||
{loading && (
|
||||
<li className="px-3 py-2 text-xs text-white/60">Searching tags…</li>
|
||||
@@ -268,6 +268,7 @@ export default function TagInput({
|
||||
const queryCacheRef = useRef(new Map())
|
||||
const abortControllerRef = useRef(null)
|
||||
const debounceTimerRef = useRef(null)
|
||||
const hasFocusedRef = useRef(false)
|
||||
|
||||
const listboxId = useMemo(() => `tag-input-listbox-${Math.random().toString(16).slice(2)}`, [])
|
||||
const aiSuggestedItems = useMemo(() => toSuggestionItems(suggestedTags), [suggestedTags])
|
||||
@@ -366,6 +367,9 @@ export default function TagInput({
|
||||
}, [searchEndpoint, popularEndpoint, selectedTags])
|
||||
|
||||
useEffect(() => {
|
||||
// Don't fire on initial mount — wait for the user to focus or type.
|
||||
if (!hasFocusedRef.current) return
|
||||
|
||||
const query = inputValue.trim()
|
||||
|
||||
if (debounceTimerRef.current) {
|
||||
@@ -451,6 +455,7 @@ export default function TagInput({
|
||||
}, [applyPastedTags])
|
||||
|
||||
const handleFocus = useCallback(() => {
|
||||
hasFocusedRef.current = true
|
||||
if (inputValue.trim() !== '') return
|
||||
runSearch('')
|
||||
}, [inputValue, runSearch])
|
||||
|
||||
405
resources/js/components/tags/TagPicker.jsx
Normal file
405
resources/js/components/tags/TagPicker.jsx
Normal file
@@ -0,0 +1,405 @@
|
||||
/**
|
||||
* TagPicker – studio-style list-based tag selector
|
||||
*
|
||||
* - Loads popular tags on mount
|
||||
* - Debounced live search; shows "Add '<query>'" row for custom tags
|
||||
* - Enter / comma / Tab commits a custom tag from the input
|
||||
* - Scrollable list with circle / circle-check toggles
|
||||
* - Selected chips shown above the list
|
||||
* - AI-suggested tags shown with a purple badge
|
||||
* - Search icon on the right side of the input
|
||||
* - Counter footer: X/15 tags selected
|
||||
*
|
||||
* Value format: string[] of tag slugs
|
||||
*/
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
|
||||
const MAX_TAGS = 15
|
||||
const DEBOUNCE_MS = 250
|
||||
const MAX_RESULTS = 30
|
||||
const MIN_LENGTH = 2
|
||||
const MAX_LENGTH = 32
|
||||
|
||||
// ─── helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
function normalizeSlug(raw) {
|
||||
return String(raw ?? '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/[^a-z0-9_-]/g, '')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^[-_]+|[-_]+$/g, '')
|
||||
.slice(0, MAX_LENGTH)
|
||||
}
|
||||
|
||||
function toListItem(item) {
|
||||
if (!item) return null
|
||||
if (typeof item === 'string') {
|
||||
const slug = normalizeSlug(item)
|
||||
return slug ? { key: slug, slug, name: slug, usageCount: null, isAi: false } : null
|
||||
}
|
||||
const slug = normalizeSlug(item.slug || item.tag || item.name || '')
|
||||
if (!slug) return null
|
||||
return {
|
||||
key: String(item.id ?? slug),
|
||||
slug,
|
||||
name: item.name || item.tag || item.slug || slug,
|
||||
usageCount: typeof item.usage_count === 'number' ? item.usage_count : null,
|
||||
isAi: Boolean(item.is_ai || item.source === 'ai'),
|
||||
}
|
||||
}
|
||||
|
||||
// ─── sub-components ───────────────────────────────────────────────────────────
|
||||
|
||||
function SearchInput({ value, onChange, onKeyDown, inputRef, disabled, hint }) {
|
||||
return (
|
||||
<div className="relative">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
disabled={disabled}
|
||||
className="w-full rounded-xl border border-white/10 bg-white/5 py-2.5 pl-3 pr-9 text-sm text-white placeholder:text-white/40 focus:border-accent/50 focus:outline-none focus:ring-2 focus:ring-accent/30 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
placeholder={hint || 'Search or add tags…'}
|
||||
aria-label="Search or add tags"
|
||||
autoComplete="off"
|
||||
spellCheck={false}
|
||||
/>
|
||||
{/* Search icon – right side */}
|
||||
<svg
|
||||
className="pointer-events-none absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 text-white/35"
|
||||
viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"
|
||||
>
|
||||
<path fillRule="evenodd" d="M12.9 14.32a8 8 0 111.414-1.414l4.387 4.387-1.414 1.414-4.387-4.387zM8 14A6 6 0 108 2a6 6 0 000 12z" clipRule="evenodd" />
|
||||
</svg>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SelectedChips({ slugs, names, onRemove, disabled }) {
|
||||
if (slugs.length === 0) return null
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{slugs.map((slug) => (
|
||||
<span
|
||||
key={slug}
|
||||
className="inline-flex items-center gap-1 rounded-lg bg-accent/20 px-2.5 py-1 text-xs font-medium text-accent"
|
||||
>
|
||||
{names[slug] || slug}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onRemove(slug)}
|
||||
disabled={disabled}
|
||||
className="ml-0.5 flex h-4 w-4 items-center justify-center rounded-full hover:bg-white/15 disabled:cursor-not-allowed"
|
||||
aria-label={`Remove tag ${names[slug] || slug}`}
|
||||
>
|
||||
<svg className="h-2.5 w-2.5" viewBox="0 0 10 10" fill="currentColor" aria-hidden="true">
|
||||
<path d="M6.414 5l2.293-2.293a1 1 0 00-1.414-1.414L5 3.586 2.707 1.293A1 1 0 001.293 2.707L3.586 5 1.293 7.293a1 1 0 101.414 1.414L5 6.414l2.293 2.293a1 1 0 001.414-1.414L6.414 5z" />
|
||||
</svg>
|
||||
</button>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AddNewRow({ label, onAdd, disabled }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onAdd}
|
||||
disabled={disabled}
|
||||
className="flex w-full items-center gap-2.5 rounded-lg px-3 py-2 text-sm text-sky-300 transition-all hover:bg-sky-400/10 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<svg className="h-3.5 w-3.5 shrink-0" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
|
||||
<path strokeLinecap="round" d="M8 3v10M3 8h10" />
|
||||
</svg>
|
||||
<span>Add <span className="font-semibold">"{label}"</span></span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function ListRow({ item, isSelected, onToggle, disabled }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onToggle(item)}
|
||||
disabled={disabled}
|
||||
className={`flex w-full items-center justify-between rounded-lg px-3 py-2 text-sm transition-all disabled:cursor-not-allowed ${
|
||||
isSelected
|
||||
? 'bg-accent/12 text-accent'
|
||||
: 'text-slate-300 hover:bg-white/6 hover:text-white'
|
||||
}`}
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-2.5">
|
||||
{isSelected ? (
|
||||
<svg className="h-3.5 w-3.5 shrink-0 text-accent" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
|
||||
<path fillRule="evenodd" d="M8 16A8 8 0 108 0a8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L7 8.586 5.707 7.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clipRule="evenodd" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="h-3.5 w-3.5 shrink-0 text-white/30" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden="true">
|
||||
<circle cx="8" cy="8" r="6.25" />
|
||||
</svg>
|
||||
)}
|
||||
|
||||
<span className="truncate">{item.name}</span>
|
||||
|
||||
{item.isAi && (
|
||||
<span className="shrink-0 rounded-full border border-purple-400/40 bg-purple-400/10 px-1.5 py-0.5 text-[10px] uppercase tracking-wide text-purple-200">
|
||||
AI
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
|
||||
{typeof item.usageCount === 'number' && (
|
||||
<span className="ml-3 shrink-0 text-[11px] text-white/40">
|
||||
{item.usageCount.toLocaleString()} uses
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── main component ───────────────────────────────────────────────────────────
|
||||
|
||||
export default function TagPicker({
|
||||
value = [],
|
||||
onChange,
|
||||
suggestedTags = [],
|
||||
disabled = false,
|
||||
maxTags = MAX_TAGS,
|
||||
searchEndpoint = '/api/tags/search',
|
||||
popularEndpoint = '/api/tags/popular',
|
||||
placeholder,
|
||||
error,
|
||||
}) {
|
||||
const selectedSlugs = useMemo(
|
||||
() => Array.from(new Set((Array.isArray(value) ? value : []).map(normalizeSlug).filter(Boolean))),
|
||||
[value]
|
||||
)
|
||||
|
||||
const [query, setQuery] = useState('')
|
||||
const [results, setResults] = useState([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [fetchError, setFetchError] = useState(false)
|
||||
const [inputError, setInputError] = useState('')
|
||||
|
||||
// slug → display name (for chips)
|
||||
const [nameMap, setNameMap] = useState({})
|
||||
const updateNameMap = useCallback((items) => {
|
||||
setNameMap((prev) => {
|
||||
const next = { ...prev }
|
||||
items.forEach((item) => {
|
||||
if (item?.slug && item.name) next[item.slug] = item.name
|
||||
})
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
const abortRef = useRef(null)
|
||||
const timerRef = useRef(null)
|
||||
const inputRef = useRef(null)
|
||||
const cacheRef = useRef(new Map())
|
||||
|
||||
const aiItems = useMemo(() => {
|
||||
return (Array.isArray(suggestedTags) ? suggestedTags : [])
|
||||
.map(toListItem)
|
||||
.filter(Boolean)
|
||||
.map((item) => ({ ...item, isAi: true }))
|
||||
}, [suggestedTags])
|
||||
|
||||
const fetchTags = useCallback(async (q) => {
|
||||
const cacheKey = q.trim() || '__popular__'
|
||||
if (cacheRef.current.has(cacheKey)) {
|
||||
setResults(cacheRef.current.get(cacheKey))
|
||||
setFetchError(false)
|
||||
return
|
||||
}
|
||||
|
||||
if (abortRef.current) abortRef.current.abort()
|
||||
abortRef.current = new AbortController()
|
||||
|
||||
setLoading(true)
|
||||
setFetchError(false)
|
||||
|
||||
try {
|
||||
const url = q.trim()
|
||||
? `${searchEndpoint}?q=${encodeURIComponent(q.trim())}`
|
||||
: popularEndpoint
|
||||
const res = await window.axios.get(url, { signal: abortRef.current.signal })
|
||||
const raw = res?.data?.data || res?.data || []
|
||||
const items = (Array.isArray(raw) ? raw : []).map(toListItem).filter(Boolean).slice(0, MAX_RESULTS)
|
||||
cacheRef.current.set(cacheKey, items)
|
||||
setResults(items)
|
||||
updateNameMap(items)
|
||||
} catch (err) {
|
||||
if (err?.code === 'ERR_CANCELED' || abortRef.current?.signal?.aborted) return
|
||||
setFetchError(true)
|
||||
setResults([])
|
||||
} finally {
|
||||
if (!abortRef.current?.signal?.aborted) setLoading(false)
|
||||
}
|
||||
}, [searchEndpoint, popularEndpoint, updateNameMap])
|
||||
|
||||
useEffect(() => {
|
||||
if (timerRef.current) clearTimeout(timerRef.current)
|
||||
timerRef.current = setTimeout(() => fetchTags(query), query ? DEBOUNCE_MS : 0)
|
||||
return () => clearTimeout(timerRef.current)
|
||||
}, [query, fetchTags])
|
||||
|
||||
useEffect(() => () => { abortRef.current?.abort() }, [])
|
||||
|
||||
useEffect(() => { updateNameMap(aiItems) }, [aiItems, updateNameMap])
|
||||
|
||||
// ── tag actions ──────────────────────────────────────────────────────────
|
||||
|
||||
const addTag = useCallback((rawSlug, displayName) => {
|
||||
const slug = normalizeSlug(rawSlug)
|
||||
if (!slug) return
|
||||
if (slug.length < MIN_LENGTH) { setInputError(`Too short (min ${MIN_LENGTH} chars)`); return }
|
||||
if (selectedSlugs.includes(slug)) { setInputError('Already added'); return }
|
||||
if (selectedSlugs.length >= maxTags) { setInputError('Maximum tags reached'); return }
|
||||
setInputError('')
|
||||
updateNameMap([{ slug, name: displayName || rawSlug }])
|
||||
onChange?.([...selectedSlugs, slug])
|
||||
setQuery('')
|
||||
}, [selectedSlugs, maxTags, onChange, updateNameMap])
|
||||
|
||||
const toggleTag = useCallback((item) => {
|
||||
if (selectedSlugs.includes(item.slug)) {
|
||||
onChange?.(selectedSlugs.filter((s) => s !== item.slug))
|
||||
} else {
|
||||
addTag(item.slug, item.name)
|
||||
}
|
||||
}, [selectedSlugs, addTag, onChange])
|
||||
|
||||
const removeTag = useCallback((slug) => {
|
||||
setInputError('')
|
||||
onChange?.(selectedSlugs.filter((s) => s !== slug))
|
||||
}, [selectedSlugs, onChange])
|
||||
|
||||
// Commit on Enter / comma / Tab
|
||||
const handleKeyDown = useCallback((e) => {
|
||||
const commit = e.key === 'Enter' || e.key === ',' || e.key === 'Tab'
|
||||
if (!commit) return
|
||||
|
||||
// Backspace on empty → remove last chip
|
||||
if (e.key === 'Backspace' && query === '' && selectedSlugs.length > 0) {
|
||||
removeTag(selectedSlugs[selectedSlugs.length - 1])
|
||||
return
|
||||
}
|
||||
|
||||
const candidate = query.trim().replace(/,$/, '')
|
||||
if (!candidate) return
|
||||
|
||||
e.preventDefault()
|
||||
addTag(candidate, candidate)
|
||||
}, [query, selectedSlugs, addTag, removeTag])
|
||||
|
||||
// Show "Add 'query'" row when the query doesn't exactly match any result
|
||||
const querySlug = normalizeSlug(query)
|
||||
const showAddNew = Boolean(
|
||||
query.trim() &&
|
||||
querySlug.length >= MIN_LENGTH &&
|
||||
!results.some((r) => r.slug === querySlug) &&
|
||||
!selectedSlugs.includes(querySlug)
|
||||
)
|
||||
|
||||
const displayList = useMemo(() => {
|
||||
const seen = new Set()
|
||||
const merged = []
|
||||
|
||||
if (!query.trim()) {
|
||||
aiItems.forEach((item) => {
|
||||
if (!seen.has(item.slug)) { seen.add(item.slug); merged.push(item) }
|
||||
})
|
||||
}
|
||||
|
||||
results.forEach((item) => {
|
||||
if (!seen.has(item.slug)) { seen.add(item.slug); merged.push(item) }
|
||||
})
|
||||
|
||||
return merged
|
||||
}, [aiItems, results, query])
|
||||
|
||||
const atMax = selectedSlugs.length >= maxTags
|
||||
|
||||
return (
|
||||
<div className="space-y-3" data-testid="tag-picker-root">
|
||||
{/* Search / add input */}
|
||||
<SearchInput
|
||||
value={query}
|
||||
onChange={(v) => { setQuery(v); setInputError('') }}
|
||||
onKeyDown={handleKeyDown}
|
||||
inputRef={inputRef}
|
||||
disabled={disabled}
|
||||
hint={placeholder}
|
||||
/>
|
||||
|
||||
{/* Inline input validation */}
|
||||
{inputError && (
|
||||
<p className="text-xs text-red-300" role="alert">{inputError}</p>
|
||||
)}
|
||||
|
||||
{/* Selected chips */}
|
||||
<SelectedChips
|
||||
slugs={selectedSlugs}
|
||||
names={nameMap}
|
||||
onRemove={removeTag}
|
||||
disabled={disabled}
|
||||
/>
|
||||
|
||||
{/* Scrollable results list */}
|
||||
<div className="max-h-52 overflow-y-auto rounded-xl border border-white/8 bg-white/[0.02] p-1 sb-scrollbar">
|
||||
{loading && (
|
||||
<div className="flex items-center justify-center py-5">
|
||||
<div className="h-5 w-5 animate-spin rounded-full border-2 border-accent/30 border-t-accent" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && fetchError && (
|
||||
<p className="py-4 text-center text-xs text-amber-300/80">Tag search unavailable</p>
|
||||
)}
|
||||
|
||||
{/* "Add new" row — always shown when query doesn't match */}
|
||||
{!loading && showAddNew && (
|
||||
<AddNewRow
|
||||
label={query.trim()}
|
||||
onAdd={() => addTag(query.trim(), query.trim())}
|
||||
disabled={disabled || atMax}
|
||||
/>
|
||||
)}
|
||||
|
||||
{!loading && !fetchError && displayList.length === 0 && !showAddNew && (
|
||||
<p className="py-4 text-center text-sm text-white/40">
|
||||
{query ? 'No tags found — press Enter to add' : 'Type to search or add tags'}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{!loading && !fetchError && displayList.map((item) => (
|
||||
<ListRow
|
||||
key={item.key}
|
||||
item={item}
|
||||
isSelected={selectedSlugs.includes(item.slug)}
|
||||
onToggle={toggleTag}
|
||||
disabled={disabled || (atMax && !selectedSlugs.includes(item.slug))}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex items-center justify-between text-xs text-white/45">
|
||||
<span>{selectedSlugs.length}/{maxTags} tags selected</span>
|
||||
{atMax
|
||||
? <span className="text-amber-300/80">Maximum tags reached</span>
|
||||
: <span className="text-white/30">Enter, comma or Tab to add</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
{error && <p className="text-xs text-red-300">{error}</p>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
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>
|
||||
)
|
||||
}
|
||||
88
resources/js/components/upload/ContentTypeSelector.jsx
Normal file
88
resources/js/components/upload/ContentTypeSelector.jsx
Normal file
@@ -0,0 +1,88 @@
|
||||
import React from 'react'
|
||||
import { getContentTypeValue, getContentTypeVisualKey } from '../../lib/uploadUtils'
|
||||
|
||||
/**
|
||||
* ContentTypeSelector
|
||||
*
|
||||
* Reusable mascot-icon content-type picker.
|
||||
* Displays each content type as a card with a mascot icon.
|
||||
*
|
||||
* @param {object} props
|
||||
* @param {Array} props.contentTypes List of content type objects from API
|
||||
* @param {string} props.selected Currently selected type value
|
||||
* @param {string} [props.error] Validation error message
|
||||
* @param {function} props.onChange Called with new type value string
|
||||
*/
|
||||
export default function ContentTypeSelector({ contentTypes = [], selected = '', error = '', onChange }) {
|
||||
if (!Array.isArray(contentTypes) || contentTypes.length === 0) {
|
||||
return (
|
||||
<div className="rounded-xl ring-1 ring-white/10 bg-white/5 px-4 py-3 text-sm text-white/60">
|
||||
No content types available.
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex gap-3 overflow-x-auto py-2 no-scrollbar">
|
||||
{contentTypes.map((ct) => {
|
||||
const typeValue = getContentTypeValue(ct)
|
||||
const active = String(typeValue) === String(selected || '')
|
||||
const visualKey = getContentTypeVisualKey(ct)
|
||||
const iconPath = `/gfx/mascot_${visualKey}.webp`
|
||||
|
||||
return (
|
||||
<button
|
||||
key={typeValue || ct.name}
|
||||
type="button"
|
||||
onClick={() => onChange?.(String(typeValue))}
|
||||
aria-pressed={active}
|
||||
className={[
|
||||
'group flex flex-col items-center gap-2 min-w-[96px] rounded-xl border px-3 py-2.5',
|
||||
'transition-all duration-150 focus:outline-none focus-visible:ring-2 focus-visible:ring-emerald-400/50',
|
||||
active
|
||||
? 'border-emerald-400/70 bg-emerald-400/15 shadow-lg shadow-emerald-900/30 scale-105'
|
||||
: 'border-white/10 bg-white/[0.03] hover:border-white/20 hover:bg-white/[0.06] hover:-translate-y-0.5 hover:scale-[1.03]',
|
||||
].join(' ')}
|
||||
>
|
||||
{/* Mascot icon */}
|
||||
<div className="h-16 w-16 rounded-full overflow-hidden flex items-center justify-center">
|
||||
<img
|
||||
src={iconPath}
|
||||
alt={`${ct.name || 'Content type'} icon`}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
width={64}
|
||||
height={64}
|
||||
className={[
|
||||
'h-full w-full object-contain transition-all duration-200',
|
||||
active
|
||||
? 'grayscale-0 opacity-100'
|
||||
: 'grayscale opacity-40 group-hover:grayscale-0 group-hover:opacity-85',
|
||||
].join(' ')}
|
||||
onError={(e) => {
|
||||
if (!e.currentTarget.src.includes('mascot_other.webp')) {
|
||||
e.currentTarget.src = '/gfx/mascot_other.webp'
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Label */}
|
||||
<span className={`text-xs font-semibold text-center leading-snug ${active ? 'text-white' : 'text-white/60 group-hover:text-white/85'}`}>
|
||||
{ct.name || 'Type'}
|
||||
</span>
|
||||
|
||||
{/* Active indicator bar */}
|
||||
<div className={`h-0.5 w-8 rounded-full transition-all ${active ? 'bg-emerald-400/80' : 'bg-white/10 group-hover:bg-white/25'}`} />
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="mt-2 text-xs text-red-300" role="alert">{error}</p>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -116,9 +116,9 @@ export default function ScreenshotUploader({
|
||||
animate={prefersReducedMotion ? {} : { opacity: 1, scale: 1 }}
|
||||
exit={prefersReducedMotion ? {} : { opacity: 0, scale: 0.96 }}
|
||||
transition={quickTransition}
|
||||
className="rounded-lg border border-white/50 bg-white/5 p-2 text-xs"
|
||||
className="rounded-lg ring-1 ring-white/10 bg-white/5 p-2 text-xs"
|
||||
>
|
||||
<div className="flex h-40 w-40 items-center justify-center overflow-hidden rounded-md border border-white/50 bg-black/25">
|
||||
<div className="flex h-40 w-40 items-center justify-center overflow-hidden rounded-md ring-1 ring-white/10 bg-black/25">
|
||||
<img
|
||||
src={item.url}
|
||||
alt={`Screenshot ${index + 1}`}
|
||||
|
||||
@@ -67,7 +67,7 @@ export default function UploadDropzone({
|
||||
}
|
||||
|
||||
return (
|
||||
<section className={`rounded-xl border bg-gradient-to-br p-0 shadow-[0_12px_32px_rgba(0,0,0,0.35)] transition-colors ${invalid ? 'border-red-400/45 from-red-500/10 to-slate-900/45' : 'border-white/50 from-slate-900/80 to-slate-900/50'}`}>
|
||||
<section className={`rounded-xl bg-gradient-to-br p-0 shadow-[0_12px_32px_rgba(0,0,0,0.35)] transition-colors ${invalid ? 'ring-1 ring-red-400/40 from-red-500/10 to-slate-900/45' : 'ring-1 ring-white/10 from-slate-900/80 to-slate-900/50'}`}>
|
||||
{/* Intended props: file, dragState, accept, onDrop, onBrowse, onReset, disabled */}
|
||||
<motion.div
|
||||
data-testid="upload-dropzone"
|
||||
@@ -100,7 +100,7 @@ export default function UploadDropzone({
|
||||
}}
|
||||
animate={prefersReducedMotion ? undefined : { scale: dragging ? 1.01 : 1 }}
|
||||
transition={dragTransition}
|
||||
className={`group rounded-xl border-2 border-dashed border-white/50 py-6 px-4 text-center transition hover:border-accent/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-300/70 ${locked ? 'cursor-default bg-white/5 opacity-75' : 'cursor-pointer'} ${invalid ? 'border-red-300/70 bg-red-500/10 shadow-[0_0_0_1px_rgba(248,113,113,0.2)]' : dragging ? 'border-cyan-300 bg-cyan-500/20 shadow-[0_0_0_1px_rgba(103,232,249,0.35)]' : locked ? 'bg-white/5' : 'bg-sky-500/5 hover:bg-sky-500/12'}`}
|
||||
className={`group rounded-xl border-2 border-dashed border-white/15 py-6 px-4 text-center transition hover:border-accent/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-300/70 ${locked ? 'cursor-default bg-white/5 opacity-75' : 'cursor-pointer'} ${invalid ? 'border-red-300/70 bg-red-500/10 shadow-[0_0_0_1px_rgba(248,113,113,0.2)]' : dragging ? 'border-cyan-300 bg-cyan-500/20 shadow-[0_0_0_1px_rgba(103,232,249,0.35)]' : locked ? 'bg-white/5' : 'bg-sky-500/5 hover:bg-sky-500/12'}`}
|
||||
>
|
||||
<h3 className="mt-3 text-sm font-semibold text-white">{title}</h3>
|
||||
<p className="mt-1 text-xs text-soft">{description}</p>
|
||||
@@ -155,7 +155,7 @@ export default function UploadDropzone({
|
||||
/>
|
||||
|
||||
{(previewUrl || (fileMeta && String(fileMeta.type || '').startsWith('image/'))) && (
|
||||
<div className="mt-3 rounded-lg border border-white/50 bg-black/25 px-3 py-2 text-left text-xs text-white/80">
|
||||
<div className="mt-3 rounded-lg ring-1 ring-white/10 bg-black/25 px-3 py-2 text-left text-xs text-white/80">
|
||||
<div className="font-medium text-white/85">Selected file</div>
|
||||
<div className="mt-1 truncate">{fileName || fileHint}</div>
|
||||
{fileMeta && (
|
||||
|
||||
@@ -15,9 +15,9 @@ export default function UploadPreview({
|
||||
invalid = false,
|
||||
}) {
|
||||
return (
|
||||
<section className={`rounded-xl border bg-gradient-to-br p-5 shadow-[0_12px_32px_rgba(0,0,0,0.35)] transition-colors sm:p-6 ${invalid ? 'border-red-400/45 from-red-500/10 to-slate-900/45' : 'border-white/50 from-slate-900/80 to-slate-900/45'}`}>
|
||||
<section className={`rounded-xl bg-gradient-to-br p-5 shadow-[0_12px_32px_rgba(0,0,0,0.35)] transition-colors sm:p-6 ${invalid ? 'ring-1 ring-red-400/40 from-red-500/10 to-slate-900/45' : 'ring-1 ring-white/10 from-slate-900/80 to-slate-900/45'}`}>
|
||||
{/* Intended props: file, previewUrl, isArchive, dimensions, fileSize, format, warning */}
|
||||
<div className={`rounded-xl border p-4 transition-colors ${invalid ? 'border-red-300/45 bg-red-500/5' : 'border-white/50 bg-black/25'}`}>
|
||||
<div className={`rounded-xl ring-1 p-4 transition-colors ${invalid ? 'ring-red-300/40 bg-red-500/5' : 'ring-white/8 bg-black/25'}`}>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<h3 className="text-lg font-semibold text-white">{title}</h3>
|
||||
<span className="rounded-full border border-white/15 bg-white/5 px-2.5 py-1 text-[11px] text-white/65">
|
||||
|
||||
@@ -60,7 +60,7 @@ export default function UploadProgress({
|
||||
const progressValue = Math.max(0, Math.min(100, Number(progress) || 0))
|
||||
|
||||
return (
|
||||
<header className="rounded-xl border border-white/50 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">
|
||||
<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>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React from 'react'
|
||||
import TagInput from '../tags/TagInput'
|
||||
import TagPicker from '../tags/TagPicker'
|
||||
import Checkbox from '../../Components/ui/Checkbox'
|
||||
|
||||
export default function UploadSidebar({
|
||||
@@ -62,16 +62,14 @@ export default function UploadSidebar({
|
||||
<h4 className="text-sm font-semibold text-white">Tags</h4>
|
||||
<p className="mt-1 text-xs text-white/60">Use keywords people would search for. Press Enter, comma, or Tab to add a tag.</p>
|
||||
</div>
|
||||
<TagInput
|
||||
<TagPicker
|
||||
value={metadata.tags}
|
||||
onChange={(nextTags) => onChangeTags?.(nextTags)}
|
||||
suggestedTags={suggestedTags}
|
||||
maxTags={15}
|
||||
minLength={2}
|
||||
maxLength={32}
|
||||
searchEndpoint="/api/tags/search"
|
||||
popularEndpoint="/api/tags/popular"
|
||||
placeholder="Type tags (e.g. cyberpunk, city)"
|
||||
error={errors.tags}
|
||||
/>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ export default function UploadStepper({ steps = [], activeStep = 1, highestUnloc
|
||||
const safeActive = Math.max(1, Math.min(steps.length || 1, activeStep))
|
||||
|
||||
return (
|
||||
<nav aria-label="Upload steps" className="rounded-xl border border-white/50 bg-slate-900/70 px-3 py-3 sm:px-4">
|
||||
<nav aria-label="Upload steps" className="rounded-xl ring-1 ring-white/10 bg-slate-900/70 px-3 py-3 sm:px-4">
|
||||
<ol className="flex flex-nowrap items-center gap-3 overflow-x-auto sm:gap-4">
|
||||
{steps.map((step, index) => {
|
||||
const number = index + 1
|
||||
@@ -19,7 +19,7 @@ export default function UploadStepper({ steps = [], activeStep = 1, highestUnloc
|
||||
: isComplete
|
||||
? 'border-emerald-300/30 bg-emerald-500/15 text-emerald-100 hover:bg-emerald-500/25'
|
||||
: isLocked
|
||||
? 'cursor-default border-white/50 bg-white/5 text-white/40'
|
||||
? 'cursor-default border-white/10 bg-white/5 text-white/40'
|
||||
: 'border-white/10 bg-white/5 text-white/80 hover:bg-white/10'
|
||||
|
||||
const circleClass = isComplete
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
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