1752ec555a
paperDashboard.css가 PaperTradingPage에만 임포트되어 있어 VirtualTradingPage·알림목록·실시간 차트·백테스팅 등에서 ptd-split-card-body의 flex:1 레이아웃이 없어 콘텐츠 높이가 0이 됨. BuilderPageShell·ChartWorkspaceView·TradeNotificationListPage에 paperDashboard.css 임포트 추가로 전 페이지에서 정상 표시되도록 수정. Co-authored-by: Cursor <cursoragent@cursor.com>
439 lines
15 KiB
TypeScript
439 lines
15 KiB
TypeScript
import React, { useCallback, useMemo, useRef, useState } from 'react';
|
|
import type { Theme } from '../../types';
|
|
import {
|
|
readStoredBool,
|
|
readStoredSize,
|
|
storeBool,
|
|
storeSize,
|
|
usePanelResize,
|
|
} from '../strategyEditor/usePanelResize';
|
|
import '../../styles/strategyEditorTheme.css';
|
|
import '../../styles/builderPageShell.css';
|
|
import '../../styles/paperDashboard.css';
|
|
import '../../styles/tradeRightPanel.css';
|
|
|
|
const LEFT_MIN = 220;
|
|
const LEFT_MAX = 520;
|
|
const LEFT_DEFAULT = 280;
|
|
const RIGHT_MIN = 260;
|
|
const RIGHT_MAX = 520;
|
|
const RIGHT_DEFAULT = 280;
|
|
const FOOTER_MIN = 88;
|
|
const FOOTER_MAX = 420;
|
|
const FOOTER_DEFAULT = 140;
|
|
|
|
export interface BuilderPageShellProps {
|
|
theme: Theme;
|
|
title: string;
|
|
subtitle?: string;
|
|
headerActions?: React.ReactNode;
|
|
/** 타이틀 우측·중앙 그리드 시작선 정렬 (가상투자 등) */
|
|
headerCenter?: React.ReactNode;
|
|
/** grid: 좌패널폭 기준 3열 / between: 타이틀·버튼 사이 중앙 (모의투자 안내 등) */
|
|
headerCenterMode?: 'grid' | 'between';
|
|
pageClassName?: string;
|
|
banner?: React.ReactNode;
|
|
leftTitle?: string;
|
|
leftTabs?: React.ReactNode;
|
|
leftActions?: React.ReactNode;
|
|
left: React.ReactNode;
|
|
centerHead?: React.ReactNode;
|
|
center: React.ReactNode;
|
|
rightTitle?: string;
|
|
rightTabs?: React.ReactNode;
|
|
right?: React.ReactNode;
|
|
footerLabel?: string;
|
|
footer?: React.ReactNode;
|
|
leftStorageKey?: string;
|
|
leftDefaultWidth?: number;
|
|
rightStorageKey?: string;
|
|
rightDefaultWidth?: number;
|
|
footerStorageKey?: string;
|
|
/** 실시간 차트처럼 좌·우 패널 접기/펼치기 */
|
|
collapsiblePanels?: boolean;
|
|
leftCollapsedStorageKey?: string;
|
|
rightCollapsedStorageKey?: string;
|
|
loading?: boolean;
|
|
loadingText?: string;
|
|
}
|
|
|
|
function PanelCollapseHandle({
|
|
side,
|
|
open,
|
|
onToggle,
|
|
label,
|
|
}: {
|
|
side: 'left' | 'right';
|
|
open: boolean;
|
|
onToggle: () => void;
|
|
label: string;
|
|
}) {
|
|
return (
|
|
<button
|
|
type="button"
|
|
className={`bps-panel-handle bps-panel-handle--${side}${open ? ' bps-panel-handle--open' : ''}`}
|
|
onClick={e => {
|
|
e.stopPropagation();
|
|
onToggle();
|
|
}}
|
|
title={open ? `${label} 닫기` : `${label} 열기`}
|
|
aria-expanded={open}
|
|
aria-label={open ? `${label} 닫기` : `${label} 열기`}
|
|
>
|
|
<svg
|
|
width="8"
|
|
height="14"
|
|
viewBox="0 0 8 14"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="1.8"
|
|
strokeLinecap="round"
|
|
strokeLinejoin="round"
|
|
aria-hidden
|
|
>
|
|
{side === 'left' ? (
|
|
open ? <polyline points="6,1 2,7 6,13" /> : <polyline points="2,1 6,7 2,13" />
|
|
) : (
|
|
open ? <polyline points="2,1 6,7 2,13" /> : <polyline points="6,1 2,7 6,13" />
|
|
)}
|
|
</svg>
|
|
</button>
|
|
);
|
|
}
|
|
|
|
export default function BuilderPageShell({
|
|
theme,
|
|
title,
|
|
subtitle,
|
|
headerActions,
|
|
headerCenter,
|
|
headerCenterMode = 'grid',
|
|
pageClassName,
|
|
banner,
|
|
leftTitle,
|
|
leftTabs,
|
|
leftActions,
|
|
left,
|
|
centerHead,
|
|
center,
|
|
rightTitle,
|
|
rightTabs,
|
|
right,
|
|
footerLabel,
|
|
footer,
|
|
leftStorageKey = 'bps-left-width',
|
|
leftDefaultWidth = LEFT_DEFAULT,
|
|
rightStorageKey = 'bps-right-width',
|
|
rightDefaultWidth = RIGHT_DEFAULT,
|
|
footerStorageKey = 'bps-footer-height',
|
|
collapsiblePanels = false,
|
|
leftCollapsedStorageKey,
|
|
rightCollapsedStorageKey,
|
|
loading = false,
|
|
loadingText = '로딩 중…',
|
|
}: BuilderPageShellProps) {
|
|
const [leftWidth, setLeftWidth] = useState(() => readStoredSize(leftStorageKey, leftDefaultWidth));
|
|
const [rightWidth, setRightWidth] = useState(() => readStoredSize(rightStorageKey, rightDefaultWidth));
|
|
const [leftOpen, setLeftOpen] = useState(() =>
|
|
collapsiblePanels && leftCollapsedStorageKey
|
|
? readStoredBool(leftCollapsedStorageKey, true)
|
|
: true,
|
|
);
|
|
const [rightOpen, setRightOpen] = useState(() =>
|
|
collapsiblePanels && rightCollapsedStorageKey
|
|
? readStoredBool(rightCollapsedStorageKey, true)
|
|
: true,
|
|
);
|
|
const [footerHeight, setFooterHeight] = useState(() => readStoredSize(footerStorageKey, FOOTER_DEFAULT));
|
|
const leftWidthRef = useRef(leftWidth);
|
|
const rightWidthRef = useRef(rightWidth);
|
|
const footerHeightRef = useRef(footerHeight);
|
|
leftWidthRef.current = leftWidth;
|
|
rightWidthRef.current = rightWidth;
|
|
footerHeightRef.current = footerHeight;
|
|
|
|
const onLeftSplitter = usePanelResize(
|
|
'vertical',
|
|
setLeftWidth,
|
|
() => leftWidthRef.current,
|
|
LEFT_MIN,
|
|
LEFT_MAX,
|
|
v => storeSize(leftStorageKey, v),
|
|
);
|
|
|
|
const handleRightSplitter = useCallback((e: React.PointerEvent<HTMLDivElement>) => {
|
|
const startX = e.clientX;
|
|
const start = rightWidthRef.current;
|
|
const cursor = 'col-resize';
|
|
|
|
document.body.style.cursor = cursor;
|
|
document.body.style.userSelect = 'none';
|
|
e.currentTarget.setPointerCapture(e.pointerId);
|
|
e.currentTarget.classList.add('se-splitter--active');
|
|
const splitter = e.currentTarget;
|
|
|
|
const onMove = (ev: PointerEvent) => {
|
|
const delta = ev.clientX - startX;
|
|
const next = Math.min(RIGHT_MAX, Math.max(RIGHT_MIN, start - delta));
|
|
setRightWidth(next);
|
|
};
|
|
|
|
const onUp = (ev: PointerEvent) => {
|
|
document.body.style.cursor = '';
|
|
document.body.style.userSelect = '';
|
|
splitter.releasePointerCapture(ev.pointerId);
|
|
splitter.classList.remove('se-splitter--active');
|
|
window.removeEventListener('pointermove', onMove);
|
|
window.removeEventListener('pointerup', onUp);
|
|
storeSize(rightStorageKey, rightWidthRef.current);
|
|
};
|
|
|
|
window.addEventListener('pointermove', onMove);
|
|
window.addEventListener('pointerup', onUp);
|
|
}, [rightStorageKey]);
|
|
|
|
const onFooterSplitter = usePanelResize(
|
|
'horizontal',
|
|
setFooterHeight,
|
|
() => footerHeightRef.current,
|
|
FOOTER_MIN,
|
|
FOOTER_MAX,
|
|
v => storeSize(footerStorageKey, v),
|
|
);
|
|
|
|
const toggleLeft = useCallback(() => {
|
|
setLeftOpen(prev => {
|
|
const next = !prev;
|
|
if (leftCollapsedStorageKey) storeBool(leftCollapsedStorageKey, next);
|
|
return next;
|
|
});
|
|
}, [leftCollapsedStorageKey]);
|
|
|
|
const toggleRight = useCallback(() => {
|
|
setRightOpen(prev => {
|
|
const next = !prev;
|
|
if (rightCollapsedStorageKey) storeBool(rightCollapsedStorageKey, next);
|
|
return next;
|
|
});
|
|
}, [rightCollapsedStorageKey]);
|
|
|
|
const pageStyle = useMemo(() => ({
|
|
'--bps-left-width': leftOpen ? `${leftWidth}px` : '0px',
|
|
'--bps-right-width': rightOpen ? `${rightWidth}px` : '0px',
|
|
'--bps-left-open': leftOpen ? '1' : '0',
|
|
'--bps-right-open': rightOpen ? '1' : '0',
|
|
'--bps-left-splitter': leftOpen ? '5px' : '0px',
|
|
'--bps-right-splitter': rightOpen ? '5px' : '0px',
|
|
'--bps-left-handle': '16px',
|
|
'--bps-splitter-w': '5px',
|
|
}) as React.CSSProperties, [leftOpen, leftWidth, rightOpen, rightWidth]);
|
|
|
|
const bodyStyle = useMemo(() => ({
|
|
'--bps-footer-height': `${footerHeight}px`,
|
|
'--bps-left-width': leftOpen ? `${leftWidth}px` : '0px',
|
|
'--bps-right-width': rightOpen ? `${rightWidth}px` : '0px',
|
|
}) as React.CSSProperties, [footerHeight, leftOpen, leftWidth, rightOpen, rightWidth]);
|
|
|
|
const centerContentClass = [
|
|
'bps-center-content',
|
|
collapsiblePanels && !leftOpen ? 'bps-center-content--left-collapsed' : '',
|
|
collapsiblePanels && !rightOpen ? 'bps-center-content--right-collapsed' : '',
|
|
collapsiblePanels && !leftOpen && !rightOpen ? 'bps-center-content--both-collapsed' : '',
|
|
].filter(Boolean).join(' ');
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className={`bps-page se-page se-page--${theme}`}>
|
|
<header className="bps-header">
|
|
<div className="bps-header-left">
|
|
<h1 className="bps-title">{title}</h1>
|
|
{subtitle && <span className="bps-subtitle">{subtitle}</span>}
|
|
</div>
|
|
</header>
|
|
<div className="bps-loading">{loadingText}</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div
|
|
className={`bps-page se-page se-page--${theme}${pageClassName ? ` ${pageClassName}` : ''}`}
|
|
style={pageStyle}
|
|
>
|
|
<header
|
|
className={[
|
|
'bps-header',
|
|
headerCenter && headerCenterMode === 'between' ? 'bps-header--between' : '',
|
|
headerCenter && headerCenterMode === 'grid' ? 'bps-header--vtd' : '',
|
|
].filter(Boolean).join(' ')}
|
|
>
|
|
<div className="bps-header-left">
|
|
<h1 className="bps-title">{title}</h1>
|
|
{subtitle && <span className="bps-subtitle">{subtitle}</span>}
|
|
</div>
|
|
{headerCenter ? (
|
|
headerCenterMode === 'between' ? (
|
|
<>
|
|
<div className="bps-header-center">{headerCenter}</div>
|
|
{headerActions && <div className="bps-header-actions">{headerActions}</div>}
|
|
</>
|
|
) : (
|
|
<>
|
|
<div className="bps-header-center">{headerCenter}</div>
|
|
{headerActions && (
|
|
<>
|
|
<span className="header-toolbar-divider" aria-hidden="true" />
|
|
<div className="bps-header-actions">{headerActions}</div>
|
|
</>
|
|
)}
|
|
</>
|
|
)
|
|
) : (
|
|
headerActions && <div className="bps-header-actions">{headerActions}</div>
|
|
)}
|
|
</header>
|
|
|
|
{banner}
|
|
|
|
<div className="bps-body" style={bodyStyle}>
|
|
{collapsiblePanels ? (
|
|
<div className={`bps-side-wrap bps-side-wrap--left${leftOpen ? ' bps-side-wrap--open' : ''}`}>
|
|
<aside
|
|
className={`bps-left bps-left--collapsible${leftOpen ? ' bps-left--collapsible--open' : ''}`}
|
|
>
|
|
<div className="bps-panel">
|
|
{leftTabs ? (
|
|
<>
|
|
{leftTabs}
|
|
<div className="bps-panel-body bps-panel-body--tabs">{left}</div>
|
|
</>
|
|
) : (
|
|
<>
|
|
<div className="bps-panel-head">
|
|
<h2 className="bps-panel-title">{leftTitle}</h2>
|
|
{leftActions && <div className="bps-panel-actions">{leftActions}</div>}
|
|
</div>
|
|
<div className="bps-panel-body">{left}</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
</aside>
|
|
<PanelCollapseHandle side="left" open={leftOpen} onToggle={toggleLeft} label="좌측 패널" />
|
|
</div>
|
|
) : null}
|
|
{collapsiblePanels && leftOpen ? (
|
|
<div
|
|
className="bps-splitter bps-splitter--v se-splitter se-splitter--v"
|
|
role="separator"
|
|
aria-orientation="vertical"
|
|
aria-label="좌측 패널 너비 조절"
|
|
onPointerDown={onLeftSplitter}
|
|
/>
|
|
) : null}
|
|
{!collapsiblePanels ? (
|
|
<>
|
|
<aside className="bps-left" style={{ width: leftWidth }}>
|
|
<div className="bps-panel">
|
|
{leftTabs ? (
|
|
<>
|
|
{leftTabs}
|
|
<div className="bps-panel-body bps-panel-body--tabs">{left}</div>
|
|
</>
|
|
) : (
|
|
<>
|
|
<div className="bps-panel-head">
|
|
<h2 className="bps-panel-title">{leftTitle}</h2>
|
|
{leftActions && <div className="bps-panel-actions">{leftActions}</div>}
|
|
</div>
|
|
<div className="bps-panel-body">{left}</div>
|
|
</>
|
|
)}
|
|
</div>
|
|
</aside>
|
|
<div
|
|
className="bps-splitter bps-splitter--v se-splitter se-splitter--v"
|
|
role="separator"
|
|
aria-orientation="vertical"
|
|
aria-label="좌측 패널 너비 조절"
|
|
onPointerDown={onLeftSplitter}
|
|
/>
|
|
</>
|
|
) : null}
|
|
|
|
<div className="bps-main">
|
|
<div className="bps-main-row">
|
|
<main className="bps-center">
|
|
<div className="bps-center-work">
|
|
{centerHead && <div className="bps-center-head">{centerHead}</div>}
|
|
<div className={centerContentClass}>{center}</div>
|
|
</div>
|
|
|
|
{footer && (
|
|
<>
|
|
<div
|
|
className="bps-splitter bps-splitter--h se-splitter se-splitter--h"
|
|
role="separator"
|
|
aria-orientation="horizontal"
|
|
aria-label="하단 패널 높이 조절"
|
|
onPointerDown={onFooterSplitter}
|
|
/>
|
|
<footer className="bps-footer" style={{ height: footerHeight }}>
|
|
{footerLabel && <div className="bps-footer-label">{footerLabel}</div>}
|
|
<div className="bps-footer-body">{footer}</div>
|
|
</footer>
|
|
</>
|
|
)}
|
|
</main>
|
|
|
|
{right && collapsiblePanels && rightOpen ? (
|
|
<div
|
|
className="bps-splitter bps-splitter--v se-splitter se-splitter--v"
|
|
role="separator"
|
|
aria-orientation="vertical"
|
|
aria-label="우측 패널 너비 조절"
|
|
onPointerDown={handleRightSplitter}
|
|
/>
|
|
) : null}
|
|
{right && (collapsiblePanels ? (
|
|
<div className={`bps-side-wrap bps-side-wrap--right${rightOpen ? ' bps-side-wrap--open' : ''}`}>
|
|
<PanelCollapseHandle side="right" open={rightOpen} onToggle={toggleRight} label="우측 패널" />
|
|
<aside
|
|
className={`bps-right trade-right-panel bps-right--collapsible${rightOpen ? ' bps-right--collapsible--open' : ''}`}
|
|
>
|
|
{rightTabs ?? (rightTitle ? (
|
|
<div className="bps-panel-head" style={{ margin: 0, padding: '12px 12px 10px', borderBottom: '1px solid var(--se-border)' }}>
|
|
<h2 className="bps-panel-title">{rightTitle}</h2>
|
|
</div>
|
|
) : null)}
|
|
<div className="bps-right-body">{right}</div>
|
|
</aside>
|
|
</div>
|
|
) : (
|
|
<>
|
|
<div
|
|
className="bps-splitter bps-splitter--v se-splitter se-splitter--v"
|
|
role="separator"
|
|
aria-orientation="vertical"
|
|
aria-label="우측 패널 너비 조절"
|
|
onPointerDown={handleRightSplitter}
|
|
/>
|
|
<aside
|
|
className="bps-right trade-right-panel"
|
|
style={{ width: rightWidth, flex: `0 0 ${rightWidth}px` }}
|
|
>
|
|
{rightTabs ?? (rightTitle ? (
|
|
<div className="bps-panel-head" style={{ margin: 0, padding: '12px 12px 10px', borderBottom: '1px solid var(--se-border)' }}>
|
|
<h2 className="bps-panel-title">{rightTitle}</h2>
|
|
</div>
|
|
) : null)}
|
|
<div className="bps-right-body">{right}</div>
|
|
</aside>
|
|
</>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|