diff --git a/components/nav-menu.tsx b/components/nav-menu.tsx index 86ffd84..2547311 100644 --- a/components/nav-menu.tsx +++ b/components/nav-menu.tsx @@ -1,6 +1,7 @@ 'use client'; -import { useState, useRef, FocusEvent } from 'react'; +import { useState, useRef, FocusEvent, useEffect } from 'react'; +import { createPortal } from 'react-dom'; import { FiMenu, FiX, @@ -15,9 +16,11 @@ import { FiServer, FiCpu, FiList, - FiChevronDown + FiChevronDown, + FiChevronRight } from 'react-icons/fi'; import Link from 'next/link'; +import { usePathname } from 'next/navigation'; export type IconKey = | 'home' @@ -61,10 +64,35 @@ interface NavMenuProps { export function NavMenu({ items }: NavMenuProps) { const [open, setOpen] = useState(false); const [activeDropdown, setActiveDropdown] = useState(null); + const [expandedMobileItems, setExpandedMobileItems] = useState([]); + const [mounted, setMounted] = useState(false); const closeTimer = useRef(null); + const pathname = usePathname(); + + useEffect(() => { + setMounted(true); + }, []); + + // Lock body scroll when menu is open + useEffect(() => { + if (open) { + document.body.style.overflow = 'hidden'; + } else { + document.body.style.overflow = ''; + } + return () => { + document.body.style.overflow = ''; + }; + }, [open]); + + // Close menu on route change + useEffect(() => { + setOpen(false); + }, [pathname]); const toggle = () => setOpen((val) => !val); const close = () => setOpen(false); + const handleBlur = (event: FocusEvent) => { if (!event.currentTarget.contains(event.relatedTarget as Node)) { setActiveDropdown(null); @@ -88,7 +116,13 @@ export function NavMenu({ items }: NavMenuProps) { closeTimer.current = window.setTimeout(() => setActiveDropdown(null), 180); }; - const renderChild = (item: NavLinkItem) => { + const toggleMobileItem = (key: string) => { + setExpandedMobileItems(prev => + prev.includes(key) ? prev.filter(k => k !== key) : [...prev, key] + ); + }; + + const renderDesktopChild = (item: NavLinkItem) => { const Icon = ICON_MAP[item.iconKey] ?? FiFile; return item.href ? ( { + const Icon = ICON_MAP[item.iconKey] ?? FiFile; + const hasChildren = item.children && item.children.length > 0; + const isExpanded = expandedMobileItems.includes(item.key); + + if (hasChildren) { + return ( +
+ +
+
+
+ {item.children!.map(child => renderMobileItem(child, depth + 1))} +
+
+
+
+ ); + } + + return item.href ? ( + + + {item.label} + + ) : null; + }; + return ( -
+ <> + {/* Mobile Menu Trigger */} -
); } @@ -178,6 +300,6 @@ export function NavMenu({ items }: NavMenuProps) { ) : null; })} - + ); } diff --git a/components/post-layout.tsx b/components/post-layout.tsx index 9921ee9..ebc5cb2 100644 --- a/components/post-layout.tsx +++ b/components/post-layout.tsx @@ -2,7 +2,7 @@ import { useState, useEffect } from 'react'; import { createPortal } from 'react-dom'; -import { FiList, FiChevronRight } from 'react-icons/fi'; +import { FiList, FiX } from 'react-icons/fi'; import { PostToc } from './post-toc'; import { clsx, type ClassValue } from 'clsx'; import { twMerge } from 'tailwind-merge'; @@ -12,40 +12,99 @@ function cn(...inputs: ClassValue[]) { } export function PostLayout({ children, hasToc = true, contentKey }: { children: React.ReactNode; hasToc?: boolean; contentKey?: string }) { - const [isTocOpen, setIsTocOpen] = useState(hasToc); + const [isTocOpen, setIsTocOpen] = useState(false); // Default closed on mobile + const [isDesktopTocOpen, setIsDesktopTocOpen] = useState(hasToc); // Separate state for desktop const [mounted, setMounted] = useState(false); useEffect(() => { setMounted(true); }, []); - const mobileToc = isTocOpen && hasToc && mounted + // Lock body scroll when mobile TOC is open + useEffect(() => { + if (isTocOpen) { + document.body.style.overflow = 'hidden'; + } else { + document.body.style.overflow = ''; + } + return () => { + document.body.style.overflow = ''; + }; + }, [isTocOpen]); + + const mobileToc = hasToc && mounted ? createPortal( -
-
- setIsTocOpen(false)} /> + <> + {/* Backdrop */} +
setIsTocOpen(false)} + aria-hidden="true" + /> + + {/* Drawer */} +
+ {/* Handle / Header */} +
setIsTocOpen(false)}> +
+ + 目錄 +
+ +
+ + {/* Content */} +
+ setIsTocOpen(false)} + showTitle={false} + className="w-full" + /> +
-
, + , document.body ) : null; const tocButton = hasToc && mounted ? ( + ) : null; + + const desktopTocButton = hasToc && mounted ? ( + ) : null; @@ -53,11 +112,11 @@ export function PostLayout({ children, hasToc = true, contentKey }: { children:
{/* Main Content Area */}
-
+
{children}
@@ -65,7 +124,7 @@ export function PostLayout({ children, hasToc = true, contentKey }: { children: {/* Desktop Sidebar (TOC) */}