ui phase 2

This commit is contained in:
hamid
2026-07-17 19:05:17 +03:30
parent 370c1beefa
commit 222856d600
42 changed files with 1354 additions and 451 deletions
+75 -90
View File
@@ -1,117 +1,102 @@
'use client';
import { FunctionComponent, ReactNode, useMemo, useState } from 'react';
import { FunctionComponent, PropsWithChildren, ReactNode, useState } from 'react';
import { useTranslations } from 'next-intl';
import { Stack, StackProps } from '@mui/material';
import { Box, Stack, useTheme } from '@mui/material';
import { AppIconButton, ErrorBoundary } from '@/components';
import { LinkToPage } from '@/utils';
import { useIsMobile } from '@/hooks';
import { TopBar } from './components';
import SideBar, { SideBarProps } from './components/SideBar';
import SideBar from './components/SideBar';
import { DarkModeToggleButton } from './components/DarkModeButton';
import {
SIDE_BAR_DESKTOP_ANCHOR,
SIDE_BAR_MOBILE_ANCHOR,
SIDE_BAR_WIDTH,
TOP_BAR_DESKTOP_HEIGHT,
TOP_BAR_MOBILE_HEIGHT,
} from './config';
import { TOP_BAR_DESKTOP_HEIGHT, TOP_BAR_MOBILE_HEIGHT } from './config';
import { useRouteTitle } from './routeTitle';
interface Props extends StackProps {
interface Props {
sidebarItems: Array<LinkToPage>;
title: string;
variant: 'sidebarAlwaysTemporary' | 'sidebarPersistentOnDesktop' | 'sidebarAlwaysPersistent';
/** 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;
}
const TopBarAndSideBarLayout: FunctionComponent<Props> = ({ children, sidebarItems, title, variant, headerActions }) => {
/**
* 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 [sidebarVisible, setSidebarVisible] = useState(false);
const onMobile = useIsMobile();
const tCommon = useTranslations('common');
const theme = useTheme();
const title = useRouteTitle();
const [mobileOpen, setMobileOpen] = useState(false);
const sidebarProps = useMemo((): Partial<SideBarProps> => {
const anchor = onMobile ? SIDE_BAR_MOBILE_ANCHOR : SIDE_BAR_DESKTOP_ANCHOR;
let open = sidebarVisible;
let sidebarVariant: SideBarProps['variant'] = 'temporary';
switch (variant) {
case 'sidebarAlwaysTemporary':
break;
case 'sidebarPersistentOnDesktop':
open = onMobile ? sidebarVisible : true;
sidebarVariant = onMobile ? 'temporary' : 'persistent';
break;
case 'sidebarAlwaysPersistent':
open = true;
sidebarVariant = 'persistent';
break;
}
return { anchor, open, variant: sidebarVariant };
}, [onMobile, sidebarVisible, variant]);
const anchor = theme.direction === 'rtl' ? 'right' : 'left';
const stackStyles = useMemo(
() => ({
minHeight: '100vh',
paddingTop: onMobile ? TOP_BAR_MOBILE_HEIGHT : TOP_BAR_DESKTOP_HEIGHT,
paddingLeft:
sidebarProps.variant === 'persistent' && sidebarProps.open && sidebarProps?.anchor?.includes('left')
? SIDE_BAR_WIDTH
: undefined,
paddingRight:
sidebarProps.variant === 'persistent' && sidebarProps.open && sidebarProps?.anchor?.includes('right')
? SIDE_BAR_WIDTH
: undefined,
}),
[onMobile, sidebarProps]
);
const onSideBarOpen = () => { if (!sidebarVisible) setSidebarVisible(true); };
const onSideBarClose = () => { if (sidebarVisible) setSidebarVisible(false); };
const LogoButton = (
<AppIconButton
icon="logo"
title={sidebarProps.open ? undefined : 'Open Sidebar'}
to={sidebarProps.open ? '/' : undefined}
onClick={sidebarProps.open ? undefined : onSideBarOpen}
/>
);
/*
* DarkModeToggleButton is a self-contained component that subscribes to
* useColorScheme() on its own. This layout component never reads the color
* scheme and therefore never re-renders when the theme switches. The optional
* headerActions (e.g. the notification bell) travel alongside it.
*/
const headerControls = (
<Stack direction="row" sx={{ alignItems: 'center' }}>
<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>
);
const { startNode, endNode } = sidebarProps?.anchor?.includes('left')
? { startNode: LogoButton, endNode: headerControls }
: { startNode: headerControls, endNode: LogoButton };
return (
<Stack sx={stackStyles}>
<Stack component="header">
<TopBar startNode={startNode} title={title} endNode={endNode} />
<SideBar items={sidebarItems} onClose={onSideBarClose} {...sidebarProps} />
<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')}
>
{children}
</ErrorBoundary>
</Stack>
</Stack>
<Stack
component="main"
sx={{ flexGrow: 1, justifyContent: 'space-between', paddingLeft: 1, paddingRight: 1, paddingTop: 1 }}
>
<ErrorBoundary
name="Content"
title={tChrome('error_title')}
body={tChrome('error_body')}
retryLabel={tChrome('error_retry')}
>
{children}
</ErrorBoundary>
</Stack>
{mobileBottomBar && <Box sx={{ display: { xs: 'block', md: 'none' } }}>{mobileBottomBar(() => setMobileOpen(true))}</Box>}
</Stack>
);
};