Coverage for src / lexigram / admin / ui / layouts / standalone_layout.py: 37%
93 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-08-13 22:14 +0800
« prev ^ index » next coverage.py v7.13.5, created at 2026-08-13 22:14 +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 TAILWIND_THEME_CONFIG,
17 THEME_BRIDGE_SCRIPT,
18)
19from lexigram.admin.ui.layouts.components import (
20 FooterConfig,
21 FooterRenderer,
22 ServerToastChannel,
23 ToastConfig,
24 flash_to_toast,
25)
26from lexigram.ui import BaseLayoutConfig, LayoutBase
29@dataclass
30class StandaloneLayoutConfig(BaseLayoutConfig):
31 """Configuration for standalone layout."""
33 # Branding
34 app_name: str = "Admin"
35 app_logo: str | None = None
36 app_logo_alt: str = "Logo"
38 # Features
39 show_footer: bool = True
40 show_logo: bool = True
41 centered: bool = True
43 # Background
44 background_class: str = "bg-muted dark:bg-background"
47@dataclass
48class StandaloneLayoutContext:
49 """Context for standalone layout."""
51 # Page
52 page_title: str = ""
53 page_description: str | None = None
55 # URLs
56 base_url: str = "/admin"
57 login_url: str = "/admin/login"
59 # Messages
60 flash_messages: list[tuple[str, str]] = field(default_factory=list)
62 # Extra
63 extra_head: str = ""
64 extra_body_end: str = ""
67class StandaloneLayout(LayoutBase):
68 """Standalone layout without sidebar.
70 Used for login pages, error pages, and other standalone views.
71 """
73 def __init__(
74 self,
75 config: StandaloneLayoutConfig | None = None,
76 context: StandaloneLayoutContext | None = None,
77 ):
78 """Initialize standalone layout.
80 Args:
81 config: Layout configuration
82 context: Layout context
83 """
84 self.standalone_config = config or StandaloneLayoutConfig()
85 self.standalone_context = context or StandaloneLayoutContext()
87 # Initialize base
88 super().__init__(self.standalone_config)
90 # Set up components
91 self._setup_components()
93 def _setup_components(self) -> None:
94 """Set up layout components."""
95 cfg = self.standalone_config
97 # Footer
98 self.footer_renderer = FooterRenderer(
99 config=FooterConfig(
100 copyright_holder=cfg.app_name,
101 show_version=False,
102 ),
103 )
105 # Toast
106 self.toast_renderer = ServerToastChannel(
107 config=ToastConfig(
108 position="top-center",
109 ),
110 )
112 def render_head_content(self, **kwargs: Any) -> str:
113 """Render additional head content."""
114 cfg = self.standalone_config
115 ctx = self.standalone_context
117 parts: list[str] = []
119 # Title
120 if ctx.page_title:
121 parts.append(
122 f"<title>{escape(ctx.page_title)} | {escape(cfg.app_name)}</title>",
123 )
124 else:
125 parts.append(f"<title>{escape(cfg.app_name)}</title>")
127 if ctx.page_description:
128 parts.append(
129 f'<meta name="description" content="{escape(ctx.page_description)}">',
130 )
132 # Tailwind CSS via CDN (utility classes for layout)
133 parts.append('<script src="https://cdn.tailwindcss.com"></script>')
134 parts.append(TAILWIND_THEME_CONFIG)
135 parts.append(DARK_BOOTSTRAP_SCRIPT)
136 parts.append(THEME_BRIDGE_SCRIPT)
138 # Lucide icons
139 parts.append('<script src="https://unpkg.com/lucide@latest"></script>')
141 # Extra head content
142 if ctx.extra_head:
143 parts.append(ctx.extra_head)
145 return "\n".join(parts)
147 def render_body_content(self, content: str = "", **kwargs: Any) -> str:
148 """Render body content.
150 Args:
151 content: Main content
153 Returns:
154 Body inner HTML
155 """
156 cfg = self.standalone_config
157 ctx = self.standalone_context
159 parts: list[str] = []
161 # Container
162 centered_class = "min-h-screen flex flex-col" if cfg.centered else ""
163 parts.append(
164 f'<div class="standalone-wrapper {cfg.background_class} {centered_class}">',
165 )
167 # Header with logo
168 if cfg.show_logo:
169 parts.append(self._render_header())
171 # Main content
172 main_class = (
173 "flex-1 flex items-center justify-center w-full"
174 if cfg.centered
175 else "w-full"
176 )
177 parts.append(f'<main class="standalone-content {main_class}">')
178 parts.append(content)
179 parts.append("</main>")
181 # Footer
182 if cfg.show_footer:
183 parts.append(self.footer_renderer.render())
185 parts.append("</div>")
187 # Toasts
188 toasts = flash_to_toast(ctx.flash_messages)
189 parts.append(self.toast_renderer.render_container(toasts))
191 # Init icons
192 parts.append("""
193 <script>
194 document.addEventListener('DOMContentLoaded', function() {
195 if (window.lucide) lucide.createIcons();
196 });
197 </script>
198 """)
200 # Extra body end
201 if ctx.extra_body_end:
202 parts.append(ctx.extra_body_end)
204 return "\n".join(parts)
206 def _render_header(self) -> str:
207 """Render simple header with logo."""
208 cfg = self.standalone_config
209 ctx = self.standalone_context
211 parts: list[str] = []
213 parts.append('<header class="standalone-header py-2 text-center">')
214 parts.append(
215 f'<a href="{escape(ctx.base_url)}" class="inline-flex items-center gap-2">',
216 )
218 if cfg.app_logo:
219 parts.append(
220 f'<img src="{escape(cfg.app_logo)}" alt="{escape(cfg.app_logo_alt)}" class="h-10">',
221 )
222 else:
223 parts.append(
224 f'<span class="text-2xl font-bold text-foreground">{escape(cfg.app_name)}</span>',
225 )
227 parts.append("</a>")
228 parts.append("</header>")
230 return "\n".join(parts)
232 def get_body_attrs(self) -> dict[str, str]:
233 """Get body attributes."""
234 attrs = super().get_body_attrs() # type: ignore[misc]
235 attrs["class"] = "standalone-layout"
236 return attrs
239def standalone_layout(
240 content: str | Markup,
241 config: StandaloneLayoutConfig | None = None,
242 context: StandaloneLayoutContext | None = None,
243) -> Markup:
244 """Render a standalone layout.
246 Convenience function for standalone pages.
248 Args:
249 content: Page content
250 config: Layout configuration
251 context: Layout context
253 Returns:
254 Complete HTML page
255 """
256 layout = StandaloneLayout(config=config, context=context)
257 return Markup(layout.render(str(content)))
260__all__ = [
261 "StandaloneLayout",
262 "StandaloneLayoutConfig",
263 "StandaloneLayoutContext",
264 "standalone_layout",
265]