mirror of
https://github.com/perstarkse/minne.git
synced 2026-03-23 01:50:01 +01:00
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:
@@ -0,0 +1 @@
|
||||
DEFINE FIELD IF NOT EXISTS theme ON user TYPE string DEFAULT "system";
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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? {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>,
|
||||
));
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
Reference in New Issue
Block a user