|
@@ -7,6 +7,7 @@ import {
|
|
|
IconButton,
|
|
IconButton,
|
|
|
List,
|
|
List,
|
|
|
ListItemButton,
|
|
ListItemButton,
|
|
|
|
|
+ ListItemIcon,
|
|
|
ListItemText,
|
|
ListItemText,
|
|
|
Toolbar,
|
|
Toolbar,
|
|
|
Tooltip,
|
|
Tooltip,
|
|
@@ -14,13 +15,26 @@ import {
|
|
|
useMediaQuery,
|
|
useMediaQuery,
|
|
|
useTheme,
|
|
useTheme,
|
|
|
} from '@mui/material';
|
|
} from '@mui/material';
|
|
|
|
|
+import AdminPanelSettingsOutlinedIcon from '@mui/icons-material/AdminPanelSettingsOutlined';
|
|
|
|
|
+import CircleOutlinedIcon from '@mui/icons-material/CircleOutlined';
|
|
|
|
|
+import DashboardOutlinedIcon from '@mui/icons-material/DashboardOutlined';
|
|
|
import MenuIcon from '@mui/icons-material/Menu';
|
|
import MenuIcon from '@mui/icons-material/Menu';
|
|
|
import LogoutIcon from '@mui/icons-material/Logout';
|
|
import LogoutIcon from '@mui/icons-material/Logout';
|
|
|
|
|
+import PeopleOutlinedIcon from '@mui/icons-material/PeopleOutlined';
|
|
|
|
|
+import TuneOutlinedIcon from '@mui/icons-material/TuneOutlined';
|
|
|
import { useEffect, useState } from 'react';
|
|
import { useEffect, useState } from 'react';
|
|
|
|
|
+import { useLocation } from 'react-router-dom';
|
|
|
import { getNavigation } from '../../api/client';
|
|
import { getNavigation } from '../../api/client';
|
|
|
import NotificationMenu from '../notifications/NotificationMenu';
|
|
import NotificationMenu from '../notifications/NotificationMenu';
|
|
|
|
|
|
|
|
-const DRAWER_WIDTH = 248;
|
|
|
|
|
|
|
+const DRAWER_WIDTH = 224;
|
|
|
|
|
+const DEFAULT_HEADER_LABEL = 'Workspace';
|
|
|
|
|
+const NAVIGATION_ICONS = {
|
|
|
|
|
+ Demo: DashboardOutlinedIcon,
|
|
|
|
|
+ Admin: AdminPanelSettingsOutlinedIcon,
|
|
|
|
|
+ 'OM-Link Settings': TuneOutlinedIcon,
|
|
|
|
|
+ Users: PeopleOutlinedIcon,
|
|
|
|
|
+};
|
|
|
|
|
|
|
|
/**
|
|
/**
|
|
|
* Converts a menu URL into an internal React route.
|
|
* Converts a menu URL into an internal React route.
|
|
@@ -33,6 +47,79 @@ function toRoute(url) {
|
|
|
return url.startsWith('/') ? url : `/${url}`;
|
|
return url.startsWith('/') ? url : `/${url}`;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+/**
|
|
|
|
|
+ * Splits the flat, legacy menu payload into root links and populated groups.
|
|
|
|
|
+ *
|
|
|
|
|
+ * @param {Array<Record<string, unknown>>} navigation Authorized navigation entries.
|
|
|
|
|
+ * @returns {{topLevelItems: Array<Record<string, unknown>>, groups: Array<Record<string, unknown>>}} Display-ready navigation structure.
|
|
|
|
|
+ * @sideEffects None.
|
|
|
|
|
+ */
|
|
|
|
|
+function getNavigationStructure(navigation) {
|
|
|
|
|
+ const groups = navigation
|
|
|
|
|
+ .filter(item => item.mainMenu && !item.url)
|
|
|
|
|
+ .map(group => ({
|
|
|
|
|
+ ...group,
|
|
|
|
|
+ items: navigation.filter(item => item.mainMenu === group.name && item.url),
|
|
|
|
|
+ }))
|
|
|
|
|
+ .filter(group => group.items.length > 0);
|
|
|
|
|
+
|
|
|
|
|
+ return {
|
|
|
|
|
+ topLevelItems: navigation.filter(item => !item.mainMenu && item.url),
|
|
|
|
|
+ groups,
|
|
|
|
|
+ };
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+/**
|
|
|
|
|
+ * Renders one compact navigation entry with its selected and disabled states.
|
|
|
|
|
+ *
|
|
|
|
|
+ * @param {{item: Record<string, unknown>, selected: boolean, onClick: () => void}} props Navigation entry properties.
|
|
|
|
|
+ * @returns {JSX.Element} Navigation button.
|
|
|
|
|
+ * @sideEffects None.
|
|
|
|
|
+ */
|
|
|
|
|
+function NavigationItem({ item, selected, onClick }) {
|
|
|
|
|
+ const Icon = NAVIGATION_ICONS[item.name] ?? CircleOutlinedIcon;
|
|
|
|
|
+
|
|
|
|
|
+ return (
|
|
|
|
|
+ <ListItemButton
|
|
|
|
|
+ selected={selected}
|
|
|
|
|
+ disabled={item.isDisabled}
|
|
|
|
|
+ onClick={onClick}
|
|
|
|
|
+ sx={{
|
|
|
|
|
+ minHeight: 42,
|
|
|
|
|
+ mb: 0.5,
|
|
|
|
|
+ borderLeft: '3px solid',
|
|
|
|
|
+ borderColor: selected ? 'primary.main' : 'transparent',
|
|
|
|
|
+ borderRadius: 1,
|
|
|
|
|
+ color: selected ? 'navigation.contrastText' : 'navigation.muted',
|
|
|
|
|
+ pl: 1.5,
|
|
|
|
|
+ '&:hover': {
|
|
|
|
|
+ backgroundColor: 'navigation.hover',
|
|
|
|
|
+ color: 'navigation.contrastText',
|
|
|
|
|
+ },
|
|
|
|
|
+ '&.Mui-selected': {
|
|
|
|
|
+ backgroundColor: 'navigation.active',
|
|
|
|
|
+ },
|
|
|
|
|
+ '&.Mui-selected:hover': {
|
|
|
|
|
+ backgroundColor: 'navigation.active',
|
|
|
|
|
+ },
|
|
|
|
|
+ '&.Mui-focusVisible': {
|
|
|
|
|
+ outline: '2px solid',
|
|
|
|
|
+ outlineColor: 'primary.light',
|
|
|
|
|
+ outlineOffset: -2,
|
|
|
|
|
+ },
|
|
|
|
|
+ }}
|
|
|
|
|
+ >
|
|
|
|
|
+ <ListItemIcon sx={{ minWidth: 34, color: 'inherit' }}>
|
|
|
|
|
+ <Icon fontSize="small" />
|
|
|
|
|
+ </ListItemIcon>
|
|
|
|
|
+ <ListItemText
|
|
|
|
|
+ primary={item.name}
|
|
|
|
|
+ primaryTypographyProps={{ fontSize: '0.875rem', fontWeight: selected ? 700 : 500 }}
|
|
|
|
|
+ />
|
|
|
|
|
+ </ListItemButton>
|
|
|
|
|
+ );
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
/**
|
|
/**
|
|
|
* Renders the authenticated responsive application shell.
|
|
* Renders the authenticated responsive application shell.
|
|
|
*
|
|
*
|
|
@@ -42,10 +129,17 @@ function toRoute(url) {
|
|
|
*/
|
|
*/
|
|
|
function AppShell({ session, onNavigate, onLogout, children }) {
|
|
function AppShell({ session, onNavigate, onLogout, children }) {
|
|
|
const theme = useTheme();
|
|
const theme = useTheme();
|
|
|
|
|
+ const location = useLocation();
|
|
|
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
|
|
const isMobile = useMediaQuery(theme.breakpoints.down('md'));
|
|
|
const [mobileOpen, setMobileOpen] = useState(false);
|
|
const [mobileOpen, setMobileOpen] = useState(false);
|
|
|
const [navigation, setNavigation] = useState([]);
|
|
const [navigation, setNavigation] = useState([]);
|
|
|
const [navigationError, setNavigationError] = useState('');
|
|
const [navigationError, setNavigationError] = useState('');
|
|
|
|
|
+ const { topLevelItems, groups } = getNavigationStructure(navigation);
|
|
|
|
|
+ const activeItem = navigation.find(item => item.url && toRoute(item.url) === location.pathname);
|
|
|
|
|
+ const activeGroup = groups.find(group =>
|
|
|
|
|
+ group.items.some(item => toRoute(item.url) === location.pathname),
|
|
|
|
|
+ );
|
|
|
|
|
+ const headerLabel = activeGroup?.name ?? DEFAULT_HEADER_LABEL;
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
useEffect(() => {
|
|
|
let isMounted = true;
|
|
let isMounted = true;
|
|
@@ -79,79 +173,109 @@ function AppShell({ session, onNavigate, onLogout, children }) {
|
|
|
onNavigate(toRoute(url));
|
|
onNavigate(toRoute(url));
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- /**
|
|
|
|
|
- * Renders the authorized menu entries and their child groups.
|
|
|
|
|
- *
|
|
|
|
|
- * @returns {JSX.Element} Navigation list.
|
|
|
|
|
- * @sideEffects None.
|
|
|
|
|
- */
|
|
|
|
|
- function renderNavigation() {
|
|
|
|
|
- const topLevelItems = navigation.filter(item => !item.mainMenu && item.url);
|
|
|
|
|
- const groups = navigation.filter(item => item.mainMenu);
|
|
|
|
|
-
|
|
|
|
|
- return (
|
|
|
|
|
- <List component="nav" aria-label="Application navigation" className="p-3">
|
|
|
|
|
|
|
+ const drawer = (
|
|
|
|
|
+ <Box
|
|
|
|
|
+ role="presentation"
|
|
|
|
|
+ sx={{
|
|
|
|
|
+ display: 'flex',
|
|
|
|
|
+ minHeight: '100%',
|
|
|
|
|
+ flexDirection: 'column',
|
|
|
|
|
+ backgroundColor: 'navigation.main',
|
|
|
|
|
+ }}
|
|
|
|
|
+ >
|
|
|
|
|
+ <Box sx={{ display: 'flex', alignItems: 'center', gap: 1.25, px: 2, py: 2.25 }}>
|
|
|
|
|
+ <Box
|
|
|
|
|
+ aria-hidden="true"
|
|
|
|
|
+ sx={{
|
|
|
|
|
+ display: 'grid',
|
|
|
|
|
+ height: 32,
|
|
|
|
|
+ width: 32,
|
|
|
|
|
+ placeItems: 'center',
|
|
|
|
|
+ borderRadius: 1,
|
|
|
|
|
+ backgroundColor: 'primary.main',
|
|
|
|
|
+ color: 'primary.contrastText',
|
|
|
|
|
+ fontSize: '0.75rem',
|
|
|
|
|
+ fontWeight: 800,
|
|
|
|
|
+ letterSpacing: '-0.06em',
|
|
|
|
|
+ }}
|
|
|
|
|
+ >
|
|
|
|
|
+ OM
|
|
|
|
|
+ </Box>
|
|
|
|
|
+ <Box>
|
|
|
|
|
+ <Typography sx={{ color: 'navigation.contrastText', fontSize: '0.9375rem', fontWeight: 700 }}>
|
|
|
|
|
+ OM-Link
|
|
|
|
|
+ </Typography>
|
|
|
|
|
+ <Typography variant="overline" sx={{ color: 'navigation.muted', display: 'block', lineHeight: 1.3 }}>
|
|
|
|
|
+ Console
|
|
|
|
|
+ </Typography>
|
|
|
|
|
+ </Box>
|
|
|
|
|
+ </Box>
|
|
|
|
|
+ <Divider sx={{ borderColor: 'navigation.divider' }} />
|
|
|
|
|
+ <List component="nav" aria-label="Application navigation" disablePadding sx={{ px: 1.25, py: 1.5 }}>
|
|
|
{topLevelItems.map(item => (
|
|
{topLevelItems.map(item => (
|
|
|
- <ListItemButton
|
|
|
|
|
|
|
+ <NavigationItem
|
|
|
key={item.name}
|
|
key={item.name}
|
|
|
- disabled={item.isDisabled}
|
|
|
|
|
|
|
+ item={item}
|
|
|
|
|
+ selected={toRoute(item.url) === location.pathname}
|
|
|
onClick={() => handleNavigation(item.url)}
|
|
onClick={() => handleNavigation(item.url)}
|
|
|
- >
|
|
|
|
|
- <ListItemText primary={item.name} />
|
|
|
|
|
- </ListItemButton>
|
|
|
|
|
|
|
+ />
|
|
|
|
|
+ ))}
|
|
|
|
|
+ {groups.map(group => (
|
|
|
|
|
+ <Box key={group.name} sx={{ mt: 2.25 }}>
|
|
|
|
|
+ <Typography
|
|
|
|
|
+ variant="overline"
|
|
|
|
|
+ sx={{ color: 'navigation.muted', display: 'block', px: 1.5, pb: 0.75 }}
|
|
|
|
|
+ >
|
|
|
|
|
+ {group.name}
|
|
|
|
|
+ </Typography>
|
|
|
|
|
+ {group.items.map(item => (
|
|
|
|
|
+ <NavigationItem
|
|
|
|
|
+ key={item.name}
|
|
|
|
|
+ item={item}
|
|
|
|
|
+ selected={toRoute(item.url) === location.pathname}
|
|
|
|
|
+ onClick={() => handleNavigation(item.url)}
|
|
|
|
|
+ />
|
|
|
|
|
+ ))}
|
|
|
|
|
+ </Box>
|
|
|
))}
|
|
))}
|
|
|
- {groups.map(group => {
|
|
|
|
|
- const childrenItems = navigation.filter(item => item.mainMenu === group.name && item.url);
|
|
|
|
|
-
|
|
|
|
|
- return (
|
|
|
|
|
- <Box key={group.name} className="mt-3">
|
|
|
|
|
- <Typography className="px-4 pb-1" variant="overline" color="text.secondary">
|
|
|
|
|
- {group.name}
|
|
|
|
|
- </Typography>
|
|
|
|
|
- {childrenItems.map(item => (
|
|
|
|
|
- <ListItemButton
|
|
|
|
|
- key={item.name}
|
|
|
|
|
- className="pl-6"
|
|
|
|
|
- disabled={item.isDisabled}
|
|
|
|
|
- onClick={() => handleNavigation(item.url)}
|
|
|
|
|
- >
|
|
|
|
|
- <ListItemText primary={item.name} />
|
|
|
|
|
- </ListItemButton>
|
|
|
|
|
- ))}
|
|
|
|
|
- </Box>
|
|
|
|
|
- );
|
|
|
|
|
- })}
|
|
|
|
|
{navigationError && (
|
|
{navigationError && (
|
|
|
- <Typography className="px-4 py-3" variant="caption" color="error">
|
|
|
|
|
|
|
+ <Typography sx={{ color: '#fca5a5', px: 1.5, py: 2, fontSize: '0.75rem' }}>
|
|
|
{navigationError}
|
|
{navigationError}
|
|
|
</Typography>
|
|
</Typography>
|
|
|
)}
|
|
)}
|
|
|
</List>
|
|
</List>
|
|
|
- );
|
|
|
|
|
- }
|
|
|
|
|
-
|
|
|
|
|
- const drawer = (
|
|
|
|
|
- <Box role="presentation" className="h-full bg-white">
|
|
|
|
|
- <Typography variant="h6" className="px-6 py-5">
|
|
|
|
|
- OM-Link
|
|
|
|
|
- </Typography>
|
|
|
|
|
- <Divider />
|
|
|
|
|
- {renderNavigation()}
|
|
|
|
|
</Box>
|
|
</Box>
|
|
|
);
|
|
);
|
|
|
|
|
|
|
|
return (
|
|
return (
|
|
|
<Box className="flex min-h-screen bg-slate-100">
|
|
<Box className="flex min-h-screen bg-slate-100">
|
|
|
- <AppBar position="fixed" color="inherit" elevation={0} className="border-b border-slate-200">
|
|
|
|
|
|
|
+ <AppBar
|
|
|
|
|
+ position="fixed"
|
|
|
|
|
+ color="inherit"
|
|
|
|
|
+ elevation={0}
|
|
|
|
|
+ className="border-b border-slate-200"
|
|
|
|
|
+ sx={{
|
|
|
|
|
+ ml: { md: `${DRAWER_WIDTH}px` },
|
|
|
|
|
+ width: { md: `calc(100% - ${DRAWER_WIDTH}px)` },
|
|
|
|
|
+ }}
|
|
|
|
|
+ >
|
|
|
<Toolbar className="gap-2" sx={{ pt: 'env(safe-area-inset-top, 0px)' }}>
|
|
<Toolbar className="gap-2" sx={{ pt: 'env(safe-area-inset-top, 0px)' }}>
|
|
|
{isMobile && (
|
|
{isMobile && (
|
|
|
<IconButton color="inherit" aria-label="Open navigation" onClick={() => setMobileOpen(true)}>
|
|
<IconButton color="inherit" aria-label="Open navigation" onClick={() => setMobileOpen(true)}>
|
|
|
<MenuIcon />
|
|
<MenuIcon />
|
|
|
</IconButton>
|
|
</IconButton>
|
|
|
)}
|
|
)}
|
|
|
- <Typography variant="h6" component="div" className="flex-1">
|
|
|
|
|
- OM-Link
|
|
|
|
|
- </Typography>
|
|
|
|
|
|
|
+ <Box sx={{ flex: 1, minWidth: 0 }}>
|
|
|
|
|
+ <Typography
|
|
|
|
|
+ variant="overline"
|
|
|
|
|
+ sx={{ color: 'text.secondary', display: { xs: 'none', md: 'block' }, lineHeight: 1.3 }}
|
|
|
|
|
+ >
|
|
|
|
|
+ {headerLabel}
|
|
|
|
|
+ </Typography>
|
|
|
|
|
+ <Typography variant="h6" component="div" noWrap>
|
|
|
|
|
+ {isMobile ? 'OM-Link' : activeItem?.name ?? 'OM-Link'}
|
|
|
|
|
+ </Typography>
|
|
|
|
|
+ </Box>
|
|
|
<NotificationMenu onNavigate={onNavigate} />
|
|
<NotificationMenu onNavigate={onNavigate} />
|
|
|
<Tooltip title="Sign out">
|
|
<Tooltip title="Sign out">
|
|
|
<Button color="inherit" startIcon={<LogoutIcon />} onClick={onLogout}>
|
|
<Button color="inherit" startIcon={<LogoutIcon />} onClick={onLogout}>
|
|
@@ -161,12 +285,12 @@ function AppShell({ session, onNavigate, onLogout, children }) {
|
|
|
</Toolbar>
|
|
</Toolbar>
|
|
|
</AppBar>
|
|
</AppBar>
|
|
|
{isMobile ? (
|
|
{isMobile ? (
|
|
|
- <Drawer open={mobileOpen} onClose={() => setMobileOpen(false)}>
|
|
|
|
|
|
|
+ <Drawer open={mobileOpen} onClose={() => setMobileOpen(false)} PaperProps={{ sx: { width: DRAWER_WIDTH } }}>
|
|
|
{drawer}
|
|
{drawer}
|
|
|
</Drawer>
|
|
</Drawer>
|
|
|
) : (
|
|
) : (
|
|
|
- <Box component="aside" className="w-0 shrink-0 md:w-60">
|
|
|
|
|
- <Drawer variant="permanent" PaperProps={{ className: 'w-60 border-r border-slate-200' }}>
|
|
|
|
|
|
|
+ <Box component="aside" sx={{ display: { xs: 'none', md: 'block' }, flexShrink: 0, width: DRAWER_WIDTH }}>
|
|
|
|
|
+ <Drawer variant="permanent" PaperProps={{ sx: { width: DRAWER_WIDTH } }}>
|
|
|
{drawer}
|
|
{drawer}
|
|
|
</Drawer>
|
|
</Drawer>
|
|
|
</Box>
|
|
</Box>
|