MarketSidebar.tsx 5.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155
  1. 'use client';
  2. import '@/app/styles/market-sidebar.scss';
  3. import Link from 'next/link';
  4. import { useCallback, useEffect, useRef, useState } from 'react';
  5. import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
  6. import { faAnglesLeft, faAnglesRight, faStar, faSun, faMoon } from '@fortawesome/free-solid-svg-icons';
  7. import useTheme from '@/hooks/useTheme';
  8. import useAuth from '@/hooks/useAuth';
  9. import { fetchApi } from '@/lib/utils/client';
  10. import { changeDirection, changeSymbol, formatChangeRate, formatNumber } from '@/lib/utils/stock';
  11. import { StockWatchListResponse, StockWatchRow } from '@/types/stock';
  12. const STORAGE_KEY = 'az-sidebar-collapsed';
  13. const WIDTH_EXPANDED = '220px';
  14. const WIDTH_COLLAPSED = '64px';
  15. // 좌측 관심종목 사이드바 — 로그인 회원의 관심종목(종목명·현재가·등락)을 표시. 종목 상세의 ☆ 토글과 'watchlist:changed' 이벤트로 연동.
  16. // 접기 메커니즘은 ChannelSidebar 와 동일: html 루트의 --antooza-sidebar-width 로 layout grid 열 폭 반응.
  17. export default function MarketSidebar()
  18. {
  19. const [collapsed, setCollapsed] = useState(true);
  20. const { toggleTheme, isDark } = useTheme();
  21. const { isAuthenticated } = useAuth();
  22. const [items, setItems] = useState<StockWatchRow[]>([]);
  23. const [loading, setLoading] = useState(false);
  24. const reqRef = useRef(0);
  25. // 최초 mount 시 localStorage에서 collapsed 상태 복원
  26. useEffect(() => {
  27. try {
  28. const saved = window.localStorage.getItem(STORAGE_KEY);
  29. if (saved === 'false') {
  30. setCollapsed(false);
  31. }
  32. } catch {}
  33. }, []);
  34. // collapsed 변경 시 html 루트 CSS 변수 동기화
  35. useEffect(() => {
  36. const root = document.documentElement;
  37. root.style.setProperty('--antooza-sidebar-width', collapsed ? WIDTH_COLLAPSED : WIDTH_EXPANDED);
  38. try {
  39. window.localStorage.setItem(STORAGE_KEY, String(collapsed));
  40. } catch {}
  41. return () => {
  42. // unmount 시 원복 (breakpoint 별 기본값으로 복귀)
  43. root.style.removeProperty('--antooza-sidebar-width');
  44. };
  45. }, [collapsed]);
  46. const loadItems = useCallback(() => {
  47. if (!isAuthenticated) {
  48. setItems([]);
  49. return;
  50. }
  51. const reqId = ++reqRef.current;
  52. setLoading(true);
  53. fetchApi<StockWatchListResponse>('/api/stocks/watchlist', { method: 'GET', silent: true })
  54. .then(res => {
  55. // 늦게 도착한 이전 요청이 최신 목록을 덮어쓰지 않도록 순번 가드 (auth effect + watchlist:changed 중첩 방지)
  56. if (reqId !== reqRef.current) {
  57. return;
  58. }
  59. if (res.success && res.data) {
  60. setItems(res.data.list ?? []);
  61. }
  62. })
  63. .catch(() => {})
  64. .finally(() => {
  65. if (reqId === reqRef.current) {
  66. setLoading(false);
  67. }
  68. });
  69. }, [isAuthenticated]);
  70. // 로그인 상태 변화 시 목록 로드
  71. useEffect(() => {
  72. loadItems();
  73. }, [loadItems]);
  74. // 상세 페이지 등에서 관심종목 변경 시 갱신
  75. useEffect(() => {
  76. const handler = () => loadItems();
  77. window.addEventListener('watchlist:changed', handler);
  78. return () => window.removeEventListener('watchlist:changed', handler);
  79. }, [loadItems]);
  80. const handleToggleCollapse = useCallback(() => {
  81. setCollapsed(prev => !prev);
  82. }, []);
  83. return (
  84. <div className={`market-sidebar${collapsed ? ' market-sidebar--collapsed' : ''}`}>
  85. {/* 헤더: 타이틀 + 접기 토글 */}
  86. <div className='market-sidebar__header'>
  87. {!collapsed && (
  88. <span className='market-sidebar__title'>관심종목</span>
  89. )}
  90. <button
  91. type='button'
  92. className='market-sidebar__collapse-btn'
  93. onClick={handleToggleCollapse}
  94. aria-label={collapsed ? '사이드바 펼치기' : '사이드바 접기'}
  95. aria-expanded={!collapsed}
  96. aria-controls='market-sidebar-body'
  97. title={collapsed ? '사이드바 펼치기' : '사이드바 접기'}
  98. >
  99. <FontAwesomeIcon icon={collapsed ? faAnglesRight : faAnglesLeft} />
  100. </button>
  101. </div>
  102. {/* 본문: 관심종목 목록 */}
  103. <div className='market-sidebar__body' id='market-sidebar-body'>
  104. {collapsed ? (
  105. <div className='market-sidebar__icon-placeholder' title='관심종목' aria-hidden='true'>
  106. <FontAwesomeIcon icon={faStar} />
  107. </div>
  108. ) : !isAuthenticated ? (
  109. <p className='market-sidebar__empty'>로그인 후 관심종목을<br />확인할 수 있습니다.</p>
  110. ) : loading ? (
  111. <p className='market-sidebar__empty'>불러오는 중…</p>
  112. ) : items.length === 0 ? (
  113. <p className='market-sidebar__empty'>관심종목이 없습니다.<br />종목 상세에서 ☆ 로 추가하세요.</p>
  114. ) : (
  115. <ul className='market-sidebar__list'>
  116. {items.map(item => {
  117. const dir = changeDirection(item.changeRate);
  118. return (
  119. <li key={item.code}>
  120. <Link href={`/stock/${item.code}`} className='market-sidebar__item'>
  121. <span className='market-sidebar__item-name'>{item.name}</span>
  122. <span className='market-sidebar__item-figures'>
  123. <span className='market-sidebar__item-close'>{formatNumber(item.closePrice)}</span>
  124. <span className={`market-sidebar__item-change stock-change stock-change--${dir}`}>{changeSymbol(dir)} {formatChangeRate(item.changeRate)}</span>
  125. </span>
  126. </Link>
  127. </li>
  128. );
  129. })}
  130. </ul>
  131. )}
  132. </div>
  133. {/* 하단: 테마 전환 (feature-off 로 ChannelSidebar 토글 미노출 시 여기서 제공) */}
  134. <div className='market-sidebar__footer'>
  135. <button type='button' className='market-sidebar__icon-btn' onClick={toggleTheme} aria-label={isDark ? '라이트 모드' : '다크 모드'} title={isDark ? '라이트 모드' : '다크 모드'}>
  136. <FontAwesomeIcon icon={isDark ? faSun : faMoon} />
  137. </button>
  138. </div>
  139. </div>
  140. );
  141. }