diff --git a/backend/internal/web/admin.go b/backend/internal/web/admin.go index 5ae53e3..3afcaa1 100644 --- a/backend/internal/web/admin.go +++ b/backend/internal/web/admin.go @@ -18,8 +18,10 @@ type LaneReporter interface { LaneStatus() latest.Status } -// adminView is what the administrative page and the roster fragment receive. +// adminView is the shared shell data for an administrative page and the roster +// fragment returned after a Reader action. type adminView struct { + Page string Readers []store.ReaderSummary // OwnerID travels with the roster so it can tell the owner's own row from // the Readers they may act on. @@ -84,6 +86,9 @@ type adminRoute struct { func (h *Handler) adminRoutes() []adminRoute { return []adminRoute{ {"GET /admin", h.admin}, + {"GET /admin/lanes", h.adminLanes}, + {"GET /admin/readers", h.adminReaders}, + {"GET /admin/series", h.adminSeries}, {"GET /ui/admin/lanes", h.uiLanes}, {"POST /readers/{id}/revoke", h.revokeReaderSessions}, {"POST /readers/{id}/clear-marks", h.clearReaderMarks}, @@ -116,19 +121,35 @@ func (h *Handler) requireOwner(next http.HandlerFunc) http.HandlerFunc { }) } -// admin renders the owner's page: the Reader roster and Poll Lane status. +// admin renders the Overview shell. Its data arrives in later admin tickets. func (h *Handler) admin(w http.ResponseWriter, r *http.Request) { + h.renderAdmin(w, adminView{Page: "overview"}) +} + +// adminLanes renders the page that hosts the live Lane fragment. +func (h *Handler) adminLanes(w http.ResponseWriter, r *http.Request) { + h.renderAdmin(w, adminView{Page: "lanes", Lanes: h.lanesView()}) +} + +// adminReaders renders the Reader roster on its own bookmarkable page. +func (h *Handler) adminReaders(w http.ResponseWriter, r *http.Request) { readers, err := h.store.Readers() if err != nil { - log.Printf("admin: %v", err) + log.Printf("admin readers: %v", err) http.Error(w, "internal error", http.StatusInternalServerError) return } - h.render(w, http.StatusOK, "admin", adminView{ - Readers: readers, - OwnerID: h.store.OwnerID(), - Lanes: h.lanesView(), - }) + 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) } // uiLanes answers the status block's own refresh. Only the block refreshes on a diff --git a/backend/internal/web/static/admin.css b/backend/internal/web/static/admin.css new file mode 100644 index 0000000..3c0d311 --- /dev/null +++ b/backend/internal/web/static/admin.css @@ -0,0 +1,692 @@ +:root { + --measure-wide: 1080px; +} + +@media (prefers-color-scheme: light) { + :root { + --measure-wide: 1080px; + } +} + +.admin-sheet { + max-width: var(--measure-wide); +} + +.admin-sheet .brand em { + color: var(--patina); +} + +.admin-sheet .brand .mark > g > g:last-child { + stroke: var(--patina); +} + +.topbar-actions { + display: flex; + align-items: center; + gap: 18px; + margin-left: auto; +} + +.navrow { + display: flex; + gap: 18px; + padding: 2px 20px 0; + overflow-x: auto; + overflow-y: hidden; + scrollbar-width: none; + border-bottom: 1px solid var(--rule); +} + +.navrow::-webkit-scrollbar { + display: none; +} + +.navrow a { + flex: none; + display: flex; + align-items: center; + min-width: 44px; + padding: 8px 0 12px; + color: var(--mute); + font: 400 17px var(--font-display); + white-space: nowrap; +} + +.navrow a:hover { + color: var(--paper-dim); +} + +.navrow a.active { + color: var(--paper); + border-bottom: 2px solid var(--paper); + margin-bottom: -1px; +} + +.admin-page { + padding: 0 20px 40px; +} + +.admin-page > .sec, +.admin-sheet .readers h2, +.admin-sheet .lanes h2 { + position: relative; + margin: 0; + padding: 26px 0 8px; + font: 500 11px/1 var(--font-mono); + letter-spacing: .2em; + text-transform: uppercase; + color: var(--mute-2); +} + +.admin-page > .sec::before, +.admin-sheet .readers h2::before, +.admin-sheet .lanes h2::before { + content: ""; + position: absolute; + top: 14px; + left: 0; + width: 34px; + height: 2px; + background: var(--patina); +} + +.admin-sheet .readers, +.admin-sheet .lanes { + margin: 0; + padding: 0 0 16px; + border-bottom: none; +} + +.admin-sheet .readerlist, +.admin-sheet .lanelist { + margin: 0; + padding: 0; + list-style: none; +} + +.admin-sheet .readerlist li, +.admin-sheet .lanelist li { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 4px 16px; + min-height: 48px; + padding: 10px 0; + border-top: 1px solid var(--rule); +} + +.admin-sheet .reader-actions { + display: flex; + flex: 0 0 auto; + gap: 18px; + margin-left: auto; + white-space: nowrap; +} + +.admin-sheet .reader-actions .ghost, +.admin-sheet .c-act .ghost { + font-size: 12px; + color: var(--patina); +} + +.admin-sheet .reader-actions .ghost.danger, +.admin-sheet .c-act .ghost.danger { + color: var(--danger); +} + +.admin-sheet .readerlist form { + margin: 0; +} + +.admin-sheet .lane-browser { + padding: 12px 0 0; +} + +.admin-sheet .ghost.danger { + color: var(--danger); +} + +.admin-sheet .ghost.danger:hover { + color: var(--danger); + border-bottom-color: var(--danger); +} + +.admin-sheet .reader-id { + font: 500 15px/1.5 var(--font-mono); + letter-spacing: .01em; + color: var(--paper); +} + +.admin-sheet .reader-sessions, +.admin-sheet .reader-sightings, +.admin-sheet .reader-blocked, +.admin-sheet .lane-fact, +.admin-sheet .lane-mark { + font: 500 13px/1.4 var(--font-mono); + letter-spacing: .06em; + text-transform: uppercase; +} + +.admin-sheet .reader-sessions { + color: var(--paper-dim); +} + +.admin-sheet .reader-sightings, +.admin-sheet .lane-fact { + color: var(--mute); +} + +.admin-sheet .reader-blocked, +.admin-sheet .lane-mark { + color: var(--patina); +} + +.admin-sheet .lane-site { + font: 400 19px/1.2 var(--font-display); + color: var(--paper-dim); +} + +.admin-sheet .lanelist li.attention .lane-site { + color: var(--danger); +} + +/* A single grid keeps row rules continuous; cell padding supplies gutters. */ +.admin-sheet .tbl { + display: grid; + grid-template-columns: minmax(240px, 1fr) 156px 92px 110px 76px minmax(150px, 220px) 140px; + column-gap: 0; + font-variant-numeric: tabular-nums; +} + +.admin-sheet .tbl .thead { + display: contents; +} + +.admin-sheet .tbl .thead > * { + padding: 10px 14px 8px 0; + border-bottom: 1px solid var(--rule); + font: 500 12px/1 var(--font-mono); + letter-spacing: .12em; + text-transform: uppercase; + color: var(--mute-2); +} + +.admin-sheet .tbl .trow { + display: contents; +} + +.admin-sheet .tbl .trow > * { + padding: 11px 14px 11px 0; + border-bottom: 1px solid var(--rule); + font: 500 15px/1.5 var(--font-mono); + letter-spacing: .01em; + color: var(--paper-dim); +} + +.admin-sheet .tbl .thead > *:last-child, +.admin-sheet .tbl .trow > *:last-child { + padding-right: 0; +} + +.admin-sheet .tbl .c-title, +.admin-sheet .tbl .c-site { + font: 400 18px/1.35 var(--font-display); + letter-spacing: 0; + color: var(--paper); +} + +.admin-sheet .tbl .c-title a:hover, +.admin-sheet .tbl .c-act .ghost:hover { + color: var(--patina); +} + +.admin-sheet .tbl .c-ch, +.admin-sheet .tbl .c-rd { + text-align: right; + padding-right: 26px; +} + +.admin-sheet .tbl .c-act { + text-align: right; +} + +.admin-sheet .tbl .trow > .confirm-row { + grid-column: 1 / -1; + padding: 10px 12px; + border-bottom: none; +} + +.admin-sheet .stats { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(232px, 1fr)); + margin: 4px 0 0; + border-bottom: 1px solid var(--rule); +} + +.admin-sheet .stat { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 16px; + padding: 11px 24px 11px 0; +} + +.admin-sheet .stat .lbl { + font: 500 13px/1.3 var(--font-mono); + letter-spacing: .1em; + text-transform: uppercase; + color: var(--mute-2); +} + +.admin-sheet .stat .fig { + font: 500 19px/1 var(--font-mono); + font-variant-numeric: tabular-nums; + color: var(--paper); +} + +.admin-sheet .stat .fig.zero { + color: var(--mute); +} + +.admin-sheet .stat a.fig:hover { + color: var(--patina); + border-bottom: 1px solid var(--patina); +} + +.admin-sheet .mark, +.admin-sheet .mark-faint { + font: 500 13px/1 var(--font-mono); + letter-spacing: .06em; + text-transform: uppercase; + white-space: nowrap; +} + +.admin-sheet .mark { + color: var(--patina); +} + +.admin-sheet .mark-faint { + color: var(--mute-2); +} + +.admin-sheet .mark.bad { + color: var(--danger); +} + +.admin-sheet .tbl.sites { + grid-template-columns: 170px repeat(4, 106px) minmax(180px, 1fr); +} + +.admin-sheet .tbl.sites .c-site, +.admin-sheet .tbl.lanes .c-site { + font: 400 18px/1.35 var(--font-display); + letter-spacing: 0; + color: var(--paper); +} + +.admin-sheet .tbl.sites .c-state { + color: var(--patina); + white-space: nowrap; +} + +.admin-sheet .tbl.sites .c-state.bad, +.admin-sheet .tbl.lanes .c-skip .bad, +.admin-sheet .tbl.lanes .trow.attention .c-site { + color: var(--danger); +} + +.admin-sheet .tbl.sites .thead > *:nth-child(n+2):nth-child(-n+5), +.admin-sheet .tbl.sites .trow > *:nth-child(n+2):nth-child(-n+5) { + padding-right: 0; + text-align: center; +} + +.admin-sheet .tbl.lanes { + grid-template-columns: 150px 62px 88px 66px 118px minmax(0, 1fr) 168px; +} + +.admin-sheet .tbl.lanes .c-skip { + white-space: normal; +} + +.admin-sheet .tbl.lanes .c-skip > * { + white-space: nowrap; +} + +.admin-sheet .tbl.lanes .c-ctrl { + text-align: right; +} + +.admin-sheet .tbl.lanes .thead > *:nth-child(n+2):nth-child(-n+4), +.admin-sheet .tbl.lanes .trow > *:nth-child(n+2):nth-child(-n+4) { + padding-right: 26px; + text-align: right; +} + +.admin-sheet .tbl.series { + grid-template-columns: 150px 84px 104px 76px minmax(120px, 1fr) 212px; + row-gap: 4px; +} + +.admin-sheet .tbl.series .trow { + display: grid; + grid-column: 1 / -1; + grid-template-columns: subgrid; + padding: 13px 20px 14px; +} + +.admin-sheet .tbl.series .trow.band { + background: var(--hover); +} + +.admin-sheet .tbl.series .trow > * { + padding: 0 14px 0 0; + border-bottom: none; +} + +.admin-sheet .tbl.series .c-title { + display: flex; + grid-column: 1 / -1; + align-items: baseline; + gap: 16px; + padding: 0 0 4px; +} + +.admin-sheet .tbl.series .c-title .mark { + margin-left: auto; +} + +.admin-sheet .filterbar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 14px 22px; + padding: 16px 0 14px; + border-bottom: 1px solid var(--rule); +} + +.admin-sheet .fsel { + display: flex; + align-items: baseline; + gap: 10px; +} + +.admin-sheet .fsel > span { + font: 500 11px/1 var(--font-mono); + letter-spacing: .14em; + text-transform: uppercase; + color: var(--mute-2); +} + +.admin-sheet .fsel select, +.admin-sheet .pausebar select { + color-scheme: dark; + background: var(--ink); + border: none; + border-bottom: 1px solid var(--field-line); + color: var(--paper); + font: 400 16px/1.4 var(--font-display); + padding: 4px 4px 5px 0; +} + +.admin-sheet .fsel select:hover, +.admin-sheet .pausebar select:hover { + border-bottom-color: var(--patina); +} + +.admin-sheet .segrow { + display: inline-flex; + gap: 2px; +} + +.admin-sheet .segrow a { + padding: 6px 11px 7px; + border-bottom: 2px solid transparent; + color: var(--mute); + font: 500 14px/1 var(--font-mono); +} + +.admin-sheet .segrow a.active { + border-bottom-color: var(--patina); + color: var(--patina); +} + +.admin-sheet .listhead { + display: flex; + align-items: baseline; + gap: 12px; + padding: 16px 0 4px; + color: var(--paper); + font: 400 18px/1.2 var(--font-display); +} + +.admin-sheet .listhead .lbl { + color: var(--mute); +} + +.admin-sheet .listhead .lbl em { + color: var(--patina); + font-style: normal; +} + +.admin-sheet .pager { + display: flex; + align-items: center; + gap: 14px; + padding: 14px 0 0; + color: var(--mute-2); + font: 500 11px/1 var(--font-mono); + letter-spacing: .14em; + text-transform: uppercase; +} + +.admin-sheet .pager .pg.disabled { + color: var(--faint); + pointer-events: none; +} + +.admin-sheet .empty { + padding: 28px 0; + text-align: center; +} + +.admin-sheet .empty strong { + color: var(--paper); + font: 400 20px var(--font-display); +} + +.admin-sheet .empty p { + margin: 6px 0 0; + color: var(--mute); +} + +.admin-sheet .confirm-row { + display: flex; + align-items: center; + gap: 12px; + padding: 10px 12px; + background: var(--danger-wash); +} + +.admin-sheet .confirm-row span { + flex: 1 1 16ch; + color: var(--danger-soft); + font: 400 15px/1.3 var(--font-display); +} + +.admin-sheet .confirm-row div { + display: flex; + flex: none; + gap: 12px; + margin-left: auto; +} + +.admin-sheet .detail-back { + display: inline-block; + margin: 18px 0 0; +} + +.admin-sheet .detail-title { + margin: 10px 0 2px; + color: var(--paper); + font: 400 28px/1.25 var(--font-display); +} + +.admin-sheet .detail-key { + margin: 0; + color: var(--mute-2); + font: 500 11px/1.4 var(--font-mono); + letter-spacing: .08em; +} + +.admin-sheet .detail-meta { + display: flex; + flex-wrap: wrap; + gap: 4px 14px; + margin: 10px 0 0; + color: var(--mute-2); + font: 500 12px/1.5 var(--font-mono); + letter-spacing: .08em; + text-transform: uppercase; +} + +.admin-sheet .cover { + width: 160px; + aspect-ratio: 3 / 4; + display: flex; + align-items: center; + justify-content: center; + margin: 18px 0 4px; + background: var(--hatch); + color: var(--mute-2); + font: 500 10px/1 var(--font-mono); + letter-spacing: .2em; + text-transform: uppercase; +} + +.admin-sheet .detail-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0 28px; +} + +.admin-sheet .dform { + padding: 14px 0 0; +} + +.admin-sheet .dform h3 { + margin: 0 0 4px; + color: var(--mute-2); + font: 500 10px/1 var(--font-mono); + letter-spacing: .2em; + text-transform: uppercase; +} + +.admin-sheet .pausebar { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 8px; +} + +@media (max-width: 719px) { + .admin-sheet .topbar { + flex-wrap: wrap; + row-gap: 12px; + } + + .admin-sheet .brand { + flex: 1 1 100%; + } + + .admin-sheet .topbar-actions { + margin-left: auto; + } +} + +@media (max-width: 1019px) { + .admin-sheet .tbl.lanes { + display: block; + } + + .admin-sheet .tbl.lanes .thead { + display: none; + } + + .admin-sheet .tbl.lanes .trow { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 4px 16px; + padding: 12px 0; + border-bottom: 1px solid var(--rule); + } + + .admin-sheet .tbl.lanes .trow > * { + padding: 0; + border-bottom: none; + text-align: left; + } + + .admin-sheet .tbl.lanes .c-site { + width: 100%; + padding-bottom: 2px; + } + + .admin-sheet .tbl.lanes .c-ctrl { + margin-left: auto; + text-align: right; + } +} + +@media (max-width: 899px) { + .admin-sheet .tbl, + .admin-sheet .tbl.lanes { + display: block; + } + + .admin-sheet .tbl .thead { + display: none; + } + + .admin-sheet .tbl .trow { + display: flex; + flex-wrap: wrap; + gap: 2px 10px; + padding: 11px 0; + border-bottom: 1px solid var(--rule); + } + + .admin-sheet .tbl .trow > * { + padding: 0; + border-bottom: none; + text-align: left; + } + + .admin-sheet .tbl .c-ch, + .admin-sheet .tbl .c-rd { + padding-right: 0; + text-align: left; + } + + .admin-sheet .tbl .c-title { + width: 100%; + padding-bottom: 2px; + } + + .admin-sheet .tbl.series .trow { + display: flex; + } + + .admin-sheet .tbl.series .trow > * { + padding: 0; + } + + .admin-sheet .detail-grid { + grid-template-columns: 1fr; + } + + .admin-sheet .tbl .c-act { + margin-left: auto; + } +} diff --git a/backend/internal/web/static/style.css b/backend/internal/web/static/style.css index 4a1ba46..91c1526 100644 --- a/backend/internal/web/static/style.css +++ b/backend/internal/web/static/style.css @@ -296,69 +296,6 @@ button { cursor: pointer; } letter-spacing: .04em; } -/* ---- admin page: two sections on the same measured sheet, no cards ---- - The reading page is a list of series; this is a list of facts. Both are - sheets of hairline-separated rows, so the roster keeps the shape it had as - a fold-out and the Lane block copies it. */ -.readers, .lanes { margin: 0 20px; padding: 12px 0 16px; border-bottom: 1px solid var(--rule); } -.readers h2, .lanes h2 { - margin: 0; - padding: 8px 0; - font: 500 10px/1 var(--font-mono); - letter-spacing: .2em; - text-transform: uppercase; - color: var(--mute-2); -} -.readerlist, .lanelist { margin: 0; padding: 0; list-style: none; } -.readerlist li, .lanelist li { - display: flex; - align-items: center; - flex-wrap: wrap; - gap: 4px 16px; - min-height: 44px; - border-top: 1px solid var(--rule); -} -.reader-actions { display: flex; gap: 18px; margin-left: auto; } -.readerlist form { margin: 0; } -.reader-id { - font: 500 13px/1.4 var(--font-mono); - letter-spacing: .04em; - color: var(--paper); -} -.reader-sessions { - font: 500 10px/1 var(--font-mono); - letter-spacing: .14em; - text-transform: uppercase; - color: var(--mute); -} -.reader-sightings, .lane-fact { - font: 500 10px/1 var(--font-mono); - letter-spacing: .14em; - text-transform: uppercase; - color: var(--mute-2); -} -/* Two states the owner is meant to find rather than read for: a Reader whose - reports no longer defer a Poll, and a Lane that is not keeping its promise. - Both wear --patina — never ember, which means one thing, and never danger, - which is destruction. */ -.reader-blocked, .lane-mark { - font: 500 10px/1 var(--font-mono); - letter-spacing: .14em; - text-transform: uppercase; - color: var(--patina); -} -.lane-site { - font: 400 19px/1.2 var(--font-display); - color: var(--paper-dim); -} -/* The whole row leans patina when the Lane needs attention, so the scan is one - pass down the left edge rather than a read of every mark. */ -.lanelist li.attention .lane-site { color: var(--patina); } -.lane-browser { padding: 12px 0 0; } -/* Revocation cuts someone off, so it wears --danger. Ember stays reserved for - the new-chapter signal. */ -.ghost.danger { color: var(--danger); } -.ghost.danger:hover { color: var(--danger); border-bottom-color: var(--danger); } .chrome { display: flex; flex-direction: column; } @@ -643,9 +580,6 @@ button { cursor: pointer; } box-shadow: inset 0 -2px 0 var(--ember); } .topbar form { margin-left: 18px; } -/* The admin page's topbar has no switch to fill the middle, so its back link - keeps company with Log out at the right edge instead of floating centre. */ -.topbar .back { margin-left: auto; } /* At phone width brand + switch + Log out do not fit on one line, so the switch takes its own row under the wordmark rather than pushing Log out off-screen. */ @@ -655,9 +589,6 @@ button { cursor: pointer; } .libswitch { order: 3; margin-left: 0; } .libswitch a { flex: 1; text-align: center; padding: 8px 14px; } .topbar form { margin-left: 12px; } - /* The admin page has no switch to take the second row, so its brand claims - the first outright and the back link keeps Log out company below. */ - .topbar:has(.back) .brand { flex: 1 1 100%; } } /* ---- action strip: full-width on a phone, hairline-divided cells ---- */ diff --git a/backend/internal/web/templates/admin.html b/backend/internal/web/templates/admin.html index f2f8e0d..1f60e20 100644 --- a/backend/internal/web/templates/admin.html +++ b/backend/internal/web/templates/admin.html @@ -1,7 +1,5 @@ -{{/* The owner's administrative page: everything that reaches past one Reader, - at its own address so it can be bookmarked rather than hunted for inside - the reading page. Owner-only at route registration (requireOwner), which - is why nothing in here re-tests who is asking. */}} +{{/* Every owner-only address shares this shell; page content stays behind its + bookmarkable route so the active tab survives a reload. */}} {{define "admin"}} @@ -12,27 +10,40 @@ BookmarkManager — Admin + -
+

