Coverage for src / lexigram / admin / ui / templates / shell.py: 9%

116 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-08-13 22:07 +0800

1from __future__ import annotations 

2 

3from typing import Any 

4 

5from lexigram.admin.ui.organisms.command_palette import CommandPalette 

6from lexigram.admin.ui.organisms.sidebar import Sidebar, SidebarItem 

7from lexigram.admin.ui.organisms.topbar import TopBar 

8from lexigram.ui import Component, InlineToast, Zones, el, raw, render_to_string 

9 

10 

11class AdminShell(Component): 

12 """ 

13 Main responsive shell for Lexigram Admin. 

14 Stitches together the Sidebar, TopBar and content area. 

15 """ 

16 

17 def __init__( 

18 self, 

19 content: Any, 

20 title: str = "Admin", 

21 user: Any | None = None, 

22 nav_items: list | None = None, 

23 user_menu_items: list | None = None, 

24 system_menu_items: list | None = None, 

25 sidebar: Sidebar | None = None, 

26 topbar: TopBar | None = None, 

27 flash_messages: list[dict[str, str]] | None = None, 

28 breadcrumbs: list[dict[str, Any]] | None = None, 

29 commands: list[dict[str, str]] | None = None, 

30 features: dict[str, bool] | None = None, 

31 theme_css: str = "", 

32 site_name: str = "", 

33 logo_url: str = "", 

34 dark_mode: str = "", 

35 **props: Any, 

36 ) -> None: 

37 super().__init__(**props) 

38 self.content = content 

39 self.title = title 

40 self.user = user or {} 

41 self.commands = commands or [] 

42 self.features = features or {} 

43 self.theme_css = theme_css 

44 self.site_name = site_name 

45 self.logo_url = logo_url 

46 self.dark_mode = dark_mode 

47 

48 # Standardize user as a dict for components 

49 self.user_dict = props.pop("user_dict", {}) 

50 if not self.user_dict and user: 

51 if isinstance(user, dict): 

52 self.user_dict = user 

53 elif hasattr(user, "model_dump"): 

54 self.user_dict = user.model_dump() 

55 elif hasattr(user, "dict"): 

56 self.user_dict = user.dict() 

57 elif hasattr(user, "__dict__"): 

58 self.user_dict = user.__dict__ 

59 

60 self.nav_items = nav_items or [] 

61 self.user_menu_items = user_menu_items or [] 

62 self.system_menu_items = system_menu_items or [] 

63 self.sidebar_instance = sidebar 

64 self.topbar_instance = topbar 

65 self.flash_messages = flash_messages or [] 

66 if breadcrumbs is None: 

67 breadcrumbs = [ 

68 {"label": "Home", "url": "/admin/"}, 

69 {"label": title, "url": ""}, 

70 ] 

71 self.breadcrumbs = breadcrumbs 

72 

73 def _prepare_navigation(self) -> Any: 

74 """Transform raw nav_items into SidebarItem and SidebarSection instances.""" 

75 from lexigram.admin.navigation.types import SidebarNavItem 

76 from lexigram.admin.ui.organisms.sidebar import SidebarSection 

77 

78 items = [] 

79 current_section = None 

80 

81 for item in self.nav_items: 

82 if isinstance(item, SidebarNavItem): 

83 item = item.to_dict() 

84 

85 if not isinstance(item, dict): 

86 if isinstance(item, tuple): 

87 items.append(SidebarItem(label=item[0], href=item[1])) 

88 continue 

89 

90 # Handle Group Header 

91 if item.get("is_group"): 

92 current_section = SidebarSection(title=item.get("label", ""), items=[]) 

93 items.append(current_section) # type: ignore[arg-type] 

94 continue 

95 

96 # Determine permission requirement 

97 href = item.get("href", "") 

98 required_permission = item.get("permission") 

99 required_feature = item.get("feature") 

100 

101 # Check if required feature is enabled 

102 if required_feature: 

103 feature_key = f"{required_feature}_enabled" 

104 if not self.features.get(feature_key, True): 

105 continue 

106 

