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
« prev ^ index » next coverage.py v7.13.5, created at 2026-08-13 22:07 +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 **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
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__
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
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
78 items = []
79 current_section = None
81 for item in self.nav_items:
82 if isinstance(item, SidebarNavItem):
83 item = item.to_dict()
85 if not isinstance(item, dict):
86 if isinstance(item, tuple):
87 items.append(SidebarItem(label=item[0], href=item[1]))
88 continue
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
96 # Determine permission requirement
97 href = item.get("href", "")
98 required_permission = item.get("permission")
99 required_feature = item.get("feature")
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
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
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
127 if rbac_checker and not rbac_checker.has_permission(
128 self.user,
129 required_permission,
130 ):
131 continue
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 )
142 if current_section:
143 current_section.items.append(sidebar_item)
144 else:
145 items.append(sidebar_item)
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
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 )
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 )
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 )
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;
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 });
239 document.addEventListener('keydown', function(e) {
240 var results = document.getElementById('search-results');
241 if (!results || results.children.length === 0) return;
243 var items = results.querySelectorAll('.search-result-item');
244 if (items.length === 0) return;
246 // Escape closes search
247 if (e.key === 'Escape') {
248 results.innerHTML = '';
249 searchFocusedIndex = -1;
250 return;
251 }
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 }
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 });
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 });
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 });
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 });
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 )
325 sidebar_html = raw(render_to_string(sidebar))
326 topbar_html = raw(render_to_string(topbar))
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))
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 ""
346 flash_container = el("div", toast_node, id=Zones.FLASH.id)
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 )
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 )
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');
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 }});
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}');
457 let message = 'An error occurred';
458 let variant = 'error';
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 }}
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 }});
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 }}
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 }});
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 )
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}"
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 )