mirror of
http://172.20.10.11:3000/gitadmin/INSIGHT-MVP.git
synced 2026-06-25 00:16:41 +02:00
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:
parent
0f9b3d4f36
commit
3bedda2b9d
11 changed files with 1087 additions and 85 deletions
|
|
@ -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.
|
||||||
|
|
|
||||||
461
packages/frontend/src/admin/AdminCustomizePage.tsx
Normal file
461
packages/frontend/src/admin/AdminCustomizePage.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
}}
|
}}
|
||||||
|
|
|
||||||
|
|
@ -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() {
|
||||||
|
|
|
||||||
|
|
@ -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 {
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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>,
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
78
packages/frontend/src/theme/ThemeContext.tsx
Normal file
78
packages/frontend/src/theme/ThemeContext.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue