build ok,

This commit is contained in:
louiscklaw
2025-04-14 09:26:24 +08:00
commit 6c931c1fe8
770 changed files with 63959 additions and 0 deletions

View File

@@ -0,0 +1,260 @@
import type { NavItemConfig } from '@/types/nav';
import { paths } from '@/paths';
// NOTE: We did not use React Components for Icons, because
// you may one to get the config from the server.
// NOTE: First level elements are groups.
export interface LayoutConfig {
navItems: NavItemConfig[];
}
export const layoutConfig = {
navItems: [
{
key: 'dashboards',
title: 'Dashboards',
items: [
{ key: 'overview', title: 'Overview', href: paths.dashboard.overview, icon: 'house' },
{ key: 'analytics', title: 'Analytics', href: paths.dashboard.analytics, icon: 'chart-pie' },
{ key: 'ecommerce', title: 'E-commerce', href: paths.dashboard.eCommerce, icon: 'cube' },
{ key: 'crypto', title: 'Crypto', href: paths.dashboard.crypto, icon: 'currency-eth' },
],
},
{
key: 'general',
title: 'General',
items: [
{
key: 'settings',
title: 'Settings',
href: paths.dashboard.settings.account,
icon: 'gear',
matcher: { type: 'startsWith', href: '/dashboard/settings' },
},
{
key: 'customers',
title: 'Customers',
icon: 'users',
items: [
{ key: 'customers', title: 'List customers', href: paths.dashboard.customers.list },
{ key: 'customers:create', title: 'Create customer', href: paths.dashboard.customers.create },
{ key: 'customers:details', title: 'Customer details', href: paths.dashboard.customers.details('1') },
],
},
{
key: 'products',
title: 'Products',
icon: 'shopping-bag-open',
items: [
{ key: 'products', title: 'List products', href: paths.dashboard.products.list },
{ key: 'products:create', title: 'Create product', href: paths.dashboard.products.create },
{ key: 'products:details', title: 'Product details', href: paths.dashboard.products.details('1') },
],
},
{
key: 'orders',
title: 'Orders',
icon: 'shopping-cart-simple',
items: [
{ key: 'orders', title: 'List orders', href: paths.dashboard.orders.list },
{ key: 'orders:create', title: 'Create order', href: paths.dashboard.orders.create },
{ key: 'orders:details', title: 'Order details', href: paths.dashboard.orders.details('1') },
],
},
{
key: 'invoices',
title: 'Invoices',
icon: 'receipt',
items: [
{ key: 'invoices', title: 'List invoices', href: paths.dashboard.invoices.list },
{ key: 'invoices:create', title: 'Create invoice', href: paths.dashboard.invoices.create },
{ key: 'invoices:details', title: 'Invoice details', href: paths.dashboard.invoices.details('1') },
],
},
{
key: 'jobs',
title: 'Jobs',
icon: 'read-cv-logo',
items: [
{ key: 'jobs:browse', title: 'Browse jobs', href: paths.dashboard.jobs.browse },
{ key: 'jobs:create', title: 'Create job', href: paths.dashboard.jobs.create },
{
key: 'jobs:company',
title: 'Company details',
href: paths.dashboard.jobs.companies.overview('1'),
matcher: { type: 'startsWith', href: '/dashboard/jobs/companies/1' },
},
],
},
{
key: 'logistics',
title: 'Logistics',
icon: 'truck',
items: [
{ key: 'logistics:metrics', title: 'Metrics', href: paths.dashboard.logistics.metrics },
{ key: 'logistics:fleet', title: 'Fleet', href: paths.dashboard.logistics.fleet },
],
},
{
key: 'blog',
title: 'Blog',
icon: 'text-align-left',
items: [
{ key: 'blog', title: 'List posts', href: paths.dashboard.blog.list },
{ key: 'blog:create', title: 'Create post', href: paths.dashboard.blog.create },
{ key: 'blog:details', title: 'Post details', href: paths.dashboard.blog.details('1') },
],
},
{
key: 'social',
title: 'Social',
icon: 'share-network',
items: [
{
key: 'social:profile',
title: 'Profile',
href: paths.dashboard.social.profile.timeline,
matcher: { type: 'startsWith', href: '/dashboard/social/profile' },
},
{ key: 'social:feed', title: 'Feed', href: paths.dashboard.social.feed },
],
},
{
key: 'academy',
title: 'Academy',
icon: 'graduation-cap',
items: [
{ key: 'academy:browse', title: 'Browse courses', href: paths.dashboard.academy.browse },
{ key: 'academy:course', title: 'Course details', href: paths.dashboard.academy.details('1') },
],
},
{ key: 'file-storage', title: 'File storage', href: paths.dashboard.fileStorage, icon: 'upload' },
{
key: 'mail',
title: 'Mail',
href: paths.dashboard.mail.list('inbox'),
icon: 'envelope-simple',
matcher: { type: 'startsWith', href: '/dashboard/mail' },
},
{
key: 'chat',
title: 'Chat',
href: paths.dashboard.chat.base,
icon: 'chats-circle',
matcher: { type: 'startsWith', href: '/dashboard/chat' },
},
{ key: 'calendar', title: 'Calendar', href: paths.dashboard.calendar, icon: 'calendar-check' },
{ key: 'tasks', title: 'Tasks', href: paths.dashboard.tasks, icon: 'kanban' },
],
},
{
key: 'other',
title: 'Other',
items: [
{
key: 'auth',
title: 'Auth',
icon: 'lock',
items: [
{
key: 'auth:sign-in',
title: 'Sign in',
items: [
{ key: 'auth:sign-in:centered', title: 'Centered', href: paths.auth.samples.signIn.centered },
{ key: 'auth:sign-in:split', title: 'Split', href: paths.auth.samples.signIn.split },
],
},
{
key: 'auth:sign-up',
title: 'Sign up',
items: [
{ key: 'auth:sign-up:centered', title: 'Centered', href: paths.auth.samples.signUp.centered },
{ key: 'auth:sign-up:split', title: 'Split', href: paths.auth.samples.signUp.split },
],
},
{
key: 'auth:reset-password',
title: 'Reset password',
items: [
{
key: 'auth:reset-password:centered',
title: 'Centered',
href: paths.auth.samples.resetPassword.centered,
},
{ key: 'auth:reset-password:split', title: 'Split', href: paths.auth.samples.resetPassword.split },
],
},
{
key: 'auth:update-password',
title: 'Update password',
items: [
{
key: 'auth:update-password:centered',
title: 'Centered',
href: paths.auth.samples.updatePassword.centered,
},
{ key: 'auth:update-password:split', title: 'Split', href: paths.auth.samples.updatePassword.split },
],
},
{
key: 'auth:verify-code',
title: 'Verify code',
items: [
{ key: 'auth:verify-code:centered', title: 'Centered', href: paths.auth.samples.verifyCode.centered },
{ key: 'auth:verify-code:split', title: 'Split', href: paths.auth.samples.verifyCode.split },
],
},
],
},
{ key: 'pricing', title: 'Pricing', href: paths.pricing, icon: 'credit-card' },
{ key: 'checkout', title: 'Checkout', href: paths.checkout, icon: 'sign-out' },
{ key: 'contact', title: 'Contact', href: paths.contact, icon: 'address-book' },
{
key: 'error',
title: 'Error',
icon: 'file-x',
items: [
{ key: 'error:not-authorized', title: 'Not authorized', href: paths.notAuthorized },
{ key: 'error:not-found', title: 'Not found', href: paths.notFound },
{ key: 'error:internal-server-error', title: 'Internal server error', href: paths.internalServerError },
],
},
],
},
{
key: 'misc',
title: 'Misc',
items: [
{
key: 'levels:level-0',
title: 'Level 0',
icon: 'align-left',
items: [
{
key: 'levels:level-1a',
title: 'Level 1a',
items: [
{
key: 'levels:level-2a',
title: 'Level 2a',
items: [
{ key: 'levels:level-3a', title: 'Level 3a' },
{ key: 'levels:level-3b', title: 'Level 3b', disabled: true },
],
},
{ key: 'levels:level-2b', title: 'Level 2b' },
],
},
{ key: 'levels:level-1b', title: 'Level 1b' },
],
},
{ key: 'disabled', title: 'Disabled', disabled: true, icon: 'warning-diamond' },
{ key: 'label', title: 'Label', icon: 'file', label: 'New' },
{ key: 'blank', title: 'Blank', href: paths.dashboard.blank, icon: 'file-dashed' },
{ key: 'external', title: 'External link', href: 'https://devias.io', external: true, icon: 'link' },
],
},
],
} satisfies LayoutConfig;

View File

@@ -0,0 +1,143 @@
'use client';
import * as React from 'react';
import Avatar from '@mui/material/Avatar';
import Box from '@mui/material/Box';
import Link from '@mui/material/Link';
import List from '@mui/material/List';
import ListItem from '@mui/material/ListItem';
import ListItemAvatar from '@mui/material/ListItemAvatar';
import ListItemButton from '@mui/material/ListItemButton';
import ListItemText from '@mui/material/ListItemText';
import Popover from '@mui/material/Popover';
import Typography from '@mui/material/Typography';
import { dayjs } from '@/lib/dayjs';
import { Presence } from '@/components/core/presence';
export interface Contact {
id: string;
name: string;
avatar?: string;
status: 'online' | 'offline' | 'away' | 'busy';
lastActivity?: Date;
}
const contacts = [
{
id: 'USR-010',
name: 'Alcides Antonio',
avatar: '/assets/avatar-10.png',
status: 'online',
lastActivity: dayjs().subtract(2, 'hour').toDate(),
},
{
id: 'USR-003',
name: 'Carson Darrin',
avatar: '/assets/avatar-3.png',
status: 'away',
lastActivity: dayjs().subtract(15, 'minute').toDate(),
},
{ id: 'USR-005', name: 'Fran Perez', avatar: '/assets/avatar-5.png', status: 'busy', lastActivity: dayjs().toDate() },
{
id: 'USR-006',
name: 'Iulia Albu',
avatar: '/assets/avatar-6.png',
status: 'online',
lastActivity: dayjs().toDate(),
},
{ id: 'USR-008', name: 'Jie Yan', avatar: '/assets/avatar-8.png', status: 'online', lastActivity: dayjs().toDate() },
{
id: 'USR-009',
name: 'Marcus Finn',
avatar: '/assets/avatar-9.png',
status: 'offline',
lastActivity: dayjs().subtract(2, 'hour').toDate(),
},
{
id: 'USR-001',
name: 'Miron Vitold',
avatar: '/assets/avatar-1.png',
status: 'online',
lastActivity: dayjs().toDate(),
},
{
id: 'USR-007',
name: 'Nasimiyu Danai',
avatar: '/assets/avatar-7.png',
status: 'busy',
lastActivity: dayjs().toDate(),
},
{
id: 'USR-011',
name: 'Omar Darobe',
avatar: '/assets/avatar-11.png',
status: 'offline',
lastActivity: dayjs().toDate(),
},
{
id: 'USR-004',
name: 'Penjani Inyene',
avatar: '/assets/avatar-4.png',
status: 'online',
lastActivity: dayjs().subtract(6, 'hour').toDate(),
},
{
id: 'USR-002',
name: 'Siegbert Gottfried',
avatar: '/assets/avatar-2.png',
status: 'away',
lastActivity: dayjs().toDate(),
},
] satisfies Contact[];
export interface ContactsPopoverProps {
anchorEl: null | Element;
contacts?: Contact[];
onClose?: () => void;
open?: boolean;
}
export function ContactsPopover({ anchorEl, onClose, open = false }: ContactsPopoverProps): React.JSX.Element {
return (
<Popover
anchorEl={anchorEl}
anchorOrigin={{ horizontal: 'right', vertical: 'bottom' }}
onClose={onClose}
open={open}
slotProps={{ paper: { sx: { width: '300px' } } }}
transformOrigin={{ horizontal: 'right', vertical: 'top' }}
>
<Box sx={{ px: 3, py: 2 }}>
<Typography variant="h6">Contacts</Typography>
</Box>
<Box sx={{ maxHeight: '400px', overflowY: 'auto', px: 1, pb: 2 }}>
<List disablePadding sx={{ '& .MuiListItemButton-root': { borderRadius: 1 } }}>
{contacts.map((contact) => (
<ListItem disablePadding key={contact.id}>
<ListItemButton>
<ListItemAvatar>
<Avatar src={contact.avatar} />
</ListItemAvatar>
<ListItemText
disableTypography
primary={
<Link color="text.primary" noWrap underline="none" variant="subtitle2">
{contact.name}
</Link>
}
/>
{contact.status !== 'offline' ? <Presence size="small" status={contact.status} /> : null}
{contact.status === 'offline' && Boolean(contact.lastActivity) ? (
<Typography color="text.secondary" sx={{ whiteSpace: 'nowrap' }} variant="caption">
{dayjs(contact.lastActivity).fromNow()}
</Typography>
) : null}
</ListItemButton>
</ListItem>
))}
</List>
</Box>
</Popover>
);
}

