import { useEffect, useMemo, useState } from 'react' import { useNavigate } from 'react-router-dom' import { api } from '../api' import { CardSkeleton, extractPlaceholders, BeginAdventureModal, 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 (

{children}

{action}
) } export default function Home() { const [adventures, setAdventures] = useState(null) 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 ongoing = useMemo(() => (adventures || []).slice(0, CONTINUE_LIMIT), [adventures]) const featured = useMemo(() => (scenarios || []).slice(0, SCENARIO_LIMIT), [scenarios]) const begin = async (scenarioId, { persona = {}, placeholders = {} } = {}) => { try { const adv = await api.createAdventure({ scenario_id: scenarioId, placeholders, persona_name: persona.name || '', persona_pronouns: persona.pronouns || '', persona_desc: persona.desc || '', }) navigate(`/play/${adv.id}`) } catch (err) { 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]), ) // Always open the modal, even with no placeholders: it is where the // player names their character. 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 (
{/* Returning players want their story first, so the only thing above the fold is a compact banner — no full-height splash. */}

Welcome back

The table is set

{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`}

{/* ---------- Continue ---------- */} {(loading || adventures.length > 0) && (
CONTINUE_LIMIT ? ( ) : null } > Continue {loading ? ( ) : (
{ongoing.map((adv, i) => (
navigate(`/play/${adv.id}`)} >

{adv.title}

{/* An adventure created from a scenario inherits its title, so showing both just prints it twice. */} {adv.scenario_title && adv.scenario_title !== adv.title && (

From “{adv.scenario_title}”

)}
{adv.snippet ? (

{adv.snippet}

) : (

Not a word written yet. Open it and begin.

)}
{adv.action_count} {adv.action_count === 1 ? 'turn' : 'turns'} {relativeTime(adv.updated_at)} Resume →
))}
)}
)} {/* ---------- Scenarios ---------- */}
SCENARIO_LIMIT ? ( ) : null } > {adventures?.length > 0 ? 'Begin a new story' : 'Choose a world'} {loading ? ( ) : nothingAtAll ? (
Nothing here yet. Create a scenario to define your first world.
) : (
{featured.map((sc, i) => (
navigate(`/scenarios/${sc.id}`)} >

{sc.title}

{sc.description || 'No description yet.'}

{sc.is_public && ( demo ✦ )} {splitTags(sc.tags, { isPublic: sc.is_public }).slice(0, 2).map((tag) => ( {tag} ))}
))}
)}
{pending && ( setPending(null)} onSubmit={(answers) => { setPending(null); begin(pending.scenario.id, answers) }} /> )}
) }