Add initial project files
This commit is contained in:
@@ -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} ©</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
@@ -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} ©</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 };
|
||||
@@ -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} ©</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 };
|
||||
@@ -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} ©</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>
|
||||
);
|
||||
}
|
||||
+161
@@ -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>
|
||||
);
|
||||
}
|
||||
+183
@@ -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>
|
||||
);
|
||||
}
|
||||
+186
@@ -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>
|
||||
);
|
||||
}
|
||||
+13
@@ -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 };
|
||||
@@ -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} ©</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>
|
||||
);
|
||||
}
|
||||
+171
@@ -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>
|
||||
);
|
||||
}
|
||||
+181
@@ -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 };
|
||||
@@ -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} ©</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>
|
||||
);
|
||||
}
|
||||
+142
@@ -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
@@ -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
@@ -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} ©</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 };
|
||||
+54
@@ -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>
|
||||
);
|
||||
}
|
||||
+188
@@ -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 };
|
||||
@@ -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} ©</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
@@ -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} ©</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>
|
||||
);
|
||||
}
|
||||
+197
@@ -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 };
|
||||
@@ -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} ©</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>
|
||||
);
|
||||
}
|
||||
+138
@@ -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>
|
||||
);
|
||||
}
|
||||
+44
@@ -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
@@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user