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
@@ -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)}
|
||||
|
||||
Reference in New Issue
Block a user