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

124 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-21 15:04 +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 current_tenant_id: str | None = None, 

36 current_tenant_name: str = "", 

37 tenant_list: list[tuple[str, str]] | None = None, 

38 tenant_csrf_token: str | None = None, 

39 impersonation_active: bool = False, 

40 impersonation_target_id: str = "", 

41 csrf_token: str = "", 

42 **props: Any, 

43 ) -> None: 

44 super().__init__(**props) 

45 self.content = content 

46 self.title = title 

47 self.user = user or {} 

48 self.commands = commands or [] 

49 self.features = features or {} 

50 self.theme_css = theme_css 

51 self.site_name = site_name 

52 self.logo_url = logo_url 

53 self.dark_mode = dark_mode 

54 self.current_tenant_id = current_tenant_id 

55 self.current_tenant_name = current_tenant_name 

56 self.tenant_list = tenant_list or [] 

57 self.tenant_csrf_token = tenant_csrf_token 

58 self.impersonation_active = impersonation_active 

59 self.impersonation_target_id = impersonation_target_id 

60 self.csrf_token = csrf_token 

61 

62 # Standardize user as a dict for components 

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

64 if not self.user_dict and user: 

65 if isinstance(user, dict): 

66 self.user_dict = user 

67 elif hasattr(user, "model_dump"): 

68 self.user_dict = user.model_dump() 

69 elif hasattr(user, "dict"): 

70 self.user_dict = user.dict() 

71 elif hasattr(user, "__dict__"): 

72 self.user_dict = user.__dict__ 

73 

74 self.nav_items = nav_items or [] 

75 self.user_menu_items = user_menu_items or [] 

76 self.system_menu_items = system_menu_items or [] 

77 self.sidebar_instance = sidebar 

78 self.topbar_instance = topbar 

79 self.flash_messages = flash_messages or [] 

80 if breadcrumbs is None: 

81 breadcrumbs = [ 

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

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

84 ] 

85 self.breadcrumbs = breadcrumbs 

86 

87 def _prepare_navigation(self) -> Any: 

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

89 from lexigram.admin.navigation.types import SidebarNavItem 

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

91 

92 items = [] 

93 current_section = None 

94 

95 for item in self.nav_items: 

96 if isinstance(item, SidebarNavItem): 

97 item = item.to_dict() 

98 

99 if not isinstance(item, dict): 

100 if isinstance(item, tuple): 

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

102 continue 

103 

104 # Handle Group Header 

105 if item.get("is_group"): 

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

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

108 continue 

109 

110 # Determine permission requirement 

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

112 required_permission = item.get("permission") 

113 required_feature = item.get("feature") 

114 

115 # Check if required feature is enabled 

116 if required_feature: 

117 feature_key = f"{required_feature}_enabled" 

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

119 continue 

120 

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

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

123 parts = href.split("/") 

124 try: 

125 idx = parts.index("api") 

126 if len(parts) > idx + 1: 

127 resource = parts[idx + 1] 

128 required_permission = f"{resource}.read" 

129 except (ValueError, IndexError): 

130 pass 

131 

132 # Check permission if required 

133 if required_permission and self.user: 

134 try: 

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

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

137 ) 

138 except ImportError: 

139 rbac_checker = None 

140 

141 if rbac_checker and not rbac_checker.has_permission( 

142 self.user, 

143 required_permission, 

144 ): 

145 continue 

146 

147 # Build SidebarItem 

148 sidebar_item = SidebarItem( 

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

150 href=href, 

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

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

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

154 ) 

155 

156 if current_section: 

157 current_section.items.append(sidebar_item) 

158 else: 

159 items.append(sidebar_item) 

160 

161 # Filter out empty sections 

162 final_items = [] 

163 for item in items: 

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

165 continue 

166 final_items.append(item) 

167 return final_items 

168 

169 def render(self) -> Any: 

170 # 1. Prepare Sidebar 

171 sidebar = self.sidebar_instance 

172 if sidebar is None: 

173 items = self._prepare_navigation() 

