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
@@ -35,6 +35,8 @@ erDiagram
|
|||||||
enum role "admin | user"
|
enum role "admin | user"
|
||||||
varchar color "Terminfarbe, NULL = aus Id abgeleitet"
|
varchar color "Terminfarbe, NULL = aus Id abgeleitet"
|
||||||
longtext avatar "Data-URL"
|
longtext avatar "Data-URL"
|
||||||
|
varchar theme "Farbwelt, NULL = perinco"
|
||||||
|
varchar theme_mode "light | dark, NULL = folgt dem System"
|
||||||
varchar password_hash "bcrypt"
|
varchar password_hash "bcrypt"
|
||||||
tinyint is_active
|
tinyint is_active
|
||||||
}
|
}
|
||||||
|
|||||||
+67
-14
@@ -120,7 +120,7 @@ src/
|
|||||||
imageCompress.ts Bilder vor dem Upload verkleinern
|
imageCompress.ts Bilder vor dem Upload verkleinern
|
||||||
components/
|
components/
|
||||||
Sidebar.tsx Menü inkl. aufklappbarer Gruppen (Kontakte, Scanner)
|
Sidebar.tsx Menü inkl. aufklappbarer Gruppen (Kontakte, Scanner)
|
||||||
Header.tsx Titel, Untertitel, Dark-Mode-Schalter
|
Header.tsx Titel, Untertitel, Design-Auswahl, Hell/Dunkel-Schalter
|
||||||
opportunities/ Kachel, Liste, Pipeline (Drag & Drop), Formular
|
opportunities/ Kachel, Liste, Pipeline (Drag & Drop), Formular
|
||||||
calendar/ Monat/Woche/Tag, Termine (Drag & Drop), @-Mentions
|
calendar/ Monat/Woche/Tag, Termine (Drag & Drop), @-Mentions
|
||||||
leads/ Kachel, Liste, Netzwerkdiagramm (d3-force),
|
leads/ Kachel, Liste, Netzwerkdiagramm (d3-force),
|
||||||
@@ -132,26 +132,79 @@ src/
|
|||||||
ui/ Modal, Avatare, Diktatfeld
|
ui/ Modal, Avatare, Diktatfeld
|
||||||
```
|
```
|
||||||
|
|
||||||
## Dark Mode ohne `dark:`-Varianten
|
## Themes ohne `dark:`-Varianten
|
||||||
|
|
||||||
Die Klasse `dark` auf `<html>` tauscht in `src/index.css` nur die Werte der
|
Die Darstellung hat **zwei unabhängige Achsen**, beide als Klasse am `<html>`:
|
||||||
Variablen `canvas`, `surface`, `ink-*`, `brand-*` und der getönten Statusfarben.
|
|
||||||
Die Komponenten benutzen durchgängig diese Skalen und folgen damit automatisch -
|
| Achse | Klasse | Werte |
|
||||||
kein `dark:` im Markup.
|
| --- | --- | --- |
|
||||||
|
| Farbwelt | `theme-perinco`, `theme-pixel`, `theme-outdoor` | perinco ist der Standard |
|
||||||
|
| Modus | `dark` bzw. keine | hell, dunkel, oder der Systemeinstellung folgen |
|
||||||
|
|
||||||
|
`src/index.css` tauscht darin nur Variablenwerte: `canvas`, `surface`, `ink-*`,
|
||||||
|
`brand-*`, die getönten Statusfarben, dazu `--radius-*`, `--shadow-*` und die
|
||||||
|
Schriften. Die Komponenten benutzen durchgängig diese Skalen und folgen damit
|
||||||
|
automatisch - **kein `dark:` und keine themespezifische Klasse im Markup**.
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
flowchart LR
|
flowchart LR
|
||||||
T["Umschalter im Header"] --> P["localStorage<br/>perinco_crm_theme"]
|
U["Benutzer wählt<br/>im Header"] --> DB["crm_users.theme<br/>crm_users.theme_mode"]
|
||||||
P --> H["class 'dark' auf html"]
|
U --> LS["localStorage<br/>nur Zwischenspeicher"]
|
||||||
H --> V["Variablen in index.css<br/>canvas, surface, ink-*, brand-*"]
|
DB -->|"beim Anmelden"| H["Klassen am html<br/>theme-*, dark"]
|
||||||
V --> U["Utility-Klassen der Komponenten"]
|
LS -->|"vor dem ersten Frame"| H
|
||||||
|
H --> V["Variablen in index.css<br/>Farben, Radien, Schatten, Schrift"]
|
||||||
|
V --> W["Utility-Klassen der Komponenten"]
|
||||||
SYS["Systemeinstellung"] -.->|"ohne eigene Wahl"| H
|
SYS["Systemeinstellung"] -.->|"ohne eigene Wahl"| H
|
||||||
```
|
```
|
||||||
|
|
||||||
Ein Inline-Skript in `index.html` setzt die Klasse vor dem ersten Frame, sonst
|
Die Wahl gehört dem **Benutzer**, nicht dem Browser: sie steht in `crm_users`
|
||||||
blitzt beim Laden die helle Variante auf. Drei Werte bleiben in beiden Modi
|
und kommt mit `/api/auth/me`. Wer sich an einem anderen Rechner anmeldet, findet
|
||||||
gleich: `ink-950` (liegt nur unter Dialog-Overlays), `text-white` (steht immer auf
|
sein Design wieder, und zwei Benutzer an derselben Maschine kommen sich nicht in
|
||||||
farbigen Flächen) und `brand-500` (das CI-Grün der Buttons).
|
die Quere. Der `localStorage` bleibt daneben als Zwischenspeicher liegen, damit
|
||||||
|
das Inline-Skript in `index.html` die Klassen vor dem ersten Frame setzen kann -
|
||||||
|
sonst blitzt beim Laden das Standarddesign auf. Das hat genau eine sichtbare
|
||||||
|
Folge: melden sich zwei Leute an demselben Rechner an, sieht der zweite für einen
|
||||||
|
Augenblick das Design des ersten.
|
||||||
|
|
||||||
|
Der `ThemeProvider` steht deshalb in `main.tsx` **unter** dem `AuthProvider` -
|
||||||
|
er liest den Benutzer.
|
||||||
|
|
||||||
|
### Was ein neues Theme können muss
|
||||||
|
|
||||||
|
Die Rollen der Skalen sind über die ganze App gleich; ein Theme, das sie
|
||||||
|
verletzt, macht Text unlesbar. Nachgezählt im Markup:
|
||||||
|
|
||||||
|
| Skala | Rolle | Verwendungen |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `ink-50`, `ink-100` | Flächen und Hover | 102 × `bg-` |
|
||||||
|
| `ink-200` | **Rahmen** | 224 × `border-`, 1 × `bg-` |
|
||||||
|
| `ink-300`–`ink-900` | Text; `ink-400` trägt die Hinweistexte | 241 × `text-ink-400` |
|
||||||
|
| `ink-950` | nur unter Dialog-Overlays, bleibt immer dunkel | 5 × `bg-` |
|
||||||
|
| `brand-50`, `brand-100` | getönte Flächen | 70 × `bg-` |
|
||||||
|
| `brand-200`–`brand-400` | Rahmen | 76 × `border-` |
|
||||||
|
| `brand-500`, `brand-600` | gefüllte Knöpfe - **weisse Schrift darauf** | 100 × `bg-` |
|
||||||
|
| `brand-600`–`brand-800` | Text auf getönten Flächen | 108 × `text-` |
|
||||||
|
|
||||||
|
Daraus folgen zwei harte Regeln:
|
||||||
|
|
||||||
|
1. **`brand-500` und `brand-600` müssen dunkel genug für weisse Schrift sein.**
|
||||||
|
`text-white` steht 69 × im Markup, immer auf einer Markenfläche. Ein helles
|
||||||
|
Theme kann dort kein Pastell verwenden.
|
||||||
|
2. **Der helle Block eines Themes braucht `:not(.dark)`.** `html.theme-x` hat
|
||||||
|
eine Klasse mehr in der Spezifität als `.dark` und würde den Dunkelmodus
|
||||||
|
sonst überstimmen.
|
||||||
|
|
||||||
|
Was ein Theme über Farben hinaus ändern kann: Eckenradien (`--radius-*`, weil
|
||||||
|
Tailwind v4 `rounded-xl` zu `var(--radius-xl)` auflöst), Schatten (`--shadow-*`)
|
||||||
|
und die Schriften (`--font-sans` für Fliesstext, `--font-display` für
|
||||||
|
Überschriften und Feldbeschriftungen - letztere tragen dafür die Klasse
|
||||||
|
`field-label`). `rounded-full` ist die eine Ausnahme: dort schreibt Tailwind
|
||||||
|
einen festen Wert, deshalb hat Pixel Art dafür eine eigene Regel.
|
||||||
|
|
||||||
|
Drei Werte bleiben in jedem Theme und Modus gleich: `ink-950` (liegt nur unter
|
||||||
|
Dialog-Overlays), `text-white` (steht immer auf farbigen Flächen) und die 39
|
||||||
|
festen Hex-Werte für Phasenfarben, Benutzerfarben und Diagramme - die folgen
|
||||||
|
bewusst keinem Theme, weil sie Bedeutung tragen und nicht Dekoration sind.
|
||||||
|
|
||||||
Eine bewusste Ausnahme: der **Mailinhalt** bleibt auch im Dunkelmodus auf weissem
|
Eine bewusste Ausnahme: der **Mailinhalt** bleibt auch im Dunkelmodus auf weissem
|
||||||
Grund. Fremdes Mail-HTML bringt seine eigenen Farben mit; ein dunkler Hintergrund
|
Grund. Fremdes Mail-HTML bringt seine eigenen Farben mit; ein dunkler Hintergrund
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ die Scanner-Einstellungen. Alles andere ist für beide Rollen gleich.
|
|||||||
|
|
||||||
```
|
```
|
||||||
┌──────────────┬───────────────────────────────────────────┐
|
┌──────────────┬───────────────────────────────────────────┐
|
||||||
│ Kategorien │ Titel der Ansicht [Hell/Dunkel] │
|
│ Kategorien │ Titel der Ansicht [Design][Hell/Dunkel] │
|
||||||
│ Dashboard │ │
|
│ Dashboard │ │
|
||||||
│ Akquise ▾ │ │
|
│ Akquise ▾ │ │
|
||||||
│ Eingang │ │
|
│ Eingang │ │
|
||||||
@@ -64,8 +64,25 @@ die Scanner-Einstellungen. Alles andere ist für beide Rollen gleich.
|
|||||||
das ist auch die Ansicht, mit der die Anwendung startet.
|
das ist auch die Ansicht, mit der die Anwendung startet.
|
||||||
* Der Knopf mit den drei Strichen oben links klappt das Menü auf Symbolbreite
|
* Der Knopf mit den drei Strichen oben links klappt das Menü auf Symbolbreite
|
||||||
zusammen - hilfreich auf kleinen Bildschirmen.
|
zusammen - hilfreich auf kleinen Bildschirmen.
|
||||||
* Rechts oben schaltet zwischen hellem und dunklem Design. Ein **Doppelklick**
|
* Rechts oben stehen zwei Schalter. Der rechte wechselt zwischen **hell und
|
||||||
auf den Schalter folgt wieder der Systemeinstellung.
|
dunkel**; ein **Doppelklick** darauf folgt wieder der Systemeinstellung.
|
||||||
|
* Links daneben steht das **Design**. Es gibt drei zur Wahl, jedes in hell und
|
||||||
|
dunkel:
|
||||||
|
|
||||||
|
| Design | Aussehen |
|
||||||
|
| --- | --- |
|
||||||
|
| **perinco** | Das Design des Hauses: Blattgrün auf neutralem Grau |
|
||||||
|
| **Pixel Art** | 8-bit: eckige Kanten, harte Schatten, Pixelschrift in den Überschriften |
|
||||||
|
| **Outdoor** | Wanderkarte: Sandpapier, Bergsee-Türkis, weiche Ecken |
|
||||||
|
|
||||||
|
> **Die Wahl gehört dir, nicht dem Rechner.** Sie wird an deinem Benutzerkonto
|
||||||
|
> gespeichert. Meldest du dich an einem anderen Rechner an, ist dein Design
|
||||||
|
> wieder da - und wenn Kai an derselben Maschine arbeitet, sieht er weiter
|
||||||
|
> sein eigenes. Wer nichts umstellt, bleibt bei perinco.
|
||||||
|
|
||||||
|
Beim Fließtext bleibt Pixel Art bewusst bei der normalen Schrift: die Listen
|
||||||
|
haben bis zu acht Spalten, und eine Pixelschrift wäre darin nicht mehr zu
|
||||||
|
lesen.
|
||||||
|
|
||||||

