novela/containers/novela/templates/reader.html
2026-03-31 20:03:18 +02:00

678 lines
26 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1.0"/>
<title>Novela — {{ title }}</title>
<link rel="icon" href="/static/favicon.ico" sizes="16x16"/>
<link rel="icon" type="image/png" sizes="32x32" href="/static/favicon-32.png"/>
<link rel="icon" type="image/png" sizes="256x256" href="/static/favicon-256.png"/>
<link rel="apple-touch-icon" sizes="180x180" href="/static/apple-touch-icon.png"/>
<link rel="preconnect" href="https://fonts.googleapis.com"/>
<link href="https://fonts.googleapis.com/css2?family=Libre+Baskerville:ital,wght@0,400;0,700;1,400&family=DM+Mono:wght@400;500&display=swap" rel="stylesheet"/>
<link rel="stylesheet" href="/static/theme.css"/>
<style>
:root {
--header-h: 50px; --footer-h: 36px;
--content-w: 65vw;
}
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html, body { background: var(--bg); color: var(--text); }
/* ── Header ── */
.reader-header {
position: fixed; top: 0; left: 0; right: 0;
height: var(--header-h);
background: var(--surface);
border-bottom: 1px solid var(--border);
display: flex; align-items: center;
padding: 0 1rem; gap: 0.75rem;
z-index: 100;
}
.btn-hamburger {
display: flex; align-items: center; justify-content: center;
width: 30px; height: 30px; flex-shrink: 0;
background: none; border: none; cursor: pointer;
color: var(--text-dim); transition: color 0.12s;
padding: 0;
}
.btn-hamburger:hover { color: var(--text); }
.header-back {
font-family: var(--mono); font-size: 0.72rem;
color: var(--text-dim); text-decoration: none;
display: flex; align-items: center; gap: 0.35rem;
flex-shrink: 0;
margin-left: 1rem;
transition: color 0.12s;
}
.header-back:hover { color: var(--text); }
.header-title {
flex: 1;
font-family: var(--mono); font-size: 0.72rem;
color: var(--text-faint);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
text-align: center;
}
.header-title strong { color: var(--text-dim); }
.header-actions { display: flex; gap: 0.5rem; flex-shrink: 0; }
.btn-header {
display: flex; align-items: center; gap: 0.35rem;
padding: 0.3rem 0.7rem;
background: none; border: 1px solid var(--border);
border-radius: var(--radius);
font-family: var(--mono); font-size: 0.68rem;
color: var(--text-dim); cursor: pointer;
transition: color 0.12s, border-color 0.12s;
}
.btn-header:hover { color: var(--text); border-color: var(--text-faint); }
.btn-header-read { color: var(--success); border-color: rgba(107,170,107,0.3); }
.btn-header-read:hover { background: rgba(107,170,107,0.08); border-color: var(--success); }
.btn-header-bm { color: var(--accent); border-color: rgba(255,162,14,0.3); }
.btn-header-bm:hover { background: rgba(255,162,14,0.08); border-color: var(--accent); }
/* ── Bookmark modal ── */
.bm-overlay {
display: none; position: fixed; inset: 0;
background: rgba(0,0,0,0.55); z-index: 300;
align-items: center; justify-content: center;
}
.bm-overlay.open { display: flex; }
.bm-modal {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 1.4rem 1.5rem;
width: min(420px, 92vw);
}
.bm-title {
font-family: var(--mono); font-size: 0.7rem;
letter-spacing: 0.1em; text-transform: uppercase;
color: var(--accent); margin-bottom: 1rem;
}
.bm-chapter {
font-family: var(--mono); font-size: 0.72rem;
color: var(--text-dim); margin-bottom: 1rem;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.bm-label {
font-family: var(--mono); font-size: 0.7rem;
color: var(--text-dim); margin-bottom: 0.4rem; display: block;
}
.bm-textarea {
width: 100%; min-height: 90px;
background: var(--surface2); border: 1px solid var(--border);
border-radius: var(--radius);
font-family: var(--mono); font-size: 0.78rem;
color: var(--text); padding: 0.55rem 0.75rem;
resize: vertical; line-height: 1.5;
margin-bottom: 1rem;
}
.bm-textarea:focus { outline: none; border-color: var(--accent); }
.bm-actions { display: flex; gap: 0.6rem; justify-content: flex-end; }
.bm-btn {
padding: 0.4rem 1rem;
border-radius: var(--radius);
font-family: var(--mono); font-size: 0.72rem;
cursor: pointer; border: 1px solid var(--border);
}
.bm-btn-cancel { background: none; color: var(--text-dim); }
.bm-btn-cancel:hover { border-color: var(--text-faint); color: var(--text); }
.bm-btn-save { background: var(--accent); color: #fff; border-color: var(--accent); }
.bm-btn-save:hover { filter: brightness(1.1); }
/* ── Settings drawer ── */
.settings-overlay {
display: none;
position: fixed; inset: 0;
background: rgba(0,0,0,0.45);
z-index: 150;
}
.settings-overlay.open { display: block; }
.settings-drawer {
position: fixed; top: 0; left: 0; bottom: 0;
width: 260px;
background: var(--surface);
border-right: 1px solid var(--border);
z-index: 160;
padding: 1.25rem;
transform: translateX(-100%);
transition: transform 0.22s ease;
}
.settings-drawer.open { transform: translateX(0); }
.settings-drawer-title {
font-family: var(--mono); font-size: 0.7rem;
letter-spacing: 0.12em; text-transform: uppercase;
color: var(--accent); margin-bottom: 1.5rem;
}
.settings-row {
margin-bottom: 1.5rem;
}
.settings-label {
font-family: var(--mono); font-size: 0.72rem;
color: var(--text-dim); margin-bottom: 0.6rem;
display: flex; justify-content: space-between; align-items: center;
}
.settings-label span {
color: var(--text-faint); font-size: 0.65rem;
}
input[type="range"] {
width: 100%; accent-color: var(--accent);
background: transparent; cursor: pointer;
height: 4px;
}
.colour-swatches {
display: flex; gap: 0.55rem; align-items: center; flex-wrap: wrap;
}
.colour-swatch {
width: 24px; height: 24px; border-radius: 50%;
border: 2px solid transparent;
cursor: pointer; transition: border-color 0.12s, transform 0.1s;
box-shadow: 0 0 0 1px rgba(255,255,255,0.1);
padding: 0;
}
.colour-swatch:hover { transform: scale(1.15); }
.colour-swatch.active { border-color: var(--accent); }
/* ── Viewer ── */
#viewer {
margin-top: var(--header-h);
margin-bottom: var(--footer-h);
min-height: calc(100vh - var(--header-h) - var(--footer-h));
padding: 3rem 2rem 4rem;
max-width: var(--content-w);
margin-left: auto;
margin-right: auto;
}
/* Chapter content */
#chapter-content {
font-family: var(--serif);
font-size: 1.05rem;
line-height: 1.85;
color: var(--text);
}
#chapter-content h1, #chapter-content h2 {
font-family: var(--serif); color: var(--text);
margin: 2rem 0 1rem; font-size: 1.3rem;
}
#chapter-content h3, #chapter-content h4 {
font-family: var(--serif); color: var(--text-dim);
margin: 1.5rem 0 0.75rem; font-size: 1.1rem;
}
#chapter-content p { margin-bottom: 1rem; color: var(--text); }
#chapter-content em, #chapter-content i { font-style: italic; }
#chapter-content strong, #chapter-content b { font-weight: 700; }
#chapter-content a { color: var(--accent); text-decoration: none; }
#chapter-content img { max-width: 100%; height: auto; border-radius: var(--radius); margin: 1rem 0; }
#chapter-content .chapter-title {
font-size: 1.5rem; font-weight: 700;
margin-bottom: 2.5rem; padding-bottom: 1rem;
border-bottom: 1px solid var(--border);
}
/* Chapter nav */
.chapter-nav {
display: flex; justify-content: space-between; align-items: center;
margin-top: 3rem; padding-top: 1.5rem;
border-top: 1px solid var(--border);
gap: 1rem;
}
.btn-nav {
display: flex; align-items: center; gap: 0.5rem;
padding: 0.6rem 1.1rem;
background: var(--surface); border: 1px solid var(--border);
border-radius: var(--radius);
font-family: var(--mono); font-size: 0.75rem;
color: var(--text-dim); cursor: pointer; text-decoration: none;
transition: color 0.12s, border-color 0.12s;
flex-shrink: 0;
}
.btn-nav:hover { color: var(--text); border-color: var(--text-faint); }
.btn-nav:disabled { opacity: 0.3; cursor: not-allowed; }
.chapter-nav-label {
font-family: var(--mono); font-size: 0.68rem; color: var(--text-faint);
text-align: center; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
/* ── Footer ── */
.reader-footer {
position: fixed; bottom: 0; left: 0; right: 0;
height: var(--footer-h);
background: var(--surface);
border-top: 1px solid var(--border);
display: flex; align-items: center;
padding: 0 1rem; gap: 1rem;
z-index: 100;
}
.footer-progress-wrap {
flex: 1; height: 3px;
background: var(--surface2);
border-radius: 100px; overflow: hidden;
}
.footer-progress-fill {
height: 100%; background: var(--accent); border-radius: 100px;
width: 0%; transition: width 0.15s ease;
}
.footer-pct {
font-family: var(--mono); font-size: 0.65rem;
color: var(--text-faint); flex-shrink: 0;
min-width: 3rem; text-align: right;
}
/* ── Loading overlay ── */
#loading {
position: fixed; inset: 0;
background: var(--bg);
display: flex; align-items: center; justify-content: center;
z-index: 200;
font-family: var(--mono); font-size: 0.78rem; color: var(--text-faint);
}
.spinner {
width: 20px; height: 20px;
border: 2px solid var(--surface2); border-top-color: var(--accent);
border-radius: 50%; animation: spin 0.7s linear infinite;
margin-right: 0.75rem;
}
@keyframes spin { to { transform: rotate(360deg); } }
</style>
</head>
<body>
<!-- Bookmark modal -->
<div class="bm-overlay" id="bm-overlay">
<div class="bm-modal">
<div class="bm-title">Add bookmark</div>
<div class="bm-chapter" id="bm-chapter"></div>
<label class="bm-label" for="bm-note">Note (optional)</label>
<textarea class="bm-textarea" id="bm-note" placeholder="What do you want to remember here?"></textarea>
<div class="bm-actions">
<button class="bm-btn bm-btn-cancel" onclick="closeBookmarkModal()">Cancel</button>
<button class="bm-btn bm-btn-save" onclick="saveBookmark()">Save bookmark</button>
</div>
</div>
</div>
<!-- Loading -->
<div id="loading">
<div class="spinner"></div>
Loading…
</div>
<!-- Settings overlay + drawer -->
<div class="settings-overlay" id="settings-overlay" onclick="closeSettings()"></div>
<div class="settings-drawer" id="settings-drawer">
<div class="settings-drawer-title">Reading settings</div>
<div class="settings-row">
<div class="settings-label">
Content width
<span id="width-value">65%</span>
</div>
<input type="range" id="width-slider" min="30" max="100" step="1"
value="65" oninput="applyWidth(this.value)"/>
</div>
<div class="settings-row">
<div class="settings-label">Text colour</div>
<div class="colour-swatches">
<button class="colour-swatch" data-colour="#e8e2d9" title="Bright" style="background:#e8e2d9" onclick="applyTextColour('#e8e2d9')"></button>
<button class="colour-swatch" data-colour="#d4cec5" title="Warm cream" style="background:#d4cec5" onclick="applyTextColour('#d4cec5')"></button>
<button class="colour-swatch" data-colour="#bfb8ae" title="Soft sand" style="background:#bfb8ae" onclick="applyTextColour('#bfb8ae')"></button>
<button class="colour-swatch" data-colour="#a9a29a" title="Muted" style="background:#a9a29a" onclick="applyTextColour('#a9a29a')"></button>
<button class="colour-swatch" data-colour="#938d86" title="Dim" style="background:#938d86" onclick="applyTextColour('#938d86')"></button>
</div>
</div>
</div>
<!-- Header -->
<div class="reader-header">
<button class="btn-hamburger" onclick="toggleSettings()" title="Reading settings">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="3" y1="6" x2="21" y2="6"/>
<line x1="3" y1="12" x2="21" y2="12"/>
<line x1="3" y1="18" x2="21" y2="18"/>
</svg>
</button>
<a class="header-back" href="/library/book/{{ filename | urlencode }}">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<polyline points="15 18 9 12 15 6"/>
</svg>
{{ title | truncate(30, True) }}
</a>
<div class="header-title" id="header-title"></div>
<div class="header-actions">
<button class="btn-header btn-header-bm" onclick="openBookmarkModal()" title="Add bookmark at current position">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M19 21l-7-5-7 5V5a2 2 0 0 1 2-2h10a2 2 0 0 1 2 2z"/>
</svg>
Bookmark
</button>
<button class="btn-header btn-header-read" onclick="markRead()">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<polyline points="20 6 9 17 4 12"/>
</svg>
Mark as read
</button>
</div>
</div>
<!-- Viewer -->
<div id="viewer">
<div id="chapter-content"></div>
<div class="chapter-nav">
<button class="btn-nav" id="btn-prev" onclick="navigate(-1)">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<polyline points="15 18 9 12 15 6"/>
</svg>
Previous
</button>
<span class="chapter-nav-label" id="chapter-nav-label"></span>
<button class="btn-nav" id="btn-next" onclick="navigate(1)">
Next
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5">
<polyline points="9 18 15 12 9 6"/>
</svg>
</button>
</div>
</div>
<!-- Footer -->
<div class="reader-footer">
<div class="footer-progress-wrap">
<div class="footer-progress-fill" id="footer-progress"></div>
</div>
<div class="footer-pct" id="footer-pct">0%</div>
</div>
<script src="/static/books.js"></script>
<script>
const filename = {{ filename | tojson }};
const FORMAT = {{ format | tojson }};
let chapters = [];
let currentIndex = 0;
let saveTimer = null;
let scrollTimer = null;
// ── Width setting ──────────────────────────────────────────────
function applyWidth(pct) {
const val = parseInt(pct, 10);
document.documentElement.style.setProperty('--content-w', val + 'vw');
document.getElementById('width-value').textContent = val + '%';
document.getElementById('width-slider').value = val;
localStorage.setItem('reader-content-width-pct', val);
}
function loadWidth() {
const saved = parseInt(localStorage.getItem('reader-content-width-pct') || '65', 10);
applyWidth(saved);
}
// ── Text colour ────────────────────────────────────────────────
function applyTextColour(hex) {
document.documentElement.style.setProperty('--text', hex);
localStorage.setItem('reader-text-colour', hex);
document.querySelectorAll('.colour-swatch').forEach(el => {
el.classList.toggle('active', el.dataset.colour === hex);
});
}
function loadTextColour() {
const saved = localStorage.getItem('reader-text-colour') || '#e8e2d9';
applyTextColour(saved);
}
// ── Settings drawer ────────────────────────────────────────────
function toggleSettings() {
const open = document.getElementById('settings-drawer').classList.toggle('open');
document.getElementById('settings-overlay').classList.toggle('open', open);
}
function closeSettings() {
document.getElementById('settings-drawer').classList.remove('open');
document.getElementById('settings-overlay').classList.remove('open');
}
const IS_PAGED = (FORMAT === 'pdf' || FORMAT === 'cbr' || FORMAT === 'cbz');
// ── Progress ───────────────────────────────────────────────────
function calcProgress() {
const total = chapters.length;
if (IS_PAGED) {
const pct = total > 0 ? Math.round(((currentIndex + 1) / total) * 100) : 0;
return { scrollFrac: 0, pct };
}
const maxScroll = document.documentElement.scrollHeight - window.innerHeight;
const scrollFrac = maxScroll > 0 ? Math.min(1, window.scrollY / maxScroll) : 0;
const pct = total > 0
? Math.min(100, Math.round(((currentIndex + scrollFrac) / total) * 100))
: 0;
return { scrollFrac, pct };
}
function updateFooter() {
const { pct } = calcProgress();
document.getElementById('footer-progress').style.width = pct + '%';
document.getElementById('footer-pct').textContent = pct + '%';
}
function scheduleSave() {
clearTimeout(saveTimer);
saveTimer = setTimeout(() => {
const { scrollFrac, pct } = calcProgress();
fetch(`/library/progress/${encodeURIComponent(filename)}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
cfi: `${currentIndex}:${scrollFrac.toFixed(4)}`,
progress: pct,
}),
});
}, 1000);
}
// ── PDF page loading ───────────────────────────────────────────
async function loadPdfPage(index, saveProgress) {
if (index < 0 || index >= chapters.length) return;
currentIndex = index;
const content = document.getElementById('chapter-content');
content.innerHTML =
`<div style="text-align:center">` +
`<img src="/library/pdf/${encodeURIComponent(filename)}?page=${index}&dpi=150"` +
` style="max-width:100%;height:auto;border-radius:4px" alt="Page ${index + 1}"/>` +
`</div>`;
window.scrollTo(0, 0);
document.getElementById('header-title').innerHTML =
`<strong>Page ${index + 1} / ${chapters.length}</strong>`;
document.getElementById('btn-prev').disabled = index === 0;
document.getElementById('btn-next').disabled = index === chapters.length - 1;
document.getElementById('chapter-nav-label').textContent =
`${index + 1} / ${chapters.length}`;
updateFooter();
if (saveProgress) scheduleSave();
}
// ── CBR/CBZ page loading ───────────────────────────────────────
async function loadCbrPage(index, saveProgress) {
if (index < 0 || index >= chapters.length) return;
currentIndex = index;
const content = document.getElementById('chapter-content');
content.innerHTML =
`<div style="text-align:center">` +
`<img src="/library/cbr/${encodeURIComponent(filename)}?page=${index}"` +
` style="max-width:100%;height:auto;border-radius:4px" alt="Page ${index + 1}"/>` +
`</div>`;
window.scrollTo(0, 0);
document.getElementById('header-title').innerHTML =
`<strong>Page ${index + 1} / ${chapters.length}</strong>`;
document.getElementById('btn-prev').disabled = index === 0;
document.getElementById('btn-next').disabled = index === chapters.length - 1;
document.getElementById('chapter-nav-label').textContent =
`${index + 1} / ${chapters.length}`;
updateFooter();
if (saveProgress) scheduleSave();
}
// ── EPUB chapter loading ───────────────────────────────────────
async function loadChapter(index, saveProgress, scrollFrac) {
if (index < 0 || index >= chapters.length) return;
currentIndex = index;
const resp = await fetch(`/library/chapter/${index}/${encodeURIComponent(filename)}`);
const html = await resp.text();
document.getElementById('chapter-content').innerHTML = html;
if (scrollFrac && scrollFrac > 0) {
requestAnimationFrame(() => {
requestAnimationFrame(() => {
const maxScroll = document.documentElement.scrollHeight - window.innerHeight;
window.scrollTo(0, maxScroll * scrollFrac);
});
});
} else {
window.scrollTo(0, 0);
}
const ch = chapters[index];
document.getElementById('header-title').innerHTML =
ch ? `<strong>${esc(ch.title)}</strong>` : '';
document.getElementById('btn-prev').disabled = index === 0;
document.getElementById('btn-next').disabled = index === chapters.length - 1;
document.getElementById('chapter-nav-label').textContent =
`${index + 1} / ${chapters.length}`;
updateFooter();
if (saveProgress) scheduleSave();
}
function navigate(delta) {
if (FORMAT === 'pdf') {
loadPdfPage(currentIndex + delta, true);
} else if (FORMAT === 'cbr' || FORMAT === 'cbz') {
loadCbrPage(currentIndex + delta, true);
} else {
loadChapter(currentIndex + delta, true, 0);
}
}
// ── Scroll tracking (EPUB only) ────────────────────────────────
window.addEventListener('scroll', () => {
updateFooter();
if (!IS_PAGED) {
clearTimeout(scrollTimer);
scrollTimer = setTimeout(scheduleSave, 300);
}
}, { passive: true });
// ── Keyboard navigation ────────────────────────────────────────
document.addEventListener('keydown', (e) => {
if (e.key === 'ArrowRight' || e.key === 'PageDown') { e.preventDefault(); navigate(1); }
if (e.key === 'ArrowLeft' || e.key === 'PageUp') { e.preventDefault(); navigate(-1); }
if (e.key === 'Escape') { closeSettings(); closeBookmarkModal(); }
});
// ── Init ───────────────────────────────────────────────────────
async function init() {
loadWidth();
loadTextColour();
const progResp = await fetch(`/library/progress/${encodeURIComponent(filename)}`);
const prog = await progResp.json();
// Bookmark navigation takes priority over saved progress
const urlParams = new URLSearchParams(window.location.search);
const bmCh = urlParams.get('bm_ch');
const bmScr = urlParams.get('bm_scroll');
let startIndex = 0;
let startScroll = 0;
if (bmCh !== null) {
startIndex = Math.max(0, parseInt(bmCh, 10) || 0);
startScroll = parseFloat(bmScr) || 0;
} else if (prog.cfi) {
const parts = prog.cfi.split(':');
const idx = parseInt(parts[0], 10);
if (!isNaN(idx) && idx >= 0) {
startIndex = idx;
startScroll = parseFloat(parts[1]) || 0;
}
}
if (FORMAT === 'pdf') {
const infoResp = await fetch(`/api/pdf/info/${encodeURIComponent(filename)}`);
const info = await infoResp.json();
const pageCount = info.page_count || 1;
chapters = Array.from({ length: pageCount }, (_, i) => ({ index: i, title: `Page ${i + 1}` }));
if (startIndex >= chapters.length) startIndex = 0;
await loadPdfPage(startIndex, false);
} else if (FORMAT === 'cbr' || FORMAT === 'cbz') {
const infoResp = await fetch(`/api/cbr/info/${encodeURIComponent(filename)}`);
const info = await infoResp.json();
const pageCount = info.page_count || 1;
chapters = Array.from({ length: pageCount }, (_, i) => ({ index: i, title: `Page ${i + 1}` }));
if (startIndex >= chapters.length) startIndex = 0;
await loadCbrPage(startIndex, false);
} else {
const chapResp = await fetch(`/library/chapters/${encodeURIComponent(filename)}`);
chapters = await chapResp.json();
if (startIndex >= chapters.length) startIndex = 0;
await loadChapter(startIndex, false, startScroll);
}
document.getElementById('loading').style.display = 'none';
}
async function markRead() {
clearTimeout(saveTimer);
await fetch(`/library/mark-read/${encodeURIComponent(filename)}`, { method: 'POST' });
window.location.href = `/library/book/${encodeURIComponent(filename)}`;
}
// ── Bookmarks ──────────────────────────────────────────────────
function openBookmarkModal() {
const chTitle = IS_PAGED
? `Page ${currentIndex + 1}`
: (chapters[currentIndex]?.title || `Chapter ${currentIndex + 1}`);
document.getElementById('bm-chapter').textContent = chTitle;
document.getElementById('bm-note').value = '';
document.getElementById('bm-overlay').classList.add('open');
setTimeout(() => document.getElementById('bm-note').focus(), 50);
}
function closeBookmarkModal() {
document.getElementById('bm-overlay').classList.remove('open');
}
async function saveBookmark() {
const { scrollFrac } = calcProgress();
const chTitle = IS_PAGED
? `Page ${currentIndex + 1}`
: (chapters[currentIndex]?.title || `Chapter ${currentIndex + 1}`);
const note = document.getElementById('bm-note').value.trim();
closeBookmarkModal();
await fetch(`/library/bookmarks/${encodeURIComponent(filename)}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
chapter_index: currentIndex,
scroll_frac: scrollFrac,
chapter_title: chTitle,
note,
}),
});
}
document.getElementById('bm-overlay').addEventListener('click', (e) => {
if (e.target === e.currentTarget) closeBookmarkModal();
});
init();
</script>
</body>
</html>