Coverage for src/lexigram/admin/lib/template/layout.py: 30%

40 statements  

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

1"""Template utilities for lexigram-admin. 

2 

3Provides simple template rendering functions for standalone pages 

4like login, error, etc. Uses StandaloneLayout for consistent styling. 

5""" 

6 

7from typing import Any, cast 

8 

9from markupsafe import escape 

10 

11from lexigram.admin.ui.layouts import ( 

12 StandaloneLayout, 

13 StandaloneLayoutConfig, 

14 StandaloneLayoutContext, 

15) 

16from lexigram.ui import SubmitButton, TextInput, el, render_to_string 

17 

18 

19def _flash_messages( 

20 error: str, 

21 notice: str = "", 

22) -> list[tuple[str, str]]: 

23 """Build the standalone flash list from error/notice strings. 

24 

25 Args: 

26 error: Error message to display. 

27 notice: Optional success notice to display. 

28 

29 Returns: 

30 List of (category, message) tuples for the standalone layout. 

31 """ 

32 messages: list[tuple[str, str]] = [] 

33 if error: 

34 messages.append(("error", error)) 

35 if notice: 

36 messages.append(("success", notice)) 

37 return messages 

38 

39 

40def _standalone_card( 

41 page_title: str, 

42 heading: str, 

43 copy: str, 

44 children: list[Any], 

45 *, 

46 site_name: str = "Lexigram Admin", 

47 flash_messages: list[tuple[str, str]] | None = None, 

48) -> str: 

49 """Render a centred standalone auth card inside the standalone layout. 

50 

51 Args: 

52 page_title: Document title used by the standalone layout. 

53 heading: Card heading text. 

54 copy: Subtitle text rendered under the heading. 

55 children: Body elements rendered inside the card. 

56 site_name: Site name for branding. 

57 flash_messages: Error/success flashes rendered above the card. 

58 

59 Returns: 

60 Full standalone HTML document. 

61 """ 

62 content = el( 

63 "div", 

64 el( 

65 "div", 

66 el("h1", heading, class_="text-2xl font-bold text-foreground mb-2"), 

67 el("p", copy, class_="text-sm text-muted-foreground"), 

68 class_="text-center mb-6", 

69 ), 

70 *children, 

71 class_="w-full max-w-md bg-card border border-border rounded-lg shadow-lg p-8", 

72 ) 

73 layout = StandaloneLayout( 

74 config=StandaloneLayoutConfig( 

75 app_name=site_name, 

76 show_footer=True, 

77 centered=True, 

78 ), 

79 context=StandaloneLayoutContext( 

80 page_title=page_title, 

81 flash_messages=flash_messages or [], 

82 ), 

83 ) 

84 return layout.render(render_to_string(content)) 

85 

86 

87def _auth_form( 

88 action: str, 

89 csrf_token: str, 

90 fields: list[Any], 

91 submit_label: str, 

92 *, 

93 hidden: list[tuple[str, str]] | None = None, 

94 submit_variant: str = "default", 

95 footer: Any | None = None, 

96) -> Any: 

97 """Build a standard POST form with a CSRF field and submit button. 

98 

99 Args: 

100 action: Form action URL. 

101 csrf_token: CSRF token embedded as a hidden field. 

102 fields: Input components rendered in vertical order. 

103 submit_label: Submit button text. 

104 hidden: Extra hidden (name, value) fields. 

105 submit_variant: Button variant (default or destructive). 

106 footer: Optional element rendered after the submit button. 

107 

108 Returns: 

109 An ``el()`` form tree. 

110 """ 

111 children: list[Any] = [ 

112 el("input", type="hidden", name="csrf_token", value=csrf_token), 

113 ] 

114 children.extend( 

115 el("input", type="hidden", name=name, value=value) 

116 for name, value in (hidden or []) 

117 ) 

118 children.extend(el("div", field, class_="mb-4") for field in fields) 

119 children.append( 

120 SubmitButton( 

121 label=submit_label, variant=cast("Any", submit_variant), class_="w-full" 

122 ) 

123 ) 

124 if footer is not None: 

125 children.append(footer) 

126 return el("form", *children, method="post", action=action) 

127 

128 

129def _code_input(label: str) -> Any: 

130 """Build the standard 6-digit one-time code input. 

131 

132 Args: 

133 label: Field label text. 

134 

135 Returns: 

136 An ``el()`` tree with the labelled code input. 

137 """ 

138 return TextInput( 

139 name="code", 

140 label=label, 

141 inputmode="numeric", 

142 autocomplete="one-time-code", 

143 pattern="[0-9]{6}", 

144 maxlength=6, 

145 placeholder="123456", 

146 required=True, 

147 autofocus=True, 

148 ) 

149 

150 

151def _email_badge(verified: bool) -> Any: 

152 """Render an email verification status strip. 

153 

154 Args: 

155 verified: True when the email address is verified. 

156 

157 Returns: 

158 An ``el()`` tree with the status text. 

159 """ 

160 badge_text = "verified" if verified else "not verified" 

161 badge_class = "text-green-600" if verified else "text-foreground" 

162 return el( 

163 "div", 

164 el( 

165 "p", 

166 "Email address: ", 

167 el("span", badge_text, class_=f"font-medium {badge_class}"), 

168 class_="text-sm text-foreground", 

169 ), 

170 class_="mb-6 p-3 rounded-md bg-muted", 

171 ) 

172 

173 

174def _primary_link(label: str, href: str, extra_class: str = "") -> Any: 

175 """Render a primary-button-styled anchor link. 

176 

177 Args: 

178 label: Link text. 

179 href: Destination URL. 

180 extra_class: Additional CSS classes. 

181 

182 Returns: 

183 An ``el()`` anchor tree. 

184 """ 

185 return el( 

186 "a", 

187 label, 

188 href=href, 

189 class_=( 

190 "inline-flex items-center justify-center gap-2 whitespace-nowrap " 

191 "rounded-md text-sm font-medium ring-offset-background transition-colors " 

192 "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring " 

193 "focus-visible:ring-offset-2 bg-primary text-primary-foreground " 

194 f"hover:bg-primary/90 h-10 px-4 py-2 {extra_class}" 

195 ), 

196 ) 

197 

198 

199def _auth_footer(*children: Any) -> Any: 

200 """Render a centred footer line under an auth form. 

201 

202 Args: 

203 *children: Link/text elements to render. 

204 

205 Returns: 

206 An ``el()`` paragraph tree. 

207 """ 

208 return el("p", *children, class_="mt-4 text-center text-sm") 

209 

210 

211def _flash(error: str, notice: str) -> str: 

212 """Render inline error/notice flash messages. 

213 

214 Args: 

215 error: Error message to display (empty hides the block). 

216 notice: Success notice to display (empty hides the block). 

217 

218 Returns: 

219 HTML string with the flash blocks. 

220 """ 

221 parts = [] 

222 if error: 

223 parts.append(f'<div class="text-sm text-destructive">{escape(error)}</div>') 

224 if notice: 

225 parts.append(f'<div class="text-sm text-emerald-600">{escape(notice)}</div>') 

226 return "\n".join(parts)