darkwatch/client/src/components/CharacterSheet.tsx

215 lines
6.6 KiB
TypeScript

import { useState, useRef, useEffect } from "react";
import type { Character, GameItem } from "../types";
import { calculateAC } from "../utils/derived-ac";
import AcDisplay from "./AcDisplay";
import InlineNumber from "./InlineNumber";
import StatsPanel from "./StatsPanel";
import InfoPanel from "./InfoPanel";
import GearPanel from "./GearPanel";
import styles from "./CharacterSheet.module.css";
interface CharacterSheetProps {
character: Character;
mode: "view" | "edit";
campaignId: number;
onUpdate: (id: number, data: Partial<Character>) => void;
onStatChange: (characterId: number, statName: string, value: number) => void;
onAddGearFromItem: (characterId: number, item: GameItem) => void;
onAddGearCustom: (
characterId: number,
data: { name: string; type: string; slot_count: number },
) => void;
onRemoveGear: (characterId: number, gearId: number) => void;
onAddTalent: (
characterId: number,
data: {
name: string;
description: string;
effect?: Record<string, unknown>;
game_talent_id?: number | null;
},
) => void;
onRemoveTalent: (characterId: number, talentId: number) => void;
onDelete: (id: number) => void;
}
export default function CharacterSheet({
character,
mode,
campaignId,
onUpdate,
onStatChange,
onAddGearFromItem,
onAddGearCustom,
onRemoveGear,
onAddTalent,
onRemoveTalent,
onDelete,
}: CharacterSheetProps) {
const [confirmDelete, setConfirmDelete] = useState(false);
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
const acBreakdown = calculateAC(character);
const xpThreshold = character.level * 10;
useEffect(() => {
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
};
}, []);
function handleNameField(field: string, value: string) {
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
onUpdate(character.id, { [field]: value });
}, 400);
}
function handleAcOverride(value: number | null) {
const overrides = { ...(character.overrides || {}) };
if (value === null) {
delete overrides.ac;
} else {
overrides.ac = value;
}
onUpdate(character.id, { overrides } as Partial<Character>);
}
return (
<>
{/* HEADER BANNER */}
<div className={styles.banner}>
<div className={styles.identity}>
{mode === "edit" ? (
<div>
<input
className={styles.nameInput}
defaultValue={character.name}
onChange={(e) => handleNameField("name", e.target.value)}
/>
<input
className={styles.titleInput}
defaultValue={character.title}
placeholder="title..."
onChange={(e) => handleNameField("title", e.target.value)}
/>
</div>
) : (
<div className={styles.name}>
{character.name}
{character.title && (
<span className={styles.title}> {character.title}</span>
)}
</div>
)}
<div className={styles.subtitle}>
Level {character.level} {character.ancestry} {character.class}
</div>
</div>
<div className={styles.vitals}>
{/* HP — click to edit */}
<div className={styles.vital}>
<div className={styles.hpValues}>
<span className={styles.vitalLabel}>HP</span>
<InlineNumber
value={character.hp_current}
onChange={(hp) => onUpdate(character.id, { hp_current: hp })}
className={`${styles.vitalValue} ${styles.hp}`}
/>
<span className={styles.hpSlash}>/</span>
{mode === "edit" ? (
<InlineNumber
value={character.hp_max}
onChange={(hp) => onUpdate(character.id, { hp_max: hp })}
className={styles.hpMax}
min={0}
/>
) : (
<span className={styles.hpMax}>{character.hp_max}</span>
)}
</div>
</div>
{/* AC — display only in view, override in edit */}
<div className={styles.vital}>
<AcDisplay
breakdown={acBreakdown}
onOverride={handleAcOverride}
mode={mode}
/>
</div>
{/* XP — click to edit */}
<div className={styles.vital}>
<div className={styles.hpValues}>
<span className={styles.vitalLabel}>XP</span>
<InlineNumber
value={character.xp}
onChange={(xp) => onUpdate(character.id, { xp })}
className={`${styles.vitalValue} ${styles.xpCurrent}`}
min={0}
/>
<span className={styles.xpThreshold}>/ {xpThreshold}</span>
</div>
</div>
</div>
</div>
{/* THREE PANELS */}
<div className={styles.panels}>
<StatsPanel
character={character}
mode={mode}
campaignId={campaignId}
onStatChange={onStatChange}
/>
<InfoPanel
character={character}
mode={mode}
onUpdate={onUpdate}
onAddTalent={onAddTalent}
onRemoveTalent={onRemoveTalent}
/>
<GearPanel
character={character}
mode={mode}
onAddGearFromItem={onAddGearFromItem}
onAddGearCustom={onAddGearCustom}
onRemoveGear={onRemoveGear}
onCurrencyChange={(charId, field, value) =>
onUpdate(charId, { [field]: value })
}
/>
</div>
{/* DELETE — edit mode only */}
{mode === "edit" && (
<div className={styles.deleteSection}>
{confirmDelete ? (
<div>
<span>Delete {character.name}? </span>
<button
className={styles.deleteBtn}
onClick={() => onDelete(character.id)}
>
Yes, delete
</button>{" "}
<button
className={styles.deleteBtn}
onClick={() => setConfirmDelete(false)}
>
Cancel
</button>
</div>
) : (
<button
className={styles.deleteBtn}
onClick={() => setConfirmDelete(true)}
>
Delete Character
</button>
)}
</div>
)}
</>
);
}