From 4e92c02e639f8ada2c622022069f11727fc2c0d2 Mon Sep 17 00:00:00 2001 From: Jef Roosens Date: Sun, 26 Jan 2025 15:24:51 +0100 Subject: [PATCH 1/2] feat: implement typed List template page for plants --- src/server/events.rs | 2 +- src/server/images.rs | 4 +- src/server/mod.rs | 8 ++- src/server/plants.rs | 31 ++++-------- src/server/query.rs | 6 +++ src/template/list.rs | 108 ++++++++++++++++++++++++++++++++++++++++ src/template/mod.rs | 12 ++++- src/template/view.rs | 50 +++++++++++++++---- templates/li/plant.html | 5 ++ templates/list/ul.html | 18 +++++++ 10 files changed, 208 insertions(+), 36 deletions(-) create mode 100644 src/template/list.rs create mode 100644 templates/li/plant.html create mode 100644 templates/list/ul.html diff --git a/src/server/events.rs b/src/server/events.rs index d23d539..d93cda2 100644 --- a/src/server/events.rs +++ b/src/server/events.rs @@ -20,5 +20,5 @@ async fn post_event( let mut context = Context::new(); context.insert("event", &event); - Ok(Html(Update::EventLi.render(&ctx.tera, &context)?)) + Ok(Html(Update::EventLi.render_ctx(&ctx.tera, &context)?)) } diff --git a/src/server/images.rs b/src/server/images.rs index bd9203b..6aff978 100644 --- a/src/server/images.rs +++ b/src/server/images.rs @@ -79,7 +79,9 @@ async fn get_images( context.insert("is_final_page", &is_final_page); Ok(Html( - View::Images.headers(&headers).render(&ctx.tera, &context)?, + View::Images + .headers(&headers) + .render_ctx(&ctx.tera, &context)?, )) } diff --git a/src/server/mod.rs b/src/server/mod.rs index bb99d8e..bd7adc9 100644 --- a/src/server/mod.rs +++ b/src/server/mod.rs @@ -60,7 +60,9 @@ pub async fn render_home(ctx: crate::Context, headers: &HeaderMap) -> Result Result axum::Router { Router::new() @@ -47,7 +47,9 @@ async fn get_plant_page( context.insert("event_types", &db::EVENT_TYPES); Ok(Html( - View::Plant.headers(&headers).render(&ctx.tera, &context)?, + View::Plant + .headers(&headers) + .render_ctx(&ctx.tera, &context)?, )) } None => Err(AppError::NotFound), @@ -56,28 +58,17 @@ async fn get_plant_page( async fn get_plants( State(ctx): State, - Query(mut page): Query, + Query(page): Query, headers: HeaderMap, ) -> super::Result> { let plants = tokio::task::spawn_blocking(move || db::Plant::page(&ctx.pool, page)) .await .unwrap()?; - let mut context = Context::new(); - context.insert("plants", &plants); - - let is_final_page = plants.len() < page.per_page.try_into().unwrap(); - context.insert("is_final_page", &is_final_page); - - if !is_final_page { - page.page += 1; - - context.insert("query", &page.to_query().encode()); - } - - Ok(Html( - View::Plants.headers(&headers).render(&ctx.tera, &context)?, - )) + let list = List::Ul + .items("Plants", ListItem::Plant, plants) + .page("/plants", page, None); + Ok(Html(View::other(list).headers(&headers).render(&ctx.tera)?)) } async fn post_plant( @@ -90,5 +81,5 @@ async fn post_plant( let mut context = Context::new(); context.insert("plant", &plant); - Ok(Html(Update::PlantLi.render(&ctx.tera, &context)?)) + Ok(Html(Update::PlantLi.render_ctx(&ctx.tera, &context)?)) } diff --git a/src/server/query.rs b/src/server/query.rs index 5f66853..2f94ec7 100644 --- a/src/server/query.rs +++ b/src/server/query.rs @@ -18,3 +18,9 @@ impl Query { pub trait ToQuery { fn to_query(self) -> Query; } + +impl ToQuery for Query { + fn to_query(self) -> Query { + self + } +} diff --git a/src/template/list.rs b/src/template/list.rs new file mode 100644 index 0000000..5da2df3 --- /dev/null +++ b/src/template/list.rs @@ -0,0 +1,108 @@ +use serde::Serialize; +use tera::Context; + +use crate::{ + db::Pagination, + server::query::{Query, ToQuery}, +}; + +use super::Template; + +#[derive(Clone, Copy)] +pub enum List { + Ul, +} + +pub enum ListItem { + Plant, +} + +impl Template for List { + fn template(&self) -> &'static str { + match self { + List::Ul => "list/ul.html", + } + } +} + +impl Template for ListItem { + fn template(&self) -> &'static str { + match self { + Self::Plant => "li/plant.html", + } + } +} + +impl List { + pub fn items( + self, + heading: &'static str, + item_tmpl: T, + items: Vec, + ) -> ListWrapper { + ListWrapper { + list: self, + heading, + item_tmpl, + items, + next_page_url: None, + } + } +} + +pub struct ListWrapper { + list: List, + heading: &'static str, + item_tmpl: T, + items: Vec, + next_page_url: Option, +} + +impl ListWrapper { + pub fn page(mut self, url: &'static str, mut page: Pagination, query: Option) -> Self { + if self.items.len() as u32 == page.per_page { + page.page += 1; + + let query = if let Some(query) = query { + query.join(page) + } else { + page.to_query() + }; + + self.next_page_url = Some(format!("{url}?{}", query.encode())); + } + + self + } +} + +impl Template for ListWrapper { + fn template(&self) -> &'static str { + self.list.template() + } + + fn render(&self, tera: &tera::Tera) -> tera::Result { + let mut ctx = Context::new(); + + // Render each item separately + let items = self + .items + .iter() + .map(|item| { + ctx.insert("item", &item); + + tera.render(self.item_tmpl.template(), &ctx) + }) + .collect::>>()?; + + ctx.remove("item"); + ctx.insert("heading", &self.heading); + ctx.insert("items", &items); + + if let Some(next_page_url) = &self.next_page_url { + ctx.insert("next_page_url", &next_page_url); + } + + self.render_ctx(tera, &ctx) + } +} diff --git a/src/template/mod.rs b/src/template/mod.rs index 757dbcc..868a44a 100644 --- a/src/template/mod.rs +++ b/src/template/mod.rs @@ -1,6 +1,8 @@ +mod list; mod update; mod view; +pub use list::{List, ListItem}; pub use update::Update; pub use view::View; @@ -9,7 +11,15 @@ pub trait Template { fn template(&self) -> &'static str; /// Render the template with the given context - fn render(&self, tera: &tera::Tera, ctx: &tera::Context) -> tera::Result { + fn render_ctx(&self, tera: &tera::Tera, ctx: &tera::Context) -> tera::Result { tera.render(self.template(), ctx) } + + /// Render the template without providing a context. This is useful for types that manage their + /// own context. + /// + /// By default, this simply renders the template with an empty context. + fn render(&self, tera: &tera::Tera) -> tera::Result { + self.render_ctx(tera, &tera::Context::new()) + } } diff --git a/src/template/view.rs b/src/template/view.rs index 6d06da2..f591ad7 100644 --- a/src/template/view.rs +++ b/src/template/view.rs @@ -1,20 +1,24 @@ use axum::http::{HeaderMap, HeaderValue}; +use tera::Context; use super::Template; const HX_REQUEST_HEADER: &str = "HX-Request"; const HX_HISTORY_RESTORE_HEADER: &str = "HX-History-Restore-Request"; -#[derive(Clone, Copy)] pub enum View { Plant, - Plants, Images, Index, Login, + Other(Box), } impl View { + pub fn other(tmpl: impl Template + 'static) -> Self { + View::Other(Box::new(tmpl)) + } + pub fn headers(self, headers: &HeaderMap) -> ViewWrapper { let is_htmx_req = headers.get(HX_REQUEST_HEADER).is_some(); let is_hist_restore_req = headers @@ -35,10 +39,24 @@ impl Template for View { fn template(&self) -> &'static str { match self { View::Plant => "views/plant.html", - View::Plants => "views/plants.html", View::Index => "views/index.html", View::Images => "views/images.html", View::Login => "views/login.html", + View::Other(tmpl) => tmpl.template(), + } + } + + fn render(&self, tera: &tera::Tera) -> tera::Result { + match self { + View::Other(tmpl) => tmpl.render(tera), + _ => tera.render(self.template(), &Context::new()), + } + } + + fn render_ctx(&self, tera: &tera::Tera, ctx: &tera::Context) -> tera::Result { + match self { + View::Other(tmpl) => tmpl.render_ctx(tera, ctx), + _ => tera.render(self.template(), ctx), } } } @@ -48,14 +66,8 @@ pub struct ViewWrapper { include_base: bool, } -impl Template for ViewWrapper { - fn template(&self) -> &'static str { - self.view.template() - } - - fn render(&self, tera: &tera::Tera, ctx: &tera::Context) -> tera::Result { - let view = self.view.render(tera, ctx)?; - +impl ViewWrapper { + fn wrap(&self, tera: &tera::Tera, view: String) -> tera::Result { if self.include_base { let mut ctx = tera::Context::new(); ctx.insert("view", &view); @@ -66,3 +78,19 @@ impl Template for ViewWrapper { } } } + +impl Template for ViewWrapper { + fn template(&self) -> &'static str { + self.view.template() + } + + fn render(&self, tera: &tera::Tera) -> tera::Result { + let view = self.view.render(tera)?; + self.wrap(tera, view) + } + + fn render_ctx(&self, tera: &tera::Tera, ctx: &tera::Context) -> tera::Result { + let view = self.view.render_ctx(tera, ctx)?; + self.wrap(tera, view) + } +} diff --git a/templates/li/plant.html b/templates/li/plant.html new file mode 100644 index 0000000..e299da0 --- /dev/null +++ b/templates/li/plant.html @@ -0,0 +1,5 @@ +{% import "components/plant.html" as comp_plant %} + +
  • + {{ comp_plant::name(plant=item) }} +
  • diff --git a/templates/list/ul.html b/templates/list/ul.html new file mode 100644 index 0000000..61cb7ab --- /dev/null +++ b/templates/list/ul.html @@ -0,0 +1,18 @@ +

    {{ heading }}

    + +
      +{% for item in items %} + {{ item | safe }} +{% endfor %} + +{% if next_page_url %} +
      +
      +{% endif %} +
    From b17c2e7df661418fe25a3e6451216f9a6a7bb2eb Mon Sep 17 00:00:00 2001 From: Jef Roosens Date: Sun, 26 Jan 2025 15:46:53 +0100 Subject: [PATCH 2/2] feat: migrate /images page to new typed methods --- src/server/images.rs | 18 +++++--------- src/server/plants.rs | 2 +- src/server/query.rs | 2 +- src/template/list.rs | 49 ++++++++++++++++++++++++++++++--------- src/template/mod.rs | 2 +- templates/card/image.html | 9 +++++++ templates/list/card.html | 18 ++++++++++++++ 7 files changed, 74 insertions(+), 26 deletions(-) create mode 100644 templates/card/image.html create mode 100644 templates/list/card.html diff --git a/src/server/images.rs b/src/server/images.rs index 6aff978..2009dbd 100644 --- a/src/server/images.rs +++ b/src/server/images.rs @@ -20,7 +20,7 @@ use std::{io::BufWriter, path::PathBuf}; use super::error::AppError; use crate::{ db::{self, Image, NewImage, Pagination}, - template::{Template, View}, + template::{List, ListCard, Template, View}, IMG_DIR, }; @@ -72,17 +72,11 @@ async fn get_images( .await .unwrap()?; - let mut context = Context::new(); - context.insert("images", &images); - - let is_final_page = images.len() < page.per_page.try_into().unwrap(); - context.insert("is_final_page", &is_final_page); - - Ok(Html( - View::Images - .headers(&headers) - .render_ctx(&ctx.tera, &context)?, - )) + let list = List::Card + .items("Images", ListCard::Image, images) + .page("/images", page) + .query(filter); + Ok(Html(View::other(list).headers(&headers).render(&ctx.tera)?)) } async fn get_image_original( diff --git a/src/server/plants.rs b/src/server/plants.rs index 642f563..736d459 100644 --- a/src/server/plants.rs +++ b/src/server/plants.rs @@ -67,7 +67,7 @@ async fn get_plants( let list = List::Ul .items("Plants", ListItem::Plant, plants) - .page("/plants", page, None); + .page("/plants", page); Ok(Html(View::other(list).headers(&headers).render(&ctx.tera)?)) } diff --git a/src/server/query.rs b/src/server/query.rs index 2f94ec7..afc49e5 100644 --- a/src/server/query.rs +++ b/src/server/query.rs @@ -1,6 +1,6 @@ use serde::Serialize; -#[derive(Serialize)] +#[derive(Serialize, Clone)] pub struct Query(pub Vec<(String, String)>); impl Query { diff --git a/src/template/list.rs b/src/template/list.rs index 5da2df3..547e1d3 100644 --- a/src/template/list.rs +++ b/src/template/list.rs @@ -11,16 +11,22 @@ use super::Template; #[derive(Clone, Copy)] pub enum List { Ul, + Card, } pub enum ListItem { Plant, } +pub enum ListCard { + Image, +} + impl Template for List { fn template(&self) -> &'static str { match self { List::Ul => "list/ul.html", + List::Card => "list/card.html", } } } @@ -33,6 +39,14 @@ impl Template for ListItem { } } +impl Template for ListCard { + fn template(&self) -> &'static str { + match self { + Self::Image => "card/image.html", + } + } +} + impl List { pub fn items( self, @@ -45,7 +59,8 @@ impl List { heading, item_tmpl, items, - next_page_url: None, + next_page: None, + query: None, } } } @@ -55,21 +70,26 @@ pub struct ListWrapper { heading: &'static str, item_tmpl: T, items: Vec, - next_page_url: Option, + next_page: Option<(&'static str, Pagination)>, + query: Option, } impl ListWrapper { - pub fn page(mut self, url: &'static str, mut page: Pagination, query: Option) -> Self { + pub fn page(mut self, url: &'static str, mut page: Pagination) -> Self { if self.items.len() as u32 == page.per_page { page.page += 1; - let query = if let Some(query) = query { - query.join(page) - } else { - page.to_query() - }; + self.next_page = Some((url, page)); + } - self.next_page_url = Some(format!("{url}?{}", query.encode())); + self + } + + pub fn query(mut self, query: impl ToQuery) -> Self { + if let Some(old_query) = self.query { + self.query = Some(old_query.join(query)); + } else { + self.query = Some(query.to_query()); } self @@ -99,8 +119,15 @@ impl Template for ListWrapper { ctx.insert("heading", &self.heading); ctx.insert("items", &items); - if let Some(next_page_url) = &self.next_page_url { - ctx.insert("next_page_url", &next_page_url); + if let Some((url, page)) = &self.next_page { + let query = if let Some(query) = &self.query { + query.clone().join(page.clone()) + } else { + page.to_query() + }; + + let full_url = format!("{url}?{}", query.encode()); + ctx.insert("next_page_url", &full_url); } self.render_ctx(tera, &ctx) diff --git a/src/template/mod.rs b/src/template/mod.rs index 868a44a..2742ed3 100644 --- a/src/template/mod.rs +++ b/src/template/mod.rs @@ -2,7 +2,7 @@ mod list; mod update; mod view; -pub use list::{List, ListItem}; +pub use list::{List, ListCard, ListItem}; pub use update::Update; pub use view::View; diff --git a/templates/card/image.html b/templates/card/image.html new file mode 100644 index 0000000..8419b3d --- /dev/null +++ b/templates/card/image.html @@ -0,0 +1,9 @@ +{% import "components/plant.html" as comp_plant %} +
    +

    Date taken: {{ item.0.date_taken }}

    +

    Note: {{ item.0.note }}

    +

    Plant: {{ comp_plant::name(plant=item.1) }}

    + + + +
    diff --git a/templates/list/card.html b/templates/list/card.html new file mode 100644 index 0000000..5bd5021 --- /dev/null +++ b/templates/list/card.html @@ -0,0 +1,18 @@ +

    {{ heading }}

    + +
    +{% for item in items %} + {{ item | safe }} +{% endfor %} + +{% if next_page_url %} +
    +
    +{% endif %} +