292 lines
7.8 KiB
HTML
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…</p>
|
|
</div>
|
|
<ul id="page-list">
|
|
<li id="loading-msg">Loading…</li>
|
|
</ul>
|
|
</nav>
|
|
|
|
<div id="main">
|
|
<div id="nav">
|
|
<button id="btn-prev" class="tab" disabled>← Prev</button>
|
|
<span id="page-title"></span>
|
|
<button id="btn-next" class="tab" disabled>Next →</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>
|