View File

@@ -0,0 +1,22 @@
'use client';
import * as React from 'react';
import { useSettings } from '@/hooks/use-settings';
import { HorizontalLayout } from './horizontal/horizontal-layout';
import { VerticalLayout } from './vertical/vertical-layout';
export interface DynamicLayoutProps {
children: React.ReactNode;
}
export function DynamicLayout({ children }: DynamicLayoutProps): React.JSX.Element {
const { settings } = useSettings();
return settings.layout === 'horizontal' ? (
<HorizontalLayout>{children}</HorizontalLayout>
) : (
<VerticalLayout>{children}</VerticalLayout>
);
}

View File

@@ -0,0 +1,53 @@
'use client';
import * as React from 'react';
import Box from '@mui/material/Box';
import GlobalStyles from '@mui/material/GlobalStyles';
import { useSettings } from '@/hooks/use-settings';
import { layoutConfig } from '../config';
import { MainNav } from './main-nav';
export interface HorizontalLayoutProps {
children?: React.ReactNode;
}
export function HorizontalLayout({ children }: HorizontalLayoutProps): React.JSX.Element {
const { settings } = useSettings();
return (
<React.Fragment>
<GlobalStyles
styles={{ body: { '--MainNav-zIndex': 1000, '--MobileNav-width': '320px', '--MobileNav-zIndex': 1100 } }}
/>
<Box
sx={{
bgcolor: 'var(--mui-palette-background-default)',
display: 'flex',
flexDirection: 'column',
position: 'relative',
minHeight: '100%',
}}
>
<MainNav color={settings.navColor} items={layoutConfig.navItems} />
<Box
component="main"
sx={{
'--Content-margin': '0 auto',
'--Content-maxWidth': 'var(--maxWidth-xl)',
'--Content-paddingX': '24px',
'--Content-paddingY': { xs: '24px', lg: '64px' },
'--Content-padding': 'var(--Content-paddingY) var(--Content-paddingX)',
'--Content-width': '100%',
display: 'flex',
flex: '1 1 auto',
flexDirection: 'column',
}}
>
{children}
</Box>
</Box>
</React.Fragment>
);
}

View File

@@ -0,0 +1,516 @@
'use client';
import * as React from 'react';
import RouterLink from 'next/link';
import { usePathname } from 'next/navigation';
import Avatar from '@mui/material/Avatar';
import Badge from '@mui/material/Badge';
import Box from '@mui/material/Box';
import Chip from '@mui/material/Chip';
import Divider from '@mui/material/Divider';
import IconButton from '@mui/material/IconButton';
import Stack from '@mui/material/Stack';
import Tooltip from '@mui/material/Tooltip';
import Typography from '@mui/material/Typography';
import { ArrowSquareOut as ArrowSquareOutIcon } from '@phosphor-icons/react/dist/ssr/ArrowSquareOut';
import { Bell as BellIcon } from '@phosphor-icons/react/dist/ssr/Bell';
import { CaretDown as CaretDownIcon } from '@phosphor-icons/react/dist/ssr/CaretDown';
import { CaretRight as CaretRightIcon } from '@phosphor-icons/react/dist/ssr/CaretRight';
import { List as ListIcon } from '@phosphor-icons/react/dist/ssr/List';
import { MagnifyingGlass as MagnifyingGlassIcon } from '@phosphor-icons/react/dist/ssr/MagnifyingGlass';
import { Users as UsersIcon } from '@phosphor-icons/react/dist/ssr/Users';
import { useTranslation } from 'next-i18next';
import type { NavItemConfig } from '@/types/nav';
import type { NavColor } from '@/types/settings';
import type { User } from '@/types/user';
import { paths } from '@/paths';
import { isNavItemActive } from '@/lib/is-nav-item-active';
import { useDialog } from '@/hooks/use-dialog';
import { usePopover } from '@/hooks/use-popover';
import { useSettings } from '@/hooks/use-settings';
import { Dropdown } from '@/components/core/dropdown/dropdown';
import { DropdownPopover } from '@/components/core/dropdown/dropdown-popover';
import { DropdownTrigger } from '@/components/core/dropdown/dropdown-trigger';
import { Logo } from '@/components/core/logo';
import { SearchDialog } from '@/components/dashboard/layout/search-dialog';
import type { ColorScheme } from '@/styles/theme/types';
import { ContactsPopover } from '../contacts-popover';
import { languageFlags, LanguagePopover } from '../language-popover';
import type { Language } from '../language-popover';
import { MobileNav } from '../mobile-nav';
import { icons } from '../nav-icons';
import { NotificationsPopover } from '../notifications-popover';
import { UserPopover } from '../user-popover/user-popover';
import { WorkspacesSwitch } from '../workspaces-switch';
import { navColorStyles } from './styles';
const logoColors = {
dark: { blend_in: 'light', discrete: 'light', evident: 'light' },
light: { blend_in: 'dark', discrete: 'dark', evident: 'light' },
} as Record<ColorScheme, Record<NavColor, 'dark' | 'light'>>;
export interface MainNavProps {
color?: NavColor;
items?: NavItemConfig[];
}
export function MainNav({ color = 'evident', items = [] }: MainNavProps): React.JSX.Element {
const pathname = usePathname();
const [openNav, setOpenNav] = React.useState<boolean>(false);
const {
settings: { colorScheme = 'light' },
} = useSettings();
const styles = navColorStyles[colorScheme][color];
const logoColor = logoColors[colorScheme][color];
return (
<React.Fragment>
<Box
component="header"
sx={{
...styles,
bgcolor: 'var(--MainNav-background)',
border: 'var(--MainNav-border)',
color: 'var(--MainNav-color)',
left: 0,
position: 'sticky',
top: 0,
zIndex: 'var(--MainNav-zIndex)',
}}
>
<Box
sx={{
display: 'flex',
flex: '1 1 auto',
minHeight: 'var(--MainNav-height, 72px)',
px: { xs: 2, sm: 3 },
py: 1,
}}
>
<Stack direction="row" spacing={2} sx={{ alignItems: 'center', flex: '1 1 auto' }}>
<IconButton
onClick={(): void => {
setOpenNav(true);
}}
sx={{ display: { md: 'none' } }}
>
<ListIcon color="var(--NavItem-icon-color)" />
</IconButton>
<Box component={RouterLink} href={paths.home} sx={{ display: { xs: 'none', md: 'inline-block' } }}>
<Logo color={logoColor} height={32} width={122} />
</Box>
<Box sx={{ display: { xs: 'none', md: 'block' } }}>
<WorkspacesSwitch />
</Box>
</Stack>
<Stack
direction="row"
spacing={2}
sx={{ alignItems: 'center', flex: '1 1 auto', justifyContent: 'flex-end' }}
>
<SearchButton />
<NotificationsButton />
<ContactsButton />
<Divider
flexItem
orientation="vertical"
sx={{ borderColor: 'var(--MainNav-divider)', display: { xs: 'none', md: 'block' } }}
/>
<LanguageSwitch />
<UserButton />
</Stack>
</Box>
<Box
component="nav"
sx={{
borderTop: '1px solid var(--MainNav-divider)',
display: { xs: 'none', md: 'block' },
minHeight: '56px',
overflowX: 'auto',
}}
>
{renderNavGroups({ items, pathname })}
</Box>
</Box>
<MobileNav
items={items}
onClose={() => {
setOpenNav(false);
}}
open={openNav}
/>
</React.Fragment>
);
}
function SearchButton(): React.JSX.Element {
const dialog = useDialog();
return (
<React.Fragment>
<Tooltip title="Search">
<IconButton onClick={dialog.handleOpen} sx={{ display: { xs: 'none', md: 'inline-flex' } }}>
<MagnifyingGlassIcon color="var(--NavItem-icon-color)" />
</IconButton>
</Tooltip>
<SearchDialog onClose={dialog.handleClose} open={dialog.open} />
</React.Fragment>
);
}
function NotificationsButton(): React.JSX.Element {
const popover = usePopover<HTMLButtonElement>();
return (
<React.Fragment>
<Tooltip title="Notifications">
<Badge
color="error"
sx={{ '& .MuiBadge-dot': { borderRadius: '50%', height: '10px', right: '6px', top: '6px', width: '10px' } }}
variant="dot"
>
<IconButton onClick={popover.handleOpen} ref={popover.anchorRef}>
<BellIcon color="var(--NavItem-icon-color)" />
</IconButton>
</Badge>
</Tooltip>
<NotificationsPopover anchorEl={popover.anchorRef.current} onClose={popover.handleClose} open={popover.open} />
</React.Fragment>
);
}
function ContactsButton(): React.JSX.Element {
const popover = usePopover<HTMLButtonElement>();
return (
<React.Fragment>
<Tooltip title="Contacts">
<IconButton onClick={popover.handleOpen} ref={popover.anchorRef}>
<UsersIcon color="var(--NavItem-icon-color)" />
</IconButton>
</Tooltip>
<ContactsPopover anchorEl={popover.anchorRef.current} onClose={popover.handleClose} open={popover.open} />
</React.Fragment>
);
}
function LanguageSwitch(): React.JSX.Element {
const { i18n } = useTranslation();
const popover = usePopover<HTMLButtonElement>();
const language = (i18n.language || 'en') as Language;
const flag = languageFlags[language];
return (
<React.Fragment>
<Tooltip title="Language">
<IconButton
onClick={popover.handleOpen}
ref={popover.anchorRef}
sx={{ display: { xs: 'none', md: 'inline-flex' } }}
>
<Box sx={{ height: '24px', width: '24px' }}>
<Box alt={language} component="img" src={flag} sx={{ height: 'auto', width: '100%' }} />
</Box>
</IconButton>
</Tooltip>
<LanguagePopover anchorEl={popover.anchorRef.current} onClose={popover.handleClose} open={popover.open} />
</React.Fragment>
);
}
const user = {
id: 'USR-000',
name: 'Sofia Rivers',
avatar: '/assets/avatar.png',
email: 'sofia@devias.io',
} satisfies User;
function UserButton(): React.JSX.Element {
const popover = usePopover<HTMLButtonElement>();
return (
<React.Fragment>
<Box
component="button"
onClick={popover.handleOpen}
ref={popover.anchorRef}
sx={{ border: 'none', background: 'transparent', cursor: 'pointer', p: 0 }}
>
<Badge
anchorOrigin={{ horizontal: 'right', vertical: 'bottom' }}
color="success"
sx={{
'& .MuiBadge-dot': {
border: '2px solid var(--MainNav-background)',
borderRadius: '50%',
bottom: '6px',
height: '12px',
right: '6px',
width: '12px',
},
}}
variant="dot"
>
<Avatar src={user.avatar} />
</Badge>
</Box>
<UserPopover anchorEl={popover.anchorRef.current} onClose={popover.handleClose} open={popover.open} />
</React.Fragment>
);
}
function renderNavGroups({ items = [], pathname }: { items?: NavItemConfig[]; pathname: string }): React.JSX.Element {
const children = items.reduce((acc: React.ReactNode[], curr: NavItemConfig): React.ReactNode[] => {
acc.push(
<Box component="li" key={curr.key} sx={{ flex: '0 0 auto' }}>
{renderNavItems({ pathname, items: curr.items })}
</Box>
);
return acc;
}, []);
return (
<Stack component="ul" direction="row" spacing={2} sx={{ listStyle: 'none', m: 0, p: '8px 12px' }}>
{children}
</Stack>
);
}
function renderNavItems({ items = [], pathname }: { items?: NavItemConfig[]; pathname: string }): React.JSX.Element {
const children = items.reduce((acc: React.ReactNode[], curr: NavItemConfig): React.ReactNode[] => {
const { key, ...item } = curr;
acc.push(<NavItem key={key} pathname={pathname} {...item} />);
return acc;
}, []);
return (
<Stack component="ul" direction="row" spacing={2} sx={{ listStyle: 'none', m: 0, p: 0 }}>
{children}
</Stack>
);
}
interface NavItemProps extends NavItemConfig {
pathname: string;
}
function NavItem({
disabled,
external,
items,
href,
icon,
label,
matcher,
pathname,
title,
}: NavItemProps): React.JSX.Element {
const active = isNavItemActive({ disabled, external, href, matcher, pathname });
const Icon = icon ? icons[icon] : null;
const isBranch = Boolean(items);
const element = (
<Box component="li" sx={{ userSelect: 'none' }}>
<Box
{...(isBranch
? { role: 'button' }
: {
...(href
? {
component: external ? 'a' : RouterLink,
href,
target: external ? '_blank' : undefined,
rel: external ? 'noreferrer' : undefined,
}
: { role: 'button' }),
})}
sx={{
alignItems: 'center',
borderRadius: 1,
color: 'var(--NavItem-color)',
cursor: 'pointer',
display: 'flex',
flex: '0 0 auto',
gap: 1,
p: '6px 16px',
textDecoration: 'none',
whiteSpace: 'nowrap',
...(disabled && {
bgcolor: 'var(--NavItem-disabled-background)',
color: 'var(--NavItem-disabled-color)',
cursor: 'not-allowed',
}),
...(active && { bgcolor: 'var(--NavItem-active-background)', color: 'var(--NavItem-active-color)' }),
'&:hover': {
...(!disabled &&
!active && { bgcolor: 'var(--NavItem-hover-background)', color: 'var(--NavItem-hover-color)' }),
},
}}
tabIndex={0}
>
{Icon ? (
<Box sx={{ alignItems: 'center', display: 'flex', justifyContent: 'center', flex: '0 0 auto' }}>
<Icon
fill={active ? 'var(--NavItem-icon-active-color)' : 'var(--NavItem-icon-color)'}
fontSize="var(--icon-fontSize-md)"
weight={active ? 'fill' : undefined}
/>
</Box>
) : null}
<Box sx={{ flex: '1 1 auto' }}>
<Typography
component="span"
sx={{ color: 'inherit', fontSize: '0.875rem', fontWeight: 500, lineHeight: '28px' }}
>
{title}
</Typography>
</Box>
{label ? <Chip color="primary" label={label} size="small" /> : null}
{external ? (
<Box sx={{ alignItems: 'center', display: 'flex', flex: '0 0 auto' }}>
<ArrowSquareOutIcon color="var(--NavItem-icon-color)" fontSize="var(--icon-fontSize-sm)" />
</Box>
) : null}
{isBranch ? (
<Box sx={{ alignItems: 'center', display: 'flex', flex: '0 0 auto' }}>
<CaretDownIcon fontSize="var(--icon-fontSize-sm)" />
</Box>
) : null}
</Box>
</Box>
);
if (items) {
return (
<Dropdown>
<DropdownTrigger>{element}</DropdownTrigger>
<DropdownPopover
PaperProps={{ sx: { minWidth: '200px', p: 1 } }}
anchorOrigin={{ horizontal: 'left', vertical: 'bottom' }}
>
{renderDropdownItems({ pathname, items })}
</DropdownPopover>
</Dropdown>
);
}
return element;
}
function renderDropdownItems({
items = [],
pathname,
}: {
items?: NavItemConfig[];
pathname: string;
}): React.JSX.Element {
const children = items.reduce((acc: React.ReactNode[], curr: NavItemConfig): React.ReactNode[] => {
const { key, ...item } = curr;
acc.push(<DropdownItem key={key} pathname={pathname} {...item} />);
return acc;
}, []);
return (
<Stack component="ul" spacing={1} sx={{ listStyle: 'none', m: 0, p: 0 }}>
{children}
</Stack>
);
}
interface DropdownItemProps extends NavItemConfig {
pathname: string;
}
function DropdownItem({
disabled,
external,
items,
href,
matcher,
pathname,
title,
}: DropdownItemProps): React.JSX.Element {
const active = isNavItemActive({ disabled, external, href, matcher, pathname });
const isBranch = Boolean(items);
const element = (
<Box component="li" sx={{ userSelect: 'none' }}>
<Box
{...(isBranch
? { role: 'button' }
: {
...(href
? {
component: external ? 'a' : RouterLink,
href,
target: external ? '_blank' : undefined,
rel: external ? 'noreferrer' : undefined,
}
: { role: 'button' }),
})}
sx={{
alignItems: 'center',
borderRadius: 1,
color: 'var(--NavItem-color)',
cursor: 'pointer',
display: 'flex',
flex: '0 0 auto',
p: '6px 16px',
textDecoration: 'none',
whiteSpace: 'nowrap',
...(disabled && {
bgcolor: 'var(--mui-palette-action-disabledBackground)',
color: 'var(--mui-action-disabled)',
cursor: 'not-allowed',
}),
...(active && { bgcolor: 'var(--mui-palette-action-selected)', color: 'var(--mui-palette-action-active)' }),
'&:hover': {
...(!disabled &&
!active && { bgcolor: 'var(--mui-palette-action-hover)', color: 'var(--mui-palette-action-color)' }),
},
}}
tabIndex={0}
>
<Box sx={{ flex: '1 1 auto' }}>
<Typography
component="span"
sx={{ color: 'inherit', fontSize: '0.875rem', fontWeight: 500, lineHeight: '28px' }}
>
{title}
</Typography>
</Box>
{isBranch ? (
<Box sx={{ flex: '0 0 auto' }}>
<CaretRightIcon fontSize="var(--icon-fontSize-sm)" />
</Box>
) : null}
</Box>
</Box>
);
if (items) {
return (
<Dropdown>
<DropdownTrigger>{element}</DropdownTrigger>
<DropdownPopover
PaperProps={{ sx: { minWidth: '200px', p: 1 } }}
anchorOrigin={{ horizontal: 'right', vertical: 'top' }}
>
{renderDropdownItems({ pathname, items })}
</DropdownPopover>
</Dropdown>
);
}
return element;
}

