diff --git a/abogen/web/templates/reader_embed.html b/abogen/web/templates/reader_embed.html
index 2ca430f..1c4090d 100644
--- a/abogen/web/templates/reader_embed.html
+++ b/abogen/web/templates/reader_embed.html
@@ -46,13 +46,24 @@
display: flex;
align-items: center;
justify-content: space-between;
- gap: 0.75rem;
+ gap: 1rem;
padding: 0.75rem 1rem;
border-radius: 14px;
background: var(--panel);
border: 1px solid var(--border);
}
+ .reader-toolbar__group {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+ flex: 0 0 auto;
+ }
+
+ .reader-toolbar__group--end {
+ justify-content: flex-end;
+ }
+
.reader-toolbar__title {
flex: 1 1 auto;
text-align: center;
@@ -63,7 +74,8 @@
white-space: nowrap;
}
- .reader-toolbar button {
+ .reader-toolbar button,
+ .reader-chapter-panel button {
flex: 0 0 auto;
border: 1px solid var(--border);
background: rgba(30, 41, 59, 0.8);
@@ -77,17 +89,36 @@
}
.reader-toolbar button:hover,
- .reader-toolbar button:focus-visible {
+ .reader-toolbar button:focus-visible,
+ .reader-chapter-panel button:hover,
+ .reader-chapter-panel button:focus-visible {
background: rgba(56, 189, 248, 0.2);
border-color: rgba(56, 189, 248, 0.4);
outline: none;
}
- .reader-toolbar button:disabled {
+ .reader-toolbar button:disabled,
+ .reader-chapter-panel button:disabled {
opacity: 0.4;
cursor: not-allowed;
}
+ .reader-toolbar__icon {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ padding: 0.4rem;
+ width: 2.5rem;
+ height: 2.5rem;
+ border-radius: 999px;
+ }
+
+ .reader-toolbar__icon svg {
+ width: 1rem;
+ height: 1rem;
+ fill: currentColor;
+ }
+
.reader-toolbar select {
flex: 1 1 30%;
min-width: 140px;
@@ -211,24 +242,26 @@
.reader-chapter-links {
display: flex;
- flex-wrap: wrap;
+ flex-direction: column;
gap: 0.5rem;
- margin: 0.25rem 0;
- max-height: 12.5rem;
+ margin: 0;
+ max-height: 100%;
overflow-y: auto;
- padding: 0.25rem 0;
+ padding: 0.5rem 0;
}
.reader-chapter-links button {
border: 1px solid var(--border);
background: rgba(30, 41, 59, 0.7);
color: var(--text);
- border-radius: 999px;
- padding: 0.35rem 0.75rem;
- font-size: 0.85rem;
+ border-radius: 10px;
+ padding: 0.45rem 0.75rem;
+ font-size: 0.9rem;
cursor: pointer;
white-space: nowrap;
+ text-align: left;
transition: background 0.2s ease, border-color 0.2s ease, color 0.2s ease, opacity 0.2s ease;
+ width: 100%;
}
.reader-chapter-links button:hover,
@@ -254,6 +287,68 @@
color: rgba(226, 232, 240, 0.6);
}
+ .visually-hidden {
+ border: 0;
+ clip: rect(1px, 1px, 1px, 1px);
+ clip-path: inset(50%);
+ height: 1px;
+ margin: -1px;
+ overflow: hidden;
+ padding: 0;
+ position: absolute;
+ width: 1px;
+ white-space: nowrap;
+ }
+
+ .reader-chapter-panel {
+ position: fixed;
+ top: 0.75rem;
+ bottom: 0.75rem;
+ right: 0.75rem;
+ width: min(320px, 85vw);
+ background: var(--panel);
+ border: 1px solid var(--border);
+ border-radius: 16px 0 0 16px;
+ box-shadow: -6px 0 24px rgba(15, 23, 42, 0.45);
+ display: flex;
+ flex-direction: column;
+ transform: translateX(110%);
+ transition: transform 0.24s ease;
+ z-index: 20;
+ pointer-events: none;
+ }
+
+ .reader-chapter-panel.is-open {
+ transform: translateX(0);
+ pointer-events: auto;
+ }
+
+ .reader-chapter-panel__header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 0.75rem 1rem;
+ border-bottom: 1px solid var(--border);
+ }
+
+ .reader-chapter-panel__title {
+ font-weight: 600;
+ font-size: 0.95rem;
+ }
+
+ .reader-chapter-panel__body {
+ flex: 1 1 auto;
+ min-height: 0;
+ padding: 0 1rem 1rem;
+ overflow: hidden;
+ display: flex;
+ flex-direction: column;
+ }
+
+ .reader-chapter-panel__body > .reader-chapter-links {
+ flex: 1 1 auto;
+ }
+
.reader-status {
font-size: 0.85rem;
color: rgba(226, 232, 240, 0.75);
@@ -272,10 +367,27 @@
>
{% endif %}
-
Loading EPUB…
+
@@ -326,6 +451,9 @@
const playerToggleBtn = document.querySelector('[data-action="player-toggle"]');
const playbackRateSelect = document.querySelector('[data-role="playback-rate"]');
const chapterLinksEl = document.querySelector('[data-role="chapter-links"]');
+ const chapterPanel = document.querySelector('[data-role="chapter-panel"]');
+ const chapterPanelToggleBtn = document.querySelector('[data-action="toggle-chapters"]');
+ const chapterPanelCloseBtn = document.querySelector('[data-action="close-chapter-panel"]');
const bodyDataset = document.body.dataset;
const assetBase = bodyDataset.assetBase || '';
const epubUrl = bodyDataset.epubUrl || '';
@@ -770,6 +898,75 @@
let scheduledSync = 0;
let chapterLinkButtons = [];
+ let chapterPanelOpen = false;
+ let chapterPanelRestoreFocus = null;
+
+ const focusFirstChapterButton = () => {
+ if (!chapterPanelOpen || !chapterLinkButtons.length) {
+ return;
+ }
+ const firstInteractive = chapterLinkButtons.find((button) => button instanceof HTMLButtonElement && !button.disabled);
+ if (firstInteractive) {
+ try {
+ firstInteractive.focus({ preventScroll: true });
+ } catch (error) {
+ firstInteractive.focus();
+ }
+ }
+ };
+
+ const setChapterPanelOpen = (open) => {
+ if (!chapterPanel) {
+ chapterPanelOpen = false;
+ return;
+ }
+ chapterPanelOpen = Boolean(open);
+ chapterPanel.classList.toggle('is-open', chapterPanelOpen);
+ chapterPanel.setAttribute('aria-hidden', chapterPanelOpen ? 'false' : 'true');
+ if (chapterPanelToggleBtn) {
+ chapterPanelToggleBtn.setAttribute('aria-expanded', chapterPanelOpen ? 'true' : 'false');
+ }
+ if (!chapterPanelOpen && chapterPanelRestoreFocus && typeof chapterPanelRestoreFocus.focus === 'function') {
+ try {
+ chapterPanelRestoreFocus.focus({ preventScroll: true });
+ } catch (error) {
+ // ignore focus restoration failure
+ }
+ }
+ if (!chapterPanelOpen) {
+ chapterPanelRestoreFocus = null;
+ }
+ };
+
+ const openChapterPanel = () => {
+ if (!chapterPanel) {
+ return;
+ }
+ if (!chapterPanelOpen) {
+ const activeElement = document.activeElement;
+ if (activeElement instanceof HTMLElement && activeElement !== document.body) {
+ chapterPanelRestoreFocus = activeElement;
+ } else {
+ chapterPanelRestoreFocus = chapterPanelToggleBtn instanceof HTMLElement ? chapterPanelToggleBtn : null;
+ }
+ }
+ setChapterPanelOpen(true);
+ requestAnimationFrame(() => {
+ focusFirstChapterButton();
+ });
+ };
+
+ const closeChapterPanel = () => {
+ setChapterPanelOpen(false);
+ };
+
+ const toggleChapterPanel = () => {
+ if (chapterPanelOpen) {
+ closeChapterPanel();
+ } else {
+ openChapterPanel();
+ }
+ };
const updateChapterLinkState = () => {
if (!chapterLinkButtons.length) {
@@ -859,6 +1056,9 @@
chapterLinkButtons.push(button);
});
updateChapterLinkState();
+ if (chapterPanelOpen) {
+ focusFirstChapterButton();
+ }
};
const updateStatus = () => {
@@ -914,6 +1114,12 @@
const chapter = chapters[currentChapterIndex];
titleEl.textContent = chapter?.title ? `${chapter.title} · ${baseTitle}` : baseTitle;
}
+ if (chapterPanelToggleBtn) {
+ chapterPanelToggleBtn.disabled = !hasChapters;
+ if (!hasChapters && chapterPanelOpen) {
+ closeChapterPanel();
+ }
+ }
updatePlaybackControls();
updateChapterLinkState();
};
@@ -1677,6 +1883,50 @@
updatePlaybackControls();
});
+ chapterPanelToggleBtn?.addEventListener('click', (event) => {
+ event.preventDefault();
+ toggleChapterPanel();
+ });
+
+ chapterPanelCloseBtn?.addEventListener('click', (event) => {
+ event.preventDefault();
+ closeChapterPanel();
+ if (chapterPanelToggleBtn) {
+ try {
+ chapterPanelToggleBtn.focus({ preventScroll: true });
+ } catch (error) {
+ chapterPanelToggleBtn.focus();
+ }
+ }
+ });
+
+ const handleChapterPanelDismiss = (event) => {
+ if (!chapterPanelOpen || !chapterPanel) {
+ return;
+ }
+ const target = event.target;
+ if (!(target instanceof Node)) {
+ return;
+ }
+ if (chapterPanel.contains(target)) {
+ return;
+ }
+ if (chapterPanelToggleBtn && chapterPanelToggleBtn.contains(target)) {
+ return;
+ }
+ closeChapterPanel();
+ };
+
+ document.addEventListener('mousedown', handleChapterPanelDismiss);
+ document.addEventListener('touchstart', handleChapterPanelDismiss);
+
+ document.addEventListener('keydown', (event) => {
+ if (event.key === 'Escape' && chapterPanelOpen) {
+ event.preventDefault();
+ closeChapterPanel();
+ }
+ });
+
chapterLinksEl?.addEventListener('click', (event) => {
const target = event.target instanceof Element ? event.target.closest('button[data-chapter-index]') : null;
if (!(target instanceof HTMLButtonElement)) {
@@ -1688,7 +1938,11 @@
return;
}
const shouldResume = audioEl ? !audioEl.paused : false;
- navigateToChapter(idx, { preservePlayback: true, forcePlay: shouldResume });
+ navigateToChapter(idx, { preservePlayback: true, forcePlay: shouldResume }).finally(() => {
+ if (chapterPanelOpen) {
+ closeChapterPanel();
+ }
+ });
});
updatePlaybackControls();