import React, { useEffect, useMemo, useState } from 'react'
import { Head, usePage } from '@inertiajs/react'
import CollectionVisibilityBadge from '../../components/profile/collections/CollectionVisibilityBadge'
function getCsrfToken() {
if (typeof document === 'undefined') return ''
return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || ''
}
function slugify(value) {
return String(value ?? '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '')
.slice(0, 140)
}
function isoToLocalInput(value) {
if (!value) return ''
const date = new Date(value)
if (Number.isNaN(date.getTime())) return ''
const local = new Date(date.getTime() - (date.getTimezoneOffset() * 60000))
return local.toISOString().slice(0, 16)
}
function localInputToIso(value) {
if (!value) return null
const date = new Date(value)
if (Number.isNaN(date.getTime())) return null
return date.toISOString()
}
function formatDateTimeLabel(value) {
if (!value) return null
const date = new Date(value)
if (Number.isNaN(date.getTime())) return null
return date.toLocaleString()
}
function formatStateLabel(value) {
if (!value) return 'Unknown'
return String(value)
.replaceAll('_', ' ')
.replaceAll('-', ' ')
.replace(/\b\w/g, (match) => match.toUpperCase())
}
function healthFlagMeta(flag) {
const tone = {
success: 'border-emerald-300/20 bg-emerald-400/10 text-emerald-100',
warning: 'border-amber-300/20 bg-amber-400/10 text-amber-100',
danger: 'border-rose-300/20 bg-rose-400/10 text-rose-100',
neutral: 'border-white/10 bg-white/[0.04] text-slate-200',
}
const registry = {
healthy: {
label: 'Healthy',
description: 'No active health blockers are currently recorded.',
tone: tone.success,
},
needs_metadata: {
label: 'Metadata is thin',
description: 'Tighten the title, summary, cover, or tagging so discovery surfaces have stronger context.',
tone: tone.warning,
},
stale: {
label: 'Stale collection',
description: 'This collection looks quiet. Refresh the lineup or update the presentation before promoting it again.',
tone: tone.warning,
},
low_content: {
label: 'Low content',
description: 'Add more artworks so the collection can support richer layouts and recommendations.',
tone: tone.warning,
},
broken_items: {
label: 'Broken items detected',
description: 'Some attached items are no longer safely displayable. Review attachments before featuring this set.',
tone: tone.danger,
},
weak_cover: {
label: 'Weak cover',
description: 'Choose a stronger cover artwork so the collection reads clearly on cards and hero rails.',
tone: tone.warning,
},
low_engagement: {
label: 'Low engagement',
description: 'This collection is live but underperforming. Consider adjusting ordering, title, or campaign context.',
tone: tone.warning,
},
attribution_incomplete: {
label: 'Attribution incomplete',
description: 'Cross-links or creator attribution need a pass before this collection is pushed harder.',
tone: tone.warning,
},
needs_review: {
label: 'Needs review',
description: 'Workflow or moderation state is still blocking this collection from safer public programming.',
tone: tone.danger,
},
duplicate_risk: {
label: 'Duplicate risk',
description: 'A similar collection may already exist. Use the merge review tools before spreading traffic across duplicates.',
tone: tone.warning,
},
merge_candidate: {
label: 'Merge candidate',
description: 'This collection already looks like a merge candidate. Confirm the canonical target in the review section below.',
tone: tone.warning,
},
}
return registry[flag] || {
label: formatStateLabel(flag),
description: 'Review this collection state before pushing it to wider surfaces.',
tone: tone.neutral,
}
}
function buildInviteExpiryOptions(defaultDays) {
const sanitizedDefault = Math.max(1, Number.parseInt(defaultDays, 10) || 7)
const values = [sanitizedDefault, 1, 3, 7, 14, 30]
return Array.from(new Set(values)).sort((left, right) => left - right)
}
function firstEntitySelection(options) {
const firstType = Object.keys(options || {})[0] || 'creator'
return {
type: firstType,
id: options?.[firstType]?.[0]?.id || '',
}
}
async function requestJson(url, { method = 'GET', body } = {}) {
const response = await fetch(url, {
method,
credentials: 'same-origin',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
'X-CSRF-TOKEN': getCsrfToken(),
'X-Requested-With': 'XMLHttpRequest',
},
body: body ? JSON.stringify(body) : undefined,
})
const payload = await response.json().catch(() => ({}))
if (!response.ok) {
const message = payload?.message || 'Request failed.'
const error = new Error(message)
error.payload = payload
throw error
}
return payload
}
function defaultRuleValue(field) {
if (field === 'created_at') {
return { from: '', to: '' }
}
if (field === 'is_featured' || field === 'is_mature') {
return true
}
return ''
}
function operatorOptionsForField(field) {
if (field === 'created_at') {
return [{ value: 'between', label: 'Between' }]
}
if (field === 'is_featured' || field === 'is_mature') {
return [{ value: 'equals', label: 'Is' }]
}
return [
{ value: 'contains', label: 'Contains' },
{ value: 'equals', label: 'Equals' },
]
}
function createRule(field = 'tags') {
return {
field,
operator: operatorOptionsForField(field)[0]?.value || 'contains',
value: defaultRuleValue(field),
}
}
function normalizeRule(rule, fallbackField = 'tags') {
const field = rule?.field || fallbackField
const operators = operatorOptionsForField(field)
const operator = operators.some((item) => item.value === rule?.operator)
? rule.operator
: operators[0]?.value || 'contains'
if (field === 'created_at') {
return {
field,
operator,
value: {
from: rule?.value?.from || '',
to: rule?.value?.to || '',
},
}
}
if (field === 'is_featured' || field === 'is_mature') {
return {
field,
operator,
value: Boolean(rule?.value),
}
}
return {
field,
operator,
value: typeof rule?.value === 'string' ? rule.value : '',
}
}
function normalizeSmartRules(rawRules, mode = 'manual') {
if (rawRules && Array.isArray(rawRules.rules)) {
return {
match: rawRules.match === 'any' ? 'any' : 'all',
sort: rawRules.sort || 'newest',
rules: rawRules.rules.map((rule) => normalizeRule(rule)).filter(Boolean),
}
}
if (mode === 'smart') {
return {
match: 'all',
sort: 'newest',
rules: [createRule()],
}
}
return {
match: 'all',
sort: 'newest',
rules: [],
}
}
function normalizeLayoutModules(rawModules) {
if (!Array.isArray(rawModules)) return []
return rawModules.map((module) => ({
key: module?.key || '',
label: module?.label || module?.key || 'Module',
description: module?.description || '',
slot: module?.slot || 'main',
slots: Array.isArray(module?.slots) && module.slots.length ? module.slots : ['main'],
enabled: module?.enabled !== false,
locked: Boolean(module?.locked),
})).filter((module) => module.key)
}
function humanizeField(field, smartRuleOptions) {
const label = smartRuleOptions?.fields?.find((item) => item.value === field)?.label
return label || field
}
function getFieldOptions(field, smartRuleOptions) {
if (field === 'tags') return smartRuleOptions?.tag_options || []
if (field === 'category') return smartRuleOptions?.category_options || []
if (field === 'subcategory') return smartRuleOptions?.subcategory_options || []
if (field === 'medium') return smartRuleOptions?.medium_options || []
if (field === 'style') return smartRuleOptions?.style_options || []
if (field === 'color') return smartRuleOptions?.color_options || []
return []
}
function buildRuleSummary(rule, smartRuleOptions) {
if (!rule) return ''
if (rule.field === 'created_at') {
const from = rule.value?.from || 'any date'
const to = rule.value?.to || 'today'
return `Created between ${from} and ${to}`
}
if (rule.field === 'is_featured') {
return rule.value ? 'Featured artworks only' : 'Artworks not marked featured'
}
if (rule.field === 'is_mature') {
return rule.value ? 'Mature artworks only' : 'Artworks not marked mature'
}
const label = humanizeField(rule.field, smartRuleOptions)
const value = String(rule.value || '').trim() || 'Any value'
return `${label} ${rule.operator} ${value}`
}
function Field({ label, children, help }) {
return (
{label}
{children}
{help ? {help}
: null}
)
}
function StatCard({ icon, label, value, tone = 'default' }) {
const toneClass = tone === 'accent'
? 'border-sky-300/20 bg-sky-400/10 text-sky-100'
: 'border-white/10 bg-white/[0.04] text-white'
return (
)
}
function ModeButton({ active, title, description, icon, onClick }) {
return (
)
}
function SmartPreviewArtwork({ artwork }) {
return (
{artwork.title}
{[artwork.content_type, artwork.category].filter(Boolean).join(' • ') || 'Artwork'}
)
}
function ArtworkPickerCard({ artwork, checked, onToggle, actionLabel = 'Select' }) {
return (
onToggle(artwork.id)}
className={`group w-full overflow-hidden rounded-[24px] border text-left transition ${checked ? 'border-sky-300/40 bg-sky-400/10' : 'border-white/10 bg-white/[0.04] hover:border-white/18 hover:bg-white/[0.06]'}`}
>
{artwork.title}
{[artwork.content_type, artwork.category].filter(Boolean).join(' • ') || 'Artwork'}
{checked ? 'Added' : actionLabel}
)
}
function AttachedArtworkCard({ artwork, index, total, onMoveUp, onMoveDown, onRemove }) {
return (
{artwork.title}
Position {index + 1}
Up
Down
Remove
)
}
function MemberCard({ member, onRoleChange, onRemove, onAccept, onDecline, onTransfer }) {
const expiryLabel = formatDateTimeLabel(member?.expires_at)
return (
{member?.user?.name || member?.user?.username}
{member?.role} • {member?.status}
{member?.status === 'pending' && expiryLabel ?
Invite expires {expiryLabel}
: null}
{member?.is_expired ?
Invite expired
: null}
{member?.can_revoke ? (
onRoleChange?.(member, event.target.value)}
className="rounded-xl border border-white/10 bg-[#0d1726] px-3 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-white"
>
Editor
Contributor
Viewer
) : null}
{member?.can_accept ? onAccept?.(member)} className="rounded-xl border border-emerald-400/20 bg-emerald-400/10 px-3 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-emerald-100">Accept : null}
{member?.can_decline ? onDecline?.(member)} className="rounded-xl border border-white/12 bg-white/[0.05] px-3 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-white">Decline : null}
{member?.can_transfer ? onTransfer?.(member)} className="rounded-xl border border-amber-400/20 bg-amber-400/10 px-3 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-amber-100">Make owner : null}
{member?.can_revoke ? onRemove?.(member)} className="rounded-xl border border-rose-400/20 bg-rose-400/10 px-3 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-rose-100">Remove : null}
)
}
function SubmissionReviewCard({ submission, onApprove, onReject, onWithdraw }) {
return (
{submission?.artwork?.thumb ?
: null}
{submission?.artwork?.title || 'Submission'}
{submission?.status} • @{submission?.user?.username}
{submission?.message ?
{submission.message}
: null}
{submission?.can_review ? onApprove?.(submission)} className="rounded-xl border border-emerald-400/20 bg-emerald-400/10 px-3 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-emerald-100">Approve : null}
{submission?.can_review ? onReject?.(submission)} className="rounded-xl border border-rose-400/20 bg-rose-400/10 px-3 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-rose-100">Reject : null}
{submission?.can_withdraw ? onWithdraw?.(submission)} className="rounded-xl border border-white/12 bg-white/[0.05] px-3 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-white">Withdraw : null}
)
}
function AiSuggestionCard({ title, body, actionLabel, onAction, muted = false, children }) {
return (
{title}
{body}
{children}
{actionLabel && onAction ? (
{actionLabel}
) : null}
)
}
function LayoutModuleCard({ module, index, total, onToggle, onSlotChange, onMoveUp, onMoveDown }) {
return (
onSlotChange(module.key, event.target.value)}
className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white outline-none transition focus:border-sky-300/35"
>
{module.slots.map((slot) => (
{slot === 'full' ? 'Full width' : slot === 'main' ? 'Main column' : 'Sidebar'}
))}
Up
Down
)
}
function StudioTabButton({ active, label, icon, onClick }) {
return (
{label}
)
}
function SmartRuleRow({
rule,
index,
smartRuleOptions,
onFieldChange,
onOperatorChange,
onValueChange,
onRemove,
}) {
const fieldOptions = smartRuleOptions?.fields || []
const operatorOptions = operatorOptionsForField(rule.field)
const valueOptions = getFieldOptions(rule.field, smartRuleOptions)
return (
{buildRuleSummary(rule, smartRuleOptions)}
)
}
export default function CollectionManage() {
const { props } = usePage()
const {
mode,
collection,
layoutModules: initialLayoutModules,
attachedArtworks,
availableArtworks,
owner,
endpoints,
members: initialMembers,
submissions: initialSubmissions,
comments: initialComments,
duplicateCandidates: initialDuplicateCandidates,
canonicalTarget: initialCanonicalTarget,
linkedCollections: initialLinkedCollections,
linkedCollectionOptions: initialLinkedCollectionOptions,
entityLinks: initialEntityLinks,
entityLinkOptions: initialEntityLinkOptions,
smartPreview: initialSmartPreview,
smartRuleOptions,
initialMode,
featuredLimit,
viewer,
inviteExpiryDays,
} = props
const resolvedInitialMode = collection?.mode || initialMode || 'manual'
const [collectionState, setCollectionState] = useState(collection)
const [form, setForm] = useState({
title: collection?.title || '',
slug: collection?.slug || '',
subtitle: collection?.subtitle || '',
summary: collection?.summary || '',
description: collection?.description || '',
lifecycle_state: collection?.lifecycle_state || 'draft',
type: collection?.type || 'personal',
collaboration_mode: collection?.collaboration_mode || 'closed',
allow_submissions: Boolean(collection?.allow_submissions),
allow_comments: collection?.allow_comments !== false,
allow_saves: collection?.allow_saves !== false,
event_key: collection?.event_key || '',
event_label: collection?.event_label || '',
season_key: collection?.season_key || '',
banner_text: collection?.banner_text || '',
badge_label: collection?.badge_label || '',
spotlight_style: collection?.spotlight_style || 'default',
analytics_enabled: collection?.analytics_enabled !== false,
presentation_style: collection?.presentation_style || 'standard',
emphasis_mode: collection?.emphasis_mode || 'balanced',
theme_token: collection?.theme_token || 'default',
series_key: collection?.series_key || '',
series_title: collection?.series_title || '',
series_description: collection?.series_description || '',
series_order: collection?.series_order || '',
campaign_key: collection?.campaign_key || '',
campaign_label: collection?.campaign_label || '',
commercial_eligibility: Boolean(collection?.commercial_eligibility),
promotion_tier: collection?.promotion_tier || '',
sponsorship_label: collection?.sponsorship_label || '',
partner_label: collection?.partner_label || '',
monetization_ready_status: collection?.monetization_ready_status || '',
brand_safe_status: collection?.brand_safe_status || '',
editorial_notes: collection?.editorial_notes || '',
staff_commercial_notes: collection?.staff_commercial_notes || '',
published_at: isoToLocalInput(collection?.published_at),
unpublished_at: isoToLocalInput(collection?.unpublished_at),
archived_at: isoToLocalInput(collection?.archived_at),
expired_at: isoToLocalInput(collection?.expired_at),
editorial_owner_mode: collection?.owner?.mode || 'creator',
editorial_owner_username: collection?.owner?.username || '',
editorial_owner_label: collection?.type === 'editorial' && collection?.owner?.is_system ? (collection?.owner?.name || '') : '',
visibility: collection?.visibility || 'public',
mode: resolvedInitialMode,
sort_mode: collection?.sort_mode || (resolvedInitialMode === 'smart' ? 'newest' : 'manual'),
cover_artwork_id: collection?.cover_artwork_id || '',
})
const [slugTouched, setSlugTouched] = useState(Boolean(collection?.slug))
const [smartRules, setSmartRules] = useState(normalizeSmartRules(collection?.smart_rules_json, resolvedInitialMode))
const [smartPreview, setSmartPreview] = useState(initialSmartPreview || null)
const [layoutModules, setLayoutModules] = useState(normalizeLayoutModules(collection?.layout_modules || initialLayoutModules || []))
const [attached, setAttached] = useState(attachedArtworks || [])
const [available, setAvailable] = useState(availableArtworks || [])
const [members, setMembers] = useState(initialMembers || [])
const [submissions, setSubmissions] = useState(initialSubmissions || [])
const [comments] = useState(initialComments || [])
const [duplicateCandidates, setDuplicateCandidates] = useState(initialDuplicateCandidates || [])
const [canonicalTarget, setCanonicalTarget] = useState(initialCanonicalTarget || null)
const [linkedCollections, setLinkedCollections] = useState(initialLinkedCollections || [])
const [linkedCollectionOptions, setLinkedCollectionOptions] = useState(initialLinkedCollectionOptions || [])
const [selectedLinkedCollectionId, setSelectedLinkedCollectionId] = useState(initialLinkedCollectionOptions?.[0]?.id || '')
const [entityLinks, setEntityLinks] = useState(initialEntityLinks || [])
const [entityLinkOptions, setEntityLinkOptions] = useState(initialEntityLinkOptions || {})
const initialEntitySelection = useMemo(() => firstEntitySelection(initialEntityLinkOptions || {}), [initialEntityLinkOptions])
const [selectedEntityType, setSelectedEntityType] = useState(initialEntitySelection.type)
const [selectedEntityId, setSelectedEntityId] = useState(initialEntitySelection.id)
const [entityRelationship, setEntityRelationship] = useState('')
const [activeTab, setActiveTab] = useState(mode === 'edit' ? 'details' : 'details')
const [selectedIds, setSelectedIds] = useState([])
const [search, setSearch] = useState('')
const [inviteUsername, setInviteUsername] = useState('')
const [inviteRole, setInviteRole] = useState('contributor')
const [inviteExpiryMode, setInviteExpiryMode] = useState('default')
const [inviteCustomExpiry, setInviteCustomExpiry] = useState('')
const [saving, setSaving] = useState(false)
const [searching, setSearching] = useState(false)
const [previewing, setPreviewing] = useState(false)
const [featureBusy, setFeatureBusy] = useState(false)
const [aiState, setAiState] = useState({ busy: '', title: null, summary: null, cover: null, grouping: null, relatedArtworks: null, tags: null, seoDescription: null, smartRulesExplanation: null, splitThemes: null, mergeIdea: null, qualityReview: null })
const [orderDirty, setOrderDirty] = useState(false)
const [errors, setErrors] = useState({})
const [notice, setNotice] = useState('')
useEffect(() => {
const nextMode = collection?.mode || initialMode || 'manual'
setCollectionState(collection)
setForm({
title: collection?.title || '',
slug: collection?.slug || '',
subtitle: collection?.subtitle || '',
summary: collection?.summary || '',
description: collection?.description || '',
lifecycle_state: collection?.lifecycle_state || 'draft',
type: collection?.type || 'personal',
collaboration_mode: collection?.collaboration_mode || 'closed',
allow_submissions: Boolean(collection?.allow_submissions),
allow_comments: collection?.allow_comments !== false,
allow_saves: collection?.allow_saves !== false,
event_key: collection?.event_key || '',
event_label: collection?.event_label || '',
season_key: collection?.season_key || '',
banner_text: collection?.banner_text || '',
badge_label: collection?.badge_label || '',
spotlight_style: collection?.spotlight_style || 'default',
analytics_enabled: collection?.analytics_enabled !== false,
presentation_style: collection?.presentation_style || 'standard',
emphasis_mode: collection?.emphasis_mode || 'balanced',
theme_token: collection?.theme_token || 'default',
series_key: collection?.series_key || '',
series_title: collection?.series_title || '',
series_description: collection?.series_description || '',
series_order: collection?.series_order || '',
campaign_key: collection?.campaign_key || '',
campaign_label: collection?.campaign_label || '',
commercial_eligibility: Boolean(collection?.commercial_eligibility),
promotion_tier: collection?.promotion_tier || '',
sponsorship_label: collection?.sponsorship_label || '',
partner_label: collection?.partner_label || '',
monetization_ready_status: collection?.monetization_ready_status || '',
brand_safe_status: collection?.brand_safe_status || '',
editorial_notes: collection?.editorial_notes || '',
staff_commercial_notes: collection?.staff_commercial_notes || '',
published_at: isoToLocalInput(collection?.published_at),
unpublished_at: isoToLocalInput(collection?.unpublished_at),
archived_at: isoToLocalInput(collection?.archived_at),
expired_at: isoToLocalInput(collection?.expired_at),
editorial_owner_mode: collection?.owner?.mode || 'creator',
editorial_owner_username: collection?.owner?.username || '',
editorial_owner_label: collection?.type === 'editorial' && collection?.owner?.is_system ? (collection?.owner?.name || '') : '',
visibility: collection?.visibility || 'public',
mode: nextMode,
sort_mode: collection?.sort_mode || (nextMode === 'smart' ? 'newest' : 'manual'),
cover_artwork_id: collection?.cover_artwork_id || '',
})
setSmartRules(normalizeSmartRules(collection?.smart_rules_json, nextMode))
setLayoutModules(normalizeLayoutModules(collection?.layout_modules || initialLayoutModules || []))
setSmartPreview(initialSmartPreview || null)
setAttached(attachedArtworks || [])
setAvailable(availableArtworks || [])
setMembers(initialMembers || [])
setSubmissions(initialSubmissions || [])
setDuplicateCandidates(initialDuplicateCandidates || [])
setCanonicalTarget(initialCanonicalTarget || null)
setLinkedCollections(initialLinkedCollections || [])
setLinkedCollectionOptions(initialLinkedCollectionOptions || [])
setSelectedLinkedCollectionId(initialLinkedCollectionOptions?.[0]?.id || '')
setEntityLinks(initialEntityLinks || [])
setEntityLinkOptions(initialEntityLinkOptions || {})
const nextEntitySelection = firstEntitySelection(initialEntityLinkOptions || {})
setSelectedEntityType(nextEntitySelection.type)
setSelectedEntityId(nextEntitySelection.id)
setEntityRelationship('')
setActiveTab('details')
setSelectedIds([])
setAiState({ busy: '', title: null, summary: null, cover: null, grouping: null, relatedArtworks: null, tags: null, seoDescription: null, smartRulesExplanation: null, splitThemes: null, mergeIdea: null, qualityReview: null })
setOrderDirty(false)
setErrors({})
setNotice('')
}, [collection?.id, attachedArtworks, availableArtworks, initialCanonicalTarget, initialDuplicateCandidates, initialEntityLinkOptions, initialEntityLinks, initialLayoutModules, initialLinkedCollectionOptions, initialLinkedCollections, initialMembers, initialSubmissions, initialMode, initialSmartPreview])
const attachedCoverOptions = useMemo(
() => attached.map((artwork) => ({ id: artwork.id, title: artwork.title })),
[attached]
)
const inviteExpiryOptions = useMemo(() => buildInviteExpiryOptions(inviteExpiryDays), [inviteExpiryDays])
const smartRuleCount = smartRules?.rules?.length || 0
const isSmartMode = form.mode === 'smart'
const canFeature = mode === 'edit' && form.visibility === 'public' && (collectionState?.feature_url || collectionState?.unfeature_url || endpoints?.feature)
const featuredCountLabel = collectionState?.is_featured ? 'Featured' : `Up to ${featuredLimit} featured collections`
const canModerate = mode === 'edit' && Boolean(viewer?.is_admin)
const tabs = [
{ id: 'details', label: 'Details', icon: 'fa-pen-ruler' },
{ id: 'artworks', label: 'Artworks', icon: 'fa-images' },
{ id: 'members', label: 'Members', icon: 'fa-user-group' },
{ id: 'submissions', label: 'Submissions', icon: 'fa-inbox' },
{ id: 'settings', label: 'Settings', icon: 'fa-sliders' },
{ id: 'discussion', label: 'Discussion', icon: 'fa-comments' },
{ id: 'ai', label: 'AI Suggestions', icon: 'fa-wand-magic-sparkles' },
].concat(canModerate ? [{ id: 'moderation', label: 'Moderation', icon: 'fa-shield-halved' }] : [])
function applyCollectionPayload(nextCollection) {
if (!nextCollection) return
setCollectionState(nextCollection)
setForm((current) => ({
...current,
title: nextCollection.title ?? current.title,
slug: nextCollection.slug ?? current.slug,
subtitle: nextCollection.subtitle || '',
summary: nextCollection.summary || '',
description: nextCollection.description || '',
lifecycle_state: nextCollection.lifecycle_state || current.lifecycle_state,
type: nextCollection.type || current.type,
collaboration_mode: nextCollection.collaboration_mode || current.collaboration_mode,
allow_submissions: Boolean(nextCollection.allow_submissions),
allow_comments: nextCollection.allow_comments !== false,
allow_saves: nextCollection.allow_saves !== false,
event_key: nextCollection.event_key ?? '',
event_label: nextCollection.event_label ?? '',
season_key: nextCollection.season_key ?? '',
banner_text: nextCollection.banner_text ?? '',
badge_label: nextCollection.badge_label ?? '',
spotlight_style: nextCollection.spotlight_style || 'default',
analytics_enabled: nextCollection.analytics_enabled !== false,
presentation_style: nextCollection.presentation_style || current.presentation_style,
emphasis_mode: nextCollection.emphasis_mode || current.emphasis_mode,
theme_token: nextCollection.theme_token || current.theme_token,
series_key: nextCollection.series_key ?? '',
series_title: nextCollection.series_title ?? '',
series_description: nextCollection.series_description ?? '',
series_order: nextCollection.series_order ?? '',
campaign_key: nextCollection.campaign_key ?? '',
campaign_label: nextCollection.campaign_label ?? '',
commercial_eligibility: Boolean(nextCollection.commercial_eligibility),
promotion_tier: nextCollection.promotion_tier ?? '',
sponsorship_label: nextCollection.sponsorship_label ?? '',
partner_label: nextCollection.partner_label ?? '',
monetization_ready_status: nextCollection.monetization_ready_status ?? '',
brand_safe_status: nextCollection.brand_safe_status ?? '',
editorial_notes: nextCollection.editorial_notes ?? '',
staff_commercial_notes: nextCollection.staff_commercial_notes ?? '',
published_at: isoToLocalInput(nextCollection.published_at) || '',
unpublished_at: isoToLocalInput(nextCollection.unpublished_at) || '',
archived_at: isoToLocalInput(nextCollection.archived_at) || '',
expired_at: isoToLocalInput(nextCollection.expired_at) || '',
editorial_owner_mode: nextCollection?.owner?.mode || current.editorial_owner_mode,
editorial_owner_username: nextCollection?.owner?.username || current.editorial_owner_username,
editorial_owner_label: nextCollection?.type === 'editorial' && nextCollection?.owner?.is_system ? (nextCollection?.owner?.name || current.editorial_owner_label) : current.editorial_owner_label,
visibility: nextCollection.visibility || current.visibility,
mode: nextCollection.mode || current.mode,
sort_mode: nextCollection.sort_mode || current.sort_mode,
cover_artwork_id: nextCollection.cover_artwork_id || '',
}))
if (Array.isArray(nextCollection.layout_modules)) {
setLayoutModules(normalizeLayoutModules(nextCollection.layout_modules))
}
}
function updateForm(name, value) {
setForm((current) => {
const next = { ...current, [name]: value }
if (name === 'title' && !slugTouched) {
next.slug = slugify(value)
}
if (name === 'mode') {
next.sort_mode = value === 'smart'
? (smartRules?.sort || 'newest')
: (current.sort_mode === 'newest' || current.sort_mode === 'oldest' || current.sort_mode === 'popular' ? 'manual' : current.sort_mode || 'manual')
if (value === 'smart') {
next.cover_artwork_id = ''
}
}
return next
})
if (name === 'mode') {
setSmartRules((current) => {
if (value !== 'smart') {
return current
}
const normalized = normalizeSmartRules(current, 'smart')
if (normalized.rules.length > 0) {
return normalized
}
return {
...normalized,
rules: [createRule()],
}
})
}
}
function updateSmartRule(index, updater) {
setSmartRules((current) => ({
...current,
rules: current.rules.map((rule, ruleIndex) => (
ruleIndex === index ? updater(rule) : rule
)),
}))
}
function addRule() {
const defaultField = smartRuleOptions?.fields?.[0]?.value || 'tags'
setSmartRules((current) => ({
...current,
rules: [...current.rules, createRule(defaultField)],
}))
}
function removeRule(index) {
setSmartRules((current) => ({
...current,
rules: current.rules.filter((_, ruleIndex) => ruleIndex !== index),
}))
}
function buildPayload() {
return {
...form,
sort_mode: isSmartMode ? (smartRules.sort || form.sort_mode || 'newest') : form.sort_mode,
cover_artwork_id: isSmartMode ? null : (form.cover_artwork_id || null),
published_at: localInputToIso(form.published_at),
unpublished_at: localInputToIso(form.unpublished_at),
archived_at: localInputToIso(form.archived_at),
expired_at: localInputToIso(form.expired_at),
smart_rules_json: isSmartMode
? {
match: smartRules.match,
sort: smartRules.sort,
rules: smartRules.rules,
}
: null,
layout_modules_json: layoutModules.map((module) => ({
key: module.key,
enabled: module.enabled,
slot: module.slot,
})),
}
}
function updateLayoutModule(key, updates) {
setLayoutModules((current) => current.map((module) => (
module.key === key ? { ...module, ...updates } : module
)))
}
function moveLayoutModule(index, direction) {
const nextIndex = index + direction
if (nextIndex < 0 || nextIndex >= layoutModules.length) return
setLayoutModules((current) => {
const next = [...current]
const swap = next[index]
next[index] = next[nextIndex]
next[nextIndex] = swap
return next
})
}
async function handleSubmit(event) {
event.preventDefault()
setSaving(true)
setErrors({})
setNotice('')
try {
const payload = await requestJson(mode === 'create' ? endpoints.store : endpoints.update, {
method: mode === 'create' ? 'POST' : 'PATCH',
body: buildPayload(),
})
if (payload.redirect && mode === 'create') {
window.location.assign(payload.redirect)
return
}
if (payload.collection) {
applyCollectionPayload(payload.collection)
if (payload.collection.smart_rules_json) {
setSmartRules(normalizeSmartRules(payload.collection.smart_rules_json, payload.collection.mode))
}
}
if (payload.attachedArtworks) {
setAttached(payload.attachedArtworks)
}
if (payload.members) {
setMembers(payload.members)
}
if (payload.submissions) {
setSubmissions(payload.submissions)
}
setNotice(isSmartMode ? 'Collection saved and smart rules updated.' : 'Collection updated.')
} catch (error) {
setErrors(error?.payload?.errors || { form: [error.message] })
} finally {
setSaving(false)
}
}
async function handleSmartPreview() {
if (!endpoints?.smartPreview) return
setPreviewing(true)
setErrors({})
try {
const payload = await requestJson(endpoints.smartPreview, {
method: 'POST',
body: {
smart_rules_json: {
match: smartRules.match,
sort: smartRules.sort,
rules: smartRules.rules,
},
},
})
setSmartPreview(payload.preview || null)
} catch (error) {
setErrors(error?.payload?.errors || { form: [error.message] })
} finally {
setPreviewing(false)
}
}
async function handleSearch(event) {
event.preventDefault()
if (!endpoints?.available) return
setSearching(true)
try {
const url = `${endpoints.available}?search=${encodeURIComponent(search)}`
const payload = await requestJson(url)
setAvailable(payload?.data || [])
} catch (error) {
setErrors(error?.payload?.errors || { form: [error.message] })
} finally {
setSearching(false)
}
}
async function handleAiSuggestion(kind) {
const endpointMap = {
title: endpoints?.aiSuggestTitle,
summary: endpoints?.aiSuggestSummary,
cover: endpoints?.aiSuggestCover,
grouping: endpoints?.aiSuggestGrouping,
relatedArtworks: endpoints?.aiSuggestRelatedArtworks,
tags: endpoints?.aiSuggestTags,
seoDescription: endpoints?.aiSuggestSeoDescription,
smartRulesExplanation: endpoints?.aiExplainSmartRules,
splitThemes: endpoints?.aiSuggestSplitThemes,
mergeIdea: endpoints?.aiSuggestMergeIdea,
}
const url = endpointMap[kind]
if (!url) return
setAiState((current) => ({ ...current, busy: kind }))
try {
const payload = await requestJson(url, {
method: 'POST',
body: { draft: buildPayload() },
})
setAiState((current) => ({
...current,
busy: '',
[kind]: payload?.suggestion || null,
}))
} catch (error) {
setAiState((current) => ({ ...current, busy: '' }))
setErrors(error?.payload?.errors || { form: [error.message] })
}
}
async function handleAiQualityReview() {
if (!endpoints?.aiQualityReview) return
setAiState((current) => ({ ...current, busy: 'qualityReview' }))
try {
const payload = await requestJson(endpoints.aiQualityReview)
setAiState((current) => ({
...current,
busy: '',
qualityReview: payload?.review || null,
}))
} catch (error) {
setAiState((current) => ({ ...current, busy: '' }))
setErrors(error?.payload?.errors || { form: [error.message] })
}
}
function applyAiTitle() {
if (!aiState?.title?.title) return
updateForm('title', aiState.title.title)
}
function applyAiSummary() {
if (!aiState?.summary?.summary) return
updateForm('summary', aiState.summary.summary)
}
function applyAiCover() {
const artworkId = aiState?.cover?.artwork?.id
if (!artworkId || isSmartMode) return
updateForm('cover_artwork_id', artworkId)
}
function applyAiRelatedArtworks() {
const artworkIds = Array.isArray(aiState?.relatedArtworks?.artworks)
? aiState.relatedArtworks.artworks.map((artwork) => artwork.id)
: []
if (!artworkIds.length) return
setSelectedIds((current) => Array.from(new Set([...current, ...artworkIds])))
}
function toggleSelected(artworkId) {
setSelectedIds((current) => (
current.includes(artworkId)
? current.filter((id) => id !== artworkId)
: [...current, artworkId]
))
}
async function handleAttachSelected() {
if (!selectedIds.length || !endpoints?.attach) return
setSaving(true)
setErrors({})
try {
const payload = await requestJson(endpoints.attach, {
method: 'POST',
body: { artwork_ids: selectedIds },
})
setCollectionState(payload.collection)
setAttached(payload.attachedArtworks || [])
setAvailable(payload.availableArtworks || [])
setSelectedIds([])
setNotice('Artworks added to collection.')
} catch (error) {
setErrors(error?.payload?.errors || { form: [error.message] })
} finally {
setSaving(false)
}
}
async function handleRemoveArtwork(artwork) {
if (!artwork?.remove_url) return
if (!window.confirm(`Remove "${artwork.title}" from this collection?`)) return
setSaving(true)
setErrors({})
try {
const payload = await requestJson(artwork.remove_url, { method: 'DELETE' })
setCollectionState(payload.collection)
setAttached(payload.attachedArtworks || [])
setAvailable(payload.availableArtworks || [])
setForm((current) => ({
...current,
cover_artwork_id: payload.collection?.cover_artwork_id || '',
}))
setNotice('Artwork removed from collection.')
} catch (error) {
setErrors(error?.payload?.errors || { form: [error.message] })
} finally {
setSaving(false)
}
}
function moveArtwork(index, direction) {
const nextIndex = index + direction
if (nextIndex < 0 || nextIndex >= attached.length) return
setAttached((current) => {
const next = [...current]
const temp = next[index]
next[index] = next[nextIndex]
next[nextIndex] = temp
return next
})
setOrderDirty(true)
}
async function saveArtworkOrder() {
if (!orderDirty || !endpoints?.reorder) return
setSaving(true)
setErrors({})
try {
const payload = await requestJson(endpoints.reorder, {
method: 'POST',
body: { ordered_artwork_ids: attached.map((artwork) => artwork.id) },
})
setCollectionState(payload.collection)
setAttached(payload.attachedArtworks || [])
setOrderDirty(false)
setNotice('Artwork order saved.')
} catch (error) {
setErrors(error?.payload?.errors || { form: [error.message] })
} finally {
setSaving(false)
}
}
async function handleToggleFeature() {
const isFeatured = Boolean(collectionState?.is_featured)
const url = isFeatured ? endpoints?.unfeature : endpoints?.feature
if (!url) return
setFeatureBusy(true)
setErrors({})
try {
const payload = await requestJson(url, {
method: isFeatured ? 'DELETE' : 'POST',
})
if (payload.collection) {
applyCollectionPayload(payload.collection)
}
setNotice(isFeatured ? 'Collection removed from featured placement.' : 'Collection featured on your profile.')
} catch (error) {
setErrors(error?.payload?.errors || { form: [error.message] })
} finally {
setFeatureBusy(false)
}
}
async function syncLinkedCollections(nextIds, successMessage) {
if (!endpoints?.syncLinkedCollections) return
setSaving(true)
setErrors({})
try {
const payload = await requestJson(endpoints.syncLinkedCollections, {
method: 'POST',
body: {
related_collection_ids: nextIds,
},
})
if (payload?.collection) {
applyCollectionPayload(payload.collection)
}
const nextLinkedCollections = payload?.linkedCollections || []
const nextOptions = payload?.linkedCollectionOptions || []
setLinkedCollections(nextLinkedCollections)
setLinkedCollectionOptions(nextOptions)
setSelectedLinkedCollectionId((current) => {
if (current && nextOptions.some((option) => String(option.id) === String(current))) {
return current
}
return nextOptions[0]?.id || ''
})
setNotice(successMessage)
} catch (error) {
setErrors(error?.payload?.errors || { form: [error.message] })
} finally {
setSaving(false)
}
}
async function handleAddLinkedCollection() {
if (!selectedLinkedCollectionId) return
const nextIds = Array.from(new Set([
...linkedCollections.map((item) => item.id),
Number(selectedLinkedCollectionId),
]))
await syncLinkedCollections(nextIds, 'Linked collections updated.')
}
async function handleRemoveLinkedCollection(collectionId) {
const nextIds = linkedCollections
.map((item) => item.id)
.filter((id) => Number(id) !== Number(collectionId))
await syncLinkedCollections(nextIds, 'Linked collections updated.')
}
async function syncEntityLinks(nextLinks, successMessage) {
if (!endpoints?.syncEntityLinks) return
setSaving(true)
setErrors({})
try {
const payload = await requestJson(endpoints.syncEntityLinks, {
method: 'POST',
body: {
entity_links: nextLinks,
},
})
if (payload?.collection) {
applyCollectionPayload(payload.collection)
}
const nextEntityLinks = payload?.entityLinks || []
const nextOptions = payload?.entityLinkOptions || {}
setEntityLinks(nextEntityLinks)
setEntityLinkOptions(nextOptions)
setSelectedEntityId((current) => {
const optionsForType = Array.isArray(nextOptions[selectedEntityType]) ? nextOptions[selectedEntityType] : []
if (current && optionsForType.some((option) => String(option.id) === String(current))) {
return current
}
return optionsForType[0]?.id || ''
})
setEntityRelationship('')
setNotice(successMessage)
} catch (error) {
setErrors(error?.payload?.errors || { form: [error.message] })
} finally {
setSaving(false)
}
}
async function handleAddEntityLink() {
if (!selectedEntityType || !selectedEntityId) return
const nextLinks = [
...entityLinks.map((item) => ({
linked_type: item.linked_type,
linked_id: item.linked_id,
relationship_type: item.relationship_type || null,
})),
{
linked_type: selectedEntityType,
linked_id: Number(selectedEntityId),
relationship_type: entityRelationship.trim() || null,
},
]
await syncEntityLinks(nextLinks, 'Entity links updated.')
}
async function handleRemoveEntityLink(linkId) {
const nextLinks = entityLinks
.filter((item) => Number(item.id) !== Number(linkId))
.map((item) => ({
linked_type: item.linked_type,
linked_id: item.linked_id,
relationship_type: item.relationship_type || null,
}))
await syncEntityLinks(nextLinks, 'Entity links updated.')
}
function applyMergeReviewPayload(payload, successMessage) {
if (payload?.collection) {
applyCollectionPayload(payload.collection)
}
if (payload?.source) {
applyCollectionPayload(payload.source)
}
if (Object.prototype.hasOwnProperty.call(payload || {}, 'duplicate_candidates')) {
setDuplicateCandidates(Array.isArray(payload?.duplicate_candidates) ? payload.duplicate_candidates : [])
}
if (Object.prototype.hasOwnProperty.call(payload || {}, 'canonical_target')) {
setCanonicalTarget(payload?.canonical_target || null)
}
if (successMessage) {
setNotice(successMessage)
}
}
async function handleCanonicalizeCandidate(candidate) {
const targetId = candidate?.collection?.id
if (!targetId || !endpoints?.canonicalize) return
if (!window.confirm(`Designate "${candidate.collection.title}" as the canonical target for this collection?`)) return
setSaving(true)
setErrors({})
try {
const payload = await requestJson(endpoints.canonicalize, {
method: 'POST',
body: { target_collection_id: targetId },
})
applyMergeReviewPayload(payload, 'Canonical target updated.')
} catch (error) {
setErrors(error?.payload?.errors || { form: [error.message] })
} finally {
setSaving(false)
}
}
async function handleMergeCandidate(candidate) {
const targetId = candidate?.collection?.id
if (!targetId || !endpoints?.merge) return
if (!window.confirm(`Merge this collection into "${candidate.collection.title}"? This archives the current collection and moves artworks into the target.`)) return
setSaving(true)
setErrors({})
try {
const payload = await requestJson(endpoints.merge, {
method: 'POST',
body: { target_collection_id: targetId },
})
applyMergeReviewPayload(payload, 'Collection merged into canonical target.')
} catch (error) {
setErrors(error?.payload?.errors || { form: [error.message] })
} finally {
setSaving(false)
}
}
async function handleRejectDuplicateCandidate(candidate) {
const targetId = candidate?.collection?.id
if (!targetId || !endpoints?.rejectDuplicate) return
if (!window.confirm(`Mark "${candidate.collection.title}" as not a duplicate?`)) return
setSaving(true)
setErrors({})
try {
const payload = await requestJson(endpoints.rejectDuplicate, {
method: 'POST',
body: { target_collection_id: targetId },
})
applyMergeReviewPayload(payload, 'Duplicate candidate dismissed.')
} catch (error) {
setErrors(error?.payload?.errors || { form: [error.message] })
} finally {
setSaving(false)
}
}
async function handleDeleteCollection() {
if (!endpoints?.delete) return
if (!window.confirm('Delete this collection? Artworks will remain untouched.')) return
setSaving(true)
try {
const payload = await requestJson(endpoints.delete, { method: 'DELETE' })
window.location.assign(payload.redirect)
} catch (error) {
setErrors(error?.payload?.errors || { form: [error.message] })
setSaving(false)
}
}
async function handleInviteMember(event) {
event.preventDefault()
if (!inviteUsername || !endpoints?.inviteMember) return
const body = { username: inviteUsername, role: inviteRole }
if (inviteExpiryMode === 'custom') {
const customExpiryIso = localInputToIso(inviteCustomExpiry)
if (!customExpiryIso) {
setErrors({ expires_at: ['Choose a valid invite expiry date and time.'] })
return
}
body.expires_at = customExpiryIso
} else if (inviteExpiryMode !== 'default') {
body.expires_in_days = Number.parseInt(inviteExpiryMode, 10)
}
setSaving(true)
setErrors({})
try {
const payload = await requestJson(endpoints.inviteMember, {
method: 'POST',
body,
})
setMembers(payload?.members || [])
setInviteUsername('')
setInviteExpiryMode('default')
setInviteCustomExpiry('')
setNotice('Collaborator invited.')
} catch (error) {
setErrors(error?.payload?.errors || { form: [error.message] })
} finally {
setSaving(false)
}
}
async function handleMemberRoleChange(member, role) {
const url = endpoints?.memberUpdatePattern?.replace('__MEMBER__', member.id)
if (!url) return
const payload = await requestJson(url, {
method: 'PATCH',
body: { role },
})
setMembers(payload?.members || [])
}
async function handleRemoveMember(member) {
const url = endpoints?.memberDeletePattern?.replace('__MEMBER__', member.id)
if (!url) return
const payload = await requestJson(url, { method: 'DELETE' })
setMembers(payload?.members || [])
}
async function handleTransferMember(member) {
const url = endpoints?.memberTransferPattern?.replace('__MEMBER__', member.id)
if (!url) return
if (!window.confirm(`Transfer collection ownership to @${member?.user?.username}? You will keep editor access.`)) return
const payload = await requestJson(url, { method: 'POST' })
applyCollectionPayload(payload?.collection)
setMembers(payload?.members || [])
setNotice(`Ownership transferred to @${member?.user?.username}.`)
}
async function handleAcceptMember(member) {
const url = endpoints?.acceptMemberPattern?.replace('__MEMBER__', member.id)
if (!url) return
const payload = await requestJson(url, { method: 'POST' })
setMembers(payload?.members || [])
}
async function handleDeclineMember(member) {
const url = endpoints?.declineMemberPattern?.replace('__MEMBER__', member.id)
if (!url) return
const payload = await requestJson(url, { method: 'POST' })
setMembers(payload?.members || [])
}
async function handleSubmissionAction(submission, action) {
const url = action === 'approve'
? endpoints?.submissionApprovePattern?.replace('__SUBMISSION__', submission.id)
: action === 'reject'
? endpoints?.submissionRejectPattern?.replace('__SUBMISSION__', submission.id)
: endpoints?.submissionDeletePattern?.replace('__SUBMISSION__', submission.id)
if (!url) return
const payload = await requestJson(url, { method: action === 'withdraw' ? 'DELETE' : 'POST' })
setSubmissions(payload?.submissions || [])
}
async function handleModerationStatusChange(value) {
if (!endpoints?.adminModerationUpdate) return
const payload = await requestJson(endpoints.adminModerationUpdate, {
method: 'PATCH',
body: { moderation_status: value },
})
applyCollectionPayload(payload?.collection)
setNotice(`Moderation state updated to ${value.replace('_', ' ')}.`)
}
async function handleModerationToggle(key, value) {
if (!endpoints?.adminInteractionsUpdate) return
const payload = await requestJson(endpoints.adminInteractionsUpdate, {
method: 'PATCH',
body: { [key]: value },
})
applyCollectionPayload(payload?.collection)
setNotice('Collection interaction settings updated.')
}
async function handleAdminUnfeature() {
if (!endpoints?.adminUnfeature) return
const payload = await requestJson(endpoints.adminUnfeature, {
method: 'POST',
})
applyCollectionPayload(payload?.collection)
setNotice('Collection removed from featured placement by moderation action.')
}
async function handleAdminRemoveMember(member) {
const url = endpoints?.adminMemberRemovePattern?.replace('__MEMBER__', member.id)
if (!url) return
const payload = await requestJson(url, { method: 'DELETE' })
applyCollectionPayload(payload?.collection)
setMembers(payload?.members || [])
setNotice('Collaborator removed by moderation action.')
}
return (
<>
{mode === 'create' ? 'Create Collection — Skinbase Nova' : `${collectionState?.title || 'Collection'} — Manage Collection`}
{mode === 'edit' ? (
{tabs.map((tab) => (
setActiveTab(tab.id)}
/>
))}
) : null}
{mode !== 'edit' || activeTab === 'details' ? (
Collection Studio
{mode === 'create' ? 'Create a v4 collection' : collectionState?.title || 'Manage collection'}
Collections now carry lifecycle, presentation, campaign, and series metadata alongside the artwork curation itself. Use manual mode for exact storytelling or smart rules for creator-first automation.
{mode === 'edit' ? : null}
{collectionState?.is_featured ? (
Featured
) : null}
{isSmartMode ? (
Smart Collection
) : null}
{notice ? (
{notice}
) : null}
{Object.keys(errors).length ? (
{Object.entries(errors).map(([key, messages]) => (
{Array.isArray(messages) ? messages[0] : messages}
))}
) : null}
) : null}
{mode !== 'edit' || activeTab === 'artworks' ? (isSmartMode ? (
Smart Builder
Define collection rules
Rules only evaluate your own artworks. This keeps smart collections creator-first and avoids pulling content from other users.
Add Rule
{smartRuleCount ? smartRules.rules.map((rule, index) => (
updateSmartRule(index, () => normalizeRule({ field }))}
onOperatorChange={(operator) => updateSmartRule(index, (current) => ({ ...current, operator }))}
onValueChange={(value) => updateSmartRule(index, (current) => ({ ...current, value }))}
onRemove={() => removeRule(index)}
/>
)) : (
Add at least one rule to build a smart collection.
)}
Preview
Matching artworks
Refresh Preview
{smartPreview?.count ?? 0} matching artworks
{smartPreview?.summary || 'Preview the rules to see how this collection will fill automatically.'}
{smartPreview?.artworks?.data?.length ? (
{smartPreview.artworks.data.map((artwork) => (
))}
) : (
No matches yet
A smart collection can still be valid with zero results. Broaden the rules or publish more matching artworks.
)}
) : mode === 'edit' ? (
Attached Artworks
Arrange the showcase order
Save Order
{attached.length ? (
{attached.map((artwork, index) => (
moveArtwork(index, -1)}
onMoveDown={() => moveArtwork(index, 1)}
onRemove={() => handleRemoveArtwork(artwork)}
/>
))}
) : (
No artworks attached yet
Use the picker on the right to add artworks from your gallery. Manual ordering and cover selection become available as soon as the first piece is attached.
)}
Artwork Picker
Add artworks
setSearch(event.target.value)}
placeholder="Search your gallery"
className="min-w-0 flex-1 rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none transition focus:border-sky-300/35 focus:bg-white/[0.06]"
/>
Search
{selectedIds.length ? `${selectedIds.length} selected` : 'Select artworks to add'}
Add Selected
{available.length ? (
{available.map((artwork) => (
))}
) : (
No matching artworks available to add.
)}
) : (
{isSmartMode ? 'Preview it, then publish it' : 'Create it first, then curate it'}
{isSmartMode
? 'Smart collections can be previewed before creation so you can verify the rules and tune the result set.'
: 'Once the collection is created, this page will expand into the full curator workflow with artwork selection, manual ordering, and cover controls.'}
)) : null}
{mode === 'edit' && activeTab === 'ai' ? (
AI Assistant
Review-only suggestions
Use lightweight AI-style suggestions to refine naming, summaries, covers, and section groupings. Nothing is applied until you choose it, and nothing is saved until you submit the form.
handleAiSuggestion('title')} disabled={aiState.busy !== ''} className="rounded-2xl border border-white/12 bg-white/[0.05] px-4 py-3 text-xs font-semibold uppercase tracking-[0.14em] text-white transition hover:bg-white/[0.09] disabled:opacity-60">{aiState.busy === 'title' ? 'Thinking…' : 'Suggest title'}
handleAiSuggestion('summary')} disabled={aiState.busy !== ''} className="rounded-2xl border border-white/12 bg-white/[0.05] px-4 py-3 text-xs font-semibold uppercase tracking-[0.14em] text-white transition hover:bg-white/[0.09] disabled:opacity-60">{aiState.busy === 'summary' ? 'Thinking…' : 'Suggest summary'}
handleAiSuggestion('cover')} disabled={aiState.busy !== ''} className="rounded-2xl border border-white/12 bg-white/[0.05] px-4 py-3 text-xs font-semibold uppercase tracking-[0.14em] text-white transition hover:bg-white/[0.09] disabled:opacity-60">{aiState.busy === 'cover' ? 'Thinking…' : 'Suggest cover'}
handleAiSuggestion('grouping')} disabled={aiState.busy !== ''} className="rounded-2xl border border-white/12 bg-white/[0.05] px-4 py-3 text-xs font-semibold uppercase tracking-[0.14em] text-white transition hover:bg-white/[0.09] disabled:opacity-60">{aiState.busy === 'grouping' ? 'Thinking…' : 'Suggest grouping'}
handleAiSuggestion('relatedArtworks')} disabled={aiState.busy !== ''} className="rounded-2xl border border-white/12 bg-white/[0.05] px-4 py-3 text-xs font-semibold uppercase tracking-[0.14em] text-white transition hover:bg-white/[0.09] disabled:opacity-60">{aiState.busy === 'relatedArtworks' ? 'Thinking…' : 'Suggest related artworks'}
handleAiSuggestion('tags')} disabled={aiState.busy !== ''} className="rounded-2xl border border-white/12 bg-white/[0.05] px-4 py-3 text-xs font-semibold uppercase tracking-[0.14em] text-white transition hover:bg-white/[0.09] disabled:opacity-60">{aiState.busy === 'tags' ? 'Thinking…' : 'Suggest tags'}
handleAiSuggestion('seoDescription')} disabled={aiState.busy !== ''} className="rounded-2xl border border-white/12 bg-white/[0.05] px-4 py-3 text-xs font-semibold uppercase tracking-[0.14em] text-white transition hover:bg-white/[0.09] disabled:opacity-60">{aiState.busy === 'seoDescription' ? 'Thinking…' : 'Suggest SEO description'}
handleAiSuggestion('smartRulesExplanation')} disabled={aiState.busy !== ''} className="rounded-2xl border border-white/12 bg-white/[0.05] px-4 py-3 text-xs font-semibold uppercase tracking-[0.14em] text-white transition hover:bg-white/[0.09] disabled:opacity-60">{aiState.busy === 'smartRulesExplanation' ? 'Thinking…' : 'Explain smart rules'}
handleAiSuggestion('splitThemes')} disabled={aiState.busy !== ''} className="rounded-2xl border border-white/12 bg-white/[0.05] px-4 py-3 text-xs font-semibold uppercase tracking-[0.14em] text-white transition hover:bg-white/[0.09] disabled:opacity-60">{aiState.busy === 'splitThemes' ? 'Thinking…' : 'Suggest split'}
handleAiSuggestion('mergeIdea')} disabled={aiState.busy !== ''} className="rounded-2xl border border-white/12 bg-white/[0.05] px-4 py-3 text-xs font-semibold uppercase tracking-[0.14em] text-white transition hover:bg-white/[0.09] disabled:opacity-60">{aiState.busy === 'mergeIdea' ? 'Thinking…' : 'Suggest merge idea'}
{aiState.busy === 'qualityReview' ? 'Reviewing…' : 'Quality review'}
{aiState?.title?.alternatives?.length ? Alternatives: {aiState.title.alternatives.join(' • ')}
: null}
{aiState?.title?.rationale ? {aiState.title.rationale}
: null}
{aiState?.summary?.seo_description ? SEO: {aiState.summary.seo_description}
: null}
{aiState?.summary?.rationale ? {aiState.summary.rationale}
: null}
{aiState?.cover?.artwork?.thumb ? : null}
{isSmartMode && aiState?.cover?.artwork?.id ? Smart collections resolve covers dynamically, so this suggestion is preview-only.
: null}
{aiState?.cover?.rationale ? {aiState.cover.rationale}
: null}
{aiState?.grouping?.groups?.length ? (
{aiState.grouping.groups.map((group) => (
{group.label}
{group.count} suggested artworks
))}
) : null}
{aiState?.grouping?.rationale ? {aiState.grouping.rationale}
: null}
{aiState?.relatedArtworks?.artworks?.length ? (
{aiState.relatedArtworks.artworks.map((artwork) => (
{artwork.thumb ?
: null}
{artwork.title}
{artwork.shared_tags?.length ? `Shared tags: ${artwork.shared_tags.join(' • ')}` : `${artwork.shared_categories} shared categories`}
))}
) : null}
{aiState?.relatedArtworks?.rationale ? {aiState.relatedArtworks.rationale}
: null}
{aiState?.tags?.rationale ? {aiState.tags.rationale}
: null}
{aiState?.seoDescription?.rationale ? {aiState.seoDescription.rationale}
: null}
{aiState?.smartRulesExplanation?.rationale ? {aiState.smartRulesExplanation.rationale}
: null}
{aiState?.splitThemes?.splits?.length ? (
{aiState.splitThemes.splits.map((split) => (
{split.title}
{split.count} suggested artworks
))}
) : null}
{aiState?.splitThemes?.rationale ? {aiState.splitThemes.rationale}
: null}
{aiState?.mergeIdea?.idea?.summary ? {aiState.mergeIdea.idea.summary}
: null}
{aiState?.mergeIdea?.rationale ? {aiState.mergeIdea.rationale}
: null}
{aiState?.qualityReview?.missing_metadata?.length ? Missing: {aiState.qualityReview.missing_metadata.join(' • ')}
: null}
{aiState?.qualityReview?.suggested_summary?.summary ? Suggested summary: {aiState.qualityReview.suggested_summary.summary}
: null}
{aiState?.qualityReview?.suggested_related_collections?.length ? Related collections: {aiState.qualityReview.suggested_related_collections.map((item) => item.title).join(' • ')}
: null}
) : null}
{mode === 'edit' && activeTab === 'members' ? (
Collaborators
Team access
Pending invites default to {inviteExpiryDays} days, but each invite can use a shorter or longer expiry. Ownership transfers keep the previous owner as an editor.
setInviteUsername(event.target.value)} placeholder="username" className="min-w-0 flex-1 rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none transition focus:border-sky-300/35 focus:bg-white/[0.06]" />
setInviteRole(event.target.value)} className="rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white outline-none transition focus:border-sky-300/35">
Editor
Contributor
Viewer
setInviteExpiryMode(event.target.value)} className="rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white outline-none transition focus:border-sky-300/35">
Default ({inviteExpiryDays} days)
{inviteExpiryOptions.map((days) => (
{days} day{days === 1 ? '' : 's'}
))}
Custom date
{inviteExpiryMode === 'custom' ? (
setInviteCustomExpiry(event.target.value)} className="rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none transition focus:border-sky-300/35 focus:bg-white/[0.06]" />
) : (
Leave this on default to use the global expiry window for collaborator invites.
)}
Invite
{members.length ? members.map((member) => (
)) :
No collaborators yet.
}
) : null}
{mode === 'edit' && activeTab === 'submissions' ? (
Submissions
Incoming artworks
{submissions.length ? submissions.map((submission) => (
handleSubmissionAction(item, 'approve')}
onReject={(item) => handleSubmissionAction(item, 'reject')}
onWithdraw={(item) => handleSubmissionAction(item, 'withdraw')}
/>
)) : No submissions yet.
}
) : null}
{mode === 'edit' && activeTab === 'discussion' ? (
Discussion
Recent comments
{comments.length ? comments.slice(0, 6).map((comment) => (
)) :
No comments yet.
}
) : null}
{mode === 'edit' && activeTab === 'settings' ? (
Presentation
Mode
{isSmartMode ? 'Smart' : 'Manual'}
Visibility
Feature status
{featuredCountLabel}
{canFeature ? (
{collectionState?.is_featured ? 'Remove from Featured' : 'Feature this Collection'}
) : null}
Live Stats
Shares: {(collectionState?.shares_count ?? 0).toLocaleString()} {collectionState?.last_activity_at ? `• Active ${new Date(collectionState.last_activity_at).toLocaleDateString()}` : ''}
Health
Readiness and warnings
{aiState.busy === 'qualityReview' ? 'Reviewing…' : 'AI fix suggestions'}
{collectionState?.placement_eligibility ? 'Placement eligible' : 'Placement blocked'}
{collectionState?.campaign_key ? Campaign · {collectionState.campaign_key} : null}
{collectionState?.program_key ? Program · {collectionState.program_key} : null}
Metadata
{collectionState?.metadata_completeness_score !== null && collectionState?.metadata_completeness_score !== undefined ? Number(collectionState.metadata_completeness_score).toFixed(1) : 'N/A'}
Editorial
{collectionState?.editorial_readiness_score !== null && collectionState?.editorial_readiness_score !== undefined ? Number(collectionState.editorial_readiness_score).toFixed(1) : 'N/A'}
{(collectionState?.health_flags?.length ? collectionState.health_flags : [collectionState?.health_state].filter(Boolean)).map((flag) => {
const meta = healthFlagMeta(flag)
return (
{meta.label}
{meta.description}
)
})}
{aiState?.qualityReview?.missing_metadata?.length ? (
Suggested fixes
Focus first on: {aiState.qualityReview.missing_metadata.join(' • ')}
{aiState?.qualityReview?.suggested_summary?.summary ?
Suggested summary: {aiState.qualityReview.suggested_summary.summary}
: null}
) : null}
Last health check: {formatDateTimeLabel(collectionState?.last_health_check_at) || 'Not recorded yet'}
Recommendation refresh: {formatDateTimeLabel(collectionState?.last_recommendation_refresh_at) || 'Not recorded yet'}
Studio Notes
Use the Details tab for metadata and publishing options, then switch to Artworks for ordering or smart rule management.
Community and editorial collections work best when collaborators, submissions, and moderation are reviewed regularly.
Merge Review
Compare duplicate candidates
Review candidate overlaps, choose a canonical destination, merge when the target is final, or dismiss false positives so they stop resurfacing.
{duplicateCandidates.length} active candidate{duplicateCandidates.length === 1 ? '' : 's'}
{canonicalTarget ? (
Current canonical target
{canonicalTarget.title}
{canonicalTarget.owner?.name || canonicalTarget.owner?.username || 'Collection'}
{canonicalTarget.manage_url ?
Open target : null}
) : null}
{duplicateCandidates.length ? duplicateCandidates.map((candidate) => (
{candidate.collection ? : null}
Compare
{(candidate.comparison?.match_reasons || []).map((reason) => (
{reason.replaceAll('_', ' ')}
))}
Shared artworks: {candidate.comparison?.shared_artworks_count ?? 0}
Current: {candidate.comparison?.source_artworks_count ?? 0}
Target: {candidate.comparison?.target_artworks_count ?? 0}
{candidate.decision?.action_type ? (
Latest decision:
{candidate.decision.action_type.replaceAll('_', ' ')}
{candidate.decision.summary ?
{candidate.decision.summary}
: null}
) : null}
handleCanonicalizeCandidate(candidate)}
disabled={saving}
className="inline-flex items-center gap-2 rounded-2xl border border-sky-300/20 bg-sky-400/10 px-4 py-3 text-sm font-semibold text-sky-100 transition hover:bg-sky-400/15 disabled:cursor-not-allowed disabled:opacity-60"
>
Canonicalize here
handleMergeCandidate(candidate)}
disabled={saving || candidate.collection?.mode === 'smart'}
className="inline-flex items-center gap-2 rounded-2xl border border-amber-300/20 bg-amber-400/10 px-4 py-3 text-sm font-semibold text-amber-100 transition hover:bg-amber-400/15 disabled:cursor-not-allowed disabled:opacity-60"
>
Merge into target
handleRejectDuplicateCandidate(candidate)}
disabled={saving || candidate.is_current_canonical_target}
className="inline-flex items-center gap-2 rounded-2xl border border-white/10 bg-white/[0.05] px-4 py-3 text-sm font-semibold text-white transition hover:bg-white/[0.08] disabled:cursor-not-allowed disabled:opacity-60"
>
Not duplicate
{candidate.collection?.mode === 'smart' ?
Smart collections can be designated as canonical but cannot receive merged artworks directly.
: null}
)) : (
No active duplicate candidates right now. Dismissed pairs stay suppressed until the collections change again.
)}
Linked Collections
Manual related collection links
Curate a hand-picked strip of related collections for multi-part series, anthologies, and campaign bundles. These links render before recommendation-driven suggestions on the public page.
{linkedCollections.length} linked
Add manageable collection
setSelectedLinkedCollectionId(event.target.value)}
className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white outline-none transition focus:border-sky-300/35"
>
Select a collection
{linkedCollectionOptions.map((item) => (
{item.title}
))}
Add linked collection
{linkedCollections.length ? linkedCollections.map((item) => (
{item.cover_image ?
:
}
{item.title}
{item.campaign_label || item.series_key || item.owner?.name || item.owner?.username || 'Collection'}
{item.manage_url ?
Open : null}
handleRemoveLinkedCollection(item.id)}
disabled={saving}
className="inline-flex items-center gap-2 rounded-2xl border border-rose-300/20 bg-rose-400/10 px-4 py-3 text-sm font-semibold text-rose-100 transition hover:bg-rose-400/15 disabled:cursor-not-allowed disabled:opacity-60"
>
Remove
)) : (
No manual linked collections yet. Add sequels, anthologies, or campaign companions here.
)}
Cross-Entity Links
Creators, artworks, stories, categories, campaigns, events, and tags
Add contextual links that turn this collection into a richer destination page. These links can point to creators, public artworks, published stories, category landings, campaign or event context, and public tag or theme pages.
{entityLinks.length} linked entities
Entity type
{
const nextType = event.target.value
const nextOptions = Array.isArray(entityLinkOptions[nextType]) ? entityLinkOptions[nextType] : []
setSelectedEntityType(nextType)
setSelectedEntityId(nextOptions[0]?.id || '')
}}
className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white outline-none transition focus:border-sky-300/35"
>
Creator
Artwork
Story
Category
Campaign
Event
Tag or Theme
Choose entity
setSelectedEntityId(event.target.value)}
className="w-full rounded-2xl border border-white/10 bg-[#0d1726] px-4 py-3 text-white outline-none transition focus:border-sky-300/35"
>
Select an entity
{(entityLinkOptions[selectedEntityType] || []).map((item) => (
{item.label}
))}
Relationship label
setEntityRelationship(event.target.value)}
placeholder="featured creator"
className="w-full rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-white outline-none transition focus:border-sky-300/35"
maxLength={80}
/>
Add link
{entityLinks.length ? entityLinks.map((item) => (
{item.image_url ?
:
}
{item.subtitle}
{item.relationship_type ?
{item.relationship_type}
: null}
{item.url ?
Open : null}
handleRemoveEntityLink(item.id)}
disabled={saving}
className="inline-flex items-center gap-2 rounded-2xl border border-rose-300/20 bg-rose-400/10 px-4 py-3 text-sm font-semibold text-rose-100 transition hover:bg-rose-400/15 disabled:cursor-not-allowed disabled:opacity-60"
>
Remove
)) : (
No entity links yet. Connect this collection to a creator profile, a public artwork, a published story, campaign or event context, or a category landing page.
)}
Page Modules
Persisted collection layout
Reorder the collection page with predefined modules. This keeps the destination page modular without turning it into a freeform page builder.
{layoutModules.filter((module) => module.enabled).length} active modules
{layoutModules.map((module, index) => (
updateLayoutModule(key, { enabled })}
onSlotChange={(key, slot) => updateLayoutModule(key, { slot })}
onMoveUp={() => moveLayoutModule(index, -1)}
onMoveDown={() => moveLayoutModule(index, 1)}
/>
))}
) : null}
{mode === 'edit' && activeTab === 'moderation' ? (
Moderation
Admin controls
Restrict public visibility, disable risky interactions, unfeature collections, or remove collaborators when a curation surface needs intervention.
Rapid actions
Remove featured placement
handleModerationStatusChange('under_review')} className="rounded-2xl border border-white/12 bg-white/[0.05] px-4 py-3 text-sm font-semibold text-white transition hover:bg-white/[0.08]">
Send to review
handleModerationStatusChange('restricted')} className="rounded-2xl border border-rose-400/20 bg-rose-400/10 px-4 py-3 text-sm font-semibold text-rose-100 transition hover:bg-rose-400/15">
Restrict public access
Current state: {(collectionState?.moderation_status || 'active').replace('_', ' ')}
) : null}
>
)
}