107 # If no explicit permission, try to infer from resource URL 

108 if not required_permission and href and "/admin//" in href: 

109 parts = href.split("/") 

110 try: 

111 idx = parts.index("api") 

112 if len(parts) > idx + 1: 

113 resource = parts[idx + 1] 

114 required_permission = f"{resource}.read" 

115 except (ValueError, IndexError): 

116 pass 

117 

118 # Check permission if required 

119 if required_permission and self.user: 

120 try: 

121 from lexigram.admin.auth.rbac import ( # type: ignore[import-untyped] 

122 RBACChecker, # noqa: F401 # imported for optional runtime check only 

123 ) 

124 except ImportError: 

125 rbac_checker = None 

126 

127 if rbac_checker and not rbac_checker.has_permission( 

128 self.user, 

129 required_permission, 

130 ): 

131 continue 

132 

133 # Build SidebarItem 

134 sidebar_item = SidebarItem( 

135 label=item.get("label", ""), 

136 href=href, 

137 icon=item.get("icon"), 

138 badge=item.get("badge"), 

139 active=item.get("active", False), 

140 ) 

141 

142 if current_section: 

143 current_section.items.append(sidebar_item) 

144 else: 

145 items.append(sidebar_item) 

146 

147 # Filter out empty sections 

148 final_items = [] 

149 for item in items: 

150 if isinstance(item, SidebarSection) and not item.items: 

151 continue 

152 final_items.append(item) 

153 return final_items 

154 

155 def render(self) -> Any: 

156 # 1. Prepare Sidebar 

157 sidebar = self.sidebar_instance 

158 if sidebar is None: 

159 items = self._prepare_navigation() 

160 sidebar = Sidebar( 

161 items=items, 

162 user=self.user_dict, 

163 user_menu_items=self.user_menu_items, 

164 system_menu_items=self.system_menu_items, 

165 raw_user=self.user, 

166 logo_url=self.logo_url, 

167 ) 

168 

169 # 2. Prepare TopBar 

170 topbar = self.topbar_instance 

171 if topbar is None: 

172 topbar = TopBar( 

173 title=self.title, 

174 site_name=self.site_name, 

175 user=self.user, 

176 user_menu_items=self.user_menu_items, 

177 ) 

178 

179 # 3. Theme styles (injected as inline style for runtime primary color) 

180 theme_style = ( 

181 raw(f"<style id='admin-theme-css'>{self.theme_css}</style>") 

182 if self.theme_css 

183 else "" 

184 ) 

185 

186 # 4. Search overlay styles and container 

