Files
fabula-ultima-html/src/CharacterSheet.tsx
Drew Malzahn de37809eec
Some checks failed
Deploy / deploy (push) Failing after 44s
fix: Copy share URL over HTTP via execCommand fallback
navigator.clipboard is only available in a secure context (HTTPS or
localhost), so the Copy URL button threw over plain HTTP. Fall back to
the deprecated document.execCommand("copy") when the Clipboard API is
unavailable.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 01:41:17 +00:00

1538 lines
50 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useState, useEffect, useCallback, useRef } from "react";
import "./fabula-ultima-sheet.css";
const STATUSES = ["Slow", "Enraged", "Dazed", "Weak", "Poisoned", "Shaken"];
const FEELINGS = [
"Admiration",
"Inferiority",
"Loyalty",
"Mistrust",
"Affection",
"Hatred",
];
const MARTIAL_ITEMS = [
"Martial Armor",
"Martial Shields",
"Martial Melee",
"Martial Ranged",
];
const DISCIPLINES = [
"Arcanism",
"Chimerism",
"Elementalism",
"Entropism",
"Ritualism",
"Spiritism",
];
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;
}
interface Bond {
name: string;
feelings: string[];
}
interface ClassEntry {
name: string;
benefits: string;
skills: string;
}
interface Spell {
name: string;
notes: string;
mp: string | number;
targets: string;
duration: string;
}
type CheckMap = Record<string, boolean>;
// Saved/shared payloads use abbreviated keys (with legacy long-name
// fallbacks), so the shape is intentionally loose.
type SavedData = Record<string, any>;
const BLANK_FIELDS: Fields = {
charName: "",
charPronouns: "",
charIdentity: "",
charTheme: "",
charOrigin: "",
charTraits: "",
xpCurrent: 0,
zenit: 0,
initMod: 0,
defense: 0,
magDef: 0,
dexBase: 6,
dexCur: 6,
insBase: 6,
insCur: 6,
migBase: 6,
migCur: 6,
wlpBase: 6,
wlpCur: 6,
hpMax: "",
hpCur: "",
mpMax: "",
mpCur: "",
ipMax: 6,
ipCur: 6,
backpack: "",
heroicSkills: "",
ritualsNotes: "",
accName: "",
accDesc: "",
armName: "",
armDesc: "",
mhName: "",
mhDesc: "",
ohName: "",
ohDesc: "",
};
const BLANK_BONDS: Bond[] = Array.from({ length: 6 }, () => ({
name: "",
feelings: [],
}));
// URL-safe base64 (RFC 4648 §5): avoids +, /, = so the result needs no
// percent-encoding in a query string. Accepts standard base64 on decode too,
// so links shared before this change still load.
function bytesToBase64Url(bytes: Uint8Array) {
let binary = "";
for (let i = 0; i < bytes.length; i++)
binary += String.fromCharCode(bytes[i]);
return btoa(binary)
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
}
function base64UrlToBytes(b64: string) {
let std = b64.replace(/-/g, "+").replace(/_/g, "/");
if (std.length % 4) std += "=".repeat(4 - (std.length % 4));
return Uint8Array.from(atob(std), (c) => c.charCodeAt(0));
}
async function compressToBase64(str: string) {
const stream = new CompressionStream("deflate-raw");
const writer = stream.writable.getWriter();
writer.write(new TextEncoder().encode(str));
writer.close();
const buf = await new Response(stream.readable).arrayBuffer();
return bytesToBase64Url(new Uint8Array(buf));
}
async function decompressFromBase64(b64: string) {
try {
const bytes = base64UrlToBytes(b64);
const stream = new DecompressionStream("deflate-raw");
const writer = stream.writable.getWriter();
writer.write(bytes);
writer.close();
const buf = await new Response(stream.readable).arrayBuffer();
return new TextDecoder().decode(buf);
} catch {
return decodeURIComponent(escape(atob(b64)));
}
}
// Drop empty strings and empty arrays before encoding: a sheet is mostly blank
// text fields, and applyData() restores any missing key to its default.
function pruneEmpty(data: Record<string, unknown>) {
return Object.fromEntries(
Object.entries(data).filter(
([, v]) => v !== "" && !(Array.isArray(v) && v.length === 0),
),
);
}
// Resolve a share-service path relative to the current page so it works under
// any base path (e.g. /fabula/) where Caddy reverse-proxies /api/* to the
// backend. Returns e.g. https://host/fabula/api/s.
function apiURL(path: string) {
return new URL(path, window.location.href).toString();
}
// Copy text to the clipboard. navigator.clipboard is only available in a
// secure context (HTTPS or localhost); over plain HTTP it's undefined, so we
// fall back to the deprecated execCommand("copy"), which still works there.
// Must be called from within a user gesture (e.g. a click handler).
async function copyToClipboard(text: string) {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(text);
return;
}
const ta = document.createElement("textarea");
ta.value = text;
ta.style.position = "fixed";
ta.style.opacity = "0";
document.body.appendChild(ta);
ta.focus();
ta.select();
try {
document.execCommand("copy");
} finally {
document.body.removeChild(ta);
}
}
export default function CharacterSheet() {
const [activeTab, setActiveTab] = useState("main");
const [urlMode, setUrlMode] = useState(false);
const [saveStatus, setSaveStatus] = useState(false);
const [copyStatus, setCopyStatus] = useState(false);
const [level, setLevel] = useState(1);
const [fp, setFp] = useState(0);
const [fields, setFields] = useState<Fields>(BLANK_FIELDS);
const [bonds, setBonds] = useState<Bond[]>(BLANK_BONDS);
const [primaryClasses, setPrimaryClasses] = useState<ClassEntry[]>([]);
const [otherClasses, setOtherClasses] = useState<ClassEntry[]>([]);
const [spells, setSpells] = useState<Spell[]>([]);
const [statuses, setStatuses] = useState<CheckMap>({});
const [martial, setMartial] = useState<CheckMap>({});
const [disciplines, setDisciplines] = useState<CheckMap>({});
const [theme, setTheme] = useState<string>(
() =>
localStorage.getItem("fabulaUltimaTheme") ||
(window.matchMedia("(prefers-color-scheme: light)").matches
? "light"
: "dark"),
);
const importFileRef = useRef<HTMLInputElement>(null);
useEffect(() => {
document.documentElement.dataset.theme = theme;
localStorage.setItem("fabulaUltimaTheme", theme);
}, [theme]);
const f = useCallback(
<K extends keyof Fields>(key: K, val: Fields[K]) =>
setFields((prev) => ({ ...prev, [key]: val })),
[],
);
const xp = parseInt(String(fields.xpCurrent)) || 0;
const xpPct = Math.min((xp % 10) * 10, 100);
const hpMax = parseInt(String(fields.hpMax)) || 0;
const hpCur = parseInt(String(fields.hpCur)) || 0;
const inCrisis = hpMax > 0 && hpCur <= Math.floor(hpMax / 2);
const fpTotal = Math.max(10, fp);
const collectData = useCallback(
() => ({
n: fields.charName,
pn: fields.charPronouns,
id: fields.charIdentity,
th: fields.charTheme,
og: fields.charOrigin,
tr: fields.charTraits,
lv: level,
xp: fields.xpCurrent,
z: fields.zenit,
im: fields.initMod,
df: fields.defense,
md: fields.magDef,
dxb: fields.dexBase,
dxc: fields.dexCur,
inb: fields.insBase,
inc: fields.insCur,
mgb: fields.migBase,
mgc: fields.migCur,
wpb: fields.wlpBase,
wpc: fields.wlpCur,
hx: fields.hpMax,
hc: fields.hpCur,
mx: fields.mpMax,
mc: fields.mpCur,
ix: fields.ipMax,
ic: fields.ipCur,
fp,
bp: fields.backpack,
acn: fields.accName,
acd: fields.accDesc,
amn: fields.armName,
amd: fields.armDesc,
mhn: fields.mhName,
mhd: fields.mhDesc,
ohn: fields.ohName,
ohd: fields.ohDesc,
hs: fields.heroicSkills,
rn: fields.ritualsNotes,
sa: STATUSES.filter((s) => statuses[s]),
ma: MARTIAL_ITEMS.filter((m) => martial[m]),
da: DISCIPLINES.filter((d) => disciplines[d]),
bo: bonds.map((b) => ({ n: b.name, f: b.feelings })),
pc: primaryClasses.map((c) => ({
n: c.name,
b: c.benefits,
s: c.skills,
})),
oc: otherClasses.map((c) => ({ n: c.name, b: c.benefits, s: c.skills })),
sp: spells.map((s) => ({
n: s.name,
nt: s.notes,
mp: s.mp,
tg: s.targets,
dr: s.duration,
})),
}),
[
fields,
level,
fp,
bonds,
primaryClasses,
otherClasses,
spells,
statuses,
martial,
disciplines,
],
);
const applyData = useCallback((d: SavedData) => {
setFields({
charName: d.n ?? d.name ?? "",
charPronouns: d.pn ?? d.pronouns ?? "",
charIdentity: d.id ?? d.identity ?? "",
charTheme: d.th ?? d.theme ?? "",
charOrigin: d.og ?? d.origin ?? "",
charTraits: d.tr ?? d.traits ?? "",
xpCurrent: d.xp ?? 0,
zenit: d.z ?? d.zenit ?? 0,
initMod: d.im ?? d.initMod ?? 0,
defense: d.df ?? d.defense ?? 0,
magDef: d.md ?? d.magDef ?? 0,
dexBase: d.dxb ?? d.dexBase ?? 6,
dexCur: d.dxc ?? d.dexCur ?? 6,
insBase: d.inb ?? d.insBase ?? 6,
insCur: d.inc ?? d.insCur ?? 6,
migBase: d.mgb ?? d.migBase ?? 6,
migCur: d.mgc ?? d.migCur ?? 6,
wlpBase: d.wpb ?? d.wlpBase ?? 6,
wlpCur: d.wpc ?? d.wlpCur ?? 6,
hpMax: d.hx ?? d.hpMax ?? "",
hpCur: d.hc ?? d.hpCur ?? "",
mpMax: d.mx ?? d.mpMax ?? "",
mpCur: d.mc ?? d.mpCur ?? "",
ipMax: d.ix ?? d.ipMax ?? 6,
ipCur: d.ic ?? d.ipCur ?? 6,
backpack: d.bp ?? d.backpack ?? "",
accName: d.acn ?? d.accName ?? "",
accDesc: d.acd ?? d.accDesc ?? "",
armName: d.amn ?? d.armName ?? "",
armDesc: d.amd ?? d.armDesc ?? "",
mhName: d.mhn ?? d.mhName ?? "",
mhDesc: d.mhd ?? d.mhDesc ?? "",
ohName: d.ohn ?? d.ohName ?? "",
ohDesc: d.ohd ?? d.ohDesc ?? "",
heroicSkills: d.hs ?? d.heroicSkills ?? "",
ritualsNotes: d.rn ?? d.ritualsNotes ?? "",
});
setLevel(d.lv ?? d.level ?? 1);
setFp(parseInt(d.fp) || 0);
const sa = d.sa ?? d.statusesActive ?? [];
setStatuses(Object.fromEntries(STATUSES.map((s) => [s, sa.includes(s)])));
const ma = d.ma ?? d.martialChecked ?? [];
setMartial(
Object.fromEntries(MARTIAL_ITEMS.map((m) => [m, ma.includes(m)])),
);
const da = d.da ?? d.disciplinesChecked ?? [];
setDisciplines(
Object.fromEntries(DISCIPLINES.map((disc) => [disc, da.includes(disc)])),
);
const rawBonds = d.bo ?? d.bonds;
if (rawBonds)
setBonds(
rawBonds.map((b: SavedData) => ({
name: b.n ?? b.name ?? "",
feelings: b.f ?? b.feelings ?? [],
})),
);
const rawPrimary = d.pc ?? d.primaryClasses;
if (rawPrimary)
setPrimaryClasses(
rawPrimary.map((c: SavedData) => ({
name: c.n ?? c.name ?? "",
benefits: c.b ?? c.benefits ?? "",
skills: c.s ?? c.skills ?? "",
})),
);
const rawOther = d.oc ?? d.otherClasses;
if (rawOther)
setOtherClasses(
rawOther.map((c: SavedData) => ({
name: c.n ?? c.name ?? "",
benefits: c.b ?? c.benefits ?? "",
skills: c.s ?? c.skills ?? "",
})),
);
const rawSpells = d.sp ?? d.spells;
if (rawSpells)
setSpells(
rawSpells.map((s: SavedData) => ({
name: s.n ?? s.name ?? "",
notes: s.nt ?? s.notes ?? "",
mp: s.mp ?? "",
targets: s.tg ?? s.targets ?? "",
duration: s.dr ?? s.duration ?? "",
})),
);
}, []);
// Init
useEffect(() => {
const params = new URLSearchParams(window.location.search);
const shortId = params.get("s");
const encoded = params.get("c");
if (shortId) {
// Short link: fetch the sheet JSON from the share service.
fetch(apiURL("api/s/" + encodeURIComponent(shortId)))
.then((res) => {
if (!res.ok) throw new Error(String(res.status));
return res.json();
})
.then((data) => {
applyData(data);
setUrlMode(true);
})
.catch(() => tryAutoLoad());
} else if (encoded) {
// Legacy/offline self-contained link with the data inline.
decompressFromBase64(encoded)
.then((json) => {
applyData(JSON.parse(json));
setUrlMode(true);
})
.catch(() => tryAutoLoad());
} else {
tryAutoLoad();
}
function tryAutoLoad() {
const raw = localStorage.getItem("fabulaUltimaSheet");
if (raw)
try {
applyData(JSON.parse(raw));
} catch (e) {}
}
}, []); // eslint-disable-line react-hooks/exhaustive-deps
// Auto-save every 30s
useEffect(() => {
const id = setInterval(() => {
if (urlMode) return;
localStorage.setItem("fabulaUltimaSheet", JSON.stringify(collectData()));
}, 30000);
return () => clearInterval(id);
}, [urlMode, collectData]);
const saveSheet = useCallback(() => {
if (urlMode) return;
localStorage.setItem("fabulaUltimaSheet", JSON.stringify(collectData()));
setSaveStatus(true);
setTimeout(() => setSaveStatus(false), 2000);
}, [urlMode, collectData]);
const loadSheet = useCallback(() => {
const raw = localStorage.getItem("fabulaUltimaSheet");
if (!raw) return alert("No saved sheet found.");
try {
applyData(JSON.parse(raw));
} catch {
alert("Could not load sheet.");
}
}, [applyData]);
const exportSheet = useCallback(() => {
const data = collectData();
const blob = new Blob([JSON.stringify(data, null, 2)], {
type: "application/json",
});
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download =
((data.n || "character").replace(/[^a-z0-9_\- ]/gi, "").trim() ||
"character") + "-fabula-ultima.json";
a.click();
URL.revokeObjectURL(url);
}, [collectData]);
const handleImportFile = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (ev) => {
try {
applyData(JSON.parse(ev.target?.result as string));
saveSheet();
} catch {
alert("Could not import: invalid JSON file.");
}
};
reader.readAsText(file);
},
[applyData, saveSheet],
);
const copyShareURL = useCallback(async () => {
const json = JSON.stringify(pruneEmpty(collectData()));
const base = window.location.origin + window.location.pathname;
let shareURL: string;
try {
// Preferred: store the sheet server-side and share a short ?s=<id> link.
const res = await fetch(apiURL("api/s"), {
method: "POST",
headers: { "Content-Type": "application/json" },
body: json,
});
if (!res.ok) throw new Error(String(res.status));
const { id } = await res.json();
shareURL = base + "?s=" + id;
} catch {
// Backend unreachable: fall back to a self-contained inline ?c= link.
shareURL = base + "?c=" + (await compressToBase64(json));
}
await copyToClipboard(shareURL);
setCopyStatus(true);
setTimeout(() => setCopyStatus(false), 2000);
}, [collectData]);
const calcHP = useCallback(() => {
const mig = parseInt(String(fields.migBase)) || 6;
const max = mig * 5 + level;
setFields((prev) => ({ ...prev, hpMax: max, hpCur: prev.hpCur || max }));
}, [fields.migBase, level]);
const calcMP = useCallback(() => {
const wlp = parseInt(String(fields.wlpBase)) || 6;
const max = wlp * 5 + level;
setFields((prev) => ({ ...prev, mpMax: max, mpCur: prev.mpCur || max }));
}, [fields.wlpBase, level]);
const toggleStatus = (s: string) =>
setStatuses((prev) => ({ ...prev, [s]: !prev[s] }));
const toggleMartial = (m: string) =>
setMartial((prev) => ({ ...prev, [m]: !prev[m] }));
const toggleDisc = (d: string) =>
setDisciplines((prev) => ({ ...prev, [d]: !prev[d] }));
const toggleFeeling = (bondIdx: number, feeling: string) => {
setBonds((prev) =>
prev.map((b, i) => {
if (i !== bondIdx) return b;
const has = b.feelings.includes(feeling);
return {
...b,
feelings: has
? b.feelings.filter((f) => f !== feeling)
: [...b.feelings, feeling],
};
}),
);
};
return (
<>
<header>
<div className="logo">Fabula Ultima</div>
<div className="tabs">
{["main", "classes", "spells", "manage"].map((tab, i) => (
<button
key={tab}
className={`tab${activeTab === tab ? " active" : ""}`}
onClick={() => setActiveTab(tab)}
>
{["Character", "Classes", "Arcana & Spells", "Manage"][i]}
</button>
))}
</div>
<div className="toolbar">
<span className={`save-status${saveStatus ? " show" : ""}`}>
Saved!
</span>
<button className="btn-print" onClick={() => window.print()}>
Print
</button>
<button
className="btn-theme"
onClick={() => setTheme((t) => (t === "light" ? "dark" : "light"))}
>
{theme === "light" ? "☾ Dark" : "☀ Light"}
</button>
</div>
</header>
{urlMode && (
<div className="url-banner">
<span className="url-banner-icon"></span> Viewing a shared character
auto-save is disabled. Use <strong>Manage Save to Browser</strong>{" "}
to keep any changes.
</div>
)}
{/* ── PAGE 1: MAIN ── */}
<div
className={`page${activeTab === "main" ? " 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>Name</label>
<input
type="text"
value={fields.charName}
onChange={(e) => f("charName", e.target.value)}
placeholder="Character name…"
/>
</div>
<div className="field">
<label>Pronouns</label>
<input
type="text"
value={fields.charPronouns}
onChange={(e) => f("charPronouns", e.target.value)}
placeholder="they/them"
/>
</div>
</div>
<div className="field">
<label>Identity</label>
<input
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>Theme</label>
<input
type="text"
value={fields.charTheme}
onChange={(e) => f("charTheme", e.target.value)}
placeholder="Your theme…"
/>
</div>
<div className="field">
<label>Origin</label>
<input
type="text"
value={fields.charOrigin}
onChange={(e) => f("charOrigin", e.target.value)}
placeholder="Where from?"
/>
</div>
</div>
<div className="field">
<label>Traits (comma-separated)</label>
<textarea
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
className="add-btn"
style={{
marginTop: 10,
width: "100%",
justifyContent: "center",
}}
onClick={() => setLevel((l) => Math.min(50, l + 1))}
>
+ Level Up
</button>
<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>Experience Points (XP)</label>
<input
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>Zenit (currency)</label>
<input
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>Initiative Mod</label>
<input
type="number"
value={fields.initMod}
onChange={(e) => f("initMod", e.target.value)}
/>
</div>
<div className="def-block">
<label>Defense</label>
<input
type="number"
value={fields.defense}
onChange={(e) => f("defense", e.target.value)}
/>
</div>
<div className="def-block">
<label>Magic Defense</label>
<input
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
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
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) => (
<div
key={feeling}
className={`bond-feeling${bond.feelings.includes(feeling) ? " active" : ""}`}
onClick={() => toggleFeeling(idx, feeling)}
>
<div className="bond-feeling-box">
{bond.feelings.includes(feeling) ? "✓" : ""}
</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 }}
/>
</div>
</div>
</div>
{/* ── PAGE 2: CLASSES ── */}
<div
className={`page${activeTab === "classes" ? " 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 levels
each)
</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 || ""}
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",
justifyContent: "flex-end",
}}
>
<button
className="spell-del-btn"
onClick={() =>
setPrimaryClasses((prev) =>
prev.filter((_, i) => i !== idx),
)
}
>
Remove
</button>
</div>
</div>
))}
<button
className="add-btn"
onClick={() =>
setPrimaryClasses((prev) => [
...prev,
{ name: "", 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",
justifyContent: "flex-end",
}}
>
<button
className="spell-del-btn"
onClick={() =>
setOtherClasses((prev) =>
prev.filter((_, i) => i !== idx),
)
}
>
Remove
</button>
</div>
</div>
))}
<button
className="add-btn"
onClick={() =>
setOtherClasses((prev) => [
...prev,
{ name: "", benefits: "", skills: "" },
])
}
>
+ Add Class
</button>
</div>
</div>
<div className="section">
<div className="section-title">
<span className="icon"></span> Heroic Skills
</div>
<textarea
value={fields.heroicSkills}
onChange={(e) => f("heroicSkills", e.target.value)}
placeholder="Record your heroic skill abilities here…"
style={{ minHeight: 100 }}
/>
</div>
</div>
{/* ── PAGE 3: ARCANA & SPELLS ── */}
<div
className={`page${activeTab === "spells" ? " 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-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) => (
<tr key={i}>
<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,
),
)
}
/>
<textarea
placeholder="Notes / effect description…"
value={s.notes || ""}
onChange={(e) =>
setSpells((prev) =>
prev.map((sp, j) =>
j === i ? { ...sp, notes: e.target.value } : sp,
),
)
}
/>
</td>
<td className="spell-mp-col">
<input
type="number"
placeholder="0"
value={s.mp || ""}
onChange={(e) =>
setSpells((prev) =>
prev.map((sp, j) =>
j === i ? { ...sp, mp: e.target.value } : sp,
),
)
}
style={{ minHeight: 32 }}
/>
</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
className="spell-del-btn"
onClick={() =>
setSpells((prev) => prev.filter((_, j) => j !== i))
}
>
</button>
</td>
</tr>
))}
</tbody>
</table>
<button
className="add-btn"
style={{ marginTop: 10 }}
onClick={() =>
setSpells((prev) => [
...prev,
{ name: "", notes: "", mp: "", targets: "", duration: "" },
])
}
>
+ Add Spell / Arcana
</button>
</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={fields.ritualsNotes}
onChange={(e) => f("ritualsNotes", e.target.value)}
placeholder="Record ritual details, components, and notes here…"
style={{ minHeight: 120 }}
/>
</div>
</div>
{/* ── PAGE 4: MANAGE ── */}
<div
className={`page${activeTab === "manage" ? " 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 className="btn-save btn-lg" onClick={saveSheet}>
✦ Save to Browser
</button>
<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
className="btn-save btn-export btn-lg"
onClick={exportSheet}
>
↓ Export JSON
</button>
<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
className="btn-save btn-export btn-lg"
onClick={copyShareURL}
>
Copy URL
</button>
<span className={`save-status${copyStatus ? " show" : ""}`}>
Copied!
</span>
</div>
</div>
</div>
</div>
</>
);
}