1use maud::{DOCTYPE, Markup, PreEscaped, html};
8
9use crate::fields::{Field, Values, Widget, humanize};
10
11pub const TURNSTILE_ORIGIN: &str = "https://challenges.cloudflare.com";
14pub const TURNSTILE_SCRIPT: &str = "https://challenges.cloudflare.com/turnstile/v0/api.js";
17
18pub struct FormSpec<'a> {
20 pub module: &'a str,
21 pub action: &'a str,
22 pub post_to: &'a str,
24 pub intro: Option<&'a str>,
26 pub fields: &'a [Field],
27 pub values: &'a Values,
28 pub errors: &'a [(String, String)],
30 pub captcha_site_key: Option<&'a str>,
33 pub submit_label: &'a str,
34}
35
36#[must_use]
38pub fn form(spec: &FormSpec<'_>) -> Markup {
39 let form_error = spec.errors.iter().find(|(name, _)| name.is_empty());
40 html! {
41 form class="cf-form" data-cf-module=(spec.module) data-cf-action=(spec.action)
42 method="post" action=(spec.post_to) novalidate {
43 @if let Some(intro) = spec.intro {
44 p class="cf-intro" { (intro) }
45 }
46 @if let Some((_, message)) = form_error {
47 p class="cf-error cf-error--form" role="alert" { (message) }
48 }
49 @for field in spec.fields {
50 (field_markup(spec, field))
51 }
52 @if let Some(site_key) = spec.captcha_site_key {
53 div class="cf-field cf-field--captcha" data-cf-field="captchaToken" {
54 div class="cf-turnstile" data-sitekey=(site_key) {}
55 }
56 }
57 div class="cf-actions" {
58 button class="cf-submit" type="submit" { (spec.submit_label) }
59 }
60 }
61 }
62}
63
64fn field_markup(spec: &FormSpec<'_>, field: &Field) -> Markup {
65 let value = spec.values.get(&field.name).map(String::as_str);
66 if field.widget == Widget::Hidden {
67 return html! {
68 @if let Some(value) = value {
69 input type="hidden" name=(field.name) value=(value);
70 }
71 };
72 }
73 let error = spec
74 .errors
75 .iter()
76 .find(|(name, _)| name == &field.name)
77 .map(|(_, message)| message.as_str());
78 let id = format!("cf-{}-{}-{}", spec.module, spec.action, field.name);
79 let classes = if error.is_some() {
80 "cf-field cf-field--invalid"
81 } else {
82 "cf-field"
83 };
84 html! {
85 div class=(classes) data-cf-field=(field.name) {
86 @if field.widget == Widget::Checkbox {
87 label class="cf-label cf-label--checkbox" for=(id) {
88 input class="cf-input cf-input--checkbox" type="checkbox" id=(id)
89 name=(field.name) required[field.required]
90 checked[value.is_some_and(|v| matches!(v, "on" | "true" | "1"))];
91 span { (field.label) }
92 }
93 } @else {
94 label class="cf-label" for=(id) {
95 (field.label)
96 @if field.required { span class="cf-required" aria-hidden="true" { "*" } }
97 }
98 (control(field, &id, value))
99 }
100 @if let Some(help) = &field.help {
101 p class="cf-help" { (help) }
102 }
103 @if let Some(message) = error {
104 p class="cf-error" role="alert" { (message) }
105 }
106 }
107 }
108}
109
110fn control(field: &Field, id: &str, value: Option<&str>) -> Markup {
111 match field.widget {
112 Widget::Select => html! {
113 select class="cf-input cf-input--select" id=(id) name=(field.name)
114 required[field.required] {
115 @if !field.required || value.is_none() {
116 option value="" { (field.placeholder.as_deref().unwrap_or("Choose")) }
117 }
118 @for option in &field.options {
119 option value=(option.value) selected[value == Some(option.value.as_str())] {
120 (option.label)
121 }
122 }
123 }
124 },
125 Widget::Textarea => html! {
126 textarea class="cf-input cf-input--textarea" id=(id) name=(field.name)
127 required[field.required] placeholder=[field.placeholder.as_deref()] {
128 @if let Some(value) = value { (value) }
129 }
130 },
131 Widget::Email
132 | Widget::Number
133 | Widget::Text
134 | Widget::Password
135 | Widget::Checkbox
136 | Widget::Hidden => {
137 let kind = match field.widget {
138 Widget::Email => "email",
139 Widget::Number => "number",
140 Widget::Password => "password",
141 _ => "text",
142 };
143 let value = (field.widget != Widget::Password)
144 .then_some(value)
145 .flatten();
146 let autocomplete = match field.widget {
147 Widget::Email => Some("email"),
148 Widget::Password => Some("off"),
149 _ => None,
150 };
151 html! {
152 input class="cf-input" type=(kind) id=(id) name=(field.name)
153 required[field.required] placeholder=[field.placeholder.as_deref()]
154 value=[value] autocomplete=[autocomplete];
155 }
156 }
157 }
158}
159
160#[must_use]
162pub fn notice(module: &str, action: &str, tone: &str, title: &str, message: &str) -> Markup {
163 let class = format!("cf-notice cf-notice--{tone}");
164 html! {
165 div class=(class) data-cf-module=(module) data-cf-action=(action) role="status" {
166 h2 class="cf-notice-title" { (title) }
167 p class="cf-notice-text" { (message) }
168 }
169 }
170}
171
172#[must_use]
175pub fn status(module: &str, action: &str, value: &serde_json::Value) -> Markup {
176 html! {
177 dl class="cf-status" data-cf-module=(module) data-cf-action=(action) {
178 @if let Some(object) = value.as_object() {
179 @for (key, item) in object {
180 div class="cf-status-row" data-cf-field=(key) {
181 dt class="cf-status-key" { (humanize(key)) }
182 dd class="cf-status-value" { (scalar(item)) }
183 }
184 }
185 } @else {
186 div class="cf-status-row" {
187 dd class="cf-status-value" { (scalar(value)) }
188 }
189 }
190 }
191 }
192}
193
194fn scalar(value: &serde_json::Value) -> String {
195 match value {
196 serde_json::Value::String(s) => s.clone(),
197 serde_json::Value::Null => String::new(),
198 other => other.to_string(),
199 }
200}
201
202pub struct PageSpec<'a> {
204 pub venture: &'a str,
205 pub title: &'a str,
206 pub theme_tokens: bool,
210 pub theme_css: Option<&'a str>,
211 pub turnstile: bool,
212 pub admin: bool,
214}
215
216#[must_use]
219pub fn page(spec: &PageSpec<'_>, body: &Markup) -> Markup {
220 html! {
221 (DOCTYPE)
222 html lang="en" {
223 head {
224 meta charset="utf-8";
225 meta name="viewport" content="width=device-width, initial-scale=1";
226 meta name="robots" content="noindex";
227 title { (spec.title) " ยท " (spec.venture) }
228 link rel="stylesheet" href="/ui/cf.css";
229 @if spec.theme_tokens {
230 link rel="stylesheet" href="/ui/theme.css";
231 }
232 @if let Some(theme) = spec.theme_css {
233 link rel="stylesheet" href=(theme);
234 }
235 @if spec.turnstile {
236 script src=(TURNSTILE_SCRIPT) async defer {}
237 }
238 }
239 body class="cf-page" {
240 main class="cf-main" {
241 header class="cf-header" {
242 p class="cf-venture" { (spec.venture) }
243 h1 class="cf-title" { (spec.title) }
244 @if spec.admin {
245 nav class="cf-nav" {
246 a class="cf-nav-link" href="/ui/admin" { "Admin" }
247 form class="cf-logout" method="post" action="/ui/admin/logout" {
248 button class="cf-nav-link cf-logout-button" type="submit" { "Log out" }
249 }
250 }
251 }
252 }
253 (body)
254 }
255 }
256 }
257 }
258}
259
260pub struct TableRow<'a> {
263 pub cells: Vec<&'a str>,
264 pub actions: Vec<(&'a str, Vec<(&'a str, &'a str)>)>,
266}
267
268#[must_use]
270pub fn table(module: &str, source: &str, columns: &[&str], rows: &[TableRow<'_>]) -> Markup {
271 html! {
272 div class="cf-table-wrap" {
273 table class="cf-table" data-cf-module=(module) data-cf-action=(source) {
274 thead {
275 tr {
276 @for column in columns { th class="cf-table-head" scope="col" { (column) } }
277 @if rows.iter().any(|r| !r.actions.is_empty()) { th class="cf-table-head" scope="col" { "" } }
278 }
279 }
280 tbody {
281 @if rows.is_empty() {
282 tr { td class="cf-table-empty" colspan=(columns.len()) { "No rows." } }
283 }
284 @for row in rows {
285 tr class="cf-table-row" {
286 @for cell in &row.cells { td class="cf-table-cell" { (cell) } }
287 @if !row.actions.is_empty() {
288 td class="cf-table-cell cf-table-actions" {
289 @for (action, hidden) in &row.actions {
290 form class="cf-row-action" method="post"
291 action=(format!("/ui/admin/{module}/{action}")) {
292 @for (name, value) in hidden {
293 input type="hidden" name=(name) value=(value);
294 }
295 button class="cf-submit cf-submit--row" type="submit" { (humanize(action)) }
296 }
297 }
298 }
299 }
300 }
301 }
302 }
303 }
304 }
305 }
306}
307
308#[must_use]
311pub fn confirm(
312 module: &str,
313 action: &str,
314 what: &str,
315 hidden: &[(String, String)],
316 back: &str,
317) -> Markup {
318 html! {
319 div class="cf-notice cf-notice--warning" data-cf-module=(module) data-cf-action=(action) role="status" {
320 h2 class="cf-notice-title" { (humanize(action)) " " (what) "?" }
321 p class="cf-notice-text" { "This cannot be undone." }
322 form class="cf-form cf-form--confirm" method="post" action=(format!("/ui/admin/{module}/{action}")) {
323 @for (name, value) in hidden { input type="hidden" name=(name) value=(value); }
324 input type="hidden" name="confirm" value="1";
325 div class="cf-actions" {
326 button class="cf-submit cf-submit--danger" type="submit" { (humanize(action)) }
327 a class="cf-cancel" href=(back) { "Cancel" }
328 }
329 }
330 }
331 }
332}
333
334#[must_use]
336pub fn admin_index(entries: &[(String, Vec<(String, String)>)]) -> Markup {
337 html! {
338 div class="cf-admin-index" {
339 @if entries.is_empty() { p class="cf-help" { "No module declares an admin surface." } }
340 @for (module, links) in entries {
341 section class="cf-admin-module" data-cf-module=(module) {
342 h2 class="cf-admin-module-title" { (humanize(module)) }
343 ul class="cf-admin-links" {
344 @for (href, label) in links { li { a class="cf-admin-link" href=(href) { (label) } } }
345 }
346 }
347 }
348 }
349 }
350}
351
352#[must_use]
355pub fn raw(text: &str) -> Markup {
356 PreEscaped(text.to_owned())
357}