Switch to maud for templating

This commit is contained in:
Simon Bruder 2024-07-11 01:12:34 +02:00
parent 11be165f6d
commit 4f7d1808d4
Signed by: simon
GPG key ID: 347FF8699CDA0776
18 changed files with 736 additions and 692 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"
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"

View file

@ -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"

View file

@ -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<Uuid, ItemClass>,
parents: Vec<Item>,
children: Vec<Item>,
}
#[get("/item/{id}")]
async fn show_item(
req: HttpRequest,
pool: web::Data<DbPool>,
path: web::Path<Uuid>,
) -> actix_web::Result<impl Responder> {
@ -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();
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)) }
}
}
#[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<Item>,
#[allow(dead_code)] // remove once item_parents can be constructed in the template
items: HashMap<Uuid, Item>,
item_classes: HashMap<Uuid, ItemClass>,
#[allow(dead_code)] // remove once item_parents can be constructed in the template
item_tree: HashMap<Uuid, Vec<Uuid>>,
// to overcome askamas lack of support for closures
item_parents: HashMap<Uuid, Vec<Item>>,
@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<DbPool>,
) -> actix_web::Result<impl Responder> {
async fn list_items(pool: web::Data<DbPool>) -> actix_web::Result<impl Responder> {
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<Uuid, Vec<Item>> = item_tree
.iter()
.map(|is| items.get(is).unwrap().clone())
.collect()
})
.map(|(id, parent_ids)| {
(
*id,
parent_ids
.iter()
.map(|parent_id| items.get(parent_id).unwrap().clone())
.collect(),
)
})
.collect();
Ok(ItemList {
req,
item_list,
items,
item_classes,
item_tree,
item_parents,
})
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" }
}
#[derive(Template)]
#[template(path = "item_add.html")]
struct ItemAddForm {
req: HttpRequest,
data: Option<NewItem>,
}
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<impl Responder> {
Ok(ItemAddForm { req, data: None })
async fn add_item() -> actix_web::Result<impl Responder> {
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<DbPool>,
path: web::Path<Uuid>,
) -> actix_web::Result<impl Responder> {
@ -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")]

View file

@ -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<ItemClass>,
}
#[get("/item-class/{id}")]
async fn show_item_class(
req: HttpRequest,
pool: web::Data<DbPool>,
path: web::Path<Uuid>,
) -> actix_web::Result<impl Responder> {
@ -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<Uuid, ItemClass>,
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<DbPool>,
) -> actix_web::Result<impl Responder> {
async fn list_item_classes(pool: web::Data<DbPool>) -> actix_web::Result<impl Responder> {
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 })
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" }
}
#[derive(Template)]
#[template(path = "item_class_add.html")]
struct ItemClassAddForm {
req: HttpRequest,
data: Option<NewItemClass>,
}
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<impl Responder> {
Ok(ItemClassAddForm { req, data: None })
async fn add_item_class() -> actix_web::Result<impl Responder> {
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<DbPool>,
path: web::Path<Uuid>,
) -> actix_web::Result<impl Responder> {
@ -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")]

View file

@ -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! {})
}

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,65 +0,0 @@
{#
SPDX-FileCopyrightText: 2024 Simon Bruder <simon@sbruder.de>
SPDX-License-Identifier: AGPL-3.0-or-later
#}
{% let branding = "li7y" -%}
<!DOCTYPE html>
<html class="h-100">
<head>
<meta charset="utf-8">
<title>{% block title %}{{ branding }}{% endblock %}</title>
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="stylesheet" href="/static/vendor/bootstrap.min.css">
</head>
<body class="d-flex flex-column h-100">
<nav class="navbar navbar-expand-lg bg-body-secondary">
<div class="container">
<a class="navbar-brand" href="/">{{ branding }}</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbar-expander">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbar-expander">
<ul class="navbar-nav me-auto mb-2 mb-md-0">
<li class="nav-item">
<a class="nav-link{% if req.path() == "/" %} active{% endif %}" href="/">Home</a>
</li>
<li class="nav-item">
<a class="nav-link{% if req.path() == "/items" %} active{% endif %}" href="/items">Items</a>
</li>
<li class="nav-item">
<a class="nav-link{% if req.path() == "/item-classes" %} active{% endif %}" href="/item-classes">Item Classes</a>
</li>
</ul>
</div>
</div>
</nav>
<main class="container my-4">
<div class="d-flex justify-content-between mb-3">
<div>
<h2>{% block page_title %}{% endblock %}</h2>
</div>
<div>
{% block page_actions %}{% endblock %}
</div>
</div>
{% block main %}{% endblock %}
</main>
<footer class="mt-auto py-3 bg-body-tertiary text-body-tertiary">
<div class="container">
<p class="mb-0">li7y is free software, released under the terms of the AGPL v3</p>
</div>
</footer>
<script src="/static/vendor/bootstrap.bundle.min.js"></script>
<script src="/static/app.js"></script>
{# TODO this is not the best way, but it works for now #}
{% block extra_scripts %}{% endblock %}
</body>
</html>

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,52 +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 %} Edit Item Class {{ 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_class.id }}">
</div>
<div class="mb-3">
<label for="name" class="form-label">Name</label>
<input type="text" class="form-control" id="name" name="name" required value="{{ item_class.name }}">
</div>
<div class="mb-3">
<label for="type" class="form-label">Type</label>
<select class="form-select" id="type" name="type" required value="{{ item_class.type }}">
{% 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"{% if let Some(parent) = item_class.parent %} value="{{ parent }}"{% else %} disabled{% endif %}>
</div>
<button type="submit" class="btn btn-primary">Edit</button>
</form>
<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,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 %}