105 lines
3.9 KiB
TypeScript
105 lines
3.9 KiB
TypeScript
'use client';
|
|
import { FunctionComponent, PropsWithChildren, ReactNode, useState } from 'react';
|
|
import { useTranslations } from 'next-intl';
|
|
import { Box, Stack, useTheme } from '@mui/material';
|
|
import { AppIconButton, ErrorBoundary, RouteFadeIn } from '@/components';
|
|
import { LinkToPage } from '@/utils';
|
|
import { TopBar } from './components';
|
|
import SideBar from './components/SideBar';
|
|
import { DarkModeToggleButton } from './components/DarkModeButton';
|
|
import { TOP_BAR_DESKTOP_HEIGHT, TOP_BAR_MOBILE_HEIGHT } from './config';
|
|
import { useRouteTitle } from './routeTitle';
|
|
|
|
interface Props {
|
|
sidebarItems: Array<LinkToPage>;
|
|
/** Rendered below the sidebar's brand header (the nurse shell's `ProfileSummary` identity card). */
|
|
sidebarIdentity?: ReactNode;
|
|
/** Rendered in the TopBar, before the header controls (the admin/partner identity chip). */
|
|
identity?: ReactNode;
|
|
/** Extra chrome actions (e.g. the notification bell) rendered next to the dark-mode toggle. */
|
|
headerActions?: ReactNode;
|
|
/**
|
|
* A mobile-only (`<md`) bottom nav rendered under the content column (the nurse shell's 5-tab
|
|
* bar). Receives a callback that opens the same mobile drawer the TopBar's menu button opens, so
|
|
* a "more" tab can reveal the rest of the sidebar without a second drawer.
|
|
*/
|
|
mobileBottomBar?: (openMobileSidebar: () => void) => ReactNode;
|
|
}
|
|
|
|
/**
|
|
* The shared engine behind the nurse/admin/partner shells: a fixed `TopBar` showing the current
|
|
* page title (`useRouteTitle`, longest-prefix over `ROUTES.*`) plus a `SideBar` that renders as a
|
|
* mobile overlay and a desktop in-flow column at once (see `SideBar` for why that kills the SSR
|
|
* flash). The sidebar and the main column are flex siblings in a row — native RTL flexbox puts the
|
|
* sidebar at the reading-start side with no manual offset math.
|
|
* @layout TopBarAndSideBarLayout
|
|
*/
|
|
const TopBarAndSideBarLayout: FunctionComponent<PropsWithChildren<Props>> = ({
|
|
children,
|
|
sidebarItems,
|
|
sidebarIdentity,
|
|
identity,
|
|
headerActions,
|
|
mobileBottomBar,
|
|
}) => {
|
|
const tChrome = useTranslations('routeChrome');
|
|
const tCommon = useTranslations('common');
|
|
const theme = useTheme();
|
|
const title = useRouteTitle();
|
|
const [mobileOpen, setMobileOpen] = useState(false);
|
|
|
|
const anchor = theme.direction === 'rtl' ? 'right' : 'left';
|
|
|
|
const headerControls = (
|
|
<Stack direction="row" sx={{ alignItems: 'center', gap: 0.5 }}>
|
|
{identity}
|
|
{headerActions}
|
|
{/* DarkModeToggleButton subscribes to useColorScheme() on its own — this layout never re-renders on a theme flip */}
|
|
<DarkModeToggleButton />
|
|
</Stack>
|
|
);
|
|
|
|
return (
|
|
<Stack sx={{ minHeight: '100vh' }}>
|
|
<TopBar
|
|
startNode={
|
|
<AppIconButton
|
|
icon="menu"
|
|
title={tCommon('open_sidebar')}
|
|
onClick={() => setMobileOpen(true)}
|
|
sx={{ display: { xs: 'inline-flex', md: 'none' } }}
|
|
/>
|
|
}
|
|
title={title ?? ''}
|
|
align="start"
|
|
endNode={headerControls}
|
|
/>
|
|
|
|
<Stack direction="row" sx={{ flexGrow: 1, pt: { xs: TOP_BAR_MOBILE_HEIGHT, md: TOP_BAR_DESKTOP_HEIGHT } }}>
|
|
<SideBar
|
|
items={sidebarItems}
|
|
identity={sidebarIdentity}
|
|
anchor={anchor}
|
|
mobileOpen={mobileOpen}
|
|
onMobileClose={() => setMobileOpen(false)}
|
|
/>
|
|
|
|
<Stack component="main" sx={{ flexGrow: 1, minWidth: 0, padding: 2 }}>
|
|
<ErrorBoundary
|
|
name="Content"
|
|
title={tChrome('error_title')}
|
|
body={tChrome('error_body')}
|
|
retryLabel={tChrome('error_retry')}
|
|
>
|
|
<RouteFadeIn>{children}</RouteFadeIn>
|
|
</ErrorBoundary>
|
|
</Stack>
|
|
</Stack>
|
|
|
|
{mobileBottomBar && <Box sx={{ display: { xs: 'block', md: 'none' } }}>{mobileBottomBar(() => setMobileOpen(true))}</Box>}
|
|
</Stack>
|
|
);
|
|
};
|
|
|
|
export default TopBarAndSideBarLayout;
|