feat: Add Manage page, light/dark theme toggle, and URL sharing
- Move Save, Load, Export, Import buttons to a dedicated Manage page - Add light theme with warm parchment palette; auto-detect via prefers-color-scheme, persisted to localStorage - Add URL sharing: sheet state is deflate-raw compressed, base64-encoded, and stored in ?c= parameter; auto-save is suppressed when viewing a shared URL - Extract JS to fabula-ultima-sheet.js Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -964,6 +964,55 @@ input[type="number"] {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ── URL BANNER ──────────────────────────────────── */
|
||||
.url-banner {
|
||||
background: var(--surface2);
|
||||
border-bottom: 1px solid var(--teal-dim);
|
||||
padding: 10px 32px;
|
||||
font-family: var(--font-body);
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-dim);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.url-banner strong {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.url-banner-icon {
|
||||
color: var(--teal);
|
||||
font-size: 0.85rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ── MANAGE PAGE ─────────────────────────────────── */
|
||||
.manage-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 20px;
|
||||
max-width: 800px;
|
||||
}
|
||||
|
||||
.manage-desc {
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-dim);
|
||||
line-height: 1.6;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.manage-btn-row {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.btn-lg {
|
||||
font-size: 0.6rem;
|
||||
padding: 11px 22px;
|
||||
}
|
||||
|
||||
/* ── LIGHT THEME ─────────────────────────────────── */
|
||||
[data-theme="light"] {
|
||||
--bg: #f4efe4;
|
||||
|
||||
@@ -20,21 +20,20 @@
|
||||
<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">
|
||||
<button class="btn-save" onclick="saveSheet()">✦ Save</button>
|
||||
<button class="btn-load" onclick="loadSheet()">↑ Load</button>
|
||||
<div class="toolbar-sep"></div>
|
||||
<button class="btn-save btn-export" onclick="exportSheet()">↓ Export JSON</button>
|
||||
<button class="btn-load btn-import" onclick="importSheet()">↑ Import JSON</button>
|
||||
<input type="file" id="importFileInput" accept=".json,application/json" style="display:none"
|
||||
onchange="handleImportFile(this)">
|
||||
<span class="save-status" id="saveStatus">Saved!</span>
|
||||
<div class="toolbar-sep"></div>
|
||||
<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
|
||||
══════════════════════════════════════════════════ -->
|
||||
@@ -430,6 +429,44 @@
|
||||
|
||||
</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>
|
||||
|
||||
<script src="fabula-ultima-sheet.js"></script>
|
||||
</body>
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
// ── STATE ──────────────────────────────────────────
|
||||
let urlMode = false;
|
||||
let level = 1;
|
||||
let fpCount = 0;
|
||||
let primaryClasses = [];
|
||||
@@ -25,7 +26,7 @@ function switchTab(tab) {
|
||||
}
|
||||
|
||||
// ── INIT ───────────────────────────────────────────
|
||||
function 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';
|
||||
@@ -36,7 +37,50 @@ function init() {
|
||||
renderOtherClasses();
|
||||
renderSpells();
|
||||
updateXPBar();
|
||||
tryAutoLoad();
|
||||
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 ─────────────────────────────────
|
||||
@@ -236,7 +280,8 @@ function collectData() {
|
||||
};
|
||||
}
|
||||
|
||||
function saveSheet() {
|
||||
function saveSheet(isAuto = false) {
|
||||
if (isAuto && urlMode) return;
|
||||
const data = collectData();
|
||||
localStorage.setItem('fabulaUltimaSheet', JSON.stringify(data));
|
||||
const st = document.getElementById('saveStatus');
|
||||
@@ -350,6 +395,16 @@ function handleImportFile(input) {
|
||||
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;
|
||||
@@ -360,6 +415,6 @@ function toggleTheme() {
|
||||
}
|
||||
|
||||
// Auto-save every 30s
|
||||
setInterval(saveSheet, 30000);
|
||||
setInterval(() => saveSheet(true), 30000);
|
||||
|
||||
init();
|
||||
|
||||
Reference in New Issue
Block a user