Quellcode des ICON CRM (6)

This commit is contained in:
2026-09-14 16:55:40 +02:00
parent 4fcb34f324
commit bd810fa608
24 changed files with 3855 additions and 0 deletions
+164
View File
@@ -0,0 +1,164 @@
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;
}