feat: getting the hang of htmx
parent
f96bf4d193
commit
4c84c944d4
|
@ -11,6 +11,6 @@ r2d2_sqlite = "0.25.0"
|
||||||
serde = { version = "1.0.217", features = ["derive"] }
|
serde = { version = "1.0.217", features = ["derive"] }
|
||||||
tera = "1.20.0"
|
tera = "1.20.0"
|
||||||
tokio = { version = "1.42.0", features = ["full"] }
|
tokio = { version = "1.42.0", features = ["full"] }
|
||||||
tower-http = { version = "0.6.2", features = ["compression-br", "compression-gzip"] }
|
tower-http = { version = "0.6.2", features = ["compression-br", "compression-gzip", "set-header"] }
|
||||||
tracing = "0.1.41"
|
tracing = "0.1.41"
|
||||||
tracing-subscriber = "0.3.19"
|
tracing-subscriber = "0.3.19"
|
||||||
|
|
11
src/db.rs
11
src/db.rs
|
@ -95,3 +95,14 @@ pub fn insert_plant(pool: &DbPool, plant: &NewPlant) -> Result<Plant, DbError> {
|
||||||
})?,
|
})?,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn get_plant(pool: &DbPool, id: u32) -> Result<Option<Plant>, DbError> {
|
||||||
|
let conn = pool.get()?;
|
||||||
|
|
||||||
|
let mut stmt = conn.prepare("select * from plants where id = $1")?;
|
||||||
|
match stmt.query_row((id,), |row| Plant::from_row(row)) {
|
||||||
|
Ok(plant) => Ok(Some(plant)),
|
||||||
|
Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
|
||||||
|
Err(err) => Err(DbError::Db(err)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
16
src/main.rs
16
src/main.rs
|
@ -83,9 +83,21 @@ fn load_templates() -> Tera {
|
||||||
let mut tera = Tera::default();
|
let mut tera = Tera::default();
|
||||||
|
|
||||||
tera.add_raw_templates(vec![
|
tera.add_raw_templates(vec![
|
||||||
|
("base.html", include_str!("templates/base.html")),
|
||||||
("index.html", include_str!("templates/index.html")),
|
("index.html", include_str!("templates/index.html")),
|
||||||
("plants_ul.html", include_str!("templates/plants_ul.html")),
|
(
|
||||||
("plant_li.html", include_str!("templates/plant_li.html")),
|
"partials/plants_ul.html",
|
||||||
|
include_str!("templates/partials/plants_ul.html"),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"partials/plant_li.html",
|
||||||
|
include_str!("templates/partials/plant_li.html"),
|
||||||
|
),
|
||||||
|
("plant_page.html", include_str!("templates/plant_page.html")),
|
||||||
|
(
|
||||||
|
"partials/plant_info.html",
|
||||||
|
include_str!("templates/partials/plant_info.html"),
|
||||||
|
),
|
||||||
])
|
])
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
|
||||||
|
|
|
@ -1,23 +1,41 @@
|
||||||
|
mod plants;
|
||||||
|
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::State,
|
extract::State,
|
||||||
|
http::{header::VARY, HeaderMap, HeaderValue},
|
||||||
response::Html,
|
response::Html,
|
||||||
routing::{get, post},
|
routing::get,
|
||||||
Form, Router,
|
Router,
|
||||||
};
|
};
|
||||||
use tera::Context;
|
use tera::Context;
|
||||||
|
use tower_http::set_header::SetResponseHeaderLayer;
|
||||||
|
|
||||||
use crate::db;
|
use crate::db;
|
||||||
|
|
||||||
|
const HX_REQUEST_HEADER: &str = "HX-Request";
|
||||||
|
|
||||||
|
pub fn is_htmx_req(headers: &HeaderMap) -> bool {
|
||||||
|
headers.get(HX_REQUEST_HEADER).is_some()
|
||||||
|
}
|
||||||
|
|
||||||
pub fn app(ctx: crate::Context) -> axum::Router {
|
pub fn app(ctx: crate::Context) -> axum::Router {
|
||||||
let mut router = Router::new()
|
let mut router = Router::new()
|
||||||
.route("/", get(get_index))
|
.route("/", get(get_index))
|
||||||
.route("/plants", post(post_plants));
|
.with_state(ctx.clone())
|
||||||
|
.nest("/plants", plants::app(ctx.clone()));
|
||||||
|
|
||||||
for (name, content) in crate::STATIC_FILES {
|
for (name, content) in crate::STATIC_FILES {
|
||||||
router = router.route(&format!("/static/{}", name), get(content))
|
router = router.route(&format!("/static/{}", name), get(content))
|
||||||
}
|
}
|
||||||
|
|
||||||
router.with_state(ctx)
|
// Routes return either partial or full pages depending on whether the request is done using
|
||||||
|
// HTMX or just as a plain HTTP request. Adding the Vary header ensures caches don't mix
|
||||||
|
// partial and full responses.
|
||||||
|
// https://htmx.org/docs/#caching
|
||||||
|
router.layer(SetResponseHeaderLayer::appending(
|
||||||
|
VARY,
|
||||||
|
HeaderValue::from_static(HX_REQUEST_HEADER),
|
||||||
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_index(State(ctx): State<crate::Context>) -> Html<String> {
|
async fn get_index(State(ctx): State<crate::Context>) -> Html<String> {
|
||||||
|
@ -30,17 +48,3 @@ async fn get_index(State(ctx): State<crate::Context>) -> Html<String> {
|
||||||
context.insert("plants", &plants);
|
context.insert("plants", &plants);
|
||||||
Html(ctx.tera.render("index.html", &context).unwrap())
|
Html(ctx.tera.render("index.html", &context).unwrap())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn post_plants(
|
|
||||||
State(ctx): State<crate::Context>,
|
|
||||||
Form(plant): Form<db::NewPlant>,
|
|
||||||
) -> Html<String> {
|
|
||||||
let plant = tokio::task::spawn_blocking(move || db::insert_plant(&ctx.pool, &plant))
|
|
||||||
.await
|
|
||||||
.unwrap()
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
let mut context = Context::new();
|
|
||||||
context.insert("plant", &plant);
|
|
||||||
Html(ctx.tera.render("plant_li.html", &context).unwrap())
|
|
||||||
}
|
|
||||||
|
|
|
@ -0,0 +1,55 @@
|
||||||
|
use axum::{
|
||||||
|
extract::{Path, State},
|
||||||
|
http::HeaderMap,
|
||||||
|
response::Html,
|
||||||
|
routing::{get, post},
|
||||||
|
Form, Router,
|
||||||
|
};
|
||||||
|
use tera::Context;
|
||||||
|
|
||||||
|
use crate::db;
|
||||||
|
|
||||||
|
use super::is_htmx_req;
|
||||||
|
|
||||||
|
pub fn app(ctx: crate::Context) -> axum::Router {
|
||||||
|
Router::new()
|
||||||
|
.route("/:id", get(get_plant_page))
|
||||||
|
.route("/", post(post_plant))
|
||||||
|
.with_state(ctx)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_plant_page(
|
||||||
|
State(ctx): State<crate::Context>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
Path(plant_id): Path<u32>,
|
||||||
|
) -> Html<String> {
|
||||||
|
let plant = tokio::task::spawn_blocking(move || db::get_plant(&ctx.pool, plant_id))
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let mut context = Context::new();
|
||||||
|
context.insert("plant", &plant);
|
||||||
|
|
||||||
|
let tmpl = if is_htmx_req(&headers) {
|
||||||
|
"partials/plant_info.html"
|
||||||
|
} else {
|
||||||
|
"plant_page.html"
|
||||||
|
};
|
||||||
|
|
||||||
|
Html(ctx.tera.render(tmpl, &context).unwrap())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn post_plant(
|
||||||
|
State(ctx): State<crate::Context>,
|
||||||
|
Form(plant): Form<db::NewPlant>,
|
||||||
|
) -> Html<String> {
|
||||||
|
let plant = tokio::task::spawn_blocking(move || db::insert_plant(&ctx.pool, &plant))
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let mut context = Context::new();
|
||||||
|
context.insert("plant", &plant);
|
||||||
|
Html(ctx.tera.render("partials/plant_li.html", &context).unwrap())
|
||||||
|
}
|
|
@ -0,0 +1,12 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<script src="/static/htmx_2.0.4.min.js" integrity="sha384-HGfztofotfshcF7+8n44JQL2oJmowVChPTg48S+jvZoztPfvwD79OC/LTtG6dMp+"></script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="content">
|
||||||
|
{% block content %}
|
||||||
|
{% endblock content %}
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
|
@ -1,20 +1,19 @@
|
||||||
<html>
|
{% extends "base.html" %}
|
||||||
<head>
|
|
||||||
<script src="/static/htmx_2.0.4.min.js" integrity="sha384-HGfztofotfshcF7+8n44JQL2oJmowVChPTg48S+jvZoztPfvwD79OC/LTtG6dMp+"></script>
|
{% block content %}
|
||||||
</head>
|
|
||||||
<body>
|
<h1>Calathea</h1>
|
||||||
<h1>Calathea</h1>
|
<h2>Plants</h2>
|
||||||
<h2>Plants</h2>
|
{% include "partials/plants_ul.html" %}
|
||||||
{% include "plants_ul.html" %}
|
<h3>Add new plant</h3>
|
||||||
<h3>Add new plant</h3>
|
<form hx-post="/plants" hx-target="#plants" hx-swap="beforeend">
|
||||||
<form hx-post="/plants" hx-target="#plants" hx-swap="beforeend">
|
<label for="name">Name:</label>
|
||||||
<label for="name">Name:</label>
|
<input type="text" id="name" name="name"></br>
|
||||||
<input type="text" id="name" name="name"></br>
|
<label for="species">Species:</label>
|
||||||
<label for="species">Species:</label>
|
<input type="text" id="species" name="species"></br>
|
||||||
<input type="text" id="species" name="species"></br>
|
<label for="description">Description:</label>
|
||||||
<label for="description">Description:</label>
|
<textarea id="description" name="description" rows=4></textarea></br>
|
||||||
<textarea id="description" name="description" rows=4></textarea></br>
|
<input type="submit">
|
||||||
<input type="submit">
|
</form>
|
||||||
</form>
|
|
||||||
</body>
|
{% endblock content %}
|
||||||
</html>
|
|
||||||
|
|
|
@ -0,0 +1,6 @@
|
||||||
|
<h1>Calathea</h1>
|
||||||
|
<h2>{{ plant.name }}</h2>
|
||||||
|
<h3>Species</h3>
|
||||||
|
<p>{{ plant.species }}</p>
|
||||||
|
<h3>Description</h3>
|
||||||
|
<p>{{ plant.description }}</p>
|
|
@ -0,0 +1,7 @@
|
||||||
|
<ul id="plants">
|
||||||
|
{% for plant in plants %}
|
||||||
|
<li>
|
||||||
|
<a hx-get="/plants/{{ plant.id }}" hx-target="#content" hx-push-url="true">{{ plant.name }}</a> ({{ plant.species }})
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
|
@ -0,0 +1,6 @@
|
||||||
|
{% extends "base.html" %}
|
||||||
|
{% block content %}
|
||||||
|
|
||||||
|
{% include "partials/plant_info.html" %}
|
||||||
|
|
||||||
|
{% endblock content %}
|
|
@ -1,5 +0,0 @@
|
||||||
<ul id="plants">
|
|
||||||
{% for plant in plants %}
|
|
||||||
<li>{{ plant.name }} ({{ plant.species }})</li>
|
|
||||||
{% endfor %}
|
|
||||||
</ul>
|
|
Loading…
Reference in New Issue