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

1from __future__ import annotations 

2 

3from typing import Any 

4 

5from lexigram.ui import Component, el, get_icon, raw 

6 

7 

8class NotificationBell(Component): 

9 """In-app notification bell with real-time updates via SSE. 

10 

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. 

14 

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 """ 

21 

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 

33 

34 def render(self) -> Any: 

35 bell_icon = get_icon("bell", class_name="w-5 h-5") 

36 

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 )