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
@@ -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;