import type { PlayerCharacter, PlayerIcon } from "@initiative/domain"; import { Check, Eye, Import, Minus, Plus, Users } from "lucide-react"; import { type FormEvent, type RefObject, useEffect, useRef, useState, } from "react"; import type { SearchResult } from "../hooks/use-bestiary.js"; import { PLAYER_COLOR_HEX, PLAYER_ICON_MAP } from "./player-icon-map"; import { Button } from "./ui/button.js"; import { Input } from "./ui/input.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; } function creatureKey(r: SearchResult): string { return `${r.source}:${r.name}`; } export function ActionBar({ onAddCombatant, onAddFromBestiary, bestiarySearch, bestiaryLoaded, onViewStatBlock, onBulkImport, bulkImportDisabled, inputRef, playerCharacters, onAddFromPlayerCharacter, onManagePlayers, }: 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(""); // Stat block viewer: separate dropdown const [viewerOpen, setViewerOpen] = useState(false); const [viewerQuery, setViewerQuery] = useState(""); const [viewerResults, setViewerResults] = useState([]); const [viewerIndex, setViewerIndex] = useState(-1); const viewerRef = useRef(null); const viewerInputRef = useRef(null); const clearCustomFields = () => { setCustomInit(""); setCustomAc(""); setCustomMaxHp(""); }; const confirmQueued = () => { if (!queued) return; for (let i = 0; i < queued.count; i++) { onAddFromBestiary(queued.result); } setQueued(null); setNameInput(""); setSuggestions([]); setPcMatches([]); setSuggestionIndex(-1); }; 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 (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 handleNameChange = (value: string) => { setNameInput(value); setSuggestionIndex(-1); 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 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") { setQueued(null); setSuggestionIndex(-1); setSuggestions([]); setPcMatches([]); } }; // Stat block viewer dropdown handlers const openViewer = () => { setViewerOpen(true); setViewerQuery(""); setViewerResults([]); setViewerIndex(-1); requestAnimationFrame(() => viewerInputRef.current?.focus()); }; const closeViewer = () => { setViewerOpen(false); setViewerQuery(""); setViewerResults([]); setViewerIndex(-1); }; const handleViewerQueryChange = (value: string) => { setViewerQuery(value); setViewerIndex(-1); if (value.length >= 2) { setViewerResults(bestiarySearch(value)); } else { setViewerResults([]); } }; const handleViewerSelect = (result: SearchResult) => { onViewStatBlock?.(result); closeViewer(); }; const handleViewerKeyDown = (e: React.KeyboardEvent) => { if (e.key === "Escape") { closeViewer(); return; } if (viewerResults.length === 0) return; if (e.key === "ArrowDown") { e.preventDefault(); setViewerIndex((i) => (i < viewerResults.length - 1 ? i + 1 : 0)); } else if (e.key === "ArrowUp") { e.preventDefault(); setViewerIndex((i) => (i > 0 ? i - 1 : viewerResults.length - 1)); } else if (e.key === "Enter" && viewerIndex >= 0) { e.preventDefault(); handleViewerSelect(viewerResults[viewerIndex]); } }; // Close viewer on outside click useEffect(() => { if (!viewerOpen) return; function handleClickOutside(e: MouseEvent) { if (viewerRef.current && !viewerRef.current.contains(e.target as Node)) { closeViewer(); } } document.addEventListener("mousedown", handleClickOutside); return () => document.removeEventListener("mousedown", handleClickOutside); }, [viewerOpen]); return (
handleNameChange(e.target.value)} onKeyDown={handleKeyDown} placeholder="+ Add combatants" className="max-w-xs" /> {hasSuggestions && (
{pcMatches.length > 0 && ( <>
Players
    {pcMatches.map((pc) => { const PcIcon = PLAYER_ICON_MAP[pc.icon as PlayerIcon]; const pcColor = PLAYER_COLOR_HEX[ pc.color as keyof typeof PLAYER_COLOR_HEX ]; 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 )}
  • ); })}
)}
)}
{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" />
)}
{onManagePlayers && ( )} {bestiaryLoaded && onViewStatBlock && (
{viewerOpen && (
handleViewerQueryChange(e.target.value)} onKeyDown={handleViewerKeyDown} placeholder="Search stat blocks..." className="w-full" />
{viewerResults.length > 0 && (
    {viewerResults.map((result, i) => (
  • ))}
)} {viewerQuery.length >= 2 && viewerResults.length === 0 && (
No creatures found
)}
)}
)} {bestiaryLoaded && onBulkImport && ( )}
); }