187 search_overlay = raw( 

188 """ 

189 <style> 

190 #search-results { 

191 position: fixed; 

192 top: 64px; 

193 left: 50%; 

194 transform: translateX(-50%); 

195 width: 90%; 

196 max-width: 640px; 

197 z-index: 45; 

198 pointer-events: none; 

199 } 

200 #search-results > * { 

201 pointer-events: auto; 

202 } 

203 .search-subtitle { 

204 display: block; 

205 font-size: 0.75rem; 

206 color: var(--muted-foreground); 

207 margin-top: 0.125rem; 

208 } 

209 .search-result-item:focus-visible { 

210 outline: 2px solid var(--ring); 

211 outline-offset: -2px; 

212 } 

213 @media (max-width: 640px) { 

214 #search-results { 

215 top: 56px; 

216 width: 95%; 

217 } 

218 } 

219 </style> 

220 <script> 

221 (function() { 

222 if (window.__adminShellSearchInit) return; 

223 window.__adminShellSearchInit = 1; 

224 var searchResults = document.getElementById('search-results'); 

225 var searchFocusedIndex = -1; 

226 

227 document.addEventListener('click', function(e) { 

228 var results = document.getElementById('search-results'); 

229 if (!results) return; 

230 var searchInput = document.querySelector('[hx-get*="/admin/search"]'); 

231 if (results.children.length > 0 && 

232 !results.contains(e.target) && 

233 (!searchInput || !searchInput.contains(e.target))) { 

234 results.innerHTML = ''; 

235 searchFocusedIndex = -1; 

236 } 

237 }); 

238 

239 document.addEventListener('keydown', function(e) { 

240 var results = document.getElementById('search-results'); 

241 if (!results || results.children.length === 0) return; 

242 

243 var items = results.querySelectorAll('.search-result-item'); 

244 if (items.length === 0) return; 

245 

246 // Escape closes search 

247 if (e.key === 'Escape') { 

248 results.innerHTML = ''; 

249 searchFocusedIndex = -1; 

250 return; 

251 } 

252 

253 // Arrow down 

254 if (e.key === 'ArrowDown') { 

255 e.preventDefault(); 

256 searchFocusedIndex = Math.min(searchFocusedIndex + 1, items.length - 1); 

257 items[searchFocusedIndex].focus(); 

258 return; 

259 } 

260 

261 // Arrow up 

262 if (e.key === 'ArrowUp') { 

263 e.preventDefault(); 

264 searchFocusedIndex = Math.max(searchFocusedIndex - 1, 0); 

265 items[searchFocusedIndex].focus(); 

266 return; 

267 } 

268 }); 

269 

270 // Loading indicator via HTMX events 

271 document.addEventListener('htmx:beforeRequest', function(e) { 

272 var searchInput = e.detail?.elt?.closest('[hx-get*="/admin/search"]'); 

273 if (!searchInput) return; 

274 var results = document.getElementById('search-results'); 

275 if (!results) return; 

276 results.innerHTML = '<div class="search-loading text-center py-8 px-4 text-sm text-muted-foreground">Searching...</div>'; 

277 searchFocusedIndex = -1; 

278 }); 

279 

280 document.addEventListener('htmx:afterRequest', function(e) { 

281 var searchInput = e.detail?.elt?.closest('[hx-get*="/admin/search"]'); 

282 if (!searchInput) return; 

283 var results = document.getElementById('search-results'); 

284 if (!results) return; 

285 if (!results.querySelector('.search-results, .search-results-empty')) { 

286 results.innerHTML = ''; 

287 } 

288 }); 

289 

290 document.addEventListener('htmx:beforeSwap', function(e) { 

291 var searchInput = e.detail?.elt?.closest('[hx-get*="/admin/search"]'); 

292 if (searchInput) { 

293 searchFocusedIndex = -1; 

294 } 

295 }); 

296 

297 // SPA navigation: intercept plain same-origin link clicks and 

298 // swap the full page response into the body. Handled here via 

299 // document-level delegation so it survives body swaps. 

300 document.addEventListener('click', function(e) { 

301 if (e.defaultPrevented || e.button !== 0) return; 

302 if (e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return; 

303 var el = e.target instanceof Element ? e.target.closest('a[href]') : null; 

304 if (!el) return; 

305 if (el.getAttribute('target') === '_blank' || el.hasAttribute('download')) return; 

306 if (el.hasAttribute('hx-get') || el.hasAttribute('hx-post') || el.hasAttribute('hx-delete')) return; 

307 var href = el.getAttribute('href'); 

308 if (!href || href.startsWith('#')) return; 

309 var url; 

310 try { url = new URL(el.href, location.href); } catch (err) { return; } 

311 if (url.origin !== location.origin) return; 

312 e.preventDefault(); 

313 if (window.htmx) { 

314 window.htmx.ajax('GET', url.href, { target: 'body', swap: 'innerHTML' }); 

315 } else { 

316 location.href = url.href; 

317 } 

318 window.scrollTo(0, 0); 

319 }); 

320 })(); 

321 </script> 

322 """, 

323 ) 

324 

325 sidebar_html = raw(render_to_string(sidebar)) 

326 topbar_html = raw(render_to_string(topbar)) 

327 

328 content_node = self.content 

329 # Normalize content to an HTML string so the shell always exposes a 

330 # stable `#main-content` element (with constant classes) for HTMX 

331 # targets. Cluster centers render their sidebar inside the content 

332 # and own their layout. 

333 content_inner = raw(render_to_string(content_node)) 

334 

335 # 4. Handle Notifications (Toast) 

