This commit is contained in:
Matthias Hochmeister
2026-02-23 17:08:58 +01:00
commit f09748f4a1
97 changed files with 17729 additions and 0 deletions

View File

@@ -0,0 +1,95 @@
import React, { useEffect, useState } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { useAuth } from '../../contexts/AuthContext';
import { Box, CircularProgress, Typography, Alert, Button } from '@mui/material';
const LoginCallback: React.FC = () => {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const { login } = useAuth();
const [error, setError] = useState<string>('');
useEffect(() => {
const handleCallback = async () => {
const code = searchParams.get('code');
const errorParam = searchParams.get('error');
if (errorParam) {
setError(`Authentifizierungsfehler: ${errorParam}`);
return;
}
if (!code) {
setError('Kein Autorisierungscode erhalten');
return;
}
try {
await login(code);
// Redirect to dashboard on success
navigate('/dashboard', { replace: true });
} catch (err) {
console.error('Login callback error:', err);
setError(
err instanceof Error
? err.message
: 'Anmeldung fehlgeschlagen. Bitte versuchen Sie es erneut.'
);
}
};
handleCallback();
}, [searchParams, login, navigate]);
if (error) {
return (
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
minHeight: '100vh',
padding: 3,
}}
>
<Alert
severity="error"
sx={{
maxWidth: 500,
mb: 2,
width: '100%',
}}
>
{error}
</Alert>
<Button
variant="contained"
onClick={() => navigate('/login')}
>
Zurück zur Anmeldung
</Button>
</Box>
);
}
return (
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
minHeight: '100vh',
gap: 2,
}}
>
<CircularProgress size={60} />
<Typography variant="h6" color="text.secondary">
Anmeldung wird abgeschlossen...
</Typography>
</Box>
);
};
export default LoginCallback;

View File

@@ -0,0 +1,43 @@
import React from 'react';
import { Navigate } from 'react-router-dom';
import { useAuth } from '../../contexts/AuthContext';
import { Box, CircularProgress, Typography } from '@mui/material';
interface ProtectedRouteProps {
children: React.ReactNode;
}
const ProtectedRoute: React.FC<ProtectedRouteProps> = ({ children }) => {
const { isAuthenticated, isLoading } = useAuth();
// Show loading spinner while checking authentication
if (isLoading) {
return (
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
minHeight: '100vh',
gap: 2,
}}
>
<CircularProgress size={60} />
<Typography variant="h6" color="text.secondary">
Authentifizierung wird überprüft...
</Typography>
</Box>
);
}
// If not authenticated, redirect to login
if (!isAuthenticated) {
return <Navigate to="/login" replace />;
}
// User is authenticated, render children
return <>{children}</>;
};
export default ProtectedRoute;

View File

