feat: add user theme preference

- Add theme field to User model (common)
- Create migration for theme field
- Add theme selection to Account Settings (html-router)
- Implement server-side theme rendering in base template
- Update JS for system/preference theme handling
- Remove header theme toggle for authenticated users
This commit is contained in:
Per Stark
2026-01-16 13:54:07 +01:00
parent 0df2b9810c
commit b25cfb4633
12 changed files with 282 additions and 42 deletions

View File

@@ -0,0 +1 @@
DEFINE FIELD IF NOT EXISTS theme ON user TYPE string DEFAULT "system";

View File

@@ -34,7 +34,9 @@ stored_object!(
api_key: Option<String>,
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<Self, AppError> {
// 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>(&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>(&user.id)
.await
.expect("get user")
.unwrap();
assert_eq!(updated2.theme, "system");
}
}

View File

@@ -212,6 +212,7 @@ pub(crate) async fn ensure_eval_user(db: &SurrealDbClient) -> Result<User> {
api_key: None,
admin: false,
timezone: "UTC".to_string(),
theme: "system".to_string(),
};
if let Some(existing) = db.get_item::<User>(user.get_id()).await? {

View File

@@ -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);

View File

@@ -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<S>(
State(state): State<S>,
HxRequest(is_htmx): HxRequest,
response: Response<axum::body::Body>,
) -> Response<axum::body::Body>
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::<AuthSessionType>() {
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);

View File

@@ -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::<HtmlState>,
));
router = router.layer(map_response_with_state(
router = router.layer(from_fn_with_state(
self.app_state.clone(),
with_template_response::<HtmlState>,
));

View File

@@ -18,6 +18,7 @@ pub struct AccountPageData {
user: User,
timezones: Vec<String>,
conversation_archive: Vec<Conversation>,
theme_options: Vec<String>,
}
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<HtmlState>,
RequireUser(user): RequireUser,
auth: AuthSessionType,
Form(form): Form<UpdateThemeForm>,
) -> Result<impl IntoResponse, HtmlError> {
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,
},
))
}

View File

@@ -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),

View File

@@ -45,7 +45,15 @@ pub async fn process_signup_and_show_verification(
auth: AuthSessionType,
Form(form): Form<SignupParams>,
) -> Result<impl IntoResponse, HtmlError> {
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);

View File

@@ -55,6 +55,20 @@
</select>
{% endblock %}
</label>
<label class="w-full">
<div class="text-xs uppercase tracking-wide opacity-70 mb-1">Theme</div>
{% block theme_section %}
<select name="theme" class="nb-select w-full" hx-patch="/update-theme" hx-swap="outerHTML">
{% for option in theme_options %}
<option value="{{ option }}" {% if option==user.theme %}selected{% endif %}>{{ option }}</option>
{% endfor %}
</select>
<script>
document.documentElement.setAttribute('data-theme-preference', '{{ user.theme }}');
</script>
{% endblock %}
</label>
{% endblock %}
{% block settings_right_column %}

View File

@@ -1,5 +1,5 @@
<!DOCTYPE html>
<html lang="en" data-theme="light">
<html lang="en" data-theme="{{ initial_theme|default('light') }}" data-theme-preference="{{ user_theme|default('system') }}">
<head>
<meta charset="UTF-8">

View File

@@ -8,5 +8,7 @@
<label for="my-drawer" aria-label="open sidebar" class="hover:cursor-pointer lg:hidden">
{% include "icons/hamburger_icon.html" %}
</label>
{% if not is_authenticated %}
{% include "theme_toggle.html" %}
{% endif %}
{% endblock %}