mirror of
https://github.com/perstarkse/minne.git
synced 2026-04-25 10:18:38 +02: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>,
|
api_key: Option<String>,
|
||||||
admin: bool,
|
admin: bool,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
timezone: String
|
timezone: String,
|
||||||
|
#[serde(default)]
|
||||||
|
theme: String
|
||||||
});
|
});
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
@@ -70,6 +72,14 @@ fn validate_timezone(input: &str) -> String {
|
|||||||
"UTC".to_owned()
|
"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)]
|
#[derive(Serialize, Deserialize, Debug, Clone)]
|
||||||
pub struct DashboardStats {
|
pub struct DashboardStats {
|
||||||
pub total_documents: i64,
|
pub total_documents: i64,
|
||||||
@@ -168,6 +178,7 @@ impl User {
|
|||||||
password: String,
|
password: String,
|
||||||
db: &SurrealDbClient,
|
db: &SurrealDbClient,
|
||||||
timezone: String,
|
timezone: String,
|
||||||
|
theme: String,
|
||||||
) -> Result<Self, AppError> {
|
) -> Result<Self, AppError> {
|
||||||
// verify that the application allows new creations
|
// verify that the application allows new creations
|
||||||
let systemsettings = SystemSettings::get_current(db).await?;
|
let systemsettings = SystemSettings::get_current(db).await?;
|
||||||
@@ -176,6 +187,7 @@ impl User {
|
|||||||
}
|
}
|
||||||
|
|
||||||
let validated_tz = validate_timezone(&timezone);
|
let validated_tz = validate_timezone(&timezone);
|
||||||
|
let validated_theme = validate_theme(&theme);
|
||||||
let now = Utc::now();
|
let now = Utc::now();
|
||||||
let id = Uuid::new_v4().to_string();
|
let id = Uuid::new_v4().to_string();
|
||||||
|
|
||||||
@@ -190,7 +202,8 @@ impl User {
|
|||||||
anonymous = false,
|
anonymous = false,
|
||||||
created_at = $created_at,
|
created_at = $created_at,
|
||||||
updated_at = $updated_at,
|
updated_at = $updated_at,
|
||||||
timezone = $timezone",
|
timezone = $timezone,
|
||||||
|
theme = $theme",
|
||||||
)
|
)
|
||||||
.bind(("table", "user"))
|
.bind(("table", "user"))
|
||||||
.bind(("id", id))
|
.bind(("id", id))
|
||||||
@@ -199,6 +212,7 @@ impl User {
|
|||||||
.bind(("created_at", surrealdb::Datetime::from(now)))
|
.bind(("created_at", surrealdb::Datetime::from(now)))
|
||||||
.bind(("updated_at", surrealdb::Datetime::from(now)))
|
.bind(("updated_at", surrealdb::Datetime::from(now)))
|
||||||
.bind(("timezone", validated_tz))
|
.bind(("timezone", validated_tz))
|
||||||
|
.bind(("theme", validated_theme))
|
||||||
.await?
|
.await?
|
||||||
.take(1)?;
|
.take(1)?;
|
||||||
|
|
||||||
@@ -468,6 +482,19 @@ impl User {
|
|||||||
Ok(())
|
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(
|
pub async fn get_user_categories(
|
||||||
user_id: &str,
|
user_id: &str,
|
||||||
db: &SurrealDbClient,
|
db: &SurrealDbClient,
|
||||||
@@ -674,6 +701,7 @@ mod tests {
|
|||||||
password.to_string(),
|
password.to_string(),
|
||||||
&db,
|
&db,
|
||||||
timezone.to_string(),
|
timezone.to_string(),
|
||||||
|
"system".to_string(),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.expect("Failed to create user");
|
.expect("Failed to create user");
|
||||||
@@ -711,6 +739,7 @@ mod tests {
|
|||||||
password.to_string(),
|
password.to_string(),
|
||||||
&db,
|
&db,
|
||||||
"UTC".to_string(),
|
"UTC".to_string(),
|
||||||
|
"system".to_string(),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.expect("Failed to create user");
|
.expect("Failed to create user");
|
||||||
@@ -858,6 +887,7 @@ mod tests {
|
|||||||
password.to_string(),
|
password.to_string(),
|
||||||
&db,
|
&db,
|
||||||
"UTC".to_string(),
|
"UTC".to_string(),
|
||||||
|
"system".to_string(),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.expect("Failed to create user");
|
.expect("Failed to create user");
|
||||||
@@ -892,6 +922,7 @@ mod tests {
|
|||||||
password.to_string(),
|
password.to_string(),
|
||||||
&db,
|
&db,
|
||||||
"UTC".to_string(),
|
"UTC".to_string(),
|
||||||
|
"system".to_string(),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.expect("Failed to create user");
|
.expect("Failed to create user");
|
||||||
@@ -959,6 +990,7 @@ mod tests {
|
|||||||
old_password.to_string(),
|
old_password.to_string(),
|
||||||
&db,
|
&db,
|
||||||
"UTC".to_string(),
|
"UTC".to_string(),
|
||||||
|
"system".to_string(),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.expect("Failed to create user");
|
.expect("Failed to create user");
|
||||||
@@ -1006,6 +1038,7 @@ mod tests {
|
|||||||
"password".to_string(),
|
"password".to_string(),
|
||||||
&db,
|
&db,
|
||||||
"UTC".to_string(),
|
"UTC".to_string(),
|
||||||
|
"system".to_string(),
|
||||||
)
|
)
|
||||||
.await
|
.await
|
||||||
.expect("Failed to create user");
|
.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,
|
api_key: None,
|
||||||
admin: false,
|
admin: false,
|
||||||
timezone: "UTC".to_string(),
|
timezone: "UTC".to_string(),
|
||||||
|
theme: "system".to_string(),
|
||||||
};
|
};
|
||||||
|
|
||||||
if let Some(existing) = db.get_item::<User>(user.get_id()).await? {
|
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 initializeTheme = () => {
|
||||||
const themeToggle = document.querySelector('.theme-controller');
|
const themeToggle = document.querySelector('.theme-controller');
|
||||||
if (!themeToggle) {
|
const themePreference = document.documentElement.getAttribute('data-theme-preference');
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Detect system preference
|
if (themeToggle) {
|
||||||
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
// Anonymous mode
|
||||||
|
if (isSystemListenerAttached) {
|
||||||
|
systemMediaQuery.removeEventListener('change', handleSystemThemeChange);
|
||||||
|
isSystemListenerAttached = false;
|
||||||
|
}
|
||||||
|
|
||||||
// Initialize theme from local storage or system preference
|
// Avoid re-binding if already bound
|
||||||
const savedTheme = localStorage.getItem('theme');
|
if (themeToggle.dataset.bound) return;
|
||||||
const initialTheme = savedTheme ? savedTheme : (prefersDark ? 'dark' : 'light');
|
themeToggle.dataset.bound = "true";
|
||||||
document.documentElement.setAttribute('data-theme', initialTheme);
|
|
||||||
themeToggle.checked = initialTheme === 'dark';
|
|
||||||
|
|
||||||
// Update theme and local storage on toggle
|
// Detect system preference
|
||||||
themeToggle.addEventListener('change', () => {
|
const prefersDark = systemMediaQuery.matches;
|
||||||
const theme = themeToggle.checked ? 'dark' : 'light';
|
|
||||||
document.documentElement.setAttribute('data-theme', theme);
|
|
||||||
localStorage.setItem('theme', theme);
|
|
||||||
});
|
|
||||||
|
|
||||||
};
|
// 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
|
// Update theme and local storage on toggle
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
themeToggle.addEventListener('change', () => {
|
||||||
initializeTheme();
|
const theme = themeToggle.checked ? 'dark' : 'light';
|
||||||
});
|
document.documentElement.setAttribute('data-theme', theme);
|
||||||
|
localStorage.setItem('theme', theme);
|
||||||
|
});
|
||||||
|
|
||||||
// Reinitialize theme toggle after HTMX swaps
|
} else {
|
||||||
document.addEventListener('htmx:afterSwap', initializeTheme);
|
// Authenticated mode
|
||||||
document.addEventListener('htmx:afterSettle', initializeTheme);
|
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::{
|
use axum::{
|
||||||
extract::State,
|
extract::{Request, State},
|
||||||
http::{HeaderName, StatusCode},
|
http::{HeaderName, StatusCode},
|
||||||
|
middleware::Next,
|
||||||
response::{Html, IntoResponse, Redirect, Response},
|
response::{Html, IntoResponse, Redirect, Response},
|
||||||
Extension,
|
Extension,
|
||||||
};
|
};
|
||||||
@@ -11,6 +12,8 @@ use serde::Serialize;
|
|||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use tracing::error;
|
use tracing::error;
|
||||||
|
|
||||||
|
use crate::AuthSessionType;
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
pub enum TemplateKind {
|
pub enum TemplateKind {
|
||||||
Full(String),
|
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>(
|
pub async fn with_template_response<S>(
|
||||||
State(state): State<S>,
|
State(state): State<S>,
|
||||||
HxRequest(is_htmx): HxRequest,
|
HxRequest(is_htmx): HxRequest,
|
||||||
response: Response<axum::body::Body>,
|
req: Request,
|
||||||
) -> Response<axum::body::Body>
|
next: Next,
|
||||||
|
) -> Response
|
||||||
where
|
where
|
||||||
S: ProvidesTemplateEngine + Clone + Send + Sync + 'static,
|
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
|
// Headers to forward from the original response
|
||||||
const HTMX_HEADERS_TO_FORWARD: &[&str] = &["HX-Push", "HX-Trigger", "HX-Redirect"];
|
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 {
|
match &template_response.template_kind {
|
||||||
TemplateKind::Full(name) => {
|
TemplateKind::Full(name) => {
|
||||||
match template_engine.render(name, &template_response.context) {
|
match template_engine.render(name, &Value::from_serialize(&context)) {
|
||||||
Ok(html) => {
|
Ok(html) => {
|
||||||
let mut final_response = Html(html).into_response();
|
let mut final_response = Html(html).into_response();
|
||||||
forward_headers(response.headers(), final_response.headers_mut());
|
forward_headers(response.headers(), final_response.headers_mut());
|
||||||
@@ -138,7 +174,11 @@ where
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
TemplateKind::Partial(template, block) => {
|
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) => {
|
Ok(html) => {
|
||||||
let mut final_response = Html(html).into_response();
|
let mut final_response = Html(html).into_response();
|
||||||
forward_headers(response.headers(), final_response.headers_mut());
|
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_payload = json!({"toast": {"title": title, "description": description, "type": "error"}});
|
||||||
let trigger_value = serde_json::to_string(&trigger_payload).unwrap_or_else(|e| {
|
let trigger_value = serde_json::to_string(&trigger_payload).unwrap_or_else(|e| {
|
||||||
error!("Failed to serialize HX-Trigger payload: {}", 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()
|
(StatusCode::NO_CONTENT, [(HX_TRIGGER, trigger_value)], "").into_response()
|
||||||
} else {
|
} else {
|
||||||
// Non-HTMX request: Render the full errors/error.html page
|
// 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(),
|
Ok(html) => (*status, Html(html)).into_response(),
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
error!("Critical: Failed to render 'errors/error.html': {:?}", e);
|
error!("Critical: Failed to render 'errors/error.html': {:?}", e);
|
||||||
|
|||||||
@@ -1,8 +1,4 @@
|
|||||||
use axum::{
|
use axum::{extract::FromRef, middleware::from_fn_with_state, Router};
|
||||||
extract::FromRef,
|
|
||||||
middleware::{from_fn_with_state, map_response_with_state},
|
|
||||||
Router,
|
|
||||||
};
|
|
||||||
use axum_session::SessionLayer;
|
use axum_session::SessionLayer;
|
||||||
use axum_session_auth::{AuthConfig, AuthSessionLayer};
|
use axum_session_auth::{AuthConfig, AuthSessionLayer};
|
||||||
use axum_session_surreal::SessionSurrealPool;
|
use axum_session_surreal::SessionSurrealPool;
|
||||||
@@ -181,7 +177,7 @@ where
|
|||||||
self.app_state.clone(),
|
self.app_state.clone(),
|
||||||
analytics_middleware::<HtmlState>,
|
analytics_middleware::<HtmlState>,
|
||||||
));
|
));
|
||||||
router = router.layer(map_response_with_state(
|
router = router.layer(from_fn_with_state(
|
||||||
self.app_state.clone(),
|
self.app_state.clone(),
|
||||||
with_template_response::<HtmlState>,
|
with_template_response::<HtmlState>,
|
||||||
));
|
));
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ pub struct AccountPageData {
|
|||||||
user: User,
|
user: User,
|
||||||
timezones: Vec<String>,
|
timezones: Vec<String>,
|
||||||
conversation_archive: Vec<Conversation>,
|
conversation_archive: Vec<Conversation>,
|
||||||
|
theme_options: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn show_account_page(
|
pub async fn show_account_page(
|
||||||
@@ -28,6 +29,11 @@ pub async fn show_account_page(
|
|||||||
.iter()
|
.iter()
|
||||||
.map(std::string::ToString::to_string)
|
.map(std::string::ToString::to_string)
|
||||||
.collect();
|
.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?;
|
let conversation_archive = User::get_user_conversations(&user.id, &state.db).await?;
|
||||||
|
|
||||||
Ok(TemplateResponse::new_template(
|
Ok(TemplateResponse::new_template(
|
||||||
@@ -36,6 +42,7 @@ pub async fn show_account_page(
|
|||||||
user,
|
user,
|
||||||
timezones,
|
timezones,
|
||||||
conversation_archive,
|
conversation_archive,
|
||||||
|
theme_options,
|
||||||
},
|
},
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
@@ -65,6 +72,7 @@ pub async fn set_api_key(
|
|||||||
user: updated_user,
|
user: updated_user,
|
||||||
timezones: vec![],
|
timezones: vec![],
|
||||||
conversation_archive: vec![],
|
conversation_archive: vec![],
|
||||||
|
theme_options: vec![],
|
||||||
},
|
},
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
@@ -118,6 +126,47 @@ pub async fn update_timezone(
|
|||||||
user: updated_user,
|
user: updated_user,
|
||||||
timezones,
|
timezones,
|
||||||
conversation_archive: vec![],
|
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("/account", get(handlers::show_account_page))
|
||||||
.route("/set-api-key", post(handlers::set_api_key))
|
.route("/set-api-key", post(handlers::set_api_key))
|
||||||
.route("/update-timezone", patch(handlers::update_timezone))
|
.route("/update-timezone", patch(handlers::update_timezone))
|
||||||
|
.route("/update-theme", patch(handlers::update_theme))
|
||||||
.route(
|
.route(
|
||||||
"/change-password",
|
"/change-password",
|
||||||
get(handlers::show_change_password).patch(handlers::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,
|
auth: AuthSessionType,
|
||||||
Form(form): Form<SignupParams>,
|
Form(form): Form<SignupParams>,
|
||||||
) -> Result<impl IntoResponse, HtmlError> {
|
) -> 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,
|
Ok(user) => user,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::error!("{:?}", e);
|
tracing::error!("{:?}", e);
|
||||||
|
|||||||
@@ -55,6 +55,20 @@
|
|||||||
</select>
|
</select>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
</label>
|
</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 %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block settings_right_column %}
|
{% block settings_right_column %}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<!DOCTYPE html>
|
<!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>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
|
|||||||
@@ -8,5 +8,7 @@
|
|||||||
<label for="my-drawer" aria-label="open sidebar" class="hover:cursor-pointer lg:hidden">
|
<label for="my-drawer" aria-label="open sidebar" class="hover:cursor-pointer lg:hidden">
|
||||||
{% include "icons/hamburger_icon.html" %}
|
{% include "icons/hamburger_icon.html" %}
|
||||||
</label>
|
</label>
|
||||||
|
{% if not is_authenticated %}
|
||||||
{% include "theme_toggle.html" %}
|
{% include "theme_toggle.html" %}
|
||||||
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
Reference in New Issue
Block a user