Coverage for src/lexigram/admin/ui/layouts/components/header.py: 3%
89 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-24 23:31 +0800
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-24 23:31 +0800
1"""Header component for admin layout.
3Renders the top header bar with logo, search, notifications, and user menu.
4"""
6from __future__ import annotations
8from dataclasses import dataclass, field
9from typing import Any
11from markupsafe import escape
14@dataclass
15class HeaderConfig:
16 """Configuration for header."""
18 # Branding
19 site_name: str = "Admin"
20 logo_url: str | None = None
21 logo_alt: str = "Logo"
23 # Features
24 show_search: bool = True
25 show_notifications: bool = True
26 show_user_menu: bool = True
27 show_theme_toggle: bool = True
29 # Search
30 search_placeholder: str = "Search..."
31 search_url: str = "/admin/search"
32 search_hotkey: str = "/"
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"
40 # Custom
41 extra_items: list[dict[str, str]] = field(default_factory=list)
44@dataclass
45class UserInfo:
46 """User information for header."""
48 name: str = "User"
49 email: str = ""
50 avatar_url: str | None = None
51 role: str | None = None
54class HeaderRenderer:
55 """Renders the admin header bar."""
57 def __init__(
58 self,
59 config: HeaderConfig | None = None,
60 user: UserInfo | None = None,
61 ):
62 """Initialize the renderer.
64 Args:
65 config: Header configuration
66 user: Current user info
67 """
68 self.config = config or HeaderConfig()
69 self.user = user
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.
78 Args:
79 notifications: List of notification dicts
80 unread_count: Count of unread notifications
82 Returns:
83 HTML string for header
84 """
85 parts: list[str] = []
87 parts.append('<header class="admin-header">')
89 # Left section (logo, mobile menu toggle)
90 parts.append(self._render_left_section())
92 # Center section (search)
93 if self.config.show_search:
94 parts.append(self._render_search())
96 # Right section (notifications, user menu)
97 parts.append(self._render_right_section(notifications, unread_count))
99 parts.append("</header>")
101 return "\n".join(parts)
103 def _render_left_section(self) -> str:
104 """Render left section with logo."""
105 parts: list[str] = []
107 parts.append('<div class="header-left">')
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 """)
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>")
129 parts.append("</div>")
131 return "\n".join(parts)
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 """
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] = []
163 parts.append('<div class="header-right">')
165 # Theme toggle
166 if self.config.show_theme_toggle:
167 parts.append(self._render_theme_toggle())
169 # Notifications
170 if self.config.show_notifications:
171 parts.append(self._render_notifications(notifications, unread_count))
173 # User menu
174 if self.config.show_user_menu and self.user:
175 parts.append(self._render_user_menu())
177 parts.append("</div>")
179 return "\n".join(parts)
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 """
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>'
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>'
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 """
230 def _render_user_menu(self) -> str:
231 """Render user dropdown menu."""
232 if not self.user:
233 return ""
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>'
244 role_badge = ""
245 if self.user.role:
246 role_badge = (
247 f'<span class="user-role-badge">{escape(self.user.role)}</span>'
248 )
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 """
280__all__ = ["HeaderConfig", "HeaderRenderer", "UserInfo"]