feat(web): standardize theme system with auto sync option (#5231)

Co-authored-by: Claude <noreply@anthropic.com>
pull/5235/head
boojack 1 week ago committed by GitHub
parent 8f29db2f49
commit 7d4d1e8517
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -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 <Outlet />;
});

@ -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 (

@ -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<string, JSX.Element> = {
system: <Monitor className="w-4 h-4" />,
default: <Sun className="w-4 h-4" />,
"default-dark": <Moon className="w-4 h-4" />,
paper: <Palette className="w-4 h-4" />,
@ -17,7 +18,7 @@ const THEME_ICONS: Record<string, JSX.Element> = {
};
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) {

@ -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<void> => {
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<void> => {
// Set default fallback values
instanceStore.state.setPartial({
locale: "en",
theme: "default",
theme: "system",
});
}
};

@ -1 +1 @@
type Theme = "default" | "default-dark" | "paper" | "whitewall";
type Theme = "system" | "default" | "default-dark" | "paper" | "whitewall";

@ -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<ValidTheme, string | null> = {
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
};

Loading…
Cancel
Save