goldenChat base source add
This commit is contained in:
@@ -0,0 +1,330 @@
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { setMarketNames } from '../utils/marketNameCache';
|
||||
import {
|
||||
getFavorites,
|
||||
toggleFavorite,
|
||||
FAVORITES_CHANGED_EVENT,
|
||||
} from '../utils/marketStorage';
|
||||
|
||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||
interface MarketInfo {
|
||||
market: string; // "KRW-BTC"
|
||||
korean_name: string; // "비트코인"
|
||||
english_name: string; // "Bitcoin"
|
||||
symbol: string; // "BTC"
|
||||
}
|
||||
|
||||
interface TickerInfo {
|
||||
trade_price: number;
|
||||
signed_change_rate: number; // 전일 대비 변화율 (소수, e.g. 0.015 = +1.5%)
|
||||
acc_trade_price_24h: number; // 24h 거래대금 (KRW)
|
||||
}
|
||||
|
||||
// ─── Constants ───────────────────────────────────────────────────────────────
|
||||
const UPBIT_API = import.meta.env.DEV ? '/upbit-api/v1' : 'https://api.upbit.com/v1';
|
||||
const TICKER_TTL = 3000; // 3초마다 시세 갱신
|
||||
|
||||
function formatVol(vol: number): string {
|
||||
if (vol >= 1e12) return `${(vol / 1e12).toFixed(1)}조`;
|
||||
if (vol >= 1e8) return `${(vol / 1e8).toFixed(0)}억`;
|
||||
if (vol >= 1e4) return `${(vol / 1e4).toFixed(0)}만`;
|
||||
return vol.toLocaleString();
|
||||
}
|
||||
|
||||
// ─── Component ───────────────────────────────────────────────────────────────
|
||||
interface MarketSearchPanelProps {
|
||||
currentMarket: string;
|
||||
onSelect: (market: string) => void;
|
||||
onClose: () => void;
|
||||
/**
|
||||
* 슬롯 내 심볼 버튼 클릭 시 전달되는 슬롯 컨테이너 rect.
|
||||
* 제공되면 해당 슬롯 영역 안에서 패널이 표시된다.
|
||||
* 없으면 기본(전체화면 좌측 고정) 위치로 표시된다.
|
||||
*/
|
||||
anchorRect?: DOMRect | null;
|
||||
/** 외부 입력과 검색어 동기화 */
|
||||
initialQuery?: string;
|
||||
}
|
||||
|
||||
export const MarketSearchPanel: React.FC<MarketSearchPanelProps> = ({
|
||||
currentMarket, onSelect, onClose, anchorRect, initialQuery = '',
|
||||
}) => {
|
||||
const [markets, setMarkets] = useState<MarketInfo[]>([]);
|
||||
const [tickers, setTickers] = useState<Record<string, TickerInfo>>({});
|
||||
const [query, setQuery] = useState(initialQuery);
|
||||
const [favs, setFavs] = useState<Set<string>>(() => new Set(getFavorites()));
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const refreshFavs = useCallback(() => {
|
||||
setFavs(new Set(getFavorites()));
|
||||
}, []);
|
||||
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const tickerTimer = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const marketCodesRef = useRef<string>('');
|
||||
|
||||
// ── 1. 마켓 목록 로드 ─────────────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
fetch(`${UPBIT_API}/market/all?isDetails=false`)
|
||||
.then(r => r.json())
|
||||
.then((data: Array<{ market: string; korean_name: string; english_name: string }>) => {
|
||||
const krw = data
|
||||
.filter(m => m.market.startsWith('KRW-'))
|
||||
.map(m => ({ ...m, symbol: m.market.replace('KRW-', '') }));
|
||||
setMarkets(krw);
|
||||
marketCodesRef.current = krw.map(m => m.market).join(',');
|
||||
|
||||
// 한글명 캐시 저장 (Toolbar 등에서 사용)
|
||||
const nameMap: Record<string, string> = {};
|
||||
for (const m of krw) nameMap[m.market] = m.korean_name;
|
||||
setMarketNames(nameMap);
|
||||
|
||||
setLoading(false);
|
||||
})
|
||||
.catch(() => setLoading(false));
|
||||
|
||||
setTimeout(() => inputRef.current?.focus(), 50);
|
||||
refreshFavs();
|
||||
}, [refreshFavs]);
|
||||
|
||||
useEffect(() => {
|
||||
setQuery(initialQuery);
|
||||
}, [initialQuery]);
|
||||
|
||||
// 좌측 마켓 패널 등에서 관심종목 변경 시 별표 동기화
|
||||
useEffect(() => {
|
||||
const onChanged = () => refreshFavs();
|
||||
window.addEventListener(FAVORITES_CHANGED_EVENT, onChanged);
|
||||
return () => window.removeEventListener(FAVORITES_CHANGED_EVENT, onChanged);
|
||||
}, [refreshFavs]);
|
||||
|
||||
// ── 2. 시세 데이터 폴링 (100개씩 배치 요청) ─────────────────────────────
|
||||
const fetchTickers = useCallback(async () => {
|
||||
const codes = marketCodesRef.current;
|
||||
if (!codes) return;
|
||||
const allCodes = codes.split(',');
|
||||
const BATCH = 100;
|
||||
const results: Array<{ market: string } & TickerInfo> = [];
|
||||
|
||||
for (let i = 0; i < allCodes.length; i += BATCH) {
|
||||
const batch = allCodes.slice(i, i + BATCH).join(',');
|
||||
try {
|
||||
const data: Array<{ market: string } & TickerInfo> =
|
||||
await fetch(`${UPBIT_API}/ticker?markets=${batch}`).then(r => r.json());
|
||||
results.push(...data);
|
||||
} catch { /* ignore partial failures */ }
|
||||
}
|
||||
|
||||
setTickers(prev => {
|
||||
const map: Record<string, TickerInfo> = { ...prev };
|
||||
for (const t of results) map[t.market] = t;
|
||||
return map;
|
||||
});
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (markets.length === 0) return;
|
||||
fetchTickers();
|
||||
tickerTimer.current = setInterval(fetchTickers, TICKER_TTL);
|
||||
return () => { if (tickerTimer.current) clearInterval(tickerTimer.current); };
|
||||
}, [markets, fetchTickers]);
|
||||
|
||||
// ── 3. 즐겨찾기 토글 ─────────────────────────────────────────────────────
|
||||
const toggleFav = useCallback(async (market: string, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
const row = markets.find(m => m.market === market);
|
||||
try {
|
||||
const next = await toggleFavorite(market, {
|
||||
koreanName: row?.korean_name,
|
||||
englishName: row?.english_name,
|
||||
});
|
||||
setFavs(new Set(next));
|
||||
} catch (err) {
|
||||
console.warn('[MarketSearchPanel] watchlist toggle failed', err);
|
||||
}
|
||||
}, [markets]);
|
||||
|
||||
// ── 4. 필터 + 정렬 ───────────────────────────────────────────────────────
|
||||
const q = query.trim();
|
||||
const filtered = markets.filter(m => {
|
||||
if (!q) return true;
|
||||
return (
|
||||
m.korean_name.includes(q) ||
|
||||
m.symbol.toUpperCase().includes(q.toUpperCase()) ||
|
||||
m.english_name.toLowerCase().includes(q.toLowerCase()) ||
|
||||
m.market.toUpperCase().includes(q.toUpperCase())
|
||||
);
|
||||
});
|
||||
|
||||
const sorted = [...filtered].sort((a, b) => {
|
||||
const fa = favs.has(a.market) ? 1 : 0;
|
||||
const fb = favs.has(b.market) ? 1 : 0;
|
||||
if (fb !== fa) return fb - fa;
|
||||
// 즐겨찾기 동순위: 24h 거래대금 내림차순
|
||||
const va = tickers[a.market]?.acc_trade_price_24h ?? 0;
|
||||
const vb = tickers[b.market]?.acc_trade_price_24h ?? 0;
|
||||
return vb - va;
|
||||
});
|
||||
|
||||
// ── Render ────────────────────────────────────────────────────────────────
|
||||
// anchorRect 제공 시: 슬롯 영역 안에 패널을 위치시킨다.
|
||||
const HEADER_H = 30; // 슬롯 헤더 높이 (약 30px)
|
||||
const MIN_W = 260;
|
||||
|
||||
// LWC v5 canvas 레이어가 내부적으로 높은 z-index를 사용하므로
|
||||
// 인라인 스타일로 z-index를 명시해 패널이 항상 최상단에 렌더되게 보장한다.
|
||||
const PANEL_Z = 99999;
|
||||
|
||||
const panelStyle: React.CSSProperties | undefined = anchorRect
|
||||
? {
|
||||
position: 'fixed',
|
||||
top: anchorRect.top + HEADER_H,
|
||||
left: anchorRect.left,
|
||||
width: Math.max(anchorRect.width, MIN_W),
|
||||
height: anchorRect.height - HEADER_H,
|
||||
maxWidth: anchorRect.width,
|
||||
zIndex: PANEL_Z,
|
||||
// document.body 포털 시 CSS var()가 상속되지 않아 배경이 투명해지는 문제 방지
|
||||
background: 'var(--bg2, #24283b)',
|
||||
color: 'var(--text, #c0caf5)',
|
||||
border: '1px solid var(--border, rgba(122,162,247,0.15))',
|
||||
}
|
||||
: undefined; // undefined → CSS 기본값(.msp-panel) 그대로 사용
|
||||
|
||||
// 슬롯 전용 backdrop: 해당 슬롯 영역만 반투명 오버레이로 덮어 패널이 명확히 보이게 함
|
||||
const backdropStyle: React.CSSProperties | undefined = anchorRect
|
||||
? {
|
||||
position: 'fixed',
|
||||
top: anchorRect.top,
|
||||
left: anchorRect.left,
|
||||
width: anchorRect.width,
|
||||
height: anchorRect.height,
|
||||
zIndex: PANEL_Z - 1,
|
||||
background: 'rgba(0,0,0,0.45)',
|
||||
backdropFilter: 'none',
|
||||
}
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 배경 오버레이 (클릭 시 닫기) */}
|
||||
<div
|
||||
className="msp-backdrop"
|
||||
style={backdropStyle}
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* 패널: anchorRect 있으면 인라인 스타일로 위치·z-index override */}
|
||||
<div
|
||||
className={`msp-panel${anchorRect ? ' msp-panel--slot' : ''}`}
|
||||
style={panelStyle}
|
||||
>
|
||||
|
||||
{/* 검색 입력 */}
|
||||
<div className="msp-search-row">
|
||||
<span className="msp-search-icon">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/>
|
||||
</svg>
|
||||
</span>
|
||||
<input
|
||||
ref={inputRef}
|
||||
className="msp-input"
|
||||
placeholder="종목명 또는 심볼 검색"
|
||||
value={query}
|
||||
onChange={e => setQuery(e.target.value)}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Escape') onClose();
|
||||
if (e.key === 'Enter' && sorted.length > 0) {
|
||||
onSelect(sorted[0].market);
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
/>
|
||||
{query && (
|
||||
<button className="msp-btn-icon" title="초기화" onClick={() => setQuery('')}>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
||||
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
<button className="msp-btn-icon" title="닫기" onClick={onClose}>
|
||||
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
||||
<polyline points="18 15 12 9 6 15"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 컬럼 헤더 */}
|
||||
<div className="msp-header">
|
||||
<span className="msp-col msp-col-fav" />
|
||||
<span className="msp-col msp-col-name">한글명</span>
|
||||
<span className="msp-col msp-col-price">현재가</span>
|
||||
<span className="msp-col msp-col-change">전일대비</span>
|
||||
<span className="msp-col msp-col-vol">거래대금</span>
|
||||
</div>
|
||||
|
||||
{/* 목록 */}
|
||||
<div className="msp-list">
|
||||
{loading && (
|
||||
<div className="msp-empty">마켓 목록 로딩 중...</div>
|
||||
)}
|
||||
{!loading && sorted.length === 0 && (
|
||||
<div className="msp-empty">검색 결과가 없습니다</div>
|
||||
)}
|
||||
{sorted.map(m => {
|
||||
const tk = tickers[m.market];
|
||||
const isFav = favs.has(m.market);
|
||||
const isActive = m.market === currentMarket;
|
||||
const rate = tk ? tk.signed_change_rate * 100 : null;
|
||||
const isUp = rate !== null && rate >= 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={m.market}
|
||||
className={`msp-item${isActive ? ' active' : ''}`}
|
||||
onClick={() => { onSelect(m.market); onClose(); }}
|
||||
>
|
||||
{/* 즐겨찾기 */}
|
||||
<span
|
||||
className={`msp-col msp-col-fav msp-star${isFav ? ' starred' : ''}`}
|
||||
onClick={e => toggleFav(m.market, e)}
|
||||
title={isFav ? '즐겨찾기 해제' : '즐겨찾기 추가'}
|
||||
>
|
||||
{isFav ? '★' : '☆'}
|
||||
</span>
|
||||
|
||||
{/* 이름 */}
|
||||
<span className="msp-col msp-col-name">
|
||||
<span className="msp-kor">{m.korean_name}</span>
|
||||
<span className="msp-sym">{m.symbol}</span>
|
||||
</span>
|
||||
|
||||
{/* 현재가 */}
|
||||
<span className="msp-col msp-col-price">
|
||||
{tk ? tk.trade_price.toLocaleString() : '-'}
|
||||
</span>
|
||||
|
||||
{/* 전일대비 */}
|
||||
<span className={`msp-col msp-col-change ${rate === null ? '' : isUp ? 'up' : 'down'}`}>
|
||||
{rate === null
|
||||
? '-'
|
||||
: `${isUp ? '+' : ''}${rate.toFixed(2)}%`}
|
||||
</span>
|
||||
|
||||
{/* 거래대금 */}
|
||||
<span className="msp-col msp-col-vol">
|
||||
{tk?.acc_trade_price_24h ? formatVol(tk.acc_trade_price_24h) : '-'}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default MarketSearchPanel;
|
||||
Reference in New Issue
Block a user