added select and checkbox, added form_title and output_file to config, and reworked style

This commit is contained in:
2026-02-19 14:12:07 +01:00
parent 9d70fb07e4
commit 18e695eebe
7 changed files with 330 additions and 122 deletions
+54
View File
@@ -0,0 +1,54 @@
[
{
"timestamp": "2026-02-19T12:46:14.811307031Z",
"answers": {
"experience_level": "",
"full_name": "",
"age": "",
"newsletter": "true",
"about_you": "",
"comments": "",
"department": "Design",
"contact_number": "",
"password": "!Y%5#yH^QcZmwHR",
"favorite_color": "",
"email": "foobar@mail.com",
"website": ""
}
},
{
"timestamp": "2026-02-19T12:46:37.950223451Z",
"answers": {
"full_name": "",
"contact_number": "",
"website": "",
"favorite_color": "",
"age": "",
"password": "!Y%5#yH^QcZmwHR",
"department": "Design",
"comments": "",
"experience_level": "Mid-level",
"newsletter": "true",
"about_you": "",
"email": "foobar@mail.com"
}
},
{
"timestamp": "2026-02-19T12:50:56.697577391Z",
"answers": {
"website": "https://asdf.com",
"password": "!Y%5#yH^QcZmwHR",
"favorite_color": "aasdf",
"about_you": "asdf",
"terms": "true",
"comments": "asdfasdf",
"age": "3",
"contact_number": "asdf",
"full_name": "babro",
"newsletter": "true",
"department": "Engineering",
"email": "foobar@mail.com",
"experience_level": "Junior"
}
}
]
+51 -30
View File
@@ -1,53 +1,74 @@
# Path where the submitted JSON data will be appended
json_output = "answers.json"
submit_button = "Send"
# Title of the page and form
form_title = "User Onboarding Survey"
# Text displayed on the primary action button
submit_button = "Complete Submission"
# Standard single-line input
[[fields]]
name = "full_name"
title = "Full Name"
description = "Your full name"
name = "username"
title = "Username"
description = "Enter your preferred display name"
answer_type = "text"
html_before = "<h2>Identity</h2>"
html_before = "<h2>Account Information</h2>" # Append html before this element
# Numeric input
[[fields]]
name = "age"
title = "Age"
description = "Your age"
name = "years_experience"
title = "Years of Experience"
description = "How many years have you worked in this field?"
answer_type = "number"
# Email specific input
[[fields]]
name = "email"
name = "contact_email"
title = "Email Address"
description = "Your email address"
description = "Where should we send your confirmation?"
answer_type = "email"
html_before = "<h2>Contact Info</h2>"
# Obscured text input
[[fields]]
name = "contact_number"
title = "Contact Number"
description = "Phone number"
answer_type = "tel"
name = "secret_key"
title = "Secret Key"
description = "Enter your private access code"
answer_type = "password"
# Web address input
[[fields]]
name = "website"
title = "Website"
description = "Personal website (optional)"
name = "portfolio_link"
title = "Portfolio URL"
description = "Link to your personal website"
answer_type = "url"
html_after = "<h2>More About You</h2>"
# Telephone number input
[[fields]]
name = "about_you"
title = "About You"
description = "Tell us about yourself"
name = "phone_number"
title = "Phone Number"
description = "Include your country code"
answer_type = "tel"
html_after = "<h2>Detailed Background</h2>" # Append html after this element
# Multi-line text box
[[fields]]
name = "bio"
title = "Short Biography"
description = "Tell us a bit about yourself"
answer_type = "textarea"
# Dropdown menu (uses the 'options' array)
[[fields]]
name = "favorite_color"
title = "Favorite Color"
description = "Favorite color (text)"
answer_type = "text"
name = "region"
title = "Preferred Region"
description = "Select your primary work location"
answer_type = "select"
options = ["North America", "Europe", "Asia", "Other"]
# Toggle/Boolean input
[[fields]]
name = "password"
title = "Password"
description = "Set a password"
answer_type = "password"
name = "tos_agree"
title = "Terms of Service"
description = "I have read and agree to the user guidelines"
answer_type = "checkbox"
+13 -8
View File
@@ -12,11 +12,12 @@ use tokio::sync::Mutex;
#[derive(Debug, Deserialize)]
pub struct FieldDef {
pub name: String,
title: String,
description: String,
answer_type: String,
html_before: Option<String>,
html_after: Option<String>,
pub title: String,
pub description: String,
pub answer_type: String,
pub html_before: Option<String>,
pub html_after: Option<String>,
pub options: Option<Vec<String>>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
@@ -27,7 +28,9 @@ struct ResponseEntry {
#[derive(Debug, Deserialize)]
pub struct AppConfig {
submit_button: String,
pub json_output: Option<String>,
pub form_title: String,
pub submit_button: String,
pub fields: Vec<FieldDef>,
}
@@ -42,15 +45,17 @@ pub async fn render_form(State(state): State<AppState>) -> impl IntoResponse {
#[derive(Template)]
#[template(path = "form.html")]
struct FormTemplate<'a> {
form_title: &'a str,
submit_button: &'a str,
fields: &'a [FieldDef],
lang: &'a str,
submit_button: &'a str,
}
let tmpl = FormTemplate {
form_title: &state.cfg.form_title,
submit_button: &state.cfg.submit_button,
fields: &state.cfg.fields,
lang: "en",
submit_button: &state.cfg.submit_button,
};
match tmpl.render() {
Ok(html) => Html(html).into_response(),
+11 -5
View File
@@ -19,8 +19,8 @@ struct Cli {
#[arg(short, long, default_value = "config.toml")]
config_path: String,
#[arg(short, long, default_value = "answers.json")]
output_file: String,
#[arg(short, long)]
output_file: Option<String>,
#[arg(short, long)]
verbose: bool,
@@ -34,10 +34,16 @@ async fn main() -> anyhow::Result<()> {
tracing::subscriber::set_global_default(subscriber).ok();
let cfg = load_config(&cli.config_path).context(format!("loading {}", cli.config_path))?;
let output_file = cli
.output_file
.or_else(|| cfg.json_output.clone())
.unwrap_or_else(|| "answers.json".to_string());
tracing::info!(
"Loaded config: '{}', writing answers to '{}' with {} fields",
cli.config_path,
cli.output_file,
output_file,
cfg.fields.len()
);
@@ -67,7 +73,7 @@ async fn main() -> anyhow::Result<()> {
let app = Router::new()
.merge(form_generator::app_router(
cfg,
cli.output_file,
output_file,
"/form",
"/submit",
))
@@ -76,7 +82,7 @@ async fn main() -> anyhow::Result<()> {
.layer(GovernorLayer::new(governor_conf));
let port = std::env::var("SERVER_PORT").unwrap_or("8081".to_string());
let addr = format!("127.0.0.1:{port}");
let addr = format!("0.0.0.0:{port}");
let listener = tokio::net::TcpListener::bind(&addr).await?;
tracing::info!("Listening on {}", addr);
+165 -68
View File
@@ -1,110 +1,207 @@
:root {
--bg-body: #0f172a;
--bg-card: #1e293b;
--bg-input: #0f172a;
--text-main: #f8fafc;
--text-muted: #94a3b8;
--text-inv: #ffffff;
--primary: #6366f1;
--primary-hover: #818cf8;
--primary-glow: rgba(99, 102, 241, 0.25);
--border-base: #334155;
--border-focus: #6366f1;
--radius-lg: 16px;
--radius-md: 10px;
--shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.3), 0 10px 10px -5px rgba(0, 0, 0, 0.2);
}
*, *::before, *::after {
box-sizing: border-box;
}
html {
font-size: 15px;
font-size: 16px;
-webkit-font-smoothing: antialiased;
height: 100%;
}
body {
font-family: 'Inter', system-ui, -apple-system, Roboto, "Segoe UI", Arial, sans-serif;
padding: 20px;
font-family: 'Inter', system-ui, -apple-system, sans-serif;
background-color: var(--bg-body);
color: var(--text-main);
margin: 0;
line-height: 1.6;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background-color: #fff;
overflow-x: hidden;
justify-content: flex-start;
min-height: 100vh;
padding: 2rem 1rem;
}
h2 {
font-size: 1.8rem;
margin-bottom: 15px;
h1 {
margin-top: 0;
margin-bottom: 2rem;
font-size: 1.85rem;
font-weight: 800;
color: var(--text-main);
letter-spacing: -0.025em;
}
.form-container {
display: flex;
flex-direction: row;
text-align: left;
justify-content: center;
align-items: center;
background: #fff;
border-radius: 16px;
padding: 2rem;
background-color: var(--bg-card);
width: 100%;
max-width: 720px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.25);
padding: 2.5rem;
border-radius: var(--radius-lg);
box-shadow: var(--shadow);
border: 1px solid var(--border-base);
margin: auto 0;
}
.form-container img {
display: block;
margin: 0 auto;
}
.desc {
font-size: 1.2rem;
margin-bottom: 0.5rem;
color: #999;
.field {
margin-bottom: 1.8rem;
width: 100%;
}
label {
font-weight: 600;
display: block;
margin-bottom: 0.25rem;
font-size: 1.35rem;
color: #222;
cursor: pointer;
font-weight: 600;
font-size: 0.95rem;
margin-bottom: 0.4rem;
color: var(--text-main);
}
input,
.desc {
display: block;
font-weight: 400;
font-size: 0.875rem;
color: var(--text-muted);
margin-bottom: 0.8rem;
overflow-wrap: break-word;
}
input:not([type="checkbox"]),
textarea,
select {
display: block;
width: 100%;
padding: 0.6rem 0.75rem;
margin-bottom: 1rem;
border: none;
border-radius: 8px;
box-sizing: border-box;
padding: 0.8rem 1rem;
font-family: inherit;
font-size: 1rem;
color: #333333aa;
background-color: #e0f0ff; /* light blue */
border: 1px solid #88c1ff; /* medium blue */
transition: all 0.2s ease-in-out;
color: var(--text-main);
background-color: var(--bg-input);
border: 1.5px solid var(--border-base);
border-radius: var(--radius-md);
transition: all 0.2s ease;
outline: none;
-webkit-appearance: none;
}
input:focus,
input:not([type="checkbox"]):focus,
textarea:focus,
select:focus {
outline: #66aaffaa; /* soft blue focus */
background-color: #cce5ff; /* slightly darker blue on focus */
border-color: var(--border-focus);
box-shadow: 0 0 0 4px var(--primary-glow);
background-color: var(--bg-card);
}
input[type="checkbox"] {
-webkit-appearance: none;
appearance: none;
width: 1.25rem;
height: 1.25rem;
background-color: var(--bg-input);
border: 1.5px solid var(--border-base);
border-radius: 4px;
cursor: pointer;
position: relative;
display: inline-grid;
place-content: center;
transition: all 0.2s ease;
flex-shrink: 0;
}
input[type="checkbox"]:hover {
border-color: var(--primary);
}
input[type="checkbox"]:checked {
background-color: var(--primary);
border-color: var(--primary);
}
input[type="checkbox"]::before {
content: "";
width: 0.65rem;
height: 0.65rem;
transform: scale(0);
transition: 120ms transform ease-in-out;
box-shadow: inset 1rem 1rem var(--text-inv);
transform-origin: bottom left;
clip-path: polygon(14% 44%, 0 65%, 50% 100%, 100% 16%, 80% 0%, 43% 62%);
}
input[type="checkbox"]:checked::before {
transform: scale(1);
}
input[type="checkbox"]:focus {
box-shadow: 0 0 0 4px var(--primary-glow);
outline: none;
}
textarea {
resize: none;
resize: vertical;
min-height: 120px;
}
button {
padding: 0.5rem 1rem;
font-size: 1.3rem;
border: none;
border-radius: 0.25rem;
cursor: pointer;
background-color: #66aaff; /* medium blue */
color: #fff;
transition: background-color 0.2s;
transition: color 0.2s;
}
button:hover {
background-color: #3390ff; /* darker blue on hover */
select {
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke='%2394a3b8'%3E%3Cpath stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M19 9l-7 7-7-7'%3E%3C/path%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 1rem center;
background-size: 1.1rem;
padding-right: 2.5rem;
}
.submit-container {
text-align: center;
margin-top: 1rem;
margin-top: 2.5rem;
}
@media (max-width: 720px) {
html {
font-size: 20px;
}
.form-container {
max-width: 100%;
button {
width: 100%;
padding: 1rem;
font-size: 1rem;
font-weight: 700;
cursor: pointer;
border: none;
border-radius: var(--radius-md);
background-color: var(--primary);
color: var(--text-inv);
transition: all 0.2s ease;
}
button:hover {
background-color: var(--primary-hover);
transform: translateY(-1px);
}
@media (max-width: 640px) {
body {
padding: 1rem;
}
.form-container {
padding: 1.5rem;
border-radius: 12px;
margin-top: 0;
}
.field {
margin-bottom: 1.5rem;
}
}
+2 -1
View File
@@ -2,7 +2,8 @@
<html lang="{{ lang }}">
<head>
<meta charset="utf-8"/>
<title>{% block title %}Dynamic Form{% endblock %}</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block title %}Form{% endblock %}</title>
<style>
/*<![CDATA[*/
{%~ include "_layout.css" ~%}
+27 -3
View File
@@ -1,27 +1,51 @@
{% extends "_layout.html" %}
{% block title %}Form{% endblock %}
{% block title %}{{ form_title }}{% endblock %}
{% block content %}
<div class="form-container">
<h1>{{ form_title }}</h1>
<form action="submit" method="POST" autocomplete="off">
{% for f in fields %}
{% if f.html_before.is_some() %}
{{ f.html_before.as_ref().unwrap() | safe }}
{% endif %}
<div class="field">
<label for="{{ f.name }}">{{ f.title }}</label>
<div class="desc">{{ f.description }}</div>
{% if f.answer_type == "checkbox" %}
<label for="{{ f.name }}" style="display: flex; align-items: flex-start; gap: 12px; cursor: pointer;">
<input id="{{ f.name }}" name="{{ f.name }}" type="checkbox" value="true">
<div style="display: flex; flex-direction: column;">
<span style="font-weight: 600; font-size: 0.95rem; color: var(--text-main);">{{ f.title }}</span>
<span class="desc" style="margin: 4px 0 0 0;">{{ f.description }}</span>
</div>
</label>
{% else %}
<label for="{{ f.name }}">
<span style="display: block;">{{ f.title }}</span>
<span class="desc">{{ f.description }}</span>
</label>
{% if f.answer_type == "textarea" %}
<textarea id="{{ f.name }}" name="{{ f.name }}" rows="10" placeholder="{{ f.title|lower }}"></textarea>
{% elif f.answer_type == "select" %}
<select id="{{ f.name }}" name="{{ f.name }}">
<option value="">Select an option</option>
{% if f.options.is_some() %}
{% for opt in f.options.as_ref().unwrap() %}
<option value="{{ opt }}">{{ opt }}</option>
{% endfor %}
{% endif %}
</select>
{% else %}
<input id="{{ f.name }}" name="{{ f.name }}" type="{{ f.answer_type }}" placeholder="{{ f.title|lower }}">
{% endif %}
{% endif %}
</div>
{% if f.html_after.is_some() %}
{{ f.html_after.as_ref().unwrap() | safe }}
{% endif %}