feat: paginate chapter list and track reading progress in localStorage

Chapter list:
- Book page now shows the first 50 chapters only; a 'Load more' button
  (HTMX, GET /books/{slug}/chapters-page?page=N) appends the next page
  without a full navigation, replacing itself with the next load-more
  button or disappearing when all chapters are loaded.

Reading progress:
- Visiting a chapter saves {slug: chapterN} to localStorage under
  'reading_progress'.
- The book page reads that key on load and, if a saved chapter exists,
  highlights the saved chapter row with an amber dot and shows a
  'Resume — Chapter N' button that navigates directly to it.
- Progress dots are also re-applied after each Load More via
  hx-on::after-request.
This commit is contained in:
Admin
2026-03-01 16:39:26 +05:00
parent 286c30696f
commit bcdef02997
2 changed files with 193 additions and 11 deletions

View File

@@ -62,6 +62,7 @@ func (s *Server) ListenAndServe(ctx context.Context) error {
mux.HandleFunc("GET /ranking/view", s.handleRankingView)
mux.HandleFunc("GET /books/{slug}", s.handleBook)
mux.HandleFunc("GET /books/{slug}/chapters/{n}", s.handleChapter)
mux.HandleFunc("GET /books/{slug}/chapters-page", s.handleBookChaptersPage)
mux.HandleFunc("POST /ui/scrape/book", s.handleUIScrapeBook)
mux.HandleFunc("GET /ui/scrape/status", s.handleUIScrapeStatus)
// Plain-text chapter content for browser-side TTS

View File

@@ -459,6 +459,8 @@ func (s *Server) handleRankingView(w http.ResponseWriter, r *http.Request) {
// ─── GET /books/{slug} — chapter list ────────────────────────────────────────
const chapterPageSize = 50
const bookTmpl = `
<div class="max-w-2xl mx-auto px-4 py-10">
<a href="/"
@@ -474,8 +476,8 @@ const bookTmpl = `
{{if .Meta.Cover}}
<img src="{{.Meta.Cover}}" alt="cover" class="w-24 h-36 object-cover rounded-lg flex-shrink-0 shadow-lg">
{{end}}
<div>
<div class="flex items-center gap-3">
<div class="flex-1 min-w-0">
<div class="flex items-center gap-3 flex-wrap">
<h1 class="text-2xl font-bold text-zinc-100">{{.Meta.Title}}</h1>
{{if .Meta.SourceURL}}
<form hx-post="/ui/scrape/book" hx-swap="outerHTML" hx-target="closest div">
@@ -492,7 +494,7 @@ const bookTmpl = `
<div class="flex gap-2 mt-2 flex-wrap">
{{if .Meta.Status}}<span class="text-xs px-2 py-0.5 rounded-full bg-zinc-800 text-zinc-300">{{.Meta.Status}}</span>{{end}}
{{if .Meta.TotalChapters}}<span class="text-xs px-2 py-0.5 rounded-full bg-zinc-800 text-zinc-300">{{.Meta.TotalChapters}} ch total</span>{{end}}
<span class="text-xs px-2 py-0.5 rounded-full bg-amber-900 text-amber-300">{{len .Chapters}} downloaded</span>
<span class="text-xs px-2 py-0.5 rounded-full bg-amber-900 text-amber-300">{{.TotalDownloaded}} downloaded</span>
</div>
{{if .Meta.Summary}}
<p class="text-zinc-400 text-sm mt-3 line-clamp-3">{{.Meta.Summary}}</p>
@@ -500,8 +502,19 @@ const bookTmpl = `
</div>
</div>
<!-- Resume reading button — populated by JS from localStorage -->
<div id="resume-bar" class="mb-4 hidden">
<a id="resume-link"
hx-target="#main-content"
hx-push-url="true"
hx-swap="innerHTML"
class="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-amber-700 hover:bg-amber-600 text-white text-sm font-medium transition-colors cursor-pointer">
▶ Resume — Chapter <span id="resume-chapter-num"></span>
</a>
</div>
<h2 class="text-lg font-semibold text-zinc-200 mb-3">Chapters</h2>
<ul class="space-y-1">
<ul id="chapter-list" class="space-y-1">
{{range .Chapters}}
<li>
<a href="/books/{{$.Slug}}/chapters/{{.Number}}"
@@ -509,19 +522,76 @@ const bookTmpl = `
hx-target="#main-content"
hx-push-url="true"
hx-swap="innerHTML"
class="flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-zinc-800 transition-colors group cursor-pointer">
class="chapter-row flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-zinc-800 transition-colors group cursor-pointer"
data-chapter="{{.Number}}">
<span class="text-xs text-zinc-500 w-10 text-right flex-shrink-0">{{.Number}}</span>
<div class="min-w-0">
<div class="min-w-0 flex-1">
<span class="text-zinc-300 group-hover:text-amber-400 truncate block">{{.Title}}</span>
{{if .Date}}<span class="text-xs text-zinc-500 block mt-0.5">{{.Date}}</span>{{end}}
</div>
<span class="progress-dot hidden text-amber-400 text-xs flex-shrink-0">●</span>
</a>
</li>
{{else}}
<li class="text-zinc-500 px-3 py-2">No chapters downloaded yet.</li>
{{end}}
</ul>
</div>`
{{if .HasMore}}
<div id="load-more-wrap" class="mt-4 text-center">
<button
hx-get="/books/{{.Slug}}/chapters-page?page={{.NextPage}}"
hx-target="#chapter-list"
hx-swap="beforeend"
hx-on::after-request="applyProgress('{{.Slug}}')"
class="px-4 py-2 rounded-lg bg-zinc-800 hover:bg-zinc-700 text-zinc-300 text-sm transition-colors">
Load more chapters
</button>
</div>
{{end}}
</div>
<script>
(function () {
var SLUG = '{{.Slug}}';
var LS_KEY = 'reading_progress';
function getProgress() {
try { return JSON.parse(localStorage.getItem(LS_KEY) || '{}'); } catch(_) { return {}; }
}
window.applyProgress = function(slug) {
var progress = getProgress();
var saved = progress[slug];
if (!saved) return;
// Highlight the saved chapter row.
document.querySelectorAll('.chapter-row').forEach(function (a) {
var dot = a.querySelector('.progress-dot');
if (!dot) return;
if (parseInt(a.dataset.chapter) === saved) {
dot.classList.remove('hidden');
a.classList.add('bg-zinc-800/50');
}
});
// Show resume bar.
var bar = document.getElementById('resume-bar');
var link = document.getElementById('resume-link');
var num = document.getElementById('resume-chapter-num');
if (bar && link && num) {
var href = '/books/' + slug + '/chapters/' + saved;
num.textContent = saved;
link.setAttribute('href', href);
link.setAttribute('hx-get', href);
htmx.process(link);
bar.classList.remove('hidden');
}
};
applyProgress(SLUG);
}());
</script>`
func (s *Server) handleBook(w http.ResponseWriter, r *http.Request) {
slug := r.PathValue("slug")
@@ -542,17 +612,116 @@ func (s *Server) handleBook(w http.ResponseWriter, r *http.Request) {
return
}
total := len(chapters)
page := chapters
hasMore := false
if total > chapterPageSize {
page = chapters[:chapterPageSize]
hasMore = true
}
t := template.Must(template.New("book").Parse(bookTmpl))
var buf bytes.Buffer
_ = t.Execute(&buf, struct {
Slug string
Meta interface{}
Chapters interface{}
}{Slug: slug, Meta: meta, Chapters: chapters})
Slug string
Meta interface{}
Chapters interface{}
TotalDownloaded int
HasMore bool
NextPage int
}{
Slug: slug,
Meta: meta,
Chapters: page,
TotalDownloaded: total,
HasMore: hasMore,
NextPage: 2,
})
s.respond(w, r, meta.Title, buf.String())
}
// ─── GET /books/{slug}/chapters-page — paginated chapter list fragment ────────
const chapterPageTmpl = `{{range .Chapters}}
<li>
<a href="/books/{{$.Slug}}/chapters/{{.Number}}"
hx-get="/books/{{$.Slug}}/chapters/{{.Number}}"
hx-target="#main-content"
hx-push-url="true"
hx-swap="innerHTML"
class="chapter-row flex items-center gap-3 px-3 py-2 rounded-lg hover:bg-zinc-800 transition-colors group cursor-pointer"
data-chapter="{{.Number}}">
<span class="text-xs text-zinc-500 w-10 text-right flex-shrink-0">{{.Number}}</span>
<div class="min-w-0 flex-1">
<span class="text-zinc-300 group-hover:text-amber-400 truncate block">{{.Title}}</span>
{{if .Date}}<span class="text-xs text-zinc-500 block mt-0.5">{{.Date}}</span>{{end}}
</div>
<span class="progress-dot hidden text-amber-400 text-xs flex-shrink-0">●</span>
</a>
</li>
{{end}}
{{if .HasMore}}
<li id="load-more-wrap" hx-swap-oob="true">
<div class="mt-4 text-center">
<button
hx-get="/books/{{.Slug}}/chapters-page?page={{.NextPage}}"
hx-target="#chapter-list"
hx-swap="beforeend"
hx-on::after-request="applyProgress('{{.Slug}}')"
class="px-4 py-2 rounded-lg bg-zinc-800 hover:bg-zinc-700 text-zinc-300 text-sm transition-colors">
Load more chapters
</button>
</div>
</li>
{{else}}
<li id="load-more-wrap" hx-swap-oob="true"></li>
{{end}}`
func (s *Server) handleBookChaptersPage(w http.ResponseWriter, r *http.Request) {
slug := r.PathValue("slug")
page := 1
if p := r.URL.Query().Get("page"); p != "" {
if n, err := strconv.Atoi(p); err == nil && n > 0 {
page = n
}
}
chapters, err := s.writer.ListChapters(slug)
if err != nil {
http.Error(w, "failed to list chapters: "+err.Error(), http.StatusInternalServerError)
return
}
start := (page - 1) * chapterPageSize
if start >= len(chapters) {
w.WriteHeader(http.StatusNoContent)
return
}
end := start + chapterPageSize
hasMore := end < len(chapters)
if end > len(chapters) {
end = len(chapters)
}
t := template.Must(template.New("chapterPage").Parse(chapterPageTmpl))
var buf bytes.Buffer
_ = t.Execute(&buf, struct {
Slug string
Chapters interface{}
HasMore bool
NextPage int
}{
Slug: slug,
Chapters: chapters[start:end],
HasMore: hasMore,
NextPage: page + 1,
})
w.Header().Set("Content-Type", "text/html; charset=utf-8")
_, _ = buf.WriteTo(w)
}
// ─── GET /books/{slug}/chapters/{n} — chapter reader ─────────────────────────
const chapterTmpl = `
@@ -683,6 +852,18 @@ const chapterTmpl = `
var KOKORO_URL = '{{.KokoroURL}}';
var NEXT_N = {{.NextN}};
var SLUG = '{{.Slug}}';
var CHAPTER_N = {{.ChapterN}};
// ── reading progress ──────────────────────────────────────────────────────────
// Save current chapter to localStorage so the book page can show a resume button.
(function saveProgress() {
var LS_KEY = 'reading_progress';
try {
var progress = JSON.parse(localStorage.getItem(LS_KEY) || '{}');
progress[SLUG] = CHAPTER_N;
localStorage.setItem(LS_KEY, JSON.stringify(progress));
} catch(_) {}
})();
var audio = document.getElementById('tts-audio');
var btn = document.getElementById('tts-btn');