715 lines
29 KiB
TypeScript
715 lines
29 KiB
TypeScript
import React, { useState, useEffect, useCallback, lazy, Suspense } from 'react';
|
|
import { useIndicatorSettings } from './hooks/useIndicatorSettings';
|
|
import { getAppSettingsCache, useAppSettings } from './hooks/useAppSettings';
|
|
import { clampVirtualTargetMax } from './utils/virtualTargetLimits';
|
|
import { resolveTrendSearchAppSettings } from './utils/trendSearchAppSettings';
|
|
import TopMenuBar, { type MenuPage } from './components/TopMenuBar';
|
|
import { TradeNotificationProvider } from './contexts/TradeNotificationContext';
|
|
import { VerificationIssueNotificationProvider } from './contexts/VerificationIssueNotificationContext';
|
|
import { FormalLoginGate } from './components/FormalLoginGate';
|
|
import {
|
|
isFormalLogin,
|
|
isTradingMenuPage,
|
|
FORMAL_LOGIN_REQUIRED_MSG,
|
|
} from './utils/tradingAccess';
|
|
import { NotifyTopMenuBar } from './components/NotifyUiBindings';
|
|
import { AppNotificationLayer } from './components/AppNotificationLayer';
|
|
import type { Theme } from './types';
|
|
import type { ChartRealtimeSource } from './hooks/useChartRealtimeData';
|
|
import { useIsMobile } from './hooks/useMediaQuery';
|
|
import LoginModal from './components/LoginModal';
|
|
import AppDownloadModal from './components/AppDownloadModal';
|
|
import SplashScreen from './components/SplashScreen';
|
|
import { getAuthSession, setAuthSession, clearAuthSession, type AuthSession } from './utils/auth';
|
|
import { isAppEntered, setAppEntered, clearAppEntered } from './utils/appEntry';
|
|
import { syncDocumentTheme } from './utils/documentTheme';
|
|
import {
|
|
loadLocalTheme,
|
|
migrateDbThemeToLocalIfNeeded,
|
|
saveLocalTheme,
|
|
} from './utils/localThemeStorage';
|
|
import { useMenuPermissions, invalidateMenuPermissionsCache } from './hooks/useMenuPermissions';
|
|
import { firstAllowedTopMenu, normalizeRole, type SettingsCategoryId } from './utils/permissions';
|
|
import { clearAdminUnlock } from './utils/adminUnlock';
|
|
import type { LoginResponse } from './utils/backendApi';
|
|
import { invalidateAppSettingsCache } from './hooks/useAppSettings';
|
|
import { invalidateIndicatorSettingsCache } from './hooks/useIndicatorSettings';
|
|
|
|
const LAST_MENU_KEY = 'gc_last_menu';
|
|
const CHART_ONLY_INITIAL: ReadonlySet<MenuPage> = new Set(['dashboard', 'settings', 'verification-board']);
|
|
|
|
function loadLastMenu(): MenuPage {
|
|
try {
|
|
const saved = localStorage.getItem(LAST_MENU_KEY) as MenuPage | null;
|
|
// 가상매매·투자관리 등 로그인이 필요한 메뉴는 첫 로드 시 복원하지 않음
|
|
if (saved && saved !== 'chart') return saved;
|
|
} catch { /* localStorage 불가 */ }
|
|
return 'chart';
|
|
}
|
|
|
|
function saveLastMenu(page: MenuPage): void {
|
|
try { localStorage.setItem(LAST_MENU_KEY, page); } catch { /* no-op */ }
|
|
}
|
|
import { useChartWorkspaceContext } from './chart/ChartWorkspaceContext';
|
|
|
|
const ChartWorkspaceRoot = lazy(() =>
|
|
import('./chart/ChartWorkspaceRoot').then(m => ({ default: m.ChartWorkspaceRoot }))
|
|
);
|
|
import './App.css';
|
|
// virtualTradingDashboard.css는 VirtualTradingPage.tsx에서 import
|
|
// paperDashboard.css는 PaperTradingPage.tsx에서 import
|
|
// tradeRightPanel.css는 ChartWorkspaceView.tsx에서 import
|
|
// backtestDashboard.css는 BacktestHistoryPage.tsx, AnalysisReportPage.tsx에서 import
|
|
// appPopup.css는 AppPopup.tsx에서 import
|
|
|
|
const DashboardPage = lazy(() => import('./components/DashboardPage'));
|
|
const StrategyPage = lazy(() => import('./components/StrategyPage'));
|
|
const StrategyEditorPage = lazy(() => import('./components/StrategyEditorPage'));
|
|
const TradeNotificationListPage = lazy(() => import('./components/TradeNotificationListPage'));
|
|
const BacktestHistoryPage = lazy(() => import('./components/BacktestHistoryPage').then(m => ({ default: m.BacktestHistoryPage })));
|
|
const AnalysisReportPage = lazy(() => import('./components/analysisReport/AnalysisReportPage').then(m => ({ default: m.AnalysisReportPage })));
|
|
const SettingsPage = lazy(() => import('./components/SettingsPage'));
|
|
const PaperTradingPage = lazy(() => import('./components/PaperTradingPage'));
|
|
const VirtualTradingPage = lazy(() => import('./components/VirtualTradingPage'));
|
|
const TrendSearchPage = lazy(() => import('./components/TrendSearchPage'));
|
|
const VerificationBoardPage = lazy(() => import('./components/VerificationBoardPage'));
|
|
|
|
function PageLoadFallback() {
|
|
return <div className="page-load-fallback" role="status">화면 로딩 중…</div>;
|
|
}
|
|
|
|
function AppMainContent({
|
|
theme,
|
|
onThemeToggle,
|
|
sessionKey,
|
|
formalLogin,
|
|
requireFormalLogin,
|
|
authUser,
|
|
guestMode,
|
|
loginOpen,
|
|
setLoginOpen,
|
|
appDownloadOpen,
|
|
setAppDownloadOpen,
|
|
menuPage,
|
|
setMenuPage,
|
|
guardedSetMenuPage,
|
|
settingsInitialCategory,
|
|
setSettingsInitialCategory,
|
|
verificationFocusIssueId,
|
|
setVerificationFocusIssueId,
|
|
menuPermissions,
|
|
canMenu,
|
|
appDefaults,
|
|
appSettingsLoaded,
|
|
saveAppDef,
|
|
getIndicatorParams,
|
|
saveIndicatorParams,
|
|
getIndicatorVisual,
|
|
saveIndicatorVisual,
|
|
saveAllIndicatorDefaults,
|
|
indicatorSettingsRevision,
|
|
handleLoginSuccess,
|
|
handleLogout,
|
|
goToMarketChart,
|
|
chartSlot,
|
|
}: {
|
|
theme: Theme;
|
|
onThemeToggle: () => void;
|
|
sessionKey: number;
|
|
formalLogin: boolean;
|
|
requireFormalLogin: () => void;
|
|
authUser: AuthSession | null;
|
|
guestMode: boolean;
|
|
loginOpen: boolean;
|
|
setLoginOpen: (v: boolean) => void;
|
|
appDownloadOpen: boolean;
|
|
setAppDownloadOpen: (v: boolean) => void;
|
|
menuPage: MenuPage;
|
|
setMenuPage: (p: MenuPage) => void;
|
|
guardedSetMenuPage: (p: MenuPage) => void;
|
|
settingsInitialCategory: SettingsCategoryId | undefined;
|
|
setSettingsInitialCategory: (c: SettingsCategoryId | undefined) => void;
|
|
verificationFocusIssueId: number | null;
|
|
setVerificationFocusIssueId: (id: number | null) => void;
|
|
menuPermissions: ReturnType<typeof useMenuPermissions>['permissions'];
|
|
canMenu: ReturnType<typeof useMenuPermissions>['can'];
|
|
appDefaults: ReturnType<typeof useAppSettings>['defaults'];
|
|
appSettingsLoaded: boolean;
|
|
saveAppDef: ReturnType<typeof useAppSettings>['save'];
|
|
getIndicatorParams: ReturnType<typeof useIndicatorSettings>['getParams'];
|
|
saveIndicatorParams: ReturnType<typeof useIndicatorSettings>['saveParams'];
|
|
getIndicatorVisual: ReturnType<typeof useIndicatorSettings>['getVisualConfig'];
|
|
saveIndicatorVisual: ReturnType<typeof useIndicatorSettings>['saveVisual'];
|
|
saveAllIndicatorDefaults: ReturnType<typeof useIndicatorSettings>['saveAllIndicatorDefaults'];
|
|
indicatorSettingsRevision: number;
|
|
handleLoginSuccess: (res: LoginResponse) => void;
|
|
handleLogout: () => void;
|
|
/** 차트로 이동하는 App 레벨 콜백 */
|
|
goToMarketChart: (market: string) => void;
|
|
/** 차트 화면 슬롯 (lazy ChartWorkspaceRoot) */
|
|
chartSlot: React.ReactNode;
|
|
}) {
|
|
const isMobile = useIsMobile();
|
|
const {
|
|
symbol,
|
|
indicators,
|
|
refreshPaperAccount,
|
|
handlePaperOrderFilled,
|
|
paperRefreshKey,
|
|
mode,
|
|
marketTickers,
|
|
settingsPageProps,
|
|
} = useChartWorkspaceContext();
|
|
|
|
const paperTradingEnabled = appDefaults.paperTradingEnabled ?? true;
|
|
const paperAutoTradeEnabled = appDefaults.paperAutoTradeEnabled ?? false;
|
|
|
|
const handleFullscreen = () => {
|
|
if (!document.fullscreenElement) document.documentElement.requestFullscreen().catch(() => {});
|
|
else document.exitFullscreen().catch(() => {});
|
|
};
|
|
|
|
return (
|
|
<VerificationIssueNotificationProvider
|
|
enabled={canMenu('verification-board')}
|
|
notifyEnabled={appDefaults.verificationIssueNotify}
|
|
>
|
|
<div className={`app ${theme} mode-${mode}${isMobile ? ' app--mobile' : ''}`}>
|
|
<NotifyTopMenuBar
|
|
activePage={menuPage}
|
|
theme={theme}
|
|
onPage={guardedSetMenuPage}
|
|
onTheme={onThemeToggle}
|
|
onOpenAppDownload={() => setAppDownloadOpen(true)}
|
|
authUser={authUser}
|
|
guestMode={guestMode}
|
|
menuPermissions={menuPermissions}
|
|
onLoginClick={() => setLoginOpen(true)}
|
|
onLogout={handleLogout}
|
|
tradeAlertPopupPosition={appDefaults.tradeAlertPopupPosition}
|
|
tradeAlertPopupLayout={appDefaults.tradeAlertPopupLayout}
|
|
tradeAlertPopupGridCols={appDefaults.tradeAlertPopupGridCols}
|
|
onTradeAlertPopupPosition={v => saveAppDef({ tradeAlertPopupPosition: v })}
|
|
onTradeAlertPopupLayout={v => saveAppDef({ tradeAlertPopupLayout: v })}
|
|
onTradeAlertPopupGridCols={v => saveAppDef({ tradeAlertPopupGridCols: v })}
|
|
onFullscreen={handleFullscreen}
|
|
/>
|
|
|
|
<LoginModal
|
|
open={loginOpen}
|
|
onClose={() => setLoginOpen(false)}
|
|
onSuccess={handleLoginSuccess}
|
|
/>
|
|
|
|
{appDownloadOpen && (
|
|
<AppDownloadModal onClose={() => setAppDownloadOpen(false)} />
|
|
)}
|
|
|
|
{menuPage === 'dashboard' && (
|
|
<Suspense fallback={<PageLoadFallback />}>
|
|
<DashboardPage theme={theme} onGoChart={m => { goToMarketChart(m); setMenuPage('chart'); }} />
|
|
</Suspense>
|
|
)}
|
|
|
|
{menuPage === 'strategy' && (
|
|
formalLogin
|
|
? (
|
|
<Suspense fallback={<PageLoadFallback />}>
|
|
<StrategyPage theme={theme} activeIndicators={indicators} />
|
|
</Suspense>
|
|
)
|
|
: <FormalLoginGate onLogin={requireFormalLogin} />
|
|
)}
|
|
|
|
{menuPage === 'strategy-editor' && (
|
|
formalLogin
|
|
? (
|
|
<Suspense fallback={<PageLoadFallback />}>
|
|
<StrategyEditorPage theme={theme} />
|
|
</Suspense>
|
|
)
|
|
: <FormalLoginGate onLogin={requireFormalLogin} />
|
|
)}
|
|
|
|
{menuPage === 'backtest' && (
|
|
formalLogin
|
|
? (
|
|
<Suspense fallback={<PageLoadFallback />}>
|
|
<BacktestHistoryPage theme={theme} />
|
|
</Suspense>
|
|
)
|
|
: <FormalLoginGate onLogin={requireFormalLogin} />
|
|
)}
|
|
|
|
{menuPage === 'analysis-report' && (
|
|
formalLogin
|
|
? (
|
|
<Suspense fallback={<PageLoadFallback />}>
|
|
<AnalysisReportPage theme={theme} />
|
|
</Suspense>
|
|
)
|
|
: <FormalLoginGate onLogin={requireFormalLogin} />
|
|
)}
|
|
|
|
{menuPage === 'paper' && (
|
|
formalLogin ? (
|
|
<Suspense fallback={<PageLoadFallback />}>
|
|
<PaperTradingPage
|
|
theme={theme}
|
|
tickers={marketTickers}
|
|
refreshKey={paperRefreshKey}
|
|
defaultMarket={symbol}
|
|
paperTradingEnabled={paperTradingEnabled}
|
|
paperAutoTradeEnabled={paperAutoTradeEnabled}
|
|
onPaperOrderFilled={handlePaperOrderFilled}
|
|
onOpenSettings={() => {
|
|
if (!formalLogin) { requireFormalLogin(); return; }
|
|
setSettingsInitialCategory('trading');
|
|
setMenuPage('settings');
|
|
}}
|
|
onGoChart={m => {
|
|
goToMarketChart(m);
|
|
setMenuPage('chart');
|
|
}}
|
|
/>
|
|
</Suspense>
|
|
) : (
|
|
<FormalLoginGate onLogin={requireFormalLogin} title="투자관리" />
|
|
)
|
|
)}
|
|
|
|
{menuPage === 'virtual' && (
|
|
formalLogin ? (
|
|
<Suspense fallback={<PageLoadFallback />}>
|
|
<VirtualTradingPage
|
|
theme={theme}
|
|
tickers={marketTickers}
|
|
defaultMarket={symbol}
|
|
paperTradingEnabled={paperTradingEnabled}
|
|
paperAutoTradeEnabled={paperAutoTradeEnabled}
|
|
paperAutoTradeBudgetPct={appDefaults.paperAutoTradeBudgetPct}
|
|
onPaperAutoTradeEnabled={v => saveAppDef({ paperAutoTradeEnabled: v })}
|
|
onPaperOrderFilled={handlePaperOrderFilled}
|
|
/>
|
|
</Suspense>
|
|
) : (
|
|
<FormalLoginGate onLogin={requireFormalLogin} title="가상매매" />
|
|
)
|
|
)}
|
|
|
|
{menuPage === 'trend-search' && (
|
|
<Suspense fallback={<PageLoadFallback />}>
|
|
<TrendSearchPage
|
|
theme={theme}
|
|
chartRealtimeSource={(appDefaults.chartRealtimeSource ?? 'BACKEND_STOMP') as ChartRealtimeSource}
|
|
chartCandleAreaPriceLabels={appDefaults.chartCandleAreaPriceLabels}
|
|
chartSeriesPriceLabels={appDefaults.chartSeriesPriceLabels}
|
|
tickers={marketTickers}
|
|
/>
|
|
</Suspense>
|
|
)}
|
|
|
|
{menuPage === 'verification-board' && (
|
|
<Suspense fallback={<PageLoadFallback />}>
|
|
<VerificationBoardPage
|
|
theme={theme}
|
|
focusIssueId={verificationFocusIssueId}
|
|
onFocusIssueConsumed={() => setVerificationFocusIssueId(null)}
|
|
/>
|
|
</Suspense>
|
|
)}
|
|
|
|
{menuPage === 'notifications' && canMenu('notifications') && (
|
|
formalLogin ? (
|
|
<Suspense fallback={<PageLoadFallback />}>
|
|
<TradeNotificationListPage
|
|
theme={theme}
|
|
tickers={marketTickers}
|
|
defaultMarket={symbol}
|
|
paperTradingEnabled={paperTradingEnabled}
|
|
paperAutoTradeEnabled={paperAutoTradeEnabled}
|
|
onPaperOrderFilled={handlePaperOrderFilled}
|
|
onGoToChart={market => {
|
|
goToMarketChart(market);
|
|
setMenuPage('chart');
|
|
}}
|
|
/>
|
|
</Suspense>
|
|
) : (
|
|
<FormalLoginGate onLogin={requireFormalLogin} title="알림목록" />
|
|
)
|
|
)}
|
|
|
|
{menuPage === 'settings' && canMenu('settings') && (
|
|
<Suspense fallback={<PageLoadFallback />}>
|
|
<SettingsPage
|
|
isFormalLogin={formalLogin}
|
|
onRequireFormalLogin={requireFormalLogin}
|
|
theme={theme}
|
|
menuPermissions={menuPermissions}
|
|
btAutoPopup={appDefaults.btAutoPopup}
|
|
onBtAutoPopup={v => saveAppDef({ btAutoPopup: v })}
|
|
btShowPrice={appDefaults.btShowPrice}
|
|
onBtShowPrice={v => saveAppDef({ btShowPrice: v })}
|
|
liveMarket={symbol}
|
|
tradeAlertPopup={appDefaults.tradeAlertPopup}
|
|
onTradeAlertPopup={v => saveAppDef({ tradeAlertPopup: v })}
|
|
tradeAlertSoundEnabled={appDefaults.tradeAlertSoundEnabled}
|
|
onTradeAlertSoundEnabled={v => saveAppDef({ tradeAlertSoundEnabled: v })}
|
|
tradeAlertSound={appDefaults.tradeAlertSound}
|
|
onTradeAlertSound={v => saveAppDef({ tradeAlertSound: v })}
|
|
tradeAlertPopupPosition={appDefaults.tradeAlertPopupPosition}
|
|
onTradeAlertPopupPosition={v => saveAppDef({ tradeAlertPopupPosition: v })}
|
|
tradeAlertPopupLayout={appDefaults.tradeAlertPopupLayout}
|
|
onTradeAlertPopupLayout={v => saveAppDef({ tradeAlertPopupLayout: v })}
|
|
tradeAlertPopupGridCols={appDefaults.tradeAlertPopupGridCols}
|
|
onTradeAlertPopupGridCols={v => saveAppDef({ tradeAlertPopupGridCols: v })}
|
|
getIndicatorParams={getIndicatorParams}
|
|
saveIndicatorParams={saveIndicatorParams}
|
|
getIndicatorVisual={getIndicatorVisual}
|
|
saveIndicatorVisual={saveIndicatorVisual}
|
|
saveAllIndicatorDefaults={saveAllIndicatorDefaults}
|
|
indicatorSettingsRevision={indicatorSettingsRevision}
|
|
paperTradingEnabled={paperTradingEnabled}
|
|
onPaperTradingEnabled={v => saveAppDef({ paperTradingEnabled: v })}
|
|
paperInitialCapital={appDefaults.paperInitialCapital}
|
|
onPaperInitialCapital={v => saveAppDef({ paperInitialCapital: v })}
|
|
paperFeeRatePct={appDefaults.paperFeeRatePct}
|
|
onPaperFeeRatePct={v => saveAppDef({ paperFeeRatePct: v })}
|
|
paperSlippagePct={appDefaults.paperSlippagePct}
|
|
onPaperSlippagePct={v => saveAppDef({ paperSlippagePct: v })}
|
|
paperMinOrderKrw={appDefaults.paperMinOrderKrw}
|
|
onPaperMinOrderKrw={v => saveAppDef({ paperMinOrderKrw: v })}
|
|
paperAutoTradeEnabled={appDefaults.paperAutoTradeEnabled}
|
|
onPaperAutoTradeEnabled={v => saveAppDef({ paperAutoTradeEnabled: v })}
|
|
paperAutoTradeBudgetPct={appDefaults.paperAutoTradeBudgetPct}
|
|
onPaperAutoTradeBudgetPct={v => saveAppDef({ paperAutoTradeBudgetPct: v })}
|
|
paperOrderFillMode={appDefaults.paperOrderFillMode}
|
|
onPaperOrderFillMode={v => saveAppDef({ paperOrderFillMode: v })}
|
|
paperOrderAutoCancelPct={appDefaults.paperOrderAutoCancelPct}
|
|
onPaperOrderAutoCancelPct={v => saveAppDef({ paperOrderAutoCancelPct: v })}
|
|
virtualTargetMaxCount={appDefaults.virtualTargetMaxCount}
|
|
onVirtualTargetMaxCount={v => saveAppDef({ virtualTargetMaxCount: clampVirtualTargetMax(v) })}
|
|
trendSearchSettings={appDefaults.trendSearchSettings}
|
|
onTrendSearchSettingsChange={s => saveAppDef({
|
|
trendSearchSettings: resolveTrendSearchAppSettings(s),
|
|
})}
|
|
tradingMode={appDefaults.tradingMode}
|
|
onTradingMode={v => saveAppDef({ tradingMode: v })}
|
|
liveAutoTradeEnabled={appDefaults.liveAutoTradeEnabled}
|
|
onLiveAutoTradeEnabled={v => saveAppDef({ liveAutoTradeEnabled: v })}
|
|
hasUpbitKeys={appDefaults.hasUpbitKeys}
|
|
upbitAccessKeyMasked={appDefaults.upbitAccessKeyMasked}
|
|
onUpbitKeys={(access, secret) => {
|
|
const patch: Parameters<typeof saveAppDef>[0] = {};
|
|
const a = access.trim();
|
|
const s = secret.trim();
|
|
const isMasked = (v: string) => v.startsWith('····') || v.startsWith('****');
|
|
if (a && !isMasked(a)) patch.upbitAccessKey = a;
|
|
if (s && !isMasked(s)) patch.upbitSecretKey = s;
|
|
if (Object.keys(patch).length) saveAppDef(patch);
|
|
}}
|
|
chartRealtimeSource={appDefaults.chartRealtimeSource}
|
|
onChartRealtimeSource={v => saveAppDef({ chartRealtimeSource: v })}
|
|
liveAutoTradeBudgetPct={appDefaults.liveAutoTradeBudgetPct}
|
|
onLiveAutoTradeBudgetPct={v => saveAppDef({ liveAutoTradeBudgetPct: v })}
|
|
fcmPushEnabled={appDefaults.fcmPushEnabled}
|
|
onFcmPushEnabled={v => saveAppDef({ fcmPushEnabled: v })}
|
|
verificationIssueNotify={appDefaults.verificationIssueNotify}
|
|
onVerificationIssueNotify={v => saveAppDef({ verificationIssueNotify: v })}
|
|
onFcmTest={async () => {
|
|
const { sendFcmTest } = await import('./utils/backendApi');
|
|
const r = await sendFcmTest();
|
|
window.alert(r?.available ? 'FCM 테스트 요청을 보냈습니다.' : 'FCM 서버가 비활성 상태입니다.');
|
|
}}
|
|
initialCategory={settingsInitialCategory}
|
|
{...settingsPageProps}
|
|
/>
|
|
</Suspense>
|
|
)}
|
|
|
|
{chartSlot}
|
|
|
|
<AppNotificationLayer
|
|
menuPage={menuPage}
|
|
onPage={guardedSetMenuPage}
|
|
onGoToChart={goToMarketChart}
|
|
onGoToVerificationIssue={issueId => {
|
|
setVerificationFocusIssueId(issueId);
|
|
guardedSetMenuPage('verification-board');
|
|
}}
|
|
tradingMode={appDefaults.tradingMode}
|
|
hasUpbitKeys={appDefaults.hasUpbitKeys}
|
|
paperTradingEnabled={paperTradingEnabled}
|
|
liveAutoTradeBudgetPct={appDefaults.liveAutoTradeBudgetPct}
|
|
paperAutoTradeBudgetPct={appDefaults.paperAutoTradeBudgetPct}
|
|
tradeAlertPopupPosition={appDefaults.tradeAlertPopupPosition}
|
|
tradeAlertPopupLayout={appDefaults.tradeAlertPopupLayout}
|
|
tradeAlertPopupGridCols={appDefaults.tradeAlertPopupGridCols}
|
|
onOrderDone={() => {
|
|
refreshPaperAccount(symbol);
|
|
handlePaperOrderFilled();
|
|
}}
|
|
/>
|
|
</div>
|
|
</VerificationIssueNotificationProvider>
|
|
);
|
|
}
|
|
|
|
function App() {
|
|
const [sessionKey, setSessionKey] = useState(0);
|
|
const [appEntered, setAppEnteredState] = useState(() => isAppEntered());
|
|
const [authUser, setAuthUser] = useState<AuthSession | null>(() =>
|
|
isAppEntered() ? getAuthSession() : null,
|
|
);
|
|
const [guestMode, setGuestMode] = useState(() =>
|
|
isAppEntered() && !getAuthSession(),
|
|
);
|
|
const formalLogin = isFormalLogin(authUser, guestMode);
|
|
const [loginOpen, setLoginOpen] = useState(false);
|
|
|
|
const requireFormalLogin = useCallback(() => {
|
|
window.alert(FORMAL_LOGIN_REQUIRED_MSG);
|
|
setLoginOpen(true);
|
|
}, []);
|
|
const [appDownloadOpen, setAppDownloadOpen] = useState(false);
|
|
|
|
const { defaults: appDefaults, isLoaded: appSettingsLoaded, save: saveAppDef } = useAppSettings(sessionKey);
|
|
const { permissions: menuPermissions, isLoaded: menuPermsLoaded, can: canMenu } = useMenuPermissions(sessionKey);
|
|
|
|
const [theme, setTheme] = useState<Theme>(loadLocalTheme);
|
|
const [menuPage, setMenuPage] = useState<MenuPage>(loadLastMenu);
|
|
const [settingsInitialCategory, setSettingsInitialCategory] = useState<SettingsCategoryId | undefined>();
|
|
const [verificationFocusIssueId, setVerificationFocusIssueId] = useState<number | null>(null);
|
|
|
|
// 차트 lazy mount 관련 상태: 처음 chart 메뉴 진입 시 마운트, 이후 유지
|
|
const [chartEverActivated, setChartEverActivated] = useState(() => loadLastMenu() === 'chart');
|
|
const [pendingChartMarket, setPendingChartMarket] = useState<string | null>(null);
|
|
|
|
// menuPage 변경 시 localStorage 저장 + chart 활성화 추적
|
|
const setMenuPagePersist = useCallback((page: MenuPage) => {
|
|
saveLastMenu(page);
|
|
setMenuPage(page);
|
|
if (page === 'chart') setChartEverActivated(true);
|
|
}, []);
|
|
|
|
// 외부(대시보드·알림 등)에서 특정 마켓으로 차트 이동 요청
|
|
const handleGoToMarketChart = useCallback((market: string) => {
|
|
setPendingChartMarket(market);
|
|
setMenuPagePersist('chart');
|
|
setChartEverActivated(true);
|
|
}, [setMenuPagePersist]);
|
|
|
|
useEffect(() => {
|
|
if (menuPage === 'chart') setChartEverActivated(true);
|
|
}, [menuPage]);
|
|
|
|
// 지표 설정: 차트·전략·알림 관련 페이지 첫 진입 시 로드 (이후 캐시 유지)
|
|
const INDICATOR_SETTINGS_PAGES: MenuPage[] = ['chart', 'strategy-editor', 'notifications', 'strategy', 'backtest', 'analysis-report', 'settings'];
|
|
const [indicatorSettingsEnabled, setIndicatorSettingsEnabled] = useState(
|
|
() => INDICATOR_SETTINGS_PAGES.includes(menuPage),
|
|
);
|
|
useEffect(() => {
|
|
if (!indicatorSettingsEnabled && INDICATOR_SETTINGS_PAGES.includes(menuPage)) {
|
|
setIndicatorSettingsEnabled(true);
|
|
}
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [menuPage, indicatorSettingsEnabled]);
|
|
|
|
const {
|
|
getParams: getIndicatorParams,
|
|
saveParams: saveIndicatorParams,
|
|
getVisualConfig: getIndicatorVisual,
|
|
saveVisual: saveIndicatorVisual,
|
|
saveAllIndicatorDefaults,
|
|
settingsRevision: indicatorSettingsRevision,
|
|
} = useIndicatorSettings(sessionKey, indicatorSettingsEnabled);
|
|
const getParams = getIndicatorParams;
|
|
const saveParams = saveIndicatorParams;
|
|
const getVisualConfig = getIndicatorVisual;
|
|
const saveVisual = saveIndicatorVisual;
|
|
|
|
useEffect(() => {
|
|
if (menuPage !== 'settings') setSettingsInitialCategory(undefined);
|
|
}, [menuPage]);
|
|
|
|
const enterMainApp = useCallback(() => {
|
|
setAppEntered();
|
|
setAppEnteredState(true);
|
|
}, []);
|
|
|
|
const handleLoginSuccess = useCallback((res: LoginResponse) => {
|
|
const session: AuthSession = {
|
|
userId: res.userId,
|
|
username: res.username,
|
|
displayName: res.displayName,
|
|
role: normalizeRole(res.role),
|
|
};
|
|
setAuthSession(session);
|
|
setAuthUser(session);
|
|
setGuestMode(false);
|
|
clearAdminUnlock();
|
|
invalidateAppSettingsCache();
|
|
invalidateIndicatorSettingsCache();
|
|
invalidateMenuPermissionsCache();
|
|
setSessionKey(k => k + 1);
|
|
enterMainApp();
|
|
}, [enterMainApp]);
|
|
|
|
const handleGuestEnter = useCallback(() => {
|
|
clearAuthSession();
|
|
setAuthUser(null);
|
|
setGuestMode(true);
|
|
clearAdminUnlock();
|
|
invalidateAppSettingsCache();
|
|
invalidateIndicatorSettingsCache();
|
|
invalidateMenuPermissionsCache();
|
|
setSessionKey(k => k + 1);
|
|
enterMainApp();
|
|
}, [enterMainApp]);
|
|
|
|
const handleLogout = useCallback(() => {
|
|
clearAuthSession();
|
|
setAuthUser(null);
|
|
setGuestMode(false);
|
|
clearAdminUnlock();
|
|
invalidateAppSettingsCache();
|
|
invalidateIndicatorSettingsCache();
|
|
invalidateMenuPermissionsCache();
|
|
setSessionKey(k => k + 1);
|
|
clearAppEntered();
|
|
setAppEnteredState(false);
|
|
setLoginOpen(false);
|
|
}, []);
|
|
|
|
const guardedSetMenuPage = useCallback((page: MenuPage) => {
|
|
if (!formalLogin && isTradingMenuPage(page)) {
|
|
requireFormalLogin();
|
|
return;
|
|
}
|
|
if (page === 'notifications' && !canMenu('notifications')) return;
|
|
if (page !== 'notifications' && !canMenu(page)) {
|
|
setMenuPagePersist(firstAllowedTopMenu(menuPermissions));
|
|
return;
|
|
}
|
|
setMenuPagePersist(page);
|
|
}, [canMenu, menuPermissions, formalLogin, requireFormalLogin, setMenuPagePersist]);
|
|
|
|
useEffect(() => {
|
|
if (!menuPermsLoaded) return;
|
|
if (menuPage === 'notifications') {
|
|
if (!canMenu('notifications')) setMenuPagePersist(firstAllowedTopMenu(menuPermissions));
|
|
return;
|
|
}
|
|
if (!canMenu(menuPage)) {
|
|
setMenuPagePersist(firstAllowedTopMenu(menuPermissions));
|
|
}
|
|
}, [menuPage, menuPermsLoaded, menuPermissions, canMenu, setMenuPagePersist]);
|
|
|
|
const handleThemeToggle = useCallback(() => {
|
|
setTheme(t => {
|
|
const next: Theme = t === 'dark' ? 'blue' : t === 'blue' ? 'light' : 'dark';
|
|
saveLocalTheme(next);
|
|
return next;
|
|
});
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
syncDocumentTheme(theme);
|
|
}, [theme]);
|
|
|
|
useEffect(() => {
|
|
if (!appSettingsLoaded) return;
|
|
setTheme(migrateDbThemeToLocalIfNeeded(getAppSettingsCache().defaultTheme));
|
|
}, [appSettingsLoaded]);
|
|
|
|
const openNotificationsPage = useCallback(
|
|
() => guardedSetMenuPage('notifications'),
|
|
[guardedSetMenuPage],
|
|
);
|
|
|
|
if (!appEntered) {
|
|
return (
|
|
<SplashScreen
|
|
onLoginSuccess={handleLoginSuccess}
|
|
onGuest={handleGuestEnter}
|
|
/>
|
|
);
|
|
}
|
|
|
|
// 차트 슬롯: 처음 chart 페이지 진입 시 마운트, 이후 visible 토글
|
|
const chartSlot = chartEverActivated ? (
|
|
<Suspense fallback={null}>
|
|
<ChartWorkspaceRoot
|
|
visible={menuPage === 'chart'}
|
|
pendingChartMarket={pendingChartMarket}
|
|
onPendingChartMarketConsumed={() => setPendingChartMarket(null)}
|
|
menuPage={menuPage}
|
|
sessionKey={sessionKey}
|
|
formalLogin={formalLogin}
|
|
requireFormalLogin={requireFormalLogin}
|
|
theme={theme}
|
|
onThemeToggle={handleThemeToggle}
|
|
appDefaults={appDefaults as unknown as import('./utils/backendApi').AppSettingsDto}
|
|
appSettingsLoaded={appSettingsLoaded}
|
|
saveAppDef={saveAppDef}
|
|
getParams={getParams}
|
|
saveParams={saveParams}
|
|
getVisualConfig={getVisualConfig}
|
|
saveVisual={saveVisual}
|
|
saveAllIndicatorDefaults={saveAllIndicatorDefaults}
|
|
indicatorSettingsRevision={indicatorSettingsRevision}
|
|
onOpenNotificationsPage={openNotificationsPage}
|
|
onOpenAppDownload={() => setAppDownloadOpen(true)}
|
|
/>
|
|
</Suspense>
|
|
) : null;
|
|
|
|
return (
|
|
<TradeNotificationProvider
|
|
popupEnabled={appDefaults.tradeAlertPopup ?? true}
|
|
soundEnabled={appDefaults.tradeAlertSoundEnabled ?? true}
|
|
soundId={appDefaults.tradeAlertSound ?? 'bell'}
|
|
settingsSessionKey={authUser?.userId ?? 0}
|
|
>
|
|
<AppMainContent
|
|
theme={theme}
|
|
onThemeToggle={handleThemeToggle}
|
|
sessionKey={sessionKey}
|
|
formalLogin={formalLogin}
|
|
requireFormalLogin={requireFormalLogin}
|
|
authUser={authUser}
|
|
guestMode={guestMode}
|
|
loginOpen={loginOpen}
|
|
setLoginOpen={setLoginOpen}
|
|
appDownloadOpen={appDownloadOpen}
|
|
setAppDownloadOpen={setAppDownloadOpen}
|
|
menuPage={menuPage}
|
|
setMenuPage={setMenuPagePersist}
|
|
guardedSetMenuPage={guardedSetMenuPage}
|
|
settingsInitialCategory={settingsInitialCategory}
|
|
setSettingsInitialCategory={setSettingsInitialCategory}
|
|
verificationFocusIssueId={verificationFocusIssueId}
|
|
setVerificationFocusIssueId={setVerificationFocusIssueId}
|
|
menuPermissions={menuPermissions}
|
|
canMenu={canMenu}
|
|
appDefaults={appDefaults}
|
|
appSettingsLoaded={appSettingsLoaded}
|
|
saveAppDef={saveAppDef}
|
|
getIndicatorParams={getIndicatorParams}
|
|
saveIndicatorParams={saveIndicatorParams}
|
|
getIndicatorVisual={getIndicatorVisual}
|
|
saveIndicatorVisual={saveIndicatorVisual}
|
|
saveAllIndicatorDefaults={saveAllIndicatorDefaults}
|
|
indicatorSettingsRevision={indicatorSettingsRevision}
|
|
handleLoginSuccess={handleLoginSuccess}
|
|
handleLogout={handleLogout}
|
|
goToMarketChart={handleGoToMarketChart}
|
|
chartSlot={chartSlot}
|
|
/>
|
|
</TradeNotificationProvider>
|
|
);
|
|
}
|
|
|
|
export default App;
|