goldenChat base source add

This commit is contained in:
aidev
2026-05-23 15:11:48 +09:00
commit a4ea7762b5
2081 changed files with 1155760 additions and 0 deletions
+415
View File
@@ -0,0 +1,415 @@
/**
* 좌측 마켓 패널
*
* 탭 구성:
* 원화 - KRW 마켓 목록, 현재가(KRW)
* BTC - KRW 마켓 목록, 현재가(USD 환산)
* 보유 - 보유 종목 목록 (localStorage 관리)
* 관심 - 즐겨찾기 목록 (localStorage 관리)
*/
import React, { useState, useCallback, useMemo, useRef, useEffect, memo } from 'react';
import type { TickerData, ChangeDir } from '../hooks/useMarketTicker';
import {
getFavorites, saveFavorites, toggleFavorite,
getHoldings, addHolding, removeHolding,
} from '../utils/marketStorage';
// ─── 타입 ──────────────────────────────────────────────────────────────────
type Tab = 'krw' | 'btc' | 'hold' | 'fav';
type SortKey = 'name' | 'price' | 'change';
type SortDir = 'asc' | 'desc';
export interface MarketPanelProps {
open: boolean;
tickers: Map<string, TickerData>;
loading: boolean;
usdRate: number; // KRW per 1 USD (from KRW-USDT)
activeSymbol: string;
onSymbolSelect: (symbol: string) => void;
}
// ─── 가격 포맷 헬퍼 ────────────────────────────────────────────────────────
function fmtKrw(price: number | null | undefined): string {
if (price == null || !isFinite(price)) return '-';
if (price >= 1_000_000) return price.toLocaleString('ko-KR', { maximumFractionDigits: 0 });
if (price >= 1_000) return price.toLocaleString('ko-KR', { maximumFractionDigits: 0 });
if (price >= 1) return price.toLocaleString('ko-KR', { maximumFractionDigits: 2 });
if (price >= 0.01) return price.toFixed(4);
return price.toFixed(8);
}
function fmtUsd(krwPrice: number | null | undefined, rate: number): string {
if (krwPrice == null || !isFinite(krwPrice) || !rate) return '-';
const usd = krwPrice / rate;
if (usd >= 10_000) return '$' + usd.toLocaleString('en-US', { maximumFractionDigits: 0 });
if (usd >= 1) return '$' + usd.toFixed(2);
if (usd >= 0.001) return '$' + usd.toFixed(4);
return '$' + usd.toFixed(6);
}
function fmtPct(rate: number | null | undefined): string {
if (rate == null || !isFinite(rate)) return '-';
const sign = rate >= 0 ? '+' : '';
return `${sign}${(rate * 100).toFixed(2)}%`;
}
// ─── 방향 지시자 삼각형 ────────────────────────────────────────────────────
const ChangeArrow = memo(function ChangeArrow({ change }: { change: ChangeDir }) {
if (change === 'RISE') return <span className="mp-arrow mp-arrow--rise"></span>;
if (change === 'FALL') return <span className="mp-arrow mp-arrow--fall"></span>;
return <span className="mp-arrow mp-arrow--even"></span>;
});
// ─── 개별 행 ───────────────────────────────────────────────────────────────
interface RowProps {
ticker: TickerData | undefined;
market: string;
isActive: boolean;
isFav: boolean;
isHold: boolean;
showUsd: boolean;
showHoldBtn: boolean;
usdRate: number;
onSelect: () => void;
onToggleFav: (e: React.MouseEvent) => void;
onToggleHold: (e: React.MouseEvent) => void;
onRemoveHold?: (e: React.MouseEvent) => void;
}
const MarketRow = memo(function MarketRow({
ticker, market, isActive, isFav, isHold,
showUsd, showHoldBtn, usdRate,
onSelect, onToggleFav, onToggleHold, onRemoveHold,
}: RowProps) {
const change: ChangeDir = ticker?.change ?? 'EVEN';
const code = market.replace(/^KRW-/, '');
// ── 가격 변동 플래시 (frontend_golden TickerItem 로직) ─────────────────
const [flashClass, setFlashClass] = useState('');
const prevPriceRef = useRef<number | null>(null);
useEffect(() => {
const price = ticker?.tradePrice ?? null;
if (price === null) return;
const prev = prevPriceRef.current;
if (prev !== null && prev !== price) {
const cls = price > prev ? 'mp-row--flash-rise' : 'mp-row--flash-fall';
setFlashClass(cls);
const t = setTimeout(() => setFlashClass(''), 700);
prevPriceRef.current = price;
return () => clearTimeout(t);
}
prevPriceRef.current = price;
}, [ticker?.tradePrice]);
const colorCls = change === 'RISE' ? 'mp-up' : change === 'FALL' ? 'mp-dn' : 'mp-even';
return (
<div
className={`mp-row ${isActive ? 'mp-row--active' : ''} ${flashClass}`}
onClick={onSelect}
>
{/* 즐겨찾기 별 */}
<button
className={`mp-star ${isFav ? 'mp-star--on' : ''}`}
onClick={onToggleFav}
title={isFav ? '관심 해제' : '관심 등록'}
>
{isFav ? '★' : '☆'}
</button>
{/* 이름 + 방향 지시자 */}
<div className="mp-name">
<span className="mp-korean-row">
<ChangeArrow change={change} />
<span className="mp-korean">{ticker?.koreanName ?? code}</span>
</span>
<span className="mp-code">{code}/KRW</span>
</div>
{/* 현재가 */}
<span className={`mp-price ${colorCls}`}>
{ticker
? (showUsd ? fmtUsd(ticker.tradePrice, usdRate) : fmtKrw(ticker.tradePrice))
: '-'}
</span>
{/* 전일대비 */}
<span className={`mp-change ${colorCls}`}>
{ticker ? fmtPct(ticker.changeRate) : '-'}
</span>
{/* 보유 토글 / 제거 버튼 */}
{onRemoveHold ? (
<button className="mp-hold-rm" onClick={onRemoveHold} title="보유 종목 제거">×</button>
) : showHoldBtn ? (
<button
className={`mp-hold-btn ${isHold ? 'mp-hold-btn--on' : ''}`}
onClick={onToggleHold}
title={isHold ? '보유 해제' : '보유 등록'}
>
{isHold ? '₩' : '+₩'}
</button>
) : null}
</div>
);
});
// ─── 메인 패널 ─────────────────────────────────────────────────────────────
export const MarketPanel: React.FC<MarketPanelProps> = ({
open, tickers, loading, usdRate, activeSymbol, onSymbolSelect,
}) => {
const [tab, setTab] = useState<Tab>('krw');
const [search, setSearch] = useState('');
const [favorites, setFavs] = useState<string[]>(getFavorites);
const [holdings, setHolds] = useState<string[]>(getHoldings);
const [holdSearch, setHoldSrch] = useState('');
const [showHoldAdd, setShowHoldAdd] = useState(false);
const [sortKey, setSortKey] = useState<SortKey | null>(null);
const [sortDir, setSortDir] = useState<SortDir>('desc');
const searchRef = useRef<HTMLInputElement>(null);
// 패널 열릴 때 검색창 포커스
useEffect(() => { if (open) setTimeout(() => searchRef.current?.focus(), 250); }, [open]);
// 검색 초기화 (탭 전환 시)
const handleTab = useCallback((t: Tab) => { setTab(t); setSearch(''); }, []);
// 컬럼 정렬 토글
const handleSort = useCallback((key: SortKey) => {
setSortKey(prev => {
if (prev === key) {
setSortDir(d => d === 'asc' ? 'desc' : 'asc');
return key;
}
setSortDir('desc');
return key;
});
}, []);
// 즐겨찾기 토글
const handleToggleFav = useCallback(async (market: string, e: React.MouseEvent) => {
e.stopPropagation();
const row = tickers.get(market);
try {
const next = await toggleFavorite(market, { koreanName: row?.koreanName });
setFavs(next);
} catch (err) {
console.warn('[MarketPanel] watchlist toggle failed', err);
}
}, [tickers]);
// 보유 토글
const handleToggleHold = useCallback((market: string, e: React.MouseEvent) => {
e.stopPropagation();
setHolds(prev =>
prev.includes(market) ? removeHolding(market) : addHolding(market)
);
}, []);
// 보유 제거 (보유 탭)
const handleRemoveHold = useCallback((market: string, e: React.MouseEvent) => {
e.stopPropagation();
setHolds(removeHolding(market));
}, []);
// 보유 추가 검색에서 선택
const handleAddHolding = useCallback((market: string) => {
setHolds(addHolding(market));
setHoldSrch('');
setShowHoldAdd(false);
}, []);
// 탭별 마켓 목록 계산
const allKrwMarkets = useMemo(() => {
const keys = Array.from(tickers.keys()).filter(m => m.startsWith('KRW-'));
// 24h 거래대금 내림차순 정렬
return keys.sort((a, b) =>
(tickers.get(b)?.accTradePrice24 ?? 0) - (tickers.get(a)?.accTradePrice24 ?? 0)
);
}, [tickers]);
const filtered = useMemo(() => {
let base: string[] = [];
switch (tab) {
case 'krw': case 'btc': base = allKrwMarkets; break;
case 'hold': base = holdings.filter(m => tickers.has(m) || true); break;
case 'fav': base = favorites; break;
}
if (search) {
const q = search.toUpperCase();
base = base.filter(m => {
const code = m.replace(/^KRW-/, '');
const name = tickers.get(m)?.koreanName ?? '';
return code.includes(q) || name.includes(search);
});
}
if (!sortKey) return base;
const mul = sortDir === 'asc' ? 1 : -1;
return [...base].sort((a, b) => {
const ta = tickers.get(a);
const tb = tickers.get(b);
switch (sortKey) {
case 'name': {
const na = ta?.koreanName ?? a.replace(/^KRW-/, '');
const nb = tb?.koreanName ?? b.replace(/^KRW-/, '');
return mul * na.localeCompare(nb, 'ko');
}
case 'price':
return mul * ((ta?.tradePrice ?? 0) - (tb?.tradePrice ?? 0));
case 'change':
return mul * ((ta?.changeRate ?? 0) - (tb?.changeRate ?? 0));
default:
return 0;
}
});
}, [tab, allKrwMarkets, holdings, favorites, search, tickers, sortKey, sortDir]);
// 보유 탭 "추가" 검색 결과
const holdAddResults = useMemo(() => {
if (!holdSearch) return [];
const q = holdSearch.toUpperCase();
return allKrwMarkets
.filter(m => {
const code = m.replace(/^KRW-/, '');
const name = tickers.get(m)?.koreanName ?? '';
return code.includes(q) || name.includes(holdSearch);
})
.slice(0, 10);
}, [holdSearch, allKrwMarkets, tickers]);
const tabLabels: Record<Tab, string> = {
krw: '원화', btc: 'BTC', hold: '보유', fav: '관심',
};
return (
<div className={`market-panel ${open ? 'market-panel--open' : ''}`}>
{/* ── 패널 내용 ─────────────────────────────────────────────── */}
<div className="mp-body mp-body--full">
{/* 탭 헤더 */}
<div className="mp-tabs">
{(['krw', 'btc', 'hold', 'fav'] as Tab[]).map(t => (
<button
key={t}
className={`mp-tab ${tab === t ? 'mp-tab--active' : ''}`}
onClick={() => handleTab(t)}
>
{tabLabels[t]}
</button>
))}
</div>
{/* 검색 */}
<div className="mp-search-row">
<div className="mp-search-box">
<svg width="13" height="13" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.5" opacity={0.5}>
<circle cx="5.5" cy="5.5" r="4"/><line x1="9" y1="9" x2="12.5" y2="12.5"/>
</svg>
<input
ref={searchRef}
className="mp-search-input"
type="text"
placeholder="코인명/심볼검색"
value={search}
onChange={e => setSearch(e.target.value)}
/>
{search && (
<button className="mp-search-clear" onClick={() => setSearch('')}></button>
)}
</div>
</div>
{/* 컬럼 헤더 */}
<div className="mp-list-header">
{(['name', 'price', 'change'] as SortKey[]).map(key => {
const label = key === 'name' ? '한글명' : key === 'price' ? '현재가' : '전일대비';
const cls = key === 'name' ? 'mp-h-name' : key === 'price' ? 'mp-h-price' : 'mp-h-change';
const active = sortKey === key;
return (
<button
key={key}
className={`${cls} mp-h-sort-btn ${active ? 'mp-h-sort-btn--active' : ''}`}
onClick={() => handleSort(key)}
title={active ? (sortDir === 'asc' ? '내림차순 정렬' : '오름차순 정렬') : `${label} 정렬`}
>
{label}
<span className="mp-sort-icon">
{active ? (sortDir === 'asc' ? '▲' : '▼') : '⇅'}
</span>
</button>
);
})}
</div>
{/* 보유 탭: 추가 버튼 */}
{tab === 'hold' && (
<div className="mp-hold-add-area">
{showHoldAdd ? (
<div className="mp-hold-add-form">
<input
className="mp-hold-add-input"
placeholder="종목 검색..."
value={holdSearch}
onChange={e => setHoldSrch(e.target.value)}
autoFocus
/>
<button className="mp-hold-add-cancel" onClick={() => { setShowHoldAdd(false); setHoldSrch(''); }}></button>
{holdAddResults.length > 0 && (
<div className="mp-hold-add-dropdown">
{holdAddResults.map(m => (
<div key={m} className="mp-hold-add-item" onClick={() => handleAddHolding(m)}>
<span>{tickers.get(m)?.koreanName ?? m.replace(/^KRW-/, '')}</span>
<span className="mp-hold-add-code">{m.replace(/^KRW-/, '')}</span>
</div>
))}
</div>
)}
</div>
) : (
<button className="mp-hold-add-btn" onClick={() => setShowHoldAdd(true)}>
+
</button>
)}
</div>
)}
{/* 마켓 목록 */}
<div className="mp-list">
{loading && (
<div className="mp-loading">
<div className="loading-spinner" style={{ width: 18, height: 18 }} />
<span> ...</span>
</div>
)}
{!loading && filtered.length === 0 && (
<div className="mp-empty">
{tab === 'hold' ? '보유 종목이 없습니다' :
tab === 'fav' ? '관심 종목이 없습니다' :
'검색 결과 없음'}
</div>
)}
{!loading && filtered.map(market => (
<MarketRow
key={market}
market={market}
ticker={tickers.get(market)}
isActive={market === activeSymbol}
isFav={favorites.includes(market)}
isHold={holdings.includes(market)}
showUsd={tab === 'btc'}
showHoldBtn={tab === 'krw' || tab === 'btc' || tab === 'fav'}
usdRate={usdRate}
onSelect={() => onSymbolSelect(market)}
onToggleFav={e => handleToggleFav(market, e)}
onToggleHold={e => handleToggleHold(market, e)}
onRemoveHold={tab === 'hold' ? e => handleRemoveHold(market, e) : undefined}
/>
))}
</div>
</div>
</div>
);
};
export default MarketPanel;