매매 시그널 알림 화면 수정

This commit is contained in:
Macbook
2026-05-28 21:58:15 +09:00
parent 9221b8dea2
commit cbad62a5b0
10 changed files with 1814 additions and 75 deletions
@@ -5,7 +5,6 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import type { Theme, TradeOrderFillRequest } from '../types'; import type { Theme, TradeOrderFillRequest } from '../types';
import type { TickerData } from '../hooks/useMarketTicker'; import type { TickerData } from '../hooks/useMarketTicker';
import { useTradeNotification, type TradeNotificationItem } from '../contexts/TradeNotificationContext'; import { useTradeNotification, type TradeNotificationItem } from '../contexts/TradeNotificationContext';
import { buildSignalDetailRows, formatSignalPrice, getSignalHeadline } from '../utils/tradeSignalDisplay';
import { loadPaperSummary, type PaperSummaryDto } from '../utils/backendApi'; import { loadPaperSummary, type PaperSummaryDto } from '../utils/backendApi';
import { coerceFiniteNumber } from '../utils/safeFormat'; import { coerceFiniteNumber } from '../utils/safeFormat';
import TradeOrderPanel from './TradeOrderPanel'; import TradeOrderPanel from './TradeOrderPanel';
@@ -14,6 +13,20 @@ import PaperSplitPanel from './paper/PaperSplitPanel';
import { useUpbitOrderbook } from '../hooks/useUpbitOrderbook'; import { useUpbitOrderbook } from '../hooks/useUpbitOrderbook';
import { useUpbitRecentTrades } from '../hooks/useUpbitRecentTrades'; import { useUpbitRecentTrades } from '../hooks/useUpbitRecentTrades';
import '../styles/virtualTradingDashboard.css'; import '../styles/virtualTradingDashboard.css';
import '../styles/tradeNotificationList.css';
import TradeNotificationListRow from './tradeNotification/TradeNotificationListRow';
import TradeNotificationGridLayoutPicker from './tradeNotification/TradeNotificationGridLayoutPicker';
import {
loadTradeNotificationGridPreset,
loadTradeNotificationListLayout,
saveTradeNotificationGridPreset,
saveTradeNotificationListLayout,
type TradeNotificationListLayout,
} from '../utils/tradeNotificationListLayout';
import {
getTradeNotificationGridPreset,
type TradeNotificationGridPresetId,
} from '../utils/tradeNotificationGridPresets';
type RightTab = 'trade' | 'orderbook'; type RightTab = 'trade' | 'orderbook';
@@ -69,6 +82,12 @@ export const TradeNotificationListPage: React.FC<Props> = ({
} = useTradeNotification(); } = useTradeNotification();
const [filter, setFilter] = useState<'all' | 'unread'>('all'); const [filter, setFilter] = useState<'all' | 'unread'>('all');
const [listLayout, setListLayout] = useState<TradeNotificationListLayout>(
() => loadTradeNotificationListLayout(),
);
const [gridPreset, setGridPreset] = useState<TradeNotificationGridPresetId>(
() => loadTradeNotificationGridPreset(),
);
const [selectedMarket, setSelectedMarket] = useState(defaultMarket); const [selectedMarket, setSelectedMarket] = useState(defaultMarket);
const [selectedNotifyId, setSelectedNotifyId] = useState<string | null>(null); const [selectedNotifyId, setSelectedNotifyId] = useState<string | null>(null);
const [rightTab, setRightTab] = useState<RightTab>('trade'); const [rightTab, setRightTab] = useState<RightTab>('trade');
@@ -172,15 +191,57 @@ export const TradeNotificationListPage: React.FC<Props> = ({
? allNotifications.filter(n => !n.isRead) ? allNotifications.filter(n => !n.isRead)
: allNotifications; : allNotifications;
const handleListLayoutChange = useCallback((layout: TradeNotificationListLayout) => {
setListLayout(layout);
saveTradeNotificationListLayout(layout);
}, []);
const handleGridPresetChange = useCallback((preset: TradeNotificationGridPresetId) => {
setGridPreset(preset);
saveTradeNotificationGridPreset(preset);
}, []);
const isListView = listLayout === 'list';
const gridCols = getTradeNotificationGridPreset(gridPreset).cols;
return ( return (
<div className={`tnl-page tnl-page--with-right bps-page--vtd app ${theme}`}> <div className={`tnl-page tnl-page--with-right bps-page--vtd app ${theme}`}>
<div className="tnl-body"> <div className="tnl-body">
<div className="tnl-main"> <div className="tnl-main" style={{ containerType: 'inline-size', containerName: 'tnl-main' }}>
<div className="tnl-header"> <div className="tnl-header">
<h1 className="tnl-title"> </h1> <div className="tnl-header-row">
<p className="tnl-sub"> <div className="tnl-header-intro">
· . <h1 className="tnl-title"> </h1>
</p> <p className="tnl-sub">
{isListView
? '각 알림 왼쪽은 요약, 오른쪽은 캔들·지표 차트입니다. 차트 영역은 가로 스크롤로 확인할 수 있습니다.'
: `목록형과 동일한 알림 상세 카드를 ${getTradeNotificationGridPreset(gridPreset).label} 그리드로 표시합니다. 우측 아이콘으로 배치를 변경할 수 있습니다.`}
</p>
</div>
<div className="tnl-header-actions">
<div className="tnl-layout-toggle vtd-view-toggle" role="group" aria-label="목록 표시 방식">
<button
type="button"
className={`vtd-view-toggle-btn${isListView ? ' vtd-view-toggle-btn--on' : ''}`}
onClick={() => handleListLayoutChange('list')}
>
</button>
<button
type="button"
className={`vtd-view-toggle-btn${!isListView ? ' vtd-view-toggle-btn--on' : ''}`}
onClick={() => handleListLayoutChange('grid')}
>
</button>
</div>
<TradeNotificationGridLayoutPicker
value={gridPreset}
onChange={handleGridPresetChange}
disabled={isListView}
/>
</div>
</div>
<div className="tnl-toolbar"> <div className="tnl-toolbar">
<span className="tnl-chip tnl-chip--warn"> {unreadCount}</span> <span className="tnl-chip tnl-chip--warn"> {unreadCount}</span>
<div className="tnl-filter"> <div className="tnl-filter">
@@ -231,76 +292,38 @@ export const TradeNotificationListPage: React.FC<Props> = ({
</div> </div>
</div> </div>
<div className="tnl-list-wrap"> <div className={['tnl-list-wrap', !isListView ? 'tnl-list-wrap--grid' : ''].filter(Boolean).join(' ')}>
{filtered.length === 0 ? ( {filtered.length === 0 ? (
<div className="tnl-empty"> .</div> <div className="tnl-empty"> .</div>
) : ( ) : (
<ul className="tnl-list"> <ul
{filtered.map(item => { className={[
const isBuy = item.signalType === 'BUY'; 'tnl-list',
const detailRows = buildSignalDetailRows(item); isListView ? 'tnl-list--gallery' : 'tnl-list--grid',
const isSelected = selectedNotifyId === item.id; !isListView ? `tnl-grid--cols-${gridCols}` : '',
return ( ].filter(Boolean).join(' ')}
<li style={!isListView ? { gridTemplateColumns: `repeat(${gridCols}, minmax(0, 1fr))` } : undefined}
key={item.id} >
className={`tnl-row ${!item.isRead ? 'tnl-row--unread' : ''}${isSelected ? ' tnl-row--selected' : ''}`} {filtered.map(item => (
> <TradeNotificationListRow
<button key={item.id}
type="button" item={item}
className="tnl-row-main" theme={theme}
onClick={() => handleNotificationSelect(item)} layoutMode={listLayout}
> isSelected={selectedNotifyId === item.id}
<span className={`tnl-signal ${isBuy ? 'tnl-signal--buy' : 'tnl-signal--sell'}`}> chartsEnabled={isListView}
{item.signalType} onSelect={() => handleNotificationSelect(item)}
</span> onDelete={e => void handleDeleteOne(item, e)}
<span className="tnl-row-body"> onDetail={() => {
<span className="tnl-row-title">{getSignalHeadline(item)}</span> if (!item.isRead) markAsRead(item.id);
<span className="tnl-row-price">{formatSignalPrice(item.price)}</span> openDetail(item, { centered: true });
<span className="tnl-row-detail-grid"> }}
{detailRows.map(row => ( onGoToChart={() => {
<span key={row.label} className="tnl-row-detail-item"> markAsRead(item.id);
<span className="tnl-row-detail-lbl">{row.label}</span> onGoToChart(item.market);
<span className="tnl-row-detail-val">{row.value}</span> }}
</span> />
))} ))}
</span>
</span>
{!item.isRead && <span className="tnl-dot" aria-hidden />}
</button>
<button
type="button"
className="tnl-row-icon-btn"
title="삭제"
aria-label="알림 삭제"
onClick={e => void handleDeleteOne(item, e)}
>
<IcTrash />
</button>
<button
type="button"
className="tnl-row-action"
title="상세 보기"
onClick={() => {
if (!item.isRead) markAsRead(item.id);
openDetail(item, { centered: true });
}}
>
</button>
<button
type="button"
className="tnl-row-chart"
title="차트로 이동"
onClick={() => {
markAsRead(item.id);
onGoToChart(item.market);
}}
>
</button>
</li>
);
})}
</ul> </ul>
)} )}
</div> </div>
+5 -2
View File
@@ -131,6 +131,8 @@ interface TradingChartProps {
chartVisible?: boolean; chartVisible?: boolean;
/** pane(캔들·거래량·보조지표) 구분선 */ /** pane(캔들·거래량·보조지표) 구분선 */
paneSeparatorOptions?: ChartPaneSeparatorOptions; paneSeparatorOptions?: ChartPaneSeparatorOptions;
/** true: pane 합산 높이로 chart-container 를 키우지 않고 래퍼 높이에 맞춤 (미니 차트·알림 목록) */
paneLayoutClamp?: boolean;
} }
const TradingChart: React.FC<TradingChartProps> = ({ const TradingChart: React.FC<TradingChartProps> = ({
@@ -162,6 +164,7 @@ const TradingChart: React.FC<TradingChartProps> = ({
seriesPriceLabelsEnabled = true, seriesPriceLabelsEnabled = true,
chartVisible = true, chartVisible = true,
paneSeparatorOptions, paneSeparatorOptions,
paneLayoutClamp = false,
}) => { }) => {
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const wrapperRef = useRef<HTMLDivElement>(null); // 스크롤 래퍼 const wrapperRef = useRef<HTMLDivElement>(null); // 스크롤 래퍼
@@ -325,13 +328,13 @@ const TradingChart: React.FC<TradingChartProps> = ({
required = mgr.resetPaneHeights(wrapperH); required = mgr.resetPaneHeights(wrapperH);
} }
if (required > wrapperH + 4) { if (!paneLayoutClamp && required > wrapperH + 4) {
container.style.height = `${required}px`; container.style.height = `${required}px`;
} else { } else {
container.style.height = ''; container.style.height = '';
} }
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, [paneLayoutClamp]);
/** 캔들 확대 보기 → 원복 직후 가격·시간축·pane 비율 정상화 */ /** 캔들 확대 보기 → 원복 직후 가격·시간축·pane 비율 정상화 */
useEffect(() => { useEffect(() => {
@@ -0,0 +1,207 @@
/**
* 그리드형 n×n 레이아웃 선택 (TradingView 스타일 팝업)
*/
import React, { useEffect, useRef, useState } from 'react';
import {
gridPresetsByGroup,
getTradeNotificationGridPreset,
type TradeNotificationGridPreset,
type TradeNotificationGridPresetId,
} from '../../utils/tradeNotificationGridPresets';
interface Props {
value: TradeNotificationGridPresetId;
onChange: (id: TradeNotificationGridPresetId) => void;
disabled?: boolean;
}
/** 20×20 와이어프레임 아이콘 */
const LayoutIcon: React.FC<{ preset: TradeNotificationGridPreset }> = ({ preset }) => {
const s = 20;
const g = 1;
const cells: Array<{ x: number; y: number; w: number; h: number }> = [];
switch (preset.id) {
case '1x1':
cells.push({ x: 0, y: 0, w: s, h: s });
break;
case '2x1':
cells.push({ x: 0, y: 0, w: 9, h: s }, { x: 10, y: 0, w: 9, h: s });
break;
case '1x2':
cells.push({ x: 0, y: 0, w: s, h: 9 }, { x: 0, y: 10, w: s, h: 9 });
break;
case '3x1':
cells.push(
{ x: 0, y: 0, w: 6, h: s },
{ x: 7, y: 0, w: 6, h: s },
{ x: 14, y: 0, w: 6, h: s },
);
break;
case '1x3':
cells.push(
{ x: 0, y: 0, w: s, h: 6 },
{ x: 0, y: 7, w: s, h: 6 },
{ x: 0, y: 14, w: s, h: 6 },
);
break;
case '2x1-31':
cells.push({ x: 0, y: 0, w: 9, h: s }, { x: 10, y: 0, w: 9, h: 9 }, { x: 10, y: 10, w: 9, h: 9 });
break;
case '2x2':
cells.push(
{ x: 0, y: 0, w: 9, h: 9 },
{ x: 10, y: 0, w: 9, h: 9 },
{ x: 0, y: 10, w: 9, h: 9 },
{ x: 10, y: 10, w: 9, h: 9 },
);
break;
case '4x1':
for (let i = 0; i < 4; i++) cells.push({ x: i * 5, y: 0, w: 4, h: s });
break;
case '1x4':
for (let i = 0; i < 4; i++) cells.push({ x: 0, y: i * 5, w: s, h: 4 });
break;
case '2x1-211':
cells.push(
{ x: 0, y: 0, w: 9, h: 9 },
{ x: 10, y: 0, w: 9, h: 9 },
{ x: 0, y: 10, w: 9, h: 9 },
{ x: 10, y: 10, w: 9, h: 9 },
);
break;
case '5x1':
for (let i = 0; i < 5; i++) cells.push({ x: i * 4, y: 0, w: 3, h: s });
break;
case '3x2':
for (let c = 0; c < 3; c++) {
for (let r = 0; r < 2; r++) {
cells.push({ x: c * 7, y: r * 10, w: 6, h: 9 });
}
}
break;
case '2x3':
for (let c = 0; c < 2; c++) {
for (let r = 0; r < 3; r++) {
cells.push({ x: c * 10, y: r * 7, w: 9, h: 6 });
}
}
break;
case '4x2':
for (let c = 0; c < 4; c++) {
for (let r = 0; r < 2; r++) {
cells.push({ x: c * 5, y: r * 10, w: 4, h: 9 });
}
}
break;
case '2x4':
for (let c = 0; c < 2; c++) {
for (let r = 0; r < 4; r++) {
cells.push({ x: c * 10, y: r * 5, w: 9, h: 4 });
}
}
break;
default:
cells.push({ x: 0, y: 0, w: s, h: s });
}
return (
<svg width="22" height="22" viewBox="0 0 20 20" aria-hidden className="tnl-grid-preset-icon-svg">
{cells.map((c, i) => (
<rect
key={i}
x={c.x}
y={c.y}
width={c.w}
height={c.h}
rx={0.5}
fill="none"
stroke="currentColor"
strokeWidth={g}
/>
))}
</svg>
);
};
const TradeNotificationGridLayoutPicker: React.FC<Props> = ({
value,
onChange,
disabled = false,
}) => {
const [open, setOpen] = useState(false);
const rootRef = useRef<HTMLDivElement>(null);
const active = getTradeNotificationGridPreset(value);
const groups = [...gridPresetsByGroup().entries()].sort((a, b) => a[0] - b[0]);
useEffect(() => {
if (!open) return;
const onDoc = (e: MouseEvent) => {
if (!rootRef.current?.contains(e.target as Node)) setOpen(false);
};
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') setOpen(false);
};
document.addEventListener('mousedown', onDoc);
document.addEventListener('keydown', onKey);
return () => {
document.removeEventListener('mousedown', onDoc);
document.removeEventListener('keydown', onKey);
};
}, [open]);
return (
<div className="tnl-grid-layout-picker" ref={rootRef}>
<button
type="button"
className={[
'tnl-grid-layout-trigger',
open ? 'tnl-grid-layout-trigger--open' : '',
disabled ? 'tnl-grid-layout-trigger--disabled' : '',
].filter(Boolean).join(' ')}
title={`그리드 배치: ${active.label}`}
aria-label="그리드 배치 선택"
aria-expanded={open}
aria-haspopup="dialog"
disabled={disabled}
onClick={() => !disabled && setOpen(o => !o)}
>
<LayoutIcon preset={active} />
</button>
{open && (
<div className="tnl-grid-layout-popover" role="dialog" aria-label="그리드 배치">
<p className="tnl-grid-layout-popover-title"> </p>
{groups.map(([group, presets]) => (
<div key={group} className="tnl-grid-layout-group">
<span className="tnl-grid-layout-group-lbl">{group}</span>
<div className="tnl-grid-layout-group-row">
{presets.map(preset => (
<button
key={preset.id}
type="button"
className={[
'tnl-grid-preset-btn',
value === preset.id ? 'tnl-grid-preset-btn--on' : '',
].filter(Boolean).join(' ')}
title={`${preset.label} (${preset.cols}열)`}
aria-label={`${preset.label} 배치`}
aria-pressed={value === preset.id}
onClick={() => {
onChange(preset.id);
setOpen(false);
}}
>
<LayoutIcon preset={preset} />
</button>
))}
</div>
</div>
))}
</div>
)}
</div>
);
};
export default TradeNotificationGridLayoutPicker;
@@ -0,0 +1,322 @@
/**
* 매매 시그널 알림 목록 행 — 좌: 요약 카드(고정) · 우: 캔들+지표 카드(가로 스크롤)
*/
import React, { useEffect, useMemo, useRef, useState } from 'react';
import type { Theme } from '../../types';
import type { StrategyDto } from '../../utils/backendApi';
import { loadStrategy } from '../../utils/backendApi';
import type { TradeNotificationItem } from '../../contexts/TradeNotificationContext';
import {
buildSignalDetailRows,
formatCandleTypeKo,
formatSignalPrice,
getMarketDisplayLine,
getSignalTypeKo,
} from '../../utils/tradeSignalDisplay';
import {
buildStrategyChartIndicators,
candleTypeToTimeframe,
} from '../../utils/strategyToChartIndicators';
import { formatIndicatorDisplayLabel } from '../../utils/indicatorRegistry';
import { useIndicatorSettings } from '../../hooks/useIndicatorSettings';
import TradeSignalChartCard from './TradeSignalChartCard';
const IcTrash = () => (
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<polyline points="3 6 5 6 21 6" />
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
<line x1="10" y1="11" x2="10" y2="17" />
<line x1="14" y1="11" x2="14" y2="17" />
</svg>
);
export type TradeNotificationRowLayout = 'list' | 'grid';
interface Props {
item: TradeNotificationItem;
theme: Theme;
isSelected: boolean;
layoutMode?: TradeNotificationRowLayout;
chartsEnabled?: boolean;
onSelect: () => void;
onDelete: (e: React.MouseEvent) => void;
onDetail: () => void;
onGoToChart: () => void;
}
const MAX_INDICATOR_CARDS = 8;
const TradeNotificationListRow: React.FC<Props> = ({
item,
theme,
isSelected,
layoutMode = 'list',
chartsEnabled = true,
onSelect,
onDelete,
onDetail,
onGoToChart,
}) => {
const isBuy = item.signalType === 'BUY';
const allDetailRows = buildSignalDetailRows(item);
const metaRows = allDetailRows.filter(
row => row.label !== '종목' && row.label !== '매매 구분' && row.label !== '기준 가격' && row.label !== '전략',
);
const strategyRow = allDetailRows.find(row => row.label === '전략');
const { primary, secondary } = getMarketDisplayLine(item.market);
const sym = item.market.replace(/^KRW-/, '');
const isListLayout = layoutMode === 'list';
const chartTf = candleTypeToTimeframe(item.candleType ?? '1m');
const candleKo = formatCandleTypeKo(item.candleType);
const { getParams, getVisualConfig } = useIndicatorSettings();
const [strategy, setStrategy] = useState<StrategyDto | undefined>();
const strategyLabel = item.strategyName?.trim()
|| strategy?.name
|| strategyRow?.value
|| '실시간 전략';
const rowRef = useRef<HTMLLIElement>(null);
const [inView, setInView] = useState(false);
/** 펼쳐보기 중인 차트: 'candle' | 지표 id | null */
const [expandedChartKey, setExpandedChartKey] = useState<string | null>(null);
useEffect(() => {
if (!isListLayout) return;
const el = rowRef.current;
if (!el) return;
const io = new IntersectionObserver(
entries => {
if (entries[0]?.isIntersecting) setInView(true);
},
{ rootMargin: '120px 0px', threshold: 0.05 },
);
io.observe(el);
return () => io.disconnect();
}, [isListLayout]);
useEffect(() => {
if (!isListLayout || !item.strategyId || !chartsEnabled) {
setStrategy(undefined);
return;
}
let cancelled = false;
void loadStrategy(item.strategyId).then(s => {
if (!cancelled && s) setStrategy(s);
});
return () => { cancelled = true; };
}, [item.strategyId, chartsEnabled, isListLayout]);
useEffect(() => {
setExpandedChartKey(null);
}, [item.id, layoutMode]);
const indicatorCards = useMemo(() => {
if (!isListLayout || !strategy) return [];
const all = buildStrategyChartIndicators(strategy, getParams, getVisualConfig);
return all.slice(0, MAX_INDICATOR_CARDS).map(ind => ({
id: ind.id,
type: ind.type,
label: formatIndicatorDisplayLabel(ind.type),
config: [ind],
}));
}, [strategy, getParams, getVisualConfig, isListLayout]);
const chartActive = isListLayout && chartsEnabled && inView;
const chartExpanded = isListLayout && expandedChartKey != null;
const expandedIndicator = indicatorCards.find(c => c.id === expandedChartKey);
const summaryCard = (
<article
className={[
'tnl-summary-card',
isBuy ? 'tnl-summary-card--buy' : 'tnl-summary-card--sell',
!isListLayout ? 'tnl-summary-card--grid' : '',
isSelected ? 'tnl-summary-card--selected' : '',
].filter(Boolean).join(' ')}
aria-label="알림 요약"
>
<button
type="button"
className="tnl-summary-panel"
onClick={onSelect}
>
<div className="tnl-summary-head">
<div className="tnl-summary-title">
<span className="tnl-summary-ko">{primary}</span>
<span className="tnl-summary-sym">{sym || secondary}</span>
</div>
<div className="tnl-summary-head-actions">
<span className={`tnl-signal ${isBuy ? 'tnl-signal--buy' : 'tnl-signal--sell'}`}>
{item.signalType}
</span>
{!item.isRead && <span className="tnl-dot tnl-summary-dot" aria-label="미확인" />}
</div>
</div>
<div className="tnl-summary-quote" aria-label="시그널 가격">
<div className="tnl-summary-quote-left">
<span
className={`tnl-summary-arrow${isBuy ? ' tnl-summary-arrow--buy' : ' tnl-summary-arrow--sell'}`}
aria-hidden
>
{isBuy ? '▲' : '▼'}
</span>
<span className="tnl-summary-pair">{sym || secondary}/KRW</span>
<span className={`tnl-summary-type${isBuy ? ' tnl-summary-type--buy' : ' tnl-summary-type--sell'}`}>
{getSignalTypeKo(item.signalType)}
</span>
</div>
<div className="tnl-summary-quote-right">
<span
className={`tnl-summary-price${isBuy ? ' tnl-summary-price--buy' : ' tnl-summary-price--sell'}`}
>
{formatSignalPrice(item.price)}
</span>
</div>
</div>
<div className="tnl-summary-meta">
{metaRows.map(row => (
<div key={row.label} className="tnl-summary-info-row">
<span className="tnl-summary-info-lbl">{row.label}</span>
<span className={`tnl-summary-info-val${row.highlight ? ' tnl-summary-info-val--hi' : ''}`}>
{row.value}
</span>
</div>
))}
</div>
<div className="tnl-summary-strategy-field">
<span className="tnl-summary-strategy-lbl"></span>
<span className="tnl-summary-strategy-box" title={strategyLabel}>
{strategyLabel}
</span>
</div>
</button>
<div className="tnl-summary-card-actions">
<button
type="button"
className="tnl-row-icon-btn"
title="삭제"
aria-label="알림 삭제"
onClick={onDelete}
>
<IcTrash />
</button>
<button type="button" className="tnl-row-action" onClick={onDetail}>
</button>
<button type="button" className="tnl-row-chart" onClick={onGoToChart}>
</button>
</div>
</article>
);
if (!isListLayout) {
return (
<li
ref={rowRef}
className={[
'tnl-row',
'tnl-row--grid',
!item.isRead ? 'tnl-row--unread' : '',
isSelected ? 'tnl-row--selected' : '',
].filter(Boolean).join(' ')}
>
{summaryCard}
</li>
);
}
return (
<li
ref={rowRef}
className={[
'tnl-row',
'tnl-row--gallery',
!item.isRead ? 'tnl-row--unread' : '',
isSelected ? 'tnl-row--selected' : '',
].filter(Boolean).join(' ')}
>
<div className={['tnl-row-gallery', chartExpanded ? 'tnl-row-gallery--chart-expanded' : ''].filter(Boolean).join(' ')}>
{summaryCard}
{chartExpanded ? (
<div className="tnl-charts-expanded" aria-label="차트 전체보기">
{expandedChartKey === 'candle' && (
<TradeSignalChartCard
label="캔들"
meta={candleKo}
market={item.market}
timeframe={chartTf}
theme={theme}
indicators={[]}
enabled={chartActive}
expanded
onExpand={() => setExpandedChartKey('candle')}
onRestore={() => setExpandedChartKey(null)}
/>
)}
{expandedIndicator && (
<TradeSignalChartCard
label={expandedIndicator.label}
meta={candleKo}
market={item.market}
timeframe={chartTf}
theme={theme}
indicators={expandedIndicator.config}
enabled={chartActive}
expanded
onExpand={() => setExpandedChartKey(expandedIndicator.id)}
onRestore={() => setExpandedChartKey(null)}
/>
)}
</div>
) : (
<div className="tnl-charts-scroll" aria-label="캔들·지표 차트">
<div className="tnl-charts-track">
<TradeSignalChartCard
label="캔들"
meta={candleKo}
market={item.market}
timeframe={chartTf}
theme={theme}
indicators={[]}
enabled={chartActive}
expanded={false}
onExpand={() => setExpandedChartKey('candle')}
onRestore={() => setExpandedChartKey(null)}
/>
{indicatorCards.map(card => (
<TradeSignalChartCard
key={card.id}
label={card.label}
meta={candleKo}
market={item.market}
timeframe={chartTf}
theme={theme}
indicators={card.config}
enabled={chartActive}
expanded={false}
onExpand={() => setExpandedChartKey(card.id)}
onRestore={() => setExpandedChartKey(null)}
/>
))}
{item.strategyId != null && indicatorCards.length === 0 && chartActive && (
<section className="tnl-chart-card tnl-chart-card--placeholder">
<p className="tnl-muted"> </p>
</section>
)}
</div>
</div>
)}
</div>
</li>
);
};
export default TradeNotificationListRow;
@@ -0,0 +1,96 @@
/**
* 매매 시그널 알림 — 캔들·지표 미니 차트 카드 (펼쳐보기 / 원래보기)
*/
import React from 'react';
import type { IndicatorConfig } from '../../types';
import type { Theme, Timeframe } from '../../types';
import TradeSignalMiniChart from './TradeSignalMiniChart';
const IcExpand = () => (
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<polyline points="5,1 1,1 1,5" />
<polyline points="9,13 13,13 13,9" />
<line x1="1" y1="1" x2="6" y2="6" />
<line x1="13" y1="13" x2="8" y2="8" />
</svg>
);
const IcRestore = () => (
<svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth="1.7" strokeLinecap="round" strokeLinejoin="round" aria-hidden>
<polyline points="5,5 1,5 1,1" />
<polyline points="9,9 13,9 13,13" />
<line x1="1" y1="5" x2="5" y2="1" />
<line x1="13" y1="9" x2="9" y2="13" />
</svg>
);
export interface TradeSignalChartCardProps {
label: string;
meta: string;
market: string;
timeframe: Timeframe;
theme: Theme;
indicators: IndicatorConfig[];
enabled: boolean;
expanded: boolean;
onExpand: () => void;
onRestore: () => void;
}
const TradeSignalChartCard: React.FC<TradeSignalChartCardProps> = ({
label,
meta,
market,
timeframe,
theme,
indicators,
enabled,
expanded,
onExpand,
onRestore,
}) => (
<section
className={[
'tnl-chart-card',
expanded ? 'tnl-chart-card--expanded' : '',
].filter(Boolean).join(' ')}
>
<header className="tnl-chart-card-head">
<span className="tnl-chart-card-label">{label}</span>
<span className="tnl-chart-card-meta">{meta}</span>
</header>
<div className="tnl-chart-card-body">
<TradeSignalMiniChart
market={market}
timeframe={timeframe}
theme={theme}
indicators={indicators}
enabled={enabled}
fillHeight={expanded}
/>
{expanded ? (
<button
type="button"
className="tnl-chart-view-btn tnl-chart-view-btn--restore"
title="원래보기"
aria-label="원래보기"
onClick={e => { e.stopPropagation(); onRestore(); }}
>
<IcRestore />
</button>
) : (
<button
type="button"
className="tnl-chart-view-btn tnl-chart-view-btn--expand"
title="펼쳐보기"
aria-label="펼쳐보기"
onClick={e => { e.stopPropagation(); onExpand(); }}
>
<IcExpand />
</button>
)}
</div>
</section>
);
export default TradeSignalChartCard;
@@ -0,0 +1,217 @@
/**
* 매매 시그널 알림 행 — 캔들·지표 미니 차트 카드
*/
import React, {
useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState,
} from 'react';
import TradingChart from '../TradingChart';
import type { IndicatorConfig } from '../../types';
import type {
ChartMode, ChartType, Drawing, LegendData, OHLCVBar, Theme, Timeframe,
} from '../../types';
import { isUpbitMarket } from '../../utils/upbitApi';
import { useChartRealtimeData } from '../../hooks/useChartRealtimeData';
import { useHistoryLoader } from '../../hooks/useHistoryLoader';
import type { ChartManager } from '../../utils/ChartManager';
import { pinCandleWatch } from '../../utils/backendApi';
import { timeframeToCandleType } from '../../utils/chartCandleType';
import { DEFAULT_DISPLAY_TIMEZONE } from '../../utils/timezone';
interface Props {
market: string;
timeframe: Timeframe;
theme?: Theme;
indicators?: IndicatorConfig[];
enabled?: boolean;
/** 전체보기 모드 — 부모 높이에 맞춤 */
fillHeight?: boolean;
}
const noop = () => {};
const TradeSignalMiniChart: React.FC<Props> = ({
market,
timeframe,
theme = 'dark',
indicators = [],
enabled = true,
fillHeight = false,
}) => {
const managerRef = useRef<ChartManager | null>(null);
const canvasWrapRef = useRef<HTMLDivElement | null>(null);
const [chartReloadTick, setChartReloadTick] = useState(0);
const chartReloadTriggeredRef = useRef(false);
const pendingBarRef = useRef<OHLCVBar | null>(null);
const barsMarketRef = useRef<string | null>(null);
const chartLiveReadyRef = useRef(false);
const marketRef = useRef(market);
marketRef.current = market;
const handleCandlesReady = useCallback(() => {
chartLiveReadyRef.current = true;
const pending = pendingBarRef.current;
const mgr = managerRef.current;
if (!pending || !mgr || barsMarketRef.current !== marketRef.current) return;
pendingBarRef.current = null;
mgr.updateBar(pending);
const wrap = canvasWrapRef.current;
if (wrap && wrap.clientHeight > 0) mgr.resetPaneHeights(wrap.clientHeight);
}, []);
const applyRealtimeBar = useCallback((bar: OHLCVBar, append: boolean) => {
if (barsMarketRef.current !== marketRef.current) return;
if (!chartLiveReadyRef.current || !managerRef.current) {
pendingBarRef.current = bar;
return;
}
if (append) managerRef.current.appendBar(bar);
else managerRef.current.updateBar(bar);
}, []);
const handleTickUpdate = useCallback((bar: OHLCVBar) => {
applyRealtimeBar(bar, false);
}, [applyRealtimeBar]);
const handleNewCandle = useCallback((bar: OHLCVBar) => {
applyRealtimeBar(bar, true);
}, [applyRealtimeBar]);
const useUpbit = isUpbitMarket(market);
const { bars, barsMarket, isLoading } = useChartRealtimeData(
market,
timeframe,
useMemo(() => ({
onTickUpdate: handleTickUpdate,
onNewCandle: handleNewCandle,
enabled: enabled && useUpbit,
source: 'BACKEND_STOMP' as const,
}), [handleTickUpdate, handleNewCandle, enabled, useUpbit]),
);
const { isLoadingMore } = useHistoryLoader({
symbol: market,
timeframe,
isUpbit: useUpbit && enabled,
managerRef,
});
barsMarketRef.current = barsMarket;
const barsReady = bars.length >= 2 && barsMarket === market;
const applyPaneHeights = useCallback(() => {
const wrap = canvasWrapRef.current;
const mgr = managerRef.current;
if (!wrap || wrap.clientHeight <= 0 || !mgr?.hasMainSeries()) return;
mgr.resetPaneHeights(wrap.clientHeight);
}, []);
useLayoutEffect(() => {
chartLiveReadyRef.current = false;
pendingBarRef.current = null;
chartReloadTriggeredRef.current = false;
}, [market, timeframe, enabled]);
useEffect(() => {
if (!enabled) return;
const wrap = canvasWrapRef.current;
if (!wrap) return;
const timers = [200, 600, 1200].map(delay =>
setTimeout(() => {
if (!canvasWrapRef.current) return;
const h = canvasWrapRef.current.clientHeight;
if (h <= 0) return;
if (managerRef.current?.hasMainSeries()) {
managerRef.current.resetPaneHeights(h);
} else if (!chartReloadTriggeredRef.current && delay >= 600 && barsReady) {
chartReloadTriggeredRef.current = true;
setChartReloadTick(t => t + 1);
}
}, delay),
);
const ro = new ResizeObserver(() => applyPaneHeights());
ro.observe(wrap);
return () => {
timers.forEach(clearTimeout);
ro.disconnect();
};
}, [market, timeframe, enabled, barsReady, applyPaneHeights, fillHeight]);
useEffect(() => {
if (!enabled || !barsReady) return;
if (!managerRef.current?.hasMainSeries() && !chartReloadTriggeredRef.current) {
chartReloadTriggeredRef.current = true;
setChartReloadTick(t => t + 1);
}
}, [enabled, barsReady]);
useEffect(() => {
if (!enabled) return;
const t = setTimeout(() => applyPaneHeights(), 80);
return () => clearTimeout(t);
}, [fillHeight, enabled, applyPaneHeights]);
useEffect(() => {
if (!enabled || !useUpbit) return;
void pinCandleWatch(market, timeframeToCandleType(timeframe));
if (timeframeToCandleType(timeframe) !== '1m') {
void pinCandleWatch(market, '1m');
}
}, [market, timeframe, enabled, useUpbit]);
if (!enabled) {
return (
<div className={`tnl-mini-chart-placeholder${fillHeight ? ' tnl-mini-chart-placeholder--fill' : ''}`}>
</div>
);
}
return (
<div
ref={canvasWrapRef}
className={[
'tnl-mini-chart-canvas',
'slot-chart-wrap',
fillHeight ? 'tnl-mini-chart-canvas--fill' : '',
].filter(Boolean).join(' ')}
>
{isLoading && <div className="tnl-mini-chart-loading"></div>}
{isLoadingMore && (
<div className="chart-history-loading">
<div className="loading-spinner" style={{ width: 12, height: 12 }} />
</div>
)}
<TradingChart
key={`${market}-${timeframe}-${chartReloadTick}-${indicators.map(i => i.id).join(',')}`}
chartVisible
bars={bars}
barsMarket={barsMarket}
market={market}
timeframe={timeframe}
chartType={'candlestick' as ChartType}
theme={theme}
mode={'chart' as ChartMode}
indicators={indicators}
drawingTool="cursor"
drawings={[] as Drawing[]}
logScale={false}
drawingsLocked
drawingsVisible={false}
onCrosshair={noop as (d: LegendData | null) => void}
onManagerReady={mgr => { managerRef.current = mgr; }}
onAddDrawing={noop as (d: Drawing) => void}
onCandlesReady={handleCandlesReady}
magnifierEnabled={false}
volumeVisible={false}
showHoverToolbar={false}
seriesPriceLabelsEnabled={false}
paneLayoutClamp
/>
</div>
);
};
export default TradeSignalMiniChart;
@@ -0,0 +1,764 @@
/* 매매 시그널 알림 목록 — 갤러리 행 (요약 카드 + 가로 스크롤 차트) */
.tnl-row--gallery {
display: block;
overflow: visible;
}
.tnl-row-gallery {
display: flex;
flex-direction: row;
align-items: flex-start;
gap: 10px;
height: 320px;
max-height: 320px;
padding: 10px;
box-sizing: border-box;
}
/* 가상매매 투자대상 카드(vtd)와 동일한 3단 구성 */
.tnl-summary-card {
flex: 0 0 544px;
width: 544px;
height: 300px;
max-height: 300px;
align-self: flex-start;
display: flex;
flex-direction: column;
border: 1px solid color-mix(in srgb, var(--accent, #7aa2f7) 28%, var(--se-border, var(--border)));
border-radius: 12px;
background: var(--se-panel-card-bg, var(--bg2));
overflow: hidden;
}
.tnl-summary-card--buy {
border-color: color-mix(in srgb, var(--accent, #3f7ef5) 35%, var(--se-border, var(--border)));
}
.tnl-summary-card--sell {
border-color: color-mix(in srgb, #ef5350 35%, var(--se-border, var(--border)));
}
.tnl-summary-panel {
flex: 1;
min-height: 0;
width: 100%;
padding: 14px 16px 12px;
border: none;
background: transparent;
color: inherit;
text-align: left;
cursor: pointer;
display: flex;
flex-direction: column;
gap: 12px;
overflow: hidden;
box-sizing: border-box;
}
.tnl-summary-panel:hover {
background: color-mix(in srgb, var(--accent, #7aa2f7) 6%, transparent);
}
/* 1행: 종목명 */
.tnl-summary-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
flex-shrink: 0;
}
.tnl-summary-title {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
flex: 1;
}
.tnl-summary-ko {
font-size: 17px;
font-weight: 700;
line-height: 1.2;
color: var(--text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tnl-summary-sym {
font-size: 12px;
font-weight: 600;
color: var(--text3, var(--se-text-muted));
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.tnl-summary-head-actions {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.tnl-summary-dot {
flex-shrink: 0;
}
/* 2행: 페어 · 시그널 가격 */
.tnl-summary-quote {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-shrink: 0;
padding: 4px 2px;
}
.tnl-summary-quote-left {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
flex: 1;
}
.tnl-summary-arrow {
font-size: 11px;
line-height: 1;
flex-shrink: 0;
}
.tnl-summary-arrow--buy {
color: var(--accent, #3f7ef5);
}
.tnl-summary-arrow--sell {
color: #ef5350;
}
.tnl-summary-pair {
font-size: 12px;
font-weight: 600;
color: var(--text3, var(--se-text-muted));
white-space: nowrap;
}
.tnl-summary-type {
font-size: 12px;
font-weight: 700;
white-space: nowrap;
}
.tnl-summary-type--buy {
color: var(--accent, #3f7ef5);
}
.tnl-summary-type--sell {
color: #ef5350;
}
.tnl-summary-quote-right {
display: flex;
align-items: baseline;
flex-shrink: 0;
margin-left: auto;
}
.tnl-summary-price {
font-size: 20px;
font-weight: 800;
font-variant-numeric: tabular-nums;
white-space: nowrap;
line-height: 1.1;
}
.tnl-summary-price--buy {
color: var(--accent, #3f7ef5);
}
.tnl-summary-price--sell {
color: #ef5350;
}
/* 3행+: 상세 정보 */
.tnl-summary-meta {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
gap: 10px;
overflow-y: auto;
overflow-x: hidden;
padding-right: 2px;
}
.tnl-summary-info-row {
display: grid;
grid-template-columns: 88px minmax(0, 1fr);
align-items: start;
gap: 10px 14px;
}
.tnl-summary-info-lbl {
font-size: 12px;
font-weight: 600;
color: var(--text3, var(--se-text-muted));
line-height: 1.4;
flex-shrink: 0;
}
.tnl-summary-info-val {
font-size: 13px;
font-weight: 500;
color: var(--text2);
line-height: 1.45;
word-break: break-word;
text-align: right;
}
.tnl-summary-info-val--hi {
font-weight: 700;
color: var(--text);
}
/* 하단: 투자전략 (vtd-target-strategy-field) */
.tnl-summary-strategy-field {
display: flex;
align-items: center;
gap: 12px;
flex-shrink: 0;
width: 100%;
padding-top: 4px;
border-top: 1px solid var(--border);
}
.tnl-summary-strategy-lbl {
font-size: 12px;
font-weight: 600;
color: var(--text3, var(--se-text-muted));
white-space: nowrap;
flex-shrink: 0;
}
.tnl-summary-strategy-box {
flex: 1;
min-width: 0;
padding: 8px 12px;
border-radius: 8px;
border: 1px solid var(--se-border, var(--border));
background: var(--bg, var(--bg3));
color: var(--text);
font-size: 13px;
font-weight: 600;
line-height: 1.35;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-align: left;
}
.tnl-summary-card-actions {
display: flex;
align-items: center;
gap: 4px;
padding: 8px 10px;
border-top: 1px solid var(--border);
background: var(--bg3, var(--se-center-bg));
flex-shrink: 0;
}
.tnl-row-gallery--chart-expanded .tnl-charts-scroll {
display: none;
}
.tnl-charts-expanded {
flex: 1;
min-width: 0;
height: 300px;
max-height: 300px;
display: flex;
flex-direction: column;
border-radius: 12px;
border: 1px solid var(--se-border, var(--border));
background: color-mix(in srgb, var(--bg) 40%, var(--bg2));
overflow: hidden;
box-sizing: border-box;
}
.tnl-charts-expanded .tnl-chart-card--expanded {
flex: 1;
width: 100%;
height: 100%;
max-height: none;
border: none;
border-radius: 0;
gap: 8px;
padding: 0 4px 4px;
box-sizing: border-box;
}
.tnl-charts-scroll {
flex: 1;
min-width: 0;
height: 300px;
max-height: 300px;
overflow-x: auto;
overflow-y: hidden;
-webkit-overflow-scrolling: touch;
overscroll-behavior-x: contain;
border-radius: 12px;
border: 1px solid var(--se-border, var(--border));
background: color-mix(in srgb, var(--bg) 40%, var(--bg2));
}
.tnl-charts-scroll::-webkit-scrollbar {
height: 8px;
}
.tnl-charts-scroll::-webkit-scrollbar-thumb {
background: color-mix(in srgb, var(--text3) 35%, transparent);
border-radius: 4px;
}
.tnl-charts-track {
display: flex;
flex-direction: row;
align-items: flex-start;
gap: 10px;
padding: 10px;
height: 100%;
box-sizing: border-box;
width: max-content;
}
.tnl-chart-card {
position: relative;
flex: 0 0 300px;
width: 300px;
height: 280px;
max-height: 280px;
display: flex;
flex-direction: column;
gap: 6px;
border: 1px solid var(--se-border, var(--border));
border-radius: 10px;
background: var(--se-panel-card-bg, var(--bg2));
overflow: hidden;
box-sizing: border-box;
}
.tnl-chart-card-body {
position: relative;
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.tnl-chart-card--expanded .tnl-chart-card-body {
flex: 1;
min-height: 0;
}
.tnl-chart-card--placeholder {
align-items: center;
justify-content: center;
}
.tnl-chart-card-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 8px 10px 0;
flex-shrink: 0;
}
.tnl-chart-card-label {
font-size: 12px;
font-weight: 700;
}
.tnl-chart-card-meta {
font-size: 10px;
color: var(--text3);
font-weight: 600;
}
.tnl-mini-chart-canvas {
position: relative;
flex: 0 0 236px;
height: 236px;
max-height: 236px;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.tnl-mini-chart-canvas--fill {
flex: 1 1 auto;
height: 100%;
max-height: none;
}
.tnl-mini-chart-canvas > .tv-chart-wrap {
flex: 1 1 0;
min-height: 0;
max-height: 100%;
width: 100%;
overflow: hidden !important;
}
.tnl-mini-chart-loading {
position: absolute;
inset: 0;
z-index: 2;
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
color: var(--text3);
background: color-mix(in srgb, var(--bg2) 80%, transparent);
}
.tnl-mini-chart-placeholder {
flex: 0 0 236px;
height: 236px;
max-height: 236px;
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
color: var(--text3);
}
.tnl-mini-chart-placeholder--fill {
flex: 1;
height: 100%;
max-height: none;
}
/* 차트 하단 우측 — 펼쳐보기 / 원래보기 */
.tnl-chart-view-btn {
position: absolute;
right: 8px;
bottom: 8px;
z-index: 4;
display: inline-flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
padding: 0;
border: 1px solid color-mix(in srgb, var(--accent, #3f7ef5) 35%, var(--se-border, var(--border)));
border-radius: 8px;
background: color-mix(in srgb, var(--bg2) 88%, transparent);
color: var(--accent, #3f7ef5);
cursor: pointer;
box-shadow: 0 2px 8px color-mix(in srgb, var(--bg) 50%, transparent);
transition: background 0.15s, border-color 0.15s, transform 0.1s;
}
.tnl-chart-view-btn:hover {
background: color-mix(in srgb, var(--accent, #3f7ef5) 18%, var(--bg2));
border-color: var(--accent, #3f7ef5);
}
.tnl-chart-view-btn:active {
transform: scale(0.96);
}
.tnl-chart-view-btn--restore {
color: var(--text2);
border-color: var(--se-border, var(--border));
background: color-mix(in srgb, var(--bg3) 92%, transparent);
}
.tnl-chart-view-btn--restore:hover {
color: var(--text);
border-color: var(--accent, #7aa2f7);
background: color-mix(in srgb, var(--accent, #7aa2f7) 12%, var(--bg3));
}
.tnl-muted {
font-size: 12px;
color: var(--text3);
margin: 0;
}
.tnl-list--gallery {
gap: 12px;
}
/* 헤더 — 제목(좌) · 목록/그리드 전환(우) */
.tnl-header-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 14px;
}
.tnl-header-intro {
flex: 1;
min-width: 0;
}
.tnl-header-intro .tnl-title {
margin-bottom: 6px;
}
.tnl-header-intro .tnl-sub {
margin-bottom: 0;
}
.tnl-header-actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 8px;
flex-shrink: 0;
padding-top: 2px;
}
.tnl-layout-toggle {
flex-shrink: 0;
}
/* 그리드 n×n 배치 선택 */
.tnl-grid-layout-picker {
position: relative;
flex-shrink: 0;
}
.tnl-grid-layout-trigger {
display: inline-flex;
align-items: center;
justify-content: center;
width: 34px;
height: 34px;
padding: 0;
border: 1px solid var(--se-border, var(--border));
border-radius: 8px;
background: var(--bg2, var(--se-panel-card-bg));
color: var(--text2);
cursor: pointer;
transition: border-color 0.15s, background 0.15s, color 0.15s;
}
.tnl-grid-layout-trigger:hover:not(:disabled) {
border-color: var(--accent, #3f7ef5);
color: var(--accent, #3f7ef5);
background: color-mix(in srgb, var(--accent, #3f7ef5) 10%, var(--bg2));
}
.tnl-grid-layout-trigger--open {
border-color: var(--accent, #3f7ef5);
color: var(--accent, #3f7ef5);
box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent, #3f7ef5) 35%, transparent);
}
.tnl-grid-layout-trigger--disabled {
opacity: 0.4;
cursor: not-allowed;
}
.tnl-grid-layout-popover {
position: absolute;
top: calc(100% + 8px);
right: 0;
z-index: 120;
width: min(320px, calc(100vw - 48px));
max-height: min(420px, 70vh);
overflow-y: auto;
padding: 10px 12px 12px;
border-radius: 10px;
border: 1px solid var(--se-border, var(--border));
background: var(--bg2, #1a1f2e);
box-shadow:
0 12px 32px color-mix(in srgb, var(--bg) 55%, transparent),
0 0 0 1px color-mix(in srgb, var(--text3) 12%, transparent);
}
.tnl-grid-layout-popover-title {
margin: 0 0 10px;
font-size: 11px;
font-weight: 700;
color: var(--text3);
letter-spacing: 0.04em;
}
.tnl-grid-layout-group {
display: flex;
align-items: center;
gap: 8px;
padding: 4px 0;
border-top: 1px solid color-mix(in srgb, var(--border) 80%, transparent);
}
.tnl-grid-layout-group:first-of-type {
border-top: none;
padding-top: 0;
}
.tnl-grid-layout-group-lbl {
flex: 0 0 14px;
font-size: 11px;
font-weight: 600;
color: var(--text3);
text-align: center;
}
.tnl-grid-layout-group-row {
display: flex;
flex-wrap: wrap;
gap: 6px;
flex: 1;
min-width: 0;
}
.tnl-grid-preset-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 36px;
height: 32px;
padding: 0;
border: 1px solid transparent;
border-radius: 6px;
background: transparent;
color: var(--text2);
cursor: pointer;
transition: border-color 0.12s, background 0.12s, color 0.12s;
}
.tnl-grid-preset-btn:hover {
background: color-mix(in srgb, var(--accent, #3f7ef5) 10%, transparent);
color: var(--text);
}
.tnl-grid-preset-btn--on {
border-color: var(--accent, #3f7ef5);
background: color-mix(in srgb, var(--accent, #3f7ef5) 14%, transparent);
color: var(--accent, #3f7ef5);
}
.tnl-grid-preset-icon-svg {
display: block;
}
/* 그리드형 — 목록형과 동일한 알림 상세 카드 · n×n */
.tnl-list-wrap.tnl-list-wrap--grid {
flex: 1;
min-height: 0;
overflow: hidden;
display: flex;
flex-direction: column;
padding: 12px 24px 24px;
box-sizing: border-box;
container-type: inline-size;
container-name: tnl-grid-wrap;
}
ul.tnl-list.tnl-list--grid {
flex: 1;
min-height: 0;
width: 100%;
display: grid;
flex-direction: unset;
grid-auto-rows: auto;
gap: 14px;
align-content: start;
align-items: start;
list-style: none;
margin: 0;
padding: 4px 4px 16px;
overflow-y: auto;
overflow-x: hidden;
overscroll-behavior: contain;
-webkit-overflow-scrolling: touch;
}
@container tnl-grid-wrap (max-width: 640px) {
ul.tnl-list.tnl-list--grid {
grid-template-columns: minmax(0, 1fr) !important;
}
}
@container tnl-grid-wrap (max-width: 960px) {
ul.tnl-list.tnl-list--grid.tnl-grid--cols-3,
ul.tnl-list.tnl-list--grid.tnl-grid--cols-4,
ul.tnl-list.tnl-list--grid.tnl-grid--cols-5 {
grid-template-columns: repeat(2, minmax(0, 1fr)) !important;
}
}
@container tnl-grid-wrap (max-width: 1200px) {
ul.tnl-list.tnl-list--grid.tnl-grid--cols-4,
ul.tnl-list.tnl-list--grid.tnl-grid--cols-5 {
grid-template-columns: repeat(3, minmax(0, 1fr)) !important;
}
}
.tnl-row.tnl-row--grid {
display: block;
min-width: 0;
width: 100%;
border: none;
background: transparent;
border-radius: 0;
overflow: visible;
}
/* 목록형과 동일 카드 박스(544×300) — 그리드 셀 너비에 맞춤 */
.tnl-row--grid .tnl-summary-card,
.tnl-summary-card--grid {
flex: none;
width: 100%;
max-width: none;
height: 300px;
max-height: 300px;
min-height: 300px;
}
.tnl-row--grid.tnl-row--unread .tnl-summary-card {
border-color: rgba(122, 162, 247, 0.45);
}
.tnl-row--grid .tnl-summary-card--selected,
.tnl-row--grid.tnl-row--selected .tnl-summary-card {
border-color: var(--accent, #7aa2f7);
box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent, #7aa2f7) 35%, transparent);
}
.tnl-row--gallery.tnl-row--selected .tnl-summary-card,
.tnl-row--gallery.tnl-row--selected .tnl-charts-scroll,
.tnl-row--gallery.tnl-row--selected .tnl-charts-expanded {
border-color: var(--accent, #7aa2f7);
box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent, #7aa2f7) 35%, transparent);
}
@container tnl-main (max-width: 900px) {
.tnl-row-gallery {
flex-direction: column;
height: auto;
max-height: none;
}
.tnl-summary-card {
flex: 0 0 auto;
width: 100%;
max-width: 100%;
max-height: none;
}
.tnl-charts-scroll,
.tnl-charts-expanded {
height: 300px;
max-height: 300px;
}
}
+4
View File
@@ -31,6 +31,10 @@ export interface UiPreferences {
tradeNotifications?: { tradeNotifications?: {
readIds?: string[]; readIds?: string[];
hiddenIds?: string[]; hiddenIds?: string[];
/** 매매 시그널 알림 목록 화면 — list | grid */
listLayout?: 'list' | 'grid';
/** 그리드형 열 배치 프리셋 id (tradeNotificationGridPresets) */
gridPreset?: string;
}; };
} }
@@ -0,0 +1,76 @@
/** 매매 시그널 알림 — 그리드형 n×n 레이아웃 프리셋 (TradingView 스타일) */
export type TradeNotificationGridPresetId =
| '1x1'
| '2x1'
| '1x2'
| '3x1'
| '1x3'
| '2x1-31'
| '2x2'
| '4x1'
| '1x4'
| '2x1-211'
| '5x1'
| '3x2'
| '2x3'
| '4x2'
| '2x4';
export interface TradeNotificationGridPreset {
id: TradeNotificationGridPresetId;
/** 스크롤 목록에 적용할 열 수 */
cols: number;
/** 팝업 그룹 라벨 (패널 수) */
group: number;
/** 짧은 표기 (2×2 등) */
label: string;
}
export const TRADE_NOTIFICATION_GRID_PRESETS: TradeNotificationGridPreset[] = [
{ id: '1x1', cols: 1, group: 1, label: '1×1' },
{ id: '2x1', cols: 2, group: 2, label: '2×1' },
{ id: '1x2', cols: 1, group: 2, label: '1×2' },
{ id: '3x1', cols: 3, group: 3, label: '3×1' },
{ id: '1x3', cols: 1, group: 3, label: '1×3' },
{ id: '2x1-31', cols: 2, group: 3, label: '2+1' },
{ id: '2x2', cols: 2, group: 4, label: '2×2' },
{ id: '4x1', cols: 4, group: 4, label: '4×1' },
{ id: '1x4', cols: 1, group: 4, label: '1×4' },
{ id: '2x1-211', cols: 2, group: 4, label: '2+2' },
{ id: '5x1', cols: 5, group: 5, label: '5×1' },
{ id: '3x2', cols: 3, group: 6, label: '3×2' },
{ id: '2x3', cols: 2, group: 6, label: '2×3' },
{ id: '4x2', cols: 4, group: 8, label: '4×2' },
{ id: '2x4', cols: 2, group: 8, label: '2×4' },
];
const PRESET_MAP = new Map(
TRADE_NOTIFICATION_GRID_PRESETS.map(p => [p.id, p]),
);
export const DEFAULT_TRADE_NOTIFICATION_GRID_PRESET: TradeNotificationGridPresetId = '2x2';
export function getTradeNotificationGridPreset(
id: string | undefined | null,
): TradeNotificationGridPreset {
const preset = id ? PRESET_MAP.get(id as TradeNotificationGridPresetId) : undefined;
return preset ?? PRESET_MAP.get(DEFAULT_TRADE_NOTIFICATION_GRID_PRESET)!;
}
export function normalizeTradeNotificationGridPreset(
id: string | undefined | null,
): TradeNotificationGridPresetId {
return getTradeNotificationGridPreset(id).id;
}
/** 그룹별 프리셋 (팝업 행 구성) */
export function gridPresetsByGroup(): Map<number, TradeNotificationGridPreset[]> {
const map = new Map<number, TradeNotificationGridPreset[]>();
for (const p of TRADE_NOTIFICATION_GRID_PRESETS) {
const list = map.get(p.group) ?? [];
list.push(p);
map.set(p.group, list);
}
return map;
}
@@ -0,0 +1,27 @@
import { getUiPreferences, patchUiPreferences } from './uiPreferencesDb';
import {
DEFAULT_TRADE_NOTIFICATION_GRID_PRESET,
normalizeTradeNotificationGridPreset,
type TradeNotificationGridPresetId,
} from './tradeNotificationGridPresets';
export type TradeNotificationListLayout = 'list' | 'grid';
export function loadTradeNotificationListLayout(): TradeNotificationListLayout {
const v = getUiPreferences().tradeNotifications?.listLayout;
return v === 'grid' ? 'grid' : 'list';
}
export function saveTradeNotificationListLayout(layout: TradeNotificationListLayout): void {
patchUiPreferences({ tradeNotifications: { listLayout: layout } });
}
export function loadTradeNotificationGridPreset(): TradeNotificationGridPresetId {
return normalizeTradeNotificationGridPreset(
getUiPreferences().tradeNotifications?.gridPreset ?? DEFAULT_TRADE_NOTIFICATION_GRID_PRESET,
);
}
export function saveTradeNotificationGridPreset(preset: TradeNotificationGridPresetId): void {
patchUiPreferences({ tradeNotifications: { gridPreset: preset } });
}