diff --git a/common/migrations/20260116_000000_add_user_theme_preference.surql b/common/migrations/20260116_000000_add_user_theme_preference.surql new file mode 100644 index 0000000..91688f2 --- /dev/null +++ b/common/migrations/20260116_000000_add_user_theme_preference.surql @@ -0,0 +1 @@ +DEFINE FIELD IF NOT EXISTS theme ON user TYPE string DEFAULT "system"; diff --git a/common/src/storage/types/user.rs b/common/src/storage/types/user.rs index 5e0633a..57801bb 100644 --- a/common/src/storage/types/user.rs +++ b/common/src/storage/types/user.rs @@ -34,7 +34,9 @@ stored_object!( api_key: Option, admin: bool, #[serde(default)] - timezone: String + timezone: String, + #[serde(default)] + theme: String }); #[async_trait] @@ -70,6 +72,14 @@ fn validate_timezone(input: &str) -> String { "UTC".to_owned() } +/// Ensures a theme string is valid, defaulting to "system" when invalid. +fn validate_theme(input: &str) -> String { + match input { + "light" | "dark" | "system" => input.to_owned(), + _ => "system".to_owned(), + } +} + #[derive(Serialize, Deserialize, Debug, Clone)] pub struct DashboardStats { pub total_documents: i64, @@ -168,6 +178,7 @@ impl User { password: String, db: &SurrealDbClient, timezone: String, + theme: String, ) -> Result { // verify that the application allows new creations let systemsettings = SystemSettings::get_current(db).await?; @@ -176,6 +187,7 @@ impl User { } let validated_tz = validate_timezone(&timezone); + let validated_theme = validate_theme(&theme); let now = Utc::now(); let id = Uuid::new_v4().to_string(); @@ -190,7 +202,8 @@ impl User { anonymous = false, created_at = $created_at, updated_at = $updated_at, - timezone = $timezone", + timezone = $timezone, + theme = $theme", ) .bind(("table", "user")) .bind(("id", id)) @@ -199,6 +212,7 @@ impl User { .bind(("created_at", surrealdb::Datetime::from(now))) .bind(("updated_at", surrealdb::Datetime::from(now))) .bind(("timezone", validated_tz)) + .bind(("theme", validated_theme)) .await? .take(1)?; @@ -468,6 +482,19 @@ impl User { Ok(()) } + pub async fn update_theme( + user_id: &str, + theme: &str, + db: &SurrealDbClient, + ) -> Result<(), AppError> { + let validated_theme = validate_theme(theme); + db.query("UPDATE type::thing('user', $user_id) SET theme = $theme") + .bind(("user_id", user_id.to_string())) + .bind(("theme", validated_theme)) + .await?; + Ok(()) + } + pub async fn get_user_categories( user_id: &str, db: &SurrealDbClient, @@ -674,6 +701,7 @@ mod tests { password.to_string(), &db, timezone.to_string(), + "system".to_string(), ) .await .expect("Failed to create user"); @@ -711,6 +739,7 @@ mod tests { password.to_string(), &db, "UTC".to_string(), + "system".to_string(), ) .await .expect("Failed to create user"); @@ -858,6 +887,7 @@ mod tests { password.to_string(), &db, "UTC".to_string(), + "system".to_string(), ) .await .expect("Failed to create user"); @@ -892,6 +922,7 @@ mod tests { password.to_string(), &db, "UTC".to_string(), + "system".to_string(), ) .await .expect("Failed to create user"); @@ -959,6 +990,7 @@ mod tests { old_password.to_string(), &db, "UTC".to_string(), + "system".to_string(), ) .await .expect("Failed to create user"); @@ -1006,6 +1038,7 @@ mod tests { "password".to_string(), &db, "UTC".to_string(), + "system".to_string(), ) .await .expect("Failed to create user"); @@ -1116,4 +1149,51 @@ mod tests { ); } } + + #[tokio::test] + async fn test_validate_theme() { + assert_eq!(validate_theme("light"), "light"); + assert_eq!(validate_theme("dark"), "dark"); + assert_eq!(validate_theme("system"), "system"); + assert_eq!(validate_theme("invalid"), "system"); + } + + #[tokio::test] + async fn test_theme_update() { + let db = setup_test_db().await; + let email = "theme_test@example.com"; + let user = User::create_new( + email.to_string(), + "password".to_string(), + &db, + "UTC".to_string(), + "system".to_string(), + ) + .await + .expect("Failed to create user"); + + assert_eq!(user.theme, "system"); + + User::update_theme(&user.id, "dark", &db) + .await + .expect("update theme"); + + let updated = db + .get_item::(&user.id) + .await + .expect("get user") + .unwrap(); + assert_eq!(updated.theme, "dark"); + + // Invalid theme should default to system (but update_theme calls validate_theme) + User::update_theme(&user.id, "invalid", &db) + .await + .expect("update theme invalid"); + let updated2 = db + .get_item::(&user.id) + .await + .expect("get user") + .unwrap(); + assert_eq!(updated2.theme, "system"); + } } diff --git a/evaluations/src/namespace.rs b/evaluations/src/namespace.rs index 07c0f99..a0b0d10 100644 --- a/evaluations/src/namespace.rs +++ b/evaluations/src/namespace.rs @@ -212,6 +212,7 @@ pub(crate) async fn ensure_eval_user(db: &SurrealDbClient) -> Result { api_key: None, admin: false, timezone: "UTC".to_string(), + theme: "system".to_string(), }; if let Some(existing) = db.get_item::(user.get_id()).await? { diff --git a/html-router/assets/theme-toggle.js b/html-router/assets/theme-toggle.js index b0d838d..227d878 100644 --- a/html-router/assets/theme-toggle.js +++ b/html-router/assets/theme-toggle.js @@ -1,32 +1,77 @@ +// Global media query and listener state +const systemMediaQuery = window.matchMedia('(prefers-color-scheme: dark)'); +let isSystemListenerAttached = false; + +const handleSystemThemeChange = (e) => { + const themePreference = document.documentElement.getAttribute('data-theme-preference'); + if (themePreference === 'system') { + document.documentElement.setAttribute('data-theme', e.matches ? 'dark' : 'light'); + } +}; + const initializeTheme = () => { - const themeToggle = document.querySelector('.theme-controller'); - if (!themeToggle) { - return; - } + const themeToggle = document.querySelector('.theme-controller'); + const themePreference = document.documentElement.getAttribute('data-theme-preference'); - // Detect system preference - const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches; + if (themeToggle) { + // Anonymous mode + if (isSystemListenerAttached) { + systemMediaQuery.removeEventListener('change', handleSystemThemeChange); + isSystemListenerAttached = false; + } - // Initialize theme from local storage or system preference - const savedTheme = localStorage.getItem('theme'); - const initialTheme = savedTheme ? savedTheme : (prefersDark ? 'dark' : 'light'); - document.documentElement.setAttribute('data-theme', initialTheme); - themeToggle.checked = initialTheme === 'dark'; + // Avoid re-binding if already bound + if (themeToggle.dataset.bound) return; + themeToggle.dataset.bound = "true"; - // Update theme and local storage on toggle - themeToggle.addEventListener('change', () => { - const theme = themeToggle.checked ? 'dark' : 'light'; - document.documentElement.setAttribute('data-theme', theme); - localStorage.setItem('theme', theme); - }); + // Detect system preference + const prefersDark = systemMediaQuery.matches; - }; + // Initialize theme from local storage or system preference + const savedTheme = localStorage.getItem('theme'); + const initialTheme = savedTheme ? savedTheme : (prefersDark ? 'dark' : 'light'); + document.documentElement.setAttribute('data-theme', initialTheme); + themeToggle.checked = initialTheme === 'dark'; - // Run the initialization after the DOM is fully loaded - document.addEventListener('DOMContentLoaded', () => { - initializeTheme(); - }); + // Update theme and local storage on toggle + themeToggle.addEventListener('change', () => { + const theme = themeToggle.checked ? 'dark' : 'light'; + document.documentElement.setAttribute('data-theme', theme); + localStorage.setItem('theme', theme); + }); - // Reinitialize theme toggle after HTMX swaps - document.addEventListener('htmx:afterSwap', initializeTheme); - document.addEventListener('htmx:afterSettle', initializeTheme); + } else { + // Authenticated mode + localStorage.removeItem('theme'); + + if (themePreference === 'system') { + // Ensure correct theme is set immediately + const currentSystemTheme = systemMediaQuery.matches ? 'dark' : 'light'; + // Only update if needed + if (document.documentElement.getAttribute('data-theme') !== currentSystemTheme) { + document.documentElement.setAttribute('data-theme', currentSystemTheme); + } + + if (!isSystemListenerAttached) { + systemMediaQuery.addEventListener('change', handleSystemThemeChange); + isSystemListenerAttached = true; + } + } else { + if (isSystemListenerAttached) { + systemMediaQuery.removeEventListener('change', handleSystemThemeChange); + isSystemListenerAttached = false; + } + // Ensure data-theme matches preference + if (themePreference && document.documentElement.getAttribute('data-theme') !== themePreference) { + document.documentElement.setAttribute('data-theme', themePreference); + } + } + } +}; + +// Run the initialization after the DOM is fully loaded +document.addEventListener('DOMContentLoaded', initializeTheme); + +// Reinitialize theme toggle after HTMX swaps +document.addEventListener('htmx:afterSwap', initializeTheme); +document.addEventListener('htmx:afterSettle', initializeTheme); diff --git a/html-router/src/middlewares/response_middleware.rs b/html-router/src/middlewares/response_middleware.rs index a4c2dc8..6ebcaba 100644 --- a/html-router/src/middlewares/response_middleware.rs +++ b/html-router/src/middlewares/response_middleware.rs @@ -1,6 +1,7 @@ use axum::{ - extract::State, + extract::{Request, State}, http::{HeaderName, StatusCode}, + middleware::Next, response::{Html, IntoResponse, Redirect, Response}, Extension, }; @@ -11,6 +12,8 @@ use serde::Serialize; use serde_json::json; use tracing::error; +use crate::AuthSessionType; + #[derive(Clone)] pub enum TemplateKind { Full(String), @@ -98,14 +101,40 @@ impl IntoResponse for TemplateResponse { } } +#[derive(Serialize)] +struct ContextWrapper<'a> { + user_theme: &'a str, + initial_theme: &'a str, + is_authenticated: bool, + #[serde(flatten)] + context: &'a Value, +} + pub async fn with_template_response( State(state): State, HxRequest(is_htmx): HxRequest, - response: Response, -) -> Response + req: Request, + next: Next, +) -> Response where S: ProvidesTemplateEngine + Clone + Send + Sync + 'static, { + // Determine theme context + let (user_theme, initial_theme, is_authenticated) = + if let Some(auth) = req.extensions().get::() { + if let Some(user) = &auth.current_user { + let theme = user.theme.as_str(); + let initial = if theme == "dark" { "dark" } else { "light" }; + (theme.to_string(), initial.to_string(), true) + } else { + ("system".to_string(), "light".to_string(), false) + } + } else { + ("system".to_string(), "light".to_string(), false) + }; + + let response = next.run(req).await; + // Headers to forward from the original response const HTMX_HEADERS_TO_FORWARD: &[&str] = &["HX-Push", "HX-Trigger", "HX-Redirect"]; @@ -123,9 +152,16 @@ where } } + let context = ContextWrapper { + user_theme: &user_theme, + initial_theme: &initial_theme, + is_authenticated, + context: &template_response.context, + }; + match &template_response.template_kind { TemplateKind::Full(name) => { - match template_engine.render(name, &template_response.context) { + match template_engine.render(name, &Value::from_serialize(&context)) { Ok(html) => { let mut final_response = Html(html).into_response(); forward_headers(response.headers(), final_response.headers_mut()); @@ -138,7 +174,11 @@ where } } TemplateKind::Partial(template, block) => { - match template_engine.render_block(template, block, &template_response.context) { + match template_engine.render_block( + template, + block, + &Value::from_serialize(&context), + ) { Ok(html) => { let mut final_response = Html(html).into_response(); forward_headers(response.headers(), final_response.headers_mut()); @@ -169,12 +209,15 @@ where let trigger_payload = json!({"toast": {"title": title, "description": description, "type": "error"}}); let trigger_value = serde_json::to_string(&trigger_payload).unwrap_or_else(|e| { error!("Failed to serialize HX-Trigger payload: {}", e); - r#"{"toast":{"title":"Error","description":"An unexpected error occurred.", "type":"error"}}"#.to_string() + r#"{"toast":{"title":"Error","description":"An unexpected error occurred.", "type":"error"}}"# + .to_string() }); (StatusCode::NO_CONTENT, [(HX_TRIGGER, trigger_value)], "").into_response() } else { // Non-HTMX request: Render the full errors/error.html page - match template_engine.render("errors/error.html", &template_response.context) { + match template_engine + .render("errors/error.html", &Value::from_serialize(&context)) + { Ok(html) => (*status, Html(html)).into_response(), Err(e) => { error!("Critical: Failed to render 'errors/error.html': {:?}", e); diff --git a/html-router/src/router_factory.rs b/html-router/src/router_factory.rs index a85ac32..08c932d 100644 --- a/html-router/src/router_factory.rs +++ b/html-router/src/router_factory.rs @@ -1,8 +1,4 @@ -use axum::{ - extract::FromRef, - middleware::{from_fn_with_state, map_response_with_state}, - Router, -}; +use axum::{extract::FromRef, middleware::from_fn_with_state, Router}; use axum_session::SessionLayer; use axum_session_auth::{AuthConfig, AuthSessionLayer}; use axum_session_surreal::SessionSurrealPool; @@ -181,7 +177,7 @@ where self.app_state.clone(), analytics_middleware::, )); - router = router.layer(map_response_with_state( + router = router.layer(from_fn_with_state( self.app_state.clone(), with_template_response::, )); diff --git a/html-router/src/routes/account/handlers.rs b/html-router/src/routes/account/handlers.rs index 7a246c1..d865854 100644 --- a/html-router/src/routes/account/handlers.rs +++ b/html-router/src/routes/account/handlers.rs @@ -18,6 +18,7 @@ pub struct AccountPageData { user: User, timezones: Vec, conversation_archive: Vec, + theme_options: Vec, } pub async fn show_account_page( @@ -28,6 +29,11 @@ pub async fn show_account_page( .iter() .map(std::string::ToString::to_string) .collect(); + let theme_options = vec![ + "light".to_string(), + "dark".to_string(), + "system".to_string(), + ]; let conversation_archive = User::get_user_conversations(&user.id, &state.db).await?; Ok(TemplateResponse::new_template( @@ -36,6 +42,7 @@ pub async fn show_account_page( user, timezones, conversation_archive, + theme_options, }, )) } @@ -65,6 +72,7 @@ pub async fn set_api_key( user: updated_user, timezones: vec![], conversation_archive: vec![], + theme_options: vec![], }, )) } @@ -118,6 +126,47 @@ pub async fn update_timezone( user: updated_user, timezones, conversation_archive: vec![], + theme_options: vec![], + }, + )) +} + +#[derive(Deserialize)] +pub struct UpdateThemeForm { + theme: String, +} + +pub async fn update_theme( + State(state): State, + RequireUser(user): RequireUser, + auth: AuthSessionType, + Form(form): Form, +) -> Result { + User::update_theme(&user.id, &form.theme, &state.db).await?; + + // Clear the cache + auth.cache_clear_user(user.id.to_string()); + + // Update the user's theme + let updated_user = User { + theme: form.theme, + ..user.clone() + }; + + let theme_options = vec![ + "light".to_string(), + "dark".to_string(), + "system".to_string(), + ]; + + Ok(TemplateResponse::new_partial( + "auth/account_settings.html", + "theme_section", + AccountPageData { + user: updated_user, + timezones: vec![], + conversation_archive: vec![], + theme_options, }, )) } diff --git a/html-router/src/routes/account/mod.rs b/html-router/src/routes/account/mod.rs index 14582cd..4aa399a 100644 --- a/html-router/src/routes/account/mod.rs +++ b/html-router/src/routes/account/mod.rs @@ -16,6 +16,7 @@ where .route("/account", get(handlers::show_account_page)) .route("/set-api-key", post(handlers::set_api_key)) .route("/update-timezone", patch(handlers::update_timezone)) + .route("/update-theme", patch(handlers::update_theme)) .route( "/change-password", get(handlers::show_change_password).patch(handlers::change_password), diff --git a/html-router/src/routes/auth/signup.rs b/html-router/src/routes/auth/signup.rs index 5b5f5db..b67c8a2 100644 --- a/html-router/src/routes/auth/signup.rs +++ b/html-router/src/routes/auth/signup.rs @@ -45,7 +45,15 @@ pub async fn process_signup_and_show_verification( auth: AuthSessionType, Form(form): Form, ) -> Result { - let user = match User::create_new(form.email, form.password, &state.db, form.timezone).await { + let user = match User::create_new( + form.email, + form.password, + &state.db, + form.timezone, + "system".to_string(), + ) + .await + { Ok(user) => user, Err(e) => { tracing::error!("{:?}", e); diff --git a/html-router/templates/auth/_account_settings_core.html b/html-router/templates/auth/_account_settings_core.html index 0870e41..dc159d0 100644 --- a/html-router/templates/auth/_account_settings_core.html +++ b/html-router/templates/auth/_account_settings_core.html @@ -55,6 +55,20 @@ {% endblock %} + + {% endblock %} {% block settings_right_column %} diff --git a/html-router/templates/head_base.html b/html-router/templates/head_base.html index 7b4c4ed..cc05ce3 100644 --- a/html-router/templates/head_base.html +++ b/html-router/templates/head_base.html @@ -1,5 +1,5 @@ - + diff --git a/html-router/templates/navigation_bar.html b/html-router/templates/navigation_bar.html index 363db62..b78e4b0 100644 --- a/html-router/templates/navigation_bar.html +++ b/html-router/templates/navigation_bar.html @@ -8,5 +8,7 @@ + {% if not is_authenticated %} {% include "theme_toggle.html" %} + {% endif %} {% endblock %}