Files
fabula-ultima-html/books/natural-fantasy-atlas/index.html
2026-06-06 13:03:00 +00:00

292 lines
7.8 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>The Natural Fantasy Atlas for Fabula Ultima</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"
>
<style>
html, body {
height: 100vh;
overflow: hidden;
display: flex;
flex-direction: column;
min-height: unset;
}
#layout {
display: flex;
flex: 1;
overflow: hidden;
}
/* ── Sidebar ── */
#sidebar {
width: 270px;
flex-shrink: 0;
display: flex;
flex-direction: column;
background: var(--surface);
border-right: 1px solid var(--border-bright);
overflow: hidden;
}
#sidebar-header {
padding: 14px 16px;
background: var(--surface2);
border-bottom: 1px solid var(--border-bright);
}
#sidebar-header p {
font-family: var(--font-mono);
font-size: 0.7rem;
color: var(--text-dim);
margin-top: 4px;
}
#page-list {
flex: 1;
overflow-y: auto;
list-style: none;
padding: 4px 0;
}
#page-list li button {
width: 100%;
text-align: left;
padding: 6px 14px;
background: none;
border: none;
cursor: pointer;
color: var(--text-dim);
font-family: var(--font-body);
font-size: 0.88rem;
display: flex;
gap: 10px;
align-items: baseline;
transition: background 0.15s, color 0.15s;
}
#page-list li button:hover {
background: var(--surface2);
color: var(--text);
}
#page-list li.active button {
background: var(--surface3);
color: var(--teal);
}
.page-num {
flex-shrink: 0;
min-width: 24px;
font-family: var(--font-mono);
font-size: 0.72rem;
color: var(--border-bright);
}
li.active .page-num {
color: var(--teal-dim);
}
#loading-msg {
padding: 12px 14px;
font-family: var(--font-mono);
font-size: 0.72rem;
color: var(--text-dim);
}
/* ── Main area ── */
#main {
flex: 1;
display: flex;
flex-direction: column;
overflow: hidden;
}
/* ── Nav bar ── */
#nav {
display: flex;
align-items: center;
gap: 10px;
padding: 10px 20px;
background: var(--surface);
border-bottom: 1px solid var(--border-bright);
flex-shrink: 0;
}
#page-title {
flex: 1;
text-align: center;
font-family: var(--font-display);
font-size: 0.65rem;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
#page-indicator {
font-family: var(--font-mono);
font-size: 0.72rem;
color: var(--text-dim);
white-space: nowrap;
}
/* ── iframe ── */
#content {
flex: 1;
border: none;
background: #fff;
}
</style>
</head>
<body>
<header>
<div class="logo">Natural Fantasy Atlas</div>
<div class="toolbar">
<span id="page-indicator"></span>
</div>
</header>
<div id="layout">
<nav id="sidebar">
<div id="sidebar-header">
<div class="section-title" style="margin-bottom:0; border-bottom:none; padding-bottom:0;">
<span class="icon"></span> Pages
</div>
<p id="page-count">Discovering pages&hellip;</p>
</div>
<ul id="page-list">
<li id="loading-msg">Loading&hellip;</li>
</ul>
</nav>
<div id="main">
<div id="nav">
<button id="btn-prev" class="tab" disabled>&#8592; Prev</button>
<span id="page-title"></span>
<button id="btn-next" class="tab" disabled>Next &#8594;</button>
</div>
<iframe id="content" title="Book page"></iframe>
</div>
</div>
<script>
const list = document.getElementById('page-list');
const iframe = document.getElementById('content');
const btnPrev = document.getElementById('btn-prev');
const btnNext = document.getElementById('btn-next');
const indicator = document.getElementById('page-indicator');
const titleEl = document.getElementById('page-title');
const pageCount = document.getElementById('page-count');
const loadingMsg = document.getElementById('loading-msg');
const titles = []; // titles[n] = string (or '' for untitled)
let total = 0;
let current = 0;
// ── Navigation ──────────────────────────────────────────────
function goTo(n) {
if (total === 0) return;
n = Math.max(0, Math.min(total - 1, n));
current = n;
iframe.src = `${n}.html`;
indicator.textContent = `${n + 1} / ${total}`;
titleEl.textContent = titles[n] || `Page ${n}`;
btnPrev.disabled = n === 0;
btnNext.disabled = n === total - 1;
list.querySelectorAll('li[data-n]').forEach(li => {
li.classList.toggle('active', Number(li.dataset.n) === n);
});
list.querySelector(`li[data-n="${n}"]`)?.scrollIntoView({ block: 'nearest' });
history.replaceState(null, '', `#${n}`);
}
btnPrev.addEventListener('click', () => goTo(current - 1));
btnNext.addEventListener('click', () => goTo(current + 1));
document.addEventListener('keydown', e => {
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') goTo(current - 1);
if (e.key === 'ArrowRight' || e.key === 'ArrowDown') goTo(current + 1);
});
// ── Sidebar item factory ─────────────────────────────────────
function addPageItem(n, title) {
titles[n] = title;
const li = document.createElement('li');
li.dataset.n = n;
const btn = document.createElement('button');
const num = document.createElement('span');
num.className = 'page-num';
num.textContent = n;
const lbl = document.createElement('span');
lbl.textContent = title || `Page ${n}`;
btn.append(num, lbl);
btn.addEventListener('click', () => goTo(n));
li.append(btn);
list.append(li);
}
// ── Page discovery ───────────────────────────────────────────
const TITLE_RE = /<title[^>]*>([\s\S]*?)<\/title>/i;
const BATCH = 20;
async function fetchTitle(n) {
try {
const res = await fetch(`${n}.html`, { cache: 'no-store' });
if (!res.ok) return null;
const text = await res.text();
const m = TITLE_RE.exec(text);
return m ? m[1].trim() : '';
} catch {
return null;
}
}
async function discoverPages() {
let offset = 0;
while (true) {
const indices = Array.from({ length: BATCH }, (_, i) => offset + i);
const results = await Promise.all(indices.map(fetchTitle));
// Add every successful page to the sidebar
let gotAny = false;
for (let i = 0; i < results.length; i++) {
if (results[i] === null) break;
addPageItem(offset + i, results[i]);
total++;
gotAny = true;
}
// Update sidebar header count
pageCount.textContent = `${total} page${total !== 1 ? 's' : ''}`;
// Navigate to start once the first batch is ready
if (offset === 0 && total > 0) {
loadingMsg.remove();
const startPage = parseInt(location.hash.slice(1), 10);
goTo(isNaN(startPage) ? 0 : startPage);
}
// Stop when the batch contained a missing page
if (!gotAny || results.some(r => r === null)) break;
offset += BATCH;
}
}
discoverPages();
</script>
</body>
</html>