refactor: Split CharacterSheet.tsx into separate components

This commit is contained in:
2026-06-28 13:10:15 -04:00
parent c9252cfe66
commit dbf3126f29
9 changed files with 1475 additions and 1344 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,232 @@
import React from "react";
import { ClassEntry } from "../types";
import { autoResize } from "../utils";
interface ClassesPageProps {
isActive: boolean;
primaryClasses: ClassEntry[];
setPrimaryClasses: React.Dispatch<React.SetStateAction<ClassEntry[]>>;
otherClasses: ClassEntry[];
setOtherClasses: React.Dispatch<React.SetStateAction<ClassEntry[]>>;
heroicSkills: string;
onHeroicSkillsChange: (v: string) => void;
}
export default function ClassesPage({
isActive,
primaryClasses,
setPrimaryClasses,
otherClasses,
setOtherClasses,
heroicSkills,
onHeroicSkillsChange,
}: ClassesPageProps) {
return (
<div className={`page${isActive ? " active" : ""}`} id="page-classes">
<div className="grid-2" style={{ marginBottom: 20 }}>
<div className="section">
<div className="section-title">
<span className="icon"></span> Primary Classes (up to 3)
</div>
{primaryClasses.map((cls, idx) => (
<div key={idx} className="class-block">
<div className="class-header">
<input
type="text"
placeholder="Class name…"
value={cls.name || ""}
onChange={(e) =>
setPrimaryClasses((prev) =>
prev.map((c, i) => (i === idx ? { ...c, name: e.target.value } : c))
)
}
/>
<input
type="text"
placeholder="Free benefits…"
value={cls.benefits || ""}
onChange={(e) =>
setPrimaryClasses((prev) =>
prev.map((c, i) => (i === idx ? { ...c, benefits: e.target.value } : c))
)
}
/>
</div>
<div className="class-skills">
<textarea
placeholder="Skill information…"
value={cls.skills || ""}
ref={(el) => {
if (el) autoResize(el);
}}
onInput={(e) => autoResize(e.currentTarget)}
onChange={(e) =>
setPrimaryClasses((prev) =>
prev.map((c, i) => (i === idx ? { ...c, skills: e.target.value } : c))
)
}
/>
</div>
<div
style={{
padding: "6px 10px",
borderTop: "1px solid var(--border)",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
}}
>
<label
style={{ display: "flex", alignItems: "center", gap: "6px", fontSize: "0.85em" }}
>
Level
<input
type="number"
min={1}
max={10}
value={cls.level ?? 1}
onChange={(e) =>
setPrimaryClasses((prev) =>
prev.map((c, i) =>
i === idx ? { ...c, level: Number(e.target.value) } : c
)
)
}
style={{ width: "50px" }}
/>
</label>
<button
type="button"
className="spell-del-btn"
onClick={() =>
setPrimaryClasses((prev) => prev.filter((_, i) => i !== idx))
}
>
Remove
</button>
</div>
</div>
))}
<button
type="button"
className="add-btn"
disabled={primaryClasses.length >= 3}
onClick={() =>
setPrimaryClasses((prev) => [
...prev,
{ name: "", level: 1, benefits: "", skills: "" },
])
}
>
+ Add Primary Class
</button>
</div>
<div className="section">
<div className="section-title">
<span className="icon"></span> Other Classes (max 3 non-mastered)
</div>
{otherClasses.map((cls, idx) => (
<div key={idx} className="class-block">
<div className="class-header">
<input
type="text"
placeholder="Class name…"
value={cls.name || ""}
onChange={(e) =>
setOtherClasses((prev) =>
prev.map((c, i) => (i === idx ? { ...c, name: e.target.value } : c))
)
}
/>
<input
type="text"
placeholder="Free benefits…"
value={cls.benefits || ""}
onChange={(e) =>
setOtherClasses((prev) =>
prev.map((c, i) => (i === idx ? { ...c, benefits: e.target.value } : c))
)
}
/>
</div>
<div className="class-skills">
<textarea
placeholder="Skill information…"
value={cls.skills || ""}
onChange={(e) =>
setOtherClasses((prev) =>
prev.map((c, i) => (i === idx ? { ...c, skills: e.target.value } : c))
)
}
/>
</div>
<div
style={{
padding: "6px 10px",
borderTop: "1px solid var(--border)",
display: "flex",
alignItems: "center",
justifyContent: "space-between",
}}
>
<label
style={{ display: "flex", alignItems: "center", gap: "6px", fontSize: "0.85em" }}
>
Level
<input
type="number"
min={1}
max={10}
value={cls.level ?? 1}
onChange={(e) =>
setOtherClasses((prev) =>
prev.map((c, i) =>
i === idx ? { ...c, level: Number(e.target.value) } : c
)
)
}
style={{ width: "50px" }}
/>
</label>
<button
type="button"
className="spell-del-btn"
onClick={() =>
setOtherClasses((prev) => prev.filter((_, i) => i !== idx))
}
>
Remove
</button>
</div>
</div>
))}
<button
type="button"
className="add-btn"
onClick={() =>
setOtherClasses((prev) => [
...prev,
{ name: "", level: 1, benefits: "", skills: "" },
])
}
>
+ Add Class
</button>
</div>
</div>
<div className="section">
<div className="section-title">
<span className="icon"></span> Heroic Skills
</div>
<textarea
value={heroicSkills}
onChange={(e) => onHeroicSkillsChange(e.target.value)}
placeholder="Record your heroic skill abilities here…"
style={{ minHeight: 100 }}
/>
</div>
</div>
);
}

