import { rollAllInitiativeUseCase, rollInitiativeUseCase, } from "@initiative/application"; import type { CombatantId, Creature, CreatureId } from "@initiative/domain"; import { Plus } from "lucide-react"; 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 [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); // 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); 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); // 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 ? ( ) : ( encounter.combatants.map((c, i) => ( handleCombatantStatBlock(c.creatureId as string) : undefined } onRollInitiative={ c.creatureId ? handleRollInitiative : undefined } /> )) )}
{/* Action Bar — fixed at bottom */}
{/* 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 && ( )}
); }