feat: add desktop sidebar collapse to icon-only mode (refs #176)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Eric Gullickson
2026-02-13 20:07:00 -06:00
parent f987e94fed
commit bc72f09557
2 changed files with 113 additions and 65 deletions

View File

@@ -6,7 +6,7 @@ import React from 'react';
import { useAuth0 } from '@auth0/auth0-react'; import { useAuth0 } from '@auth0/auth0-react';
import { Link, useLocation } from 'react-router-dom'; import { Link, useLocation } from 'react-router-dom';
import { useLogout } from '../core/auth/useLogout'; import { useLogout } from '../core/auth/useLogout';
import { Container, Paper, Typography, Box, IconButton, Avatar } from '@mui/material'; import { Container, Paper, Typography, Box, IconButton, Avatar, Tooltip } from '@mui/material';
import HomeRoundedIcon from '@mui/icons-material/HomeRounded'; import HomeRoundedIcon from '@mui/icons-material/HomeRounded';
import DirectionsCarRoundedIcon from '@mui/icons-material/DirectionsCarRounded'; import DirectionsCarRoundedIcon from '@mui/icons-material/DirectionsCarRounded';
import LocalGasStationRoundedIcon from '@mui/icons-material/LocalGasStationRounded'; import LocalGasStationRoundedIcon from '@mui/icons-material/LocalGasStationRounded';
@@ -15,7 +15,8 @@ import PlaceRoundedIcon from '@mui/icons-material/PlaceRounded';
import SettingsRoundedIcon from '@mui/icons-material/SettingsRounded'; import SettingsRoundedIcon from '@mui/icons-material/SettingsRounded';
import DescriptionRoundedIcon from '@mui/icons-material/DescriptionRounded'; import DescriptionRoundedIcon from '@mui/icons-material/DescriptionRounded';
import MenuIcon from '@mui/icons-material/Menu'; import MenuIcon from '@mui/icons-material/Menu';
import CloseIcon from '@mui/icons-material/Close'; import ChevronLeftRoundedIcon from '@mui/icons-material/ChevronLeftRounded';
import ChevronRightRoundedIcon from '@mui/icons-material/ChevronRightRounded';
import { useAppStore } from '../core/store'; import { useAppStore } from '../core/store';
import { Button } from '../shared-minimal/components/Button'; import { Button } from '../shared-minimal/components/Button';
import { NotificationBell } from '../features/notifications'; import { NotificationBell } from '../features/notifications';
@@ -29,7 +30,7 @@ interface LayoutProps {
export const Layout: React.FC<LayoutProps> = ({ children, mobileMode = false }) => { export const Layout: React.FC<LayoutProps> = ({ children, mobileMode = false }) => {
const { user } = useAuth0(); const { user } = useAuth0();
const { logout } = useLogout(); const { logout } = useLogout();
const { sidebarOpen, toggleSidebar, setSidebarOpen } = useAppStore(); const { sidebarOpen, sidebarCollapsed, toggleSidebar, setSidebarOpen, toggleSidebarCollapse } = useAppStore();
const location = useLocation(); const location = useLocation();
// Sync theme preference with backend // Sync theme preference with backend
@@ -52,6 +53,8 @@ export const Layout: React.FC<LayoutProps> = ({ children, mobileMode = false })
{ name: 'Settings', href: '/garage/settings', icon: <SettingsRoundedIcon sx={{ fontSize: 20 }} /> }, { name: 'Settings', href: '/garage/settings', icon: <SettingsRoundedIcon sx={{ fontSize: 20 }} /> },
]; ];
const sidebarWidth = sidebarCollapsed ? 64 : 256;
// Mobile layout // Mobile layout
if (mobileMode) { if (mobileMode) {
return ( return (
@@ -107,61 +110,65 @@ export const Layout: React.FC<LayoutProps> = ({ children, mobileMode = false })
top: 0, top: 0,
left: 0, left: 0,
height: '100vh', height: '100vh',
width: 256, width: sidebarWidth,
zIndex: 1000, zIndex: 1000,
borderRadius: 0, borderRadius: 0,
borderRight: 1, borderRight: 1,
borderColor: 'divider', borderColor: 'divider',
transform: sidebarOpen ? 'translateX(0)' : 'translateX(-100%)', transform: sidebarOpen ? 'translateX(0)' : 'translateX(-100%)',
transition: 'transform 0.2s ease-in-out', transition: 'transform 0.2s ease-in-out, width 0.2s ease-in-out',
display: 'flex', display: 'flex',
flexDirection: 'column' flexDirection: 'column',
overflow: 'hidden',
}} }}
> >
<Box <Box
sx={{ sx={{
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'space-between', justifyContent: sidebarCollapsed ? 'center' : 'space-between',
height: 64, height: 64,
px: 2, px: sidebarCollapsed ? 1 : 2,
borderBottom: 1, borderBottom: 1,
borderColor: 'divider', borderColor: 'divider',
gap: 1 gap: 1
}} }}
> >
<Box {!sidebarCollapsed && (
sx={(theme) => ({ <Box
backgroundColor: 'primary.main', sx={(theme) => ({
...theme.applyStyles('dark', { backgroundColor: 'primary.main',
backgroundColor: 'transparent', ...theme.applyStyles('dark', {
}), backgroundColor: 'transparent',
borderRadius: 0.5, }),
px: 1, borderRadius: 0.5,
py: 0.5, px: 1,
display: 'inline-flex', py: 0.5,
alignItems: 'center' display: 'inline-flex',
})} alignItems: 'center',
> overflow: 'hidden',
<img })}
src="/images/logos/motovaultpro-title-slogan.png" >
alt="MotoVaultPro" <img
style={{ height: 24, width: 'auto', maxWidth: 180 }} src="/images/logos/motovaultpro-title-slogan.png"
/> alt="MotoVaultPro"
</Box> style={{ height: 24, width: 'auto', maxWidth: 180 }}
/>
</Box>
)}
<IconButton <IconButton
onClick={toggleSidebar} onClick={toggleSidebarCollapse}
size="small" size="small"
sx={{ color: 'text.secondary', flexShrink: 0 }} sx={{ color: 'text.secondary', flexShrink: 0 }}
> >
<CloseIcon /> {sidebarCollapsed ? <ChevronRightRoundedIcon /> : <ChevronLeftRoundedIcon />}
</IconButton> </IconButton>
</Box> </Box>
<Box sx={{ mt: 3, px: 2, flex: 1 }}> <Box sx={{ mt: 3, px: sidebarCollapsed ? 1 : 2, flex: 1 }}>
{navigation.map((item) => { {navigation.map((item) => {
const isActive = location.pathname.startsWith(item.href); const isActive = location.pathname.startsWith(item.href);
return ( const navItem = (
<Link <Link
key={item.name} key={item.name}
to={item.href} to={item.href}
@@ -171,7 +178,8 @@ export const Layout: React.FC<LayoutProps> = ({ children, mobileMode = false })
sx={{ sx={{
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
px: 2, justifyContent: sidebarCollapsed ? 'center' : 'flex-start',
px: sidebarCollapsed ? 1 : 2,
py: 1.5, py: 1.5,
mb: 0.5, mb: 0.5,
borderRadius: 2, borderRadius: 2,
@@ -189,52 +197,82 @@ export const Layout: React.FC<LayoutProps> = ({ children, mobileMode = false })
} }
}} }}
> >
<Box sx={{ mr: 2, display: 'flex', alignItems: 'center' }}> <Box sx={{ mr: sidebarCollapsed ? 0 : 2, display: 'flex', alignItems: 'center' }}>
{item.icon} {item.icon}
</Box> </Box>
<Typography variant="body2" sx={{ fontWeight: 500 }}> {!sidebarCollapsed && (
{item.name} <Typography variant="body2" sx={{ fontWeight: 500, whiteSpace: 'nowrap' }}>
</Typography> {item.name}
</Typography>
)}
</Box> </Box>
</Link> </Link>
); );
return sidebarCollapsed ? (
<Tooltip key={item.name} title={item.name} placement="right" arrow>
{navItem}
</Tooltip>
) : (
navItem
);
})} })}
</Box> </Box>
<Box sx={{ p: 2, borderTop: 1, borderColor: 'divider', mt: 'auto' }}> <Box sx={{ p: sidebarCollapsed ? 1 : 2, borderTop: 1, borderColor: 'divider', mt: 'auto' }}>
<Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}> {sidebarCollapsed ? (
<Avatar <Tooltip title={user?.name || user?.email || 'User'} placement="right" arrow>
sx={{ <Avatar
width: 32, sx={{
height: 32, width: 32,
bgcolor: 'primary.main', height: 32,
fontSize: '0.875rem', bgcolor: 'primary.main',
fontWeight: 600 fontSize: '0.875rem',
}} fontWeight: 600,
> mx: 'auto',
{user?.name?.charAt(0) || user?.email?.charAt(0)} cursor: 'pointer',
</Avatar> }}
<Box sx={{ ml: 1.5, flex: 1, minWidth: 0 }}> onClick={() => logout()}
<Typography variant="body2" sx={{ fontWeight: 500 }} noWrap> >
{user?.name || user?.email} {user?.name?.charAt(0) || user?.email?.charAt(0)}
</Typography> </Avatar>
</Box> </Tooltip>
</Box> ) : (
<Button <>
variant="secondary" <Box sx={{ display: 'flex', alignItems: 'center', mb: 2 }}>
size="sm" <Avatar
className="w-full" sx={{
onClick={() => logout()} width: 32,
> height: 32,
Sign Out bgcolor: 'primary.main',
</Button> fontSize: '0.875rem',
fontWeight: 600
}}
>
{user?.name?.charAt(0) || user?.email?.charAt(0)}
</Avatar>
<Box sx={{ ml: 1.5, flex: 1, minWidth: 0 }}>
<Typography variant="body2" sx={{ fontWeight: 500 }} noWrap>
{user?.name || user?.email}
</Typography>
</Box>
</Box>
<Button
variant="secondary"
size="sm"
className="w-full"
onClick={() => logout()}
>
Sign Out
</Button>
</>
)}
</Box> </Box>
</Paper> </Paper>
{/* Main content */} {/* Main content */}
<Box <Box
sx={{ sx={{
ml: sidebarOpen ? '256px' : '0', ml: sidebarOpen ? `${sidebarWidth}px` : '0',
transition: 'margin-left 0.2s ease-in-out', transition: 'margin-left 0.2s ease-in-out',
}} }}
> >
@@ -255,7 +293,7 @@ export const Layout: React.FC<LayoutProps> = ({ children, mobileMode = false })
px: 3 px: 3
}}> }}>
<IconButton <IconButton
onClick={toggleSidebar} onClick={toggleSidebarCollapse}
sx={{ color: 'text.secondary' }} sx={{ color: 'text.secondary' }}
> >
<MenuIcon /> <MenuIcon />

