import { rollAllInitiativeUseCase, rollInitiativeUseCase, } from "@initiative/application"; import { type CombatantId, type Creature, type CreatureId, isDomainError, } from "@initiative/domain"; import { useCallback, useEffect, useLayoutEffect, useRef, useState, } from "react"; import { ActionBar } from "./components/action-bar"; import { BulkImportToasts } from "./components/bulk-import-toasts"; import { CombatantRow } from "./components/combatant-row"; import { PlayerCharacterSection, type PlayerCharacterSectionHandle, } from "./components/player-character-section"; 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"; import { usePlayerCharacters } from "./hooks/use-player-characters"; import { useSidePanelState } from "./hooks/use-side-panel-state"; function rollDice(): number { return Math.floor(Math.random() * 20) + 1; } function useActionBarAnimation(combatantCount: number) { const wasEmptyRef = useRef(combatantCount === 0); const [settling, setSettling] = useState(false); const [rising, setRising] = useState(false); const [topBarExiting, setTopBarExiting] = useState(false); useLayoutEffect(() => { const nowEmpty = combatantCount === 0; if (wasEmptyRef.current && !nowEmpty) { setSettling(true); } else if (!wasEmptyRef.current && nowEmpty) { setRising(true); setTopBarExiting(true); } wasEmptyRef.current = nowEmpty; }, [combatantCount]); const empty = combatantCount === 0; const risingClass = rising ? " animate-rise-to-center" : ""; const settlingClass = settling ? " animate-settle-to-bottom" : ""; const exitingClass = topBarExiting ? " absolute inset-x-0 top-0 z-10 px-4 animate-slide-up-out" : ""; const topBarClass = settling ? " animate-slide-down-in" : exitingClass; const showTopBar = !empty || topBarExiting; return { risingClass, settlingClass, topBarClass, showTopBar, onSettleEnd: () => setSettling(false), onRiseEnd: () => setRising(false), onTopBarExitEnd: () => setTopBarExiting(false), }; } export function App() { const { encounter, isEmpty, hasCreatureCombatants, canRollAllInitiative, advanceTurn, retreatTurn, addCombatant, clearEncounter, removeCombatant, editCombatant, setInitiative, setHp, adjustHp, setAc, toggleCondition, toggleConcentration, addFromBestiary, addFromPlayerCharacter, makeStore, } = useEncounter(); const { characters: playerCharacters, createCharacter: createPlayerCharacter, editCharacter: editPlayerCharacter, deleteCharacter: deletePlayerCharacter, } = usePlayerCharacters(); const { search, getCreature, isLoaded, isSourceCached, fetchAndCacheSource, uploadAndCacheSource, refreshCache, } = useBestiary(); const bulkImport = useBulkImport(); const sidePanel = useSidePanelState(); const [rollSkippedCount, setRollSkippedCount] = useState(0); const selectedCreature: Creature | null = sidePanel.selectedCreatureId ? (getCreature(sidePanel.selectedCreatureId) ?? null) : null; const pinnedCreature: Creature | null = sidePanel.pinnedCreatureId ? (getCreature(sidePanel.pinnedCreatureId) ?? null) : null; const handleAddFromBestiary = useCallback( (result: SearchResult) => { addFromBestiary(result); }, [addFromBestiary], ); const handleCombatantStatBlock = useCallback( (creatureId: string) => { sidePanel.showCreature(creatureId as CreatureId); }, [sidePanel.showCreature], ); const handleRollInitiative = useCallback( (id: CombatantId) => { rollInitiativeUseCase(makeStore(), id, rollDice(), getCreature); }, [makeStore, getCreature], ); const handleRollAllInitiative = useCallback(() => { const result = rollAllInitiativeUseCase(makeStore(), rollDice, getCreature); if (!isDomainError(result) && result.skippedNoSource > 0) { setRollSkippedCount(result.skippedNoSource); } }, [makeStore, getCreature]); const handleViewStatBlock = useCallback( (result: SearchResult) => { const slug = result.name .toLowerCase() .replaceAll(/[^a-z0-9]+/g, "-") .replaceAll(/(^-|-$)/g, ""); const cId = `${result.source.toLowerCase()}:${slug}` as CreatureId; sidePanel.showCreature(cId); }, [sidePanel.showCreature], ); const handleStartBulkImport = useCallback( (baseUrl: string) => { bulkImport.startImport( baseUrl, fetchAndCacheSource, isSourceCached, refreshCache, ); }, [bulkImport.startImport, fetchAndCacheSource, isSourceCached, refreshCache], ); const handleBulkImportDone = useCallback(() => { sidePanel.dismissPanel(); bulkImport.reset(); }, [sidePanel.dismissPanel, bulkImport.reset]); const actionBarInputRef = useRef(null); const playerCharacterRef = useRef(null); const actionBarAnim = useActionBarAnimation(encounter.combatants.length); // Auto-scroll to the active combatant when the turn changes const activeRowRef = useRef(null); useEffect(() => { activeRowRef.current?.scrollIntoView({ block: "nearest", behavior: "smooth", }); }, []); return (
{!!actionBarAnim.showTopBar && (
)} {isEmpty ? ( /* Empty state — ActionBar centered */
playerCharacterRef.current?.openManagement() } onRollAllInitiative={handleRollAllInitiative} showRollAllInitiative={hasCreatureCombatants} rollAllInitiativeDisabled={!canRollAllInitiative} onOpenSourceManager={sidePanel.showSourceManager} autoFocus />
) : ( <> {/* Scrollable area — combatant list */}
{encounter.combatants.map((c, i) => ( handleCombatantStatBlock(c.creatureId as string) : undefined } onRollInitiative={ c.creatureId ? handleRollInitiative : undefined } /> ))}
{/* Action Bar — fixed at bottom */}
playerCharacterRef.current?.openManagement() } onRollAllInitiative={handleRollAllInitiative} showRollAllInitiative={hasCreatureCombatants} rollAllInitiativeDisabled={!canRollAllInitiative} onOpenSourceManager={sidePanel.showSourceManager} />
)}
{/* Pinned Stat Block Panel (left) */} {!!sidePanel.pinnedCreatureId && sidePanel.isWideDesktop && ( {}} onPin={() => {}} onUnpin={sidePanel.unpin} showPinButton={false} side="left" onDismiss={() => {}} /> )} {/* Browse Stat Block Panel (right) */} {}} showPinButton={sidePanel.isWideDesktop && !!selectedCreature} side="right" onDismiss={sidePanel.dismissPanel} bulkImportMode={sidePanel.bulkImportMode} bulkImportState={bulkImport.state} onStartBulkImport={handleStartBulkImport} onBulkImportDone={handleBulkImportDone} sourceManagerMode={sidePanel.sourceManagerMode} /> {rollSkippedCount > 0 && ( setRollSkippedCount(0)} autoDismissMs={4000} /> )}
); }