Coverage for src/lexigram/admin/ui/layouts/standalone_layout.py: 89%
92 statements
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-21 14:54 +0800
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-21 14:54 +0800
1"""Standalone Layout - Layout for pages without sidebar.
3Used for login, error, and other standalone pages that don't
4need the full admin chrome.
5"""
7from __future__ import annotations
9from dataclasses import dataclass, field
10from typing import Any
12from markupsafe import Markup, escape
14from lexigram.admin.theme.tailwind import (
15 DARK_BOOTSTRAP_SCRIPT,
16 THEME_BRIDGE_SCRIPT,
17)
18from lexigram.admin.ui.layouts.components import (
19 FooterConfig,
20 FooterRenderer,
21 ServerToastChannel,
22 ToastConfig,
23 flash_to_toast,
24)
25from lexigram.ui import BaseLayoutConfig, LayoutBase
28@dataclass
29class StandaloneLayoutConfig(BaseLayoutConfig):
30 """Configuration for standalone layout."""
32 # Branding
33 app_name: str = "Admin"
34 app_logo: str | None = None
35 app_logo_alt: str = "Logo"
37 # Features
38 show_footer: bool = True
39 show_logo: bool = True
40 centered: bool = True
42 # Background
43 background_class: str = "bg-muted dark:bg-background"
46@dataclass
47class StandaloneLayoutContext:
48 """Context for standalone layout."""
50 # Page
51 page_title: str = ""
52 page_description: str | None = None
54 # URLs
55 base_url: str = "/admin"
56 login_url: str = "/admin/login"
58 # Messages
59 flash_messages: list[tuple[str, str]] = field(default_factory=list)
61 # Extra
62 extra_head: str = ""
63 extra_body_end: str = ""
66class StandaloneLayout(LayoutBase):
67 """Standalone layout without sidebar.
69 Used for login pages, error pages, and other standalone views.
70 """
72 def __init__(
73 self,
74 config: StandaloneLayoutConfig | None = None,
75 context: StandaloneLayoutContext | None = None,
76 ):
77 """Initialize standalone layout.
79 Args:
80 config: Layout configuration
81 context: Layout context
82 """
83 self.standalone_config = config or StandaloneLayoutConfig()
84 self.standalone_context = context or StandaloneLayoutContext()
86 # Initialize base
87 super().__init__(self.standalone_config)
89 # Set up components
90 self._setup_components()
92 def _setup_components(self) -> None:
93 """Set up layout components."""
94 cfg = self.standalone_config
96 # Footer
97 self.footer_renderer = FooterRenderer(
98 config=FooterConfig(
99 copyright_holder=cfg.app_name,
100 show_version=False,
101 ),
102 )
104 # Toast
105 self.toast_renderer = ServerToastChannel(
106 config=ToastConfig(
107 position="top-center",
108 ),
109 )
111 def render_head_content(self, **kwargs: Any) -> str:
112 """Render additional head content."""
113 cfg = self.standalone_config
114 ctx = self.standalone_context
116 parts: list[str] = []
118 # Title
119 if ctx.page_title:
120 parts.append(
121 f"<title>{escape(ctx.page_title)} | {escape(cfg.app_name)}</title>",
122 )
123 else:
124 parts.append(f"<title>{escape(cfg.app_name)}</title>")
126 if ctx.page_description:
127 parts.append(
128 f'<meta name="description" content="{escape(ctx.page_description)}">',
129 )
131 # Tailwind CSS via static build (utility classes for layout)
132 parts.append('<link rel="stylesheet" href="/admin/static/css/tailwind.css">')
133 parts.append(DARK_BOOTSTRAP_SCRIPT)
134 parts.append(THEME_BRIDGE_SCRIPT)
136 # Lucide icons
137 parts.append('<script src="https://unpkg.com/lucide@latest"></script>')
139 # Extra head content
140 if ctx.extra_head:
141 parts.append(ctx.extra_head)
143 return "\n".join(parts)
145 def render_body_content(self, content: str = "", **kwargs: Any) -> str:
146 """Render body content.
148 Args:
149 content: Main content
151 Returns:
152 Body inner HTML
153 """
154 cfg = self.standalone_config
155 ctx = self.standalone_context
157 parts: list[str] = []
159 # Container
160 centered_class = "min-h-screen flex flex-col" if cfg.centered else ""
161 parts.append(
162 f'<div class="standalone-wrapper {cfg.background_class} {centered_class}">',
163 )
165 # Header with logo
166 if cfg.show_logo:
167 parts.append(self._render_header())
169 # Main content
170 main_class = (
171 "flex-1 flex items-center justify-center w-full"
172 if cfg.centered
173 else "w-full"
174 )
175 parts.append(f'<main class="standalone-content {main_class}">')
176 parts.append(content)
177 parts.append("</main>")
179 # Footer
180 if cfg.show_footer:
181 parts.append(self.footer_renderer.render())
183 parts.append("</div>")
185 # Toasts
186 toasts = flash_to_toast(ctx.flash_messages)
187 parts.append(self.toast_renderer.render_container(toasts))
189 # Init icons
190 parts.append("""
191 <script>
192 document.addEventListener('DOMContentLoaded', function() {
193 if (window.lucide) lucide.createIcons();
194 });
195 </script>
196 """)
198 # Extra body end
199 if ctx.extra_body_end:
200 parts.append(ctx.extra_body_end)
202 return "\n".join(parts)
204 def _render_header(self) -> str:
205 """Render simple header with logo."""
206 cfg = self.standalone_config
207 ctx = self.standalone_context
209 parts: list[str] = []
211 parts.append('<header class="standalone-header py-2 text-center">')
212 parts.append(
213 f'<a href="{escape(ctx.base_url)}" class="inline-flex items-center gap-2">',
214 )
216 if cfg.app_logo:
217 parts.append(
218 f'<img src="{escape(cfg.app_logo)}" alt="{escape(cfg.app_logo_alt)}" class="h-10">',
219 )
220 else:
221 parts.append(
222 f'<span class="text-2xl font-bold text-foreground">{escape(cfg.app_name)}</span>',
223 )
225 parts.append("</a>")
226 parts.append("</header>")
228 return "\n".join(parts)
230 def get_body_attrs(self) -> dict[str, str]:
231 """Get body attributes."""
232 attrs = super().get_body_attrs() # type: ignore[misc]
233 attrs["class"] = "standalone-layout"
234 return attrs
237def standalone_layout(
238 content: str | Markup,
239 config: StandaloneLayoutConfig | None = None,
240 context: StandaloneLayoutContext | None = None,
241) -> Markup:
242 """Render a standalone layout.
244 Convenience function for standalone pages.
246 Args:
247 content: Page content
248 config: Layout configuration
249 context: Layout context
251 Returns:
252 Complete HTML page
253 """
254 layout = StandaloneLayout(config=config, context=context)
255 return Markup(layout.render(str(content))) # noqa: S704 — framework-composed trusted HTML
258__all__ = [
259 "StandaloneLayout",
260 "StandaloneLayoutConfig",
261 "StandaloneLayoutContext",
262 "standalone_layout",
263]