Add initial project files

This commit is contained in:
Igor Martyniuk
2026-09-01 13:03:41 +03:00
parent 22e111bb44
commit 74cb5b6176
1841 changed files with 242819 additions and 30 deletions
@@ -0,0 +1,41 @@
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>
);
}
@@ -0,0 +1,19 @@
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>
);
}
@@ -0,0 +1,42 @@
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} &copy;</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>
);
}
@@ -0,0 +1,101 @@
import { Menu, SquareChevronRight } from 'lucide-react';
import { useEffect, useState } from 'react';
import { useLocation } from 'react-router';
import { Link } from 'react-router-dom';
import { useIsMobile, useScrollPosition } from '@crux-kit/hooks';
import { cn } from '@crux-kit/utils';
import { toAbsoluteUrl } from '#shared/lib/helpers';
import { Button } from '#shared/ui/Button';
import { Sheet, SheetBody, SheetContent, SheetHeader, SheetTrigger } from '#shared/ui/Sheet';
import { Container } from '#shared/widgets/common/Container';
import { UserDropdownMenu } from '#shared/widgets/UserDropdownMenu';
import { MegaMenu } from './MegaMenu';
import { MegaMenuMobile } from './MegaMenuMobile';
import { SidebarMenu } from './SidebarMenu';
export function Header() {
const [isSidebarSheetOpen, setIsSidebarSheetOpen] = useState(false);
const [isMegaMenuSheetOpen, setIsMegaMenuSheetOpen] = useState(false);
const { pathname } = useLocation();
const mobileMode = useIsMobile();
const scrollPosition = useScrollPosition();
const headerSticky: boolean = scrollPosition > 0;
// Close sheet when route changes
useEffect(() => {
setIsSidebarSheetOpen(false);
setIsMegaMenuSheetOpen(false);
}, [pathname]);
return (
<header
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)',
headerSticky && 'border-border border-b'
)}
>
<Container className="flex items-stretch justify-between lg:gap-4">
{/* HeaderLogo */}
<div className="flex items-center gap-2.5 lg:hidden">
<Link to="/" className="shrink-0">
<img
src={toAbsoluteUrl('/media/app/mini-logo.svg')}
className="h-6.25 w-full"
alt="mini-logo"
/>
</Link>
<div className="flex items-center">
{mobileMode && (
<Sheet open={isSidebarSheetOpen} onOpenChange={setIsSidebarSheetOpen}>
<SheetTrigger asChild>
<Button variant="ghost" mode="icon">
<Menu 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">
<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>
{/* Main Content (MegaMenu) */}
{!mobileMode && <MegaMenu />}
{/* 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>
</Container>
</header>
);
}
@@ -0,0 +1,119 @@
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>
);
}
@@ -0,0 +1,197 @@
'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>
);
}
@@ -0,0 +1,29 @@
import { useLocation } from 'react-router-dom';
import { cn } from '@crux-kit/utils';
import { LayoutNames } from '#app/config/types';
import { useSettings } from '#app/providers/SettingsProvider';
import { SidebarHeader } from './SidebarHeader';
import { SidebarMenu } from './SidebarMenu';
export function Sidebar() {
const { settings } = useSettings();
const { pathname } = useLocation();
return (
<div
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',
(settings.layouts[LayoutNames.DEFAULT].sidebarTheme === 'dark' ||
pathname.includes('dark-sidebar')) &&
'dark'
)}
>
<SidebarHeader />
<div className="overflow-hidden">
<div className="w-(--sidebar-default-width)">
<SidebarMenu />
</div>
</div>
</div>
);
}
@@ -0,0 +1,63 @@
import { ChevronFirst } from 'lucide-react';
import { Link } from 'react-router-dom';
import { cn } from '@crux-kit/utils';
import { LayoutNames } from '#app/config/types';
import { useSettings } from '#app/providers/SettingsProvider';
import { toAbsoluteUrl } from '#shared/lib/helpers';
import { Button } from '#shared/ui/Button';
export function SidebarHeader() {
const { settings, storeOption } = useSettings();
const handleToggleClick = () => {
storeOption(
`layouts.${LayoutNames.DEFAULT}.sidebarCollapse`,
!settings.layouts[LayoutNames.DEFAULT].sidebarCollapse
);
};
return (
<div className="sidebar-header relative hidden shrink-0 items-center justify-between px-3 lg:flex lg:px-6">
<Link to="/">
<div className="dark:hidden">
{/* <img
src={toAbsoluteUrl('/media/app/default-logo.svg')}
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
onClick={handleToggleClick}
size="sm"
mode="icon"
variant="outline"
className={cn(
'absolute start-full top-2/4 size-7 -translate-x-2/4 -translate-y-2/4 rtl:translate-x-2/4',
settings.layouts[LayoutNames.DEFAULT].sidebarCollapse
? 'ltr:rotate-180'
: 'rtl:rotate-180'
)}
>
<ChevronFirst className="size-4!" />
</Button>
</div>
);
}
@@ -0,0 +1,181 @@
'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 { 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() {
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 (
<div className="kt-scrollable-y-hover flex shrink-0 grow px-5 py-5 lg:max-h-[calc(100vh-5.5rem)]">
<AccordionMenu
selectedValue={pathname}
matchPath={matchPath}
type="single"
collapsible
classNames={classNames}
>
{buildMenu(menuConfig)}
</AccordionMenu>
</div>
);
}
@@ -0,0 +1,86 @@
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 };
+70
View File
@@ -0,0 +1,70 @@
// DEMO 1
// https://keenthemes.com/metronic/tailwind/react/demo1
import { useEffect } from 'react';
import { Outlet } from 'react-router-dom';
import { useIsMobile } from '@crux-kit/hooks';
import { LayoutNames } from '#app/config/types';
import { useSettings } from '#app/providers/SettingsProvider';
import { Footer } from './components/Footer';
import { Header } from './components/Header';
import { Sidebar } from './components/Sidebar';
export function DefaultLayout() {
const isMobile = useIsMobile();
const { settings, setOption } = useSettings();
useEffect(() => {
const bodyClass = document.body.classList;
if (settings.layouts[LayoutNames.DEFAULT].sidebarCollapse) {
bodyClass.add('sidebar-collapse');
} else {
bodyClass.remove('sidebar-collapse');
}
}, [settings]); // Runs only on settings update
useEffect(() => {
// Set current layout
setOption('layout', LayoutNames.DEFAULT);
}, [setOption]);
useEffect(() => {
const bodyClass = document.body.classList;
// Add a class to the body element
bodyClass.add(LayoutNames.DEFAULT);
bodyClass.add('sidebar-fixed');
bodyClass.add('header-fixed');
const timer = setTimeout(() => {
bodyClass.add('layout-initialized');
}, 1000); // 1000 milliseconds
// Remove the class when the component is unmounted
return () => {
bodyClass.remove(LayoutNames.DEFAULT);
bodyClass.remove('sidebar-fixed');
bodyClass.remove('sidebar-collapse');
bodyClass.remove('header-fixed');
bodyClass.remove('layout-initialized');
clearTimeout(timer);
};
}, []); // Runs only once on mount
return (
<>
{!isMobile && <Sidebar />}
<div className="wrapper flex grow flex-col">
<Header />
<main className="grow pt-5" role="content">
<Outlet />
</main>
<Footer />
</div>
</>
);
}
@@ -0,0 +1,42 @@
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} &copy;</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>
);
}
@@ -0,0 +1,45 @@
import { useEffect, useState } from 'react'; // Add useState to the imports
import { useScrollPosition } from '@crux-kit/hooks';
import { cn } from '@crux-kit/utils';
import { LayoutNames } from '#app/config/types';
import { useSettings } from '#app/providers/SettingsProvider';
import { Container } from '#shared/widgets/common/Container';
import { HeaderLogo } from './HeaderLogo';
import { HeaderTopbar } from './HeaderTopbar';
export function Header() {
const { settings } = useSettings();
const scrollPosition = useScrollPosition();
const [headerStickyOn, setHeaderStickyOn] = useState(false);
useEffect(() => {
const isSticky =
scrollPosition > settings.layouts[LayoutNames.HORIZONTAL_MENU].headerStickyOffset;
setHeaderStickyOn(isSticky);
}, [scrollPosition, settings]);
useEffect(() => {
if (headerStickyOn === true) {
document.body.setAttribute('data-sticky-header', 'on');
} else {
document.body.removeAttribute('data-sticky-header');
}
}, [headerStickyOn]);
return (
<header
className={cn(
'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 &&
headerStickyOn &&
'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">
<HeaderLogo />
<HeaderTopbar />
</Container>
</header>
);
}
@@ -0,0 +1,71 @@
// import { useEffect, useState } from 'react';
// import { ChevronDown } from 'lucide-react';
import { Link } from 'react-router-dom';
// import { MENU_ROOT } from '#app/menu';
// import {
// DropdownMenu,
// DropdownMenuContent,
// DropdownMenuItem,
// DropdownMenuTrigger,
// } from '#shared/ui/dropdown_menu';
import { toAbsoluteUrl } from '#shared/lib/helpers';
// import { cn } from '@crux-kit/utils';
export function HeaderLogo() {
// 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 (
<div className="flex items-center gap-2 lg:gap-5 2xl:-ms-15">
{/* Logo Section */}
<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 */}
<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-4 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>
);
}
@@ -0,0 +1,46 @@
// 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>
);
}
@@ -0,0 +1,14 @@
import { Container } from '#shared/widgets/common/Container';
import { NavbarLinks } from './NavbarLinks';
import { NavbarMenu } from './NavbarMenu';
export function Navbar() {
return (
<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">
<NavbarMenu />
<NavbarLinks />
</Container>
</div>
);
}
@@ -0,0 +1,15 @@
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>
);
}
@@ -0,0 +1,128 @@
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>
);
}
@@ -0,0 +1,86 @@
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 };
+90
View File
@@ -0,0 +1,90 @@
// DEMO 2
// https://keenthemes.com/metronic/tailwind/react/demo2
import { addDays, format } from 'date-fns';
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 { LayoutNames } from '#app/config/types';
import { useSettings } from '#app/providers/SettingsProvider';
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 { Navbar } from './components/Navbar';
import { Toolbar, ToolbarActions, ToolbarHeading } from './components/Toolbar';
export function HorizontalMenuLayout() {
const { pathname } = useLocation();
const { setOption } = useSettings();
useEffect(() => {
// Set current layout
setOption('layout', LayoutNames.HORIZONTAL_MENU);
}, [setOption]);
useBodyClass(`
[--header-height-default:100px]
data-[sticky-header=on]:[--header-height:60px]
[--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 (
<>
<div className="flex grow flex-col in-data-[sticky-header=on]:pt-(--header-height-default)">
<Header />
<Navbar />
<main className="grow" role="content">
{!pathname.includes('/public-profile/') && (
<Toolbar>
<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>
)}
<Outlet />
</main>
<Footer />
</div>
</>
);
}
@@ -0,0 +1,42 @@
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} &copy;</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>
);
}
@@ -0,0 +1,14 @@
import { Container } from '#shared/widgets/common/Container';
import { HeaderLogo } from './HeaderLogo';
import { HeaderTopbar } from './HeaderTopbar';
export function Header() {
return (
<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">
<HeaderLogo />
<HeaderTopbar />
</Container>
</header>
);
}
@@ -0,0 +1,87 @@
import { Menu } from 'lucide-react';
import { Link } from 'react-router-dom';
import { toAbsoluteUrl } from '#shared/lib/helpers';
// import { cn } from '@crux-kit/utils';
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 { SidebarMenu } from './SidebarMenu';
export function HeaderLogo() {
// 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 (
<div className="flex items-center gap-2.5">
{/* Logo */}
<div className="flex shrink-0 items-center justify-center lg:w-(--sidebar-width)">
<Sheet>
<SheetTrigger asChild>
<Button variant="ghost" mode="icon" className="-ms-2 lg:hidden">
<Menu className="size-4!" />
</Button>
</SheetTrigger>
<SheetContent className="w-(--sidebar-width) gap-0 p-0" side="left" close={false}>
<SheetHeader className="space-y-0 p-0" />
<SheetBody className="overflow-y-auto p-0">
<SidebarMenu />
</SheetBody>
</SheetContent>
</Sheet>
<Link to="/" className="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>
{/* Menu Section */}
<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>
);
}
@@ -0,0 +1,56 @@
// 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>
);
}
@@ -0,0 +1,16 @@
import { Container } from '#shared/widgets/common/Container';
import { NavbarLinks } from './NavbarLinks';
import { NavbarMenu } from './NavbarMenu';
export function Navbar() {
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="border-border bg-background flex grow items-stretch rounded-t-xl border">
<Container className="flex items-stretch justify-between gap-5">
<NavbarMenu />
<NavbarLinks />
</Container>
</div>
</div>
);
}
@@ -0,0 +1,46 @@
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>
);
}
@@ -0,0 +1,121 @@
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>
);
}
@@ -0,0 +1,13 @@
import { SidebarMenu } from './SidebarMenu';
export function Sidebar() {
return (
<div className="group fixed top-0 bottom-0 z-20 w-(--sidebar-width) shrink-0 flex-col items-stretch py-3 lg:top-(--header-height) lg:flex lg:py-0">
<div className="flex shrink-0 grow">
<div className="kt-scrollable-y-auto flex max-h-[calc(100vh-3rem)] shrink-0 grow flex-col items-center gap-2.5">
<SidebarMenu />
</div>
</div>
</div>
);
}
@@ -0,0 +1,100 @@
import { BarChart3, Bell, CheckSquare, Code, HelpCircle, MessageSquare, Settings, Shield, UserCircle, Users } from 'lucide-react';
import { Link } from 'react-router-dom';
import { cn } from '@crux-kit/utils';
import { Button } from '#shared/ui/Button';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '#shared/ui/Tooltip';
export interface Item {
icon: React.ComponentType<{ className?: string }>;
path: string;
title: string;
newTab?: boolean;
active?: boolean;
}
export function SidebarMenu() {
const items: Item[] = [
{
icon: BarChart3,
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 (
<TooltipProvider>
<div className="flex grow flex-col items-center gap-2.5 py-3.5 lg:py-0">
{items.map((item, index) => (
<Tooltip key={index}>
<TooltipTrigger asChild>
<Button
variant="ghost"
shape="circle"
mode="icon"
{...(item.active ? { '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'
)}
>
<Link
to={item.path || ''}
{...(item.newTab ? { target: '_blank', rel: 'noopener noreferrer' } : {})}
>
<item.icon className="size-4.5!" />
</Link>
</Button>
</TooltipTrigger>
<TooltipContent side="right">{item.title}</TooltipContent>
</Tooltip>
))}
</div>
</TooltipProvider>
);
}
@@ -0,0 +1,87 @@
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 };
+70
View File
@@ -0,0 +1,70 @@
// DEMO 3
// https://keenthemes.com/metronic/tailwind/react/demo3
import { Download } from 'lucide-react';
import { useEffect } from 'react';
import { Link, Outlet, useLocation } from 'react-router-dom';
import { useBodyClass, useIsMobile } from '@crux-kit/hooks';
import { LayoutNames } from '#app/config/types';
import { useSettings } from '#app/providers/SettingsProvider';
import { Button } from '#shared/ui/Button';
import { Footer } from './components/Footer';
import { Header } from './components/Header';
import { Navbar } from './components/Navbar';
import { Sidebar } from './components/Sidebar';
import { Toolbar, ToolbarActions, ToolbarHeading } from './components/Toolbar';
export function CompactSidebarLayout() {
const { pathname } = useLocation();
const { setOption } = useSettings();
const isMobileMode = useIsMobile();
useBodyClass(`
[--header-height:58px]
[--sidebar-width:58px]
[--navbar-height:56px]
lg:overflow-hidden
bg-muted!
`);
useEffect(() => {
setOption('layout', LayoutNames.COMPACT_SIDEBAR);
setOption('container', 'fluid');
}, [setOption]);
return (
<>
<div className="flex grow">
<Header />
<div className="flex grow flex-col pt-(--header-height) lg:flex-row">
{!isMobileMode && <Sidebar />}
<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="kt-scrollable-y flex grow flex-col pt-7 lg:[scrollbar-width:auto] lg:**:data-[slot=container]:pe-2">
<main className="grow" role="content">
{pathname !== '/' && (
<Toolbar>
<ToolbarHeading />
<ToolbarActions>
<Button variant="outline" size="sm" asChild>
<Link to={'/account/home/get-started'}>
<Download />
Export
</Link>
</Button>
</ToolbarActions>
</Toolbar>
)}
<Outlet />
</main>
<Footer />
</div>
</div>
</div>
</div>
</>
);
}
@@ -0,0 +1,42 @@
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} &copy;</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>
);
}
@@ -0,0 +1,53 @@
import { Menu } from 'lucide-react';
import { useEffect, useState } from 'react';
import { Link, useLocation } from 'react-router-dom';
import { toAbsoluteUrl } from '#shared/lib/helpers';
import { Button } from '#shared/ui/Button';
import { Sheet, SheetBody, SheetContent, SheetHeader, SheetTrigger } from '#shared/ui/Sheet';
import { Container } from '#shared/widgets/common/Container';
import { SidebarPrimary } from './SidebarPrimary';
import { SidebarSecondary } from './SidebarSecondary';
export function Header() {
const [isSheetOpen, setIsSheetOpen] = useState(false);
const { pathname } = useLocation();
// Close sheet when route changes
useEffect(() => {
setIsSheetOpen(false);
}, [pathname]);
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)">
<Container className="flex flex-wrap items-center justify-between gap-3">
<Link to="/">
<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}>
<SheetTrigger asChild>
<Button variant="ghost" mode="icon" className="-ms-2 lg:hidden">
<Menu />
</Button>
</SheetTrigger>
<SheetContent className="w-(--sidebar-width) gap-0 p-0" side="left" close={false}>
<SheetHeader className="space-y-0 p-0" />
<SheetBody className="flex shrink-0 items-stretch overflow-y-auto p-0">
<SidebarPrimary />
<SidebarSecondary />
</SheetBody>
</SheetContent>
</Sheet>
</Container>
</header>
);
}
@@ -0,0 +1,11 @@
import { SidebarPrimary } from './SidebarPrimary';
import { SidebarSecondary } from './SidebarSecondary';
export function Sidebar() {
return (
<div className="bg-muted fixed top-0 bottom-0 z-20 flex w-(--sidebar-width) shrink-0 items-stretch">
<SidebarPrimary />
<SidebarSecondary />
</div>
);
}
@@ -0,0 +1,161 @@
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>
);
}
@@ -0,0 +1,183 @@
'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>
);
}
@@ -0,0 +1,186 @@
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 { useViewport } from '@crux-kit/hooks';
import { cn, getHeight } from '@crux-kit/utils';
import { toAbsoluteUrl } from '#shared/lib/helpers';
import { Button } from '#shared/ui/Button';
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '#shared/ui/Tooltip';
// import { AppsDropdownMenu } from '#shared/partials/topbar/apps-dropdown-menu';
// 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() {
const headerRef = useRef<HTMLDivElement>(null);
const footerRef = useRef<HTMLDivElement>(null);
const [scrollableHeight, setScrollableHeight] = useState<number>(0);
const [viewportHeight] = useViewport();
const scrollableOffset = 80;
const { pathname } = useLocation();
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 (
<TooltipProvider>
<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">
<Link to="/">
<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 className="flex shrink-0 grow">
<div
className="kt-scrollable-y-hover flex shrink-0 grow flex-col gap-2.5 ps-4"
style={{ height: `${scrollableHeight}px` }}
>
{menuItems.map((item, index) => (
<Tooltip key={index}>
<TooltipTrigger asChild>
<Button
asChild
variant="ghost"
mode="icon"
{...(item === selectedMenuItem ? { 'data-state': 'open' } : {})}
className={cn(
'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',
'hover:bg-background hover:border-input hover:text-primary hover:border'
)}
>
<Link to={item.path}>
<item.icon className="size-4.5!" />
</Link>
</Button>
</TooltipTrigger>
<TooltipContent side="right">{item.tooltip}</TooltipContent>
</Tooltip>
))}
</div>
</div>
<div ref={footerRef} className="flex shrink-0 flex-col items-center gap-4">
{/* <ChatSheet
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>
</TooltipProvider>
);
}
@@ -0,0 +1,13 @@
import { useLocation } from 'react-router';
import { SidebarMenuDashboard } from './SidebarMenuDashboard';
import { SidebarMenuDefault } from './SidebarMenuDefault';
export function SidebarSecondary() {
const { pathname } = useLocation();
return (
<div className="kt-scrollable-y-hover my-5 max-h-[calc(100vh-2rem)] shrink-0 grow ps-3.5 pe-1">
{pathname === '/' ? <SidebarMenuDashboard /> : <SidebarMenuDefault />}
</div>
);
}
@@ -0,0 +1,83 @@
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 };
+94
View File
@@ -0,0 +1,94 @@
// DEMO 4
// https://keenthemes.com/metronic/tailwind/react/demo4
import { Download } from 'lucide-react';
import { useEffect } from 'react';
import { Link, Outlet } from 'react-router-dom';
import { useBodyClass, useIsMobile } from '@crux-kit/hooks';
import { LayoutNames } from '#app/config/types';
import { useSettings } from '#app/providers/SettingsProvider';
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 { Sidebar } from './components/Sidebar';
import { Toolbar, ToolbarActions, ToolbarHeading } from './components/Toolbar';
export function TwoColumnSidebarLayout() {
const { setOption } = useSettings();
const isMobileMode = useIsMobile();
// Using the custom hook to set multiple CSS variables and class properties
useBodyClass(`
[--header-height:60px]
[--sidebar-width:290px]
lg:overflow-hidden
bg-muted!
`);
useEffect(() => {
setOption('layout', LayoutNames.TWO_COLUMN_SIDEBAR);
}, [setOption]);
return (
<>
<div className="flex grow">
{isMobileMode && <Header />}
<div className="flex grow flex-col pt-(--header-height) lg:flex-row lg:pt-0">
{!isMobileMode && <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="kt-scrollable-y-auto flex grow flex-col pt-5 lg:[--kt-scrollbar-width:auto]">
<main className="grow" role="content">
<Toolbar>
<ToolbarHeading />
<ToolbarActions>
{/* <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 />
</main>
<Footer />
</div>
</div>
</div>
</div>
</>
);
}
@@ -0,0 +1,42 @@
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} &copy;</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>
);
}
@@ -0,0 +1,45 @@
import { useEffect, useState } from 'react';
import { useScrollPosition } from '@crux-kit/hooks';
import { cn } from '@crux-kit/utils';
import { LayoutNames } from '#app/config/types';
import { useSettings } from '#app/providers/SettingsProvider';
import { Container } from '#shared/widgets/common/Container';
import { HeaderLogo } from './HeaderLogo';
import { HeaderTopbar } from './HeaderTopbar';
const Header = () => {
const { settings } = useSettings();
const scrollPosition = useScrollPosition();
const [headerStickyOn, setHeaderStickyOn] = useState(false);
useEffect(() => {
const isSticky =
scrollPosition > settings.layouts[LayoutNames.HORIZONTAL_MENU].headerStickyOffset;
setHeaderStickyOn(isSticky);
}, [scrollPosition, settings]);
useEffect(() => {
if (headerStickyOn === true) {
document.body.setAttribute('data-sticky-header', 'on');
} else {
document.body.removeAttribute('data-sticky-header');
}
}, [headerStickyOn]);
return (
<header
className={cn(
'flex h-(--header-height) shrink-0 items-center bg-(--header-bg) transition-[height] dark:bg-(--header-bg-dark)',
headerStickyOn &&
'fixed start-0 end-0 top-0 z-10 bg-white/70 shadow-xs backdrop-blur-md transition-[height]'
)}
>
<Container width="fluid" className="flex flex-wrap items-center justify-between lg:gap-4">
<HeaderLogo />
<HeaderTopbar />
</Container>
</header>
);
};
export { Header };
@@ -0,0 +1,201 @@
import { ChevronDown, Menu, Settings, Shield, UserCircle, Users, type LucideIcon } from 'lucide-react';
import { useEffect, useState } from 'react';
import { Link, useLocation } from 'react-router-dom';
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 { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '#shared/ui/DropdownMenu';
import { Sheet, SheetBody, SheetContent, SheetHeader, SheetTrigger } from '#shared/ui/Sheet';
import { SidebarMenuDashboard } from './SidebarMenuDashboard';
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() {
const { pathname } = useLocation();
const [isSheetOpen, setIsSheetOpen] = useState(false);
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(() => {
setIsSheetOpen(false);
}, [pathname]);
return (
<div className="flex items-center gap-1.5 lg:gap-5">
<Link to="/">
<img
src={toAbsoluteUrl('/media/app/mini-logo-circle.svg')}
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 && (
<Sheet open={isSheetOpen} onOpenChange={setIsSheetOpen}>
<SheetTrigger asChild>
<Button variant="dim" mode="icon">
<Menu />
</Button>
</SheetTrigger>
<SheetContent className="w-62.5 gap-0 p-0" side="left" close={false}>
<SheetHeader className="space-y-0 p-0" />
<SheetBody className="overflow-y-auto p-3">
{pathname === '/' ? <SidebarMenuDashboard /> : <SidebarMenuDefault />}
</SheetBody>
</SheetContent>
</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>
);
}
@@ -0,0 +1,83 @@
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>
);
}
@@ -0,0 +1,14 @@
import { Container } from '#shared/widgets/common/Container';
import { NavbarMenu } from './NavbarMenu';
const Navbar = () => {
return (
<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">
<NavbarMenu />
</Container>
</div>
);
};
export { Navbar };
@@ -0,0 +1,74 @@
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>
);
}
@@ -0,0 +1,13 @@
import { useLocation } from 'react-router-dom';
import { SidebarMenuDashboard } from './SidebarMenuDashboard';
import { SidebarMenuDefault } from './SidebarMenuDefault';
export function Sidebar() {
const { pathname } = useLocation();
return (
<div className="bg-background flex w-(--sidebar-width) shrink-0 items-stretch px-2">
{pathname === '/' ? <SidebarMenuDashboard /> : <SidebarMenuDefault />}
</div>
);
}
@@ -0,0 +1,171 @@
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>
);
}
@@ -0,0 +1,181 @@
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>
);
}
@@ -0,0 +1,83 @@
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 };
+96
View File
@@ -0,0 +1,96 @@
// DEMO 5
// https://keenthemes.com/metronic/tailwind/react/demo5
import { addDays, format } from 'date-fns';
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 { LayoutNames } from '#app/config/types';
import { useSettings } from '#app/providers/SettingsProvider';
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 { Navbar } from './components/Navbar';
import { Sidebar } from './components/Sidebar';
import { Toolbar, ToolbarActions, ToolbarHeading } from './components/Toolbar';
export const DualRowsHeaderLayout = () => {
const isMobile = useIsMobile();
const { pathname } = useLocation();
const { setOption } = useSettings();
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(`
[--header-height:54px]
[--sidebar-width:200px]
`);
useEffect(() => {
setOption('layout', LayoutNames.DUAL_ROW_HEADER);
setOption('container', 'fluid');
}, [setOption]);
return (
<>
<div className="flex grow flex-col in-data-[sticky-header=on]:pt-(--header-height)">
<Header />
<Navbar />
<div className="flex w-full px-0 lg:ps-4">
{!isMobile && <Sidebar />}
<main className="flex grow flex-col">
{!pathname.includes('/public-profile/') && (
<Toolbar>
<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>
)}
<Outlet />
<Footer />
</main>
</div>
</div>
</>
);
};
@@ -0,0 +1,48 @@
import { APP_VERSION } from '#app/config/app';
import { Dialog, DialogBody, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from '#shared/ui/Dialog';
import { Container } from '#shared/widgets/common/Container';
const currentYear = new Date().getFullYear();
export function Footer() {
return (
<footer className="footer">
<Container>
<div className="flex flex-col items-center justify-center gap-3 py-5 md:flex-row md:justify-between">
<Dialog>
<DialogTrigger asChild>
<button
type="button"
className="order-2 flex cursor-pointer gap-2 text-sm font-normal md:order-1"
>
<span className="text-muted-foreground">{currentYear} &copy;</span>
<p className="text-secondary-foreground hover:text-primary">
C R U X<sup className="ml-1">{APP_VERSION}</sup>
</p>
</button>
</DialogTrigger>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Very nice</DialogTitle>
<DialogDescription className="sr-only"></DialogDescription>
</DialogHeader>
<DialogBody className="flex items-center justify-center">
<iframe
width="560"
height="315"
src="https://www.youtube.com/embed/dQw4w9WgXcQ?si=XGt6EEl8BaWS8e87"
title="YouTube video player"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
referrerPolicy="strict-origin-when-cross-origin"
allowFullScreen
></iframe>
</DialogBody>
</DialogContent>
</Dialog>
<nav className="text-muted-foreground order-1 flex gap-4 text-sm font-normal md:order-2"></nav>
</div>
</Container>
</footer>
);
}
@@ -0,0 +1,60 @@
import { Menu } from 'lucide-react';
import { useEffect, useState } from 'react';
import { Link, useLocation } from 'react-router-dom';
import { useIsMobile } from '@crux-kit/hooks';
import { toAbsoluteUrl } from '#shared/lib/helpers';
import { Button } from '#shared/ui/Button';
import { Sheet, SheetBody, SheetContent, SheetHeader, SheetTrigger } from '#shared/ui/Sheet';
import { Container } from '#shared/widgets/common/Container';
import { SidebarFooter } from './SidebarFooter';
import { SidebarHeader } from './SidebarHeader';
import { SidebarMenu } from './SidebarMenu';
const Header = () => {
const { pathname } = useLocation();
const [isSheetOpen, setIsSheetOpen] = useState(false);
const isMobile = useIsMobile();
useEffect(() => {
setIsSheetOpen(false);
}, [pathname]);
return (
<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">
<Link to="/">
<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}>
<SheetTrigger asChild>
<Button variant="ghost" mode="icon">
<Menu />
</Button>
</SheetTrigger>
<SheetContent className="w-68.75 gap-0 p-0" side="left" close={false}>
<SheetHeader className="space-y-0 p-0" />
<SheetBody className="flex grow flex-col p-0">
<SidebarHeader />
<SidebarMenu />
<SidebarFooter />
</SheetBody>
</SheetContent>
</Sheet>
)}
</Container>
</header>
);
};
export { Header };
@@ -0,0 +1,89 @@
import React from 'react';
import { IS_PROD_MODE } from '#app/config/app';
import { toAbsoluteUrl } from '#shared/lib/helpers';
const PEEK_INTERVAL_MS = 120_000;
type Side = 'bottom' | 'top' | 'bottom-left' | 'bottom-right';
const SIDES: Side[] = ['bottom', 'top', 'bottom-left', 'bottom-right'];
const ROTATION: Record<Side, number> = {
'bottom': 0,
'top': 180,
'bottom-left': 45,
'bottom-right': -45
};
function buildKeyframes(side: Side): string {
if (side === 'bottom-left') {
return `@keyframes rooster-peek {
0% { left: -200px; bottom: -200px; }
40% { left: 20px; bottom: 20px; }
70% { left: 20px; bottom: 20px; }
100% { left: -200px; bottom: -200px; }
}`;
}
if (side === 'bottom-right') {
return `@keyframes rooster-peek {
0% { right: -200px; bottom: -200px; }
40% { right: 20px; bottom: 20px; }
70% { right: 20px; bottom: 20px; }
100% { right: -200px; bottom: -200px; }
}`;
}
const prop = side;
return `@keyframes rooster-peek {
0% { ${prop}: -200px; }
40% { ${prop}: 20px; }
70% { ${prop}: 20px; }
100% { ${prop}: -200px; }
}`;
}
// prettier-ignore
function buildStyle(side: Side): React.CSSProperties {
const rotation = `rotate(${ROTATION[side]}deg)`;
const base: React.CSSProperties = {
animation: 'rooster-peek 4s ease-in-out 1 forwards',
transform: rotation,
};
if (side === 'bottom') return { ...base, bottom: '-200px', left: '20px' };
if (side === 'top') return { ...base, top: '-200px', left: '20px' };
if (side === 'bottom-left') return { ...base, left: '-200px', bottom: '-200px' };
return { ...base, right: '-200px', bottom: '-200px' };
}
function randomSide(): Side {
return SIDES[Math.floor(Math.random() * SIDES.length)];
}
export const RoosterPeek: React.FC = () => {
const [{ tick, side }, setState] = React.useState(() => ({ tick: 0, side: randomSide() }));
React.useEffect(() => {
if (IS_PROD_MODE) return;
const id = setInterval(
() => setState((s) => ({ tick: s.tick + 1, side: randomSide() })),
PEEK_INTERVAL_MS
);
return () => clearInterval(id);
}, []);
if (IS_PROD_MODE) return null;
return (
<React.Fragment>
<style>{buildKeyframes(side)}</style>
<img
key={tick}
src={toAbsoluteUrl('/media/app/brutal-aggressive-rooster-head-icon.svg')}
className="fixed z-1000 size-50"
style={buildStyle(side)}
alt="Brutal rooster"
/>
</React.Fragment>
);
};
@@ -0,0 +1,13 @@
import { SidebarFooter } from './SidebarFooter';
import { SidebarHeader } from './SidebarHeader';
import { SidebarMenu } from './SidebarMenu';
export function Sidebar() {
return (
<div className="fixed top-0 bottom-0 z-50 w-(--sidebar-width) shrink-0 flex-col bg-(--page-bg) lg:flex dark:bg-(--page-bg-dark)">
<SidebarHeader />
<SidebarMenu />
<SidebarFooter />
</div>
);
}
@@ -0,0 +1,24 @@
import useIntlName from '#app/i18n/useIntlName';
import { useAuth } from '#core/Auth/providers/AuthProvider';
import { Avatar, AvatarFallback, AvatarImage } from '#shared/ui/Avatar';
import { UserDropdownMenu } from '#shared/widgets/UserDropdownMenu';
export function SidebarFooter() {
const { user } = useAuth();
const formatName = useIntlName();
return (
<div className="flex-center flex h-14 shrink-0 justify-between ps-4 pe-3.5">
{user && (
<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>
);
}
@@ -0,0 +1,45 @@
import { Search } from 'lucide-react';
import { Link } from 'react-router-dom';
import { toAbsoluteUrl } from '#shared/lib/helpers';
import { Input } from '#shared/ui/Input';
export function SidebarHeader() {
const handleInputChange = () => {};
return (
<div className="mb-3.5">
<div className="flex h-17.5 items-center justify-between gap-2.5 px-3.5">
<Link to="/">
<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]">
C R U X <sub className="text-[50%]">Goat</sub>
</div>
</div>
<div className="mb-1 px-3.5 pt-2.5">
<div className="relative">
<Search className="text-muted-foreground absolute start-3.5 top-1/2 size-4 -translate-y-1/2" />
<Input
placeholder="Search"
onChange={handleInputChange}
className="min-w-0 px-9"
value=""
/>
<span className="text-muted-foreground absolute end-3.5 top-1/2 -translate-y-1/2 text-xs">
cmd + /
</span>
</div>
</div>
</div>
);
}
@@ -0,0 +1,9 @@
import { SidebarMenuPrimary } from './SidebarMenuPrimary';
export function SidebarMenu() {
return (
<div className="kt-scrollable-y-auto max-h-[calc(100vh-11.5rem)] grow">
<SidebarMenuPrimary />
</div>
);
}
@@ -0,0 +1,142 @@
'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>
);
}
@@ -0,0 +1,104 @@
import { HomeIcon } 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="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-1.5 lg:gap-3.5">{children}</div>;
}
function ToolbarBreadcrumbs({ homeVisible = true }: { homeVisible?: boolean }) {
const menuConfig = useMenuConfig();
const dashboard = menuConfig[0];
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">
{homeVisible && (
<>
<Link
to={dashboard.path!}
className={cn(
'flex items-center gap-1',
isActive(dashboard.path)
? 'text-mono'
: 'text-secondary-foreground hover:text-primary'
)}
>
<HomeIcon className="size-4" />
</Link>
<span className="text-muted-foreground">/</span>
</>
)}
{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 gap-1">
<h1 className="text-mono text-lg font-medium">{title || item?.title}</h1>
<ToolbarBreadcrumbs />
</div>
);
};
export { Toolbar, ToolbarActions, ToolbarBreadcrumbs, ToolbarHeading };
+69
View File
@@ -0,0 +1,69 @@
// DEMO 6
// https://keenthemes.com/metronic/tailwind/react/demo6
import { Fragment, useEffect, useMemo } from 'react';
import { Outlet, useLocation } from 'react-router-dom';
import { useBodyClass, useIsMobile } from '@crux-kit/hooks';
import { LayoutNames } from '#app/config/types';
import { useSettings } from '#app/providers/SettingsProvider';
import { Footer } from './components/Footer';
import { Header } from './components/Header';
import { Sidebar } from './components/Sidebar';
import { Toolbar, ToolbarHeading } from './components/Toolbar';
export const MultipleMenusLayout = () => {
const isMobile = useIsMobile();
const { setOption } = useSettings();
const { pathname } = useLocation();
useBodyClass(`
[--header-height:60px]
[--sidebar-width:270px]
lg:overflow-hidden
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 (
<Fragment>
<div className="flex grow">
{!isMobile && <Sidebar />}
{isMobile && <Header />}
<div className="flex grow flex-col pt-(--header-height) lg:flex-row lg:pt-0">
<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]">
<main className="grow" role="content">
{toolbar}
<Outlet />
</main>
</div>
{footer}
</div>
</div>
</div>
</Fragment>
);
};
+25
View File
@@ -0,0 +1,25 @@
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,42 @@
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} &copy;</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>
);
}
@@ -0,0 +1,45 @@
import { useEffect, useState } from 'react';
import { useScrollPosition } from '@crux-kit/hooks';
import { cn } from '@crux-kit/utils';
import { LayoutNames } from '#app/config/types';
import { useSettings } from '#app/providers/SettingsProvider';
import { Container } from '#shared/widgets/common/Container';
import { HeaderLogo } from './HeaderLogo';
import { HeaderTopbar } from './HeaderTopbar';
const Header = () => {
const { settings } = useSettings();
const scrollPosition = useScrollPosition();
const [headerStickyOn, setHeaderStickyOn] = useState(false);
useEffect(() => {
const isSticky =
scrollPosition > settings.layouts[LayoutNames.HORIZONTAL_MENU].headerStickyOffset;
setHeaderStickyOn(isSticky);
}, [scrollPosition, settings]);
useEffect(() => {
if (headerStickyOn === true) {
document.body.setAttribute('data-sticky-header', 'on');
} else {
document.body.removeAttribute('data-sticky-header');
}
}, [headerStickyOn]);
return (
<header
className={cn(
'bg-background flex shrink-0 items-center py-4 transition-[height] lg:h-(--header-height) lg:py-0',
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]'
)}
>
<Container className="flex flex-wrap items-center gap-2 lg:gap-4">
<HeaderLogo />
<HeaderTopbar />
</Container>
</header>
);
};
export { Header };
@@ -0,0 +1,60 @@
import { Menu } from 'lucide-react';
import { useEffect, useState } from 'react';
import { Link, useLocation } from 'react-router-dom';
import { useIsMobile } from '@crux-kit/hooks';
import { toAbsoluteUrl } from '#shared/lib/helpers';
import { Button } from '#shared/ui/Button';
import { Sheet, SheetBody, SheetContent, SheetHeader, SheetTrigger } from '#shared/ui/Sheet';
import { MegaMenu } from './MegaMenu';
import { MegaMenuMobile } from './MegaMenuMobile';
const HeaderLogo = () => {
const { pathname } = useLocation();
const [isSheetOpen, setIsSheetOpen] = useState(false);
const isMobile = useIsMobile();
useEffect(() => {
setIsSheetOpen(false);
}, [pathname]);
return (
<div className="flex grow items-stretch gap-1.5 lg:gap-10">
<div className="flex items-center gap-2.5">
<Link to="/">
<img
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>
{!isMobile ? (
<MegaMenu />
) : (
<Sheet open={isSheetOpen} onOpenChange={setIsSheetOpen}>
<SheetTrigger asChild>
<Button variant="dim" mode="icon">
<Menu />
</Button>
</SheetTrigger>
<SheetContent className="w-68.75 gap-0 p-0" side="left" close={false}>
<SheetHeader className="space-y-0 p-0" />
<SheetBody className="flex grow flex-col p-0">
<MegaMenuMobile />
</SheetBody>
</SheetContent>
</Sheet>
)}
</div>
);
};
export { HeaderLogo };
@@ -0,0 +1,54 @@
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 };
@@ -0,0 +1,114 @@
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>
);
}
@@ -0,0 +1,188 @@
'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>
);
}
@@ -0,0 +1,87 @@
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 };
+95
View File
@@ -0,0 +1,95 @@
// DEMO 7
// https://keenthemes.com/metronic/tailwind/react/demo7
import { addDays, format } from 'date-fns';
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 { LayoutNames } from '#app/config/types';
import { useSettings } from '#app/providers/SettingsProvider';
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 { Toolbar, ToolbarActions, ToolbarHeading } from './components/Toolbar';
export const AdvancedMegaMenuLayout = () => {
const { setOption } = useSettings();
const { pathname } = useLocation();
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(`
[--header-height-default:95px]
data-[sticky-header=on]:[--header-height:60px]
[--header-height:var(--header-height-default)]
[--header-height-mobile:70px]
`);
useEffect(() => {
setOption('layout', LayoutNames.ADVANCE_MEGA_MENU);
}, [setOption]);
return (
<>
<div className="flex grow flex-col in-data-[sticky-header=on]:pt-(--header-height-default)">
<Header />
<div className="grow" role="content">
{!pathname.includes('/public-profile/') && (
<Toolbar>
<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>
)}
<Outlet />
</div>
<Footer />
</div>
</>
);
};
@@ -0,0 +1,42 @@
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} &copy;</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>
);
}
@@ -0,0 +1,53 @@
import { Menu } from 'lucide-react';
import { useEffect, useState } from 'react';
import { Link, useLocation } from 'react-router-dom';
import { toAbsoluteUrl } from '#shared/lib/helpers';
import { Button } from '#shared/ui/Button';
import { Sheet, SheetBody, SheetContent, SheetHeader, SheetTrigger } from '#shared/ui/Sheet';
import { Container } from '#shared/widgets/common/Container';
import { SidebarFooter } from './SidebarFooter';
import { SidebarMenu } from './SidebarMenu';
export function Header() {
const { pathname } = useLocation();
const [isSheetOpen, setIsSheetOpen] = useState(false);
// Close sheet when route changes
useEffect(() => {
setIsSheetOpen(false);
}, [pathname]);
return (
<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">
<Link to="/">
<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}>
<SheetTrigger asChild>
<Button variant="dim" mode="icon">
<Menu />
</Button>
</SheetTrigger>
<SheetContent className="w-(--sidebar-width) gap-0 p-0" side="left" close={false}>
<SheetHeader className="space-y-0 p-0" />
<SheetBody className="flex grow flex-col px-0 pt-5">
<SidebarMenu />
<SidebarFooter />
</SheetBody>
</SheetContent>
</Sheet>
</Container>
</header>
);
}
@@ -0,0 +1,13 @@
import { SidebarFooter } from './SidebarFooter';
import { SidebarHeader } from './SidebarHeader';
import { SidebarMenu } from './SidebarMenu';
export function Sidebar() {
return (
<div className="bg-muted fixed top-0 bottom-0 z-20 flex w-(--sidebar-width) shrink-0 grow flex-col items-stretch">
<SidebarHeader />
<SidebarMenu />
<SidebarFooter />
</div>
);
}
@@ -0,0 +1,47 @@
// import { LayoutGrid, MessageCircleMore } from 'lucide-react';
// 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() {
return (
<div className="flex shrink-0 flex-col items-center gap-5 pb-5">
<div className="flex flex-col 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>
<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>
);
}
@@ -0,0 +1,21 @@
import { Link } from 'react-router-dom';
import { toAbsoluteUrl } from '#shared/lib/helpers';
export function SidebarHeader() {
return (
<div className="hidden shrink-0 items-center justify-center pt-8 pb-3.5 lg:flex">
<Link to="/">
<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>
);
}
@@ -0,0 +1,121 @@
import { BarChart2, Settings, ShoppingCart, User, Users } 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 { createMenuPathMatcher } from '#shared/lib/menuPath';
import { Button } from '#shared/ui/Button';
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuSub, DropdownMenuSubContent, DropdownMenuSubTrigger, DropdownMenuTrigger } from '#shared/ui/DropdownMenu';
export function SidebarMenu() {
const { pathname } = useLocation();
const { hasActiveChild } = useMenu(pathname);
const config = useMenuConfig();
const menuConfig: MenuConfig = [
{
title: 'Boards',
icon: BarChart2,
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) {
return (
<DropdownMenuSub key={index}>
<DropdownMenuSubTrigger data-here={hasActiveChild(item.children) || undefined}>
{item.title}
</DropdownMenuSubTrigger>
<DropdownMenuSubContent className="w-50">
<DropdownMenuLabel>{item.title}</DropdownMenuLabel>
{buildMenuChildren(item.children)}
</DropdownMenuSubContent>
</DropdownMenuSub>
);
}
return (
<DropdownMenuItem key={index} asChild>
<Link to={item.path || '#'}>{item.title}</Link>
</DropdownMenuItem>
);
});
};
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)]">
{buildMenu(menuConfig)}
</div>
);
}
@@ -0,0 +1,82 @@
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 };
+85
View File
@@ -0,0 +1,85 @@
// DEMO 8
// https://keenthemes.com/metronic/tailwind/react/demo8
import { Download } from 'lucide-react';
import { useEffect } from 'react';
import { Link, Outlet } from 'react-router-dom';
import { useBodyClass, useIsMobile } from '@crux-kit/hooks';
import { LayoutNames } from '#app/config/types';
import { useSettings } from '#app/providers/SettingsProvider';
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 { Sidebar } from './components/Sidebar';
import { Toolbar, ToolbarActions, ToolbarHeading } from './components/Toolbar';
export function DropdownMenuLayout() {
const isMobile = useIsMobile();
const { setOption } = useSettings();
// Using the custom hook to set classes on the body
useBodyClass(`
[--header-height:60px]
[--sidebar-width:90px]
bg-muted!
`);
useEffect(() => {
setOption('layout', LayoutNames.DROPDOWN_MENU);
}, [setOption]);
return (
<>
<div className="flex grow">
{isMobile && <Header />}
<div className="flex grow flex-col pt-(--header-height) lg:flex-row lg:pt-0">
{!isMobile && <Sidebar />}
<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]">
<main className="grow" role="content">
<Toolbar>
<ToolbarHeading />
<ToolbarActions>
{/* <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 />
</main>
</div>
<Footer />
</div>
</div>
</div>
</>
);
}
@@ -0,0 +1,42 @@
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} &copy;</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>
);
}
@@ -0,0 +1,45 @@
import { useEffect, useState } from 'react';
import { useScrollPosition } from '@crux-kit/hooks';
import { cn } from '@crux-kit/utils';
import { LayoutNames } from '#app/config/types';
import { useSettings } from '#app/providers/SettingsProvider';
import { Container } from '#shared/widgets/common/Container';
import { HeaderLogo } from './HeaderLogo';
import { HeaderSearch } from './HeaderSearch';
import { HeaderTopbar } from './HeaderTopbar';
export function Header() {
const { settings } = useSettings();
const scrollPosition = useScrollPosition();
const [headerStickyOn, setHeaderStickyOn] = useState(false);
useEffect(() => {
const isSticky =
scrollPosition > settings.layouts[LayoutNames.HORIZONTAL_MENU].headerStickyOffset;
setHeaderStickyOn(isSticky);
}, [scrollPosition, settings]);
useEffect(() => {
if (headerStickyOn === true) {
document.body.setAttribute('data-sticky-header', 'on');
} else {
document.body.removeAttribute('data-sticky-header');
}
}, [headerStickyOn]);
return (
<header
className={cn(
'flex h-(--header-height) shrink-0 items-center transition-[height]',
headerStickyOn &&
'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">
<HeaderLogo />
<HeaderSearch />
<HeaderTopbar />
</Container>
</header>
);
}
@@ -0,0 +1,55 @@
import { Menu } from 'lucide-react';
import { useEffect, useState } from 'react';
import { Link, useLocation } from 'react-router-dom';
import { useIsMobile } from '@crux-kit/hooks';
import { toAbsoluteUrl } from '#shared/lib/helpers';
import { Button } from '#shared/ui/Button';
import { Sheet, SheetBody, SheetContent, SheetHeader, SheetTrigger } from '#shared/ui/Sheet';
import { MegaMenuMobile } from './MegaMenuMobile';
export function HeaderLogo() {
const isMobile = useIsMobile();
const { pathname } = useLocation();
const [isSheetOpen, setIsSheetOpen] = useState(false);
// Close sheet when route changes
useEffect(() => {
setIsSheetOpen(false);
}, [pathname]);
return (
<div className="flex grow items-center gap-1 lg:w-100 lg:grow-0">
<div className="flex shrink-0 items-center gap-2">
<Link to="/">
<img
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>
{isMobile && (
<Sheet open={isSheetOpen} onOpenChange={setIsSheetOpen}>
<SheetTrigger asChild>
<Button variant="dim" mode="icon">
<Menu />
</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>
);
}
@@ -0,0 +1,23 @@
import { Search } from 'lucide-react';
import { Input } from '#shared/ui/Input';
export function HeaderSearch() {
const handleInputChange = () => {};
return (
<div className="hidden items-center md:flex">
<div className="relative">
<Search className="text-muted-foreground absolute start-3.5 top-1/2 size-4 -translate-y-1/2" />
<Input
placeholder="Search"
onChange={handleInputChange}
className="min-w-0 px-9"
value=""
/>
<span className="text-muted-foreground absolute end-3.5 top-1/2 -translate-y-1/2 text-xs">
cmd + /
</span>
</div>
</div>
);
}
@@ -0,0 +1,71 @@
// 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>
);
}
@@ -0,0 +1,122 @@
// 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>
);
}
@@ -0,0 +1,197 @@
'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>
);
}
@@ -0,0 +1,12 @@
import { Container } from '#shared/widgets/common/Container';
import { MegaMenu } from './MegaMenu';
export function Navbar() {
return (
<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">
<MegaMenu />
</Container>
</div>
);
}
@@ -0,0 +1,83 @@
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 };
+97
View File
@@ -0,0 +1,97 @@
// DEMO 9
// https://keenthemes.com/metronic/tailwind/react/demo9
import { addDays, format } from 'date-fns';
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 { LayoutNames } from '#app/config/types';
import { useSettings } from '#app/providers/SettingsProvider';
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 { Navbar } from './components/Navbar';
import { Toolbar, ToolbarActions, ToolbarHeading } from './components/Toolbar';
export function ExtendedHeaderLayout() {
const { setOption } = useSettings();
const { pathname } = useLocation();
const isMobile = useIsMobile();
const [date, setDate] = useState<DateRange | undefined>({
from: new Date(2025, 0, 20),
to: addDays(new Date(2025, 0, 20), 20)
});
useBodyClass(`
[--header-height:78px]
bg-background!
`);
useEffect(() => {
setOption('layout', LayoutNames.EXTENDED_HEADER);
}, [setOption]);
return (
<>
<div className="flex grow flex-col in-data-[sticky-header=on]:pt-(--header-height)">
<Header />
{!isMobile && <Navbar />}
<main className="flex grow flex-col" role="content">
{!pathname.includes('/public-profile/') && (
<Toolbar>
<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>
)}
<Outlet />
<Footer />
</main>
</div>
</>
);
}
@@ -0,0 +1,42 @@
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} &copy;</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>
);
}
@@ -0,0 +1,54 @@
import { Menu } from 'lucide-react';
import { useEffect, useState } from 'react';
import { Link, useLocation } from 'react-router-dom';
import { useIsMobile } from '@crux-kit/hooks';
import { toAbsoluteUrl } from '#shared/lib/helpers';
import { Button } from '#shared/ui/Button';
import { Sheet, SheetBody, SheetContent, SheetHeader, SheetTrigger } from '#shared/ui/Sheet';
import { Container } from '#shared/widgets/common/Container';
import { SidebarFooter } from './SidebarFooter';
import { SidebarHeader } from './SidebarHeader';
import { SidebarMenu } from './SidebarMenu';
export function Header() {
const isMobile = useIsMobile();
const { pathname } = useLocation();
const [isSheetOpen, setIsSheetOpen] = useState(false);
// Close sheet when route changes
useEffect(() => {
setIsSheetOpen(false);
}, [pathname]);
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">
<Container className="flex flex-wrap items-center justify-between gap-3">
<Link to="/">
<img
src={toAbsoluteUrl('/media/app/mini-logo-circle-success.svg')}
className="h-8.5"
alt=""
/>
</Link>
{isMobile && (
<Sheet open={isSheetOpen} onOpenChange={setIsSheetOpen}>
<SheetTrigger asChild>
<Button variant="dim" mode="icon" className="hover:text-white">
<Menu />
</Button>
</SheetTrigger>
<SheetContent className="dark w-62.5 gap-0 p-0" side="left" close={false}>
<SheetHeader className="space-y-0 p-0" />
<SheetBody className="flex grow flex-col overflow-y-auto p-0">
<SidebarHeader />
<SidebarMenu />
<SidebarFooter />
</SheetBody>
</SheetContent>
</Sheet>
)}
</Container>
</header>
);
}
@@ -0,0 +1,13 @@
import { SidebarFooter } from './SidebarFooter';
import { SidebarHeader } from './SidebarHeader';
import { SidebarMenu } from './SidebarMenu';
export function Sidebar() {
return (
<div className="dark fixed top-0 bottom-0 z-20 w-(--sidebar-width) shrink-0 flex-col items-stretch lg:flex">
<SidebarHeader />
<SidebarMenu />
<SidebarFooter />
</div>
);
}
@@ -0,0 +1,48 @@
// import { LayoutGrid, MessageCircleMore } from 'lucide-react';
// 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() {
return (
<div className="flex-center mb-3.5 flex shrink-0 justify-between ps-4 pe-3.5">
<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 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>
);
}
@@ -0,0 +1,84 @@
// import { useEffect, useState } from 'react';
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() {
// 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 (
<div className="flex flex-col gap-2.5">
<div className="flex h-17.5 items-center justify-between gap-2.5 px-3.5">
<Link to="/">
<img
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>
);
}
@@ -0,0 +1,11 @@
import { SidebarMenuPrimary } from './SidebarMenuPrimary';
import { SidebarMenuSecondary } from './SidebarMenuSecondary';
export function SidebarMenu() {
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)]">
<SidebarMenuPrimary />
<SidebarMenuSecondary />
</div>
);
}
@@ -0,0 +1,138 @@
'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 { createMenuPathMatcher } from '#shared/lib/menuPath';
import { AccordionMenu, AccordionMenuClassNames, AccordionMenuGroup, AccordionMenuItem, AccordionMenuLabel, AccordionMenuSub, AccordionMenuSubContent, AccordionMenuSubTrigger } from '#shared/ui/AccordionMenu';
export function SidebarMenuPrimary() {
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: 'space-y-2.5 px-3.5',
group: 'gap-px',
label: 'uppercase text-xs font-medium text-muted-foreground pt-2.25 pb-px',
separator: '',
item: 'h-10 border-0 hover:bg-transparent border border-transparent text-white/70 hover:text-white data-[selected=true]:text-white data-[selected=true]:bg-secondary/70',
sub: '',
subTrigger:
'h-10 border-0 hover:bg-transparent border border-transparent text-white/70 hover:text-white data-[selected=true]:text-white data-[selected=true]:bg-secondary/70',
subContent: 'p-0',
indicator: ''
};
const buildMenu = (items: MenuConfig): JSX.Element[] => {
return items.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 <></>;
}
});
};
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}
>
<AccordionMenuLabel className="text-xs uppercase">Pages</AccordionMenuLabel>
{buildMenu(menuConfig)}
</AccordionMenu>
);
}
@@ -0,0 +1,44 @@
import { Link } from 'react-router-dom';
import { toAbsoluteUrl } from '#shared/lib/helpers';
const items = [
{
logo: 'x-dark.svg',
title: '@keenthemes',
path: 'https://keenthemes.com/metronic/tailwind/docs/'
},
{
logo: 'slack.svg',
title: '@keenthemes_hub',
path: 'https://github.com/keenthemes/'
},
{
logo: 'figma.svg',
title: 'metronic',
path: 'https://keenthemes.com/metronic/tailwind/docs/'
}
];
export function SidebarMenuSecondary() {
return (
<div>
<h3 className="text-muted-foreground mb-3 inline-block ps-5 text-xs uppercase">Outline</h3>
<div className="flex w-full flex-col gap-1.5 px-3.5">
{items.map((item, index) => (
<Link key={index} to={item.path} className="group flex items-center gap-2.5 px-1 py-1">
<span className="flex size-7 items-center justify-center rounded-md bg-black">
<img
src={toAbsoluteUrl(`/media/brand-logos/${item.logo}`)}
className="size-3.5"
alt={item.title}
/>
</span>
<span className="text-secondary-foreground group-hover:text-mono text-sm">
{item.title}
</span>
</Link>
))}
</div>
</div>
);
}
@@ -0,0 +1,83 @@
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="flex flex-wrap items-center justify-between gap-2 pt-3 pb-7 lg:gap-5">
{children}
</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 };
@@ -0,0 +1,49 @@
'use client';
import { Calendar, ChevronDown } from 'lucide-react';
import { Link } from 'react-router-dom';
import { Button } from '#shared/ui/Button';
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '#shared/ui/DropdownMenu';
export function ToolbarMenu() {
const months = [
{ title: 'January, 2024' },
{ title: 'February, 2024' },
{ title: 'March, 2024', active: true },
{ title: 'April, 2024' },
{ title: 'May, 2024' },
{ title: 'June, 2024' },
{ title: 'July, 2024' },
{ title: 'August, 2024' },
{ title: 'September, 2024' },
{ title: 'October, 2024' },
{ title: 'November, 2024' },
{ title: 'December, 2024' }
];
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="flex flex-nowrap items-center gap-1">
<Calendar className="h-4 w-4" />
<span className="hidden whitespace-nowrap md:inline">September, 2024</span>
<span className="inline whitespace-nowrap md:hidden">Sep, 2024</span>
<ChevronDown className="ml-1 h-3 w-3 lg:ml-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="max-h-62.5 w-48 overflow-y-auto">
{months.map((item, index) => (
<DropdownMenuItem
key={index}
className={item.active ? 'bg-muted font-medium' : ''}
asChild
>
<Link to="/" className="w-full">
{item.title}
</Link>
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
);
}
+101
View File
@@ -0,0 +1,101 @@
// DEMO 10
// https://keenthemes.com/metronic/tailwind/react/demo10
import { addDays, format } from 'date-fns';
import { CalendarDays, Download } from 'lucide-react';
import { useEffect, useState } from 'react';
import { DateRange } from 'react-day-picker';
import { Link, Outlet } from 'react-router-dom';
import { useBodyClass, useIsMobile } from '@crux-kit/hooks';
import { LayoutNames } from '#app/config/types';
import { useSettings } from '#app/providers/SettingsProvider';
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 { Sidebar } from './components/Sidebar';
import { Toolbar, ToolbarActions, ToolbarHeading } from './components/Toolbar';
export const DarkSidebarLayout = () => {
const { setOption } = useSettings();
const isMobile = useIsMobile();
const [date, setDate] = useState<DateRange | undefined>({
from: new Date(2025, 0, 20),
to: addDays(new Date(2025, 0, 20), 20)
});
useBodyClass(`
[--header-height:60px]
[--sidebar-width:270px]
bg-mono! dark:bg-background!
`);
useEffect(() => {
setOption('layout', LayoutNames.DARK_SIDEBAR);
}, [setOption]);
return (
<>
<div className="flex grow">
{isMobile && <Header />}
<div className="flex grow flex-col pt-(--header-height) lg:flex-row lg:pt-0">
{!isMobile && <Sidebar />}
<div className="bg-background border-input flex grow flex-col border lg:ms-(--sidebar-width) lg:rounded-s-xl">
<div className="kt-scrollable-y-auto flex grow flex-col pt-5 lg:[scrollbar-width:auto]">
<main className="grow" role="content">
<Toolbar>
<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>
<Outlet />
</main>
<Footer />
</div>
</div>
</div>
</div>
</>
);
};