import React, { useEffect, useMemo, useState } from 'react'
import ArchiveScreenshotPicker from '../ArchiveScreenshotPicker'
import UploadSidebar from '../UploadSidebar'
import { NovaSelect } from '../../ui'
import { additionalCategoryOptions, 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,
onAdditionalCategoriesChange,
maxSecondaryCategories = 5,
groupOptions,
currentContributorOptions,
onGroupChange,
onPrimaryAuthorChange,
onContributorToggle,
onContributorRoleChange,
onContributorPrimaryChange,
// Sidebar (title / tags / description / rights)
suggestedTags,
publishMode,
scheduledAt,
timezone,
onPublishModeChange,
onScheduleAt,
onChangeTitle,
onChangeTags,
onChangeDescription,
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 selectedLeafCategoryId = metadata.subCategoryId || (!requiresSubCategory ? metadata.rootCategoryId : '')
const extraCategoryOptions = useMemo(
() => additionalCategoryOptions(filteredCategoryTree, selectedLeafCategoryId),
[filteredCategoryTree, selectedLeafCategoryId]
)
const selectedAdditionalIds = useMemo(
() => (Array.isArray(metadata.additionalCategoryIds) ? metadata.additionalCategoryIds.map(String) : []),
[metadata.additionalCategoryIds]
)
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 (
{/* Step header */}
Artwork details
Complete required metadata and rights confirmation before publishing.
{/* Uploaded asset summary */}
Uploaded asset
{/* Thumbnail / Archive icon */}
{primaryPreviewUrl && !isArchive ? (
) : (
)}
{/* File metadata */}
{primaryFile?.name || 'Primary file'}
{isArchive
? `Archive · ${screenshots.length} screenshot${screenshots.length !== 1 ? 's' : ''}`
: fileMetadata.resolution !== '—'
? `${fileMetadata.resolution} · ${fileMetadata.size}`
: fileMetadata.size}
{isArchive ? 'Archive' : 'Image'}
{isArchive && screenshots.length > 0 && (
)}
{/* ── Combined: Content type → Category → Subcategory ─────────────────── */}
{/* Section header */}
Content type & category
Choose the content family, then narrow down to a category and subcategory.
Step 2
{/* ── Content type ── */}
Content type
{contentTypeOptions.length === 0 && (
No content types are available right now.
)}
{selectedContentType && !isContentTypeChooserOpen && (
{ e.currentTarget.style.display = 'none' }}
/>
Selected
{selectedContentType.name}
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
)}
{(!selectedContentType || isContentTypeChooserOpen) && (
{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 (
{
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}
>
{ e.currentTarget.style.display = 'none' }}
/>
{ct.name}
{categoryCount} {categoryCount === 1 ? 'category' : 'categories'}
{isActive ? 'Selected' : 'Open'}
)
})}
)}
{metadataErrors.contentType &&
{metadataErrors.contentType}
}
{/* ── Category ── */}
{selectedContentType && (
<>
Category
{filteredCategoryTree.length} available
{selectedRoot && !isCategoryChooserOpen && (
Selected
{selectedRoot.name}
{subCategories.length > 0
? `${subCategories.length} subcategories available`
: 'No subcategory required'}
{ 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
)}
{(!selectedRoot || isCategoryChooserOpen) && (
{sortedFilteredCategories.length === 0 && (
No categories match “{categorySearch}”
)}
{sortedFilteredCategories.map((cat) => {
const isActive = String(metadata.rootCategoryId || '') === String(cat.id)
const childCount = cat.children?.length || 0
return (
{
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(' ')}
>
{cat.name}
{childCount > 0 ? `${childCount} subcategories` : 'Standalone'}
{isActive ? 'Selected' : 'Choose'}
)
})}
)}
{metadataErrors.category &&
{metadataErrors.category}
}
>
)}
{/* ── Subcategory ── */}
{selectedRoot && subCategories.length > 0 && (
<>
Subcategory
{subCategories.length} available
{!metadata.subCategoryId && requiresSubCategory && (
Subcategory still needs to be selected.
)}
{selectedSubCategory && !isSubCategoryChooserOpen && (
Selected
{selectedSubCategory.name}
Path: {selectedRoot.name} / {selectedSubCategory.name}
{ 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
)}
{(!selectedSubCategory || isSubCategoryChooserOpen) && (
{sortedFilteredSubCategories.length === 0 && (
No subcategories match “{subCategorySearch}”
)}
{sortedFilteredSubCategories.map((sub) => {
const isActive = String(metadata.subCategoryId || '') === String(sub.id)
return (
{
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(' ')}
>
{isActive ? 'Selected' : 'Choose'}
)
})}
)}
>
)}
{selectedRoot && subCategories.length === 0 && selectedRoot && (
{selectedRoot.name} has no subcategories — selecting it is enough.
)}
{selectedLeafCategoryId && extraCategoryOptions.length > 0 && (
<>
Additional categories
{selectedAdditionalIds.length}/{maxSecondaryCategories}
Optional. Add up to {maxSecondaryCategories} other categories from this content type. They appear on those category pages but do not change the artwork URL or breadcrumb.
{extraCategoryOptions.map((option) => {
const selected = selectedAdditionalIds.includes(String(option.id))
const atLimit = !selected && selectedAdditionalIds.length >= maxSecondaryCategories
return (
{
const next = selected
? selectedAdditionalIds.filter((id) => id !== String(option.id))
: [...selectedAdditionalIds, String(option.id)]
onAdditionalCategoriesChange?.(next)
}}
className={[
'rounded-full border px-3 py-1.5 text-sm transition',
selected
? 'border-sky-400/40 bg-sky-400/15 text-sky-100'
: 'border-white/10 bg-white/[0.04] text-slate-300 hover:border-white/20 hover:bg-white/[0.08] disabled:cursor-not-allowed disabled:opacity-40',
].join(' ')}
aria-pressed={selected}
>
{option.name}
)
})}
{metadataErrors.secondary_category_ids && (
{metadataErrors.secondary_category_ids}
)}
>
)}
{Array.isArray(groupOptions) && groupOptions.length > 0 && (
Publisher attribution
Publish personally or switch into a group identity while preserving author and contributor credits.
{metadata.group ?
Group publish :
Personal publish }
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"
/>
{metadata.group && (
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"
/>
The primary author is shown as the lead creator for this group-published artwork.
Contributors
Optional
{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 (
{user.avatar_url ?
:
}
{user.name || user.username}
@{user.username}
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(' ')}
>
{active ? '✓' : ''}
{active ? 'Added' : 'Add credit'}
{active ? (
Credit role
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"
/>
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'}
) : null}
)
})}
)}
)}
{/* Title, tags, description, rights */}
)
}