Coverage for src/lexigram/admin/ui/templates/shell.py: 8%
124 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-24 23:39 +0800
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-24 23:39 +0800
1from __future__ import annotations
3from typing import Any
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
11class AdminShell(Component):
12 """
13 Main responsive shell for Lexigram Admin.
14 Stitches together the Sidebar, TopBar and content area.
15 """
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
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__
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
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
92 items = []
93 current_section = None
95 for item in self.nav_items:
96 if isinstance(item, SidebarNavItem):
97 item = item.to_dict()
99 if not isinstance(item, dict):
100 if isinstance(item, tuple):
101 items.append(SidebarItem(label=item[0], href=item[1]))
102 continue
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
110 # Determine permission requirement
111 href = item.get("href", "")
112 required_permission = item.get("permission")
113 required_feature = item.get("feature")
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
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
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
141 if rbac_checker and not rbac_checker.has_permission(
142 self.user,
143 required_permission,
144 ):
145 continue
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 )
156 if current_section:
157 current_section.items.append(sidebar_item)
158 else:
159 items.append(sidebar_item)
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
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 )
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 )
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 )
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;
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 });
260 document.addEventListener('keydown', function(e) {
261 var results = document.getElementById('search-results');
262 if (!results || results.children.length === 0) return;
264 var items = results.querySelectorAll('.search-result-item');
265 if (items.length === 0) return;
267 // Escape closes search
268 if (e.key === 'Escape') {
269 results.innerHTML = '';
270 searchFocusedIndex = -1;
271 return;
272 }
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 }
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 });
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 });
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 });
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 });
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 )
352 sidebar_html = raw(render_to_string(sidebar))
353 topbar_html = raw(render_to_string(topbar))
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))
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 ""
373 flash_container = el("div", toast_node, id=Zones.FLASH.id)
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 )
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 )
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 )
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');
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 }});
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 }});
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 }});
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}');
576 let message = 'An error occurred';
577 let variant = 'error';
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 }}
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 }});
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 }}
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 }});
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 )
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}"
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 )