use maud::{DOCTYPE, Markup, PreEscaped, html};
use crate::fields::{Field, Values, Widget, humanize};
pub const TURNSTILE_ORIGIN: &str = "https://challenges.cloudflare.com";
pub const TURNSTILE_SCRIPT: &str = "https://challenges.cloudflare.com/turnstile/v0/api.js";
pub struct FormSpec<'a> {
pub module: &'a str,
pub action: &'a str,
pub post_to: &'a str,
pub intro: Option<&'a str>,
pub fields: &'a [Field],
pub values: &'a Values,
pub errors: &'a [(String, String)],
pub captcha_site_key: Option<&'a str>,
pub submit_label: &'a str,
}
#[must_use]
pub fn form(spec: &FormSpec<'_>) -> Markup {
let form_error = spec.errors.iter().find(|(name, _)| name.is_empty());
html! {
form class="cf-form" data-cf-module=(spec.module) data-cf-action=(spec.action)
method="post" action=(spec.post_to) novalidate {
@if let Some(intro) = spec.intro {
p class="cf-intro" { (intro) }
}
@if let Some((_, message)) = form_error {
p class="cf-error cf-error--form" role="alert" { (message) }
}
@for field in spec.fields {
(field_markup(spec, field))
}
@if let Some(site_key) = spec.captcha_site_key {
div class="cf-field cf-field--captcha" data-cf-field="captchaToken" {
div class="cf-turnstile" data-sitekey=(site_key) {}
}
}
div class="cf-actions" {
button class="cf-submit" type="submit" { (spec.submit_label) }
}
}
}
}
fn field_markup(spec: &FormSpec<'_>, field: &Field) -> Markup {
let value = spec.values.get(&field.name).map(String::as_str);
if field.widget == Widget::Hidden {
return html! {
@if let Some(value) = value {
input type="hidden" name=(field.name) value=(value);
}
};
}
let error = spec
.errors
.iter()
.find(|(name, _)| name == &field.name)
.map(|(_, message)| message.as_str());
let id = format!("cf-{}-{}-{}", spec.module, spec.action, field.name);
let classes = if error.is_some() {
"cf-field cf-field--invalid"
} else {
"cf-field"
};
html! {
div class=(classes) data-cf-field=(field.name) {
@if field.widget == Widget::Checkbox {
label class="cf-label cf-label--checkbox" for=(id) {
input class="cf-input cf-input--checkbox" type="checkbox" id=(id)
name=(field.name) required[field.required]
checked[value.is_some_and(|v| matches!(v, "on" | "true" | "1"))];
span { (field.label) }
}
} @else {
label class="cf-label" for=(id) {
(field.label)
@if field.required { span class="cf-required" aria-hidden="true" { "*" } }
}
(control(field, &id, value))
}
@if let Some(help) = &field.help {
p class="cf-help" { (help) }
}
@if let Some(message) = error {
p class="cf-error" role="alert" { (message) }
}
}
}
}
fn control(field: &Field, id: &str, value: Option<&str>) -> Markup {
match field.widget {
Widget::Select => html! {
select class="cf-input cf-input--select" id=(id) name=(field.name)
required[field.required] {
@if !field.required || value.is_none() {
option value="" { (field.placeholder.as_deref().unwrap_or("Choose")) }
}
@for option in &field.options {
option value=(option.value) selected[value == Some(option.value.as_str())] {
(option.label)
}
}
}
},
Widget::Textarea => html! {
textarea class="cf-input cf-input--textarea" id=(id) name=(field.name)
required[field.required] placeholder=[field.placeholder.as_deref()] {
@if let Some(value) = value { (value) }
}
},
Widget::Email
| Widget::Number
| Widget::Text
| Widget::Password
| Widget::Checkbox
| Widget::Hidden => {
let kind = match field.widget {
Widget::Email => "email",
Widget::Number => "number",
Widget::Password => "password",
_ => "text",
};
let value = (field.widget != Widget::Password)
.then_some(value)
.flatten();
let autocomplete = match field.widget {
Widget::Email => Some("email"),
Widget::Password => Some("off"),
_ => None,
};
html! {
input class="cf-input" type=(kind) id=(id) name=(field.name)
required[field.required] placeholder=[field.placeholder.as_deref()]
value=[value] autocomplete=[autocomplete];
}
}
}
}
#[must_use]
pub fn notice(module: &str, action: &str, tone: &str, title: &str, message: &str) -> Markup {
let class = format!("cf-notice cf-notice--{tone}");
html! {
div class=(class) data-cf-module=(module) data-cf-action=(action) role="status" {
h2 class="cf-notice-title" { (title) }
p class="cf-notice-text" { (message) }
}
}
}
#[must_use]
pub fn status(module: &str, action: &str, value: &serde_json::Value) -> Markup {
html! {
dl class="cf-status" data-cf-module=(module) data-cf-action=(action) {
@if let Some(object) = value.as_object() {
@for (key, item) in object {
div class="cf-status-row" data-cf-field=(key) {
dt class="cf-status-key" { (humanize(key)) }
dd class="cf-status-value" { (scalar(item)) }
}
}
} @else {
div class="cf-status-row" {
dd class="cf-status-value" { (scalar(value)) }
}
}
}
}
}
fn scalar(value: &serde_json::Value) -> String {
match value {
serde_json::Value::String(s) => s.clone(),
serde_json::Value::Null => String::new(),
other => other.to_string(),
}
}
pub struct PageSpec<'a> {
pub venture: &'a str,
pub title: &'a str,
pub theme_tokens: bool,
pub theme_css: Option<&'a str>,
pub turnstile: bool,
pub admin: bool,
}
#[must_use]
pub fn page(spec: &PageSpec<'_>, body: &Markup) -> Markup {
html! {
(DOCTYPE)
html lang="en" {
head {
meta charset="utf-8";
meta name="viewport" content="width=device-width, initial-scale=1";
meta name="robots" content="noindex";
title { (spec.title) " · " (spec.venture) }
link rel="stylesheet" href="/ui/cf.css";
@if spec.theme_tokens {
link rel="stylesheet" href="/ui/theme.css";
}
@if let Some(theme) = spec.theme_css {
link rel="stylesheet" href=(theme);
}
@if spec.turnstile {
script src=(TURNSTILE_SCRIPT) async defer {}
}
}
body class="cf-page" {
main class="cf-main" {
header class="cf-header" {
p class="cf-venture" { (spec.venture) }
h1 class="cf-title" { (spec.title) }
@if spec.admin {
nav class="cf-nav" {
a class="cf-nav-link" href="/ui/admin" { "Admin" }
form class="cf-logout" method="post" action="/ui/admin/logout" {
button class="cf-nav-link cf-logout-button" type="submit" { "Log out" }
}
}
}
}
(body)
}
}
}
}
}
pub struct TableRow<'a> {
pub cells: Vec<&'a str>,
pub actions: Vec<(&'a str, Vec<(&'a str, &'a str)>)>,
}
#[must_use]
pub fn table(module: &str, source: &str, columns: &[&str], rows: &[TableRow<'_>]) -> Markup {
html! {
div class="cf-table-wrap" {
table class="cf-table" data-cf-module=(module) data-cf-action=(source) {
thead {
tr {
@for column in columns { th class="cf-table-head" scope="col" { (column) } }
@if rows.iter().any(|r| !r.actions.is_empty()) { th class="cf-table-head" scope="col" { "" } }
}
}
tbody {
@if rows.is_empty() {
tr { td class="cf-table-empty" colspan=(columns.len()) { "No rows." } }
}
@for row in rows {
tr class="cf-table-row" {
@for cell in &row.cells { td class="cf-table-cell" { (cell) } }
@if !row.actions.is_empty() {
td class="cf-table-cell cf-table-actions" {
@for (action, hidden) in &row.actions {
form class="cf-row-action" method="post"
action=(format!("/ui/admin/{module}/{action}")) {
@for (name, value) in hidden {
input type="hidden" name=(name) value=(value);
}
button class="cf-submit cf-submit--row" type="submit" { (humanize(action)) }
}
}
}
}
}
}
}
}
}
}
}
#[must_use]
pub fn confirm(
module: &str,
action: &str,
what: &str,
hidden: &[(String, String)],
back: &str,
) -> Markup {
html! {
div class="cf-notice cf-notice--warning" data-cf-module=(module) data-cf-action=(action) role="status" {
h2 class="cf-notice-title" { (humanize(action)) " " (what) "?" }
p class="cf-notice-text" { "This cannot be undone." }
form class="cf-form cf-form--confirm" method="post" action=(format!("/ui/admin/{module}/{action}")) {
@for (name, value) in hidden { input type="hidden" name=(name) value=(value); }
input type="hidden" name="confirm" value="1";
div class="cf-actions" {
button class="cf-submit cf-submit--danger" type="submit" { (humanize(action)) }
a class="cf-cancel" href=(back) { "Cancel" }
}
}
}
}
}
#[must_use]
pub fn admin_index(entries: &[(String, Vec<(String, String)>)]) -> Markup {
html! {
div class="cf-admin-index" {
@if entries.is_empty() { p class="cf-help" { "No module declares an admin surface." } }
@for (module, links) in entries {
section class="cf-admin-module" data-cf-module=(module) {
h2 class="cf-admin-module-title" { (humanize(module)) }
ul class="cf-admin-links" {
@for (href, label) in links { li { a class="cf-admin-link" href=(href) { (label) } } }
}
}
}
}
}
}
#[must_use]
pub fn raw(text: &str) -> Markup {
PreEscaped(text.to_owned())
}