import React, { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, } from 'react'; import { authApi } from './api'; import { useAuth } from './auth'; import { THEME_PALETTES, type ThemePalette } from '../types'; import { RainyWeather } from '../components/layout/RainyWeather'; import { SnowWeather } from '../components/layout/SnowWeather'; import { AuraSky } from '../components/layout/AuraSky'; import { PixelRunner } from '../components/layout/PixelRunner'; /* * Darstellung der Oberflaeche. Sie hat zwei unabhaengige Achsen: * * Farbwelt (palette) perinco | pixel | outdoor | cyberpunk | rainy | snow | aura | mobile * -> Klasse theme-* am * Modus (mode) hell | dunkel -> Klasse dark am * * Beide gehoeren zum **Benutzer** und nicht zum Browser: sie stehen in * pulse_users.theme und pulse_users.theme_mode. Wer sich an einem anderen Rechner * anmeldet, findet seine Wahl wieder, und melden sich zwei Leute an derselben * Maschine an, sieht jeder seine eigene. * * Der localStorage bleibt als Zwischenspeicher daneben liegen, aber nicht als * Quelle der Wahrheit: er wird vom Inline-Skript in index.html gelesen, um die * Klassen vor dem ersten Frame zu setzen. Ohne das blitzt beim Laden das * Standarddesign auf, weil das Bundle erst danach laeuft. Er hat genau eine * sichtbare Folge: melden sich zwei Benutzer an demselben Rechner an, sieht der * zweite fuer einen Augenblick das Design des ersten. */ export type ThemeMode = 'light' | 'dark'; /** null = der Systemeinstellung folgen */ type ModePreference = ThemeMode | null; const MODE_KEY = 'pulse_theme'; const PALETTE_KEY = 'pulse_palette'; const DEFAULT_PALETTE: ThemePalette = 'perinco'; interface ThemeContextValue { /** Tatsaechlich aktive Darstellung. */ theme: ThemeMode; /** Gespeicherte Wahl des Modus; null bedeutet "folgt dem System". */ preference: ModePreference; /** Aktive Farbwelt. */ palette: ThemePalette; /** Schaltet explizit auf den jeweils anderen Modus. */ toggle: () => void; /** Zurueck zur Systemeinstellung. */ useSystem: () => void; /** Farbwelt wechseln. */ setPalette: (palette: ThemePalette) => void; } const ThemeContext = createContext(null); function readCachedMode(): ModePreference { try { const stored = localStorage.getItem(MODE_KEY); return stored === 'light' || stored === 'dark' ? stored : null; } catch { // Privater Modus ohne localStorage - dann eben ohne Zwischenspeicher. return null; } } function readCachedPalette(): ThemePalette { try { const stored = localStorage.getItem(PALETTE_KEY); return THEME_PALETTES.includes(stored as ThemePalette) ? (stored as ThemePalette) : DEFAULT_PALETTE; } catch { return DEFAULT_PALETTE; } } /** Schreibt in den Zwischenspeicher fuers naechste Laden; Fehler sind egal. */ function cache(key: string, value: string | null) { try { if (value === null) localStorage.removeItem(key); else localStorage.setItem(key, value); } catch { // Nicht speicherbar - dann blitzt beim naechsten Laden eben kurz das // Standarddesign auf. Die Wahl selbst liegt in der Datenbank. } } function systemTheme(): ThemeMode { return typeof window !== 'undefined' && window.matchMedia?.('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; } /** * Setzt die Klassen, an denen die Farbwerte in index.css haengen. Das * Inline-Skript in index.html macht dasselbe schon vor dem ersten Frame. * * Die Klassen aller anderen Farbwelten werden entfernt, nicht nur die eigene * gesetzt: sonst bliebe beim Wechsel die vorige stehen und zwei Themes * ueberlagerten sich. */ function applyTheme(mode: ThemeMode, palette: ThemePalette) { const root = document.documentElement; root.classList.toggle('dark', mode === 'dark'); root.style.colorScheme = mode; for (const candidate of THEME_PALETTES) { root.classList.toggle(`theme-${candidate}`, candidate === palette); } } export const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => { const { user, updateUser } = useAuth(); const [preference, setPreference] = useState(() => readCachedMode()); const [palette, setPaletteState] = useState(() => readCachedPalette()); const [resolvedSystem, setResolvedSystem] = useState(() => systemTheme()); const theme = preference ?? resolvedSystem; useEffect(() => { applyTheme(theme, palette); }, [theme, palette]); // Ohne eigene Wahl folgt die App einer Aenderung im Betriebssystem sofort. useEffect(() => { const query = window.matchMedia?.('(prefers-color-scheme: dark)'); if (!query) return; const onChange = (event: MediaQueryListEvent) => setResolvedSystem(event.matches ? 'dark' : 'light'); query.addEventListener('change', onChange); return () => query.removeEventListener('change', onChange); }, []); /* * Die Wahl des angemeldeten Benutzers uebernehmen - einmal je Anmeldung. * * Der Merker ist wichtig: `user` aendert sich auch, wenn jemand sein Foto * tauscht oder gerade das Design umstellt (updateUser). Ohne ihn wuerde * dieser Effekt bei jeder solchen Aenderung erneut laufen und koennte eine * gerade getroffene Wahl mit dem alten Stand ueberschreiben. */ const syncedForUser = useRef(null); useEffect(() => { if (!user) { // Abmelden: die Anzeige bleibt, wie sie ist. Der naechste Benutzer // bekommt beim Anmelden seine eigene Wahl. syncedForUser.current = null; return; } if (syncedForUser.current === user.id) return; syncedForUser.current = user.id; const nextPalette = THEME_PALETTES.includes(user.theme as ThemePalette) ? (user.theme as ThemePalette) : DEFAULT_PALETTE; const nextMode: ModePreference = user.theme_mode === 'light' || user.theme_mode === 'dark' ? user.theme_mode : null; setPaletteState(nextPalette); setPreference(nextMode); cache(PALETTE_KEY, nextPalette === DEFAULT_PALETTE ? null : nextPalette); cache(MODE_KEY, nextMode); }, [user]); /* * Speichern am Benutzer. Die Anzeige wechselt sofort und wartet nicht auf das * Backend - ein Design, das nach dem Klick eine halbe Sekunde ueberlegt, * fuehlt sich kaputt an. Schlaegt das Speichern fehl, gilt die Wahl fuer * diese Sitzung und ist nach dem naechsten Anmelden weg; das ist harmlos und * deshalb keine Fehlermeldung wert. */ const persist = useCallback( (changes: { theme?: ThemePalette | null; theme_mode?: ModePreference }) => { if (!user) return; authApi .updateMe(changes) .then(({ user: saved }) => updateUser(saved)) .catch((err) => { console.warn('[Design] Konnte nicht gespeichert werden:', err); }); }, [user, updateUser] ); const storeMode = useCallback( (next: ModePreference) => { setPreference(next); cache(MODE_KEY, next); persist({ theme_mode: next }); }, [persist] ); const setPalette = useCallback( (next: ThemePalette) => { setPaletteState(next); cache(PALETTE_KEY, next === DEFAULT_PALETTE ? null : next); // Der Standard wird als null gespeichert, damit "keine Wahl getroffen" // und "ausdruecklich perinco" in der Datenbank dasselbe bedeuten. persist({ theme: next === DEFAULT_PALETTE ? null : next }); }, [persist] ); const value = useMemo( () => ({ theme, preference, palette, toggle: () => storeMode(theme === 'dark' ? 'light' : 'dark'), useSystem: () => storeMode(null), setPalette, }), [theme, preference, palette, storeMode, setPalette] ); return ( {children} {/* Wetter und Himmel gehoeren zum Theme, nicht zu einer Ansicht. */} {palette === 'rainy' && } {palette === 'snow' && } {palette === 'aura' && } {palette === 'pixel' && } ); }; export function useTheme(): ThemeContextValue { const ctx = useContext(ThemeContext); if (!ctx) throw new Error('useTheme muss innerhalb von verwendet werden.'); return ctx; }