diff options
| author | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-03-26 00:37:41 +0000 |
|---|---|---|
| committer | dujinkim <dujin.kim@dtsolution.co.kr> | 2025-03-26 00:37:41 +0000 |
| commit | e0dfb55c5457aec489fc084c4567e791b4c65eb1 (patch) | |
| tree | 68543a65d88f5afb3a0202925804103daa91bc6f /components/layout | |
3/25 까지의 대표님 작업사항
Diffstat (limited to 'components/layout')
| -rw-r--r-- | components/layout/Footer.tsx | 16 | ||||
| -rw-r--r-- | components/layout/Header.tsx | 225 | ||||
| -rw-r--r-- | components/layout/MobileMenu.tsx | 88 | ||||
| -rw-r--r-- | components/layout/command-menu.tsx | 139 | ||||
| -rw-r--r-- | components/layout/createEmotionCashe.ts | 5 | ||||
| -rw-r--r-- | components/layout/mode-switcher.tsx | 35 | ||||
| -rw-r--r-- | components/layout/providers.tsx | 38 | ||||
| -rw-r--r-- | components/layout/sidebar-nav.tsx | 44 |
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> + ) +} |
