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
+
+
+
![cover zoomed]()
+
+
{{if .Meta.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) {