Add nested navigation groups
This commit is contained in:
@@ -14,7 +14,8 @@ import {
|
|||||||
FiTag,
|
FiTag,
|
||||||
FiServer,
|
FiServer,
|
||||||
FiCpu,
|
FiCpu,
|
||||||
FiList
|
FiList,
|
||||||
|
FiChevronDown
|
||||||
} from 'react-icons/fi';
|
} from 'react-icons/fi';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
|
||||||
@@ -47,9 +48,10 @@ const ICON_MAP: Record<IconKey, any> = {
|
|||||||
|
|
||||||
export interface NavLinkItem {
|
export interface NavLinkItem {
|
||||||
key: string;
|
key: string;
|
||||||
href: string;
|
href?: string;
|
||||||
label?: string;
|
label: string;
|
||||||
iconKey: IconKey;
|
iconKey: IconKey;
|
||||||
|
children?: NavLinkItem[];
|
||||||
}
|
}
|
||||||
|
|
||||||
interface NavMenuProps {
|
interface NavMenuProps {
|
||||||
@@ -62,6 +64,21 @@ export function NavMenu({ items }: NavMenuProps) {
|
|||||||
const toggle = () => setOpen((val) => !val);
|
const toggle = () => setOpen((val) => !val);
|
||||||
const close = () => setOpen(false);
|
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 (
|
return (
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<button
|
<button
|
||||||
@@ -76,21 +93,49 @@ export function NavMenu({ items }: NavMenuProps) {
|
|||||||
<nav
|
<nav
|
||||||
className={`${open ? 'flex' : 'hidden'} flex-col gap-2 sm:flex sm:flex-row sm:items-center sm:gap-3`}
|
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) => {
|
||||||
<Link
|
const Icon = ICON_MAP[item.iconKey] ?? FiFile;
|
||||||
key={item.key}
|
|
||||||
href={item.href}
|
if (item.children && item.children.length > 0) {
|
||||||
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"
|
return (
|
||||||
onClick={close}
|
<div key={item.key} className="group relative">
|
||||||
>
|
<button
|
||||||
{(() => {
|
type="button"
|
||||||
const Icon = ICON_MAP[item.iconKey] ?? FiFile;
|
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"
|
||||||
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>{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" />
|
<FiChevronDown className="h-3 w-3 text-slate-400 transition group-hover:text-accent" />
|
||||||
</Link>
|
</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}
|
||||||
|
>
|
||||||
|
<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>
|
</nav>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -21,15 +21,56 @@ export function SiteHeader() {
|
|||||||
.slice()
|
.slice()
|
||||||
.sort((a, b) => (a.title || '').localeCompare(b.title || ''));
|
.sort((a, b) => (a.title || '').localeCompare(b.title || ''));
|
||||||
|
|
||||||
|
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,
|
||||||
|
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[] = [
|
const navItems: NavLinkItem[] = [
|
||||||
{ key: 'home', href: '/', label: '首頁', iconKey: 'home' },
|
{ key: 'home', href: '/', label: '首頁', iconKey: 'home' },
|
||||||
{ key: 'blog', href: '/blog', label: 'Blog', iconKey: 'blog' },
|
{
|
||||||
...pages.map((page) => ({
|
key: 'about',
|
||||||
key: page._id,
|
href: aboutChildren[0]?.href,
|
||||||
href: page.url,
|
label: '關於',
|
||||||
label: page.title,
|
iconKey: 'user',
|
||||||
iconKey: getIconForPage(page.title, page.slug)
|
children: aboutChildren
|
||||||
}))
|
},
|
||||||
|
{
|
||||||
|
key: 'devices',
|
||||||
|
href: deviceChildren[0]?.href,
|
||||||
|
label: '裝置',
|
||||||
|
iconKey: 'device',
|
||||||
|
children: deviceChildren
|
||||||
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
2
next-env.d.ts
vendored
2
next-env.d.ts
vendored
@@ -1,6 +1,6 @@
|
|||||||
/// <reference types="next" />
|
/// <reference types="next" />
|
||||||
/// <reference types="next/image-types/global" />
|
/// <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
|
// NOTE: This file should not be edited
|
||||||
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
|
||||||
|
|||||||
Reference in New Issue
Block a user