Files
SkinbaseNova/resources/js/Pages/Studio/StudioWorldEditor.jsx
2026-04-18 17:02:56 +02:00

1073 lines
64 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useEffect, useMemo, useState } from 'react'
import { router, useForm, usePage } from '@inertiajs/react'
import StudioLayout from '../../Layouts/StudioLayout'
import RichTextEditor from '../../components/forum/RichTextEditor'
import { Checkbox, DateTimePicker, NovaSelect } from '../../components/ui'
import NovaConfirmDialog from '../../components/ui/NovaConfirmDialog'
import WorldDuplicateActionMenu from '../../components/worlds/editor/WorldDuplicateActionMenu'
import 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 (
<div className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
<div className="flex items-start justify-between gap-4">
<div>
<h2 className="text-xl font-semibold text-white">{title}</h2>
{description ? <p className="mt-1 text-sm leading-6 text-slate-400">{description}</p> : null}
</div>
{actions}
</div>
<div className="mt-5">{children}</div>
</div>
)
}
function WorldEditorActionPanel({ formProcessing, isEditing, publishUrl, archiveUrl, publicUrl }) {
return (
<div className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
<div className="flex items-start justify-between gap-4">
<div>
<h2 className="text-xl font-semibold text-white">Campaign actions</h2>
<p className="mt-1 text-sm leading-6 text-slate-400">Keep save, publish, archive, and public-page actions reachable while reviewing the campaign summary.</p>
</div>
<div className="hidden rounded-full border border-white/10 bg-black/20 px-3 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-slate-400 xl:inline-flex">Sticky</div>
</div>
<div className="mt-5 grid gap-3 sm:grid-cols-2">
<button type="submit" disabled={formProcessing} className="inline-flex items-center justify-center gap-2 rounded-2xl bg-white px-5 py-3 text-sm font-semibold text-slate-950 transition hover:bg-sky-100 disabled:opacity-60">{formProcessing ? 'Saving…' : (isEditing ? 'Save world' : 'Create world')}</button>
{publishUrl ? <button type="button" onClick={() => router.post(publishUrl)} className="inline-flex items-center justify-center gap-2 rounded-2xl border border-emerald-300/20 bg-emerald-400/10 px-5 py-3 text-sm font-semibold text-emerald-100">Publish</button> : null}
{archiveUrl ? <button type="button" onClick={() => router.post(archiveUrl)} className="inline-flex items-center justify-center gap-2 rounded-2xl border border-amber-300/20 bg-amber-400/10 px-5 py-3 text-sm font-semibold text-amber-100">Archive</button> : null}
{publicUrl ? <a href={publicUrl} className="inline-flex items-center justify-center gap-2 rounded-2xl border border-white/10 bg-white/[0.04] px-5 py-3 text-sm font-semibold text-white">Public page</a> : null}
</div>
</div>
)
}
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 (
<StudioLayout title={props.title} subtitle={props.description}>
<form onSubmit={submit} className="grid gap-6 xl:grid-cols-[minmax(0,1.04fr)_minmax(360px,0.96fr)]">
<section className="space-y-6">
{errorEntries.length > 0 ? (
<div className="rounded-[28px] border border-rose-300/20 bg-rose-400/10 p-5 text-sm text-rose-100">
<div className="font-semibold">Fix the highlighted editor issues before publishing.</div>
{firstErrorTab ? <div className="mt-2 text-sm text-rose-100/90">The first blocked section is <span className="font-semibold">{firstErrorTab.label}</span>.</div> : null}
<div className="mt-3 grid gap-1 text-sm leading-6">
{errorEntries.slice(0, 8).map(([key, message]) => <div key={key}>{message}</div>)}
</div>
</div>
) : null}
<div className="rounded-[28px] border border-white/10 bg-white/[0.03] p-3">
<div className="grid gap-2 md:grid-cols-2 2xl:grid-cols-3">
{editorTabs.map((tab) => {
const isActive = activeTab === tab.id
return (
<button
key={tab.id}
type="button"
onClick={() => setActiveTab(tab.id)}
role="tab"
aria-selected={isActive}
className={[
'rounded-[22px] border px-4 py-3 text-left transition',
isActive
? 'border-sky-300/30 bg-sky-400/12 text-white shadow-[0_16px_40px_rgba(14,165,233,0.12)]'
: 'border-white/10 bg-black/20 text-slate-300 hover:border-white/15 hover:bg-white/[0.05]',
].join(' ')}
>
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="flex items-center gap-2">
<i className={`${tab.icon} text-[11px] ${isActive ? 'text-sky-100' : 'text-slate-500'}`} aria-hidden="true" />
<span className="text-sm font-semibold">{tab.label}</span>
</div>
<div className="mt-1 text-xs leading-5 text-slate-400">{tab.meta}</div>
</div>
<div className="flex shrink-0 items-center gap-2">
{tab.errorCount > 0 ? <span className="rounded-full border border-rose-300/20 bg-rose-400/12 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.16em] text-rose-100">{tab.errorCount}</span> : null}
{isActive ? <span className="rounded-full border border-sky-300/25 bg-sky-400/12 px-2 py-0.5 text-[10px] font-semibold uppercase tracking-[0.16em] text-sky-100">Active</span> : null}
</div>
</div>
</button>
)
})}
</div>
</div>
{activeTab === 'basics' ? (
<WorldEditorSection title="World basics" description="Shape the title, summary, and long-form story before you worry about composition or scheduling.">
<div className="grid gap-4">
<div className="grid gap-4 md:grid-cols-[minmax(0,1.2fr)_minmax(0,0.8fr)]">
<label className="grid gap-2 text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Title</span>
<input value={form.data.title} onChange={(event) => form.setData('title', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<span className="text-xs leading-5 text-slate-500">Use a campaign-ready title that will still read clearly in homepage spotlight, preview, and archive contexts.</span>
</label>
<label className="grid gap-2 text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Slug</span>
<input value={form.data.slug} onChange={(event) => form.setData('slug', event.target.value)} placeholder="optional-manual-slug" className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<span className="text-xs leading-5 text-slate-500">Keep this short and durable. It becomes the public world URL and should survive future editorial updates.</span>
</label>
</div>
<div className="grid gap-4 md:grid-cols-2">
<label className="grid gap-2 text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Tagline</span>
<input value={form.data.tagline} onChange={(event) => form.setData('tagline', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
</label>
<label className="grid gap-2 text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Summary</span>
<input value={form.data.summary} onChange={(event) => form.setData('summary', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
</label>
</div>
<div className="grid gap-2 text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Description</span>
<RichTextEditor
content={form.data.description}
onChange={(nextValue) => form.setData('description', nextValue)}
placeholder="Build the world story with rich formatting, links, emoji, and campaign callouts."
error={form.errors.description}
minHeight={18}
autofocus={false}
/>
</div>
</div>
</WorldEditorSection>
) : null}
{activeTab === 'structure' ? (
<>
<WorldEditorSection
title="Curated relations"
description="Attach explicit artworks, collections, creators, groups, news, challenges, events, releases, and cards as editorial composition blocks."
actions={<button type="button" onClick={() => openRelationPicker(null)} className="rounded-full border border-sky-300/20 bg-sky-400/10 px-4 py-2 text-sm font-semibold text-sky-100">Add relation</button>}
>
<div className="grid gap-4">
{form.data.relations.length > 0 ? form.data.relations.map((relation, index) => (
<WorldRelationCard
key={`${relation.related_type}-${relation.related_id || index}-${index}`}
relation={relation}
index={index}
total={form.data.relations.length}
sectionLabel={sectionMap[relation.section_key]?.label || relation.section_key}
onEdit={() => openRelationPicker(index)}
onRemove={() => removeRelation(index)}
onMove={moveRelation}
/>
)) : <div className="rounded-[24px] border border-dashed border-white/10 bg-white/[0.02] p-5 text-sm text-slate-400">No curated relations attached yet. Add relations to turn the world into a real campaign hub instead of a static shell.</div>}
</div>
</WorldEditorSection>
<WorldEditorSection title="Sections" description="Reorder and hide public sections without losing their underlying data or editorial intent.">
<WorldSectionToggleList
sectionOptions={sectionOptions}
order={form.data.section_order_json}
visibility={form.data.section_visibility_json}
relationCounts={relationCounts}
onChange={updateSectionControls}
/>
</WorldEditorSection>
</>
) : null}
{activeTab === 'publishing' ? (
<WorldEditorSection title="Publishing and timing" description="Control the public state, campaign dates, and recurrence lifecycle without mixing in community settings.">
<div className="mt-5 grid gap-4">
<div className="grid gap-4 md:grid-cols-2">
<NovaSelect label="Type" value={form.data.type || null} onChange={(nextValue) => form.setData('type', String(nextValue || ''))} options={typeOptions} searchable={false} className="bg-black/20" />
<NovaSelect label="Workflow status" value={form.data.status || null} onChange={(nextValue) => form.setData('status', String(nextValue || ''))} options={props.statusOptions || []} searchable={false} className="bg-black/20" />
</div>
<DateTimePicker label="Publish at" value={form.data.published_at} onChange={(nextValue) => form.setData('published_at', nextValue)} placeholder="Pick an automatic publish date" clearable className="bg-black/20" />
<div className="-mt-1 text-xs leading-5 text-slate-500">A future publish date keeps the world in the editorial pipeline until the release moment arrives automatically.</div>
<div className="grid gap-4 md:grid-cols-2">
<DateTimePicker label="Starts at" value={form.data.starts_at} onChange={(nextValue) => form.setData('starts_at', nextValue)} placeholder="Pick the campaign start" clearable className="bg-black/20" />
<DateTimePicker label="Ends at" value={form.data.ends_at} onChange={(nextValue) => form.setData('ends_at', nextValue)} placeholder="Pick the campaign end" clearable className="bg-black/20" />
</div>
<div className="-mt-1 text-xs leading-5 text-slate-500">The campaign window drives whether the world reads as upcoming, active, or finished on public surfaces.</div>
<div className="grid gap-3 md:grid-cols-2">
<div className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3">
<Checkbox checked={form.data.is_featured} onChange={(event) => form.setData('is_featured', event.target.checked)} label="Feature on Worlds surfaces and homepage spotlight" size={20} variant="accent" />
<div className="mt-2 text-xs leading-5 text-slate-500">Enable this when the world should be eligible for promoted placement beyond its own public URL.</div>
</div>
<div className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3">
<Checkbox checked={form.data.is_recurring} onChange={(event) => form.setData('is_recurring', event.target.checked)} label="Recurring world" size={20} variant="accent" />
<div className="mt-2 text-xs leading-5 text-slate-500">Use recurrence for annual or repeatable campaigns so new editions can be rolled forward cleanly.</div>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<label className="grid gap-2 text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Recurrence key</span>
<input value={form.data.recurrence_key} onChange={(event) => form.setData('recurrence_key', event.target.value)} placeholder="halloween, retro-month" className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
</label>
<label className="grid gap-2 text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Edition year</span>
<input type="number" min="2000" max="2100" value={form.data.edition_year} onChange={(event) => form.setData('edition_year', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
</label>
</div>
<label className="grid gap-2 text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Recurrence rule</span>
<input value={form.data.recurrence_rule} onChange={(event) => form.setData('recurrence_rule', event.target.value)} placeholder="annual:10, annual:12, campaign-window" className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
</label>
<WorldRecurrenceHelper
enabled={form.data.is_recurring}
recurrenceKey={form.data.recurrence_key}
editionYear={form.data.edition_year}
recurrenceKeyError={form.errors.recurrence_key}
editionYearError={form.errors.edition_year}
/>
<WorldDuplicateActionMenu
duplicateUrl={duplicateActions?.duplicateUrl}
newEditionUrl={duplicateActions?.newEditionUrl}
canCreateEdition={Boolean(duplicateActions?.canCreateEdition)}
onDuplicate={() => runDuplicateAction(duplicateActions?.duplicateUrl, 'Duplicate this world into a new draft?')}
onCreateEdition={() => runDuplicateAction(duplicateActions?.newEditionUrl, 'Create the next edition draft from this world?')}
/>
</div>
</WorldEditorSection>
) : null}
{activeTab === 'community' ? (
<WorldEditorSection title="Community participation" description="Choose how creators can join the world, define the participation window, and moderate community placements without touching curated relations.">
<div className="grid gap-4">
<div className="rounded-[24px] border border-white/10 bg-black/20 p-4">
<div className="grid gap-4">
<div>
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Participation mode</div>
<div className="mt-2 text-sm leading-6 text-slate-300">Pick whether creator artworks need approval, go live automatically, or stay hidden from creator participation surfaces.</div>
</div>
<NovaSelect
label="Creator participation"
value={form.data.participation_mode || 'closed'}
onChange={(nextValue) => {
const nextMode = String(nextValue || 'closed')
form.setData({
...form.data,
participation_mode: nextMode,
accepts_submissions: nextMode !== 'closed',
})
}}
options={PARTICIPATION_MODE_OPTIONS}
searchable={false}
className="bg-black/20"
/>
<div className="grid gap-3 md:grid-cols-2">
<div className="rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3">
<Checkbox checked={form.data.submission_note_enabled} onChange={(event) => form.setData('submission_note_enabled', event.target.checked)} label="Allow creator notes" size={20} variant="accent" />
</div>
<div className="rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3">
<Checkbox checked={form.data.community_section_enabled} onChange={(event) => form.setData('community_section_enabled', event.target.checked)} label="Show community section on the public world page" size={20} variant="accent" />
</div>
</div>
<div className="rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3">
<Checkbox checked={form.data.allow_readd_after_removal} onChange={(event) => form.setData('allow_readd_after_removal', event.target.checked)} label="Allow creators to re-add artworks after removal" size={20} variant="accent" />
<div className="mt-2 text-xs leading-5 text-slate-500">Blocked artworks still stay locked until a moderator unblocks them.</div>
</div>
</div>
<div className="mt-4 grid gap-4 md:grid-cols-2">
<DateTimePicker label="Submission opens" value={form.data.submission_starts_at} onChange={(nextValue) => form.setData('submission_starts_at', nextValue)} placeholder="Optional submission start" clearable className="bg-black/20" />
<DateTimePicker label="Submission closes" value={form.data.submission_ends_at} onChange={(nextValue) => form.setData('submission_ends_at', nextValue)} placeholder="Optional submission end" clearable className="bg-black/20" />
</div>
<label className="mt-4 grid gap-2 text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Creator guidelines</span>
<textarea value={form.data.submission_guidelines} onChange={(event) => form.setData('submission_guidelines', event.target.value)} rows={5} className="rounded-[24px] border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" placeholder="Explain what qualifies, what you want creators to submit, and any world-specific expectations." />
<span className="text-xs leading-5 text-slate-500">These notes are shown to creators in upload and artwork-edit submission surfaces.</span>
</label>
</div>
{world ? (
<div className="rounded-[24px] border border-white/10 bg-black/20 p-4">
<div className="flex flex-col gap-3 lg:flex-row lg:items-end lg:justify-between">
<div>
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Submission review queue</div>
<div className="mt-2 text-sm leading-6 text-slate-300">Moderate creator participation without converting those artworks into curated world relations.</div>
</div>
<div className="flex flex-wrap gap-2 text-xs">
{[
['Pending', reviewQueue?.counts?.pending || 0, 'border-sky-300/25 bg-sky-400/10 text-sky-100'],
['Live', reviewQueue?.counts?.live || 0, 'border-emerald-300/25 bg-emerald-400/10 text-emerald-100'],
['Featured', reviewQueue?.counts?.featured || 0, 'border-amber-300/25 bg-amber-400/10 text-amber-100'],
['Removed', reviewQueue?.counts?.removed || 0, 'border-orange-300/25 bg-orange-400/10 text-orange-100'],
['Blocked', reviewQueue?.counts?.blocked || 0, 'border-rose-300/25 bg-rose-400/10 text-rose-100'],
].map(([label, count, tone]) => (
<span key={label} className={`rounded-full border px-3 py-1 font-semibold uppercase tracking-[0.14em] ${tone}`}>{label}: {count}</span>
))}
</div>
</div>
<div className="mt-4 grid gap-3">
{Array.isArray(reviewQueue?.items) && reviewQueue.items.length > 0 ? reviewQueue.items.map((item) => (
<div key={item.id} className="rounded-2xl border border-white/10 bg-white/[0.03] p-4">
<div className="flex flex-col gap-4 xl:flex-row">
<div className="h-24 w-24 shrink-0 overflow-hidden rounded-[20px] border border-white/10 bg-slate-950/80">
{item.artwork?.thumbnail_url ? <img src={item.artwork.thumbnail_url} alt="" className="h-full w-full object-cover" /> : <div className="flex h-full items-center justify-center text-slate-500"><i className="fa-solid fa-image" /></div>}
</div>
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<a href={item.artwork?.edit_url || item.artwork?.url || '#'} className="text-base font-semibold text-white hover:text-sky-100">{item.artwork?.title || 'Untitled artwork'}</a>
<span className="rounded-full border border-white/10 bg-white/[0.04] px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.14em] text-slate-300">{item.status_label}</span>
</div>
<div className="mt-1 text-xs uppercase tracking-[0.16em] text-slate-500">{item.submitted_by?.name || item.artwork?.creator_name || 'Unknown creator'}</div>
{Array.isArray(item.artwork?.meta) && item.artwork.meta.length > 0 ? <div className="mt-3 flex flex-wrap gap-2">{item.artwork.meta.map((entry) => <span key={entry} className="rounded-full border border-white/10 bg-black/20 px-3 py-1 text-xs text-slate-300">{entry}</span>)}</div> : null}
{item.note ? <div className="mt-3 rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-sm leading-6 text-slate-300"><div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Creator note</div><div className="mt-2">{item.note}</div></div> : null}
{item.reviewer_note ? <div className="mt-3 rounded-2xl border border-amber-300/20 bg-amber-400/10 px-4 py-3 text-sm leading-6 text-amber-50"><div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-amber-100/80">Moderator note</div><div className="mt-2">{item.reviewer_note}</div></div> : null}
</div>
<div className="flex shrink-0 flex-wrap gap-2 xl:w-[220px] xl:flex-col">
{item.status === 'pending' ? <button type="button" onClick={() => runSubmissionAction(item.actions?.approve, 'Approve this submission and make it live?', { title: 'Approve submission?', confirmLabel: 'Approve' })} className="rounded-2xl border border-emerald-300/20 bg-emerald-400/10 px-4 py-2 text-sm font-semibold text-emerald-100">Approve</button> : null}
{item.status === 'removed' ? <button type="button" onClick={() => runSubmissionAction(item.actions?.restore, 'Restore this submission to the live community section?', { title: 'Restore submission?', confirmLabel: 'Restore' })} className="rounded-2xl border border-emerald-300/20 bg-emerald-400/10 px-4 py-2 text-sm font-semibold text-emerald-100">Restore</button> : null}
{item.status === 'blocked' ? <button type="button" onClick={() => runSubmissionAction(item.actions?.unblock, 'Unblock this artwork so it can be restored or re-added later?', { title: 'Unblock artwork?', confirmLabel: 'Unblock', confirmTone: 'accent' })} className="rounded-2xl border border-sky-300/20 bg-sky-400/10 px-4 py-2 text-sm font-semibold text-sky-100">Unblock</button> : null}
{item.status !== 'blocked' ? <button type="button" onClick={() => runSubmissionAction(item.is_featured ? item.actions?.unfeature : item.actions?.feature, item.is_featured ? 'Remove this artwork from featured community placement?' : 'Feature this artwork in the public community section?', { title: item.is_featured ? 'Unfeature artwork?' : 'Feature artwork?', confirmLabel: item.is_featured ? 'Unfeature' : 'Feature' })} className="rounded-2xl border border-amber-300/20 bg-amber-400/10 px-4 py-2 text-sm font-semibold text-amber-100">{item.is_featured ? 'Unfeature' : 'Feature'}</button> : null}
{item.status !== 'pending' && item.status !== 'blocked' ? <button type="button" onClick={() => runSubmissionAction(item.actions?.pending, 'Return this submission to pending review?', { title: 'Return to pending?', confirmLabel: 'Set pending' })} className="rounded-2xl border border-sky-300/20 bg-sky-400/10 px-4 py-2 text-sm font-semibold text-sky-100">Set pending</button> : null}
{item.status !== 'removed' && item.status !== 'blocked' ? <button type="button" onClick={() => runSubmissionAction(item.actions?.remove, 'Remove this artwork from community participation for this world?', { title: 'Remove artwork?', confirmLabel: 'Remove', cancelLabel: 'Keep live', confirmTone: 'danger', noteEnabled: true })} className="rounded-2xl border border-orange-300/20 bg-orange-400/10 px-4 py-2 text-sm font-semibold text-orange-100">Remove</button> : null}
{item.status !== 'blocked' ? <button type="button" onClick={() => runSubmissionAction(item.actions?.block, 'Block this artwork from re-adding to this world until a moderator clears it?', { title: 'Block artwork?', confirmLabel: 'Block', cancelLabel: 'Cancel', confirmTone: 'danger', noteEnabled: true })} className="rounded-2xl border border-rose-300/20 bg-rose-400/10 px-4 py-2 text-sm font-semibold text-rose-100">Block</button> : null}
</div>
</div>
</div>
)) : <div className="rounded-2xl border border-dashed border-white/10 bg-white/[0.02] p-4 text-sm text-slate-400">No submissions have been attached to this world yet.</div>}
</div>
</div>
) : null}
</div>
</WorldEditorSection>
) : null}
{activeTab === 'presentation' ? (
<>
<WorldEditorSection title="Theme and identity" description="Apply theme presets and tune the visual language that drives the worlds mood and recognition.">
<div className="mt-5 grid gap-4">
<NovaSelect
label="Theme preset"
value={form.data.theme_key || null}
onChange={(nextValue) => handleThemeChange(String(nextValue || ''))}
options={[{ value: '', label: 'No preset' }, ...themeOptions]}
searchable={false}
className="bg-black/20"
/>
<WorldThemePresetHelper theme={selectedTheme} onApply={() => handleThemeChange(form.data.theme_key, true)} />
<div className="grid gap-4 md:grid-cols-2">
<label className="grid gap-2 text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Accent color</span>
<input value={form.data.accent_color} onChange={(event) => form.setData('accent_color', event.target.value)} placeholder="#f97316" className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
</label>
<label className="grid gap-2 text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Secondary accent</span>
<input value={form.data.accent_color_secondary} onChange={(event) => form.setData('accent_color_secondary', event.target.value)} placeholder="#0f172a" className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
</label>
</div>
<div className="grid gap-4 md:grid-cols-2">
<label className="grid gap-2 text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Background motif</span>
<input value={form.data.background_motif} onChange={(event) => form.setData('background_motif', event.target.value)} placeholder="embers, frost, scanlines" className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
</label>
<label className="grid gap-2 text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Icon class</span>
<input value={form.data.icon_name} onChange={(event) => form.setData('icon_name', event.target.value)} placeholder="fa-solid fa-ghost" className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
</label>
</div>
</div>
</WorldEditorSection>
<WorldEditorSection title="Media assets" description={props.mediaSupport?.helper_text}>
<div className="mt-5 grid gap-4">
<WorldMediaUploadField
label="Cover image"
slot="cover"
value={form.data.cover_path}
previewUrl={resolveMediaUrl(form.data.cover_path, world?.cover_path === form.data.cover_path ? world?.cover_url || '' : '', filesBaseUrl)}
emptyLabel="No cover selected"
helperText="Use the strongest hero asset you have so the world reads clearly in spotlight, cards, and preview states."
uploadUrl={props.mediaSupport?.upload_url}
deleteUrl={props.mediaSupport?.delete_url}
worldId={world?.id || null}
isTemporaryValue={temporaryMediaPaths.cover !== '' && temporaryMediaPaths.cover === form.data.cover_path}
accept={(props.mediaSupport?.accepted_mime_types || []).join(',') || 'image/jpeg,image/png,image/webp'}
maxFileSizeMb={props.mediaSupport?.max_file_size_mb || 6}
onChange={({ path }) => {
form.setData('cover_path', path)
setTemporaryMediaPaths((current) => ({ ...current, cover: path || '' }))
}}
/>
<div className="grid gap-3">
<WorldMediaUploadField
label="OG image"
slot="og"
value={form.data.og_image_path}
previewUrl={resolveMediaUrl(form.data.og_image_path, world?.og_image_path === form.data.og_image_path ? world?.og_image_url || '' : '', filesBaseUrl)}
emptyLabel="Falls back to cover image when blank"
helperText="Upload a dedicated social share image, or leave it blank and use the cover image fallback."
uploadUrl={props.mediaSupport?.upload_url}
deleteUrl={props.mediaSupport?.delete_url}
worldId={world?.id || null}
isTemporaryValue={temporaryMediaPaths.og !== '' && temporaryMediaPaths.og === form.data.og_image_path}
accept={(props.mediaSupport?.accepted_mime_types || []).join(',') || 'image/jpeg,image/png,image/webp'}
maxFileSizeMb={props.mediaSupport?.max_file_size_mb || 6}
onChange={({ path }) => {
form.setData('og_image_path', path)
setTemporaryMediaPaths((current) => ({ ...current, og: path || '' }))
}}
/>
<div className="flex justify-end">
<button
type="button"
onClick={applyCoverToOg}
disabled={!form.data.cover_path}
className="rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-sm font-semibold text-white disabled:opacity-50"
>
Use cover
</button>
</div>
</div>
<div className="grid gap-4 md:grid-cols-2">
<div className="rounded-[24px] border border-white/10 bg-black/20 p-4">
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Cover preview</div>
<div className="mt-3 h-40 overflow-hidden rounded-[20px] border border-white/10 bg-slate-950">{previewWorld.cover_url ? <img src={previewWorld.cover_url} alt="" className="h-full w-full object-cover" /> : <div className="flex h-full items-center justify-center text-sm text-slate-500">No cover selected</div>}</div>
</div>
<div className="rounded-[24px] border border-white/10 bg-black/20 p-4">
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">OG preview</div>
<div className="mt-3 h-40 overflow-hidden rounded-[20px] border border-white/10 bg-slate-950">{form.data.og_image_path || previewWorld.cover_url ? <img src={resolveMediaUrl(form.data.og_image_path || form.data.cover_path, form.data.og_image_path ? (world?.og_image_path === form.data.og_image_path ? world?.og_image_url || '' : '') : (world?.cover_path === form.data.cover_path ? world?.cover_url || '' : ''), filesBaseUrl)} alt="" className="h-full w-full object-cover" /> : <div className="flex h-full items-center justify-center text-sm text-slate-500">Falls back to cover image when blank</div>}</div>
</div>
</div>
</div>
</WorldEditorSection>
<WorldEditorSection title="CTA and badge" description="Handle the promotional copy and campaign affordances that sit around the world, not inside its core story.">
<div className="mt-5 grid gap-4">
<div className="grid gap-4 md:grid-cols-2">
<label className="grid gap-2 text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">CTA label</span>
<input value={form.data.cta_label} onChange={(event) => form.setData('cta_label', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<span className="text-xs leading-5 text-slate-500">Keep this action specific to the campaign intent: explore, join, enter, submit, or discover.</span>
</label>
<label className="grid gap-2 text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">CTA URL</span>
<input value={form.data.cta_url} onChange={(event) => form.setData('cta_url', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<span className="text-xs leading-5 text-slate-500">Point this at the destination that matches the world, such as a challenge, collection, landing page, or participation flow.</span>
</label>
</div>
<div className="grid gap-4 md:grid-cols-2">
<label className="grid gap-2 text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Badge label</span>
<input value={form.data.badge_label} onChange={(event) => form.setData('badge_label', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<span className="text-xs leading-5 text-slate-500">Use concise editorial language that still reads cleanly in the hero, cards, and spotlight surfaces.</span>
</label>
<label className="grid gap-2 text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Badge URL</span>
<input value={form.data.badge_url} onChange={(event) => form.setData('badge_url', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<span className="text-xs leading-5 text-slate-500">Optional. Use this when the badge should send visitors to a participation or explanation destination.</span>
</label>
</div>
<label className="grid gap-2 text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Badge description</span>
<textarea value={form.data.badge_description} onChange={(event) => form.setData('badge_description', event.target.value)} rows={4} className="rounded-[24px] border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
</label>
<label className="grid gap-2 text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Related tags</span>
<input value={tagString} onChange={(event) => form.setData('related_tags_json', event.target.value.split(',').map((tag) => tag.trim()).filter(Boolean))} placeholder="halloween, spooky, demoscene" className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
<span className="text-xs leading-5 text-slate-500">Use these to reinforce the campaign identity and keep recurring presets consistent across editions.</span>
</label>
</div>
</WorldEditorSection>
</>
) : null}
{activeTab === 'seo' ? (
<WorldEditorSection title="SEO" description="Set metadata that travels with the world into search results, previews, and social cards.">
<div className="mt-5 grid gap-4">
<label className="grid gap-2 text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">SEO title</span>
<input value={form.data.seo_title} onChange={(event) => form.setData('seo_title', event.target.value)} className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
</label>
<label className="grid gap-2 text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">SEO description</span>
<textarea value={form.data.seo_description} onChange={(event) => form.setData('seo_description', event.target.value)} rows={4} className="rounded-[24px] border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
</label>
</div>
</WorldEditorSection>
) : null}
</section>
<section className="space-y-6 xl:sticky xl:top-6 xl:max-h-[calc(100vh-48px)] xl:self-start xl:overflow-y-auto xl:overscroll-contain xl:pr-1 nova-scrollbar">
<WorldEditorActionPanel
formProcessing={form.processing}
isEditing={Boolean(props.updateUrl)}
publishUrl={props.publishUrl}
archiveUrl={props.archiveUrl}
publicUrl={world?.urls?.public}
/>
<WorldSummaryCard world={form.data} themeLabel={selectedTheme?.label || ''} relationCount={form.data.relations.length} enabledSectionsCount={enabledSectionsCount} />
<div className="rounded-[28px] border border-white/10 bg-white/[0.03] p-5">
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Current focus</div>
<div className="mt-2 flex items-center gap-2 text-white">
<i className={`${currentTab?.icon || 'fa-solid fa-pen-ruler'} text-[11px] text-sky-200`} aria-hidden="true" />
<span className="text-base font-semibold">{currentTab?.label || 'Editor'}</span>
</div>
<p className="mt-3 text-sm leading-6 text-slate-300">{currentTab?.description}</p>
</div>
<WorldMiniPreviewPanel world={previewWorld} sections={previewSections} previewUrl={props.previewUrl} />
</section>
</form>
<WorldRelationPickerModal
open={pickerState.open}
onClose={closeRelationPicker}
onSave={saveRelation}
initialRelation={editingRelation}
sectionOptions={sectionOptions}
relationTypeOptions={relationTypeOptions}
searchEntities={searchEntities}
/>
<NovaConfirmDialog
open={actionConfirm.open}
title={actionConfirm.title}
message={actionConfirm.message}
confirmLabel={actionConfirm.confirmLabel}
cancelLabel={actionConfirm.cancelLabel}
confirmTone={actionConfirm.confirmTone}
onConfirm={confirmAction}
onClose={closeActionConfirm}
busy={actionBusy}
>
{actionConfirm.noteEnabled ? (
<label className="grid gap-2 text-sm text-slate-300">
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Moderator note</span>
<textarea
value={actionReviewNote}
onChange={(event) => setActionReviewNote(event.target.value)}
rows={4}
className="rounded-[24px] border border-white/10 bg-black/20 px-4 py-3 text-white outline-none"
placeholder="Explain the moderation decision so creators understand what changed."
/>
</label>
) : null}
</NovaConfirmDialog>
</StudioLayout>
)
}