1791 lines
115 KiB
JavaScript
1791 lines
115 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 WorldRecapArticlePickerModal from '../../components/worlds/editor/WorldRecapArticlePickerModal'
|
||
import WorldLinkedChallengePickerModal from '../../components/worlds/editor/WorldLinkedChallengePickerModal'
|
||
import WorldMiniPreviewPanel from '../../components/worlds/editor/WorldMiniPreviewPanel'
|
||
import WorldRecurrenceHelper from '../../components/worlds/editor/WorldRecurrenceHelper'
|
||
import WorldRelationCard from '../../components/worlds/editor/WorldRelationCard'
|
||
import WorldRelationPickerModal from '../../components/worlds/editor/WorldRelationPickerModal'
|
||
import WorldSectionToggleList from '../../components/worlds/editor/WorldSectionToggleList'
|
||
import WorldMediaUploadField from '../../components/worlds/editor/WorldMediaUploadField'
|
||
import WorldAnalyticsPanel from '../../components/worlds/editor/analytics/WorldAnalyticsPanel'
|
||
import WorldSuggestionsPanel from '../../components/worlds/editor/suggestions/WorldSuggestionsPanel'
|
||
import WorldSummaryCard from '../../components/worlds/editor/WorldSummaryCard'
|
||
import WorldThemePresetHelper from '../../components/worlds/editor/WorldThemePresetHelper'
|
||
|
||
function toDateTimeLocal(value) {
|
||
if (!value) return ''
|
||
return String(value).slice(0, 16)
|
||
}
|
||
|
||
function arraysEqual(left, right) {
|
||
if (!Array.isArray(left) || !Array.isArray(right) || left.length !== right.length) {
|
||
return false
|
||
}
|
||
|
||
return left.every((entry, index) => entry === right[index])
|
||
}
|
||
|
||
function normalizeRelations(relations) {
|
||
return (Array.isArray(relations) ? relations : []).map((relation, index) => ({
|
||
...relation,
|
||
sort_order: index,
|
||
}))
|
||
}
|
||
|
||
function relationForEditor(relation) {
|
||
return {
|
||
...relation,
|
||
sort_order: Number(relation?.sort_order || 0),
|
||
is_featured: Boolean(relation?.is_featured),
|
||
preview: relation?.preview || null,
|
||
query: relation?.query || relation?.preview?.title || '',
|
||
}
|
||
}
|
||
|
||
function upsertRelation(relations, nextRelation) {
|
||
const items = Array.isArray(relations) ? relations : []
|
||
const normalized = relationForEditor(nextRelation)
|
||
const existingIndex = items.findIndex((relation) => relation.related_type === normalized.related_type && Number(relation.related_id) === Number(normalized.related_id))
|
||
|
||
if (existingIndex === -1) {
|
||
return normalizeRelations([...items, normalized])
|
||
}
|
||
|
||
return normalizeRelations(items.map((relation, index) => (index === existingIndex ? relationForEditor({ ...relation, ...normalized }) : relation)))
|
||
}
|
||
|
||
function initialSectionVisibility(sectionOptions, worldVisibility) {
|
||
const defaults = Object.fromEntries((Array.isArray(sectionOptions) ? sectionOptions : []).map((option) => [option.value, true]))
|
||
return { ...defaults, ...(worldVisibility || {}) }
|
||
}
|
||
|
||
function buildDefaultRelation(sectionOptions, relationTypeOptions, existingCount = 0) {
|
||
const firstSection = sectionOptions?.[0]
|
||
return {
|
||
section_key: firstSection?.value || 'featured_artworks',
|
||
related_type: firstSection?.relation_types?.[0] || relationTypeOptions?.[0]?.value || 'artwork',
|
||
related_id: '',
|
||
context_label: '',
|
||
sort_order: existingCount,
|
||
is_featured: false,
|
||
preview: null,
|
||
query: '',
|
||
}
|
||
}
|
||
|
||
function resolveMediaUrl(path, fallbackUrl = '', filesBaseUrl = '') {
|
||
if (!path) return fallbackUrl || ''
|
||
if (String(path).startsWith('http://') || String(path).startsWith('https://') || String(path).startsWith('/')) {
|
||
return path
|
||
}
|
||
|
||
if (fallbackUrl) {
|
||
return fallbackUrl
|
||
}
|
||
|
||
if (filesBaseUrl) {
|
||
return `${String(filesBaseUrl).replace(/\/$/, '')}/${String(path).replace(/^\//, '')}`
|
||
}
|
||
|
||
return path
|
||
}
|
||
|
||
function formatCompactNumber(value) {
|
||
const number = Number(value || 0)
|
||
|
||
if (!Number.isFinite(number)) {
|
||
return '0'
|
||
}
|
||
|
||
return new Intl.NumberFormat('en', { notation: 'compact', maximumFractionDigits: 1 }).format(number)
|
||
}
|
||
|
||
const DEFAULT_ACTION_CONFIRM = {
|
||
open: false,
|
||
url: '',
|
||
title: 'Please confirm',
|
||
message: '',
|
||
confirmLabel: 'Confirm',
|
||
cancelLabel: 'Cancel',
|
||
confirmTone: 'danger',
|
||
noteEnabled: false,
|
||
copyModeEnabled: false,
|
||
copyModeOptions: [],
|
||
defaultCopyMode: 'with_relations',
|
||
preserveScroll: true,
|
||
}
|
||
|
||
function buildPreviewWorld(formData, world, themeOptions, typeOptions, filesBaseUrl) {
|
||
const theme = (themeOptions || []).find((item) => item.value === formData.theme_key) || null
|
||
const type = (typeOptions || []).find((item) => item.value === formData.type)
|
||
|
||
return {
|
||
title: formData.title,
|
||
tagline: formData.tagline,
|
||
summary: formData.summary,
|
||
description: formData.description,
|
||
cover_url: resolveMediaUrl(formData.cover_path, world?.cover_path === formData.cover_path ? world?.cover_url || '' : '', filesBaseUrl),
|
||
teaser_image_url: resolveMediaUrl(formData.teaser_image_path, world?.teaser_image_path === formData.teaser_image_path ? world?.teaser_image_url || '' : '', filesBaseUrl),
|
||
type: type?.label || formData.type || 'Seasonal',
|
||
badge_label: formData.badge_label,
|
||
campaign_label: formData.campaign_label,
|
||
badge_description: formData.badge_description,
|
||
cta_label: formData.cta_label,
|
||
accent_color: formData.accent_color || theme?.accent_color || '#38bdf8',
|
||
accent_color_secondary: formData.accent_color_secondary || theme?.accent_color_secondary || '#0f172a',
|
||
background_motif: formData.background_motif || theme?.background_motif || 'atmosphere',
|
||
icon_name: formData.icon_name || theme?.icon_name || 'fa-solid fa-sparkles',
|
||
is_featured: Boolean(formData.is_featured),
|
||
is_active_campaign: Boolean(formData.is_active_campaign),
|
||
is_homepage_featured: Boolean(formData.is_homepage_featured),
|
||
}
|
||
}
|
||
|
||
const WORLD_EDITOR_TABS = [
|
||
{
|
||
id: 'basics',
|
||
label: 'Basics',
|
||
icon: 'fa-solid fa-pen-ruler',
|
||
description: 'Core editorial copy, title, and public story framing for the world.',
|
||
},
|
||
{
|
||
id: 'structure',
|
||
label: 'Structure',
|
||
icon: 'fa-solid fa-diagram-project',
|
||
description: 'Curated relations and section ordering that shape the public world composition.',
|
||
},
|
||
{
|
||
id: 'suggestions',
|
||
label: 'Suggestions',
|
||
icon: 'fa-solid fa-wand-magic-sparkles',
|
||
description: 'Editorial assist candidates scored from theme, submissions, challenge context, and recurring-world signals.',
|
||
},
|
||
{
|
||
id: 'community',
|
||
label: 'Community',
|
||
icon: 'fa-solid fa-people-group',
|
||
description: 'Submission settings and the moderation queue for creator participation.',
|
||
},
|
||
{
|
||
id: 'publishing',
|
||
label: 'Publishing',
|
||
icon: 'fa-solid fa-calendar-check',
|
||
description: 'Status, schedule, recurrence, and edition management controls.',
|
||
},
|
||
{
|
||
id: 'presentation',
|
||
label: 'Presentation',
|
||
icon: 'fa-solid fa-swatchbook',
|
||
description: 'Theme preset, visual identity, media assets, CTA, and badge surface copy.',
|
||
},
|
||
{
|
||
id: 'recap',
|
||
label: 'Recap',
|
||
icon: 'fa-solid fa-stars',
|
||
description: 'Shape the archive-facing recap with editorial framing, linked recap story, and publish-ready summary state.',
|
||
},
|
||
{
|
||
id: 'seo',
|
||
label: 'SEO',
|
||
icon: 'fa-solid fa-magnifying-glass-chart',
|
||
description: 'Search and social metadata that ships with the world page.',
|
||
},
|
||
{
|
||
id: 'analytics',
|
||
label: 'Analytics',
|
||
icon: 'fa-solid fa-chart-column',
|
||
description: 'Traffic, source surfaces, engagement, participation, challenge energy, and recurring-edition comparison.',
|
||
},
|
||
]
|
||
|
||
const WORLD_EDITOR_TAB_FIELDS = {
|
||
basics: ['title', 'slug', 'tagline', 'summary', 'description'],
|
||
structure: ['relations', 'section_order_json', 'section_visibility_json', 'linked_challenge_id', 'show_linked_challenge_section', 'show_linked_challenge_entries', 'show_linked_challenge_winners', 'show_linked_challenge_finalists', 'auto_grant_challenge_world_rewards', 'challenge_teaser_override', 'hidden_linked_challenge_artwork_ids_json'],
|
||
suggestions: [],
|
||
community: ['accepts_submissions', 'participation_mode', 'submission_starts_at', 'submission_ends_at', 'submission_note_enabled', 'community_section_enabled', 'allow_readd_after_removal', 'submission_guidelines'],
|
||
publishing: ['type', 'status', 'published_at', 'starts_at', 'ends_at', 'promotion_starts_at', 'promotion_ends_at', 'is_featured', 'is_active_campaign', 'is_homepage_featured', 'campaign_priority', 'is_recurring', 'recurrence_key', 'recurrence_rule', 'edition_year'],
|
||
presentation: ['theme_key', 'accent_color', 'accent_color_secondary', 'background_motif', 'icon_name', 'cover_path', 'teaser_image_path', 'og_image_path', 'teaser_title', 'teaser_summary', 'cta_label', 'cta_url', 'badge_label', 'campaign_label', 'badge_description', 'badge_url', 'related_tags_json'],
|
||
recap: ['recap_status', 'recap_title', 'recap_summary', 'recap_intro', 'recap_editor_note', 'recap_cover_path', 'recap_article_id'],
|
||
seo: ['seo_title', 'seo_description'],
|
||
analytics: [],
|
||
}
|
||
|
||
const PARTICIPATION_MODE_OPTIONS = [
|
||
{ value: 'manual_approval', label: 'Manual approval', description: 'Creators can add artworks, but each one starts pending until a moderator approves it.' },
|
||
{ value: 'auto_add', label: 'Auto add', description: 'Eligible artworks go live in the community section immediately.' },
|
||
{ value: 'closed', label: 'Closed', description: 'Hide this world from creator participation surfaces.' },
|
||
]
|
||
|
||
function errorBelongsToTab(tabId, errorKey) {
|
||
const prefixes = WORLD_EDITOR_TAB_FIELDS[tabId] || []
|
||
|
||
return prefixes.some((prefix) => errorKey === prefix || errorKey.startsWith(`${prefix}.`) || errorKey.startsWith(`${prefix}[`))
|
||
}
|
||
|
||
function WorldEditorSection({ title, description, actions = null, children }) {
|
||
return (
|
||
<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 LinkedChallengeCard({ challenge, onChange, onClear }) {
|
||
return (
|
||
<div className="rounded-[24px] border border-white/10 bg-black/20 p-4">
|
||
{challenge ? (
|
||
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
|
||
<div className="flex min-w-0 gap-4">
|
||
<div className="h-16 w-16 shrink-0 overflow-hidden rounded-[20px] border border-white/10 bg-slate-950">
|
||
{challenge.image ? <img src={challenge.image} 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-trophy" /></div>}
|
||
</div>
|
||
<div className="min-w-0">
|
||
<div className="flex flex-wrap items-center gap-2">
|
||
<div className="text-base font-semibold text-white">{challenge.title}</div>
|
||
{challenge.entity_label ? <span className="rounded-full border border-sky-300/20 bg-sky-400/10 px-2.5 py-1 text-[10px] font-semibold uppercase tracking-[0.16em] text-sky-100">{challenge.entity_label}</span> : null}
|
||
</div>
|
||
{challenge.subtitle ? <div className="mt-1 text-xs uppercase tracking-[0.14em] text-slate-500">{challenge.subtitle}</div> : null}
|
||
{challenge.description ? <div className="mt-2 text-sm leading-6 text-slate-400">{challenge.description}</div> : null}
|
||
{Array.isArray(challenge.meta) && challenge.meta.length > 0 ? <div className="mt-2 flex flex-wrap gap-2 text-xs text-slate-500">{challenge.meta.map((entry) => <span key={entry}>{entry}</span>)}</div> : null}
|
||
</div>
|
||
</div>
|
||
<div className="flex shrink-0 flex-wrap gap-2">
|
||
<button type="button" onClick={onChange} className="rounded-2xl border border-sky-300/20 bg-sky-400/10 px-4 py-2 text-sm font-semibold text-sky-100">Change</button>
|
||
<button type="button" onClick={onClear} className="rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-2 text-sm font-semibold text-slate-200">Clear</button>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||
<div>
|
||
<div className="text-sm font-semibold text-white">No primary challenge linked</div>
|
||
<p className="mt-1 text-sm leading-6 text-slate-400">Link one group challenge when this world should automatically surface challenge status, entries, and winner context.</p>
|
||
</div>
|
||
<button type="button" onClick={onChange} className="rounded-2xl border border-sky-300/20 bg-sky-400/10 px-4 py-2 text-sm font-semibold text-sky-100">Select challenge</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function WorldEditorActionPanel({ formProcessing, isEditing, publishUrl, publishRecapUrl, canPublishRecap, recapStatusLabel, 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}
|
||
{publishRecapUrl ? <button type="button" onClick={() => router.post(publishRecapUrl)} disabled={!canPublishRecap} className="inline-flex items-center justify-center gap-2 rounded-2xl border border-sky-300/20 bg-sky-400/10 px-5 py-3 text-sm font-semibold text-sky-100 disabled:cursor-not-allowed disabled:opacity-50">{recapStatusLabel === 'Published recap' ? 'Refresh recap snapshot' : 'Publish recap'}</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>
|
||
)
|
||
}
|
||
|
||
function LinkedChallengeEntryVisibilityManager({ challenge, hiddenIds, onToggle, error = '' }) {
|
||
const items = Array.isArray(challenge?.entry_preview_items) ? challenge.entry_preview_items : []
|
||
|
||
if (!challenge || items.length === 0) {
|
||
return null
|
||
}
|
||
|
||
return (
|
||
<div className="rounded-[24px] border border-white/10 bg-black/20 p-4">
|
||
<div className="flex flex-col gap-2 md:flex-row md:items-end md:justify-between">
|
||
<div>
|
||
<div className="text-sm font-semibold text-white">Entry visibility overrides</div>
|
||
<p className="mt-1 text-sm leading-6 text-slate-400">Hide specific linked challenge entries from the derived world feed when moderation or editorial context requires it.</p>
|
||
</div>
|
||
<div className="text-xs uppercase tracking-[0.16em] text-slate-500">{hiddenIds.length} hidden</div>
|
||
</div>
|
||
|
||
<div className="mt-4 grid gap-3 md:grid-cols-2 xl:grid-cols-3">
|
||
{items.map((item) => {
|
||
const hidden = hiddenIds.includes(item.id)
|
||
const statusLabel = item.status === 'winner' ? 'Winner' : item.status === 'finalist' ? 'Finalist' : 'Entry'
|
||
|
||
return (
|
||
<button
|
||
key={item.id}
|
||
type="button"
|
||
onClick={() => onToggle(item.id)}
|
||
className={`flex items-center gap-3 rounded-[20px] border px-3 py-3 text-left transition ${hidden ? 'border-amber-300/25 bg-amber-400/10' : 'border-white/10 bg-white/[0.03] hover:bg-white/[0.05]'}`}
|
||
>
|
||
<div className="h-14 w-14 shrink-0 overflow-hidden rounded-2xl border border-white/10 bg-slate-950">
|
||
{item.image ? <img src={item.image} alt="" className="h-full w-full object-cover" /> : <div className="flex h-full w-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="truncate text-sm font-semibold text-white">{item.title}</div>
|
||
<div className="mt-1 text-[11px] uppercase tracking-[0.16em] text-slate-500">{statusLabel}</div>
|
||
<div className={`mt-2 text-xs font-semibold ${hidden ? 'text-amber-100' : 'text-emerald-100'}`}>{hidden ? 'Hidden from world entries' : 'Visible on world page'}</div>
|
||
</div>
|
||
</button>
|
||
)
|
||
})}
|
||
</div>
|
||
|
||
{error ? <div className="mt-3 text-sm text-rose-300">{error}</div> : null}
|
||
</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 suggestions = props.suggestions || null
|
||
const reviewQueue = world?.submission_review_queue || { counts: { pending: 0, live: 0, removed: 0, blocked: 0, featured: 0 }, items: [] }
|
||
|
||
const initialRelations = Array.isArray(world?.relations) ? normalizeRelations(world.relations.map((relation) => relationForEditor({
|
||
section_key: relation.section_key || sectionOptions?.[0]?.value || 'featured_artworks',
|
||
related_type: relation.related_type || relationTypeOptions?.[0]?.value || 'artwork',
|
||
related_id: relation.related_id || '',
|
||
context_label: relation.context_label || '',
|
||
sort_order: relation.sort_order || 0,
|
||
is_featured: Boolean(relation.is_featured),
|
||
preview: relation.preview || null,
|
||
}))) : []
|
||
|
||
const form = useForm({
|
||
title: world?.title || '',
|
||
slug: world?.slug || '',
|
||
tagline: world?.tagline || '',
|
||
summary: world?.summary || '',
|
||
teaser_title: world?.teaser_title || '',
|
||
teaser_summary: world?.teaser_summary || '',
|
||
description: world?.description || '',
|
||
cover_path: world?.cover_path || '',
|
||
teaser_image_path: world?.teaser_image_path || '',
|
||
theme_key: world?.theme_key ?? '',
|
||
accent_color: world?.accent_color || '',
|
||
accent_color_secondary: world?.accent_color_secondary || '',
|
||
background_motif: world?.background_motif || '',
|
||
icon_name: world?.icon_name || '',
|
||
status: world?.status || 'draft',
|
||
type: world?.type || 'seasonal',
|
||
published_at: toDateTimeLocal(world?.published_at),
|
||
starts_at: toDateTimeLocal(world?.starts_at),
|
||
ends_at: toDateTimeLocal(world?.ends_at),
|
||
promotion_starts_at: toDateTimeLocal(world?.promotion_starts_at),
|
||
promotion_ends_at: toDateTimeLocal(world?.promotion_ends_at),
|
||
accepts_submissions: Boolean(world?.accepts_submissions),
|
||
participation_mode: world?.participation_mode || (world?.accepts_submissions ? 'manual_approval' : 'closed'),
|
||
submission_starts_at: toDateTimeLocal(world?.submission_starts_at),
|
||
submission_ends_at: toDateTimeLocal(world?.submission_ends_at),
|
||
submission_note_enabled: world?.submission_note_enabled !== false,
|
||
community_section_enabled: world?.community_section_enabled !== false,
|
||
allow_readd_after_removal: world?.allow_readd_after_removal !== false,
|
||
is_featured: Boolean(world?.is_featured),
|
||
is_active_campaign: Boolean(world?.is_active_campaign),
|
||
is_homepage_featured: Boolean(world?.is_homepage_featured),
|
||
campaign_priority: world?.campaign_priority ?? '',
|
||
is_recurring: Boolean(world?.is_recurring),
|
||
recurrence_key: world?.recurrence_key || '',
|
||
recurrence_rule: world?.recurrence_rule || '',
|
||
edition_year: world?.edition_year || '',
|
||
cta_label: world?.cta_label || '',
|
||
cta_url: world?.cta_url || '',
|
||
badge_label: world?.badge_label || '',
|
||
campaign_label: world?.campaign_label || '',
|
||
badge_description: world?.badge_description || '',
|
||
submission_guidelines: world?.submission_guidelines || '',
|
||
badge_url: world?.badge_url || '',
|
||
seo_title: world?.seo_title || '',
|
||
seo_description: world?.seo_description || '',
|
||
og_image_path: world?.og_image_path || '',
|
||
recap_status: world?.recap_status || 'draft',
|
||
recap_title: world?.recap_title || '',
|
||
recap_summary: world?.recap_summary || '',
|
||
recap_intro: world?.recap_intro || '',
|
||
recap_editor_note: world?.recap_editor_note || '',
|
||
recap_cover_path: world?.recap_cover_path || '',
|
||
recap_article_id: world?.recap_article_id || '',
|
||
linked_challenge_id: world?.linked_challenge_id || '',
|
||
show_linked_challenge_section: world?.show_linked_challenge_section !== false,
|
||
show_linked_challenge_entries: world?.show_linked_challenge_entries !== false,
|
||
show_linked_challenge_winners: world?.show_linked_challenge_winners !== false,
|
||
show_linked_challenge_finalists: world?.show_linked_challenge_finalists !== false,
|
||
auto_grant_challenge_world_rewards: world?.auto_grant_challenge_world_rewards !== false,
|
||
challenge_teaser_override: world?.challenge_teaser_override || '',
|
||
hidden_linked_challenge_artwork_ids_json: Array.isArray(world?.hidden_linked_challenge_artwork_ids_json) ? world.hidden_linked_challenge_artwork_ids_json : [],
|
||
related_tags_json: Array.isArray(world?.related_tags_json) ? world.related_tags_json : [],
|
||
section_order_json: Array.isArray(world?.section_order_json) && world.section_order_json.length > 0 ? world.section_order_json : sectionOptions.map((option) => option.value),
|
||
section_visibility_json: initialSectionVisibility(sectionOptions, world?.section_visibility_json),
|
||
relations: initialRelations,
|
||
})
|
||
|
||
const [pickerState, setPickerState] = useState({ open: false, index: null })
|
||
const [linkedChallengePickerOpen, setLinkedChallengePickerOpen] = useState(false)
|
||
const [linkedChallengePreview, setLinkedChallengePreview] = useState(world?.linked_challenge || null)
|
||
const [recapArticlePickerOpen, setRecapArticlePickerOpen] = useState(false)
|
||
const [recapArticlePreview, setRecapArticlePreview] = useState(world?.recap_article || null)
|
||
const [activeTab, setActiveTab] = useState('basics')
|
||
const [temporaryMediaPaths, setTemporaryMediaPaths] = useState({
|
||
cover: '',
|
||
teaser: '',
|
||
og: '',
|
||
recap: '',
|
||
})
|
||
|
||
const themeMap = useMemo(() => Object.fromEntries(themeOptions.map((option) => [option.value, option])), [themeOptions])
|
||
const sectionMap = useMemo(() => Object.fromEntries(sectionOptions.map((option) => [option.value, option])), [sectionOptions])
|
||
const tagString = useMemo(() => (Array.isArray(form.data.related_tags_json) ? form.data.related_tags_json.join(', ') : ''), [form.data.related_tags_json])
|
||
const linkedChallengeEntryPreviewItems = useMemo(() => Array.isArray(linkedChallengePreview?.entry_preview_items) ? linkedChallengePreview.entry_preview_items : [], [linkedChallengePreview])
|
||
|
||
const selectedTheme = form.data.theme_key ? themeMap[form.data.theme_key] : null
|
||
const previewWorld = useMemo(() => buildPreviewWorld(form.data, world, themeOptions, typeOptions, filesBaseUrl), [filesBaseUrl, form.data, world, themeOptions, typeOptions])
|
||
const recapCoverPreviewUrl = useMemo(() => resolveMediaUrl(form.data.recap_cover_path, world?.recap_cover_path === form.data.recap_cover_path ? world?.recap_cover_url || '' : '', filesBaseUrl), [filesBaseUrl, form.data.recap_cover_path, world?.recap_cover_path, world?.recap_cover_url])
|
||
|
||
const relationCounts = useMemo(() => form.data.relations.reduce((counts, relation) => ({ ...counts, [relation.section_key]: (counts[relation.section_key] || 0) + 1 }), {}), [form.data.relations])
|
||
const enabledSectionsCount = useMemo(() => Object.values(form.data.section_visibility_json || {}).filter(Boolean).length, [form.data.section_visibility_json])
|
||
const defaultAnalyticsRange = world?.analytics?.default_range || '30d'
|
||
const analyticsSummary = world?.analytics?.ranges?.[defaultAnalyticsRange]?.summary || null
|
||
|
||
const previewSections = useMemo(() => {
|
||
const visibleKeys = (form.data.section_order_json || []).filter((key) => form.data.section_visibility_json?.[key] !== false)
|
||
return visibleKeys.map((key) => ({
|
||
key,
|
||
label: sectionMap[key]?.label || key,
|
||
count: form.data.relations.filter((relation) => relation.section_key === key).length,
|
||
items: form.data.relations.filter((relation) => relation.section_key === key).map((relation) => relation.preview).filter(Boolean).slice(0, 3),
|
||
}))
|
||
}, [form.data.section_order_json, form.data.section_visibility_json, form.data.relations, sectionMap])
|
||
|
||
const recapStatsSnapshot = world?.recap_stats_snapshot || null
|
||
const recapStatsSummary = recapStatsSnapshot?.summary || null
|
||
const canPublishRecap = Boolean(world && (world.status === 'archived' || (world.ends_at && new Date(world.ends_at) < new Date())))
|
||
|
||
const errorEntries = Object.entries(form.errors || {})
|
||
const tabErrorCounts = useMemo(() => WORLD_EDITOR_TABS.reduce((counts, tab) => ({
|
||
...counts,
|
||
[tab.id]: errorEntries.filter(([key]) => errorBelongsToTab(tab.id, key)).length,
|
||
}), {}), [errorEntries])
|
||
const editorTabs = useMemo(() => WORLD_EDITOR_TABS.map((tab) => {
|
||
let meta = ''
|
||
|
||
switch (tab.id) {
|
||
case 'basics':
|
||
meta = form.data.title ? 'Named and writable' : 'Needs title'
|
||
break
|
||
case 'structure':
|
||
meta = form.data.relations.length > 0 ? `${form.data.relations.length} relation${form.data.relations.length === 1 ? '' : 's'}` : 'No relations yet'
|
||
break
|
||
case 'suggestions':
|
||
meta = !world
|
||
? 'Save to unlock'
|
||
: `${suggestions?.summary?.available_count || 0} ready · ${suggestions?.summary?.pinned_count || 0} pinned`
|
||
break
|
||
case 'community':
|
||
meta = form.data.participation_mode === 'closed'
|
||
? 'Closed to creators'
|
||
: form.data.participation_mode === 'auto_add'
|
||
? `${reviewQueue?.counts?.live || 0} live now`
|
||
: `${reviewQueue?.counts?.pending || 0} pending review`
|
||
break
|
||
case 'publishing':
|
||
meta = form.data.is_recurring ? 'Recurring workflow' : 'Single edition'
|
||
break
|
||
case 'presentation':
|
||
meta = selectedTheme?.label || 'Custom identity'
|
||
break
|
||
case 'recap':
|
||
meta = form.data.recap_title || recapArticlePreview?.title
|
||
? `${form.data.recap_status === 'published' ? 'Published' : 'Draft'} recap`
|
||
: 'Optional archive layer'
|
||
break
|
||
case 'seo':
|
||
meta = form.data.seo_title || form.data.seo_description ? 'Configured' : 'Optional metadata'
|
||
break
|
||
case 'analytics':
|
||
meta = analyticsSummary?.views > 0 ? `${analyticsSummary.views} tracked views` : (world ? 'Ready for measurement' : 'Available after create')
|
||
break
|
||
default:
|
||
meta = ''
|
||
}
|
||
|
||
return {
|
||
...tab,
|
||
meta,
|
||
errorCount: tabErrorCounts[tab.id] || 0,
|
||
}
|
||
}), [analyticsSummary?.views, form.data.participation_mode, form.data.recap_status, form.data.recap_title, form.data.title, form.data.relations.length, form.data.is_recurring, form.data.seo_description, form.data.seo_title, recapArticlePreview?.title, reviewQueue?.counts?.live, reviewQueue?.counts?.pending, selectedTheme?.label, suggestions?.summary?.available_count, suggestions?.summary?.pinned_count, tabErrorCounts, world])
|
||
const firstErrorTab = useMemo(() => editorTabs.find((tab) => tab.errorCount > 0) || null, [editorTabs])
|
||
const currentTab = editorTabs.find((tab) => tab.id === activeTab) || editorTabs[0]
|
||
const editingRelation = pickerState.index === null ? buildDefaultRelation(sectionOptions, relationTypeOptions, form.data.relations.length) : form.data.relations[pickerState.index]
|
||
const [actionConfirm, setActionConfirm] = useState(DEFAULT_ACTION_CONFIRM)
|
||
const [actionReviewNote, setActionReviewNote] = useState('')
|
||
const [actionCopyMode, setActionCopyMode] = useState(DEFAULT_ACTION_CONFIRM.defaultCopyMode)
|
||
const [actionBusy, setActionBusy] = useState(false)
|
||
const [suggestionBusyKey, setSuggestionBusyKey] = useState('')
|
||
const [suggestionNotice, setSuggestionNotice] = useState('')
|
||
|
||
useEffect(() => {
|
||
if (firstErrorTab && firstErrorTab.id !== activeTab) {
|
||
setActiveTab(firstErrorTab.id)
|
||
}
|
||
}, [activeTab, firstErrorTab])
|
||
|
||
const searchEntities = async (type, query) => {
|
||
const url = new URL(props.entitySearchUrl, window.location.origin)
|
||
url.searchParams.set('type', type)
|
||
url.searchParams.set('q', query)
|
||
|
||
const response = await fetch(url.toString(), {
|
||
headers: { Accept: 'application/json' },
|
||
credentials: 'same-origin',
|
||
})
|
||
|
||
if (!response.ok) {
|
||
return []
|
||
}
|
||
|
||
const payload = await response.json()
|
||
return Array.isArray(payload.items) ? payload.items : []
|
||
}
|
||
|
||
const submit = (event) => {
|
||
event.preventDefault()
|
||
|
||
const options = {
|
||
onSuccess: () => {
|
||
setTemporaryMediaPaths({ cover: '', teaser: '', og: '', recap: '' })
|
||
},
|
||
}
|
||
|
||
if (props.updateUrl) {
|
||
form.patch(props.updateUrl, options)
|
||
return
|
||
}
|
||
|
||
form.post(props.storeUrl, options)
|
||
}
|
||
|
||
const deleteTemporaryMediaPath = async (path) => {
|
||
if (!props.mediaSupport?.delete_url || !path) return
|
||
|
||
await fetch(props.mediaSupport.delete_url, {
|
||
method: 'DELETE',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '',
|
||
Accept: 'application/json',
|
||
},
|
||
credentials: 'same-origin',
|
||
body: JSON.stringify({
|
||
path,
|
||
world_id: world?.id || undefined,
|
||
}),
|
||
})
|
||
}
|
||
|
||
const applyCoverToOg = async () => {
|
||
if (!form.data.cover_path) return
|
||
|
||
const currentOgPath = form.data.og_image_path
|
||
const shouldDeleteTemporaryOg = temporaryMediaPaths.og !== '' && temporaryMediaPaths.og === currentOgPath && currentOgPath !== form.data.cover_path
|
||
|
||
if (shouldDeleteTemporaryOg) {
|
||
try {
|
||
await deleteTemporaryMediaPath(currentOgPath)
|
||
} catch {
|
||
// Leave the field update available even if cleanup fails.
|
||
}
|
||
}
|
||
|
||
form.setData('og_image_path', form.data.cover_path)
|
||
setTemporaryMediaPaths((current) => ({ ...current, og: '' }))
|
||
}
|
||
|
||
const handleThemeChange = (nextThemeKey, force = false) => {
|
||
const nextTheme = themeMap[nextThemeKey]
|
||
const previousTheme = themeMap[form.data.theme_key]
|
||
const currentTags = Array.isArray(form.data.related_tags_json) ? form.data.related_tags_json : []
|
||
|
||
const shouldReplace = (currentValue, nextValue, previousValue) => {
|
||
if (!nextValue) return currentValue
|
||
if (force || !currentValue || currentValue === previousValue) return nextValue
|
||
return currentValue
|
||
}
|
||
|
||
const nextData = {
|
||
...form.data,
|
||
theme_key: String(nextThemeKey || ''),
|
||
accent_color: shouldReplace(form.data.accent_color, nextTheme?.accent_color || '', previousTheme?.accent_color || ''),
|
||
accent_color_secondary: shouldReplace(form.data.accent_color_secondary, nextTheme?.accent_color_secondary || '', previousTheme?.accent_color_secondary || ''),
|
||
background_motif: shouldReplace(form.data.background_motif, nextTheme?.background_motif || '', previousTheme?.background_motif || ''),
|
||
icon_name: shouldReplace(form.data.icon_name, nextTheme?.icon_name || '', previousTheme?.icon_name || ''),
|
||
badge_label: shouldReplace(form.data.badge_label, nextTheme?.suggested_badge_label || '', previousTheme?.suggested_badge_label || ''),
|
||
cta_label: shouldReplace(form.data.cta_label, nextTheme?.suggested_cta_label || '', previousTheme?.suggested_cta_label || ''),
|
||
related_tags_json: force || currentTags.length === 0 || arraysEqual(currentTags, previousTheme?.related_tags_json || [])
|
||
? [...(nextTheme?.related_tags_json || [])]
|
||
: currentTags,
|
||
}
|
||
|
||
form.setData(nextData)
|
||
}
|
||
|
||
const openRelationPicker = (index = null) => setPickerState({ open: true, index })
|
||
const closeRelationPicker = () => setPickerState({ open: false, index: null })
|
||
const saveRelation = (relation) => {
|
||
const nextRelations = pickerState.index === null
|
||
? normalizeRelations([...form.data.relations, relation])
|
||
: normalizeRelations(form.data.relations.map((item, index) => (index === pickerState.index ? relation : item)))
|
||
|
||
form.setData('relations', nextRelations)
|
||
closeRelationPicker()
|
||
}
|
||
|
||
const removeRelation = (index) => form.setData('relations', normalizeRelations(form.data.relations.filter((_, relationIndex) => relationIndex !== index)))
|
||
const moveRelation = (index, delta) => {
|
||
const nextIndex = index + delta
|
||
if (nextIndex < 0 || nextIndex >= form.data.relations.length) return
|
||
const next = [...form.data.relations]
|
||
const [entry] = next.splice(index, 1)
|
||
next.splice(nextIndex, 0, entry)
|
||
form.setData('relations', normalizeRelations(next))
|
||
}
|
||
|
||
const updateSectionControls = (nextOrder, nextVisibility) => {
|
||
form.setData({
|
||
...form.data,
|
||
section_order_json: nextOrder,
|
||
section_visibility_json: nextVisibility,
|
||
})
|
||
}
|
||
|
||
const closeActionConfirm = () => {
|
||
if (actionBusy) return
|
||
setActionConfirm(DEFAULT_ACTION_CONFIRM)
|
||
setActionReviewNote('')
|
||
setActionCopyMode(DEFAULT_ACTION_CONFIRM.defaultCopyMode)
|
||
}
|
||
|
||
const saveLinkedChallenge = (challenge) => {
|
||
setLinkedChallengePreview(challenge)
|
||
form.setData({
|
||
...form.data,
|
||
linked_challenge_id: challenge?.id || '',
|
||
hidden_linked_challenge_artwork_ids_json: [],
|
||
})
|
||
setLinkedChallengePickerOpen(false)
|
||
}
|
||
|
||
|
||
const saveRecapArticle = (article) => {
|
||
setRecapArticlePreview(article)
|
||
form.setData({
|
||
...form.data,
|
||
recap_article_id: article?.id || '',
|
||
})
|
||
setRecapArticlePickerOpen(false)
|
||
}
|
||
|
||
const clearRecapArticle = () => {
|
||
setRecapArticlePreview(null)
|
||
form.setData('recap_article_id', '')
|
||
}
|
||
const clearLinkedChallenge = () => {
|
||
setLinkedChallengePreview(null)
|
||
form.setData({
|
||
...form.data,
|
||
linked_challenge_id: '',
|
||
challenge_teaser_override: '',
|
||
hidden_linked_challenge_artwork_ids_json: [],
|
||
})
|
||
}
|
||
|
||
const toggleHiddenLinkedChallengeEntry = (artworkId) => {
|
||
const currentIds = Array.isArray(form.data.hidden_linked_challenge_artwork_ids_json) ? form.data.hidden_linked_challenge_artwork_ids_json : []
|
||
const nextIds = currentIds.includes(artworkId)
|
||
? currentIds.filter((id) => id !== artworkId)
|
||
: [...currentIds, artworkId]
|
||
|
||
form.setData('hidden_linked_challenge_artwork_ids_json', nextIds)
|
||
}
|
||
|
||
const openActionConfirm = (config) => {
|
||
if (!config?.url) return
|
||
|
||
setActionReviewNote('')
|
||
setActionCopyMode(config.defaultCopyMode || DEFAULT_ACTION_CONFIRM.defaultCopyMode)
|
||
setActionConfirm({
|
||
...DEFAULT_ACTION_CONFIRM,
|
||
...config,
|
||
open: true,
|
||
})
|
||
}
|
||
|
||
const confirmAction = () => {
|
||
if (!actionConfirm.url || actionBusy) return
|
||
|
||
const payload = {
|
||
...(actionConfirm.noteEnabled ? { review_note: actionReviewNote } : {}),
|
||
...(actionConfirm.copyModeEnabled ? { copy_mode: actionCopyMode } : {}),
|
||
}
|
||
|
||
setActionBusy(true)
|
||
router.post(actionConfirm.url, payload, {
|
||
preserveScroll: actionConfirm.preserveScroll,
|
||
onSuccess: () => {
|
||
setActionConfirm(DEFAULT_ACTION_CONFIRM)
|
||
setActionReviewNote('')
|
||
},
|
||
onFinish: () => {
|
||
setActionBusy(false)
|
||
},
|
||
})
|
||
}
|
||
|
||
const runDuplicateAction = (url, promptText, copyModeOptions = []) => {
|
||
openActionConfirm({
|
||
url,
|
||
title: 'Duplicate world?',
|
||
message: promptText,
|
||
confirmLabel: 'Continue',
|
||
cancelLabel: 'Cancel',
|
||
confirmTone: 'accent',
|
||
copyModeEnabled: copyModeOptions.length > 0,
|
||
copyModeOptions,
|
||
defaultCopyMode: copyModeOptions.find((option) => option.value === 'with_relations')?.value || copyModeOptions[0]?.value || 'with_relations',
|
||
preserveScroll: false,
|
||
})
|
||
}
|
||
|
||
const runSubmissionAction = (url, promptText, options = {}) => {
|
||
const {
|
||
title = 'Confirm submission action',
|
||
confirmLabel = 'Confirm',
|
||
cancelLabel = 'Cancel',
|
||
confirmTone = 'accent',
|
||
noteEnabled = false,
|
||
} = options
|
||
|
||
openActionConfirm({
|
||
url,
|
||
title,
|
||
message: promptText,
|
||
confirmLabel,
|
||
cancelLabel,
|
||
confirmTone,
|
||
noteEnabled,
|
||
preserveScroll: true,
|
||
})
|
||
}
|
||
|
||
const postSuggestionAction = async (url, payload) => {
|
||
const response = await fetch(url, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
'X-CSRF-TOKEN': document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '',
|
||
Accept: 'application/json',
|
||
},
|
||
credentials: 'same-origin',
|
||
body: JSON.stringify(payload),
|
||
})
|
||
|
||
const data = await response.json().catch(() => ({}))
|
||
|
||
if (!response.ok) {
|
||
throw new Error(data?.message || 'Suggestion action failed.')
|
||
}
|
||
|
||
return data
|
||
}
|
||
|
||
const refreshSuggestions = () => {
|
||
if (!world) return
|
||
|
||
router.reload({
|
||
only: ['suggestions'],
|
||
preserveScroll: true,
|
||
preserveState: true,
|
||
})
|
||
}
|
||
|
||
const handleSuggestionAction = async (item, action, payload = {}, afterSuccess = null) => {
|
||
const url = props.suggestionActions?.[action]
|
||
if (!url || !item) return
|
||
|
||
setSuggestionBusyKey(item.key)
|
||
|
||
try {
|
||
const response = await postSuggestionAction(url, {
|
||
related_type: item.entity_type,
|
||
related_id: item.entity_id,
|
||
...payload,
|
||
})
|
||
|
||
if (typeof afterSuccess === 'function') {
|
||
afterSuccess(response)
|
||
}
|
||
|
||
setSuggestionNotice(response?.message || 'Suggestion updated.')
|
||
refreshSuggestions()
|
||
} catch (error) {
|
||
setSuggestionNotice(error?.message || 'Suggestion action failed.')
|
||
} finally {
|
||
setSuggestionBusyKey('')
|
||
}
|
||
}
|
||
|
||
const addSuggestionRelation = (item, sectionKey, isFeatured) => handleSuggestionAction(item, 'add', {
|
||
section_key: sectionKey,
|
||
is_featured: isFeatured,
|
||
}, (response) => {
|
||
if (response?.relation) {
|
||
form.setData('relations', upsertRelation(form.data.relations, response.relation))
|
||
}
|
||
})
|
||
|
||
const pinSuggestion = (item, sectionKey) => handleSuggestionAction(item, 'pin', {
|
||
section_key: sectionKey,
|
||
})
|
||
|
||
const dismissSuggestion = (item) => handleSuggestionAction(item, 'dismiss')
|
||
|
||
const markSuggestionNotRelevant = (item) => handleSuggestionAction(item, 'notRelevant')
|
||
|
||
const restoreSuggestion = (item) => handleSuggestionAction(item, 'restore')
|
||
|
||
return (
|
||
<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>}
|
||
|
||
{activeTab === 'suggestions' ? (
|
||
<WorldSuggestionsPanel
|
||
suggestions={suggestions}
|
||
notice={suggestionNotice}
|
||
worldExists={Boolean(world)}
|
||
busyKey={suggestionBusyKey}
|
||
onAddFeatured={(item, sectionKey) => addSuggestionRelation(item, sectionKey, true)}
|
||
onAddSection={(item, sectionKey, isFeatured = false) => addSuggestionRelation(item, sectionKey, isFeatured)}
|
||
onPin={pinSuggestion}
|
||
onDismiss={dismissSuggestion}
|
||
onNotRelevant={markSuggestionNotRelevant}
|
||
onRestore={restoreSuggestion}
|
||
/>
|
||
) : null}
|
||
</div>
|
||
</WorldEditorSection>
|
||
|
||
<WorldEditorSection title="Linked challenge automation" description="Use one primary challenge to drive a dedicated challenge panel, entry rail, winner carry-over, and campaign freshness across public surfaces.">
|
||
<div className="grid gap-4">
|
||
<LinkedChallengeCard challenge={linkedChallengePreview} onChange={() => setLinkedChallengePickerOpen(true)} onClear={clearLinkedChallenge} />
|
||
|
||
<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.show_linked_challenge_section} onChange={(event) => form.setData('show_linked_challenge_section', event.target.checked)} label="Show challenge panel on the world page" size={20} variant="accent" />
|
||
</div>
|
||
<div className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3">
|
||
<Checkbox checked={form.data.show_linked_challenge_entries} onChange={(event) => form.setData('show_linked_challenge_entries', event.target.checked)} label="Show linked challenge entries rail" size={20} variant="accent" />
|
||
</div>
|
||
<div className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3">
|
||
<Checkbox checked={form.data.show_linked_challenge_winners} onChange={(event) => form.setData('show_linked_challenge_winners', event.target.checked)} label="Show linked challenge winner section" size={20} variant="accent" />
|
||
</div>
|
||
<div className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3">
|
||
<Checkbox checked={form.data.auto_grant_challenge_world_rewards} onChange={(event) => form.setData('auto_grant_challenge_world_rewards', event.target.checked)} label="Auto-grant winner and finalist rewards from the linked challenge" size={20} variant="accent" />
|
||
</div>
|
||
</div>
|
||
|
||
<div className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3">
|
||
<Checkbox checked={form.data.show_linked_challenge_finalists} onChange={(event) => form.setData('show_linked_challenge_finalists', event.target.checked)} label="Show linked challenge finalists when they exist" size={20} variant="accent" />
|
||
<div className="mt-2 text-xs leading-5 text-slate-500">Finalists now come from structured challenge outcomes, so worlds can surface them automatically without waiting for manual recap edits.</div>
|
||
</div>
|
||
|
||
<label className="grid gap-2 text-sm text-slate-300">
|
||
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Challenge teaser override</span>
|
||
<textarea value={form.data.challenge_teaser_override} onChange={(event) => form.setData('challenge_teaser_override', event.target.value)} rows={4} placeholder="Optional world-specific framing for the linked challenge panel." className="rounded-[24px] border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
||
<span className="text-xs leading-5 text-slate-500">Leave this blank to reuse the challenge summary automatically. Use it when the world needs a more editorial campaign voice than the raw challenge copy.</span>
|
||
</label>
|
||
|
||
<LinkedChallengeEntryVisibilityManager
|
||
challenge={linkedChallengePreview}
|
||
hiddenIds={Array.isArray(form.data.hidden_linked_challenge_artwork_ids_json) ? form.data.hidden_linked_challenge_artwork_ids_json : []}
|
||
onToggle={toggleHiddenLinkedChallengeEntry}
|
||
error={form.errors.hidden_linked_challenge_artwork_ids_json}
|
||
/>
|
||
|
||
{linkedChallengePreview && linkedChallengeEntryPreviewItems.length === 0 ? <div className="rounded-2xl border border-dashed border-white/10 bg-white/[0.03] px-4 py-3 text-sm text-slate-400">No visible challenge entries are available yet for per-entry overrides.</div> : null}
|
||
</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 === 'suggestions' ? (
|
||
<WorldSuggestionsPanel
|
||
suggestions={suggestions}
|
||
notice={suggestionNotice}
|
||
worldExists={Boolean(world)}
|
||
busyKey={suggestionBusyKey}
|
||
onAddFeatured={(item, sectionKey) => addSuggestionRelation(item, sectionKey, true)}
|
||
onAddSection={(item, sectionKey, isFeatured = false) => addSuggestionRelation(item, sectionKey, isFeatured)}
|
||
onPin={pinSuggestion}
|
||
onDismiss={dismissSuggestion}
|
||
onNotRelevant={markSuggestionNotRelevant}
|
||
onRestore={restoreSuggestion}
|
||
/>
|
||
) : 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="rounded-[24px] border border-white/10 bg-black/20 p-4">
|
||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Activation and promotion</div>
|
||
<div className="mt-2 text-sm leading-6 text-slate-300">Use campaign activation for platform-level surfacing. Homepage feature is a stronger spotlight signal on top of that base state.</div>
|
||
|
||
<div className="mt-4 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.is_active_campaign}
|
||
onChange={(event) => form.setData({
|
||
...form.data,
|
||
is_active_campaign: event.target.checked,
|
||
is_homepage_featured: event.target.checked ? form.data.is_homepage_featured : false,
|
||
})}
|
||
label="Mark as active campaign"
|
||
size={20}
|
||
variant="accent"
|
||
/>
|
||
<div className="mt-2 text-xs leading-5 text-slate-500">Active campaigns become eligible for stronger public surfacing on homepage, upload, and promoted worlds views.</div>
|
||
</div>
|
||
<div className="rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3">
|
||
<Checkbox
|
||
checked={form.data.is_homepage_featured}
|
||
onChange={(event) => form.setData({
|
||
...form.data,
|
||
is_homepage_featured: event.target.checked,
|
||
is_active_campaign: event.target.checked ? true : form.data.is_active_campaign,
|
||
})}
|
||
label="Feature on homepage spotlight"
|
||
size={20}
|
||
variant="accent"
|
||
/>
|
||
<div className="mt-2 text-xs leading-5 text-slate-500">Use this when the campaign should compete for the primary homepage spotlight position.</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="mt-4 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">Campaign priority</span>
|
||
<input type="number" min="0" max="9999" value={form.data.campaign_priority} onChange={(event) => form.setData('campaign_priority', 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">Higher values win when multiple campaigns are active at once.</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">Campaign label</span>
|
||
<input value={form.data.campaign_label} onChange={(event) => form.setData('campaign_label', event.target.value)} placeholder="Spring campaign, Returning seasonal world" className="rounded-2xl border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
||
</label>
|
||
</div>
|
||
|
||
<div className="mt-4 grid gap-4 md:grid-cols-2">
|
||
<DateTimePicker label="Promotion starts" value={form.data.promotion_starts_at} onChange={(nextValue) => form.setData('promotion_starts_at', nextValue)} placeholder="Optional promotion start" clearable className="bg-black/20" />
|
||
<DateTimePicker label="Promotion ends" value={form.data.promotion_ends_at} onChange={(nextValue) => form.setData('promotion_ends_at', nextValue)} placeholder="Optional promotion end" clearable className="bg-black/20" />
|
||
</div>
|
||
<div className="mt-2 text-xs leading-5 text-slate-500">Promotion dates control when the world is highlighted beyond its own page. Leave them blank to use the main campaign window.</div>
|
||
</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 lists and supporting rails" size={20} variant="accent" />
|
||
<div className="mt-2 text-xs leading-5 text-slate-500">Keep this for secondary featured placement on worlds surfaces. Homepage spotlighting is controlled separately by active campaign and homepage feature.</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}
|
||
/>
|
||
|
||
{world?.is_recurring ? (
|
||
<div className="rounded-[24px] border border-white/10 bg-black/20 p-4 text-sm text-slate-300">
|
||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||
<div>
|
||
<div className="text-sm font-semibold text-white">Recurring family status</div>
|
||
<p className="mt-1 max-w-2xl leading-6 text-slate-400">Track whether this editor is pointing at the canonical family edition, an archived edition, or a sibling inside the same recurrence group.</p>
|
||
</div>
|
||
<div className="flex flex-wrap gap-2">
|
||
<span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-200">{world.family_title || world.recurrence_key}</span>
|
||
<span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-200">Edition {world.edition_year || 'Draft'}</span>
|
||
<span className={`rounded-full border px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] ${world.is_canonical_edition ? 'border-emerald-300/20 bg-emerald-400/12 text-emerald-100' : 'border-amber-300/20 bg-amber-400/12 text-amber-100'}`}>{world.is_canonical_edition ? 'Canonical family edition' : 'Archive or sibling edition'}</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="mt-4 grid gap-3 md:grid-cols-2 xl:grid-cols-4">
|
||
<div className="rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3">
|
||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Family key</div>
|
||
<div className="mt-2 font-semibold text-white">{world.recurrence_key}</div>
|
||
</div>
|
||
<div className="rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3">
|
||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Editions</div>
|
||
<div className="mt-2 font-semibold text-white">{world.family_edition_count || 1}</div>
|
||
</div>
|
||
<div className="rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3">
|
||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Archive count</div>
|
||
<div className="mt-2 font-semibold text-white">{world.archive_edition_count || 0}</div>
|
||
</div>
|
||
<div className="rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3">
|
||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Current public edition</div>
|
||
<div className="mt-2 font-semibold text-white">{world.current_edition?.title || 'None published yet'}</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="mt-4 grid gap-3 md:grid-cols-3">
|
||
<a href={world.family_url || '#'} className={`rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3 ${world.family_url ? 'text-white' : 'pointer-events-none text-slate-500'}`}>
|
||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Family route</div>
|
||
<div className="mt-2 text-sm font-semibold">{world.family_url ? 'Open canonical family page' : 'Unavailable'}</div>
|
||
</a>
|
||
<div className="rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3">
|
||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Previous edition</div>
|
||
<div className="mt-2 text-sm font-semibold text-white">{world.previous_edition?.title || 'None'}</div>
|
||
</div>
|
||
<div className="rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3">
|
||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Next edition</div>
|
||
<div className="mt-2 text-sm font-semibold text-white">{world.next_edition?.title || 'None yet'}</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
|
||
<WorldDuplicateActionMenu
|
||
duplicateUrl={duplicateActions?.duplicateUrl}
|
||
newEditionUrl={duplicateActions?.newEditionUrl}
|
||
canCreateEdition={Boolean(duplicateActions?.canCreateEdition)}
|
||
copyModeCount={Math.max(duplicateActions?.duplicateModeOptions?.length || 0, duplicateActions?.newEditionModeOptions?.length || 0)}
|
||
onDuplicate={() => runDuplicateAction(duplicateActions?.duplicateUrl, 'Duplicate this world into a new draft?', duplicateActions?.duplicateModeOptions || [])}
|
||
onCreateEdition={() => runDuplicateAction(duplicateActions?.newEditionUrl, 'Create the next edition draft from this world?', duplicateActions?.newEditionModeOptions || [])}
|
||
/>
|
||
</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}
|
||
{Array.isArray(item.world_rewards) && item.world_rewards.length > 0 ? (
|
||
<div className="mt-3 flex flex-wrap gap-2">
|
||
{item.world_rewards.map((reward) => (
|
||
<span key={reward.id} className="rounded-full border border-sky-300/20 bg-sky-400/10 px-3 py-1 text-xs font-semibold uppercase tracking-[0.14em] text-sky-100">{reward.reward_label}</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.can_grant_manual_rewards ? <button type="button" onClick={() => runSubmissionAction(item.actions?.grant_rewards?.winner, 'Grant winner recognition to this creator for this world edition?', { title: 'Grant winner reward?', confirmLabel: 'Grant winner', noteEnabled: true })} className="rounded-2xl border border-emerald-300/20 bg-emerald-400/10 px-4 py-2 text-sm font-semibold text-emerald-100">Winner</button> : null}
|
||
{item.can_grant_manual_rewards ? <button type="button" onClick={() => runSubmissionAction(item.actions?.grant_rewards?.finalist, 'Grant finalist recognition to this creator for this world edition?', { title: 'Grant finalist reward?', confirmLabel: 'Grant finalist', noteEnabled: true })} className="rounded-2xl border border-violet-300/20 bg-violet-400/10 px-4 py-2 text-sm font-semibold text-violet-100">Finalist</button> : null}
|
||
{item.can_grant_manual_rewards ? <button type="button" onClick={() => runSubmissionAction(item.actions?.grant_rewards?.spotlight, 'Grant spotlight recognition to this creator for this world edition?', { title: 'Grant spotlight reward?', confirmLabel: 'Grant spotlight', 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">Spotlight</button> : null}
|
||
{Array.isArray(item.world_rewards) && item.world_rewards.some((reward) => reward.reward_type === 'winner') ? <button type="button" onClick={() => runSubmissionAction(item.actions?.revoke_rewards?.winner, 'Revoke winner recognition for this creator in this world edition?', { title: 'Revoke winner reward?', confirmLabel: 'Revoke winner', confirmTone: 'danger' })} className="rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-2 text-sm font-semibold text-slate-200">Revoke winner</button> : null}
|
||
{Array.isArray(item.world_rewards) && item.world_rewards.some((reward) => reward.reward_type === 'finalist') ? <button type="button" onClick={() => runSubmissionAction(item.actions?.revoke_rewards?.finalist, 'Revoke finalist recognition for this creator in this world edition?', { title: 'Revoke finalist reward?', confirmLabel: 'Revoke finalist', confirmTone: 'danger' })} className="rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-2 text-sm font-semibold text-slate-200">Revoke finalist</button> : null}
|
||
{Array.isArray(item.world_rewards) && item.world_rewards.some((reward) => reward.reward_type === 'spotlight') ? <button type="button" onClick={() => runSubmissionAction(item.actions?.revoke_rewards?.spotlight, 'Revoke spotlight recognition for this creator in this world edition?', { title: 'Revoke spotlight reward?', confirmLabel: 'Revoke spotlight', confirmTone: 'danger' })} className="rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-2 text-sm font-semibold text-slate-200">Revoke spotlight</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 || '' }))
|
||
}}
|
||
/>
|
||
|
||
<WorldMediaUploadField
|
||
label="Teaser image"
|
||
slot="teaser"
|
||
value={form.data.teaser_image_path}
|
||
previewUrl={resolveMediaUrl(form.data.teaser_image_path, world?.teaser_image_path === form.data.teaser_image_path ? world?.teaser_image_url || '' : '', filesBaseUrl)}
|
||
emptyLabel="Falls back to cover image when blank"
|
||
helperText="Use a promo-specific image when homepage, upload, or world index surfaces need a tighter campaign crop than the full world cover."
|
||
uploadUrl={props.mediaSupport?.upload_url}
|
||
deleteUrl={props.mediaSupport?.delete_url}
|
||
worldId={world?.id || null}
|
||
isTemporaryValue={temporaryMediaPaths.teaser !== '' && temporaryMediaPaths.teaser === form.data.teaser_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('teaser_image_path', path)
|
||
setTemporaryMediaPaths((current) => ({ ...current, teaser: 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">Teaser title</span>
|
||
<input value={form.data.teaser_title} onChange={(event) => form.setData('teaser_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">Optional. Use this for homepage, upload, and world-card headlines without changing the canonical world title.</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">Teaser summary</span>
|
||
<textarea value={form.data.teaser_summary} onChange={(event) => form.setData('teaser_summary', 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>
|
||
|
||
<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 === 'recap' ? (
|
||
<>
|
||
<WorldEditorSection title="Recap framing" description="Prepare the archive-facing editorial layer that turns an ended edition into a meaningful recap instead of a quiet archive shell.">
|
||
<div className="grid gap-4">
|
||
<div className="grid gap-3 md:grid-cols-[minmax(0,1fr)_auto] md:items-start">
|
||
<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">Recap workflow</div>
|
||
<div className="mt-3 flex flex-wrap gap-2">
|
||
<span className={`rounded-full border px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] ${form.data.recap_status === 'published' ? 'border-sky-300/25 bg-sky-400/10 text-sky-100' : 'border-white/10 bg-white/[0.04] text-slate-200'}`}>{form.data.recap_status === 'published' ? 'Published recap' : 'Draft recap'}</span>
|
||
{world?.recap_published_at ? <span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-200">Published {new Date(world.recap_published_at).toLocaleDateString()}</span> : null}
|
||
{canPublishRecap ? <span className="rounded-full border border-emerald-300/20 bg-emerald-400/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-emerald-100">Ready to publish</span> : <span className="rounded-full border border-amber-300/20 bg-amber-400/10 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-amber-100">Publish after the edition ends</span>}
|
||
</div>
|
||
<p className="mt-3 text-sm leading-6 text-slate-300">Recap stays editorial but data-backed. Curated world relations, challenge outcomes, community highlights, and analytics snapshots all flow into the public recap once you publish it.</p>
|
||
</div>
|
||
|
||
<div className="rounded-[24px] border border-white/10 bg-black/20 p-4 text-sm text-slate-300">
|
||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Primary recap metrics</div>
|
||
<div className="mt-3 grid gap-3">
|
||
{recapStatsSummary ? (
|
||
<>
|
||
<div className="rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3"><div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Views</div><div className="mt-2 text-lg font-semibold text-white">{formatCompactNumber(recapStatsSummary.views)}</div></div>
|
||
<div className="rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3"><div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Submissions</div><div className="mt-2 text-lg font-semibold text-white">{formatCompactNumber(recapStatsSummary.submissions)}</div></div>
|
||
<div className="rounded-2xl border border-white/10 bg-white/[0.03] px-4 py-3"><div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Recognitions</div><div className="mt-2 text-lg font-semibold text-white">{formatCompactNumber(recapStatsSummary.reward_grants)}</div></div>
|
||
</>
|
||
) : <div className="rounded-2xl border border-dashed border-white/10 bg-white/[0.03] px-4 py-3 text-sm text-slate-400">A recap snapshot will be captured when you publish the recap.</div>}
|
||
</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">Recap title</span>
|
||
<input value={form.data.recap_title} onChange={(event) => form.setData('recap_title', event.target.value)} placeholder="Halloween World 2026 recap" 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">Leave blank to fall back to “{form.data.title || 'World'} recap”.</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">Recap summary</span>
|
||
<textarea value={form.data.recap_summary} onChange={(event) => form.setData('recap_summary', event.target.value)} rows={4} placeholder="Short archive-facing summary for the recap hero." className="rounded-[24px] 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">Recap intro</span>
|
||
<RichTextEditor
|
||
content={form.data.recap_intro}
|
||
onChange={(nextValue) => form.setData('recap_intro', nextValue)}
|
||
placeholder="Write the archive-facing intro that frames what made this edition memorable."
|
||
error={form.errors.recap_intro}
|
||
minHeight={14}
|
||
autofocus={false}
|
||
/>
|
||
</div>
|
||
|
||
<WorldMediaUploadField
|
||
label="Recap cover"
|
||
slot="recap_cover"
|
||
value={form.data.recap_cover_path}
|
||
previewUrl={recapCoverPreviewUrl}
|
||
emptyLabel="Falls back to the main world cover when blank"
|
||
helperText="Optional. Use a calmer archive-facing image when the recap should feel distinct from the live campaign hero."
|
||
uploadUrl={props.mediaSupport?.upload_url}
|
||
deleteUrl={props.mediaSupport?.delete_url}
|
||
worldId={world?.id || null}
|
||
isTemporaryValue={temporaryMediaPaths.recap !== '' && temporaryMediaPaths.recap === form.data.recap_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('recap_cover_path', path)
|
||
setTemporaryMediaPaths((current) => ({ ...current, recap: path || '' }))
|
||
}}
|
||
/>
|
||
|
||
<label className="grid gap-2 text-sm text-slate-300">
|
||
<span className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Editor note</span>
|
||
<textarea value={form.data.recap_editor_note} onChange={(event) => form.setData('recap_editor_note', event.target.value)} rows={4} placeholder="Internal notes for editorial context, follow-up cleanup, or recap publishing reminders." className="rounded-[24px] border border-white/10 bg-black/20 px-4 py-3 text-white outline-none" />
|
||
<span className="text-xs leading-5 text-slate-500">Internal only. This note is stored with the recap draft but is not shown on the public recap page.</span>
|
||
</label>
|
||
</div>
|
||
</WorldEditorSection>
|
||
|
||
<WorldEditorSection title="Recap article and publish state" description="Link one editorial story when the recap should point visitors toward a fuller write-up, results article, or reflective editorial piece.">
|
||
<div className="grid gap-4">
|
||
<div className="rounded-[24px] border border-white/10 bg-black/20 p-4">
|
||
{recapArticlePreview ? (
|
||
<div className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between">
|
||
<div className="flex min-w-0 gap-4">
|
||
<div className="h-16 w-16 shrink-0 overflow-hidden rounded-[20px] border border-white/10 bg-slate-950">
|
||
{recapArticlePreview.image ? <img src={recapArticlePreview.image} 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-newspaper" /></div>}
|
||
</div>
|
||
<div className="min-w-0">
|
||
<div className="text-base font-semibold text-white">{recapArticlePreview.title}</div>
|
||
{recapArticlePreview.subtitle ? <div className="mt-1 text-xs uppercase tracking-[0.14em] text-slate-500">{recapArticlePreview.subtitle}</div> : null}
|
||
{recapArticlePreview.description ? <div className="mt-2 text-sm leading-6 text-slate-400">{recapArticlePreview.description}</div> : null}
|
||
</div>
|
||
</div>
|
||
<div className="flex shrink-0 flex-wrap gap-2">
|
||
<button type="button" onClick={() => setRecapArticlePickerOpen(true)} className="rounded-2xl border border-sky-300/20 bg-sky-400/10 px-4 py-2 text-sm font-semibold text-sky-100">Change</button>
|
||
<button type="button" onClick={clearRecapArticle} className="rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-2 text-sm font-semibold text-slate-200">Clear</button>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||
<div>
|
||
<div className="text-sm font-semibold text-white">No recap article linked</div>
|
||
<p className="mt-1 text-sm leading-6 text-slate-400">Link an article when this recap should push visitors toward a fuller editorial recap, results post, or archive story.</p>
|
||
</div>
|
||
<button type="button" onClick={() => setRecapArticlePickerOpen(true)} className="rounded-2xl border border-sky-300/20 bg-sky-400/10 px-4 py-2 text-sm font-semibold text-sky-100">Select article</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div className="rounded-[24px] border border-white/10 bg-black/20 p-4 text-sm leading-6 text-slate-300">
|
||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Publish behavior</div>
|
||
<p className="mt-2">Publishing recap captures a stats snapshot and makes the recap-first archive layout public for ended editions. You can keep refining the draft copy before that point.</p>
|
||
</div>
|
||
</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}
|
||
|
||
{activeTab === 'analytics' ? (
|
||
<WorldEditorSection title="World analytics" description="Use campaign-useful metrics to judge traffic, editorial engagement, participation quality, challenge impact, and recurring-edition performance.">
|
||
<WorldAnalyticsPanel analytics={world?.analytics || null} world={world} />
|
||
</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}
|
||
publishRecapUrl={props.publishRecapUrl}
|
||
canPublishRecap={canPublishRecap}
|
||
recapStatusLabel={world?.recap_status_label || (form.data.recap_status === 'published' ? 'Published recap' : 'Draft recap')}
|
||
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}
|
||
/>
|
||
|
||
<WorldLinkedChallengePickerModal
|
||
open={linkedChallengePickerOpen}
|
||
onClose={() => setLinkedChallengePickerOpen(false)}
|
||
onSave={saveLinkedChallenge}
|
||
initialChallenge={linkedChallengePreview}
|
||
searchEntities={searchEntities}
|
||
/>
|
||
|
||
<WorldRecapArticlePickerModal
|
||
open={recapArticlePickerOpen}
|
||
onClose={() => setRecapArticlePickerOpen(false)}
|
||
onSave={saveRecapArticle}
|
||
initialArticle={recapArticlePreview}
|
||
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.copyModeEnabled ? (
|
||
<div className="grid gap-2">
|
||
<div className="text-[11px] font-semibold uppercase tracking-[0.16em] text-slate-500">Copy mode</div>
|
||
<div className="grid gap-2">
|
||
{(actionConfirm.copyModeOptions || []).map((option) => (
|
||
<label key={option.value} className="flex cursor-pointer items-start gap-3 rounded-[20px] border border-white/10 bg-black/20 px-4 py-3 text-sm text-slate-300">
|
||
<input
|
||
type="radio"
|
||
name="world-copy-mode"
|
||
value={option.value}
|
||
checked={actionCopyMode === option.value}
|
||
onChange={(event) => setActionCopyMode(event.target.value)}
|
||
className="mt-1"
|
||
/>
|
||
<span>
|
||
<span className="block font-semibold text-white">{option.label}</span>
|
||
{option.description ? <span className="mt-1 block text-xs leading-5 text-slate-400">{option.description}</span> : null}
|
||
</span>
|
||
</label>
|
||
))}
|
||
</div>
|
||
</div>
|
||
) : null}
|
||
{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>
|
||
)
|
||
} |