View File

@@ -4,21 +4,31 @@ import { Vehicle } from '../../features/vehicles/types/vehicles.types';
interface AppState { interface AppState {
// UI state // UI state
sidebarOpen: boolean; sidebarOpen: boolean;
sidebarCollapsed: boolean;
selectedVehicle: Vehicle | null; selectedVehicle: Vehicle | null;
// Actions // Actions
toggleSidebar: () => void; toggleSidebar: () => void;
setSidebarOpen: (open: boolean) => void; setSidebarOpen: (open: boolean) => void;
toggleSidebarCollapse: () => void;
setSelectedVehicle: (vehicle: Vehicle | null) => void; setSelectedVehicle: (vehicle: Vehicle | null) => void;
} }
const savedCollapsed = localStorage.getItem('sidebarCollapsed') === 'true';
export const useAppStore = create<AppState>((set) => ({ export const useAppStore = create<AppState>((set) => ({
// Initial state // Initial state
sidebarOpen: false, sidebarOpen: false,
sidebarCollapsed: savedCollapsed,
selectedVehicle: null, selectedVehicle: null,
// Actions // Actions
toggleSidebar: () => set((state) => ({ sidebarOpen: !state.sidebarOpen })), toggleSidebar: () => set((state) => ({ sidebarOpen: !state.sidebarOpen })),
setSidebarOpen: (open: boolean) => set({ sidebarOpen: open }), setSidebarOpen: (open: boolean) => set({ sidebarOpen: open }),
toggleSidebarCollapse: () => set((state) => {
const next = !state.sidebarCollapsed;
localStorage.setItem('sidebarCollapsed', String(next));
return { sidebarCollapsed: next };
}),
setSelectedVehicle: (vehicle: Vehicle | null) => set({ selectedVehicle: vehicle }), setSelectedVehicle: (vehicle: Vehicle | null) => set({ selectedVehicle: vehicle }),
})); }));