import React, { createContext, ReactElement, useContext, useEffect, useState, } from "react"; type BuiltInThemes = "light" | "dark"; export interface Theme { name: BuiltInThemes | "custom"; palette: Palette; } export type SetThemeInput = BuiltInThemes | Partial; export const PALETTE_NAMES = [ "--primary-background", "--secondary-background", "--primary-accent", "--primary-accent-soft", "--primary-accent-light", "--primary-accent-lighter", "--primary-accent-lightest", "--secondary-accent", "--secondary-accent-light", "--primary-heading", "--primary-title", "--primary-subtitle", "--secondary-subtitle", "--primary-text", "--text", "--text-light", "--author-text", "--sidebar-text", "--mini-event-card-text", "--form-invalid", "--warning-background", "--warning-text", "--input-background", "--input-placeholder-text", "--input-text", "--icon", "--code-background", "--button-background", "--footer-background", "--card-background", "--dark-card-background", "--table-header", "--table-section", "--navbar-page-overlay", "--link", "--link-hover", "--blue-gradient", "--border", "--marker", ] as const; export const emptyPalette = PALETTE_NAMES.reduce( (partial, varName) => ({ ...partial, [varName]: "#c4e0f8", }), {} as Palette ); const ThemeContext = createContext< | { theme: Theme; setTheme(input: SetThemeInput): void; save(): void; clearSaved(): void; } | undefined >(undefined); export interface Props { children?: ReactElement; } export function ThemeProvider(props: Props) { const update = useForcedUpdate(); const [themeName, setThemeName] = useState(undefined); const setTheme = (input: SetThemeInput) => { if (typeof input === "string") { PALETTE_NAMES.forEach((name) => document.body.style.setProperty(name, "var(--" + input + name + ")") ); savePalette(getCurrentPalette(input)); setThemeName(input); } else { const properties = Object.keys(input) as PaletteNames[]; properties.forEach((property) => document.body.style.setProperty( property, input[property]?.trim() ?? null ) ); setThemeName("custom"); } update(); }; useEffect(() => { const customPalette = getSavedPalette(); if (customPalette == null) { const prefersDark = window.matchMedia( "(prefers-color-scheme: dark)" ).matches; if (prefersDark) { setTheme("dark"); } else { setTheme("light"); } } else { setTheme(customPalette); setThemeName("custom"); } // eslint-disable-next-line react-hooks/exhaustive-deps }, []); return ( savePalette(getCurrentPalette(themeName)), clearSaved: clearSavedPalette, } } > {props.children} ); } export function useThemeContext() { return useContext(ThemeContext); } export type PaletteNames = typeof PALETTE_NAMES[number]; export type Palette = { [key in PaletteNames]: string; }; function themePropertyName( name: PaletteNames, themeName: BuiltInThemes ): string { return `--${themeName}${name}`; } function getCurrentPalette(themeName: string) { const styles = getComputedStyle(document.body); if (themeName === "light" || themeName === "dark") { return PALETTE_NAMES.reduce( (partial, name) => ({ ...partial, [name]: styles .getPropertyValue(themePropertyName(name, themeName)) .trim(), }), {} as Palette ); } return PALETTE_NAMES.reduce( (partial, name) => ({ ...partial, [name]: styles.getPropertyValue(name).trim(), }), {} as Palette ); } function useForcedUpdate() { const [fakeState, setFakeState] = useState(true); return () => setFakeState(!fakeState); } const STORAGE_KEY = "csc-theme-palette"; function getSavedPalette() { const raw = localStorage.getItem(STORAGE_KEY); return raw == null ? undefined : (JSON.parse(raw) as Palette); } function savePalette(palette: Palette) { localStorage.setItem(STORAGE_KEY, JSON.stringify(palette)); } function clearSavedPalette() { localStorage.removeItem(STORAGE_KEY); }