update layouts
This commit is contained in:
@@ -11,6 +11,8 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|
||||||
|
- Rebuilt the nine non-default layouts against the application's own data.
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
### Removed
|
### Removed
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
VITE_API_URL=http://localhost:3333
|
VITE_API_URL=http://localhost:3333
|
||||||
VITE_TINYMCE_API_KEY=
|
VITE_TINYMCE_API_KEY=
|
||||||
VITE_WEB_URL=http://localhost:3000
|
VITE_WEB_URL=http://localhost:3000
|
||||||
|
VITE_LAYOUT=
|
||||||
@@ -4,6 +4,8 @@ export const TINYMCE_API_KEY = import.meta.env['VITE_TINYMCE_API_KEY'];
|
|||||||
|
|
||||||
export const API_URL = import.meta.env['VITE_API_URL'];
|
export const API_URL = import.meta.env['VITE_API_URL'];
|
||||||
|
|
||||||
|
export const LAYOUT = import.meta.env['VITE_LAYOUT'];
|
||||||
|
|
||||||
export const IS_DEV_MODE = import.meta.env.MODE === 'development';
|
export const IS_DEV_MODE = import.meta.env.MODE === 'development';
|
||||||
|
|
||||||
export const IS_PROD_MODE = import.meta.env.MODE === 'production';
|
export const IS_PROD_MODE = import.meta.env.MODE === 'production';
|
||||||
|
|||||||
@@ -1,28 +1,30 @@
|
|||||||
import { LayoutNames, Settings } from './types';
|
import { LayoutNames, LayoutOptions, Settings } from './types';
|
||||||
|
|
||||||
|
const DEFAULT_LAYOUT_OPTIONS: LayoutOptions = {
|
||||||
|
sidebarCollapse: false,
|
||||||
|
sidebarTheme: 'light',
|
||||||
|
headerSticky: true,
|
||||||
|
headerStickyOffset: 200
|
||||||
|
};
|
||||||
|
|
||||||
|
const layouts = Object.fromEntries(
|
||||||
|
Object.values(LayoutNames).map((layout) => [layout, { ...DEFAULT_LAYOUT_OPTIONS }])
|
||||||
|
) as Record<LayoutNames, LayoutOptions>;
|
||||||
|
|
||||||
|
export const DEFAULT_CONTAINER_BY_LAYOUT: Record<LayoutNames, 'fixed' | 'fluid'> = {
|
||||||
|
[LayoutNames.DEFAULT]: 'fixed',
|
||||||
|
[LayoutNames.HORIZONTAL_MENU]: 'fixed',
|
||||||
|
[LayoutNames.COMPACT_SIDEBAR]: 'fixed',
|
||||||
|
[LayoutNames.TWO_COLUMN_SIDEBAR]: 'fixed',
|
||||||
|
[LayoutNames.DUAL_ROW_HEADER]: 'fixed',
|
||||||
|
[LayoutNames.MULTIPLE_MENUS]: 'fixed',
|
||||||
|
[LayoutNames.ADVANCE_MEGA_MENU]: 'fluid',
|
||||||
|
[LayoutNames.DROPDOWN_MENU]: 'fixed',
|
||||||
|
[LayoutNames.EXTENDED_HEADER]: 'fixed',
|
||||||
|
[LayoutNames.DARK_SIDEBAR]: 'fixed'
|
||||||
|
};
|
||||||
|
|
||||||
export const APP_SETTINGS: Settings = {
|
export const APP_SETTINGS: Settings = {
|
||||||
layout: '',
|
|
||||||
container: 'fixed',
|
container: 'fixed',
|
||||||
layouts: {
|
layouts
|
||||||
[LayoutNames.DEFAULT]: {
|
|
||||||
sidebarCollapse: false,
|
|
||||||
sidebarTheme: 'light'
|
|
||||||
},
|
|
||||||
[LayoutNames.HORIZONTAL_MENU]: {
|
|
||||||
headerSticky: true,
|
|
||||||
headerStickyOffset: 200
|
|
||||||
},
|
|
||||||
[LayoutNames.DUAL_ROW_HEADER]: {
|
|
||||||
headerSticky: true,
|
|
||||||
headerStickyOffset: 200
|
|
||||||
},
|
|
||||||
[LayoutNames.ADVANCE_MEGA_MENU]: {
|
|
||||||
headerSticky: true,
|
|
||||||
headerStickyOffset: 200
|
|
||||||
},
|
|
||||||
[LayoutNames.EXTENDED_HEADER]: {
|
|
||||||
headerSticky: true,
|
|
||||||
headerStickyOffset: 200
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -33,29 +33,14 @@ export enum LayoutNames {
|
|||||||
DARK_SIDEBAR = 'dark-sidebar-layout', // demo10
|
DARK_SIDEBAR = 'dark-sidebar-layout', // demo10
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface LayoutOptions {
|
||||||
|
sidebarCollapse: boolean;
|
||||||
|
sidebarTheme: 'light' | 'dark';
|
||||||
|
headerSticky: boolean;
|
||||||
|
headerStickyOffset: number;
|
||||||
|
}
|
||||||
|
|
||||||
export interface Settings {
|
export interface Settings {
|
||||||
container: 'fixed' | 'fluid';
|
container: 'fixed' | 'fluid';
|
||||||
layout: string;
|
layouts: Record<LayoutNames, LayoutOptions>;
|
||||||
layouts: {
|
|
||||||
[LayoutNames.DEFAULT]: {
|
|
||||||
sidebarCollapse: boolean;
|
|
||||||
sidebarTheme: 'light' | 'dark';
|
|
||||||
};
|
|
||||||
[LayoutNames.HORIZONTAL_MENU]: {
|
|
||||||
headerSticky: boolean;
|
|
||||||
headerStickyOffset: number;
|
|
||||||
};
|
|
||||||
[LayoutNames.DUAL_ROW_HEADER]: {
|
|
||||||
headerSticky: boolean;
|
|
||||||
headerStickyOffset: number;
|
|
||||||
};
|
|
||||||
[LayoutNames.ADVANCE_MEGA_MENU]: {
|
|
||||||
headerSticky: boolean;
|
|
||||||
headerStickyOffset: number;
|
|
||||||
};
|
|
||||||
[LayoutNames.EXTENDED_HEADER]: {
|
|
||||||
headerSticky: boolean;
|
|
||||||
headerStickyOffset: number;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# Default layout
|
||||||
|
|
||||||
|
**Metronic 9 reference:** [Demo 1](https://keenthemes.com/metronic/tailwind/react/demo1)
|
||||||
|
|
||||||
|
A fixed sidebar carrying the application menu next to a fixed header. The sidebar can be collapsed
|
||||||
|
to icons, and the choice is remembered in settings under
|
||||||
|
`layouts.default-layout.sidebarCollapse`. Below `lg` the sidebar moves into a sheet opened from the
|
||||||
|
header.
|
||||||
|
|
||||||
|
## Composition
|
||||||
|
|
||||||
|
| Piece | Source |
|
||||||
|
| ----------------------- | --------------------------------------------- |
|
||||||
|
| Sidebar menu | `SidebarAccordionMenu` from `../shared` |
|
||||||
|
| Toolbar and breadcrumbs | `Toolbar` / `ToolbarHeading` from `../shared` |
|
||||||
|
| Footer | `AppFooter` from `../shared` |
|
||||||
|
| Account menu | `UserAvatarMenu` from `../shared` |
|
||||||
|
|
||||||
|
Layout-specific: `Sidebar`, `SidebarHeader` (logo plus the collapse toggle), `Header`.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Body classes `sidebar-fixed`, `header-fixed` and `layout-initialized` are added on mount and
|
||||||
|
removed on unmount, so switching to another layout leaves no styling behind.
|
||||||
@@ -1,41 +0,0 @@
|
|||||||
import { ChevronRight } from 'lucide-react';
|
|
||||||
import { Fragment } from 'react';
|
|
||||||
import { useLocation } from 'react-router';
|
|
||||||
import { cn } from '@crux-kit/utils';
|
|
||||||
import { MenuItem } from '#app/config/types';
|
|
||||||
import { useMenuConfig } from '#app/menu';
|
|
||||||
import { useMenu } from '#shared/hooks/useMenu';
|
|
||||||
|
|
||||||
export function Breadcrumb() {
|
|
||||||
const { pathname } = useLocation();
|
|
||||||
const { getBreadcrumb, isActive } = useMenu(pathname);
|
|
||||||
const menuConfig = useMenuConfig();
|
|
||||||
const items: MenuItem[] = getBreadcrumb(menuConfig);
|
|
||||||
|
|
||||||
if (items.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mb-2.5 flex items-center gap-1.25 text-xs font-medium lg:mb-0 lg:text-sm">
|
|
||||||
{items.map((item, index) => {
|
|
||||||
const last = index === items.length - 1;
|
|
||||||
const active = item.path ? isActive(item.path) : false;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Fragment key={`root-${index}`}>
|
|
||||||
<span
|
|
||||||
className={cn(active ? 'text-mono' : 'text-secondary-foreground')}
|
|
||||||
key={`item-${index}`}
|
|
||||||
>
|
|
||||||
{item.title}
|
|
||||||
</span>
|
|
||||||
{!last && (
|
|
||||||
<ChevronRight className="text-muted-foreground size-3.5" key={`separator-${index}`} />
|
|
||||||
)}
|
|
||||||
</Fragment>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
import { Outlet } from 'react-router';
|
|
||||||
import { useIsMobile } from '@crux-kit/hooks';
|
|
||||||
import { Container } from '#shared/widgets/common/Container';
|
|
||||||
import { Breadcrumb } from './Breadcrumb';
|
|
||||||
|
|
||||||
export function Content() {
|
|
||||||
const mobile = useIsMobile();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="content grow pt-5" role="content">
|
|
||||||
{mobile && (
|
|
||||||
<Container>
|
|
||||||
<Breadcrumb />
|
|
||||||
</Container>
|
|
||||||
)}
|
|
||||||
<Outlet />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
import { generalSettings } from '#app/config/general';
|
|
||||||
import { Container } from '#shared/widgets/common/Container';
|
|
||||||
|
|
||||||
export function Footer() {
|
|
||||||
const currentYear = new Date().getFullYear();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<footer className="footer">
|
|
||||||
<Container>
|
|
||||||
<div className="flex flex-col items-center justify-center gap-3 py-5 md:flex-row md:justify-between">
|
|
||||||
<div className="order-2 flex gap-2 text-sm font-normal md:order-1">
|
|
||||||
<span className="text-muted-foreground">{currentYear} ©</span>
|
|
||||||
<a
|
|
||||||
href="https://keenthemes.com"
|
|
||||||
target="_blank"
|
|
||||||
className="text-secondary-foreground hover:text-primary"
|
|
||||||
>
|
|
||||||
Keenthemes Inc.
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<nav className="text-muted-foreground order-1 flex gap-4 text-sm font-normal md:order-2">
|
|
||||||
<a href={generalSettings.docsLink} target="_blank" className="hover:text-primary">
|
|
||||||
Docs
|
|
||||||
</a>
|
|
||||||
<a href={generalSettings.purchaseLink} target="_blank" className="hover:text-primary">
|
|
||||||
Purchase
|
|
||||||
</a>
|
|
||||||
<a href={generalSettings.faqLink} target="_blank" className="hover:text-primary">
|
|
||||||
FAQ
|
|
||||||
</a>
|
|
||||||
<a href="https://devs.keenthemes.com" target="_blank" className="hover:text-primary">
|
|
||||||
Support
|
|
||||||
</a>
|
|
||||||
<a href={generalSettings.licenseLink} target="_blank" className="hover:text-primary">
|
|
||||||
License
|
|
||||||
</a>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
</Container>
|
|
||||||
</footer>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,99 +1,57 @@
|
|||||||
import { Menu, SquareChevronRight } from 'lucide-react';
|
import { Menu } from 'lucide-react';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useLocation } from 'react-router';
|
import { useLocation } from 'react-router-dom';
|
||||||
import { Link } from 'react-router-dom';
|
|
||||||
import { useIsMobile, useScrollPosition } from '@crux-kit/hooks';
|
import { useIsMobile, useScrollPosition } from '@crux-kit/hooks';
|
||||||
import { cn } from '@crux-kit/utils';
|
import { cn } from '@crux-kit/utils';
|
||||||
import { toAbsoluteUrl } from '#shared/lib/helpers';
|
|
||||||
import { Button } from '#shared/ui/Button';
|
import { Button } from '#shared/ui/Button';
|
||||||
import { Sheet, SheetBody, SheetContent, SheetHeader, SheetTrigger } from '#shared/ui/Sheet';
|
import { Sheet, SheetBody, SheetContent, SheetHeader, SheetTrigger } from '#shared/ui/Sheet';
|
||||||
import { Container } from '#shared/widgets/common/Container';
|
import { Container } from '#shared/widgets/common/Container';
|
||||||
import { UserDropdownMenu } from '#shared/widgets/UserDropdownMenu';
|
import { AppLogo, UserAvatarMenu } from '../../shared';
|
||||||
import { MegaMenu } from './MegaMenu';
|
|
||||||
import { MegaMenuMobile } from './MegaMenuMobile';
|
|
||||||
import { SidebarMenu } from './SidebarMenu';
|
import { SidebarMenu } from './SidebarMenu';
|
||||||
|
|
||||||
export function Header() {
|
export function Header() {
|
||||||
const [isSidebarSheetOpen, setIsSidebarSheetOpen] = useState(false);
|
const [isSidebarSheetOpen, setIsSidebarSheetOpen] = useState(false);
|
||||||
const [isMegaMenuSheetOpen, setIsMegaMenuSheetOpen] = useState(false);
|
|
||||||
|
|
||||||
const { pathname } = useLocation();
|
const { pathname } = useLocation();
|
||||||
const mobileMode = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
const scrollPosition = useScrollPosition();
|
const scrollPosition = useScrollPosition();
|
||||||
const headerSticky: boolean = scrollPosition > 0;
|
const headerSticky: boolean = scrollPosition > 0;
|
||||||
|
|
||||||
// Close sheet when route changes
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setIsSidebarSheetOpen(false);
|
setIsSidebarSheetOpen(false);
|
||||||
setIsMegaMenuSheetOpen(false);
|
|
||||||
}, [pathname]);
|
}, [pathname]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header
|
<header
|
||||||
className={cn(
|
className={cn(
|
||||||
'header bg-background fixed start-0 end-0 top-0 z-10 flex shrink-0 items-stretch border-b border-transparent pe-(--removed-body-scroll-bar-size,0px)',
|
'header bg-background fixed inset-s-0 inset-e-0 top-0 z-10 flex shrink-0 items-stretch border-b border-transparent pe-(--removed-body-scroll-bar-size,0px)',
|
||||||
headerSticky && 'border-border border-b'
|
headerSticky && 'border-border border-b'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Container className="flex items-stretch justify-between lg:gap-4">
|
<Container className="flex items-stretch justify-between lg:gap-4">
|
||||||
{/* HeaderLogo */}
|
|
||||||
<div className="flex items-center gap-2.5 lg:hidden">
|
<div className="flex items-center gap-2.5 lg:hidden">
|
||||||
<Link to="/" className="shrink-0">
|
<AppLogo className="h-6.25" />
|
||||||
<img
|
|
||||||
src={toAbsoluteUrl('/media/app/mini-logo.svg')}
|
{isMobile && (
|
||||||
className="h-6.25 w-full"
|
<Sheet open={isSidebarSheetOpen} onOpenChange={setIsSidebarSheetOpen}>
|
||||||
alt="mini-logo"
|
<SheetTrigger asChild>
|
||||||
/>
|
<Button variant="ghost" mode="icon">
|
||||||
</Link>
|
<Menu className="text-muted-foreground/70" />
|
||||||
<div className="flex items-center">
|
</Button>
|
||||||
{mobileMode && (
|
</SheetTrigger>
|
||||||
<Sheet open={isSidebarSheetOpen} onOpenChange={setIsSidebarSheetOpen}>
|
<SheetContent className="w-68.75 gap-0 p-0" side="left" close={false}>
|
||||||
<SheetTrigger asChild>
|
<SheetHeader className="space-y-0 p-0" />
|
||||||
<Button variant="ghost" mode="icon">
|
<SheetBody className="overflow-y-auto p-0">
|
||||||
<Menu className="text-muted-foreground/70" />
|
<SidebarMenu />
|
||||||
</Button>
|
</SheetBody>
|
||||||
</SheetTrigger>
|
</SheetContent>
|
||||||
<SheetContent className="w-68.75 gap-0 p-0" side="left" close={false}>
|
</Sheet>
|
||||||
<SheetHeader className="space-y-0 p-0" />
|
)}
|
||||||
<SheetBody className="overflow-y-auto p-0">
|
|
||||||
<SidebarMenu />
|
|
||||||
</SheetBody>
|
|
||||||
</SheetContent>
|
|
||||||
</Sheet>
|
|
||||||
)}
|
|
||||||
{mobileMode && (
|
|
||||||
<Sheet open={isMegaMenuSheetOpen} onOpenChange={setIsMegaMenuSheetOpen}>
|
|
||||||
<SheetTrigger asChild>
|
|
||||||
<Button variant="ghost" mode="icon">
|
|
||||||
<SquareChevronRight className="text-muted-foreground/70" />
|
|
||||||
</Button>
|
|
||||||
</SheetTrigger>
|
|
||||||
<SheetContent className="w-68.75 gap-0 p-0" side="left" close={false}>
|
|
||||||
<SheetHeader className="space-y-0 p-0" />
|
|
||||||
<SheetBody className="overflow-y-auto p-0">
|
|
||||||
<MegaMenuMobile />
|
|
||||||
</SheetBody>
|
|
||||||
</SheetContent>
|
|
||||||
</Sheet>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Main Content (MegaMenu) */}
|
<div className="flex grow items-center justify-end gap-3">
|
||||||
{!mobileMode && <MegaMenu />}
|
<UserAvatarMenu />
|
||||||
|
|
||||||
{/* HeaderTopbar */}
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<UserDropdownMenu
|
|
||||||
trigger={
|
|
||||||
<img
|
|
||||||
className="size-9 shrink-0 cursor-pointer rounded-full border-2 border-green-500"
|
|
||||||
src={toAbsoluteUrl('/media/avatars/300-2.png')}
|
|
||||||
alt="User Avatar"
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</Container>
|
</Container>
|
||||||
</header>
|
</header>
|
||||||
|
|||||||
@@ -1,119 +0,0 @@
|
|||||||
import {
|
|
||||||
NavigationMenu,
|
|
||||||
// NavigationMenuContent,
|
|
||||||
// NavigationMenuItem,
|
|
||||||
// NavigationMenuLink,
|
|
||||||
NavigationMenuList
|
|
||||||
// NavigationMenuTrigger,
|
|
||||||
} from '#shared/ui/NavigationMenu';
|
|
||||||
|
|
||||||
// import { useMenu } from '#shared/hooks/use_menu';
|
|
||||||
// import { cn } from '@crux-kit/utils';
|
|
||||||
// import { MegaMenuSubAccount } from '#shared/widgets/mega_menu/mega_menu_sub_account';
|
|
||||||
// import { MegaMenuSubAuth } from '#shared/widgets/mega_menu/mega_menu_sub_auth';
|
|
||||||
// import { MegaMenuSubNetwork } from '#shared/widgets/mega_menu/mega_menu_sub_network';
|
|
||||||
// import { MegaMenuSubProfiles } from '#shared/widgets/mega_menu/mega_menu_sub_profiles';
|
|
||||||
// import { MegaMenuSubStore } from '#shared/widgets/mega_menu/mega_menu_sub_store';
|
|
||||||
|
|
||||||
export function MegaMenu() {
|
|
||||||
// const { pathname } = useLocation();
|
|
||||||
// const { isActive, hasActiveChild } = useMenu(pathname);
|
|
||||||
// const homeItem = MENU_MEGA[0];
|
|
||||||
// const publicProfilesItem = MENU_MEGA[1];
|
|
||||||
// const myAccountItem = MENU_MEGA[2];
|
|
||||||
// const networkItem = MENU_MEGA[3];
|
|
||||||
// const authItem = MENU_MEGA[4];
|
|
||||||
// const storeItem = MENU_MEGA[5];
|
|
||||||
|
|
||||||
// const linkClass = `
|
|
||||||
// text-sm text-secondary-foreground font-medium
|
|
||||||
// hover:text-primary hover:bg-transparent
|
|
||||||
// focus:text-primary focus:bg-transparent
|
|
||||||
// data-[active=true]:text-primary data-[active=true]:bg-transparent
|
|
||||||
// data-[state=open]:text-primary data-[state=open]:bg-transparent
|
|
||||||
// `;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<NavigationMenu>
|
|
||||||
<NavigationMenuList className="gap-0">
|
|
||||||
{/* Home Item */}
|
|
||||||
{/* <NavigationMenuItem>
|
|
||||||
<NavigationMenuLink asChild>
|
|
||||||
<Link
|
|
||||||
to={homeItem.path || '/'}
|
|
||||||
className={cn(linkClass)}
|
|
||||||
data-active={isActive(homeItem.path) || undefined}
|
|
||||||
>
|
|
||||||
{homeItem.title}
|
|
||||||
</Link>
|
|
||||||
</NavigationMenuLink>
|
|
||||||
</NavigationMenuItem> */}
|
|
||||||
|
|
||||||
{/* Public Profiles Item */}
|
|
||||||
{/* <NavigationMenuItem>
|
|
||||||
<NavigationMenuTrigger
|
|
||||||
className={cn(linkClass)}
|
|
||||||
data-active={hasActiveChild(publicProfilesItem.children) || undefined}
|
|
||||||
>
|
|
||||||
{publicProfilesItem.title}
|
|
||||||
</NavigationMenuTrigger>
|
|
||||||
<NavigationMenuContent className="p-0">
|
|
||||||
<MegaMenuSubProfiles items={MENU_MEGA} />
|
|
||||||
</NavigationMenuContent>
|
|
||||||
</NavigationMenuItem> */}
|
|
||||||
|
|
||||||
{/* My Account Item */}
|
|
||||||
{/* <NavigationMenuItem>
|
|
||||||
<NavigationMenuTrigger
|
|
||||||
className={cn(linkClass)}
|
|
||||||
data-active={hasActiveChild(myAccountItem.children) || undefined}
|
|
||||||
>
|
|
||||||
{myAccountItem.title}
|
|
||||||
</NavigationMenuTrigger>
|
|
||||||
<NavigationMenuContent className="p-0">
|
|
||||||
<MegaMenuSubAccount items={MENU_MEGA} />
|
|
||||||
</NavigationMenuContent>
|
|
||||||
</NavigationMenuItem> */}
|
|
||||||
|
|
||||||
{/* Network Item */}
|
|
||||||
{/* <NavigationMenuItem>
|
|
||||||
<NavigationMenuTrigger
|
|
||||||
className={cn(linkClass)}
|
|
||||||
data-active={hasActiveChild(networkItem.children || []) || undefined}
|
|
||||||
>
|
|
||||||
{networkItem.title}
|
|
||||||
</NavigationMenuTrigger>
|
|
||||||
<NavigationMenuContent className="p-0">
|
|
||||||
<MegaMenuSubNetwork items={MENU_MEGA} />
|
|
||||||
</NavigationMenuContent>
|
|
||||||
</NavigationMenuItem> */}
|
|
||||||
|
|
||||||
{/* Store Item */}
|
|
||||||
{/* <NavigationMenuItem>
|
|
||||||
<NavigationMenuTrigger
|
|
||||||
className={cn(linkClass)}
|
|
||||||
data-active={hasActiveChild(storeItem.children || []) || undefined}
|
|
||||||
>
|
|
||||||
{storeItem.title}
|
|
||||||
</NavigationMenuTrigger>
|
|
||||||
<NavigationMenuContent className="p-0">
|
|
||||||
<MegaMenuSubStore items={MENU_MEGA} />
|
|
||||||
</NavigationMenuContent>
|
|
||||||
</NavigationMenuItem> */}
|
|
||||||
|
|
||||||
{/* Authentication Item */}
|
|
||||||
{/* <NavigationMenuItem>
|
|
||||||
<NavigationMenuTrigger
|
|
||||||
className={cn(linkClass)}
|
|
||||||
data-active={hasActiveChild(authItem.children) || undefined}
|
|
||||||
>
|
|
||||||
{authItem.title}
|
|
||||||
</NavigationMenuTrigger>
|
|
||||||
<NavigationMenuContent className="p-0">
|
|
||||||
<MegaMenuSubAuth items={MENU_MEGA} />
|
|
||||||
</NavigationMenuContent>
|
|
||||||
</NavigationMenuItem> */}
|
|
||||||
</NavigationMenuList>
|
|
||||||
</NavigationMenu>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,197 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { LucideIcon } from 'lucide-react';
|
|
||||||
import { JSX, useCallback } from 'react';
|
|
||||||
import { Link, useLocation } from 'react-router-dom';
|
|
||||||
import { cn } from '@crux-kit/utils';
|
|
||||||
// import { MENU_MEGA_MOBILE } from '#app/menu';
|
|
||||||
import {
|
|
||||||
AccordionMenu,
|
|
||||||
AccordionMenuClassNames,
|
|
||||||
AccordionMenuGroup,
|
|
||||||
AccordionMenuItem,
|
|
||||||
// AccordionMenuLabel,
|
|
||||||
AccordionMenuSub,
|
|
||||||
AccordionMenuSubContent,
|
|
||||||
AccordionMenuSubTrigger
|
|
||||||
} from '#shared/ui/AccordionMenu';
|
|
||||||
import { Badge } from '#shared/ui/Badge';
|
|
||||||
|
|
||||||
export interface MenuItem {
|
|
||||||
title?: string;
|
|
||||||
icon?: LucideIcon;
|
|
||||||
path?: string;
|
|
||||||
rootPath?: string;
|
|
||||||
childrenIndex?: number;
|
|
||||||
heading?: string;
|
|
||||||
children?: MenuItem[];
|
|
||||||
disabled?: boolean;
|
|
||||||
collapse?: boolean;
|
|
||||||
collapseTitle?: string;
|
|
||||||
expandTitle?: string;
|
|
||||||
badge?: string;
|
|
||||||
separator?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type MenuConfig = MenuItem[];
|
|
||||||
|
|
||||||
export function MegaMenuMobile() {
|
|
||||||
const { pathname } = useLocation();
|
|
||||||
|
|
||||||
// Memoize matchPath to prevent unnecessary re-renders
|
|
||||||
const matchPath = useCallback(
|
|
||||||
(path: string): boolean => path === pathname || (path.length > 1 && pathname.startsWith(path)),
|
|
||||||
[pathname]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Global classNames for consistent styling
|
|
||||||
const classNames: AccordionMenuClassNames = {
|
|
||||||
root: 'space-y-1',
|
|
||||||
group: 'gap-px',
|
|
||||||
label: 'uppercase text-xs font-medium text-muted-foreground/70 pt-2.25 pb-px',
|
|
||||||
separator: '',
|
|
||||||
item: 'h-8 hover:bg-transparent text-accent-foreground hover:text-primary data-[selected=true]:text-primary data-[selected=true]:bg-muted data-[selected=true]:font-medium',
|
|
||||||
sub: '',
|
|
||||||
subTrigger:
|
|
||||||
'h-8 hover:bg-transparent text-accent-foreground hover:text-primary data-[selected=true]:text-primary data-[selected=true]:bg-muted data-[selected=true]:font-medium',
|
|
||||||
subContent: 'py-0',
|
|
||||||
indicator: ''
|
|
||||||
};
|
|
||||||
|
|
||||||
// const buildMenu = (items: MenuConfig): JSX.Element[] => {
|
|
||||||
// return items.map((item: MenuItem, index: number) => {
|
|
||||||
// if (item.heading) {
|
|
||||||
// return buildMenuHeading(item, index);
|
|
||||||
// } else if (!item.disabled) {
|
|
||||||
// return buildMenuItemRoot(item, index);
|
|
||||||
// } else {
|
|
||||||
// return <></>;
|
|
||||||
// }
|
|
||||||
// });
|
|
||||||
// };
|
|
||||||
|
|
||||||
// const buildMenuItemRoot = (item: MenuItem, index: number): JSX.Element => {
|
|
||||||
// if (item.children) {
|
|
||||||
// return (
|
|
||||||
// <AccordionMenuSub key={index} value={item.path || `root-${index}`}>
|
|
||||||
// <AccordionMenuSubTrigger className="text-sm font-medium">
|
|
||||||
// {item.icon && <item.icon data-slot="accordion-menu-icon" />}
|
|
||||||
// <div className="flex items-center justify-between grow gap-2">
|
|
||||||
// <span data-slot="accordion-menu-title">{item.title}</span>
|
|
||||||
// {item.badge && (
|
|
||||||
// <Badge variant="secondary" size="sm" className="ms-auto">
|
|
||||||
// {item.badge}
|
|
||||||
// </Badge>
|
|
||||||
// )}
|
|
||||||
// </div>
|
|
||||||
// </AccordionMenuSubTrigger>
|
|
||||||
// <AccordionMenuSubContent
|
|
||||||
// type="single"
|
|
||||||
// collapsible
|
|
||||||
// parentValue={item.path || `root-${index}`}
|
|
||||||
// className="ps-6"
|
|
||||||
// >
|
|
||||||
// <AccordionMenuGroup>{buildMenuItemChildren(item.children, 1)}</AccordionMenuGroup>
|
|
||||||
// </AccordionMenuSubContent>
|
|
||||||
// </AccordionMenuSub>
|
|
||||||
// );
|
|
||||||
// } else {
|
|
||||||
// return (
|
|
||||||
// <AccordionMenuItem key={index} value={item.path || ''} className="text-sm font-medium">
|
|
||||||
// <Link to={item.path || '#'} className="">
|
|
||||||
// {item.icon && <item.icon data-slot="accordion-menu-icon" />}
|
|
||||||
// <div className="flex items-center justify-between grow gap-2">
|
|
||||||
// <span data-slot="accordion-menu-title">{item.title}</span>
|
|
||||||
// {item.badge && (
|
|
||||||
// <Badge variant="secondary" size="sm" className="ms-auto">
|
|
||||||
// {item.badge}
|
|
||||||
// </Badge>
|
|
||||||
// )}
|
|
||||||
// </div>
|
|
||||||
// </Link>
|
|
||||||
// </AccordionMenuItem>
|
|
||||||
// );
|
|
||||||
// }
|
|
||||||
// };
|
|
||||||
|
|
||||||
const buildMenuItemChildren = (items: MenuConfig, level: number = 0): JSX.Element[] => {
|
|
||||||
return items.map((item: MenuItem, index: number) => {
|
|
||||||
if (!item.disabled) {
|
|
||||||
return buildMenuItemChild(item, index, level);
|
|
||||||
} else {
|
|
||||||
return <></>;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const buildMenuItemChild = (item: MenuItem, index: number, level: number = 0): JSX.Element => {
|
|
||||||
if (item.children) {
|
|
||||||
return (
|
|
||||||
<AccordionMenuSub key={index} value={item.path || `child-${level}-${index}`}>
|
|
||||||
<AccordionMenuSubTrigger className="text-[13px]">
|
|
||||||
{item.icon && <item.icon data-slot="accordion-menu-icon" />}
|
|
||||||
{item.collapse ? (
|
|
||||||
<span className="text-muted-foreground">
|
|
||||||
<span className="hidden [[data-state=open]>span>&]:inline">
|
|
||||||
{item.collapseTitle}
|
|
||||||
</span>
|
|
||||||
<span className="inline [[data-state=open]>span>&]:hidden">{item.expandTitle}</span>
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
item.title
|
|
||||||
)}
|
|
||||||
{item.badge && (
|
|
||||||
<Badge variant="secondary" size="sm" className="ms-auto">
|
|
||||||
{item.badge}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</AccordionMenuSubTrigger>
|
|
||||||
<AccordionMenuSubContent
|
|
||||||
type="single"
|
|
||||||
collapsible
|
|
||||||
parentValue={item.path || `child-${level}-${index}`}
|
|
||||||
className={cn('ps-4', !item.collapse && 'relative')}
|
|
||||||
>
|
|
||||||
<AccordionMenuGroup>
|
|
||||||
{buildMenuItemChildren(item.children, item.collapse ? level : level + 1)}
|
|
||||||
</AccordionMenuGroup>
|
|
||||||
</AccordionMenuSubContent>
|
|
||||||
</AccordionMenuSub>
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
return (
|
|
||||||
<AccordionMenuItem key={index} value={item.path || ''} className="text-[13px]">
|
|
||||||
<Link to={item.path || '#'}>
|
|
||||||
{item.icon && <item.icon data-slot="accordion-menu-icon" />}
|
|
||||||
<div className="flex grow items-center justify-between gap-2">
|
|
||||||
<span>{item.title}</span>
|
|
||||||
{item.badge && (
|
|
||||||
<Badge variant="secondary" size="sm" className="ms-auto">
|
|
||||||
{item.badge}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
</AccordionMenuItem>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// const buildMenuHeading = (item: MenuItem, index: number): JSX.Element => {
|
|
||||||
// return <AccordionMenuLabel key={index}>{item.heading}</AccordionMenuLabel>;
|
|
||||||
// };
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex shrink-0 grow px-5 py-5">
|
|
||||||
<AccordionMenu
|
|
||||||
selectedValue={pathname}
|
|
||||||
matchPath={matchPath}
|
|
||||||
type="single"
|
|
||||||
collapsible
|
|
||||||
classNames={classNames}
|
|
||||||
>
|
|
||||||
{/* {buildMenu(MENU_MEGA_MOBILE)} */}
|
|
||||||
</AccordionMenu>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,4 +1,3 @@
|
|||||||
import { useLocation } from 'react-router-dom';
|
|
||||||
import { cn } from '@crux-kit/utils';
|
import { cn } from '@crux-kit/utils';
|
||||||
import { LayoutNames } from '#app/config/types';
|
import { LayoutNames } from '#app/config/types';
|
||||||
import { useSettings } from '#app/providers/SettingsProvider';
|
import { useSettings } from '#app/providers/SettingsProvider';
|
||||||
@@ -7,15 +6,12 @@ import { SidebarMenu } from './SidebarMenu';
|
|||||||
|
|
||||||
export function Sidebar() {
|
export function Sidebar() {
|
||||||
const { settings } = useSettings();
|
const { settings } = useSettings();
|
||||||
const { pathname } = useLocation();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'sidebar bg-background lg:border-border shrink-0 flex-col items-stretch lg:fixed lg:top-0 lg:bottom-0 lg:z-20 lg:flex lg:border-e',
|
'sidebar bg-background lg:border-border shrink-0 flex-col items-stretch lg:fixed lg:top-0 lg:bottom-0 lg:z-20 lg:flex lg:border-e',
|
||||||
(settings.layouts[LayoutNames.DEFAULT].sidebarTheme === 'dark' ||
|
settings.layouts[LayoutNames.DEFAULT].sidebarTheme === 'dark' && 'dark'
|
||||||
pathname.includes('dark-sidebar')) &&
|
|
||||||
'dark'
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<SidebarHeader />
|
<SidebarHeader />
|
||||||
|
|||||||
@@ -1,59 +1,34 @@
|
|||||||
import { ChevronFirst } from 'lucide-react';
|
import { ChevronFirst } from 'lucide-react';
|
||||||
import { Link } from 'react-router-dom';
|
|
||||||
import { cn } from '@crux-kit/utils';
|
import { cn } from '@crux-kit/utils';
|
||||||
import { LayoutNames } from '#app/config/types';
|
import { LayoutNames } from '#app/config/types';
|
||||||
import { useSettings } from '#app/providers/SettingsProvider';
|
import { useSettings } from '#app/providers/SettingsProvider';
|
||||||
import { toAbsoluteUrl } from '#shared/lib/helpers';
|
|
||||||
import { Button } from '#shared/ui/Button';
|
import { Button } from '#shared/ui/Button';
|
||||||
|
import { AppLogo } from '../../shared';
|
||||||
|
|
||||||
export function SidebarHeader() {
|
export function SidebarHeader() {
|
||||||
const { settings, storeOption } = useSettings();
|
const { settings, storeOption } = useSettings();
|
||||||
|
|
||||||
|
const collapsed = settings.layouts[LayoutNames.DEFAULT].sidebarCollapse;
|
||||||
|
|
||||||
const handleToggleClick = () => {
|
const handleToggleClick = () => {
|
||||||
storeOption(
|
storeOption(`layouts.${LayoutNames.DEFAULT}.sidebarCollapse`, !collapsed);
|
||||||
`layouts.${LayoutNames.DEFAULT}.sidebarCollapse`,
|
|
||||||
!settings.layouts[LayoutNames.DEFAULT].sidebarCollapse
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="sidebar-header relative hidden shrink-0 items-center justify-between px-3 lg:flex lg:px-6">
|
<div className="sidebar-header relative hidden shrink-0 items-center px-5 lg:flex">
|
||||||
<Link to="/">
|
<AppLogo className="size-9 max-w-none" />
|
||||||
<div className="dark:hidden">
|
<span className="default-logo ml-2 text-2xl font-bold tracking-widest whitespace-nowrap">
|
||||||
{/* <img
|
C.R.U.X
|
||||||
src={toAbsoluteUrl('/media/app/default-logo.svg')}
|
</span>
|
||||||
className="default-logo h-[22px] max-w-none"
|
|
||||||
alt="Default Logo"
|
|
||||||
/>
|
|
||||||
<img
|
|
||||||
src={toAbsoluteUrl('/media/app/mini-logo.svg')}
|
|
||||||
className="small-logo h-[22px] max-w-none"
|
|
||||||
alt="Mini Logo"
|
|
||||||
/> */}
|
|
||||||
</div>
|
|
||||||
<div className="hidden dark:block">
|
|
||||||
{/* <img
|
|
||||||
src={toAbsoluteUrl('/media/app/default-logo-dark.svg')}
|
|
||||||
className="default-logo h-[22px] max-w-none"
|
|
||||||
alt="Default Dark Logo"
|
|
||||||
/> */}
|
|
||||||
<img
|
|
||||||
src={toAbsoluteUrl('/media/app/mini-logo.svg')}
|
|
||||||
className="small-logo h-5.5 max-w-none"
|
|
||||||
alt="Mini Logo"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
<Button
|
<Button
|
||||||
onClick={handleToggleClick}
|
onClick={handleToggleClick}
|
||||||
size="sm"
|
size="sm"
|
||||||
mode="icon"
|
mode="icon"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
aria-label={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
|
||||||
className={cn(
|
className={cn(
|
||||||
'absolute start-full top-2/4 size-7 -translate-x-2/4 -translate-y-2/4 rtl:translate-x-2/4',
|
'absolute inset-s-full top-2/4 size-7 -translate-x-2/4 -translate-y-2/4 rtl:translate-x-2/4',
|
||||||
settings.layouts[LayoutNames.DEFAULT].sidebarCollapse
|
collapsed ? 'ltr:rotate-180' : 'rtl:rotate-180'
|
||||||
? 'ltr:rotate-180'
|
|
||||||
: 'rtl:rotate-180'
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<ChevronFirst className="size-4!" />
|
<ChevronFirst className="size-4!" />
|
||||||
|
|||||||
@@ -1,181 +1,22 @@
|
|||||||
'use client';
|
import { SidebarAccordionMenu } from '../../shared';
|
||||||
|
|
||||||
import { JSX, useMemo } from 'react';
|
|
||||||
import { Link, useLocation } from 'react-router-dom';
|
|
||||||
import { cn } from '@crux-kit/utils';
|
|
||||||
import { MenuConfig, MenuItem } from '#app/config/types';
|
|
||||||
import { useMenuConfig } from '#app/menu';
|
|
||||||
import { createMenuPathMatcher } from '#shared/lib/menuPath';
|
|
||||||
import { AccordionMenu, AccordionMenuClassNames, AccordionMenuGroup, AccordionMenuItem, AccordionMenuLabel, AccordionMenuSub, AccordionMenuSubContent, AccordionMenuSubTrigger } from '#shared/ui/AccordionMenu';
|
|
||||||
import { Badge } from '#shared/ui/Badge';
|
|
||||||
|
|
||||||
export function SidebarMenu() {
|
export function SidebarMenu() {
|
||||||
const { pathname } = useLocation();
|
|
||||||
const menuConfig = useMenuConfig();
|
|
||||||
|
|
||||||
// Memoize matchPath to prevent unnecessary re-renders
|
|
||||||
const matchPath = useMemo(
|
|
||||||
() => createMenuPathMatcher(menuConfig, pathname),
|
|
||||||
[menuConfig, pathname]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Global classNames for consistent styling
|
|
||||||
const classNames: AccordionMenuClassNames = {
|
|
||||||
root: 'lg:ps-1 space-y-3',
|
|
||||||
group: 'gap-px',
|
|
||||||
label: 'uppercase text-xs font-medium text-muted-foreground/70 pt-2.25 pb-px',
|
|
||||||
separator: '',
|
|
||||||
item: 'h-8 hover:bg-transparent text-accent-foreground hover:text-primary data-[selected=true]:text-primary data-[selected=true]:bg-muted data-[selected=true]:font-medium',
|
|
||||||
sub: '',
|
|
||||||
subTrigger:
|
|
||||||
'h-8 hover:bg-transparent text-accent-foreground hover:text-primary data-[selected=true]:text-primary data-[selected=true]:bg-muted data-[selected=true]:font-medium',
|
|
||||||
subContent: 'py-0',
|
|
||||||
indicator: ''
|
|
||||||
};
|
|
||||||
|
|
||||||
const buildMenu = (items: MenuConfig): JSX.Element[] => {
|
|
||||||
return items.map((item: MenuItem, index: number) => {
|
|
||||||
if (item.heading) {
|
|
||||||
return buildMenuHeading(item, index);
|
|
||||||
} else if (item.disabled) {
|
|
||||||
return buildMenuItemRootDisabled(item, index);
|
|
||||||
} else {
|
|
||||||
return buildMenuItemRoot(item, index);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const buildMenuItemRoot = (item: MenuItem, index: number): JSX.Element => {
|
|
||||||
if (item.children) {
|
|
||||||
return (
|
|
||||||
<AccordionMenuSub key={index} value={item.path || `root-${index}`}>
|
|
||||||
<AccordionMenuSubTrigger className="text-sm font-medium">
|
|
||||||
{item.icon && <item.icon data-slot="accordion-menu-icon" />}
|
|
||||||
<span data-slot="accordion-menu-title">{item.title}</span>
|
|
||||||
</AccordionMenuSubTrigger>
|
|
||||||
<AccordionMenuSubContent
|
|
||||||
type="single"
|
|
||||||
collapsible
|
|
||||||
parentValue={item.path || `root-${index}`}
|
|
||||||
className="ps-6"
|
|
||||||
>
|
|
||||||
<AccordionMenuGroup>{buildMenuItemChildren(item.children, 1)}</AccordionMenuGroup>
|
|
||||||
</AccordionMenuSubContent>
|
|
||||||
</AccordionMenuSub>
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
return (
|
|
||||||
<AccordionMenuItem key={index} value={item.path || ''} className="text-sm font-medium">
|
|
||||||
<Link to={item.path || '#'} className="flex grow items-center justify-start gap-2">
|
|
||||||
{item.icon && <item.icon data-slot="accordion-menu-icon" />}
|
|
||||||
<span data-slot="accordion-menu-title">{item.title}</span>
|
|
||||||
</Link>
|
|
||||||
</AccordionMenuItem>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const buildMenuItemRootDisabled = (item: MenuItem, index: number): JSX.Element => {
|
|
||||||
return (
|
|
||||||
<AccordionMenuItem key={index} value={`disabled-${index}`} className="text-sm font-medium">
|
|
||||||
{item.icon && <item.icon data-slot="accordion-menu-icon" />}
|
|
||||||
<span data-slot="accordion-menu-title">{item.title}</span>
|
|
||||||
{item.disabled && (
|
|
||||||
<Badge variant="secondary" size="sm" className="ms-auto -me-2.5">
|
|
||||||
Soon
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</AccordionMenuItem>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const buildMenuItemChildren = (items: MenuConfig, level: number = 0): JSX.Element[] => {
|
|
||||||
return items.map((item: MenuItem, index: number) => {
|
|
||||||
if (item.disabled) {
|
|
||||||
return buildMenuItemChildDisabled(item, index, level);
|
|
||||||
} else {
|
|
||||||
return buildMenuItemChild(item, index, level);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const buildMenuItemChild = (item: MenuItem, index: number, level: number = 0): JSX.Element => {
|
|
||||||
if (item.children) {
|
|
||||||
return (
|
|
||||||
<AccordionMenuSub key={index} value={item.path || `child-${level}-${index}`}>
|
|
||||||
<AccordionMenuSubTrigger className="text-[13px]">
|
|
||||||
{item.collapse ? (
|
|
||||||
<span className="text-muted-foreground">
|
|
||||||
<span className="hidden [[data-state=open]>span>&]:inline">
|
|
||||||
{item.collapseTitle}
|
|
||||||
</span>
|
|
||||||
<span className="inline [[data-state=open]>span>&]:hidden">{item.expandTitle}</span>
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
item.title
|
|
||||||
)}
|
|
||||||
</AccordionMenuSubTrigger>
|
|
||||||
<AccordionMenuSubContent
|
|
||||||
type="single"
|
|
||||||
collapsible
|
|
||||||
parentValue={item.path || `child-${level}-${index}`}
|
|
||||||
className={cn(
|
|
||||||
'ps-4',
|
|
||||||
!item.collapse && 'relative',
|
|
||||||
!item.collapse && (level > 0 ? '' : '')
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<AccordionMenuGroup>
|
|
||||||
{buildMenuItemChildren(item.children, item.collapse ? level : level + 1)}
|
|
||||||
</AccordionMenuGroup>
|
|
||||||
</AccordionMenuSubContent>
|
|
||||||
</AccordionMenuSub>
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
return (
|
|
||||||
<AccordionMenuItem key={index} value={item.path || ''} className="text-[13px]">
|
|
||||||
<Link to={item.path || '#'}>{item.title}</Link>
|
|
||||||
</AccordionMenuItem>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const buildMenuItemChildDisabled = (
|
|
||||||
item: MenuItem,
|
|
||||||
index: number,
|
|
||||||
level: number = 0
|
|
||||||
): JSX.Element => {
|
|
||||||
return (
|
|
||||||
<AccordionMenuItem
|
|
||||||
key={index}
|
|
||||||
value={`disabled-child-${level}-${index}`}
|
|
||||||
className="text-[13px]"
|
|
||||||
>
|
|
||||||
<span data-slot="accordion-menu-title">{item.title}</span>
|
|
||||||
{item.disabled && (
|
|
||||||
<Badge variant="secondary" size="sm" className="ms-auto -me-2.5">
|
|
||||||
Soon
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</AccordionMenuItem>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const buildMenuHeading = (item: MenuItem, index: number): JSX.Element => {
|
|
||||||
return <AccordionMenuLabel key={index}>{item.heading}</AccordionMenuLabel>;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="kt-scrollable-y-hover flex shrink-0 grow px-5 py-5 lg:max-h-[calc(100vh-5.5rem)]">
|
<div className="kt-scrollable-y-hover flex shrink-0 grow px-5 py-5 lg:max-h-[calc(100vh-5.5rem)]">
|
||||||
<AccordionMenu
|
<SidebarAccordionMenu
|
||||||
selectedValue={pathname}
|
classNames={{
|
||||||
matchPath={matchPath}
|
root: 'lg:ps-1 space-y-3',
|
||||||
type="single"
|
group: 'gap-px',
|
||||||
collapsible
|
label: 'uppercase text-xs font-medium text-muted-foreground/70 pt-2.25 pb-px',
|
||||||
classNames={classNames}
|
separator: '',
|
||||||
>
|
item: 'h-8 hover:bg-transparent text-accent-foreground hover:text-primary data-[selected=true]:text-primary data-[selected=true]:bg-muted data-[selected=true]:font-medium',
|
||||||
{buildMenu(menuConfig)}
|
sub: '',
|
||||||
</AccordionMenu>
|
subTrigger:
|
||||||
|
'h-8 hover:bg-transparent text-accent-foreground hover:text-primary data-[selected=true]:text-primary data-[selected=true]:bg-muted data-[selected=true]:font-medium',
|
||||||
|
subContent: 'py-0',
|
||||||
|
indicator: ''
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,86 +0,0 @@
|
|||||||
import { ChevronRight } from 'lucide-react';
|
|
||||||
import { Fragment, ReactNode } from 'react';
|
|
||||||
import { Link, useLocation } from 'react-router-dom';
|
|
||||||
import { cn } from '@crux-kit/utils';
|
|
||||||
import { MenuItem } from '#app/config/types';
|
|
||||||
import { useMenuConfig } from '#app/menu';
|
|
||||||
import { useMenu } from '#shared/hooks/useMenu';
|
|
||||||
|
|
||||||
export interface ToolbarHeadingProps {
|
|
||||||
title?: string | ReactNode;
|
|
||||||
description?: string | ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
function Toolbar({ children }: { children?: ReactNode }) {
|
|
||||||
return <div className="flex flex-wrap items-center justify-between gap-5 pb-7.5">{children}</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function ToolbarActions({ children }: { children?: ReactNode }) {
|
|
||||||
return <div className="flex items-center gap-2.5">{children}</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function ToolbarBreadcrumbs() {
|
|
||||||
const config = useMenuConfig();
|
|
||||||
const { pathname } = useLocation();
|
|
||||||
const { getBreadcrumb, isActive } = useMenu(pathname);
|
|
||||||
const items: MenuItem[] = getBreadcrumb(config);
|
|
||||||
|
|
||||||
if (items.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="[.header_&]:below-lg:hidden mb-2.5 flex items-center gap-1.25 text-xs font-medium lg:mb-0 lg:text-sm">
|
|
||||||
<div className="breadcrumb flex items-center gap-1">
|
|
||||||
{items.map((item, index) => {
|
|
||||||
const isLast = index === items.length - 1;
|
|
||||||
const active = item.path ? isActive(item.path) : false;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Fragment key={index}>
|
|
||||||
{item.path ? (
|
|
||||||
<Link
|
|
||||||
to={item.path}
|
|
||||||
className={cn(
|
|
||||||
'flex items-center gap-1',
|
|
||||||
active ? 'text-mono' : 'text-muted-foreground hover:text-primary'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{item.title}
|
|
||||||
</Link>
|
|
||||||
) : (
|
|
||||||
<span className={cn(isLast ? 'text-mono' : 'text-muted-foreground')}>
|
|
||||||
{item.title}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{!isLast && <ChevronRight className="muted-foreground size-3.5" />}
|
|
||||||
</Fragment>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ToolbarHeading({ title = '', description }: ToolbarHeadingProps) {
|
|
||||||
const { pathname } = useLocation();
|
|
||||||
const { getCurrentItem } = useMenu(pathname);
|
|
||||||
const config = useMenuConfig();
|
|
||||||
|
|
||||||
const item = getCurrentItem(config);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col justify-center gap-2">
|
|
||||||
<h1 className="text-mono text-xl leading-none font-medium">
|
|
||||||
{title || item?.title || 'Untitled'}
|
|
||||||
</h1>
|
|
||||||
{description && (
|
|
||||||
<div className="text-muted-foreground flex items-center gap-2 text-sm font-normal">
|
|
||||||
{description}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export { Toolbar, ToolbarActions, ToolbarBreadcrumbs, ToolbarHeading };
|
|
||||||
@@ -6,42 +6,40 @@ import { Outlet } from 'react-router-dom';
|
|||||||
import { useIsMobile } from '@crux-kit/hooks';
|
import { useIsMobile } from '@crux-kit/hooks';
|
||||||
import { LayoutNames } from '#app/config/types';
|
import { LayoutNames } from '#app/config/types';
|
||||||
import { useSettings } from '#app/providers/SettingsProvider';
|
import { useSettings } from '#app/providers/SettingsProvider';
|
||||||
import { Footer } from './components/Footer';
|
import { AppFooter, Toolbar, ToolbarHeading, useLayoutChrome, useLayoutSettings } from '../shared';
|
||||||
import { Header } from './components/Header';
|
import { Header } from './components/Header';
|
||||||
import { Sidebar } from './components/Sidebar';
|
import { Sidebar } from './components/Sidebar';
|
||||||
|
|
||||||
export function DefaultLayout() {
|
export function DefaultLayout() {
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
const { settings, setOption } = useSettings();
|
const { settings } = useSettings();
|
||||||
|
|
||||||
|
useLayoutSettings(LayoutNames.DEFAULT);
|
||||||
|
const { showToolbar, showFooter } = useLayoutChrome();
|
||||||
|
|
||||||
|
const sidebarCollapse = settings.layouts[LayoutNames.DEFAULT].sidebarCollapse;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const bodyClass = document.body.classList;
|
const bodyClass = document.body.classList;
|
||||||
|
|
||||||
if (settings.layouts[LayoutNames.DEFAULT].sidebarCollapse) {
|
if (sidebarCollapse) {
|
||||||
bodyClass.add('sidebar-collapse');
|
bodyClass.add('sidebar-collapse');
|
||||||
} else {
|
} else {
|
||||||
bodyClass.remove('sidebar-collapse');
|
bodyClass.remove('sidebar-collapse');
|
||||||
}
|
}
|
||||||
}, [settings]); // Runs only on settings update
|
}, [sidebarCollapse]);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// Set current layout
|
|
||||||
setOption('layout', LayoutNames.DEFAULT);
|
|
||||||
}, [setOption]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const bodyClass = document.body.classList;
|
const bodyClass = document.body.classList;
|
||||||
|
|
||||||
// Add a class to the body element
|
|
||||||
bodyClass.add(LayoutNames.DEFAULT);
|
bodyClass.add(LayoutNames.DEFAULT);
|
||||||
bodyClass.add('sidebar-fixed');
|
bodyClass.add('sidebar-fixed');
|
||||||
bodyClass.add('header-fixed');
|
bodyClass.add('header-fixed');
|
||||||
|
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
bodyClass.add('layout-initialized');
|
bodyClass.add('layout-initialized');
|
||||||
}, 1000); // 1000 milliseconds
|
}, 1000);
|
||||||
|
|
||||||
// Remove the class when the component is unmounted
|
|
||||||
return () => {
|
return () => {
|
||||||
bodyClass.remove(LayoutNames.DEFAULT);
|
bodyClass.remove(LayoutNames.DEFAULT);
|
||||||
bodyClass.remove('sidebar-fixed');
|
bodyClass.remove('sidebar-fixed');
|
||||||
@@ -50,7 +48,7 @@ export function DefaultLayout() {
|
|||||||
bodyClass.remove('layout-initialized');
|
bodyClass.remove('layout-initialized');
|
||||||
clearTimeout(timer);
|
clearTimeout(timer);
|
||||||
};
|
};
|
||||||
}, []); // Runs only once on mount
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -60,10 +58,15 @@ export function DefaultLayout() {
|
|||||||
<Header />
|
<Header />
|
||||||
|
|
||||||
<main className="grow pt-5" role="content">
|
<main className="grow pt-5" role="content">
|
||||||
|
{showToolbar && (
|
||||||
|
<Toolbar>
|
||||||
|
<ToolbarHeading />
|
||||||
|
</Toolbar>
|
||||||
|
)}
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<Footer />
|
{showFooter && <AppFooter />}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# Horizontal Menu layout
|
||||||
|
|
||||||
|
**Metronic 9 reference:** [Demo 2](https://keenthemes.com/metronic/tailwind/react/demo2)
|
||||||
|
|
||||||
|
A header carrying the logo and the account menu, with the application menu on a horizontal menubar
|
||||||
|
underneath it. The header becomes sticky and shrinks once the page is scrolled past
|
||||||
|
`layouts.horizontal-menu-layout.headerStickyOffset`.
|
||||||
|
|
||||||
|
## Composition
|
||||||
|
|
||||||
|
| Piece | Source |
|
||||||
|
| ----------------------- | --------------------------------------------- |
|
||||||
|
| Navigation menubar | `NavMenubar` from `../shared` |
|
||||||
|
| Mobile menu (sheet) | `SidebarAccordionMenu` from `../shared` |
|
||||||
|
| Toolbar and breadcrumbs | `Toolbar` / `ToolbarHeading` from `../shared` |
|
||||||
|
| Footer | `AppFooter` from `../shared` |
|
||||||
|
| Account menu | `UserAvatarMenu` from `../shared` |
|
||||||
|
|
||||||
|
Layout-specific: `Header`, `HeaderLogo`, `Navbar`.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Below `lg` the menubar is replaced by an accordion in a sheet, since a horizontal menubar cannot
|
||||||
|
hold the full menu on a narrow viewport.
|
||||||
|
- The `data-sticky-header` body attribute is removed on unmount so the next layout starts clean.
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
import { generalSettings } from '#app/config/general';
|
|
||||||
import { Container } from '#shared/widgets/common/Container';
|
|
||||||
|
|
||||||
export function Footer() {
|
|
||||||
const currentYear = new Date().getFullYear();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<footer className="footer">
|
|
||||||
<Container>
|
|
||||||
<div className="flex flex-col items-center justify-center gap-3 py-5 md:flex-row md:justify-between">
|
|
||||||
<div className="order-2 flex gap-2 text-sm font-normal md:order-1">
|
|
||||||
<span className="text-muted-foreground">{currentYear} ©</span>
|
|
||||||
<a
|
|
||||||
href="https://keenthemes.com"
|
|
||||||
target="_blank"
|
|
||||||
className="text-secondary-foreground hover:text-primary"
|
|
||||||
>
|
|
||||||
Keenthemes Inc.
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<nav className="text-muted-foreground order-1 flex gap-4 text-sm font-normal md:order-2">
|
|
||||||
<a href={generalSettings.docsLink} target="_blank" className="hover:text-primary">
|
|
||||||
Docs
|
|
||||||
</a>
|
|
||||||
<a href={generalSettings.purchaseLink} target="_blank" className="hover:text-primary">
|
|
||||||
Purchase
|
|
||||||
</a>
|
|
||||||
<a href={generalSettings.faqLink} target="_blank" className="hover:text-primary">
|
|
||||||
FAQ
|
|
||||||
</a>
|
|
||||||
<a href="https://devs.keenthemes.com" target="_blank" className="hover:text-primary">
|
|
||||||
Support
|
|
||||||
</a>
|
|
||||||
<a href={generalSettings.licenseLink} target="_blank" className="hover:text-primary">
|
|
||||||
License
|
|
||||||
</a>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
</Container>
|
|
||||||
</footer>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,44 +1,45 @@
|
|||||||
import { useEffect, useState } from 'react'; // Add useState to the imports
|
import { useEffect, useState } from 'react';
|
||||||
|
|
||||||
import { useScrollPosition } from '@crux-kit/hooks';
|
import { useScrollPosition } from '@crux-kit/hooks';
|
||||||
import { cn } from '@crux-kit/utils';
|
import { cn } from '@crux-kit/utils';
|
||||||
import { LayoutNames } from '#app/config/types';
|
import { LayoutNames } from '#app/config/types';
|
||||||
import { useSettings } from '#app/providers/SettingsProvider';
|
import { useSettings } from '#app/providers/SettingsProvider';
|
||||||
import { Container } from '#shared/widgets/common/Container';
|
import { Container } from '#shared/widgets/common/Container';
|
||||||
|
import { UserAvatarMenu } from '../../shared';
|
||||||
import { HeaderLogo } from './HeaderLogo';
|
import { HeaderLogo } from './HeaderLogo';
|
||||||
import { HeaderTopbar } from './HeaderTopbar';
|
|
||||||
|
|
||||||
export function Header() {
|
export function Header() {
|
||||||
const { settings } = useSettings();
|
const { settings } = useSettings();
|
||||||
const scrollPosition = useScrollPosition();
|
const scrollPosition = useScrollPosition();
|
||||||
const [headerStickyOn, setHeaderStickyOn] = useState(false);
|
const [headerStickyOn, setHeaderStickyOn] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
const { headerSticky, headerStickyOffset } = settings.layouts[LayoutNames.HORIZONTAL_MENU];
|
||||||
const isSticky =
|
|
||||||
scrollPosition > settings.layouts[LayoutNames.HORIZONTAL_MENU].headerStickyOffset;
|
|
||||||
setHeaderStickyOn(isSticky);
|
|
||||||
}, [scrollPosition, settings]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (headerStickyOn === true) {
|
setHeaderStickyOn(scrollPosition > headerStickyOffset);
|
||||||
|
}, [scrollPosition, headerStickyOffset]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (headerStickyOn) {
|
||||||
document.body.setAttribute('data-sticky-header', 'on');
|
document.body.setAttribute('data-sticky-header', 'on');
|
||||||
} else {
|
} else {
|
||||||
document.body.removeAttribute('data-sticky-header');
|
document.body.removeAttribute('data-sticky-header');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return () => document.body.removeAttribute('data-sticky-header');
|
||||||
}, [headerStickyOn]);
|
}, [headerStickyOn]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header
|
<header
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex h-(--header-height) shrink-0 items-center transition-[height] in-data-[sticky-header=on]:pe-(--removed-body-scroll-bar-size,0px)',
|
'flex h-(--header-height) shrink-0 items-center transition-[height] in-data-[sticky-header=on]:pe-(--removed-body-scroll-bar-size,0px)',
|
||||||
settings.layouts[LayoutNames.HORIZONTAL_MENU].headerSticky &&
|
headerSticky &&
|
||||||
headerStickyOn &&
|
headerStickyOn &&
|
||||||
'bg-background/70 fixed start-0 end-0 top-0 z-10 shadow-xs backdrop-blur-md transition-[height]'
|
'bg-background/70 fixed start-0 end-0 top-0 z-10 shadow-xs backdrop-blur-md transition-[height]'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Container className="flex items-center justify-between lg:gap-4">
|
<Container className="flex items-center justify-between lg:gap-4">
|
||||||
<HeaderLogo />
|
<HeaderLogo />
|
||||||
<HeaderTopbar />
|
<UserAvatarMenu />
|
||||||
</Container>
|
</Container>
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,71 +1,41 @@
|
|||||||
// import { useEffect, useState } from 'react';
|
import { Menu } from 'lucide-react';
|
||||||
// import { ChevronDown } from 'lucide-react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { useLocation } from 'react-router-dom';
|
||||||
// import { MENU_ROOT } from '#app/menu';
|
import { useIsMobile } from '@crux-kit/hooks';
|
||||||
// import {
|
import { Button } from '#shared/ui/Button';
|
||||||
// DropdownMenu,
|
import { Sheet, SheetBody, SheetContent, SheetHeader, SheetTrigger } from '#shared/ui/Sheet';
|
||||||
// DropdownMenuContent,
|
import { AppLogo, AppWordmark, SidebarAccordionMenu } from '../../shared';
|
||||||
// DropdownMenuItem,
|
|
||||||
// DropdownMenuTrigger,
|
|
||||||
// } from '#shared/ui/dropdown_menu';
|
|
||||||
import { toAbsoluteUrl } from '#shared/lib/helpers';
|
|
||||||
|
|
||||||
// import { cn } from '@crux-kit/utils';
|
|
||||||
|
|
||||||
export function HeaderLogo() {
|
export function HeaderLogo() {
|
||||||
// const { pathname } = useLocation();
|
const { pathname } = useLocation();
|
||||||
// const [selectedMenuItem, setSelectedMenuItem] = useState(MENU_ROOT[1]);
|
const isMobile = useIsMobile();
|
||||||
|
const [isSheetOpen, setIsSheetOpen] = useState(false);
|
||||||
|
|
||||||
// useEffect(() => {
|
useEffect(() => {
|
||||||
// MENU_ROOT.forEach((item) => {
|
setIsSheetOpen(false);
|
||||||
// if (item.rootPath && pathname.includes(item.rootPath)) {
|
}, [pathname]);
|
||||||
// setSelectedMenuItem(item);
|
|
||||||
// }
|
|
||||||
// });
|
|
||||||
// }, [pathname]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2 lg:gap-5 2xl:-ms-15">
|
<div className="flex items-center gap-2 lg:gap-5 2xl:-ms-15">
|
||||||
{/* Logo Section */}
|
<AppLogo className="size-10.5" />
|
||||||
<Link to="/" className="shrink-0">
|
|
||||||
<img
|
|
||||||
src={toAbsoluteUrl('/media/app/mini-logo-circle.svg')}
|
|
||||||
className="min-h-10.5 dark:hidden"
|
|
||||||
alt="logo"
|
|
||||||
/>
|
|
||||||
<img
|
|
||||||
src={toAbsoluteUrl('/media/app/mini-logo-circle-dark.svg')}
|
|
||||||
className="hidden min-h-10.5 dark:inline-block"
|
|
||||||
alt="logo"
|
|
||||||
/>
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
{/* Menu Section */}
|
<AppWordmark className="text-accent-foreground hidden text-base md:block" />
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<h3 className="text-accent-foreground hidden text-base md:block">Metronic Team</h3>
|
|
||||||
<span className="text-muted-foreground hidden text-sm font-medium md:inline">/</span>
|
|
||||||
|
|
||||||
{/* <DropdownMenu>
|
{isMobile && (
|
||||||
<DropdownMenuTrigger className="cursor-pointer text-mono font-medium flex items-center gap-2">
|
<Sheet open={isSheetOpen} onOpenChange={setIsSheetOpen}>
|
||||||
{selectedMenuItem.title}
|
<SheetTrigger asChild>
|
||||||
<ChevronDown className="size-4 text-muted-foreground" />
|
<Button variant="ghost" mode="icon">
|
||||||
</DropdownMenuTrigger>
|
<Menu />
|
||||||
<DropdownMenuContent sideOffset={10} side="bottom" align="start">
|
</Button>
|
||||||
{MENU_ROOT.map((item, index) => (
|
</SheetTrigger>
|
||||||
<DropdownMenuItem
|
<SheetContent className="w-68.75 gap-0 p-0" side="left" close={false}>
|
||||||
key={index}
|
<SheetHeader className="space-y-0 p-0" />
|
||||||
asChild
|
<SheetBody className="overflow-y-auto py-5">
|
||||||
className={cn(item === selectedMenuItem && 'bg-accent')}
|
<SidebarAccordionMenu />
|
||||||
>
|
</SheetBody>
|
||||||
<Link to={item.path || ''}>
|
</SheetContent>
|
||||||
{item.icon && <item.icon />}
|
</Sheet>
|
||||||
{item.title}
|
)}
|
||||||
</Link>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
))}
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu> */}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,46 +0,0 @@
|
|||||||
// import { MessageCircleMore, MessageSquareDot, Search } from 'lucide-react';
|
|
||||||
// import { Button } from '#shared/ui/button';
|
|
||||||
import { toAbsoluteUrl } from '#shared/lib/helpers';
|
|
||||||
// import { SearchDialog } from '#shared/partials/dialogs/search/search-dialog';
|
|
||||||
// import { ChatSheet } from '#shared/partials/topbar/chat-sheet';
|
|
||||||
// import { NotificationsSheet } from '#shared/partials/topbar/notifications-sheet';
|
|
||||||
import { UserDropdownMenu } from '#shared/widgets/UserDropdownMenu';
|
|
||||||
|
|
||||||
export function HeaderTopbar() {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
{/* <SearchDialog
|
|
||||||
trigger={
|
|
||||||
<Button variant="ghost" mode="icon" shape="circle" className="size-9">
|
|
||||||
<Search className="size-4.5!" />
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<ChatSheet
|
|
||||||
trigger={
|
|
||||||
<Button variant="ghost" mode="icon" shape="circle" className="size-9">
|
|
||||||
<MessageCircleMore className="size-4.5!" />
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<NotificationsSheet
|
|
||||||
trigger={
|
|
||||||
<Button variant="ghost" mode="icon" size="sm" shape="circle" className="size-9">
|
|
||||||
<MessageSquareDot className="size-4.5!" />
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/> */}
|
|
||||||
</div>
|
|
||||||
<UserDropdownMenu
|
|
||||||
trigger={
|
|
||||||
<img
|
|
||||||
className="size-9 shrink-0 cursor-pointer justify-center rounded-full border border-gray-500"
|
|
||||||
src={toAbsoluteUrl('/media/avatars/gray/5.png')}
|
|
||||||
alt=""
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,13 +1,18 @@
|
|||||||
|
import { useIsMobile } from '@crux-kit/hooks';
|
||||||
import { Container } from '#shared/widgets/common/Container';
|
import { Container } from '#shared/widgets/common/Container';
|
||||||
import { NavbarLinks } from './NavbarLinks';
|
import { NavMenubar } from '../../shared';
|
||||||
import { NavbarMenu } from './NavbarMenu';
|
|
||||||
|
|
||||||
export function Navbar() {
|
export function Navbar() {
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
|
if (isMobile) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="border-border mb-5 border-b pb-5 lg:mb-10 lg:pb-0">
|
<div className="border-border mb-5 border-b pb-5 lg:mb-10 lg:pb-0">
|
||||||
<Container className="flex flex-wrap items-center justify-between gap-2">
|
<Container className="flex flex-wrap items-center justify-between gap-2">
|
||||||
<NavbarMenu />
|
<NavMenubar />
|
||||||
<NavbarLinks />
|
|
||||||
</Container>
|
</Container>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,15 +0,0 @@
|
|||||||
export function NavbarLinks() {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-5 text-sm lg:pb-1">
|
|
||||||
<a className="hover:text-primary" href="https://www.youtube.com/c/KeenThemesTuts/videos">
|
|
||||||
Videos
|
|
||||||
</a>
|
|
||||||
<a className="hover:text-primary" href="https://keenthemes.com/metronic/tailwind/docs/">
|
|
||||||
User Guides
|
|
||||||
</a>
|
|
||||||
<a className="hover:text-primary" href="https://devs.keenthemes.com">
|
|
||||||
Support
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,128 +0,0 @@
|
|||||||
import { ChevronDown } from 'lucide-react';
|
|
||||||
import { useMemo } from 'react';
|
|
||||||
import { Link, useLocation } from 'react-router-dom';
|
|
||||||
import { cn } from '@crux-kit/utils';
|
|
||||||
import { MenuConfig } from '#app/config/types';
|
|
||||||
import { useMenuConfig } from '#app/menu';
|
|
||||||
import { useMenu } from '#shared/hooks/useMenu';
|
|
||||||
import { createMenuPathMatcher } from '#shared/lib/menuPath';
|
|
||||||
import { Menubar, MenubarContent, MenubarItem, MenubarMenu, MenubarSub, MenubarSubContent, MenubarSubTrigger, MenubarTrigger } from '#shared/ui/MenuBar';
|
|
||||||
|
|
||||||
export function NavbarMenu() {
|
|
||||||
const { pathname } = useLocation();
|
|
||||||
const menuConfig = useMenuConfig();
|
|
||||||
|
|
||||||
let navbarMenu;
|
|
||||||
|
|
||||||
if (pathname.includes('/public-profile/')) {
|
|
||||||
navbarMenu = menuConfig?.[2];
|
|
||||||
} else if (pathname.includes('/network/')) {
|
|
||||||
navbarMenu = menuConfig?.[4];
|
|
||||||
} else if (pathname.includes('/store-client/')) {
|
|
||||||
navbarMenu = menuConfig?.[0];
|
|
||||||
} else if (pathname.includes('/authentication/')) {
|
|
||||||
navbarMenu = menuConfig?.[5];
|
|
||||||
} else {
|
|
||||||
navbarMenu = menuConfig?.[3];
|
|
||||||
}
|
|
||||||
|
|
||||||
const { hasActiveChild } = useMenu(pathname);
|
|
||||||
// A leaf highlights only when nothing deeper in the menu matches the route more closely.
|
|
||||||
const isActive = useMemo(
|
|
||||||
() => createMenuPathMatcher((navbarMenu?.children ?? []) as MenuConfig, pathname),
|
|
||||||
[navbarMenu, pathname]
|
|
||||||
);
|
|
||||||
|
|
||||||
const buildMenu = (items: MenuConfig) => {
|
|
||||||
return items.map((item, index) => {
|
|
||||||
if (item.children) {
|
|
||||||
return (
|
|
||||||
<MenubarMenu key={index}>
|
|
||||||
<MenubarTrigger
|
|
||||||
className={cn(
|
|
||||||
'text-secondary-foreground flex items-center gap-1.5 px-3 py-3.5 text-sm text-nowrap',
|
|
||||||
'rounded-none border-b-2 border-transparent bg-transparent!',
|
|
||||||
'hover:text-mono hover:bg-transparent',
|
|
||||||
'focus:text-mono focus:bg-transparent',
|
|
||||||
'data-[state=open]:text-mono data-[state=open]:bg-transparent',
|
|
||||||
'data-[here=true]:text-mono data-[here=true]:border-mono'
|
|
||||||
)}
|
|
||||||
data-active={isActive(item.path) || undefined}
|
|
||||||
data-here={hasActiveChild(item.children) || undefined}
|
|
||||||
>
|
|
||||||
{item.title}
|
|
||||||
<ChevronDown className="ms-auto size-3.5" />
|
|
||||||
</MenubarTrigger>
|
|
||||||
<MenubarContent className="min-w-43.75" sideOffset={0}>
|
|
||||||
{buildSubMenu(item.children)}
|
|
||||||
</MenubarContent>
|
|
||||||
</MenubarMenu>
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
return (
|
|
||||||
<MenubarMenu key={index}>
|
|
||||||
<MenubarTrigger
|
|
||||||
asChild
|
|
||||||
className={cn(
|
|
||||||
'text-secondary-foreground flex items-center px-3 py-3.5 text-sm text-nowrap',
|
|
||||||
'rounded-none border-b-2 border-transparent bg-transparent!',
|
|
||||||
'hover:text-mono hover:bg-transparent',
|
|
||||||
'focus:text-mono focus:bg-transparent',
|
|
||||||
'data-[active=true]:text-mono data-[active=true]:border-mono'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Link
|
|
||||||
to={item.path || ''}
|
|
||||||
data-active={isActive(item.path) || undefined}
|
|
||||||
data-here={hasActiveChild(item.children) || undefined}
|
|
||||||
>
|
|
||||||
{item.title}
|
|
||||||
</Link>
|
|
||||||
</MenubarTrigger>
|
|
||||||
</MenubarMenu>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const buildSubMenu = (items: MenuConfig) => {
|
|
||||||
return items.map((item, index) => {
|
|
||||||
if (item.children) {
|
|
||||||
return (
|
|
||||||
<MenubarSub key={index}>
|
|
||||||
<MenubarSubTrigger
|
|
||||||
data-active={isActive(item.path) || undefined}
|
|
||||||
data-here={hasActiveChild(item.children) || undefined}
|
|
||||||
>
|
|
||||||
<span>{item.title}</span>
|
|
||||||
</MenubarSubTrigger>
|
|
||||||
<MenubarSubContent className="min-w-43.75">
|
|
||||||
{buildSubMenu(item.children)}
|
|
||||||
</MenubarSubContent>
|
|
||||||
</MenubarSub>
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
return (
|
|
||||||
<MenubarItem
|
|
||||||
key={index}
|
|
||||||
asChild
|
|
||||||
data-active={isActive(item.path) || undefined}
|
|
||||||
data-here={hasActiveChild(item.children) || undefined}
|
|
||||||
>
|
|
||||||
<Link to={item.path || ''}>{item.title}</Link>
|
|
||||||
</MenubarItem>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="grid">
|
|
||||||
<div className="kt-scrollable-x-auto">
|
|
||||||
<Menubar className="flex h-auto items-stretch border-none bg-transparent p-0">
|
|
||||||
{buildMenu(navbarMenu.children as MenuConfig)}
|
|
||||||
</Menubar>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,86 +0,0 @@
|
|||||||
import { ChevronRight } from 'lucide-react';
|
|
||||||
import { Fragment, ReactNode } from 'react';
|
|
||||||
import { Link, useLocation } from 'react-router-dom';
|
|
||||||
import { cn } from '@crux-kit/utils';
|
|
||||||
import { MenuItem } from '#app/config/types';
|
|
||||||
import { useMenuConfig } from '#app/menu';
|
|
||||||
import { useMenu } from '#shared/hooks/useMenu';
|
|
||||||
import { Container } from '#shared/widgets/common/Container';
|
|
||||||
|
|
||||||
export interface ToolbarHeadingProps {
|
|
||||||
title?: string | ReactNode;
|
|
||||||
description?: string | ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
function Toolbar({ children }: { children?: ReactNode }) {
|
|
||||||
return (
|
|
||||||
<div className="mb-5 lg:mb-10">
|
|
||||||
<Container className="flex flex-wrap items-center justify-between gap-5">
|
|
||||||
{children}
|
|
||||||
</Container>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ToolbarActions({ children }: { children?: ReactNode }) {
|
|
||||||
return <div className="flex items-center gap-2.5">{children}</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function ToolbarBreadcrumbs() {
|
|
||||||
const menuConfig = useMenuConfig();
|
|
||||||
const { pathname } = useLocation();
|
|
||||||
const { getBreadcrumb, isActive } = useMenu(pathname);
|
|
||||||
const items: MenuItem[] = getBreadcrumb(menuConfig);
|
|
||||||
|
|
||||||
if (items.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="[.header_&]:below-lg:hidden mb-2.5 flex items-center gap-1.25 text-xs font-medium lg:mb-0 lg:text-sm">
|
|
||||||
<div className="breadcrumb flex items-center gap-1">
|
|
||||||
{items.map((item, index) => {
|
|
||||||
const isLast = index === items.length - 1;
|
|
||||||
const active = item.path ? isActive(item.path) : false;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Fragment key={index}>
|
|
||||||
{item.path ? (
|
|
||||||
<Link
|
|
||||||
to={item.path}
|
|
||||||
className={cn(
|
|
||||||
'flex items-center gap-1',
|
|
||||||
active ? 'text-mono' : 'text-muted-foreground hover:text-primary'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{item.title}
|
|
||||||
</Link>
|
|
||||||
) : (
|
|
||||||
<span className={cn(isLast ? 'text-mono' : 'text-muted-foreground')}>
|
|
||||||
{item.title}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{!isLast && <ChevronRight className="muted-foreground size-3.5" />}
|
|
||||||
</Fragment>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const ToolbarHeading = ({ title = '' }: ToolbarHeadingProps) => {
|
|
||||||
const { pathname } = useLocation();
|
|
||||||
const { getCurrentItem } = useMenu(pathname);
|
|
||||||
const menuConfig = useMenuConfig();
|
|
||||||
const item = getCurrentItem(menuConfig);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-wrap items-center gap-1 lg:gap-5">
|
|
||||||
<h1 className="text-mono text-lg font-medium">{title || item?.title}</h1>
|
|
||||||
<ToolbarBreadcrumbs />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export { Toolbar, ToolbarActions, ToolbarBreadcrumbs, ToolbarHeading };
|
|
||||||
@@ -1,30 +1,16 @@
|
|||||||
// DEMO 2
|
// DEMO 2
|
||||||
// https://keenthemes.com/metronic/tailwind/react/demo2
|
// https://keenthemes.com/metronic/tailwind/react/demo2
|
||||||
|
|
||||||
import { addDays, format } from 'date-fns';
|
import { Outlet } from 'react-router-dom';
|
||||||
import { CalendarDays } from 'lucide-react';
|
|
||||||
import { useEffect, useState } from 'react';
|
|
||||||
import { DateRange } from 'react-day-picker';
|
|
||||||
import { Outlet, useLocation } from 'react-router-dom';
|
|
||||||
import { useBodyClass } from '@crux-kit/hooks';
|
import { useBodyClass } from '@crux-kit/hooks';
|
||||||
import { LayoutNames } from '#app/config/types';
|
import { LayoutNames } from '#app/config/types';
|
||||||
import { useSettings } from '#app/providers/SettingsProvider';
|
import { AppFooter, Toolbar, ToolbarHeading, useLayoutChrome, useLayoutSettings } from '../shared';
|
||||||
import { Button } from '#shared/ui/Button';
|
|
||||||
import { Calendar } from '#shared/ui/Calendar';
|
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from '#shared/ui/Popover';
|
|
||||||
import { Footer } from './components/Footer';
|
|
||||||
import { Header } from './components/Header';
|
import { Header } from './components/Header';
|
||||||
import { Navbar } from './components/Navbar';
|
import { Navbar } from './components/Navbar';
|
||||||
import { Toolbar, ToolbarActions, ToolbarHeading } from './components/Toolbar';
|
|
||||||
|
|
||||||
export function HorizontalMenuLayout() {
|
export function HorizontalMenuLayout() {
|
||||||
const { pathname } = useLocation();
|
useLayoutSettings(LayoutNames.HORIZONTAL_MENU);
|
||||||
const { setOption } = useSettings();
|
const { showToolbar, showFooter } = useLayoutChrome();
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// Set current layout
|
|
||||||
setOption('layout', LayoutNames.HORIZONTAL_MENU);
|
|
||||||
}, [setOption]);
|
|
||||||
|
|
||||||
useBodyClass(`
|
useBodyClass(`
|
||||||
[--header-height-default:100px]
|
[--header-height-default:100px]
|
||||||
@@ -32,11 +18,6 @@ export function HorizontalMenuLayout() {
|
|||||||
[--header-height:var(--header-height-default)]
|
[--header-height:var(--header-height-default)]
|
||||||
`);
|
`);
|
||||||
|
|
||||||
const [date, setDate] = useState<DateRange | undefined>({
|
|
||||||
from: new Date(2025, 0, 20),
|
|
||||||
to: addDays(new Date(2025, 0, 20), 20)
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex grow flex-col in-data-[sticky-header=on]:pt-(--header-height-default)">
|
<div className="flex grow flex-col in-data-[sticky-header=on]:pt-(--header-height-default)">
|
||||||
@@ -45,45 +26,15 @@ export function HorizontalMenuLayout() {
|
|||||||
<Navbar />
|
<Navbar />
|
||||||
|
|
||||||
<main className="grow" role="content">
|
<main className="grow" role="content">
|
||||||
{!pathname.includes('/public-profile/') && (
|
{showToolbar && (
|
||||||
<Toolbar>
|
<Toolbar>
|
||||||
<ToolbarHeading />
|
<ToolbarHeading />
|
||||||
<ToolbarActions>
|
|
||||||
<Popover>
|
|
||||||
<PopoverTrigger asChild>
|
|
||||||
<Button mode="input" variant="outline">
|
|
||||||
<CalendarDays />
|
|
||||||
{date?.from ? (
|
|
||||||
date.to ? (
|
|
||||||
<>
|
|
||||||
{format(date.from, 'LLL dd, y')} - {format(date.to, 'LLL dd, y')}
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
format(date.from, 'LLL dd, y')
|
|
||||||
)
|
|
||||||
) : (
|
|
||||||
<span>Pick a date range</span>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent className="w-auto p-0" align="end">
|
|
||||||
<Calendar
|
|
||||||
initialFocus
|
|
||||||
mode="range"
|
|
||||||
defaultMonth={date?.from}
|
|
||||||
selected={date}
|
|
||||||
onSelect={setDate}
|
|
||||||
numberOfMonths={2}
|
|
||||||
/>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
</ToolbarActions>
|
|
||||||
</Toolbar>
|
</Toolbar>
|
||||||
)}
|
)}
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<Footer />
|
{showFooter && <AppFooter />}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
# Compact Sidebar layout
|
||||||
|
|
||||||
|
**Metronic 9 reference:** [Demo 3](https://keenthemes.com/metronic/tailwind/react/demo3)
|
||||||
|
|
||||||
|
A narrow icon rail listing the top-level menu sections, with the selected section's children on a
|
||||||
|
horizontal navbar above the content card.
|
||||||
|
|
||||||
|
## Composition
|
||||||
|
|
||||||
|
| Piece | Source |
|
||||||
|
| --------------------------- | ------------------------------------------------------------ |
|
||||||
|
| Section chooser (icon rail) | `useAppMenu` + `firstMenuPath` from `../shared` |
|
||||||
|
| Section navbar | `NavMenubar` from `../shared`, fed by `useActiveMenuSection` |
|
||||||
|
| Mobile menu (sheet) | `SidebarAccordionMenu` from `../shared` |
|
||||||
|
| Toolbar and breadcrumbs | `Toolbar` / `ToolbarHeading` from `../shared` |
|
||||||
|
| Footer | `AppFooter` from `../shared` |
|
||||||
|
| Account menu | `UserAvatarMenu` from `../shared` |
|
||||||
|
|
||||||
|
Layout-specific: `Header`, `HeaderLogo`, `Sidebar`, `SidebarMenu`, `Navbar`.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- A section with no children renders no navbar, so a single-page section such as the dashboard fills
|
||||||
|
the card.
|
||||||
|
- This layout sets `container` to `fluid`; the setting is per-layout, so other layouts keep theirs.
|
||||||
|
- Body variables: `--header-height: 58px`, `--sidebar-width: 58px`, `--navbar-height: 56px`.
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
import { generalSettings } from '#app/config/general';
|
|
||||||
import { Container } from '#shared/widgets/common/Container';
|
|
||||||
|
|
||||||
export function Footer() {
|
|
||||||
const currentYear = new Date().getFullYear();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<footer className="footer">
|
|
||||||
<Container>
|
|
||||||
<div className="flex flex-col items-center justify-center gap-3 py-5 md:flex-row md:justify-between">
|
|
||||||
<div className="order-2 flex gap-2 text-sm font-normal md:order-1">
|
|
||||||
<span className="text-muted-foreground">{currentYear} ©</span>
|
|
||||||
<a
|
|
||||||
href="https://keenthemes.com"
|
|
||||||
target="_blank"
|
|
||||||
className="text-secondary-foreground hover:text-primary"
|
|
||||||
>
|
|
||||||
Keenthemes Inc.
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<nav className="text-muted-foreground order-1 flex gap-4 text-sm font-normal md:order-2">
|
|
||||||
<a href={generalSettings.docsLink} target="_blank" className="hover:text-primary">
|
|
||||||
Docs
|
|
||||||
</a>
|
|
||||||
<a href={generalSettings.purchaseLink} target="_blank" className="hover:text-primary">
|
|
||||||
Purchase
|
|
||||||
</a>
|
|
||||||
<a href={generalSettings.faqLink} target="_blank" className="hover:text-primary">
|
|
||||||
FAQ
|
|
||||||
</a>
|
|
||||||
<a href="https://devs.keenthemes.com" target="_blank" className="hover:text-primary">
|
|
||||||
Support
|
|
||||||
</a>
|
|
||||||
<a href={generalSettings.licenseLink} target="_blank" className="hover:text-primary">
|
|
||||||
License
|
|
||||||
</a>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
</Container>
|
|
||||||
</footer>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,13 +1,13 @@
|
|||||||
import { Container } from '#shared/widgets/common/Container';
|
import { Container } from '#shared/widgets/common/Container';
|
||||||
|
import { UserAvatarMenu } from '../../shared';
|
||||||
import { HeaderLogo } from './HeaderLogo';
|
import { HeaderLogo } from './HeaderLogo';
|
||||||
import { HeaderTopbar } from './HeaderTopbar';
|
|
||||||
|
|
||||||
export function Header() {
|
export function Header() {
|
||||||
return (
|
return (
|
||||||
<header className="bg-muted fixed top-0 right-0 left-0 z-10 flex h-(--header-height) shrink-0 items-center">
|
<header className="bg-muted fixed top-0 right-0 left-0 z-10 flex h-(--header-height) shrink-0 items-center">
|
||||||
<Container className="flex items-stretch justify-between px-5 lg:gap-4 lg:ps-0">
|
<Container className="flex items-stretch justify-between px-5 lg:gap-4 lg:ps-0">
|
||||||
<HeaderLogo />
|
<HeaderLogo />
|
||||||
<HeaderTopbar />
|
<UserAvatarMenu />
|
||||||
</Container>
|
</Container>
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,87 +1,39 @@
|
|||||||
import { Menu } from 'lucide-react';
|
import { Menu } from 'lucide-react';
|
||||||
import { Link } from 'react-router-dom';
|
import { useEffect, useState } from 'react';
|
||||||
import { toAbsoluteUrl } from '#shared/lib/helpers';
|
import { useLocation } from 'react-router-dom';
|
||||||
// import { cn } from '@crux-kit/utils';
|
|
||||||
import { Button } from '#shared/ui/Button';
|
import { Button } from '#shared/ui/Button';
|
||||||
// import {
|
|
||||||
// DropdownMenu,
|
|
||||||
// DropdownMenuContent,
|
|
||||||
// DropdownMenuItem,
|
|
||||||
// DropdownMenuTrigger,
|
|
||||||
// } from '#shared/ui/dropdown_menu';
|
|
||||||
import { Sheet, SheetBody, SheetContent, SheetHeader, SheetTrigger } from '#shared/ui/Sheet';
|
import { Sheet, SheetBody, SheetContent, SheetHeader, SheetTrigger } from '#shared/ui/Sheet';
|
||||||
import { SidebarMenu } from './SidebarMenu';
|
import { AppLogo, AppWordmark, SidebarAccordionMenu } from '../../shared';
|
||||||
|
|
||||||
export function HeaderLogo() {
|
export function HeaderLogo() {
|
||||||
// const { pathname } = useLocation();
|
const { pathname } = useLocation();
|
||||||
// const [selectedMenuItem, setSelectedMenuItem] = useState(MENU_ROOT[1]);
|
const [isSheetOpen, setIsSheetOpen] = useState(false);
|
||||||
|
|
||||||
// useEffect(() => {
|
useEffect(() => {
|
||||||
// MENU_ROOT.forEach((item) => {
|
setIsSheetOpen(false);
|
||||||
// if (item.rootPath && pathname.includes(item.rootPath)) {
|
}, [pathname]);
|
||||||
// setSelectedMenuItem(item);
|
|
||||||
// }
|
|
||||||
// });
|
|
||||||
// }, [pathname]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-2.5">
|
<div className="flex items-center gap-2.5">
|
||||||
{/* Logo */}
|
|
||||||
<div className="flex shrink-0 items-center justify-center lg:w-(--sidebar-width)">
|
<div className="flex shrink-0 items-center justify-center lg:w-(--sidebar-width)">
|
||||||
<Sheet>
|
<Sheet open={isSheetOpen} onOpenChange={setIsSheetOpen}>
|
||||||
<SheetTrigger asChild>
|
<SheetTrigger asChild>
|
||||||
<Button variant="ghost" mode="icon" className="-ms-2 lg:hidden">
|
<Button variant="ghost" mode="icon" className="-ms-2 lg:hidden">
|
||||||
<Menu className="size-4!" />
|
<Menu className="size-4!" />
|
||||||
</Button>
|
</Button>
|
||||||
</SheetTrigger>
|
</SheetTrigger>
|
||||||
<SheetContent className="w-(--sidebar-width) gap-0 p-0" side="left" close={false}>
|
<SheetContent className="w-68.75 gap-0 p-0" side="left" close={false}>
|
||||||
<SheetHeader className="space-y-0 p-0" />
|
<SheetHeader className="space-y-0 p-0" />
|
||||||
<SheetBody className="overflow-y-auto p-0">
|
<SheetBody className="overflow-y-auto py-5">
|
||||||
<SidebarMenu />
|
<SidebarAccordionMenu />
|
||||||
</SheetBody>
|
</SheetBody>
|
||||||
</SheetContent>
|
</SheetContent>
|
||||||
</Sheet>
|
</Sheet>
|
||||||
|
|
||||||
<Link to="/" className="mx-1">
|
<AppLogo className="size-6" linkClassName="mx-1" />
|
||||||
<img
|
|
||||||
src={toAbsoluteUrl('/media/app/mini-logo-primary.svg')}
|
|
||||||
className="min-h-6 dark:hidden"
|
|
||||||
alt="logo"
|
|
||||||
/>
|
|
||||||
<img
|
|
||||||
src={toAbsoluteUrl('/media/app/mini-logo-primary-dark.svg')}
|
|
||||||
className="hidden min-h-6 dark:inline-block"
|
|
||||||
alt="logo"
|
|
||||||
/>
|
|
||||||
</Link>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Menu Section */}
|
<AppWordmark className="text-accent-foreground hidden text-base md:block" />
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<h3 className="text-accent-foreground hidden text-base md:block">Metronic Team</h3>
|
|
||||||
<span className="text-muted-foreground hidden text-sm font-medium md:inline">/</span>
|
|
||||||
|
|
||||||
{/* <DropdownMenu>
|
|
||||||
<DropdownMenuTrigger className="cursor-pointer text-mono font-medium flex items-center gap-2">
|
|
||||||
{selectedMenuItem.title}
|
|
||||||
<ChevronDown className="size-3.5! text-muted-foreground" />
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent sideOffset={10} side="bottom" align="start">
|
|
||||||
{MENU_ROOT.map((item, index) => (
|
|
||||||
<DropdownMenuItem
|
|
||||||
key={index}
|
|
||||||
asChild
|
|
||||||
className={cn(item === selectedMenuItem && 'bg-accent')}
|
|
||||||
>
|
|
||||||
<Link to={item.path || ''}>
|
|
||||||
{item.icon && <item.icon />}
|
|
||||||
{item.title}
|
|
||||||
</Link>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
))}
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu> */}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,56 +0,0 @@
|
|||||||
// import { LayoutGrid, MessageCircleMore, MessageSquareDot, Search } from 'lucide-react';
|
|
||||||
import { Link } from 'react-router-dom';
|
|
||||||
import { toAbsoluteUrl } from '#shared/lib/helpers';
|
|
||||||
import { Button } from '#shared/ui/Button';
|
|
||||||
// import { SearchDialog } from '#shared/partials/dialogs/search/search-dialog';
|
|
||||||
// import { AppsDropdownMenu } from '#shared/partials/topbar/apps-dropdown-menu';
|
|
||||||
// import { ChatSheet } from '#shared/partials/topbar/chat-sheet';
|
|
||||||
// import { NotificationsSheet } from '#shared/partials/topbar/notifications-sheet';
|
|
||||||
import { UserDropdownMenu } from '#shared/widgets/UserDropdownMenu';
|
|
||||||
|
|
||||||
export function HeaderTopbar() {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-2 lg:gap-3.5">
|
|
||||||
<Button asChild className="hidden md:inline-flex">
|
|
||||||
<Link to="account/home/get-started">Get Started</Link>
|
|
||||||
</Button>
|
|
||||||
{/* <SearchDialog
|
|
||||||
trigger={
|
|
||||||
<Button variant="ghost" mode="icon" shape="circle" className="hover:[&_svg]:text-primary">
|
|
||||||
<Search className="size-4.5!" />
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<ChatSheet
|
|
||||||
trigger={
|
|
||||||
<Button variant="ghost" mode="icon" shape="circle" className="hover:[&_svg]:text-primary">
|
|
||||||
<MessageCircleMore className="size-4.5!" />
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<AppsDropdownMenu
|
|
||||||
trigger={
|
|
||||||
<Button variant="ghost" mode="icon" shape="circle" className="hover:[&_svg]:text-primary">
|
|
||||||
<LayoutGrid className="size-4.5!" />
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<NotificationsSheet
|
|
||||||
trigger={
|
|
||||||
<Button variant="ghost" mode="icon" shape="circle" className="hover:[&_svg]:text-primary">
|
|
||||||
<MessageSquareDot className="size-4.5!" />
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/> */}
|
|
||||||
<UserDropdownMenu
|
|
||||||
trigger={
|
|
||||||
<img
|
|
||||||
className="border-input size-9 shrink-0 cursor-pointer rounded-full border-2"
|
|
||||||
src={toAbsoluteUrl('/media/avatars/gray/5.png')}
|
|
||||||
alt="User Avatar"
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,14 +1,20 @@
|
|||||||
import { Container } from '#shared/widgets/common/Container';
|
import { Container } from '#shared/widgets/common/Container';
|
||||||
import { NavbarLinks } from './NavbarLinks';
|
import { NavMenubar, useActiveMenuSection } from '../../shared';
|
||||||
import { NavbarMenu } from './NavbarMenu';
|
|
||||||
|
|
||||||
|
/** The children of the section selected in the compact sidebar. */
|
||||||
export function Navbar() {
|
export function Navbar() {
|
||||||
|
const section = useActiveMenuSection();
|
||||||
|
const items = section?.children ?? [];
|
||||||
|
|
||||||
|
if (items.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-muted start-(--sidebar-width) end-5 top-(--header-height) z-5 mx-5 flex h-(--navbar-height) items-stretch lg:fixed lg:mx-0">
|
<div className="bg-muted start-(--sidebar-width) end-5 top-(--header-height) z-5 mx-5 flex h-(--navbar-height) items-stretch lg:fixed lg:mx-0">
|
||||||
<div className="border-border bg-background flex grow items-stretch rounded-t-xl border">
|
<div className="border-border bg-background flex grow items-stretch rounded-t-xl border">
|
||||||
<Container className="flex items-stretch justify-between gap-5">
|
<Container className="flex items-stretch justify-between gap-5">
|
||||||
<NavbarMenu />
|
<NavMenubar items={items} />
|
||||||
<NavbarLinks />
|
|
||||||
</Container>
|
</Container>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,46 +0,0 @@
|
|||||||
import { addDays, format } from 'date-fns';
|
|
||||||
import { CalendarDays } from 'lucide-react';
|
|
||||||
import { useState } from 'react';
|
|
||||||
import { DateRange } from 'react-day-picker';
|
|
||||||
import { Button } from '#shared/ui/Button';
|
|
||||||
import { Calendar } from '#shared/ui/Calendar';
|
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from '#shared/ui/Popover';
|
|
||||||
|
|
||||||
export function NavbarLinks() {
|
|
||||||
const [date, setDate] = useState<DateRange | undefined>({
|
|
||||||
from: new Date(2025, 0, 20),
|
|
||||||
to: addDays(new Date(2025, 0, 20), 20)
|
|
||||||
});
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex items-center">
|
|
||||||
<Popover>
|
|
||||||
<PopoverTrigger asChild>
|
|
||||||
<Button id="date" variant="outline" mode="input">
|
|
||||||
<CalendarDays />
|
|
||||||
{date?.from ? (
|
|
||||||
date.to ? (
|
|
||||||
<span>
|
|
||||||
{format(date.from, 'LLL dd, y')} - {format(date.to, 'LLL dd, y')}
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
format(date.from, 'LLL dd, y')
|
|
||||||
)
|
|
||||||
) : (
|
|
||||||
<span>Pick a date range</span>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent className="w-auto p-0" align="end">
|
|
||||||
<Calendar
|
|
||||||
mode="range"
|
|
||||||
defaultMonth={date?.from}
|
|
||||||
selected={date}
|
|
||||||
onSelect={setDate}
|
|
||||||
numberOfMonths={2}
|
|
||||||
/>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,121 +0,0 @@
|
|||||||
import { ChevronDown } from 'lucide-react';
|
|
||||||
import { Link, useLocation } from 'react-router-dom';
|
|
||||||
import { cn } from '@crux-kit/utils';
|
|
||||||
import { MenuConfig } from '#app/config/types';
|
|
||||||
import { useMenuConfig } from '#app/menu';
|
|
||||||
import { useMenu } from '#shared/hooks/useMenu';
|
|
||||||
import { Menubar, MenubarContent, MenubarItem, MenubarMenu, MenubarSub, MenubarSubContent, MenubarSubTrigger, MenubarTrigger } from '#shared/ui/MenuBar';
|
|
||||||
|
|
||||||
export function NavbarMenu() {
|
|
||||||
const { pathname } = useLocation();
|
|
||||||
const menuConfig = useMenuConfig();
|
|
||||||
|
|
||||||
let navbarMenu;
|
|
||||||
|
|
||||||
if (pathname.includes('/public-profile/')) {
|
|
||||||
navbarMenu = menuConfig?.[2];
|
|
||||||
} else if (pathname.includes('/network/')) {
|
|
||||||
navbarMenu = menuConfig?.[4];
|
|
||||||
} else if (pathname.includes('/store-client/')) {
|
|
||||||
navbarMenu = menuConfig?.[0];
|
|
||||||
} else if (pathname.includes('/authentication/')) {
|
|
||||||
navbarMenu = menuConfig?.[5];
|
|
||||||
} else {
|
|
||||||
navbarMenu = menuConfig?.[3];
|
|
||||||
}
|
|
||||||
|
|
||||||
const { isActive, hasActiveChild } = useMenu(pathname);
|
|
||||||
|
|
||||||
const buildMenu = (items: MenuConfig) => {
|
|
||||||
return items.map((item, index) => {
|
|
||||||
if (item.children) {
|
|
||||||
return (
|
|
||||||
<MenubarMenu key={index}>
|
|
||||||
<MenubarTrigger
|
|
||||||
className={cn(
|
|
||||||
'text-secondary-foreground flex items-center gap-1 px-0 py-3.5 text-sm text-nowrap',
|
|
||||||
'rounded-none border-b-2 border-transparent bg-transparent!',
|
|
||||||
'hover:text-mono hover:bg-transparent',
|
|
||||||
'focus:text-mono focus:bg-transparent',
|
|
||||||
'data-[state=open]:text-mono data-[state=open]:bg-transparent',
|
|
||||||
'data-[here=true]:text-mono data-[here=true]:border-mono'
|
|
||||||
)}
|
|
||||||
data-active={isActive(item.path) || undefined}
|
|
||||||
data-here={hasActiveChild(item.children) || undefined}
|
|
||||||
>
|
|
||||||
{item.title}
|
|
||||||
<ChevronDown className="ms-auto size-3.5!" />
|
|
||||||
</MenubarTrigger>
|
|
||||||
<MenubarContent className="min-w-43.75" sideOffset={0}>
|
|
||||||
{buildSubMenu(item.children)}
|
|
||||||
</MenubarContent>
|
|
||||||
</MenubarMenu>
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
return (
|
|
||||||
<MenubarMenu key={index}>
|
|
||||||
<MenubarTrigger
|
|
||||||
asChild
|
|
||||||
className={cn(
|
|
||||||
'text-secondary-foreground flex items-center px-3 py-3.5 text-sm text-nowrap',
|
|
||||||
'rounded-none border-b-2 border-transparent bg-transparent!',
|
|
||||||
'hover:text-mono hover:bg-transparent',
|
|
||||||
'focus:text-mono focus:bg-transparent',
|
|
||||||
'data-[active=true]:text-mono data-[active=true]:border-mono'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Link
|
|
||||||
to={item.path || ''}
|
|
||||||
data-active={isActive(item.path) || undefined}
|
|
||||||
data-here={hasActiveChild(item.children) || undefined}
|
|
||||||
>
|
|
||||||
{item.title}
|
|
||||||
</Link>
|
|
||||||
</MenubarTrigger>
|
|
||||||
</MenubarMenu>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const buildSubMenu = (items: MenuConfig) => {
|
|
||||||
return items.map((item, index) => {
|
|
||||||
if (item.children) {
|
|
||||||
return (
|
|
||||||
<MenubarSub key={index}>
|
|
||||||
<MenubarSubTrigger
|
|
||||||
data-active={isActive(item.path) || undefined}
|
|
||||||
data-here={hasActiveChild(item.children) || undefined}
|
|
||||||
>
|
|
||||||
<span>{item.title}</span>
|
|
||||||
</MenubarSubTrigger>
|
|
||||||
<MenubarSubContent className="min-w-43.75">
|
|
||||||
{buildSubMenu(item.children)}
|
|
||||||
</MenubarSubContent>
|
|
||||||
</MenubarSub>
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
return (
|
|
||||||
<MenubarItem
|
|
||||||
key={index}
|
|
||||||
asChild
|
|
||||||
data-active={isActive(item.path) || undefined}
|
|
||||||
data-here={hasActiveChild(item.children) || undefined}
|
|
||||||
>
|
|
||||||
<Link to={item.path || ''}>{item.title}</Link>
|
|
||||||
</MenubarItem>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="grid">
|
|
||||||
<div className="kt-scrollable-x-auto flex items-stretch">
|
|
||||||
<Menubar className="flex h-auto items-stretch gap-5 space-x-0 border-none bg-transparent p-0">
|
|
||||||
{buildMenu(navbarMenu.children as MenuConfig)}
|
|
||||||
</Menubar>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,99 +1,52 @@
|
|||||||
import { BarChart3, Bell, CheckSquare, Code, HelpCircle, MessageSquare, Settings, Shield, UserCircle, Users } from 'lucide-react';
|
import { Link, useLocation } from 'react-router-dom';
|
||||||
import { Link } from 'react-router-dom';
|
|
||||||
import { cn } from '@crux-kit/utils';
|
import { cn } from '@crux-kit/utils';
|
||||||
|
import { useMenu } from '#shared/hooks/useMenu';
|
||||||
import { Button } from '#shared/ui/Button';
|
import { Button } from '#shared/ui/Button';
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '#shared/ui/Tooltip';
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '#shared/ui/Tooltip';
|
||||||
|
import { firstMenuPath, useAppMenu } from '../../shared';
|
||||||
|
|
||||||
export interface Item {
|
/**
|
||||||
icon: React.ComponentType<{ className?: string }>;
|
* The section chooser: one icon per top-level menu entry. Its children are shown by the navbar, so
|
||||||
path: string;
|
* this rail never needs to expand.
|
||||||
title: string;
|
*/
|
||||||
newTab?: boolean;
|
|
||||||
active?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SidebarMenu() {
|
export function SidebarMenu() {
|
||||||
const items: Item[] = [
|
const menuConfig = useAppMenu();
|
||||||
{
|
const { pathname } = useLocation();
|
||||||
icon: BarChart3,
|
const { isItemActive } = useMenu(pathname);
|
||||||
path: '/',
|
|
||||||
title: 'Dashboard'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: UserCircle,
|
|
||||||
path: '/public-profile/profiles/default',
|
|
||||||
title: 'Profile'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: Settings,
|
|
||||||
path: '/account/home/get-started',
|
|
||||||
title: 'Account'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: Users,
|
|
||||||
path: '/network/get-started',
|
|
||||||
title: 'Network',
|
|
||||||
active: true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: Shield,
|
|
||||||
path: '/account/billing/plans',
|
|
||||||
title: 'Plans'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: MessageSquare,
|
|
||||||
path: '/account/security/security-log',
|
|
||||||
title: 'Security Logs'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: Bell,
|
|
||||||
path: '/account/notifications',
|
|
||||||
title: 'Notifications'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: CheckSquare,
|
|
||||||
path: '/account/members/roles',
|
|
||||||
title: 'ACL'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: Code,
|
|
||||||
path: '/account/api-keys',
|
|
||||||
title: 'API Keys'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: HelpCircle,
|
|
||||||
path: 'https://docs.keenthemes.com/metronic-vite',
|
|
||||||
title: 'Docs'
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<div className="flex grow flex-col items-center gap-2.5 py-3.5 lg:py-0">
|
<div className="flex grow flex-col items-center gap-2.5 py-3.5 lg:py-0">
|
||||||
{items.map((item, index) => (
|
{menuConfig.map((item, index) => {
|
||||||
<Tooltip key={index}>
|
const path = firstMenuPath(item);
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<Button
|
if (!path) {
|
||||||
variant="ghost"
|
return null;
|
||||||
shape="circle"
|
}
|
||||||
mode="icon"
|
|
||||||
{...(item.active ? { 'data-state': 'open' } : {})}
|
return (
|
||||||
className={cn(
|
<Tooltip key={index}>
|
||||||
'data-[state=open]:bg-background data-[state=open]:border-input data-[state=open]:text-primary data-[state=open]:border',
|
<TooltipTrigger asChild>
|
||||||
'hover:bg-background hover:border-input hover:text-primary hover:border'
|
<Button
|
||||||
)}
|
asChild
|
||||||
>
|
variant="ghost"
|
||||||
<Link
|
shape="circle"
|
||||||
to={item.path || ''}
|
mode="icon"
|
||||||
{...(item.newTab ? { target: '_blank', rel: 'noopener noreferrer' } : {})}
|
{...(isItemActive(item) ? { 'data-state': 'open' } : {})}
|
||||||
|
className={cn(
|
||||||
|
'data-[state=open]:bg-background data-[state=open]:border-input data-[state=open]:text-primary data-[state=open]:border',
|
||||||
|
'hover:bg-background hover:border-input hover:text-primary hover:border'
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<item.icon className="size-4.5!" />
|
<Link to={path} aria-label={item.title}>
|
||||||
</Link>
|
{item.icon && <item.icon className="size-4.5!" />}
|
||||||
</Button>
|
</Link>
|
||||||
</TooltipTrigger>
|
</Button>
|
||||||
<TooltipContent side="right">{item.title}</TooltipContent>
|
</TooltipTrigger>
|
||||||
</Tooltip>
|
<TooltipContent side="right">{item.title}</TooltipContent>
|
||||||
))}
|
</Tooltip>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,87 +0,0 @@
|
|||||||
import { ChevronRight } from 'lucide-react';
|
|
||||||
import { Fragment, ReactNode } from 'react';
|
|
||||||
import { Link, useLocation } from 'react-router-dom';
|
|
||||||
import { cn } from '@crux-kit/utils';
|
|
||||||
import { MenuItem } from '#app/config/types';
|
|
||||||
import { useMenuConfig } from '#app/menu';
|
|
||||||
import { useMenu } from '#shared/hooks/useMenu';
|
|
||||||
import { Container } from '#shared/widgets/common/Container';
|
|
||||||
|
|
||||||
export interface ToolbarHeadingProps {
|
|
||||||
title?: string | ReactNode;
|
|
||||||
description?: string | ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
function Toolbar({ children }: { children?: ReactNode }) {
|
|
||||||
return (
|
|
||||||
<div className="mb-5 lg:mb-10">
|
|
||||||
<Container className="flex flex-wrap items-center justify-between gap-5">
|
|
||||||
{children}
|
|
||||||
</Container>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ToolbarActions({ children }: { children?: ReactNode }) {
|
|
||||||
return <div className="flex items-center gap-2.5">{children}</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function ToolbarBreadcrumbs() {
|
|
||||||
const menuConfig = useMenuConfig();
|
|
||||||
|
|
||||||
const { pathname } = useLocation();
|
|
||||||
const { getBreadcrumb, isActive } = useMenu(pathname);
|
|
||||||
const items: MenuItem[] = getBreadcrumb(menuConfig);
|
|
||||||
|
|
||||||
if (items.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="mb-2.5 flex items-center gap-1.5 text-xs font-medium lg:mb-0 lg:text-sm">
|
|
||||||
<div className="breadcrumb flex items-center gap-1">
|
|
||||||
{items.map((item, index) => {
|
|
||||||
const isLast = index === items.length - 1;
|
|
||||||
const active = item.path ? isActive(item.path) : false;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Fragment key={index}>
|
|
||||||
{item.path ? (
|
|
||||||
<Link
|
|
||||||
to={item.path}
|
|
||||||
className={cn(
|
|
||||||
'flex items-center gap-1',
|
|
||||||
active ? 'text-mono' : 'text-muted-foreground hover:text-primary'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{item.title}
|
|
||||||
</Link>
|
|
||||||
) : (
|
|
||||||
<span className={cn(isLast ? 'text-mono' : 'text-muted-foreground')}>
|
|
||||||
{item.title}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{!isLast && <ChevronRight className="muted-foreground size-3.5" />}
|
|
||||||
</Fragment>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const ToolbarHeading = ({ title = '' }: ToolbarHeadingProps) => {
|
|
||||||
const { pathname } = useLocation();
|
|
||||||
const { getCurrentItem } = useMenu(pathname);
|
|
||||||
const menuConfig = useMenuConfig();
|
|
||||||
const item = getCurrentItem(menuConfig);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-wrap items-center gap-1 lg:gap-5">
|
|
||||||
<h1 className="text-mono text-lg font-medium">{title || item?.title}</h1>
|
|
||||||
<ToolbarBreadcrumbs />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export { Toolbar, ToolbarActions, ToolbarBreadcrumbs, ToolbarHeading };
|
|
||||||
@@ -1,23 +1,18 @@
|
|||||||
// DEMO 3
|
// DEMO 3
|
||||||
// https://keenthemes.com/metronic/tailwind/react/demo3
|
// https://keenthemes.com/metronic/tailwind/react/demo3
|
||||||
|
|
||||||
import { Download } from 'lucide-react';
|
import { Outlet } from 'react-router-dom';
|
||||||
import { useEffect } from 'react';
|
|
||||||
import { Link, Outlet, useLocation } from 'react-router-dom';
|
|
||||||
import { useBodyClass, useIsMobile } from '@crux-kit/hooks';
|
import { useBodyClass, useIsMobile } from '@crux-kit/hooks';
|
||||||
import { LayoutNames } from '#app/config/types';
|
import { LayoutNames } from '#app/config/types';
|
||||||
import { useSettings } from '#app/providers/SettingsProvider';
|
import { AppFooter, Toolbar, ToolbarHeading, useLayoutChrome, useLayoutSettings } from '../shared';
|
||||||
import { Button } from '#shared/ui/Button';
|
|
||||||
import { Footer } from './components/Footer';
|
|
||||||
import { Header } from './components/Header';
|
import { Header } from './components/Header';
|
||||||
import { Navbar } from './components/Navbar';
|
import { Navbar } from './components/Navbar';
|
||||||
import { Sidebar } from './components/Sidebar';
|
import { Sidebar } from './components/Sidebar';
|
||||||
import { Toolbar, ToolbarActions, ToolbarHeading } from './components/Toolbar';
|
|
||||||
|
|
||||||
export function CompactSidebarLayout() {
|
export function CompactSidebarLayout() {
|
||||||
const { pathname } = useLocation();
|
useLayoutSettings(LayoutNames.COMPACT_SIDEBAR);
|
||||||
const { setOption } = useSettings();
|
const isMobile = useIsMobile();
|
||||||
const isMobileMode = useIsMobile();
|
const { showToolbar, showFooter } = useLayoutChrome();
|
||||||
|
|
||||||
useBodyClass(`
|
useBodyClass(`
|
||||||
[--header-height:58px]
|
[--header-height:58px]
|
||||||
@@ -27,40 +22,27 @@ export function CompactSidebarLayout() {
|
|||||||
bg-muted!
|
bg-muted!
|
||||||
`);
|
`);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setOption('layout', LayoutNames.COMPACT_SIDEBAR);
|
|
||||||
setOption('container', 'fluid');
|
|
||||||
}, [setOption]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex grow">
|
<div className="flex grow">
|
||||||
<Header />
|
<Header />
|
||||||
|
|
||||||
<div className="flex grow flex-col pt-(--header-height) lg:flex-row">
|
<div className="flex grow flex-col pt-(--header-height) lg:flex-row">
|
||||||
{!isMobileMode && <Sidebar />}
|
{!isMobile && <Sidebar />}
|
||||||
|
|
||||||
<Navbar />
|
<Navbar />
|
||||||
|
|
||||||
<div className="bg-background border-border mx-5 mb-5 flex grow rounded-b-xl border-x border-b lg:ms-(--sidebar-width) lg:mt-(--navbar-height)">
|
<div className="bg-background border-border mx-5 mb-5 flex grow rounded-b-xl border-x border-b lg:ms-(--sidebar-width) lg:mt-(--navbar-height)">
|
||||||
<div className="kt-scrollable-y flex grow flex-col pt-7 lg:[scrollbar-width:auto] lg:**:data-[slot=container]:pe-2">
|
<div className="kt-scrollable-y flex grow flex-col pt-7 lg:[scrollbar-width:auto] lg:**:data-[slot=container]:pe-2">
|
||||||
<main className="grow" role="content">
|
<main className="grow" role="content">
|
||||||
{pathname !== '/' && (
|
{showToolbar && (
|
||||||
<Toolbar>
|
<Toolbar>
|
||||||
<ToolbarHeading />
|
<ToolbarHeading />
|
||||||
<ToolbarActions>
|
|
||||||
<Button variant="outline" size="sm" asChild>
|
|
||||||
<Link to={'/account/home/get-started'}>
|
|
||||||
<Download />
|
|
||||||
Export
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
</ToolbarActions>
|
|
||||||
</Toolbar>
|
</Toolbar>
|
||||||
)}
|
)}
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
<Footer />
|
{showFooter && <AppFooter />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# Two Column Sidebar layout
|
||||||
|
|
||||||
|
**Metronic 9 reference:** [Demo 4](https://keenthemes.com/metronic/tailwind/react/demo4)
|
||||||
|
|
||||||
|
Two sidebar columns: a narrow icon rail choosing the top-level section, and a wider column with that
|
||||||
|
section's children as an accordion. Below `lg` both columns move into a single sheet.
|
||||||
|
|
||||||
|
## Composition
|
||||||
|
|
||||||
|
| Piece | Source |
|
||||||
|
| ----------------------- | ---------------------------------------------------------------------- |
|
||||||
|
| Section chooser | `useAppMenu` + `firstMenuPath` from `../shared` |
|
||||||
|
| Section menu | `SidebarAccordionMenu` from `../shared`, fed by `useActiveMenuSection` |
|
||||||
|
| Toolbar and breadcrumbs | `Toolbar` / `ToolbarHeading` from `../shared` |
|
||||||
|
| Footer | `AppFooter` from `../shared` |
|
||||||
|
| Account menu | `UserAvatarMenu` from `../shared` |
|
||||||
|
|
||||||
|
Layout-specific: `Sidebar`, `SidebarPrimary`, `SidebarSecondary`, `Header`.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- When the active section has no children, the secondary column shows the whole menu rather than
|
||||||
|
going blank.
|
||||||
|
- Body variables: `--header-height: 60px`, `--sidebar-width: 290px`.
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
import { generalSettings } from '#app/config/general';
|
|
||||||
import { Container } from '#shared/widgets/common/Container';
|
|
||||||
|
|
||||||
export function Footer() {
|
|
||||||
const currentYear = new Date().getFullYear();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<footer className="footer">
|
|
||||||
<Container>
|
|
||||||
<div className="flex flex-col items-center justify-center gap-3 py-5 md:flex-row md:justify-between">
|
|
||||||
<div className="order-2 flex gap-2 text-sm font-normal md:order-1">
|
|
||||||
<span className="text-muted-foreground">{currentYear} ©</span>
|
|
||||||
<a
|
|
||||||
href="https://keenthemes.com"
|
|
||||||
target="_blank"
|
|
||||||
className="text-secondary-foreground hover:text-primary"
|
|
||||||
>
|
|
||||||
Keenthemes Inc.
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<nav className="text-muted-foreground order-1 flex gap-4 text-sm font-normal md:order-2">
|
|
||||||
<a href={generalSettings.docsLink} target="_blank" className="hover:text-primary">
|
|
||||||
Docs
|
|
||||||
</a>
|
|
||||||
<a href={generalSettings.purchaseLink} target="_blank" className="hover:text-primary">
|
|
||||||
Purchase
|
|
||||||
</a>
|
|
||||||
<a href={generalSettings.faqLink} target="_blank" className="hover:text-primary">
|
|
||||||
FAQ
|
|
||||||
</a>
|
|
||||||
<a href="https://devs.keenthemes.com" target="_blank" className="hover:text-primary">
|
|
||||||
Support
|
|
||||||
</a>
|
|
||||||
<a href={generalSettings.licenseLink} target="_blank" className="hover:text-primary">
|
|
||||||
License
|
|
||||||
</a>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
</Container>
|
|
||||||
</footer>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
import { Menu } from 'lucide-react';
|
import { Menu } from 'lucide-react';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Link, useLocation } from 'react-router-dom';
|
import { useLocation } from 'react-router-dom';
|
||||||
import { toAbsoluteUrl } from '#shared/lib/helpers';
|
|
||||||
import { Button } from '#shared/ui/Button';
|
import { Button } from '#shared/ui/Button';
|
||||||
import { Sheet, SheetBody, SheetContent, SheetHeader, SheetTrigger } from '#shared/ui/Sheet';
|
import { Sheet, SheetBody, SheetContent, SheetHeader, SheetTrigger } from '#shared/ui/Sheet';
|
||||||
import { Container } from '#shared/widgets/common/Container';
|
import { Container } from '#shared/widgets/common/Container';
|
||||||
|
import { AppLogo } from '../../shared';
|
||||||
import { SidebarPrimary } from './SidebarPrimary';
|
import { SidebarPrimary } from './SidebarPrimary';
|
||||||
import { SidebarSecondary } from './SidebarSecondary';
|
import { SidebarSecondary } from './SidebarSecondary';
|
||||||
|
|
||||||
@@ -12,7 +12,6 @@ export function Header() {
|
|||||||
const [isSheetOpen, setIsSheetOpen] = useState(false);
|
const [isSheetOpen, setIsSheetOpen] = useState(false);
|
||||||
const { pathname } = useLocation();
|
const { pathname } = useLocation();
|
||||||
|
|
||||||
// Close sheet when route changes
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setIsSheetOpen(false);
|
setIsSheetOpen(false);
|
||||||
}, [pathname]);
|
}, [pathname]);
|
||||||
@@ -20,18 +19,7 @@ export function Header() {
|
|||||||
return (
|
return (
|
||||||
<header className="fixed start-0 end-0 top-0 z-10 flex h-(--header-height) shrink-0 items-center bg-(--page-bg) lg:hidden dark:bg-(--page-bg-dark)">
|
<header className="fixed start-0 end-0 top-0 z-10 flex h-(--header-height) shrink-0 items-center bg-(--page-bg) lg:hidden dark:bg-(--page-bg-dark)">
|
||||||
<Container className="flex flex-wrap items-center justify-between gap-3">
|
<Container className="flex flex-wrap items-center justify-between gap-3">
|
||||||
<Link to="/">
|
<AppLogo className="size-7.5" />
|
||||||
<img
|
|
||||||
src={toAbsoluteUrl('/media/app/mini-logo-gray.svg')}
|
|
||||||
className="min-h-7.5 dark:hidden"
|
|
||||||
alt=""
|
|
||||||
/>
|
|
||||||
<img
|
|
||||||
src={toAbsoluteUrl('/media/app/mini-logo-gray-dark.svg')}
|
|
||||||
className="hidden min-h-7.5 dark:block"
|
|
||||||
alt=""
|
|
||||||
/>
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<Sheet open={isSheetOpen} onOpenChange={setIsSheetOpen}>
|
<Sheet open={isSheetOpen} onOpenChange={setIsSheetOpen}>
|
||||||
<SheetTrigger asChild>
|
<SheetTrigger asChild>
|
||||||
|
|||||||
@@ -1,161 +0,0 @@
|
|||||||
import { Badge, ChevronDown, FileText, Settings, SquareCode, UserCircle } from 'lucide-react';
|
|
||||||
import { useCallback, useMemo } from 'react';
|
|
||||||
import { Link, useLocation } from 'react-router-dom';
|
|
||||||
import { AccordionMenu, AccordionMenuClassNames, AccordionMenuGroup, AccordionMenuItem, AccordionMenuLabel } from '#shared/ui/AccordionMenu';
|
|
||||||
import { Button } from '#shared/ui/Button';
|
|
||||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '#shared/ui/DropdownMenu';
|
|
||||||
|
|
||||||
interface DropdownItem {
|
|
||||||
title: string;
|
|
||||||
path: string;
|
|
||||||
icon: React.ComponentType<{ className?: string }>;
|
|
||||||
active?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MenuItem {
|
|
||||||
title: string;
|
|
||||||
path?: string;
|
|
||||||
active?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MenuLabel {
|
|
||||||
label: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
type MenuNode = MenuItem | MenuLabel;
|
|
||||||
|
|
||||||
export function SidebarMenuDashboard() {
|
|
||||||
const { pathname } = useLocation();
|
|
||||||
|
|
||||||
const dropdownItems: DropdownItem[] = [
|
|
||||||
{
|
|
||||||
title: 'Client API',
|
|
||||||
path: '/account/home/user-profile',
|
|
||||||
icon: SquareCode,
|
|
||||||
active: true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Profile',
|
|
||||||
path: '/public-profile/profiles/company',
|
|
||||||
icon: UserCircle
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'My Account',
|
|
||||||
path: '/account/integrations',
|
|
||||||
icon: Settings
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Projects',
|
|
||||||
path: '/public-profile/projects/3-columns',
|
|
||||||
icon: FileText
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Personal info',
|
|
||||||
path: '/public-profile/profiles/creator',
|
|
||||||
icon: Badge
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
const currentDropdownItem = dropdownItems[0];
|
|
||||||
|
|
||||||
const menuItems = useMemo<MenuNode[]>(
|
|
||||||
() => [
|
|
||||||
{ label: 'Configuration' },
|
|
||||||
{ title: 'API Setup', path: '/account/api-keys' },
|
|
||||||
{ title: 'Team Settings', path: '/account/home/settings-sidebar' },
|
|
||||||
{ title: 'Authentication', path: '/authentication/classic/sign-in' },
|
|
||||||
{ title: 'Endpoints Configs', path: '/account/appearance' },
|
|
||||||
{ title: 'Rate Limiting', path: '/public-profile/network' },
|
|
||||||
{ label: 'Security' },
|
|
||||||
{ title: 'Data Encryption', path: '/account/billing/enterprise' },
|
|
||||||
{ title: 'Text', path: '/account/security/overview' },
|
|
||||||
{ title: 'Access Control', path: '/account/security/privacy-settings' },
|
|
||||||
{ label: 'Analytics' },
|
|
||||||
{
|
|
||||||
title: 'Incident Response',
|
|
||||||
path: '/account/security/current-sessions'
|
|
||||||
},
|
|
||||||
{ title: 'Fetching Data', path: '/account/members/team-info' },
|
|
||||||
{ title: 'Custom Reports', path: '/account/home/user-profile' },
|
|
||||||
{
|
|
||||||
title: 'Real Time Analytics',
|
|
||||||
path: '/account/home/settings-enterprise'
|
|
||||||
},
|
|
||||||
{ title: 'Exporting Data', path: '/account/members/import-members' },
|
|
||||||
{ title: 'Dashboard Integration', path: '/account/members/team-info' }
|
|
||||||
],
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
const classNames: AccordionMenuClassNames = {
|
|
||||||
root: 'space-y-1',
|
|
||||||
label: 'uppercase text-xs font-medium text-muted-foreground/80 pt-6 mb-2 pb-0',
|
|
||||||
item: 'h-8 hover:bg-background border-accent text-accent-foreground hover:text-primary data-[selected=true]:text-primary data-[selected=true]:bg-background data-[selected=true]:font-medium'
|
|
||||||
};
|
|
||||||
|
|
||||||
const matchPath = useCallback(
|
|
||||||
(path: string): boolean => path === pathname || (path.length > 1 && pathname.startsWith(path)),
|
|
||||||
[pathname]
|
|
||||||
);
|
|
||||||
|
|
||||||
const buildDropdown = () => {
|
|
||||||
return (
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button variant="outline" mode="input" className="w-full justify-between">
|
|
||||||
<span className="flex items-center gap-2">
|
|
||||||
<currentDropdownItem.icon />
|
|
||||||
{currentDropdownItem.title}
|
|
||||||
</span>
|
|
||||||
<ChevronDown className="size-3.5" />
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent className="w-(--radix-dropdown-menu-trigger-width)">
|
|
||||||
{dropdownItems.map((item, index) => (
|
|
||||||
<DropdownMenuItem key={index} asChild>
|
|
||||||
<Link to={item.path}>
|
|
||||||
<item.icon />
|
|
||||||
<span>{item.title}</span>
|
|
||||||
</Link>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
))}
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const buildMenu = () => {
|
|
||||||
return (
|
|
||||||
<AccordionMenu
|
|
||||||
selectedValue={'/account/home/settings-sidebar'}
|
|
||||||
matchPath={matchPath}
|
|
||||||
type="single"
|
|
||||||
collapsible
|
|
||||||
classNames={classNames}
|
|
||||||
>
|
|
||||||
<AccordionMenuGroup>
|
|
||||||
{menuItems.map((item, index) =>
|
|
||||||
'label' in item ? (
|
|
||||||
<AccordionMenuLabel key={index}>{item.label}</AccordionMenuLabel>
|
|
||||||
) : (
|
|
||||||
<AccordionMenuItem
|
|
||||||
key={index}
|
|
||||||
value={item.path || `item-${index}`}
|
|
||||||
className="text-sm"
|
|
||||||
>
|
|
||||||
<Link to={item.path || '#'}>{item.title}</Link>
|
|
||||||
</AccordionMenuItem>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
</AccordionMenuGroup>
|
|
||||||
</AccordionMenu>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="w-full space-y-1">
|
|
||||||
{buildDropdown()}
|
|
||||||
{buildMenu()}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,183 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { JSX, useMemo } from 'react';
|
|
||||||
import { Link, useLocation } from 'react-router-dom';
|
|
||||||
import { cn } from '@crux-kit/utils';
|
|
||||||
import { MenuConfig, MenuItem } from '#app/config/types';
|
|
||||||
import { useMenuConfig } from '#app/menu';
|
|
||||||
import { useMenu } from '#shared/hooks/useMenu';
|
|
||||||
import { createMenuPathMatcher } from '#shared/lib/menuPath';
|
|
||||||
import { AccordionMenu, AccordionMenuClassNames, AccordionMenuGroup, AccordionMenuItem, AccordionMenuLabel, AccordionMenuSub, AccordionMenuSubContent, AccordionMenuSubTrigger } from '#shared/ui/AccordionMenu';
|
|
||||||
import { Badge } from '#shared/ui/Badge';
|
|
||||||
|
|
||||||
export function SidebarMenuDefault() {
|
|
||||||
const { pathname } = useLocation();
|
|
||||||
const fullMenuConfig = useMenuConfig();
|
|
||||||
|
|
||||||
// Memoize matchPath to prevent unnecessary re-renders
|
|
||||||
const matchPath = useMemo(
|
|
||||||
() => createMenuPathMatcher(fullMenuConfig, pathname),
|
|
||||||
[fullMenuConfig, pathname]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Global classNames for consistent styling
|
|
||||||
const classNames: AccordionMenuClassNames = {
|
|
||||||
root: 'lg:ps-1 space-y-1',
|
|
||||||
group: 'gap-px',
|
|
||||||
label: 'uppercase text-xs font-medium text-muted-foreground/70 pt-2.25 pb-px',
|
|
||||||
separator: '',
|
|
||||||
item: 'h-8 hover:bg-background border-accent text-accent-foreground hover:text-primary data-[selected=true]:text-primary data-[selected=true]:bg-background data-[selected=true]:font-medium',
|
|
||||||
sub: '',
|
|
||||||
subTrigger:
|
|
||||||
'h-8 hover:bg-transparent text-accent-foreground hover:text-primary data-[selected=true]:text-primary data-[selected=true]:bg-transparent data-[selected=true]:font-medium',
|
|
||||||
subContent: 'ps-3 py-0',
|
|
||||||
indicator: ''
|
|
||||||
};
|
|
||||||
|
|
||||||
const buildMenu = (items: MenuConfig): JSX.Element[] => {
|
|
||||||
return items.map((item: MenuItem, index: number) => {
|
|
||||||
if (item.heading) {
|
|
||||||
return buildMenuHeading(item, index);
|
|
||||||
} else if (item.disabled) {
|
|
||||||
return buildMenuItemRootDisabled(item, index);
|
|
||||||
} else {
|
|
||||||
return buildMenuItemRoot(item, index);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const buildMenuItemRoot = (item: MenuItem, index: number): JSX.Element => {
|
|
||||||
if (item.children) {
|
|
||||||
return (
|
|
||||||
<AccordionMenuSub key={index} value={item.path || `root-${index}`}>
|
|
||||||
<AccordionMenuSubTrigger className="text-sm">
|
|
||||||
{item.icon && <item.icon data-slot="accordion-menu-icon" />}
|
|
||||||
<span data-slot="accordion-menu-title">{item.title}</span>
|
|
||||||
</AccordionMenuSubTrigger>
|
|
||||||
<AccordionMenuSubContent
|
|
||||||
type="single"
|
|
||||||
collapsible
|
|
||||||
parentValue={item.path || `root-${index}`}
|
|
||||||
>
|
|
||||||
<AccordionMenuGroup>{buildMenuItemChildren(item.children, 1)}</AccordionMenuGroup>
|
|
||||||
</AccordionMenuSubContent>
|
|
||||||
</AccordionMenuSub>
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
return (
|
|
||||||
<AccordionMenuItem key={index} value={item.path || ''} className="text-sm">
|
|
||||||
<Link to={item.path || '#'} className="flex grow items-center justify-between gap-2">
|
|
||||||
{item.icon && <item.icon data-slot="accordion-menu-icon" />}
|
|
||||||
<span data-slot="accordion-menu-title">{item.title}</span>
|
|
||||||
</Link>
|
|
||||||
</AccordionMenuItem>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const buildMenuItemRootDisabled = (item: MenuItem, index: number): JSX.Element => {
|
|
||||||
return (
|
|
||||||
<AccordionMenuItem key={index} value={`disabled-${index}`} className="text-sm font-medium">
|
|
||||||
{item.icon && <item.icon data-slot="accordion-menu-icon" />}
|
|
||||||
<span data-slot="accordion-menu-title">{item.title}</span>
|
|
||||||
{item.disabled && (
|
|
||||||
<Badge variant="secondary" size="sm" className="ms-auto -me-2.5">
|
|
||||||
Soon
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</AccordionMenuItem>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const buildMenuItemChildren = (items: MenuConfig, level: number = 0): JSX.Element[] => {
|
|
||||||
return items.map((item: MenuItem, index: number) => {
|
|
||||||
if (item.disabled) {
|
|
||||||
return buildMenuItemChildDisabled(item, index, level);
|
|
||||||
} else {
|
|
||||||
return buildMenuItemChild(item, index, level);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const buildMenuItemChild = (item: MenuItem, index: number, level: number = 0): JSX.Element => {
|
|
||||||
if (item.children) {
|
|
||||||
return (
|
|
||||||
<AccordionMenuSub key={index} value={item.path || `child-${level}-${index}`}>
|
|
||||||
<AccordionMenuSubTrigger className="text-[13px]">
|
|
||||||
{item.collapse ? (
|
|
||||||
<span className="text-muted-foreground">
|
|
||||||
<span className="hidden [[data-state=open]>span>&]:inline">
|
|
||||||
{item.collapseTitle}
|
|
||||||
</span>
|
|
||||||
<span className="inline [[data-state=open]>span>&]:hidden">{item.expandTitle}</span>
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
item.title
|
|
||||||
)}
|
|
||||||
</AccordionMenuSubTrigger>
|
|
||||||
<AccordionMenuSubContent
|
|
||||||
type="single"
|
|
||||||
collapsible
|
|
||||||
parentValue={item.path || `child-${level}-${index}`}
|
|
||||||
className={cn(
|
|
||||||
'ps-4',
|
|
||||||
!item.collapse && 'relative',
|
|
||||||
!item.collapse && (level > 0 ? '' : '')
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<AccordionMenuGroup>
|
|
||||||
{buildMenuItemChildren(item.children, item.collapse ? level : level + 1)}
|
|
||||||
</AccordionMenuGroup>
|
|
||||||
</AccordionMenuSubContent>
|
|
||||||
</AccordionMenuSub>
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
return (
|
|
||||||
<AccordionMenuItem key={index} value={item.path || ''} className="text-[13px]">
|
|
||||||
<Link to={item.path || '#'}>{item.title}</Link>
|
|
||||||
</AccordionMenuItem>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const buildMenuItemChildDisabled = (
|
|
||||||
item: MenuItem,
|
|
||||||
index: number,
|
|
||||||
level: number = 0
|
|
||||||
): JSX.Element => {
|
|
||||||
return (
|
|
||||||
<AccordionMenuItem
|
|
||||||
key={index}
|
|
||||||
value={`disabled-child-${level}-${index}`}
|
|
||||||
className="text-[13px]"
|
|
||||||
>
|
|
||||||
<span data-slot="accordion-menu-title">{item.title}</span>
|
|
||||||
{item.disabled && (
|
|
||||||
<Badge variant="secondary" size="sm" className="ms-auto -me-2.5">
|
|
||||||
Soon
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</AccordionMenuItem>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const buildMenuHeading = (item: MenuItem, index: number): JSX.Element => {
|
|
||||||
return <AccordionMenuLabel key={index}>{item.heading}</AccordionMenuLabel>;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Memoize the menu to prevent unnecessary re-renders
|
|
||||||
const { getChildren } = useMenu(pathname);
|
|
||||||
const menuItems = getChildren(fullMenuConfig, 0);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AccordionMenu
|
|
||||||
selectedValue={pathname}
|
|
||||||
matchPath={matchPath}
|
|
||||||
type="single"
|
|
||||||
collapsible
|
|
||||||
classNames={classNames}
|
|
||||||
>
|
|
||||||
{buildMenu(menuItems || [])}
|
|
||||||
</AccordionMenu>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,184 +1,57 @@
|
|||||||
import { BarChart3, Bell, CheckSquare, Code, MessageSquare, Settings, Shield, ShoppingCart, UserCircle, Users } from 'lucide-react';
|
|
||||||
import { useEffect, useRef, useState } from 'react';
|
|
||||||
import { Link, useLocation } from 'react-router-dom';
|
import { Link, useLocation } from 'react-router-dom';
|
||||||
import { useViewport } from '@crux-kit/hooks';
|
import { cn } from '@crux-kit/utils';
|
||||||
import { cn, getHeight } from '@crux-kit/utils';
|
import { useMenu } from '#shared/hooks/useMenu';
|
||||||
import { toAbsoluteUrl } from '#shared/lib/helpers';
|
|
||||||
import { Button } from '#shared/ui/Button';
|
import { Button } from '#shared/ui/Button';
|
||||||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '#shared/ui/Tooltip';
|
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '#shared/ui/Tooltip';
|
||||||
// import { AppsDropdownMenu } from '#shared/partials/topbar/apps-dropdown-menu';
|
import { AppLogo, firstMenuPath, useAppMenu, UserAvatarMenu } from '../../shared';
|
||||||
// import { ChatSheet } from '#shared/partials/topbar/chat-sheet';
|
|
||||||
import { UserDropdownMenu } from '#shared/widgets/UserDropdownMenu';
|
|
||||||
|
|
||||||
interface MenuItem {
|
|
||||||
icon: React.ComponentType<{ className?: string }>;
|
|
||||||
tooltip: string;
|
|
||||||
path: string;
|
|
||||||
rootPath?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const menuItems: MenuItem[] = [
|
|
||||||
{ icon: BarChart3, tooltip: 'Dashboard', path: '/', rootPath: '/' },
|
|
||||||
{
|
|
||||||
icon: UserCircle,
|
|
||||||
tooltip: 'Profile',
|
|
||||||
path: '/public-profile/profiles/default',
|
|
||||||
rootPath: '/public-profile/'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: Settings,
|
|
||||||
tooltip: 'Account',
|
|
||||||
path: '/account/home/get-started',
|
|
||||||
rootPath: '/account/'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: Users,
|
|
||||||
tooltip: 'Network',
|
|
||||||
path: '/network/get-started',
|
|
||||||
rootPath: '/network/'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: ShoppingCart,
|
|
||||||
tooltip: 'Store - Client',
|
|
||||||
path: '/store-client/home',
|
|
||||||
rootPath: ''
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: Shield,
|
|
||||||
tooltip: 'Authentication',
|
|
||||||
path: '/authentication/get-started',
|
|
||||||
rootPath: '/authentication/'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: MessageSquare,
|
|
||||||
tooltip: 'Security Logs',
|
|
||||||
path: '/account/security/security-log',
|
|
||||||
rootPath: '/account/'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: Bell,
|
|
||||||
tooltip: 'Notifications',
|
|
||||||
path: '/account/notifications',
|
|
||||||
rootPath: ''
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: CheckSquare,
|
|
||||||
tooltip: 'ACL',
|
|
||||||
path: '/account/members/roles',
|
|
||||||
rootPath: ''
|
|
||||||
},
|
|
||||||
{ icon: Code, tooltip: 'API Keys', path: '/account/api-keys', rootPath: '' }
|
|
||||||
];
|
|
||||||
|
|
||||||
export function SidebarPrimary() {
|
export function SidebarPrimary() {
|
||||||
const headerRef = useRef<HTMLDivElement>(null);
|
const menuConfig = useAppMenu();
|
||||||
const footerRef = useRef<HTMLDivElement>(null);
|
|
||||||
const [scrollableHeight, setScrollableHeight] = useState<number>(0);
|
|
||||||
const [viewportHeight] = useViewport();
|
|
||||||
const scrollableOffset = 80;
|
|
||||||
const { pathname } = useLocation();
|
const { pathname } = useLocation();
|
||||||
|
const { isItemActive } = useMenu(pathname);
|
||||||
useEffect(() => {
|
|
||||||
if (headerRef.current && footerRef.current) {
|
|
||||||
const headerHeight = getHeight(headerRef.current);
|
|
||||||
const footerHeight = getHeight(footerRef.current);
|
|
||||||
const availableHeight = viewportHeight - headerHeight - footerHeight - scrollableOffset;
|
|
||||||
setScrollableHeight(availableHeight);
|
|
||||||
} else {
|
|
||||||
setScrollableHeight(viewportHeight);
|
|
||||||
}
|
|
||||||
}, [viewportHeight]);
|
|
||||||
|
|
||||||
const [selectedMenuItem, setSelectedMenuItem] = useState(menuItems[0]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
menuItems.forEach((item) => {
|
|
||||||
if (item.rootPath === pathname || (item.rootPath && pathname.includes(item.rootPath))) {
|
|
||||||
setSelectedMenuItem(item);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}, [pathname]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<div className="border-input flex w-17.5 shrink-0 flex-col items-stretch gap-5 border-e py-5">
|
<div className="border-input flex w-17.5 shrink-0 flex-col items-stretch gap-5 border-e py-5">
|
||||||
<div ref={headerRef} className="hidden shrink-0 items-center justify-center lg:flex">
|
<div className="hidden shrink-0 items-center justify-center lg:flex">
|
||||||
<Link to="/">
|
<AppLogo className="size-7.5" />
|
||||||
<img
|
|
||||||
src={toAbsoluteUrl('/media/app/mini-logo-gray.svg')}
|
|
||||||
className="min-h-7.5 dark:hidden"
|
|
||||||
alt=""
|
|
||||||
/>
|
|
||||||
<img
|
|
||||||
src={toAbsoluteUrl('/media/app/mini-logo-gray-dark.svg')}
|
|
||||||
className="hidden min-h-7.5 dark:block"
|
|
||||||
alt=""
|
|
||||||
/>
|
|
||||||
</Link>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex shrink-0 grow">
|
<div className="kt-scrollable-y-hover flex shrink-0 grow flex-col gap-2.5 ps-4">
|
||||||
<div
|
{menuConfig.map((item, index) => {
|
||||||
className="kt-scrollable-y-hover flex shrink-0 grow flex-col gap-2.5 ps-4"
|
const path = firstMenuPath(item);
|
||||||
style={{ height: `${scrollableHeight}px` }}
|
|
||||||
>
|
if (!path) {
|
||||||
{menuItems.map((item, index) => (
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
<Tooltip key={index}>
|
<Tooltip key={index}>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<Button
|
<Button
|
||||||
asChild
|
asChild
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
mode="icon"
|
mode="icon"
|
||||||
{...(item === selectedMenuItem ? { 'data-state': 'open' } : {})}
|
{...(isItemActive(item) ? { 'data-state': 'open' } : {})}
|
||||||
className={cn(
|
className={cn(
|
||||||
'size-9 shrink-0 rounded-md',
|
'size-9 shrink-0 rounded-md',
|
||||||
'data-[state=open]:bg-background data-[state=open]:border-input data-[state=open]:text-primary data-[state=open]:border',
|
'data-[state=open]:bg-background data-[state=open]:border-input data-[state=open]:text-primary data-[state=open]:border',
|
||||||
'hover:bg-background hover:border-input hover:text-primary hover:border'
|
'hover:bg-background hover:border-input hover:text-primary hover:border'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Link to={item.path}>
|
<Link to={path} aria-label={item.title}>
|
||||||
<item.icon className="size-4.5!" />
|
{item.icon && <item.icon className="size-4.5!" />}
|
||||||
</Link>
|
</Link>
|
||||||
</Button>
|
</Button>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="right">{item.tooltip}</TooltipContent>
|
<TooltipContent side="right">{item.title}</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
))}
|
);
|
||||||
</div>
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div ref={footerRef} className="flex shrink-0 flex-col items-center gap-4">
|
<div className="flex shrink-0 flex-col items-center gap-4">
|
||||||
{/* <ChatSheet
|
<UserAvatarMenu />
|
||||||
trigger={
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
mode="icon"
|
|
||||||
className="size-9 hover:bg-background hover:[&_svg]:text-primary"
|
|
||||||
>
|
|
||||||
<MessageCircleMore className="size-4.5!" />
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<AppsDropdownMenu
|
|
||||||
trigger={
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
mode="icon"
|
|
||||||
className="size-9 hover:bg-background hover:[&_svg]:text-primary"
|
|
||||||
>
|
|
||||||
<LayoutGrid className="size-4.5!" />
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/> */}
|
|
||||||
<UserDropdownMenu
|
|
||||||
trigger={
|
|
||||||
<img
|
|
||||||
className="border-border size-9 shrink-0 cursor-pointer rounded-full border"
|
|
||||||
src={toAbsoluteUrl('/media/avatars/300-2.png')}
|
|
||||||
alt="User Avatar"
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
|
|||||||
@@ -1,13 +1,30 @@
|
|||||||
import { useLocation } from 'react-router';
|
import { SidebarAccordionMenu, useActiveMenuSection } from '../../shared';
|
||||||
import { SidebarMenuDashboard } from './SidebarMenuDashboard';
|
|
||||||
import { SidebarMenuDefault } from './SidebarMenuDefault';
|
|
||||||
|
|
||||||
|
const CLASS_NAMES = {
|
||||||
|
root: 'lg:ps-1 space-y-1',
|
||||||
|
group: 'gap-px',
|
||||||
|
label: 'uppercase text-xs font-medium text-muted-foreground/70 pt-2.25 pb-px',
|
||||||
|
separator: '',
|
||||||
|
item: 'h-8 hover:bg-background border-accent text-accent-foreground hover:text-primary data-[selected=true]:text-primary data-[selected=true]:bg-background data-[selected=true]:font-medium',
|
||||||
|
sub: '',
|
||||||
|
subTrigger:
|
||||||
|
'h-8 hover:bg-transparent text-accent-foreground hover:text-primary data-[selected=true]:text-primary data-[selected=true]:bg-transparent data-[selected=true]:font-medium',
|
||||||
|
subContent: 'ps-3 py-0',
|
||||||
|
indicator: ''
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The children of the section chosen in the primary column. A section without children (a
|
||||||
|
* single-page entry such as the dashboard) falls back to the whole menu, so this column is never
|
||||||
|
* blank.
|
||||||
|
*/
|
||||||
export function SidebarSecondary() {
|
export function SidebarSecondary() {
|
||||||
const { pathname } = useLocation();
|
const section = useActiveMenuSection();
|
||||||
|
const items = section?.children;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="kt-scrollable-y-hover my-5 max-h-[calc(100vh-2rem)] shrink-0 grow ps-3.5 pe-1">
|
<div className="kt-scrollable-y-hover my-5 max-h-[calc(100vh-2rem)] shrink-0 grow ps-3.5 pe-1">
|
||||||
{pathname === '/' ? <SidebarMenuDashboard /> : <SidebarMenuDefault />}
|
<SidebarAccordionMenu classNames={CLASS_NAMES} items={items} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,83 +0,0 @@
|
|||||||
import { Fragment, ReactNode } from 'react';
|
|
||||||
import { Link, useLocation } from 'react-router-dom';
|
|
||||||
import { cn } from '@crux-kit/utils';
|
|
||||||
import { MenuItem } from '#app/config/types';
|
|
||||||
import { useMenuConfig } from '#app/menu';
|
|
||||||
import { useMenu } from '#shared/hooks/useMenu';
|
|
||||||
import { Container } from '#shared/widgets/common/Container';
|
|
||||||
|
|
||||||
export interface ToolbarHeadingProps {
|
|
||||||
title?: string | ReactNode;
|
|
||||||
description?: string | ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
function Toolbar({ children }: { children?: ReactNode }) {
|
|
||||||
return (
|
|
||||||
<div className="mb-5">
|
|
||||||
<Container className="flex flex-wrap items-center justify-between gap-5">
|
|
||||||
{children}
|
|
||||||
</Container>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ToolbarActions({ children }: { children?: ReactNode }) {
|
|
||||||
return <div className="flex items-center gap-2.5">{children}</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function ToolbarBreadcrumbs() {
|
|
||||||
const { pathname } = useLocation();
|
|
||||||
const menuConfig = useMenuConfig();
|
|
||||||
const { getBreadcrumb, isActive } = useMenu(pathname);
|
|
||||||
const items: MenuItem[] = getBreadcrumb(menuConfig);
|
|
||||||
|
|
||||||
if (items.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-1 text-sm">
|
|
||||||
{items.map((item, index) => {
|
|
||||||
const isLast = index === items.length - 1;
|
|
||||||
const active = item.path ? isActive(item.path) : false;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Fragment key={index}>
|
|
||||||
{item.path ? (
|
|
||||||
<Link
|
|
||||||
to={item.path}
|
|
||||||
className={cn(
|
|
||||||
'flex items-center gap-1',
|
|
||||||
active ? 'text-mono' : 'text-secondary-foreground hover:text-primary'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{item.title}
|
|
||||||
</Link>
|
|
||||||
) : (
|
|
||||||
<span className={cn(isLast ? 'text-mono' : 'text-secondary-foreground')}>
|
|
||||||
{item.title}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{!isLast && <span className="text-secondary-foreground">/</span>}
|
|
||||||
</Fragment>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const ToolbarHeading = ({ title = '' }: ToolbarHeadingProps) => {
|
|
||||||
const { pathname } = useLocation();
|
|
||||||
const { getCurrentItem } = useMenu(pathname);
|
|
||||||
const menuConfig = useMenuConfig();
|
|
||||||
const item = getCurrentItem(menuConfig);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-wrap items-center gap-1 lg:gap-5">
|
|
||||||
<h1 className="text-mono text-lg font-medium">{title || item?.title}</h1>
|
|
||||||
<ToolbarBreadcrumbs />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export { Toolbar, ToolbarActions, ToolbarBreadcrumbs, ToolbarHeading };
|
|
||||||
@@ -1,25 +1,18 @@
|
|||||||
// DEMO 4
|
// DEMO 4
|
||||||
// https://keenthemes.com/metronic/tailwind/react/demo4
|
// https://keenthemes.com/metronic/tailwind/react/demo4
|
||||||
|
|
||||||
import { Download } from 'lucide-react';
|
import { Outlet } from 'react-router-dom';
|
||||||
import { useEffect } from 'react';
|
|
||||||
import { Link, Outlet } from 'react-router-dom';
|
|
||||||
import { useBodyClass, useIsMobile } from '@crux-kit/hooks';
|
import { useBodyClass, useIsMobile } from '@crux-kit/hooks';
|
||||||
import { LayoutNames } from '#app/config/types';
|
import { LayoutNames } from '#app/config/types';
|
||||||
import { useSettings } from '#app/providers/SettingsProvider';
|
import { AppFooter, Toolbar, ToolbarHeading, useLayoutChrome, useLayoutSettings } from '../shared';
|
||||||
import { Button } from '#shared/ui/Button';
|
|
||||||
// import { SearchDialog } from '#shared/partials/dialogs/search/search-dialog';
|
|
||||||
// import { NotificationsSheet } from '#shared/partials/topbar/notifications-sheet';
|
|
||||||
import { Footer } from './components/Footer';
|
|
||||||
import { Header } from './components/Header';
|
import { Header } from './components/Header';
|
||||||
import { Sidebar } from './components/Sidebar';
|
import { Sidebar } from './components/Sidebar';
|
||||||
import { Toolbar, ToolbarActions, ToolbarHeading } from './components/Toolbar';
|
|
||||||
|
|
||||||
export function TwoColumnSidebarLayout() {
|
export function TwoColumnSidebarLayout() {
|
||||||
const { setOption } = useSettings();
|
useLayoutSettings(LayoutNames.TWO_COLUMN_SIDEBAR);
|
||||||
const isMobileMode = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
|
const { showToolbar, showFooter } = useLayoutChrome();
|
||||||
|
|
||||||
// Using the custom hook to set multiple CSS variables and class properties
|
|
||||||
useBodyClass(`
|
useBodyClass(`
|
||||||
[--header-height:60px]
|
[--header-height:60px]
|
||||||
[--sidebar-width:290px]
|
[--sidebar-width:290px]
|
||||||
@@ -27,64 +20,27 @@ export function TwoColumnSidebarLayout() {
|
|||||||
bg-muted!
|
bg-muted!
|
||||||
`);
|
`);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setOption('layout', LayoutNames.TWO_COLUMN_SIDEBAR);
|
|
||||||
}, [setOption]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex grow">
|
<div className="flex grow">
|
||||||
{isMobileMode && <Header />}
|
{isMobile && <Header />}
|
||||||
|
|
||||||
<div className="flex grow flex-col pt-(--header-height) lg:flex-row lg:pt-0">
|
<div className="flex grow flex-col pt-(--header-height) lg:flex-row lg:pt-0">
|
||||||
{!isMobileMode && <Sidebar />}
|
{!isMobile && <Sidebar />}
|
||||||
|
|
||||||
<div className="bg-background border-input m-5 mt-0 flex grow rounded-xl border lg:ms-(--sidebar-width) lg:mt-5">
|
<div className="bg-background border-input m-5 mt-0 flex grow rounded-xl border lg:ms-(--sidebar-width) lg:mt-5">
|
||||||
<div className="kt-scrollable-y-auto flex grow flex-col pt-5 lg:[--kt-scrollbar-width:auto]">
|
<div className="kt-scrollable-y-auto flex grow flex-col pt-5 lg:[--kt-scrollbar-width:auto]">
|
||||||
<main className="grow" role="content">
|
<main className="grow" role="content">
|
||||||
<Toolbar>
|
{showToolbar && (
|
||||||
<ToolbarHeading />
|
<Toolbar>
|
||||||
|
<ToolbarHeading />
|
||||||
<ToolbarActions>
|
</Toolbar>
|
||||||
{/* <SearchDialog
|
)}
|
||||||
trigger={
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
mode="icon"
|
|
||||||
className="hover:bg-primary/10 hover:[&_svg]:text-primary"
|
|
||||||
>
|
|
||||||
<Search className="size-4.5!" />
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<NotificationsSheet
|
|
||||||
trigger={
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
mode="icon"
|
|
||||||
className="hover:bg-primary/10 hover:[&_svg]:text-primary"
|
|
||||||
>
|
|
||||||
<MessageSquareDot className="size-4.5!" />
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/> */}
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
className="hover:bg-background hover:[&_svg]:text-primary hover:text-primary ms-2.5"
|
|
||||||
asChild
|
|
||||||
>
|
|
||||||
<Link to={'/account/home/get-started'}>
|
|
||||||
<Download />
|
|
||||||
Export
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
</ToolbarActions>
|
|
||||||
</Toolbar>
|
|
||||||
|
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<Footer />
|
{showFooter && <AppFooter />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# Dual Rows Header layout
|
||||||
|
|
||||||
|
**Metronic 9 reference:** [Demo 5](https://keenthemes.com/metronic/tailwind/react/demo5)
|
||||||
|
|
||||||
|
A two-row header — logo and account menu on top, the top-level sections on a menubar below — with
|
||||||
|
the selected section's children in a slim sidebar beside the content.
|
||||||
|
|
||||||
|
## Composition
|
||||||
|
|
||||||
|
| Piece | Source |
|
||||||
|
| ----------------------- | ---------------------------------------------------------------------- |
|
||||||
|
| Section menubar | `NavMenubar` from `../shared` |
|
||||||
|
| Section sidebar | `SidebarAccordionMenu` from `../shared`, fed by `useActiveMenuSection` |
|
||||||
|
| Mobile menu (sheet) | `SidebarAccordionMenu` from `../shared` |
|
||||||
|
| Toolbar and breadcrumbs | `Toolbar` / `ToolbarHeading` from `../shared` |
|
||||||
|
| Footer | `AppFooter` from `../shared` |
|
||||||
|
| Account menu | `UserAvatarMenu` from `../shared` |
|
||||||
|
|
||||||
|
Layout-specific: `Header`, `HeaderLogo`, `Navbar`, `Sidebar`.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- A section with no children renders no sidebar, so the content spans the full width.
|
||||||
|
- This layout sets `container` to `fluid`.
|
||||||
|
- Body variables: `--header-height: 54px`, `--sidebar-width: 200px`.
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
import { generalSettings } from '#app/config/general';
|
|
||||||
import { Container } from '#shared/widgets/common/Container';
|
|
||||||
|
|
||||||
export function Footer() {
|
|
||||||
const currentYear = new Date().getFullYear();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<footer className="footer">
|
|
||||||
<Container>
|
|
||||||
<div className="flex flex-col items-center justify-center gap-3 py-5 md:flex-row md:justify-between">
|
|
||||||
<div className="order-2 flex gap-2 text-sm font-normal md:order-1">
|
|
||||||
<span className="text-muted-foreground">{currentYear} ©</span>
|
|
||||||
<a
|
|
||||||
href="https://keenthemes.com"
|
|
||||||
target="_blank"
|
|
||||||
className="text-secondary-foreground hover:text-primary"
|
|
||||||
>
|
|
||||||
Keenthemes Inc.
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<nav className="text-muted-foreground order-1 flex gap-4 text-sm font-normal md:order-2">
|
|
||||||
<a href={generalSettings.docsLink} target="_blank" className="hover:text-primary">
|
|
||||||
Docs
|
|
||||||
</a>
|
|
||||||
<a href={generalSettings.purchaseLink} target="_blank" className="hover:text-primary">
|
|
||||||
Purchase
|
|
||||||
</a>
|
|
||||||
<a href={generalSettings.faqLink} target="_blank" className="hover:text-primary">
|
|
||||||
FAQ
|
|
||||||
</a>
|
|
||||||
<a href="https://devs.keenthemes.com" target="_blank" className="hover:text-primary">
|
|
||||||
Support
|
|
||||||
</a>
|
|
||||||
<a href={generalSettings.licenseLink} target="_blank" className="hover:text-primary">
|
|
||||||
License
|
|
||||||
</a>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
</Container>
|
|
||||||
</footer>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -4,42 +4,43 @@ import { cn } from '@crux-kit/utils';
|
|||||||
import { LayoutNames } from '#app/config/types';
|
import { LayoutNames } from '#app/config/types';
|
||||||
import { useSettings } from '#app/providers/SettingsProvider';
|
import { useSettings } from '#app/providers/SettingsProvider';
|
||||||
import { Container } from '#shared/widgets/common/Container';
|
import { Container } from '#shared/widgets/common/Container';
|
||||||
|
import { UserAvatarMenu } from '../../shared';
|
||||||
import { HeaderLogo } from './HeaderLogo';
|
import { HeaderLogo } from './HeaderLogo';
|
||||||
import { HeaderTopbar } from './HeaderTopbar';
|
|
||||||
|
|
||||||
const Header = () => {
|
export function Header() {
|
||||||
const { settings } = useSettings();
|
const { settings } = useSettings();
|
||||||
const scrollPosition = useScrollPosition();
|
const scrollPosition = useScrollPosition();
|
||||||
const [headerStickyOn, setHeaderStickyOn] = useState(false);
|
const [headerStickyOn, setHeaderStickyOn] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
const { headerSticky, headerStickyOffset } = settings.layouts[LayoutNames.DUAL_ROW_HEADER];
|
||||||
const isSticky =
|
|
||||||
scrollPosition > settings.layouts[LayoutNames.HORIZONTAL_MENU].headerStickyOffset;
|
|
||||||
setHeaderStickyOn(isSticky);
|
|
||||||
}, [scrollPosition, settings]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (headerStickyOn === true) {
|
setHeaderStickyOn(scrollPosition > headerStickyOffset);
|
||||||
|
}, [scrollPosition, headerStickyOffset]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (headerStickyOn) {
|
||||||
document.body.setAttribute('data-sticky-header', 'on');
|
document.body.setAttribute('data-sticky-header', 'on');
|
||||||
} else {
|
} else {
|
||||||
document.body.removeAttribute('data-sticky-header');
|
document.body.removeAttribute('data-sticky-header');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return () => document.body.removeAttribute('data-sticky-header');
|
||||||
}, [headerStickyOn]);
|
}, [headerStickyOn]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header
|
<header
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex h-(--header-height) shrink-0 items-center bg-(--header-bg) transition-[height] dark:bg-(--header-bg-dark)',
|
'flex h-(--header-height) shrink-0 items-center transition-[height]',
|
||||||
headerStickyOn &&
|
headerSticky &&
|
||||||
'fixed start-0 end-0 top-0 z-10 bg-white/70 shadow-xs backdrop-blur-md transition-[height]'
|
headerStickyOn &&
|
||||||
|
'bg-background/70 fixed start-0 end-0 top-0 z-10 shadow-xs backdrop-blur-md transition-[height]'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Container width="fluid" className="flex flex-wrap items-center justify-between lg:gap-4">
|
<Container className="flex items-center justify-between gap-4">
|
||||||
<HeaderLogo />
|
<HeaderLogo />
|
||||||
<HeaderTopbar />
|
<UserAvatarMenu />
|
||||||
</Container>
|
</Container>
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|
||||||
export { Header };
|
|
||||||
|
|||||||
@@ -1,108 +1,25 @@
|
|||||||
import { ChevronDown, Menu, Settings, Shield, UserCircle, Users, type LucideIcon } from 'lucide-react';
|
import { Menu } from 'lucide-react';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Link, useLocation } from 'react-router-dom';
|
import { useLocation } from 'react-router-dom';
|
||||||
import { useIsMobile } from '@crux-kit/hooks';
|
import { useIsMobile } from '@crux-kit/hooks';
|
||||||
import { cn } from '@crux-kit/utils';
|
|
||||||
import { toAbsoluteUrl } from '#shared/lib/helpers';
|
|
||||||
import { Button } from '#shared/ui/Button';
|
import { Button } from '#shared/ui/Button';
|
||||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '#shared/ui/DropdownMenu';
|
|
||||||
import { Sheet, SheetBody, SheetContent, SheetHeader, SheetTrigger } from '#shared/ui/Sheet';
|
import { Sheet, SheetBody, SheetContent, SheetHeader, SheetTrigger } from '#shared/ui/Sheet';
|
||||||
import { SidebarMenuDashboard } from './SidebarMenuDashboard';
|
import { AppLogo, AppWordmark, SidebarAccordionMenu } from '../../shared';
|
||||||
import { SidebarMenuDefault } from './SidebarMenuDefault';
|
|
||||||
|
|
||||||
interface HeaderLogoTeam {
|
|
||||||
title: string;
|
|
||||||
icon?: LucideIcon;
|
|
||||||
urlPartial: string;
|
|
||||||
path: string;
|
|
||||||
}
|
|
||||||
type HeaderLogoTeams = Array<HeaderLogoTeam>;
|
|
||||||
|
|
||||||
interface HeaderLogoItem {
|
|
||||||
title: string;
|
|
||||||
icon?: LucideIcon;
|
|
||||||
}
|
|
||||||
type HeaderLogoItems = Array<HeaderLogoItem>;
|
|
||||||
|
|
||||||
interface HeaderLogoStaging {
|
|
||||||
title: string;
|
|
||||||
icon?: LucideIcon;
|
|
||||||
}
|
|
||||||
type HeaderLogoStagings = Array<HeaderLogoStaging>;
|
|
||||||
|
|
||||||
export function HeaderLogo() {
|
export function HeaderLogo() {
|
||||||
const { pathname } = useLocation();
|
const { pathname } = useLocation();
|
||||||
const [isSheetOpen, setIsSheetOpen] = useState(false);
|
const [isSheetOpen, setIsSheetOpen] = useState(false);
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
const teams: HeaderLogoTeams = [
|
|
||||||
{
|
|
||||||
title: 'MetronicTeam',
|
|
||||||
icon: UserCircle,
|
|
||||||
urlPartial: '/public-profile/',
|
|
||||||
path: '/public-profile/profiles/default'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'KeenTeam',
|
|
||||||
icon: Settings,
|
|
||||||
urlPartial: '/account/',
|
|
||||||
path: '/'
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
const items: HeaderLogoItems = [
|
|
||||||
{
|
|
||||||
title: 'Campaign',
|
|
||||||
icon: UserCircle
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Fall Winter 2024 ',
|
|
||||||
icon: Settings
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Barberry Autmn 24',
|
|
||||||
icon: Users
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'PF24 Advertising',
|
|
||||||
icon: Shield
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
const stagings: HeaderLogoStagings = [
|
|
||||||
{
|
|
||||||
title: 'Staging',
|
|
||||||
icon: UserCircle
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Account',
|
|
||||||
icon: Settings
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
const [selectedTeam, setSelectedTeam] = useState(teams[0]);
|
|
||||||
const [selectedItem, setSelectedItem] = useState(items[0]);
|
|
||||||
const [selectedStaging, setSelectedStaging] = useState(stagings[0]);
|
|
||||||
|
|
||||||
// Close sheet when route changes
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setIsSheetOpen(false);
|
setIsSheetOpen(false);
|
||||||
}, [pathname]);
|
}, [pathname]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-1.5 lg:gap-5">
|
<div className="flex items-center gap-1.5 lg:gap-5">
|
||||||
<Link to="/">
|
<AppLogo className="size-8.5" />
|
||||||
<img
|
|
||||||
src={toAbsoluteUrl('/media/app/mini-logo-circle.svg')}
|
<AppWordmark className="text-accent-foreground hidden text-base md:block" />
|
||||||
className="min-h-8.5 dark:hidden"
|
|
||||||
alt="logo"
|
|
||||||
/>
|
|
||||||
<img
|
|
||||||
src={toAbsoluteUrl('/media/app/mini-logo-circle-dark.svg')}
|
|
||||||
className="hidden min-h-8.5 dark:inline-block"
|
|
||||||
alt="logo"
|
|
||||||
/>
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
{isMobile && (
|
{isMobile && (
|
||||||
<Sheet open={isSheetOpen} onOpenChange={setIsSheetOpen}>
|
<Sheet open={isSheetOpen} onOpenChange={setIsSheetOpen}>
|
||||||
@@ -113,89 +30,12 @@ export function HeaderLogo() {
|
|||||||
</SheetTrigger>
|
</SheetTrigger>
|
||||||
<SheetContent className="w-62.5 gap-0 p-0" side="left" close={false}>
|
<SheetContent className="w-62.5 gap-0 p-0" side="left" close={false}>
|
||||||
<SheetHeader className="space-y-0 p-0" />
|
<SheetHeader className="space-y-0 p-0" />
|
||||||
<SheetBody className="overflow-y-auto p-3">
|
<SheetBody className="overflow-y-auto py-5">
|
||||||
{pathname === '/' ? <SidebarMenuDashboard /> : <SidebarMenuDefault />}
|
<SidebarAccordionMenu />
|
||||||
</SheetBody>
|
</SheetBody>
|
||||||
</SheetContent>
|
</SheetContent>
|
||||||
</Sheet>
|
</Sheet>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!isMobile && (
|
|
||||||
<div className="items-stretch gap-3 lg:flex">
|
|
||||||
{/* Teams Dropdown */}
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger className="text-secondary-foreground flex cursor-pointer items-center gap-2 text-sm font-medium">
|
|
||||||
{selectedTeam.title}
|
|
||||||
<ChevronDown className="text-muted-foreground size-3.5" />
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent sideOffset={15} side="bottom" align="start">
|
|
||||||
{teams.map((team, index) => (
|
|
||||||
<DropdownMenuItem
|
|
||||||
key={index}
|
|
||||||
asChild
|
|
||||||
className={cn(team === selectedTeam && 'bg-accent')}
|
|
||||||
onSelect={() => setSelectedTeam(team)}
|
|
||||||
>
|
|
||||||
<Link to={team.path}>
|
|
||||||
{team.icon && <team.icon />}
|
|
||||||
{team.title}
|
|
||||||
</Link>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
))}
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
|
|
||||||
<span className="text-muted-foreground px-2.5 text-sm font-medium md:inline">/</span>
|
|
||||||
|
|
||||||
{/* Items Dropdown */}
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger className="text-secondary-foreground flex cursor-pointer items-center gap-2 text-sm font-medium">
|
|
||||||
{selectedItem.title}
|
|
||||||
<ChevronDown className="text-muted-foreground size-3.5" />
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent sideOffset={15} side="bottom" align="start">
|
|
||||||
{items.map((item, index) => (
|
|
||||||
<DropdownMenuItem
|
|
||||||
key={index}
|
|
||||||
asChild
|
|
||||||
className={cn(item === selectedItem && 'bg-accent')}
|
|
||||||
onSelect={() => setSelectedItem(item)}
|
|
||||||
>
|
|
||||||
<Link to="/">
|
|
||||||
{item.icon && <item.icon />}
|
|
||||||
{item.title}
|
|
||||||
</Link>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
))}
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
|
|
||||||
<span className="text-muted-foreground px-2.5 text-sm font-medium">/</span>
|
|
||||||
|
|
||||||
{/* Staging Dropdown */}
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger className="text-secondary-foreground flex cursor-pointer items-center gap-2 text-sm font-medium">
|
|
||||||
{selectedStaging.title}
|
|
||||||
<ChevronDown className="text-muted-foreground size-3.5" />
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent sideOffset={15} side="bottom" align="start">
|
|
||||||
{stagings.map((staging, index) => (
|
|
||||||
<DropdownMenuItem
|
|
||||||
key={index}
|
|
||||||
asChild
|
|
||||||
className={cn(staging === selectedStaging && 'bg-accent')}
|
|
||||||
onSelect={() => setSelectedStaging(staging)}
|
|
||||||
>
|
|
||||||
<Link to="/">
|
|
||||||
{staging.icon && <staging.icon />}
|
|
||||||
{staging.title}
|
|
||||||
</Link>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
))}
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,83 +0,0 @@
|
|||||||
import { Users } from 'lucide-react';
|
|
||||||
import { Link } from 'react-router-dom';
|
|
||||||
import { toAbsoluteUrl } from '#shared/lib/helpers';
|
|
||||||
import { Button } from '#shared/ui/Button';
|
|
||||||
// import { SearchDialog } from '#shared/partials/dialogs/search/search-dialog';
|
|
||||||
// import { AppsDropdownMenu } from '#shared/partials/topbar/apps-dropdown-menu';
|
|
||||||
// import { ChatSheet } from '#shared/partials/topbar/chat-sheet';
|
|
||||||
// import { NotificationsSheet } from '#shared/partials/topbar/notifications-sheet';
|
|
||||||
import { UserDropdownMenu } from '#shared/widgets/UserDropdownMenu';
|
|
||||||
|
|
||||||
export function HeaderTopbar() {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-2 lg:gap-3.5">
|
|
||||||
<Button variant="outline" asChild>
|
|
||||||
<Link to="/account/members/team-members">
|
|
||||||
<Users />
|
|
||||||
Add <span className="hidden md:inline">Teammate</span>
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
{/* <SearchDialog
|
|
||||||
trigger={
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
mode="icon"
|
|
||||||
shape="circle"
|
|
||||||
className="hover:bg-transparent hover:[&_svg]:text-primary"
|
|
||||||
>
|
|
||||||
<Search className="size-4.5!" />
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/> */}
|
|
||||||
{/* <ChatSheet
|
|
||||||
trigger={
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
mode="icon"
|
|
||||||
shape="circle"
|
|
||||||
className="hover:bg-transparent hover:[&_svg]:text-primary"
|
|
||||||
>
|
|
||||||
<MessageCircleMore className="size-4.5!" />
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/> */}
|
|
||||||
{/* <AppsDropdownMenu
|
|
||||||
trigger={
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
mode="icon"
|
|
||||||
shape="circle"
|
|
||||||
className="hover:bg-transparent hover:[&_svg]:text-primary"
|
|
||||||
>
|
|
||||||
<LayoutGrid className="size-4.5!" />
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/> */}
|
|
||||||
{/* <NotificationsSheet
|
|
||||||
trigger={
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
mode="icon"
|
|
||||||
shape="circle"
|
|
||||||
className="hover:bg-transparent hover:[&_svg]:text-primary"
|
|
||||||
>
|
|
||||||
<MessageSquareDot className="size-4.5!" />
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/> */}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<UserDropdownMenu
|
|
||||||
trigger={
|
|
||||||
<img
|
|
||||||
className="border-mono/25 size-9 shrink-0 cursor-pointer rounded-full border-2"
|
|
||||||
src={toAbsoluteUrl('/media/avatars/300-2.png')}
|
|
||||||
alt="User Avatar"
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,11 +1,19 @@
|
|||||||
|
import { useIsMobile } from '@crux-kit/hooks';
|
||||||
import { Container } from '#shared/widgets/common/Container';
|
import { Container } from '#shared/widgets/common/Container';
|
||||||
import { NavbarMenu } from './NavbarMenu';
|
import { NavMenubar } from '../../shared';
|
||||||
|
|
||||||
|
/** The top-level sections, on the header's second row. */
|
||||||
const Navbar = () => {
|
const Navbar = () => {
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
|
if (isMobile) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-bg-background border-border mb-5 border-y lg:mb-8">
|
<div className="bg-bg-background border-border mb-5 border-y lg:mb-8">
|
||||||
<Container className="flex flex-wrap items-center justify-between gap-2">
|
<Container className="flex flex-wrap items-center justify-between gap-2">
|
||||||
<NavbarMenu />
|
<NavMenubar />
|
||||||
</Container>
|
</Container>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,74 +0,0 @@
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
|
||||||
import { Link, useLocation } from 'react-router-dom';
|
|
||||||
import { cn } from '@crux-kit/utils';
|
|
||||||
|
|
||||||
export function NavbarMenu() {
|
|
||||||
const { pathname } = useLocation();
|
|
||||||
|
|
||||||
const items = useMemo(
|
|
||||||
() => [
|
|
||||||
{
|
|
||||||
title: 'Dashboards',
|
|
||||||
path: '/',
|
|
||||||
partial: '/'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Public Profiles',
|
|
||||||
path: '/public-profile/profiles/default',
|
|
||||||
partial: '/public-profile'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Account Settings',
|
|
||||||
path: '/account/home/get-started',
|
|
||||||
partial: '/account'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Network',
|
|
||||||
path: '/network/get-started',
|
|
||||||
partial: '/network'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Store - Client',
|
|
||||||
path: '/store-client/home',
|
|
||||||
partial: '/store-client'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Authentication',
|
|
||||||
path: '/authentication/get-started',
|
|
||||||
partial: '/authentication'
|
|
||||||
}
|
|
||||||
],
|
|
||||||
[] // Empty dependency array since the data is static
|
|
||||||
);
|
|
||||||
|
|
||||||
const [selectedItem, setSelectedItem] = useState(items[0]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
items.forEach((item) => {
|
|
||||||
if (item.partial !== '' && pathname.startsWith(item.partial)) {
|
|
||||||
setSelectedItem(item);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}, [items, pathname]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="grid">
|
|
||||||
<div className="kt-scrollable-x-auto">
|
|
||||||
<div className="flex h-12 items-stretch gap-5 lg:gap-7.5">
|
|
||||||
{items.map((item, index) => (
|
|
||||||
<Link
|
|
||||||
key={index}
|
|
||||||
to={item.path}
|
|
||||||
className={cn(
|
|
||||||
'text-secondary-foreground flex items-center text-sm font-medium text-nowrap',
|
|
||||||
item.path === selectedItem.path && 'border-mono text-mono border-b'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{item.title}
|
|
||||||
</Link>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,13 +1,30 @@
|
|||||||
import { useLocation } from 'react-router-dom';
|
import { SidebarAccordionMenu, useActiveMenuSection } from '../../shared';
|
||||||
import { SidebarMenuDashboard } from './SidebarMenuDashboard';
|
|
||||||
import { SidebarMenuDefault } from './SidebarMenuDefault';
|
|
||||||
|
|
||||||
|
const CLASS_NAMES = {
|
||||||
|
root: 'space-y-1 py-5',
|
||||||
|
group: 'gap-px',
|
||||||
|
label: 'uppercase text-xs font-medium text-muted-foreground/80 pt-6 mb-2 pb-0',
|
||||||
|
separator: '',
|
||||||
|
item: 'h-8 hover:bg-background border-accent text-accent-foreground hover:text-primary data-[selected=true]:text-primary data-[selected=true]:bg-background data-[selected=true]:font-medium',
|
||||||
|
sub: '',
|
||||||
|
subTrigger:
|
||||||
|
'h-8 hover:bg-transparent text-accent-foreground hover:text-primary data-[selected=true]:text-primary data-[selected=true]:bg-transparent data-[selected=true]:font-medium',
|
||||||
|
subContent: 'ps-3 py-0',
|
||||||
|
indicator: ''
|
||||||
|
};
|
||||||
|
|
||||||
|
/** The children of the section selected on the navbar. */
|
||||||
export function Sidebar() {
|
export function Sidebar() {
|
||||||
const { pathname } = useLocation();
|
const section = useActiveMenuSection();
|
||||||
|
const items = section?.children;
|
||||||
|
|
||||||
|
if (!items || items.length === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-background flex w-(--sidebar-width) shrink-0 items-stretch px-2">
|
<div className="bg-background flex w-(--sidebar-width) shrink-0 items-stretch px-2">
|
||||||
{pathname === '/' ? <SidebarMenuDashboard /> : <SidebarMenuDefault />}
|
<SidebarAccordionMenu classNames={CLASS_NAMES} items={items} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,171 +0,0 @@
|
|||||||
import { Badge, ChevronDown, FileText, LucideIcon, Settings, SquareCode, UserCircle } from 'lucide-react';
|
|
||||||
import { useCallback, useMemo } from 'react';
|
|
||||||
import { Link, useLocation } from 'react-router-dom';
|
|
||||||
import { AccordionMenu, AccordionMenuClassNames, AccordionMenuGroup, AccordionMenuItem, AccordionMenuLabel } from '#shared/ui/AccordionMenu';
|
|
||||||
import { Button } from '#shared/ui/Button';
|
|
||||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '#shared/ui/DropdownMenu';
|
|
||||||
|
|
||||||
interface DropdownItem {
|
|
||||||
title: string;
|
|
||||||
path: string;
|
|
||||||
icon: LucideIcon;
|
|
||||||
active?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MenuItem {
|
|
||||||
title: string;
|
|
||||||
path?: string;
|
|
||||||
active?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface MenuLabel {
|
|
||||||
label: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
type MenuNode = MenuItem | MenuLabel;
|
|
||||||
|
|
||||||
export function SidebarMenuDashboard() {
|
|
||||||
const { pathname } = useLocation();
|
|
||||||
|
|
||||||
const dropdownItems = useMemo<DropdownItem[]>(
|
|
||||||
() => [
|
|
||||||
{
|
|
||||||
title: 'Client API',
|
|
||||||
path: '/account/home/user-profile',
|
|
||||||
icon: SquareCode,
|
|
||||||
active: true
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Profile',
|
|
||||||
path: '/public-profile/profiles/company',
|
|
||||||
icon: UserCircle
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'My Account',
|
|
||||||
path: '/account/integrations',
|
|
||||||
icon: Settings
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Projects',
|
|
||||||
path: '/public-profile/projects/3-columns',
|
|
||||||
icon: FileText
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Personal info',
|
|
||||||
path: '/public-profile/profiles/creator',
|
|
||||||
icon: Badge
|
|
||||||
}
|
|
||||||
],
|
|
||||||
[]
|
|
||||||
);
|
|
||||||
|
|
||||||
const currentDropdownItem = dropdownItems[0];
|
|
||||||
|
|
||||||
const menuItems = useMemo<MenuNode[]>(
|
|
||||||
() => [
|
|
||||||
{ label: 'Configuration' },
|
|
||||||
{ title: 'API Setup', path: '/account/api-keys' },
|
|
||||||
{ title: 'Team Settings', path: '/account/home/settings-sidebar' },
|
|
||||||
{ title: 'Authentication', path: '/authentication/classic/sign-in' },
|
|
||||||
{ title: 'Endpoints Configs', path: '/account/appearance' },
|
|
||||||
{ title: 'Rate Limiting', path: '/public-profile/network' },
|
|
||||||
{ label: 'Security' },
|
|
||||||
{ title: 'Data Encryption', path: '/account/billing/enterprise' },
|
|
||||||
{ title: 'Text', path: '/account/security/overview' },
|
|
||||||
{ title: 'Access Control', path: '/account/security/privacy-settings' },
|
|
||||||
{ label: 'Analytics' },
|
|
||||||
{
|
|
||||||
title: 'Incident Response',
|
|
||||||
path: '/account/security/current-sessions'
|
|
||||||
},
|
|
||||||
{ title: 'Fetching Data', path: '/account/members/team-info' },
|
|
||||||
{ title: 'Custom Reports', path: '/account/home/user-profile' },
|
|
||||||
{
|
|
||||||
title: 'Real Time Analytics',
|
|
||||||
path: '/account/home/settings-enterprise'
|
|
||||||
},
|
|
||||||
{ title: 'Exporting Data', path: '/account/members/import-members' },
|
|
||||||
{ title: 'Dashboard Integration', path: '/account/members/team-info' }
|
|
||||||
],
|
|
||||||
[] // Empty dependency array since the data is static
|
|
||||||
);
|
|
||||||
|
|
||||||
const classNames: AccordionMenuClassNames = {
|
|
||||||
root: 'space-y-1',
|
|
||||||
label: 'uppercase text-xs font-medium text-muted-foreground/80 pt-6 mb-2 pb-0',
|
|
||||||
item: 'h-8 hover:bg-background border-accent text-accent-foreground hover:text-primary data-[selected=true]:text-primary data-[selected=true]:bg-background data-[selected=true]:font-medium'
|
|
||||||
};
|
|
||||||
|
|
||||||
const matchPath = useCallback(
|
|
||||||
(path: string): boolean => path === pathname || (path.length > 1 && pathname.startsWith(path)),
|
|
||||||
[pathname]
|
|
||||||
);
|
|
||||||
|
|
||||||
const buildDropdown = () => {
|
|
||||||
return (
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
mode="input"
|
|
||||||
className="w-full justify-between focus-visible:ring-0 focus-visible:ring-offset-0"
|
|
||||||
>
|
|
||||||
<span className="flex items-center gap-2">
|
|
||||||
<currentDropdownItem.icon />
|
|
||||||
{currentDropdownItem.title}
|
|
||||||
</span>
|
|
||||||
<ChevronDown className="size-3.5" />
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent className="w-(--radix-dropdown-menu-trigger-width)">
|
|
||||||
{dropdownItems.map((item, index) => (
|
|
||||||
<DropdownMenuItem key={index} asChild>
|
|
||||||
<Link to={item.path}>
|
|
||||||
<item.icon />
|
|
||||||
<span>{item.title}</span>
|
|
||||||
</Link>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
))}
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const memoizedMenu = useMemo(
|
|
||||||
() => (
|
|
||||||
<AccordionMenuGroup>
|
|
||||||
{menuItems.map((item, index) =>
|
|
||||||
'label' in item ? (
|
|
||||||
<AccordionMenuLabel key={index}>{item.label}</AccordionMenuLabel>
|
|
||||||
) : (
|
|
||||||
<AccordionMenuItem key={index} value={item.path || `item-${index}`} className="text-sm">
|
|
||||||
<Link to={item.path || '#'}>{item.title}</Link>
|
|
||||||
</AccordionMenuItem>
|
|
||||||
)
|
|
||||||
)}
|
|
||||||
</AccordionMenuGroup>
|
|
||||||
),
|
|
||||||
[menuItems]
|
|
||||||
);
|
|
||||||
|
|
||||||
const buildMenu = () => {
|
|
||||||
return (
|
|
||||||
<AccordionMenu
|
|
||||||
selectedValue={'/account/home/settings-sidebar'}
|
|
||||||
matchPath={matchPath}
|
|
||||||
type="single"
|
|
||||||
collapsible
|
|
||||||
classNames={classNames}
|
|
||||||
>
|
|
||||||
{memoizedMenu}
|
|
||||||
</AccordionMenu>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="w-full space-y-1">
|
|
||||||
{buildDropdown()}
|
|
||||||
{buildMenu()}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,181 +0,0 @@
|
|||||||
import { JSX, useMemo } from 'react';
|
|
||||||
import { Link, useLocation } from 'react-router-dom';
|
|
||||||
import { cn } from '@crux-kit/utils';
|
|
||||||
import { MenuConfig, MenuItem } from '#app/config/types';
|
|
||||||
import { useMenuConfig } from '#app/menu';
|
|
||||||
import { useMenu } from '#shared/hooks/useMenu';
|
|
||||||
import { createMenuPathMatcher } from '#shared/lib/menuPath';
|
|
||||||
import { AccordionMenu, AccordionMenuClassNames, AccordionMenuGroup, AccordionMenuItem, AccordionMenuLabel, AccordionMenuSub, AccordionMenuSubContent, AccordionMenuSubTrigger } from '#shared/ui/AccordionMenu';
|
|
||||||
import { Badge } from '#shared/ui/Badge';
|
|
||||||
|
|
||||||
export function SidebarMenuDefault() {
|
|
||||||
const { pathname } = useLocation();
|
|
||||||
const menuConfig = useMenuConfig();
|
|
||||||
|
|
||||||
// Memoize matchPath to prevent unnecessary re-renders
|
|
||||||
const matchPath = useMemo(
|
|
||||||
() => createMenuPathMatcher(menuConfig, pathname),
|
|
||||||
[menuConfig, pathname]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Global classNames for consistent styling
|
|
||||||
const classNames: AccordionMenuClassNames = {
|
|
||||||
root: 'lg:ps-1 space-y-1',
|
|
||||||
group: 'gap-px',
|
|
||||||
label: 'uppercase text-xs font-medium text-muted-foreground/70 pt-2.25 pb-px',
|
|
||||||
separator: '',
|
|
||||||
item: 'border border-transparent h-9 hover:bg-background text-accent-foreground hover:text-primary data-[selected=true]:text-primary data-[selected=true]:bg-background data-[selected=true]:font-medium data-[selected=true]:border-border',
|
|
||||||
sub: '',
|
|
||||||
subTrigger:
|
|
||||||
'h-8 hover:bg-transparent text-accent-foreground hover:text-primary data-[selected=true]:text-primary data-[selected=true]:bg-transparent data-[selected=true]:font-medium',
|
|
||||||
subContent: 'ps-3 py-0',
|
|
||||||
indicator: ''
|
|
||||||
};
|
|
||||||
|
|
||||||
const buildMenu = (items: MenuConfig): JSX.Element[] => {
|
|
||||||
return items.map((item: MenuItem, index: number) => {
|
|
||||||
if (item.heading) {
|
|
||||||
return buildMenuHeading(item, index);
|
|
||||||
} else if (item.disabled) {
|
|
||||||
return buildMenuItemRootDisabled(item, index);
|
|
||||||
} else {
|
|
||||||
return buildMenuItemRoot(item, index);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const buildMenuItemRoot = (item: MenuItem, index: number): JSX.Element => {
|
|
||||||
if (item.children) {
|
|
||||||
return (
|
|
||||||
<AccordionMenuSub key={index} value={item.path || `root-${index}`}>
|
|
||||||
<AccordionMenuSubTrigger className="text-sm font-medium">
|
|
||||||
{item.icon && <item.icon data-slot="accordion-menu-icon" />}
|
|
||||||
<span data-slot="accordion-menu-title">{item.title}</span>
|
|
||||||
</AccordionMenuSubTrigger>
|
|
||||||
<AccordionMenuSubContent
|
|
||||||
type="single"
|
|
||||||
collapsible
|
|
||||||
parentValue={item.path || `root-${index}`}
|
|
||||||
>
|
|
||||||
<AccordionMenuGroup>{buildMenuItemChildren(item.children, 1)}</AccordionMenuGroup>
|
|
||||||
</AccordionMenuSubContent>
|
|
||||||
</AccordionMenuSub>
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
return (
|
|
||||||
<AccordionMenuItem key={index} value={item.path || ''} className="text-sm font-medium">
|
|
||||||
<Link to={item.path || '#'} className="flex grow items-center justify-between gap-2">
|
|
||||||
{item.icon && <item.icon data-slot="accordion-menu-icon" />}
|
|
||||||
<span data-slot="accordion-menu-title">{item.title}</span>
|
|
||||||
</Link>
|
|
||||||
</AccordionMenuItem>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const buildMenuItemRootDisabled = (item: MenuItem, index: number): JSX.Element => {
|
|
||||||
return (
|
|
||||||
<AccordionMenuItem key={index} value={`disabled-${index}`} className="text-sm font-medium">
|
|
||||||
{item.icon && <item.icon data-slot="accordion-menu-icon" />}
|
|
||||||
<span data-slot="accordion-menu-title">{item.title}</span>
|
|
||||||
{item.disabled && (
|
|
||||||
<Badge variant="secondary" size="sm" className="ms-auto -me-2.5">
|
|
||||||
Soon
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</AccordionMenuItem>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const buildMenuItemChildren = (items: MenuConfig, level: number = 0): JSX.Element[] => {
|
|
||||||
return items.map((item: MenuItem, index: number) => {
|
|
||||||
if (item.disabled) {
|
|
||||||
return buildMenuItemChildDisabled(item, index, level);
|
|
||||||
} else {
|
|
||||||
return buildMenuItemChild(item, index, level);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const buildMenuItemChild = (item: MenuItem, index: number, level: number = 0): JSX.Element => {
|
|
||||||
if (item.children) {
|
|
||||||
return (
|
|
||||||
<AccordionMenuSub key={index} value={item.path || `child-${level}-${index}`}>
|
|
||||||
<AccordionMenuSubTrigger className="text-[13px]">
|
|
||||||
{item.collapse ? (
|
|
||||||
<span className="text-muted-foreground">
|
|
||||||
<span className="hidden [[data-state=open]>span>&]:inline">
|
|
||||||
{item.collapseTitle}
|
|
||||||
</span>
|
|
||||||
<span className="inline [[data-state=open]>span>&]:hidden">{item.expandTitle}</span>
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
item.title
|
|
||||||
)}
|
|
||||||
</AccordionMenuSubTrigger>
|
|
||||||
<AccordionMenuSubContent
|
|
||||||
type="single"
|
|
||||||
collapsible
|
|
||||||
parentValue={item.path || `child-${level}-${index}`}
|
|
||||||
className={cn(
|
|
||||||
'ps-4',
|
|
||||||
!item.collapse && 'relative',
|
|
||||||
!item.collapse && (level > 0 ? '' : '')
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<AccordionMenuGroup>
|
|
||||||
{buildMenuItemChildren(item.children, item.collapse ? level : level + 1)}
|
|
||||||
</AccordionMenuGroup>
|
|
||||||
</AccordionMenuSubContent>
|
|
||||||
</AccordionMenuSub>
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
return (
|
|
||||||
<AccordionMenuItem key={index} value={item.path || ''} className="text-[13px]">
|
|
||||||
<Link to={item.path || '#'}>{item.title}</Link>
|
|
||||||
</AccordionMenuItem>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const buildMenuItemChildDisabled = (
|
|
||||||
item: MenuItem,
|
|
||||||
index: number,
|
|
||||||
level: number = 0
|
|
||||||
): JSX.Element => {
|
|
||||||
return (
|
|
||||||
<AccordionMenuItem
|
|
||||||
key={index}
|
|
||||||
value={`disabled-child-${level}-${index}`}
|
|
||||||
className="text-[13px]"
|
|
||||||
>
|
|
||||||
<span data-slot="accordion-menu-title">{item.title}</span>
|
|
||||||
{item.disabled && (
|
|
||||||
<Badge variant="secondary" size="sm" className="ms-auto -me-2.5">
|
|
||||||
Soon
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</AccordionMenuItem>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const buildMenuHeading = (item: MenuItem, index: number): JSX.Element => {
|
|
||||||
return <AccordionMenuLabel key={index}>{item.heading}</AccordionMenuLabel>;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Memoize the menu to prevent unnecessary re-renders
|
|
||||||
const { getChildren } = useMenu(pathname);
|
|
||||||
const menuItems = getChildren(menuConfig, 0);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AccordionMenu
|
|
||||||
selectedValue={pathname}
|
|
||||||
matchPath={matchPath}
|
|
||||||
type="single"
|
|
||||||
collapsible
|
|
||||||
classNames={classNames}
|
|
||||||
>
|
|
||||||
{buildMenu(menuItems || [])}
|
|
||||||
</AccordionMenu>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,83 +0,0 @@
|
|||||||
import { Fragment, ReactNode } from 'react';
|
|
||||||
import { Link, useLocation } from 'react-router-dom';
|
|
||||||
import { cn } from '@crux-kit/utils';
|
|
||||||
import { MenuItem } from '#app/config/types';
|
|
||||||
import { useMenuConfig } from '#app/menu';
|
|
||||||
import { useMenu } from '#shared/hooks/useMenu';
|
|
||||||
import { Container } from '#shared/widgets/common/Container';
|
|
||||||
|
|
||||||
export interface ToolbarHeadingProps {
|
|
||||||
title?: string | ReactNode;
|
|
||||||
description?: string | ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
function Toolbar({ children }: { children?: ReactNode }) {
|
|
||||||
return (
|
|
||||||
<div className="mb-5 lg:mb-7.5">
|
|
||||||
<Container className="flex flex-wrap items-center justify-between gap-5">
|
|
||||||
{children}
|
|
||||||
</Container>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ToolbarActions({ children }: { children?: ReactNode }) {
|
|
||||||
return <div className="flex items-center gap-2.5">{children}</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function ToolbarBreadcrumbs() {
|
|
||||||
const menuConfig = useMenuConfig();
|
|
||||||
const { pathname } = useLocation();
|
|
||||||
const { getBreadcrumb, isActive } = useMenu(pathname);
|
|
||||||
const items: MenuItem[] = getBreadcrumb(menuConfig);
|
|
||||||
|
|
||||||
if (items.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="breadcrumb flex items-center gap-1 text-sm">
|
|
||||||
{items.map((item, index) => {
|
|
||||||
const isLast = index === items.length - 1;
|
|
||||||
const active = item.path ? isActive(item.path) : false;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Fragment key={index}>
|
|
||||||
{item.path ? (
|
|
||||||
<Link
|
|
||||||
to={item.path}
|
|
||||||
className={cn(
|
|
||||||
'flex items-center gap-1',
|
|
||||||
active ? 'text-mono' : 'text-secondary-foreground hover:text-primary'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{item.title}
|
|
||||||
</Link>
|
|
||||||
) : (
|
|
||||||
<span className={cn(isLast ? 'text-mono' : 'text-secondary-foreground')}>
|
|
||||||
{item.title}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{!isLast && <span className="text-muted-foreground">/</span>}
|
|
||||||
</Fragment>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const ToolbarHeading = ({ title = '' }: ToolbarHeadingProps) => {
|
|
||||||
const { pathname } = useLocation();
|
|
||||||
const { getCurrentItem } = useMenu(pathname);
|
|
||||||
const menuConfig = useMenuConfig();
|
|
||||||
const item = getCurrentItem(menuConfig);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col flex-wrap gap-1 md:flex-row md:items-center lg:gap-5">
|
|
||||||
<h1 className="text-mono text-lg font-medium">{title || item?.title}</h1>
|
|
||||||
<ToolbarBreadcrumbs />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export { Toolbar, ToolbarActions, ToolbarBreadcrumbs, ToolbarHeading };
|
|
||||||
@@ -1,44 +1,24 @@
|
|||||||
// DEMO 5
|
// DEMO 5
|
||||||
// https://keenthemes.com/metronic/tailwind/react/demo5
|
// https://keenthemes.com/metronic/tailwind/react/demo5
|
||||||
|
|
||||||
import { addDays, format } from 'date-fns';
|
import { Outlet } from 'react-router';
|
||||||
import { CalendarDays } from 'lucide-react';
|
|
||||||
import { useEffect, useState } from 'react';
|
|
||||||
import { DateRange } from 'react-day-picker';
|
|
||||||
import { Outlet, useLocation } from 'react-router';
|
|
||||||
import { useBodyClass, useIsMobile } from '@crux-kit/hooks';
|
import { useBodyClass, useIsMobile } from '@crux-kit/hooks';
|
||||||
import { LayoutNames } from '#app/config/types';
|
import { LayoutNames } from '#app/config/types';
|
||||||
import { useSettings } from '#app/providers/SettingsProvider';
|
import { AppFooter, Toolbar, ToolbarHeading, useLayoutChrome, useLayoutSettings } from '../shared';
|
||||||
import { Button } from '#shared/ui/Button';
|
|
||||||
import { Calendar } from '#shared/ui/Calendar';
|
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from '#shared/ui/Popover';
|
|
||||||
import { Footer } from './components/Footer';
|
|
||||||
import { Header } from './components/Header';
|
import { Header } from './components/Header';
|
||||||
import { Navbar } from './components/Navbar';
|
import { Navbar } from './components/Navbar';
|
||||||
import { Sidebar } from './components/Sidebar';
|
import { Sidebar } from './components/Sidebar';
|
||||||
import { Toolbar, ToolbarActions, ToolbarHeading } from './components/Toolbar';
|
|
||||||
|
|
||||||
export const DualRowsHeaderLayout = () => {
|
export const DualRowsHeaderLayout = () => {
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
const { pathname } = useLocation();
|
useLayoutSettings(LayoutNames.DUAL_ROW_HEADER);
|
||||||
const { setOption } = useSettings();
|
const { showToolbar, showFooter } = useLayoutChrome();
|
||||||
|
|
||||||
const [date, setDate] = useState<DateRange | undefined>({
|
|
||||||
from: new Date(2025, 0, 20),
|
|
||||||
to: addDays(new Date(2025, 0, 20), 20)
|
|
||||||
});
|
|
||||||
|
|
||||||
// Using the custom hook to set multiple CSS variables and class properties
|
|
||||||
useBodyClass(`
|
useBodyClass(`
|
||||||
[--header-height:54px]
|
[--header-height:54px]
|
||||||
[--sidebar-width:200px]
|
[--sidebar-width:200px]
|
||||||
`);
|
`);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setOption('layout', LayoutNames.DUAL_ROW_HEADER);
|
|
||||||
setOption('container', 'fluid');
|
|
||||||
}, [setOption]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex grow flex-col in-data-[sticky-header=on]:pt-(--header-height)">
|
<div className="flex grow flex-col in-data-[sticky-header=on]:pt-(--header-height)">
|
||||||
@@ -50,44 +30,15 @@ export const DualRowsHeaderLayout = () => {
|
|||||||
{!isMobile && <Sidebar />}
|
{!isMobile && <Sidebar />}
|
||||||
|
|
||||||
<main className="flex grow flex-col">
|
<main className="flex grow flex-col">
|
||||||
{!pathname.includes('/public-profile/') && (
|
{showToolbar && (
|
||||||
<Toolbar>
|
<Toolbar>
|
||||||
<ToolbarHeading />
|
<ToolbarHeading />
|
||||||
<ToolbarActions>
|
|
||||||
<Popover>
|
|
||||||
<PopoverTrigger asChild>
|
|
||||||
<Button id="date" variant="outline" mode="input">
|
|
||||||
<CalendarDays />
|
|
||||||
{date?.from ? (
|
|
||||||
date.to ? (
|
|
||||||
<span>
|
|
||||||
{format(date.from, 'LLL dd, y')} - {format(date.to, 'LLL dd, y')}
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
format(date.from, 'LLL dd, y')
|
|
||||||
)
|
|
||||||
) : (
|
|
||||||
<span>Pick a date range</span>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent className="w-auto p-0" align="end">
|
|
||||||
<Calendar
|
|
||||||
mode="range"
|
|
||||||
defaultMonth={date?.from}
|
|
||||||
selected={date}
|
|
||||||
onSelect={setDate}
|
|
||||||
numberOfMonths={2}
|
|
||||||
/>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
</ToolbarActions>
|
|
||||||
</Toolbar>
|
</Toolbar>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Outlet />
|
<Outlet />
|
||||||
|
|
||||||
<Footer />
|
{showFooter && <AppFooter />}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# Multiple Menus layout
|
||||||
|
|
||||||
|
**Metronic 9 reference:** [Demo 6](https://keenthemes.com/metronic/tailwind/react/demo6)
|
||||||
|
|
||||||
|
A fixed light sidebar holding the whole application menu as an accordion, with the page content in a
|
||||||
|
rounded card beside it. Below `lg` the sidebar collapses into a sheet opened from a compact header.
|
||||||
|
|
||||||
|
## Composition
|
||||||
|
|
||||||
|
| Piece | Source |
|
||||||
|
| ----------------------- | --------------------------------------------- |
|
||||||
|
| Sidebar menu | `SidebarAccordionMenu` from `../shared` |
|
||||||
|
| Toolbar and breadcrumbs | `Toolbar` / `ToolbarHeading` from `../shared` |
|
||||||
|
| Footer | `AppFooter` from `../shared` |
|
||||||
|
| Account menu | `UserAvatarMenu` from `../shared` |
|
||||||
|
|
||||||
|
Layout-specific: `Sidebar`, `SidebarHeader` (logo plus search box), `SidebarFooter`, `Header`
|
||||||
|
(mobile sheet).
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- `RoosterPeek` is a development-only easter egg kept with this layout; it renders nothing in
|
||||||
|
production builds.
|
||||||
|
- Body variables: `--header-height: 60px`, `--sidebar-width: 270px`.
|
||||||
@@ -1,11 +1,10 @@
|
|||||||
import { Menu } from 'lucide-react';
|
import { Menu } from 'lucide-react';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Link, useLocation } from 'react-router-dom';
|
import { useLocation } from 'react-router-dom';
|
||||||
import { useIsMobile } from '@crux-kit/hooks';
|
|
||||||
import { toAbsoluteUrl } from '#shared/lib/helpers';
|
|
||||||
import { Button } from '#shared/ui/Button';
|
import { Button } from '#shared/ui/Button';
|
||||||
import { Sheet, SheetBody, SheetContent, SheetHeader, SheetTrigger } from '#shared/ui/Sheet';
|
import { Sheet, SheetBody, SheetContent, SheetHeader, SheetTrigger } from '#shared/ui/Sheet';
|
||||||
import { Container } from '#shared/widgets/common/Container';
|
import { Container } from '#shared/widgets/common/Container';
|
||||||
|
import { AppLogo } from '../../shared';
|
||||||
import { SidebarFooter } from './SidebarFooter';
|
import { SidebarFooter } from './SidebarFooter';
|
||||||
import { SidebarHeader } from './SidebarHeader';
|
import { SidebarHeader } from './SidebarHeader';
|
||||||
import { SidebarMenu } from './SidebarMenu';
|
import { SidebarMenu } from './SidebarMenu';
|
||||||
@@ -13,7 +12,6 @@ import { SidebarMenu } from './SidebarMenu';
|
|||||||
const Header = () => {
|
const Header = () => {
|
||||||
const { pathname } = useLocation();
|
const { pathname } = useLocation();
|
||||||
const [isSheetOpen, setIsSheetOpen] = useState(false);
|
const [isSheetOpen, setIsSheetOpen] = useState(false);
|
||||||
const isMobile = useIsMobile();
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setIsSheetOpen(false);
|
setIsSheetOpen(false);
|
||||||
@@ -22,36 +20,23 @@ const Header = () => {
|
|||||||
return (
|
return (
|
||||||
<header className="bg-muted fixed start-0 end-0 top-0 z-10 flex h-(--header-height) shrink-0 items-center lg:hidden">
|
<header className="bg-muted fixed start-0 end-0 top-0 z-10 flex h-(--header-height) shrink-0 items-center lg:hidden">
|
||||||
<Container className="flex flex-wrap items-center justify-between gap-3">
|
<Container className="flex flex-wrap items-center justify-between gap-3">
|
||||||
<Link to="/">
|
<AppLogo />
|
||||||
<img
|
|
||||||
src={toAbsoluteUrl('/media/app/logo-light.svg')}
|
|
||||||
className="h-8.5 dark:hidden"
|
|
||||||
alt="Logo"
|
|
||||||
/>
|
|
||||||
<img
|
|
||||||
src={toAbsoluteUrl('/media/app/logo-dark.svg')}
|
|
||||||
className="hidden h-8.5 dark:block"
|
|
||||||
alt="Logo"
|
|
||||||
/>
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
{isMobile && (
|
<Sheet open={isSheetOpen} onOpenChange={setIsSheetOpen}>
|
||||||
<Sheet open={isSheetOpen} onOpenChange={setIsSheetOpen}>
|
<SheetTrigger asChild>
|
||||||
<SheetTrigger asChild>
|
<Button variant="ghost" mode="icon">
|
||||||
<Button variant="ghost" mode="icon">
|
<Menu />
|
||||||
<Menu />
|
</Button>
|
||||||
</Button>
|
</SheetTrigger>
|
||||||
</SheetTrigger>
|
<SheetContent className="w-68.75 gap-0 p-0" side="left" close={false}>
|
||||||
<SheetContent className="w-68.75 gap-0 p-0" side="left" close={false}>
|
<SheetHeader className="space-y-0 p-0" />
|
||||||
<SheetHeader className="space-y-0 p-0" />
|
<SheetBody className="flex grow flex-col p-0">
|
||||||
<SheetBody className="flex grow flex-col p-0">
|
<SidebarHeader />
|
||||||
<SidebarHeader />
|
<SidebarMenu />
|
||||||
<SidebarMenu />
|
<SidebarFooter />
|
||||||
<SidebarFooter />
|
</SheetBody>
|
||||||
</SheetBody>
|
</SheetContent>
|
||||||
</SheetContent>
|
</Sheet>
|
||||||
</Sheet>
|
|
||||||
)}
|
|
||||||
</Container>
|
</Container>
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,24 +1,9 @@
|
|||||||
import useIntlName from '#app/i18n/useIntlName';
|
import { UserAvatarMenu } from '../../shared';
|
||||||
import { useAuth } from '#core/Auth/providers/AuthProvider';
|
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from '#shared/ui/Avatar';
|
|
||||||
import { UserDropdownMenu } from '#shared/widgets/UserDropdownMenu';
|
|
||||||
|
|
||||||
export function SidebarFooter() {
|
export function SidebarFooter() {
|
||||||
const { user } = useAuth();
|
|
||||||
const formatName = useIntlName();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex-center flex h-14 shrink-0 justify-between ps-4 pe-3.5">
|
<div className="flex-center flex h-14 shrink-0 justify-between ps-4 pe-3.5">
|
||||||
{user && (
|
<UserAvatarMenu />
|
||||||
<UserDropdownMenu
|
|
||||||
trigger={
|
|
||||||
<Avatar className="size-9 cursor-pointer">
|
|
||||||
<AvatarImage src={undefined} alt="User avatar" />
|
|
||||||
<AvatarFallback className="text-sm">{formatName(user, 'INITIALS_LF')}</AvatarFallback>
|
|
||||||
</Avatar>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { Search } from 'lucide-react';
|
import { Search } from 'lucide-react';
|
||||||
import { Link } from 'react-router-dom';
|
|
||||||
import { toAbsoluteUrl } from '#shared/lib/helpers';
|
|
||||||
import { Input } from '#shared/ui/Input';
|
import { Input } from '#shared/ui/Input';
|
||||||
|
import { AppLogo } from '../../shared';
|
||||||
|
|
||||||
export function SidebarHeader() {
|
export function SidebarHeader() {
|
||||||
const handleInputChange = () => {};
|
const handleInputChange = () => {};
|
||||||
@@ -9,18 +8,7 @@ export function SidebarHeader() {
|
|||||||
return (
|
return (
|
||||||
<div className="mb-3.5">
|
<div className="mb-3.5">
|
||||||
<div className="flex h-17.5 items-center justify-between gap-2.5 px-3.5">
|
<div className="flex h-17.5 items-center justify-between gap-2.5 px-3.5">
|
||||||
<Link to="/">
|
<AppLogo className="h-10.5" />
|
||||||
<img
|
|
||||||
src={toAbsoluteUrl('/media/app/logo-light.svg')}
|
|
||||||
className="h-10.5 dark:hidden"
|
|
||||||
alt=""
|
|
||||||
/>
|
|
||||||
<img
|
|
||||||
src={toAbsoluteUrl('/media/app/logo-dark.svg')}
|
|
||||||
className="hidden h-10.5 dark:inline-block"
|
|
||||||
alt=""
|
|
||||||
/>
|
|
||||||
</Link>
|
|
||||||
<div className="shrink-0 dark:text-[#c08183]">
|
<div className="shrink-0 dark:text-[#c08183]">
|
||||||
C R U X <sub className="text-[50%]">Goat</sub>
|
C R U X <sub className="text-[50%]">Goat</sub>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { SidebarMenuPrimary } from './SidebarMenuPrimary';
|
import { SidebarAccordionMenu } from '../../shared';
|
||||||
|
|
||||||
export function SidebarMenu() {
|
export function SidebarMenu() {
|
||||||
return (
|
return (
|
||||||
<div className="kt-scrollable-y-auto max-h-[calc(100vh-11.5rem)] grow">
|
<div className="kt-scrollable-y-auto max-h-[calc(100vh-11.5rem)] grow">
|
||||||
<SidebarMenuPrimary />
|
<SidebarAccordionMenu />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,142 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { Fragment, JSX, useMemo } from 'react';
|
|
||||||
import { Link, useLocation } from 'react-router-dom';
|
|
||||||
import { cn } from '@crux-kit/utils';
|
|
||||||
import { MenuConfig, MenuItem } from '#app/config/types';
|
|
||||||
import { useMenuConfig } from '#app/menu';
|
|
||||||
import { useAuth } from '#core/Auth/providers/AuthProvider';
|
|
||||||
import { createMenuPathMatcher } from '#shared/lib/menuPath';
|
|
||||||
import { AccordionMenu, AccordionMenuClassNames, AccordionMenuGroup, AccordionMenuItem, AccordionMenuSub, AccordionMenuSubContent, AccordionMenuSubTrigger } from '#shared/ui/AccordionMenu';
|
|
||||||
import { getPermittedMenuItems } from '../lib';
|
|
||||||
|
|
||||||
export function SidebarMenuPrimary() {
|
|
||||||
const { pathname } = useLocation();
|
|
||||||
const { user } = useAuth();
|
|
||||||
const menuConfig = useMenuConfig();
|
|
||||||
|
|
||||||
const userPermissions = user?.permissions ?? [];
|
|
||||||
|
|
||||||
const matchPath = useMemo(
|
|
||||||
() => createMenuPathMatcher(menuConfig, pathname),
|
|
||||||
[menuConfig, pathname]
|
|
||||||
);
|
|
||||||
|
|
||||||
const classNames: AccordionMenuClassNames = {
|
|
||||||
root: 'space-y-2.5 px-3.5',
|
|
||||||
group: 'gap-px',
|
|
||||||
label: 'uppercase text-xs font-medium text-muted-foreground/70 pt-2.25 pb-px',
|
|
||||||
separator: '',
|
|
||||||
item: 'h-9 hover:bg-transparent border border-transparent text-accent-foreground hover:text-mono data-[selected=true]:text-mono data-[selected=true]:bg-background data-[selected=true]:border-border data-[selected=true]:font-medium',
|
|
||||||
sub: '',
|
|
||||||
subTrigger:
|
|
||||||
'h-9 hover:bg-transparent border border-transparent text-accent-foreground hover:text-mono data-[selected=true]:text-mono data-[selected=true]:bg-background data-[selected=true]:border-border data-[selected=true]:font-medium',
|
|
||||||
subContent: 'py-0',
|
|
||||||
indicator: ''
|
|
||||||
};
|
|
||||||
|
|
||||||
const buildMenu = (items: MenuConfig): JSX.Element[] => {
|
|
||||||
const menuItems = getPermittedMenuItems(items, userPermissions);
|
|
||||||
|
|
||||||
return menuItems.map((item: MenuItem, index: number) => {
|
|
||||||
if (!item.heading && !item.disabled) {
|
|
||||||
return buildMenuItemRoot(item, index);
|
|
||||||
} else {
|
|
||||||
return <></>;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const buildMenuItemRoot = (item: MenuItem, index: number): JSX.Element => {
|
|
||||||
if (item.children) {
|
|
||||||
return (
|
|
||||||
<AccordionMenuSub key={index} value={item.path || `root-${index}`}>
|
|
||||||
<AccordionMenuSubTrigger className="text-sm font-medium">
|
|
||||||
{item.icon && <item.icon data-slot="accordion-menu-icon" />}
|
|
||||||
<span data-slot="accordion-menu-title">{item.title}</span>
|
|
||||||
</AccordionMenuSubTrigger>
|
|
||||||
<AccordionMenuSubContent
|
|
||||||
type="single"
|
|
||||||
collapsible
|
|
||||||
parentValue={item.path || `root-${index}`}
|
|
||||||
className="ps-6"
|
|
||||||
>
|
|
||||||
<AccordionMenuGroup>{buildMenuItemChildren(item.children, 1)}</AccordionMenuGroup>
|
|
||||||
</AccordionMenuSubContent>
|
|
||||||
</AccordionMenuSub>
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
return (
|
|
||||||
<AccordionMenuItem key={index} value={item.path || ''} className="text-sm font-medium">
|
|
||||||
<Link to={item.path || '#'}>
|
|
||||||
{item.icon && <item.icon data-slot="accordion-menu-icon" />}
|
|
||||||
<span data-slot="accordion-menu-title">{item.title}</span>
|
|
||||||
</Link>
|
|
||||||
</AccordionMenuItem>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const buildMenuItemChildren = (items: MenuConfig, level: number = 0): JSX.Element[] => {
|
|
||||||
return items.map((item: MenuItem, index: number) => {
|
|
||||||
if (!item.heading && !item.disabled) {
|
|
||||||
return buildMenuItemChild(item, index, level);
|
|
||||||
} else {
|
|
||||||
return <Fragment key={index}></Fragment>;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const buildMenuItemChild = (item: MenuItem, index: number, level: number = 0): JSX.Element => {
|
|
||||||
if (item.children) {
|
|
||||||
return (
|
|
||||||
<AccordionMenuSub key={index} value={item.path || `child-${level}-${index}`}>
|
|
||||||
<AccordionMenuSubTrigger className="text-[13px]">
|
|
||||||
{item.collapse ? (
|
|
||||||
<span className="text-muted-foreground">
|
|
||||||
<span className="hidden [[data-state=open]>span>&]:inline">
|
|
||||||
{item.collapseTitle}
|
|
||||||
</span>
|
|
||||||
<span className="inline [[data-state=open]>span>&]:hidden">{item.expandTitle}</span>
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
item.title
|
|
||||||
)}
|
|
||||||
</AccordionMenuSubTrigger>
|
|
||||||
<AccordionMenuSubContent
|
|
||||||
type="single"
|
|
||||||
collapsible
|
|
||||||
parentValue={item.path || `child-${level}-${index}`}
|
|
||||||
className={cn(
|
|
||||||
'ps-4',
|
|
||||||
!item.collapse && 'relative',
|
|
||||||
!item.collapse && (level > 0 ? '' : '')
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<AccordionMenuGroup>
|
|
||||||
{buildMenuItemChildren(item.children, item.collapse ? level : level + 1)}
|
|
||||||
</AccordionMenuGroup>
|
|
||||||
</AccordionMenuSubContent>
|
|
||||||
</AccordionMenuSub>
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
return (
|
|
||||||
<AccordionMenuItem key={index} value={item.path || ''} className="text-[13px]">
|
|
||||||
<Link to={item.path || '#'}>{item.title}</Link>
|
|
||||||
</AccordionMenuItem>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AccordionMenu
|
|
||||||
type="single"
|
|
||||||
selectedValue={pathname}
|
|
||||||
matchPath={matchPath}
|
|
||||||
collapsible
|
|
||||||
classNames={classNames}
|
|
||||||
>
|
|
||||||
{buildMenu(menuConfig)}
|
|
||||||
</AccordionMenu>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,20 +1,18 @@
|
|||||||
// DEMO 6
|
// DEMO 6
|
||||||
// https://keenthemes.com/metronic/tailwind/react/demo6
|
// https://keenthemes.com/metronic/tailwind/react/demo6
|
||||||
|
|
||||||
import { Fragment, useEffect, useMemo } from 'react';
|
import { Fragment } from 'react';
|
||||||
import { Outlet, useLocation } from 'react-router-dom';
|
import { Outlet } from 'react-router-dom';
|
||||||
import { useBodyClass, useIsMobile } from '@crux-kit/hooks';
|
import { useBodyClass, useIsMobile } from '@crux-kit/hooks';
|
||||||
import { LayoutNames } from '#app/config/types';
|
import { LayoutNames } from '#app/config/types';
|
||||||
import { useSettings } from '#app/providers/SettingsProvider';
|
import { AppFooter, Toolbar, ToolbarHeading, useLayoutChrome, useLayoutSettings } from '../shared';
|
||||||
import { Footer } from './components/Footer';
|
|
||||||
import { Header } from './components/Header';
|
import { Header } from './components/Header';
|
||||||
import { Sidebar } from './components/Sidebar';
|
import { Sidebar } from './components/Sidebar';
|
||||||
import { Toolbar, ToolbarHeading } from './components/Toolbar';
|
|
||||||
|
|
||||||
export const MultipleMenusLayout = () => {
|
export const MultipleMenusLayout = () => {
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
const { setOption } = useSettings();
|
useLayoutSettings(LayoutNames.MULTIPLE_MENUS);
|
||||||
const { pathname } = useLocation();
|
const { showToolbar, showFooter } = useLayoutChrome();
|
||||||
|
|
||||||
useBodyClass(`
|
useBodyClass(`
|
||||||
[--header-height:60px]
|
[--header-height:60px]
|
||||||
@@ -23,28 +21,6 @@ export const MultipleMenusLayout = () => {
|
|||||||
bg-muted!
|
bg-muted!
|
||||||
`);
|
`);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setOption('layout', LayoutNames.MULTIPLE_MENUS);
|
|
||||||
}, [setOption]);
|
|
||||||
|
|
||||||
const toolbar = useMemo(() => {
|
|
||||||
if (pathname.includes('/devtools')) return null;
|
|
||||||
if (pathname.includes('/access-control')) return null;
|
|
||||||
if (pathname.includes('/file-manager')) return null;
|
|
||||||
if (pathname.includes('/web-studio')) return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Toolbar>
|
|
||||||
<ToolbarHeading />
|
|
||||||
</Toolbar>
|
|
||||||
);
|
|
||||||
}, [pathname]);
|
|
||||||
|
|
||||||
const footer = useMemo(() => {
|
|
||||||
if (pathname.includes('/file-manager')) return null;
|
|
||||||
return <Footer />;
|
|
||||||
}, [pathname]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Fragment>
|
<Fragment>
|
||||||
<div className="flex grow">
|
<div className="flex grow">
|
||||||
@@ -56,11 +32,15 @@ export const MultipleMenusLayout = () => {
|
|||||||
<div className="bg-background border-input m-3.75 mt-0 flex grow flex-col items-stretch overflow-hidden rounded-xl border lg:ms-(--sidebar-width) lg:mt-3.75">
|
<div className="bg-background border-input m-3.75 mt-0 flex grow flex-col items-stretch overflow-hidden rounded-xl border lg:ms-(--sidebar-width) lg:mt-3.75">
|
||||||
<div className="kt-scrollable-y-auto flex grow flex-col pt-5 [--kt-scrollbar-width:auto]">
|
<div className="kt-scrollable-y-auto flex grow flex-col pt-5 [--kt-scrollbar-width:auto]">
|
||||||
<main className="grow" role="content">
|
<main className="grow" role="content">
|
||||||
{toolbar}
|
{showToolbar && (
|
||||||
|
<Toolbar>
|
||||||
|
<ToolbarHeading />
|
||||||
|
</Toolbar>
|
||||||
|
)}
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
{footer}
|
{showFooter && <AppFooter />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,25 +0,0 @@
|
|||||||
import { MenuItem } from '#app/config/types';
|
|
||||||
|
|
||||||
export function getPermittedMenuItems(items: MenuItem[], userPermission: string[]): MenuItem[] {
|
|
||||||
return items
|
|
||||||
.map((item) => {
|
|
||||||
const filteredChildren = item.children
|
|
||||||
? getPermittedMenuItems(item.children, userPermission)
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
const hasPermission = item.permissions
|
|
||||||
? item.permissions.every((permission) => userPermission.includes(permission))
|
|
||||||
: true;
|
|
||||||
|
|
||||||
if (hasPermission || (filteredChildren && filteredChildren.length > 0)) {
|
|
||||||
return {
|
|
||||||
...item,
|
|
||||||
children: filteredChildren
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
})
|
|
||||||
.filter((item) => item != null)
|
|
||||||
.filter((item) => (item.children ? item.children.length > 0 : true));
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
# Advanced Mega Menu layout
|
||||||
|
|
||||||
|
**Metronic 9 reference:** [Demo 7](https://keenthemes.com/metronic/tailwind/react/demo7)
|
||||||
|
|
||||||
|
A header-only layout: the whole application menu lives in a mega menu beside the logo, and the page
|
||||||
|
content runs full width beneath it. The header becomes sticky and shrinks past
|
||||||
|
`layouts.advanced-mega-menu-layout.headerStickyOffset`.
|
||||||
|
|
||||||
|
## Composition
|
||||||
|
|
||||||
|
| Piece | Source |
|
||||||
|
| ----------------------- | --------------------------------------------- |
|
||||||
|
| Mega menu | `MegaNavMenu` from `../shared` |
|
||||||
|
| Mobile menu (sheet) | `SidebarAccordionMenu` from `../shared` |
|
||||||
|
| Toolbar and breadcrumbs | `Toolbar` / `ToolbarHeading` from `../shared` |
|
||||||
|
| Footer | `AppFooter` from `../shared` |
|
||||||
|
| Account menu | `UserAvatarMenu` from `../shared` |
|
||||||
|
|
||||||
|
Layout-specific: `Header`, `HeaderLogo`.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- The mega menu panels show two levels — a section's children as columns, their children as links.
|
||||||
|
Deeper entries stay reachable through the toolbar breadcrumbs and the mobile accordion.
|
||||||
|
- The `data-sticky-header` body attribute is removed on unmount.
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
import { generalSettings } from '#app/config/general';
|
|
||||||
import { Container } from '#shared/widgets/common/Container';
|
|
||||||
|
|
||||||
export function Footer() {
|
|
||||||
const currentYear = new Date().getFullYear();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<footer className="footer">
|
|
||||||
<Container>
|
|
||||||
<div className="flex flex-col items-center justify-center gap-3 py-5 md:flex-row md:justify-between">
|
|
||||||
<div className="order-2 flex gap-2 text-sm font-normal md:order-1">
|
|
||||||
<span className="text-muted-foreground">{currentYear} ©</span>
|
|
||||||
<a
|
|
||||||
href="https://keenthemes.com"
|
|
||||||
target="_blank"
|
|
||||||
className="text-secondary-foreground hover:text-primary"
|
|
||||||
>
|
|
||||||
Keenthemes Inc.
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<nav className="text-muted-foreground order-1 flex gap-4 text-sm font-normal md:order-2">
|
|
||||||
<a href={generalSettings.docsLink} target="_blank" className="hover:text-primary">
|
|
||||||
Docs
|
|
||||||
</a>
|
|
||||||
<a href={generalSettings.purchaseLink} target="_blank" className="hover:text-primary">
|
|
||||||
Purchase
|
|
||||||
</a>
|
|
||||||
<a href={generalSettings.faqLink} target="_blank" className="hover:text-primary">
|
|
||||||
FAQ
|
|
||||||
</a>
|
|
||||||
<a href="https://devs.keenthemes.com" target="_blank" className="hover:text-primary">
|
|
||||||
Support
|
|
||||||
</a>
|
|
||||||
<a href={generalSettings.licenseLink} target="_blank" className="hover:text-primary">
|
|
||||||
License
|
|
||||||
</a>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
</Container>
|
|
||||||
</footer>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -4,39 +4,42 @@ import { cn } from '@crux-kit/utils';
|
|||||||
import { LayoutNames } from '#app/config/types';
|
import { LayoutNames } from '#app/config/types';
|
||||||
import { useSettings } from '#app/providers/SettingsProvider';
|
import { useSettings } from '#app/providers/SettingsProvider';
|
||||||
import { Container } from '#shared/widgets/common/Container';
|
import { Container } from '#shared/widgets/common/Container';
|
||||||
|
import { UserAvatarMenu } from '../../shared';
|
||||||
import { HeaderLogo } from './HeaderLogo';
|
import { HeaderLogo } from './HeaderLogo';
|
||||||
import { HeaderTopbar } from './HeaderTopbar';
|
|
||||||
|
|
||||||
const Header = () => {
|
const Header = () => {
|
||||||
const { settings } = useSettings();
|
const { settings } = useSettings();
|
||||||
const scrollPosition = useScrollPosition();
|
const scrollPosition = useScrollPosition();
|
||||||
const [headerStickyOn, setHeaderStickyOn] = useState(false);
|
const [headerStickyOn, setHeaderStickyOn] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
const { headerSticky, headerStickyOffset } = settings.layouts[LayoutNames.ADVANCE_MEGA_MENU];
|
||||||
const isSticky =
|
|
||||||
scrollPosition > settings.layouts[LayoutNames.HORIZONTAL_MENU].headerStickyOffset;
|
|
||||||
setHeaderStickyOn(isSticky);
|
|
||||||
}, [scrollPosition, settings]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (headerStickyOn === true) {
|
setHeaderStickyOn(scrollPosition > headerStickyOffset);
|
||||||
|
}, [scrollPosition, headerStickyOffset]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (headerStickyOn) {
|
||||||
document.body.setAttribute('data-sticky-header', 'on');
|
document.body.setAttribute('data-sticky-header', 'on');
|
||||||
} else {
|
} else {
|
||||||
document.body.removeAttribute('data-sticky-header');
|
document.body.removeAttribute('data-sticky-header');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return () => document.body.removeAttribute('data-sticky-header');
|
||||||
}, [headerStickyOn]);
|
}, [headerStickyOn]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header
|
<header
|
||||||
className={cn(
|
className={cn(
|
||||||
'bg-background flex shrink-0 items-center py-4 transition-[height] lg:h-(--header-height) lg:py-0',
|
'bg-background flex shrink-0 items-center py-4 transition-[height] lg:h-(--header-height) lg:py-0',
|
||||||
headerStickyOn &&
|
headerSticky &&
|
||||||
|
headerStickyOn &&
|
||||||
'bg-background/70 fixed top-0 right-0 left-0 z-10 pe-(--removed-body-scroll-bar-size,0px) shadow-xs backdrop-blur-md transition-[height]'
|
'bg-background/70 fixed top-0 right-0 left-0 z-10 pe-(--removed-body-scroll-bar-size,0px) shadow-xs backdrop-blur-md transition-[height]'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Container className="flex flex-wrap items-center gap-2 lg:gap-4">
|
<Container className="flex flex-wrap items-center gap-2 lg:gap-4">
|
||||||
<HeaderLogo />
|
<HeaderLogo />
|
||||||
<HeaderTopbar />
|
<UserAvatarMenu />
|
||||||
</Container>
|
</Container>
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
import { Menu } from 'lucide-react';
|
import { Menu } from 'lucide-react';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Link, useLocation } from 'react-router-dom';
|
import { useLocation } from 'react-router-dom';
|
||||||
import { useIsMobile } from '@crux-kit/hooks';
|
import { useIsMobile } from '@crux-kit/hooks';
|
||||||
import { toAbsoluteUrl } from '#shared/lib/helpers';
|
|
||||||
import { Button } from '#shared/ui/Button';
|
import { Button } from '#shared/ui/Button';
|
||||||
import { Sheet, SheetBody, SheetContent, SheetHeader, SheetTrigger } from '#shared/ui/Sheet';
|
import { Sheet, SheetBody, SheetContent, SheetHeader, SheetTrigger } from '#shared/ui/Sheet';
|
||||||
import { MegaMenu } from './MegaMenu';
|
import { AppLogo, AppWordmark, MegaNavMenu, SidebarAccordionMenu } from '../../shared';
|
||||||
import { MegaMenuMobile } from './MegaMenuMobile';
|
|
||||||
|
|
||||||
const HeaderLogo = () => {
|
const HeaderLogo = () => {
|
||||||
const { pathname } = useLocation();
|
const { pathname } = useLocation();
|
||||||
@@ -20,24 +18,12 @@ const HeaderLogo = () => {
|
|||||||
return (
|
return (
|
||||||
<div className="flex grow items-stretch gap-1.5 lg:gap-10">
|
<div className="flex grow items-stretch gap-1.5 lg:gap-10">
|
||||||
<div className="flex items-center gap-2.5">
|
<div className="flex items-center gap-2.5">
|
||||||
<Link to="/">
|
<AppLogo className="size-8.5" />
|
||||||
<img
|
<AppWordmark className="text-mono hidden text-lg font-medium lg:block" />
|
||||||
src={toAbsoluteUrl('/media/app/mini-logo-circle-primary.svg')}
|
|
||||||
className="min-h-8.5 dark:hidden"
|
|
||||||
alt="logo"
|
|
||||||
/>
|
|
||||||
<img
|
|
||||||
src={toAbsoluteUrl('/media/app/mini-logo-circle-primary-dark.svg')}
|
|
||||||
className="hidden min-h-8.5 dark:inline-block"
|
|
||||||
alt="logo"
|
|
||||||
/>
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<h3 className="text-mono hidden text-lg font-medium lg:block">Metronic</h3>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!isMobile ? (
|
{!isMobile ? (
|
||||||
<MegaMenu />
|
<MegaNavMenu />
|
||||||
) : (
|
) : (
|
||||||
<Sheet open={isSheetOpen} onOpenChange={setIsSheetOpen}>
|
<Sheet open={isSheetOpen} onOpenChange={setIsSheetOpen}>
|
||||||
<SheetTrigger asChild>
|
<SheetTrigger asChild>
|
||||||
@@ -47,8 +33,8 @@ const HeaderLogo = () => {
|
|||||||
</SheetTrigger>
|
</SheetTrigger>
|
||||||
<SheetContent className="w-68.75 gap-0 p-0" side="left" close={false}>
|
<SheetContent className="w-68.75 gap-0 p-0" side="left" close={false}>
|
||||||
<SheetHeader className="space-y-0 p-0" />
|
<SheetHeader className="space-y-0 p-0" />
|
||||||
<SheetBody className="flex grow flex-col p-0">
|
<SheetBody className="flex grow flex-col overflow-y-auto py-5">
|
||||||
<MegaMenuMobile />
|
<SidebarAccordionMenu />
|
||||||
</SheetBody>
|
</SheetBody>
|
||||||
</SheetContent>
|
</SheetContent>
|
||||||
</Sheet>
|
</Sheet>
|
||||||
|
|||||||
@@ -1,54 +0,0 @@
|
|||||||
import { ArrowUp, Crown } from 'lucide-react';
|
|
||||||
import { Link } from 'react-router-dom';
|
|
||||||
import { Button } from '#shared/ui/Button';
|
|
||||||
import { UserDropdownMenu } from '#shared/widgets/UserDropdownMenu';
|
|
||||||
|
|
||||||
const HeaderTopbar = () => {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-wrap items-center gap-2 lg:gap-3.5">
|
|
||||||
<div className="flex items-center gap-2.5 lg:gap-3.5">
|
|
||||||
<div className="flex flex-col items-end pt-0.5">
|
|
||||||
<span className="text-foreground inline-flex gap-0.5 text-xs font-medium uppercase">
|
|
||||||
<Crown className="size-3.5" />
|
|
||||||
free
|
|
||||||
</span>
|
|
||||||
<span className="text-secondary-foreground text-xs">
|
|
||||||
182/200 <span className="hidden sm:inline-block">Uploads</span>
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button size="sm" asChild>
|
|
||||||
<Link to="/account/security/privacy-settings">
|
|
||||||
Upgrade
|
|
||||||
<ArrowUp />
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="border-border mx-1.5 h-5 border-e lg:mx-3"></div>
|
|
||||||
|
|
||||||
<UserDropdownMenu
|
|
||||||
trigger={
|
|
||||||
<div className="border-input bg-muted relative inline-flex size-8.5 cursor-pointer items-center justify-center rounded-full border text-lg font-medium">
|
|
||||||
S
|
|
||||||
<svg
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
width="15"
|
|
||||||
height="16"
|
|
||||||
viewBox="0 0 15 16"
|
|
||||||
fill="none"
|
|
||||||
className="text-primary absolute -top-0.5 left-6 size-3"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
d="M14.5425 6.89749L13.5 5.83999C13.4273 5.76877 13.3699 5.6835 13.3312 5.58937C13.2925 5.49525 13.2734 5.39424 13.275 5.29249V3.79249C13.274 3.58699 13.2324 3.38371 13.1527 3.19432C13.0729 3.00494 12.9565 2.83318 12.8101 2.68892C12.6638 2.54466 12.4904 2.43073 12.2998 2.35369C12.1093 2.27665 11.9055 2.23801 11.7 2.23999H10.2C10.0982 2.24159 9.99722 2.22247 9.9031 2.18378C9.80898 2.1451 9.72371 2.08767 9.65249 2.01499L8.60249 0.957487C8.30998 0.665289 7.91344 0.50116 7.49999 0.50116C7.08654 0.50116 6.68999 0.665289 6.39749 0.957487L5.33999 1.99999C5.26876 2.07267 5.1835 2.1301 5.08937 2.16879C4.99525 2.20747 4.89424 2.22659 4.79249 2.22499H3.29249C3.08699 2.22597 2.88371 2.26754 2.69432 2.34731C2.50494 2.42709 2.33318 2.54349 2.18892 2.68985C2.04466 2.8362 1.93073 3.00961 1.85369 3.20013C1.77665 3.39064 1.73801 3.5945 1.73999 3.79999V5.29999C1.74159 5.40174 1.72247 5.50275 1.68378 5.59687C1.6451 5.691 1.58767 5.77627 1.51499 5.84749L0.457487 6.89749C0.165289 7.19 0.00115967 7.58654 0.00115967 7.99999C0.00115967 8.41344 0.165289 8.80998 0.457487 9.10249L1.49999 10.16C1.57267 10.2312 1.6301 10.3165 1.66878 10.4106C1.70747 10.5047 1.72659 10.6057 1.72499 10.7075V12.2075C1.72597 12.413 1.76754 12.6163 1.84731 12.8056C1.92709 12.995 2.04349 13.1668 2.18985 13.3111C2.3362 13.4553 2.50961 13.5692 2.70013 13.6463C2.89064 13.7233 3.0945 13.762 3.29999 13.76H4.79999C4.90174 13.7584 5.00275 13.7775 5.09687 13.8162C5.191 13.8549 5.27627 13.9123 5.34749 13.985L6.40499 15.0425C6.69749 15.3347 7.09404 15.4988 7.50749 15.4988C7.92094 15.4988 8.31748 15.3347 8.60999 15.0425L9.65999 14C9.73121 13.9273 9.81647 13.8699 9.9106 13.8312C10.0047 13.7925 10.1057 13.7734 10.2075 13.775H11.7075C12.1212 13.775 12.518 13.6106 12.8106 13.3181C13.1031 13.0255 13.2675 12.6287 13.2675 12.215V10.715C13.2659 10.6132 13.285 10.5122 13.3237 10.4181C13.3624 10.324 13.4198 10.2387 13.4925 10.1675L14.55 9.10999C14.6953 8.96452 14.8104 8.79176 14.8887 8.60164C14.9671 8.41152 15.007 8.20779 15.0063 8.00218C15.0056 7.79656 14.9643 7.59311 14.8847 7.40353C14.8051 7.21394 14.6888 7.04197 14.5425 6.89749ZM10.635 6.64999L6.95249 10.25C6.90055 10.3026 6.83864 10.3443 6.77038 10.3726C6.70212 10.4009 6.62889 10.4153 6.55499 10.415C6.48062 10.4139 6.40719 10.3982 6.33896 10.3685C6.27073 10.3389 6.20905 10.2961 6.15749 10.2425L4.37999 8.44249C4.32532 8.39044 4.28169 8.32793 4.25169 8.25867C4.22169 8.18941 4.20593 8.11482 4.20536 8.03934C4.20479 7.96387 4.21941 7.88905 4.24836 7.81934C4.27731 7.74964 4.31999 7.68647 4.37387 7.63361C4.42774 7.58074 4.4917 7.53926 4.56194 7.51163C4.63218 7.484 4.70726 7.47079 4.78271 7.47278C4.85816 7.47478 4.93244 7.49194 5.00112 7.52324C5.0698 7.55454 5.13148 7.59935 5.18249 7.65499L6.56249 9.05749L9.84749 5.84749C9.95296 5.74215 10.0959 5.68298 10.245 5.68298C10.394 5.68298 10.537 5.74215 10.6425 5.84749C10.6953 5.90034 10.737 5.96318 10.7653 6.03234C10.7935 6.1015 10.8077 6.1756 10.807 6.25031C10.8063 6.32502 10.7908 6.39884 10.7612 6.46746C10.7317 6.53608 10.6888 6.59813 10.635 6.64999Z"
|
|
||||||
fill="currentColor"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export { HeaderTopbar };
|
|
||||||
@@ -1,114 +0,0 @@
|
|||||||
import { Link, useLocation } from 'react-router-dom';
|
|
||||||
import { cn } from '@crux-kit/utils';
|
|
||||||
import { MenuConfig } from '#app/config/types';
|
|
||||||
import { useMenu } from '#shared/hooks/useMenu';
|
|
||||||
import { NavigationMenu, NavigationMenuContent, NavigationMenuItem, NavigationMenuLink, NavigationMenuList, NavigationMenuTrigger } from '#shared/ui/NavigationMenu';
|
|
||||||
import { MegaMenuSubAccount } from '#shared/widgets/MegaMenu/MegaMenuSubAccount';
|
|
||||||
import { MegaMenuSubAuth } from '#shared/widgets/MegaMenu/MegaMenuSubAuth';
|
|
||||||
import { MegaMenuSubNetwork } from '#shared/widgets/MegaMenu/MegaMenuSubNetwork';
|
|
||||||
import { MegaMenuSubProfiles } from '#shared/widgets/MegaMenu/MegaMenuSubProfiles';
|
|
||||||
import { MegaMenuSubStore } from '#shared/widgets/MegaMenu/MegaMenuSubStore';
|
|
||||||
|
|
||||||
const MENU_MEGA: MenuConfig = [];
|
|
||||||
|
|
||||||
export function MegaMenu() {
|
|
||||||
const { pathname } = useLocation();
|
|
||||||
const { isActive, hasActiveChild } = useMenu(pathname);
|
|
||||||
const homeItem = MENU_MEGA[0];
|
|
||||||
const publicProfilesItem = MENU_MEGA[1];
|
|
||||||
const myAccountItem = MENU_MEGA[2];
|
|
||||||
const networkItem = MENU_MEGA[3];
|
|
||||||
const storeItem = MENU_MEGA[5];
|
|
||||||
const authItem = MENU_MEGA[4];
|
|
||||||
const linkClass = `
|
|
||||||
text-sm text-secondary-foreground font-medium rounded-none px-0 border-b border-transparent
|
|
||||||
hover:text-primary hover:bg-transparent
|
|
||||||
focus:text-primary focus:bg-transparent
|
|
||||||
data-[active=true]:text-mono data-[active=true]:bg-transparent data-[active=true]:border-mono
|
|
||||||
data-[state=open]:text-mono data-[state=open]:bg-transparent
|
|
||||||
`;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<NavigationMenu>
|
|
||||||
<NavigationMenuList className="gap-7.5">
|
|
||||||
{/* Home Item */}
|
|
||||||
<NavigationMenuItem>
|
|
||||||
<NavigationMenuLink asChild>
|
|
||||||
<Link
|
|
||||||
to={homeItem.path || '/'}
|
|
||||||
className={cn(linkClass)}
|
|
||||||
data-active={isActive(homeItem.path) || undefined}
|
|
||||||
>
|
|
||||||
{homeItem.title}
|
|
||||||
</Link>
|
|
||||||
</NavigationMenuLink>
|
|
||||||
</NavigationMenuItem>
|
|
||||||
|
|
||||||
{/* Public Profiles Item */}
|
|
||||||
<NavigationMenuItem>
|
|
||||||
<NavigationMenuTrigger
|
|
||||||
className={cn(linkClass)}
|
|
||||||
data-active={hasActiveChild(publicProfilesItem.children) || undefined}
|
|
||||||
>
|
|
||||||
{publicProfilesItem.title}
|
|
||||||
</NavigationMenuTrigger>
|
|
||||||
<NavigationMenuContent className="p-0">
|
|
||||||
<MegaMenuSubProfiles items={MENU_MEGA} />
|
|
||||||
</NavigationMenuContent>
|
|
||||||
</NavigationMenuItem>
|
|
||||||
|
|
||||||
{/* My Account Item */}
|
|
||||||
<NavigationMenuItem>
|
|
||||||
<NavigationMenuTrigger
|
|
||||||
className={cn(linkClass)}
|
|
||||||
data-active={hasActiveChild(myAccountItem.children) || undefined}
|
|
||||||
>
|
|
||||||
{myAccountItem.title}
|
|
||||||
</NavigationMenuTrigger>
|
|
||||||
<NavigationMenuContent className="p-0">
|
|
||||||
<MegaMenuSubAccount items={MENU_MEGA} />
|
|
||||||
</NavigationMenuContent>
|
|
||||||
</NavigationMenuItem>
|
|
||||||
|
|
||||||
{/* Network Item */}
|
|
||||||
<NavigationMenuItem>
|
|
||||||
<NavigationMenuTrigger
|
|
||||||
className={cn(linkClass)}
|
|
||||||
data-active={hasActiveChild(networkItem.children || []) || undefined}
|
|
||||||
>
|
|
||||||
{networkItem.title}
|
|
||||||
</NavigationMenuTrigger>
|
|
||||||
<NavigationMenuContent className="p-0">
|
|
||||||
<MegaMenuSubNetwork items={MENU_MEGA} />
|
|
||||||
</NavigationMenuContent>
|
|
||||||
</NavigationMenuItem>
|
|
||||||
|
|
||||||
{/* Store Item */}
|
|
||||||
<NavigationMenuItem>
|
|
||||||
<NavigationMenuTrigger
|
|
||||||
className={cn(linkClass)}
|
|
||||||
data-active={hasActiveChild(storeItem.children || []) || undefined}
|
|
||||||
>
|
|
||||||
{storeItem.title}
|
|
||||||
</NavigationMenuTrigger>
|
|
||||||
<NavigationMenuContent className="p-0">
|
|
||||||
<MegaMenuSubStore items={MENU_MEGA} />
|
|
||||||
</NavigationMenuContent>
|
|
||||||
</NavigationMenuItem>
|
|
||||||
|
|
||||||
{/* Authentication Item */}
|
|
||||||
<NavigationMenuItem>
|
|
||||||
<NavigationMenuTrigger
|
|
||||||
className={cn(linkClass)}
|
|
||||||
data-active={hasActiveChild(authItem.children) || undefined}
|
|
||||||
>
|
|
||||||
{authItem.title}
|
|
||||||
</NavigationMenuTrigger>
|
|
||||||
<NavigationMenuContent className="p-0">
|
|
||||||
<MegaMenuSubAuth items={MENU_MEGA} />
|
|
||||||
</NavigationMenuContent>
|
|
||||||
</NavigationMenuItem>
|
|
||||||
</NavigationMenuList>
|
|
||||||
</NavigationMenu>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,188 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { LucideIcon } from 'lucide-react';
|
|
||||||
import { JSX, useCallback } from 'react';
|
|
||||||
import { Link, useLocation } from 'react-router-dom';
|
|
||||||
import { cn } from '@crux-kit/utils';
|
|
||||||
import { AccordionMenu, AccordionMenuClassNames, AccordionMenuGroup, AccordionMenuItem, AccordionMenuLabel, AccordionMenuSub, AccordionMenuSubContent, AccordionMenuSubTrigger } from '#shared/ui/AccordionMenu';
|
|
||||||
import { Badge } from '#shared/ui/Badge';
|
|
||||||
|
|
||||||
export interface MenuItem {
|
|
||||||
title?: string;
|
|
||||||
icon?: LucideIcon;
|
|
||||||
path?: string;
|
|
||||||
rootPath?: string;
|
|
||||||
childrenIndex?: number;
|
|
||||||
heading?: string;
|
|
||||||
children?: MenuItem[];
|
|
||||||
disabled?: boolean;
|
|
||||||
collapse?: boolean;
|
|
||||||
collapseTitle?: string;
|
|
||||||
expandTitle?: string;
|
|
||||||
badge?: string;
|
|
||||||
separator?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type MenuConfig = MenuItem[];
|
|
||||||
const MENU_MEGA_MOBILE: MenuConfig = [];
|
|
||||||
|
|
||||||
export function MegaMenuMobile() {
|
|
||||||
const { pathname } = useLocation();
|
|
||||||
|
|
||||||
// Memoize matchPath to prevent unnecessary re-renders
|
|
||||||
const matchPath = useCallback(
|
|
||||||
(path: string): boolean => path === pathname || (path.length > 1 && pathname.startsWith(path)),
|
|
||||||
[pathname]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Global classNames for consistent styling
|
|
||||||
const classNames: AccordionMenuClassNames = {
|
|
||||||
root: 'space-y-1',
|
|
||||||
group: 'gap-px',
|
|
||||||
label: 'uppercase text-xs font-medium text-muted-foreground/70 pt-2.25 pb-px',
|
|
||||||
separator: '',
|
|
||||||
item: 'h-8 hover:bg-transparent text-accent-foreground hover:text-primary data-[selected=true]:text-primary data-[selected=true]:bg-muted data-[selected=true]:font-medium',
|
|
||||||
sub: '',
|
|
||||||
subTrigger:
|
|
||||||
'h-8 hover:bg-transparent text-accent-foreground hover:text-primary data-[selected=true]:text-primary data-[selected=true]:bg-muted data-[selected=true]:font-medium',
|
|
||||||
subContent: 'py-0',
|
|
||||||
indicator: ''
|
|
||||||
};
|
|
||||||
|
|
||||||
const buildMenu = (items: MenuConfig): JSX.Element[] => {
|
|
||||||
return items.map((item: MenuItem, index: number) => {
|
|
||||||
if (item.heading) {
|
|
||||||
return buildMenuHeading(item, index);
|
|
||||||
} else if (!item.disabled) {
|
|
||||||
return buildMenuItemRoot(item, index);
|
|
||||||
} else {
|
|
||||||
return <></>;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const buildMenuItemRoot = (item: MenuItem, index: number): JSX.Element => {
|
|
||||||
if (item.children) {
|
|
||||||
return (
|
|
||||||
<AccordionMenuSub key={index} value={item.path || `root-${index}`}>
|
|
||||||
<AccordionMenuSubTrigger className="text-sm font-medium">
|
|
||||||
{item.icon && <item.icon data-slot="accordion-menu-icon" />}
|
|
||||||
<div className="flex grow items-center justify-between gap-2">
|
|
||||||
<span data-slot="accordion-menu-title">{item.title}</span>
|
|
||||||
{item.badge && (
|
|
||||||
<Badge variant="secondary" size="sm" className="ms-auto">
|
|
||||||
{item.badge}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</AccordionMenuSubTrigger>
|
|
||||||
<AccordionMenuSubContent
|
|
||||||
type="single"
|
|
||||||
collapsible
|
|
||||||
parentValue={item.path || `root-${index}`}
|
|
||||||
className="ps-6"
|
|
||||||
>
|
|
||||||
<AccordionMenuGroup>{buildMenuItemChildren(item.children, 1)}</AccordionMenuGroup>
|
|
||||||
</AccordionMenuSubContent>
|
|
||||||
</AccordionMenuSub>
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
return (
|
|
||||||
<AccordionMenuItem key={index} value={item.path || ''} className="text-sm font-medium">
|
|
||||||
<Link to={item.path || '#'} className="">
|
|
||||||
{item.icon && <item.icon data-slot="accordion-menu-icon" />}
|
|
||||||
<div className="flex grow items-center justify-between gap-2">
|
|
||||||
<span data-slot="accordion-menu-title">{item.title}</span>
|
|
||||||
{item.badge && (
|
|
||||||
<Badge variant="secondary" size="sm" className="ms-auto">
|
|
||||||
{item.badge}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
</AccordionMenuItem>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const buildMenuItemChildren = (items: MenuConfig, level: number = 0): JSX.Element[] => {
|
|
||||||
return items.map((item: MenuItem, index: number) => {
|
|
||||||
if (!item.disabled) {
|
|
||||||
return buildMenuItemChild(item, index, level);
|
|
||||||
} else {
|
|
||||||
return <></>;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const buildMenuItemChild = (item: MenuItem, index: number, level: number = 0): JSX.Element => {
|
|
||||||
if (item.children) {
|
|
||||||
return (
|
|
||||||
<AccordionMenuSub key={index} value={item.path || `child-${level}-${index}`}>
|
|
||||||
<AccordionMenuSubTrigger className="text-[13px]">
|
|
||||||
{item.icon && <item.icon data-slot="accordion-menu-icon" />}
|
|
||||||
{item.collapse ? (
|
|
||||||
<span className="text-muted-foreground">
|
|
||||||
<span className="hidden [[data-state=open]>span>&]:inline">
|
|
||||||
{item.collapseTitle}
|
|
||||||
</span>
|
|
||||||
<span className="inline [[data-state=open]>span>&]:hidden">{item.expandTitle}</span>
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
item.title
|
|
||||||
)}
|
|
||||||
{item.badge && (
|
|
||||||
<Badge variant="secondary" size="sm" className="ms-auto">
|
|
||||||
{item.badge}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</AccordionMenuSubTrigger>
|
|
||||||
<AccordionMenuSubContent
|
|
||||||
type="single"
|
|
||||||
collapsible
|
|
||||||
parentValue={item.path || `child-${level}-${index}`}
|
|
||||||
className={cn('ps-4', !item.collapse && 'relative')}
|
|
||||||
>
|
|
||||||
<AccordionMenuGroup>
|
|
||||||
{buildMenuItemChildren(item.children, item.collapse ? level : level + 1)}
|
|
||||||
</AccordionMenuGroup>
|
|
||||||
</AccordionMenuSubContent>
|
|
||||||
</AccordionMenuSub>
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
return (
|
|
||||||
<AccordionMenuItem key={index} value={item.path || ''} className="text-[13px]">
|
|
||||||
<Link to={item.path || '#'}>
|
|
||||||
{item.icon && <item.icon data-slot="accordion-menu-icon" />}
|
|
||||||
<div className="flex grow items-center justify-between gap-2">
|
|
||||||
<span>{item.title}</span>
|
|
||||||
{item.badge && (
|
|
||||||
<Badge variant="secondary" size="sm" className="ms-auto">
|
|
||||||
{item.badge}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
</AccordionMenuItem>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const buildMenuHeading = (item: MenuItem, index: number): JSX.Element => {
|
|
||||||
return <AccordionMenuLabel key={index}>{item.heading}</AccordionMenuLabel>;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex shrink-0 grow px-5 py-5">
|
|
||||||
<AccordionMenu
|
|
||||||
selectedValue={pathname}
|
|
||||||
matchPath={matchPath}
|
|
||||||
type="single"
|
|
||||||
collapsible
|
|
||||||
classNames={classNames}
|
|
||||||
>
|
|
||||||
{buildMenu(MENU_MEGA_MOBILE)}
|
|
||||||
</AccordionMenu>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,87 +0,0 @@
|
|||||||
import { Fragment, ReactNode } from 'react';
|
|
||||||
import { Link, useLocation } from 'react-router-dom';
|
|
||||||
import { cn } from '@crux-kit/utils';
|
|
||||||
import { MenuItem } from '#app/config/types';
|
|
||||||
import { useMenuConfig } from '#app/menu';
|
|
||||||
import { useMenu } from '#shared/hooks/useMenu';
|
|
||||||
import { Container } from '#shared/widgets/common/Container';
|
|
||||||
|
|
||||||
export interface ToolbarHeadingProps {
|
|
||||||
title?: string | ReactNode;
|
|
||||||
description?: string | ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
function Toolbar({ children }: { children?: ReactNode }) {
|
|
||||||
return (
|
|
||||||
<Container>
|
|
||||||
<div className="border-border border-t"></div>
|
|
||||||
<div className="la:gap-5 my-5 flex flex-wrap items-center justify-between gap-2">
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
<div className="border-border mb-5 border-b lg:mb-7.5"></div>
|
|
||||||
</Container>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ToolbarActions({ children }: { children?: ReactNode }) {
|
|
||||||
return <div className="flex flex-wrap items-center gap-2.5">{children}</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function ToolbarBreadcrumbs() {
|
|
||||||
const { pathname } = useLocation();
|
|
||||||
const { getBreadcrumb, isActive } = useMenu(pathname);
|
|
||||||
const menuConfig = useMenuConfig();
|
|
||||||
|
|
||||||
const items: MenuItem[] = getBreadcrumb(menuConfig);
|
|
||||||
|
|
||||||
if (items.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-1 text-sm">
|
|
||||||
{items.map((item, index) => {
|
|
||||||
const isLast = index === items.length - 1;
|
|
||||||
const active = item.path ? isActive(item.path) : false;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Fragment key={index}>
|
|
||||||
{item.path ? (
|
|
||||||
<Link
|
|
||||||
to={item.path}
|
|
||||||
className={cn(
|
|
||||||
'flex items-center gap-1',
|
|
||||||
active ? 'text-mono' : 'text-secondary-foreground hover:text-primary'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{item.title}
|
|
||||||
</Link>
|
|
||||||
) : (
|
|
||||||
<span className={cn(isLast ? 'text-mono' : 'text-secondary-foreground')}>
|
|
||||||
{item.title}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{!isLast && <span className="text-muted-foreground">/</span>}
|
|
||||||
</Fragment>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ToolbarHeading({ title = '' }: ToolbarHeadingProps) {
|
|
||||||
const { pathname } = useLocation();
|
|
||||||
const { getCurrentItem } = useMenu(pathname);
|
|
||||||
const menuConfig = useMenuConfig();
|
|
||||||
|
|
||||||
const item = getCurrentItem(menuConfig);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<h1 className="text-mono text-lg font-medium">{title || item?.title}</h1>
|
|
||||||
<ToolbarBreadcrumbs />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export { Toolbar, ToolbarActions, ToolbarBreadcrumbs, ToolbarHeading };
|
|
||||||
@@ -1,31 +1,16 @@
|
|||||||
// DEMO 7
|
// DEMO 7
|
||||||
// https://keenthemes.com/metronic/tailwind/react/demo7
|
// https://keenthemes.com/metronic/tailwind/react/demo7
|
||||||
|
|
||||||
import { addDays, format } from 'date-fns';
|
import { Outlet } from 'react-router-dom';
|
||||||
import { CalendarDays, Download } from 'lucide-react';
|
|
||||||
import { useEffect, useState } from 'react';
|
|
||||||
import { DateRange } from 'react-day-picker';
|
|
||||||
import { Link, Outlet, useLocation } from 'react-router-dom';
|
|
||||||
import { useBodyClass } from '@crux-kit/hooks';
|
import { useBodyClass } from '@crux-kit/hooks';
|
||||||
import { LayoutNames } from '#app/config/types';
|
import { LayoutNames } from '#app/config/types';
|
||||||
import { useSettings } from '#app/providers/SettingsProvider';
|
import { AppFooter, Toolbar, ToolbarHeading, useLayoutChrome, useLayoutSettings } from '../shared';
|
||||||
import { Button } from '#shared/ui/Button';
|
|
||||||
import { Calendar } from '#shared/ui/Calendar';
|
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from '#shared/ui/Popover';
|
|
||||||
import { Footer } from './components/Footer';
|
|
||||||
import { Header } from './components/Header';
|
import { Header } from './components/Header';
|
||||||
import { Toolbar, ToolbarActions, ToolbarHeading } from './components/Toolbar';
|
|
||||||
|
|
||||||
export const AdvancedMegaMenuLayout = () => {
|
export const AdvancedMegaMenuLayout = () => {
|
||||||
const { setOption } = useSettings();
|
useLayoutSettings(LayoutNames.ADVANCE_MEGA_MENU);
|
||||||
const { pathname } = useLocation();
|
const { showToolbar, showFooter } = useLayoutChrome();
|
||||||
|
|
||||||
const [date, setDate] = useState<DateRange | undefined>({
|
|
||||||
from: new Date(2025, 0, 20),
|
|
||||||
to: addDays(new Date(2025, 0, 20), 20)
|
|
||||||
});
|
|
||||||
|
|
||||||
// Using the custom hook to set multiple CSS variables and class properties
|
|
||||||
useBodyClass(`
|
useBodyClass(`
|
||||||
[--header-height-default:95px]
|
[--header-height-default:95px]
|
||||||
data-[sticky-header=on]:[--header-height:60px]
|
data-[sticky-header=on]:[--header-height:60px]
|
||||||
@@ -33,62 +18,21 @@ export const AdvancedMegaMenuLayout = () => {
|
|||||||
[--header-height-mobile:70px]
|
[--header-height-mobile:70px]
|
||||||
`);
|
`);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setOption('layout', LayoutNames.ADVANCE_MEGA_MENU);
|
|
||||||
}, [setOption]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex grow flex-col in-data-[sticky-header=on]:pt-(--header-height-default)">
|
<div className="flex grow flex-col in-data-[sticky-header=on]:pt-(--header-height-default)">
|
||||||
<Header />
|
<Header />
|
||||||
|
|
||||||
<div className="grow" role="content">
|
<main className="grow" role="content">
|
||||||
{!pathname.includes('/public-profile/') && (
|
{showToolbar && (
|
||||||
<Toolbar>
|
<Toolbar>
|
||||||
<ToolbarHeading />
|
<ToolbarHeading />
|
||||||
|
|
||||||
<ToolbarActions>
|
|
||||||
<Button variant="outline" asChild>
|
|
||||||
<Link to={'/account/home/get-started'}>
|
|
||||||
<Download />
|
|
||||||
Export
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Popover>
|
|
||||||
<PopoverTrigger asChild>
|
|
||||||
<Button id="date" variant="outline">
|
|
||||||
<CalendarDays />
|
|
||||||
{date?.from ? (
|
|
||||||
date.to ? (
|
|
||||||
<span>
|
|
||||||
{format(date.from, 'LLL dd, y')} - {format(date.to, 'LLL dd, y')}
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
format(date.from, 'LLL dd, y')
|
|
||||||
)
|
|
||||||
) : (
|
|
||||||
<span>Pick a date range</span>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent className="w-auto p-0" align="end">
|
|
||||||
<Calendar
|
|
||||||
mode="range"
|
|
||||||
defaultMonth={date?.from}
|
|
||||||
selected={date}
|
|
||||||
onSelect={setDate}
|
|
||||||
numberOfMonths={2}
|
|
||||||
/>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
</ToolbarActions>
|
|
||||||
</Toolbar>
|
</Toolbar>
|
||||||
)}
|
)}
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</div>
|
</main>
|
||||||
|
|
||||||
<Footer />
|
{showFooter && <AppFooter />}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# Dropdown Menu layout
|
||||||
|
|
||||||
|
**Metronic 9 reference:** [Demo 8](https://keenthemes.com/metronic/tailwind/react/demo8)
|
||||||
|
|
||||||
|
A 90px sidebar of labelled icon tiles, one per top-level section. A section with children opens its
|
||||||
|
whole subtree in a nested dropdown rather than expanding in place.
|
||||||
|
|
||||||
|
## Composition
|
||||||
|
|
||||||
|
| Piece | Source |
|
||||||
|
| --------------------------- | --------------------------------------------- |
|
||||||
|
| Sidebar tiles and dropdowns | `useAppMenu` from `../shared` |
|
||||||
|
| Mobile menu (sheet) | `SidebarAccordionMenu` from `../shared` |
|
||||||
|
| Toolbar and breadcrumbs | `Toolbar` / `ToolbarHeading` from `../shared` |
|
||||||
|
| Footer | `AppFooter` from `../shared` |
|
||||||
|
| Account menu | `UserAvatarMenu` from `../shared` |
|
||||||
|
|
||||||
|
Layout-specific: `Sidebar`, `SidebarHeader`, `SidebarMenu`, `SidebarFooter`, `Header`.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Below `lg` the tiles give way to the shared accordion in a sheet, which reads better on a narrow
|
||||||
|
viewport than nested dropdowns.
|
||||||
|
- Body variables: `--header-height: 60px`, `--sidebar-width: 90px`.
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
import { generalSettings } from '#app/config/general';
|
|
||||||
import { Container } from '#shared/widgets/common/Container';
|
|
||||||
|
|
||||||
export function Footer() {
|
|
||||||
const currentYear = new Date().getFullYear();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<footer className="footer">
|
|
||||||
<Container>
|
|
||||||
<div className="flex flex-col items-center justify-center gap-3 py-5 md:flex-row md:justify-between">
|
|
||||||
<div className="order-2 flex gap-2 text-sm font-normal md:order-1">
|
|
||||||
<span className="text-muted-foreground">{currentYear} ©</span>
|
|
||||||
<a
|
|
||||||
href="https://keenthemes.com"
|
|
||||||
target="_blank"
|
|
||||||
className="text-secondary-foreground hover:text-primary"
|
|
||||||
>
|
|
||||||
Keenthemes Inc.
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<nav className="text-muted-foreground order-1 flex gap-4 text-sm font-normal md:order-2">
|
|
||||||
<a href={generalSettings.docsLink} target="_blank" className="hover:text-primary">
|
|
||||||
Docs
|
|
||||||
</a>
|
|
||||||
<a href={generalSettings.purchaseLink} target="_blank" className="hover:text-primary">
|
|
||||||
Purchase
|
|
||||||
</a>
|
|
||||||
<a href={generalSettings.faqLink} target="_blank" className="hover:text-primary">
|
|
||||||
FAQ
|
|
||||||
</a>
|
|
||||||
<a href="https://devs.keenthemes.com" target="_blank" className="hover:text-primary">
|
|
||||||
Support
|
|
||||||
</a>
|
|
||||||
<a href={generalSettings.licenseLink} target="_blank" className="hover:text-primary">
|
|
||||||
License
|
|
||||||
</a>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
</Container>
|
|
||||||
</footer>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,18 +1,16 @@
|
|||||||
import { Menu } from 'lucide-react';
|
import { Menu } from 'lucide-react';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Link, useLocation } from 'react-router-dom';
|
import { useLocation } from 'react-router-dom';
|
||||||
import { toAbsoluteUrl } from '#shared/lib/helpers';
|
|
||||||
import { Button } from '#shared/ui/Button';
|
import { Button } from '#shared/ui/Button';
|
||||||
import { Sheet, SheetBody, SheetContent, SheetHeader, SheetTrigger } from '#shared/ui/Sheet';
|
import { Sheet, SheetBody, SheetContent, SheetHeader, SheetTrigger } from '#shared/ui/Sheet';
|
||||||
import { Container } from '#shared/widgets/common/Container';
|
import { Container } from '#shared/widgets/common/Container';
|
||||||
|
import { AppLogo, SidebarAccordionMenu } from '../../shared';
|
||||||
import { SidebarFooter } from './SidebarFooter';
|
import { SidebarFooter } from './SidebarFooter';
|
||||||
import { SidebarMenu } from './SidebarMenu';
|
|
||||||
|
|
||||||
export function Header() {
|
export function Header() {
|
||||||
const { pathname } = useLocation();
|
const { pathname } = useLocation();
|
||||||
const [isSheetOpen, setIsSheetOpen] = useState(false);
|
const [isSheetOpen, setIsSheetOpen] = useState(false);
|
||||||
|
|
||||||
// Close sheet when route changes
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setIsSheetOpen(false);
|
setIsSheetOpen(false);
|
||||||
}, [pathname]);
|
}, [pathname]);
|
||||||
@@ -20,18 +18,7 @@ export function Header() {
|
|||||||
return (
|
return (
|
||||||
<header className="bg-muted fixed start-0 end-0 top-0 z-10 flex h-(--header-height) shrink-0 items-center">
|
<header className="bg-muted fixed start-0 end-0 top-0 z-10 flex h-(--header-height) shrink-0 items-center">
|
||||||
<Container className="flex flex-wrap items-center justify-between gap-3">
|
<Container className="flex flex-wrap items-center justify-between gap-3">
|
||||||
<Link to="/">
|
<AppLogo className="h-7.5" />
|
||||||
<img
|
|
||||||
src={toAbsoluteUrl('/media/app/mini-logo-gray.svg')}
|
|
||||||
className="h-7.5 dark:hidden"
|
|
||||||
alt="image"
|
|
||||||
/>
|
|
||||||
<img
|
|
||||||
src={toAbsoluteUrl('/media/app/mini-logo-gray-dark.svg')}
|
|
||||||
className="hidden h-7.5 dark:inline-block"
|
|
||||||
alt="image"
|
|
||||||
/>
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<Sheet open={isSheetOpen} onOpenChange={setIsSheetOpen}>
|
<Sheet open={isSheetOpen} onOpenChange={setIsSheetOpen}>
|
||||||
<SheetTrigger asChild>
|
<SheetTrigger asChild>
|
||||||
@@ -39,10 +26,10 @@ export function Header() {
|
|||||||
<Menu />
|
<Menu />
|
||||||
</Button>
|
</Button>
|
||||||
</SheetTrigger>
|
</SheetTrigger>
|
||||||
<SheetContent className="w-(--sidebar-width) gap-0 p-0" side="left" close={false}>
|
<SheetContent className="w-68.75 gap-0 p-0" side="left" close={false}>
|
||||||
<SheetHeader className="space-y-0 p-0" />
|
<SheetHeader className="space-y-0 p-0" />
|
||||||
<SheetBody className="flex grow flex-col px-0 pt-5">
|
<SheetBody className="flex grow flex-col px-0 pt-5">
|
||||||
<SidebarMenu />
|
<SidebarAccordionMenu />
|
||||||
<SidebarFooter />
|
<SidebarFooter />
|
||||||
</SheetBody>
|
</SheetBody>
|
||||||
</SheetContent>
|
</SheetContent>
|
||||||
|
|||||||
@@ -1,47 +1,9 @@
|
|||||||
// import { LayoutGrid, MessageCircleMore } from 'lucide-react';
|
import { UserAvatarMenu } from '../../shared';
|
||||||
// import { Button } from '#shared/ui/button';
|
|
||||||
import { toAbsoluteUrl } from '#shared/lib/helpers';
|
|
||||||
// import { AppsDropdownMenu } from '#shared/partials/topbar/apps-dropdown-menu';
|
|
||||||
// import { ChatSheet } from '#shared/partials/topbar/chat-sheet';
|
|
||||||
import { UserDropdownMenu } from '#shared/widgets/UserDropdownMenu';
|
|
||||||
|
|
||||||
export function SidebarFooter() {
|
export function SidebarFooter() {
|
||||||
return (
|
return (
|
||||||
<div className="flex shrink-0 flex-col items-center gap-5 pb-5">
|
<div className="flex shrink-0 flex-col items-center gap-5 py-5">
|
||||||
<div className="flex flex-col gap-1.5">
|
<UserAvatarMenu className="rounded-lg" />
|
||||||
{/* <ChatSheet
|
|
||||||
trigger={
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
mode="icon"
|
|
||||||
className="hover:bg-background hover:[&_svg]:text-primary"
|
|
||||||
>
|
|
||||||
<MessageCircleMore className="size-4.5!" />
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/> */}
|
|
||||||
{/* <AppsDropdownMenu
|
|
||||||
trigger={
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
mode="icon"
|
|
||||||
className="hover:bg-background hover:[&_svg]:text-primary"
|
|
||||||
>
|
|
||||||
<LayoutGrid className="size-4.5!" />
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/> */}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<UserDropdownMenu
|
|
||||||
trigger={
|
|
||||||
<img
|
|
||||||
className="border-mono/30 size-8 shrink-0 cursor-pointer rounded-lg border-2"
|
|
||||||
src={toAbsoluteUrl('/media/avatars/300-2.png')}
|
|
||||||
alt="User Avatar"
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,21 +1,9 @@
|
|||||||
import { Link } from 'react-router-dom';
|
import { AppLogo } from '../../shared';
|
||||||
import { toAbsoluteUrl } from '#shared/lib/helpers';
|
|
||||||
|
|
||||||
export function SidebarHeader() {
|
export function SidebarHeader() {
|
||||||
return (
|
return (
|
||||||
<div className="hidden shrink-0 items-center justify-center pt-8 pb-3.5 lg:flex">
|
<div className="hidden shrink-0 items-center justify-center pt-8 pb-3.5 lg:flex">
|
||||||
<Link to="/">
|
<AppLogo className="size-10.5" />
|
||||||
<img
|
|
||||||
src={toAbsoluteUrl('/media/app/mini-logo-square-gray.svg')}
|
|
||||||
className="min-h-10.5 dark:hidden"
|
|
||||||
alt=""
|
|
||||||
/>
|
|
||||||
<img
|
|
||||||
src={toAbsoluteUrl('/media/app/mini-logo-square-gray-dark.svg')}
|
|
||||||
className="hidden min-h-10.5 dark:block"
|
|
||||||
alt=""
|
|
||||||
/>
|
|
||||||
</Link>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,97 +1,30 @@
|
|||||||
import { BarChart2, Settings, ShoppingCart, User, Users } from 'lucide-react';
|
|
||||||
import { Link, useLocation } from 'react-router-dom';
|
import { Link, useLocation } from 'react-router-dom';
|
||||||
import { cn } from '@crux-kit/utils';
|
import { cn } from '@crux-kit/utils';
|
||||||
import { MenuConfig } from '#app/config/types';
|
import { MenuConfig, MenuItem } from '#app/config/types';
|
||||||
import { useMenuConfig } from '#app/menu';
|
|
||||||
import { useMenu } from '#shared/hooks/useMenu';
|
import { useMenu } from '#shared/hooks/useMenu';
|
||||||
import { createMenuPathMatcher } from '#shared/lib/menuPath';
|
|
||||||
import { Button } from '#shared/ui/Button';
|
import { Button } from '#shared/ui/Button';
|
||||||
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger } from '#shared/ui/DropdownMenu';
|
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger } from '#shared/ui/DropdownMenu';
|
||||||
|
import { useAppMenu } from '../../shared';
|
||||||
|
|
||||||
|
const TILE_CLASS_NAMES = cn(
|
||||||
|
'flex h-15 w-15.5 flex-col items-center justify-center gap-1 rounded-lg p-2',
|
||||||
|
'text-muted-foreground bg-transparent text-xs font-medium shadow-none',
|
||||||
|
'hover:text-primary hover:bg-background hover:border-border',
|
||||||
|
'data-[active=true]:text-primary data-[active=true]:bg-background data-[active=true]:border-border',
|
||||||
|
'data-[here=true]:text-primary data-[here=true]:bg-background data-[here=true]:border-border'
|
||||||
|
);
|
||||||
|
|
||||||
export function SidebarMenu() {
|
export function SidebarMenu() {
|
||||||
const { pathname } = useLocation();
|
const { pathname } = useLocation();
|
||||||
const { hasActiveChild } = useMenu(pathname);
|
const { hasActiveChild, isSelected } = useMenu(pathname);
|
||||||
const config = useMenuConfig();
|
const menuConfig = useAppMenu();
|
||||||
|
|
||||||
const menuConfig: MenuConfig = [
|
const buildChildren = (items: MenuConfig) =>
|
||||||
{
|
items.map((item: MenuItem, index: number) => {
|
||||||
title: 'Boards',
|
if (item.separator) {
|
||||||
icon: BarChart2,
|
return <DropdownMenuSeparator key={index} />;
|
||||||
path: '/'
|
}
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Profiles',
|
|
||||||
icon: User,
|
|
||||||
children: config?.[2].children
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Account',
|
|
||||||
icon: Settings,
|
|
||||||
children: config?.[3].children
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Store',
|
|
||||||
icon: ShoppingCart,
|
|
||||||
children: config?.[7].children
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: 'Network',
|
|
||||||
icon: Users,
|
|
||||||
children: config?.[4].children
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
// A leaf highlights only when nothing deeper in the menu matches the route more closely.
|
|
||||||
const isActive = createMenuPathMatcher(menuConfig, pathname);
|
|
||||||
|
|
||||||
const buildMenu = (items: MenuConfig) => {
|
|
||||||
return items.map((item, index) => (
|
|
||||||
<div key={index} className="flex flex-col items-center">
|
|
||||||
{item.children ? (
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>
|
|
||||||
<Button
|
|
||||||
data-here={hasActiveChild(item.children) || undefined}
|
|
||||||
className={cn(
|
|
||||||
'flex h-15 w-15 flex-col items-center justify-center gap-1 rounded-lg shadow-none',
|
|
||||||
'text-secondary-foreground bg-transparent text-xs font-medium',
|
|
||||||
'hover:text-primary hover:bg-background hover:border-border',
|
|
||||||
'data-[state=open]:text-primary data-[state=open]:bg-background data-[state=open]:border-border',
|
|
||||||
'data-[here=true]:text-primary data-[here=true]:bg-background data-[here=true]:border-border'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{item.icon && <item.icon className="size-5!" />}
|
|
||||||
{item.title}
|
|
||||||
</Button>
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="start" side="right" className="w-50">
|
|
||||||
<DropdownMenuLabel>{item.title}</DropdownMenuLabel>
|
|
||||||
{buildMenuChildren(item.children)}
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
) : (
|
|
||||||
<Link
|
|
||||||
data-active={isActive(item.path) || undefined}
|
|
||||||
to={item.path || '#'}
|
|
||||||
className={cn(
|
|
||||||
'flex h-15 w-15.5 flex-col items-center justify-center gap-1 rounded-lg p-2',
|
|
||||||
'text-muted-foreground bg-transparent text-xs font-medium',
|
|
||||||
'hover:text-primary hover:bg-background hover:border-border',
|
|
||||||
'data-[active=true]:text-primary data-[active=true]:bg-background data-[active=true]:border-border'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{item.icon && <item.icon className="size-5!" />}
|
|
||||||
{item.title}
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
));
|
|
||||||
};
|
|
||||||
|
|
||||||
const buildMenuChildren = (items: MenuConfig) => {
|
|
||||||
return items.map((item, index) => {
|
|
||||||
if (item.disabled) return null;
|
|
||||||
if (item.separator) return <DropdownMenuSeparator key={index} />;
|
|
||||||
if (item.children) {
|
if (item.children) {
|
||||||
return (
|
return (
|
||||||
<DropdownMenuSub key={index}>
|
<DropdownMenuSub key={index}>
|
||||||
@@ -100,22 +33,50 @@ export function SidebarMenu() {
|
|||||||
</DropdownMenuSubTrigger>
|
</DropdownMenuSubTrigger>
|
||||||
<DropdownMenuSubContent className="w-50">
|
<DropdownMenuSubContent className="w-50">
|
||||||
<DropdownMenuLabel>{item.title}</DropdownMenuLabel>
|
<DropdownMenuLabel>{item.title}</DropdownMenuLabel>
|
||||||
{buildMenuChildren(item.children)}
|
{buildChildren(item.children)}
|
||||||
</DropdownMenuSubContent>
|
</DropdownMenuSubContent>
|
||||||
</DropdownMenuSub>
|
</DropdownMenuSub>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<DropdownMenuItem key={index} asChild>
|
<DropdownMenuItem key={index} asChild>
|
||||||
<Link to={item.path || '#'}>{item.title}</Link>
|
<Link to={item.path || '#'}>{item.title}</Link>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="kt-scrollable-y-auto flex max-h-[calc(100vh-5rem)] grow flex-col gap-2.5 lg:max-h-[calc(100vh-6rem)]">
|
<div className="kt-scrollable-y-auto flex max-h-[calc(100vh-5rem)] grow flex-col items-center gap-2.5 lg:max-h-[calc(100vh-6rem)]">
|
||||||
{buildMenu(menuConfig)}
|
{menuConfig.map((item, index) =>
|
||||||
|
item.children ? (
|
||||||
|
<DropdownMenu key={index}>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
data-here={hasActiveChild(item.children) || undefined}
|
||||||
|
className={TILE_CLASS_NAMES}
|
||||||
|
>
|
||||||
|
{item.icon && <item.icon className="size-5!" />}
|
||||||
|
{item.title}
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="start" side="right" className="w-50">
|
||||||
|
<DropdownMenuLabel>{item.title}</DropdownMenuLabel>
|
||||||
|
{buildChildren(item.children)}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
) : (
|
||||||
|
<Link
|
||||||
|
key={index}
|
||||||
|
to={item.path || '#'}
|
||||||
|
data-active={isSelected(item.path, menuConfig) || undefined}
|
||||||
|
className={TILE_CLASS_NAMES}
|
||||||
|
>
|
||||||
|
{item.icon && <item.icon className="size-5!" />}
|
||||||
|
{item.title}
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,82 +0,0 @@
|
|||||||
import { Fragment, ReactNode } from 'react';
|
|
||||||
import { Link, useLocation } from 'react-router-dom';
|
|
||||||
import { cn } from '@crux-kit/utils';
|
|
||||||
import { MenuItem } from '#app/config/types';
|
|
||||||
import { useMenuConfig } from '#app/menu';
|
|
||||||
import { useMenu } from '#shared/hooks/useMenu';
|
|
||||||
import { Container } from '#shared/widgets/common/Container';
|
|
||||||
|
|
||||||
export interface ToolbarHeadingProps {
|
|
||||||
title?: string | ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
function Toolbar({ children }: { children?: ReactNode }) {
|
|
||||||
return (
|
|
||||||
<div className="pb-5">
|
|
||||||
<Container className="flex flex-wrap items-center justify-between gap-3">
|
|
||||||
{children}
|
|
||||||
</Container>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ToolbarActions({ children }: { children?: ReactNode }) {
|
|
||||||
return <div className="flex flex-wrap items-center gap-2.5">{children}</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function ToolbarBreadcrumbs() {
|
|
||||||
const { pathname } = useLocation();
|
|
||||||
const { getBreadcrumb, isActive } = useMenu(pathname);
|
|
||||||
const menuConfig = useMenuConfig();
|
|
||||||
const items: MenuItem[] = getBreadcrumb(menuConfig);
|
|
||||||
|
|
||||||
if (items.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-1 text-sm">
|
|
||||||
{items.map((item, index) => {
|
|
||||||
const isLast = index === items.length - 1;
|
|
||||||
const active = item.path ? isActive(item.path) : false;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Fragment key={index}>
|
|
||||||
{item.path ? (
|
|
||||||
<Link
|
|
||||||
to={item.path}
|
|
||||||
className={cn(
|
|
||||||
'flex items-center gap-1',
|
|
||||||
active ? 'text-mono' : 'text-secondary-foreground hover:text-primary'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{item.title}
|
|
||||||
</Link>
|
|
||||||
) : (
|
|
||||||
<span className={cn(isLast ? 'text-mono' : 'text-secondary-foreground')}>
|
|
||||||
{item.title}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{!isLast && <span className="text-muted-foreground">/</span>}
|
|
||||||
</Fragment>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ToolbarHeading({ title = '' }: ToolbarHeadingProps) {
|
|
||||||
const { pathname } = useLocation();
|
|
||||||
const { getCurrentItem } = useMenu(pathname);
|
|
||||||
const menuConfig = useMenuConfig();
|
|
||||||
const item = getCurrentItem(menuConfig);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-wrap items-center gap-1 lg:gap-5">
|
|
||||||
<h1 className="text-mono text-base font-medium">{title || item?.title}</h1>
|
|
||||||
<ToolbarBreadcrumbs />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export { Toolbar, ToolbarActions, ToolbarBreadcrumbs, ToolbarHeading };
|
|
||||||
@@ -1,35 +1,24 @@
|
|||||||
// DEMO 8
|
// DEMO 8
|
||||||
// https://keenthemes.com/metronic/tailwind/react/demo8
|
// https://keenthemes.com/metronic/tailwind/react/demo8
|
||||||
|
|
||||||
import { Download } from 'lucide-react';
|
import { Outlet } from 'react-router-dom';
|
||||||
import { useEffect } from 'react';
|
|
||||||
import { Link, Outlet } from 'react-router-dom';
|
|
||||||
import { useBodyClass, useIsMobile } from '@crux-kit/hooks';
|
import { useBodyClass, useIsMobile } from '@crux-kit/hooks';
|
||||||
import { LayoutNames } from '#app/config/types';
|
import { LayoutNames } from '#app/config/types';
|
||||||
import { useSettings } from '#app/providers/SettingsProvider';
|
import { AppFooter, Toolbar, ToolbarHeading, useLayoutChrome, useLayoutSettings } from '../shared';
|
||||||
import { Button } from '#shared/ui/Button';
|
|
||||||
// import { SearchDialog } from '#shared/partials/dialogs/search/search-dialog';
|
|
||||||
// import { ChatSheet } from '#shared/partials/topbar/chat-sheet';
|
|
||||||
import { Footer } from './components/Footer';
|
|
||||||
import { Header } from './components/Header';
|
import { Header } from './components/Header';
|
||||||
import { Sidebar } from './components/Sidebar';
|
import { Sidebar } from './components/Sidebar';
|
||||||
import { Toolbar, ToolbarActions, ToolbarHeading } from './components/Toolbar';
|
|
||||||
|
|
||||||
export function DropdownMenuLayout() {
|
export function DropdownMenuLayout() {
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
const { setOption } = useSettings();
|
useLayoutSettings(LayoutNames.DROPDOWN_MENU);
|
||||||
|
const { showToolbar, showFooter } = useLayoutChrome();
|
||||||
|
|
||||||
// Using the custom hook to set classes on the body
|
|
||||||
useBodyClass(`
|
useBodyClass(`
|
||||||
[--header-height:60px]
|
[--header-height:60px]
|
||||||
[--sidebar-width:90px]
|
[--sidebar-width:90px]
|
||||||
bg-muted!
|
bg-muted!
|
||||||
`);
|
`);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setOption('layout', LayoutNames.DROPDOWN_MENU);
|
|
||||||
}, [setOption]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex grow">
|
<div className="flex grow">
|
||||||
@@ -41,42 +30,17 @@ export function DropdownMenuLayout() {
|
|||||||
<div className="bg-background border-input m-4 mt-0 flex grow flex-col rounded-xl border lg:m-5 lg:ms-(--sidebar-width)">
|
<div className="bg-background border-input m-4 mt-0 flex grow flex-col rounded-xl border lg:m-5 lg:ms-(--sidebar-width)">
|
||||||
<div className="kt-scrollable-y-auto flex grow flex-col pt-5 lg:[scrollbar-width:auto]">
|
<div className="kt-scrollable-y-auto flex grow flex-col pt-5 lg:[scrollbar-width:auto]">
|
||||||
<main className="grow" role="content">
|
<main className="grow" role="content">
|
||||||
<Toolbar>
|
{showToolbar && (
|
||||||
<ToolbarHeading />
|
<Toolbar>
|
||||||
|
<ToolbarHeading />
|
||||||
<ToolbarActions>
|
</Toolbar>
|
||||||
{/* <SearchDialog
|
)}
|
||||||
trigger={
|
|
||||||
<Button variant="ghost" mode="icon" className="hover:[&_svg]:text-primary">
|
|
||||||
<Search className="size-4.5!" />
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<ChatSheet
|
|
||||||
trigger={
|
|
||||||
<Button variant="ghost" mode="icon" className="hover:[&_svg]:text-primary">
|
|
||||||
<MessageCircleMore className="size-4.5!" />
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/> */}
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
asChild
|
|
||||||
className="hover:text-primary hover:[&_svg]:text-primary ms-2.5"
|
|
||||||
>
|
|
||||||
<Link to={'/account/home/get-started'}>
|
|
||||||
<Download />
|
|
||||||
Export
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
</ToolbarActions>
|
|
||||||
</Toolbar>
|
|
||||||
|
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Footer />
|
{showFooter && <AppFooter />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# Extended Header layout
|
||||||
|
|
||||||
|
**Metronic 9 reference:** [Demo 9](https://keenthemes.com/metronic/tailwind/react/demo9)
|
||||||
|
|
||||||
|
A tall header holding the logo, a search box and the account menu, with the application menu on a
|
||||||
|
mega-menu strip directly beneath it.
|
||||||
|
|
||||||
|
## Composition
|
||||||
|
|
||||||
|
| Piece | Source |
|
||||||
|
| ----------------------- | --------------------------------------------- |
|
||||||
|
| Mega menu strip | `MegaNavMenu` from `../shared` |
|
||||||
|
| Mobile menu (sheet) | `SidebarAccordionMenu` from `../shared` |
|
||||||
|
| Toolbar and breadcrumbs | `Toolbar` / `ToolbarHeading` from `../shared` |
|
||||||
|
| Footer | `AppFooter` from `../shared` |
|
||||||
|
| Account menu | `UserAvatarMenu` from `../shared` |
|
||||||
|
|
||||||
|
Layout-specific: `Header`, `HeaderLogo`, `HeaderSearch`, `Navbar`.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- `HeaderSearch` is presentational; it has no query handling wired up yet.
|
||||||
|
- The mega-menu strip is hidden below `lg`, where the sheet accordion takes over.
|
||||||
|
- The `data-sticky-header` body attribute is removed on unmount.
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
import { generalSettings } from '#app/config/general';
|
|
||||||
import { Container } from '#shared/widgets/common/Container';
|
|
||||||
|
|
||||||
export function Footer() {
|
|
||||||
const currentYear = new Date().getFullYear();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<footer className="footer">
|
|
||||||
<Container>
|
|
||||||
<div className="flex flex-col items-center justify-center gap-3 py-5 md:flex-row md:justify-between">
|
|
||||||
<div className="order-2 flex gap-2 text-sm font-normal md:order-1">
|
|
||||||
<span className="text-muted-foreground">{currentYear} ©</span>
|
|
||||||
<a
|
|
||||||
href="https://keenthemes.com"
|
|
||||||
target="_blank"
|
|
||||||
className="text-secondary-foreground hover:text-primary"
|
|
||||||
>
|
|
||||||
Keenthemes Inc.
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<nav className="text-muted-foreground order-1 flex gap-4 text-sm font-normal md:order-2">
|
|
||||||
<a href={generalSettings.docsLink} target="_blank" className="hover:text-primary">
|
|
||||||
Docs
|
|
||||||
</a>
|
|
||||||
<a href={generalSettings.purchaseLink} target="_blank" className="hover:text-primary">
|
|
||||||
Purchase
|
|
||||||
</a>
|
|
||||||
<a href={generalSettings.faqLink} target="_blank" className="hover:text-primary">
|
|
||||||
FAQ
|
|
||||||
</a>
|
|
||||||
<a href="https://devs.keenthemes.com" target="_blank" className="hover:text-primary">
|
|
||||||
Support
|
|
||||||
</a>
|
|
||||||
<a href={generalSettings.licenseLink} target="_blank" className="hover:text-primary">
|
|
||||||
License
|
|
||||||
</a>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
</Container>
|
|
||||||
</footer>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -4,41 +4,44 @@ import { cn } from '@crux-kit/utils';
|
|||||||
import { LayoutNames } from '#app/config/types';
|
import { LayoutNames } from '#app/config/types';
|
||||||
import { useSettings } from '#app/providers/SettingsProvider';
|
import { useSettings } from '#app/providers/SettingsProvider';
|
||||||
import { Container } from '#shared/widgets/common/Container';
|
import { Container } from '#shared/widgets/common/Container';
|
||||||
|
import { UserAvatarMenu } from '../../shared';
|
||||||
import { HeaderLogo } from './HeaderLogo';
|
import { HeaderLogo } from './HeaderLogo';
|
||||||
import { HeaderSearch } from './HeaderSearch';
|
import { HeaderSearch } from './HeaderSearch';
|
||||||
import { HeaderTopbar } from './HeaderTopbar';
|
|
||||||
|
|
||||||
export function Header() {
|
export function Header() {
|
||||||
const { settings } = useSettings();
|
const { settings } = useSettings();
|
||||||
const scrollPosition = useScrollPosition();
|
const scrollPosition = useScrollPosition();
|
||||||
const [headerStickyOn, setHeaderStickyOn] = useState(false);
|
const [headerStickyOn, setHeaderStickyOn] = useState(false);
|
||||||
|
|
||||||
useEffect(() => {
|
const { headerSticky, headerStickyOffset } = settings.layouts[LayoutNames.EXTENDED_HEADER];
|
||||||
const isSticky =
|
|
||||||
scrollPosition > settings.layouts[LayoutNames.HORIZONTAL_MENU].headerStickyOffset;
|
|
||||||
setHeaderStickyOn(isSticky);
|
|
||||||
}, [scrollPosition, settings]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (headerStickyOn === true) {
|
setHeaderStickyOn(scrollPosition > headerStickyOffset);
|
||||||
|
}, [scrollPosition, headerStickyOffset]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (headerStickyOn) {
|
||||||
document.body.setAttribute('data-sticky-header', 'on');
|
document.body.setAttribute('data-sticky-header', 'on');
|
||||||
} else {
|
} else {
|
||||||
document.body.removeAttribute('data-sticky-header');
|
document.body.removeAttribute('data-sticky-header');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return () => document.body.removeAttribute('data-sticky-header');
|
||||||
}, [headerStickyOn]);
|
}, [headerStickyOn]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header
|
<header
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex h-(--header-height) shrink-0 items-center transition-[height]',
|
'flex h-(--header-height) shrink-0 items-center transition-[height]',
|
||||||
headerStickyOn &&
|
headerSticky &&
|
||||||
|
headerStickyOn &&
|
||||||
'bg-background/70 fixed start-0 end-0 top-0 z-10 shadow-xs backdrop-blur-md transition-[height]'
|
'bg-background/70 fixed start-0 end-0 top-0 z-10 shadow-xs backdrop-blur-md transition-[height]'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<Container className="flex items-center gap-2.5 lg:justify-between">
|
<Container className="flex items-center gap-2.5 lg:justify-between">
|
||||||
<HeaderLogo />
|
<HeaderLogo />
|
||||||
<HeaderSearch />
|
<HeaderSearch />
|
||||||
<HeaderTopbar />
|
<UserAvatarMenu />
|
||||||
</Container>
|
</Container>
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,18 +1,16 @@
|
|||||||
import { Menu } from 'lucide-react';
|
import { Menu } from 'lucide-react';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Link, useLocation } from 'react-router-dom';
|
import { useLocation } from 'react-router-dom';
|
||||||
import { useIsMobile } from '@crux-kit/hooks';
|
import { useIsMobile } from '@crux-kit/hooks';
|
||||||
import { toAbsoluteUrl } from '#shared/lib/helpers';
|
|
||||||
import { Button } from '#shared/ui/Button';
|
import { Button } from '#shared/ui/Button';
|
||||||
import { Sheet, SheetBody, SheetContent, SheetHeader, SheetTrigger } from '#shared/ui/Sheet';
|
import { Sheet, SheetBody, SheetContent, SheetHeader, SheetTrigger } from '#shared/ui/Sheet';
|
||||||
import { MegaMenuMobile } from './MegaMenuMobile';
|
import { AppLogo, AppWordmark, SidebarAccordionMenu } from '../../shared';
|
||||||
|
|
||||||
export function HeaderLogo() {
|
export function HeaderLogo() {
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
const { pathname } = useLocation();
|
const { pathname } = useLocation();
|
||||||
const [isSheetOpen, setIsSheetOpen] = useState(false);
|
const [isSheetOpen, setIsSheetOpen] = useState(false);
|
||||||
|
|
||||||
// Close sheet when route changes
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setIsSheetOpen(false);
|
setIsSheetOpen(false);
|
||||||
}, [pathname]);
|
}, [pathname]);
|
||||||
@@ -20,19 +18,8 @@ export function HeaderLogo() {
|
|||||||
return (
|
return (
|
||||||
<div className="flex grow items-center gap-1 lg:w-100 lg:grow-0">
|
<div className="flex grow items-center gap-1 lg:w-100 lg:grow-0">
|
||||||
<div className="flex shrink-0 items-center gap-2">
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
<Link to="/">
|
<AppLogo className="size-8.5" />
|
||||||
<img
|
<AppWordmark className="text-mono hidden text-lg font-medium md:block" />
|
||||||
src={toAbsoluteUrl('/media/app/mini-logo-circle.svg')}
|
|
||||||
className="min-h-8.5 shrink-0 dark:hidden"
|
|
||||||
alt="logo"
|
|
||||||
/>
|
|
||||||
<img
|
|
||||||
src={toAbsoluteUrl('/media/app/mini-logo-circle-dark.svg')}
|
|
||||||
className="hidden min-h-8.5 shrink-0 dark:inline-block"
|
|
||||||
alt="logo"
|
|
||||||
/>
|
|
||||||
</Link>
|
|
||||||
<h3 className="text-mono hidden text-lg font-medium md:block">Metronic</h3>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isMobile && (
|
{isMobile && (
|
||||||
@@ -44,8 +31,8 @@ export function HeaderLogo() {
|
|||||||
</SheetTrigger>
|
</SheetTrigger>
|
||||||
<SheetContent className="w-68.75 gap-0 p-0" side="left" close={false}>
|
<SheetContent className="w-68.75 gap-0 p-0" side="left" close={false}>
|
||||||
<SheetHeader className="space-y-0 p-0" />
|
<SheetHeader className="space-y-0 p-0" />
|
||||||
<SheetBody className="overflow-y-auto p-0">
|
<SheetBody className="overflow-y-auto py-5">
|
||||||
<MegaMenuMobile />
|
<SidebarAccordionMenu />
|
||||||
</SheetBody>
|
</SheetBody>
|
||||||
</SheetContent>
|
</SheetContent>
|
||||||
</Sheet>
|
</Sheet>
|
||||||
|
|||||||
@@ -1,71 +0,0 @@
|
|||||||
// import { ChevronDown, MessageCircleMore, MessageSquareDot } from 'lucide-react';
|
|
||||||
// import { Button } from '#shared/ui/button';
|
|
||||||
import { toAbsoluteUrl } from '#shared/lib/helpers';
|
|
||||||
import { Label } from '#shared/ui/Label';
|
|
||||||
import { Switch } from '#shared/ui/Switch';
|
|
||||||
// import { DropdownMenu2 } from '#shared/partials/dropdown-menu/dropdown-menu-2';
|
|
||||||
// import { ChatSheet } from '#shared/partials/topbar/chat-sheet';
|
|
||||||
// import { NotificationsSheet } from '#shared/partials/topbar/notifications-sheet';
|
|
||||||
import { UserDropdownMenu } from '#shared/widgets/UserDropdownMenu';
|
|
||||||
|
|
||||||
export function HeaderTopbar() {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center justify-end gap-2 lg:w-100 lg:gap-3.5">
|
|
||||||
<div className="me-0.5 flex items-center gap-2">
|
|
||||||
{/* <ChatSheet
|
|
||||||
trigger={
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
mode="icon"
|
|
||||||
shape="circle"
|
|
||||||
className="hover:bg-transparent hover:[&_svg]:text-primary"
|
|
||||||
>
|
|
||||||
<MessageCircleMore className="size-4.5!" />
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<NotificationsSheet
|
|
||||||
trigger={
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
mode="icon"
|
|
||||||
shape="circle"
|
|
||||||
className="hover:bg-transparent hover:[&_svg]:text-primary"
|
|
||||||
>
|
|
||||||
<MessageSquareDot className="size-4.5!" />
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/> */}
|
|
||||||
|
|
||||||
<UserDropdownMenu
|
|
||||||
trigger={
|
|
||||||
<img
|
|
||||||
className="border-success ms-2.5 size-9 shrink-0 cursor-pointer rounded-full border-2"
|
|
||||||
src={toAbsoluteUrl('/media/avatars/300-2.png')}
|
|
||||||
alt="User Avatar"
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="border-border h-5 border-e"></div>
|
|
||||||
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<Switch id="auto-update" size="sm" defaultChecked />
|
|
||||||
<Label htmlFor="auto-update">Pro</Label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="border-border h-5 border-e"></div>
|
|
||||||
|
|
||||||
{/* <DropdownMenu2
|
|
||||||
trigger={
|
|
||||||
<Button variant="mono">
|
|
||||||
Create
|
|
||||||
<ChevronDown />
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/> */}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,122 +0,0 @@
|
|||||||
// import { Link, useLocation } from 'react-router-dom';
|
|
||||||
// import { MENU_MEGA } from '#app/menu';
|
|
||||||
import {
|
|
||||||
NavigationMenu,
|
|
||||||
// NavigationMenuContent,
|
|
||||||
// NavigationMenuItem,
|
|
||||||
// NavigationMenuLink,
|
|
||||||
NavigationMenuList
|
|
||||||
// NavigationMenuTrigger,
|
|
||||||
} from '#shared/ui/NavigationMenu';
|
|
||||||
|
|
||||||
// import { useMenu } from '#shared/hooks/use_menu';
|
|
||||||
// import { cn } from '@crux-kit/utils';
|
|
||||||
// import { MegaMenuSubAccount } from '#shared/widgets/mega_menu/mega_menu_sub_account';
|
|
||||||
// import { MegaMenuSubAuth } from '#shared/widgets/mega_menu/mega_menu_sub_auth';
|
|
||||||
// import { MegaMenuSubNetwork } from '#shared/widgets/mega_menu/mega_menu_sub_network';
|
|
||||||
// import { MegaMenuSubProfiles } from '#shared/widgets/mega_menu/mega_menu_sub_profiles';
|
|
||||||
// import { MegaMenuSubStore } from '#shared/widgets/mega_menu/mega_menu_sub_store';
|
|
||||||
|
|
||||||
export function MegaMenu() {
|
|
||||||
// const { pathname } = useLocation();
|
|
||||||
// const { isActive, hasActiveChild } = useMenu(pathname);
|
|
||||||
// const homeItem = MENU_MEGA[0];
|
|
||||||
// const publicProfilesItem = MENU_MEGA[1];
|
|
||||||
// const myAccountItem = MENU_MEGA[2];
|
|
||||||
// const networkItem = MENU_MEGA[3];
|
|
||||||
// const storeItem = MENU_MEGA[5];
|
|
||||||
// const authItem = MENU_MEGA[4];
|
|
||||||
// const linkClass = `
|
|
||||||
// inline-flex flex-row items-center h-12 py-0 border-b border-transparent rounded-none bg-transparent -mb-[1px]
|
|
||||||
// text-sm text-secondary-foreground font-medium
|
|
||||||
// hover:text-mono hover:bg-transparent
|
|
||||||
// focus:text-mono focus:bg-transparent
|
|
||||||
// data-[active=true]:text-mono data-[active=true]:bg-transparent data-[active=true]:border-mono
|
|
||||||
// data-[here=true]:text-mono data-[here=true]:bg-transparent data-[here=true]:border-mono
|
|
||||||
// data-[state=open]:text-mono data-[state=open]:bg-transparent
|
|
||||||
// `;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<NavigationMenu>
|
|
||||||
<NavigationMenuList className="gap-2">
|
|
||||||
{/* Home Item */}
|
|
||||||
{/* <NavigationMenuItem>
|
|
||||||
<NavigationMenuLink asChild>
|
|
||||||
<Link
|
|
||||||
to={homeItem.path || '/'}
|
|
||||||
className={cn(linkClass)}
|
|
||||||
data-active={isActive(homeItem.path) || undefined}
|
|
||||||
>
|
|
||||||
{homeItem.title}
|
|
||||||
</Link>
|
|
||||||
</NavigationMenuLink>
|
|
||||||
</NavigationMenuItem> */}
|
|
||||||
|
|
||||||
{/* Public Profiles Item */}
|
|
||||||
{/* <NavigationMenuItem>
|
|
||||||
<NavigationMenuTrigger
|
|
||||||
className={cn(linkClass)}
|
|
||||||
data-active={hasActiveChild(publicProfilesItem.children) || undefined}
|
|
||||||
>
|
|
||||||
{publicProfilesItem.title}
|
|
||||||
</NavigationMenuTrigger>
|
|
||||||
<NavigationMenuContent className="p-0">
|
|
||||||
<MegaMenuSubProfiles items={MENU_MEGA} />
|
|
||||||
</NavigationMenuContent>
|
|
||||||
</NavigationMenuItem> */}
|
|
||||||
|
|
||||||
{/* My Account Item */}
|
|
||||||
{/* <NavigationMenuItem>
|
|
||||||
<NavigationMenuTrigger
|
|
||||||
className={cn(linkClass)}
|
|
||||||
data-active={hasActiveChild(myAccountItem.children) || undefined}
|
|
||||||
>
|
|
||||||
{myAccountItem.title}
|
|
||||||
</NavigationMenuTrigger>
|
|
||||||
<NavigationMenuContent className="p-0">
|
|
||||||
<MegaMenuSubAccount items={MENU_MEGA} />
|
|
||||||
</NavigationMenuContent>
|
|
||||||
</NavigationMenuItem> */}
|
|
||||||
|
|
||||||
{/* Network Item */}
|
|
||||||
{/* <NavigationMenuItem>
|
|
||||||
<NavigationMenuTrigger
|
|
||||||
className={cn(linkClass)}
|
|
||||||
data-active={hasActiveChild(networkItem.children || []) || undefined}
|
|
||||||
>
|
|
||||||
{networkItem.title}
|
|
||||||
</NavigationMenuTrigger>
|
|
||||||
<NavigationMenuContent className="p-0">
|
|
||||||
<MegaMenuSubNetwork items={MENU_MEGA} />
|
|
||||||
</NavigationMenuContent>
|
|
||||||
</NavigationMenuItem> */}
|
|
||||||
|
|
||||||
{/* Store Item */}
|
|
||||||
{/* <NavigationMenuItem>
|
|
||||||
<NavigationMenuTrigger
|
|
||||||
className={cn(linkClass)}
|
|
||||||
data-active={hasActiveChild(storeItem.children || []) || undefined}
|
|
||||||
>
|
|
||||||
{storeItem.title}
|
|
||||||
</NavigationMenuTrigger>
|
|
||||||
<NavigationMenuContent className="p-0">
|
|
||||||
<MegaMenuSubStore items={MENU_MEGA} />
|
|
||||||
</NavigationMenuContent>
|
|
||||||
</NavigationMenuItem> */}
|
|
||||||
|
|
||||||
{/* Authentication Item */}
|
|
||||||
{/* <NavigationMenuItem>
|
|
||||||
<NavigationMenuTrigger
|
|
||||||
className={cn(linkClass)}
|
|
||||||
data-active={hasActiveChild(authItem.children) || undefined}
|
|
||||||
>
|
|
||||||
{authItem.title}
|
|
||||||
</NavigationMenuTrigger>
|
|
||||||
<NavigationMenuContent className="p-0">
|
|
||||||
<MegaMenuSubAuth items={MENU_MEGA} />
|
|
||||||
</NavigationMenuContent>
|
|
||||||
</NavigationMenuItem> */}
|
|
||||||
</NavigationMenuList>
|
|
||||||
</NavigationMenu>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,197 +0,0 @@
|
|||||||
'use client';
|
|
||||||
|
|
||||||
import { LucideIcon } from 'lucide-react';
|
|
||||||
import { JSX, useCallback } from 'react';
|
|
||||||
import { Link, useLocation } from 'react-router-dom';
|
|
||||||
import { cn } from '@crux-kit/utils';
|
|
||||||
// import { MENU_MEGA_MOBILE } from '#app/menu';
|
|
||||||
import {
|
|
||||||
AccordionMenu,
|
|
||||||
AccordionMenuClassNames,
|
|
||||||
AccordionMenuGroup,
|
|
||||||
AccordionMenuItem,
|
|
||||||
// AccordionMenuLabel,
|
|
||||||
AccordionMenuSub,
|
|
||||||
AccordionMenuSubContent,
|
|
||||||
AccordionMenuSubTrigger
|
|
||||||
} from '#shared/ui/AccordionMenu';
|
|
||||||
import { Badge } from '#shared/ui/Badge';
|
|
||||||
|
|
||||||
export interface MenuItem {
|
|
||||||
title?: string;
|
|
||||||
icon?: LucideIcon;
|
|
||||||
path?: string;
|
|
||||||
rootPath?: string;
|
|
||||||
childrenIndex?: number;
|
|
||||||
heading?: string;
|
|
||||||
children?: MenuItem[];
|
|
||||||
disabled?: boolean;
|
|
||||||
collapse?: boolean;
|
|
||||||
collapseTitle?: string;
|
|
||||||
expandTitle?: string;
|
|
||||||
badge?: string;
|
|
||||||
separator?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type MenuConfig = MenuItem[];
|
|
||||||
|
|
||||||
export function MegaMenuMobile() {
|
|
||||||
const { pathname } = useLocation();
|
|
||||||
|
|
||||||
// Memoize matchPath to prevent unnecessary re-renders
|
|
||||||
const matchPath = useCallback(
|
|
||||||
(path: string): boolean => path === pathname || (path.length > 1 && pathname.startsWith(path)),
|
|
||||||
[pathname]
|
|
||||||
);
|
|
||||||
|
|
||||||
// Global classNames for consistent styling
|
|
||||||
const classNames: AccordionMenuClassNames = {
|
|
||||||
root: 'space-y-1',
|
|
||||||
group: 'gap-px',
|
|
||||||
label: 'uppercase text-xs font-medium text-muted-foreground/70 pt-2.25 pb-px',
|
|
||||||
separator: '',
|
|
||||||
item: 'h-8 hover:bg-transparent text-accent-foreground hover:text-primary data-[selected=true]:text-primary data-[selected=true]:bg-muted data-[selected=true]:font-medium',
|
|
||||||
sub: '',
|
|
||||||
subTrigger:
|
|
||||||
'h-8 hover:bg-transparent text-accent-foreground hover:text-primary data-[selected=true]:text-primary data-[selected=true]:bg-muted data-[selected=true]:font-medium',
|
|
||||||
subContent: 'py-0',
|
|
||||||
indicator: ''
|
|
||||||
};
|
|
||||||
|
|
||||||
// const buildMenu = (items: MenuConfig): JSX.Element[] => {
|
|
||||||
// return items.map((item: MenuItem, index: number) => {
|
|
||||||
// if (item.heading) {
|
|
||||||
// return buildMenuHeading(item, index);
|
|
||||||
// } else if (!item.disabled) {
|
|
||||||
// return buildMenuItemRoot(item, index);
|
|
||||||
// } else {
|
|
||||||
// return <></>;
|
|
||||||
// }
|
|
||||||
// });
|
|
||||||
// };
|
|
||||||
|
|
||||||
// const buildMenuItemRoot = (item: MenuItem, index: number): JSX.Element => {
|
|
||||||
// if (item.children) {
|
|
||||||
// return (
|
|
||||||
// <AccordionMenuSub key={index} value={item.path || `root-${index}`}>
|
|
||||||
// <AccordionMenuSubTrigger className="text-sm font-medium">
|
|
||||||
// {item.icon && <item.icon data-slot="accordion-menu-icon" />}
|
|
||||||
// <div className="flex items-center justify-between grow gap-2">
|
|
||||||
// <span data-slot="accordion-menu-title">{item.title}</span>
|
|
||||||
// {item.badge && (
|
|
||||||
// <Badge variant="secondary" size="sm" className="ms-auto">
|
|
||||||
// {item.badge}
|
|
||||||
// </Badge>
|
|
||||||
// )}
|
|
||||||
// </div>
|
|
||||||
// </AccordionMenuSubTrigger>
|
|
||||||
// <AccordionMenuSubContent
|
|
||||||
// type="single"
|
|
||||||
// collapsible
|
|
||||||
// parentValue={item.path || `root-${index}`}
|
|
||||||
// className="ps-6"
|
|
||||||
// >
|
|
||||||
// <AccordionMenuGroup>{buildMenuItemChildren(item.children, 1)}</AccordionMenuGroup>
|
|
||||||
// </AccordionMenuSubContent>
|
|
||||||
// </AccordionMenuSub>
|
|
||||||
// );
|
|
||||||
// } else {
|
|
||||||
// return (
|
|
||||||
// <AccordionMenuItem key={index} value={item.path || ''} className="text-sm font-medium">
|
|
||||||
// <Link to={item.path || '#'} className="">
|
|
||||||
// {item.icon && <item.icon data-slot="accordion-menu-icon" />}
|
|
||||||
// <div className="flex items-center justify-between grow gap-2">
|
|
||||||
// <span data-slot="accordion-menu-title">{item.title}</span>
|
|
||||||
// {item.badge && (
|
|
||||||
// <Badge variant="secondary" size="sm" className="ms-auto">
|
|
||||||
// {item.badge}
|
|
||||||
// </Badge>
|
|
||||||
// )}
|
|
||||||
// </div>
|
|
||||||
// </Link>
|
|
||||||
// </AccordionMenuItem>
|
|
||||||
// );
|
|
||||||
// }
|
|
||||||
// };
|
|
||||||
|
|
||||||
const buildMenuItemChildren = (items: MenuConfig, level: number = 0): JSX.Element[] => {
|
|
||||||
return items.map((item: MenuItem, index: number) => {
|
|
||||||
if (!item.disabled) {
|
|
||||||
return buildMenuItemChild(item, index, level);
|
|
||||||
} else {
|
|
||||||
return <></>;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const buildMenuItemChild = (item: MenuItem, index: number, level: number = 0): JSX.Element => {
|
|
||||||
if (item.children) {
|
|
||||||
return (
|
|
||||||
<AccordionMenuSub key={index} value={item.path || `child-${level}-${index}`}>
|
|
||||||
<AccordionMenuSubTrigger className="text-[13px]">
|
|
||||||
{item.icon && <item.icon data-slot="accordion-menu-icon" />}
|
|
||||||
{item.collapse ? (
|
|
||||||
<span className="text-muted-foreground">
|
|
||||||
<span className="hidden [[data-state=open]>span>&]:inline">
|
|
||||||
{item.collapseTitle}
|
|
||||||
</span>
|
|
||||||
<span className="inline [[data-state=open]>span>&]:hidden">{item.expandTitle}</span>
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
item.title
|
|
||||||
)}
|
|
||||||
{item.badge && (
|
|
||||||
<Badge variant="secondary" size="sm" className="ms-auto">
|
|
||||||
{item.badge}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</AccordionMenuSubTrigger>
|
|
||||||
<AccordionMenuSubContent
|
|
||||||
type="single"
|
|
||||||
collapsible
|
|
||||||
parentValue={item.path || `child-${level}-${index}`}
|
|
||||||
className={cn('ps-4', !item.collapse && 'relative')}
|
|
||||||
>
|
|
||||||
<AccordionMenuGroup>
|
|
||||||
{buildMenuItemChildren(item.children, item.collapse ? level : level + 1)}
|
|
||||||
</AccordionMenuGroup>
|
|
||||||
</AccordionMenuSubContent>
|
|
||||||
</AccordionMenuSub>
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
return (
|
|
||||||
<AccordionMenuItem key={index} value={item.path || ''} className="text-[13px]">
|
|
||||||
<Link to={item.path || '#'}>
|
|
||||||
{item.icon && <item.icon data-slot="accordion-menu-icon" />}
|
|
||||||
<div className="flex grow items-center justify-between gap-2">
|
|
||||||
<span>{item.title}</span>
|
|
||||||
{item.badge && (
|
|
||||||
<Badge variant="secondary" size="sm" className="ms-auto">
|
|
||||||
{item.badge}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Link>
|
|
||||||
</AccordionMenuItem>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// const buildMenuHeading = (item: MenuItem, index: number): JSX.Element => {
|
|
||||||
// return <AccordionMenuLabel key={index}>{item.heading}</AccordionMenuLabel>;
|
|
||||||
// };
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex shrink-0 grow px-5 py-5">
|
|
||||||
<AccordionMenu
|
|
||||||
selectedValue={pathname}
|
|
||||||
matchPath={matchPath}
|
|
||||||
type="single"
|
|
||||||
collapsible
|
|
||||||
classNames={classNames}
|
|
||||||
>
|
|
||||||
{/* {buildMenu(MENU_MEGA_MOBILE)} */}
|
|
||||||
</AccordionMenu>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
import { Container } from '#shared/widgets/common/Container';
|
import { Container } from '#shared/widgets/common/Container';
|
||||||
import { MegaMenu } from './MegaMenu';
|
import { MegaNavMenu } from '../../shared';
|
||||||
|
|
||||||
export function Navbar() {
|
export function Navbar() {
|
||||||
return (
|
return (
|
||||||
<div className="bg-muted/80 border-border border-y lg:flex lg:items-stretch">
|
<div className="bg-muted/80 border-border border-y lg:flex lg:items-stretch">
|
||||||
<Container className="flex flex-wrap items-center justify-between gap-2 px-0 lg:px-7.5">
|
<Container className="flex flex-wrap items-center justify-between gap-2 px-0 lg:px-7.5">
|
||||||
<MegaMenu />
|
<MegaNavMenu />
|
||||||
</Container>
|
</Container>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,83 +0,0 @@
|
|||||||
import { Fragment, ReactNode } from 'react';
|
|
||||||
import { Link, useLocation } from 'react-router-dom';
|
|
||||||
import { cn } from '@crux-kit/utils';
|
|
||||||
import { MenuItem } from '#app/config/types';
|
|
||||||
import { useMenuConfig } from '#app/menu';
|
|
||||||
import { useMenu } from '#shared/hooks/useMenu';
|
|
||||||
import { Container } from '#shared/widgets/common/Container';
|
|
||||||
|
|
||||||
export interface ToolbarHeadingProps {
|
|
||||||
title?: string | ReactNode;
|
|
||||||
description?: string | ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
function Toolbar({ children }: { children?: ReactNode }) {
|
|
||||||
return (
|
|
||||||
<Container>
|
|
||||||
<div className="la:gap-5 flex flex-wrap items-center justify-between gap-2 py-7">
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
</Container>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ToolbarActions({ children }: { children?: ReactNode }) {
|
|
||||||
return <div className="flex flex-wrap items-center gap-2.5">{children}</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
function ToolbarBreadcrumbs() {
|
|
||||||
const menuConfig = useMenuConfig();
|
|
||||||
const { pathname } = useLocation();
|
|
||||||
const { getBreadcrumb, isActive } = useMenu(pathname);
|
|
||||||
const items: MenuItem[] = getBreadcrumb(menuConfig);
|
|
||||||
|
|
||||||
if (items.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex items-center gap-1 text-sm">
|
|
||||||
{items.map((item, index) => {
|
|
||||||
const isLast = index === items.length - 1;
|
|
||||||
const active = item.path ? isActive(item.path) : false;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Fragment key={index}>
|
|
||||||
{item.path ? (
|
|
||||||
<Link
|
|
||||||
to={item.path}
|
|
||||||
className={cn(
|
|
||||||
'flex items-center gap-1',
|
|
||||||
active ? 'text-mono' : 'text-secondary-foreground hover:text-primary'
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{item.title}
|
|
||||||
</Link>
|
|
||||||
) : (
|
|
||||||
<span className={cn(isLast ? 'text-mono' : 'text-secondary-foreground')}>
|
|
||||||
{item.title}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
{!isLast && <span className="text-muted-foreground">/</span>}
|
|
||||||
</Fragment>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ToolbarHeading({ title = '' }: ToolbarHeadingProps) {
|
|
||||||
const { pathname } = useLocation();
|
|
||||||
const { getCurrentItem } = useMenu(pathname);
|
|
||||||
const menuConfig = useMenuConfig();
|
|
||||||
const item = getCurrentItem(menuConfig);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<h1 className="text-mono text-lg font-medium">{title || item?.title}</h1>
|
|
||||||
<ToolbarBreadcrumbs />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export { Toolbar, ToolbarActions, ToolbarBreadcrumbs, ToolbarHeading };
|
|
||||||
@@ -1,41 +1,23 @@
|
|||||||
// DEMO 9
|
// DEMO 9
|
||||||
// https://keenthemes.com/metronic/tailwind/react/demo9
|
// https://keenthemes.com/metronic/tailwind/react/demo9
|
||||||
|
|
||||||
import { addDays, format } from 'date-fns';
|
import { Outlet } from 'react-router';
|
||||||
import { CalendarDays, Download } from 'lucide-react';
|
|
||||||
import { useEffect, useState } from 'react';
|
|
||||||
import { DateRange } from 'react-day-picker';
|
|
||||||
import { Link, Outlet, useLocation } from 'react-router';
|
|
||||||
import { useBodyClass, useIsMobile } from '@crux-kit/hooks';
|
import { useBodyClass, useIsMobile } from '@crux-kit/hooks';
|
||||||
import { LayoutNames } from '#app/config/types';
|
import { LayoutNames } from '#app/config/types';
|
||||||
import { useSettings } from '#app/providers/SettingsProvider';
|
import { AppFooter, Toolbar, ToolbarHeading, useLayoutChrome, useLayoutSettings } from '../shared';
|
||||||
import { Button } from '#shared/ui/Button';
|
|
||||||
import { Calendar } from '#shared/ui/Calendar';
|
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from '#shared/ui/Popover';
|
|
||||||
import { Footer } from './components/Footer';
|
|
||||||
import { Header } from './components/Header';
|
import { Header } from './components/Header';
|
||||||
import { Navbar } from './components/Navbar';
|
import { Navbar } from './components/Navbar';
|
||||||
import { Toolbar, ToolbarActions, ToolbarHeading } from './components/Toolbar';
|
|
||||||
|
|
||||||
export function ExtendedHeaderLayout() {
|
export function ExtendedHeaderLayout() {
|
||||||
const { setOption } = useSettings();
|
useLayoutSettings(LayoutNames.EXTENDED_HEADER);
|
||||||
const { pathname } = useLocation();
|
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
|
const { showToolbar, showFooter } = useLayoutChrome();
|
||||||
const [date, setDate] = useState<DateRange | undefined>({
|
|
||||||
from: new Date(2025, 0, 20),
|
|
||||||
to: addDays(new Date(2025, 0, 20), 20)
|
|
||||||
});
|
|
||||||
|
|
||||||
useBodyClass(`
|
useBodyClass(`
|
||||||
[--header-height:78px]
|
[--header-height:78px]
|
||||||
bg-background!
|
bg-background!
|
||||||
`);
|
`);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setOption('layout', LayoutNames.EXTENDED_HEADER);
|
|
||||||
}, [setOption]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="flex grow flex-col in-data-[sticky-header=on]:pt-(--header-height)">
|
<div className="flex grow flex-col in-data-[sticky-header=on]:pt-(--header-height)">
|
||||||
@@ -44,52 +26,15 @@ export function ExtendedHeaderLayout() {
|
|||||||
{!isMobile && <Navbar />}
|
{!isMobile && <Navbar />}
|
||||||
|
|
||||||
<main className="flex grow flex-col" role="content">
|
<main className="flex grow flex-col" role="content">
|
||||||
{!pathname.includes('/public-profile/') && (
|
{showToolbar && (
|
||||||
<Toolbar>
|
<Toolbar className="pt-5">
|
||||||
<ToolbarHeading />
|
<ToolbarHeading />
|
||||||
|
|
||||||
<ToolbarActions>
|
|
||||||
<Button variant="outline" asChild>
|
|
||||||
<Link to={'/account/home/get-started'}>
|
|
||||||
<Download />
|
|
||||||
Export
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<Popover>
|
|
||||||
<PopoverTrigger asChild>
|
|
||||||
<Button id="date" variant="outline">
|
|
||||||
<CalendarDays />
|
|
||||||
{date?.from ? (
|
|
||||||
date.to ? (
|
|
||||||
<span>
|
|
||||||
{format(date.from, 'LLL dd, y')} - {format(date.to, 'LLL dd, y')}
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
format(date.from, 'LLL dd, y')
|
|
||||||
)
|
|
||||||
) : (
|
|
||||||
<span>Pick a date range</span>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent className="w-auto p-0" align="end">
|
|
||||||
<Calendar
|
|
||||||
mode="range"
|
|
||||||
defaultMonth={date?.from}
|
|
||||||
selected={date}
|
|
||||||
onSelect={setDate}
|
|
||||||
numberOfMonths={2}
|
|
||||||
/>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
</ToolbarActions>
|
|
||||||
</Toolbar>
|
</Toolbar>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Outlet />
|
<Outlet />
|
||||||
|
|
||||||
<Footer />
|
{showFooter && <AppFooter />}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
# Dark Sidebar layout
|
||||||
|
|
||||||
|
**Metronic 9 reference:** [Demo 10](https://keenthemes.com/metronic/tailwind/react/demo10)
|
||||||
|
|
||||||
|
The Multiple Menus arrangement rendered dark: a fixed sidebar forced into the `dark` theme class
|
||||||
|
regardless of the visitor's colour scheme, against a content panel that follows the theme.
|
||||||
|
|
||||||
|
## Composition
|
||||||
|
|
||||||
|
| Piece | Source |
|
||||||
|
| ----------------------- | --------------------------------------------- |
|
||||||
|
| Sidebar menu | `SidebarAccordionMenu` from `../shared` |
|
||||||
|
| Toolbar and breadcrumbs | `Toolbar` / `ToolbarHeading` from `../shared` |
|
||||||
|
| Footer | `AppFooter` from `../shared` |
|
||||||
|
| Account menu | `UserAvatarMenu` from `../shared` |
|
||||||
|
|
||||||
|
Layout-specific: `Sidebar`, `SidebarHeader`, `SidebarFooter`, `Header` (mobile sheet, also `dark`).
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- The `dark` class is applied to the sidebar and to the mobile sheet, so the two stay consistent.
|
||||||
|
- Body variables: `--header-height: 60px`, `--sidebar-width: 270px`.
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
import { generalSettings } from '#app/config/general';
|
|
||||||
import { Container } from '#shared/widgets/common/Container';
|
|
||||||
|
|
||||||
export function Footer() {
|
|
||||||
const currentYear = new Date().getFullYear();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<footer className="footer">
|
|
||||||
<Container>
|
|
||||||
<div className="flex flex-col items-center justify-center gap-3 py-5 md:flex-row md:justify-between">
|
|
||||||
<div className="order-2 flex gap-2 text-sm font-normal md:order-1">
|
|
||||||
<span className="text-muted-foreground">{currentYear} ©</span>
|
|
||||||
<a
|
|
||||||
href="https://keenthemes.com"
|
|
||||||
target="_blank"
|
|
||||||
className="text-secondary-foreground hover:text-primary"
|
|
||||||
>
|
|
||||||
Keenthemes Inc.
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<nav className="text-muted-foreground order-1 flex gap-4 text-sm font-normal md:order-2">
|
|
||||||
<a href={generalSettings.docsLink} target="_blank" className="hover:text-primary">
|
|
||||||
Docs
|
|
||||||
</a>
|
|
||||||
<a href={generalSettings.purchaseLink} target="_blank" className="hover:text-primary">
|
|
||||||
Purchase
|
|
||||||
</a>
|
|
||||||
<a href={generalSettings.faqLink} target="_blank" className="hover:text-primary">
|
|
||||||
FAQ
|
|
||||||
</a>
|
|
||||||
<a href="https://devs.keenthemes.com" target="_blank" className="hover:text-primary">
|
|
||||||
Support
|
|
||||||
</a>
|
|
||||||
<a href={generalSettings.licenseLink} target="_blank" className="hover:text-primary">
|
|
||||||
License
|
|
||||||
</a>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
</Container>
|
|
||||||
</footer>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,21 +1,18 @@
|
|||||||
import { Menu } from 'lucide-react';
|
import { Menu } from 'lucide-react';
|
||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Link, useLocation } from 'react-router-dom';
|
import { useLocation } from 'react-router-dom';
|
||||||
import { useIsMobile } from '@crux-kit/hooks';
|
|
||||||
import { toAbsoluteUrl } from '#shared/lib/helpers';
|
|
||||||
import { Button } from '#shared/ui/Button';
|
import { Button } from '#shared/ui/Button';
|
||||||
import { Sheet, SheetBody, SheetContent, SheetHeader, SheetTrigger } from '#shared/ui/Sheet';
|
import { Sheet, SheetBody, SheetContent, SheetHeader, SheetTrigger } from '#shared/ui/Sheet';
|
||||||
import { Container } from '#shared/widgets/common/Container';
|
import { Container } from '#shared/widgets/common/Container';
|
||||||
|
import { AppLogo } from '../../shared';
|
||||||
import { SidebarFooter } from './SidebarFooter';
|
import { SidebarFooter } from './SidebarFooter';
|
||||||
import { SidebarHeader } from './SidebarHeader';
|
import { SidebarHeader } from './SidebarHeader';
|
||||||
import { SidebarMenu } from './SidebarMenu';
|
import { SidebarMenu } from './SidebarMenu';
|
||||||
|
|
||||||
export function Header() {
|
export function Header() {
|
||||||
const isMobile = useIsMobile();
|
|
||||||
const { pathname } = useLocation();
|
const { pathname } = useLocation();
|
||||||
const [isSheetOpen, setIsSheetOpen] = useState(false);
|
const [isSheetOpen, setIsSheetOpen] = useState(false);
|
||||||
|
|
||||||
// Close sheet when route changes
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setIsSheetOpen(false);
|
setIsSheetOpen(false);
|
||||||
}, [pathname]);
|
}, [pathname]);
|
||||||
@@ -23,31 +20,23 @@ export function Header() {
|
|||||||
return (
|
return (
|
||||||
<header className="bg-mono dark:bg-background fixed start-0 end-0 top-0 z-10 flex h-(--header-height) shrink-0 items-center lg:hidden">
|
<header className="bg-mono dark:bg-background fixed start-0 end-0 top-0 z-10 flex h-(--header-height) shrink-0 items-center lg:hidden">
|
||||||
<Container className="flex flex-wrap items-center justify-between gap-3">
|
<Container className="flex flex-wrap items-center justify-between gap-3">
|
||||||
<Link to="/">
|
<AppLogo className="h-8.5" />
|
||||||
<img
|
|
||||||
src={toAbsoluteUrl('/media/app/mini-logo-circle-success.svg')}
|
|
||||||
className="h-8.5"
|
|
||||||
alt=""
|
|
||||||
/>
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
{isMobile && (
|
<Sheet open={isSheetOpen} onOpenChange={setIsSheetOpen}>
|
||||||
<Sheet open={isSheetOpen} onOpenChange={setIsSheetOpen}>
|
<SheetTrigger asChild>
|
||||||
<SheetTrigger asChild>
|
<Button variant="dim" mode="icon" className="hover:text-white">
|
||||||
<Button variant="dim" mode="icon" className="hover:text-white">
|
<Menu />
|
||||||
<Menu />
|
</Button>
|
||||||
</Button>
|
</SheetTrigger>
|
||||||
</SheetTrigger>
|
<SheetContent className="dark w-62.5 gap-0 p-0" side="left" close={false}>
|
||||||
<SheetContent className="dark w-62.5 gap-0 p-0" side="left" close={false}>
|
<SheetHeader className="space-y-0 p-0" />
|
||||||
<SheetHeader className="space-y-0 p-0" />
|
<SheetBody className="flex grow flex-col overflow-y-auto p-0">
|
||||||
<SheetBody className="flex grow flex-col overflow-y-auto p-0">
|
<SidebarHeader />
|
||||||
<SidebarHeader />
|
<SidebarMenu />
|
||||||
<SidebarMenu />
|
<SidebarFooter />
|
||||||
<SidebarFooter />
|
</SheetBody>
|
||||||
</SheetBody>
|
</SheetContent>
|
||||||
</SheetContent>
|
</Sheet>
|
||||||
</Sheet>
|
|
||||||
)}
|
|
||||||
</Container>
|
</Container>
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,48 +1,9 @@
|
|||||||
// import { LayoutGrid, MessageCircleMore } from 'lucide-react';
|
import { UserAvatarMenu } from '../../shared';
|
||||||
// import { Button } from '#shared/ui/button';
|
|
||||||
import { toAbsoluteUrl } from '#shared/lib/helpers';
|
|
||||||
// import { AppsDropdownMenu } from '#shared/partials/topbar/apps-dropdown-menu';
|
|
||||||
// import { ChatSheet } from '#shared/partials/topbar/chat-sheet';
|
|
||||||
import { UserDropdownMenu } from '#shared/widgets/UserDropdownMenu';
|
|
||||||
|
|
||||||
export function SidebarFooter() {
|
export function SidebarFooter() {
|
||||||
return (
|
return (
|
||||||
<div className="flex-center mb-3.5 flex shrink-0 justify-between ps-4 pe-3.5">
|
<div className="flex-center mb-3.5 flex shrink-0 justify-between ps-4 pe-3.5">
|
||||||
<UserDropdownMenu
|
<UserAvatarMenu />
|
||||||
trigger={
|
|
||||||
<img
|
|
||||||
className="border-mono/25 size-9 shrink-0 cursor-pointer rounded-full border-2"
|
|
||||||
src={toAbsoluteUrl('/media/avatars/300-2.png')}
|
|
||||||
alt="User Avatar"
|
|
||||||
/>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
{/* <ChatSheet
|
|
||||||
trigger={
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
mode="icon"
|
|
||||||
className="hover:bg-background hover:[&_svg]:text-primary"
|
|
||||||
>
|
|
||||||
<MessageCircleMore className="size-4.5!" />
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/> */}
|
|
||||||
|
|
||||||
{/* <AppsDropdownMenu
|
|
||||||
trigger={
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
mode="icon"
|
|
||||||
className="hover:bg-background hover:[&_svg]:text-primary"
|
|
||||||
>
|
|
||||||
<LayoutGrid className="size-4.5!" />
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/> */}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,83 +1,11 @@
|
|||||||
// import { useEffect, useState } from 'react';
|
import { AppLogo, AppWordmark } from '../../shared';
|
||||||
import { ChevronsUpDown, Plus } from 'lucide-react';
|
|
||||||
import { Link } from 'react-router-dom';
|
|
||||||
import { toAbsoluteUrl } from '#shared/lib/helpers';
|
|
||||||
// import { cn } from '@crux-kit/utils';
|
|
||||||
// import { MENU_ROOT } from '#app/menu';
|
|
||||||
import { Button } from '#shared/ui/Button';
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
// DropdownMenuItem,
|
|
||||||
DropdownMenuTrigger
|
|
||||||
} from '#shared/ui/DropdownMenu';
|
|
||||||
|
|
||||||
// import { SearchDialog } from '#shared/partials/dialogs/search/search-dialog';
|
|
||||||
|
|
||||||
export function SidebarHeader() {
|
export function SidebarHeader() {
|
||||||
// const { pathname } = useLocation();
|
|
||||||
// const [selectedMenuItem, setSelectedMenuItem] = useState(MENU_ROOT[1]);
|
|
||||||
|
|
||||||
// useEffect(() => {
|
|
||||||
// MENU_ROOT.forEach((item) => {
|
|
||||||
// if (item.rootPath && pathname.includes(item.rootPath)) {
|
|
||||||
// setSelectedMenuItem(item);
|
|
||||||
// }
|
|
||||||
// });
|
|
||||||
// }, [pathname]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-2.5">
|
<div className="flex flex-col gap-2.5">
|
||||||
<div className="flex h-17.5 items-center justify-between gap-2.5 px-3.5">
|
<div className="flex h-17.5 items-center gap-2.5 px-3.5">
|
||||||
<Link to="/">
|
<AppLogo className="h-8.5" />
|
||||||
<img
|
<AppWordmark className="text-mono font-medium" />
|
||||||
src={toAbsoluteUrl('/media/app/mini-logo-circle-success.svg')}
|
|
||||||
className="h-8.5"
|
|
||||||
alt=""
|
|
||||||
/>
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger className="text-mono flex w-47.5 cursor-pointer items-center justify-between gap-2 font-medium">
|
|
||||||
Metronic
|
|
||||||
<ChevronsUpDown className="me-1 size-3.5!" />
|
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent
|
|
||||||
sideOffset={10}
|
|
||||||
side="bottom"
|
|
||||||
align="start"
|
|
||||||
className="dark w-(--radix-popper-anchor-width)"
|
|
||||||
>
|
|
||||||
{/* {MENU_ROOT.map((item, index) => (
|
|
||||||
<DropdownMenuItem
|
|
||||||
key={index}
|
|
||||||
asChild
|
|
||||||
className={cn(item === selectedMenuItem && 'bg-accent')}
|
|
||||||
>
|
|
||||||
<Link to={item.path || ''}>
|
|
||||||
{item.icon && <item.icon />}
|
|
||||||
{item.title}
|
|
||||||
</Link>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
))} */}
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-2.5 px-3.5">
|
|
||||||
<Button asChild variant="secondary" className="w-full max-w-49.5 justify-center text-white">
|
|
||||||
<Link to="/public-profile/projects/3-columns">
|
|
||||||
<Plus /> Add New
|
|
||||||
</Link>
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{/* <SearchDialog
|
|
||||||
trigger={
|
|
||||||
<Button mode="icon" variant="secondary" className="justify-center text-white shrink-0">
|
|
||||||
<Search className="size-4.5!" />
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/> */}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
import { SidebarMenuPrimary } from './SidebarMenuPrimary';
|
import { SidebarAccordionMenu } from '../../shared';
|
||||||
import { SidebarMenuSecondary } from './SidebarMenuSecondary';
|
|
||||||
|
|
||||||
export function SidebarMenu() {
|
export function SidebarMenu() {
|
||||||
return (
|
return (
|
||||||
<div className="kt-scrollable-y-auto my-5 max-h-[calc(100vh-13rem)] shrink-0 grow space-y-5 [--scrollbar-thumb-color:var(--input)]">
|
<div className="kt-scrollable-y-auto my-5 max-h-[calc(100vh-13rem)] shrink-0 grow space-y-5 [--scrollbar-thumb-color:var(--input)]">
|
||||||
<SidebarMenuPrimary />
|
<SidebarAccordionMenu />
|
||||||
<SidebarMenuSecondary />
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user