Implement the 021-bestiary-statblock feature that adds a searchable D&D 2024 Monster Manual creature library with inline autocomplete suggestions, full stat block display in a fixed side panel, auto-numbering of duplicate creature names, HP/AC pre-fill from bestiary data, and automatic stat block display on turn change for wide viewports

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Lukas
2026-03-09 11:01:07 +01:00
parent 04a4f18f98
commit fa078be2f9
30 changed files with 66221 additions and 56 deletions

View File

@@ -1,6 +1,10 @@
import type { Creature } from "@initiative/domain";
import { useCallback, useEffect, 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() {
@@ -17,51 +21,125 @@ export function App() {
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-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="mx-auto flex min-h-screen max-w-2xl flex-col gap-6 px-4 py-8">
{/* Header */}
<header className="space-y-1">
<h1 className="text-2xl font-bold tracking-tight">
Initiative Tracker
</h1>
</header>
<div className="h-screen overflow-y-auto">
<div className="mx-auto flex w-full max-w-2xl flex-col gap-6 px-4 py-8">
{/* Header */}
<header className="space-y-1">
<h1 className="text-2xl font-bold tracking-tight">
Initiative Tracker
</h1>
</header>
{/* Turn Navigation */}
<TurnNavigation
encounter={encounter}
onAdvanceTurn={advanceTurn}
onRetreatTurn={retreatTurn}
/>
{/* Turn Navigation */}
<TurnNavigation
encounter={encounter}
onAdvanceTurn={advanceTurn}
onRetreatTurn={retreatTurn}
/>
{/* Combatant List */}
<div className="flex flex-1 flex-col gap-1">
{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}
combatant={c}
isActive={i === encounter.activeIndex}
onRename={editCombatant}
onSetInitiative={setInitiative}
onRemove={removeCombatant}
onSetHp={setHp}
onAdjustHp={adjustHp}
onSetAc={setAc}
onToggleCondition={toggleCondition}
onToggleConcentration={toggleConcentration}
/>
))
)}
{/* Combatant List */}
<div className="flex flex-1 flex-col gap-1">
{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}
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>
{/* Action Bar */}
<ActionBar
onAddCombatant={addCombatant}
onAddFromBestiary={handleAddFromBestiary}
bestiarySearch={search}
bestiaryLoaded={isLoaded}
suggestions={suggestions}
onSearchChange={handleSearchChange}
onShowStatBlock={handleShowStatBlock}
/>
</div>
{/* Action Bar */}
<ActionBar onAddCombatant={addCombatant} />
{/* Stat Block Panel */}
<StatBlockPanel
creature={selectedCreature}
onClose={() => setSelectedCreature(null)}
/>
</div>
);
}