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.
| 59b6fb2 | 1 | /** |
| 2 | * Notification routes — bell icon, list, mark read, clear. | |
| 3 | */ | |
| 4 | ||
| 5 | import { Hono } from "hono"; | |
| 6 | import { eq, and, desc, sql } from "drizzle-orm"; | |
| 7 | import { db } from "../db"; | |
| 8 | import { notifications } from "../db/schema-extensions"; | |
| 9 | import { users, repositories } from "../db/schema"; | |
| 10 | import { Layout } from "../views/layout"; | |
| 11 | import { softAuth, requireAuth } from "../middleware/auth"; | |
| 12 | import type { AuthEnv } from "../middleware/auth"; | |
| 13 | ||
| 14 | const notificationRoutes = new Hono<AuthEnv>(); | |
| 15 | ||
| 16 | // Notification list page | |
| 17 | notificationRoutes.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 | |
| 121 | notificationRoutes.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 | |
| 138 | notificationRoutes.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) | |
| 154 | notificationRoutes.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 | ||
| 169 | function 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 | ||
| 183 | export default notificationRoutes; |