Keep the theme on the account, not in the browser

- users.theme and users.theme_mode, added by migrate(); GET /api/me returns them
  and PATCH /api/me saves them, refusing anything but a plain name and
  light/dark/auto, since index() writes them into the page's <html> tag.
- The page arrives with data-theme and data-choice already on <html> (and
  data-mode unless Auto), so it is drawn in the account's theme from the start.
- A theme a browser kept in localStorage goes up to the account once, the first
  time an account with none loads the page.
- Saves go one at a time, each with the choice as it stands: sent all at once, a
  quick run through the list could land out of order and keep a theme passed on
  the way. The browser test caught it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-18 14:33:59 +00:00
parent 9b2537761f
commit 9c16408d04
6 changed files with 164 additions and 27 deletions

View File

@@ -81,7 +81,10 @@ CREATE TABLE IF NOT EXISTS users (
is_admin INTEGER NOT NULL DEFAULT 0,
-- For whoever maintains the server. NULL where it is not known.
created INTEGER,
last_login INTEGER
last_login INTEGER,
-- The theme chosen in Settings, and light, dark or auto. NULL until one is chosen.
theme TEXT,
theme_mode TEXT
);
-- What one person wants from a feed. The feed, its items and its files are shared; this
@@ -176,6 +179,9 @@ fn migrate(conn: &Connection) -> Result<()> {
("feeds", "error_since", "INTEGER"),
("feeds", "category", "TEXT"),
("entry_state", "duration", "INTEGER"),
// Kept per account so a theme follows you to another browser; it was in localStorage.
("users", "theme", "TEXT"),
("users", "theme_mode", "TEXT"),
];
let retired: &[(&str, &str)] = &[
// Read state from before accounts, long since moved to entry_state. Two bugs came from
@@ -1180,6 +1186,23 @@ impl Db {
Ok(())
}
/// The theme this person chose, and light, dark or auto; None for either until they choose.
pub fn theme(&self, user_id: i64) -> Result<(Option<String>, Option<String>)> {
let conn = self.conn.lock().unwrap();
Ok(conn.query_row("SELECT theme, theme_mode FROM users WHERE id = ?1", [user_id], |r| {
Ok((r.get(0)?, r.get(1)?))
})?)
}
pub fn set_theme(&self, user_id: i64, theme: &str, mode: &str) -> Result<()> {
let conn = self.conn.lock().unwrap();
conn.execute(
"UPDATE users SET theme = ?2, theme_mode = ?3 WHERE id = ?1",
params![user_id, theme, mode],
)?;
Ok(())
}
/// The user behind a session cookie, if it is still live. Idle sessions expire after
/// `max_idle_secs`; touching `seen` is what keeps a session in daily use alive.
pub fn session_user(&self, token: &str, max_idle_secs: i64) -> Result<Option<User>> {