Add chapter count badges, cover zoom, latest chapter button, and dismiss continue-reading cards
Some checks failed
CI / Lint (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Build (push) Has been cancelled

- Home page book cards show downloaded chapter count (amber badge) via new CountChapters method
- Book page cover image is clickable to zoom into a fullscreen overlay; click anywhere to dismiss
- Book page shows a 'Latest Chapter' button for unstarted books linking to the last downloaded chapter
- Continue-reading cards on home page have a dismiss (×) button that removes progress from localStorage and slides the card out to the left
This commit is contained in:
Admin
2026-03-01 22:45:18 +05:00
parent 05a1cc04c0
commit 33b0ee4771
2 changed files with 114 additions and 2 deletions

View File

@@ -13,6 +13,7 @@ import (
"time" "time"
"github.com/libnovel/scraper/internal/orchestrator" "github.com/libnovel/scraper/internal/orchestrator"
"github.com/libnovel/scraper/internal/scraper"
"github.com/libnovel/scraper/internal/writer" "github.com/libnovel/scraper/internal/writer"
"github.com/yuin/goldmark" "github.com/yuin/goldmark"
"github.com/yuin/goldmark/extension" "github.com/yuin/goldmark/extension"
@@ -167,6 +168,7 @@ const homeTmpl = `
<div class="flex gap-2 mt-2 flex-wrap"> <div class="flex gap-2 mt-2 flex-wrap">
{{if .Status}}<span class="text-xs px-2 py-0.5 rounded-full bg-zinc-800 text-zinc-300">{{.Status}}</span>{{end}} {{if .Status}}<span class="text-xs px-2 py-0.5 rounded-full bg-zinc-800 text-zinc-300">{{.Status}}</span>{{end}}
{{if .TotalChapters}}<span class="text-xs px-2 py-0.5 rounded-full bg-zinc-800 text-zinc-300">{{.TotalChapters}} ch</span>{{end}} {{if .TotalChapters}}<span class="text-xs px-2 py-0.5 rounded-full bg-zinc-800 text-zinc-300">{{.TotalChapters}} ch</span>{{end}}
{{if .Downloaded}}<span class="text-xs px-2 py-0.5 rounded-full bg-amber-900 text-amber-300">{{.Downloaded}} downloaded</span>{{end}}
</div> </div>
</div> </div>
</div> </div>
@@ -231,6 +233,42 @@ const homeTmpl = `
} }
} }
htmx.process(a); htmx.process(a);
// Add dismiss button (swipe-left to remove from continue reading).
var dismissBtn = document.createElement('button');
dismissBtn.type = 'button';
dismissBtn.title = 'Remove from continue reading';
dismissBtn.className = 'dismiss-btn absolute top-2 right-2 z-10 flex items-center justify-center w-6 h-6 rounded-full bg-zinc-700 hover:bg-zinc-600 text-zinc-400 hover:text-zinc-100 transition-colors opacity-0 group-hover:opacity-100';
dismissBtn.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" class="w-3 h-3" viewBox="0 0 20 20" fill="currentColor"><path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"/></svg>';
dismissBtn.addEventListener('click', function(e) {
e.preventDefault();
e.stopPropagation();
var cardEl = this.closest('[data-slug]');
var cardSlug = cardEl ? cardEl.dataset.slug : null;
if (cardSlug) {
var p = {};
try { p = JSON.parse(localStorage.getItem('reading_progress') || '{}'); } catch(_) {}
delete p[cardSlug];
localStorage.setItem('reading_progress', JSON.stringify(p));
}
// Slide left and fade out, then remove.
var target = cardEl || this.parentElement;
target.style.transition = 'transform 0.25s ease, opacity 0.25s ease';
target.style.transform = 'translateX(-60px)';
target.style.opacity = '0';
setTimeout(function() {
target.remove();
// Hide section if no cards remain.
var remaining = continueGrid.querySelectorAll('[data-slug]');
if (remaining.length === 0) {
continueSection.classList.add('hidden');
}
}, 260);
});
// Make card position:relative so the absolute button positions correctly.
a.style.position = 'relative';
a.appendChild(dismissBtn);
continueGrid.appendChild(a); continueGrid.appendChild(a);
// Keep the original card visible in #books-grid — do not hide it. // Keep the original card visible in #books-grid — do not hide it.
// Books should always appear in Available regardless of reading progress. // Books should always appear in Available regardless of reading progress.
@@ -268,6 +306,12 @@ const homeTmpl = `
}()); }());
</script>` </script>`
// homeBookItem wraps BookMeta with the count of chapters already on disk.
type homeBookItem struct {
scraper.BookMeta
Downloaded int
}
func (s *Server) handleHome(w http.ResponseWriter, r *http.Request) { func (s *Server) handleHome(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/" { if r.URL.Path != "/" {
http.NotFound(w, r) http.NotFound(w, r)
@@ -280,12 +324,20 @@ func (s *Server) handleHome(w http.ResponseWriter, r *http.Request) {
return return
} }
items := make([]homeBookItem, len(books))
for i, b := range books {
items[i] = homeBookItem{
BookMeta: b,
Downloaded: s.writer.CountChapters(b.Slug),
}
}
t := template.Must(template.New("home").Parse(homeTmpl)) t := template.Must(template.New("home").Parse(homeTmpl))
var buf bytes.Buffer var buf bytes.Buffer
_ = t.Execute(&buf, struct { _ = t.Execute(&buf, struct {
Books interface{} Books interface{}
}{ }{
Books: books, Books: items,
}) })
s.respond(w, r, "Home", buf.String()) s.respond(w, r, "Home", buf.String())
@@ -1262,9 +1314,19 @@ const bookTmpl = `
← All books ← All books
</a> </a>
<!-- Cover zoom overlay -->
<div id="cover-zoom-overlay"
class="fixed inset-0 z-50 hidden items-center justify-center bg-black/80 cursor-zoom-out"
onclick="document.getElementById('cover-zoom-overlay').classList.add('hidden');document.getElementById('cover-zoom-overlay').classList.remove('flex');">
<img id="cover-zoom-img" src="" alt="cover zoomed" class="max-w-[90vw] max-h-[90vh] object-contain rounded-xl shadow-2xl">
</div>
<div class="flex gap-5 mb-8 mt-4"> <div class="flex gap-5 mb-8 mt-4">
{{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"
id="cover-thumb"
class="w-24 h-36 object-cover rounded-lg flex-shrink-0 shadow-lg cursor-zoom-in"
onclick="(function(src){var o=document.getElementById('cover-zoom-overlay');document.getElementById('cover-zoom-img').src=src;o.classList.remove('hidden');o.classList.add('flex');})(this.src)">
{{end}} {{end}}
<div class="flex-1 min-w-0"> <div class="flex-1 min-w-0">
<div class="flex items-center gap-3 flex-wrap"> <div class="flex items-center gap-3 flex-wrap">
@@ -1311,6 +1373,20 @@ const bookTmpl = `
</a> </a>
</div> </div>
<!-- Latest chapter button — shown by JS when book has not been started -->
{{if .LastChapter}}
<div id="latest-bar" class="mb-4 hidden">
<a href="/books/{{.Slug}}/chapters/{{.LastChapter}}"
hx-get="/books/{{.Slug}}/chapters/{{.LastChapter}}"
hx-target="#main-content"
hx-push-url="true"
hx-swap="innerHTML"
class="inline-flex items-center gap-2 px-4 py-2 rounded-lg text-zinc-300 hover:text-amber-300 text-sm font-medium transition-colors cursor-pointer border border-zinc-700 hover:border-amber-500">
↓ Latest — Chapter {{.LastChapter}}
</a>
</div>
{{end}}
<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 id="chapter-list" class="space-y-1"> <ul id="chapter-list" class="space-y-1">
{{range .Chapters}} {{range .Chapters}}
@@ -1381,6 +1457,17 @@ const bookTmpl = `
window.applyProgress = function(slug) { window.applyProgress = function(slug) {
var progress = getProgress(); var progress = getProgress();
var saved = progress[slug]; var saved = progress[slug];
// Show/hide latest bar (only when no progress recorded for this book).
var latestBar = document.getElementById('latest-bar');
if (latestBar) {
if (!saved) {
latestBar.classList.remove('hidden');
} else {
latestBar.classList.add('hidden');
}
}
if (!saved) return; if (!saved) return;
// Highlight the saved chapter row. // Highlight the saved chapter row.
@@ -1456,6 +1543,10 @@ func (s *Server) handleBook(w http.ResponseWriter, r *http.Request) {
t := template.Must(template.New("book").Funcs(funcMap).Parse(bookTmpl)) t := template.Must(template.New("book").Funcs(funcMap).Parse(bookTmpl))
var buf bytes.Buffer var buf bytes.Buffer
lastChapter := 0
if total > 0 {
lastChapter = chapters[total-1].Number
}
_ = t.Execute(&buf, struct { _ = t.Execute(&buf, struct {
Slug string Slug string
Meta interface{} Meta interface{}
@@ -1463,6 +1554,7 @@ func (s *Server) handleBook(w http.ResponseWriter, r *http.Request) {
TotalDownloaded int TotalDownloaded int
TotalPages int TotalPages int
CurrentPage int CurrentPage int
LastChapter int
}{ }{
Slug: slug, Slug: slug,
Meta: meta, Meta: meta,
@@ -1470,6 +1562,7 @@ func (s *Server) handleBook(w http.ResponseWriter, r *http.Request) {
TotalDownloaded: total, TotalDownloaded: total,
TotalPages: totalPages, TotalPages: totalPages,
CurrentPage: currentPage, CurrentPage: currentPage,
LastChapter: lastChapter,
}) })
s.respond(w, r, meta.Title, buf.String()) s.respond(w, r, meta.Title, buf.String())

View File

@@ -206,6 +206,25 @@ func (w *Writer) ListChapters(slug string) ([]ChapterInfo, error) {
return chapters, nil return chapters, nil
} }
// CountChapters returns the number of chapter markdown files on disk for slug.
// It is cheaper than ListChapters because it does not read file contents.
func (w *Writer) CountChapters(slug string) int {
bookDir := w.bookDir(slug)
volDirs, err := filepath.Glob(filepath.Join(bookDir, "vol-*"))
if err != nil {
return 0
}
count := 0
for _, vd := range volDirs {
rangeDirs, _ := filepath.Glob(filepath.Join(vd, "*-*"))
for _, rd := range rangeDirs {
files, _ := filepath.Glob(filepath.Join(rd, "chapter-*.md"))
count += len(files)
}
}
return count
}
// chapterTitle reads the first non-empty line of a markdown file and strips // chapterTitle reads the first non-empty line of a markdown file and strips
// the leading "# " heading marker. Falls back to "Chapter N". // the leading "# " heading marker. Falls back to "Chapter N".
func chapterTitle(path string, n int) (title, date string) { func chapterTitle(path string, n int) (title, date string) {