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 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 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 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 } const DEFAULT_ACTION_CONFIRM = { open: false, url: '', title: 'Please confirm', message: '', confirmLabel: 'Confirm', cancelLabel: 'Cancel', confirmTone: 'danger', noteEnabled: false, 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), type: type?.label || formData.type || 'Seasonal', badge_label: formData.badge_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), } } 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: '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: 'seo', label: 'SEO', icon: 'fa-solid fa-magnifying-glass-chart', description: 'Search and social metadata that ships with the world page.', }, ] const WORLD_EDITOR_TAB_FIELDS = { basics: ['title', 'slug', 'tagline', 'summary', 'description'], structure: ['relations', 'section_order_json', 'section_visibility_json'], 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', 'is_featured', 'is_recurring', 'recurrence_key', 'recurrence_rule', 'edition_year'], presentation: ['theme_key', 'accent_color', 'accent_color_secondary', 'background_motif', 'icon_name', 'cover_path', 'og_image_path', 'cta_label', 'cta_url', 'badge_label', 'badge_description', 'badge_url', 'related_tags_json'], seo: ['seo_title', 'seo_description'], } 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 WorldEditorActionPanel({ formProcessing, isEditing, publishUrl, archiveUrl, publicUrl }) { return (

Campaign actions

Keep save, publish, archive, and public-page actions reachable while reviewing the campaign summary.

Sticky
{publishUrl ? : null} {archiveUrl ? : null} {publicUrl ? Public page : 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 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) => ({ 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, query: relation.preview?.title || '', }))) : [] const form = useForm({ title: world?.title || '', slug: world?.slug || '', tagline: world?.tagline || '', summary: world?.summary || '', description: world?.description || '', cover_path: world?.cover_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), 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_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 || '', 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 || '', 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 [activeTab, setActiveTab] = useState('basics') const [temporaryMediaPaths, setTemporaryMediaPaths] = useState({ cover: '', og: '', }) 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 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 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 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 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 '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 'seo': meta = form.data.seo_title || form.data.seo_description ? 'Configured' : 'Optional metadata' break default: meta = '' } return { ...tab, meta, errorCount: tabErrorCounts[tab.id] || 0, } }), [form.data.participation_mode, form.data.title, form.data.relations.length, form.data.is_recurring, form.data.seo_description, form.data.seo_title, reviewQueue?.counts?.live, reviewQueue?.counts?.pending, selectedTheme?.label, tabErrorCounts]) 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 [actionBusy, setActionBusy] = useState(false) 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: '', og: '' }) }, } 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('') } const openActionConfirm = (config) => { if (!config?.url) return setActionReviewNote('') setActionConfirm({ ...DEFAULT_ACTION_CONFIRM, ...config, open: true, }) } const confirmAction = () => { if (!actionConfirm.url || actionBusy) return const payload = actionConfirm.noteEnabled ? { review_note: actionReviewNote } : {} setActionBusy(true) router.post(actionConfirm.url, payload, { preserveScroll: actionConfirm.preserveScroll, onSuccess: () => { setActionConfirm(DEFAULT_ACTION_CONFIRM) setActionReviewNote('') }, onFinish: () => { setActionBusy(false) }, }) } const runDuplicateAction = (url, promptText) => { openActionConfirm({ url, title: 'Duplicate world?', message: promptText, confirmLabel: 'Continue', cancelLabel: 'Cancel', confirmTone: 'accent', 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, }) } 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 (