Perinco-Farben, aber rund 16 % dichter: --spacing 0.21rem statt 0.25rem, kleinere Ueberschriften (lg bis 5xl), Kopfleiste 3.5rem, flachere Schatten. text-sm/base bleiben, damit iOS beim Tippen in Felder nicht zoomt. Dunkel mit echtem Schwarz fuer OLED. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
245 lines
8.2 KiB
TypeScript
245 lines
8.2 KiB
TypeScript
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 <html>
|
|
* Modus (mode) hell | dunkel -> Klasse dark am <html>
|
|
*
|
|
* 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<ThemeContextValue | null>(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<ModePreference>(() => readCachedMode());
|
|
const [palette, setPaletteState] = useState<ThemePalette>(() => readCachedPalette());
|
|
const [resolvedSystem, setResolvedSystem] = useState<ThemeMode>(() => 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<number | null>(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<ThemeContextValue>(
|
|
() => ({
|
|
theme,
|
|
preference,
|
|
palette,
|
|
toggle: () => storeMode(theme === 'dark' ? 'light' : 'dark'),
|
|
useSystem: () => storeMode(null),
|
|
setPalette,
|
|
}),
|
|
[theme, preference, palette, storeMode, setPalette]
|
|
);
|
|
|
|
return (
|
|
<ThemeContext.Provider value={value}>
|
|
{children}
|
|
{/* Wetter und Himmel gehoeren zum Theme, nicht zu einer Ansicht. */}
|
|
{palette === 'rainy' && <RainyWeather />}
|
|
{palette === 'snow' && <SnowWeather />}
|
|
{palette === 'aura' && <AuraSky />}
|
|
{palette === 'pixel' && <PixelRunner />}
|
|
</ThemeContext.Provider>
|
|
);
|
|
};
|
|
|
|
export function useTheme(): ThemeContextValue {
|
|
const ctx = useContext(ThemeContext);
|
|
if (!ctx) throw new Error('useTheme muss innerhalb von <ThemeProvider> verwendet werden.');
|
|
return ctx;
|
|
}
|