Switch to maud for templating

This commit is contained in:
Simon Bruder 2024-07-11 01:12:34 +02:00
parent 11be165f6d
commit 4a2dc721d4
Signed by: simon
GPG key ID: 347FF8699CDA0776
16 changed files with 736 additions and 575 deletions

121
Cargo.lock generated
View file

@ -347,60 +347,6 @@ version = "0.7.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96d30a06541fbafbc7f82ed10c06164cfbd2c401138f6addd8404629c4b16711" checksum = "96d30a06541fbafbc7f82ed10c06164cfbd2c401138f6addd8404629c4b16711"
[[package]]
name = "askama"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b79091df18a97caea757e28cd2d5fda49c6cd4bd01ddffd7ff01ace0c0ad2c28"
dependencies = [
"askama_derive",
"askama_escape",
"humansize",
"num-traits",
"percent-encoding",
]
[[package]]
name = "askama_actix"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e4b0dd17cfe203b00ba3853a89fba459ecf24c759b738b244133330607c78e55"
dependencies = [
"actix-web",
"askama",
]
[[package]]
name = "askama_derive"
version = "0.12.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19fe8d6cb13c4714962c072ea496f3392015f0989b1a2847bb4b2d9effd71d83"
dependencies = [
"askama_parser",
"basic-toml",
"mime",
"mime_guess",
"proc-macro2",
"quote",
"serde",
"syn 2.0.68",
]
[[package]]
name = "askama_escape"
version = "0.10.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "619743e34b5ba4e9703bba34deac3427c72507c7159f5fd030aea8cac0cfe341"
[[package]]
name = "askama_parser"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "acb1161c6b64d1c3d83108213c2a2533a342ac225aabd0bda218278c2ddb00c0"
dependencies = [
"nom",
]
[[package]] [[package]]
name = "async-trait" name = "async-trait"
version = "0.1.80" version = "0.1.80"
@ -451,15 +397,6 @@ version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "basic-toml"
version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "823388e228f614e9558c6804262db37960ec8821856535f5c3f59913140558f8"
dependencies = [
"serde",
]
[[package]] [[package]]
name = "bitflags" name = "bitflags"
version = "1.3.2" version = "1.3.2"
@ -1082,15 +1019,6 @@ version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
[[package]]
name = "humansize"
version = "2.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6cb51c9a029ddc91b07a787f1d86b53ccfa49b0e86688c946ebe8d3555685dd7"
dependencies = [
"libm",
]
[[package]] [[package]]
name = "humantime" name = "humantime"
version = "2.1.0" version = "2.1.0"
@ -1180,8 +1108,6 @@ version = "0.0.0"
dependencies = [ dependencies = [
"actix-files", "actix-files",
"actix-web", "actix-web",
"askama",
"askama_actix",
"barcoders", "barcoders",
"datamatrix", "datamatrix",
"diesel", "diesel",
@ -1190,6 +1116,7 @@ dependencies = [
"diesel_migrations", "diesel_migrations",
"env_logger", "env_logger",
"log", "log",
"maud",
"mime", "mime",
"printpdf", "printpdf",
"rust-fontconfig", "rust-fontconfig",
@ -1204,12 +1131,6 @@ version = "0.2.155"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97b3888a4aecf77e811145cadf6eef5901f4782c53886191b2f693f24761847c" checksum = "97b3888a4aecf77e811145cadf6eef5901f4782c53886191b2f693f24761847c"
[[package]]
name = "libm"
version = "0.2.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4ec2a862134d2a7d32d7983ddcdd1c4923530833c9f2ea1a44fc5fa473989058"
[[package]] [[package]]
name = "linked-hash-map" name = "linked-hash-map"
version = "0.5.6" version = "0.5.6"
@ -1266,6 +1187,30 @@ dependencies = [
"weezl", "weezl",
] ]
[[package]]
name = "maud"
version = "0.26.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df518b75016b4289cdddffa1b01f2122f4a49802c93191f3133f6dc2472ebcaa"
dependencies = [
"actix-web",
"futures-util",
"itoa",
"maud_macros",
]
[[package]]
name = "maud_macros"
version = "0.26.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fa453238ec218da0af6b11fc5978d3b5c3a45ed97b722391a2a11f3306274e18"
dependencies = [
"proc-macro-error",
"proc-macro2",
"quote",
"syn 2.0.68",
]
[[package]] [[package]]
name = "md-5" name = "md-5"
version = "0.10.6" version = "0.10.6"
@ -1325,12 +1270,6 @@ dependencies = [
"unicase", "unicase",
] ]
[[package]]
name = "minimal-lexical"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
[[package]] [[package]]
name = "miniz_oxide" name = "miniz_oxide"
version = "0.7.4" version = "0.7.4"
@ -1362,16 +1301,6 @@ dependencies = [
"winapi", "winapi",
] ]
[[package]]
name = "nom"
version = "7.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a"
dependencies = [
"memchr",
"minimal-lexical",
]
[[package]] [[package]]
name = "num-conv" name = "num-conv"
version = "0.1.0" version = "0.1.0"

View file

