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
@@ -2,7 +2,12 @@ import express from 'express';
|
||||
import bcrypt from 'bcryptjs';
|
||||
import { pool } from '../db.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();
|
||||
|
||||
@@ -15,6 +20,9 @@ function publicUser(row) {
|
||||
role: row.role,
|
||||
color: row.color ?? 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),
|
||||
created_at: row.created_at,
|
||||
updated_at: row.updated_at,
|
||||
@@ -59,10 +67,11 @@ router.get('/me', requireAuth, (req, res) => {
|
||||
res.json({ user: publicUser(req.user) });
|
||||
});
|
||||
|
||||
// PATCH /api/auth/me { color?, avatar? }
|
||||
// Jeder darf seine eigene Terminfarbe und sein Foto setzen - dafuer braucht
|
||||
// es keine Adminrechte. null bzw. "" setzt das Feld zurueck (Farbe: automatisch
|
||||
// aus der Palette, Foto: Initiale).
|
||||
// PATCH /api/auth/me { color?, avatar?, theme?, theme_mode? }
|
||||
// Jeder darf seine eigene Terminfarbe, sein Foto und seine Darstellung setzen -
|
||||
// dafuer braucht es keine Adminrechte. null bzw. "" setzt das Feld zurueck
|
||||
// (Farbe: automatisch aus der Palette, Foto: Initiale, Design: perinco, Modus:
|
||||
// folgt dem System).
|
||||
router.patch('/me', requireAuth, async (req, res, next) => {
|
||||
const fields = [];
|
||||
const values = [];
|
||||
@@ -71,6 +80,8 @@ router.patch('/me', requireAuth, async (req, res, next) => {
|
||||
for (const [field, normalize] of [
|
||||
['color', normalizeColor],
|
||||
['avatar', normalizeAvatar],
|
||||
['theme', normalizeTheme],
|
||||
['theme_mode', normalizeThemeMode],
|
||||
]) {
|
||||
if (req.body?.[field] === undefined) continue;
|
||||
const result = normalize(req.body[field]);
|
||||
|
||||
@@ -31,7 +31,8 @@ function normalizeEmail(value) {
|
||||
router.get('/directory', async (req, res, next) => {
|
||||
try {
|
||||
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`
|
||||
);
|
||||
res.json(rows.map(publicUser));
|
||||
@@ -65,7 +66,8 @@ function duplicateMessage(err) {
|
||||
router.get('/', async (req, res, next) => {
|
||||
try {
|
||||
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`
|
||||
);
|
||||
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]
|
||||
);
|
||||
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 = ?`,
|
||||
[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);
|
||||
|
||||
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 = ?`,
|
||||
[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.
|
||||
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 (
|
||||
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
salutation VARCHAR(32) NULL,
|
||||
|
||||
@@ -34,3 +34,39 @@ export function normalizeAvatar(value) {
|
||||
}
|
||||
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 };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user