Add nested navigation groups

This commit is contained in:
2025-11-21 01:10:15 +08:00
parent 7685c79705
commit d768d108d6
3 changed files with 112 additions and 26 deletions

View File

@@ -14,7 +14,8 @@ import {
FiTag,
FiServer,
FiCpu,
FiList
FiList,
FiChevronDown
} from 'react-icons/fi';
import Link from 'next/link';
@@ -47,9 +48,10 @@ const ICON_MAP: Record<IconKey, any> = {
export interface NavLinkItem {
key: string;
href: string;
label?: string;
href?: string;
label: string;
iconKey: IconKey;
children?: NavLinkItem[];
}
interface NavMenuProps {
@@ -62,6 +64,21 @@ export function NavMenu({ items }: NavMenuProps) {
const toggle = () => setOpen((val) => !val);
const close = () => setOpen(false);
const renderChild = (item: NavLinkItem) => {
const Icon = ICON_MAP[item.iconKey] ?? FiFile;
return item.href ? (
<Link
key={item.key}
href={item.href}
className="motion-link inline-flex items-center gap-2 rounded-xl px-3 py-2 text-sm text-slate-600 hover:bg-slate-100 hover:text-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 dark:text-slate-200 dark:hover:bg-slate-800"
onClick={close}
>
<Icon className="h-4 w-4 text-slate-400" />
<span>{item.label}</span>
</Link>
) : null;
};
return (
<div className="flex items-center gap-3">
<button
@@ -76,21 +93,49 @@ export function NavMenu({ items }: NavMenuProps) {
<nav
className={`${open ? 'flex' : 'hidden'} flex-col gap-2 sm:flex sm:flex-row sm:items-center sm:gap-3`}
>
{items.map((item) => (
{items.map((item) => {
const Icon = ICON_MAP[item.iconKey] ?? FiFile;
if (item.children && item.children.length > 0) {
return (
<div key={item.key} className="group relative">
<button
type="button"
className="motion-link type-nav inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-slate-600 hover:text-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 dark:text-slate-200"
>
<Icon className="h-3.5 w-3.5 text-slate-400 transition group-hover:text-accent" />
<span>{item.label}</span>
<FiChevronDown className="h-3 w-3 text-slate-400 transition group-hover:text-accent" />
</button>
{/* Desktop dropdown */}
<div className="pointer-events-none absolute left-0 top-full hidden min-w-[12rem] translate-y-1 rounded-2xl border border-slate-200 bg-white p-2 opacity-0 shadow-lg transition duration-200 ease-snappy group-hover:pointer-events-auto group-hover:translate-y-2 group-hover:opacity-100 dark:border-slate-800 dark:bg-slate-900 sm:block">
<div className="flex flex-col gap-1">
{item.children.map((child) => renderChild(child))}
</div>
</div>
{/* Mobile inline list */}
<div className="sm:hidden ml-3 mt-1 flex flex-col gap-1">
{item.children.map((child) => renderChild(child))}
</div>
</div>
);
}
return item.href ? (
<Link
key={item.key}
href={item.href}
className="motion-link type-nav group relative inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-slate-600 hover:text-accent focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent/40 dark:text-slate-200"
onClick={close}
>
{(() => {
const Icon = ICON_MAP[item.iconKey] ?? FiFile;
return <Icon className="h-3.5 w-3.5 text-slate-400 transition group-hover:text-accent" />;
})()}
<Icon className="h-3.5 w-3.5 text-slate-400 transition group-hover:text-accent" />
<span>{item.label}</span>
<span className="absolute inset-x-3 -bottom-0.5 h-px origin-left scale-x-0 bg-accent transition duration-180 ease-snappy group-hover:scale-x-100" aria-hidden="true" />
</Link>
))}
) : null;
})}
</nav>
</div>
);

View File

@@ -21,15 +21,56 @@ export function SiteHeader() {
.slice()
.sort((a, b) => (a.title || '').localeCompare(b.title || ''));
const navItems: NavLinkItem[] = [
{ key: 'home', href: '/', label: '首頁', iconKey: 'home' },
{ key: 'blog', href: '/blog', label: 'Blog', iconKey: 'blog' },
...pages.map((page) => ({
const findPage = (title: string) => pages.find((page) => page.title === title);
const aboutChildren = [
{ title: '關於作者', label: '作者' },
{ title: '關於本站', label: '本站' }
]
.map(({ title, label }) => {
const page = findPage(title);
if (!page) return null;
return {
key: page._id,
href: page.url,
label: page.title,
label,
iconKey: getIconForPage(page.title, page.slug)
}))
} satisfies NavLinkItem;
})
.filter(Boolean) as NavLinkItem[];
const deviceChildren = [
{ title: '開發工作環境', label: '開發環境' },
{ title: 'HomeLab', label: 'HomeLab' }
]
.map(({ title, label }) => {
const page = findPage(title);
if (!page) return null;
return {
key: page._id,
href: page.url,
label,
iconKey: getIconForPage(page.title, page.slug)
} satisfies NavLinkItem;
})
.filter(Boolean) as NavLinkItem[];
const navItems: NavLinkItem[] = [
{ key: 'home', href: '/', label: '首頁', iconKey: 'home' },
{
key: 'about',
href: aboutChildren[0]?.href,
label: '關於',
iconKey: 'user',
children: aboutChildren
},
{
key: 'devices',
href: deviceChildren[0]?.href,
label: '裝置',
iconKey: 'device',
children: deviceChildren
}
];
return (

2
next-env.d.ts vendored
View File

@@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts";
import "./.next/types/routes.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.