refactor: Convert main page to React

This commit is contained in:
2026-06-11 02:53:25 +00:00
parent e1efd7dd37
commit 26d7a4a7fc
5 changed files with 1393 additions and 1181 deletions

View File

@@ -1,563 +0,0 @@
<!doctype html>
<html lang="en" data-theme="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Fabula Ultima — Character Sheet</title>
<link
href="https://fonts.googleapis.com/css2?family=Cinzel:wght@400;600;700&family=Crimson+Text:ital,wght@0,400;0,600;1,400&family=Inconsolata:wght@400;600&display=swap"
rel="stylesheet" />
<script>
(function () {
var t =
localStorage.getItem("fabulaUltimaTheme") ||
(window.matchMedia("(prefers-color-scheme: light)").matches
? "light"
: "dark");
document.documentElement.dataset.theme = t;
})();
</script>
</head>
<body>
<header>
<div class="logo">Fabula Ultima</div>
<div class="tabs">
<button class="tab active" onclick="switchTab('main')">
Character
</button>
<button class="tab" onclick="switchTab('classes')">Classes</button>
<button class="tab" onclick="switchTab('spells')">
Arcana & Spells
</button>
<button class="tab" onclick="switchTab('manage')">Manage</button>
</div>
<div class="toolbar">
<span class="save-status" id="saveStatus">Saved!</span>
<button class="btn-theme" id="themeToggle" onclick="toggleTheme()">
☀ Light
</button>
</div>
</header>
<div id="urlBanner" class="url-banner" style="display: none">
<span class="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 CHARACTER
══════════════════════════════════════════════════ -->
<div class="page active" id="page-main">
<!-- Row 1: Identity + Level -->
<div class="grid-2" style="margin-bottom: 20px">
<!-- Identity -->
<div class="section">
<div class="section-title">
<span class="icon"></span> Identity & Traits
</div>
<div class="field-row">
<div class="field" style="flex: 2">
<label>Name</label>
<input type="text" id="charName" placeholder="Character name…" />
</div>
<div class="field">
<label>Pronouns</label>
<input type="text" id="charPronouns" placeholder="they/them" />
</div>
</div>
<div class="field">
<label>Identity</label>
<input type="text" id="charIdentity" placeholder="Who are you?" />
</div>
<div class="field-row">
<div class="field">
<label>Theme</label>
<input type="text" id="charTheme" placeholder="Your theme…" />
</div>
<div class="field">
<label>Origin</label>
<input type="text" id="charOrigin" placeholder="Where from?" />
</div>
</div>
<div class="field">
<label>Traits (comma-separated)</label>
<textarea id="charTraits" placeholder="Brave, Reckless, Loyal to a fault…"
style="min-height: 55px"></textarea>
</div>
</div>
<!-- Level + XP + Defenses -->
<div style="display: flex; flex-direction: column; gap: 16px">
<div class="section">
<div class="section-title">
<span class="icon"></span> Level & Experience
</div>
<div class="grid-2" style="gap: 14px">
<div class="level-display">
<span class="level-num" id="levelDisplay">1</span>
<span class="level-text">Character Level</span>
<button class="add-btn" style="margin-top: 10px; width: 100%; justify-content: center"
onclick="adjustLevel(1)">
+ Level Up
</button>
<button class="add-btn" style="
margin-top: 4px;
width: 100%;
justify-content: center;
border-color: var(--border-bright);
color: var(--text-dim);
" onclick="adjustLevel(-1)">
Level Down
</button>
</div>
<div>
<div class="field">
<label>Experience Points (XP)</label>
<input type="number" id="xpCurrent" value="0" min="0" oninput="updateXPBar()" />
</div>
<div class="xp-bar-wrap">
<div class="xp-bar" id="xpBar" style="width: 0%"></div>
</div>
<div class="xp-label">
<span id="xpVal">0 XP</span>
<span>10 XP = Level</span>
</div>
<div class="field" style="margin-top: 12px">
<label>Zenit (currency)</label>
<input type="number" id="zenit" value="0" min="0" />
</div>
</div>
</div>
</div>
<div class="section">
<div class="section-title">
<span class="icon"></span> Defenses
</div>
<div class="def-row">
<div class="def-block">
<label>Initiative Mod</label>
<input type="number" id="initMod" value="0" />
</div>
<div class="def-block">
<label>Defense</label>
<input type="number" id="defense" value="0" />
</div>
<div class="def-block">
<label>Magic Defense</label>
<input type="number" id="magDef" value="0" />
</div>
</div>
</div>
</div>
</div>
<!-- Row 2: Attributes + Status + HP/MP/IP -->
<div class="grid-3" style="margin-bottom: 20px">
<!-- Attributes -->
<div class="section">
<div class="section-title">
<span class="icon"></span> Attributes
</div>
<div class="attr-grid">
<div class="attr-block">
<div class="attr-name">Dexterity</div>
<div class="attr-inputs">
<div style="flex: 1">
<label style="font-size: 0.48rem">Base</label>
<input type="number" id="dex-base" value="6" min="6" max="12" oninput="recalcStats()" />
</div>
<div class="attr-sep"></div>
<div style="flex: 1">
<label style="font-size: 0.48rem">Current</label>
<input type="number" id="dex-cur" value="6" min="6" max="12" />
</div>
</div>
</div>
<div class="attr-block">
<div class="attr-name">Insight</div>
<div class="attr-inputs">
<div style="flex: 1">
<label style="font-size: 0.48rem">Base</label>
<input type="number" id="ins-base" value="6" min="6" max="12" oninput="recalcStats()" />
</div>
<div class="attr-sep"></div>
<div style="flex: 1">
<label style="font-size: 0.48rem">Current</label>
<input type="number" id="ins-cur" value="6" min="6" max="12" />
</div>
</div>
</div>
<div class="attr-block">
<div class="attr-name">Might</div>
<div class="attr-inputs">
<div style="flex: 1">
<label style="font-size: 0.48rem">Base</label>
<input type="number" id="mig-base" value="6" min="6" max="12" oninput="recalcStats()" />
</div>
<div class="attr-sep"></div>
<div style="flex: 1">
<label style="font-size: 0.48rem">Current</label>
<input type="number" id="mig-cur" value="6" min="6" max="12" />
</div>
</div>
</div>
<div class="attr-block">
<div class="attr-name">Willpower</div>
<div class="attr-inputs">
<div style="flex: 1">
<label style="font-size: 0.48rem">Base</label>
<input type="number" id="wlp-base" value="6" min="6" max="12" oninput="recalcStats()" />
</div>
<div class="attr-sep"></div>
<div style="flex: 1">
<label style="font-size: 0.48rem">Current</label>
<input type="number" id="wlp-cur" value="6" min="6" max="12" />
</div>
</div>
</div>
</div>
</div>
<!-- Status Effects -->
<div class="section">
<div class="section-title">
<span class="icon"></span> Status Effects
</div>
<div class="status-grid" id="statusGrid">
<!-- generated by JS -->
</div>
</div>
<!-- HP / MP / IP -->
<div class="section">
<div class="section-title">
<span class="icon"></span> Hit, Mind & Inventory Points
</div>
<div class="vital-block">
<div class="vital-label hp">HP</div>
<div style="flex: 1">
<div class="vital-formula">MIG×5 + Level + Other</div>
<div class="vital-inputs">
<input type="number" id="hpCur" placeholder="Cur" oninput="updateCrisis()" />
<div class="vital-sep">/</div>
<input type="number" id="hpMax" placeholder="Max" oninput="updateCrisis()" />
<div class="crisis-badge" id="crisisBadge" style="display: none">
CRISIS
</div>
</div>
</div>
<button class="add-btn" style="padding: 4px 8px" onclick="calcHP()" title="Auto-calculate from Might">
Calc
</button>
</div>
<div class="vital-block">
<div class="vital-label mp">MP</div>
<div style="flex: 1">
<div class="vital-formula">WLP×5 + Level + Other</div>
<div class="vital-inputs">
<input type="number" id="mpCur" placeholder="Cur" />
<div class="vital-sep">/</div>
<input type="number" id="mpMax" placeholder="Max" />
</div>
</div>
<button class="add-btn" style="padding: 4px 8px" onclick="calcMP()" title="Auto-calculate from Willpower">
Calc
</button>
</div>
<div class="vital-block">
<div class="vital-label ip">IP</div>
<div style="flex: 1">
<div class="vital-formula">6 + Other</div>
<div class="vital-inputs">
<input type="number" id="ipCur" value="6" placeholder="Cur" />
<div class="vital-sep">/</div>
<input type="number" id="ipMax" value="6" placeholder="Max" />
</div>
</div>
</div>
</div>
</div>
<!-- Row 3: Fabula Points + Bonds -->
<div class="grid-2" style="margin-bottom: 20px">
<!-- Fabula Points -->
<div class="section">
<div class="section-title">
<span class="icon"></span> Fabula Points
</div>
<div style="
display: flex;
align-items: center;
gap: 14px;
flex-wrap: wrap;
">
<div>
<label>Current FP</label>
<input type="number" id="fpCount" value="0" min="0" max="20" style="
width: 70px;
text-align: center;
font-size: 1.4rem;
font-family: var(--font-mono);
" oninput="renderFP()" />
</div>
<div class="fp-pips" id="fpPips"></div>
</div>
<div class="fp-rules">
<div class="fp-rule">
<strong>+1 FP</strong> if you have none at start of session.
</div>
<div class="fp-rule">
<strong>+1 FP</strong> when a Villain makes an entrance.
</div>
<div class="fp-rule">
<strong>+1 FP</strong> when you fumble a Check.
</div>
<div class="fp-rule">
<strong>+2 FP</strong> if you surrender at zero HP.
</div>
<div class="fp-rule" style="margin-top: 6px">
<strong>Spend 1 FP</strong> to invoke a trait: reroll one or both
dice.
</div>
<div class="fp-rule">
<strong>Spend 1 FP</strong> to invoke a bond: add its strength to
the result.
</div>
<div class="fp-rule">
<strong>Spend 1 FP</strong> to alter the story.
</div>
</div>
</div>
<!-- Bonds -->
<div class="section">
<div class="section-title"><span class="icon"></span> Bonds</div>
<div id="bondsContainer"></div>
</div>
</div>
<!-- Row 4: Equipment + Backpack -->
<div class="grid-2" style="margin-bottom: 20px">
<!-- Equipment -->
<div class="section">
<div class="section-title"><span class="icon"></span> Equipment</div>
<div class="martial-row" id="martialRow">
<div class="martial-item" onclick="toggleMartial(this)">
<div class="martial-box"></div>
<span>Martial Armor</span>
</div>
<div class="martial-item" onclick="toggleMartial(this)">
<div class="martial-box"></div>
<span>Martial Shields</span>
</div>
<div class="martial-item" onclick="toggleMartial(this)">
<div class="martial-box"></div>
<span>Martial Melee</span>
</div>
<div class="martial-item" onclick="toggleMartial(this)">
<div class="martial-box"></div>
<span>Martial Ranged</span>
</div>
</div>
<div style="margin-top: 14px">
<div class="equip-row">
<div class="equip-slot">Accessory</div>
<div class="equip-fields">
<input type="text" id="acc-name" placeholder="Item name" />
<input type="text" id="acc-desc" placeholder="Description / effect" />
</div>
</div>
<div class="equip-row">
<div class="equip-slot">Armor</div>
<div class="equip-fields">
<input type="text" id="arm-name" placeholder="Item name" />
<input type="text" id="arm-desc" placeholder="Defense bonus / effect" />
</div>
</div>
<div class="equip-row">
<div class="equip-slot">Main Hand</div>
<div class="equip-fields">
<input type="text" id="mh-name" placeholder="Weapon name" />
<input type="text" id="mh-desc" placeholder="Damage / effect" />
</div>
</div>
<div class="equip-row">
<div class="equip-slot">Off-Hand</div>
<div class="equip-fields">
<input type="text" id="oh-name" placeholder="Weapon / shield" />
<input type="text" id="oh-desc" placeholder="Damage / effect" />
</div>
</div>
</div>
</div>
<!-- Backpack & Notes -->
<div class="section">
<div class="section-title">
<span class="icon"></span> Backpack & Notes
</div>
<textarea id="backpack" placeholder="Items, notes, lore…" style="min-height: 200px"></textarea>
</div>
</div>
</div>
<!-- ══════════════════════════════════════════════════
PAGE 2: CLASSES
══════════════════════════════════════════════════ -->
<div class="page" id="page-classes">
<div class="grid-2" style="margin-bottom: 20px">
<div class="section">
<div class="section-title">
<span class="icon"></span> Primary Classes (up to 3 levels each)
</div>
<div id="primaryClassContainer"></div>
<button class="add-btn" onclick="addPrimaryClass()">
+ Add Primary Class
</button>
</div>
<div class="section">
<div class="section-title">
<span class="icon"></span> Other Classes (max 3 non-mastered)
</div>
<div id="otherClassContainer"></div>
<button class="add-btn" onclick="addOtherClass()">+ Add Class</button>
</div>
</div>
<div class="section">
<div class="section-title">
<span class="icon"></span> Heroic Skills
</div>
<textarea id="heroicSkills" placeholder="Record your heroic skill abilities here…"
style="min-height: 100px"></textarea>
</div>
</div>
<!-- ══════════════════════════════════════════════════
PAGE 3: ARCANA & SPELLS
══════════════════════════════════════════════════ -->
<div class="page" id="page-spells">
<div class="section" style="margin-bottom: 20px">
<div class="section-title">
<span class="icon"></span> Arcana & Spells
</div>
<table class="spells-table" id="spellsTable">
<thead>
<tr>
<th class="spell-name-col">Name / Notes</th>
<th class="spell-mp-col">MP Cost</th>
<th class="spell-targets-col">Targets</th>
<th class="spell-dur-col">Duration</th>
<th class="spell-del-col"></th>
</tr>
</thead>
<tbody id="spellsBody"></tbody>
</table>
<button class="add-btn" onclick="addSpell()" style="margin-top: 10px">
+ Add Spell / Arcana
</button>
</div>
<div class="section">
<div class="section-title"><span class="icon"></span> Rituals</div>
<div class="disciplines-row" id="disciplinesRow">
<div class="disc-item" onclick="toggleDisc(this)">
<div class="disc-box"></div>
<span>Arcanism</span>
</div>
<div class="disc-item" onclick="toggleDisc(this)">
<div class="disc-box"></div>
<span>Chimerism</span>
</div>
<div class="disc-item" onclick="toggleDisc(this)">
<div class="disc-box"></div>
<span>Elementalism</span>
</div>
<div class="disc-item" onclick="toggleDisc(this)">
<div class="disc-box"></div>
<span>Entropism</span>
</div>
<div class="disc-item" onclick="toggleDisc(this)">
<div class="disc-box"></div>
<span>Ritualism</span>
</div>
<div class="disc-item" onclick="toggleDisc(this)">
<div class="disc-box"></div>
<span>Spiritism</span>
</div>
</div>
<textarea id="ritualsNotes" placeholder="Record ritual details, components, and notes here…"
style="min-height: 120px"></textarea>
</div>
</div>
<!-- ══════════════════════════════════════════════════
PAGE 4: MANAGE
══════════════════════════════════════════════════ -->
<div class="page" id="page-manage">
<div class="manage-grid">
<div class="section">
<div class="section-title">
<span class="icon"></span> Local Save
</div>
<p class="manage-desc">
Save your character sheet to your browser's local storage, or load a
previously saved sheet.
</p>
<div class="manage-btn-row">
<button class="btn-save btn-lg" onclick="saveSheet()">
✦ Save to Browser
</button>
<button class="btn-load btn-lg" onclick="loadSheet()">
↑ Load from Browser
</button>
</div>
</div>
<div class="section">
<div class="section-title"><span class="icon"></span> JSON File</div>
<p class="manage-desc">
Export your character to a JSON file for backup or sharing, or
import from a previously exported file.
</p>
<div class="manage-btn-row">
<button class="btn-save btn-export btn-lg" onclick="exportSheet()">
↓ Export JSON
</button>
<button class="btn-load btn-import btn-lg" onclick="importSheet()">
↑ Import JSON
</button>
<input type="file" id="importFileInput" accept=".json,application/json" style="display: none"
onchange="handleImportFile(this)" />
</div>
</div>
<div class="section col-span-2">
<div class="section-title">
<span class="icon"></span> Share via URL
</div>
<p class="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 class="manage-btn-row">
<button class="btn-save btn-export btn-lg" onclick="copyShareURL()">
⎘ Copy URL
</button>
<span class="save-status" id="copyStatus">Copied!</span>
</div>
</div>
</div>
</div>
</body>
</html>

