From 4a2dc721d4fe8507c8544ea83cb83d638fdbce47 Mon Sep 17 00:00:00 2001 From: Simon Bruder Date: Thu, 11 Jul 2024 01:12:34 +0200 Subject: [PATCH] Switch to maud for templating --- Cargo.lock | 121 +++---------- Cargo.toml | 3 +- src/frontend/item.rs | 273 ++++++++++++++++++++---------- src/frontend/item_class.rs | 231 ++++++++++++++++++++----- src/frontend/mod.rs | 15 +- src/frontend/templates/forms.rs | 70 ++++++++ src/frontend/templates/helpers.rs | 136 +++++++++++++++ src/frontend/templates/mod.rs | 128 ++++++++++++++ templates/item_add.html | 35 ---- templates/item_class_add.html | 50 ------ templates/item_class_details.html | 49 ------ templates/item_class_list.html | 29 ---- templates/item_details.html | 58 ------- templates/item_edit.html | 40 ----- templates/item_list.html | 37 ---- templates/macros.html | 36 ---- 16 files changed, 736 insertions(+), 575 deletions(-) create mode 100644 src/frontend/templates/forms.rs create mode 100644 src/frontend/templates/helpers.rs create mode 100644 src/frontend/templates/mod.rs delete mode 100644 templates/item_add.html delete mode 100644 templates/item_class_add.html delete mode 100644 templates/item_class_details.html delete mode 100644 templates/item_class_list.html delete mode 100644 templates/item_details.html delete mode 100644 templates/item_edit.html delete mode 100644 templates/item_list.html delete mode 100644 templates/macros.html diff --git a/Cargo.lock b/Cargo.lock index d34e88f..a331d21 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -347,60 +347,6 @@ version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "async-trait" version = "0.1.80" @@ -451,15 +397,6 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "bitflags" version = "1.3.2" @@ -1082,15 +1019,6 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" -[[package]] -name = "humansize" -version = "2.1.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6cb51c9a029ddc91b07a787f1d86b53ccfa49b0e86688c946ebe8d3555685dd7" -dependencies = [ - "libm", -] - [[package]] name = "humantime" version = "2.1.0" @@ -1180,8 +1108,6 @@ version = "0.0.0" dependencies = [ "actix-files", "actix-web", - "askama", - "askama_actix", "barcoders", "datamatrix", "diesel", @@ -1190,6 +1116,7 @@ dependencies = [ "diesel_migrations", "env_logger", "log", + "maud", "mime", "printpdf", "rust-fontconfig", @@ -1204,12 +1131,6 @@ version = "0.2.155" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "97b3888a4aecf77e811145cadf6eef5901f4782c53886191b2f693f24761847c" -[[package]] -name = "libm" -version = "0.2.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ec2a862134d2a7d32d7983ddcdd1c4923530833c9f2ea1a44fc5fa473989058" - [[package]] name = "linked-hash-map" version = "0.5.6" @@ -1266,6 +1187,30 @@ dependencies = [ "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]] name = "md-5" version = "0.10.6" @@ -1325,12 +1270,6 @@ dependencies = [ "unicase", ] -[[package]] -name = "minimal-lexical" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" - [[package]] name = "miniz_oxide" version = "0.7.4" @@ -1362,16 +1301,6 @@ dependencies = [ "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]] name = "num-conv" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 22fe2d2..6ba2869 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,8 +12,6 @@ license = "AGPL-3.0-or-later" [dependencies] actix-files = "0.6.6" 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"] } datamatrix = "0.3.1" 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"] } env_logger = "0.11.3" log = "0.4.21" +maud = { version = "0.26.0", features = ["actix-web"] } mime = "0.3.17" printpdf = "0.7.0" rust-fontconfig = "0.1.7" diff --git a/src/frontend/item.rs b/src/frontend/item.rs index 3a34360..80dc380 100644 --- a/src/frontend/item.rs +++ b/src/frontend/item.rs @@ -4,10 +4,11 @@ use std::collections::HashMap; -use actix_web::{error, get, post, web, HttpRequest, Responder}; -use askama_actix::Template; +use actix_web::{error, get, post, web, Responder}; +use maud::html; use uuid::Uuid; +use super::templates::{self, forms, TemplateConfig}; use crate::manage; use crate::models::*; use crate::DbPool; @@ -21,20 +22,8 @@ pub fn config(cfg: &mut web::ServiceConfig) { .service(edit_item_post); } -#[derive(Template)] -#[template(path = "item_details.html")] -struct ItemDetails { - req: HttpRequest, - item: Item, - item_class: ItemClass, - item_classes: HashMap, - parents: Vec, - children: Vec, -} - #[get("/item/{id}")] async fn show_item( - req: HttpRequest, pool: web::Data, path: web::Path, ) -> actix_web::Result { @@ -48,9 +37,6 @@ async fn show_item( .await .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) .await .map_err(error::ErrorInternalServerError)?; @@ -59,37 +45,62 @@ async fn show_item( .await .map_err(error::ErrorInternalServerError)?; - Ok(ItemDetails { - req, - item, - item_class, - item_classes, - parents, - children, - }) -} + let item_class = item_classes.get(&item.class).unwrap(); -#[derive(Template)] -#[template(path = "item_list.html")] -struct ItemList { - req: HttpRequest, - // Both a Vec and a HashMap are used to have both the natural order, - // as well as arbitrary access capabilities. - item_list: Vec, - #[allow(dead_code)] // remove once item_parents can be constructed in the template - items: HashMap, - item_classes: HashMap, - #[allow(dead_code)] // remove once item_parents can be constructed in the template - item_tree: HashMap>, - // to overcome askama’s lack of support for closures - item_parents: HashMap>, + let item_name = templates::helpers::ItemName::new(&item, item_class); + let mut title = item_name.to_string(); + title.push_str(" – Item Details"); + + Ok(templates::base( + TemplateConfig { + path: &format!("/item/{}", item.id), + title: Some(&title), + page_title: Some(Box::new(item_name.clone())), + page_actions: vec![ + (templates::helpers::PageAction { + href: &format!("/item/{}/edit", item.id), + name: "Edit", + }), + ], + ..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")] -async fn list_items( - req: HttpRequest, - pool: web::Data, -) -> actix_web::Result { +async fn list_items(pool: web::Data) -> actix_web::Result { let item_list = manage::item::get_all(&mut pool.get().await.unwrap()) .await .map_err(error::ErrorInternalServerError)?; @@ -107,41 +118,95 @@ async fn list_items( .map_err(error::ErrorInternalServerError)?; // TODO: remove clone (should be possible without it) - let item_parents = items - .clone() - .into_iter() - .map(|(id, item)| { - (id, { - item_tree - .get(&item.id) - .unwrap() + let item_parents: HashMap> = item_tree + .iter() + .map(|(id, parent_ids)| { + ( + *id, + parent_ids .iter() - .map(|is| items.get(is).unwrap().clone()) - .collect() - }) + .map(|parent_id| items.get(parent_id).unwrap().clone()) + .collect(), + ) }) .collect(); - Ok(ItemList { - req, - item_list, - items, - item_classes, - item_tree, - item_parents, - }) -} - -#[derive(Template)] -#[template(path = "item_add.html")] -struct ItemAddForm { - req: HttpRequest, - data: Option, + Ok(templates::base( + TemplateConfig { + path: "/items", + title: Some("Item List"), + page_title: Some(Box::new("Item List")), + page_actions: vec![ + (templates::helpers::PageAction { + href: "/items/add", + name: "Add", + }), + ], + ..Default::default() + }, + html! { + 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")] -async fn add_item(req: HttpRequest) -> actix_web::Result { - Ok(ItemAddForm { req, data: None }) +async fn add_item() -> actix_web::Result { + 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")] @@ -155,17 +220,8 @@ async fn add_item_post( 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")] async fn edit_item( - req: HttpRequest, pool: web::Data, path: web::Path, ) -> actix_web::Result { @@ -179,11 +235,56 @@ async fn edit_item( .await .map_err(error::ErrorInternalServerError)?; - Ok(ItemEditForm { - req, - item, - item_class, - }) + let item_name = templates::helpers::ItemName::new(&item, &item_class); + let mut title = item_name.to_string(); + title.push_str(" – Edit Item"); + + 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")] diff --git a/src/frontend/item_class.rs b/src/frontend/item_class.rs index 8cbc9fc..f4cda33 100644 --- a/src/frontend/item_class.rs +++ b/src/frontend/item_class.rs @@ -2,16 +2,35 @@ // // SPDX-License-Identifier: AGPL-3.0-or-later -use std::collections::HashMap; - -use actix_web::{error, get, post, web, HttpRequest, Responder}; -use askama_actix::Template; +use actix_web::{error, get, post, web, Responder}; +use maud::html; use uuid::Uuid; +use super::templates::{self, forms, TemplateConfig}; use crate::manage; use crate::models::*; 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) { cfg.service(show_item_class) .service(list_item_classes) @@ -21,17 +40,8 @@ pub fn config(cfg: &mut web::ServiceConfig) { .service(edit_item_class_post); } -#[derive(Template)] -#[template(path = "item_class_details.html")] -struct ItemClassDetails { - req: HttpRequest, - item_class: ItemClass, - parent: Option, -} - #[get("/item-class/{id}")] async fn show_item_class( - req: HttpRequest, pool: web::Data, path: web::Path, ) -> actix_web::Result { @@ -50,42 +60,131 @@ async fn show_item_class( None => None, }; - Ok(ItemClassDetails { - req, - item_class, - parent, - }) -} + let mut title = item_class.name.clone(); + title.push_str(" – Item Details"); -#[derive(Template)] -#[template(path = "item_class_list.html")] -struct ItemClassList { - req: HttpRequest, - item_classes: HashMap, + Ok(templates::base( + TemplateConfig { + path: &format!("/item-class/{}", item_class.id), + title: Some(&title), + 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")] -async fn list_item_classes( - req: HttpRequest, - pool: web::Data, -) -> actix_web::Result { +async fn list_item_classes(pool: web::Data) -> actix_web::Result { let item_classes = manage::item_class::get_all_as_map(&mut pool.get().await.unwrap()) .await .map_err(error::ErrorInternalServerError)?; - Ok(ItemClassList { req, item_classes }) -} - -#[derive(Template)] -#[template(path = "item_class_add.html")] -struct ItemClassAddForm { - req: HttpRequest, - data: Option, + Ok(templates::base( + TemplateConfig { + path: "/item-classes", + title: Some("Item Class List"), + page_title: Some(Box::new("Item Class List")), + page_actions: vec![ + (templates::helpers::PageAction { + 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")] -async fn add_item_class(req: HttpRequest) -> actix_web::Result { - Ok(ItemClassAddForm { req, data: None }) +async fn add_item_class() -> actix_web::Result { + 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")] @@ -99,16 +198,8 @@ async fn add_item_class_post( 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")] async fn edit_item_class( - req: HttpRequest, pool: web::Data, path: web::Path, ) -> actix_web::Result { @@ -118,7 +209,53 @@ async fn edit_item_class( .await .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")] diff --git a/src/frontend/mod.rs b/src/frontend/mod.rs index 0594e79..88e78fc 100644 --- a/src/frontend/mod.rs +++ b/src/frontend/mod.rs @@ -4,9 +4,10 @@ mod item; mod item_class; +mod templates; -use actix_web::{get, web, HttpRequest, Responder}; -use askama_actix::Template; +use actix_web::{get, web, Responder}; +use maud::html; pub fn config(cfg: &mut web::ServiceConfig) { cfg.service(index) @@ -14,13 +15,7 @@ pub fn config(cfg: &mut web::ServiceConfig) { .configure(item_class::config); } -#[derive(Template)] -#[template(path = "base.html")] -struct Home { - req: HttpRequest, -} - #[get("/")] -async fn index(req: HttpRequest) -> impl Responder { - Home { req } +async fn index() -> impl Responder { + templates::base(templates::TemplateConfig::default(), html! {}) } diff --git a/src/frontend/templates/forms.rs b/src/frontend/templates/forms.rs new file mode 100644 index 0000000..89992ac --- /dev/null +++ b/src/frontend/templates/forms.rs @@ -0,0 +1,70 @@ +// SPDX-FileCopyrightText: 2024 Simon Bruder +// +// 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()) + } + } + } + } + } +} diff --git a/src/frontend/templates/helpers.rs b/src/frontend/templates/helpers.rs new file mode 100644 index 0000000..0b7238f --- /dev/null +++ b/src/frontend/templates/helpers.rs @@ -0,0 +1,136 @@ +// SPDX-FileCopyrightText: 2024 Simon Bruder +// +// 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, + 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)) } + } + } +} diff --git a/src/frontend/templates/mod.rs b/src/frontend/templates/mod.rs new file mode 100644 index 0000000..934b7cb --- /dev/null +++ b/src/frontend/templates/mod.rs @@ -0,0 +1,128 @@ +// SPDX-FileCopyrightText: 2024 Simon Bruder +// +// 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>, + pub page_actions: Vec>, + pub extra_css: Vec>, + pub extra_js: Vec>, +} + +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) + } + } + } + } +} diff --git a/templates/item_add.html b/templates/item_add.html deleted file mode 100644 index 1fdf1d8..0000000 --- a/templates/item_add.html +++ /dev/null @@ -1,35 +0,0 @@ -{# -SPDX-FileCopyrightText: 2024 Simon Bruder - -SPDX-License-Identifier: AGPL-3.0-or-later -#} - -{% extends "base.html" %} -{% block title %}{% block page_title %}Add Item{% endblock %} – {{ branding }}{% endblock %} -{% block main %} -
-
- -
-
- -
- -
-
-
- - -
-
- -
-
- -
- -
-
- -
-{% endblock %} diff --git a/templates/item_class_add.html b/templates/item_class_add.html deleted file mode 100644 index 944f3f7..0000000 --- a/templates/item_class_add.html +++ /dev/null @@ -1,50 +0,0 @@ -{# -SPDX-FileCopyrightText: 2024 Simon Bruder - -SPDX-License-Identifier: AGPL-3.0-or-later -#} - -{% extends "base.html" %} -{% block title %}{% block page_title %}Add Item Class{% endblock %} – {{ branding }}{% endblock %} -{% block main %} -
-
- - -
-
- - -
-
- - -
- -
-{% endblock %} -{% block extra_scripts %} - -{% endblock %} diff --git a/templates/item_class_details.html b/templates/item_class_details.html deleted file mode 100644 index 38c6d60..0000000 --- a/templates/item_class_details.html +++ /dev/null @@ -1,49 +0,0 @@ -{# -SPDX-FileCopyrightText: 2024 Simon Bruder - -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 %} -Edit -{% endblock %} -{% block main %} - - - - - - - - - - - - - - - {% if let Some(parent) = parent -%} - - - - - {%- endif %} - -
- UUID - {{ item_class.id }}
- Name - - {{ item_class.name }} -
- Type - - {{ item_class.type }} -
- Parent - - {{ parent.name }} -
-{% endblock %} diff --git a/templates/item_class_list.html b/templates/item_class_list.html deleted file mode 100644 index 89b835a..0000000 --- a/templates/item_class_list.html +++ /dev/null @@ -1,29 +0,0 @@ -{# -SPDX-FileCopyrightText: 2024 Simon Bruder - -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 %} -Add -{% endblock %} -{% block main %} - - - - - - - - - {% for item_class in item_classes.values() -%} - - - - - {% endfor -%} - -
NameParent
{{ item_class.name }}{% if let Some(parent) = item_class.parent %}{{ item_classes.get(parent).unwrap().name }}{% else %}-{% endif %}
-{% endblock %} diff --git a/templates/item_details.html b/templates/item_details.html deleted file mode 100644 index b8ca08b..0000000 --- a/templates/item_details.html +++ /dev/null @@ -1,58 +0,0 @@ -{# -SPDX-FileCopyrightText: 2024 Simon Bruder - -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 %} -Edit -{% endblock %} -{% block main %} - - - - - - - - - - - - - - - - - - - -
- UUID - {{ item.id }}
- Name - - {% call macros::item_name_terse(item, true) %} -
- Class - - {{ item_class.name }} -
- Parents - - {%- call macros::parents_breadcrumb(item, parents, item_classes, full=true) %} -
- -{% if children.len() != 0 %} -

Direct Children

- - -{% endif %} -{% endblock %} diff --git a/templates/item_edit.html b/templates/item_edit.html deleted file mode 100644 index 791e37e..0000000 --- a/templates/item_edit.html +++ /dev/null @@ -1,40 +0,0 @@ -{# -SPDX-FileCopyrightText: 2024 Simon Bruder - -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 %} -
-
- - -
-
- -
-
- -
- -
-
-
- - -
-
- -
-
- -
- -
-
- -
-{% endblock %} diff --git a/templates/item_list.html b/templates/item_list.html deleted file mode 100644 index 08a1ddf..0000000 --- a/templates/item_list.html +++ /dev/null @@ -1,37 +0,0 @@ -{# -SPDX-FileCopyrightText: 2024 Simon Bruder - -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 %} -Add -{% endblock %} -{% block main %} - - - - - - - - - - {% for item in item_list -%} - {% let class = item_classes.get(item.class).unwrap() %} - {# inlining this breaks? #} - {%- let parents = item_parents.get(item.id).unwrap() %} - - - - - - {% endfor -%} - -
NameClassParents
{% call macros::item_name_terse(item, true) %}{{ class.name }} - {%- call macros::parents_breadcrumb(item, parents, item_classes, full=false) %} -
-{% endblock %} diff --git a/templates/macros.html b/templates/macros.html deleted file mode 100644 index e2c90ec..0000000 --- a/templates/macros.html +++ /dev/null @@ -1,36 +0,0 @@ -{# -SPDX-FileCopyrightText: 2024 Simon Bruder - -SPDX-License-Identifier: AGPL-3.0-or-later -#} - -{% macro emphasize(text, html) %} -{%- if html %}{{ text }}{% 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() %} - -{%- endmacro %}