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:
@@ -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
|
||||
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user