161 lines
4.5 KiB
TypeScript
161 lines
4.5 KiB
TypeScript
import type { Creature } from "@initiative/domain";
|
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
import { ActionBar } from "./components/action-bar";
|
|
import { CombatantRow } from "./components/combatant-row";
|
|
import { StatBlockPanel } from "./components/stat-block-panel";
|
|
import { TurnNavigation } from "./components/turn-navigation";
|
|
import { useBestiary } from "./hooks/use-bestiary";
|
|
import { useEncounter } from "./hooks/use-encounter";
|
|
|
|
export function App() {
|
|
const {
|
|
encounter,
|
|
advanceTurn,
|
|
retreatTurn,
|
|
addCombatant,
|
|
removeCombatant,
|
|
editCombatant,
|
|
setInitiative,
|
|
setHp,
|
|
adjustHp,
|
|
setAc,
|
|
toggleCondition,
|
|
toggleConcentration,
|
|
addFromBestiary,
|
|
} = useEncounter();
|
|
|
|
const { search, getCreature, isLoaded } = useBestiary();
|
|
|
|
const [selectedCreature, setSelectedCreature] = useState<Creature | null>(
|
|
null,
|
|
);
|
|
const [suggestions, setSuggestions] = useState<Creature[]>([]);
|
|
|
|
const handleAddFromBestiary = useCallback(
|
|
(creature: Creature) => {
|
|
addFromBestiary(creature);
|
|
setSelectedCreature(creature);
|
|
},
|
|
[addFromBestiary],
|
|
);
|
|
|
|
const handleShowStatBlock = useCallback((creature: Creature) => {
|
|
setSelectedCreature(creature);
|
|
}, []);
|
|
|
|
const handleCombatantStatBlock = useCallback(
|
|
(creatureId: string) => {
|
|
const creature = getCreature(
|
|
creatureId as import("@initiative/domain").CreatureId,
|
|
);
|
|
if (creature) setSelectedCreature(creature);
|
|
},
|
|
[getCreature],
|
|
);
|
|
|
|
const handleSearchChange = useCallback(
|
|
(query: string) => {
|
|
if (!isLoaded || query.length < 2) {
|
|
setSuggestions([]);
|
|
return;
|
|
}
|
|
setSuggestions(search(query));
|
|
},
|
|
[isLoaded, search],
|
|
);
|
|
|
|
// Auto-scroll to the active combatant when the turn changes
|
|
const activeRowRef = useRef<HTMLDivElement>(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
|
|
useEffect(() => {
|
|
if (!window.matchMedia("(min-width: 1024px)").matches) return;
|
|
const active = encounter.combatants[encounter.activeIndex];
|
|
if (!active?.creatureId || !isLoaded) return;
|
|
const creature = getCreature(
|
|
active.creatureId as import("@initiative/domain").CreatureId,
|
|
);
|
|
if (creature) setSelectedCreature(creature);
|
|
}, [encounter.activeIndex, encounter.combatants, getCreature, isLoaded]);
|
|
|
|
return (
|
|
<div className="flex h-screen flex-col">
|
|
<div className="mx-auto flex w-full max-w-2xl flex-1 flex-col gap-6 px-4 min-h-0">
|
|
{/* Turn Navigation — fixed at top */}
|
|
<div className="shrink-0 pt-8">
|
|
<TurnNavigation
|
|
encounter={encounter}
|
|
onAdvanceTurn={advanceTurn}
|
|
onRetreatTurn={retreatTurn}
|
|
/>
|
|
</div>
|
|
|
|
{/* Scrollable area — combatant list */}
|
|
<div className="flex-1 overflow-y-auto min-h-0">
|
|
<header className="space-y-1 mb-6">
|
|
<h1 className="text-2xl font-bold tracking-tight">
|
|
Initiative Tracker
|
|
</h1>
|
|
</header>
|
|
|
|
<div className="flex flex-col gap-1 pb-2">
|
|
{encounter.combatants.length === 0 ? (
|
|
<p className="py-12 text-center text-sm text-muted-foreground">
|
|
No combatants yet — add one to get started
|
|
</p>
|
|
) : (
|
|
encounter.combatants.map((c, i) => (
|
|
<CombatantRow
|
|
key={c.id}
|
|
ref={i === encounter.activeIndex ? activeRowRef : null}
|
|
combatant={c}
|
|
isActive={i === encounter.activeIndex}
|
|
onRename={editCombatant}
|
|
onSetInitiative={setInitiative}
|
|
onRemove={removeCombatant}
|
|
onSetHp={setHp}
|
|
onAdjustHp={adjustHp}
|
|
onSetAc={setAc}
|
|
onToggleCondition={toggleCondition}
|
|
onToggleConcentration={toggleConcentration}
|
|
onShowStatBlock={
|
|
c.creatureId
|
|
? () => handleCombatantStatBlock(c.creatureId as string)
|
|
: undefined
|
|
}
|
|
/>
|
|
))
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Action Bar — fixed at bottom */}
|
|
<div className="shrink-0 pb-8">
|
|
<ActionBar
|
|
onAddCombatant={addCombatant}
|
|
onAddFromBestiary={handleAddFromBestiary}
|
|
bestiarySearch={search}
|
|
bestiaryLoaded={isLoaded}
|
|
suggestions={suggestions}
|
|
onSearchChange={handleSearchChange}
|
|
onShowStatBlock={handleShowStatBlock}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Stat Block Panel */}
|
|
<StatBlockPanel
|
|
creature={selectedCreature}
|
|
onClose={() => setSelectedCreature(null)}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|