Compare commits

...

3 Commits

Author SHA1 Message Date
Jef Roosens a57e301d16
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.
2025-06-17 11:09:18 +02:00
Jef Roosens 68b2b1beb4
chore: format code 2025-06-17 09:53:50 +02:00
Jef Roosens e8e0c94937
feat(server): partial implementation of session page pagination 2025-06-17 09:52:47 +02:00
22 changed files with 213 additions and 64 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

@ -1,9 +1,9 @@
data_dir = "./data"
[net]
# type = "tcp"
# domain = "127.0.0.1"
# port = 8080
type = "tcp"
domain = "127.0.0.1"
port = 8080
type = "unix"
path = "./otter.socket"
# type = "unix"
# path = "./otter.socket"

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

@ -6,8 +6,8 @@ use std::path::PathBuf;
use clap::{Args, Parser, Subcommand, ValueEnum};
use figment::{
providers::{Env, Format, Serialized, Toml},
Figment,
providers::{Env, Format, Serialized, Toml},
};
use serde::Serialize;

View File

@ -1,20 +1,20 @@
use axum::{
Router,
extract::{Path, State},
routing::post,
Router,
};
use axum_extra::{
extract::{cookie::Cookie, CookieJar},
headers::{authorization::Basic, Authorization, UserAgent},
TypedHeader,
extract::{CookieJar, cookie::Cookie},
headers::{Authorization, UserAgent, authorization::Basic},
};
use cookie::time::Duration;
use gpodder::AuthErr;
use crate::server::{
Context,
error::{AppError, AppResult},
gpodder::SESSION_ID_COOKIE,
Context,
};
pub fn router() -> Router<Context> {

View File

@ -1,18 +1,18 @@
use axum::{
Extension, Json, Router,
extract::{Path, State},
middleware,
routing::{get, post},
Extension, Json, Router,
};
use crate::server::{
Context,
error::{AppError, AppResult},
gpodder::{
auth_api_middleware,
format::{Format, StringWithFormat},
models,
},
Context,
};
pub fn router(ctx: Context) -> Router<Context> {

View File

@ -1,13 +1,14 @@
use axum::{
Extension, Json, Router,
extract::{Path, Query, State},
middleware,
routing::post,
Extension, Json, Router,
};
use chrono::DateTime;
use serde::{Deserialize, Serialize};
use crate::server::{
Context,
error::{AppError, AppResult},
gpodder::{
auth_api_middleware,
@ -15,7 +16,6 @@ use crate::server::{
models,
models::UpdatedUrlsResponse,
},
Context,
};
pub fn router(ctx: Context) -> Router<Context> {

View File

@ -1,19 +1,19 @@
use axum::{
Extension, Json, Router,
extract::{Path, Query, State},
middleware,
routing::post,
Extension, Json, Router,
};
use serde::Deserialize;
use crate::server::{
Context,
error::{AppError, AppResult},
gpodder::{
auth_api_middleware,
format::{Format, StringWithFormat},
models::{SubscriptionDelta, SubscriptionDeltaResponse, UpdatedUrlsResponse},
},
Context,
};
pub fn router(ctx: Context) -> Router<Context> {

View File

@ -1,18 +1,18 @@
use axum::{
Extension, Json, Router,
extract::{Path, State},
middleware,
routing::get,
Extension, Json, Router,
};
use crate::server::{
Context,
error::{AppError, AppResult},
gpodder::{
auth_api_middleware,
format::{Format, StringWithFormat},
models::{SyncStatus, SyncStatusDelta},
},
Context,
};
pub fn router(ctx: Context) -> Router<Context> {

View File

@ -1,8 +1,8 @@
use std::ops::Deref;
use serde::{
de::{value::StrDeserializer, Visitor},
Deserialize,
de::{Visitor, value::StrDeserializer},
};
#[derive(Deserialize, Debug, PartialEq, Eq)]

View File

@ -4,16 +4,16 @@ mod models;
mod simple;
use axum::{
RequestExt, Router,
extract::{Request, State},
http::{header::WWW_AUTHENTICATE, HeaderName, HeaderValue, StatusCode},
http::{HeaderName, HeaderValue, StatusCode, header::WWW_AUTHENTICATE},
middleware::Next,
response::{IntoResponse, Response},
RequestExt, Router,
};
use axum_extra::{
extract::{cookie::Cookie, CookieJar},
headers::{authorization::Basic, Authorization},
TypedHeader,
extract::{CookieJar, cookie::Cookie},
headers::{Authorization, authorization::Basic},
};
use tower_http::set_header::SetResponseHeaderLayer;

View File

@ -1,14 +1,14 @@
use axum::{
Extension, Json, Router,
extract::{Path, State},
middleware,
routing::get,
Extension, Json, Router,
};
use crate::server::{
Context,
error::{AppError, AppResult},
gpodder::{auth_api_middleware, format::StringWithFormat},
Context,
};
pub fn router(ctx: Context) -> Router<Context> {

View File

@ -6,12 +6,12 @@ mod web;
use std::sync::Arc;
use axum::{
Router,
body::Body,
extract::Request,
http::StatusCode,
middleware::Next,
response::{IntoResponse, Response},
Router,
};
use http_body_util::BodyExt;
use tower_http::trace::TraceLayer;

View File

@ -1,7 +1,7 @@
use std::io::Cursor;
use axum::{routing::get, Router};
use axum_extra::{headers::Range, TypedHeader};
use axum::{Router, routing::get};
use axum_extra::{TypedHeader, headers::Range};
use axum_range::{KnownSize, Ranged};
use super::Context;

View File

@ -1,17 +1,19 @@
mod sessions;
use axum::{
Form, RequestExt, Router,
extract::{Request, State},
http::{HeaderMap, HeaderName, HeaderValue, header},
middleware::{self, Next},
http::HeaderMap,
middleware::Next,
response::{IntoResponse, Redirect, Response},
routing::{get, post},
};
use axum_extra::{TypedHeader, extract::CookieJar, headers::UserAgent};
use cookie::{Cookie, time::Duration};
use gpodder::{AuthErr, Session};
use serde::Deserialize;
use serde::{Deserialize, Serialize};
use crate::web::{Page, TemplateExt, TemplateResponse, View};
use crate::web::{Page, Query, TemplateExt, TemplateResponse, ToQuery, View};
use super::{
Context,
@ -20,13 +22,50 @@ use super::{
const SESSION_ID_COOKIE: &str = "sessionid";
#[derive(Deserialize, Clone)]
#[serde(default)]
pub struct Pagination {
page: u32,
per_page: u32,
}
impl From<Pagination> for gpodder::Page {
fn from(value: Pagination) -> Self {
Self {
page: value.page,
per_page: value.per_page,
}
}
}
impl Default for Pagination {
fn default() -> Self {
Self {
page: 0,
per_page: 1,
}
}
}
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("/sessions", get(get_sessions))
.route_layer(axum::middleware::from_fn_with_state(
ctx.clone(),
auth_web_middleware,
))
.route("/", get(get_index))
// .layer(middleware::from_fn_with_state(
// ctx.clone(),
@ -36,6 +75,7 @@ pub fn router(ctx: Context) -> Router<Context> {
// loop
.route("/login", get(get_login).post(post_login))
.route("/logout", post(post_logout))
.merge(sessions::router(ctx.clone()))
}
async fn get_index(
@ -166,14 +206,3 @@ pub async fn auth_web_middleware(
Err(err) => err.into_response(),
}
}
pub async fn get_sessions(
State(ctx): State<Context>,
headers: HeaderMap,
) -> TemplateResponse<Page<View>> {
View::Sessions(Vec::new())
.page(&headers)
.headers(&headers)
.authenticated(true)
.response(&ctx.tera)
}

View File

@ -0,0 +1,42 @@
use axum::{
Extension, Router,
extract::{Query, State},
http::HeaderMap,
routing::get,
};
use crate::{
server::{Context, error::AppResult},
web::{Page, TemplateExt, TemplateResponse, ToQuery, View},
};
pub fn router(ctx: Context) -> Router<Context> {
Router::new()
.route("/sessions", get(get_sessions))
.route_layer(axum::middleware::from_fn_with_state(
ctx.clone(),
super::auth_web_middleware,
))
}
pub async fn get_sessions(
State(ctx): State<Context>,
headers: HeaderMap,
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()?;
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)
.response(&ctx.tera))
}

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";
@ -22,7 +24,7 @@ pub trait Template {
/// Render the template using the given Tera instance.
///
/// Templates are expected to manage their own context requirements if needed.
fn render(&self, tera: &tera::Tera) -> tera::Result<String>;
fn render(self, tera: &tera::Tera) -> tera::Result<String>;
}
/// Useful additional functions on sized Template implementors
@ -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

@ -18,7 +18,7 @@ impl<T: Template> Template for Page<T> {
self.template.template()
}
fn render(&self, tera: &tera::Tera) -> tera::Result<String> {
fn render(self, tera: &tera::Tera) -> tera::Result<String> {
let inner = self.template.render(tera)?;
if self.wrap_with_base {

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

@ -9,10 +9,20 @@
</tr>
</thead>
<tbody>
{% for session in sessions %}
<tr>
<th>Firefox</th>
<th>yesterday</th>
<th>{{ session.user_agent }}</th>
<th>{{ session.last_seen }}</th>
<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,9 +1,18 @@
use super::Template;
use chrono::{DateTime, Utc};
use serde::Serialize;
use super::{Query, Template};
pub enum View {
Index,
Login,
Sessions(Vec<gpodder::Session>),
Sessions(Vec<gpodder::Session>, Option<Query>),
}
#[derive(Serialize)]
struct Session {
user_agent: Option<String>,
last_seen: DateTime<Utc>,
}
impl Template for View {
@ -11,19 +20,37 @@ 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",
}
}
fn render(&self, tera: &tera::Tera) -> tera::Result<String> {
fn render(self, tera: &tera::Tera) -> tera::Result<String> {
let mut ctx = tera::Context::new();
let template = self.template();
// match self {
// Self::Sessions(sessions) => {
// ctx.insert("sessions", sessions);
// }
// };
match self {
Self::Sessions(sessions, query) => {
ctx.insert(
"sessions",
&sessions.into_iter().map(Session::from).collect::<Vec<_>>(),
);
tera.render(self.template(), &ctx)
if let Some(query) = query {
ctx.insert("next_page_query", &query.encode());
}
}
_ => {}
};
tera.render(template, &ctx)
}
}
impl From<gpodder::Session> for Session {
fn from(value: gpodder::Session) -> Self {
Self {
user_agent: value.user_agent,
last_seen: value.last_seen,
}
}
}