Save workspace changes
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 20–30 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 & 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 “{categorySearch}”</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 “{subCategorySearch}”</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>
|
||||
)
|
||||
}
|
||||
@@ -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(/ /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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user