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

1"""AdminLayout - Main layout wrapper for admin pages. 

2 

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 

10 

11Uses inheritance from BaseLayout for code reuse. 

12 

13UI-08: AdminLayout implementation. 

14""" 

15 

16from __future__ import annotations 

17 

18from dataclasses import dataclass, field 

19from typing import Any 

20 

21from markupsafe import Markup, escape 

22 

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 

43 

44 

45@dataclass 

46class AdminLayoutConfig(BaseLayoutConfig): 

47 """Configuration for admin layout. 

48 

49 Extends BaseLayoutConfig with admin-specific options. 

50 """ 

51 

52 # Branding 

53 app_name: str = "Admin" 

54 app_logo: str | None = None 

55 app_logo_alt: str = "Logo" 

56 

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 

63 

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 

70 

71 

72@dataclass 

73class NavItemConfig: 

74 """Navigation item configuration.""" 

75 

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 

84 

85 

86@dataclass 

87class AdminLayoutContext(BaseLayoutContext): 

88 """Context for admin layout rendering. 

89 

90 Extends BaseLayoutContext with admin-specific data. 

91 """ 

92 

93 # Current page 

94 page_title: str = "Dashboard" 

95 page_description: str | None = None 

96 

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 

102 

103 # Navigation 

104 nav_items: list[NavItemConfig] = field(default_factory=list) 

105 current_path: str = "/" 

106 

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" 

112 

113 # Notifications 

114 notifications: list[dict[str, Any]] = field(default_factory=list) 

115 unread_count: int = 0 

116 

117 # Messages/Toasts 

118 flash_messages: list[tuple[str, str]] = field(default_factory=list) 

119 

120 # CSRF 

121 csrf_token: str | None = None 

122 

123 # State 

124 sidebar_collapsed: bool = False 

125 

126 

127class AdminLayout(LayoutBase): 

128 """Admin layout with sidebar, header, and footer. 

129 

130 Extends BaseLayout with admin-specific components and rendering. 

131 """ 

132 

133 def __init__( 

134 self, 

135 config: AdminLayoutConfig | None = None, 

136 context: AdminLayoutContext | None = None, 

137 ): 

138 """Initialize admin layout. 

139 

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() 

146 

147 # Initialize base layout 

148 super().__init__(self.admin_config) 

149 

150 # Set up component renderers 

151 self._setup_components() 

152 

153 def _setup_components(self) -> None: 

154 """Set up layout component renderers.""" 

155 ctx = self.admin_context 

156 cfg = self.admin_config 

157 

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 ) 

181 

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 ) 

194 

195 # Footer 

196 self.footer_renderer = FooterRenderer( 

197 config=FooterConfig( 

198 copyright_holder=cfg.app_name, 

199 show_version=False, 

200 ), 

201 ) 

202 

203 # Toast 

204 self.toast_renderer = ServerToastChannel( 

205 config=ToastConfig( 

206 position="top-right", 

207 default_duration_ms=5000, 

208 ), 

209 ) 

210 

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] 

214 

215 if items: 

216 return [NavGroup(label=None, items=items)] 

217 return [] 

218 

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] 

222 

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 ) 

228 

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 ) 

240 

241 def render_head_content(self, **kwargs: Any) -> str: 

242 """Render additional head content. 

243 

244 Returns admin-specific CSS and theme variables. 

245 """ 

246 cfg = self.admin_config 

247 ctx = self.admin_context 

248 

249 parts: list[str] = [] 

250 

251 # Page title 

252 parts.append( 

253 f"<title>{escape(ctx.page_title)} | {escape(cfg.app_name)}</title>", 

254 ) 

255 

256 if ctx.page_description: 

257 parts.append( 

258 f'<meta name="description" content="{escape(ctx.page_description)}">', 

259 ) 

260 

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

270 

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) 

276 

277 # Lucide icons 

278 parts.append('<script src="https://unpkg.com/lucide@latest"></script>') 

279 

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 ) 

284 

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 ) 

301 

302 return "\n".join(parts) 

303 

304 def render_body_content(self, content: str = "", **kwargs: Any) -> str: 

305 """Render the body content. 

306 

307 Args: 

308 content: Main page content 

309 

310 Returns: 

311 Complete body inner HTML 

312 """ 

313 cfg = self.admin_config 

314 ctx = self.admin_context 

315 

316 parts: list[str] = [] 

317 

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 ) 

322 

323 # Layout wrapper 

324 collapsed_class = "sidebar-collapsed" if ctx.sidebar_collapsed else "" 

325 parts.append(f'<div class="admin-wrapper {collapsed_class}">') 

326 

327 # Sidebar 

328 parts.append(self.sidebar_renderer.render(ctx.current_path)) 

329 

330 # Main area 

331 parts.append('<div class="admin-main">') 

332 

333 # Header 

334 parts.append( 

335 self.header_renderer.render( 

336 notifications=ctx.notifications, 

337 unread_count=ctx.unread_count, 

338 ), 

339 ) 

340 

341 # Main content 

342 parts.append('<main id="main-content" class="admin-content">') 

343 parts.append(content) 

344 parts.append("</main>") 

345 

346 # Footer 

347 if cfg.show_footer: 

348 parts.append(self.footer_renderer.render()) 

349 

350 parts.append("</div>") # admin-main 

351 parts.append("</div>") # admin-wrapper 

352 

353 # Toast container with flash messages 

354 toasts = flash_to_toast(ctx.flash_messages) 

355 parts.append(self.toast_renderer.render_container(toasts)) 

356 

357 # Initialize Lucide icons 

358 parts.append(""" 

359 <script> 

360 document.addEventListener('DOMContentLoaded', function() { 

361 if (window.lucide) lucide.createIcons(); 

362 }); 

363 </script> 

364 """) 

365 

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

375 

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

384 

385 # Core admin JS (served from admin router's static mount) 

386 parts.append('<script src="/admin/static/js/admin.js"></script>') 

387 

388 return "\n".join(parts) 

389 

390 def get_body_attrs(self) -> dict[str, str]: 

391 """Get body tag attributes.""" 

392 cfg = self.admin_config 

393 ctx = self.admin_context 

394 

395 attrs = super().get_body_attrs() # type: ignore[misc] 

396 

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

404 

405 attrs["class"] = " ".join(classes) 

406 

407 return attrs 

408 

409 

410def admin_layout( 

411 content: str | Markup, 

412 config: AdminLayoutConfig, 

413 context: AdminLayoutContext, 

414) -> Markup: 

415 """Render the complete admin layout. 

416 

417 Convenience function that creates AdminLayout and renders. 

418 

419 Args: 

420 content: Page content to wrap 

421 config: Layout configuration 

422 context: Context (user, nav, etc.) 

423 

424 Returns: 

425 Complete HTML page markup 

426 """ 

427 layout = AdminLayout(config=config, context=context) 

428 return Markup(layout.render(str(content))) 

429 

430 

431__all__ = [ 

432 "AdminLayout", 

433 "AdminLayoutConfig", 

434 "AdminLayoutContext", 

435 "NavItemConfig", 

436 "admin_layout", 

437]