Coverage for src/lexigram/admin/ui/layouts/components/header.py: 44%

89 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-21 14:56 +0800

1"""Header component for admin layout. 

2 

3Renders the top header bar with logo, search, notifications, and user menu. 

4""" 

5 

6from __future__ import annotations 

7 

8from dataclasses import dataclass, field 

9from typing import Any 

10 

11from markupsafe import escape 

12 

13 

14@dataclass 

15class HeaderConfig: 

16 """Configuration for header.""" 

17 

18 # Branding 

19 site_name: str = "Admin" 

20 logo_url: str | None = None 

21 logo_alt: str = "Logo" 

22 

23 # Features 

24 show_search: bool = True 

25 show_notifications: bool = True 

26 show_user_menu: bool = True 

27 show_theme_toggle: bool = True 

28 

29 # Search 

30 search_placeholder: str = "Search..." 

31 search_url: str = "/admin/search" 

32 search_hotkey: str = "/" 

33 

34 # URLs 

35 home_url: str = "/admin/" 

36 profile_url: str = "/admin/profile" 

37 settings_url: str = "/admin/settings" 

38 logout_url: str = "/admin/logout" 

39 

40 # Custom 

41 extra_items: list[dict[str, str]] = field(default_factory=list) 

42 

43 

44@dataclass 

45class UserInfo: 

46 """User information for header.""" 

47 

48 name: str = "User" 

49 email: str = "" 

50 avatar_url: str | None = None 

51 role: str | None = None 

52 

53 

54class HeaderRenderer: 

55 """Renders the admin header bar.""" 

56 

57 def __init__( 

58 self, 

59 config: HeaderConfig | None = None, 

60 user: UserInfo | None = None, 

61 ): 

62 """Initialize the renderer. 

63 

64 Args: 

65 config: Header configuration 

66 user: Current user info 

67 """ 

68 self.config = config or HeaderConfig() 

69 self.user = user 

70 

71 def render( 

72 self, 

73 notifications: list[dict[str, Any]] | None = None, 

74 unread_count: int = 0, 

75 ) -> str: 

76 """Render the header bar. 

77 

78 Args: 

79 notifications: List of notification dicts 

80 unread_count: Count of unread notifications 

81 

82 Returns: 

83 HTML string for header 

84 """ 

85 parts: list[str] = [] 

86 

87 parts.append('<header class="admin-header">') 

88 

89 # Left section (logo, mobile menu toggle) 

90 parts.append(self._render_left_section()) 

91 

92 # Center section (search) 

93 if self.config.show_search: 

94 parts.append(self._render_search()) 

95 

96 # Right section (notifications, user menu) 

97 parts.append(self._render_right_section(notifications, unread_count)) 

98 

99 parts.append("</header>") 

100 

101 return "\n".join(parts) 

102 

103 def _render_left_section(self) -> str: 

104 """Render left section with logo.""" 

105 parts: list[str] = [] 

106 

107 parts.append('<div class="header-left">') 

108 

109 # Mobile menu toggle 

110 parts.append(""" 

111 <button type="button" class="mobile-menu-btn lg:hidden" 

112 onclick="document.body.classList.toggle('sidebar-open')"> 

113 <i data-lucide="menu" class="w-5 h-5"></i> 

114 </button> 

115 """) 

116 

117 # Logo 

118 parts.append(f'<a href="{escape(self.config.home_url)}" class="header-logo">') 

119 if self.config.logo_url: 

120 parts.append( 

121 f'<img src="{escape(self.config.logo_url)}" alt="{escape(self.config.logo_alt)}" class="h-8">', 

122 ) 

123 else: 

124 parts.append( 

125 f'<span class="font-bold text-xl">{escape(self.config.site_name)}</span>', 

126 ) 

127 parts.append("</a>") 

128 

129 parts.append("</div>") 

130 

131 return "\n".join(parts) 

132 

133 def _render_search(self) -> str: 

134 """Render search box.""" 

135 return f""" 

136 <div class="header-search"> 

137 <div class="relative"> 

138 <input type="search" 

139 name="q" 

140 placeholder="{escape(self.config.search_placeholder)}" 

141 class="search-input" 

142 hx-get="{escape(self.config.search_url)}" 

143 hx-trigger="keyup changed delay:300ms" 

144 hx-target="#search-results" 

145 autocomplete="off"> 

146 <div class="search-icon"> 

147 <i data-lucide="search" class="w-4 h-4"></i> 

148 </div> 

149 <kbd class="search-shortcut hidden sm:block">{escape(self.config.search_hotkey)}</kbd> 

150 </div> 

151 </div> 

152 </div> 

153 """ 

154 

155 def _render_right_section( 

156 self, 

157 notifications: list[dict[str, Any]] | None, 

158 unread_count: int, 

159 ) -> str: 

