Files
concord/concord-client/src/components/theme-provider.tsx

501 lines
16 KiB
TypeScript
Raw Normal View History

import { createContext, useContext, useEffect, useState } from "react";
export interface ThemeColors {
background: string;
foreground: string;
card: string;
cardForeground: string;
popover: string;
popoverForeground: string;
primary: string;
primaryForeground: string;
secondary: string;
secondaryForeground: string;
muted: string;
mutedForeground: string;
accent: string;
accentForeground: string;
destructive: string;
border: string;
input: string;
ring: string;
// Chart colors
chart1: string;
chart2: string;
chart3: string;
chart4: string;
chart5: string;
// Sidebar colors
sidebar: string;
sidebarForeground: string;
sidebarPrimary: string;
sidebarPrimaryForeground: string;
sidebarAccent: string;
sidebarAccentForeground: string;
sidebarBorder: string;
sidebarRing: string;
}
export interface ThemeDefinition {
id: string;
name: string;
description?: string;
mode: "light" | "dark";
colors: ThemeColors;
isCustom?: boolean;
}
// Fixed themes using proper OKLCH format
const DEFAULT_THEMES: ThemeDefinition[] = [
{
id: "default-light",
name: "Default Light",
mode: "light",
colors: {
background: "oklch(1 0 0)",
foreground: "oklch(0.145 0 0)",
card: "oklch(1 0 0)",
cardForeground: "oklch(0.145 0 0)",
popover: "oklch(1 0 0)",
popoverForeground: "oklch(0.145 0 0)",
primary: "oklch(0.205 0 0)",
primaryForeground: "oklch(0.985 0 0)",
secondary: "oklch(0.97 0 0)",
secondaryForeground: "oklch(0.205 0 0)",
muted: "oklch(0.97 0 0)",
mutedForeground: "oklch(0.556 0 0)",
accent: "oklch(0.97 0 0)",
accentForeground: "oklch(0.205 0 0)",
destructive: "oklch(0.577 0.245 27.325)",
border: "oklch(0.922 0 0)",
input: "oklch(0.922 0 0)",
ring: "oklch(0.708 0 0)",
chart1: "oklch(0.646 0.222 41.116)",
chart2: "oklch(0.6 0.118 184.704)",
chart3: "oklch(0.398 0.07 227.392)",
chart4: "oklch(0.828 0.189 84.429)",
chart5: "oklch(0.769 0.188 70.08)",
sidebar: "oklch(0.985 0 0)",
sidebarForeground: "oklch(0.145 0 0)",
sidebarPrimary: "oklch(0.205 0 0)",
sidebarPrimaryForeground: "oklch(0.985 0 0)",
sidebarAccent: "oklch(0.97 0 0)",
sidebarAccentForeground: "oklch(0.205 0 0)",
sidebarBorder: "oklch(0.922 0 0)",
sidebarRing: "oklch(0.708 0 0)",
},
},
{
id: "default-dark",
name: "Default Dark",
mode: "dark",
colors: {
background: "oklch(0.145 0 0)",
foreground: "oklch(0.985 0 0)",
card: "oklch(0.205 0 0)",
cardForeground: "oklch(0.985 0 0)",
popover: "oklch(0.205 0 0)",
popoverForeground: "oklch(0.985 0 0)",
primary: "oklch(0.922 0 0)",
primaryForeground: "oklch(0.205 0 0)",
secondary: "oklch(0.269 0 0)",
secondaryForeground: "oklch(0.985 0 0)",
muted: "oklch(0.269 0 0)",
mutedForeground: "oklch(0.708 0 0)",
accent: "oklch(0.269 0 0)",
accentForeground: "oklch(0.985 0 0)",
destructive: "oklch(0.704 0.191 22.216)",
border: "oklch(1 0 0 / 10%)",
input: "oklch(1 0 0 / 15%)",
ring: "oklch(0.556 0 0)",
chart1: "oklch(0.488 0.243 264.376)",
chart2: "oklch(0.696 0.17 162.48)",
chart3: "oklch(0.769 0.188 70.08)",
chart4: "oklch(0.627 0.265 303.9)",
chart5: "oklch(0.645 0.246 16.439)",
sidebar: "oklch(0.205 0 0)",
sidebarForeground: "oklch(0.985 0 0)",
sidebarPrimary: "oklch(0.488 0.243 264.376)",
sidebarPrimaryForeground: "oklch(0.985 0 0)",
sidebarAccent: "oklch(0.269 0 0)",
sidebarAccentForeground: "oklch(0.985 0 0)",
sidebarBorder: "oklch(1 0 0 / 10%)",
sidebarRing: "oklch(0.556 0 0)",
},
},
{
id: "paper-light",
name: "Paper",
description: "Clean paper-like theme",
mode: "light",
colors: {
background: "oklch(0.99 0.01 85)",
foreground: "oklch(0.15 0.02 65)",
card: "oklch(0.99 0.01 85)",
cardForeground: "oklch(0.15 0.02 65)",
popover: "oklch(1 0 0)",
popoverForeground: "oklch(0.15 0.02 65)",
primary: "oklch(0.25 0.03 45)",
primaryForeground: "oklch(0.98 0.01 85)",
secondary: "oklch(0.96 0.01 75)",
secondaryForeground: "oklch(0.25 0.03 45)",
muted: "oklch(0.96 0.01 75)",
mutedForeground: "oklch(0.45 0.02 55)",
accent: "oklch(0.96 0.01 75)",
accentForeground: "oklch(0.25 0.03 45)",
destructive: "oklch(0.577 0.245 27.325)",
border: "oklch(0.90 0.01 65)",
input: "oklch(0.90 0.01 65)",
ring: "oklch(0.25 0.03 45)",
chart1: "oklch(0.646 0.222 41.116)",
chart2: "oklch(0.6 0.118 184.704)",
chart3: "oklch(0.398 0.07 227.392)",
chart4: "oklch(0.828 0.189 84.429)",
chart5: "oklch(0.769 0.188 70.08)",
sidebar: "oklch(0.97 0.01 80)",
sidebarForeground: "oklch(0.15 0.02 65)",
sidebarPrimary: "oklch(0.25 0.03 45)",
sidebarPrimaryForeground: "oklch(0.98 0.01 85)",
sidebarAccent: "oklch(0.94 0.01 75)",
sidebarAccentForeground: "oklch(0.25 0.03 45)",
sidebarBorder: "oklch(0.88 0.01 65)",
sidebarRing: "oklch(0.25 0.03 45)",
},
},
{
id: "comfy-brown-dark",
name: "Comfy Brown",
description: "Warm brown theme for dark mode",
mode: "dark",
colors: {
background: "oklch(0.15 0.03 65)",
foreground: "oklch(0.95 0.01 85)",
card: "oklch(0.20 0.03 55)",
cardForeground: "oklch(0.95 0.01 85)",
popover: "oklch(0.20 0.03 55)",
popoverForeground: "oklch(0.95 0.01 85)",
primary: "oklch(0.65 0.15 45)",
primaryForeground: "oklch(0.95 0.01 85)",
secondary: "oklch(0.25 0.04 50)",
secondaryForeground: "oklch(0.95 0.01 85)",
muted: "oklch(0.25 0.04 50)",
mutedForeground: "oklch(0.70 0.02 65)",
accent: "oklch(0.25 0.04 50)",
accentForeground: "oklch(0.95 0.01 85)",
destructive: "oklch(0.704 0.191 22.216)",
border: "oklch(0.30 0.04 55)",
input: "oklch(0.30 0.04 55)",
ring: "oklch(0.65 0.15 45)",
chart1: "oklch(0.65 0.15 45)",
chart2: "oklch(0.55 0.12 85)",
chart3: "oklch(0.75 0.18 25)",
chart4: "oklch(0.60 0.14 105)",
chart5: "oklch(0.70 0.16 65)",
sidebar: "oklch(0.18 0.03 60)",
sidebarForeground: "oklch(0.95 0.01 85)",
sidebarPrimary: "oklch(0.65 0.15 45)",
sidebarPrimaryForeground: "oklch(0.95 0.01 85)",
sidebarAccent: "oklch(0.22 0.04 50)",
sidebarAccentForeground: "oklch(0.95 0.01 85)",
sidebarBorder: "oklch(0.28 0.04 55)",
sidebarRing: "oklch(0.65 0.15 45)",
},
},
{
id: "midnight-dark",
name: "Midnight",
description: "Deep blue midnight theme",
mode: "dark",
colors: {
background: "oklch(0.12 0.08 250)",
foreground: "oklch(0.95 0.01 230)",
card: "oklch(0.18 0.06 240)",
cardForeground: "oklch(0.95 0.01 230)",
popover: "oklch(0.18 0.06 240)",
popoverForeground: "oklch(0.95 0.01 230)",
primary: "oklch(0.60 0.20 240)",
primaryForeground: "oklch(0.95 0.01 230)",
secondary: "oklch(0.22 0.05 235)",
secondaryForeground: "oklch(0.95 0.01 230)",
muted: "oklch(0.22 0.05 235)",
mutedForeground: "oklch(0.70 0.02 230)",
accent: "oklch(0.22 0.05 235)",
accentForeground: "oklch(0.95 0.01 230)",
destructive: "oklch(0.704 0.191 22.216)",
border: "oklch(0.25 0.05 235)",
input: "oklch(0.25 0.05 235)",
ring: "oklch(0.60 0.20 240)",
chart1: "oklch(0.60 0.20 240)",
chart2: "oklch(0.50 0.15 200)",
chart3: "oklch(0.65 0.18 280)",
chart4: "oklch(0.55 0.16 160)",
chart5: "oklch(0.70 0.22 300)",
sidebar: "oklch(0.15 0.07 245)",
sidebarForeground: "oklch(0.95 0.01 230)",
sidebarPrimary: "oklch(0.60 0.20 240)",
sidebarPrimaryForeground: "oklch(0.95 0.01 230)",
sidebarAccent: "oklch(0.20 0.05 235)",
sidebarAccentForeground: "oklch(0.95 0.01 230)",
sidebarBorder: "oklch(0.22 0.05 235)",
sidebarRing: "oklch(0.60 0.20 240)",
},
},
];
type ThemeMode = "light" | "dark" | "system";
type ThemeProviderProps = {
children: React.ReactNode;
defaultTheme?: ThemeMode;
storageKey?: string;
};
type ThemeProviderState = {
mode: ThemeMode;
currentTheme: ThemeDefinition;
currentLightTheme: ThemeDefinition;
currentDarkTheme: ThemeDefinition;
themes: ThemeDefinition[];
setMode: (mode: ThemeMode) => void;
setTheme: (themeId: string) => void;
addCustomTheme: (theme: Omit<ThemeDefinition, "id" | "isCustom">) => void;
removeCustomTheme: (themeId: string) => void;
getThemesForMode: (mode: "light" | "dark") => ThemeDefinition[];
};
const initialState: ThemeProviderState = {
mode: "system",
currentTheme: DEFAULT_THEMES[1], // Default to dark theme
currentLightTheme: DEFAULT_THEMES[0], // Default light
currentDarkTheme: DEFAULT_THEMES[1], // Default dark
themes: DEFAULT_THEMES,
setMode: () => null,
setTheme: () => null,
addCustomTheme: () => null,
removeCustomTheme: () => null,
getThemesForMode: () => [],
};
const ThemeProviderContext = createContext<ThemeProviderState>(initialState);
export function ThemeProvider({
children,
defaultTheme = "system",
storageKey = "concord-theme",
...props
}: ThemeProviderProps) {
const [mode, setMode] = useState<ThemeMode>(
() =>
(localStorage.getItem(storageKey + "-mode") as ThemeMode) || defaultTheme,
);
const [themes, setThemes] = useState<ThemeDefinition[]>(() => {
const saved = localStorage.getItem(storageKey + "-themes");
const customThemes = saved ? JSON.parse(saved) : [];
return [...DEFAULT_THEMES, ...customThemes];
});
const [currentLightThemeId, setCurrentLightThemeId] = useState<string>(() => {
const saved = localStorage.getItem(storageKey + "-light");
return saved || "default-light";
});
const [currentDarkThemeId, setCurrentDarkThemeId] = useState<string>(() => {
const saved = localStorage.getItem(storageKey + "-dark");
return saved || "default-dark";
});
const currentLightTheme =
themes.find((t) => t.id === currentLightThemeId) || DEFAULT_THEMES[0];
const currentDarkTheme =
themes.find((t) => t.id === currentDarkThemeId) || DEFAULT_THEMES[2];
// Determine the current theme based on mode and system preference
const getCurrentTheme = (): ThemeDefinition => {
switch (mode) {
case "light":
return currentLightTheme;
case "dark":
return currentDarkTheme;
case "system":
const systemPrefersDark = window.matchMedia(
"(prefers-color-scheme: dark)",
).matches;
return systemPrefersDark ? currentDarkTheme : currentLightTheme;
default:
return currentDarkTheme;
}
};
const currentTheme = getCurrentTheme();
const applyTheme = (theme: ThemeDefinition) => {
const root = window.document.documentElement;
// Remove existing theme classes
root.classList.remove("light", "dark");
// Apply mode class
root.classList.add(theme.mode);
// Apply CSS custom properties with proper mapping
Object.entries(theme.colors).forEach(([key, value]) => {
// Convert camelCase to kebab-case and map to CSS variables
const cssVarMap: Record<string, string> = {
background: "--background",
foreground: "--foreground",
card: "--card",
cardForeground: "--card-foreground",
popover: "--popover",
popoverForeground: "--popover-foreground",
primary: "--primary",
primaryForeground: "--primary-foreground",
secondary: "--secondary",
secondaryForeground: "--secondary-foreground",
muted: "--muted",
mutedForeground: "--muted-foreground",
accent: "--accent",
accentForeground: "--accent-foreground",
destructive: "--destructive",
border: "--border",
input: "--input",
ring: "--ring",
chart1: "--chart-1",
chart2: "--chart-2",
chart3: "--chart-3",
chart4: "--chart-4",
chart5: "--chart-5",
sidebar: "--sidebar",
sidebarForeground: "--sidebar-foreground",
sidebarPrimary: "--sidebar-primary",
sidebarPrimaryForeground: "--sidebar-primary-foreground",
sidebarAccent: "--sidebar-accent",
sidebarAccentForeground: "--sidebar-accent-foreground",
sidebarBorder: "--sidebar-border",
sidebarRing: "--sidebar-ring",
};
const cssVar = cssVarMap[key];
if (cssVar) {
root.style.setProperty(cssVar, value);
}
});
};
useEffect(() => {
applyTheme(currentTheme);
}, [currentTheme]);
useEffect(() => {
const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)");
const handleChange = () => {
if (mode === "system") {
// Theme will be recalculated due to getCurrentTheme dependency
const newTheme = getCurrentTheme();
applyTheme(newTheme);
}
};
mediaQuery.addEventListener("change", handleChange);
return () => mediaQuery.removeEventListener("change", handleChange);
}, [mode, currentLightTheme, currentDarkTheme]);
const setTheme = (themeId: string) => {
const theme = themes.find((t) => t.id === themeId);
if (!theme) return;
// Update the appropriate theme based on the theme's mode
if (theme.mode === "light") {
setCurrentLightThemeId(themeId);
localStorage.setItem(storageKey + "-light", themeId);
} else {
setCurrentDarkThemeId(themeId);
localStorage.setItem(storageKey + "-dark", themeId);
}
};
const handleSetMode = (newMode: ThemeMode) => {
setMode(newMode);
localStorage.setItem(storageKey + "-mode", newMode);
};
const addCustomTheme = (
themeData: Omit<ThemeDefinition, "id" | "isCustom">,
) => {
const newTheme: ThemeDefinition = {
...themeData,
id: `custom-${Date.now()}`,
isCustom: true,
};
const updatedThemes = [...themes, newTheme];
setThemes(updatedThemes);
// Save only custom themes to localStorage
const customThemes = updatedThemes.filter((t) => t.isCustom);
localStorage.setItem(storageKey + "-themes", JSON.stringify(customThemes));
};
const removeCustomTheme = (themeId: string) => {
const updatedThemes = themes.filter((t) => t.id !== themeId);
setThemes(updatedThemes);
// If removing current theme, switch to default
if (currentLightThemeId === themeId) {
const defaultLight = updatedThemes.find(
(t) => t.mode === "light" && !t.isCustom,
);
if (defaultLight) {
setCurrentLightThemeId(defaultLight.id);
localStorage.setItem(storageKey + "-light", defaultLight.id);
}
}
if (currentDarkThemeId === themeId) {
const defaultDark = updatedThemes.find(
(t) => t.mode === "dark" && !t.isCustom,
);
if (defaultDark) {
setCurrentDarkThemeId(defaultDark.id);
localStorage.setItem(storageKey + "-dark", defaultDark.id);
}
}
// Save only custom themes to localStorage
const customThemes = updatedThemes.filter((t) => t.isCustom);
localStorage.setItem(storageKey + "-themes", JSON.stringify(customThemes));
};
const getThemesForMode = (targetMode: "light" | "dark") => {
return themes.filter((t) => t.mode === targetMode);
};
const value = {
mode,
currentTheme,
currentLightTheme,
currentDarkTheme,
themes,
setMode: handleSetMode,
setTheme,
addCustomTheme,
removeCustomTheme,
getThemesForMode,
};
return (
<ThemeProviderContext.Provider {...props} value={value}>
{children}
</ThemeProviderContext.Provider>
);
}
export const useTheme = () => {
const context = useContext(ThemeProviderContext);
if (context === undefined)
throw new Error("useTheme must be used within a ThemeProvider");
return context;
};