Coverage for src / lexigram / admin / ui / organisms / notification_bell.py: 50%
12 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-08-11 02:25 +0800
« prev ^ index » next coverage.py v7.13.5, created at 2026-08-11 02:25 +0800
1from __future__ import annotations
3from typing import Any
5from lexigram.ui import Component, el, get_icon, raw
8class NotificationBell(Component):
9 """In-app notification bell with real-time updates via SSE.
11 Alpine.js-driven component that connects to the admin SSE endpoint,
12 displays an unread count badge on the bell icon, and shows a dropdown
13 of recent notifications with mark-as-read support.
15 Args:
16 sse_url: SSE endpoint URL for real-time notification events.
17 inbox_url: Link to the full notification inbox page.
18 max_display: Maximum number of notifications shown in the dropdown.
19 **props: Extra HTML attributes forwarded to the root element.
20 """
22 def __init__(
23 self,
24 sse_url: str = "/admin/_sse/events",
25 inbox_url: str = "/admin/notifications",
26 max_display: int = 10,
27 **props: Any,
28 ) -> None:
29 super().__init__(**props)
30 self.sse_url = sse_url
31 self.inbox_url = inbox_url
32 self.max_display = max_display
34 def render(self) -> Any:
35 bell_icon = get_icon("bell", class_name="w-5 h-5")
37 return el(
38 "div",
39 {
40 "x-data": "notificationBell",
41 "x-init": "init()",
42 "x-on:beforeunload.window": "destroy()",
43 "class": "relative",
44 },
45 # Bell button
46 el(
47 "button",
48 {
49 "type": "button",
50 "x-on:click": "toggle()",
51 "class": "relative p-2 rounded-lg hover:bg-muted dark:hover:bg-card transition-colors focus:outline-none focus:ring-2 focus:ring-primary-500",
52 "aria-label": "Notifications",
53 },
54 bell_icon,
55 el(
56 "span",
57 {
58 "x-show": "unreadCount > 0",
59 "x-text": "unreadCount > 99 ? '99+' : unreadCount",
60 "class": "absolute -top-0.5 -right-0.5 inline-flex items-center justify-center px-1.5 py-0.5 text-[10px] font-bold leading-none text-white bg-destructive rounded-full min-w-[16px]",
61 },
62 ),
63 ),
64 # Dropdown
65 el(
66 "div",
67 {
68 "x-show": "open",
69 "x-on:click.away": "close()",
70 "x-transition:enter": "transition ease-out duration-200",
71 "x-transition:enter-start": "opacity-0 translate-y-1",
72 "x-transition:enter-end": "opacity-100 translate-y-0",
73 "x-transition:leave": "transition ease-in duration-150",
74 "x-transition:leave-start": "opacity-100 translate-y-0",
75 "x-transition:leave-end": "opacity-0 translate-y-1",
76 "class": "absolute right-0 mt-2 w-80 rounded-xl shadow-lg bg-card ring-1 ring-border divide-y divide-border z-50",
77 },
78 # Header
79 el(
80 "div",
81 {"class": "flex items-center justify-between px-4 py-3"},
82 el(
83 "h3",
84 {"class": "text-sm font-semibold text-foreground"},
85 "Notifications",
86 ),
87 el(
88 "button",
89 {
90 "x-on:click": "markAllRead()",
91 "x-show": "unreadCount > 0",
92 "class": "text-xs text-primary-600 dark:text-primary-400 hover:underline",
93 },
94 "Mark all read",
95 ),
96 ),
97 # Notification list
98 el(
99 "div",
100 {"class": "max-h-64 overflow-y-auto"},
101 el(
102 "template",
103 {"x-for": "(notif, idx) in notifications", ":key": "notif.id"},
104 el(
105 "div",
106 {
107 ":class": "notif.read ? 'opacity-60' : ''",
108 "class": "flex items-start gap-3 px-4 py-3 hover:bg-muted dark:hover:bg-muted/50 cursor-pointer transition-colors",
109 "x-on:click": "markAsRead(notif.id)",
110 },
111 # Level indicator dot
112 el(
113 "span",
114 {
115 "class": "mt-1.5 w-2 h-2 rounded-full flex-shrink-0",
116 ":class": "{'bg-info': notif.level === 'info', 'bg-warning': notif.level === 'warning', 'bg-destructive': notif.level === 'error', 'bg-success': notif.level === 'success'}",
117 },
118 ),
119 # Content
120 el(
121 "div",
122 el(
123 "p",
124 {
125 "x-text": "notif.title",
126 "class": "text-sm font-medium text-foreground",
127 },
128 ),
129 el(
130 "p",
131 {
132 "x-text": "notif.message",
133 "class": "text-xs text-muted-foreground dark:text-muted-foreground mt-0.5 line-clamp-2",
134 },
135 ),
136 class_="flex-1 min-w-0",
137 ),
138 ),
139 ),
140 # Empty state
141 el(
142 "div",
143 {
144 "x-show": "notifications.length === 0",
145 "class": "px-4 py-8 text-center text-sm text-muted-foreground dark:text-muted-foreground",
146 },
147 "No new notifications",
148 ),
149 ),
150 # Footer (show when more notifications exist than max_display)
151 el(
152 "div",
153 el(
154 "a",
155 {
156 "href": self.inbox_url,
157 "class": "block w-full text-center px-4 py-2 text-xs text-primary-600 dark:text-primary-400 hover:bg-muted dark:hover:bg-muted/50 rounded-b-xl transition-colors",
158 },
159 "View all notifications",
160 ),
161 ),
162 ),
163 # Alpine.js component registration
164 el(
165 "script",
166 raw(f"""
167document.addEventListener('alpine:init', () => {{
168 Alpine.data('notificationBell', () => ({{
169 open: false,
170 unreadCount: 0,
171 notifications: [],
172 eventSource: null,
173 toggle() {{ this.open = !this.open; }},
174 close() {{ this.open = false; }},
175 init() {{
176 this.eventSource = new EventSource('{self.sse_url}');
177 this.eventSource.addEventListener('notification', (e) => {{
178 const data = JSON.parse(e.data);
179 this.notifications.unshift({{
180 id: data.id || Date.now() + Math.random(),
181 title: data.title || 'Notification',
182 message: data.message || '',
183 level: data.level || 'info',
184 read: false,
185 time: data.timestamp
186 }});
187 this.unreadCount++;
188 if (this.notifications.length > {self.max_display}) {{
189 this.notifications.pop();
190 }}
191 }});
192 this.eventSource.addEventListener('toast', (e) => {{
193 const data = JSON.parse(e.data);
194 if (typeof window.showToast === 'function') {{
195 window.showToast(data.message || data.title, data.variant || 'success');
196 }}
197 }});
198 this.eventSource.addEventListener('error', () => {{}});
199 }},
200 destroy() {{
201 if (this.eventSource) this.eventSource.close();
202 }},
203 markAsRead(id) {{
204 const notif = this.notifications.find(n => n.id === id);
205 if (notif && !notif.read) {{
206 notif.read = true;
207 this.unreadCount = Math.max(0, this.unreadCount - 1);
208 }}
209 }},
210 markAllRead() {{
211 this.notifications.forEach(n => n.read = true);
212 this.unreadCount = 0;
213 }}
214 }}));
215}});
216"""),
217 ),
218 )