www-new/components/Theme.tsx

225 lines
4.6 KiB
TypeScript

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<Palette>;
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<Theme["name"] | undefined>(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 (
<ThemeContext.Provider
value={
themeName == null
? undefined
: {
theme: {
name: themeName,
get palette() {
return getCurrentPalette(themeName);
},
},
setTheme,
save: () => savePalette(getCurrentPalette(themeName)),
clearSaved: clearSavedPalette,
}
}
>
{props.children}
</ThemeContext.Provider>
);
}
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);
}