mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-28 20:40:56 +00:00
merge design protal and backoffice
This commit is contained in:
@@ -1,3 +1,9 @@
|
||||
export * from './lib/feedback/ConfirmModal';
|
||||
export * from './lib/feedback/ApiErrorAlert';
|
||||
export * from './lib/feedback/notify';
|
||||
export * from './lib/layout/AppHeader';
|
||||
export * from './lib/layout/AppSidebar';
|
||||
export * from './lib/layout/BrandAvatar';
|
||||
export * from './lib/layout/ColorSchemeToggle';
|
||||
export * from './lib/layout/LanguageSwitcher';
|
||||
export * from './lib/layout/PageHeader';
|
||||
|
||||
271
libs/ui/src/lib/layout/AppHeader.tsx
Normal file
271
libs/ui/src/lib/layout/AppHeader.tsx
Normal file
@@ -0,0 +1,271 @@
|
||||
import {
|
||||
Anchor,
|
||||
Burger,
|
||||
Group,
|
||||
Indicator,
|
||||
Menu,
|
||||
UnstyledButton,
|
||||
rem,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconBell,
|
||||
IconChevronRight,
|
||||
IconLogout,
|
||||
IconUserCircle,
|
||||
} from '@tabler/icons-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { LanguageSwitcher } from './LanguageSwitcher';
|
||||
import { ColorSchemeToggle } from './ColorSchemeToggle';
|
||||
import { BrandAvatar } from './BrandAvatar';
|
||||
|
||||
export interface Breadcrumb {
|
||||
label: string;
|
||||
path: string;
|
||||
}
|
||||
|
||||
interface AppHeaderProps {
|
||||
onToggleNav: () => void;
|
||||
onToggleSidebar: () => void;
|
||||
navOpened: boolean;
|
||||
breadcrumbs: Breadcrumb[];
|
||||
onNavigate: (path: string) => void;
|
||||
onLogout: () => void;
|
||||
userName?: string;
|
||||
userInitials?: string;
|
||||
supportedLanguages: readonly string[];
|
||||
}
|
||||
|
||||
export function AppHeader({
|
||||
onToggleNav,
|
||||
onToggleSidebar,
|
||||
navOpened,
|
||||
breadcrumbs,
|
||||
onNavigate,
|
||||
onLogout,
|
||||
userName = 'User',
|
||||
userInitials = '?',
|
||||
supportedLanguages,
|
||||
}: AppHeaderProps) {
|
||||
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768;
|
||||
|
||||
return (
|
||||
<Group h="100%" px="lg" justify="space-between" wrap="nowrap">
|
||||
<Group gap="md" wrap="nowrap">
|
||||
{/* Hamburger — styled like user-management Top.tsx */}
|
||||
<UnstyledButton
|
||||
onClick={isMobile ? onToggleNav : onToggleSidebar}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: rem(38),
|
||||
height: rem(38),
|
||||
borderRadius: rem(12),
|
||||
background: 'var(--mantine-color-body)',
|
||||
color: 'var(--mantine-color-gray-6)',
|
||||
boxShadow: '0 1px 3px rgba(0,0,0,0.04), 0 0 0 1px rgba(0,0,0,0.06)',
|
||||
transition: 'all 150ms ease',
|
||||
cursor: 'pointer',
|
||||
border: 'none',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = 'var(--mantine-color-primary-light)';
|
||||
e.currentTarget.style.color = 'var(--mantine-color-primary-6)';
|
||||
e.currentTarget.style.boxShadow =
|
||||
'0 1px 3px rgba(0,0,0,0.04), 0 0 0 1px var(--mantine-color-primary-2)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'var(--mantine-color-body)';
|
||||
e.currentTarget.style.color = 'var(--mantine-color-gray-6)';
|
||||
e.currentTarget.style.boxShadow =
|
||||
'0 1px 3px rgba(0,0,0,0.04), 0 0 0 1px rgba(0,0,0,0.06)';
|
||||
}}
|
||||
>
|
||||
<Burger
|
||||
opened={navOpened}
|
||||
onClick={() => {}}
|
||||
size="sm"
|
||||
aria-label="Toggle navigation"
|
||||
styles={{
|
||||
root: { border: 'none', background: 'transparent' },
|
||||
burger: { '--burger-color': 'currentColor' },
|
||||
}}
|
||||
/>
|
||||
</UnstyledButton>
|
||||
|
||||
{/* Breadcrumbs — card container with pill-style crumbs */}
|
||||
<Group
|
||||
gap={2}
|
||||
wrap="nowrap"
|
||||
visibleFrom="xs"
|
||||
style={{
|
||||
borderRadius: rem(12),
|
||||
background: 'var(--mantine-color-body)',
|
||||
padding: '4px 10px',
|
||||
boxShadow: '0 1px 3px rgba(0,0,0,0.04), 0 0 0 1px rgba(0,0,0,0.06)',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
{breadcrumbs.map((crumb, i) => {
|
||||
const isLast = i === breadcrumbs.length - 1;
|
||||
return (
|
||||
<div key={crumb.path} style={{ display: 'flex', alignItems: 'center', gap: rem(2) }}>
|
||||
{i > 0 && (
|
||||
<IconChevronRight size={14} style={{ color: 'var(--mantine-color-gray-4)', flexShrink: 0 }} />
|
||||
)}
|
||||
{isLast ? (
|
||||
<span
|
||||
style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
borderRadius: rem(999),
|
||||
padding: '2px 12px',
|
||||
fontSize: rem(12),
|
||||
fontWeight: 600,
|
||||
background: 'var(--mantine-color-primary-light)',
|
||||
color: 'var(--mantine-color-primary-7)',
|
||||
boxShadow: '0 0 0 1px var(--mantine-color-primary-2)',
|
||||
whiteSpace: 'nowrap',
|
||||
lineHeight: '22px',
|
||||
}}
|
||||
>
|
||||
{crumb.label}
|
||||
</span>
|
||||
) : (
|
||||
<Anchor
|
||||
size="sm"
|
||||
c="gray.6"
|
||||
onClick={() => onNavigate(crumb.path)}
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
borderRadius: rem(999),
|
||||
padding: '2px 10px',
|
||||
fontSize: rem(12),
|
||||
fontWeight: 500,
|
||||
whiteSpace: 'nowrap',
|
||||
textDecoration: 'none',
|
||||
lineHeight: '22px',
|
||||
transition: 'all 150ms ease',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = 'var(--mantine-color-primary-light)';
|
||||
e.currentTarget.style.color = 'var(--mantine-color-primary-7)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'transparent';
|
||||
e.currentTarget.style.color = 'var(--mantine-color-gray-6)';
|
||||
}}
|
||||
>
|
||||
{crumb.label}
|
||||
</Anchor>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<LanguageSwitcher supportedLanguages={supportedLanguages} />
|
||||
<ColorSchemeToggle />
|
||||
|
||||
{/* Notification bell */}
|
||||
<UnstyledButton
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: rem(38),
|
||||
height: rem(38),
|
||||
borderRadius: rem(12),
|
||||
background: 'var(--mantine-color-body)',
|
||||
color: 'var(--mantine-color-gray-6)',
|
||||
boxShadow: '0 1px 3px rgba(0,0,0,0.04), 0 0 0 1px rgba(0,0,0,0.06)',
|
||||
transition: 'all 150ms ease',
|
||||
cursor: 'pointer',
|
||||
border: 'none',
|
||||
position: 'relative',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = 'var(--mantine-color-primary-light)';
|
||||
e.currentTarget.style.color = 'var(--mantine-color-primary-6)';
|
||||
e.currentTarget.style.boxShadow =
|
||||
'0 1px 3px rgba(0,0,0,0.04), 0 0 0 1px var(--mantine-color-primary-2)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'var(--mantine-color-body)';
|
||||
e.currentTarget.style.color = 'var(--mantine-color-gray-6)';
|
||||
e.currentTarget.style.boxShadow =
|
||||
'0 1px 3px rgba(0,0,0,0.04), 0 0 0 1px rgba(0,0,0,0.06)';
|
||||
}}
|
||||
aria-label="Notifications"
|
||||
>
|
||||
<Indicator color="red" size={9} offset={5} withBorder>
|
||||
<IconBell size={19} />
|
||||
</Indicator>
|
||||
</UnstyledButton>
|
||||
|
||||
{/* Profile avatar menu */}
|
||||
<Menu position="bottom-end" width={220} shadow="md" withinPortal>
|
||||
<Menu.Target>
|
||||
<UnstyledButton
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: rem(38),
|
||||
height: rem(38),
|
||||
borderRadius: rem(12),
|
||||
transition: 'all 150ms ease',
|
||||
cursor: 'pointer',
|
||||
border: 'none',
|
||||
background: 'transparent',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.transform = 'scale(1.05)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.transform = 'scale(1)';
|
||||
}}
|
||||
>
|
||||
<BrandAvatar initials={userInitials} size={38} />
|
||||
</UnstyledButton>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown
|
||||
style={{
|
||||
borderRadius: rem(12),
|
||||
padding: rem(6),
|
||||
boxShadow: '0 8px 24px rgba(0,0,0,0.12)',
|
||||
}}
|
||||
>
|
||||
<Menu.Label
|
||||
style={{
|
||||
padding: rem(10),
|
||||
fontWeight: 600,
|
||||
fontSize: rem(13),
|
||||
}}
|
||||
>
|
||||
{userName}
|
||||
</Menu.Label>
|
||||
<Menu.Item
|
||||
leftSection={<IconUserCircle size={16} />}
|
||||
onClick={() => onNavigate('/profile')}
|
||||
style={{ borderRadius: rem(8) }}
|
||||
>
|
||||
Profile
|
||||
</Menu.Item>
|
||||
<Menu.Divider />
|
||||
<Menu.Item
|
||||
color="red"
|
||||
leftSection={<IconLogout size={16} />}
|
||||
onClick={onLogout}
|
||||
style={{ borderRadius: rem(8) }}
|
||||
>
|
||||
Logout
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
</Group>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
196
libs/ui/src/lib/layout/AppSidebar.tsx
Normal file
196
libs/ui/src/lib/layout/AppSidebar.tsx
Normal file
@@ -0,0 +1,196 @@
|
||||
import {
|
||||
AppShell,
|
||||
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;
|
||||
soon?: boolean;
|
||||
}
|
||||
|
||||
interface AppSidebarProps {
|
||||
navItems: NavItem[];
|
||||
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={4}>
|
||||
{navItems.map((item) => {
|
||||
const ItemIcon = item.icon;
|
||||
const active = activeNavItem(item);
|
||||
|
||||
if (collapsed) {
|
||||
return (
|
||||
<Tooltip key={item.label} label={item.label} position="right" withArrow>
|
||||
<UnstyledButton
|
||||
onClick={() => onNavigate(item)}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: '100%',
|
||||
height: rem(40),
|
||||
borderRadius: rem(10),
|
||||
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={item.label}
|
||||
leftSection={<ItemIcon size={19} stroke={1.6} />}
|
||||
onClick={() => onNavigate(item)}
|
||||
variant="light"
|
||||
styles={{ root: { borderRadius: rem(10) }, label: { fontWeight: 500 } }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
30
libs/ui/src/lib/layout/BrandAvatar.tsx
Normal file
30
libs/ui/src/lib/layout/BrandAvatar.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { Box, useMantineTheme } from '@mantine/core';
|
||||
|
||||
export function BrandAvatar({
|
||||
initials = '?',
|
||||
size = 38,
|
||||
}: {
|
||||
initials?: string;
|
||||
size?: number;
|
||||
}) {
|
||||
const theme = useMantineTheme();
|
||||
return (
|
||||
<Box
|
||||
w={size}
|
||||
h={size}
|
||||
style={{
|
||||
borderRadius: '50%',
|
||||
background: theme.other.heroGradient as string,
|
||||
color: '#fff',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
fontSize: size * 0.36,
|
||||
fontWeight: 700,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{initials}
|
||||
</Box>
|
||||
);
|
||||
}
|
||||
45
libs/ui/src/lib/layout/ColorSchemeToggle.tsx
Normal file
45
libs/ui/src/lib/layout/ColorSchemeToggle.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
import { UnstyledButton, useMantineColorScheme, useComputedColorScheme, rem } from '@mantine/core';
|
||||
import { IconSun, IconMoon } from '@tabler/icons-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export function ColorSchemeToggle() {
|
||||
const { t } = useTranslation();
|
||||
const { setColorScheme } = useMantineColorScheme();
|
||||
const computed = useComputedColorScheme('light', { getInitialValueInEffect: true });
|
||||
const isDark = computed === 'dark';
|
||||
|
||||
return (
|
||||
<UnstyledButton
|
||||
aria-label={t('common.toggleTheme')}
|
||||
onClick={() => setColorScheme(isDark ? 'light' : 'dark')}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: rem(38),
|
||||
height: rem(38),
|
||||
borderRadius: rem(12),
|
||||
background: 'var(--mantine-color-body)',
|
||||
color: 'var(--mantine-color-gray-6)',
|
||||
boxShadow: '0 1px 3px rgba(0,0,0,0.04), 0 0 0 1px rgba(0,0,0,0.06)',
|
||||
transition: 'all 150ms ease',
|
||||
cursor: 'pointer',
|
||||
border: 'none',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = 'var(--mantine-color-primary-light)';
|
||||
e.currentTarget.style.color = 'var(--mantine-color-primary-6)';
|
||||
e.currentTarget.style.boxShadow =
|
||||
'0 1px 3px rgba(0,0,0,0.04), 0 0 0 1px var(--mantine-color-primary-2)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'var(--mantine-color-body)';
|
||||
e.currentTarget.style.color = 'var(--mantine-color-gray-6)';
|
||||
e.currentTarget.style.boxShadow =
|
||||
'0 1px 3px rgba(0,0,0,0.04), 0 0 0 1px rgba(0,0,0,0.06)';
|
||||
}}
|
||||
>
|
||||
{isDark ? <IconSun size={19} /> : <IconMoon size={19} />}
|
||||
</UnstyledButton>
|
||||
);
|
||||
}
|
||||
83
libs/ui/src/lib/layout/LanguageSwitcher.tsx
Normal file
83
libs/ui/src/lib/layout/LanguageSwitcher.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import { Menu, UnstyledButton, Text, rem } from '@mantine/core';
|
||||
import { IconWorld, IconCheck, IconChevronDown } from '@tabler/icons-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
interface LanguageSwitcherProps {
|
||||
supportedLanguages: readonly string[];
|
||||
/** `icon` renders a compact globe button; `button` shows the language label. */
|
||||
variant?: 'icon' | 'button';
|
||||
}
|
||||
|
||||
export function LanguageSwitcher({ supportedLanguages, variant = 'icon' }: LanguageSwitcherProps) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const current = i18n.language;
|
||||
|
||||
const change = (lng: string) => {
|
||||
if (lng !== current) i18n.changeLanguage(lng);
|
||||
};
|
||||
|
||||
return (
|
||||
<Menu shadow="md" width={160} position="bottom-end" withinPortal>
|
||||
<Menu.Target>
|
||||
<UnstyledButton
|
||||
aria-label={t('language.label')}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: rem(4),
|
||||
width: variant === 'icon' ? rem(38) : 'auto',
|
||||
height: rem(38),
|
||||
padding: variant === 'icon' ? 0 : '0 12px',
|
||||
borderRadius: rem(12),
|
||||
background: 'var(--mantine-color-body)',
|
||||
color: 'var(--mantine-color-gray-6)',
|
||||
boxShadow: '0 1px 3px rgba(0,0,0,0.04), 0 0 0 1px rgba(0,0,0,0.06)',
|
||||
transition: 'all 150ms ease',
|
||||
cursor: 'pointer',
|
||||
border: 'none',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
e.currentTarget.style.background = 'var(--mantine-color-primary-light)';
|
||||
e.currentTarget.style.color = 'var(--mantine-color-primary-6)';
|
||||
e.currentTarget.style.boxShadow =
|
||||
'0 1px 3px rgba(0,0,0,0.04), 0 0 0 1px var(--mantine-color-primary-2)';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
e.currentTarget.style.background = 'var(--mantine-color-body)';
|
||||
e.currentTarget.style.color = 'var(--mantine-color-gray-6)';
|
||||
e.currentTarget.style.boxShadow =
|
||||
'0 1px 3px rgba(0,0,0,0.04), 0 0 0 1px rgba(0,0,0,0.06)';
|
||||
}}
|
||||
>
|
||||
<IconWorld size={19} />
|
||||
{variant !== 'icon' && (
|
||||
<>
|
||||
<Text size="sm" fw={500}>
|
||||
{t(`language.${current}`)}
|
||||
</Text>
|
||||
<IconChevronDown size={14} />
|
||||
</>
|
||||
)}
|
||||
</UnstyledButton>
|
||||
</Menu.Target>
|
||||
<Menu.Dropdown
|
||||
style={{ borderRadius: rem(12), padding: rem(6) }}
|
||||
>
|
||||
<Menu.Label>{t('language.label')}</Menu.Label>
|
||||
{supportedLanguages.map((lng) => (
|
||||
<Menu.Item
|
||||
key={lng}
|
||||
onClick={() => change(lng)}
|
||||
rightSection={
|
||||
current === lng ? <IconCheck size={16} /> : undefined
|
||||
}
|
||||
style={{ borderRadius: rem(8) }}
|
||||
>
|
||||
{t(`language.${lng}`)}
|
||||
</Menu.Item>
|
||||
))}
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
);
|
||||
}
|
||||
25
libs/ui/src/lib/layout/PageHeader.tsx
Normal file
25
libs/ui/src/lib/layout/PageHeader.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Group, Stack, Text, Title } from '@mantine/core';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
interface PageHeaderProps {
|
||||
title: string;
|
||||
subtitle?: string;
|
||||
/** Right-aligned actions (e.g. a primary button). */
|
||||
action?: ReactNode;
|
||||
}
|
||||
|
||||
export function PageHeader({ title, subtitle, action }: PageHeaderProps) {
|
||||
return (
|
||||
<Group justify="space-between" align="flex-end" wrap="wrap" gap="sm">
|
||||
<Stack gap={2}>
|
||||
<Title order={2}>{title}</Title>
|
||||
{subtitle && (
|
||||
<Text c="dimmed" size="sm">
|
||||
{subtitle}
|
||||
</Text>
|
||||
)}
|
||||
</Stack>
|
||||
{action}
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user