import type { PlayerCharacter, PlayerIcon } from "@initiative/domain"; import { Check, Eye, EyeOff, Import, Library, Minus, Plus, Users, } from "lucide-react"; import { type FormEvent, type RefObject, useState } from "react"; import type { SearchResult } from "../hooks/use-bestiary.js"; import { cn } from "../lib/utils.js"; import { D20Icon } from "./d20-icon.js"; import { PLAYER_COLOR_HEX, PLAYER_ICON_MAP } from "./player-icon-map"; import { Button } from "./ui/button.js"; import { Input } from "./ui/input.js"; import { OverflowMenu, type OverflowMenuItem } from "./ui/overflow-menu.js"; interface QueuedCreature { result: SearchResult; count: number; } interface ActionBarProps { onAddCombatant: ( name: string, opts?: { initiative?: number; ac?: number; maxHp?: number }, ) => void; onAddFromBestiary: (result: SearchResult) => void; bestiarySearch: (query: string) => SearchResult[]; bestiaryLoaded: boolean; onViewStatBlock?: (result: SearchResult) => void; onBulkImport?: () => void; bulkImportDisabled?: boolean; inputRef?: RefObject; playerCharacters?: readonly PlayerCharacter[]; onAddFromPlayerCharacter?: (pc: PlayerCharacter) => void; onManagePlayers?: () => void; onRollAllInitiative?: () => void; showRollAllInitiative?: boolean; rollAllInitiativeDisabled?: boolean; onOpenSourceManager?: () => void; autoFocus?: boolean; } function creatureKey(r: SearchResult): string { return `${r.source}:${r.name}`; } function AddModeSuggestions({ nameInput, suggestions, pcMatches, suggestionIndex, queued, onDismiss, onClickSuggestion, onSetSuggestionIndex, onSetQueued, onConfirmQueued, onAddFromPlayerCharacter, onClear, }: { nameInput: string; suggestions: SearchResult[]; pcMatches: PlayerCharacter[]; suggestionIndex: number; queued: QueuedCreature | null; onDismiss: () => void; onClear: () => void; onClickSuggestion: (result: SearchResult) => void; onSetSuggestionIndex: (i: number) => void; onSetQueued: (q: QueuedCreature | null) => void; onConfirmQueued: () => void; onAddFromPlayerCharacter?: (pc: PlayerCharacter) => void; }) { return (
{pcMatches.length > 0 && ( <>
Players
    {pcMatches.map((pc) => { const PcIcon = pc.icon ? PLAYER_ICON_MAP[pc.icon as PlayerIcon] : undefined; const pcColor = pc.color ? PLAYER_COLOR_HEX[pc.color as keyof typeof PLAYER_COLOR_HEX] : undefined; return (
  • ); })}
)} {suggestions.length > 0 && (
    {suggestions.map((result, i) => { const key = creatureKey(result); const isQueued = queued !== null && creatureKey(queued.result) === key; return (
  • {queued.count} ) : ( result.sourceDisplayName )}
  • ); })}
)}
); } function buildOverflowItems(opts: { onManagePlayers?: () => void; onOpenSourceManager?: () => void; bestiaryLoaded: boolean; onBulkImport?: () => void; bulkImportDisabled?: boolean; }): OverflowMenuItem[] { const items: OverflowMenuItem[] = []; if (opts.onManagePlayers) { items.push({ icon: , label: "Player Characters", onClick: opts.onManagePlayers, }); } if (opts.onOpenSourceManager) { items.push({ icon: , label: "Manage Sources", onClick: opts.onOpenSourceManager, }); } if (opts.bestiaryLoaded && opts.onBulkImport) { items.push({ icon: , label: "Import All Sources", onClick: opts.onBulkImport, disabled: opts.bulkImportDisabled, }); } return items; } export function ActionBar({ onAddCombatant, onAddFromBestiary, bestiarySearch, bestiaryLoaded, onViewStatBlock, onBulkImport, bulkImportDisabled, inputRef, playerCharacters, onAddFromPlayerCharacter, onManagePlayers, onRollAllInitiative, showRollAllInitiative, rollAllInitiativeDisabled, onOpenSourceManager, autoFocus, }: ActionBarProps) { const [nameInput, setNameInput] = useState(""); const [suggestions, setSuggestions] = useState([]); const [pcMatches, setPcMatches] = useState([]); const [suggestionIndex, setSuggestionIndex] = useState(-1); const [queued, setQueued] = useState(null); const [customInit, setCustomInit] = useState(""); const [customAc, setCustomAc] = useState(""); const [customMaxHp, setCustomMaxHp] = useState(""); const [browseMode, setBrowseMode] = useState(false); const clearCustomFields = () => { setCustomInit(""); setCustomAc(""); setCustomMaxHp(""); }; const clearInput = () => { setNameInput(""); setSuggestions([]); setPcMatches([]); setQueued(null); setSuggestionIndex(-1); }; const dismissSuggestions = () => { setSuggestions([]); setPcMatches([]); setQueued(null); setSuggestionIndex(-1); }; const confirmQueued = () => { if (!queued) return; for (let i = 0; i < queued.count; i++) { onAddFromBestiary(queued.result); } clearInput(); }; const parseNum = (v: string): number | undefined => { if (v.trim() === "") return undefined; const n = Number(v); return Number.isNaN(n) ? undefined : n; }; const handleAdd = (e: FormEvent) => { e.preventDefault(); if (browseMode) return; if (queued) { confirmQueued(); return; } if (nameInput.trim() === "") return; const opts: { initiative?: number; ac?: number; maxHp?: number } = {}; const init = parseNum(customInit); const ac = parseNum(customAc); const maxHp = parseNum(customMaxHp); if (init !== undefined) opts.initiative = init; if (ac !== undefined) opts.ac = ac; if (maxHp !== undefined) opts.maxHp = maxHp; onAddCombatant(nameInput, Object.keys(opts).length > 0 ? opts : undefined); setNameInput(""); setSuggestions([]); setPcMatches([]); clearCustomFields(); }; const handleBrowseSearch = (value: string) => { setSuggestions(value.length >= 2 ? bestiarySearch(value) : []); }; const handleAddSearch = (value: string) => { let newSuggestions: SearchResult[] = []; let newPcMatches: PlayerCharacter[] = []; if (value.length >= 2) { newSuggestions = bestiarySearch(value); setSuggestions(newSuggestions); if (playerCharacters && playerCharacters.length > 0) { const lower = value.toLowerCase(); newPcMatches = playerCharacters.filter((pc) => pc.name.toLowerCase().includes(lower), ); } setPcMatches(newPcMatches); } else { setSuggestions([]); setPcMatches([]); } if (newSuggestions.length > 0 || newPcMatches.length > 0) { clearCustomFields(); } if (queued) { const qKey = creatureKey(queued.result); const stillVisible = newSuggestions.some((s) => creatureKey(s) === qKey); if (!stillVisible) { setQueued(null); } } }; const handleNameChange = (value: string) => { setNameInput(value); setSuggestionIndex(-1); if (browseMode) { handleBrowseSearch(value); } else { handleAddSearch(value); } }; const handleClickSuggestion = (result: SearchResult) => { const key = creatureKey(result); if (queued && creatureKey(queued.result) === key) { setQueued({ ...queued, count: queued.count + 1 }); } else { setQueued({ result, count: 1 }); } }; const handleEnter = () => { if (queued) { confirmQueued(); } else if (suggestionIndex >= 0) { handleClickSuggestion(suggestions[suggestionIndex]); } }; const hasSuggestions = suggestions.length > 0 || pcMatches.length > 0; const handleKeyDown = (e: React.KeyboardEvent) => { if (!hasSuggestions) 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") { e.preventDefault(); handleEnter(); } else if (e.key === "Escape") { dismissSuggestions(); } }; const handleBrowseKeyDown = (e: React.KeyboardEvent) => { if (e.key === "Escape") { setBrowseMode(false); clearInput(); return; } 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(); onViewStatBlock?.(suggestions[suggestionIndex]); setBrowseMode(false); clearInput(); } }; const handleBrowseSelect = (result: SearchResult) => { onViewStatBlock?.(result); setBrowseMode(false); clearInput(); }; const toggleBrowseMode = () => { setBrowseMode((m) => !m); clearInput(); clearCustomFields(); }; const overflowItems = buildOverflowItems({ onManagePlayers, onOpenSourceManager, bestiaryLoaded, onBulkImport, bulkImportDisabled, }); return (
handleNameChange(e.target.value)} onKeyDown={browseMode ? handleBrowseKeyDown : handleKeyDown} placeholder={ browseMode ? "Search stat blocks..." : "+ Add combatants" } className="pr-8" autoFocus={autoFocus} /> {bestiaryLoaded && onViewStatBlock && ( )} {browseMode && suggestions.length > 0 && (
    {suggestions.map((result, i) => (
  • ))}
)} {!browseMode && hasSuggestions && ( )}
{!browseMode && nameInput.length >= 2 && !hasSuggestions && (
setCustomInit(e.target.value)} placeholder="Init" className="w-16 text-center" /> setCustomAc(e.target.value)} placeholder="AC" className="w-16 text-center" /> setCustomMaxHp(e.target.value)} placeholder="MaxHP" className="w-18 text-center" />
)} {!browseMode && nameInput.length >= 2 && !hasSuggestions && ( )} {showRollAllInitiative && onRollAllInitiative && ( )} {overflowItems.length > 0 && }
); }