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 07b075f04a
commit c560a61a9a
3 changed files with 64 additions and 20 deletions
+11 -4
View File
@@ -14,11 +14,14 @@
if (!card.hidden) visible++;
});
// The strip is never filtered, so leaving it up during a search puts
// non-matching covers above an empty list and the screen contradicts
// itself.
// The strip is All-only and never filtered. It lives outside #list, so an
// htmx tab switch does not re-render it and this is what takes it down;
// leaving it up during a search would also put non-matching covers above
// an empty list.
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
// 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.
document.body.addEventListener("htmx:afterSwap", applyFilter);
document.addEventListener("mangabm:refilter", applyFilter);
})();
function setActiveTab(el) {
@@ -52,6 +56,9 @@ function setActiveTab(el) {
if (on) t.setAttribute("aria-current", "page");
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
+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 })
// 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() })
var items []Bookmark
@@ -206,6 +200,15 @@ func (h *webHandler) buildListView(tab string) (listView, error) {
tab = "all"
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
}
+44 -10
View File
@@ -1,6 +1,7 @@
package main
import (
"fmt"
"net/http"
"net/http/httptest"
"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()
srv, store := newWebTestServer(t, cfg)
seedStatusRows(t, store)
req := httptest.NewRequest(http.MethodGet, "/?tab=archived", nil)
req.AddCookie(sessionCookie(t, cfg))
rr := httptest.NewRecorder()
srv.ServeHTTP(rr, req)
for _, tab := range []string{"all", "new", "fav", "archived", "finished"} {
if strings.Contains(stripOf(t, srv, cfg, tab), `class="recent"`) {
t.Fatalf("tab %s rendered the strip for a library of one reading series", tab)
}
}
strip := rr.Body.String()
if i := strings.Index(strip, `id="list"`); i >= 0 {
strip = strip[:i] // everything above the list is the recent section
// Past recentCount the strip stops repeating the whole list.
for i := 0; i <= recentCount; i++ {
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") {
t.Fatal("recent strip included an archived or finished series")
}
if !strings.Contains(strip, "ReadingOne") {
t.Fatal("recent strip dropped the reading series")
for _, tab := range []string{"new", "fav", "archived", "finished"} {
if strings.Contains(stripOf(t, srv, cfg, tab), `class="recent"`) {
t.Fatalf("tab %s rendered the strip", tab)
}
}
}