summaryrefslogtreecommitdiff
path: root/components/layout
diff options
context:
space:
mode:
authordujinkim <dujin.kim@dtsolution.co.kr>2025-03-26 00:37:41 +0000
committerdujinkim <dujin.kim@dtsolution.co.kr>2025-03-26 00:37:41 +0000
commite0dfb55c5457aec489fc084c4567e791b4c65eb1 (patch)
tree68543a65d88f5afb3a0202925804103daa91bc6f /components/layout
3/25 까지의 대표님 작업사항
Diffstat (limited to 'components/layout')
-rw-r--r--components/layout/Footer.tsx16
-rw-r--r--components/layout/Header.tsx225
-rw-r--r--components/layout/MobileMenu.tsx88
-rw-r--r--components/layout/command-menu.tsx139
-rw-r--r--components/layout/createEmotionCashe.ts5
-rw-r--r--components/layout/mode-switcher.tsx35
-rw-r--r--components/layout/providers.tsx38
-rw-r--r--components/layout/sidebar-nav.tsx44
8 files changed, 590 insertions, 0 deletions
diff --git a/components/layout/Footer.tsx b/components/layout/Footer.tsx
new file mode 100644
index 00000000..f7d6906d
--- /dev/null
+++ b/components/layout/Footer.tsx
@@ -0,0 +1,16 @@
+import { siteConfig } from "@/config/site"
+
+export function SiteFooter() {
+ return (
+ <footer className="border-grid border-t py-6 md:px-8 md:py-0">
+ <div className="container-wrapper">
+ <div className="container py-4">
+ <div className="text-balance text-center text-sm leading-loose text-muted-foreground md:text-left">
+ Built by{" "}
+
+ </div>
+ </div>
+ </div>
+ </footer>
+ )
+}
diff --git a/components/layout/Header.tsx b/components/layout/Header.tsx
new file mode 100644
index 00000000..1b6c45bb
--- /dev/null
+++ b/components/layout/Header.tsx
@@ -0,0 +1,225 @@
+"use client";
+
+import * as React from "react";
+import Link from "next/link";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+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, BellIcon, Menu } from "lucide-react";
+import { useParams, usePathname } from "next/navigation";
+import { cn } from "@/lib/utils";
+import Image from "next/image";
+import { mainNav, additionalNav, MenuSection, mainNavVendor, additionalNavVendor } from "@/config/menuConfig"; // 메뉴 구성 임포트
+import { MobileMenu } from "./MobileMenu";
+import { CommandMenu } from "./command-menu";
+import { useSession, signOut } from "next-auth/react";
+
+
+export function Header() {
+ const params = useParams();
+ const lng = params.lng as string;
+ const pathname = usePathname();
+ const { data: session } = useSession();
+
+ const userName = session?.user?.name || ""; // 없을 수도 있으니 안전하게 처리
+ const initials = userName
+ .split(" ")
+ .map((word) => word[0]?.toUpperCase())
+ .join("");
+
+
+ const [isMobileMenuOpen, setIsMobileMenuOpen] = React.useState(false); // 모바일 메뉴 상태
+
+ const toggleMobileMenu = () => {
+ setIsMobileMenuOpen(!isMobileMenuOpen);
+ };
+
+ const isPartnerRoute = pathname.includes("/partners");
+
+ const main = isPartnerRoute ? mainNavVendor : mainNav;
+ const additional = isPartnerRoute ? additionalNavVendor : additionalNav;
+
+ const basePath = `/${lng}${isPartnerRoute ? "/partners" : "/evcp"}`;
+
+ return (
+ <>
+ <header className="border-grid sticky top-0 z-50 w-full border-b bg-background/95 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">Toggle Menu</span>
+ </Button>
+
+
+ <div className="mr-4 hidden md:flex">
+
+ {/* 로고 영역 */}
+ <div className="mr-4 flex items-center gap-2 lg:mr-6">
+ <Link href={`/${lng}/evcp`} 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">eVCP</span>
+ </Link>
+ </div>
+ {/* 데스크탑 네비게이션 메뉴 */}
+ <NavigationMenu className="flex items-center gap-4 text-sm xl:gap-6">
+ <NavigationMenuList>
+ {main.map((section: MenuSection) => (
+ <NavigationMenuItem key={section.title}>
+ <NavigationMenuTrigger>{section.title}</NavigationMenuTrigger>
+ <NavigationMenuContent>
+ <ul className="grid w-[400px] gap-3 p-4 md:w-[500px] md:grid-cols-2 lg:w-[600px] ">
+ {section.items.map((item) => (
+ <ListItem
+ key={item.title}
+ title={item.title}
+ href={`/${lng}${item.href}`}
+ >
+ {item.description}
+ </ListItem>
+ ))}
+ </ul>
+ </NavigationMenuContent>
+ </NavigationMenuItem>
+ ))}
+
+
+ {/* 추가 네비게이션 항목 */}
+ {additional.map((item) => (
+ <NavigationMenuItem key={item.title}>
+ <Link href={`/${lng}${item.href}`} legacyBehavior passHref>
+ <NavigationMenuLink className={navigationMenuTriggerStyle()}>
+ {item.title}
+ </NavigationMenuLink>
+ </Link>
+ </NavigationMenuItem>
+ ))}
+ </NavigationMenuList>
+ </NavigationMenu>
+
+
+ </div>
+
+
+ {/* 우측 영역 */}
+ <div className="flex flex-1 items-center justify-between gap-2 md:justify-end">
+
+ <CommandMenu />
+
+
+ <div className="flex items-center space-x-4">
+ {/* 알림 버튼 */}
+ <Button variant="ghost" className="relative p-2" aria-label="Notifications">
+ <BellIcon className="h-5 w-5" />
+ {/* 알림 뱃지 예시 */}
+ <span className="absolute -top-1 -right-1 inline-flex h-2 w-2 rounded-full bg-red-500"></span>
+ </Button>
+
+ {/* 사용자 메뉴 (DropdownMenu) */}
+ <DropdownMenu>
+ <DropdownMenuTrigger asChild>
+ <Avatar className="cursor-pointer">
+ <AvatarImage src={`/profiles/${session?.user?.image}`||"/user-avatar.jpg"} alt="User Avatar" />
+ <AvatarFallback>
+ {initials || "?"}
+ </AvatarFallback>
+ </Avatar>
+ </DropdownMenuTrigger>
+ <DropdownMenuContent className="w-48" align="end">
+ <DropdownMenuLabel>My Account</DropdownMenuLabel>
+ <DropdownMenuSeparator />
+ {/* <DropdownMenuItem asChild>
+ <Link href={`${basePath}/profile`}>Profile</Link>
+ </DropdownMenuItem> */}
+ <DropdownMenuItem asChild>
+ <Link href={`${basePath}/settings`}>Settings</Link>
+ </DropdownMenuItem>
+ <DropdownMenuSeparator />
+ <DropdownMenuItem onSelect={() => signOut({ callbackUrl: `/${lng}/login` })}>
+ Logout
+ </DropdownMenuItem>
+ </DropdownMenuContent>
+ </DropdownMenu>
+
+ {/* 모바일 햄버거 메뉴 버튼 */}
+
+ </div>
+ </div>
+ </div>
+ </div>
+
+ {/* 모바일 메뉴 */}
+ {isMobileMenuOpen && <MobileMenu lng={lng} onClose={toggleMobileMenu} />}
+ </header>
+ </>
+ );
+}
+
+const ListItem = React.forwardRef<
+ React.ElementRef<"a">,
+ React.ComponentPropsWithoutRef<"a">
+>(({ className, title, children, ...props }, ref) => {
+ return (
+ <li>
+ <NavigationMenuLink asChild>
+ <a
+ ref={ref}
+ className={cn(
+ "block select-none space-y-1 rounded-md p-3 leading-none no-underline outline-none transition-colors hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground",
+ className
+ )}
+ {...props}
+ >
+ <div className="text-sm font-medium leading-none">{title}</div>
+ {children && (
+ <p className="line-clamp-2 text-sm leading-snug text-muted-foreground">
+ {children}
+ </p>
+ )}
+ </a>
+ </NavigationMenuLink>
+ </li>
+ );
+});
+ListItem.displayName = "ListItem"; \ No newline at end of file
diff --git a/components/layout/MobileMenu.tsx b/components/layout/MobileMenu.tsx
new file mode 100644
index 00000000..d2e6b927
--- /dev/null
+++ b/components/layout/MobileMenu.tsx
@@ -0,0 +1,88 @@
+// components/MobileMenu.tsx
+
+"use client";
+
+import * as React from "react";
+import Link from "next/link";
+import { useRouter,usePathname } from "next/navigation";
+import { MenuSection, mainNav, additionalNav, MenuItem, mainNavVendor, additionalNavVendor } from "@/config/menuConfig";
+import { cn } from "@/lib/utils";
+import { Drawer, DrawerContent,DrawerTitle,DrawerTrigger } from "@/components/ui/drawer";
+import { Button } from "@/components/ui/button";
+
+interface MobileMenuProps {
+ lng: string;
+ onClose: () => void;
+}
+
+export function MobileMenu({ lng, onClose }: MobileMenuProps) {
+ const router = useRouter();
+
+
+ const handleLinkClick = (href: string) => {
+ router.push(href);
+ onClose();
+ };
+ const pathname = usePathname();
+ const isPartnerRoute = pathname.includes("/partners");
+
+ const main = isPartnerRoute ? mainNavVendor : mainNav;
+ const additional = isPartnerRoute ? additionalNavVendor : additionalNav;
+
+ return (
+ <Drawer open={true} onOpenChange={onClose}>
+ <DrawerTrigger asChild>
+ </DrawerTrigger>
+ <DrawerTitle />
+ <DrawerContent className="max-h-[60vh] p-0">
+ <div className="overflow-auto p-6">
+
+ <nav>
+ <ul className="space-y-4">
+ {/* 메인 네비게이션 섹션 */}
+ {main.map((section: MenuSection) => (
+ <li key={section.title}>
+ <h3 className="text-md font-medium">{section.title}</h3>
+ <ul className="mt-2 space-y-2">
+ {section.items.map((item: MenuItem) => (
+ <li key={item.title}>
+ <Link
+ href={`/${lng}${item.href}`}
+ className="text-indigo-600"
+ onClick={() => handleLinkClick(item.href)}
+ >
+ {item.title}
+ {item.label && (
+ <span className="ml-2 rounded-md bg-[#adfa1d] px-1.5 py-0.5 text-xs text-[#000000]">
+ {item.label}
+ </span>
+ )}
+ </Link>
+ {item.description && (
+ <p className="text-xs text-gray-500">{item.description}</p>
+ )}
+ </li>
+ ))}
+ </ul>
+ </li>
+ ))}
+
+ {/* 추가 네비게이션 항목 */}
+ {additional.map((item: MenuItem) => (
+ <li key={item.title}>
+ <Link
+ href={item.href}
+ className="block text-sm text-indigo-600"
+ onClick={() => handleLinkClick(`/${lng}${item.href}`)}
+ >
+ {item.title}
+ </Link>
+ </li>
+ ))}
+ </ul>
+ </nav>
+ </div>
+ </DrawerContent>
+ </Drawer>
+ );
+} \ No newline at end of file
diff --git a/components/layout/command-menu.tsx b/components/layout/command-menu.tsx
new file mode 100644
index 00000000..5537a042
--- /dev/null
+++ b/components/layout/command-menu.tsx
@@ -0,0 +1,139 @@
+"use client"
+
+import * as React from "react"
+import { useRouter,usePathname } from "next/navigation"
+import { type DialogProps } from "@radix-ui/react-dialog"
+import { Circle, File, Laptop, Moon, Sun } from "lucide-react"
+import { useTheme } from "next-themes"
+
+import { MenuSection, mainNav, additionalNav, MenuItem, mainNavVendor, additionalNavVendor } from "@/config/menuConfig";
+import { cn } from "@/lib/utils"
+import { Button } from "@/components/ui/button"
+import {
+ CommandDialog,
+ CommandEmpty,
+ CommandGroup,
+ CommandInput,
+ CommandItem,
+ CommandList,
+ CommandSeparator,
+} from "@/components/ui/command"
+import { DialogTitle } from "@/components/ui/dialog"
+
+export function CommandMenu({ ...props }: DialogProps) {
+ const router = useRouter()
+ const [open, setOpen] = React.useState(false)
+ const { setTheme } = useTheme()
+
+ React.useEffect(() => {
+ const down = (e: KeyboardEvent) => {
+ if ((e.key === "k" && (e.metaKey || e.ctrlKey)) || e.key === "/") {
+ if (
+ (e.target instanceof HTMLElement && e.target.isContentEditable) ||
+ e.target instanceof HTMLInputElement ||
+ e.target instanceof HTMLTextAreaElement ||
+ e.target instanceof HTMLSelectElement
+ ) {
+ return
+ }
+
+ e.preventDefault()
+ setOpen((open) => !open)
+ }
+ }
+
+ document.addEventListener("keydown", down)
+ return () => document.removeEventListener("keydown", down)
+ }, [])
+
+ const runCommand = React.useCallback((command: () => unknown) => {
+ setOpen(false)
+ command()
+ }, [])
+
+
+const pathname = usePathname();
+const isPartnerRoute = pathname.includes("/partners");
+
+ const main = isPartnerRoute ? mainNavVendor : mainNav;
+ const additional = isPartnerRoute ? additionalNavVendor : additionalNav;
+
+
+ return (
+ <>
+ <Button
+ variant="outline"
+ className={cn(
+ "relative h-8 w-full justify-start rounded-[0.5rem] bg-muted/50 text-sm font-normal text-muted-foreground shadow-none sm:pr-12 md:w-40 lg:w-56 xl:w-64"
+ )}
+ onClick={() => setOpen(true)}
+ {...props}
+ >
+ <span className="hidden lg:inline-flex">Search Menu...</span>
+ <span className="inline-flex lg:hidden">Search...</span>
+ <kbd className="pointer-events-none absolute right-[0.3rem] top-[0.3rem] hidden h-5 select-none items-center gap-1 rounded border bg-muted px-1.5 font-mono text-[10px] font-medium opacity-100 sm:flex">
+ <span className="text-xs">⌘</span>K
+ </kbd>
+ </Button>
+ <CommandDialog open={open} onOpenChange={setOpen}>
+ <DialogTitle className="sr-only">Search Menu</DialogTitle>
+ <CommandInput placeholder="Type a command or search..." />
+ <CommandList>
+ <CommandEmpty>No results found.</CommandEmpty>
+
+ {main.map((group) => (
+ <CommandGroup key={group.title} heading={group.title}>
+ {group.items.map((navItem) => (
+ <CommandItem
+ key={navItem.title}
+ value={navItem.title}
+ onSelect={() => {
+ runCommand(() => router.push(navItem.href as string))
+ }}
+ >
+ <div className="mr-2 flex h-4 w-4 items-center justify-center">
+ <Circle className="h-3 w-3" />
+ </div>
+ {navItem.title}
+ </CommandItem>
+ ))}
+ </CommandGroup>
+ ))}
+ <CommandGroup heading="">
+ {additional
+ // .filter((navitem) => !navitem.external)
+ .map((navItem) => (
+ <CommandItem
+ key={navItem.title}
+ value={navItem.title}
+ onSelect={() => {
+ runCommand(() => router.push(navItem.href as string))
+ }}
+ >
+ <File />
+ {navItem.title}
+ </CommandItem>
+ ))}
+ </CommandGroup>
+ <CommandSeparator />
+
+
+ <CommandGroup heading="Theme">
+ <CommandItem onSelect={() => runCommand(() => setTheme("light"))}>
+ <Sun />
+ Light
+ </CommandItem>
+ <CommandItem onSelect={() => runCommand(() => setTheme("dark"))}>
+ <Moon />
+ Dark
+ </CommandItem>
+ <CommandItem onSelect={() => runCommand(() => setTheme("system"))}>
+ <Laptop />
+ System
+ </CommandItem>
+ </CommandGroup>
+ </CommandList>
+ </CommandDialog>
+ </>
+ )
+}
diff --git a/components/layout/createEmotionCashe.ts b/components/layout/createEmotionCashe.ts
new file mode 100644
index 00000000..ae8bc3b5
--- /dev/null
+++ b/components/layout/createEmotionCashe.ts
@@ -0,0 +1,5 @@
+import createCache from '@emotion/cache';
+
+export default function createEmotionCache() {
+ return createCache({ key: 'css' });
+} \ No newline at end of file
diff --git a/components/layout/mode-switcher.tsx b/components/layout/mode-switcher.tsx
new file mode 100644
index 00000000..d27b6a73
--- /dev/null
+++ b/components/layout/mode-switcher.tsx
@@ -0,0 +1,35 @@
+"use client"
+
+import * as React from "react"
+import { MoonIcon, SunIcon } from "lucide-react"
+import { useTheme } from "next-themes"
+
+import { META_THEME_COLORS } from "@/config/site"
+import { useMetaColor } from "@/hooks/use-meta-color"
+import { Button } from "@/components/ui/button"
+
+export function ModeSwitcher() {
+ const { setTheme, resolvedTheme } = useTheme()
+ const { setMetaColor } = useMetaColor()
+
+ const toggleTheme = React.useCallback(() => {
+ setTheme(resolvedTheme === "dark" ? "light" : "dark")
+ setMetaColor(
+ resolvedTheme === "dark"
+ ? META_THEME_COLORS.light
+ : META_THEME_COLORS.dark
+ )
+ }, [resolvedTheme, setTheme, setMetaColor])
+
+ return (
+ <Button
+ variant="ghost"
+ className="group/toggle h-8 w-8 px-0"
+ onClick={toggleTheme}
+ >
+ <SunIcon className="hidden [html.dark_&]:block" />
+ <MoonIcon className="hidden [html.light_&]:block" />
+ <span className="sr-only">Toggle theme</span>
+ </Button>
+ )
+}
diff --git a/components/layout/providers.tsx b/components/layout/providers.tsx
new file mode 100644
index 00000000..1c645531
--- /dev/null
+++ b/components/layout/providers.tsx
@@ -0,0 +1,38 @@
+"use client"
+
+import * as React from "react"
+import { Provider as JotaiProvider } from "jotai"
+import { ThemeProvider as NextThemesProvider } from "next-themes"
+import { NuqsAdapter } from "nuqs/adapters/next/app"
+import { SessionProvider } from "next-auth/react";
+import { CacheProvider } from '@emotion/react';
+
+import { TooltipProvider } from "@/components/ui/tooltip"
+import createEmotionCache from './createEmotionCashe';
+
+
+const cache = createEmotionCache();
+
+
+export function ThemeProvider({
+ children,
+ ...props
+}: React.ComponentProps<typeof NextThemesProvider>) {
+ return (
+ <JotaiProvider>
+ <CacheProvider value={cache}>
+
+ <NextThemesProvider {...props}>
+ <TooltipProvider delayDuration={0}>
+ <NuqsAdapter>
+ <SessionProvider>
+ {children}
+ </SessionProvider>
+ </NuqsAdapter>
+ </TooltipProvider>
+ </NextThemesProvider>
+ </CacheProvider>
+
+ </JotaiProvider>
+ )
+}
diff --git a/components/layout/sidebar-nav.tsx b/components/layout/sidebar-nav.tsx
new file mode 100644
index 00000000..addcfefd
--- /dev/null
+++ b/components/layout/sidebar-nav.tsx
@@ -0,0 +1,44 @@
+"use client"
+
+import Link from "next/link"
+import { usePathname } from "next/navigation"
+
+import { cn } from "@/lib/utils"
+import { buttonVariants } from "@/components/ui/button"
+
+interface SidebarNavProps extends React.HTMLAttributes<HTMLElement> {
+ items: {
+ href: string
+ title: string
+ }[]
+}
+
+export function SidebarNav({ className, items, ...props }: SidebarNavProps) {
+ const pathname = usePathname()
+
+ return (
+ <nav
+ className={cn(
+ "flex space-x-2 lg:flex-col lg:space-x-0 lg:space-y-1",
+ className
+ )}
+ {...props}
+ >
+ {items.map((item) => (
+ <Link
+ key={item.href}
+ href={item.href}
+ className={cn(
+ buttonVariants({ variant: "ghost" }),
+ pathname === item.href
+ ? "bg-muted hover:bg-muted"
+ : "hover:bg-transparent hover:underline",
+ "justify-start"
+ )}
+ >
+ {item.title}
+ </Link>
+ ))}
+ </nav>
+ )
+}