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

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

27 

28 

29@dataclass 

30class StandaloneLayoutConfig(BaseLayoutConfig): 

31 """Configuration for standalone layout.""" 

32 

33 # Branding 

34 app_name: str = "Admin" 

35 app_logo: str | None = None 

36 app_logo_alt: str = "Logo" 

37 

38 # Features 

39 show_footer: bool = True 

40 show_logo: bool = True 

41 centered: bool = True 

42 

43 # Background 

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

45 

46 

47@dataclass 

48class StandaloneLayoutContext: 

49 """Context for standalone layout.""" 

50 

51 # Page 

52 page_title: str = "" 

53 page_description: str | None = None 

54 

55 # URLs 

56 base_url: str = "/admin" 

57 login_url: str = "/admin/login" 

58 

59 # Messages 

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

61 

62 # Extra 

63 extra_head: str = "" 

64 extra_body_end: str = "" 

65 

66 

67class StandaloneLayout(LayoutBase): 

68 """Standalone layout without sidebar. 

69 

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

71 """ 

72 

73 def __init__( 

74 self, 

75 config: StandaloneLayoutConfig | None = None, 

76 context: StandaloneLayoutContext | None = None, 

77 ): 

78 """Initialize standalone layout. 

79 

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

86 

87 # Initialize base 

88 super().__init__(self.standalone_config) 

89 

90 # Set up components 

91 self._setup_components() 

92 

93 def _setup_components(self) -> None: 

94 """Set up layout components.""" 

95 cfg = self.standalone_config 

96 

97 # Footer 

98 self.footer_renderer = FooterRenderer( 

99 config=FooterConfig( 

100 copyright_holder=cfg.app_name, 

101 show_version=False, 

102 ), 

103 ) 

104 

105 # Toast 

106 self.toast_renderer = ServerToastChannel( 

107 config=ToastConfig( 

108 position="top-center", 

109 ), 

110 ) 

111 

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

113 """Render additional head content.""" 

114 cfg = self.standalone_config 

115 ctx = self.standalone_context 

116 

117 parts: list[str] = [] 

118 

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

126 

127 if ctx.page_description: 

128 parts.append( 

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

130 ) 

131 

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) 

137 

138 # Lucide icons 

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

140 

141 # Extra head content 

142 if ctx.extra_head: 

143 parts.append(ctx.extra_head) 

144 

145 return "\n".join(parts) 

146 

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

148 """Render body content. 

149 

150 Args: 

151 content: Main content 

152 

153 Returns: 

154 Body inner HTML 

155 """ 

156 cfg = self.standalone_config 

157 ctx = self.standalone_context 

158 

159 parts: list[str] = [] 

160 

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 ) 

166 

167 # Header with logo 

168 if cfg.show_logo: 

169 parts.append(self._render_header()) 

170 

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

180 

181 # Footer 

182 if cfg.show_footer: 

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

184 

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

186 

187 # Toasts 

188 toasts = flash_to_toast(ctx.flash_messages) 

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

190 

191 # Init icons 

192 parts.append(""" 

193 <script> 

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

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

196 }); 

197 </script> 

198 """) 

199 

200 # Extra body end 

201 if ctx.extra_body_end: 

202 parts.append(ctx.extra_body_end) 

203 

204 return "\n".join(parts) 

205 

206 def _render_header(self) -> str: 

207 """Render simple header with logo.""" 

208 cfg = self.standalone_config 

209 ctx = self.standalone_context 

210 

211 parts: list[str] = [] 

212 

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 ) 

217 

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 ) 

226 

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

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

229 

230 return "\n".join(parts) 

231 

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 

237 

238 

239def standalone_layout( 

240 content: str | Markup, 

241 config: StandaloneLayoutConfig | None = None, 

242 context: StandaloneLayoutContext | None = None, 

243) -> Markup: 

244 """Render a standalone layout. 

245 

246 Convenience function for standalone pages. 

247 

248 Args: 

249 content: Page content 

250 config: Layout configuration 

251 context: Layout context 

252 

253 Returns: 

254 Complete HTML page 

255 """ 

256 layout = StandaloneLayout(config=config, context=context) 

257 return Markup(layout.render(str(content))) 

258 

259 

260__all__ = [ 

261 "StandaloneLayout", 

262 "StandaloneLayoutConfig", 

263 "StandaloneLayoutContext", 

264 "standalone_layout", 

265]