Files
goldenChart/frontend/src/App.tsx
T
2026-06-11 00:14:14 +09:00

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;