@ -12,8 +12,6 @@ license = "AGPL-3.0-or-later"
[dependencies] [dependencies]
actix-files = "0.6.6" actix-files = "0.6.6"
actix-web = "4.8.0" actix-web = "4.8.0"
askama = { version = "0.12.1", features = ["with-actix-web"] }
askama_actix = "0.14.0"
barcoders = { version = "2.0.0", default-features = false, features = ["std"] } barcoders = { version = "2.0.0", default-features = false, features = ["std"] }
datamatrix = "0.3.1" datamatrix = "0.3.1"
diesel = { version = "2.2.1", features = ["uuid"] } diesel = { version = "2.2.1", features = ["uuid"] }
@ -22,6 +20,7 @@ diesel-derive-enum = { version = "2.1.0", features = ["postgres"] }
diesel_migrations = { version = "2.2.0", features = ["postgres"] } diesel_migrations = { version = "2.2.0", features = ["postgres"] }
env_logger = "0.11.3" env_logger = "0.11.3"
log = "0.4.21" log = "0.4.21"
maud = { version = "0.26.0", features = ["actix-web"] }
mime = "0.3.17" mime = "0.3.17"
printpdf = "0.7.0" printpdf = "0.7.0"
rust-fontconfig = "0.1.7" rust-fontconfig = "0.1.7"

View file

