feat: implement infinite scroll on /plants page

main
Jef Roosens 2025-01-25 11:08:53 +01:00
parent dce23599ef
commit fff85d4479
No known key found for this signature in database
GPG Key ID: 21FD3D77D56BAF49
10 changed files with 78 additions and 6 deletions

View File

@ -1,5 +1,6 @@
* *
!migrations
!Cargo.toml !Cargo.toml
!Cargo.lock !Cargo.lock
!src/** !src/**

1
Cargo.lock generated
View File

@ -355,6 +355,7 @@ dependencies = [
"mime", "mime",
"rand", "rand",
"serde", "serde",
"serde_urlencoded",
"tera", "tera",
"tokio", "tokio",
"tokio-util", "tokio-util",

View File

@ -29,6 +29,7 @@ tower = { version = "0.5.2", features = ["util"] }
tower-http = { version = "0.6.2", features = ["compression-br", "compression-gzip", "set-header", "fs"] } tower-http = { version = "0.6.2", features = ["compression-br", "compression-gzip", "set-header", "fs"] }
tracing = "0.1.41" tracing = "0.1.41"
tracing-subscriber = "0.3.19" tracing-subscriber = "0.3.19"
serde_urlencoded = "0.7.1"
[profile.release] [profile.release]
strip = true strip = true

View File

@ -72,8 +72,8 @@ pub fn initialize_db(path: impl AsRef<Path>, run_migrations: bool) -> Result<DbP
#[derive(Deserialize, Copy, Clone)] #[derive(Deserialize, Copy, Clone)]
#[serde(default)] #[serde(default)]
pub struct Pagination { pub struct Pagination {
page: u32, pub page: u32,
per_page: u32, pub per_page: u32,
} }
impl Default for Pagination { impl Default for Pagination {
@ -84,3 +84,12 @@ impl Default for Pagination {
} }
} }
} }
impl crate::server::query::ToQuery for Pagination {
fn to_query(self) -> crate::server::query::Query {
crate::server::query::Query(vec![
("page".to_string(), self.page.to_string()),
("per_page".to_string(), self.per_page.to_string()),
])
}
}

View File

@ -2,7 +2,10 @@ use chrono::NaiveDate;
use diesel::prelude::*; use diesel::prelude::*;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use crate::db::{schema::*, DbPool, DbResult, Pagination, Plant}; use crate::{
db::{schema::*, DbPool, DbResult, Pagination, Plant},
server::query::{Query, ToQuery},
};
#[derive(Serialize, Queryable, Selectable)] #[derive(Serialize, Queryable, Selectable)]
#[diesel(table_name = images)] #[diesel(table_name = images)]
@ -30,6 +33,18 @@ pub struct ImageFilter {
pub plant_id: Option<i32>, pub plant_id: Option<i32>,
} }
impl ToQuery for ImageFilter {
fn to_query(self) -> crate::server::query::Query {
let mut out = vec![];
if let Some(plant_id) = self.plant_id {
out.push(("plant_id".to_string(), plant_id.to_string()));
}
Query(out)
}
}
impl NewImage { impl NewImage {
pub fn new( pub fn new(
plant_id: i32, plant_id: i32,

View File

@ -74,6 +74,9 @@ async fn get_images(
let mut context = Context::new(); let mut context = Context::new();
context.insert("images", &images); 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(super::render_view( Ok(Html(super::render_view(
&ctx.tera, &ctx.tera,
"views/images.html", "views/images.html",

View File

@ -3,6 +3,7 @@ mod error;
mod events; mod events;
mod images; mod images;
mod plants; mod plants;
pub mod query;
use std::path::Path; use std::path::Path;

View File

@ -1,3 +1,5 @@
use std::time::Duration;
use axum::{ use axum::{
extract::{Path, Query, State}, extract::{Path, Query, State},
http::HeaderMap, http::HeaderMap,
@ -9,7 +11,7 @@ use tera::Context;
use crate::db::{self, DbError, Event, Pagination, Plant}; use crate::db::{self, DbError, Event, Pagination, Plant};
use super::error::AppError; use super::{error::AppError, query::ToQuery};
pub fn app() -> axum::Router<crate::Context> { pub fn app() -> axum::Router<crate::Context> {
Router::new() Router::new()
@ -56,7 +58,7 @@ async fn get_plant_page(
async fn get_plants( async fn get_plants(
State(ctx): State<crate::Context>, State(ctx): State<crate::Context>,
Query(page): Query<Pagination>, Query(mut 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))
@ -66,6 +68,15 @@ async fn get_plants(
let mut context = Context::new(); let mut context = Context::new();
context.insert("plants", &plants); 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(super::render_view( Ok(Html(super::render_view(
&ctx.tera, &ctx.tera,
"views/plants.html", "views/plants.html",

View File

@ -0,0 +1,20 @@
use serde::Serialize;
#[derive(Serialize)]
pub struct Query(pub Vec<(String, String)>);
impl Query {
pub fn join(mut self, other: impl ToQuery) -> Self {
self.0.extend(other.to_query().0);
self
}
pub fn encode(self) -> String {
serde_urlencoded::to_string(self).unwrap()
}
}
pub trait ToQuery {
fn to_query(self) -> Query;
}

View File

@ -2,8 +2,18 @@
<h1>Plants</h1> <h1>Plants</h1>
<ul> <ul id="plants">
{% for plant in plants %} {% for plant in plants %}
{{ comp_plant::li(plant=plant) }} {{ comp_plant::li(plant=plant) }}
{% endfor %} {% endfor %}
{% if not is_final_page %}
<div
aria-busy="true"
hx-get="/plants?{{ query }}"
hx-select="ul > *"
hx-trigger="revealed"
hx-target="this"
hx-swap="outerHTML">
</div>
{% endif %}
</ul> </ul>