Compare commits

..

2 Commits

12 changed files with 266 additions and 46 deletions

View File

@ -20,5 +20,5 @@ async fn post_event(
let mut context = Context::new(); let mut context = Context::new();
context.insert("event", &event); context.insert("event", &event);
Ok(Html(Update::EventLi.render(&ctx.tera, &context)?)) Ok(Html(Update::EventLi.render_ctx(&ctx.tera, &context)?))
} }

View File

@ -20,7 +20,7 @@ use std::{io::BufWriter, path::PathBuf};
use super::error::AppError; use super::error::AppError;
use crate::{ use crate::{
db::{self, Image, NewImage, Pagination}, db::{self, Image, NewImage, Pagination},
template::{Template, View}, template::{List, ListCard, Template, View},
IMG_DIR, IMG_DIR,
}; };
@ -72,15 +72,11 @@ async fn get_images(
.await .await
.unwrap()?; .unwrap()?;
let mut context = Context::new(); let list = List::Card
context.insert("images", &images); .items("Images", ListCard::Image, images)
.page("/images", page)
let is_final_page = images.len() < page.per_page.try_into().unwrap(); .query(filter);
context.insert("is_final_page", &is_final_page); Ok(Html(View::other(list).headers(&headers).render(&ctx.tera)?))
Ok(Html(
View::Images.headers(&headers).render(&ctx.tera, &context)?,
))
} }
async fn get_image_original( async fn get_image_original(

View File

@ -60,7 +60,9 @@ pub async fn render_home(ctx: crate::Context, headers: &HeaderMap) -> Result<Htm
context.insert("plants", &plants); context.insert("plants", &plants);
Ok(Html( Ok(Html(
View::Index.headers(headers).render(&ctx.tera, &context)?, View::Index
.headers(headers)
.render_ctx(&ctx.tera, &context)?,
)) ))
} }
@ -68,7 +70,9 @@ pub fn render_login(ctx: crate::Context, headers: &HeaderMap) -> Result<Html<Str
let context = Context::new(); let context = Context::new();
Ok(Html( Ok(Html(
View::Login.headers(headers).render(&ctx.tera, &context)?, View::Login
.headers(headers)
.render_ctx(&ctx.tera, &context)?,
)) ))
} }

View File