@ -4,10 +4,11 @@
use std::collections::HashMap; use std::collections::HashMap;
use actix_web::{error, get, post, web, HttpRequest, Responder}; use actix_web::{error, get, post, web, Responder};
use askama_actix::Template; use maud::html;
use uuid::Uuid; use uuid::Uuid;
use super::templates::{self, forms, TemplateConfig};
use crate::manage; use crate::manage;
use crate::models::*; use crate::models::*;
use crate::DbPool; use crate::DbPool;
@ -21,20 +22,8 @@ pub fn config(cfg: &mut web::ServiceConfig) {
.service(edit_item_post); .service(edit_item_post);
} }
#[derive(Template)]
#[template(path = "item_details.html")]
struct ItemDetails {
req: HttpRequest,
item: Item,
item_class: ItemClass,
item_classes: HashMap<Uuid, ItemClass>,
parents: Vec<Item>,
children: Vec<Item>,
}
#[get("/item/{id}")] #[get("/item/{id}")]
async fn show_item( async fn show_item(
req: HttpRequest,
pool: web::Data<DbPool>, pool: web::Data<DbPool>,
path: web::Path<Uuid>, path: web::Path<Uuid>,
) -> actix_web::Result<impl Responder> { ) -> actix_web::Result<impl Responder> {
@ -48,9 +37,6 @@ async fn show_item(
.await .await
.map_err(error::ErrorInternalServerError)?; .map_err(error::ErrorInternalServerError)?;
// TODO: remove clone (should be possible without it)
let item_class = item_classes.get(&item.class).unwrap().clone();
let parents = manage::item::get_parents_details(&mut pool.get().await.unwrap(), item.id) let parents = manage::item::get_parents_details(&mut pool.get().await.unwrap(), item.id)
.await .await
.map_err(error::ErrorInternalServerError)?; .map_err(error::ErrorInternalServerError)?;
@ -59,37 +45,62 @@ async fn show_item(
.await .await
.map_err(error::ErrorInternalServerError)?; .map_err(error::ErrorInternalServerError)?;
Ok(ItemDetails { let item_class = item_classes.get(&item.class).unwrap();
req,
item,
item_class,
item_classes,
parents,
children,
})
}
#[derive(Template)] let item_name = templates::helpers::ItemName::new(&item, item_class);
#[template(path = "item_list.html")] let mut title = item_name.to_string();
struct ItemList { title.push_str(" Item Details");
req: HttpRequest,
// Both a Vec and a HashMap are used to have both the natural order, Ok(templates::base(
// as well as arbitrary access capabilities. TemplateConfig {
item_list: Vec<Item>, path: &format!("/item/{}", item.id),
#[allow(dead_code)] // remove once item_parents can be constructed in the template title: Some(&title),
items: HashMap<Uuid, Item>, page_title: Some(Box::new(item_name.clone())),
item_classes: HashMap<Uuid, ItemClass>, page_actions: vec![
#[allow(dead_code)] // remove once item_parents can be constructed in the template (templates::helpers::PageAction {
item_tree: HashMap<Uuid, Vec<Uuid>>, href: &format!("/item/{}/edit", item.id),
// to overcome askamas lack of support for closures name: "Edit",
item_parents: HashMap<Uuid, Vec<Item>>, }),
],
..Default::default()
},
html! {
table .table {
tr {
th { "UUID" }
td { (item.id) }
}
tr {
th { "Name" }
td { (item_name.clone().terse()) }
}
tr {
th { "Class" }
td { a href={ "/item-class/" (item.class) } { (item_class.name) } }
}
tr {
th { "Parents" }
td { (templates::helpers::parents_breadcrumb(&item, &item_class, &parents, &item_classes, true)) }
}
}
@if !children.is_empty() {
h3 .mt-4 { "Direct Children" }
ul {
@for child in children {
li {
a href={ "/item/" (child.id) } { (templates::helpers::ItemName::new(&child, &item_classes.get(&child.class).unwrap())) }
}
}
}
}
},
))
} }
#[get("/items")] #[get("/items")]
async fn list_items( async fn list_items(pool: web::Data<DbPool>) -> actix_web::Result<impl Responder> {
req: HttpRequest,
pool: web::Data<DbPool>,
) -> actix_web::Result<impl Responder> {
let item_list = manage::item::get_all(&mut pool.get().await.unwrap()) let item_list = manage::item::get_all(&mut pool.get().await.unwrap())
.await .await
.map_err(error::ErrorInternalServerError)?; .map_err(error::ErrorInternalServerError)?;
@ -107,41 +118,95 @@ async fn list_items(
.map_err(error::ErrorInternalServerError)?; .map_err(error::ErrorInternalServerError)?;
// TODO: remove clone (should be possible without it) // TODO: remove clone (should be possible without it)
let item_parents = items let item_parents: HashMap<Uuid, Vec<Item>> = item_tree
.clone() .iter()
.into_iter() .map(|(id, parent_ids)| {
.map(|(id, item)| { (
(id, { *id,
item_tree parent_ids
.get(&item.id)
.unwrap()
.iter() .iter()
.map(|is| items.get(is).unwrap().clone()) .map(|parent_id| items.get(parent_id).unwrap().clone())
.collect() .collect(),
}) )
}) })
.collect(); .collect();
Ok(ItemList { Ok(templates::base(
req, TemplateConfig {
item_list, path: "/items",
items, title: Some("Item List"),
item_classes, page_title: Some(Box::new("Item List")),
item_tree, page_actions: vec![
item_parents, (templates::helpers::PageAction {
}) href: "/items/add",
} name: "Add",
}),
#[derive(Template)] ],
#[template(path = "item_add.html")] ..Default::default()
struct ItemAddForm { },
req: HttpRequest, html! {
data: Option<NewItem>, table .table {
thead {
tr {
th { "Name" }
th { "Class" }
th { "Parents" }
}
}
tbody {
@for item in item_list {
@let class = item_classes.get(&item.class).unwrap();
tr {
td {
a href={ "/item/" (item.id) } {
(templates::helpers::ItemName::new(&item, class).terse())
}
}
td { a href={ "/item-class/" (class.id) } { (class.name) } }
td { (templates::helpers::parents_breadcrumb(&item, &class, item_parents.get(&item.id).unwrap(), &item_classes, false)) }
}
}
}
}
},
))
} }
#[get("/items/add")] #[get("/items/add")]
async fn add_item(req: HttpRequest) -> actix_web::Result<impl Responder> { async fn add_item() -> actix_web::Result<impl Responder> {
Ok(ItemAddForm { req, data: None }) Ok(templates::base(
TemplateConfig {
path: "/items/add",
title: Some("Add Item"),
page_title: Some(Box::new("Add Item")),
..Default::default()
},
html! {
form method="POST" {
(forms::InputGroup {
r#type: forms::InputType::Text,
name: "name",
title: "Name",
..Default::default()
})
(forms::InputGroup {
r#type: forms::InputType::Text,
name: "class",
title: "Class",
required: true,
..Default::default()
})
(forms::InputGroup {
r#type: forms::InputType::Text,
name: "parent",
title: "Parent",
..Default::default()
})
button .btn.btn-primary type="submit" { "Add" }
}
},
))
} }
#[post("/items/add")] #[post("/items/add")]
@ -155,17 +220,8 @@ async fn add_item_post(
Ok(web::Redirect::to("/item/".to_owned() + &item.id.to_string()).see_other()) Ok(web::Redirect::to("/item/".to_owned() + &item.id.to_string()).see_other())
} }
#[derive(Template)]
#[template(path = "item_edit.html")]
struct ItemEditForm {
req: HttpRequest,
item: Item,
item_class: ItemClass,
}
#[get("/item/{id}/edit")] #[get("/item/{id}/edit")]
async fn edit_item( async fn edit_item(
req: HttpRequest,
pool: web::Data<DbPool>, pool: web::Data<DbPool>,
path: web::Path<Uuid>, path: web::Path<Uuid>,
) -> actix_web::Result<impl Responder> { ) -> actix_web::Result<impl Responder> {
@ -179,11 +235,56 @@ async fn edit_item(
.await .await
.map_err(error::ErrorInternalServerError)?; .map_err(error::ErrorInternalServerError)?;
Ok(ItemEditForm { let item_name = templates::helpers::ItemName::new(&item, &item_class);
req, let mut title = item_name.to_string();
item, title.push_str(" Edit Item");
item_class,
}) Ok(templates::base(
TemplateConfig {
path: &format!("/item/{}/edit", item.id),
title: Some(&title),
page_title: Some(Box::new(item_name.clone())),
..Default::default()
},
html! {
form method="POST" {
(forms::InputGroup {
r#type: forms::InputType::Text,
name: "id",
title: "UUID",
required: true,
disabled: true,
value: Some(item.id.to_string().as_str()),
})
(forms::InputGroup {
r#type: forms::InputType::Text,
name: "name",
title: "Name",
value: item.name.as_deref(),
disabled: item.name.is_none(),
..Default::default()
})
(forms::InputGroup {
r#type: forms::InputType::Text,
name: "class",
title: "Class",
required: true,
value: Some(item.class.to_string().as_str()),
..Default::default()
})
(forms::InputGroup {
r#type: forms::InputType::Text,
name: "parent",
title: "Parent",
value: item.parent.map(|id| id.to_string()).as_deref(),
disabled: item.parent.is_none(),
..Default::default()
})
button .btn.btn-primary type="submit" { "Edit" }
}
},
))
} }
#[post("/item/{id}/edit")] #[post("/item/{id}/edit")]

View file

@ -2,16 +2,35 @@
// //
// SPDX-License-Identifier: AGPL-3.0-or-later // SPDX-License-Identifier: AGPL-3.0-or-later
use std::collections::HashMap; use actix_web::{error, get, post, web, Responder};
use maud::html;
use actix_web::{error, get, post, web, HttpRequest, Responder};
use askama_actix::Template;
use uuid::Uuid; use uuid::Uuid;
use super::templates::{self, forms, TemplateConfig};
use crate::manage; use crate::manage;
use crate::models::*; use crate::models::*;
use crate::DbPool; use crate::DbPool;
const FORM_ENSURE_PARENT: templates::helpers::Js = templates::helpers::Js::Inline(
r#"
(() => {
document.getElementById("type").addEventListener("change", e => {
let parentInput = document.getElementById("parent")
switch (e.target.value) {
case "generic":
parentInput.disabled = true
parentInput.value = ""
break
case "specific":
parentInput.disabled = false
break
default:
console.error("invalid type!")
}
})
})()"#,
);
pub fn config(cfg: &mut web::ServiceConfig) { pub fn config(cfg: &mut web::ServiceConfig) {
cfg.service(show_item_class) cfg.service(show_item_class)
.service(list_item_classes) .service(list_item_classes)
@ -21,17 +40,8 @@ pub fn config(cfg: &mut web::ServiceConfig) {
.service(edit_item_class_post); .service(edit_item_class_post);
} }
#[derive(Template)]
#[template(path = "item_class_details.html")]
struct ItemClassDetails {
req: HttpRequest,
item_class: ItemClass,
parent: Option<ItemClass>,
}
#[get("/item-class/{id}")] #[get("/item-class/{id}")]
async fn show_item_class( async fn show_item_class(
req: HttpRequest,
pool: web::Data<DbPool>, pool: web::Data<DbPool>,
path: web::Path<Uuid>, path: web::Path<Uuid>,
) -> actix_web::Result<impl Responder> { ) -> actix_web::Result<impl Responder> {
@ -50,42 +60,131 @@ async fn show_item_class(
None => None, None => None,
}; };
Ok(ItemClassDetails { let mut title = item_class.name.clone();
req, title.push_str(" Item Details");
item_class,
parent,
})
}
#[derive(Template)] Ok(templates::base(
#[template(path = "item_class_list.html")] TemplateConfig {
struct ItemClassList { path: &format!("/item-class/{}", item_class.id),
req: HttpRequest, title: Some(&title),
item_classes: HashMap<Uuid, ItemClass>, page_title: Some(Box::new(item_class.name.clone())),
page_actions: vec![
(templates::helpers::PageAction {
href: &format!("/item-class/{}/edit", item_class.id),
name: "Edit",
}),
],
..Default::default()
},
html! {
table .table {
tr {
th { "UUID" }
td { (item_class.id) }
}
tr {
th { "Name" }
td { (item_class.name) }
}
tr {
th { "Type" }
td { (item_class.r#type) }
}
@if let Some(parent) = parent {
tr {
th { "Parent" }
td { a href={ "/item-class/" (parent.id) } { (parent.name) } }
}
}
}
},
))
} }
#[get("/item-classes")] #[get("/item-classes")]
async fn list_item_classes( async fn list_item_classes(pool: web::Data<DbPool>) -> actix_web::Result<impl Responder> {
req: HttpRequest,
pool: web::Data<DbPool>,
) -> actix_web::Result<impl Responder> {
let item_classes = manage::item_class::get_all_as_map(&mut pool.get().await.unwrap()) let item_classes = manage::item_class::get_all_as_map(&mut pool.get().await.unwrap())
.await .await
.map_err(error::ErrorInternalServerError)?; .map_err(error::ErrorInternalServerError)?;
Ok(ItemClassList { req, item_classes }) Ok(templates::base(
} TemplateConfig {
path: "/item-classes",
#[derive(Template)] title: Some("Item Class List"),
#[template(path = "item_class_add.html")] page_title: Some(Box::new("Item Class List")),
struct ItemClassAddForm { page_actions: vec![
req: HttpRequest, (templates::helpers::PageAction {
data: Option<NewItemClass>, href: "/item-classes/add",
name: "Add",
}),
],
..Default::default()
},
html! {
table .table {
thead {
tr {
th { "Name" }
th { "Parents" }
}
}
tbody {
@for item_class in item_classes.values() {
tr {
td { a href={ "/item-class/" (item_class.id) } { (item_class.name) } }
td {
@if let Some(parent) = item_class.parent {
@let parent = item_classes.get(&parent).unwrap();
a href={ "/item-class/" (parent.id) } { (parent.name) }
} @else {
"-"
}
}
}
}
}
}
},
))
} }
#[get("/item-classes/add")] #[get("/item-classes/add")]
async fn add_item_class(req: HttpRequest) -> actix_web::Result<impl Responder> { async fn add_item_class() -> actix_web::Result<impl Responder> {
Ok(ItemClassAddForm { req, data: None }) Ok(templates::base(
TemplateConfig {
path: "/items-classes/add",
title: Some("Add Item Class"),
page_title: Some(Box::new("Add Item Class")),
extra_js: vec![FORM_ENSURE_PARENT],
..Default::default()
},
html! {
form method="POST" {
(forms::InputGroup {
r#type: forms::InputType::Text,
name: "name",
title: "Name",
required: true,
..Default::default()
})
// TODO: drop type in favour of determining it on whether parent is set
.mb-3 {
label .form-label for="type" { "Type" }
select .form-select #type name="type" required {
@for variant in ItemClassType::VARIANTS {
option { (variant) }
}
}
}
.mb-3 {
label .form-label for="parent" { "Parent" }
input .form-control #parent type="text" name="parent" disabled;
}
button .btn.btn-primary type="submit" { "Add" }
}
},
))
} }
#[post("/item-classes/add")] #[post("/item-classes/add")]
@ -99,16 +198,8 @@ async fn add_item_class_post(
Ok(web::Redirect::to("/item-class/".to_owned() + &item.id.to_string()).see_other()) Ok(web::Redirect::to("/item-class/".to_owned() + &item.id.to_string()).see_other())
} }
#[derive(Template)]
#[template(path = "item_class_edit.html")]
struct ItemClassEditForm {
req: HttpRequest,
item_class: ItemClass,
}
#[get("/item-class/{id}/edit")] #[get("/item-class/{id}/edit")]
async fn edit_item_class( async fn edit_item_class(
req: HttpRequest,
pool: web::Data<DbPool>, pool: web::Data<DbPool>,
path: web::Path<Uuid>, path: web::Path<Uuid>,
) -> actix_web::Result<impl Responder> { ) -> actix_web::Result<impl Responder> {
@ -118,7 +209,53 @@ async fn edit_item_class(
.await .await
.map_err(error::ErrorInternalServerError)?; .map_err(error::ErrorInternalServerError)?;
Ok(ItemClassEditForm { req, item_class }) let mut title = item_class.name.clone();
title.push_str(" Item Details");
Ok(templates::base(
TemplateConfig {
path: &format!("/items-class/{}/add", id),
title: Some(&title),
page_title: Some(Box::new(item_class.name.clone())),
extra_js: vec![FORM_ENSURE_PARENT],
..Default::default()
},
html! {
form method="POST" {
(forms::InputGroup {
r#type: forms::InputType::Text,
name: "id",
title: "UUID",
disabled: true,
required: true,
value: Some(item_class.id.to_string().as_str()),
})
(forms::InputGroup {
r#type: forms::InputType::Text,
name: "name",
title: "Name",
required: true,
value: Some(&item_class.name),
..Default::default()
})
// TODO: drop type in favour of determining it on whether parent is set
.mb-3 {
label .form-label for="type" { "Type" }
select .form-select #type name="type" required {
@for variant in ItemClassType::VARIANTS {
option selected[variant == item_class.r#type] { (variant) }
}
}
}
.mb-3 {
label .form-label for="parent" { "Parent" }
input .form-control #parent type="text" name="parent" disabled[item_class.parent.is_none()] value=[item_class.parent];
}
button .btn.btn-primary type="submit" { "Edit" }
}
},
))
} }
#[post("/item-class/{id}/edit")] #[post("/item-class/{id}/edit")]

View file

@ -4,9 +4,10 @@
mod item; mod item;
mod item_class; mod item_class;
mod templates;
use actix_web::{get, web, HttpRequest, Responder}; use actix_web::{get, web, Responder};
use askama_actix::Template; use maud::html;
pub fn config(cfg: &mut web::ServiceConfig) { pub fn config(cfg: &mut web::ServiceConfig) {
cfg.service(index) cfg.service(index)
@ -14,13 +15,7 @@ pub fn config(cfg: &mut web::ServiceConfig) {
.configure(item_class::config); .configure(item_class::config);
} }
#[derive(Template)]
#[template(path = "base.html")]
struct Home {
req: HttpRequest,
}
#[get("/")] #[get("/")]
async fn index(req: HttpRequest) -> impl Responder { async fn index() -> impl Responder {
Home { req } templates::base(templates::TemplateConfig::default(), html! {})
} }

View file

@ -0,0 +1,70 @@
// SPDX-FileCopyrightText: 2024 Simon Bruder <simon@sbruder.de>
//
// SPDX-License-Identifier: AGPL-3.0-or-later
use std::fmt;
use maud::{html, Markup, Render};
#[derive(Clone)]
pub enum InputType {
Text,
}
impl fmt::Display for InputType {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Text => write!(f, "text"),
}
}
}
pub struct InputGroup<'a> {
pub r#type: InputType,
pub name: &'a str,
pub title: &'a str,
pub required: bool,
pub disabled: bool,
pub value: Option<&'a str>,
}
impl Default for InputGroup<'_> {
fn default() -> Self {
Self {
r#type: InputType::Text,
name: "placeholder",
title: "Placeholder",
required: false,
disabled: false,
value: None,
}
}
}
impl InputGroup<'_> {
fn main_input(&self) -> Markup {
html! {
input .form-control #(self.name) name={ (self.name) } type={ (self.r#type) } required[self.required] disabled[self.disabled] value=[self.value];
}
}
}
impl Render for InputGroup<'_> {
fn render(&self) -> Markup {
html! {
.mb-3 {
label .form-label for={ (self.name) } { (self.title) }
@if self.required {
(self.main_input())
} @else {
.input-group {
.input-group-text {
input .form-check-input.mt-0.input-toggle type="checkbox" checked[!self.disabled];
}
(self.main_input())
}
}
}
}
}
}

