import type { CreatureId, PlayerCharacter } from "@initiative/domain"; import { Check, Eye, EyeOff, Import, Library, Minus, Monitor, Moon, Plus, Sun, Users, } from "lucide-react"; import React, { type RefObject, useCallback, useDeferredValue, useState, } from "react"; import type { SearchResult } from "../contexts/bestiary-context.js"; import { useBestiaryContext } from "../contexts/bestiary-context.js"; import { useBulkImportContext } from "../contexts/bulk-import-context.js"; import { useEncounterContext } from "../contexts/encounter-context.js"; import { useInitiativeRollsContext } from "../contexts/initiative-rolls-context.js"; import { usePlayerCharactersContext } from "../contexts/player-characters-context.js"; import { useSidePanelContext } from "../contexts/side-panel-context.js"; import { useThemeContext } from "../contexts/theme-context.js"; import { useLongPress } from "../hooks/use-long-press.js"; import { cn } from "../lib/utils.js"; import { D20Icon } from "./d20-icon.js"; import { PLAYER_COLOR_HEX, PLAYER_ICON_MAP } from "./player-icon-map.js"; import { RollModeMenu } from "./roll-mode-menu.js"; 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 { inputRef?: RefObject; autoFocus?: boolean; onManagePlayers?: () => void; } function creatureKey(r: SearchResult): string { return `${r.source}:${r.name}`; } function AddModeSuggestions({ nameInput, suggestions, pcMatches, suggestionIndex, queued, onDismiss, onClickSuggestion, onSetSuggestionIndex, onSetQueued, onConfirmQueued, onAddFromPlayerCharacter, onClear, }: Readonly<{ 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] : undefined; const pcColor = pc.color ? PLAYER_COLOR_HEX[pc.color] : 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 )}
  • ); })}
)}
); } const THEME_ICONS = { system: Monitor, light: Sun, dark: Moon, } as const; const THEME_LABELS = { system: "Theme: System", light: "Theme: Light", dark: "Theme: Dark", } as const; function buildOverflowItems(opts: { onManagePlayers?: () => void; onOpenSourceManager?: () => void; bestiaryLoaded: boolean; onBulkImport?: () => void; bulkImportDisabled?: boolean; themePreference?: "system" | "light" | "dark"; onCycleTheme?: () => void; }): 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, }); } if (opts.onCycleTheme) { const pref = opts.themePreference ?? "system"; const ThemeIcon = THEME_ICONS[pref]; items.push({ icon: , label: THEME_LABELS[pref], onClick: opts.onCycleTheme, keepOpen: true, }); } return items; } export function ActionBar({ inputRef, autoFocus, onManagePlayers, }: Readonly) { const { addCombatant, addFromBestiary, addFromPlayerCharacter, hasCreatureCombatants, canRollAllInitiative, } = useEncounterContext(); const { search: bestiarySearch, isLoaded: bestiaryLoaded } = useBestiaryContext(); const { characters: playerCharacters } = usePlayerCharactersContext(); const { showBulkImport, showSourceManager, showCreature, panelView } = useSidePanelContext(); const { preference: themePreference, cycleTheme } = useThemeContext(); const { handleRollAllInitiative } = useInitiativeRollsContext(); const { state: bulkImportState } = useBulkImportContext(); const handleAddFromBestiary = useCallback( (result: SearchResult) => { const creatureId = addFromBestiary(result); if (creatureId && panelView.mode === "closed") { showCreature(creatureId); } }, [addFromBestiary, panelView.mode, showCreature], ); const handleViewStatBlock = useCallback( (result: SearchResult) => { const slug = result.name .toLowerCase() .replaceAll(/[^a-z0-9]+/g, "-") .replaceAll(/(^-|-$)/g, ""); const cId = `${result.source.toLowerCase()}:${slug}` as CreatureId; showCreature(cId); }, [showCreature], ); const [nameInput, setNameInput] = useState(""); const [suggestions, setSuggestions] = useState([]); const [pcMatches, setPcMatches] = useState([]); const deferredSuggestions = useDeferredValue(suggestions); const deferredPcMatches = useDeferredValue(pcMatches); 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++) { handleAddFromBestiary(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: React.SubmitEvent) => { 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; addCombatant(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 = deferredSuggestions.length > 0 || deferredPcMatches.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(); handleViewStatBlock(suggestions[suggestionIndex]); setBrowseMode(false); clearInput(); } }; const handleBrowseSelect = (result: SearchResult) => { handleViewStatBlock(result); setBrowseMode(false); clearInput(); }; const toggleBrowseMode = () => { setBrowseMode((prev) => { const next = !prev; setSuggestionIndex(-1); setQueued(null); if (next) { handleBrowseSearch(nameInput); } else { handleAddSearch(nameInput); } return next; }); clearCustomFields(); }; const [rollAllMenuPos, setRollAllMenuPos] = useState<{ x: number; y: number; } | null>(null); const openRollAllMenu = useCallback((x: number, y: number) => { setRollAllMenuPos({ x, y }); }, []); const rollAllLongPress = useLongPress( useCallback( (e: React.TouchEvent) => { const touch = e.touches[0]; if (touch) openRollAllMenu(touch.clientX, touch.clientY); }, [openRollAllMenu], ), ); const overflowItems = buildOverflowItems({ onManagePlayers, onOpenSourceManager: showSourceManager, bestiaryLoaded, onBulkImport: showBulkImport, bulkImportDisabled: bulkImportState.status === "loading", themePreference, onCycleTheme: cycleTheme, }); return (
handleNameChange(e.target.value)} onKeyDown={browseMode ? handleBrowseKeyDown : handleKeyDown} placeholder={ browseMode ? "Search stat blocks..." : "+ Add combatants" } className="pr-8" autoFocus={autoFocus} /> {!!bestiaryLoaded && ( )} {browseMode && deferredSuggestions.length > 0 && (
    {deferredSuggestions.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 && ( )} {!!hasCreatureCombatants && ( <> {!!rollAllMenuPos && ( handleRollAllInitiative(mode)} onClose={() => setRollAllMenuPos(null)} /> )} )} {overflowItems.length > 0 && }
); }