475 lines
28 KiB
JavaScript
475 lines
28 KiB
JavaScript
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 (
|
|
<label className="block rounded-2xl border border-white/10 bg-white/[0.04] p-4 text-sm text-slate-300">
|
|
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">{label}</div>
|
|
<div className="mt-2">{children}</div>
|
|
{hint ? <div className="mt-2 text-xs text-slate-500">{hint}</div> : null}
|
|
</label>
|
|
)
|
|
}
|
|
|
|
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 (
|
|
<article className="rounded-[24px] border border-white/10 bg-black/20 p-5">
|
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
<div>
|
|
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-500">Page {page.position}</div>
|
|
<div className="mt-2 text-lg font-semibold text-white">{page.headline || 'Untitled page'}</div>
|
|
</div>
|
|
<div className="flex gap-2">
|
|
<button type="button" onClick={save} disabled={busy} className="rounded-full border border-sky-300/20 bg-sky-400/12 px-4 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-sky-50 transition hover:bg-sky-400/20 disabled:opacity-60">Save</button>
|
|
<button type="button" onClick={destroy} disabled={busy} className="rounded-full border border-rose-300/20 bg-rose-400/12 px-4 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-rose-100 transition hover:bg-rose-400/20 disabled:opacity-60">Delete</button>
|
|
</div>
|
|
</div>
|
|
|
|
{error ? <div className="mt-4 rounded-2xl border border-rose-300/20 bg-rose-400/10 px-4 py-3 text-sm text-rose-100">{error}</div> : null}
|
|
|
|
<div className="mt-4 grid gap-3 xl:grid-cols-2">
|
|
<Field label="Headline">
|
|
<input value={localPage.headline || ''} onChange={(event) => 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" />
|
|
</Field>
|
|
<Field label="Caption">
|
|
<input value={localPage.caption || ''} onChange={(event) => 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" />
|
|
</Field>
|
|
<Field label="Body" hint="Maximum 180 characters.">
|
|
<textarea value={localPage.body || ''} onChange={(event) => setLocalPage((current) => ({ ...current, body: event.target.value }))} rows={3} className="w-full rounded-xl border border-white/10 bg-slate-950/70 px-3 py-2 text-white outline-none" />
|
|
</Field>
|
|
<Field label="Alt text">
|
|
<input value={localPage.alt_text || ''} onChange={(event) => 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" />
|
|
</Field>
|
|
<Field label="Layout">
|
|
<select value={localPage.layout} onChange={(event) => setLocalPage((current) => ({ ...current, layout: event.target.value }))} className="w-full rounded-xl border border-white/10 bg-slate-950/70 px-3 py-2 text-white outline-none">
|
|
{['cover', 'artwork', 'creator', 'mood', 'collection', 'cta'].map((value) => <option key={value} value={value}>{value}</option>)}
|
|
</select>
|
|
</Field>
|
|
<Field label="Background type">
|
|
<select value={localPage.background_type} onChange={(event) => setLocalPage((current) => ({ ...current, background_type: event.target.value }))} className="w-full rounded-xl border border-white/10 bg-slate-950/70 px-3 py-2 text-white outline-none">
|
|
{['image', 'video', 'gradient'].map((value) => <option key={value} value={value}>{value}</option>)}
|
|
</select>
|
|
</Field>
|
|
<Field label="Background path">
|
|
<input value={localPage.background_path || ''} onChange={(event) => 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" />
|
|
</Field>
|
|
<Field label="Mobile background path">
|
|
<input value={localPage.background_mobile_path || ''} onChange={(event) => 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" />
|
|
</Field>
|
|
<Field label="CTA label">
|
|
<input value={localPage.cta_label || ''} onChange={(event) => 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" />
|
|
</Field>
|
|
<Field label="CTA URL">
|
|
<input value={localPage.cta_url || ''} onChange={(event) => 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" />
|
|
</Field>
|
|
<Field label="Text position">
|
|
<select value={localPage.text_position} onChange={(event) => setLocalPage((current) => ({ ...current, text_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">
|
|
{['top', 'center', 'bottom'].map((value) => <option key={value} value={value}>{value}</option>)}
|
|
</select>
|
|
</Field>
|
|
<Field label="Animation">
|
|
<select value={localPage.animation || ''} onChange={(event) => setLocalPage((current) => ({ ...current, animation: event.target.value }))} className="w-full rounded-xl border border-white/10 bg-slate-950/70 px-3 py-2 text-white outline-none">
|
|
<option value="">None</option>
|
|
{['fade-in', 'fly-in-bottom', 'pulse', 'pan-left', 'pan-right'].map((value) => <option key={value} value={value}>{value}</option>)}
|
|
</select>
|
|
</Field>
|
|
<Field label="Overlay strength">
|
|
<input type="number" min="0" max="100" value={localPage.overlay_strength || 35} onChange={(event) => 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" />
|
|
</Field>
|
|
<Field label="Artwork ID">
|
|
<input type="number" min="1" value={localPage.artwork_id || ''} onChange={(event) => 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" />
|
|
</Field>
|
|
<Field label="Position">
|
|
<input type="number" min="1" value={localPage.position || 1} onChange={(event) => 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" />
|
|
</Field>
|
|
<Field label="Credit text">
|
|
<input value={localPage.credit_text || ''} onChange={(event) => 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" />
|
|
</Field>
|
|
<label className="flex items-center gap-3 rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-sm text-slate-300">
|
|
<input type="checkbox" checked={Boolean(localPage.active)} onChange={(event) => setLocalPage((current) => ({ ...current, active: event.target.checked }))} />
|
|
Page active
|
|
</label>
|
|
</div>
|
|
</article>
|
|
)
|
|
}
|
|
|
|
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 (
|
|
<div className="w-full pb-16 pt-8">
|
|
<Head title={isNew ? 'New World Web Story' : `Edit ${story.title}`} />
|
|
|
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
<div>
|
|
<p className="text-[11px] font-semibold uppercase tracking-[0.28em] text-sky-200/80">Moderation surface</p>
|
|
<h1 className="mt-2 text-3xl font-semibold tracking-[-0.04em] text-white">{isNew ? 'Create World Web Story' : story.title}</h1>
|
|
<p className="mt-2 text-sm leading-relaxed text-slate-300">Build a standalone AMP story companion for a Skinbase World without changing the canonical World route.</p>
|
|
</div>
|
|
<div className="flex flex-wrap gap-2">
|
|
<Link href={endpoints.index} className="rounded-full border border-white/10 bg-white/[0.05] px-4 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-white transition hover:bg-white/[0.09]">Back</Link>
|
|
{!isNew && story.public_url ? <a href={story.public_url} className="rounded-full border border-white/10 bg-white/[0.05] px-4 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-white transition hover:bg-white/[0.09]">Open story</a> : null}
|
|
{!isNew ? <button type="button" onClick={() => performStoryAction(endpoints.publish)} className="rounded-full border border-emerald-300/20 bg-emerald-400/12 px-4 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-emerald-100 transition hover:bg-emerald-400/18">Publish</button> : null}
|
|
{!isNew ? <button type="button" onClick={() => performStoryAction(endpoints.unpublish)} className="rounded-full border border-amber-300/20 bg-amber-400/12 px-4 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-amber-100 transition hover:bg-amber-400/18">Unpublish</button> : null}
|
|
</div>
|
|
</div>
|
|
|
|
{notice ? <div className="mt-6 rounded-2xl border border-emerald-300/20 bg-emerald-400/10 px-4 py-3 text-sm text-emerald-50">{notice}</div> : null}
|
|
{error ? <div className="mt-6 rounded-2xl border border-rose-300/20 bg-rose-400/10 px-4 py-3 text-sm text-rose-100">{error}</div> : null}
|
|
|
|
<form onSubmit={submit} className="mt-6 rounded-[32px] border border-white/10 bg-[linear-gradient(180deg,rgba(15,23,42,0.96),rgba(2,6,23,0.92))] p-6 shadow-[0_24px_70px_rgba(2,6,23,0.32)]">
|
|
<div className="grid gap-4 xl:grid-cols-2">
|
|
<Field label="Related World">
|
|
<select value={form.data.world_id} onChange={(event) => form.setData('world_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">
|
|
<option value="">No related World</option>
|
|
{worldOptions.map((world) => <option key={world.value} value={world.value}>{world.label}</option>)}
|
|
</select>
|
|
</Field>
|
|
<Field label="Slug">
|
|
<input value={form.data.slug} onChange={(event) => form.setData('slug', event.target.value)} className="w-full rounded-xl border border-white/10 bg-slate-950/70 px-3 py-2 text-white outline-none" />
|
|
</Field>
|
|
<Field label="Title">
|
|
<input value={form.data.title} onChange={(event) => form.setData('title', event.target.value)} className="w-full rounded-xl border border-white/10 bg-slate-950/70 px-3 py-2 text-white outline-none" />
|
|
</Field>
|
|
<Field label="Subtitle">
|
|
<input value={form.data.subtitle} onChange={(event) => form.setData('subtitle', event.target.value)} className="w-full rounded-xl border border-white/10 bg-slate-950/70 px-3 py-2 text-white outline-none" />
|
|
</Field>
|
|
<Field label="Excerpt">
|
|
<textarea value={form.data.excerpt} onChange={(event) => form.setData('excerpt', event.target.value)} rows={3} className="w-full rounded-xl border border-white/10 bg-slate-950/70 px-3 py-2 text-white outline-none" />
|
|
</Field>
|
|
<Field label="Description">
|
|
<textarea value={form.data.description} onChange={(event) => form.setData('description', event.target.value)} rows={3} className="w-full rounded-xl border border-white/10 bg-slate-950/70 px-3 py-2 text-white outline-none" />
|
|
</Field>
|
|
<Field label="SEO title">
|
|
<input value={form.data.seo_title} onChange={(event) => form.setData('seo_title', event.target.value)} className="w-full rounded-xl border border-white/10 bg-slate-950/70 px-3 py-2 text-white outline-none" />
|
|
</Field>
|
|
<Field label="SEO description">
|
|
<textarea value={form.data.seo_description} onChange={(event) => form.setData('seo_description', event.target.value)} rows={3} className="w-full rounded-xl border border-white/10 bg-slate-950/70 px-3 py-2 text-white outline-none" />
|
|
</Field>
|
|
<Field label="Poster portrait path">
|
|
<input value={form.data.poster_portrait_path} onChange={(event) => form.setData('poster_portrait_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" />
|
|
</Field>
|
|
<Field label="Poster square path">
|
|
<input value={form.data.poster_square_path} onChange={(event) => form.setData('poster_square_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" />
|
|
</Field>
|
|
<Field label="Publisher logo path">
|
|
<input value={form.data.publisher_logo_path} onChange={(event) => form.setData('publisher_logo_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" />
|
|
</Field>
|
|
<Field label="Status">
|
|
<select value={form.data.status} onChange={(event) => form.setData('status', event.target.value)} className="w-full rounded-xl border border-white/10 bg-slate-950/70 px-3 py-2 text-white outline-none">
|
|
{['draft', 'published', 'archived'].map((value) => <option key={value} value={value}>{value}</option>)}
|
|
</select>
|
|
</Field>
|
|
<Field label="Starts at">
|
|
<input type="datetime-local" value={form.data.starts_at ? form.data.starts_at.slice(0, 16) : ''} onChange={(event) => form.setData('starts_at', event.target.value)} className="w-full rounded-xl border border-white/10 bg-slate-950/70 px-3 py-2 text-white outline-none" />
|
|
</Field>
|
|
<Field label="Ends at">
|
|
<input type="datetime-local" value={form.data.ends_at ? form.data.ends_at.slice(0, 16) : ''} onChange={(event) => form.setData('ends_at', event.target.value)} className="w-full rounded-xl border border-white/10 bg-slate-950/70 px-3 py-2 text-white outline-none" />
|
|
</Field>
|
|
</div>
|
|
|
|
<div className="mt-4 flex flex-wrap gap-4">
|
|
<label className="flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.05] px-4 py-2 text-sm text-slate-300"><input type="checkbox" checked={Boolean(form.data.featured)} onChange={(event) => form.setData('featured', event.target.checked)} /> Featured</label>
|
|
<label className="flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.05] px-4 py-2 text-sm text-slate-300"><input type="checkbox" checked={Boolean(form.data.active)} onChange={(event) => form.setData('active', event.target.checked)} /> Active</label>
|
|
<label className="flex items-center gap-2 rounded-full border border-white/10 bg-white/[0.05] px-4 py-2 text-sm text-slate-300"><input type="checkbox" checked={Boolean(form.data.noindex)} onChange={(event) => form.setData('noindex', event.target.checked)} /> Noindex</label>
|
|
</div>
|
|
|
|
<div className="mt-6 flex flex-wrap gap-3">
|
|
<button type="submit" disabled={form.processing} className="rounded-full border border-sky-300/20 bg-sky-400/12 px-5 py-2.5 text-xs font-semibold uppercase tracking-[0.14em] text-sky-50 transition hover:bg-sky-400/20 disabled:opacity-60">Save story</button>
|
|
{!isNew ? <button type="button" onClick={generateFromWorld} className="rounded-full border border-white/10 bg-white/[0.05] px-5 py-2.5 text-xs font-semibold uppercase tracking-[0.14em] text-white transition hover:bg-white/[0.09]">Regenerate from World</button> : null}
|
|
</div>
|
|
</form>
|
|
|
|
{!isNew ? (
|
|
<>
|
|
<section className="mt-8 rounded-[32px] border border-white/10 bg-[linear-gradient(180deg,rgba(15,23,42,0.96),rgba(2,6,23,0.92))] p-6 shadow-[0_24px_70px_rgba(2,6,23,0.32)]">
|
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
<div>
|
|
<h2 className="text-2xl font-semibold tracking-[-0.03em] text-white">Validation</h2>
|
|
<p className="mt-2 text-sm leading-6 text-slate-300">Publish only when poster, logo, page count, alt text, and CTA rules are satisfied.</p>
|
|
</div>
|
|
<div className={`rounded-full border px-4 py-2 text-xs font-semibold uppercase tracking-[0.14em] ${story.validation?.valid ? 'border-emerald-300/20 bg-emerald-400/12 text-emerald-100' : 'border-amber-300/20 bg-amber-400/12 text-amber-100'}`}>
|
|
{story.validation?.valid ? 'Ready to publish' : 'Needs fixes'}
|
|
</div>
|
|
</div>
|
|
{(story.validation?.errors || []).length > 0 ? (
|
|
<div className="mt-4 rounded-2xl border border-amber-300/20 bg-amber-400/10 px-4 py-4 text-sm text-amber-50">
|
|
<ul className="space-y-2">
|
|
{(story.validation.errors || []).map((item) => <li key={item}>{item}</li>)}
|
|
</ul>
|
|
</div>
|
|
) : null}
|
|
</section>
|
|
|
|
<section className="mt-8 rounded-[32px] border border-white/10 bg-[linear-gradient(180deg,rgba(15,23,42,0.96),rgba(2,6,23,0.92))] p-6 shadow-[0_24px_70px_rgba(2,6,23,0.32)]">
|
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
|
<div>
|
|
<h2 className="text-2xl font-semibold tracking-[-0.03em] text-white">Story pages</h2>
|
|
<p className="mt-2 text-sm leading-6 text-slate-300">Keep each page short, visual, and clearly tied back to the World narrative.</p>
|
|
</div>
|
|
</div>
|
|
|
|
<form onSubmit={createPage} className="mt-6 grid gap-3 xl:grid-cols-2">
|
|
<Field label="New page headline"><input value={newPage.headline} onChange={(event) => setNewPage((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" /></Field>
|
|
<Field label="New page caption"><input value={newPage.caption} onChange={(event) => setNewPage((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" /></Field>
|
|
<Field label="New page body"><textarea value={newPage.body} onChange={(event) => setNewPage((current) => ({ ...current, body: event.target.value }))} rows={3} className="w-full rounded-xl border border-white/10 bg-slate-950/70 px-3 py-2 text-white outline-none" /></Field>
|
|
<Field label="Alt text"><input value={newPage.alt_text} onChange={(event) => setNewPage((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" /></Field>
|
|
<Field label="Layout"><select value={newPage.layout} onChange={(event) => setNewPage((current) => ({ ...current, layout: event.target.value }))} className="w-full rounded-xl border border-white/10 bg-slate-950/70 px-3 py-2 text-white outline-none">{['cover', 'artwork', 'creator', 'mood', 'collection', 'cta'].map((value) => <option key={value} value={value}>{value}</option>)}</select></Field>
|
|
<Field label="Background type"><select value={newPage.background_type} onChange={(event) => setNewPage((current) => ({ ...current, background_type: event.target.value }))} className="w-full rounded-xl border border-white/10 bg-slate-950/70 px-3 py-2 text-white outline-none">{['image', 'video', 'gradient'].map((value) => <option key={value} value={value}>{value}</option>)}</select></Field>
|
|
<Field label="Background path"><input value={newPage.background_path} onChange={(event) => setNewPage((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" /></Field>
|
|
<Field label="Mobile background path"><input value={newPage.background_mobile_path} onChange={(event) => setNewPage((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" /></Field>
|
|
<div className="xl:col-span-2 flex justify-end">
|
|
<button type="submit" className="rounded-full border border-sky-300/20 bg-sky-400/12 px-5 py-2.5 text-xs font-semibold uppercase tracking-[0.14em] text-sky-50 transition hover:bg-sky-400/20">Add page</button>
|
|
</div>
|
|
</form>
|
|
|
|
<div className="mt-6 space-y-4">
|
|
{pages.sort((left, right) => left.position - right.position).map((page) => (
|
|
<div key={page.id}>
|
|
<div className="mb-2 flex justify-end gap-2">
|
|
<button type="button" onClick={() => reorder(page.id, -1)} className="rounded-full border border-white/10 bg-white/[0.05] px-3 py-1.5 text-[11px] font-semibold uppercase tracking-[0.14em] text-white transition hover:bg-white/[0.09]">Move up</button>
|
|
<button type="button" onClick={() => reorder(page.id, 1)} className="rounded-full border border-white/10 bg-white/[0.05] px-3 py-1.5 text-[11px] font-semibold uppercase tracking-[0.14em] text-white transition hover:bg-white/[0.09]">Move down</button>
|
|
</div>
|
|
<StoryPageCard page={page} endpoints={endpoints} onChanged={reloadEditor} />
|
|
</div>
|
|
))}
|
|
</div>
|
|
</section>
|
|
</>
|
|
) : null}
|
|
</div>
|
|
)
|
|
} |