Coverage for src / lexigram / admin / lib / template.py: 15%
52 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"""Template utilities for lexigram-admin.
3Provides simple template rendering functions for standalone pages
4like login, error, etc. Uses StandaloneLayout for consistent styling.
5"""
7from __future__ import annotations
9from typing import Any
11from markupsafe import escape
13from lexigram.admin.ui.layouts import (
14 StandaloneLayout,
15 StandaloneLayoutConfig,
16 StandaloneLayoutContext,
17)
20def render_login_page(
21 next_url: str = "/admin/",
22 error: str = "",
23 site_name: str = "Lexigram Admin",
24 csrf_token: str = "",
25) -> str:
26 """Render a standalone login page.
28 Args:
29 next_url: URL to redirect to after login.
30 error: Error message to display.
31 site_name: Site name for branding.
32 csrf_token: CSRF token to embed as a hidden form field.
34 Returns:
35 HTML string for login page.
36 """
37 flash_messages: list[tuple[str, str]] = []
38 if error:
39 flash_messages.append(("error", error))
41 config = StandaloneLayoutConfig(
42 app_name=site_name,
43 show_footer=True,
44 centered=True,
45 )
46 context = StandaloneLayoutContext(
47 page_title="Login",
48 flash_messages=flash_messages,
49 )
51 content = f"""
52 <div class="w-full max-w-md bg-card border border-border rounded-lg shadow-lg p-8">
53 <div class="text-center mb-6">
54 <h1 class="text-2xl font-bold text-foreground mb-2">Sign In</h1>
55 <p class="text-sm text-muted-foreground">Please sign in to continue</p>
56 </div>
57 <form method="post" action="/admin/login">
58 <input type="hidden" name="csrf_token" value="{escape(csrf_token)}">
59 <input type="hidden" name="next" value="{escape(next_url)}">
60 <div class="mb-4">
61 <label for="email" class="block text-sm font-medium text-foreground mb-2">Email</label>
62 <input type="email" id="email" name="email" placeholder="your@email.com" required
63 class="w-full px-3 py-2 rounded-md border border-input bg-card text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring">
64 </div>
65 <div class="mb-4">
66 <label for="password" class="block text-sm font-medium text-foreground mb-2">Password</label>
67 <input type="password" id="password" name="password" placeholder="Password" required
68 class="w-full px-3 py-2 rounded-md border border-input bg-card text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring">
69 </div>
70 <button type="submit"
71 class="w-full py-2.5 rounded-md bg-primary text-primary-foreground font-medium cursor-pointer hover:bg-primary/90 transition-colors focus:outline-none focus:ring-2 focus:ring-ring">Sign In</button>
72 </form>
73 </div>
74 """
76 layout = StandaloneLayout(config=config, context=context)
77 return layout.render(content)
80def render_setup_page(
81 error: str = "",
82 site_name: str = "Lexigram Admin",
83 locked: bool = False,
84 csrf_token: str = "",
85) -> str:
86 """Render the first-run admin setup page.
88 Args:
89 error: Optional error message to display.
90 site_name: Site name for branding.
91 locked: When True, shows a locked state (setup already complete).
92 csrf_token: CSRF token to embed as a hidden form field (only used when not locked).
94 Returns:
95 HTML string for the setup page.
96 """
97 flash_messages: list[tuple[str, str]] = []
98 if error:
99 category = "warning" if locked else "error"
100 flash_messages.append((category, error))
102 config = StandaloneLayoutConfig(
103 app_name=site_name,
104 show_footer=True,
105 centered=True,
106 )
107 context = StandaloneLayoutContext(
108 page_title="Initial Setup" if not locked else "Setup Complete",
109 flash_messages=flash_messages,
110 )
112 if locked:
113 content = """
114 <div class="w-full max-w-md bg-card border border-border rounded-lg shadow-lg p-8 text-center">
115 <h1 class="text-2xl font-bold text-foreground mb-2">✅ Setup Complete</h1>
116 <p class="text-sm text-muted-foreground mb-4">An administrator account already exists.<br>
117 Please log in with your credentials.</p>
118 <a href="/admin/login"
119 class="block w-full py-2.5 rounded-md bg-primary text-primary-foreground font-medium text-center hover:bg-primary/90 transition-colors">Go to Login</a>
120 </div>
121 """
122 else:
123 content = f"""
124 <div class="w-full max-w-md bg-card border border-border rounded-lg shadow-lg p-8">
125 <div class="text-center mb-6">
126 <h1 class="text-2xl font-bold text-foreground mb-2">Welcome to {escape(site_name)}</h1>
127 <p class="text-sm text-muted-foreground">Create your administrator account to get started.</p>
128 </div>
129 <form method="post" action="/admin/setup">
130 <input type="hidden" name="csrf_token" value="{escape(csrf_token)}">
131 <div class="mb-4">
132 <label for="name" class="block text-sm font-medium text-foreground mb-2">Full Name</label>
133 <input type="text" id="name" name="name" placeholder="Your Name" required
134 class="w-full px-3 py-2 rounded-md border border-input bg-card text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring">
135 </div>
136 <div class="mb-4">
137 <label for="email" class="block text-sm font-medium text-foreground mb-2">Email Address</label>
138 <input type="email" id="email" name="email"
139 placeholder="admin@example.com" required
140 class="w-full px-3 py-2 rounded-md border border-input bg-card text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring">
141 </div>
142 <div class="mb-4">
143 <label for="password" class="block text-sm font-medium text-foreground mb-2">Password</label>
144 <input type="password" id="password" name="password"
145 placeholder="At least 8 characters" required minlength="8"
146 class="w-full px-3 py-2 rounded-md border border-input bg-card text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring">
147 <p class="text-xs text-muted-foreground mt-1">Minimum 8 characters.</p>
148 </div>
149 <div class="mb-4">
150 <label for="confirm_password" class="block text-sm font-medium text-foreground mb-2">Confirm Password</label>
151 <input type="password" id="confirm_password" name="confirm_password"
152 placeholder="Repeat password" required minlength="8"
153 class="w-full px-3 py-2 rounded-md border border-input bg-card text-foreground placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:border-ring">
154 </div>
155 <button type="submit"
156 class="w-full py-2.5 rounded-md bg-primary text-primary-foreground font-medium cursor-pointer hover:bg-primary/90 transition-colors focus:outline-none focus:ring-2 focus:ring-ring">Create Account</button>
157 </form>
158 </div>
159 """
161 layout = StandaloneLayout(config=config, context=context)
162 return layout.render(content)
165def render_error_page(
166 status_code: int = 500,
167 title: str = "Error",
168 message: str = "An error occurred",
169 details: str = "",
170 site_name: str = "Lexigram Admin",
171 icon: str = "",
172 action_text: str = "Return to Admin",
173 action_url: str = "/admin/",
174) -> str:
175 """Render a standalone error page.
177 Args:
178 status_code: HTTP status code
179 title: Error title
180 message: Error message
181 details: Additional details (hidden in production)
182 site_name: Site name for branding
183 icon: Emoji icon to display
184 action_text: Call-to-action button text
185 action_url: Call-to-action button URL
187 Returns:
188 HTML string for error page
189 """
190 config = StandaloneLayoutConfig(
191 app_name=site_name,
192 show_footer=True,
193 centered=True,
194 )
195 context = StandaloneLayoutContext(
196 page_title=title,
197 )
199 details_html = ""
200 if details:
201 details_html = f"""
202 <details class="text-left mt-4">
203 <summary class="cursor-pointer font-medium text-sm text-muted-foreground">Technical Details</summary>
204 <pre class="bg-muted text-foreground p-4 rounded-md overflow-x-auto mt-2">{escape(details)}</pre>
205 </details>
206 """
208 icon_html = (
209 f'<div style="font-size: 3rem; margin-bottom: 0.5rem;">{icon}</div>'
210 if icon
211 else ""
212 )
214 content = f"""
215 <div class="w-full max-w-2xl bg-card border border-border rounded-lg shadow-lg p-8 text-center">
216 {icon_html}
217 <div class="text-6xl font-bold text-muted-foreground mb-2">{status_code}</div>
218 <h1 class="text-2xl font-bold text-foreground mb-2">{escape(title)}</h1>
219 <p class="text-sm text-muted-foreground">{escape(message)}</p>
220 {details_html}
221 <a href="{escape(action_url)}"
222 class="inline-block px-6 py-3 rounded-md bg-primary text-primary-foreground font-medium mt-4 hover:bg-primary/90 transition-colors">{escape(action_text)}</a>
223 </div>
224 """
226 layout = StandaloneLayout(config=config, context=context)
227 return layout.render(content)
230def render_template(
231 template_name: str,
232 context: dict[str, Any] | None = None,
233 **kwargs: Any,
234) -> str:
235 """Render a named template.
237 This is a simple fallback template renderer. For full template support,
238 use the Jinja2 integration in ui/templates_jinja.py.
240 Args:
241 template_name: Template name (used as title)
242 context: Template context
243 **kwargs: Additional context values
245 Returns:
246 HTML string
247 """
248 ctx = context or {}
249 ctx.update(kwargs)
251 title = ctx.get("title", template_name)
252 content = ctx.get("content", "")
254 # Include debug info when error template is requested
255 if template_name == "debug_error.html":
256 exc_type = ctx.get("exc_type", "")
257 exc_message = ctx.get("exc_message", "")
258 traceback = ctx.get("traceback", "")
259 traceback_plain = ctx.get("traceback_plain", "")
260 debug_html = f"""
261 <div class="error-details" style="margin-top: 1rem; padding: 1rem; background: #fef2f2; border: 1px solid #fecaca; border-radius: 4px;">
262 <h2 style="color: #b91c1c;">{escape(str(exc_type))}</h2>
263 <pre style="white-space: pre-wrap; word-break: break-word; font-family: monospace; font-size: 0.85rem; margin-top: 0.5rem;">{escape(str(exc_message))}</pre>
264 <details style="margin-top: 0.5rem;">
265 <summary style="cursor: pointer; font-weight: 500;">Traceback</summary>
266 <pre style="white-space: pre-wrap; word-break: break-word; font-family: monospace; font-size: 0.75rem; max-height: 400px; overflow: auto; margin-top: 0.25rem; background: #1f2937; color: #e5e7eb; padding: 0.75rem; border-radius: 4px;">{escape(str(traceback_plain))}</pre>
267 </details>
268 </div>
269 """
270 content = debug_html
272 config = StandaloneLayoutConfig(
273 show_footer=False,
274 show_logo=False,
275 centered=False,
276 )
277 layout_context = StandaloneLayoutContext(
278 page_title=title,
279 )
281 page_content = f"""
282 <main class="container" style="padding: 2rem;">
283 <h1>{escape(title)}</h1>
284 {content}
285 </main>
286 """
288 layout = StandaloneLayout(config=config, context=layout_context)
289 return layout.render(page_content)