244 lines
13 KiB
JavaScript
244 lines
13 KiB
JavaScript
import React from 'react'
|
|
import { Head, Link, router, 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] || 'Request failed.')
|
|
}
|
|
|
|
return payload
|
|
}
|
|
|
|
function replacePattern(pattern, value) {
|
|
return String(pattern || '').replace('__STORY__', String(value)).replace('__WORLD__', String(value))
|
|
}
|
|
|
|
function StatusBadge({ story }) {
|
|
const tone = story.status === 'published'
|
|
? 'border-emerald-300/20 bg-emerald-400/12 text-emerald-100'
|
|
: story.status === 'archived'
|
|
? 'border-amber-300/20 bg-amber-400/12 text-amber-100'
|
|
: 'border-white/10 bg-white/[0.06] text-slate-200'
|
|
|
|
return <span className={`inline-flex rounded-full border px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] ${tone}`}>{story.status}</span>
|
|
}
|
|
|
|
export default function WorldWebStoriesIndex() {
|
|
const { props } = usePage()
|
|
const stories = props.stories || { data: [] }
|
|
const endpoints = props.endpoints || {}
|
|
const worldOptions = props.worldOptions || []
|
|
const [filters, setFilters] = React.useState(props.filters || { q: '', status: 'all' })
|
|
const [notice, setNotice] = React.useState('')
|
|
const [error, setError] = React.useState('')
|
|
const [busyKey, setBusyKey] = React.useState('')
|
|
const [generator, setGenerator] = React.useState({ world_id: worldOptions[0]?.value || '', pages: 7, force: false, publish: false })
|
|
|
|
React.useEffect(() => {
|
|
setFilters(props.filters || { q: '', status: 'all' })
|
|
}, [props.filters])
|
|
|
|
function applyFilters(event) {
|
|
event.preventDefault()
|
|
router.get(endpoints.index, filters, { preserveState: true, replace: true, preserveScroll: true })
|
|
}
|
|
|
|
async function performAction(key, url, method = 'POST', body = null) {
|
|
setBusyKey(key)
|
|
setNotice('')
|
|
setError('')
|
|
|
|
try {
|
|
const payload = await requestJson(url, { method, body })
|
|
setNotice(payload.message || 'Action completed.')
|
|
router.reload({ only: ['stories', 'stats', 'filters'], preserveScroll: true })
|
|
} catch (requestError) {
|
|
setError(requestError.message || 'Action failed.')
|
|
} finally {
|
|
setBusyKey('')
|
|
}
|
|
}
|
|
|
|
async function generateDraft(event) {
|
|
event.preventDefault()
|
|
if (!generator.world_id) return
|
|
|
|
setBusyKey('generate')
|
|
setNotice('')
|
|
setError('')
|
|
|
|
try {
|
|
const payload = await requestJson(replacePattern(endpoints.generatePattern, generator.world_id), {
|
|
body: {
|
|
pages: Number(generator.pages || 7),
|
|
force: Boolean(generator.force),
|
|
publish: Boolean(generator.publish),
|
|
},
|
|
})
|
|
|
|
setNotice(payload.message || 'Web story generated.')
|
|
|
|
if (payload.story?.edit_url) {
|
|
router.visit(payload.story.edit_url)
|
|
return
|
|
}
|
|
|
|
router.reload({ only: ['stories', 'stats'], preserveScroll: true })
|
|
} catch (requestError) {
|
|
setError(requestError.message || 'Generation failed.')
|
|
} finally {
|
|
setBusyKey('')
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="w-full pb-16 pt-8">
|
|
<Head title="World Web Stories" />
|
|
|
|
<section className="rounded-[32px] border border-white/10 bg-[radial-gradient(circle_at_top_left,rgba(56,189,248,0.16),transparent_36%),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-col gap-4 xl:flex-row xl:items-end xl:justify-between">
|
|
<div>
|
|
<p className="text-[11px] font-semibold uppercase tracking-[0.28em] text-sky-200/80">Moderation surface</p>
|
|
<h1 className="mt-3 text-3xl font-semibold tracking-[-0.04em] text-white">World Web Stories</h1>
|
|
<p className="mt-2 max-w-3xl text-sm leading-relaxed text-slate-300">Create standalone AMP Web Stories for Skinbase Worlds, keep them self-canonical, and publish only when the story is complete and visible.</p>
|
|
</div>
|
|
|
|
<div className="flex flex-wrap gap-3">
|
|
<Link href={endpoints.create} className="inline-flex items-center gap-2 rounded-full border border-sky-300/20 bg-sky-400/12 px-5 py-2 text-xs font-semibold uppercase tracking-[0.14em] text-sky-50 transition hover:bg-sky-400/20">
|
|
<i className="fa-solid fa-plus text-[10px]" />
|
|
New story
|
|
</Link>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mt-6 grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
|
{[
|
|
['Total stories', props.stats?.total || 0],
|
|
['Published', props.stats?.published || 0],
|
|
['Drafts', props.stats?.draft || 0],
|
|
['Hidden', props.stats?.hidden || 0],
|
|
].map(([label, value]) => (
|
|
<div key={label} className="rounded-[24px] border border-white/10 bg-white/[0.04] p-5">
|
|
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">{label}</div>
|
|
<div className="mt-4 text-3xl font-semibold tracking-[-0.04em] text-white">{Number(value).toLocaleString()}</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
<form onSubmit={applyFilters} className="mt-6 grid gap-3 lg:grid-cols-[2fr_1fr_auto]">
|
|
<input
|
|
value={filters.q || ''}
|
|
onChange={(event) => setFilters((current) => ({ ...current, q: event.target.value }))}
|
|
placeholder="Search by title, slug, or world"
|
|
className="rounded-2xl border border-white/10 bg-slate-950/70 px-4 py-3 text-sm text-white outline-none"
|
|
/>
|
|
<select
|
|
value={filters.status || 'all'}
|
|
onChange={(event) => setFilters((current) => ({ ...current, status: event.target.value }))}
|
|
className="rounded-2xl border border-white/10 bg-slate-950/70 px-4 py-3 text-sm text-white outline-none"
|
|
>
|
|
<option value="all">All statuses</option>
|
|
<option value="draft">Draft</option>
|
|
<option value="published">Published</option>
|
|
<option value="archived">Archived</option>
|
|
</select>
|
|
<button type="submit" className="rounded-2xl border border-white/10 bg-white/[0.06] px-5 py-3 text-xs font-semibold uppercase tracking-[0.14em] text-white transition hover:bg-white/[0.1]">Apply</button>
|
|
</form>
|
|
|
|
<form onSubmit={generateDraft} className="mt-6 rounded-[24px] border border-white/10 bg-black/20 p-5">
|
|
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-slate-400">Generate from World</div>
|
|
<div className="mt-4 grid gap-3 lg:grid-cols-[2fr_120px_auto_auto_auto]">
|
|
<select
|
|
value={generator.world_id}
|
|
onChange={(event) => setGenerator((current) => ({ ...current, world_id: event.target.value }))}
|
|
className="rounded-2xl border border-white/10 bg-slate-950/70 px-4 py-3 text-sm text-white outline-none"
|
|
>
|
|
<option value="">Select a World</option>
|
|
{worldOptions.map((world) => (
|
|
<option key={world.value} value={world.value}>{world.label}</option>
|
|
))}
|
|
</select>
|
|
<input
|
|
type="number"
|
|
min="5"
|
|
max="10"
|
|
value={generator.pages}
|
|
onChange={(event) => setGenerator((current) => ({ ...current, pages: event.target.value }))}
|
|
className="rounded-2xl border border-white/10 bg-slate-950/70 px-4 py-3 text-sm text-white outline-none"
|
|
/>
|
|
<label className="flex items-center gap-2 rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-sm text-slate-300">
|
|
<input type="checkbox" checked={generator.force} onChange={(event) => setGenerator((current) => ({ ...current, force: event.target.checked }))} />
|
|
Force
|
|
</label>
|
|
<label className="flex items-center gap-2 rounded-2xl border border-white/10 bg-white/[0.04] px-4 py-3 text-sm text-slate-300">
|
|
<input type="checkbox" checked={generator.publish} onChange={(event) => setGenerator((current) => ({ ...current, publish: event.target.checked }))} />
|
|
Publish
|
|
</label>
|
|
<button type="submit" disabled={busyKey === 'generate'} className="rounded-2xl border border-sky-300/20 bg-sky-400/12 px-5 py-3 text-xs font-semibold uppercase tracking-[0.14em] text-sky-50 transition hover:bg-sky-400/20 disabled:opacity-60">Generate</button>
|
|
</div>
|
|
</form>
|
|
</section>
|
|
|
|
{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}
|
|
|
|
<div className="mt-8 grid gap-4 xl:grid-cols-2">
|
|
{(stories.data || []).map((story) => (
|
|
<article key={story.id} className="overflow-hidden rounded-[28px] border border-white/10 bg-[#08111d] shadow-[0_18px_48px_rgba(2,6,23,0.2)]">
|
|
<div className="grid gap-4 md:grid-cols-[180px_1fr]">
|
|
<div className="aspect-[3/4] bg-black/30">
|
|
{story.poster_portrait_url ? <img src={story.poster_portrait_url} alt={story.title} className="h-full w-full object-cover" /> : <div className="flex h-full items-center justify-center text-white/20"><i className="fa-solid fa-book-open-reader text-4xl" /></div>}
|
|
</div>
|
|
<div className="p-5">
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<StatusBadge story={story} />
|
|
{!story.active ? <span className="inline-flex rounded-full border border-amber-300/20 bg-amber-400/12 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-amber-100">inactive</span> : null}
|
|
{story.noindex ? <span className="inline-flex rounded-full border border-rose-300/20 bg-rose-400/12 px-3 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-rose-100">noindex</span> : null}
|
|
</div>
|
|
|
|
<h2 className="mt-3 text-2xl font-semibold tracking-[-0.03em] text-white">{story.title}</h2>
|
|
<p className="mt-2 text-sm text-slate-300">/{story.slug}{story.world ? ` • ${story.world.title}` : ''}</p>
|
|
{story.excerpt ? <p className="mt-3 text-sm leading-6 text-slate-300">{story.excerpt}</p> : null}
|
|
|
|
<div className="mt-4 flex flex-wrap gap-2 text-xs uppercase tracking-[0.16em] text-slate-400">
|
|
<span>{story.page_count} pages</span>
|
|
{story.published_at ? <span>{new Date(story.published_at).toLocaleDateString()}</span> : null}
|
|
</div>
|
|
|
|
<div className="mt-5 flex flex-wrap gap-2">
|
|
<Link href={replacePattern(endpoints.editPattern, story.id)} className="inline-flex items-center gap-2 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]">Edit</Link>
|
|
<a href={story.public_url} className="inline-flex items-center gap-2 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</a>
|
|
{story.status === 'published' ? (
|
|
<button type="button" onClick={() => performAction(`unpublish-${story.id}`, replacePattern(endpoints.unpublishPattern, story.id))} className="inline-flex items-center gap-2 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>
|
|
) : (
|
|
<button type="button" onClick={() => performAction(`publish-${story.id}`, replacePattern(endpoints.publishPattern, story.id))} className="inline-flex items-center gap-2 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>
|
|
)}
|
|
<button type="button" onClick={() => performAction(`delete-${story.id}`, replacePattern(endpoints.destroyPattern, story.id), 'DELETE')} className="inline-flex items-center gap-2 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/18">Delete</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</article>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)
|
|
} |