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:
@@ -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;
|
||||
@@ -1,12 +1,14 @@
|
||||
/**
|
||||
* 드래그 가능한 모달 프레임 — 그라데이션 타이틀바(gc-popup-header) + 화면 중앙 초기 배치
|
||||
* 드래그 가능한 모달 프레임 — AppPopup 기반
|
||||
*/
|
||||
import React from 'react';
|
||||
import { useDraggablePanel } from '../hooks/useDraggablePanel';
|
||||
import AppPopup from './AppPopup';
|
||||
|
||||
export interface DraggableModalFrameProps {
|
||||
onClose: () => void;
|
||||
title: React.ReactNode;
|
||||
titleKo?: React.ReactNode;
|
||||
badge?: string;
|
||||
children: React.ReactNode;
|
||||
overlayClassName?: string;
|
||||
dialogClassName?: string;
|
||||
@@ -19,60 +21,32 @@ export interface DraggableModalFrameProps {
|
||||
export const DraggableModalFrame: React.FC<DraggableModalFrameProps> = ({
|
||||
onClose,
|
||||
title,
|
||||
titleKo,
|
||||
badge,
|
||||
children,
|
||||
overlayClassName = 'sp-modal-overlay',
|
||||
dialogClassName = 'sp-modal',
|
||||
bodyClassName = 'sp-modal-body',
|
||||
bodyClassName = 'sp-modal-body app-popup-body',
|
||||
zIndex = 1000,
|
||||
width,
|
||||
closeOnBackdrop = true,
|
||||
}) => {
|
||||
const {
|
||||
panelRef,
|
||||
dragging,
|
||||
onHeaderPointerDown, headerTouchStyle,
|
||||
panelStyle,
|
||||
headerCursor,
|
||||
} = useDraggablePanel({ centerOnMount: true });
|
||||
|
||||
return (
|
||||
<div
|
||||
className={overlayClassName}
|
||||
style={{ zIndex }}
|
||||
onMouseDown={e => {
|
||||
if (closeOnBackdrop && e.target === e.currentTarget) onClose();
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
);
|
||||
};
|
||||
}) => (
|
||||
<AppPopup
|
||||
onClose={onClose}
|
||||
title={title}
|
||||
titleKo={titleKo}
|
||||
badge={badge}
|
||||
overlayClassName={overlayClassName}
|
||||
shellClassName={dialogClassName}
|
||||
bodyClassName={bodyClassName}
|
||||
zIndex={zIndex}
|
||||
width={width ?? 420}
|
||||
backdrop
|
||||
closeOnBackdrop={closeOnBackdrop}
|
||||
centered
|
||||
>
|
||||
{children}
|
||||
</AppPopup>
|
||||
);
|
||||
|
||||
export default DraggableModalFrame;
|
||||
|
||||
@@ -5,7 +5,9 @@
|
||||
* 관심종목(★) 등록 종목 전체가 체크 대상이며, 여기서 전략·실행 방식을 지정합니다.
|
||||
*/
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import type { Theme } from '../types';
|
||||
import { useDraggablePanel } from '../hooks/useDraggablePanel';
|
||||
import {
|
||||
saveLiveStrategySettings,
|
||||
type LiveStrategySettingsDto,
|
||||
@@ -28,6 +30,20 @@ interface LiveStrategyPanelProps {
|
||||
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> = ({
|
||||
theme, market, strategies, settings,
|
||||
watchlistCount = 0, monitoredMarkets = [],
|
||||
@@ -35,6 +51,18 @@ const LiveStrategyPanel: React.FC<LiveStrategyPanelProps> = ({
|
||||
}) => {
|
||||
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 next: LiveStrategySettingsDto = { ...settings, ...patch, market };
|
||||
const prev = settings;
|
||||
@@ -61,157 +89,209 @@ const LiveStrategyPanel: React.FC<LiveStrategyPanelProps> = ({
|
||||
const execType = settings.executionType;
|
||||
const stratId = settings.strategyId;
|
||||
const monCount = monitoredMarkets.length;
|
||||
const selectedStrategy = strategies.find(s => s.id === stratId);
|
||||
|
||||
return (
|
||||
<div className={`lsp-wrap lsp-wrap--${theme}`}>
|
||||
<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>
|
||||
const execLabel = execType === 'REALTIME_TICK' ? '실시간 틱 (3초)' : '봉 마감 직후';
|
||||
const posModeLabel = (settings.positionMode ?? 'LONG_ONLY') === 'LONG_ONLY'
|
||||
? '보유 자산 기준'
|
||||
: '순수 지표 기준';
|
||||
|
||||
<div className="lsp-monitor-stats">
|
||||
<span>관심 {watchlistCount}개</span>
|
||||
<span className="lsp-monitor-sep">·</span>
|
||||
<span className={monCount > 0 ? 'lsp-monitor-on' : ''}>
|
||||
체크 대상 {monCount}개
|
||||
</span>
|
||||
</div>
|
||||
{isOn && stratId && watchlistCount > 0 && (
|
||||
<p className="lsp-monitor-hint">
|
||||
★ 관심종목으로 등록한 종목이 자동으로 전략 체크 대상입니다.
|
||||
</p>
|
||||
)}
|
||||
{monCount > 0 && (
|
||||
<div className="lsp-monitor-list" title={monitoredMarkets.join(', ')}>
|
||||
{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>}
|
||||
</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 })}
|
||||
const panel = (
|
||||
<div className={`lsp-overlay lsp-overlay--${theme}`}>
|
||||
<div
|
||||
ref={panelRef}
|
||||
className="lsp-modal app-popup-shell"
|
||||
style={{
|
||||
...panelStyle,
|
||||
zIndex: 10000,
|
||||
cursor: dragging ? 'grabbing' : undefined,
|
||||
'--lsp-accent': ACCENT,
|
||||
'--lsp-accent-glow': ACCENT_GLOW,
|
||||
} as React.CSSProperties}
|
||||
onMouseDown={e => e.stopPropagation()}
|
||||
>
|
||||
<div
|
||||
className="gc-popup-header lsp-header"
|
||||
onPointerDown={onHeaderPointerDown}
|
||||
style={{ cursor: headerCursor, ...headerTouchStyle }}
|
||||
>
|
||||
<option value="">전략 선택…</option>
|
||||
{strategies.map(s => (
|
||||
<option key={s.id} value={s.id}>{s.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="lsp-header-left">
|
||||
<span className="lsp-badge" style={{ background: ACCENT }}>LIVE</span>
|
||||
<span className="lsp-header-title">
|
||||
STRATEGY CHECK
|
||||
<span className="lsp-header-ko"> (실시간 전략 체크)</span>
|
||||
</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' : ''}`}>
|
||||
<span className="lsp-label">실행 방식</span>
|
||||
<div className="lsp-radio-group">
|
||||
<label className="lsp-radio">
|
||||
<input
|
||||
type="radio"
|
||||
name="execType"
|
||||
value="CANDLE_CLOSE"
|
||||
checked={execType === 'CANDLE_CLOSE'}
|
||||
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>
|
||||
<div className="lsp-summary-section">
|
||||
<div className="lsp-summary-row">
|
||||
<span className="lsp-summary-label">관심 종목</span>
|
||||
<span className="lsp-summary-value">{watchlistCount}개</span>
|
||||
</div>
|
||||
<div className="lsp-summary-row">
|
||||
<span className="lsp-summary-label">체크 대상</span>
|
||||
<span className={`lsp-summary-value${monCount > 0 ? ' lsp-summary-value--active' : ''}`}>
|
||||
{monCount}개
|
||||
</span>
|
||||
</label>
|
||||
<label className="lsp-radio">
|
||||
<input
|
||||
type="radio"
|
||||
name="execType"
|
||||
value="REALTIME_TICK"
|
||||
checked={execType === 'REALTIME_TICK'}
|
||||
</div>
|
||||
{isOn && stratId && watchlistCount > 0 && (
|
||||
<p className="lsp-summary-hint">
|
||||
★ 관심종목으로 등록한 종목이 자동으로 전략 체크 대상입니다.
|
||||
</p>
|
||||
)}
|
||||
</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}
|
||||
onChange={() => persist({ executionType: 'REALTIME_TICK' })}
|
||||
/>
|
||||
<span className="lsp-radio-label">
|
||||
<span className="lsp-radio-title">실시간 틱 (3초)</span>
|
||||
<span className="lsp-radio-desc">진행 중인 캔들 기준, 3초 주기 판정</span>
|
||||
</span>
|
||||
</label>
|
||||
value={stratId ?? ''}
|
||||
onChange={e => persist({ strategyId: e.target.value ? Number(e.target.value) : null })}
|
||||
>
|
||||
<option value="">전략 선택…</option>
|
||||
{strategies.map(s => (
|
||||
<option key={s.id} value={s.id}>{s.name}</option>
|
||||
))}
|
||||
</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 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>
|
||||
);
|
||||
|
||||
if (typeof document === 'undefined') return null;
|
||||
return createPortal(panel, document.body);
|
||||
};
|
||||
|
||||
export default LiveStrategyPanel;
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
/**
|
||||
* 로그인 모달 — 기본값 admin / admin
|
||||
* 로그인 모달 — AppPopup(tam-) 스타일
|
||||
*/
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { loginUser, type LoginResponse } from '../utils/backendApi';
|
||||
import AppPopup from './AppPopup';
|
||||
|
||||
const DEFAULT_USERNAME = 'admin';
|
||||
const DEFAULT_PASSWORD = 'admin';
|
||||
@@ -16,7 +17,7 @@ interface Props {
|
||||
const LoginModal: React.FC<Props> = ({ open, onClose, onSuccess }) => {
|
||||
const [username, setUsername] = useState(DEFAULT_USERNAME);
|
||||
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);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -49,54 +50,53 @@ const LoginModal: React.FC<Props> = ({ open, onClose, onSuccess }) => {
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="login-modal-backdrop" onClick={onClose}>
|
||||
<div
|
||||
className="login-modal"
|
||||
role="dialog"
|
||||
aria-labelledby="login-modal-title"
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
<div className="login-modal-header">
|
||||
<h2 id="login-modal-title">로그인</h2>
|
||||
<button type="button" className="login-modal-close" onClick={onClose} aria-label="닫기">×</button>
|
||||
<AppPopup
|
||||
onClose={onClose}
|
||||
title="LOGIN"
|
||||
titleKo="로그인"
|
||||
badge="AUTH"
|
||||
width={380}
|
||||
backdrop
|
||||
shellClassName="login-modal"
|
||||
bodyClassName="app-popup-body login-modal-body"
|
||||
>
|
||||
<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>
|
||||
<p className="login-modal-hint">
|
||||
로그인 시 동일 계정의 설정이 모든 기기에서 공유됩니다. 로그인하지 않으면 이 브라우저(기기)별로 설정이 저장됩니다.
|
||||
</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>
|
||||
</form>
|
||||
</AppPopup>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
@@ -1,81 +1,15 @@
|
||||
/**
|
||||
* 공통 팝업 셸 — 그라데이션 타이틀바 + 드래그 이동
|
||||
* 공통 팝업 셸 — AppPopup 래퍼 (하위 호환)
|
||||
*/
|
||||
import React from 'react';
|
||||
import { useDraggablePanel } from '../hooks/useDraggablePanel';
|
||||
import AppPopup, { type AppPopupProps } from './AppPopup';
|
||||
|
||||
export interface PopupShellProps {
|
||||
onClose: () => void;
|
||||
export type PopupShellProps = Omit<AppPopupProps, 'titleKo' | 'badge'> & {
|
||||
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;
|
||||
|
||||
@@ -159,7 +159,7 @@ export const TradeAlertModal: React.FC<Props> = ({
|
||||
<div className="tam-overlay">
|
||||
<div
|
||||
ref={panelRef}
|
||||
className="tam-modal"
|
||||
className="tam-modal app-popup-shell"
|
||||
style={{
|
||||
...panelStyle,
|
||||
zIndex: 10000,
|
||||
|
||||
Reference in New Issue
Block a user