View File

@@ -0,0 +1,130 @@
import type { NavColor } from '@/types/settings';
import type { ColorScheme } from '@/styles/theme/types';
export const navColorStyles = {
dark: {
blend_in: {
'--MainNav-background': 'var(--mui-palette-background-default)',
'--MainNav-color': 'var(--mui-palette-common-white)',
'--MainNav-border': '1px solid var(--mui-palette-neutral-700)',
'--MainNav-divider': 'var(--mui-palette-neutral-700)',
'--NavItem-color': 'var(--mui-palette-neutral-300)',
'--NavItem-hover-background': 'var(--mui-palette-action-hover)',
'--NavItem-active-background': 'var(--mui-palette-primary-main)',
'--NavItem-active-color': 'var(--mui-palette-primary-contrastText)',
'--NavItem-disabled-color': 'var(--mui-palette-neutral-500)',
'--NavItem-icon-color': 'var(--mui-palette-neutral-400)',
'--NavItem-icon-active-color': 'var(--mui-palette-primary-contrastText)',
'--NavItem-icon-disabled-color': 'var(--mui-palette-neutral-600)',
'--NavItem-expand-color': 'var(--mui-palette-neutral-400)',
'--Workspaces-background': 'var(--mui-palette-neutral-950)',
'--Workspaces-border-color': 'var(--mui-palette-neutral-700)',
'--Workspaces-title-color': 'var(--mui-palette-neutral-400)',
'--Workspaces-name-color': 'var(--mui-palette-neutral-300)',
'--Workspaces-expand-color': 'var(--mui-palette-neutral-400)',
},
discrete: {
'--MainNav-background': 'var(--mui-palette-neutral-900)',
'--MainNav-color': 'var(--mui-palette-common-white)',
'--MainNav-border': '1px solid var(--mui-palette-neutral-700)',
'--MainNav-divider': 'var(--mui-palette-neutral-700)',
'--NavItem-color': 'var(--mui-palette-neutral-300)',
'--NavItem-hover-background': 'var(--mui-palette-action-hover)',
'--NavItem-active-background': 'var(--mui-palette-primary-main)',
'--NavItem-active-color': 'var(--mui-palette-primary-contrastText)',
'--NavItem-disabled-color': 'var(--mui-palette-neutral-500)',
'--NavItem-icon-color': 'var(--mui-palette-neutral-400)',
'--NavItem-icon-active-color': 'var(--mui-palette-primary-contrastText)',
'--NavItem-icon-disabled-color': 'var(--mui-palette-neutral-600)',
'--NavItem-expand-color': 'var(--mui-palette-neutral-400)',
'--Workspaces-background': 'var(--mui-palette-neutral-950)',
'--Workspaces-border-color': 'var(--mui-palette-neutral-700)',
'--Workspaces-title-color': 'var(--mui-palette-neutral-400)',
'--Workspaces-name-color': 'var(--mui-palette-neutral-300)',
'--Workspaces-expand-color': 'var(--mui-palette-neutral-400)',
},
evident: {
'--MainNav-background': 'var(--mui-palette-neutral-800)',
'--MainNav-color': 'var(--mui-palette-common-white)',
'--MainNav-border': 'none',
'--MainNav-divider': 'var(--mui-palette-neutral-700)',
'--NavItem-color': 'var(--mui-palette-neutral-300)',
'--NavItem-hover-background': 'rgba(255, 255, 255, 0.04)',
'--NavItem-active-background': 'var(--mui-palette-primary-main)',
'--NavItem-active-color': 'var(--mui-palette-primary-contrastText)',
'--NavItem-disabled-color': 'var(--mui-palette-neutral-500)',
'--NavItem-icon-color': 'var(--mui-palette-neutral-400)',
'--NavItem-icon-active-color': 'var(--mui-palette-primary-contrastText)',
'--NavItem-icon-disabled-color': 'var(--mui-palette-neutral-600)',
'--NavItem-expand-color': 'var(--mui-palette-neutral-400)',
'--Workspaces-background': 'var(--mui-palette-neutral-950)',
'--Workspaces-border-color': 'var(--mui-palette-neutral-700)',
'--Workspaces-title-color': 'var(--mui-palette-neutral-400)',
'--Workspaces-name-color': 'var(--mui-palette-neutral-300)',
'--Workspaces-expand-color': 'var(--mui-palette-neutral-400)',
},
},
light: {
blend_in: {
'--MainNav-background': 'var(--mui-palette-background-default)',
'--MainNav-color': 'var(--mui-palette-text-primary)',
'--MainNav-border': '1px solid var(--mui-palette-divider)',
'--MainNav-divider': 'var(--mui-palette-divider)',
'--NavItem-color': 'var(--mui-palette-neutral-600)',
'--NavItem-hover-background': 'var(--mui-palette-action-hover)',
'--NavItem-active-background': 'var(--mui-palette-primary-main)',
'--NavItem-active-color': 'var(--mui-palette-primary-contrastText)',
'--NavItem-disabled-color': 'var(--mui-palette-neutral-500)',
'--NavItem-icon-color': 'var(--mui-palette-neutral-500)',
'--NavItem-icon-active-color': 'var(--mui-palette-primary-contrastText)',
'--NavItem-icon-disabled-color': 'var(--mui-palette-neutral-500)',
'--NavItem-expand-color': 'var(--mui-palette-neutral-500)',
'--Workspaces-background': 'var(--mui-palette-neutral-100)',
'--Workspaces-border-color': 'var(--mui-palette-divider)',
'--Workspaces-title-color': 'var(--mui-palette-neutra-400)',
'--Workspaces-name-color': 'var(--mui-palette-neutral-900)',
'--Workspaces-expand-color': 'var(--mui-palette-neutral-400)',
},
discrete: {
'--MainNav-background': 'var(--mui-palette-neutral-50)',
'--MainNav-color': 'var(--mui-palette-text-primary)',
'--MainNav-border': '1px solid var(--mui-palette-divider)',
'--MainNav-divider': 'var(--mui-palette-divider)',
'--NavGroup-title-color': 'var(--mui-palette-neutral-600)',
'--NavItem-color': 'var(--mui-palette-neutral-600)',
'--NavItem-hover-background': 'var(--mui-palette-action-hover)',
'--NavItem-active-background': 'var(--mui-palette-primary-main)',
'--NavItem-active-color': 'var(--mui-palette-primary-contrastText)',
'--NavItem-disabled-color': 'var(--mui-palette-neutral-500)',
'--NavItem-icon-color': 'var(--mui-palette-neutral-500)',
'--NavItem-icon-active-color': 'var(--mui-palette-primary-contrastText)',
'--NavItem-icon-disabled-color': 'var(--mui-palette-neutral-500)',
'--NavItem-expand-color': 'var(--mui-palette-neutral-500)',
'--Workspaces-background': 'var(--mui-palette-neutral-100)',
'--Workspaces-border-color': 'var(--mui-palette-divider)',
'--Workspaces-title-color': 'var(--mui-palette-neutra-400)',
'--Workspaces-name-color': 'var(--mui-palette-neutral-900)',
'--Workspaces-expand-color': 'var(--mui-palette-neutral-400)',
},
evident: {
'--MainNav-background': 'var(--mui-palette-neutral-950)',
'--MainNav-color': 'var(--mui-palette-common-white)',
'--MainNav-border': 'none',
'--MainNav-divider': 'var(--mui-palette-neutral-700)',
'--NavItem-color': 'var(--mui-palette-neutral-300)',
'--NavItem-hover-background': 'rgba(255, 255, 255, 0.04)',
'--NavItem-active-background': 'var(--mui-palette-primary-main)',
'--NavItem-active-color': 'var(--mui-palette-primary-contrastText)',
'--NavItem-disabled-color': 'var(--mui-palette-neutral-500)',
'--NavItem-icon-color': 'var(--mui-palette-neutral-400)',
'--NavItem-icon-active-color': 'var(--mui-palette-primary-contrastText)',
'--NavItem-icon-disabled-color': 'var(--mui-palette-neutral-600)',
'--NavItem-expand-color': 'var(--mui-palette-neutral-400)',
'--Workspaces-background': 'var(--mui-palette-neutral-950)',
'--Workspaces-border-color': 'var(--mui-palette-neutral-700)',
'--Workspaces-title-color': 'var(--mui-palette-neutral-400)',
'--Workspaces-name-color': 'var(--mui-palette-neutral-300)',
'--Workspaces-expand-color': 'var(--mui-palette-neutral-400)',
},
},
} satisfies Record<ColorScheme, Record<NavColor, Record<string, string>>>;