@@ -0,0 +1,174 @@
import React from 'react';
import {
Card,
CardContent,
Typography,
List,
ListItem,
ListItemAvatar,
ListItemText,
Avatar,
Box,
} from '@mui/material';
import {
LocalFireDepartment,
Person,
DirectionsCar,
Assignment,
} from '@mui/icons-material';
interface Activity {
id: string;
type: 'incident' | 'member' | 'vehicle' | 'task';
title: string;
description: string;
timestamp: string;
}
// Placeholder activities
const placeholderActivities: Activity[] = [
{
id: '1',
type: 'incident',
title: 'Brandeinsatz',
description: 'Kleinbrand in der Hauptstraße',
timestamp: 'Vor 2 Stunden',
},
{
id: '2',
type: 'member',
title: 'Neues Mitglied',
description: 'Max Mustermann ist der Feuerwehr beigetreten',
timestamp: 'Vor 5 Stunden',
},
{
id: '3',
type: 'vehicle',
title: 'Fahrzeugwartung',
description: 'LF 16/12 - Wartung abgeschlossen',
timestamp: 'Gestern',
},
{
id: '4',
type: 'task',
title: 'Aufgabe zugewiesen',
description: 'Neue Aufgabe: Inventur Atemschutzgeräte',
timestamp: 'Vor 2 Tagen',
},
];
const ActivityFeed: React.FC = () => {
const getActivityIcon = (type: Activity['type']) => {
switch (type) {
case 'incident':
return <LocalFireDepartment />;
case 'member':
return <Person />;
case 'vehicle':
return <DirectionsCar />;
case 'task':
return <Assignment />;
default:
return <LocalFireDepartment />;
}
};
const getActivityColor = (type: Activity['type']) => {
switch (type) {
case 'incident':
return 'error.main';
case 'member':
return 'success.main';
case 'vehicle':
return 'warning.main';
case 'task':
return 'info.main';
default:
return 'primary.main';
}
};
return (
<Card>
<CardContent>
<Typography variant="h6" gutterBottom>
Letzte Aktivitäten
</Typography>
<List sx={{ pt: 2 }}>
{placeholderActivities.map((activity, index) => (
<React.Fragment key={activity.id}>
<ListItem
alignItems="flex-start"
sx={{
px: 0,
position: 'relative',
'&::before':
index < placeholderActivities.length - 1
? {
content: '""',
position: 'absolute',
left: 19,
top: 56,
bottom: -8,
width: 2,
bgcolor: 'divider',
}
: {},
}}
>
<ListItemAvatar>
<Avatar
sx={{
bgcolor: getActivityColor(activity.type),
width: 40,
height: 40,
}}
>
{getActivityIcon(activity.type)}
</Avatar>
</ListItemAvatar>
<ListItemText
primary={
<Typography variant="subtitle2" component="span">
{activity.title}
</Typography>
}
secondary={
<Box>
<Typography
variant="body2"
color="text.secondary"
component="span"
sx={{ display: 'block' }}
>
{activity.description}
</Typography>
<Typography
variant="caption"
color="text.secondary"
component="span"
>
{activity.timestamp}
</Typography>
</Box>
}
/>
</ListItem>
</React.Fragment>
))}
</List>
{placeholderActivities.length === 0 && (
<Box sx={{ textAlign: 'center', py: 4 }}>
<Typography variant="body2" color="text.secondary">
Keine Aktivitäten vorhanden
</Typography>
</Box>
)}
</CardContent>
</Card>
);
};
export default ActivityFeed;

View File

@@ -0,0 +1,21 @@
import React from 'react';
import { MenuBook } from '@mui/icons-material';
import ServiceCard from './ServiceCard';
interface BookstackCardProps {
onClick?: () => void;
}
const BookstackCard: React.FC<BookstackCardProps> = ({ onClick }) => {
return (
<ServiceCard
title="Bookstack"
description="Dokumentation und Wiki"
icon={MenuBook}
status="disconnected"
onClick={onClick}
/>
);
};
export default BookstackCard;

View File

@@ -0,0 +1,46 @@
import { useState, ReactNode } from 'react';
import { Box, Toolbar } from '@mui/material';
import Header from '../shared/Header';
import Sidebar from '../shared/Sidebar';
import { useAuth } from '../../contexts/AuthContext';
import Loading from '../shared/Loading';
interface DashboardLayoutProps {
children: ReactNode;
}
function DashboardLayout({ children }: DashboardLayoutProps) {
const [mobileOpen, setMobileOpen] = useState(false);
const { isLoading } = useAuth();
const handleDrawerToggle = () => {
setMobileOpen(!mobileOpen);
};
if (isLoading) {
return <Loading message="Lade Dashboard..." />;
}
return (
<Box sx={{ display: 'flex' }}>
<Header onMenuClick={handleDrawerToggle} />
<Sidebar mobileOpen={mobileOpen} onMobileClose={() => setMobileOpen(false)} />
<Box
component="main"
sx={{
flexGrow: 1,
p: 3,
width: { sm: `calc(100% - 240px)` },
minHeight: '100vh',
backgroundColor: 'background.default',
}}
>
<Toolbar />
{children}
</Box>
</Box>
);
}
export default DashboardLayout;

View File

