From af230a423342fd3e05913c14e0b1302261bc9a93 Mon Sep 17 00:00:00 2001 From: Macbook Date: Sun, 24 May 2026 20:13:21 +0900 Subject: [PATCH] =?UTF-8?q?=EB=AA=A8=EC=9D=98=ED=88=AC=EC=9E=90,=20?= =?UTF-8?q?=EB=B0=B1=ED=85=8C=EC=8A=A4=ED=8C=85=20=EB=A0=88=EC=9D=B4?= =?UTF-8?q?=EC=95=84=EC=9B=83=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/App.css | 173 +------- frontend/src/App.tsx | 2 +- .../src/components/BacktestHistoryPage.tsx | 175 +++++--- frontend/src/components/PaperTradingPage.tsx | 366 +++++++--------- frontend/src/components/SplashScreen.tsx | 143 ++----- .../src/components/StrategyEditorPage.tsx | 36 +- .../components/layout/BuilderPageShell.tsx | 182 ++++++++ .../components/paper/PaperAnalysisChart.tsx | 32 ++ .../paper/PaperCompactOrderbook.tsx | 20 +- .../components/paper/PaperIndicatorPanel.tsx | 21 +- .../components/paper/PaperLeftSettingsTab.tsx | 133 ++++++ .../components/paper/PaperLeftStrategyTab.tsx | 182 ++++++++ .../src/components/paper/PaperMiniChart.tsx | 14 +- .../src/components/paper/PaperSplitPanel.tsx | 25 ++ .../splash/SplashChartBackground.tsx | 214 ++++++++++ .../components/strategyEditor/PaletteChip.tsx | 34 +- .../strategyEditor/StrategyEditorCanvas.tsx | 43 +- frontend/src/styles/backtestDashboard.css | 212 ++++----- frontend/src/styles/builderPageShell.css | 368 ++++++++++++++++ frontend/src/styles/paperDashboard.css | 402 ++++++++++++++---- frontend/src/styles/splashScreen.css | 252 +++++++++++ frontend/src/styles/strategyEditor.css | 127 +++++- .../utils/strategyCanvasInteractionStorage.ts | 20 + 23 files changed, 2394 insertions(+), 782 deletions(-) create mode 100644 frontend/src/components/layout/BuilderPageShell.tsx create mode 100644 frontend/src/components/paper/PaperAnalysisChart.tsx create mode 100644 frontend/src/components/paper/PaperLeftSettingsTab.tsx create mode 100644 frontend/src/components/paper/PaperLeftStrategyTab.tsx create mode 100644 frontend/src/components/paper/PaperSplitPanel.tsx create mode 100644 frontend/src/components/splash/SplashChartBackground.tsx create mode 100644 frontend/src/styles/builderPageShell.css create mode 100644 frontend/src/styles/splashScreen.css create mode 100644 frontend/src/utils/strategyCanvasInteractionStorage.ts diff --git a/frontend/src/App.css b/frontend/src/App.css index 490e7a1..80b196c 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -11317,173 +11317,8 @@ html.theme-light .tam-disclaimer { color: #90a4ae; } .admin-perm-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 6px 12px; max-height: 360px; overflow-y: auto; padding: 8px; background: var(--bg3); border-radius: 6px; border: 1px solid var(--border); } .admin-perm-item { display: flex; align-items: center; gap: 8px; font-size: 12px; cursor: pointer; } -/* ══════════════════════════════════════════════════════════════════ - SPLASH (진입 화면) - ══════════════════════════════════════════════════════════════════ */ -.splash { - position: fixed; - inset: 0; - z-index: 10000; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - background: #0a0e14; - color: #e8eaed; - overflow: auto; - padding: 24px 16px 48px; -} -.splash-bg { - position: absolute; - inset: 0; - background: - radial-gradient(ellipse 80% 50% at 50% -10%, rgba(33, 150, 243, 0.25), transparent), - radial-gradient(ellipse 60% 40% at 100% 80%, rgba(255, 213, 79, 0.08), transparent), - linear-gradient(180deg, #0d1117 0%, #0a0e14 100%); - pointer-events: none; -} -.splash-chart-deco { - position: absolute; - left: 0; - right: 0; - bottom: 18%; - height: 140px; - opacity: 0.55; - pointer-events: none; -} -.splash-chart-svg { width: 100%; height: 100%; } -.splash-line { - stroke-dasharray: 600; - animation: splash-draw 2.5s ease-out forwards; -} -@keyframes splash-draw { - from { stroke-dashoffset: 600; } - to { stroke-dashoffset: 0; } -} -.splash-inner { - position: relative; - z-index: 1; - width: 100%; - max-width: 480px; - display: flex; - flex-direction: column; - align-items: center; - gap: 24px; -} -.splash-brand { text-align: center; } -.splash-logo { margin-bottom: 12px; filter: drop-shadow(0 4px 20px rgba(33, 150, 243, 0.4)); } -.splash-title { - margin: 0; - font-size: 2rem; - font-weight: 700; - letter-spacing: -0.02em; - background: linear-gradient(135deg, #fff 0%, #90caf9 100%); - -webkit-background-clip: text; - -webkit-text-fill-color: transparent; - background-clip: text; -} -.splash-tagline { - margin: 8px 0 0; - font-size: 14px; - color: #9aa0a6; - line-height: 1.5; -} -.splash-features { - list-style: none; - margin: 0; - padding: 0; - display: grid; - grid-template-columns: 1fr 1fr; - gap: 8px 16px; - width: 100%; - font-size: 12px; - color: #b0b8c4; -} -.splash-features li { - display: flex; - align-items: center; - gap: 6px; -} -.splash-feat-icon { font-size: 14px; } -.splash-card { - width: 100%; - padding: 24px; - background: rgba(22, 28, 36, 0.85); - border: 1px solid rgba(255, 255, 255, 0.08); - border-radius: 12px; - box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4); - backdrop-filter: blur(12px); -} -.splash-card-title { margin: 0 0 6px; font-size: 18px; font-weight: 600; } -.splash-card-desc { margin: 0 0 16px; font-size: 13px; color: #9aa0a6; line-height: 1.5; } -.splash-form { display: flex; flex-direction: column; gap: 12px; } -.splash-form-row { display: flex; flex-direction: column; gap: 10px; } -.splash-field { display: flex; flex-direction: column; gap: 4px; font-size: 12px; color: #9aa0a6; } -.splash-field input { - padding: 10px 12px; - border: 1px solid rgba(255, 255, 255, 0.12); - border-radius: 6px; - background: #0d1117; - color: #e8eaed; - font-size: 14px; -} -.splash-field input:focus { - outline: none; - border-color: #2196f3; - box-shadow: 0 0 0 2px rgba(33, 150, 243, 0.25); -} -.splash-error { margin: 0; font-size: 12px; color: #ef5350; } -.splash-actions { - display: flex; - gap: 10px; - flex-wrap: wrap; -} -.splash-btn { - flex: 1; - min-width: 120px; - padding: 11px 18px; - border-radius: 6px; - font-size: 14px; - font-weight: 600; - cursor: pointer; - border: none; - transition: background 0.15s, transform 0.1s; -} -.splash-btn:disabled { opacity: 0.55; cursor: not-allowed; } -.splash-btn--primary { - background: #2196f3; - color: #fff; -} -.splash-btn--primary:hover:not(:disabled) { background: #42a5f5; } -.splash-btn--guest { - background: transparent; - color: #90caf9; - border: 1px solid rgba(33, 150, 243, 0.5); -} -.splash-btn--guest:hover:not(:disabled) { - background: rgba(33, 150, 243, 0.12); - border-color: #42a5f5; -} -.splash-footnote { - margin: 14px 0 0; - font-size: 11px; - color: #6b7280; - text-align: center; -} -.splash-footnote code { - padding: 1px 5px; - background: rgba(255, 255, 255, 0.06); - border-radius: 3px; - font-size: 10px; -} -.splash-footer { - position: relative; - z-index: 1; - margin-top: auto; - padding-top: 24px; - font-size: 11px; - color: #4b5563; -} +/* splash styles moved to styles/splashScreen.css */ + .tmb-guest-badge { font-size: 12px; font-weight: 600; @@ -11493,7 +11328,3 @@ html.theme-light .tam-disclaimer { color: #90a4ae; } border-radius: 10px; background: rgba(255, 193, 7, 0.1); } -@media (min-width: 520px) { - .splash-form-row { flex-direction: row; } - .splash-form-row .splash-field { flex: 1; } -} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 19c930e..58f61f9 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1498,7 +1498,7 @@ function App() { {/* ── 백테스팅 이력 화면 ─────────────────────────────────────────── */} {menuPage === 'backtest' && ( - + )} {menuPage === 'paper' && ( diff --git a/frontend/src/components/BacktestHistoryPage.tsx b/frontend/src/components/BacktestHistoryPage.tsx index 230403c..3f00414 100644 --- a/frontend/src/components/BacktestHistoryPage.tsx +++ b/frontend/src/components/BacktestHistoryPage.tsx @@ -1,5 +1,5 @@ /** - * BacktestHistoryPage — 첨부 이미지 스타일 (타임라인 이력 + 대시보드) + * BacktestHistoryPage — 전략편집기와 동일한 3열 + 하단 패널 레이아웃 */ import React, { useEffect, useState, useCallback, useMemo } from 'react'; import { @@ -9,9 +9,11 @@ import { type BacktestAnalysis, type BacktestSignal, } from '../utils/backendApi'; +import type { Theme } from '../types'; import { buildEquityFromSignals } from '../utils/backtestEquity'; import { BacktestResultDashboard } from './backtest/BacktestResultDashboard'; import BacktestSparkline from './backtest/BacktestSparkline'; +import BuilderPageShell from './layout/BuilderPageShell'; const pct = (v: number) => isFinite(v) && v !== 0 ? `${v >= 0 ? '+' : ''}${(v * 100).toFixed(1)}%` : '0.0%'; @@ -64,7 +66,11 @@ function TimelineItem({ r, active, onClick }: TimelineItemProps) { ); } -export function BacktestHistoryPage() { +interface Props { + theme?: Theme; +} + +export function BacktestHistoryPage({ theme = 'dark' }: Props) { const [records, setRecords] = useState([]); const [selected, setSelected] = useState(null); const [loading, setLoading] = useState(true); @@ -89,59 +95,124 @@ export function BacktestHistoryPage() { const selectedDetail = selected ? parseDetail(selected) : null; + const signalStats = useMemo(() => { + const sigs = selectedDetail?.signals ?? []; + if (!sigs.length) return null; + const buy = sigs.filter(s => s.type === 'BUY' || s.type === 'SHORT_EXIT').length; + const sell = sigs.filter(s => s.type === 'SELL' || s.type === 'SHORT_ENTRY' || s.type === 'PARTIAL_SELL').length; + return { buy, sell, total: sigs.length }; + }, [selectedDetail]); + + const leftContent = records.length === 0 ? ( +
+

백테스팅 이력이 없습니다.

+

차트 화면에서 전략을 선택하고
백테스팅을 실행하세요.

+
+ ) : ( +
+ + {records.map(r => ( + setSelected(r)} + /> + ))} +
+ ); + return ( -
- - -
- {selected && selectedDetail ? ( - - ) : ( -
- 📈 -

좌측 목록에서 백테스팅 결과를 선택하세요.

-
- )} -
-
+ ) : undefined} + /> ); } diff --git a/frontend/src/components/PaperTradingPage.tsx b/frontend/src/components/PaperTradingPage.tsx index 494eff6..3b99bfa 100644 --- a/frontend/src/components/PaperTradingPage.tsx +++ b/frontend/src/components/PaperTradingPage.tsx @@ -13,20 +13,17 @@ import { import { computePaperMetrics } from '../utils/paperMetrics'; import { fmtKrw } from './TradeOrderPanel'; import TradeOrderPanel from './TradeOrderPanel'; -import type { TradeOrderFillRequest } from '../types'; -import PaperMiniChart from './paper/PaperMiniChart'; -import PaperIndicatorPanel from './paper/PaperIndicatorPanel'; +import type { Theme, TradeOrderFillRequest } from '../types'; +import PaperAnalysisChart from './paper/PaperAnalysisChart'; +import PaperLeftStrategyTab from './paper/PaperLeftStrategyTab'; +import PaperLeftSettingsTab from './paper/PaperLeftSettingsTab'; import PaperCompactOrderbook from './paper/PaperCompactOrderbook'; -import type { Theme } from '../types'; - -const COIN_COLORS: Record = { - BTC: '#f7931a', ETH: '#627eea', XRP: '#23292f', SOL: '#9945ff', - DOGE: '#c2a633', ADA: '#0033ad', DOT: '#e6007a', AVAX: '#e84142', -}; -const DONUT_COLORS = ['#3f7ef5', '#22c55e', '#a78bfa', '#fbbf24', '#38bdf8', '#64748b']; +import PaperSplitPanel from './paper/PaperSplitPanel'; +import BuilderPageShell from './layout/BuilderPageShell'; type HistoryTab = 'open' | 'recent'; -type RightTab = 'trade' | 'orderbook'; +type RightTab = 'trade' | 'orderbook' | 'history'; +type LeftTab = 'strategy' | 'settings'; interface Props { theme?: Theme; @@ -63,10 +60,6 @@ function coinCode(symbol: string): string { return symbol.replace(/^KRW-/, ''); } -function coinIcon(code: string): string { - return code.slice(0, 1); -} - const PaperTradingPage: React.FC = ({ theme = 'dark', tickers, @@ -82,6 +75,7 @@ const PaperTradingPage: React.FC = ({ const [selectedMarket, setSelectedMarket] = useState(defaultMarket); const [historyTab, setHistoryTab] = useState('recent'); const [rightTab, setRightTab] = useState('trade'); + const [leftTab, setLeftTab] = useState('strategy'); const [fillBuy, setFillBuy] = useState(null); const [fillSell, setFillSell] = useState(null); const orderAnchorRef = useRef(null); @@ -126,7 +120,6 @@ const PaperTradingPage: React.FC = ({ const metrics = useMemo(() => computePaperMetrics(trades, summary), [trades, summary]); const s = summary; - const retUp = (s?.totalReturnPct ?? 0) >= 0; const tradePrice = tickers?.get(selectedMarket)?.tradePrice ?? null; const posQty = useMemo(() => { @@ -134,29 +127,6 @@ const PaperTradingPage: React.FC = ({ return p?.quantity ?? 0; }, [s?.positions, selectedMarket]); - const allocation = useMemo(() => { - if (!s) return [] as { label: string; value: number; pct: number }[]; - const items: { label: string; value: number }[] = []; - if (s.cashBalance > 0) items.push({ label: 'KRW', value: s.cashBalance }); - s.positions.forEach(p => { - const v = p.evalAmount ?? p.quantity * (p.markPrice ?? p.avgPrice); - if (v > 0) items.push({ label: coinCode(p.symbol), value: v }); - }); - const total = items.reduce((a, x) => a + x.value, 0) || 1; - return items.map(x => ({ ...x, pct: (x.value / total) * 100 })); - }, [s]); - - const donutStyle = useMemo(() => { - if (!allocation.length) return { background: '#1f2335' }; - let acc = 0; - const stops = allocation.map((a, i) => { - const start = acc; - acc += a.pct; - return `${DONUT_COLORS[i % DONUT_COLORS.length]} ${start}% ${acc}%`; - }); - return { background: `conic-gradient(${stops.join(', ')})` }; - }, [allocation]); - const handleObPick = useCallback((price: number, rowType: 'ask' | 'bid') => { const side = rowType === 'ask' ? 'buy' : 'sell'; fillSeqRef.current += 1; @@ -176,178 +146,170 @@ const PaperTradingPage: React.FC = ({ if (res) { setSummary(res); setTrades([]); } }, []); - if (initialLoading) { - return
모의투자 대시보드 로딩…
; - } + const rightTabs = ( +
+ + + +
+ ); + + const leftTabs = ( +
+ + +
+ ); return ( -
-
-

모의투자

-
- - -
-
- - {!s?.enabled && ( -
모의투자가 꺼져 있습니다. 설정에서 활성화하세요.
+ + + + )} - -
+ banner={!s?.enabled ? ( +
모의투자가 꺼져 있습니다. 설정에서 활성화하세요.
+ ) : undefined} + leftTabs={leftTabs} + left={leftTab === 'strategy' ? ( + + ) : ( + { setSummary(null); setTrades([]); void loadData(); }} + /> + )} + centerHead={( + {coinCode(selectedMarket)} / KRW + )} + center={( +
+ +
+ )} + rightTabs={rightTabs} + right={( +
+ {rightTab === 'trade' ? ( + { onPaperOrderFilled?.(); void loadData(); }} + /> + )} + bottom={( + { onPaperOrderFilled?.(); void loadData(); }} + /> + )} + /> + ) : rightTab === 'orderbook' ? ( + + )} + bottom={( + + )} + /> + ) : ( + + + +
+ )} + bottom={historyTab === 'open' ? ( +

미체결 주문 없음 (모의투자 즉시 체결)

+ ) : ( + + + + + + {trades.slice(0, 20).map(t => ( + + + + + + + + ))} + {!trades.length && } + +
시간자산유형가격상태
{t.createdAt?.slice(11, 19) ?? '—'}{coinCode(t.symbol)}{t.side === 'BUY' ? '매수' : '매도'}{fmtKrw(t.price)}체결
체결 내역 없음
+ )} + /> + )} +
+ )} + footerLabel="성과 지표" + footer={(
- - -
- - -
-
- - -
-
- - -
- + )} + /> ); }; diff --git a/frontend/src/components/SplashScreen.tsx b/frontend/src/components/SplashScreen.tsx index 834e0ae..cd63ad7 100644 --- a/frontend/src/components/SplashScreen.tsx +++ b/frontend/src/components/SplashScreen.tsx @@ -1,11 +1,14 @@ /** - * 서비스 진입 스플래시 — 로그인 또는 게스트 모드로 메인 화면 진입 + * 서비스 진입 스플래시 — 글래스모피즘 로그인 */ import React, { useState } from 'react'; import { loginUser, type LoginResponse } from '../utils/backendApi'; +import SplashChartBackground from './splash/SplashChartBackground'; +import '../styles/splashScreen.css'; const DEFAULT_USERNAME = 'admin'; const DEFAULT_PASSWORD = 'admin'; +const APP_VERSION = '1.0.3'; interface Props { onLoginSuccess: (res: LoginResponse) => void; @@ -35,118 +38,50 @@ const SplashScreen: React.FC = ({ onLoginSuccess, onGuest }) => { return (
-
- - - - - - - - - - - + +
+
+
-
-
-
- - - - -
-

GoldenChart

-

- 암호화폐 실시간 차트 · 투자전략 · 백테스팅 · 모의투자 -

-
+
+
+

GoldenChart

-
    -
  • 📈 업비트 연동 실시간 캔들·호가
  • -
  • ⚙️ 커스텀 전략 & 백테스트
  • -
  • 🔔 매매 시그널 알림
  • -
  • 🛡️ 역할별 메뉴·기능 권한 (관리자 설정)
  • -
- -
-

시작하기

-

- 로그인하면 계정 설정이 기기 간 공유됩니다. 게스트 모드는 제한된 메뉴로 바로 체험할 수 있습니다. -

-
- - -
+ setUsername(e.target.value)} + disabled={loading} + placeholder="ID" + /> + setPassword(e.target.value)} + disabled={loading} + placeholder="••••••••" + /> {error &&

{error}

} -
- - -
+
-

- 기본 관리자: admin / admin · 권한은 설정 → 관리자 설정에서 변경 + +

+ 버전: {APP_VERSION} | Core Engine: Ta4j & Spring Boot

-
-
- GoldenChart Trading Platform -
+ +
); }; diff --git a/frontend/src/components/StrategyEditorPage.tsx b/frontend/src/components/StrategyEditorPage.tsx index b7063a9..67aa14b 100644 --- a/frontend/src/components/StrategyEditorPage.tsx +++ b/frontend/src/components/StrategyEditorPage.tsx @@ -90,6 +90,7 @@ export default function StrategyEditorPage({ theme, activeIndicators = [] }: Pro const [signalTab, setSignalTab] = useState<'buy' | 'sell'>('buy'); const [rightTab, setRightTab] = useState<'indicators' | 'templates'>('indicators'); const [paletteSearch, setPaletteSearch] = useState(''); + const [selectedPaletteKey, setSelectedPaletteKey] = useState(null); const [stratName, setStratName] = useState(''); const [stratDesc, setStratDesc] = useState(''); const [isSaving, setIsSaving] = useState(false); @@ -575,6 +576,13 @@ export default function StrategyEditorPage({ theme, activeIndicators = [] }: Pro const match = (label: string, desc?: string) => !q || label.toLowerCase().includes(q) || (desc?.toLowerCase().includes(q) ?? false); + const paletteKey = (type: 'operator' | 'indicator', value: string) => `${type}:${value}`; + const selectPalette = (type: 'operator' | 'indicator', value: string) => { + setSelectedPaletteKey(paletteKey(type, value)); + }; + const isPaletteSelected = (type: 'operator' | 'indicator', value: string) => + selectedPaletteKey === paletteKey(type, value); + return (
{saveToast && ( @@ -683,7 +691,7 @@ export default function StrategyEditorPage({ theme, activeIndicators = [] }: Pro setDeleteOpen(true); }} > - + -
-
+
+
+
+
+
+
+
+
{saveOpen && ( diff --git a/frontend/src/components/layout/BuilderPageShell.tsx b/frontend/src/components/layout/BuilderPageShell.tsx new file mode 100644 index 0000000..9f80175 --- /dev/null +++ b/frontend/src/components/layout/BuilderPageShell.tsx @@ -0,0 +1,182 @@ +import React, { useMemo, useRef, useState } from 'react'; +import type { Theme } from '../../types'; +import { readStoredSize, storeSize, usePanelResize } from '../strategyEditor/usePanelResize'; +import '../../styles/strategyEditorTheme.css'; +import '../../styles/builderPageShell.css'; + +const LEFT_MIN = 220; +const LEFT_MAX = 520; +const LEFT_DEFAULT = 280; +const FOOTER_MIN = 88; +const FOOTER_MAX = 420; +const FOOTER_DEFAULT = 140; + +export interface BuilderPageShellProps { + theme: Theme; + title: string; + subtitle?: string; + headerActions?: React.ReactNode; + banner?: React.ReactNode; + leftTitle?: string; + leftTabs?: React.ReactNode; + leftActions?: React.ReactNode; + left: React.ReactNode; + centerHead?: React.ReactNode; + center: React.ReactNode; + rightTitle?: string; + rightTabs?: React.ReactNode; + right?: React.ReactNode; + footerLabel?: string; + footer?: React.ReactNode; + leftStorageKey?: string; + footerStorageKey?: string; + loading?: boolean; + loadingText?: string; +} + +export default function BuilderPageShell({ + theme, + title, + subtitle, + headerActions, + banner, + leftTitle, + leftTabs, + leftActions, + left, + centerHead, + center, + rightTitle, + rightTabs, + right, + footerLabel, + footer, + leftStorageKey = 'bps-left-width', + footerStorageKey = 'bps-footer-height', + loading = false, + loadingText = '로딩 중…', +}: BuilderPageShellProps) { + const [leftWidth, setLeftWidth] = useState(() => readStoredSize(leftStorageKey, LEFT_DEFAULT)); + const [footerHeight, setFooterHeight] = useState(() => readStoredSize(footerStorageKey, FOOTER_DEFAULT)); + const leftWidthRef = useRef(leftWidth); + const footerHeightRef = useRef(footerHeight); + leftWidthRef.current = leftWidth; + footerHeightRef.current = footerHeight; + + const onLeftSplitter = usePanelResize( + 'vertical', + setLeftWidth, + () => leftWidthRef.current, + LEFT_MIN, + LEFT_MAX, + v => storeSize(leftStorageKey, v), + ); + + const onFooterSplitter = usePanelResize( + 'horizontal', + setFooterHeight, + () => footerHeightRef.current, + FOOTER_MIN, + FOOTER_MAX, + v => storeSize(footerStorageKey, v), + ); + + const bodyStyle = useMemo(() => ({ + '--bps-footer-height': `${footerHeight}px`, + }) as React.CSSProperties, [footerHeight]); + + if (loading) { + return ( +
+
+
+

{title}

+ {subtitle && {subtitle}} +
+
+
{loadingText}
+
+ ); + } + + return ( +
+
+
+

{title}

+ {subtitle && {subtitle}} +
+ {headerActions &&
{headerActions}
} +
+ + {banner} + +
+ + +
+ +
+
+
+
+ {centerHead &&
{centerHead}
} +
{center}
+
+ + {footer && ( + <> +
+
+ {footerLabel &&
{footerLabel}
} +
{footer}
+
+ + )} +
+ + {right && ( + + )} +
+
+
+
+ ); +} diff --git a/frontend/src/components/paper/PaperAnalysisChart.tsx b/frontend/src/components/paper/PaperAnalysisChart.tsx new file mode 100644 index 0000000..a2d6077 --- /dev/null +++ b/frontend/src/components/paper/PaperAnalysisChart.tsx @@ -0,0 +1,32 @@ +import React, { useState } from 'react'; +import type { Theme, Timeframe } from '../../types'; +import PaperMiniChart from './PaperMiniChart'; +import PaperIndicatorPanel from './PaperIndicatorPanel'; + +interface Props { + market: string; + theme?: Theme; +} + +/** 모의투자 중앙 — 상단 캔들 + 하단 보조지표 (실시간 차트 레이아웃) */ +const PaperAnalysisChart: React.FC = ({ market, theme = 'dark' }) => { + const [timeframe, setTimeframe] = useState('1h'); + + return ( +
+
+ +
+
+ +
+
+ ); +}; + +export default PaperAnalysisChart; diff --git a/frontend/src/components/paper/PaperCompactOrderbook.tsx b/frontend/src/components/paper/PaperCompactOrderbook.tsx index 7802e22..3e576c5 100644 --- a/frontend/src/components/paper/PaperCompactOrderbook.tsx +++ b/frontend/src/components/paper/PaperCompactOrderbook.tsx @@ -7,6 +7,8 @@ interface Props { fillHeight?: boolean; hideHeader?: boolean; depth?: number; + /** split 패널용 — 매도/매수 호가만 */ + section?: 'all' | 'asks' | 'bids'; } function fmtPrice(p: number): string { @@ -26,6 +28,7 @@ const PaperCompactOrderbook: React.FC = memo(({ fillHeight = false, hideHeader = false, depth = 8, + section = 'all', }) => { const { orderbook } = useUpbitOrderbook(market); const rowCount = fillHeight ? Math.max(depth, 12) : depth; @@ -38,11 +41,15 @@ const PaperCompactOrderbook: React.FC = memo(({ ); const mid = bids[0]?.price ?? asks[asks.length - 1]?.price ?? 0; + const showAsks = section === 'all' || section === 'asks'; + const showBids = section === 'all' || section === 'bids'; + const showMid = section === 'all' || section === 'asks'; + return ( -
- {!hideHeader &&
호가 (Depth)
} +
+ {!hideHeader && section === 'all' &&
호가 (Depth)
}
- {asks.map(a => ( + {showAsks && asks.map(a => ( ))} -
{mid ? fmtPrice(mid) : '—'}
- {bids.map(b => ( + {showMid && section !== 'all' && ( +
{mid ? fmtPrice(mid) : '—'}
+ )} + {section === 'all' &&
{mid ? fmtPrice(mid) : '—'}
} + {showBids && bids.map(b => ( + + ); + + return ( + + ); +}; + +export default PaperLeftSettingsTab; diff --git a/frontend/src/components/paper/PaperLeftStrategyTab.tsx b/frontend/src/components/paper/PaperLeftStrategyTab.tsx new file mode 100644 index 0000000..2792dd5 --- /dev/null +++ b/frontend/src/components/paper/PaperLeftStrategyTab.tsx @@ -0,0 +1,182 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { + loadStrategies, + loadLiveStrategySettings, + saveLiveStrategySettings, + type LiveStrategySettingsDto, + type PaperSummaryDto, + type StrategyDto, +} from '../../utils/backendApi'; +import { fmtKrw } from '../TradeOrderPanel'; +import PaperSplitPanel from './PaperSplitPanel'; + +const CANDLE_TYPES = ['1m', '3m', '5m', '15m', '30m', '1h', '4h', '1d'] as const; + +interface Props { + market: string; + summary: PaperSummaryDto | null; + paperAutoTradeEnabled?: boolean; + onMarketSelect?: (market: string) => void; +} + +const PaperLeftStrategyTab: React.FC = ({ + market, + summary, + paperAutoTradeEnabled = false, + onMarketSelect, +}) => { + const [strategies, setStrategies] = useState([]); + const [settings, setSettings] = useState(null); + const [saving, setSaving] = useState(false); + + useEffect(() => { + loadStrategies().then(setStrategies).catch(() => setStrategies([])); + }, []); + + useEffect(() => { + let cancelled = false; + loadLiveStrategySettings(market) + .then(s => { if (!cancelled) setSettings(s); }) + .catch(() => { + if (!cancelled) { + setSettings({ + market, + strategyId: null, + isLiveCheck: false, + executionType: 'CANDLE_CLOSE', + positionMode: 'LONG_ONLY', + candleType: '1m', + }); + } + }); + return () => { cancelled = true; }; + }, [market]); + + const persist = useCallback(async (patch: Partial) => { + if (!settings) return; + const next = { ...settings, ...patch, market }; + const prev = settings; + setSettings(next); + setSaving(true); + try { + const saved = await saveLiveStrategySettings(next); + if (saved) setSettings(saved); + else setSettings(prev); + } catch { + setSettings(prev); + } finally { + setSaving(false); + } + }, [settings, market]); + + const selected = strategies.find(s => s.id === settings?.strategyId); + + const top = ( + <> +
+
+ {summary ? fmtKrw(summary.totalAsset) : '—'} KRW +
+
= 0 ? 'up' : 'down'}`}> + {summary ? `${(summary.totalReturnPct ?? 0) >= 0 ? '+' : ''}${summary.totalReturnPct.toFixed(2)}%` : '—'} +
+
+
+ + + {selected &&

적용: {selected.name}

} + + +
+

+ {paperAutoTradeEnabled ? '자동매매 ON' : '자동매매 OFF — 설정 탭에서 변경'} +

+ + ); + + const bottom = ( + + + + + + {(summary?.positions ?? []).length === 0 ? ( + + ) : summary!.positions.map(p => { + const code = p.symbol.replace(/^KRW-/, ''); + const up = (p.profitLoss ?? 0) >= 0; + return ( + onMarketSelect?.(p.symbol)} + > + + + + + ); + })} + +
자산수량손익
보유 없음
{code}{p.quantity.toFixed(4)} + {p.profitLoss != null ? `${up ? '+' : ''}${fmtKrw(p.profitLoss)}` : '—'} +
+ ); + + return ( + + ); +}; + +export default PaperLeftStrategyTab; diff --git a/frontend/src/components/paper/PaperMiniChart.tsx b/frontend/src/components/paper/PaperMiniChart.tsx index 69fe171..7434d84 100644 --- a/frontend/src/components/paper/PaperMiniChart.tsx +++ b/frontend/src/components/paper/PaperMiniChart.tsx @@ -9,6 +9,8 @@ interface Props { market: string; theme?: Theme; wsLabel?: string; + timeframe?: Timeframe; + onTimeframeChange?: (tf: Timeframe) => void; } function readChartTheme(container: HTMLElement | null) { @@ -25,11 +27,19 @@ function readChartTheme(container: HTMLElement | null) { }; } -const PaperMiniChart: React.FC = ({ market, theme = 'dark', wsLabel = 'WebSocket <100ms' }) => { +const PaperMiniChart: React.FC = ({ + market, + theme = 'dark', + wsLabel = 'WebSocket <100ms', + timeframe: timeframeProp, + onTimeframeChange, +}) => { const containerRef = useRef(null); const chartRef = useRef(null); const seriesRef = useRef | null>(null); - const [timeframe, setTimeframe] = useState('1h'); + const [timeframeInternal, setTimeframeInternal] = useState('1h'); + const timeframe = timeframeProp ?? timeframeInternal; + const setTimeframe = onTimeframeChange ?? setTimeframeInternal; const [loading, setLoading] = useState(true); const applyTheme = useCallback((chart: IChartApi, series: ISeriesApi<'Candlestick'>) => { diff --git a/frontend/src/components/paper/PaperSplitPanel.tsx b/frontend/src/components/paper/PaperSplitPanel.tsx new file mode 100644 index 0000000..6a95aac --- /dev/null +++ b/frontend/src/components/paper/PaperSplitPanel.tsx @@ -0,0 +1,25 @@ +import React from 'react'; + +interface Props { + topTitle?: string; + bottomTitle?: string; + top: React.ReactNode; + bottom: React.ReactNode; + className?: string; +} + +/** 좌·우 패널 상·하단 카드 분리 */ +const PaperSplitPanel: React.FC = ({ topTitle, bottomTitle, top, bottom, className = '' }) => ( +
+
+ {topTitle &&
{topTitle}
} +
{top}
+
+
+ {bottomTitle &&
{bottomTitle}
} +
{bottom}
+
+
+); + +export default PaperSplitPanel; diff --git a/frontend/src/components/splash/SplashChartBackground.tsx b/frontend/src/components/splash/SplashChartBackground.tsx new file mode 100644 index 0000000..a6fc7e1 --- /dev/null +++ b/frontend/src/components/splash/SplashChartBackground.tsx @@ -0,0 +1,214 @@ +import React, { useMemo } from 'react'; + +interface Candle { + x: number; + o: number; + h: number; + l: number; + c: number; +} + +interface Point { + x: number; + y: number; +} + +const W = 1400; +const H = 820; +const CANDLE_COUNT = 110; +const DISPLACE = 26; + +/** Y축: 위로 갈수록(y↓) 가격 상승 — 좌하→우상 우상향 */ +function buildUpwardCandles(): Candle[] { + const startX = 48; + const step = (W - 96) / (CANDLE_COUNT - 1); + const yStart = H - 120; + const yEnd = 100; + const out: Candle[] = []; + + for (let i = 0; i < CANDLE_COUNT; i++) { + const t = i / (CANDLE_COUNT - 1); + const trend = yStart + (yEnd - yStart) * Math.pow(t, 0.92); + const ripple = Math.sin(i * 0.38) * 14 + Math.cos(i * 0.16) * 9 + Math.sin(i * 0.07) * 5; + const o = i === 0 ? trend + 8 : out[i - 1].c; + const c = trend + ripple * 0.55; + const bodyTop = Math.min(o, c); + const bodyBot = Math.max(o, c); + const wick = 4 + (i % 3); + out.push({ + x: startX + i * step, + o, + c, + h: bodyTop - wick, + l: bodyBot + wick, + }); + } + return out; +} + +function ichimokuMid(candles: Candle[], i: number, period: number): number { + const from = Math.max(0, i - period + 1); + const slice = candles.slice(from, i + 1); + const highest = Math.min(...slice.map(c => c.h)); + const lowest = Math.max(...slice.map(c => c.l)); + return (highest + lowest) / 2; +} + +function linePath(points: Point[]): string { + if (points.length < 2) return ''; + return points.map((p, i) => `${i === 0 ? 'M' : 'L'}${p.x.toFixed(1)},${p.y.toFixed(1)}`).join(' '); +} + +function buildKumoRegions(senkouA: Point[], senkouB: Point[]): { bullPaths: string[]; bearPaths: string[] } { + const n = Math.min(senkouA.length, senkouB.length); + const bullPaths: string[] = []; + const bearPaths: string[] = []; + let runStart = 0; + let runBull = senkouA[0].y <= senkouB[0].y; + + const closeRun = (end: number) => { + if (end - runStart < 2) return; + const sliceA = senkouA.slice(runStart, end); + const sliceB = senkouB.slice(runStart, end); + const fwdA = sliceA.map(p => `${p.x},${p.y}`).join(' L'); + const backB = [...sliceB].reverse().map(p => `${p.x},${p.y}`).join(' L'); + const d = `M${fwdA} L${backB} Z`; + if (runBull) bullPaths.push(d); + else bearPaths.push(d); + }; + + for (let i = 1; i < n; i++) { + const bull = senkouA[i].y <= senkouB[i].y; + if (bull !== runBull) { + closeRun(i); + runStart = i; + runBull = bull; + } + } + closeRun(n); + return { bullPaths, bearPaths }; +} + +export default function SplashChartBackground() { + const model = useMemo(() => { + const candles = buildUpwardCandles(); + + const tenkan: Point[] = candles.map((c, i) => ({ + x: c.x, + y: ichimokuMid(candles, i, 9), + })); + const kijun: Point[] = candles.map((c, i) => ({ + x: c.x, + y: ichimokuMid(candles, i, 26), + })); + + const senkouA: Point[] = []; + const senkouB: Point[] = []; + for (let i = 0; i < candles.length; i++) { + const future = candles[Math.min(i + DISPLACE, candles.length - 1)]; + senkouA.push({ + x: future.x, + y: (tenkan[i].y + kijun[i].y) / 2, + }); + senkouB.push({ + x: future.x, + y: ichimokuMid(candles, i, 52), + }); + } + + const { bullPaths, bearPaths } = buildKumoRegions(senkouA, senkouB); + + const volumes = candles.map((c, i) => { + const body = Math.abs(c.c - c.o); + const vh = 10 + body * 1.4 + (Math.sin(i * 0.4) + 1) * 8; + return { x: c.x, h: vh }; + }); + + return { candles, tenkan, kijun, senkouA, senkouB, bullPaths, bearPaths, volumes }; + }, []); + + const gridY = useMemo(() => { + const lines: number[] = []; + for (let y = 80; y <= H - 60; y += 70) lines.push(y); + return lines; + }, []); + + return ( + + + + + + + + + + + + + + + {gridY.map(y => ( + + ))} + + {/* 거래량 */} + + {model.volumes.map((v, i) => ( + + ))} + + + {/* 일목 구름 */} + + {model.bearPaths.map((d, i) => ( + + ))} + {model.bullPaths.map((d, i) => ( + + ))} + + + {/* 일목 선행·전환·기준 */} + + + + + + {/* 캔들 */} + + {model.candles.map((c, i) => { + const up = c.c < c.o; + const top = Math.min(c.o, c.c); + const bodyH = Math.max(Math.abs(c.c - c.o), 3.5); + return ( + + + + + ); + })} + + + ); +} diff --git a/frontend/src/components/strategyEditor/PaletteChip.tsx b/frontend/src/components/strategyEditor/PaletteChip.tsx index 5918f65..9d70992 100644 --- a/frontend/src/components/strategyEditor/PaletteChip.tsx +++ b/frontend/src/components/strategyEditor/PaletteChip.tsx @@ -13,25 +13,51 @@ interface Props { desc?: string; color?: string; period?: string; + selected?: boolean; + onSelect?: () => void; onAdd: () => void; } -export default function PaletteChip({ type, value, label, desc, color, period, onAdd }: Props) { +export default function PaletteChip({ + type, value, label, desc, color, period, selected = false, onSelect, onAdd, +}: Props) { const onDragStart = (e: React.DragEvent) => { const payload: PaletteDragPayload = { type, value, label }; e.dataTransfer.setData('application/json', JSON.stringify(payload)); e.dataTransfer.effectAllowed = 'copy'; }; + const handleClick = () => { + if (onSelect) onSelect(); + else onAdd(); + }; + + const handleDoubleClick = (e: React.MouseEvent) => { + e.preventDefault(); + onAdd(); + }; + + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key !== 'Enter' && e.key !== ' ') return; + e.preventDefault(); + if (onSelect) { + if (selected) onAdd(); + else onSelect(); + } else { + onAdd(); + } + }; + return (
{ if (e.key === 'Enter' || e.key === ' ') onAdd(); }} + onKeyDown={handleKeyDown} >
{label.slice(0, 1)} diff --git a/frontend/src/components/strategyEditor/StrategyEditorCanvas.tsx b/frontend/src/components/strategyEditor/StrategyEditorCanvas.tsx index f61ca32..0c01031 100644 --- a/frontend/src/components/strategyEditor/StrategyEditorCanvas.tsx +++ b/frontend/src/components/strategyEditor/StrategyEditorCanvas.tsx @@ -1,4 +1,4 @@ -import React, { useCallback, useEffect, useMemo, useRef } from 'react'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { ReactFlow, Background, @@ -54,6 +54,11 @@ import { StrategyFlowEdge } from './StrategyFlowEdge'; import { edgeDisconnectRef, edgeBindingUpdateRef, layoutFlushRef } from './strategyEditorCallbacks'; import { MultiSelectionDeleteButton } from './MultiSelectionDeleteButton'; import { getMinimapColors } from './minimapTheme'; +import { + loadCanvasInteractionMode, + saveCanvasInteractionMode, + type StrategyCanvasInteractionMode, +} from '../../utils/strategyCanvasInteractionStorage'; import type { FlowLayoutChangePayload } from '../../utils/strategyEditorLayoutStorage'; export type FlowLayoutSeed = { @@ -190,6 +195,14 @@ function StrategyEditorCanvasInner({ const structureKeyRef = useRef(null); const orphanKeyRef = useRef(null); const layoutEmitTimerRef = useRef(null); + const [interactionMode, setInteractionMode] = useState( + () => loadCanvasInteractionMode(), + ); + + const handleInteractionModeChange = useCallback((mode: StrategyCanvasInteractionMode) => { + setInteractionMode(mode); + saveCanvasInteractionMode(mode); + }, []); const [nodes, setNodes, onNodesChangeBase] = useNodesState([]); const [edges, setEdges, onEdgesChange] = useEdgesState([]); @@ -839,7 +852,7 @@ function StrategyEditorCanvasInner({ return (
@@ -865,9 +878,9 @@ function StrategyEditorCanvasInner({ nodesDraggable nodesConnectable elementsSelectable - selectionOnDrag + selectionOnDrag={interactionMode === 'select'} selectionMode={SelectionMode.Partial} - panOnDrag={[1, 2]} + panOnDrag={interactionMode === 'pan' ? true : [1, 2]} selectNodesOnDrag={false} multiSelectionKeyCode={['Meta', 'Control', 'Shift']} deleteKeyCode={null} @@ -893,8 +906,28 @@ function StrategyEditorCanvasInner({ /> 전략 빌더 +
+ + +
- 다중 선택 × 일괄삭제 · 연결선 × 끊기 · Del 삭제 + {interactionMode === 'pan' + ? '빈 영역 드래그 → 그래프 이동 · 노드 드래그 → 위치 변경' + : '빈 영역 드래그 → 다중 선택 · Del 삭제 · 연결선 × 끊기'} {orphans.length > 0 ? ` · 미연결 ${orphans.length}` : ''}
diff --git a/frontend/src/styles/backtestDashboard.css b/frontend/src/styles/backtestDashboard.css index 0ee4728..0093098 100644 --- a/frontend/src/styles/backtestDashboard.css +++ b/frontend/src/styles/backtestDashboard.css @@ -1,6 +1,7 @@ -/* 백테스팅 대시보드 (btd-*) — 앱 테마 변수 + 금색 포인트 */ +/* 백테스팅 대시보드 (btd-*) — BuilderPageShell 내부 콘텐츠 스타일 */ -.btd-page { +.bps-page, +.se-page { --btd-gold: #c9a227; --btd-gold-light: #e8c547; --btd-gold-dark: #b8860b; @@ -8,105 +9,109 @@ --btd-gold-border-strong: rgba(201, 162, 39, 0.35); --btd-gold-bg: rgba(201, 162, 39, 0.08); --btd-gold-bg-hover: rgba(201, 162, 39, 0.14); - - display: flex; - height: calc(100vh - var(--tmb-h, 46px)); - overflow: hidden; - background: var(--bg); - color: var(--text); - font-family: var(--font, 'Noto Sans KR', sans-serif); } -.app.light .btd-page, -html.theme-light .btd-page { +.se-page--light { --btd-gold-border: rgba(184, 134, 11, 0.28); --btd-gold-border-strong: rgba(184, 134, 11, 0.45); --btd-gold-bg: rgba(201, 162, 39, 0.1); --btd-gold-bg-hover: rgba(201, 162, 39, 0.16); } -/* ── 좌측 타임라인 사이드바 (~23%) ── */ -.btd-sidebar { - width: 23%; - min-width: 220px; - max-width: 300px; - flex-shrink: 0; - display: flex; - flex-direction: column; - border-right: 1px solid var(--btd-gold-border); - background: var(--bg2); - overflow: hidden; -} +.bps-center-content .btd-empty span { font-size: 48px; opacity: 0.35; } +.btd-sidebar-hint { font-size: 0.72rem; opacity: 0.75; line-height: 1.6; } -.btd-sidebar-head { - display: flex; - align-items: center; - gap: 8px; - padding: 12px 12px 10px; - border-bottom: 1px solid var(--btd-gold-border); - flex-shrink: 0; -} - -.btd-sidebar-bar { - width: 3px; - height: 18px; - border-radius: 2px; - background: linear-gradient(180deg, var(--btd-gold-light), var(--btd-gold-dark)); - flex-shrink: 0; -} - -.btd-sidebar-title { - margin: 0; - flex: 1; - font-size: 13px; - font-weight: 800; - color: var(--text); - letter-spacing: -0.2px; -} - -.btd-sidebar-actions { - display: flex; - gap: 4px; -} - -.btd-icon-btn { - width: 28px; - height: 28px; - border-radius: 6px; - border: 1px solid var(--btd-gold-border); - background: var(--btd-gold-bg); - color: var(--btd-gold); - font-size: 13px; - cursor: pointer; - display: flex; - align-items: center; - justify-content: center; -} -.btd-icon-btn:hover:not(:disabled) { background: var(--btd-gold-bg-hover); } -.btd-icon-btn:disabled { opacity: 0.35; cursor: not-allowed; } -.btd-icon-btn--danger { color: #ef4444; border-color: rgba(239, 68, 68, 0.3); } - -.btd-sidebar-empty { - flex: 1; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - padding: 24px; - text-align: center; - color: var(--text3); - font-size: 12px; - gap: 8px; -} -.btd-sidebar-hint { font-size: 11px; opacity: 0.75; line-height: 1.6; } - -.btd-timeline { +.bps-panel-body .btd-timeline { position: relative; flex: 1; overflow-y: auto; - padding: 12px 10px 16px 22px; + padding: 4px 2px 8px 14px; + min-height: 120px; } +.bps-panel-body .btd-sidebar-empty { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 24px 12px; + text-align: center; + color: var(--se-text-dim); + font-size: 0.78rem; + gap: 8px; +} + +.bps-center-content .btd-dashboard { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + gap: 10px; + padding: 0; + overflow: hidden; +} + +.bps-center-content .btd-empty { + flex: 1; + min-height: 240px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 10px; + color: var(--se-text-dim); + font-size: 0.85rem; +} + +.btd-meta { + display: flex; + flex-direction: column; + gap: 8px; +} + +.btd-meta-value { + font-size: 0.82rem; + font-weight: 700; + color: var(--se-text); +} + +.btd-footer-stats { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 10px; +} + +.btd-footer-stat { + display: flex; + flex-direction: column; + gap: 4px; + padding: 8px 10px; + border-radius: 8px; + border: 1px solid var(--se-border); + background: color-mix(in srgb, var(--se-bg-elevated) 88%, transparent); +} + +.btd-footer-stat-label { + font-size: 0.68rem; + color: var(--se-text-muted); +} + +.btd-footer-stat-value { + font-size: 1rem; + font-weight: 800; + color: var(--se-text); +} + +.btd-footer-stat-value.up { color: var(--se-up); } +.btd-footer-stat-value.down { color: var(--se-down); } + +@media (max-width: 1200px) { + .btd-footer-stats { grid-template-columns: repeat(2, 1fr); } +} + +/* legacy page layout removed — shell handles structure */ + .btd-timeline-line { position: absolute; left: 28px; @@ -207,39 +212,6 @@ html.theme-light .btd-page { border: 1px solid rgba(239, 68, 68, 0.25); } -/* ── 우측 메인 ── */ -.btd-main { - flex: 1; - min-width: 0; - min-height: 0; - overflow: hidden; - display: flex; - flex-direction: column; - background: var(--bg); -} - -.btd-dashboard { - flex: 1; - min-height: 0; - display: flex; - flex-direction: column; - gap: 10px; - padding: 12px 16px 10px; - overflow: hidden; -} - -.btd-empty { - flex: 1; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - gap: 10px; - color: var(--text3); - font-size: 13px; -} -.btd-empty span { font-size: 48px; opacity: 0.35; } - /* ── 섹션 헤더 (금색 바) ── */ .btd-section-head { display: flex; diff --git a/frontend/src/styles/builderPageShell.css b/frontend/src/styles/builderPageShell.css new file mode 100644 index 0000000..c8d03b7 --- /dev/null +++ b/frontend/src/styles/builderPageShell.css @@ -0,0 +1,368 @@ +/* Builder shell — 전략편집기(se-page)와 동일한 3열 + 하단 패널 레이아웃 */ + +.bps-page { + display: flex; + flex-direction: column; + flex: 1; + height: calc(100vh - var(--tmb-h, 44px)); + min-height: 0; + background: var(--se-bg); + color: var(--se-text); + overflow: hidden; + position: relative; +} + +.bps-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 18px; + border-bottom: 1px solid var(--se-header-border); + background: var(--se-header-bg); + flex-shrink: 0; +} + +.bps-header-left { display: flex; align-items: baseline; gap: 10px; } + +.bps-title { + margin: 0; + font-size: 1.1rem; + font-weight: 800; + background: var(--se-title-gradient); + -webkit-background-clip: text; + background-clip: text; + color: transparent; +} + +.bps-subtitle { font-size: 0.72rem; color: var(--se-text-muted); } + +.bps-header-actions { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; } + +.bps-btn { + border: 1px solid var(--se-border); + background: var(--se-btn-bg); + color: var(--se-text); + border-radius: 8px; + padding: 7px 14px; + font-size: 0.82rem; + cursor: pointer; + transition: box-shadow 0.15s, border-color 0.15s; +} +.bps-btn:hover { border-color: color-mix(in srgb, var(--se-accent) 50%, transparent); } +.bps-btn--ghost { background: transparent; } +.bps-btn--gold { + background: var(--se-btn-gold-bg); + border-color: var(--se-btn-gold-border); + color: var(--se-btn-gold-text); + font-weight: 700; +} +.bps-btn--danger { border-color: color-mix(in srgb, var(--se-danger) 50%, transparent); color: var(--se-danger); } +.bps-btn:disabled { opacity: 0.35; cursor: not-allowed; } + +.bps-banner { + flex-shrink: 0; + margin: 0 12px; + padding: 8px 12px; + border-radius: 8px; + font-size: 0.78rem; +} +.bps-banner--warn { + background: color-mix(in srgb, #fbbf24 12%, transparent); + border: 1px solid color-mix(in srgb, #fbbf24 35%, transparent); + color: #fbbf24; +} + +.bps-body { + flex: 1; + min-height: 0; + display: flex; + min-width: 0; +} + +.bps-left { + flex-shrink: 0; + display: flex; + flex-direction: column; + min-height: 0; + background: var(--se-panel-shell-bg); + padding: 10px; +} + +.bps-panel { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + padding: 14px 12px 12px; + border-radius: 14px; + background: var(--se-panel-card-bg); + border: 1px solid var(--se-panel-card-border); + box-shadow: var(--se-panel-card-shadow), inset 0 1px 0 color-mix(in srgb, var(--se-text) 4%, transparent); +} + +.bps-panel-head { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 12px; + padding-bottom: 10px; + border-bottom: 1px solid var(--se-border); + flex-shrink: 0; +} + +.bps-panel-title { + margin: 0; + flex: 1; + font-size: 0.88rem; + font-weight: 800; + color: var(--se-text); +} + +.bps-panel-actions { display: flex; gap: 4px; flex-shrink: 0; } + +.bps-panel-body { + flex: 1; + min-height: 0; + overflow: auto; + display: flex; + flex-direction: column; + gap: 10px; +} + +.bps-splitter { + flex-shrink: 0; + background: var(--se-splitter-bg); + transition: background 0.12s, box-shadow 0.12s; + touch-action: none; + z-index: 6; +} +.bps-splitter:hover, +.bps-splitter.bps-splitter--active { + background: var(--se-splitter-active); + box-shadow: 0 0 10px color-mix(in srgb, var(--se-accent) 20%, transparent); +} +.bps-splitter--v { width: 5px; cursor: col-resize; } +.bps-splitter--h { + height: 5px; + margin: 0 10px; + border-radius: 3px; + cursor: row-resize; + flex-shrink: 0; +} + +.bps-center { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + min-height: 0; + background: var(--se-center-bg); +} + +.bps-main { + flex: 1; + min-width: 0; + min-height: 0; + display: flex; + padding-bottom: 10px; +} + +.bps-main-row { + flex: 1; + min-height: 0; + min-width: 0; + display: flex; + overflow: hidden; +} + +.bps-center-work { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.bps-center-head { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 12px 0; + flex-shrink: 0; + gap: 10px; +} + +.bps-center-head-title { + font-size: 0.82rem; + font-weight: 700; + color: var(--se-text-muted); +} + +.bps-center-content { + flex: 1; + min-height: 0; + overflow: auto; + padding: 8px 12px 12px; + display: flex; + flex-direction: column; +} + +.bps-right { + flex: 0 0 280px; + width: 280px; + border-left: 1px solid var(--se-border); + display: flex; + flex-direction: column; + min-height: 0; + align-self: stretch; + background: var(--se-palette-bg); + overflow: hidden; +} + +.bps-right-tabs { + display: flex; + border-bottom: 1px solid var(--se-border); + flex-shrink: 0; +} + +.bps-left-tabs { + display: flex; + border-bottom: 1px solid var(--se-border); + flex-shrink: 0; + margin: -14px -12px 0; + padding: 0 4px; +} + +.bps-left-tab, +.bps-right-tab { + flex: 1; + padding: 10px 6px; + border: none; + background: transparent; + color: var(--se-text-muted); + font-size: 0.76rem; + cursor: pointer; +} +.bps-left-tab--on, +.bps-right-tab--on { + color: var(--se-tab-active); + box-shadow: inset 0 -2px 0 var(--se-tab-active); +} + +.bps-panel-body--tabs { + padding-top: 10px; +} + +.bps-right-body { + flex: 1; + min-height: 0; + overflow: hidden; + padding: 10px; + display: flex; + flex-direction: column; +} + +.bps-footer { + flex-shrink: 0; + display: flex; + flex-direction: column; + margin: 0 12px 0; + border: 1px solid var(--se-terminal-border); + border-radius: 10px; + background: var(--se-terminal-bg); + overflow: hidden; + min-height: 88px; +} + +.bps-footer-label { + flex-shrink: 0; + font-size: 0.62rem; + font-weight: 800; + letter-spacing: 0.14em; + color: var(--se-terminal-label); + padding: 6px 12px; + border-bottom: 1px solid var(--se-border); +} + +.bps-footer-body { + flex: 1; + min-height: 0; + overflow: auto; + padding: 10px 12px; +} + +.bps-list-item { + display: flex; + align-items: center; + gap: 8px; + width: 100%; + padding: 10px 12px; + border-radius: 10px; + border: 1px solid var(--se-strat-item-border); + background: var(--se-strat-item-bg); + box-shadow: var(--se-strat-item-shadow); + color: var(--se-text); + cursor: pointer; + text-align: left; + transition: border-color 0.12s, box-shadow 0.12s, background 0.12s; +} +.bps-list-item:hover { + box-shadow: var(--se-strat-item-hover-shadow); + background: var(--se-item-hover-bg); +} +.bps-list-item--sel { + background: var(--se-item-selected-bg); + border-color: var(--se-item-selected-border); + box-shadow: 0 0 0 1px color-mix(in srgb, var(--se-gold) 20%, transparent); +} +.bps-list-item-name { + flex: 1; + min-width: 0; + font-size: 0.82rem; + font-weight: 700; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.bps-list-item--sel .bps-list-item-name { color: var(--se-item-selected-text); } +.bps-list-item-meta { font-size: 0.68rem; color: var(--se-text-muted); } +.bps-list-item-badge { + flex-shrink: 0; + font-size: 0.62rem; + font-weight: 700; + padding: 2px 6px; + border-radius: 999px; +} +.bps-list-item-badge.up { color: var(--se-up); background: color-mix(in srgb, var(--se-up) 12%, transparent); } +.bps-list-item-badge.down { color: var(--se-down); background: color-mix(in srgb, var(--se-down) 12%, transparent); } + +.bps-empty { + font-size: 0.78rem; + color: var(--se-text-dim); + text-align: center; + padding: 24px 12px; + line-height: 1.6; +} + +.bps-card { + border: 1px solid var(--se-border); + border-radius: 10px; + background: color-mix(in srgb, var(--se-bg-elevated) 88%, transparent); + overflow: hidden; +} +.bps-card-head { + padding: 8px 10px 0; + font-size: 0.72rem; + font-weight: 700; + color: var(--se-text-muted); +} +.bps-card-body { padding: 8px 10px 10px; } + +.bps-loading { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + color: var(--se-text-muted); + font-size: 0.85rem; +} diff --git a/frontend/src/styles/paperDashboard.css b/frontend/src/styles/paperDashboard.css index 5c348e1..a3ef9b6 100644 --- a/frontend/src/styles/paperDashboard.css +++ b/frontend/src/styles/paperDashboard.css @@ -1,67 +1,296 @@ -/* 모의투자 대시보드 (ptd-) — 전체 화면 고정 레이아웃, 앱 테마 변수 연동 */ -.ptd-page { +/* 모의투자 — 좌측 패널·우측 탭·성과 지표 하단선 정렬 */ + +.bps-page .bps-main { + padding: 0 10px 10px 0; + box-sizing: border-box; +} + +.bps-page .bps-right { + border: 1px solid var(--se-panel-card-border); + border-radius: 14px; + box-shadow: var(--se-panel-card-shadow), inset 0 1px 0 color-mix(in srgb, var(--se-text) 4%, transparent); +} + +.bps-page .bps-footer { + margin: 0 12px 0; +} + +.bps-center-content .ptd-center-fill { + height: 100%; + min-height: 0; + display: flex; + flex-direction: column; +} + +.bps-center-content .ptd-chart-card { + flex: 1; + min-height: 0; + height: 100%; + border: 1px solid var(--se-border); + background: var(--se-panel-card-bg); + padding: 0; + overflow: hidden; +} + +/* 중앙 — 캔들 + 보조지표 (실시간 차트 레이아웃) */ +.ptd-analysis-chart { + flex: 1; + min-height: 0; + height: 100%; + display: flex; + flex-direction: column; + overflow: hidden; +} +.ptd-analysis-chart .ptd-main-pane { + flex: 1.55; + min-height: 0; + display: flex; + flex-direction: column; + overflow: hidden; +} +.ptd-analysis-chart .ptd-sub-panes { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + border-top: 1px solid var(--sep); + overflow: hidden; +} +.ptd-analysis-chart .ptd-chart-wrap { + flex: 1; + min-height: 0; + padding: 8px 10px 0; +} +.ptd-analysis-chart .ptd-chart-canvas { + flex: 1; + min-height: 120px; + max-height: none; +} + +.ptd-ind-stack { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + overflow-y: auto; +} +.ptd-ind-row { + flex: 1; + min-height: 72px; + display: flex; + flex-direction: column; + padding: 4px 10px 6px; + border-bottom: 1px solid var(--sep); + background: color-mix(in srgb, var(--bg3) 80%, transparent); +} +.ptd-ind-row:last-child { border-bottom: none; } +.ptd-ind-row .ptd-ind-svg { + flex: 1; + min-height: 48px; + width: 100%; +} + +/* 좌·우 상하단 카드 분리 */ +.ptd-split-panel { + flex: 1; + min-height: 0; + height: 100%; + display: flex; + flex-direction: column; + gap: 8px; +} +.bps-panel-body--tabs { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + overflow: hidden; +} +.bps-panel-body--tabs > .ptd-split-panel--left, +.bps-right-body > .ptd-right-body > .ptd-split-panel--right { + flex: 1; + min-height: 0; +} +.ptd-split-card { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + overflow: hidden; + border: 1px solid var(--se-border, var(--border)); + border-radius: 10px; + background: color-mix(in srgb, var(--se-bg-elevated, var(--bg2)) 88%, transparent); + box-shadow: inset 0 1px 0 color-mix(in srgb, var(--se-text, var(--text)) 4%, transparent); +} +.ptd-split-card-head { + flex-shrink: 0; + padding: 8px 10px; + font-size: 11px; + font-weight: 700; + color: var(--text2, var(--se-text-muted)); + border-bottom: 1px solid var(--sep, var(--se-border)); +} +.ptd-split-card-body { + flex: 1; + min-height: 0; + overflow-y: auto; + overflow-x: hidden; + padding: 8px 10px; + display: flex; + flex-direction: column; + gap: 8px; +} +.ptd-split-panel--right .ptd-split-card-body .ptd-ob--section { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + border: none; + background: transparent; +} +.ptd-split-panel--right .ptd-split-card-body .ptd-ob--section .ptd-ob-body { + flex: 1; + min-height: 0; + max-height: none; + overflow-y: auto; +} +.ptd-split-panel--right .ptd-order-card, +.ptd-split-panel--right .top-panel { + background: transparent; +} +.ptd-tabs--in-card { + border-bottom: none; + margin: -4px -2px 0; +} +.ptd-asset-inline { + display: flex; + align-items: baseline; + justify-content: space-between; + gap: 8px; + padding-bottom: 4px; + border-bottom: 1px solid var(--sep); +} +.ptd-asset-total--sm { + padding: 0; + font-size: 16px; +} +.ptd-asset-ret--sm { + padding: 0; + font-size: 14px; +} + +/* 좌측 전략·설정 탭 */ +.ptd-left-tab { + display: flex; + flex-direction: column; + gap: 10px; +} +.ptd-left-section { + display: flex; + flex-direction: column; + gap: 8px; +} +.ptd-left-section-title { + font-size: 11px; + font-weight: 700; + color: var(--text2); + padding-bottom: 4px; + border-bottom: 1px solid var(--sep); +} +.ptd-left-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + font-size: 11px; + color: var(--text2); +} +.ptd-left-field { + display: flex; + flex-direction: column; + gap: 4px; + font-size: 10px; + color: var(--text3); +} +.ptd-left-select, +.ptd-left-input { + width: 100%; + padding: 6px 8px; + border-radius: 8px; + border: 1px solid var(--border); + background: var(--bg3); + color: var(--text); + font-size: 11px; +} +.ptd-left-hint { + margin: 0; + font-size: 10px; + color: var(--accent); +} +.ptd-left-status { + margin: 0; + font-size: 10px; + color: var(--text3); + padding: 6px 8px; + border-radius: 8px; + background: var(--bg3); +} +.ptd-left-status--on { + color: #22c55e; + background: rgba(34, 197, 94, 0.1); +} +.ptd-left-reset { + width: 100%; + margin-top: 4px; +} + +.bps-right-body .ptd-right-body { flex: 1; min-height: 0; overflow: hidden; display: flex; flex-direction: column; - background: var(--bg); - color: var(--text); - padding: 8px 12px 10px; - font-family: var(--font, 'Noto Sans KR', sans-serif); -} -.ptd-page--loading { - display: flex; - align-items: center; - justify-content: center; - min-height: 320px; - color: var(--text2); + gap: 0; + padding: 0; } -.ptd-topbar { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - margin-bottom: 8px; - flex-shrink: 0; -} -.ptd-title { margin: 0; font-size: 18px; font-weight: 800; color: var(--text); } -.ptd-topbar-actions { display: flex; gap: 8px; flex-shrink: 0; } -.ptd-btn { - padding: 6px 10px; - border-radius: 8px; - border: 1px solid var(--border); - background: var(--bg2); - color: var(--text); - font-size: 11px; - cursor: pointer; -} -.ptd-btn:hover { background: var(--bg3); } -.ptd-btn--danger { border-color: rgba(239,68,68,0.4); color: #ef4444; } - -.ptd-banner { - padding: 8px 10px; - border-radius: 8px; - margin-bottom: 8px; - font-size: 11px; - flex-shrink: 0; -} -.ptd-banner--warn { - background: rgba(251,191,36,0.1); - border: 1px solid rgba(251,191,36,0.3); - color: #fbbf24; +.bps-right-body .ptd-right-body > * { + flex: 1; + min-height: 0; } -.ptd-metrics { - margin-bottom: 8px; - flex-shrink: 0; +.bps-right-body .ptd-right-body > .ptd-split-panel { + gap: 8px; } -.ptd-metrics-grid { + +.bps-footer .ptd-metrics-grid { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 8px; } + +.bps-page .ptd-metric, +.se-page .ptd-metric { + background: color-mix(in srgb, var(--se-bg-elevated) 88%, transparent); + border-color: var(--se-border); +} + +.bps-page .ptd-card, +.se-page .ptd-card { + background: color-mix(in srgb, var(--se-bg-elevated) 88%, transparent); + border-color: var(--se-border); +} + +.bps-page .up, +.se-page .up { color: var(--se-up, #22c55e); } +.bps-page .down, +.se-page .down { color: var(--se-down, #ef4444); } + +@media (max-width: 1200px) { + .bps-footer .ptd-metrics-grid { grid-template-columns: repeat(2, 1fr); } +} + +/* legacy layout removed — shell handles page structure */ .ptd-metric { display: flex; align-items: flex-start; @@ -78,28 +307,6 @@ .ptd-metric--up .ptd-metric-value { color: #22c55e; } .ptd-metric--down .ptd-metric-value { color: #ef4444; } -.ptd-main { - display: grid; - grid-template-columns: minmax(220px, 260px) minmax(0, 1fr) minmax(250px, 280px); - gap: 10px; - flex: 1; - min-height: 0; - overflow: hidden; -} -.ptd-col { - display: flex; - flex-direction: column; - gap: 8px; - min-height: 0; - overflow: hidden; -} -.ptd-col--left { - overflow-y: auto; - overflow-x: hidden; -} -.ptd-col--center { min-width: 0; } -.ptd-col--right { min-width: 0; } - .ptd-card { background: var(--bg2); border: 1px solid var(--border); @@ -300,23 +507,30 @@ .ptd-right-body { flex: 1; min-height: 0; - overflow-y: auto; - overflow-x: hidden; + overflow: hidden; display: flex; flex-direction: column; - gap: 8px; - padding: 8px; + gap: 0; + padding: 0; } .ptd-right-body > .ptd-ob--fill { flex: 1; min-height: 0; - margin: -8px; + margin: 0; border: none; border-radius: 0; } -.ptd-order-card { flex-shrink: 0; border: none; background: transparent; } -.ptd-order-card .ptd-order-stack { padding: 0 4px 4px; } +.ptd-order-card { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + overflow-y: auto; + border: none; + background: transparent; +} +.ptd-order-card .ptd-order-stack { padding: 0 4px 4px; flex: 1; } .ptd-order-card .top-panel { padding: 4px 2px; background: transparent; } .ptd-order-card .top-field { margin-bottom: 4px; } .ptd-order-card .top-submit--buy { background: #22c55e !important; border-color: #22c55e !important; } @@ -397,6 +611,23 @@ border: none; background: transparent; } +.ptd-history-card--tab { + flex: 1; + min-height: 0; + overflow: hidden; + margin: 0; + padding: 0; +} +.ptd-history-card--tab .ptd-table { + flex: 1; + min-height: 0; + display: block; + overflow-y: auto; +} +.bps-right-body > .ptd-right-body > .ptd-history-card--tab { + margin: 0; + padding: 0; +} .ptd-history-card .ptd-table-wrap, .ptd-history-card .ptd-table { flex: 1; @@ -425,16 +656,3 @@ } .ptd-status--done { background: rgba(34,197,94,0.15); color: #22c55e; } -.ptd-page .up { color: #22c55e; } -.ptd-page .down { color: #ef4444; } - -@media (max-width: 1200px) { - .ptd-page { overflow-y: auto; } - .ptd-main { - grid-template-columns: 1fr; - overflow: visible; - min-height: auto; - } - .ptd-metrics-grid { grid-template-columns: repeat(2, 1fr); } - .ptd-col--left { overflow: visible; } -} diff --git a/frontend/src/styles/splashScreen.css b/frontend/src/styles/splashScreen.css new file mode 100644 index 0000000..7d96e52 --- /dev/null +++ b/frontend/src/styles/splashScreen.css @@ -0,0 +1,252 @@ +/* GoldenChart splash — glassmorphism login + 은은한 차트 배경 */ + +.splash { + position: fixed; + inset: 0; + z-index: 10000; + display: flex; + align-items: center; + justify-content: center; + background: #0a0c10; + color: #e8eaed; + overflow: hidden; + font-family: var(--font, 'Noto Sans KR', 'Inter', sans-serif); +} + +.splash-bg { + position: absolute; + inset: 0; + z-index: 0; + background: + radial-gradient(ellipse 70% 50% at 78% 35%, rgba(45, 212, 191, 0.07), transparent 58%), + linear-gradient(165deg, #0c0e13 0%, #0a0c10 55%, #08090d 100%); + pointer-events: none; +} + +/* 전체 화면 차트 — 우상향 캔들 + 일목 구름 */ +.splash-chart-layer { + position: absolute; + inset: -4% -6% -4% -2%; + z-index: 0; + overflow: hidden; + pointer-events: none; + transform: translate(3%, -4%) scale(1.08); + transform-origin: 75% 35%; +} + +.splash-chart-bg { + display: block; + width: 100%; + height: 100%; + opacity: 0.62; +} + +.splash-chart-fade { + position: absolute; + inset: 0; + z-index: 1; + pointer-events: none; + background: + radial-gradient(ellipse 42% 38% at 50% 52%, rgba(10, 12, 16, 0.55) 0%, rgba(10, 12, 16, 0.82) 72%), + linear-gradient(125deg, rgba(10, 12, 16, 0.72) 0%, rgba(10, 12, 16, 0.12) 38%, rgba(10, 12, 16, 0.45) 100%), + linear-gradient(to top, rgba(10, 12, 16, 0.35) 0%, transparent 28%); +} + +.splash-chart-grid { + stroke: rgba(255, 255, 255, 0.05); + stroke-width: 1; +} + +.splash-chart-kumo { + opacity: 0.85; +} + +.splash-ichi { + fill: none; + stroke-linecap: round; + stroke-linejoin: round; +} +.splash-ichi--tenkan { + stroke: rgba(56, 189, 248, 0.35); + stroke-width: 1.4; +} +.splash-ichi--kijun { + stroke: rgba(244, 114, 182, 0.32); + stroke-width: 1.4; +} +.splash-ichi--senkou-a { + stroke: rgba(34, 197, 94, 0.28); + stroke-width: 1; + stroke-dasharray: 4 4; +} +.splash-ichi--senkou-b { + stroke: rgba(239, 68, 68, 0.25); + stroke-width: 1; + stroke-dasharray: 4 4; +} + +.splash-candle-wick { + stroke: rgba(203, 213, 225, 0.45); + stroke-width: 1.3; +} +.splash-candle-up { + fill: rgba(45, 212, 191, 0.55); + stroke: rgba(94, 234, 212, 0.75); + stroke-width: 1; +} +.splash-candle-down { + fill: rgba(100, 116, 139, 0.5); + stroke: rgba(148, 163, 184, 0.45); + stroke-width: 1; +} + +.splash-chart-volume { + opacity: 0.35; +} +.splash-vol-bar { + fill: rgba(100, 116, 139, 0.35); +} + +.splash-center { + position: relative; + z-index: 2; + display: flex; + flex-direction: column; + align-items: center; + gap: 18px; + width: min(92vw, 380px); + padding: 24px 16px; +} + +.splash-glass { + width: 100%; + padding: 36px 32px 28px; + border-radius: 20px; + border: 1px solid rgba(255, 255, 255, 0.1); + background: rgba(12, 14, 18, 0.55); + backdrop-filter: blur(20px); + -webkit-backdrop-filter: blur(20px); + box-shadow: + 0 24px 64px rgba(0, 0, 0, 0.5), + inset 0 1px 0 rgba(255, 255, 255, 0.06); +} + +.splash-brand { + margin: 0 0 28px; + text-align: center; + font-size: 2rem; + font-weight: 800; + letter-spacing: -0.02em; + line-height: 1.1; + background: linear-gradient(180deg, #f9e29c 0%, #d4af37 42%, #b8860b 100%); + -webkit-background-clip: text; + background-clip: text; + color: transparent; + filter: drop-shadow(0 2px 12px rgba(212, 175, 55, 0.25)); +} + +.splash-form { + display: flex; + flex-direction: column; + gap: 12px; +} + +.splash-input { + width: 100%; + box-sizing: border-box; + padding: 13px 14px; + border-radius: 8px; + border: 1px solid rgba(255, 255, 255, 0.1); + background: rgba(0, 0, 0, 0.35); + color: #f3f4f6; + font-size: 0.95rem; + transition: border-color 0.15s, box-shadow 0.15s; +} + +.splash-input::placeholder { + color: rgba(255, 255, 255, 0.35); +} + +.splash-input:focus { + outline: none; + border-color: rgba(212, 175, 55, 0.45); + box-shadow: 0 0 0 3px rgba(212, 175, 55, 0.12); +} + +.splash-input:disabled { + opacity: 0.6; +} + +.splash-error { + margin: 0; + font-size: 0.78rem; + color: #f87171; + text-align: center; +} + +.splash-login-btn { + width: 100%; + margin-top: 4px; + padding: 13px 16px; + border: none; + border-radius: 8px; + cursor: pointer; + font-size: 1rem; + font-weight: 800; + color: #1a1408; + background: linear-gradient(180deg, #f9e29c 0%, #d4af37 48%, #c9a227 100%); + box-shadow: 0 4px 16px rgba(201, 162, 39, 0.35); + transition: transform 0.12s, filter 0.12s, box-shadow 0.12s; +} + +.splash-login-btn:hover:not(:disabled) { + filter: brightness(1.05); + box-shadow: 0 6px 20px rgba(201, 162, 39, 0.45); +} + +.splash-login-btn:active:not(:disabled) { + transform: translateY(1px); +} + +.splash-login-btn:disabled { + opacity: 0.65; + cursor: not-allowed; +} + +.splash-version { + margin: 22px 0 0; + text-align: center; + font-size: 0.68rem; + color: rgba(255, 255, 255, 0.32); + letter-spacing: 0.01em; +} + +.splash-guest-link { + border: none; + background: transparent; + color: rgba(255, 255, 255, 0.38); + font-size: 0.78rem; + cursor: pointer; + padding: 4px 8px; + transition: color 0.15s; +} + +.splash-guest-link:hover:not(:disabled) { + color: rgba(249, 226, 156, 0.85); +} + +.splash-guest-link:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +@media (max-width: 420px) { + .splash-glass { + padding: 28px 22px 22px; + border-radius: 16px; + } + .splash-brand { + font-size: 1.65rem; + margin-bottom: 22px; + } +} diff --git a/frontend/src/styles/strategyEditor.css b/frontend/src/styles/strategyEditor.css index ff573c8..ad295eb 100644 --- a/frontend/src/styles/strategyEditor.css +++ b/frontend/src/styles/strategyEditor.css @@ -137,6 +137,28 @@ min-height: 0; display: flex; min-width: 0; + align-items: stretch; + padding-bottom: 10px; + box-sizing: border-box; +} + +.se-main { + flex: 1; + min-width: 0; + min-height: 0; + display: flex; + flex-direction: column; + padding: 10px 10px 0 0; + box-sizing: border-box; +} + +.se-main-row { + flex: 1; + min-height: 0; + min-width: 0; + display: flex; + gap: 10px; + overflow: hidden; } .se-splitter { @@ -157,7 +179,7 @@ } .se-splitter--h { height: 5px; - margin: 0 10px; + margin: 0 8px; border-radius: 3px; cursor: row-resize; flex-shrink: 0; @@ -170,8 +192,9 @@ display: flex; flex-direction: column; min-height: 0; + align-self: stretch; background: var(--se-panel-shell-bg); - padding: 10px; + padding: 10px 10px 0; } .se-strat-panel { @@ -273,7 +296,7 @@ .se-strat-name { flex: 1; min-width: 0; - font-size: 0.82rem; + font-size: 0.92rem; font-weight: 700; color: var(--se-text); white-space: nowrap; @@ -295,11 +318,11 @@ display: flex; align-items: center; justify-content: center; - width: 26px; - height: 26px; + width: 32px; + height: 32px; padding: 0; border: none; - border-radius: 6px; + border-radius: 7px; background: transparent; color: var(--se-text-dim); cursor: pointer; @@ -350,7 +373,20 @@ display: flex; flex-direction: column; min-height: 0; - background: var(--se-center-bg); + align-self: stretch; + background: transparent; +} + +.se-center-panel { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + border-radius: 14px; + background: var(--se-panel-card-bg); + border: 1px solid var(--se-panel-card-border); + box-shadow: var(--se-panel-card-shadow), inset 0 1px 0 color-mix(in srgb, var(--se-text) 4%, transparent); + overflow: hidden; } .se-center-work { @@ -432,10 +468,48 @@ display: flex; gap: 10px; align-items: center; + flex-wrap: wrap; } .se-canvas-toolbar-title { font-size: 0.72rem; font-weight: 700; color: var(--se-toolbar-title); } .se-canvas-toolbar-hint { font-size: 0.65rem; color: var(--se-text-muted); } +.se-canvas-interaction { + display: inline-flex; + border: 1px solid var(--se-border); + border-radius: 6px; + overflow: hidden; + flex-shrink: 0; +} +.se-canvas-interaction-btn { + border: none; + background: transparent; + color: var(--se-text-muted); + font-size: 0.65rem; + font-weight: 700; + padding: 4px 10px; + cursor: pointer; + transition: background 0.12s, color 0.12s; +} +.se-canvas-interaction-btn:hover { + background: color-mix(in srgb, var(--se-accent) 10%, transparent); + color: var(--se-text); +} +.se-canvas-interaction-btn--on { + background: color-mix(in srgb, var(--se-accent) 18%, transparent); + color: var(--se-tab-active); + box-shadow: inset 0 -2px 0 var(--se-tab-active); +} + +.se-canvas-wrap--pan .react-flow__pane { + cursor: grab; +} +.se-canvas-wrap--pan .react-flow__pane:active { + cursor: grabbing; +} +.se-canvas-wrap--select .react-flow__pane { + cursor: default; +} + .se-canvas-empty { background: var(--se-toolbar-bg); border: 1px dashed var(--se-border); @@ -764,9 +838,10 @@ flex-shrink: 0; display: flex; flex-direction: column; - margin: 0 10px 10px; - border: 1px solid var(--se-terminal-border); - border-radius: 10px; + margin: 0; + border: none; + border-top: 1px solid var(--se-terminal-border); + border-radius: 0; background: var(--se-terminal-bg); overflow: hidden; min-height: 88px; @@ -834,11 +909,23 @@ .se-right { flex: 0 0 260px; width: 260px; - border-left: 1px solid var(--se-border); display: flex; flex-direction: column; min-height: 0; - background: var(--se-palette-bg); + align-self: stretch; + background: transparent; + overflow: hidden; +} + +.se-palette-panel { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + border-radius: 14px; + background: var(--se-panel-card-bg); + border: 1px solid var(--se-panel-card-border); + box-shadow: var(--se-panel-card-shadow), inset 0 1px 0 color-mix(in srgb, var(--se-text) 4%, transparent); overflow: hidden; } @@ -860,6 +947,14 @@ box-shadow: inset 0 -2px 0 var(--se-tab-active); } +.se-right-body { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + overflow: hidden; +} + .se-palette-search { margin: 10px 10px 6px; padding: 8px 10px; @@ -907,12 +1002,20 @@ transform: translateY(-2px); } +.se-palette-card--selected { + outline: 2px solid var(--se-accent); + outline-offset: 1px; +} + /* ── 논리 연산: AND / OR / NOT ── */ .se-palette-card--logic-and { border-color: color-mix(in srgb, var(--se-palette-logic-and-accent) 42%, transparent); background: var(--se-palette-logic-and-bg); box-shadow: inset 3px 0 0 var(--se-palette-logic-and-accent); } +.se-palette-card--selected.se-palette-card--logic-and { + outline-color: var(--se-palette-logic-and-accent); +} .se-palette-card--logic-and .se-palette-card-icon { background: color-mix(in srgb, var(--se-palette-logic-and-accent) 22%, transparent); color: var(--se-palette-logic-and-accent); diff --git a/frontend/src/utils/strategyCanvasInteractionStorage.ts b/frontend/src/utils/strategyCanvasInteractionStorage.ts new file mode 100644 index 0000000..5b23182 --- /dev/null +++ b/frontend/src/utils/strategyCanvasInteractionStorage.ts @@ -0,0 +1,20 @@ +export type StrategyCanvasInteractionMode = 'pan' | 'select'; + +const STORAGE_KEY = 'gc_se_canvas_interaction'; + +export function loadCanvasInteractionMode(): StrategyCanvasInteractionMode { + try { + const raw = localStorage.getItem(STORAGE_KEY); + return raw === 'pan' ? 'pan' : 'select'; + } catch { + return 'select'; + } +} + +export function saveCanvasInteractionMode(mode: StrategyCanvasInteractionMode): void { + try { + localStorage.setItem(STORAGE_KEY, mode); + } catch { + /* ignore */ + } +}