1073 lines
64 KiB
JavaScript
1073 lines
64 KiB
JavaScript
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 world’s 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>
|
||
)
|
||
} |