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
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-24 23:39 +0800
1"""Template utilities for lexigram-admin.
3Provides simple template rendering functions for standalone pages
4like login, error, etc. Uses StandaloneLayout for consistent styling.
5"""
7from typing import Any, cast
9from markupsafe import escape
11from lexigram.admin.ui.layouts import (
12 StandaloneLayout,
13 StandaloneLayoutConfig,
14 StandaloneLayoutContext,
15)
16from lexigram.ui import SubmitButton, TextInput, el, render_to_string
19def _flash_messages(
20 error: str,
21 notice: str = "",
22) -> list[tuple[str, str]]:
23 """Build the standalone flash list from error/notice strings.
25 Args:
26 error: Error message to display.
27 notice: Optional success notice to display.
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
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.
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.
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))
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.
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.
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)
129def _code_input(label: str) -> Any:
130 """Build the standard 6-digit one-time code input.
132 Args:
133 label: Field label text.
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 )
151def _email_badge(verified: bool) -> Any:
152 """Render an email verification status strip.
154 Args:
155 verified: True when the email address is verified.
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 )
174def _primary_link(label: str, href: str, extra_class: str = "") -> Any:
175 """Render a primary-button-styled anchor link.
177 Args:
178 label: Link text.
179 href: Destination URL.
180 extra_class: Additional CSS classes.
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 )
199def _auth_footer(*children: Any) -> Any:
200 """Render a centred footer line under an auth form.
202 Args:
203 *children: Link/text elements to render.
205 Returns:
206 An ``el()`` paragraph tree.
207 """
208 return el("p", *children, class_="mt-4 text-center text-sm")
211def _flash(error: str, notice: str) -> str:
212 """Render inline error/notice flash messages.
214 Args:
215 error: Error message to display (empty hides the block).
216 notice: Success notice to display (empty hides the block).
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)