160 """Render right section with notifications and user menu.""" 

161 parts: list[str] = [] 

162 

163 parts.append('<div class="header-right">') 

164 

165 # Theme toggle 

166 if self.config.show_theme_toggle: 

167 parts.append(self._render_theme_toggle()) 

168 

169 # Notifications 

170 if self.config.show_notifications: 

171 parts.append(self._render_notifications(notifications, unread_count)) 

172 

173 # User menu 

174 if self.config.show_user_menu and self.user: 

175 parts.append(self._render_user_menu()) 

176 

177 parts.append("</div>") 

178 

179 return "\n".join(parts) 

180 

181 def _render_theme_toggle(self) -> str: 

182 """Render theme toggle button.""" 

183 return """ 

184 <button type="button" class="theme-toggle-btn" 

185 onclick="toggleTheme()" 

186 title="Toggle theme"> 

187 <i data-lucide="sun" class="w-5 h-5 dark:hidden"></i> 

188 <i data-lucide="moon" class="w-5 h-5 hidden dark:block"></i> 

189 </button> 

190 """ 

191 

192 def _render_notifications( 

193 self, 

194 notifications: list[dict[str, Any]] | None, 

195 unread_count: int, 

196 ) -> str: 

197 """Render notifications dropdown.""" 

198 badge = "" 

199 if unread_count > 0: 

200 badge = f'<span class="notification-badge">{unread_count}</span>' 

201 

202 items = "" 

203 if notifications: 

204 for n in notifications[:5]: 

205 items += f""" 

206 <a href="{escape(n.get("url", "#"))}" class="notification-item"> 

207 <span class="notification-title">{escape(n.get("title", ""))}</span> 

208 <span class="notification-time">{escape(n.get("time", ""))}</span> 

209 </a> 

210 """ 

211 else: 

212 items = '<p class="notification-empty">No new notifications</p>' 

213 

214 return f""" 

215 <div class="notifications-dropdown" x-data="{{open: false}}"> 

216 <button type="button" @click="open = !open" class="notification-btn"> 

217 <i data-lucide="bell" class="w-5 h-5"></i> 

218 {badge} 

219 </button> 

220 <div x-show="open" @click.away="open = false" class="notifications-panel"> 

221 <div class="notifications-header">Notifications</div> 

222 <div class="notifications-list"> 

223 {items} 

224 </div> 

225 <a href="/admin/notifications" class="notifications-footer">View all</a> 

226 </div> 

227 </div> 

228 """ 

229 

230 def _render_user_menu(self) -> str: 

231 """Render user dropdown menu.""" 

232 if not self.user: 

233 return "" 

234 

235 avatar = "" 

236 if self.user.avatar_url: 

237 avatar = ( 

238 f'<img src="{escape(self.user.avatar_url)}" alt="" class="user-avatar">' 

239 ) 

240 else: 

241 initial = self.user.name[0].upper() if self.user.name else "U" 

242 avatar = f'<span class="user-avatar-placeholder">{escape(initial)}</span>' 

243 

244 role_badge = "" 

245 if self.user.role: 

246 role_badge = ( 

247 f'<span class="user-role-badge">{escape(self.user.role)}</span>' 

248 ) 

249 

250 return f""" 

251 <div class="user-dropdown" x-data="{{open: false}}"> 

252 <button type="button" @click="open = !open" class="user-btn"> 

253 {avatar} 

254 <span class="user-name hidden md:block">{escape(self.user.name)}</span> 

255 <i data-lucide="chevron-down" class="w-4 h-4 hidden md:block"></i> 

256 </button> 

257 <div x-show="open" @click.away="open = false" class="user-panel"> 

258 <div class="user-info"> 

259 <div class="font-medium">{escape(self.user.name)}</div> 

260 <div class="text-sm text-muted-foreground">{escape(self.user.email)}</div> 

261 {role_badge} 

262 </div> 

263 <div class="user-menu-items"> 

264 <a href="{escape(self.config.profile_url)}" class="user-menu-item"> 

265 <i data-lucide="user" class="w-4 h-4"></i> Profile 

266 </a> 

267 <a href="{escape(self.config.settings_url)}" class="user-menu-item"> 

268 <i data-lucide="settings" class="w-4 h-4"></i> Settings 

269 </a> 

270 <hr class="my-1"> 

271 <a href="{escape(self.config.logout_url)}" class="user-menu-item text-destructive"> 

272 <i data-lucide="log-out" class="w-4 h-4"></i> Logout 

273 </a> 

274 </div> 

275 </div> 

276 </div> 

277 """ 

278 

279 

280__all__ = ["HeaderConfig", "HeaderRenderer", "UserInfo"]