420 lines
15 KiB
TypeScript

'use client';
import * as React from 'react';
import { useEffect, useState, useRef, useId } from 'react';
import { SearchIcon, BellIcon, UserIcon, ChevronDownIcon } from 'lucide-react';
import { Button } from '@repo/shadcn-ui/components/ui/button';
import { Input } from '@repo/shadcn-ui/components/ui/input';
import {
NavigationMenu,
NavigationMenuItem,
NavigationMenuLink,
NavigationMenuList,
} from '@repo/shadcn-ui/components/ui/navigation-menu';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@repo/shadcn-ui/components/ui/popover';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@repo/shadcn-ui/components/ui/dropdown-menu';
import { Avatar, AvatarFallback, AvatarImage } from '@repo/shadcn-ui/components/ui/avatar';
import { Badge } from '@repo/shadcn-ui/components/ui/badge';
import { cn } from '@repo/shadcn-ui/lib/utils';
import type { ComponentProps } from 'react';
// Simple logo component for the navbar
const Logo = (props: React.SVGAttributes<SVGElement>) => {
return (
<svg width='1em' height='1em' viewBox='0 0 324 323' fill='currentColor' xmlns='http://www.w3.org/2000/svg' {...props}>
<rect
x='88.1023'
y='144.792'
width='151.802'
height='36.5788'
rx='18.2894'
transform='rotate(-38.5799 88.1023 144.792)'
fill='currentColor'
/>
<rect
x='85.3459'
y='244.537'
width='151.802'
height='36.5788'
rx='18.2894'
transform='rotate(-38.5799 85.3459 244.537)'
fill='currentColor'
/>
</svg>
);
};
// Hamburger icon component
const HamburgerIcon = ({ className, ...props }: React.SVGAttributes<SVGElement>) => (
<svg
className={cn('pointer-events-none', className)}
width={16}
height={16}
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
xmlns="http://www.w3.org/2000/svg"
{...props}
>
<path
d="M4 12L20 12"
className="origin-center -translate-y-[7px] transition-all duration-300 ease-[cubic-bezier(.5,.85,.25,1.1)] group-aria-expanded:translate-x-0 group-aria-expanded:translate-y-0 group-aria-expanded:rotate-[315deg]"
/>
<path
d="M4 12H20"
className="origin-center transition-all duration-300 ease-[cubic-bezier(.5,.85,.25,1.8)] group-aria-expanded:rotate-45"
/>
<path
d="M4 12H20"
className="origin-center translate-y-[7px] transition-all duration-300 ease-[cubic-bezier(.5,.85,.25,1.1)] group-aria-expanded:translate-y-0 group-aria-expanded:rotate-[135deg]"
/>
</svg>
);
// Notification Menu Component
const NotificationMenu = ({
notificationCount = 3,
onItemClick
}: {
notificationCount?: number;
onItemClick?: (item: string) => void;
}) => (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-9 w-9 relative">
<BellIcon className="h-4 w-4" />
{notificationCount > 0 && (
<Badge className="absolute -top-1 -right-1 h-5 w-5 flex items-center justify-center p-0 text-xs">
{notificationCount > 9 ? '9+' : notificationCount}
</Badge>
)}
<span className="sr-only">Notifications</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-80">
<DropdownMenuLabel>Notifications</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => onItemClick?.('notification1')}>
<div className="flex flex-col gap-1">
<p className="text-sm font-medium">New message received</p>
<p className="text-xs text-muted-foreground">2 minutes ago</p>
</div>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onItemClick?.('notification2')}>
<div className="flex flex-col gap-1">
<p className="text-sm font-medium">System update available</p>
<p className="text-xs text-muted-foreground">1 hour ago</p>
</div>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onItemClick?.('notification3')}>
<div className="flex flex-col gap-1">
<p className="text-sm font-medium">Weekly report ready</p>
<p className="text-xs text-muted-foreground">3 hours ago</p>
</div>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => onItemClick?.('view-all')}>
View all notifications
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
// User Menu Component
const UserMenu = ({
userName = 'John Doe',
userEmail = 'john@example.com',
userAvatar,
onItemClick
}: {
userName?: string;
userEmail?: string;
userAvatar?: string;
onItemClick?: (item: string) => void;
}) => (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="h-9 px-2 py-0 hover:bg-accent hover:text-accent-foreground">
<Avatar className="h-7 w-7">
<AvatarImage src={userAvatar} alt={userName} />
<AvatarFallback className="text-xs">
{userName.split(' ').map(n => n[0]).join('')}
</AvatarFallback>
</Avatar>
<ChevronDownIcon className="h-3 w-3 ml-1" />
<span className="sr-only">User menu</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuLabel>
<div className="flex flex-col space-y-1">
<p className="text-sm font-medium leading-none">{userName}</p>
<p className="text-xs leading-none text-muted-foreground">
{userEmail}
</p>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => onItemClick?.('profile')}>
Profile
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onItemClick?.('settings')}>
Settings
</DropdownMenuItem>
<DropdownMenuItem onClick={() => onItemClick?.('billing')}>
Billing
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => onItemClick?.('logout')}>
Log out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
// Types
export interface Navbar08NavItem {
href?: string;
label: string;
active?: boolean;
}
export interface Navbar08Props extends React.HTMLAttributes<HTMLElement> {
logo?: React.ReactNode;
logoHref?: string;
navigationLinks?: Navbar08NavItem[];
searchPlaceholder?: string;
searchShortcut?: string;
userName?: string;
userEmail?: string;
userAvatar?: string;
notificationCount?: number;
onNavItemClick?: (href: string) => void;
onSearchSubmit?: (query: string) => void;
onNotificationItemClick?: (item: string) => void;
onUserItemClick?: (item: string) => void;
}
// Default navigation links
const defaultNavigationLinks: Navbar08NavItem[] = [
{ href: '#', label: 'Home', active: true },
{ href: '#', label: 'Features' },
{ href: '#', label: 'Pricing' },
{ href: '#', label: 'About' },
];
export const Navbar08 = React.forwardRef<HTMLElement, Navbar08Props>(
(
{
className,
logo = <Logo />,
logoHref = '#',
navigationLinks = defaultNavigationLinks,
searchPlaceholder = 'Search...',
searchShortcut = '⌘K',
userName = 'John Doe',
userEmail = 'john@example.com',
userAvatar,
notificationCount = 3,
onNavItemClick,
onSearchSubmit,
onNotificationItemClick,
onUserItemClick,
...props
},
ref
) => {
const [isMobile, setIsMobile] = useState(false);
const containerRef = useRef<HTMLElement>(null);
const searchId = useId();
useEffect(() => {
const checkWidth = () => {
if (containerRef.current) {
const width = containerRef.current.offsetWidth;
setIsMobile(width < 768); // 768px is md breakpoint
}
};
checkWidth();
const resizeObserver = new ResizeObserver(checkWidth);
if (containerRef.current) {
resizeObserver.observe(containerRef.current);
}
return () => {
resizeObserver.disconnect();
};
}, []);
// Combine refs
const combinedRef = React.useCallback((node: HTMLElement | null) => {
containerRef.current = node;
if (typeof ref === 'function') {
ref(node);
} else if (ref) {
ref.current = node;
}
}, [ref]);
const handleSearchSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const query = formData.get('search') as string;
if (onSearchSubmit) {
onSearchSubmit(query);
}
};
return (
<header
ref={combinedRef}
className={cn(
'sticky top-0 z-50 w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60 px-4 md:px-6 [&_*]:no-underline',
className
)}
{...props}
>
<div className="container mx-auto max-w-screen-2xl">
{/* Top section */}
<div className="flex h-16 items-center justify-between gap-4">
{/* Left side */}
<div className="flex flex-1 items-center gap-2">
{/* Mobile menu trigger */}
{isMobile && (
<Popover>
<PopoverTrigger asChild>
<Button
className="group h-8 w-8 hover:bg-accent hover:text-accent-foreground"
variant="ghost"
size="icon"
>
<HamburgerIcon />
</Button>
</PopoverTrigger>
<PopoverContent align="start" className="w-64 p-1">
<NavigationMenu className="max-w-none">
<NavigationMenuList className="flex-col items-start gap-0">
{navigationLinks.map((link, index) => (
<NavigationMenuItem key={index} className="w-full">
<button
onClick={(e) => {
e.preventDefault();
if (onNavItemClick && link.href) onNavItemClick(link.href);
}}
className={cn(
'flex w-full items-center rounded-md px-3 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground cursor-pointer no-underline',
link.active && 'bg-accent text-accent-foreground'
)}
>
{link.label}
</button>
</NavigationMenuItem>
))}
</NavigationMenuList>
</NavigationMenu>
</PopoverContent>
</Popover>
)}
{/* Logo */}
<div className="flex items-center">
<button
onClick={(e) => e.preventDefault()}
className="flex items-center space-x-2 text-primary hover:text-primary/90 transition-colors cursor-pointer"
>
<div className="text-2xl">
{logo}
</div>
<span className="hidden font-bold text-xl sm:inline-block">shadcn.io</span>
</button>
</div>
</div>
{/* Middle area */}
<div className="grow">
{/* Search form */}
<form onSubmit={handleSearchSubmit} className="relative mx-auto w-full max-w-xs">
<Input
id={searchId}
name="search"
className="peer h-8 ps-8 pe-10"
placeholder={searchPlaceholder}
type="search"
/>
<div className="text-muted-foreground/80 pointer-events-none absolute inset-y-0 start-0 flex items-center justify-center ps-2 peer-disabled:opacity-50">
<SearchIcon size={16} />
</div>
<div className="text-muted-foreground pointer-events-none absolute inset-y-0 end-0 flex items-center justify-center pe-2">
<kbd className="text-muted-foreground/70 inline-flex h-5 max-h-full items-center rounded border px-1 font-[inherit] text-[0.625rem] font-medium">
{searchShortcut}
</kbd>
</div>
</form>
</div>
{/* Right side */}
<div className="flex flex-1 items-center justify-end gap-2">
{/* Notification */}
<NotificationMenu
notificationCount={notificationCount}
onItemClick={onNotificationItemClick}
/>
{/* User menu */}
<UserMenu
userName={userName}
userEmail={userEmail}
userAvatar={userAvatar}
onItemClick={onUserItemClick}
/>
</div>
</div>
{/* Bottom navigation */}
{!isMobile && (
<div className="border-t py-2">
{/* Navigation menu */}
<NavigationMenu>
<NavigationMenuList className="gap-2">
{navigationLinks.map((link, index) => (
<NavigationMenuItem key={index}>
<NavigationMenuLink
href={link.href}
onClick={(e) => {
e.preventDefault();
if (onNavItemClick && link.href) onNavItemClick(link.href);
}}
className={cn(
'text-muted-foreground hover:text-primary py-1.5 font-medium transition-colors cursor-pointer group inline-flex h-10 w-max items-center justify-center rounded-md bg-background px-4 py-2 text-sm focus:bg-accent focus:text-accent-foreground focus:outline-none disabled:pointer-events-none disabled:opacity-50',
link.active && 'text-primary'
)}
data-active={link.active}
>
{link.label}
</NavigationMenuLink>
</NavigationMenuItem>
))}
</NavigationMenuList>
</NavigationMenu>
</div>
)}
</div>
</header>
);
}
);
Navbar08.displayName = 'Navbar08';
export { Logo, HamburgerIcon, NotificationMenu, UserMenu };