Guest-first multi-user mode behind AIDND_MULTI_USER (local installs unchanged): signed-cookie guest sessions bootstrapped by /api/auth/me, register upgrades the guest in place, login/logout, per-IP rate limits. Every router scoped by user_id; Settings become per-user with the API key Fernet-encrypted at rest and write-only through the API. Users without a key get a server-funded demo key (OpenRouter free models, 20 turns/day, memory bank disabled on demo turns). Public read-only demo scenarios (seed_demo.py); debug log restricted to local mode. Frontend: auth modal + guest nudge, 401 re-establish/retry, demo banner and key management in Settings. Migrations 13-23 adopt existing data under a local user and encrypt stored keys. Verified: migration on a copy of real data.db, two-session isolation + register/login via curl and Chrome, demo cap 429, live OpenRouter turn through the encrypted-key path, vite build + oxlint. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KFsGHju9szibJJa2YJcdbg
146 lines
5.1 KiB
React
146 lines
5.1 KiB
React
import { useEffect, useMemo, useState } from 'react'
|
|
import { useNavigate } from 'react-router-dom'
|
|
import { api } from '../api'
|
|
import { extractPlaceholders, pickJSONFile, PlaceholderModal } from '../components'
|
|
|
|
function splitTags(tags) {
|
|
return (tags || '').split(',').map((t) => t.trim()).filter(Boolean)
|
|
}
|
|
|
|
export default function Scenarios() {
|
|
const [scenarios, setScenarios] = useState(null)
|
|
const [search, setSearch] = useState('')
|
|
const [tagFilter, setTagFilter] = useState(null)
|
|
const [pending, setPending] = useState(null) // { scenario, names } awaiting placeholder values
|
|
const navigate = useNavigate()
|
|
|
|
useEffect(() => {
|
|
api.listScenarios().then(setScenarios).catch(() => setScenarios([]))
|
|
}, [])
|
|
|
|
const allTags = useMemo(() => {
|
|
const tags = new Set()
|
|
for (const sc of scenarios || []) splitTags(sc.tags).forEach((t) => tags.add(t))
|
|
return [...tags].sort()
|
|
}, [scenarios])
|
|
|
|
const visible = useMemo(() => {
|
|
if (!scenarios) return null
|
|
const q = search.trim().toLowerCase()
|
|
return scenarios.filter((sc) => {
|
|
if (tagFilter && !splitTags(sc.tags).includes(tagFilter)) return false
|
|
if (q && !`${sc.title} ${sc.description} ${sc.tags}`.toLowerCase().includes(q)) return false
|
|
return true
|
|
})
|
|
}, [scenarios, search, tagFilter])
|
|
|
|
const createScenario = async () => {
|
|
const scenario = await api.createScenario({ title: 'New Scenario' })
|
|
navigate(`/scenarios/${scenario.id}`)
|
|
}
|
|
|
|
const begin = async (scenarioId, placeholders = {}) => {
|
|
const adv = await api.createAdventure({ scenario_id: scenarioId, placeholders })
|
|
navigate(`/play/${adv.id}`)
|
|
}
|
|
|
|
const startAdventure = async (e, scenarioId) => {
|
|
e.stopPropagation()
|
|
const scenario = await api.getScenario(scenarioId)
|
|
const names = extractPlaceholders(
|
|
scenario.prompt, scenario.memory, scenario.authors_note, scenario.ai_instructions,
|
|
// Cards can carry ${placeholders} in trigger keys too, not just entries.
|
|
...scenario.story_cards.flatMap((c) => [c.keys, c.entry]),
|
|
)
|
|
if (names.length === 0) return begin(scenarioId)
|
|
setPending({ scenario, names })
|
|
}
|
|
|
|
const startBlank = async () => {
|
|
const adv = await api.createAdventure({ title: 'Blank Adventure' })
|
|
navigate(`/play/${adv.id}`)
|
|
}
|
|
|
|
return (
|
|
<div className="page">
|
|
<div className="page-header">
|
|
<h1>Scenarios</h1>
|
|
<div style={{ display: 'flex', gap: 10 }}>
|
|
<button onClick={startBlank}>Blank Adventure</button>
|
|
<button onClick={async () => {
|
|
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(', ')}`)
|
|
navigate(`/scenarios/${scenario.id}`)
|
|
} catch (err) { alert(err.message) }
|
|
}}>Import</button>
|
|
<button className="primary" onClick={createScenario}>+ New Scenario</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="filter-bar">
|
|
<input
|
|
type="text"
|
|
className="search-input"
|
|
placeholder="Search scenarios…"
|
|
value={search}
|
|
onChange={(e) => setSearch(e.target.value)}
|
|
/>
|
|
{allTags.length > 0 && (
|
|
<div className="tag-row">
|
|
{allTags.map((tag) => (
|
|
<button
|
|
key={tag}
|
|
className={`tag ${tagFilter === tag ? 'active' : ''}`}
|
|
onClick={() => setTagFilter(tagFilter === tag ? null : tag)}
|
|
>
|
|
{tag}
|
|
</button>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{visible === null ? null : visible.length === 0 ? (
|
|
<div className="empty">
|
|
{scenarios.length === 0
|
|
? 'No scenarios yet. Create one to define a reusable story template.'
|
|
: 'No scenarios match your search.'}
|
|
</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)}
|
|
>
|
|
Play
|
|
</button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{pending && (
|
|
<PlaceholderModal
|
|
title={pending.scenario.title}
|
|
names={pending.names}
|
|
onCancel={() => setPending(null)}
|
|
onSubmit={(values) => { setPending(null); begin(pending.scenario.id, values) }}
|
|
/>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|