@@ -0,0 +1,21 @@
import React from 'react';
import { Cloud } from '@mui/icons-material';
import ServiceCard from './ServiceCard';
interface NextcloudCardProps {
onClick?: () => void;
}
const NextcloudCard: React.FC<NextcloudCardProps> = ({ onClick }) => {
return (
<ServiceCard
title="Nextcloud"
description="Dateien und Dokumente"
icon={Cloud}
status="disconnected"
onClick={onClick}
/>
);
};
export default NextcloudCard;

View File

@@ -0,0 +1,106 @@
import React from 'react';
import {
Card,
CardActionArea,
CardContent,
Typography,
Box,
Chip,
} from '@mui/material';
import { SvgIconComponent } from '@mui/icons-material';
interface ServiceCardProps {
title: string;
description: string;
icon: SvgIconComponent;
status: 'connected' | 'disconnected';
onClick?: () => void;
}
const ServiceCard: React.FC<ServiceCardProps> = ({
title,
description,
icon: Icon,
status,
onClick,
}) => {
const isConnected = status === 'connected';
return (
<Card
sx={{
height: '100%',
transition: 'all 0.3s ease',
'&:hover': {
transform: 'translateY(-4px)',
boxShadow: 4,
},
}}
>
<CardActionArea
onClick={onClick}
sx={{
height: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'stretch',
justifyContent: 'flex-start',
}}
>
<CardContent sx={{ flexGrow: 1, width: '100%' }}>
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
mb: 2,
}}
>
<Box
sx={{
display: 'flex',
alignItems: 'center',
bgcolor: isConnected ? 'success.light' : 'grey.300',
borderRadius: '50%',
p: 1.5,
}}
>
<Icon
sx={{
fontSize: 32,
color: isConnected ? 'success.dark' : 'grey.600',
}}
/>
</Box>
<Box
sx={{
width: 12,
height: 12,
borderRadius: '50%',
bgcolor: isConnected ? 'success.main' : 'grey.400',
}}
/>
</Box>
<Typography variant="h6" component="div" gutterBottom>
{title}
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ mb: 2 }}>
{description}
</Typography>
<Chip
label={isConnected ? 'Verbunden' : 'Noch nicht konfiguriert'}
size="small"
color={isConnected ? 'success' : 'default'}
variant={isConnected ? 'filled' : 'outlined'}
/>
</CardContent>
</CardActionArea>
</Card>
);
};
export default ServiceCard;

View File

@@ -0,0 +1,74 @@
import React from 'react';
import { Card, CardContent, Typography, Box } from '@mui/material';
import { SvgIconComponent } from '@mui/icons-material';
interface StatsCardProps {
title: string;
value: string | number;
icon: SvgIconComponent;
color?: string;
}
const StatsCard: React.FC<StatsCardProps> = ({
title,
value,
icon: Icon,
color = 'primary.main',
}) => {
return (
<Card
sx={{
height: '100%',
transition: 'all 0.3s ease',
'&:hover': {
boxShadow: 3,
},
}}
>
<CardContent>
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}
>
<Box sx={{ flex: 1 }}>
<Typography
variant="body2"
color="text.secondary"
gutterBottom
sx={{ textTransform: 'uppercase', fontSize: '0.75rem' }}
>
{title}
</Typography>
<Typography variant="h4" component="div" sx={{ fontWeight: 'bold' }}>
{value}
</Typography>
</Box>
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
bgcolor: `${color}15`,
borderRadius: '50%',
width: 56,
height: 56,
}}
>
<Icon
sx={{
fontSize: 32,
color: color,
}}
/>
</Box>
</Box>
</CardContent>
</Card>
);
};
export default StatsCard;

View File

