Extract useActionBarState hook with all search/queue/mode state and handlers. Extract RollAllButton (context-consuming, zero props), BrowseSuggestions, CustomStatFields, and refactor AddModeSuggestions to use grouped SuggestionActions interface (11 props → 6). ActionBar is now a ~120-line layout shell. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
507 lines
14 KiB
TypeScript
507 lines
14 KiB
TypeScript
import type { PlayerCharacter } from "@initiative/domain";
|
|
import {
|
|
Check,
|
|
Eye,
|
|
EyeOff,
|
|
Import,
|
|
Library,
|
|
Minus,
|
|
Plus,
|
|
Settings,
|
|
Users,
|
|
} from "lucide-react";
|
|
import React, { type RefObject, useCallback, useState } from "react";
|
|
import type { SearchResult } 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 {
|
|
creatureKey,
|
|
type QueuedCreature,
|
|
type SuggestionActions,
|
|
useActionBarState,
|
|
} from "../hooks/use-action-bar-state.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 ActionBarProps {
|
|
inputRef?: RefObject<HTMLInputElement | null>;
|
|
autoFocus?: boolean;
|
|
onManagePlayers?: () => void;
|
|
onOpenSettings?: () => void;
|
|
}
|
|
|
|
interface AddModeSuggestionsProps {
|
|
nameInput: string;
|
|
suggestions: SearchResult[];
|
|
pcMatches: PlayerCharacter[];
|
|
suggestionIndex: number;
|
|
queued: QueuedCreature | null;
|
|
actions: SuggestionActions;
|
|
}
|
|
|
|
function AddModeSuggestions({
|
|
nameInput,
|
|
suggestions,
|
|
pcMatches,
|
|
suggestionIndex,
|
|
queued,
|
|
actions,
|
|
}: Readonly<AddModeSuggestionsProps>) {
|
|
return (
|
|
<div className="card-glow absolute bottom-full z-50 mb-1 w-full max-w-xs rounded-lg border border-border bg-card">
|
|
<button
|
|
type="button"
|
|
className="flex w-full items-center gap-1.5 border-border border-b px-3 py-2 text-left text-accent text-sm hover:bg-accent/20"
|
|
onMouseDown={(e) => e.preventDefault()}
|
|
onClick={actions.dismiss}
|
|
>
|
|
<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-muted-foreground text-xs">
|
|
Esc
|
|
</kbd>
|
|
</button>
|
|
<div className="max-h-48 overflow-y-auto py-1">
|
|
{pcMatches.length > 0 && (
|
|
<>
|
|
<div className="px-3 py-1 font-medium text-muted-foreground text-xs">
|
|
Players
|
|
</div>
|
|
<ul>
|
|
{pcMatches.map((pc) => {
|
|
const PcIcon = pc.icon ? PLAYER_ICON_MAP[pc.icon] : undefined;
|
|
const pcColor = pc.color
|
|
? PLAYER_COLOR_HEX[pc.color]
|
|
: undefined;
|
|
return (
|
|
<li key={pc.id}>
|
|
<button
|
|
type="button"
|
|
className="flex w-full items-center gap-2 px-3 py-1.5 text-left text-foreground text-sm hover:bg-hover-neutral-bg"
|
|
onMouseDown={(e) => e.preventDefault()}
|
|
onClick={() => {
|
|
actions.addFromPlayerCharacter?.(pc);
|
|
actions.clear();
|
|
}}
|
|
>
|
|
{!!PcIcon && (
|
|
<PcIcon size={14} style={{ color: pcColor }} />
|
|
)}
|
|
<span className="flex-1 truncate">{pc.name}</span>
|
|
<span className="text-muted-foreground text-xs">
|
|
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={cn(
|
|
"flex w-full items-center justify-between px-3 py-1.5 text-left text-foreground text-sm",
|
|
isQueued && "bg-accent/30",
|
|
!isQueued && i === suggestionIndex && "bg-accent/20",
|
|
!isQueued &&
|
|
i !== suggestionIndex &&
|
|
"hover:bg-hover-neutral-bg",
|
|
)}
|
|
onMouseDown={(e) => e.preventDefault()}
|
|
onClick={() => actions.clickSuggestion(result)}
|
|
onMouseEnter={() => actions.setSuggestionIndex(i)}
|
|
>
|
|
<span>{result.name}</span>
|
|
<span className="flex items-center gap-1 text-muted-foreground text-xs">
|
|
{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) {
|
|
actions.setQueued(null);
|
|
} else {
|
|
actions.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-primary-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();
|
|
actions.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();
|
|
actions.confirmQueued();
|
|
}}
|
|
>
|
|
<Check className="h-3.5 w-3.5" />
|
|
</button>
|
|
</>
|
|
) : (
|
|
result.sourceDisplayName
|
|
)}
|
|
</span>
|
|
</button>
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
interface BrowseSuggestionsProps {
|
|
suggestions: SearchResult[];
|
|
suggestionIndex: number;
|
|
onSelect: (result: SearchResult) => void;
|
|
onHover: (index: number) => void;
|
|
}
|
|
|
|
function BrowseSuggestions({
|
|
suggestions,
|
|
suggestionIndex,
|
|
onSelect,
|
|
onHover,
|
|
}: Readonly<BrowseSuggestionsProps>) {
|
|
if (suggestions.length === 0) return null;
|
|
|
|
return (
|
|
<div className="card-glow absolute bottom-full z-50 mb-1 w-full rounded-lg border border-border bg-card">
|
|
<ul className="max-h-48 overflow-y-auto py-1">
|
|
{suggestions.map((result, i) => (
|
|
<li key={creatureKey(result)}>
|
|
<button
|
|
type="button"
|
|
className={cn(
|
|
"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-hover-neutral-bg",
|
|
)}
|
|
onMouseDown={(e) => e.preventDefault()}
|
|
onClick={() => onSelect(result)}
|
|
onMouseEnter={() => onHover(i)}
|
|
>
|
|
<span>{result.name}</span>
|
|
<span className="text-muted-foreground text-xs">
|
|
{result.sourceDisplayName}
|
|
</span>
|
|
</button>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
interface CustomStatFieldsProps {
|
|
customInit: string;
|
|
customAc: string;
|
|
customMaxHp: string;
|
|
onInitChange: (v: string) => void;
|
|
onAcChange: (v: string) => void;
|
|
onMaxHpChange: (v: string) => void;
|
|
}
|
|
|
|
function CustomStatFields({
|
|
customInit,
|
|
customAc,
|
|
customMaxHp,
|
|
onInitChange,
|
|
onAcChange,
|
|
onMaxHpChange,
|
|
}: Readonly<CustomStatFieldsProps>) {
|
|
return (
|
|
<div className="hidden items-center gap-2 sm:flex">
|
|
<Input
|
|
type="text"
|
|
inputMode="numeric"
|
|
value={customInit}
|
|
onChange={(e) => onInitChange(e.target.value)}
|
|
placeholder="Init"
|
|
className="w-16 text-center"
|
|
/>
|
|
<Input
|
|
type="text"
|
|
inputMode="numeric"
|
|
value={customAc}
|
|
onChange={(e) => onAcChange(e.target.value)}
|
|
placeholder="AC"
|
|
className="w-16 text-center"
|
|
/>
|
|
<Input
|
|
type="text"
|
|
inputMode="numeric"
|
|
value={customMaxHp}
|
|
onChange={(e) => onMaxHpChange(e.target.value)}
|
|
placeholder="MaxHP"
|
|
className="w-18 text-center"
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function RollAllButton() {
|
|
const { hasCreatureCombatants, canRollAllInitiative } = useEncounterContext();
|
|
const { handleRollAllInitiative } = useInitiativeRollsContext();
|
|
|
|
const [menuPos, setMenuPos] = useState<{
|
|
x: number;
|
|
y: number;
|
|
} | null>(null);
|
|
|
|
const openMenu = useCallback((x: number, y: number) => {
|
|
setMenuPos({ x, y });
|
|
}, []);
|
|
|
|
const longPress = useLongPress(
|
|
useCallback(
|
|
(e: React.TouchEvent) => {
|
|
const touch = e.touches[0];
|
|
if (touch) openMenu(touch.clientX, touch.clientY);
|
|
},
|
|
[openMenu],
|
|
),
|
|
);
|
|
|
|
if (!hasCreatureCombatants) return null;
|
|
|
|
return (
|
|
<>
|
|
<Button
|
|
type="button"
|
|
size="icon"
|
|
variant="ghost"
|
|
className="text-muted-foreground hover:text-hover-action"
|
|
onClick={() => handleRollAllInitiative()}
|
|
onContextMenu={(e) => {
|
|
e.preventDefault();
|
|
openMenu(e.clientX, e.clientY);
|
|
}}
|
|
{...longPress}
|
|
disabled={!canRollAllInitiative}
|
|
title="Roll all initiative"
|
|
aria-label="Roll all initiative"
|
|
>
|
|
<D20Icon className="h-6 w-6" />
|
|
</Button>
|
|
{!!menuPos && (
|
|
<RollModeMenu
|
|
position={menuPos}
|
|
onSelect={(mode) => handleRollAllInitiative(mode)}
|
|
onClose={() => setMenuPos(null)}
|
|
/>
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
|
|
function buildOverflowItems(opts: {
|
|
onManagePlayers?: () => void;
|
|
onOpenSourceManager?: () => void;
|
|
bestiaryLoaded: boolean;
|
|
onBulkImport?: () => void;
|
|
bulkImportDisabled?: boolean;
|
|
onOpenSettings?: () => void;
|
|
}): OverflowMenuItem[] {
|
|
const items: OverflowMenuItem[] = [];
|
|
if (opts.onManagePlayers) {
|
|
items.push({
|
|
icon: <Users className="h-4 w-4" />,
|
|
label: "Player Characters",
|
|
onClick: opts.onManagePlayers,
|
|
});
|
|
}
|
|
if (opts.onOpenSourceManager) {
|
|
items.push({
|
|
icon: <Library className="h-4 w-4" />,
|
|
label: "Manage Sources",
|
|
onClick: opts.onOpenSourceManager,
|
|
});
|
|
}
|
|
if (opts.bestiaryLoaded && opts.onBulkImport) {
|
|
items.push({
|
|
icon: <Import className="h-4 w-4" />,
|
|
label: "Import All Sources",
|
|
onClick: opts.onBulkImport,
|
|
disabled: opts.bulkImportDisabled,
|
|
});
|
|
}
|
|
if (opts.onOpenSettings) {
|
|
items.push({
|
|
icon: <Settings className="h-4 w-4" />,
|
|
label: "Settings",
|
|
onClick: opts.onOpenSettings,
|
|
});
|
|
}
|
|
return items;
|
|
}
|
|
|
|
export function ActionBar({
|
|
inputRef,
|
|
autoFocus,
|
|
onManagePlayers,
|
|
onOpenSettings,
|
|
}: Readonly<ActionBarProps>) {
|
|
const {
|
|
nameInput,
|
|
suggestions,
|
|
pcMatches,
|
|
suggestionIndex,
|
|
queued,
|
|
customInit,
|
|
customAc,
|
|
customMaxHp,
|
|
browseMode,
|
|
bestiaryLoaded,
|
|
hasSuggestions,
|
|
showBulkImport,
|
|
showSourceManager,
|
|
suggestionActions,
|
|
handleNameChange,
|
|
handleKeyDown,
|
|
handleBrowseKeyDown,
|
|
handleAdd,
|
|
handleBrowseSelect,
|
|
toggleBrowseMode,
|
|
setCustomInit,
|
|
setCustomAc,
|
|
setCustomMaxHp,
|
|
} = useActionBarState();
|
|
|
|
const { state: bulkImportState } = useBulkImportContext();
|
|
|
|
const overflowItems = buildOverflowItems({
|
|
onManagePlayers,
|
|
onOpenSourceManager: showSourceManager,
|
|
bestiaryLoaded,
|
|
onBulkImport: showBulkImport,
|
|
bulkImportDisabled: bulkImportState.status === "loading",
|
|
onOpenSettings,
|
|
});
|
|
|
|
return (
|
|
<div className="card-glow flex items-center gap-3 border-border border-t bg-card px-4 py-3 sm:rounded-lg sm:border">
|
|
<form
|
|
onSubmit={handleAdd}
|
|
className="relative flex flex-1 flex-wrap items-center gap-3 sm:flex-nowrap"
|
|
>
|
|
<div className="flex-1">
|
|
<div className="relative max-w-xs">
|
|
<Input
|
|
ref={inputRef}
|
|
type="text"
|
|
value={nameInput}
|
|
onChange={(e) => handleNameChange(e.target.value)}
|
|
onKeyDown={browseMode ? handleBrowseKeyDown : handleKeyDown}
|
|
placeholder={
|
|
browseMode ? "Search stat blocks..." : "+ Add combatants"
|
|
}
|
|
className="pr-8"
|
|
autoFocus={autoFocus}
|
|
/>
|
|
{!!bestiaryLoaded && (
|
|
<button
|
|
type="button"
|
|
tabIndex={-1}
|
|
className={cn(
|
|
"absolute top-1/2 right-2 -translate-y-1/2 text-muted-foreground hover:text-hover-neutral",
|
|
browseMode && "text-accent",
|
|
)}
|
|
onMouseDown={(e) => e.preventDefault()}
|
|
onClick={toggleBrowseMode}
|
|
title={browseMode ? "Switch to add mode" : "Browse stat blocks"}
|
|
aria-label={
|
|
browseMode ? "Switch to add mode" : "Browse stat blocks"
|
|
}
|
|
>
|
|
{browseMode ? (
|
|
<EyeOff className="h-4 w-4" />
|
|
) : (
|
|
<Eye className="h-4 w-4" />
|
|
)}
|
|
</button>
|
|
)}
|
|
{!!browseMode && (
|
|
<BrowseSuggestions
|
|
suggestions={suggestions}
|
|
suggestionIndex={suggestionIndex}
|
|
onSelect={handleBrowseSelect}
|
|
onHover={suggestionActions.setSuggestionIndex}
|
|
/>
|
|
)}
|
|
{!browseMode && hasSuggestions && (
|
|
<AddModeSuggestions
|
|
nameInput={nameInput}
|
|
suggestions={suggestions}
|
|
pcMatches={pcMatches}
|
|
suggestionIndex={suggestionIndex}
|
|
queued={queued}
|
|
actions={suggestionActions}
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
{!browseMode && nameInput.length >= 2 && !hasSuggestions && (
|
|
<CustomStatFields
|
|
customInit={customInit}
|
|
customAc={customAc}
|
|
customMaxHp={customMaxHp}
|
|
onInitChange={setCustomInit}
|
|
onAcChange={setCustomAc}
|
|
onMaxHpChange={setCustomMaxHp}
|
|
/>
|
|
)}
|
|
{!browseMode && nameInput.length >= 2 && !hasSuggestions && (
|
|
<Button type="submit">Add</Button>
|
|
)}
|
|
<RollAllButton />
|
|
{overflowItems.length > 0 && <OverflowMenu items={overflowItems} />}
|
|
</form>
|
|
</div>
|
|
);
|
|
}
|