Files
dashfront/src/components/AppShell.jsx
2026-10-28 00:08:45 +01:00

439 lines
15 KiB
JavaScript

import { PureComponent, lazy, Suspense } from 'react';
import Box from '@mui/material/Box';
import List from '@mui/material/List';
import ListItemButton from '@mui/material/ListItemButton';
import ListItemIcon from '@mui/material/ListItemIcon';
import ListItemText from '@mui/material/ListItemText';
import ButtonBase from '@mui/material/ButtonBase';
import Typography from '@mui/material/Typography';
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
import UserMenu from './UserMenu';
import HelpBadge from './HelpBadge';
import { NAV_ITEMS, SUB_NAV, resolveActiveNav, resolveActiveSub } from '../nav';
import { artikelQueryPath, buildSearchUrl } from './article/artikelQuery';
import { defaultCategoryPath } from './category/categoryRoute';
import companyAutomat from '../automats/companyAutomat';
import navigationAutomat from '../automats/navigationAutomat';
import { initPwa } from '../automats/pwaAutomat';
import PwaUpdateDialog from './PwaUpdateDialog';
import {
NAV_ITEM_ACTIVE_ACCENT,
NAV_SUB_ACTIVE,
NAV_SUB_ACTIVE_HOVER,
NAV_SURFACE,
} from '../colors';
import { ChatFab, ChatDrawer } from './chat';
import { PinnedAppletFabs, PinnedAppletDialog } from './applet';
// Ensure companyAutomat is retained as part of the 1st-level navigation shell
void companyAutomat;
initPwa();
const BreakpointOverlay = 0 && import.meta.env.DEV
? lazy(() => import('./BreakpointOverlay'))
: null;
const ChunkCacheMonitor = import.meta.env.DEV
? lazy(() => import('../devtools/ChunkCacheMonitor'))
: null;
const SIDEBAR_WIDTH = 144;
const SUB_NAV_WIDTH = 88;
class AppSidebar extends PureComponent {
constructor(props) {
super(props);
this.state = {
lastDeliveryPath: navigationAutomat.state.lastDeliveryPath,
lastArticleSku: navigationAutomat.state.lastArticleSku,
lastArticleSearchPath: navigationAutomat.state.lastArticleSearchPath,
lastSearchTerm: navigationAutomat.state.lastSearchTerm,
};
}
componentDidMount() {
this.unsub = navigationAutomat.subscribe(this, (state) => ({
lastDeliveryPath: state.lastDeliveryPath,
lastArticleSku: state.lastArticleSku,
lastArticleSearchPath: state.lastArticleSearchPath,
lastSearchTerm: state.lastSearchTerm,
}));
}
componentWillUnmount() {
this.unsub?.();
}
render() {
const { activeNav, navigate } = this.props;
const {
lastDeliveryPath,
lastArticleSku,
lastArticleSearchPath,
lastSearchTerm,
} = this.state;
return (
<Box
component="nav"
sx={(theme) => ({
width: { xs: '100%', lg: SIDEBAR_WIDTH },
flexShrink: 0,
display: 'flex',
flexDirection: { xs: 'row', lg: 'column' },
alignItems: { xs: 'center', lg: 'stretch' },
backgroundColor: theme.nav.backgroundColor || NAV_SURFACE,
background: theme.nav.background,
position: 'sticky',
top: 0,
zIndex: theme.zIndex.appBar || 1100,
alignSelf: 'flex-start',
height: { xs: 52, sm: 56, lg: 'var(--df-vh, 100vh)' },
px: { xs: 1, sm: 1.5, lg: 0 },
boxSizing: 'border-box',
boxShadow: { xs: '0 2px 8px rgba(0,0,0,0.18)', lg: 'none' },
})}
>
<List
disablePadding
sx={{
flexGrow: 1,
display: { xs: 'flex', lg: 'block' },
flexDirection: 'row',
alignItems: 'center',
height: { xs: '100%', lg: 'auto' },
overflowX: { xs: 'auto', lg: 'visible' },
scrollbarWidth: 'none',
'&::-webkit-scrollbar': { display: 'none' },
gap: { xs: 0.5, sm: 1, lg: 0 },
}}
>
{NAV_ITEMS.map(({ path, label, help, icon: Icon }) => {
const active = path === activeNav;
return (
<ListItemButton
key={path}
type="button"
aria-label={label}
title={label}
selected={active}
onMouseDown={(e) => e.preventDefault()}
onClick={() => {
if (path === '/artikel') {
if (lastArticleSku) {
navigate(`/artikel/${lastArticleSku}`);
} else if (lastArticleSearchPath) {
navigate(lastArticleSearchPath);
} else if (lastSearchTerm) {
navigate(buildSearchUrl(lastSearchTerm));
} else {
navigate(artikelQueryPath());
}
} else if (path === '/lieferung') {
if (lastDeliveryPath) {
navigate(lastDeliveryPath);
} else {
navigate(path);
}
} else {
navigate(path);
}
}}
sx={(theme) => ({
borderRadius: { xs: 1, lg: 0 },
width: { xs: 'auto', lg: '100%' },
height: { xs: '100%', lg: 'auto' },
px: { xs: active ? 1.25 : 1, sm: active ? 1.5 : 1.25, md: 1.5, lg: 1.25 },
py: { xs: 0.75, sm: 1, lg: 1.25 },
borderLeft: { xs: 'none', lg: '3px solid transparent' },
borderBottom: { xs: '3px solid transparent', lg: 'none' },
color: theme.nav.item,
whiteSpace: 'nowrap',
flexShrink: 0,
'& .MuiListItemIcon-root': {
color: 'inherit',
minWidth: { xs: 'auto', lg: 32 },
mr: active ? { xs: 0.75, lg: 0 } : { xs: 0, md: 0.75, lg: 0 },
justifyContent: { xs: 'center', lg: 'flex-start' },
},
'&:hover': {
bgcolor: theme.nav.itemHover,
color: theme.nav.itemHoverText,
},
'&.Mui-selected': {
bgcolor: theme.nav.itemActive,
color: theme.nav.itemActiveText,
fontWeight: 600,
borderLeftColor: { xs: 'transparent', lg: theme.nav.itemActiveAccent },
borderBottomColor: { xs: theme.nav.itemActiveAccent, lg: 'transparent' },
'&:hover': {
bgcolor: theme.nav.itemActive,
color: theme.nav.itemActiveText,
},
},
})}
>
<ListItemIcon>
<HelpBadge title={label} help={help} placement="right">
<Icon />
</HelpBadge>
</ListItemIcon>
<ListItemText
primary={label}
slotProps={{
primary: {
fontSize: { xs: '0.8rem', sm: '0.875rem' },
fontWeight: active ? 600 : 500,
noWrap: true,
},
}}
sx={{
m: { xs: 0, lg: 'initial' },
display: active ? 'block' : { xs: 'none', md: 'block' },
}}
/>
</ListItemButton>
);
})}
</List>
<Box sx={{ ml: { xs: 'auto', lg: 0 }, flexShrink: 0, display: 'flex', alignItems: 'center' }}>
<UserMenu />
</Box>
</Box>
);
}
}
class AppSubNav extends PureComponent {
constructor(props) {
super(props);
this.state = {
lastArticleSearchPath: navigationAutomat.state.lastArticleSearchPath,
lastSearchTerm: navigationAutomat.state.lastSearchTerm,
lastCategoryPath: navigationAutomat.state.lastCategoryPath,
lastCategorySlug: navigationAutomat.state.lastCategorySlug,
};
}
componentDidMount() {
this.unsub = navigationAutomat.subscribe(this, (state) => ({
lastArticleSearchPath: state.lastArticleSearchPath,
lastSearchTerm: state.lastSearchTerm,
lastCategoryPath: state.lastCategoryPath,
lastCategorySlug: state.lastCategorySlug,
}));
}
componentWillUnmount() {
this.unsub?.();
}
render() {
const { activeNav, activeSub, navigate } = this.props;
const {
lastArticleSearchPath,
lastSearchTerm,
lastCategoryPath,
lastCategorySlug,
} = this.state;
const subNav = activeNav ? SUB_NAV[activeNav] : null;
if (!subNav?.length) {
return (
<Box
sx={{
width: { xs: 0, lg: SUB_NAV_WIDTH },
display: { xs: 'none', lg: 'block' },
flexShrink: 0,
ml: { xs: 0, lg: 3 },
mr: { xs: 0, lg: 2 },
}}
/>
);
}
return (
<Box
component="nav"
aria-label="Unternavigation"
sx={(theme) => ({
width: { xs: '100%', sm: 72, md: SUB_NAV_WIDTH },
flexShrink: 0,
display: 'flex',
flexDirection: { xs: 'row', sm: 'column' },
alignItems: 'center',
gap: { xs: 1, sm: 1.5 },
position: 'sticky',
top: { xs: 52, sm: 56, lg: 0 },
zIndex: { xs: 1090, sm: 'auto' },
alignSelf: 'flex-start',
height: {
xs: 'auto',
sm: 'calc(var(--df-vh, 100vh) - 56px)',
lg: 'var(--df-vh, 100vh)',
},
py: { xs: 0.75, sm: 2, lg: 4 },
px: { xs: 1.5, sm: 0 },
ml: { xs: 0, sm: 2, lg: 3 },
mr: { xs: 0, sm: 1.5, lg: 2 },
overflowX: { xs: 'auto', sm: 'visible' },
scrollbarWidth: 'none',
'&::-webkit-scrollbar': { display: 'none' },
bgcolor: { xs: theme.nav?.mainBackground || 'background.default', sm: 'transparent' },
borderBottom: { xs: '1px solid', sm: 'none' },
borderColor: { xs: 'divider', sm: 'transparent' },
boxSizing: 'border-box',
})}
>
{subNav.map(({ segment, label, title, help, icon: Icon }) => {
const active = segment === activeSub;
return (
<ButtonBase
key={segment || 'index'}
type="button"
title={label}
aria-label={label}
aria-current={active ? 'page' : undefined}
disableRipple
onMouseDown={(e) => e.preventDefault()}
onClick={() => {
if (!segment && activeNav === '/artikel') {
const target =
lastArticleSearchPath ||
(lastSearchTerm ? buildSearchUrl(lastSearchTerm) : null) ||
artikelQueryPath();
navigate(target);
return;
}
if (segment === 'kategorien') {
const target =
lastCategoryPath ||
(lastCategorySlug ? `/kategorien/${lastCategorySlug}` : null) ||
defaultCategoryPath();
navigate(target);
return;
}
navigate(segment ? `${activeNav}/${segment}` : activeNav);
}}
sx={(theme) => ({
width: { xs: 'auto', sm: 72, md: SUB_NAV_WIDTH },
py: { xs: 0.5, sm: 1 },
px: { xs: active ? 1.25 : 0.75, sm: 0.5 },
borderRadius: 2,
display: 'flex',
flexDirection: { xs: 'row', sm: 'column' },
alignItems: 'center',
justifyContent: 'center',
gap: { xs: active ? 0.75 : 0, sm: 0.5 },
flexShrink: 0,
whiteSpace: 'nowrap',
color: active
? theme.nav?.itemActiveAccent || NAV_ITEM_ACTIVE_ACCENT
: theme.palette.text.secondary,
bgcolor: active ? theme.nav?.subActive || NAV_SUB_ACTIVE : 'transparent',
'&:hover': {
bgcolor: active
? theme.nav?.subActiveHover || NAV_SUB_ACTIVE_HOVER
: theme.palette.action.hover,
color: active
? theme.nav?.itemActiveAccent || NAV_ITEM_ACTIVE_ACCENT
: theme.palette.primary.main,
},
})}
>
<HelpBadge title={title || label} help={help} placement="right">
<Icon sx={{ fontSize: { xs: 20, sm: 28, md: 32 } }} />
</HelpBadge>
{label ? (
<Typography
variant="caption"
noWrap
sx={{
fontSize: { xs: '0.75rem', sm: '0.68rem' },
fontWeight: 500,
lineHeight: 1.2,
textAlign: 'center',
maxWidth: '100%',
display: active ? 'block' : { xs: 'none', sm: 'block' },
}}
>
{label}
</Typography>
) : null}
</ButtonBase>
);
})}
</Box>
);
}
}
export default function AppShell() {
const navigate = useNavigate();
const { pathname } = useLocation();
const activeNav = resolveActiveNav(pathname);
const activeSub = activeNav ? resolveActiveSub(pathname, activeNav) : null;
return (
<Box
sx={{
display: 'flex',
flexDirection: { xs: 'column', lg: 'row' },
minHeight: 'var(--df-vh, 100vh)',
bgcolor: 'background.default',
}}
>
<AppSidebar activeNav={activeNav} navigate={navigate} />
<Box
sx={{
display: 'flex',
flexDirection: { xs: 'column', sm: 'row' },
flexGrow: 1,
minWidth: 0,
}}
>
<AppSubNav
activeNav={activeNav}
activeSub={activeSub}
navigate={navigate}
/>
<Box
component="main"
sx={(theme) => ({
flexGrow: 1,
display: 'flex',
flexDirection: 'column',
py: { xs: 2, md: 3, lg: 4 },
pr: { xs: 1.5, sm: 2, lg: 3 },
pl: { xs: 1.5, sm: 2, lg: 0 },
bgcolor: theme.nav.mainBackground,
minWidth: 0,
})}
>
<Suspense fallback={null}>
<Outlet />
</Suspense>
</Box>
</Box>
<ChatFab />
<ChatDrawer />
<PinnedAppletFabs pathname={pathname} />
<PinnedAppletDialog />
<PwaUpdateDialog />
{ChunkCacheMonitor ? (
<Suspense fallback={null}>
<ChunkCacheMonitor />
</Suspense>
) : null}
{BreakpointOverlay && (
<Suspense fallback={null}>
<BreakpointOverlay />
</Suspense>
)}
</Box>
);
}