174 sidebar = Sidebar( 

175 items=items, 

176 user=self.user_dict, 

177 user_menu_items=self.user_menu_items, 

178 system_menu_items=self.system_menu_items, 

179 raw_user=self.user, 

180 logo_url=self.logo_url, 

181 ) 

182 

183 # 2. Prepare TopBar 

184 topbar = self.topbar_instance 

185 if topbar is None: 

186 topbar = TopBar( 

187 title=self.title, 

188 site_name=self.site_name, 

189 user=self.user, 

190 user_menu_items=self.user_menu_items, 

191 current_tenant_id=self.current_tenant_id, 

192 current_tenant_name=self.current_tenant_name, 

193 tenant_list=self.tenant_list, 

194 tenant_csrf_token=self.tenant_csrf_token, 

195 ) 

196 

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

198 theme_style = ( 

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

200 if self.theme_css 

201 else "" 

202 ) 

203 

204 # 4. Search overlay styles and container 

205 search_overlay = raw( 

206 """ 

207 <style> 

208 [x-cloak] { 

209 display: none !important; 

210 } 

211 #search-results { 

212 position: fixed; 

213 top: 64px; 

214 left: 50%; 

215 transform: translateX(-50%); 

216 width: 90%; 

217 max-width: 640px; 

218 z-index: 45; 

219 pointer-events: none; 

220 } 

221 #search-results > * { 

222 pointer-events: auto; 

223 } 

224 .search-subtitle { 

225 display: block; 

226 font-size: 0.75rem; 

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

228 margin-top: 0.125rem; 

229 } 

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

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

232 outline-offset: -2px; 

233 } 

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

235 #search-results { 

236 top: 56px; 

237 width: 95%; 

238 } 

239 } 

240 </style> 

241 <script> 

242 (function() { 

243 if (window.__adminShellSearchInit) return; 

244 window.__adminShellSearchInit = 1; 

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

246 var searchFocusedIndex = -1; 

247 

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

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

250 if (!results) return; 

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

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

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

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

255 results.innerHTML = ''; 

256 searchFocusedIndex = -1; 

257 } 

258 }); 

259 

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

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

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

263 

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

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

266 

267 // Escape closes search 

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

269 results.innerHTML = ''; 

270 searchFocusedIndex = -1; 

271 return; 

272 } 

273 

274 // Arrow down 

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

276 e.preventDefault(); 

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

278 items[searchFocusedIndex].focus(); 

279 return; 

280 } 

281 

282 // Arrow up 

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

284 e.preventDefault(); 

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

286 items[searchFocusedIndex].focus(); 

287 return; 

288 } 

289 }); 

290 

291 // Loading indicator via HTMX events 

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

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

294 if (!searchInput) return; 

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

296 if (!results) return; 

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

298 searchFocusedIndex = -1; 

299 }); 

300 

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

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

303 if (!searchInput) return; 

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

305 if (!results) return; 

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

307 results.innerHTML = ''; 

308 } 

309 }); 

310 

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

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

313 if (searchInput) { 

314 searchFocusedIndex = -1; 

315 } 

316 }); 

317 

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

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

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

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

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

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

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

325 if (!el) return; 

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

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

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

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

330 var url; 

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

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

333 e.preventDefault(); 

334 if (window.htmx) { 

335 // Abort stale in-flight widget loads — they belong to the 

336 // page we are leaving and would otherwise hold browser 

337 // connection slots until the swap completes. 

338 document.querySelectorAll('.widget-body[hx-get]').forEach(function(w) { 

339 w.dispatchEvent(new Event('htmx:abort', { bubbles: true })); 

340 }); 

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

342 } else { 

343 location.href = url.href; 

344 } 

345 window.scrollTo(0, 0); 

346 }); 

347 })(); 

348 </script> 

349 """, 

350 ) 

351 

352 sidebar_html = raw(render_to_string(sidebar)) 

353 topbar_html = raw(render_to_string(topbar)) 

354 

355 content_node = self.content 

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

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

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

359 # and own their layout. 

360 content_inner = raw(render_to_string(content_node)) 

361 

