AppShell.jsx 8.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309
  1. import {
  2. AppBar,
  3. Box,
  4. Button,
  5. Divider,
  6. Drawer,
  7. IconButton,
  8. List,
  9. ListItemButton,
  10. ListItemIcon,
  11. ListItemText,
  12. Toolbar,
  13. Tooltip,
  14. Typography,
  15. useMediaQuery,
  16. useTheme,
  17. } from '@mui/material';
  18. import AdminPanelSettingsOutlinedIcon from '@mui/icons-material/AdminPanelSettingsOutlined';
  19. import CircleOutlinedIcon from '@mui/icons-material/CircleOutlined';
  20. import Inventory2OutlinedIcon from '@mui/icons-material/Inventory2Outlined';
  21. import DashboardOutlinedIcon from '@mui/icons-material/DashboardOutlined';
  22. import MenuIcon from '@mui/icons-material/Menu';
  23. import LogoutIcon from '@mui/icons-material/Logout';
  24. import PeopleOutlinedIcon from '@mui/icons-material/PeopleOutlined';
  25. import SecurityOutlinedIcon from '@mui/icons-material/SecurityOutlined';
  26. import TuneOutlinedIcon from '@mui/icons-material/TuneOutlined';
  27. import { useEffect, useState } from 'react';
  28. import { useLocation } from 'react-router-dom';
  29. import { getNavigation } from '../../api/client';
  30. import NotificationMenu from '../notifications/NotificationMenu';
  31. const DRAWER_WIDTH = 224;
  32. const DEFAULT_HEADER_LABEL = 'Workspace';
  33. const NAVIGATION_ICONS = {
  34. Demo: DashboardOutlinedIcon,
  35. Admin: AdminPanelSettingsOutlinedIcon,
  36. Roles: SecurityOutlinedIcon,
  37. 'PHPEngine Settings': TuneOutlinedIcon,
  38. Users: PeopleOutlinedIcon,
  39. 'Inventory Monitor': Inventory2OutlinedIcon,
  40. };
  41. /**
  42. * Converts a menu URL into an internal React route.
  43. *
  44. * @param {string} url Legacy menu URL.
  45. * @returns {string} Route path.
  46. * @sideEffects None.
  47. */
  48. function toRoute(url) {
  49. return url.startsWith('/') ? url : `/${url}`;
  50. }
  51. /**
  52. * Splits the flat, legacy menu payload into root links and populated groups.
  53. *
  54. * @param {Array<Record<string, unknown>>} navigation Authorized navigation entries.
  55. * @returns {{topLevelItems: Array<Record<string, unknown>>, groups: Array<Record<string, unknown>>}} Display-ready navigation structure.
  56. * @sideEffects None.
  57. */
  58. function getNavigationStructure(navigation) {
  59. const groups = navigation
  60. .filter(item => item.mainMenu && !item.url)
  61. .map(group => ({
  62. ...group,
  63. items: navigation.filter(item => item.mainMenu === group.name && item.url),
  64. }))
  65. .filter(group => group.items.length > 0);
  66. return {
  67. topLevelItems: navigation.filter(item => !item.mainMenu && item.url),
  68. groups,
  69. };
  70. }
  71. /**
  72. * Renders one compact navigation entry with its selected and disabled states.
  73. *
  74. * @param {{item: Record<string, unknown>, selected: boolean, onClick: () => void}} props Navigation entry properties.
  75. * @returns {JSX.Element} Navigation button.
  76. * @sideEffects None.
  77. */
  78. function NavigationItem({ item, selected, onClick }) {
  79. const Icon = NAVIGATION_ICONS[item.name] ?? CircleOutlinedIcon;
  80. return (
  81. <ListItemButton
  82. selected={selected}
  83. disabled={item.isDisabled}
  84. onClick={onClick}
  85. sx={{
  86. minHeight: 42,
  87. mb: 0.5,
  88. borderLeft: '3px solid',
  89. borderColor: selected ? 'primary.main' : 'transparent',
  90. borderRadius: 1,
  91. color: selected ? 'navigation.contrastText' : 'navigation.muted',
  92. pl: 1.5,
  93. '&:hover': {
  94. backgroundColor: 'navigation.hover',
  95. color: 'navigation.contrastText',
  96. },
  97. '&.Mui-selected': {
  98. backgroundColor: 'navigation.active',
  99. },
  100. '&.Mui-selected:hover': {
  101. backgroundColor: 'navigation.active',
  102. },
  103. '&.Mui-focusVisible': {
  104. outline: '2px solid',
  105. outlineColor: 'primary.light',
  106. outlineOffset: -2,
  107. },
  108. }}
  109. >
  110. <ListItemIcon sx={{ minWidth: 34, color: 'inherit' }}>
  111. <Icon fontSize="small" />
  112. </ListItemIcon>
  113. <ListItemText
  114. primary={item.name}
  115. primaryTypographyProps={{ fontSize: '0.875rem', fontWeight: selected ? 700 : 500 }}
  116. />
  117. </ListItemButton>
  118. );
  119. }
  120. /**
  121. * Renders the authenticated responsive application shell.
  122. *
  123. * @param {{session: Record<string, unknown>, onNavigate: (path: string) => void, onLogout: () => void, children: import('react').ReactNode}} props Shell properties.
  124. * @returns {JSX.Element} Application shell.
  125. * @sideEffects Loads permission-filtered navigation and renders authenticated controls.
  126. */
  127. function AppShell({ session, onNavigate, onLogout, children }) {
  128. const theme = useTheme();
  129. const location = useLocation();
  130. const isMobile = useMediaQuery(theme.breakpoints.down('md'));
  131. const [mobileOpen, setMobileOpen] = useState(false);
  132. const [navigation, setNavigation] = useState([]);
  133. const [navigationError, setNavigationError] = useState('');
  134. const { topLevelItems, groups } = getNavigationStructure(navigation);
  135. const activeItem = navigation.find(item => item.url && toRoute(item.url) === location.pathname);
  136. const activeGroup = groups.find(group =>
  137. group.items.some(item => toRoute(item.url) === location.pathname),
  138. );
  139. const headerLabel = activeGroup?.name ?? DEFAULT_HEADER_LABEL;
  140. useEffect(() => {
  141. let isMounted = true;
  142. getNavigation()
  143. .then(items => {
  144. if (isMounted) {
  145. setNavigation(items);
  146. }
  147. })
  148. .catch(() => {
  149. if (isMounted) {
  150. setNavigationError('Navigation is temporarily unavailable.');
  151. }
  152. });
  153. return () => {
  154. isMounted = false;
  155. };
  156. }, [session]);
  157. /**
  158. * Navigates through the route controller and closes a mobile drawer.
  159. *
  160. * @param {string} url Authorized menu URL.
  161. * @returns {void}
  162. * @sideEffects Changes the application route through the parent controller.
  163. */
  164. function handleNavigation(url) {
  165. setMobileOpen(false);
  166. onNavigate(toRoute(url));
  167. }
  168. const drawer = (
  169. <Box
  170. role="presentation"
  171. sx={{
  172. display: 'flex',
  173. minHeight: '100%',
  174. flexDirection: 'column',
  175. backgroundColor: 'navigation.main',
  176. }}
  177. >
  178. <Box sx={{ display: 'flex', alignItems: 'center', gap: 1.25, px: 2, py: 2.25 }}>
  179. <Box
  180. aria-hidden="true"
  181. sx={{
  182. display: 'grid',
  183. height: 32,
  184. width: 32,
  185. placeItems: 'center',
  186. borderRadius: 1,
  187. backgroundColor: 'primary.main',
  188. color: 'primary.contrastText',
  189. fontSize: '0.75rem',
  190. fontWeight: 800,
  191. letterSpacing: '-0.06em',
  192. }}
  193. >
  194. PHP
  195. </Box>
  196. <Box>
  197. <Typography sx={{ color: 'navigation.contrastText', fontSize: '0.9375rem', fontWeight: 700 }}>
  198. PHPEngine
  199. </Typography>
  200. <Typography variant="overline" sx={{ color: 'navigation.muted', display: 'block', lineHeight: 1.3 }}>
  201. Console
  202. </Typography>
  203. </Box>
  204. </Box>
  205. <Divider sx={{ borderColor: 'navigation.divider' }} />
  206. <List component="nav" aria-label="Application navigation" disablePadding sx={{ px: 1.25, py: 1.5 }}>
  207. {topLevelItems.map(item => (
  208. <NavigationItem
  209. key={item.name}
  210. item={item}
  211. selected={toRoute(item.url) === location.pathname}
  212. onClick={() => handleNavigation(item.url)}
  213. />
  214. ))}
  215. {groups.map(group => (
  216. <Box key={group.name} sx={{ mt: 2.25 }}>
  217. <Typography
  218. variant="overline"
  219. sx={{ color: 'navigation.muted', display: 'block', px: 1.5, pb: 0.75 }}
  220. >
  221. {group.name}
  222. </Typography>
  223. {group.items.map(item => (
  224. <NavigationItem
  225. key={item.name}
  226. item={item}
  227. selected={toRoute(item.url) === location.pathname}
  228. onClick={() => handleNavigation(item.url)}
  229. />
  230. ))}
  231. </Box>
  232. ))}
  233. {navigationError && (
  234. <Typography sx={{ color: '#fca5a5', px: 1.5, py: 2, fontSize: '0.75rem' }}>
  235. {navigationError}
  236. </Typography>
  237. )}
  238. </List>
  239. </Box>
  240. );
  241. return (
  242. <Box className="flex min-h-screen bg-slate-100">
  243. <AppBar
  244. position="fixed"
  245. color="inherit"
  246. elevation={0}
  247. className="border-b border-slate-200"
  248. sx={{
  249. ml: { md: `${DRAWER_WIDTH}px` },
  250. width: { md: `calc(100% - ${DRAWER_WIDTH}px)` },
  251. }}
  252. >
  253. <Toolbar className="gap-2" sx={{ pt: 'env(safe-area-inset-top, 0px)' }}>
  254. {isMobile && (
  255. <IconButton color="inherit" aria-label="Open navigation" onClick={() => setMobileOpen(true)}>
  256. <MenuIcon />
  257. </IconButton>
  258. )}
  259. <Box sx={{ flex: 1, minWidth: 0 }}>
  260. <Typography
  261. variant="overline"
  262. sx={{ color: 'text.secondary', display: { xs: 'none', md: 'block' }, lineHeight: 1.3 }}
  263. >
  264. {headerLabel}
  265. </Typography>
  266. <Typography variant="h6" component="div" noWrap>
  267. {isMobile ? 'PHPEngine' : activeItem?.name ?? 'PHPEngine'}
  268. </Typography>
  269. </Box>
  270. <NotificationMenu onNavigate={onNavigate} />
  271. <Tooltip title="Sign out">
  272. <Button color="inherit" startIcon={<LogoutIcon />} onClick={onLogout}>
  273. <span className="hidden sm:inline">Sign out</span>
  274. </Button>
  275. </Tooltip>
  276. </Toolbar>
  277. </AppBar>
  278. {isMobile ? (
  279. <Drawer open={mobileOpen} onClose={() => setMobileOpen(false)} PaperProps={{ sx: { width: DRAWER_WIDTH } }}>
  280. {drawer}
  281. </Drawer>
  282. ) : (
  283. <Box component="aside" sx={{ display: { xs: 'none', md: 'block' }, flexShrink: 0, width: DRAWER_WIDTH }}>
  284. <Drawer variant="permanent" PaperProps={{ sx: { width: DRAWER_WIDTH } }}>
  285. {drawer}
  286. </Drawer>
  287. </Box>
  288. )}
  289. <Box component="main" className="min-w-0 flex-1 px-4 pb-8 pt-24 sm:px-8">
  290. {children}
  291. </Box>
  292. </Box>
  293. );
  294. }
  295. export default AppShell;