|
|
@@ -1,22 +1,30 @@
|
|
|
'use client';
|
|
|
|
|
|
import '@/app/styles/market-sidebar.scss';
|
|
|
-import { useCallback, useEffect, useState } from 'react';
|
|
|
+import Link from 'next/link';
|
|
|
+import { useCallback, useEffect, useRef, useState } from 'react';
|
|
|
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
|
|
|
import { faAnglesLeft, faAnglesRight, faStar, faSun, faMoon } from '@fortawesome/free-solid-svg-icons';
|
|
|
import useTheme from '@/hooks/useTheme';
|
|
|
+import useAuth from '@/hooks/useAuth';
|
|
|
+import { fetchApi } from '@/lib/utils/client';
|
|
|
+import { changeDirection, changeSymbol, formatChangeRate, formatNumber } from '@/lib/utils/stock';
|
|
|
+import { StockWatchListResponse, StockWatchRow } from '@/types/stock';
|
|
|
|
|
|
const STORAGE_KEY = 'az-sidebar-collapsed';
|
|
|
const WIDTH_EXPANDED = '220px';
|
|
|
const WIDTH_COLLAPSED = '64px';
|
|
|
|
|
|
-// 좌측 사이드바 자리표시 (D6 그리드 셸) — 관심종목/게시판 즐겨찾기 본구현은 후속(MarketSidebar 본체)
|
|
|
-// 접기 메커니즘은 ChannelSidebar 와 동일: html 루트의 --antooza-sidebar-width 로 layout grid 열 폭 반응
|
|
|
+// 좌측 관심종목 사이드바 — 로그인 회원의 관심종목(종목명·현재가·등락)을 표시. 종목 상세의 ☆ 토글과 'watchlist:changed' 이벤트로 연동.
|
|
|
+// 접기 메커니즘은 ChannelSidebar 와 동일: html 루트의 --antooza-sidebar-width 로 layout grid 열 폭 반응.
|
|
|
export default function MarketSidebar()
|
|
|
{
|
|
|
- // 자리표시 단계라 기본 접힘(64px 아이콘 레일) — 본문 우선. 토글 시 localStorage 영속.
|
|
|
const [collapsed, setCollapsed] = useState(true);
|
|
|
const { toggleTheme, isDark } = useTheme();
|
|
|
+ const { isAuthenticated } = useAuth();
|
|
|
+ const [items, setItems] = useState<StockWatchRow[]>([]);
|
|
|
+ const [loading, setLoading] = useState(false);
|
|
|
+ const reqRef = useRef(0);
|
|
|
|
|
|
// 최초 mount 시 localStorage에서 collapsed 상태 복원
|
|
|
useEffect(() => {
|
|
|
@@ -36,11 +44,49 @@ export default function MarketSidebar()
|
|
|
window.localStorage.setItem(STORAGE_KEY, String(collapsed));
|
|
|
} catch {}
|
|
|
return () => {
|
|
|
- // unmount 시 원복 (breakpoint 별 --az-sidebar-w 기본값으로 복귀)
|
|
|
+ // unmount 시 원복 (breakpoint 별 기본값으로 복귀)
|
|
|
root.style.removeProperty('--antooza-sidebar-width');
|
|
|
};
|
|
|
}, [collapsed]);
|
|
|
|
|
|
+ const loadItems = useCallback(() => {
|
|
|
+ if (!isAuthenticated) {
|
|
|
+ setItems([]);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ const reqId = ++reqRef.current;
|
|
|
+ setLoading(true);
|
|
|
+ fetchApi<StockWatchListResponse>('/api/stocks/watchlist', { method: 'GET', silent: true })
|
|
|
+ .then(res => {
|
|
|
+ // 늦게 도착한 이전 요청이 최신 목록을 덮어쓰지 않도록 순번 가드 (auth effect + watchlist:changed 중첩 방지)
|
|
|
+ if (reqId !== reqRef.current) {
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ if (res.success && res.data) {
|
|
|
+ setItems(res.data.list ?? []);
|
|
|
+ }
|
|
|
+ })
|
|
|
+ .catch(() => {})
|
|
|
+ .finally(() => {
|
|
|
+ if (reqId === reqRef.current) {
|
|
|
+ setLoading(false);
|
|
|
+ }
|
|
|
+ });
|
|
|
+ }, [isAuthenticated]);
|
|
|
+
|
|
|
+ // 로그인 상태 변화 시 목록 로드
|
|
|
+ useEffect(() => {
|
|
|
+ loadItems();
|
|
|
+ }, [loadItems]);
|
|
|
+
|
|
|
+ // 상세 페이지 등에서 관심종목 변경 시 갱신
|
|
|
+ useEffect(() => {
|
|
|
+ const handler = () => loadItems();
|
|
|
+ window.addEventListener('watchlist:changed', handler);
|
|
|
+ return () => window.removeEventListener('watchlist:changed', handler);
|
|
|
+ }, [loadItems]);
|
|
|
+
|
|
|
const handleToggleCollapse = useCallback(() => {
|
|
|
setCollapsed(prev => !prev);
|
|
|
}, []);
|
|
|
@@ -58,24 +104,47 @@ export default function MarketSidebar()
|
|
|
onClick={handleToggleCollapse}
|
|
|
aria-label={collapsed ? '사이드바 펼치기' : '사이드바 접기'}
|
|
|
aria-expanded={!collapsed}
|
|
|
+ aria-controls='market-sidebar-body'
|
|
|
title={collapsed ? '사이드바 펼치기' : '사이드바 접기'}
|
|
|
>
|
|
|
<FontAwesomeIcon icon={collapsed ? faAnglesRight : faAnglesLeft} />
|
|
|
</button>
|
|
|
</div>
|
|
|
|
|
|
- {/* 자리표시 본문 */}
|
|
|
- <div className='market-sidebar__body'>
|
|
|
+ {/* 본문: 관심종목 목록 */}
|
|
|
+ <div className='market-sidebar__body' id='market-sidebar-body'>
|
|
|
{collapsed ? (
|
|
|
- <div className='market-sidebar__icon-placeholder' title='관심종목 (준비 중)' aria-hidden='true'>
|
|
|
+ <div className='market-sidebar__icon-placeholder' title='관심종목' aria-hidden='true'>
|
|
|
<FontAwesomeIcon icon={faStar} />
|
|
|
</div>
|
|
|
+ ) : !isAuthenticated ? (
|
|
|
+ <p className='market-sidebar__empty'>로그인 후 관심종목을<br />확인할 수 있습니다.</p>
|
|
|
+ ) : loading ? (
|
|
|
+ <p className='market-sidebar__empty'>불러오는 중…</p>
|
|
|
+ ) : items.length === 0 ? (
|
|
|
+ <p className='market-sidebar__empty'>관심종목이 없습니다.<br />종목 상세에서 ☆ 로 추가하세요.</p>
|
|
|
) : (
|
|
|
- <p className='market-sidebar__empty'>관심종목 기능을 준비 중입니다</p>
|
|
|
+ <ul className='market-sidebar__list'>
|
|
|
+ {items.map(item => {
|
|
|
+ const dir = changeDirection(item.changeRate);
|
|
|
+
|
|
|
+ return (
|
|
|
+ <li key={item.code}>
|
|
|
+ <Link href={`/stock/${item.code}`} className='market-sidebar__item'>
|
|
|
+ <span className='market-sidebar__item-name'>{item.name}</span>
|
|
|
+ <span className='market-sidebar__item-figures'>
|
|
|
+ <span className='market-sidebar__item-close'>{formatNumber(item.closePrice)}</span>
|
|
|
+ <span className={`market-sidebar__item-change stock-change stock-change--${dir}`}>{changeSymbol(dir)} {formatChangeRate(item.changeRate)}</span>
|
|
|
+ </span>
|
|
|
+ </Link>
|
|
|
+ </li>
|
|
|
+ );
|
|
|
+ })}
|
|
|
+ </ul>
|
|
|
)}
|
|
|
</div>
|
|
|
|
|
|
- {/* 하단: 테마 전환 (기존 ChannelSidebar 토글이 feature-off 로 미노출되어 여기서 제공) */}
|
|
|
+ {/* 하단: 테마 전환 (feature-off 로 ChannelSidebar 토글 미노출 시 여기서 제공) */}
|
|
|
<div className='market-sidebar__footer'>
|
|
|
<button type='button' className='market-sidebar__icon-btn' onClick={toggleTheme} aria-label={isDark ? '라이트 모드' : '다크 모드'} title={isDark ? '라이트 모드' : '다크 모드'}>
|
|
|
<FontAwesomeIcon icon={isDark ? faSun : faMoon} />
|
|
|
@@ -83,4 +152,4 @@ export default function MarketSidebar()
|
|
|
</div>
|
|
|
</div>
|
|
|
);
|
|
|
-}
|
|
|
+}
|