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

@@ -0,0 +1,169 @@
import type { PlayerCharacter } from "@initiative/domain";
import { X } from "lucide-react";
import { type FormEvent, useEffect, useState } from "react";
import { ColorPalette } from "./color-palette";
import { IconGrid } from "./icon-grid";
import { Button } from "./ui/button";
import { Input } from "./ui/input";
interface CreatePlayerModalProps {
open: boolean;
onClose: () => void;
onSave: (
name: string,
ac: number,
maxHp: number,
color: string,
icon: string,
) => void;
playerCharacter?: PlayerCharacter;
}
export function CreatePlayerModal({
open,
onClose,
onSave,
playerCharacter,
}: CreatePlayerModalProps) {
const [name, setName] = useState("");
const [ac, setAc] = useState("10");
const [maxHp, setMaxHp] = useState("10");
const [color, setColor] = useState("blue");
const [icon, setIcon] = useState("sword");
const [error, setError] = useState("");
const isEdit = !!playerCharacter;
useEffect(() => {
if (open) {
if (playerCharacter) {
setName(playerCharacter.name);
setAc(String(playerCharacter.ac));
setMaxHp(String(playerCharacter.maxHp));
setColor(playerCharacter.color);
setIcon(playerCharacter.icon);
} else {
setName("");
setAc("10");
setMaxHp("10");
setColor("blue");
setIcon("sword");
}
setError("");
}
}, [open, playerCharacter]);
if (!open) return null;
const handleSubmit = (e: FormEvent) => {
e.preventDefault();
const trimmed = name.trim();
if (trimmed === "") {
setError("Name is required");
return;
}
const acNum = Number.parseInt(ac, 10);
if (Number.isNaN(acNum) || acNum < 0) {
setError("AC must be a non-negative number");
return;
}
const hpNum = Number.parseInt(maxHp, 10);
if (Number.isNaN(hpNum) || hpNum < 1) {
setError("Max HP must be at least 1");
return;
}
onSave(trimmed, acNum, hpNum, color, icon);
onClose();
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div className="w-full max-w-md rounded-lg border border-border bg-card p-6 shadow-xl">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-lg font-semibold text-foreground">
{isEdit ? "Edit Player" : "Create Player"}
</h2>
<button
type="button"
onClick={onClose}
className="text-muted-foreground hover:text-foreground transition-colors"
>
<X size={20} />
</button>
</div>
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
<div>
<span className="mb-1 block text-sm text-muted-foreground">
Name
</span>
<Input
type="text"
value={name}
onChange={(e) => {
setName(e.target.value);
setError("");
}}
placeholder="Character name"
aria-label="Name"
autoFocus
/>
{error && <p className="mt-1 text-sm text-destructive">{error}</p>}
</div>
<div className="flex gap-3">
<div className="flex-1">
<span className="mb-1 block text-sm text-muted-foreground">
AC
</span>
<Input
type="text"
inputMode="numeric"
value={ac}
onChange={(e) => setAc(e.target.value)}
placeholder="AC"
aria-label="AC"
className="text-center"
/>
</div>
<div className="flex-1">
<span className="mb-1 block text-sm text-muted-foreground">
Max HP
</span>
<Input
type="text"
inputMode="numeric"
value={maxHp}
onChange={(e) => setMaxHp(e.target.value)}
placeholder="Max HP"
aria-label="Max HP"
className="text-center"
/>
</div>
</div>
<div>
<span className="mb-2 block text-sm text-muted-foreground">
Color
</span>
<ColorPalette value={color} onChange={setColor} />
</div>
<div>
<span className="mb-2 block text-sm text-muted-foreground">
Icon
</span>
<IconGrid value={icon} onChange={setIcon} />
</div>
<div className="flex justify-end gap-2 pt-2">
<Button type="button" variant="ghost" onClick={onClose}>
Cancel
</Button>
<Button type="submit">{isEdit ? "Save" : "Create"}</Button>
</div>
</form>
</div>
</div>
);
}