Sidebar.tsx 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360
  1. 'use client';
  2. import { useState, useEffect, useCallback } from 'react';
  3. import Link from 'next/link';
  4. import Image from 'next/image';
  5. import { usePathname } from 'next/navigation';
  6. import { ChevronsUpDown } from 'lucide-react';
  7. import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
  8. import {
  9. faGauge,
  10. faHeart,
  11. faChevronDown,
  12. faBell,
  13. faBullseye,
  14. faTrophy,
  15. faUsers,
  16. faWallet,
  17. faCoins,
  18. faChartLine,
  19. faArrowRightFromBracket,
  20. faCalculator,
  21. faBuildingColumns,
  22. faFileLines,
  23. faArrowLeft,
  24. faCircleExclamation,
  25. } from '@fortawesome/free-solid-svg-icons';
  26. import {
  27. Sidebar as SidebarRoot,
  28. SidebarContent,
  29. SidebarHeader,
  30. SidebarMenu,
  31. SidebarMenuButton,
  32. SidebarMenuItem,
  33. SidebarMenuSub,
  34. SidebarMenuSubButton,
  35. SidebarMenuSubItem,
  36. SidebarGroup,
  37. SidebarGroupContent,
  38. useSidebar,
  39. } from '@/components/ui/sidebar';
  40. import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
  41. import {
  42. DropdownMenu,
  43. DropdownMenuContent,
  44. DropdownMenuItem,
  45. DropdownMenuLabel,
  46. DropdownMenuSeparator,
  47. DropdownMenuTrigger,
  48. } from '@/components/ui/dropdown-menu';
  49. import { fetchApi } from '@/lib/utils/client';
  50. import type { StudioSettingsResponse } from '@/types/response/studio/settings';
  51. import { User } from 'lucide-react';
  52. export default function Sidebar()
  53. {
  54. const pathname = usePathname();
  55. const { isMobile } = useSidebar();
  56. const [channel, setChannel] = useState<Pick<StudioSettingsResponse, 'isYouTubeConnected'|'channelName'|'handle'|'thumbnailUrl'>|null>(null);
  57. const isDonationPath = pathname.startsWith('/studio/donation');
  58. const isWalletPath = pathname.startsWith('/studio/wallet');
  59. const isSettlementPath = pathname.startsWith('/studio/settlement');
  60. const readCookie = (key: string, fallback: boolean) => {
  61. if (typeof document === 'undefined') return fallback;
  62. const match = document.cookie.match(new RegExp(`(?:^|; )${key}=([^;]*)`));
  63. if (!match) return fallback;
  64. return match[1] !== 'false';
  65. };
  66. const [donationOpen, setDonationOpen] = useState(() => readCookie('studio_menu_donation', true) || isDonationPath);
  67. const [walletOpen, setWalletOpen] = useState(() => readCookie('studio_menu_wallet', true) || isWalletPath);
  68. const [settlementOpen, setSettlementOpen] = useState(() => readCookie('studio_menu_settlement', true) || isSettlementPath);
  69. const saveCookie = useCallback((key: string, value: boolean) => {
  70. document.cookie = `${key}=${value}; path=/studio; max-age=${60 * 60 * 24 * 365}; SameSite=Lax`;
  71. }, []);
  72. const handleDonationToggle = useCallback((open: boolean) => {
  73. setDonationOpen(open);
  74. saveCookie('studio_menu_donation', open);
  75. }, [saveCookie]);
  76. const handleWalletToggle = useCallback((open: boolean) => {
  77. setWalletOpen(open);
  78. saveCookie('studio_menu_wallet', open);
  79. }, [saveCookie]);
  80. const handleSettlementToggle = useCallback((open: boolean) => {
  81. setSettlementOpen(open);
  82. saveCookie('studio_menu_settlement', open);
  83. }, [saveCookie]);
  84. const isActive = (href: string) => pathname === href || pathname.startsWith(href + '/');
  85. useEffect(() => {
  86. fetchApi<StudioSettingsResponse>('/api/studio/settings')
  87. .then(res => {
  88. if (res.data) {
  89. setChannel(res.data);
  90. }
  91. })
  92. .catch(() => {});
  93. }, []);
  94. const isConnected = channel?.isYouTubeConnected === true;
  95. return (
  96. <SidebarRoot collapsible="icon">
  97. <SidebarHeader>
  98. {/* 상단 Studio 라벨 + 돌아가기 버튼 */}
  99. <div className="flex items-center gap-1.5 px-2 py-1 group-data-[collapsible=icon]:hidden">
  100. <Link
  101. href="/"
  102. className="flex size-6 items-center justify-center rounded text-sidebar-foreground/50 hover:bg-sidebar-accent hover:text-sidebar-foreground transition-colors"
  103. title="뒤로가기"
  104. >
  105. <FontAwesomeIcon icon={faArrowLeft} className="text-xs" />
  106. </Link>
  107. <span className="text-xs font-semibold text-sidebar-foreground/50">Studio</span>
  108. </div>
  109. {/* YouTube 채널 카드 */}
  110. <SidebarMenu>
  111. <SidebarMenuItem>
  112. <DropdownMenu>
  113. <DropdownMenuTrigger asChild>
  114. <SidebarMenuButton
  115. size="lg"
  116. className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
  117. tooltip={channel?.channelName ?? 'YouTube 채널'}
  118. >
  119. {/* 아이콘 (collapse 시 이것만 표시) */}
  120. <div className={`relative flex size-8 shrink-0 items-center justify-center rounded-lg ${!channel?.thumbnailUrl ? 'bg-sidebar-primary' : 'border'} text-sidebar-primary-foreground overflow-hidden`}>
  121. {channel?.thumbnailUrl ? (
  122. <Image
  123. src={channel.thumbnailUrl}
  124. alt={channel.channelName ?? 'YouTube'}
  125. width={32}
  126. height={32}
  127. className="size-full object-cover"
  128. />
  129. ) : (
  130. <span className="text-xs font-bold">
  131. <User />
  132. </span>
  133. )}
  134. {/* 미연동 경고 뱃지 */}
  135. {channel !== null && !isConnected && (
  136. <span className="absolute -right-1 -bottom-1 flex size-3.5 items-center justify-center rounded-full bg-background">
  137. <FontAwesomeIcon icon={faCircleExclamation} className="text-[10px] text-amber-500" />
  138. </span>
  139. )}
  140. </div>
  141. {/* 텍스트 (collapse 시 hidden) */}
  142. <div className="grid flex-1 text-left text-sm leading-tight">
  143. <span className="truncate font-semibold">
  144. {isConnected
  145. ? (channel?.channelName ?? '채널 이름')
  146. : '채널 미연동'}
  147. </span>
  148. <span className="truncate text-xs text-sidebar-foreground/60">
  149. {isConnected
  150. ? (channel?.handle ?? '')
  151. : '채널을 연결해 주세요'}
  152. </span>
  153. </div>
  154. <ChevronsUpDown className="ml-auto size-4 shrink-0" />
  155. </SidebarMenuButton>
  156. </DropdownMenuTrigger>
  157. <DropdownMenuContent
  158. className="w-[--radix-dropdown-menu-trigger-width] min-w-56 rounded-lg"
  159. align="start"
  160. side={isMobile ? 'bottom' : 'right'}
  161. sideOffset={4}
  162. >
  163. {isConnected ? (
  164. <>
  165. <DropdownMenuLabel className="text-xs text-muted-foreground">
  166. {channel?.channelName}
  167. </DropdownMenuLabel>
  168. <DropdownMenuSeparator />
  169. <DropdownMenuItem asChild>
  170. <Link href="/studio/settings">채널 정보 보기</Link>
  171. </DropdownMenuItem>
  172. </>
  173. ) : (
  174. <>
  175. <DropdownMenuLabel className="text-xs text-muted-foreground">
  176. YouTube 채널이 연동되지 않았습니다
  177. </DropdownMenuLabel>
  178. <DropdownMenuSeparator />
  179. <DropdownMenuItem asChild>
  180. <Link href="/studio/settings">YouTube 연동하기</Link>
  181. </DropdownMenuItem>
  182. </>
  183. )}
  184. </DropdownMenuContent>
  185. </DropdownMenu>
  186. </SidebarMenuItem>
  187. </SidebarMenu>
  188. </SidebarHeader>
  189. <SidebarContent>
  190. <SidebarGroup>
  191. <SidebarGroupContent>
  192. <SidebarMenu>
  193. {/* 대시보드 */}
  194. <SidebarMenuItem>
  195. <SidebarMenuButton asChild isActive={isActive('/studio/dashboard')} tooltip="대시보드">
  196. <Link href="/studio/dashboard">
  197. <FontAwesomeIcon icon={faGauge} />
  198. <span>대시보드</span>
  199. </Link>
  200. </SidebarMenuButton>
  201. </SidebarMenuItem>
  202. {/* 후원 설정 */}
  203. <Collapsible open={donationOpen} onOpenChange={handleDonationToggle}>
  204. <SidebarMenuItem>
  205. <CollapsibleTrigger asChild>
  206. <SidebarMenuButton isActive={isDonationPath} tooltip="후원 설정">
  207. <FontAwesomeIcon icon={faHeart} />
  208. <span>후원 설정</span>
  209. <FontAwesomeIcon
  210. icon={faChevronDown}
  211. className={`ml-auto text-xs transition-transform duration-200${donationOpen ? ' rotate-180' : ''}`}
  212. />
  213. </SidebarMenuButton>
  214. </CollapsibleTrigger>
  215. <CollapsibleContent>
  216. <SidebarMenuSub>
  217. <SidebarMenuSubItem>
  218. <SidebarMenuSubButton asChild isActive={isActive('/studio/donation/alert')}>
  219. <Link href="/studio/donation/alert">
  220. <FontAwesomeIcon icon={faBell} />
  221. <span>알림</span>
  222. </Link>
  223. </SidebarMenuSubButton>
  224. </SidebarMenuSubItem>
  225. <SidebarMenuSubItem>
  226. <SidebarMenuSubButton asChild isActive={isActive('/studio/donation/goal')}>
  227. <Link href="/studio/donation/goal">
  228. <FontAwesomeIcon icon={faBullseye} />
  229. <span>목표</span>
  230. </Link>
  231. </SidebarMenuSubButton>
  232. </SidebarMenuSubItem>
  233. <SidebarMenuSubItem>
  234. <SidebarMenuSubButton asChild isActive={isActive('/studio/donation/rank')}>
  235. <Link href="/studio/donation/rank">
  236. <FontAwesomeIcon icon={faTrophy} />
  237. <span>순위</span>
  238. </Link>
  239. </SidebarMenuSubButton>
  240. </SidebarMenuSubItem>
  241. <SidebarMenuSubItem>
  242. <SidebarMenuSubButton asChild isActive={isActive('/studio/donation/crew')}>
  243. <Link href="/studio/donation/crew">
  244. <FontAwesomeIcon icon={faUsers} />
  245. <span>크루 후원</span>
  246. </Link>
  247. </SidebarMenuSubButton>
  248. </SidebarMenuSubItem>
  249. </SidebarMenuSub>
  250. </CollapsibleContent>
  251. </SidebarMenuItem>
  252. </Collapsible>
  253. {/* 지갑 */}
  254. <Collapsible open={walletOpen} onOpenChange={handleWalletToggle}>
  255. <SidebarMenuItem>
  256. <CollapsibleTrigger asChild>
  257. <SidebarMenuButton isActive={isWalletPath} tooltip="지갑">
  258. <FontAwesomeIcon icon={faWallet} />
  259. <span>지갑</span>
  260. <FontAwesomeIcon
  261. icon={faChevronDown}
  262. className={`ml-auto text-xs transition-transform duration-200${walletOpen ? ' rotate-180' : ''}`}
  263. />
  264. </SidebarMenuButton>
  265. </CollapsibleTrigger>
  266. <CollapsibleContent>
  267. <SidebarMenuSub>
  268. <SidebarMenuSubItem>
  269. <SidebarMenuSubButton asChild isActive={isActive('/studio/wallet/balance')}>
  270. <Link href="/studio/wallet/balance">
  271. <FontAwesomeIcon icon={faCoins} />
  272. <span>잔액</span>
  273. </Link>
  274. </SidebarMenuSubButton>
  275. </SidebarMenuSubItem>
  276. <SidebarMenuSubItem>
  277. <SidebarMenuSubButton asChild isActive={isActive('/studio/wallet/revenue')}>
  278. <Link href="/studio/wallet/revenue">
  279. <FontAwesomeIcon icon={faChartLine} />
  280. <span>수익</span>
  281. </Link>
  282. </SidebarMenuSubButton>
  283. </SidebarMenuSubItem>
  284. <SidebarMenuSubItem>
  285. <SidebarMenuSubButton asChild isActive={isActive('/studio/wallet/withdraw')}>
  286. <Link href="/studio/wallet/withdraw">
  287. <FontAwesomeIcon icon={faArrowRightFromBracket} />
  288. <span>출금</span>
  289. </Link>
  290. </SidebarMenuSubButton>
  291. </SidebarMenuSubItem>
  292. </SidebarMenuSub>
  293. </CollapsibleContent>
  294. </SidebarMenuItem>
  295. </Collapsible>
  296. {/* 정산 */}
  297. <Collapsible open={settlementOpen} onOpenChange={handleSettlementToggle}>
  298. <SidebarMenuItem>
  299. <CollapsibleTrigger asChild>
  300. <SidebarMenuButton isActive={isSettlementPath} tooltip="정산">
  301. <FontAwesomeIcon icon={faCalculator} />
  302. <span>정산</span>
  303. <FontAwesomeIcon
  304. icon={faChevronDown}
  305. className={`ml-auto text-xs transition-transform duration-200${settlementOpen ? ' rotate-180' : ''}`}
  306. />
  307. </SidebarMenuButton>
  308. </CollapsibleTrigger>
  309. <CollapsibleContent>
  310. <SidebarMenuSub>
  311. <SidebarMenuSubItem>
  312. <SidebarMenuSubButton asChild isActive={isActive('/studio/settlement/account')}>
  313. <Link href="/studio/settlement/account">
  314. <FontAwesomeIcon icon={faBuildingColumns} />
  315. <span>계좌 관리</span>
  316. </Link>
  317. </SidebarMenuSubButton>
  318. </SidebarMenuSubItem>
  319. <SidebarMenuSubItem>
  320. <SidebarMenuSubButton asChild isActive={isActive('/studio/settlement/tax')}>
  321. <Link href="/studio/settlement/tax">
  322. <FontAwesomeIcon icon={faFileLines} />
  323. <span>세금계산서</span>
  324. </Link>
  325. </SidebarMenuSubButton>
  326. </SidebarMenuSubItem>
  327. </SidebarMenuSub>
  328. </CollapsibleContent>
  329. </SidebarMenuItem>
  330. </Collapsible>
  331. </SidebarMenu>
  332. </SidebarGroupContent>
  333. </SidebarGroup>
  334. </SidebarContent>
  335. </SidebarRoot>
  336. );
  337. }