Implement the 017-combat-conditions feature that adds D&D 5e status conditions to combatants with icon tags, color coding, and a compact toggle picker in the encounter tracker

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Lukas
2026-03-06 11:29:39 +01:00
parent 78c6591973
commit febe892e15
22 changed files with 1301 additions and 62 deletions

View File

@@ -0,0 +1,124 @@
import { CONDITION_DEFINITIONS, type ConditionId } from "@initiative/domain";
import type { LucideIcon } from "lucide-react";
import {
ArrowDown,
Ban,
BatteryLow,
Droplet,
EarOff,
EyeOff,
Gem,
Ghost,
Hand,
Heart,
Link,
Moon,
Siren,
Sparkles,
ZapOff,
} from "lucide-react";
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { cn } from "../lib/utils";
const ICON_MAP: Record<string, LucideIcon> = {
EyeOff,
Heart,
EarOff,
BatteryLow,
Siren,
Hand,
Ban,
Ghost,
ZapOff,
Gem,
Droplet,
ArrowDown,
Link,
Sparkles,
Moon,
};
const COLOR_CLASSES: Record<string, string> = {
neutral: "text-muted-foreground",
pink: "text-pink-400",
amber: "text-amber-400",
orange: "text-orange-400",
gray: "text-gray-400",
violet: "text-violet-400",
yellow: "text-yellow-400",
slate: "text-slate-400",
green: "text-green-400",
indigo: "text-indigo-400",
};
interface ConditionPickerProps {
activeConditions: readonly ConditionId[] | undefined;
onToggle: (conditionId: ConditionId) => void;
onClose: () => void;
}
export function ConditionPicker({
activeConditions,
onToggle,
onClose,
}: ConditionPickerProps) {
const ref = useRef<HTMLDivElement>(null);
const [flipped, setFlipped] = useState(false);
useLayoutEffect(() => {
const el = ref.current;
if (!el) return;
const rect = el.getBoundingClientRect();
setFlipped(rect.bottom > window.innerHeight);
}, []);
useEffect(() => {
function handleClickOutside(e: MouseEvent) {
if (ref.current && !ref.current.contains(e.target as Node)) {
onClose();
}
}
document.addEventListener("mousedown", handleClickOutside);
return () => document.removeEventListener("mousedown", handleClickOutside);
}, [onClose]);
const active = new Set(activeConditions ?? []);
return (
<div
ref={ref}
className={cn(
"absolute z-10 w-fit rounded-md border border-border bg-background p-1 shadow-lg",
flipped ? "bottom-full mb-1" : "mt-1",
)}
>
{CONDITION_DEFINITIONS.map((def) => {
const Icon = ICON_MAP[def.iconName];
if (!Icon) return null;
const isActive = active.has(def.id);
const colorClass = COLOR_CLASSES[def.color] ?? "text-muted-foreground";
return (
<button
key={def.id}
type="button"
className={cn(
"flex w-full items-center gap-2 rounded px-2 py-1 text-sm transition-colors hover:bg-card",
isActive && "bg-card/50",
)}
onClick={() => onToggle(def.id)}
>
<Icon
size={14}
className={isActive ? colorClass : "text-muted-foreground"}
/>
<span
className={isActive ? "text-foreground" : "text-muted-foreground"}
>
{def.label}
</span>
</button>
);
})}
</div>
);
}