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:
@@ -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();
|
||||
}
|
||||
|
||||
@@ -12,7 +12,26 @@
|
||||
</p>
|
||||
<div class="actions">
|
||||
<a class="primary" href="{{.ContinueURL}}" target="_blank" rel="noopener noreferrer">Continue</a>
|
||||
<button class="icon {{if .Favorite}}on{{end}}"
|
||||
title="Favourite" aria-label="Toggle favourite"
|
||||
hx-post="/ui/bookmarks/{{.Key}}/favorite"
|
||||
hx-target="#card-{{.Key}}" hx-swap="outerHTML">
|
||||
{{if .Favorite}}★{{else}}☆{{end}}
|
||||
</button>
|
||||
<button class="icon" title="Set chapter" aria-label="Set chapter"
|
||||
onclick="toggleChapterForm('{{.Key}}')">✎</button>
|
||||
<button class="icon danger" title="Remove" aria-label="Remove"
|
||||
hx-delete="/ui/bookmarks/{{.Key}}"
|
||||
hx-target="#card-{{.Key}}" hx-swap="outerHTML"
|
||||
hx-confirm="Remove {{.Title}} from the list?">🗑</button>
|
||||
</div>
|
||||
<form class="chapter-form" id="chapter-form-{{.Key}}" hidden
|
||||
hx-post="/ui/bookmarks/{{.Key}}/chapter"
|
||||
hx-target="#card-{{.Key}}" hx-swap="outerHTML">
|
||||
<input name="chapter" type="number" step="0.1" min="0"
|
||||
value="{{.LastChapterNum}}" aria-label="Chapter number" required>
|
||||
<button type="submit">Save</button>
|
||||
</form>
|
||||
</div>
|
||||
</article>
|
||||
{{end}}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user