336 # We wrap in a container to allow OOB swaps 

337 toasts = "" 

338 for msg in self.flash_messages: 

339 toasts += render_to_string( 

340 InlineToast( 

341 msg.get("message", ""), toast_type=msg.get("category", "info") 

342 ), 

343 ) 

344 toast_node = raw(toasts) if toasts else "" 

345 

346 flash_container = el("div", toast_node, id=Zones.FLASH.id) 

347 

348 # 5. Build Responsive Layout 

349 sidebar_container = el( 

350 "div", 

351 # Overlay for mobile 

352 el( 

353 "div", 

354 class_="fixed inset-0 z-30 bg-muted/50 backdrop-blur-sm lg:hidden", 

355 x_show="sidebarOpen", 

356 x_transition_enter="transition-opacity ease-linear duration-300", 

357 x_transition_enter_start="opacity-0", 

358 x_transition_enter_end="opacity-100", 

359 x_transition_leave="transition-opacity ease-linear duration-300", 

360 x_transition_leave_start="opacity-100", 

361 x_transition_leave_end="opacity-0", 

362 **{"x-on:click": "sidebarOpen = false"}, 

363 aria_hidden="true", 

364 ), 

365 # Sidebar drawer 

366 el( 

367 "div", 

368 sidebar_html, 

369 class_="fixed inset-y-0 left-0 z-40 transform transition-transform duration-300 ease-in-out lg:translate-x-0 lg:static lg:inset-0 bg-transparent lg:pointer-events-auto", 

370 **{ 

371 # Enable pointer events when the sidebar is open on small screens; keep auto on lg 

372 "x-bind:class": "sidebarOpen ? 'translate-x-0 pointer-events-auto' : '-translate-x-full pointer-events-none'", 

373 }, 

374 ), 

375 class_="lg:flex lg:flex-shrink-0", 

376 ) 

377 

378 main_area = el( 

379 "div", 

380 topbar_html, 

381 # Breadcrumbs 

382 *( 

383 [ 

384 el( 

385 "div", 

386 el( 

387 "nav", 

388 {"class": "flex text-muted-foreground text-xs mb-4"}, 

389 [ 

390 el( 

391 "div", 

392 {"class": "flex items-center"}, 

393 el( 

394 "a", 

395 { 

396 "href": b["url"], 

397 "class": "hover:text-primary", 

398 } 

399 if b["url"] 

400 else {}, 

401 b["label"], 

402 ), 

403 el("span", {"class": "mx-2"}, "/") 

404 if i < len(self.breadcrumbs) - 1 

405 else "", 

406 ) 

407 for i, b in enumerate(self.breadcrumbs) 

408 ], 

409 ), 

410 class_="px-4 mt-2", 

411 ) 

412 ] 

413 if self.breadcrumbs 

414 else [] 

415 ), 

416 el( 

417 "main", 

418 el("div", content_inner, id="main-content", class_="px-4 py-4"), 

419 class_="flex-1 overflow-y-auto bg-muted dark:bg-background focus:outline-none transition-colors duration-300", 

420 ), 

421 class_="flex flex-col flex-1 min-w-0 overflow-hidden", 

422 ) 

423 

424 # Global HTMX loading indicator and error handling 

