ionian/packages/client/src/components/layout/nav.tsx

281 lines
No EOL
9.4 KiB
TypeScript

import { Link } from "wouter"
import { SearchIcon, SettingsIcon, TagsIcon, ArchiveIcon, UserIcon, LayoutListIcon, PanelLeftIcon, PanelLeftCloseIcon, MenuIcon, XIcon, type LucideIcon } from "lucide-react"
import { Button, buttonVariants } from "@/components/ui/button.tsx"
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip.tsx"
import { useLogin } from "@/state/user.ts";
import { useNavItems } from "./navAtom";
import { Separator } from "../ui/separator";
import { cn } from "@/lib/utils";
import { useMemo } from "react";
import { useAtom, useAtomValue } from "jotai";
import { sidebarAtom } from "./sidebarAtom";
const NAV_ICON_CLASS = "size-4";
const NAV_LINKS = {
search: { to: "/search", Icon: SearchIcon },
tags: { to: "/tags", Icon: TagsIcon },
difference: { to: "/difference", Icon: ArchiveIcon },
queue: { to: "/queue", Icon: LayoutListIcon },
settings: { to: "/setting", Icon: SettingsIcon }
} satisfies Record<string, { to: string; Icon: LucideIcon }>;
type NavLinkKey = keyof typeof NAV_LINKS;
type NavItemData = {
key: string;
icon: React.ReactNode;
to?: string;
name: string;
className?: string;
}
const createNavItem = (key: NavLinkKey, name: string, className?: string): NavItemData => {
const { Icon, to } = NAV_LINKS[key];
return {
key,
icon: <Icon className={NAV_ICON_CLASS} />,
to,
name,
className
};
};
function useNavItemsData() {
const loginInfo = useLogin();
const isLoggedIn = Boolean(loginInfo);
return useMemo(() => {
const accountItem: NavItemData = {
key: "account",
icon: <UserIcon className={NAV_ICON_CLASS} />,
to: isLoggedIn ? "/profile" : "/login",
name: isLoggedIn ? "Profiles" : "Login",
};
return ({
main: [
createNavItem("search", "Search"),
createNavItem("tags", "Tags"),
createNavItem("difference", "Difference"),
],
footer: [
accountItem,
createNavItem("settings", "Settings")
],
bottomNav: (useCustomItems: boolean) => useCustomItems ? [] : [
createNavItem("tags", "Tags"),
createNavItem("difference", "Diff"),
{ ...accountItem, name: isLoggedIn ? "Profile" : "Login" },
createNavItem("settings", "Settings")
]
})
}, [isLoggedIn]);
}
export function NavList() {
const customNavItems = useNavItems();
const { main, footer } = useNavItemsData();
return (
<aside className="h-dvh flex flex-col">
<nav className="flex flex-col items-center gap-4 px-2 sm:py-5 flex-1">
{customNavItems && (
<>
{customNavItems()}
<Separator />
</>
)}
{main.map(({ key, icon, to, name, className }) => (
<SidebarNavItem key={key} name={name} to={to} className={className}>
{icon}
</SidebarNavItem>
))}
</nav>
<nav className="mt-auto flex flex-col items-center gap-4 px-2 sm:py-5 flex-grow-0">
{footer.map(({ key, icon, to, name, className }) => (
<SidebarNavItem key={key} name={name} to={to} className={className}>
{icon}
</SidebarNavItem>
))}
</nav>
</aside>
);
}
// 사이드바 토글 버튼
export function SidebarToggle() {
const [sidebarState, setSidebarState] = useAtom(sidebarAtom);
const isOpen = sidebarState.isCollapsed;
const onToggle = () => setSidebarState((s) => ({ ...s, isCollapsed: !s.isCollapsed }));
return (
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="sm" onClick={onToggle} className="size-8 p-0">
{isOpen ? <PanelLeftCloseIcon className="size-4" /> : <PanelLeftIcon className="size-4" />}
<span className="sr-only">{isOpen ? "Close sidebar" : "Open sidebar"}</span>
</Button>
</TooltipTrigger>
<TooltipContent side="right">
{isOpen ? "Close sidebar" : "Open sidebar"}
</TooltipContent>
</Tooltip>
);
}
// 모바일용 사이드바 토글 버튼
export function MobileSidebarToggle() {
const [sidebarState, setSidebarState] = useAtom(sidebarAtom);
const isOpen = sidebarState.isCollapsed;
const onToggle = () => setSidebarState((s) => ({ ...s, isCollapsed: !s.isCollapsed }));
return (
<Button variant="ghost" size="sm" onClick={onToggle} className="size-8 p-0">
{isOpen ? <XIcon className="size-5" /> : <MenuIcon className="size-5" />}
<span className="sr-only">{isOpen ? "Close menu" : "Open menu"}</span>
</Button>
);
}
// 사이드바 네비게이션 아이템
interface SidebarNavItemProps {
children: React.ReactNode;
name: string;
to?: string;
className?: string;
onClick?: () => void;
}
export function SidebarNavItem({ children, name, to, className, onClick }: SidebarNavItemProps) {
const sidebarState = useAtomValue(sidebarAtom);
const isCollapsed = sidebarState.isCollapsed;
const buttonClass = cn(
buttonVariants({ variant: "ghost", size: "sm" }),
"rounded-none md:rounded-md",
isCollapsed ? "justify-center size-10 p-0" : "justify-start gap-3 h-10 px-3",
className
);
const Container = to ? ({ children, ref }: { children: React.ReactNode,
ref?: React.Ref<HTMLAnchorElement>
}) => (
<Link
ref={ref}
href={to} className={buttonClass}
onClick={onClick}
>
{children}
</Link>
) : ({ children, ref }: { children: React.ReactNode,
ref?: React.Ref<HTMLButtonElement>
}) => (
<button className={buttonClass} onClick={onClick} ref={ref}>
{children}
</button>
);
const linkContent = (
<Container>
{children}
<span className={cn(
"sr-only",
!isCollapsed && "text-sm truncate leading-normal md:not-sr-only"
)
}>{name}</span>
</Container>
);
if (isCollapsed) {
return (
<Tooltip>
<TooltipTrigger asChild>{linkContent}</TooltipTrigger>
<TooltipContent side="right">{name}</TooltipContent>
</Tooltip>
);
}
return linkContent;
}
// 데스크탑용 사이드바 네비게이션
export function SidebarNav() {
const customNavItems = useNavItems();
const { main, footer } = useNavItemsData();
return (
<div className="flex flex-col h-full">
<nav className="flex flex-col gap-2 p-3 flex-1 min-h-0">
{customNavItems && (
<>
<div className={cn("flex flex-col gap-2")}>
{customNavItems()}
</div>
<Separator className="my-3" />
</>
)}
<div className="flex flex-col gap-2">
{main.map(({ key, icon, to, name, className }) => (
<SidebarNavItem
key={key}
name={name}
to={to}
className={className}
>
{icon}
</SidebarNavItem>
))}
</div>
</nav>
<div className="border-t p-3 flex flex-col gap-2 flex-shrink-0">
{footer.map(({ key, icon, to, name, className }) => (
<SidebarNavItem
key={key}
name={name}
to={to}
className={className}
>
{icon}
</SidebarNavItem>
))}
</div>
</div>
);
}
// 모바일용 하단 네비게이션
export function BottomNav() {
const customNavItems = useNavItems();
const { main, bottomNav } = useNavItemsData();
const searchItem = { ...main[0] }; // Search item
const items = bottomNav(Boolean(customNavItems));
return (
<nav className="mb-1">
<div className="flex justify-around items-center max-w-md mx-auto overflow-hidden bg-background/50 backdrop-blur-md border rounded-full">
<SidebarNavItem
name={searchItem.name}
to={searchItem.to}
className={searchItem.className}
>
{searchItem.icon}
</SidebarNavItem>
{customNavItems ? (
customNavItems()
) : (
items.map(({ key, icon, to, name, className }) =>
<SidebarNavItem
key={key}
name={name}
to={to}
className={className}
>
{icon}
</SidebarNavItem>
)
)}
</div>
</nav>
);
}