@@ -0,0 +1,137 @@
import React from 'react';
import {
Card,
CardContent,
Avatar,
Typography,
Box,
Chip,
} from '@mui/material';
import { User } from '../../types/auth.types';
interface UserProfileProps {
user: User;
}
const UserProfile: React.FC<UserProfileProps> = ({ user }) => {
// Get first letter of name for avatar
const getInitials = (name: string): string => {
return name.charAt(0).toUpperCase();
};
// Format date (placeholder until we have actual dates)
const formatDate = (date?: string): string => {
if (!date) return 'Nicht verfügbar';
return new Date(date).toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
});
};
return (
<Card
sx={{
background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
color: 'white',
}}
>
<CardContent>
<Box
sx={{
display: 'flex',
alignItems: 'center',
flexDirection: { xs: 'column', sm: 'row' },
gap: 3,
}}
>
{/* Avatar */}
<Avatar
sx={{
width: 80,
height: 80,
bgcolor: 'rgba(255, 255, 255, 0.2)',
fontSize: '2rem',
fontWeight: 'bold',
}}
>
{getInitials(user.name)}
</Avatar>
{/* User Info */}
<Box sx={{ flex: 1, textAlign: { xs: 'center', sm: 'left' } }}>
<Typography variant="h5" component="div" gutterBottom>
{user.name}
</Typography>
<Typography variant="body2" sx={{ opacity: 0.9 }}>
{user.email}
</Typography>
{user.preferred_username && (
<Typography variant="body2" sx={{ opacity: 0.9 }}>
@{user.preferred_username}
</Typography>
)}
<Box
sx={{
display: 'flex',
gap: 1,
mt: 2,
flexWrap: 'wrap',
justifyContent: { xs: 'center', sm: 'flex-start' },
}}
>
<Chip
label="Aktiv"
size="small"
sx={{
bgcolor: 'rgba(255, 255, 255, 0.2)',
color: 'white',
}}
/>
{user.groups && user.groups.length > 0 && (
<Chip
label={`${user.groups.length} Gruppe${user.groups.length > 1 ? 'n' : ''}`}
size="small"
sx={{
bgcolor: 'rgba(255, 255, 255, 0.2)',
color: 'white',
}}
/>
)}
</Box>
</Box>
{/* Additional Info */}
<Box
sx={{
display: 'flex',
flexDirection: 'column',
gap: 1,
textAlign: { xs: 'center', sm: 'right' },
}}
>
<Box>
<Typography variant="caption" sx={{ opacity: 0.8 }}>
Letzter Login
</Typography>
<Typography variant="body2" sx={{ fontWeight: 'medium' }}>
Heute
</Typography>
</Box>
<Box>
<Typography variant="caption" sx={{ opacity: 0.8 }}>
Mitglied seit
</Typography>
<Typography variant="body2" sx={{ fontWeight: 'medium' }}>
{formatDate()}
</Typography>
</Box>
</Box>
</Box>
</CardContent>
</Card>
);
};
export default UserProfile;

View File

@@ -0,0 +1,21 @@
import React from 'react';
import { Assignment } from '@mui/icons-material';
import ServiceCard from './ServiceCard';
interface VikunjaCardProps {
onClick?: () => void;
}
const VikunjaCard: React.FC<VikunjaCardProps> = ({ onClick }) => {
return (
<ServiceCard
title="Vikunja"
description="Aufgaben und Projekte"
icon={Assignment}
status="disconnected"
onClick={onClick}
/>
);
};
export default VikunjaCard;

View File

@@ -0,0 +1,8 @@
export { default as UserProfile } from './UserProfile';
export { default as ServiceCard } from './ServiceCard';
export { default as NextcloudCard } from './NextcloudCard';
export { default as VikunjaCard } from './VikunjaCard';
export { default as BookstackCard } from './BookstackCard';
export { default as StatsCard } from './StatsCard';
export { default as ActivityFeed } from './ActivityFeed';
export { default as DashboardLayout } from './DashboardLayout';

View File

