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}} -
-

Continue reading

-
- {{range .Recent}} - - - {{if .Cover}} - {{else}}{{end}} - {{if .HasNewChapter}} - {{else if .Favorite}}{{end}} - - {{.Title}} - {{.DisplayChapter}}{{if .HasNewChapter}} · New{{end}} - - {{end}} -
-
- {{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"}} +
+

Continue reading

+ +
+{{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) + } +}