feat: Primärfarbe (Button-/Akzentfarbe) im Branding konfigurierbar

- Backend: primaryColor-Feld in GET/POST /settings/branding (Redis, Hex-Validierung)
- Frontend AppLayout: --color-primary/--color-primary-hover/--color-primary-light
  CSS-Variablen dynamisch aus Branding-Einstellungen setzen
- AdminCustomizePage: neuer Card-Block „Button-/Primärfarbe" mit 6 Farbpresets,
  Color-Picker, Hex-Input und Live-Vorschau (Solid + Outline Button)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Thomas Reitz 2026-03-15 09:14:41 +01:00
parent 0a5a37d169
commit bfaf718596
3 changed files with 150 additions and 1 deletions

View file

@ -164,11 +164,13 @@ export class SettingsController {
loginBgColor1: string | null; loginBgColor1: string | null;
loginBgColor2: string | null; loginBgColor2: string | null;
loginBgImage: string | null; loginBgImage: string | null;
primaryColor: string | null;
}> { }> {
const raw = await this.redis.get(BRANDING_LOGO_KEY); const raw = await this.redis.get(BRANDING_LOGO_KEY);
const empty = { const empty = {
logo: null, sidebarColor: null, logoWidth: null, sidebarWidth: null, logo: null, sidebarColor: null, logoWidth: null, sidebarWidth: null,
loginBgType: null, loginBgColor1: null, loginBgColor2: null, loginBgImage: null, loginBgType: null, loginBgColor1: null, loginBgColor2: null, loginBgImage: null,
primaryColor: null,
}; };
if (!raw) return empty; if (!raw) return empty;
@ -177,6 +179,8 @@ export class SettingsController {
const loginBgType = ['gradient', 'solid', 'image'].includes(data.loginBgType) const loginBgType = ['gradient', 'solid', 'image'].includes(data.loginBgType)
? (data.loginBgType as 'gradient' | 'solid' | 'image') ? (data.loginBgType as 'gradient' | 'solid' | 'image')
: null; : null;
// Hex-Farbe validieren (#rrggbb)
const hexColorRe = /^#[0-9a-fA-F]{6}$/;
return { return {
logo: data.logo || null, logo: data.logo || null,
sidebarColor: data.sidebarColor || null, sidebarColor: data.sidebarColor || null,
@ -186,6 +190,9 @@ export class SettingsController {
loginBgColor1: data.loginBgColor1 || null, loginBgColor1: data.loginBgColor1 || null,
loginBgColor2: data.loginBgColor2 || null, loginBgColor2: data.loginBgColor2 || null,
loginBgImage: data.loginBgImage || null, loginBgImage: data.loginBgImage || null,
primaryColor: typeof data.primaryColor === 'string' && hexColorRe.test(data.primaryColor)
? data.primaryColor
: null,
}; };
} catch { } catch {
// Legacy: nur Logo als String // Legacy: nur Logo als String
@ -211,6 +218,7 @@ export class SettingsController {
loginBgColor1?: string | null; loginBgColor1?: string | null;
loginBgColor2?: string | null; loginBgColor2?: string | null;
loginBgImage?: string | null; loginBgImage?: string | null;
primaryColor?: string | null;
}, },
): Promise<{ success: boolean }> { ): Promise<{ success: boolean }> {
if (body.logo && body.logo.length > 500_000) { if (body.logo && body.logo.length > 500_000) {
@ -234,6 +242,12 @@ export class SettingsController {
? Math.min(Math.max(Math.round(body.sidebarWidth), 200), 360) ? Math.min(Math.max(Math.round(body.sidebarWidth), 200), 360)
: null; : null;
// Primärfarbe validieren — muss gültiges #rrggbb sein
const hexColorRe = /^#[0-9a-fA-F]{6}$/;
const primaryColor = typeof body.primaryColor === 'string' && hexColorRe.test(body.primaryColor)
? body.primaryColor
: null;
const data = { const data = {
logo: body.logo || null, logo: body.logo || null,
sidebarColor: body.sidebarColor || null, sidebarColor: body.sidebarColor || null,
@ -243,6 +257,7 @@ export class SettingsController {
loginBgColor1: body.loginBgColor1 || null, loginBgColor1: body.loginBgColor1 || null,
loginBgColor2: body.loginBgColor2 || null, loginBgColor2: body.loginBgColor2 || null,
loginBgImage: body.loginBgImage || null, loginBgImage: body.loginBgImage || null,
primaryColor,
}; };
await this.redis.set(BRANDING_LOGO_KEY, JSON.stringify(data)); await this.redis.set(BRANDING_LOGO_KEY, JSON.stringify(data));

View file

@ -11,8 +11,18 @@ interface BrandingData {
loginBgColor1: string | null; loginBgColor1: string | null;
loginBgColor2: string | null; loginBgColor2: string | null;
loginBgImage: string | null; loginBgImage: string | null;
primaryColor: string | null;
} }
const PRIMARY_COLOR_PRESETS = [
{ label: 'Standard', color: '#1040bb' },
{ label: 'Indigo', color: '#4f46e5' },
{ label: 'Grün', color: '#059669' },
{ label: 'Orange', color: '#d97706' },
{ label: 'Lila', color: '#7c3aed' },
{ label: 'Rot', color: '#dc2626' },
];
const SIDEBAR_PRESETS = [ const SIDEBAR_PRESETS = [
{ label: 'Standard', color: '#1e293b' }, { label: 'Standard', color: '#1e293b' },
{ label: 'Dunkelblau', color: '#0f172a' }, { label: 'Dunkelblau', color: '#0f172a' },
@ -76,6 +86,7 @@ export function AdminCustomizePage() {
const [loginBgColor1, setLoginBgColor1] = useState<string>('#1a56db'); const [loginBgColor1, setLoginBgColor1] = useState<string>('#1a56db');
const [loginBgColor2, setLoginBgColor2] = useState<string>('#1e3a5f'); const [loginBgColor2, setLoginBgColor2] = useState<string>('#1e3a5f');
const [loginBgImage, setLoginBgImage] = useState<string | null>(null); const [loginBgImage, setLoginBgImage] = useState<string | null>(null);
const [primaryColor, setPrimaryColor] = useState<string>('#1040bb');
const [hasChanges, setHasChanges] = useState(false); const [hasChanges, setHasChanges] = useState(false);
const [saveSuccess, setSaveSuccess] = useState(false); const [saveSuccess, setSaveSuccess] = useState(false);
@ -97,6 +108,7 @@ export function AdminCustomizePage() {
setLoginBgColor1(data.loginBgColor1 || '#1a56db'); setLoginBgColor1(data.loginBgColor1 || '#1a56db');
setLoginBgColor2(data.loginBgColor2 || '#1e3a5f'); setLoginBgColor2(data.loginBgColor2 || '#1e3a5f');
setLoginBgImage(data.loginBgImage || null); setLoginBgImage(data.loginBgImage || null);
setPrimaryColor(data.primaryColor || '#1040bb');
setHasChanges(false); setHasChanges(false);
} }
}, [data]); }, [data]);
@ -111,6 +123,7 @@ export function AdminCustomizePage() {
loginBgColor1: string; loginBgColor1: string;
loginBgColor2: string; loginBgColor2: string;
loginBgImage: string | null; loginBgImage: string | null;
primaryColor: string;
}) => { }) => {
const res = await api.post('/settings/branding', branding); const res = await api.post('/settings/branding', branding);
return res.data; return res.data;
@ -154,7 +167,7 @@ export function AdminCustomizePage() {
}; };
const handleSave = () => { const handleSave = () => {
saveMutation.mutate({ logo, sidebarColor, logoWidth, sidebarWidth, loginBgType, loginBgColor1, loginBgColor2, loginBgImage }); saveMutation.mutate({ logo, sidebarColor, logoWidth, sidebarWidth, loginBgType, loginBgColor1, loginBgColor2, loginBgImage, primaryColor });
}; };
const handleLoginBgFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => { const handleLoginBgFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
@ -733,6 +746,103 @@ export function AdminCustomizePage() {
</div> </div>
</div> </div>
{/* Button-/Primärfarbe */}
<div style={cardStyle}>
<h3 style={{ fontSize: '1rem', fontWeight: 600, marginBottom: '0.5rem' }}>
Button-/Primärfarbe
</h3>
<p style={{ fontSize: '0.8125rem', color: 'var(--color-text-secondary)', marginBottom: '1.25rem' }}>
Farbe für Buttons, aktive Links und Akzente in der gesamten Oberfläche.
</p>
{/* Voreinstellungen */}
<div style={{ marginBottom: '1rem' }}>
<label style={labelStyle}>Voreinstellungen</label>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem' }}>
{PRIMARY_COLOR_PRESETS.map((preset) => (
<button
key={preset.color}
onClick={() => { setPrimaryColor(preset.color); setHasChanges(true); }}
style={{
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
padding: '0.375rem 0.75rem',
background: primaryColor === preset.color ? 'var(--color-primary-light)' : 'none',
border: primaryColor === preset.color ? '2px solid var(--color-primary)' : '1px solid var(--color-border)',
borderRadius: 'var(--radius-sm)',
cursor: 'pointer',
fontSize: '0.8125rem',
color: 'var(--color-text)',
}}
>
<span
style={{
width: 20,
height: 20,
borderRadius: '50%',
background: preset.color,
border: '1px solid rgba(0,0,0,0.15)',
flexShrink: 0,
}}
/>
{preset.label}
</button>
))}
</div>
</div>
{/* Eigene Farbe + Vorschau */}
<div>
<label style={labelStyle}>Eigene Farbe</label>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', flexWrap: 'wrap' }}>
<input
type="color"
value={primaryColor}
onChange={(e) => { setPrimaryColor(e.target.value); setHasChanges(true); }}
style={{ width: 40, height: 40, padding: 0, border: '1px solid var(--color-border)', borderRadius: 'var(--radius-sm)', cursor: 'pointer' }}
/>
<input
type="text"
value={primaryColor}
onChange={(e) => { setPrimaryColor(e.target.value); setHasChanges(true); }}
style={{ padding: '0.5rem 0.75rem', fontSize: '0.875rem', border: '1px solid var(--color-border)', borderRadius: 'var(--radius-sm)', background: 'var(--color-bg-card)', color: 'var(--color-text)', width: 120, fontFamily: 'monospace' }}
/>
{/* Vorschau-Button */}
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
<button
style={{
padding: '0.5rem 1.25rem',
background: primaryColor,
color: 'white',
border: 'none',
borderRadius: 'var(--radius-sm)',
fontSize: '0.875rem',
fontWeight: 600,
cursor: 'default',
}}
>
Vorschau
</button>
<button
style={{
padding: '0.5rem 1.25rem',
background: 'none',
color: primaryColor,
border: `1px solid ${primaryColor}`,
borderRadius: 'var(--radius-sm)',
fontSize: '0.875rem',
fontWeight: 600,
cursor: 'default',
}}
>
Outline
</button>
</div>
</div>
</div>
</div>
{/* Speichern */} {/* Speichern */}
<div <div
style={{ style={{

View file

@ -156,6 +156,7 @@ export function AppLayout() {
sidebarColor: string | null; sidebarColor: string | null;
logoWidth: number | null; logoWidth: number | null;
sidebarWidth: number | null; sidebarWidth: number | null;
primaryColor: string | null;
}>({ }>({
queryKey: ['settings', 'branding'], queryKey: ['settings', 'branding'],
queryFn: async () => { queryFn: async () => {
@ -164,12 +165,35 @@ export function AppLayout() {
sidebarColor: string | null; sidebarColor: string | null;
logoWidth: number | null; logoWidth: number | null;
sidebarWidth: number | null; sidebarWidth: number | null;
primaryColor: string | null;
}>('/settings/branding'); }>('/settings/branding');
return res.data; return res.data;
}, },
staleTime: 5 * 60 * 1000, staleTime: 5 * 60 * 1000,
}); });
// Primärfarbe als CSS-Variable setzen
useEffect(() => {
const root = document.documentElement;
const hex = branding?.primaryColor;
if (hex && /^#[0-9a-fA-F]{6}$/.test(hex)) {
const r = parseInt(hex.slice(1, 3), 16);
const g = parseInt(hex.slice(3, 5), 16);
const b = parseInt(hex.slice(5, 7), 16);
const rh = Math.round(r * 0.82);
const gh = Math.round(g * 0.82);
const bh = Math.round(b * 0.82);
const hoverHex = `#${rh.toString(16).padStart(2, '0')}${gh.toString(16).padStart(2, '0')}${bh.toString(16).padStart(2, '0')}`;
root.style.setProperty('--color-primary', hex);
root.style.setProperty('--color-primary-hover', hoverHex);
root.style.setProperty('--color-primary-light', `rgba(${r}, ${g}, ${b}, 0.12)`);
} else {
root.style.removeProperty('--color-primary');
root.style.removeProperty('--color-primary-hover');
root.style.removeProperty('--color-primary-light');
}
}, [branding?.primaryColor]);
const handleLogout = async () => { const handleLogout = async () => {
await logout(); await logout();
navigate('/login'); navigate('/login');