@@ -0,0 +1,55 @@
import React, { ReactNode } from 'react';
import { Box, Typography, Button } from '@mui/material';
interface EmptyStateProps {
icon: ReactNode;
title: string;
message: string;
action?: {
label: string;
onClick: () => void;
};
}
const EmptyState: React.FC<EmptyStateProps> = ({ icon, title, message, action }) => {
return (
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
py: 8,
px: 3,
textAlign: 'center',
}}
>
<Box
sx={{
fontSize: 80,
color: 'text.disabled',
mb: 2,
}}
>
{icon}
</Box>
<Typography variant="h5" component="h2" gutterBottom color="text.primary">
{title}
</Typography>
<Typography
variant="body1"
color="text.secondary"
sx={{ mb: action ? 3 : 0, maxWidth: 500 }}
>
{message}
</Typography>
{action && (
<Button variant="contained" color="primary" onClick={action.onClick}>
{action.label}
</Button>
)}
</Box>
);
};
export default EmptyState;

View File

@@ -0,0 +1,137 @@
import React, { Component, ErrorInfo, ReactNode } from 'react';
import { Box, Card, CardContent, Typography, Button } from '@mui/material';
import { ErrorOutline, Refresh } from '@mui/icons-material';
interface Props {
children: ReactNode;
}
interface State {
hasError: boolean;
error: Error | null;
errorInfo: ErrorInfo | null;
}
class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = {
hasError: false,
error: null,
errorInfo: null,
};
}
static getDerivedStateFromError(error: Error): Partial<State> {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: ErrorInfo): void {
console.error('ErrorBoundary caught an error:', error);
console.error('Error Info:', errorInfo);
this.setState({ error, errorInfo });
}
handleReset = (): void => {
this.setState({
hasError: false,
error: null,
errorInfo: null,
});
};
render(): ReactNode {
if (this.state.hasError) {
return (
<Box
sx={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
minHeight: '100vh',
bgcolor: 'background.default',
p: 3,
}}
>
<Card
sx={{
maxWidth: 600,
width: '100%',
}}
>
<CardContent
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
p: 4,
}}
>
<ErrorOutline
sx={{
fontSize: 80,
color: 'error.main',
mb: 2,
}}
/>
<Typography variant="h5" component="h1" gutterBottom align="center">
Etwas ist schiefgelaufen
</Typography>
<Typography
variant="body1"
color="text.secondary"
align="center"
sx={{ mb: 3 }}
>
Ein unerwarteter Fehler ist aufgetreten. Bitte versuchen Sie es
erneut.
</Typography>
{this.state.error && (
<Box
sx={{
width: '100%',
bgcolor: 'grey.100',
p: 2,
borderRadius: 1,
mb: 3,
maxHeight: 200,
overflow: 'auto',
}}
>
<Typography
variant="caption"
component="pre"
sx={{
fontFamily: 'monospace',
whiteSpace: 'pre-wrap',
wordBreak: 'break-word',
margin: 0,
}}
>
{this.state.error.toString()}
</Typography>
</Box>
)}
<Button
variant="contained"
color="primary"
size="large"
startIcon={<Refresh />}
onClick={this.handleReset}
fullWidth
>
Erneut versuchen
</Button>
</CardContent>
</Card>
</Box>
);
}
return this.props.children;
}
}
export default ErrorBoundary;

View File

