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:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user