@ -9,10 +9,10 @@ use tera::Context;
use crate::{ use crate::{
db::{self, DbError, Event, Pagination, Plant}, db::{self, DbError, Event, Pagination, Plant},
template::{Template, Update, View}, template::{List, ListItem, Template, Update, View},
}; };
use super::{error::AppError, query::ToQuery}; use super::error::AppError;
pub fn app() -> axum::Router<crate::Context> { pub fn app() -> axum::Router<crate::Context> {
Router::new() Router::new()
@ -47,7 +47,9 @@ async fn get_plant_page(
context.insert("event_types", &db::EVENT_TYPES); context.insert("event_types", &db::EVENT_TYPES);
Ok(Html( Ok(Html(
View::Plant.headers(&headers).render(&ctx.tera, &context)?, View::Plant
.headers(&headers)
.render_ctx(&ctx.tera, &context)?,
)) ))
} }
None => Err(AppError::NotFound), None => Err(AppError::NotFound),
@ -56,28 +58,17 @@ async fn get_plant_page(
async fn get_plants( async fn get_plants(
State(ctx): State<crate::Context>, State(ctx): State<crate::Context>,
Query(mut page): Query<Pagination>, Query(page): Query<Pagination>,
headers: HeaderMap, headers: HeaderMap,
) -> super::Result<Html<String>> { ) -> super::Result<Html<String>> {
let plants = tokio::task::spawn_blocking(move || db::Plant::page(&ctx.pool, page)) let plants = tokio::task::spawn_blocking(move || db::Plant::page(&ctx.pool, page))
.await .await
.unwrap()?; .unwrap()?;
let mut context = Context::new(); let list = List::Ul
context.insert("plants", &plants); .items("Plants", ListItem::Plant, plants)
.page("/plants", page);
let is_final_page = plants.len() < page.per_page.try_into().unwrap(); Ok(Html(View::other(list).headers(&headers).render(&ctx.tera)?))
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)?,
))
} }
async fn post_plant( async fn post_plant(
@ -90,5 +81,5 @@ async fn post_plant(
let mut context = Context::new(); let mut context = Context::new();
context.insert("plant", &plant); context.insert("plant", &plant);
Ok(Html(Update::PlantLi.render(&ctx.tera, &context)?)) Ok(Html(Update::PlantLi.render_ctx(&ctx.tera, &context)?))
} }

View File

@ -1,6 +1,6 @@
use serde::Serialize; use serde::Serialize;
#[derive(Serialize)] #[derive(Serialize, Clone)]
pub struct Query(pub Vec<(String, String)>); pub struct Query(pub Vec<(String, String)>);
impl Query { impl Query {
@ -18,3 +18,9 @@ impl Query {
pub trait ToQuery { pub trait ToQuery {
fn to_query(self) -> Query; fn to_query(self) -> Query;
} }
impl ToQuery for Query {
fn to_query(self) -> Query {
self
}
}

View File

@ -0,0 +1,135 @@
use serde::Serialize;
use tera::Context;
use crate::{
db::Pagination,
server::query::{Query, ToQuery},
};
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",
}
}
}
impl Template for ListItem {
fn template(&self) -> &'static str {
match self {
Self::Plant => "li/plant.html",
}
}
}
impl Template for ListCard {
fn template(&self) -> &'static str {
match self {
Self::Image => "card/image.html",
}
}
}
impl List {
pub fn items<T: Template, I: Serialize>(
self,
heading: &'static str,
item_tmpl: T,
items: Vec<I>,
) -> ListWrapper<T, I> {
ListWrapper {
list: self,
heading,
item_tmpl,
items,
next_page: None,
query: None,
}
}
}
pub struct ListWrapper<T: Template, I: Serialize> {
list: List,
heading: &'static str,
item_tmpl: T,
items: Vec<I>,
next_page: Option<(&'static str, Pagination)>,
query: Option<Query>,
}
impl<T: Template, I: Serialize> ListWrapper<T, I> {
pub fn page(mut self, url: &'static str, mut page: Pagination) -> Self {
if self.items.len() as u32 == page.per_page {
page.page += 1;
self.next_page = Some((url, page));
}
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
}
}
impl<T: Template, I: Serialize> Template for ListWrapper<T, I> {
fn template(&self) -> &'static str {
self.list.template()
}
fn render(&self, tera: &tera::Tera) -> tera::Result<String> {
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::<tera::Result<Vec<String>>>()?;
ctx.remove("item");
ctx.insert("heading", &self.heading);
ctx.insert("items", &items);
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)
}
}

View File