425 loading_bar = raw( 

426 f""" 

427 <div id="htmx-loading-bar" class="hidden fixed top-0 left-0 right-0 h-1 bg-primary-600 z-50 transition-opacity"> 

428 <div class="h-full bg-primary-400 animate-pulse"></div> 

429 </div> 

430 <script> 

431 (function() {{ 

432 if (window.__adminShellInit) return; 

433 window.__adminShellInit = 1; 

434 // Loading bar 

435 document.body.addEventListener('htmx:beforeRequest', function(e) {{ 

436 document.getElementById('htmx-loading-bar').classList.remove('hidden'); 

437  

438 // Cleanup filter storage on full page/resource navigation 

439 if (e.detail && e.detail.target && e.detail.target.id === 'main-content') {{ 

440 Object.keys(localStorage).forEach(function(key) {{ 

441 if (key.startsWith('lexigram_filter_') && key !== 'lexigram_filter_drawer_open') {{ 

442 localStorage.removeItem(key); 

443 }} 

444 }}); 

445 }} 

446 }}); 

447 document.body.addEventListener('htmx:afterRequest', function() {{ 

448 document.getElementById('htmx-loading-bar').classList.add('hidden'); 

449 }}); 

450 

451 // Error handling for HTMX requests 

452 document.body.addEventListener('htmx:responseError', function(evt) {{ 

453 const {{ xhr }} = evt.detail; 

454 const status = xhr.status; 

455 const flashContainer = document.getElementById('{Zones.FLASH.id}'); 

456 

457 let message = 'An error occurred'; 

458 let variant = 'error'; 

459 

460 if (status === 403) {{ 

461 message = 'Permission denied. You may need to log in again.'; 

462 }} else if (status === 404) {{ 

463 message = 'The requested resource was not found.'; 

464 }} else if (status === 422) {{ 

465 message = 'Please check your input and try again.'; 

466 variant = 'warning'; 

467 }} else if (status === 429) {{ 

468 message = 'Too many requests. Please wait and try again.'; 

469 variant = 'warning'; 

470 }} else if (status >= 500) {{ 

471 message = 'Server error. Please try again later.'; 

472 }} 

473 

474 // Display toast notification 

475 if (flashContainer) {{ 

476 flashContainer.innerHTML = `<div class="fixed top-4 right-4 z-50 p-4 rounded-lg shadow-lg bg-destructive/10 border border-destructive/30 text-destructive max-w-sm" role="alert"> 

477 <div class="flex items-start gap-3"> 

478 <svg class="w-5 h-5 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z" clip-rule="evenodd"></path></svg> 

479 <div class="flex-1"> 

480 <p class="font-medium">Error</p> 

481 <p class="text-sm mt-1">${{message}}</p> 

482 </div> 

483 <button onclick="this.closest('[role=alert]').remove()" class="text-destructive hover:text-destructive"><svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"></path></svg></button> 

484 </div> 

485 </div>`; 

486 // Auto-dismiss after 5 seconds 

487 setTimeout(() => {{ if (flashContainer.firstChild) flashContainer.innerHTML = ''; }}, 5000); 

488 }} 

489 }}); 

490 

491 // Show toast helper 

492 function showToast(message, type) {{ 

493 const flashContainer = document.getElementById('{Zones.FLASH.id}'); 

494 if (!flashContainer) return; 

495 const bgColors = {{success: 'bg-success/10 border border-success/30 text-success', error: 'bg-destructive/10 border border-destructive/30 text-destructive', warning: 'bg-warning/10 border border-warning/30 text-warning', info: 'bg-info/10 border border-info/30 text-info'}}; 

496 const icons = {{success: 'M16.707 5.293a1 1 0 010 1.414l-8 8a1 1 0 01-1.414 0l-4-4a1 1 0 011.414-1.414L8 12.586l7.293-7.293a1 1 0 011.414 0z', error: 'M10 18a8 8 0 100-16 8 8 0 000 16zM8.707 7.293a1 1 0 00-1.414 1.414L8.586 10l-1.293 1.293a1 1 0 101.414 1.414L10 11.414l1.293 1.293a1 1 0 001.414-1.414L11.414 10l1.293-1.293a1 1 0 00-1.414-1.414L10 8.586 8.707 7.293z', warning: 'M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z', info: 'M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z'}}; 

497 const colorClass = bgColors[type] || bgColors.info; 

498 const iconPath = icons[type] || icons.info; 

499 const labels = {{success: 'Success', error: 'Error', warning: 'Warning', info: 'Info'}}; 

500 flashContainer.innerHTML = `<div class="fixed top-4 right-4 z-50 p-4 rounded-lg shadow-lg ${{colorClass}} border max-w-sm" role="alert"> 

501 <div class="flex items-start gap-3"> 

502 <svg class="w-5 h-5 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="${{iconPath}}" clip-rule="evenodd"></path></svg> 

503 <div class="flex-1"> 

504 <p class="font-medium">${{labels[type] || 'Info'}}</p> 

505 <p class="text-sm mt-1">${{message}}</p> 

506 </div> 

507 <button onclick="this.closest('[role=alert]').remove()" class="opacity-60 hover:opacity-100"><svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"></path></svg></button> 

508 </div> 

509 </div>`; 

510 setTimeout(() => {{ if (flashContainer.firstChild) flashContainer.innerHTML = ''; }}, 5000); 

511 }} 

512 

513 // Listen for show-toast custom event (fired via HX-Trigger) 

514 document.body.addEventListener('show-toast', function(evt) {{ 

515 var detail = evt.detail; 

516 showToast(detail.message || 'Success', detail.type || 'success'); 

517 }}); 

518 

519 // Network error handling 

520 document.body.addEventListener('htmx:sendError', function(evt) {{ 

521 const flashContainer = document.getElementById('{Zones.FLASH.id}'); 

522 if (flashContainer) {{ 

523 flashContainer.innerHTML = `<div class="fixed top-4 right-4 z-50 p-4 rounded-lg shadow-lg bg-warning/10 border border-warning/30 text-warning max-w-sm" role="alert"> 

524 <div class="flex items-start gap-3"> 

525 <svg class="w-5 h-5 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd"></path></svg> 

526 <div class="flex-1"> 

527 <p class="font-medium">Network Error</p> 

528 <p class="text-sm mt-1">Unable to connect. Check your internet connection.</p> 

529 </div> 

530 <button onclick="this.closest('[role=alert]').remove()" class="text-warning hover:text-warning/90"><svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20"><path fill-rule="evenodd" d="M4.293 4.293a1 1 0 011.414 0L10 8.586l4.293-4.293a1 1 0 111.414 1.414L11.414 10l4.293 4.293a1 1 0 01-1.414 1.414L10 11.414l-4.293 4.293a1 1 0 01-1.414-1.414L8.586 10 4.293 5.707a1 1 0 010-1.414z" clip-rule="evenodd"></path></svg></button> 

531 </div> 

532 </div>`; 

533 }} 

534 }}); 

535 }})(); 

536 </script> 

537 """, 

538 ) 

