Coverage for src/lexigram/admin/ui/layouts/standalone_layout.py: 18%

92 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-24 23:31 +0800

1"""Standalone Layout - Layout for pages without sidebar. 

2 

3Used for login, error, and other standalone pages that don't 

4need the full admin chrome. 

5""" 

6 

7from __future__ import annotations 

8 

9from dataclasses import dataclass, field 

10from typing import Any 

11 

12from markupsafe import Markup, escape 

13 

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 

26 

27 

28@dataclass 

29class StandaloneLayoutConfig(BaseLayoutConfig): 

30 """Configuration for standalone layout.""" 

31 

32 # Branding 

33 app_name: str = "Admin" 

34 app_logo: str | None = None 

35 app_logo_alt: str = "Logo" 

36 

37 # Features 

38 show_footer: bool = True 

39 show_logo: bool = True 

40 centered: bool = True 

41 

42 # Background 

43 background_class: str = "bg-muted dark:bg-background" 

44 

45 

46@dataclass 

47class StandaloneLayoutContext: 

48 """Context for standalone layout.""" 

49 

50 # Page 

51 page_title: str = "" 

52 page_description: str | None = None 

53 

54 # URLs 

55 base_url: str = "/admin" 

56 login_url: str = "/admin/login" 

57 

58 # Messages 

59 flash_messages: list[tuple[str, str]] = field(default_factory=list) 

60 

61 # Extra 

62 extra_head: str = "" 

63 extra_body_end: str = "" 

64 

65 

66class StandaloneLayout(LayoutBase): 

67 """Standalone layout without sidebar. 

68 

69 Used for login pages, error pages, and other standalone views. 

70 """ 

71 

72 def __init__( 

73 self, 

74 config: StandaloneLayoutConfig | None = None, 

75 context: StandaloneLayoutContext | None = None, 

76 ): 

77 """Initialize standalone layout. 

78 

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() 

85 

86 # Initialize base 

87 super().__init__(self.standalone_config) 

88 

89 # Set up components 

90 self._setup_components() 

91 

92 def _setup_components(self) -> None: 

93 """Set up layout components.""" 

94 cfg = self.standalone_config 

95 

96 # Footer 

97 self.footer_renderer = FooterRenderer( 

98 config=FooterConfig( 

99 copyright_holder=cfg.app_name, 

100 show_version=False, 

101 ), 

102 ) 

103 

104 # Toast 

105 self.toast_renderer = ServerToastChannel( 

106 config=ToastConfig( 

107 position="top-center", 

108 ), 

109 ) 

110 

111 def render_head_content(self, **kwargs: Any) -> str: 

112 """Render additional head content.""" 

113 cfg = self.standalone_config 

114 ctx = self.standalone_context 

115 

116 parts: list[str] = [] 

117 

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

125 

126 if ctx.page_description: 

127 parts.append( 

128 f'<meta name="description" content="{escape(ctx.page_description)}">', 

129 ) 

130 

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) 

135 

136 # Lucide icons 

137 parts.append('<script src="https://unpkg.com/lucide@latest"></script>') 

138 

139 # Extra head content 

140 if ctx.extra_head: 

141 parts.append(ctx.extra_head) 

142 

143 return "\n".join(parts) 

144 

145 def render_body_content(self, content: str = "", **kwargs: Any) -> str: 

146 """Render body content. 

147 

148 Args: 

149 content: Main content 

150 

151 Returns: 

152 Body inner HTML 

153 """ 

154 cfg = self.standalone_config 

155 ctx = self.standalone_context 

156 

157 parts: list[str] = [] 

158 

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 ) 

164 

165 # Header with logo 

166 if cfg.show_logo: 

167 parts.append(self._render_header()) 

168 

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

178 

179 # Footer 

180 if cfg.show_footer: 

181 parts.append(self.footer_renderer.render()) 

182 

183 parts.append("</div>") 

184 

185 # Toasts 

186 toasts = flash_to_toast(ctx.flash_messages) 

187 parts.append(self.toast_renderer.render_container(toasts)) 

188 

189 # Init icons 

190 parts.append(""" 

191 <script> 

192 document.addEventListener('DOMContentLoaded', function() { 

193 if (window.lucide) lucide.createIcons(); 

194 }); 

195 </script> 

196 """) 

197 

198 # Extra body end 

199 if ctx.extra_body_end: 

200 parts.append(ctx.extra_body_end) 

201 

202 return "\n".join(parts) 

203 

204 def _render_header(self) -> str: 

205 """Render simple header with logo.""" 

206 cfg = self.standalone_config 

207 ctx = self.standalone_context 

208 

209 parts: list[str] = [] 

210 

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 ) 

215 

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 ) 

224 

225 parts.append("</a>") 

226 parts.append("</header>") 

227 

228 return "\n".join(parts) 

229 

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 

235 

236 

237def standalone_layout( 

238 content: str | Markup, 

239 config: StandaloneLayoutConfig | None = None, 

240 context: StandaloneLayoutContext | None = None, 

241) -> Markup: 

242 """Render a standalone layout. 

243 

244 Convenience function for standalone pages. 

245 

246 Args: 

247 content: Page content 

248 config: Layout configuration 

249 context: Layout context 

250 

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 

256 

257 

258__all__ = [ 

259 "StandaloneLayout", 

260 "StandaloneLayoutConfig", 

261 "StandaloneLayoutContext", 

262 "standalone_layout", 

263]