diff --git a/answers.json b/answers.json
new file mode 100644
index 0000000..9fd3e63
--- /dev/null
+++ b/answers.json
@@ -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"
+ }
+ }
+]
\ No newline at end of file
diff --git a/config.toml b/config.toml
index 013c084..13ff523 100644
--- a/config.toml
+++ b/config.toml
@@ -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 = "
Identity
"
+html_before = "Account Information
" # 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 = "Contact Info
"
+# 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 = "More About You
"
+# 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 = "Detailed Background
" # 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"
diff --git a/src/handlers.rs b/src/handlers.rs
index 04a3b46..44e7497 100644
--- a/src/handlers.rs
+++ b/src/handlers.rs
@@ -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,
- html_after: Option,
+ pub title: String,
+ pub description: String,
+ pub answer_type: String,
+ pub html_before: Option,
+ pub html_after: Option,
+ pub options: Option>,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
@@ -27,7 +28,9 @@ struct ResponseEntry {
#[derive(Debug, Deserialize)]
pub struct AppConfig {
- submit_button: String,
+ pub json_output: Option,
+ pub form_title: String,
+ pub submit_button: String,
pub fields: Vec,
}
@@ -42,15 +45,17 @@ pub async fn render_form(State(state): State) -> 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(),
diff --git a/src/main.rs b/src/main.rs
index f81dcb8..327f126 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -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,
#[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);
diff --git a/templates/_layout.css b/templates/_layout.css
index ef7087e..8169493 100644
--- a/templates/_layout.css
+++ b/templates/_layout.css
@@ -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 {
- width: 100%;
- padding: 0.6rem 0.75rem;
- margin-bottom: 1rem;
- border: none;
- border-radius: 8px;
- box-sizing: border-box;
+ display: block;
+ width: 100%;
+ 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;
- }
+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 {
- max-width: 100%;
- width: 100%;
+ padding: 1.5rem;
+ border-radius: 12px;
+ margin-top: 0;
+ }
+ .field {
+ margin-bottom: 1.5rem;
}
}
diff --git a/templates/_layout.html b/templates/_layout.html
index 141cd93..8ef4d74 100644
--- a/templates/_layout.html
+++ b/templates/_layout.html
@@ -2,7 +2,8 @@
- {% block title %}Dynamic Form{% endblock %}
+
+ {% block title %}Form{% endblock %}