229 lines
4.7 KiB
TypeScript
229 lines
4.7 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",
|
|
|
|
"--scrollbar-track",
|
|
"--scrollbar-thumb",
|
|
"--scrollbar-hover",
|
|
|
|
"--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);
|
|
}
|