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:
@@ -2,22 +2,38 @@ import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { Paper } from '@mui/material';
|
||||
|
||||
/**
|
||||
* 드래그 가능한 Dialog Paper 컴포넌트
|
||||
* 모든 Dialog에서 재사용 가능
|
||||
* 드래그 가능한 Dialog Paper — tam 스타일 팝업, 마운트 시 화면 중앙 배치
|
||||
*/
|
||||
const DraggablePaper = (props: any) => {
|
||||
const paperRef = useRef<HTMLDivElement>(null);
|
||||
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 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 target = e.target as HTMLElement;
|
||||
// 버튼, 아이콘버튼, 또는 그 내부 요소를 클릭한 경우 드래그 시작 안 함
|
||||
if (target.closest('.MuiDialogTitle-root') &&
|
||||
!target.closest('button') &&
|
||||
!target.closest('.MuiButton-root') &&
|
||||
if (target.closest('.MuiDialogTitle-root') &&
|
||||
!target.closest('button') &&
|
||||
!target.closest('.MuiButton-root') &&
|
||||
!target.closest('.MuiIconButton-root')) {
|
||||
setIsDragging(true);
|
||||
setDragStart({
|
||||
@@ -29,17 +45,9 @@ const DraggablePaper = (props: any) => {
|
||||
|
||||
const handleMouseMove = useCallback((e: MouseEvent) => {
|
||||
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({
|
||||
x: newX,
|
||||
y: newY,
|
||||
x: e.clientX - dragStart.x,
|
||||
y: Math.max(0, e.clientY - dragStart.y),
|
||||
});
|
||||
}
|
||||
}, [isDragging, dragStart]);
|
||||
@@ -71,12 +79,29 @@ const DraggablePaper = (props: any) => {
|
||||
left: position.x,
|
||||
margin: 0,
|
||||
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': {
|
||||
cursor: 'grab',
|
||||
userSelect: 'none',
|
||||
'&:active': {
|
||||
cursor: 'grabbing',
|
||||
},
|
||||
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,
|
||||
'&: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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
@@ -1,7 +1,6 @@
|
||||
import React, { useState } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogTitle,
|
||||
DialogContent,
|
||||
DialogActions,
|
||||
TextField,
|
||||
@@ -14,9 +13,10 @@ import {
|
||||
InputAdornment,
|
||||
Divider,
|
||||
} 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 DraggablePaper from './DraggablePaper';
|
||||
import GoldenPopupDialogTitle from './GoldenPopupDialogTitle';
|
||||
|
||||
interface LoginDialogProps {
|
||||
open: boolean;
|
||||
@@ -87,32 +87,15 @@ const LoginDialog: React.FC<LoginDialogProps> = ({ open, onClose, onSwitchToSign
|
||||
pointerEvents: 'none',
|
||||
'& .MuiDialog-container': {
|
||||
pointerEvents: 'none',
|
||||
alignItems: 'flex-start',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
}}
|
||||
PaperProps={{
|
||||
sx: {
|
||||
pointerEvents: 'auto',
|
||||
boxShadow: '0 8px 32px rgba(0, 0, 0, 0.3)',
|
||||
border: '2px solid rgba(80, 100, 160, 0.5)',
|
||||
}
|
||||
sx: { pointerEvents: 'auto' },
|
||||
}}
|
||||
>
|
||||
<DialogTitle sx={{
|
||||
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>
|
||||
<GoldenPopupDialogTitle badge="AUTH" title="LOGIN" titleKo="로그인" onClose={handleClose} />
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<DialogContent>
|
||||
|
||||
@@ -17,10 +17,8 @@ import {
|
||||
useTheme,
|
||||
} from '@mui/material';
|
||||
import CloseIcon from '@mui/icons-material/Close';
|
||||
import DragIndicatorIcon from '@mui/icons-material/DragIndicator';
|
||||
import FullscreenIcon from '@mui/icons-material/Fullscreen';
|
||||
import FullscreenExitIcon from '@mui/icons-material/FullscreenExit';
|
||||
import ShowChartIcon from '@mui/icons-material/ShowChart';
|
||||
import {
|
||||
evaluateAlertStrategy,
|
||||
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) };
|
||||
}
|
||||
|
||||
function nearAnchor(anchor: HTMLElement, w: number, h: number) {
|
||||
const r = anchor.getBoundingClientRect();
|
||||
return clamp(r.right - w, r.bottom + 8, w, h);
|
||||
function centerPanel(w: number, h: number) {
|
||||
if (typeof window === 'undefined') return { x: 24, y: 80 };
|
||||
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' {
|
||||
@@ -431,7 +433,7 @@ const AlertRealtimeStrategyPopupShell: React.FC<{
|
||||
const resizeListenersRef = useRef<{ move: (e: MouseEvent) => void; up: () => void } | null>(null);
|
||||
|
||||
useLayoutEffect(() => {
|
||||
setDragPos(nearAnchor(anchorEl, panelW, panelH));
|
||||
setDragPos(centerPanel(panelW, panelH));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [anchorEl]);
|
||||
|
||||
@@ -516,7 +518,7 @@ const AlertRealtimeStrategyPopupShell: React.FC<{
|
||||
}}
|
||||
>
|
||||
<Paper
|
||||
elevation={10}
|
||||
elevation={0}
|
||||
sx={{
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
@@ -524,21 +526,22 @@ const AlertRealtimeStrategyPopupShell: React.FC<{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
position: 'relative',
|
||||
border: isDark ? '1px solid rgba(255,255,255,0.12)' : '1px solid rgba(0,0,0,0.12)',
|
||||
bgcolor: 'background.paper',
|
||||
borderRadius: '12px',
|
||||
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
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 0.5,
|
||||
px: 1,
|
||||
py: 0.75,
|
||||
gap: 1,
|
||||
px: 1.75,
|
||||
py: 1.25,
|
||||
flexShrink: 0,
|
||||
borderBottom: 1,
|
||||
borderColor: 'divider',
|
||||
bgcolor: isDark ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.04)',
|
||||
borderBottom: '1px solid rgba(122,162,247,0.12)',
|
||||
background: 'linear-gradient(180deg, rgba(63,126,245,0.14) 0%, rgba(63,126,245,0.04) 28px, #1f2335 100%)',
|
||||
}}
|
||||
>
|
||||
<Box
|
||||
@@ -546,7 +549,7 @@ const AlertRealtimeStrategyPopupShell: React.FC<{
|
||||
sx={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: 0.75,
|
||||
gap: 1,
|
||||
flex: 1,
|
||||
minWidth: 0,
|
||||
cursor: isMaximized ? 'default' : 'grab',
|
||||
@@ -554,10 +557,27 @@ const AlertRealtimeStrategyPopupShell: React.FC<{
|
||||
'&:active': { cursor: isMaximized ? 'default' : 'grabbing' },
|
||||
}}
|
||||
>
|
||||
<ShowChartIcon sx={{ fontSize: 20, color: 'primary.main', flexShrink: 0 }} />
|
||||
<DragIndicatorIcon sx={{ fontSize: 18, color: 'text.secondary', flexShrink: 0, opacity: 0.85 }} />
|
||||
<Typography variant="subtitle2" sx={{ fontWeight: 700, flexShrink: 0 }}>
|
||||
실시간 전략 데이터
|
||||
<Box
|
||||
component="span"
|
||||
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
|
||||
variant="caption"
|
||||
@@ -587,10 +607,17 @@ const AlertRealtimeStrategyPopupShell: React.FC<{
|
||||
size="small"
|
||||
onClick={toggleMaximize}
|
||||
aria-label={isMaximized ? '이전 크기로' : '최대화'}
|
||||
sx={{ color: '#6272a4', '&:hover': { color: '#c0caf5', bgcolor: 'rgba(255,255,255,0.07)' } }}
|
||||
>
|
||||
{isMaximized ? <FullscreenExitIcon fontSize="small" /> : <FullscreenIcon fontSize="small" />}
|
||||
</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" />
|
||||
</IconButton>
|
||||
</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: {
|
||||
styleOverrides: {
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user