539 

540 dark_mode_init = self.dark_mode or "" 

541 if dark_mode_init == "dark": 

542 server_default_expr = "true" 

543 elif dark_mode_init == "light": 

544 server_default_expr = "false" 

545 else: 

546 server_default_expr = "(window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches)" 

547 dm_expr = f"localStorage.getItem('darkMode') !== null ? localStorage.getItem('darkMode') === 'true' : {server_default_expr}" 

548 

549 return el( 

550 "div", 

551 { 

552 "x-data": "{ sidebarOpen: false, sidebarMini: localStorage.getItem('sidebarMini') === 'true', darkMode: " 

553 + dm_expr 

554 + " }", 

555 "x-init": "$watch('darkMode', val => { localStorage.setItem('darkMode', val); document.documentElement.classList.toggle('dark', val) }); $watch('sidebarMini', val => localStorage.setItem('sidebarMini', val)); document.documentElement.classList.toggle('dark', darkMode)", 

556 "x-on:darkmode-change.window": "darkMode = $event.detail.dark", 

557 "class": "flex h-screen overflow-hidden bg-background transition-colors duration-300 font-sans text-foreground", 

558 "x-on:beforeunload.window": "window.notificationEventSource?.close()", 

559 }, 

560 loading_bar, 

561 theme_style, 

562 search_overlay, 

563 sidebar_container, 

564 main_area, 

565 el("div", id="search-results"), 

566 flash_container, 

567 # Modal container for HTMX modals 

568 el("div", id=Zones.MODAL.id, class_="absolute z-[100]"), 

569 # Slide-over container for side panels 

570 el( 

571 "div", 

572 id=Zones.SLIDE_OVER.id, 

573 class_="fixed inset-0 z-[100] pointer-events-none", 

574 ), 

575 CommandPalette(commands=self.commands), 

576 )