Save workspace changes

This commit is contained in:
2026-04-18 17:02:56 +02:00
parent f02ea9a711
commit 87d60af5a9
4220 changed files with 1388603 additions and 1554 deletions

View File

@@ -0,0 +1,146 @@
import React, { useEffect, useMemo, useState } from 'react'
function getScreenshotName(item, fallbackIndex) {
if (item && typeof item === 'object' && typeof item.name === 'string' && item.name.trim()) {
return item.name.trim()
}
return `Screenshot ${fallbackIndex + 1}`
}
function resolveScreenshotSource(item) {
if (!item) return { src: null, revoke: null }
if (typeof item === 'string') {
return { src: item, revoke: null }
}
if (typeof item === 'object') {
if (typeof item.preview === 'string' && item.preview) {
return { src: item.preview, revoke: null }
}
if (typeof item.src === 'string' && item.src) {
return { src: item.src, revoke: null }
}
if (typeof item.url === 'string' && item.url) {
return { src: item.url, revoke: null }
}
if (typeof File !== 'undefined' && item instanceof File) {
const objectUrl = URL.createObjectURL(item)
return {
src: objectUrl,
revoke: () => URL.revokeObjectURL(objectUrl),
}
}
}
return { src: null, revoke: null }
}
export default function ArchiveScreenshotPicker({
screenshots = [],
selectedIndex = 0,
onSelect,
compact = false,
title = 'Screenshots',
description = 'Choose which screenshot should be used as the default preview.',
}) {
const [resolvedScreenshots, setResolvedScreenshots] = useState([])
useEffect(() => {
const cleanup = []
const next = (Array.isArray(screenshots) ? screenshots : []).map((item, index) => {
const { src, revoke } = resolveScreenshotSource(item)
if (revoke) cleanup.push(revoke)
return {
src,
alt: getScreenshotName(item, index),
}
}).filter((item) => Boolean(item.src))
setResolvedScreenshots(next)
return () => {
cleanup.forEach((revoke) => revoke())
}
}, [screenshots])
const normalizedIndex = useMemo(() => {
if (resolvedScreenshots.length === 0) return 0
if (!Number.isFinite(selectedIndex)) return 0
return Math.min(Math.max(0, Math.floor(selectedIndex)), resolvedScreenshots.length - 1)
}, [resolvedScreenshots.length, selectedIndex])
const selectedScreenshot = resolvedScreenshots[normalizedIndex] ?? null
if (!selectedScreenshot) {
return null
}
return (
<div className={compact ? 'space-y-3' : 'space-y-4'}>
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-white/45">{title}</p>
<p className="mt-1 text-xs text-white/55">{description}</p>
</div>
<span className="rounded-full border border-emerald-300/30 bg-emerald-400/10 px-2.5 py-1 text-[11px] text-emerald-100">
Default preview
</span>
</div>
<div className={compact ? 'overflow-hidden rounded-2xl border border-white/10 bg-black/25' : 'overflow-hidden rounded-3xl border border-white/10 bg-black/25'}>
<img
src={selectedScreenshot.src}
alt={selectedScreenshot.alt}
className={compact ? 'h-40 w-full object-cover' : 'h-56 w-full object-cover sm:h-72'}
loading="lazy"
decoding="async"
/>
</div>
<div className={compact ? 'grid grid-cols-4 gap-2' : 'grid grid-cols-2 gap-3 sm:grid-cols-4'}>
{resolvedScreenshots.map((item, index) => {
const isSelected = index === normalizedIndex
return (
<button
key={`${item.src}-${index}`}
type="button"
onClick={() => onSelect?.(index)}
aria-label={`Use ${item.alt} as default screenshot`}
className={[
'group relative overflow-hidden rounded-2xl border text-left transition',
isSelected
? 'border-emerald-300/45 bg-emerald-400/10 shadow-[0_0_0_1px_rgba(52,211,153,0.22)]'
: 'border-white/10 bg-white/[0.03] hover:border-white/20 hover:bg-white/[0.05]',
].join(' ')}
aria-pressed={isSelected}
>
<img
src={item.src}
alt={item.alt}
className={compact ? 'h-16 w-full object-cover' : 'h-20 w-full object-cover'}
loading="lazy"
decoding="async"
/>
<div className="flex items-center justify-between gap-2 px-2.5 py-2">
<span className="truncate text-[11px] text-white/70">{item.alt}</span>
<span className={[
'shrink-0 rounded-full px-2 py-0.5 text-[10px]',
isSelected ? 'bg-emerald-300/20 text-emerald-100' : 'bg-white/10 text-white/45',
].join(' ')}>
{isSelected ? 'Default' : 'Use'}
</span>
</div>
</button>
)
})}
</div>
</div>
)
}

View File

@@ -0,0 +1,146 @@
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 rootOptions = hasContentType ? categories : allRoots
const selectedRoot = categories.find((c) => String(c.id) === String(rootCategoryId || '')) ?? null
const hasSubcategories = Boolean(
selectedRoot && Array.isArray(selectedRoot.children) && selectedRoot.children.length > 0
)
return (
<div className="space-y-3">
{!hasContentType ? (
<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>
) : categories.length === 0 ? (
<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>
) : (
<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>
{rootOptions.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>
)
}

View 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>
)
}

View File

@@ -0,0 +1,287 @@
import React, { useCallback } from 'react'
import ArchiveScreenshotPicker from './ArchiveScreenshotPicker'
import ReadinessChecklist from './ReadinessChecklist'
import SchedulePublishPicker from './SchedulePublishPicker'
import Checkbox from '../../Components/ui/Checkbox'
/**
* PublishPanel
*
* Right-sidebar panel (or mobile bottom-sheet) that shows:
* - Thumbnail preview + title
* - Status pill
* - ReadinessChecklist
* - Visibility selector
* - Publish now / Schedule controls
* - Primary action button
*
* Props mirror what UploadWizard collects.
*/
const STATUS_PILL = {
idle: null,
initializing: { label: 'Uploading', cls: 'bg-sky-500/20 text-sky-200 border-sky-300/30' },
uploading: { label: 'Uploading', cls: 'bg-sky-500/25 text-sky-100 border-sky-300/40' },
finishing: { label: 'Processing', cls: 'bg-amber-500/20 text-amber-200 border-amber-300/30' },
processing: { label: 'Processing', cls: 'bg-amber-500/20 text-amber-200 border-amber-300/30' },
ready_to_publish: { label: 'Ready', cls: 'bg-emerald-500/20 text-emerald-100 border-emerald-300/35' },
publishing: { label: 'Publishing…', cls: 'bg-sky-500/25 text-sky-100 border-sky-300/40' },
complete: { label: 'Published', cls: 'bg-emerald-500/25 text-emerald-100 border-emerald-300/50' },
scheduled: { label: 'Scheduled', cls: 'bg-violet-500/20 text-violet-200 border-violet-300/30' },
error: { label: 'Error', cls: 'bg-red-500/20 text-red-200 border-red-300/30' },
cancelled: { label: 'Cancelled', cls: 'bg-white/8 text-white/40 border-white/10' },
}
export default function PublishPanel({
// Asset
primaryPreviewUrl = null,
isArchive = false,
screenshots = [],
selectedScreenshotIndex = 0,
onSelectedScreenshotChange,
// Metadata
metadata = {},
// Readiness
machineState = 'idle',
uploadReady = false,
canPublish = false,
isPublishing = false,
isArchiveRequiresScreenshot = false,
// Publish options
publishMode = 'now', // 'now' | 'schedule'
scheduledAt = null,
timezone = Intl.DateTimeFormat().resolvedOptions().timeZone,
visibility = 'public', // 'public' | 'unlisted' | 'private'
showRightsConfirmation = true,
showVisibility = false,
onPublishModeChange,
onScheduleAt,
onVisibilityChange,
onToggleRights,
// Actions
onPublish,
onCancel,
// Navigation helpers (for checklist quick-links)
onGoToStep,
allRootCategoryOptions = [],
actionLabel = 'Publish now',
showScheduleControls = true,
}) {
const pill = STATUS_PILL[machineState] ?? null
const hasPreview = Boolean(primaryPreviewUrl && !isArchive)
const title = String(metadata.title || '').trim()
const hasTitle = Boolean(title)
const selectedRoot = allRootCategoryOptions.find(
(item) => String(item.id) === String(metadata.rootCategoryId || '')
) ?? null
const requiresSubCategory = Boolean(selectedRoot?.children?.length)
const hasCompleteCategory = Boolean(
metadata.rootCategoryId && (!requiresSubCategory || metadata.subCategoryId)
)
const hasTag = Array.isArray(metadata.tags) && metadata.tags.length > 0
const hasRights = Boolean(metadata.rightsAccepted)
const hasScreenshot = !isArchiveRequiresScreenshot || screenshots.length > 0
const checklist = [
{ label: 'File uploaded & processed', ok: uploadReady },
{ label: 'Title', ok: hasTitle, onClick: () => onGoToStep?.(2) },
{ label: 'Category', ok: hasCompleteCategory, onClick: () => onGoToStep?.(2) },
{ label: 'Rights confirmed', ok: hasRights, onClick: () => onGoToStep?.(2) },
...( isArchiveRequiresScreenshot
? [{ label: 'Screenshot (required for pack)', ok: hasScreenshot, onClick: () => onGoToStep?.(1) }]
: [] ),
{ label: 'At least 1 tag', ok: hasTag, onClick: () => onGoToStep?.(2) },
]
const publishLabel = useCallback(() => {
if (isPublishing) return `${actionLabel}`
if (!showScheduleControls) return actionLabel
if (publishMode === 'schedule') return 'Schedule publish'
return actionLabel
}, [isPublishing, publishMode, actionLabel, showScheduleControls])
const canSchedulePublish =
publishMode === 'schedule' ? Boolean(scheduledAt) && canPublish : canPublish
const rightsError = uploadReady && !hasRights ? 'Rights confirmation is required.' : null
const visibilityOptions = [
{
value: 'public',
label: 'Public',
hint: 'Visible to everyone',
},
{
value: 'unlisted',
label: 'Unlisted',
hint: 'Available by direct link',
},
{
value: 'private',
label: 'Private',
hint: 'Keep as draft visibility',
},
]
return (
<div className="h-fit space-y-5 rounded-[28px] border border-white/10 bg-[linear-gradient(180deg,rgba(255,255,255,0.05),rgba(255,255,255,0.02))] p-5 shadow-[0_24px_90px_rgba(2,8,23,0.28)] backdrop-blur">
{/* Preview + title */}
<div className="flex items-start gap-3">
{/* Thumbnail */}
<div className="shrink-0 h-[72px] w-[72px] overflow-hidden rounded-xl ring-1 ring-white/10 bg-black/30 flex items-center justify-center">
{hasPreview ? (
<img
src={primaryPreviewUrl}
alt="Artwork preview"
className="max-h-full max-w-full object-contain"
loading="lazy"
decoding="async"
width={72}
height={72}
/>
) : (
<svg className="h-6 w-6 text-white/25" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
)}
</div>
{/* Title + status */}
<div className="min-w-0 flex-1 pt-0.5">
<p className="truncate text-sm font-semibold text-white leading-snug">
{hasTitle ? title : <span className="italic text-white/35">Untitled artwork</span>}
</p>
{pill && (
<span className={`mt-1 inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[10px] ${pill.cls}`}>
{['uploading', 'initializing', 'finishing', 'processing', 'publishing'].includes(machineState) && (
<span className="relative flex h-1.5 w-1.5 shrink-0" aria-hidden="true">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-current opacity-60" />
<span className="relative inline-flex h-1.5 w-1.5 rounded-full bg-current" />
</span>
)}
{pill.label}
</span>
)}
</div>
</div>
{isArchive && screenshots.length > 0 && (
<div className="rounded-2xl border border-white/8 bg-white/[0.03] p-3">
<ArchiveScreenshotPicker
screenshots={screenshots}
selectedIndex={selectedScreenshotIndex}
onSelect={onSelectedScreenshotChange}
compact
title="Preview screenshot"
description="Choose which screenshot should represent this archive in the publish panel."
/>
</div>
)}
{/* Divider */}
<div className="border-t border-white/8" />
{/* Readiness checklist */}
<div className="rounded-2xl border border-white/8 bg-white/[0.03] p-4">
<ReadinessChecklist items={checklist} />
</div>
{/* Visibility (only when showVisibility=true) */}
{showVisibility && (
<div>
<label className="mb-2 block text-[10px] uppercase tracking-wider text-white/40" htmlFor="publish-visibility">
Visibility
</label>
<div id="publish-visibility" className="grid gap-2">
{visibilityOptions.map((option) => {
const active = visibility === option.value
return (
<button
key={option.value}
type="button"
onClick={() => onVisibilityChange?.(option.value)}
disabled={!canPublish && machineState !== 'ready_to_publish'}
className={[
'flex items-start justify-between gap-3 rounded-2xl border px-4 py-3 text-left transition disabled:opacity-50',
active
? 'border-sky-300/30 bg-sky-400/10 text-white'
: 'border-white/10 bg-white/[0.03] text-white/75 hover:border-white/20 hover:bg-white/[0.06]',
].join(' ')}
>
<div>
<div className="text-sm font-semibold">{option.label}</div>
<div className="mt-1 text-xs text-white/50">{option.hint}</div>
</div>
<span className={[
'mt-0.5 inline-flex h-5 w-5 items-center justify-center rounded-full border text-[10px]',
active ? 'border-sky-300/40 bg-sky-400/20 text-sky-100' : 'border-white/10 bg-white/5 text-white/35',
].join(' ')}>
{active ? '✓' : ''}
</span>
</button>
)
})}
</div>
</div>
)}
{/* Schedule picker only shows when enabled for this panel */}
{showVisibility && showScheduleControls && uploadReady && machineState !== 'complete' && (
<SchedulePublishPicker
mode={publishMode}
scheduledAt={scheduledAt}
timezone={timezone}
onModeChange={onPublishModeChange}
onScheduleAt={onScheduleAt}
disabled={!canPublish || isPublishing}
/>
)}
{showRightsConfirmation && (
<div>
<Checkbox
id="publish-rights-confirm"
checked={Boolean(metadata.rightsAccepted)}
onChange={(event) => onToggleRights?.(event.target.checked)}
variant="emerald"
size={18}
label={<span className="text-xs text-white/85">I confirm I own the rights to this content.</span>}
hint={<span className="text-[11px] text-white/50">Required before publishing.</span>}
error={rightsError}
required
/>
</div>
)}
{/* Primary action button */}
<button
type="button"
disabled={!canSchedulePublish || isPublishing}
onClick={() => onPublish?.()}
title={!canPublish ? 'Complete all requirements first' : undefined}
className={[
'w-full rounded-2xl py-3 text-sm font-semibold transition',
canSchedulePublish && !isPublishing
? publishMode === 'schedule'
? 'bg-violet-500/80 text-white hover:bg-violet-500 shadow-[0_4px_16px_rgba(139,92,246,0.25)]'
: 'btn-primary'
: 'cursor-not-allowed bg-white/8 text-white/35 ring-1 ring-white/10',
].join(' ')}
>
{publishLabel()}
</button>
{/* Cancel link */}
{onCancel && machineState !== 'idle' && machineState !== 'complete' && (
<button
type="button"
onClick={onCancel}
className="w-full text-center text-xs text-white/35 hover:text-white/70 transition"
>
Cancel upload
</button>
)}
</div>
)
}

View File

@@ -0,0 +1,55 @@
import React from 'react'
/**
* ReadinessChecklist
*
* Shows upload readiness requirements with status icons.
* Each item can have an optional `href` to jump to the section for a quick fix.
*/
export default function ReadinessChecklist({ items = [] }) {
const allOk = items.every((item) => item.ok)
return (
<div>
<p className="mb-2 text-[10px] uppercase tracking-wider text-white/40">
Readiness
</p>
<ul className="space-y-1" role="list">
{items.map((item) => (
<li key={item.label} className="flex items-center gap-2 text-xs">
<span
className={[
'flex h-4 w-4 shrink-0 items-center justify-center rounded-full text-[9px] font-bold',
item.ok
? 'bg-emerald-500/25 text-emerald-300'
: 'bg-white/8 text-white/30',
].join(' ')}
aria-hidden="true"
>
{item.ok ? '✓' : '○'}
</span>
<span className={item.ok ? 'text-white/70' : 'text-white/40'}>
{(item.onClick || item.href) && !item.ok ? (
<button
type="button"
onClick={item.onClick}
className="text-sky-400 hover:underline focus-visible:outline-none focus-visible:underline"
>
{item.label}
</button>
) : (
item.label
)}
</span>
{item.optional && !item.ok && (
<span className="ml-auto text-[9px] text-white/25 italic">optional</span>
)}
</li>
))}
</ul>
{allOk && (
<p className="mt-2 text-[11px] text-emerald-300/80">All requirements met.</p>
)}
</div>
)
}

View File

