feat(ui): drei Farbwelten je hell und dunkel, gewaehlt pro Benutzer
Neben dem Design des Hauses ("perinco") gibt es "Pixel Art" und
"Outdoor", jedes in hell und dunkel. Die Wahl steht neben dem
Hell/Dunkel-Schalter im Kopf der App.
Sie gehoert dem BENUTZER und nicht dem Browser: crm_users.theme und
crm_users.theme_mode, gesetzt ueber PATCH /api/auth/me, das jeder fuer
sich selbst darf. Wer sich an einem anderen Rechner anmeldet, findet sein
Design wieder, und zwei Benutzer an derselben Maschine kommen sich nicht
in die Quere. Der localStorage bleibt als Zwischenspeicher daneben, damit
das Inline-Skript in index.html die Klassen vor dem ersten Frame setzen
kann - ohne das blitzt beim Laden das Standarddesign auf. Das hat eine
sichtbare Folge: melden sich zwei Leute an demselben Rechner an, sieht
der zweite fuer einen Augenblick das Design des ersten.
Der ThemeProvider steht deshalb in main.tsx jetzt unter dem AuthProvider
- er liest den Benutzer.
Keine Komponente wurde umgefaerbt. Im Markup steht nirgends eine feste
Farbe, sondern durchgaengig ink-*, brand-*, canvas und surface; Tailwind
v4 loest auch --radius-*, --shadow-* und die Schriften ueber Variablen
auf. Ein Theme tauscht also nur Werte, und deshalb kann Pixel Art alle
380 abgerundeten Ecken eckig machen, ohne eine Klasse anzufassen.
Zwei Dinge waren Voraussetzung:
- Der helle Block eines Themes braucht :not(.dark). html.theme-x hat
eine Klasse mehr in der Spezifitaet als .dark und wuerde den
Dunkelmodus sonst ueberstimmen.
- Auf brand-500/600 steht 69 Mal weisse Schrift. Jedes Theme braucht
dort einen ausreichend dunklen Ton - ein helles Pastell waere
unlesbar.
Die Pixelschrift (Silkscreen) sitzt nur in --font-display, also in den
Ueberschriften und den Feldbeschriftungen; labelClass tragt dafuer die
neue Klasse "field-label". Der Fliesstext bleibt Nunito Sans: die Listen
haben bis zu acht Spalten bei 11-13 px, und eine Pixelschrift waere darin
nicht mehr zu lesen. Outdoor bringt Cabin fuer beides mit.
server/test/userFields.test.mjs prueft die Werte und dass die Liste der
Farbwelten in userFields.js und src/types.ts nicht auseinanderlaeuft -
sonst zeigt die Auswahl ein Design an, dessen Speichern das Backend
abweist, und niemand weiss warum.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
5a0f154851
commit
87759a8a24
+10
-3
@@ -10,6 +10,7 @@ import type {
|
||||
LeadConnection,
|
||||
Opportunity,
|
||||
Role,
|
||||
ThemePalette,
|
||||
} from '../types';
|
||||
|
||||
const TOKEN_KEY = 'perinco_crm_token';
|
||||
@@ -90,10 +91,16 @@ export const authApi = {
|
||||
}),
|
||||
me: () => request<{ user: AppUser }>('/auth/me'),
|
||||
/**
|
||||
* Eigene Terminfarbe und eigenes Foto setzen - ohne Adminrechte.
|
||||
* null setzt zurueck: Farbe auf die Palettenfarbe, Foto auf die Initiale.
|
||||
* Eigene Terminfarbe, eigenes Foto und eigenes Design setzen - ohne
|
||||
* Adminrechte. null setzt zurueck: Farbe auf die Palettenfarbe, Foto auf die
|
||||
* Initiale, Design auf perinco, Modus auf "folgt dem System".
|
||||
*/
|
||||
updateMe: (input: { color?: string | null; avatar?: string | null }) =>
|
||||
updateMe: (input: {
|
||||
color?: string | null;
|
||||
avatar?: string | null;
|
||||
theme?: ThemePalette | null;
|
||||
theme_mode?: 'light' | 'dark' | null;
|
||||
}) =>
|
||||
request<{ user: AppUser }>('/auth/me', { method: 'PATCH', body: JSON.stringify(input) }),
|
||||
changePassword: (current_password: string, new_password: string) =>
|
||||
request<{ ok: true }>('/auth/change-password', {
|
||||
|
||||
+166
-33
@@ -1,35 +1,95 @@
|
||||
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from 'react';
|
||||
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';
|
||||
|
||||
/*
|
||||
* Darstellung der Oberflaeche. Sie hat zwei unabhaengige Achsen:
|
||||
*
|
||||
* Farbwelt (palette) perinco | pixel | outdoor -> Klasse theme-* am <html>
|
||||
* Modus (mode) hell | dunkel -> Klasse dark am <html>
|
||||
*
|
||||
* Beide gehoeren zum **Benutzer** und nicht zum Browser: sie stehen in
|
||||
* crm_users.theme und crm_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';
|
||||
|
||||
export type Theme = 'light' | 'dark';
|
||||
/** null = der Systemeinstellung folgen */
|
||||
type Preference = Theme | null;
|
||||
type ModePreference = ThemeMode | null;
|
||||
|
||||
const STORAGE_KEY = 'perinco_crm_theme';
|
||||
const MODE_KEY = 'perinco_crm_theme';
|
||||
const PALETTE_KEY = 'perinco_crm_palette';
|
||||
|
||||
const DEFAULT_PALETTE: ThemePalette = 'perinco';
|
||||
|
||||
interface ThemeContextValue {
|
||||
/** Tatsaechlich aktive Darstellung. */
|
||||
theme: Theme;
|
||||
/** Gespeicherte Wahl; null bedeutet "folgt dem System". */
|
||||
preference: Preference;
|
||||
/** Schaltet explizit auf das jeweils andere Thema. */
|
||||
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 readPreference(): Preference {
|
||||
function readCachedMode(): ModePreference {
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
const stored = localStorage.getItem(MODE_KEY);
|
||||
return stored === 'light' || stored === 'dark' ? stored : null;
|
||||
} catch {
|
||||
// Privater Modus ohne localStorage - dann eben ohne gespeicherte Wahl.
|
||||
// Privater Modus ohne localStorage - dann eben ohne Zwischenspeicher.
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function systemTheme(): Theme {
|
||||
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'
|
||||
@@ -37,25 +97,35 @@ function systemTheme(): Theme {
|
||||
}
|
||||
|
||||
/**
|
||||
* Setzt die Klasse, an der die dunklen Farbwerte in index.css haengen. Ein
|
||||
* Inline-Skript in index.html macht dasselbe schon vor dem ersten Frame, damit
|
||||
* es beim Laden nicht weiss aufblitzt.
|
||||
* 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(theme: Theme) {
|
||||
function applyTheme(mode: ThemeMode, palette: ThemePalette) {
|
||||
const root = document.documentElement;
|
||||
root.classList.toggle('dark', theme === 'dark');
|
||||
root.style.colorScheme = theme;
|
||||
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 [preference, setPreference] = useState<Preference>(() => readPreference());
|
||||
const [resolvedSystem, setResolvedSystem] = useState<Theme>(() => systemTheme());
|
||||
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);
|
||||
}, [theme]);
|
||||
applyTheme(theme, palette);
|
||||
}, [theme, palette]);
|
||||
|
||||
// Ohne eigene Wahl folgt die App einer Aenderung im Betriebssystem sofort.
|
||||
useEffect(() => {
|
||||
@@ -67,24 +137,87 @@ export const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({ childre
|
||||
return () => query.removeEventListener('change', onChange);
|
||||
}, []);
|
||||
|
||||
const store = useCallback((next: Preference) => {
|
||||
setPreference(next);
|
||||
try {
|
||||
if (next === null) localStorage.removeItem(STORAGE_KEY);
|
||||
else localStorage.setItem(STORAGE_KEY, next);
|
||||
} catch {
|
||||
// Nicht speicherbar - die Wahl gilt dann nur fuer diese Sitzung.
|
||||
/*
|
||||
* 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,
|
||||
toggle: () => store(theme === 'dark' ? 'light' : 'dark'),
|
||||
useSystem: () => store(null),
|
||||
palette,
|
||||
toggle: () => storeMode(theme === 'dark' ? 'light' : 'dark'),
|
||||
useSystem: () => storeMode(null),
|
||||
setPalette,
|
||||
}),
|
||||
[theme, preference, store]
|
||||
[theme, preference, palette, storeMode, setPalette]
|
||||
);
|
||||
|
||||
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
|
||||
|
||||
Reference in New Issue
Block a user