Some checks failed
Deploy / deploy (push) Failing after 44s
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>
1538 lines
50 KiB
TypeScript
1538 lines
50 KiB
TypeScript
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 & 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 & 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 & 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 & 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 & 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>
|
||
</>
|
||
);
|
||
}
|