Add player character management feature
All checks were successful
CI / check (push) Successful in 45s
CI / build-image (push) Successful in 18s

Persistent player character templates (name, AC, HP, color, icon) with
full CRUD, bestiary-style search to add PCs to encounters with pre-filled
stats, and color/icon visual distinction in combatant rows. Also stops
the stat block panel from auto-opening when adding a creature.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Lukas
2026-03-12 18:11:08 +01:00
parent 768e7a390f
commit 91703ddebc
38 changed files with 3055 additions and 96 deletions

View File

@@ -1,4 +1,5 @@
import { Check, Eye, Import, Minus, Plus } from "lucide-react";
import type { PlayerCharacter, PlayerIcon } from "@initiative/domain";
import { Check, Eye, Import, Minus, Plus, Users } from "lucide-react";
import {
type FormEvent,
type RefObject,
@@ -7,6 +8,7 @@ import {
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";
@@ -27,6 +29,9 @@ interface ActionBarProps {
onBulkImport?: () => void;
bulkImportDisabled?: boolean;
inputRef?: RefObject<HTMLInputElement | null>;
playerCharacters?: readonly PlayerCharacter[];
onAddFromPlayerCharacter?: (pc: PlayerCharacter) => void;
onManagePlayers?: () => void;
}
function creatureKey(r: SearchResult): string {
@@ -42,9 +47,13 @@ export function ActionBar({
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("");
@@ -73,6 +82,7 @@ export function ActionBar({
setQueued(null);
setNameInput("");
setSuggestions([]);
setPcMatches([]);
setSuggestionIndex(-1);
};
@@ -99,6 +109,7 @@ export function ActionBar({
onAddCombatant(nameInput, Object.keys(opts).length > 0 ? opts : undefined);
setNameInput("");
setSuggestions([]);
setPcMatches([]);
clearCustomFields();
};
@@ -106,13 +117,22 @@ export function ActionBar({
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) {
if (newSuggestions.length > 0 || newPcMatches.length > 0) {
clearCustomFields();
}
if (queued) {
@@ -141,8 +161,10 @@ export function ActionBar({
}
};
const hasSuggestions = suggestions.length > 0 || pcMatches.length > 0;
const handleKeyDown = (e: React.KeyboardEvent) => {
if (suggestions.length === 0) return;
if (!hasSuggestions) return;
if (e.key === "ArrowDown") {
e.preventDefault();
@@ -157,6 +179,7 @@ export function ActionBar({
setQueued(null);
setSuggestionIndex(-1);
setSuggestions([]);
setPcMatches([]);
}
};
@@ -238,7 +261,7 @@ export function ActionBar({
placeholder="+ Add combatants"
className="max-w-xs"
/>
{suggestions.length > 0 && (
{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"
@@ -246,6 +269,7 @@ export function ActionBar({
onMouseDown={(e) => e.preventDefault()}
onClick={() => {
setSuggestions([]);
setPcMatches([]);
setQueued(null);
setSuggestionIndex(-1);
}}
@@ -256,90 +280,133 @@ export function ActionBar({
Esc
</kbd>
</button>
<ul className="max-h-48 overflow-y-auto py-1">
{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}
<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
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>
</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 && suggestions.length === 0 && (
{nameInput.length >= 2 && !hasSuggestions && (
<div className="flex items-center gap-2">
<Input
type="text"
@@ -370,6 +437,17 @@ export function ActionBar({
<Button type="submit" size="sm">
Add
</Button>
{onManagePlayers && (
<Button
type="button"
size="sm"
variant="ghost"
onClick={onManagePlayers}
title="Player characters"
>
<Users className="h-4 w-4" />
</Button>
)}
{bestiaryLoaded && onViewStatBlock && (
<div ref={viewerRef} className="relative">
<Button