@ -1,6 +1,8 @@
mod list;
mod update; mod update;
mod view; mod view;
pub use list::{List, ListCard, ListItem};
pub use update::Update; pub use update::Update;
pub use view::View; pub use view::View;
@ -9,7 +11,15 @@ pub trait Template {
fn template(&self) -> &'static str; fn template(&self) -> &'static str;
/// Render the template with the given context /// Render the template with the given context
fn render(&self, tera: &tera::Tera, ctx: &tera::Context) -> tera::Result<String> { fn render_ctx(&self, tera: &tera::Tera, ctx: &tera::Context) -> tera::Result<String> {
tera.render(self.template(), ctx) 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<String> {
self.render_ctx(tera, &tera::Context::new())
}
} }

View File

@ -1,20 +1,24 @@
use axum::http::{HeaderMap, HeaderValue}; use axum::http::{HeaderMap, HeaderValue};
use tera::Context;
use super::Template; use super::Template;
const HX_REQUEST_HEADER: &str = "HX-Request"; const HX_REQUEST_HEADER: &str = "HX-Request";
const HX_HISTORY_RESTORE_HEADER: &str = "HX-History-Restore-Request"; const HX_HISTORY_RESTORE_HEADER: &str = "HX-History-Restore-Request";
#[derive(Clone, Copy)]
pub enum View { pub enum View {
Plant, Plant,
Plants,
Images, Images,
Index, Index,
Login, Login,
Other(Box<dyn Template>),
} }
impl View { impl View {
pub fn other(tmpl: impl Template + 'static) -> Self {
View::Other(Box::new(tmpl))
}
pub fn headers(self, headers: &HeaderMap) -> ViewWrapper { pub fn headers(self, headers: &HeaderMap) -> ViewWrapper {
let is_htmx_req = headers.get(HX_REQUEST_HEADER).is_some(); let is_htmx_req = headers.get(HX_REQUEST_HEADER).is_some();
let is_hist_restore_req = headers let is_hist_restore_req = headers
@ -35,10 +39,24 @@ impl Template for View {
fn template(&self) -> &'static str { fn template(&self) -> &'static str {
match self { match self {
View::Plant => "views/plant.html", View::Plant => "views/plant.html",
View::Plants => "views/plants.html",
View::Index => "views/index.html", View::Index => "views/index.html",
View::Images => "views/images.html", View::Images => "views/images.html",
View::Login => "views/login.html", View::Login => "views/login.html",
View::Other(tmpl) => tmpl.template(),
}
}
fn render(&self, tera: &tera::Tera) -> tera::Result<String> {
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<String> {
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, include_base: bool,
} }
impl Template for ViewWrapper { impl ViewWrapper {
fn template(&self) -> &'static str { fn wrap(&self, tera: &tera::Tera, view: String) -> tera::Result<String> {
self.view.template()
}
fn render(&self, tera: &tera::Tera, ctx: &tera::Context) -> tera::Result<String> {
let view = self.view.render(tera, ctx)?;
if self.include_base { if self.include_base {
let mut ctx = tera::Context::new(); let mut ctx = tera::Context::new();
ctx.insert("view", &view); 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<String> {
let view = self.view.render(tera)?;
self.wrap(tera, view)
}
fn render_ctx(&self, tera: &tera::Tera, ctx: &tera::Context) -> tera::Result<String> {
let view = self.view.render_ctx(tera, ctx)?;
self.wrap(tera, view)
}
}

View File

@ -0,0 +1,9 @@
{% import "components/plant.html" as comp_plant %}
<article>
<p>Date taken: {{ item.0.date_taken }}</p>
<p>Note: {{ item.0.note }}</p>
<p>Plant: {{ comp_plant::name(plant=item.1) }}</p>
<a href="/images/{{ item.0.id }}/original" target="_blank">
<img src="/images/{{ item.0.id }}/thumb">
</a>
</article>

View File

@ -0,0 +1,5 @@
{% import "components/plant.html" as comp_plant %}
<li>
{{ comp_plant::name(plant=item) }}
</li>

View File

@ -0,0 +1,18 @@
<h1>{{ heading }}</h1>
<div id="items">
{% for item in items %}
{{ item | safe }}
{% endfor %}
{% if next_page_url %}
<div
aria-busy="true"
hx-get="{{ next_page_url }}"
hx-select="#items > *"
hx-trigger="revealed"
hx-target="this"
hx-swap="outerHTML">
</div>
{% endif %}
</div>

View File

@ -0,0 +1,18 @@
<h1>{{ heading }}</h1>
<ul id="items">
{% for item in items %}
{{ item | safe }}
{% endfor %}
{% if next_page_url %}
<div
aria-busy="true"
hx-get="{{ next_page_url }}"
hx-select="#items > *"
hx-trigger="revealed"
hx-target="this"
hx-swap="outerHTML">
</div>
{% endif %}
</ul>