build ok,
This commit is contained in:
260
002_source/cms/src/components/dashboard/layout/config.ts
Normal file
260
002_source/cms/src/components/dashboard/layout/config.ts
Normal 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;
|
@@ -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>
|
||||
);
|
||||
}
|
@@ -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>
|
||||
);
|
||||
}
|
@@ -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>
|
||||
);
|
||||
}
|
@@ -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;
|
||||
}
|
@@ -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>>>;
|
@@ -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>
|
||||
);
|
||||
}
|
282
002_source/cms/src/components/dashboard/layout/mobile-nav.tsx
Normal file
282
002_source/cms/src/components/dashboard/layout/mobile-nav.tsx
Normal 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>
|
||||
);
|
||||
}
|
62
002_source/cms/src/components/dashboard/layout/nav-icons.tsx
Normal file
62
002_source/cms/src/components/dashboard/layout/nav-icons.tsx
Normal 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>;
|
@@ -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 />;
|
||||
}
|
149
002_source/cms/src/components/dashboard/layout/search-dialog.tsx
Normal file
149
002_source/cms/src/components/dashboard/layout/search-dialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
@@ -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>
|
||||
);
|
||||
}
|
@@ -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>
|
||||
);
|
||||
}
|
@@ -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>
|
||||
);
|
||||
}
|
@@ -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>
|
||||
);
|
||||
}
|
@@ -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>
|
||||
);
|
||||
}
|
@@ -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>
|
||||
);
|
||||
}
|
@@ -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>
|
||||
);
|
||||
}
|
@@ -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>
|
||||
);
|
||||
}
|
@@ -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>>>;
|
@@ -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>
|
||||
);
|
||||
}
|
@@ -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>
|
||||
);
|
||||
}
|
@@ -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>
|
||||
);
|
||||
}
|
Reference in New Issue
Block a user