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.tsxBlame183 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";
13
14const notificationRoutes = new Hono<AuthEnv>();
15
16// Notification list page
17notificationRoutes.get("/notifications", softAuth, requireAuth, async (c) => {
18 const user = c.get("user")!;
19 const filter = c.req.query("filter") || "unread";
20
21 const query = db
22 .select()
23 .from(notifications)
24 .where(
25 filter === "all"
26 ? eq(notifications.userId, user.id)
27 : and(eq(notifications.userId, user.id), eq(notifications.isRead, false))
28 )
29 .orderBy(desc(notifications.createdAt))
30 .limit(50);
31
32 let items: any[] = [];
33 try {
34 items = await query;
35 } catch {
36 // Table may not exist yet
37 }
38
39 let unreadCount = 0;
40 try {
41 const [result] = await db
42 .select({ count: sql<number>`count(*)` })
43 .from(notifications)
44 .where(and(eq(notifications.userId, user.id), eq(notifications.isRead, false)));
45 unreadCount = result?.count ?? 0;
46 } catch {
47 // Table may not exist yet
48 }
49
50 return c.html(
51 <Layout title="Notifications" user={user}>
52 <div style="max-width:800px">
53 <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:20px">
54 <h2>Notifications</h2>
55 <div style="display:flex;gap:8px">
56 {unreadCount > 0 && (
57 <form method="post" action="/notifications/read-all">
58 <input type="hidden" name="_csrf" value={(c as any).get("csrfToken") || ""} />
59 <button type="submit" class="btn btn-sm">Mark all read</button>
60 </form>
61 )}
62 </div>
63 </div>
64
65 <div class="issue-tabs" style="margin-bottom:16px">
66 <a href="/notifications?filter=unread" class={filter === "unread" ? "active" : ""}>
67 Unread {unreadCount > 0 && `(${unreadCount})`}
68 </a>
69 <a href="/notifications?filter=all" class={filter === "all" ? "active" : ""}>
70 All
71 </a>
72 </div>
73
74 {items.length === 0 ? (
75 <div class="empty-state">
76 <h2>All caught up</h2>
77 <p>No {filter === "unread" ? "unread " : ""}notifications.</p>
78 </div>
79 ) : (
80 <div class="issue-list">
81 {items.map((n: any) => (
82 <div class="issue-item" style={n.isRead ? "opacity:0.6" : ""}>
83 <div style="font-size:18px;padding-top:2px">
84 {n.type === "issue_comment" ? "\u{1F4AC}" :
85 n.type === "pr_review" ? "\u{1F50D}" :
86 n.type === "mention" ? "\u{1F4E3}" :
87 n.type === "star" ? "\u2B50" :
88 n.type === "ci_status" ? "\u2699\uFE0F" : "\u{1F514}"}
89 </div>
90 <div style="flex:1">
91 <div style="font-size:14px;font-weight:500">
92 {n.url ? <a href={n.url} style="color:var(--text)">{n.title}</a> : n.title}
93 </div>
94 {n.body && (
95 <div style="font-size:13px;color:var(--text-muted);margin-top:2px">
96 {n.body.length > 120 ? n.body.slice(0, 120) + "..." : n.body}
97 </div>
98 )}
99 <div style="font-size:12px;color:var(--text-muted);margin-top:4px">
100 {formatRelative(n.createdAt)}
101 </div>
102 </div>
103 {!n.isRead && (
104 <form method="post" action={`/notifications/${n.id}/read`} style="flex-shrink:0">
105 <input type="hidden" name="_csrf" value={(c as any).get("csrfToken") || ""} />
106 <button type="submit" class="btn btn-sm btn-ghost" title="Mark as read">
107 \u2713
108 </button>
109 </form>
110 )}
111 </div>
112 ))}
113 </div>
114 )}
115 </div>
116 </Layout>
117 );
118});
119
120// Mark single notification as read
121notificationRoutes.post("/notifications/:id/read", softAuth, requireAuth, async (c) => {
122 const user = c.get("user")!;
123 const id = c.req.param("id");
124
125 try {
126 await db
127 .update(notifications)
128 .set({ isRead: true })
129 .where(and(eq(notifications.id, id), eq(notifications.userId, user.id)));
130 } catch {
131 // Table may not exist
132 }
133
134 return c.redirect("/notifications");
135});
136
137// Mark all as read
138notificationRoutes.post("/notifications/read-all", softAuth, requireAuth, async (c) => {
139 const user = c.get("user")!;
140
141 try {
142 await db
143 .update(notifications)
144 .set({ isRead: true })
145 .where(and(eq(notifications.userId, user.id), eq(notifications.isRead, false)));
146 } catch {
147 // Table may not exist
148 }
149
150 return c.redirect("/notifications");
151});
152
153// API: Get unread count (for bell icon polling)
154notificationRoutes.get("/api/notifications/count", softAuth, async (c) => {
155 const user = c.get("user");
156 if (!user) return c.json({ count: 0 });
157
158 try {
159 const [result] = await db
160 .select({ count: sql<number>`count(*)` })
161 .from(notifications)
162 .where(and(eq(notifications.userId, user.id), eq(notifications.isRead, false)));
163 return c.json({ count: result?.count ?? 0 });
164 } catch {
165 return c.json({ count: 0 });
166 }
167});
168
169function formatRelative(date: Date | string): string {
170 const d = typeof date === "string" ? new Date(date) : date;
171 const now = new Date();
172 const diffMs = now.getTime() - d.getTime();
173 const diffMins = Math.floor(diffMs / 60000);
174 if (diffMins < 1) return "just now";
175 if (diffMins < 60) return `${diffMins}m ago`;
176 const diffHours = Math.floor(diffMins / 60);
177 if (diffHours < 24) return `${diffHours}h ago`;
178 const diffDays = Math.floor(diffHours / 24);
179 if (diffDays < 30) return `${diffDays}d ago`;
180 return d.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
181}
182
183export default notificationRoutes;