@@ -0,0 +1,161 @@
import { useState } from 'react';
import {
AppBar,
Toolbar,
Typography,
IconButton,
Menu,
MenuItem,
Avatar,
ListItemIcon,
Divider,
Box,
} from '@mui/material';
import {
LocalFireDepartment,
Person,
Settings,
Logout,
Menu as MenuIcon,
} from '@mui/icons-material';
import { useAuth } from '../../contexts/AuthContext';
import { useNavigate } from 'react-router-dom';
interface HeaderProps {
onMenuClick: () => void;
}
function Header({ onMenuClick }: HeaderProps) {
const { user, logout } = useAuth();
const navigate = useNavigate();
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
const handleMenuOpen = (event: React.MouseEvent<HTMLElement>) => {
setAnchorEl(event.currentTarget);
};
const handleMenuClose = () => {
setAnchorEl(null);
};
const handleProfile = () => {
handleMenuClose();
navigate('/profile');
};
const handleSettings = () => {
handleMenuClose();
navigate('/settings');
};
const handleLogout = () => {
handleMenuClose();
logout();
};
// Get initials for avatar
const getInitials = () => {
if (!user) return '?';
const initials = (user.given_name?.[0] || '') + (user.family_name?.[0] || '');
return initials || user.name?.[0] || '?';
};
return (
<AppBar
position="fixed"
sx={{
zIndex: (theme) => theme.zIndex.drawer + 1,
}}
>
<Toolbar>
<IconButton
color="inherit"
aria-label="Menü öffnen"
edge="start"
onClick={onMenuClick}
sx={{ mr: 2, display: { sm: 'none' } }}
>
<MenuIcon />
</IconButton>
<LocalFireDepartment sx={{ mr: 2 }} />
<Typography variant="h6" component="div" sx={{ flexGrow: 1 }}>
Feuerwehr Dashboard
</Typography>
{user && (
<>
<IconButton
onClick={handleMenuOpen}
size="small"
aria-label="Benutzerkonto"
aria-controls="user-menu"
aria-haspopup="true"
>
<Avatar
sx={{
bgcolor: 'secondary.main',
width: 32,
height: 32,
fontSize: '0.875rem',
}}
>
{getInitials()}
</Avatar>
</IconButton>
<Menu
id="user-menu"
anchorEl={anchorEl}
open={Boolean(anchorEl)}
onClose={handleMenuClose}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'right',
}}
transformOrigin={{
vertical: 'top',
horizontal: 'right',
}}
PaperProps={{
elevation: 3,
sx: { minWidth: 250, mt: 1 },
}}
>
<Box sx={{ px: 2, py: 1.5 }}>
<Typography variant="subtitle1" sx={{ fontWeight: 600 }}>
{user.name}
</Typography>
<Typography variant="body2" color="text.secondary">
{user.email}
</Typography>
</Box>
<Divider />
<MenuItem onClick={handleProfile}>
<ListItemIcon>
<Person fontSize="small" />
</ListItemIcon>
Profil
</MenuItem>
<MenuItem onClick={handleSettings}>
<ListItemIcon>
<Settings fontSize="small" />
</ListItemIcon>
Einstellungen
</MenuItem>
<Divider />
<MenuItem onClick={handleLogout}>
<ListItemIcon>
<Logout fontSize="small" />
</ListItemIcon>
Abmelden
</MenuItem>
</Menu>
</>
)}
</Toolbar>
</AppBar>
);
}
export default Header;

View File

@@ -0,0 +1,29 @@
import { Box, CircularProgress, Typography } from '@mui/material';
interface LoadingProps {
message?: string;
}
function Loading({ message }: LoadingProps) {
return (
<Box
sx={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
minHeight: '100vh',
gap: 2,
}}
>
<CircularProgress />
{message && (
<Typography variant="body1" color="text.secondary">
{message}
</Typography>
)}
</Box>
);
}
export default Loading;

View File

