Files
perinco-Pulse/src/lib/theme.tsx
T
schakal00783andClaude Opus 5.5 bea758e651 feat(theme): Theme "Mobile" - kompakte Darstellung fuers Handy
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>
2026-09-23 10:47:26 +02:00

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;
}