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:
parththakkar106
2026-07-26 13:17:46 +05:30
co-authored by Claude Opus 5
parent 970d71a5b6
commit 57c24a07d8
25 changed files with 1879 additions and 158 deletions
+201 -82
View File
@@ -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>
)