Refresh the strip and the badge out of band
The "Continue reading" strip and the Updated badge describe the whole library but live outside the #list htmx swaps, so nothing corrected them after a mutation: archiving a series left it sitting in the strip under "Continue reading", with the badge still counting it. A tab switch had the same problem from the other side — the strip was server-gated to All but could not be re-rendered client-side, so /?tab=all reached by htmx and the same URL reloaded produced different DOM. Both regions move into chrome.html and always render, hidden when they have nothing to say, so an out-of-band swap always has an id to target. /ui/list and every mutation append them; the tab the reader is on comes from HX-Current-URL. filter.js keeps only the job the server cannot do — hiding the strip while a filter is active. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -14,13 +14,13 @@
|
|||||||
if (!card.hidden) visible++;
|
if (!card.hidden) visible++;
|
||||||
});
|
});
|
||||||
|
|
||||||
// The strip is All-only and never filtered. It lives outside #list, so an
|
// The server decides what the strip holds — it ships empty for every tab
|
||||||
// htmx tab switch does not re-render it and this is what takes it down;
|
// but All, and out of band after every mutation. All this has to do is keep
|
||||||
// leaving it up during a search would also put non-matching covers above
|
// it down while a filter is active, since the strip is never filtered and
|
||||||
// an empty list.
|
// would otherwise put non-matching covers above an empty list.
|
||||||
var recent = document.querySelector(".recent");
|
var recent = document.querySelector(".recent");
|
||||||
if (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
|
// An empty bucket already explains itself server-side; this only speaks
|
||||||
|
|||||||
@@ -44,8 +44,8 @@
|
|||||||
<a href="/?tab=new" class="tab-new {{if eq .Tab "new"}}active{{end}}"
|
<a href="/?tab=new" class="tab-new {{if eq .Tab "new"}}active{{end}}"
|
||||||
{{if eq .Tab "new"}}aria-current="page"{{end}}
|
{{if eq .Tab "new"}}aria-current="page"{{end}}
|
||||||
hx-get="/ui/list?tab=new" hx-target="#list" hx-swap="innerHTML"
|
hx-get="/ui/list?tab=new" hx-target="#list" hx-swap="innerHTML"
|
||||||
hx-push-url="/?tab=new" hx-on::after-request="setActiveTab(this)">Updated{{if .NewCount}}
|
hx-push-url="/?tab=new" hx-on::after-request="setActiveTab(this)">Updated
|
||||||
<span class="count">{{.NewCount}}</span>{{end}}</a>
|
{{template "newcount" .}}</a>
|
||||||
<a href="/?tab=fav" class="{{if eq .Tab "fav"}}active{{end}}"
|
<a href="/?tab=fav" class="{{if eq .Tab "fav"}}active{{end}}"
|
||||||
{{if eq .Tab "fav"}}aria-current="page"{{end}}
|
{{if eq .Tab "fav"}}aria-current="page"{{end}}
|
||||||
hx-get="/ui/list?tab=fav" hx-target="#list" hx-swap="innerHTML"
|
hx-get="/ui/list?tab=fav" hx-target="#list" hx-swap="innerHTML"
|
||||||
@@ -61,26 +61,7 @@
|
|||||||
</nav>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{if .Recent}}
|
{{template "recent" .}}
|
||||||
<section class="recent">
|
|
||||||
<h2>Continue reading</h2>
|
|
||||||
<div class="recent-strip">
|
|
||||||
{{range .Recent}}
|
|
||||||
<a class="recent-card {{if .HasNewChapter}}is-new{{end}}" href="{{.ContinueURL}}"
|
|
||||||
target="_blank" rel="noopener noreferrer">
|
|
||||||
<span class="recent-cover">
|
|
||||||
{{if .Cover}}<img src="{{.Cover}}" alt="" loading="lazy">
|
|
||||||
{{else}}<span class="monogram" aria-hidden="true">{{.Initial}}</span>{{end}}
|
|
||||||
{{if .HasNewChapter}}<span class="foot-rule"></span>
|
|
||||||
{{else if .Favorite}}<span class="foot-rule brass"></span>{{end}}
|
|
||||||
</span>
|
|
||||||
<span class="recent-title">{{.Title}}</span>
|
|
||||||
<span class="recent-chapter">{{.DisplayChapter}}{{if .HasNewChapter}} · New{{end}}</span>
|
|
||||||
</a>
|
|
||||||
{{end}}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
{{end}}
|
|
||||||
|
|
||||||
<main id="list" class="list">
|
<main id="list" class="list">
|
||||||
{{template "list" .}}
|
{{template "list" .}}
|
||||||
|
|||||||
@@ -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"}}
|
||||||
|
<section class="recent" id="recent"{{if .OOB}} hx-swap-oob="true"{{end}}{{if not .Recent}} hidden{{end}}>
|
||||||
|
<h2>Continue reading</h2>
|
||||||
|
<div class="recent-strip">
|
||||||
|
{{range .Recent}}
|
||||||
|
<a class="recent-card {{if .HasNewChapter}}is-new{{end}}" href="{{.ContinueURL}}"
|
||||||
|
target="_blank" rel="noopener noreferrer">
|
||||||
|
<span class="recent-cover">
|
||||||
|
{{if .Cover}}<img src="{{.Cover}}" alt="" loading="lazy">
|
||||||
|
{{else}}<span class="monogram" aria-hidden="true">{{.Initial}}</span>{{end}}
|
||||||
|
{{if .HasNewChapter}}<span class="foot-rule"></span>
|
||||||
|
{{else if .Favorite}}<span class="foot-rule brass"></span>{{end}}
|
||||||
|
</span>
|
||||||
|
<span class="recent-title">{{.Title}}</span>
|
||||||
|
<span class="recent-chapter">{{.DisplayChapter}}{{if .HasNewChapter}} · New{{end}}</span>
|
||||||
|
</a>
|
||||||
|
{{end}}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
{{end}}
|
||||||
|
|
||||||
|
{{define "newcount"}}<span class="count" id="new-count"{{if .OOB}} hx-swap-oob="true"{{end}}{{if not .NewCount}} hidden{{end}}>{{.NewCount}}</span>{{end}}
|
||||||
+59
-12
@@ -9,6 +9,7 @@ import (
|
|||||||
"math"
|
"math"
|
||||||
"mime"
|
"mime"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"net/url"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -44,6 +45,9 @@ type listView struct {
|
|||||||
// reading set, not the active tab, so the badge does not change meaning as
|
// reading set, not the active tab, so the badge does not change meaning as
|
||||||
// the user moves between tabs.
|
// the user moves between tabs.
|
||||||
NewCount int
|
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
|
// 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
|
return
|
||||||
}
|
}
|
||||||
h.render(w, http.StatusOK, "list", view)
|
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) {
|
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
|
return b, true
|
||||||
}
|
}
|
||||||
|
|
||||||
// saveAndRenderCard upserts and renders the row as stored. Upsert decides
|
// saveAndRenderCard upserts and renders the row as stored, then refreshes the
|
||||||
// whether updated_at moves, so the argument's timestamp is only a candidate and
|
// chrome. Upsert decides whether updated_at moves, so the argument's timestamp
|
||||||
// the response must come from the return value.
|
// 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
|
// 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
|
// matches the active tab. That much is deliberate — the card showing its new
|
||||||
// bug rather than as feedback. Archiving from the All tab leaves the card on
|
// state is the feedback for the tap. The strip and the badge are not: they
|
||||||
// screen until the next list load. The alternative costs a full list round
|
// describe the whole library, so they are rebuilt out of band on every
|
||||||
// trip on every toggle, and the card visibly showing its new state is the
|
// mutation, at the cost of one extra list read per toggle.
|
||||||
// feedback the user needs.
|
func (h *webHandler) saveAndRenderCard(w http.ResponseWriter, r *http.Request, b Bookmark) {
|
||||||
func (h *webHandler) saveAndRenderCard(w http.ResponseWriter, b Bookmark) {
|
|
||||||
stored, err := h.store.Upsert(b)
|
stored, err := h.store.Upsert(b)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("ui upsert %q: %v", b.Key, err)
|
log.Printf("ui upsert %q: %v", b.Key, err)
|
||||||
@@ -301,6 +344,7 @@ func (h *webHandler) saveAndRenderCard(w http.ResponseWriter, b Bookmark) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
h.render(w, http.StatusOK, "card", stored)
|
h.render(w, http.StatusOK, "card", stored)
|
||||||
|
h.refreshChrome(w, r)
|
||||||
}
|
}
|
||||||
|
|
||||||
// uiFavorite flips the favourite flag. last_chapter_num is untouched, so
|
// 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.Favorite = !b.Favorite
|
||||||
b.UpdatedAt = time.Now().UnixMilli()
|
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
|
// 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
|
return
|
||||||
}
|
}
|
||||||
b.UpdatedAt = time.Now().UnixMilli()
|
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.
|
// 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.LastChapterNum = num
|
||||||
}
|
}
|
||||||
b.UpdatedAt = time.Now().UnixMilli()
|
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
|
// 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.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
w.WriteHeader(http.StatusOK)
|
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)
|
||||||
}
|
}
|
||||||
|
|||||||
+50
-4
@@ -451,8 +451,14 @@ func TestUIDeleteRemovesRow(t *testing.T) {
|
|||||||
if rr.Code != http.StatusOK {
|
if rr.Code != http.StatusOK {
|
||||||
t.Fatalf("delete status = %d, want 200", rr.Code)
|
t.Fatalf("delete status = %d, want 200", rr.Code)
|
||||||
}
|
}
|
||||||
if rr.Body.Len() != 0 {
|
// The body carries only out-of-band chrome, so htmx has nothing to swap into
|
||||||
t.Fatalf("delete body = %q, want empty so htmx swaps the card away", rr.Body.String())
|
// 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 {
|
if _, ok, _ := store.Get("asura:solo"); ok {
|
||||||
t.Fatal("row still present after delete")
|
t.Fatal("row still present after delete")
|
||||||
@@ -635,8 +641,14 @@ func TestRecentStripCarriesUnreadOnlyAndOnlyOnAll(t *testing.T) {
|
|||||||
if _, err := store.Upsert(reading); err != nil {
|
if _, err := store.Upsert(reading); err != nil {
|
||||||
t.Fatalf("Upsert: %v", err)
|
t.Fatalf("Upsert: %v", err)
|
||||||
}
|
}
|
||||||
if strings.Contains(stripOf(t, srv, cfg, "all"), `class="recent"`) {
|
// The section still ships (an out-of-band swap needs the id to exist) but
|
||||||
t.Fatal("strip rendered with no unread chapters anywhere")
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user