281 lines
No EOL
9.4 KiB
TypeScript
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>
|
|
);
|
|
} |