- Standardize icon button sizing (size="icon") and color (text-muted-foreground) across top and bottom bars - Group bottom bar icon buttons with gap-0 to match top bar style - Add missing tooltips/aria-labels for stat block viewer, bulk import buttons - Replace Settings icon with Library for source manager - Make step forward/back buttons use primary (solid) variant - Move round badge next to combatant name in center of top bar - Close player create/edit and management modals on backdrop click Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
531 lines
16 KiB
TypeScript
531 lines
16 KiB
TypeScript
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<HTMLInputElement | null>;
|
|
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<SearchResult[]>([]);
|
|
const [pcMatches, setPcMatches] = useState<PlayerCharacter[]>([]);
|
|
const [suggestionIndex, setSuggestionIndex] = useState(-1);
|
|
const [queued, setQueued] = useState<QueuedCreature | null>(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<SearchResult[]>([]);
|
|
const [viewerIndex, setViewerIndex] = useState(-1);
|
|
const viewerRef = useRef<HTMLDivElement>(null);
|
|
const viewerInputRef = useRef<HTMLInputElement>(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 (
|
|
<div className="flex items-center gap-3 rounded-md border border-border bg-card px-4 py-3">
|
|
<form
|
|
onSubmit={handleAdd}
|
|
className="relative flex flex-1 items-center gap-2"
|
|
>
|
|
<div className="relative flex-1">
|
|
<Input
|
|
ref={inputRef}
|
|
type="text"
|
|
value={nameInput}
|
|
onChange={(e) => handleNameChange(e.target.value)}
|
|
onKeyDown={handleKeyDown}
|
|
placeholder="+ Add combatants"
|
|
className="max-w-xs"
|
|
/>
|
|
{hasSuggestions && (
|
|
<div className="absolute bottom-full z-50 mb-1 w-full max-w-xs rounded-md border border-border bg-card shadow-lg">
|
|
<button
|
|
type="button"
|
|
className="flex w-full items-center gap-1.5 border-b border-border px-3 py-2 text-left text-sm text-accent hover:bg-accent/20"
|
|
onMouseDown={(e) => e.preventDefault()}
|
|
onClick={() => {
|
|
setSuggestions([]);
|
|
setPcMatches([]);
|
|
setQueued(null);
|
|
setSuggestionIndex(-1);
|
|
}}
|
|
>
|
|
<Plus className="h-3.5 w-3.5" />
|
|
<span className="flex-1">Add "{nameInput}" as custom</span>
|
|
<kbd className="rounded border border-border px-1.5 py-0.5 text-xs text-muted-foreground">
|
|
Esc
|
|
</kbd>
|
|
</button>
|
|
<div className="max-h-48 overflow-y-auto py-1">
|
|
{pcMatches.length > 0 && (
|
|
<>
|
|
<div className="px-3 py-1 text-xs font-medium text-muted-foreground">
|
|
Players
|
|
</div>
|
|
<ul>
|
|
{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 (
|
|
<li key={pc.id}>
|
|
<button
|
|
type="button"
|
|
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm text-foreground hover:bg-hover-neutral-bg"
|
|
onMouseDown={(e) => e.preventDefault()}
|
|
onClick={() => {
|
|
onAddFromPlayerCharacter?.(pc);
|
|
setNameInput("");
|
|
setSuggestions([]);
|
|
setPcMatches([]);
|
|
}}
|
|
>
|
|
{PcIcon && (
|
|
<PcIcon size={14} style={{ color: pcColor }} />
|
|
)}
|
|
<span className="flex-1 truncate">{pc.name}</span>
|
|
<span className="text-xs text-muted-foreground">
|
|
Player
|
|
</span>
|
|
</button>
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
</>
|
|
)}
|
|
{suggestions.length > 0 && (
|
|
<ul>
|
|
{suggestions.map((result, i) => {
|
|
const key = creatureKey(result);
|
|
const isQueued =
|
|
queued !== null && creatureKey(queued.result) === key;
|
|
return (
|
|
<li key={key}>
|
|
<button
|
|
type="button"
|
|
className={`flex w-full items-center justify-between px-3 py-1.5 text-left text-sm ${
|
|
isQueued
|
|
? "bg-accent/30 text-foreground"
|
|
: i === suggestionIndex
|
|
? "bg-accent/20 text-foreground"
|
|
: "text-foreground hover:bg-hover-neutral-bg"
|
|
}`}
|
|
onMouseDown={(e) => e.preventDefault()}
|
|
onClick={() => handleClickSuggestion(result)}
|
|
onMouseEnter={() => setSuggestionIndex(i)}
|
|
>
|
|
<span>{result.name}</span>
|
|
<span className="flex items-center gap-1 text-xs text-muted-foreground">
|
|
{isQueued ? (
|
|
<>
|
|
<button
|
|
type="button"
|
|
className="rounded p-0.5 text-foreground hover:bg-accent/40"
|
|
onMouseDown={(e) => e.preventDefault()}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
if (queued.count <= 1) {
|
|
setQueued(null);
|
|
} else {
|
|
setQueued({
|
|
...queued,
|
|
count: queued.count - 1,
|
|
});
|
|
}
|
|
}}
|
|
>
|
|
<Minus className="h-3 w-3" />
|
|
</button>
|
|
<span className="min-w-5 rounded-full bg-accent px-1.5 py-0.5 text-center font-medium text-foreground">
|
|
{queued.count}
|
|
</span>
|
|
<button
|
|
type="button"
|
|
className="rounded p-0.5 text-foreground hover:bg-accent/40"
|
|
onMouseDown={(e) => e.preventDefault()}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
setQueued({
|
|
...queued,
|
|
count: queued.count + 1,
|
|
});
|
|
}}
|
|
>
|
|
<Plus className="h-3 w-3" />
|
|
</button>
|
|
<button
|
|
type="button"
|
|
className="ml-0.5 rounded p-0.5 text-foreground hover:bg-accent/40"
|
|
onMouseDown={(e) => e.preventDefault()}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
confirmQueued();
|
|
}}
|
|
>
|
|
<Check className="h-3.5 w-3.5" />
|
|
</button>
|
|
</>
|
|
) : (
|
|
result.sourceDisplayName
|
|
)}
|
|
</span>
|
|
</button>
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
{nameInput.length >= 2 && !hasSuggestions && (
|
|
<div className="flex items-center gap-2">
|
|
<Input
|
|
type="text"
|
|
inputMode="numeric"
|
|
value={customInit}
|
|
onChange={(e) => setCustomInit(e.target.value)}
|
|
placeholder="Init"
|
|
className="w-16 text-center"
|
|
/>
|
|
<Input
|
|
type="text"
|
|
inputMode="numeric"
|
|
value={customAc}
|
|
onChange={(e) => setCustomAc(e.target.value)}
|
|
placeholder="AC"
|
|
className="w-16 text-center"
|
|
/>
|
|
<Input
|
|
type="text"
|
|
inputMode="numeric"
|
|
value={customMaxHp}
|
|
onChange={(e) => setCustomMaxHp(e.target.value)}
|
|
placeholder="MaxHP"
|
|
className="w-18 text-center"
|
|
/>
|
|
</div>
|
|
)}
|
|
<Button type="submit" size="sm">
|
|
Add
|
|
</Button>
|
|
<div className="flex items-center gap-0">
|
|
{onManagePlayers && (
|
|
<Button
|
|
type="button"
|
|
size="icon"
|
|
variant="ghost"
|
|
className="text-muted-foreground hover:text-hover-neutral"
|
|
onClick={onManagePlayers}
|
|
title="Player characters"
|
|
aria-label="Player characters"
|
|
>
|
|
<Users className="h-5 w-5" />
|
|
</Button>
|
|
)}
|
|
{bestiaryLoaded && onViewStatBlock && (
|
|
<div ref={viewerRef} className="relative">
|
|
<Button
|
|
type="button"
|
|
size="icon"
|
|
variant="ghost"
|
|
className="text-muted-foreground hover:text-hover-neutral"
|
|
onClick={() => (viewerOpen ? closeViewer() : openViewer())}
|
|
title="Browse stat blocks"
|
|
aria-label="Browse stat blocks"
|
|
>
|
|
<Eye className="h-5 w-5" />
|
|
</Button>
|
|
{viewerOpen && (
|
|
<div className="absolute bottom-full right-0 z-50 mb-1 w-64 rounded-md border border-border bg-card shadow-lg">
|
|
<div className="p-2">
|
|
<Input
|
|
ref={viewerInputRef}
|
|
type="text"
|
|
value={viewerQuery}
|
|
onChange={(e) => handleViewerQueryChange(e.target.value)}
|
|
onKeyDown={handleViewerKeyDown}
|
|
placeholder="Search stat blocks..."
|
|
className="w-full"
|
|
/>
|
|
</div>
|
|
{viewerResults.length > 0 && (
|
|
<ul className="max-h-48 overflow-y-auto border-t border-border py-1">
|
|
{viewerResults.map((result, i) => (
|
|
<li key={creatureKey(result)}>
|
|
<button
|
|
type="button"
|
|
className={`flex w-full items-center justify-between px-3 py-1.5 text-left text-sm ${
|
|
i === viewerIndex
|
|
? "bg-accent/20 text-foreground"
|
|
: "text-foreground hover:bg-hover-neutral-bg"
|
|
}`}
|
|
onClick={() => handleViewerSelect(result)}
|
|
onMouseEnter={() => setViewerIndex(i)}
|
|
>
|
|
<span>{result.name}</span>
|
|
<span className="text-xs text-muted-foreground">
|
|
{result.sourceDisplayName}
|
|
</span>
|
|
</button>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
{viewerQuery.length >= 2 && viewerResults.length === 0 && (
|
|
<div className="border-t border-border px-3 py-2 text-sm text-muted-foreground">
|
|
No creatures found
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
{bestiaryLoaded && onBulkImport && (
|
|
<Button
|
|
type="button"
|
|
size="icon"
|
|
variant="ghost"
|
|
className="text-muted-foreground hover:text-hover-neutral"
|
|
onClick={onBulkImport}
|
|
disabled={bulkImportDisabled}
|
|
title="Bulk import"
|
|
aria-label="Bulk import"
|
|
>
|
|
<Import className="h-5 w-5" />
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</form>
|
|
</div>
|
|
);
|
|
}
|