Files
initiative/apps/web/src/components/combatant-row.tsx

435 lines
10 KiB
TypeScript

import {
type CombatantId,
type ConditionId,
deriveHpStatus,
} from "@initiative/domain";
import { Brain, Shield, X } from "lucide-react";
import { useCallback, useEffect, useRef, useState } from "react";
import { cn } from "../lib/utils";
import { ConditionPicker } from "./condition-picker";
import { ConditionTags } from "./condition-tags";
import { HpAdjustPopover } from "./hp-adjust-popover";
import { Button } from "./ui/button";
import { Input } from "./ui/input";
interface Combatant {
readonly id: CombatantId;
readonly name: string;
readonly initiative?: number;
readonly maxHp?: number;
readonly currentHp?: number;
readonly ac?: number;
readonly conditions?: readonly ConditionId[];
readonly isConcentrating?: boolean;
}
interface CombatantRowProps {
combatant: Combatant;
isActive: boolean;
onRename: (id: CombatantId, newName: string) => void;
onSetInitiative: (id: CombatantId, value: number | undefined) => void;
onRemove: (id: CombatantId) => void;
onSetHp: (id: CombatantId, maxHp: number | undefined) => void;
onAdjustHp: (id: CombatantId, delta: number) => void;
onSetAc: (id: CombatantId, value: number | undefined) => void;
onToggleCondition: (id: CombatantId, conditionId: ConditionId) => void;
onToggleConcentration: (id: CombatantId) => void;
}
function EditableName({
name,
combatantId,
onRename,
}: {
name: string;
combatantId: CombatantId;
onRename: (id: CombatantId, newName: string) => 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 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={startEditing}
className="truncate text-left text-sm text-foreground hover:text-primary transition-colors"
>
{name}
</button>
);
}
function MaxHpDisplay({
maxHp,
onCommit,
}: {
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 text-sm 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="inline-block h-7 min-w-[3ch] text-center text-sm leading-7 tabular-nums text-muted-foreground transition-colors hover:text-primary"
>
{maxHp ?? "Max"}
</button>
);
}
function ClickableHp({
currentHp,
maxHp,
onAdjust,
dimmed,
}: {
currentHp: number | undefined;
maxHp: number | undefined;
onAdjust: (delta: number) => void;
dimmed?: boolean;
}) {
const [popoverOpen, setPopoverOpen] = useState(false);
const status = deriveHpStatus(currentHp, maxHp);
if (maxHp === undefined) {
return (
<span
className={cn(
"inline-block h-7 w-[4ch] text-center text-sm leading-7 tabular-nums text-muted-foreground",
dimmed && "opacity-50",
)}
>
--
</span>
);
}
return (
<div className="relative">
<button
type="button"
onClick={() => setPopoverOpen(true)}
className={cn(
"inline-block h-7 min-w-[3ch] text-center text-sm font-medium leading-7 tabular-nums transition-colors hover:text-primary",
status === "bloodied" && "text-amber-400",
status === "unconscious" && "text-red-400",
status === "healthy" && "text-foreground",
dimmed && "opacity-50",
)}
>
{currentHp}
</button>
{popoverOpen && (
<HpAdjustPopover
onAdjust={onAdjust}
onClose={() => setPopoverOpen(false)}
/>
)}
</div>
);
}
function AcDisplay({
ac,
onCommit,
}: {
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 (
<div className="flex items-center gap-1">
<Shield size={14} className="text-muted-foreground" />
<Input
ref={inputRef}
type="text"
inputMode="numeric"
value={draft}
placeholder="AC"
className="h-7 w-[6ch] text-center text-sm tabular-nums"
onChange={(e) => setDraft(e.target.value)}
onBlur={commit}
onKeyDown={(e) => {
if (e.key === "Enter") commit();
if (e.key === "Escape") setEditing(false);
}}
/>
</div>
);
}
return (
<button
type="button"
onClick={startEditing}
className="flex items-center gap-1 text-sm tabular-nums text-muted-foreground transition-colors hover:text-primary"
>
<Shield size={14} />
{ac !== undefined ? <span>{ac}</span> : null}
</button>
);
}
export function CombatantRow({
combatant,
isActive,
onRename,
onSetInitiative,
onRemove,
onSetHp,
onAdjustHp,
onSetAc,
onToggleCondition,
onToggleConcentration,
}: CombatantRowProps) {
const { id, name, initiative, maxHp, currentHp } = combatant;
const status = deriveHpStatus(currentHp, maxHp);
const dimmed = status === "unconscious";
const [pickerOpen, setPickerOpen] = useState(false);
const prevHpRef = useRef(currentHp);
const [isPulsing, setIsPulsing] = useState(false);
const pulseTimerRef = useRef<ReturnType<typeof setTimeout>>(undefined);
useEffect(() => {
const prevHp = prevHpRef.current;
prevHpRef.current = currentHp;
if (
prevHp !== undefined &&
currentHp !== undefined &&
currentHp < prevHp &&
combatant.isConcentrating
) {
setIsPulsing(true);
clearTimeout(pulseTimerRef.current);
pulseTimerRef.current = setTimeout(() => setIsPulsing(false), 1200);
}
}, [currentHp, combatant.isConcentrating]);
useEffect(() => {
if (!combatant.isConcentrating) {
clearTimeout(pulseTimerRef.current);
setIsPulsing(false);
}
}, [combatant.isConcentrating]);
return (
<div
className={cn(
"group rounded-md px-3 py-2 transition-colors",
isActive
? "border-l-2 border-l-accent bg-accent/10"
: combatant.isConcentrating
? "border-l-2 border-l-purple-400"
: "border-l-2 border-l-transparent",
isPulsing && "animate-concentration-pulse",
)}
>
<div className="grid grid-cols-[1.25rem_3rem_1fr_auto_auto_2rem] items-center gap-3">
{/* Concentration */}
<button
type="button"
onClick={() => onToggleConcentration(id)}
title="Concentrating"
aria-label="Toggle concentration"
className={cn(
"flex items-center justify-center transition-opacity",
combatant.isConcentrating
? dimmed
? "opacity-50 text-purple-400"
: "opacity-100 text-purple-400"
: "opacity-0 group-hover:opacity-50 text-muted-foreground",
)}
>
<Brain size={16} />
</button>
{/* Initiative */}
<Input
type="text"
inputMode="numeric"
value={initiative ?? ""}
placeholder="--"
className={cn(
"h-7 w-[6ch] text-center text-sm tabular-nums",
dimmed && "opacity-50",
)}
onChange={(e) => {
const raw = e.target.value;
if (raw === "") {
onSetInitiative(id, undefined);
} else {
const n = Number.parseInt(raw, 10);
if (!Number.isNaN(n)) {
onSetInitiative(id, n);
}
}
}}
/>
{/* Name */}
<div className={cn(dimmed && "opacity-50")}>
<EditableName name={name} combatantId={id} onRename={onRename} />
</div>
{/* AC */}
<div className={cn(dimmed && "opacity-50")}>
<AcDisplay ac={combatant.ac} onCommit={(v) => onSetAc(id, v)} />
</div>
{/* HP */}
<div className="flex items-center gap-1">
<ClickableHp
currentHp={currentHp}
maxHp={maxHp}
onAdjust={(delta) => onAdjustHp(id, delta)}
dimmed={dimmed}
/>
{maxHp !== undefined && (
<span
className={cn(
"text-sm tabular-nums text-muted-foreground",
dimmed && "opacity-50",
)}
>
/
</span>
)}
<div className={cn(dimmed && "opacity-50")}>
<MaxHpDisplay maxHp={maxHp} onCommit={(v) => onSetHp(id, v)} />
</div>
</div>
{/* Actions */}
<Button
variant="ghost"
size="icon"
className={cn(
"h-7 w-7 text-muted-foreground hover:text-destructive",
dimmed && "opacity-50",
)}
onClick={() => onRemove(id)}
title="Remove combatant"
aria-label="Remove combatant"
>
<X size={16} />
</Button>
</div>
{/* Conditions */}
<div className="relative ml-[calc(3rem+0.75rem)]">
<div className={cn(dimmed && "opacity-50")}>
<ConditionTags
conditions={combatant.conditions}
onRemove={(conditionId) => onToggleCondition(id, conditionId)}
onOpenPicker={() => setPickerOpen((prev) => !prev)}
/>
</div>
{pickerOpen && (
<ConditionPicker
activeConditions={combatant.conditions}
onToggle={(conditionId) => onToggleCondition(id, conditionId)}
onClose={() => setPickerOpen(false)}
/>
)}
</div>
</div>
);
}