Add hx-boosted support to Layout
It will now skip rendering the layout if the request is coming from an hx-boosted link or form and only update the head title with a hx-swap-oob.
This commit is contained in:
parent
81b4ef860e
commit
092a38ad52
1
Cargo.lock
generated
1
Cargo.lock
generated
@ -744,6 +744,7 @@ dependencies = [
|
||||
"dotenvy",
|
||||
"feed-rs",
|
||||
"futures",
|
||||
"headers",
|
||||
"http",
|
||||
"maud",
|
||||
"notify",
|
||||
|
@ -26,6 +26,7 @@ clap = { version = "4.4", features = ["derive", "env"] }
|
||||
dotenvy = "0.15"
|
||||
feed-rs = "1.3"
|
||||
futures = "0.3"
|
||||
headers = "0.3"
|
||||
http = "0.2.9"
|
||||
maud = { version = "0.25", features = ["axum"] }
|
||||
notify = "6"
|
||||
|
@ -228,7 +228,6 @@ header.feed-header button {
|
||||
display: grid;
|
||||
grid-template-columns: fit-content(100%) minmax(100px, 400px);
|
||||
grid-gap: 16px;
|
||||
width: 100%;
|
||||
margin: 16px;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
@ -2,11 +2,13 @@ use std::fs;
|
||||
|
||||
use axum::extract::{Path, State};
|
||||
use axum::response::Response;
|
||||
use axum::TypedHeader;
|
||||
use maud::{html, PreEscaped};
|
||||
use sqlx::PgPool;
|
||||
|
||||
use crate::config::Config;
|
||||
use crate::error::Result;
|
||||
use crate::htmx::HXBoosted;
|
||||
use crate::models::entry::Entry;
|
||||
use crate::partials::layout::Layout;
|
||||
use crate::uuid::Base62Uuid;
|
||||
@ -15,6 +17,7 @@ pub async fn get(
|
||||
Path(id): Path<Base62Uuid>,
|
||||
State(pool): State<PgPool>,
|
||||
State(config): State<Config>,
|
||||
hx_boosted: Option<TypedHeader<HXBoosted>>,
|
||||
layout: Layout,
|
||||
) -> Result<Response> {
|
||||
let entry = Entry::get(&pool, id.as_uuid()).await?;
|
||||
@ -25,20 +28,23 @@ pub async fn get(
|
||||
.published_at
|
||||
.to_rfc3339_opts(chrono::SecondsFormat::Millis, true);
|
||||
let content = fs::read_to_string(content_path).unwrap_or_else(|_| "No content".to_string());
|
||||
Ok(layout.with_subtitle(&title).render(html! {
|
||||
article {
|
||||
header {
|
||||
h2 class="title" { a href=(entry.url) { (title) } }
|
||||
}
|
||||
div {
|
||||
span class="published" {
|
||||
strong { "Published: " }
|
||||
time datetime=(published_at) class="local-time" {
|
||||
(published_at)
|
||||
Ok(layout
|
||||
.with_subtitle(&title)
|
||||
.boosted(hx_boosted)
|
||||
.render(html! {
|
||||
article {
|
||||
header {
|
||||
h2 class="title" { a href=(entry.url) { (title) } }
|
||||
}
|
||||
div {
|
||||
span class="published" {
|
||||
strong { "Published: " }
|
||||
time datetime=(published_at) class="local-time" {
|
||||
(published_at)
|
||||
}
|
||||
}
|
||||
}
|
||||
(PreEscaped(content))
|
||||
}
|
||||
(PreEscaped(content))
|
||||
}
|
||||
}))
|
||||
}))
|
||||
}
|
||||
|
@ -4,7 +4,7 @@ use axum::extract::{Path, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::sse::{Event, KeepAlive};
|
||||
use axum::response::{IntoResponse, Redirect, Response, Sse};
|
||||
use axum::Form;
|
||||
use axum::{Form, TypedHeader};
|
||||
use feed_rs::parser;
|
||||
use maud::html;
|
||||
use serde::Deserialize;
|
||||
@ -16,6 +16,7 @@ use tokio_stream::StreamExt;
|
||||
use crate::actors::crawl_scheduler::{CrawlSchedulerHandle, CrawlSchedulerHandleMessage};
|
||||
use crate::actors::feed_crawler::FeedCrawlerHandleMessage;
|
||||
use crate::error::{Error, Result};
|
||||
use crate::htmx::HXBoosted;
|
||||
use crate::models::entry::{Entry, GetEntriesOptions};
|
||||
use crate::models::feed::{CreateFeed, Feed};
|
||||
use crate::partials::add_feed_form::add_feed_form;
|
||||
@ -27,6 +28,7 @@ use crate::uuid::Base62Uuid;
|
||||
pub async fn get(
|
||||
Path(id): Path<Base62Uuid>,
|
||||
State(pool): State<PgPool>,
|
||||
hx_boosted: Option<TypedHeader<HXBoosted>>,
|
||||
layout: Layout,
|
||||
) -> Result<Response> {
|
||||
let feed = Feed::get(&pool, id.as_uuid()).await?;
|
||||
@ -37,7 +39,7 @@ pub async fn get(
|
||||
let title = feed.title.unwrap_or_else(|| "Untitled Feed".to_string());
|
||||
let entries = Entry::get_all(&pool, &options).await?;
|
||||
let delete_url = format!("/feed/{}/delete", id);
|
||||
Ok(layout.with_subtitle(&title).render(html! {
|
||||
Ok(layout.with_subtitle(&title).boosted(hx_boosted).render(html! {
|
||||
header class="feed-header" {
|
||||
h2 { (title) }
|
||||
button class="edit-feed" { "✏️ Edit feed" }
|
||||
|
@ -1,29 +1,38 @@
|
||||
use axum::extract::State;
|
||||
use axum::response::Response;
|
||||
use axum::TypedHeader;
|
||||
use maud::html;
|
||||
use sqlx::PgPool;
|
||||
|
||||
use crate::error::Result;
|
||||
use crate::htmx::HXBoosted;
|
||||
use crate::models::feed::{Feed, GetFeedsOptions};
|
||||
use crate::partials::add_feed_form::add_feed_form;
|
||||
use crate::partials::feed_list::feed_list;
|
||||
use crate::partials::layout::Layout;
|
||||
use crate::partials::opml_import_form::opml_import_form;
|
||||
|
||||
pub async fn get(State(pool): State<PgPool>, layout: Layout) -> Result<Response> {
|
||||
pub async fn get(
|
||||
State(pool): State<PgPool>,
|
||||
hx_boosted: Option<TypedHeader<HXBoosted>>,
|
||||
layout: Layout,
|
||||
) -> Result<Response> {
|
||||
let options = GetFeedsOptions::default();
|
||||
let feeds = Feed::get_all(&pool, &options).await?;
|
||||
Ok(layout.with_subtitle("feeds").render(html! {
|
||||
header { h2 { "Feeds" } }
|
||||
div class="feeds" {
|
||||
ul id="feeds" {
|
||||
(feed_list(feeds, &options))
|
||||
Ok(layout
|
||||
.with_subtitle("feeds")
|
||||
.boosted(hx_boosted)
|
||||
.render(html! {
|
||||
header { h2 { "Feeds" } }
|
||||
div class="feeds" {
|
||||
ul id="feeds" {
|
||||
(feed_list(feeds, &options))
|
||||
}
|
||||
div class="add-feed" {
|
||||
h3 { "Add Feed" }
|
||||
(add_feed_form())
|
||||
(opml_import_form())
|
||||
}
|
||||
}
|
||||
div class="add-feed" {
|
||||
h3 { "Add Feed" }
|
||||
(add_feed_form())
|
||||
(opml_import_form())
|
||||
}
|
||||
}
|
||||
}))
|
||||
}))
|
||||
}
|
||||
|
@ -1,16 +1,22 @@
|
||||
use axum::extract::State;
|
||||
use axum::response::Response;
|
||||
use axum::TypedHeader;
|
||||
use maud::html;
|
||||
use sqlx::PgPool;
|
||||
|
||||
use crate::error::Result;
|
||||
use crate::htmx::HXBoosted;
|
||||
use crate::models::entry::Entry;
|
||||
use crate::partials::{layout::Layout, entry_list::entry_list};
|
||||
use crate::partials::{entry_list::entry_list, layout::Layout};
|
||||
|
||||
pub async fn get(State(pool): State<PgPool>, layout: Layout) -> Result<Response> {
|
||||
pub async fn get(
|
||||
State(pool): State<PgPool>,
|
||||
hx_boosted: Option<TypedHeader<HXBoosted>>,
|
||||
layout: Layout,
|
||||
) -> Result<Response> {
|
||||
let options = Default::default();
|
||||
let entries = Entry::get_all(&pool, &options).await?;
|
||||
Ok(layout.render(html! {
|
||||
Ok(layout.boosted(hx_boosted).render(html! {
|
||||
ul class="entries" {
|
||||
(entry_list(entries, &options))
|
||||
}
|
||||
|
@ -9,6 +9,7 @@ use axum::response::{
|
||||
sse::{Event, Sse},
|
||||
Response,
|
||||
};
|
||||
use axum::TypedHeader;
|
||||
use bytes::Bytes;
|
||||
use maud::{html, PreEscaped};
|
||||
use tokio::sync::watch::Receiver;
|
||||
@ -17,16 +18,20 @@ use tokio_stream::Stream;
|
||||
use tokio_stream::StreamExt;
|
||||
|
||||
use crate::error::Result;
|
||||
use crate::htmx::HXBoosted;
|
||||
use crate::log::MEM_LOG;
|
||||
use crate::partials::layout::Layout;
|
||||
|
||||
pub async fn get(layout: Layout) -> Result<Response> {
|
||||
pub async fn get(hx_boosted: Option<TypedHeader<HXBoosted>>, layout: Layout) -> Result<Response> {
|
||||
let mem_buf = MEM_LOG.lock().unwrap();
|
||||
Ok(layout.with_subtitle("log").render(html! {
|
||||
pre id="log" hx-sse="connect:/log/stream swap:message" hx-swap="beforeend" {
|
||||
(PreEscaped(convert_escaped(from_utf8(mem_buf.as_slices().0).unwrap()).unwrap()))
|
||||
}
|
||||
}))
|
||||
Ok(layout
|
||||
.with_subtitle("log")
|
||||
.boosted(hx_boosted)
|
||||
.render(html! {
|
||||
pre id="log" hx-sse="connect:/log/stream swap:message" hx-swap="beforeend" hx-target="#log" {
|
||||
(PreEscaped(convert_escaped(from_utf8(mem_buf.as_slices().0).unwrap()).unwrap()))
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn stream(
|
||||
|
@ -1,4 +1,5 @@
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::TypedHeader;
|
||||
use axum::{extract::State, Form};
|
||||
use maud::html;
|
||||
use serde::Deserialize;
|
||||
@ -7,7 +8,7 @@ use sqlx::PgPool;
|
||||
|
||||
use crate::auth::verify_password;
|
||||
use crate::error::{Error, Result};
|
||||
use crate::htmx::HXRedirect;
|
||||
use crate::htmx::{HXBoosted, HXRedirect};
|
||||
use crate::partials::login_form::{login_form, LoginFormProps};
|
||||
use crate::{
|
||||
models::user::{AuthContext, User},
|
||||
@ -21,13 +22,16 @@ pub struct Login {
|
||||
password: String,
|
||||
}
|
||||
|
||||
pub async fn get(layout: Layout) -> Result<Response> {
|
||||
Ok(layout.with_subtitle("login").render(html! {
|
||||
header {
|
||||
h2 { "Login" }
|
||||
}
|
||||
(login_form(LoginFormProps::default()))
|
||||
}))
|
||||
pub async fn get(hx_boosted: Option<TypedHeader<HXBoosted>>, layout: Layout) -> Result<Response> {
|
||||
Ok(layout
|
||||
.with_subtitle("login")
|
||||
.boosted(hx_boosted)
|
||||
.render(html! {
|
||||
header {
|
||||
h2 { "Login" }
|
||||
}
|
||||
(login_form(LoginFormProps::default()))
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn post(
|
||||
|
@ -1,4 +1,5 @@
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::TypedHeader;
|
||||
use axum::{extract::State, Form};
|
||||
use maud::html;
|
||||
use serde::Deserialize;
|
||||
@ -6,7 +7,7 @@ use serde_with::{serde_as, NoneAsEmptyString};
|
||||
use sqlx::PgPool;
|
||||
|
||||
use crate::error::{Error, Result};
|
||||
use crate::htmx::HXRedirect;
|
||||
use crate::htmx::{HXBoosted, HXRedirect};
|
||||
use crate::models::user::{AuthContext, CreateUser, User};
|
||||
use crate::partials::layout::Layout;
|
||||
use crate::partials::signup_form::{signup_form, SignupFormProps};
|
||||
@ -21,13 +22,16 @@ pub struct Signup {
|
||||
pub name: Option<String>,
|
||||
}
|
||||
|
||||
pub async fn get(layout: Layout) -> Result<Response> {
|
||||
Ok(layout.with_subtitle("signup").render(html! {
|
||||
header {
|
||||
h2 { "Signup" }
|
||||
}
|
||||
(signup_form(SignupFormProps::default()))
|
||||
}))
|
||||
pub async fn get(hx_boosted: Option<TypedHeader<HXBoosted>>, layout: Layout) -> Result<Response> {
|
||||
Ok(layout
|
||||
.with_subtitle("signup")
|
||||
.boosted(hx_boosted)
|
||||
.render(html! {
|
||||
header {
|
||||
h2 { "Signup" }
|
||||
}
|
||||
(signup_form(SignupFormProps::default()))
|
||||
}))
|
||||
}
|
||||
|
||||
pub async fn post(
|
||||
|
53
src/htmx.rs
53
src/htmx.rs
@ -1,9 +1,12 @@
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use headers::{self, Header};
|
||||
use http::header::{HeaderName, HeaderValue};
|
||||
use http::StatusCode;
|
||||
|
||||
#[allow(clippy::declare_interior_mutable_const)]
|
||||
const HX_LOCATION: HeaderName = HeaderName::from_static("hx-location");
|
||||
pub const HX_LOCATION: HeaderName = HeaderName::from_static("hx-location");
|
||||
#[allow(clippy::declare_interior_mutable_const)]
|
||||
pub const HX_BOOSTED: HeaderName = HeaderName::from_static("hx-boosted");
|
||||
|
||||
/// Sets the HX-Location header so that HTMX redirects to the given URI. Unlike
|
||||
/// axum::response::Redirect this does not return a 300-level status code (instead, a 200 status
|
||||
@ -30,3 +33,51 @@ impl IntoResponse for HXRedirect {
|
||||
.into_response()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct HXBoosted(bool);
|
||||
|
||||
impl HXBoosted {
|
||||
pub fn is_boosted(&self) -> bool {
|
||||
self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl Header for HXBoosted {
|
||||
fn name() -> &'static HeaderName {
|
||||
static HX_BOOSTED_STATIC: HeaderName = HX_BOOSTED;
|
||||
&HX_BOOSTED_STATIC
|
||||
}
|
||||
|
||||
fn decode<'i, I>(values: &mut I) -> Result<Self, headers::Error>
|
||||
where
|
||||
I: Iterator<Item = &'i HeaderValue>,
|
||||
{
|
||||
let value = values
|
||||
.next()
|
||||
.ok_or_else(headers::Error::invalid)?;
|
||||
|
||||
if value == "true" {
|
||||
Ok(HXBoosted(true))
|
||||
} else if value == "false" {
|
||||
Ok(HXBoosted(false))
|
||||
} else {
|
||||
Err(headers::Error::invalid())
|
||||
}
|
||||
}
|
||||
|
||||
fn encode<E>(&self, values: &mut E)
|
||||
where
|
||||
E: Extend<HeaderValue>,
|
||||
{
|
||||
let s = if self.0 {
|
||||
"true"
|
||||
} else {
|
||||
"false"
|
||||
};
|
||||
|
||||
let value = HeaderValue::from_static(s);
|
||||
|
||||
values.extend(std::iter::once(value));
|
||||
}
|
||||
}
|
||||
|
@ -8,15 +8,16 @@ use axum::{
|
||||
extract::{FromRef, FromRequestParts, State},
|
||||
http::request::Parts,
|
||||
response::{Html, IntoResponse, Response},
|
||||
TypedHeader,
|
||||
};
|
||||
use axum_login::{extractors::AuthContext, SqlxStore};
|
||||
use maud::{html, Markup, DOCTYPE};
|
||||
use sqlx::PgPool;
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::partials::header::header;
|
||||
use crate::{config::Config, partials::footer::footer};
|
||||
use crate::models::user::User;
|
||||
use crate::{config::Config, partials::footer::footer};
|
||||
use crate::{htmx::HXBoosted, partials::header::header};
|
||||
#[cfg(not(debug_assertions))]
|
||||
use crate::{CSS_MANIFEST, JS_MANIFEST};
|
||||
|
||||
@ -25,6 +26,7 @@ pub struct Layout {
|
||||
pub title: String,
|
||||
pub subtitle: Option<String>,
|
||||
pub user: Option<User>,
|
||||
pub boosted: bool,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
@ -39,9 +41,10 @@ where
|
||||
let State(config) = State::<Config>::from_request_parts(parts, state)
|
||||
.await
|
||||
.map_err(|err| err.into_response())?;
|
||||
let auth_context = AuthContext::<Uuid, User, SqlxStore<PgPool, User>>::from_request_parts(parts, state)
|
||||
.await
|
||||
.map_err(|err| err.into_response())?;
|
||||
let auth_context =
|
||||
AuthContext::<Uuid, User, SqlxStore<PgPool, User>>::from_request_parts(parts, state)
|
||||
.await
|
||||
.map_err(|err| err.into_response())?;
|
||||
Ok(Self {
|
||||
title: config.title,
|
||||
user: auth_context.current_user,
|
||||
@ -115,6 +118,22 @@ impl Layout {
|
||||
self
|
||||
}
|
||||
|
||||
/// If the given HX-Boosted header is present and "true", makes this Layout boosted.
|
||||
///
|
||||
/// A boosted layout will skip rendering the layout and only render the template with a
|
||||
/// hx-swap-oob <title> element to update the document title.
|
||||
///
|
||||
/// Links and forms that are boosted with the hx-boost attribute are only updating a portion of
|
||||
/// the page inside the layout, so there is no need to render and send the layout again.
|
||||
pub fn boosted(mut self, hx_boosted: Option<TypedHeader<HXBoosted>>) -> Self {
|
||||
if let Some(hx_boosted) = hx_boosted {
|
||||
if hx_boosted.is_boosted() {
|
||||
self.boosted = true;
|
||||
}
|
||||
}
|
||||
self
|
||||
}
|
||||
|
||||
fn full_title(&self) -> String {
|
||||
if let Some(subtitle) = &self.subtitle {
|
||||
format!("{} - {}", self.title, subtitle)
|
||||
@ -124,28 +143,34 @@ impl Layout {
|
||||
}
|
||||
|
||||
pub fn render(self, template: Markup) -> Response {
|
||||
let with_layout = html! {
|
||||
(DOCTYPE)
|
||||
html lang="en" {
|
||||
head {
|
||||
meta charset="utf-8";
|
||||
title { (self.full_title()) }
|
||||
@for js_file in js_manifest() {
|
||||
script type="module" src=(js_file) {}
|
||||
if self.boosted {
|
||||
html! {
|
||||
title hx-swap-oob="true" { (self.full_title()) }
|
||||
(template)
|
||||
}
|
||||
.into_response()
|
||||
} else {
|
||||
html! {
|
||||
(DOCTYPE)
|
||||
html lang="en" {
|
||||
head {
|
||||
meta charset="utf-8";
|
||||
title { (self.full_title()) }
|
||||
@for js_file in js_manifest() {
|
||||
script type="module" src=(js_file) {}
|
||||
}
|
||||
@for css_file in css_manifest() {
|
||||
link rel="stylesheet" href=(css_file) {}
|
||||
}
|
||||
}
|
||||
@for css_file in css_manifest() {
|
||||
link rel="stylesheet" href=(css_file) {}
|
||||
body hx-boost="true" hx-target="#main-content" {
|
||||
(header(&self.title, self.user))
|
||||
main id="main-content" { (template) }
|
||||
(footer())
|
||||
}
|
||||
}
|
||||
body hx-booster="true" {
|
||||
(header(&self.title, self.user))
|
||||
(template)
|
||||
(footer())
|
||||
}
|
||||
}
|
||||
.into_response()
|
||||
}
|
||||
.into_string();
|
||||
|
||||
Html(with_layout).into_response()
|
||||
}
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user