feat: split admin surface into bookmarkable pages (#138)

This commit is contained in:
2026-08-21 16:54:54 +07:00
parent 0a245a0dde
commit 030ffdc26e
5 changed files with 795 additions and 108 deletions
+29 -8
View File
@@ -18,8 +18,10 @@ type LaneReporter interface {
LaneStatus() latest.Status 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 { type adminView struct {
Page string
Readers []store.ReaderSummary Readers []store.ReaderSummary
// OwnerID travels with the roster so it can tell the owner's own row from // OwnerID travels with the roster so it can tell the owner's own row from
// the Readers they may act on. // the Readers they may act on.
@@ -84,6 +86,9 @@ type adminRoute struct {
func (h *Handler) adminRoutes() []adminRoute { func (h *Handler) adminRoutes() []adminRoute {
return []adminRoute{ return []adminRoute{
{"GET /admin", h.admin}, {"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}, {"GET /ui/admin/lanes", h.uiLanes},
{"POST /readers/{id}/revoke", h.revokeReaderSessions}, {"POST /readers/{id}/revoke", h.revokeReaderSessions},
{"POST /readers/{id}/clear-marks", h.clearReaderMarks}, {"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) { 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() readers, err := h.store.Readers()
if err != nil { if err != nil {
log.Printf("admin: %v", err) log.Printf("admin readers: %v", err)
http.Error(w, "internal error", http.StatusInternalServerError) http.Error(w, "internal error", http.StatusInternalServerError)
return return
} }
h.render(w, http.StatusOK, "admin", adminView{ h.renderAdmin(w, adminView{Page: "readers", Readers: readers, OwnerID: h.store.OwnerID()})
Readers: readers, }
OwnerID: h.store.OwnerID(),
Lanes: h.lanesView(), // 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 // uiLanes answers the status block's own refresh. Only the block refreshes on a
+692
View File
@@ -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;
}
}
-69
View File
@@ -296,69 +296,6 @@ button { cursor: pointer; }
letter-spacing: .04em; 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; } .chrome { display: flex; flex-direction: column; }
@@ -643,9 +580,6 @@ button { cursor: pointer; }
box-shadow: inset 0 -2px 0 var(--ember); box-shadow: inset 0 -2px 0 var(--ember);
} }
.topbar form { margin-left: 18px; } .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 /* 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 switch takes its own row under the wordmark rather than pushing Log out
off-screen. */ off-screen. */
@@ -655,9 +589,6 @@ button { cursor: pointer; }
.libswitch { order: 3; margin-left: 0; } .libswitch { order: 3; margin-left: 0; }
.libswitch a { flex: 1; text-align: center; padding: 8px 14px; } .libswitch a { flex: 1; text-align: center; padding: 8px 14px; }
.topbar form { margin-left: 12px; } .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 ---- */ /* ---- action strip: full-width on a phone, hairline-divided cells ---- */
+27 -16
View File
@@ -1,7 +1,5 @@
{{/* The owner's administrative page: everything that reaches past one Reader, {{/* Every owner-only address shares this shell; page content stays behind its
at its own address so it can be bookmarked rather than hunted for inside bookmarkable route so the active tab survives a reload. */}}
the reading page. Owner-only at route registration (requireOwner), which
is why nothing in here re-tests who is asking. */}}
{{define "admin"}} {{define "admin"}}
<!doctype html> <!doctype html>
<html lang="en"> <html lang="en">
@@ -12,27 +10,40 @@
<title>BookmarkManager — Admin</title> <title>BookmarkManager — Admin</title>
<link rel="icon" href="/static/logo.svg" type="image/svg+xml"> <link rel="icon" href="/static/logo.svg" type="image/svg+xml">
<link rel="stylesheet" href="/static/style.css"> <link rel="stylesheet" href="/static/style.css">
<link rel="stylesheet" href="/static/admin.css">
<link rel="preload" href="/static/fonts/instrument-serif-400-latin.woff2" as="font" type="font/woff2" crossorigin> <link rel="preload" href="/static/fonts/instrument-serif-400-latin.woff2" as="font" type="font/woff2" crossorigin>
<script src="/static/htmx.min.js" defer></script> <script src="/static/htmx.min.js" defer></script>
</head> </head>
<body> <body>
<div class="sheet"> <div class="sheet admin-sheet">
<header class="topbar"> <header class="topbar">
<h1 class="brand">{{template "mark" .}}<span>Bookmark<em>Manager</em></span></h1> <h1 class="brand">{{template "mark" .}}<span>Bookmark<em>Manager</em></span></h1>
{{/* Back to the library, no switch: this page belongs to neither library, <span class="topbar-actions">
and the ember-lit switch says which library you are reading. */}} <a class="ghost" href="/">Library</a>
<a class="ghost back" href="/">Library</a> <form method="post" action="/logout">
<form method="post" action="/logout"> <button type="submit" class="ghost">Log out</button>
<button type="submit" class="ghost">Log out</button> </form>
</form> </span>
</header> </header>
{{/* The live region wraps the swapped block rather than being it: the <nav class="navrow" aria-label="Admin pages">
refresh replaces the section wholesale, and a region recreated on every <a href="/admin" class="{{if eq .Page "overview"}}active{{end}}" {{if eq .Page "overview"}}aria-current="page"{{end}}>Overview</a>
update is never announced. */}} <a href="/admin/lanes" class="{{if eq .Page "lanes"}}active{{end}}" {{if eq .Page "lanes"}}aria-current="page"{{end}}>Lanes</a>
<div aria-live="polite">{{template "lanes" .Lanes}}</div> <a href="/admin/readers" class="{{if eq .Page "readers"}}active{{end}}" {{if eq .Page "readers"}}aria-current="page"{{end}}>Readers</a>
<a href="/admin/series" class="{{if eq .Page "series"}}active{{end}}" {{if eq .Page "series"}}aria-current="page"{{end}}>Series</a>
</nav>
{{template "readers" .}} <main class="page admin-page">
{{if eq .Page "lanes"}}
<div aria-live="polite">{{template "lanes" .Lanes}}</div>
{{else if eq .Page "readers"}}
{{template "readers" .}}
{{else if eq .Page "series"}}
<h2 class="sec">Series</h2>
{{else}}
<h2 class="sec">Overview</h2>
{{end}}
</main>
</div> </div>
</body> </body>
</html> </html>
+47 -15
View File
@@ -640,10 +640,9 @@ type fakeLanes struct{ status latest.Status }
func (f fakeLanes) LaneStatus() latest.Status { return f.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 // Every admin address carries the same navigation, while the roster only lives
// link, everyone else gets nothing, and the page itself lists every Reader with // on its own page and the other pages keep their shells independent.
// the counters and the two controls. func TestAdminPagesCarrySharedNavigation(t *testing.T) {
func TestAdminPageCarriesRosterAndOwnerLink(t *testing.T) {
router, st, _ := oauthWebTestServer(t) router, st, _ := oauthWebTestServer(t)
theirCookie := signInCookie(t, router) theirCookie := signInCookie(t, router)
ownerCookie := sessionCookie(t, st) ownerCookie := sessionCookie(t, st)
@@ -654,12 +653,11 @@ func TestAdminPageCarriesRosterAndOwnerLink(t *testing.T) {
router.ServeHTTP(rr, req) router.ServeHTTP(rr, req)
body := rr.Body.String() body := rr.Body.String()
if strings.Contains(body, `id="readers"`) { 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"`) { if !strings.Contains(body, `href="/admin"`) {
t.Error("the owner's reading page offers no link to the admin page") t.Error("the owner's reading page offers no link to the admin page")
} }
req = httptest.NewRequest(http.MethodGet, "/", nil) req = httptest.NewRequest(http.MethodGet, "/", nil)
req.AddCookie(theirCookie) req.AddCookie(theirCookie)
rr = httptest.NewRecorder() rr = httptest.NewRecorder()
@@ -668,21 +666,55 @@ func TestAdminPageCarriesRosterAndOwnerLink(t *testing.T) {
t.Error("a non-owner was offered the admin link") 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) req.AddCookie(ownerCookie)
rr = httptest.NewRecorder() rr = httptest.NewRecorder()
router.ServeHTTP(rr, req) router.ServeHTTP(rr, req)
if rr.Code != http.StatusOK {
t.Fatalf("GET /admin status = %d, want 200", rr.Code)
}
body = rr.Body.String() body = rr.Body.String()
for _, want := range []string{`id="readers"`, testOwnerID, "Revoke sessions", "Clear marks", "confirmed"} { for _, want := range []string{`id="readers"`, testOwnerID, "Revoke sessions", "Clear marks", "confirmed"} {
if !strings.Contains(body, want) { 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 { 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) 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) { t.Run(tc.name, func(t *testing.T) {
router, st, _ := oauthWebTestServer(t, tc.lanes...) 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)) req.AddCookie(sessionCookie(t, st))
rr := httptest.NewRecorder() rr := httptest.NewRecorder()
router.ServeHTTP(rr, req) router.ServeHTTP(rr, req)
@@ -881,7 +913,7 @@ func TestOwnerClearsReaderMarks(t *testing.T) {
t.Fatalf("mark reader: %v", err) t.Fatalf("mark reader: %v", err)
} }
req := httptest.NewRequest(http.MethodGet, "/admin", nil) req := httptest.NewRequest(http.MethodGet, "/admin/readers", nil)
req.AddCookie(cookie) req.AddCookie(cookie)
rr := httptest.NewRecorder() rr := httptest.NewRecorder()
router.ServeHTTP(rr, req) router.ServeHTTP(rr, req)