{{template "mark" .}}BookmarkManager

- {{/* Back to the library, no switch: this page belongs to neither library, - and the ember-lit switch says which library you are reading. */}} - Library -
- -
+ + Library +
+ +
+
- {{/* The live region wraps the swapped block rather than being it: the - refresh replaces the section wholesale, and a region recreated on every - update is never announced. */}} -
{{template "lanes" .Lanes}}
+ - {{template "readers" .}} +
+ {{if eq .Page "lanes"}} +
{{template "lanes" .Lanes}}
+ {{else if eq .Page "readers"}} + {{template "readers" .}} + {{else if eq .Page "series"}} +

Series

+ {{else}} +

Overview

+ {{end}} +
diff --git a/backend/web_test.go b/backend/web_test.go index baf61c7..dcfd21d 100644 --- a/backend/web_test.go +++ b/backend/web_test.go @@ -640,10 +640,9 @@ type fakeLanes struct{ status latest.Status } func (f fakeLanes) LaneStatus() latest.Status { return f.status } -// The roster moved off the reading page onto its own address: the owner gets a -// link, everyone else gets nothing, and the page itself lists every Reader with -// the counters and the two controls. -func TestAdminPageCarriesRosterAndOwnerLink(t *testing.T) { +// Every admin address carries the same navigation, while the roster only lives +// on its own page and the other pages keep their shells independent. +func TestAdminPagesCarrySharedNavigation(t *testing.T) { router, st, _ := oauthWebTestServer(t) theirCookie := signInCookie(t, router) ownerCookie := sessionCookie(t, st) @@ -654,12 +653,11 @@ func TestAdminPageCarriesRosterAndOwnerLink(t *testing.T) { router.ServeHTTP(rr, req) body := rr.Body.String() if strings.Contains(body, `id="readers"`) { - t.Error("the reading page still carries the roster; it belongs on /admin") + t.Error("the reading page still carries the roster; it belongs on /admin/readers") } if !strings.Contains(body, `href="/admin"`) { t.Error("the owner's reading page offers no link to the admin page") } - req = httptest.NewRequest(http.MethodGet, "/", nil) req.AddCookie(theirCookie) rr = httptest.NewRecorder() @@ -668,21 +666,55 @@ func TestAdminPageCarriesRosterAndOwnerLink(t *testing.T) { t.Error("a non-owner was offered the admin link") } - req = httptest.NewRequest(http.MethodGet, "/admin", nil) + for _, page := range []struct { + path string + name string + }{ + {"/admin", "Overview"}, + {"/admin/lanes", "Lanes"}, + {"/admin/readers", "Readers"}, + {"/admin/series", "Series"}, + } { + t.Run(page.name, func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, page.path, nil) + req.AddCookie(ownerCookie) + rr := httptest.NewRecorder() + router.ServeHTTP(rr, req) + if rr.Code != http.StatusOK { + t.Fatalf("GET %s status = %d, want 200", page.path, rr.Code) + } + body := rr.Body.String() + if !strings.Contains(body, `class="topbar-actions"`) { + t.Errorf("%s has no topbar action cluster:\n%s", page.path, body) + } + if !strings.Contains(body, `aria-label="Admin pages"`) { + t.Errorf("%s has no admin navigation:\n%s", page.path, body) + } + if strings.Count(body, `aria-current="page"`) != 1 { + t.Errorf("%s has %d active admin tabs, want 1:\n%s", page.path, strings.Count(body, `aria-current="page"`), body) + } + if !strings.Contains(body, page.name) { + t.Errorf("%s does not name its active page %q:\n%s", page.path, page.name, body) + } + if !strings.Contains(body, `href="/static/admin.css"`) { + t.Errorf("%s does not load the admin foundation stylesheet", page.path) + } + if page.name == "Lanes" && strings.Count(body, `hx-trigger="every 30s"`) != 1 { + t.Errorf("%s has %d Lane timers, want exactly 1", page.path, strings.Count(body, `hx-trigger="every 30s"`)) + } + }) + } + + req = httptest.NewRequest(http.MethodGet, "/admin/readers", nil) req.AddCookie(ownerCookie) rr = httptest.NewRecorder() router.ServeHTTP(rr, req) - if rr.Code != http.StatusOK { - t.Fatalf("GET /admin status = %d, want 200", rr.Code) - } body = rr.Body.String() for _, want := range []string{`id="readers"`, testOwnerID, "Revoke sessions", "Clear marks", "confirmed"} { if !strings.Contains(body, want) { - t.Errorf("admin page lacks %q:\n%s", want, body) + t.Errorf("readers page lacks %q:\n%s", want, body) } } - // Exactly one revocable row: the other Reader's. The owner's own row carries - // the same session count and no button. if n := strings.Count(body, "/revoke"); n != 1 { t.Fatalf("roster has %d revoke controls, want 1 (the owner's own row must have none):\n%s", n, body) } @@ -845,7 +877,7 @@ func TestAdminPageWithoutAPollerSaysSo(t *testing.T) { } { t.Run(tc.name, func(t *testing.T) { router, st, _ := oauthWebTestServer(t, tc.lanes...) - req := httptest.NewRequest(http.MethodGet, "/admin", nil) + req := httptest.NewRequest(http.MethodGet, "/admin/lanes", nil) req.AddCookie(sessionCookie(t, st)) rr := httptest.NewRecorder() router.ServeHTTP(rr, req) @@ -881,7 +913,7 @@ func TestOwnerClearsReaderMarks(t *testing.T) { t.Fatalf("mark reader: %v", err) } - req := httptest.NewRequest(http.MethodGet, "/admin", nil) + req := httptest.NewRequest(http.MethodGet, "/admin/readers", nil) req.AddCookie(cookie) rr := httptest.NewRecorder() router.ServeHTTP(rr, req)