215 lines
6.6 KiB
TypeScript
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>
|
|
)}
|
|
</>
|
|
);
|
|
}
|