diff options
Diffstat (limited to 'components')
| -rw-r--r-- | components/layout/DynamicMenuRender.tsx | 146 | ||||
| -rw-r--r-- | components/layout/HeaderV2.tsx | 295 | ||||
| -rw-r--r-- | components/layout/MobileMenuV2.tsx | 160 |
3 files changed, 601 insertions, 0 deletions
diff --git a/components/layout/DynamicMenuRender.tsx b/components/layout/DynamicMenuRender.tsx new file mode 100644 index 00000000..f94223ae --- /dev/null +++ b/components/layout/DynamicMenuRender.tsx @@ -0,0 +1,146 @@ +"use client"; + +import Link from "next/link"; +import { cn } from "@/lib/utils"; +import { NavigationMenuLink } from "@/components/ui/navigation-menu"; +import type { MenuTreeNode } from "@/lib/menu-v2/types"; + +interface DynamicMenuRenderProps { + groups: MenuTreeNode[] | undefined; + lng: string; + getTitle: (node: MenuTreeNode) => string; + getDescription: (node: MenuTreeNode) => string | null; + onItemClick?: () => void; +} + +export default function DynamicMenuRender({ + groups, + lng, + getTitle, + getDescription, + onItemClick, +}: DynamicMenuRenderProps) { + if (!groups || groups.length === 0) { + return ( + <div className="p-4 text-sm text-muted-foreground"> + 메뉴가 없습니다. + </div> + ); + } + + // 그룹별로 메뉴 분류 + const groupedMenus = new Map<string, MenuTreeNode[]>(); + const ungroupedMenus: MenuTreeNode[] = []; + + for (const item of groups) { + if (item.nodeType === "group") { + // 그룹인 경우, 그룹의 children을 해당 그룹에 추가 + const groupTitle = getTitle(item); + if (!groupedMenus.has(groupTitle)) { + groupedMenus.set(groupTitle, []); + } + if (item.children) { + groupedMenus.get(groupTitle)!.push(...item.children); + } + } else if (item.nodeType === "menu") { + // 직접 메뉴인 경우 (그룹 없이 직접 메뉴그룹에 속한 경우) + ungroupedMenus.push(item); + } + } + + // 그룹이 없고 메뉴만 있는 경우 - 단순 그리드 렌더링 + if (groupedMenus.size === 0 && ungroupedMenus.length > 0) { + return ( + <ul className="grid w-[400px] gap-3 p-4 md:w-[500px] md:grid-cols-2 lg:w-[600px]"> + {ungroupedMenus.map((menu) => ( + <MenuListItem + key={menu.id} + href={`/${lng}${menu.menuPath}`} + title={getTitle(menu)} + onClick={onItemClick} + > + {getDescription(menu)} + </MenuListItem> + ))} + </ul> + ); + } + + // 그룹별 렌더링 - 가로 스크롤 지원 + // 컨텐츠가 85vw를 초과할 때만 스크롤 발생 + return ( + <div className="p-4 max-w-[85vw]"> + <div className="flex gap-6 overflow-x-auto"> + {/* 그룹화되지 않은 메뉴 (있는 경우) */} + {ungroupedMenus.length > 0 && ( + <div className="w-[200px] flex-shrink-0"> + <ul className="space-y-2"> + {ungroupedMenus.map((menu) => ( + <MenuListItem + key={menu.id} + href={`/${lng}${menu.menuPath}`} + title={getTitle(menu)} + onClick={onItemClick} + > + {getDescription(menu)} + </MenuListItem> + ))} + </ul> + </div> + )} + + {/* 그룹별 메뉴 - 순서대로 가로 배치 */} + {Array.from(groupedMenus.entries()).map(([groupTitle, menus]) => ( + <div key={groupTitle} className="w-[200px] flex-shrink-0"> + <h4 className="mb-2 text-sm font-semibold text-muted-foreground whitespace-nowrap"> + {groupTitle} + </h4> + <ul className="space-y-2"> + {menus.map((menu) => ( + <MenuListItem + key={menu.id} + href={`/${lng}${menu.menuPath}`} + title={getTitle(menu)} + onClick={onItemClick} + > + {getDescription(menu)} + </MenuListItem> + ))} + </ul> + </div> + ))} + </div> + </div> + ); +} + +interface MenuListItemProps { + href: string; + title: string; + children?: React.ReactNode; + onClick?: () => void; +} + +function MenuListItem({ href, title, children, onClick }: MenuListItemProps) { + return ( + <li> + <NavigationMenuLink asChild> + <Link + href={href} + onClick={onClick} + className={cn( + "block select-none space-y-1 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="text-sm font-medium leading-none">{title}</div> + {children && ( + <p className="line-clamp-2 text-xs leading-snug text-muted-foreground"> + {children} + </p> + )} + </Link> + </NavigationMenuLink> + </li> + ); +} + diff --git a/components/layout/HeaderV2.tsx b/components/layout/HeaderV2.tsx new file mode 100644 index 00000000..88d50cc5 --- /dev/null +++ b/components/layout/HeaderV2.tsx @@ -0,0 +1,295 @@ +"use client"; + +import * as React from "react"; +import Link from "next/link"; +import { Button } from "@/components/ui/button"; +import { Avatar, AvatarImage, AvatarFallback } from "@/components/ui/avatar"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { + NavigationMenu, + NavigationMenuContent, + NavigationMenuItem, + NavigationMenuLink, + NavigationMenuList, + NavigationMenuTrigger, + navigationMenuTriggerStyle, +} from "@/components/ui/navigation-menu"; +import { SearchIcon, Loader2 } from "lucide-react"; +import { useParams, usePathname } from "next/navigation"; +import { cn } from "@/lib/utils"; +import Image from "next/image"; +import { useSession } from "next-auth/react"; +import { customSignOut } from "@/lib/auth/custom-signout"; +import DynamicMenuRender from "./DynamicMenuRender"; +import { MobileMenuV2 } from "./MobileMenuV2"; +import { CommandMenu } from "./command-menu"; +import { NotificationDropdown } from "./NotificationDropdown"; +import { useVisibleMenuTree } from "@/hooks/use-visible-menu-tree"; +import { useTranslation } from "@/i18n/client"; +import type { MenuDomain, MenuTreeNode } from "@/lib/menu-v2/types"; + +// 도메인별 브랜드명 +const domainBrandingKeys: Record<MenuDomain, string> = { + evcp: "branding.evcp_main", + partners: "branding.evcp_partners", +}; + +export function HeaderV2() { + const params = useParams(); + const lng = (params?.lng as string) || "ko"; + const pathname = usePathname(); + const { data: session } = useSession(); + const { t } = useTranslation(lng, "menu"); + + // 현재 도메인 결정 + const domain: MenuDomain = pathname?.includes("/partners") ? "partners" : "evcp"; + + // 메뉴 데이터 로드 (tree에 드롭다운과 단일 링크가 모두 포함됨) + const { tree, isLoading } = useVisibleMenuTree(domain); + + const userName = session?.user?.name || ""; + const initials = userName + .split(" ") + .map((word) => word[0]?.toUpperCase()) + .join(""); + + const [isMobileMenuOpen, setIsMobileMenuOpen] = React.useState(false); + const [openMenuKey, setOpenMenuKey] = React.useState<string>(""); + + const toggleMobileMenu = () => { + setIsMobileMenuOpen(!isMobileMenuOpen); + }; + + const toggleMenu = React.useCallback((menuKey: string) => { + setOpenMenuKey((prev) => (prev === menuKey ? "" : menuKey)); + }, []); + + // 페이지 이동 시 메뉴 닫기 + React.useEffect(() => { + setOpenMenuKey(""); + }, [pathname]); + + // 브랜딩 및 경로 설정 + const brandNameKey = domainBrandingKeys[domain]; + const logoHref = `/${lng}/${domain}`; + const basePath = `/${lng}/${domain}`; + + // 다국어 텍스트 선택 + const getTitle = (node: MenuTreeNode) => + lng === "ko" ? node.titleKo : node.titleEn || node.titleKo; + + const getDescription = (node: MenuTreeNode) => + lng === "ko" + ? node.descriptionKo + : node.descriptionEn || node.descriptionKo; + + // 메뉴 노드가 드롭다운(자식 있음)인지 단일 링크인지 판단 + const isDropdownMenu = (node: MenuTreeNode) => + node.nodeType === 'menu_group' && node.children && node.children.length > 0; + + return ( + <> + <header className="border-grid sticky top-0 z-40 w-full border-b bg-slate-100 backdrop-blur supports-[backdrop-filter]:bg-background/60"> + <div className="container-wrapper"> + <div className="container flex h-14 items-center"> + {/* 햄버거 메뉴 버튼 (모바일) */} + <Button + onClick={toggleMobileMenu} + variant="ghost" + className="-ml-2 mr-2 h-8 w-8 px-0 text-base hover:bg-transparent focus-visible:bg-transparent focus-visible:ring-0 focus-visible:ring-offset-0 md:hidden" + > + <svg + xmlns="http://www.w3.org/2000/svg" + fill="none" + viewBox="0 0 24 24" + strokeWidth="1.5" + stroke="currentColor" + className="!size-6" + > + <path + strokeLinecap="round" + strokeLinejoin="round" + d="M3.75 9h16.5m-16.5 6.75h16.5" + /> + </svg> + <span className="sr-only">{t("menu.toggle_menu")}</span> + </Button> + + {/* 로고 영역 */} + <div className="mr-4 flex-shrink-0 flex items-center gap-2 lg:mr-6"> + <Link href={logoHref} className="flex items-center gap-2"> + <Image + className="dark:invert" + src="/images/vercel.svg" + alt="EVCP Logo" + width={20} + height={20} + /> + <span className="hidden font-bold lg:inline-block"> + {t(brandNameKey)} + </span> + </Link> + </div> + + {/* 네비게이션 메뉴 */} + <div className="hidden md:block flex-1 min-w-0"> + {isLoading ? ( + <div className="flex items-center justify-center h-10"> + <Loader2 className="h-4 w-4 animate-spin text-muted-foreground" /> + </div> + ) : ( + <NavigationMenu + className="relative z-50" + value={openMenuKey} + onValueChange={setOpenMenuKey} + > + <div className="w-full overflow-x-auto pb-1"> + <NavigationMenuList className="flex-nowrap w-max"> + {tree.map((node) => { + // 드롭다운 메뉴 (menu_group with children) + if (isDropdownMenu(node)) { + return ( + <NavigationMenuItem + key={node.id} + value={String(node.id)} + > + <NavigationMenuTrigger + className="px-2 xl:px-3 text-sm whitespace-nowrap" + onClick={(e) => { + e.preventDefault(); + e.stopPropagation(); + toggleMenu(String(node.id)); + }} + onPointerEnter={(e) => e.preventDefault()} + onPointerMove={(e) => e.preventDefault()} + onPointerLeave={(e) => e.preventDefault()} + > + {getTitle(node)} + </NavigationMenuTrigger> + + <NavigationMenuContent + className="max-h-[80vh] overflow-y-auto overflow-x-hidden" + onPointerEnter={(e) => e.preventDefault()} + onPointerLeave={(e) => e.preventDefault()} + forceMount={ + openMenuKey === String(node.id) + ? true + : undefined + } + > + <DynamicMenuRender + groups={node.children} + lng={lng} + getTitle={getTitle} + getDescription={getDescription} + onItemClick={() => setOpenMenuKey("")} + /> + </NavigationMenuContent> + </NavigationMenuItem> + ); + } + + // 단일 링크 메뉴 (최상위 menu) + if (node.nodeType === 'menu' && node.menuPath) { + return ( + <NavigationMenuItem key={node.id}> + <Link + href={`/${lng}${node.menuPath}`} + legacyBehavior + passHref + > + <NavigationMenuLink + className={cn( + navigationMenuTriggerStyle(), + "px-2 xl:px-3 text-sm whitespace-nowrap" + )} + onPointerEnter={(e) => e.preventDefault()} + onPointerLeave={(e) => e.preventDefault()} + > + {getTitle(node)} + </NavigationMenuLink> + </Link> + </NavigationMenuItem> + ); + } + + return null; + })} + </NavigationMenuList> + </div> + </NavigationMenu> + )} + </div> + + {/* 우측 영역 */} + <div className="ml-auto flex flex-shrink-0 items-center space-x-2"> + {/* 데스크탑에서는 CommandMenu, 모바일에서는 검색 아이콘만 */} + <div className="hidden md:block md:w-auto"> + <CommandMenu /> + </div> + <Button + variant="ghost" + size="icon" + className="md:hidden" + aria-label={t("common.search")} + > + <SearchIcon className="h-5 w-5" /> + </Button> + + {/* 알림 버튼 */} + <NotificationDropdown /> + + {/* 사용자 메뉴 */} + <DropdownMenu> + <DropdownMenuTrigger asChild> + <Avatar className="cursor-pointer h-8 w-8"> + <AvatarImage + src={`${session?.user?.image}` || "/user-avatar.jpg"} + alt="User Avatar" + /> + <AvatarFallback>{initials || "?"}</AvatarFallback> + </Avatar> + </DropdownMenuTrigger> + <DropdownMenuContent className="w-48" align="end"> + <DropdownMenuLabel>{t("user.my_account")}</DropdownMenuLabel> + <DropdownMenuSeparator /> + <DropdownMenuItem asChild> + <Link href={`${basePath}/settings`}>{t("user.settings")}</Link> + </DropdownMenuItem> + <DropdownMenuSeparator /> + <DropdownMenuItem + onSelect={() => + customSignOut({ + callbackUrl: `${window.location.origin}${basePath}`, + }) + } + > + {t("user.logout")} + </DropdownMenuItem> + </DropdownMenuContent> + </DropdownMenu> + </div> + </div> + </div> + + {/* 모바일 메뉴 */} + {isMobileMenuOpen && ( + <MobileMenuV2 + lng={lng} + onClose={toggleMobileMenu} + tree={tree} + getTitle={getTitle} + getDescription={getDescription} + /> + )} + </header> + </> + ); +} diff --git a/components/layout/MobileMenuV2.tsx b/components/layout/MobileMenuV2.tsx new file mode 100644 index 00000000..c83ba779 --- /dev/null +++ b/components/layout/MobileMenuV2.tsx @@ -0,0 +1,160 @@ +"use client"; + +import * as React from "react"; +import Link from "next/link"; +import { cn } from "@/lib/utils"; +import { Button } from "@/components/ui/button"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { X, ChevronDown, ChevronRight } from "lucide-react"; +import type { MenuTreeNode } from "@/lib/menu-v2/types"; + +interface MobileMenuV2Props { + lng: string; + onClose: () => void; + tree: MenuTreeNode[]; + getTitle: (node: MenuTreeNode) => string; + getDescription: (node: MenuTreeNode) => string | null; +} + +export function MobileMenuV2({ + lng, + onClose, + tree, + getTitle, + getDescription, +}: MobileMenuV2Props) { + const [expandedGroups, setExpandedGroups] = React.useState<Set<number>>( + new Set() + ); + + const toggleGroup = (groupId: number) => { + setExpandedGroups((prev) => { + const next = new Set(prev); + if (next.has(groupId)) { + next.delete(groupId); + } else { + next.add(groupId); + } + return next; + }); + }; + + // 드롭다운 메뉴인지 판단 + const isDropdownMenu = (node: MenuTreeNode) => + node.nodeType === 'menu_group' && node.children && node.children.length > 0; + + return ( + <div className="fixed inset-0 z-50 bg-background md:hidden"> + {/* 헤더 */} + <div className="flex items-center justify-between px-4 h-14 border-b"> + <span className="font-semibold">메뉴</span> + <Button variant="ghost" size="icon" onClick={onClose}> + <X className="h-5 w-5" /> + <span className="sr-only">닫기</span> + </Button> + </div> + + {/* 스크롤 영역 */} + <ScrollArea className="h-[calc(100vh-56px)]"> + <div className="px-4 py-4 space-y-2"> + {tree.map((node) => { + // 드롭다운 메뉴 (menu_group with children) + if (isDropdownMenu(node)) { + return ( + <div key={node.id} className="space-y-2"> + {/* 메뉴그룹 헤더 */} + <button + onClick={() => toggleGroup(node.id)} + className="flex items-center justify-between w-full py-2 text-left font-semibold" + > + <span>{getTitle(node)}</span> + {expandedGroups.has(node.id) ? ( + <ChevronDown className="h-4 w-4" /> + ) : ( + <ChevronRight className="h-4 w-4" /> + )} + </button> + + {/* 하위 메뉴 */} + {expandedGroups.has(node.id) && ( + <div className="pl-4 space-y-1"> + {node.children?.map((item) => { + if (item.nodeType === "group") { + // 그룹인 경우 + return ( + <div key={item.id} className="space-y-1"> + <div className="text-xs text-muted-foreground font-medium py-1"> + {getTitle(item)} + </div> + <div className="pl-2 space-y-1"> + {item.children?.map((menu) => ( + <MobileMenuLink + key={menu.id} + href={`/${lng}${menu.menuPath}`} + title={getTitle(menu)} + onClick={onClose} + /> + ))} + </div> + </div> + ); + } else if (item.nodeType === "menu") { + // 직접 메뉴인 경우 + return ( + <MobileMenuLink + key={item.id} + href={`/${lng}${item.menuPath}`} + title={getTitle(item)} + onClick={onClose} + /> + ); + } + return null; + })} + </div> + )} + </div> + ); + } + + // 단일 링크 메뉴 (최상위 menu) + if (node.nodeType === 'menu' && node.menuPath) { + return ( + <MobileMenuLink + key={node.id} + href={`/${lng}${node.menuPath}`} + title={getTitle(node)} + onClick={onClose} + /> + ); + } + + return null; + })} + </div> + </ScrollArea> + </div> + ); +} + +interface MobileMenuLinkProps { + href: string; + title: string; + onClick: () => void; +} + +function MobileMenuLink({ href, title, onClick }: MobileMenuLinkProps) { + return ( + <Link + href={href} + onClick={onClick} + className={cn( + "block py-2 px-2 rounded-md text-sm", + "hover:bg-accent hover:text-accent-foreground", + "transition-colors" + )} + > + {title} + </Link> + ); +} |
