From 7d4d1e8517d7578fa6e0daf6cc53ff42e97a997c Mon Sep 17 00:00:00 2001 From: boojack Date: Sat, 8 Nov 2025 00:41:21 +0800 Subject: [PATCH] feat(web): standardize theme system with auto sync option (#5231) Co-authored-by: Claude --- web/src/App.tsx | 21 +++++- .../Settings/PreferencesSection.tsx | 5 +- web/src/components/ThemeSelect.tsx | 5 +- web/src/store/instance.ts | 8 +-- web/src/types/modules/setting.d.ts | 2 +- web/src/utils/theme.ts | 68 ++++++++++++++++--- 6 files changed, 90 insertions(+), 19 deletions(-) diff --git a/web/src/App.tsx b/web/src/App.tsx index a5afdcc93..8ad767e5b 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -5,7 +5,7 @@ import { Outlet } from "react-router-dom"; import useNavigateTo from "./hooks/useNavigateTo"; import { userStore, instanceStore } from "./store"; import { cleanupExpiredOAuthState } from "./utils/oauth"; -import { loadTheme } from "./utils/theme"; +import { loadTheme, setupSystemThemeListener } from "./utils/theme"; const App = observer(() => { const { i18n } = useTranslation(); @@ -85,6 +85,25 @@ const App = observer(() => { } }, [userGeneralSetting?.theme, instanceStore.state.theme]); + // Listen for system theme changes when using "system" theme + useEffect(() => { + const currentTheme = userGeneralSetting?.theme || instanceStore.state.theme; + + // Only set up listener if theme is "system" + if (currentTheme !== "system") { + return; + } + + // Set up listener for OS theme preference changes + const cleanup = setupSystemThemeListener(() => { + // Reload theme when system preference changes + loadTheme(currentTheme); + }); + + // Cleanup listener on unmount or when theme changes + return cleanup; + }, [userGeneralSetting?.theme, instanceStore.state.theme]); + return ; }); diff --git a/web/src/components/Settings/PreferencesSection.tsx b/web/src/components/Settings/PreferencesSection.tsx index 5df3f42ad..3d1353d75 100644 --- a/web/src/components/Settings/PreferencesSection.tsx +++ b/web/src/components/Settings/PreferencesSection.tsx @@ -27,6 +27,9 @@ const PreferencesSection = observer(() => { }; const handleThemeChange = async (theme: string) => { + // Update instance store immediately for instant UI feedback + instanceStore.state.setPartial({ theme }); + // Persist to user settings await userStore.updateUserGeneralSetting({ theme }, ["theme"]); }; @@ -34,7 +37,7 @@ const PreferencesSection = observer(() => { const setting: UserSetting_GeneralSetting = generalSetting || { locale: "en", memoVisibility: "PRIVATE", - theme: "default", + theme: "system", }; return ( diff --git a/web/src/components/ThemeSelect.tsx b/web/src/components/ThemeSelect.tsx index d9f2fd1bc..623a5d04c 100644 --- a/web/src/components/ThemeSelect.tsx +++ b/web/src/components/ThemeSelect.tsx @@ -1,4 +1,4 @@ -import { Moon, Palette, Sun, Wallpaper } from "lucide-react"; +import { Moon, Monitor, Palette, Sun, Wallpaper } from "lucide-react"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { instanceStore } from "@/store"; import { THEME_OPTIONS } from "@/utils/theme"; @@ -10,6 +10,7 @@ interface ThemeSelectProps { } const THEME_ICONS: Record = { + system: , default: , "default-dark": , paper: , @@ -17,7 +18,7 @@ const THEME_ICONS: Record = { }; const ThemeSelect = ({ value, onValueChange, className }: ThemeSelectProps = {}) => { - const currentTheme = value || instanceStore.state.theme || "default"; + const currentTheme = value || instanceStore.state.theme || "system"; const handleThemeChange = (newTheme: Theme) => { if (onValueChange) { diff --git a/web/src/store/instance.ts b/web/src/store/instance.ts index b02a8ae4a..06787913a 100644 --- a/web/src/store/instance.ts +++ b/web/src/store/instance.ts @@ -17,7 +17,7 @@ import { createRequestKey } from "./store-utils"; /** * Valid theme options */ -const VALID_THEMES = ["default", "default-dark", "paper", "whitewall"] as const; +const VALID_THEMES = ["system", "default", "default-dark", "paper", "whitewall"] as const; export type Theme = (typeof VALID_THEMES)[number]; /** @@ -40,7 +40,7 @@ class InstanceState extends StandardState { * Current theme * Note: Accepts string for flexibility, but validates to Theme */ - theme: Theme | string = "default"; + theme: Theme | string = "system"; /** * Instance profile containing owner and metadata @@ -249,7 +249,7 @@ export const initialInstanceStore = async (): Promise => { const instanceGeneralSetting = instanceStore.state.generalSetting; instanceStore.state.setPartial({ locale: instanceGeneralSetting.customProfile?.locale || "en", - theme: "default", + theme: instanceGeneralSetting.theme || "system", profile: instanceProfile, }); } catch (error) { @@ -257,7 +257,7 @@ export const initialInstanceStore = async (): Promise => { // Set default fallback values instanceStore.state.setPartial({ locale: "en", - theme: "default", + theme: "system", }); } }; diff --git a/web/src/types/modules/setting.d.ts b/web/src/types/modules/setting.d.ts index 456c428a8..ffe65b304 100644 --- a/web/src/types/modules/setting.d.ts +++ b/web/src/types/modules/setting.d.ts @@ -1 +1 @@ -type Theme = "default" | "default-dark" | "paper" | "whitewall"; +type Theme = "system" | "default" | "default-dark" | "paper" | "whitewall"; diff --git a/web/src/utils/theme.ts b/web/src/utils/theme.ts index e4e80a2e0..d270c3a79 100644 --- a/web/src/utils/theme.ts +++ b/web/src/utils/theme.ts @@ -2,10 +2,11 @@ import defaultDarkThemeContent from "../themes/default-dark.css?raw"; import paperThemeContent from "../themes/paper.css?raw"; import whitewallThemeContent from "../themes/whitewall.css?raw"; -const VALID_THEMES = ["default", "default-dark", "paper", "whitewall"] as const; +const VALID_THEMES = ["system", "default", "default-dark", "paper", "whitewall"] as const; type ValidTheme = (typeof VALID_THEMES)[number]; const THEME_CONTENT: Record = { + system: null, // System theme dynamically chooses between default and default-dark default: null, "default-dark": defaultDarkThemeContent, paper: paperThemeContent, @@ -18,8 +19,9 @@ export interface ThemeOption { } export const THEME_OPTIONS: ThemeOption[] = [ - { value: "default", label: "Default Light" }, - { value: "default-dark", label: "Default Dark" }, + { value: "system", label: "Sync with system" }, + { value: "default", label: "Light" }, + { value: "default-dark", label: "Dark" }, { value: "paper", label: "Paper" }, { value: "whitewall", label: "Whitewall" }, ]; @@ -38,6 +40,18 @@ export const getSystemTheme = (): "default" | "default-dark" => { return "default"; }; +/** + * Resolves the actual theme to apply based on user preference + * If theme is "system", returns the system preference, otherwise returns the theme as-is + */ +export const resolveTheme = (theme: string): "default" | "default-dark" | "paper" | "whitewall" => { + if (theme === "system") { + return getSystemTheme(); + } + const validTheme = validateTheme(theme); + return validTheme === "system" ? getSystemTheme() : validTheme; +}; + /** * Gets the theme that should be applied on initial load * Priority: stored user preference -> system preference -> default @@ -53,8 +67,8 @@ export const getInitialTheme = (): ValidTheme => { // localStorage might not be available } - // Fall back to system preference - return getSystemTheme(); + // Fall back to system preference (return "system" to enable auto-switching) + return "system"; }; /** @@ -68,12 +82,15 @@ export const applyThemeEarly = (): void => { export const loadTheme = (themeName: string): void => { const validTheme = validateTheme(themeName); + // Resolve "system" to actual theme based on OS preference + const resolvedTheme = resolveTheme(validTheme); + // Remove existing theme document.getElementById("instance-theme")?.remove(); // Apply theme (skip for default) - if (validTheme !== "default") { - const css = THEME_CONTENT[validTheme]; + if (resolvedTheme !== "default") { + const css = THEME_CONTENT[resolvedTheme]; if (css) { const style = document.createElement("style"); style.id = "instance-theme"; @@ -82,13 +99,44 @@ export const loadTheme = (themeName: string): void => { } } - // Set data attribute - document.documentElement.setAttribute("data-theme", validTheme); + // Set data attribute with resolved theme + document.documentElement.setAttribute("data-theme", resolvedTheme); - // Store theme preference for future loads + // Store theme preference (original, not resolved) for future loads try { localStorage.setItem("memos-theme", validTheme); } catch { // localStorage might not be available } }; + +/** + * Sets up a listener for system theme preference changes + * Returns a cleanup function to remove the listener + */ +export const setupSystemThemeListener = (onThemeChange: () => void): (() => void) => { + if (typeof window === "undefined" || !window.matchMedia) { + return () => {}; // No-op cleanup + } + + const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)"); + + // Handle theme change + const handleChange = () => { + onThemeChange(); + }; + + // Modern API (addEventListener) + if (mediaQuery.addEventListener) { + mediaQuery.addEventListener("change", handleChange); + return () => mediaQuery.removeEventListener("change", handleChange); + } + + // Legacy API (addListener) - for older browsers + if (mediaQuery.addListener) { + mediaQuery.addListener(handleChange); + return () => mediaQuery.removeListener(handleChange); + } + + return () => {}; // No-op cleanup +};