- 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
103 lines
2.9 KiB
TypeScript
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;
|
|
}
|