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.tsxBlame216 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
0316dbbClaude101 {items.length === 0 ? (
102 <EmptyState title="All caught up">
103 <p>No {filter === "unread" ? "unread " : ""}notifications.</p>
104 </EmptyState>
105 ) : (
106 <List>
107 {items.map((n: any) => (
108 <ListItem style={n.isRead ? "opacity:0.6" : ""}>
109 <div style="font-size:18px;padding-top:2px">
110 {n.type === "issue_comment" ? "\u{1F4AC}" :
111 n.type === "pr_review" ? "\u{1F50D}" :
112 n.type === "mention" ? "\u{1F4E3}" :
113 n.type === "star" ? "\u2B50" :
114 n.type === "ci_status" ? "\u2699\uFE0F" : "\u{1F514}"}
115 </div>
116 <Flex direction="column" style="flex:1">
117 <Text size={14} weight={500}>
118 {n.url ? <a href={n.url} style="color:var(--text)">{n.title}</a> : n.title}
119 </Text>
120 {n.body && (
121 <Text size={13} muted style="margin-top:2px">
122 {n.body.length > 120 ? n.body.slice(0, 120) + "..." : n.body}
3e8f8e8Claude123 </Text>
0316dbbClaude124 )}
125 <div class="notification-meta">
126 {n.repoOwner && n.repoName && (
127 <>
128 <a href={`/${n.repoOwner}/${n.repoName}`}>
129 {n.repoOwner}/{n.repoName}
130 </a>
131 <span> · </span>
132 </>
3ef4c9dClaude133 )}
0316dbbClaude134 <span>{formatRelative(n.createdAt)}</span>
3ef4c9dClaude135 </div>
0316dbbClaude136 </Flex>
137 <div class="notification-actions">
138 {!n.isRead && (
139 <form method="post" action={`/notifications/${n.id}/read`}>
3ef4c9dClaude140 <button
141 type="submit"
142 class="btn btn-sm"
0316dbbClaude143 title="Mark as read"
3ef4c9dClaude144 >
0316dbbClaude145 {"\u2713"}
3ef4c9dClaude146 </button>
147 </form>
0316dbbClaude148 )}
149 <form method="post" action={`/notifications/${n.id}/delete`}>
150 <button
151 type="submit"
152 class="btn btn-sm"
153 title="Dismiss"
154 >
155 {"\u00D7"}
156 </button>
157 </form>
3ef4c9dClaude158 </div>
0316dbbClaude159 </ListItem>
160 ))}
161 </List>
3ef4c9dClaude162 )}
163 </Layout>
164 );
165});
166
59b6fb2Claude167// Mark single notification as read
168notificationRoutes.post("/notifications/:id/read", softAuth, requireAuth, async (c) => {
3ef4c9dClaude169 const user = c.get("user")!;
170 const id = c.req.param("id");
59b6fb2Claude171
3ef4c9dClaude172 try {
173 await db
174 .update(notifications)
59b6fb2Claude175 .set({ isRead: true })
3ef4c9dClaude176 .where(and(eq(notifications.id, id), eq(notifications.userId, user.id)));
59b6fb2Claude177 } catch {
178 // Table may not exist
3ef4c9dClaude179 }
59b6fb2Claude180
3ef4c9dClaude181 return c.redirect("/notifications");
182});
183
59b6fb2Claude184// Mark all as read
185notificationRoutes.post("/notifications/read-all", softAuth, requireAuth, async (c) => {
3ef4c9dClaude186 const user = c.get("user")!;
59b6fb2Claude187
3ef4c9dClaude188 try {
189 await db
190 .update(notifications)
59b6fb2Claude191 .set({ isRead: true })
192 .where(and(eq(notifications.userId, user.id), eq(notifications.isRead, false)));
193 } catch {
194 // Table may not exist
3ef4c9dClaude195 }
196
59b6fb2Claude197 return c.redirect("/notifications");
3ef4c9dClaude198});
199
59b6fb2Claude200// API: Get unread count (for bell icon polling)
201notificationRoutes.get("/api/notifications/count", softAuth, async (c) => {
202 const user = c.get("user");
203 if (!user) return c.json({ count: 0 });
3ef4c9dClaude204
205 try {
59b6fb2Claude206 const [result] = await db
207 .select({ count: sql<number>`count(*)` })
3ef4c9dClaude208 .from(notifications)
59b6fb2Claude209 .where(and(eq(notifications.userId, user.id), eq(notifications.isRead, false)));
210 return c.json({ count: result?.count ?? 0 });
3ef4c9dClaude211 } catch {
59b6fb2Claude212 return c.json({ count: 0 });
3ef4c9dClaude213 }
214});
215
59b6fb2Claude216export default notificationRoutes;