feat(backend): favourite, chapter override, and delete fragments

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 <noreply@anthropic.com>
This commit is contained in:
2026-07-25 23:10:55 +07:00
parent f70707f154
commit 2c3d687b6d
4 changed files with 329 additions and 0 deletions
+95
View File
@@ -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)
}