Files
emaui/libs/ui/src/lib/layout/AppSidebar.tsx
2026-07-29 16:53:50 +03:00

260 lines
7.7 KiB
TypeScript

import {
AppShell,
Badge,
NavLink,
ScrollArea,
Stack,
Text,
Tooltip,
UnstyledButton,
rem,
} from '@mantine/core';
import {
IconChevronLeft,
IconChevronRight,
} from '@tabler/icons-react';
import type { Icon } from '@tabler/icons-react';
import { useTranslation } from 'react-i18next';
import { BrandMark } from '@ema-platform/auth';
export interface NavItem {
label: string;
icon: Icon;
to?: string;
/** Not yet connected to real data — surfaced as a "Soon" badge. */
soon?: boolean;
}
/** A labelled group of nav items. */
export interface NavSection {
/** i18n key or literal heading. Omit for an ungrouped leading block. */
label?: string;
items: NavItem[];
}
/** Both shapes are accepted so callers can migrate to sections gradually. */
export type NavEntries = NavItem[] | NavSection[];
function toSections(entries: NavEntries): NavSection[] {
if (entries.length === 0) return [];
const isSectioned = (entries as NavSection[])[0]?.items !== undefined;
return isSectioned
? (entries as NavSection[])
: [{ items: entries as NavItem[] }];
}
interface AppSidebarProps {
navItems: NavEntries;
collapsed: boolean;
activePath: string;
onToggleCollapse: () => void;
onNavigate: (item: NavItem) => void;
brandName: string;
brandSubtitle: string;
}
export function AppSidebar({
navItems,
collapsed,
activePath,
onToggleCollapse,
onNavigate,
brandName,
brandSubtitle,
}: AppSidebarProps) {
const { t } = useTranslation();
const activeNavItem = (item: NavItem) =>
!!item.to &&
(activePath === item.to || activePath.startsWith(`${item.to}/`));
return (
<>
{/* Brand header */}
<div
style={{
display: 'flex',
alignItems: 'center',
gap: rem(12),
height: 74,
padding: collapsed ? '0 12px' : '0 20px',
borderBottom: '1px solid var(--mantine-color-gray-2)',
justifyContent: collapsed ? 'center' : 'flex-start',
flexShrink: 0,
}}
>
<BrandMark size={32} />
{!collapsed && (
<div style={{ minWidth: 0 }}>
<Text
fw={800}
size="xs"
style={{
textTransform: 'uppercase',
letterSpacing: '0.08em',
lineHeight: 1.2,
color: 'var(--mantine-color-primary-7)',
}}
>
{brandName}
</Text>
<Text
size="xs"
c="dimmed"
style={{
textTransform: 'uppercase',
letterSpacing: '0.05em',
fontSize: rem(10),
lineHeight: 1.3,
}}
>
{brandSubtitle}
</Text>
</div>
)}
</div>
{/* Navigation items */}
<AppShell.Section grow component={ScrollArea} p="md">
<Stack gap={2}>
{toSections(navItems).map((section, sectionIndex) => (
<Stack gap={2} key={section.label ?? `section-${sectionIndex}`}>
{/* Headings are noise when only icons are visible. */}
{section.label && !collapsed && (
<Text
size="xs"
fw={700}
c="dimmed"
mt={sectionIndex === 0 ? 0 : rem(14)}
pl={rem(12)}
style={{ textTransform: 'uppercase', letterSpacing: '0.06em' }}
>
{t(section.label)}
</Text>
)}
{section.label && collapsed && sectionIndex > 0 && (
<div
style={{
height: 1,
margin: `${rem(8)} ${rem(6)}`,
backgroundColor: 'var(--mantine-color-gray-2)',
}}
/>
)}
{section.items.map((item) => {
const ItemIcon = item.icon;
const active = activeNavItem(item);
if (collapsed) {
return (
<Tooltip
key={item.label}
label={item.soon ? `${t(item.label)}${t('nav.soon', 'Soon')}` : t(item.label)}
position="right"
withArrow
>
<UnstyledButton
onClick={() => onNavigate(item)}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '100%',
height: rem(40),
borderRadius: rem(10),
opacity: item.soon ? 0.55 : 1,
color: active ? 'var(--mantine-color-blue-6)' : undefined,
backgroundColor: active ? 'var(--mantine-color-blue-light)' : undefined,
}}
>
<ItemIcon size={20} stroke={1.6} />
</UnstyledButton>
</Tooltip>
);
}
return (
<NavLink
key={item.label}
active={active}
label={t(item.label)}
leftSection={<ItemIcon size={19} stroke={1.6} />}
// Tells a reviewer at a glance which screens are wired up.
rightSection={
item.soon ? (
<Badge size="xs" variant="light" color="gray" radius="sm">
{t('nav.soon', 'Soon')}
</Badge>
) : undefined
}
onClick={() => onNavigate(item)}
variant="light"
styles={{
root: { borderRadius: rem(10), opacity: item.soon ? 0.7 : 1 },
label: { fontWeight: 500 },
}}
/>
);
})}
</Stack>
))}
</Stack>
</AppShell.Section>
{/* Collapse toggle */}
<div
style={{
padding: rem(8),
borderTop: '1px solid var(--mantine-color-gray-2)',
flexShrink: 0,
}}
>
<Tooltip
label={collapsed ? t('nav.expandSidebar', 'Expand sidebar') : t('nav.collapseSidebar', 'Collapse sidebar')}
position="right"
withArrow
disabled={!collapsed}
>
<UnstyledButton
onClick={onToggleCollapse}
visibleFrom="sm"
style={{
display: 'flex',
alignItems: 'center',
justifyContent: collapsed ? 'center' : 'flex-start',
gap: rem(10),
width: '100%',
padding: '10px 12px',
borderRadius: rem(12),
color: 'var(--mantine-color-gray-5)',
transition: 'all 150ms ease',
cursor: 'pointer',
border: 'none',
background: 'transparent',
fontSize: rem(13),
fontWeight: 500,
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'var(--mantine-color-gray-0)';
e.currentTarget.style.color = 'var(--mantine-color-gray-7)';
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'transparent';
e.currentTarget.style.color = 'var(--mantine-color-gray-5)';
}}
>
{collapsed ? (
<IconChevronRight size={18} stroke={1.6} />
) : (
<>
<IconChevronLeft size={18} stroke={1.6} />
<span>{t('nav.collapseSidebar', 'Collapse')}</span>
</>
)}
</UnstyledButton>
</Tooltip>
</div>
</>
);
}