adding chat features, admin features and bug fixes

This commit is contained in:
Matthias Hochmeister
2026-03-12 08:16:34 +01:00
parent 7b14e3d5ba
commit 31f1414e06
43 changed files with 2610 additions and 16 deletions

View File

@@ -23,6 +23,7 @@
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-router-dom": "^6.20.0",
"dompurify": "^2.5.8",
"recharts": "^2.12.7"
},
"devDependencies": {

View File

@@ -24,6 +24,8 @@ import Kalender from './pages/Kalender';
import UebungDetail from './pages/UebungDetail';
import Veranstaltungen from './pages/Veranstaltungen';
import VeranstaltungKategorien from './pages/VeranstaltungKategorien';
import Wissen from './pages/Wissen';
import AdminDashboard from './pages/AdminDashboard';
import NotFound from './pages/NotFound';
function App() {
@@ -203,6 +205,22 @@ function App() {
</ProtectedRoute>
}
/>
<Route
path="/wissen"
element={
<ProtectedRoute>
<Wissen />
</ProtectedRoute>
}
/>
<Route
path="/admin"
element={
<ProtectedRoute>
<AdminDashboard />
</ProtectedRoute>
}
/>
<Route path="*" element={<NotFound />} />
</Routes>
</AuthProvider>

View File

@@ -0,0 +1,131 @@
import { useState } from 'react';
import {
Box,
TextField,
Button,
MenuItem,
Typography,
Dialog,
DialogTitle,
DialogContent,
DialogContentText,
DialogActions,
CircularProgress,
} from '@mui/material';
import SendIcon from '@mui/icons-material/Send';
import { useMutation } from '@tanstack/react-query';
import { adminApi } from '../../services/admin';
import { useNotification } from '../../contexts/NotificationContext';
import type { BroadcastPayload } from '../../types/admin.types';
function NotificationBroadcastTab() {
const { showSuccess, showError } = useNotification();
const [titel, setTitel] = useState('');
const [nachricht, setNachricht] = useState('');
const [schwere, setSchwere] = useState<'info' | 'warnung' | 'fehler'>('info');
const [targetGroup, setTargetGroup] = useState('');
const [confirmOpen, setConfirmOpen] = useState(false);
const broadcastMutation = useMutation({
mutationFn: (data: BroadcastPayload) => adminApi.broadcast(data),
onSuccess: (result) => {
showSuccess(`Benachrichtigung an ${result.sent} Benutzer gesendet`);
setTitel('');
setNachricht('');
setSchwere('info');
setTargetGroup('');
},
onError: () => {
showError('Fehler beim Senden der Benachrichtigung');
},
});
const handleSubmit = () => {
setConfirmOpen(true);
};
const handleConfirm = () => {
setConfirmOpen(false);
broadcastMutation.mutate({
titel,
nachricht,
schwere,
...(targetGroup.trim() ? { targetGroup: targetGroup.trim() } : {}),
});
};
return (
<Box sx={{ maxWidth: 600 }}>
<Typography variant="h6" sx={{ mb: 2 }}>Benachrichtigung senden</Typography>
<TextField
label="Titel"
fullWidth
value={titel}
onChange={(e) => setTitel(e.target.value)}
sx={{ mb: 2 }}
inputProps={{ maxLength: 200 }}
/>
<TextField
label="Nachricht"
fullWidth
multiline
rows={4}
value={nachricht}
onChange={(e) => setNachricht(e.target.value)}
sx={{ mb: 2 }}
inputProps={{ maxLength: 2000 }}
/>
<TextField
select
label="Schwere"
fullWidth
value={schwere}
onChange={(e) => setSchwere(e.target.value as 'info' | 'warnung' | 'fehler')}
sx={{ mb: 2 }}
>
<MenuItem value="info">Info</MenuItem>
<MenuItem value="warnung">Warnung</MenuItem>
<MenuItem value="fehler">Fehler</MenuItem>
</TextField>
<TextField
label="Zielgruppe (optional)"
fullWidth
value={targetGroup}
onChange={(e) => setTargetGroup(e.target.value)}
helperText="Leer lassen um an alle aktiven Benutzer zu senden"
sx={{ mb: 3 }}
/>
<Button
variant="contained"
startIcon={broadcastMutation.isPending ? <CircularProgress size={18} color="inherit" /> : <SendIcon />}
onClick={handleSubmit}
disabled={!titel.trim() || !nachricht.trim() || broadcastMutation.isPending}
>
Senden
</Button>
<Dialog open={confirmOpen} onClose={() => setConfirmOpen(false)}>
<DialogTitle>Benachrichtigung senden?</DialogTitle>
<DialogContent>
<DialogContentText>
Sind Sie sicher, dass Sie diese Benachrichtigung
{targetGroup.trim() ? ` an die Gruppe "${targetGroup.trim()}"` : ' an alle aktiven Benutzer'} senden moechten?
</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={() => setConfirmOpen(false)}>Abbrechen</Button>
<Button onClick={handleConfirm} variant="contained" color="primary">
Bestaetigen
</Button>
</DialogActions>
</Dialog>
</Box>
);
}
export default NotificationBroadcastTab;

View File

@@ -0,0 +1,205 @@
import { useState } from 'react';
import {
Box,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
Paper,
Button,
Dialog,
DialogTitle,
DialogContent,
DialogActions,
TextField,
IconButton,
Typography,
CircularProgress,
} from '@mui/material';
import DeleteIcon from '@mui/icons-material/Delete';
import AddIcon from '@mui/icons-material/Add';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { adminApi } from '../../services/admin';
import type { PingResult } from '../../types/admin.types';
function ServiceManagerTab() {
const queryClient = useQueryClient();
const [dialogOpen, setDialogOpen] = useState(false);
const [newName, setNewName] = useState('');
const [newUrl, setNewUrl] = useState('');
const { data: services, isLoading: servicesLoading } = useQuery({
queryKey: ['admin', 'services'],
queryFn: adminApi.getServices,
refetchInterval: 15000,
});
const { data: pingResults, isLoading: pingLoading } = useQuery({
queryKey: ['admin', 'services', 'ping'],
queryFn: adminApi.pingAll,
refetchInterval: 15000,
});
const createMutation = useMutation({
mutationFn: (data: { name: string; url: string }) => adminApi.createService(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin', 'services'] });
setDialogOpen(false);
setNewName('');
setNewUrl('');
},
});
const deleteMutation = useMutation({
mutationFn: (id: string) => adminApi.deleteService(id),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['admin', 'services'] });
},
});
const getPingForUrl = (url: string): PingResult | undefined => {
return pingResults?.find((p) => p.url === url);
};
const handleCreate = () => {
if (newName.trim() && newUrl.trim()) {
createMutation.mutate({ name: newName.trim(), url: newUrl.trim() });
}
};
if (servicesLoading) {
return <Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}><CircularProgress /></Box>;
}
const allItems = [
...(services ?? []).map((s) => ({
id: s.id,
name: s.name,
url: s.url,
type: s.type,
isCustom: s.type === 'custom',
})),
];
// Also include internal services from ping results that aren't in the DB
if (pingResults) {
for (const pr of pingResults) {
if (!allItems.find((item) => item.url === pr.url)) {
allItems.push({
id: pr.url,
name: pr.name,
url: pr.url,
type: 'internal' as const,
isCustom: false,
});
}
}
}
return (
<Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Typography variant="h6">Service Monitor</Typography>
<Button startIcon={<AddIcon />} variant="contained" onClick={() => setDialogOpen(true)}>
Service hinzufuegen
</Button>
</Box>
<TableContainer component={Paper}>
<Table>
<TableHead>
<TableRow>
<TableCell>Status</TableCell>
<TableCell>Name</TableCell>
<TableCell>URL</TableCell>
<TableCell>Typ</TableCell>
<TableCell>Latenz</TableCell>
<TableCell>Aktionen</TableCell>
</TableRow>
</TableHead>
<TableBody>
{allItems.map((item) => {
const ping = getPingForUrl(item.url);
return (
<TableRow key={item.id}>
<TableCell>
{pingLoading ? (
<CircularProgress size={16} />
) : (
<Box
sx={{
width: 12,
height: 12,
borderRadius: '50%',
bgcolor: ping?.status === 'up' ? 'success.main' : ping ? 'error.main' : 'grey.400',
}}
/>
)}
</TableCell>
<TableCell>{item.name}</TableCell>
<TableCell sx={{ maxWidth: 300, overflow: 'hidden', textOverflow: 'ellipsis' }}>
{item.url}
</TableCell>
<TableCell>{item.type}</TableCell>
<TableCell>{ping ? `${ping.latencyMs}ms` : '-'}</TableCell>
<TableCell>
{item.isCustom && (
<IconButton
size="small"
color="error"
onClick={() => deleteMutation.mutate(item.id)}
disabled={deleteMutation.isPending}
>
<DeleteIcon fontSize="small" />
</IconButton>
)}
</TableCell>
</TableRow>
);
})}
{allItems.length === 0 && (
<TableRow>
<TableCell colSpan={6} align="center">Keine Services konfiguriert</TableCell>
</TableRow>
)}
</TableBody>
</Table>
</TableContainer>
<Dialog open={dialogOpen} onClose={() => setDialogOpen(false)} maxWidth="sm" fullWidth>
<DialogTitle>Neuen Service hinzufuegen</DialogTitle>
<DialogContent>
<TextField
autoFocus
margin="dense"
label="Name"
fullWidth
value={newName}
onChange={(e) => setNewName(e.target.value)}
/>
<TextField
margin="dense"
label="URL"
fullWidth
value={newUrl}
onChange={(e) => setNewUrl(e.target.value)}
/>
</DialogContent>
<DialogActions>
<Button onClick={() => setDialogOpen(false)}>Abbrechen</Button>
<Button
onClick={handleCreate}
variant="contained"
disabled={createMutation.isPending || !newName.trim() || !newUrl.trim()}
>
Erstellen
</Button>
</DialogActions>
</Dialog>
</Box>
);
}
export default ServiceManagerTab;

View File

@@ -0,0 +1,117 @@
import { Box, Card, CardContent, Typography, Chip, LinearProgress, CircularProgress, Grid } from '@mui/material';
import { useQuery } from '@tanstack/react-query';
import { adminApi } from '../../services/admin';
function formatUptime(seconds: number): string {
const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600);
const mins = Math.floor((seconds % 3600) / 60);
const parts: string[] = [];
if (days > 0) parts.push(`${days}d`);
if (hours > 0) parts.push(`${hours}h`);
parts.push(`${mins}m`);
return parts.join(' ');
}
function formatBytes(bytes: number): string {
const mb = bytes / (1024 * 1024);
return `${mb.toFixed(1)} MB`;
}
function SystemHealthTab() {
const { data: health, isLoading } = useQuery({
queryKey: ['admin', 'system', 'health'],
queryFn: adminApi.getSystemHealth,
refetchInterval: 30000,
});
if (isLoading || !health) {
return <Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}><CircularProgress /></Box>;
}
const heapPercent = (health.memoryUsage.heapUsed / health.memoryUsage.heapTotal) * 100;
return (
<Box>
<Typography variant="h6" sx={{ mb: 2 }}>Systemstatus</Typography>
<Grid container spacing={2}>
<Grid item xs={12} sm={6} md={4}>
<Card>
<CardContent>
<Typography color="text.secondary" gutterBottom>Node.js Version</Typography>
<Chip label={health.nodeVersion} color="primary" />
</CardContent>
</Card>
</Grid>
<Grid item xs={12} sm={6} md={4}>
<Card>
<CardContent>
<Typography color="text.secondary" gutterBottom>Uptime</Typography>
<Typography variant="h5">{formatUptime(health.uptime)}</Typography>
</CardContent>
</Card>
</Grid>
<Grid item xs={12} sm={6} md={4}>
<Card>
<CardContent>
<Typography color="text.secondary" gutterBottom>Datenbank</Typography>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
<Box
sx={{
width: 12,
height: 12,
borderRadius: '50%',
bgcolor: health.dbStatus ? 'success.main' : 'error.main',
}}
/>
<Typography>{health.dbStatus ? 'Verbunden' : 'Nicht erreichbar'}</Typography>
</Box>
<Typography variant="body2" color="text.secondary" sx={{ mt: 1 }}>
Groesse: {formatBytes(Number(health.dbSize))}
</Typography>
</CardContent>
</Card>
</Grid>
<Grid item xs={12} sm={6} md={4}>
<Card>
<CardContent>
<Typography color="text.secondary" gutterBottom>Heap Speicher</Typography>
<Typography variant="body2">
{formatBytes(health.memoryUsage.heapUsed)} / {formatBytes(health.memoryUsage.heapTotal)}
</Typography>
<LinearProgress
variant="determinate"
value={heapPercent}
sx={{ mt: 1, height: 8, borderRadius: 4 }}
color={heapPercent > 85 ? 'error' : heapPercent > 70 ? 'warning' : 'primary'}
/>
</CardContent>
</Card>
</Grid>
<Grid item xs={12} sm={6} md={4}>
<Card>
<CardContent>
<Typography color="text.secondary" gutterBottom>RSS Speicher</Typography>
<Typography variant="h5">{formatBytes(health.memoryUsage.rss)}</Typography>
</CardContent>
</Card>
</Grid>
<Grid item xs={12} sm={6} md={4}>
<Card>
<CardContent>
<Typography color="text.secondary" gutterBottom>External Speicher</Typography>
<Typography variant="h5">{formatBytes(health.memoryUsage.external)}</Typography>
</CardContent>
</Card>
</Grid>
</Grid>
</Box>
);
}
export default SystemHealthTab;

View File

@@ -0,0 +1,166 @@
import { useState, useMemo } from 'react';
import {
Box,
Table,
TableBody,
TableCell,
TableContainer,
TableHead,
TableRow,
TableSortLabel,
Paper,
TextField,
Chip,
Typography,
CircularProgress,
} from '@mui/material';
import { useQuery } from '@tanstack/react-query';
import { adminApi } from '../../services/admin';
import type { UserOverview } from '../../types/admin.types';
type SortKey = 'name' | 'email' | 'role' | 'is_active' | 'last_login_at';
type SortDir = 'asc' | 'desc';
function formatRelativeTime(dateStr: string | null): string {
if (!dateStr) return 'Nie';
const date = new Date(dateStr);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / 60000);
if (diffMins < 1) return 'Gerade eben';
if (diffMins < 60) return `vor ${diffMins}m`;
const diffHours = Math.floor(diffMins / 60);
if (diffHours < 24) return `vor ${diffHours}h`;
const diffDays = Math.floor(diffHours / 24);
return `vor ${diffDays}d`;
}
function UserOverviewTab() {
const [search, setSearch] = useState('');
const [sortKey, setSortKey] = useState<SortKey>('name');
const [sortDir, setSortDir] = useState<SortDir>('asc');
const { data: users, isLoading } = useQuery({
queryKey: ['admin', 'users'],
queryFn: adminApi.getUsers,
});
const handleSort = (key: SortKey) => {
if (sortKey === key) {
setSortDir(sortDir === 'asc' ? 'desc' : 'asc');
} else {
setSortKey(key);
setSortDir('asc');
}
};
const filtered = useMemo(() => {
if (!users) return [];
const q = search.toLowerCase();
let result = users.filter(
(u) => u.name?.toLowerCase().includes(q) || u.email?.toLowerCase().includes(q)
);
result.sort((a, b) => {
const valA = a[sortKey];
const valB = b[sortKey];
let cmp = 0;
if (valA == null && valB == null) cmp = 0;
else if (valA == null) cmp = -1;
else if (valB == null) cmp = 1;
else if (typeof valA === 'string' && typeof valB === 'string') {
cmp = valA.localeCompare(valB);
} else if (typeof valA === 'boolean' && typeof valB === 'boolean') {
cmp = valA === valB ? 0 : valA ? 1 : -1;
}
return sortDir === 'asc' ? cmp : -cmp;
});
return result;
}, [users, search, sortKey, sortDir]);
if (isLoading) {
return <Box sx={{ display: 'flex', justifyContent: 'center', p: 4 }}><CircularProgress /></Box>;
}
return (
<Box>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 2 }}>
<Typography variant="h6">Benutzer ({filtered.length})</Typography>
<TextField
size="small"
placeholder="Suche nach Name oder E-Mail..."
value={search}
onChange={(e) => setSearch(e.target.value)}
sx={{ minWidth: 280 }}
/>
</Box>
<TableContainer component={Paper}>
<Table>
<TableHead>
<TableRow>
<TableCell>
<TableSortLabel active={sortKey === 'name'} direction={sortKey === 'name' ? sortDir : 'asc'} onClick={() => handleSort('name')}>
Name
</TableSortLabel>
</TableCell>
<TableCell>
<TableSortLabel active={sortKey === 'email'} direction={sortKey === 'email' ? sortDir : 'asc'} onClick={() => handleSort('email')}>
E-Mail
</TableSortLabel>
</TableCell>
<TableCell>
<TableSortLabel active={sortKey === 'role'} direction={sortKey === 'role' ? sortDir : 'asc'} onClick={() => handleSort('role')}>
Rolle
</TableSortLabel>
</TableCell>
<TableCell>Gruppen</TableCell>
<TableCell>
<TableSortLabel active={sortKey === 'is_active'} direction={sortKey === 'is_active' ? sortDir : 'asc'} onClick={() => handleSort('is_active')}>
Status
</TableSortLabel>
</TableCell>
<TableCell>
<TableSortLabel active={sortKey === 'last_login_at'} direction={sortKey === 'last_login_at' ? sortDir : 'asc'} onClick={() => handleSort('last_login_at')}>
Letzter Login
</TableSortLabel>
</TableCell>
</TableRow>
</TableHead>
<TableBody>
{filtered.map((user: UserOverview) => (
<TableRow key={user.id}>
<TableCell>{user.name}</TableCell>
<TableCell>{user.email}</TableCell>
<TableCell>
<Chip
label={user.role}
size="small"
color={user.role === 'admin' ? 'error' : 'default'}
/>
</TableCell>
<TableCell>
<Box sx={{ display: 'flex', gap: 0.5, flexWrap: 'wrap' }}>
{(user.groups ?? []).map((g) => (
<Chip key={g} label={g} size="small" variant="outlined" />
))}
</Box>
</TableCell>
<TableCell>
<Chip
label={user.is_active ? 'Aktiv' : 'Inaktiv'}
size="small"
color={user.is_active ? 'success' : 'default'}
/>
</TableCell>
<TableCell>{formatRelativeTime(user.last_login_at)}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</TableContainer>
</Box>
);
}
export default UserOverviewTab;

View File

@@ -0,0 +1,72 @@
import React from 'react';
import Box from '@mui/material/Box';
import Typography from '@mui/material/Typography';
import Paper from '@mui/material/Paper';
import type { NextcloudMessage } from '../../types/nextcloud.types';
interface ChatMessageProps {
message: NextcloudMessage;
isOwnMessage: boolean;
}
const ChatMessage: React.FC<ChatMessageProps> = ({ message, isOwnMessage }) => {
const time = new Date(message.timestamp * 1000).toLocaleTimeString('de-DE', {
hour: '2-digit',
minute: '2-digit',
});
if (message.systemMessage) {
return (
<Box sx={{ display: 'flex', justifyContent: 'center', my: 0.5 }}>
<Typography variant="caption" sx={{ fontStyle: 'italic', color: 'text.secondary' }}>
{message.message} - {time}
</Typography>
</Box>
);
}
return (
<Box
sx={{
display: 'flex',
justifyContent: isOwnMessage ? 'flex-end' : 'flex-start',
my: 0.5,
px: 1,
}}
>
<Paper
elevation={0}
sx={{
px: 1.5,
py: 0.75,
maxWidth: '80%',
bgcolor: isOwnMessage ? 'primary.main' : (theme) => theme.palette.mode === 'dark' ? 'grey.800' : 'grey.200',
color: isOwnMessage ? 'primary.contrastText' : 'text.primary',
borderRadius: 2,
}}
>
{!isOwnMessage && (
<Typography variant="caption" sx={{ fontWeight: 600, display: 'block' }}>
{message.actorDisplayName}
</Typography>
)}
<Typography variant="body2" sx={{ whiteSpace: 'pre-wrap', wordBreak: 'break-word' }}>
{message.message}
</Typography>
<Typography
variant="caption"
sx={{
display: 'block',
textAlign: 'right',
mt: 0.25,
opacity: 0.7,
}}
>
{time}
</Typography>
</Paper>
</Box>
);
};
export default ChatMessage;

View File

@@ -0,0 +1,119 @@
import React, { useEffect, useRef, useState } from 'react';
import Box from '@mui/material/Box';
import TextField from '@mui/material/TextField';
import IconButton from '@mui/material/IconButton';
import Typography from '@mui/material/Typography';
import SendIcon from '@mui/icons-material/Send';
import ArrowBackIcon from '@mui/icons-material/ArrowBack';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { nextcloudApi } from '../../services/nextcloud';
import { useChat } from '../../contexts/ChatContext';
import { useLayout } from '../../contexts/LayoutContext';
import ChatMessage from './ChatMessage';
const ChatMessageView: React.FC = () => {
const { selectedRoomToken, selectRoom, rooms, loginName } = useChat();
const { chatPanelOpen } = useLayout();
const queryClient = useQueryClient();
const messagesEndRef = useRef<HTMLDivElement>(null);
const [input, setInput] = useState('');
const room = rooms.find((r) => r.token === selectedRoomToken);
const { data: messages } = useQuery({
queryKey: ['nextcloud', 'messages', selectedRoomToken],
queryFn: () => nextcloudApi.getMessages(selectedRoomToken!),
enabled: !!selectedRoomToken && chatPanelOpen,
refetchInterval: 5000,
});
const sendMutation = useMutation({
mutationFn: (message: string) => nextcloudApi.sendMessage(selectedRoomToken!, message),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['nextcloud', 'messages', selectedRoomToken] });
queryClient.invalidateQueries({ queryKey: ['nextcloud', 'rooms'] });
},
});
useEffect(() => {
if (selectedRoomToken && chatPanelOpen) {
nextcloudApi.markAsRead(selectedRoomToken).then(() => {
queryClient.invalidateQueries({ queryKey: ['nextcloud', 'rooms'] });
}).catch(() => {});
}
}, [selectedRoomToken, chatPanelOpen, queryClient]);
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, [messages]);
const handleSend = () => {
const trimmed = input.trim();
if (!trimmed || !selectedRoomToken) return;
sendMutation.mutate(trimmed);
setInput('');
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
handleSend();
}
};
if (!selectedRoomToken) {
return (
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', flex: 1 }}>
<Typography color="text.secondary" variant="body2">
Raum auswählen
</Typography>
</Box>
);
}
return (
<Box sx={{ display: 'flex', flexDirection: 'column', flex: 1, minHeight: 0 }}>
<Box sx={{ px: 1.5, py: 1, borderBottom: 1, borderColor: 'divider', display: 'flex', alignItems: 'center', gap: 1 }}>
<IconButton size="small" onClick={() => selectRoom(null)}>
<ArrowBackIcon fontSize="small" />
</IconButton>
<Typography variant="subtitle2" noWrap>
{room?.displayName ?? selectedRoomToken}
</Typography>
</Box>
<Box sx={{ flex: 1, overflow: 'auto', py: 1 }}>
{messages?.map((msg) => (
<ChatMessage
key={msg.id}
message={msg}
isOwnMessage={msg.actorType === 'users' && msg.actorId === loginName}
/>
))}
<div ref={messagesEndRef} />
</Box>
<Box sx={{ p: 1, borderTop: 1, borderColor: 'divider', display: 'flex', gap: 0.5 }}>
<TextField
size="small"
fullWidth
placeholder="Nachricht..."
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
multiline
maxRows={3}
/>
<IconButton
color="primary"
onClick={handleSend}
disabled={!input.trim() || sendMutation.isPending}
>
<SendIcon />
</IconButton>
</Box>
</Box>
);
};
export default ChatMessageView;

View File

@@ -0,0 +1,93 @@
import React from 'react';
import Box from '@mui/material/Box';
import Paper from '@mui/material/Paper';
import IconButton from '@mui/material/IconButton';
import ChatIcon from '@mui/icons-material/Chat';
import Typography from '@mui/material/Typography';
import { useLayout } from '../../contexts/LayoutContext';
import { ChatProvider, useChat } from '../../contexts/ChatContext';
import ChatRoomList from './ChatRoomList';
import ChatMessageView from './ChatMessageView';
const ChatPanelInner: React.FC = () => {
const { chatPanelOpen, setChatPanelOpen } = useLayout();
const { selectedRoomToken, connected } = useChat();
if (!chatPanelOpen) {
return (
<Paper
elevation={2}
sx={{
width: 60,
height: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
pt: 1,
flexShrink: 0,
transition: 'width 0.2s ease',
}}
>
<IconButton onClick={() => setChatPanelOpen(true)}>
<ChatIcon />
</IconButton>
</Paper>
);
}
return (
<Paper
elevation={2}
sx={{
width: 360,
height: '100%',
display: 'flex',
flexDirection: 'column',
flexShrink: 0,
transition: 'width 0.2s ease',
overflow: 'hidden',
}}
>
<Box
sx={{
px: 1.5,
py: 1,
borderBottom: 1,
borderColor: 'divider',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}
>
<Typography variant="subtitle1" fontWeight={600}>
Chat
</Typography>
<IconButton size="small" onClick={() => setChatPanelOpen(false)}>
<ChatIcon fontSize="small" />
</IconButton>
</Box>
{!connected ? (
<Box sx={{ p: 2 }}>
<Typography variant="body2" color="text.secondary">
Nextcloud nicht verbunden. Bitte verbinden Sie sich in den Einstellungen.
</Typography>
</Box>
) : selectedRoomToken ? (
<ChatMessageView />
) : (
<ChatRoomList />
)}
</Paper>
);
};
const ChatPanel: React.FC = () => {
return (
<ChatProvider>
<ChatPanelInner />
</ChatProvider>
);
};
export default ChatPanel;

View File

@@ -0,0 +1,48 @@
import React from 'react';
import Box from '@mui/material/Box';
import List from '@mui/material/List';
import ListItemButton from '@mui/material/ListItemButton';
import Badge from '@mui/material/Badge';
import Typography from '@mui/material/Typography';
import { useChat } from '../../contexts/ChatContext';
const ChatRoomList: React.FC = () => {
const { rooms, selectedRoomToken, selectRoom } = useChat();
return (
<Box sx={{ overflow: 'auto', flex: 1 }}>
<List disablePadding>
{rooms.map((room) => (
<ListItemButton
key={room.token}
selected={room.token === selectedRoomToken}
onClick={() => selectRoom(room.token)}
sx={{ py: 1, px: 1.5 }}
>
<Box sx={{ flex: 1, minWidth: 0 }}>
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
<Typography variant="subtitle2" noWrap sx={{ flex: 1 }}>
{room.displayName}
</Typography>
{room.unreadMessages > 0 && (
<Badge
badgeContent={room.unreadMessages}
color="primary"
sx={{ ml: 1 }}
/>
)}
</Box>
{room.lastMessage && (
<Typography variant="caption" color="text.secondary" noWrap>
{room.lastMessage.author}: {room.lastMessage.text}
</Typography>
)}
</Box>
</ListItemButton>
))}
</List>
</Box>
);
};
export default ChatRoomList;

View File

@@ -0,0 +1,67 @@
import { Card, CardContent, Typography, Box, Chip } from '@mui/material';
import { useQuery } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom';
import { MonitorHeartOutlined } from '@mui/icons-material';
import { adminApi } from '../../services/admin';
import { useCountUp } from '../../hooks/useCountUp';
import { useAuth } from '../../contexts/AuthContext';
function AdminStatusWidget() {
const { user } = useAuth();
const navigate = useNavigate();
const isAdmin = user?.groups?.includes('dashboard_admin') ?? false;
const { data } = useQuery({
queryKey: ['admin-status-summary'],
queryFn: () => adminApi.getStatusSummary(),
refetchInterval: 30_000,
enabled: isAdmin,
});
const up = useCountUp(data?.up ?? 0);
const total = useCountUp(data?.total ?? 0);
if (!isAdmin) return null;
const allUp = data && data.up === data.total;
const majorityDown = data && data.total > 0 && data.up < data.total / 2;
const color = allUp ? 'success' : majorityDown ? 'error' : 'warning';
return (
<Card
sx={{ cursor: 'pointer', '&:hover': { boxShadow: 4 } }}
onClick={() => navigate('/admin')}
>
<CardContent>
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, mb: 1 }}>
<MonitorHeartOutlined color={color} />
<Typography variant="h6" component="div">
Service Status
</Typography>
</Box>
<Box sx={{ display: 'flex', alignItems: 'baseline', gap: 0.5, mb: 1 }}>
<Typography variant="h3" component="span" sx={{ fontWeight: 700 }}>
{up}
</Typography>
<Typography variant="h5" component="span" color="text.secondary">
/ {total}
</Typography>
<Typography variant="body2" color="text.secondary" sx={{ ml: 0.5 }}>
Services online
</Typography>
</Box>
<Chip
label={allUp ? 'Alle aktiv' : majorityDown ? 'Kritisch' : 'Teilweise gestört'}
color={color}
size="small"
variant="outlined"
/>
</CardContent>
</Card>
);
}
export default AdminStatusWidget;

View File

@@ -4,14 +4,17 @@ import Header from '../shared/Header';
import Sidebar from '../shared/Sidebar';
import { useAuth } from '../../contexts/AuthContext';
import Loading from '../shared/Loading';
import { LayoutProvider, useLayout, DRAWER_WIDTH, DRAWER_WIDTH_COLLAPSED } from '../../contexts/LayoutContext';
import ChatPanel from '../chat/ChatPanel';
interface DashboardLayoutProps {
children: ReactNode;
}
function DashboardLayout({ children }: DashboardLayoutProps) {
function DashboardLayoutInner({ children }: DashboardLayoutProps) {
const [mobileOpen, setMobileOpen] = useState(false);
const { isLoading } = useAuth();
const { sidebarCollapsed, chatPanelOpen } = useLayout();
const handleDrawerToggle = () => {
setMobileOpen(!mobileOpen);
@@ -21,6 +24,9 @@ function DashboardLayout({ children }: DashboardLayoutProps) {
return <Loading message="Lade Dashboard..." />;
}
const sidebarWidth = sidebarCollapsed ? DRAWER_WIDTH_COLLAPSED : DRAWER_WIDTH;
const chatWidth = chatPanelOpen ? 360 : 60;
return (
<Box sx={{ display: 'flex' }}>
<Header onMenuClick={handleDrawerToggle} />
@@ -31,16 +37,27 @@ function DashboardLayout({ children }: DashboardLayoutProps) {
sx={{
flexGrow: 1,
p: 3,
width: { sm: `calc(100% - 240px)` },
width: { sm: `calc(100% - ${sidebarWidth}px - ${chatWidth}px)` },
minHeight: '100vh',
backgroundColor: 'background.default',
transition: 'width 225ms cubic-bezier(0.4, 0, 0.6, 1)',
}}
>
<Toolbar />
{children}
</Box>
<ChatPanel />
</Box>
);
}
function DashboardLayout({ children }: DashboardLayoutProps) {
return (
<LayoutProvider>
<DashboardLayoutInner>{children}</DashboardLayoutInner>
</LayoutProvider>
);
}
export default DashboardLayout;

View File

@@ -9,3 +9,4 @@ export { default as BookStackSearchWidget } from './BookStackSearchWidget';
export { default as VikunjaMyTasksWidget } from './VikunjaMyTasksWidget';
export { default as VikunjaQuickAddWidget } from './VikunjaQuickAddWidget';
export { default as VikunjaOverdueNotifier } from './VikunjaOverdueNotifier';
export { default as AdminStatusWidget } from './AdminStatusWidget';

View File

@@ -10,6 +10,7 @@ import {
ListItemIcon,
Divider,
Box,
Tooltip,
} from '@mui/material';
import {
LocalFireDepartment,
@@ -17,10 +18,15 @@ import {
Settings,
Logout,
Menu as MenuIcon,
Launch,
Chat,
} from '@mui/icons-material';
import { useQuery } from '@tanstack/react-query';
import { useAuth } from '../../contexts/AuthContext';
import { useNavigate } from 'react-router-dom';
import NotificationBell from './NotificationBell';
import { configApi } from '../../services/config';
import { useLayout } from '../../contexts/LayoutContext';
interface HeaderProps {
onMenuClick: () => void;
@@ -29,7 +35,16 @@ interface HeaderProps {
function Header({ onMenuClick }: HeaderProps) {
const { user, logout } = useAuth();
const navigate = useNavigate();
const { toggleChatPanel } = useLayout();
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
const [toolsAnchorEl, setToolsAnchorEl] = useState<null | HTMLElement>(null);
const { data: externalLinks } = useQuery({
queryKey: ['external-links'],
queryFn: () => configApi.getExternalLinks(),
staleTime: 10 * 60 * 1000,
enabled: !!user,
});
const handleMenuOpen = (event: React.MouseEvent<HTMLElement>) => {
setAnchorEl(event.currentTarget);
@@ -39,6 +54,14 @@ function Header({ onMenuClick }: HeaderProps) {
setAnchorEl(null);
};
const handleToolsOpen = (event: React.MouseEvent<HTMLElement>) => {
setToolsAnchorEl(event.currentTarget);
};
const handleToolsClose = () => {
setToolsAnchorEl(null);
};
const handleProfile = () => {
handleMenuClose();
navigate('/profile');
@@ -54,6 +77,11 @@ function Header({ onMenuClick }: HeaderProps) {
logout();
};
const handleOpenExternal = (url: string) => {
handleToolsClose();
window.open(url, '_blank', 'noopener,noreferrer');
};
// Get initials for avatar
const getInitials = () => {
if (!user) return '?';
@@ -61,6 +89,16 @@ function Header({ onMenuClick }: HeaderProps) {
return initials || user.name?.[0] || '?';
};
const linkEntries = externalLinks
? Object.entries(externalLinks).filter(([, url]) => !!url)
: [];
const linkLabels: Record<string, string> = {
nextcloud: 'Nextcloud',
bookstack: 'BookStack',
vikunja: 'Vikunja',
};
return (
<AppBar
position="fixed"
@@ -71,7 +109,7 @@ function Header({ onMenuClick }: HeaderProps) {
<Toolbar>
<IconButton
color="inherit"
aria-label="Men� �ffnen"
aria-label="Menü öffnen"
edge="start"
onClick={onMenuClick}
sx={{ mr: 2, display: { sm: 'none' } }}
@@ -86,6 +124,63 @@ function Header({ onMenuClick }: HeaderProps) {
{user && (
<>
{linkEntries.length > 0 && (
<>
<Tooltip title="Externe Tools">
<IconButton
color="inherit"
onClick={handleToolsOpen}
size="small"
aria-label="Externe Tools"
aria-controls="tools-menu"
aria-haspopup="true"
>
<Launch />
</IconButton>
</Tooltip>
<Menu
id="tools-menu"
anchorEl={toolsAnchorEl}
open={Boolean(toolsAnchorEl)}
onClose={handleToolsClose}
anchorOrigin={{
vertical: 'bottom',
horizontal: 'right',
}}
transformOrigin={{
vertical: 'top',
horizontal: 'right',
}}
PaperProps={{
elevation: 3,
sx: { minWidth: 180, mt: 1 },
}}
>
{linkEntries.map(([key, url]) => (
<MenuItem key={key} onClick={() => handleOpenExternal(url)}>
<ListItemIcon>
<Launch fontSize="small" />
</ListItemIcon>
{linkLabels[key] || key}
</MenuItem>
))}
</Menu>
</>
)}
<Tooltip title="Chat">
<IconButton
color="inherit"
onClick={toggleChatPanel}
size="small"
aria-label="Chat öffnen"
sx={{ ml: 0.5 }}
>
<Chat />
</IconButton>
</Tooltip>
<NotificationBell />
<IconButton

View File

@@ -1,5 +1,8 @@
import { useMemo } from 'react';
import {
Box,
Drawer,
IconButton,
List,
ListItem,
ListItemButton,
@@ -15,10 +18,16 @@ import {
People,
Air,
CalendarMonth,
MenuBook,
AdminPanelSettings,
ChevronLeft,
ChevronRight,
} from '@mui/icons-material';
import { useNavigate, useLocation } from 'react-router-dom';
import { useLayout, DRAWER_WIDTH, DRAWER_WIDTH_COLLAPSED } from '../../contexts/LayoutContext';
import { useAuth } from '../../contexts/AuthContext';
const DRAWER_WIDTH = 240;
export { DRAWER_WIDTH, DRAWER_WIDTH_COLLAPSED };
interface NavigationItem {
text: string;
@@ -26,7 +35,7 @@ interface NavigationItem {
path: string;
}
const navigationItems: NavigationItem[] = [
const baseNavigationItems: NavigationItem[] = [
{
text: 'Dashboard',
icon: <DashboardIcon />,
@@ -57,8 +66,19 @@ const navigationItems: NavigationItem[] = [
icon: <Air />,
path: '/atemschutz',
},
{
text: 'Wissen',
icon: <MenuBook />,
path: '/wissen',
},
];
const adminItem: NavigationItem = {
text: 'Admin',
icon: <AdminPanelSettings />,
path: '/admin',
};
interface SidebarProps {
mobileOpen: boolean;
onMobileClose: () => void;
@@ -67,6 +87,14 @@ interface SidebarProps {
function Sidebar({ mobileOpen, onMobileClose }: SidebarProps) {
const navigate = useNavigate();
const location = useLocation();
const { sidebarCollapsed, toggleSidebar } = useLayout();
const { user } = useAuth();
const isAdmin = user?.groups?.includes('dashboard_admin') ?? false;
const navigationItems = useMemo(() => {
return isAdmin ? [...baseNavigationItems, adminItem] : baseNavigationItems;
}, [isAdmin]);
const handleNavigation = (path: string) => {
navigate(path);
@@ -74,19 +102,25 @@ function Sidebar({ mobileOpen, onMobileClose }: SidebarProps) {
};
const drawerContent = (
<>
<Box sx={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<Toolbar />
<List>
<List sx={{ flex: 1 }}>
{navigationItems.map((item) => {
const isActive = location.pathname === item.path;
return (
<ListItem key={item.text} disablePadding>
<Tooltip title={item.text} placement="right" arrow>
<Tooltip
title={item.text}
placement="right"
arrow
disableHoverListener={!sidebarCollapsed}
>
<ListItemButton
selected={isActive}
onClick={() => handleNavigation(item.path)}
aria-label={`Zu ${item.text} navigieren`}
sx={{
justifyContent: sidebarCollapsed ? 'center' : 'initial',
'&.Mui-selected': {
backgroundColor: 'primary.light',
color: 'primary.contrastText',
@@ -102,18 +136,30 @@ function Sidebar({ mobileOpen, onMobileClose }: SidebarProps) {
<ListItemIcon
sx={{
color: isActive ? 'inherit' : 'text.secondary',
minWidth: sidebarCollapsed ? 0 : undefined,
justifyContent: 'center',
}}
>
{item.icon}
</ListItemIcon>
<ListItemText primary={item.text} />
<ListItemText
primary={item.text}
sx={{
display: sidebarCollapsed ? 'none' : 'block',
}}
/>
</ListItemButton>
</Tooltip>
</ListItem>
);
})}
</List>
</>
<Box sx={{ display: 'flex', justifyContent: 'center', mb: 1 }}>
<IconButton onClick={toggleSidebar} aria-label="Sidebar umschalten">
{sidebarCollapsed ? <ChevronRight /> : <ChevronLeft />}
</IconButton>
</Box>
</Box>
);
return (
@@ -143,11 +189,13 @@ function Sidebar({ mobileOpen, onMobileClose }: SidebarProps) {
variant="permanent"
sx={{
display: { xs: 'none', sm: 'block' },
width: DRAWER_WIDTH,
width: sidebarCollapsed ? DRAWER_WIDTH_COLLAPSED : DRAWER_WIDTH,
flexShrink: 0,
'& .MuiDrawer-paper': {
width: DRAWER_WIDTH,
width: sidebarCollapsed ? DRAWER_WIDTH_COLLAPSED : DRAWER_WIDTH,
boxSizing: 'border-box',
transition: 'width 225ms cubic-bezier(0.4, 0, 0.6, 1)',
overflowX: 'hidden',
},
}}
open

View File

@@ -0,0 +1,57 @@
import React, { createContext, useContext, useState, useCallback, ReactNode } from 'react';
import { useQuery } from '@tanstack/react-query';
import { nextcloudApi } from '../services/nextcloud';
import { useLayout } from './LayoutContext';
import type { NextcloudConversation } from '../types/nextcloud.types';
interface ChatContextType {
rooms: NextcloudConversation[];
selectedRoomToken: string | null;
selectRoom: (token: string | null) => void;
connected: boolean;
loginName: string | null;
}
const ChatContext = createContext<ChatContextType | undefined>(undefined);
interface ChatProviderProps {
children: ReactNode;
}
export const ChatProvider: React.FC<ChatProviderProps> = ({ children }) => {
const [selectedRoomToken, setSelectedRoomToken] = useState<string | null>(null);
const { chatPanelOpen } = useLayout();
const { data } = useQuery({
queryKey: ['nextcloud', 'rooms'],
queryFn: () => nextcloudApi.getRooms(),
refetchInterval: chatPanelOpen ? 30000 : false,
enabled: chatPanelOpen,
});
const rooms = data?.rooms ?? [];
const connected = data?.connected ?? false;
const loginName = data?.loginName ?? null;
const selectRoom = useCallback((token: string | null) => {
setSelectedRoomToken(token);
}, []);
const value: ChatContextType = {
rooms,
selectedRoomToken,
selectRoom,
connected,
loginName,
};
return <ChatContext.Provider value={value}>{children}</ChatContext.Provider>;
};
export const useChat = (): ChatContextType => {
const context = useContext(ChatContext);
if (context === undefined) {
throw new Error('useChat must be used within a ChatProvider');
}
return context;
};

View File

@@ -0,0 +1,70 @@
import React, { createContext, useContext, useState, useCallback, ReactNode } from 'react';
export const DRAWER_WIDTH = 240;
export const DRAWER_WIDTH_COLLAPSED = 64;
interface LayoutContextType {
sidebarCollapsed: boolean;
chatPanelOpen: boolean;
toggleSidebar: () => void;
toggleChatPanel: () => void;
setChatPanelOpen: (open: boolean) => void;
}
const LayoutContext = createContext<LayoutContextType | undefined>(undefined);
function getInitialCollapsed(): boolean {
try {
const stored = localStorage.getItem('sidebar-collapsed');
return stored === 'true';
} catch {
return false;
}
}
interface LayoutProviderProps {
children: ReactNode;
}
export const LayoutProvider: React.FC<LayoutProviderProps> = ({ children }) => {
const [sidebarCollapsed, setSidebarCollapsed] = useState(getInitialCollapsed);
const [chatPanelOpen, setChatPanelOpenState] = useState(false);
const toggleSidebar = useCallback(() => {
setSidebarCollapsed((prev) => {
const next = !prev;
try {
localStorage.setItem('sidebar-collapsed', String(next));
} catch {
// ignore storage errors
}
return next;
});
}, []);
const toggleChatPanel = useCallback(() => {
setChatPanelOpenState((prev) => !prev);
}, []);
const setChatPanelOpen = useCallback((open: boolean) => {
setChatPanelOpenState(open);
}, []);
const value: LayoutContextType = {
sidebarCollapsed,
chatPanelOpen,
toggleSidebar,
toggleChatPanel,
setChatPanelOpen,
};
return <LayoutContext.Provider value={value}>{children}</LayoutContext.Provider>;
};
export const useLayout = (): LayoutContextType => {
const context = useContext(LayoutContext);
if (context === undefined) {
throw new Error('useLayout must be used within a LayoutProvider');
}
return context;
};

7
frontend/src/dompurify.d.ts vendored Normal file
View File

@@ -0,0 +1,7 @@
declare module 'dompurify' {
interface DOMPurifyI {
sanitize(source: string | Node, config?: Record<string, unknown>): string;
}
const DOMPurify: DOMPurifyI;
export default DOMPurify;
}

View File

@@ -0,0 +1,42 @@
import { useState, useEffect, useRef } from 'react';
export function useCountUp(target: number, duration: number = 1000): number {
const [current, setCurrent] = useState(0);
const rafRef = useRef<number>(0);
const currentRef = useRef<number>(0);
useEffect(() => {
if (target === 0) {
setCurrent(0);
currentRef.current = 0;
return;
}
const startTime = performance.now();
const startValue = currentRef.current;
const animate = (now: number) => {
const elapsed = now - startTime;
const t = Math.min(elapsed / duration, 1);
// ease-out cubic: 1 - (1-t)^3
const eased = 1 - Math.pow(1 - t, 3);
const value = Math.round(startValue + (target - startValue) * eased);
setCurrent(value);
currentRef.current = value;
if (t < 1) {
rafRef.current = requestAnimationFrame(animate);
}
};
rafRef.current = requestAnimationFrame(animate);
return () => {
if (rafRef.current) {
cancelAnimationFrame(rafRef.current);
}
};
}, [target, duration]);
return current;
}

View File

@@ -0,0 +1,61 @@
import { useState } from 'react';
import { Box, Tabs, Tab, Typography } from '@mui/material';
import { Navigate } from 'react-router-dom';
import DashboardLayout from '../components/dashboard/DashboardLayout';
import ServiceManagerTab from '../components/admin/ServiceManagerTab';
import SystemHealthTab from '../components/admin/SystemHealthTab';
import UserOverviewTab from '../components/admin/UserOverviewTab';
import NotificationBroadcastTab from '../components/admin/NotificationBroadcastTab';
import { useAuth } from '../contexts/AuthContext';
interface TabPanelProps {
children: React.ReactNode;
index: number;
value: number;
}
function TabPanel({ children, value, index }: TabPanelProps) {
if (value !== index) return null;
return <Box sx={{ pt: 3 }}>{children}</Box>;
}
function AdminDashboard() {
const [tab, setTab] = useState(0);
const { user } = useAuth();
const isAdmin = user?.groups?.includes('dashboard_admin') ?? false;
if (!isAdmin) {
return <Navigate to="/dashboard" replace />;
}
return (
<DashboardLayout>
<Typography variant="h4" sx={{ mb: 3 }}>Administration</Typography>
<Box sx={{ borderBottom: 1, borderColor: 'divider' }}>
<Tabs value={tab} onChange={(_e, v) => setTab(v)}>
<Tab label="Services" />
<Tab label="System" />
<Tab label="Benutzer" />
<Tab label="Broadcast" />
</Tabs>
</Box>
<TabPanel value={tab} index={0}>
<ServiceManagerTab />
</TabPanel>
<TabPanel value={tab} index={1}>
<SystemHealthTab />
</TabPanel>
<TabPanel value={tab} index={2}>
<UserOverviewTab />
</TabPanel>
<TabPanel value={tab} index={3}>
<NotificationBroadcastTab />
</TabPanel>
</DashboardLayout>
);
}
export default AdminDashboard;

View File

@@ -18,8 +18,10 @@ import BookStackSearchWidget from '../components/dashboard/BookStackSearchWidget
import VikunjaMyTasksWidget from '../components/dashboard/VikunjaMyTasksWidget';
import VikunjaQuickAddWidget from '../components/dashboard/VikunjaQuickAddWidget';
import VikunjaOverdueNotifier from '../components/dashboard/VikunjaOverdueNotifier';
import AdminStatusWidget from '../components/dashboard/AdminStatusWidget';
function Dashboard() {
const { user } = useAuth();
const isAdmin = user?.groups?.includes('dashboard_admin') ?? false;
const canViewAtemschutz = user?.groups?.some(g =>
['dashboard_admin', 'dashboard_kommando', 'dashboard_atemschutz', 'dashboard_moderator'].includes(g)
) ?? false;
@@ -148,6 +150,17 @@ function Dashboard() {
{/* Vikunja — Overdue Notifier (invisible, polling component) */}
<VikunjaOverdueNotifier />
{/* Admin Status Widget — only for admins */}
{isAdmin && (
<Box>
<Fade in={!dataLoading} timeout={600} style={{ transitionDelay: '680ms' }}>
<Box>
<AdminStatusWidget />
</Box>
</Fade>
</Box>
)}
</Box>
</Container>
</DashboardLayout>

View File

@@ -0,0 +1,240 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import {
Box,
TextField,
Typography,
Paper,
List,
ListItem,
ListItemButton,
ListItemText,
CircularProgress,
InputAdornment,
Divider,
} from '@mui/material';
import { Search as SearchIcon } from '@mui/icons-material';
import { useQuery } from '@tanstack/react-query';
import DOMPurify from 'dompurify';
import DashboardLayout from '../components/dashboard/DashboardLayout';
import { bookstackApi } from '../services/bookstack';
import type { BookStackPage, BookStackSearchResult } from '../types/bookstack.types';
export default function Wissen() {
const [searchTerm, setSearchTerm] = useState('');
const [debouncedSearch, setDebouncedSearch] = useState('');
const [selectedPageId, setSelectedPageId] = useState<number | null>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
setDebouncedSearch(searchTerm.trim());
}, 400);
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
};
}, [searchTerm]);
const recentQuery = useQuery({
queryKey: ['bookstack', 'recent'],
queryFn: () => bookstackApi.getRecent(),
});
const searchQuery = useQuery({
queryKey: ['bookstack', 'search', debouncedSearch],
queryFn: () => bookstackApi.search(debouncedSearch),
enabled: debouncedSearch.length > 0,
});
const pageQuery = useQuery({
queryKey: ['bookstack', 'page', selectedPageId],
queryFn: () => bookstackApi.getPage(selectedPageId!),
enabled: selectedPageId !== null,
});
const handleSelectPage = useCallback((id: number) => {
setSelectedPageId(id);
}, []);
const isNotConfigured =
recentQuery.data && !recentQuery.data.configured;
if (isNotConfigured) {
return (
<DashboardLayout>
<Box sx={{ p: 3 }}>
<Typography variant="h5" gutterBottom>
Wissen
</Typography>
<Typography color="text.secondary">
BookStack ist nicht konfiguriert. Bitte BOOKSTACK_URL, BOOKSTACK_TOKEN_ID und
BOOKSTACK_TOKEN_SECRET in der .env-Datei setzen.
</Typography>
</Box>
</DashboardLayout>
);
}
const isSearching = debouncedSearch.length > 0;
const listItems: (BookStackSearchResult | BookStackPage)[] = isSearching
? searchQuery.data?.data ?? []
: recentQuery.data?.data ?? [];
const listLoading = isSearching ? searchQuery.isLoading : recentQuery.isLoading;
return (
<DashboardLayout>
<Box sx={{ display: 'flex', height: 'calc(100vh - 120px)', gap: 2, p: 2 }}>
{/* Left panel: search + list */}
<Paper sx={{ width: '40%', display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
<Box sx={{ p: 2 }}>
<TextField
fullWidth
size="small"
placeholder="Seiten durchsuchen..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
InputProps={{
startAdornment: (
<InputAdornment position="start">
<SearchIcon />
</InputAdornment>
),
}}
/>
</Box>
<Divider />
<Box sx={{ flex: 1, overflow: 'auto' }}>
{listLoading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
<CircularProgress size={28} />
</Box>
) : listItems.length === 0 ? (
<Typography sx={{ p: 2 }} color="text.secondary">
{isSearching ? 'Keine Ergebnisse gefunden.' : 'Keine Seiten vorhanden.'}
</Typography>
) : (
<List disablePadding>
{listItems.map((item) => (
<ListItem key={item.id} disablePadding>
<ListItemButton
selected={selectedPageId === item.id}
onClick={() => handleSelectPage(item.id)}
>
<ListItemText
primary={item.name}
secondary={
'book' in item && item.book
? item.book.name
: undefined
}
primaryTypographyProps={{ noWrap: true }}
secondaryTypographyProps={{ noWrap: true }}
/>
</ListItemButton>
</ListItem>
))}
</List>
)}
</Box>
</Paper>
{/* Right panel: page content */}
<Paper sx={{ width: '60%', overflow: 'auto', p: 3 }}>
{!selectedPageId ? (
<Typography color="text.secondary">
Seite aus der Liste auswaehlen, um den Inhalt anzuzeigen.
</Typography>
) : pageQuery.isLoading ? (
<Box sx={{ display: 'flex', justifyContent: 'center', py: 4 }}>
<CircularProgress />
</Box>
) : pageQuery.isError ? (
<Typography color="error">
Fehler beim Laden der Seite.
</Typography>
) : pageQuery.data?.data ? (
<Box>
<Typography variant="h5" gutterBottom>
{pageQuery.data.data.name}
</Typography>
{pageQuery.data.data.book && (
<Typography variant="body2" color="text.secondary" gutterBottom>
Buch: {pageQuery.data.data.book.name}
</Typography>
)}
<Divider sx={{ my: 2 }} />
<Box
className="bookstack-content"
sx={(theme) => ({
'& h1, & h2, & h3, & h4, & h5, & h6': {
color: theme.palette.text.primary,
mt: 2,
mb: 1,
},
'& p': {
color: theme.palette.text.primary,
lineHeight: 1.7,
mb: 1,
},
'& a': {
color: theme.palette.primary.main,
textDecoration: 'none',
'&:hover': { textDecoration: 'underline' },
},
'& table': {
borderCollapse: 'collapse',
width: '100%',
mb: 2,
},
'& th, & td': {
border: `1px solid ${theme.palette.divider}`,
padding: theme.spacing(1),
textAlign: 'left',
},
'& th': {
backgroundColor: theme.palette.action.hover,
fontWeight: 600,
},
'& img': {
maxWidth: '100%',
height: 'auto',
borderRadius: 1,
},
'& code': {
backgroundColor: theme.palette.action.hover,
padding: '2px 6px',
borderRadius: 1,
fontSize: '0.875em',
},
'& pre': {
backgroundColor: theme.palette.action.hover,
padding: theme.spacing(2),
borderRadius: 1,
overflow: 'auto',
},
'& ul, & ol': {
pl: 3,
mb: 1,
},
'& blockquote': {
borderLeft: `4px solid ${theme.palette.primary.main}`,
pl: 2,
ml: 0,
color: theme.palette.text.secondary,
},
})}
dangerouslySetInnerHTML={{
__html: DOMPurify.sanitize(pageQuery.data.data.html),
}}
/>
</Box>
) : (
<Typography color="text.secondary">
Seite nicht gefunden.
</Typography>
)}
</Paper>
</Box>
</DashboardLayout>
);
}

View File

@@ -0,0 +1,19 @@
import { api } from './api';
import type { MonitoredService, PingResult, StatusSummary, SystemHealth, UserOverview, BroadcastPayload } from '../types/admin.types';
interface ApiResponse<T> {
success: boolean;
data: T;
}
export const adminApi = {
getServices: () => api.get<ApiResponse<MonitoredService[]>>('/api/admin/services').then(r => r.data.data),
createService: (data: { name: string; url: string }) => api.post<ApiResponse<MonitoredService>>('/api/admin/services', data).then(r => r.data.data),
updateService: (id: string, data: Partial<MonitoredService>) => api.put<ApiResponse<MonitoredService>>(`/api/admin/services/${id}`, data).then(r => r.data.data),
deleteService: (id: string) => api.delete(`/api/admin/services/${id}`).then(() => undefined),
pingAll: () => api.get<ApiResponse<PingResult[]>>('/api/admin/services/ping').then(r => r.data.data),
getStatusSummary: () => api.get<ApiResponse<StatusSummary>>('/api/admin/services/status-summary').then(r => r.data.data),
getSystemHealth: () => api.get<ApiResponse<SystemHealth>>('/api/admin/system/health').then(r => r.data.data),
getUsers: () => api.get<ApiResponse<UserOverview[]>>('/api/admin/users').then(r => r.data.data),
broadcast: (data: BroadcastPayload) => api.post<ApiResponse<{ sent: number }>>('/api/admin/notifications/broadcast', data).then(r => r.data.data),
};

View File

@@ -1,5 +1,5 @@
import { api } from './api';
import type { BookStackRecentResponse, BookStackSearchResponse } from '../types/bookstack.types';
import type { BookStackRecentResponse, BookStackSearchResponse, BookStackPageDetail } from '../types/bookstack.types';
interface ApiResponse<T> {
success: boolean;
@@ -14,6 +14,12 @@ export const bookstackApi = {
.then((r) => ({ configured: r.data.configured, data: r.data.data }));
},
getPage(id: number): Promise<{ configured: boolean; data: BookStackPageDetail | null }> {
return api
.get<ApiResponse<BookStackPageDetail | null>>(`/api/bookstack/pages/${id}`)
.then((r) => ({ configured: r.data.configured, data: r.data.data }));
},
search(query: string): Promise<BookStackSearchResponse> {
return api
.get<ApiResponse<BookStackSearchResponse['data']>>('/api/bookstack/search', {

View File

@@ -0,0 +1,15 @@
import { api } from './api';
import type { ExternalLinks } from '../types/config.types';
interface ApiResponse<T> {
success: boolean;
data: T;
}
export const configApi = {
getExternalLinks(): Promise<ExternalLinks> {
return api
.get<ApiResponse<ExternalLinks>>('/api/config/external-links')
.then((r) => r.data.data);
},
};

View File

@@ -1,5 +1,5 @@
import { api } from './api';
import type { NextcloudTalkData, NextcloudConnectData, NextcloudPollData } from '../types/nextcloud.types';
import type { NextcloudTalkData, NextcloudConnectData, NextcloudPollData, NextcloudMessage, NextcloudRoomListData } from '../types/nextcloud.types';
interface ApiResponse<T> {
success: boolean;
@@ -30,4 +30,28 @@ export const nextcloudApi = {
.delete('/api/nextcloud/talk/connect')
.then(() => undefined);
},
getRooms(): Promise<NextcloudRoomListData> {
return api
.get<ApiResponse<NextcloudRoomListData>>('/api/nextcloud/talk/rooms')
.then((r) => r.data.data);
},
getMessages(token: string): Promise<NextcloudMessage[]> {
return api
.get<ApiResponse<NextcloudMessage[]>>(`/api/nextcloud/talk/rooms/${encodeURIComponent(token)}/messages`)
.then((r) => r.data.data);
},
sendMessage(token: string, message: string): Promise<void> {
return api
.post(`/api/nextcloud/talk/rooms/${encodeURIComponent(token)}/messages`, { message })
.then(() => undefined);
},
markAsRead(token: string): Promise<void> {
return api
.post(`/api/nextcloud/talk/rooms/${encodeURIComponent(token)}/read`)
.then(() => undefined);
},
};

View File

@@ -0,0 +1,52 @@
export interface MonitoredService {
id: string;
name: string;
url: string;
type: 'internal' | 'custom';
is_active: boolean;
created_at: string;
updated_at: string;
}
export interface PingResult {
name: string;
url: string;
status: 'up' | 'down';
latencyMs: number;
error?: string;
}
export interface StatusSummary {
up: number;
total: number;
}
export interface SystemHealth {
nodeVersion: string;
uptime: number;
memoryUsage: {
heapUsed: number;
heapTotal: number;
rss: number;
external: number;
};
dbStatus: boolean;
dbSize: string;
}
export interface UserOverview {
id: string;
email: string;
name: string;
role: string;
groups: string[];
is_active: boolean;
last_login_at: string | null;
}
export interface BroadcastPayload {
titel: string;
nachricht: string;
schwere?: 'info' | 'warnung' | 'fehler';
targetGroup?: string;
}

View File

@@ -34,3 +34,24 @@ export interface BookStackSearchResponse {
configured: boolean;
data: BookStackSearchResult[];
}
export interface BookStackPageDetail {
id: number;
name: string;
slug: string;
book_id: number;
book_slug: string;
chapter_id: number;
html: string;
created_at: string;
updated_at: string;
url: string;
book?: { name: string };
createdBy?: { name: string };
updatedBy?: { name: string };
}
export interface BookStackPageDetailResponse {
configured: boolean;
data: BookStackPageDetail | null;
}

View File

@@ -0,0 +1,5 @@
export interface ExternalLinks {
nextcloud?: string;
bookstack?: string;
vikunja?: string;
}

View File

@@ -27,3 +27,21 @@ export interface NextcloudConnectData {
export interface NextcloudPollData {
completed: boolean;
}
export interface NextcloudMessage {
id: number;
token: string;
actorType: string;
actorId: string;
actorDisplayName: string;
message: string;
timestamp: number;
messageType: string;
systemMessage: string;
}
export interface NextcloudRoomListData {
connected: boolean;
rooms: NextcloudConversation[];
loginName?: string;
}