Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history

notifications.tsx

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

notifications.tsxBlame807 lines · 1 contributor
3ef4c9dClaude1/**
59b6fb2Claude2 * Notification routes — bell icon, list, mark read, clear.
fd8be1fClaude3 *
4 * Visual polish: 2026 hero + filter pills + AI-row treatment.
5 * All visual styling scoped to `.notif-*` classes via an inline <style>
6 * block so we never bleed into shared view files. No logic touched —
7 * the route still reads/writes the same query params and endpoints.
3ef4c9dClaude8 */
9
10import { Hono } from "hono";
59b6fb2Claude11import { eq, and, desc, sql } from "drizzle-orm";
3ef4c9dClaude12import { db } from "../db";
59b6fb2Claude13import { notifications } from "../db/schema-extensions";
14import { users, repositories } from "../db/schema";
3ef4c9dClaude15import { Layout } from "../views/layout";
59b6fb2Claude16import { softAuth, requireAuth } from "../middleware/auth";
3ef4c9dClaude17import type { AuthEnv } from "../middleware/auth";
fd8be1fClaude18import { formatRelative } from "../views/ui";
59b6fb2Claude19
20const notificationRoutes = new Hono<AuthEnv>();
21
fd8be1fClaude22// ---------------------------------------------------------------------------
23// Inline, scoped CSS — every class prefixed with `.notif-` so it cannot
24// bleed onto other surfaces. Pattern mirrors the dashboard-hero polish
25// (commit a004c46), issues row treatment (f7ad7b8), and settings section
26// cards (98eb360). All tokens come from :root in layout.tsx.
27// ---------------------------------------------------------------------------
28const notifStyles = `
29 /* ─── Hero card ─── */
30 .notif-hero {
31 position: relative;
32 margin: 4px 0 24px;
33 padding: 28px 32px;
34 background: var(--bg-elevated);
35 border: 1px solid var(--border);
36 border-radius: 16px;
37 overflow: hidden;
38 }
39 .notif-hero::before {
40 content: '';
41 position: absolute;
42 top: 0; left: 0; right: 0;
43 height: 2px;
44 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
45 opacity: 0.7;
46 pointer-events: none;
47 }
48 .notif-hero-bg {
49 position: absolute;
50 inset: -30% -10% auto auto;
51 width: 360px;
52 height: 360px;
53 pointer-events: none;
54 z-index: 0;
55 }
56 .notif-hero-orb {
57 position: absolute;
58 inset: 0;
59 background: radial-gradient(circle, rgba(140,109,255,0.18), rgba(54,197,214,0.09) 45%, transparent 70%);
60 filter: blur(80px);
61 opacity: 0.7;
62 animation: notifHeroOrb 14s ease-in-out infinite;
63 }
64 @keyframes notifHeroOrb {
65 0%, 100% { transform: scale(1) translate(0, 0); opacity: 0.6; }
66 50% { transform: scale(1.1) translate(-10px, 8px); opacity: 0.85; }
67 }
68 @media (prefers-reduced-motion: reduce) {
69 .notif-hero-orb { animation: none; }
70 }
71 .notif-hero-inner {
72 position: relative;
73 z-index: 1;
74 display: flex;
75 justify-content: space-between;
76 align-items: flex-end;
77 gap: 24px;
78 flex-wrap: wrap;
79 }
80 .notif-hero-text { flex: 1; min-width: 280px; }
81 .notif-hero-eyebrow {
82 font-size: 12.5px;
83 color: var(--text-muted);
84 margin-bottom: 8px;
85 letter-spacing: 0.04em;
86 text-transform: uppercase;
87 font-weight: 600;
88 }
89 .notif-hero-eyebrow .notif-hero-username {
90 color: var(--accent);
91 text-transform: none;
92 letter-spacing: -0.005em;
93 font-weight: 600;
94 }
95 .notif-hero-title {
96 font-family: var(--font-display);
97 font-size: clamp(28px, 4vw, 40px);
98 font-weight: 800;
99 letter-spacing: -0.028em;
100 line-height: 1.05;
101 margin: 0 0 10px;
102 color: var(--text-strong);
103 }
104 .notif-hero-title .gradient-text {
105 background-image: linear-gradient(135deg, #a48bff 0%, #8c6dff 50%, #36c5d6 100%);
106 -webkit-background-clip: text;
107 background-clip: text;
108 -webkit-text-fill-color: transparent;
109 color: transparent;
110 }
111 .notif-hero-sub {
112 font-size: 15px;
113 color: var(--text-muted);
114 margin: 0;
115 line-height: 1.5;
116 max-width: 580px;
117 }
118 .notif-hero-sub strong {
119 color: var(--text-strong);
120 font-weight: 600;
121 }
122 .notif-hero-sub .notif-dot {
123 margin: 0 8px;
124 color: var(--text-faint, var(--text-muted));
125 }
126 .notif-hero-actions {
127 display: flex;
128 gap: 8px;
129 flex-wrap: wrap;
130 }
131 @media (max-width: 720px) {
132 .notif-hero { padding: 24px 20px; }
133 .notif-hero-inner { flex-direction: column; align-items: flex-start; }
134 .notif-hero-actions { width: 100%; }
135 }
136
137 /* ─── Filter row + Mark all read button ─── */
138 .notif-toolbar {
139 display: flex;
140 align-items: center;
141 justify-content: space-between;
142 gap: 12px;
143 flex-wrap: wrap;
144 margin: 0 0 16px;
145 }
146 .notif-filters {
147 display: inline-flex;
148 background: var(--bg-elevated);
149 border: 1px solid var(--border);
150 border-radius: 9999px;
151 padding: 4px;
152 gap: 2px;
153 max-width: 100%;
154 overflow-x: auto;
155 }
156 .notif-filter {
157 display: inline-flex;
158 align-items: center;
159 gap: 6px;
160 padding: 6px 14px;
161 border-radius: 9999px;
162 font-size: 13px;
163 font-weight: 500;
164 color: var(--text-muted);
165 text-decoration: none;
166 transition: color 120ms ease, background 120ms ease;
167 line-height: 1.4;
168 white-space: nowrap;
169 }
170 .notif-filter:hover { color: var(--text-strong); text-decoration: none; }
171 .notif-filter.is-active {
172 background: rgba(140,109,255,0.14);
173 color: var(--text-strong);
174 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.30);
175 }
176 .notif-filter-count {
177 font-variant-numeric: tabular-nums;
178 font-size: 11.5px;
179 color: var(--text-muted);
180 background: rgba(255,255,255,0.04);
181 padding: 1px 7px;
182 border-radius: 9999px;
183 }
184 .notif-filter.is-active .notif-filter-count {
185 background: rgba(140,109,255,0.22);
186 color: var(--text);
187 }
188 .notif-mark-all {
189 display: inline-flex;
190 align-items: center;
191 gap: 6px;
192 padding: 7px 14px;
193 background: var(--bg-elevated);
194 color: var(--text);
195 border: 1px solid var(--border);
196 border-radius: 9999px;
197 font-size: 12.5px;
198 font-weight: 500;
199 cursor: pointer;
200 transition: border-color 120ms ease, color 120ms ease, background 120ms ease;
201 font-family: inherit;
202 }
203 .notif-mark-all:hover {
204 border-color: rgba(140,109,255,0.45);
205 color: var(--text-strong);
206 background: rgba(140,109,255,0.06);
207 }
208 .notif-mark-all-form { margin: 0; padding: 0; }
209
210 /* ─── Notification list ─── */
211 .notif-list {
212 list-style: none;
213 margin: 0;
214 padding: 0;
215 border: 1px solid var(--border);
216 border-radius: 14px;
217 overflow: hidden;
218 background: var(--bg-elevated);
219 }
220 .notif-row {
221 position: relative;
222 display: flex;
223 align-items: flex-start;
224 gap: 14px;
225 padding: 14px 18px 14px 22px;
226 border-bottom: 1px solid var(--border);
227 transition: background 140ms ease, transform 140ms ease, box-shadow 140ms ease;
228 }
229 .notif-row:last-child { border-bottom: none; }
230 .notif-row:hover {
231 background: rgba(140,109,255,0.04);
232 transform: translateY(-1px);
233 box-shadow: 0 8px 24px -16px rgba(0,0,0,0.45);
234 }
235 .notif-row.is-read { opacity: 0.72; }
236 .notif-row.is-unread::before {
237 content: '';
238 position: absolute;
239 left: 0; top: 12px; bottom: 12px;
240 width: 3px;
241 border-radius: 0 3px 3px 0;
242 background: linear-gradient(180deg, #a48bff 0%, #8c6dff 60%, #36c5d6 100%);
243 box-shadow: 0 0 10px rgba(140,109,255,0.45);
244 }
245 .notif-row-icon {
246 flex-shrink: 0;
247 width: 34px;
248 height: 34px;
249 border-radius: 10px;
250 background: var(--bg-secondary);
251 border: 1px solid var(--border);
252 display: inline-flex;
253 align-items: center;
254 justify-content: center;
255 font-size: 16px;
256 color: var(--text);
257 margin-top: 2px;
258 }
259 .notif-row-icon.is-comment { color: #60a5fa; background: rgba(96,165,250,0.10); border-color: rgba(96,165,250,0.25); }
260 .notif-row-icon.is-mention { color: #fbbf24; background: rgba(251,191,36,0.10); border-color: rgba(251,191,36,0.25); }
261 .notif-row-icon.is-star { color: #fbbf24; background: rgba(251,191,36,0.10); border-color: rgba(251,191,36,0.25); }
262 .notif-row-icon.is-ci { color: #34d399; background: rgba(52,211,153,0.10); border-color: rgba(52,211,153,0.25); }
263 .notif-row-icon.is-ai {
264 color: #fff;
265 background: linear-gradient(135deg, #8c6dff 0%, #36c5d6 100%);
266 border-color: rgba(140,109,255,0.55);
267 box-shadow: 0 0 14px rgba(140,109,255,0.40);
268 }
269 .notif-row-main { flex: 1; min-width: 0; }
270 .notif-row-title {
271 font-family: var(--font-display);
272 font-size: 14.5px;
273 line-height: 1.4;
274 letter-spacing: -0.01em;
275 margin: 0;
276 color: var(--text);
277 display: flex;
278 flex-wrap: wrap;
279 align-items: center;
280 gap: 8px;
281 }
282 .notif-row-title a {
283 color: inherit;
284 text-decoration: none;
285 transition: color 120ms ease;
286 }
287 .notif-row-title a:hover { color: var(--accent); }
288 .notif-row-title strong { color: var(--text-strong); font-weight: 700; }
289 .notif-row-body {
290 margin-top: 4px;
291 font-size: 13px;
292 color: var(--text-muted);
293 line-height: 1.5;
294 }
295 .notif-row-meta {
296 margin-top: 6px;
297 font-size: 12px;
298 color: var(--text-muted);
299 display: flex;
300 gap: 8px;
301 flex-wrap: wrap;
302 align-items: center;
303 }
304 .notif-row-meta a {
305 color: var(--text-link);
306 text-decoration: none;
307 font-variant-numeric: tabular-nums;
308 }
309 .notif-row-meta a:hover { color: var(--accent-hover, var(--accent)); }
310 .notif-row-meta .notif-dot { color: var(--text-faint, var(--text-muted)); }
311 .notif-row-actions {
312 flex-shrink: 0;
313 display: inline-flex;
314 align-items: center;
315 gap: 6px;
316 margin-top: 1px;
317 }
318 .notif-row-action {
319 display: inline-flex;
320 align-items: center;
321 justify-content: center;
322 width: 30px;
323 height: 30px;
324 padding: 0;
325 border-radius: 8px;
326 background: transparent;
327 color: var(--text-muted);
328 border: 1px solid transparent;
329 cursor: pointer;
330 transition: color 120ms ease, background 120ms ease, border-color 120ms ease;
331 font-family: inherit;
332 font-size: 14px;
333 line-height: 1;
334 }
335 .notif-row-action:hover {
336 color: var(--text-strong);
337 background: rgba(140,109,255,0.10);
338 border-color: rgba(140,109,255,0.30);
339 }
340 .notif-row-action.is-open {
341 width: auto;
342 padding: 0 10px;
343 font-size: 12px;
344 font-weight: 500;
345 color: var(--text);
346 }
347 .notif-row-action-form { margin: 0; padding: 0; display: inline; }
348
349 /* AI-specific row treatment — purple-tinted */
350 .notif-row.notif-row-ai {
351 background:
352 linear-gradient(90deg, rgba(140,109,255,0.06) 0%, rgba(140,109,255,0.015) 60%, transparent 100%),
353 var(--bg-elevated);
354 }
355 .notif-row.notif-row-ai:hover {
356 background:
357 linear-gradient(90deg, rgba(140,109,255,0.10) 0%, rgba(140,109,255,0.03) 60%, transparent 100%),
358 var(--bg-elevated);
359 }
360 .notif-row-ai .notif-row-title strong { color: var(--text-strong); }
361 .notif-ai-badge {
362 display: inline-flex;
363 align-items: center;
364 gap: 4px;
365 padding: 1px 8px;
366 border-radius: 9999px;
367 background: linear-gradient(135deg, #8c6dff 0%, #36c5d6 100%);
368 color: #fff;
369 font-size: 10.5px;
370 font-weight: 700;
371 letter-spacing: 0.04em;
372 text-transform: uppercase;
373 line-height: 1.4;
374 box-shadow: 0 0 10px rgba(140,109,255,0.30);
375 }
376
377 /* ─── Empty state ─── */
378 .notif-empty {
379 position: relative;
380 padding: 56px 32px;
381 background: var(--bg-elevated);
382 border: 1px solid var(--border);
383 border-radius: 16px;
384 text-align: center;
385 overflow: hidden;
386 }
387 .notif-empty::before {
388 content: '';
389 position: absolute;
390 top: 0; left: 0; right: 0;
391 height: 2px;
392 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
393 opacity: 0.55;
394 pointer-events: none;
395 }
396 .notif-empty-art {
397 width: 96px;
398 height: 96px;
399 margin: 0 auto 18px;
400 display: block;
401 opacity: 0.9;
402 }
403 .notif-empty-title {
404 font-family: var(--font-display);
405 font-size: 22px;
406 font-weight: 700;
407 letter-spacing: -0.018em;
408 color: var(--text-strong);
409 margin: 0 0 8px;
410 }
411 .notif-empty-sub {
412 font-size: 14.5px;
413 color: var(--text-muted);
414 line-height: 1.55;
415 margin: 0 auto;
416 max-width: 460px;
417 }
418
419 @media (max-width: 720px) {
420 .notif-row { padding: 12px 14px 12px 18px; gap: 10px; }
421 .notif-row-icon { width: 30px; height: 30px; font-size: 14px; }
422 .notif-toolbar { flex-direction: column; align-items: stretch; }
423 .notif-filters { width: 100%; justify-content: center; }
424 .notif-mark-all { width: 100%; justify-content: center; }
425 }
426`;
427
428// ---------------------------------------------------------------------------
429// Heuristics — purely cosmetic. Detects AI-origin notifications so we can
430// give them a distinct treatment. The classification only changes CSS; the
431// underlying record + actions are unchanged.
432// ---------------------------------------------------------------------------
433function isAiNotification(n: any): boolean {
434 if (n?.type === "pr_review") return true;
435 const hay = `${n?.title ?? ""} ${n?.body ?? ""}`.toLowerCase();
436 return (
437 hay.includes("ai review") ||
438 hay.includes("ai triage") ||
439 hay.includes("claude ") ||
440 hay.includes("auto-merge") ||
441 hay.includes("incident report")
442 );
443}
444
445function iconForKind(type: string, ai: boolean): { glyph: string; cls: string } {
446 if (ai) return { glyph: "✨", cls: "is-ai" }; // ✨
447 switch (type) {
448 case "issue_comment": return { glyph: "\u{1F4AC}", cls: "is-comment" }; // 💬
449 case "pr_review": return { glyph: "✨", cls: "is-ai" }; // ✨ (covered above)
450 case "mention": return { glyph: "@", cls: "is-mention" };
451 case "star": return { glyph: "★", cls: "is-star" }; // ★
452 case "ci_status": return { glyph: "⚙", cls: "is-ci" }; // ⚙
453 default: return { glyph: "\u{1F514}", cls: "" }; // 🔔
454 }
455}
456
457// Empty-state illustration — abstract, gluecron-tinted bell with a gradient
458// "all-clear" check inside. Inline so we don't depend on external assets.
459function EmptyArt() {
460 return (
461 <svg
462 class="notif-empty-art"
463 viewBox="0 0 96 96"
464 fill="none"
465 xmlns="http://www.w3.org/2000/svg"
466 aria-hidden="true"
467 >
468 <defs>
469 <linearGradient id="notifEmptyGrad" x1="0" y1="0" x2="1" y2="1">
470 <stop offset="0%" stop-color="#a48bff" />
471 <stop offset="55%" stop-color="#8c6dff" />
472 <stop offset="100%" stop-color="#36c5d6" />
473 </linearGradient>
474 <radialGradient id="notifEmptyGlow" cx="0.5" cy="0.55" r="0.6">
475 <stop offset="0%" stop-color="rgba(140,109,255,0.40)" />
476 <stop offset="100%" stop-color="rgba(140,109,255,0)" />
477 </radialGradient>
478 </defs>
479 <circle cx="48" cy="50" r="36" fill="url(#notifEmptyGlow)" />
480 <path
481 d="M48 18c-9 0-16 7-16 16v10c0 5-2 8-5 11-1.5 1.5-1 4 1 4h40c2 0 2.5-2.5 1-4-3-3-5-6-5-11V34c0-9-7-16-16-16Z"
482 stroke="url(#notifEmptyGrad)"
483 stroke-width="2"
484 stroke-linejoin="round"
485 fill="rgba(140,109,255,0.06)"
486 />
487 <path
488 d="M42 64c1.5 3 3.5 4.5 6 4.5s4.5-1.5 6-4.5"
489 stroke="url(#notifEmptyGrad)"
490 stroke-width="2"
491 stroke-linecap="round"
492 fill="none"
493 />
494 <path
495 d="M40 42.5 L46 48.5 L57 37"
496 stroke="url(#notifEmptyGrad)"
497 stroke-width="2.5"
498 stroke-linecap="round"
499 stroke-linejoin="round"
500 fill="none"
501 />
502 </svg>
503 );
504}
505
59b6fb2Claude506// Notification list page
507notificationRoutes.get("/notifications", softAuth, requireAuth, async (c) => {
508 const user = c.get("user")!;
509 const filter = c.req.query("filter") || "unread";
3e8f8e8Claude510 const csrfToken = (c as any).get("csrfToken") || "";
59b6fb2Claude511
512 const query = db
513 .select()
514 .from(notifications)
515 .where(
516 filter === "all"
517 ? eq(notifications.userId, user.id)
518 : and(eq(notifications.userId, user.id), eq(notifications.isRead, false))
519 )
520 .orderBy(desc(notifications.createdAt))
521 .limit(50);
522
523 let items: any[] = [];
3ef4c9dClaude524 try {
59b6fb2Claude525 items = await query;
3ef4c9dClaude526 } catch {
59b6fb2Claude527 // Table may not exist yet
3ef4c9dClaude528 }
529
59b6fb2Claude530 let unreadCount = 0;
fd8be1fClaude531 let totalCount = 0;
532 let weekReadCount = 0;
533 let mentionsCount = 0;
3ef4c9dClaude534 try {
fd8be1fClaude535 const [r1] = await db
59b6fb2Claude536 .select({ count: sql<number>`count(*)` })
3ef4c9dClaude537 .from(notifications)
59b6fb2Claude538 .where(and(eq(notifications.userId, user.id), eq(notifications.isRead, false)));
fd8be1fClaude539 unreadCount = r1?.count ?? 0;
540 } catch { /* table may not exist */ }
541 try {
542 const [r2] = await db
543 .select({ count: sql<number>`count(*)` })
544 .from(notifications)
545 .where(eq(notifications.userId, user.id));
546 totalCount = r2?.count ?? 0;
547 } catch { /* table may not exist */ }
548 try {
549 const [r3] = await db
550 .select({ count: sql<number>`count(*)` })
551 .from(notifications)
552 .where(
553 and(
554 eq(notifications.userId, user.id),
555 eq(notifications.isRead, true),
556 sql`${notifications.createdAt} > now() - interval '7 days'`
557 )
558 );
559 weekReadCount = r3?.count ?? 0;
560 } catch { /* table may not exist */ }
561 try {
562 const [r4] = await db
563 .select({ count: sql<number>`count(*)` })
564 .from(notifications)
565 .where(and(eq(notifications.userId, user.id), eq(notifications.type, "mention")));
566 mentionsCount = r4?.count ?? 0;
567 } catch { /* table may not exist */ }
3ef4c9dClaude568
fd8be1fClaude569 // Context-aware sub-line for the hero.
570 const subLine = (() => {
571 if (unreadCount === 0 && totalCount === 0) {
572 return <>You are all caught up. Notifications about AI reviews, mentions, and CI activity will land here.</>;
573 }
574 if (unreadCount === 0) {
575 return <>You are all caught up. <strong>{weekReadCount}</strong> read this week.</>;
576 }
577 const unreadLabel = unreadCount === 1 ? "unread" : "unread";
578 return (
579 <>
580 <strong>{unreadCount}</strong> {unreadLabel}
581 <span class="notif-dot">·</span>
582 <strong>{weekReadCount}</strong> read this week
583 </>
584 );
585 })();
586
587 // Filter pill definitions — labels are cosmetic, query values are preserved.
588 const filterPills: Array<{
589 key: string;
590 href: string;
591 label: string;
592 count: number | null;
593 }> = [
594 { key: "unread", href: "/notifications?filter=unread", label: "Inbox", count: unreadCount },
595 { key: "mentions", href: "/notifications?filter=mentions", label: "Mentions", count: mentionsCount },
596 { key: "all", href: "/notifications?filter=all", label: "All", count: totalCount },
597 ];
3ef4c9dClaude598
599 return c.html(
600 <Layout title="Notifications" user={user}>
fd8be1fClaude601 <style dangerouslySetInnerHTML={{ __html: notifStyles }} />
3ef4c9dClaude602
fd8be1fClaude603 {/* ─── Hero ─── */}
604 <section class="notif-hero">
605 <div class="notif-hero-bg" aria-hidden="true">
606 <div class="notif-hero-orb" />
607 </div>
608 <div class="notif-hero-inner">
609 <div class="notif-hero-text">
610 <div class="notif-hero-eyebrow">
611 Inbox{" "}
612 <span class="notif-hero-username">· @{user.username}</span>
613 </div>
614 <h1 class="notif-hero-title">
615 <span class="gradient-text">Notifications</span>
616 </h1>
617 <p class="notif-hero-sub">{subLine}</p>
618 </div>
619 <div class="notif-hero-actions">
620 {unreadCount > 0 && (
621 <form method="post" action="/notifications/read-all" class="notif-mark-all-form">
622 {csrfToken && <input type="hidden" name="_csrf" value={csrfToken} />}
623 <button type="submit" class="notif-mark-all" title="Mark all as read">
624 <span aria-hidden="true">{"✓"}</span>
625 Mark all read
626 </button>
627 </form>
628 )}
629 </div>
630 </div>
631 </section>
632
633 {/* ─── Filter pill row ─── */}
634 <div class="notif-toolbar">
635 <nav class="notif-filters" aria-label="Notification filters">
636 {filterPills.map((p) => (
637 <a
638 href={p.href}
639 class={"notif-filter" + (filter === p.key ? " is-active" : "")}
640 >
641 {p.label}
642 {p.count !== null && p.count > 0 && (
643 <span class="notif-filter-count">{p.count}</span>
644 )}
645 </a>
646 ))}
647 </nav>
3ef4c9dClaude648 </div>
649
fd8be1fClaude650 {/* ─── List / empty state ─── */}
0316dbbClaude651 {items.length === 0 ? (
fd8be1fClaude652 <div class="notif-empty">
653 <EmptyArt />
654 <h2 class="notif-empty-title">All caught up</h2>
655 <p class="notif-empty-sub">
656 {filter === "unread"
657 ? "No unread notifications. New AI reviews, mentions, and activity on your repos will appear here."
658 : filter === "mentions"
659 ? "No mentions yet. When someone @-mentions you in an issue or PR, you'll see it here."
660 : "No notifications. Once you're collaborating, this is where everything lands."}
661 </p>
662 </div>
0316dbbClaude663 ) : (
fd8be1fClaude664 <ul class="notif-list">
665 {items.map((n: any) => {
666 const ai = isAiNotification(n);
667 const icon = iconForKind(n.type, ai);
668 const rowCls =
669 "notif-row " +
670 (n.isRead ? "is-read" : "is-unread") +
671 (ai ? " notif-row-ai" : "");
672 return (
673 <li class={rowCls}>
674 <span class={"notif-row-icon " + icon.cls} aria-hidden="true">
675 {icon.glyph}
676 </span>
677 <div class="notif-row-main">
678 <div class="notif-row-title">
679 {n.url ? (
680 <a href={n.url}>{n.title}</a>
681 ) : (
682 <span>{n.title}</span>
683 )}
684 {ai && <span class="notif-ai-badge">AI</span>}
685 </div>
686 {n.body && (
687 <div class="notif-row-body">
688 {n.body.length > 140 ? n.body.slice(0, 140) + "…" : n.body}
689 </div>
3ef4c9dClaude690 )}
fd8be1fClaude691 <div class="notif-row-meta">
692 {n.repoOwner && n.repoName && (
693 <>
694 <a href={`/${n.repoOwner}/${n.repoName}`}>
695 {n.repoOwner}/{n.repoName}
696 </a>
697 <span class="notif-dot">·</span>
698 </>
699 )}
700 <span>{formatRelative(n.createdAt)}</span>
701 </div>
3ef4c9dClaude702 </div>
fd8be1fClaude703 <div class="notif-row-actions">
704 {n.url && (
705 <a
706 href={n.url}
707 class="notif-row-action is-open"
708 title="Open"
709 >
710 Open
711 </a>
712 )}
713 {!n.isRead && (
714 <form
715 method="post"
716 action={`/notifications/${n.id}/read`}
717 class="notif-row-action-form"
718 >
719 {csrfToken && <input type="hidden" name="_csrf" value={csrfToken} />}
720 <button
721 type="submit"
722 class="notif-row-action"
723 title="Mark as read"
724 aria-label="Mark as read"
725 >
726 {"✓"}
727 </button>
728 </form>
729 )}
730 <form
731 method="post"
732 action={`/notifications/${n.id}/delete`}
733 class="notif-row-action-form"
734 >
735 {csrfToken && <input type="hidden" name="_csrf" value={csrfToken} />}
3ef4c9dClaude736 <button
737 type="submit"
fd8be1fClaude738 class="notif-row-action"
739 title="Dismiss"
740 aria-label="Dismiss"
3ef4c9dClaude741 >
fd8be1fClaude742 {"×"}
3ef4c9dClaude743 </button>
744 </form>
fd8be1fClaude745 </div>
746 </li>
747 );
748 })}
749 </ul>
3ef4c9dClaude750 )}
751 </Layout>
752 );
753});
754
59b6fb2Claude755// Mark single notification as read
756notificationRoutes.post("/notifications/:id/read", softAuth, requireAuth, async (c) => {
3ef4c9dClaude757 const user = c.get("user")!;
758 const id = c.req.param("id");
59b6fb2Claude759
3ef4c9dClaude760 try {
761 await db
762 .update(notifications)
59b6fb2Claude763 .set({ isRead: true })
3ef4c9dClaude764 .where(and(eq(notifications.id, id), eq(notifications.userId, user.id)));
59b6fb2Claude765 } catch {
766 // Table may not exist
3ef4c9dClaude767 }
59b6fb2Claude768
3ef4c9dClaude769 return c.redirect("/notifications");
770});
771
59b6fb2Claude772// Mark all as read
773notificationRoutes.post("/notifications/read-all", softAuth, requireAuth, async (c) => {
3ef4c9dClaude774 const user = c.get("user")!;
59b6fb2Claude775
3ef4c9dClaude776 try {
777 await db
778 .update(notifications)
59b6fb2Claude779 .set({ isRead: true })
780 .where(and(eq(notifications.userId, user.id), eq(notifications.isRead, false)));
781 } catch {
782 // Table may not exist
3ef4c9dClaude783 }
784
59b6fb2Claude785 return c.redirect("/notifications");
3ef4c9dClaude786});
787
59b6fb2Claude788// API: Get unread count (for bell icon polling)
b7ecb14Claude789// Returns both `unread` (canonical) and `count` (legacy alias) so the nav
790// bell polling script and any older callers both work without changes.
59b6fb2Claude791notificationRoutes.get("/api/notifications/count", softAuth, async (c) => {
792 const user = c.get("user");
b7ecb14Claude793 if (!user) return c.json({ unread: 0, count: 0 });
3ef4c9dClaude794
795 try {
59b6fb2Claude796 const [result] = await db
797 .select({ count: sql<number>`count(*)` })
3ef4c9dClaude798 .from(notifications)
59b6fb2Claude799 .where(and(eq(notifications.userId, user.id), eq(notifications.isRead, false)));
b7ecb14Claude800 const unread = Number(result?.count ?? 0);
801 return c.json({ unread, count: unread });
3ef4c9dClaude802 } catch {
b7ecb14Claude803 return c.json({ unread: 0, count: 0 });
3ef4c9dClaude804 }
805});
806
59b6fb2Claude807export default notificationRoutes;