51
src/components/Header.tsx Normal file
View File

@@ -0,0 +1,51 @@
import React from "react";
const TABS = ["main", "classes", "spells", "manage"] as const;
const TAB_LABELS = ["Character", "Classes", "Arcana & Spells", "Manage"];
interface HeaderProps {
activeTab: string;
setActiveTab: (tab: string) => void;
theme: string;
setTheme: React.Dispatch<React.SetStateAction<string>>;
saveStatus: boolean;
}
export default function Header({
activeTab,
setActiveTab,
theme,
setTheme,
saveStatus,
}: HeaderProps) {
return (
<header>
<div className="logo">Fabula Ultima</div>
<div className="tabs">
{TABS.map((tab, i) => (
<button
type="button"
key={tab}
className={`tab${activeTab === tab ? " active" : ""}`}
onClick={() => setActiveTab(tab)}
>
{TAB_LABELS[i]}
</button>
))}
</div>
<div className="toolbar">
<span className={`save-status${saveStatus ? " show" : ""}`}>Saved!</span>
<button type="button" className="btn-print" onClick={() => window.print()}>
Print
</button>
<button
type="button"
className="btn-theme"
onClick={() => setTheme((t) => (t === "light" ? "dark" : "light"))}
>
{theme === "light" ? "☾ Dark" : "☀ Light"}
</button>
</div>
</header>
);
}

683
src/components/MainPage.tsx Normal file
View File

