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 }