@@ -1,204 +1,158 @@
|
||||
import { useState } from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Drawer,
|
||||
Box,
|
||||
List,
|
||||
ListItemButton,
|
||||
ListItemIcon,
|
||||
ListItemText,
|
||||
Typography,
|
||||
Collapse,
|
||||
Tooltip,
|
||||
Toolbar,
|
||||
alpha
|
||||
} from '@mui/material';
|
||||
import ExpandLess from '@mui/icons-material/ExpandLess';
|
||||
import ExpandMore from '@mui/icons-material/ExpandMore';
|
||||
import FiberManualRecordIcon from '@mui/icons-material/FiberManualRecord';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { SideNav, SideNavSection, SideNavItem } from '@astryxdesign/core/SideNav';
|
||||
import { Text } from '@astryxdesign/core/Text';
|
||||
|
||||
import navItems from '@/menu/navItems';
|
||||
import Logo from '@/components/Logo';
|
||||
|
||||
export const DRAWER_WIDTH = 264;
|
||||
export const MINI_WIDTH = 78;
|
||||
// ==============================|| DOORMILE - SIDE NAV ||============================== //
|
||||
// Thin wrapper around Astryx's SideNav template: sections + items driven by navItems.
|
||||
// Branding lives in the TopNav heading, so this stays icon+label only (Astryx guidance:
|
||||
// avoid a SideNavHeading when TopNav already carries app identity).
|
||||
|
||||
const RED = '#C01227';
|
||||
|
||||
function NavLeaf({ item, open, active, depth = 0, onClick }) {
|
||||
const Icon = item.icon;
|
||||
const button = (
|
||||
<ListItemButton
|
||||
selected={active}
|
||||
onClick={onClick}
|
||||
sx={{
|
||||
minHeight: 44,
|
||||
my: 0.25,
|
||||
mx: open ? 1 : 0.75,
|
||||
px: open ? 1.5 : 0,
|
||||
justifyContent: open ? 'flex-start' : 'center',
|
||||
borderRadius: 2,
|
||||
color: active ? RED : 'grey.700',
|
||||
'& .MuiListItemIcon-root': { color: active ? RED : 'grey.500' },
|
||||
'&:hover': { bgcolor: alpha(RED, 0.04), color: RED, '& .MuiListItemIcon-root': { color: RED } },
|
||||
'&.Mui-selected': {
|
||||
bgcolor: alpha(RED, 0.08),
|
||||
color: RED,
|
||||
'& .MuiListItemIcon-root': { color: RED },
|
||||
borderLeft: open ? `4px solid ${RED}` : 'none',
|
||||
'&:hover': { bgcolor: alpha(RED, 0.12) }
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ListItemIcon sx={{ minWidth: open ? 34 : 'auto', justifyContent: 'center' }}>
|
||||
{depth > 0 && !Icon ? <FiberManualRecordIcon sx={{ fontSize: 8 }} /> : Icon ? <Icon fontSize="small" /> : null}
|
||||
</ListItemIcon>
|
||||
{open && (
|
||||
<ListItemText
|
||||
primary={item.title}
|
||||
primaryTypographyProps={{ fontSize: '0.875rem', fontWeight: active ? 700 : 500 }}
|
||||
/>
|
||||
)}
|
||||
</ListItemButton>
|
||||
);
|
||||
|
||||
return open ? button : <Tooltip title={item.title} placement="right">{button}</Tooltip>;
|
||||
}
|
||||
|
||||
export default function Sidebar({ open, mobileOpen, onMobileClose, isMobile }) {
|
||||
export default function Sidebar() {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const isActive = (url) => url && location.pathname.startsWith(url);
|
||||
const expanded = open || isMobile;
|
||||
|
||||
const initialOpen = navItems
|
||||
.flatMap((g) => g.items)
|
||||
.filter((i) => i.children && i.children.some((c) => isActive(c.url)))
|
||||
.map((i) => i.id);
|
||||
const [collapse, setCollapse] = useState(initialOpen);
|
||||
|
||||
const go = (url) => {
|
||||
navigate(url);
|
||||
if (isMobile) onMobileClose();
|
||||
};
|
||||
|
||||
const content = (
|
||||
<Box sx={{ bgcolor: '#fff', height: '100%', color: 'grey.800', display: 'flex', flexDirection: 'column', borderRight: 1, borderColor: 'divider' }}>
|
||||
<Toolbar sx={{ px: expanded ? 2.5 : 0, justifyContent: expanded ? 'flex-start' : 'center', minHeight: 64 }}>
|
||||
<Logo compact={!expanded} />
|
||||
</Toolbar>
|
||||
<Box sx={{ overflowY: 'auto', overflowX: 'hidden', flexGrow: 1, pb: 2 }}>
|
||||
{navItems.map((grp) => (
|
||||
<Box key={grp.group} sx={{ mt: 1 }}>
|
||||
{expanded && (
|
||||
<Typography
|
||||
variant="overline"
|
||||
sx={{ px: 2.5, color: '#A06060', fontSize: '0.6875rem', letterSpacing: '0.08em', fontWeight: 700 }}
|
||||
>
|
||||
{grp.group}
|
||||
</Typography>
|
||||
)}
|
||||
<List disablePadding sx={{ mt: 0.5 }}>
|
||||
{grp.items.map((item) => {
|
||||
if (item.children) {
|
||||
const opened = collapse.includes(item.id);
|
||||
const childActive = item.children.some((c) => isActive(c.url));
|
||||
const Icon = item.icon;
|
||||
const head = (
|
||||
<ListItemButton
|
||||
onClick={() =>
|
||||
expanded
|
||||
? setCollapse((p) => (p.includes(item.id) ? p.filter((x) => x !== item.id) : [...p, item.id]))
|
||||
: go(item.children[0].url)
|
||||
}
|
||||
sx={{
|
||||
minHeight: 44,
|
||||
my: 0.25,
|
||||
mx: expanded ? 1 : 0.75,
|
||||
px: expanded ? 1.5 : 0,
|
||||
justifyContent: expanded ? 'flex-start' : 'center',
|
||||
borderRadius: 2,
|
||||
color: childActive ? RED : 'grey.700',
|
||||
bgcolor: childActive && !opened ? alpha(RED, 0.08) : 'transparent',
|
||||
'& .MuiListItemIcon-root': { color: childActive ? RED : 'grey.500' },
|
||||
'&:hover': { bgcolor: alpha(RED, 0.04), color: RED, '& .MuiListItemIcon-root': { color: RED } }
|
||||
}}
|
||||
>
|
||||
<ListItemIcon sx={{ minWidth: expanded ? 34 : 'auto', justifyContent: 'center' }}>
|
||||
<Icon fontSize="small" />
|
||||
</ListItemIcon>
|
||||
{expanded && (
|
||||
<>
|
||||
<ListItemText primary={item.title} primaryTypographyProps={{ fontSize: '0.875rem', fontWeight: 500 }} />
|
||||
{opened ? <ExpandLess fontSize="small" /> : <ExpandMore fontSize="small" />}
|
||||
</>
|
||||
)}
|
||||
</ListItemButton>
|
||||
);
|
||||
return (
|
||||
<Box key={item.id}>
|
||||
{expanded ? head : <Tooltip title={item.title} placement="right">{head}</Tooltip>}
|
||||
{expanded && (
|
||||
<Collapse in={opened} timeout="auto" unmountOnExit>
|
||||
<Box sx={{ pl: 1.5 }}>
|
||||
{item.children.map((c) => (
|
||||
<NavLeaf key={c.id} item={c} open depth={1} active={isActive(c.url)} onClick={() => go(c.url)} />
|
||||
))}
|
||||
</Box>
|
||||
</Collapse>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<NavLeaf key={item.id} item={item} open={expanded} active={isActive(item.url)} onClick={() => go(item.url)} />
|
||||
);
|
||||
})}
|
||||
</List>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
{expanded && (
|
||||
<Box sx={{ p: 2, borderTop: '1px solid', borderColor: 'divider' }}>
|
||||
<Typography variant="caption" sx={{ color: 'text.secondary' }}>
|
||||
Doormile CRM v1.0
|
||||
</Typography>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<Drawer
|
||||
variant="temporary"
|
||||
open={mobileOpen}
|
||||
onClose={onMobileClose}
|
||||
ModalProps={{ keepMounted: true }}
|
||||
sx={{ '& .MuiDrawer-paper': { width: DRAWER_WIDTH, border: 'none' } }}
|
||||
>
|
||||
{content}
|
||||
</Drawer>
|
||||
);
|
||||
}
|
||||
const isActive = (url) => !!url && location.pathname.startsWith(url);
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
variant="permanent"
|
||||
sx={{
|
||||
width: open ? DRAWER_WIDTH : MINI_WIDTH,
|
||||
flexShrink: 0,
|
||||
whiteSpace: 'nowrap',
|
||||
'& .MuiDrawer-paper': {
|
||||
width: open ? DRAWER_WIDTH : MINI_WIDTH,
|
||||
border: 'none',
|
||||
overflowX: 'hidden',
|
||||
transition: (t) => t.transitions.create('width', { duration: t.transitions.duration.standard })
|
||||
<>
|
||||
<SideNav
|
||||
className="doormile-side-nav"
|
||||
collapsible={{ defaultIsCollapsed: true, buttonLabel: 'Collapse navigation' }}
|
||||
style={{
|
||||
// White + a right border/shadow matches the TopNav's chrome so the
|
||||
// top and side nav read as one unified surface instead of two
|
||||
// mismatched panels (TopNav is white with a bottom border already).
|
||||
backgroundColor: '#ffffff',
|
||||
borderRight: '1px solid rgba(5, 54, 89, 0.08)',
|
||||
boxShadow: '1px 0 3px rgba(15, 23, 42, 0.03)',
|
||||
paddingBlock: '12px',
|
||||
paddingInline: '8px',
|
||||
boxSizing: 'border-box',
|
||||
// Astryx's collapsed-rail width reads var(--spacing-12) (48px default),
|
||||
// which feels cramped for our icons. Overriding the token on this
|
||||
// element only widens the collapsed rail without touching the same
|
||||
// token's unrelated uses elsewhere (e.g. AppShell's mobile top bar).
|
||||
'--spacing-12': '72px'
|
||||
}}
|
||||
>
|
||||
{navItems.map((grp) => (
|
||||
<SideNavSection key={grp.group} title={grp.group}>
|
||||
{grp.items.map((item) => (
|
||||
<SideNavItem
|
||||
key={item.id}
|
||||
label={item.title}
|
||||
icon={item.icon}
|
||||
href={item.url}
|
||||
isSelected={isActive(item.url)}
|
||||
/>
|
||||
))}
|
||||
</SideNavSection>
|
||||
))}
|
||||
</SideNav>
|
||||
<style>{`
|
||||
/* Astryx packs items 2px apart by default, which reads as one
|
||||
merged block on hover instead of distinct rows. The items
|
||||
wrapper has no stable class of its own, but it's always the
|
||||
section's second/last child div, right after the header. */
|
||||
.doormile-side-nav .astryx-side-nav-section > div:last-child {
|
||||
gap: 6px !important;
|
||||
}
|
||||
}}
|
||||
open={open}
|
||||
>
|
||||
{content}
|
||||
</Drawer>
|
||||
|
||||
/* Collapsed nav items are the only ones Astryx renders with an
|
||||
aria-label on the item element itself (expanded rows show the
|
||||
label as visible text instead), so it doubles as a stable hook
|
||||
for "collapsed only" styling without touching the expanded rail. */
|
||||
.doormile-side-nav .astryx-side-nav-item[aria-label] {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
margin-inline: auto;
|
||||
border-radius: 10px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
.doormile-side-nav .astryx-side-nav-item[aria-label] .astryx-icon {
|
||||
width: 18px !important;
|
||||
height: 18px !important;
|
||||
}
|
||||
.doormile-side-nav .astryx-side-nav-item[aria-label]:hover {
|
||||
background-color: rgba(192, 18, 39, 0.08) !important;
|
||||
}
|
||||
.doormile-side-nav .astryx-side-nav-item[aria-label]:focus-visible {
|
||||
outline: 2px solid rgba(192, 18, 39, 0.4);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.doormile-side-nav .astryx-side-nav-item[aria-label][data-selected='selected'] {
|
||||
background-color: rgba(192, 18, 39, 0.12) !important;
|
||||
}
|
||||
.doormile-side-nav .astryx-side-nav-item[aria-label][data-selected='selected'] .astryx-icon {
|
||||
color: #C01227 !important;
|
||||
}
|
||||
|
||||
/* Expanded rows: rounded hover/selected states with a left accent
|
||||
bar on the active item, instead of the default flat highlight.
|
||||
The accent bar is always present (via ::before) but only faded
|
||||
in on selection, so clicking a link fades it in smoothly instead
|
||||
of the bar snapping into place flush against the row edges.
|
||||
Row sizing mirrors doormile_console's NavItem pattern (py: 1,
|
||||
i.e. 8px top/bottom padding on the button itself) instead of a
|
||||
fixed-height row: the highlight is painted directly on the item's
|
||||
own (now-padded) background, so it naturally gets breathing room
|
||||
above and below rather than filling a cramped fixed-height box
|
||||
edge to edge. */
|
||||
.doormile-side-nav .astryx-side-nav-item:not([aria-label]) {
|
||||
position: relative;
|
||||
margin-inline: 2px;
|
||||
height: auto;
|
||||
padding-block: 8px !important;
|
||||
transition: background-color 0.15s ease, transform 0.15s ease;
|
||||
}
|
||||
.doormile-side-nav .astryx-side-nav-item:not([aria-label])::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: -2px;
|
||||
top: 8px;
|
||||
bottom: 8px;
|
||||
width: 3px;
|
||||
border-radius: 3px;
|
||||
background-color: #C01227;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease;
|
||||
}
|
||||
.doormile-side-nav .astryx-side-nav-item:not([aria-label]):hover {
|
||||
background-color: rgba(192, 18, 39, 0.05) !important;
|
||||
transform: translateX(2px);
|
||||
}
|
||||
.doormile-side-nav .astryx-side-nav-item:not([aria-label]):focus-visible {
|
||||
outline: 2px solid rgba(192, 18, 39, 0.4);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.doormile-side-nav .astryx-side-nav-item:not([aria-label])[data-selected='selected'] {
|
||||
background-color: rgba(192, 18, 39, 0.08) !important;
|
||||
}
|
||||
.doormile-side-nav .astryx-side-nav-item:not([aria-label])[data-selected='selected']::before {
|
||||
opacity: 1;
|
||||
}
|
||||
.doormile-side-nav .astryx-side-nav-item:not([aria-label])[data-selected='selected'] .astryx-icon {
|
||||
color: #C01227 !important;
|
||||
}
|
||||
|
||||
/* Collapse/expand toggle (the "<" / ">" chevron button). It sits
|
||||
right on the sidebar's edge, so the default square ghost-button
|
||||
hover clips against that border and looks broken. Give it a
|
||||
clean circular hover detached from the edge instead. */
|
||||
.doormile-side-nav button[aria-label*="sidebar"],
|
||||
.doormile-side-nav button[aria-label*="navigation"] {
|
||||
border-radius: 50% !important;
|
||||
transition: background-color 0.15s ease !important;
|
||||
transform: translateX(-6px) !important;
|
||||
}
|
||||
.doormile-side-nav button[aria-label*="sidebar"]:hover,
|
||||
.doormile-side-nav button[aria-label*="navigation"]:hover {
|
||||
background-color: rgba(192, 18, 39, 0.08) !important;
|
||||
}
|
||||
`}</style>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user