Redesign the library UI and add scenario cover art
Rework the home page into a single landing surface and give the app a
consistent visual language, chosen as "illuminated tome" over two other
pitched directions because it builds on the existing Cinzel + gold identity
instead of replacing it.
Home is now Continue (up to 4 in-progress stories, each showing where you
left off) over a scenario shelf, each section with a "See all" link. The
full adventure list moves to /adventures.
Scenario cover art has three tiers, in precedence order: an uploaded picture
(downscaled client-side to 400px WebP before storing), an emoji, or gradient
art generated from a hash of the title so no card is ever an empty box.
Adventures inherit their scenario's art. Images live in the row rather than
on disk because Render's free tier has no persistent volume, and it keeps
export bundles self-contained; list responses carry a cacheable
/api/scenarios/{id}/image URL rather than the base64.
Also: ambient drifting motes behind the app, loading skeletons, staggered
card entrance, ornamental scene breaks and a drop cap in the story, a
"Weaving" thinking indicator, and a toast system replacing every alert().
Two fixes found along the way:
- Importing a scenario bundle with no "tags" key returned a 500. Column
defaults are not applied until flush, so the attribute was still None
when the width clamp sliced it.
- Anything meaning "the story's latest narration" was missing action type
"start", which is the only text a freshly created adventure has, so new
adventures looked empty. Collected as NARRATION_TYPES.
Migrations 30 and 31 add scenarios.image and scenarios.icon; both are
additive with a '' default and were verified against a database stamped at
29. vite.config.js now reads AIDND_API_PORT so the recurring port-8000
clash with another local app needs no file edit.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014FGY1yvzSeKgTtRfeVtDmx
This commit is contained in:
co-authored by
Claude Opus 5
parent
970d71a5b6
commit
57c24a07d8
@@ -0,0 +1,134 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { api } from '../api'
|
||||
import { CardSkeleton, downloadJSON, pickJSONFile, ScenarioArt, useToast } from '../components'
|
||||
|
||||
export default function Adventures() {
|
||||
const [adventures, setAdventures] = useState(null)
|
||||
const [search, setSearch] = useState('')
|
||||
const navigate = useNavigate()
|
||||
const toast = useToast()
|
||||
|
||||
useEffect(() => {
|
||||
api.listAdventures().then(setAdventures).catch(() => setAdventures([]))
|
||||
}, [])
|
||||
|
||||
const visible = useMemo(() => {
|
||||
if (!adventures) return null
|
||||
const q = search.trim().toLowerCase()
|
||||
if (!q) return adventures
|
||||
return adventures.filter((a) =>
|
||||
`${a.title} ${a.scenario_title || ''}`.toLowerCase().includes(q))
|
||||
}, [adventures, search])
|
||||
|
||||
const remove = async (e, id) => {
|
||||
e.stopPropagation()
|
||||
if (!confirm('Delete this adventure permanently?')) return
|
||||
try {
|
||||
await api.deleteAdventure(id)
|
||||
setAdventures(adventures.filter((a) => a.id !== id))
|
||||
toast('Adventure deleted')
|
||||
} catch (err) {
|
||||
toast(err.message, 'error')
|
||||
}
|
||||
}
|
||||
|
||||
const exportOne = async (e, adv) => {
|
||||
e.stopPropagation()
|
||||
try {
|
||||
const bundle = await api.exportAdventure(adv.id)
|
||||
const safe = adv.title.replace(/[^\w-]+/g, '_').slice(0, 60) || 'adventure'
|
||||
downloadJSON(bundle, `${safe}.json`)
|
||||
} catch (err) {
|
||||
toast(err.message, 'error')
|
||||
}
|
||||
}
|
||||
|
||||
const importOne = async () => {
|
||||
try {
|
||||
const bundle = await pickJSONFile()
|
||||
const adv = await api.importAdventure(bundle)
|
||||
navigate(`/play/${adv.id}`)
|
||||
} catch (err) {
|
||||
toast(err.message, 'error')
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<div className="page-header">
|
||||
<h1>Adventures</h1>
|
||||
<div style={{ display: 'flex', gap: 10 }}>
|
||||
<button onClick={importOne}>Import</button>
|
||||
<button className="primary" onClick={() => navigate('/scenarios')}>
|
||||
+ New Adventure
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{adventures?.length > 0 && (
|
||||
<div className="filter-bar">
|
||||
<input
|
||||
type="text"
|
||||
className="search-input"
|
||||
placeholder="Search adventures…"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{visible === null ? (
|
||||
<CardSkeleton count={4} lines={3} />
|
||||
) : visible.length === 0 ? (
|
||||
<div className="empty">
|
||||
{adventures.length === 0
|
||||
? 'No adventures yet. Head to Scenarios to begin your first story.'
|
||||
: 'No adventures match your search.'}
|
||||
</div>
|
||||
) : (
|
||||
<div className="card-grid wide">
|
||||
{visible.map((adv, i) => (
|
||||
<article
|
||||
key={adv.id}
|
||||
className="card tome enter"
|
||||
style={{ animationDelay: `${Math.min(i, 8) * 50}ms` }}
|
||||
onClick={() => navigate(`/play/${adv.id}`)}
|
||||
>
|
||||
<div className="card-head">
|
||||
<ScenarioArt image={adv.image_url} icon={adv.icon} title={adv.title} large />
|
||||
<div className="card-headings">
|
||||
<h3>{adv.title}</h3>
|
||||
<p className="card-from">
|
||||
{/* Adventures inherit the scenario's title, so only name
|
||||
the source when it actually differs. */}
|
||||
{adv.scenario_title && adv.scenario_title !== adv.title
|
||||
? `From “${adv.scenario_title}” · `
|
||||
: ''}
|
||||
{adv.action_count} {adv.action_count === 1 ? 'turn' : 'turns'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{adv.snippet && <p className="snippet">{adv.snippet}</p>}
|
||||
|
||||
<footer className="card-foot">
|
||||
<span className="turns">
|
||||
Last played {new Date(adv.updated_at + 'Z').toLocaleDateString()}
|
||||
</span>
|
||||
<span className="card-actions">
|
||||
<button className="tiny" title="Export as JSON backup" onClick={(e) => exportOne(e, adv)}>
|
||||
Export
|
||||
</button>
|
||||
<button className="tiny danger" onClick={(e) => remove(e, adv.id)}>
|
||||
Delete
|
||||
</button>
|
||||
</span>
|
||||
</footer>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+201
-82
@@ -1,110 +1,229 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { api } from '../api'
|
||||
import { downloadJSON, pickJSONFile } from '../components'
|
||||
import {
|
||||
CardSkeleton,
|
||||
extractPlaceholders,
|
||||
PlaceholderModal,
|
||||
ScenarioArt,
|
||||
useToast,
|
||||
} from '../components'
|
||||
|
||||
// How many in-progress stories the landing page shows before deferring to
|
||||
// "See all". Two rows on a wide screen; enough to recognise, not a full index.
|
||||
const CONTINUE_LIMIT = 4
|
||||
const SCENARIO_LIMIT = 6
|
||||
|
||||
function splitTags(tags, { isPublic = false } = {}) {
|
||||
const all = (tags || '').split(',').map((t) => t.trim()).filter(Boolean)
|
||||
// Public scenarios already carry a "demo ✦" badge; the tag would repeat it.
|
||||
return isPublic ? all.filter((t) => t.toLowerCase() !== 'demo') : all
|
||||
}
|
||||
|
||||
function relativeTime(iso) {
|
||||
// Stored timestamps are naive UTC, hence the appended Z (matches the rest of
|
||||
// the app's date handling).
|
||||
const then = new Date(iso + 'Z')
|
||||
const minutes = Math.round((Date.now() - then.getTime()) / 60000)
|
||||
if (minutes < 2) return 'just now'
|
||||
if (minutes < 60) return `${minutes} min ago`
|
||||
const hours = Math.round(minutes / 60)
|
||||
if (hours < 24) return `${hours}h ago`
|
||||
const days = Math.round(hours / 24)
|
||||
if (days < 7) return `${days}d ago`
|
||||
return then.toLocaleDateString()
|
||||
}
|
||||
|
||||
/** Section heading framed by ornamental rules — the illuminated-tome motif. */
|
||||
function Rule({ children, action }) {
|
||||
return (
|
||||
<div className="rule-head">
|
||||
<h2 className="rule">
|
||||
<span>{children}</span>
|
||||
</h2>
|
||||
{action}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function Home() {
|
||||
const [adventures, setAdventures] = useState(null)
|
||||
const [search, setSearch] = useState('')
|
||||
const [scenarios, setScenarios] = useState(null)
|
||||
const [pending, setPending] = useState(null) // { scenario, names } awaiting placeholders
|
||||
const navigate = useNavigate()
|
||||
const toast = useToast()
|
||||
|
||||
useEffect(() => {
|
||||
api.listAdventures().then(setAdventures).catch(() => setAdventures([]))
|
||||
api.listScenarios().then(setScenarios).catch(() => setScenarios([]))
|
||||
}, [])
|
||||
|
||||
const visible = useMemo(() => {
|
||||
if (!adventures) return null
|
||||
const q = search.trim().toLowerCase()
|
||||
if (!q) return adventures
|
||||
return adventures.filter((a) =>
|
||||
`${a.title} ${a.scenario_title || ''}`.toLowerCase().includes(q))
|
||||
}, [adventures, search])
|
||||
const ongoing = useMemo(() => (adventures || []).slice(0, CONTINUE_LIMIT), [adventures])
|
||||
const featured = useMemo(() => (scenarios || []).slice(0, SCENARIO_LIMIT), [scenarios])
|
||||
|
||||
const remove = async (e, id) => {
|
||||
e.stopPropagation()
|
||||
if (!confirm('Delete this adventure permanently?')) return
|
||||
await api.deleteAdventure(id)
|
||||
setAdventures(adventures.filter((a) => a.id !== id))
|
||||
}
|
||||
|
||||
const exportOne = async (e, adv) => {
|
||||
e.stopPropagation()
|
||||
const bundle = await api.exportAdventure(adv.id)
|
||||
const safe = adv.title.replace(/[^\w-]+/g, '_').slice(0, 60) || 'adventure'
|
||||
downloadJSON(bundle, `${safe}.json`)
|
||||
}
|
||||
|
||||
const importOne = async () => {
|
||||
const begin = async (scenarioId, placeholders = {}) => {
|
||||
try {
|
||||
const bundle = await pickJSONFile()
|
||||
const adv = await api.importAdventure(bundle)
|
||||
const adv = await api.createAdventure({ scenario_id: scenarioId, placeholders })
|
||||
navigate(`/play/${adv.id}`)
|
||||
} catch (err) {
|
||||
alert(err.message)
|
||||
toast(err.message, 'error')
|
||||
}
|
||||
}
|
||||
|
||||
const startAdventure = async (e, scenarioId) => {
|
||||
e.stopPropagation()
|
||||
try {
|
||||
const scenario = await api.getScenario(scenarioId)
|
||||
const names = extractPlaceholders(
|
||||
scenario.prompt, scenario.memory, scenario.authors_note, scenario.ai_instructions,
|
||||
...scenario.story_cards.flatMap((c) => [c.keys, c.entry]),
|
||||
)
|
||||
if (names.length === 0) return begin(scenarioId)
|
||||
setPending({ scenario, names })
|
||||
} catch (err) {
|
||||
toast(err.message, 'error')
|
||||
}
|
||||
}
|
||||
|
||||
const loading = adventures === null || scenarios === null
|
||||
const nothingAtAll = !loading && adventures.length === 0 && scenarios.length === 0
|
||||
|
||||
return (
|
||||
<div className="page">
|
||||
<div className="page-header">
|
||||
<h1>Adventures</h1>
|
||||
<div style={{ display: 'flex', gap: 10 }}>
|
||||
<button onClick={importOne}>Import</button>
|
||||
<button className="primary" onClick={() => navigate('/scenarios')}>
|
||||
+ New Adventure
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/* Returning players want their story first, so the only thing above the
|
||||
fold is a compact banner — no full-height splash. */}
|
||||
<header className="hall">
|
||||
<p className="hall-eyebrow">Welcome back</p>
|
||||
<h1 className="hall-title">The table is set</h1>
|
||||
<p className="hall-sub">
|
||||
{loading
|
||||
? 'Gathering your stories…'
|
||||
: adventures.length > 0
|
||||
? `${adventures.length} ${adventures.length === 1 ? 'story' : 'stories'} in progress · ${scenarios.length} ${scenarios.length === 1 ? 'world' : 'worlds'} to explore`
|
||||
: `${scenarios.length} ${scenarios.length === 1 ? 'world' : 'worlds'} waiting for a first line`}
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{adventures?.length > 0 && (
|
||||
<div className="filter-bar">
|
||||
<input
|
||||
type="text"
|
||||
className="search-input"
|
||||
placeholder="Search adventures…"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{/* ---------- Continue ---------- */}
|
||||
{(loading || adventures.length > 0) && (
|
||||
<section className="home-section">
|
||||
<Rule
|
||||
action={
|
||||
adventures?.length > CONTINUE_LIMIT ? (
|
||||
<button className="linklike ornate" onClick={() => navigate('/adventures')}>
|
||||
See all {adventures.length} ❖
|
||||
</button>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
Continue
|
||||
</Rule>
|
||||
|
||||
{loading ? (
|
||||
<CardSkeleton count={2} lines={3} />
|
||||
) : (
|
||||
<div className="card-grid wide">
|
||||
{ongoing.map((adv, i) => (
|
||||
<article
|
||||
key={adv.id}
|
||||
className="card tome enter"
|
||||
style={{ animationDelay: `${i * 60}ms` }}
|
||||
onClick={() => navigate(`/play/${adv.id}`)}
|
||||
>
|
||||
<span className="seal" title="In progress" aria-hidden="true">✦</span>
|
||||
<div className="card-head">
|
||||
<ScenarioArt image={adv.image_url} icon={adv.icon} title={adv.title} large />
|
||||
<div className="card-headings">
|
||||
<h3>{adv.title}</h3>
|
||||
{/* An adventure created from a scenario inherits its
|
||||
title, so showing both just prints it twice. */}
|
||||
{adv.scenario_title && adv.scenario_title !== adv.title && (
|
||||
<p className="card-from">From “{adv.scenario_title}”</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{adv.snippet ? (
|
||||
<p className="snippet dropcap">{adv.snippet}</p>
|
||||
) : (
|
||||
<p className="snippet muted">Not a word written yet. Open it and begin.</p>
|
||||
)}
|
||||
<footer className="card-foot">
|
||||
<span className="turns">
|
||||
{adv.action_count} {adv.action_count === 1 ? 'turn' : 'turns'}
|
||||
</span>
|
||||
<span className="dot" aria-hidden="true">·</span>
|
||||
<span className="turns">{relativeTime(adv.updated_at)}</span>
|
||||
<span className="resume">Resume →</span>
|
||||
</footer>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{visible === null ? null : visible.length === 0 ? (
|
||||
<div className="empty">
|
||||
{adventures.length === 0
|
||||
? 'No adventures yet. Head to Scenarios to begin your first story.'
|
||||
: 'No adventures match your search.'}
|
||||
</div>
|
||||
) : (
|
||||
<div className="card-grid">
|
||||
{visible.map((adv) => (
|
||||
<div key={adv.id} className="card" onClick={() => navigate(`/play/${adv.id}`)}>
|
||||
<h3>{adv.title}</h3>
|
||||
<p>
|
||||
{adv.scenario_title ? `From “${adv.scenario_title}” · ` : ''}
|
||||
{adv.action_count} actions
|
||||
</p>
|
||||
<div className="meta">
|
||||
Last played {new Date(adv.updated_at + 'Z').toLocaleString()}
|
||||
<span style={{ float: 'right', display: 'inline-flex', gap: 4 }}>
|
||||
<button
|
||||
title="Export as JSON backup"
|
||||
style={{ padding: '2px 8px', fontSize: '0.75rem' }}
|
||||
onClick={(e) => exportOne(e, adv)}
|
||||
>
|
||||
Export
|
||||
{/* ---------- Scenarios ---------- */}
|
||||
<section className="home-section">
|
||||
<Rule
|
||||
action={
|
||||
scenarios?.length > SCENARIO_LIMIT ? (
|
||||
<button className="linklike ornate" onClick={() => navigate('/scenarios')}>
|
||||
See all {scenarios.length} ❖
|
||||
</button>
|
||||
) : null
|
||||
}
|
||||
>
|
||||
{adventures?.length > 0 ? 'Begin a new story' : 'Choose a world'}
|
||||
</Rule>
|
||||
|
||||
{loading ? (
|
||||
<CardSkeleton count={3} />
|
||||
) : nothingAtAll ? (
|
||||
<div className="empty">
|
||||
Nothing here yet. Create a scenario to define your first world.
|
||||
</div>
|
||||
) : (
|
||||
<div className="card-grid">
|
||||
{featured.map((sc, i) => (
|
||||
<article
|
||||
key={sc.id}
|
||||
className="card tome enter"
|
||||
style={{ animationDelay: `${i * 60}ms` }}
|
||||
onClick={() => navigate(`/scenarios/${sc.id}`)}
|
||||
>
|
||||
<div className="card-head">
|
||||
<ScenarioArt image={sc.image_url} icon={sc.icon} title={sc.title} />
|
||||
<div className="card-headings">
|
||||
<h3>{sc.title}</h3>
|
||||
</div>
|
||||
</div>
|
||||
<p className="snippet">{sc.description || 'No description yet.'}</p>
|
||||
<footer className="card-foot">
|
||||
<div className="tag-cluster">
|
||||
{sc.is_public && (
|
||||
<span className="tag small" title="Shared demo scenario (read-only)">demo ✦</span>
|
||||
)}
|
||||
{splitTags(sc.tags, { isPublic: sc.is_public }).slice(0, 2).map((tag) => (
|
||||
<span key={tag} className="tag small">{tag}</span>
|
||||
))}
|
||||
</div>
|
||||
<button className="primary compact" onClick={(e) => startAdventure(e, sc.id)}>
|
||||
Play
|
||||
</button>
|
||||
<button
|
||||
className="danger"
|
||||
style={{ padding: '2px 8px', fontSize: '0.75rem' }}
|
||||
onClick={(e) => remove(e, adv.id)}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</footer>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{pending && (
|
||||
<PlaceholderModal
|
||||
title={pending.scenario.title}
|
||||
names={pending.names}
|
||||
onCancel={() => setPending(null)}
|
||||
onSubmit={(values) => { setPending(null); begin(pending.scenario.id, values) }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
+56
-25
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Fragment, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
import { api } from '../api'
|
||||
import { Field, StoryCardRow, downloadJSON, pickJSONFile } from '../components'
|
||||
import { Field, StoryCardRow, downloadJSON, pickJSONFile, useToast } from '../components'
|
||||
|
||||
const MODES = ['do', 'say', 'story']
|
||||
const PLAYER_TYPES = ['do', 'say', 'story']
|
||||
@@ -53,6 +53,7 @@ const SECTION_LABELS = {
|
||||
}
|
||||
|
||||
function PlotPanel({ adventure, setAdventure }) {
|
||||
const toast = useToast()
|
||||
// One timer per field/card: a single shared timer would cancel the pending
|
||||
// save of whatever was edited previously within the debounce window.
|
||||
const saveTimers = useRef(new Map())
|
||||
@@ -100,11 +101,11 @@ function PlotPanel({ adventure, setAdventure }) {
|
||||
try {
|
||||
const parsed = await pickJSONFile()
|
||||
const cards = Array.isArray(parsed) ? parsed : (parsed.cards || parsed.storyCards)
|
||||
if (!Array.isArray(cards)) return alert('Expected a JSON array of story cards.')
|
||||
if (!Array.isArray(cards)) return toast('Expected a JSON array of story cards.', 'error')
|
||||
const created = await api.importStoryCards({ adventure_id: adventure.id, cards })
|
||||
setAdventure({ ...adventure, story_cards: [...adventure.story_cards, ...created] })
|
||||
} catch (err) {
|
||||
alert(err.message)
|
||||
toast(err.message, 'error')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -949,6 +950,20 @@ export default function Play() {
|
||||
const pinnedRef = useRef(true) // autoscroll only while the reader is at the bottom
|
||||
const inputRef = useRef(null)
|
||||
|
||||
// The drop cap belongs to the story's first narrated beat. `start` is the
|
||||
// scenario's opening prompt, so it's usually that; an adventure begun blank
|
||||
// has no `start` action and the first AI reply takes it instead. Tracked by
|
||||
// id rather than position so deleting earlier turns moves the cap correctly
|
||||
// instead of stranding it on a removed row.
|
||||
const firstNarrationId = useMemo(
|
||||
() => actions.find((a) => a.type === 'start' || a.type === 'ai')?.id ?? null,
|
||||
[actions],
|
||||
)
|
||||
// send() sets streaming to '' before the request goes out; reasoningStream
|
||||
// stays null until reasoning tokens (if any) arrive. Both still at those
|
||||
// values means the request is in flight with nothing to show yet.
|
||||
const waitingForFirstToken = streaming === '' && reasoningStream === null
|
||||
|
||||
// Grow the action box with its content (CSS caps it at ~4 lines, then
|
||||
// scrolls); shrinks back after send() clears the text.
|
||||
useEffect(() => {
|
||||
@@ -1152,8 +1167,16 @@ export default function Play() {
|
||||
{actions.length === 0 && streaming === null && (
|
||||
<div className="empty">A blank page. Type something below to begin your story.</div>
|
||||
)}
|
||||
{actions.map((action) =>
|
||||
editing?.id === action.id ? (
|
||||
{actions.map((action, i) => {
|
||||
const isPlayer = PLAYER_TYPES.includes(action.type)
|
||||
// A player action opens a new turn, so that's where the ornamental
|
||||
// break belongs — never above the very first line on the page.
|
||||
const sceneBreak = isPlayer && i > 0
|
||||
// Drop cap goes on the first narrated beat only. `firstNarrationId`
|
||||
// is derived once above rather than per row.
|
||||
const opening = action.id === firstNarrationId
|
||||
|
||||
return editing?.id === action.id ? (
|
||||
<div key={action.id} className="action-edit">
|
||||
<textarea
|
||||
autoFocus
|
||||
@@ -1166,26 +1189,34 @@ export default function Play() {
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div key={action.id}
|
||||
className={`action ${PLAYER_TYPES.includes(action.type) ? 'player' : ''}`}>
|
||||
<ReasoningBlock text={action.reasoning} />
|
||||
{renderEmphasis(action.text)}
|
||||
{action.type === 'ai' && <StateChangeChips changes={action.world_changes} />}
|
||||
{!busy && (
|
||||
<span className="action-tools">
|
||||
{action.type === 'ai' && (
|
||||
<button title="View the exact prompt that produced this"
|
||||
onClick={() => inspect(action.id)}>🔍</button>
|
||||
)}
|
||||
<button title="Edit"
|
||||
onClick={() => setEditing({ id: action.id, text: action.text })}>✎</button>
|
||||
<button title="Delete" onClick={() => removeAction(action.id)}>✕</button>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<Fragment key={action.id}>
|
||||
{sceneBreak && <div className="scene-break" aria-hidden="true">❖</div>}
|
||||
<div className={`action ${isPlayer ? 'player' : ''}${opening ? ' opening' : ''}`}>
|
||||
<ReasoningBlock text={action.reasoning} />
|
||||
{renderEmphasis(action.text)}
|
||||
{action.type === 'ai' && <StateChangeChips changes={action.world_changes} />}
|
||||
{!busy && (
|
||||
<span className="action-tools">
|
||||
{action.type === 'ai' && (
|
||||
<button title="View the exact prompt that produced this"
|
||||
onClick={() => inspect(action.id)}>🔍</button>
|
||||
)}
|
||||
<button title="Edit"
|
||||
onClick={() => setEditing({ id: action.id, text: action.text })}>✎</button>
|
||||
<button title="Delete" onClick={() => removeAction(action.id)}>✕</button>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</Fragment>
|
||||
)
|
||||
})}
|
||||
{waitingForFirstToken && (
|
||||
<div className="thinking" role="status">
|
||||
<i /><i /><i />
|
||||
<span>Weaving</span>
|
||||
</div>
|
||||
)}
|
||||
{streaming !== null && (
|
||||
{streaming !== null && !waitingForFirstToken && (
|
||||
<div className="action streaming">
|
||||
<ReasoningBlock text={reasoningStream} streaming />
|
||||
{renderEmphasis(streaming)}
|
||||
@@ -1256,7 +1287,7 @@ export default function Play() {
|
||||
)}
|
||||
|
||||
{toast && (
|
||||
<div className={`toast ${toast.isError ? '' : 'ok'}`}>
|
||||
<div className={`play-toast ${toast.isError ? '' : 'ok'}`}>
|
||||
{toast.text}
|
||||
{toast.isError && (
|
||||
<button style={{ marginLeft: 12 }} onClick={retry}>Retry</button>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
import { api } from '../api'
|
||||
import { Field, StoryCardRow, downloadJSON, pickJSONFile } from '../components'
|
||||
import { Field, StoryCardRow, downloadJSON, pickJSONFile, useToast } from '../components'
|
||||
import ArtPicker from '../ArtPicker'
|
||||
import SchemaEditor from '../SchemaEditor'
|
||||
|
||||
export default function ScenarioEditor() {
|
||||
@@ -16,6 +17,7 @@ export default function ScenarioEditor() {
|
||||
// Last successfully-parsed schema (drives the form editor + preview).
|
||||
const [parsedSchema, setParsedSchema] = useState(null)
|
||||
const [schemaView, setSchemaView] = useState('form') // 'form' | 'json'
|
||||
const toast = useToast()
|
||||
// One timer per field/card: a single shared timer would cancel the pending
|
||||
// save of whatever was edited previously within the debounce window.
|
||||
const saveTimers = useRef(new Map())
|
||||
@@ -43,6 +45,17 @@ export default function ScenarioEditor() {
|
||||
})
|
||||
}
|
||||
|
||||
// Several fields at once, in one PATCH — the art picker sets `image` and
|
||||
// `icon` together (choosing one clears the other, and they must not race).
|
||||
const setFields = (patch) => {
|
||||
setScenario((prev) => ({ ...prev, ...patch }))
|
||||
debounceSave('art', async () => {
|
||||
await api.updateScenario(id, patch)
|
||||
setStatus('Saved')
|
||||
setTimeout(() => setStatus(''), 1500)
|
||||
})
|
||||
}
|
||||
|
||||
// JSON view: typing only updates the text buffer; nothing is parsed or saved
|
||||
// until the user clicks Save (so a half-typed edit doesn't spam errors).
|
||||
const onJsonText = (text) => {
|
||||
@@ -124,11 +137,11 @@ export default function ScenarioEditor() {
|
||||
try {
|
||||
const parsed = await pickJSONFile()
|
||||
const cards = Array.isArray(parsed) ? parsed : (parsed.cards || parsed.storyCards)
|
||||
if (!Array.isArray(cards)) return alert('Expected a JSON array of story cards.')
|
||||
if (!Array.isArray(cards)) return toast('Expected a JSON array of story cards.', 'error')
|
||||
const created = await api.importStoryCards({ scenario_id: Number(id), cards })
|
||||
setScenario({ ...scenario, story_cards: [...scenario.story_cards, ...created] })
|
||||
} catch (err) {
|
||||
alert(err.message)
|
||||
toast(err.message, 'error')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -185,6 +198,13 @@ export default function ScenarioEditor() {
|
||||
<Field label="Title" value={scenario.title} onChange={(v) => setField('title', v)} />
|
||||
<Field label="Description" value={scenario.description} onChange={(v) => setField('description', v)}
|
||||
textarea placeholder="Shown in the scenario list; not sent to the AI." />
|
||||
<ArtPicker
|
||||
title={scenario.title}
|
||||
image={scenario.image}
|
||||
icon={scenario.icon}
|
||||
disabled={readOnly}
|
||||
onChange={setFields}
|
||||
/>
|
||||
<Field label="Opening Prompt" value={scenario.prompt} onChange={(v) => setField('prompt', v)}
|
||||
textarea rows={6} placeholder="The opening story text. Supports ${placeholders} (Phase 5)." />
|
||||
<Field label="Plot Essentials (Memory)" value={scenario.memory} onChange={(v) => setField('memory', v)}
|
||||
|
||||
@@ -1,10 +1,19 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { api } from '../api'
|
||||
import { extractPlaceholders, pickJSONFile, PlaceholderModal } from '../components'
|
||||
import {
|
||||
CardSkeleton,
|
||||
extractPlaceholders,
|
||||
pickJSONFile,
|
||||
PlaceholderModal,
|
||||
ScenarioArt,
|
||||
useToast,
|
||||
} from '../components'
|
||||
|
||||
function splitTags(tags) {
|
||||
return (tags || '').split(',').map((t) => t.trim()).filter(Boolean)
|
||||
function splitTags(tags, { isPublic = false } = {}) {
|
||||
const all = (tags || '').split(',').map((t) => t.trim()).filter(Boolean)
|
||||
// Public scenarios already carry a "demo ✦" badge; the tag would repeat it.
|
||||
return isPublic ? all.filter((t) => t.toLowerCase() !== 'demo') : all
|
||||
}
|
||||
|
||||
export default function Scenarios() {
|
||||
@@ -13,6 +22,7 @@ export default function Scenarios() {
|
||||
const [tagFilter, setTagFilter] = useState(null)
|
||||
const [pending, setPending] = useState(null) // { scenario, names } awaiting placeholder values
|
||||
const navigate = useNavigate()
|
||||
const toast = useToast()
|
||||
|
||||
useEffect(() => {
|
||||
api.listScenarios().then(setScenarios).catch(() => setScenarios([]))
|
||||
@@ -71,9 +81,11 @@ export default function Scenarios() {
|
||||
try {
|
||||
const bundle = await pickJSONFile()
|
||||
const { scenario, unmapped_keys } = await api.importScenario(bundle)
|
||||
if (unmapped_keys.length) alert(`Imported. Unmapped fields ignored: ${unmapped_keys.join(', ')}`)
|
||||
if (unmapped_keys.length) {
|
||||
toast(`Imported. Ignored unknown fields: ${unmapped_keys.join(', ')}`)
|
||||
}
|
||||
navigate(`/scenarios/${scenario.id}`)
|
||||
} catch (err) { alert(err.message) }
|
||||
} catch (err) { toast(err.message, 'error') }
|
||||
}}>Import</button>
|
||||
<button className="primary" onClick={createScenario}>+ New Scenario</button>
|
||||
</div>
|
||||
@@ -102,7 +114,9 @@ export default function Scenarios() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
{visible === null ? null : visible.length === 0 ? (
|
||||
{visible === null ? (
|
||||
<CardSkeleton count={6} />
|
||||
) : visible.length === 0 ? (
|
||||
<div className="empty">
|
||||
{scenarios.length === 0
|
||||
? 'No scenarios yet. Create one to define a reusable story template.'
|
||||
@@ -110,24 +124,34 @@ export default function Scenarios() {
|
||||
</div>
|
||||
) : (
|
||||
<div className="card-grid">
|
||||
{visible.map((sc) => (
|
||||
<div key={sc.id} className="card" onClick={() => navigate(`/scenarios/${sc.id}`)}>
|
||||
<h3>{sc.title}</h3>
|
||||
<p>{sc.description || 'No description'}</p>
|
||||
<div className="meta">
|
||||
{sc.is_public && <span className="tag small" title="Shared demo scenario (read-only)">demo ✦</span>}
|
||||
{splitTags(sc.tags).map((tag) => (
|
||||
<span key={tag} className="tag small">{tag}</span>
|
||||
))}
|
||||
<button
|
||||
className="primary"
|
||||
style={{ float: 'right', padding: '3px 10px', fontSize: '0.78rem' }}
|
||||
onClick={(e) => startAdventure(e, sc.id)}
|
||||
>
|
||||
{visible.map((sc, i) => (
|
||||
<article
|
||||
key={sc.id}
|
||||
className="card tome enter"
|
||||
style={{ animationDelay: `${Math.min(i, 10) * 50}ms` }}
|
||||
onClick={() => navigate(`/scenarios/${sc.id}`)}
|
||||
>
|
||||
<div className="card-head">
|
||||
<ScenarioArt image={sc.image_url} icon={sc.icon} title={sc.title} />
|
||||
<div className="card-headings">
|
||||
<h3>{sc.title}</h3>
|
||||
</div>
|
||||
</div>
|
||||
<p className="snippet">{sc.description || 'No description yet.'}</p>
|
||||
<footer className="card-foot">
|
||||
<div className="tag-cluster">
|
||||
{sc.is_public && (
|
||||
<span className="tag small" title="Shared demo scenario (read-only)">demo ✦</span>
|
||||
)}
|
||||
{splitTags(sc.tags, { isPublic: sc.is_public }).slice(0, 3).map((tag) => (
|
||||
<span key={tag} className="tag small">{tag}</span>
|
||||
))}
|
||||
</div>
|
||||
<button className="primary compact" onClick={(e) => startAdventure(e, sc.id)}>
|
||||
Play
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { api } from '../api'
|
||||
import { downloadJSON, pickJSONFile } from '../components'
|
||||
import { downloadJSON, pickJSONFile, useToast } from '../components'
|
||||
|
||||
export default function Scripts() {
|
||||
const [scripts, setScripts] = useState(null)
|
||||
const navigate = useNavigate()
|
||||
const toast = useToast()
|
||||
|
||||
useEffect(() => {
|
||||
api.listScripts().then(setScripts).catch(() => setScripts([]))
|
||||
@@ -25,7 +26,7 @@ export default function Scripts() {
|
||||
const script = await api.importScript(bundle)
|
||||
navigate(`/scripts/${script.id}`)
|
||||
} catch (err) {
|
||||
alert(err.message)
|
||||
toast(err.message, 'error')
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user