Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesPull RequestsActionsSecurityInsightsSettings
✨ AI
More
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.tsxBlame185 lines · 1 contributor
59b6fb2Claude1/**
2 * Notification routes — bell icon, list, mark read, clear.
3 */
4
5import { Hono } from "hono";
6import { eq, and, desc, sql } from "drizzle-orm";
7import { db } from "../db";
8import { notifications } from "../db/schema-extensions";
9import { users, repositories } from "../db/schema";
10import { Layout } from "../views/layout";
11import { softAuth, requireAuth } from "../middleware/auth";
12import 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[] = [];
47 try {
48 items = await query;
49 } catch {
50 // Table may not exist yet
51 }
52
53 let unreadCount = 0;
54 try {
55 const [result] = await db
56 .select({ count: sql<number>`count(*)` })
57 .from(notifications)
58 .where(and(eq(notifications.userId, user.id), eq(notifications.isRead, false)));
59 unreadCount = result?.count ?? 0;
60 } catch {
61 // Table may not exist yet
62 }
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;
69
59b6fb2Claude70 return c.html(
71 <Layout title="Notifications" user={user}>
3e8f8e8Claude72 <Container maxWidth={800}>
73 <PageHeader title="Notifications" actions={markAllReadAction} />
74
75 <div style="margin-bottom:16px">
76 <FilterTabs tabs={[
77 {
78 label: `Unread${unreadCount > 0 ? ` (${unreadCount})` : ""}`,
79 href: "/notifications?filter=unread",
80 active: filter === "unread",
81 },
82 {
83 label: "All",
84 href: "/notifications?filter=all",
85 active: filter === "all",
86 },
87 ]} />
59b6fb2Claude88 </div>
89
90 {items.length === 0 ? (
3e8f8e8Claude91 <EmptyState title="All caught up">
59b6fb2Claude92 <p>No {filter === "unread" ? "unread " : ""}notifications.</p>
3e8f8e8Claude93 </EmptyState>
59b6fb2Claude94 ) : (
3e8f8e8Claude95 <List>
59b6fb2Claude96 {items.map((n: any) => (
3e8f8e8Claude97 <ListItem style={n.isRead ? "opacity:0.6" : ""}>
59b6fb2Claude98 <div style="font-size:18px;padding-top:2px">
99 {n.type === "issue_comment" ? "\u{1F4AC}" :
100 n.type === "pr_review" ? "\u{1F50D}" :
101 n.type === "mention" ? "\u{1F4E3}" :
102 n.type === "star" ? "\u2B50" :
103 n.type === "ci_status" ? "\u2699\uFE0F" : "\u{1F514}"}
104 </div>
3e8f8e8Claude105 <Flex direction="column" style="flex:1">
106 <Text size={14} weight={500}>
59b6fb2Claude107 {n.url ? <a href={n.url} style="color:var(--text)">{n.title}</a> : n.title}
3e8f8e8Claude108 </Text>
59b6fb2Claude109 {n.body && (
3e8f8e8Claude110 <Text size={13} muted style="margin-top:2px">
59b6fb2Claude111 {n.body.length > 120 ? n.body.slice(0, 120) + "..." : n.body}
3e8f8e8Claude112 </Text>
59b6fb2Claude113 )}
3e8f8e8Claude114 <Text size={12} muted style="margin-top:4px">
59b6fb2Claude115 {formatRelative(n.createdAt)}
3e8f8e8Claude116 </Text>
117 </Flex>
59b6fb2Claude118 {!n.isRead && (
3e8f8e8Claude119 <div style="flex-shrink:0">
120 <Form action={`/notifications/${n.id}/read`} csrfToken={csrfToken}>
121 <Button size="sm" variant="ghost" type="submit">
122 {"\u2713"}
123 </Button>
124 </Form>
125 </div>
59b6fb2Claude126 )}
3e8f8e8Claude127 </ListItem>
59b6fb2Claude128 ))}
3e8f8e8Claude129 </List>
59b6fb2Claude130 )}
3e8f8e8Claude131 </Container>
59b6fb2Claude132 </Layout>
133 );
134});
135
136// Mark single notification as read
137notificationRoutes.post("/notifications/:id/read", softAuth, requireAuth, async (c) => {
138 const user = c.get("user")!;
139 const id = c.req.param("id");
140
141 try {
142 await db
143 .update(notifications)
144 .set({ isRead: true })
145 .where(and(eq(notifications.id, id), eq(notifications.userId, user.id)));
146 } catch {
147 // Table may not exist
148 }
149
150 return c.redirect("/notifications");
151});
152
153// Mark all as read
154notificationRoutes.post("/notifications/read-all", softAuth, requireAuth, async (c) => {
155 const user = c.get("user")!;
156
157 try {
158 await db
159 .update(notifications)
160 .set({ isRead: true })
161 .where(and(eq(notifications.userId, user.id), eq(notifications.isRead, false)));
162 } catch {
163 // Table may not exist
164 }
165
166 return c.redirect("/notifications");
167});
168
169// API: Get unread count (for bell icon polling)
170notificationRoutes.get("/api/notifications/count", softAuth, async (c) => {
171 const user = c.get("user");
172 if (!user) return c.json({ count: 0 });
173
174 try {
175 const [result] = await db
176 .select({ count: sql<number>`count(*)` })
177 .from(notifications)
178 .where(and(eq(notifications.userId, user.id), eq(notifications.isRead, false)));
179 return c.json({ count: result?.count ?? 0 });
180 } catch {
181 return c.json({ count: 0 });
182 }
183});
184
185export default notificationRoutes;