Unify popup UI to TradeAlertModal design system.

Add AppPopup shell, shared CSS, MUI theme overrides, and center positioning for consistent modal styling across frontend and frontend_golden.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Macbook
2026-05-23 16:56:37 +09:00
parent 67db5982d9
commit eaf067db1c
15 changed files with 1383 additions and 494 deletions
+355 -100
View File
@@ -6356,116 +6356,405 @@
.bt-drop-run.running { background: var(--bg4); color: var(--text2); } .bt-drop-run.running { background: var(--bg4); color: var(--text2); }
/* /*
실시간 전략 체크 패널 (LiveStrategyPanel) 실시간 전략 체크 패널 (LiveStrategyPanel) TradeAlertModal 스타일
*/ */
.lsp-wrap { .lsp-overlay {
position: fixed;
inset: 0;
z-index: 9998;
pointer-events: none;
}
.lsp-overlay > .lsp-modal {
pointer-events: auto;
}
.lsp-modal {
position: fixed;
width: 400px;
max-width: calc(100vw - 24px);
max-height: calc(100vh - 24px);
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 8px; background: var(--bg2, #24283b);
min-width: 240px; border: 1px solid var(--lsp-accent, var(--border, rgba(122,162,247,0.15)));
padding: 12px 14px; border-radius: 12px;
background: var(--bg2); box-shadow:
border: 1px solid var(--border); 0 0 0 1px var(--lsp-accent-glow, rgba(122,162,247,0.08)),
border-radius: 8px; 0 20px 60px rgba(0,0,0,0.65),
font-size: 12px; 0 0 40px var(--lsp-accent-glow, rgba(122,162,247,0.06));
color: var(--text); overflow: hidden;
font-size: 13px;
color: var(--text, #c0caf5);
font-family: var(--font, 'Noto Sans KR', sans-serif);
} }
.lsp-header { .lsp-header {
display: flex; flex-shrink: 0;
align-items: center;
gap: 6px;
padding-bottom: 8px;
border-bottom: 1px solid var(--border);
} }
.lsp-icon { opacity: 0.75; flex-shrink: 0; color: #00BCD4; } .lsp-header-left {
.lsp-title { font-weight: 700; font-size: 12.5px; flex: 1; }
.lsp-saving { font-size: 10px; color: var(--text3); }
.lsp-close-btn {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; gap: 9px;
width: 22px; flex: 1;
height: 22px; min-width: 0;
}
.lsp-badge {
display: inline-block;
padding: 3px 10px;
border-radius: 20px;
font-size: 11px;
font-weight: 800;
color: #fff;
letter-spacing: 1.2px;
flex-shrink: 0;
}
.lsp-header-title {
font-size: 14px;
font-weight: 700;
color: var(--text, #c0caf5);
letter-spacing: 0.4px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.lsp-header-ko {
font-size: 12px;
font-weight: 400;
color: var(--text2, #9aa5ce);
}
.lsp-saving {
font-size: 10px;
color: var(--text3, #6272a4);
flex-shrink: 0;
}
.lsp-close {
background: none; background: none;
border: none; border: none;
border-radius: 4px; color: var(--text3, #6272a4);
color: var(--text3); font-size: 16px;
cursor: pointer; cursor: pointer;
padding: 0; padding: 2px 6px;
line-height: 1;
border-radius: 4px;
transition: color 0.15s, background 0.15s;
flex-shrink: 0; flex-shrink: 0;
transition: background 0.15s, color 0.15s;
} }
.lsp-close-btn:hover { background: var(--bg3); color: var(--text); } .lsp-close:hover {
color: var(--text, #c0caf5);
background: var(--bg4, rgba(255,255,255,0.07));
}
.lsp-row { .lsp-summary-section {
padding: 12px 16px 10px;
background: linear-gradient(
135deg,
var(--lsp-accent-glow, rgba(122,162,247,0.08)) 0%,
transparent 60%
);
border-bottom: 1px solid var(--border, rgba(122,162,247,0.1));
flex-shrink: 0;
}
.lsp-summary-row {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 8px; gap: 8px;
margin-bottom: 4px;
}
.lsp-summary-label {
font-size: 11.5px;
color: var(--text2, #9aa5ce);
}
.lsp-summary-value {
font-size: 15px;
font-weight: 800;
color: var(--text, #c0caf5);
letter-spacing: -0.3px;
}
.lsp-summary-value--active {
color: var(--lsp-accent, #3f7ef5);
}
.lsp-summary-hint {
margin: 6px 0 0;
font-size: 10.5px;
color: var(--text3, #6272a4);
line-height: 1.45;
} }
.lsp-row--disabled { opacity: 0.45; pointer-events: none; }
.lsp-label { color: var(--text2); white-space: nowrap; } .lsp-info-card {
display: flex;
/* 토글 (stg-toggle 재활용 패턴) */ align-items: flex-start;
.lsp-toggle { position: relative; display: inline-flex; align-items: center; cursor: pointer; } gap: 12px;
.lsp-toggle input { opacity: 0; width: 0; height: 0; position: absolute; } margin: 12px 14px 0;
.lsp-toggle-slider { padding: 11px 14px;
width: 34px; height: 18px; background: var(--bg3, #1f2335);
background: var(--bg3); border: 1px solid var(--border, rgba(122,162,247,0.12));
border-radius: 9px; border-radius: 9px;
transition: background 0.2s; flex-shrink: 0;
}
.lsp-info-icon {
flex-shrink: 0;
display: flex;
align-items: center;
}
.lsp-info-body {
display: flex;
flex-direction: column;
gap: 6px;
flex: 1;
min-width: 0;
}
.lsp-info-row {
display: flex;
flex-direction: column;
gap: 4px;
}
.lsp-info-label {
font-size: 11px;
color: var(--text3, #6272a4);
}
.lsp-info-val {
font-size: 12.5px;
font-weight: 600;
color: var(--text, #c0caf5);
}
.lsp-body {
display: flex;
flex-direction: column;
gap: 10px;
padding: 12px 14px 14px;
overflow-y: auto;
flex: 1;
min-height: 0;
}
.lsp-field-row {
display: flex;
align-items: center;
gap: 8px;
}
.lsp-field-row--disabled,
.lsp-section--disabled {
opacity: 0.45;
pointer-events: none;
}
.lsp-field-label,
.lsp-section-label {
font-size: 11.5px;
color: var(--text2, #9aa5ce);
flex-shrink: 0;
min-width: 62px;
}
.lsp-section-label {
display: block;
margin-bottom: 6px;
}
.lsp-toggle {
position: relative;
display: inline-flex;
align-items: center;
cursor: pointer;
margin-left: auto;
}
.lsp-toggle input {
opacity: 0;
width: 0;
height: 0;
position: absolute;
}
.lsp-toggle-slider {
width: 38px;
height: 20px;
background: var(--bg3, #1f2335);
border: 1px solid var(--border, rgba(122,162,247,0.18));
border-radius: 10px;
transition: background 0.2s, border-color 0.2s;
position: relative; position: relative;
} }
.lsp-toggle-slider::after { .lsp-toggle-slider::after {
content: ''; position: absolute; content: '';
width: 14px; height: 14px; position: absolute;
width: 14px;
height: 14px;
border-radius: 50%; border-radius: 50%;
background: #fff; background: #fff;
top: 2px; left: 2px; top: 2px;
left: 2px;
transition: transform 0.2s; transition: transform 0.2s;
} }
.lsp-toggle input:checked + .lsp-toggle-slider { background: #00BCD4; } .lsp-toggle input:checked + .lsp-toggle-slider {
.lsp-toggle input:checked + .lsp-toggle-slider::after { transform: translateX(16px); } background: var(--lsp-accent, #3f7ef5);
border-color: var(--lsp-accent, #3f7ef5);
}
.lsp-toggle input:checked + .lsp-toggle-slider::after {
transform: translateX(18px);
}
.lsp-select { .lsp-select {
flex: 1; flex: 1;
padding: 3px 6px;
background: var(--bg3);
border: 1px solid var(--border);
border-radius: 4px;
color: var(--text);
font-size: 11.5px;
cursor: pointer;
min-width: 0; min-width: 0;
padding: 7px 10px;
background: var(--bg3, #1f2335);
border: 1px solid var(--border, rgba(122,162,247,0.18));
border-radius: 6px;
color: var(--text, #c0caf5);
font-size: 12.5px;
font-weight: 600;
cursor: pointer;
outline: none;
transition: border-color 0.15s;
}
.lsp-select:focus {
border-color: var(--lsp-accent, #3f7ef5);
}
.lsp-select:disabled {
cursor: not-allowed;
} }
.lsp-exec-wrap { .lsp-section {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 4px;
} }
.lsp-radio-group {
.lsp-option-group {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 4px; gap: 6px;
padding-left: 4px;
} }
.lsp-radio { .lsp-option {
display: flex; display: flex;
align-items: flex-start; align-items: flex-start;
gap: 6px; gap: 8px;
padding: 9px 11px;
border: 1px solid var(--border, rgba(122,162,247,0.12));
border-radius: 8px;
background: var(--bg3, #1f2335);
cursor: pointer; cursor: pointer;
padding: 4px 6px; transition: border-color 0.15s, background 0.15s;
border-radius: 5px;
transition: background 0.15s;
} }
.lsp-radio:hover { background: var(--bg3); } .lsp-option:hover {
.lsp-radio input { margin-top: 2px; accent-color: #00BCD4; flex-shrink: 0; } border-color: var(--lsp-accent, rgba(63,126,245,0.35));
.lsp-radio-label { display: flex; flex-direction: column; gap: 1px; } background: var(--bg4, #414868);
.lsp-radio-title { font-size: 11.5px; font-weight: 600; } }
.lsp-radio-desc { font-size: 10px; color: var(--text3); } .lsp-option--active {
border-color: var(--lsp-accent, #3f7ef5);
background: rgba(63, 126, 245, 0.1);
box-shadow: inset 0 0 0 1px rgba(63, 126, 245, 0.15);
}
.lsp-option input {
margin-top: 3px;
accent-color: var(--lsp-accent, #3f7ef5);
flex-shrink: 0;
}
.lsp-option-text {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.lsp-option-title {
font-size: 12px;
font-weight: 700;
color: var(--text, #c0caf5);
}
.lsp-option-desc {
font-size: 10.5px;
color: var(--text3, #6272a4);
line-height: 1.4;
}
.lsp-hint {
font-size: 10.5px;
color: var(--text3, #6272a4);
margin: 0;
line-height: 1.45;
}
.lsp-monitor-chips {
display: flex;
flex-wrap: wrap;
gap: 4px;
font-weight: 400;
}
.lsp-monitor-chip {
font-size: 10px;
padding: 2px 7px;
border-radius: 4px;
background: rgba(63, 126, 245, 0.12);
color: var(--lsp-accent, #3f7ef5);
border: 1px solid rgba(63, 126, 245, 0.25);
font-weight: 600;
}
.lsp-status-bar {
display: flex;
align-items: center;
gap: 8px;
padding: 9px 11px;
background: var(--bg3, #1f2335);
border: 1px solid var(--border, rgba(122,162,247,0.12));
border-radius: 8px;
}
.lsp-status-text {
font-size: 11px;
color: var(--text2, #9aa5ce);
line-height: 1.4;
min-width: 0;
}
.lsp-dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--text3, #6272a4);
flex-shrink: 0;
}
.lsp-dot--on {
background: var(--lsp-accent, #3f7ef5);
box-shadow: 0 0 6px var(--lsp-accent-glow, rgba(63,126,245,0.5));
animation: lsp-pulse 1.5s ease-in-out infinite;
}
@keyframes lsp-pulse {
0%,100% { opacity: 1; }
50% { opacity: 0.4; }
}
.lsp-disclaimer {
font-size: 10.5px;
color: var(--text3, #6272a4);
text-align: center;
margin: 2px 0 0;
line-height: 1.5;
}
/* 라이트 테마 */
.app.light .lsp-modal {
background: #ffffff;
border-color: rgba(25,118,210,0.35);
color: #212121;
box-shadow: 0 20px 60px rgba(0,0,0,0.18), 0 0 40px rgba(25,118,210,0.08);
}
.app.light .lsp-header-title { color: #1a237e; }
.app.light .lsp-header-ko { color: #546e7a; }
.app.light .lsp-close { color: #78909c; }
.app.light .lsp-close:hover { background: rgba(0,0,0,0.06); color: #37474f; }
.app.light .lsp-summary-section { border-color: rgba(0,0,0,0.08); }
.app.light .lsp-summary-label { color: #546e7a; }
.app.light .lsp-summary-value { color: #212121; }
.app.light .lsp-info-card { background: #f5f7fa; border-color: rgba(0,0,0,0.1); }
.app.light .lsp-info-label { color: #78909c; }
.app.light .lsp-info-val { color: #212121; }
.app.light .lsp-field-label,
.app.light .lsp-section-label { color: #546e7a; }
.app.light .lsp-select { background: #f0f3f8; border-color: rgba(0,0,0,0.12); color: #212121; }
.app.light .lsp-option { background: #f5f7fa; border-color: rgba(0,0,0,0.1); }
.app.light .lsp-option:hover { background: #eef2f7; }
.app.light .lsp-option--active { background: rgba(25,118,210,0.08); border-color: rgba(25,118,210,0.45); }
.app.light .lsp-option-title { color: #212121; }
.app.light .lsp-status-bar { background: #f5f7fa; border-color: rgba(0,0,0,0.1); }
.app.light .lsp-status-text { color: #546e7a; }
.app.light .lsp-monitor-chip { background: rgba(25,118,210,0.1); color: #1976d2; border-color: rgba(25,118,210,0.25); }
.lsp-monitor-stats { .lsp-monitor-stats {
display: flex; display: flex;
@@ -6476,7 +6765,7 @@
margin-bottom: 6px; margin-bottom: 6px;
} }
.lsp-monitor-sep { opacity: 0.5; } .lsp-monitor-sep { opacity: 0.5; }
.lsp-monitor-on { color: #00BCD4; font-weight: 600; } .lsp-monitor-on { color: #3f7ef5; font-weight: 600; }
.lsp-monitor-warn { .lsp-monitor-warn {
font-size: 10.5px; font-size: 10.5px;
color: #ff9800; color: #ff9800;
@@ -6509,14 +6798,6 @@
gap: 4px; gap: 4px;
margin-bottom: 8px; margin-bottom: 8px;
} }
.lsp-monitor-chip {
font-size: 9.5px;
padding: 2px 6px;
border-radius: 3px;
background: rgba(0, 188, 212, 0.12);
color: #00BCD4;
border: 1px solid rgba(0, 188, 212, 0.25);
}
.stg-btn-secondary { .stg-btn-secondary {
background: var(--bg3); background: var(--bg3);
color: var(--text); color: var(--text);
@@ -6545,32 +6826,6 @@
padding: 6px 12px; padding: 6px 12px;
} }
.lsp-status {
display: flex;
align-items: center;
gap: 6px;
font-size: 10.5px;
color: var(--text3);
padding: 4px 6px;
background: var(--bg3);
border-radius: 4px;
}
.lsp-dot {
width: 6px; height: 6px;
border-radius: 50%;
background: var(--text3);
flex-shrink: 0;
}
.lsp-dot--on {
background: #00BCD4;
box-shadow: 0 0 5px rgba(0,188,212,0.6);
animation: lsp-pulse 1.5s ease-in-out infinite;
}
@keyframes lsp-pulse {
0%,100% { opacity: 1; }
50% { opacity: 0.4; }
}
/* /*
백테스팅 결과 통계 배지 (차트 오버레이) 백테스팅 결과 통계 배지 (차트 오버레이)
*/ */
+12 -13
View File
@@ -93,6 +93,7 @@ import type { LoginResponse } from './utils/backendApi';
import { invalidateAppSettingsCache } from './hooks/useAppSettings'; import { invalidateAppSettingsCache } from './hooks/useAppSettings';
import { invalidateIndicatorSettingsCache } from './hooks/useIndicatorSettings'; import { invalidateIndicatorSettingsCache } from './hooks/useIndicatorSettings';
import './App.css'; import './App.css';
import './styles/appPopup.css';
let _indCounter = 0; let _indCounter = 0;
function newIndId() { return `ind_${++_indCounter}_${Date.now()}`; } function newIndId() { return `ind_${++_indCounter}_${Date.now()}`; }
@@ -1866,19 +1867,17 @@ function App() {
{/* 실시간 전략 체크 패널 */} {/* 실시간 전략 체크 패널 */}
{showLivePanel && menuPage === 'chart' && layoutDef.count === 1 && ( {showLivePanel && menuPage === 'chart' && layoutDef.count === 1 && (
<div style={{ position: 'absolute', top: 48, right: 14, zIndex: 60 }}> <LiveStrategyPanel
<LiveStrategyPanel theme={theme}
theme={theme} market={symbol}
market={symbol} strategies={liveStrategies}
strategies={liveStrategies} settings={liveStrategySettings}
settings={liveStrategySettings} watchlistCount={watchlist.length}
watchlistCount={watchlist.length} monitoredMarkets={monitoredMarkets}
monitoredMarkets={monitoredMarkets} onClearMarkers={() => { clearLiveMarkers(); managerRef.current?.clearLiveStrategyMarkers(); }}
onClearMarkers={() => { clearLiveMarkers(); managerRef.current?.clearLiveStrategyMarkers(); }} onSettingsChange={handleLiveSettingsChange}
onSettingsChange={handleLiveSettingsChange} onClose={() => setShowLivePanel(false)}
onClose={() => setShowLivePanel(false)} />
/>
</div>
)} )}
{/* 백테스팅 설정 모달 */} {/* 백테스팅 설정 모달 */}
+145
View File
@@ -0,0 +1,145 @@
/**
* AppPopup — TradeAlertModal(tam-) 기준 공통 팝업 셸
* 신규 팝업은 이 컴포넌트를 사용하세요.
*/
import React from 'react';
import { createPortal } from 'react-dom';
import { useDraggablePanel } from '../hooks/useDraggablePanel';
export const APP_POPUP_ACCENT = '#3f7ef5';
export const APP_POPUP_ACCENT_GLOW = 'rgba(63,126,245,0.22)';
export interface AppPopupProps {
onClose: () => void;
title: React.ReactNode;
titleKo?: React.ReactNode;
badge?: string;
accentColor?: string;
children: React.ReactNode;
width?: number | string;
maxWidth?: number | string;
className?: string;
shellClassName?: string;
overlayClassName?: string;
bodyClassName?: string;
centered?: boolean;
zIndex?: number;
closeOnBackdrop?: boolean;
backdrop?: boolean;
portal?: boolean;
headerExtra?: React.ReactNode;
footer?: React.ReactNode;
initialPosition?: { x: number; y: number };
}
export const AppPopup: React.FC<AppPopupProps> = ({
onClose,
title,
titleKo,
badge,
accentColor = APP_POPUP_ACCENT,
children,
width = 400,
maxWidth = '96vw',
className,
shellClassName,
overlayClassName,
bodyClassName = 'app-popup-body',
centered = true,
zIndex = 9999,
closeOnBackdrop = true,
backdrop = false,
portal = true,
headerExtra,
footer,
initialPosition,
}) => {
const accentGlow = accentColor === APP_POPUP_ACCENT
? APP_POPUP_ACCENT_GLOW
: `${accentColor}38`;
const {
panelRef,
dragging,
onHeaderPointerDown,
headerTouchStyle,
panelStyle,
headerCursor,
} = useDraggablePanel({ centerOnMount: centered, initialPosition });
const overlayCls = [
'app-popup-overlay',
backdrop ? 'app-popup-overlay--dim' : '',
overlayClassName ?? '',
className ?? '',
].filter(Boolean).join(' ');
const shellCls = ['app-popup-shell', shellClassName ?? ''].filter(Boolean).join(' ');
const content = (
<div
className={overlayCls}
style={{ zIndex }}
onMouseDown={e => {
if (closeOnBackdrop && backdrop && e.target === e.currentTarget) onClose();
}}
>
<div
ref={panelRef}
className={shellCls}
style={{
...panelStyle,
width,
maxWidth,
cursor: dragging ? 'grabbing' : undefined,
'--app-popup-accent': accentColor,
'--app-popup-accent-glow': accentGlow,
'--tam-accent': accentColor,
'--tam-accent-glow': accentGlow,
'--lsp-accent': accentColor,
'--lsp-accent-glow': accentGlow,
} as React.CSSProperties}
onMouseDown={e => e.stopPropagation()}
>
<div
className="gc-popup-header app-popup-header"
onPointerDown={onHeaderPointerDown}
style={{ cursor: headerCursor, ...headerTouchStyle }}
>
<div className="app-popup-header-left">
{badge ? (
<span className="app-popup-badge" style={{ background: accentColor }}>
{badge}
</span>
) : null}
<span className="app-popup-header-title">
{title}
{titleKo != null && titleKo !== '' ? (
<span className="app-popup-header-ko"> ({titleKo})</span>
) : null}
</span>
</div>
{headerExtra}
<button
type="button"
className="app-popup-close gc-popup-close"
onMouseDown={e => e.stopPropagation()}
onClick={onClose}
aria-label="닫기"
>
</button>
</div>
<div className={bodyClassName}>{children}</div>
{footer}
</div>
</div>
);
if (portal && typeof document !== 'undefined' && document.body) {
return createPortal(content, document.body);
}
return content;
};
export default AppPopup;
+25 -51
View File
@@ -1,12 +1,14 @@
/** /**
* 드래그 가능한 모달 프레임 — 그라데이션 타이틀바(gc-popup-header) + 화면 중앙 초기 배치 * 드래그 가능한 모달 프레임 — AppPopup 기반
*/ */
import React from 'react'; import React from 'react';
import { useDraggablePanel } from '../hooks/useDraggablePanel'; import AppPopup from './AppPopup';
export interface DraggableModalFrameProps { export interface DraggableModalFrameProps {
onClose: () => void; onClose: () => void;
title: React.ReactNode; title: React.ReactNode;
titleKo?: React.ReactNode;
badge?: string;
children: React.ReactNode; children: React.ReactNode;
overlayClassName?: string; overlayClassName?: string;
dialogClassName?: string; dialogClassName?: string;
@@ -19,60 +21,32 @@ export interface DraggableModalFrameProps {
export const DraggableModalFrame: React.FC<DraggableModalFrameProps> = ({ export const DraggableModalFrame: React.FC<DraggableModalFrameProps> = ({
onClose, onClose,
title, title,
titleKo,
badge,
children, children,
overlayClassName = 'sp-modal-overlay', overlayClassName = 'sp-modal-overlay',
dialogClassName = 'sp-modal', dialogClassName = 'sp-modal',
bodyClassName = 'sp-modal-body', bodyClassName = 'sp-modal-body app-popup-body',
zIndex = 1000, zIndex = 1000,
width, width,
closeOnBackdrop = true, closeOnBackdrop = true,
}) => { }) => (
const { <AppPopup
panelRef, onClose={onClose}
dragging, title={title}
onHeaderPointerDown, headerTouchStyle, titleKo={titleKo}
panelStyle, badge={badge}
headerCursor, overlayClassName={overlayClassName}
} = useDraggablePanel({ centerOnMount: true }); shellClassName={dialogClassName}
bodyClassName={bodyClassName}
return ( zIndex={zIndex}
<div width={width ?? 420}
className={overlayClassName} backdrop
style={{ zIndex }} closeOnBackdrop={closeOnBackdrop}
onMouseDown={e => { centered
if (closeOnBackdrop && e.target === e.currentTarget) onClose(); >
}} {children}
> </AppPopup>
<div );
ref={panelRef}
className={dialogClassName}
style={{
...panelStyle,
...(width != null ? { width } : {}),
cursor: dragging ? 'grabbing' : undefined,
}}
onMouseDown={e => e.stopPropagation()}
>
<div
className="gc-popup-header sp-modal-header"
onPointerDown={onHeaderPointerDown}
style={{ cursor: headerCursor, ...headerTouchStyle }}
>
<span className="gc-popup-title sp-modal-title">{title}</span>
<button
type="button"
className="gc-popup-close"
onMouseDown={e => e.stopPropagation()}
onClick={onClose}
aria-label="닫기"
>
</button>
</div>
<div className={bodyClassName}>{children}</div>
</div>
</div>
);
};
export default DraggableModalFrame; export default DraggableModalFrame;
+221 -141
View File
@@ -5,7 +5,9 @@
* 관심종목(★) 등록 종목 전체가 체크 대상이며, 여기서 전략·실행 방식을 지정합니다. * 관심종목(★) 등록 종목 전체가 체크 대상이며, 여기서 전략·실행 방식을 지정합니다.
*/ */
import React, { useState, useCallback } from 'react'; import React, { useState, useCallback } from 'react';
import { createPortal } from 'react-dom';
import type { Theme } from '../types'; import type { Theme } from '../types';
import { useDraggablePanel } from '../hooks/useDraggablePanel';
import { import {
saveLiveStrategySettings, saveLiveStrategySettings,
type LiveStrategySettingsDto, type LiveStrategySettingsDto,
@@ -28,6 +30,20 @@ interface LiveStrategyPanelProps {
onSettingsChange?: (settings: LiveStrategySettingsDto) => void; onSettingsChange?: (settings: LiveStrategySettingsDto) => void;
} }
const ACCENT = '#3f7ef5';
const ACCENT_GLOW = 'rgba(63,126,245,0.22)';
const CandleIcon: React.FC<{ color: string }> = ({ color }) => (
<svg width="28" height="28" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="6" y="9" width="5" height="12" rx="1" fill={color} opacity="0.9"/>
<line x1="8.5" y1="5" x2="8.5" y2="9" stroke={color} strokeWidth="1.5"/>
<line x1="8.5" y1="21" x2="8.5" y2="25" stroke={color} strokeWidth="1.5"/>
<rect x="17" y="7" width="5" height="9" rx="1" fill="var(--down,#4dabf7)" opacity="0.8"/>
<line x1="19.5" y1="4" x2="19.5" y2="7" stroke="var(--down,#4dabf7)" strokeWidth="1.5"/>
<line x1="19.5" y1="16" x2="19.5" y2="20" stroke="var(--down,#4dabf7)" strokeWidth="1.5"/>
</svg>
);
const LiveStrategyPanel: React.FC<LiveStrategyPanelProps> = ({ const LiveStrategyPanel: React.FC<LiveStrategyPanelProps> = ({
theme, market, strategies, settings, theme, market, strategies, settings,
watchlistCount = 0, monitoredMarkets = [], watchlistCount = 0, monitoredMarkets = [],
@@ -35,6 +51,18 @@ const LiveStrategyPanel: React.FC<LiveStrategyPanelProps> = ({
}) => { }) => {
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const {
panelRef,
dragging,
onHeaderPointerDown,
headerTouchStyle,
panelStyle,
headerCursor,
} = useDraggablePanel({
centerOnMount: true,
initialPosition: { x: 40, y: 40 },
});
const persist = useCallback(async (patch: Partial<LiveStrategySettingsDto>) => { const persist = useCallback(async (patch: Partial<LiveStrategySettingsDto>) => {
const next: LiveStrategySettingsDto = { ...settings, ...patch, market }; const next: LiveStrategySettingsDto = { ...settings, ...patch, market };
const prev = settings; const prev = settings;
@@ -61,157 +89,209 @@ const LiveStrategyPanel: React.FC<LiveStrategyPanelProps> = ({
const execType = settings.executionType; const execType = settings.executionType;
const stratId = settings.strategyId; const stratId = settings.strategyId;
const monCount = monitoredMarkets.length; const monCount = monitoredMarkets.length;
const selectedStrategy = strategies.find(s => s.id === stratId);
return ( const execLabel = execType === 'REALTIME_TICK' ? '실시간 틱 (3초)' : '봉 마감 직후';
<div className={`lsp-wrap lsp-wrap--${theme}`}> const posModeLabel = (settings.positionMode ?? 'LONG_ONLY') === 'LONG_ONLY'
<div className="lsp-header"> ? '보유 자산 기준'
<svg className="lsp-icon" width="13" height="13" viewBox="0 0 14 14" : '순수 지표 기준';
fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
<circle cx="7" cy="7" r="5.5"/>
<polyline points="5,7 7,9 10,5"/>
</svg>
<span className="lsp-title"> </span>
{saving && <span className="lsp-saving"> </span>}
{onClose && (
<button className="lsp-close-btn" onClick={onClose} title="닫기">
<svg width="11" height="11" viewBox="0 0 11 11" fill="none"
stroke="currentColor" strokeWidth="1.8" strokeLinecap="round">
<line x1="1" y1="1" x2="10" y2="10"/>
<line x1="10" y1="1" x2="1" y2="10"/>
</svg>
</button>
)}
</div>
<div className="lsp-monitor-stats"> const panel = (
<span> {watchlistCount}</span> <div className={`lsp-overlay lsp-overlay--${theme}`}>
<span className="lsp-monitor-sep">·</span> <div
<span className={monCount > 0 ? 'lsp-monitor-on' : ''}> ref={panelRef}
{monCount} className="lsp-modal app-popup-shell"
</span> style={{
</div> ...panelStyle,
{isOn && stratId && watchlistCount > 0 && ( zIndex: 10000,
<p className="lsp-monitor-hint"> cursor: dragging ? 'grabbing' : undefined,
. '--lsp-accent': ACCENT,
</p> '--lsp-accent-glow': ACCENT_GLOW,
)} } as React.CSSProperties}
{monCount > 0 && ( onMouseDown={e => e.stopPropagation()}
<div className="lsp-monitor-list" title={monitoredMarkets.join(', ')}> >
{monitoredMarkets.slice(0, 8).map(m => ( <div
<span key={m} className="lsp-monitor-chip">{m.replace('KRW-', '')}</span> className="gc-popup-header lsp-header"
))} onPointerDown={onHeaderPointerDown}
{monCount > 8 && <span className="lsp-monitor-chip">+{monCount - 8}</span>} style={{ cursor: headerCursor, ...headerTouchStyle }}
</div>
)}
{isOn && !stratId && (
<p className="lsp-monitor-hint"> {watchlistCount} .</p>
)}
<div className="lsp-row">
<span className="lsp-label"> </span>
<label className="lsp-toggle">
<input
type="checkbox"
checked={isOn}
onChange={e => persist({ isLiveCheck: e.target.checked })}
/>
<span className="lsp-toggle-slider" />
</label>
</div>
<div className={`lsp-row${!isOn ? ' lsp-row--disabled' : ''}`}>
<span className="lsp-label"> </span>
<select
className="lsp-select"
disabled={!isOn}
value={stratId ?? ''}
onChange={e => persist({ strategyId: e.target.value ? Number(e.target.value) : null })}
> >
<option value=""> </option> <div className="lsp-header-left">
{strategies.map(s => ( <span className="lsp-badge" style={{ background: ACCENT }}>LIVE</span>
<option key={s.id} value={s.id}>{s.name}</option> <span className="lsp-header-title">
))} STRATEGY CHECK
</select> <span className="lsp-header-ko"> ( )</span>
</div> </span>
</div>
{saving && <span className="lsp-saving"> </span>}
{onClose && (
<button type="button" className="lsp-close" onClick={onClose} title="닫기"></button>
)}
</div>
<div className={`lsp-exec-wrap${!isOn ? ' lsp-row--disabled' : ''}`}> <div className="lsp-summary-section">
<span className="lsp-label"> </span> <div className="lsp-summary-row">
<div className="lsp-radio-group"> <span className="lsp-summary-label"> </span>
<label className="lsp-radio"> <span className="lsp-summary-value">{watchlistCount}</span>
<input </div>
type="radio" <div className="lsp-summary-row">
name="execType" <span className="lsp-summary-label"> </span>
value="CANDLE_CLOSE" <span className={`lsp-summary-value${monCount > 0 ? ' lsp-summary-value--active' : ''}`}>
checked={execType === 'CANDLE_CLOSE'} {monCount}
disabled={!isOn}
onChange={() => persist({ executionType: 'CANDLE_CLOSE' })}
/>
<span className="lsp-radio-label">
<span className="lsp-radio-title"> </span>
<span className="lsp-radio-desc"> 1 </span>
</span> </span>
</label> </div>
<label className="lsp-radio"> {isOn && stratId && watchlistCount > 0 && (
<input <p className="lsp-summary-hint">
type="radio" .
name="execType" </p>
value="REALTIME_TICK" )}
checked={execType === 'REALTIME_TICK'} </div>
{monCount > 0 && (
<div className="lsp-info-card">
<div className="lsp-info-icon">
<CandleIcon color={ACCENT} />
</div>
<div className="lsp-info-body">
<div className="lsp-info-row">
<span className="lsp-info-label"></span>
<span className="lsp-info-val lsp-monitor-chips">
{monitoredMarkets.slice(0, 8).map(m => (
<span key={m} className="lsp-monitor-chip">{m.replace('KRW-', '')}</span>
))}
{monCount > 8 && <span className="lsp-monitor-chip">+{monCount - 8}</span>}
</span>
</div>
{isOn && selectedStrategy && (
<div className="lsp-info-row">
<span className="lsp-info-label"> </span>
<span className="lsp-info-val">{selectedStrategy.name}</span>
</div>
)}
</div>
</div>
)}
<div className="lsp-body">
<div className="lsp-field-row">
<span className="lsp-field-label"> </span>
<label className="lsp-toggle">
<input
type="checkbox"
checked={isOn}
onChange={e => persist({ isLiveCheck: e.target.checked })}
/>
<span className="lsp-toggle-slider" />
</label>
</div>
<div className={`lsp-field-row${!isOn ? ' lsp-field-row--disabled' : ''}`}>
<span className="lsp-field-label"> </span>
<select
className="lsp-select"
disabled={!isOn} disabled={!isOn}
onChange={() => persist({ executionType: 'REALTIME_TICK' })} value={stratId ?? ''}
/> onChange={e => persist({ strategyId: e.target.value ? Number(e.target.value) : null })}
<span className="lsp-radio-label"> >
<span className="lsp-radio-title"> (3)</span> <option value=""> </option>
<span className="lsp-radio-desc"> , 3 </span> {strategies.map(s => (
</span> <option key={s.id} value={s.id}>{s.name}</option>
</label> ))}
</select>
</div>
<div className={`lsp-section${!isOn ? ' lsp-section--disabled' : ''}`}>
<span className="lsp-section-label"> </span>
<div className="lsp-option-group">
<label className={`lsp-option${execType === 'CANDLE_CLOSE' ? ' lsp-option--active' : ''}`}>
<input
type="radio"
name="execType"
value="CANDLE_CLOSE"
checked={execType === 'CANDLE_CLOSE'}
disabled={!isOn}
onChange={() => persist({ executionType: 'CANDLE_CLOSE' })}
/>
<span className="lsp-option-text">
<span className="lsp-option-title"> </span>
<span className="lsp-option-desc"> 1 </span>
</span>
</label>
<label className={`lsp-option${execType === 'REALTIME_TICK' ? ' lsp-option--active' : ''}`}>
<input
type="radio"
name="execType"
value="REALTIME_TICK"
checked={execType === 'REALTIME_TICK'}
disabled={!isOn}
onChange={() => persist({ executionType: 'REALTIME_TICK' })}
/>
<span className="lsp-option-text">
<span className="lsp-option-title"> (3)</span>
<span className="lsp-option-desc"> , 3 </span>
</span>
</label>
</div>
</div>
<div className={`lsp-section${!isOn ? ' lsp-section--disabled' : ''}`}>
<span className="lsp-section-label"> </span>
<div className="lsp-option-group">
<label className={`lsp-option${(settings.positionMode ?? 'LONG_ONLY') === 'LONG_ONLY' ? ' lsp-option--active' : ''}`}>
<input
type="radio"
name="lsp-posMode"
value="LONG_ONLY"
checked={(settings.positionMode ?? 'LONG_ONLY') === 'LONG_ONLY'}
disabled={!isOn}
onChange={() => persist({ positionMode: 'LONG_ONLY' })}
/>
<span className="lsp-option-text">
<span className="lsp-option-title"> </span>
<span className="lsp-option-desc"> </span>
</span>
</label>
<label className={`lsp-option${settings.positionMode === 'SIGNAL_ONLY' ? ' lsp-option--active' : ''}`}>
<input
type="radio"
name="lsp-posMode"
value="SIGNAL_ONLY"
checked={settings.positionMode === 'SIGNAL_ONLY'}
disabled={!isOn}
onChange={() => persist({ positionMode: 'SIGNAL_ONLY' })}
/>
<span className="lsp-option-text">
<span className="lsp-option-title"> </span>
<span className="lsp-option-desc"> , </span>
</span>
</label>
</div>
</div>
{isOn && !stratId && (
<p className="lsp-hint"> {watchlistCount} .</p>
)}
{isOn && (
<div className="lsp-status-bar">
<span className={`lsp-dot${isOn ? ' lsp-dot--on' : ''}`} />
<span className="lsp-status-text">
{stratId
? `${selectedStrategy?.name ?? '전략'} · ${execLabel} · ${posModeLabel} · ${monCount}종목`
: '전략을 선택하세요'}
</span>
</div>
)}
<p className="lsp-disclaimer">
ON () .
</p>
</div> </div>
</div> </div>
<div className={`lsp-exec-wrap${!isOn ? ' lsp-row--disabled' : ''}`}>
<span className="lsp-label"> </span>
<div className="lsp-radio-group">
<label className="lsp-radio">
<input
type="radio"
name="lsp-posMode"
value="LONG_ONLY"
checked={(settings.positionMode ?? 'LONG_ONLY') === 'LONG_ONLY'}
disabled={!isOn}
onChange={() => persist({ positionMode: 'LONG_ONLY' })}
/>
<span className="lsp-radio-label">
<span className="lsp-radio-title"> </span>
<span className="lsp-radio-desc"> </span>
</span>
</label>
<label className="lsp-radio">
<input
type="radio"
name="lsp-posMode"
value="SIGNAL_ONLY"
checked={settings.positionMode === 'SIGNAL_ONLY'}
disabled={!isOn}
onChange={() => persist({ positionMode: 'SIGNAL_ONLY' })}
/>
<span className="lsp-radio-label">
<span className="lsp-radio-title"> </span>
<span className="lsp-radio-desc"> , </span>
</span>
</label>
</div>
</div>
{isOn && (
<div className="lsp-status">
<span className={`lsp-dot${isOn ? ' lsp-dot--on' : ''}`} />
{stratId
? `${strategies.find(s => s.id === stratId)?.name ?? '전략'} · 관심 ${monCount}종목`
: '전략을 선택하세요'}
</div>
)}
</div> </div>
); );
if (typeof document === 'undefined') return null;
return createPortal(panel, document.body);
}; };
export default LiveStrategyPanel; export default LiveStrategyPanel;
+49 -49
View File
@@ -1,8 +1,9 @@
/** /**
* 로그인 모달 — 기본값 admin / admin * 로그인 모달 — AppPopup(tam-) 스타일
*/ */
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { loginUser, type LoginResponse } from '../utils/backendApi'; import { loginUser, type LoginResponse } from '../utils/backendApi';
import AppPopup from './AppPopup';
const DEFAULT_USERNAME = 'admin'; const DEFAULT_USERNAME = 'admin';
const DEFAULT_PASSWORD = 'admin'; const DEFAULT_PASSWORD = 'admin';
@@ -16,7 +17,7 @@ interface Props {
const LoginModal: React.FC<Props> = ({ open, onClose, onSuccess }) => { const LoginModal: React.FC<Props> = ({ open, onClose, onSuccess }) => {
const [username, setUsername] = useState(DEFAULT_USERNAME); const [username, setUsername] = useState(DEFAULT_USERNAME);
const [password, setPassword] = useState(DEFAULT_PASSWORD); const [password, setPassword] = useState(DEFAULT_PASSWORD);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
useEffect(() => { useEffect(() => {
@@ -49,54 +50,53 @@ const LoginModal: React.FC<Props> = ({ open, onClose, onSuccess }) => {
}; };
return ( return (
<div className="login-modal-backdrop" onClick={onClose}> <AppPopup
<div onClose={onClose}
className="login-modal" title="LOGIN"
role="dialog" titleKo="로그인"
aria-labelledby="login-modal-title" badge="AUTH"
onClick={e => e.stopPropagation()} width={380}
> backdrop
<div className="login-modal-header"> shellClassName="login-modal"
<h2 id="login-modal-title"></h2> bodyClassName="app-popup-body login-modal-body"
<button type="button" className="login-modal-close" onClick={onClose} aria-label="닫기">×</button> >
<p className="login-modal-hint app-popup-disclaimer" style={{ textAlign: 'left', marginBottom: 8 }}>
. () .
</p>
<form className="login-modal-form" onSubmit={submit}>
<label className="login-modal-field app-popup-field-row">
<span className="app-popup-field-label"></span>
<input
className="app-popup-input"
type="text"
autoComplete="username"
value={username}
onChange={e => setUsername(e.target.value)}
disabled={loading}
/>
</label>
<label className="login-modal-field app-popup-field-row" style={{ marginTop: 8 }}>
<span className="app-popup-field-label"></span>
<input
className="app-popup-input login-modal-field--visible"
type="text"
autoComplete="off"
value={password}
onChange={e => setPassword(e.target.value)}
disabled={loading}
/>
</label>
{error && <p className="login-modal-error">{error}</p>}
<div className="login-modal-actions" style={{ marginTop: 12, display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
<button type="button" className="app-popup-btn" onClick={onClose} disabled={loading}>
</button>
<button type="submit" className="app-popup-btn app-popup-btn--primary" style={{ width: 'auto', padding: '8px 20px' }} disabled={loading}>
{loading ? '로그인 중…' : '로그인'}
</button>
</div> </div>
<p className="login-modal-hint"> </form>
. () . </AppPopup>
</p>
<form className="login-modal-form" onSubmit={submit}>
<label className="login-modal-field">
<span></span>
<input
type="text"
autoComplete="username"
value={username}
onChange={e => setUsername(e.target.value)}
disabled={loading}
/>
</label>
<label className="login-modal-field">
<span></span>
<input
type="text"
autoComplete="off"
className="login-modal-field--visible"
value={password}
onChange={e => setPassword(e.target.value)}
disabled={loading}
/>
</label>
{error && <p className="login-modal-error">{error}</p>}
<div className="login-modal-actions">
<button type="button" className="login-modal-btn login-modal-btn--ghost" onClick={onClose} disabled={loading}>
</button>
<button type="submit" className="login-modal-btn login-modal-btn--primary" disabled={loading}>
{loading ? '로그인 중…' : '로그인'}
</button>
</div>
</form>
</div>
</div>
); );
}; };
+7 -73
View File
@@ -1,81 +1,15 @@
/** /**
* 공통 팝업 셸 — 그라데이션 타이틀바 + 드래그 이동 * 공통 팝업 셸 — AppPopup 래퍼 (하위 호환)
*/ */
import React from 'react'; import React from 'react';
import { useDraggablePanel } from '../hooks/useDraggablePanel'; import AppPopup, { type AppPopupProps } from './AppPopup';
export interface PopupShellProps { export type PopupShellProps = Omit<AppPopupProps, 'titleKo' | 'badge'> & {
onClose: () => void;
title: React.ReactNode; title: React.ReactNode;
children: React.ReactNode;
/** 패널 너비 (px 또는 CSS 값) */
width?: number | string;
className?: string;
dialogClassName?: string;
centered?: boolean;
zIndex?: number;
closeOnBackdrop?: boolean;
initialPosition?: { x: number; y: number };
}
export const PopupShell: React.FC<PopupShellProps> = ({
onClose,
title,
children,
width,
className,
dialogClassName,
centered = true,
zIndex = 5000,
closeOnBackdrop = true,
initialPosition,
}) => {
const {
panelRef,
dragging,
onHeaderPointerDown, headerTouchStyle,
panelStyle,
headerCursor,
} = useDraggablePanel({ centerOnMount: centered, initialPosition });
return (
<div
className={`gc-popup-overlay${className ? ` ${className}` : ''}`}
style={{ zIndex }}
onMouseDown={e => {
if (closeOnBackdrop && e.target === e.currentTarget) onClose();
}}
>
<div
ref={panelRef}
className={`gc-popup-panel${dialogClassName ? ` ${dialogClassName}` : ''}`}
style={{
...panelStyle,
width,
cursor: dragging ? 'grabbing' : undefined,
}}
onMouseDown={e => e.stopPropagation()}
>
<div
className="gc-popup-header"
onPointerDown={onHeaderPointerDown}
style={{ cursor: headerCursor, ...headerTouchStyle }}
>
<span className="gc-popup-title">{title}</span>
<button
type="button"
className="gc-popup-close"
onMouseDown={e => e.stopPropagation()}
onClick={onClose}
aria-label="닫기"
>
</button>
</div>
{children}
</div>
</div>
);
}; };
export const PopupShell: React.FC<PopupShellProps> = (props) => (
<AppPopup backdrop={props.backdrop ?? true} {...props} />
);
export default PopupShell; export default PopupShell;
+1 -1
View File
@@ -159,7 +159,7 @@ export const TradeAlertModal: React.FC<Props> = ({
<div className="tam-overlay"> <div className="tam-overlay">
<div <div
ref={panelRef} ref={panelRef}
className="tam-modal" className="tam-modal app-popup-shell"
style={{ style={{
...panelStyle, ...panelStyle,
zIndex: 10000, zIndex: 10000,
+314
View File
@@ -0,0 +1,314 @@
/* ═══════════════════════════════════════════════════════════════════════════
App Popup Design System (TradeAlertModal / tam- 기준)
신규 팝업: AppPopup 컴포넌트 + app-popup-* 클래스 사용
═══════════════════════════════════════════════════════════════════════════ */
.app-popup-overlay {
position: fixed;
inset: 0;
z-index: 9999;
pointer-events: none;
}
.app-popup-overlay--dim {
pointer-events: auto;
background: rgba(0, 0, 0, 0.45);
}
.app-popup-overlay--dim > .app-popup-shell,
.app-popup-overlay--dim > .tam-modal,
.app-popup-overlay--dim > .lsp-modal,
.app-popup-overlay--dim > .gc-popup-panel,
.brm-overlay > .brm-modal,
.ism-overlay > .ism-dialog,
.ibsm-overlay > .ibsm-dialog,
.mcs-overlay > .mcs-dialog,
.dsm-overlay > .dsm-dialog,
.sp-modal-overlay > .sp-modal,
.obj-tree-overlay > .obj-tree-panel,
.ind-overlay > .ind-panel,
.plsp-overlay > .plsp-popup,
.login-modal-backdrop > .login-modal {
pointer-events: auto;
}
.app-popup-overlay > .app-popup-shell,
.tam-overlay > .tam-modal,
.lsp-overlay > .lsp-modal {
pointer-events: auto;
}
/* ── 셸 (모달 컨테이너) ── */
.app-popup-shell,
.tam-modal,
.lsp-modal,
.gc-popup-panel,
.bts-modal,
.brm-modal,
.ism-dialog,
.ibsm-dialog,
.mcs-dialog,
.dsm-dialog,
.sp-modal,
.obj-tree-panel,
.ind-panel,
.plsp-popup,
.indicator-modal,
.login-modal {
position: fixed;
display: flex;
flex-direction: column;
background: var(--bg2, #1e222d);
border: 1px solid var(--app-popup-accent, var(--tam-accent, rgba(63, 126, 245, 0.35)));
border-radius: 12px;
box-shadow:
0 0 0 1px var(--app-popup-accent-glow, var(--tam-accent-glow, rgba(63, 126, 245, 0.08))),
0 20px 60px rgba(0, 0, 0, 0.65),
0 0 40px var(--app-popup-accent-glow, rgba(63, 126, 245, 0.06));
overflow: hidden;
font-size: 13px;
color: var(--text, #c0caf5);
font-family: var(--font, 'Noto Sans KR', sans-serif);
max-width: 96vw;
max-height: 90vh;
}
/* ── 헤더 ── */
.app-popup-header,
.gc-popup-header.tam-header,
.gc-popup-header.lsp-header,
.gc-popup-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 10px 14px;
flex-shrink: 0;
cursor: grab;
user-select: none;
touch-action: none;
border-bottom: 1px solid var(--border, rgba(122, 162, 247, 0.12));
background: linear-gradient(
180deg,
rgba(63, 126, 245, 0.14) 0%,
rgba(63, 126, 245, 0.04) 28px,
var(--bg3, #1f2335) 100%
);
}
.app-popup-header:active,
.gc-popup-header:active { cursor: grabbing; }
.app-popup-header-left,
.tam-header-left,
.lsp-header-left {
display: flex;
align-items: center;
gap: 9px;
flex: 1;
min-width: 0;
}
.app-popup-badge,
.tam-badge,
.lsp-badge {
display: inline-block;
padding: 3px 10px;
border-radius: 20px;
font-size: 11px;
font-weight: 800;
color: #fff;
letter-spacing: 1.2px;
flex-shrink: 0;
}
.app-popup-header-title,
.tam-header-title,
.lsp-header-title,
.gc-popup-title {
font-size: 14px;
font-weight: 700;
color: var(--text, #c0caf5);
letter-spacing: 0.4px;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.app-popup-header-ko,
.tam-header-ko,
.lsp-header-ko {
font-size: 12px;
font-weight: 400;
color: var(--text2, #9aa5ce);
}
.app-popup-close,
.tam-close,
.lsp-close,
.gc-popup-close,
.bts-close,
.brm-close,
.ism-close,
.ibsm-close,
.login-modal-close {
background: none;
border: none;
color: var(--text3, #6272a4);
font-size: 16px;
cursor: pointer;
padding: 2px 6px;
line-height: 1;
border-radius: 4px;
transition: color 0.15s, background 0.15s;
flex-shrink: 0;
}
.app-popup-close:hover,
.tam-close:hover,
.lsp-close:hover,
.gc-popup-close:hover,
.bts-close:hover,
.brm-close:hover { color: var(--text, #c0caf5); background: var(--bg4, rgba(255, 255, 255, 0.07)); }
/* ── 본문 ── */
.app-popup-body {
display: flex;
flex-direction: column;
gap: 10px;
padding: 12px 14px 14px;
overflow-y: auto;
flex: 1;
min-height: 0;
}
.app-popup-section {
padding: 12px 16px;
border-bottom: 1px solid var(--border, rgba(122, 162, 247, 0.1));
}
.app-popup-section--gradient {
background: linear-gradient(
135deg,
var(--app-popup-accent-glow, rgba(63, 126, 245, 0.08)) 0%,
transparent 60%
);
}
.app-popup-card {
display: flex;
align-items: flex-start;
gap: 12px;
margin: 0 14px;
padding: 11px 14px;
background: var(--bg3, #1f2335);
border: 1px solid var(--border, rgba(122, 162, 247, 0.12));
border-radius: 9px;
}
.app-popup-field-row {
display: flex;
align-items: center;
gap: 8px;
}
.app-popup-field-label {
font-size: 11.5px;
color: var(--text2, #9aa5ce);
flex-shrink: 0;
min-width: 42px;
}
.app-popup-input,
.app-popup-select {
flex: 1;
min-width: 0;
box-sizing: border-box;
background: var(--bg3, #1f2335);
border: 1px solid var(--border, rgba(122, 162, 247, 0.18));
border-radius: 6px;
outline: none;
color: var(--text, #c0caf5);
font-size: 12.5px;
padding: 7px 10px;
transition: border-color 0.15s;
}
.app-popup-input:focus,
.app-popup-select:focus {
border-color: var(--app-popup-accent, #3f7ef5);
}
.app-popup-btn {
padding: 7px 11px;
border: 1px solid var(--border, rgba(122, 162, 247, 0.18));
border-radius: 6px;
background: var(--bg3, #1f2335);
color: var(--text2, #9aa5ce);
font-size: 11.5px;
font-weight: 600;
cursor: pointer;
transition: border-color 0.15s, color 0.15s, background 0.15s;
}
.app-popup-btn:hover {
border-color: var(--app-popup-accent, #3f7ef5);
color: var(--text, #c0caf5);
background: var(--bg4, #414868);
}
.app-popup-btn--primary {
width: 100%;
padding: 12px;
border: none;
border-radius: 8px;
background: var(--app-popup-accent, #3f7ef5);
color: #fff;
font-size: 14px;
font-weight: 800;
box-shadow: 0 4px 16px var(--app-popup-accent-glow, rgba(63, 126, 245, 0.25));
}
.app-popup-btn--primary:hover { opacity: 0.92; }
.app-popup-disclaimer {
font-size: 10.5px;
color: var(--text3, #6272a4);
text-align: center;
margin: 0;
line-height: 1.5;
}
/* 레거시 오버레이 통일 */
.brm-overlay,
.ism-overlay,
.ibsm-overlay,
.mcs-overlay,
.dsm-overlay,
.sp-modal-overlay,
.obj-tree-overlay,
.ind-overlay,
.plsp-overlay,
.login-modal-backdrop {
position: fixed;
inset: 0;
z-index: 5000;
background: rgba(0, 0, 0, 0.45);
}
/* 라이트 테마 */
.app.light .app-popup-shell,
.app.light .tam-modal,
.app.light .lsp-modal,
.app.light .gc-popup-panel,
.app.light .bts-modal,
.app.light .brm-modal,
.app.light .ism-dialog,
.app.light .login-modal {
background: #ffffff;
border-color: rgba(25, 118, 210, 0.35);
color: #212121;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.18), 0 0 40px rgba(25, 118, 210, 0.08);
}
.app.light .app-popup-header,
.app.light .gc-popup-header {
background: linear-gradient(180deg, rgba(25, 118, 210, 0.1) 0%, #f5f7fa 100%);
border-color: rgba(0, 0, 0, 0.1);
}
.app.light .app-popup-header-title,
.app.light .gc-popup-title { color: #1a237e; }
.app.light .app-popup-header-ko { color: #546e7a; }
.app.light .app-popup-card { background: #f5f7fa; border-color: rgba(0, 0, 0, 0.1); }
.app.light .app-popup-input,
.app.light .app-popup-select { background: #f0f3f8; border-color: rgba(0, 0, 0, 0.12); color: #212121; }
@@ -2,19 +2,35 @@ import React, { useState, useEffect, useCallback, useRef } from 'react';
import { Paper } from '@mui/material'; import { Paper } from '@mui/material';
/** /**
* 드래그 가능한 Dialog Paper 컴포넌트 * 드래그 가능한 Dialog Paper — tam 스타일 팝업, 마운트 시 화면 중앙 배치
* 모든 Dialog에서 재사용 가능
*/ */
const DraggablePaper = (props: any) => { const DraggablePaper = (props: any) => {
const paperRef = useRef<HTMLDivElement>(null); const paperRef = useRef<HTMLDivElement>(null);
const [isDragging, setIsDragging] = useState(false); const [isDragging, setIsDragging] = useState(false);
const [position, setPosition] = useState({ x: 0, y: 60 }); // 초기 위치를 더 아래로 (60px) const [position, setPosition] = useState({ x: 0, y: 0 });
const [dragStart, setDragStart] = useState({ x: 0, y: 0 }); const [dragStart, setDragStart] = useState({ x: 0, y: 0 });
const centeredOnce = useRef(false);
useEffect(() => {
if (centeredOnce.current) return;
const el = paperRef.current;
if (!el) return;
const placeCenter = () => {
const w = el.offsetWidth || 400;
const h = el.offsetHeight || 320;
setPosition({
x: Math.max(8, (window.innerWidth - w) / 2),
y: Math.max(8, (window.innerHeight - h) / 2),
});
centeredOnce.current = true;
};
placeCenter();
const id = requestAnimationFrame(placeCenter);
return () => cancelAnimationFrame(id);
}, []);
const handleMouseDown = (e: React.MouseEvent) => { const handleMouseDown = (e: React.MouseEvent) => {
// 타이틀바 영역에서만 드래그 시작
const target = e.target as HTMLElement; const target = e.target as HTMLElement;
// 버튼, 아이콘버튼, 또는 그 내부 요소를 클릭한 경우 드래그 시작 안 함
if (target.closest('.MuiDialogTitle-root') && if (target.closest('.MuiDialogTitle-root') &&
!target.closest('button') && !target.closest('button') &&
!target.closest('.MuiButton-root') && !target.closest('.MuiButton-root') &&
@@ -29,17 +45,9 @@ const DraggablePaper = (props: any) => {
const handleMouseMove = useCallback((e: MouseEvent) => { const handleMouseMove = useCallback((e: MouseEvent) => {
if (isDragging && paperRef.current) { if (isDragging && paperRef.current) {
// 새 위치 계산
let newX = e.clientX - dragStart.x;
let newY = e.clientY - dragStart.y;
// 상단만 제한 (0보다 작아지지 않도록)
const minY = 0;
newY = Math.max(minY, newY);
setPosition({ setPosition({
x: newX, x: e.clientX - dragStart.x,
y: newY, y: Math.max(0, e.clientY - dragStart.y),
}); });
} }
}, [isDragging, dragStart]); }, [isDragging, dragStart]);
@@ -71,12 +79,29 @@ const DraggablePaper = (props: any) => {
left: position.x, left: position.x,
margin: 0, margin: 0,
cursor: isDragging ? 'grabbing' : 'default', cursor: isDragging ? 'grabbing' : 'default',
borderRadius: '12px',
border: '1px solid rgba(63, 126, 245, 0.35)',
backgroundImage: 'none',
backgroundColor: '#1e222d',
boxShadow: '0 0 0 1px rgba(63,126,245,0.08), 0 20px 60px rgba(0,0,0,0.65), 0 0 40px rgba(63,126,245,0.06)',
overflow: 'hidden',
'& .MuiDialogTitle-root': { '& .MuiDialogTitle-root': {
cursor: 'grab', cursor: 'grab',
userSelect: 'none', userSelect: 'none',
'&:active': { background: 'linear-gradient(180deg, rgba(63,126,245,0.14) 0%, rgba(63,126,245,0.04) 28px, #1f2335 100%)',
cursor: 'grabbing', borderBottom: '1px solid rgba(122,162,247,0.12)',
}, color: '#c0caf5',
fontSize: '14px',
fontWeight: 700,
'&:active': { cursor: 'grabbing' },
},
'& .MuiDialogContent-root': {
bgcolor: '#1e222d',
color: '#c0caf5',
},
'& .MuiDialogActions-root': {
bgcolor: '#1e222d',
borderTop: '1px solid rgba(122,162,247,0.1)',
}, },
}} }}
/> />
@@ -84,4 +109,3 @@ const DraggablePaper = (props: any) => {
}; };
export default DraggablePaper; export default DraggablePaper;
@@ -0,0 +1,39 @@
/**
* TradeAlertModal 스타일 MUI Dialog 타이틀 (배지 + EN/KO 제목)
*/
import React from 'react';
import { Box, DialogTitle, IconButton, Typography } from '@mui/material';
import CloseIcon from '@mui/icons-material/Close';
import { goldenPopupBadgeSx, goldenPopupDialogTitleSx } from '../utils/goldenPopupStyles';
interface Props {
badge?: string;
title: React.ReactNode;
titleKo?: React.ReactNode;
onClose?: () => void;
extra?: React.ReactNode;
}
const GoldenPopupDialogTitle: React.FC<Props> = ({ badge, title, titleKo, onClose, extra }) => (
<DialogTitle sx={goldenPopupDialogTitleSx} component="div">
<Box sx={{ display: 'flex', alignItems: 'center', gap: 1, flex: 1, minWidth: 0 }}>
{badge ? <Box component="span" sx={goldenPopupBadgeSx}>{badge}</Box> : null}
<Typography component="span" sx={{ fontSize: 'inherit', fontWeight: 'inherit', minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{title}
{titleKo ? (
<Typography component="span" sx={{ fontSize: '12px', fontWeight: 400, color: '#9aa5ce', ml: 0.5 }}>
({titleKo})
</Typography>
) : null}
</Typography>
</Box>
{extra}
{onClose ? (
<IconButton size="small" onClick={onClose} sx={{ color: '#6272a4', '&:hover': { color: '#c0caf5', bgcolor: 'rgba(255,255,255,0.07)' } }}>
<CloseIcon fontSize="small" />
</IconButton>
) : null}
</DialogTitle>
);
export default GoldenPopupDialogTitle;
+6 -23
View File
@@ -1,7 +1,6 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { import {
Dialog, Dialog,
DialogTitle,
DialogContent, DialogContent,
DialogActions, DialogActions,
TextField, TextField,
@@ -14,9 +13,10 @@ import {
InputAdornment, InputAdornment,
Divider, Divider,
} from '@mui/material'; } from '@mui/material';
import { Visibility, VisibilityOff, Close, AccountCircle } from '@mui/icons-material'; import { Visibility, VisibilityOff, AccountCircle } from '@mui/icons-material';
import { useAuth } from '../contexts/AuthContext'; import { useAuth } from '../contexts/AuthContext';
import DraggablePaper from './DraggablePaper'; import DraggablePaper from './DraggablePaper';
import GoldenPopupDialogTitle from './GoldenPopupDialogTitle';
interface LoginDialogProps { interface LoginDialogProps {
open: boolean; open: boolean;
@@ -87,32 +87,15 @@ const LoginDialog: React.FC<LoginDialogProps> = ({ open, onClose, onSwitchToSign
pointerEvents: 'none', pointerEvents: 'none',
'& .MuiDialog-container': { '& .MuiDialog-container': {
pointerEvents: 'none', pointerEvents: 'none',
alignItems: 'flex-start', alignItems: 'center',
justifyContent: 'center',
}, },
}} }}
PaperProps={{ PaperProps={{
sx: { sx: { pointerEvents: 'auto' },
pointerEvents: 'auto',
boxShadow: '0 8px 32px rgba(0, 0, 0, 0.3)',
border: '2px solid rgba(80, 100, 160, 0.5)',
}
}} }}
> >
<DialogTitle sx={{ <GoldenPopupDialogTitle badge="AUTH" title="LOGIN" titleKo="로그인" onClose={handleClose} />
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
bgcolor: '#1976d2',
color: '#fff',
py: 1.2,
}}>
<Typography variant="h6" sx={{ fontWeight: 600, fontSize: '16px', color: '#fff' }}>
🔐
</Typography>
<IconButton size="small" onClick={handleClose} sx={{ color: '#fff' }}>
<Close />
</IconButton>
</DialogTitle>
<form onSubmit={handleSubmit}> <form onSubmit={handleSubmit}>
<DialogContent> <DialogContent>
@@ -17,10 +17,8 @@ import {
useTheme, useTheme,
} from '@mui/material'; } from '@mui/material';
import CloseIcon from '@mui/icons-material/Close'; import CloseIcon from '@mui/icons-material/Close';
import DragIndicatorIcon from '@mui/icons-material/DragIndicator';
import FullscreenIcon from '@mui/icons-material/Fullscreen'; import FullscreenIcon from '@mui/icons-material/Fullscreen';
import FullscreenExitIcon from '@mui/icons-material/FullscreenExit'; import FullscreenExitIcon from '@mui/icons-material/FullscreenExit';
import ShowChartIcon from '@mui/icons-material/ShowChart';
import { import {
evaluateAlertStrategy, evaluateAlertStrategy,
getAllAlerts, getAllAlerts,
@@ -104,9 +102,13 @@ function clamp(x: number, y: number, w: number, h: number) {
return { x: Math.min(Math.max(m, x), maxX), y: Math.min(Math.max(m, y), maxY) }; return { x: Math.min(Math.max(m, x), maxX), y: Math.min(Math.max(m, y), maxY) };
} }
function nearAnchor(anchor: HTMLElement, w: number, h: number) { function centerPanel(w: number, h: number) {
const r = anchor.getBoundingClientRect(); if (typeof window === 'undefined') return { x: 24, y: 80 };
return clamp(r.right - w, r.bottom + 8, w, h); const m = 8;
return {
x: Math.max(m, (window.innerWidth - w) / 2),
y: Math.max(m, (window.innerHeight - h) / 2),
};
} }
function statusColor(s: ConnectionStatus): 'default' | 'primary' | 'success' | 'error' | 'warning' { function statusColor(s: ConnectionStatus): 'default' | 'primary' | 'success' | 'error' | 'warning' {
@@ -431,7 +433,7 @@ const AlertRealtimeStrategyPopupShell: React.FC<{
const resizeListenersRef = useRef<{ move: (e: MouseEvent) => void; up: () => void } | null>(null); const resizeListenersRef = useRef<{ move: (e: MouseEvent) => void; up: () => void } | null>(null);
useLayoutEffect(() => { useLayoutEffect(() => {
setDragPos(nearAnchor(anchorEl, panelW, panelH)); setDragPos(centerPanel(panelW, panelH));
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [anchorEl]); }, [anchorEl]);
@@ -516,7 +518,7 @@ const AlertRealtimeStrategyPopupShell: React.FC<{
}} }}
> >
<Paper <Paper
elevation={10} elevation={0}
sx={{ sx={{
width: '100%', width: '100%',
height: '100%', height: '100%',
@@ -524,21 +526,22 @@ const AlertRealtimeStrategyPopupShell: React.FC<{
display: 'flex', display: 'flex',
flexDirection: 'column', flexDirection: 'column',
position: 'relative', position: 'relative',
border: isDark ? '1px solid rgba(255,255,255,0.12)' : '1px solid rgba(0,0,0,0.12)', borderRadius: '12px',
bgcolor: 'background.paper', border: '1px solid rgba(63, 126, 245, 0.35)',
bgcolor: '#1e222d',
boxShadow: '0 0 0 1px rgba(63,126,245,0.08), 0 20px 60px rgba(0,0,0,0.65), 0 0 40px rgba(63,126,245,0.06)',
}} }}
> >
<Box <Box
sx={{ sx={{
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
gap: 0.5, gap: 1,
px: 1, px: 1.75,
py: 0.75, py: 1.25,
flexShrink: 0, flexShrink: 0,
borderBottom: 1, borderBottom: '1px solid rgba(122,162,247,0.12)',
borderColor: 'divider', background: 'linear-gradient(180deg, rgba(63,126,245,0.14) 0%, rgba(63,126,245,0.04) 28px, #1f2335 100%)',
bgcolor: isDark ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.04)',
}} }}
> >
<Box <Box
@@ -546,7 +549,7 @@ const AlertRealtimeStrategyPopupShell: React.FC<{
sx={{ sx={{
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
gap: 0.75, gap: 1,
flex: 1, flex: 1,
minWidth: 0, minWidth: 0,
cursor: isMaximized ? 'default' : 'grab', cursor: isMaximized ? 'default' : 'grab',
@@ -554,10 +557,27 @@ const AlertRealtimeStrategyPopupShell: React.FC<{
'&:active': { cursor: isMaximized ? 'default' : 'grabbing' }, '&:active': { cursor: isMaximized ? 'default' : 'grabbing' },
}} }}
> >
<ShowChartIcon sx={{ fontSize: 20, color: 'primary.main', flexShrink: 0 }} /> <Box
<DragIndicatorIcon sx={{ fontSize: 18, color: 'text.secondary', flexShrink: 0, opacity: 0.85 }} /> component="span"
<Typography variant="subtitle2" sx={{ fontWeight: 700, flexShrink: 0 }}> sx={{
px: 1.25,
py: 0.35,
borderRadius: '20px',
fontSize: '11px',
fontWeight: 800,
color: '#fff',
letterSpacing: '1.2px',
bgcolor: '#3f7ef5',
flexShrink: 0,
}}
>
LIVE
</Box>
<Typography variant="subtitle2" sx={{ fontWeight: 700, flexShrink: 0, color: '#c0caf5', fontSize: '14px' }}>
STRATEGY DATA
<Typography component="span" sx={{ fontSize: '12px', fontWeight: 400, color: '#9aa5ce', ml: 0.5 }}>
( )
</Typography>
</Typography> </Typography>
<Typography <Typography
variant="caption" variant="caption"
@@ -587,10 +607,17 @@ const AlertRealtimeStrategyPopupShell: React.FC<{
size="small" size="small"
onClick={toggleMaximize} onClick={toggleMaximize}
aria-label={isMaximized ? '이전 크기로' : '최대화'} aria-label={isMaximized ? '이전 크기로' : '최대화'}
sx={{ color: '#6272a4', '&:hover': { color: '#c0caf5', bgcolor: 'rgba(255,255,255,0.07)' } }}
> >
{isMaximized ? <FullscreenExitIcon fontSize="small" /> : <FullscreenIcon fontSize="small" />} {isMaximized ? <FullscreenExitIcon fontSize="small" /> : <FullscreenIcon fontSize="small" />}
</IconButton> </IconButton>
<IconButton className="rt-strategy-no-drag" size="small" onClick={onClose} aria-label="닫기"> <IconButton
className="rt-strategy-no-drag"
size="small"
onClick={onClose}
aria-label="닫기"
sx={{ color: '#6272a4', '&:hover': { color: '#c0caf5', bgcolor: 'rgba(255,255,255,0.07)' } }}
>
<CloseIcon fontSize="small" /> <CloseIcon fontSize="small" />
</IconButton> </IconButton>
</Box> </Box>
@@ -230,6 +230,48 @@ const darkTheme = createTheme({
}, },
}, },
}, },
MuiDialog: {
styleOverrides: {
paper: {
borderRadius: 12,
border: '1px solid rgba(63, 126, 245, 0.35)',
backgroundImage: 'none',
backgroundColor: '#1e222d',
boxShadow: '0 0 0 1px rgba(63,126,245,0.08), 0 20px 60px rgba(0,0,0,0.65), 0 0 40px rgba(63,126,245,0.06)',
overflow: 'hidden',
},
},
},
MuiDialogTitle: {
styleOverrides: {
root: {
background: 'linear-gradient(180deg, rgba(63,126,245,0.14) 0%, rgba(63,126,245,0.04) 28px, #1f2335 100%)',
borderBottom: '1px solid rgba(122,162,247,0.12)',
color: '#c0caf5',
fontSize: '14px',
fontWeight: 700,
padding: '10px 14px',
},
},
},
MuiDialogContent: {
styleOverrides: {
root: {
backgroundColor: '#1e222d',
color: '#c0caf5',
padding: '12px 14px',
},
},
},
MuiDialogActions: {
styleOverrides: {
root: {
backgroundColor: '#1e222d',
borderTop: '1px solid rgba(122,162,247,0.1)',
padding: '10px 14px',
},
},
},
MuiInputBase: { MuiInputBase: {
styleOverrides: { styleOverrides: {
root: { root: {
@@ -0,0 +1,73 @@
/** TradeAlertModal(tam-) 기준 MUI Dialog 공통 스타일 — frontend_golden */
export const GOLDEN_POPUP_ACCENT = '#3f7ef5';
export const GOLDEN_POPUP_ACCENT_GLOW = 'rgba(63,126,245,0.22)';
export const goldenPopupDialogPaperSx = {
borderRadius: '12px',
border: `1px solid ${GOLDEN_POPUP_ACCENT}40`,
background: '#1e222d',
backgroundImage: 'none',
boxShadow: `0 0 0 1px ${GOLDEN_POPUP_ACCENT_GLOW}, 0 20px 60px rgba(0,0,0,0.65), 0 0 40px ${GOLDEN_POPUP_ACCENT_GLOW}`,
overflow: 'hidden',
maxWidth: '96vw',
maxHeight: '90vh',
} as const;
export const goldenPopupDialogTitleSx = {
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 1,
py: 1.25,
px: 1.75,
cursor: 'grab',
userSelect: 'none',
background: `linear-gradient(180deg, rgba(63,126,245,0.14) 0%, rgba(63,126,245,0.04) 28px, #1f2335 100%)`,
borderBottom: '1px solid rgba(122,162,247,0.12)',
color: '#c0caf5',
fontSize: '14px',
fontWeight: 700,
letterSpacing: '0.4px',
'&:active': { cursor: 'grabbing' },
} as const;
export const goldenPopupDialogContentSx = {
px: 1.75,
py: 1.5,
bgcolor: '#1e222d',
color: '#c0caf5',
} as const;
export const goldenPopupDialogActionsSx = {
px: 1.75,
py: 1.25,
bgcolor: '#1e222d',
borderTop: '1px solid rgba(122,162,247,0.1)',
} as const;
export const goldenPopupBadgeSx = {
display: 'inline-block',
px: 1.25,
py: 0.35,
borderRadius: '20px',
fontSize: '11px',
fontWeight: 800,
color: '#fff',
letterSpacing: '1.2px',
bgcolor: GOLDEN_POPUP_ACCENT,
flexShrink: 0,
} as const;
/** Dialog 공통 props (드래그 Paper + tam 스타일) */
export function goldenPopupDialogProps(width = 400) {
return {
hideBackdrop: true,
PaperProps: {
sx: {
...goldenPopupDialogPaperSx,
width,
m: 0,
},
},
} as const;
}