import { rollAllInitiativeUseCase, rollInitiativeUseCase, } from "@initiative/application"; import type { CombatantId, Creature, CreatureId } from "@initiative/domain"; import { useCallback, useEffect, useRef, useState } from "react"; import { ActionBar } from "./components/action-bar"; import { CombatantRow } from "./components/combatant-row"; import { SourceManager } from "./components/source-manager"; import { StatBlockPanel } from "./components/stat-block-panel"; import { Toast } from "./components/toast"; import { TurnNavigation } from "./components/turn-navigation"; import { type SearchResult, useBestiary } from "./hooks/use-bestiary"; import { useBulkImport } from "./hooks/use-bulk-import"; import { useEncounter } from "./hooks/use-encounter"; function rollDice(): number { return Math.floor(Math.random() * 20) + 1; } export function App() { const { encounter, advanceTurn, retreatTurn, addCombatant, clearEncounter, removeCombatant, editCombatant, setInitiative, setHp, adjustHp, setAc, toggleCondition, toggleConcentration, addFromBestiary, makeStore, } = useEncounter(); const { search, getCreature, isLoaded, isSourceCached, fetchAndCacheSource, uploadAndCacheSource, refreshCache, } = useBestiary(); const bulkImport = useBulkImport(); const [selectedCreatureId, setSelectedCreatureId] = useState(null); const [bulkImportMode, setBulkImportMode] = useState(false); const [sourceManagerOpen, setSourceManagerOpen] = useState(false); const selectedCreature: Creature | null = selectedCreatureId ? (getCreature(selectedCreatureId) ?? null) : null; const handleAddFromBestiary = useCallback( (result: SearchResult) => { addFromBestiary(result); // Derive the creature ID so stat block panel can try to show it const slug = result.name .toLowerCase() .replace(/[^a-z0-9]+/g, "-") .replace(/(^-|-$)/g, ""); setSelectedCreatureId( `${result.source.toLowerCase()}:${slug}` as CreatureId, ); }, [addFromBestiary], ); const handleCombatantStatBlock = useCallback((creatureId: string) => { setSelectedCreatureId(creatureId as CreatureId); }, []); const handleRollInitiative = useCallback( (id: CombatantId) => { rollInitiativeUseCase(makeStore(), id, rollDice(), getCreature); }, [makeStore, getCreature], ); const handleRollAllInitiative = useCallback(() => { rollAllInitiativeUseCase(makeStore(), rollDice, getCreature); }, [makeStore, getCreature]); const handleBulkImport = useCallback(() => { setBulkImportMode(true); setSelectedCreatureId(null); }, []); const handleStartBulkImport = useCallback( (baseUrl: string) => { bulkImport.startImport( baseUrl, fetchAndCacheSource, isSourceCached, refreshCache, ); }, [bulkImport.startImport, fetchAndCacheSource, isSourceCached, refreshCache], ); const handleBulkImportDone = useCallback(() => { setBulkImportMode(false); bulkImport.reset(); }, [bulkImport.reset]); // Auto-scroll to the active combatant when the turn changes const activeRowRef = useRef(null); useEffect(() => { activeRowRef.current?.scrollIntoView({ block: "nearest", behavior: "smooth", }); }, [encounter.activeIndex]); // Auto-show stat block for the active combatant when turn changes, // but only when the viewport is wide enough to show it alongside the tracker. // Only react to activeIndex changes — not combatant reordering (e.g. Roll All). const prevActiveIndexRef = useRef(encounter.activeIndex); useEffect(() => { if (prevActiveIndexRef.current === encounter.activeIndex) return; prevActiveIndexRef.current = encounter.activeIndex; if (!window.matchMedia("(min-width: 1024px)").matches) return; const active = encounter.combatants[encounter.activeIndex]; if (!active?.creatureId || !isLoaded) return; setSelectedCreatureId(active.creatureId as CreatureId); }, [encounter.activeIndex, encounter.combatants, isLoaded]); return (
{/* Turn Navigation — fixed at top */}
setSourceManagerOpen((o) => !o)} />
{sourceManagerOpen && (
)} {/* Scrollable area — combatant list */}
{encounter.combatants.length === 0 ? (

No combatants yet — add one to get started

) : ( encounter.combatants.map((c, i) => ( handleCombatantStatBlock(c.creatureId as string) : undefined } onRollInitiative={ c.creatureId ? handleRollInitiative : undefined } /> )) )}
{/* Action Bar — fixed at bottom */}
{/* Stat Block Panel */} { setSelectedCreatureId(null); setBulkImportMode(false); }} bulkImportMode={bulkImportMode} bulkImportState={bulkImport.state} onStartBulkImport={handleStartBulkImport} onBulkImportDone={handleBulkImportDone} /> {/* Toast for bulk import progress when panel is closed */} {bulkImport.state.status === "loading" && !bulkImportMode && ( 0 ? (bulkImport.state.completed + bulkImport.state.failed) / bulkImport.state.total : 0 } onDismiss={() => {}} /> )} {bulkImport.state.status === "complete" && !bulkImportMode && ( )} {bulkImport.state.status === "partial-failure" && !bulkImportMode && ( )}
); }