View file

@ -0,0 +1,136 @@
// SPDX-FileCopyrightText: 2024 Simon Bruder <simon@sbruder.de>
//
// SPDX-License-Identifier: AGPL-3.0-or-later
use std::collections::HashMap;
use std::fmt::{self, Display};
use crate::models::*;
use maud::{html, Markup, PreEscaped, Render};
use uuid::Uuid;
pub enum Css<'a> {
File(&'a str),
#[allow(dead_code)]
Inline(&'a str),
}
impl Render for Css<'_> {
fn render(&self) -> Markup {
html! {
@match self {
Self::File(path) => {
link rel="stylesheet" href=(path);
},
Self::Inline(content) => {
style { (PreEscaped(content)) }
},
}
}
}
}
pub enum Js<'a> {
File(&'a str),
Inline(&'a str),
}
impl Render for Js<'_> {
fn render(&self) -> Markup {
html! {
@match self {
Self::File(path) => {
script src=(path) { }
},
Self::Inline(content) => {
script { (PreEscaped(content)) }
},
}
}
}
}
#[derive(Clone)]
pub enum ItemName {
Item(String),
Class(String),
None,
}
impl ItemName {
pub fn new(item: &Item, class: &ItemClass) -> Self {
if let Some(ref name) = item.name {
Self::Item(name.to_string())
} else {
Self::Class(class.name.clone())
}
}
}
impl ItemName {
pub fn terse(self) -> Self {
match self {
Self::Item(_) => self,
Self::Class(_) | Self::None => Self::None,
}
}
}
impl Display for ItemName {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::Item(name) => write!(f, "{name}"),
Self::Class(name) => write!(f, "*{name}*"),
Self::None => write!(f, "[no name]"),
}
}
}
impl Render for ItemName {
fn render(&self) -> Markup {
html! {
@match self {
Self::Item(name) => { (name) },
Self::Class(name) => { em { (name) } },
Self::None => { em { "[no name]" } },
}
}
}
}
pub struct PageAction<'a> {
pub href: &'a str,
pub name: &'a str,
}
impl Render for PageAction<'_> {
fn render(&self) -> Markup {
html! {
a .btn.btn-primary href=(self.href) { (self.name) }
}
}
}
pub fn parents_breadcrumb(
item: &Item,
item_class: &ItemClass,
parents: &[Item],
parents_item_classes: &HashMap<Uuid, ItemClass>,
full: bool,
) -> Markup {
const LIMIT: usize = 3;
html! {
ol .breadcrumb .mb-0 {
@if !full && parents.len() > LIMIT {
li .breadcrumb-item { "" }
}
@for parent in parents.iter().rev().take(LIMIT).rev() {
li .breadcrumb-item {
a href={ "/item/" (parent.id) } { (ItemName::new(parent, parents_item_classes.get(&parent.class).unwrap()) )}
}
}
li .breadcrumb-item.active { (ItemName::new(item, item_class)) }
}
}
}

