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
6 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 −0
package.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": {

view file

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);

view file

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 }

view file

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",

view file

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 () => {

view file

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 &lt;on&gt; 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 });

view file