import React from 'react'
import { Head, Link, router, useForm, usePage } from '@inertiajs/react'
function getCsrfToken() {
if (typeof document === 'undefined') return ''
return document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || ''
}
async function requestJson(url, { method = 'POST', body } = {}) {
const response = await fetch(url, {
method,
credentials: 'same-origin',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
'X-CSRF-TOKEN': getCsrfToken(),
'X-Requested-With': 'XMLHttpRequest',
},
body: body ? JSON.stringify(body) : undefined,
})
const payload = await response.json().catch(() => ({}))
if (!response.ok) {
throw new Error(payload?.message || payload?.errors?.story?.[0] || Object.values(payload?.errors || {})?.[0]?.[0] || 'Request failed.')
}
return payload
}
function replacePagePattern(pattern, pageId) {
return String(pattern || '').replace('__PAGE__', String(pageId))
}
function Field({ label, children, hint }) {
return (
)
}
function StoryPageCard({ page, endpoints, onChanged }) {
const [localPage, setLocalPage] = React.useState(page)
const [busy, setBusy] = React.useState(false)
const [error, setError] = React.useState('')
React.useEffect(() => {
setLocalPage(page)
}, [page])
async function save() {
setBusy(true)
setError('')
try {
await requestJson(replacePagePattern(endpoints.pagesUpdatePattern, page.id), {
method: 'PATCH',
body: {
...localPage,
overlay_strength: Number(localPage.overlay_strength || 35),
active: Boolean(localPage.active),
},
})
onChanged()
} catch (requestError) {
setError(requestError.message || 'Unable to save page.')
} finally {
setBusy(false)
}
}
async function destroy() {
setBusy(true)
setError('')
try {
await requestJson(replacePagePattern(endpoints.pagesDestroyPattern, page.id), { method: 'DELETE' })
onChanged()
} catch (requestError) {
setError(requestError.message || 'Unable to delete page.')
} finally {
setBusy(false)
}
}
return (
Page {page.position}
{page.headline || 'Untitled page'}
{error ? {error}
: null}
setLocalPage((current) => ({ ...current, headline: event.target.value }))} className="w-full rounded-xl border border-white/10 bg-slate-950/70 px-3 py-2 text-white outline-none" />
setLocalPage((current) => ({ ...current, caption: event.target.value }))} className="w-full rounded-xl border border-white/10 bg-slate-950/70 px-3 py-2 text-white outline-none" />
setLocalPage((current) => ({ ...current, alt_text: event.target.value }))} className="w-full rounded-xl border border-white/10 bg-slate-950/70 px-3 py-2 text-white outline-none" />
setLocalPage((current) => ({ ...current, background_path: event.target.value }))} className="w-full rounded-xl border border-white/10 bg-slate-950/70 px-3 py-2 text-white outline-none" />
setLocalPage((current) => ({ ...current, background_mobile_path: event.target.value }))} className="w-full rounded-xl border border-white/10 bg-slate-950/70 px-3 py-2 text-white outline-none" />
setLocalPage((current) => ({ ...current, cta_label: event.target.value }))} className="w-full rounded-xl border border-white/10 bg-slate-950/70 px-3 py-2 text-white outline-none" />
setLocalPage((current) => ({ ...current, cta_url: event.target.value }))} className="w-full rounded-xl border border-white/10 bg-slate-950/70 px-3 py-2 text-white outline-none" />
setLocalPage((current) => ({ ...current, overlay_strength: event.target.value }))} className="w-full rounded-xl border border-white/10 bg-slate-950/70 px-3 py-2 text-white outline-none" />
setLocalPage((current) => ({ ...current, artwork_id: event.target.value }))} className="w-full rounded-xl border border-white/10 bg-slate-950/70 px-3 py-2 text-white outline-none" />
setLocalPage((current) => ({ ...current, position: event.target.value }))} className="w-full rounded-xl border border-white/10 bg-slate-950/70 px-3 py-2 text-white outline-none" />
setLocalPage((current) => ({ ...current, credit_text: event.target.value }))} className="w-full rounded-xl border border-white/10 bg-slate-950/70 px-3 py-2 text-white outline-none" />
)
}
export default function WorldWebStoryEditor() {
const { props } = usePage()
const story = props.story
const endpoints = props.endpoints || {}
const worldOptions = props.worldOptions || []
const isNew = Boolean(props.isNew)
const [notice, setNotice] = React.useState('')
const [error, setError] = React.useState('')
const [pages, setPages] = React.useState(story.pages || [])
const [newPage, setNewPage] = React.useState({
layout: 'cover',
background_type: 'image',
headline: '',
body: '',
cta_label: '',
cta_url: '',
alt_text: '',
caption: '',
credit_text: '',
background_path: '',
background_mobile_path: '',
artwork_id: '',
text_position: 'bottom',
overlay_strength: 35,
animation: '',
active: true,
})
React.useEffect(() => {
setPages(story.pages || [])
}, [story.pages])
const form = useForm({
world_id: story.world_id || '',
slug: story.slug || '',
title: story.title || '',
subtitle: story.subtitle || '',
excerpt: story.excerpt || '',
description: story.description || '',
seo_title: story.seo_title || '',
seo_description: story.seo_description || '',
poster_portrait_path: story.poster_portrait_path || '',
poster_square_path: story.poster_square_path || '',
publisher_logo_path: story.publisher_logo_path || '',
status: story.status || 'draft',
featured: Boolean(story.featured),
active: Boolean(story.active),
noindex: Boolean(story.noindex),
published_at: story.published_at || '',
starts_at: story.starts_at || '',
ends_at: story.ends_at || '',
})
function submit(event) {
event.preventDefault()
setError('')
setNotice('')
const options = {
preserveScroll: true,
onSuccess: () => setNotice('Web story saved.'),
onError: (errors) => setError(Object.values(errors)[0] || 'Save failed.'),
}
if (isNew) {
form.post(endpoints.store, options)
return
}
form.patch(endpoints.update, options)
}
async function reloadEditor() {
router.reload({ preserveScroll: true, only: ['story'] })
}
async function createPage(event) {
event.preventDefault()
setError('')
setNotice('')
try {
await requestJson(endpoints.pagesStore, {
body: {
...newPage,
overlay_strength: Number(newPage.overlay_strength || 35),
artwork_id: newPage.artwork_id ? Number(newPage.artwork_id) : null,
active: Boolean(newPage.active),
},
})
setNotice('Page created.')
setNewPage({
layout: 'cover',
background_type: 'image',
headline: '',
body: '',
cta_label: '',
cta_url: '',
alt_text: '',
caption: '',
credit_text: '',
background_path: '',
background_mobile_path: '',
artwork_id: '',
text_position: 'bottom',
overlay_strength: 35,
animation: '',
active: true,
})
reloadEditor()
} catch (requestError) {
setError(requestError.message || 'Unable to create page.')
}
}
async function performStoryAction(url) {
setError('')
setNotice('')
try {
const payload = await requestJson(url)
setNotice(payload.message || 'Action completed.')
reloadEditor()
} catch (requestError) {
setError(requestError.message || 'Action failed.')
}
}
async function reorder(pageId, direction) {
const sorted = [...pages].sort((left, right) => left.position - right.position)
const currentIndex = sorted.findIndex((page) => page.id === pageId)
const targetIndex = currentIndex + direction
if (currentIndex < 0 || targetIndex < 0 || targetIndex >= sorted.length) return
const next = [...sorted]
;[next[currentIndex], next[targetIndex]] = [next[targetIndex], next[currentIndex]]
try {
await requestJson(endpoints.pagesReorder, {
body: { page_ids: next.map((page) => page.id) },
})
reloadEditor()
} catch (requestError) {
setError(requestError.message || 'Unable to reorder pages.')
}
}
async function generateFromWorld() {
if (!form.data.world_id) return
try {
const payload = await requestJson(endpoints.generateFromWorldPattern.replace('__WORLD__', String(form.data.world_id)), {
body: { force: true, pages: Math.max(5, pages.length || 7) },
})
setNotice(payload.message || 'Draft regenerated from World.')
if (payload.story?.edit_url) {
router.visit(payload.story.edit_url)
return
}
reloadEditor()
} catch (requestError) {
setError(requestError.message || 'Generation failed.')
}
}
return (
Moderation surface
{isNew ? 'Create World Web Story' : story.title}
Build a standalone AMP story companion for a Skinbase World without changing the canonical World route.
Back
{!isNew && story.public_url ?
Open story : null}
{!isNew ?
: null}
{!isNew ?
: null}
{notice ? {notice}
: null}
{error ? {error}
: null}
{!isNew ? (
<>
Validation
Publish only when poster, logo, page count, alt text, and CTA rules are satisfied.
{story.validation?.valid ? 'Ready to publish' : 'Needs fixes'}
{(story.validation?.errors || []).length > 0 ? (
{(story.validation.errors || []).map((item) => - {item}
)}
) : null}
Story pages
Keep each page short, visual, and clearly tied back to the World narrative.
{pages.sort((left, right) => left.position - right.position).map((page) => (
))}
>
) : null}
)
}