From 3207c8ea57eac714417494e06ce0f82864b7ff1e Mon Sep 17 00:00:00 2001 From: Vika Date: Sun, 20 Apr 2025 08:25:57 +0300 Subject: WIP: Theme support Kittybox can now ship with several different stylesheets, provided by the renderer. Unknown stylesheets fall back to the default one, which is the same Kittybox has shipped since its inception. There's also a settings field for custom CSS, but it's not exposed anywhere yet. Change-Id: I2850dace5c40f9fed04b4651c551a861df5b83d3 --- Cargo.lock | 1 + migrations/0002_custom_theme_setting.sql | 3 +++ src/database/settings.rs | 19 +++++++++++++++++++ src/frontend/mod.rs | 22 +++++++++++++++++----- src/frontend/onboarding.rs | 2 ++ src/indieauth/mod.rs | 15 ++++++++++++--- src/login.rs | 28 ++++++++++++++++++++++------ templates/Cargo.toml | 1 + templates/src/lib.rs | 1 + templates/src/templates.rs | 6 +++--- templates/src/themes.rs | 25 +++++++++++++++++++++++++ 11 files changed, 106 insertions(+), 17 deletions(-) create mode 100644 migrations/0002_custom_theme_setting.sql create mode 100644 templates/src/themes.rs diff --git a/Cargo.lock b/Cargo.lock index 783ec4f..f4137d3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1924,6 +1924,7 @@ dependencies = [ "markup", "microformats", "rand", + "serde", "serde_json", "time", "walkdir", diff --git a/migrations/0002_custom_theme_setting.sql b/migrations/0002_custom_theme_setting.sql new file mode 100644 index 0000000..a258e82 --- /dev/null +++ b/migrations/0002_custom_theme_setting.sql @@ -0,0 +1,3 @@ +ALTER TABLE kittybox.users +ADD COLUMN custom_css JSONB NOT NULL DEFAULT '""'::jsonb, +ADD COLUMN theme JSONB NOT NULL DEFAULT '"kittybox"'::jsonb; diff --git a/src/database/settings.rs b/src/database/settings.rs index 792a155..77e5821 100644 --- a/src/database/settings.rs +++ b/src/database/settings.rs @@ -1,3 +1,5 @@ +pub use kittybox_frontend_renderer::themes::ThemeName; + mod private { pub trait Sealed {} } @@ -61,3 +63,20 @@ impl Setting for Webring { Self(data) } } + +#[derive(Debug, Default, serde::Serialize, serde::Deserialize, Clone, Copy, PartialEq, Eq)] +/// Theme setting for Kittybox, specifying the stylesheet used for laying out the website. +pub struct Theme(ThemeName); +impl private::Sealed for Theme {} +impl Setting for Theme { + type Data = ThemeName; + const ID: &'static str = "theme"; + + fn into_inner(self) -> Self::Data { + self.0 + } + + fn new(data: Self::Data) -> Self { + Self(data) + } +} diff --git a/src/frontend/mod.rs b/src/frontend/mod.rs index 94b8aa7..a05c91d 100644 --- a/src/frontend/mod.rs +++ b/src/frontend/mod.rs @@ -1,4 +1,4 @@ -use crate::database::{Storage, StorageError}; +use crate::database::{settings::Setting, Storage, StorageError}; use axum::{ extract::{Query, State}, http::{StatusCode, Uri}, @@ -271,11 +271,13 @@ pub async fn homepage( // other requests anyway if it's serious...) // // btw is it more efficient to fetch these in parallel? - let (blogname, webring, channels) = tokio::join!( + let (blogname, webring, theme, channels) = tokio::join!( db.get_setting::(&hcard_url) .map(Result::unwrap_or_default), db.get_setting::(&hcard_url) .map(Result::unwrap_or_default), + db.get_setting::(&hcard_url) + .map(Result::unwrap_or_default), db.get_channels(&hcard_url).map(|i| i.unwrap_or_default()) ); @@ -293,6 +295,7 @@ pub async fn homepage( title: blogname.as_ref(), blog_name: blogname.as_ref(), feeds: channels, + theme: theme.into_inner(), user: session.as_deref(), content: MainPage { feed: &hfeed, @@ -319,9 +322,11 @@ pub async fn homepage( } else { error!("Error while fetching h-card and/or h-feed: {}", err); // Return the error - let (blogname, channels) = tokio::join!( + let (blogname, theme, channels) = tokio::join!( db.get_setting::(&hcard_url) .map(Result::unwrap_or_default), + db.get_setting::(&hcard_url) + .map(Result::unwrap_or_default), db.get_channels(&hcard_url).map(|i| i.unwrap_or_default()) ); @@ -332,6 +337,7 @@ pub async fn homepage( title: blogname.as_ref(), blog_name: blogname.as_ref(), feeds: channels, + theme: theme.into_inner(), user: session.as_deref(), content: ErrorPage { code: err.code(), @@ -361,9 +367,11 @@ pub async fn catchall( match get_post_from_database(&db, path.as_str(), query.after, user).await { Ok((post, cursor)) => { - let (blogname, channels) = tokio::join!( + let (blogname, theme, channels) = tokio::join!( db.get_setting::(&host) .map(Result::unwrap_or_default), + db.get_setting::(&host) + .map(Result::unwrap_or_default), db.get_channels(&host).map(|i| i.unwrap_or_default()) ); let mut headers = axum::http::HeaderMap::new(); @@ -411,6 +419,7 @@ pub async fn catchall( title: blogname.as_ref(), blog_name: blogname.as_ref(), feeds: channels, + theme: theme.into_inner(), user: session.as_deref(), content: match post.pointer("/type/0").and_then(|i| i.as_str()) { Some("h-entry") => Entry { @@ -434,9 +443,11 @@ pub async fn catchall( .into_response() } Err(err) => { - let (blogname, channels) = tokio::join!( + let (blogname, theme, channels) = tokio::join!( db.get_setting::(&host) .map(Result::unwrap_or_default), + db.get_setting::(&host) + .map(Result::unwrap_or_default), db.get_channels(&host).map(|i| i.unwrap_or_default()) ); ( @@ -449,6 +460,7 @@ pub async fn catchall( title: blogname.as_ref(), blog_name: blogname.as_ref(), feeds: channels, + theme: theme.into_inner(), user: session.as_deref(), content: ErrorPage { code: err.code(), diff --git a/src/frontend/onboarding.rs b/src/frontend/onboarding.rs index 3b53911..3caa458 100644 --- a/src/frontend/onboarding.rs +++ b/src/frontend/onboarding.rs @@ -21,6 +21,7 @@ pub async fn get() -> Html { title: "Kittybox - Onboarding", blog_name: "Kittybox", feeds: vec![], + theme: Default::default(), user: None, content: OnboardingPage {}.to_string(), } @@ -150,6 +151,7 @@ pub async fn post( title: "Kittybox - Onboarding", blog_name: "Kittybox", feeds: vec![], + theme: Default::default(), user: None, content: ErrorPage { code: err.code(), diff --git a/src/indieauth/mod.rs b/src/indieauth/mod.rs index 5cdbf05..af49a7f 100644 --- a/src/indieauth/mod.rs +++ b/src/indieauth/mod.rs @@ -1,4 +1,4 @@ -use crate::database::Storage; +use crate::database::{settings::Setting, Storage}; use axum::{ extract::{Form, FromRef, Json, Query, State}, http::StatusCode, @@ -13,6 +13,7 @@ use axum_extra::{ headers::{authorization::Bearer, Authorization, ContentType, HeaderMapExt}, TypedHeader, }; +use futures::FutureExt; use kittybox_indieauth::{ AuthorizationRequest, AuthorizationResponse, ClientMetadata, Error, ErrorKind, GrantRequest, GrantResponse, GrantType, IntrospectionEndpointAuthMethod, Metadata, PKCEMethod, Profile, @@ -319,12 +320,20 @@ async fn authorization_endpoint_get( { request.scope.replace(Scopes::new(vec![Scope::Profile])); } + let (blog_name, theme, channels) = tokio::join!( + db.get_setting::(&me) + .map(Result::unwrap_or_default), + db.get_setting::(&me) + .map(Result::unwrap_or_default), + db.get_channels(&me).map(|i| i.unwrap_or_default()) + ); Html( kittybox_frontend_renderer::Template { title: "Confirm sign-in via IndieAuth", - blog_name: "Kittybox", - feeds: vec![], + blog_name: &blog_name.as_ref(), + feeds: channels, + theme: theme.into_inner(), user: None, content: kittybox_frontend_renderer::AuthorizationRequestPage { request, diff --git a/src/login.rs b/src/login.rs index 3038d9c..1b6a6f3 100644 --- a/src/login.rs +++ b/src/login.rs @@ -23,7 +23,7 @@ use kittybox_frontend_renderer::{LoginPage, LogoutPage, Template}; use kittybox_indieauth::{AuthorizationResponse, Error, GrantType, PKCEVerifier, Scope, Scopes}; use sha2::Digest; -use crate::database::Storage; +use crate::database::{settings::Setting, Storage}; /// Show a login page. async fn get( @@ -32,9 +32,11 @@ async fn get( ) -> impl axum::response::IntoResponse { let hcard_url: url::Url = format!("https://{}/", host).parse().unwrap(); - let (blogname, channels) = tokio::join!( + let (blogname, theme, channels) = tokio::join!( db.get_setting::(&hcard_url) .map(Result::unwrap_or_default), + db.get_setting::(&hcard_url) + .map(Result::unwrap_or_default), db.get_channels(&hcard_url).map(|i| i.unwrap_or_default()) ); ( @@ -47,6 +49,7 @@ async fn get( title: "Sign in with your website", blog_name: blogname.as_ref(), feeds: channels, + theme: theme.into_inner(), user: None, content: LoginPage {}.to_string(), } @@ -380,14 +383,27 @@ async fn callback( /// of crawlers working with a user's cookies (wget?). If a crawler is /// stupid enough to execute JS and send a POST request though, that's /// on the crawler. -async fn logout_page() -> impl axum::response::IntoResponse { +async fn logout_page( + State(db): State, + Host(host): Host, +) -> impl axum::response::IntoResponse { + let me: url::Url = format!("https://{host}/").parse().unwrap(); + let (blog_name, theme, channels) = tokio::join!( + db.get_setting::(&me) + .map(Result::unwrap_or_default), + db.get_setting::(&me) + .map(Result::unwrap_or_default), + db.get_channels(&me).map(|i| i.unwrap_or_default()) + ); + ( StatusCode::OK, [("Content-Type", "text/html")], Template { title: "Signing out...", - blog_name: "Kittybox", - feeds: vec![], + blog_name: blog_name.as_ref(), + theme: theme.into_inner(), + feeds: channels, user: None, content: LogoutPage {}.to_string(), } @@ -501,6 +517,6 @@ where axum::routing::Router::new() .route("/start", axum::routing::get(get::).post(post)) .route("/finish", axum::routing::get(callback)) - .route("/logout", axum::routing::get(logout_page).post(logout)) + .route("/logout", axum::routing::get(logout_page::).post(logout)) .route("/client_metadata", axum::routing::get(client_metadata::)) } diff --git a/templates/Cargo.toml b/templates/Cargo.toml index ca56dfe..de81af5 100644 --- a/templates/Cargo.toml +++ b/templates/Cargo.toml @@ -23,6 +23,7 @@ http = { workspace = true } include_dir = { workspace = true } markup = { workspace = true } serde_json = { workspace = true } +serde = { workspace = true } [dependencies.kittybox-util] version = "0.3.0" diff --git a/templates/src/lib.rs b/templates/src/lib.rs index 0f9f7c6..194a837 100644 --- a/templates/src/lib.rs +++ b/templates/src/lib.rs @@ -10,6 +10,7 @@ mod mf2; pub use mf2::{Entry, Feed, Food, VCard, POSTS_PER_PAGE}; pub mod admin; pub mod assets; +pub mod themes; #[cfg(test)] mod tests { diff --git a/templates/src/templates.rs b/templates/src/templates.rs index 5772b4d..785a4b8 100644 --- a/templates/src/templates.rs +++ b/templates/src/templates.rs @@ -1,16 +1,16 @@ #![allow(clippy::needless_lifetimes)] -use crate::{Feed, VCard}; +use crate::{themes::ThemeName, Feed, VCard}; use http::StatusCode; use kittybox_util::micropub::Channel; markup::define! { - Template<'a>(title: &'a str, blog_name: &'a str, feeds: Vec, user: Option<&'a kittybox_indieauth::ProfileUrl>, content: String) { + Template<'a>(title: &'a str, blog_name: &'a str, feeds: Vec, theme: ThemeName, user: Option<&'a kittybox_indieauth::ProfileUrl>, content: String) { @markup::doctype() html { head { title { @title } link[rel="preconnect", href="https://fonts.gstatic.com"]; - link[rel="stylesheet", href="/.kittybox/static/style.css"]; + link[rel="stylesheet", href=theme.into_css_link()]; meta[name="viewport", content="initial-scale=1, width=device-width"]; link[rel="micropub", href="/.kittybox/micropub"]; diff --git a/templates/src/themes.rs b/templates/src/themes.rs new file mode 100644 index 0000000..ebc0ed9 --- /dev/null +++ b/templates/src/themes.rs @@ -0,0 +1,25 @@ +#[derive(Debug, Default, serde::Serialize, serde::Deserialize, Clone, Copy, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +/// Choices of themes possible in Kittybox. +pub enum ThemeName { + #[default] + /// Default theme shipped with Kittybox. + Kittybox, + /// "Serious business" theme, typeset like a business memo. + VivianWork, + /// Emulation of the old style websites used back in the day. + Retro, + /// Custom CSS specified by user. + Custom, +} + +impl ThemeName { + pub(crate) fn into_css_link(self) -> &'static str { + match self { + ThemeName::Kittybox => "/.kittybox/static/style.css", + ThemeName::VivianWork => "/.kittybox/static/vivian_work.style.css", + ThemeName::Retro => "/.kittybox/static/retro.style.css", + ThemeName::Custom => "/.kittybox/custom_style.css", + } + } +} -- cgit 1.4.1