Files
goldenChart/frontend/src/components/layout/BuilderPageShell.tsx
T
Macbook 1752ec555a 우측 매수/매도 패널 내용 표시 안되는 버그 수정
paperDashboard.css가 PaperTradingPage에만 임포트되어 있어
VirtualTradingPage·알림목록·실시간 차트·백테스팅 등에서
ptd-split-card-body의 flex:1 레이아웃이 없어 콘텐츠 높이가 0이 됨.

BuilderPageShell·ChartWorkspaceView·TradeNotificationListPage에
paperDashboard.css 임포트 추가로 전 페이지에서 정상 표시되도록 수정.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-11 11:05:46 +09:00

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>
);
}