Written by
Parkdev
on
on
[Next.js] Sidebar 만들기 + code review
- 라이브러리 설치
# 여러 추가 hook을 사용할 수 있다.
npm install usehooks-ts
- 작성 코드
"use client";
import Link from "next/link";
import { Plus } from "lucide-react";
import { useLocalStorage } from "usehooks-ts";
import { useOrganization, useOrganizationList } from "@clerk/nextjs";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { Skeleton } from "@/components/ui/skeleton";
import { Accordion } from "@/components/ui/accordion";
interface SidebarProps {
storageKey?: string;
}
export const Sidebar = ({ storageKey = "t-sidebar-state" }: SidebarProps) => { // 초기값 설정
// Localstorage에 사용되는 Key와 value의 type정의
const [expanded, setExpanded] = useLocalStorage<Record<string, any>>(
storageKey,
{}
);
// 사용할 value만 재정의 (회사정보, 로드 정보)
const { organization: activeOrganization, isLoaded: isLoadedOrg } =
useOrganization();
// 맴버정보, 로드 정보
const { userMemberships, isLoaded: isLoadedOrgList } = useOrganizationList({
userMemberships: {
infinite: true, //무한리스트 사용 설정
},
});
// 로컬스토리지(메뉴목록)을 돌며, key가 true인 경우 해당 key를 defaultAccordionValue array에 추가
// {"myorg-123": true} => ["myorg-123"]
// ex) ['org_2cUZMP7J7xibJlMXPP1NPdurJro', 'org_2cUDkHhAtBXtfzlCmB8WhFT3M5l']
const defaultAccordionValue: string[] = Object.keys(expanded).reduce(acc: string[], key: string) => {
if (expanded[key]) {
acc.push(key);
}
return acc;
}, []);
// 펼처진 행의 id를 받아서 펼치고 받은 아이디에 대해서 value를 반대로 지정
// 현재 값 {"key" : boolean} 형태의 object를 그대로 복사해서, id를 key로가지는 boolean을 반대로 만들어주는 역할을 한다.
// 참고
// const onExpand = (id: string) => {
// console.log("start on Expand");
// console.log("id : ", id); // org_xxxxx
// setExpanded((curr) => {
// console.log("curr : ", curr); //{ org_xxxxx: false }
// let a = [...curr];
// console.log("...curr : ", a); /// { org_xxxx: false }
// return {
// ...curr,
// [id]: !expanded[id],
// };
});
console.log(expande
const onExpand = (id: string) => {
setExpanded((curr) => ({ // object를 return하는데
...curr, //current값을 그대로 복사해와서
[id]: !expanded[id], // id에 할당된 value값만 뒤집어서
})); // localstorage에 저장한다.
};
//로딩이 완료되지 않았을때 스켈레톤 노출
if (!isLoadedOrg || !isLoadedOrgList || userMemberships.isLoading) {
return (
<>
<Skeleton />
</>
);
}
return (
<>
<div className="font-medium text-xs flex items-center mb-1">
<span className="pl-4">Workspace</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} // 해당 조직 정보 전달 (organization 객체를 Organization 타입으로 단언)
onExpand={onExpand} // 열림상태변환 토글함수 전달
/>
))}
</Accordion>
</>
);
};
비고. reduce의 기본 구조
array.reduce((accumulator, currentValue) => {
// 로직
}, initialValue);
// 누적값과, 현재 값을 인자로 받으며 초기값을 시작으로 각 값을 돌며 로직을 실행한 후 하나의 return 객체를 반환해주는 함수이다.
//nav-item component
//app/(dashboard)/_compoenets/nav-item.tsx
"use client"; //CSR 컴포넌트로 지정
import { useRouter, usePathname } from "next/navigation"; // AppRouter을 사용하려면 next/navigation을 사용해야한다.
import Image from "next/image";
import { Activity, CreditCard, Layout, Settings } from "lucide-react";
import { cn } from "@/lib/utils";
import {
AccordionContent,
AccordionItem,
AccordionTrigger,
} from "@/components/ui/accordion";
import { Button } from "@/components/ui/button";
export type Organization = {
id: string;
slug: string;
imageUrl: string;
name: string;
};
// 인터페이스 설정 type과 비슷하지만 몇가지 다른점이있다.
// 1. interface는 확장이 가능하며, interface는 다른 interface나 type과 결합이 가능하다
// 3. 중복 선언시 선언이 결합된다. type을 중복해서 정의할경우 오류를 발생시킨다.
interface NavItemProps {
isExpanded: boolean;
isActive: boolean;
organization: any;
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">
{/* 외부 이미지를 사용하기 위해서는 next.config.js에 허용설정을 해야한다. *코드 3 참조*/}
<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>
);
};
- 코드3. 외부 이미지를 사용시 next.config.mjs에서 사용 설정을 해야한다.
// /next.config.mjs
/** @type {import('next').NextConfig} */
//const nextConfig = {
images: {
remotePatterns: [
{
protocol: "https",
hostname: "img.clerk.com",
},
],
},
//};
//export default nextConfig;