nexus/ui/src/context/ThemeContext.tsx
Mikkel Georgsen 44be367ada feat(07-01): extend ThemeContext to support three named themes with THEME_META export
- Expand Theme type to catppuccin-mocha | tokyo-night | catppuccin-latte
- Export THEME_META with label, dark boolean, bg hex, primary hex per theme
- applyTheme toggles .dark and .theme-tokyo-night classes correctly
- toggleTheme cycles all three themes (Mocha -> Tokyo Night -> Latte -> Mocha)
- readStoredTheme falls back to catppuccin-mocha for old localStorage values
- Fix Layout.tsx: replace theme === 'dark' comparison with THEME_META[theme].dark
- Fix MarkdownBody.tsx: replace theme === 'dark' comparisons with THEME_META[theme].dark
2026-04-04 03:55:42 +00:00

103 lines
2.9 KiB
TypeScript

import {
createContext,
useCallback,
useContext,
useEffect,
useMemo,
useState,
type ReactNode,
} from "react";
export type Theme = "catppuccin-mocha" | "tokyo-night" | "catppuccin-latte";
export const THEME_META: Record<Theme, { label: string; dark: boolean; bg: string; primary: string }> = {
"catppuccin-mocha": { label: "Catppuccin Mocha", dark: true, bg: "#1e1e2e", primary: "#89b4fa" },
"tokyo-night": { label: "Tokyo Night", dark: true, bg: "#1a1b26", primary: "#7aa2f7" },
"catppuccin-latte": { label: "Catppuccin Latte", dark: false, bg: "#eff1f5", primary: "#1e66f5" },
};
const VALID_THEMES: Theme[] = ["catppuccin-mocha", "tokyo-night", "catppuccin-latte"];
const DEFAULT_THEME: Theme = "catppuccin-mocha";
interface ThemeContextValue {
theme: Theme;
setTheme: (theme: Theme) => void;
toggleTheme: () => void;
}
const THEME_STORAGE_KEY = "paperclip.theme";
const ThemeContext = createContext<ThemeContextValue | undefined>(undefined);
function isValidTheme(value: string | null): value is Theme {
return value !== null && VALID_THEMES.includes(value as Theme);
}
function readStoredTheme(): Theme {
if (typeof document === "undefined") return DEFAULT_THEME;
try {
const stored = localStorage.getItem(THEME_STORAGE_KEY);
return isValidTheme(stored) ? stored : DEFAULT_THEME;
} catch {
return DEFAULT_THEME;
}
}
function applyTheme(theme: Theme) {
if (typeof document === "undefined") return;
const meta = THEME_META[theme];
const root = document.documentElement;
root.classList.toggle("dark", meta.dark);
root.classList.toggle("theme-tokyo-night", theme === "tokyo-night");
root.style.colorScheme = meta.dark ? "dark" : "light";
const themeColorMeta = document.querySelector('meta[name="theme-color"]');
if (themeColorMeta instanceof HTMLMetaElement) {
themeColorMeta.setAttribute("content", meta.bg);
}
}
export function ThemeProvider({ children }: { children: ReactNode }) {
const [theme, setThemeState] = useState<Theme>(() => readStoredTheme());
const setTheme = useCallback((nextTheme: Theme) => {
setThemeState(nextTheme);
}, []);
const toggleTheme = useCallback(() => {
setThemeState((current) => {
const idx = VALID_THEMES.indexOf(current);
return VALID_THEMES[(idx + 1) % VALID_THEMES.length];
});
}, []);
useEffect(() => {
applyTheme(theme);
try {
localStorage.setItem(THEME_STORAGE_KEY, theme);
} catch {
// Ignore localStorage write failures in restricted environments.
}
}, [theme]);
const value = useMemo(
() => ({
theme,
setTheme,
toggleTheme,
}),
[theme, setTheme, toggleTheme],
);
return (
<ThemeContext.Provider value={value}>
{children}
</ThemeContext.Provider>
);
}
export function useTheme() {
const context = useContext(ThemeContext);
if (!context) {
throw new Error("useTheme must be used within ThemeProvider");
}
return context;
}