mirror of https://github.com/usememos/memos.git
feat(web): standardize theme system with auto sync option (#5231)
Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
parent
8f29db2f49
commit
7d4d1e8517
|
|
@ -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…
Reference in New Issue