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