@@ -0,0 +1,156 @@
import {
Drawer,
List,
ListItem,
ListItemButton,
ListItemIcon,
ListItemText,
Toolbar,
Tooltip,
} from '@mui/material';
import {
Dashboard as DashboardIcon,
LocalFireDepartment,
DirectionsCar,
Build,
People,
} from '@mui/icons-material';
import { useNavigate, useLocation } from 'react-router-dom';
const DRAWER_WIDTH = 240;
interface NavigationItem {
text: string;
icon: JSX.Element;
path: string;
}
const navigationItems: NavigationItem[] = [
{
text: 'Dashboard',
icon: <DashboardIcon />,
path: '/dashboard',
},
{
text: 'Einsätze',
icon: <LocalFireDepartment />,
path: '/einsaetze',
},
{
text: 'Fahrzeuge',
icon: <DirectionsCar />,
path: '/fahrzeuge',
},
{
text: 'Ausrüstung',
icon: <Build />,
path: '/ausruestung',
},
{
text: 'Mitglieder',
icon: <People />,
path: '/mitglieder',
},
];
interface SidebarProps {
mobileOpen: boolean;
onMobileClose: () => void;
}
function Sidebar({ mobileOpen, onMobileClose }: SidebarProps) {
const navigate = useNavigate();
const location = useLocation();
const handleNavigation = (path: string) => {
navigate(path);
onMobileClose();
};
const drawerContent = (
<>
<Toolbar />
<List>
{navigationItems.map((item) => {
const isActive = location.pathname === item.path;
return (
<ListItem key={item.text} disablePadding>
<Tooltip title={item.text} placement="right" arrow>
<ListItemButton
selected={isActive}
onClick={() => handleNavigation(item.path)}
aria-label={`Zu ${item.text} navigieren`}
sx={{
'&.Mui-selected': {
backgroundColor: 'primary.light',
color: 'primary.contrastText',
'&:hover': {
backgroundColor: 'primary.main',
},
'& .MuiListItemIcon-root': {
color: 'primary.contrastText',
},
},
}}
>
<ListItemIcon
sx={{
color: isActive ? 'inherit' : 'text.secondary',
}}
>
{item.icon}
</ListItemIcon>
<ListItemText primary={item.text} />
</ListItemButton>
</Tooltip>
</ListItem>
);
})}
</List>
</>
);
return (
<>
{/* Mobile drawer */}
<Drawer
variant="temporary"
open={mobileOpen}
onClose={onMobileClose}
ModalProps={{
keepMounted: true, // Better mobile performance
}}
sx={{
display: { xs: 'block', sm: 'none' },
'& .MuiDrawer-paper': {
boxSizing: 'border-box',
width: DRAWER_WIDTH,
},
}}
aria-label="Mobile Navigation"
>
{drawerContent}
</Drawer>
{/* Desktop drawer */}
<Drawer
variant="permanent"
sx={{
display: { xs: 'none', sm: 'block' },
width: DRAWER_WIDTH,
flexShrink: 0,
'& .MuiDrawer-paper': {
width: DRAWER_WIDTH,
boxSizing: 'border-box',
},
}}
open
aria-label="Desktop Navigation"
>
{drawerContent}
</Drawer>
</>
);
}
export default Sidebar;

View File

@@ -0,0 +1,44 @@
import React from 'react';
import { Card, CardContent, Skeleton, Box } from '@mui/material';
interface SkeletonCardProps {
variant?: 'basic' | 'withAvatar' | 'detailed';
}
const SkeletonCard: React.FC<SkeletonCardProps> = ({ variant = 'basic' }) => {
return (
<Card>
<CardContent>
{variant === 'withAvatar' && (
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
<Skeleton variant="circular" width={40} height={40} sx={{ mr: 2 }} />
<Box sx={{ flex: 1 }}>
<Skeleton variant="text" width="60%" height={24} />
<Skeleton variant="text" width="40%" height={20} />
</Box>
</Box>
)}
{variant === 'detailed' && (
<>
<Skeleton variant="text" width="80%" height={32} sx={{ mb: 1 }} />
<Skeleton variant="text" width="100%" height={20} sx={{ mb: 1 }} />
<Skeleton variant="text" width="100%" height={20} sx={{ mb: 1 }} />
<Skeleton variant="text" width="60%" height={20} sx={{ mb: 2 }} />
<Skeleton variant="rectangular" width="100%" height={140} />
</>
)}
{variant === 'basic' && (
<>
<Skeleton variant="text" width="70%" height={28} sx={{ mb: 1 }} />
<Skeleton variant="text" width="100%" height={20} sx={{ mb: 1 }} />
<Skeleton variant="text" width="90%" height={20} />
</>
)}
</CardContent>
</Card>
);
};
export default SkeletonCard;

View File

@@ -0,0 +1,7 @@
// Shared components barrel export
export { default as ErrorBoundary } from './ErrorBoundary';
export { default as EmptyState } from './EmptyState';
export { default as SkeletonCard } from './SkeletonCard';
export { default as Header } from './Header';
export { default as Sidebar } from './Sidebar';
export { default as Loading } from './Loading';