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 (
{actionConfirm.noteEnabled ? (
) : null}
)
}