From a57e301d1683962a23f9b88283d3029465d2ce36 Mon Sep 17 00:00:00 2001 From: Jef Roosens Date: Tue, 17 Jun 2025 11:09:18 +0200 Subject: [PATCH] feat(server): implement infinite scroll table for sessions page A query type is introduced along with the ToQuery trait to convert types into queries. A query can then be properly formatted as a URL query parameter string, allowing us to pass arbitrary safely typed query parameters to the Tera templates. This is then used by HTMX to request the next page of content once the last row of a table is visible. --- Cargo.lock | 1 + Justfile | 2 +- otter/Cargo.toml | 1 + otter/src/server/web/mod.rs | 21 ++++++++++-- otter/src/server/web/sessions.rs | 8 +++-- otter/src/web/mod.rs | 4 ++- otter/src/web/query.rs | 37 +++++++++++++++++++++ otter/src/web/templates/views/sessions.html | 8 +++++ otter/src/web/view.rs | 12 ++++--- 9 files changed, 84 insertions(+), 10 deletions(-) create mode 100644 otter/src/web/query.rs diff --git a/Cargo.lock b/Cargo.lock index 630c663..bc750cf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1260,6 +1260,7 @@ dependencies = [ "http-body-util", "rand", "serde", + "serde_urlencoded", "tera", "tokio", "tower-http", diff --git a/Justfile b/Justfile index c19ef20..1280487 100644 --- a/Justfile +++ b/Justfile @@ -46,7 +46,7 @@ run: --log debug doc: - cargo doc --workspace --frozen + cargo doc --workspace --frozen --open publish-release-binaries tag: build-release-static curl \ diff --git a/otter/Cargo.toml b/otter/Cargo.toml index 5b98adc..0c3bf8f 100644 --- a/otter/Cargo.toml +++ b/otter/Cargo.toml @@ -25,3 +25,4 @@ http-body-util = "0.1.3" tokio = { version = "1.43.0", features = ["full"] } tracing-subscriber = "0.3.19" tera = "1.20.0" +serde_urlencoded = "0.7.1" diff --git a/otter/src/server/web/mod.rs b/otter/src/server/web/mod.rs index f924f9b..dac3229 100644 --- a/otter/src/server/web/mod.rs +++ b/otter/src/server/web/mod.rs @@ -13,7 +13,7 @@ use cookie::{Cookie, time::Duration}; use gpodder::{AuthErr, Session}; use serde::{Deserialize, Serialize}; -use crate::web::{Page, TemplateExt, TemplateResponse, View}; +use crate::web::{Page, Query, TemplateExt, TemplateResponse, ToQuery, View}; use super::{ Context, @@ -22,7 +22,7 @@ use super::{ const SESSION_ID_COOKIE: &str = "sessionid"; -#[derive(Deserialize, Serialize)] +#[derive(Deserialize, Clone)] #[serde(default)] pub struct Pagination { page: u32, @@ -47,6 +47,23 @@ impl Default for Pagination { } } +impl ToQuery for Pagination { + fn to_query(self) -> Query { + Query::default() + .parameter("page", self.page) + .parameter("per_page", self.per_page) + } +} + +impl Pagination { + pub fn next_page(&self) -> Self { + Self { + page: self.page + 1, + per_page: self.per_page, + } + } +} + pub fn router(ctx: Context) -> Router { Router::new() .route("/", get(get_index)) diff --git a/otter/src/server/web/sessions.rs b/otter/src/server/web/sessions.rs index 9020bab..611372a 100644 --- a/otter/src/server/web/sessions.rs +++ b/otter/src/server/web/sessions.rs @@ -7,7 +7,7 @@ use axum::{ use crate::{ server::{Context, error::AppResult}, - web::{Page, TemplateExt, TemplateResponse, View}, + web::{Page, TemplateExt, TemplateResponse, ToQuery, View}, }; pub fn router(ctx: Context) -> Router { @@ -25,12 +25,16 @@ pub async fn get_sessions( Extension(user): Extension, Query(page): Query, ) -> AppResult>> { + let next_page = page.next_page(); let sessions = tokio::task::spawn_blocking(move || ctx.store.paginated_sessions(&user, page.into())) .await .unwrap()?; - Ok(View::Sessions(sessions) + let next_page_query = + (sessions.len() == next_page.per_page as usize).then_some(next_page.to_query()); + + Ok(View::Sessions(sessions, next_page_query) .page(&headers) .headers(&headers) .authenticated(true) diff --git a/otter/src/web/mod.rs b/otter/src/web/mod.rs index 05d66e2..f99ff53 100644 --- a/otter/src/web/mod.rs +++ b/otter/src/web/mod.rs @@ -1,4 +1,5 @@ mod page; +mod query; mod view; use std::sync::Arc; @@ -10,6 +11,7 @@ use axum::{ }; pub use page::Page; +pub use query::{Query, ToQuery}; pub use view::View; const BASE_TEMPLATE: &str = "base.html"; @@ -80,7 +82,7 @@ pub fn initialize_tera() -> tera::Result { include_str!("templates/views/login.html"), ), ( - View::Sessions(Vec::new()).template(), + View::Sessions(Vec::new(), None).template(), include_str!("templates/views/sessions.html"), ), ])?; diff --git a/otter/src/web/query.rs b/otter/src/web/query.rs new file mode 100644 index 0000000..409afd5 --- /dev/null +++ b/otter/src/web/query.rs @@ -0,0 +1,37 @@ +/// Represents a list of query parameters +#[derive(Default)] +pub struct Query(Vec<(String, String)>); + +impl Query { + /// Combine two queries into one + pub fn join(mut self, other: impl ToQuery) -> Self { + let mut other = other.to_query(); + self.0.append(&mut other.0); + + self + } + + /// Convert the query into a url-encoded query parameter string + pub fn encode(self) -> String { + // TODO is this unwrap safe? + serde_urlencoded::to_string(&self.0).unwrap() + } + + /// Builder-style method that appends a parameter to the query + pub fn parameter(mut self, key: impl ToString, value: impl ToString) -> Self { + self.0.push((key.to_string(), value.to_string())); + + self + } +} + +/// Allows objects to be converted into queries +pub trait ToQuery { + fn to_query(self) -> Query; +} + +impl ToQuery for Query { + fn to_query(self) -> Query { + self + } +} diff --git a/otter/src/web/templates/views/sessions.html b/otter/src/web/templates/views/sessions.html index f160d41..1a59e34 100644 --- a/otter/src/web/templates/views/sessions.html +++ b/otter/src/web/templates/views/sessions.html @@ -16,5 +16,13 @@ Remove {% endfor %} + {% if next_page_query %} + + {% endif %} diff --git a/otter/src/web/view.rs b/otter/src/web/view.rs index e1a0f59..5636a16 100644 --- a/otter/src/web/view.rs +++ b/otter/src/web/view.rs @@ -1,12 +1,12 @@ use chrono::{DateTime, Utc}; use serde::Serialize; -use super::Template; +use super::{Query, Template}; pub enum View { Index, Login, - Sessions(Vec), + Sessions(Vec, Option), } #[derive(Serialize)] @@ -20,7 +20,7 @@ impl Template for View { match self { Self::Index => "views/index.html", Self::Login => "views/login.html", - Self::Sessions(_) => "views/sessions.html", + Self::Sessions(..) => "views/sessions.html", } } @@ -29,11 +29,15 @@ impl Template for View { let template = self.template(); match self { - Self::Sessions(sessions) => { + Self::Sessions(sessions, query) => { ctx.insert( "sessions", &sessions.into_iter().map(Session::from).collect::>(), ); + + if let Some(query) = query { + ctx.insert("next_page_query", &query.encode()); + } } _ => {} };