View file

@ -0,0 +1,128 @@
// SPDX-FileCopyrightText: 2024 Simon Bruder <simon@sbruder.de>
//
// SPDX-License-Identifier: AGPL-3.0-or-later
pub mod forms;
pub mod helpers;
use maud::{html, Markup, Render, DOCTYPE};
use helpers::*;
const BRANDING: &str = "li7y";
const NAVBAR_ITEMS: &[(&str, &str)] = &[
("/", "Home"),
("/items", "Items"),
("/item-classes", "Item Classes"),
];
fn navbar(path: &str) -> Markup {
html! {
nav .navbar.navbar-expand-lg.bg-body-secondary {
div .container {
a .navbar-brand href="/" { (BRANDING) }
button .navbar-toggler type="button" data-bs-toggle="collapse" data-bs-target="#navbar-expander" {
span .navbar-toggler-icon { }
}
div .collapse.navbar-collapse #navbar-expander {
ul .navbar-nav.me-auto.mb-2.mb-md-0 {
@for (target, name) in NAVBAR_ITEMS {
li .nav-item { a .nav-link .active[path == *target] href=(target) { (name) } }
}
}
}
}
}
}
}
fn footer() -> Markup {
html! {
footer .mt-auto.py-3.bg-body-tertiary.text-body-tertiary {
div .container {
p .mb-0 { "li7y is free software, released under the terms of the AGPL v3" }
}
}
}
}
pub struct TemplateConfig<'a> {
pub path: &'a str,
pub title: Option<&'a str>,
pub page_title: Option<Box<dyn Render>>,
pub page_actions: Vec<PageAction<'a>>,
pub extra_css: Vec<Css<'a>>,
pub extra_js: Vec<Js<'a>>,
}
impl Default for TemplateConfig<'_> {
fn default() -> Self {
Self {
path: "/",
title: None,
page_title: None,
page_actions: Vec::new(),
extra_css: Vec::new(),
extra_js: Vec::new(),
}
}
}
pub fn base(config: TemplateConfig, content: Markup) -> Markup {
html! {
(DOCTYPE)
html .h-100 {
head {
meta charset="utf-8";
title {
@if let Some(ref title) = config.title {
(title) " "
}
(BRANDING)
}
meta name="viewport" content="width=device-width, initial-scale=1";
(Css::File("/static/vendor/bootstrap.min.css"))
@for css in config.extra_css {
(css)
}
}
body .d-flex.flex-column.h-100 {
(navbar(config.path))
main .container.my-4 {
div .d-flex.justify-content-between.mb-3 {
div {
@if let Some(ref page_title) = config.page_title {
h2 {
(page_title)
}
}
}
div {
@for page_action in config.page_actions {
(page_action)
}
}
}
(content)
}
(footer())
(Js::File("/static/vendor/bootstrap.bundle.min.js"))
(Js::File("/static/app.js"))
// TODO this is not the best way, but it works for now
@for js in config.extra_js {
(js)
}
}
}
}
}

