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.
signup-links
Jef Roosens 2025-06-17 11:09:18 +02:00
parent 68b2b1beb4
commit a57e301d16
No known key found for this signature in database
GPG Key ID: 21FD3D77D56BAF49
9 changed files with 84 additions and 10 deletions

1
Cargo.lock generated
View File

@ -1260,6 +1260,7 @@ dependencies = [
"http-body-util",
"rand",
"serde",
"serde_urlencoded",
"tera",
"tokio",
"tower-http",

View File

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

View File

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

View File

@ -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<Context> {
Router::new()
.route("/", get(get_index))

View File

@ -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<Context> {
@ -25,12 +25,16 @@ pub async fn get_sessions(
Extension(user): Extension<gpodder::User>,
Query(page): Query<super::Pagination>,
) -> AppResult<TemplateResponse<Page<View>>> {
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)

View File

@ -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<tera::Tera> {
include_str!("templates/views/login.html"),
),
(
View::Sessions(Vec::new()).template(),
View::Sessions(Vec::new(), None).template(),
include_str!("templates/views/sessions.html"),
),
])?;

View File

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

View File

@ -16,5 +16,13 @@
<th>Remove</th>
</tr>
{% endfor %}
{% if next_page_query %}
<tr
hx-get="/sessions?{{ next_page_query }}"
hx-trigger="revealed"
hx-swap="outerHTML"
hx-select="table > tbody > tr"
></tr>
{% endif %}
</tbody>
</table>

View File

@ -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<gpodder::Session>),
Sessions(Vec<gpodder::Session>, Option<Query>),
}
#[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::<Vec<_>>(),
);
if let Some(query) = query {
ctx.insert("next_page_query", &query.encode());
}
}
_ => {}
};