362 # 4. Handle Notifications (Toast) 

363 # We wrap in a container to allow OOB swaps 

364 toasts = "" 

365 for msg in self.flash_messages: 

366 toasts += render_to_string( 

367 InlineToast( 

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

369 ), 

370 ) 

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

372 

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

374 

375 # 5. Build Responsive Layout 

376 sidebar_container = el( 

377 "div", 

378 # Overlay for mobile 

379 el( 

380 "div", 

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

382 x_show="sidebarOpen", 

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

384 x_transition_enter_start="opacity-0", 

385 x_transition_enter_end="opacity-100", 

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

387 x_transition_leave_start="opacity-100", 

388 x_transition_leave_end="opacity-0", 

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

390 aria_hidden="true", 

391 ), 

392 # Sidebar drawer 

393 el( 

394 "div", 

395 sidebar_html, 

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

397 **{ 

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

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

400 }, 

401 ), 

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

403 ) 

404 

405 impersonation_banner = ( 

406 el( 

407 "div", 

408 el( 

409 "span", 

410 f"Impersonating {self.impersonation_target_id}", 

411 class_="font-medium", 

412 ), 

413 el( 

414 "form", 

415 el( 

416 "input", 

417 type_="hidden", 

418 name="csrf_token", 

419 value=self.csrf_token or "", 

420 ), 

421 el( 

422 "button", 

423 "Stop impersonating", 

424 type="submit", 

425 class_=( 

426 "ml-4 px-3 py-1 text-xs font-medium rounded-md " 

427 "bg-white/20 hover:bg-white/30 transition-colors" 

428 ), 

429 ), 

430 method="post", 

431 action="/admin/impersonate/stop", 

432 class_="inline-flex items-center", 

433 ), 

434 class_=( 

435 "flex items-center justify-between px-4 py-2 text-sm text-white " 

436 "bg-amber-600 dark:bg-amber-700" 

437 ), 

438 ) 

439 if self.impersonation_active 

440 else "" 

441 ) 

442 

443 main_area = el( 

444 "div", 

445 topbar_html, 

446 impersonation_banner, 

447 # Breadcrumbs 

448 *( 

449 [ 

450 el( 

451 "div", 

452 el( 

453 "nav", 

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

455 [ 

456 el( 

457 "div", 

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

459 el( 

460 "a", 

461 { 

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

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

464 } 

465 if b["url"] 

466 else {}, 

467 b["label"], 

468 ), 

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

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

471 else "", 

472 ) 

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

474 ], 

475 ), 

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

477 ) 

478 ] 

479 if self.breadcrumbs 

480 else [] 

481 ), 

482 el( 

483 "main", 

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

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

486 ), 

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

488 ) 

489 

490 # Global HTMX loading indicator and error handling 

