diff --git a/backend/internal/latest/poller.go b/backend/internal/latest/poller.go index dac582b..aca436f 100644 --- a/backend/internal/latest/poller.go +++ b/backend/internal/latest/poller.go @@ -5,7 +5,6 @@ import ( "errors" "log" "net/url" - "sort" "sync" "time" @@ -173,14 +172,7 @@ func fetcherFor(site string, browser, tls Fetcher) Fetcher { // laneNames returns every registry Site in the deterministic order both Run // and runOnce iterate: sorted, so lane behaviour and its tests agree on who // runs first. -func laneNames() []string { - names := make([]string, 0, len(sites)) - for name := range sites { - names = append(names, name) - } - sort.Strings(names) - return names -} +func laneNames() []string { return SiteNames() } func (p *Poller) Run(ctx context.Context) { names := laneNames() diff --git a/backend/internal/latest/sites.go b/backend/internal/latest/sites.go index 64580b4..dd179cf 100644 --- a/backend/internal/latest/sites.go +++ b/backend/internal/latest/sites.go @@ -511,6 +511,18 @@ var sites = map[string]site{ }, } +// SiteNames returns every registry Site, sorted. The admin Series list's Site +// select needs the full registry, not just the Sites that have rows, and +// laneNames() is the poller's copy of the same list — both read this. +func SiteNames() []string { + names := make([]string, 0, len(sites)) + for name := range sites { + names = append(names, name) + } + sort.Strings(names) + return names +} + // browserBackedSites is derived from the registry: the Sites whose pages are // read through the browser sidecar. Sorted so callers that range it (the // browser fetcher's dispatch) see a stable order instead of map-iteration diff --git a/backend/internal/web/admin.go b/backend/internal/web/admin.go index 3afcaa1..b903801 100644 --- a/backend/internal/web/admin.go +++ b/backend/internal/web/admin.go @@ -18,6 +18,11 @@ type LaneReporter interface { LaneStatus() latest.Status } +// ownerWindow is the staleness boundary the Series list's "not checked in +// 12h" filter compares against, and the same twelve hours the landing page's +// figures share. Declared once; later admin tickets read it. +const ownerWindow = 12 * time.Hour + // adminView is the shared shell data for an administrative page and the roster // fragment returned after a Reader action. type adminView struct { @@ -25,8 +30,9 @@ type adminView struct { Readers []store.ReaderSummary // OwnerID travels with the roster so it can tell the owner's own row from // the Readers they may act on. - OwnerID int64 - Lanes lanesView + OwnerID int64 + Lanes lanesView + SeriesList seriesListView } // lanesView is the Lane status block: one row per Site that has run, plus the @@ -142,12 +148,6 @@ func (h *Handler) adminReaders(w http.ResponseWriter, r *http.Request) { h.renderAdmin(w, adminView{Page: "readers", Readers: readers, OwnerID: h.store.OwnerID()}) } -// adminSeries renders the Series shell. Its data arrives in a later admin -// ticket. -func (h *Handler) adminSeries(w http.ResponseWriter, r *http.Request) { - h.renderAdmin(w, adminView{Page: "series"}) -} - func (h *Handler) renderAdmin(w http.ResponseWriter, view adminView) { h.render(w, http.StatusOK, "admin", view) } diff --git a/backend/internal/web/admin_series.go b/backend/internal/web/admin_series.go new file mode 100644 index 0000000..96e88e2 --- /dev/null +++ b/backend/internal/web/admin_series.go @@ -0,0 +1,308 @@ +package web + +import ( + "fmt" + "log" + "net/http" + "net/url" + "strconv" + "time" + + "bookmarkmanager/backend/internal/latest" + "bookmarkmanager/backend/internal/store" +) + +// seriesPageSize matches the store's row-read page length: the pager's range +// must agree with the LIMIT the store applies or the "of N" figure describes +// the wrong page. The store does not export it (#140). +const seriesPageSize = 50 + +// seriesFilterLabels names every hygiene filter for the Series list select, +// keyed by the wire constant the URL carries. The render order is +// seriesFilterOrder; the labels are read by later admin tickets too, so the +// map and the constants cannot drift apart. +var seriesFilterLabels = map[string]string{ + store.SeriesFilterAll: "All series", + store.SeriesFilterNoURL: "No series URL", + store.SeriesFilterNoChapter: "Never read a chapter", + store.SeriesFilterNoReaders: "No Readers", + store.SeriesFilterNeverChecked: "Never checked", + store.SeriesFilterStale: "Not checked in 12h", + store.SeriesFilterNoCover: "No cover", + store.SeriesFilterReaderReport: "Latest from a Reader", +} + +// seriesFilterOrder is the select's render order: All first, then the +// permanent repairs, then the fixable ones (issue #140). +var seriesFilterOrder = []string{ + store.SeriesFilterAll, + store.SeriesFilterNoURL, + store.SeriesFilterNoChapter, + store.SeriesFilterNoReaders, + store.SeriesFilterNeverChecked, + store.SeriesFilterStale, + store.SeriesFilterNoCover, + store.SeriesFilterReaderReport, +} + +// seriesListView is the Series list page's data. The template renders strings +// and flags, and every judgement about what a value means is made here. +type seriesListView struct { + Filters []seriesFilterOption + Sites []string + Site string // "" = every Site + Kind string // "" = both libraries + FilterLabel string + Rows []seriesRowView + Total int + // KindBoth / KindManga / KindNovel are the Library segment links, and + // PrevHref / NextHref the pager's, all carrying the active filter, Site + // and Kind so narrowing never drops state. + KindBoth string + KindManga string + KindNovel string + PrevHref string + NextHref string + Range string +} + +// seriesFilterOption is one entry of the Show select: its wire value, its +// rendered label with the library-wide count, and whether it is the active +// filter. +type seriesFilterOption struct { + Name string + Label string + Count int + Selected bool +} + +// seriesRowView is one Series row formatted for the template. Band carries +// the alternating row tint by class rather than nth-of-type, so the confirm +// rows later tickets add are row siblings without breaking the alternation. +// Attention tints the title patina: a row with any hygiene chip needs one. +type seriesRowView struct { + Key string + Title string + Site string + Ch string // chapter number; "—" until first captured + Age string // checked age; "never" until first check + Readers int + Notes []string // chips, capped at two + More int // chips past the cap, rendered as a +N tail + Band bool + Attention bool +} + +// adminSeries renders the filterable, bookmarkable Series list: filter, Site, +// Library and page all live in the query string, so the list's state is an +// address rather than a click path. +func (h *Handler) adminSeries(w http.ResponseWriter, r *http.Request) { + view, err := h.seriesListView(r) + if err != nil { + log.Printf("admin series: %v", err) + http.Error(w, "internal error", http.StatusInternalServerError) + return + } + h.renderAdmin(w, adminView{Page: "series", SeriesList: view}) +} + +// seriesListView assembles one Series list view from the request's query +// string. An unknown filter value is the absent All case, never an error: the +// select's options are not the only way this URL can be reached. +func (h *Handler) seriesListView(r *http.Request) (seriesListView, error) { + q := r.URL.Query() + filter := q.Get("filter") + if _, ok := seriesFilterLabels[filter]; !ok { + filter = store.SeriesFilterAll + } + site := q.Get("site") + kind := q.Get("kind") + if kind != store.KindManga && kind != store.KindNovel { + kind = "" + } + page := 1 + if p, err := strconv.Atoi(q.Get("page")); err == nil && p > 1 { + page = p + } + + sf := store.SeriesFilter{ + Site: site, + Kind: kind, + Name: filter, + Cutoff: time.Now().Add(-ownerWindow).UnixMilli(), + Page: page, + } + data, err := h.store.SeriesPage(sf) + if err != nil { + return seriesListView{}, err + } + // A page past the end is not an empty list: the store's window count runs + // over the rows the result set carries, so an overflow page reports zero + // rows and zero total, and the list re-reads at page 1 to know the truth. + if len(data.Rows) == 0 && page > 1 { + page = 1 + sf.Page = 1 + data, err = h.store.SeriesPage(sf) + if err != nil { + return seriesListView{}, err + } + } + + view := seriesListView{ + Site: site, + Kind: kind, + FilterLabel: seriesFilterLabels[filter], + Rows: make([]seriesRowView, 0, len(data.Rows)), + Total: data.Total, + Sites: latest.SiteNames(), + } + now := time.Now() + for i, a := range data.Rows { + view.Rows = append(view.Rows, seriesRow(a, i, now)) + } + view.Filters, err = h.seriesFilterOptions(filter) + if err != nil { + return seriesListView{}, err + } + view.KindBoth = seriesListHref(filter, site, "", 0) + view.KindManga = seriesListHref(filter, site, store.KindManga, 0) + view.KindNovel = seriesListHref(filter, site, store.KindNovel, 0) + if page > 1 { + view.PrevHref = seriesListHref(filter, site, kind, page-1) + } + if last := (data.Total + seriesPageSize - 1) / seriesPageSize; page < last { + view.NextHref = seriesListHref(filter, site, kind, page+1) + } + view.Range = pagerRange(data.Total, len(data.Rows), page) + return view, nil +} + +// seriesFilterOptions renders every hygiene filter with its library-wide +// count, one SeriesShapes pass per filter summed in Go — the shipped surface +// offers eight grouped passes, not a single stats query (#140). The counts +// are library-wide because the select sits next to the Site narrowing and +// must not shift as the owner narrows the list itself. +func (h *Handler) seriesFilterOptions(selected string) ([]seriesFilterOption, error) { + out := make([]seriesFilterOption, 0, len(seriesFilterOrder)) + for _, name := range seriesFilterOrder { + shapes, err := h.store.SeriesShapes(store.SeriesFilter{Name: name}) + if err != nil { + return nil, err + } + count := 0 + for _, sh := range shapes { + count += sh.Total + } + out = append(out, seriesFilterOption{ + Name: name, + Label: seriesFilterLabels[name], + Count: count, + Selected: name == selected, + }) + } + return out, nil +} + +// seriesRow formats one store row for the template. The note chips are the +// row's own hygiene facts, capped at two with a +N tail; attention marks a +// row that carries any. +func seriesRow(a store.AdminSeries, i int, now time.Time) seriesRowView { + row := seriesRowView{ + Key: a.Key(), + Title: a.Title, + Site: a.Site, + Readers: a.ReaderCount, + Band: i%2 == 1, + } + if a.LatestChapterNum != nil { + row.Ch = strconv.FormatFloat(*a.LatestChapterNum, 'f', -1, 64) + } else { + row.Ch = "—" + } + row.Age = checkedAge(now, a.LatestCheckedAt) + notes := seriesNotes(a, now) + if n := len(notes); n > 2 { + row.Notes, row.More = notes[:2], n-2 + } else { + row.Notes = notes + } + row.Attention = len(notes) > 0 + return row +} + +// seriesNotes are a row's hygiene chips in the design's order: no URL, no +// cover, orphan, stale, reader sighting. +func seriesNotes(a store.AdminSeries, now time.Time) []string { + notes := []string{} + if a.SeriesURL == "" { + notes = append(notes, "no URL") + } + if a.CoverAddress == "" { + notes = append(notes, "no cover") + } + if a.ReaderCount == 0 { + notes = append(notes, "orphan") + } + if a.LatestCheckedAt > 0 && a.LatestCheckedAt < now.Add(-ownerWindow).UnixMilli() { + notes = append(notes, "stale") + } + if a.RaisedByReader { + notes = append(notes, "reader sighting") + } + return notes +} + +// checkedAge formats how long ago a Series was last checked, at the +// granularity the list reads at — minutes, hours, days. Zero means never. +func checkedAge(now time.Time, ts int64) string { + if ts == 0 { + return "never" + } + d := now.Sub(time.UnixMilli(ts)) + switch { + case d < time.Hour: + m := int(d / time.Minute) + if m < 1 { + m = 1 + } + return fmt.Sprintf("%dm ago", m) + case d < 24*time.Hour: + return fmt.Sprintf("%dh ago", int(d/time.Hour)) + default: + return fmt.Sprintf("%dd ago", int(d/(24*time.Hour))) + } +} + +// pagerRange is the pager's "1–50 of 120" line. Zero rows reads as "0 of 0": +// the empty state carries the page then, and a window number for a list that +// rendered nothing would be a number nothing points at. +func pagerRange(total, rows, page int) string { + if rows == 0 { + return "0 of 0" + } + from := (page-1)*seriesPageSize + 1 + return fmt.Sprintf("%d–%d of %d", from, from+rows-1, total) +} + +// seriesListHref is one Series list address carrying the filter, Site, Kind +// and page. The All filter and page 1 are the absent cases and stay out of +// the URL, so the default address is the shortest one. +func seriesListHref(filter, site, kind string, page int) string { + q := url.Values{} + if filter != "" && filter != store.SeriesFilterAll { + q.Set("filter", filter) + } + if site != "" { + q.Set("site", site) + } + if kind != "" { + q.Set("kind", kind) + } + if page > 1 { + q.Set("page", strconv.Itoa(page)) + } + if len(q) == 0 { + return "/admin/series" + } + return "/admin/series?" + q.Encode() +} diff --git a/backend/internal/web/static/admin.css b/backend/internal/web/static/admin.css index 3c0d311..9cc4126 100644 --- a/backend/internal/web/static/admin.css +++ b/backend/internal/web/static/admin.css @@ -240,12 +240,20 @@ color: var(--patina); } +.admin-sheet .tbl .trow.attention .c-title { + color: var(--patina); +} + .admin-sheet .tbl .c-ch, .admin-sheet .tbl .c-rd { text-align: right; padding-right: 26px; } +.admin-sheet .tbl .trow .c-note .mark { + margin-right: 8px; +} + .admin-sheet .tbl .c-act { text-align: right; } @@ -368,6 +376,42 @@ row-gap: 4px; } +.admin-sheet .tbl.series .thead > *:first-child { + padding-left: 20px; +} + +.admin-sheet .tbl.series .thead > *:last-child { + padding-right: 20px; +} + +/* The site cell's colour is a class, never an inline style: site is + client-supplied and unvalidated, and a hostile value reaching a CSS + context would render ZgotmplZ, while an unknown class degrades to the + unstyled cell. */ +.admin-sheet .tbl .c-site.site-asura { + color: var(--asura); +} + +.admin-sheet .tbl .c-site.site-demonic { + color: var(--demonic); +} + +.admin-sheet .tbl .c-site.site-comix { + color: var(--comix); +} + +.admin-sheet .tbl .c-site.site-kagane { + color: var(--kagane); +} + +.admin-sheet .tbl .c-site.site-novelfull { + color: var(--novelfull); +} + +.admin-sheet .tbl .c-site.site-lightnovelworld { + color: var(--lightnovelworld); +} + .admin-sheet .tbl.series .trow { display: grid; grid-column: 1 / -1; @@ -434,6 +478,12 @@ border-bottom-color: var(--patina); } +.admin-sheet .fsel select option, +.admin-sheet .pausebar select option { + background: var(--ink); + color: var(--paper); +} + .admin-sheet .segrow { display: inline-flex; gap: 2px; @@ -480,6 +530,14 @@ text-transform: uppercase; } +.admin-sheet .pager .pg { + color: var(--paper-dim); +} + +.admin-sheet .pager .pg:hover { + color: var(--paper); +} + .admin-sheet .pager .pg.disabled { color: var(--faint); pointer-events: none; @@ -497,6 +555,7 @@ .admin-sheet .empty p { margin: 6px 0 0; + font: 14px/1.5 var(--font-body); color: var(--mute); } diff --git a/backend/internal/web/templates/admin.html b/backend/internal/web/templates/admin.html index 1f60e20..5684c26 100644 --- a/backend/internal/web/templates/admin.html +++ b/backend/internal/web/templates/admin.html @@ -39,7 +39,7 @@ {{else if eq .Page "readers"}} {{template "readers" .}} {{else if eq .Page "series"}} -
Nothing matches {{.FilterLabel}}.