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 (
{errorEntries.length > 0 ? (
Fix the highlighted editor issues before publishing.
{firstErrorTab ?
The first blocked section is {firstErrorTab.label}.
: null}
{errorEntries.slice(0, 8).map(([key, message]) =>
{message}
)}
) : null}
{editorTabs.map((tab) => { const isActive = activeTab === tab.id return (