View File

@@ -0,0 +1,77 @@
'use client';
import * as React from 'react';
import Box from '@mui/material/Box';
import ListItemIcon from '@mui/material/ListItemIcon';
import Menu from '@mui/material/Menu';
import MenuItem from '@mui/material/MenuItem';
import Typography from '@mui/material/Typography';
import { useTranslation } from 'react-i18next';
import { toast } from '@/components/core/toaster';
export type Language = 'en' | 'de' | 'es';
export const languageFlags = {
en: '/assets/flag-uk.svg',
de: '/assets/flag-de.svg',
es: '/assets/flag-es.svg',
} as const;
const languageOptions = {
en: { icon: '/assets/flag-uk.svg', label: 'English' },
de: { icon: '/assets/flag-de.svg', label: 'German' },
es: { icon: '/assets/flag-es.svg', label: 'Spanish' },
} as const;
export interface LanguagePopoverProps {
anchorEl: null | Element;
onClose?: () => void;
open?: boolean;
}
export function LanguagePopover({ anchorEl, onClose, open = false }: LanguagePopoverProps): React.JSX.Element {
const { i18n, t } = useTranslation();
const handleChange = React.useCallback(
async (language: Language): Promise<void> => {
onClose?.();
await i18n.changeLanguage(language);
toast.success(t('languageChanged'));
},
[onClose, i18n, t]
);
return (
<Menu
anchorEl={anchorEl}
anchorOrigin={{ horizontal: 'right', vertical: 'bottom' }}
onClose={onClose}
open={open}
slotProps={{ paper: { sx: { width: '220px' } } }}
transformOrigin={{ horizontal: 'right', vertical: 'top' }}
>
{(Object.keys(languageOptions) as Language[]).map((language) => {
const option = languageOptions[language];
return (
<MenuItem
key={language}
onClick={(): void => {
handleChange(language).catch(() => {
// ignore
});
}}
>
<ListItemIcon>
<Box sx={{ height: '28px', width: '28px' }}>
<Box alt={option.label} component="img" src={option.icon} sx={{ height: 'auto', width: '100%' }} />
</Box>
</ListItemIcon>
<Typography variant="subtitle2">{option.label}</Typography>
</MenuItem>
);
})}
</Menu>
);
}

View File

@@ -0,0 +1,282 @@
import * as React from 'react';
import RouterLink from 'next/link';
import { usePathname } from 'next/navigation';
import Box from '@mui/material/Box';
import Chip from '@mui/material/Chip';
import Drawer from '@mui/material/Drawer';
import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography';
import { ArrowSquareOut as ArrowSquareOutIcon } from '@phosphor-icons/react/dist/ssr/ArrowSquareOut';
import { CaretDown as CaretDownIcon } from '@phosphor-icons/react/dist/ssr/CaretDown';
import { CaretRight as CaretRightIcon } from '@phosphor-icons/react/dist/ssr/CaretRight';
import type { NavItemConfig } from '@/types/nav';
import { paths } from '@/paths';
import { isNavItemActive } from '@/lib/is-nav-item-active';
import { Logo } from '@/components/core/logo';
import { icons } from './nav-icons';
import { WorkspacesSwitch } from './workspaces-switch';
export interface MobileNavProps {
onClose?: () => void;
open?: boolean;
items?: NavItemConfig[];
}
export function MobileNav({ items = [], open, onClose }: MobileNavProps): React.JSX.Element {
const pathname = usePathname();
return (
<Drawer
PaperProps={{
sx: {
'--MobileNav-background': 'var(--mui-palette-neutral-950)',
'--MobileNav-color': 'var(--mui-palette-common-white)',
'--NavGroup-title-color': 'var(--mui-palette-neutral-400)',
'--NavItem-color': 'var(--mui-palette-neutral-300)',
'--NavItem-hover-background': 'rgba(255, 255, 255, 0.04)',
'--NavItem-active-background': 'var(--mui-palette-primary-main)',
'--NavItem-active-color': 'var(--mui-palette-primary-contrastText)',
'--NavItem-disabled-color': 'var(--mui-palette-neutral-500)',
'--NavItem-icon-color': 'var(--mui-palette-neutral-400)',
'--NavItem-icon-active-color': 'var(--mui-palette-primary-contrastText)',
'--NavItem-icon-disabled-color': 'var(--mui-palette-neutral-600)',
'--NavItem-expand-color': 'var(--mui-palette-neutral-400)',
'--NavItem-children-border': 'var(--mui-palette-neutral-700)',
'--NavItem-children-indicator': 'var(--mui-palette-neutral-400)',
'--Workspaces-background': 'var(--mui-palette-neutral-950)',
'--Workspaces-border-color': 'var(--mui-palette-neutral-700)',
'--Workspaces-title-color': 'var(--mui-palette-neutral-400)',
'--Workspaces-name-color': 'var(--mui-palette-neutral-300)',
'--Workspaces-expand-color': 'var(--mui-palette-neutral-400)',
bgcolor: 'var(--MobileNav-background)',
color: 'var(--MobileNav-color)',
display: 'flex',
flexDirection: 'column',
maxWidth: '100%',
width: 'var(--MobileNav-width)',
zIndex: 'var(--MobileNav-zIndex)',
scrollbarWidth: 'none',
'&::-webkit-scrollbar': { display: 'none' },
},
}}
onClose={onClose}
open={open}
>
<Stack spacing={2} sx={{ p: 2 }}>
<div>
<Box component={RouterLink} href={paths.home} sx={{ display: 'inline-flex' }}>
<Logo color="light" height={32} width={122} />
</Box>
</div>
<WorkspacesSwitch />
</Stack>
<Box component="nav" sx={{ flex: '1 1 auto', p: 2 }}>
{renderNavGroups({ items, onClose, pathname })}
</Box>
</Drawer>
);
}
function renderNavGroups({
items,
onClose,
pathname,
}: {
items: NavItemConfig[];
onClose?: () => void;
pathname: string;
}): React.JSX.Element {
const children = items.reduce((acc: React.ReactNode[], curr: NavItemConfig): React.ReactNode[] => {
acc.push(
<Stack component="li" key={curr.key} spacing={1.5}>
{curr.title ? (
<div>
<Typography sx={{ color: 'var(--NavGroup-title-color)', fontSize: '0.875rem', fontWeight: 500 }}>
{curr.title}
</Typography>
</div>
) : null}
<div>{renderNavItems({ depth: 0, items: curr.items, onClose, pathname })}</div>
</Stack>
);
return acc;
}, []);
return (
<Stack component="ul" spacing={2} sx={{ listStyle: 'none', m: 0, p: 0 }}>
{children}
</Stack>
);
}
function renderNavItems({
depth = 0,
items = [],
onClose,
pathname,
}: {
depth: number;
items?: NavItemConfig[];
onClose?: () => void;
pathname: string;
}): React.JSX.Element {
const children = items.reduce((acc: React.ReactNode[], curr: NavItemConfig): React.ReactNode[] => {
const { items: childItems, key, ...item } = curr;
const forceOpen = childItems
? Boolean(childItems.find((childItem) => childItem.href && pathname.startsWith(childItem.href)))
: false;
acc.push(
<NavItem depth={depth} forceOpen={forceOpen} key={key} onClose={onClose} pathname={pathname} {...item}>
{childItems ? renderNavItems({ depth: depth + 1, items: childItems, onClose, pathname }) : null}
</NavItem>
);
return acc;
}, []);
return (
<Stack component="ul" data-depth={depth} spacing={1} sx={{ listStyle: 'none', m: 0, p: 0 }}>
{children}
</Stack>
);
}
interface NavItemProps extends Omit<NavItemConfig, 'items'> {
children?: React.ReactNode;
depth: number;
forceOpen?: boolean;
onClose?: () => void;
pathname: string;
}
function NavItem({
children,
depth,
disabled,
external,
forceOpen = false,
href,
icon,
label,
matcher,
onClose,
pathname,
title,
}: NavItemProps): React.JSX.Element {
const [open, setOpen] = React.useState<boolean>(forceOpen);
const active = isNavItemActive({ disabled, external, href, matcher, pathname });
const Icon = icon ? icons[icon] : null;
const ExpandIcon = open ? CaretDownIcon : CaretRightIcon;
const isBranch = children && !href;
const showChildren = Boolean(children && open);
return (
<Box component="li" data-depth={depth} sx={{ userSelect: 'none' }}>
<Box
{...(isBranch
? {
onClick: (): void => {
setOpen(!open);
},
onKeyUp: (event: React.KeyboardEvent<HTMLElement>): void => {
if (event.key === 'Enter' || event.key === ' ') {
setOpen(!open);
}
},
role: 'button',
}
: {
...(href
? {
component: external ? 'a' : RouterLink,
href,
target: external ? '_blank' : undefined,
rel: external ? 'noreferrer' : undefined,
onClick: (): void => {
onClose?.();
},
}
: { role: 'button' }),
})}
sx={{
alignItems: 'center',
borderRadius: 1,
color: 'var(--NavItem-color)',
cursor: 'pointer',
display: 'flex',
flex: '0 0 auto',
gap: 1,
p: '6px 16px',
position: 'relative',
textDecoration: 'none',
whiteSpace: 'nowrap',
...(disabled && {
bgcolor: 'var(--NavItem-disabled-background)',
color: 'var(--NavItem-disabled-color)',
cursor: 'not-allowed',
}),
...(active && {
bgcolor: 'var(--NavItem-active-background)',
color: 'var(--NavItem-active-color)',
...(depth > 0 && {
'&::before': {
bgcolor: 'var(--NavItem-children-indicator)',
borderRadius: '2px',
content: '" "',
height: '20px',
left: '-14px',
position: 'absolute',
width: '3px',
},
}),
}),
...(open && { color: 'var(--NavItem-open-color)' }),
'&:hover': {
...(!disabled &&
!active && { bgcolor: 'var(--NavItem-hover-background)', color: 'var(--NavItem-hover-color)' }),
},
}}
tabIndex={0}
>
<Box sx={{ alignItems: 'center', display: 'flex', justifyContent: 'center', flex: '0 0 auto' }}>
{Icon ? (
<Icon
fill={active ? 'var(--NavItem-icon-active-color)' : 'var(--NavItem-icon-color)'}
fontSize="var(--icon-fontSize-md)"
weight={forceOpen || active ? 'fill' : undefined}
/>
) : null}
</Box>
<Box sx={{ flex: '1 1 auto' }}>
<Typography
component="span"
sx={{ color: 'inherit', fontSize: '0.875rem', fontWeight: 500, lineHeight: '28px' }}
>
{title}
</Typography>
</Box>
{label ? <Chip color="primary" label={label} size="small" /> : null}
{external ? (
<Box sx={{ alignItems: 'center', display: 'flex', flex: '0 0 auto' }}>
<ArrowSquareOutIcon color="var(--NavItem-icon-color)" fontSize="var(--icon-fontSize-sm)" />
</Box>
) : null}
{isBranch ? (
<Box sx={{ alignItems: 'center', display: 'flex', flex: '0 0 auto' }}>
<ExpandIcon color="var(--NavItem-expand-color)" fontSize="var(--icon-fontSize-sm)" />
</Box>
) : null}
</Box>
{showChildren ? (
<Box sx={{ pl: '24px' }}>
<Box sx={{ borderLeft: '1px solid var(--NavItem-children-border)', pl: '12px' }}>{children}</Box>
</Box>
) : null}
</Box>
);
}

View File

