Browse Source

feat(ui): redesign admin navigation

softword 2 months ago
parent
commit
78e30fdbcd

File diff suppressed because it is too large
+ 0 - 0
assets/app/assets/index-5-6sgTwQ.js


File diff suppressed because it is too large
+ 0 - 0
assets/app/assets/index-B94u0hbw.css


File diff suppressed because it is too large
+ 0 - 0
assets/app/assets/index-BKCGXNjj.css


File diff suppressed because it is too large
+ 0 - 0
assets/app/assets/index-BpJqOKYq.js


+ 2 - 2
assets/app/index.html

@@ -5,8 +5,8 @@
     <meta name="viewport" content="width=device-width, initial-scale=1.0" />
     <meta name="viewport" content="width=device-width, initial-scale=1.0" />
     <link rel="icon" href="/phengine/assets/images/favicon.ico" />
     <link rel="icon" href="/phengine/assets/images/favicon.ico" />
     <title>OM-Link</title>
     <title>OM-Link</title>
-    <script type="module" crossorigin src="/phengine/assets/app/assets/index-5-6sgTwQ.js"></script>
-    <link rel="stylesheet" crossorigin href="/phengine/assets/app/assets/index-B94u0hbw.css">
+    <script type="module" crossorigin src="/phengine/assets/app/assets/index-BpJqOKYq.js"></script>
+    <link rel="stylesheet" crossorigin href="/phengine/assets/app/assets/index-BKCGXNjj.css">
   </head>
   </head>
   <body>
   <body>
     <div id="root"></div>
     <div id="root"></div>

+ 2 - 2
assets/app/manifest.json

@@ -1,11 +1,11 @@
 {
 {
   "index.html": {
   "index.html": {
-    "file": "assets/index-5-6sgTwQ.js",
+    "file": "assets/index-BpJqOKYq.js",
     "name": "index",
     "name": "index",
     "src": "index.html",
     "src": "index.html",
     "isEntry": true,
     "isEntry": true,
     "css": [
     "css": [
-      "assets/index-B94u0hbw.css"
+      "assets/index-BKCGXNjj.css"
     ]
     ]
   }
   }
 }
 }

+ 6 - 1
ui/app/src/features/admin/UsersPage.jsx

@@ -196,7 +196,12 @@ function UsersPage() {
 						<Typography variant="h3" component="h1">Users</Typography>
 						<Typography variant="h3" component="h1">Users</Typography>
 						<Typography color="text.secondary">Manage administrator accounts and application roles.</Typography>
 						<Typography color="text.secondary">Manage administrator accounts and application roles.</Typography>
 					</Stack>
 					</Stack>
-					<Button variant="contained" onClick={() => openUserDialog()} disabled={isLoading || isSaving}>
+					<Button
+						variant="contained"
+						onClick={() => openUserDialog()}
+						disabled={isLoading || isSaving}
+						sx={{ alignSelf: { xs: 'stretch', sm: 'center' }, minHeight: 40, px: 2 }}
+					>
 						Create user
 						Create user
 					</Button>
 					</Button>
 				</Stack>
 				</Stack>

+ 181 - 57
ui/app/src/features/shell/AppShell.jsx

@@ -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>

+ 26 - 2
ui/app/src/theme.js

@@ -3,10 +3,34 @@ import { createTheme } from '@mui/material/styles';
 const theme = createTheme({
 const theme = createTheme({
 	palette: {
 	palette: {
 		primary: {
 		primary: {
-			main: '#1d4ed8',
+			main: '#3b82f6',
 		},
 		},
 		background: {
 		background: {
-			default: '#f1f5f9',
+			default: '#f6f8fc',
+		},
+		text: {
+			primary: '#172033',
+			secondary: '#64748b',
+		},
+		navigation: {
+			main: '#14233a',
+			contrastText: '#f8fafc',
+			muted: '#a5b4c7',
+			hover: '#1d3150',
+			active: '#263e65',
+			divider: '#273a55',
+		},
+	},
+	shape: {
+		borderRadius: 8,
+	},
+	typography: {
+		fontFamily: 'Inter, ui-sans-serif, system-ui, sans-serif',
+		overline: {
+			fontFamily: 'ui-monospace, SFMono-Regular, Consolas, monospace',
+			fontSize: '0.625rem',
+			fontWeight: 700,
+			letterSpacing: '0.12em',
 		},
 		},
 	},
 	},
 });
 });

+ 9 - 0
vite.config.js

@@ -18,5 +18,14 @@ export default defineConfig({
 		emptyOutDir: true,
 		emptyOutDir: true,
 		manifest: 'manifest.json',
 		manifest: 'manifest.json',
 		sourcemap: false,
 		sourcemap: false,
+		rollupOptions: {
+			onwarn(warning, warn) {
+				if (warning.code === 'MODULE_LEVEL_DIRECTIVE' && warning.message.includes('"use client"')) {
+					return;
+				}
+
+				warn(warning);
+			},
+		},
 	},
 	},
 });
 });

Some files were not shown because too many files changed in this diff