@@ -0,0 +1,219 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react'
/**
* SchedulePublishPicker
*
* Toggle between "Publish now" and "Schedule publish".
* When scheduled, shows a date + time input with validation
* (must be >= now + 5 minutes).
*
* Props:
* mode 'now' | 'schedule'
* scheduledAt ISO string | null current scheduled datetime (UTC)
* timezone string IANA tz (e.g. 'Europe/Ljubljana')
* onModeChange (mode) => void
* onScheduleAt (iso | null) => void
* disabled bool
*/
function toLocalDateTimeString(isoString, tz) {
if (!isoString) return { date: '', time: '' }
try {
const d = new Date(isoString)
const opts = { timeZone: tz, year: 'numeric', month: '2-digit', day: '2-digit' }
const dateStr = new Intl.DateTimeFormat('en-CA', opts).format(d) // en-CA gives YYYY-MM-DD
const timeStr = new Intl.DateTimeFormat('en-GB', {
timeZone: tz,
hour: '2-digit',
minute: '2-digit',
hour12: false,
}).format(d)
return { date: dateStr, time: timeStr }
} catch {
return { date: '', time: '' }
}
}
function formatPreviewLabel(isoString, tz) {
if (!isoString) return null
try {
return new Intl.DateTimeFormat('en-GB', {
timeZone: tz,
weekday: 'short',
day: 'numeric',
month: 'short',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
hour12: false,
timeZoneName: 'short',
}).format(new Date(isoString))
} catch {
return isoString
}
}
function localToUtcIso(dateStr, timeStr, tz) {
if (!dateStr || !timeStr) return null
try {
const dtStr = `${dateStr}T${timeStr}:00`
const local = new Date(
new Date(dtStr).toLocaleString('en-US', { timeZone: tz })
)
const utcOffset = new Date(dtStr) - local
const utcDate = new Date(new Date(dtStr).getTime() + utcOffset)
return utcDate.toISOString()
} catch {
return null
}
}
const MIN_FUTURE_MS = 5 * 60 * 1000 // 5 minutes
export default function SchedulePublishPicker({
mode = 'now',
scheduledAt = null,
timezone = Intl.DateTimeFormat().resolvedOptions().timeZone,
onModeChange,
onScheduleAt,
disabled = false,
}) {
const initial = useMemo(
() => toLocalDateTimeString(scheduledAt, timezone),
// eslint-disable-next-line react-hooks/exhaustive-deps
[]
)
const [dateStr, setDateStr] = useState(initial.date || '')
const [timeStr, setTimeStr] = useState(initial.time || '')
const [error, setError] = useState('')
const validate = useCallback(
(d, t) => {
if (!d || !t) return 'Date and time are required.'
const iso = localToUtcIso(d, t, timezone)
if (!iso) return 'Invalid date or time.'
const target = new Date(iso)
if (Number.isNaN(target.getTime())) return 'Invalid date or time.'
if (target.getTime() - Date.now() < MIN_FUTURE_MS) {
return 'Scheduled time must be at least 5 minutes in the future.'
}
return ''
},
[timezone]
)
useEffect(() => {
if (mode !== 'schedule') {
setError('')
return
}
if (!dateStr && !timeStr) {
setError('')
onScheduleAt?.(null)
return
}
const err = validate(dateStr, timeStr)
setError(err)
if (!err) {
onScheduleAt?.(localToUtcIso(dateStr, timeStr, timezone))
} else {
onScheduleAt?.(null)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [dateStr, timeStr, mode])
const previewLabel = useMemo(() => {
if (mode !== 'schedule' || error) return null
const iso = localToUtcIso(dateStr, timeStr, timezone)
return formatPreviewLabel(iso, timezone)
}, [mode, error, dateStr, timeStr, timezone])
return (
<div className="space-y-3">
<div className="flex gap-2" role="group" aria-label="Publish mode">
<button
type="button"
disabled={disabled}
onClick={() => {
onModeChange?.('now')
setError('')
}}
className={[
'flex-1 rounded-lg border py-2 text-sm transition',
mode === 'now'
? 'border-sky-300/60 bg-sky-500/25 text-white'
: 'border-white/15 bg-white/6 text-white/60 hover:bg-white/10',
disabled ? 'cursor-not-allowed opacity-50' : '',
].join(' ')}
aria-pressed={mode === 'now'}
>
Publish now
</button>
<button
type="button"
disabled={disabled}
onClick={() => onModeChange?.('schedule')}
className={[
'flex-1 rounded-lg border py-2 text-sm transition',
mode === 'schedule'
? 'border-sky-300/60 bg-sky-500/25 text-white'
: 'border-white/15 bg-white/6 text-white/60 hover:bg-white/10',
disabled ? 'cursor-not-allowed opacity-50' : '',
].join(' ')}
aria-pressed={mode === 'schedule'}
>
Schedule
</button>
</div>
{mode === 'schedule' && (
<div className="space-y-2 rounded-xl border border-white/10 bg-white/[0.03] p-3">
<div className="flex flex-col gap-2 sm:flex-row">
<div className="flex-1">
<label className="block text-[10px] uppercase tracking-wide text-white/40 mb-1" htmlFor="schedule-date">
Date
</label>
<input
id="schedule-date"
type="date"
disabled={disabled}
value={dateStr}
onChange={(e) => setDateStr(e.target.value)}
min={new Date().toISOString().slice(0, 10)}
className="w-full rounded-lg border border-white/15 bg-white/8 px-3 py-1.5 text-sm text-white placeholder-white/30 focus:outline-none focus:ring-1 focus:ring-sky-400/60 disabled:opacity-50"
/>
</div>
<div className="w-28 shrink-0">
<label className="block text-[10px] uppercase tracking-wide text-white/40 mb-1" htmlFor="schedule-time">
Time
</label>
<input
id="schedule-time"
type="time"
disabled={disabled}
value={timeStr}
onChange={(e) => setTimeStr(e.target.value)}
className="w-full rounded-lg border border-white/15 bg-white/8 px-3 py-1.5 text-sm text-white placeholder-white/30 focus:outline-none focus:ring-1 focus:ring-sky-400/60 disabled:opacity-50"
/>
</div>
</div>
<p className="text-[10px] text-white/35">
Timezone: <span className="text-white/55">{timezone}</span>
</p>
{error && (
<p className="text-xs text-red-400" role="alert">
{error}
</p>
)}
{previewLabel && (
<p className="text-xs text-emerald-300/80">
Will publish on: <span className="font-medium">{previewLabel}</span>
</p>
)}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,164 @@
import React, { useEffect, useMemo, useRef } from 'react'
import { AnimatePresence, motion, useReducedMotion } from 'framer-motion'
export default function ScreenshotUploader({
title = 'Archive screenshots',
description = 'Screenshot requirement placeholder for archive uploads',
visible = false,
files = [],
perFileErrors = [],
errors = [],
invalid = false,
showLooksGood = false,
looksGoodText = 'Looks good',
onFilesChange,
min = 1,
max = 5,
}) {
const inputRef = useRef(null)
const prefersReducedMotion = useReducedMotion()
const quickTransition = prefersReducedMotion
? { duration: 0 }
: { duration: 0.2, ease: 'easeOut' }
const previewItems = useMemo(() => files.map((file) => ({
file,
url: URL.createObjectURL(file),
})), [files])
useEffect(() => {
return () => {
previewItems.forEach((item) => URL.revokeObjectURL(item.url))
}
}, [previewItems])
if (!visible) return null
const emitFiles = (fileList, merge = false) => {
const incoming = Array.from(fileList || [])
const next = merge ? [...files, ...incoming] : incoming
if (typeof onFilesChange === 'function') {
onFilesChange(next.slice(0, max))
}
}
const removeAt = (index) => {
const next = files.filter((_, idx) => idx !== index)
if (typeof onFilesChange === 'function') {
onFilesChange(next)
}
}
return (
<section className={`rounded-xl border bg-gradient-to-br p-5 shadow-[0_12px_28px_rgba(0,0,0,0.3)] transition-colors sm:p-6 ${invalid ? 'border-red-400/45 from-red-500/10 to-slate-900/40' : 'border-amber-300/25 from-amber-500/10 to-slate-900/40'}`}>
{/* Intended props: screenshots, minResolution, maxFileSizeMb, required, onChange, onRemove, error */}
<div className={`rounded-lg border p-4 transition-colors ${invalid ? 'border-red-300/45 bg-red-500/5' : 'border-amber-300/30 bg-black/20'}`}>
<div className="flex flex-wrap items-center justify-between gap-2">
<h3 className="text-lg font-semibold text-amber-100">{title} <span className="text-red-200">(Required)</span></h3>
<span className="rounded-full border border-amber-200/35 bg-amber-500/15 px-2.5 py-1 text-xs text-amber-100">{Math.min(files.length, max)}/{max} screenshots</span>
</div>
<p className="mt-1 text-sm text-amber-100/85">{description}</p>
<div className="mt-3 rounded-lg border border-amber-200/20 bg-amber-500/10 px-3 py-3 text-xs text-amber-50/90">
<p className="font-semibold">Why we need screenshots</p>
<p className="mt-1">Screenshots provide a visual thumbnail and help AI analysis/moderation before archive contents are published.</p>
<p className="mt-2 text-amber-100/85">Rules: JPG/PNG/WEBP · 1280×720 minimum · 10MB max each · {min} to {max} files.</p>
</div>
<div
className={`mt-3 rounded-lg border-2 border-dashed p-4 text-center transition-colors ${invalid ? 'border-red-300/45 bg-red-500/10' : 'border-white/20 bg-white/5 hover:border-amber-300/45 hover:bg-amber-500/5'}`}
onDragOver={(event) => event.preventDefault()}
onDrop={(event) => {
event.preventDefault()
emitFiles(event.dataTransfer?.files, true)
}}
>
<p className="text-sm text-white/85">Drop screenshots here or click to browse</p>
<button
type="button"
className="btn-secondary mt-2 text-xs"
onClick={() => inputRef.current?.click()}
>
Browse screenshots
</button>
<input
ref={inputRef}
type="file"
className="hidden"
aria-label="Screenshot file input"
multiple
accept=".jpg,.jpeg,.png,.webp,image/jpeg,image/png,image/webp"
onChange={(event) => emitFiles(event.target.files, true)}
/>
</div>
<div className="mt-3 text-xs text-white/70">
{files.length} selected · minimum {min}, maximum {max}
</div>
{showLooksGood && (
<div className="mt-2 inline-flex items-center gap-1.5 rounded-full border border-emerald-300/35 bg-emerald-500/15 px-3 py-1 text-xs text-emerald-100">
<span aria-hidden="true"></span>
<span>{looksGoodText}</span>
</div>
)}
{previewItems.length > 0 && (
<ul className="mt-3 grid grid-cols-1 gap-3 sm:grid-cols-2">
<AnimatePresence initial={false}>
{previewItems.map((item, index) => (
<motion.li
layout={!prefersReducedMotion}
key={`${item.file.name}-${index}`}
initial={prefersReducedMotion ? false : { opacity: 0, scale: 0.96 }}
animate={prefersReducedMotion ? {} : { opacity: 1, scale: 1 }}
exit={prefersReducedMotion ? {} : { opacity: 0, scale: 0.96 }}
transition={quickTransition}
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 ring-1 ring-white/10 bg-black/25">
<img
src={item.url}
alt={`Screenshot ${index + 1}`}
className="max-h-full max-w-full object-contain"
loading="lazy"
decoding="async"
width="160"
height="160"
/>
</div>
<div className="mt-2 truncate text-white/90">{item.file.name}</div>
<div className="mt-1 text-white/55">{Math.round(item.file.size / 1024)} KB</div>
{perFileErrors[index] && <div className="mt-1 rounded-md border border-red-300/35 bg-red-500/10 px-2 py-1 text-red-200">{perFileErrors[index]}</div>}
<button
type="button"
onClick={() => removeAt(index)}
className="mt-2 rounded-md border border-white/20 bg-white/5 px-2.5 py-1 text-[11px] text-white/80 transition hover:bg-white/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-amber-300/70"
>
Remove
</button>
</motion.li>
))}
</AnimatePresence>
</ul>
)}
{errors.length > 0 && (
<ul className="mt-3 space-y-1 text-xs text-red-100" role="status" aria-live="polite">
{errors.map((error, index) => (
<li key={`${error}-${index}`} className="rounded-md border border-red-300/35 bg-red-500/10 px-2 py-1">
{error}
</li>
))}
</ul>
)}
{invalid && (
<p className="mt-3 text-xs text-red-200">Continue is blocked until screenshot requirements are valid.</p>
)}
</div>
</section>
)
}

View File

@@ -0,0 +1,141 @@
import React from 'react'
import { motion, useReducedMotion } from 'framer-motion'
/**
* StudioStatusBar
*
* Sticky header beneath the main nav that shows:
* - Step pills (reuse UploadStepper visual style but condensed)
* - Upload progress bar (visible while uploading/processing)
* - Machine-state pill
* - Back / Next primary actions
*/
const STATE_LABELS = {
idle: null,
initializing: 'Initializing…',
uploading: 'Uploading',
finishing: 'Finishing…',
processing: 'Processing',
ready_to_publish: 'Ready',
publishing: 'Publishing…',
complete: 'Published',
error: 'Error',
cancelled: 'Cancelled',
}
const STATE_COLORS = {
idle: '',
initializing: 'bg-sky-500/20 text-sky-200 border-sky-300/30',
uploading: 'bg-sky-500/25 text-sky-100 border-sky-300/40',
finishing: 'bg-sky-400/20 text-sky-200 border-sky-300/30',
processing: 'bg-amber-500/20 text-amber-100 border-amber-300/30',
ready_to_publish: 'bg-emerald-500/20 text-emerald-100 border-emerald-300/35',
publishing: 'bg-sky-500/25 text-sky-100 border-sky-300/40',
complete: 'bg-emerald-500/25 text-emerald-100 border-emerald-300/50',
error: 'bg-red-500/20 text-red-200 border-red-300/30',
cancelled: 'bg-white/8 text-white/50 border-white/15',
}
export default function StudioStatusBar({
steps = [],
activeStep = 1,
highestUnlockedStep = 1,
machineState = 'idle',
progress = 0,
showProgress = false,
onStepClick,
}) {
const prefersReducedMotion = useReducedMotion()
const transition = prefersReducedMotion ? { duration: 0 } : { duration: 0.3, ease: 'easeOut' }
const stateLabel = STATE_LABELS[machineState] ?? machineState
const stateColor = STATE_COLORS[machineState] ?? 'bg-white/8 text-white/50 border-white/15'
return (
<div className="sticky top-0 z-20 -mx-4 px-4 pb-0 pt-2 sm:-mx-6 sm:px-6">
{/* Blur backdrop */}
<div className="absolute inset-0 bg-slate-950/80 backdrop-blur-md" aria-hidden="true" />
<div className="relative overflow-hidden rounded-[24px] border border-white/8 bg-[linear-gradient(180deg,rgba(255,255,255,0.04),rgba(255,255,255,0.02))] px-3 shadow-[0_14px_44px_rgba(2,8,23,0.24)] sm:px-4">
{/* Step pills row */}
<nav aria-label="Upload steps">
<ol className="flex flex-nowrap items-center gap-2 overflow-x-auto py-3 pr-1 sm:gap-3">
{steps.map((step, index) => {
const number = index + 1
const isActive = number === activeStep
const isComplete = number < activeStep
const isLocked = number > highestUnlockedStep
const canNavigate = !isLocked && number < activeStep
const btnClass = [
'inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-[11px] sm:text-xs transition',
isActive
? 'border-sky-300/70 bg-sky-500/25 text-white shadow-[0_10px_30px_rgba(14,165,233,0.14)]'
: isComplete
? 'border-emerald-300/30 bg-emerald-500/15 text-emerald-100 hover:bg-emerald-500/25 cursor-pointer'
: isLocked
? 'cursor-default border-white/10 bg-white/5 text-white/35 pointer-events-none'
: 'border-white/15 bg-white/6 text-white/70 hover:bg-white/12 cursor-pointer',
].join(' ')
const circleClass = isComplete
? 'border-emerald-300/50 bg-emerald-500/20 text-emerald-100'
: isActive
? 'border-sky-300/50 bg-sky-500/25 text-white'
: 'border-white/20 bg-white/6 text-white/60'
return (
<li key={step.key} className="flex shrink-0 items-center gap-2">
<button
type="button"
onClick={() => canNavigate && onStepClick?.(number)}
disabled={isLocked}
aria-disabled={isLocked}
aria-current={isActive ? 'step' : undefined}
className={btnClass}
>
<span className={`grid h-4 w-4 place-items-center rounded-full border text-[10px] shrink-0 ${circleClass}`}>
{isComplete ? '✓' : number}
</span>
<span className="whitespace-nowrap">{step.label}</span>
</button>
{index < steps.length - 1 && (
<span className="text-white/30 select-none text-xs" aria-hidden="true"></span>
)}
</li>
)
})}
{/* Spacer */}
<li className="flex-1" aria-hidden="true" />
{/* State pill */}
{stateLabel && (
<li className="shrink-0">
<span className={`inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[10px] ${stateColor}`}>
{['uploading', 'initializing', 'finishing', 'processing', 'publishing'].includes(machineState) && (
<span className="relative flex h-2 w-2 shrink-0" aria-hidden="true">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-sky-300 opacity-60" />
<span className="relative inline-flex h-2 w-2 rounded-full bg-sky-300" />
</span>
)}
{stateLabel}
</span>
</li>
)}
</ol>
</nav>
{/* Progress bar (shown during upload/processing) */}
{showProgress && (
<div className="mb-2 h-1 w-full overflow-hidden rounded-full bg-white/8">
<motion.div
className="h-full rounded-full bg-gradient-to-r from-sky-400 via-cyan-300 to-emerald-300"
animate={{ width: `${progress}%` }}
transition={transition}
/>
</div>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,160 @@
import React, { useEffect, useState } from 'react'
export default function UploadActions({
step = 1,
canStart = false,
canContinue = false,
canPublish = false,
canGoBack = false,
canReset = true,
canCancel = false,
canRetry = false,
isUploading = false,
isProcessing = false,
isPublishing = false,
isCancelling = false,
disableReason = 'Complete required fields',
onStart,
onContinue,
onPublish,
onBack,
onCancel,
onReset,
onRetry,
onSaveDraft,
showSaveDraft = false,
mobileSticky = true,
resetLabel = 'Reset',
publishLabel = 'Publish',
}) {
const [confirmCancel, setConfirmCancel] = useState(false)
useEffect(() => {
if (!confirmCancel) return
const timer = window.setTimeout(() => setConfirmCancel(false), 3200)
return () => window.clearTimeout(timer)
}, [confirmCancel])
const handleCancel = () => {
if (!canCancel || isCancelling) return
if (!confirmCancel) {
setConfirmCancel(true)
return
}
setConfirmCancel(false)
onCancel?.()
}
const renderPrimary = () => {
if (step === 1) {
const disabled = !canStart || isUploading || isProcessing || isCancelling
const label = isUploading ? 'Uploading…' : isProcessing ? 'Processing…' : 'Start upload'
return (
<button
type="button"
disabled={disabled}
title={disabled ? disableReason : 'Start upload'}
onClick={() => onStart?.()}
className={`btn-primary text-sm ${disabled ? 'cursor-not-allowed opacity-60' : ''}`}
>
{label}
</button>
)
}
if (step === 2) {
const disabled = !canContinue
return (
<button
type="button"
disabled={disabled}
title={disabled ? disableReason : 'Continue to Publish'}
onClick={() => onContinue?.()}
className={`btn-primary text-sm ${disabled ? 'cursor-not-allowed opacity-60' : ''}`}
>
Continue to Publish
</button>
)
}
const disabled = !canPublish || isPublishing
return (
<button
type="button"
disabled={disabled}
title={disabled ? disableReason : publishLabel}
onClick={() => onPublish?.()}
className={`btn-primary text-sm ${disabled ? 'cursor-not-allowed opacity-60' : ''}`}
>
{isPublishing ? `${publishLabel}` : publishLabel}
</button>
)
}
return (
<footer data-testid="wizard-action-bar" className={`${mobileSticky ? 'sticky bottom-0 z-20 px-4 pb-3 lg:static lg:px-0 lg:pb-0' : ''}`}>
<div className="mx-auto w-full max-w-4xl rounded-[24px] border border-white/10 bg-[#08111c]/88 p-3 shadow-[0_-12px_32px_rgba(2,8,23,0.65)] backdrop-blur-sm sm:p-4 lg:shadow-none">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="text-xs uppercase tracking-[0.18em] text-white/35">
{step === 1 ? 'Step 1 of 3' : step === 2 ? 'Step 2 of 3' : 'Step 3 of 3'}
</div>
<div className="flex flex-wrap items-center justify-end gap-2.5">
{canGoBack && (
<button
type="button"
onClick={() => onBack?.()}
className="btn-secondary text-sm"
>
Back
</button>
)}
{showSaveDraft && (
<button
type="button"
onClick={() => onSaveDraft?.()}
className="btn-secondary text-sm"
>
Save draft
</button>
)}
{step === 1 && canCancel && (
<button
type="button"
onClick={handleCancel}
disabled={isCancelling}
title={confirmCancel ? 'Click again to confirm cancel' : 'Cancel current upload'}
className="btn-secondary text-sm disabled:cursor-not-allowed disabled:opacity-60"
>
{isCancelling ? 'Cancelling…' : confirmCancel ? 'Cancel upload?' : 'Cancel'}
</button>
)}
{canRetry && (
<button
type="button"
onClick={() => onRetry?.()}
className="btn-secondary text-sm"
>
Retry
</button>
)}
{canReset && (
<button
type="button"
onClick={() => onReset?.()}
className="btn-secondary text-sm"
>
{resetLabel}
</button>
)}
{renderPrimary()}
</div>
</div>
</div>
</footer>
)
}

View File

@@ -0,0 +1,208 @@
import React, { useRef, useState } from 'react'
import { AnimatePresence, motion, useReducedMotion } from 'framer-motion'
function getExtension(fileName = '') {
const parts = String(fileName).toLowerCase().split('.')
return parts.length > 1 ? parts.pop() : ''
}
function detectPrimaryType(file) {
if (!file) return 'unknown'
const extension = getExtension(file.name)
const mime = String(file.type || '').toLowerCase()
const imageExt = new Set(['jpg', 'jpeg', 'png', 'webp'])
const archiveExt = new Set(['zip', 'rar', '7z', 'tar', 'gz'])
const imageMime = new Set(['image/jpeg', 'image/png', 'image/webp'])
const archiveMime = new Set([
'application/zip',
'application/x-zip-compressed',
'application/x-rar-compressed',
'application/vnd.rar',
'application/x-7z-compressed',
'application/x-tar',
'application/gzip',
'application/x-gzip',
'application/octet-stream',
])
if (imageMime.has(mime) || imageExt.has(extension)) return 'image'
if (archiveMime.has(mime) || archiveExt.has(extension)) return 'archive'
return 'unsupported'
}
export default function UploadDropzone({
title = 'Upload file',
description = 'Drop file here or click to browse',
fileName = '',
fileHint = 'No file selected yet',
previewUrl = '',
fileMeta = null,
errors = [],
invalid = false,
showLooksGood = false,
looksGoodText = 'Looks good',
locked = false,
onPrimaryFileChange,
onValidationResult,
}) {
const [dragging, setDragging] = useState(false)
const inputRef = useRef(null)
const prefersReducedMotion = useReducedMotion()
const dragTransition = prefersReducedMotion
? { duration: 0 }
: { duration: 0.2, ease: 'easeOut' }
const emitFile = (file) => {
const detectedType = detectPrimaryType(file)
if (typeof onPrimaryFileChange === 'function') {
onPrimaryFileChange(file, { detectedType })
}
if (typeof onValidationResult === 'function') {
onValidationResult({ file, detectedType })
}
}
return (
<section className={`rounded-[28px] bg-gradient-to-br p-0 shadow-[0_20px_60px_rgba(0,0,0,0.30)] 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"
role="button"
aria-disabled={locked ? 'true' : 'false'}
tabIndex={locked ? -1 : 0}
onClick={() => {
if (locked) return
inputRef.current?.click()
}}
onKeyDown={(event) => {
if (locked) return
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
inputRef.current?.click()
}
}}
onDragOver={(event) => {
if (locked) return
event.preventDefault()
setDragging(true)
}}
onDragLeave={() => setDragging(false)}
onDrop={(event) => {
if (locked) return
event.preventDefault()
setDragging(false)
const droppedFile = event.dataTransfer?.files?.[0]
if (droppedFile) emitFile(droppedFile)
}}
animate={prefersReducedMotion ? undefined : { scale: dragging ? 1.01 : 1 }}
transition={dragTransition}
className={`group rounded-[26px] border-2 border-dashed border-white/15 px-5 py-7 text-center transition hover:border-accent/60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-300/70 sm:px-6 ${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-[linear-gradient(180deg,rgba(14,165,233,0.08),rgba(255,255,255,0.02))] 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>
{previewUrl ? (
<div className="mt-2 w-full flex flex-col items-center gap-2">
<div className="flex h-52 w-64 items-center justify-center overflow-hidden rounded-lg bg-black/40 ring-1 ring-white/10">
<img
src={previewUrl}
alt="Selected preview"
className="h-full w-full object-contain object-center"
loading="lazy"
decoding="async"
width="250"
height="208"
/>
</div>
<div className="text-xs text-white/70">Click to replace</div>
</div>
) : (
<>
<div className="mx-auto flex h-16 w-16 items-center justify-center rounded-2xl border border-sky-400/40 bg-sky-500/12 text-sky-100 shadow-[0_14px_40px_rgba(14,165,233,0.18)]">
<svg viewBox="0 0 24 24" className="h-7 w-7" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M21 15v4a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1v-4" />
<path d="M7 10l5-5 5 5" />
<path d="M12 5v10" />
</svg>
</div>
<div className="mt-3 flex flex-wrap items-center justify-center gap-2 text-[11px] text-white/65">
<span className="rounded-full border border-white/10 bg-white/5 px-2.5 py-1">JPG, PNG, WEBP</span>
<span className="rounded-full border border-white/10 bg-white/5 px-2.5 py-1">ZIP, RAR, 7Z</span>
<span className="rounded-full border border-white/10 bg-white/5 px-2.5 py-1">50MB images</span>
<span className="rounded-full border border-white/10 bg-white/5 px-2.5 py-1">200MB archives</span>
</div>
<span className={`btn-secondary mt-4 inline-flex text-sm ${locked ? 'opacity-80' : 'group-focus-visible:bg-white/15'}`}>
Click to browse files
</span>
</>
)}
<input
ref={inputRef}
type="file"
className="hidden"
aria-label="Upload file input"
disabled={locked}
accept=".jpg,.jpeg,.png,.webp,.zip,.rar,.7z,.tar,.gz,image/jpeg,image/png,image/webp"
onChange={(event) => {
const selectedFile = event.target.files?.[0]
if (selectedFile) {
emitFile(selectedFile)
}
}}
/>
{(previewUrl || (fileMeta && String(fileMeta.type || '').startsWith('image/'))) && (
<div className="mt-4 rounded-2xl ring-1 ring-white/10 bg-black/25 px-4 py-3 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 && (
<div className="mt-1 flex flex-wrap gap-2 text-xs text-white/60">
<span>Type: <span className="text-white/80">{fileMeta.type || '—'}</span></span>
<span>·</span>
<span>Size: <span className="text-white/80">{fileMeta.size || '—'}</span></span>
<span>·</span>
<span>Resolution: <span className="text-white/80">{fileMeta.resolution || '—'}</span></span>
</div>
)}
</div>
)}
{showLooksGood && (
<div className="mt-3 inline-flex items-center gap-1.5 rounded-full border border-emerald-300/35 bg-emerald-500/15 px-3 py-1 text-xs text-emerald-100">
<span aria-hidden="true"></span>
<span>{looksGoodText}</span>
</div>
)}
<AnimatePresence initial={false}>
{errors.length > 0 && (
<motion.div
key="dropzone-errors"
initial={prefersReducedMotion ? false : { opacity: 0, y: 4 }}
animate={prefersReducedMotion ? {} : { opacity: 1, y: 0 }}
exit={prefersReducedMotion ? {} : { opacity: 0, y: -4 }}
transition={dragTransition}
className="mt-4 rounded-lg border border-red-300/40 bg-red-500/10 p-3 text-left"
>
<p className="text-xs font-semibold uppercase tracking-wide text-red-100">Please fix the following</p>
<ul className="mt-2 space-y-1 text-xs text-red-100" role="status" aria-live="polite">
{errors.map((error, index) => (
<li key={`${error}-${index}`} className="rounded-md border border-red-300/35 bg-red-500/10 px-2 py-1">
{error}
</li>
))}
</ul>
</motion.div>
)}
</AnimatePresence>
</motion.div>
</section>
)
}

View File

@@ -0,0 +1,245 @@
import React from 'react'
import { AnimatePresence, motion, useReducedMotion } from 'framer-motion'
/**
* UploadOverlay
*
* A centered modal-style progress overlay shown while an upload or processing
* job is in flight.
*
* Shows:
* - State icon + label + live percentage
* - Thick animated progress bar with gradient
* - Processing transparency label (what the backend is doing)
* - Error strip with Retry / Reset when something goes wrong
*/
const ACTIVE_STATES = new Set([
'initializing',
'uploading',
'finishing',
'processing',
])
const STATE_META = {
initializing: {
label: 'Initializing',
sublabel: 'Preparing your upload…',
color: 'text-sky-300',
barColor: 'from-sky-500 via-sky-400 to-cyan-300',
icon: (
<svg className="h-4 w-4 shrink-0 animate-spin" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<circle className="opacity-20" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="3" />
<path className="opacity-80" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
),
},
uploading: {
label: 'Uploading',
sublabel: 'Sending your file to the server…',
color: 'text-sky-300',
barColor: 'from-sky-500 via-cyan-400 to-teal-300',
icon: (
<svg className="h-4 w-4 shrink-0" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path fillRule="evenodd" d="M10 3a1 1 0 011 1v7.586l1.293-1.293a1 1 0 111.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 111.414-1.414L9 11.586V4a1 1 0 011-1zM3 17a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1z" clipRule="evenodd" />
</svg>
),
},
finishing: {
label: 'Finishing',
sublabel: 'Wrapping up the transfer…',
color: 'text-cyan-300',
barColor: 'from-cyan-500 via-teal-400 to-emerald-300',
icon: (
<svg className="h-4 w-4 shrink-0 animate-spin" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<circle className="opacity-20" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="3" />
<path className="opacity-80" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
),
},
processing: {
label: 'Processing',
sublabel: 'Analyzing your artwork…',
color: 'text-amber-300',
barColor: 'from-amber-500 via-yellow-400 to-lime-300',
icon: (
<svg className="h-4 w-4 shrink-0" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path fillRule="evenodd" d="M11.49 3.17c-.38-1.56-2.6-1.56-2.98 0a1.532 1.532 0 01-2.286.948c-1.372-.836-2.942.734-2.106 2.106.54.886.061 2.042-.947 2.287-1.561.379-1.561 2.6 0 2.978a1.532 1.532 0 01.947 2.287c-.836 1.372.734 2.942 2.106 2.106a1.532 1.532 0 012.287.947c.379 1.561 2.6 1.561 2.978 0a1.533 1.533 0 012.287-.947c1.372.836 2.942-.734 2.106-2.106a1.533 1.533 0 01.947-2.287c1.561-.379 1.561-2.6 0-2.978a1.532 1.532 0 01-.947-2.287c.836-1.372-.734-2.942-2.106-2.106a1.532 1.532 0 01-2.287-.947zM10 13a3 3 0 100-6 3 3 0 000 6z" clipRule="evenodd" />
</svg>
),
},
error: {
label: 'Upload failed',
sublabel: null,
color: 'text-rose-300',
barColor: 'from-rose-600 via-rose-500 to-rose-400',
icon: (
<svg className="h-4 w-4 shrink-0" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path fillRule="evenodd" d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7 4a1 1 0 11-2 0 1 1 0 012 0zm-1-9a1 1 0 00-1 1v4a1 1 0 102 0V6a1 1 0 00-1-1z" clipRule="evenodd" />
</svg>
),
},
}
export default function UploadOverlay({
machineState = 'idle',
progress = 0,
processingLabel = null,
error = null,
onRetry,
onReset,
}) {
const prefersReducedMotion = useReducedMotion()
const isVisible = ACTIVE_STATES.has(machineState) || machineState === 'error'
const meta = STATE_META[machineState] ?? STATE_META.uploading
const barTransition = prefersReducedMotion
? { duration: 0 }
: { duration: 0.4, ease: 'easeOut' }
const overlayTransition = prefersReducedMotion
? { duration: 0 }
: { duration: 0.25, ease: [0.32, 0.72, 0, 1] }
const displayLabel = processingLabel || meta.sublabel
return (
<AnimatePresence initial={false}>
{isVisible && (
<motion.div
key="upload-overlay"
initial={prefersReducedMotion ? false : { opacity: 0 }}
animate={{ opacity: 1 }}
exit={prefersReducedMotion ? {} : { opacity: 0 }}
transition={overlayTransition}
className="fixed inset-0 z-[80] flex items-center justify-center p-4 sm:p-6"
>
<div className="absolute inset-0 bg-slate-950/72 backdrop-blur-sm" aria-hidden="true" />
<motion.div
role="dialog"
aria-modal="true"
aria-labelledby="upload-overlay-title"
aria-describedby="upload-overlay-description"
initial={prefersReducedMotion ? false : { opacity: 0, y: 18, scale: 0.97 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={prefersReducedMotion ? {} : { opacity: 0, y: 12, scale: 0.98 }}
transition={overlayTransition}
className="relative w-full max-w-xl overflow-hidden rounded-[28px] border border-white/10 bg-[linear-gradient(180deg,rgba(6,14,24,0.96),rgba(2,8,23,0.92))] px-5 pb-5 pt-5 shadow-[0_30px_120px_rgba(2,8,23,0.72)] ring-1 ring-inset ring-white/8 backdrop-blur-xl sm:px-6 sm:pb-6 sm:pt-6"
>
<div
role="status"
aria-live="polite"
aria-label={`${meta.label}${progress > 0 ? `${progress}%` : ''}`}
>
<div className="mb-4 flex items-start justify-between gap-4">
<div>
<div className={`flex items-center gap-2 ${meta.color}`}>
{meta.icon}
<span id="upload-overlay-title" className="text-xl font-semibold tracking-tight">
{meta.label}
</span>
{machineState !== 'error' && (
<span className="relative flex h-2.5 w-2.5 shrink-0" aria-hidden="true">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-current opacity-50" />
<span className="relative inline-flex h-2.5 w-2.5 rounded-full bg-current opacity-80" />
</span>
)}
</div>
<p id="upload-overlay-description" className="mt-2 text-sm text-white/60">
{machineState === 'error'
? 'The upload was interrupted. You can retry safely or start over.'
: 'Keep this tab open while we finish the upload and process your artwork.'}
</p>
</div>
{machineState !== 'error' && (
<span className={`shrink-0 tabular-nums text-2xl font-bold ${meta.color}`}>
{progress}%
</span>
)}
</div>
<div className="rounded-2xl border border-white/8 bg-black/20 p-4 sm:p-5">
<div className="flex items-center justify-between gap-3">
<span className={`text-lg font-semibold ${meta.color}`}>
{meta.label}
</span>
{machineState !== 'error' && (
<span className="text-sm text-white/45">Secure pipeline active</span>
)}
</div>
<div className="mt-4 h-3 w-full overflow-hidden rounded-full bg-white/8">
<motion.div
className={`h-full rounded-full bg-gradient-to-r ${meta.barColor}`}
animate={{ width: machineState === 'error' ? '100%' : `${progress}%` }}
transition={barTransition}
style={machineState === 'error' ? { opacity: 0.35 } : {}}
/>
</div>
<AnimatePresence mode="wait" initial={false}>
{machineState !== 'error' && displayLabel && (
<motion.p
key={displayLabel}
initial={prefersReducedMotion ? false : { opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
exit={prefersReducedMotion ? {} : { opacity: 0, y: -4 }}
transition={{ duration: 0.2 }}
className="mt-4 text-sm text-white/60"
>
{displayLabel}
</motion.p>
)}
</AnimatePresence>
{machineState !== 'error' && (
<p className="mt-2 text-xs uppercase tracking-[0.2em] text-white/30">
Progress updates are live
</p>
)}
</div>
<AnimatePresence initial={false}>
{machineState === 'error' && (
<motion.div
key="error-block"
initial={prefersReducedMotion ? false : { opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={prefersReducedMotion ? {} : { opacity: 0, height: 0 }}
transition={{ duration: 0.2 }}
className="overflow-hidden"
>
<div className="mt-4 rounded-2xl border border-rose-400/20 bg-rose-500/10 px-4 py-4">
<p className="text-sm leading-relaxed text-rose-100">
{error || 'Something went wrong. You can retry safely.'}
</p>
<div className="mt-4 flex flex-wrap gap-2">
<button
type="button"
onClick={onRetry}
className="rounded-lg border border-rose-300/30 bg-rose-400/15 px-3.5 py-2 text-sm font-medium text-rose-100 transition hover:bg-rose-400/25 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-rose-300/60"
>
Retry upload
</button>
<button
type="button"
onClick={onReset}
className="rounded-lg border border-white/20 bg-white/8 px-3.5 py-2 text-sm font-medium text-white/70 transition hover:bg-white/14 hover:text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/40"
>
Start over
</button>
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
</motion.div>
</motion.div>
)}
</AnimatePresence>
)
}

View File

@@ -0,0 +1,76 @@
import React from 'react'
export default function UploadPreview({
title = 'Preview',
description = 'Live artwork preview placeholder',
previewUrl = '',
isArchive = false,
metadata = {
resolution: '—',
size: '—',
type: '—',
},
warnings = [],
errors = [],
invalid = false,
}) {
return (
<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 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">
{isArchive ? 'Archive' : 'Image'}
</span>
</div>
<p className="mt-1 text-sm text-white/65">{description}</p>
<div className="mt-4 flex flex-col md:flex-row gap-4 items-start">
<div className="w-40 h-40 rounded-lg overflow-hidden bg-black/40 ring-1 ring-white/10 flex items-center justify-center">
{previewUrl && !isArchive ? (
<img src={previewUrl} alt="Upload preview" className="max-w-full max-h-full object-contain" />
) : (
<span className="text-sm text-soft">{isArchive ? 'Archive selected' : 'Image preview placeholder'}</span>
)}
</div>
<div className="flex-1 space-y-2 text-sm">
<div>
<span className="text-soft">Type</span>
<span className="text-white ml-2">{metadata.type}</span>
</div>
<div>
<span className="text-soft">Size</span>
<span className="text-white ml-2">{metadata.size}</span>
</div>
<div>
<span className="text-soft">Resolution</span>
<span className="text-white ml-2">{metadata.resolution}</span>
</div>
{errors.length > 0 && (
<ul className="space-y-1" role="status" aria-live="polite">
{errors.map((error, index) => (
<li key={`${error}-${index}`} className="text-red-400 text-xs">
{error}
</li>
))}
</ul>
)}
</div>
</div>
{warnings.length > 0 && (
<ul className="mt-4 space-y-1 text-xs text-amber-100" role="status" aria-live="polite">
{warnings.map((warning, index) => (
<li key={`${warning}-${index}`} className="rounded-md border border-amber-300/35 bg-amber-500/10 px-2 py-1">
{warning}
</li>
))}
</ul>
)}
</div>
</section>
)
}

View File

@@ -0,0 +1,141 @@
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 2030 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>
)
}

View File

@@ -0,0 +1,132 @@
import React from 'react'
import TagPicker from '../tags/TagPicker'
import Checkbox from '../../Components/ui/Checkbox'
import RichTextEditor from '../forum/RichTextEditor'
import SchedulePublishPicker from './SchedulePublishPicker'
export default function UploadSidebar({
title = 'Artwork details',
description = 'Complete metadata before publishing',
showHeader = true,
metadata,
suggestedTags = [],
errors = {},
publishMode,
scheduledAt,
timezone,
onPublishModeChange,
onScheduleAt,
onChangeTitle,
onChangeTags,
onChangeDescription,
onToggleMature,
onToggleRights,
}) {
return (
<div className="space-y-5">
{showHeader && (
<div className="mb-5 rounded-xl border border-white/8 bg-white/[0.04] p-4">
<h3 className="text-lg font-semibold text-white">{title}</h3>
<p className="mt-1 text-sm text-white/65">{description}</p>
</div>
)}
<div className="space-y-5">
<section className="rounded-2xl border border-white/10 bg-white/[0.03] p-5">
<div className="mb-3">
<h4 className="text-sm font-semibold text-white">Basics</h4>
<p className="mt-1 text-xs text-white/60">Add a clear title and short description.</p>
</div>
<div className="space-y-4">
<label className="block">
<span className="text-sm font-medium text-white/90">Title <span className="text-red-300">*</span></span>
<input
id="upload-sidebar-title"
value={metadata.title}
onChange={(event) => onChangeTitle?.(event.target.value)}
className={`mt-2 w-full rounded-xl border bg-white/10 px-3 py-2 text-sm text-white focus:outline-none focus:ring-2 ${errors.title ? 'border-red-300/60 focus:ring-red-300/70' : 'border-white/15 focus:ring-sky-300/70'}`}
placeholder="Give your artwork a clear title"
/>
{errors.title && <p className="mt-1 text-xs text-red-200">{errors.title}</p>}
</label>
<label className="block">
<span className="text-sm font-medium text-white/90">Description <span className="text-red-300">*</span></span>
<div className="mt-2">
<RichTextEditor
content={metadata.description}
onChange={onChangeDescription}
placeholder="Describe your artwork, tools, inspiration…"
error={Array.isArray(errors.description) ? errors.description[0] : errors.description}
minHeight={12}
autofocus={false}
/>
</div>
</label>
</div>
</section>
<section className="rounded-2xl border border-white/10 bg-white/[0.03] p-5">
<div className="mb-3">
<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>
<TagPicker
value={metadata.tags}
onChange={(nextTags) => onChangeTags?.(nextTags)}
suggestedTags={suggestedTags}
maxTags={30}
searchEndpoint="/api/tags/search"
popularEndpoint="/api/tags/popular"
error={errors.tags}
/>
</section>
{typeof publishMode === 'string' && typeof onPublishModeChange === 'function' && (
<section className="rounded-2xl border border-white/10 bg-white/[0.03] p-5">
<div className="mb-3">
<h4 className="text-sm font-semibold text-white">Publish settings</h4>
<p className="mt-1 text-xs text-white/60">Choose whether this artwork should publish immediately or on a schedule.</p>
</div>
<SchedulePublishPicker
mode={publishMode}
scheduledAt={scheduledAt}
timezone={timezone}
onModeChange={onPublishModeChange}
onScheduleAt={onScheduleAt}
disabled={false}
/>
</section>
)}
<section className="rounded-2xl border border-white/10 bg-white/[0.03] p-5">
<Checkbox
id="upload-sidebar-mature"
checked={Boolean(metadata.isMature)}
onChange={(event) => onToggleMature?.(event.target.checked)}
variant="accent"
size={20}
label="Mark this artwork as mature content."
hint="Use this for NSFW, explicit, or otherwise age-restricted artwork."
/>
</section>
<section className="rounded-2xl border border-white/10 bg-white/[0.03] p-5">
<Checkbox
id="upload-sidebar-rights"
checked={Boolean(metadata.rightsAccepted)}
onChange={(event) => onToggleRights?.(event.target.checked)}
variant="emerald"
size={20}
label="I confirm I own the rights to this content."
hint="Required before publishing."
error={errors.rights}
required
/>
</section>
</div>
</div>
)
}

View File

@@ -0,0 +1,53 @@
import React from 'react'
export default function UploadStepper({ steps = [], activeStep = 1, highestUnlockedStep = 1, onStepClick }) {
const safeActive = Math.max(1, Math.min(steps.length || 1, activeStep))
return (
<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
const isActive = number === safeActive
const isComplete = number < safeActive
const isLocked = number > highestUnlockedStep
const canNavigate = number < safeActive && !isLocked
const baseBtn = 'inline-flex items-center gap-2 rounded-full border px-2.5 py-1.5 text-xs sm:px-3'
const stateClass = isActive
? 'border-sky-300/80 bg-sky-500/30 text-white shadow-[0_8px_24px_rgba(14,165,233,0.12)]'
: isComplete
? 'border-emerald-300/30 bg-emerald-500/15 text-emerald-100 hover:bg-emerald-500/25'
: isLocked
? '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
? 'border-emerald-300/60 bg-emerald-500/20 text-emerald-100'
: isActive
? 'border-sky-300/60 bg-sky-500/30 text-white'
: 'border-white/20 bg-white/5 text-white/80'
return (
<li key={step.key} className="flex-shrink-0 flex items-center gap-3">
<button
type="button"
onClick={() => canNavigate && onStepClick?.(number)}
disabled={isLocked}
aria-disabled={isLocked ? 'true' : 'false'}
aria-current={isActive ? 'step' : undefined}
className={`${baseBtn} ${stateClass} flex-shrink-0`}
>
<span className={`grid h-5 w-5 place-items-center rounded-full border text-[11px] ${circleClass}`}>
{isComplete ? '✓' : number}
</span>
<span className="whitespace-nowrap pr-3">{step.label}</span>
</button>
{index < steps.length - 1 && <span className="text-white/50 mx-1 select-none"></span>}
</li>
)
})}
</ol>
</nav>
)
}

View File

@@ -0,0 +1,944 @@
/**
* UploadWizard refactored orchestrator
*
* A 3-step upload wizard that delegates:
* - Machine state → useUploadMachine
* - File validation → useFileValidation
* - Vision AI tags → useVisionTags
* - Step rendering → Step1FileUpload / Step2Details / Step3Publish
* - Reusable UI → ContentTypeSelector / CategorySelector / UploadSidebar …
*/
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { AnimatePresence, motion, useReducedMotion } from 'framer-motion'
import useUploadMachine, { machineStates } from '../../hooks/upload/useUploadMachine'
import useFileValidation from '../../hooks/upload/useFileValidation'
import useVisionTags from '../../hooks/upload/useVisionTags'
import StudioStatusBar from './StudioStatusBar'
import UploadOverlay from './UploadOverlay'
import UploadActions from './UploadActions'
import PublishPanel from './PublishPanel'
import Step1FileUpload from './steps/Step1FileUpload'
import Step2Details from './steps/Step2Details'
import Step3Publish from './steps/Step3Publish'
import {
buildCategoryTree,
getContentTypeValue,
getProcessingTransparencyLabel,
} from '../../lib/uploadUtils'
// ─── Wizard step config ───────────────────────────────────────────────────────
const wizardSteps = [
{ key: 'upload', label: 'Upload' },
{ key: 'details', label: 'Details' },
{ key: 'publish', label: 'Publish' },
]
function createInitialMetadata(initialGroupSlug = '', currentUserId = null, contributorOptionsByGroup = {}) {
const normalizedGroupSlug = String(initialGroupSlug || '').trim()
const contributors = Array.isArray(contributorOptionsByGroup?.[normalizedGroupSlug])
? contributorOptionsByGroup[normalizedGroupSlug]
: []
const defaultPrimaryAuthor = contributors.some((user) => Number(user.id) === Number(currentUserId))
? Number(currentUserId)
: Number(contributors[0]?.id || 0) || null
return {
title: '',
rootCategoryId: '',
subCategoryId: '',
tags: [],
description: '',
isMature: false,
rightsAccepted: false,
contentType: '',
group: normalizedGroupSlug,
primaryAuthorUserId: defaultPrimaryAuthor,
contributorUserIds: [],
contributorCredits: {},
}
}
function normalizeContributorCredits(contributorIds = [], contributorCredits = {}) {
const normalized = {}
const ids = Array.isArray(contributorIds)
? contributorIds.map((id) => Number(id)).filter((id) => Number.isFinite(id) && id > 0)
: []
ids.forEach((id) => {
const current = contributorCredits?.[id] || contributorCredits?.[String(id)] || {}
normalized[id] = {
creditRole: typeof current.creditRole === 'string' ? current.creditRole : '',
isPrimary: Boolean(current.isPrimary),
}
})
const leadIds = Object.entries(normalized)
.filter(([, value]) => value.isPrimary)
.map(([id]) => Number(id))
if (leadIds.length > 1) {
leadIds.slice(1).forEach((id) => {
normalized[id] = {
...normalized[id],
isPrimary: false,
}
})
}
return normalized
}
// ─── Helpers ─────────────────────────────────────────────────────────────────
function isValidForUpload(primaryFile, primaryErrors, isArchive, screenshotErrors) {
if (!primaryFile) return false
if (primaryErrors.length > 0) return false
if (isArchive && screenshotErrors.length > 0) return false
return true
}
// ─── Component ────────────────────────────────────────────────────────────────
export default function UploadWizard({
onValidationStateChange,
initialDraftId = null,
chunkSize,
chunkRequestTimeoutMs,
contentTypes = [],
suggestedTags = [],
groupOptions = [],
contributorOptionsByGroup = {},
initialGroupSlug = '',
currentUserId = null,
}) {
const [notices, setNotices] = useState([])
// ── UI state ──────────────────────────────────────────────────────────────
const [activeStep, setActiveStep] = useState(1)
const [showRestoredBanner, setShowRestoredBanner] = useState(Boolean(initialDraftId))
const [isUploadLocked, setIsUploadLocked] = useState(false)
const [resolvedArtworkId, setResolvedArtworkId] = useState(() => {
const parsed = Number(initialDraftId)
return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : null
})
// ── Publish options (Studio) ──────────────────────────────────────────────
const [publishMode, setPublishMode] = useState('now') // 'now' | 'schedule'
const [scheduledAt, setScheduledAt] = useState(null) // UTC ISO or null
const [visibility, setVisibility] = useState('public') // 'public'|'unlisted'|'private'
const [showMobilePublishPanel, setShowMobilePublishPanel] = useState(false)
const userTimezone = useMemo(() => {
try { return Intl.DateTimeFormat().resolvedOptions().timeZone } catch { return 'UTC' }
}, [])
// ── File + screenshot state ───────────────────────────────────────────────
const [primaryFile, setPrimaryFile] = useState(null)
const [screenshots, setScreenshots] = useState([])
const [selectedScreenshotIndex, setSelectedScreenshotIndex] = useState(0)
// ── Metadata state ────────────────────────────────────────────────────────
const [metadata, setMetadata] = useState(() => createInitialMetadata(initialGroupSlug, currentUserId, contributorOptionsByGroup))
// ── Refs ──────────────────────────────────────────────────────────────────
const prefersReducedMotion = useReducedMotion()
const stepContentRef = useRef(null)
const stepHeadingRef = useRef(null)
const hasAutoAdvancedRef = useRef(false)
const quickTransition = prefersReducedMotion ? { duration: 0 } : { duration: 0.2, ease: 'easeOut' }
// ── File validation hook ──────────────────────────────────────────────────
const {
primaryType,
primaryErrors,
primaryWarnings,
fileMetadata,
primaryPreviewUrl,
screenshotErrors,
screenshotPerFileErrors,
} = useFileValidation(primaryFile, screenshots)
const isArchive = primaryType === 'archive'
const canStartUpload = isValidForUpload(primaryFile, primaryErrors, isArchive, screenshotErrors)
useEffect(() => {
if (!Array.isArray(screenshots) || screenshots.length === 0) {
setSelectedScreenshotIndex(0)
return
}
setSelectedScreenshotIndex((prev) => {
if (!Number.isFinite(prev) || prev < 0) return 0
return Math.min(prev, screenshots.length - 1)
})
}, [screenshots])
// ── Machine hook ──────────────────────────────────────────────────────────
const {
machine,
runUploadFlow,
handleCancel,
handlePublish,
handleRetry,
resetMachine,
abortAllRequests,
clearPolling,
} = useUploadMachine({
primaryFile,
screenshots,
selectedScreenshotIndex,
canStartUpload,
primaryType,
isArchive,
initialDraftId,
metadata,
chunkSize,
chunkRequestTimeoutMs,
onArtworkCreated: (id) => setResolvedArtworkId(id),
onNotice: (notice) => {
if (!notice?.message) return
const normalizedType = ['success', 'warning', 'error'].includes(String(notice.type || '').toLowerCase())
? String(notice.type).toLowerCase()
: 'error'
const id = `${Date.now()}-${Math.random().toString(16).slice(2)}`
setNotices((prev) => [...prev, { id, type: normalizedType, message: String(notice.message) }])
window.setTimeout(() => {
setNotices((prev) => prev.filter((item) => item.id !== id))
}, 4500)
},
})
// ── Upload-ready flag (needed before vision hook) ─────────────────────────
const uploadReady = (
machine.state === machineStates.ready_to_publish ||
machine.processingStatus === 'ready' ||
machine.state === machineStates.complete
)
// ── Vision tags hook fires on upload completion, not step change ──────────
// Starts fetching AI tag suggestions while the user is still on Step 1,
// so results are ready (or partially ready) by the time Step 2 opens.
const { visionSuggestedTags } = useVisionTags(resolvedArtworkId, uploadReady)
// ── Category tree computation ─────────────────────────────────────────────
const categoryTreeByType = useMemo(() => {
const result = {}
const list = Array.isArray(contentTypes) ? contentTypes : []
list.forEach((type) => {
const value = getContentTypeValue(type)
if (!value) return
result[value] = buildCategoryTree([type])
})
return result
}, [contentTypes])
const filteredCategoryTree = useMemo(() => {
const selected = String(metadata.contentType || '')
if (!selected) return []
return categoryTreeByType[selected] || []
}, [categoryTreeByType, metadata.contentType])
const selectedGroupOption = useMemo(() => {
const selectedSlug = String(metadata.group || '')
if (!selectedSlug) return null
return (Array.isArray(groupOptions) ? groupOptions : []).find((group) => String(group.slug || '') === selectedSlug) || null
}, [groupOptions, metadata.group])
const reviewSubmissionMode = Boolean(
selectedGroupOption &&
!selectedGroupOption?.permissions?.can_publish_artworks &&
selectedGroupOption?.permissions?.can_submit_artwork_for_review
)
const publishActionLabel = reviewSubmissionMode
? 'Submit for review'
: (publishMode === 'schedule' ? 'Schedule publish' : 'Publish now')
const currentContributorOptions = useMemo(() => {
const selectedSlug = String(metadata.group || '')
return Array.isArray(contributorOptionsByGroup?.[selectedSlug]) ? contributorOptionsByGroup[selectedSlug] : []
}, [contributorOptionsByGroup, metadata.group])
const allRootCategoryOptions = useMemo(() => {
const items = []
Object.entries(categoryTreeByType).forEach(([contentTypeValue, roots]) => {
roots.forEach((root) => items.push({ ...root, contentTypeValue }))
})
return items
}, [categoryTreeByType])
const selectedRootCategory = useMemo(() => {
const id = String(metadata.rootCategoryId || '')
if (!id) return null
return (
filteredCategoryTree.find((r) => String(r.id) === id) ||
allRootCategoryOptions.find((r) => String(r.id) === id) ||
null
)
}, [filteredCategoryTree, allRootCategoryOptions, metadata.rootCategoryId])
const requiresSubCategory = Boolean(
selectedRootCategory &&
Array.isArray(selectedRootCategory.children) &&
selectedRootCategory.children.length > 0
)
useEffect(() => {
const selectedSlug = String(metadata.group || '')
if (!selectedSlug) {
if (metadata.primaryAuthorUserId || metadata.contributorUserIds.length > 0 || Object.keys(metadata.contributorCredits || {}).length > 0) {
setMetadata((current) => ({ ...current, primaryAuthorUserId: null, contributorUserIds: [], contributorCredits: {} }))
}
return
}
const validGroup = (Array.isArray(groupOptions) ? groupOptions : []).some((group) => String(group.slug || '') === selectedSlug)
if (!validGroup) {
setMetadata((current) => ({ ...current, group: '', primaryAuthorUserId: null, contributorUserIds: [], contributorCredits: {} }))
return
}
const validContributorIds = currentContributorOptions.map((user) => Number(user.id)).filter((id) => Number.isFinite(id) && id > 0)
const nextPrimaryAuthorId = validContributorIds.includes(Number(metadata.primaryAuthorUserId))
? Number(metadata.primaryAuthorUserId)
: (validContributorIds.includes(Number(currentUserId)) ? Number(currentUserId) : (validContributorIds[0] || null))
const nextContributorIds = (Array.isArray(metadata.contributorUserIds) ? metadata.contributorUserIds : [])
.map((id) => Number(id))
.filter((id) => validContributorIds.includes(id) && id !== nextPrimaryAuthorId)
const nextContributorCredits = normalizeContributorCredits(nextContributorIds, metadata.contributorCredits)
const currentPrimary = metadata.primaryAuthorUserId ? Number(metadata.primaryAuthorUserId) : null
const currentContributors = (Array.isArray(metadata.contributorUserIds) ? metadata.contributorUserIds : []).map((id) => Number(id))
const contributorsChanged = nextContributorIds.length !== currentContributors.length || nextContributorIds.some((id, index) => id !== currentContributors[index])
const contributorCreditsChanged = JSON.stringify(nextContributorCredits) !== JSON.stringify(normalizeContributorCredits(currentContributors, metadata.contributorCredits))
if (currentPrimary !== nextPrimaryAuthorId || contributorsChanged || contributorCreditsChanged) {
setMetadata((current) => ({
...current,
primaryAuthorUserId: nextPrimaryAuthorId,
contributorUserIds: nextContributorIds,
contributorCredits: nextContributorCredits,
}))
}
}, [groupOptions, currentContributorOptions, currentUserId, metadata.group, metadata.primaryAuthorUserId, metadata.contributorUserIds, metadata.contributorCredits])
// ── Metadata validation ───────────────────────────────────────────────────
const metadataErrors = useMemo(() => {
const errors = {}
if (!String(metadata.title || '').trim()) errors.title = 'Title is required.'
if (!metadata.contentType) errors.contentType = 'Content type is required.'
if (!metadata.rootCategoryId) errors.category = 'Root category is required.'
if (metadata.rootCategoryId && requiresSubCategory && !metadata.subCategoryId) {
errors.category = 'Subcategory is required for the selected category.'
}
if (!metadata.rightsAccepted) errors.rights = 'Rights confirmation is required.'
return errors
}, [metadata, requiresSubCategory])
const detailsValid = Object.keys(metadataErrors).length === 0
// ── Merged AI + manual suggested tags ────────────────────────────────────
const mergedSuggestedTags = useMemo(() => {
const map = new Map()
const addTag = (item) => {
if (!item) return
const key = String(item?.slug || item?.tag || item?.name || item).trim().toLowerCase()
if (!key || map.has(key)) return
map.set(key, typeof item === 'string' ? item : {
id: item.id ?? key,
name: item.name || item.tag || item.slug || key,
slug: item.slug || item.tag || key,
usage_count: Number(item.usage_count || 0),
is_ai: Boolean(item.is_ai || item.source === 'ai'),
source: item.source || (item.is_ai ? 'ai' : 'manual'),
})
}
;(Array.isArray(suggestedTags) ? suggestedTags : []).forEach(addTag)
;(Array.isArray(visionSuggestedTags) ? visionSuggestedTags : []).forEach(addTag)
return Array.from(map.values())
}, [suggestedTags, visionSuggestedTags])
// ── Derived flags ─────────────────────────────────────────────────────────
const highestUnlockedStep = uploadReady ? (detailsValid ? 3 : 2) : 1
const showProgress = ![machineStates.idle, machineStates.cancelled].includes(machine.state)
const processingLabel = getProcessingTransparencyLabel(machine.processingStatus, machine.state)
const showOverlay = ['initializing', 'uploading', 'finishing', 'processing', 'error'].includes(machine.state)
const hasTitle = Boolean(String(metadata.title || '').trim())
const hasCompleteCategory = Boolean(
metadata.rootCategoryId && (!requiresSubCategory || metadata.subCategoryId)
)
const hasTag = Array.isArray(metadata.tags) && metadata.tags.length > 0
const hasRequiredScreenshot = !isArchive || screenshots.length > 0
const canPublish = useMemo(() => (
uploadReady &&
hasTitle &&
hasCompleteCategory &&
hasTag &&
hasRequiredScreenshot &&
metadata.rightsAccepted &&
machine.state !== machineStates.publishing
), [uploadReady, hasTitle, hasCompleteCategory, hasTag, hasRequiredScreenshot, metadata.rightsAccepted, machine.state])
const canScheduleSubmit = useMemo(() => {
if (!canPublish) return false
if (reviewSubmissionMode) return true
if (publishMode === 'schedule') return Boolean(scheduledAt)
return true
}, [canPublish, reviewSubmissionMode, publishMode, scheduledAt])
// ── Validation surface for parent ────────────────────────────────────────
const validationErrors = useMemo(
() => [...primaryErrors, ...screenshotErrors],
[primaryErrors, screenshotErrors]
)
useEffect(() => {
if (typeof onValidationStateChange === 'function') {
onValidationStateChange({ isValid: canStartUpload, validationErrors, isArchive })
}
}, [canStartUpload, validationErrors, isArchive, onValidationStateChange])
// ── Auto-advance to step 2 after upload complete ──────────────────────────
useEffect(() => {
if (uploadReady && activeStep === 1 && !hasAutoAdvancedRef.current) {
hasAutoAdvancedRef.current = true
setIsUploadLocked(true)
setActiveStep(2)
}
}, [uploadReady, activeStep])
useEffect(() => {
if (uploadReady) setIsUploadLocked(true)
}, [uploadReady])
// ── Step scroll + focus ───────────────────────────────────────────────────
useEffect(() => {
if (!stepContentRef.current) return
stepContentRef.current.scrollIntoView({
behavior: prefersReducedMotion ? 'auto' : 'smooth',
block: 'start',
})
window.setTimeout(() => {
stepHeadingRef.current?.focus?.({ preventScroll: true })
}, 0)
}, [activeStep, prefersReducedMotion])
// ── Cleanup ───────────────────────────────────────────────────────────────
useEffect(() => {
return () => {
abortAllRequests()
clearPolling()
}
}, [abortAllRequests, clearPolling])
// ── ESC key closes mobile drawer (spec §7) ─────────────────────────────
useEffect(() => {
if (!showMobilePublishPanel) return
const handler = (e) => { if (e.key === 'Escape') setShowMobilePublishPanel(false) }
document.addEventListener('keydown', handler)
return () => document.removeEventListener('keydown', handler)
}, [showMobilePublishPanel])
// ── Metadata helpers ──────────────────────────────────────────────────────
const setMeta = useCallback((patch) => setMetadata((prev) => ({ ...prev, ...patch })), [])
// ── Full reset ────────────────────────────────────────────────────────────
const handleReset = useCallback(() => {
resetMachine()
setPrimaryFile(null)
setScreenshots([])
setSelectedScreenshotIndex(0)
setMetadata(createInitialMetadata(initialGroupSlug, currentUserId, contributorOptionsByGroup))
setIsUploadLocked(false)
hasAutoAdvancedRef.current = false
setPublishMode('now')
setScheduledAt(null)
setVisibility('public')
setShowMobilePublishPanel(false)
setResolvedArtworkId(() => {
const parsed = Number(initialDraftId)
return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : null
})
setActiveStep(1)
}, [resetMachine, initialDraftId, initialGroupSlug, currentUserId, contributorOptionsByGroup])
const goToStep = useCallback((step) => {
if (step >= 1 && step <= highestUnlockedStep) setActiveStep(step)
}, [highestUnlockedStep])
// ── Step content renderer ─────────────────────────────────────────────────
const renderStepContent = () => {
// Complete / success screen
if (machine.state === machineStates.complete) {
const wasScheduled = machine.lastAction === 'schedule'
const studioArtworksUrl = '/studio/artworks'
const studioArtworkUrl = resolvedArtworkId
? `/studio/artworks/${resolvedArtworkId}/edit`
: studioArtworksUrl
return (
<motion.div
initial={prefersReducedMotion ? false : { opacity: 0, scale: 0.98 }}
animate={{ opacity: 1, scale: 1 }}
transition={prefersReducedMotion ? { duration: 0 } : { duration: 0.28, ease: 'easeOut' }}
className={`rounded-2xl p-8 text-center ${wasScheduled ? 'ring-1 ring-violet-300/25 bg-violet-500/8' : 'ring-1 ring-emerald-300/25 bg-emerald-500/8'}`}
>
<motion.div
initial={prefersReducedMotion ? false : { scale: 0.7, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={prefersReducedMotion ? { duration: 0 } : { delay: 0.1, duration: 0.26, ease: 'backOut' }}
className={`mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-full text-2xl`}
>
{wasScheduled ? '🕐' : '🎉'}
</motion.div>
<h3 className="text-xl font-semibold text-white">
{wasScheduled ? 'Artwork scheduled!' : 'Your artwork is live!'}
</h3>
<p className="mt-2 text-sm text-white/65">
{wasScheduled
? scheduledAt
? `Will publish on ${new Intl.DateTimeFormat('en-GB', { timeZone: userTimezone, weekday: 'short', day: 'numeric', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit', hour12: false }).format(new Date(scheduledAt))}`
: 'Your artwork is scheduled for future publishing.'
: 'It has been published and is now visible to the community.'}
</p>
<div className="mt-6 flex flex-wrap justify-center gap-3">
{!wasScheduled && (
<a
href={resolvedArtworkId
? `/art/${resolvedArtworkId}${machine.slug ? `/${machine.slug}` : ''}`
: '/'}
className="rounded-lg ring-1 ring-emerald-300/45 bg-emerald-400/20 px-4 py-2 text-sm font-medium text-emerald-50 hover:bg-emerald-400/30 transition"
>
View artwork
</a>
)}
<a
href={studioArtworksUrl}
className="rounded-lg ring-1 ring-sky-300/35 bg-sky-400/12 px-4 py-2 text-sm font-medium text-sky-50 hover:bg-sky-400/20 transition"
>
View in studio
</a>
<a
href={studioArtworkUrl}
className="rounded-lg ring-1 ring-white/20 bg-white/8 px-4 py-2 text-sm font-medium text-white hover:bg-white/15 transition"
>
Edit artwork in studio
</a>
<button
type="button"
onClick={handleReset}
className="rounded-lg ring-1 ring-white/20 bg-white/8 px-4 py-2 text-sm text-white hover:bg-white/15 transition"
>
Upload another
</button>
</div>
</motion.div>
)
}
if (activeStep === 1) {
return (
<Step1FileUpload
headingRef={stepHeadingRef}
primaryFile={primaryFile}
primaryPreviewUrl={primaryPreviewUrl}
primaryErrors={primaryErrors}
primaryWarnings={primaryWarnings}
fileMetadata={fileMetadata}
fileSelectionLocked={isUploadLocked}
onPrimaryFileChange={setPrimaryFile}
isArchive={isArchive}
screenshots={screenshots}
selectedScreenshotIndex={selectedScreenshotIndex}
screenshotErrors={screenshotErrors}
screenshotPerFileErrors={screenshotPerFileErrors}
onScreenshotsChange={setScreenshots}
onSelectedScreenshotChange={setSelectedScreenshotIndex}
machine={machine}
/>
)
}
if (activeStep === 2) {
return (
<Step2Details
headingRef={stepHeadingRef}
primaryFile={primaryFile}
primaryPreviewUrl={primaryPreviewUrl}
isArchive={isArchive}
fileMetadata={fileMetadata}
screenshots={screenshots}
selectedScreenshotIndex={selectedScreenshotIndex}
onSelectedScreenshotChange={setSelectedScreenshotIndex}
contentTypes={contentTypes}
metadata={metadata}
metadataErrors={metadataErrors}
filteredCategoryTree={filteredCategoryTree}
allRootCategoryOptions={allRootCategoryOptions}
requiresSubCategory={requiresSubCategory}
onContentTypeChange={(value) => setMeta({ contentType: value, rootCategoryId: '', subCategoryId: '' })}
onRootCategoryChange={(rootId) => setMeta({ rootCategoryId: rootId, subCategoryId: '' })}
onSubCategoryChange={(subId) => setMeta({ subCategoryId: subId })}
groupOptions={groupOptions}
currentContributorOptions={currentContributorOptions}
onGroupChange={(groupSlug) => setMeta({ group: groupSlug })}
onPrimaryAuthorChange={(authorId) => setMeta({ primaryAuthorUserId: authorId ? Number(authorId) : null })}
onContributorToggle={(contributorId) => setMetadata((current) => {
const normalizedId = Number(contributorId)
const nextIds = new Set((Array.isArray(current.contributorUserIds) ? current.contributorUserIds : []).map((id) => Number(id)).filter((id) => id !== Number(current.primaryAuthorUserId)))
if (nextIds.has(normalizedId)) {
nextIds.delete(normalizedId)
} else {
nextIds.add(normalizedId)
}
const contributorUserIds = Array.from(nextIds).filter((id) => id !== Number(current.primaryAuthorUserId))
return {
...current,
contributorUserIds,
contributorCredits: normalizeContributorCredits(contributorUserIds, current.contributorCredits),
}
})}
onContributorRoleChange={(contributorId, creditRole) => setMetadata((current) => {
const contributorUserIds = (Array.isArray(current.contributorUserIds) ? current.contributorUserIds : []).map((id) => Number(id))
if (!contributorUserIds.includes(Number(contributorId))) return current
const contributorCredits = normalizeContributorCredits(contributorUserIds, current.contributorCredits)
return {
...current,
contributorCredits: {
...contributorCredits,
[Number(contributorId)]: {
...(contributorCredits[Number(contributorId)] || { isPrimary: false }),
creditRole,
},
},
}
})}
onContributorPrimaryChange={(contributorId) => setMetadata((current) => {
const contributorUserIds = (Array.isArray(current.contributorUserIds) ? current.contributorUserIds : []).map((id) => Number(id))
const contributorCredits = normalizeContributorCredits(contributorUserIds, current.contributorCredits)
contributorUserIds.forEach((id) => {
contributorCredits[id] = {
...(contributorCredits[id] || { creditRole: '' }),
isPrimary: id === Number(contributorId),
}
})
return {
...current,
contributorCredits,
}
})}
suggestedTags={mergedSuggestedTags}
publishMode={publishMode}
scheduledAt={scheduledAt}
timezone={userTimezone}
onPublishModeChange={setPublishMode}
onScheduleAt={setScheduledAt}
onChangeTitle={(value) => setMeta({ title: value })}
onChangeTags={(value) => setMeta({ tags: value })}
onChangeDescription={(value) => setMeta({ description: value })}
onToggleMature={(value) => setMeta({ isMature: Boolean(value) })}
onToggleRights={(value) => setMeta({ rightsAccepted: Boolean(value) })}
/>
)
}
return (
<Step3Publish
headingRef={stepHeadingRef}
primaryFile={primaryFile}
primaryPreviewUrl={primaryPreviewUrl}
isArchive={isArchive}
screenshots={screenshots}
selectedScreenshotIndex={selectedScreenshotIndex}
onSelectedScreenshotChange={setSelectedScreenshotIndex}
fileMetadata={fileMetadata}
metadata={metadata}
canPublish={canPublish}
uploadReady={uploadReady}
publishMode={publishMode}
scheduledAt={scheduledAt}
timezone={userTimezone}
visibility={visibility}
actionLabel={publishActionLabel}
showScheduleSummary={!reviewSubmissionMode}
onVisibilityChange={setVisibility}
selectedGroup={selectedGroupOption}
currentContributorOptions={currentContributorOptions}
allRootCategoryOptions={allRootCategoryOptions}
filteredCategoryTree={filteredCategoryTree}
/>
)
}
// ── Action bar helpers ────────────────────────────────────────────────────
const disableReason = (() => {
if (activeStep === 1) return validationErrors[0] || machine.error || 'Complete upload requirements first.'
if (activeStep === 2) return metadataErrors.title || metadataErrors.contentType || metadataErrors.category || metadataErrors.rights || metadataErrors.tags || 'Complete required metadata.'
return machine.error || 'Publish is available when upload is ready and rights are confirmed.'
})()
// ─────────────────────────────────────────────────────────────────────────
return (
<section
ref={stepContentRef}
className="space-y-5 pb-32 text-white lg:pb-8"
data-is-archive={isArchive ? 'true' : 'false'}
>
{notices.length > 0 && (
<div className="fixed right-4 top-4 z-[70] w-[min(92vw,420px)] space-y-2">
{notices.map((notice) => (
<div
key={notice.id}
role="alert"
aria-live="polite"
className={[
'rounded-xl border px-4 py-3 text-sm shadow-lg backdrop-blur',
notice.type === 'success'
? 'border-emerald-400/45 bg-emerald-500/12 text-emerald-100'
: notice.type === 'warning'
? 'border-amber-400/45 bg-amber-500/12 text-amber-100'
: 'border-red-400/45 bg-red-500/12 text-red-100',
].join(' ')}
>
{notice.message}
</div>
))}
</div>
)}
{/* Restored draft banner */}
{showRestoredBanner && (
<div className="rounded-2xl border border-sky-300/20 bg-sky-500/10 px-4 py-3 text-sm text-sky-100 shadow-[0_14px_44px_rgba(14,165,233,0.10)]">
<div className="flex items-center justify-between gap-3">
<span>Draft restored. Continue from your previous upload session.</span>
<button
type="button"
onClick={() => setShowRestoredBanner(false)}
className="shrink-0 rounded-md ring-1 ring-sky-200/35 bg-sky-500/15 px-2 py-1 text-xs text-sky-100 hover:bg-sky-500/25 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-sky-300/70"
>
Dismiss
</button>
</div>
</div>
)}
{/* ── Studio Status Bar (sticky step header + progress) ────────────── */}
<StudioStatusBar
steps={wizardSteps}
activeStep={activeStep}
highestUnlockedStep={highestUnlockedStep}
machineState={machine.state}
progress={machine.progress}
showProgress={showProgress}
onStepClick={goToStep}
/>
{/* ── Main body: two-column on desktop ─────────────────────────────── */}
<div className="flex flex-col gap-5 lg:flex-row lg:items-start lg:gap-8">
{/* Left / main column: step content */}
<div className="min-w-0 flex-1">
{/* Step content + centered progress overlay */}
<div className="relative">
<AnimatePresence mode="wait" initial={false}>
<motion.div
key={`step-${activeStep}`}
initial={prefersReducedMotion ? false : { opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
exit={prefersReducedMotion ? {} : { opacity: 0, y: -8 }}
transition={quickTransition}
>
{renderStepContent()}
</motion.div>
</AnimatePresence>
<UploadOverlay
machineState={machine.state}
progress={machine.progress}
processingLabel={processingLabel}
error={machine.error}
onRetry={() => handleRetry(canPublish, { mode: publishMode, publishAt: scheduledAt, timezone: userTimezone, visibility })}
onReset={handleReset}
/>
</div>
{/* Wizard action bar (nav: back/next/start/retry) */}
{machine.state !== machineStates.complete && (
<div className="mt-5">
<UploadActions
step={activeStep}
canStart={canStartUpload && [machineStates.idle, machineStates.error, machineStates.cancelled].includes(machine.state)}
canContinue={detailsValid}
canPublish={canScheduleSubmit}
canGoBack={activeStep > 1 && machine.state !== machineStates.complete}
canReset={Boolean(primaryFile || screenshots.length || metadata.title || metadata.description || metadata.tags.length)}
canCancel={activeStep === 1 && [
machineStates.initializing,
machineStates.uploading,
machineStates.finishing,
machineStates.processing,
].includes(machine.state)}
canRetry={machine.state === machineStates.error}
isUploading={[machineStates.uploading, machineStates.initializing].includes(machine.state)}
isProcessing={[machineStates.processing, machineStates.finishing].includes(machine.state)}
isPublishing={machine.state === machineStates.publishing}
isCancelling={machine.isCancelling}
disableReason={disableReason}
onStart={runUploadFlow}
onContinue={() => detailsValid && setActiveStep(3)}
onPublish={() => handlePublish(canPublish, { mode: reviewSubmissionMode ? 'now' : publishMode, publishAt: reviewSubmissionMode ? null : scheduledAt, timezone: userTimezone, visibility, action: reviewSubmissionMode ? 'submit_review' : 'publish' })}
onBack={() => setActiveStep((s) => Math.max(1, s - 1))}
onCancel={handleCancel}
onReset={handleReset}
onRetry={() => handleRetry(canPublish, { mode: reviewSubmissionMode ? 'now' : publishMode, publishAt: reviewSubmissionMode ? null : scheduledAt, timezone: userTimezone, visibility, action: reviewSubmissionMode ? 'submit_review' : 'publish' })}
onSaveDraft={() => {}}
showSaveDraft={activeStep === 2}
resetLabel={isUploadLocked ? 'Reset upload' : 'Reset'}
publishLabel={publishActionLabel}
mobileSticky
/>
</div>
)}
</div>
{/* Right column: PublishPanel (sticky sidebar on lg+, Step 2+ only) */}
{(primaryFile || resolvedArtworkId) && machine.state !== machineStates.complete && activeStep > 1 && (
<div className="hidden shrink-0 lg:block lg:w-80 xl:w-[22rem] lg:sticky lg:top-20 lg:self-start">
<PublishPanel
primaryPreviewUrl={primaryPreviewUrl}
isArchive={isArchive}
screenshots={screenshots}
selectedScreenshotIndex={selectedScreenshotIndex}
onSelectedScreenshotChange={setSelectedScreenshotIndex}
metadata={metadata}
machineState={machine.state}
uploadReady={uploadReady}
canPublish={canPublish}
isPublishing={machine.state === machineStates.publishing}
isArchiveRequiresScreenshot={isArchive}
publishMode={publishMode}
scheduledAt={scheduledAt}
timezone={userTimezone}
visibility={visibility}
actionLabel={publishActionLabel}
showScheduleControls={!reviewSubmissionMode}
showRightsConfirmation={activeStep === 3}
showVisibility={false}
onPublishModeChange={setPublishMode}
onScheduleAt={setScheduledAt}
onVisibilityChange={setVisibility}
onToggleRights={(checked) => setMeta({ rightsAccepted: Boolean(checked) })}
onPublish={() => handlePublish(canPublish, { mode: reviewSubmissionMode ? 'now' : publishMode, publishAt: reviewSubmissionMode ? null : scheduledAt, timezone: userTimezone, visibility, action: reviewSubmissionMode ? 'submit_review' : 'publish' })}
onCancel={handleCancel}
onGoToStep={goToStep}
allRootCategoryOptions={allRootCategoryOptions}
/>
</div>
)}
</div>
{/* ── Mobile: floating "Publish" button that opens bottom sheet ────── */}
{(primaryFile || resolvedArtworkId) && machine.state !== machineStates.complete && activeStep > 1 && (
<div className="fixed bottom-4 right-4 z-30 lg:hidden">
<button
type="button"
aria-label="Open publish panel"
onClick={() => setShowMobilePublishPanel((v) => !v)}
className="flex items-center gap-2 rounded-full border border-sky-300/20 bg-sky-500 px-4 py-2.5 text-sm font-semibold text-white shadow-[0_18px_50px_rgba(14,165,233,0.35)] transition hover:bg-sky-400 active:scale-95"
>
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
</svg>
{reviewSubmissionMode ? 'Review' : 'Publish'}
{!canPublish && (
<span className="ml-1 rounded-full bg-white/20 px-1.5 text-[10px]">
{[
...(!uploadReady ? [1] : []),
...(hasTitle ? [] : [1]),
...(hasCompleteCategory ? [] : [1]),
...(hasTag ? [] : [1]),
...(hasRequiredScreenshot ? [] : [1]),
...(metadata.rightsAccepted ? [] : [1]),
].length}
</span>
)}
</button>
</div>
)}
{/* ── Mobile Publish panel bottom-sheet overlay ────────────────────── */}
<AnimatePresence>
{showMobilePublishPanel && (
<>
{/* Backdrop */}
<motion.div
key="mobile-panel-backdrop"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
className="fixed inset-0 z-40 bg-black/60 backdrop-blur-sm lg:hidden"
onClick={() => setShowMobilePublishPanel(false)}
/>
{/* Sheet */}
<motion.div
key="mobile-panel-sheet"
initial={{ y: '100%' }}
animate={{ y: 0 }}
exit={{ y: '100%' }}
transition={prefersReducedMotion ? { duration: 0 } : { type: 'spring', damping: 30, stiffness: 300 }}
className="fixed bottom-0 left-0 right-0 z-50 max-h-[80vh] overflow-y-auto rounded-t-2xl bg-slate-900 ring-1 ring-white/10 p-5 pb-8 lg:hidden"
>
<div className="mx-auto mb-4 h-1 w-12 rounded-full bg-white/20" aria-hidden="true" />
<PublishPanel
primaryPreviewUrl={primaryPreviewUrl}
isArchive={isArchive}
screenshots={screenshots}
selectedScreenshotIndex={selectedScreenshotIndex}
onSelectedScreenshotChange={setSelectedScreenshotIndex}
metadata={metadata}
machineState={machine.state}
uploadReady={uploadReady}
canPublish={canPublish}
isPublishing={machine.state === machineStates.publishing}
isArchiveRequiresScreenshot={isArchive}
publishMode={publishMode}
scheduledAt={scheduledAt}
timezone={userTimezone}
visibility={visibility}
actionLabel={publishActionLabel}
showScheduleControls={!reviewSubmissionMode}
showRightsConfirmation={activeStep === 3}
showVisibility={false}
onPublishModeChange={setPublishMode}
onScheduleAt={setScheduledAt}
onVisibilityChange={setVisibility}
onToggleRights={(checked) => setMeta({ rightsAccepted: Boolean(checked) })}
onPublish={() => {
setShowMobilePublishPanel(false)
handlePublish(canPublish, { mode: reviewSubmissionMode ? 'now' : publishMode, publishAt: reviewSubmissionMode ? null : scheduledAt, timezone: userTimezone, visibility, action: reviewSubmissionMode ? 'submit_review' : 'publish' })
}}
onCancel={() => {
setShowMobilePublishPanel(false)
handleCancel()
}}
onGoToStep={(s) => {
setShowMobilePublishPanel(false)
goToStep(s)
}}
allRootCategoryOptions={allRootCategoryOptions}
/>
</motion.div>
</>
)}
</AnimatePresence>
</section>
)
}

View File

@@ -0,0 +1,517 @@
import React from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { act } from 'react'
import { cleanup, render, screen, waitFor, within } from '@testing-library/react'
import userEvent from '@testing-library/user-event'
import UploadWizard from '../UploadWizard'
function installAxiosStubs({ statusValue = 'ready', initError = null, holdChunk = false, finishError = null } = {}) {
window.axios = {
post: vi.fn((url, payload, config = {}) => {
if (url === '/api/uploads/init') {
if (initError) return Promise.reject(initError)
return Promise.resolve({
data: {
session_id: 'session-1',
upload_token: 'token-1',
},
})
}
if (url === '/api/uploads/chunk') {
if (holdChunk) {
return new Promise((resolve, reject) => {
if (config?.signal?.aborted) {
reject({ name: 'CanceledError', code: 'ERR_CANCELED' })
return
}
config?.signal?.addEventListener?.('abort', () => reject({ name: 'CanceledError', code: 'ERR_CANCELED' }))
setTimeout(() => resolve({ data: { received_bytes: 1024, progress: 55 } }), 20)
})
}
const offset = Number(payload?.get?.('offset') || 0)
const chunkSize = Number(payload?.get?.('chunk_size') || 0)
const totalSize = Number(payload?.get?.('total_size') || 1)
const received = Math.min(totalSize, offset + chunkSize)
return Promise.resolve({
data: {
received_bytes: received,
progress: Math.round((received / totalSize) * 100),
},
})
}
if (url === '/api/uploads/finish') {
if (finishError) return Promise.reject(finishError)
return Promise.resolve({ data: { processing_state: statusValue, status: statusValue } })
}
if (/^\/api\/uploads\/[^/]+\/publish$/.test(url)) {
return Promise.resolve({ data: { success: true, status: 'published' } })
}
if (url === '/api/uploads/cancel') {
return Promise.resolve({ data: { success: true, status: 'cancelled' } })
}
return Promise.reject(new Error(`Unhandled POST ${url}`))
}),
get: vi.fn((url) => {
if (url === '/api/uploads/status/session-1') {
return Promise.resolve({
data: {
id: 'session-1',
processing_state: statusValue,
status: statusValue,
},
})
}
if (url === '/api/tags/popular' || String(url).startsWith('/api/tags/search')) {
return Promise.resolve({
data: {
data: [],
},
})
}
return Promise.reject(new Error(`Unhandled GET ${url}`))
}),
}
}
async function flushUi() {
await act(async () => {
await new Promise((resolve) => window.setTimeout(resolve, 0))
})
}
async function renderWizard(props = {}) {
await act(async () => {
render(<UploadWizard {...props} />)
})
await flushUi()
}
async function uploadPrimary(file) {
await act(async () => {
const input = screen.getByLabelText('Upload file input')
await userEvent.upload(input, file)
})
await flushUi()
}
async function uploadScreenshot(file) {
await act(async () => {
const input = await screen.findByLabelText('Screenshot file input')
await userEvent.upload(input, file)
})
await flushUi()
}
async function completeStep1ToReady() {
await uploadPrimary(new File(['img'], 'ready.png', { type: 'image/png' }))
await act(async () => {
await userEvent.click(await screen.findByRole('button', { name: /start upload/i }))
})
await waitFor(() => {
expect(screen.getByRole('button', { name: /continue to publish/i })).not.toBeNull()
})
}
async function completeRequiredDetails({ title = 'My Art', mature = false } = {}) {
await act(async () => {
await userEvent.type(screen.getByRole('textbox', { name: /title/i }), title)
await userEvent.click(screen.getByRole('button', { name: /art .* open/i }))
await userEvent.click(await screen.findByRole('button', { name: /root .* choose/i }))
await userEvent.click(await screen.findByRole('button', { name: /sub .* choose/i }))
await userEvent.type(screen.getByLabelText(/search or add tags/i), 'fantasy{enter}')
if (mature) {
await userEvent.click(screen.getByLabelText(/mark this artwork as mature content/i))
}
await userEvent.click(screen.getByLabelText(/i confirm i own the rights to this content/i))
})
}
describe('UploadWizard step flow', () => {
let originalImage
let originalScrollTo
let originalScrollIntoView
let consoleErrorSpy
beforeEach(() => {
window.URL.createObjectURL = vi.fn(() => `blob:${Math.random().toString(16).slice(2)}`)
window.URL.revokeObjectURL = vi.fn()
originalImage = global.Image
originalScrollTo = window.scrollTo
originalScrollIntoView = Element.prototype.scrollIntoView
window.scrollTo = vi.fn()
Element.prototype.scrollIntoView = vi.fn()
consoleErrorSpy = vi.spyOn(console, 'error').mockImplementation((...args) => {
const text = args.map((arg) => String(arg)).join(' ')
if (text.includes('not configured to support act')) return
if (text.includes('not wrapped in act')) return
console.warn(...args)
})
global.Image = class MockImage {
set src(_value) {
this.naturalWidth = 1920
this.naturalHeight = 1080
setTimeout(() => {
if (typeof this.onload === 'function') this.onload()
}, 0)
}
}
})
afterEach(() => {
global.Image = originalImage
window.scrollTo = originalScrollTo
Element.prototype.scrollIntoView = originalScrollIntoView
consoleErrorSpy?.mockRestore()
cleanup()
vi.restoreAllMocks()
})
it('renders 3-step stepper', () => {
installAxiosStubs()
return renderWizard({ initialDraftId: 301 }).then(() => {
expect(screen.getByRole('navigation', { name: /upload steps/i })).not.toBeNull()
expect(screen.getByRole('button', { name: /1 upload/i })).not.toBeNull()
expect(screen.getByRole('button', { name: /2 details/i })).not.toBeNull()
expect(screen.getByRole('button', { name: /3 publish/i })).not.toBeNull()
})
})
it('marks locked steps with aria-disabled and blocks click', async () => {
installAxiosStubs()
await renderWizard({ initialDraftId: 307 })
const stepper = screen.getByRole('navigation', { name: /upload steps/i })
const detailsStep = within(stepper).getByRole('button', { name: /2 details/i })
const publishStep = within(stepper).getByRole('button', { name: /3 publish/i })
expect(detailsStep.getAttribute('aria-disabled')).toBe('true')
expect(publishStep.getAttribute('aria-disabled')).toBe('true')
await act(async () => {
await userEvent.click(detailsStep)
})
expect(screen.getByRole('heading', { level: 2, name: /upload your artwork/i })).not.toBeNull()
expect(screen.queryByText(/add details/i)).toBeNull()
})
it('keeps step 2 hidden until step 1 upload is ready', async () => {
installAxiosStubs({ statusValue: 'processing' })
await renderWizard({ initialDraftId: 302 })
expect(screen.queryByText(/artwork details/i)).toBeNull()
await uploadPrimary(new File(['img'], 'x.png', { type: 'image/png' }))
await act(async () => {
await userEvent.click(await screen.findByRole('button', { name: /start upload/i }))
})
await waitFor(() => {
expect(screen.queryByRole('button', { name: /continue to publish/i })).toBeNull()
})
expect(screen.queryByText(/artwork details/i)).toBeNull()
})
it('requires archive screenshot before start upload enables', async () => {
installAxiosStubs()
await renderWizard({ initialDraftId: 303 })
await uploadPrimary(new File(['zip'], 'bundle.zip', { type: 'application/zip' }))
const start = await screen.findByRole('button', { name: /start upload/i })
await waitFor(() => {
expect(start.disabled).toBe(true)
})
await uploadScreenshot(new File(['shot'], 'screen.png', { type: 'image/png' }))
await waitFor(() => {
expect(start.disabled).toBe(false)
})
})
it('uses the selected archive screenshot as the preview upload source', async () => {
installAxiosStubs({ statusValue: 'ready' })
await renderWizard({ initialDraftId: 312 })
await uploadPrimary(new File(['zip'], 'bundle.zip', { type: 'application/zip' }))
await uploadScreenshot(new File(['shot-1'], 'shot-1.png', { type: 'image/png' }))
await uploadScreenshot(new File(['shot-2'], 'shot-2.png', { type: 'image/png' }))
await act(async () => {
await userEvent.click(screen.getByRole('button', { name: /use shot-2\.png as default screenshot/i }))
})
await waitFor(() => {
expect(screen.getAllByText('Default').length).toBeGreaterThan(0)
})
await act(async () => {
await userEvent.click(await screen.findByRole('button', { name: /start upload/i }))
})
await waitFor(() => {
expect(window.axios.post).toHaveBeenCalledWith(
'/api/uploads/finish',
expect.objectContaining({
file_name: 'shot-2.png',
archive_file_name: 'bundle.zip',
additional_screenshot_sessions: [
expect.objectContaining({
file_name: 'shot-1.png',
}),
],
}),
expect.anything(),
)
})
})
it('allows navigation back to completed previous step', async () => {
installAxiosStubs({ statusValue: 'ready' })
await renderWizard({ initialDraftId: 304 })
await completeStep1ToReady()
expect(await screen.findByText(/artwork details/i)).not.toBeNull()
const stepper = screen.getByRole('navigation', { name: /upload steps/i })
await act(async () => {
await userEvent.click(within(stepper).getByRole('button', { name: /upload/i }))
})
expect(await screen.findByRole('heading', { level: 2, name: /upload your artwork/i })).not.toBeNull()
})
it('triggers scroll-to-top behavior on step change', async () => {
installAxiosStubs({ statusValue: 'ready' })
await renderWizard({ initialDraftId: 308 })
const scrollSpy = Element.prototype.scrollIntoView
const initialCalls = scrollSpy.mock.calls.length
await completeStep1ToReady()
await waitFor(() => {
expect(scrollSpy.mock.calls.length).toBeGreaterThan(initialCalls)
})
})
it('shows publish only on step 3 and only after ready_to_publish path', async () => {
installAxiosStubs({ statusValue: 'ready' })
await renderWizard({ initialDraftId: 305, contentTypes: [{ id: 1, name: 'Art', categories: [{ id: 10, name: 'Root', children: [{ id: 11, name: 'Sub' }] }] }] })
await completeStep1ToReady()
expect(await screen.findByText(/artwork details/i)).not.toBeNull()
expect(screen.queryByRole('button', { name: /^publish$/i })).toBeNull()
await completeRequiredDetails({ title: 'My Art' })
await act(async () => {
await userEvent.click(screen.getByRole('button', { name: /continue to publish/i }))
})
await waitFor(() => {
const publish = screen.getByRole('button', { name: /^publish$/i })
expect(publish.disabled).toBe(false)
})
await act(async () => {
await userEvent.click(screen.getByRole('button', { name: /^publish$/i }))
})
await waitFor(() => {
expect(screen.getByText(/your artwork is live/i)).not.toBeNull()
})
expect(window.axios.post).not.toHaveBeenCalledWith('/api/uploads/session-1/publish', expect.anything(), expect.anything())
})
it('includes the mature flag in the final publish payload when selected', async () => {
installAxiosStubs({ statusValue: 'ready' })
await renderWizard({ initialDraftId: 311, contentTypes: [{ id: 1, name: 'Art', categories: [{ id: 10, name: 'Root', children: [{ id: 11, name: 'Sub' }] }] }] })
await completeStep1ToReady()
await screen.findByText(/artwork details/i)
await completeRequiredDetails({ title: 'Mature Piece', mature: true })
await act(async () => {
await userEvent.click(screen.getByRole('button', { name: /continue to publish/i }))
})
await waitFor(() => {
const publish = screen.getByRole('button', { name: /^publish$/i })
expect(publish.disabled).toBe(false)
})
await act(async () => {
await userEvent.click(screen.getByRole('button', { name: /^publish$/i }))
})
await waitFor(() => {
expect(window.axios.post).toHaveBeenCalledWith(
'/api/uploads/311/publish',
expect.objectContaining({ is_mature: true }),
expect.anything(),
)
})
})
it('includes contributor credit metadata in the final publish payload', async () => {
installAxiosStubs({ statusValue: 'ready' })
await renderWizard({
initialDraftId: 313,
currentUserId: 11,
initialGroupSlug: 'warp-collective',
groupOptions: [{ slug: 'warp-collective', name: 'Warp Collective' }],
contributorOptionsByGroup: {
'warp-collective': [
{ id: 10, name: 'Owner User', username: 'owner-user' },
{ id: 11, name: 'Editor User', username: 'editor-user' },
],
},
contentTypes: [{ id: 1, name: 'Art', categories: [{ id: 10, name: 'Root', children: [{ id: 11, name: 'Sub' }] }] }],
})
await completeStep1ToReady()
await screen.findByText(/artwork details/i)
await act(async () => {
await userEvent.click(screen.getByRole('button', { name: /add credit/i }))
await userEvent.type(screen.getByLabelText(/credit role for owner user/i), 'Color assist')
await userEvent.click(screen.getByRole('button', { name: /mark owner user as lead supporting credit/i }))
})
await completeRequiredDetails({ title: 'Collaborative Piece' })
await act(async () => {
await userEvent.click(screen.getByRole('button', { name: /continue to publish/i }))
})
await waitFor(() => {
const publish = screen.getByRole('button', { name: /^publish$/i })
expect(publish.disabled).toBe(false)
})
await act(async () => {
await userEvent.click(screen.getByRole('button', { name: /^publish$/i }))
})
await waitFor(() => {
expect(window.axios.post).toHaveBeenCalledWith(
'/api/uploads/313/publish',
expect.objectContaining({
contributor_user_ids: [10],
contributor_credits: [
expect.objectContaining({
user_id: 10,
credit_role: 'Color assist',
is_primary: true,
}),
],
}),
expect.anything(),
)
})
})
it('shows personal and group publish options when group publishing is available', async () => {
installAxiosStubs({ statusValue: 'ready' })
await renderWizard({
initialDraftId: 314,
currentUserId: 11,
groupOptions: [{ slug: 'warp-collective', name: 'Warp Collective' }],
contributorOptionsByGroup: {
'warp-collective': [
{ id: 11, name: 'Editor User', username: 'editor-user' },
{ id: 12, name: 'Owner User', username: 'owner-user' },
],
},
contentTypes: [{ id: 1, name: 'Art', categories: [{ id: 10, name: 'Root', children: [{ id: 11, name: 'Sub' }] }] }],
})
await completeStep1ToReady()
await screen.findByText(/artwork details/i)
const publishAs = screen.getByRole('combobox', { name: /publishing identity/i })
expect(screen.getByRole('option', { name: /personal profile/i })).not.toBeNull()
expect(screen.getByRole('option', { name: /warp collective/i })).not.toBeNull()
await act(async () => {
await userEvent.selectOptions(publishAs, 'warp-collective')
})
expect(await screen.findByRole('combobox', { name: /primary author/i })).not.toBeNull()
expect(screen.getByText(/contributors/i)).not.toBeNull()
})
it('keeps mobile sticky action bar visible class', async () => {
installAxiosStubs()
await renderWizard({ initialDraftId: 306 })
const bar = screen.getByTestId('wizard-action-bar')
expect((bar.className || '').includes('sticky')).toBe(true)
expect((bar.className || '').includes('bottom-0')).toBe(true)
})
it('shows mapped duplicate hash toast when finish returns duplicate_hash', async () => {
installAxiosStubs({
finishError: {
response: {
status: 409,
data: {
reason: 'duplicate_hash',
message: 'Duplicate upload is not allowed. This file already exists.',
},
},
},
})
await renderWizard({ initialDraftId: 310 })
await uploadPrimary(new File(['img'], 'duplicate.png', { type: 'image/png' }))
await act(async () => {
await userEvent.click(await screen.findByRole('button', { name: /start upload/i }))
})
const toast = await screen.findByRole('alert')
expect(toast.textContent).toMatch(/already exists in skinbase/i)
})
it('locks step 1 file input after upload and unlocks after reset', async () => {
installAxiosStubs({ statusValue: 'ready' })
await renderWizard({ initialDraftId: 309 })
await completeStep1ToReady()
const stepper = screen.getByRole('navigation', { name: /upload steps/i })
await act(async () => {
await userEvent.click(within(stepper).getByRole('button', { name: /upload/i }))
})
await waitFor(() => {
const dropzoneButton = screen.getByTestId('upload-dropzone')
expect(dropzoneButton.getAttribute('aria-disabled')).toBe('true')
})
expect(screen.getByText(/file is locked after upload starts\. reset to change the file\./i)).not.toBeNull()
await act(async () => {
await userEvent.click(screen.getByRole('button', { name: /reset upload/i }))
})
await waitFor(() => {
const unlockedDropzone = screen.getByTestId('upload-dropzone')
expect(unlockedDropzone.getAttribute('aria-disabled')).toBe('false')
})
})
})

View File

@@ -0,0 +1,160 @@
import React from 'react'
import ArchiveScreenshotPicker from '../ArchiveScreenshotPicker'
import UploadDropzone from '../UploadDropzone'
import ScreenshotUploader from '../ScreenshotUploader'
/**
* 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,
selectedScreenshotIndex,
screenshotErrors,
screenshotPerFileErrors,
onScreenshotsChange,
onSelectedScreenshotChange,
// Machine state (passed for potential future use)
machine,
}) {
const fileSelected = Boolean(primaryFile)
return (
<div className="space-y-6 rounded-[28px] border border-white/10 bg-[linear-gradient(180deg,rgba(255,255,255,0.05),rgba(255,255,255,0.02))] p-6 shadow-[0_24px_90px_rgba(2,8,23,0.28)] backdrop-blur sm:p-8">
{/* ── Hero heading ─────────────────────────────────────────────────── */}
<div className="text-center">
<span className="inline-flex items-center gap-1.5 rounded-full border border-sky-400/30 bg-sky-400/10 px-3 py-1 text-[11px] uppercase tracking-widest text-sky-300">
Step 1 of 3
</span>
<h2
ref={headingRef}
tabIndex={-1}
className="mt-4 text-2xl font-bold text-white focus:outline-none"
>
Upload your artwork
</h2>
<p className="mx-auto mt-2 max-w-md text-sm text-white/55">
Drop an image or an archive pack. We validate the file instantly so you can start uploading straight away.
</p>
</div>
{/* ── Locked notice ────────────────────────────────────────────────── */}
{fileSelectionLocked && (
<div className="flex items-center gap-2.5 rounded-2xl bg-amber-500/10 px-4 py-3 text-sm text-amber-100 ring-1 ring-amber-300/30">
<svg className="h-4 w-4 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 starts. Reset to change the file.
</div>
)}
{/* ── Primary dropzone ─────────────────────────────────────────────── */}
<UploadDropzone
title="Drop your file here"
description="JPG, PNG, WEBP · ZIP, RAR, 7Z · Images up to 50 MB · Archives up to 200 MB"
fileName={primaryFile?.name || ''}
previewUrl={primaryPreviewUrl}
fileMeta={fileMetadata}
fileHint="No file selected"
invalid={primaryErrors.length > 0}
errors={primaryErrors}
showLooksGood={fileSelected && primaryErrors.length === 0}
looksGoodText="File looks good — ready to upload"
locked={fileSelectionLocked}
onPrimaryFileChange={(file) => {
if (fileSelectionLocked) return
onPrimaryFileChange(file || null)
}}
/>
{/* ── Screenshots (archives only) ──────────────────────────────────── */}
<ScreenshotUploader
title="Archive screenshots"
description="Add at least 1 screenshot so we can generate a thumbnail and analyze your 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="Screenshots look good"
onFilesChange={onScreenshotsChange}
/>
{isArchive && screenshots.length > 0 && (
<div className="rounded-2xl border border-white/10 bg-white/[0.03] p-4 sm:p-5">
<ArchiveScreenshotPicker
screenshots={screenshots}
selectedIndex={selectedScreenshotIndex}
onSelect={onSelectedScreenshotChange}
compact
title="Choose default screenshot"
description="Pick the screenshot that should be uploaded as the archive preview before you start the upload."
/>
</div>
)}
{/* ── Subtle what-happens-next hints (shown only before a file is picked) */}
{!fileSelected && (
<div className="grid gap-3 sm:grid-cols-3">
{[
{
icon: (
<svg className="h-5 w-5 text-sky-300" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M21 15v4a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1v-4" /><path d="M7 10l5-5 5 5" /><path d="M12 5v10" />
</svg>
),
label: 'Add your file',
hint: 'Image or archive — drop it in or click to browse.',
},
{
icon: (
<svg className="h-5 w-5 text-violet-300" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M9 12l2 2 4-4" /><path d="M12 22C6.477 22 2 17.523 2 12S6.477 2 12 2s10 4.477 10 10-4.477 10-10 10z" />
</svg>
),
label: 'Instant validation',
hint: 'Format, size, and screenshot checks run immediately.',
},
{
icon: (
<svg className="h-5 w-5 text-emerald-300" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true">
<path d="M5 13l4 4L19 7" />
</svg>
),
label: 'Start upload',
hint: 'One click sends your file through the secure pipeline.',
},
].map((item) => (
<div key={item.label} className="flex items-start gap-3 rounded-2xl border border-white/8 bg-white/[0.03] px-4 py-4">
<div className="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-xl border border-white/10 bg-white/[0.05]">
{item.icon}
</div>
<div>
<p className="text-sm font-semibold text-white">{item.label}</p>
<p className="mt-1 text-xs leading-5 text-slate-400">{item.hint}</p>
</div>
</div>
))}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,623 @@
import React, { useEffect, useMemo, useState } from 'react'
import ArchiveScreenshotPicker from '../ArchiveScreenshotPicker'
import UploadSidebar from '../UploadSidebar'
import { NovaSelect } from '../../ui'
import { getContentTypeValue, getContentTypeVisualKey } from '../../../lib/uploadUtils'
/**
* 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,
selectedScreenshotIndex,
onSelectedScreenshotChange,
// Content type + category
contentTypes,
metadata,
metadataErrors,
filteredCategoryTree,
allRootCategoryOptions,
requiresSubCategory,
onContentTypeChange,
onRootCategoryChange,
onSubCategoryChange,
groupOptions,
currentContributorOptions,
onGroupChange,
onPrimaryAuthorChange,
onContributorToggle,
onContributorRoleChange,
onContributorPrimaryChange,
// Sidebar (title / tags / description / rights)
suggestedTags,
publishMode,
scheduledAt,
timezone,
onPublishModeChange,
onScheduleAt,
onChangeTitle,
onChangeTags,
onChangeDescription,
onToggleMature,
onToggleRights,
}) {
const [isContentTypeChooserOpen, setIsContentTypeChooserOpen] = useState(() => !metadata.contentType)
const [isCategoryChooserOpen, setIsCategoryChooserOpen] = useState(() => !metadata.rootCategoryId)
const [isSubCategoryChooserOpen, setIsSubCategoryChooserOpen] = useState(() => !metadata.subCategoryId)
const [categorySearch, setCategorySearch] = useState('')
const [subCategorySearch, setSubCategorySearch] = useState('')
const contentTypeOptions = useMemo(
() => (Array.isArray(contentTypes) ? contentTypes : []).map((item) => {
const normalizedName = String(item?.name || '').trim().toLowerCase()
const normalizedSlug = String(item?.slug || '').trim().toLowerCase()
if (normalizedName === 'other' || normalizedSlug === 'other') {
return {
...item,
name: 'Others',
}
}
return item
}),
[contentTypes]
)
const selectedContentType = useMemo(
() => contentTypeOptions.find((item) => String(getContentTypeValue(item)) === String(metadata.contentType || '')) ?? null,
[contentTypeOptions, metadata.contentType]
)
const selectedRoot = useMemo(
() => filteredCategoryTree.find((item) => String(item.id) === String(metadata.rootCategoryId || '')) ?? null,
[filteredCategoryTree, metadata.rootCategoryId]
)
const subCategories = selectedRoot?.children || []
const selectedSubCategory = useMemo(
() => subCategories.find((item) => String(item.id) === String(metadata.subCategoryId || '')) ?? null,
[subCategories, metadata.subCategoryId]
)
const sortedFilteredCategories = useMemo(() => {
const sorted = [...filteredCategoryTree].sort((a, b) => a.name.localeCompare(b.name))
const q = categorySearch.trim().toLowerCase()
return q ? sorted.filter((c) => c.name.toLowerCase().includes(q)) : sorted
}, [filteredCategoryTree, categorySearch])
const sortedFilteredSubCategories = useMemo(() => {
const sorted = [...subCategories].sort((a, b) => a.name.localeCompare(b.name))
const q = subCategorySearch.trim().toLowerCase()
return q ? sorted.filter((s) => s.name.toLowerCase().includes(q)) : sorted
}, [subCategories, subCategorySearch])
const contributorCredits = metadata.contributorCredits || {}
useEffect(() => {
if (!metadata.contentType) {
setIsContentTypeChooserOpen(true)
}
}, [metadata.contentType])
useEffect(() => {
if (!metadata.rootCategoryId) {
setIsCategoryChooserOpen(true)
}
}, [metadata.rootCategoryId])
useEffect(() => {
if (!metadata.subCategoryId) {
setIsSubCategoryChooserOpen(true)
}
}, [metadata.subCategoryId])
return (
<div className="space-y-5 rounded-[28px] border border-white/10 bg-[linear-gradient(180deg,rgba(255,255,255,0.05),rgba(255,255,255,0.02))] p-6 shadow-[0_24px_90px_rgba(2,8,23,0.28)] backdrop-blur sm:p-7">
{/* Step header */}
<div className="rounded-2xl border border-white/8 bg-white/[0.04] px-5 py-4">
<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-2xl ring-1 ring-white/8 bg-white/[0.025] p-5">
<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>
{isArchive && screenshots.length > 0 && (
<div className="mt-5 border-t border-white/8 pt-5">
<ArchiveScreenshotPicker
screenshots={screenshots}
selectedIndex={selectedScreenshotIndex}
onSelect={onSelectedScreenshotChange}
title="Archive screenshots"
description="All selected screenshots are shown here. Pick the one that should become the main preview thumbnail."
/>
</div>
)}
</div>
{/* ── Combined: Content type → Category → Subcategory ─────────────────── */}
<section className="rounded-2xl border border-white/10 bg-[radial-gradient(ellipse_at_top_left,_rgba(14,165,233,0.07),_transparent_45%),radial-gradient(ellipse_at_bottom_right,_rgba(168,85,247,0.07),_transparent_45%)] p-5 sm:p-6">
{/* Section header */}
<div className="mb-5 flex flex-wrap items-start justify-between gap-2">
<div>
<h3 className="text-sm font-semibold text-white">Content type &amp; category</h3>
<p className="mt-1 text-xs text-white/55">Choose the content family, then narrow down to a category and subcategory.</p>
</div>
<span className="rounded-full border border-sky-400/30 bg-sky-400/10 px-2.5 py-0.5 text-[11px] uppercase tracking-widest text-sky-300">Step 2</span>
</div>
{/* ── Content type ── */}
<div>
<p className="mb-3 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Content type</p>
{contentTypeOptions.length === 0 && (
<div className="rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-4 text-sm text-slate-400">
No content types are available right now.
</div>
)}
{selectedContentType && !isContentTypeChooserOpen && (
<div className="rounded-2xl border border-emerald-400/25 bg-emerald-400/[0.08] p-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl border border-emerald-400/30 bg-emerald-400/10">
<img
src={`/gfx/mascot_${getContentTypeVisualKey(selectedContentType)}.webp`}
alt=""
className="h-7 w-7 object-contain"
onError={(e) => { e.currentTarget.style.display = 'none' }}
/>
</div>
<div>
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-emerald-200/70">Selected</div>
<div className="mt-0.5 text-base font-semibold text-white">{selectedContentType.name}</div>
</div>
</div>
<button
type="button"
onClick={() => setIsContentTypeChooserOpen(true)}
className="inline-flex items-center justify-center rounded-xl border border-white/10 bg-white/[0.05] px-3 py-2 text-sm font-medium text-slate-200 transition hover:border-white/20 hover:bg-white/[0.08] hover:text-white"
>
Change
</button>
</div>
</div>
)}
{(!selectedContentType || isContentTypeChooserOpen) && (
<div className="grid gap-3 lg:grid-cols-2">
{contentTypeOptions.map((ct) => {
const typeValue = String(getContentTypeValue(ct))
const isActive = typeValue === String(metadata.contentType || '')
const visualKey = getContentTypeVisualKey(ct)
const categoryCount = Array.isArray(ct.categories) ? ct.categories.length : 0
return (
<button
key={typeValue || ct.name}
type="button"
onClick={() => {
setIsContentTypeChooserOpen(false)
setIsCategoryChooserOpen(true)
onContentTypeChange(typeValue)
}}
className={[
'group flex w-full items-center gap-3 rounded-2xl border px-3 py-3 text-left transition-all',
isActive
? 'border-emerald-400/40 bg-emerald-400/10 shadow-[0_0_0_1px_rgba(52,211,153,0.18)]'
: 'border-white/10 bg-white/[0.03] hover:border-white/20 hover:bg-white/[0.06]',
].join(' ')}
aria-pressed={isActive}
>
<div className={`flex h-12 w-12 items-center justify-center rounded-2xl border ${isActive ? 'border-emerald-400/30 bg-emerald-400/10' : 'border-white/10 bg-white/[0.04]'}`}>
<img
src={`/gfx/mascot_${visualKey}.webp`}
alt=""
className="h-8 w-8 object-contain"
onError={(e) => { e.currentTarget.style.display = 'none' }}
/>
</div>
<div className="min-w-0 flex-1">
<div className={`text-sm font-semibold ${isActive ? 'text-emerald-200' : 'text-white'}`}>{ct.name}</div>
<div className="mt-1 text-[11px] text-slate-500">{categoryCount} {categoryCount === 1 ? 'category' : 'categories'}</div>
</div>
<div className={`text-xs ${isActive ? 'text-emerald-300' : 'text-slate-500 group-hover:text-slate-300'}`}>
{isActive ? 'Selected' : 'Open'}
</div>
</button>
)
})}
</div>
)}
{metadataErrors.contentType && <p className="mt-3 text-xs text-red-300">{metadataErrors.contentType}</p>}
</div>
{/* ── Category ── */}
{selectedContentType && (
<>
<div className="my-5 border-t border-white/8" />
<div>
<div className="mb-3 flex items-center justify-between gap-2">
<p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Category</p>
<span className="text-[11px] text-slate-600">{filteredCategoryTree.length} available</span>
</div>
{selectedRoot && !isCategoryChooserOpen && (
<div className="rounded-2xl border border-purple-400/25 bg-purple-400/[0.07] p-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-purple-200/70">Selected</div>
<div className="mt-0.5 text-base font-semibold text-white">{selectedRoot.name}</div>
<div className="mt-1 text-xs text-slate-500">
{subCategories.length > 0
? `${subCategories.length} subcategories available`
: 'No subcategory required'}
</div>
</div>
<button
type="button"
onClick={() => { setCategorySearch(''); setIsCategoryChooserOpen(true) }}
className="inline-flex items-center justify-center rounded-xl border border-white/10 bg-white/[0.05] px-3 py-2 text-sm font-medium text-slate-200 transition hover:border-white/20 hover:bg-white/[0.08] hover:text-white"
>
Change
</button>
</div>
</div>
)}
{(!selectedRoot || isCategoryChooserOpen) && (
<div className="space-y-3">
<div className="relative">
<svg className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-500" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"><path fillRule="evenodd" d="M9 3.5a5.5 5.5 0 100 11 5.5 5.5 0 000-11zM2 9a7 7 0 1112.452 4.391l3.328 3.329a.75.75 0 11-1.06 1.06l-3.329-3.328A7 7 0 012 9z" clipRule="evenodd" /></svg>
<input
type="search"
value={categorySearch}
onChange={(e) => setCategorySearch(e.target.value)}
placeholder="Search categories…"
className="w-full rounded-xl border border-white/10 bg-white/[0.04] py-2.5 pl-9 pr-4 text-sm text-white placeholder:text-slate-500 focus:border-purple-400/40 focus:outline-none focus:ring-1 focus:ring-purple-400/30"
/>
</div>
{sortedFilteredCategories.length === 0 && (
<p className="py-4 text-center text-sm text-slate-500">No categories match &ldquo;{categorySearch}&rdquo;</p>
)}
<div className="grid gap-3 lg:grid-cols-2">
{sortedFilteredCategories.map((cat) => {
const isActive = String(metadata.rootCategoryId || '') === String(cat.id)
const childCount = cat.children?.length || 0
return (
<button
key={cat.id}
type="button"
onClick={() => {
setIsCategoryChooserOpen(false)
onRootCategoryChange(String(cat.id))
}}
className={[
'rounded-2xl border px-4 py-4 text-left transition-all',
isActive
? 'border-purple-400/40 bg-purple-400/12 shadow-[0_0_0_1px_rgba(192,132,252,0.15)]'
: 'border-white/10 bg-white/[0.03] hover:border-white/20 hover:bg-white/[0.05]',
].join(' ')}
>
<div className="flex items-start justify-between gap-3">
<div>
<div className={`text-sm font-semibold ${isActive ? 'text-purple-200' : 'text-white'}`}>{cat.name}</div>
<div className="mt-1 text-[11px] text-slate-500">{childCount > 0 ? `${childCount} subcategories` : 'Standalone'}</div>
</div>
<span className={`rounded-full px-2 py-1 text-[11px] ${isActive ? 'bg-purple-300/15 text-purple-200' : 'bg-white/[0.05] text-slate-500'}`}>
{isActive ? 'Selected' : 'Choose'}
</span>
</div>
</button>
)
})}
</div>
</div>
)}
{metadataErrors.category && <p className="mt-3 text-xs text-red-300">{metadataErrors.category}</p>}
</div>
</>
)}
{/* ── Subcategory ── */}
{selectedRoot && subCategories.length > 0 && (
<>
<div className="my-5 border-t border-white/8" />
<div>
<div className="mb-3 flex items-center justify-between gap-2">
<p className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Subcategory</p>
<span className="text-[11px] text-slate-600">{subCategories.length} available</span>
</div>
{!metadata.subCategoryId && requiresSubCategory && (
<div className="mb-3 rounded-xl border border-amber-400/20 bg-amber-400/10 px-3 py-2 text-sm text-amber-100">
Subcategory still needs to be selected.
</div>
)}
{selectedSubCategory && !isSubCategoryChooserOpen && (
<div className="rounded-2xl border border-cyan-400/25 bg-cyan-400/[0.07] p-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-cyan-200/70">Selected</div>
<div className="mt-0.5 text-base font-semibold text-white">{selectedSubCategory.name}</div>
<div className="mt-1 text-xs text-slate-500">
Path: <span className="text-slate-300">{selectedRoot.name}</span> / <span className="text-cyan-200">{selectedSubCategory.name}</span>
</div>
</div>
<button
type="button"
onClick={() => { setSubCategorySearch(''); setIsSubCategoryChooserOpen(true) }}
className="inline-flex items-center justify-center rounded-xl border border-white/10 bg-white/[0.05] px-3 py-2 text-sm font-medium text-slate-200 transition hover:border-white/20 hover:bg-white/[0.08] hover:text-white"
>
Change
</button>
</div>
</div>
)}
{(!selectedSubCategory || isSubCategoryChooserOpen) && (
<div className="space-y-3">
<div className="relative">
<svg className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-slate-500" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true"><path fillRule="evenodd" d="M9 3.5a5.5 5.5 0 100 11 5.5 5.5 0 000-11zM2 9a7 7 0 1112.452 4.391l3.328 3.329a.75.75 0 11-1.06 1.06l-3.329-3.328A7 7 0 012 9z" clipRule="evenodd" /></svg>
<input
type="search"
value={subCategorySearch}
onChange={(e) => setSubCategorySearch(e.target.value)}
placeholder="Search subcategories…"
className="w-full rounded-xl border border-white/10 bg-white/[0.04] py-2.5 pl-9 pr-4 text-sm text-white placeholder:text-slate-500 focus:border-cyan-400/40 focus:outline-none focus:ring-1 focus:ring-cyan-400/30"
/>
</div>
{sortedFilteredSubCategories.length === 0 && (
<p className="py-4 text-center text-sm text-slate-500">No subcategories match &ldquo;{subCategorySearch}&rdquo;</p>
)}
<div className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{sortedFilteredSubCategories.map((sub) => {
const isActive = String(metadata.subCategoryId || '') === String(sub.id)
return (
<button
key={sub.id}
type="button"
onClick={() => {
setIsSubCategoryChooserOpen(false)
onSubCategoryChange(String(sub.id))
}}
className={[
'group rounded-2xl border px-4 py-3 text-left transition-all',
isActive
? 'border-cyan-400/40 bg-cyan-400/[0.13] shadow-[0_0_0_1px_rgba(34,211,238,0.14)]'
: 'border-white/10 bg-white/[0.04] hover:border-white/20 hover:bg-white/[0.06]',
].join(' ')}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className={['text-sm font-semibold transition-colors', isActive ? 'text-cyan-100' : 'text-slate-100 group-hover:text-white'].join(' ')}>
{sub.name}
</div>
<div className={['mt-1 text-xs', isActive ? 'text-cyan-200/80' : 'text-slate-500 group-hover:text-slate-300'].join(' ')}>
Subcategory
</div>
</div>
<span className={['shrink-0 rounded-full px-2.5 py-1 text-[11px] font-medium', isActive ? 'bg-cyan-300/15 text-cyan-100' : 'bg-white/[0.05] text-slate-500 group-hover:text-slate-300'].join(' ')}>
{isActive ? 'Selected' : 'Choose'}
</span>
</div>
</button>
)
})}
</div>
</div>
)}
</div>
</>
)}
{selectedRoot && subCategories.length === 0 && selectedRoot && (
<div className="mt-5 rounded-2xl border border-white/8 bg-white/[0.02] px-4 py-3 text-sm text-slate-500">
<span className="font-medium text-slate-300">{selectedRoot.name}</span> has no subcategories selecting it is enough.
</div>
)}
</section>
{Array.isArray(groupOptions) && groupOptions.length > 0 && (
<section className="rounded-2xl border border-white/10 bg-[radial-gradient(ellipse_at_top_left,_rgba(56,189,248,0.08),_transparent_42%),linear-gradient(180deg,rgba(255,255,255,0.04),rgba(255,255,255,0.02))] p-5 sm:p-6">
<div className="mb-5 flex flex-wrap items-start justify-between gap-3">
<div>
<h3 className="text-sm font-semibold text-white">Publisher attribution</h3>
<p className="mt-1 text-xs text-white/55">Publish personally or switch into a group identity while preserving author and contributor credits.</p>
</div>
{metadata.group ? <span className="rounded-full border border-sky-300/20 bg-sky-300/10 px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-sky-100">Group publish</span> : <span className="rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-300">Personal publish</span>}
</div>
<label className="block">
<NovaSelect
label="Publishing identity"
value={metadata.group || ''}
onChange={(nextValue) => onGroupChange?.(String(nextValue || ''))}
options={[
{ value: '', label: 'Personal profile' },
...groupOptions.map((group) => ({ value: group.slug, label: group.name })),
]}
searchable={false}
className="mt-2 bg-black/20"
/>
</label>
{metadata.group && (
<div className="mt-5 grid gap-5 lg:grid-cols-[minmax(0,0.95fr)_minmax(0,1.05fr)]">
<div>
<label className="block">
<NovaSelect
label="Primary author"
value={metadata.primaryAuthorUserId || null}
onChange={(nextValue) => onPrimaryAuthorChange?.(nextValue == null ? '' : String(nextValue))}
options={currentContributorOptions.map((user) => ({
value: user.id,
label: user.name || user.username,
}))}
searchable={false}
className="mt-2 bg-black/20"
/>
</label>
<p className="mt-2 text-xs text-slate-400">The primary author is shown as the lead creator for this group-published artwork.</p>
</div>
<div>
<div className="flex items-center justify-between gap-3">
<span className="text-sm font-medium text-white/90">Contributors</span>
<span className="text-xs text-slate-500">Optional</span>
</div>
<div className="mt-2 grid gap-2">
{currentContributorOptions.filter((user) => Number(user.id) !== Number(metadata.primaryAuthorUserId)).map((user) => {
const active = Array.isArray(metadata.contributorUserIds) && metadata.contributorUserIds.some((id) => Number(id) === Number(user.id))
const creditMeta = contributorCredits?.[user.id] || contributorCredits?.[String(user.id)] || { creditRole: '', isPrimary: false }
return (
<div
key={user.id}
className={[
'rounded-2xl border px-3 py-3 transition',
active
? 'border-sky-300/30 bg-sky-300/10 text-white'
: 'border-white/10 bg-white/[0.03] text-slate-200 hover:border-white/20 hover:bg-white/[0.06]',
].join(' ')}
>
<div className="flex items-center gap-3">
{user.avatar_url ? <img src={user.avatar_url} alt={user.name || user.username} className="h-10 w-10 rounded-2xl object-cover" /> : <div className="flex h-10 w-10 items-center justify-center rounded-2xl border border-white/10 bg-white/[0.03] text-slate-400"><i className="fa-solid fa-user" /></div>}
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-semibold">{user.name || user.username}</div>
<div className="truncate text-xs text-slate-400">@{user.username}</div>
</div>
<button
type="button"
onClick={() => onContributorToggle?.(user.id)}
className={[
'inline-flex items-center gap-2 rounded-full border px-3 py-1.5 text-xs font-semibold transition',
active
? 'border-sky-300/40 bg-sky-300/20 text-sky-50'
: 'border-white/10 bg-white/[0.03] text-white/70 hover:border-white/20 hover:text-white',
].join(' ')}
>
<span className={['inline-flex h-4 w-4 items-center justify-center rounded-full border text-[10px]', active ? 'border-sky-300/40 bg-sky-300/20 text-sky-50' : 'border-white/10 bg-white/[0.03] text-white/35'].join(' ')}>{active ? '✓' : ''}</span>
{active ? 'Added' : 'Add credit'}
</button>
</div>
{active ? (
<div className="mt-4 grid gap-3 md:grid-cols-[minmax(0,1fr)_auto] md:items-end">
<label className="block">
<span className="text-xs font-medium uppercase tracking-[0.16em] text-slate-300">Credit role</span>
<input
type="text"
value={creditMeta.creditRole || ''}
onChange={(event) => onContributorRoleChange?.(user.id, event.target.value)}
placeholder="Colorist, concept support, layout..."
aria-label={`Credit role for ${user.name || user.username}`}
className="mt-2 w-full rounded-xl border border-white/15 bg-black/20 px-3 py-3 text-sm text-white outline-none transition focus:border-sky-300/40"
/>
</label>
<button
type="button"
onClick={() => onContributorPrimaryChange?.(user.id)}
aria-pressed={creditMeta.isPrimary ? 'true' : 'false'}
aria-label={`Mark ${user.name || user.username} as lead supporting credit`}
className={[
'inline-flex items-center justify-center rounded-xl border px-3 py-3 text-sm font-medium transition',
creditMeta.isPrimary
? 'border-emerald-300/35 bg-emerald-400/12 text-emerald-100'
: 'border-white/10 bg-white/[0.03] text-slate-200 hover:border-white/20 hover:bg-white/[0.06] hover:text-white',
].join(' ')}
>
{creditMeta.isPrimary ? 'Lead support' : 'Set lead support'}
</button>
</div>
) : null}
</div>
)
})}
</div>
</div>
</div>
)}
</section>
)}
{/* Title, tags, description, rights */}
<UploadSidebar
showHeader={false}
metadata={metadata}
suggestedTags={suggestedTags}
errors={metadataErrors}
publishMode={publishMode}
scheduledAt={scheduledAt}
timezone={timezone}
onPublishModeChange={onPublishModeChange}
onScheduleAt={onScheduleAt}
onChangeTitle={onChangeTitle}
onChangeTags={onChangeTags}
onChangeDescription={onChangeDescription}
onToggleMature={onToggleMature}
onToggleRights={onToggleRights}
/>
</div>
)
}

View File

@@ -0,0 +1,312 @@
import React from 'react'
import { motion, useReducedMotion } from 'framer-motion'
import ArchiveScreenshotPicker from '../ArchiveScreenshotPicker'
function stripHtml(value) {
return String(value || '')
.replace(/<[^>]*>/g, ' ')
.replace(/&nbsp;/g, ' ')
.replace(/\s+/g, ' ')
.trim()
}
/**
* 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, readiness badges,
* and a summary of publish mode / schedule + visibility.
*
* Publish controls (mode/schedule picker) live in PublishPanel (sidebar).
* This step serves as the final review before the user clicks Publish.
*/
export default function Step3Publish({
headingRef,
// Asset
primaryFile,
primaryPreviewUrl,
isArchive,
screenshots,
selectedScreenshotIndex,
onSelectedScreenshotChange,
fileMetadata,
// Metadata
metadata,
// Readiness
canPublish,
uploadReady,
// Publish options (from wizard state, for summary display only)
publishMode = 'now',
scheduledAt = null,
timezone = null,
visibility = 'public',
onVisibilityChange,
selectedGroup = null,
currentContributorOptions = [],
actionLabel = 'Publish now',
showScheduleSummary = true,
// Category tree (for label lookup)
allRootCategoryOptions = [],
filteredCategoryTree = [],
}) {
const prefersReducedMotion = useReducedMotion()
const quickTransition = prefersReducedMotion ? { duration: 0 } : { duration: 0.2, ease: 'easeOut' }
const hasPreview = Boolean(primaryPreviewUrl && !isArchive)
// ── Category label lookup ────────────────────────────────────────────────
const rootCategory = allRootCategoryOptions.find(
(r) => String(r.id) === String(metadata.rootCategoryId)
) ?? null
const rootLabel = rootCategory?.name ?? null
const subCategory = rootCategory?.children?.find(
(c) => String(c.id) === String(metadata.subCategoryId)
) ?? null
const subLabel = subCategory?.name ?? null
const descriptionPreview = stripHtml(metadata.description)
const primaryAuthor = (Array.isArray(currentContributorOptions) ? currentContributorOptions : []).find(
(user) => Number(user.id) === Number(metadata.primaryAuthorUserId)
) ?? null
const contributorCredits = metadata.contributorCredits || {}
const contributors = (Array.isArray(currentContributorOptions) ? currentContributorOptions : [])
.filter((user) => Array.isArray(metadata.contributorUserIds) && metadata.contributorUserIds.some((id) => Number(id) === Number(user.id)))
.map((user) => {
const creditMeta = contributorCredits?.[user.id] || contributorCredits?.[String(user.id)] || { creditRole: '', isPrimary: false }
return {
...user,
creditRole: creditMeta.creditRole || '',
isPrimary: Boolean(creditMeta.isPrimary),
}
})
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) },
{ label: 'Tags added', ok: Array.isArray(metadata.tags) && metadata.tags.length > 0 },
]
return (
<div className="space-y-5 rounded-[28px] border border-white/10 bg-[linear-gradient(180deg,rgba(255,255,255,0.05),rgba(255,255,255,0.02))] p-6 shadow-[0_24px_90px_rgba(2,8,23,0.28)] backdrop-blur sm:p-7">
{/* Step header */}
<div className="rounded-2xl border border-white/8 bg-white/[0.04] px-5 py-4">
<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-2xl ring-1 ring-white/8 bg-white/[0.025] p-5">
<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>
)}
{rootLabel && (
<span>Category: <span className="text-white/75">{rootLabel}</span></span>
)}
{subLabel && (
<span>Sub: <span className="text-white/75">{subLabel}</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>
<span>Audience: <span className="text-white/75">{metadata.isMature ? 'Mature' : 'General'}</span></span>
{selectedGroup ? <span>Publisher: <span className="text-white/75">{selectedGroup.name}</span></span> : <span>Publisher: <span className="text-white/75">Personal profile</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>
{(selectedGroup || primaryAuthor || contributors.length > 0) && (
<div className="space-y-2 text-xs text-white/55">
{primaryAuthor ? <span>Primary author: <span className="text-white/75">{primaryAuthor.name || primaryAuthor.username}</span></span> : null}
{contributors.length > 0 ? (
<div>
<span>Contributors:</span>
<div className="mt-1 flex flex-wrap gap-2">
{contributors.map((user) => (
<span key={user.id} className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.04] px-3 py-1.5 text-white/80">
<span>{user.name || user.username}</span>
{user.creditRole ? <span className="text-white/50">{user.creditRole}</span> : null}
{user.isPrimary ? <span className="rounded-full border border-emerald-300/30 bg-emerald-400/12 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.16em] text-emerald-100">Lead support</span> : null}
</span>
))}
</div>
</div>
) : null}
</div>
)}
{descriptionPreview && (
<p className="line-clamp-2 text-xs text-white/50">{descriptionPreview}</p>
)}
</div>
</div>
{isArchive && screenshots.length > 0 && (
<div className="mt-5 border-t border-white/8 pt-5">
<ArchiveScreenshotPicker
screenshots={screenshots}
selectedIndex={selectedScreenshotIndex}
onSelect={onSelectedScreenshotChange}
title="Archive preview"
description="This screenshot will be used as the default preview once the archive is published."
/>
</div>
)}
</div>
{/* ── Visibility selector ────────────────────────────────────────── */}
<section className="rounded-2xl border border-white/10 bg-white/[0.03] p-5">
<p className="mb-3 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Visibility</p>
<div className="grid gap-2 sm:grid-cols-3">
{[
{ value: 'public', label: 'Public', hint: 'Visible to everyone' },
{ value: 'unlisted', label: 'Unlisted', hint: 'Available by direct link' },
{ value: 'private', label: 'Private', hint: 'Keep as draft visibility' },
].map((option) => {
const active = visibility === option.value
return (
<button
key={option.value}
type="button"
onClick={() => onVisibilityChange?.(option.value)}
className={[
'flex items-start justify-between gap-3 rounded-2xl border px-4 py-3 text-left transition',
active
? 'border-sky-300/30 bg-sky-400/10 text-white shadow-[0_0_0_1px_rgba(56,189,248,0.12)]'
: 'border-white/10 bg-white/[0.03] text-white/75 hover:border-white/20 hover:bg-white/[0.06]',
].join(' ')}
>
<div>
<div className="text-sm font-semibold">{option.label}</div>
<div className="mt-1 text-xs text-white/45">{option.hint}</div>
</div>
<span className={[
'mt-0.5 inline-flex h-5 w-5 shrink-0 items-center justify-center rounded-full border text-[10px]',
active ? 'border-sky-300/40 bg-sky-400/20 text-sky-100' : 'border-white/10 bg-white/5 text-white/30',
].join(' ')}>
{active ? '✓' : ''}
</span>
</button>
)
})}
</div>
</section>
{/* Publish summary: schedule info */}
<div className="flex flex-wrap gap-3">
<span className="inline-flex items-center gap-1.5 rounded-full border border-white/15 bg-white/6 px-2.5 py-1 text-xs text-white/60">
👁 {visibility === 'public' ? 'Public' : visibility === 'unlisted' ? 'Unlisted' : 'Private'}
</span>
{showScheduleSummary && publishMode === 'schedule' && scheduledAt ? (
<span className="inline-flex items-center gap-1.5 rounded-full border border-violet-300/30 bg-violet-500/15 px-2.5 py-1 text-xs text-violet-200">
🕐 Scheduled
{timezone && (
<span className="text-violet-300/70">
{' '}·{' '}
{new Intl.DateTimeFormat('en-GB', {
timeZone: timezone,
weekday: 'short', day: 'numeric', month: 'short',
hour: '2-digit', minute: '2-digit', hour12: false,
}).format(new Date(scheduledAt))}
</span>
)}
</span>
) : (
<span className="inline-flex items-center gap-1.5 rounded-full border border-emerald-300/30 bg-emerald-500/12 px-2.5 py-1 text-xs text-emerald-200">
{actionLabel}
</span>
)}
</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>
)
}