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 /ranking/view", s.handleRankingView)
|
||||||
mux.HandleFunc("GET /books/{slug}", s.handleBook)
|
mux.HandleFunc("GET /books/{slug}", s.handleBook)
|
||||||
mux.HandleFunc("GET /books/{slug}/chapters/{n}", s.handleChapter)
|
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("POST /ui/scrape/book", s.handleUIScrapeBook)
|
||||||
mux.HandleFunc("GET /ui/scrape/status", s.handleUIScrapeStatus)
|
mux.HandleFunc("GET /ui/scrape/status", s.handleUIScrapeStatus)
|
||||||
// Plain-text chapter content for browser-side TTS
|
// 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 ────────────────────────────────────────
|
// ─── GET /books/{slug} — chapter list ────────────────────────────────────────
|
||||||
|
|
||||||
|
const chapterPageSize = 50
|
||||||
|
|
||||||
const bookTmpl = `
|
const bookTmpl = `
|
||||||
<div class="max-w-2xl mx-auto px-4 py-10">
|
<div class="max-w-2xl mx-auto px-4 py-10">
|
||||||
<a href="/"
|
<a href="/"
|
||||||
@@ -474,8 +476,8 @@ const bookTmpl = `
|
|||||||
{{if .Meta.Cover}}
|
{{if .Meta.Cover}}
|
||||||
<img src="{{.Meta.Cover}}" alt="cover" class="w-24 h-36 object-cover rounded-lg flex-shrink-0 shadow-lg">
|
<img src="{{.Meta.Cover}}" alt="cover" class="w-24 h-36 object-cover rounded-lg flex-shrink-0 shadow-lg">
|
||||||
{{end}}
|
{{end}}
|
||||||
<div>
|
<div class="flex-1 min-w-0">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3 flex-wrap">
|
||||||
<h1 class="text-2xl font-bold text-zinc-100">{{.Meta.Title}}</h1>
|
<h1 class="text-2xl font-bold text-zinc-100">{{.Meta.Title}}</h1>
|
||||||
{{if .Meta.SourceURL}}
|
{{if .Meta.SourceURL}}
|
||||||
<form hx-post="/ui/scrape/book" hx-swap="outerHTML" hx-target="closest div">
|
<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">
|
<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.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}}
|
{{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>
|
</div>
|
||||||
{{if .Meta.Summary}}
|
{{if .Meta.Summary}}
|
||||||
<p class="text-zinc-400 text-sm mt-3 line-clamp-3">{{.Meta.Summary}}</p>
|
<p class="text-zinc-400 text-sm mt-3 line-clamp-3">{{.Meta.Summary}}</p>
|
||||||
@@ -500,8 +502,19 @@ const bookTmpl = `
|
|||||||
</div>
|
</div>
|
||||||
</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>
|
<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}}
|
{{range .Chapters}}
|
||||||
<li>
|
<li>
|
||||||
<a href="/books/{{$.Slug}}/chapters/{{.Number}}"
|
<a href="/books/{{$.Slug}}/chapters/{{.Number}}"
|
||||||
@@ -509,19 +522,76 @@ const bookTmpl = `
|
|||||||
hx-target="#main-content"
|
hx-target="#main-content"
|
||||||
hx-push-url="true"
|
hx-push-url="true"
|
||||||
hx-swap="innerHTML"
|
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>
|
<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>
|
<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}}
|
{{if .Date}}<span class="text-xs text-zinc-500 block mt-0.5">{{.Date}}</span>{{end}}
|
||||||
</div>
|
</div>
|
||||||
|
<span class="progress-dot hidden text-amber-400 text-xs flex-shrink-0">●</span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
{{else}}
|
{{else}}
|
||||||
<li class="text-zinc-500 px-3 py-2">No chapters downloaded yet.</li>
|
<li class="text-zinc-500 px-3 py-2">No chapters downloaded yet.</li>
|
||||||
{{end}}
|
{{end}}
|
||||||
</ul>
|
</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) {
|
func (s *Server) handleBook(w http.ResponseWriter, r *http.Request) {
|
||||||
slug := r.PathValue("slug")
|
slug := r.PathValue("slug")
|
||||||
@@ -542,17 +612,116 @@ func (s *Server) handleBook(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
total := len(chapters)
|
||||||
|
page := chapters
|
||||||
|
hasMore := false
|
||||||
|
if total > chapterPageSize {
|
||||||
|
page = chapters[:chapterPageSize]
|
||||||
|
hasMore = true
|
||||||
|
}
|
||||||
|
|
||||||
t := template.Must(template.New("book").Parse(bookTmpl))
|
t := template.Must(template.New("book").Parse(bookTmpl))
|
||||||
var buf bytes.Buffer
|
var buf bytes.Buffer
|
||||||
_ = t.Execute(&buf, struct {
|
_ = t.Execute(&buf, struct {
|
||||||
Slug string
|
Slug string
|
||||||
Meta interface{}
|
Meta interface{}
|
||||||
Chapters interface{}
|
Chapters interface{}
|
||||||
}{Slug: slug, Meta: meta, Chapters: chapters})
|
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())
|
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 ─────────────────────────
|
// ─── GET /books/{slug}/chapters/{n} — chapter reader ─────────────────────────
|
||||||
|
|
||||||
const chapterTmpl = `
|
const chapterTmpl = `
|
||||||
@@ -683,6 +852,18 @@ const chapterTmpl = `
|
|||||||
var KOKORO_URL = '{{.KokoroURL}}';
|
var KOKORO_URL = '{{.KokoroURL}}';
|
||||||
var NEXT_N = {{.NextN}};
|
var NEXT_N = {{.NextN}};
|
||||||
var SLUG = '{{.Slug}}';
|
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 audio = document.getElementById('tts-audio');
|
||||||
var btn = document.getElementById('tts-btn');
|
var btn = document.getElementById('tts-btn');
|
||||||
|
|||||||
Reference in New Issue
Block a user