import { rollAllInitiativeUseCase, rollInitiativeUseCase, } from "@initiative/application"; import type { CombatantId, Creature, CreatureId } from "@initiative/domain"; import { useCallback, useEffect, useLayoutEffect, useRef, useState, } from "react"; import { ActionBar } from "./components/action-bar"; import { CombatantRow } from "./components/combatant-row"; import { CreatePlayerModal } from "./components/create-player-modal"; import { PlayerManagement } from "./components/player-management"; 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"; import { usePlayerCharacters } from "./hooks/use-player-characters"; 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 topBarClass = settling ? " animate-slide-down-in" : topBarExiting ? " absolute inset-x-0 top-0 z-10 px-4 animate-slide-up-out" : ""; const showTopBar = !empty || topBarExiting; return { risingClass, settlingClass, topBarClass, showTopBar, onSettleEnd: () => setSettling(false), onRiseEnd: () => setRising(false), onTopBarExitEnd: () => setTopBarExiting(false), }; } export function App() { const { encounter, 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 [createPlayerOpen, setCreatePlayerOpen] = useState(false); const [managementOpen, setManagementOpen] = useState(false); const [editingPlayer, setEditingPlayer] = useState< (typeof playerCharacters)[number] | undefined >(undefined); 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 [isRightPanelFolded, setIsRightPanelFolded] = useState(false); const [pinnedCreatureId, setPinnedCreatureId] = useState( null, ); const [isWideDesktop, setIsWideDesktop] = useState( () => window.matchMedia("(min-width: 1280px)").matches, ); useEffect(() => { const mq = window.matchMedia("(min-width: 1280px)"); const handler = (e: MediaQueryListEvent) => setIsWideDesktop(e.matches); mq.addEventListener("change", handler); return () => mq.removeEventListener("change", handler); }, []); const selectedCreature: Creature | null = selectedCreatureId ? (getCreature(selectedCreatureId) ?? null) : null; const pinnedCreature: Creature | null = pinnedCreatureId ? (getCreature(pinnedCreatureId) ?? null) : null; const handleAddFromBestiary = useCallback( (result: SearchResult) => { addFromBestiary(result); }, [addFromBestiary], ); const handleCombatantStatBlock = useCallback((creatureId: string) => { setSelectedCreatureId(creatureId as CreatureId); setIsRightPanelFolded(false); }, []); const handleRollInitiative = useCallback( (id: CombatantId) => { rollInitiativeUseCase(makeStore(), id, rollDice(), getCreature); }, [makeStore, getCreature], ); const handleRollAllInitiative = useCallback(() => { rollAllInitiativeUseCase(makeStore(), rollDice, getCreature); }, [makeStore, getCreature]); const handleViewStatBlock = useCallback((result: SearchResult) => { const slug = result.name .toLowerCase() .replace(/[^a-z0-9]+/g, "-") .replace(/(^-|-$)/g, ""); const cId = `${result.source.toLowerCase()}:${slug}` as CreatureId; setSelectedCreatureId(cId); setIsRightPanelFolded(false); }, []); 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]); const handleDismissBrowsePanel = useCallback(() => { setSelectedCreatureId(null); setBulkImportMode(false); }, []); const handleToggleFold = useCallback(() => { setIsRightPanelFolded((f) => !f); }, []); const handlePin = useCallback(() => { if (selectedCreatureId) { setPinnedCreatureId((prev) => prev === selectedCreatureId ? null : selectedCreatureId, ); } }, [selectedCreatureId]); const handleUnpin = useCallback(() => { setPinnedCreatureId(null); }, []); const actionBarInputRef = 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", }); }, [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]); const isEmpty = encounter.combatants.length === 0; const showRollAllInitiative = encounter.combatants.some( (c) => c.creatureId != null && c.initiative == null, ); return (
{actionBarAnim.showTopBar && (
)} {isEmpty ? ( /* Empty state — ActionBar centered */
setManagementOpen(true)} onRollAllInitiative={handleRollAllInitiative} showRollAllInitiative={showRollAllInitiative} onOpenSourceManager={() => setSourceManagerOpen((o) => !o)} autoFocus />
) : ( <> {sourceManagerOpen && (
)} {/* 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 */}
setManagementOpen(true)} onRollAllInitiative={handleRollAllInitiative} showRollAllInitiative={showRollAllInitiative} onOpenSourceManager={() => setSourceManagerOpen((o) => !o)} />
)}
{/* Pinned Stat Block Panel (left) */} {pinnedCreatureId && isWideDesktop && ( {}} onPin={() => {}} onUnpin={handleUnpin} showPinButton={false} side="left" onDismiss={() => {}} /> )} {/* Browse Stat Block Panel (right) */} {}} showPinButton={isWideDesktop && !!selectedCreature} side="right" onDismiss={handleDismissBrowsePanel} 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 && ( )} { setCreatePlayerOpen(false); setEditingPlayer(undefined); }} onSave={(name, ac, maxHp, color, icon) => { if (editingPlayer) { editPlayerCharacter?.(editingPlayer.id, { name, ac, maxHp, color, icon, }); } else { createPlayerCharacter(name, ac, maxHp, color, icon); } }} playerCharacter={editingPlayer} /> setManagementOpen(false)} characters={playerCharacters} onEdit={(pc) => { setEditingPlayer(pc); setCreatePlayerOpen(true); setManagementOpen(false); }} onDelete={(id) => deletePlayerCharacter?.(id)} onCreate={() => { setEditingPlayer(undefined); setCreatePlayerOpen(true); setManagementOpen(false); }} />
); }