Coverage for src / lexigram / admin / ui / layouts / admin_layout.py: 45%
137 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-08-13 22:14 +0800
« prev ^ index » next coverage.py v7.13.5, created at 2026-08-13 22:14 +0800
1"""AdminLayout - Main layout wrapper for admin pages.
3This module provides the AdminLayout class that renders admin pages with:
4- HTML head with meta, CSS, JS
5- Navigation header
6- Sidebar navigation
7- Main content area
8- Footer
9- Toast notifications area
11Uses inheritance from BaseLayout for code reuse.
13UI-08: AdminLayout implementation.
14"""
16from __future__ import annotations
18from dataclasses import dataclass, field
19from typing import Any
21from markupsafe import Markup, escape
23from lexigram.admin.theme.tailwind import (
24 DARK_BOOTSTRAP_SCRIPT,
25 TAILWIND_THEME_CONFIG,
26 THEME_BRIDGE_SCRIPT,
27)
28from lexigram.admin.ui.layouts.components import (
29 FooterConfig,
30 FooterRenderer,
31 HeaderConfig,
32 HeaderRenderer,
33 NavGroup,
34 NavItem,
35 ServerToastChannel,
36 SidebarConfig,
37 SidebarRenderer,
38 ToastConfig,
39 UserInfo,
40 flash_to_toast,
41)
42from lexigram.ui import BaseLayoutConfig, BaseLayoutContext, LayoutBase
45@dataclass
46class AdminLayoutConfig(BaseLayoutConfig):
47 """Configuration for admin layout.
49 Extends BaseLayoutConfig with admin-specific options.
50 """
52 # Branding
53 app_name: str = "Admin"
54 app_logo: str | None = None
55 app_logo_alt: str = "Logo"
57 # Layout options
58 sidebar_collapsed: bool = False
59 sidebar_width: str = "256px"
60 sidebar_collapsed_width: str = "64px"
61 fixed_header: bool = True
62 fixed_sidebar: bool = True
64 # Features
65 show_search: bool = True
66 show_notifications: bool = True
67 show_user_menu: bool = True
68 show_footer: bool = True
69 show_breadcrumbs: bool = True
72@dataclass
73class NavItemConfig:
74 """Navigation item configuration."""
76 label: str
77 url: str
78 icon: str | None = None
79 badge: str | None = None
80 badge_variant: str = "primary"
81 active: bool = False
82 children: list[NavItemConfig] = field(default_factory=list)
83 permission: str | None = None
86@dataclass
87class AdminLayoutContext(BaseLayoutContext):
88 """Context for admin layout rendering.
90 Extends BaseLayoutContext with admin-specific data.
91 """
93 # Current page
94 page_title: str = "Dashboard"
95 page_description: str | None = None
97 # Current user
98 user_name: str | None = None
99 user_email: str | None = None
100 user_avatar: str | None = None
101 user_role: str | None = None
103 # Navigation
104 nav_items: list[NavItemConfig] = field(default_factory=list)
105 current_path: str = "/"
107 # URLs
108 base_url: str = "/admin"
109 logout_url: str = "/admin/logout"
110 profile_url: str = "/admin/profile"
111 settings_url: str = "/admin/settings"
113 # Notifications
114 notifications: list[dict[str, Any]] = field(default_factory=list)
115 unread_count: int = 0
117 # Messages/Toasts
118 flash_messages: list[tuple[str, str]] = field(default_factory=list)
120 # CSRF
121 csrf_token: str | None = None
123 # State
124 sidebar_collapsed: bool = False
127class AdminLayout(LayoutBase):
128 """Admin layout with sidebar, header, and footer.
130 Extends BaseLayout with admin-specific components and rendering.
131 """
133 def __init__(
134 self,
135 config: AdminLayoutConfig | None = None,
136 context: AdminLayoutContext | None = None,
137 ):
138 """Initialize admin layout.
140 Args:
141 config: Layout configuration
142 context: Layout context with user, nav, etc.
143 """
144 self.admin_config = config or AdminLayoutConfig()
145 self.admin_context = context or AdminLayoutContext()
147 # Initialize base layout
148 super().__init__(self.admin_config)
150 # Set up component renderers
151 self._setup_components()
153 def _setup_components(self) -> None:
154 """Set up layout component renderers."""
155 ctx = self.admin_context
156 cfg = self.admin_config
158 # Header
159 self.header_renderer = HeaderRenderer(
160 config=HeaderConfig(
161 site_name=cfg.app_name,
162 logo_url=cfg.app_logo,
163 logo_alt=cfg.app_logo_alt,
164 show_search=cfg.show_search,
165 show_notifications=cfg.show_notifications,
166 show_user_menu=cfg.show_user_menu,
167 home_url=ctx.base_url,
168 profile_url=ctx.profile_url,
169 settings_url=ctx.settings_url,
170 logout_url=ctx.logout_url,
171 ),
172 user=UserInfo(
173 name=ctx.user_name or "User",
174 email=ctx.user_email or "",
175 avatar_url=ctx.user_avatar,
176 role=ctx.user_role,
177 )
178 if ctx.user_name
179 else None,
180 )
182 # Sidebar
183 nav_groups = self._build_nav_groups()
184 self.sidebar_renderer = SidebarRenderer(
185 config=SidebarConfig(
186 width=cfg.sidebar_width,
187 collapsed_width=cfg.sidebar_collapsed_width,
188 default_collapsed=cfg.sidebar_collapsed,
189 show_logo=False, # Logo in header
190 site_name=cfg.app_name,
191 ),
192 groups=nav_groups,
193 )
195 # Footer
196 self.footer_renderer = FooterRenderer(
197 config=FooterConfig(
198 copyright_holder=cfg.app_name,
199 show_version=False,
200 ),
201 )
203 # Toast
204 self.toast_renderer = ServerToastChannel(
205 config=ToastConfig(
206 position="top-right",
207 default_duration_ms=5000,
208 ),
209 )
211 def _build_nav_groups(self) -> list[NavGroup]:
212 """Build navigation groups from NavItemConfig list."""
213 items = [self._convert_nav_item(item) for item in self.admin_context.nav_items]
215 if items:
216 return [NavGroup(label=None, items=items)]
217 return []
219 def _convert_nav_item(self, item: NavItemConfig) -> NavItem:
220 """Convert NavItemConfig to NavItem."""
221 children = [self._convert_nav_item(child) for child in item.children]
223 is_active = (
224 item.active
225 or self.admin_context.current_path == item.url
226 or self.admin_context.current_path.startswith(item.url + "/")
227 )
229 return NavItem(
230 label=item.label,
231 url=item.url,
232 icon=item.icon or "circle",
233 badge=item.badge,
234 badge_color="blue"
235 if item.badge_variant == "primary"
236 else item.badge_variant,
237 is_active=is_active,
238 children=children,
239 )
241 def render_head_content(self, **kwargs: Any) -> str:
242 """Render additional head content.
244 Returns admin-specific CSS and theme variables.
245 """
246 cfg = self.admin_config
247 ctx = self.admin_context
249 parts: list[str] = []
251 # Page title
252 parts.append(
253 f"<title>{escape(ctx.page_title)} | {escape(cfg.app_name)}</title>",
254 )
256 if ctx.page_description:
257 parts.append(
258 f'<meta name="description" content="{escape(ctx.page_description)}">',
259 )
261 # Theme CSS variables
262 parts.append(f"""
263 <style>
264 :root {{
265 --admin-sidebar-width: {escape(cfg.sidebar_width)};
266 --admin-sidebar-collapsed-width: {escape(cfg.sidebar_collapsed_width)};
267 }}
268 </style>
269 """)
271 # Tailwind CSS (CDN)
272 parts.append('<script src="https://cdn.tailwindcss.com"></script>')
273 parts.append(TAILWIND_THEME_CONFIG)
274 parts.append(DARK_BOOTSTRAP_SCRIPT)
275 parts.append(THEME_BRIDGE_SCRIPT)
277 # Lucide icons
278 parts.append('<script src="https://unpkg.com/lucide@latest"></script>')
280 # SortableJS for dashboard widget drag-and-drop
281 parts.append(
282 '<script src="https://unpkg.com/sortablejs@1.15.0/Sortable.min.js"></script>'
283 )
285 # Alpine.js plugins (loaded before Alpine core)
286 parts.append(
287 '<script defer src="https://unpkg.com/@alpinejs/focus@3.x.x/dist/cdn.min.js"></script>',
288 )
289 # Alpine.js for dropdowns, modals, slide-overs
290 parts.append(
291 '<script defer src="https://unpkg.com/alpinejs@3.x.x/dist/cdn.min.js"></script>',
292 )
293 # Patch Alpine's transition handler to catch isFromCancelledTransition
294 parts.append(
295 "<script defer>var origToggle=Element.prototype._x_toggleAndCascadeWithTransitions;origToggle&&(Element.prototype._x_toggleAndCascadeWithTransitions=function(e,t,r,n){var o=origToggle.call(this,e,t,r,n);if(!t&&this._x_hidePromise)this._x_hidePromise.catch(function(a){});return o})</script>",
296 )
297 # Suppress Alpine's harmless transition-cancelled promise rejections
298 parts.append(
299 '<script>window.addEventListener("unhandledrejection",function(e){e.promise&&e.promise.catch(function(){});if(!e.reason)return;var r=e.reason;if(r.isFromCancelledTransition||r instanceof TypeError){e.preventDefault();e.stopImmediatePropagation()}})</script>',
300 )
302 return "\n".join(parts)
304 def render_body_content(self, content: str = "", **kwargs: Any) -> str:
305 """Render the body content.
307 Args:
308 content: Main page content
310 Returns:
311 Complete body inner HTML
312 """
313 cfg = self.admin_config
314 ctx = self.admin_context
316 parts: list[str] = []
318 # Skip link for accessibility
319 parts.append(
320 '<a href="#main-content" class="skip-link sr-only focus:not-sr-only">Skip to content</a>',
321 )
323 # Layout wrapper
324 collapsed_class = "sidebar-collapsed" if ctx.sidebar_collapsed else ""
325 parts.append(f'<div class="admin-wrapper {collapsed_class}">')
327 # Sidebar
328 parts.append(self.sidebar_renderer.render(ctx.current_path))
330 # Main area
331 parts.append('<div class="admin-main">')
333 # Header
334 parts.append(
335 self.header_renderer.render(
336 notifications=ctx.notifications,
337 unread_count=ctx.unread_count,
338 ),
339 )
341 # Main content
342 parts.append('<main id="main-content" class="admin-content">')
343 parts.append(content)
344 parts.append("</main>")
346 # Footer
347 if cfg.show_footer:
348 parts.append(self.footer_renderer.render())
350 parts.append("</div>") # admin-main
351 parts.append("</div>") # admin-wrapper
353 # Toast container with flash messages
354 toasts = flash_to_toast(ctx.flash_messages)
355 parts.append(self.toast_renderer.render_container(toasts))
357 # Initialize Lucide icons
358 parts.append("""
359 <script>
360 document.addEventListener('DOMContentLoaded', function() {
361 if (window.lucide) lucide.createIcons();
362 });
363 </script>
364 """)
366 # HTMX re-init icons after swap
367 if cfg.htmx_enabled:
368 csrf_header = ""
369 if ctx.csrf_token:
370 csrf_header = f"""
371 document.body.addEventListener('htmx:configRequest', function(evt) {{
372 evt.detail.headers['X-CSRF-Token'] = '{escape(ctx.csrf_token)}';
373 }});
374 """
376 parts.append(f"""
377 <script>
378 {csrf_header}
379 document.body.addEventListener('htmx:afterSwap', function() {{
380 if (window.lucide) lucide.createIcons();
381 }});
382 </script>
383 """)
385 # Core admin JS (served from admin router's static mount)
386 parts.append('<script src="/admin/static/js/admin.js"></script>')
388 return "\n".join(parts)
390 def get_body_attrs(self) -> dict[str, str]:
391 """Get body tag attributes."""
392 cfg = self.admin_config
393 ctx = self.admin_context
395 attrs = super().get_body_attrs() # type: ignore[misc]
397 classes = ["admin-layout"]
398 if cfg.fixed_header:
399 classes.append("fixed-header")
400 if cfg.fixed_sidebar:
401 classes.append("fixed-sidebar")
402 if ctx.sidebar_collapsed:
403 classes.append("sidebar-collapsed")
405 attrs["class"] = " ".join(classes)
407 return attrs
410def admin_layout(
411 content: str | Markup,
412 config: AdminLayoutConfig,
413 context: AdminLayoutContext,
414) -> Markup:
415 """Render the complete admin layout.
417 Convenience function that creates AdminLayout and renders.
419 Args:
420 content: Page content to wrap
421 config: Layout configuration
422 context: Context (user, nav, etc.)
424 Returns:
425 Complete HTML page markup
426 """
427 layout = AdminLayout(config=config, context=context)
428 return Markup(layout.render(str(content)))
431__all__ = [
432 "AdminLayout",
433 "AdminLayoutConfig",
434 "AdminLayoutContext",
435 "NavItemConfig",
436 "admin_layout",
437]