|

|
||||||
* Die Menüpunkte **Akquise**, **Opportunities**, **Kontakte**, **Mails** und
|
* Die Menüpunkte **Akquise**, **Opportunities**, **Kontakte**, **Mails** und
|
||||||
|
|||||||
@@ -220,8 +220,12 @@ Browser-Download.
|
|||||||
* **Deutsch** in Bezeichnern, Kommentaren, Meldungen. Fachbegriffe bleiben
|
* **Deutsch** in Bezeichnern, Kommentaren, Meldungen. Fachbegriffe bleiben
|
||||||
englisch (`Match`, `Opportunity`, `Lead`).
|
englisch (`Match`, `Opportunity`, `Lead`).
|
||||||
* **Kommentare erklären das Warum.** Was der Code tut, steht im Code.
|
* **Kommentare erklären das Warum.** Was der Code tut, steht im Code.
|
||||||
* **Kein `dark:` im Markup.** Nur die Skalen `ink-*`, `brand-*`, `canvas`,
|
* **Kein `dark:` und keine Theme-Klasse im Markup.** Nur die Skalen `ink-*`,
|
||||||
`surface` benutzen, den Rest macht `index.css`.
|
`brand-*`, `canvas`, `surface` benutzen, den Rest macht `index.css`. Das ist
|
||||||
|
nicht Kosmetik: daran hängen drei Farbwelten in je zwei Modi, und eine feste
|
||||||
|
Farbe im Markup bricht fünf davon. Die Rollen der Skalen und die Regeln für ein
|
||||||
|
neues Theme stehen in [05 Frontend](05-frontend.md#themes-ohne-dark-varianten) -
|
||||||
|
wichtigste davon: auf `brand-500`/`brand-600` steht weisse Schrift.
|
||||||
* **Gemeinsame Klassen** statt kopierter Utility-Ketten: `inputClass`,
|
* **Gemeinsame Klassen** statt kopierter Utility-Ketten: `inputClass`,
|
||||||
`labelClass` aus `components/leads/leadUi.tsx`, dazu `cardClass`,
|
`labelClass` aus `components/leads/leadUi.tsx`, dazu `cardClass`,
|
||||||
`primaryButtonClass` aus `components/scanner/scannerUi.tsx`.
|
`primaryButtonClass` aus `components/scanner/scannerUi.tsx`.
|
||||||
|
|||||||
@@ -247,7 +247,7 @@ Uhrzeit, dieses Jahr Tag und Monat, sonst mit Jahr.
|
|||||||
| Datei | Hält | Eigenheit |
|
| Datei | Hält | Eigenheit |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| `auth.tsx` | Benutzer, `isAdmin`, `login`, `logout`, `updateUser` | prüft beim Start das gespeicherte Token gegen `/auth/me` |
|
| `auth.tsx` | Benutzer, `isAdmin`, `login`, `logout`, `updateUser` | prüft beim Start das gespeicherte Token gegen `/auth/me` |
|
||||||
| `theme.tsx` | hell/dunkel/System | Wahl in `localStorage`, Doppelklick löst sie wieder |
|
| `theme.tsx` | Farbwelt (perinco/Pixel Art/Outdoor) und Modus (hell/dunkel/System) | Beides gehört dem Benutzer und steht in `crm_users`; `localStorage` ist nur der Zwischenspeicher fürs Inline-Skript in `index.html`. Doppelklick auf den Modus-Schalter folgt wieder dem System |
|
||||||
| `navigation.tsx` | `currentView`, `goTo(view, { tab, leadId })` | erspart es, Callbacks durch vier Ebenen zu reichen; `leadId` öffnet den Kontakt direkt |
|
| `navigation.tsx` | `currentView`, `goTo(view, { tab, leadId })` | erspart es, Callbacks durch vier Ebenen zu reichen; `leadId` öffnet den Kontakt direkt |
|
||||||
| `whisper.tsx` | Modellstatus, Worker, Fortschritt, `transcribe` | ein Worker für die ganze App |
|
| `whisper.tsx` | Modellstatus, Worker, Fortschritt, `transcribe` | ein Worker für die ganze App |
|
||||||
|
|
||||||
|
|||||||
+25
-7
@@ -7,25 +7,43 @@
|
|||||||
<meta name="robots" content="noindex, nofollow" />
|
<meta name="robots" content="noindex, nofollow" />
|
||||||
<title>perinco CRM</title>
|
<title>perinco CRM</title>
|
||||||
<script>
|
<script>
|
||||||
// Setzt das Thema vor dem ersten Frame - ohne das blitzt beim Laden die
|
// Setzt Design und Modus vor dem ersten Frame - ohne das blitzt beim Laden
|
||||||
// helle Variante auf, weil das Bundle erst danach laeuft.
|
// die helle perinco-Variante auf, weil das Bundle erst danach laeuft.
|
||||||
|
//
|
||||||
|
// Die verbindliche Wahl steht am Benutzer in der Datenbank; hier liegt nur
|
||||||
|
// die zuletzt benutzte als Zwischenspeicher, denn vor dem ersten Frame ist
|
||||||
|
// noch niemand angemeldet. Melden sich zwei Benutzer an demselben Rechner
|
||||||
|
// an, sieht der zweite fuer einen Moment das Design des ersten und dann
|
||||||
|
// sein eigenes - das ist der Preis dafuer, dass es gar nicht blitzt.
|
||||||
(function () {
|
(function () {
|
||||||
try {
|
try {
|
||||||
var stored = localStorage.getItem('perinco_crm_theme');
|
var mode = localStorage.getItem('perinco_crm_theme');
|
||||||
var dark =
|
var dark =
|
||||||
stored === 'dark' ||
|
mode === 'dark' ||
|
||||||
(stored !== 'light' && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
(mode !== 'light' && window.matchMedia('(prefers-color-scheme: dark)').matches);
|
||||||
if (dark) document.documentElement.classList.add('dark');
|
if (dark) document.documentElement.classList.add('dark');
|
||||||
document.documentElement.style.colorScheme = dark ? 'dark' : 'light';
|
document.documentElement.style.colorScheme = dark ? 'dark' : 'light';
|
||||||
|
|
||||||
|
var palette = localStorage.getItem('perinco_crm_palette');
|
||||||
|
// Whitelist, damit ein manipulierter Eintrag keine Klasse setzt.
|
||||||
|
if (palette === 'pixel' || palette === 'outdoor') {
|
||||||
|
document.documentElement.classList.add('theme-' + palette);
|
||||||
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
/* localStorage gesperrt - dann eben im hellen Design starten */
|
/* localStorage gesperrt - dann eben im hellen perinco-Design starten */
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
</script>
|
</script>
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<!--
|
||||||
|
Nunito Sans traegt perinco und Pixel Art (Fliesstext), Cabin das Outdoor-
|
||||||
|
Design, Silkscreen die Ueberschriften von Pixel Art. Alle drei in einer
|
||||||
|
Anfrage: ein Nachladen beim Umschalten wuerde die Schrift sichtbar
|
||||||
|
springen lassen, und Silkscreen wiegt nur wenige Kilobyte.
|
||||||
|
-->
|
||||||
<link
|
<link
|
||||||
href="https://fonts.googleapis.com/css2?family=Nunito+Sans:ital,opsz,wght@0,6..12,300;0,6..12,400;0,6..12,600;0,6..12,700;0,6..12,800&display=swap"
|
href="https://fonts.googleapis.com/css2?family=Nunito+Sans:ital,opsz,wght@0,6..12,300;0,6..12,400;0,6..12,600;0,6..12,700;0,6..12,800&family=Cabin:wght@400;500;600;700&family=Silkscreen:wght@400;700&display=swap"
|
||||||
rel="stylesheet"
|
rel="stylesheet"
|
||||||
/>
|
/>
|
||||||
</head>
|
</head>
|
||||||
|
|||||||
+1
-1
@@ -13,7 +13,7 @@
|
|||||||
"dev": "node --watch src/server.js",
|
"dev": "node --watch src/server.js",
|
||||||
"migrate": "node src/migrate.js",
|
"migrate": "node src/migrate.js",
|
||||||
"scanner:import": "node src/scanner/importLegacy.js",
|
"scanner:import": "node src/scanner/importLegacy.js",
|
||||||
"test": "node test/mail.test.mjs && node test/acquisition.test.mjs"
|
"test": "node test/mail.test.mjs && node test/acquisition.test.mjs && node test/userFields.test.mjs"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"bcryptjs": "^2.4.3",
|
"bcryptjs": "^2.4.3",
|
||||||
|
|||||||
@@ -2,7 +2,12 @@ import express from 'express';
|
|||||||
import bcrypt from 'bcryptjs';
|
import bcrypt from 'bcryptjs';
|
||||||
import { pool } from '../db.js';
|
import { pool } from '../db.js';
|
||||||
import { signToken, requireAuth } from '../middleware/auth.js';
|
import { signToken, requireAuth } from '../middleware/auth.js';
|
||||||
import { normalizeAvatar, normalizeColor } from '../userFields.js';
|
import {
|
||||||
|
normalizeAvatar,
|
||||||
|
normalizeColor,
|
||||||
|
normalizeTheme,
|
||||||
|
normalizeThemeMode,
|
||||||
|
} from '../userFields.js';
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
|
|
||||||
@@ -15,6 +20,9 @@ function publicUser(row) {
|
|||||||
role: row.role,
|
role: row.role,
|
||||||
color: row.color ?? null,
|
color: row.color ?? null,
|
||||||
avatar: row.avatar ?? null,
|
avatar: row.avatar ?? null,
|
||||||
|
// Persoenliche Darstellung; null heisst "Standard" bzw. "folgt dem System".
|
||||||
|
theme: row.theme ?? null,
|
||||||
|
theme_mode: row.theme_mode ?? null,
|
||||||
is_active: Boolean(row.is_active),
|
is_active: Boolean(row.is_active),
|
||||||
created_at: row.created_at,
|
created_at: row.created_at,
|
||||||
updated_at: row.updated_at,
|
updated_at: row.updated_at,
|
||||||
@@ -59,10 +67,11 @@ router.get('/me', requireAuth, (req, res) => {
|
|||||||
res.json({ user: publicUser(req.user) });
|
res.json({ user: publicUser(req.user) });
|
||||||
});
|
});
|
||||||
|
|
||||||
// PATCH /api/auth/me { color?, avatar? }
|
// PATCH /api/auth/me { color?, avatar?, theme?, theme_mode? }
|
||||||
// Jeder darf seine eigene Terminfarbe und sein Foto setzen - dafuer braucht
|
// Jeder darf seine eigene Terminfarbe, sein Foto und seine Darstellung setzen -
|
||||||
// es keine Adminrechte. null bzw. "" setzt das Feld zurueck (Farbe: automatisch
|
// dafuer braucht es keine Adminrechte. null bzw. "" setzt das Feld zurueck
|
||||||
// aus der Palette, Foto: Initiale).
|
// (Farbe: automatisch aus der Palette, Foto: Initiale, Design: perinco, Modus:
|
||||||
|
// folgt dem System).
|
||||||
router.patch('/me', requireAuth, async (req, res, next) => {
|
router.patch('/me', requireAuth, async (req, res, next) => {
|
||||||
const fields = [];
|
const fields = [];
|
||||||
const values = [];
|
const values = [];
|
||||||
@@ -71,6 +80,8 @@ router.patch('/me', requireAuth, async (req, res, next) => {
|
|||||||
for (const [field, normalize] of [
|
for (const [field, normalize] of [
|
||||||
['color', normalizeColor],
|
['color', normalizeColor],
|
||||||
['avatar', normalizeAvatar],
|
['avatar', normalizeAvatar],
|
||||||
|
['theme', normalizeTheme],
|
||||||
|
['theme_mode', normalizeThemeMode],
|
||||||
]) {
|
]) {
|
||||||
if (req.body?.[field] === undefined) continue;
|
if (req.body?.[field] === undefined) continue;
|
||||||
const result = normalize(req.body[field]);
|
const result = normalize(req.body[field]);
|
||||||
|
|||||||
@@ -31,7 +31,8 @@ function normalizeEmail(value) {
|
|||||||
router.get('/directory', async (req, res, next) => {
|
router.get('/directory', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const [rows] = await pool.query(
|
const [rows] = await pool.query(
|
||||||
`SELECT id, username, email, name, role, color, avatar, is_active, created_at, updated_at
|
`SELECT id, username, email, name, role, color, avatar, theme, theme_mode, is_active,
|
||||||
|
created_at, updated_at
|
||||||
FROM crm_users ORDER BY name ASC`
|
FROM crm_users ORDER BY name ASC`
|
||||||
);
|
);
|
||||||
res.json(rows.map(publicUser));
|
res.json(rows.map(publicUser));
|
||||||
@@ -65,7 +66,8 @@ function duplicateMessage(err) {
|
|||||||
router.get('/', async (req, res, next) => {
|
router.get('/', async (req, res, next) => {
|
||||||
try {
|
try {
|
||||||
const [rows] = await pool.query(
|
const [rows] = await pool.query(
|
||||||
`SELECT id, username, email, name, role, color, avatar, is_active, created_at, updated_at
|
`SELECT id, username, email, name, role, color, avatar, theme, theme_mode, is_active,
|
||||||
|
created_at, updated_at
|
||||||
FROM crm_users ORDER BY role DESC, name ASC`
|
FROM crm_users ORDER BY role DESC, name ASC`
|
||||||
);
|
);
|
||||||
res.json(rows.map(publicUser));
|
res.json(rows.map(publicUser));
|
||||||
@@ -105,7 +107,8 @@ router.post('/', async (req, res, next) => {
|
|||||||
[username, email, name, role, optional.color, optional.avatar, hash, isActive]
|
[username, email, name, role, optional.color, optional.avatar, hash, isActive]
|
||||||
);
|
);
|
||||||
const [rows] = await pool.query(
|
const [rows] = await pool.query(
|
||||||
`SELECT id, username, email, name, role, color, avatar, is_active, created_at, updated_at
|
`SELECT id, username, email, name, role, color, avatar, theme, theme_mode, is_active,
|
||||||
|
created_at, updated_at
|
||||||
FROM crm_users WHERE id = ?`,
|
FROM crm_users WHERE id = ?`,
|
||||||
[result.insertId]
|
[result.insertId]
|
||||||
);
|
);
|
||||||
@@ -194,7 +197,8 @@ router.patch('/:id', async (req, res, next) => {
|
|||||||
await pool.query(`UPDATE crm_users SET ${fields.join(', ')} WHERE id = ?`, values);
|
await pool.query(`UPDATE crm_users SET ${fields.join(', ')} WHERE id = ?`, values);
|
||||||
|
|
||||||
const [rows] = await pool.query(
|
const [rows] = await pool.query(
|
||||||
`SELECT id, username, email, name, role, color, avatar, is_active, created_at, updated_at
|
`SELECT id, username, email, name, role, color, avatar, theme, theme_mode, is_active,
|
||||||
|
created_at, updated_at
|
||||||
FROM crm_users WHERE id = ?`,
|
FROM crm_users WHERE id = ?`,
|
||||||
[id]
|
[id]
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -24,6 +24,16 @@ ALTER TABLE crm_users ADD COLUMN IF NOT EXISTS color VARCHAR(9) NULL AFTER role;
|
|||||||
-- Benutzerfoto als Data-URL oder externe URL - wie bei crm_leads.avatar.
|
-- Benutzerfoto als Data-URL oder externe URL - wie bei crm_leads.avatar.
|
||||||
ALTER TABLE crm_users ADD COLUMN IF NOT EXISTS avatar LONGTEXT NULL AFTER color;
|
ALTER TABLE crm_users ADD COLUMN IF NOT EXISTS avatar LONGTEXT NULL AFTER color;
|
||||||
|
|
||||||
|
-- Persoenliche Darstellung. Sie haengt am Benutzer und nicht am Browser: wer
|
||||||
|
-- sich an einem anderen Rechner anmeldet, findet sein Design wieder, und zwei
|
||||||
|
-- Benutzer am selben Rechner kommen sich nicht in die Quere.
|
||||||
|
-- theme = Farbwelt ('perinco', 'pixel', 'outdoor'); NULL = 'perinco'
|
||||||
|
-- theme_mode = 'light' oder 'dark'; NULL = der Systemeinstellung folgen
|
||||||
|
-- Bewusst VARCHAR und kein ENUM: ein weiteres Theme soll eine Zeile im
|
||||||
|
-- Frontend sein und keine Datenbankmigration. Geprueft wird in userFields.js.
|
||||||
|
ALTER TABLE crm_users ADD COLUMN IF NOT EXISTS theme VARCHAR(32) NULL AFTER avatar;
|
||||||
|
ALTER TABLE crm_users ADD COLUMN IF NOT EXISTS theme_mode VARCHAR(16) NULL AFTER theme;
|
||||||
|
|
||||||
CREATE TABLE IF NOT EXISTS crm_leads (
|
CREATE TABLE IF NOT EXISTS crm_leads (
|
||||||
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
salutation VARCHAR(32) NULL,
|
salutation VARCHAR(32) NULL,
|
||||||
|
|||||||
@@ -34,3 +34,39 @@ export function normalizeAvatar(value) {
|
|||||||
}
|
}
|
||||||
return { value: avatar };
|
return { value: avatar };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Persoenliche Darstellung.
|
||||||
|
*
|
||||||
|
* Die Liste steht hier und nicht in der Datenbank (kein ENUM): ein weiteres
|
||||||
|
* Theme soll ein Block in src/index.css und eine Zeile hier sein, keine
|
||||||
|
* Migration. Das Gegenstueck fuer das Frontend steht in src/types.ts - wer hier
|
||||||
|
* etwas ergaenzt, muss dort mitziehen (server/test/userFields.test.mjs prueft,
|
||||||
|
* dass beide Listen gleich bleiben).
|
||||||
|
*
|
||||||
|
* Ein unbekannter Wert wird abgewiesen und nicht stillschweigend auf den
|
||||||
|
* Standard gesetzt: sonst faellt ein Tippfehler im Frontend nie auf, die
|
||||||
|
* Oberflaeche zeigt weiter das alte Design und niemand weiss, warum.
|
||||||
|
*/
|
||||||
|
export const THEMES = ['perinco', 'pixel', 'outdoor'];
|
||||||
|
export const THEME_MODES = ['light', 'dark'];
|
||||||
|
|
||||||
|
/** Farbwelt; null bzw. '' bedeutet "perinco", der Standard. */
|
||||||
|
export function normalizeTheme(value) {
|
||||||
|
if (value === null || value === undefined || String(value).trim() === '') return { value: null };
|
||||||
|
const theme = String(value).trim().toLowerCase();
|
||||||
|
if (!THEMES.includes(theme)) {
|
||||||
|
return { error: `Unbekanntes Design (erlaubt: ${THEMES.join(', ')}).` };
|
||||||
|
}
|
||||||
|
return { value: theme };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Hell oder dunkel; null bzw. '' bedeutet "der Systemeinstellung folgen". */
|
||||||
|
export function normalizeThemeMode(value) {
|
||||||
|
if (value === null || value === undefined || String(value).trim() === '') return { value: null };
|
||||||
|
const mode = String(value).trim().toLowerCase();
|
||||||
|
if (!THEME_MODES.includes(mode)) {
|
||||||
|
return { error: `Unbekannter Modus (erlaubt: ${THEME_MODES.join(', ')}).` };
|
||||||
|
}
|
||||||
|
return { value: mode };
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,67 @@
|
|||||||
|
/*
|
||||||
|
* Pruefungen der Benutzerfelder: laeuft mit `npm test` im Verzeichnis server/.
|
||||||
|
*
|
||||||
|
* Der eigentliche Zweck ist der letzte Block: die Liste der Farbwelten steht
|
||||||
|
* zweimal - hier in userFields.js (das Backend weist Unbekanntes ab) und in
|
||||||
|
* src/types.ts (das Frontend baut daraus die Auswahl). Laufen die beiden
|
||||||
|
* auseinander, faellt es sonst niemandem auf: das Auswahlfeld zeigt ein Design
|
||||||
|
* an, das Speichern schlaegt fehl, und die Oberflaeche bleibt beim alten
|
||||||
|
* Aussehen.
|
||||||
|
*/
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
|
||||||
|
const { normalizeTheme, normalizeThemeMode, normalizeColor, THEMES, THEME_MODES } = await import(
|
||||||
|
'../src/userFields.js'
|
||||||
|
);
|
||||||
|
|
||||||
|
let failures = 0;
|
||||||
|
|
||||||
|
function check(name, condition, detail = '') {
|
||||||
|
if (condition) {
|
||||||
|
console.log(` ok ${name}${detail ? ` - ${detail}` : ''}`);
|
||||||
|
} else {
|
||||||
|
console.log(` FAIL ${name}${detail ? ` - ${detail}` : ''}`);
|
||||||
|
failures += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('\nFarbwelt');
|
||||||
|
check('Bekanntes Design', normalizeTheme('pixel').value === 'pixel');
|
||||||
|
check('Gross geschrieben', normalizeTheme('Outdoor').value === 'outdoor');
|
||||||
|
check('Mit Leerzeichen', normalizeTheme(' perinco ').value === 'perinco');
|
||||||
|
check('Leer wird null', normalizeTheme('').value === null);
|
||||||
|
check('null bleibt null', normalizeTheme(null).value === null);
|
||||||
|
check('Unbekanntes wird abgewiesen', Boolean(normalizeTheme('barbie').error));
|
||||||
|
check(
|
||||||
|
'Die Fehlermeldung nennt die erlaubten Werte',
|
||||||
|
(normalizeTheme('barbie').error ?? '').includes('outdoor')
|
||||||
|
);
|
||||||
|
|
||||||
|
console.log('\nModus');
|
||||||
|
check('hell', normalizeThemeMode('light').value === 'light');
|
||||||
|
check('dunkel', normalizeThemeMode('dark').value === 'dark');
|
||||||
|
check('Leer heisst "folgt dem System"', normalizeThemeMode('').value === null);
|
||||||
|
check('Unbekanntes wird abgewiesen', Boolean(normalizeThemeMode('auto').error));
|
||||||
|
|
||||||
|
console.log('\nFarbe (unveraendert, als Regressionsschutz)');
|
||||||
|
check('Hex wird angenommen', normalizeColor('#6BA04A').value === '#6ba04a');
|
||||||
|
check('Kurzform wird abgewiesen', Boolean(normalizeColor('#fff').error));
|
||||||
|
|
||||||
|
console.log('\nBackend und Frontend kennen dieselben Designs');
|
||||||
|
const types = readFileSync(new URL('../../src/types.ts', import.meta.url), 'utf8');
|
||||||
|
const match = /export const THEME_PALETTES = \[([^\]]*)\]/.exec(types);
|
||||||
|
check('THEME_PALETTES in src/types.ts gefunden', Boolean(match));
|
||||||
|
if (match) {
|
||||||
|
const frontend = [...match[1].matchAll(/'([^']+)'/g)].map((entry) => entry[1]);
|
||||||
|
check(
|
||||||
|
'Beide Listen sind gleich',
|
||||||
|
JSON.stringify(frontend) === JSON.stringify(THEMES),
|
||||||
|
`Frontend ${JSON.stringify(frontend)} / Backend ${JSON.stringify(THEMES)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
check('Es gibt genau zwei Modi', THEME_MODES.length === 2);
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
failures === 0 ? '\nAlle Pruefungen bestanden.' : `\n${failures} Pruefung(en) fehlgeschlagen.`
|
||||||
|
);
|
||||||
|
process.exit(failures === 0 ? 0 : 1);
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useTheme } from '../lib/theme';
|
import { useTheme } from '../lib/theme';
|
||||||
|
import { THEME_PALETTES, THEME_PALETTE_LABELS } from '../types';
|
||||||
|
|
||||||
interface HeaderProps {
|
interface HeaderProps {
|
||||||
/** Beschriftung der Ansicht - meist ihr Name, siehe viewTitle(). */
|
/** Beschriftung der Ansicht - meist ihr Name, siehe viewTitle(). */
|
||||||
@@ -13,7 +14,7 @@ const SUN_ICON =
|
|||||||
const MOON_ICON = 'M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z';
|
const MOON_ICON = 'M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z';
|
||||||
|
|
||||||
export const Header: React.FC<HeaderProps> = ({ title, subtitle, onToggleSidebar }) => {
|
export const Header: React.FC<HeaderProps> = ({ title, subtitle, onToggleSidebar }) => {
|
||||||
const { theme, preference, toggle, useSystem } = useTheme();
|
const { theme, preference, toggle, useSystem, palette, setPalette } = useTheme();
|
||||||
const isDark = theme === 'dark';
|
const isDark = theme === 'dark';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -35,6 +36,29 @@ export const Header: React.FC<HeaderProps> = ({ title, subtitle, onToggleSidebar
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="ml-auto flex items-center gap-2">
|
<div className="ml-auto flex items-center gap-2">
|
||||||
|
{/*
|
||||||
|
Farbwelt. Ein Auswahlfeld und keine Knopfreihe: es kommen weitere
|
||||||
|
Designs dazu, und drei bis fuenf Knoepfe im Kopf wuerden den Titel der
|
||||||
|
Ansicht verdraengen. Jede Wahl gilt nur fuer den angemeldeten
|
||||||
|
Benutzer - sie steht an seinem Konto, nicht im Browser.
|
||||||
|
*/}
|
||||||
|
<label className="sr-only" htmlFor="theme-palette">
|
||||||
|
Design
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
id="theme-palette"
|
||||||
|
value={palette}
|
||||||
|
onChange={(event) => setPalette(event.target.value as typeof palette)}
|
||||||
|
title={`Design: ${THEME_PALETTE_LABELS[palette].hint}`}
|
||||||
|
className="rounded-xl border border-ink-200 bg-surface px-3 py-2 text-xs font-bold text-ink-600 outline-none transition hover:border-brand-300 hover:text-brand-700 focus:border-brand-500"
|
||||||
|
>
|
||||||
|
{THEME_PALETTES.map((option) => (
|
||||||
|
<option key={option} value={option}>
|
||||||
|
{THEME_PALETTE_LABELS[option].name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
{/* Doppelklick loest die manuelle Wahl und folgt wieder dem System. */}
|
{/* Doppelklick loest die manuelle Wahl und folgt wieder dem System. */}
|
||||||
<button
|
<button
|
||||||
onClick={toggle}
|
onClick={toggle}
|
||||||
|
|||||||
@@ -29,7 +29,13 @@ export const KindBadge: React.FC<{ kind: LeadKind; className?: string }> = ({
|
|||||||
export const inputClass =
|
export const inputClass =
|
||||||
'w-full rounded-xl border border-ink-200 bg-surface px-4 py-2.5 text-sm text-ink-900 outline-none transition focus:border-brand-500 focus:ring-2 focus:ring-brand-500/25';
|
'w-full rounded-xl border border-ink-200 bg-surface px-4 py-2.5 text-sm text-ink-900 outline-none transition focus:border-brand-500 focus:ring-2 focus:ring-brand-500/25';
|
||||||
|
|
||||||
export const labelClass = 'mb-1.5 block text-[11px] font-extrabold uppercase tracking-wider text-ink-500';
|
/*
|
||||||
|
* `field-label` ist keine Utility-Klasse, sondern ein Anker: darueber gibt ein
|
||||||
|
* Theme allen Feldbeschriftungen eine andere Schrift (siehe index.css). Ohne
|
||||||
|
* sie muesste jedes Label im Markup angefasst werden.
|
||||||
|
*/
|
||||||
|
export const labelClass =
|
||||||
|
'field-label mb-1.5 block text-[11px] font-extrabold uppercase tracking-wider text-ink-500';
|
||||||
|
|
||||||
export const Avatar: React.FC<{
|
export const Avatar: React.FC<{
|
||||||
src?: string | null;
|
src?: string | null;
|
||||||
|
|||||||
+283
@@ -9,9 +9,40 @@
|
|||||||
* installiert, wird es verwendet, sonst Nunito Sans als freies Aequivalent.
|
* installiert, wird es verwendet, sonst Nunito Sans als freies Aequivalent.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Drei Farbwelten, jede in hell und dunkel. Sie haengen an einer Klasse am
|
||||||
|
* <html> (theme-pixel, theme-outdoor); ohne Klasse gilt perinco. Der Modus
|
||||||
|
* bleibt die vorhandene Klasse "dark".
|
||||||
|
*
|
||||||
|
* Warum das ohne Aenderung an den Komponenten geht: im Markup steht nirgends
|
||||||
|
* eine feste Farbe, sondern durchgaengig ink-*, brand-*, canvas und surface -
|
||||||
|
* und Tailwind v4 loest auch Eckenradien (--radius-*), Schatten und Schriften
|
||||||
|
* ueber Variablen auf. Ein Theme tauscht also nur Werte.
|
||||||
|
*
|
||||||
|
* Die Rollen der Skalen sind ueber die ganze App gleich (nachgezaehlt, damit
|
||||||
|
* ein Theme nichts unlesbar macht):
|
||||||
|
* ink-50/100 Flaechen und Hover
|
||||||
|
* ink-200 Rahmen (224 von 225 Verwendungen)
|
||||||
|
* ink-300-900 Text, 400 traegt die Hinweistexte
|
||||||
|
* ink-950 liegt nur unter Dialog-Overlays, bleibt immer dunkel
|
||||||
|
* brand-50/100 getoente Flaechen
|
||||||
|
* brand-200-400 Rahmen
|
||||||
|
* brand-500/600 gefuellte Knoepfe - hier steht WEISSE Schrift drauf, jedes
|
||||||
|
* Theme braucht dort also einen ausreichend dunklen Ton
|
||||||
|
* brand-600-800 Text auf getoenten Flaechen
|
||||||
|
*/
|
||||||
|
|
||||||
@theme {
|
@theme {
|
||||||
--font-sans: 'Avenir Next', 'Avenir', 'Nunito Sans', ui-sans-serif, system-ui, sans-serif;
|
--font-sans: 'Avenir Next', 'Avenir', 'Nunito Sans', ui-sans-serif, system-ui, sans-serif;
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Zweite Schrift fuer Ueberschriften und Feldbeschriftungen. Bei perinco und
|
||||||
|
* Outdoor ist sie mit der Grundschrift identisch, nur "Pixel Art" setzt hier
|
||||||
|
* eine Pixelschrift ein - ohne dass ein Theme dafuer eigene Selektoren
|
||||||
|
* braucht.
|
||||||
|
*/
|
||||||
|
--font-display: 'Avenir Next', 'Avenir', 'Nunito Sans', ui-sans-serif, system-ui, sans-serif;
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* Flaechen sind bewusst semantisch benannt statt "weiss": im Dark Mode werden
|
* Flaechen sind bewusst semantisch benannt statt "weiss": im Dark Mode werden
|
||||||
* unten nur diese Variablen umgesetzt, die Utility-Klassen in den Komponenten
|
* unten nur diese Variablen umgesetzt, die Utility-Klassen in den Komponenten
|
||||||
@@ -115,6 +146,218 @@
|
|||||||
--color-red-700: #f28b8b;
|
--color-red-700: #f28b8b;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ===========================================================================
|
||||||
|
* Outdoor
|
||||||
|
*
|
||||||
|
* Wanderkarte und Bergsee: Sandpapier als Flaeche, Seeblau-Gruen als Akzent,
|
||||||
|
* warme Lehmgrautoene fuer Text. Bewusst kein zweites Blattgruen - das haette
|
||||||
|
* neben perinco wie ein Versehen gewirkt. Der Unterschied faellt an drei
|
||||||
|
* Stellen auf: die Flaeche ist warm statt neutral, die Ecken sind runder und
|
||||||
|
* die Schatten weicher.
|
||||||
|
*
|
||||||
|
* Der helle Block ist mit :not(.dark) abgegrenzt. Ohne das gewinnt er gegen
|
||||||
|
* .dark (eine Klasse mehr in der Spezifitaet) und der Dunkelmodus waere
|
||||||
|
* kaputt - genau das ist der Grund fuer die Schreibweise in allen Themes.
|
||||||
|
* =========================================================================== */
|
||||||
|
html.theme-outdoor:not(.dark) {
|
||||||
|
--color-canvas: #f2eee3;
|
||||||
|
--color-surface: #fdfbf6;
|
||||||
|
|
||||||
|
--color-brand-50: #e8f2f1;
|
||||||
|
--color-brand-100: #d2e6e3;
|
||||||
|
--color-brand-200: #a8cfca;
|
||||||
|
--color-brand-300: #74b3ac;
|
||||||
|
--color-brand-400: #3f938b;
|
||||||
|
--color-brand-500: #12736c;
|
||||||
|
--color-brand-600: #0d5d57;
|
||||||
|
--color-brand-700: #0a4a45;
|
||||||
|
--color-brand-800: #073833;
|
||||||
|
--color-brand-900: #052b27;
|
||||||
|
--color-brand-accent: #12736c;
|
||||||
|
|
||||||
|
/* Lehmgrau: dieselbe Helligkeitstreppe wie perinco, nur mit warmem Stich. */
|
||||||
|
--color-ink-50: #f4f0e6;
|
||||||
|
--color-ink-100: #e9e3d4;
|
||||||
|
--color-ink-200: #d6cdb8;
|
||||||
|
--color-ink-300: #a1977f;
|
||||||
|
/* Hinweistexte (text-ink-400, 241 Stellen) muessen auf dem Sand lesbar
|
||||||
|
bleiben - deshalb dunkler als die reine Umkehrung. */
|
||||||
|
--color-ink-400: #6f6650;
|
||||||
|
--color-ink-500: #5c5344;
|
||||||
|
--color-ink-600: #4b4436;
|
||||||
|
--color-ink-700: #3b352a;
|
||||||
|
--color-ink-800: #2b271e;
|
||||||
|
--color-ink-900: #1d1a14;
|
||||||
|
--color-ink-950: #100e0a;
|
||||||
|
|
||||||
|
/* Runder und weicher als perinco - der zweite Wiedererkennungswert. */
|
||||||
|
--radius-sm: 0.3rem;
|
||||||
|
--radius-md: 0.45rem;
|
||||||
|
--radius-lg: 0.65rem;
|
||||||
|
--radius-xl: 0.9rem;
|
||||||
|
--radius-2xl: 1.25rem;
|
||||||
|
|
||||||
|
--shadow-card: 0 2px 4px rgb(61 53 42 / 0.06), 0 8px 24px rgb(61 53 42 / 0.1);
|
||||||
|
--shadow-lift: 0 12px 32px rgb(61 53 42 / 0.16);
|
||||||
|
|
||||||
|
--font-sans: 'Cabin', 'Avenir Next', 'Nunito Sans', ui-sans-serif, system-ui, sans-serif;
|
||||||
|
--font-display: 'Cabin', 'Avenir Next', 'Nunito Sans', ui-sans-serif, system-ui, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Outdoor, dunkel: Waldnacht. Die Flaechen sind nicht neutral schwarz, sondern
|
||||||
|
* ins Tannengruen gezogen; der Text bekommt einen warmen Stich, damit es nicht
|
||||||
|
* nach kaltem Terminal aussieht.
|
||||||
|
*/
|
||||||
|
html.theme-outdoor.dark {
|
||||||
|
--color-canvas: #12170f;
|
||||||
|
--color-surface: #1a2016;
|
||||||
|
|
||||||
|
--color-brand-50: #0c2b28;
|
||||||
|
--color-brand-100: #103833;
|
||||||
|
--color-brand-200: #17544d;
|
||||||
|
--color-brand-300: #1d6d65;
|
||||||
|
--color-brand-400: #2f9187;
|
||||||
|
--color-brand-500: #14837a;
|
||||||
|
--color-brand-600: #1f9c91;
|
||||||
|
--color-brand-700: #74b3ac;
|
||||||
|
--color-brand-800: #a8cfca;
|
||||||
|
--color-brand-900: #d2e6e3;
|
||||||
|
--color-brand-accent: #1f9c91;
|
||||||
|
|
||||||
|
--color-ink-50: #1d2419;
|
||||||
|
--color-ink-100: #26301f;
|
||||||
|
--color-ink-200: #35412c;
|
||||||
|
--color-ink-300: #6f7a63;
|
||||||
|
--color-ink-400: #8e9a81;
|
||||||
|
--color-ink-500: #a7b39a;
|
||||||
|
--color-ink-600: #bdc7b2;
|
||||||
|
--color-ink-700: #d3dbca;
|
||||||
|
--color-ink-800: #e6ebe0;
|
||||||
|
--color-ink-900: #f4f7f0;
|
||||||
|
--color-ink-950: #000000;
|
||||||
|
|
||||||
|
--radius-sm: 0.3rem;
|
||||||
|
--radius-md: 0.45rem;
|
||||||
|
--radius-lg: 0.65rem;
|
||||||
|
--radius-xl: 0.9rem;
|
||||||
|
--radius-2xl: 1.25rem;
|
||||||
|
|
||||||
|
--shadow-card: 0 2px 4px rgb(0 0 0 / 0.4), 0 8px 24px rgb(0 0 0 / 0.45);
|
||||||
|
--shadow-lift: 0 12px 32px rgb(0 0 0 / 0.6);
|
||||||
|
|
||||||
|
--font-sans: 'Cabin', 'Avenir Next', 'Nunito Sans', ui-sans-serif, system-ui, sans-serif;
|
||||||
|
--font-display: 'Cabin', 'Avenir Next', 'Nunito Sans', ui-sans-serif, system-ui, sans-serif;
|
||||||
|
|
||||||
|
/* Die Statusfarben kommen aus .dark und passen dort schon - hier nur die
|
||||||
|
Pillen leicht ins Warme gezogen, damit sie nicht herausfallen. */
|
||||||
|
--color-amber-50: #2c2109;
|
||||||
|
--color-amber-200: #574112;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===========================================================================
|
||||||
|
* Pixel Art
|
||||||
|
*
|
||||||
|
* 8-bit: keine runden Ecken, harte Schatten ohne Weichzeichnung, Rahmen als
|
||||||
|
* schwarze Outlines, gesaettigtes Arcade-Gruen.
|
||||||
|
*
|
||||||
|
* Die Pixelschrift steckt nur in --font-display (Ueberschriften und
|
||||||
|
* Feldbeschriftungen). Der Fliesstext bleibt bewusst Nunito Sans: die
|
||||||
|
* Akquise-Tabelle hat acht Spalten bei 11-13 px, und eine Pixelschrift waere
|
||||||
|
* darin nicht mehr zu lesen - ein Design, das die Arbeit behindert, waere ein
|
||||||
|
* schlechtes Design.
|
||||||
|
* =========================================================================== */
|
||||||
|
html.theme-pixel:not(.dark) {
|
||||||
|
/* Deutlich dunkler als die Karten: Pixelgrafik lebt von harten Kanten, und
|
||||||
|
bei zu aehnlichen Toenen verschwimmen Karte und Grund. */
|
||||||
|
--color-canvas: #cdd8c2;
|
||||||
|
--color-surface: #f6f8f1;
|
||||||
|
|
||||||
|
--color-brand-50: #e0f2d4;
|
||||||
|
--color-brand-100: #c8e8b4;
|
||||||
|
/* Rahmen sind hier keine Andeutung, sondern Kontur - deshalb dunkel. */
|
||||||
|
--color-brand-200: #2b7a1c;
|
||||||
|
--color-brand-300: #256b18;
|
||||||
|
--color-brand-400: #35931f;
|
||||||
|
--color-brand-500: #2e8b22;
|
||||||
|
--color-brand-600: #24701a;
|
||||||
|
--color-brand-700: #1b5613;
|
||||||
|
--color-brand-800: #143f0e;
|
||||||
|
--color-brand-900: #0e2f0a;
|
||||||
|
--color-brand-accent: #2e8b22;
|
||||||
|
|
||||||
|
--color-ink-50: #eef2e8;
|
||||||
|
--color-ink-100: #dde4d4;
|
||||||
|
/* Der Kniff des Themes: ink-200 ist fast ausschliesslich Rahmen, wird hier
|
||||||
|
also zur schwarzen Outline - 224 Stellen ohne eine Zeile im Markup. */
|
||||||
|
--color-ink-200: #1f2a1a;
|
||||||
|
--color-ink-300: #5a6653;
|
||||||
|
--color-ink-400: #47523f;
|
||||||
|
--color-ink-500: #38412f;
|
||||||
|
--color-ink-600: #2c3425;
|
||||||
|
--color-ink-700: #22291c;
|
||||||
|
--color-ink-800: #182015;
|
||||||
|
--color-ink-900: #0e140c;
|
||||||
|
--color-ink-950: #000000;
|
||||||
|
|
||||||
|
--radius-sm: 0;
|
||||||
|
--radius-md: 0;
|
||||||
|
--radius-lg: 0;
|
||||||
|
--radius-xl: 0;
|
||||||
|
--radius-2xl: 0;
|
||||||
|
|
||||||
|
/* Versetzt statt weichgezeichnet - so sehen Schatten in Pixelgrafik aus. */
|
||||||
|
--shadow-card: 4px 4px 0 0 #1f2a1a;
|
||||||
|
--shadow-lift: 6px 6px 0 0 #1f2a1a;
|
||||||
|
|
||||||
|
--font-display: 'Silkscreen', 'Courier New', ui-monospace, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Pixel Art, dunkel: Arcade-Automat im dunklen Raum. Flaechen fast schwarz mit
|
||||||
|
* blauem Stich, Konturen in gedecktem Gruen (schwarze Outlines waeren auf
|
||||||
|
* schwarzem Grund unsichtbar), das Gruen der Knoepfe leuchtet kraeftiger.
|
||||||
|
*/
|
||||||
|
html.theme-pixel.dark {
|
||||||
|
--color-canvas: #0d0f14;
|
||||||
|
--color-surface: #151a22;
|
||||||
|
|
||||||
|
--color-brand-50: #0f2a0c;
|
||||||
|
--color-brand-100: #163c11;
|
||||||
|
--color-brand-200: #2c7a1f;
|
||||||
|
--color-brand-300: #35931f;
|
||||||
|
--color-brand-400: #3aa524;
|
||||||
|
--color-brand-500: #3aa524;
|
||||||
|
--color-brand-600: #49bf30;
|
||||||
|
--color-brand-700: #7fdc63;
|
||||||
|
--color-brand-800: #b0ec9c;
|
||||||
|
--color-brand-900: #d8f7cd;
|
||||||
|
--color-brand-accent: #49bf30;
|
||||||
|
|
||||||
|
--color-ink-50: #1a2029;
|
||||||
|
--color-ink-100: #232b36;
|
||||||
|
--color-ink-200: #33734a;
|
||||||
|
--color-ink-300: #6f7f92;
|
||||||
|
--color-ink-400: #93a3b5;
|
||||||
|
--color-ink-500: #adbccb;
|
||||||
|
--color-ink-600: #c2cfdb;
|
||||||
|
--color-ink-700: #d6e0ea;
|
||||||
|
--color-ink-800: #e9eff5;
|
||||||
|
--color-ink-900: #f6f9fc;
|
||||||
|
--color-ink-950: #000000;
|
||||||
|
|
||||||
|
--radius-sm: 0;
|
||||||
|
--radius-md: 0;
|
||||||
|
--radius-lg: 0;
|
||||||
|
--radius-xl: 0;
|
||||||
|
--radius-2xl: 0;
|
||||||
|
|
||||||
|
--shadow-card: 4px 4px 0 0 #000000;
|
||||||
|
--shadow-lift: 6px 6px 0 0 #000000;
|
||||||
|
|
||||||
|
--font-display: 'Silkscreen', 'Courier New', ui-monospace, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
html,
|
html,
|
||||||
body {
|
body {
|
||||||
@@ -139,10 +382,38 @@
|
|||||||
h4,
|
h4,
|
||||||
h5,
|
h5,
|
||||||
h6 {
|
h6 {
|
||||||
|
font-family: var(--font-display);
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
letter-spacing: -0.015em;
|
letter-spacing: -0.015em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Die Feldbeschriftungen (labelClass in components/leads/leadUi.tsx) tragen
|
||||||
|
* dieselbe Klasse, damit ein Theme sie mit einer Regel erreicht. Bei perinco
|
||||||
|
* und Outdoor ist --font-display die Grundschrift, hier aendert sich also
|
||||||
|
* nichts.
|
||||||
|
*/
|
||||||
|
.field-label {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Pixelschrift hat eine feste Zeichenbreite und traegt ihre Sperrung selbst;
|
||||||
|
* die negative Laufweite der Ueberschriften wuerde die Buchstaben ineinander
|
||||||
|
* schieben. Ausserdem kein Kantenglaetten - Pixel sollen Pixel bleiben.
|
||||||
|
*/
|
||||||
|
html.theme-pixel h1,
|
||||||
|
html.theme-pixel h2,
|
||||||
|
html.theme-pixel h3,
|
||||||
|
html.theme-pixel h4,
|
||||||
|
html.theme-pixel h5,
|
||||||
|
html.theme-pixel h6,
|
||||||
|
html.theme-pixel .field-label {
|
||||||
|
letter-spacing: 0;
|
||||||
|
-webkit-font-smoothing: none;
|
||||||
|
font-smooth: never;
|
||||||
|
}
|
||||||
|
|
||||||
/* Fokus-Ring durchgaengig in CI-Gruen */
|
/* Fokus-Ring durchgaengig in CI-Gruen */
|
||||||
:focus-visible {
|
:focus-visible {
|
||||||
outline: 2px solid var(--color-brand-500);
|
outline: 2px solid var(--color-brand-500);
|
||||||
@@ -160,6 +431,18 @@
|
|||||||
filter: invert(1) hue-rotate(180deg);
|
filter: invert(1) hue-rotate(180deg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* "rounded-full" laesst sich nicht ueber eine Variable abschalten - Tailwind
|
||||||
|
* schreibt dort einen festen Wert. Fuer Pixel Art muss es aber weg: runde
|
||||||
|
* Punkte, Pillen und Bilder neben eckigen Karten waeren ein Bruch. Der
|
||||||
|
* Attributselektor greift genau das eine Klassenwort und ist mit dem
|
||||||
|
* vorangestellten html spezifischer als die Utility-Klasse, deshalb ohne
|
||||||
|
* !important.
|
||||||
|
*/
|
||||||
|
html.theme-pixel [class~='rounded-full'] {
|
||||||
|
border-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.scrollbar-slim::-webkit-scrollbar {
|
.scrollbar-slim::-webkit-scrollbar {
|
||||||
width: 8px;
|
width: 8px;
|
||||||
height: 8px;
|
height: 8px;
|
||||||
|
|||||||
+10
-3
@@ -10,6 +10,7 @@ import type {
|
|||||||
LeadConnection,
|
LeadConnection,
|
||||||
Opportunity,
|
Opportunity,
|
||||||
Role,
|
Role,
|
||||||
|
ThemePalette,
|
||||||
} from '../types';
|
} from '../types';
|
||||||
|
|
||||||
const TOKEN_KEY = 'perinco_crm_token';
|
const TOKEN_KEY = 'perinco_crm_token';
|
||||||
@@ -90,10 +91,16 @@ export const authApi = {
|
|||||||
}),
|
}),
|
||||||
me: () => request<{ user: AppUser }>('/auth/me'),
|
me: () => request<{ user: AppUser }>('/auth/me'),
|
||||||
/**
|
/**
|
||||||
* Eigene Terminfarbe und eigenes Foto setzen - ohne Adminrechte.
|
* Eigene Terminfarbe, eigenes Foto und eigenes Design setzen - ohne
|
||||||
* null setzt zurueck: Farbe auf die Palettenfarbe, Foto auf die Initiale.
|
* 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) }),
|
request<{ user: AppUser }>('/auth/me', { method: 'PATCH', body: JSON.stringify(input) }),
|
||||||
changePassword: (current_password: string, new_password: string) =>
|
changePassword: (current_password: string, new_password: string) =>
|
||||||
request<{ ok: true }>('/auth/change-password', {
|
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 */
|
/** 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 {
|
interface ThemeContextValue {
|
||||||
/** Tatsaechlich aktive Darstellung. */
|
/** Tatsaechlich aktive Darstellung. */
|
||||||
theme: Theme;
|
theme: ThemeMode;
|
||||||
/** Gespeicherte Wahl; null bedeutet "folgt dem System". */
|
/** Gespeicherte Wahl des Modus; null bedeutet "folgt dem System". */
|
||||||
preference: Preference;
|
preference: ModePreference;
|
||||||
/** Schaltet explizit auf das jeweils andere Thema. */
|
/** Aktive Farbwelt. */
|
||||||
|
palette: ThemePalette;
|
||||||
|
/** Schaltet explizit auf den jeweils anderen Modus. */
|
||||||
toggle: () => void;
|
toggle: () => void;
|
||||||
/** Zurueck zur Systemeinstellung. */
|
/** Zurueck zur Systemeinstellung. */
|
||||||
useSystem: () => void;
|
useSystem: () => void;
|
||||||
|
/** Farbwelt wechseln. */
|
||||||
|
setPalette: (palette: ThemePalette) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ThemeContext = createContext<ThemeContextValue | null>(null);
|
const ThemeContext = createContext<ThemeContextValue | null>(null);
|
||||||
|
|
||||||
function readPreference(): Preference {
|
function readCachedMode(): ModePreference {
|
||||||
try {
|
try {
|
||||||
const stored = localStorage.getItem(STORAGE_KEY);
|
const stored = localStorage.getItem(MODE_KEY);
|
||||||
return stored === 'light' || stored === 'dark' ? stored : null;
|
return stored === 'light' || stored === 'dark' ? stored : null;
|
||||||
} catch {
|
} catch {
|
||||||
// Privater Modus ohne localStorage - dann eben ohne gespeicherte Wahl.
|
// Privater Modus ohne localStorage - dann eben ohne Zwischenspeicher.
|
||||||
return null;
|
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' &&
|
return typeof window !== 'undefined' &&
|
||||||
window.matchMedia?.('(prefers-color-scheme: dark)').matches
|
window.matchMedia?.('(prefers-color-scheme: dark)').matches
|
||||||
? 'dark'
|
? 'dark'
|
||||||
@@ -37,25 +97,35 @@ function systemTheme(): Theme {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Setzt die Klasse, an der die dunklen Farbwerte in index.css haengen. Ein
|
* Setzt die Klassen, an denen die Farbwerte in index.css haengen. Das
|
||||||
* Inline-Skript in index.html macht dasselbe schon vor dem ersten Frame, damit
|
* Inline-Skript in index.html macht dasselbe schon vor dem ersten Frame.
|
||||||
* es beim Laden nicht weiss aufblitzt.
|
*
|
||||||
|
* 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;
|
const root = document.documentElement;
|
||||||
root.classList.toggle('dark', theme === 'dark');
|
root.classList.toggle('dark', mode === 'dark');
|
||||||
root.style.colorScheme = theme;
|
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 }) => {
|
export const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||||
const [preference, setPreference] = useState<Preference>(() => readPreference());
|
const { user, updateUser } = useAuth();
|
||||||
const [resolvedSystem, setResolvedSystem] = useState<Theme>(() => systemTheme());
|
|
||||||
|
const [preference, setPreference] = useState<ModePreference>(() => readCachedMode());
|
||||||
|
const [palette, setPaletteState] = useState<ThemePalette>(() => readCachedPalette());
|
||||||
|
const [resolvedSystem, setResolvedSystem] = useState<ThemeMode>(() => systemTheme());
|
||||||
|
|
||||||
const theme = preference ?? resolvedSystem;
|
const theme = preference ?? resolvedSystem;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
applyTheme(theme);
|
applyTheme(theme, palette);
|
||||||
}, [theme]);
|
}, [theme, palette]);
|
||||||
|
|
||||||
// Ohne eigene Wahl folgt die App einer Aenderung im Betriebssystem sofort.
|
// Ohne eigene Wahl folgt die App einer Aenderung im Betriebssystem sofort.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -67,24 +137,87 @@ export const ThemeProvider: React.FC<{ children: React.ReactNode }> = ({ childre
|
|||||||
return () => query.removeEventListener('change', onChange);
|
return () => query.removeEventListener('change', onChange);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const store = useCallback((next: Preference) => {
|
/*
|
||||||
setPreference(next);
|
* Die Wahl des angemeldeten Benutzers uebernehmen - einmal je Anmeldung.
|
||||||
try {
|
*
|
||||||
if (next === null) localStorage.removeItem(STORAGE_KEY);
|
* Der Merker ist wichtig: `user` aendert sich auch, wenn jemand sein Foto
|
||||||
else localStorage.setItem(STORAGE_KEY, next);
|
* tauscht oder gerade das Design umstellt (updateUser). Ohne ihn wuerde
|
||||||
} catch {
|
* dieser Effekt bei jeder solchen Aenderung erneut laufen und koennte eine
|
||||||
// Nicht speicherbar - die Wahl gilt dann nur fuer diese Sitzung.
|
* 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>(
|
const value = useMemo<ThemeContextValue>(
|
||||||
() => ({
|
() => ({
|
||||||
theme,
|
theme,
|
||||||
preference,
|
preference,
|
||||||
toggle: () => store(theme === 'dark' ? 'light' : 'dark'),
|
palette,
|
||||||
useSystem: () => store(null),
|
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>;
|
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
|
||||||
|
|||||||
+9
-2
@@ -10,17 +10,24 @@ import { WhisperProvider } from './lib/whisper';
|
|||||||
const container = document.getElementById('root');
|
const container = document.getElementById('root');
|
||||||
if (!container) throw new Error('Root-Element #root nicht gefunden.');
|
if (!container) throw new Error('Root-Element #root nicht gefunden.');
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Die Reihenfolge ist keine Geschmacksfrage: der ThemeProvider steht **unter**
|
||||||
|
* dem AuthProvider, weil das Design am Benutzer haengt und er ihn dafuer lesen
|
||||||
|
* muss (crm_users.theme). Bis jemand angemeldet ist - also auf dem
|
||||||
|
* Anmeldebildschirm - gilt der Zwischenspeicher aus dem localStorage, den auch
|
||||||
|
* das Inline-Skript in index.html benutzt.
|
||||||
|
*/
|
||||||
createRoot(container).render(
|
createRoot(container).render(
|
||||||
<React.StrictMode>
|
<React.StrictMode>
|
||||||
<ThemeProvider>
|
|
||||||
<ToastProvider>
|
<ToastProvider>
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
|
<ThemeProvider>
|
||||||
{/* Haelt Modelleinstellungen und den Whisper-Worker */}
|
{/* Haelt Modelleinstellungen und den Whisper-Worker */}
|
||||||
<WhisperProvider>
|
<WhisperProvider>
|
||||||
<App />
|
<App />
|
||||||
</WhisperProvider>
|
</WhisperProvider>
|
||||||
|
</ThemeProvider>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
</ToastProvider>
|
</ToastProvider>
|
||||||
</ThemeProvider>
|
|
||||||
</React.StrictMode>
|
</React.StrictMode>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -82,6 +82,23 @@ export const viewTitle = (view: ViewState): string => VIEW_TITLES[view] ?? view;
|
|||||||
|
|
||||||
export type Role = 'admin' | 'user';
|
export type Role = 'admin' | 'user';
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Farbwelten. Die Liste muss mit THEMES in server/src/userFields.js
|
||||||
|
* uebereinstimmen - das Backend weist einen unbekannten Wert ab, damit ein
|
||||||
|
* Tippfehler hier nicht still zum Standarddesign fuehrt.
|
||||||
|
* server/test/userFields.test.mjs prueft, dass beide Listen gleich bleiben.
|
||||||
|
*/
|
||||||
|
export const THEME_PALETTES = ['perinco', 'pixel', 'outdoor'] as const;
|
||||||
|
|
||||||
|
export type ThemePalette = (typeof THEME_PALETTES)[number];
|
||||||
|
|
||||||
|
/** Beschriftung und Kurzbeschreibung fuer die Auswahl im Kopf der App. */
|
||||||
|
export const THEME_PALETTE_LABELS: Record<ThemePalette, { name: string; hint: string }> = {
|
||||||
|
perinco: { name: 'perinco', hint: 'Das Design des Hauses - Blattgrün auf neutralem Grau' },
|
||||||
|
pixel: { name: 'Pixel Art', hint: '8-bit: eckige Kanten, harte Schatten, Pixelschrift' },
|
||||||
|
outdoor: { name: 'Outdoor', hint: 'Wanderkarte: Sandpapier, Bergsee-Türkis, weiche Ecken' },
|
||||||
|
};
|
||||||
|
|
||||||
export interface AppUser {
|
export interface AppUser {
|
||||||
id: number;
|
id: number;
|
||||||
username: string;
|
username: string;
|
||||||
@@ -92,6 +109,12 @@ export interface AppUser {
|
|||||||
color?: string | null;
|
color?: string | null;
|
||||||
/** Benutzerfoto als Data-URL oder externe URL; null = Initiale anzeigen. */
|
/** Benutzerfoto als Data-URL oder externe URL; null = Initiale anzeigen. */
|
||||||
avatar?: string | null;
|
avatar?: string | null;
|
||||||
|
/**
|
||||||
|
* Persoenliche Darstellung, am Benutzer und nicht am Browser: null = das
|
||||||
|
* Standarddesign perinco bzw. der Systemeinstellung folgen.
|
||||||
|
*/
|
||||||
|
theme?: ThemePalette | null;
|
||||||
|
theme_mode?: 'light' | 'dark' | null;
|
||||||
is_active: boolean;
|
is_active: boolean;
|
||||||
created_at?: string;
|
created_at?: string;
|
||||||
updated_at?: string;
|
updated_at?: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user