Files
initiative/apps/web/src/components/combatant-row.tsx
Lukas 48795071f7
All checks were successful
CI / check (push) Successful in 2m26s
CI / build-image (push) Successful in 18s
Hide concentration UI in PF2e mode
PF2e uses action-based spell sustaining, not damage-triggered
concentration checks. The Brain icon, purple border accent, and
damage pulse animation are now hidden when PF2e is active, and
the freed gutter column is reclaimed for row content. Concentration
state is preserved so switching back to D&D restores it.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 00:25:54 +02:00

659 lines
17 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, showCreature, toggleCollapse } =
useSidePanelContext();
const { handleRollInitiative } = useInitiativeRollsContext();
const { edition } = useRulesEditionContext();
const isPf2e = edition === "pf2e";
// Derive what was previously conditional props
const isStatBlockOpen = combatant.creatureId === selectedCreatureId;
const { creatureId } = combatant;
const hasStatBlock = !!creatureId;
const onToggleStatBlock = hasStatBlock
? () => {
if (isStatBlockOpen) {
toggleCollapse();
} else {
showCreature(creatureId);
}
}
: 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>
);
}