feat: enhance navbar with nested menu, mobile responsiveness, and marketing links

This commit is contained in:
javayhu 2025-03-01 14:15:16 +08:00
parent 4d60d96180
commit 7ab78c95dc
9 changed files with 878 additions and 95 deletions

View File

@ -20,6 +20,7 @@
"@radix-ui/react-icons": "^1.3.2",
"@radix-ui/react-label": "^2.1.2",
"@radix-ui/react-navigation-menu": "^1.2.5",
"@radix-ui/react-portal": "^1.1.4",
"@radix-ui/react-separator": "^1.1.2",
"@radix-ui/react-slot": "^1.1.2",
"@radix-ui/react-tabs": "^1.1.3",
@ -35,6 +36,7 @@
"date-fns": "^4.1.0",
"dotenv": "^16.4.7",
"drizzle-orm": "^0.39.3",
"framer-motion": "^12.4.7",
"geist": "^1.3.1",
"lucide-react": "^0.475.0",
"mdast-util-toc": "^7.1.0",
@ -46,6 +48,7 @@
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-hook-form": "^7.54.2",
"react-remove-scroll": "^2.6.3",
"rehype-autolink-headings": "^7.1.0",
"rehype-pretty-code": "^0.14.0",
"rehype-slug": "^6.0.0",

25
pnpm-lock.yaml generated
View File

