Weak/Normal/Elite toggle in PF2e stat block header applies standard adjustments (level, AC, HP, saves, Perception, attacks, damage) to individual combatants. Adjusted stats are highlighted blue (elite) or red (weak). Persisted via creatureAdjustment field on Combatant. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
665 lines
18 KiB
TypeScript
665 lines
18 KiB
TypeScript
import {
|
|
type CombatantId,
|
|
type ConditionEntry,
|
|
type CreatureId,
|
|
deriveHpStatus,
|
|
type PlayerIcon,
|
|
type RollMode,
|
|
} from "@initiative/domain";
|
|
import { Brain, Pencil, X } from "lucide-react";
|
|
import { type Ref, useCallback, useEffect, useRef, useState } from "react";
|
|
import { useEncounterContext } from "../contexts/encounter-context.js";
|
|
import { useInitiativeRollsContext } from "../contexts/initiative-rolls-context.js";
|
|
import { useRulesEditionContext } from "../contexts/rules-edition-context.js";
|
|
import { useSidePanelContext } from "../contexts/side-panel-context.js";
|
|
import { useLongPress } from "../hooks/use-long-press.js";
|
|
import { cn } from "../lib/utils.js";
|
|
import { AcShield } from "./ac-shield.js";
|
|
import { ConditionPicker } from "./condition-picker.js";
|
|
import { ConditionTags } from "./condition-tags.js";
|
|
import { D20Icon } from "./d20-icon.js";
|
|
import { HpAdjustPopover } from "./hp-adjust-popover.js";
|
|
import { PLAYER_COLOR_HEX, PLAYER_ICON_MAP } from "./player-icon-map.js";
|
|
import { RollModeMenu } from "./roll-mode-menu.js";
|
|
import { ConfirmButton } from "./ui/confirm-button.js";
|
|
import { Input } from "./ui/input.js";
|
|
|
|
interface Combatant {
|
|
readonly id: CombatantId;
|
|
readonly name: string;
|
|
readonly initiative?: number;
|
|
readonly maxHp?: number;
|
|
readonly currentHp?: number;
|
|
readonly tempHp?: number;
|
|
readonly ac?: number;
|
|
readonly conditions?: readonly ConditionEntry[];
|
|
readonly isConcentrating?: boolean;
|
|
readonly color?: string;
|
|
readonly icon?: string;
|
|
readonly creatureId?: CreatureId;
|
|
}
|
|
|
|
interface CombatantRowProps {
|
|
combatant: Combatant;
|
|
isActive: boolean;
|
|
}
|
|
|
|
function EditableName({
|
|
name,
|
|
combatantId,
|
|
onRename,
|
|
color,
|
|
onToggleStatBlock,
|
|
}: Readonly<{
|
|
name: string;
|
|
combatantId: CombatantId;
|
|
onRename: (id: CombatantId, newName: string) => void;
|
|
color?: string;
|
|
onToggleStatBlock?: () => void;
|
|
}>) {
|
|
const [editing, setEditing] = useState(false);
|
|
const [draft, setDraft] = useState(name);
|
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
|
|
const commit = useCallback(() => {
|
|
const trimmed = draft.trim();
|
|
if (trimmed !== "" && trimmed !== name) {
|
|
onRename(combatantId, trimmed);
|
|
}
|
|
setEditing(false);
|
|
}, [draft, name, combatantId, onRename]);
|
|
|
|
const startEditing = useCallback(() => {
|
|
setDraft(name);
|
|
setEditing(true);
|
|
requestAnimationFrame(() => inputRef.current?.select());
|
|
}, [name]);
|
|
|
|
if (editing) {
|
|
return (
|
|
<Input
|
|
ref={inputRef}
|
|
type="text"
|
|
value={draft}
|
|
className="h-7 max-w-48 text-sm"
|
|
onChange={(e) => setDraft(e.target.value)}
|
|
onBlur={commit}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter") commit();
|
|
if (e.key === "Escape") setEditing(false);
|
|
}}
|
|
/>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<button
|
|
type="button"
|
|
onClick={onToggleStatBlock}
|
|
disabled={!onToggleStatBlock}
|
|
className={cn(
|
|
"truncate text-left text-sm transition-colors",
|
|
onToggleStatBlock
|
|
? "cursor-pointer text-foreground hover:text-hover-neutral"
|
|
: "cursor-default text-foreground",
|
|
)}
|
|
style={color ? { color } : undefined}
|
|
>
|
|
{name}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={startEditing}
|
|
title="Rename"
|
|
aria-label="Rename"
|
|
className="inline-flex pointer-coarse:w-auto w-0 shrink-0 items-center overflow-hidden pointer-coarse:overflow-visible rounded p-0.5 text-muted-foreground opacity-0 pointer-coarse:opacity-100 transition-all duration-150 hover:bg-hover-neutral-bg hover:text-hover-neutral focus:w-auto focus:overflow-visible focus:opacity-100 group-hover:w-auto group-hover:overflow-visible group-hover:opacity-100"
|
|
>
|
|
<Pencil size={14} />
|
|
</button>
|
|
</>
|
|
);
|
|
}
|
|
|
|
function MaxHpDisplay({
|
|
maxHp,
|
|
onCommit,
|
|
}: Readonly<{
|
|
maxHp: number | undefined;
|
|
onCommit: (value: number | undefined) => void;
|
|
}>) {
|
|
const [editing, setEditing] = useState(false);
|
|
const [draft, setDraft] = useState(maxHp?.toString() ?? "");
|
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
|
|
const commit = useCallback(() => {
|
|
if (draft === "") {
|
|
onCommit(undefined);
|
|
} else {
|
|
const n = Number.parseInt(draft, 10);
|
|
if (!Number.isNaN(n) && n >= 1) {
|
|
onCommit(n);
|
|
}
|
|
}
|
|
setEditing(false);
|
|
}, [draft, onCommit]);
|
|
|
|
const startEditing = useCallback(() => {
|
|
setDraft(maxHp?.toString() ?? "");
|
|
setEditing(true);
|
|
requestAnimationFrame(() => inputRef.current?.select());
|
|
}, [maxHp]);
|
|
|
|
if (editing) {
|
|
return (
|
|
<Input
|
|
ref={inputRef}
|
|
type="text"
|
|
inputMode="numeric"
|
|
value={draft}
|
|
placeholder="Max"
|
|
className="h-7 w-[7ch] text-center tabular-nums"
|
|
onChange={(e) => setDraft(e.target.value)}
|
|
onBlur={commit}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter") commit();
|
|
if (e.key === "Escape") setEditing(false);
|
|
}}
|
|
/>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<button
|
|
type="button"
|
|
onClick={startEditing}
|
|
className={cn(
|
|
"inline-block h-7 min-w-[3ch] text-center leading-7 transition-colors hover:text-hover-neutral",
|
|
maxHp === undefined
|
|
? "text-muted-foreground text-sm"
|
|
: "text-muted-foreground text-xs",
|
|
)}
|
|
>
|
|
{maxHp ?? "Max"}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
function ClickableHp({
|
|
currentHp,
|
|
maxHp,
|
|
tempHp,
|
|
onAdjust,
|
|
onSetTempHp,
|
|
}: Readonly<{
|
|
currentHp: number | undefined;
|
|
maxHp: number | undefined;
|
|
tempHp: number | undefined;
|
|
onAdjust: (delta: number) => void;
|
|
onSetTempHp: (value: number) => void;
|
|
}>) {
|
|
const [popoverOpen, setPopoverOpen] = useState(false);
|
|
const status = deriveHpStatus(currentHp, maxHp);
|
|
|
|
if (maxHp === undefined) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<div className="relative flex items-center">
|
|
<button
|
|
type="button"
|
|
onClick={() => setPopoverOpen(true)}
|
|
aria-label={`Current HP: ${currentHp}${tempHp ? ` (+${tempHp} temp)` : ""} (${status})`}
|
|
className={cn(
|
|
"inline-block h-7 min-w-[3ch] text-center font-medium text-sm leading-7 transition-colors hover:text-hover-neutral",
|
|
status === "bloodied" && "text-amber-400",
|
|
status === "unconscious" && "text-red-400",
|
|
status === "healthy" && "text-foreground",
|
|
)}
|
|
>
|
|
{currentHp}
|
|
</button>
|
|
{!!tempHp && (
|
|
<span className="font-medium text-cyan-400 text-sm leading-7">
|
|
+{tempHp}
|
|
</span>
|
|
)}
|
|
{!!popoverOpen && (
|
|
<HpAdjustPopover
|
|
onAdjust={onAdjust}
|
|
onSetTempHp={onSetTempHp}
|
|
onClose={() => setPopoverOpen(false)}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function AcDisplay({
|
|
ac,
|
|
onCommit,
|
|
}: Readonly<{
|
|
ac: number | undefined;
|
|
onCommit: (value: number | undefined) => void;
|
|
}>) {
|
|
const [editing, setEditing] = useState(false);
|
|
const [draft, setDraft] = useState(ac?.toString() ?? "");
|
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
|
|
const commit = useCallback(() => {
|
|
if (draft === "") {
|
|
onCommit(undefined);
|
|
} else {
|
|
const n = Number.parseInt(draft, 10);
|
|
if (!Number.isNaN(n) && n >= 0) {
|
|
onCommit(n);
|
|
}
|
|
}
|
|
setEditing(false);
|
|
}, [draft, onCommit]);
|
|
|
|
const startEditing = useCallback(() => {
|
|
setDraft(ac?.toString() ?? "");
|
|
setEditing(true);
|
|
requestAnimationFrame(() => inputRef.current?.select());
|
|
}, [ac]);
|
|
|
|
if (editing) {
|
|
return (
|
|
<Input
|
|
ref={inputRef}
|
|
type="text"
|
|
inputMode="numeric"
|
|
value={draft}
|
|
placeholder="AC"
|
|
className="h-7 w-[6ch] text-center tabular-nums"
|
|
onChange={(e) => setDraft(e.target.value)}
|
|
onBlur={commit}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter") commit();
|
|
if (e.key === "Escape") setEditing(false);
|
|
}}
|
|
/>
|
|
);
|
|
}
|
|
|
|
return <AcShield value={ac} onClick={startEditing} />;
|
|
}
|
|
|
|
function InitiativeDisplay({
|
|
initiative,
|
|
combatantId,
|
|
dimmed,
|
|
onSetInitiative,
|
|
onRollInitiative,
|
|
}: Readonly<{
|
|
initiative: number | undefined;
|
|
combatantId: CombatantId;
|
|
dimmed: boolean;
|
|
onSetInitiative: (id: CombatantId, value: number | undefined) => void;
|
|
onRollInitiative?: (id: CombatantId, mode?: RollMode) => void;
|
|
}>) {
|
|
const [editing, setEditing] = useState(false);
|
|
const [draft, setDraft] = useState(initiative?.toString() ?? "");
|
|
const inputRef = useRef<HTMLInputElement>(null);
|
|
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],
|
|
),
|
|
);
|
|
|
|
const commit = useCallback(() => {
|
|
if (draft === "") {
|
|
onSetInitiative(combatantId, undefined);
|
|
} else {
|
|
const n = Number.parseInt(draft, 10);
|
|
if (!Number.isNaN(n)) {
|
|
onSetInitiative(combatantId, n);
|
|
}
|
|
}
|
|
setEditing(false);
|
|
}, [draft, combatantId, onSetInitiative]);
|
|
|
|
const startEditing = useCallback(() => {
|
|
setDraft(initiative?.toString() ?? "");
|
|
setEditing(true);
|
|
requestAnimationFrame(() => inputRef.current?.select());
|
|
}, [initiative]);
|
|
|
|
if (editing) {
|
|
return (
|
|
<Input
|
|
ref={inputRef}
|
|
type="text"
|
|
inputMode="numeric"
|
|
value={draft}
|
|
placeholder="--"
|
|
className={cn(
|
|
"h-7 w-full text-center tabular-nums",
|
|
dimmed && "opacity-50",
|
|
)}
|
|
onChange={(e) => setDraft(e.target.value)}
|
|
onBlur={commit}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter") commit();
|
|
if (e.key === "Escape") setEditing(false);
|
|
}}
|
|
/>
|
|
);
|
|
}
|
|
|
|
// Empty + bestiary creature -> d20 roll button
|
|
if (initiative === undefined && onRollInitiative) {
|
|
return (
|
|
<>
|
|
<button
|
|
type="button"
|
|
onClick={() => onRollInitiative(combatantId)}
|
|
onContextMenu={(e) => {
|
|
e.preventDefault();
|
|
openMenu(e.clientX, e.clientY);
|
|
}}
|
|
{...longPress}
|
|
className={cn(
|
|
"flex h-7 w-full items-center justify-center text-muted-foreground transition-colors hover:text-hover-neutral",
|
|
dimmed && "opacity-50",
|
|
)}
|
|
title="Roll initiative"
|
|
aria-label="Roll initiative"
|
|
>
|
|
<D20Icon className="h-7 w-7" />
|
|
</button>
|
|
{!!menuPos && (
|
|
<RollModeMenu
|
|
position={menuPos}
|
|
onSelect={(mode) => onRollInitiative(combatantId, mode)}
|
|
onClose={() => setMenuPos(null)}
|
|
/>
|
|
)}
|
|
</>
|
|
);
|
|
}
|
|
|
|
// Has value -> bold number, click to edit
|
|
// Empty + manual -> "--" placeholder, click to edit
|
|
return (
|
|
<button
|
|
type="button"
|
|
onClick={startEditing}
|
|
className={cn(
|
|
"h-7 w-full text-center text-sm tabular-nums leading-7 transition-colors",
|
|
initiative === undefined
|
|
? "text-muted-foreground hover:text-hover-neutral"
|
|
: "font-medium text-foreground hover:text-hover-neutral",
|
|
dimmed && "opacity-50",
|
|
)}
|
|
>
|
|
{initiative ?? "--"}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
function rowBorderClass(
|
|
isActive: boolean,
|
|
isConcentrating: boolean | undefined,
|
|
isPf2e: boolean,
|
|
): string {
|
|
const showConcentration = isConcentrating && !isPf2e;
|
|
if (isActive && showConcentration)
|
|
return "border border-l-2 border-active-row-border border-l-purple-400 bg-active-row-bg card-glow";
|
|
if (isActive)
|
|
return "border border-l-2 border-active-row-border bg-active-row-bg card-glow";
|
|
if (showConcentration)
|
|
return "border border-l-2 border-transparent border-l-purple-400";
|
|
return "border border-l-2 border-transparent";
|
|
}
|
|
|
|
function concentrationIconClass(
|
|
isConcentrating: boolean | undefined,
|
|
dimmed: boolean,
|
|
): string {
|
|
if (!isConcentrating)
|
|
return "opacity-0 pointer-coarse:opacity-50 group-hover:opacity-50 text-muted-foreground";
|
|
return dimmed ? "opacity-50 text-purple-400" : "opacity-100 text-purple-400";
|
|
}
|
|
|
|
export function CombatantRow({
|
|
ref,
|
|
combatant,
|
|
isActive,
|
|
}: CombatantRowProps & { ref?: Ref<HTMLDivElement> }) {
|
|
const {
|
|
editCombatant,
|
|
setInitiative,
|
|
removeCombatant,
|
|
setHp,
|
|
adjustHp,
|
|
setTempHp,
|
|
setAc,
|
|
toggleCondition,
|
|
setConditionValue,
|
|
decrementCondition,
|
|
toggleConcentration,
|
|
} = useEncounterContext();
|
|
const {
|
|
selectedCreatureId,
|
|
selectedCombatantId,
|
|
showCreature,
|
|
toggleCollapse,
|
|
} = useSidePanelContext();
|
|
const { handleRollInitiative } = useInitiativeRollsContext();
|
|
const { edition } = useRulesEditionContext();
|
|
const isPf2e = edition === "pf2e";
|
|
|
|
// Derive what was previously conditional props
|
|
const isStatBlockOpen =
|
|
combatant.creatureId === selectedCreatureId &&
|
|
combatant.id === selectedCombatantId;
|
|
const { creatureId } = combatant;
|
|
const hasStatBlock = !!creatureId;
|
|
const onToggleStatBlock = hasStatBlock
|
|
? () => {
|
|
if (isStatBlockOpen) {
|
|
toggleCollapse();
|
|
} else {
|
|
showCreature(creatureId, combatant.id);
|
|
}
|
|
}
|
|
: undefined;
|
|
const onRollInitiative = combatant.creatureId
|
|
? handleRollInitiative
|
|
: undefined;
|
|
|
|
const { id, name, initiative, maxHp, currentHp } = combatant;
|
|
const status = deriveHpStatus(currentHp, maxHp);
|
|
const dimmed = status === "unconscious";
|
|
const [pickerOpen, setPickerOpen] = useState(false);
|
|
const conditionAnchorRef = useRef<HTMLDivElement>(null);
|
|
|
|
const prevHpRef = useRef(currentHp);
|
|
const prevTempHpRef = useRef(combatant.tempHp);
|
|
const [isPulsing, setIsPulsing] = useState(false);
|
|
const pulseTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
|
|
|
|
useEffect(() => {
|
|
const prevHp = prevHpRef.current;
|
|
const prevTempHp = prevTempHpRef.current;
|
|
prevHpRef.current = currentHp;
|
|
prevTempHpRef.current = combatant.tempHp;
|
|
|
|
const realHpDropped =
|
|
prevHp !== undefined && currentHp !== undefined && currentHp < prevHp;
|
|
const tempHpDropped =
|
|
prevTempHp !== undefined && (combatant.tempHp ?? 0) < prevTempHp;
|
|
|
|
if (
|
|
(realHpDropped || tempHpDropped) &&
|
|
combatant.isConcentrating &&
|
|
!isPf2e
|
|
) {
|
|
setIsPulsing(true);
|
|
clearTimeout(pulseTimerRef.current);
|
|
pulseTimerRef.current = setTimeout(() => setIsPulsing(false), 1200);
|
|
}
|
|
}, [currentHp, combatant.tempHp, combatant.isConcentrating, isPf2e]);
|
|
|
|
useEffect(() => {
|
|
if (!combatant.isConcentrating) {
|
|
clearTimeout(pulseTimerRef.current);
|
|
setIsPulsing(false);
|
|
}
|
|
}, [combatant.isConcentrating]);
|
|
|
|
const pcColor = combatant.color
|
|
? PLAYER_COLOR_HEX[combatant.color as keyof typeof PLAYER_COLOR_HEX]
|
|
: undefined;
|
|
|
|
return (
|
|
<div
|
|
ref={ref}
|
|
className={cn(
|
|
"group rounded-lg pr-3 transition-colors",
|
|
rowBorderClass(isActive, combatant.isConcentrating, isPf2e),
|
|
isPulsing && "animate-concentration-pulse",
|
|
)}
|
|
>
|
|
<div
|
|
className={cn(
|
|
"grid items-center gap-1.5 py-3 sm:gap-3 sm:py-2",
|
|
isPf2e
|
|
? "grid-cols-[3rem_auto_1fr_auto_2rem] pl-3 sm:grid-cols-[3.5rem_auto_1fr_auto_2rem]"
|
|
: "grid-cols-[2rem_3rem_auto_1fr_auto_2rem] sm:grid-cols-[2rem_3.5rem_auto_1fr_auto_2rem]",
|
|
)}
|
|
>
|
|
{/* Concentration — hidden in PF2e mode */}
|
|
{!isPf2e && (
|
|
<button
|
|
type="button"
|
|
onClick={() => toggleConcentration(id)}
|
|
title="Concentrating"
|
|
aria-label="Toggle concentration"
|
|
className={cn(
|
|
"-my-2 -ml-[2px] flex w-full items-center justify-center self-stretch pl-[14px] transition-opacity hover:text-hover-neutral hover:opacity-100",
|
|
concentrationIconClass(combatant.isConcentrating, dimmed),
|
|
)}
|
|
>
|
|
<Brain size={16} />
|
|
</button>
|
|
)}
|
|
|
|
{/* Initiative */}
|
|
<div className="rounded-md bg-muted/30 px-1">
|
|
<InitiativeDisplay
|
|
initiative={initiative}
|
|
combatantId={id}
|
|
dimmed={dimmed}
|
|
onSetInitiative={setInitiative}
|
|
onRollInitiative={onRollInitiative}
|
|
/>
|
|
</div>
|
|
|
|
{/* AC */}
|
|
<div className={cn(dimmed && "opacity-50")}>
|
|
<AcDisplay ac={combatant.ac} onCommit={(v) => setAc(id, v)} />
|
|
</div>
|
|
|
|
{/* Name + Conditions */}
|
|
<div
|
|
className={cn(
|
|
"relative flex min-w-0 flex-wrap items-center gap-1",
|
|
dimmed && "opacity-50",
|
|
)}
|
|
>
|
|
{!!combatant.icon &&
|
|
!!combatant.color &&
|
|
(() => {
|
|
const PcIcon = PLAYER_ICON_MAP[combatant.icon as PlayerIcon];
|
|
const iconColor =
|
|
PLAYER_COLOR_HEX[
|
|
combatant.color as keyof typeof PLAYER_COLOR_HEX
|
|
];
|
|
return PcIcon ? (
|
|
<PcIcon
|
|
size={16}
|
|
style={{ color: iconColor }}
|
|
className="shrink-0"
|
|
/>
|
|
) : null;
|
|
})()}
|
|
<EditableName
|
|
name={name}
|
|
combatantId={id}
|
|
onRename={editCombatant}
|
|
color={pcColor}
|
|
onToggleStatBlock={onToggleStatBlock}
|
|
/>
|
|
<div ref={conditionAnchorRef}>
|
|
<ConditionTags
|
|
conditions={combatant.conditions}
|
|
onRemove={(conditionId) => toggleCondition(id, conditionId)}
|
|
onDecrement={(conditionId) => decrementCondition(id, conditionId)}
|
|
onOpenPicker={() => setPickerOpen((prev) => !prev)}
|
|
/>
|
|
</div>
|
|
{!!pickerOpen && (
|
|
<ConditionPicker
|
|
anchorRef={conditionAnchorRef}
|
|
activeConditions={combatant.conditions}
|
|
onToggle={(conditionId) => toggleCondition(id, conditionId)}
|
|
onSetValue={(conditionId, value) =>
|
|
setConditionValue(id, conditionId, value)
|
|
}
|
|
onClose={() => setPickerOpen(false)}
|
|
/>
|
|
)}
|
|
</div>
|
|
|
|
{/* HP */}
|
|
<div
|
|
className={cn(
|
|
"flex items-center rounded-md tabular-nums",
|
|
maxHp === undefined
|
|
? ""
|
|
: "gap-0.5 border border-border/50 bg-muted/30 px-1.5",
|
|
dimmed && "opacity-50",
|
|
)}
|
|
>
|
|
<ClickableHp
|
|
currentHp={currentHp}
|
|
maxHp={maxHp}
|
|
tempHp={combatant.tempHp}
|
|
onAdjust={(delta) => adjustHp(id, delta)}
|
|
onSetTempHp={(value) => setTempHp(id, value)}
|
|
/>
|
|
{maxHp !== undefined && (
|
|
<span className="text-muted-foreground/50 text-xs">/</span>
|
|
)}
|
|
<MaxHpDisplay maxHp={maxHp} onCommit={(v) => setHp(id, v)} />
|
|
</div>
|
|
|
|
{/* Actions */}
|
|
<ConfirmButton
|
|
icon={<X size={16} />}
|
|
label="Remove combatant"
|
|
onConfirm={() => removeCombatant(id)}
|
|
className="pointer-events-none pointer-coarse:pointer-events-auto h-7 w-7 text-muted-foreground opacity-0 pointer-coarse:opacity-100 transition-opacity focus:pointer-events-auto focus:opacity-100 group-hover:pointer-events-auto group-hover:opacity-100"
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|