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 (
{title}
{description ?
{description}
: null}
{actions}
{children}
)
}
function LinkedChallengeCard({ challenge, onChange, onClear }) {
return (
{challenge ? (
{challenge.image ?

:
}
{challenge.title}
{challenge.entity_label ?
{challenge.entity_label} : null}
{challenge.subtitle ?
{challenge.subtitle}
: null}
{challenge.description ?
{challenge.description}
: null}
{Array.isArray(challenge.meta) && challenge.meta.length > 0 ?
{challenge.meta.map((entry) => {entry})}
: null}
) : (
No primary challenge linked
Link one group challenge when this world should automatically surface challenge status, entries, and winner context.
)}
)
}
function WorldEditorActionPanel({ formProcessing, isEditing, publishUrl, publishRecapUrl, canPublishRecap, recapStatusLabel, archiveUrl, publicUrl }) {
return (
Campaign actions
Keep save, publish, archive, and public-page actions reachable while reviewing the campaign summary.
Sticky
{publishUrl ?
: null}
{publishRecapUrl ?
: null}
{archiveUrl ?
: null}
{publicUrl ?
Public page : null}
)
}
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 (
Entry visibility overrides
Hide specific linked challenge entries from the derived world feed when moderation or editorial context requires it.
{hiddenIds.length} hidden
{items.map((item) => {
const hidden = hiddenIds.includes(item.id)
const statusLabel = item.status === 'winner' ? 'Winner' : item.status === 'finalist' ? 'Finalist' : 'Entry'
return (
)
})}
{error ?
{error}
: null}
)
}
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 (
setLinkedChallengePickerOpen(false)}
onSave={saveLinkedChallenge}
initialChallenge={linkedChallengePreview}
searchEntities={searchEntities}
/>
setRecapArticlePickerOpen(false)}
onSave={saveRecapArticle}
initialArticle={recapArticlePreview}
searchEntities={searchEntities}
/>
{actionConfirm.copyModeEnabled ? (
) : null}
{actionConfirm.noteEnabled ? (
) : null}
)
}