Files
SkinbaseNova/resources/js/Pages/Studio/StudioWorldEditor.jsx

1791 lines
115 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useEffect, useMemo, useState } from 'react'
import { router, useForm, usePage } from '@inertiajs/react'
import StudioLayout from '../../Layouts/StudioLayout'
import RichTextEditor from '../../components/forum/RichTextEditor'
import { Checkbox, DateTimePicker, NovaSelect } from '../../components/ui'
import NovaConfirmDialog from '../../components/ui/NovaConfirmDialog'
import WorldDuplicateActionMenu from '../../components/worlds/editor/WorldDuplicateActionMenu'
import WorldRecapArticlePickerModal from '../../components/worlds/editor/WorldRecapArticlePickerModal'
import WorldLinkedChallengePickerModal from '../../components/worlds/editor/WorldLinkedChallengePickerModal'
import WorldMiniPreviewPanel from '../../components/worlds/editor/WorldMiniPreviewPanel'
import WorldRecurrenceHelper from '../../components/worlds/editor/WorldRecurrenceHelper'
import WorldRelationCard from '../../components/worlds/editor/WorldRelationCard'
import WorldRelationPickerModal from '../../components/worlds/editor/WorldRelationPickerModal'
import WorldSectionToggleList from '../../components/worlds/editor/WorldSectionToggleList'
import WorldMediaUploadField from '../../components/worlds/editor/WorldMediaUploadField'
import WorldAnalyticsPanel from '../../components/worlds/editor/analytics/WorldAnalyticsPanel'
import WorldSuggestionsPanel from '../../components/worlds/editor/suggestions/WorldSuggestionsPanel'
import WorldSummaryCard from '../../components/worlds/editor/WorldSummaryCard'
import WorldThemePresetHelper from '../../components/worlds/editor/WorldThemePresetHelper'
function toDateTimeLocal(value) {
if (!value) return ''
return String(value).slice(0, 16)
}
function arraysEqual(left, right) {
if (!Array.isArray(left) || !Array.isArray(right) || left.length !== right.length) {
return false
}
return left.every((entry, index) => entry === right[index])
}
function normalizeRelations(relations) {
return (Array.isArray(relations) ? relations : []).map((relation, index) => ({
...relation,
sort_order: index,
}))
}
function relationForEditor(relation) {
return {
...relation,
sort_order: Number(relation?.sort_order || 0),
is_featured: Boolean(relation?.is_featured),
preview: relation?.preview || null,
query: relation?.query || relation?.preview?.title || '',
}
}
function upsertRelation(relations, nextRelation) {
const items = Array.isArray(relations) ? relations : []
const normalized = relationForEditor(nextRelation)
const existingIndex = items.findIndex((relation) => relation.related_type === normalized.related_type && Number(relation.related_id) === Number(normalized.related_id))
if (existingIndex === -1) {
return normalizeRelations([...items, normalized])
}
return normalizeRelations(items.map((relation, index) => (index === existingIndex ? relationForEditor({ ...relation, ...normalized }) : relation)))
}
function initialSectionVisibility(sectionOptions, worldVisibility) {
const defaults = Object.fromEntries((Array.isArray(sectionOptions) ? sectionOptions : []).map((option) => [option.value, true]))
return { ...defaults, ...(worldVisibility || {}) }
}
function buildDefaultRelation(sectionOptions, relationTypeOptions, existingCount = 0) {
const firstSection = sectionOptions?.[0]
return {
section_key: firstSection?.value || 'featured_artworks',
related_type: firstSection?.relation_types?.[0] || relationTypeOptions?.[0]?.value || 'artwork',
related_id: '',
context_label: '',
sort_order: existingCount,
is_featured: false,
preview: null,
query: '',
}
}
function resolveMediaUrl(path, fallbackUrl = '', filesBaseUrl = '') {
if (!path) return fallbackUrl || ''
if (String(path).startsWith('http://') || String(path).startsWith('https://') || String(path).startsWith('/')) {
return path
}
if (fallbackUrl) {
return fallbackUrl
}
if (filesBaseUrl) {
return `${String(filesBaseUrl).replace(/\/$/, '')}/${String(path).replace(/^\//, '')}`
}
return path
}
function formatCompactNumber(value) {
const number = Number(value || 0)
if (!Number.isFinite(number)) {
return '0'
}
return new Intl.NumberFormat('en', { notation: 'compact', maximumFractionDigits: 1 }).format(number)
}
const DEFAULT_ACTION_CONFIRM = {
open: false,
url: '',
title: 'Please confirm',
message: '',
confirmLabel: 'Confirm',
cancelLabel: 'Cancel',
confirmTone: 'danger',
noteEnabled: false,
copyModeEnabled: false,
copyModeOptions: [],
defaultCopyMode: 'with_relations',
preserveScroll: true,
}
function buildPreviewWorld(formData, world, themeOptions, typeOptions, filesBaseUrl) {
const theme = (themeOptions || []).find((item) => item.value === formData.theme_key) || null
const type = (typeOptions || []).find((item) => item.value === formData.type)
return {
title: formData.title,
tagline: formData.tagline,
summary: formData.summary,
description: formData.description,
cover_url: resolveMediaUrl(formData.cover_path, world?.cover_path === formData.cover_path ? world?.cover_url || '' : '', filesBaseUrl),
teaser_image_url: resolveMediaUrl(formData.teaser_image_path, world?.teaser_image_path === formData.teaser_image_path ? world?.teaser_image_url || '' : '', filesBaseUrl),
type: type?.label || formData.type || 'Seasonal',
badge_label: formData.badge_label,
campaign_label: formData.campaign_label,
badge_description: formData.badge_description,
cta_label: formData.cta_label,
accent_color: formData.accent_color || theme?.accent_color || '#38bdf8',
accent_color_secondary: formData.accent_color_secondary || theme?.accent_color_secondary || '#0f172a',
background_motif: formData.background_motif || theme?.background_motif || 'atmosphere',
icon_name: formData.icon_name || theme?.icon_name || 'fa-solid fa-sparkles',
is_featured: Boolean(formData.is_featured),
is_active_campaign: Boolean(formData.is_active_campaign),
is_homepage_featured: Boolean(formData.is_homepage_featured),
}
}
const WORLD_EDITOR_TABS = [
{
id: 'basics',
label: 'Basics',
icon: 'fa-solid fa-pen-ruler',
description: 'Core editorial copy, title, and public story framing for the world.',
},
{
id: 'structure',
label: 'Structure',
icon: 'fa-solid fa-diagram-project',
description: 'Curated relations and section ordering that shape the public world composition.',
},
{
id: 'suggestions',
label: 'Suggestions',
icon: 'fa-solid fa-wand-magic-sparkles',
description: 'Editorial assist candidates scored from theme, submissions, challenge context, and recurring-world signals.',
},
{
id: 'community',
label: 'Community',
icon: 'fa-solid fa-people-group',
description: 'Submission settings and the moderation queue for creator participation.',
},
{
id: 'publishing',
label: 'Publishing',
icon: 'fa-solid fa-calendar-check',
description: 'Status, schedule, recurrence, and edition management controls.',
},
{
id: 'presentation',
label: 'Presentation',
icon: 'fa-solid fa-swatchbook',
description: 'Theme preset, visual identity, media assets, CTA, and badge surface copy.',
},
{
id: 'recap',
label: 'Recap',
icon: 'fa-solid fa-stars',
description: 'Shape the archive-facing recap with editorial framing, linked recap story, and publish-ready summary state.',
},
{
id: 'seo',
label: 'SEO',
icon: 'fa-solid fa-magnifying-glass-chart',
description: 'Search and social metadata that ships with the world page.',
},
{
id: 'analytics',
label: 'Analytics',
icon: 'fa-solid fa-chart-column',
description: 'Traffic, source surfaces, engagement, participation, challenge energy, and recurring-edition comparison.',
},
]
const WORLD_EDITOR_TAB_FIELDS = {
basics: ['title', 'slug', 'tagline', 'summary', 'description'],
structure: ['relations', 'section_order_json', 'section_visibility_json', 'linked_challenge_id', 'show_linked_challenge_section', 'show_linked_challenge_entries', 'show_linked_challenge_winners', 'show_linked_challenge_finalists', 'auto_grant_challenge_world_rewards', 'challenge_teaser_override', 'hidden_linked_challenge_artwork_ids_json'],
suggestions: [],
community: ['accepts_submissions', 'participation_mode', 'submission_starts_at', 'submission_ends_at', 'submission_note_enabled', 'community_section_enabled', 'allow_readd_after_removal', 'submission_guidelines'],
publishing: ['type', 'status', 'published_at', 'starts_at', 'ends_at', 'promotion_starts_at', 'promotion_ends_at', 'is_featured', 'is_active_campaign', 'is_homepage_featured', 'campaign_priority', 'is_recurring', 'recurrence_key', 'recurrence_rule', 'edition_year'],
presentation: ['theme_key', 'accent_color', 'accent_color_secondary', 'background_motif', 'icon_name', 'cover_path', 'teaser_image_path', 'og_image_path', 'teaser_title', 'teaser_summary', 'cta_label', 'cta_url', 'badge_label', 'campaign_label', 'badge_description', 'badge_url', 'related_tags_json'],
recap: ['recap_status', 'recap_title', 'recap_summary', 'recap_intro', 'recap_editor_note', 'recap_cover_path', 'recap_article_id'],
seo: ['seo_title', 'seo_description'],
analytics: [],
}
const PARTICIPATION_MODE_OPTIONS = [
{ value: 'manual_approval', label: 'Manual approval', description: 'Creators can add artworks, but each one starts pending until a moderator approves it.' },
{ value: 'auto_add', label: 'Auto add', description: 'Eligible artworks go live in the community section immediately.' },
{ value: 'closed', label: 'Closed', description: 'Hide this world from creator participation surfaces.' },
]
function errorBelongsToTab(tabId, errorKey) {
const prefixes = WORLD_EDITOR_TAB_FIELDS[tabId] || []
return prefixes.some((prefix) => errorKey === prefix || errorKey.startsWith(`${prefix}.`) || errorKey.startsWith(`${prefix}[`))
}
function WorldEditorSection({ title, description, actions = null, children }) {
return (
<div className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
<div className="flex items-start justify-between gap-4">
<div>
<h2 className="text-xl font-semibold text-white">{title}</h2>
{description ? <p className="mt-1 text-sm leading-6 text-slate-400">{description}</p> : null}
</div>
{actions}
</div>
<div className="mt-5">{children}</div>
</div>
)
}
function LinkedChallengeCard({ challenge, onChange, onClear }) {
return (
<div className="rounded-[24px] border border-white/10 bg-black/20 p-4">
{challenge ? (
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
<div className="flex min-w-0 gap-4">
<div className="h-16 w-16 shrink-0 overflow-hidden rounded-[20px] border border-white/10 bg-slate-950">
{challenge.image ? <img src={challenge.image} alt="" className="h-full w-full object-cover" /> : <div className="flex h-full items-center justify-center text-slate-500"><i className="fa-solid fa-trophy" /></div>}
</div>
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<div className="text-base font-semibold text-white">{challenge.title}</div>
{challenge.entity_label ? <span className="rounded-full border border-sky-300/20 bg-sky-400/10 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-sky-100">{challenge.entity_label}</span> : null}
</div>
{challenge.subtitle ? <div className="mt-1 text-xs uppercase tracking-[0.14em] text-slate-500">{challenge.subtitle}</div> : null}
{challenge.description ? <div className="mt-2 text-sm leading-6 text-slate-400">{challenge.description}</div> : null}
{Array.isArray(challenge.meta) && challenge.meta.length > 0 ? <div className="mt-2 flex flex-wrap gap-2 text-xs text-slate-500">{challenge.meta.map((entry) => <span key={entry}>{entry}</span>)}</div> : null}
</div>
</div>
<div className="flex shrink-0 flex-wrap gap-2">
<button type="button" onClick={onChange} className="rounded-2xl border border-sky-300/20 bg-sky-400/10 px-4 py-2 text-sm font-semibold text-sky-100">Change</button>
<button type="button" onClick={onClear} className="rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-2 text-sm font-semibold text-slate-200">Clear</button>
</div>
</div>
) : (
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div>
<div className="text-sm font-semibold text-white">No primary challenge linked</div>
<p className="mt-1 text-sm leading-6 text-slate-400">Link one group challenge when this world should automatically surface challenge status, entries, and winner context.</p>
</div>
<button type="button" onClick={onChange} className="rounded-2xl border border-sky-300/20 bg-sky-400/10 px-4 py-2 text-sm font-semibold text-sky-100">Select challenge</button>
</div>
)}
</div>
)
}
function WorldEditorActionPanel({ formProcessing, isEditing, publishUrl, publishRecapUrl, canPublishRecap, recapStatusLabel, archiveUrl, publicUrl }) {
return (
<div className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
<div className="flex items-start justify-between gap-4">
<div>
<h2 className="text-xl font-semibold text-white">Campaign actions</h2>
<p className="mt-1 text-sm leading-6 text-slate-400">Keep save, publish, archive, and public-page actions reachable while reviewing the campaign summary.</p>
</div>
<div className="hidden rounded-full border border-white/10 bg-black/20 px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-400 xl:inline-flex">Sticky</div>
</div>
<div className="mt-5 grid gap-3 sm:grid-cols-2">
<button type="submit" disabled={formProcessing} className="inline-flex items-center justify-center gap-2 rounded-2xl bg-white px-5 py-3 text-sm font-semibold text-slate-950 transition hover:bg-sky-100 disabled:opacity-60">{formProcessing ? 'Saving…' : (isEditing ? 'Save world' : 'Create world')}</button>
{publishUrl ? <button type="button" onClick={() => router.post(publishUrl)} className="inline-flex items-center justify-center gap-2 rounded-2xl border border-emerald-300/20 bg-emerald-400/10 px-5 py-3 text-sm font-semibold text-emerald-100">Publish</button> : null}
{publishRecapUrl ? <button type="button" onClick={() => router.post(publishRecapUrl)} disabled={!canPublishRecap} className="inline-flex items-center justify-center gap-2 rounded-2xl border border-sky-300/20 bg-sky-400/10 px-5 py-3 text-sm font-semibold text-sky-100 disabled:cursor-not-allowed disabled:opacity-50">{recapStatusLabel === 'Published recap' ? 'Refresh recap snapshot' : 'Publish recap'}</button> : null}
{archiveUrl ? <button type="button" onClick={() => router.post(archiveUrl)} className="inline-flex items-center justify-center gap-2 rounded-2xl border border-amber-300/20 bg-amber-400/10 px-5 py-3 text-sm font-semibold text-amber-100">Archive</button> : null}
{publicUrl ? <a href={publicUrl} className="inline-flex items-center justify-center gap-2 rounded-2xl border border-white/10 bg-white/[0.04] px-5 py-3 text-sm font-semibold text-white">Public page</a> : null}
</div>
</div>
)
}
function LinkedChallengeEntryVisibilityManager({ challenge, hiddenIds, onToggle, error = '' }) {
const items = Array.isArray(challenge?.entry_preview_items) ? challenge.entry_preview_items : []
if (!challenge || items.length === 0) {
return null
}
return (
<div className="rounded-[24px] border border-white/10 bg-black/20 p-4">
<div className="flex flex-col gap-2 md:flex-row md:items-end md:justify-between">
<div>
<div className="text-sm font-semibold text-white">Entry visibility overrides</div>
<p className="mt-1 text-sm leading-6 text-slate-400">Hide specific linked challenge entries from the derived world feed when moderation or editorial context requires it.</p>
</div>
<div className="text-xs uppercase tracking-[0.16em] text-slate-500">{hiddenIds.length} hidden</div>
</div>
<div className="mt-4 grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{items.map((item) => {
const hidden = hiddenIds.includes(item.id)
const statusLabel = item.status === 'winner' ? 'Winner' : item.status === 'finalist' ? 'Finalist' : 'Entry'
return (
<button
key={item.id}
type="button"
onClick={() => onToggle(item.id)}
className={`flex items-center gap-3 rounded-[20px] border px-3 py-3 text-left transition ${hidden ? 'border-amber-300/25 bg-amber-400/10' : 'border-white/10 bg-white/[0.03] hover:bg-white/[0.05]'}`}
>
<div className="h-14 w-14 shrink-0 overflow-hidden rounded-2xl border border-white/10 bg-slate-950">
{item.image ? <img src={item.image} alt="" className="h-full w-full object-cover" /> : <div className="flex h-full w-full items-center justify-center text-slate-500"><i className="fa-solid fa-image" /></div>}
</div>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-semibold text-white">{item.title}</div>
<div className="mt-1 text-[11px] uppercase tracking-[0.16em] text-slate-500">{statusLabel}</div>
<div className={`mt-2 text-xs font-semibold ${hidden ? 'text-amber-100' : 'text-emerald-100'}`}>{hidden ? 'Hidden from world entries' : 'Visible on world page'}</div>
</div>
</button>
)
})}
</div>
{error ? <div className="mt-3 text-sm text-rose-300">{error}</div> : null}
</div>
)
}
export default function StudioWorldEditor() {
const { props } = usePage()
const world = props.world || null
const filesBaseUrl = props.mediaSupport?.files_base_url || ''
const sectionOptions = props.sectionOptions || []
const relationTypeOptions = props.relationTypeOptions || []
const themeOptions = props.themeOptions || []
const typeOptions = props.typeOptions || []
const duplicateActions = props.duplicateActions || null
const suggestions = props.suggestions || null
const reviewQueue = world?.submission_review_queue || { counts: { pending: 0, live: 0, removed: 0, blocked: 0, featured: 0 }, items: [] }
const initialRelations = Array.isArray(world?.relations) ? normalizeRelations(world.relations.map((relation) => relationForEditor({
section_key: relation.section_key || sectionOptions?.[0]?.value || 'featured_artworks',
related_type: relation.related_type || relationTypeOptions?.[0]?.value || 'artwork',
related_id: relation.related_id || '',
context_label: relation.context_label || '',
sort_order: relation.sort_order || 0,
is_featured: Boolean(relation.is_featured),
preview: relation.preview || null,
}))) : []
const form = useForm({
title: world?.title || '',
slug: world?.slug || '',
tagline: world?.tagline || '',
summary: world?.summary || '',
teaser_title: world?.teaser_title || '',
teaser_summary: world?.teaser_summary || '',
description: world?.description || '',
cover_path: world?.cover_path || '',
teaser_image_path: world?.teaser_image_path || '',
theme_key: world?.theme_key ?? '',
accent_color: world?.accent_color || '',
accent_color_secondary: world?.accent_color_secondary || '',
background_motif: world?.background_motif || '',
icon_name: world?.icon_name || '',
status: world?.status || 'draft',
type: world?.type || 'seasonal',
published_at: toDateTimeLocal(world?.published_at),
starts_at: toDateTimeLocal(world?.starts_at),
ends_at: toDateTimeLocal(world?.ends_at),
promotion_starts_at: toDateTimeLocal(world?.promotion_starts_at),
promotion_ends_at: toDateTimeLocal(world?.promotion_ends_at),
accepts_submissions: Boolean(world?.accepts_submissions),
participation_mode: world?.participation_mode || (world?.accepts_submissions ? 'manual_approval' : 'closed'),
submission_starts_at: toDateTimeLocal(world?.submission_starts_at),
submission_ends_at: toDateTimeLocal(world?.submission_ends_at),
submission_note_enabled: world?.submission_note_enabled !== false,
community_section_enabled: world?.community_section_enabled !== false,
allow_readd_after_removal: world?.allow_readd_after_removal !== false,
is_featured: Boolean(world?.is_featured),
is_active_campaign: Boolean(world?.is_active_campaign),
is_homepage_featured: Boolean(world?.is_homepage_featured),
campaign_priority: world?.campaign_priority ?? '',
is_recurring: Boolean(world?.is_recurring),
recurrence_key: world?.recurrence_key || '',
recurrence_rule: world?.recurrence_rule || '',
edition_year: world?.edition_year || '',
cta_label: world?.cta_label || '',
cta_url: world?.cta_url || '',
badge_label: world?.badge_label || '',
campaign_label: world?.campaign_label || '',
badge_description: world?.badge_description || '',
submission_guidelines: world?.submission_guidelines || '',
badge_url: world?.badge_url || '',
seo_title: world?.seo_title || '',
seo_description: world?.seo_description || '',
og_image_path: world?.og_image_path || '',
recap_status: world?.recap_status || 'draft',
recap_title: world?.recap_title || '',
recap_summary: world?.recap_summary || '',
recap_intro: world?.recap_intro || '',
recap_editor_note: world?.recap_editor_note || '',
recap_cover_path: world?.recap_cover_path || '',
recap_article_id: world?.recap_article_id || '',
linked_challenge_id: world?.linked_challenge_id || '',
show_linked_challenge_section: world?.show_linked_challenge_section !== false,
show_linked_challenge_entries: world?.show_linked_challenge_entries !== false,
show_linked_challenge_winners: world?.show_linked_challenge_winners !== false,
show_linked_challenge_finalists: world?.show_linked_challenge_finalists !== false,
auto_grant_challenge_world_rewards: world?.auto_grant_challenge_world_rewards !== false,
challenge_teaser_override: world?.challenge_teaser_override || '',
hidden_linked_challenge_artwork_ids_json: Array.isArray(world?.hidden_linked_challenge_artwork_ids_json) ? world.hidden_linked_challenge_artwork_ids_json : [],
related_tags_json: Array.isArray(world?.related_tags_json) ? world.related_tags_json : [],
section_order_json: Array.isArray(world?.section_order_json) && world.section_order_json.length > 0 ? world.section_order_json : sectionOptions.map((option) => option.value),
section_visibility_json: initialSectionVisibility(sectionOptions, world?.section_visibility_json),
relations: initialRelations,
})
const [pickerState, setPickerState] = useState({ open: false, index: null })
const [linkedChallengePickerOpen, setLinkedChallengePickerOpen] = useState(false)
const [linkedChallengePreview, setLinkedChallengePreview] = useState(world?.linked_challenge || null)
const [recapArticlePickerOpen, setRecapArticlePickerOpen] = useState(false)
const [recapArticlePreview, setRecapArticlePreview] = useState(world?.recap_article || null)
const [activeTab, setActiveTab] = useState('basics')
const [temporaryMediaPaths, setTemporaryMediaPaths] = useState({
cover: '',
teaser: '',
og: '',
recap: '',
})
const themeMap = useMemo(() => Object.fromEntries(themeOptions.map((option) => [option.value, option])), [themeOptions])
const sectionMap = useMemo(() => Object.fromEntries(sectionOptions.map((option) => [option.value, option])), [sectionOptions])
const tagString = useMemo(() => (Array.isArray(form.data.related_tags_json) ? form.data.related_tags_json.join(', ') : ''), [form.data.related_tags_json])
const linkedChallengeEntryPreviewItems = useMemo(() => Array.isArray(linkedChallengePreview?.entry_preview_items) ? linkedChallengePreview.entry_preview_items : [], [linkedChallengePreview])
const selectedTheme = form.data.theme_key ? themeMap[form.data.theme_key] : null
const previewWorld = useMemo(() => buildPreviewWorld(form.data, world, themeOptions, typeOptions, filesBaseUrl), [filesBaseUrl, form.data, world, themeOptions, typeOptions])
const recapCoverPreviewUrl = useMemo(() => resolveMediaUrl(form.data.recap_cover_path, world?.recap_cover_path === form.data.recap_cover_path ? world?.recap_cover_url || '' : '', filesBaseUrl), [filesBaseUrl, form.data.recap_cover_path, world?.recap_cover_path, world?.recap_cover_url])
const relationCounts = useMemo(() => form.data.relations.reduce((counts, relation) => ({ ...counts, [relation.section_key]: (counts[relation.section_key] || 0) + 1 }), {}), [form.data.relations])
const enabledSectionsCount = useMemo(() => Object.values(form.data.section_visibility_json || {}).filter(Boolean).length, [form.data.section_visibility_json])
const defaultAnalyticsRange = world?.analytics?.default_range || '30d'
const analyticsSummary = world?.analytics?.ranges?.[defaultAnalyticsRange]?.summary || null
const previewSections = useMemo(() => {
const visibleKeys = (form.data.section_order_json || []).filter((key) => form.data.section_visibility_json?.[key] !== false)
return visibleKeys.map((key) => ({
key,
label: sectionMap[key]?.label || key,
count: form.data.relations.filter((relation) => relation.section_key === key).length,
items: form.data.relations.filter((relation) => relation.section_key === key).map((relation) => relation.preview).filter(Boolean).slice(0, 3),
}))
}, [form.data.section_order_json, form.data.section_visibility_json, form.data.relations, sectionMap])
const recapStatsSnapshot = world?.recap_stats_snapshot || null
const recapStatsSummary = recapStatsSnapshot?.summary || null
const canPublishRecap = Boolean(world && (world.status === 'archived' || (world.ends_at && new Date(world.ends_at) < new Date())))
const errorEntries = Object.entries(form.errors || {})
const tabErrorCounts = useMemo(() => WORLD_EDITOR_TABS.reduce((counts, tab) => ({
...counts,
[tab.id]: errorEntries.filter(([key]) => errorBelongsToTab(tab.id, key)).length,
}), {}), [errorEntries])
const editorTabs = useMemo(() => WORLD_EDITOR_TABS.map((tab) => {
let meta = ''
switch (tab.id) {
case 'basics':
meta = form.data.title ? 'Named and writable' : 'Needs title'
break
case 'structure':
meta = form.data.relations.length > 0 ? `${form.data.relations.length} relation${form.data.relations.length === 1 ? '' : 's'}` : 'No relations yet'
break
case 'suggestions':
meta = !world
? 'Save to unlock'
: `${suggestions?.summary?.available_count || 0} ready · ${suggestions?.summary?.pinned_count || 0} pinned`
break
case 'community':
meta = form.data.participation_mode === 'closed'
? 'Closed to creators'
: form.data.participation_mode === 'auto_add'
? `${reviewQueue?.counts?.live || 0} live now`
: `${reviewQueue?.counts?.pending || 0} pending review`
break
case 'publishing':
meta = form.data.is_recurring ? 'Recurring workflow' : 'Single edition'
break
case 'presentation':
meta = selectedTheme?.label || 'Custom identity'
break
case 'recap':
meta = form.data.recap_title || recapArticlePreview?.title
? `${form.data.recap_status === 'published' ? 'Published' : 'Draft'} recap`
: 'Optional archive layer'
break
case 'seo':
meta = form.data.seo_title || form.data.seo_description ? 'Configured' : 'Optional metadata'
break
case 'analytics':
meta = analyticsSummary?.views > 0 ? `${analyticsSummary.views} tracked views` : (world ? 'Ready for measurement' : 'Available after create')
break
default:
meta = ''
}
return {
...tab,
meta,
errorCount: tabErrorCounts[tab.id] || 0,
}
}), [analyticsSummary?.views, form.data.participation_mode, form.data.recap_status, form.data.recap_title, form.data.title, form.data.relations.length, form.data.is_recurring, form.data.seo_description, form.data.seo_title, recapArticlePreview?.title, reviewQueue?.counts?.live, reviewQueue?.counts?.pending, selectedTheme?.label, suggestions?.summary?.available_count, suggestions?.summary?.pinned_count, tabErrorCounts, world])
const firstErrorTab = useMemo(() => editorTabs.find((tab) => tab.errorCount > 0) || null, [editorTabs])
const currentTab = editorTabs.find((tab) => tab.id === activeTab) || editorTabs[0]
const editingRelation = pickerState.index === null ? buildDefaultRelation(sectionOptions, relationTypeOptions, form.data.relations.length) : form.data.relations[pickerState.index]
const [actionConfirm, setActionConfirm] = useState(DEFAULT_ACTION_CONFIRM)
const [actionReviewNote, setActionReviewNote] = useState('')
const [actionCopyMode, setActionCopyMode] = useState(DEFAULT_ACTION_CONFIRM.defaultCopyMode)
const [actionBusy, setActionBusy] = useState(false)
const [suggestionBusyKey, setSuggestionBusyKey] = useState('')
const [suggestionNotice, setSuggestionNotice] = useState('')
useEffect(() => {
if (firstErrorTab && firstErrorTab.id !== activeTab) {
setActiveTab(firstErrorTab.id)
}
}, [activeTab, firstErrorTab])
const searchEntities = async (type, query) => {
const url = new URL(props.entitySearchUrl, window.location.origin)
url.searchParams.set('type', type)
url.searchParams.set('q', query)
const response = await fetch(url.toString(), {
headers: { Accept: 'application/json' },
credentials: 'same-origin',
})
if (!response.ok) {
return []
}
const payload = await response.json()
return Array.isArray(payload.items) ? payload.items : []
}
const submit = (event) => {
event.preventDefault()
const options = {
onSuccess: () => {
setTemporaryMediaPaths({ cover: '', teaser: '', og: '', recap: '' })
},
}
if (props.updateUrl) {
form.patch(props.updateUrl, options)
return
}
form.post(props.storeUrl, options)
}
const deleteTemporaryMediaPath = async (path) => {
if (!props.mediaSupport?.delete_url || !path) return
await fetch(props.mediaSupport.delete_url, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '',
Accept: 'application/json',
},
credentials: 'same-origin',
body: JSON.stringify({
path,
world_id: world?.id || undefined,
}),
})
}
const applyCoverToOg = async () => {
if (!form.data.cover_path) return
const currentOgPath = form.data.og_image_path
const shouldDeleteTemporaryOg = temporaryMediaPaths.og !== '' && temporaryMediaPaths.og === currentOgPath && currentOgPath !== form.data.cover_path
if (shouldDeleteTemporaryOg) {
try {
await deleteTemporaryMediaPath(currentOgPath)
} catch {
// Leave the field update available even if cleanup fails.
}
}
form.setData('og_image_path', form.data.cover_path)
setTemporaryMediaPaths((current) => ({ ...current, og: '' }))
}
const handleThemeChange = (nextThemeKey, force = false) => {
const nextTheme = themeMap[nextThemeKey]
const previousTheme = themeMap[form.data.theme_key]
const currentTags = Array.isArray(form.data.related_tags_json) ? form.data.related_tags_json : []
const shouldReplace = (currentValue, nextValue, previousValue) => {
if (!nextValue) return currentValue
if (force || !currentValue || currentValue === previousValue) return nextValue
return currentValue
}
const nextData = {
...form.data,
theme_key: String(nextThemeKey || ''),
accent_color: shouldReplace(form.data.accent_color, nextTheme?.accent_color || '', previousTheme?.accent_color || ''),
accent_color_secondary: shouldReplace(form.data.accent_color_secondary, nextTheme?.accent_color_secondary || '', previousTheme?.accent_color_secondary || ''),
background_motif: shouldReplace(form.data.background_motif, nextTheme?.background_motif || '', previousTheme?.background_motif || ''),
icon_name: shouldReplace(form.data.icon_name, nextTheme?.icon_name || '', previousTheme?.icon_name || ''),
badge_label: shouldReplace(form.data.badge_label, nextTheme?.suggested_badge_label || '', previousTheme?.suggested_badge_label || ''),
cta_label: shouldReplace(form.data.cta_label, nextTheme?.suggested_cta_label || '', previousTheme?.suggested_cta_label || ''),
related_tags_json: force || currentTags.length === 0 || arraysEqual(currentTags, previousTheme?.related_tags_json || [])
? [...(nextTheme?.related_tags_json || [])]
: currentTags,
}
form.setData(nextData)
}
const openRelationPicker = (index = null) => setPickerState({ open: true, index })
const closeRelationPicker = () => setPickerState({ open: false, index: null })
const saveRelation = (relation) => {
const nextRelations = pickerState.index === null
? normalizeRelations([...form.data.relations, relation])
: normalizeRelations(form.data.relations.map((item, index) => (index === pickerState.index ? relation : item)))
form.setData('relations', nextRelations)
closeRelationPicker()
}
const removeRelation = (index) => form.setData('relations', normalizeRelations(form.data.relations.filter((_, relationIndex) => relationIndex !== index)))
const moveRelation = (index, delta) => {
const nextIndex = index + delta
if (nextIndex < 0 || nextIndex >= form.data.relations.length) return
const next = [...form.data.relations]
const [entry] = next.splice(index, 1)
next.splice(nextIndex, 0, entry)
form.setData('relations', normalizeRelations(next))
}
const updateSectionControls = (nextOrder, nextVisibility) => {
form.setData({
...form.data,
section_order_json: nextOrder,
section_visibility_json: nextVisibility,
})
}
const closeActionConfirm = () => {
if (actionBusy) return
setActionConfirm(DEFAULT_ACTION_CONFIRM)
setActionReviewNote('')
setActionCopyMode(DEFAULT_ACTION_CONFIRM.defaultCopyMode)
}
const saveLinkedChallenge = (challenge) => {
setLinkedChallengePreview(challenge)
form.setData({
...form.data,
linked_challenge_id: challenge?.id || '',
hidden_linked_challenge_artwork_ids_json: [],
})
setLinkedChallengePickerOpen(false)
}
const saveRecapArticle = (article) => {
setRecapArticlePreview(article)
form.setData({
...form.data,
recap_article_id: article?.id || '',
})
setRecapArticlePickerOpen(false)
}
const clearRecapArticle = () => {
setRecapArticlePreview(null)
form.setData('recap_article_id', '')
}
const clearLinkedChallenge = () => {
setLinkedChallengePreview(null)
form.setData({
...form.data,
linked_challenge_id: '',
challenge_teaser_override: '',
hidden_linked_challenge_artwork_ids_json: [],
})
}
const toggleHiddenLinkedChallengeEntry = (artworkId) => {
const currentIds = Array.isArray(form.data.hidden_linked_challenge_artwork_ids_json) ? form.data.hidden_linked_challenge_artwork_ids_json : []
const nextIds = currentIds.includes(artworkId)
? currentIds.filter((id) => id !== artworkId)
: [...currentIds, artworkId]
form.setData('hidden_linked_challenge_artwork_ids_json', nextIds)
}
const openActionConfirm = (config) => {
if (!config?.url) return
setActionReviewNote('')
setActionCopyMode(config.defaultCopyMode || DEFAULT_ACTION_CONFIRM.defaultCopyMode)
setActionConfirm({
...DEFAULT_ACTION_CONFIRM,
...config,
open: true,
})
}
const confirmAction = () => {
if (!actionConfirm.url || actionBusy) return
const payload = {
...(actionConfirm.noteEnabled ? { review_note: actionReviewNote } : {}),
...(actionConfirm.copyModeEnabled ? { copy_mode: actionCopyMode } : {}),
}
setActionBusy(true)
router.post(actionConfirm.url, payload, {
preserveScroll: actionConfirm.preserveScroll,
onSuccess: () => {
setActionConfirm(DEFAULT_ACTION_CONFIRM)
setActionReviewNote('')
},
onFinish: () => {
setActionBusy(false)
},
})
}
const runDuplicateAction = (url, promptText, copyModeOptions = []) => {
openActionConfirm({
url,
title: 'Duplicate world?',
message: promptText,
confirmLabel: 'Continue',
cancelLabel: 'Cancel',
confirmTone: 'accent',
copyModeEnabled: copyModeOptions.length > 0,
copyModeOptions,
defaultCopyMode: copyModeOptions.find((option) => option.value === 'with_relations')?.value || copyModeOptions[0]?.value || 'with_relations',
preserveScroll: false,
})
}
const runSubmissionAction = (url, promptText, options = {}) => {
const {
title = 'Confirm submission action',
confirmLabel = 'Confirm',
cancelLabel = 'Cancel',
confirmTone = 'accent',
noteEnabled = false,
} = options
openActionConfirm({
url,
title,
message: promptText,
confirmLabel,
cancelLabel,
confirmTone,
noteEnabled,
preserveScroll: true,
})
}
const postSuggestionAction = async (url, payload) => {
const response = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '',
Accept: 'application/json',
},
credentials: 'same-origin',
body: JSON.stringify(payload),
})
const data = await response.json().catch(() => ({}))
if (!response.ok) {
throw new Error(data?.message || 'Suggestion action failed.')
}
return data
}
const refreshSuggestions = () => {
if (!world) return
router.reload({
only: ['suggestions'],
preserveScroll: true,
preserveState: true,
})
}
const handleSuggestionAction = async (item, action, payload = {}, afterSuccess = null) => {
const url = props.suggestionActions?.[action]
if (!url || !item) return
setSuggestionBusyKey(item.key)
try {
const response = await postSuggestionAction(url, {
related_type: item.entity_type,
related_id: item.entity_id,
...payload,
})
if (typeof afterSuccess === 'function') {
afterSuccess(response)
}
setSuggestionNotice(response?.message || 'Suggestion updated.')
refreshSuggestions()
} catch (error) {
setSuggestionNotice(error?.message || 'Suggestion action failed.')
} finally {
setSuggestionBusyKey('')
}
}
const addSuggestionRelation = (item, sectionKey, isFeatured) => handleSuggestionAction(item, 'add', {
section_key: sectionKey,
is_featured: isFeatured,
}, (response) => {
if (response?.relation) {
form.setData('relations', upsertRelation(form.data.relations, response.relation))
}
})
const pinSuggestion = (item, sectionKey) => handleSuggestionAction(item, 'pin', {
section_key: sectionKey,
})
const dismissSuggestion = (item) => handleSuggestionAction(item, 'dismiss')
const markSuggestionNotRelevant = (item) => handleSuggestionAction(item, 'notRelevant')
const restoreSuggestion = (item) => handleSuggestionAction(item, 'restore')
return (
<StudioLayout title={props.title} subtitle={props.description}>
<form onSubmit={submit} className="grid gap-6 xl:grid-cols-[minmax(0,1.04fr)_minmax(360px,0.96fr)]">
<section className="space-y-6">
{errorEntries.length > 0 ? (
<div className="rounded-[28px] border border-rose-300/20 bg-rose-400/10 p-5 text-sm text-rose-100">
<div className="font-semibold">Fix the highlighted editor issues before publishing.</div>
{firstErrorTab ? <div className="mt-2 text-sm text-rose-100/90">The first blocked section is <span className="font-semibold">{firstErrorTab.label}</span>.</div> : null}
<div className="mt-3 grid gap-1 text-sm leading-6">
{errorEntries.slice(0, 8).map(([key, message]) => <div key={key}>{message}</div>)}
</div>
</div>
) : null}
<div className="rounded-[28px] border border-white/10 bg-white/[0.03] p-3">
<div className="grid gap-2 md:grid-cols-2 2xl:grid-cols-3">
{editorTabs.map((tab) => {
const isActive = activeTab === tab.id
return (
<button
key={tab.id}
type="button"
onClick={() => setActiveTab(tab.id)}
role="tab"
aria-selected={isActive}
className={[
'rounded-[22px] border px-4 py-3 text-left transition',
isActive
? 'border-sky-300/30 bg-sky-400/12 text-white shadow-[0_16px_40px_rgba(14,165,233,0.12)]'
: 'border-white/10 bg-black/20 text-slate-300 hover:border-white/15 hover:bg-white/[0.05]',
].join(' ')}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex items-center gap-2">
<i className={`${tab.icon} text-[11px] ${isActive ? 'text-sky-100' : 'text-slate-500'}`} aria-hidden="true" />
<span className="text-sm font-semibold">{tab.label}</span>
</div>
<div className="mt-1 text-xs leading-5 text-slate-400">{tab.meta}</div>
</div>
<div className="flex shrink-0 items-center gap-2">
{tab.errorCount > 0 ? <span className="rounded-full border border-rose-300/20 bg-rose-400/12 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.16em] text-rose-100">{tab.errorCount}</span> : null}
{isActive ? <span className="rounded-full border border-sky-300/25 bg-sky-400/12 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.16em] text-sky-100">Active</span> : null}
</div>
</div>
</button>
)
})}
</div>
</div>
{activeTab === 'basics' ? (
<WorldEditorSection title="World basics" description="Shape the title, summary, and long-form story before you worry about composition or scheduling.">
<div className="grid gap-4">
<div className="grid gap-4 md:grid-cols-[minmax(0,1.2fr)_minmax(0,0.8fr)]">
<label className="grid gap-2 text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Title</span>
<input value={form.data.title} onChange={(event) => form.setData('title', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<span className="text-xs leading-5 text-slate-500">Use a campaign-ready title that will still read clearly in homepage spotlight, preview, and archive contexts.</span>
</label>
<label className="grid gap-2 text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Slug</span>
<input value={form.data.slug} onChange={(event) => form.setData('slug', event.target.value)} placeholder="optional-manual-slug" className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<span className="text-xs leading-5 text-slate-500">Keep this short and durable. It becomes the public world URL and should survive future editorial updates.</span>
</label>
</div>
<div className="grid gap-4 md:grid-cols-2">
<label className="grid gap-2 text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Tagline</span>
<input value={form.data.tagline} onChange={(event) => form.setData('tagline', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
</label>
<label className="grid gap-2 text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Summary</span>
<input value={form.data.summary} onChange={(event) => form.setData('summary', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
</label>
</div>
<div className="grid gap-2 text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Description</span>
<RichTextEditor
content={form.data.description}
onChange={(nextValue) => form.setData('description', nextValue)}
placeholder="Build the world story with rich formatting, links, emoji, and campaign callouts."
error={form.errors.description}
minHeight={18}
autofocus={false}
/>
</div>
</div>
</WorldEditorSection>
) : null}
{activeTab === 'structure' ? (
<>
<WorldEditorSection
title="Curated relations"
description="Attach explicit artworks, collections, creators, groups, news, challenges, events, releases, and cards as editorial composition blocks."
actions={<button type="button" onClick={() => openRelationPicker(null)} className="rounded-full border border-sky-300/20 bg-sky-400/10 px-4 py-2 text-sm font-semibold text-sky-100">Add relation</button>}
>
<div className="grid gap-4">
{form.data.relations.length > 0 ? form.data.relations.map((relation, index) => (
<WorldRelationCard
key={`${relation.related_type}-${relation.related_id || index}-${index}`}
relation={relation}
index={index}
total={form.data.relations.length}
sectionLabel={sectionMap[relation.section_key]?.label || relation.section_key}
onEdit={() => openRelationPicker(index)}
onRemove={() => removeRelation(index)}
onMove={moveRelation}
/>
)) : <div className="rounded-[24px] border border-dashed border-white/10 bg-white/[0.02] p-5 text-sm text-slate-400">No curated relations attached yet. Add relations to turn the world into a real campaign hub instead of a static shell.</div>}
{activeTab === 'suggestions' ? (
<WorldSuggestionsPanel
suggestions={suggestions}
notice={suggestionNotice}
worldExists={Boolean(world)}
busyKey={suggestionBusyKey}
onAddFeatured={(item, sectionKey) => addSuggestionRelation(item, sectionKey, true)}
onAddSection={(item, sectionKey, isFeatured = false) => addSuggestionRelation(item, sectionKey, isFeatured)}
onPin={pinSuggestion}
onDismiss={dismissSuggestion}
onNotRelevant={markSuggestionNotRelevant}
onRestore={restoreSuggestion}
/>
) : null}
</div>
</WorldEditorSection>
<WorldEditorSection title="Linked challenge automation" description="Use one primary challenge to drive a dedicated challenge panel, entry rail, winner carry-over, and campaign freshness across public surfaces.">
<div className="grid gap-4">
<LinkedChallengeCard challenge={linkedChallengePreview} onChange={() => setLinkedChallengePickerOpen(true)} onClear={clearLinkedChallenge} />
<div className="grid gap-3 md:grid-cols-2">
<div className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3">
<Checkbox checked={form.data.show_linked_challenge_section} onChange={(event) => form.setData('show_linked_challenge_section', event.target.checked)} label="Show challenge panel on the world page" size={20} variant="accent" />
</div>
<div className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3">
<Checkbox checked={form.data.show_linked_challenge_entries} onChange={(event) => form.setData('show_linked_challenge_entries', event.target.checked)} label="Show linked challenge entries rail" size={20} variant="accent" />
</div>
<div className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3">
<Checkbox checked={form.data.show_linked_challenge_winners} onChange={(event) => form.setData('show_linked_challenge_winners', event.target.checked)} label="Show linked challenge winner section" size={20} variant="accent" />
</div>
<div className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3">
<Checkbox checked={form.data.auto_grant_challenge_world_rewards} onChange={(event) => form.setData('auto_grant_challenge_world_rewards', event.target.checked)} label="Auto-grant winner and finalist rewards from the linked challenge" size={20} variant="accent" />
</div>
</div>
<div className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3">
<Checkbox checked={form.data.show_linked_challenge_finalists} onChange={(event) => form.setData('show_linked_challenge_finalists', event.target.checked)} label="Show linked challenge finalists when they exist" size={20} variant="accent" />
<div className="mt-2 text-xs leading-5 text-slate-500">Finalists now come from structured challenge outcomes, so worlds can surface them automatically without waiting for manual recap edits.</div>
</div>
<label className="grid gap-2 text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Challenge teaser override</span>
<textarea value={form.data.challenge_teaser_override} onChange={(event) => form.setData('challenge_teaser_override', event.target.value)} rows={4} placeholder="Optional world-specific framing for the linked challenge panel." className="rounded-[24px] border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<span className="text-xs leading-5 text-slate-500">Leave this blank to reuse the challenge summary automatically. Use it when the world needs a more editorial campaign voice than the raw challenge copy.</span>
</label>
<LinkedChallengeEntryVisibilityManager
challenge={linkedChallengePreview}
hiddenIds={Array.isArray(form.data.hidden_linked_challenge_artwork_ids_json) ? form.data.hidden_linked_challenge_artwork_ids_json : []}
onToggle={toggleHiddenLinkedChallengeEntry}
error={form.errors.hidden_linked_challenge_artwork_ids_json}
/>
{linkedChallengePreview && linkedChallengeEntryPreviewItems.length === 0 ? <div className="rounded-2xl border border-dashed border-white/10 bg-white/[0.03] px-4 py-3 text-sm text-slate-400">No visible challenge entries are available yet for per-entry overrides.</div> : null}
</div>
</WorldEditorSection>
<WorldEditorSection title="Sections" description="Reorder and hide public sections without losing their underlying data or editorial intent.">
<WorldSectionToggleList
sectionOptions={sectionOptions}
order={form.data.section_order_json}
visibility={form.data.section_visibility_json}
relationCounts={relationCounts}
onChange={updateSectionControls}
/>
</WorldEditorSection>
</>
) : null}
{activeTab === 'suggestions' ? (
<WorldSuggestionsPanel
suggestions={suggestions}
notice={suggestionNotice}
worldExists={Boolean(world)}
busyKey={suggestionBusyKey}
onAddFeatured={(item, sectionKey) => addSuggestionRelation(item, sectionKey, true)}
onAddSection={(item, sectionKey, isFeatured = false) => addSuggestionRelation(item, sectionKey, isFeatured)}
onPin={pinSuggestion}
onDismiss={dismissSuggestion}
onNotRelevant={markSuggestionNotRelevant}
onRestore={restoreSuggestion}
/>
) : null}
{activeTab === 'publishing' ? (
<WorldEditorSection title="Publishing and timing" description="Control the public state, campaign dates, and recurrence lifecycle without mixing in community settings.">
<div className="mt-5 grid gap-4">
<div className="grid gap-4 md:grid-cols-2">
<NovaSelect label="Type" value={form.data.type || null} onChange={(nextValue) => form.setData('type', String(nextValue || ''))} options={typeOptions} searchable={false} className="bg-black/20" />
<NovaSelect label="Workflow status" value={form.data.status || null} onChange={(nextValue) => form.setData('status', String(nextValue || ''))} options={props.statusOptions || []} searchable={false} className="bg-black/20" />
</div>
<DateTimePicker label="Publish at" value={form.data.published_at} onChange={(nextValue) => form.setData('published_at', nextValue)} placeholder="Pick an automatic publish date" clearable className="bg-black/20" />
<div className="-mt-1 text-xs leading-5 text-slate-500">A future publish date keeps the world in the editorial pipeline until the release moment arrives automatically.</div>
<div className="grid gap-4 md:grid-cols-2">
<DateTimePicker label="Starts at" value={form.data.starts_at} onChange={(nextValue) => form.setData('starts_at', nextValue)} placeholder="Pick the campaign start" clearable className="bg-black/20" />
<DateTimePicker label="Ends at" value={form.data.ends_at} onChange={(nextValue) => form.setData('ends_at', nextValue)} placeholder="Pick the campaign end" clearable className="bg-black/20" />
</div>
<div className="-mt-1 text-xs leading-5 text-slate-500">The campaign window drives whether the world reads as upcoming, active, or finished on public surfaces.</div>
<div className="rounded-[24px] border border-white/10 bg-black/20 p-4">
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Activation and promotion</div>
<div className="mt-2 text-sm leading-6 text-slate-300">Use campaign activation for platform-level surfacing. Homepage feature is a stronger spotlight signal on top of that base state.</div>
<div className="mt-4 grid gap-3 md:grid-cols-2">
<div className="rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3">
<Checkbox
checked={form.data.is_active_campaign}
onChange={(event) => form.setData({
...form.data,
is_active_campaign: event.target.checked,
is_homepage_featured: event.target.checked ? form.data.is_homepage_featured : false,
})}
label="Mark as active campaign"
size={20}
variant="accent"
/>
<div className="mt-2 text-xs leading-5 text-slate-500">Active campaigns become eligible for stronger public surfacing on homepage, upload, and promoted worlds views.</div>
</div>
<div className="rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3">
<Checkbox
checked={form.data.is_homepage_featured}
onChange={(event) => form.setData({
...form.data,
is_homepage_featured: event.target.checked,
is_active_campaign: event.target.checked ? true : form.data.is_active_campaign,
})}
label="Feature on homepage spotlight"
size={20}
variant="accent"
/>
<div className="mt-2 text-xs leading-5 text-slate-500">Use this when the campaign should compete for the primary homepage spotlight position.</div>
</div>
</div>
<div className="mt-4 grid gap-4 md:grid-cols-2">
<label className="grid gap-2 text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Campaign priority</span>
<input type="number" min="0" max="9999" value={form.data.campaign_priority} onChange={(event) => form.setData('campaign_priority', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<span className="text-xs leading-5 text-slate-500">Higher values win when multiple campaigns are active at once.</span>
</label>
<label className="grid gap-2 text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Campaign label</span>
<input value={form.data.campaign_label} onChange={(event) => form.setData('campaign_label', event.target.value)} placeholder="Spring campaign, Returning seasonal world" className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
</label>
</div>
<div className="mt-4 grid gap-4 md:grid-cols-2">
<DateTimePicker label="Promotion starts" value={form.data.promotion_starts_at} onChange={(nextValue) => form.setData('promotion_starts_at', nextValue)} placeholder="Optional promotion start" clearable className="bg-black/20" />
<DateTimePicker label="Promotion ends" value={form.data.promotion_ends_at} onChange={(nextValue) => form.setData('promotion_ends_at', nextValue)} placeholder="Optional promotion end" clearable className="bg-black/20" />
</div>
<div className="mt-2 text-xs leading-5 text-slate-500">Promotion dates control when the world is highlighted beyond its own page. Leave them blank to use the main campaign window.</div>
</div>
<div className="grid gap-3 md:grid-cols-2">
<div className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3">
<Checkbox checked={form.data.is_featured} onChange={(event) => form.setData('is_featured', event.target.checked)} label="Feature on Worlds lists and supporting rails" size={20} variant="accent" />
<div className="mt-2 text-xs leading-5 text-slate-500">Keep this for secondary featured placement on worlds surfaces. Homepage spotlighting is controlled separately by active campaign and homepage feature.</div>
</div>
<div className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3">
<Checkbox checked={form.data.is_recurring} onChange={(event) => form.setData('is_recurring', event.target.checked)} label="Recurring world" size={20} variant="accent" />
<div className="mt-2 text-xs leading-5 text-slate-500">Use recurrence for annual or repeatable campaigns so new editions can be rolled forward cleanly.</div>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<label className="grid gap-2 text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Recurrence key</span>
<input value={form.data.recurrence_key} onChange={(event) => form.setData('recurrence_key', event.target.value)} placeholder="halloween, retro-month" className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
</label>
<label className="grid gap-2 text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Edition year</span>
<input type="number" min="2000" max="2100" value={form.data.edition_year} onChange={(event) => form.setData('edition_year', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
</label>
</div>
<label className="grid gap-2 text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Recurrence rule</span>
<input value={form.data.recurrence_rule} onChange={(event) => form.setData('recurrence_rule', event.target.value)} placeholder="annual:10, annual:12, campaign-window" className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
</label>
<WorldRecurrenceHelper
enabled={form.data.is_recurring}
recurrenceKey={form.data.recurrence_key}
editionYear={form.data.edition_year}
recurrenceKeyError={form.errors.recurrence_key}
editionYearError={form.errors.edition_year}
/>
{world?.is_recurring ? (
<div className="rounded-[24px] border border-white/10 bg-black/20 p-4 text-sm text-slate-300">
<div className="flex flex-wrap items-start justify-between gap-4">
<div>
<div className="text-sm font-semibold text-white">Recurring family status</div>
<p className="mt-1 max-w-2xl leading-6 text-slate-400">Track whether this editor is pointing at the canonical family edition, an archived edition, or a sibling inside the same recurrence group.</p>
</div>
<div className="flex flex-wrap gap-2">
<span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-200">{world.family_title || world.recurrence_key}</span>
<span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-200">Edition {world.edition_year || 'Draft'}</span>
<span className={`rounded-full border px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] ${world.is_canonical_edition ? 'border-emerald-300/20 bg-emerald-400/12 text-emerald-100' : 'border-amber-300/20 bg-amber-400/12 text-amber-100'}`}>{world.is_canonical_edition ? 'Canonical family edition' : 'Archive or sibling edition'}</span>
</div>
</div>
<div className="mt-4 grid gap-3 md:grid-cols-2 xl:grid-cols-4">
<div className="rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3">
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Family key</div>
<div className="mt-2 font-semibold text-white">{world.recurrence_key}</div>
</div>
<div className="rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3">
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Editions</div>
<div className="mt-2 font-semibold text-white">{world.family_edition_count || 1}</div>
</div>
<div className="rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3">
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Archive count</div>
<div className="mt-2 font-semibold text-white">{world.archive_edition_count || 0}</div>
</div>
<div className="rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3">
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Current public edition</div>
<div className="mt-2 font-semibold text-white">{world.current_edition?.title || 'None published yet'}</div>
</div>
</div>
<div className="mt-4 grid gap-3 md:grid-cols-3">
<a href={world.family_url || '#'} className={`rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3 ${world.family_url ? 'text-white' : 'pointer-events-none text-slate-500'}`}>
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Family route</div>
<div className="mt-2 text-sm font-semibold">{world.family_url ? 'Open canonical family page' : 'Unavailable'}</div>
</a>
<div className="rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3">
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Previous edition</div>
<div className="mt-2 text-sm font-semibold text-white">{world.previous_edition?.title || 'None'}</div>
</div>
<div className="rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3">
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Next edition</div>
<div className="mt-2 text-sm font-semibold text-white">{world.next_edition?.title || 'None yet'}</div>
</div>
</div>
</div>
) : null}
<WorldDuplicateActionMenu
duplicateUrl={duplicateActions?.duplicateUrl}
newEditionUrl={duplicateActions?.newEditionUrl}
canCreateEdition={Boolean(duplicateActions?.canCreateEdition)}
copyModeCount={Math.max(duplicateActions?.duplicateModeOptions?.length || 0, duplicateActions?.newEditionModeOptions?.length || 0)}
onDuplicate={() => runDuplicateAction(duplicateActions?.duplicateUrl, 'Duplicate this world into a new draft?', duplicateActions?.duplicateModeOptions || [])}
onCreateEdition={() => runDuplicateAction(duplicateActions?.newEditionUrl, 'Create the next edition draft from this world?', duplicateActions?.newEditionModeOptions || [])}
/>
</div>
</WorldEditorSection>
) : null}
{activeTab === 'community' ? (
<WorldEditorSection title="Community participation" description="Choose how creators can join the world, define the participation window, and moderate community placements without touching curated relations.">
<div className="grid gap-4">
<div className="rounded-[24px] border border-white/10 bg-black/20 p-4">
<div className="grid gap-4">
<div>
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Participation mode</div>
<div className="mt-2 text-sm leading-6 text-slate-300">Pick whether creator artworks need approval, go live automatically, or stay hidden from creator participation surfaces.</div>
</div>
<NovaSelect
label="Creator participation"
value={form.data.participation_mode || 'closed'}
onChange={(nextValue) => {
const nextMode = String(nextValue || 'closed')
form.setData({
...form.data,
participation_mode: nextMode,
accepts_submissions: nextMode !== 'closed',
})
}}
options={PARTICIPATION_MODE_OPTIONS}
searchable={false}
className="bg-black/20"
/>
<div className="grid gap-3 md:grid-cols-2">
<div className="rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3">
<Checkbox checked={form.data.submission_note_enabled} onChange={(event) => form.setData('submission_note_enabled', event.target.checked)} label="Allow creator notes" size={20} variant="accent" />
</div>
<div className="rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3">
<Checkbox checked={form.data.community_section_enabled} onChange={(event) => form.setData('community_section_enabled', event.target.checked)} label="Show community section on the public world page" size={20} variant="accent" />
</div>
</div>
<div className="rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3">
<Checkbox checked={form.data.allow_readd_after_removal} onChange={(event) => form.setData('allow_readd_after_removal', event.target.checked)} label="Allow creators to re-add artworks after removal" size={20} variant="accent" />
<div className="mt-2 text-xs leading-5 text-slate-500">Blocked artworks still stay locked until a moderator unblocks them.</div>
</div>
</div>
<div className="mt-4 grid gap-4 md:grid-cols-2">
<DateTimePicker label="Submission opens" value={form.data.submission_starts_at} onChange={(nextValue) => form.setData('submission_starts_at', nextValue)} placeholder="Optional submission start" clearable className="bg-black/20" />
<DateTimePicker label="Submission closes" value={form.data.submission_ends_at} onChange={(nextValue) => form.setData('submission_ends_at', nextValue)} placeholder="Optional submission end" clearable className="bg-black/20" />
</div>
<label className="mt-4 grid gap-2 text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Creator guidelines</span>
<textarea value={form.data.submission_guidelines} onChange={(event) => form.setData('submission_guidelines', event.target.value)} rows={5} className="rounded-[24px] border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" placeholder="Explain what qualifies, what you want creators to submit, and any world-specific expectations." />
<span className="text-xs leading-5 text-slate-500">These notes are shown to creators in upload and artwork-edit submission surfaces.</span>
</label>
</div>
{world ? (
<div className="rounded-[24px] border border-white/10 bg-black/20 p-4">
<div className="flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between">
<div>
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Submission review queue</div>
<div className="mt-2 text-sm leading-6 text-slate-300">Moderate creator participation without converting those artworks into curated world relations.</div>
</div>
<div className="flex flex-wrap gap-2 text-xs">
{[
['Pending', reviewQueue?.counts?.pending || 0, 'border-sky-300/25 bg-sky-400/10 text-sky-100'],
['Live', reviewQueue?.counts?.live || 0, 'border-emerald-300/25 bg-emerald-400/10 text-emerald-100'],
['Featured', reviewQueue?.counts?.featured || 0, 'border-amber-300/25 bg-amber-400/10 text-amber-100'],
['Removed', reviewQueue?.counts?.removed || 0, 'border-orange-300/25 bg-orange-400/10 text-orange-100'],
['Blocked', reviewQueue?.counts?.blocked || 0, 'border-rose-300/25 bg-rose-400/10 text-rose-100'],
].map(([label, count, tone]) => (
<span key={label} className={`rounded-full border px-3 py-1 font-semibold uppercase tracking-[0.14em] ${tone}`}>{label}: {count}</span>
))}
</div>
</div>
<div className="mt-4 grid gap-3">
{Array.isArray(reviewQueue?.items) && reviewQueue.items.length > 0 ? reviewQueue.items.map((item) => (
<div key={item.id} className="rounded-2xl border border-white/10 bg-white/[0.03] p-4">
<div className="flex flex-col gap-4 xl:flex-row">
<div className="h-24 w-24 shrink-0 overflow-hidden rounded-[20px] border border-white/10 bg-slate-950/80">
{item.artwork?.thumbnail_url ? <img src={item.artwork.thumbnail_url} alt="" className="h-full w-full object-cover" /> : <div className="flex h-full items-center justify-center text-slate-500"><i className="fa-solid fa-image" /></div>}
</div>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<a href={item.artwork?.edit_url || item.artwork?.url || '#'} className="text-base font-semibold text-white hover:text-sky-100">{item.artwork?.title || 'Untitled artwork'}</a>
<span className="rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.14em] text-slate-300">{item.status_label}</span>
</div>
<div className="mt-1 text-xs uppercase tracking-[0.16em] text-slate-500">{item.submitted_by?.name || item.artwork?.creator_name || 'Unknown creator'}</div>
{Array.isArray(item.artwork?.meta) && item.artwork.meta.length > 0 ? <div className="mt-3 flex flex-wrap gap-2">{item.artwork.meta.map((entry) => <span key={entry} className="rounded-full border border-white/10 bg-black/20 px-3 py-1 text-xs text-slate-300">{entry}</span>)}</div> : null}
{Array.isArray(item.world_rewards) && item.world_rewards.length > 0 ? (
<div className="mt-3 flex flex-wrap gap-2">
{item.world_rewards.map((reward) => (
<span key={reward.id} className="rounded-full border border-sky-300/20 bg-sky-400/10 px-3 py-1 text-xs font-semibold uppercase tracking-[0.14em] text-sky-100">{reward.reward_label}</span>
))}
</div>
) : null}
{item.note ? <div className="mt-3 rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm leading-6 text-slate-300"><div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Creator note</div><div className="mt-2">{item.note}</div></div> : null}
{item.reviewer_note ? <div className="mt-3 rounded-2xl border border-amber-300/20 bg-amber-400/10 px-4 py-3 text-sm leading-6 text-amber-50"><div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-amber-100/80">Moderator note</div><div className="mt-2">{item.reviewer_note}</div></div> : null}
</div>
<div className="flex shrink-0 flex-wrap gap-2 xl:w-[220px] xl:flex-col">
{item.status === 'pending' ? <button type="button" onClick={() => runSubmissionAction(item.actions?.approve, 'Approve this submission and make it live?', { title: 'Approve submission?', confirmLabel: 'Approve' })} className="rounded-2xl border border-emerald-300/20 bg-emerald-400/10 px-4 py-2 text-sm font-semibold text-emerald-100">Approve</button> : null}
{item.status === 'removed' ? <button type="button" onClick={() => runSubmissionAction(item.actions?.restore, 'Restore this submission to the live community section?', { title: 'Restore submission?', confirmLabel: 'Restore' })} className="rounded-2xl border border-emerald-300/20 bg-emerald-400/10 px-4 py-2 text-sm font-semibold text-emerald-100">Restore</button> : null}
{item.status === 'blocked' ? <button type="button" onClick={() => runSubmissionAction(item.actions?.unblock, 'Unblock this artwork so it can be restored or re-added later?', { title: 'Unblock artwork?', confirmLabel: 'Unblock', confirmTone: 'accent' })} className="rounded-2xl border border-sky-300/20 bg-sky-400/10 px-4 py-2 text-sm font-semibold text-sky-100">Unblock</button> : null}
{item.status !== 'blocked' ? <button type="button" onClick={() => runSubmissionAction(item.is_featured ? item.actions?.unfeature : item.actions?.feature, item.is_featured ? 'Remove this artwork from featured community placement?' : 'Feature this artwork in the public community section?', { title: item.is_featured ? 'Unfeature artwork?' : 'Feature artwork?', confirmLabel: item.is_featured ? 'Unfeature' : 'Feature' })} className="rounded-2xl border border-amber-300/20 bg-amber-400/10 px-4 py-2 text-sm font-semibold text-amber-100">{item.is_featured ? 'Unfeature' : 'Feature'}</button> : null}
{item.status !== 'pending' && item.status !== 'blocked' ? <button type="button" onClick={() => runSubmissionAction(item.actions?.pending, 'Return this submission to pending review?', { title: 'Return to pending?', confirmLabel: 'Set pending' })} className="rounded-2xl border border-sky-300/20 bg-sky-400/10 px-4 py-2 text-sm font-semibold text-sky-100">Set pending</button> : null}
{item.can_grant_manual_rewards ? <button type="button" onClick={() => runSubmissionAction(item.actions?.grant_rewards?.winner, 'Grant winner recognition to this creator for this world edition?', { title: 'Grant winner reward?', confirmLabel: 'Grant winner', noteEnabled: true })} className="rounded-2xl border border-emerald-300/20 bg-emerald-400/10 px-4 py-2 text-sm font-semibold text-emerald-100">Winner</button> : null}
{item.can_grant_manual_rewards ? <button type="button" onClick={() => runSubmissionAction(item.actions?.grant_rewards?.finalist, 'Grant finalist recognition to this creator for this world edition?', { title: 'Grant finalist reward?', confirmLabel: 'Grant finalist', noteEnabled: true })} className="rounded-2xl border border-violet-300/20 bg-violet-400/10 px-4 py-2 text-sm font-semibold text-violet-100">Finalist</button> : null}
{item.can_grant_manual_rewards ? <button type="button" onClick={() => runSubmissionAction(item.actions?.grant_rewards?.spotlight, 'Grant spotlight recognition to this creator for this world edition?', { title: 'Grant spotlight reward?', confirmLabel: 'Grant spotlight', noteEnabled: true })} className="rounded-2xl border border-rose-300/20 bg-rose-400/10 px-4 py-2 text-sm font-semibold text-rose-100">Spotlight</button> : null}
{Array.isArray(item.world_rewards) && item.world_rewards.some((reward) => reward.reward_type === 'winner') ? <button type="button" onClick={() => runSubmissionAction(item.actions?.revoke_rewards?.winner, 'Revoke winner recognition for this creator in this world edition?', { title: 'Revoke winner reward?', confirmLabel: 'Revoke winner', confirmTone: 'danger' })} className="rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-2 text-sm font-semibold text-slate-200">Revoke winner</button> : null}
{Array.isArray(item.world_rewards) && item.world_rewards.some((reward) => reward.reward_type === 'finalist') ? <button type="button" onClick={() => runSubmissionAction(item.actions?.revoke_rewards?.finalist, 'Revoke finalist recognition for this creator in this world edition?', { title: 'Revoke finalist reward?', confirmLabel: 'Revoke finalist', confirmTone: 'danger' })} className="rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-2 text-sm font-semibold text-slate-200">Revoke finalist</button> : null}
{Array.isArray(item.world_rewards) && item.world_rewards.some((reward) => reward.reward_type === 'spotlight') ? <button type="button" onClick={() => runSubmissionAction(item.actions?.revoke_rewards?.spotlight, 'Revoke spotlight recognition for this creator in this world edition?', { title: 'Revoke spotlight reward?', confirmLabel: 'Revoke spotlight', confirmTone: 'danger' })} className="rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-2 text-sm font-semibold text-slate-200">Revoke spotlight</button> : null}
{item.status !== 'removed' && item.status !== 'blocked' ? <button type="button" onClick={() => runSubmissionAction(item.actions?.remove, 'Remove this artwork from community participation for this world?', { title: 'Remove artwork?', confirmLabel: 'Remove', cancelLabel: 'Keep live', confirmTone: 'danger', noteEnabled: true })} className="rounded-2xl border border-orange-300/20 bg-orange-400/10 px-4 py-2 text-sm font-semibold text-orange-100">Remove</button> : null}
{item.status !== 'blocked' ? <button type="button" onClick={() => runSubmissionAction(item.actions?.block, 'Block this artwork from re-adding to this world until a moderator clears it?', { title: 'Block artwork?', confirmLabel: 'Block', cancelLabel: 'Cancel', confirmTone: 'danger', noteEnabled: true })} className="rounded-2xl border border-rose-300/20 bg-rose-400/10 px-4 py-2 text-sm font-semibold text-rose-100">Block</button> : null}
</div>
</div>
</div>
)) : <div className="rounded-2xl border border-dashed border-white/10 bg-white/[0.02] p-4 text-sm text-slate-400">No submissions have been attached to this world yet.</div>}
</div>
</div>
) : null}
</div>
</WorldEditorSection>
) : null}
{activeTab === 'presentation' ? (
<>
<WorldEditorSection title="Theme and identity" description="Apply theme presets and tune the visual language that drives the worlds mood and recognition.">
<div className="mt-5 grid gap-4">
<NovaSelect
label="Theme preset"
value={form.data.theme_key || null}
onChange={(nextValue) => handleThemeChange(String(nextValue || ''))}
options={[{ value: '', label: 'No preset' }, ...themeOptions]}
searchable={false}
className="bg-black/20"
/>
<WorldThemePresetHelper theme={selectedTheme} onApply={() => handleThemeChange(form.data.theme_key, true)} />
<div className="grid gap-4 md:grid-cols-2">
<label className="grid gap-2 text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Accent color</span>
<input value={form.data.accent_color} onChange={(event) => form.setData('accent_color', event.target.value)} placeholder="#f97316" className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
</label>
<label className="grid gap-2 text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Secondary accent</span>
<input value={form.data.accent_color_secondary} onChange={(event) => form.setData('accent_color_secondary', event.target.value)} placeholder="#0f172a" className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
</label>
</div>
<div className="grid gap-4 md:grid-cols-2">
<label className="grid gap-2 text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Background motif</span>
<input value={form.data.background_motif} onChange={(event) => form.setData('background_motif', event.target.value)} placeholder="embers, frost, scanlines" className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
</label>
<label className="grid gap-2 text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Icon class</span>
<input value={form.data.icon_name} onChange={(event) => form.setData('icon_name', event.target.value)} placeholder="fa-solid fa-ghost" className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
</label>
</div>
</div>
</WorldEditorSection>
<WorldEditorSection title="Media assets" description={props.mediaSupport?.helper_text}>
<div className="mt-5 grid gap-4">
<WorldMediaUploadField
label="Cover image"
slot="cover"
value={form.data.cover_path}
previewUrl={resolveMediaUrl(form.data.cover_path, world?.cover_path === form.data.cover_path ? world?.cover_url || '' : '', filesBaseUrl)}
emptyLabel="No cover selected"
helperText="Use the strongest hero asset you have so the world reads clearly in spotlight, cards, and preview states."
uploadUrl={props.mediaSupport?.upload_url}
deleteUrl={props.mediaSupport?.delete_url}
worldId={world?.id || null}
isTemporaryValue={temporaryMediaPaths.cover !== '' && temporaryMediaPaths.cover === form.data.cover_path}
accept={(props.mediaSupport?.accepted_mime_types || []).join(',') || 'image/jpeg,image/png,image/webp'}
maxFileSizeMb={props.mediaSupport?.max_file_size_mb || 6}
onChange={({ path }) => {
form.setData('cover_path', path)
setTemporaryMediaPaths((current) => ({ ...current, cover: path || '' }))
}}
/>
<WorldMediaUploadField
label="Teaser image"
slot="teaser"
value={form.data.teaser_image_path}
previewUrl={resolveMediaUrl(form.data.teaser_image_path, world?.teaser_image_path === form.data.teaser_image_path ? world?.teaser_image_url || '' : '', filesBaseUrl)}
emptyLabel="Falls back to cover image when blank"
helperText="Use a promo-specific image when homepage, upload, or world index surfaces need a tighter campaign crop than the full world cover."
uploadUrl={props.mediaSupport?.upload_url}
deleteUrl={props.mediaSupport?.delete_url}
worldId={world?.id || null}
isTemporaryValue={temporaryMediaPaths.teaser !== '' && temporaryMediaPaths.teaser === form.data.teaser_image_path}
accept={(props.mediaSupport?.accepted_mime_types || []).join(',') || 'image/jpeg,image/png,image/webp'}
maxFileSizeMb={props.mediaSupport?.max_file_size_mb || 6}
onChange={({ path }) => {
form.setData('teaser_image_path', path)
setTemporaryMediaPaths((current) => ({ ...current, teaser: path || '' }))
}}
/>
<div className="grid gap-3">
<WorldMediaUploadField
label="OG image"
slot="og"
value={form.data.og_image_path}
previewUrl={resolveMediaUrl(form.data.og_image_path, world?.og_image_path === form.data.og_image_path ? world?.og_image_url || '' : '', filesBaseUrl)}
emptyLabel="Falls back to cover image when blank"
helperText="Upload a dedicated social share image, or leave it blank and use the cover image fallback."
uploadUrl={props.mediaSupport?.upload_url}
deleteUrl={props.mediaSupport?.delete_url}
worldId={world?.id || null}
isTemporaryValue={temporaryMediaPaths.og !== '' && temporaryMediaPaths.og === form.data.og_image_path}
accept={(props.mediaSupport?.accepted_mime_types || []).join(',') || 'image/jpeg,image/png,image/webp'}
maxFileSizeMb={props.mediaSupport?.max_file_size_mb || 6}
onChange={({ path }) => {
form.setData('og_image_path', path)
setTemporaryMediaPaths((current) => ({ ...current, og: path || '' }))
}}
/>
<div className="flex justify-end">
<button
type="button"
onClick={applyCoverToOg}
disabled={!form.data.cover_path}
className="rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-sm font-semibold text-white disabled:opacity-50"
>
Use cover
</button>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="rounded-[24px] border border-white/10 bg-black/20 p-4">
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Cover preview</div>
<div className="mt-3 h-40 overflow-hidden rounded-[20px] border border-white/10 bg-slate-950">{previewWorld.cover_url ? <img src={previewWorld.cover_url} alt="" className="h-full w-full object-cover" /> : <div className="flex h-full items-center justify-center text-sm text-slate-500">No cover selected</div>}</div>
</div>
<div className="rounded-[24px] border border-white/10 bg-black/20 p-4">
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">OG preview</div>
<div className="mt-3 h-40 overflow-hidden rounded-[20px] border border-white/10 bg-slate-950">{form.data.og_image_path || previewWorld.cover_url ? <img src={resolveMediaUrl(form.data.og_image_path || form.data.cover_path, form.data.og_image_path ? (world?.og_image_path === form.data.og_image_path ? world?.og_image_url || '' : '') : (world?.cover_path === form.data.cover_path ? world?.cover_url || '' : ''), filesBaseUrl)} alt="" className="h-full w-full object-cover" /> : <div className="flex h-full items-center justify-center text-sm text-slate-500">Falls back to cover image when blank</div>}</div>
</div>
</div>
</div>
</WorldEditorSection>
<WorldEditorSection title="CTA and badge" description="Handle the promotional copy and campaign affordances that sit around the world, not inside its core story.">
<div className="mt-5 grid gap-4">
<div className="grid gap-4 md:grid-cols-2">
<label className="grid gap-2 text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Teaser title</span>
<input value={form.data.teaser_title} onChange={(event) => form.setData('teaser_title', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<span className="text-xs leading-5 text-slate-500">Optional. Use this for homepage, upload, and world-card headlines without changing the canonical world title.</span>
</label>
<label className="grid gap-2 text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Teaser summary</span>
<textarea value={form.data.teaser_summary} onChange={(event) => form.setData('teaser_summary', event.target.value)} rows={4} className="rounded-[24px] border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
</label>
</div>
<div className="grid gap-4 md:grid-cols-2">
<label className="grid gap-2 text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">CTA label</span>
<input value={form.data.cta_label} onChange={(event) => form.setData('cta_label', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<span className="text-xs leading-5 text-slate-500">Keep this action specific to the campaign intent: explore, join, enter, submit, or discover.</span>
</label>
<label className="grid gap-2 text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">CTA URL</span>
<input value={form.data.cta_url} onChange={(event) => form.setData('cta_url', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<span className="text-xs leading-5 text-slate-500">Point this at the destination that matches the world, such as a challenge, collection, landing page, or participation flow.</span>
</label>
</div>
<div className="grid gap-4 md:grid-cols-2">
<label className="grid gap-2 text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Badge label</span>
<input value={form.data.badge_label} onChange={(event) => form.setData('badge_label', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<span className="text-xs leading-5 text-slate-500">Use concise editorial language that still reads cleanly in the hero, cards, and spotlight surfaces.</span>
</label>
<label className="grid gap-2 text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Badge URL</span>
<input value={form.data.badge_url} onChange={(event) => form.setData('badge_url', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<span className="text-xs leading-5 text-slate-500">Optional. Use this when the badge should send visitors to a participation or explanation destination.</span>
</label>
</div>
<label className="grid gap-2 text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Badge description</span>
<textarea value={form.data.badge_description} onChange={(event) => form.setData('badge_description', event.target.value)} rows={4} className="rounded-[24px] border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
</label>
<label className="grid gap-2 text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Related tags</span>
<input value={tagString} onChange={(event) => form.setData('related_tags_json', event.target.value.split(',').map((tag) => tag.trim()).filter(Boolean))} placeholder="halloween, spooky, demoscene" className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<span className="text-xs leading-5 text-slate-500">Use these to reinforce the campaign identity and keep recurring presets consistent across editions.</span>
</label>
</div>
</WorldEditorSection>
</>
) : null}
{activeTab === 'recap' ? (
<>
<WorldEditorSection title="Recap framing" description="Prepare the archive-facing editorial layer that turns an ended edition into a meaningful recap instead of a quiet archive shell.">
<div className="grid gap-4">
<div className="grid gap-3 md:grid-cols-[minmax(0,1fr)_auto] md:items-start">
<div className="rounded-[24px] border border-white/10 bg-black/20 p-4">
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Recap workflow</div>
<div className="mt-3 flex flex-wrap gap-2">
<span className={`rounded-full border px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] ${form.data.recap_status === 'published' ? 'border-sky-300/25 bg-sky-400/10 text-sky-100' : 'border-white/10 bg-white/[0.04] text-slate-200'}`}>{form.data.recap_status === 'published' ? 'Published recap' : 'Draft recap'}</span>
{world?.recap_published_at ? <span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-200">Published {new Date(world.recap_published_at).toLocaleDateString()}</span> : null}
{canPublishRecap ? <span className="rounded-full border border-emerald-300/20 bg-emerald-400/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-emerald-100">Ready to publish</span> : <span className="rounded-full border border-amber-300/20 bg-amber-400/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-amber-100">Publish after the edition ends</span>}
</div>
<p className="mt-3 text-sm leading-6 text-slate-300">Recap stays editorial but data-backed. Curated world relations, challenge outcomes, community highlights, and analytics snapshots all flow into the public recap once you publish it.</p>
</div>
<div className="rounded-[24px] border border-white/10 bg-black/20 p-4 text-sm text-slate-300">
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Primary recap metrics</div>
<div className="mt-3 grid gap-3">
{recapStatsSummary ? (
<>
<div className="rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3"><div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Views</div><div className="mt-2 text-lg font-semibold text-white">{formatCompactNumber(recapStatsSummary.views)}</div></div>
<div className="rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3"><div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Submissions</div><div className="mt-2 text-lg font-semibold text-white">{formatCompactNumber(recapStatsSummary.submissions)}</div></div>
<div className="rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3"><div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Recognitions</div><div className="mt-2 text-lg font-semibold text-white">{formatCompactNumber(recapStatsSummary.reward_grants)}</div></div>
</>
) : <div className="rounded-2xl border border-dashed border-white/10 bg-white/[0.03] px-4 py-3 text-sm text-slate-400">A recap snapshot will be captured when you publish the recap.</div>}
</div>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<label className="grid gap-2 text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Recap title</span>
<input value={form.data.recap_title} onChange={(event) => form.setData('recap_title', event.target.value)} placeholder="Halloween World 2026 recap" className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<span className="text-xs leading-5 text-slate-500">Leave blank to fall back to {form.data.title || 'World'} recap.</span>
</label>
<label className="grid gap-2 text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Recap summary</span>
<textarea value={form.data.recap_summary} onChange={(event) => form.setData('recap_summary', event.target.value)} rows={4} placeholder="Short archive-facing summary for the recap hero." className="rounded-[24px] border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
</label>
</div>
<div className="grid gap-2 text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Recap intro</span>
<RichTextEditor
content={form.data.recap_intro}
onChange={(nextValue) => form.setData('recap_intro', nextValue)}
placeholder="Write the archive-facing intro that frames what made this edition memorable."
error={form.errors.recap_intro}
minHeight={14}
autofocus={false}
/>
</div>
<WorldMediaUploadField
label="Recap cover"
slot="recap_cover"
value={form.data.recap_cover_path}
previewUrl={recapCoverPreviewUrl}
emptyLabel="Falls back to the main world cover when blank"
helperText="Optional. Use a calmer archive-facing image when the recap should feel distinct from the live campaign hero."
uploadUrl={props.mediaSupport?.upload_url}
deleteUrl={props.mediaSupport?.delete_url}
worldId={world?.id || null}
isTemporaryValue={temporaryMediaPaths.recap !== '' && temporaryMediaPaths.recap === form.data.recap_cover_path}
accept={(props.mediaSupport?.accepted_mime_types || []).join(',') || 'image/jpeg,image/png,image/webp'}
maxFileSizeMb={props.mediaSupport?.max_file_size_mb || 6}
onChange={({ path }) => {
form.setData('recap_cover_path', path)
setTemporaryMediaPaths((current) => ({ ...current, recap: path || '' }))
}}
/>
<label className="grid gap-2 text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Editor note</span>
<textarea value={form.data.recap_editor_note} onChange={(event) => form.setData('recap_editor_note', event.target.value)} rows={4} placeholder="Internal notes for editorial context, follow-up cleanup, or recap publishing reminders." className="rounded-[24px] border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<span className="text-xs leading-5 text-slate-500">Internal only. This note is stored with the recap draft but is not shown on the public recap page.</span>
</label>
</div>
</WorldEditorSection>
<WorldEditorSection title="Recap article and publish state" description="Link one editorial story when the recap should point visitors toward a fuller write-up, results article, or reflective editorial piece.">
<div className="grid gap-4">
<div className="rounded-[24px] border border-white/10 bg-black/20 p-4">
{recapArticlePreview ? (
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
<div className="flex min-w-0 gap-4">
<div className="h-16 w-16 shrink-0 overflow-hidden rounded-[20px] border border-white/10 bg-slate-950">
{recapArticlePreview.image ? <img src={recapArticlePreview.image} alt="" className="h-full w-full object-cover" /> : <div className="flex h-full items-center justify-center text-slate-500"><i className="fa-solid fa-newspaper" /></div>}
</div>
<div className="min-w-0">
<div className="text-base font-semibold text-white">{recapArticlePreview.title}</div>
{recapArticlePreview.subtitle ? <div className="mt-1 text-xs uppercase tracking-[0.14em] text-slate-500">{recapArticlePreview.subtitle}</div> : null}
{recapArticlePreview.description ? <div className="mt-2 text-sm leading-6 text-slate-400">{recapArticlePreview.description}</div> : null}
</div>
</div>
<div className="flex shrink-0 flex-wrap gap-2">
<button type="button" onClick={() => setRecapArticlePickerOpen(true)} className="rounded-2xl border border-sky-300/20 bg-sky-400/10 px-4 py-2 text-sm font-semibold text-sky-100">Change</button>
<button type="button" onClick={clearRecapArticle} className="rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-2 text-sm font-semibold text-slate-200">Clear</button>
</div>
</div>
) : (
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div>
<div className="text-sm font-semibold text-white">No recap article linked</div>
<p className="mt-1 text-sm leading-6 text-slate-400">Link an article when this recap should push visitors toward a fuller editorial recap, results post, or archive story.</p>
</div>
<button type="button" onClick={() => setRecapArticlePickerOpen(true)} className="rounded-2xl border border-sky-300/20 bg-sky-400/10 px-4 py-2 text-sm font-semibold text-sky-100">Select article</button>
</div>
)}
</div>
<div className="rounded-[24px] border border-white/10 bg-black/20 p-4 text-sm leading-6 text-slate-300">
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Publish behavior</div>
<p className="mt-2">Publishing recap captures a stats snapshot and makes the recap-first archive layout public for ended editions. You can keep refining the draft copy before that point.</p>
</div>
</div>
</WorldEditorSection>
</>
) : null}
{activeTab === 'seo' ? (
<WorldEditorSection title="SEO" description="Set metadata that travels with the world into search results, previews, and social cards.">
<div className="mt-5 grid gap-4">
<label className="grid gap-2 text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">SEO title</span>
<input value={form.data.seo_title} onChange={(event) => form.setData('seo_title', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
</label>
<label className="grid gap-2 text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">SEO description</span>
<textarea value={form.data.seo_description} onChange={(event) => form.setData('seo_description', event.target.value)} rows={4} className="rounded-[24px] border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
</label>
</div>
</WorldEditorSection>
) : null}
{activeTab === 'analytics' ? (
<WorldEditorSection title="World analytics" description="Use campaign-useful metrics to judge traffic, editorial engagement, participation quality, challenge impact, and recurring-edition performance.">
<WorldAnalyticsPanel analytics={world?.analytics || null} world={world} />
</WorldEditorSection>
) : null}
</section>
<section className="space-y-6 xl:sticky xl:top-6 xl:max-h-[calc(100vh-48px)] xl:self-start xl:overflow-y-auto xl:overscroll-contain xl:pr-1 nova-scrollbar">
<WorldEditorActionPanel
formProcessing={form.processing}
isEditing={Boolean(props.updateUrl)}
publishUrl={props.publishUrl}
publishRecapUrl={props.publishRecapUrl}
canPublishRecap={canPublishRecap}
recapStatusLabel={world?.recap_status_label || (form.data.recap_status === 'published' ? 'Published recap' : 'Draft recap')}
archiveUrl={props.archiveUrl}
publicUrl={world?.urls?.public}
/>
<WorldSummaryCard world={form.data} themeLabel={selectedTheme?.label || ''} relationCount={form.data.relations.length} enabledSectionsCount={enabledSectionsCount} />
<div className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Current focus</div>
<div className="mt-2 flex items-center gap-2 text-white">
<i className={`${currentTab?.icon || 'fa-solid fa-pen-ruler'} text-[11px] text-sky-200`} aria-hidden="true" />
<span className="text-base font-semibold">{currentTab?.label || 'Editor'}</span>
</div>
<p className="mt-3 text-sm leading-6 text-slate-300">{currentTab?.description}</p>
</div>
<WorldMiniPreviewPanel world={previewWorld} sections={previewSections} previewUrl={props.previewUrl} />
</section>
</form>
<WorldRelationPickerModal
open={pickerState.open}
onClose={closeRelationPicker}
onSave={saveRelation}
initialRelation={editingRelation}
sectionOptions={sectionOptions}
relationTypeOptions={relationTypeOptions}
searchEntities={searchEntities}
/>
<WorldLinkedChallengePickerModal
open={linkedChallengePickerOpen}
onClose={() => setLinkedChallengePickerOpen(false)}
onSave={saveLinkedChallenge}
initialChallenge={linkedChallengePreview}
searchEntities={searchEntities}
/>
<WorldRecapArticlePickerModal
open={recapArticlePickerOpen}
onClose={() => setRecapArticlePickerOpen(false)}
onSave={saveRecapArticle}
initialArticle={recapArticlePreview}
searchEntities={searchEntities}
/>
<NovaConfirmDialog
open={actionConfirm.open}
title={actionConfirm.title}
message={actionConfirm.message}
confirmLabel={actionConfirm.confirmLabel}
cancelLabel={actionConfirm.cancelLabel}
confirmTone={actionConfirm.confirmTone}
onConfirm={confirmAction}
onClose={closeActionConfirm}
busy={actionBusy}
>
{actionConfirm.copyModeEnabled ? (
<div className="grid gap-2">
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Copy mode</div>
<div className="grid gap-2">
{(actionConfirm.copyModeOptions || []).map((option) => (
<label key={option.value} className="flex cursor-pointer items-start gap-3 rounded-[20px] border border-white/10 bg-black/20 px-4 py-3 text-sm text-slate-300">
<input
type="radio"
name="world-copy-mode"
value={option.value}
checked={actionCopyMode === option.value}
onChange={(event) => setActionCopyMode(event.target.value)}
className="mt-1"
/>
<span>
<span className="block font-semibold text-white">{option.label}</span>
{option.description ? <span className="mt-1 block text-xs leading-5 text-slate-400">{option.description}</span> : null}
</span>
</label>
))}
</div>
</div>
) : null}
{actionConfirm.noteEnabled ? (
<label className="grid gap-2 text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Moderator note</span>
<textarea
value={actionReviewNote}
onChange={(event) => setActionReviewNote(event.target.value)}
rows={4}
className="rounded-[24px] border border-white/10 bg-black/20 px-4 py-3 text-white outline-none"
placeholder="Explain the moderation decision so creators understand what changed."
/>
</label>
) : null}
</NovaConfirmDialog>
</StudioLayout>
)
}