From 2c3d687b6d91ee117851a5401f3405cdaee7a317 Mon Sep 17 00:00:00 2001
From: Sulthan Zaki
Date: Sat, 25 Jul 2026 23:10:55 +0700
Subject: [PATCH] feat(backend): favourite, chapter override, and delete
fragments
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Adds the three UI mutation endpoints (favourite toggle, manual chapter
override, delete) plus the card controls that call them via htmx.
Each mutation is a read-modify-write through Store.Get/Upsert so
Upsert alone decides whether updated_at moves — favouriting must not
reorder the list, only real reading progress should.
Co-Authored-By: Claude Opus 5
---
backend/static/filter.js | 7 ++
backend/templates/card.html | 19 ++++
backend/web.go | 95 ++++++++++++++++
backend/web_test.go | 208 ++++++++++++++++++++++++++++++++++++
4 files changed, 329 insertions(+)
diff --git a/backend/static/filter.js b/backend/static/filter.js
index cc1ffd6..f4c9200 100644
--- a/backend/static/filter.js
+++ b/backend/static/filter.js
@@ -4,3 +4,10 @@ function setActiveTab(el) {
t.classList.toggle("active", t === el);
});
}
+
+function toggleChapterForm(key) {
+ var form = document.getElementById("chapter-form-" + key);
+ if (!form) return;
+ form.hidden = !form.hidden;
+ if (!form.hidden) form.querySelector("input").focus();
+}
diff --git a/backend/templates/card.html b/backend/templates/card.html
index 5430666..ea23820 100644
--- a/backend/templates/card.html
+++ b/backend/templates/card.html
@@ -12,7 +12,26 @@
Continue
+
+
+
+
{{end}}
diff --git a/backend/web.go b/backend/web.go
index 3a8623b..8036a8b 100644
--- a/backend/web.go
+++ b/backend/web.go
@@ -8,6 +8,7 @@ import (
"log"
"net/http"
"strconv"
+ "strings"
"time"
)
@@ -66,6 +67,9 @@ func (h *webHandler) register(mux *http.ServeMux) {
mux.Handle("GET /static/", staticHandler())
mux.HandleFunc("GET /ui/list", h.requireSession(h.uiList))
+ mux.HandleFunc("POST /ui/bookmarks/{key}/favorite", h.requireSession(h.uiFavorite))
+ mux.HandleFunc("POST /ui/bookmarks/{key}/chapter", h.requireSession(h.uiChapter))
+ mux.HandleFunc("DELETE /ui/bookmarks/{key}", h.requireSession(h.uiDelete))
}
// staticHandler serves the embedded assets. The vendored htmx build and the
@@ -199,3 +203,94 @@ func (h *webHandler) logout(w http.ResponseWriter, r *http.Request) {
clearSessionCookie(w, r)
http.Redirect(w, r, "/", http.StatusSeeOther)
}
+
+// loadForMutation fetches the row a mutation targets, writing the error
+// response itself when there is nothing to mutate.
+func (h *webHandler) loadForMutation(w http.ResponseWriter, r *http.Request) (Bookmark, bool) {
+ key := r.PathValue("key")
+ if key == "" {
+ http.Error(w, "missing key", http.StatusBadRequest)
+ return Bookmark{}, false
+ }
+ b, ok, err := h.store.Get(key)
+ if err != nil {
+ log.Printf("ui get %q: %v", key, err)
+ http.Error(w, "internal error", http.StatusInternalServerError)
+ return Bookmark{}, false
+ }
+ if !ok {
+ http.Error(w, "not found", http.StatusNotFound)
+ return Bookmark{}, false
+ }
+ 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.
+func (h *webHandler) saveAndRenderCard(w http.ResponseWriter, b Bookmark) {
+ stored, err := h.store.Upsert(b)
+ if err != nil {
+ log.Printf("ui upsert %q: %v", b.Key, err)
+ http.Error(w, "internal error", http.StatusInternalServerError)
+ return
+ }
+ h.render(w, http.StatusOK, "card", stored)
+}
+
+// uiFavorite flips the favourite flag. last_chapter_num is untouched, so
+// Upsert keeps the stored updated_at and the list does not reorder.
+func (h *webHandler) uiFavorite(w http.ResponseWriter, r *http.Request) {
+ b, ok := h.loadForMutation(w, r)
+ if !ok {
+ return
+ }
+ b.Favorite = !b.Favorite
+ b.UpdatedAt = time.Now().UnixMilli()
+ h.saveAndRenderCard(w, b)
+}
+
+// uiChapter forces the read chapter to a value the user typed.
+//
+// It clears last_chapter_url: that URL points at the chapter actually read, and
+// once the number is forced elsewhere it would send the reader backwards.
+// ContinueURL then falls back to the series page, which is always right.
+func (h *webHandler) uiChapter(w http.ResponseWriter, r *http.Request) {
+ b, ok := h.loadForMutation(w, r)
+ if !ok {
+ return
+ }
+ if err := r.ParseForm(); err != nil {
+ http.Error(w, "invalid form", http.StatusBadRequest)
+ return
+ }
+ raw := strings.TrimSpace(r.PostFormValue("chapter"))
+ num, err := strconv.ParseFloat(raw, 64)
+ if err != nil || num < 0 {
+ http.Error(w, "chapter must be a non-negative number", http.StatusBadRequest)
+ return
+ }
+
+ b.LastChapter = raw
+ b.LastChapterNum = num
+ b.LastChapterURL = ""
+ b.UpdatedAt = time.Now().UnixMilli()
+ h.saveAndRenderCard(w, b)
+}
+
+// uiDelete removes the row and answers with an empty body, which htmx swaps in
+// place of the card — removing it from the page.
+func (h *webHandler) uiDelete(w http.ResponseWriter, r *http.Request) {
+ key := r.PathValue("key")
+ if key == "" {
+ http.Error(w, "missing key", http.StatusBadRequest)
+ return
+ }
+ if err := h.store.Delete(key); err != nil {
+ log.Printf("ui delete %q: %v", key, err)
+ http.Error(w, "internal error", http.StatusInternalServerError)
+ return
+ }
+ w.Header().Set("Content-Type", "text/html; charset=utf-8")
+ w.WriteHeader(http.StatusOK)
+}
diff --git a/backend/web_test.go b/backend/web_test.go
index 349ce45..7d8ddae 100644
--- a/backend/web_test.go
+++ b/backend/web_test.go
@@ -199,3 +199,211 @@ func TestStaticAssetsServed(t *testing.T) {
}
}
}
+
+// seed inserts one bookmark and returns it as stored.
+func seed(t *testing.T, store *Store, b Bookmark) Bookmark {
+ t.Helper()
+ stored, err := store.Upsert(b)
+ if err != nil {
+ t.Fatalf("Upsert: %v", err)
+ }
+ return stored
+}
+
+func uiRequest(t *testing.T, cfg Config, method, path string, form url.Values) *http.Request {
+ t.Helper()
+ var req *http.Request
+ if form == nil {
+ req = httptest.NewRequest(method, path, nil)
+ } else {
+ req = httptest.NewRequest(method, path, strings.NewReader(form.Encode()))
+ req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
+ }
+ req.AddCookie(sessionCookie(t, cfg))
+ return req
+}
+
+func TestUIRoutesRequireSession(t *testing.T) {
+ srv, _ := newWebTestServer(t, webConfig())
+ cases := []struct{ method, path string }{
+ {http.MethodGet, "/ui/list"},
+ {http.MethodPost, "/ui/bookmarks/asura:solo/favorite"},
+ {http.MethodPost, "/ui/bookmarks/asura:solo/chapter"},
+ {http.MethodDelete, "/ui/bookmarks/asura:solo"},
+ }
+ for _, tc := range cases {
+ t.Run(tc.method+" "+tc.path, func(t *testing.T) {
+ rr := httptest.NewRecorder()
+ srv.ServeHTTP(rr, httptest.NewRequest(tc.method, tc.path, nil))
+ if rr.Code != http.StatusUnauthorized {
+ t.Fatalf("status = %d, want 401", rr.Code)
+ }
+ })
+ }
+}
+
+func TestFavoriteTogglesWithoutReordering(t *testing.T) {
+ cfg := webConfig()
+ srv, store := newWebTestServer(t, cfg)
+ before := seed(t, store, Bookmark{
+ Key: "asura:solo", Site: "asura", SeriesID: "solo",
+ Title: "Solo Leveling", LastChapter: "45", LastChapterNum: 45,
+ UpdatedAt: 1_000_000,
+ })
+
+ rr := httptest.NewRecorder()
+ srv.ServeHTTP(rr, uiRequest(t, cfg, http.MethodPost, "/ui/bookmarks/asura:solo/favorite", nil))
+ if rr.Code != http.StatusOK {
+ t.Fatalf("favorite status = %d, want 200", rr.Code)
+ }
+
+ after, ok, err := store.Get("asura:solo")
+ if err != nil || !ok {
+ t.Fatalf("Get after favorite: %v ok=%v", err, ok)
+ }
+ if !after.Favorite {
+ t.Fatal("Favorite = false after toggling, want true")
+ }
+ if after.UpdatedAt != before.UpdatedAt {
+ t.Fatalf("UpdatedAt moved from %d to %d; favouriting must not reorder the list",
+ before.UpdatedAt, after.UpdatedAt)
+ }
+ if !strings.Contains(rr.Body.String(), `id="card-asura:solo"`) {
+ t.Fatal("favorite response did not render the card fragment")
+ }
+
+ // Toggling again turns it back off.
+ rr = httptest.NewRecorder()
+ srv.ServeHTTP(rr, uiRequest(t, cfg, http.MethodPost, "/ui/bookmarks/asura:solo/favorite", nil))
+ back, _, _ := store.Get("asura:solo")
+ if back.Favorite {
+ t.Fatal("Favorite = true after a second toggle, want false")
+ }
+}
+
+func TestChapterOverrideMovesUpdatedAt(t *testing.T) {
+ cfg := webConfig()
+ srv, store := newWebTestServer(t, cfg)
+ before := seed(t, store, Bookmark{
+ Key: "asura:solo", Site: "asura", SeriesID: "solo",
+ Title: "Solo Leveling", LastChapter: "45", LastChapterNum: 45,
+ LastChapterURL: "https://example.test/ch/45", SeriesURL: "https://example.test/solo",
+ UpdatedAt: 1_000_000,
+ })
+
+ rr := httptest.NewRecorder()
+ srv.ServeHTTP(rr, uiRequest(t, cfg, http.MethodPost,
+ "/ui/bookmarks/asura:solo/chapter", url.Values{"chapter": {"60"}}))
+ if rr.Code != http.StatusOK {
+ t.Fatalf("chapter override status = %d, want 200", rr.Code)
+ }
+
+ after, ok, err := store.Get("asura:solo")
+ if err != nil || !ok {
+ t.Fatalf("Get after override: %v ok=%v", err, ok)
+ }
+ if after.LastChapterNum != 60 || after.LastChapter != "60" {
+ t.Fatalf("chapter = %q/%v, want 60", after.LastChapter, after.LastChapterNum)
+ }
+ if after.UpdatedAt <= before.UpdatedAt {
+ t.Fatalf("UpdatedAt = %d, want later than %d", after.UpdatedAt, before.UpdatedAt)
+ }
+ if after.LastChapterURL != "" {
+ t.Fatalf("LastChapterURL = %q, want cleared by a manual override", after.LastChapterURL)
+ }
+ if after.Title != "Solo Leveling" {
+ t.Fatalf("Title = %q, want the untouched fields preserved", after.Title)
+ }
+}
+
+func TestChapterOverrideRejectsBadInput(t *testing.T) {
+ cfg := webConfig()
+ srv, store := newWebTestServer(t, cfg)
+ seed(t, store, Bookmark{
+ Key: "asura:solo", Site: "asura", SeriesID: "solo",
+ Title: "Solo Leveling", LastChapterNum: 45, UpdatedAt: 1_000_000,
+ })
+
+ for _, bad := range []string{"", "abc", "-3"} {
+ t.Run("input "+bad, func(t *testing.T) {
+ rr := httptest.NewRecorder()
+ srv.ServeHTTP(rr, uiRequest(t, cfg, http.MethodPost,
+ "/ui/bookmarks/asura:solo/chapter", url.Values{"chapter": {bad}}))
+ if rr.Code != http.StatusBadRequest {
+ t.Fatalf("status = %d, want 400", rr.Code)
+ }
+ after, _, _ := store.Get("asura:solo")
+ if after.LastChapterNum != 45 {
+ t.Fatalf("chapter changed to %v on invalid input", after.LastChapterNum)
+ }
+ })
+ }
+}
+
+func TestMutationsOnMissingKey(t *testing.T) {
+ cfg := webConfig()
+ srv, _ := newWebTestServer(t, cfg)
+ cases := []struct {
+ name string
+ req *http.Request
+ }{
+ {"favorite", uiRequest(t, cfg, http.MethodPost, "/ui/bookmarks/asura:nope/favorite", nil)},
+ {"chapter", uiRequest(t, cfg, http.MethodPost, "/ui/bookmarks/asura:nope/chapter", url.Values{"chapter": {"1"}})},
+ }
+ for _, tc := range cases {
+ t.Run(tc.name, func(t *testing.T) {
+ rr := httptest.NewRecorder()
+ srv.ServeHTTP(rr, tc.req)
+ if rr.Code != http.StatusNotFound {
+ t.Fatalf("status = %d, want 404", rr.Code)
+ }
+ })
+ }
+}
+
+func TestUIDeleteRemovesRow(t *testing.T) {
+ cfg := webConfig()
+ srv, store := newWebTestServer(t, cfg)
+ seed(t, store, Bookmark{
+ Key: "asura:solo", Site: "asura", SeriesID: "solo",
+ Title: "Solo Leveling", UpdatedAt: 1_000_000,
+ })
+
+ rr := httptest.NewRecorder()
+ srv.ServeHTTP(rr, uiRequest(t, cfg, http.MethodDelete, "/ui/bookmarks/asura:solo", nil))
+ 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())
+ }
+ if _, ok, _ := store.Get("asura:solo"); ok {
+ t.Fatal("row still present after delete")
+ }
+}
+
+func TestUIListFavouritesTab(t *testing.T) {
+ cfg := webConfig()
+ srv, store := newWebTestServer(t, cfg)
+ seed(t, store, Bookmark{
+ Key: "asura:solo", Site: "asura", SeriesID: "solo",
+ Title: "Solo Leveling", Favorite: true, UpdatedAt: 2_000_000,
+ })
+ seed(t, store, Bookmark{
+ Key: "demonic:tower", Site: "demonic", SeriesID: "tower",
+ Title: "Tower of God", Favorite: false, UpdatedAt: 1_000_000,
+ })
+
+ rr := httptest.NewRecorder()
+ srv.ServeHTTP(rr, uiRequest(t, cfg, http.MethodGet, "/ui/list?tab=fav", nil))
+ if rr.Code != http.StatusOK {
+ t.Fatalf("status = %d, want 200", rr.Code)
+ }
+ body := rr.Body.String()
+ if !strings.Contains(body, "Solo Leveling") {
+ t.Fatal("favourites tab omitted the favourited series")
+ }
+ if strings.Contains(body, "Tower of God") {
+ t.Fatal("favourites tab included a non-favourite")
+ }
+}