bd7e99daf81b
add a navbar bell with unread dot and recent notifications (v3.0.207)
- time
- 2026-10-03 14:45 UTC · 1 week ago
- machine
- Junes-MacBook-Pro.local
- agent
- gpt-6.1-sol
- session
- tidy operator navigation
- parent
- 7531ccf13b73
↓ kepr-bd7e99da.tar.gzatlas at this save
files changed6 files
package.json+1 −1public/css/kepr.css+78 −0public/js/nav.js+73 −1src/admin/notifications.ts+21 −0tests/navbar.test.ts+15 −3tests/notifications.test.ts+31 −0package.json+1 −1
@@ -1,6 +1,6 @@11 {22 "name": "kepr",3- "version": "3.0.206",3+ "version": "3.0.207",44 "private": true,55 "type": "module",66 "scripts": {public/css/kepr.css+78 −0
@@ -152,6 +152,79 @@152152 text-decoration-color: var(--accent);153153 }154154 155+.nav-notifications {156+ position: relative;157+ margin-left: 24px;158+ font-family: var(--mono);159+}160+.nav-notifications summary {161+ position: relative;162+ display: flex;163+ align-items: center;164+ justify-content: center;165+ width: 36px;166+ height: 36px;167+ cursor: pointer;168+ list-style: none;169+ color: var(--text);170+}171+.nav-notifications summary::-webkit-details-marker {172+ display: none;173+}174+.nav-notifications svg {175+ width: 20px;176+ height: 20px;177+ fill: none;178+ stroke: currentColor;179+ stroke-width: 1.5;180+ stroke-linecap: round;181+ stroke-linejoin: round;182+}183+.notification-dot {184+ position: absolute;185+ top: 4px;186+ right: 4px;187+ width: 7px;188+ height: 7px;189+ border-radius: 50%;190+ background: var(--accent-bright);191+}192+.notification-preview {193+ position: absolute;194+ top: calc(100% + 8px);195+ right: 0;196+ z-index: 20;197+ width: min(340px, calc(100vw - 32px));198+ max-height: 70vh;199+ overflow-y: auto;200+ padding: 16px;201+ background: var(--bg-elevated);202+ border: 1px solid var(--border);203+ font-size: 0.75rem;204+ line-height: 1.6;205+}206+.notification-preview strong {207+ display: block;208+ margin-bottom: 12px;209+ color: var(--heading);210+}211+.notification-list > a {212+ display: block;213+ padding: 12px 0;214+ border-top: 1px solid var(--border);215+ overflow-wrap: anywhere;216+ color: var(--text);217+}218+.notification-list small {219+ display: block;220+ margin-top: 4px;221+ color: var(--text-secondary);222+}223+.notification-all {224+ display: block;225+ margin-top: 12px;226+}227+155228 main {156229 max-width: 980px;157230 margin: 0 auto;@@ -2210,6 +2283,11 @@22102283 box-shadow: inset 2px 0 0 var(--accent);22112284 background: var(--accent-tint-04);22122285 }2286+.notification-list .notification-unread {2287+ border-left: 2px solid var(--accent);2288+ padding-left: 12px;2289+}2290+22132291 .admin-sidebar a[aria-current] {22142292 color: var(--accent);22152293 border-left: 2px solid var(--accent);public/js/nav.js+73 −1
@@ -1,7 +1,7 @@11 // The navbar when signed in: "admin" and "log out" in place of "log in". Loaded right after the22 // nav, before the rest of the page, so the signed-out links never show first. Pages are the same33 // for everyone; the readable kepr_editor cookie, set beside the session, carries the form token4-// that log out posts back, so this needs no request.4+// that log out posts back. Only the private notification preview needs a request.55 {66 const token = /(?:^|;\s*)kepr_editor=([0-9a-f]{64})(?:;|$)/.exec(document.cookie)?.[1];77 const link = document.querySelector('.nav-links a[href^="/sign-in"]');@@ -21,6 +21,78 @@2121 button.type = "submit";2222 button.textContent = "log out";2323 form.append(field, button);24+ const bell = document.createElement("details");25+ bell.className = "nav-notifications";26+ const toggle = document.createElement("summary");27+ toggle.setAttribute("aria-label", "notifications");28+ toggle.innerHTML =29+ '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M18 8a6 6 0 0 0-12 0c0 7-3 7-3 9h18c0-2-3-2-3-9M10 21h4"/></svg>';30+ const dot = document.createElement("span");31+ dot.className = "notification-dot";32+ dot.hidden = true;33+ toggle.append(dot);34+ const preview = document.createElement("div");35+ preview.className = "notification-preview";36+ const heading = document.createElement("strong");37+ heading.textContent = "notifications";38+ const list = document.createElement("div");39+ list.className = "notification-list";40+ list.textContent = "loading…";41+ const all = document.createElement("a");42+ all.href = "/admin/notifications";43+ all.className = "notification-all";44+ all.textContent = "view all notifications ↗";45+ preview.append(heading, list, all);46+ bell.append(toggle, preview);47+ link.parentElement.after(bell);48+ let loading = false;49+ const refresh = async () => {50+ if (loading) return;51+ loading = true;52+ try {53+ const response = await fetch("/admin/notifications?format=json", { cache: "no-store" });54+ if (response.status === 401) {55+ bell.remove();56+ return;57+ }58+ if (!response.ok) throw new Error(`notifications: ${response.status}`);59+ const feed = await response.json();60+ dot.hidden = feed.unread === 0;61+ toggle.setAttribute("aria-label", `notifications, ${feed.unread} unread`);62+ list.replaceChildren();63+ for (const item of feed.notifications) {64+ const entry = document.createElement("a");65+ entry.href = `/admin/notifications/${item.id}`;66+ entry.className = item.read ? "" : "notification-unread";67+ const text = document.createElement("span");68+ text.textContent = item.text;69+ const when = document.createElement("small");70+ when.textContent = `${item.read ? "" : "unread · "}${item.when}`;71+ entry.append(text, when);72+ list.append(entry);73+ }74+ if (!feed.notifications.length) list.textContent = "nothing yet";75+ } catch (error) {76+ list.textContent = "Could not load notifications. Open the full list or try again.";77+ console.error(error);78+ } finally {79+ loading = false;80+ }81+ };82+ bell.addEventListener("toggle", () => {83+ if (bell.open) refresh();84+ });85+ document.addEventListener("click", (event) => {86+ if (!bell.contains(event.target)) bell.open = false;87+ });88+ document.addEventListener("keydown", (event) => {89+ if (event.key === "Escape" && bell.open) {90+ bell.open = false;91+ toggle.focus();92+ }93+ });94+ refresh();95+2496 link.replaceWith(admin, form);2597 }2698 }src/admin/notifications.ts+21 −0
@@ -17,6 +17,27 @@1717 /** §24's Notifications section: the list, opening one, mark all read, and which kinds to raise. */1818 export function registerNotifications(app: Hono, ctx: AdminContext): void {1919 app.get("/admin/notifications", (c) => {20+ if (c.req.query("format") === "json") {21+ const unread =22+ ctx.kepr23+ .query<{ n: number }, []>("SELECT COUNT(*) AS n FROM notifications WHERE read = 0")24+ .get()?.n ?? 0;25+ const recent = ctx.kepr26+ .query<Notification, []>(27+ "SELECT id, text, href, created_at, read FROM notifications ORDER BY id DESC LIMIT 5",28+ )29+ .all();30+ return c.json({31+ unread,32+ notifications: recent.map(({ id, text, created_at, read }) => ({33+ id,34+ text,35+ read,36+ when: relativeTime(created_at),37+ })),38+ });39+ }40+2041 const rows = ctx.kepr2142 .query<Notification, []>(2243 "SELECT id, text, href, created_at, read FROM notifications ORDER BY id DESC",tests/navbar.test.ts+15 −3
@@ -1,5 +1,5 @@11 // Covers the signed-in navbar (nav.js): "admin" and "log out" in place of "log in", built from2-// the readable mark signing in sets beside the session, with no request.2+// the readable mark signing in sets beside the session. Notifications load separately.33 44 import { describe, expect, test } from "bun:test";55 import { runInNewContext } from "node:vm";@@ -14,7 +14,9 @@1414 let replacement: unknown[] = [];1515 const document = {1616 cookie,17+ addEventListener: () => {},1718 querySelector: () => ({19+ parentElement: { after: () => {} },1820 replaceWith: (...replaced: unknown[]) => {1921 replacement = replaced;2022 },@@ -22,6 +24,11 @@2224 createElement: (tag: string) => {2325 const node: Record<string, unknown> = {2426 tag,27+ setAttribute: (name: string, value: string) => {28+ node[name] = value;29+ },30+ addEventListener: () => {},31+ replaceChildren: () => {},2532 append: (...children: unknown[]) => {2633 node.children = children;2734 },@@ -30,7 +37,10 @@3037 return node;3138 },3239 };33- runInNewContext(script, { document });40+ runInNewContext(script, {41+ document,42+ fetch: async () => ({ ok: true, json: async () => ({ unread: 0, notifications: [] }) }),43+ });3444 return { nodes, replacement };3545 }3646 @@ -49,7 +59,9 @@4959 const [field, button] = (form?.children ?? []) as Record<string, unknown>[];5060 expect(field).toMatchObject({ tag: "input", type: "hidden", name: "form", value: TOKEN });5161 expect(button).toMatchObject({ tag: "button", type: "submit", textContent: "log out" });52- expect(nodes).toHaveLength(4);62+ expect(nodes.find((node) => node.tag === "summary")).toMatchObject({63+ "aria-label": "notifications",64+ });5365 });5466 5567 test("the mark's token is the one log out needs", async () => {tests/notifications.test.ts+31 −0
@@ -8,6 +8,11 @@88 import { Browser, type Instance, instance } from "./browser.ts";99 import { makeRepos } from "./fixture.ts";1010 11+type Feed = {12+ unread: number;13+ notifications: { id: number; text: string; read: number; when: string }[];14+};15+1116 let clock = Date.parse("2026-09-29T12:00:00Z");1217 const tick = (seconds: number) => {1318 clock += seconds * 1000;@@ -61,6 +66,18 @@6166 expect(text).toContain("New issue app #1: Crash <on> start");6267 });6368 69+ test("the navbar feed is private, bounded and leaves unread state unchanged", async () => {70+ expect((await inst.app.request("/admin/notifications?format=json")).status).toBe(401);71+ const response = await operator.get("/admin/notifications?format=json");72+ expect(response.headers.get("cache-control")).toContain("no-store");73+ const feed = (await response.json()) as Feed;74+ expect(feed.unread).toBe(1);75+ expect(feed.notifications).toHaveLength(1);76+ expect(feed.notifications[0].text).toContain("Crash <on> start");77+ expect(feed.notifications[0].read).toBe(0);78+ expect(unread()).toBe(1);79+ });80+6481 test("opening one marks it read and goes to what it is about", async () => {6582 const id = inst.kepr.query<{ id: number }, []>("SELECT id FROM notifications").get()?.id;6683 const opened = await operator.get(`/admin/notifications/${id}`);@@ -135,5 +152,19 @@135152 .all();136153 expect(rows.length).toBe(200);137154 expect(rows[0]?.text).toBe("n5");155+ });156+ test("the navbar shows only the newest five while counting every unread item", async () => {157+ clear();158+ for (let i = 0; i < 8; i++) notify(inst.kepr, "sync_failed", `recent ${i}`, "/admin/issues");159+ const feed = (await (await operator.get("/admin/notifications?format=json")).json()) as Feed;160+ expect(feed.unread).toBe(8);161+ expect(feed.notifications.map((n: { text: string }) => n.text)).toEqual([162+ "recent 7",163+ "recent 6",164+ "recent 5",165+ "recent 4",166+ "recent 3",167+ ]);168+ expect(unread()).toBe(8);138169 });139170 });