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.tsxBlame223 lines · 1 contributor
3ef4c9dClaude1/**
59b6fb2Claude2 * Notification routes — bell icon, list, mark read, clear.
3ef4c9dClaude3 */
4
5import { Hono } from "hono";
59b6fb2Claude6import { eq, and, desc, sql } from "drizzle-orm";
3ef4c9dClaude7import { db } from "../db";
59b6fb2Claude8import { notifications } from "../db/schema-extensions";
9import { users, repositories } from "../db/schema";
3ef4c9dClaude10import { Layout } from "../views/layout";
59b6fb2Claude11import { softAuth, requireAuth } from "../middleware/auth";
3ef4c9dClaude12import type { AuthEnv } from "../middleware/auth";
3e8f8e8Claude13import {
14 Container,
15 PageHeader,
16 Flex,
17 FilterTabs,
18 EmptyState,
19 List,
20 ListItem,
21 Form,
22 Button,
23 Text,
24 formatRelative,
25} from "../views/ui";
59b6fb2Claude26
27const notificationRoutes = new Hono<AuthEnv>();
28
29// Notification list page
30notificationRoutes.get("/notifications", softAuth, requireAuth, async (c) => {
31 const user = c.get("user")!;
32 const filter = c.req.query("filter") || "unread";
3e8f8e8Claude33 const csrfToken = (c as any).get("csrfToken") || "";
59b6fb2Claude34
35 const query = db
36 .select()
37 .from(notifications)
38 .where(
39 filter === "all"
40 ? eq(notifications.userId, user.id)
41 : and(eq(notifications.userId, user.id), eq(notifications.isRead, false))
42 )
43 .orderBy(desc(notifications.createdAt))
44 .limit(50);
45
46 let items: any[] = [];
3ef4c9dClaude47 try {
59b6fb2Claude48 items = await query;
3ef4c9dClaude49 } catch {
59b6fb2Claude50 // Table may not exist yet
3ef4c9dClaude51 }
52
59b6fb2Claude53 let unreadCount = 0;
3ef4c9dClaude54 try {
59b6fb2Claude55 const [result] = await db
56 .select({ count: sql<number>`count(*)` })
3ef4c9dClaude57 .from(notifications)
59b6fb2Claude58 .where(and(eq(notifications.userId, user.id), eq(notifications.isRead, false)));
59 unreadCount = result?.count ?? 0;
60 } catch {
61 // Table may not exist yet
3ef4c9dClaude62 }
63
3e8f8e8Claude64 const markAllReadAction = unreadCount > 0 ? (
65 <Form action="/notifications/read-all" csrfToken={csrfToken}>
66 <Button size="sm" type="submit">Mark all read</Button>
67 </Form>
68 ) : null;
3ef4c9dClaude69
70 return c.html(
71 <Layout title="Notifications" user={user}>
72 <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 16px">
73 <h2>Notifications</h2>
74 {unreadCount > 0 && (
e7e240eClaude75 <form method="post" action="/notifications/read-all">
3ef4c9dClaude76 <button type="submit" class="btn btn-sm">
77 Mark all as read
78 </button>
79 </form>
80 )}
81 </div>
82
83 <div class="issue-tabs" style="margin-bottom: 16px">
84 <a href="/notifications" class={filter === "all" ? "active" : ""}>
85 All
86 </a>
87 <a
88 href="/notifications?filter=unread"
89 class={filter === "unread" ? "active" : ""}
90 >
91 Unread
92 </a>
93 <a
94 href="/notifications?filter=mentions"
95 class={filter === "mentions" ? "active" : ""}
96 >
97 Mentions
98 </a>
99 </div>
100
101 {rows.length === 0 ? (
102 <div class="empty-state">
103 <h2>Inbox zero</h2>
104 <p>You're all caught up.</p>
105 </div>
59b6fb2Claude106
107 {items.length === 0 ? (
3e8f8e8Claude108 <EmptyState title="All caught up">
59b6fb2Claude109 <p>No {filter === "unread" ? "unread " : ""}notifications.</p>
3e8f8e8Claude110 </EmptyState>
59b6fb2Claude111 ) : (
3e8f8e8Claude112 <List>
59b6fb2Claude113 {items.map((n: any) => (
3e8f8e8Claude114 <ListItem style={n.isRead ? "opacity:0.6" : ""}>
59b6fb2Claude115 <div style="font-size:18px;padding-top:2px">
116 {n.type === "issue_comment" ? "\u{1F4AC}" :
117 n.type === "pr_review" ? "\u{1F50D}" :
118 n.type === "mention" ? "\u{1F4E3}" :
119 n.type === "star" ? "\u2B50" :
120 n.type === "ci_status" ? "\u2699\uFE0F" : "\u{1F514}"}
121 </div>
3e8f8e8Claude122 <Flex direction="column" style="flex:1">
123 <Text size={14} weight={500}>
59b6fb2Claude124 {n.url ? <a href={n.url} style="color:var(--text)">{n.title}</a> : n.title}
3e8f8e8Claude125 </Text>
3ef4c9dClaude126 {n.body && (
3e8f8e8Claude127 <Text size={13} muted style="margin-top:2px">
59b6fb2Claude128 {n.body.length > 120 ? n.body.slice(0, 120) + "..." : n.body}
3e8f8e8Claude129 </Text>
3ef4c9dClaude130 )}
131 <div class="notification-meta">
132 {n.repoOwner && n.repoName && (
133 <>
134 <a href={`/${n.repoOwner}/${n.repoName}`}>
135 {n.repoOwner}/{n.repoName}
136 </a>
137 <span> · </span>
138 </>
139 )}
140 <span>{formatRelative(n.createdAt)}</span>
141 </div>
142 </div>
143 <div class="notification-actions">
144 {unread && (
e7e240eClaude145 <form method="post" action={`/notifications/${n.id}/read`}>
3ef4c9dClaude146 <button
147 type="submit"
148 class="btn btn-sm"
149 title="Mark as read"
150 >
151 {"\u2713"}
152 </button>
153 </form>
154 )}
e7e240eClaude155 <form method="post" action={`/notifications/${n.id}/delete`}>
3ef4c9dClaude156 <button
157 type="submit"
158 class="btn btn-sm"
159 title="Dismiss"
160 >
161 {"\u00D7"}
162 </button>
163 </form>
164 </div>
165 </div>
166 );
167 })}
168 </div>
169 )}
170 </Layout>
171 );
172});
173
59b6fb2Claude174// Mark single notification as read
175notificationRoutes.post("/notifications/:id/read", softAuth, requireAuth, async (c) => {
3ef4c9dClaude176 const user = c.get("user")!;
177 const id = c.req.param("id");
59b6fb2Claude178
3ef4c9dClaude179 try {
180 await db
181 .update(notifications)
59b6fb2Claude182 .set({ isRead: true })
3ef4c9dClaude183 .where(and(eq(notifications.id, id), eq(notifications.userId, user.id)));
59b6fb2Claude184 } catch {
185 // Table may not exist
3ef4c9dClaude186 }
59b6fb2Claude187
3ef4c9dClaude188 return c.redirect("/notifications");
189});
190
59b6fb2Claude191// Mark all as read
192notificationRoutes.post("/notifications/read-all", softAuth, requireAuth, async (c) => {
3ef4c9dClaude193 const user = c.get("user")!;
59b6fb2Claude194
3ef4c9dClaude195 try {
196 await db
197 .update(notifications)
59b6fb2Claude198 .set({ isRead: true })
199 .where(and(eq(notifications.userId, user.id), eq(notifications.isRead, false)));
200 } catch {
201 // Table may not exist
3ef4c9dClaude202 }
203
59b6fb2Claude204 return c.redirect("/notifications");
3ef4c9dClaude205});
206
59b6fb2Claude207// API: Get unread count (for bell icon polling)
208notificationRoutes.get("/api/notifications/count", softAuth, async (c) => {
209 const user = c.get("user");
210 if (!user) return c.json({ count: 0 });
3ef4c9dClaude211
212 try {
59b6fb2Claude213 const [result] = await db
214 .select({ count: sql<number>`count(*)` })
3ef4c9dClaude215 .from(notifications)
59b6fb2Claude216 .where(and(eq(notifications.userId, user.id), eq(notifications.isRead, false)));
217 return c.json({ count: result?.count ?? 0 });
3ef4c9dClaude218 } catch {
59b6fb2Claude219 return c.json({ count: 0 });
3ef4c9dClaude220 }
221});
222
59b6fb2Claude223export default notificationRoutes;