Show the recent strip only where it is not a duplicate

The strip is the head of the same updated_at DESC reading list the All tab
renders, and app.html put it on every tab: on a small library it repeated the
list immediately below it while eating ~230px of the first phone screen, and on
Archived and Finished it showed reading series above the opposite bucket.

buildListView now fills Recent only for tab=all with more reading series than
the strip holds. Because the strip lives outside the htmx-swapped #list, an
in-page tab switch cannot re-render it, so applyFilter also decides its
visibility from the active tab; setActiveTab pokes it after a swap.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-30 19:10:42 +07:00
parent aacf57170c
commit 264cf21960
3 changed files with 64 additions and 20 deletions
+11 -4
View File
@@ -14,11 +14,14 @@
if (!card.hidden) visible++; if (!card.hidden) visible++;
}); });
// The strip is never filtered, so leaving it up during a search puts // The strip is All-only and never filtered. It lives outside #list, so an
// non-matching covers above an empty list and the screen contradicts // htmx tab switch does not re-render it and this is what takes it down;
// itself. // leaving it up during a search would also put non-matching covers above
// an empty list.
var recent = document.querySelector(".recent"); var recent = document.querySelector(".recent");
if (recent) recent.hidden = needle !== ""; if (recent) {
recent.hidden = needle !== "" || !document.querySelector('.tabs a.active[href="/?tab=all"]');
}
// An empty bucket already explains itself server-side; this only speaks // An empty bucket already explains itself server-side; this only speaks
// when the filter is what emptied the screen. // when the filter is what emptied the screen.
@@ -43,6 +46,7 @@
// htmx replaces the list on a tab switch, so re-apply to the new cards. // htmx replaces the list on a tab switch, so re-apply to the new cards.
document.body.addEventListener("htmx:afterSwap", applyFilter); document.body.addEventListener("htmx:afterSwap", applyFilter);
document.addEventListener("mangabm:refilter", applyFilter);
})(); })();
function setActiveTab(el) { function setActiveTab(el) {
@@ -52,6 +56,9 @@ function setActiveTab(el) {
if (on) t.setAttribute("aria-current", "page"); if (on) t.setAttribute("aria-current", "page");
else t.removeAttribute("aria-current"); else t.removeAttribute("aria-current");
}); });
// The strip is outside the swapped region, so its visibility is re-decided
// here rather than by the server that just answered.
document.dispatchEvent(new Event("mangabm:refilter"));
} }
// The chapter-edit form and the archive/finish/remove confirm rows are the // The chapter-edit form and the archive/finish/remove confirm rows are the
+9 -6
View File
@@ -184,12 +184,6 @@ func (h *webHandler) buildListView(tab string) (listView, error) {
} }
reading := filterBookmarks(all, func(b Bookmark) bool { return b.Status == statusReading }) reading := filterBookmarks(all, func(b Bookmark) bool { return b.Status == statusReading })
// The strip reflects overall reading recency, not the active tab.
recent := reading
if len(recent) > recentCount {
recent = recent[:recentCount]
}
withNew := filterBookmarks(reading, func(b Bookmark) bool { return b.HasNewChapter() }) withNew := filterBookmarks(reading, func(b Bookmark) bool { return b.HasNewChapter() })
var items []Bookmark var items []Bookmark
@@ -206,6 +200,15 @@ func (h *webHandler) buildListView(tab string) (listView, error) {
tab = "all" tab = "all"
items = reading items = reading
} }
// The strip is the head of the same updated_at DESC reading list the All tab
// renders, so it only earns its ~230px of the first phone screen where it is
// not a pure duplicate of what sits right below it: on All, with more series
// than it shows. On the other tabs it either repeats the list or, worse,
// puts reading series above the archived and finished buckets.
var recent []Bookmark
if tab == "all" && len(reading) > recentCount {
recent = reading[:recentCount]
}
return listView{Tab: tab, Recent: recent, Items: items, NewCount: len(withNew)}, nil return listView{Tab: tab, Recent: recent, Items: items, NewCount: len(withNew)}, nil
} }
+44 -10
View File
@@ -1,6 +1,7 @@
package main package main
import ( import (
"fmt"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"net/url" "net/url"
@@ -578,25 +579,58 @@ func TestTabsShowOnlyTheirBucket(t *testing.T) {
} }
} }
func TestRecentStripExcludesArchivedAndFinished(t *testing.T) { // stripOf returns everything above the list, which is where the recent section
// renders.
func stripOf(t *testing.T, srv http.Handler, cfg Config, tab string) string {
t.Helper()
req := httptest.NewRequest(http.MethodGet, "/?tab="+tab, nil)
req.AddCookie(sessionCookie(t, cfg))
rr := httptest.NewRecorder()
srv.ServeHTTP(rr, req)
body := rr.Body.String()
if i := strings.Index(body, `id="list"`); i >= 0 {
body = body[:i]
}
return body
}
// The strip is the head of the same list the All tab renders, so it only shows
// where it is not a pure duplicate: on All, with more reading series than it
// holds.
func TestRecentStripOnlyOnAllAndOnlyWhenItAddsSomething(t *testing.T) {
cfg := webConfig() cfg := webConfig()
srv, store := newWebTestServer(t, cfg) srv, store := newWebTestServer(t, cfg)
seedStatusRows(t, store) seedStatusRows(t, store)
req := httptest.NewRequest(http.MethodGet, "/?tab=archived", nil) for _, tab := range []string{"all", "new", "fav", "archived", "finished"} {
req.AddCookie(sessionCookie(t, cfg)) if strings.Contains(stripOf(t, srv, cfg, tab), `class="recent"`) {
rr := httptest.NewRecorder() t.Fatalf("tab %s rendered the strip for a library of one reading series", tab)
srv.ServeHTTP(rr, req) }
}
strip := rr.Body.String() // Past recentCount the strip stops repeating the whole list.
if i := strings.Index(strip, `id="list"`); i >= 0 { for i := 0; i <= recentCount; i++ {
strip = strip[:i] // everything above the list is the recent section b := Bookmark{
Key: fmt.Sprintf("asura:more%d", i), Site: "asura",
SeriesID: fmt.Sprintf("more%d", i), Title: fmt.Sprintf("MoreReading%d", i),
Status: statusReading, LastChapterNum: 1, UpdatedAt: time.Now().UnixMilli(),
}
if _, err := store.Upsert(b); err != nil {
t.Fatalf("seed %s: %v", b.Key, err)
}
}
strip := stripOf(t, srv, cfg, "all")
if !strings.Contains(strip, `class="recent"`) {
t.Fatal("strip missing on All with more reading series than it shows")
} }
if strings.Contains(strip, "ArchivedOne") || strings.Contains(strip, "FinishedOne") { if strings.Contains(strip, "ArchivedOne") || strings.Contains(strip, "FinishedOne") {
t.Fatal("recent strip included an archived or finished series") t.Fatal("recent strip included an archived or finished series")
} }
if !strings.Contains(strip, "ReadingOne") { for _, tab := range []string{"new", "fav", "archived", "finished"} {
t.Fatal("recent strip dropped the reading series") if strings.Contains(stripOf(t, srv, cfg, tab), `class="recent"`) {
t.Fatalf("tab %s rendered the strip", tab)
}
} }
} }