From 18e695eebeb2ed614fc3e5933d1abbaf4ec154c4 Mon Sep 17 00:00:00 2001 From: eiiko6 Date: Thu, 19 Feb 2026 14:12:07 +0100 Subject: [PATCH] added select and checkbox, added form_title and output_file to config, and reworked style --- answers.json | 54 ++++++++++ config.toml | 81 ++++++++------ src/handlers.rs | 21 ++-- src/main.rs | 16 ++- templates/_layout.css | 233 +++++++++++++++++++++++++++++------------ templates/_layout.html | 3 +- templates/form.html | 44 ++++++-- 7 files changed, 330 insertions(+), 122 deletions(-) create mode 100644 answers.json 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 %}