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; // Saved/shared payloads use abbreviated keys (with legacy long-name // fallbacks), so the shape is intentionally loose. type SavedData = Record; 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) { 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(BLANK_FIELDS); const [bonds, setBonds] = useState(BLANK_BONDS); const [primaryClasses, setPrimaryClasses] = useState([]); const [otherClasses, setOtherClasses] = useState([]); const [spells, setSpells] = useState([]); const [statuses, setStatuses] = useState({}); const [martial, setMartial] = useState({}); const [disciplines, setDisciplines] = useState({}); const [theme, setTheme] = useState( () => localStorage.getItem("fabulaUltimaTheme") || (window.matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark"), ); const importFileRef = useRef(null); useEffect(() => { document.documentElement.dataset.theme = theme; localStorage.setItem("fabulaUltimaTheme", theme); }, [theme]); const f = useCallback( (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) => { 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= 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 ( <>
Fabula Ultima
{["main", "classes", "spells", "manage"].map((tab, i) => ( ))}
Saved!
{urlMode && (
Viewing a shared character — auto-save is disabled. Use Manage → Save to Browser{" "} to keep any changes.
)} {/* ── PAGE 1: MAIN ── */}
{/* Row 1: Identity + Level */}
Identity & Traits
f("charName", e.target.value)} placeholder="Character name…" />
f("charPronouns", e.target.value)} placeholder="they/them" />
f("charIdentity", e.target.value)} placeholder="Who are you?" />
f("charTheme", e.target.value)} placeholder="Your theme…" />
f("charOrigin", e.target.value)} placeholder="Where from?" />