feat: sticky chapter reader toolbar with settings menu
Some checks failed
CI / Lint (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Build (push) Has been cancelled

Replace the separate top nav bar and TTS player bar with a single compact
sticky header that stays visible while scrolling. Navigation (← back, Prev,
Next) and the Listen/Pause button are always accessible. Voice, speed and
auto-next settings are moved into a ⚙ dropdown that opens from the header,
keeping the reading area clean. A thin status strip below the toolbar shows
Buffering/Playing/Error states only when active.
This commit is contained in:
Admin
2026-03-01 20:11:39 +05:00
parent ffadf7a0c6
commit 5b527919f6

View File

@@ -1018,84 +1018,119 @@ func (s *Server) handleBookChaptersPage(w http.ResponseWriter, r *http.Request)
// ─── GET /books/{slug}/chapters/{n} — chapter reader ─────────────────────────
const chapterTmpl = `
<div class="max-w-2xl mx-auto px-4 py-10">
<nav aria-label="Chapter navigation" class="flex items-center justify-between mb-8">
<!-- ─── Sticky reader toolbar ──────────────────────────────────────────────── -->
<header id="reader-header" style="position:sticky;top:0;z-index:50;background:#09090b;border-bottom:1px solid #27272a;">
<div class="max-w-2xl mx-auto px-4 flex items-center gap-2 h-12">
<!-- Back to book -->
<a href="/books/{{.Slug}}"
hx-get="/books/{{.Slug}}"
hx-target="#main-content"
hx-push-url="true"
hx-swap="innerHTML"
class="text-sm text-zinc-400 hover:text-amber-400 inline-flex items-center gap-1">
← Chapter list
title="Chapter list"
style="flex-shrink:0;padding:0.375rem 0.5rem;border-radius:0.5rem;color:#a1a1aa;font-size:0.875rem;text-decoration:none;transition:color 0.15s;"
onmouseover="this.style.color='#fbbf24'" onmouseout="this.style.color='#a1a1aa'">
&#8592;
</a>
<div class="flex gap-2">
{{if .PrevN}}
<a href="/books/{{.Slug}}/chapters/{{.PrevN}}"
hx-get="/books/{{.Slug}}/chapters/{{.PrevN}}"
hx-target="#main-content"
hx-push-url="true"
hx-swap="innerHTML"
class="text-sm px-3 py-1.5 rounded-lg bg-zinc-800 hover:bg-zinc-700 text-zinc-300 transition-colors">
← Prev
</a>
{{end}}
{{if .NextN}}
<a href="/books/{{.Slug}}/chapters/{{.NextN}}"
hx-get="/books/{{.Slug}}/chapters/{{.NextN}}"
hx-target="#main-content"
hx-push-url="true"
hx-swap="innerHTML"
class="text-sm px-3 py-1.5 rounded-lg bg-zinc-800 hover:bg-zinc-700 text-zinc-300 transition-colors">
Next →
</a>
{{end}}
</div>
</nav>
<header>
<h1 class="text-2xl font-bold text-zinc-100 mb-6">{{.Title}}</h1>
</header>
<!-- Prev chapter -->
{{if .PrevN}}
<a href="/books/{{.Slug}}/chapters/{{.PrevN}}"
hx-get="/books/{{.Slug}}/chapters/{{.PrevN}}"
hx-target="#main-content"
hx-push-url="true"
hx-swap="innerHTML"
title="Previous chapter"
style="flex-shrink:0;padding:0.375rem 0.625rem;border-radius:0.5rem;background:#27272a;color:#d4d4d8;font-size:0.8125rem;text-decoration:none;transition:background 0.15s;"
onmouseover="this.style.background='#3f3f46'" onmouseout="this.style.background='#27272a'">
&#8592; Prev
</a>
{{else}}
<span style="flex-shrink:0;padding:0.375rem 0.625rem;border-radius:0.5rem;color:#52525b;font-size:0.8125rem;cursor:default;">&#8592; Prev</span>
{{end}}
<!-- TTS player bar — calls Kokoro directly from the browser -->
<div id="tts-bar" class="mb-6 rounded-xl border border-zinc-800 bg-zinc-900 px-4 py-3">
<div class="flex items-center gap-3 flex-wrap">
<!-- Chapter title (centre, truncated) -->
<span style="flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;text-align:center;font-size:0.8125rem;color:#a1a1aa;" title="{{.Title}}">{{.Title}}</span>
<!-- Play/pause button -->
<button id="tts-btn"
onclick="ttsToggle()"
class="flex items-center gap-2 text-sm px-3 py-1.5 rounded-lg bg-amber-600 hover:bg-amber-500 text-white font-medium transition-colors">
<span id="tts-icon">&#9654;</span>
<span id="tts-label">Listen</span>
</button>
<!-- Next chapter -->
{{if .NextN}}
<a href="/books/{{.Slug}}/chapters/{{.NextN}}"
hx-get="/books/{{.Slug}}/chapters/{{.NextN}}"
hx-target="#main-content"
hx-push-url="true"
hx-swap="innerHTML"
title="Next chapter"
style="flex-shrink:0;padding:0.375rem 0.625rem;border-radius:0.5rem;background:#27272a;color:#d4d4d8;font-size:0.8125rem;text-decoration:none;transition:background 0.15s;"
onmouseover="this.style.background='#3f3f46'" onmouseout="this.style.background='#27272a'">
Next &#8594;
</a>
{{else}}
<span style="flex-shrink:0;padding:0.375rem 0.625rem;border-radius:0.5rem;color:#52525b;font-size:0.8125rem;cursor:default;">Next &#8594;</span>
{{end}}
<!-- Voice selector -->
<!-- TTS play/pause button -->
<button id="tts-btn"
onclick="ttsToggle()"
title="Listen"
style="flex-shrink:0;display:flex;align-items:center;gap:0.375rem;padding:0.375rem 0.75rem;border-radius:0.5rem;background:#d97706;color:#fff;font-size:0.8125rem;font-weight:500;border:none;cursor:pointer;transition:background 0.15s;"
onmouseover="this.style.background='#b45309'" onmouseout="this.style.background='#d97706'">
<span id="tts-icon">&#9654;</span>
<span id="tts-label">Listen</span>
</button>
<!-- Settings gear button -->
<button id="settings-btn"
onclick="toggleSettings()"
title="Settings"
style="flex-shrink:0;padding:0.375rem 0.5rem;border-radius:0.5rem;background:transparent;color:#a1a1aa;font-size:1rem;border:none;cursor:pointer;transition:color 0.15s;"
onmouseover="this.style.color='#fbbf24'" onmouseout="this.style.color='#a1a1aa'">
&#9881;
</button>
</div>
<!-- TTS status strip (shows Buffering / Playing / Error under the bar) -->
<div id="tts-status-bar" style="display:none;padding:0.125rem 1rem;background:#18181b;font-size:0.75rem;color:#71717a;text-align:center;">
<span id="tts-status"></span>
</div>
<!-- Settings dropdown panel -->
<div id="settings-panel"
style="display:none;position:absolute;right:max(0.5rem,calc(50% - 32rem + 0.5rem));top:calc(100% + 0.25rem);min-width:260px;background:#18181b;border:1px solid #27272a;border-radius:0.75rem;padding:1rem;box-shadow:0 8px 24px rgba(0,0,0,0.5);z-index:100;">
<!-- Voice -->
<label style="display:block;margin-bottom:0.875rem;">
<span style="display:block;font-size:0.75rem;color:#71717a;margin-bottom:0.375rem;">Voice</span>
<select id="tts-voice"
class="rounded-lg bg-zinc-800 border border-zinc-700 px-2 py-1.5 text-sm text-zinc-200 focus:outline-none focus:border-amber-500 transition-colors">
style="width:100%;border-radius:0.5rem;background:#27272a;border:1px solid #3f3f46;padding:0.375rem 0.5rem;font-size:0.875rem;color:#e4e4e7;outline:none;">
{{range .Voices}}
<option value="{{.}}"{{if eq . $.DefaultVoice}} selected{{end}}>{{.}}</option>
{{end}}
</select>
<!-- Speed control -->
<div class="flex items-center gap-1.5">
<span class="text-xs text-zinc-500 whitespace-nowrap">Speed</span>
<input id="tts-speed" type="range"
min="0.5" max="2" step="0.1" value="1"
class="w-20 accent-amber-500 cursor-pointer" />
<span id="tts-speed-label" class="text-xs text-zinc-400 w-7 text-right">1.0×</span>
</div>
<div id="tts-status" class="text-xs text-zinc-500 ml-1"></div>
<!-- Auto-play next chapter toggle -->
<label class="flex items-center gap-1.5 ml-auto cursor-pointer select-none" title="Automatically start the next chapter when this one finishes">
<input id="tts-autoplay" type="checkbox" class="accent-amber-500 cursor-pointer" />
<span class="text-xs text-zinc-400 whitespace-nowrap">Auto-next</span>
</label>
</div>
<audio id="tts-audio" style="display:none"></audio>
</label>
<!-- Speed -->
<label style="display:block;margin-bottom:0.875rem;">
<span style="display:block;font-size:0.75rem;color:#71717a;margin-bottom:0.375rem;">Speed — <span id="tts-speed-label">1.0×</span></span>
<input id="tts-speed" type="range"
min="0.5" max="2" step="0.1" value="1"
style="width:100%;accent-color:#f59e0b;cursor:pointer;" />
</label>
<!-- Auto-next -->
<label style="display:flex;align-items:center;gap:0.5rem;cursor:pointer;user-select:none;">
<input id="tts-autoplay" type="checkbox" style="accent-color:#f59e0b;cursor:pointer;width:1rem;height:1rem;" />
<span style="font-size:0.875rem;color:#d4d4d8;">Auto-play next chapter</span>
</label>
</div>
<audio id="tts-audio" style="display:none"></audio>
</header>
<!-- ─── Chapter content ────────────────────────────────────────────────────── -->
<div class="max-w-2xl mx-auto px-4 py-10">
<header>
<h1 class="text-2xl font-bold text-zinc-100 mb-6">{{.Title}}</h1>
</header>
<!-- Paragraphs get data-para-idx injected by JS after render -->
<article id="chapter-article" class="prose text-zinc-300 leading-relaxed text-[1.05rem]">
{{.HTML}}
@@ -1142,6 +1177,8 @@ const chapterTmpl = `
border-left: 2px solid #f59e0b;
padding-left: calc(0.5rem - 2px);
}
/* Prevent sticky header from covering anchor targets */
#main-content { scroll-padding-top: 3.5rem; }
</style>
<script>
@@ -1166,13 +1203,36 @@ const chapterTmpl = `
var btn = document.getElementById('tts-btn');
var icon = document.getElementById('tts-icon');
var label = document.getElementById('tts-label');
var status = document.getElementById('tts-status');
var statusEl = document.getElementById('tts-status');
var statusBar = document.getElementById('tts-status-bar');
var voiceSel = document.getElementById('tts-voice');
var speedSlider = document.getElementById('tts-speed');
var speedLabel = document.getElementById('tts-speed-label');
var autoplayChk = document.getElementById('tts-autoplay');
var article = document.getElementById('chapter-article');
// ── settings panel toggle ─────────────────────────────────────────────────────
var settingsPanel = document.getElementById('settings-panel');
window.toggleSettings = function () {
var open = settingsPanel.style.display !== 'none';
settingsPanel.style.display = open ? 'none' : 'block';
};
// Close settings panel when clicking outside
document.addEventListener('click', function (e) {
if (settingsPanel.style.display === 'none') return;
var btn = document.getElementById('settings-btn');
if (!settingsPanel.contains(e.target) && e.target !== btn && !btn.contains(e.target)) {
settingsPanel.style.display = 'none';
}
});
function setStatus(text) {
statusEl.textContent = text;
statusBar.style.display = text ? '' : 'none';
}
var abortCtrl = null;
var mse = null;
var srcBuf = null;
@@ -1272,9 +1332,10 @@ const chapterTmpl = `
highlightPara(paraIdx);
icon.textContent = '\u231B';
label.textContent = 'Buffering';
status.textContent = 'Fetching from Kokoro\u2026';
setStatus('Fetching from Kokoro\u2026');
btn.disabled = true;
btn.classList.add('opacity-60', 'cursor-not-allowed');
btn.style.opacity = '0.6';
btn.style.cursor = 'not-allowed';
voiceSel.disabled = true;
speedSlider.disabled = true;
}
@@ -1282,24 +1343,26 @@ const chapterTmpl = `
function setPlaying() {
icon.innerHTML = '&#9646;&#9646;';
label.textContent = 'Pause';
status.textContent = 'Playing';
setStatus('Playing');
btn.disabled = false;
btn.classList.remove('opacity-60', 'cursor-not-allowed');
btn.style.opacity = '1';
btn.style.cursor = 'pointer';
}
function setPaused() {
icon.innerHTML = '&#9654;';
label.textContent = 'Resume';
status.textContent = 'Paused';
setStatus('Paused');
}
function setStopped() {
highlightPara(-1);
icon.innerHTML = '&#9654;';
label.textContent = 'Listen';
status.textContent = '';
setStatus('');
btn.disabled = false;
btn.classList.remove('opacity-60', 'cursor-not-allowed');
btn.style.opacity = '1';
btn.style.cursor = 'pointer';
voiceSel.disabled = false;
speedSlider.disabled = false;
}
@@ -1308,9 +1371,10 @@ const chapterTmpl = `
highlightPara(-1);
icon.innerHTML = '&#9654;';
label.textContent = 'Listen';
status.textContent = 'Error: ' + msg;
setStatus('Error: ' + msg);
btn.disabled = false;
btn.classList.remove('opacity-60', 'cursor-not-allowed');
btn.style.opacity = '1';
btn.style.cursor = 'pointer';
voiceSel.disabled = false;
speedSlider.disabled = false;
}
@@ -1570,7 +1634,7 @@ const chapterTmpl = `
// Fetch the full audio as a single blob then hand it to the <audio> element.
// Playback begins only once the download is complete, but this is the only
// approach that works reliably on iOS Safari.
status.textContent = 'Downloading\u2026';
setStatus('Downloading\u2026');
fetch(KOKORO_URL + '/v1/audio/speech', {
method: 'POST',
signal: abortCtrl.signal,
@@ -1678,7 +1742,7 @@ const chapterTmpl = `
audio.play().then(setPlaying).catch(function (e) { setError(e.message); });
}
});
audio.addEventListener('waiting', function () { status.textContent = 'Buffering\u2026'; });
audio.addEventListener('waiting', function () { setStatus('Buffering\u2026'); });
audio.addEventListener('playing', setPlaying);
audio.addEventListener('ended', function () {
// Cancel the main stream state but keep prefetch alive — goNextChapter needs it.