Files
silo-server/web/src/hooks/useTheme.tsx
T

181 lines
5.6 KiB
TypeScript

import { createContext, useContext, useEffect, useState, useCallback } from "react";
import type { ReactNode } from "react";
import type { ThemeId } from "@/lib/themes";
import { useSettings, useSetSetting } from "@/hooks/queries/settings";
import { useOptionalAuth } from "@/hooks/useAuth";
import { storage } from "@/utils/storage";
import {
getInitialTheme,
isValidTheme,
parseHighContrast,
parseTextScale,
parseTextWeight,
shouldLoadApiTheme,
} from "@/hooks/themePreferences";
import type { TextScale, TextWeight } from "@/hooks/themePreferences";
interface ThemeContextValue {
theme: ThemeId;
setTheme: (theme: ThemeId) => void;
previewTheme: (theme: ThemeId) => void;
resetPreviewTheme: () => void;
textScale: TextScale;
setTextScale: (value: TextScale) => void;
textWeight: TextWeight;
setTextWeight: (value: TextWeight) => void;
highContrast: boolean;
setHighContrast: (value: boolean) => void;
}
const ThemeContext = createContext<ThemeContextValue | null>(null);
function applyThemeToDOM(theme: ThemeId): void {
document.documentElement.setAttribute("data-theme", theme);
}
function applyTextScaleToDOM(scale: TextScale): void {
document.documentElement.setAttribute("data-text-scale", scale);
}
function applyTextWeightToDOM(weight: TextWeight): void {
document.documentElement.setAttribute("data-text-weight", weight);
}
function applyHighContrastToDOM(value: boolean): void {
document.documentElement.setAttribute("data-high-contrast", value ? "true" : "false");
}
export function ThemeProvider({ children }: { children: ReactNode }) {
const [themePreference, setThemePreference] = useState<ThemeId>(getInitialTheme);
const [previewThemeState, setPreviewThemeState] = useState<ThemeId | null>(null);
const [textScalePreference, setTextScalePreference] = useState<TextScale>(() =>
parseTextScale(storage.get(storage.KEYS.UI_TEXT_SCALE)),
);
const [textWeightPreference, setTextWeightPreference] = useState<TextWeight>(() =>
parseTextWeight(storage.get(storage.KEYS.UI_TEXT_WEIGHT)),
);
const [highContrastPreference, setHighContrastPreference] = useState<boolean>(() =>
parseHighContrast(storage.get(storage.KEYS.UI_HIGH_CONTRAST)),
);
const auth = useOptionalAuth();
const loadApiTheme = shouldLoadApiTheme({
loading: auth?.loading ?? false,
user: auth?.user ? { id: auth.user.id } : null,
});
// Load persisted setting from API (profile-scoped)
const { data: apiSettings } = useSettings({ enabled: loadApiTheme });
const apiTheme = apiSettings?.ui_theme;
const apiTextScale = apiSettings?.ui_text_scale;
const apiTextWeight = apiSettings?.ui_text_weight;
const apiHighContrast = apiSettings?.ui_high_contrast;
const settingMutation = useSetSetting();
const theme =
loadApiTheme && apiTheme ? getInitialThemeFromApi(apiTheme, themePreference) : themePreference;
const textScale = loadApiTheme
? parseTextScale(apiTextScale ?? textScalePreference)
: textScalePreference;
const textWeight = loadApiTheme
? parseTextWeight(apiTextWeight ?? textWeightPreference)
: textWeightPreference;
const highContrast = loadApiTheme
? parseHighContrast(apiHighContrast ?? String(highContrastPreference))
: highContrastPreference;
useEffect(() => {
applyThemeToDOM(previewThemeState ?? theme);
}, [previewThemeState, theme]);
useEffect(() => {
applyTextScaleToDOM(textScale);
}, [textScale]);
useEffect(() => {
applyTextWeightToDOM(textWeight);
}, [textWeight]);
useEffect(() => {
applyHighContrastToDOM(highContrast);
}, [highContrast]);
const setTheme = useCallback(
(newTheme: ThemeId) => {
setPreviewThemeState(null);
setThemePreference(newTheme);
applyThemeToDOM(newTheme);
storage.set(storage.KEYS.THEME, newTheme);
settingMutation.mutate({ key: "ui_theme", value: newTheme });
},
[settingMutation],
);
const previewTheme = useCallback((newTheme: ThemeId) => {
setPreviewThemeState(newTheme);
}, []);
const resetPreviewTheme = useCallback(() => {
setPreviewThemeState(null);
}, []);
const setTextScale = useCallback(
(value: TextScale) => {
setTextScalePreference(value);
applyTextScaleToDOM(value);
storage.set(storage.KEYS.UI_TEXT_SCALE, value);
settingMutation.mutate({ key: "ui_text_scale", value });
},
[settingMutation],
);
const setTextWeight = useCallback(
(value: TextWeight) => {
setTextWeightPreference(value);
applyTextWeightToDOM(value);
storage.set(storage.KEYS.UI_TEXT_WEIGHT, value);
settingMutation.mutate({ key: "ui_text_weight", value });
},
[settingMutation],
);
const setHighContrast = useCallback(
(value: boolean) => {
setHighContrastPreference(value);
applyHighContrastToDOM(value);
storage.set(storage.KEYS.UI_HIGH_CONTRAST, String(value));
settingMutation.mutate({ key: "ui_high_contrast", value: String(value) });
},
[settingMutation],
);
return (
<ThemeContext
value={{
theme,
setTheme,
previewTheme,
resetPreviewTheme,
textScale,
setTextScale,
textWeight,
setTextWeight,
highContrast,
setHighContrast,
}}
>
{children}
</ThemeContext>
);
}
export function useTheme(): ThemeContextValue {
const ctx = useContext(ThemeContext);
if (!ctx) throw new Error("useTheme must be used within ThemeProvider");
return ctx;
}
function getInitialThemeFromApi(apiTheme: string | null, fallback: ThemeId): ThemeId {
if (!apiTheme || !isValidTheme(apiTheme)) return fallback;
return storage.get(storage.KEYS.THEME) !== apiTheme ? apiTheme : fallback;
}