hooks 사용 할 수 있도록 설치합니다.
npm i usehooks-ts
shadcn에 있는 skeleton 사용하기 위해서 추가합니다.
npx shadcn-ui@latest add skeleton
레이아웃 파일을 생성 합니다. navbar가 상단에 있는 경우 콘텐츠가 보이지 않을 수 있어서 pt를 통해서 아래 보이도록 합니다.
메뉴를 표현하는 도구로 accordion과 separator를 추가합니다.
npx shadcn-ui@latest add accordion
npx shadcn-ui@latest add separator
/app/(platform)/(dashboard)/organization/layout.tsx
const OrganizationLayout = ({
children
} : {
children: React.ReactNode
}) => {
return (
<main className="pt-20 md:pt-24 px-4 max-w-6xl 2xl:max-w-screen-xl mx-auto">
{children}
</main>
)
}
export default OrganizationLayout
사이드바 콤포넌트를 생성합니다.
/app/(platform)/(dashboard)/_components/sidebar.tsx
"use client";
import { Skeleton } from "@/components/ui/skeleton";
import { useOrganization, useOrganizationList } from "@clerk/nextjs";
import { useLocalStorage } from "usehooks-ts";
interface SidebarProps {
storageKey?: string;
}
export const Sidebar = ({
storageKey = "t-sidebar-state"
}: SidebarProps) => {
const [expanded, setExpanded] = useLocalStorage<Record<string, any>>(storageKey, {});
const {
organization: activeOrganization,
isLoaded: isLoadedOrg
} = useOrganization();
const {
userMemberships,
isLoaded: isLoadedOrgList
} = useOrganizationList({
userMemberships: {
infinite: true
}
});
const defaultAccordionValue: string[] = Object.keys(expanded).reduce((acc: string[], key: string) => {
if (expanded[key]) {
acc.push(key);
}
return acc;
}, []);
const onExpand = (id: string) => {
setExpanded((curr) => ({
...curr,
[id]: !expanded[id]
}));
}
if(!isLoadedOrg || !isLoadedOrgList || userMemberships.isLoading) {
return (
<>
<Skeleton />
</>
)
}
return (
<div>
Sidebar
</div>
)
}
생성한 Sidebar를 불러오도록 layout.tsx 파일을 수정합니다.
/app/(platform)/(dashboard)/organization/layout.tsx
import { Sidebar } from "../_components/sidebar"
const OrganizationLayout = ({
children
} : {
children: React.ReactNode
}) => {
return (
<main className="pt-20 md:pt-24 px-4 max-w-6xl 2xl:max-w-screen-xl mx-auto">
<div className="flex gap-x7">
{/* 모바일에서는 보이지 않고 PC에서는 64px */}
<div className="w-64 shrink-0 hidden md:block">
{/* Sidebar */}
<Sidebar />
</div>
{children}
</div>
</main>
)
}
export default OrganizationLayout
사이드바를 마무리합니다.
/app/(platform)/(dashboard)/_components/sidebar.tsx
"use client";
import { Accordion } from "@/components/ui/accordion";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { useOrganization, useOrganizationList } from "@clerk/nextjs";
import { Plus } from "lucide-react";
import Link from "next/link";
import { useLocalStorage } from "usehooks-ts";
import { NavItem, Organization } from "./nav-item";
interface SidebarProps {
storageKey?: string;
}
export const Sidebar = ({
storageKey = "t-sidebar-state"
}: SidebarProps) => {
const [expanded, setExpanded] = useLocalStorage<Record<string, any>>(storageKey, {});
const {
organization: activeOrganization,
isLoaded: isLoadedOrg
} = useOrganization();
const {
userMemberships,
isLoaded: isLoadedOrgList
} = useOrganizationList({
userMemberships: {
infinite: true
}
});
const defaultAccordionValue: string[] = Object.keys(expanded).reduce((acc: string[], key: string) => {
if (expanded[key]) {
acc.push(key);
}
return acc;
}, []);
const onExpand = (id: string) => {
setExpanded((curr) => ({
...curr,
[id]: !expanded[id]
}));
}
if(!isLoadedOrg || !isLoadedOrgList || userMemberships.isLoading) {
return (
<>
<Skeleton />
</>
)
}
return (
<>
<div className="font-medium text-xs flex items-center mb-1">
<span className="pl-4">
Workspaces
</span>
<Button asChild type="button" size="icon" variant="ghost" className="ml-auto">
<Link href="/select-org">
<Plus className="h-4 w-4" />
</Link>
</Button>
</div>
<Accordion
type="multiple"
defaultValue={defaultAccordionValue}
className="space-y-2"
>
{userMemberships.data.map(({organization}) => (
<NavItem
key={organization.id}
isActive={activeOrganization?.id === organization.id}
isExpanded={expanded[organization.id]}
organization={organization as Organization}
onExpand={onExpand}
/>
))}
</Accordion>
</>
)
}
NavItem 선택하는 시점부터 네이게이션 아이템을 생성합니다.
/app/(platform)/(dashboard)/_components/nav-item.tsx
"use client";
import { AccordionTrigger, AccordionItem, AccordionContent } from "@/components/ui/accordion";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { Activity, CreditCard, Layout, Settings } from "lucide-react";
import Image from "next/image";
import { usePathname, useRouter } from "next/navigation";
export type Organization = {
id: string;
slug: string;
imageUrl: string;
name: string;
}
interface NavItemProps {
isExpanded: boolean;
isActive: boolean;
// organization: any;
organization: Organization;
onExpand: (id: string) => void;
}
export const NavItem = ({
isExpanded,
isActive,
organization,
onExpand
}: NavItemProps) => {
const router = useRouter();
const pathname = usePathname();
const routes = [
{
label: "Boards",
icon: <Layout className="h-4 w-4 mr-2" />,
href: `/organization/${organization.id}`
},
{
label: "Activity",
icon: <Activity className="h-4 w-4 mr-2" />,
href: `/organization/${organization.id}/activity`
},
{
label: "Settings",
icon: <Settings className="h-4 w-4 mr-2" />,
href: `/organization/${organization.id}/settings`
},
{
label: "Billing",
icon: <CreditCard className="h-4 w-4 mr-2" />,
href: `/organization/${organization.id}/billing`
}
];
const onClick = (href: string) => {
router.push(href);
}
return (
<AccordionItem
value={organization.id}
className="border-none"
>
<AccordionTrigger
onClick={() => onExpand(organization.id)}
className={cn(
"flex items-center gap-x-2 p-1.5 text-neutral-700 rounded-md hover:bg-neutral-500/10 transition text-start no-underline hover:no-underline",
isActive && !isExpanded && "bg-sky-500/10 text-sky-700"
)}
>
<div className="flex items-center gap-x-2">
<div className="w-7 h-7 relative">
<Image
fill
src={organization.imageUrl}
alt="Organization"
className="rounded-sm object-cover"
/>
</div>
<span className="font-medium text-sm">
{organization.name}
</span>
</div>
</AccordionTrigger>
<AccordionContent className="pt-1 text-neutral-700">
{routes.map((route)=>(
<Button
key={route.href}
size="sm"
onClick={() => onClick(route.href)}
className={cn(
"w-full font-normal justify-start pl-10 mb-1",
pathname === route.href && "bg-sky-500/10 text-sky-700"
)}
variant="ghost"
>
{route.icon}
{route.label}
</Button>
))}
</AccordionContent>
</AccordionItem>
)
}