- DiceTray: remove pointer-events:auto from .active so UI remains clickable during dice animation - FogOverlay: accept intensity prop, map 0-100 to 0.15-1.0 opacity - CampaignView: pass fog intensity to FogOverlay - InfoPanel: auto-derive Shadowdark title when class/alignment/level changes - Add shadowdark-titles.ts utility with full title lookup table from Player Quickstart - Add CLAUDE.md with project instructions and pre-approved permissions Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
191 lines
6.6 KiB
TypeScript
191 lines
6.6 KiB
TypeScript
import { useRef, useEffect } from "react";
|
|
import type { Character } from "../types";
|
|
import TalentList from "./TalentList";
|
|
import SelectDropdown from "./SelectDropdown";
|
|
import { getShadowdarkTitle } from "../utils/shadowdark-titles.js";
|
|
import styles from "./InfoPanel.module.css";
|
|
|
|
const CLASSES = ["Fighter", "Priest", "Thief", "Wizard"];
|
|
const ANCESTRIES = ["Human", "Elf", "Dwarf", "Halfling", "Goblin", "Half-Orc"];
|
|
const ALIGNMENTS = ["Lawful", "Neutral", "Chaotic"];
|
|
|
|
interface InfoPanelProps {
|
|
character: Character;
|
|
mode: "view" | "edit";
|
|
onUpdate: (id: number, data: Partial<Character>) => 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;
|
|
}
|
|
|
|
export default function InfoPanel({
|
|
character,
|
|
mode,
|
|
onUpdate,
|
|
onAddTalent,
|
|
onRemoveTalent,
|
|
}: InfoPanelProps) {
|
|
const debounceRef = useRef<ReturnType<typeof setTimeout>>();
|
|
|
|
useEffect(() => {
|
|
return () => {
|
|
if (debounceRef.current) clearTimeout(debounceRef.current);
|
|
};
|
|
}, []);
|
|
|
|
function handleField(field: string, value: string | number) {
|
|
const update: Partial<Character> = { [field]: value };
|
|
|
|
// Auto-update title when class, alignment, or level changes
|
|
if (field === "class" || field === "alignment" || field === "level") {
|
|
const newClass = field === "class" ? String(value) : character.class;
|
|
const newAlignment = field === "alignment" ? String(value) : character.alignment;
|
|
const newLevel = field === "level" ? Number(value) : character.level;
|
|
const derived = getShadowdarkTitle(newClass, newAlignment, newLevel);
|
|
if (derived) update.title = derived;
|
|
}
|
|
|
|
if (typeof value === "string" && field !== "class" && field !== "alignment") {
|
|
if (debounceRef.current) clearTimeout(debounceRef.current);
|
|
debounceRef.current = setTimeout(() => {
|
|
onUpdate(character.id, update);
|
|
}, 400);
|
|
} else {
|
|
onUpdate(character.id, update);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className={styles.panel}>
|
|
<TalentList
|
|
talents={character.talents}
|
|
onAdd={(data) => onAddTalent(character.id, data)}
|
|
onRemove={(id) => onRemoveTalent(character.id, id)}
|
|
mode={mode}
|
|
/>
|
|
|
|
<div className={styles.sectionTitle} style={{ marginTop: "0.75rem" }}>
|
|
Info
|
|
</div>
|
|
|
|
{mode === "view" ? (
|
|
<div className={styles.infoGrid}>
|
|
{character.background && (
|
|
<div className={styles.infoRow}>
|
|
<span className={styles.infoLabel}>Background</span>
|
|
<span className={styles.infoValue}>{character.background}</span>
|
|
</div>
|
|
)}
|
|
{character.deity && (
|
|
<div className={styles.infoRow}>
|
|
<span className={styles.infoLabel}>Deity</span>
|
|
<span className={styles.infoValue}>{character.deity}</span>
|
|
</div>
|
|
)}
|
|
{character.languages && (
|
|
<div className={styles.infoRow}>
|
|
<span className={styles.infoLabel}>Languages</span>
|
|
<span className={styles.infoValue}>{character.languages}</span>
|
|
</div>
|
|
)}
|
|
<div className={styles.infoRow}>
|
|
<span className={styles.infoLabel}>Alignment</span>
|
|
<span className={styles.infoValue}>{character.alignment}</span>
|
|
</div>
|
|
{character.notes && (
|
|
<>
|
|
<div className={styles.infoRow}>
|
|
<span className={styles.infoLabel}>Notes</span>
|
|
</div>
|
|
<div className={styles.notes}>{character.notes}</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<div className={styles.infoGrid}>
|
|
<div className={styles.editRow}>
|
|
<div className={styles.field}>
|
|
<label className={styles.fieldLabel}>Class</label>
|
|
<SelectDropdown
|
|
value={character.class}
|
|
options={CLASSES}
|
|
onChange={(v) => handleField("class", v)}
|
|
/>
|
|
</div>
|
|
<div className={styles.field}>
|
|
<label className={styles.fieldLabel}>Ancestry</label>
|
|
<SelectDropdown
|
|
value={character.ancestry}
|
|
options={ANCESTRIES}
|
|
onChange={(v) => handleField("ancestry", v)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className={styles.editRow}>
|
|
<div className={styles.field}>
|
|
<label className={styles.fieldLabel}>Level</label>
|
|
<input
|
|
className={styles.editField}
|
|
type="number"
|
|
min={0}
|
|
value={character.level}
|
|
onChange={(e) => handleField("level", Number(e.target.value))}
|
|
/>
|
|
</div>
|
|
<div className={styles.field}>
|
|
<label className={styles.fieldLabel}>Alignment</label>
|
|
<SelectDropdown
|
|
value={character.alignment}
|
|
options={ALIGNMENTS}
|
|
onChange={(v) => handleField("alignment", v)}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<div className={styles.field}>
|
|
<label className={styles.fieldLabel}>Background</label>
|
|
<input
|
|
className={styles.editField}
|
|
value={character.background}
|
|
placeholder="Urchin..."
|
|
onChange={(e) => handleField("background", e.target.value)}
|
|
/>
|
|
</div>
|
|
<div className={styles.field}>
|
|
<label className={styles.fieldLabel}>Deity</label>
|
|
<input
|
|
className={styles.editField}
|
|
value={character.deity}
|
|
placeholder="None..."
|
|
onChange={(e) => handleField("deity", e.target.value)}
|
|
/>
|
|
</div>
|
|
<div className={styles.field}>
|
|
<label className={styles.fieldLabel}>Languages</label>
|
|
<input
|
|
className={styles.editField}
|
|
value={character.languages}
|
|
placeholder="Common, Elvish..."
|
|
onChange={(e) => handleField("languages", e.target.value)}
|
|
/>
|
|
</div>
|
|
<div className={styles.field}>
|
|
<label className={styles.fieldLabel}>Notes</label>
|
|
<textarea
|
|
className={styles.notesEdit}
|
|
value={character.notes}
|
|
onChange={(e) => handleField("notes", e.target.value)}
|
|
placeholder="Freeform notes..."
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|