feat: dark mode, collapsible sidebar, branding customization

- Add Light/Dark/System theme toggle with ThemeContext and CSS variables
- Sidebar fully collapsible (icons-only mode, persisted in localStorage)
- Anwendungen section collapsible with chevron toggle
- Admin "Anpassungen" page: logo upload, sidebar color picker with presets
- Backend branding endpoints (GET/POST /settings/branding) stored in Redis
- Optional custom icon upload for external links (click icon field)
- Backend favicon proxy with HTML parsing for reliable icon loading
- Dark mode CSS variables for all components
- Login page SSO button and error styles use CSS variables

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Thomas Reitz 2026-03-10 11:47:51 +01:00
parent 0f9b3d4f36
commit 3bedda2b9d
11 changed files with 1087 additions and 85 deletions

View file

@ -23,9 +23,11 @@ interface ExternalLink {
label: string; label: string;
url: string; url: string;
sortOrder: number; sortOrder: number;
customIcon?: string; // Optional: Base64-encoded custom icon
} }
const EXTERNAL_LINKS_KEY = 'platform_external_links'; const EXTERNAL_LINKS_KEY = 'platform_external_links';
const BRANDING_LOGO_KEY = 'platform_branding_logo';
@ApiTags('Settings') @ApiTags('Settings')
@Controller('settings') @Controller('settings')
@ -83,6 +85,7 @@ export class SettingsController {
label: link.label.trim(), label: link.label.trim(),
url: link.url.trim(), url: link.url.trim(),
sortOrder: link.sortOrder ?? index, sortOrder: link.sortOrder ?? index,
...(link.customIcon && { customIcon: link.customIcon }),
})); }));
sorted.sort((a, b) => a.sortOrder - b.sortOrder); sorted.sort((a, b) => a.sortOrder - b.sortOrder);
@ -94,6 +97,57 @@ export class SettingsController {
return { success: true, count: sorted.length }; return { success: true, count: sorted.length };
} }
/**
* GET /api/v1/settings/branding
* Branding-Einstellungen lesen (Logo, Sidebar-Farbe etc.).
*/
@Get('branding')
@ApiOperation({ summary: 'Branding-Einstellungen lesen' })
async getBranding(): Promise<{
logo: string | null;
sidebarColor: string | null;
}> {
const raw = await this.redis.get(BRANDING_LOGO_KEY);
if (!raw) return { logo: null, sidebarColor: null };
try {
const data = JSON.parse(raw);
return {
logo: data.logo || null,
sidebarColor: data.sidebarColor || null,
};
} catch {
// Legacy: nur Logo als String
return { logo: raw, sidebarColor: null };
}
}
/**
* POST /api/v1/settings/branding
* Branding-Einstellungen speichern (nur PLATFORM_ADMIN).
*/
@Post('branding')
@Roles('PLATFORM_ADMIN')
@UseGuards(RolesGuard)
@ApiOperation({ summary: 'Branding-Einstellungen speichern (Admin)' })
async saveBranding(
@Body() body: { logo?: string | null; sidebarColor?: string | null },
): Promise<{ success: boolean }> {
if (body.logo && body.logo.length > 500_000) {
throw new BadRequestException('Logo darf maximal 500KB gross sein');
}
const data = {
logo: body.logo || null,
sidebarColor: body.sidebarColor || null,
};
await this.redis.set(BRANDING_LOGO_KEY, JSON.stringify(data));
this.logger.log('Branding aktualisiert');
return { success: true };
}
/** /**
* GET /api/v1/settings/favicon?url=https://example.com * GET /api/v1/settings/favicon?url=https://example.com
* Favicon-URL fuer eine beliebige Webseite ermitteln. * Favicon-URL fuer eine beliebige Webseite ermitteln.

View file

@ -0,0 +1,461 @@
import { useState, useEffect, useRef } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '../api/client';
interface BrandingData {
logo: string | null;
sidebarColor: string | null;
}
const SIDEBAR_PRESETS = [
{ label: 'Standard', color: '#1e293b' },
{ label: 'Dunkelblau', color: '#0f172a' },
{ label: 'Marine', color: '#1e3a5f' },
{ label: 'Anthrazit', color: '#18181b' },
{ label: 'Dunkelgrau', color: '#374151' },
{ label: 'Schwarz', color: '#0a0a0a' },
{ label: 'Dunkelgruen', color: '#14532d' },
{ label: 'Bordeaux', color: '#4a1d2e' },
];
const cardStyle: React.CSSProperties = {
background: 'var(--color-bg-card)',
borderRadius: 'var(--radius-md)',
boxShadow: 'var(--shadow-sm)',
border: '1px solid var(--color-border)',
padding: '1.5rem',
marginBottom: '1.5rem',
};
const btnPrimary: React.CSSProperties = {
padding: '0.5rem 1.25rem',
background: 'var(--color-primary)',
color: 'white',
border: 'none',
borderRadius: 'var(--radius-sm)',
fontSize: '0.875rem',
fontWeight: 600,
cursor: 'pointer',
};
const btnSecondary: React.CSSProperties = {
padding: '0.375rem 0.75rem',
background: 'none',
color: 'var(--color-text-secondary)',
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-sm)',
fontSize: '0.8125rem',
cursor: 'pointer',
};
const labelStyle: React.CSSProperties = {
display: 'block',
fontSize: '0.75rem',
fontWeight: 600,
color: 'var(--color-text-secondary)',
marginBottom: '0.25rem',
textTransform: 'uppercase' as const,
letterSpacing: '0.5px',
};
export function AdminCustomizePage() {
const queryClient = useQueryClient();
const fileInputRef = useRef<HTMLInputElement>(null);
const [logo, setLogo] = useState<string | null>(null);
const [sidebarColor, setSidebarColor] = useState<string>('#1e293b');
const [hasChanges, setHasChanges] = useState(false);
const [saveSuccess, setSaveSuccess] = useState(false);
const { data } = useQuery<BrandingData>({
queryKey: ['settings', 'branding'],
queryFn: async () => {
const res = await api.get<BrandingData>('/settings/branding');
return res.data;
},
});
useEffect(() => {
if (data) {
setLogo(data.logo);
setSidebarColor(data.sidebarColor || '#1e293b');
setHasChanges(false);
}
}, [data]);
const saveMutation = useMutation({
mutationFn: async (branding: {
logo: string | null;
sidebarColor: string;
}) => {
const res = await api.post('/settings/branding', branding);
return res.data;
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['settings', 'branding'] });
setHasChanges(false);
setSaveSuccess(true);
setTimeout(() => setSaveSuccess(false), 3000);
},
});
const handleFileSelect = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
if (!file.type.startsWith('image/')) {
alert('Bitte nur Bilddateien hochladen');
return;
}
if (file.size > 500_000) {
alert('Datei darf maximal 500KB gross sein');
return;
}
const reader = new FileReader();
reader.onload = () => {
setLogo(reader.result as string);
setHasChanges(true);
};
reader.readAsDataURL(file);
};
const handleRemoveLogo = () => {
setLogo(null);
setHasChanges(true);
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
};
const handleSave = () => {
saveMutation.mutate({ logo, sidebarColor });
};
return (
<div>
<div style={{ marginBottom: '1.5rem' }}>
<h1 style={{ fontSize: '1.5rem', fontWeight: 600 }}>Anpassungen</h1>
<p
style={{
fontSize: '0.875rem',
color: 'var(--color-text-secondary)',
marginTop: '0.25rem',
}}
>
Logo, Farben und Branding-Einstellungen fuer die Plattform.
</p>
</div>
{/* Logo */}
<div style={cardStyle}>
<h3
style={{
fontSize: '1rem',
fontWeight: 600,
marginBottom: '1rem',
}}
>
Plattform-Logo
</h3>
<p
style={{
fontSize: '0.8125rem',
color: 'var(--color-text-secondary)',
marginBottom: '1rem',
}}
>
Das Logo wird oben links in der Sidebar angezeigt. Empfohlen: PNG oder
SVG mit transparentem Hintergrund, max. 500KB.
</p>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '1.5rem',
marginBottom: '1.25rem',
}}
>
<div
style={{
width: 120,
height: 60,
borderRadius: 'var(--radius-sm)',
border: '2px dashed var(--color-border)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
overflow: 'hidden',
background: sidebarColor,
}}
>
{logo ? (
<img
src={logo}
alt="Logo"
style={{
maxWidth: '100%',
maxHeight: '100%',
objectFit: 'contain',
}}
/>
) : (
<span
style={{
color: '#60a5fa',
fontWeight: 700,
letterSpacing: 2,
fontSize: '1.25rem',
}}
>
INSIGHT
</span>
)}
</div>
<div style={{ display: 'flex', gap: '0.5rem' }}>
<input
ref={fileInputRef}
type="file"
accept="image/*"
style={{ display: 'none' }}
onChange={handleFileSelect}
/>
<button
style={btnSecondary}
onClick={() => fileInputRef.current?.click()}
>
Logo hochladen
</button>
{logo && (
<button
style={{
...btnSecondary,
color: 'var(--color-error)',
borderColor: 'var(--color-error)',
}}
onClick={handleRemoveLogo}
>
Entfernen
</button>
)}
</div>
</div>
</div>
{/* Sidebar-Farbe */}
<div style={cardStyle}>
<h3
style={{
fontSize: '1rem',
fontWeight: 600,
marginBottom: '1rem',
}}
>
Sidebar-Farbe
</h3>
<p
style={{
fontSize: '0.8125rem',
color: 'var(--color-text-secondary)',
marginBottom: '1rem',
}}
>
Die Hintergrundfarbe der linken Menue-Leiste.
</p>
{/* Voreinstellungen */}
<div style={{ marginBottom: '1rem' }}>
<label style={labelStyle}>Voreinstellungen</label>
<div
style={{
display: 'flex',
flexWrap: 'wrap',
gap: '0.5rem',
}}
>
{SIDEBAR_PRESETS.map((preset) => (
<button
key={preset.color}
onClick={() => {
setSidebarColor(preset.color);
setHasChanges(true);
}}
style={{
display: 'flex',
alignItems: 'center',
gap: '0.5rem',
padding: '0.375rem 0.75rem',
background:
sidebarColor === preset.color
? 'var(--color-primary-light)'
: 'none',
border:
sidebarColor === 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: 3,
background: preset.color,
border: '1px solid rgba(0,0,0,0.2)',
flexShrink: 0,
}}
/>
{preset.label}
</button>
))}
</div>
</div>
{/* Eigene Farbe */}
<div>
<label style={labelStyle}>Eigene Farbe</label>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '0.75rem',
}}
>
<input
type="color"
value={sidebarColor}
onChange={(e) => {
setSidebarColor(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={sidebarColor}
onChange={(e) => {
setSidebarColor(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',
}}
/>
{/* Live-Vorschau */}
<div
style={{
width: 160,
height: 80,
borderRadius: 'var(--radius-sm)',
background: sidebarColor,
border: '1px solid var(--color-border)',
display: 'flex',
flexDirection: 'column',
padding: '0.5rem',
gap: '0.25rem',
}}
>
<div
style={{
fontSize: '0.5rem',
color: '#60a5fa',
fontWeight: 700,
letterSpacing: 1,
}}
>
INSIGHT
</div>
<div
style={{
height: 4,
width: '60%',
background: 'rgba(255,255,255,0.2)',
borderRadius: 2,
}}
/>
<div
style={{
height: 4,
width: '80%',
background: 'rgba(255,255,255,0.15)',
borderRadius: 2,
}}
/>
<div
style={{
height: 4,
width: '50%',
background: 'rgba(255,255,255,0.1)',
borderRadius: 2,
}}
/>
</div>
</div>
</div>
</div>
{/* Speichern */}
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '0.75rem',
justifyContent: 'flex-end',
}}
>
{saveSuccess && (
<span
style={{
fontSize: '0.8125rem',
color: 'var(--color-success)',
fontWeight: 500,
}}
>
Gespeichert!
</span>
)}
{saveMutation.isError && (
<span
style={{
fontSize: '0.8125rem',
color: 'var(--color-error)',
}}
>
Fehler beim Speichern
</span>
)}
<button
onClick={handleSave}
disabled={!hasChanges || saveMutation.isPending}
style={{
...btnPrimary,
opacity: hasChanges ? 1 : 0.5,
cursor: hasChanges ? 'pointer' : 'not-allowed',
}}
>
{saveMutation.isPending ? 'Speichern...' : 'Speichern'}
</button>
</div>
</div>
);
}

