213 lines
6.9 KiB
TypeScript
213 lines
6.9 KiB
TypeScript
"use client";
|
|
|
|
import { cn } from "@/lib/utils";
|
|
import {
|
|
BarChart2,
|
|
ChevronLeft,
|
|
Home,
|
|
Settings,
|
|
User,
|
|
Wallet,
|
|
} from "lucide-react";
|
|
import Link from "next/link";
|
|
import { usePathname } from "next/navigation";
|
|
import { useState } from "react";
|
|
import { MenuItem } from "../types";
|
|
|
|
const MENU_ITEMS: MenuItem[] = [
|
|
{
|
|
title: "대시보드",
|
|
href: "/dashboard",
|
|
icon: Home,
|
|
variant: "default",
|
|
matchExact: true,
|
|
showInBottomNav: true,
|
|
},
|
|
{
|
|
title: "자동매매",
|
|
href: "/trade",
|
|
icon: BarChart2,
|
|
variant: "ghost",
|
|
badge: "LIVE",
|
|
showInBottomNav: true,
|
|
},
|
|
{
|
|
title: "자산현황",
|
|
href: "/assets",
|
|
icon: Wallet,
|
|
variant: "ghost",
|
|
showInBottomNav: true,
|
|
},
|
|
{
|
|
title: "프로필",
|
|
href: "/profile",
|
|
icon: User,
|
|
variant: "ghost",
|
|
showInBottomNav: false,
|
|
},
|
|
{
|
|
title: "설정",
|
|
href: "/settings",
|
|
icon: Settings,
|
|
variant: "ghost",
|
|
showInBottomNav: true,
|
|
},
|
|
];
|
|
|
|
/**
|
|
* @description 메인 좌측 사이드바(데스크탑): 기본 축소 상태에서 hover/focus 시 확장됩니다.
|
|
* @see features/layout/components/sidebar.tsx MENU_ITEMS 한 곳에서 메뉴/배지/모바일 탭 구성을 함께 관리합니다.
|
|
*/
|
|
export function Sidebar() {
|
|
const pathname = usePathname();
|
|
const [isExpanded, setIsExpanded] = useState(false);
|
|
|
|
return (
|
|
<aside
|
|
className={cn(
|
|
"relative hidden h-[calc(100vh-4rem)] shrink-0 overflow-x-visible overflow-y-auto border-r border-brand-100 bg-white px-2 py-5 transition-[width] duration-200 dark:border-brand-900/40 dark:bg-background md:sticky md:top-16 md:block",
|
|
isExpanded ? "md:w-64" : "md:w-[74px]",
|
|
)}
|
|
>
|
|
<button
|
|
type="button"
|
|
onClick={() => setIsExpanded((prev) => !prev)}
|
|
aria-label={isExpanded ? "Collapse sidebar" : "Expand sidebar"}
|
|
className={cn(
|
|
"absolute -right-3 top-20 z-50 hidden h-8 w-8 items-center justify-center rounded-full",
|
|
"border border-zinc-200/50 bg-white/80 shadow-lg backdrop-blur-md transition-all duration-300",
|
|
"hover:scale-110 hover:bg-white active:scale-95",
|
|
"dark:border-zinc-800/50 dark:bg-zinc-900/80 dark:hover:bg-zinc-900",
|
|
"md:flex",
|
|
)}
|
|
>
|
|
<ChevronLeft
|
|
className={cn(
|
|
"h-4 w-4 text-zinc-600 transition-transform duration-300 dark:text-zinc-300",
|
|
isExpanded ? "rotate-0" : "rotate-180",
|
|
)}
|
|
/>
|
|
</button>
|
|
|
|
<div className="h-1.5" />
|
|
{/* ========== SIDEBAR ITEMS ========== */}
|
|
<div className="flex flex-col space-y-1.5">
|
|
{MENU_ITEMS.map((item) => {
|
|
const isActive = item.matchExact
|
|
? pathname === item.href
|
|
: pathname.startsWith(item.href);
|
|
|
|
return (
|
|
<Link
|
|
key={item.href}
|
|
href={item.href}
|
|
title={item.title}
|
|
className={cn(
|
|
"group/item relative flex items-center rounded-xl px-3 py-2.5 text-sm transition-colors",
|
|
"hover:bg-brand-50 hover:text-brand-800 dark:hover:bg-brand-900/30 dark:hover:text-brand-100",
|
|
isActive
|
|
? "bg-brand-100 text-brand-800 shadow-sm dark:bg-brand-900/40 dark:text-brand-100"
|
|
: "text-muted-foreground dark:text-brand-200/80",
|
|
)}
|
|
>
|
|
{/* ========== ACTIVE BAR ========== */}
|
|
<span
|
|
className={cn(
|
|
"absolute left-0 top-1/2 h-5 -translate-y-1/2 rounded-r-full transition-all",
|
|
isActive ? "w-1.5 bg-brand-500" : "w-0",
|
|
)}
|
|
/>
|
|
|
|
{/* ========== ICON + DOT BADGE ========== */}
|
|
<item.icon
|
|
className={cn(
|
|
"h-5 w-5 shrink-0 transition-colors",
|
|
isActive
|
|
? "text-brand-700 dark:text-brand-200"
|
|
: "text-zinc-400 group-hover/item:text-brand-700 dark:text-brand-300/70 dark:group-hover/item:text-brand-200",
|
|
)}
|
|
/>
|
|
|
|
{item.badge && !isExpanded && (
|
|
<span className="absolute left-7 top-2 h-2 w-2 rounded-full bg-brand-500" />
|
|
)}
|
|
|
|
{/* ========== LABEL (EXPAND ON TOGGLE) ========== */}
|
|
<span
|
|
className={cn(
|
|
"ml-3 flex min-w-0 items-center gap-1.5 overflow-hidden whitespace-nowrap transition-all duration-200",
|
|
isExpanded
|
|
? "max-w-[180px] opacity-100"
|
|
: "max-w-0 opacity-0",
|
|
)}
|
|
>
|
|
<span className="truncate font-medium">{item.title}</span>
|
|
{item.badge && (
|
|
<span className="shrink-0 rounded-full bg-brand-100 px-1.5 py-0.5 text-[10px] font-semibold text-brand-700">
|
|
{item.badge}
|
|
</span>
|
|
)}
|
|
</span>
|
|
</Link>
|
|
);
|
|
})}
|
|
</div>
|
|
</aside>
|
|
);
|
|
}
|
|
|
|
/**
|
|
* @description 모바일 하단 빠른 탭 네비게이션.
|
|
* @see features/layout/components/sidebar.tsx Sidebar와 같은 MENU_ITEMS를 공유해 중복 정의를 줄입니다.
|
|
*/
|
|
export function MobileBottomNav() {
|
|
const pathname = usePathname();
|
|
const bottomItems = MENU_ITEMS.filter(
|
|
(item) => item.showInBottomNav !== false,
|
|
);
|
|
|
|
return (
|
|
<nav
|
|
aria-label="모바일 빠른 메뉴"
|
|
className="fixed inset-x-0 bottom-0 z-40 border-t border-brand-100 bg-white/95 backdrop-blur-sm supports-backdrop-filter:bg-white/80 dark:border-brand-900/40 dark:bg-background/95 dark:supports-backdrop-filter:bg-background/80 md:hidden"
|
|
>
|
|
{/* ========== BOTTOM NAV ITEMS ========== */}
|
|
<div
|
|
className={cn(
|
|
"grid",
|
|
bottomItems.length === 4 ? "grid-cols-4" : "grid-cols-5",
|
|
)}
|
|
>
|
|
{bottomItems.map((item) => {
|
|
const isActive = item.matchExact
|
|
? pathname === item.href
|
|
: pathname.startsWith(item.href);
|
|
|
|
return (
|
|
<Link
|
|
key={`bottom-${item.href}`}
|
|
href={item.href}
|
|
className={cn(
|
|
"flex min-h-16 flex-col items-center justify-center gap-1.5 text-[11px] font-medium transition-colors",
|
|
isActive
|
|
? "text-brand-700"
|
|
: "text-muted-foreground hover:text-brand-700 dark:text-brand-200/80 dark:hover:text-brand-200",
|
|
)}
|
|
>
|
|
<span className="relative">
|
|
<item.icon
|
|
className={cn("h-4 w-4", isActive && "text-brand-600")}
|
|
/>
|
|
{item.badge && (
|
|
<span className="absolute -right-1 -top-1 h-2 w-2 rounded-full bg-brand-500" />
|
|
)}
|
|
</span>
|
|
<span className="leading-none">{item.title}</span>
|
|
</Link>
|
|
);
|
|
})}
|
|
</div>
|
|
</nav>
|
|
);
|
|
}
|