feat: password-gated web UI on the same backend #1

Merged
sulthan merged 14 commits from feat/web-ui into main 2026-07-26 03:59:59 +07:00
3 changed files with 42 additions and 5 deletions
Showing only changes of commit daec18546c - Show all commits
+3 -3
View File
@@ -15,19 +15,19 @@
<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">
hx-target="[id='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-target="[id='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">
hx-target="[id='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>
+2 -1
View File
@@ -6,6 +6,7 @@ import (
"html/template"
"io/fs"
"log"
"math"
"net/http"
"strconv"
"strings"
@@ -266,7 +267,7 @@ func (h *webHandler) uiChapter(w http.ResponseWriter, r *http.Request) {
}
raw := strings.TrimSpace(r.PostFormValue("chapter"))
num, err := strconv.ParseFloat(raw, 64)
if err != nil || num < 0 {
if err != nil || num < 0 || math.IsNaN(num) || math.IsInf(num, 0) {
http.Error(w, "chapter must be a non-negative number", http.StatusBadRequest)
return
}
+37 -1
View File
@@ -281,6 +281,42 @@ func TestFavoriteTogglesWithoutReordering(t *testing.T) {
}
}
// TestCardHxTargetIsValidSelectorForColonKey asserts the rendered card's
// hx-target attributes use the fixed-string attribute-selector form
// ([id='card-<key>']) rather than a bare CSS id-selector (#card-<key>).
//
// A key like "asura:solo" makes "#card-asura:solo" an invalid CSS selector:
// the browser parses ":solo" as an unrecognised pseudo-class and htmx's
// querySelectorAll throws SyntaxError, so the button never resolves its
// swap target. httptest never executes htmx, so this only checks the
// rendered attribute's shape — it is not proof the browser accepts the
// selector, just a regression guard against reintroducing the bare-id form.
func TestCardHxTargetIsValidSelectorForColonKey(t *testing.T) {
cfg := webConfig()
srv, store := newWebTestServer(t, cfg)
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.MethodGet, "/ui/list", nil))
if rr.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rr.Code)
}
body := rr.Body.String()
want := `hx-target="[id='card-asura:solo']"`
if strings.Count(body, want) != 3 {
t.Fatalf("body has %d occurrences of %s, want 3 (favorite button, delete button, chapter form)",
strings.Count(body, want), want)
}
if strings.Contains(body, `hx-target="#card-asura:solo"`) {
t.Fatal("body still uses the bare id CSS selector, which is invalid for a key containing ':'")
}
}
func TestChapterOverrideMovesUpdatedAt(t *testing.T) {
cfg := webConfig()
srv, store := newWebTestServer(t, cfg)
@@ -324,7 +360,7 @@ func TestChapterOverrideRejectsBadInput(t *testing.T) {
Title: "Solo Leveling", LastChapterNum: 45, UpdatedAt: 1_000_000,
})
for _, bad := range []string{"", "abc", "-3"} {
for _, bad := range []string{"", "abc", "-3", "NaN", "Infinity", "-Inf"} {
t.Run("input "+bad, func(t *testing.T) {
rr := httptest.NewRecorder()
srv.ServeHTTP(rr, uiRequest(t, cfg, http.MethodPost,