@@ -0,0 +1,62 @@
import type { Icon } from '@phosphor-icons/react/dist/lib/types';
import { AddressBook as AddressBookIcon } from '@phosphor-icons/react/dist/ssr/AddressBook';
import { AlignLeft as AlignLeftIcon } from '@phosphor-icons/react/dist/ssr/AlignLeft';
import { CalendarCheck as CalendarCheckIcon } from '@phosphor-icons/react/dist/ssr/CalendarCheck';
import { ChartPie as ChartPieIcon } from '@phosphor-icons/react/dist/ssr/ChartPie';
import { ChatsCircle as ChatsCircleIcon } from '@phosphor-icons/react/dist/ssr/ChatsCircle';
import { CreditCard as CreditCardIcon } from '@phosphor-icons/react/dist/ssr/CreditCard';
import { Cube as CubeIcon } from '@phosphor-icons/react/dist/ssr/Cube';
import { CurrencyEth as CurrencyEthIcon } from '@phosphor-icons/react/dist/ssr/CurrencyEth';
import { EnvelopeSimple as EnvelopeSimpleIcon } from '@phosphor-icons/react/dist/ssr/EnvelopeSimple';
import { File as FileIcon } from '@phosphor-icons/react/dist/ssr/File';
import { FileDashed as FileDashedIcon } from '@phosphor-icons/react/dist/ssr/FileDashed';
import { FileX as FileXIcon } from '@phosphor-icons/react/dist/ssr/FileX';
import { Gear as GearIcon } from '@phosphor-icons/react/dist/ssr/Gear';
import { GraduationCap as GraduationCapIcon } from '@phosphor-icons/react/dist/ssr/GraduationCap';
import { House as HouseIcon } from '@phosphor-icons/react/dist/ssr/House';
import { Kanban as KanbanIcon } from '@phosphor-icons/react/dist/ssr/Kanban';
import { Link as LinkIcon } from '@phosphor-icons/react/dist/ssr/Link';
import { Lock as LockIcon } from '@phosphor-icons/react/dist/ssr/Lock';
import { ReadCvLogo as ReadCvLogoIcon } from '@phosphor-icons/react/dist/ssr/ReadCvLogo';
import { Receipt as ReceiptIcon } from '@phosphor-icons/react/dist/ssr/Receipt';
import { ShareNetwork as ShareNetworkIcon } from '@phosphor-icons/react/dist/ssr/ShareNetwork';
import { ShoppingBagOpen as ShoppingBagOpenIcon } from '@phosphor-icons/react/dist/ssr/ShoppingBagOpen';
import { ShoppingCartSimple as ShoppingCartSimpleIcon } from '@phosphor-icons/react/dist/ssr/ShoppingCartSimple';
import { SignOut as SignOutIcon } from '@phosphor-icons/react/dist/ssr/SignOut';
import { TextAlignLeft as TextAlignLeftIcon } from '@phosphor-icons/react/dist/ssr/TextAlignLeft';
import { Truck as TruckIcon } from '@phosphor-icons/react/dist/ssr/Truck';
import { Upload as UploadIcon } from '@phosphor-icons/react/dist/ssr/Upload';
import { Users as UsersIcon } from '@phosphor-icons/react/dist/ssr/Users';
import { WarningDiamond as WarningDiamondIcon } from '@phosphor-icons/react/dist/ssr/WarningDiamond';
export const icons = {
'address-book': AddressBookIcon,
'align-left': AlignLeftIcon,
'calendar-check': CalendarCheckIcon,
'chart-pie': ChartPieIcon,
'chats-circle': ChatsCircleIcon,
'credit-card': CreditCardIcon,
'currency-eth': CurrencyEthIcon,
'envelope-simple': EnvelopeSimpleIcon,
'file-dashed': FileDashedIcon,
'file-x': FileXIcon,
'graduation-cap': GraduationCapIcon,
'read-cv-logo': ReadCvLogoIcon,
'share-network': ShareNetworkIcon,
'shopping-bag-open': ShoppingBagOpenIcon,
'shopping-cart-simple': ShoppingCartSimpleIcon,
'sign-out': SignOutIcon,
'text-align-left': TextAlignLeftIcon,
'warning-diamond': WarningDiamondIcon,
cube: CubeIcon,
file: FileIcon,
house: HouseIcon,
kanban: KanbanIcon,
link: LinkIcon,
lock: LockIcon,
receipt: ReceiptIcon,
truck: TruckIcon,
upload: UploadIcon,
gear: GearIcon,
users: UsersIcon,
} as Record<string, Icon>;

View File

@@ -0,0 +1,208 @@
'use client';
import * as React from 'react';
import Avatar from '@mui/material/Avatar';
import Box from '@mui/material/Box';
import IconButton from '@mui/material/IconButton';
import Link from '@mui/material/Link';
import List from '@mui/material/List';
import ListItem from '@mui/material/ListItem';
import Popover from '@mui/material/Popover';
import Stack from '@mui/material/Stack';
import Tooltip from '@mui/material/Tooltip';
import Typography from '@mui/material/Typography';
import { ChatText as ChatTextIcon } from '@phosphor-icons/react/dist/ssr/ChatText';
import { EnvelopeSimple as EnvelopeSimpleIcon } from '@phosphor-icons/react/dist/ssr/EnvelopeSimple';
import { User as UserIcon } from '@phosphor-icons/react/dist/ssr/User';
import { X as XIcon } from '@phosphor-icons/react/dist/ssr/X';
import { dayjs } from '@/lib/dayjs';
export type Notification = { id: string; createdAt: Date; read: boolean } & (
| { type: 'new_feature'; description: string }
| { type: 'new_company'; author: { name: string; avatar?: string }; company: { name: string } }
| { type: 'new_job'; author: { name: string; avatar?: string }; job: { title: string } }
);
const notifications = [
{
id: 'EV-004',
createdAt: dayjs().subtract(7, 'minute').subtract(5, 'hour').subtract(1, 'day').toDate(),
read: false,
type: 'new_job',
author: { name: 'Jie Yan', avatar: '/assets/avatar-8.png' },
job: { title: 'Remote React / React Native Developer' },
},
{
id: 'EV-003',
createdAt: dayjs().subtract(18, 'minute').subtract(3, 'hour').subtract(5, 'day').toDate(),
read: true,
type: 'new_job',
author: { name: 'Fran Perez', avatar: '/assets/avatar-5.png' },
job: { title: 'Senior Golang Backend Engineer' },
},
{
id: 'EV-002',
createdAt: dayjs().subtract(4, 'minute').subtract(5, 'hour').subtract(7, 'day').toDate(),
read: true,
type: 'new_feature',
description: 'Logistics management is now available',
},
{
id: 'EV-001',
createdAt: dayjs().subtract(7, 'minute').subtract(8, 'hour').subtract(7, 'day').toDate(),
read: true,
type: 'new_company',
author: { name: 'Jie Yan', avatar: '/assets/avatar-8.png' },
company: { name: 'Stripe' },
},
] satisfies Notification[];
export interface NotificationsPopoverProps {
anchorEl: null | Element;
onClose?: () => void;
onMarkAllAsRead?: () => void;
onRemoveOne?: (id: string) => void;
open?: boolean;
}
export function NotificationsPopover({
anchorEl,
onClose,
onMarkAllAsRead,
onRemoveOne,
open = false,
}: NotificationsPopoverProps): React.JSX.Element {
return (
<Popover
anchorEl={anchorEl}
anchorOrigin={{ horizontal: 'right', vertical: 'bottom' }}
onClose={onClose}
open={open}
slotProps={{ paper: { sx: { width: '380px' } } }}
transformOrigin={{ horizontal: 'right', vertical: 'top' }}
>
<Stack direction="row" spacing={2} sx={{ alignItems: 'center', justifyContent: 'space-between', px: 3, py: 2 }}>
<Typography variant="h6">Notifications</Typography>
<Tooltip title="Mark all as read">
<IconButton edge="end" onClick={onMarkAllAsRead}>
<EnvelopeSimpleIcon />
</IconButton>
</Tooltip>
</Stack>
{notifications.length === 0 ? (
<Box sx={{ p: 2 }}>
<Typography variant="subtitle2">There are no notifications</Typography>
</Box>
) : (
<Box sx={{ maxHeight: '270px', overflowY: 'auto' }}>
<List disablePadding>
{notifications.map((notification, index) => (
<NotificationItem
divider={index < notifications.length - 1}
key={notification.id}
notification={notification}
onRemove={() => {
onRemoveOne?.(notification.id);
}}
/>
))}
</List>
</Box>
)}
</Popover>
);
}
interface NotificationItemProps {
divider?: boolean;
notification: Notification;
onRemove?: () => void;
}
function NotificationItem({ divider, notification, onRemove }: NotificationItemProps): React.JSX.Element {
return (
<ListItem divider={divider} sx={{ alignItems: 'flex-start', justifyContent: 'space-between' }}>
<NotificationContent notification={notification} />
<Tooltip title="Remove">
<IconButton edge="end" onClick={onRemove} size="small">
<XIcon />
</IconButton>
</Tooltip>
</ListItem>
);
}
interface NotificationContentProps {
notification: Notification;
}
function NotificationContent({ notification }: NotificationContentProps): React.JSX.Element {
if (notification.type === 'new_feature') {
return (
<Stack direction="row" spacing={2} sx={{ alignItems: 'flex-start' }}>
<Avatar>
<ChatTextIcon fontSize="var(--Icon-fontSize)" />
</Avatar>
<div>
<Typography variant="subtitle2">New feature!</Typography>
<Typography variant="body2">{notification.description}</Typography>
<Typography color="text.secondary" variant="caption">
{dayjs(notification.createdAt).format('MMM D, hh:mm A')}
</Typography>
</div>
</Stack>
);
}
if (notification.type === 'new_company') {
return (
<Stack direction="row" spacing={2} sx={{ alignItems: 'flex-start' }}>
<Avatar src={notification.author.avatar}>
<UserIcon />
</Avatar>
<div>
<Typography variant="body2">
<Typography component="span" variant="subtitle2">
{notification.author.name}
</Typography>{' '}
created{' '}
<Link underline="always" variant="body2">
{notification.company.name}
</Link>{' '}
company
</Typography>
<Typography color="text.secondary" variant="caption">
{dayjs(notification.createdAt).format('MMM D, hh:mm A')}
</Typography>
</div>
</Stack>
);
}
if (notification.type === 'new_job') {
return (
<Stack direction="row" spacing={2} sx={{ alignItems: 'flex-start' }}>
<Avatar src={notification.author.avatar}>
<UserIcon />
</Avatar>
<div>
<Typography variant="body2">
<Typography component="span" variant="subtitle2">
{notification.author.name}
</Typography>{' '}
added a new job{' '}
<Link underline="always" variant="body2">
{notification.job.title}
</Link>
</Typography>
<Typography color="text.secondary" variant="caption">
{dayjs(notification.createdAt).format('MMM D, hh:mm A')}
</Typography>
</div>
</Stack>
);
}
return <div />;
}

View File

