1use crate::config::AdminConfig;
8use crate::dashboard::DashboardView;
9use crate::ui::generate_admin_css;
10use crate::views::{CreateView, DetailView, EditView, FormField, ListView};
11
12pub(crate) fn escape(s: &str) -> String {
14 s.replace('&', "&")
15 .replace('<', "<")
16 .replace('>', ">")
17 .replace('"', """)
18 .replace('\'', "'")
19}
20
21fn page(config: &AdminConfig, title: &str, body: &str) -> String {
23 let favicon = match &config.favicon_url {
25 Some(url) => format!("<link rel=\"icon\" href=\"{}\">\n", escape(url)),
26 None => String::new(),
27 };
28
29 let custom_css = match &config.custom_css {
32 Some(css) => format!("<style>{css}</style>\n"),
33 None => String::new(),
34 };
35
36 let logo = match &config.logo_url {
38 Some(url) => format!(
39 "<img class=\"admin-logo\" src=\"{}\" alt=\"{}\">",
40 escape(url),
41 escape(&config.title)
42 ),
43 None => String::new(),
44 };
45
46 let footer = match &config.footer_text {
48 Some(text) => format!("\n<footer class=\"admin-footer\">{}</footer>", escape(text)),
49 None => String::new(),
50 };
51
52 let custom_js = match &config.custom_js {
54 Some(js) => format!("\n<script>{js}</script>"),
55 None => String::new(),
56 };
57
58 format!(
59 "<!DOCTYPE html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">\n\
60 <meta name=\"viewport\" content=\"width=device-width, initial-scale=1\">\n\
61 <title>{title} · {app}</title>\n{favicon}<style>{css}</style>\n{custom_css}</head>\n\
62 <body>\n<header class=\"admin-header\">{logo}</header>\n\
63 <div class=\"admin-content\" data-admin-root>\n{body}\n</div>{footer}{custom_js}\n</body>\n</html>",
64 title = escape(title),
65 app = escape(&config.title),
66 favicon = favicon,
67 css = generate_admin_css(&config.theme),
68 custom_css = custom_css,
69 logo = logo,
70 body = body,
71 footer = footer,
72 custom_js = custom_js,
73 )
74}
75
76fn breadcrumbs_html(crumbs: &[crate::ui::Breadcrumb]) -> String {
77 let items: Vec<String> = crumbs
78 .iter()
79 .map(|c| match &c.url {
80 Some(url) => format!("<a href=\"{}\">{}</a>", escape(url), escape(&c.label)),
81 None => format!("<span>{}</span>", escape(&c.label)),
82 })
83 .collect();
84 format!(
85 "<nav class=\"admin-breadcrumbs\">{}</nav>",
86 items.join(" / ")
87 )
88}
89
90pub fn render_dashboard(view: &DashboardView, config: &AdminConfig) -> String {
92 let stats: Vec<String> = view
93 .stats
94 .iter()
95 .map(|s| {
96 format!(
97 "<div class=\"admin-card admin-stat\"><span class=\"stat-title\">{}</span>\
98 <span class=\"stat-value\">{}</span></div>",
99 escape(&s.title),
100 escape(&s.value)
101 )
102 })
103 .collect();
104
105 let models: Vec<String> = view
106 .model_summaries
107 .iter()
108 .map(|m| {
109 format!(
110 "<li><a href=\"{}\">{}</a> <span class=\"count\">{}</span></li>",
111 escape(&m.url),
112 escape(&m.verbose_name),
113 m.count
114 )
115 })
116 .collect();
117
118 let actions: Vec<String> = view
119 .quick_actions
120 .iter()
121 .map(|a| {
122 format!(
123 "<a class=\"admin-btn admin-btn-primary\" href=\"{}\">{}</a>",
124 escape(&a.url),
125 escape(&a.label)
126 )
127 })
128 .collect();
129
130 let body = format!(
131 "<h1>{title}</h1>\n<div class=\"admin-stats-row\">{stats}</div>\n\
132 <div class=\"admin-quick-actions\">{actions}</div>\n\
133 <h2>Models</h2>\n<ul class=\"admin-model-list\">{models}</ul>",
134 title = escape(&view.title),
135 stats = stats.join(""),
136 actions = actions.join(""),
137 models = models.join(""),
138 );
139 page(config, &view.title, &body)
140}
141
142pub fn render_list(view: &ListView, config: &AdminConfig) -> String {
144 let headers: Vec<String> = view
145 .columns
146 .iter()
147 .map(|c| format!("<th>{}</th>", escape(&c.label)))
148 .collect();
149
150 let rows: Vec<String> = view
151 .rows
152 .iter()
153 .map(|row| {
154 let cells: Vec<String> = row
155 .cells
156 .iter()
157 .map(|cell| format!("<td>{}</td>", cell.rendered))
158 .collect();
159 let view_url = format!(
160 "{}/{}/{}",
161 config.base_path,
162 view.model_name,
163 escape(&row.id)
164 );
165 format!(
166 "<tr>{}<td><a href=\"{}\">View</a></td></tr>",
167 cells.join(""),
168 view_url
169 )
170 })
171 .collect();
172
173 let rows_html = if rows.is_empty() {
174 format!(
175 "<tr><td colspan=\"{}\" class=\"admin-empty\">No records</td></tr>",
176 view.columns.len() + 1
177 )
178 } else {
179 rows.join("")
180 };
181
182 let p = &view.pagination;
183 let pagination = format!(
184 "<div class=\"admin-pagination\">Page {} of {} · {} items (showing {}–{})</div>",
185 p.page, p.total_pages, p.total_items, p.start_item, p.end_item
186 );
187
188 let add = if view.can_add {
189 format!(
190 "<a class=\"admin-btn admin-btn-primary\" href=\"{}\">Add {}</a>",
191 escape(&view.add_url),
192 escape(&view.verbose_name)
193 )
194 } else {
195 String::new()
196 };
197
198 let list_url = format!("{}/{}", config.base_path, view.model_name);
201
202 let search = if config.enable_search {
205 format!(
206 "<form class=\"admin-search\" method=\"get\" action=\"{}\">\
207 <input class=\"admin-input\" type=\"search\" name=\"search\" placeholder=\"Search\">\
208 <button class=\"admin-btn\" type=\"submit\">Search</button></form>",
209 escape(&list_url)
210 )
211 } else {
212 String::new()
213 };
214
215 let export = if config.enable_export {
217 format!(
218 "<a class=\"admin-btn admin-export\" href=\"{}?export=csv\">Export</a>",
219 escape(&list_url)
220 )
221 } else {
222 String::new()
223 };
224
225 let body = format!(
226 "{crumbs}\n<div class=\"admin-list-header\"><h1>{title}</h1>{search}{export}{add}</div>\n\
227 <table class=\"admin-table\"><thead><tr>{headers}<th>Actions</th></tr></thead>\
228 <tbody>{rows}</tbody></table>\n{pagination}",
229 crumbs = breadcrumbs_html(&view.breadcrumbs),
230 title = escape(&view.title),
231 search = search,
232 export = export,
233 add = add,
234 headers = headers.join(""),
235 rows = rows_html,
236 pagination = pagination,
237 );
238 page(config, &view.title, &body)
239}
240
241pub fn render_detail(view: &DetailView, config: &AdminConfig) -> String {
243 let fields: Vec<String> = view
244 .fields
245 .iter()
246 .map(|f| {
247 format!(
248 "<div class=\"admin-field\"><span class=\"field-label\">{}</span>\
249 <span class=\"field-value\">{}</span></div>",
250 escape(&f.label),
251 f.rendered
252 )
253 })
254 .collect();
255
256 let fields_html = if fields.is_empty() {
257 "<p class=\"admin-empty\">No data</p>".to_string()
258 } else {
259 fields.join("")
260 };
261
262 let mut actions = format!(
263 "<a class=\"admin-btn\" href=\"{}\">Back to list</a>",
264 escape(&view.list_url)
265 );
266 if view.can_edit {
267 actions.push_str(&format!(
268 "<a class=\"admin-btn admin-btn-primary\" href=\"{}\">Edit</a>",
269 escape(&view.edit_url)
270 ));
271 }
272 if view.can_delete {
273 actions.push_str(&format!(
274 "<form method=\"post\" action=\"{}\" class=\"admin-inline-form\">\
275 <button class=\"admin-btn admin-btn-danger\" type=\"submit\">Delete</button></form>",
276 escape(&view.delete_url)
277 ));
278 }
279
280 let body = format!(
281 "{crumbs}\n<h1>{title}</h1>\n<div class=\"admin-card\">{fields}</div>\n\
282 <div class=\"admin-actions\">{actions}</div>",
283 crumbs = breadcrumbs_html(&view.breadcrumbs),
284 title = escape(&view.title),
285 fields = fields_html,
286 actions = actions,
287 );
288 page(config, &view.title, &body)
289}
290
291fn form_input(field: &FormField) -> String {
292 let value = match &field.value {
293 serde_json::Value::Null => String::new(),
294 serde_json::Value::String(s) => escape(s),
295 other => escape(&other.to_string()),
296 };
297 let required = if field.required { " required" } else { "" };
298 let input = if field.widget.contains("textarea") {
299 format!(
300 "<textarea class=\"admin-input\" name=\"{}\"{}>{}</textarea>",
301 escape(&field.name),
302 required,
303 value
304 )
305 } else {
306 format!(
307 "<input class=\"admin-input\" name=\"{}\" value=\"{}\"{}>",
308 escape(&field.name),
309 value,
310 required
311 )
312 };
313 format!(
314 "<div class=\"admin-form-field\"><label>{}</label>{}</div>",
315 escape(&field.label),
316 input
317 )
318}
319
320pub fn render_create(view: &CreateView, config: &AdminConfig) -> String {
322 let fields: Vec<String> = view.fields.iter().map(form_input).collect();
323 let body = format!(
324 "{crumbs}\n<h1>{title}</h1>\n\
325 <form method=\"post\" action=\"{action}\" class=\"admin-card admin-form\">\
326 {fields}<div class=\"admin-actions\">\
327 <a class=\"admin-btn\" href=\"{cancel}\">Cancel</a>\
328 <button class=\"admin-btn admin-btn-primary\" type=\"submit\">Save</button></div></form>",
329 crumbs = breadcrumbs_html(&view.breadcrumbs),
330 title = escape(&view.title),
331 action = escape(&view.submit_url),
332 fields = fields.join(""),
333 cancel = escape(&view.cancel_url),
334 );
335 page(config, &view.title, &body)
336}
337
338pub fn render_edit(view: &EditView, config: &AdminConfig) -> String {
340 let fields: Vec<String> = view.fields.iter().map(form_input).collect();
341 let delete = if view.can_delete {
342 format!(
343 "<form method=\"post\" action=\"{}\" class=\"admin-inline-form\">\
344 <button class=\"admin-btn admin-btn-danger\" type=\"submit\">Delete</button></form>",
345 escape(&view.delete_url)
346 )
347 } else {
348 String::new()
349 };
350 let body = format!(
351 "{crumbs}\n<h1>{title}</h1>\n\
352 <form method=\"post\" action=\"{action}\" class=\"admin-card admin-form\">\
353 {fields}<div class=\"admin-actions\">\
354 <a class=\"admin-btn\" href=\"{cancel}\">Cancel</a>\
355 <button class=\"admin-btn admin-btn-primary\" type=\"submit\">Save</button></div></form>\n{delete}",
356 crumbs = breadcrumbs_html(&view.breadcrumbs),
357 title = escape(&view.title),
358 action = escape(&view.submit_url),
359 fields = fields.join(""),
360 cancel = escape(&view.cancel_url),
361 delete = delete,
362 );
363 page(config, &view.title, &body)
364}
365
366pub fn render_unauthorized(config: &AdminConfig) -> String {
368 page(
369 config,
370 "Unauthorized",
371 "<h1>401 Unauthorized</h1><p>Authentication is required to access this admin dashboard.</p>",
372 )
373}
374
375pub fn render_error(config: &AdminConfig, message: &str) -> String {
377 page(
378 config,
379 "Error",
380 &format!("<h1>Request failed</h1><p>{}</p>", escape(message)),
381 )
382}
383
384pub fn render_not_found(config: &AdminConfig) -> String {
386 page(
387 config,
388 "Not Found",
389 "<h1>404 Not Found</h1><p>The requested resource does not exist.</p>",
390 )
391}
392
393#[cfg(test)]
394mod tests {
395 use super::*;
396 use crate::ListParams;
397 use crate::field::{FieldDefinition, FieldType};
398 use crate::model::ModelDefinition;
399
400 fn sample_list_view() -> ListView {
401 let model = ModelDefinition::builder("user")
402 .id_field()
403 .field(FieldDefinition::new("name", FieldType::String))
404 .list_display(["id", "name"])
405 .build();
406 ListView::new(&model, ListParams::default(), 25)
407 }
408
409 #[test]
412 fn test_display_knobs_wired_into_html() {
413 let config = AdminConfig {
414 logo_url: Some("https://cdn.example.com/logo.png".to_string()),
415 favicon_url: Some("https://cdn.example.com/favicon.ico".to_string()),
416 custom_css: Some(".admin-header{background:hotpink}".to_string()),
417 custom_js: Some("console.log('admin loaded');".to_string()),
418 footer_text: Some("© 2026 Example Corp".to_string()),
419 enable_search: true,
420 enable_export: true,
421 ..AdminConfig::default()
422 };
423
424 let html = render_list(&sample_list_view(), &config);
425
426 assert!(html.contains("<link rel=\"icon\" href=\"https://cdn.example.com/favicon.ico\">"));
428 assert!(html.contains("src=\"https://cdn.example.com/logo.png\""));
429 assert!(html.contains("admin-logo"));
430 assert!(html.contains(".admin-header{background:hotpink}"));
432 assert!(html.contains("console.log('admin loaded');"));
433 assert!(html.contains("admin-footer"));
435 assert!(html.contains("© 2026 Example Corp"));
436 assert!(html.contains("admin-search"));
438 assert!(html.contains("name=\"search\""));
439 assert!(html.contains("admin-export"));
440 assert!(html.contains("?export=csv"));
441 }
442
443 #[test]
445 fn test_display_knobs_absent_when_unset() {
446 let config = AdminConfig {
447 enable_search: false,
448 enable_export: false,
449 ..AdminConfig::default()
450 };
451 let html = render_list(&sample_list_view(), &config);
454
455 assert!(!html.contains("<link rel=\"icon\""));
456 assert!(!html.contains("admin-logo"));
457 assert!(!html.contains("admin-footer"));
458 assert!(!html.contains("admin-search"));
459 assert!(!html.contains("admin-export"));
460 }
461}