Add chapter count badges, cover zoom, latest chapter button, and dismiss continue-reading cards
- 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:
@@ -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())
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user