mirror of
https://github.com/denizsafak/abogen.git
synced 2026-07-18 13:40:27 +02:00
445 lines
13 KiB
HTML
445 lines
13 KiB
HTML
<!doctype html>
|
|
<html lang="en">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>{{ job.original_filename }} · Reader</title>
|
|
<style>
|
|
:root {
|
|
color-scheme: dark;
|
|
--bg: #020617;
|
|
--panel: rgba(15, 23, 42, 0.92);
|
|
--border: rgba(148, 163, 184, 0.25);
|
|
--text: #e2e8f0;
|
|
--accent: #38bdf8;
|
|
}
|
|
|
|
* {
|
|
box-sizing: border-box;
|
|
}
|
|
|
|
html,
|
|
body {
|
|
margin: 0;
|
|
height: 100%;
|
|
background: var(--bg);
|
|
color: var(--text);
|
|
font-family: "Inter", "Segoe UI", system-ui, -apple-system, sans-serif;
|
|
}
|
|
|
|
body {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.75rem;
|
|
padding: 0.75rem;
|
|
}
|
|
|
|
.reader-shell {
|
|
display: flex;
|
|
flex-direction: column;
|
|
flex: 1 1 auto;
|
|
min-height: 0;
|
|
gap: 0.75rem;
|
|
}
|
|
|
|
.reader-toolbar {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
gap: 0.75rem;
|
|
padding: 0.75rem 1rem;
|
|
border-radius: 14px;
|
|
background: var(--panel);
|
|
border: 1px solid var(--border);
|
|
}
|
|
|
|
.reader-toolbar__title {
|
|
flex: 1 1 auto;
|
|
text-align: center;
|
|
font-weight: 600;
|
|
font-size: 1rem;
|
|
overflow: hidden;
|
|
text-overflow: ellipsis;
|
|
white-space: nowrap;
|
|
}
|
|
|
|
.reader-toolbar button {
|
|
flex: 0 0 auto;
|
|
border: 1px solid var(--border);
|
|
background: rgba(30, 41, 59, 0.8);
|
|
color: var(--text);
|
|
border-radius: 12px;
|
|
padding: 0.45rem 0.9rem;
|
|
font-weight: 500;
|
|
font-size: 0.95rem;
|
|
cursor: pointer;
|
|
transition: background 0.2s ease, border-color 0.2s ease;
|
|
}
|
|
|
|
.reader-toolbar button:hover,
|
|
.reader-toolbar button:focus-visible {
|
|
background: rgba(56, 189, 248, 0.2);
|
|
border-color: rgba(56, 189, 248, 0.4);
|
|
outline: none;
|
|
}
|
|
|
|
.reader-toolbar select {
|
|
flex: 1 1 30%;
|
|
min-width: 140px;
|
|
max-width: 320px;
|
|
border-radius: 12px;
|
|
border: 1px solid var(--border);
|
|
background: rgba(30, 41, 59, 0.8);
|
|
color: var(--text);
|
|
padding: 0.4rem 0.75rem;
|
|
font-size: 0.95rem;
|
|
}
|
|
|
|
.reader-toolbar select:focus-visible {
|
|
outline: 2px solid var(--accent);
|
|
outline-offset: 2px;
|
|
}
|
|
|
|
#reader-container {
|
|
flex: 1 1 auto;
|
|
min-height: 0;
|
|
border-radius: 16px;
|
|
background: var(--panel);
|
|
border: 1px solid var(--border);
|
|
overflow: hidden;
|
|
display: flex;
|
|
}
|
|
|
|
#reader {
|
|
flex: 1 1 auto;
|
|
padding: 1.5rem;
|
|
overflow-y: auto;
|
|
overflow-x: hidden;
|
|
line-height: 1.6;
|
|
font-size: 1.05rem;
|
|
scroll-behavior: smooth;
|
|
}
|
|
|
|
#reader:focus-visible {
|
|
outline: 2px solid var(--accent);
|
|
outline-offset: 4px;
|
|
}
|
|
|
|
#reader .chunk {
|
|
margin-bottom: 1.75rem;
|
|
white-space: pre-wrap;
|
|
word-wrap: break-word;
|
|
}
|
|
|
|
#reader p,
|
|
#reader li,
|
|
#reader blockquote {
|
|
max-width: 60ch;
|
|
}
|
|
|
|
.reader-footer {
|
|
border-radius: 14px;
|
|
background: var(--panel);
|
|
border: 1px solid var(--border);
|
|
padding: 0.75rem 1rem;
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.5rem;
|
|
}
|
|
|
|
.reader-footer audio {
|
|
width: 100%;
|
|
}
|
|
|
|
.reader-status {
|
|
font-size: 0.85rem;
|
|
color: rgba(226, 232, 240, 0.75);
|
|
}
|
|
</style>
|
|
</head>
|
|
<body
|
|
data-chapter-url="{{ chapter_url }}"
|
|
data-asset-base="{{ asset_base }}"
|
|
>
|
|
<div class="reader-shell" data-role="reader-shell">
|
|
<div class="reader-toolbar">
|
|
<button type="button" data-action="prev">Previous</button>
|
|
<select data-role="reader-chapter" aria-label="Select chapter"></select>
|
|
<div class="reader-toolbar__title" data-role="reader-title">{{ job.original_filename }}</div>
|
|
<button type="button" data-action="next">Next</button>
|
|
</div>
|
|
<div id="reader-container">
|
|
<div id="reader" tabindex="0"></div>
|
|
</div>
|
|
<div class="reader-footer">
|
|
{% if audio_url %}
|
|
<audio controls preload="metadata" src="{{ audio_url }}"></audio>
|
|
{% endif %}
|
|
<div class="reader-status" data-role="reader-status">Loading EPUB…</div>
|
|
</div>
|
|
</div>
|
|
<script type="application/json" id="reader-data">{{ {'chapters': chapters, 'title': job.original_filename}|tojson }}</script>
|
|
<script type="module">
|
|
const statusEl = document.querySelector('[data-role="reader-status"]');
|
|
const prevBtn = document.querySelector('[data-action="prev"]');
|
|
const nextBtn = document.querySelector('[data-action="next"]');
|
|
const titleEl = document.querySelector('[data-role="reader-title"]');
|
|
const readerEl = document.getElementById('reader');
|
|
const chapterSelect = document.querySelector('[data-role="reader-chapter"]');
|
|
const rawData = document.getElementById('reader-data');
|
|
const chapterUrl = document.body.dataset.chapterUrl || '';
|
|
const assetBase = document.body.dataset.assetBase || '';
|
|
|
|
let data = { chapters: [] };
|
|
try {
|
|
data = JSON.parse(rawData?.textContent || '{}');
|
|
} catch (error) {
|
|
data = { chapters: [] };
|
|
}
|
|
|
|
const chapters = Array.isArray(data.chapters) ? data.chapters : [];
|
|
const baseTitle = typeof data.title === 'string' && data.title ? data.title : 'Book';
|
|
let currentIndex = 0;
|
|
const parser = new DOMParser();
|
|
const activeStyleNodes = [];
|
|
|
|
const escapeFragment = (value) => {
|
|
if (!value) return '';
|
|
if (typeof CSS !== 'undefined' && typeof CSS.escape === 'function') {
|
|
return CSS.escape(value);
|
|
}
|
|
return value.replace(/[^a-zA-Z0-9_-]/g, '\\$&');
|
|
};
|
|
|
|
const setStatus = (message) => {
|
|
if (statusEl) {
|
|
statusEl.textContent = message;
|
|
}
|
|
};
|
|
|
|
const resolveAsset = (value) => {
|
|
if (!value) return value;
|
|
const trimmed = value.trim();
|
|
if (/^(?:[a-z]+:)?\/\//i.test(trimmed) || trimmed.startsWith('data:')) {
|
|
return trimmed;
|
|
}
|
|
const base = window.location.origin + assetBase;
|
|
try {
|
|
const url = new URL(trimmed, base);
|
|
return url.pathname + url.search + url.hash;
|
|
} catch (error) {
|
|
const normalized = trimmed.replace(/^\.\//, '').replace(/^\/+/, '');
|
|
return `${assetBase}${normalized}`;
|
|
}
|
|
};
|
|
|
|
const rewriteAssets = (root) => {
|
|
root.querySelectorAll('script').forEach((node) => node.remove());
|
|
root.querySelectorAll('[src]').forEach((node) => {
|
|
const resolved = resolveAsset(node.getAttribute('src'));
|
|
if (resolved) {
|
|
node.setAttribute('src', resolved);
|
|
}
|
|
});
|
|
root.querySelectorAll('link[href]').forEach((node) => {
|
|
const resolved = resolveAsset(node.getAttribute('href'));
|
|
if (resolved) {
|
|
node.setAttribute('href', resolved);
|
|
}
|
|
});
|
|
};
|
|
|
|
const applyStyles = (root) => {
|
|
while (activeStyleNodes.length) {
|
|
const node = activeStyleNodes.pop();
|
|
node?.remove();
|
|
}
|
|
|
|
root.querySelectorAll('style').forEach((styleNode) => {
|
|
const clone = styleNode.cloneNode(true);
|
|
document.head.append(clone);
|
|
activeStyleNodes.push(clone);
|
|
styleNode.remove();
|
|
});
|
|
|
|
root.querySelectorAll('link[rel="stylesheet"]').forEach((linkNode) => {
|
|
const href = linkNode.getAttribute('href');
|
|
if (!href) {
|
|
linkNode.remove();
|
|
return;
|
|
}
|
|
const clone = document.createElement('link');
|
|
clone.rel = 'stylesheet';
|
|
clone.href = href;
|
|
document.head.append(clone);
|
|
activeStyleNodes.push(clone);
|
|
linkNode.remove();
|
|
});
|
|
};
|
|
|
|
const updateControls = () => {
|
|
const hasChapters = chapters.length > 0;
|
|
if (prevBtn) {
|
|
prevBtn.disabled = !hasChapters || currentIndex <= 0;
|
|
}
|
|
if (nextBtn) {
|
|
nextBtn.disabled = !hasChapters || currentIndex >= chapters.length - 1;
|
|
}
|
|
if (chapterSelect && hasChapters) {
|
|
chapterSelect.value = String(currentIndex);
|
|
}
|
|
if (titleEl) {
|
|
const current = chapters[currentIndex];
|
|
titleEl.textContent = current?.title ? `${current.title} · ${baseTitle}` : baseTitle;
|
|
}
|
|
};
|
|
|
|
const renderChapter = async (index, fragmentId = '') => {
|
|
if (!chapters[index]) {
|
|
setStatus('No chapter available.');
|
|
return;
|
|
}
|
|
setStatus('Loading chapter…');
|
|
currentIndex = index;
|
|
updateControls();
|
|
try {
|
|
const target = new URL(chapterUrl, window.location.origin);
|
|
target.searchParams.set('href', chapters[index].href);
|
|
const response = await fetch(target.toString(), { credentials: 'same-origin' });
|
|
if (!response.ok) {
|
|
throw new Error(`HTTP ${response.status}`);
|
|
}
|
|
const payload = await response.json();
|
|
const content = payload?.content || '';
|
|
const doc = parser.parseFromString(content, 'text/html');
|
|
rewriteAssets(doc);
|
|
applyStyles(doc);
|
|
const fragment = document.createDocumentFragment();
|
|
const body = doc.body || doc.documentElement;
|
|
body.childNodes.forEach((node) => {
|
|
fragment.append(node.cloneNode(true));
|
|
});
|
|
readerEl?.replaceChildren(fragment);
|
|
if (readerEl) {
|
|
readerEl.scrollTop = 0;
|
|
if (fragmentId) {
|
|
const target = readerEl.querySelector(`#${escapeFragment(fragmentId)}`);
|
|
if (target instanceof HTMLElement) {
|
|
target.scrollIntoView({ block: 'start', behavior: 'auto' });
|
|
}
|
|
}
|
|
}
|
|
setStatus(`Chapter ${index + 1} of ${chapters.length}`);
|
|
updateControls();
|
|
} catch (error) {
|
|
console.error('Failed to load chapter', error);
|
|
setStatus('Unable to load this chapter. Try another one.');
|
|
}
|
|
};
|
|
|
|
const initChapters = () => {
|
|
chapterSelect?.replaceChildren();
|
|
if (!chapters.length) {
|
|
setStatus('No chapters found in this book.');
|
|
if (chapterSelect) {
|
|
const option = document.createElement('option');
|
|
option.textContent = 'No chapters';
|
|
option.value = '';
|
|
chapterSelect.append(option);
|
|
chapterSelect.disabled = true;
|
|
}
|
|
updateControls();
|
|
readerEl.textContent = 'This EPUB does not include any readable chapters.';
|
|
return;
|
|
}
|
|
|
|
chapterSelect?.removeAttribute('disabled');
|
|
chapters.forEach((chapter, idx) => {
|
|
const option = document.createElement('option');
|
|
option.value = String(idx);
|
|
option.textContent = chapter.title || `Chapter ${idx + 1}`;
|
|
chapterSelect?.append(option);
|
|
});
|
|
|
|
chapterSelect?.addEventListener('change', (event) => {
|
|
const target = event.target;
|
|
if (!(target instanceof HTMLSelectElement)) {
|
|
return;
|
|
}
|
|
const value = Number.parseInt(target.value, 10);
|
|
if (!Number.isNaN(value)) {
|
|
renderChapter(value);
|
|
}
|
|
});
|
|
|
|
prevBtn?.addEventListener('click', () => {
|
|
if (currentIndex > 0) {
|
|
renderChapter(currentIndex - 1);
|
|
}
|
|
});
|
|
|
|
nextBtn?.addEventListener('click', () => {
|
|
if (currentIndex < chapters.length - 1) {
|
|
renderChapter(currentIndex + 1);
|
|
}
|
|
});
|
|
|
|
document.addEventListener('keydown', (event) => {
|
|
if (event.defaultPrevented || event.altKey || event.metaKey || event.ctrlKey) {
|
|
return;
|
|
}
|
|
const target = event.target;
|
|
if (target instanceof HTMLElement) {
|
|
const tag = target.tagName.toLowerCase();
|
|
if (tag === 'input' || tag === 'textarea' || tag === 'select' || target.isContentEditable) {
|
|
return;
|
|
}
|
|
}
|
|
if (event.key === 'ArrowLeft') {
|
|
if (currentIndex > 0) {
|
|
renderChapter(currentIndex - 1);
|
|
}
|
|
} else if (event.key === 'ArrowRight') {
|
|
if (currentIndex < chapters.length - 1) {
|
|
renderChapter(currentIndex + 1);
|
|
}
|
|
}
|
|
});
|
|
|
|
renderChapter(0);
|
|
};
|
|
|
|
initChapters();
|
|
|
|
readerEl?.addEventListener('click', (event) => {
|
|
const anchor = event.target instanceof Element ? event.target.closest('a[href]') : null;
|
|
if (!anchor) {
|
|
return;
|
|
}
|
|
const href = anchor.getAttribute('href') || '';
|
|
if (!href || href.startsWith('http://') || href.startsWith('https://') || href.startsWith('mailto:')) {
|
|
return;
|
|
}
|
|
if (href.startsWith('#')) {
|
|
event.preventDefault();
|
|
const fragmentId = href.slice(1);
|
|
const target = readerEl.querySelector(`#${escapeFragment(fragmentId)}`);
|
|
if (target instanceof HTMLElement) {
|
|
target.scrollIntoView({ block: 'start', behavior: 'auto' });
|
|
}
|
|
return;
|
|
}
|
|
const [resource, fragment] = href.split('#', 2);
|
|
const matchIndex = chapters.findIndex((chapter) => {
|
|
const normalized = (chapter.href || '').split('#', 1)[0];
|
|
return normalized === resource;
|
|
});
|
|
if (matchIndex >= 0) {
|
|
event.preventDefault();
|
|
renderChapter(matchIndex, fragment || '');
|
|
}
|
|
});
|
|
</script>
|
|
</body>
|
|
</html>
|