678 lines
26 KiB
HTML
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>
|