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 ( ) } 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 (
{label}
{value}
) } function ModeButton({ active, title, description, icon, onClick }) { return ( ) } function SmartPreviewArtwork({ artwork }) { return (
{artwork.title}
{artwork.title}
{[artwork.content_type, artwork.category].filter(Boolean).join(' • ') || 'Artwork'}
) } function ArtworkPickerCard({ artwork, checked, onToggle, actionLabel = 'Select' }) { return ( ) } function AttachedArtworkCard({ artwork, index, total, onMoveUp, onMoveDown, onRemove }) { return (
{artwork.title}
{artwork.title}
Position {index + 1}
) } function MemberCard({ member, onRoleChange, onRemove, onAccept, onDecline, onTransfer }) { const expiryLabel = formatDateTimeLabel(member?.expires_at) return (
{member?.user?.name
{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 ? ( ) : null} {member?.can_accept ? : null} {member?.can_decline ? : null} {member?.can_transfer ? : null} {member?.can_revoke ? : null}
) } function SubmissionReviewCard({ submission, onApprove, onReject, onWithdraw }) { return (
{submission?.artwork?.thumb ? {submission.artwork.title} : null}
{submission?.artwork?.title || 'Submission'}
{submission?.status} • @{submission?.user?.username}
{submission?.message ?

{submission.message}

: null}
{submission?.can_review ? : null} {submission?.can_review ? : null} {submission?.can_withdraw ? : null}
) } function AiSuggestionCard({ title, body, actionLabel, onAction, muted = false, children }) { return (
{title}
{body}
{children} {actionLabel && onAction ? ( ) : null}
) } function LayoutModuleCard({ module, index, total, onToggle, onSlotChange, onMoveUp, onMoveDown }) { return (
{module.label}

{module.description}

) } function StudioTabButton({ active, label, icon, onClick }) { return ( ) } 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 (
Rule {index + 1}
{rule.field === 'created_at' ? (
onValueChange({ ...(rule.value || {}), from: event.target.value })} 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 focus:bg-white/[0.06]" /> onValueChange({ ...(rule.value || {}), to: event.target.value })} 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 focus:bg-white/[0.06]" />
) : rule.field === 'is_featured' || rule.field === 'is_mature' ? ( ) : valueOptions.length ? ( ) : ( onValueChange(event.target.value)} 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 focus:bg-white/[0.06]" placeholder="Enter a value" /> )}

{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`}