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 ( { e.stopPropagation(); onToggle(); }} title={open ? `${label} 닫기` : `${label} 열기`} aria-expanded={open} aria-label={open ? `${label} 닫기` : `${label} 열기`} > {side === 'left' ? ( open ? : ) : ( open ? : )} ); } 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) => { 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 ( {title} {subtitle && {subtitle}} {loadingText} ); } return ( {title} {subtitle && {subtitle}} {headerCenter ? ( headerCenterMode === 'between' ? ( <> {headerCenter} {headerActions && {headerActions}} > ) : ( <> {headerCenter} {headerActions && ( <> {headerActions} > )} > ) ) : ( headerActions && {headerActions} )} {banner} {collapsiblePanels ? ( ) : null} {collapsiblePanels && leftOpen ? ( ) : null} {!collapsiblePanels ? ( <> > ) : null} {centerHead && {centerHead}} {center} {footer && ( <> > )} {right && collapsiblePanels && rightOpen ? ( ) : null} {right && (collapsiblePanels ? ( ) : ( <> > ))} ); }