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,35 +1,143 @@
import type { Creature } from "@initiative/domain";
import { Search } from "lucide-react";
import { type FormEvent, useState } from "react";
import { Button } from "./ui/button";
import { Input } from "./ui/input";
import { BestiarySearch } from "./bestiary-search.js";
import { Button } from "./ui/button.js";
import { Input } from "./ui/input.js";
interface ActionBarProps {
onAddCombatant: (name: string) => void;
onAddFromBestiary: (creature: Creature) => void;
bestiarySearch: (query: string) => Creature[];
bestiaryLoaded: boolean;
suggestions: Creature[];
onSearchChange: (query: string) => void;
onShowStatBlock?: (creature: Creature) => void;
}
export function ActionBar({ onAddCombatant }: ActionBarProps) {
export function ActionBar({
onAddCombatant,
onAddFromBestiary,
bestiarySearch,
bestiaryLoaded,
suggestions,
onSearchChange,
onShowStatBlock,
}: ActionBarProps) {
const [nameInput, setNameInput] = useState("");
const [searchOpen, setSearchOpen] = useState(false);
const [suggestionIndex, setSuggestionIndex] = useState(-1);
const handleAdd = (e: FormEvent) => {
e.preventDefault();
if (nameInput.trim() === "") return;
onAddCombatant(nameInput);
setNameInput("");
onSearchChange("");
};
const handleNameChange = (value: string) => {
setNameInput(value);
setSuggestionIndex(-1);
onSearchChange(value);
};
const handleSelectCreature = (creature: Creature) => {
onAddFromBestiary(creature);
setSearchOpen(false);
setNameInput("");
onSearchChange("");
onShowStatBlock?.(creature);
};
const handleSelectSuggestion = (creature: Creature) => {
onAddFromBestiary(creature);
setNameInput("");
onSearchChange("");
onShowStatBlock?.(creature);
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (suggestions.length === 0) return;
if (e.key === "ArrowDown") {
e.preventDefault();
setSuggestionIndex((i) => (i < suggestions.length - 1 ? i + 1 : 0));
} else if (e.key === "ArrowUp") {
e.preventDefault();
setSuggestionIndex((i) => (i > 0 ? i - 1 : suggestions.length - 1));
} else if (e.key === "Enter" && suggestionIndex >= 0) {
e.preventDefault();
handleSelectSuggestion(suggestions[suggestionIndex]);
} else if (e.key === "Escape") {
setSuggestionIndex(-1);
onSearchChange("");
}
};
return (
<div className="flex items-center gap-3 rounded-md border border-border bg-card px-4 py-3">
<form onSubmit={handleAdd} className="flex flex-1 items-center gap-2">
<Input
type="text"
value={nameInput}
onChange={(e) => setNameInput(e.target.value)}
placeholder="Combatant name"
className="max-w-xs"
{searchOpen ? (
<BestiarySearch
onSelectCreature={handleSelectCreature}
onClose={() => setSearchOpen(false)}
searchFn={bestiarySearch}
/>
<Button type="submit" size="sm">
Add
</Button>
</form>
) : (
<form
onSubmit={handleAdd}
className="relative flex flex-1 items-center gap-2"
>
<div className="relative flex-1">
<Input
type="text"
value={nameInput}
onChange={(e) => handleNameChange(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Combatant name"
className="max-w-xs"
/>
{suggestions.length > 0 && (
<div className="absolute bottom-full z-50 mb-1 w-full max-w-xs rounded-md border border-border bg-card shadow-lg">
<ul className="max-h-48 overflow-y-auto py-1">
{suggestions.map((creature, i) => (
<li key={creature.id}>
<button
type="button"
className={`flex w-full items-center justify-between px-3 py-1.5 text-left text-sm ${
i === suggestionIndex
? "bg-accent/20 text-foreground"
: "text-foreground hover:bg-accent/10"
}`}
onClick={() => handleSelectSuggestion(creature)}
onMouseEnter={() => setSuggestionIndex(i)}
>
<span>{creature.name}</span>
<span className="text-xs text-muted-foreground">
{creature.sourceDisplayName}
</span>
</button>
</li>
))}
</ul>
</div>
)}
</div>
<Button type="submit" size="sm">
Add
</Button>
{bestiaryLoaded && (
<Button
type="button"
size="sm"
variant="ghost"
onClick={() => setSearchOpen(true)}
>
<Search className="h-4 w-4" />
</Button>
)}
</form>
)}
</div>
);
}