| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309 |
- import {
- AppBar,
- Box,
- Button,
- Divider,
- Drawer,
- IconButton,
- List,
- ListItemButton,
- ListItemIcon,
- ListItemText,
- Toolbar,
- Tooltip,
- Typography,
- useMediaQuery,
- useTheme,
- } from '@mui/material';
- import AdminPanelSettingsOutlinedIcon from '@mui/icons-material/AdminPanelSettingsOutlined';
- import CircleOutlinedIcon from '@mui/icons-material/CircleOutlined';
- import Inventory2OutlinedIcon from '@mui/icons-material/Inventory2Outlined';
- import DashboardOutlinedIcon from '@mui/icons-material/DashboardOutlined';
- import MenuIcon from '@mui/icons-material/Menu';
- import LogoutIcon from '@mui/icons-material/Logout';
- import PeopleOutlinedIcon from '@mui/icons-material/PeopleOutlined';
- import SecurityOutlinedIcon from '@mui/icons-material/SecurityOutlined';
- import TuneOutlinedIcon from '@mui/icons-material/TuneOutlined';
- import { useEffect, useState } from 'react';
- import { useLocation } from 'react-router-dom';
- import { getNavigation } from '../../api/client';
- import NotificationMenu from '../notifications/NotificationMenu';
- const DRAWER_WIDTH = 224;
- const DEFAULT_HEADER_LABEL = 'Workspace';
- const NAVIGATION_ICONS = {
- Demo: DashboardOutlinedIcon,
- Admin: AdminPanelSettingsOutlinedIcon,
- Roles: SecurityOutlinedIcon,
- 'PHPEngine Settings': TuneOutlinedIcon,
- Users: PeopleOutlinedIcon,
- 'Inventory Monitor': Inventory2OutlinedIcon,
- };
- /**
- * Converts a menu URL into an internal React route.
- *
- * @param {string} url Legacy menu URL.
- * @returns {string} Route path.
- * @sideEffects None.
- */
- function toRoute(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.
- *
- * @param {{session: Record<string, unknown>, onNavigate: (path: string) => void, onLogout: () => void, children: import('react').ReactNode}} props Shell properties.
- * @returns {JSX.Element} Application shell.
- * @sideEffects Loads permission-filtered navigation and renders authenticated controls.
- */
- function AppShell({ session, onNavigate, onLogout, children }) {
- const theme = useTheme();
- const location = useLocation();
- const isMobile = useMediaQuery(theme.breakpoints.down('md'));
- const [mobileOpen, setMobileOpen] = useState(false);
- const [navigation, setNavigation] = 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(() => {
- let isMounted = true;
- getNavigation()
- .then(items => {
- if (isMounted) {
- setNavigation(items);
- }
- })
- .catch(() => {
- if (isMounted) {
- setNavigationError('Navigation is temporarily unavailable.');
- }
- });
- return () => {
- isMounted = false;
- };
- }, [session]);
- /**
- * Navigates through the route controller and closes a mobile drawer.
- *
- * @param {string} url Authorized menu URL.
- * @returns {void}
- * @sideEffects Changes the application route through the parent controller.
- */
- function handleNavigation(url) {
- setMobileOpen(false);
- onNavigate(toRoute(url));
- }
- 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',
- }}
- >
- PHP
- </Box>
- <Box>
- <Typography sx={{ color: 'navigation.contrastText', fontSize: '0.9375rem', fontWeight: 700 }}>
- PHPEngine
- </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 => (
- <NavigationItem
- key={item.name}
- item={item}
- selected={toRoute(item.url) === location.pathname}
- onClick={() => handleNavigation(item.url)}
- />
- ))}
- {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>
- ))}
- {navigationError && (
- <Typography sx={{ color: '#fca5a5', px: 1.5, py: 2, fontSize: '0.75rem' }}>
- {navigationError}
- </Typography>
- )}
- </List>
- </Box>
- );
- return (
- <Box className="flex min-h-screen bg-slate-100">
- <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)' }}>
- {isMobile && (
- <IconButton color="inherit" aria-label="Open navigation" onClick={() => setMobileOpen(true)}>
- <MenuIcon />
- </IconButton>
- )}
- <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 ? 'PHPEngine' : activeItem?.name ?? 'PHPEngine'}
- </Typography>
- </Box>
- <NotificationMenu onNavigate={onNavigate} />
- <Tooltip title="Sign out">
- <Button color="inherit" startIcon={<LogoutIcon />} onClick={onLogout}>
- <span className="hidden sm:inline">Sign out</span>
- </Button>
- </Tooltip>
- </Toolbar>
- </AppBar>
- {isMobile ? (
- <Drawer open={mobileOpen} onClose={() => setMobileOpen(false)} PaperProps={{ sx: { width: DRAWER_WIDTH } }}>
- {drawer}
- </Drawer>
- ) : (
- <Box component="aside" sx={{ display: { xs: 'none', md: 'block' }, flexShrink: 0, width: DRAWER_WIDTH }}>
- <Drawer variant="permanent" PaperProps={{ sx: { width: DRAWER_WIDTH } }}>
- {drawer}
- </Drawer>
- </Box>
- )}
- <Box component="main" className="min-w-0 flex-1 px-4 pb-8 pt-24 sm:px-8">
- {children}
- </Box>
- </Box>
- );
- }
- export default AppShell;
|