diff --git a/backend/static/filter.js b/backend/static/filter.js
index 3cddea3..40bbb47 100644
--- a/backend/static/filter.js
+++ b/backend/static/filter.js
@@ -14,13 +14,13 @@
if (!card.hidden) visible++;
});
- // 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.
+ // The server decides what the strip holds — it ships empty for every tab
+ // but All, and out of band after every mutation. All this has to do is keep
+ // it down while a filter is active, since the strip is never filtered and
+ // would otherwise put non-matching covers above an empty list.
var recent = document.querySelector(".recent");
if (recent) {
- recent.hidden = needle !== "" || !document.querySelector('.tabs a.active[href="/?tab=all"]');
+ recent.hidden = needle !== "" || !recent.querySelector(".recent-card");
}
// An empty bucket already explains itself server-side; this only speaks
diff --git a/backend/templates/app.html b/backend/templates/app.html
index f133fc0..538d541 100644
--- a/backend/templates/app.html
+++ b/backend/templates/app.html
@@ -44,8 +44,8 @@
Updated{{if .NewCount}}
- {{.NewCount}}{{end}}
+ hx-push-url="/?tab=new" hx-on::after-request="setActiveTab(this)">Updated
+ {{template "newcount" .}}
- {{if .Recent}}
-
- {{end}}
+ {{template "recent" .}}
{{template "list" .}}
diff --git a/backend/templates/chrome.html b/backend/templates/chrome.html
new file mode 100644
index 0000000..83f54db
--- /dev/null
+++ b/backend/templates/chrome.html
@@ -0,0 +1,30 @@
+{{/* The two regions that live outside the swapped #list: the "Continue
+ reading" strip and the Updated badge. Both are rendered inline by app.html
+ and again, out of band, on every /ui/ response — a mutation must not leave
+ them describing the library as it was before the tap.
+
+ Both always render, hidden when they have nothing to say, so an out-of-band
+ swap always has an element with the right id to replace. */}}
+
+{{define "recent"}}
+
+{{end}}
+
+{{define "newcount"}}{{.NewCount}}{{end}}
diff --git a/backend/web.go b/backend/web.go
index 7ec0d3d..b3368db 100644
--- a/backend/web.go
+++ b/backend/web.go
@@ -9,6 +9,7 @@ import (
"math"
"mime"
"net/http"
+ "net/url"
"strconv"
"strings"
"time"
@@ -44,6 +45,9 @@ type listView struct {
// reading set, not the active tab, so the badge does not change meaning as
// the user moves between tabs.
NewCount int
+ // OOB marks a render of the chrome partials as an out-of-band swap rather
+ // than the inline copy app.html lays out.
+ OOB bool
}
// Initial is the monogram the templates show in place of a cover when the
@@ -227,6 +231,46 @@ func (h *webHandler) uiList(w http.ResponseWriter, r *http.Request) {
return
}
h.render(w, http.StatusOK, "list", view)
+ // The chrome is outside this response's swap target, so without this a tab
+ // switch would leave the strip and badge from whichever tab the page was
+ // loaded on — the same URL would render differently depending on how the
+ // reader got there.
+ h.writeChromeOOB(w, view)
+}
+
+// currentTab is the tab the reader is looking at, read from htmx's own header,
+// so out-of-band chrome is rebuilt for that view rather than for a default.
+func currentTab(r *http.Request) string {
+ u, err := url.Parse(r.Header.Get("HX-Current-URL"))
+ if err != nil {
+ return ""
+ }
+ return u.Query().Get("tab")
+}
+
+// writeChromeOOB appends the two regions that live outside #list — the recent
+// strip and the Updated badge — as out-of-band swaps, so a mutation cannot
+// leave them describing the library as it was before the tap.
+func (h *webHandler) writeChromeOOB(w http.ResponseWriter, view listView) {
+ view.OOB = true
+ for _, name := range []string{"recent", "newcount"} {
+ if err := h.tmpl.ExecuteTemplate(w, name, view); err != nil {
+ // The card is already written; stale chrome beats a torn response.
+ log.Printf("render %s oob: %v", name, err)
+ return
+ }
+ }
+}
+
+// refreshChrome rebuilds the chrome for the reader's current tab after a
+// mutation and appends it to the response.
+func (h *webHandler) refreshChrome(w http.ResponseWriter, r *http.Request) {
+ view, err := h.buildListView(currentTab(r))
+ if err != nil {
+ log.Printf("ui chrome: %v", err)
+ return
+ }
+ h.writeChromeOOB(w, view)
}
func (h *webHandler) login(w http.ResponseWriter, r *http.Request) {
@@ -283,17 +327,16 @@ func (h *webHandler) loadForMutation(w http.ResponseWriter, r *http.Request) (Bo
return b, true
}
-// saveAndRenderCard upserts and renders the row as stored. Upsert decides
-// whether updated_at moves, so the argument's timestamp is only a candidate and
-// the response must come from the return value.
+// saveAndRenderCard upserts and renders the row as stored, then refreshes the
+// chrome. Upsert decides whether updated_at moves, so the argument's timestamp
+// is only a candidate and the response must come from the return value.
//
// ponytail: the swapped card stays put even when its new status no longer
-// matches the active tab, add an hx-swap-oob list refresh if that reads as a
-// bug rather than as feedback. Archiving from the All tab leaves the card on
-// screen until the next list load. The alternative costs a full list round
-// trip on every toggle, and the card visibly showing its new state is the
-// feedback the user needs.
-func (h *webHandler) saveAndRenderCard(w http.ResponseWriter, b Bookmark) {
+// matches the active tab. That much is deliberate — the card showing its new
+// state is the feedback for the tap. The strip and the badge are not: they
+// describe the whole library, so they are rebuilt out of band on every
+// mutation, at the cost of one extra list read per toggle.
+func (h *webHandler) saveAndRenderCard(w http.ResponseWriter, r *http.Request, b Bookmark) {
stored, err := h.store.Upsert(b)
if err != nil {
log.Printf("ui upsert %q: %v", b.Key, err)
@@ -301,6 +344,7 @@ func (h *webHandler) saveAndRenderCard(w http.ResponseWriter, b Bookmark) {
return
}
h.render(w, http.StatusOK, "card", stored)
+ h.refreshChrome(w, r)
}
// uiFavorite flips the favourite flag. last_chapter_num is untouched, so
@@ -312,7 +356,7 @@ func (h *webHandler) uiFavorite(w http.ResponseWriter, r *http.Request) {
}
b.Favorite = !b.Favorite
b.UpdatedAt = time.Now().UnixMilli()
- h.saveAndRenderCard(w, b)
+ h.saveAndRenderCard(w, r, b)
}
// uiStatus moves a bookmark between lifecycle buckets. This is the only place
@@ -338,7 +382,7 @@ func (h *webHandler) uiStatus(w http.ResponseWriter, r *http.Request) {
return
}
b.UpdatedAt = time.Now().UnixMilli()
- h.saveAndRenderCard(w, b)
+ h.saveAndRenderCard(w, r, b)
}
// uiChapter forces the read chapter to a value the user typed.
@@ -374,7 +418,7 @@ func (h *webHandler) uiChapter(w http.ResponseWriter, r *http.Request) {
b.LastChapterNum = num
}
b.UpdatedAt = time.Now().UnixMilli()
- h.saveAndRenderCard(w, b)
+ h.saveAndRenderCard(w, r, b)
}
// uiDelete removes the row and answers with an empty body, which htmx swaps in
@@ -392,4 +436,7 @@ func (h *webHandler) uiDelete(w http.ResponseWriter, r *http.Request) {
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusOK)
+ // The empty body is what removes the card; the chrome still has to be told
+ // the library got smaller.
+ h.refreshChrome(w, r)
}
diff --git a/backend/web_test.go b/backend/web_test.go
index 90a8648..a37bd0f 100644
--- a/backend/web_test.go
+++ b/backend/web_test.go
@@ -451,8 +451,14 @@ func TestUIDeleteRemovesRow(t *testing.T) {
if rr.Code != http.StatusOK {
t.Fatalf("delete status = %d, want 200", rr.Code)
}
- if rr.Body.Len() != 0 {
- t.Fatalf("delete body = %q, want empty so htmx swaps the card away", rr.Body.String())
+ // The body carries only out-of-band chrome, so htmx has nothing to swap into
+ // the card's place and the row disappears.
+ body := rr.Body.String()
+ if strings.Contains(body, `class="card`) {
+ t.Fatalf("delete body = %q, want no card so htmx swaps it away", body)
+ }
+ if !strings.Contains(body, `id="new-count" hx-swap-oob="true"`) {
+ t.Fatalf("delete body = %q, want the out-of-band badge", body)
}
if _, ok, _ := store.Get("asura:solo"); ok {
t.Fatal("row still present after delete")
@@ -635,8 +641,14 @@ func TestRecentStripCarriesUnreadOnlyAndOnlyOnAll(t *testing.T) {
if _, err := store.Upsert(reading); err != nil {
t.Fatalf("Upsert: %v", err)
}
- if strings.Contains(stripOf(t, srv, cfg, "all"), `class="recent"`) {
- t.Fatal("strip rendered with no unread chapters anywhere")
+ // The section still ships (an out-of-band swap needs the id to exist) but
+ // carries no cards and is hidden.
+ empty := stripOf(t, srv, cfg, "all")
+ if strings.Contains(empty, "recent-card") {
+ t.Fatal("strip rendered cards with no unread chapters anywhere")
+ }
+ if !strings.Contains(empty, `id="recent" hidden`) {
+ t.Fatalf("strip not hidden with nothing new: %q", empty)
}
}
@@ -790,3 +802,37 @@ func TestAppRendersNewTabs(t *testing.T) {
}
}
}
+
+// A mutation has to bring the chrome with it: the strip and the badge live
+// outside the swapped card, so nothing else would correct them.
+func TestMutationRefreshesChromeOutOfBand(t *testing.T) {
+ cfg := webConfig()
+ srv, store := newWebTestServer(t, cfg)
+ seed(t, store, Bookmark{
+ Key: "asura:solo", Site: "asura", SeriesID: "solo", Title: "Solo Leveling",
+ Status: statusReading, LastChapterNum: 10, LatestChapter: "Chapter 11",
+ LatestChapterNum: floatPtr(11), UpdatedAt: time.Now().UnixMilli(),
+ })
+
+ before := stripOf(t, srv, cfg, "all")
+ if !strings.Contains(before, "Solo Leveling") || !strings.Contains(before, `id="new-count"`) {
+ t.Fatalf("expected the series in the strip to start with: %q", before)
+ }
+
+ req := uiRequest(t, cfg, http.MethodPost, "/ui/bookmarks/asura:solo/status",
+ url.Values{"status": {statusArchived}})
+ req.Header.Set("HX-Current-URL", "http://localhost/?tab=all")
+ rr := httptest.NewRecorder()
+ srv.ServeHTTP(rr, req)
+ if rr.Code != http.StatusOK {
+ t.Fatalf("status post = %d, want 200", rr.Code)
+ }
+
+ body := rr.Body.String()
+ if !strings.Contains(body, `id="recent" hx-swap-oob="true" hidden`) {
+ t.Fatalf("archiving did not empty the strip out of band: %q", body)
+ }
+ if !strings.Contains(body, `id="new-count" hx-swap-oob="true" hidden`) {
+ t.Fatalf("archiving did not clear the Updated badge out of band: %q", body)
+ }
+}