mirror of
http://172.20.10.11:3000/gitadmin/INSIGHT-MVP.git
synced 2026-06-25 00:16:41 +02:00
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:
parent
0a5a37d169
commit
bfaf718596
3 changed files with 150 additions and 1 deletions
|
|
@ -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));
|
||||||
|
|
|
||||||
|
|
@ -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={{
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue