feat: add full-page chat route and sidebar menu ordering
This commit is contained in:
@@ -39,6 +39,7 @@ import AusruestungsanfrageNeu from './pages/AusruestungsanfrageNeu';
|
||||
import Issues from './pages/Issues';
|
||||
import IssueDetail from './pages/IssueDetail';
|
||||
import IssueNeu from './pages/IssueNeu';
|
||||
import Chat from './pages/Chat';
|
||||
import AdminDashboard from './pages/AdminDashboard';
|
||||
import AdminSettings from './pages/AdminSettings';
|
||||
import NotFound from './pages/NotFound';
|
||||
@@ -77,6 +78,14 @@ function App() {
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/chat"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<Chat />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/einsaetze"
|
||||
element={
|
||||
@@ -261,14 +270,6 @@ function App() {
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/bestellungen/lieferanten/neu"
|
||||
element={
|
||||
<ProtectedRoute>
|
||||
<LieferantDetail />
|
||||
</ProtectedRoute>
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="/bestellungen/lieferanten/:id"
|
||||
element={
|
||||
|
||||
@@ -13,7 +13,7 @@ import Tooltip from '@mui/material/Tooltip';
|
||||
import List from '@mui/material/List';
|
||||
import ListItem from '@mui/material/ListItem';
|
||||
import { useLayout, CHAT_PANEL_MIN_WIDTH, CHAT_PANEL_MAX_WIDTH } from '../../contexts/LayoutContext';
|
||||
import { ChatProvider, useChat } from '../../contexts/ChatContext';
|
||||
import { useChat } from '../../contexts/ChatContext';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { configApi } from '../../services/config';
|
||||
@@ -341,11 +341,7 @@ const ChatPanelInner: React.FC = () => {
|
||||
};
|
||||
|
||||
const ChatPanel: React.FC = () => {
|
||||
return (
|
||||
<ChatProvider>
|
||||
<ChatPanelInner />
|
||||
</ChatProvider>
|
||||
);
|
||||
return <ChatPanelInner />;
|
||||
};
|
||||
|
||||
export default ChatPanel;
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { useState, ReactNode } from 'react';
|
||||
import { Box, Toolbar } from '@mui/material';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
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 { ChatProvider } from '../../contexts/ChatContext';
|
||||
import ChatPanel from '../chat/ChatPanel';
|
||||
|
||||
interface DashboardLayoutProps {
|
||||
@@ -15,6 +17,8 @@ function DashboardLayoutInner({ children }: DashboardLayoutProps) {
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
const { isLoading } = useAuth();
|
||||
const { sidebarCollapsed, chatPanelOpen, chatPanelWidth } = useLayout();
|
||||
const location = useLocation();
|
||||
const onChatPage = location.pathname === '/chat';
|
||||
|
||||
const handleDrawerToggle = () => {
|
||||
setMobileOpen(!mobileOpen);
|
||||
@@ -25,7 +29,7 @@ function DashboardLayoutInner({ children }: DashboardLayoutProps) {
|
||||
}
|
||||
|
||||
const sidebarWidth = sidebarCollapsed ? DRAWER_WIDTH_COLLAPSED : DRAWER_WIDTH;
|
||||
const chatWidth = chatPanelOpen ? chatPanelWidth : 64;
|
||||
const chatWidth = onChatPage ? 0 : (chatPanelOpen ? chatPanelWidth : 64);
|
||||
|
||||
return (
|
||||
<Box sx={{ display: 'flex', height: '100vh', overflow: 'hidden' }}>
|
||||
@@ -48,7 +52,7 @@ function DashboardLayoutInner({ children }: DashboardLayoutProps) {
|
||||
{children}
|
||||
</Box>
|
||||
|
||||
<ChatPanel />
|
||||
{!onChatPage && <ChatPanel />}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
@@ -56,7 +60,9 @@ function DashboardLayoutInner({ children }: DashboardLayoutProps) {
|
||||
function DashboardLayout({ children }: DashboardLayoutProps) {
|
||||
return (
|
||||
<LayoutProvider>
|
||||
<DashboardLayoutInner>{children}</DashboardLayoutInner>
|
||||
<ChatProvider>
|
||||
<DashboardLayoutInner>{children}</DashboardLayoutInner>
|
||||
</ChatProvider>
|
||||
</LayoutProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -28,12 +28,14 @@ import {
|
||||
LocalShipping,
|
||||
BugReport,
|
||||
BookOnline,
|
||||
Forum,
|
||||
} from '@mui/icons-material';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useLayout, DRAWER_WIDTH, DRAWER_WIDTH_COLLAPSED } from '../../contexts/LayoutContext';
|
||||
import { usePermissionContext } from '../../contexts/PermissionContext';
|
||||
import { vehiclesApi } from '../../services/vehicles';
|
||||
import { preferencesApi } from '../../services/settings';
|
||||
|
||||
export { DRAWER_WIDTH, DRAWER_WIDTH_COLLAPSED };
|
||||
|
||||
@@ -69,6 +71,11 @@ const baseNavigationItems: NavigationItem[] = [
|
||||
icon: <DashboardIcon />,
|
||||
path: '/dashboard',
|
||||
},
|
||||
{
|
||||
text: 'Chat',
|
||||
icon: <Forum />,
|
||||
path: '/chat',
|
||||
},
|
||||
{
|
||||
text: 'Kalender',
|
||||
icon: <CalendarMonth />,
|
||||
@@ -169,6 +176,14 @@ function Sidebar({ mobileOpen, onMobileClose }: SidebarProps) {
|
||||
staleTime: 2 * 60 * 1000,
|
||||
});
|
||||
|
||||
const { data: preferences } = useQuery({
|
||||
queryKey: ['user-preferences'],
|
||||
queryFn: preferencesApi.get,
|
||||
staleTime: 5 * 60 * 1000,
|
||||
});
|
||||
|
||||
const menuOrder: string[] = (preferences?.menuOrder as string[] | undefined) ?? [];
|
||||
|
||||
const vehicleSubItems: SubItem[] = useMemo(
|
||||
() =>
|
||||
(vehicleList ?? []).map((v) => ({
|
||||
@@ -220,8 +235,21 @@ function Sidebar({ mobileOpen, onMobileClose }: SidebarProps) {
|
||||
return item;
|
||||
})
|
||||
.filter((item) => !item.permission || hasPermission(item.permission));
|
||||
|
||||
// Apply custom menu order: items in menuOrder are sorted to their index; rest keep relative order
|
||||
if (menuOrder.length > 0) {
|
||||
items.sort((a, b) => {
|
||||
const aIdx = menuOrder.indexOf(a.path);
|
||||
const bIdx = menuOrder.indexOf(b.path);
|
||||
if (aIdx !== -1 && bIdx !== -1) return aIdx - bIdx;
|
||||
if (aIdx !== -1) return -1;
|
||||
if (bIdx !== -1) return 1;
|
||||
return 0;
|
||||
});
|
||||
}
|
||||
|
||||
return hasPermission('admin:view') ? [...items, adminItem, adminSettingsItem] : items;
|
||||
}, [vehicleSubItems, hasPermission]);
|
||||
}, [vehicleSubItems, hasPermission, menuOrder]);
|
||||
|
||||
// Expand state for items with sub-items — auto-expand when route matches
|
||||
const [expandedItems, setExpandedItems] = useState<Record<string, boolean>>({});
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import React, { createContext, useContext, useState, useCallback, useEffect, useRef, ReactNode } from 'react';
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { nextcloudApi } from '../services/nextcloud';
|
||||
import { useLayout } from './LayoutContext';
|
||||
import { useNotification } from './NotificationContext';
|
||||
@@ -24,7 +25,10 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({ children }) => {
|
||||
const { chatPanelOpen } = useLayout();
|
||||
const { showNotificationToast } = useNotification();
|
||||
const queryClient = useQueryClient();
|
||||
const location = useLocation();
|
||||
const onChatPage = location.pathname === '/chat';
|
||||
const prevPanelOpenRef = useRef(chatPanelOpen);
|
||||
const prevOnChatPageRef = useRef(onChatPage);
|
||||
const prevUnreadRef = useRef<Map<string, number>>(new Map());
|
||||
|
||||
// Invalidate rooms/connection when panel opens so data is fresh immediately
|
||||
@@ -36,10 +40,21 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({ children }) => {
|
||||
prevPanelOpenRef.current = chatPanelOpen;
|
||||
}, [chatPanelOpen, queryClient]);
|
||||
|
||||
// Invalidate rooms/connection when navigating to the chat page
|
||||
useEffect(() => {
|
||||
if (onChatPage && !prevOnChatPageRef.current) {
|
||||
queryClient.invalidateQueries({ queryKey: ['nextcloud', 'connection'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['nextcloud', 'rooms'] });
|
||||
}
|
||||
prevOnChatPageRef.current = onChatPage;
|
||||
}, [onChatPage, queryClient]);
|
||||
|
||||
const isActive = chatPanelOpen || onChatPage;
|
||||
|
||||
const { data: connData } = useQuery({
|
||||
queryKey: ['nextcloud', 'connection'],
|
||||
queryFn: () => nextcloudApi.getConversations(),
|
||||
refetchInterval: chatPanelOpen ? 5000 : 15000,
|
||||
refetchInterval: isActive ? 5000 : 15000,
|
||||
retry: false,
|
||||
});
|
||||
|
||||
@@ -48,7 +63,7 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({ children }) => {
|
||||
const { data } = useQuery({
|
||||
queryKey: ['nextcloud', 'rooms'],
|
||||
queryFn: () => nextcloudApi.getRooms(),
|
||||
refetchInterval: chatPanelOpen ? 5000 : 15000,
|
||||
refetchInterval: isActive ? 5000 : 15000,
|
||||
enabled: isConnected,
|
||||
});
|
||||
|
||||
@@ -65,7 +80,7 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({ children }) => {
|
||||
}
|
||||
}, [isConnected]);
|
||||
|
||||
// Detect new unread messages while panel is closed and show toast
|
||||
// Detect new unread messages while panel is closed and not on chat page — show toast
|
||||
useEffect(() => {
|
||||
if (!rooms.length) return;
|
||||
const prev = prevUnreadRef.current;
|
||||
@@ -81,7 +96,7 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({ children }) => {
|
||||
|
||||
for (const room of rooms) {
|
||||
const prevCount = prev.get(room.token) ?? 0;
|
||||
if (!chatPanelOpen && room.unreadMessages > prevCount) {
|
||||
if (!chatPanelOpen && !onChatPage && room.unreadMessages > prevCount) {
|
||||
showNotificationToast(room.displayName, 'info');
|
||||
}
|
||||
prev.set(room.token, room.unreadMessages);
|
||||
@@ -92,7 +107,7 @@ export const ChatProvider: React.FC<ChatProviderProps> = ({ children }) => {
|
||||
for (const key of prev.keys()) {
|
||||
if (!currentTokens.has(key)) prev.delete(key);
|
||||
}
|
||||
}, [rooms, chatPanelOpen, showNotificationToast]);
|
||||
}, [rooms, chatPanelOpen, onChatPage, showNotificationToast]);
|
||||
|
||||
const selectRoom = useCallback((token: string | null) => {
|
||||
setSelectedRoomToken(token);
|
||||
|
||||
98
frontend/src/pages/Chat.tsx
Normal file
98
frontend/src/pages/Chat.tsx
Normal file
@@ -0,0 +1,98 @@
|
||||
import React from 'react';
|
||||
import Box from '@mui/material/Box';
|
||||
import Typography from '@mui/material/Typography';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
||||
import { useChat } from '../contexts/ChatContext';
|
||||
import { nextcloudApi } from '../services/nextcloud';
|
||||
import { notificationsApi } from '../services/notifications';
|
||||
import ChatRoomList from '../components/chat/ChatRoomList';
|
||||
import ChatMessageView from '../components/chat/ChatMessageView';
|
||||
|
||||
const ChatPage: React.FC = () => {
|
||||
const { rooms, selectedRoomToken, connected } = useChat();
|
||||
const queryClient = useQueryClient();
|
||||
const markedRoomsRef = React.useRef(new Set<string>());
|
||||
|
||||
// Dismiss nextcloud_talk notifications when on this page
|
||||
React.useEffect(() => {
|
||||
notificationsApi.dismissByType('nextcloud_talk').then(() => {
|
||||
queryClient.invalidateQueries({ queryKey: ['notifications'] });
|
||||
queryClient.invalidateQueries({ queryKey: ['unreadNotificationCount'] });
|
||||
}).catch(() => {});
|
||||
}, [queryClient]);
|
||||
|
||||
// Mark unread rooms as read when rooms data updates
|
||||
React.useEffect(() => {
|
||||
const unread = rooms.filter(
|
||||
(r) => r.unreadMessages > 0 && !markedRoomsRef.current.has(r.token),
|
||||
);
|
||||
if (unread.length === 0) return;
|
||||
unread.forEach((r) => markedRoomsRef.current.add(r.token));
|
||||
Promise.allSettled(unread.map((r) => nextcloudApi.markAsRead(r.token))).then(() => {
|
||||
queryClient.invalidateQueries({ queryKey: ['nextcloud', 'rooms'] });
|
||||
});
|
||||
}, [rooms, queryClient]);
|
||||
|
||||
// Mark selected room as read when a conversation is opened
|
||||
React.useEffect(() => {
|
||||
if (!selectedRoomToken) return;
|
||||
nextcloudApi.markAsRead(selectedRoomToken).then(() => {
|
||||
queryClient.invalidateQueries({ queryKey: ['nextcloud', 'rooms'] });
|
||||
}).catch(() => {});
|
||||
}, [selectedRoomToken, queryClient]);
|
||||
|
||||
return (
|
||||
<DashboardLayout>
|
||||
<Box
|
||||
sx={{
|
||||
height: 'calc(100vh - 112px)',
|
||||
display: 'flex',
|
||||
border: 1,
|
||||
borderColor: 'divider',
|
||||
borderRadius: 1,
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{!connected ? (
|
||||
<Box sx={{ p: 3, display: 'flex', alignItems: 'center' }}>
|
||||
<Typography variant="body1" color="text.secondary">
|
||||
Nextcloud nicht verbunden. Bitte verbinden Sie sich in den{' '}
|
||||
<Link to="/settings" style={{ color: 'inherit' }}>Einstellungen</Link>.
|
||||
</Typography>
|
||||
</Box>
|
||||
) : (
|
||||
<>
|
||||
<Box
|
||||
sx={{
|
||||
width: 280,
|
||||
flexShrink: 0,
|
||||
borderRight: 1,
|
||||
borderColor: 'divider',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<ChatRoomList />
|
||||
</Box>
|
||||
<Box sx={{ flex: 1, minWidth: 0, display: 'flex', flexDirection: 'column', overflow: 'hidden' }}>
|
||||
{selectedRoomToken ? (
|
||||
<ChatMessageView />
|
||||
) : (
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'center', height: '100%' }}>
|
||||
<Typography variant="body1" color="text.secondary">
|
||||
Wähle ein Gespräch aus, um zu beginnen.
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
</>
|
||||
)}
|
||||
</Box>
|
||||
</DashboardLayout>
|
||||
);
|
||||
};
|
||||
|
||||
export default ChatPage;
|
||||
@@ -15,8 +15,12 @@ import {
|
||||
CircularProgress,
|
||||
Button,
|
||||
Chip,
|
||||
IconButton,
|
||||
List,
|
||||
ListItem,
|
||||
ListItemText,
|
||||
} from '@mui/material';
|
||||
import { Settings as SettingsIcon, Notifications, Palette, Language, SettingsBrightness, LightMode, DarkMode, Widgets, Cloud, LinkOff, Forum, Person, OpenInNew } from '@mui/icons-material';
|
||||
import { Settings as SettingsIcon, Notifications, Palette, Language, SettingsBrightness, LightMode, DarkMode, Widgets, Cloud, LinkOff, Forum, Person, OpenInNew, ArrowUpward, ArrowDownward, Sort, Restore } from '@mui/icons-material';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import DashboardLayout from '../components/dashboard/DashboardLayout';
|
||||
@@ -26,16 +30,34 @@ import { WIDGETS, WidgetKey } from '../constants/widgets';
|
||||
import { nextcloudApi } from '../services/nextcloud';
|
||||
import { useNotification } from '../contexts/NotificationContext';
|
||||
import { useAuth } from '../contexts/AuthContext';
|
||||
import { usePermissionContext } from '../contexts/PermissionContext';
|
||||
|
||||
const POLL_INTERVAL = 2000;
|
||||
const POLL_TIMEOUT = 5 * 60 * 1000;
|
||||
|
||||
// Ordered list of nav items eligible for reordering (mirrors baseNavigationItems, excluding admin/settings)
|
||||
const ORDERABLE_NAV_ITEMS = [
|
||||
{ text: 'Dashboard', path: '/dashboard', permission: undefined as string | undefined },
|
||||
{ text: 'Chat', path: '/chat', permission: undefined as string | undefined },
|
||||
{ text: 'Kalender', path: '/kalender', permission: 'kalender:view' },
|
||||
{ text: 'Fahrzeugbuchungen', path: '/fahrzeugbuchungen', permission: 'fahrzeugbuchungen:view' },
|
||||
{ text: 'Fahrzeuge', path: '/fahrzeuge', permission: 'fahrzeuge:view' },
|
||||
{ text: 'Ausrüstung', path: '/ausruestung', permission: 'ausruestung:view' },
|
||||
{ text: 'Mitglieder', path: '/mitglieder', permission: 'mitglieder:view_own' },
|
||||
{ text: 'Atemschutz', path: '/atemschutz', permission: 'atemschutz:view' },
|
||||
{ text: 'Wissen', path: '/wissen', permission: 'wissen:view' },
|
||||
{ text: 'Bestellungen', path: '/bestellungen', permission: 'bestellungen:view' },
|
||||
{ text: 'Interne Bestellungen', path: '/ausruestungsanfrage', permission: 'ausruestungsanfrage:view' },
|
||||
{ text: 'Issues', path: '/issues', permission: 'issues:view_own' },
|
||||
];
|
||||
|
||||
function Settings() {
|
||||
const { themeMode, setThemeMode } = useThemeMode();
|
||||
const queryClient = useQueryClient();
|
||||
const { showInfo } = useNotification();
|
||||
const { user } = useAuth();
|
||||
const navigate = useNavigate();
|
||||
const { hasPermission } = usePermissionContext();
|
||||
|
||||
const { data: preferences, isLoading: prefsLoading } = useQuery({
|
||||
queryKey: ['user-preferences'],
|
||||
@@ -60,6 +82,36 @@ function Settings() {
|
||||
mutation.mutate({ ...current, widgets });
|
||||
};
|
||||
|
||||
// Menu ordering
|
||||
const menuOrder: string[] = (preferences?.menuOrder as string[] | undefined) ?? [];
|
||||
const visibleNavItems = ORDERABLE_NAV_ITEMS.filter(
|
||||
(item) => !item.permission || hasPermission(item.permission),
|
||||
);
|
||||
const orderedNavItems = [...visibleNavItems].sort((a, b) => {
|
||||
const aIdx = menuOrder.indexOf(a.path);
|
||||
const bIdx = menuOrder.indexOf(b.path);
|
||||
if (aIdx !== -1 && bIdx !== -1) return aIdx - bIdx;
|
||||
if (aIdx !== -1) return -1;
|
||||
if (bIdx !== -1) return 1;
|
||||
return 0;
|
||||
});
|
||||
|
||||
const moveNavItem = (fromIdx: number, toIdx: number) => {
|
||||
if (toIdx < 0 || toIdx >= orderedNavItems.length) return;
|
||||
const newOrder = [...orderedNavItems];
|
||||
const [moved] = newOrder.splice(fromIdx, 1);
|
||||
newOrder.splice(toIdx, 0, moved);
|
||||
const current = preferences ?? {};
|
||||
mutation.mutate({ ...current, menuOrder: newOrder.map((i) => i.path) });
|
||||
};
|
||||
|
||||
const resetMenuOrder = () => {
|
||||
const current = preferences ?? {};
|
||||
const updated = { ...current };
|
||||
delete updated.menuOrder;
|
||||
mutation.mutate(updated);
|
||||
};
|
||||
|
||||
// Nextcloud Talk connection
|
||||
const { data: ncData, isLoading: ncLoading } = useQuery({
|
||||
queryKey: ['nextcloud-talk-rooms'],
|
||||
@@ -194,6 +246,70 @@ function Settings() {
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
{/* Menu Ordering */}
|
||||
<Grid item xs={12} md={6}>
|
||||
<Card>
|
||||
<CardContent>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', mb: 2 }}>
|
||||
<Box sx={{ display: 'flex', alignItems: 'center' }}>
|
||||
<Sort color="primary" sx={{ mr: 2 }} />
|
||||
<Typography variant="h6">Menü-Reihenfolge</Typography>
|
||||
</Box>
|
||||
<Button
|
||||
size="small"
|
||||
startIcon={<Restore />}
|
||||
onClick={resetMenuOrder}
|
||||
disabled={mutation.isPending || menuOrder.length === 0}
|
||||
>
|
||||
Zurücksetzen
|
||||
</Button>
|
||||
</Box>
|
||||
<Divider sx={{ mb: 1 }} />
|
||||
{prefsLoading ? (
|
||||
<Box sx={{ display: 'flex', justifyContent: 'center', py: 2 }}>
|
||||
<CircularProgress size={24} />
|
||||
</Box>
|
||||
) : (
|
||||
<List disablePadding>
|
||||
{orderedNavItems.map((item, idx) => (
|
||||
<ListItem
|
||||
key={item.path}
|
||||
disablePadding
|
||||
sx={{ py: 0.25 }}
|
||||
secondaryAction={
|
||||
<Box>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => moveNavItem(idx, idx - 1)}
|
||||
disabled={idx === 0 || mutation.isPending}
|
||||
aria-label="Nach oben"
|
||||
>
|
||||
<ArrowUpward fontSize="small" />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
size="small"
|
||||
onClick={() => moveNavItem(idx, idx + 1)}
|
||||
disabled={idx === orderedNavItems.length - 1 || mutation.isPending}
|
||||
aria-label="Nach unten"
|
||||
>
|
||||
<ArrowDownward fontSize="small" />
|
||||
</IconButton>
|
||||
</Box>
|
||||
}
|
||||
>
|
||||
<ListItemText
|
||||
primary={item.text}
|
||||
primaryTypographyProps={{ variant: 'body2' }}
|
||||
sx={{ pr: 8 }}
|
||||
/>
|
||||
</ListItem>
|
||||
))}
|
||||
</List>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Grid>
|
||||
|
||||
{/* Nextcloud Talk */}
|
||||
<Grid item xs={12} md={6}>
|
||||
<Card>
|
||||
|
||||
Reference in New Issue
Block a user