feat: chapter list drawer in sticky header + remove duplicate title heading
Some checks failed
CI / Lint (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Build (push) Has been cancelled

- Remove the duplicate <h1> that appeared in the content area below the
  sticky bar (title is already shown in the header)
- Replace the static title span with a clickable button (title + ▼ caret)
  that opens a full-width chapter list drawer below the sticky bar
- Drawer is server-rendered with all chapters; current chapter is highlighted
  in amber and scrolled into view on open; clicking any row navigates to it
  and closes the drawer
- Opening the chapter list closes the settings panel and vice versa
- Both panels dismiss on outside click
- Pass AllChapters to the chapter template data struct
This commit is contained in:
Admin
2026-03-01 20:18:45 +05:00
parent de998d5932
commit 79f5edf80c

View File

@@ -1075,8 +1075,14 @@ const chapterTmpl = `
<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}}
<!-- 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>
<!-- Chapter title button — opens chapter list drawer -->
<button id="chapter-list-btn"
onclick="toggleChapterList()"
title="Jump to chapter"
style="flex:1;min-width:0;display:flex;align-items:center;justify-content:center;gap:0.375rem;overflow:hidden;background:transparent;border:none;cursor:pointer;padding:0.375rem 0.25rem;">
<span style="overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:0.8125rem;color:#a1a1aa;">{{.Title}}</span>
<span style="flex-shrink:0;font-size:0.625rem;color:#52525b;">&#9660;</span>
</button>
<!-- Next chapter -->
{{if .NextN}}
@@ -1119,6 +1125,28 @@ const chapterTmpl = `
<span id="tts-status"></span>
</div>
<!-- Chapter list drawer -->
<div id="chapter-list-panel"
style="display:none;position:absolute;left:0;right:0;top:100%;max-height:60vh;overflow-y:auto;background:#18181b;border-bottom:1px solid #27272a;box-shadow:0 8px 24px rgba(0,0,0,0.6);z-index:99;">
<div style="max-width:42rem;margin:0 auto;padding:0.5rem 0;">
{{range .AllChapters}}
<a href="/books/{{$.Slug}}/chapters/{{.Number}}"
hx-get="/books/{{$.Slug}}/chapters/{{.Number}}"
hx-target="#main-content"
hx-push-url="true"
hx-swap="innerHTML"
onclick="closeChapterList()"
{{if eq .Number $.ChapterN}}data-current="1"{{end}}
style="display:flex;align-items:baseline;gap:0.75rem;padding:0.5rem 1rem;text-decoration:none;transition:background 0.1s;{{if eq .Number $.ChapterN}}background:#27272a;{{end}}"
onmouseover="this.style.background='#27272a'" onmouseout="this.style.background='{{if eq .Number $.ChapterN}}#27272a{{else}}transparent{{end}}'">
<span style="flex-shrink:0;font-size:0.75rem;color:#52525b;width:2.5rem;text-align:right;">{{.Number}}</span>
<span style="font-size:0.875rem;{{if eq .Number $.ChapterN}}color:#fbbf24;font-weight:500;{{else}}color:#d4d4d8;{{end}}">{{if .Title}}{{.Title}}{{else}}Chapter {{.Number}}{{end}}</span>
{{if eq .Number $.ChapterN}}<span style="flex-shrink:0;font-size:0.7rem;color:#f59e0b;margin-left:auto;">&#9654; now</span>{{end}}
</a>
{{end}}
</div>
</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;">
@@ -1152,10 +1180,6 @@ const chapterTmpl = `
<!-- ─── 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}}
@@ -1238,19 +1262,49 @@ const chapterTmpl = `
// ── settings panel toggle ─────────────────────────────────────────────────────
var settingsPanel = document.getElementById('settings-panel');
var chapterListPanel = document.getElementById('chapter-list-panel');
var chapterListBtn = document.getElementById('chapter-list-btn');
window.closeChapterList = function () {
chapterListPanel.style.display = 'none';
chapterListBtn.querySelector('span:last-child').innerHTML = '&#9660;';
};
window.toggleChapterList = function () {
var open = chapterListPanel.style.display !== 'none';
// Close settings if open
settingsPanel.style.display = 'none';
if (open) {
closeChapterList();
} else {
chapterListPanel.style.display = 'block';
chapterListBtn.querySelector('span:last-child').innerHTML = '&#9650;';
// Scroll the current chapter row into view inside the panel
var current = chapterListPanel.querySelector('[data-current="1"]');
if (current) current.scrollIntoView({ block: 'center' });
}
};
window.toggleSettings = function () {
var open = settingsPanel.style.display !== 'none';
// Close chapter list if open
closeChapterList();
settingsPanel.style.display = open ? 'none' : 'block';
};
// Close settings panel when clicking outside
// Close both panels 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)) {
var settingsBtn = document.getElementById('settings-btn');
if (settingsPanel.style.display !== 'none' &&
!settingsPanel.contains(e.target) &&
e.target !== settingsBtn && !settingsBtn.contains(e.target)) {
settingsPanel.style.display = 'none';
}
if (chapterListPanel.style.display !== 'none' &&
!chapterListPanel.contains(e.target) &&
e.target !== chapterListBtn && !chapterListBtn.contains(e.target)) {
closeChapterList();
}
});
function setStatus(text) {
@@ -1848,6 +1902,7 @@ func (s *Server) handleChapter(w http.ResponseWriter, r *http.Request) {
NextN int
ChapterN int
Title string
AllChapters interface{}
KokoroURL string
Voices []string
DefaultVoice string
@@ -1858,6 +1913,7 @@ func (s *Server) handleChapter(w http.ResponseWriter, r *http.Request) {
NextN: nextN,
ChapterN: n,
Title: title,
AllChapters: chapters,
KokoroURL: s.kokoroURL,
Voices: kokoroVoices,
DefaultVoice: s.kokoroVoice,