Initial commit: AI Dungeon clone (FastAPI backend + React frontend)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KFsGHju9szibJJa2YJcdbg
This commit is contained in:
parththakkar106
2026-07-06 16:08:19 +05:30
co-authored by Claude Fable 5
commit db9f904222
57 changed files with 7804 additions and 0 deletions
+143
View File
@@ -0,0 +1,143 @@
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,
...scenario.story_cards.map((c) => 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">
{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>
)
}