darkwatch/client/src/components/CharacterCard.tsx
Aaron Wood 47215b48f1 feat: add DM/player role separation — atmosphere, invite, and edit controls gated by role
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-11 00:44:27 -04:00

100 lines
3.3 KiB
TypeScript

import type { Character } from "../types";
import HpBar from "./HpBar";
import TorchTimer from "./TorchTimer";
import styles from "./CharacterCard.module.css";
import { calculateAC } from "../utils/derived-ac";
import { getTalentHpBonus } from "../utils/talent-effects";
import { getEffectiveStat } from "../utils/talent-effects";
import { getModifier, formatModifier } from "../utils/modifiers";
function getAvatarUrl(character: Character): string {
const style = (character.overrides?.avatar_style as string) || "micah";
const seed = encodeURIComponent(character.name || "hero");
return `https://api.dicebear.com/9.x/${style}/svg?seed=${seed}`;
}
const STATS = ["STR", "DEX", "CON", "INT", "WIS", "CHA"];
interface CharacterCardProps {
character: Character;
onHpChange: (characterId: number, hp: number) => void;
onUpdate: (characterId: number, data: Partial<Character>) => void;
onClick: (characterId: number) => void;
canEdit?: boolean;
}
export default function CharacterCard({
character,
onHpChange,
onUpdate,
onClick,
canEdit = true,
}: CharacterCardProps) {
return (
<div
className={styles.card}
onClick={() => onClick(character.id)}
style={{ borderLeftColor: character.color, borderLeftWidth: "3px" }}
>
<div className={styles.cardHeader}>
<img className={styles.avatar} src={getAvatarUrl(character)} alt="" />
<div className={styles.nameRow}>
<span className={styles.name}>
{character.name}
{character.title ? ` ${character.title}` : ""}
</span>
<span className={styles.level}>Lvl {character.level}</span>
</div>
</div>
<div className={styles.meta}>
{character.ancestry} {character.class}
</div>
<div className={styles.vitalsRow} onClick={(e) => e.stopPropagation()}>
<HpBar
current={character.hp_current}
max={character.hp_max + getTalentHpBonus(character)}
onChange={(hp) => onHpChange(character.id, hp)}
/>
<div className={styles.ac}>
<span className={styles.acLabel}>AC</span>
<span className={styles.acValue}>
{calculateAC(character).effective}
</span>
</div>
<span
className={styles.luck}
title={character.luck_token ? "Luck available" : "Luck spent"}
>
{character.luck_token ? "\u2605" : "\u2606"}
</span>
<TorchTimer
torchLitAt={character.torch_lit_at}
onToggle={() => {
const isLit = character.torch_lit_at !== null;
onUpdate(character.id, {
torch_lit_at: isLit ? null : new Date().toISOString(),
} as Partial<Character>);
}}
/>
</div>
<div className={styles.modRow}>
{STATS.map((stat) => {
const value = getEffectiveStat(character, stat);
const mod = getModifier(value);
return (
<span key={stat} className={styles.mod}>
<span className={styles.modLabel}>{stat}</span>
<span className={styles.modValue}>{formatModifier(mod)}</span>
</span>
);
})}
</div>
<div className={styles.xp}>
XP {character.xp} / {character.level * 10}
</div>
</div>
);
}