From 14990bde21723dcf5b7242dbdffc698ed2b2ae21 Mon Sep 17 00:00:00 2001 From: Sulthan Zaki Date: Fri, 21 Aug 2026 19:38:44 +0700 Subject: [PATCH] feat(web): per-Series detail page keyed by site:series_id (#144) --- backend/internal/web/admin.go | 3 + backend/internal/web/admin_series_detail.go | 90 ++++++++ backend/internal/web/templates/admin.html | 4 +- .../internal/web/templates/series-detail.html | 21 ++ backend/web_test.go | 194 ++++++++++++++++++ 5 files changed, 311 insertions(+), 1 deletion(-) create mode 100644 backend/internal/web/admin_series_detail.go create mode 100644 backend/internal/web/templates/series-detail.html diff --git a/backend/internal/web/admin.go b/backend/internal/web/admin.go index 3afcaa1..8cd1330 100644 --- a/backend/internal/web/admin.go +++ b/backend/internal/web/admin.go @@ -27,6 +27,8 @@ type adminView struct { // the Readers they may act on. OwnerID int64 Lanes lanesView + // Detail is the per-Series page data; zero on every other page. + Detail seriesDetailView } // lanesView is the Lane status block: one row per Site that has run, plus the @@ -89,6 +91,7 @@ func (h *Handler) adminRoutes() []adminRoute { {"GET /admin/lanes", h.adminLanes}, {"GET /admin/readers", h.adminReaders}, {"GET /admin/series", h.adminSeries}, + {"GET /admin/series/{key}", h.adminSeriesDetail}, {"GET /ui/admin/lanes", h.uiLanes}, {"POST /readers/{id}/revoke", h.revokeReaderSessions}, {"POST /readers/{id}/clear-marks", h.clearReaderMarks}, diff --git a/backend/internal/web/admin_series_detail.go b/backend/internal/web/admin_series_detail.go new file mode 100644 index 0000000..536333a --- /dev/null +++ b/backend/internal/web/admin_series_detail.go @@ -0,0 +1,90 @@ +package web + +import ( + "log" + "net/http" + "strconv" + "strings" + "time" + + "bookmarkmanager/backend/internal/store" +) + +// seriesDetailView is one Series' page as the owner sees it: strings and +// flags, every judgement made here, the template left to print. ReaderCount +// is the only figure that crosses the privacy boundary — the owner learns how +// many Readers hold the Series, never which Reader reads what. +type seriesDetailView struct { + Key string // ":", the page's address and the Series' identity + Site string + Kind string + // Title, Cover and Chapter come from the shared Series row; the Cover is + // the wire URL of the stored bytes, "" before any exist. + Title string + Cover string + Chapter string // Latest Chapter number, or "—" before the first capture + Checked string // how long ago the poller last checked, or "never" + Readers int + + // Marks, one per hygiene fact, rendered only while it holds. + Unpollable bool // no SeriesURL to fetch + NoCover bool + Orphan bool // no Reader holds the Series + SightingRaised bool // a Reader's Sighting set the Latest Chapter +} + +// adminSeriesDetail renders one Series' page, keyed by the composite +// ":" the list row already shows. The row is read through +// the list's own SeriesPage read narrowed to the key's Site: the admin +// projection is the privacy boundary, and a second single-row read would be a +// second definition of it. +// ponytail: one 50-row page scan per detail view; a keyed read alongside +// SeriesPage when the library outgrows the page size. +func (h *Handler) adminSeriesDetail(w http.ResponseWriter, r *http.Request) { + site, seriesID, ok := strings.Cut(r.PathValue("key"), ":") + if !ok || site == "" || seriesID == "" { + http.NotFound(w, r) + return + } + page, err := h.store.SeriesPage(store.SeriesFilter{Site: site}) + if err != nil { + log.Printf("series detail %s: %v", site+":"+seriesID, err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + for i := range page.Rows { + if page.Rows[i].SeriesID == seriesID { + h.renderAdmin(w, adminView{Page: "series-detail", Detail: h.seriesDetailView(page.Rows[i])}) + return + } + } + http.NotFound(w, r) +} + +// seriesDetailView shapes one AdminSeries row for display: every judgement in +// Go, the template left to print strings and flags. +func (h *Handler) seriesDetailView(a store.AdminSeries) seriesDetailView { + v := seriesDetailView{ + Key: a.Key(), + Site: a.Site, + Kind: a.Kind, + Title: a.Title, + Cover: h.store.CoverWireURL(a.CoverAddress), + Readers: a.ReaderCount, + Unpollable: a.SeriesURL == "", + NoCover: a.CoverAddress == "", + Orphan: a.ReaderCount == 0, + SightingRaised: a.RaisedByReader, + } + if a.LatestChapterNum == nil { + v.Chapter = "—" + } else { + v.Chapter = strconv.FormatFloat(*a.LatestChapterNum, 'f', -1, 64) + } + if a.LatestCheckedAt == 0 { + v.Checked = "never" + } else { + v.Checked = since(time.Now(), time.UnixMilli(a.LatestCheckedAt)) + } + return v +} diff --git a/backend/internal/web/templates/admin.html b/backend/internal/web/templates/admin.html index 1f60e20..5c7b5b8 100644 --- a/backend/internal/web/templates/admin.html +++ b/backend/internal/web/templates/admin.html @@ -30,7 +30,7 @@ Overview Lanes Readers - Series + Series
@@ -40,6 +40,8 @@ {{template "readers" .}} {{else if eq .Page "series"}}

Series

+ {{else if eq .Page "series-detail"}} + {{template "series-detail" .Detail}} {{else}}

Overview

{{end}} diff --git a/backend/internal/web/templates/series-detail.html b/backend/internal/web/templates/series-detail.html new file mode 100644 index 0000000..8cbbc80 --- /dev/null +++ b/backend/internal/web/templates/series-detail.html @@ -0,0 +1,21 @@ +{{/* Per-Series page: one address per Series, keyed ":" so the + list row is one hop from it. Everything here is a Series-level fact plus + the anonymous Reader count; the intervention forms a later ticket adds + land inside the empty .detail-grid, so this page edits nothing. */}} +{{define "series-detail"}} +← Series +

{{.Title}}

+

{{.Key}} · {{.Site}} · {{.Kind}}

+{{if .Cover}}
+{{else}}
{{end}} +
+ ch {{.Chapter}} + checked {{.Checked}} + {{.Readers}} readers + {{if .Unpollable}}unpollable{{end}} + {{if .NoCover}}no cover{{end}} + {{if .Orphan}}orphan{{end}} + {{if .SightingRaised}}sighting-raised{{end}} +
+
+{{end}} diff --git a/backend/web_test.go b/backend/web_test.go index dcfd21d..492ce77 100644 --- a/backend/web_test.go +++ b/backend/web_test.go @@ -1,6 +1,7 @@ package main import ( + "crypto/sha256" "database/sql" "encoding/json" "fmt" @@ -1840,3 +1841,196 @@ func TestNovelNewTabFallsBackToAll(t *testing.T) { t.Fatal("novel tab=new should render the novel All list") } } + +// The per-Series page renders every Series-level fact the admin read model +// holds for the key the list row already shows: title, the composite key with +// Site and kind, the Latest Chapter, the check age and the anonymous Reader +// count. No Reader identity or progress may appear anywhere in the response. +func TestAdminSeriesDetailRendersFacts(t *testing.T) { + router, st := newWebTestServer(t, testConfig()) + // Two Readers hold the same Series, so the anonymous count is 2. + seed(t, st, store.Bookmark{ + Key: "kagane:sp-baby", Site: "kagane", SeriesID: "sp-baby", + Title: "SP Baby", SeriesURL: "https://kagane.to/series/sp-baby", + Kind: "manga", LatestChapter: "Chapter 45", LatestChapterNum: floatPtr(45), + }) + other, err := st.EnsureReader("reader-two", sha256.Sum256([]byte("reader-two-hash"))) + if err != nil { + t.Fatalf("EnsureReader: %v", err) + } + if _, err := st.Upsert(other, store.Bookmark{ + Key: "kagane:sp-baby", Site: "kagane", SeriesID: "sp-baby", + Title: "SP Baby", SeriesURL: "https://kagane.to/series/sp-baby", + Kind: "manga", LatestChapter: "Chapter 45", LatestChapterNum: floatPtr(45), + }); err != nil { + t.Fatalf("Upsert second reader: %v", err) + } + if err := st.MarkLatestChecked("kagane", "sp-baby", time.Now().Add(-2*time.Hour).UnixMilli()); err != nil { + t.Fatalf("MarkLatestChecked: %v", err) + } + + req := httptest.NewRequest(http.MethodGet, "/admin/series/kagane:sp-baby", nil) + req.AddCookie(sessionCookie(t, st)) + rr := httptest.NewRecorder() + router.ServeHTTP(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("GET /admin/series/kagane:sp-baby status = %d, want 200", rr.Code) + } + body := rr.Body.String() + for _, want := range []string{ + "SP Baby", + "kagane:sp-baby · kagane · manga", + "ch 45", + "checked 2h0m0s ago", + "2 readers", + } { + if !strings.Contains(body, want) { + t.Errorf("series detail lacks %q:\n%s", want, body) + } + } +} + +// A Series the poller has never read renders the never-read state — "ch —" and +// "checked never" — rather than a confident zero, and a Series no Reader holds +// renders its count as "0 readers" rather than as a blank. +func TestAdminSeriesDetailNeverReadOrphanState(t *testing.T) { + router, st := newWebTestServer(t, testConfig()) + seed(t, st, store.Bookmark{ + Key: "asura:abandoned", Site: "asura", SeriesID: "abandoned", + Title: "Abandoned", SeriesURL: "https://asurascans.com/series/abandoned", + Kind: "manga", + }) + // Removing the only Bookmark orphans the Series: the row outlives it. + if err := st.Delete(st.OwnerID(), "asura:abandoned"); err != nil { + t.Fatalf("Delete: %v", err) + } + + req := httptest.NewRequest(http.MethodGet, "/admin/series/asura:abandoned", nil) + req.AddCookie(sessionCookie(t, st)) + rr := httptest.NewRecorder() + router.ServeHTTP(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rr.Code) + } + body := rr.Body.String() + for _, want := range []string{"ch —", "checked never", "0 readers"} { + if !strings.Contains(body, want) { + t.Errorf("never-read orphan detail lacks %q:\n%s", want, body) + } + } + if strings.Contains(body, "ch 0") { + t.Errorf("a never-read Series renders chapter 0:\n%s", body) + } +} + +// The meta row renders each hygiene mark exactly when the underlying fact +// holds: a Series with no page to fetch, no cover, no Reader and a chapter a +// Sighting raised carries all four, and a whole one carries none. +func TestAdminSeriesDetailRendersMarks(t *testing.T) { + router, st := newWebTestServer(t, testConfig()) + + seed(t, st, store.Bookmark{ + Key: "demonic:broken", Site: "demonic", SeriesID: "broken", + Title: "Broken", Kind: "manga", + }) + if err := st.Delete(st.OwnerID(), "demonic:broken"); err != nil { + t.Fatalf("Delete: %v", err) + } + if err := st.RecordSighting(st.OwnerID(), "demonic", "broken", floatPtr(7), time.Now().UnixMilli()); err != nil { + t.Fatalf("RecordSighting: %v", err) + } + + req := httptest.NewRequest(http.MethodGet, "/admin/series/demonic:broken", nil) + req.AddCookie(sessionCookie(t, st)) + rr := httptest.NewRecorder() + router.ServeHTTP(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rr.Code) + } + for _, want := range []string{"unpollable", "no cover", "orphan", "sighting-raised"} { + if !strings.Contains(rr.Body.String(), want) { + t.Errorf("broken series detail lacks the %q mark:\n%s", want, rr.Body.String()) + } + } + + seed(t, st, store.Bookmark{ + Key: "asura:solo", Site: "asura", SeriesID: "solo", + Title: "Solo Leveling", SeriesURL: "https://asurascans.com/series/solo", + Kind: "manga", LatestChapter: "45", LatestChapterNum: floatPtr(45), + }) + if err := st.SetSeriesCover("asura", "solo", "https://cdn.asurascans.com/covers/solo.webp", + []byte("\x00webp-bytes"), "image/webp"); err != nil { + t.Fatalf("SetSeriesCover: %v", err) + } + + req = httptest.NewRequest(http.MethodGet, "/admin/series/asura:solo", nil) + req.AddCookie(sessionCookie(t, st)) + rr = httptest.NewRecorder() + router.ServeHTTP(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rr.Code) + } + body := rr.Body.String() + for _, mark := range []string{"unpollable", "no cover", "orphan", "sighting-raised"} { + if strings.Contains(body, mark) { + t.Errorf("whole series detail carries the %q mark:\n%s", mark, body) + } + } + if !strings.Contains(body, `src="https://bookmarks.test/covers/`) { + t.Errorf("whole series detail does not render its stored cover:\n%s", body) + } +} + +// A well-formed key naming no row is a 404, and so is a key with no ":", +// an empty Site or an empty SeriesID — the detail page never answers 500 for +// an address nobody can reach. +func TestAdminSeriesDetailUnknownKey404(t *testing.T) { + router, st := newWebTestServer(t, testConfig()) + seed(t, st, store.Bookmark{ + Key: "asura:solo", Site: "asura", SeriesID: "solo", + Title: "Solo Leveling", SeriesURL: "https://asurascans.com/series/solo", + Kind: "manga", + }) + + for _, path := range []string{ + "/admin/series/asura:no-such-row", + "/admin/series/no-colon", + "/admin/series/:empty-site", + "/admin/series/asura:", + "/admin/series/unknown-site:row", + } { + req := httptest.NewRequest(http.MethodGet, path, nil) + req.AddCookie(sessionCookie(t, st)) + rr := httptest.NewRecorder() + router.ServeHTTP(rr, req) + if rr.Code != http.StatusNotFound { + t.Errorf("GET %s status = %d, want 404", path, rr.Code) + } + } +} + +// The title and the key line come from the database, so they must render +// escaped: a title that is markup stays markup in the response, never HTML. +func TestAdminSeriesDetailEscapesStoredStrings(t *testing.T) { + router, st := newWebTestServer(t, testConfig()) + seed(t, st, store.Bookmark{ + Key: "asura:evil", Site: "asura", SeriesID: "evil", + Title: ``, SeriesURL: "https://asurascans.com/series/evil", + Kind: "manga", + }) + + req := httptest.NewRequest(http.MethodGet, "/admin/series/asura:evil", nil) + req.AddCookie(sessionCookie(t, st)) + rr := httptest.NewRecorder() + router.ServeHTTP(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rr.Code) + } + body := rr.Body.String() + if !strings.Contains(body, "<script>") { + t.Errorf("title is not escaped:\n%s", body) + } + if strings.Contains(body, "