refactor: Split CharacterSheet.tsx into separate components
This commit is contained in:
File diff suppressed because it is too large
Load Diff
232
src/components/ClassesPage.tsx
Normal file
232
src/components/ClassesPage.tsx
Normal 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
51
src/components/Header.tsx
Normal 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
683
src/components/MainPage.tsx
Normal 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 & 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 & 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 & 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 & 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
96
src/components/ManagePage.tsx
Normal file
96
src/components/ManagePage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
236
src/components/SpellsPage.tsx
Normal file
236
src/components/SpellsPage.tsx
Normal 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 & 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
31
src/constants.ts
Normal 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
64
src/types.ts
Normal 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
4
src/utils.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export function autoResize(el: HTMLTextAreaElement) {
|
||||||
|
el.style.height = "auto";
|
||||||
|
el.style.height = el.scrollHeight + "px";
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user