View file

@ -1,4 +1,4 @@
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback, useRef } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import api from '../api/client'; import api from '../api/client';
@ -42,6 +42,7 @@ interface ExternalLink {
label: string; label: string;
url: string; url: string;
sortOrder: number; sortOrder: number;
customIcon?: string;
} }
const cardStyle: React.CSSProperties = { const cardStyle: React.CSSProperties = {
@ -114,6 +115,24 @@ function LinkRow({
isLast: boolean; isLast: boolean;
}) { }) {
const faviconUrl = useFavicon(link.url); const faviconUrl = useFavicon(link.url);
const iconInputRef = useRef<HTMLInputElement>(null);
const handleIconUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
if (!file.type.startsWith('image/')) return;
if (file.size > 100_000) {
alert('Icon darf maximal 100KB gross sein');
return;
}
const reader = new FileReader();
reader.onload = () => {
onChange({ ...link, customIcon: reader.result as string });
};
reader.readAsDataURL(file);
};
const iconSrc = link.customIcon || faviconUrl;
return ( return (
<div <div
@ -126,9 +145,16 @@ function LinkRow({
borderBottom: '1px solid var(--color-border)', borderBottom: '1px solid var(--color-border)',
}} }}
> >
{/* Favicon-Vorschau */} {/* Icon-Vorschau (klickbar fuer Upload) */}
<div> <div>
<label style={labelStyle}>Icon</label> <label style={labelStyle}>Icon</label>
<input
ref={iconInputRef}
type="file"
accept="image/*"
style={{ display: 'none' }}
onChange={handleIconUpload}
/>
<div <div
style={{ style={{
width: 36, width: 36,
@ -140,11 +166,15 @@ function LinkRow({
justifyContent: 'center', justifyContent: 'center',
overflow: 'hidden', overflow: 'hidden',
background: 'var(--color-bg)', background: 'var(--color-bg)',
cursor: 'pointer',
position: 'relative',
}} }}
onClick={() => iconInputRef.current?.click()}
title="Klicken zum Icon hochladen"
> >
{faviconUrl ? ( {iconSrc ? (
<img <img
src={faviconUrl} src={iconSrc}
alt="" alt=""
style={{ width: 20, height: 20, objectFit: 'contain' }} style={{ width: 20, height: 20, objectFit: 'contain' }}
onError={(e) => { onError={(e) => {
@ -164,6 +194,34 @@ function LinkRow({
<path d="M2 8h12M8 2a10 10 0 014 6 10 10 0 01-4 6 10 10 0 01-4-6 10 10 0 014-6z" /> <path d="M2 8h12M8 2a10 10 0 014 6 10 10 0 01-4 6 10 10 0 01-4-6 10 10 0 014-6z" />
</svg> </svg>
)} )}
{link.customIcon && (
<button
onClick={(e) => {
e.stopPropagation();
onChange({ ...link, customIcon: undefined });
}}
style={{
position: 'absolute',
top: -4,
right: -4,
width: 14,
height: 14,
borderRadius: '50%',
background: 'var(--color-error)',
color: 'white',
border: 'none',
fontSize: '0.5rem',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
cursor: 'pointer',
lineHeight: 1,
}}
title="Eigenes Icon entfernen"
>
x
</button>
)}
</div> </div>
</div> </div>
@ -517,18 +575,19 @@ export function AdminExternalLinksPage() {
<div <div
style={{ style={{
padding: '1rem 1.25rem', padding: '1rem 1.25rem',
background: '#eff6ff', background: 'color-mix(in srgb, var(--color-primary) 8%, var(--color-bg-card))',
border: '1px solid #bfdbfe', border: '1px solid color-mix(in srgb, var(--color-primary) 25%, transparent)',
borderRadius: 'var(--radius-sm)', borderRadius: 'var(--radius-sm)',
fontSize: '0.8125rem', fontSize: '0.8125rem',
color: '#1e40af', color: 'var(--color-primary)',
}} }}
> >
<strong>Hinweis:</strong> Das Icon wird automatisch als Favicon der <strong>Hinweis:</strong> Das Icon wird automatisch als Favicon der
jeweiligen Webseite geladen. Gib eine vollstaendige URL inkl.{' '} jeweiligen Webseite geladen. Optional kann ein eigenes Icon hochgeladen
werden (Klick auf das Icon-Feld). Gib eine vollstaendige URL inkl.{' '}
<code <code
style={{ style={{
background: '#dbeafe', background: 'color-mix(in srgb, var(--color-primary) 15%, transparent)',
padding: '0.125rem 0.25rem', padding: '0.125rem 0.25rem',
borderRadius: 2, borderRadius: 2,
}} }}

View file

@ -6,6 +6,7 @@ const tabs = [
{ to: '/admin/tenants', label: 'Mandanten' }, { to: '/admin/tenants', label: 'Mandanten' },
{ to: '/admin/sso', label: 'SSO-Konfiguration' }, { to: '/admin/sso', label: 'SSO-Konfiguration' },
{ to: '/admin/external-links', label: 'Externe Links' }, { to: '/admin/external-links', label: 'Externe Links' },
{ to: '/admin/customize', label: 'Anpassungen' },
]; ];
export function AdminLayout() { export function AdminLayout() {

View file

@ -97,12 +97,12 @@
} }
.error { .error {
background: #fef2f2; background: color-mix(in srgb, var(--color-error) 10%, var(--color-bg-card));
color: var(--color-error); color: var(--color-error);
padding: 0.75rem; padding: 0.75rem;
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
font-size: 0.875rem; font-size: 0.875rem;
border: 1px solid #fecaca; border: 1px solid color-mix(in srgb, var(--color-error) 25%, transparent);
} }
.divider { .divider {
@ -134,8 +134,8 @@
gap: 0.625rem; gap: 0.625rem;
width: 100%; width: 100%;
padding: 0.75rem; padding: 0.75rem;
background: white; background: var(--color-bg-card);
color: #374151; color: var(--color-text);
border: 1px solid var(--color-border); border: 1px solid var(--color-border);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
font-size: 0.9375rem; font-size: 0.9375rem;
@ -145,8 +145,8 @@
} }
.ssoButton:hover { .ssoButton:hover {
background: #f9fafb; background: var(--color-bg);
border-color: #9ca3af; border-color: var(--color-text-muted);
} }
.ssoButton svg { .ssoButton svg {

View file

@ -41,6 +41,29 @@
background-color: var(--color-bg); background-color: var(--color-bg);
} }
/* ===== Dark Mode ===== */
:root[data-theme='dark'] {
--color-primary: #3b82f6;
--color-primary-hover: #2563eb;
--color-primary-light: #1e3a5f;
--color-secondary: #9ca3af;
--color-success: #10b981;
--color-warning: #f59e0b;
--color-error: #ef4444;
--color-bg: #0f172a;
--color-bg-card: #1e293b;
--color-border: #334155;
--color-text: #f1f5f9;
--color-text-secondary: #94a3b8;
--color-text-muted: #64748b;
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.3);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.4);
--sidebar-bg: #0f172a;
}
* { * {
margin: 0; margin: 0;
padding: 0; padding: 0;

View file

@ -3,6 +3,7 @@ import ReactDOM from 'react-dom/client';
import { BrowserRouter } from 'react-router-dom'; import { BrowserRouter } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { AuthProvider } from './auth/AuthContext'; import { AuthProvider } from './auth/AuthContext';
import { ThemeProvider } from './theme/ThemeContext';
import { App } from './shell/App'; import { App } from './shell/App';
import './index.css'; import './index.css';
@ -20,9 +21,11 @@ ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode> <React.StrictMode>
<BrowserRouter> <BrowserRouter>
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<ThemeProvider>
<AuthProvider> <AuthProvider>
<App /> <App />
</AuthProvider> </AuthProvider>
</ThemeProvider>
</QueryClientProvider> </QueryClientProvider>
</BrowserRouter> </BrowserRouter>
</React.StrictMode>, </React.StrictMode>,

View file

@ -9,6 +9,7 @@ import { AdminUsersPage } from '../admin/AdminUsersPage';
import { AdminTenantsPage } from '../admin/AdminTenantsPage'; import { AdminTenantsPage } from '../admin/AdminTenantsPage';
import { AdminSsoPage } from '../admin/AdminSsoPage'; import { AdminSsoPage } from '../admin/AdminSsoPage';
import { AdminExternalLinksPage } from '../admin/AdminExternalLinksPage'; import { AdminExternalLinksPage } from '../admin/AdminExternalLinksPage';
import { AdminCustomizePage } from '../admin/AdminCustomizePage';
import { ProfilePage } from '../profile/ProfilePage'; import { ProfilePage } from '../profile/ProfilePage';
function PrivateRoute({ children }: { children: React.ReactNode }) { function PrivateRoute({ children }: { children: React.ReactNode }) {
@ -54,6 +55,7 @@ export function App() {
<Route path="tenants" element={<AdminTenantsPage />} /> <Route path="tenants" element={<AdminTenantsPage />} />
<Route path="sso" element={<AdminSsoPage />} /> <Route path="sso" element={<AdminSsoPage />} />
<Route path="external-links" element={<AdminExternalLinksPage />} /> <Route path="external-links" element={<AdminExternalLinksPage />} />
<Route path="customize" element={<AdminCustomizePage />} />
</Route> </Route>
</Route> </Route>

View file

@ -3,9 +3,10 @@
min-height: 100vh; min-height: 100vh;
} }
/* ===== Sidebar ===== */
.sidebar { .sidebar {
width: var(--sidebar-width); width: var(--sidebar-width);
background: #1e293b; background: var(--sidebar-bg, #1e293b);
color: white; color: white;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
@ -14,11 +15,27 @@
left: 0; left: 0;
bottom: 0; bottom: 0;
z-index: 100; z-index: 100;
transition: width 0.2s ease;
} }
.sidebarCollapsed {
width: 60px;
}
/* ===== Brand / Logo ===== */
.brand { .brand {
padding: 1.25rem 1.5rem; padding: 1.25rem 1.5rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.1); border-bottom: 1px solid rgba(255, 255, 255, 0.1);
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
min-height: 56px;
}
.sidebarCollapsed .brand {
padding: 1.25rem 0;
justify-content: center;
} }
.brand h2 { .brand h2 {
@ -26,8 +43,29 @@
font-weight: 700; font-weight: 700;
letter-spacing: 2px; letter-spacing: 2px;
color: #60a5fa; color: #60a5fa;
white-space: nowrap;
} }
.collapseBtn {
background: none;
border: none;
color: rgba(255, 255, 255, 0.5);
cursor: pointer;
padding: 0.25rem;
border-radius: var(--radius-sm);
transition: all 0.15s;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
}
.collapseBtn:hover {
color: white;
background: rgba(255, 255, 255, 0.1);
}
/* ===== Navigation ===== */
.nav { .nav {
flex: 1; flex: 1;
padding: 1rem 0; padding: 1rem 0;
@ -74,6 +112,11 @@
transform: rotate(0deg); transform: rotate(0deg);
} }
.navDivider {
margin: 0.5rem 1rem;
border-top: 1px solid rgba(255, 255, 255, 0.1);
}
.navLink { .navLink {
display: flex; display: flex;
align-items: center; align-items: center;
@ -84,6 +127,14 @@
font-size: 0.875rem; font-size: 0.875rem;
transition: all 0.15s; transition: all 0.15s;
border-left: 3px solid transparent; border-left: 3px solid transparent;
white-space: nowrap;
overflow: hidden;
}
.sidebarCollapsed .navLink {
padding: 0.625rem 0;
justify-content: center;
border-left: none;
} }
.navLink:hover { .navLink:hover {
@ -98,12 +149,20 @@
border-left-color: #60a5fa; border-left-color: #60a5fa;
} }
/* Admin-Bereich ueber dem Profil */ .sidebarCollapsed .active {
border-left-color: transparent;
}
/* ===== Admin-Bereich ueber dem Profil ===== */
.adminSection { .adminSection {
padding: 0.5rem 0.75rem; padding: 0.5rem 0.75rem;
border-top: 1px solid rgba(255, 255, 255, 0.1); border-top: 1px solid rgba(255, 255, 255, 0.1);
} }
.sidebarCollapsed .adminSection {
padding: 0.5rem 0.25rem;
}
.adminLink { .adminLink {
display: flex; display: flex;
align-items: center; align-items: center;
@ -115,6 +174,13 @@
font-weight: 500; font-weight: 500;
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
transition: all 0.15s; transition: all 0.15s;
white-space: nowrap;
overflow: hidden;
}
.sidebarCollapsed .adminLink {
padding: 0.625rem 0;
justify-content: center;
} }
.adminLink:hover { .adminLink:hover {
@ -128,11 +194,90 @@
background: rgba(96, 165, 250, 0.15); background: rgba(96, 165, 250, 0.15);
} }
/* ===== Theme Toggle ===== */
.themeToggle {
padding: 0.5rem 0.75rem;
border-top: 1px solid rgba(255, 255, 255, 0.1);
}
.sidebarCollapsed .themeToggle {
padding: 0.5rem 0;
display: flex;
justify-content: center;
}
.themeBtn {
background: rgba(255, 255, 255, 0.08);
border: none;
color: rgba(255, 255, 255, 0.7);
width: 32px;
height: 32px;
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.875rem;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.15s;
}
.themeBtn:hover {
background: rgba(255, 255, 255, 0.15);
color: white;
}
.themeBtnGroup {
display: flex;
gap: 2px;
background: rgba(255, 255, 255, 0.05);
border-radius: var(--radius-sm);
padding: 2px;
}
.themeOption {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
gap: 0.25rem;
padding: 0.3rem 0.25rem;
background: none;
border: none;
color: rgba(255, 255, 255, 0.5);
font-size: 0.6875rem;
font-weight: 500;
border-radius: 3px;
cursor: pointer;
transition: all 0.15s;
white-space: nowrap;
}
.themeOption:hover {
color: rgba(255, 255, 255, 0.8);
}
.themeOptionActive {
background: rgba(255, 255, 255, 0.15);
color: white;
}
.themeIcon {
font-size: 0.75rem;
line-height: 1;
}
/* ===== User Info ===== */
.userInfo { .userInfo {
padding: 1rem 1.5rem; padding: 1rem 1.5rem;
border-top: 1px solid rgba(255, 255, 255, 0.1); border-top: 1px solid rgba(255, 255, 255, 0.1);
} }
.sidebarCollapsed .userInfo {
padding: 0.75rem 0;
display: flex;
justify-content: center;
}
.userProfile { .userProfile {
cursor: pointer; cursor: pointer;
padding: 0.375rem; padding: 0.375rem;
@ -200,8 +345,10 @@
color: white; color: white;
} }
/* ===== Main Content ===== */
.main { .main {
flex: 1; flex: 1;
margin-left: var(--sidebar-width); margin-left: var(--sidebar-width);
padding: 2rem; padding: 2rem;
transition: margin-left 0.2s ease;
} }

View file

@ -4,6 +4,7 @@ import { useQuery } from '@tanstack/react-query';
import { useAuth } from '../auth/AuthContext'; import { useAuth } from '../auth/AuthContext';
import { UserAvatar } from '../components/UserAvatar'; import { UserAvatar } from '../components/UserAvatar';
import api from '../api/client'; import api from '../api/client';
import { useTheme } from '../theme/ThemeContext';
import styles from './AppLayout.module.css'; import styles from './AppLayout.module.css';
interface ExternalLink { interface ExternalLink {
@ -11,10 +12,19 @@ interface ExternalLink {
label: string; label: string;
url: string; url: string;
sortOrder: number; sortOrder: number;
customIcon?: string;
} }
/** Favicon ueber Backend-Proxy laden (cached in Redis) */ /** Favicon ueber Backend-Proxy laden (cached in Redis) */
function FaviconImg({ url, label }: { url: string; label: string }) { function FaviconImg({
url,
label,
customIcon,
}: {
url: string;
label: string;
customIcon?: string;
}) {
const [faviconUrl, setFaviconUrl] = useState<string | null>(null); const [faviconUrl, setFaviconUrl] = useState<string | null>(null);
const [failed, setFailed] = useState(false); const [failed, setFailed] = useState(false);
@ -39,6 +49,22 @@ function FaviconImg({ url, label }: { url: string; label: string }) {
fetchFavicon(); fetchFavicon();
}, [fetchFavicon]); }, [fetchFavicon]);
// Eigenes Icon hat Vorrang
if (customIcon) {
return (
<img
src={customIcon}
alt=""
style={{
width: 16,
height: 16,
objectFit: 'contain',
borderRadius: 2,
}}
/>
);
}
if (failed || !faviconUrl) { if (failed || !faviconUrl) {
return ( return (
<span <span
@ -76,10 +102,28 @@ function FaviconImg({ url, label }: { url: string; label: string }) {
); );
} }
const THEME_OPTIONS = [
{ value: 'light' as const, label: 'Hell', icon: '☀' },
{ value: 'dark' as const, label: 'Dunkel', icon: '☾' },
{ value: 'system' as const, label: 'System', icon: '⚙' },
];
export function AppLayout() { export function AppLayout() {
const { user, logout } = useAuth(); const { user, logout } = useAuth();
const navigate = useNavigate(); const navigate = useNavigate();
const { mode, setMode } = useTheme();
const [appsOpen, setAppsOpen] = useState(true); const [appsOpen, setAppsOpen] = useState(true);
const [collapsed, setCollapsed] = useState(() => {
return localStorage.getItem('sidebar-collapsed') === 'true';
});
const toggleCollapsed = () => {
setCollapsed((prev) => {
const next = !prev;
localStorage.setItem('sidebar-collapsed', String(next));
return next;
});
};
const { data: externalLinks } = useQuery<ExternalLink[]>({ const { data: externalLinks } = useQuery<ExternalLink[]>({
queryKey: ['settings', 'external-links'], queryKey: ['settings', 'external-links'],
@ -90,6 +134,21 @@ export function AppLayout() {
staleTime: 5 * 60 * 1000, staleTime: 5 * 60 * 1000,
}); });
const { data: branding } = useQuery<{
logo: string | null;
sidebarColor: string | null;
}>({
queryKey: ['settings', 'branding'],
queryFn: async () => {
const res = await api.get<{
logo: string | null;
sidebarColor: string | null;
}>('/settings/branding');
return res.data;
},
staleTime: 5 * 60 * 1000,
});
const handleLogout = async () => { const handleLogout = async () => {
await logout(); await logout();
navigate('/login'); navigate('/login');
@ -98,9 +157,55 @@ export function AppLayout() {
return ( return (
<div className={styles.layout}> <div className={styles.layout}>
{/* Sidebar */} {/* Sidebar */}
<aside className={styles.sidebar}> <aside
className={`${styles.sidebar} ${collapsed ? styles.sidebarCollapsed : ''}`}
style={
branding?.sidebarColor
? { background: branding.sidebarColor }
: undefined
}
>
<div className={styles.brand}> <div className={styles.brand}>
{!collapsed &&
(branding?.logo ? (
<img
src={branding.logo}
alt="Logo"
style={{
maxHeight: 32,
maxWidth: 140,
objectFit: 'contain',
}}
/>
) : (
<h2>INSIGHT</h2> <h2>INSIGHT</h2>
))}
<button
className={styles.collapseBtn}
onClick={toggleCollapsed}
title={collapsed ? 'Menue ausklappen' : 'Menue einklappen'}
>
<svg
width="18"
height="18"
viewBox="0 0 18 18"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
>
{collapsed ? (
<>
<path d="M3 4.5h12M3 9h12M3 13.5h12" />
</>
) : (
<>
<path d="M11 4l-5 5 5 5" />
</>
)}
</svg>
</button>
</div> </div>
<nav className={styles.nav}> <nav className={styles.nav}>
@ -110,6 +215,7 @@ export function AppLayout() {
className={({ isActive }) => className={({ isActive }) =>
`${styles.navLink} ${isActive ? styles.active : ''}` `${styles.navLink} ${isActive ? styles.active : ''}`
} }
title="Dashboard"
> >
<svg <svg
width="16" width="16"
@ -124,12 +230,13 @@ export function AppLayout() {
<path d="M2 6l6-4 6 4v7a1 1 0 01-1 1H3a1 1 0 01-1-1V6z" /> <path d="M2 6l6-4 6 4v7a1 1 0 01-1 1H3a1 1 0 01-1-1V6z" />
<path d="M6 14V8h4v6" /> <path d="M6 14V8h4v6" />
</svg> </svg>
Dashboard {!collapsed && 'Dashboard'}
</NavLink> </NavLink>
{/* Externe Links (aufklappbar) */} {/* Externe Links (aufklappbar) */}
{externalLinks && externalLinks.length > 0 && ( {externalLinks && externalLinks.length > 0 && (
<> <>
{!collapsed ? (
<button <button
className={styles.navSectionToggle} className={styles.navSectionToggle}
onClick={() => setAppsOpen((p) => !p)} onClick={() => setAppsOpen((p) => !p)}
@ -147,22 +254,24 @@ export function AppLayout() {
<path d="M3 4.5l3 3 3-3" /> <path d="M3 4.5l3 3 3-3" />
</svg> </svg>
</button> </button>
{appsOpen && ) : (
<div className={styles.navDivider} />
)}
{(appsOpen || collapsed) &&
externalLinks.map((link) => ( externalLinks.map((link) => (
<a <a
key={link.id} key={link.id}
href={link.url} href={link.url}
onClick={(e) => { onClick={(e) => {
e.preventDefault(); e.preventDefault();
window.open( window.open(link.url, link.label, 'popup,noopener');
link.url,
link.label,
'popup,noopener',
);
}} }}
className={styles.navLink} className={styles.navLink}
title={link.label}
> >
<FaviconImg url={link.url} label={link.label} /> <FaviconImg url={link.url} label={link.label} customIcon={link.customIcon} />
{!collapsed && (
<>
{link.label} {link.label}
<svg <svg
width="10" width="10"
@ -176,6 +285,8 @@ export function AppLayout() {
<path d="M6 1h3v3" /> <path d="M6 1h3v3" />
<path d="M4 6l5-5" /> <path d="M4 6l5-5" />
</svg> </svg>
</>
)}
</a> </a>
))} ))}
</> </>
@ -190,7 +301,7 @@ export function AppLayout() {
className={({ isActive }) => className={({ isActive }) =>
`${styles.adminLink} ${isActive ? styles.adminLinkActive : ''}` `${styles.adminLink} ${isActive ? styles.adminLinkActive : ''}`
} }
// Alle /admin/* Pfade sollen aktiv sein title="Administration"
style={({ isActive }) => { style={({ isActive }) => {
const isAdminPath = const isAdminPath =
window.location.pathname.startsWith('/admin'); window.location.pathname.startsWith('/admin');
@ -215,12 +326,48 @@ export function AppLayout() {
<path d="M12.22 2h-.44a2 2 0 00-2 2v.18a2 2 0 01-1 1.73l-.43.25a2 2 0 01-2 0l-.15-.08a2 2 0 00-2.73.73l-.22.38a2 2 0 00.73 2.73l.15.1a2 2 0 011 1.72v.51a2 2 0 01-1 1.74l-.15.09a2 2 0 00-.73 2.73l.22.38a2 2 0 002.73.73l.15-.08a2 2 0 012 0l.43.25a2 2 0 011 1.73V20a2 2 0 002 2h.44a2 2 0 002-2v-.18a2 2 0 011-1.73l.43-.25a2 2 0 012 0l.15.08a2 2 0 002.73-.73l.22-.39a2 2 0 00-.73-2.73l-.15-.08a2 2 0 01-1-1.74v-.5a2 2 0 011-1.74l.15-.09a2 2 0 00.73-2.73l-.22-.38a2 2 0 00-2.73-.73l-.15.08a2 2 0 01-2 0l-.43-.25a2 2 0 01-1-1.73V4a2 2 0 00-2-2z" /> <path d="M12.22 2h-.44a2 2 0 00-2 2v.18a2 2 0 01-1 1.73l-.43.25a2 2 0 01-2 0l-.15-.08a2 2 0 00-2.73.73l-.22.38a2 2 0 00.73 2.73l.15.1a2 2 0 011 1.72v.51a2 2 0 01-1 1.74l-.15.09a2 2 0 00-.73 2.73l.22.38a2 2 0 002.73.73l.15-.08a2 2 0 012 0l.43.25a2 2 0 011 1.73V20a2 2 0 002 2h.44a2 2 0 002-2v-.18a2 2 0 011-1.73l.43-.25a2 2 0 012 0l.15.08a2 2 0 002.73-.73l.22-.39a2 2 0 00-.73-2.73l-.15-.08a2 2 0 01-1-1.74v-.5a2 2 0 011-1.74l.15-.09a2 2 0 00.73-2.73l-.22-.38a2 2 0 00-2.73-.73l-.15.08a2 2 0 01-2 0l-.43-.25a2 2 0 01-1-1.73V4a2 2 0 00-2-2z" />
<circle cx="12" cy="12" r="3" /> <circle cx="12" cy="12" r="3" />
</svg> </svg>
Administration {!collapsed && 'Administration'}
</NavLink> </NavLink>
</div> </div>
)} )}
{/* Theme Toggle */}
<div className={styles.themeToggle}>
{collapsed ? (
<button
className={styles.themeBtn}
onClick={() => {
const next =
mode === 'light'
? 'dark'
: mode === 'dark'
? 'system'
: 'light';
setMode(next);
}}
title={`Theme: ${THEME_OPTIONS.find((o) => o.value === mode)?.label}`}
>
{THEME_OPTIONS.find((o) => o.value === mode)?.icon}
</button>
) : (
<div className={styles.themeBtnGroup}>
{THEME_OPTIONS.map((opt) => (
<button
key={opt.value}
className={`${styles.themeOption} ${mode === opt.value ? styles.themeOptionActive : ''}`}
onClick={() => setMode(opt.value)}
title={opt.label}
>
<span className={styles.themeIcon}>{opt.icon}</span>
{opt.label}
</button>
))}
</div>
)}
</div>
<div className={styles.userInfo}> <div className={styles.userInfo}>
{collapsed ? (
<div <div
className={styles.userProfile} className={styles.userProfile}
onClick={() => navigate('/profile')} onClick={() => navigate('/profile')}
@ -229,6 +376,26 @@ export function AppLayout() {
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') navigate('/profile'); if (e.key === 'Enter' || e.key === ' ') navigate('/profile');
}} }}
title={`${user?.firstName} ${user?.lastName}`}
>
<UserAvatar
firstName={user?.firstName ?? ''}
lastName={user?.lastName ?? ''}
avatar={user?.avatar}
size={32}
/>
</div>
) : (
<>
<div
className={styles.userProfile}
onClick={() => navigate('/profile')}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ')
navigate('/profile');
}}
> >
<div className={styles.userProfileInner}> <div className={styles.userProfileInner}>
<UserAvatar <UserAvatar
@ -249,11 +416,18 @@ export function AppLayout() {
<button className={styles.logoutBtn} onClick={handleLogout}> <button className={styles.logoutBtn} onClick={handleLogout}>
Abmelden Abmelden
</button> </button>
</>
)}
</div> </div>
</aside> </aside>
{/* Main Content */} {/* Main Content */}
<main className={styles.main}> <main
className={styles.main}
style={{
marginLeft: collapsed ? 60 : undefined,
}}
>
<Outlet /> <Outlet />
</main> </main>
</div> </div>

View file

@ -0,0 +1,78 @@
import {
createContext,
useContext,
useState,
useEffect,
type ReactNode,
} from 'react';
type ThemeMode = 'light' | 'dark' | 'system';
interface ThemeContextType {
mode: ThemeMode;
setMode: (mode: ThemeMode) => void;
/** Tatsaechlich aktives Theme (resolved from system) */
resolved: 'light' | 'dark';
}
const ThemeContext = createContext<ThemeContextType>({
mode: 'system',
setMode: () => {},
resolved: 'light',
});
export function useTheme() {
return useContext(ThemeContext);
}
function getSystemTheme(): 'light' | 'dark' {
if (
typeof window !== 'undefined' &&
window.matchMedia('(prefers-color-scheme: dark)').matches
) {
return 'dark';
}
return 'light';
}
export function ThemeProvider({ children }: { children: ReactNode }) {
const [mode, setModeState] = useState<ThemeMode>(() => {
const saved = localStorage.getItem('theme-mode');
if (saved === 'light' || saved === 'dark' || saved === 'system') {
return saved;
}
return 'system';
});
const [systemTheme, setSystemTheme] = useState<'light' | 'dark'>(
getSystemTheme,
);
// Listen for system theme changes
useEffect(() => {
const mq = window.matchMedia('(prefers-color-scheme: dark)');
const handler = (e: MediaQueryListEvent) => {
setSystemTheme(e.matches ? 'dark' : 'light');
};
mq.addEventListener('change', handler);
return () => mq.removeEventListener('change', handler);
}, []);
const resolved = mode === 'system' ? systemTheme : mode;
// Apply theme to document
useEffect(() => {
document.documentElement.setAttribute('data-theme', resolved);
}, [resolved]);
const setMode = (newMode: ThemeMode) => {
setModeState(newMode);
localStorage.setItem('theme-mode', newMode);
};
return (
<ThemeContext.Provider value={{ mode, setMode, resolved }}>
{children}
</ThemeContext.Provider>
);
}