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
parent
68b2b1beb4
commit
a57e301d16
|
@ -1260,6 +1260,7 @@ dependencies = [
|
|||
"http-body-util",
|
||||
"rand",
|
||||
"serde",
|
||||
"serde_urlencoded",
|
||||
"tera",
|
||||
"tokio",
|
||||
"tower-http",
|
||||
|
|
2
Justfile
2
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 \
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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))
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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"),
|
||||
),
|
||||
])?;
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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>
|
||||
|
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
};
|
||||
|
|
Loading…
Reference in New Issue