View file

@ -1,35 +0,0 @@
{#
SPDX-FileCopyrightText: 2024 Simon Bruder <simon@sbruder.de>
SPDX-License-Identifier: AGPL-3.0-or-later
#}
{% extends "base.html" %}
{% block title %}{% block page_title %}Add Item{% endblock %} {{ branding }}{% endblock %}
{% block main %}
<form method="POST">
<div class="mb-3">
<label for="name" class="form-label">Name</label>
<div class="input-group">
<div class="input-group-text">
<input type="checkbox" class="form-check-input mt-0 input-toggle"{% if let Some(data) = data %}{% if data.name.is_some() %} checked{% endif %}{% endif %}>
</div>
<input type="text" class="form-control" id="name" name="name"{% if let Some(data) = data %}{% if let Some(name) = data.name %} value="{{ name }}"{% else %} disabled{% endif %}{% else %}disabled{% endif %}>
</div>
</div>
<div class="mb-3">
<label for="class" class="form-label">Class</label>
<input type="text" class="form-control" id="class" name="class" required{% if let Some(data) = data %} value="{{ data.class }}"{% endif %}>
</div>
<div class="mb-3">
<label for="parent" class="form-label">Parent</label>
<div class="input-group">
<div class="input-group-text">
<input type="checkbox" class="form-check-input mt-0 input-toggle"{% if let Some (data) = data %}{% if data.parent.is_some() %} checked{% endif %}{% endif %}>
</div>
<input type="text" class="form-control" id="parent" name="parent"{% if let Some(data) = data %}{% if let Some(parent) = data.parent %} value="{{ parent }}"{% else %} disabled{% endif %}{% else %}disabled{% endif %}>
</div>
</div>
<button type="submit" class="btn btn-primary">Add</button>
</form>
{% endblock %}

View file

@ -1,50 +0,0 @@
{#
SPDX-FileCopyrightText: 2024 Simon Bruder <simon@sbruder.de>
SPDX-License-Identifier: AGPL-3.0-or-later
#}
{% extends "base.html" %}
{% block title %}{% block page_title %}Add Item Class{% endblock %} {{ branding }}{% endblock %}
{% block main %}
<form method="POST">
<div class="mb-3">
<label for="name" class="form-label">Name</label>
<input type="text" class="form-control" id="name" name="name" required{% if let Some(data) = data %} value="{{ data.name }}"{% endif %}>
</div>
<div class="mb-3">
<label for="type" class="form-label">Type</label>
<select class="form-select" id="type" name="type" required{% if let Some(data) = data %} value="{{ data.type }}"{% endif %}>
{% for variant in ItemClassType::VARIANTS %}
<option>{{ variant }}</option>
{% endfor %}
</select>
</div>
<div class="mb-3">
<label for="parent" class="form-label">Parent</label>
<input type="text" class="form-control" id="parent" name="parent" disabled{% if let Some(data) = data %}{% if let Some(parent) = data.parent %} value="{{ parent }}"{% endif %}{% endif %}>
</div>
<button type="submit" class="btn btn-primary">Add</button>
</form>
{% endblock %}
{% block extra_scripts %}
<script>
(() => {
document.getElementById("type").addEventListener("change", e => {
console.log(e)
let parentInput = document.getElementById("parent")
switch (e.target.value) {
case "generic":
parentInput.disabled = true
parentInput.value = ""
break
case "specific":
parentInput.disabled = false
break
default:
console.error("invalid type!")
}
})
})()
</script>
{% endblock %}

View file

@ -1,49 +0,0 @@
{#
SPDX-FileCopyrightText: 2024 Simon Bruder <simon@sbruder.de>
SPDX-License-Identifier: AGPL-3.0-or-later
#}
{% extends "base.html" %}
{% block title %}{% block page_title %}{{ item_class.name }}{% endblock %} Item Class Details {{ branding }}{% endblock %}
{% block page_actions %}
<a class="btn btn-warning" href="/item-class/{{ item_class.id }}/edit">Edit</a>
{% endblock %}
{% block main %}
<table class="table">
<tbody>
<tr>
<th>
UUID
</th>
<td>{{ item_class.id }}</td>
</tr>
<tr>
<th>
Name
</th>
<td>
{{ item_class.name }}
</td>
</tr>
<tr>
<th>
Type
</th>
<td>
{{ item_class.type }}
</td>
</tr>
{% if let Some(parent) = parent -%}
<tr>
<th>
Parent
</th>
<td>
<a href="/item-class/{{ parent.id }}">{{ parent.name }}</a>
</td>
</tr>
{%- endif %}
</tbody>
</table>
{% endblock %}

View file

@ -1,29 +0,0 @@
{#
SPDX-FileCopyrightText: 2024 Simon Bruder <simon@sbruder.de>
SPDX-License-Identifier: AGPL-3.0-or-later
#}
{% extends "base.html" %}
{% block title %}{% block page_title %}Item Class List{% endblock %} {{ branding }}{% endblock %}
{% block page_actions %}
<a class="btn btn-primary" href="/item-classes/add">Add</a>
{% endblock %}
{% block main %}
<table class="table">
<thead>
<tr>
<th>Name</th>
<th>Parent</th>
</tr>
</thead>
<tbody>
{% for item_class in item_classes.values() -%}
<tr>
<td><a href="/item-class/{{ item_class.id }}">{{ item_class.name }}</a></td>
<td>{% if let Some(parent) = item_class.parent %}<a href="/item-class/{{ parent }}">{{ item_classes.get(parent).unwrap().name }}</a>{% else %}-{% endif %}</td>
</tr>
{% endfor -%}
</tbody>
</table>
{% endblock %}

View file

@ -1,58 +0,0 @@
{#
SPDX-FileCopyrightText: 2024 Simon Bruder <simon@sbruder.de>
SPDX-License-Identifier: AGPL-3.0-or-later
#}
{%- import "macros.html" as macros -%}
{% extends "base.html" %}
{% block title %}{% block page_title %}{% call macros::item_name(item, item_class, false) %}{% endblock %} Item Details {{ branding }}{% endblock %}
{% block page_actions %}
<a class="btn btn-warning" href="/item/{{ item.id }}/edit">Edit</a>
{% endblock %}
{% block main %}
<table class="table">
<tbody>
<tr>
<th>
UUID
</th>
<td>{{ item.id }}</td>
</tr>
<tr>
<th>
Name
</th>
<td>
{% call macros::item_name_terse(item, true) %}
</td>
</tr>
<tr>
<th>
Class
</th>
<td>
<a href="/item-class/{{ item.class }}">{{ item_class.name }}</a>
</td>
</tr>
<tr>
<th>
Parents
</th>
<td>
{%- call macros::parents_breadcrumb(item, parents, item_classes, full=true) %}
</td>
</tr>
</tbody>
</table>
{% if children.len() != 0 %}
<h3 class="mt-4">Direct Children</h3>
<ul>
{% for child in children %}
<li><a href="/item/{{ child.id }}">{% call macros::item_name(child, item_classes.get(child.class).unwrap(), true) %}</a></li>
{% endfor %}
</ul>
{% endif %}
{% endblock %}

View file

@ -1,40 +0,0 @@
{#
SPDX-FileCopyrightText: 2024 Simon Bruder <simon@sbruder.de>
SPDX-License-Identifier: AGPL-3.0-or-later
#}
{%- import "macros.html" as macros -%}
{% extends "base.html" %}
{% block title %}{% block page_title %}{% call macros::item_name(item, item_class, false) %}{% endblock %} Edit Item {{ branding }}{% endblock %}
{% block main %}
<form method="POST">
<div class="mb-3">
<label for="uuid" class="form-label">UUID</label>
<input type="text" class="form-control" id="uuid" disabled required value="{{ item.id }}">
</div>
<div class="mb-3">
<label for="name" class="form-label">Name</label>
<div class="input-group">
<div class="input-group-text">
<input type="checkbox" class="form-check-input mt-0 input-toggle"{% if item.name.is_some() %} checked{% endif %}>
</div>
<input type="text" class="form-control" id="name" name="name"{% if let Some(name) = item.name %} value="{{ name }}"{% else %} disabled{% endif %}>
</div>
</div>
<div class="mb-3">
<label for="class" class="form-label">Class</label>
<input type="text" class="form-control" id="class" name="class" required value="{{ item.class }}">
</div>
<div class="mb-3">
<label for="parent" class="form-label">Parent</label>
<div class="input-group">
<div class="input-group-text">
<input type="checkbox" class="form-check-input mt-0 input-toggle"{% if item.parent.is_some() %} checked{% endif %}>
</div>
<input type="text" class="form-control" id="parent" name="parent"{% if let Some(parent) = item.parent %} value="{{ parent }}"{% else %} disabled{% endif %}>
</div>
</div>
<button type="submit" class="btn btn-primary">Edit</button>
</form>
{% endblock %}

View file

@ -1,37 +0,0 @@
{#
SPDX-FileCopyrightText: 2024 Simon Bruder <simon@sbruder.de>
SPDX-License-Identifier: AGPL-3.0-or-later
#}
{%- import "macros.html" as macros -%}
{% extends "base.html" %}
{% block title %}{% block page_title %}Item List{% endblock %} {{ branding }}{% endblock %}
{% block page_actions %}
<a class="btn btn-primary" href="/items/add">Add</a>
{% endblock %}
{% block main %}
<table class="table">
<thead>
<tr>
<th>Name</th>
<th>Class</th>
<th>Parents</th>
</tr>
</thead>
<tbody>
{% for item in item_list -%}
{% let class = item_classes.get(item.class).unwrap() %}
{# inlining this breaks? #}
{%- let parents = item_parents.get(item.id).unwrap() %}
<tr>
<td><a href="/item/{{ item.id }}">{% call macros::item_name_terse(item, true) %}</a></td>
<td><a href="/item-class/{{ class.id }}">{{ class.name }}</a></td>
<td>
{%- call macros::parents_breadcrumb(item, parents, item_classes, full=false) %}
</td>
</tr>
{% endfor -%}
</tbody>
</table>
{% endblock %}

View file

@ -1,36 +0,0 @@
{#
SPDX-FileCopyrightText: 2024 Simon Bruder <simon@sbruder.de>
SPDX-License-Identifier: AGPL-3.0-or-later
#}
{% macro emphasize(text, html) %}
{%- if html %}<em>{{ text }}</em>{% else %}*{{ text }}*{% endif %}
{%- endmacro %}
{% macro item_name_generic(name, fallback, html) %}
{%- if let Some(name) = name %}{{ name }}{% else %}{% call emphasize(fallback, html) %}{% endif %}
{%- endmacro %}
{% macro item_name(item, item_class, html) %}
{%- call item_name_generic(item.name, item_class.name, html) %}
{%- endmacro %}
{% macro item_name_terse(item, html) %}
{%- call item_name_generic(item.name, "[no name]", html) %}
{%- endmacro %}
{% macro parents_breadcrumb(item, parents, parents_item_classes, full) %}
{%- let limit = 3 %}
{%- let limited = parents.len() > limit && !full %}
{%- let parents = parents.iter().rev().take(limit.into()).rev() %}
<ol class="breadcrumb mb-0">
{%- if limited %}
<li class="breadcrumb-item"></li>
{%- endif %}
{%- for parent in parents %}
<li class="breadcrumb-item"><a href="/item/{{ parent.id }}">{% call item_name(parent, parents_item_classes.get(parent.class).unwrap(), true) %}</a></li>
{%- endfor %}
<li class="breadcrumb-item active">{% call item_name(item, item_classes.get(item.class).unwrap(), true) %}</li>
</ol>
{%- endmacro %}