From 33b0ee47710e25ba645506a14dd974c5595efade Mon Sep 17 00:00:00 2001 From: Admin Date: Sun, 1 Mar 2026 22:45:18 +0500 Subject: [PATCH] Add chapter count badges, cover zoom, latest chapter button, and dismiss continue-reading cards MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- scraper/internal/server/ui.go | 97 ++++++++++++++++++++++++++++++- scraper/internal/writer/writer.go | 19 ++++++ 2 files changed, 114 insertions(+), 2 deletions(-) diff --git a/scraper/internal/server/ui.go b/scraper/internal/server/ui.go index cd64dc7..25a66c9 100644 --- a/scraper/internal/server/ui.go +++ b/scraper/internal/server/ui.go @@ -13,6 +13,7 @@ import ( "time" "github.com/libnovel/scraper/internal/orchestrator" + "github.com/libnovel/scraper/internal/scraper" "github.com/libnovel/scraper/internal/writer" "github.com/yuin/goldmark" "github.com/yuin/goldmark/extension" @@ -167,6 +168,7 @@ const homeTmpl = `
{{if .Status}}{{.Status}}{{end}} {{if .TotalChapters}}{{.TotalChapters}} ch{{end}} + {{if .Downloaded}}{{.Downloaded}} downloaded{{end}}
@@ -231,6 +233,42 @@ const homeTmpl = ` } } 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 = ''; + 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); // Keep the original card visible in #books-grid — do not hide it. // Books should always appear in Available regardless of reading progress. @@ -268,6 +306,12 @@ const homeTmpl = ` }()); ` +// 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) { if r.URL.Path != "/" { http.NotFound(w, r) @@ -280,12 +324,20 @@ func (s *Server) handleHome(w http.ResponseWriter, r *http.Request) { 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)) var buf bytes.Buffer _ = t.Execute(&buf, struct { Books interface{} }{ - Books: books, + Books: items, }) s.respond(w, r, "Home", buf.String()) @@ -1262,9 +1314,19 @@ const bookTmpl = ` ← All books + + +
{{if .Meta.Cover}} - cover + cover {{end}}
@@ -1311,6 +1373,20 @@ const bookTmpl = `
+ + {{if .LastChapter}} + + {{end}} +

Chapters

    {{range .Chapters}} @@ -1381,6 +1457,17 @@ const bookTmpl = ` window.applyProgress = function(slug) { var progress = getProgress(); 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; // 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)) var buf bytes.Buffer + lastChapter := 0 + if total > 0 { + lastChapter = chapters[total-1].Number + } _ = t.Execute(&buf, struct { Slug string Meta interface{} @@ -1463,6 +1554,7 @@ func (s *Server) handleBook(w http.ResponseWriter, r *http.Request) { TotalDownloaded int TotalPages int CurrentPage int + LastChapter int }{ Slug: slug, Meta: meta, @@ -1470,6 +1562,7 @@ func (s *Server) handleBook(w http.ResponseWriter, r *http.Request) { TotalDownloaded: total, TotalPages: totalPages, CurrentPage: currentPage, + LastChapter: lastChapter, }) s.respond(w, r, meta.Title, buf.String()) diff --git a/scraper/internal/writer/writer.go b/scraper/internal/writer/writer.go index 6beffe7..fdd9ee4 100644 --- a/scraper/internal/writer/writer.go +++ b/scraper/internal/writer/writer.go @@ -206,6 +206,25 @@ func (w *Writer) ListChapters(slug string) ([]ChapterInfo, error) { 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 // the leading "# " heading marker. Falls back to "Chapter N". func chapterTitle(path string, n int) (title, date string) {