160 lines
4.3 KiB
TypeScript
160 lines
4.3 KiB
TypeScript
// Path: Frontend/src/theme/runtime.ts
|
|
|
|
import { defaultThemePresetMeta, defaultThemePresetPath, resolveThemePresetPath } from "./presets";
|
|
import { createCssVariableMap, isThemeModeName, validateThemeDefinition, type ThemeDefinition, type ThemeModeName } from "./schema";
|
|
|
|
export type Theme = ThemeModeName;
|
|
|
|
export const THEME_STORAGE_KEY = "theme";
|
|
export const THEME_PRESET_STORAGE_KEY = "theme-preset";
|
|
export const DEFAULT_THEME: Theme = "light";
|
|
|
|
let activeThemeDefinition: ThemeDefinition | null = null;
|
|
let themeInitializationPromise: Promise<ThemeDefinition | null> | null = null;
|
|
|
|
const canUseDom = (): boolean => typeof document !== "undefined";
|
|
|
|
const canUseStorage = (): boolean => typeof localStorage !== "undefined";
|
|
|
|
const canUseMatchMedia = (): boolean => typeof window !== "undefined" && typeof window.matchMedia === "function";
|
|
|
|
const getRootElement = (): HTMLElement | null => {
|
|
return canUseDom() ? document.documentElement : null;
|
|
};
|
|
|
|
const persistThemePreset = (themeDefinition: ThemeDefinition): void => {
|
|
if (!canUseStorage()) {
|
|
return;
|
|
}
|
|
|
|
localStorage.setItem(THEME_PRESET_STORAGE_KEY, themeDefinition.id);
|
|
};
|
|
|
|
const setDocumentThemeMode = (theme: Theme): void => {
|
|
const rootElement = getRootElement();
|
|
|
|
rootElement?.setAttribute("data-theme", theme);
|
|
|
|
if (rootElement) {
|
|
rootElement.style.colorScheme = theme;
|
|
}
|
|
|
|
if (canUseStorage()) {
|
|
localStorage.setItem(THEME_STORAGE_KEY, theme);
|
|
}
|
|
};
|
|
|
|
const applyThemeVariables = (themeDefinition: ThemeDefinition, theme: Theme): void => {
|
|
const rootElement = getRootElement();
|
|
|
|
if (!rootElement) {
|
|
return;
|
|
}
|
|
|
|
const variableMap = createCssVariableMap(themeDefinition, theme);
|
|
|
|
for (const [name, value] of Object.entries(variableMap)) {
|
|
rootElement.style.setProperty(name, value);
|
|
}
|
|
};
|
|
|
|
export const resolvePreferredTheme = (): Theme => {
|
|
const stored = canUseStorage() ? localStorage.getItem(THEME_STORAGE_KEY) : null;
|
|
|
|
if (isThemeModeName(stored)) {
|
|
return stored;
|
|
}
|
|
|
|
if (canUseMatchMedia()) {
|
|
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : DEFAULT_THEME;
|
|
}
|
|
|
|
return DEFAULT_THEME;
|
|
};
|
|
|
|
export const getDocumentTheme = (): Theme => {
|
|
const theme = getRootElement()?.getAttribute("data-theme");
|
|
|
|
return isThemeModeName(theme) ? theme : resolvePreferredTheme();
|
|
};
|
|
|
|
export const applyThemeDefinition = (themeDefinition: ThemeDefinition, theme: Theme): void => {
|
|
activeThemeDefinition = themeDefinition;
|
|
persistThemePreset(themeDefinition);
|
|
setDocumentThemeMode(theme);
|
|
applyThemeVariables(themeDefinition, theme);
|
|
};
|
|
|
|
export const resolvePreferredThemePresetId = (): string => {
|
|
if (!canUseStorage()) {
|
|
return defaultThemePresetMeta.id;
|
|
}
|
|
|
|
const stored = localStorage.getItem(THEME_PRESET_STORAGE_KEY);
|
|
|
|
if (stored && resolveThemePresetPath(stored)) {
|
|
return stored;
|
|
}
|
|
|
|
return defaultThemePresetMeta.id;
|
|
};
|
|
|
|
export const resolvePreferredThemePresetPath = (): string => {
|
|
const presetId = resolvePreferredThemePresetId();
|
|
|
|
return resolveThemePresetPath(presetId) ?? defaultThemePresetPath;
|
|
};
|
|
|
|
export const initializeThemeRuntime = async (): Promise<ThemeDefinition | null> => {
|
|
if (typeof window === "undefined") {
|
|
return null;
|
|
}
|
|
|
|
if (activeThemeDefinition) {
|
|
applyThemeDefinition(activeThemeDefinition, getDocumentTheme());
|
|
return activeThemeDefinition;
|
|
}
|
|
|
|
if (!themeInitializationPromise) {
|
|
themeInitializationPromise = (async (): Promise<ThemeDefinition | null> => {
|
|
try {
|
|
const response = await fetch(resolvePreferredThemePresetPath(), {
|
|
headers: {
|
|
Accept: "application/json",
|
|
},
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new Error(`Theme preset request failed with status ${response.status}.`);
|
|
}
|
|
|
|
const candidate = (await response.json()) as unknown;
|
|
const result = validateThemeDefinition(candidate);
|
|
|
|
if (!result.success) {
|
|
throw new Error(result.errors.join(" "));
|
|
}
|
|
|
|
applyThemeDefinition(result.data, getDocumentTheme());
|
|
|
|
return result.data;
|
|
} catch (error) {
|
|
console.error("Failed to initialize theme runtime.", error);
|
|
return null;
|
|
} finally {
|
|
themeInitializationPromise = null;
|
|
}
|
|
})();
|
|
}
|
|
|
|
return themeInitializationPromise;
|
|
};
|
|
|
|
export const setTheme = (theme: Theme): void => {
|
|
setDocumentThemeMode(theme);
|
|
|
|
if (activeThemeDefinition) {
|
|
applyThemeVariables(activeThemeDefinition, theme);
|
|
}
|
|
};
|