* feat: add theme switcher with system/light/dark segmented control Add a three-way theme toggle (system, light, dark) as a segmented icon control in the account dropdown menu. Rename placeholder daisyUI theme names from "todo" to "openseo". * refactor: always resolve data-theme explicitly, remove duplicated dark CSS Instead of removing data-theme in "system" mode and relying on @media (prefers-color-scheme: dark) CSS, always resolve to an explicit theme name via matchMedia. This eliminates the duplicated dark-mode CSS overrides.
134 lines
3.7 KiB
TypeScript
134 lines
3.7 KiB
TypeScript
import * as React from "react";
|
|
|
|
export type ThemePreference = "system" | "light" | "dark";
|
|
|
|
const LIGHT_THEME_NAME = "openseo";
|
|
const DARK_THEME_NAME = "openseo-dark";
|
|
|
|
const THEME_STORAGE_KEY = "theme-preference";
|
|
const THEME_CHANGE_EVENT = "theme-preference-change";
|
|
|
|
function readThemePreference(): ThemePreference {
|
|
if (typeof window === "undefined") {
|
|
return "system";
|
|
}
|
|
|
|
try {
|
|
const stored = window.localStorage.getItem(THEME_STORAGE_KEY);
|
|
if (stored === "light" || stored === "dark") {
|
|
return stored;
|
|
}
|
|
return "system";
|
|
} catch {
|
|
return "system";
|
|
}
|
|
}
|
|
|
|
function writeThemePreference(themePreference: ThemePreference) {
|
|
try {
|
|
if (themePreference === "system") {
|
|
window.localStorage.removeItem(THEME_STORAGE_KEY);
|
|
} else {
|
|
window.localStorage.setItem(THEME_STORAGE_KEY, themePreference);
|
|
}
|
|
} catch {
|
|
// localStorage can be unavailable in private browsing or strict browser modes.
|
|
}
|
|
}
|
|
|
|
function resolveThemeName(themePreference: ThemePreference): string {
|
|
if (themePreference === "light") return LIGHT_THEME_NAME;
|
|
if (themePreference === "dark") return DARK_THEME_NAME;
|
|
|
|
// "system" — resolve from OS preference
|
|
if (
|
|
typeof window !== "undefined" &&
|
|
window.matchMedia("(prefers-color-scheme: dark)").matches
|
|
) {
|
|
return DARK_THEME_NAME;
|
|
}
|
|
return LIGHT_THEME_NAME;
|
|
}
|
|
|
|
function applyThemePreference(themePreference: ThemePreference) {
|
|
if (typeof document === "undefined") {
|
|
return;
|
|
}
|
|
|
|
document.documentElement.setAttribute(
|
|
"data-theme",
|
|
resolveThemeName(themePreference),
|
|
);
|
|
}
|
|
|
|
function subscribeToThemePreference(onStoreChange: () => void) {
|
|
if (typeof window === "undefined") {
|
|
return () => {};
|
|
}
|
|
|
|
const handleThemeChange = () => {
|
|
onStoreChange();
|
|
};
|
|
|
|
const handleStorage = (event: StorageEvent) => {
|
|
if (event.key && event.key !== THEME_STORAGE_KEY) {
|
|
return;
|
|
}
|
|
|
|
onStoreChange();
|
|
};
|
|
|
|
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
|
|
const handleMediaChange = () => {
|
|
// Re-apply when OS preference changes so "system" mode stays in sync
|
|
applyThemePreference(readThemePreference());
|
|
onStoreChange();
|
|
};
|
|
|
|
window.addEventListener(THEME_CHANGE_EVENT, handleThemeChange);
|
|
window.addEventListener("storage", handleStorage);
|
|
mediaQuery.addEventListener("change", handleMediaChange);
|
|
|
|
return () => {
|
|
window.removeEventListener(THEME_CHANGE_EVENT, handleThemeChange);
|
|
window.removeEventListener("storage", handleStorage);
|
|
mediaQuery.removeEventListener("change", handleMediaChange);
|
|
};
|
|
}
|
|
|
|
export function useThemePreference() {
|
|
const themePreference = React.useSyncExternalStore<ThemePreference>(
|
|
subscribeToThemePreference,
|
|
readThemePreference,
|
|
() => "system",
|
|
);
|
|
|
|
React.useEffect(() => {
|
|
applyThemePreference(themePreference);
|
|
}, [themePreference]);
|
|
|
|
const setThemePreference = React.useCallback(
|
|
(nextThemePreference: ThemePreference) => {
|
|
writeThemePreference(nextThemePreference);
|
|
applyThemePreference(nextThemePreference);
|
|
window.dispatchEvent(new Event(THEME_CHANGE_EVENT));
|
|
},
|
|
[],
|
|
);
|
|
|
|
return { themePreference, setThemePreference };
|
|
}
|
|
|
|
export const themePreferenceInitScript = `(() => {
|
|
try {
|
|
var p = window.localStorage.getItem(${JSON.stringify(THEME_STORAGE_KEY)});
|
|
var t;
|
|
if (p === "light") t = ${JSON.stringify(LIGHT_THEME_NAME)};
|
|
else if (p === "dark") t = ${JSON.stringify(DARK_THEME_NAME)};
|
|
else t = window.matchMedia("(prefers-color-scheme: dark)").matches ? ${JSON.stringify(DARK_THEME_NAME)} : ${JSON.stringify(LIGHT_THEME_NAME)};
|
|
document.documentElement.setAttribute("data-theme", t);
|
|
} catch {
|
|
document.documentElement.setAttribute("data-theme", ${JSON.stringify(LIGHT_THEME_NAME)});
|
|
}
|
|
})();`;
|