@@ -0,0 +1,149 @@
'use client';
import * as React from 'react';
import Badge from '@mui/material/Badge';
import Box from '@mui/material/Box';
import CircularProgress from '@mui/material/CircularProgress';
import Dialog from '@mui/material/Dialog';
import DialogContent from '@mui/material/DialogContent';
import Divider from '@mui/material/Divider';
import IconButton from '@mui/material/IconButton';
import InputAdornment from '@mui/material/InputAdornment';
import OutlinedInput from '@mui/material/OutlinedInput';
import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography';
import { MagnifyingGlass as MagnifyingGlassIcon } from '@phosphor-icons/react/dist/ssr/MagnifyingGlass';
import { X as XIcon } from '@phosphor-icons/react/dist/ssr/X';
import { Tip } from '@/components/core/tip';
function wait(time: number): Promise<void> {
return new Promise((res) => {
setTimeout(res, time);
});
}
interface Article {
id: string;
description: string;
title: string;
category: string;
}
const articles: Record<string, Article[]> = {
Platform: [
{
id: 'ART-1',
description:
'Provide your users with the content they need, exactly when they need it, by building a next-level site search experience using our AI-powered search API.',
title: 'Level up your site search experience with our hosted API',
category: 'Users / Api-usage',
},
{
id: 'ART-2',
description:
'Algolia is a search-as-a-service API that helps marketplaces build performant search experiences at scale while reducing engineering time.',
title: 'Build performant marketplace search at scale',
category: 'Users / Api-usage',
},
],
Resources: [
{
id: 'ART-3',
description: "Algolia's architecture is heavily redundant, hosting every application on …",
title: "Using NetInfo API to Improve Algolia's JavaScript Client",
category: 'Resources / Blog posts',
},
{
id: 'ART-4',
description: 'Explore the intricacies of building high-performance applications with Algolia.',
title: 'Build performance',
category: 'Resources / UI libraries',
},
],
};
export interface SearchDialogProps {
onClose?: () => void;
open?: boolean;
}
export function SearchDialog({ onClose, open = false }: SearchDialogProps): React.JSX.Element {
const [value, setValue] = React.useState<string>('');
const [isLoading, setIsLoading] = React.useState<boolean>(false);
const [displayArticles, setDisplayArticles] = React.useState<boolean>(false);
const handleSubmit = React.useCallback(async (event: React.FormEvent): Promise<void> => {
event.preventDefault();
setDisplayArticles(false);
setIsLoading(true);
// Do search here
await wait(1500);
setIsLoading(false);
setDisplayArticles(true);
}, []);
return (
<Dialog fullWidth maxWidth="sm" onClose={onClose} open={open}>
<Stack direction="row" spacing={3} sx={{ alignItems: 'center', justifyContent: 'space-between', px: 3, py: 2 }}>
<Typography variant="h6">Search</Typography>
<IconButton onClick={onClose}>
<XIcon />
</IconButton>
</Stack>
<DialogContent>
<Stack spacing={3}>
<Tip message="Search by entering a keyword and pressing Enter" />
<form onSubmit={handleSubmit}>
<OutlinedInput
fullWidth
label="Search"
onChange={(event) => {
setValue(event.target.value);
}}
placeholder="Search..."
startAdornment={
<InputAdornment position="start">
<MagnifyingGlassIcon />
</InputAdornment>
}
value={value}
/>
</form>
{isLoading ? (
<Box sx={{ display: 'flex', justifyContent: 'center' }}>
<CircularProgress />
</Box>
) : null}
{displayArticles ? (
<Stack spacing={2}>
{Object.keys(articles).map((group) => (
<Stack key={group} spacing={2}>
<Typography variant="h6">{group}</Typography>
<Stack divider={<Divider />} sx={{ border: '1px solid var(--mui-palette-divider)', borderRadius: 1 }}>
{articles[group].map((article) => (
<Stack key={article.id} spacing={1} sx={{ p: 2 }}>
<div>
<Stack direction="row" spacing={2} sx={{ alignItems: 'center', pl: 1 }}>
<Badge color="primary" variant="dot" />
<Typography variant="subtitle1">{article.title}</Typography>
</Stack>
<Typography color="text.secondary" variant="body2">
{article.category}
</Typography>
</div>
<Typography color="text.secondary" variant="body2">
{article.description}
</Typography>
</Stack>
))}
</Stack>
</Stack>
))}
</Stack>
) : null}
</Stack>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,12 @@
import * as React from 'react';
import MenuItem from '@mui/material/MenuItem';
import { paths } from '@/paths';
export function Auth0SignOut(): React.JSX.Element {
return (
<MenuItem component="a" href={paths.auth.auth0.signOut} sx={{ justifyContent: 'center' }}>
Sign out
</MenuItem>
);
}

View File

@@ -0,0 +1,27 @@
'use client';
import * as React from 'react';
import { signOut } from '@aws-amplify/auth';
import MenuItem from '@mui/material/MenuItem';
import { logger } from '@/lib/default-logger';
import { toast } from '@/components/core/toaster';
export function CognitoSignOut(): React.JSX.Element {
const handleSignOut = React.useCallback(async (): Promise<void> => {
try {
await signOut();
// UserProvider will handle Router refresh
// After refresh, GuestGuard will handle the redirect
} catch (err) {
logger.error('Sign out error', err);
toast.error('Something went wrong, unable to sign out');
}
}, []);
return (
<MenuItem component="div" onClick={handleSignOut} sx={{ justifyContent: 'center' }}>
Sign out
</MenuItem>
);
}

View File

@@ -0,0 +1,44 @@
'use client';
import * as React from 'react';
import { useRouter } from 'next/navigation';
import MenuItem from '@mui/material/MenuItem';
import { authClient } from '@/lib/auth/custom/client';
import { logger } from '@/lib/default-logger';
import { useUser } from '@/hooks/use-user';
import { toast } from '@/components/core/toaster';
export function CustomSignOut(): React.JSX.Element {
const { checkSession } = useUser();
const router = useRouter();
const handleSignOut = React.useCallback(async (): Promise<void> => {
try {
const { error } = await authClient.signOut();
if (error) {
logger.error('Sign out error', error);
toast.error('Something went wrong, unable to sign out');
return;
}
// Refresh the auth state
await checkSession?.();
// UserProvider, for this case, will not refresh the router and we need to do it manually
router.refresh();
// After refresh, AuthGuard will handle the redirect
} catch (err) {
logger.error('Sign out error', err);
toast.error('Something went wrong, unable to sign out');
}
}, [checkSession, router]);
return (
<MenuItem component="div" onClick={handleSignOut} sx={{ justifyContent: 'center' }}>
Sign out
</MenuItem>
);
}

View File

@@ -0,0 +1,31 @@
'use client';
import * as React from 'react';
import MenuItem from '@mui/material/MenuItem';
import type { Auth } from 'firebase/auth';
import { signOut } from 'firebase/auth';
import { getFirebaseAuth } from '@/lib/auth/firebase/client';
import { logger } from '@/lib/default-logger';
import { toast } from '@/components/core/toaster';
export function FirebaseSignOut(): React.JSX.Element {
const [firebaseAuth] = React.useState<Auth>(getFirebaseAuth());
const handleSignOut = React.useCallback(async (): Promise<void> => {
try {
await signOut(firebaseAuth);
// UserProvider will handle Router refresh
// After refresh, GuestGuard will handle the redirect
} catch (err) {
logger.error('Sign out error', err);
toast.error('Something went wrong, unable to sign out');
}
}, [firebaseAuth]);
return (
<MenuItem component="div" onClick={handleSignOut} sx={{ justifyContent: 'center' }}>
Sign out
</MenuItem>
);
}

View File

@@ -0,0 +1,36 @@
'use client';
import * as React from 'react';
import MenuItem from '@mui/material/MenuItem';
import type { SupabaseClient } from '@supabase/supabase-js';
import { logger } from '@/lib/default-logger';
import { createClient as createSupabaseClient } from '@/lib/supabase/client';
import { toast } from '@/components/core/toaster';
export function SupabaseSignOut(): React.JSX.Element {
const [supabaseClient] = React.useState<SupabaseClient>(createSupabaseClient());
const handleSignOut = React.useCallback(async (): Promise<void> => {
try {
const { error } = await supabaseClient.auth.signOut();
if (error) {
logger.error('Sign out error', error);
toast.error('Something went wrong, unable to sign out');
} else {
// UserProvider will handle Router refresh
// After refresh, GuestGuard will handle the redirect
}
} catch (err) {
logger.error('Sign out error', err);
toast.error('Something went wrong, unable to sign out');
}
}, [supabaseClient]);
return (
<MenuItem component="div" onClick={handleSignOut} sx={{ justifyContent: 'center' }}>
Sign out
</MenuItem>
);
}

View File

@@ -0,0 +1,87 @@
'use client';
import * as React from 'react';
import RouterLink from 'next/link';
import Box from '@mui/material/Box';
import Divider from '@mui/material/Divider';
import List from '@mui/material/List';
import ListItemIcon from '@mui/material/ListItemIcon';
import MenuItem from '@mui/material/MenuItem';
import Popover from '@mui/material/Popover';
import Typography from '@mui/material/Typography';
import { CreditCard as CreditCardIcon } from '@phosphor-icons/react/dist/ssr/CreditCard';
import { LockKey as LockKeyIcon } from '@phosphor-icons/react/dist/ssr/LockKey';
import { User as UserIcon } from '@phosphor-icons/react/dist/ssr/User';
import type { User } from '@/types/user';
import { config } from '@/config';
import { paths } from '@/paths';
import { AuthStrategy } from '@/lib/auth/strategy';
import { Auth0SignOut } from './auth0-sign-out';
import { CognitoSignOut } from './cognito-sign-out';
import { CustomSignOut } from './custom-sign-out';
import { FirebaseSignOut } from './firebase-sign-out';
import { SupabaseSignOut } from './supabase-sign-out';
const user = {
id: 'USR-000',
name: 'Sofia Rivers',
avatar: '/assets/avatar.png',
email: 'sofia@devias.io',
} satisfies User;
export interface UserPopoverProps {
anchorEl: null | Element;
onClose?: () => void;
open: boolean;
}
export function UserPopover({ anchorEl, onClose, open }: UserPopoverProps): React.JSX.Element {
return (
<Popover
anchorEl={anchorEl}
anchorOrigin={{ horizontal: 'right', vertical: 'bottom' }}
onClose={onClose}
open={Boolean(open)}
slotProps={{ paper: { sx: { width: '280px' } } }}
transformOrigin={{ horizontal: 'right', vertical: 'top' }}
>
<Box sx={{ p: 2 }}>
<Typography>{user.name}</Typography>
<Typography color="text.secondary" variant="body2">
{user.email}
</Typography>
</Box>
<Divider />
<List sx={{ p: 1 }}>
<MenuItem component={RouterLink} href={paths.dashboard.settings.account} onClick={onClose}>
<ListItemIcon>
<UserIcon />
</ListItemIcon>
Account
</MenuItem>
<MenuItem component={RouterLink} href={paths.dashboard.settings.security} onClick={onClose}>
<ListItemIcon>
<LockKeyIcon />
</ListItemIcon>
Security
</MenuItem>
<MenuItem component={RouterLink} href={paths.dashboard.settings.billing} onClick={onClose}>
<ListItemIcon>
<CreditCardIcon />
</ListItemIcon>
Billing
</MenuItem>
</List>
<Divider />
<Box sx={{ p: 1 }}>
{config.auth.strategy === AuthStrategy.CUSTOM ? <CustomSignOut /> : null}
{config.auth.strategy === AuthStrategy.AUTH0 ? <Auth0SignOut /> : null}
{config.auth.strategy === AuthStrategy.COGNITO ? <CognitoSignOut /> : null}
{config.auth.strategy === AuthStrategy.FIREBASE ? <FirebaseSignOut /> : null}
{config.auth.strategy === AuthStrategy.SUPABASE ? <SupabaseSignOut /> : null}
</Box>
</Popover>
);
}

View File

@@ -0,0 +1,216 @@
'use client';
import * as React from 'react';
import Avatar from '@mui/material/Avatar';
import Badge from '@mui/material/Badge';
import Box from '@mui/material/Box';
import Divider from '@mui/material/Divider';
import IconButton from '@mui/material/IconButton';
import Stack from '@mui/material/Stack';
import Tooltip from '@mui/material/Tooltip';
import { Bell as BellIcon } from '@phosphor-icons/react/dist/ssr/Bell';
import { List as ListIcon } from '@phosphor-icons/react/dist/ssr/List';
import { MagnifyingGlass as MagnifyingGlassIcon } from '@phosphor-icons/react/dist/ssr/MagnifyingGlass';
import { Users as UsersIcon } from '@phosphor-icons/react/dist/ssr/Users';
import { useTranslation } from 'next-i18next';
import type { NavItemConfig } from '@/types/nav';
import type { User } from '@/types/user';
import { useDialog } from '@/hooks/use-dialog';
import { usePopover } from '@/hooks/use-popover';
import { ContactsPopover } from '../contacts-popover';
import { languageFlags, LanguagePopover } from '../language-popover';
import type { Language } from '../language-popover';
import { MobileNav } from '../mobile-nav';
import { NotificationsPopover } from '../notifications-popover';
import { SearchDialog } from '../search-dialog';
import { UserPopover } from '../user-popover/user-popover';
export interface MainNavProps {
items: NavItemConfig[];
}
export function MainNav({ items }: MainNavProps): React.JSX.Element {
const [openNav, setOpenNav] = React.useState<boolean>(false);
return (
<React.Fragment>
<Box
component="header"
sx={{
'--MainNav-background': 'var(--mui-palette-background-default)',
'--MainNav-divider': 'var(--mui-palette-divider)',
bgcolor: 'var(--MainNav-background)',
left: 0,
position: 'sticky',
pt: { lg: 'var(--Layout-gap)' },
top: 0,
width: '100%',
zIndex: 'var(--MainNav-zIndex)',
}}
>
<Box
sx={{
borderBottom: '1px solid var(--MainNav-divider)',
display: 'flex',
flex: '1 1 auto',
minHeight: 'var(--MainNav-height)',
px: { xs: 2, lg: 3 },
py: 1,
}}
>
<Stack direction="row" spacing={2} sx={{ alignItems: 'center', flex: '1 1 auto' }}>
<IconButton
onClick={(): void => {
setOpenNav(true);
}}
sx={{ display: { lg: 'none' } }}
>
<ListIcon />
</IconButton>
<SearchButton />
</Stack>
<Stack
direction="row"
spacing={2}
sx={{ alignItems: 'center', flex: '1 1 auto', justifyContent: 'flex-end' }}
>
<NotificationsButton />
<ContactsButton />
<Divider
flexItem
orientation="vertical"
sx={{ borderColor: 'var(--MainNav-divider)', display: { xs: 'none', lg: 'block' } }}
/>
<LanguageSwitch />
<UserButton />
</Stack>
</Box>
</Box>
<MobileNav
items={items}
onClose={() => {
setOpenNav(false);
}}
open={openNav}
/>
</React.Fragment>
);
}
function SearchButton(): React.JSX.Element {
const dialog = useDialog();
return (
<React.Fragment>
<Tooltip title="Search">
<IconButton onClick={dialog.handleOpen} sx={{ display: { xs: 'none', lg: 'inline-flex' } }}>
<MagnifyingGlassIcon />
</IconButton>
</Tooltip>
<SearchDialog onClose={dialog.handleClose} open={dialog.open} />
</React.Fragment>
);
}
function ContactsButton(): React.JSX.Element {
const popover = usePopover<HTMLButtonElement>();
return (
<React.Fragment>
<Tooltip title="Contacts">
<IconButton onClick={popover.handleOpen} ref={popover.anchorRef}>
<UsersIcon />
</IconButton>
</Tooltip>
<ContactsPopover anchorEl={popover.anchorRef.current} onClose={popover.handleClose} open={popover.open} />
</React.Fragment>
);
}
function NotificationsButton(): React.JSX.Element {
const popover = usePopover<HTMLButtonElement>();
return (
<React.Fragment>
<Tooltip title="Notifications">
<Badge
color="error"
sx={{ '& .MuiBadge-dot': { borderRadius: '50%', height: '10px', right: '6px', top: '6px', width: '10px' } }}
variant="dot"
>
<IconButton onClick={popover.handleOpen} ref={popover.anchorRef}>
<BellIcon />
</IconButton>
</Badge>
</Tooltip>
<NotificationsPopover anchorEl={popover.anchorRef.current} onClose={popover.handleClose} open={popover.open} />
</React.Fragment>
);
}
function LanguageSwitch(): React.JSX.Element {
const { i18n } = useTranslation();
const popover = usePopover<HTMLButtonElement>();
const language = (i18n.language || 'en') as Language;
const flag = languageFlags[language];
return (
<React.Fragment>
<Tooltip title="Language">
<IconButton
onClick={popover.handleOpen}
ref={popover.anchorRef}
sx={{ display: { xs: 'none', lg: 'inline-flex' } }}
>
<Box sx={{ height: '24px', width: '24px' }}>
<Box alt={language} component="img" src={flag} sx={{ height: 'auto', width: '100%' }} />
</Box>
</IconButton>
</Tooltip>
<LanguagePopover anchorEl={popover.anchorRef.current} onClose={popover.handleClose} open={popover.open} />
</React.Fragment>
);
}
const user = {
id: 'USR-000',
name: 'Sofia Rivers',
avatar: '/assets/avatar.png',
email: 'sofia@devias.io',
} satisfies User;
function UserButton(): React.JSX.Element {
const popover = usePopover<HTMLButtonElement>();
return (
<React.Fragment>
<Box
component="button"
onClick={popover.handleOpen}
ref={popover.anchorRef}
sx={{ border: 'none', background: 'transparent', cursor: 'pointer', p: 0 }}
>
<Badge
anchorOrigin={{ horizontal: 'right', vertical: 'bottom' }}
color="success"
sx={{
'& .MuiBadge-dot': {
border: '2px solid var(--MainNav-background)',
borderRadius: '50%',
bottom: '6px',
height: '12px',
right: '6px',
width: '12px',
},
}}
variant="dot"
>
<Avatar src={user.avatar} />
</Badge>
</Box>
<UserPopover anchorEl={popover.anchorRef.current} onClose={popover.handleClose} open={popover.open} />
</React.Fragment>
);
}

View File

@@ -0,0 +1,270 @@
import * as React from 'react';
import RouterLink from 'next/link';
import { usePathname } from 'next/navigation';
import Box from '@mui/material/Box';
import Chip from '@mui/material/Chip';
import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography';
import { ArrowSquareOut as ArrowSquareOutIcon } from '@phosphor-icons/react/dist/ssr/ArrowSquareOut';
import { CaretDown as CaretDownIcon } from '@phosphor-icons/react/dist/ssr/CaretDown';
import { CaretRight as CaretRightIcon } from '@phosphor-icons/react/dist/ssr/CaretRight';
import type { NavItemConfig } from '@/types/nav';
import type { NavColor } from '@/types/settings';
import { paths } from '@/paths';
import { isNavItemActive } from '@/lib/is-nav-item-active';
import { useSettings } from '@/hooks/use-settings';
import { Logo } from '@/components/core/logo';
import type { ColorScheme } from '@/styles/theme/types';
import { icons } from '../nav-icons';
import { WorkspacesSwitch } from '../workspaces-switch';
import { navColorStyles } from './styles';
const logoColors = {
dark: { blend_in: 'light', discrete: 'light', evident: 'light' },
light: { blend_in: 'dark', discrete: 'dark', evident: 'light' },
} as Record<ColorScheme, Record<NavColor, 'dark' | 'light'>>;
export interface SideNavProps {
color?: NavColor;
items?: NavItemConfig[];
}
export function SideNav({ color = 'evident', items = [] }: SideNavProps): React.JSX.Element {
const pathname = usePathname();
const {
settings: { colorScheme = 'light' },
} = useSettings();
const styles = navColorStyles[colorScheme][color];
const logoColor = logoColors[colorScheme][color];
return (
<Box
sx={{
...styles,
bgcolor: 'var(--SideNav-background)',
borderRight: 'var(--SideNav-border)',
color: 'var(--SideNav-color)',
display: { xs: 'none', lg: 'flex' },
flexDirection: 'column',
height: '100%',
left: 0,
position: 'fixed',
top: 0,
width: 'var(--SideNav-width)',
zIndex: 'var(--SideNav-zIndex)',
}}
>
<Stack spacing={2} sx={{ p: 2 }}>
<div>
<Box component={RouterLink} href={paths.home} sx={{ display: 'inline-flex' }}>
<Logo color={logoColor} height={32} width={122} />
</Box>
</div>
<WorkspacesSwitch />
</Stack>
<Box
component="nav"
sx={{
flex: '1 1 auto',
overflowY: 'auto',
p: 2,
scrollbarWidth: 'none',
'&::-webkit-scrollbar': { display: 'none' },
}}
>
{renderNavGroups({ items, pathname })}
</Box>
</Box>
);
}
function renderNavGroups({ items, pathname }: { items: NavItemConfig[]; pathname: string }): React.JSX.Element {
const children = items.reduce((acc: React.ReactNode[], curr: NavItemConfig): React.ReactNode[] => {
acc.push(
<Stack component="li" key={curr.key} spacing={1.5}>
{curr.title ? (
<div>
<Typography sx={{ color: 'var(--NavGroup-title-color)', fontSize: '0.875rem', fontWeight: 500 }}>
{curr.title}
</Typography>
</div>
) : null}
<div>{renderNavItems({ depth: 0, items: curr.items, pathname })}</div>
</Stack>
);
return acc;
}, []);
return (
<Stack component="ul" spacing={2} sx={{ listStyle: 'none', m: 0, p: 0 }}>
{children}
</Stack>
);
}
function renderNavItems({
depth = 0,
items = [],
pathname,
}: {
depth: number;
items?: NavItemConfig[];
pathname: string;
}): React.JSX.Element {
const children = items.reduce((acc: React.ReactNode[], curr: NavItemConfig): React.ReactNode[] => {
const { items: childItems, key, ...item } = curr;
const forceOpen = childItems
? Boolean(childItems.find((childItem) => childItem.href && pathname.startsWith(childItem.href)))
: false;
acc.push(
<NavItem depth={depth} forceOpen={forceOpen} key={key} pathname={pathname} {...item}>
{childItems ? renderNavItems({ depth: depth + 1, pathname, items: childItems }) : null}
</NavItem>
);
return acc;
}, []);
return (
<Stack component="ul" data-depth={depth} spacing={1} sx={{ listStyle: 'none', m: 0, p: 0 }}>
{children}
</Stack>
);
}
interface NavItemProps extends Omit<NavItemConfig, 'items'> {
children?: React.ReactNode;
depth: number;
forceOpen?: boolean;
pathname: string;
}
function NavItem({
children,
depth,
disabled,
external,
forceOpen = false,
href,
icon,
label,
matcher,
pathname,
title,
}: NavItemProps): React.JSX.Element {
const [open, setOpen] = React.useState<boolean>(forceOpen);
const active = isNavItemActive({ disabled, external, href, matcher, pathname });
const Icon = icon ? icons[icon] : null;
const ExpandIcon = open ? CaretDownIcon : CaretRightIcon;
const isBranch = children && !href;
const showChildren = Boolean(children && open);
return (
<Box component="li" data-depth={depth} sx={{ userSelect: 'none' }}>
<Box
{...(isBranch
? {
onClick: (): void => {
setOpen(!open);
},
onKeyUp: (event: React.KeyboardEvent<HTMLDivElement>): void => {
if (event.key === 'Enter' || event.key === ' ') {
setOpen(!open);
}
},
role: 'button',
}
: {
...(href
? {
component: external ? 'a' : RouterLink,
href,
target: external ? '_blank' : undefined,
rel: external ? 'noreferrer' : undefined,
}
: { role: 'button' }),
})}
sx={{
alignItems: 'center',
borderRadius: 1,
color: 'var(--NavItem-color)',
cursor: 'pointer',
display: 'flex',
flex: '0 0 auto',
gap: 1,
p: '6px 16px',
position: 'relative',
textDecoration: 'none',
whiteSpace: 'nowrap',
...(disabled && {
bgcolor: 'var(--NavItem-disabled-background)',
color: 'var(--NavItem-disabled-color)',
cursor: 'not-allowed',
}),
...(active && {
bgcolor: 'var(--NavItem-active-background)',
color: 'var(--NavItem-active-color)',
...(depth > 0 && {
'&::before': {
bgcolor: 'var(--NavItem-children-indicator)',
borderRadius: '2px',
content: '" "',
height: '20px',
left: '-14px',
position: 'absolute',
width: '3px',
},
}),
}),
...(open && { color: 'var(--NavItem-open-color)' }),
'&:hover': {
...(!disabled &&
!active && { bgcolor: 'var(--NavItem-hover-background)', color: 'var(--NavItem-hover-color)' }),
},
}}
tabIndex={0}
>
<Box sx={{ alignItems: 'center', display: 'flex', justifyContent: 'center', flex: '0 0 auto' }}>
{Icon ? (
<Icon
fill={active ? 'var(--NavItem-icon-active-color)' : 'var(--NavItem-icon-color)'}
fontSize="var(--icon-fontSize-md)"
weight={forceOpen || active ? 'fill' : undefined}
/>
) : null}
</Box>
<Box sx={{ flex: '1 1 auto' }}>
<Typography
component="span"
sx={{ color: 'inherit', fontSize: '0.875rem', fontWeight: 500, lineHeight: '28px' }}
>
{title}
</Typography>
</Box>
{label ? <Chip color="primary" label={label} size="small" /> : null}
{external ? (
<Box sx={{ alignItems: 'center', display: 'flex', flex: '0 0 auto' }}>
<ArrowSquareOutIcon color="var(--NavItem-icon-color)" fontSize="var(--icon-fontSize-sm)" />
</Box>
) : null}
{isBranch ? (
<Box sx={{ alignItems: 'center', display: 'flex', flex: '0 0 auto' }}>
<ExpandIcon color="var(--NavItem-expand-color)" fontSize="var(--icon-fontSize-sm)" />
</Box>
) : null}
</Box>
{showChildren ? (
<Box sx={{ pl: '24px' }}>
<Box sx={{ borderLeft: '1px solid var(--NavItem-children-border)', pl: '12px' }}>{children}</Box>
</Box>
) : null}
</Box>
);
}

View File

@@ -0,0 +1,141 @@
import type { NavColor } from '@/types/settings';
import type { ColorScheme } from '@/styles/theme/types';
export const navColorStyles = {
dark: {
blend_in: {
'--SideNav-background': 'var(--mui-palette-background-default)',
'--SideNav-color': 'var(--mui-palette-common-white)',
'--SideNav-border': '1px solid var(--mui-palette-neutral-700)',
'--NavGroup-title-color': 'var(--mui-palette-neutral-400)',
'--NavItem-color': 'var(--mui-palette-neutral-300)',
'--NavItem-hover-background': 'var(--mui-palette-action-hover)',
'--NavItem-active-background': 'var(--mui-palette-primary-main)',
'--NavItem-active-color': 'var(--mui-palette-primary-contrastText)',
'--NavItem-disabled-color': 'var(--mui-palette-neutral-500)',
'--NavItem-icon-color': 'var(--mui-palette-neutral-400)',
'--NavItem-icon-active-color': 'var(--mui-palette-primary-contrastText)',
'--NavItem-icon-disabled-color': 'var(--mui-palette-neutral-600)',
'--NavItem-expand-color': 'var(--mui-palette-neutral-400)',
'--NavItem-children-border': 'var(--mui-palette-neutral-700)',
'--NavItem-children-indicator': 'var(--mui-palette-neutral-400)',
'--Workspaces-background': 'var(--mui-palette-neutral-950)',
'--Workspaces-border-color': 'var(--mui-palette-neutral-700)',
'--Workspaces-title-color': 'var(--mui-palette-neutral-400)',
'--Workspaces-name-color': 'var(--mui-palette-neutral-300)',
'--Workspaces-expand-color': 'var(--mui-palette-neutral-400)',
},
discrete: {
'--SideNav-background': 'var(--mui-palette-neutral-900)',
'--SideNav-color': 'var(--mui-palette-common-white)',
'--SideNav-border': '1px solid var(--mui-palette-neutral-700)',
'--NavGroup-title-color': 'var(--mui-palette-neutral-400)',
'--NavItem-color': 'var(--mui-palette-neutral-300)',
'--NavItem-hover-background': 'var(--mui-palette-action-hover)',
'--NavItem-active-background': 'var(--mui-palette-primary-main)',
'--NavItem-active-color': 'var(--mui-palette-primary-contrastText)',
'--NavItem-disabled-color': 'var(--mui-palette-neutral-500)',
'--NavItem-icon-color': 'var(--mui-palette-neutral-400)',
'--NavItem-icon-active-color': 'var(--mui-palette-primary-contrastText)',
'--NavItem-icon-disabled-color': 'var(--mui-palette-neutral-600)',
'--NavItem-expand-color': 'var(--mui-palette-neutral-400)',
'--NavItem-children-border': 'var(--mui-palette-neutral-700)',
'--NavItem-children-indicator': 'var(--mui-palette-neutral-400)',
'--Workspaces-background': 'var(--mui-palette-neutral-950)',
'--Workspaces-border-color': 'var(--mui-palette-neutral-700)',
'--Workspaces-title-color': 'var(--mui-palette-neutral-400)',
'--Workspaces-name-color': 'var(--mui-palette-neutral-300)',
'--Workspaces-expand-color': 'var(--mui-palette-neutral-400)',
},
evident: {
'--SideNav-background': 'var(--mui-palette-neutral-800)',
'--SideNav-color': 'var(--mui-palette-common-white)',
'--SideNav-border': 'none',
'--NavGroup-title-color': 'var(--mui-palette-neutral-400)',
'--NavItem-color': 'var(--mui-palette-neutral-300)',
'--NavItem-hover-background': 'rgba(255, 255, 255, 0.04)',
'--NavItem-active-background': 'var(--mui-palette-primary-main)',
'--NavItem-active-color': 'var(--mui-palette-primary-contrastText)',
'--NavItem-disabled-color': 'var(--mui-palette-neutral-500)',
'--NavItem-icon-color': 'var(--mui-palette-neutral-400)',
'--NavItem-icon-active-color': 'var(--mui-palette-primary-contrastText)',
'--NavItem-icon-disabled-color': 'var(--mui-palette-neutral-600)',
'--NavItem-expand-color': 'var(--mui-palette-neutral-400)',
'--NavItem-children-border': 'var(--mui-palette-neutral-700)',
'--NavItem-children-indicator': 'var(--mui-palette-neutral-400)',
'--Workspaces-background': 'var(--mui-palette-neutral-950)',
'--Workspaces-border-color': 'var(--mui-palette-neutral-700)',
'--Workspaces-title-color': 'var(--mui-palette-neutral-400)',
'--Workspaces-name-color': 'var(--mui-palette-neutral-300)',
'--Workspaces-expand-color': 'var(--mui-palette-neutral-400)',
},
},
light: {
blend_in: {
'--SideNav-background': 'var(--mui-palette-background-default)',
'--SideNav-color': 'var(--mui-palette-text-primary)',
'--SideNav-border': '1px solid var(--mui-palette-divider)',
'--NavGroup-title-color': 'var(--mui-palette-neutral-600)',
'--NavItem-color': 'var(--mui-palette-neutral-600)',
'--NavItem-hover-background': 'var(--mui-palette-action-hover)',
'--NavItem-active-background': 'var(--mui-palette-primary-main)',
'--NavItem-active-color': 'var(--mui-palette-primary-contrastText)',
'--NavItem-disabled-color': 'var(--mui-palette-neutral-500)',
'--NavItem-icon-color': 'var(--mui-palette-neutral-500)',
'--NavItem-icon-active-color': 'var(--mui-palette-primary-contrastText)',
'--NavItem-icon-disabled-color': 'var(--mui-palette-neutral-500)',
'--NavItem-expand-color': 'var(--mui-palette-neutral-500)',
'--NavItem-children-border': 'var(--mui-palette-neutral-200)',
'--NavItem-children-indicator': 'var(--mui-palette-neutral-500)',
'--Workspaces-background': 'var(--mui-palette-neutral-100)',
'--Workspaces-border-color': 'var(--mui-palette-divider)',
'--Workspaces-title-color': 'var(--mui-palette-neutra-400)',
'--Workspaces-name-color': 'var(--mui-palette-neutral-900)',
'--Workspaces-expand-color': 'var(--mui-palette-neutral-400)',
},
discrete: {
'--SideNav-background': 'var(--mui-palette-neutral-50)',
'--SideNav-color': 'var(--mui-palette-text-primary)',
'--SideNav-border': '1px solid var(--mui-palette-divider)',
'--NavGroup-title-color': 'var(--mui-palette-neutral-600)',
'--NavItem-color': 'var(--mui-palette-neutral-600)',
'--NavItem-hover-background': 'var(--mui-palette-action-hover)',
'--NavItem-active-background': 'var(--mui-palette-primary-main)',
'--NavItem-active-color': 'var(--mui-palette-primary-contrastText)',
'--NavItem-disabled-color': 'var(--mui-palette-neutral-500)',
'--NavItem-icon-color': 'var(--mui-palette-neutral-500)',
'--NavItem-icon-active-color': 'var(--mui-palette-primary-contrastText)',
'--NavItem-icon-disabled-color': 'var(--mui-palette-neutral-500)',
'--NavItem-expand-color': 'var(--mui-palette-neutral-500)',
'--NavItem-children-border': 'var(--mui-palette-neutral-200)',
'--NavItem-children-indicator': 'var(--mui-palette-neutral-500)',
'--Workspaces-background': 'var(--mui-palette-neutral-100)',
'--Workspaces-border-color': 'var(--mui-palette-divider)',
'--Workspaces-title-color': 'var(--mui-palette-neutra-400)',
'--Workspaces-name-color': 'var(--mui-palette-neutral-900)',
'--Workspaces-expand-color': 'var(--mui-palette-neutral-400)',
},
evident: {
'--SideNav-background': 'var(--mui-palette-neutral-950)',
'--SideNav-color': 'var(--mui-palette-common-white)',
'--SideNav-border': 'none',
'--NavGroup-title-color': 'var(--mui-palette-neutral-400)',
'--NavItem-color': 'var(--mui-palette-neutral-300)',
'--NavItem-hover-background': 'rgba(255, 255, 255, 0.04)',
'--NavItem-active-background': 'var(--mui-palette-primary-main)',
'--NavItem-active-color': 'var(--mui-palette-primary-contrastText)',
'--NavItem-disabled-color': 'var(--mui-palette-neutral-500)',
'--NavItem-icon-color': 'var(--mui-palette-neutral-400)',
'--NavItem-icon-active-color': 'var(--mui-palette-primary-contrastText)',
'--NavItem-icon-disabled-color': 'var(--mui-palette-neutral-600)',
'--NavItem-expand-color': 'var(--mui-palette-neutral-400)',
'--NavItem-children-border': 'var(--mui-palette-neutral-700)',
'--NavItem-children-indicator': 'var(--mui-palette-neutral-400)',
'--Workspaces-background': 'var(--mui-palette-neutral-950)',
'--Workspaces-border-color': 'var(--mui-palette-neutral-700)',
'--Workspaces-title-color': 'var(--mui-palette-neutral-400)',
'--Workspaces-name-color': 'var(--mui-palette-neutral-300)',
'--Workspaces-expand-color': 'var(--mui-palette-neutral-400)',
},
},
} satisfies Record<ColorScheme, Record<NavColor, Record<string, string>>>;

View File

@@ -0,0 +1,66 @@
'use client';
import * as React from 'react';
import Box from '@mui/material/Box';
import GlobalStyles from '@mui/material/GlobalStyles';
import { useSettings } from '@/hooks/use-settings';
import { layoutConfig } from '../config';
import { MainNav } from './main-nav';
import { SideNav } from './side-nav';
export interface VerticalLayoutProps {
children?: React.ReactNode;
}
export function VerticalLayout({ children }: VerticalLayoutProps): React.JSX.Element {
const { settings } = useSettings();
return (
<React.Fragment>
<GlobalStyles
styles={{
body: {
'--MainNav-height': '56px',
'--MainNav-zIndex': 1000,
'--SideNav-width': '280px',
'--SideNav-zIndex': 1100,
'--MobileNav-width': '320px',
'--MobileNav-zIndex': 1100,
},
}}
/>
<Box
sx={{
bgcolor: 'var(--mui-palette-background-default)',
display: 'flex',
flexDirection: 'column',
position: 'relative',
minHeight: '100%',
}}
>
<SideNav color={settings.navColor} items={layoutConfig.navItems} />
<Box sx={{ display: 'flex', flex: '1 1 auto', flexDirection: 'column', pl: { lg: 'var(--SideNav-width)' } }}>
<MainNav items={layoutConfig.navItems} />
<Box
component="main"
sx={{
'--Content-margin': '0 auto',
'--Content-maxWidth': 'var(--maxWidth-xl)',
'--Content-paddingX': '24px',
'--Content-paddingY': { xs: '24px', lg: '64px' },
'--Content-padding': 'var(--Content-paddingY) var(--Content-paddingX)',
'--Content-width': '100%',
display: 'flex',
flex: '1 1 auto',
flexDirection: 'column',
}}
>
{children}
</Box>
</Box>
</Box>
</React.Fragment>
);
}

View File

@@ -0,0 +1,56 @@
'use client';
import * as React from 'react';
import Avatar from '@mui/material/Avatar';
import ListItemAvatar from '@mui/material/ListItemAvatar';
import Menu from '@mui/material/Menu';
import MenuItem from '@mui/material/MenuItem';
export const workspaces = [
{ name: 'Devias', avatar: '/assets/workspace-avatar-1.png' },
{ name: 'Carpatin', avatar: '/assets/workspace-avatar-2.png' },
] satisfies Workspaces[];
export interface Workspaces {
name: string;
avatar: string;
}
export interface WorkspacesPopoverProps {
anchorEl: null | Element;
onChange?: (tenant: string) => void;
onClose?: () => void;
open?: boolean;
}
export function WorkspacesPopover({
anchorEl,
onChange,
onClose,
open = false,
}: WorkspacesPopoverProps): React.JSX.Element {
return (
<Menu
anchorEl={anchorEl}
anchorOrigin={{ horizontal: 'right', vertical: 'bottom' }}
onClose={onClose}
open={open}
slotProps={{ paper: { sx: { width: '250px' } } }}
transformOrigin={{ horizontal: 'right', vertical: 'top' }}
>
{workspaces.map((workspace) => (
<MenuItem
key={workspace.name}
onClick={() => {
onChange?.(workspace.name);
}}
>
<ListItemAvatar>
<Avatar src={workspace.avatar} sx={{ '--Avatar-size': '32px' }} variant="rounded" />
</ListItemAvatar>
{workspace.name}
</MenuItem>
))}
</Menu>
);
}

View File

@@ -0,0 +1,52 @@
'use client';
import * as React from 'react';
import Avatar from '@mui/material/Avatar';
import Box from '@mui/material/Box';
import Stack from '@mui/material/Stack';
import Typography from '@mui/material/Typography';
import { CaretUpDown as CaretUpDownIcon } from '@phosphor-icons/react/dist/ssr/CaretUpDown';
import { usePopover } from '@/hooks/use-popover';
import { workspaces, WorkspacesPopover } from './workspaces-popover';
export function WorkspacesSwitch(): React.JSX.Element {
const popover = usePopover<HTMLDivElement>();
const workspace = workspaces[0];
return (
<React.Fragment>
<Stack
direction="row"
onClick={popover.handleOpen}
ref={popover.anchorRef}
spacing={2}
sx={{
alignItems: 'center',
border: '1px solid var(--Workspaces-border-color)',
borderRadius: '12px',
cursor: 'pointer',
p: '4px 8px',
}}
>
<Avatar src={workspace.avatar} variant="rounded" />
<Box sx={{ flex: '1 1 auto' }}>
<Typography color="var(--Workspaces-title-color)" variant="caption">
Workspace
</Typography>
<Typography color="var(--Workspaces-name-color)" variant="subtitle2">
{workspace.name}
</Typography>
</Box>
<CaretUpDownIcon color="var(--Workspaces-expand-color)" fontSize="var(--icon-fontSize-sm)" />
</Stack>
<WorkspacesPopover
anchorEl={popover.anchorRef.current}
onChange={popover.handleClose}
onClose={popover.handleClose}
open={popover.open}
/>
</React.Fragment>
);
}