@@ -0,0 +1,683 @@
import React from "react";
import weaponsFile from "../../data/weapons.yml";
import armorShieldsFile from "../../data/armor_shields.yml";
import { Fields, Bond, CheckMap } from "../types";
import { STATUSES, FEELINGS, MUTUAL_EXCLUSIVE, MARTIAL_ITEMS } from "../constants";
interface MainPageProps {
isActive: boolean;
fields: Fields;
f: <K extends keyof Fields>(key: K, val: Fields[K]) => void;
level: number;
setLevel: React.Dispatch<React.SetStateAction<number>>;
xp: number;
xpPct: number;
fp: number;
setFp: React.Dispatch<React.SetStateAction<number>>;
fpTotal: number;
inCrisis: boolean;
calcHP: () => void;
calcMP: () => void;
statuses: CheckMap;
toggleStatus: (s: string) => void;
martial: CheckMap;
toggleMartial: (m: string) => void;
bonds: Bond[];
setBonds: React.Dispatch<React.SetStateAction<Bond[]>>;
toggleFeeling: (bondIdx: number, feeling: string) => void;
weaponPickerOpen: boolean;
setWeaponPickerOpen: React.Dispatch<React.SetStateAction<boolean>>;
weaponCategory: string;
setWeaponCategory: React.Dispatch<React.SetStateAction<string>>;
}
export default function MainPage({
isActive,
fields,
f,
level,
setLevel,
xp,
xpPct,
fp,
setFp,
fpTotal,
inCrisis,
calcHP,
calcMP,
statuses,
toggleStatus,
martial,
toggleMartial,
bonds,
setBonds,
toggleFeeling,
weaponPickerOpen,
setWeaponPickerOpen,
weaponCategory,
setWeaponCategory,
}: MainPageProps) {
return (
<div className={`page${isActive ? " active" : ""}`} id="page-main">
{/* Row 1: Identity + Level */}
<div className="grid-2" style={{ marginBottom: 20 }}>
<div className="section">
<div className="section-title">
<span className="icon"></span> Identity &amp; Traits
</div>
<div className="field-row">
<div className="field" style={{ flex: 2 }}>
<label htmlFor="character-name">Name</label>
<input
id="character-name"
type="text"
value={fields.charName}
onChange={(e) => f("charName", e.target.value)}
placeholder="Character name…"
/>
</div>
<div className="field">
<label htmlFor="character-pronouns">Pronouns</label>
<input
id="character-pronouns"
type="text"
value={fields.charPronouns}
onChange={(e) => f("charPronouns", e.target.value)}
placeholder="they/them"
/>
</div>
</div>
<div className="field">
<label htmlFor="character-identity">Identity</label>
<input
id="character-identity"
type="text"
value={fields.charIdentity}
onChange={(e) => f("charIdentity", e.target.value)}
placeholder="Who are you?"
/>
</div>
<div className="field-row">
<div className="field">
<label htmlFor="character-theme">Theme</label>
<input
id="character-theme"
type="text"
value={fields.charTheme}
onChange={(e) => f("charTheme", e.target.value)}
placeholder="Your theme…"
/>
</div>
<div className="field">
<label htmlFor="character-origin">Origin</label>
<input
id="character-origin"
type="text"
value={fields.charOrigin}
onChange={(e) => f("charOrigin", e.target.value)}
placeholder="Where from?"
/>
</div>
</div>
<div className="field">
<label htmlFor="character-traits">Traits (comma-separated)</label>
<textarea
id="character-traits"
value={fields.charTraits}
onChange={(e) => f("charTraits", e.target.value)}
placeholder="Brave, Reckless, Loyal to a fault…"
style={{ minHeight: 55 }}
/>
</div>
</div>
<div style={{ display: "flex", flexDirection: "column", gap: 16 }}>
<div className="section">
<div className="section-title">
<span className="icon"></span> Level &amp; Experience
</div>
<div className="grid-2" style={{ gap: 14 }}>
<div className="level-display">
<span className="level-num">{level}</span>
<span className="level-text">Character Level</span>
<button
type="button"
className="add-btn"
style={{ marginTop: 10, width: "100%", justifyContent: "center" }}
onClick={() => setLevel((l) => Math.min(50, l + 1))}
>
+ Level Up
</button>
<button
type="button"
className="add-btn"
style={{
marginTop: 4,
width: "100%",
justifyContent: "center",
borderColor: "var(--border-bright)",
color: "var(--text-dim)",
}}
onClick={() => setLevel((l) => Math.max(1, l - 1))}
>
Level Down
</button>
</div>
<div>
<div className="field">
<label htmlFor="character-xp-current">Experience Points (XP)</label>
<input
id="character-xp-current"
type="number"
value={fields.xpCurrent}
onChange={(e) => f("xpCurrent", e.target.value)}
min="0"
/>
</div>
<div className="xp-bar-wrap">
<div className="xp-bar" style={{ width: xpPct + "%" }} />
</div>
<div className="xp-label">
<span>{xp} XP</span>
<span>10 XP = Level</span>
</div>
<div className="field" style={{ marginTop: 12 }}>
<label htmlFor="character-zenit">Zenit (currency)</label>
<input
id="character-zenit"
type="number"
value={fields.zenit}
onChange={(e) => f("zenit", e.target.value)}
min="0"
/>
</div>
</div>
</div>
</div>
<div className="section">
<div className="section-title">
<span className="icon"></span> Defenses
</div>
<div className="def-row">
<div className="def-block">
<label htmlFor="character-initiative">Initiative Mod</label>
<input
id="character-initiative"
type="number"
value={fields.initMod}
onChange={(e) => f("initMod", e.target.value)}
/>
</div>
<div className="def-block">
<label htmlFor="character-defense">Defense</label>
<input
id="character-defense"
type="number"
value={fields.defense}
onChange={(e) => f("defense", e.target.value)}
/>
</div>
<div className="def-block">
<label htmlFor="character-magic-defense">Magic Defense</label>
<input
id="character-magic-defense"
type="number"
value={fields.magDef}
onChange={(e) => f("magDef", e.target.value)}
/>
</div>
</div>
</div>
</div>
</div>
{/* Row 2: Attributes + Status + Vitals */}
<div className="grid-3" style={{ marginBottom: 20 }}>
<div className="section">
<div className="section-title">
<span className="icon"></span> Attributes
</div>
<div className="attr-grid">
{(
[
{ label: "Dexterity", base: "dexBase", cur: "dexCur" },
{ label: "Insight", base: "insBase", cur: "insCur" },
{ label: "Might", base: "migBase", cur: "migCur" },
{ label: "Willpower", base: "wlpBase", cur: "wlpCur" },
] as const
).map(({ label, base, cur }) => (
<div key={label} className="attr-block">
<div className="attr-name">{label}</div>
<div className="attr-inputs">
<div style={{ flex: 1 }}>
<label style={{ fontSize: "0.48rem" }}>Base</label>
<input
type="number"
value={fields[base]}
onChange={(e) => f(base, e.target.value)}
min="6"
max="12"
/>
</div>
<div className="attr-sep"></div>
<div style={{ flex: 1 }}>
<label style={{ fontSize: "0.48rem" }}>Current</label>
<input
type="number"
value={fields[cur]}
onChange={(e) => f(cur, e.target.value)}
min="6"
max="12"
/>
</div>
</div>
</div>
))}
</div>
</div>
<div className="section">
<div className="section-title">
<span className="icon"></span> Status Effects
</div>
<div className="status-grid">
{STATUSES.map((s) => (
<div
key={s}
className={`status-item${statuses[s] ? " active-status" : ""}`}
onClick={() => toggleStatus(s)}
>
<div className="status-check">{statuses[s] ? "✗" : ""}</div>
<div className="status-label">{s}</div>
</div>
))}
</div>
</div>
<div className="section">
<div className="section-title">
<span className="icon"></span> Hit, Mind &amp; Inventory Points
</div>
<div className="vital-block">
<div className="vital-label hp">HP</div>
<div style={{ flex: 1 }}>
<div className="vital-formula">MIG×5 + Level + Other</div>
<div className="vital-inputs">
<input
type="number"
value={fields.hpCur}
onChange={(e) => f("hpCur", e.target.value)}
placeholder="Cur"
/>
<div className="vital-sep">/</div>
<input
type="number"
value={fields.hpMax}
onChange={(e) => f("hpMax", e.target.value)}
placeholder="Max"
/>
{inCrisis && <div className="crisis-badge">CRISIS</div>}
</div>
</div>
<button
type="button"
className="add-btn"
style={{ padding: "4px 8px" }}
onClick={calcHP}
title="Auto-calculate from Might"
>
Calc
</button>
</div>
<div className="vital-block">
<div className="vital-label mp">MP</div>
<div style={{ flex: 1 }}>
<div className="vital-formula">WLP×5 + Level + Other</div>
<div className="vital-inputs">
<input
type="number"
value={fields.mpCur}
onChange={(e) => f("mpCur", e.target.value)}
placeholder="Cur"
/>
<div className="vital-sep">/</div>
<input
type="number"
value={fields.mpMax}
onChange={(e) => f("mpMax", e.target.value)}
placeholder="Max"
/>
</div>
</div>
<button
type="button"
className="add-btn"
style={{ padding: "4px 8px" }}
onClick={calcMP}
title="Auto-calculate from Willpower"
>
Calc
</button>
</div>
<div className="vital-block">
<div className="vital-label ip">IP</div>
<div style={{ flex: 1 }}>
<div className="vital-formula">6 + Other</div>
<div className="vital-inputs">
<input
type="number"
value={fields.ipCur}
onChange={(e) => f("ipCur", e.target.value)}
placeholder="Cur"
/>
<div className="vital-sep">/</div>
<input
type="number"
value={fields.ipMax}
onChange={(e) => f("ipMax", e.target.value)}
placeholder="Max"
/>
</div>
</div>
</div>
</div>
</div>
{/* Row 3: FP + Bonds */}
<div className="grid-2" style={{ marginBottom: 20 }}>
<div className="section">
<div className="section-title">
<span className="icon"></span> Fabula Points
</div>
<div style={{ display: "flex", alignItems: "center", gap: 14, flexWrap: "wrap" }}>
<div>
<label>Current FP</label>
<input
type="number"
value={fp}
min="0"
max="20"
onChange={(e) => setFp(parseInt(e.target.value) || 0)}
style={{
width: 70,
textAlign: "center",
fontSize: "1.4rem",
fontFamily: "var(--font-mono)",
}}
/>
</div>
<div className="fp-pips">
{Array.from({ length: fpTotal }, (_, i) => (
<div
key={i}
className={`fp-pip${i < fp ? " filled" : ""}`}
onClick={() => setFp(i < fp ? i : i + 1)}
/>
))}
</div>
</div>
<div className="fp-rules">
<div className="fp-rule">
<strong>+1 FP</strong> if you have none at start of session.
</div>
<div className="fp-rule">
<strong>+1 FP</strong> when a Villain makes an entrance.
</div>
<div className="fp-rule">
<strong>+1 FP</strong> when you fumble a Check.
</div>
<div className="fp-rule">
<strong>+2 FP</strong> if you surrender at zero HP.
</div>
<div className="fp-rule" style={{ marginTop: 6 }}>
<strong>Spend 1 FP</strong> to invoke a trait: reroll one or both dice.
</div>
<div className="fp-rule">
<strong>Spend 1 FP</strong> to invoke a bond: add its strength to the result.
</div>
<div className="fp-rule">
<strong>Spend 1 FP</strong> to alter the story.
</div>
</div>
</div>
<div className="section">
<div className="section-title">
<span className="icon"></span> Bonds
</div>
{bonds.map((bond, idx) => (
<div key={idx} className="bond-block">
<div className="bond-header">
<div className="bond-num">{idx + 1}</div>
<input
type="text"
placeholder="Bond target name…"
value={bond.name}
onChange={(e) =>
setBonds((prev) =>
prev.map((b, i) => (i === idx ? { ...b, name: e.target.value } : b))
)
}
/>
</div>
<div className="bond-feelings">
{FEELINGS.map((feeling) => {
const isActive = bond.feelings.includes(feeling);
const isDisabled =
!isActive && bond.feelings.includes(MUTUAL_EXCLUSIVE[feeling]);
return (
<div
key={feeling}
className={`bond-feeling${isActive ? " active" : ""}${isDisabled ? " disabled" : ""}`}
onClick={() => !isDisabled && toggleFeeling(idx, feeling)}
>
<div className="bond-feeling-box">{isActive ? "✓" : ""}</div>
<span>{feeling}</span>
</div>
);
})}
</div>
</div>
))}
</div>
</div>
{/* Row 4: Equipment + Backpack */}
<div className="grid-2" style={{ marginBottom: 20 }}>
<div className="section">
<div className="section-title">
<span className="icon"></span> Equipment
</div>
<div className="martial-row">
{MARTIAL_ITEMS.map((m) => (
<div
key={m}
className={`martial-item${martial[m] ? " checked" : ""}`}
onClick={() => toggleMartial(m)}
>
<div className="martial-box">{martial[m] ? "✓" : ""}</div>
<span>{m}</span>
</div>
))}
</div>
<div style={{ marginTop: 14 }}>
{(
[
{
slot: "Accessory",
name: "accName",
desc: "accDesc",
namePh: "Item name",
descPh: "Description / effect",
},
{
slot: "Armor",
name: "armName",
desc: "armDesc",
namePh: "Item name",
descPh: "Defense bonus / effect",
},
{
slot: "Main Hand",
name: "mhName",
desc: "mhDesc",
namePh: "Weapon name",
descPh: "Damage / effect",
},
{
slot: "Off-Hand",
name: "ohName",
desc: "ohDesc",
namePh: "Weapon / shield",
descPh: "Damage / effect",
},
] as const
).map(({ slot, name, desc, namePh, descPh }) => (
<div key={slot} className="equip-row">
<div className="equip-slot">{slot}</div>
<div className="equip-fields">
<input
type="text"
value={fields[name]}
onChange={(e) => f(name, e.target.value)}
placeholder={namePh}
/>
<input
type="text"
value={fields[desc]}
onChange={(e) => f(desc, e.target.value)}
placeholder={descPh}
/>
</div>
</div>
))}
</div>
</div>
<div className="section">
<div className="section-title">
<span className="icon"></span> Backpack &amp; Notes
</div>
<textarea
value={fields.backpack}
onChange={(e) => f("backpack", e.target.value)}
placeholder="Items, notes, lore…"
style={{ minHeight: 200 }}
/>
<button
type="button"
className="add-btn"
style={{ marginTop: 8 }}
onClick={() => {
setWeaponCategory("all");
setWeaponPickerOpen(true);
}}
>
+ Add Equipment
</button>
{weaponPickerOpen &&
(() => {
const allWeapons = (weaponsFile as WeaponsFile).weapons;
const allArmorShields = (armorShieldsFile as ArmorShieldsFile).armor_shields;
type PickerItem =
| { kind: "weapon"; data: WeaponTemplate }
| { kind: "armor"; data: ArmorShieldTemplate };
const allItems: PickerItem[] = [
...allWeapons.map((w) => ({ kind: "weapon" as const, data: w })),
...allArmorShields.map((a) => ({ kind: "armor" as const, data: a })),
];
const categories = [
"all",
...Array.from(new Set(allItems.map((i) => i.data.category))).sort(),
];
const visible =
weaponCategory === "all"
? allItems
: allItems.filter((i) => i.data.category === weaponCategory);
const formatLine = (item: PickerItem) => {
if (item.kind === "weapon") {
const w = item.data;
return `${w.name}: Acc ${w.accuracy}, Dmg ${w.damage}${w.description ? ` | ${w.description}` : ""}${w.cost > 0 ? ` (${w.cost}z)` : ""}`;
} else {
const a = item.data;
const init = a.initiative ?? a.initative ?? 0;
return `${a.name}: DEF ${a.defense}, MDEF ${a.magic_defense}, Init ${init}${a.description ? ` | ${a.description}` : ""}${a.cost > 0 ? ` (${a.cost}z)` : ""}`;
}
};
return (
<div
className="spell-picker-overlay"
onClick={() => setWeaponPickerOpen(false)}
>
<div
className="spell-picker-modal"
onClick={(e) => e.stopPropagation()}
>
<div className="spell-picker-header">
<span>Choose equipment</span>
<button
type="button"
className="spell-picker-close"
onClick={() => setWeaponPickerOpen(false)}
>
</button>
</div>
<div
style={{
display: "flex",
gap: 6,
flexWrap: "wrap",
padding: "8px 14px",
borderBottom: "1px solid var(--border)",
}}
>
{categories.map((cat) => (
<button
type="button"
key={cat}
className={`add-btn${weaponCategory === cat ? " active-filter" : ""}`}
style={{
padding: "2px 8px",
fontSize: "0.65rem",
textTransform: "capitalize",
}}
onClick={() => setWeaponCategory(cat)}
>
{cat}
</button>
))}
</div>
<ul className="spell-picker-list">
{visible.map((item, i) => (
<li
key={i}
className="spell-picker-item"
onClick={() => {
const line = formatLine(item);
f(
"backpack",
fields.backpack ? fields.backpack + "\n" + line : line,
);
setWeaponPickerOpen(false);
}}
>
<span className="spell-picker-name">{item.data.name}</span>
<span className="spell-picker-class">{item.data.category}</span>
</li>
))}
</ul>
</div>
</div>
);
})()}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,96 @@
import React from "react";
interface ManagePageProps {
isActive: boolean;
saveSheet: () => void;
loadSheet: () => void;
exportSheet: () => void;
handleImportFile: (e: React.ChangeEvent<HTMLInputElement>) => void;
importFileRef: React.RefObject<HTMLInputElement | null>;
copyShareURL: () => Promise<void>;
copyStatus: boolean;
}
export default function ManagePage({
isActive,
saveSheet,
loadSheet,
exportSheet,
handleImportFile,
importFileRef,
copyShareURL,
copyStatus,
}: ManagePageProps) {
return (
<div className={`page${isActive ? " active" : ""}`} id="page-manage">
<div className="manage-grid">
<div className="section">
<div className="section-title">
<span className="icon"></span> Local Save
</div>
<p className="manage-desc">
Save your character sheet to your browser's local storage, or load a previously saved
sheet.
</p>
<div className="manage-btn-row">
<button type="button" className="btn-save btn-lg" onClick={saveSheet}>
✦ Save to Browser
</button>
<button type="button" className="btn-load btn-lg" onClick={loadSheet}>
↑ Load from Browser
</button>
</div>
</div>
<div className="section">
<div className="section-title">
<span className="icon">⊕</span> JSON File
</div>
<p className="manage-desc">
Export your character to a JSON file for backup or sharing, or import from a previously
exported file.
</p>
<div className="manage-btn-row">
<button type="button" className="btn-save btn-export btn-lg" onClick={exportSheet}>
↓ Export JSON
</button>
<button
type="button"
className="btn-load btn-import btn-lg"
onClick={() => {
if (!importFileRef.current) return;
importFileRef.current.value = "";
importFileRef.current.click();
}}
>
↑ Import JSON
</button>
<input
ref={importFileRef}
type="file"
accept=".json,application/json"
style={{ display: "none" }}
onChange={handleImportFile}
/>
</div>
</div>
<div className="section col-span-2">
<div className="section-title">
<span className="icon">⎘</span> Share via URL
</div>
<p className="manage-desc">
Encode your character's current state into a shareable link. Anyone who opens the link
will see your character auto-save is disabled for viewers.
</p>
<div className="manage-btn-row">
<button type="button" className="btn-save btn-export btn-lg" onClick={copyShareURL}>
Copy URL
</button>
<span className={`save-status${copyStatus ? " show" : ""}`}>Copied!</span>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,236 @@
import React from "react";
import spellsFile from "../../data/spells.yml";
import { Spell, CheckMap } from "../types";
import { DISCIPLINES } from "../constants";
import { autoResize } from "../utils";
interface SpellsPageProps {
isActive: boolean;
spells: Spell[];
setSpells: React.Dispatch<React.SetStateAction<Spell[]>>;
disciplines: CheckMap;
toggleDisc: (d: string) => void;
ritualsNotes: string;
onRitualsNotesChange: (v: string) => void;
spellPickerOpen: boolean;
setSpellPickerOpen: React.Dispatch<React.SetStateAction<boolean>>;
}
export default function SpellsPage({
isActive,
spells,
setSpells,
disciplines,
toggleDisc,
ritualsNotes,
onRitualsNotesChange,
spellPickerOpen,
setSpellPickerOpen,
}: SpellsPageProps) {
return (
<div className={`page${isActive ? " active" : ""}`} id="page-spells">
<div className="section" style={{ marginBottom: 20 }}>
<div className="section-title">
<span className="icon"></span> Arcana &amp; Spells
</div>
<table className="spells-table">
<thead>
<tr>
<th className="spell-name-col">Name / Notes</th>
<th className="spell-class-col">Class</th>
<th className="spell-mp-col">MP Cost</th>
<th className="spell-targets-col">Targets</th>
<th className="spell-dur-col">Duration</th>
<th className="spell-del-col"></th>
</tr>
</thead>
<tbody>
{spells.map((s, i) => (
<React.Fragment key={i}>
<tr className="spell-inputs-row">
<td className="spell-name-col">
<input
type="text"
placeholder="Spell / Arcana name…"
value={s.name || ""}
onChange={(e) =>
setSpells((prev) =>
prev.map((sp, j) => (j === i ? { ...sp, name: e.target.value } : sp))
)
}
/>
</td>
<td className="spell-class-col">
<input
type="text"
placeholder="Class…"
value={s.spellClass || ""}
onChange={(e) =>
setSpells((prev) =>
prev.map((sp, j) =>
j === i ? { ...sp, spellClass: e.target.value } : sp
)
)
}
/>
</td>
<td className="spell-mp-col">
<input
type="text"
placeholder="MP cost…"
value={s.mp || ""}
onChange={(e) =>
setSpells((prev) =>
prev.map((sp, j) => (j === i ? { ...sp, mp: e.target.value } : sp))
)
}
/>
</td>
<td className="spell-targets-col">
<input
type="text"
placeholder="Target(s)…"
value={s.targets || ""}
onChange={(e) =>
setSpells((prev) =>
prev.map((sp, j) =>
j === i ? { ...sp, targets: e.target.value } : sp
)
)
}
/>
</td>
<td className="spell-dur-col">
<input
type="text"
placeholder="Duration…"
value={s.duration || ""}
onChange={(e) =>
setSpells((prev) =>
prev.map((sp, j) =>
j === i ? { ...sp, duration: e.target.value } : sp
)
)
}
/>
</td>
<td className="spell-del-col">
<button
type="button"
className="spell-del-btn"
onClick={() => setSpells((prev) => prev.filter((_, j) => j !== i))}
>
</button>
</td>
</tr>
<tr className="spell-notes-row">
<td colSpan={6}>
<textarea
placeholder="Notes / effect description…"
value={s.notes || ""}
ref={(el) => {
if (el) autoResize(el);
}}
onInput={(e) => autoResize(e.currentTarget)}
onChange={(e) =>
setSpells((prev) =>
prev.map((sp, j) =>
j === i ? { ...sp, notes: e.target.value } : sp
)
)
}
/>
</td>
</tr>
</React.Fragment>
))}
</tbody>
</table>
<div style={{ display: "flex", gap: 8, marginTop: 10 }}>
<button
type="button"
className="add-btn"
onClick={() =>
setSpells((prev) => [
...prev,
{ name: "", spellClass: "", notes: "", mp: "", targets: "", duration: "" },
])
}
>
+ Add Spell / Arcana
</button>
<button type="button" className="add-btn" onClick={() => setSpellPickerOpen(true)}>
+ Add Template Spell
</button>
</div>
{spellPickerOpen && (
<div className="spell-picker-overlay" onClick={() => setSpellPickerOpen(false)}>
<div className="spell-picker-modal" onClick={(e) => e.stopPropagation()}>
<div className="spell-picker-header">
<span>Choose a spell</span>
<button
type="button"
className="spell-picker-close"
onClick={() => setSpellPickerOpen(false)}
>
</button>
</div>
<ul className="spell-picker-list">
{(spellsFile as SpellsFile).spells.map((t, i) => (
<li
key={i}
className="spell-picker-item"
onClick={() => {
setSpells((prev) => [
...prev,
{
name: t.name,
spellClass: t.class,
notes: t.description,
mp: t.cost,
targets: t.targets,
duration: t.duration,
},
]);
setSpellPickerOpen(false);
}}
>
<span className="spell-picker-name">{t.name}</span>
<span className="spell-picker-class">{t.class}</span>
</li>
))}
</ul>
</div>
</div>
)}
</div>
<div className="section">
<div className="section-title">
<span className="icon"></span> Rituals
</div>
<div className="disciplines-row">
{DISCIPLINES.map((d) => (
<div
key={d}
className={`disc-item${disciplines[d] ? " checked" : ""}`}
onClick={() => toggleDisc(d)}
>
<div className="disc-box">{disciplines[d] ? "✓" : ""}</div>
<span>{d}</span>
</div>
))}
</div>
<textarea
value={ritualsNotes}
onChange={(e) => onRitualsNotesChange(e.target.value)}
placeholder="Record ritual details, components, and notes here…"
style={{ minHeight: 120 }}
/>
</div>
</div>
);
}

31
src/constants.ts Normal file
View File

@@ -0,0 +1,31 @@
export const STATUSES = ["Slow", "Enraged", "Dazed", "Weak", "Poisoned", "Shaken"];
export const FEELINGS = [
"Admiration",
"Loyalty",
"Hatred",
"Inferiority",
"Mistrust",
"Affection",
];
export const MUTUAL_EXCLUSIVE: Record<string, string> = {
Admiration: "Inferiority",
Inferiority: "Admiration",
Loyalty: "Mistrust",
Mistrust: "Loyalty",
Hatred: "Affection",
Affection: "Hatred",
};
export const MARTIAL_ITEMS = [
"Martial Armor",
"Martial Shields",
"Martial Melee",
"Martial Ranged",
];
export const DISCIPLINES = [
"Arcanism",
"Chimerism",
"Elementalism",
"Entropism",
"Ritualism",
"Spiritism",
];

64
src/types.ts Normal file
View File

@@ -0,0 +1,64 @@
export interface Fields {
charName: string;
charPronouns: string;
charIdentity: string;
charTheme: string;
charOrigin: string;
charTraits: string;
xpCurrent: string | number;
zenit: string | number;
initMod: string | number;
defense: string | number;
magDef: string | number;
dexBase: string | number;
dexCur: string | number;
insBase: string | number;
insCur: string | number;
migBase: string | number;
migCur: string | number;
wlpBase: string | number;
wlpCur: string | number;
hpMax: string | number;
hpCur: string | number;
mpMax: string | number;
mpCur: string | number;
ipMax: string | number;
ipCur: string | number;
backpack: string;
heroicSkills: string;
ritualsNotes: string;
accName: string;
accDesc: string;
armName: string;
armDesc: string;
mhName: string;
mhDesc: string;
ohName: string;
ohDesc: string;
}
export interface Bond {
name: string;
feelings: string[];
}
export interface ClassEntry {
name: string;
level: number;
benefits: string;
skills: string;
}
export interface Spell {
name: string;
spellClass: string;
notes: string;
mp: string;
targets: string;
duration: string;
}
export type CheckMap = Record<string, boolean>;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export type SavedData = Record<string, any>;

4
src/utils.ts Normal file
View File

@@ -0,0 +1,4 @@
export function autoResize(el: HTMLTextAreaElement) {
el.style.height = "auto";
el.style.height = el.scrollHeight + "px";
}