491 loading_bar = raw( 

492 f""" 

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

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

495 </div> 

496 <script> 

497 (function() {{ 

498 if (window.__adminShellInit) return; 

499 window.__adminShellInit = 1; 

500 // Apply the sidebar width class synchronously on first 

501 // load so the shell paints at the right width before 

502 // Alpine's deferred scripts start. Also pre-inject the 

503 // width utility rules: the Tailwind CDN regenerates its 

504 // stylesheet after htmx body swaps and would not have 

505 // these rules at swap time, flashing the sidebar at 

506 // auto width until it re-scans. 

507 var sideWidthStyle = document.createElement('style'); 

508 sideWidthStyle.textContent = '.w-24 {{ width: 6rem; }} .w-72 {{ width: 18rem; }}'; 

509 document.head.appendChild(sideWidthStyle); 

510 var aside = document.getElementById('main-sidebar'); 

511 if (aside) {{ 

512 aside.classList.add(localStorage.getItem('sidebarMini') === 'true' ? 'w-24' : 'w-72'); 

513 }} 

514 // Loading bar 

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

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

517  

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

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

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

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

522 localStorage.removeItem(key); 

523 }} 

524 }}); 

525 }} 

526 }}); 

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

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

529 }}); 

530 

531 // Body swaps can leave Alpine components partially initialized 

532 // (bindings registered but effects never run) because Alpine's 

533 // observer and this initTree race each other through the same 

534 // directive deferral queue. Wait for the observer to settle, 

535 // then run initTree — it can only be safely re-run on a scoped 

536 // subtree, not the whole body (see below). Only run on full 

537 // body swaps, not widget fragment swaps. 

538 document.body.addEventListener('htmx:afterSwap', function(evt) {{ 

539 if (evt.detail && evt.detail.elt !== document.body) return; 

540 setTimeout(function() {{ 

541 try {{ 

542 // Scope the re-init to the swapped content region 

543 // only. Alpine's initTree is not idempotent: it 

544 // re-registers every directive it walks, and 

545 // re-initializing the shell chrome (x-for 

546 // templates like the notification list) a second 

547 // time leaves duplicate cleanups behind, so the 

548 // second cleanup run throws (x-for reads 

549 // _x_lookup after the first cleanup deleted it). 

550 var mainContent = document.getElementById('main-content'); 

551 if (mainContent && window.Alpine) window.Alpine.initTree(mainContent); 

552 }} catch (err) {{ /* noop: initTree is best-effort */ }} 

553 }}, 200); 

554 }}); 

555 

556 // htmx's handleAttributes restore step runs right before 

557 // htmx:afterSettle and resets each node with an id back to 

558 // its pristine server markup -- which wipes the width class 

559 // Alpine's x-bind effect applied to the sidebar. Re-running 

560 // the aside's own effects restores the class through Alpine's 

561 // bookkeeping, so the mini-mode toggle keeps working. 

562 document.body.addEventListener('htmx:afterSettle', function(evt) {{ 

563 if (evt.detail && evt.detail.elt !== document.body) return; 

564 var aside = document.getElementById('main-sidebar'); 

565 if (aside && aside._x_runEffects) {{ 

566 aside._x_runEffects(); 

567 }} 

568 }}); 

569 

570 // Error handling for HTMX requests 

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

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

573 const status = xhr.status; 

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

575 

576 let message = 'An error occurred'; 

577 let variant = 'error'; 

578 

579 if (status === 403) {{ 

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

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

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

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

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

585 variant = 'warning'; 

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

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

588 variant = 'warning'; 

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

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

591 }} 

592 

593 // Display toast notification 

594 if (flashContainer) {{ 

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

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

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

598 <div class="flex-1"> 

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

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

601 </div> 

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

603 </div> 

604 </div>`; 

605 // Auto-dismiss after 5 seconds 

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

607 }} 

608 }}); 

609 

610 // Show toast helper 

611 function showToast(message, type) {{ 

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

613 if (!flashContainer) return; 

614 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'}}; 

615 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'}}; 

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

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

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

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

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

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

622 <div class="flex-1"> 

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

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

625 </div> 

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

627 </div> 

628 </div>`; 

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

630 }} 

631 

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

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

634 var detail = evt.detail; 

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

636 }}); 

637 

638 // Network error handling 

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

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

641 if (flashContainer) {{ 

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

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

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

645 <div class="flex-1"> 

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

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

648 </div> 

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

650 </div> 

651 </div>`; 

652 }} 

653 }}); 

654 }})(); 

655 </script> 

656 """, 

657 ) 

658 

659 dark_mode_init = self.dark_mode or "" 

660 if dark_mode_init == "dark": 

661 server_default_expr = "true" 

662 elif dark_mode_init == "light": 

663 server_default_expr = "false" 

664 else: 

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

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

667 

668 return el( 

669 "div", 

670 { 

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

672 + dm_expr 

673 + " }", 

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

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

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

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

678 }, 

679 loading_bar, 

680 theme_style, 

681 search_overlay, 

682 sidebar_container, 

683 main_area, 

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

685 flash_container, 

686 # Modal container for HTMX modals 

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

688 # Slide-over container for side panels 

689 el( 

690 "div", 

691 id=Zones.SLIDE_OVER.id, 

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

693 ), 

694 CommandPalette(commands=self.commands), 

695 )