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