165 lines
5.7 KiB
TypeScript
165 lines
5.7 KiB
TypeScript
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
|
import { authApi } from './api';
|
|
import { useAuth } from './auth';
|
|
import { THEME_PALETTES, type ThemePalette } from '../types';
|
|
|
|
/*
|
|
* Modus (hell/dunkel) und Design.
|
|
*
|
|
* Die Wahl liegt doppelt: im localStorage, damit das Inline-Skript in
|
|
* index.html sie vor dem ersten Frame setzen kann, und am Benutzer in der
|
|
* Datenbank, damit sie auf jedem Geraet gilt. Ohne Wahl folgt der Modus der
|
|
* Systemeinstellung.
|
|
*/
|
|
|
|
export type ThemeName = ThemePalette;
|
|
export type ThemeMode = 'light' | 'dark';
|
|
|
|
const MODE_KEY = 'icon_crm_theme_mode';
|
|
const THEME_KEY = 'icon_crm_theme';
|
|
|
|
interface ThemeContextValue {
|
|
theme: ThemeName;
|
|
mode: ThemeMode;
|
|
/** true, solange der Benutzer nichts gewaehlt hat und das System entscheidet. */
|
|
followsSystem: boolean;
|
|
setTheme: (theme: ThemeName) => void;
|
|
setMode: (mode: ThemeMode) => void;
|
|
toggleMode: () => void;
|
|
/** Zurueck auf "folgt dem System". */
|
|
resetMode: () => void;
|
|
}
|
|
|
|
const ThemeContext = createContext<ThemeContextValue | null>(null);
|
|
|
|
function systemPrefersDark(): boolean {
|
|
return window.matchMedia?.('(prefers-color-scheme: dark)').matches ?? false;
|
|
}
|
|
|
|
function readStored<T extends string>(key: string): T | null {
|
|
try {
|
|
return (localStorage.getItem(key) as T | null) ?? null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function store(key: string, value: string | null) {
|
|
try {
|
|
if (value === null) localStorage.removeItem(key);
|
|
else localStorage.setItem(key, value);
|
|
} catch {
|
|
/* Privater Modus oder gesperrte Website-Daten - dann gilt nur die Sitzung. */
|
|
}
|
|
}
|
|
|
|
export const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
|
const { user, updateUser } = useAuth();
|
|
const [storedMode, setStoredMode] = useState<ThemeMode | null>(() => readStored<ThemeMode>(MODE_KEY));
|
|
const [theme, setThemeState] = useState<ThemeName>(() => {
|
|
// Ein Wert aus einer früheren Fassung (etwa ein entferntes Design) darf
|
|
// nicht dazu führen, dass eine Klasse gesetzt wird, die es nicht gibt.
|
|
const stored = readStored<ThemeName>(THEME_KEY);
|
|
return stored && THEME_PALETTES.includes(stored) ? stored : 'icon';
|
|
});
|
|
const [systemDark, setSystemDark] = useState(systemPrefersDark);
|
|
|
|
// Die Wahl des angemeldeten Benutzers gewinnt gegen den lokalen Speicher:
|
|
// sie gilt auf jedem Geraet.
|
|
useEffect(() => {
|
|
if (!user) return;
|
|
if (user.theme_mode && user.theme_mode !== storedMode) {
|
|
setStoredMode(user.theme_mode);
|
|
store(MODE_KEY, user.theme_mode);
|
|
}
|
|
/*
|
|
* Auch der Wert vom Server wird geprüft. Ein Design, das aus der Liste
|
|
* geflogen ist, steht weiter am Konto - und hat die Anwendung schon einmal
|
|
* beim Start abstürzen lassen (14.09.2026, "duke").
|
|
*/
|
|
if (user.theme && THEME_PALETTES.includes(user.theme as ThemeName) && user.theme !== theme) {
|
|
setThemeState(user.theme as ThemeName);
|
|
store(THEME_KEY, user.theme);
|
|
}
|
|
// Absichtlich nur an user gebunden - sonst schriebe jede lokale Aenderung
|
|
// sich selbst wieder zurueck.
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [user?.id, user?.theme, user?.theme_mode]);
|
|
|
|
useEffect(() => {
|
|
const media = window.matchMedia?.('(prefers-color-scheme: dark)');
|
|
if (!media) return;
|
|
const listener = (event: MediaQueryListEvent) => setSystemDark(event.matches);
|
|
media.addEventListener('change', listener);
|
|
return () => media.removeEventListener('change', listener);
|
|
}, []);
|
|
|
|
const mode: ThemeMode = storedMode ?? (systemDark ? 'dark' : 'light');
|
|
|
|
useEffect(() => {
|
|
const root = document.documentElement;
|
|
root.classList.toggle('dark', mode === 'dark');
|
|
/*
|
|
* Jede theme-* Klasse abräumen, nicht nur die bekannten: das Inline-Skript
|
|
* in index.html setzt sie aus dem localStorage, und dort kann ein längst
|
|
* entfernter Name stehen. Der bliebe sonst für immer am <html> hängen.
|
|
*/
|
|
for (const name of [...root.classList]) {
|
|
if (name.startsWith('theme-')) root.classList.remove(name);
|
|
}
|
|
if (theme !== 'icon') root.classList.add(`theme-${theme}`);
|
|
}, [mode, theme]);
|
|
|
|
const persist = useCallback(
|
|
async (body: { theme?: ThemeName; theme_mode?: ThemeMode | null }) => {
|
|
if (!user) return;
|
|
try {
|
|
const { user: updated } = await authApi.updateMe(body);
|
|
updateUser(updated);
|
|
} catch {
|
|
/* Die Darstellung steht lokal schon richtig - ein Fehler beim Sichern
|
|
darf den Wechsel nicht zurueckdrehen. */
|
|
}
|
|
},
|
|
[user, updateUser]
|
|
);
|
|
|
|
const setMode = useCallback(
|
|
(next: ThemeMode) => {
|
|
setStoredMode(next);
|
|
store(MODE_KEY, next);
|
|
void persist({ theme_mode: next });
|
|
},
|
|
[persist]
|
|
);
|
|
|
|
const value = useMemo<ThemeContextValue>(
|
|
() => ({
|
|
theme,
|
|
mode,
|
|
followsSystem: storedMode === null,
|
|
setTheme: (next: ThemeName) => {
|
|
setThemeState(next);
|
|
store(THEME_KEY, next);
|
|
void persist({ theme: next });
|
|
},
|
|
setMode,
|
|
toggleMode: () => setMode(mode === 'dark' ? 'light' : 'dark'),
|
|
resetMode: () => {
|
|
setStoredMode(null);
|
|
store(MODE_KEY, null);
|
|
void persist({ theme_mode: null });
|
|
},
|
|
}),
|
|
[theme, mode, storedMode, setMode, persist]
|
|
);
|
|
|
|
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
|
|
};
|
|
|
|
export function useTheme(): ThemeContextValue {
|
|
const ctx = useContext(ThemeContext);
|
|
if (!ctx) throw new Error('useTheme muss innerhalb von <ThemeProvider> verwendet werden.');
|
|
return ctx;
|
|
}
|