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"; | |
| 3e8f8e8 | 13 | import { |
| 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"; | |
| 59b6fb2 | 26 | |
| 27 | const notificationRoutes = new Hono<AuthEnv>(); | |
| 28 | ||
| 29 | // Notification list page | |
| 30 | notificationRoutes.get("/notifications", softAuth, requireAuth, async (c) => { | |
| 31 | const user = c.get("user")!; | |
| 32 | const filter = c.req.query("filter") || "unread"; | |
| 3e8f8e8 | 33 | const csrfToken = (c as any).get("csrfToken") || ""; |
| 59b6fb2 | 34 | |
| 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 | ||
| 3e8f8e8 | 64 | 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 | ||
| 59b6fb2 | 70 | return c.html( |
| 71 | <Layout title="Notifications" user={user}> | |
| 3e8f8e8 | 72 | <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 | ]} /> | |
| 59b6fb2 | 88 | </div> |
| 89 | ||
| 90 | {items.length === 0 ? ( | |
| 3e8f8e8 | 91 | <EmptyState title="All caught up"> |
| 59b6fb2 | 92 | <p>No {filter === "unread" ? "unread " : ""}notifications.</p> |
| 3e8f8e8 | 93 | </EmptyState> |
| 59b6fb2 | 94 | ) : ( |
| 3e8f8e8 | 95 | <List> |
| 59b6fb2 | 96 | {items.map((n: any) => ( |
| 3e8f8e8 | 97 | <ListItem style={n.isRead ? "opacity:0.6" : ""}> |
| 59b6fb2 | 98 | <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> | |
| 3e8f8e8 | 105 | <Flex direction="column" style="flex:1"> |
| 106 | <Text size={14} weight={500}> | |
| 59b6fb2 | 107 | {n.url ? <a href={n.url} style="color:var(--text)">{n.title}</a> : n.title} |
| 3e8f8e8 | 108 | </Text> |
| 59b6fb2 | 109 | {n.body && ( |
| 3e8f8e8 | 110 | <Text size={13} muted style="margin-top:2px"> |
| 59b6fb2 | 111 | {n.body.length > 120 ? n.body.slice(0, 120) + "..." : n.body} |
| 3e8f8e8 | 112 | </Text> |
| 59b6fb2 | 113 | )} |
| 3e8f8e8 | 114 | <Text size={12} muted style="margin-top:4px"> |
| 59b6fb2 | 115 | {formatRelative(n.createdAt)} |
| 3e8f8e8 | 116 | </Text> |
| 117 | </Flex> | |
| 59b6fb2 | 118 | {!n.isRead && ( |
| 3e8f8e8 | 119 | <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> | |
| 59b6fb2 | 126 | )} |
| 3e8f8e8 | 127 | </ListItem> |
| 59b6fb2 | 128 | ))} |
| 3e8f8e8 | 129 | </List> |
| 59b6fb2 | 130 | )} |
| 3e8f8e8 | 131 | </Container> |
| 59b6fb2 | 132 | </Layout> |
| 133 | ); | |
| 134 | }); | |
| 135 | ||
| 136 | // Mark single notification as read | |
| 137 | notificationRoutes.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 | |
| 154 | notificationRoutes.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) | |
| 170 | notificationRoutes.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 | ||
| 185 | export default notificationRoutes; |