@ -41,6 +41,9 @@ importers:
'@radix-ui/react-navigation-menu':
specifier: ^1.2.5
version: 1.2.5(@types/react-dom@19.0.3(@types/react@19.0.9))(@types/react@19.0.9)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
'@radix-ui/react-portal':
specifier: ^1.1.4
version: 1.1.4(@types/react-dom@19.0.3(@types/react@19.0.9))(@types/react@19.0.9)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
'@radix-ui/react-separator':
specifier: ^1.1.2
version: 1.1.2(@types/react-dom@19.0.3(@types/react@19.0.9))(@types/react@19.0.9)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
@ -86,6 +89,9 @@ importers:
drizzle-orm:
specifier: ^0.39.3
version: 0.39.3(@neondatabase/serverless@0.10.4)(@opentelemetry/api@1.9.0)(@types/pg@8.11.11)(kysely@0.27.5)(pg@8.13.3)
framer-motion:
specifier: ^12.4.7
version: 12.4.7(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
geist:
specifier: ^1.3.1
version: 1.3.1(next@15.1.7(@opentelemetry/api@1.9.0)(react-dom@19.0.0(react@19.0.0))(react@19.0.0))
@ -119,6 +125,9 @@ importers:
react-hook-form:
specifier: ^7.54.2
version: 7.54.2(react@19.0.0)
react-remove-scroll:
specifier: ^2.6.3
version: 2.6.3(@types/react@19.0.9)(react@19.0.0)
rehype-autolink-headings:
specifier: ^7.1.0
version: 7.1.0
@ -2355,8 +2364,8 @@ packages:
resolution: {integrity: sha512-wzsgA6WOq+09wrU1tsJ09udeR/YZRaeArL9e1wPbFg3GG2yDnC2ldKpxs4xunpFF9DgqCqOIra3bc1HWrJ37Ww==}
engines: {node: '>=0.4.x'}
framer-motion@12.4.3:
resolution: {integrity: sha512-rsMeO7w3dKyNG09o3cGwSH49iHU+VgDmfSSfsX+wfkO3zDA6WWkh4sUsMXd155YROjZP+7FTIhDrBYfgZeHjKQ==}
framer-motion@12.4.7:
resolution: {integrity: sha512-VhrcbtcAMXfxlrjeHPpWVu2+mkcoR31e02aNSR7OUS/hZAciKa8q6o3YN2mA1h+jjscRsSyKvX6E1CiY/7OLMw==}
peerDependencies:
'@emotion/is-prop-valid': '*'
react: ^18.0.0 || ^19.0.0
@ -2811,8 +2820,8 @@ packages:
resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==}
engines: {node: '>=16 || 14 >=14.17'}
motion-dom@12.0.0:
resolution: {integrity: sha512-CvYd15OeIR6kHgMdonCc1ihsaUG4MYh/wrkz8gZ3hBX/uamyZCXN9S9qJoYF03GqfTt7thTV/dxnHYX4+55vDg==}
motion-dom@12.4.5:
resolution: {integrity: sha512-Q2xmhuyYug1CGTo0jdsL05EQ4RhIYXlggFS/yPhQQRNzbrhjKQ1tbjThx5Plv68aX31LsUQRq4uIkuDxdO5vRQ==}
motion-utils@12.0.0:
resolution: {integrity: sha512-MNFiBKbbqnmvOjkPyOKgHUp3Q6oiokLkI1bEwm5QA28cxMZrv0CbbBGDNmhF6DIXsi1pCQBSs0dX8xjeER1tmA==}
@ -5562,9 +5571,9 @@ snapshots:
format@0.2.2: {}
framer-motion@12.4.3(react-dom@19.0.0(react@19.0.0))(react@19.0.0):
framer-motion@12.4.7(react-dom@19.0.0(react@19.0.0))(react@19.0.0):
dependencies:
motion-dom: 12.0.0
motion-dom: 12.4.5
motion-utils: 12.0.0
tslib: 2.8.1
optionalDependencies:
@ -6361,7 +6370,7 @@ snapshots:
minipass@7.1.2: {}
motion-dom@12.0.0:
motion-dom@12.4.5:
dependencies:
motion-utils: 12.0.0
@ -6369,7 +6378,7 @@ snapshots:
motion@12.4.3(react-dom@19.0.0(react@19.0.0))(react@19.0.0):
dependencies:
framer-motion: 12.4.3(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
framer-motion: 12.4.7(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
tslib: 2.8.1
optionalDependencies:
react: 19.0.0

View File

@ -1,5 +1,5 @@
import { Footer } from "@/components/layout/footer";
import { Navbar } from "@/components/layout/navbar";
import { Navbar } from "@/components/marketing/navbar";
import { marketingConfig } from "@/config/marketing";
interface MarketingLayoutProps {
@ -11,7 +11,8 @@ export default async function MarketingLayout({
}: MarketingLayoutProps) {
return (
<div className="flex flex-col min-h-screen">
<Navbar scroll={true} config={marketingConfig} />
{/* <Navbar scroll={true} config={marketingConfig} /> */}
<Navbar scroll={false} config={marketingConfig} />
<main className="flex-1">{children}</main>

View File

@ -3,28 +3,29 @@
import { LoginWrapper } from "@/components/auth/login-button";
import Container from "@/components/container";
import { Icons } from "@/components/icons/icons";
import { ModeToggle } from "@/components/layout/mode-toggle";
import { UserButton } from "@/components/layout/user-button";
import { Logo } from "@/components/logo";
import { Button } from "@/components/ui/button";
import {
NavigationMenu,
NavigationMenuContent,
NavigationMenuItem,
NavigationMenuLink,
NavigationMenuList,
NavigationMenuTrigger,
navigationMenuTriggerStyle,
} from "@/components/ui/navigation-menu";
import { Sheet, SheetContent, SheetTitle, SheetHeader, SheetTrigger } from "@/components/ui/sheet";
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from "@/components/ui/sheet";
import { siteConfig } from "@/config/site";
import { useScroll } from "@/hooks/use-scroll";
import { authClient } from "@/lib/auth-client";
import { cn } from "@/lib/utils";
import type { DashboardConfig, MarketingConfig } from "@/types";
import { ArrowRightIcon, MenuIcon } from "lucide-react";
import type { DashboardConfig, MarketingConfig, NestedNavItem } from "@/types";
import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from "@radix-ui/react-accordion";
import { MenuIcon } from "lucide-react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useEffect, useState } from "react";
import React from "react";
import { Logo } from "@/components/logo";
import { authClient } from "@/lib/auth-client";
interface NavBarProps {
scroll?: boolean;
@ -39,10 +40,9 @@ export function Navbar({ scroll = false, config }: NavBarProps) {
const pathname = usePathname();
// console.log(`Navbar, pathname: ${pathname}`);
const links = config.menus;
// console.log(`Navbar, links: ${links.map((link) => link.title)}`);
const menus = config.menus;
const isLinkActive = (href: string) => {
const isMenuActive = (href: string) => {
if (href === "/") {
return pathname === "/";
}
@ -82,27 +82,10 @@ export function Navbar({ scroll = false, config }: NavBarProps) {
{/* links */}
<div className="flex-1 flex justify-center">
{links && links.length > 0 ? (
{menus && menus.length > 0 ? (
<NavigationMenu>
<NavigationMenuList>
{links.map((item) => (
<NavigationMenuItem key={item.title}>
<NavigationMenuLink
href={item.disabled ? "#" : item.href}
target={item.external ? "_blank" : ""}
className={cn(
navigationMenuTriggerStyle(),
"px-4 bg-transparent focus:bg-transparent text-base",
isLinkActive(item.href)
? "text-foreground font-semibold"
: "text-foreground/60",
item.disabled && "cursor-not-allowed opacity-80",
)}
>
{item.title}
</NavigationMenuLink>
</NavigationMenuItem>
))}
{menus.map((item) => renderMenuItem(item))}
</NavigationMenuList>
</NavigationMenu>
) : null}
@ -117,17 +100,17 @@ export function Navbar({ scroll = false, config }: NavBarProps) {
) : (
<LoginWrapper mode="modal" asChild>
<Button
className="flex gap-2 px-5 rounded-full"
className="flex gap-2"
variant="default"
size="default"
size="sm"
>
<span>Sign In</span>
<ArrowRightIcon className="size-4" />
<span>Login</span>
{/* <ArrowRightIcon className="size-4" /> */}
</Button>
</LoginWrapper>
)}
<ModeToggle />
{/* <ModeToggle /> */}
</div>
</Container>
</header>
@ -148,47 +131,26 @@ export function Navbar({ scroll = false, config }: NavBarProps) {
<span className="sr-only">Toggle navigation menu</span>
</Button>
</SheetTrigger>
<SheetContent side="left" className="flex flex-col p-0">
<SheetContent side="left" className="overflow-y-auto">
<SheetHeader>
<SheetTitle />
<SheetTitle>
<a href="/"
className="flex items-center space-x-2"
onClick={() => setOpen(false)}
>
<Logo />
<span className="text-xl font-bold">{siteConfig.name}</span>
</a>
</SheetTitle>
</SheetHeader>
<div className="flex h-screen flex-col">
{/* logo */}
<a href="/"
className="flex items-center space-x-2 pl-4 pt-4"
onClick={() => setOpen(false)}
<div className="my-6 flex flex-col gap-6">
<Accordion
type="single"
collapsible
className="flex w-full flex-col gap-4"
>
<Logo />
<span className="text-xl font-bold">{siteConfig.name}</span>
</a>
<nav className="flex flex-1 flex-col gap-2 p-2 pt-8 font-medium">
{links.map((item) => {
const Icon = Icons[item.icon || "arrowRight"];
return (
<Link
key={item.title}
href={item.disabled ? "#" : item.href}
target={item.external ? "_blank" : ""}
onClick={() => {
if (!item.disabled) setOpen(false);
}}
className={cn(
"flex items-center rounded-md gap-2 p-2 text-sm font-medium hover:bg-muted",
isLinkActive(item.href)
? "bg-muted text-foreground"
: "text-muted-foreground hover:text-foreground",
item.disabled &&
"cursor-not-allowed opacity-80 hover:bg-transparent hover:text-muted-foreground",
)}
>
<Icon className="size-5" />
{item.title}
</Link>
);
})}
</nav>
{menus.map((item) => renderMobileMenuItem(item))}
</Accordion>
</div>
</SheetContent>
</Sheet>
@ -204,7 +166,7 @@ export function Navbar({ scroll = false, config }: NavBarProps) {
</a>
</div>
{/* mobile navbar right show sign in or account */}
{/* mobile navbar right show sign in or user button */}
<div className="flex items-center gap-x-4">
{user ? (
<div className="flex items-center">
@ -213,20 +175,116 @@ export function Navbar({ scroll = false, config }: NavBarProps) {
) : (
<LoginWrapper mode="redirect" asChild>
<Button
className="flex gap-2 px-5 rounded-full"
className="flex gap-2"
variant="default"
size="default"
size="sm"
>
<span>Sign In</span>
<ArrowRightIcon className="size-4" />
<span>Login</span>
{/* <ArrowRightIcon className="size-4" /> */}
</Button>
</LoginWrapper>
)}
<ModeToggle />
{/* <ModeToggle /> */}
</div>
</div>
</header>
</div>
);
}
const renderMenuItem = (item: NestedNavItem) => {
if (item.items) {
return (
<NavigationMenuItem key={item.title} className="text-muted-foreground">
<NavigationMenuTrigger className="text-base">
{item.title}
</NavigationMenuTrigger>
<NavigationMenuContent>
<ul className="w-80 p-4">
<NavigationMenuLink>
{item.items.map((subItem) => {
const CustomMenuIcon = Icons[subItem.icon || "arrowRight"];
return (
<li key={subItem.title}>
<a
className="flex items-center select-none gap-4 rounded-md p-4 leading-none no-underline outline-none transition-colors hover:bg-muted hover:text-accent-foreground"
href={subItem.href}
>
{subItem.icon && <CustomMenuIcon className="size-4 shrink-0" />}
<div>
<div className="text-base text-foreground/60 hover:text-foreground">
{subItem.title}
</div>
</div>
</a>
</li>
);
})}
</NavigationMenuLink>
</ul>
</NavigationMenuContent>
</NavigationMenuItem>
);
}
return (
<NavigationMenuItem key={item.title}>
<Link
href={item.disabled ? "#" : item.href || "#"}
target={item.external ? "_blank" : ""}
className={cn(
navigationMenuTriggerStyle(),
"px-4 bg-transparent focus:bg-transparent text-base",
"text-foreground/60 hover:text-foreground",
item.disabled && "cursor-not-allowed opacity-80"
)}
>
{item.title}
</Link>
</NavigationMenuItem>
);
};
const renderMobileMenuItem = (item: NestedNavItem) => {
console.log(`renderMobileMenuItem, item:`, item, `, items:`, item.items);
if (item.items) {
return (
<AccordionItem key={item.title} value={item.title} className="border-b-0">
<AccordionTrigger className="py-0 hover:no-underline">
{item.title}
</AccordionTrigger>
<AccordionContent className="mt-2">
{item.items.map((subItem) => {
const CustomMenuIcon = Icons[subItem.icon || "arrowRight"];
return (
<Link
key={subItem.title}
className="flex select-none gap-4 rounded-md p-3 leading-none outline-none transition-colors hover:bg-muted hover:text-accent-foreground"
href={subItem.disabled ? "#" : subItem.href}
>
{subItem.icon && <CustomMenuIcon className="size-4 shrink-0" />}
<div>
<div className="text-sm font-semibold">{subItem.title}</div>
</div>
</Link>
);
})}
</AccordionContent>
</AccordionItem>
);
}
const CustomMenuIcon = Icons[item.icon || "arrowRight"];
return (
<Link
key={item.title}
href={item.disabled ? "#" : item.href || "#"}
target={item.external ? "_blank" : ""}
className="flex items-center rounded-md gap-2 p-2 text-sm font-medium hover:bg-muted text-muted-foreground hover:text-foreground"
>
{item.icon && <CustomMenuIcon className="size-4 shrink-0" />}
{item.title}
</Link>
);
};

View File

@ -0,0 +1,195 @@
import * as React from 'react';
import { CubeIcon, PaperPlaneIcon } from '@radix-ui/react-icons';
import {
BookIcon,
BookOpenIcon,
CircuitBoardIcon,
CuboidIcon,
FileBarChartIcon,
LayoutIcon,
PlayIcon
} from 'lucide-react';
import { Routes } from '@/constants/routes';
import { FacebookIcon } from '@/components/icons/facebook';
import { InstagramIcon } from '@/components/icons/instagram';
import { LinkedInIcon } from '@/components/icons/linkedin';
import { TikTokIcon } from '@/components/icons/tiktok';
import { XTwitterIcon } from '@/components/icons/x';
export const MENU_LINKS = [
{
title: 'Features',
href: Routes.Pricing,
external: false
},
{
title: 'Pricing',
href: Routes.Pricing,
external: false
},
{
title: 'Blog',
href: Routes.Blog,
external: false
},
{
title: 'AI',
items: [
{
title: 'Features',
description: 'Short description here',
icon: <CubeIcon className="size-5 shrink-0" />,
href: '#',
external: false
},
{
title: 'Pricing',
description: 'Short description here',
icon: <PlayIcon className="size-5 shrink-0" />,
href: '#',
external: false
},
{
title: 'Testimonials',
description: 'Short description here',
icon: <CircuitBoardIcon className="size-5 shrink-0" />,
href: '#',
external: false
},
{
title: 'FAQ',
description: 'Short description here',
icon: <LayoutIcon className="size-5 shrink-0" />,
href: '#',
external: false
},
{
title: 'Roadmap',
description: 'Short description here',
icon: <FileBarChartIcon className="size-5 shrink-0" />,
href: '#',
external: false
}
]
},
{
title: 'Pages',
items: [
{
title: 'Contact',
description: 'Short description here',
icon: <PaperPlaneIcon className="size-5 shrink-0" />,
href: Routes.Contact,
external: false
},
{
title: 'Roadmap',
description: 'Short description here',
icon: <LayoutIcon className="size-5 shrink-0" />,
href: Routes.Roadmap,
external: true
},
{
title: 'Waitlist',
description: 'Short description here',
icon: <BookOpenIcon className="size-5 shrink-0" />,
href: Routes.Docs,
external: false
}
]
},
];
export const FOOTER_LINKS = [
{
title: 'Product',
links: [
{ name: 'Feature 1', href: '#', external: false },
{ name: 'Feature 2', href: '#', external: false },
{ name: 'Feature 3', href: '#', external: false },
{ name: 'Feature 4', href: '#', external: false },
{ name: 'Feature 5', href: '#', external: false }
]
},
{
title: 'Resources',
links: [
{ name: 'Contact', href: Routes.Contact, external: false },
{ name: 'Roadmap', href: Routes.Roadmap, external: true },
{ name: 'Docs', href: Routes.Docs, external: false }
]
},
{
title: 'About',
links: [
{ name: 'Story', href: Routes.Story, external: false },
{ name: 'Blog', href: Routes.Blog, external: false },
{ name: 'Careers', href: Routes.Careers, external: false }
]
},
{
title: 'Legal',
links: [
{ name: 'Terms of Use', href: Routes.TermsOfUse, external: false },
{ name: 'Privacy Policy', href: Routes.PrivacyPolicy, external: false },
{ name: 'Cookie Policy', href: Routes.CookiePolicy, external: false }
]
}
];
export const SOCIAL_LINKS = [
{
name: 'X (formerly Twitter)',
href: '#',
icon: <XTwitterIcon className="size-4 shrink-0" />
},
{
name: 'LinkedIn',
href: '#',
icon: <LinkedInIcon className="size-4 shrink-0" />
},
{
name: 'Facebook',
href: '#',
icon: <FacebookIcon className="size-4 shrink-0" />
},
{
name: 'Instagram',
href: '#',
icon: <InstagramIcon className="size-4 shrink-0" />
},
{
name: 'TikTok',
href: '#',
icon: <TikTokIcon className="size-4 shrink-0" />
}
];
export const DOCS_LINKS = [
{
title: 'Getting Started',
icon: <CuboidIcon className="size-4 shrink-0 text-muted-foreground" />,
items: [
{
title: 'Introduction',
href: '/docs',
items: []
},
{
title: 'Dependencies',
href: '/docs/dependencies',
items: []
}
]
},
{
title: 'Guides',
icon: <BookIcon className="size-4 shrink-0 text-muted-foreground" />,
items: [
{
title: 'Using MDX',
href: '/docs/using-mdx',
items: []
}
]
}
];

View File

@ -0,0 +1,313 @@
'use client';
import { Logo } from '@/components/logo';
import { DOCS_LINKS, MENU_LINKS } from '@/components/marketing/marketing-links';
import { Button, buttonVariants } from '@/components/ui/button';
import {
Collapsible,
CollapsibleContent,
CollapsibleTrigger
} from '@/components/ui/collapsible';
import { siteConfig } from '@/config/site';
import { Routes } from '@/constants/routes';
import { cn } from '@/lib/utils';
import { Portal } from '@radix-ui/react-portal';
import { ChevronDown, ChevronUp, MenuIcon, X } from 'lucide-react';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import * as React from 'react';
import { RemoveScroll } from 'react-remove-scroll';
import { Icons } from '../icons/icons';
import { ThemeSwitcherHorizontal } from '@/components/layout/theme-switcher-horizontal';
export function NavbarMobile({
className,
...other
}: React.HTMLAttributes<HTMLDivElement>) {
const [open, setOpen] = React.useState<boolean>(false);
const pathname = usePathname();
const isDocs = pathname.startsWith('/docs');
React.useEffect(() => {
const handleRouteChangeStart = () => {
if (document.activeElement instanceof HTMLInputElement) {
document.activeElement.blur();
}
setOpen(false);
};
handleRouteChangeStart();
}, [pathname]);
const handleChange = () => {
const mediaQueryList = window.matchMedia('(min-width: 1024px)');
setOpen((open) => (open ? !mediaQueryList.matches : false));
};
React.useEffect(() => {
handleChange();
const mediaQueryList = window.matchMedia('(min-width: 1024px)');
mediaQueryList.addEventListener('change', handleChange);
return () => mediaQueryList.removeEventListener('change', handleChange);
}, []);
const handleToggleMobileMenu = (): void => {
setOpen((open) => !open);
};
return (
<>
<div
className={cn('flex items-center justify-between', className)}
{...other}
>
{/* navbar left shows logo */}
<Link href={Routes.Root} className="flex items-center gap-2">
<Logo />
<span className="text-xl font-semibold">{siteConfig.name}</span>
</Link>
{/* navbar right shows menu icon */}
<Button
variant="ghost"
size="icon"
aria-expanded={open}
aria-label="Toggle Mobile Menu"
onClick={handleToggleMobileMenu}
className="flex aspect-square h-fit select-none items-center justify-center rounded-md border"
>
{open ? (
<X className="size-8" />
) : (
<MenuIcon className="size-8" />
)}
</Button>
</div>
{/* mobile menu */}
{open && (
<Portal asChild>
<RemoveScroll allowPinchZoom enabled>
{isDocs ? (
<DocsMobileMenu onLinkClicked={handleToggleMobileMenu} />
) : (
<MainMobileMenu onLinkClicked={handleToggleMobileMenu} />
)}
</RemoveScroll>
</Portal>
)}
</>
);
}
type MainMobileMenuProps = {
onLinkClicked: () => void;
};
function MainMobileMenu({ onLinkClicked }: MainMobileMenuProps) {
const [expanded, setExpanded] = React.useState<Record<string, boolean>>({});
return (
<div className="fixed inset-0 z-50 mt-[72px] overflow-y-auto bg-background animate-in fade-in-0">
<div className="flex size-full flex-col items-start space-y-4 p-4">
{/* action buttons */}
<div className="flex w-full flex-col gap-2">
<Link
href={Routes.Login}
onClick={onLinkClicked}
className={cn(
buttonVariants({
variant: 'outline',
size: 'lg'
}),
'w-full'
)}
>
Log in
</Link>
<Link
href={Routes.SignUp}
className={cn(
buttonVariants({
variant: 'default',
size: 'lg'
}),
'w-full'
)}
onClick={onLinkClicked}
>
Sign up
</Link>
</div>
{/* main menu */}
<ul className="w-full">
{MENU_LINKS.map((item) => (
<li key={item.title} className="py-2">
{item.items ? (
<Collapsible
open={expanded[item.title.toLowerCase()]}
onOpenChange={(isOpen) =>
setExpanded((prev) => ({
...prev,
[item.title.toLowerCase()]: isOpen
}))
}
>
<CollapsibleTrigger asChild>
<Button
type="button"
variant="ghost"
className="flex h-8 w-full items-center justify-between text-left"
>
<span className="text-base font-medium">
{item.title}
</span>
{expanded[item.title.toLowerCase()] ? (
<ChevronUp className="size-4" />
) : (
<ChevronDown className="size-4" />
)}
</Button>
</CollapsibleTrigger>
<CollapsibleContent>
<ul className="mt-2 pl-4">
{item.items.map((subItem) => (
<li key={subItem.title}>
<Link
href={subItem.href || '#'}
target={subItem.external ? '_blank' : undefined}
rel={
subItem.external
? 'noopener noreferrer'
: undefined
}
className={cn(
buttonVariants({ variant: 'ghost' }),
'm-0 h-auto w-full justify-start gap-4 p-2'
)}
onClick={onLinkClicked}
>
<div className="flex size-8 shrink-0 items-center justify-center text-muted-foreground transition-colors group-hover:text-foreground">
{subItem.icon}
</div>
<div>
<span className="text-sm font-medium">
{subItem.title}
{subItem.external && (
<Icons.externalLink className="-mt-2 ml-1 inline text-muted-foreground" />
)}
</span>
{subItem.description && (
<p className="text-xs text-muted-foreground">
{subItem.description}
</p>
)}
</div>
</Link>
</li>
))}
</ul>
</CollapsibleContent>
</Collapsible>
) : (
<Link
href={item.href || '#'}
target={item.external ? '_blank' : undefined}
rel={item.external ? 'noopener noreferrer' : undefined}
className={cn(
buttonVariants({ variant: 'ghost' }),
'w-full justify-start'
)}
onClick={onLinkClicked}
>
<span className="text-base">{item.title}</span>
</Link>
)}
</li>
))}
</ul>
{/* bottom buttons */}
<div className="flex w-full items-center justify-between gap-2 border-t border-border/40 p-4">
<div className="text-base font-medium"></div>
<ThemeSwitcherHorizontal />
</div>
</div>
</div>
);
}
type DocsMobileMenuProps = {
onLinkClicked: () => void;
};
function DocsMobileMenu({
onLinkClicked
}: DocsMobileMenuProps): React.JSX.Element {
const [expanded, setExpanded] = React.useState<Record<string, boolean>>({});
return (
<div className="fixed inset-0 z-50 mt-[69px] overflow-y-auto bg-background animate-in fade-in-0">
<div className="flex size-full flex-col items-start space-y-3 p-4">
<ul className="w-full">
{DOCS_LINKS.map((item) => (
<li
key={item.title}
className="py-2"
>
<Collapsible
open={expanded[item.title.toLowerCase()]}
onOpenChange={(isOpen) =>
setExpanded((prev) => ({
...prev,
[item.title.toLowerCase()]: isOpen
}))
}
>
<CollapsibleTrigger asChild>
<Button
type="button"
variant="ghost"
className="flex h-9 w-full items-center justify-between px-4 text-left"
>
<div className="flex flex-row items-center gap-2 text-base font-medium">
{item.icon}
{item.title}
</div>
{expanded[item.title.toLowerCase()] ? (
<ChevronUp className="size-4" />
) : (
<ChevronDown className="size-4" />
)}
</Button>
</CollapsibleTrigger>
<CollapsibleContent>
<ul className="mt-2 pl-4">
{item.items.map((subItem) => (
<li key={subItem.title}>
<Link
href={subItem.href || '#'}
className={cn(
buttonVariants({ variant: 'ghost' }),
'm-0 h-auto w-full justify-start gap-4 p-1.5 text-sm font-medium'
)}
onClick={onLinkClicked}
>
{subItem.title}
</Link>
</li>
))}
</ul>
</CollapsibleContent>
</Collapsible>
</li>
))}
</ul>
<div className="flex w-full items-center justify-between gap-2 border-y border-border/40 p-4">
<div className="text-base font-medium"></div>
<ThemeSwitcherHorizontal />
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,184 @@
'use client';
import { LoginWrapper } from '@/components/auth/login-button';
import Container from '@/components/container';
import { Icons } from '@/components/icons/icons';
import { ThemeSwitcher } from '@/components/layout/theme-swticher';
import { UserButton } from '@/components/layout/user-button';
import { Logo } from '@/components/logo';
import { MENU_LINKS } from '@/components/marketing/marketing-links';
import { NavbarMobile } from '@/components/marketing/navbar-mobile';
import { Button } from '@/components/ui/button';
import {
NavigationMenu,
NavigationMenuContent,
NavigationMenuItem,
NavigationMenuLink,
NavigationMenuList,
NavigationMenuTrigger,
navigationMenuTriggerStyle
} from '@/components/ui/navigation-menu';
import { siteConfig } from '@/config/site';
import { useScroll } from "@/hooks/use-scroll";
import { authClient } from '@/lib/auth-client';
import { cn } from '@/lib/utils';
import { AUTH_ROUTE_REGISTER } from '@/routes';
import { MarketingConfig } from '@/types';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
interface NavBarProps {
scroll?: boolean;
config: MarketingConfig;
}
export function Navbar({ scroll, config }: NavBarProps) {
const scrolled = useScroll(50);
const { data: session, error } = authClient.useSession();
const user = session?.user;
console.log(`Navbar, user:`, user);
const pathname = usePathname();
return (
<section className={cn(
"sticky inset-x-0 top-0 z-40 bg-background py-4",
scroll ? (scrolled ? "border-b" : "bg-transparent") : "border-b"
)}>
<Container className="px-4">
{/* desktop navbar */}
<nav className="hidden lg:flex">
{/* logo and name */}
<div className="flex items-center">
<a href="/" className="flex items-center space-x-2">
<Logo />
<span className="text-xl font-semibold">{siteConfig.name}</span>
</a>
</div>
{/* menu links */}
<div className="flex-1 flex items-center justify-center space-x-2">
<NavigationMenu className="relative">
<NavigationMenuList className="flex items-center">
{MENU_LINKS.map((item, index) =>
item.items ? (
<NavigationMenuItem key={index} className="relative">
<NavigationMenuTrigger
data-active={
item.items.some((subItem) =>
pathname.startsWith(subItem.href)
)
? ''
: undefined
}
className="data-[active]:bg-accent"
>
{item.title}
</NavigationMenuTrigger>
<NavigationMenuContent>
{/* set the width of the menu content to the width of the navigation menu */}
<ul className="w-96 list-none p-2">
{item.items.map((subItem, subIndex) => (
<li key={subIndex}>
<NavigationMenuLink asChild>
<Link
href={subItem.href || '#'}
target={
subItem.external ? '_blank' : undefined
}
rel={
subItem.external
? 'noopener noreferrer'
: undefined
}
className="group flex select-none flex-row items-center gap-4 rounded-md p-2 leading-none no-underline outline-none transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground"
>
<div className="flex size-8 shrink-0 items-center justify-center text-muted-foreground transition-colors group-hover:text-foreground">
{subItem.icon}
</div>
<div>
<div className="text-sm font-medium">
{subItem.title}
{subItem.external && (
<Icons.externalLink className="-mt-2 ml-1 inline text-muted-foreground" />
)}
</div>
{subItem.description && (
<div className="text-sm text-muted-foreground">
{subItem.description}
</div>
)}
</div>
</Link>
</NavigationMenuLink>
</li>
))}
</ul>
</NavigationMenuContent>
</NavigationMenuItem>
) : (
<NavigationMenuItem key={index}>
<NavigationMenuLink
asChild
active={pathname.startsWith(item.href)}
className={cn(
navigationMenuTriggerStyle(),
'data-[active]:bg-accent'
)}
>
<Link
href={item.href || '#'}
target={item.external ? '_blank' : undefined}
rel={
item.external ? 'noopener noreferrer' : undefined
}
>
{item.title}
</Link>
</NavigationMenuLink>
</NavigationMenuItem>
)
)}
</NavigationMenuList>
</NavigationMenu>
</div>
{/* navbar right show sign in or user */}
<div className="flex items-center gap-x-4">
{user ? (
<div className="flex items-center">
<UserButton />
</div>
) : (
<div className="flex items-center gap-x-4">
<LoginWrapper mode="modal" asChild>
<Button
variant="outline"
size="sm"
>
<span>Log in</span>
</Button>
</LoginWrapper>
<Button
variant="default"
size="sm"
asChild
>
<a href={AUTH_ROUTE_REGISTER}>
Sign up
</a>
</Button>
</div>
)}
<ThemeSwitcher />
</div>
</nav>
{/* mobile navbar */}
<NavbarMobile className="lg:hidden" />
</Container>
</section>
);
}

View File

@ -18,9 +18,26 @@ export const marketingConfig: MarketingConfig = {
icon: "blog",
},
{
title: "About",
href: "/about",
title: "Pages",
href: "#",
icon: "about",
},
items: [
{
title: "Waitlist",
href: "/waitlist",
icon: "about",
},
{
title: "Contact",
href: "/contact",
icon: "about",
},
{
title: "About",
href: "/about",
icon: "about",
},
],
},
],
};

11
src/types/index.d.ts vendored
View File

@ -17,7 +17,6 @@ export type SiteConfig = {
links: {
github?: string;
twitter?: string;
twitter_cn?: string;
bluesky?: string;
youtube?: string;
docs?: string;
@ -54,11 +53,11 @@ export type HeroConfig = {
};
export type MarketingConfig = {
menus: NavItem[];
menus: NestedNavItem[];
};
export type DashboardConfig = {
menus: NavItem[];
menus: NestedNavItem[];
};
export type UserButtonConfig = {
@ -81,7 +80,11 @@ export type NavItem = {
export type NestedNavItem = {
title: string;
items: NavItem[];
items?: NavItem[];
href?: string;
badge?: number;
disabled?: boolean;
external?: boolean;
authorizeOnly?: UserRole;
icon?: keyof typeof Icons;
};