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,74 @@
/**
* 차트 칸 타이틀 드래그 시 DragOverlay에 표시하는 미리보기 카드
*/
import React from 'react';
import { Box, Typography } from '@mui/material';
import { ShowChart as ShowChartIcon, DragIndicator as DragIndicatorIcon } from '@mui/icons-material';
import type { StockInfo } from '../../stores/useDashboardStore';
export interface ChartDragOverlayPreviewProps {
stock: StockInfo;
width: number;
height: number;
}
export const ChartDragOverlayPreview: React.FC<ChartDragOverlayPreviewProps> = ({ stock, width, height }) => {
const maxW = typeof window !== 'undefined' ? Math.min(width, window.innerWidth - 24) : width;
const maxH = typeof window !== 'undefined' ? Math.min(height, Math.round(window.innerHeight * 0.5)) : height;
const w = Math.max(200, maxW);
const h = Math.max(160, maxH);
return (
<Box
sx={{
width: w,
height: h,
borderRadius: 1,
overflow: 'hidden',
boxShadow: 12,
border: '2px solid',
borderColor: 'primary.main',
bgcolor: 'background.paper',
display: 'flex',
flexDirection: 'column',
pointerEvents: 'none',
cursor: 'grabbing',
opacity: 0.97,
}}
>
<Box
sx={{
display: 'flex',
alignItems: 'center',
gap: 0.5,
px: 1,
py: 0.5,
flexShrink: 0,
bgcolor: 'action.selected',
borderBottom: '1px solid',
borderColor: 'divider',
}}
>
<DragIndicatorIcon sx={{ fontSize: 16, color: 'text.secondary', flexShrink: 0 }} />
<Typography variant="caption" fontWeight="bold" noWrap sx={{ flex: 1, minWidth: 0 }}>
{stock.koreanName}
</Typography>
<Typography variant="caption" color="text.secondary" sx={{ flexShrink: 0 }}>
{stock.symbol}
</Typography>
</Box>
<Box
sx={{
flex: 1,
minHeight: 0,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'linear-gradient(180deg, #2a2e39 0%, #131722 55%, #0c0e12 100%)',
}}
>
<ShowChartIcon sx={{ fontSize: Math.min(72, h * 0.22), color: 'grey.500', opacity: 0.45 }} />
</Box>
</Box>
);
};
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,170 @@
/**
* 멀티차트 WebSocket 연결 상태 표시
* - 클릭 시 연결 목록 및 수신 상태 팝업
* - MultiChartMarketDataProvider 내부에서 사용
*/
import React from 'react';
import { Box, Popover, LinearProgress, Typography, Tooltip, Button } from '@mui/material';
import { useMultiChartMarketData } from '../../contexts/MultiChartMarketDataContext';
import type { ConnectionStatus } from '../../hooks/useWebSocketConnectionStatus';
const WS_STATUS_CONFIG: Record<ConnectionStatus, { label: string; color: string; bgColor: string }> = {
connected: { label: '실시간 연결됨', color: 'success.main', bgColor: 'success.main' },
connecting: { label: '연결 중...', color: 'warning.main', bgColor: 'warning.main' },
disconnected: { label: '연결 끊김', color: 'text.secondary', bgColor: 'grey.400' },
error: { label: '연결 오류', color: 'error.main', bgColor: 'error.main' },
};
export const MultiChartWebSocketStatus: React.FC = () => {
const ctx = useMultiChartMarketData();
const status = ctx?.status ?? 'disconnected';
const connectionList = ctx?.connectionList ?? [];
const [popoverAnchor, setPopoverAnchor] = React.useState<HTMLElement | null>(null);
const [, setProgressTick] = React.useState(0);
const [dataReceivedBlink, setDataReceivedBlink] = React.useState(false); // ✅ 데이터 수신 깜빡임
const lastReceivedTimestampRef = React.useRef<number>(0);
React.useEffect(() => {
if (!popoverAnchor) return;
const id = setInterval(() => setProgressTick((t) => t + 1), 1000);
return () => clearInterval(id);
}, [popoverAnchor]);
// ✅ connectionList의 lastReceivedAt 변화 감지 → 깜빡임 트리거
React.useEffect(() => {
if (connectionList.length === 0) return;
const latestReceived = Math.max(...connectionList.map(c => c.lastReceivedAt));
if (latestReceived > 0 && latestReceived !== lastReceivedTimestampRef.current) {
lastReceivedTimestampRef.current = latestReceived;
setDataReceivedBlink(true);
setTimeout(() => setDataReceivedBlink(false), 300); // 300ms 후 자동 꺼짐
}
}, [connectionList]);
const config = WS_STATUS_CONFIG[status as ConnectionStatus] ?? WS_STATUS_CONFIG.disconnected;
const getReceiveProgress = (lastReceivedAt: number) => {
if (!lastReceivedAt) return 0;
const elapsed = (Date.now() - lastReceivedAt) / 1000;
if (elapsed <= 5) return 100;
if (elapsed >= 30) return 0;
return Math.round(100 - ((elapsed - 5) / 25) * 100);
};
return (
<>
<Tooltip title={`WebSocket: ${config.label} (클릭하여 연결 목록 보기)`}>
<Box
onClick={(e) => setPopoverAnchor(e.currentTarget)}
sx={{
display: 'flex',
alignItems: 'center',
gap: 0.75,
px: 1,
py: 0.5,
borderRadius: 1,
bgcolor: 'action.hover',
fontSize: '0.75rem',
color: config.color,
cursor: 'pointer',
'&:hover': { bgcolor: 'action.selected' },
// ✅ 데이터 수신 시 연두색 형광 깜빡임
...(dataReceivedBlink && status === 'connected' ? {
boxShadow: '0 0 10px 3px rgba(204, 255, 0, 0.7), inset 0 0 8px 2px rgba(204, 255, 0, 0.3)',
transition: 'box-shadow 0.15s ease-out',
} : {}),
}}
>
<Box
sx={{
width: 6,
height: 6,
borderRadius: '50%',
bgcolor: config.bgColor,
...(status === 'connecting' && {
animation: 'pulse 1.5s ease-in-out infinite',
'@keyframes pulse': { '0%, 100%': { opacity: 1 }, '50%': { opacity: 0.4 } },
}),
}}
/>
{config.label}
</Box>
</Tooltip>
<Popover
open={Boolean(popoverAnchor)}
anchorEl={popoverAnchor}
onClose={() => setPopoverAnchor(null)}
anchorOrigin={{ vertical: 'bottom', horizontal: 'center' }}
transformOrigin={{ vertical: 'top', horizontal: 'center' }}
slotProps={{ paper: { sx: { mt: 0.5, minWidth: 280 } } }}
>
<Box sx={{ p: 1.5 }}>
<Typography variant="subtitle2" sx={{ mb: 1, fontWeight: 600 }}>
WebSocket
</Typography>
{connectionList.length === 0 ? (
<Typography variant="body2" color="text.secondary">
.
</Typography>
) : (
<Box sx={{ display: 'flex', flexDirection: 'column', gap: 1.5 }}>
{connectionList.map((item) => {
const progress = getReceiveProgress(item.lastReceivedAt);
const hasNoData = item.candleCount === 0;
const displayName = item.koreanName || item.market;
return (
<Box key={`${item.market}:${item.timeframe}`} sx={{ minWidth: 240 }}>
<Box sx={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', mb: 0.25 }}>
<Typography variant="body2" sx={{ fontWeight: 500 }}>
{displayName}
</Typography>
<Typography variant="caption" color="text.secondary">
{item.timeframe} · {item.candleCount}
</Typography>
</Box>
<LinearProgress
variant="determinate"
value={progress}
sx={{
height: 6,
borderRadius: 1,
bgcolor: 'action.hover',
'& .MuiLinearProgress-bar': {
bgcolor: progress >= 80 ? 'success.main' : progress >= 40 ? 'warning.main' : 'grey.400',
},
}}
/>
{hasNoData && (
<Typography variant="caption" color="error.main" sx={{ display: 'block', mt: 0.5 }}>
미수신: Redis , ,
</Typography>
)}
</Box>
);
})}
</Box>
)}
<Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 1.5, pt: 1, borderTop: 1, borderColor: 'divider' }}>
0개: market-data , .
</Typography>
{(status === 'disconnected' || status === 'error') && ctx?.reconnect && (
<Button
size="small"
variant="outlined"
fullWidth
sx={{ mt: 1 }}
onClick={() => {
ctx.reconnect();
setPopoverAnchor(null);
}}
>
</Button>
)}
</Box>
</Popover>
</>
);
};
@@ -0,0 +1,76 @@
/**
* 종목 선택 다이얼로그
*/
import React, { useState } from 'react';
import {
Dialog,
DialogTitle,
DialogContent,
TextField,
InputAdornment,
List,
ListItemButton,
ListItemText,
} from '@mui/material';
import { Search as SearchIcon } from '@mui/icons-material';
import { MARKET_LIST } from '../../constants/marketList';
import type { StockInfo } from '../../stores/useDashboardStore';
interface StockSelectDialogProps {
open: boolean;
onClose: () => void;
onSelect: (stock: StockInfo) => void;
}
export const StockSelectDialog: React.FC<StockSelectDialogProps> = ({ open, onClose, onSelect }) => {
const [search, setSearch] = useState('');
const filtered = MARKET_LIST.filter(
(m) =>
m.koreanName.toLowerCase().includes(search.toLowerCase()) ||
m.symbol.toLowerCase().includes(search.toLowerCase())
);
const handleSelect = (item: (typeof MARKET_LIST)[0]) => {
onSelect({
symbol: item.symbol,
koreanName: item.koreanName,
englishName: item.englishName,
});
onClose();
setSearch('');
};
return (
<Dialog open={open} onClose={onClose} maxWidth="xs" fullWidth>
<DialogTitle> </DialogTitle>
<DialogContent>
<TextField
size="small"
placeholder="검색..."
value={search}
onChange={(e) => setSearch(e.target.value)}
fullWidth
sx={{ mb: 1 }}
InputProps={{
startAdornment: (
<InputAdornment position="start">
<SearchIcon fontSize="small" />
</InputAdornment>
),
}}
/>
<List sx={{ maxHeight: 320, overflow: 'auto' }}>
{filtered.map((item) => (
<ListItemButton key={item.symbol} onClick={() => handleSelect(item)}>
<ListItemText
primary={item.koreanName}
secondary={item.symbol}
/>
</ListItemButton>
))}
</List>
</DialogContent>
</Dialog>
);
};
@@ -0,0 +1,191 @@
/**
* 멀티차트 대시보드 - 종목 선택 드롭다운 (펼친 상태에서 드래그 가능)
*/
import React, { useState, useEffect } from 'react';
import {
Button,
Menu,
MenuItem,
TextField,
InputAdornment,
List,
ListItem,
ListItemButton,
Box,
Typography,
} from '@mui/material';
import { Add as AddIcon, Search as SearchIcon, DragIndicator } from '@mui/icons-material';
import { useDraggable } from '@dnd-kit/core';
import { MARKET_LIST, type MarketItem } from '../../constants/marketList';
import type { StockInfo } from '../../stores/useDashboardStore';
interface DraggableStockItemProps {
item: MarketItem;
onClick?: (item: MarketItem) => void;
}
const DraggableStockItem: React.FC<DraggableStockItemProps> = ({ item, onClick }) => {
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
id: `stock-${item.symbol}`,
data: {
type: 'stock',
stock: {
symbol: item.symbol,
koreanName: item.koreanName,
englishName: item.englishName,
} as StockInfo,
},
});
const handleClick = (e: React.MouseEvent) => {
// 드래그 중이 아닐 때만 클릭 이벤트 처리
if (!isDragging && onClick) {
e.stopPropagation();
onClick(item);
}
};
return (
<ListItem disablePadding ref={setNodeRef} {...attributes} {...listeners}>
<ListItemButton
onClick={handleClick}
sx={{
py: 0.75,
opacity: isDragging ? 0.5 : 1,
borderRadius: 1,
'&:hover': { bgcolor: 'action.hover' },
}}
>
<DragIndicator fontSize="small" sx={{ mr: 1, color: 'text.disabled' }} />
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography variant="body2" noWrap>
{item.koreanName}
</Typography>
<Typography variant="caption" color="text.secondary">
{item.symbol}
</Typography>
</Box>
</ListItemButton>
</ListItem>
);
};
interface StockSelectDropdownProps {
open: boolean;
onOpen: () => void;
onClose: () => void;
/** 종목 선택 시 콜백 (클릭으로 선택) */
onSelect?: (stock: StockInfo) => void;
/** 드래그 중에는 메뉴 닫지 않음 */
isDraggingFromMenu?: boolean;
}
export const StockSelectDropdown: React.FC<StockSelectDropdownProps> = ({
open,
onOpen,
onClose,
onSelect,
isDraggingFromMenu = false,
}) => {
const [anchorEl, setAnchorEl] = useState<null | HTMLElement>(null);
const [search, setSearch] = useState('');
const handleOpen = (e: React.MouseEvent<HTMLElement>) => {
setAnchorEl(e.currentTarget);
onOpen();
};
const menuOpen = open && Boolean(anchorEl);
const handleClose = () => {
if (!isDraggingFromMenu) {
setAnchorEl(null);
setSearch('');
onClose();
}
};
const handleItemClick = (item: MarketItem) => {
if (onSelect) {
onSelect({
symbol: item.symbol,
koreanName: item.koreanName,
englishName: item.englishName,
});
handleClose();
}
};
useEffect(() => {
if (!open) {
setAnchorEl(null);
setSearch('');
}
}, [open]);
const filtered = MARKET_LIST.filter(
(m) =>
m.koreanName.toLowerCase().includes(search.toLowerCase()) ||
m.symbol.toLowerCase().includes(search.toLowerCase())
);
return (
<>
<Button
variant="outlined"
size="small"
startIcon={<AddIcon />}
onClick={handleOpen}
sx={{ minWidth: 140, justifyContent: 'flex-start' }}
>
</Button>
<Menu
anchorEl={anchorEl}
open={menuOpen}
onClose={handleClose}
anchorOrigin={{ vertical: 'bottom', horizontal: 'left' }}
transformOrigin={{ vertical: 'top', horizontal: 'left' }}
PaperProps={{
sx: {
maxHeight: 400,
width: 280,
mt: 1.5,
},
}}
disableAutoFocusItem
disableScrollLock
>
<Box sx={{ px: 1.5, pt: 1, pb: 0.5 }}>
<TextField
size="small"
placeholder="검색..."
value={search}
onChange={(e) => setSearch(e.target.value)}
fullWidth
InputProps={{
startAdornment: (
<InputAdornment position="start">
<SearchIcon fontSize="small" />
</InputAdornment>
),
}}
sx={{ '& .MuiOutlinedInput-root': { bgcolor: 'action.hover' } }}
/>
</Box>
<List sx={{ maxHeight: 320, overflow: 'auto', py: 0 }}>
{filtered.map((item) => (
<DraggableStockItem key={item.symbol} item={item} onClick={handleItemClick} />
))}
</List>
{filtered.length === 0 && (
<Box sx={{ p: 2, textAlign: 'center' }}>
<Typography variant="body2" color="text.secondary">
</Typography>
</Box>
)}
</Menu>
</>
);
};
@@ -0,0 +1,121 @@
/**
* 멀티차트 대시보드 - 드래그 가능한 종목 리스트 사이드바
*/
import React, { useState } from 'react';
import {
Box,
Typography,
TextField,
InputAdornment,
List,
ListItem,
ListItemButton,
Paper,
} from '@mui/material';
import { Search as SearchIcon, DragIndicator } from '@mui/icons-material';
import { useDraggable } from '@dnd-kit/core';
import { MARKET_LIST, type MarketItem } from '../../constants/marketList';
import type { StockInfo } from '../../stores/useDashboardStore';
interface DraggableStockProps {
item: MarketItem;
}
const DraggableStock: React.FC<DraggableStockProps> = ({ item }) => {
const { attributes, listeners, setNodeRef, isDragging } = useDraggable({
id: `stock-${item.symbol}`,
data: {
type: 'stock',
stock: {
symbol: item.symbol,
koreanName: item.koreanName,
englishName: item.englishName,
} as StockInfo,
},
});
return (
<ListItem disablePadding ref={setNodeRef} {...attributes} {...listeners}>
<ListItemButton
sx={{
py: 0.75,
opacity: isDragging ? 0.5 : 1,
borderRadius: 1,
'&:hover': { bgcolor: 'action.hover' },
}}
>
<DragIndicator fontSize="small" sx={{ mr: 1, color: 'text.disabled' }} />
<Box sx={{ flex: 1, minWidth: 0 }}>
<Typography variant="body2" noWrap>
{item.koreanName}
</Typography>
<Typography variant="caption" color="text.secondary">
{item.symbol}
</Typography>
</Box>
</ListItemButton>
</ListItem>
);
};
interface StockSidebarProps {
onSelectStock?: (stock: StockInfo) => void;
}
export const StockSidebar: React.FC<StockSidebarProps> = ({ onSelectStock }) => {
const [search, setSearch] = useState('');
const filtered = MARKET_LIST.filter(
(m) =>
m.koreanName.toLowerCase().includes(search.toLowerCase()) ||
m.symbol.toLowerCase().includes(search.toLowerCase())
);
return (
<Paper
elevation={0}
sx={{
width: 240,
flexShrink: 0,
bgcolor: 'background.paper',
border: '1px solid',
borderColor: 'divider',
borderRadius: 1,
overflow: 'hidden',
}}
>
<Box sx={{ p: 1.5, borderBottom: '1px solid', borderColor: 'divider' }}>
<Typography variant="subtitle2" fontWeight="bold" gutterBottom>
</Typography>
<TextField
size="small"
placeholder="검색..."
value={search}
onChange={(e) => setSearch(e.target.value)}
fullWidth
InputProps={{
startAdornment: (
<InputAdornment position="start">
<SearchIcon fontSize="small" />
</InputAdornment>
),
}}
sx={{ '& .MuiOutlinedInput-root': { bgcolor: 'action.hover' } }}
/>
</Box>
<List sx={{ maxHeight: 400, overflow: 'auto', py: 0 }}>
{filtered.map((item) => (
<DraggableStock key={item.symbol} item={item} />
))}
</List>
{filtered.length === 0 && (
<Box sx={{ p: 2, textAlign: 'center' }}>
<Typography variant="body2" color="text.secondary">
</Typography>
</Box>
)}
</Paper>
);
};