tirbofish/kitgit · commit
dbfb44e6710bccfc98906cb97f596756091e91a5
merge: feat/theme-toggle into integrate/all-features
Type: SSH
SSH Key Fingerprint:
Verified
SgQHY4vUORbJC3ZtdixCl62ek/4QGh/iG2FRSyjfGPc
@@ -0,0 +1,10 @@ +-- Per-user appearance preference: light | dark | system + +ALTER TABLE users + ADD COLUMN IF NOT EXISTS theme TEXT NOT NULL DEFAULT 'system'; + +ALTER TABLE users + DROP CONSTRAINT IF EXISTS users_theme_check; + +ALTER TABLE users + ADD CONSTRAINT users_theme_check CHECK (theme IN ('light', 'dark', 'system')); @@ -17,10 +17,21 @@ pub struct User { pub is_suspended: bool, pub show_email: bool, pub vigilant_mode: bool, + /// `light`, `dark`, or `system` (follows prefers-color-scheme). + pub theme: String, pub created_at: DateTime<Utc>, pub updated_at: DateTime<Utc>, } +impl User { + pub fn theme_pref(&self) -> &str { + match self.theme.as_str() { + "light" | "dark" => self.theme.as_str(), + _ => "system", + } + } +} + #[derive(Debug, Clone, FromRow)] pub struct UserMfa { pub user_id: Uuid, @@ -2003,6 +2003,19 @@ pub async fn update_privacy( .await?) } +pub async fn update_user_theme(pool: &PgPool, id: Uuid, theme: &str) -> Result<User> { + Ok(sqlx::query_as::<_, User>( + r#" + UPDATE users SET theme = $2, updated_at = now() + WHERE id = $1 RETURNING * + "#, + ) + .bind(id) + .bind(theme) + .fetch_one(pool) + .await?) +} + pub async fn list_user_emails(pool: &PgPool, user_id: Uuid) -> Result<Vec<UserEmail>> { Ok(sqlx::query_as::<_, UserEmail>( "SELECT * FROM user_emails WHERE user_id = $1 ORDER BY is_primary DESC, email", @@ -2281,6 +2294,7 @@ pub async fn export_user_data(pool: &PgPool, user_id: Uuid) -> Result<serde_json "bio": user.bio, "show_email": user.show_email, "vigilant_mode": user.vigilant_mode, + "theme": user.theme, "created_at": user.created_at, }, "emails": emails, @@ -112,6 +112,10 @@ pub fn app_router(state: AppState) -> Router { post(routes_extra::account_privacy), ) .route( + "/settings/account/theme", + post(routes_extra::account_theme), + ) + .route( "/settings/account/emails", post(routes_extra::account_add_email), ) @@ -717,6 +717,25 @@ pub async fn account_privacy( } #[derive(Deserialize)] +pub struct ThemeForm { + pub theme: String, +} + +pub async fn account_theme( + State(state): State<AppState>, + headers: HeaderMap, + Form(form): Form<ThemeForm>, +) -> AppResult<Response> { + let user = require_login(&state.auth, &headers).await?; + let theme = match form.theme.trim() { + "light" | "dark" | "system" => form.theme.trim(), + _ => return Err(AppError::bad("theme must be light, dark, or system")), + }; + queries::update_user_theme(&state.pool, user.id, theme).await?; + Ok(redirect_see_other("/settings/account")) +} + +#[derive(Deserialize)] pub struct EmailForm { pub email: String, } @@ -1,7 +1,8 @@ /* kitgit — brand theme ink on paper. no chrome. */ -:root { +:root, +[data-theme="light"] { --kg-bg: #ffffff; --kg-bg-raised: #f7f7f7; --kg-fg: #0a0a0a; @@ -34,6 +35,7 @@ color-scheme: light; } +[data-theme="dark"], html.kg-dark { --kg-bg: #0a0a0a; --kg-bg-raised: #141414; @@ -43,6 +45,7 @@ html.kg-dark { --kg-line: #262626; --kg-line-strong: #f5f5f5; --kg-link: #f5f5f5; + --kg-focus: #f5f5f5; --kg-danger: #ff4d5a; --kg-danger-bg: #2a1214; --kg-invert-bg: #f5f5f5; @@ -51,6 +54,26 @@ html.kg-dark { color-scheme: dark; } +@media (prefers-color-scheme: dark) { + [data-theme="system"] { + --kg-bg: #0a0a0a; + --kg-bg-raised: #141414; + --kg-fg: #f5f5f5; + --kg-muted: #8a8a8a; + --kg-faint: #5c5c5c; + --kg-line: #262626; + --kg-line-strong: #f5f5f5; + --kg-link: #f5f5f5; + --kg-focus: #f5f5f5; + --kg-danger: #ff4d5a; + --kg-danger-bg: #2a1214; + --kg-invert-bg: #f5f5f5; + --kg-invert-fg: #0a0a0a; + --kg-invert-muted: #525252; + color-scheme: dark; + } +} + *, *::before, *::after { @@ -1316,35 +1339,67 @@ a:hover { .kg-codeview .entity.other.attribute-name { color: #8a4b08; } .kg-codeview .punctuation { color: #3d3d3d; } -html.kg-dark .kg-codeview .comment, -html.kg-dark .kg-codeview .punctuation.definition.comment { color: #8a8a8a; } -html.kg-dark .kg-codeview .string, -html.kg-dark .kg-codeview .string.quoted, -html.kg-dark .kg-codeview .constant.character.escape { color: #5ec49a; } -html.kg-dark .kg-codeview .constant.numeric, -html.kg-dark .kg-codeview .constant.language, -html.kg-dark .kg-codeview .constant.character { color: #e0a35a; } -html.kg-dark .kg-codeview .keyword, -html.kg-dark .kg-codeview .keyword.control, -html.kg-dark .kg-codeview .keyword.operator, -html.kg-dark .kg-codeview .storage, -html.kg-dark .kg-codeview .storage.type, -html.kg-dark .kg-codeview .storage.modifier { color: #f5f5f5; font-weight: 700; } -html.kg-dark .kg-codeview .entity.name.function, -html.kg-dark .kg-codeview .support.function { color: #7eb0f0; } -html.kg-dark .kg-codeview .entity.name.class, -html.kg-dark .kg-codeview .entity.name.struct, -html.kg-dark .kg-codeview .entity.name.type, -html.kg-dark .kg-codeview .entity.name.namespace, -html.kg-dark .kg-codeview .support.type, -html.kg-dark .kg-codeview .support.class { color: #c9a0ff; } -html.kg-dark .kg-codeview .variable, -html.kg-dark .kg-codeview .variable.parameter, -html.kg-dark .kg-codeview .variable.other { color: #f5f5f5; } -html.kg-dark .kg-codeview .meta.annotation, -html.kg-dark .kg-codeview .entity.name.tag { color: #ff8a8a; } -html.kg-dark .kg-codeview .entity.other.attribute-name { color: #e0a35a; } -html.kg-dark .kg-codeview .punctuation { color: #b0b0b0; } +:is([data-theme="dark"], html.kg-dark) .kg-codeview .comment, +:is([data-theme="dark"], html.kg-dark) .kg-codeview .punctuation.definition.comment { color: #8a8a8a; } +:is([data-theme="dark"], html.kg-dark) .kg-codeview .string, +:is([data-theme="dark"], html.kg-dark) .kg-codeview .string.quoted, +:is([data-theme="dark"], html.kg-dark) .kg-codeview .constant.character.escape { color: #5ec49a; } +:is([data-theme="dark"], html.kg-dark) .kg-codeview .constant.numeric, +:is([data-theme="dark"], html.kg-dark) .kg-codeview .constant.language, +:is([data-theme="dark"], html.kg-dark) .kg-codeview .constant.character { color: #e0a35a; } +:is([data-theme="dark"], html.kg-dark) .kg-codeview .keyword, +:is([data-theme="dark"], html.kg-dark) .kg-codeview .keyword.control, +:is([data-theme="dark"], html.kg-dark) .kg-codeview .keyword.operator, +:is([data-theme="dark"], html.kg-dark) .kg-codeview .storage, +:is([data-theme="dark"], html.kg-dark) .kg-codeview .storage.type, +:is([data-theme="dark"], html.kg-dark) .kg-codeview .storage.modifier { color: #f5f5f5; font-weight: 700; } +:is([data-theme="dark"], html.kg-dark) .kg-codeview .entity.name.function, +:is([data-theme="dark"], html.kg-dark) .kg-codeview .support.function { color: #7eb0f0; } +:is([data-theme="dark"], html.kg-dark) .kg-codeview .entity.name.class, +:is([data-theme="dark"], html.kg-dark) .kg-codeview .entity.name.struct, +:is([data-theme="dark"], html.kg-dark) .kg-codeview .entity.name.type, +:is([data-theme="dark"], html.kg-dark) .kg-codeview .entity.name.namespace, +:is([data-theme="dark"], html.kg-dark) .kg-codeview .support.type, +:is([data-theme="dark"], html.kg-dark) .kg-codeview .support.class { color: #c9a0ff; } +:is([data-theme="dark"], html.kg-dark) .kg-codeview .variable, +:is([data-theme="dark"], html.kg-dark) .kg-codeview .variable.parameter, +:is([data-theme="dark"], html.kg-dark) .kg-codeview .variable.other { color: #f5f5f5; } +:is([data-theme="dark"], html.kg-dark) .kg-codeview .meta.annotation, +:is([data-theme="dark"], html.kg-dark) .kg-codeview .entity.name.tag { color: #ff8a8a; } +:is([data-theme="dark"], html.kg-dark) .kg-codeview .entity.other.attribute-name { color: #e0a35a; } +:is([data-theme="dark"], html.kg-dark) .kg-codeview .punctuation { color: #b0b0b0; } + +@media (prefers-color-scheme: dark) { + [data-theme="system"] .kg-codeview .comment, + [data-theme="system"] .kg-codeview .punctuation.definition.comment { color: #8a8a8a; } + [data-theme="system"] .kg-codeview .string, + [data-theme="system"] .kg-codeview .string.quoted, + [data-theme="system"] .kg-codeview .constant.character.escape { color: #5ec49a; } + [data-theme="system"] .kg-codeview .constant.numeric, + [data-theme="system"] .kg-codeview .constant.language, + [data-theme="system"] .kg-codeview .constant.character { color: #e0a35a; } + [data-theme="system"] .kg-codeview .keyword, + [data-theme="system"] .kg-codeview .keyword.control, + [data-theme="system"] .kg-codeview .keyword.operator, + [data-theme="system"] .kg-codeview .storage, + [data-theme="system"] .kg-codeview .storage.type, + [data-theme="system"] .kg-codeview .storage.modifier { color: #f5f5f5; font-weight: 700; } + [data-theme="system"] .kg-codeview .entity.name.function, + [data-theme="system"] .kg-codeview .support.function { color: #7eb0f0; } + [data-theme="system"] .kg-codeview .entity.name.class, + [data-theme="system"] .kg-codeview .entity.name.struct, + [data-theme="system"] .kg-codeview .entity.name.type, + [data-theme="system"] .kg-codeview .entity.name.namespace, + [data-theme="system"] .kg-codeview .support.type, + [data-theme="system"] .kg-codeview .support.class { color: #c9a0ff; } + [data-theme="system"] .kg-codeview .variable, + [data-theme="system"] .kg-codeview .variable.parameter, + [data-theme="system"] .kg-codeview .variable.other { color: #f5f5f5; } + [data-theme="system"] .kg-codeview .meta.annotation, + [data-theme="system"] .kg-codeview .entity.name.tag { color: #ff8a8a; } + [data-theme="system"] .kg-codeview .entity.other.attribute-name { color: #e0a35a; } + [data-theme="system"] .kg-codeview .punctuation { color: #b0b0b0; } +} .kg-readme { border: 1px solid var(--kg-line); @@ -20,6 +20,28 @@ {% endif %} <div class="kg-settings-section" style="margin-top:1rem;border-top:0;padding-top:0"> + <span class="kg-kicker">appearance</span> + <p class="kg-muted">Ink on paper by day, charcoal by night — or follow your system.</p> + <form class="kg-form" method="post" action="/settings/account/theme"> + <label class="row"> + <input type="radio" name="theme" value="light"{% if user.theme == "light" %} checked{% endif %} /> + light + </label> + <label class="row"> + <input type="radio" name="theme" value="dark"{% if user.theme == "dark" %} checked{% endif %} /> + dark + </label> + <label class="row"> + <input type="radio" name="theme" value="system"{% if user.theme != "light" && user.theme != "dark" %} checked{% endif %} /> + system + </label> + <div class="kg-actions"> + <button class="kg-btn" type="submit">save theme</button> + </div> + </form> + </div> + + <div class="kg-settings-section"> <span class="kg-kicker">username</span> <form class="kg-form" method="post" action="/settings/account/username"> <label> @@ -1,5 +1,5 @@ <!DOCTYPE html> -<html lang="en"> +<html lang="en" data-theme="{% if let Some(u) = viewer %}{{ u.theme_pref() }}{% else %}system{% endif %}"> <head> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1" />