diff --git a/packages/core-service/src/core/settings/settings.controller.ts b/packages/core-service/src/core/settings/settings.controller.ts index 07700d7..84e8ebf 100644 --- a/packages/core-service/src/core/settings/settings.controller.ts +++ b/packages/core-service/src/core/settings/settings.controller.ts @@ -23,9 +23,11 @@ interface ExternalLink { label: string; url: string; sortOrder: number; + customIcon?: string; // Optional: Base64-encoded custom icon } const EXTERNAL_LINKS_KEY = 'platform_external_links'; +const BRANDING_LOGO_KEY = 'platform_branding_logo'; @ApiTags('Settings') @Controller('settings') @@ -83,6 +85,7 @@ export class SettingsController { label: link.label.trim(), url: link.url.trim(), sortOrder: link.sortOrder ?? index, + ...(link.customIcon && { customIcon: link.customIcon }), })); sorted.sort((a, b) => a.sortOrder - b.sortOrder); @@ -94,6 +97,57 @@ export class SettingsController { 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 * Favicon-URL fuer eine beliebige Webseite ermitteln. diff --git a/packages/frontend/src/admin/AdminCustomizePage.tsx b/packages/frontend/src/admin/AdminCustomizePage.tsx new file mode 100644 index 0000000..32afa07 --- /dev/null +++ b/packages/frontend/src/admin/AdminCustomizePage.tsx @@ -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(null); + const [logo, setLogo] = useState(null); + const [sidebarColor, setSidebarColor] = useState('#1e293b'); + const [hasChanges, setHasChanges] = useState(false); + const [saveSuccess, setSaveSuccess] = useState(false); + + const { data } = useQuery({ + queryKey: ['settings', 'branding'], + queryFn: async () => { + const res = await api.get('/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) => { + 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 ( +
+
+

Anpassungen

+

+ Logo, Farben und Branding-Einstellungen fuer die Plattform. +

+
+ + {/* Logo */} +
+

+ Plattform-Logo +

+

+ Das Logo wird oben links in der Sidebar angezeigt. Empfohlen: PNG oder + SVG mit transparentem Hintergrund, max. 500KB. +

+ +
+
+ {logo ? ( + Logo + ) : ( + + INSIGHT + + )} +
+ +
+ + + {logo && ( + + )} +
+
+
+ + {/* Sidebar-Farbe */} +
+

+ Sidebar-Farbe +

+

+ Die Hintergrundfarbe der linken Menue-Leiste. +

+ + {/* Voreinstellungen */} +
+ +
+ {SIDEBAR_PRESETS.map((preset) => ( + + ))} +
+
+ + {/* Eigene Farbe */} +
+ +
+ { + 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', + }} + /> + { + 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 */} +
+
+ INSIGHT +
+
+
+
+
+
+
+
+ + {/* Speichern */} +
+ {saveSuccess && ( + + Gespeichert! + + )} + {saveMutation.isError && ( + + Fehler beim Speichern + + )} + +
+
+ ); +} diff --git a/packages/frontend/src/admin/AdminExternalLinksPage.tsx b/packages/frontend/src/admin/AdminExternalLinksPage.tsx index 8e1f49a..a3bdb78 100644 --- a/packages/frontend/src/admin/AdminExternalLinksPage.tsx +++ b/packages/frontend/src/admin/AdminExternalLinksPage.tsx @@ -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 api from '../api/client'; @@ -42,6 +42,7 @@ interface ExternalLink { label: string; url: string; sortOrder: number; + customIcon?: string; } const cardStyle: React.CSSProperties = { @@ -114,6 +115,24 @@ function LinkRow({ isLast: boolean; }) { const faviconUrl = useFavicon(link.url); + const iconInputRef = useRef(null); + + const handleIconUpload = (e: React.ChangeEvent) => { + 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 (
- {/* Favicon-Vorschau */} + {/* Icon-Vorschau (klickbar fuer Upload) */}
+
iconInputRef.current?.click()} + title="Klicken zum Icon hochladen" > - {faviconUrl ? ( + {iconSrc ? ( { @@ -164,6 +194,34 @@ function LinkRow({ )} + {link.customIcon && ( + + )}
@@ -517,18 +575,19 @@ export function AdminExternalLinksPage() {
Hinweis: 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.{' '} - - - + + + + + , diff --git a/packages/frontend/src/shell/App.tsx b/packages/frontend/src/shell/App.tsx index 1636c1f..f95f2d2 100644 --- a/packages/frontend/src/shell/App.tsx +++ b/packages/frontend/src/shell/App.tsx @@ -9,6 +9,7 @@ import { AdminUsersPage } from '../admin/AdminUsersPage'; import { AdminTenantsPage } from '../admin/AdminTenantsPage'; import { AdminSsoPage } from '../admin/AdminSsoPage'; import { AdminExternalLinksPage } from '../admin/AdminExternalLinksPage'; +import { AdminCustomizePage } from '../admin/AdminCustomizePage'; import { ProfilePage } from '../profile/ProfilePage'; function PrivateRoute({ children }: { children: React.ReactNode }) { @@ -54,6 +55,7 @@ export function App() { } /> } /> } /> + } /> diff --git a/packages/frontend/src/shell/AppLayout.module.css b/packages/frontend/src/shell/AppLayout.module.css index 26a2278..511dbe1 100644 --- a/packages/frontend/src/shell/AppLayout.module.css +++ b/packages/frontend/src/shell/AppLayout.module.css @@ -3,9 +3,10 @@ min-height: 100vh; } +/* ===== Sidebar ===== */ .sidebar { width: var(--sidebar-width); - background: #1e293b; + background: var(--sidebar-bg, #1e293b); color: white; display: flex; flex-direction: column; @@ -14,11 +15,27 @@ left: 0; bottom: 0; z-index: 100; + transition: width 0.2s ease; } +.sidebarCollapsed { + width: 60px; +} + +/* ===== Brand / Logo ===== */ .brand { padding: 1.25rem 1.5rem; 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 { @@ -26,8 +43,29 @@ font-weight: 700; letter-spacing: 2px; 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 { flex: 1; padding: 1rem 0; @@ -74,6 +112,11 @@ transform: rotate(0deg); } +.navDivider { + margin: 0.5rem 1rem; + border-top: 1px solid rgba(255, 255, 255, 0.1); +} + .navLink { display: flex; align-items: center; @@ -84,6 +127,14 @@ font-size: 0.875rem; transition: all 0.15s; border-left: 3px solid transparent; + white-space: nowrap; + overflow: hidden; +} + +.sidebarCollapsed .navLink { + padding: 0.625rem 0; + justify-content: center; + border-left: none; } .navLink:hover { @@ -98,12 +149,20 @@ border-left-color: #60a5fa; } -/* Admin-Bereich ueber dem Profil */ +.sidebarCollapsed .active { + border-left-color: transparent; +} + +/* ===== Admin-Bereich ueber dem Profil ===== */ .adminSection { padding: 0.5rem 0.75rem; border-top: 1px solid rgba(255, 255, 255, 0.1); } +.sidebarCollapsed .adminSection { + padding: 0.5rem 0.25rem; +} + .adminLink { display: flex; align-items: center; @@ -115,6 +174,13 @@ font-weight: 500; border-radius: var(--radius-sm); transition: all 0.15s; + white-space: nowrap; + overflow: hidden; +} + +.sidebarCollapsed .adminLink { + padding: 0.625rem 0; + justify-content: center; } .adminLink:hover { @@ -128,11 +194,90 @@ 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 { padding: 1rem 1.5rem; border-top: 1px solid rgba(255, 255, 255, 0.1); } +.sidebarCollapsed .userInfo { + padding: 0.75rem 0; + display: flex; + justify-content: center; +} + .userProfile { cursor: pointer; padding: 0.375rem; @@ -200,8 +345,10 @@ color: white; } +/* ===== Main Content ===== */ .main { flex: 1; margin-left: var(--sidebar-width); padding: 2rem; + transition: margin-left 0.2s ease; } diff --git a/packages/frontend/src/shell/AppLayout.tsx b/packages/frontend/src/shell/AppLayout.tsx index 96c2e48..f95eeff 100644 --- a/packages/frontend/src/shell/AppLayout.tsx +++ b/packages/frontend/src/shell/AppLayout.tsx @@ -4,6 +4,7 @@ import { useQuery } from '@tanstack/react-query'; import { useAuth } from '../auth/AuthContext'; import { UserAvatar } from '../components/UserAvatar'; import api from '../api/client'; +import { useTheme } from '../theme/ThemeContext'; import styles from './AppLayout.module.css'; interface ExternalLink { @@ -11,10 +12,19 @@ interface ExternalLink { label: string; url: string; sortOrder: number; + customIcon?: string; } /** 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(null); const [failed, setFailed] = useState(false); @@ -39,6 +49,22 @@ function FaviconImg({ url, label }: { url: string; label: string }) { fetchFavicon(); }, [fetchFavicon]); + // Eigenes Icon hat Vorrang + if (customIcon) { + return ( + + ); + } + if (failed || !faviconUrl) { return ( { + 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({ queryKey: ['settings', 'external-links'], @@ -90,6 +134,21 @@ export function AppLayout() { 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 () => { await logout(); navigate('/login'); @@ -98,9 +157,55 @@ export function AppLayout() { return (
{/* Sidebar */} -
diff --git a/packages/frontend/src/theme/ThemeContext.tsx b/packages/frontend/src/theme/ThemeContext.tsx new file mode 100644 index 0000000..f86b983 --- /dev/null +++ b/packages/frontend/src/theme/ThemeContext.tsx @@ -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({ + 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(() => { + 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 ( + + {children} + + ); +}