refactored logic from templates to rust, and added cli network config

This commit is contained in:
2026-02-19 14:31:54 +01:00
parent 18e695eebe
commit 429a5cc051
6 changed files with 122 additions and 104 deletions
+3 -9
View File
@@ -2,14 +2,6 @@
A lightweight application for generating dynamic form websites and storing user responses. The form fields, labels, and behavior are fully configurable via a TOML configuration file. A lightweight application for generating dynamic form websites and storing user responses. The form fields, labels, and behavior are fully configurable via a TOML configuration file.
## Usage
The server will listen on `127.0.0.1:8081` by default. You can override with:
```bash
SERVER_PORT=8082 form-generator
```
## Configuration ## Configuration
The application is configured via a TOML file (default: `config.toml`). The application is configured via a TOML file (default: `config.toml`).
@@ -20,13 +12,15 @@ See [config.toml](./config.toml) for an example.
* `json_output`: Path to the JSON file where responses are stored. * `json_output`: Path to the JSON file where responses are stored.
* `form_title`: Title of the page and form.
* `submit_button`: Text displayed on the forms submit button. * `submit_button`: Text displayed on the forms submit button.
* `fields`: List of form fields * `fields`: List of form fields
* `name`: Internal key for storage. * `name`: Internal key for storage.
* `title`: Label displayed in the form. * `title`: Label displayed in the form.
* `description`: Short description displayed below the field. * `description`: Short description displayed below the field.
* `answer_type`: Type of input (`text`, `number`, `email`, `password`, `url`, `tel`, `textarea`). * `answer_type`: Type of input (`text`, `number`, `email`, `password`, `url`, `tel`, `textarea`, `select`, `checkbox`, etc).
* `html_before` (optional): HTML snippet rendered before the field. * `html_before` (optional): HTML snippet rendered before the field.
* `html_after` (optional): HTML snippet rendered after the field. * `html_after` (optional): HTML snippet rendered after the field.
-54
View File
@@ -1,54 +0,0 @@
[
{
"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"
}
}
]
+37 -3
View File
@@ -20,10 +20,28 @@ pub struct FieldDef {
pub options: Option<Vec<String>>, pub options: Option<Vec<String>>,
} }
pub enum FieldWidget<'a> {
Checkbox,
Textarea,
Select(&'a [String]),
Input(&'a str),
}
impl FieldDef {
pub fn widget(&self) -> FieldWidget<'_> {
match self.answer_type.as_str() {
"checkbox" => FieldWidget::Checkbox,
"textarea" => FieldWidget::Textarea,
"select" => FieldWidget::Select(self.options.as_deref().unwrap_or(&[])),
other => FieldWidget::Input(other),
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize)] #[derive(Clone, Debug, Serialize, Deserialize)]
struct ResponseEntry { struct ResponseEntry {
timestamp: chrono::DateTime<chrono::Utc>, timestamp: chrono::DateTime<chrono::Utc>,
answers: HashMap<String, String>, answers: HashMap<String, Option<String>>,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
@@ -65,11 +83,27 @@ pub async fn render_form(State(state): State<AppState>) -> impl IntoResponse {
pub async fn submit( pub async fn submit(
State(state): State<AppState>, State(state): State<AppState>,
Form(form): Form<HashMap<String, String>>, Form(mut form): Form<HashMap<String, String>>,
) -> impl IntoResponse { ) -> impl IntoResponse {
let mut processed_answers = HashMap::new();
for field in &state.cfg.fields {
let value = form
.remove(&field.name)
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty());
processed_answers.insert(field.name.clone(), value);
}
for (key, val) in form {
let value = Some(val.trim().to_string()).filter(|s| !s.is_empty());
processed_answers.insert(key, value);
}
let entry = ResponseEntry { let entry = ResponseEntry {
timestamp: Utc::now(), timestamp: Utc::now(),
answers: form, answers: processed_answers,
}; };
let _guard = state.file_lock.lock().await; let _guard = state.file_lock.lock().await;
+12 -3
View File
@@ -22,6 +22,12 @@ struct Cli {
#[arg(short, long)] #[arg(short, long)]
output_file: Option<String>, output_file: Option<String>,
#[arg(short, long, default_value = "8081")]
port: u16,
#[arg(short = 'H', long)]
host: bool,
#[arg(short, long)] #[arg(short, long)]
verbose: bool, verbose: bool,
} }
@@ -55,7 +61,7 @@ async fn main() -> anyhow::Result<()> {
// rate limiter // rate limiter
let governor_conf = GovernorConfigBuilder::default() let governor_conf = GovernorConfigBuilder::default()
.per_second(3) .per_second(1)
.burst_size(10) .burst_size(10)
.finish() .finish()
.unwrap(); .unwrap();
@@ -81,8 +87,11 @@ async fn main() -> anyhow::Result<()> {
.layer(cors) .layer(cors)
.layer(GovernorLayer::new(governor_conf)); .layer(GovernorLayer::new(governor_conf));
let port = std::env::var("SERVER_PORT").unwrap_or("8081".to_string()); let addr = if cli.host {
let addr = format!("0.0.0.0:{port}"); format!("0.0.0.0:{}", cli.port)
} else {
format!("127.0.0.1:{}", cli.port)
};
let listener = tokio::net::TcpListener::bind(&addr).await?; let listener = tokio::net::TcpListener::bind(&addr).await?;
tracing::info!("Listening on {}", addr); tracing::info!("Listening on {}", addr);
+23
View File
@@ -69,6 +69,29 @@ h1 {
width: 100%; width: 100%;
} }
.checkbox-label {
display: flex;
align-items: flex-start;
gap: 12px;
cursor: pointer;
margin: 0;
}
.label-content {
display: flex;
flex-direction: column;
}
.label-title {
font-weight: 600;
font-size: 0.95rem;
color: var(--text-main);
}
.checkbox-desc {
margin: 4px 0 0 0;
}
label { label {
display: block; display: block;
cursor: pointer; cursor: pointer;
+34 -22
View File
@@ -8,49 +8,61 @@
<form action="submit" method="POST" autocomplete="off"> <form action="submit" method="POST" autocomplete="off">
{% for f in fields %} {% for f in fields %}
{% if f.html_before.is_some() %}
{{ f.html_before.as_ref().unwrap() | safe }} {% if let Some(html) = f.html_before %}
{{ html | safe }}
{% endif %} {% endif %}
<div class="field"> <div class="field">
{% if f.answer_type == "checkbox" %} {% match f.widget() %}
<label for="{{ f.name }}" style="display: flex; align-items: flex-start; gap: 12px; cursor: pointer;">
{% when FieldWidget::Checkbox %}
<label for="{{ f.name }}" class="checkbox-label">
<input id="{{ f.name }}" name="{{ f.name }}" type="checkbox" value="true"> <input id="{{ f.name }}" name="{{ f.name }}" type="checkbox" value="true">
<div style="display: flex; flex-direction: column;"> <div class="label-content">
<span style="font-weight: 600; font-size: 0.95rem; color: var(--text-main);">{{ f.title }}</span> <span class="label-title">{{ f.title }}</span>
<span class="desc" style="margin: 4px 0 0 0;">{{ f.description }}</span> <span class="desc checkbox-desc">{{ f.description }}</span>
</div> </div>
</label> </label>
{% else %}
{% when FieldWidget::Textarea %}
<label for="{{ f.name }}"> <label for="{{ f.name }}">
<span style="display: block;">{{ f.title }}</span> <span class="label-title">{{ f.title }}</span>
<span class="desc">{{ f.description }}</span> <span class="desc">{{ f.description }}</span>
</label> </label>
{% if f.answer_type == "textarea" %}
<textarea id="{{ f.name }}" name="{{ f.name }}" rows="10" placeholder="{{ f.title|lower }}"></textarea> <textarea id="{{ f.name }}" name="{{ f.name }}" rows="10" placeholder="{{ f.title|lower }}"></textarea>
{% elif f.answer_type == "select" %} {% when FieldWidget::Select with (options) %}
<label for="{{ f.name }}">
<span class="label-title">{{ f.title }}</span>
<span class="desc">{{ f.description }}</span>
</label>
<select id="{{ f.name }}" name="{{ f.name }}"> <select id="{{ f.name }}" name="{{ f.name }}">
<option value="">Select an option</option> <option value="">Select an option</option>
{% if f.options.is_some() %} {% for opt in options %}
{% for opt in f.options.as_ref().unwrap() %}
<option value="{{ opt }}">{{ opt }}</option> <option value="{{ opt }}">{{ opt }}</option>
{% endfor %} {% endfor %}
{% endif %}
</select> </select>
{% else %} {% when FieldWidget::Input with (input_type) %}
<input id="{{ f.name }}" name="{{ f.name }}" type="{{ f.answer_type }}" placeholder="{{ f.title|lower }}"> <label for="{{ f.name }}">
{% endif %} <span class="label-title">{{ f.title }}</span>
{% endif %} <span class="desc">{{ f.description }}</span>
</label>
<input id="{{ f.name }}" name="{{ f.name }}" type="{{ input_type }}" placeholder="{{ f.title|lower }}">
{% endmatch %}
</div> </div>
{% if f.html_after.is_some() %} {% if let Some(html) = f.html_after %}
{{ f.html_after.as_ref().unwrap() | safe }} {{ html | safe }}
{% endif %} {% endif %}
{% endfor %} {% endfor %}
<div class="submit-container"><button type="submit">{{ submit_button }}</button></div>
<div class="submit-container">
<button type="submit">{{ submit_button }}</button>
</div>
</form> </form>
</div> </div>
{% endblock %} {% endblock %}