refactored logic from templates to rust, and added cli network config
This commit is contained in:
@@ -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 form’s submit button.
|
* `submit_button`: Text displayed on the form’s 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.
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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);
|
||||||
|
|||||||
@@ -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
@@ -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 %}
|
||||||
|
|||||||
Reference in New Issue
Block a user