diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 5f11e1f..89ef9a0 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -89,7 +89,7 @@ import { getOrderedSettingsIndicatorTypes, sortIndicatorsByTypeOrder, } from './utils/indicatorListOrder'; -import { useAppSettings } from './hooks/useAppSettings'; +import { getAppSettingsCache, useAppSettings } from './hooks/useAppSettings'; import { clampVirtualTargetMax } from './utils/virtualTargetLimits'; import { resolveTrendSearchAppSettings } from './utils/trendSearchAppSettings'; import MarketPanel from './components/MarketPanel'; @@ -154,6 +154,11 @@ 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'; @@ -227,7 +232,7 @@ function App() { const [symbol, setSymbol] = useState(initial.symbol); const [timeframe, setTimeframe] = useState(initial.timeframe); const [chartType, setChartType] = useState(initial.chartType); - const [theme, setTheme] = useState(initial.theme); + const [theme, setTheme] = useState(loadLocalTheme); const [mode, setMode] = useState('chart'); const [logScale, setLogScale] = useState(false); const [indicators, setIndicators] = useState(initial.indicators ?? []); @@ -1261,11 +1266,11 @@ function App() { syncOptions, slots: [{ slotIndex: 0, - symbol, timeframe, chartType, theme, mode, logScale, + symbol, timeframe, chartType, mode, logScale, indicators, drawings, drawingsLocked, drawingsVisible, mainChartStyle, }], - }), [layoutDef.id, syncOptions, symbol, timeframe, chartType, theme, + }), [layoutDef.id, syncOptions, symbol, timeframe, chartType, mode, logScale, indicators, drawings, drawingsLocked, drawingsVisible, mainChartStyle]); const { isLoaded: _wsLoaded } = useWorkspacePersist({ @@ -1285,7 +1290,6 @@ function App() { if (slot0.symbol) setSymbol(slot0.symbol as string); if (slot0.timeframe) setTimeframe(slot0.timeframe as Timeframe); if (slot0.chartType) setChartType(slot0.chartType as ChartType); - if (slot0.theme) setTheme(slot0.theme as Theme); if (slot0.mode) setMode(slot0.mode as ChartMode); if (slot0.logScale != null) setLogScale(Boolean(slot0.logScale)); if (Array.isArray(slot0.indicators)) { @@ -1677,14 +1681,20 @@ function App() { slotRefs.current.forEach(slot => slot?.setCrosshairMagnet(enabled)); }, [magnetMode]); - // 테마 순환: dark → blue → light → dark, DB 에 저장 + // 테마 순환: dark → blue → light → dark (기기별 localStorage) const handleThemeToggle = useCallback(() => { setTheme(t => { const next: Theme = t === 'dark' ? 'blue' : t === 'blue' ? 'light' : 'dark'; - saveAppDef({ defaultTheme: next }); + saveLocalTheme(next); return next; }); - }, [saveAppDef]); + }, []); + + // 최초 이 기기: DB defaultTheme → localStorage 1회만 (이후 기기별 독립) + useEffect(() => { + if (!appSettingsLoaded) return; + setTheme(migrateDbThemeToLocalIfNeeded(getAppSettingsCache().defaultTheme)); + }, [appSettingsLoaded]); const handleTimeframe = useCallback((tf: Timeframe) => { if (layoutDef.count > 1) { diff --git a/frontend/src/components/ChartSlot.tsx b/frontend/src/components/ChartSlot.tsx index 8c13a94..1e42d14 100644 --- a/frontend/src/components/ChartSlot.tsx +++ b/frontend/src/components/ChartSlot.tsx @@ -424,7 +424,6 @@ const ChartSlot = forwardRef(function ChartSlot symbol, timeframe, chartType: 'candlestick', - theme, mode: 'normal', logScale: false, drawingsLocked: false, @@ -437,7 +436,7 @@ const ChartSlot = forwardRef(function ChartSlot }, 2000); return () => { if (saveTimerRef.current) clearTimeout(saveTimerRef.current); }; // eslint-disable-next-line react-hooks/exhaustive-deps - }, [slotIndex, symbol, timeframe, theme, indicators, mainChartStyle]); + }, [slotIndex, symbol, timeframe, indicators, mainChartStyle]); // ── 동기화 콜백 refs (항상 최신 값을 참조하도록) ───────────────────────── const onVisibleRangeChangeRef = useRef(onVisibleRangeChange); diff --git a/frontend/src/hooks/useAppSettings.ts b/frontend/src/hooks/useAppSettings.ts index 0d887d4..49422dc 100644 --- a/frontend/src/hooks/useAppSettings.ts +++ b/frontend/src/hooks/useAppSettings.ts @@ -7,7 +7,7 @@ * - defaultSymbol : 기본 종목 (DEFAULT_STATE.symbol) * - defaultTimeframe : 기본 타임프레임 (DEFAULT_STATE.timeframe) * - defaultChartType : 기본 차트 타입 - * - defaultTheme : 기본 테마 + * - (테마는 DB 미사용 — localStorage, see localThemeStorage) * - defaultLogScale : 기본 로그 스케일 * - defaultLayoutId : 기본 레이아웃 ID * - mainChartStyle : 캔들 색상 (DEFAULT_MAIN_CHART_STYLE) @@ -20,8 +20,7 @@ * // 기본 심볼 사용 * const symbol = settings.defaultSymbol ?? 'KRW-BTC'; * - * // 테마 변경 시 DB 에 저장 - * save({ defaultTheme: newTheme }); + * // 테마 변경 시 saveLocalTheme() (App.tsx) * ``` */ @@ -61,6 +60,7 @@ import { type ChartPaneSeparatorOptions, } from '../types/chartPaneSeparator'; import { migrateLocalStorageToUiPreferences } from '../utils/uiPreferencesMigrate'; +import { loadLocalTheme } from '../utils/localThemeStorage'; // 전역 캐시 — 여러 컴포넌트에서 공유하여 중복 요청 방지 let _cache: AppSettingsDto | null = null; @@ -146,7 +146,7 @@ export function resolveAppDefaults(s: AppSettingsDto) { symbol: (s.defaultSymbol ?? 'KRW-BTC') as string, timeframe: (s.defaultTimeframe ?? '1D') as Timeframe, chartType: (s.defaultChartType ?? 'candlestick') as ChartType, - theme: (s.defaultTheme ?? 'dark') as Theme, + theme: loadLocalTheme(), logScale: s.defaultLogScale ?? false, layoutId: s.defaultLayoutId ?? '1', mainChartStyle:(s.mainChartStyle as unknown as MainChartStyle | null) ?? DEFAULT_MAIN_CHART_STYLE, @@ -164,7 +164,7 @@ export function resolveAppDefaults(s: AppSettingsDto) { ), chartPaneSeparator: resolveChartPaneSeparatorOptions( s.chartPaneSeparator as Partial | null | undefined, - (s.defaultTheme ?? 'dark') as Theme, + loadLocalTheme(), ), tradeAlertPopup: s.tradeAlertPopup ?? true, tradeAlertSoundEnabled: s.tradeAlertSoundEnabled ?? true, @@ -263,7 +263,9 @@ export function useAppSettings(sessionKey = 0) { * 낙관적 업데이트 — DB 저장 실패해도 UI 는 즉시 반영. */ const save = useCallback((patch: AppSettingsDto) => { - const merged = { ...(_cache ?? {}), ...patch }; + const { defaultTheme: _omitTheme, ...patchWithoutTheme } = patch; + const apiPatch = 'defaultTheme' in patch ? patchWithoutTheme : patch; + const merged = { ...(_cache ?? {}), ...apiPatch }; _cache = merged; notifyAppSettingsListeners(); setSettings(merged); @@ -276,7 +278,7 @@ export function useAppSettings(sessionKey = 0) { if (patch.tradeAlertTimeFormat != null) { setTradeAlertTimeFormat(normalizeChartTimeFormat(patch.tradeAlertTimeFormat)); } - saveAppSettings(patch).then(updated => { + saveAppSettings(apiPatch).then(updated => { if (updated) { _cache = { ...(_cache ?? {}), ...updated }; notifyAppSettingsListeners(); diff --git a/frontend/src/utils/localThemeStorage.ts b/frontend/src/utils/localThemeStorage.ts new file mode 100644 index 0000000..63abb4a --- /dev/null +++ b/frontend/src/utils/localThemeStorage.ts @@ -0,0 +1,41 @@ +import type { Theme } from '../types'; + +const STORAGE_KEY = 'gc_local_theme'; + +const VALID: readonly Theme[] = ['dark', 'light', 'blue']; + +function isTheme(v: unknown): v is Theme { + return typeof v === 'string' && (VALID as readonly string[]).includes(v); +} + +/** 이 브라우저(기기)에 저장된 UI 테마. 계정·DB와 무관 */ +export function loadLocalTheme(): Theme { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (isTheme(raw)) return raw; + } catch { + /* private mode 등 */ + } + return 'dark'; +} + +export function saveLocalTheme(theme: Theme): void { + try { + localStorage.setItem(STORAGE_KEY, theme); + } catch { + /* quota / private mode */ + } +} + +/** localStorage에 없을 때만 DB defaultTheme을 이 기기에 1회 복사 */ +export function migrateDbThemeToLocalIfNeeded(dbTheme: string | undefined): Theme { + try { + const existing = localStorage.getItem(STORAGE_KEY); + if (isTheme(existing)) return existing; + } catch { + /* ignore */ + } + const theme: Theme = isTheme(dbTheme) ? dbTheme : 'dark'; + saveLocalTheme(theme); + return theme; +}