This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

appview: add theme drop-down persisted in database

Fixes #364

Allows users to manually change theme, useful for cases like
LibreWolf where it defaults to light-mode irrespective of system
settings (with resistFingerprinting enabled).

Stores theme as 'light', 'dark', or 'auto' in the database, and
dynamically adds/removes the 'dark' class from the HTML.

Signed-off-by: TheBananaPancake <did:plc:3ywraszv5eqhnlvzumwju5fa>

author
Banana Pancake
committer
oppiliappan
date (Jul 23, 2026, 3:15 PM +0100) commit 4f12da98 parent 5b61f763 change-id kzxssvwv
+132 -29
+14
appview/db/db.go
··· 2445 2445 return err 2446 2446 }) 2447 2447 2448 + orm.RunMigration(conn, logger, "add-theme-preferences", func(tx *sql.Tx) error { 2449 + _, err := tx.Exec(` 2450 + create table if not exists theme_preferences ( 2451 + id integer primary key autoincrement, 2452 + user_did text not null unique, 2453 + theme text not null check (theme in ('auto', 'light', 'dark')), 2454 + created_at text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')), 2455 + updated_at text not null default (strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) 2456 + ); 2457 + create index if not exists idx_theme_preferences_user_did on theme_preferences(user_did); 2458 + `) 2459 + return err 2460 + }) 2461 + 2448 2462 orm.RunMigration(conn, logger, "drop-label-ops-indexed", func(tx *sql.Tx) error { 2449 2463 _, err := tx.Exec(`alter table label_ops drop column indexed`) 2450 2464 return err
+32
appview/db/theme.go
··· 1 + package db 2 + 3 + import ( 4 + "database/sql" 5 + 6 + "tangled.org/core/appview/models" 7 + ) 8 + 9 + func GetThemePreference(e Execer, did string) (models.ThemePreference, error) { 10 + var theme string 11 + err := e.QueryRow( 12 + `select theme from theme_preferences where user_did = ?`, 13 + did, 14 + ).Scan(&theme) 15 + if err == sql.ErrNoRows { 16 + return models.ThemePreference(models.ThemeAuto), nil 17 + } 18 + if err != nil { 19 + return "", err 20 + } 21 + return models.ThemePreference(theme), nil 22 + } 23 + 24 + func UpsertThemePreference(e Execer, did string, theme models.ThemePreference) error { 25 + _, err := e.Exec( 26 + `insert into theme_preferences (user_did, theme, updated_at) values (?, ?, strftime('%Y-%m-%dT%H:%M:%SZ', 'now')) 27 + on conflict(user_did) do update set theme = excluded.theme, updated_at = excluded.updated_at`, 28 + did, 29 + string(theme), 30 + ) 31 + return err 32 + }
+6
appview/middleware/middleware.go
··· 103 103 LoggedInUser: user, 104 104 } 105 105 if user != nil { 106 + theme, err := db.GetThemePreference(m.db, user.Did) 107 + if err != nil { 108 + slog.Default().Warn("failed to get theme preference", "err", err) 109 + } else { 110 + bp.ThemePreference = theme 111 + } 106 112 if focusing, _ := db.GetFocusStatus(m.db, user.Did); focusing { 107 113 if item, _ := db.GetNextFocusItem(m.db, user.Did); item != nil { 108 114 count, _ := db.CountFocusNotifs(m.db, user.Did)
+5
appview/models/theme.go
··· 1 + package models 2 + 3 + type ThemePreference string 4 + 5 + const ThemeAuto ThemePreference = "auto"
+9 -7
appview/pages/pages.go
··· 43 43 type baseParamsCtxKey struct{} 44 44 45 45 type BaseParams struct { 46 - LoggedInUser *oauth.MultiAccountUser 47 - FocusParams FocusParams 46 + LoggedInUser *oauth.MultiAccountUser 47 + FocusParams FocusParams 48 + ThemePreference models.ThemePreference 48 49 } 49 50 50 51 type FocusParams struct { ··· 351 352 } 352 353 353 354 type SignupParams struct { 355 + BaseParams 354 356 CloudflareSiteKey string 355 357 EmailId string 356 358 } ··· 360 362 } 361 363 362 364 func (p *Pages) CompleteSignup(w io.Writer) error { 363 - return p.executeLogin("user/completeSignup", w, nil) 365 + return p.executeLogin("user/completeSignup", w, BaseParams{}) 364 366 } 365 367 366 368 type SignupSuccessParams struct { ··· 2105 2107 } 2106 2108 2107 2109 func (p *Pages) Error500(w io.Writer) error { 2108 - return p.execute("errors/500", w, nil) 2110 + return p.execute("errors/500", w, BaseParams{}) 2109 2111 } 2110 2112 2111 2113 func (p *Pages) Error404(w io.Writer) error { 2112 - return p.execute("errors/404", w, nil) 2114 + return p.execute("errors/404", w, BaseParams{}) 2113 2115 } 2114 2116 2115 2117 func (p *Pages) ErrorKnot404(w io.Writer) error { 2116 - return p.execute("errors/knot404", w, nil) 2118 + return p.execute("errors/knot404", w, BaseParams{}) 2117 2119 } 2118 2120 2119 2121 func (p *Pages) Error503(w io.Writer) error { 2120 - return p.execute("errors/503", w, nil) 2122 + return p.execute("errors/503", w, BaseParams{}) 2121 2123 }
+5 -1
appview/pages/templates/layouts/base.html
··· 1 1 {{ define "layouts/base" }} 2 2 <!doctype html> 3 - <html lang="en" class="dark:bg-gray-900"> 3 + <html lang="en"{{ if or (eq .ThemePreference "dark") (eq .ThemePreference "light") }} data-theme="{{ .ThemePreference }}"{{ end }}> 4 4 <head> 5 5 <meta charset="UTF-8" /> 6 6 <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover"/> ··· 40 40 <!-- preload main font --> 41 41 <link rel="preload" href="/static/fonts/InterVariable.woff2" as="font" type="font/woff2" crossorigin /> 42 42 43 + <script> 44 + if (!document.documentElement.dataset.theme && matchMedia('(prefers-color-scheme: dark)').matches) 45 + document.documentElement.dataset.theme = 'dark'; 46 + </script> 43 47 <link rel="stylesheet" href="/static/tw.css?{{ cssContentHash }}" type="text/css" /> 44 48 45 49 <script>
+17
appview/pages/templates/user/settings/profile.html
··· 11 11 </div> 12 12 <div class="col-span-1 md:col-span-3 flex flex-col gap-6"> 13 13 {{ template "profile" . }} 14 + {{ template "theme" . }} 14 15 {{ if .IsTnglSh }} 15 16 {{ template "accountActions" . }} 16 17 {{ end }} ··· 128 129 document.getElementById('change-handle-modal').showPopover(); 129 130 </script> 130 131 {{ end }} 132 + </div> 133 + {{ end }} 134 + 135 + {{ define "theme" }} 136 + <div> 137 + <h2 class="text-lg font-medium">Theme</h2> 138 + <p class="text-gray-500 dark:text-gray-400 pb-2"> 139 + Customize how the site looks. 140 + </p> 141 + <form hx-post="/profile/theme" hx-trigger="change" hx-swap="none" class="flex flex-col gap-3"> 142 + <select name="theme" id="theme-select"> 143 + <option value="auto" {{ if eq $.ThemePreference "auto" }}selected{{ end }}>Auto (System)</option> 144 + <option value="light" {{ if eq $.ThemePreference "light" }}selected{{ end }}>Light</option> 145 + <option value="dark" {{ if eq $.ThemePreference "dark" }}selected{{ end }}>Dark</option> 146 + </select> 147 + </form> 131 148 </div> 132 149 {{ end }} 133 150
+1
appview/signup/signup.go
··· 120 120 case http.MethodGet: 121 121 emailId := r.URL.Query().Get("id") 122 122 s.pages.Signup(w, pages.SignupParams{ 123 + BaseParams: pages.BaseParamsFromContext(r.Context()), 123 124 CloudflareSiteKey: s.config.Cloudflare.Turnstile.SiteKey, 124 125 EmailId: emailId, 125 126 })
+1
appview/state/login.go
··· 24 24 25 25 registry := s.oauth.GetAccounts(r) 26 26 s.pages.Login(w, pages.LoginParams{ 27 + BaseParams: pages.BaseParamsFromContext(r.Context()), 27 28 ReturnUrl: returnURL, 28 29 ErrorCode: errorCode, 29 30 AddAccount: addAccount,
+24
appview/state/profile.go
··· 1337 1337 1338 1338 s.pages.HxRefresh(w) 1339 1339 } 1340 + 1341 + func (s *State) UpdateProfileThemeSetting(w http.ResponseWriter, r *http.Request) { 1342 + l := s.logger.With("handler", "UpdateProfileThemeSetting") 1343 + err := r.ParseForm() 1344 + if err != nil { 1345 + l.Error("invalid profile update form", "err", err) 1346 + return 1347 + } 1348 + user := s.oauth.GetMultiAccountUser(r) 1349 + 1350 + theme := r.Form.Get("theme") 1351 + if theme != "auto" && theme != "light" && theme != "dark" { 1352 + l.Error("invalid theme value", "theme", theme) 1353 + return 1354 + } 1355 + 1356 + err = db.UpsertThemePreference(s.db, user.Did, models.ThemePreference(theme)) 1357 + if err != nil { 1358 + l.Error("failed to update theme preferences", "err", err) 1359 + return 1360 + } 1361 + 1362 + s.pages.HxRefresh(w) 1363 + }
+1
appview/state/router.go
··· 271 271 r.Post("/avatar", s.UploadProfileAvatar) 272 272 r.Delete("/avatar", s.RemoveProfileAvatar) 273 273 r.Post("/punchcard", s.UpdateProfilePunchcardSetting) 274 + r.Post("/theme", s.UpdateProfileThemeSetting) 274 275 }) 275 276 276 277 r.With(middleware.AuthMiddleware(s.oauth)).Route("/welcome", func(r chi.Router) {
+16 -20
input.css
··· 142 142 input[type="checkbox"]:disabled:indeterminate { 143 143 background-image: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12' fill='none'><path d='M2.5 6h7' stroke='rgb(156,163,175)' stroke-width='1.5' stroke-linecap='round'/></svg>"); 144 144 } 145 - @media (prefers-color-scheme: dark) { 146 - input[type="checkbox"]:checked { 147 - background-image: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12' fill='none'><path d='M10 3 4.5 8.5 2 6' stroke='rgb(17,24,39)' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/></svg>"); 148 - } 149 - input[type="checkbox"]:indeterminate { 150 - background-image: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12' fill='none'><path d='M2.5 6h7' stroke='rgb(17,24,39)' stroke-width='1.5' stroke-linecap='round'/></svg>"); 151 - } 152 - input[type="checkbox"]:disabled:indeterminate { 153 - background-image: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12' fill='none'><path d='M2.5 6h7' stroke='rgb(107,114,128)' stroke-width='1.5' stroke-linecap='round'/></svg>"); 154 - } 145 + [data-theme="dark"] input[type="checkbox"]:checked { 146 + background-image: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12' fill='none'><path d='M10 3 4.5 8.5 2 6' stroke='rgb(17,24,39)' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/></svg>"); 147 + } 148 + [data-theme="dark"] input[type="checkbox"]:indeterminate { 149 + background-image: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12' fill='none'><path d='M2.5 6h7' stroke='rgb(17,24,39)' stroke-width='1.5' stroke-linecap='round'/></svg>"); 150 + } 151 + [data-theme="dark"] input[type="checkbox"]:disabled:indeterminate { 152 + background-image: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12' fill='none'><path d='M2.5 6h7' stroke='rgb(107,114,128)' stroke-width='1.5' stroke-linecap='round'/></svg>"); 155 153 } 156 154 157 155 input[type="radio"] { ··· 187 185 input[type="radio"]:disabled:checked { 188 186 background-image: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12'><circle cx='6' cy='6' r='4.5' fill='rgb(209,213,219)'/></svg>"); 189 187 } 190 - @media (prefers-color-scheme: dark) { 191 - input[type="radio"]:checked { 192 - background-image: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12'><circle cx='6' cy='6' r='4.5' fill='rgb(243,244,246)'/></svg>"); 193 - } 194 - input[type="radio"]:disabled:checked { 195 - background-image: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12'><circle cx='6' cy='6' r='4.5' fill='rgb(107,114,128)'/></svg>"); 196 - } 188 + [data-theme="dark"] input[type="radio"]:checked { 189 + background-image: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12'><circle cx='6' cy='6' r='4.5' fill='rgb(243,244,246)'/></svg>"); 190 + } 191 + [data-theme="dark"] input[type="radio"]:disabled:checked { 192 + background-image: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 12'><circle cx='6' cy='6' r='4.5' fill='rgb(107,114,128)'/></svg>"); 197 193 } 198 194 199 195 select { ··· 902 898 text-decoration: underline; 903 899 } 904 900 905 - @media (prefers-color-scheme: dark) { 901 + [data-theme="dark"] { 906 902 /* Background */ 907 903 .bg { 908 904 color: #cad3f5; ··· 1260 1256 0 2px 4px -2px rgb(0 0 0 / 0.1); 1261 1257 } 1262 1258 1263 - @media (prefers-color-scheme: dark) { 1259 + [data-theme="dark"] { 1264 1260 actor-typeahead { 1265 1261 --color-background: #1f2937; 1266 1262 --color-border: #4b5563; ··· 1588 1584 @apply line-through; 1589 1585 } 1590 1586 1591 - @media (prefers-color-scheme: dark) { 1587 + [data-theme="dark"] { 1592 1588 .term-fg30 { 1593 1589 color: #a5adcb; 1594 1590 } /* black */
+1 -1
tailwind.config.js
··· 12 12 "./blog/templates/**/*.html", 13 13 "./blog/posts/**/*.md", 14 14 ], 15 - darkMode: "media", 15 + darkMode: ['selector', '[data-theme="dark"]'], 16 16 theme: { 17 17 container: { 18 18 padding: "2rem",