feat: sticky chapter reader toolbar with settings menu
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:
@@ -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'">
|
||||
←
|
||||
</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'">
|
||||
← 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;">← 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">▶</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 →
|
||||
</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 →</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">▶</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'">
|
||||
⚙
|
||||
</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 = '▮▮';
|
||||
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 = '▶';
|
||||
label.textContent = 'Resume';
|
||||
status.textContent = 'Paused';
|
||||
setStatus('Paused');
|
||||
}
|
||||
|
||||
function setStopped() {
|
||||
highlightPara(-1);
|
||||
icon.innerHTML = '▶';
|
||||
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 = '▶';
|
||||
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.
|
||||
|
||||
Reference in New Issue
Block a user