View File

@@ -1,617 +0,0 @@
import "./fabula-ultima-sheet.css";
// ── STATE ──────────────────────────────────────────
let urlMode = false;
let level = 1;
let fpCount = 0;
let primaryClasses = [];
let otherClasses = [];
let spells = [];
let bonds = [
{ name: "", feelings: [] },
{ name: "", feelings: [] },
{ name: "", feelings: [] },
{ name: "", feelings: [] },
{ name: "", feelings: [] },
{ name: "", feelings: [] },
];
const STATUSES = ["Slow", "Enraged", "Dazed", "Weak", "Poisoned", "Shaken"];
const FEELINGS = [
"Admiration",
"Inferiority",
"Loyalty",
"Mistrust",
"Affection",
"Hatred",
];
// ── TABS ───────────────────────────────────────────
function switchTab(tab) {
document
.querySelectorAll(".tab")
.forEach((t) => t.classList.remove("active"));
document
.querySelectorAll(".page")
.forEach((p) => p.classList.remove("active"));
event.currentTarget.classList.add("active");
document.getElementById("page-" + tab).classList.add("active");
}
// ── INIT ───────────────────────────────────────────
async function init() {
const savedTheme =
localStorage.getItem("fabulaUltimaTheme") ||
(window.matchMedia("(prefers-color-scheme: light)").matches
? "light"
: "dark");
document.getElementById("themeToggle").textContent =
savedTheme === "light" ? "☾ Dark" : "☀ Light";
renderStatuses();
renderFP();
renderBonds();
renderPrimaryClasses();
renderOtherClasses();
renderSpells();
updateXPBar();
if (!(await tryLoadFromURL())) tryAutoLoad();
}
// ── URL STATE ──────────────────────────────────────
async function compressToBase64(str) {
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();
const bytes = new Uint8Array(buf);
let binary = "";
for (let i = 0; i < bytes.length; i++)
binary += String.fromCharCode(bytes[i]);
return btoa(binary);
}
async function decompressFromBase64(b64) {
try {
const bytes = Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
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 {
// Fall back to legacy uncompressed URLs
return decodeURIComponent(escape(atob(b64)));
}
}
async function tryLoadFromURL() {
const encoded = new URLSearchParams(window.location.search).get("c");
if (!encoded) return false;
try {
const json = await decompressFromBase64(encoded);
applyData(JSON.parse(json));
urlMode = true;
document.getElementById("urlBanner").style.display = "block";
return true;
} catch (e) {
console.warn("Could not load state from URL:", e);
return false;
}
}
// ── STATUS EFFECTS ─────────────────────────────────
function renderStatuses() {
const g = document.getElementById("statusGrid");
g.innerHTML = "";
STATUSES.forEach((s) => {
const el = document.createElement("div");
el.className = "status-item";
el.dataset.status = s;
el.onclick = () => {
el.classList.toggle("active-status");
el.querySelector(".status-check").textContent = el.classList.contains(
"active-status",
)
? "✗"
: "";
};
el.innerHTML = `<div class="status-check"></div><div class="status-label">${s}</div>`;
g.appendChild(el);
});
}
// ── FABULA POINTS ──────────────────────────────────
function renderFP() {
fpCount = parseInt(document.getElementById("fpCount").value) || 0;
const pips = document.getElementById("fpPips");
const total = Math.max(10, fpCount);
pips.innerHTML = "";
for (let i = 0; i < total; i++) {
const pip = document.createElement("div");
pip.className = "fp-pip" + (i < fpCount ? " filled" : "");
pip.onclick = () => {
const newVal = i < fpCount ? i : i + 1;
document.getElementById("fpCount").value = newVal;
renderFP();
};
pips.appendChild(pip);
}
}
// ── BONDS ──────────────────────────────────────────
function renderBonds() {
const container = document.getElementById("bondsContainer");
container.innerHTML = "";
bonds.forEach((bond, idx) => {
const el = document.createElement("div");
el.className = "bond-block";
el.innerHTML = `
<div class="bond-header">
<div class="bond-num">${idx + 1}</div>
<input type="text" placeholder="Bond target name…" value="${bond.name}" oninput="bonds[${idx}].name=this.value">
</div>
<div class="bond-feelings">
${FEELINGS.map(
(f) => `
<div class="bond-feeling${bond.feelings.includes(f) ? " active" : ""}" onclick="toggleFeeling(${idx},'${f}',this)">
<div class="bond-feeling-box">${bond.feelings.includes(f) ? "✓" : ""}</div>
<span>${f}</span>
</div>`,
).join("")}
</div>`;
container.appendChild(el);
});
}
function toggleFeeling(bondIdx, feeling, el) {
const bond = bonds[bondIdx];
if (bond.feelings.includes(feeling)) {
bond.feelings = bond.feelings.filter((f) => f !== feeling);
el.classList.remove("active");
el.querySelector(".bond-feeling-box").textContent = "";
} else {
bond.feelings.push(feeling);
el.classList.add("active");
el.querySelector(".bond-feeling-box").textContent = "✓";
}
}
// ── LEVEL ──────────────────────────────────────────
function adjustLevel(delta) {
level = Math.max(1, Math.min(50, level + delta));
document.getElementById("levelDisplay").textContent = level;
}
// ── XP BAR ─────────────────────────────────────────
function updateXPBar() {
const xp = parseInt(document.getElementById("xpCurrent").value) || 0;
const pct = Math.min((xp % 10) * 10, 100);
document.getElementById("xpBar").style.width = pct + "%";
document.getElementById("xpVal").textContent = xp + " XP";
}
// ── STAT CALC ──────────────────────────────────────
function recalcStats() {
/* auto-calc on keyup */
}
function calcHP() {
const mig = parseInt(document.getElementById("mig-base").value) || 6;
const max = mig * 5 + level;
document.getElementById("hpMax").value = max;
if (!document.getElementById("hpCur").value)
document.getElementById("hpCur").value = max;
updateCrisis();
}
function calcMP() {
const wlp = parseInt(document.getElementById("wlp-base").value) || 6;
const max = wlp * 5 + level;
document.getElementById("mpMax").value = max;
if (!document.getElementById("mpCur").value)
document.getElementById("mpCur").value = max;
}
function updateCrisis() {
const max = parseInt(document.getElementById("hpMax").value) || 0;
const cur = parseInt(document.getElementById("hpCur").value) || 0;
const badge = document.getElementById("crisisBadge");
badge.style.display =
max > 0 && cur <= Math.floor(max / 2) ? "block" : "none";
}
// ── MARTIAL ────────────────────────────────────────
function toggleMartial(el) {
el.classList.toggle("checked");
el.querySelector(".martial-box").textContent = el.classList.contains(
"checked",
)
? "✓"
: "";
}
// ── DISCIPLINES ────────────────────────────────────
function toggleDisc(el) {
el.classList.toggle("checked");
el.querySelector(".disc-box").textContent = el.classList.contains("checked")
? "✓"
: "";
}
// ── CLASSES ────────────────────────────────────────
function classBlockHTML(cls, idx, type) {
return `
<div class="class-block" id="${type}-class-${idx}">
<div class="class-header">
<input type="text" placeholder="Class name…" value="${cls.name || ""}" oninput="${type}Classes[${idx}].name=this.value">
<input type="text" placeholder="Free benefits…" value="${cls.benefits || ""}" oninput="${type}Classes[${idx}].benefits=this.value">
</div>
<div class="class-skills">
<textarea placeholder="Skill information…" oninput="${type}Classes[${idx}].skills=this.value">${cls.skills || ""}</textarea>
</div>
<div style="padding:6px 10px; border-top:1px solid var(--border); display:flex; justify-content:flex-end;">
<button class="spell-del-btn" onclick="removeClass('${type}',${idx})">✕ Remove</button>
</div>
</div>`;
}
function renderPrimaryClasses() {
document.getElementById("primaryClassContainer").innerHTML = primaryClasses
.map((c, i) => classBlockHTML(c, i, "primary"))
.join("");
}
function renderOtherClasses() {
document.getElementById("otherClassContainer").innerHTML = otherClasses
.map((c, i) => classBlockHTML(c, i, "other"))
.join("");
}
function addPrimaryClass() {
primaryClasses.push({});
renderPrimaryClasses();
}
function addOtherClass() {
otherClasses.push({});
renderOtherClasses();
}
function removeClass(type, idx) {
if (type === "primary") {
primaryClasses.splice(idx, 1);
renderPrimaryClasses();
} else {
otherClasses.splice(idx, 1);
renderOtherClasses();
}
}
// ── SPELLS ─────────────────────────────────────────
function renderSpells() {
const tbody = document.getElementById("spellsBody");
tbody.innerHTML = spells
.map(
(s, i) => `
<tr>
<td class="spell-name-col">
<input type="text" placeholder="Spell / Arcana name…" value="${s.name || ""}" oninput="spells[${i}].name=this.value">
<textarea placeholder="Notes / effect description…" oninput="spells[${i}].notes=this.value">${s.notes || ""}</textarea>
</td>
<td class="spell-mp-col"><input type="number" placeholder="0" value="${s.mp || ""}" oninput="spells[${i}].mp=this.value" style="min-height:32px;"></td>
<td class="spell-targets-col"><input type="text" placeholder="Target(s)…" value="${s.targets || ""}" oninput="spells[${i}].targets=this.value"></td>
<td class="spell-dur-col"><input type="text" placeholder="Duration…" value="${s.duration || ""}" oninput="spells[${i}].duration=this.value"></td>
<td class="spell-del-col"><button class="spell-del-btn" onclick="removeSpell(${i})">✕</button></td>
</tr>`,
)
.join("");
}
function addSpell() {
spells.push({});
renderSpells();
}
function removeSpell(i) {
spells.splice(i, 1);
renderSpells();
}
// ── SAVE / LOAD ────────────────────────────────────
// Short key legend (serialized form only; in-memory objects use full names):
// n=name, pn=pronouns, id=identity, th=theme, og=origin, tr=traits
// lv=level, xp=xp, z=zenit
// im=initMod, df=defense, md=magDef
// dxb=dexBase, dxc=dexCur, inb=insBase, inc=insCur
// mgb=migBase, mgc=migCur, wpb=wlpBase, wpc=wlpCur
// hx=hpMax, hc=hpCur, mx=mpMax, mc=mpCur, ix=ipMax, ic=ipCur
// fp=fp, bp=backpack
// acn=accName, acd=accDesc, amn=armName, amd=armDesc
// mhn=mhName, mhd=mhDesc, ohn=ohName, ohd=ohDesc
// hs=heroicSkills, rn=ritualsNotes
// sa=statusesActive, ma=martialChecked, da=disciplinesChecked
// bo=bonds, pc=primaryClasses, oc=otherClasses, sp=spells
// Nested bonds: n=name, f=feelings
// Nested classes: n=name, b=benefits, s=skills
// Nested spells: n=name, nt=notes, mp=mp, tg=targets, dr=duration
function collectData() {
const get = (id) => {
const el = document.getElementById(id);
return el ? el.value : "";
};
const sa = [...document.querySelectorAll(".status-item.active-status")].map(
(el) => el.dataset.status,
);
const ma = [...document.querySelectorAll(".martial-item.checked")].map(
(el) => el.querySelector("span").textContent,
);
const da = [...document.querySelectorAll(".disc-item.checked")].map(
(el) => el.querySelector("span").textContent,
);
return {
n: get("charName"),
pn: get("charPronouns"),
id: get("charIdentity"),
th: get("charTheme"),
og: get("charOrigin"),
tr: get("charTraits"),
lv: level,
xp: get("xpCurrent"),
z: get("zenit"),
im: get("initMod"),
df: get("defense"),
md: get("magDef"),
dxb: get("dex-base"),
dxc: get("dex-cur"),
inb: get("ins-base"),
inc: get("ins-cur"),
mgb: get("mig-base"),
mgc: get("mig-cur"),
wpb: get("wlp-base"),
wpc: get("wlp-cur"),
hx: get("hpMax"),
hc: get("hpCur"),
mx: get("mpMax"),
mc: get("mpCur"),
ix: get("ipMax"),
ic: get("ipCur"),
fp: get("fpCount"),
bp: get("backpack"),
acn: get("acc-name"),
acd: get("acc-desc"),
amn: get("arm-name"),
amd: get("arm-desc"),
mhn: get("mh-name"),
mhd: get("mh-desc"),
ohn: get("oh-name"),
ohd: get("oh-desc"),
hs: get("heroicSkills"),
rn: get("ritualsNotes"),
sa,
ma,
da,
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,
})),
};
}
function saveSheet(isAuto = false) {
if (isAuto && urlMode) return;
const data = collectData();
localStorage.setItem("fabulaUltimaSheet", JSON.stringify(data));
const st = document.getElementById("saveStatus");
st.classList.add("show");
setTimeout(() => st.classList.remove("show"), 2000);
}
function tryAutoLoad() {
const raw = localStorage.getItem("fabulaUltimaSheet");
if (raw)
try {
applyData(JSON.parse(raw));
} catch (e) {}
}
function loadSheet() {
const raw = localStorage.getItem("fabulaUltimaSheet");
if (!raw) return alert("No saved sheet found.");
try {
applyData(JSON.parse(raw));
} catch (e) {
alert("Could not load sheet.");
}
}
function applyData(d) {
const set = (id, val) => {
const el = document.getElementById(id);
if (el && val !== undefined) el.value = val;
};
set("charName", d.n ?? d.name);
set("charPronouns", d.pn ?? d.pronouns);
set("charIdentity", d.id ?? d.identity);
set("charTheme", d.th ?? d.theme);
set("charOrigin", d.og ?? d.origin);
set("charTraits", d.tr ?? d.traits);
level = d.lv ?? d.level ?? 1;
document.getElementById("levelDisplay").textContent = level;
set("xpCurrent", d.xp);
set("zenit", d.z ?? d.zenit);
set("initMod", d.im ?? d.initMod);
set("defense", d.df ?? d.defense);
set("magDef", d.md ?? d.magDef);
set("dex-base", d.dxb ?? d.dexBase);
set("dex-cur", d.dxc ?? d.dexCur);
set("ins-base", d.inb ?? d.insBase);
set("ins-cur", d.inc ?? d.insCur);
set("mig-base", d.mgb ?? d.migBase);
set("mig-cur", d.mgc ?? d.migCur);
set("wlp-base", d.wpb ?? d.wlpBase);
set("wlp-cur", d.wpc ?? d.wlpCur);
set("hpMax", d.hx ?? d.hpMax);
set("hpCur", d.hc ?? d.hpCur);
set("mpMax", d.mx ?? d.mpMax);
set("mpCur", d.mc ?? d.mpCur);
set("ipMax", d.ix ?? d.ipMax);
set("ipCur", d.ic ?? d.ipCur);
set("fpCount", d.fp);
set("backpack", d.bp ?? d.backpack);
set("acc-name", d.acn ?? d.accName);
set("acc-desc", d.acd ?? d.accDesc);
set("arm-name", d.amn ?? d.armName);
set("arm-desc", d.amd ?? d.armDesc);
set("mh-name", d.mhn ?? d.mhName);
set("mh-desc", d.mhd ?? d.mhDesc);
set("oh-name", d.ohn ?? d.ohName);
set("oh-desc", d.ohd ?? d.ohDesc);
set("heroicSkills", d.hs ?? d.heroicSkills);
set("ritualsNotes", d.rn ?? d.ritualsNotes);
// Statuses
const sa = d.sa ?? d.statusesActive ?? [];
document.querySelectorAll(".status-item").forEach((el) => {
const active = sa.includes(el.dataset.status);
el.classList.toggle("active-status", active);
el.querySelector(".status-check").textContent = active ? "✗" : "";
});
// Martial
const ma = d.ma ?? d.martialChecked ?? [];
document.querySelectorAll(".martial-item").forEach((el) => {
const checked = ma.includes(el.querySelector("span").textContent);
el.classList.toggle("checked", checked);
el.querySelector(".martial-box").textContent = checked ? "✓" : "";
});
// Disciplines
const da = d.da ?? d.disciplinesChecked ?? [];
document.querySelectorAll(".disc-item").forEach((el) => {
const checked = da.includes(el.querySelector("span").textContent);
el.classList.toggle("checked", checked);
el.querySelector(".disc-box").textContent = checked ? "✓" : "";
});
const rawBonds = d.bo ?? d.bonds;
if (rawBonds) {
bonds = rawBonds.map((b) => ({
name: b.n ?? b.name,
feelings: b.f ?? b.feelings ?? [],
}));
renderBonds();
}
const rawPrimary = d.pc ?? d.primaryClasses;
if (rawPrimary) {
primaryClasses = rawPrimary.map((c) => ({
name: c.n ?? c.name,
benefits: c.b ?? c.benefits,
skills: c.s ?? c.skills,
}));
renderPrimaryClasses();
}
const rawOther = d.oc ?? d.otherClasses;
if (rawOther) {
otherClasses = rawOther.map((c) => ({
name: c.n ?? c.name,
benefits: c.b ?? c.benefits,
skills: c.s ?? c.skills,
}));
renderOtherClasses();
}
const rawSpells = d.sp ?? d.spells;
if (rawSpells) {
spells = rawSpells.map((s) => ({
name: s.n ?? s.name,
notes: s.nt ?? s.notes,
mp: s.mp,
targets: s.tg ?? s.targets,
duration: s.dr ?? s.duration,
}));
renderSpells();
}
renderFP();
updateXPBar();
updateCrisis();
}
// ── EXPORT / IMPORT JSON ───────────────────────────
function exportSheet() {
const data = collectData();
const json = JSON.stringify(data, null, 2);
const blob = new Blob([json], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
const charName =
(data.n || "character").replace(/[^a-z0-9_\- ]/gi, "").trim() ||
"character";
a.href = url;
a.download = charName + "-fabula-ultima.json";
a.click();
URL.revokeObjectURL(url);
}
function importSheet() {
document.getElementById("importFileInput").value = "";
document.getElementById("importFileInput").click();
}
function handleImportFile(input) {
const file = input.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (e) => {
try {
const data = JSON.parse(e.target.result);
applyData(data);
saveSheet();
const st = document.getElementById("saveStatus");
st.textContent = "Imported!";
st.classList.add("show");
setTimeout(() => {
st.classList.remove("show");
st.textContent = "Saved!";
}, 2500);
} catch (err) {
alert("Could not import: invalid JSON file.");
}
};
reader.readAsText(file);
}
async function copyShareURL() {
const json = JSON.stringify(collectData());
const encoded = await compressToBase64(json);
const url =
window.location.origin +
window.location.pathname +
"?c=" +
encodeURIComponent(encoded);
await navigator.clipboard.writeText(url);
const st = document.getElementById("copyStatus");
st.classList.add("show");
setTimeout(() => st.classList.remove("show"), 2000);
}
// ── THEME ──────────────────────────────────────────
function toggleTheme() {
const html = document.documentElement;
const goLight = html.dataset.theme !== "light";
html.dataset.theme = goLight ? "light" : "dark";
document.getElementById("themeToggle").textContent = goLight
? "☾ Dark"
: "☀ Light";
localStorage.setItem("fabulaUltimaTheme", html.dataset.theme);
}
// Auto-save every 30s
setInterval(() => saveSheet(true), 30000);
init();

1387
src/CharacterSheet.jsx Normal file

File diff suppressed because it is too large Load Diff

5
src/sheet-main.jsx Normal file
View File

@@ -0,0 +1,5 @@
import React from 'react';
import { createRoot } from 'react-dom/client';
import CharacterSheet from './CharacterSheet';
createRoot(document.getElementById('root')).render(<CharacterSheet />);

View File

@@ -44,7 +44,7 @@ module.exports = (env, argv) => {
return { return {
entry: { entry: {
sheet: "./fabula-ultima-sheet.js", sheet: "./src/sheet-main.jsx",
book: "./book.js", book: "./book.js",
}, },
output: { output: {