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 useNavigateTo from "./hooks/useNavigateTo";
|
||||||
import { userStore, instanceStore } from "./store";
|
import { userStore, instanceStore } from "./store";
|
||||||
import { cleanupExpiredOAuthState } from "./utils/oauth";
|
import { cleanupExpiredOAuthState } from "./utils/oauth";
|
||||||
import { loadTheme } from "./utils/theme";
|
import { loadTheme, setupSystemThemeListener } from "./utils/theme";
|
||||||
|
|
||||||
const App = observer(() => {
|
const App = observer(() => {
|
||||||
const { i18n } = useTranslation();
|
const { i18n } = useTranslation();
|
||||||
|
|
@ -85,6 +85,25 @@ const App = observer(() => {
|
||||||
}
|
}
|
||||||
}, [userGeneralSetting?.theme, instanceStore.state.theme]);
|
}, [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 />;
|
return <Outlet />;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,9 @@ const PreferencesSection = observer(() => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleThemeChange = async (theme: string) => {
|
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"]);
|
await userStore.updateUserGeneralSetting({ theme }, ["theme"]);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -34,7 +37,7 @@ const PreferencesSection = observer(() => {
|
||||||
const setting: UserSetting_GeneralSetting = generalSetting || {
|
const setting: UserSetting_GeneralSetting = generalSetting || {
|
||||||
locale: "en",
|
locale: "en",
|
||||||
memoVisibility: "PRIVATE",
|
memoVisibility: "PRIVATE",
|
||||||
theme: "default",
|
theme: "system",
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||||
import { instanceStore } from "@/store";
|
import { instanceStore } from "@/store";
|
||||||
import { THEME_OPTIONS } from "@/utils/theme";
|
import { THEME_OPTIONS } from "@/utils/theme";
|
||||||
|
|
@ -10,6 +10,7 @@ interface ThemeSelectProps {
|
||||||
}
|
}
|
||||||
|
|
||||||
const THEME_ICONS: Record<string, JSX.Element> = {
|
const THEME_ICONS: Record<string, JSX.Element> = {
|
||||||
|
system: <Monitor className="w-4 h-4" />,
|
||||||
default: <Sun className="w-4 h-4" />,
|
default: <Sun className="w-4 h-4" />,
|
||||||
"default-dark": <Moon className="w-4 h-4" />,
|
"default-dark": <Moon className="w-4 h-4" />,
|
||||||
paper: <Palette 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 ThemeSelect = ({ value, onValueChange, className }: ThemeSelectProps = {}) => {
|
||||||
const currentTheme = value || instanceStore.state.theme || "default";
|
const currentTheme = value || instanceStore.state.theme || "system";
|
||||||
|
|
||||||
const handleThemeChange = (newTheme: Theme) => {
|
const handleThemeChange = (newTheme: Theme) => {
|
||||||
if (onValueChange) {
|
if (onValueChange) {
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ import { createRequestKey } from "./store-utils";
|
||||||
/**
|
/**
|
||||||
* Valid theme options
|
* 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];
|
export type Theme = (typeof VALID_THEMES)[number];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -40,7 +40,7 @@ class InstanceState extends StandardState {
|
||||||
* Current theme
|
* Current theme
|
||||||
* Note: Accepts string for flexibility, but validates to Theme
|
* Note: Accepts string for flexibility, but validates to Theme
|
||||||
*/
|
*/
|
||||||
theme: Theme | string = "default";
|
theme: Theme | string = "system";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Instance profile containing owner and metadata
|
* Instance profile containing owner and metadata
|
||||||
|
|
@ -249,7 +249,7 @@ export const initialInstanceStore = async (): Promise<void> => {
|
||||||
const instanceGeneralSetting = instanceStore.state.generalSetting;
|
const instanceGeneralSetting = instanceStore.state.generalSetting;
|
||||||
instanceStore.state.setPartial({
|
instanceStore.state.setPartial({
|
||||||
locale: instanceGeneralSetting.customProfile?.locale || "en",
|
locale: instanceGeneralSetting.customProfile?.locale || "en",
|
||||||
theme: "default",
|
theme: instanceGeneralSetting.theme || "system",
|
||||||
profile: instanceProfile,
|
profile: instanceProfile,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|
@ -257,7 +257,7 @@ export const initialInstanceStore = async (): Promise<void> => {
|
||||||
// Set default fallback values
|
// Set default fallback values
|
||||||
instanceStore.state.setPartial({
|
instanceStore.state.setPartial({
|
||||||
locale: "en",
|
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 paperThemeContent from "../themes/paper.css?raw";
|
||||||
import whitewallThemeContent from "../themes/whitewall.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];
|
type ValidTheme = (typeof VALID_THEMES)[number];
|
||||||
|
|
||||||
const THEME_CONTENT: Record<ValidTheme, string | null> = {
|
const THEME_CONTENT: Record<ValidTheme, string | null> = {
|
||||||
|
system: null, // System theme dynamically chooses between default and default-dark
|
||||||
default: null,
|
default: null,
|
||||||
"default-dark": defaultDarkThemeContent,
|
"default-dark": defaultDarkThemeContent,
|
||||||
paper: paperThemeContent,
|
paper: paperThemeContent,
|
||||||
|
|
@ -18,8 +19,9 @@ export interface ThemeOption {
|
||||||
}
|
}
|
||||||
|
|
||||||
export const THEME_OPTIONS: ThemeOption[] = [
|
export const THEME_OPTIONS: ThemeOption[] = [
|
||||||
{ value: "default", label: "Default Light" },
|
{ value: "system", label: "Sync with system" },
|
||||||
{ value: "default-dark", label: "Default Dark" },
|
{ value: "default", label: "Light" },
|
||||||
|
{ value: "default-dark", label: "Dark" },
|
||||||
{ value: "paper", label: "Paper" },
|
{ value: "paper", label: "Paper" },
|
||||||
{ value: "whitewall", label: "Whitewall" },
|
{ value: "whitewall", label: "Whitewall" },
|
||||||
];
|
];
|
||||||
|
|
@ -38,6 +40,18 @@ export const getSystemTheme = (): "default" | "default-dark" => {
|
||||||
return "default";
|
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
|
* Gets the theme that should be applied on initial load
|
||||||
* Priority: stored user preference -> system preference -> default
|
* Priority: stored user preference -> system preference -> default
|
||||||
|
|
@ -53,8 +67,8 @@ export const getInitialTheme = (): ValidTheme => {
|
||||||
// localStorage might not be available
|
// localStorage might not be available
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fall back to system preference
|
// Fall back to system preference (return "system" to enable auto-switching)
|
||||||
return getSystemTheme();
|
return "system";
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -68,12 +82,15 @@ export const applyThemeEarly = (): void => {
|
||||||
export const loadTheme = (themeName: string): void => {
|
export const loadTheme = (themeName: string): void => {
|
||||||
const validTheme = validateTheme(themeName);
|
const validTheme = validateTheme(themeName);
|
||||||
|
|
||||||
|
// Resolve "system" to actual theme based on OS preference
|
||||||
|
const resolvedTheme = resolveTheme(validTheme);
|
||||||
|
|
||||||
// Remove existing theme
|
// Remove existing theme
|
||||||
document.getElementById("instance-theme")?.remove();
|
document.getElementById("instance-theme")?.remove();
|
||||||
|
|
||||||
// Apply theme (skip for default)
|
// Apply theme (skip for default)
|
||||||
if (validTheme !== "default") {
|
if (resolvedTheme !== "default") {
|
||||||
const css = THEME_CONTENT[validTheme];
|
const css = THEME_CONTENT[resolvedTheme];
|
||||||
if (css) {
|
if (css) {
|
||||||
const style = document.createElement("style");
|
const style = document.createElement("style");
|
||||||
style.id = "instance-theme";
|
style.id = "instance-theme";
|
||||||
|
|
@ -82,13 +99,44 @@ export const loadTheme = (themeName: string): void => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set data attribute
|
// Set data attribute with resolved theme
|
||||||
document.documentElement.setAttribute("data-theme", validTheme);
|
document.documentElement.setAttribute("data-theme", resolvedTheme);
|
||||||
|
|
||||||
// Store theme preference for future loads
|
// Store theme preference (original, not resolved) for future loads
|
||||||
try {
|
try {
|
||||||
localStorage.setItem("memos-theme", validTheme);
|
localStorage.setItem("memos-theme", validTheme);
|
||||||
} catch {
|
} catch {
|
||||||
// localStorage might not be available
|
// 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