mobile download

This commit is contained in:
Macbook
2026-05-28 14:44:19 +09:00
parent e2816b037f
commit 3503ef33f5
152 changed files with 11021 additions and 687 deletions
@@ -0,0 +1,151 @@
/**
* 모바일 앱 설치 — APK 다운로드 URL·QR 코드
*/
import React, { useEffect, useMemo, useState } from 'react';
import QRCode from 'qrcode';
import DraggableModalFrame from './DraggableModalFrame';
import { loadMobileAppReleaseInfo, type MobileAppReleaseInfoDto } from '../utils/backendApi';
interface Props {
onClose: () => void;
}
function formatBytes(n?: number): string {
if (n == null || !Number.isFinite(n)) return '—';
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
}
/** APK·QR은 항상 서버가 내려준 절대 URL 사용 (exdev.co.kr, localhost 아님) */
function resolveDownloadUrl(info: MobileAppReleaseInfoDto | null): string {
if (!info?.available) return '';
return info.downloadUrl?.trim() ?? '';
}
const AppDownloadModal: React.FC<Props> = ({ onClose }) => {
const [info, setInfo] = useState<MobileAppReleaseInfoDto | null>(null);
const [loading, setLoading] = useState(true);
const [qrDataUrl, setQrDataUrl] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const downloadUrl = useMemo(() => resolveDownloadUrl(info), [info]);
useEffect(() => {
let cancelled = false;
void loadMobileAppReleaseInfo()
.then(data => {
if (!cancelled) setInfo(data);
})
.catch(() => {
if (!cancelled) setError('앱 정보를 불러오지 못했습니다.');
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => { cancelled = true; };
}, []);
useEffect(() => {
if (!downloadUrl) {
setQrDataUrl(null);
return;
}
let cancelled = false;
void QRCode.toDataURL(downloadUrl, {
width: 220,
margin: 2,
color: { dark: '#111827', light: '#ffffff' },
}).then(url => {
if (!cancelled) setQrDataUrl(url);
}).catch(() => {
if (!cancelled) setQrDataUrl(null);
});
return () => { cancelled = true; };
}, [downloadUrl]);
const handleDownload = () => {
if (!downloadUrl) return;
const a = document.createElement('a');
a.href = downloadUrl;
a.download = info?.fileName ?? 'goldenchart-android.apk';
a.rel = 'noopener';
document.body.appendChild(a);
a.click();
a.remove();
};
return (
<DraggableModalFrame
onClose={onClose}
title="Mobile App"
titleKo="GoldenChart 앱 설치"
badge="Android"
width={440}
dialogClassName="sp-modal app-download-modal"
>
<div className="app-download-body">
{loading && <p className="app-download-muted"> </p>}
{!loading && error && <p className="app-download-error">{error}</p>}
{!loading && !error && !info?.available && (
<div className="app-download-empty">
<div className="app-download-empty-icon" aria-hidden>📱</div>
<h3> </h3>
<p>
Android APK가 .
<br />
<code>data/mobile-releases/goldenchart-android.apk</code> .
</p>
</div>
)}
{!loading && info?.available && (
<>
<div className="app-download-hero">
<div className="app-download-app-icon" aria-hidden>GC</div>
<div>
<h3 className="app-download-app-name">GoldenChart</h3>
<p className="app-download-meta">
{info.version && <span>v{info.version}</span>}
{info.sizeBytes != null && <span>{formatBytes(info.sizeBytes)}</span>}
{info.updatedAt && <span>{info.updatedAt}</span>}
</p>
</div>
</div>
<div className="app-download-qr-block">
{qrDataUrl ? (
<img src={qrDataUrl} alt="앱 설치 QR 코드" className="app-download-qr" width={220} height={220} />
) : (
<div className="app-download-qr app-download-qr--placeholder">QR </div>
)}
<div className="app-download-qr-help">
<strong> QR </strong>
<p>Android에서 APK . .</p>
<p className="app-download-muted app-download-url" title={downloadUrl}>{downloadUrl}</p>
</div>
</div>
<button type="button" className="app-download-btn-primary" onClick={handleDownload}>
Android APK
</button>
<details className="app-download-steps">
<summary> (Android)</summary>
<ol>
<li>QR APK를 .</li>
<li> .</li>
<li> APK .</li>
<li> API URL을 ( ).</li>
</ol>
</details>
<p className="app-download-ios-note">
iOS는 App Store / TestFlight . QR·APK Android .
</p>
</>
)}
</div>
</DraggableModalFrame>
);
};
export default AppDownloadModal;
+7 -1
View File
@@ -13,6 +13,7 @@ interface NotifyTopMenuBarProps {
theme: Theme;
onPage: (page: MenuPage) => void;
onTheme: () => void;
onOpenAppDownload?: () => void;
authUser?: AuthSession | null;
onLoginClick?: () => void;
onLogout?: () => void;
@@ -47,8 +48,12 @@ export const NotifyTopMenuBar: React.FC<NotifyTopMenuBarProps> = props => {
import Toolbar, { type ToolbarProps } from './Toolbar';
export const ChartToolbarNotify: React.FC<ToolbarProps & { onOpenNotifyList: () => void }> = ({
export const ChartToolbarNotify: React.FC<ToolbarProps & {
onOpenNotifyList: () => void;
onOpenAppDownload?: () => void;
}> = ({
onOpenNotifyList,
onOpenAppDownload,
...toolbarProps
}) => {
const { unreadCount } = useTradeNotification();
@@ -57,6 +62,7 @@ export const ChartToolbarNotify: React.FC<ToolbarProps & { onOpenNotifyList: ()
{...toolbarProps}
tradeNotifyUnread={unreadCount}
onOpenTradeNotifications={onOpenNotifyList}
onOpenAppDownload={onOpenAppDownload}
/>
);
};
+242 -159
View File
@@ -5,7 +5,8 @@ import React, { useState, useCallback, useEffect, useMemo, useRef } from 'react'
import { ReactFlowProvider } from '@xyflow/react';
import DraggableModalFrame from './DraggableModalFrame';
import type { Theme } from '../types/index';
import { loadStrategies, saveStrategy, deleteStrategy, type StrategyDto as ApiStrategyDto } from '../utils/backendApi';
import { loadStrategies, loadStrategy, saveStrategy, deleteStrategy, type StrategyDto as ApiStrategyDto } from '../utils/backendApi';
import { syncStrategyTimeframesFromLayoutIfNeeded } from '../utils/strategyTimeframeSync';
import type { LogicNode } from '../utils/strategyTypes';
import {
buildStrategyEditorDefFromSettings,
@@ -35,7 +36,7 @@ import {
type EditorConditionState,
} from '../utils/strategyConditionSerde';
import { migrateLogicRootForEditor } from '../utils/strategyConditionNormalize';
import { persistStrategyEvaluationTimeframes } from '../utils/strategyTimeframeSync';
import { persistStrategyEditorState } from '../utils/strategyTimeframeSync';
import {
START_NODE_ID,
defaultStartMeta,
@@ -48,10 +49,10 @@ import {
} from '../utils/strategyPaletteStorage';
import {
emptySignalFlowLayout,
loadStrategyFlowLayout,
migrateStrategyFlowLayout,
saveStrategyFlowLayout,
deleteStrategyFlowLayout,
buildStrategyFlowLayoutStore,
normalizeStrategyFlowLayout,
readLegacyFlowLayoutFromLocalStorage,
clearLegacyFlowLayoutFromLocalStorage,
type SignalFlowLayoutSnapshot,
type FlowLayoutChangePayload,
type StrategyFlowLayoutStore,
@@ -97,21 +98,6 @@ interface Props {
theme: Theme;
}
function readTabLayout(strategyKey: string, tab: 'buy' | 'sell'): SignalFlowLayoutSnapshot {
const stored = loadStrategyFlowLayout(strategyKey);
const snap = stored?.[tab];
if (!snap) return emptySignalFlowLayout();
return {
positions: snap.positions ?? {},
edgeHandles: snap.edgeHandles ?? {},
orphans: snap.orphans ?? [],
startMeta: snap.startMeta ?? defaultStartMeta(),
extraStartIds: snap.extraStartIds ?? [],
extraRoots: snap.extraRoots ?? {},
startCombineOp: normalizeStartCombineOp(snap.startCombineOp),
};
}
function toEditorState(
root: LogicNode | null,
layout: Pick<SignalFlowLayoutSnapshot, 'startMeta' | 'extraStartIds' | 'extraRoots' | 'startCombineOp'>,
@@ -136,6 +122,28 @@ function normalizeTabLayoutState(snap: SignalFlowLayoutSnapshot) {
};
}
function applyFlowLayoutFromStore(
stored: StrategyFlowLayoutStore | null,
setBuyLayout: React.Dispatch<React.SetStateAction<ReturnType<typeof normalizeTabLayoutState>>>,
setSellLayout: React.Dispatch<React.SetStateAction<ReturnType<typeof normalizeTabLayoutState>>>,
setBuyOrphans: React.Dispatch<React.SetStateAction<LogicNode[]>>,
setSellOrphans: React.Dispatch<React.SetStateAction<LogicNode[]>>,
) {
if (stored) {
setBuyLayout(normalizeTabLayoutState(stored.buy));
setSellLayout(normalizeTabLayoutState(stored.sell));
setBuyOrphans(stored.buy.orphans ?? []);
setSellOrphans(stored.sell.orphans ?? []);
} else {
const emptyBuy = emptySignalFlowLayout();
const emptySell = emptySignalFlowLayout();
setBuyLayout(normalizeTabLayoutState(emptyBuy));
setSellLayout(normalizeTabLayoutState(emptySell));
setBuyOrphans([]);
setSellOrphans([]);
}
}
export default function StrategyEditorPage({ theme }: Props) {
const { getParams, getVisualConfig, settingsRevision, settingsLoaded } = useIndicatorSettings();
const DEF = useMemo(
@@ -164,44 +172,32 @@ export default function StrategyEditorPage({ theme }: Props) {
const [descOpen, setDescOpen] = useState(false);
const [deleteId, setDeleteId] = useState<number | null>(null);
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
const initialDraftBuy = readTabLayout('draft', 'buy');
const initialDraftSell = readTabLayout('draft', 'sell');
const [buyOrphans, setBuyOrphans] = useState<LogicNode[]>(() => initialDraftBuy.orphans ?? []);
const [sellOrphans, setSellOrphans] = useState<LogicNode[]>(() => initialDraftSell.orphans ?? []);
const [buyOrphans, setBuyOrphans] = useState<LogicNode[]>([]);
const [sellOrphans, setSellOrphans] = useState<LogicNode[]>([]);
const [snack, setSnack] = useState<{ msg: string; ok: boolean } | null>(null);
const [saveToast, setSaveToast] = useState(false);
const [leftWidth, setLeftWidth] = useState(() => readStoredSize('se-left-width', LEFT_PANEL_DEFAULT));
const [terminalHeight, setTerminalHeight] = useState(() => readStoredSize('se-terminal-height', TERMINAL_DEFAULT));
const leftWidthRef = useRef(leftWidth);
const terminalHeightRef = useRef(terminalHeight);
const [buyLayout, setBuyLayout] = useState(() => ({
positions: initialDraftBuy.positions,
edgeHandles: initialDraftBuy.edgeHandles,
startMeta: initialDraftBuy.startMeta ?? defaultStartMeta(),
extraStartIds: initialDraftBuy.extraStartIds ?? [],
extraRoots: initialDraftBuy.extraRoots ?? {},
startCombineOp: normalizeStartCombineOp(initialDraftBuy.startCombineOp),
}));
const [sellLayout, setSellLayout] = useState(() => ({
positions: initialDraftSell.positions,
edgeHandles: initialDraftSell.edgeHandles,
startMeta: initialDraftSell.startMeta ?? defaultStartMeta(),
extraStartIds: initialDraftSell.extraStartIds ?? [],
extraRoots: initialDraftSell.extraRoots ?? {},
startCombineOp: normalizeStartCombineOp(initialDraftSell.startCombineOp),
}));
const [buyLayout, setBuyLayout] = useState(() => normalizeTabLayoutState(emptySignalFlowLayout()));
const [sellLayout, setSellLayout] = useState(() => normalizeTabLayoutState(emptySignalFlowLayout()));
const buyLayoutRef = useRef(buyLayout);
const sellLayoutRef = useRef(sellLayout);
const buyConditionRef = useRef(buyCondition);
const sellConditionRef = useRef(sellCondition);
const buyOrphansRef = useRef(buyOrphans);
const sellOrphansRef = useRef(sellOrphans);
buyLayoutRef.current = buyLayout;
sellLayoutRef.current = sellLayout;
buyConditionRef.current = buyCondition;
sellConditionRef.current = sellCondition;
const timeframeAutosaveRef = useRef<number | null>(null);
buyOrphansRef.current = buyOrphans;
sellOrphansRef.current = sellOrphans;
const layoutRevisionRef = useRef(0);
const persistLayoutTimerRef = useRef<number | null>(null);
const strategyAutosaveTimerRef = useRef<number | null>(null);
const layoutPersistReadyRef = useRef(false);
const STRATEGY_AUTOSAVE_MS = 350;
leftWidthRef.current = leftWidth;
terminalHeightRef.current = terminalHeight;
@@ -268,20 +264,67 @@ export default function StrategyEditorPage({ theme }: Props) {
const layoutStrategyKey = selectedId != null ? String(selectedId) : 'draft';
const persistFlowLayout = useCallback((strategyKey: string) => {
saveStrategyFlowLayout(strategyKey, {
buy: { ...buyLayoutRef.current, orphans: buyOrphans },
sell: { ...sellLayoutRef.current, orphans: sellOrphans },
});
}, [buyOrphans, sellOrphans]);
const buildCurrentFlowLayout = useCallback(
(): StrategyFlowLayoutStore => buildStrategyFlowLayoutStore(
buyLayoutRef.current,
sellLayoutRef.current,
buyOrphansRef.current,
sellOrphansRef.current,
),
[],
);
const schedulePersistFlowLayout = useCallback((strategyKey: string) => {
if (persistLayoutTimerRef.current != null) window.clearTimeout(persistLayoutTimerRef.current);
persistLayoutTimerRef.current = window.setTimeout(() => {
persistLayoutTimerRef.current = null;
persistFlowLayout(strategyKey);
}, 150);
}, [persistFlowLayout]);
const persistStrategyToDb = useCallback(async () => {
if (selectedId == null || !stratName.trim()) return;
const buy = toEditorState(buyConditionRef.current, buyLayoutRef.current);
const sell = toEditorState(sellConditionRef.current, sellLayoutRef.current);
const aligned = alignBuySellStartCandleTypesForSave(buy, sell);
const encodedBuy = encodeConditionForSave(aligned.buy);
const encodedSell = encodeConditionForSave(aligned.sell);
if (!encodedBuy && !encodedSell) return;
const flowLayout = buildCurrentFlowLayout();
const saved = await persistStrategyEditorState(
selectedId,
stratName,
stratDesc,
aligned.buy,
aligned.sell,
flowLayout,
);
setStrategies(prev => prev.map(s => s.id === selectedId
? {
...s,
buyCondition: encodedBuy,
sellCondition: encodedSell,
flowLayout,
updatedAt: saved?.updatedAt ?? s.updatedAt,
}
: s));
}, [selectedId, stratName, stratDesc, buildCurrentFlowLayout]);
const flushStrategyPersist = useCallback(() => {
if (strategyAutosaveTimerRef.current != null) {
window.clearTimeout(strategyAutosaveTimerRef.current);
strategyAutosaveTimerRef.current = null;
}
if (selectedId == null || !stratName.trim()) return;
void persistStrategyToDb().catch(err => {
console.warn('[StrategyEditor] 전략 DB 저장 실패:', err);
});
}, [selectedId, stratName, persistStrategyToDb]);
const scheduleStrategyPersist = useCallback(() => {
if (selectedId == null || !stratName.trim()) return;
if (strategyAutosaveTimerRef.current != null) {
window.clearTimeout(strategyAutosaveTimerRef.current);
}
strategyAutosaveTimerRef.current = window.setTimeout(() => {
strategyAutosaveTimerRef.current = null;
void persistStrategyToDb().catch(err => {
console.warn('[StrategyEditor] 전략 자동저장 실패:', err);
});
}, STRATEGY_AUTOSAVE_MS);
}, [selectedId, stratName, persistStrategyToDb]);
const bumpLayoutSeed = useCallback((strategyKey: string, tab: 'buy' | 'sell') => {
layoutRevisionRef.current += 1;
@@ -318,50 +361,11 @@ export default function StrategyEditorPage({ theme }: Props) {
extraRoots: syncExtraRoots(prev.extraRoots ?? {}, 'sell'),
}));
bumpLayoutSeed(layoutStrategyKey, signalTab);
}, [settingsLoaded, settingsRevision, DEF, layoutStrategyKey, signalTab, bumpLayoutSeed]);
if (selectedId != null && stratName.trim()) scheduleStrategyPersist();
}, [settingsLoaded, settingsRevision, DEF, layoutStrategyKey, signalTab, bumpLayoutSeed, selectedId, stratName, scheduleStrategyPersist]);
const resetFlowLayout = useCallback((strategyKey: string, tab: 'buy' | 'sell' = 'buy') => {
const emptyBuy = emptySignalFlowLayout();
const emptySell = emptySignalFlowLayout();
setBuyLayout(normalizeTabLayoutState(emptyBuy));
setSellLayout(normalizeTabLayoutState(emptySell));
setBuyOrphans([]);
setSellOrphans([]);
saveStrategyFlowLayout(strategyKey, { buy: emptyBuy, sell: emptySell });
bumpLayoutSeed(strategyKey, tab);
}, [bumpLayoutSeed]);
const applyStoredFlowLayout = useCallback((strategyKey: string, tab: 'buy' | 'sell' = 'buy') => {
const stored = loadStrategyFlowLayout(strategyKey);
if (stored) {
const nextBuy = {
positions: stored.buy.positions ?? {},
edgeHandles: stored.buy.edgeHandles ?? {},
startMeta: stored.buy.startMeta ?? defaultStartMeta(),
extraStartIds: stored.buy.extraStartIds ?? [],
extraRoots: stored.buy.extraRoots ?? {},
startCombineOp: normalizeStartCombineOp(stored.buy.startCombineOp),
};
const nextSell = {
positions: stored.sell.positions ?? {},
edgeHandles: stored.sell.edgeHandles ?? {},
startMeta: stored.sell.startMeta ?? defaultStartMeta(),
extraStartIds: stored.sell.extraStartIds ?? [],
extraRoots: stored.sell.extraRoots ?? {},
startCombineOp: normalizeStartCombineOp(stored.sell.startCombineOp),
};
setBuyLayout(nextBuy);
setSellLayout(nextSell);
setBuyOrphans(stored.buy.orphans ?? []);
setSellOrphans(stored.sell.orphans ?? []);
} else {
const emptyBuy = emptySignalFlowLayout();
const emptySell = emptySignalFlowLayout();
setBuyLayout(normalizeTabLayoutState(emptyBuy));
setSellLayout(normalizeTabLayoutState(emptySell));
setBuyOrphans([]);
setSellOrphans([]);
}
applyFlowLayoutFromStore(null, setBuyLayout, setSellLayout, setBuyOrphans, setSellOrphans);
bumpLayoutSeed(strategyKey, tab);
}, [bumpLayoutSeed]);
@@ -372,24 +376,34 @@ export default function StrategyEditorPage({ theme }: Props) {
};
if (snapshot.tab === 'buy') setBuyLayout(prev => ({ ...prev, ...patch }));
else setSellLayout(prev => ({ ...prev, ...patch }));
schedulePersistFlowLayout(layoutStrategyKey);
}, [layoutStrategyKey, schedulePersistFlowLayout]);
scheduleStrategyPersist();
}, [scheduleStrategyPersist]);
const handleRootChange = useCallback((root: LogicNode | null) => {
setCurrentRoot(root);
scheduleStrategyPersist();
}, [setCurrentRoot, scheduleStrategyPersist]);
const handleOrphansChange = useCallback((orphans: LogicNode[]) => {
setCurrentOrphans(orphans);
scheduleStrategyPersist();
}, [setCurrentOrphans, scheduleStrategyPersist]);
const handleStartMetaChange = useCallback((meta: Record<string, import('../utils/strategyStartNodes').StartNodeMeta>) => {
setCurrentLayout(prev => ({ ...prev, startMeta: meta }));
schedulePersistFlowLayout(layoutStrategyKey);
}, [setCurrentLayout, layoutStrategyKey, schedulePersistFlowLayout]);
scheduleStrategyPersist();
}, [setCurrentLayout, scheduleStrategyPersist]);
const handleExtraStartIdsChange = useCallback((ids: string[]) => {
setCurrentLayout(prev => ({ ...prev, extraStartIds: ids }));
schedulePersistFlowLayout(layoutStrategyKey);
scheduleStrategyPersist();
bumpLayoutSeed(layoutStrategyKey, signalTab);
}, [setCurrentLayout, layoutStrategyKey, schedulePersistFlowLayout, bumpLayoutSeed, signalTab]);
}, [setCurrentLayout, layoutStrategyKey, scheduleStrategyPersist, bumpLayoutSeed, signalTab]);
const handleExtraRootsChange = useCallback((roots: Record<string, LogicNode | null>) => {
setCurrentLayout(prev => ({ ...prev, extraRoots: roots }));
schedulePersistFlowLayout(layoutStrategyKey);
}, [setCurrentLayout, layoutStrategyKey, schedulePersistFlowLayout]);
scheduleStrategyPersist();
}, [setCurrentLayout, scheduleStrategyPersist]);
const handleEditorStateChange = useCallback((next: EditorConditionState) => {
setCurrentRoot(next.root);
@@ -400,24 +414,8 @@ export default function StrategyEditorPage({ theme }: Props) {
extraRoots: next.extraRoots,
startCombineOp: normalizeStartCombineOp(next.startCombineOp),
}));
schedulePersistFlowLayout(layoutStrategyKey);
}, [setCurrentRoot, setCurrentLayout, layoutStrategyKey, schedulePersistFlowLayout]);
const scheduleTimeframePersist = useCallback(() => {
if (selectedId == null || !stratName.trim()) return;
if (timeframeAutosaveRef.current != null) window.clearTimeout(timeframeAutosaveRef.current);
const id = selectedId;
const name = stratName;
const desc = stratDesc;
timeframeAutosaveRef.current = window.setTimeout(() => {
timeframeAutosaveRef.current = null;
const buy = toEditorState(buyConditionRef.current, buyLayoutRef.current);
const sell = toEditorState(sellConditionRef.current, sellLayoutRef.current);
void persistStrategyEvaluationTimeframes(id, name, desc, buy, sell).catch(err => {
console.warn('[StrategyEditor] START 분봉 자동저장 실패:', err);
});
}, 700);
}, [selectedId, stratName, stratDesc]);
scheduleStrategyPersist();
}, [setCurrentRoot, setCurrentLayout, scheduleStrategyPersist]);
const handleStartCandleTypesChange = useCallback((startId: string, candleTypes: string[]) => {
if (startId === START_NODE_ID) {
@@ -438,20 +436,16 @@ export default function StrategyEditorPage({ theme }: Props) {
extraRoots: synced.sell.extraRoots,
startCombineOp: normalizeStartCombineOp(synced.sell.startCombineOp),
}));
schedulePersistFlowLayout(layoutStrategyKey);
scheduleTimeframePersist();
scheduleStrategyPersist();
return;
}
const state = signalTab === 'buy' ? buyEditorState : sellEditorState;
handleEditorStateChange(updateStartCandleTypes(state, startId, candleTypes));
scheduleTimeframePersist();
}, [
buyEditorState,
sellEditorState,
handleEditorStateChange,
layoutStrategyKey,
schedulePersistFlowLayout,
scheduleTimeframePersist,
scheduleStrategyPersist,
signalTab,
]);
@@ -461,18 +455,18 @@ export default function StrategyEditorPage({ theme }: Props) {
const switchSignalTab = useCallback((tab: 'buy' | 'sell') => {
if (editorMode === 'graph') layoutFlushRef.current?.();
persistFlowLayout(layoutStrategyKey);
flushStrategyPersist();
layoutRevisionRef.current += 1;
setLayoutSeedKey(`${layoutStrategyKey}:${tab}:${layoutRevisionRef.current}`);
setSignalTab(tab);
setSelectedNodeId(null);
}, [layoutStrategyKey, persistFlowLayout, editorMode]);
}, [layoutStrategyKey, flushStrategyPersist, editorMode]);
const handleEditorModeChange = useCallback((mode: StrategyEditorMode) => {
if (mode === editorMode) return;
if (editorMode === 'graph') {
layoutFlushRef.current?.();
persistFlowLayout(layoutStrategyKey);
flushStrategyPersist();
}
if (mode === 'list' && (buyOrphans.length > 0 || sellOrphans.length > 0)) {
showSnack('목록 방식에서는 미연결(고아) 노드가 표시되지 않습니다', false);
@@ -480,7 +474,7 @@ export default function StrategyEditorPage({ theme }: Props) {
setEditorMode(mode);
saveEditorMode(mode);
setSelectedNodeId(null);
}, [editorMode, layoutStrategyKey, persistFlowLayout, buyOrphans.length, sellOrphans.length]);
}, [editorMode, flushStrategyPersist, buyOrphans.length, sellOrphans.length]);
const layoutSeed = useMemo((): FlowLayoutSeed => {
const source = signalTab === 'buy' ? buyLayout : sellLayout;
@@ -491,17 +485,29 @@ export default function StrategyEditorPage({ theme }: Props) {
};
}, [signalTab, layoutSeedKey, buyLayout, sellLayout]);
useEffect(() => () => {
if (persistLayoutTimerRef.current != null) window.clearTimeout(persistLayoutTimerRef.current);
}, []);
const persistStrategyToDbRef = useRef(persistStrategyToDb);
persistStrategyToDbRef.current = persistStrategyToDb;
useEffect(() => {
const id = selectedId;
const name = stratName;
return () => {
if (strategyAutosaveTimerRef.current != null) {
window.clearTimeout(strategyAutosaveTimerRef.current);
strategyAutosaveTimerRef.current = null;
}
if (id != null && name.trim()) {
void persistStrategyToDbRef.current().catch(() => { /* unmount flush */ });
}
};
}, [selectedId, stratName]);
useEffect(() => {
if (!layoutPersistReadyRef.current) {
layoutPersistReadyRef.current = true;
return;
}
schedulePersistFlowLayout(layoutStrategyKey);
}, [buyOrphans, sellOrphans, layoutStrategyKey, schedulePersistFlowLayout]);
scheduleStrategyPersist();
}, [buyOrphans, sellOrphans, scheduleStrategyPersist]);
useEffect(() => { saveStratsLocal(strategies); }, [strategies]);
@@ -514,6 +520,7 @@ export default function StrategyEditorPage({ theme }: Props) {
description: s.description,
buyCondition: s.buyCondition as LogicNode | null ?? null,
sellCondition: s.sellCondition as LogicNode | null ?? null,
flowLayout: normalizeStrategyFlowLayout(s.flowLayout) ?? undefined,
enabled: s.enabled ?? true,
createdAt: s.createdAt,
updatedAt: s.updatedAt,
@@ -553,7 +560,10 @@ export default function StrategyEditorPage({ theme }: Props) {
const handleSelectStrategy = (s: StrategyDto) => {
const buyDecoded = decodeConditionForEditor(s.buyCondition ?? null);
const sellDecoded = decodeConditionForEditor(s.sellCondition ?? null);
const stored = loadStrategyFlowLayout(String(s.id));
let stored = normalizeStrategyFlowLayout(s.flowLayout);
if (!stored) {
stored = readLegacyFlowLayoutFromLocalStorage(String(s.id));
}
const buyMigrated = migrateLogicRootForEditor(buyDecoded.root, stored?.buy.orphans ?? [], DEF, 'buy');
const sellMigrated = migrateLogicRootForEditor(sellDecoded.root, stored?.sell.orphans ?? [], DEF, 'sell');
@@ -600,6 +610,58 @@ export default function StrategyEditorPage({ theme }: Props) {
});
setSelectedNodeId(null);
bumpLayoutSeed(String(s.id), signalTab);
if (!s.flowLayout && stored) {
void (async () => {
try {
const aligned = alignBuySellStartCandleTypesForSave(
toEditorState(buySynced.root, {
startMeta: mergeStartMetaForLoad(buyDecoded.startMeta, stored.buy.startMeta),
extraStartIds: stored.buy.extraStartIds ?? buyDecoded.extraStartIds,
extraRoots: stored.buy.extraRoots ?? buyDecoded.extraRoots,
startCombineOp: normalizeStartCombineOp(stored.buy.startCombineOp ?? buyDecoded.startCombineOp),
}),
toEditorState(sellSynced.root, {
startMeta: mergeStartMetaForLoad(sellDecoded.startMeta, stored.sell.startMeta),
extraStartIds: stored.sell.extraStartIds ?? sellDecoded.extraStartIds,
extraRoots: stored.sell.extraRoots ?? sellDecoded.extraRoots,
startCombineOp: normalizeStartCombineOp(stored.sell.startCombineOp ?? sellDecoded.startCombineOp),
}),
);
await saveStrategy({
id: s.id,
name: s.name,
description: s.description ?? '',
buyCondition: encodeConditionForSave(aligned.buy),
sellCondition: encodeConditionForSave(aligned.sell),
flowLayout: stored,
enabled: s.enabled ?? true,
});
clearLegacyFlowLayoutFromLocalStorage(String(s.id));
setStrategies(prev => prev.map(x => x.id === s.id ? { ...x, flowLayout: stored! } : x));
} catch {
/* ignore */
}
})();
}
// flow layout START 분봉이 DB DSL보다 최신이면 서버에 반영 (실시간 평가는 DB 기준)
void (async () => {
const synced = await syncStrategyTimeframesFromLayoutIfNeeded(s.id, { ...s, flowLayout: stored ?? s.flowLayout });
if (!synced) return;
try {
const fresh = await loadStrategy(s.id);
if (!fresh) return;
const buyD = decodeConditionForEditor((fresh.buyCondition ?? null) as LogicNode | null);
const sellD = decodeConditionForEditor((fresh.sellCondition ?? null) as LogicNode | null);
const buyM = migrateLogicRootForEditor(buyD.root, buySynced.orphans, DEF, 'buy');
const sellM = migrateLogicRootForEditor(sellD.root, sellSynced.orphans, DEF, 'sell');
setBuyCondition(syncLogicRootWithGlobalDef(buyM.root, buyM.orphans, DEF, 'buy').root);
setSellCondition(syncLogicRootWithGlobalDef(sellM.root, sellM.orphans, DEF, 'sell').root);
} catch {
/* ignore */
}
})();
};
const handleNew = () => {
@@ -620,39 +682,59 @@ export default function StrategyEditorPage({ theme }: Props) {
}
setSaveNameError(false);
if (editorMode === 'graph') layoutFlushRef.current?.();
if (strategyAutosaveTimerRef.current != null) {
window.clearTimeout(strategyAutosaveTimerRef.current);
strategyAutosaveTimerRef.current = null;
}
const aligned = alignBuySellStartCandleTypesForSave(buyEditorState, sellEditorState);
const encodedBuy = encodeConditionForSave(aligned.buy);
const encodedSell = encodeConditionForSave(aligned.sell);
if (!encodedBuy && !encodedSell) { showSnack('조건을 최소 1개 추가하세요', false); return; }
setIsSaving(true);
try {
const flowLayout = buildCurrentFlowLayout();
const payload: ApiStrategyDto = {
id: selectedId ?? undefined,
name: stratName,
description: stratDesc,
buyCondition: encodedBuy,
sellCondition: encodedSell,
flowLayout,
enabled: true,
};
const saved = await saveStrategy(payload);
const now = new Date().toISOString();
const dbId = saved?.id ?? selectedId ?? Date.now();
const wasDraft = selectedId == null;
setStrategies(prev => {
const existing = prev.find(s => s.id === selectedId);
if (existing && selectedId) {
return prev.map(s => s.id === selectedId
? { ...s, id: dbId, name: stratName, description: stratDesc, buyCondition: encodedBuy, sellCondition: encodedSell, updatedAt: saved?.updatedAt ?? now }
? {
...s,
id: dbId,
name: stratName,
description: stratDesc,
buyCondition: encodedBuy,
sellCondition: encodedSell,
flowLayout,
updatedAt: saved?.updatedAt ?? now,
}
: s);
}
setSelectedId(dbId);
return [...prev, { id: dbId, name: stratName, description: stratDesc, buyCondition: encodedBuy, sellCondition: encodedSell, enabled: true, createdAt: saved?.createdAt ?? now, updatedAt: saved?.updatedAt ?? now }];
return [...prev, {
id: dbId,
name: stratName,
description: stratDesc,
buyCondition: encodedBuy,
sellCondition: encodedSell,
flowLayout,
enabled: true,
createdAt: saved?.createdAt ?? now,
updatedAt: saved?.updatedAt ?? now,
}];
});
if (!selectedId) setSelectedId(dbId);
if (wasDraft) {
migrateStrategyFlowLayout('draft', String(dbId));
}
persistFlowLayout(String(dbId));
bumpLayoutSeed(String(dbId), signalTab);
setSaveOpen(false);
setSaveToast(true);
@@ -668,7 +750,7 @@ export default function StrategyEditorPage({ theme }: Props) {
const handleDeleteConfirm = async () => {
if (!deleteId) return;
try { await deleteStrategy(deleteId); } catch { /* local */ }
deleteStrategyFlowLayout(String(deleteId));
clearLegacyFlowLayoutFromLocalStorage(String(deleteId));
setStrategies(prev => prev.filter(s => s.id !== deleteId));
if (selectedId === deleteId) handleNew();
setDeleteOpen(false);
@@ -696,7 +778,6 @@ export default function StrategyEditorPage({ theme }: Props) {
});
setBuyOrphans(layout.buy.orphans ?? []);
setSellOrphans(layout.sell.orphans ?? []);
saveStrategyFlowLayout('draft', layout);
} else {
resetFlowLayout('draft', tab);
}
@@ -773,7 +854,7 @@ export default function StrategyEditorPage({ theme }: Props) {
}
const items = strategies.map(s => strategyDtoToListExportItem(
s,
loadStrategyFlowLayout(String(s.id)),
s.flowLayout ?? null,
));
downloadStrategyJson(
`전략목록_${new Date().toISOString().slice(0, 10)}`,
@@ -794,7 +875,6 @@ export default function StrategyEditorPage({ theme }: Props) {
const baseId = Date.now();
const imported = result.data.strategies.map((item, index) => {
const id = baseId + index;
if (item.flowLayout) saveStrategyFlowLayout(String(id), item.flowLayout);
return listImportItemToStrategyDto(item, id);
});
setStrategies(prev => [...prev, ...imported]);
@@ -819,7 +899,8 @@ export default function StrategyEditorPage({ theme }: Props) {
if (!root) setCurrentRoot(newNode);
else if (type === 'operator') setCurrentRoot(mergeAtRoot(root, newNode, true));
else setCurrentRoot(mergeAtRoot(root, newNode, false));
}, [signalTab, DEF, currentRoot, setCurrentRoot, handleAddStartSection]);
scheduleStrategyPersist();
}, [signalTab, DEF, currentRoot, setCurrentRoot, handleAddStartSection, scheduleStrategyPersist]);
const applyPaletteItem = useCallback((item: PaletteItem) => {
const composite = item.kind === 'composite';
@@ -827,7 +908,8 @@ export default function StrategyEditorPage({ theme }: Props) {
const root = currentRoot;
if (!root) setCurrentRoot(newNode);
else setCurrentRoot(mergeAtRoot(root, newNode, false));
}, [signalTab, DEF, currentRoot, setCurrentRoot]);
scheduleStrategyPersist();
}, [signalTab, DEF, currentRoot, setCurrentRoot, scheduleStrategyPersist]);
const templates = useMemo(() => getStrategyTemplates(DEF), [DEF]);
@@ -846,9 +928,10 @@ export default function StrategyEditorPage({ theme }: Props) {
resetFlowLayout(layoutStrategyKey, targetTab);
if (targetTab !== signalTab) setSignalTab(targetTab);
scheduleStrategyPersist();
showSnack(`"${tmpl.label}" 템플릿이 적용됐습니다`);
}, [editorMode, layoutStrategyKey, resetFlowLayout, signalTab]);
}, [editorMode, layoutStrategyKey, resetFlowLayout, signalTab, scheduleStrategyPersist]);
const operators = [
{ type: 'operator' as const, value: 'AND', label: 'AND', color: 'logic-and' },
@@ -1068,10 +1151,10 @@ export default function StrategyEditorPage({ theme }: Props) {
theme={theme}
root={currentRoot}
orphans={currentOrphans}
onOrphansChange={setCurrentOrphans}
onOrphansChange={handleOrphansChange}
def={DEF}
signalTab={signalTab}
onChange={setCurrentRoot}
onChange={handleRootChange}
selectedNodeId={selectedNodeId}
onSelectNode={setSelectedNodeId}
layoutSeed={layoutSeed}
@@ -1097,14 +1180,14 @@ export default function StrategyEditorPage({ theme }: Props) {
if (!selectedNodeId) return;
const inOrphans = currentOrphans.some(o => o.id === selectedNodeId);
if (inOrphans) {
setCurrentOrphans(currentOrphans.map(o => (
handleOrphansChange(currentOrphans.map(o => (
o.id === selectedNodeId ? updateNode(o, selectedNodeId, n => ({ ...n, condition: c })) : o
)));
return;
}
if (currentRoot && findLogicNode(currentRoot, currentOrphans, selectedNodeId, currentLayout.extraRoots ?? {})) {
if (findLogicNode(currentRoot, [], selectedNodeId)) {
setCurrentRoot(updateNode(currentRoot, selectedNodeId, n => ({ ...n, condition: c })));
handleRootChange(updateNode(currentRoot, selectedNodeId, n => ({ ...n, condition: c })));
return;
}
for (const [startId, branch] of Object.entries(currentLayout.extraRoots ?? {})) {
@@ -1131,7 +1214,7 @@ export default function StrategyEditorPage({ theme }: Props) {
onEditorStateChange={handleEditorStateChange}
onAddStart={handleAddStartSection}
orphans={currentOrphans}
onOrphansChange={setCurrentOrphans}
onOrphansChange={handleOrphansChange}
/>
)}
+2 -5
View File
@@ -66,11 +66,8 @@ interface ValidationResult {
let _cnt = 0;
const genId = () => `n_${++_cnt}_${Date.now()}`;
const STORAGE_KEY = 'gc_strat_v2';
const loadStratsLocal = (): StrategyDto[] => {
try { return JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '[]'); } catch { return []; }
};
const saveStratsLocal = (s: StrategyDto[]) => localStorage.setItem(STORAGE_KEY, JSON.stringify(s));
const loadStratsLocal = (): StrategyDto[] => [];
const saveStratsLocal = (_s: StrategyDto[]) => { /* DB only */ };
// ─── 기본 파라미터 (IndicatorSettings 미사용 → 하드코딩 기본값) ─────────────
/** 지표별 hline 임계값 (과열선/중앙선/침체선) */
+30 -23
View File
@@ -40,30 +40,18 @@ import LayoutPicker from './LayoutPicker';
import { DEFAULT_SYNC, type LayoutDef, type SyncOptions } from '../utils/layoutTypes';
import { loadStrategies } from '../utils/backendApi';
const LOCAL_BT_KEY = 'gc_strat_v2';
interface BtStrategy { id?: number; name: string; source: 'db' | 'local'; buyCondition?: unknown; sellCondition?: unknown; }
interface BtStrategy { id?: number; name: string; source: 'db'; buyCondition?: unknown; sellCondition?: unknown; }
function loadBtStrategies(): Promise<BtStrategy[]> {
return loadStrategies()
.then(list => {
if (list && list.length > 0) {
return list.map(s => ({ id: s.id, name: s.name, source: 'db' as const, buyCondition: s.buyCondition, sellCondition: s.sellCondition }));
}
// localStorage 폴백
try {
const local: { id?: number; name: string; buyCondition?: unknown; sellCondition?: unknown }[] =
JSON.parse(localStorage.getItem(LOCAL_BT_KEY) ?? '[]');
return local.map(s => ({ id: s.id, name: s.name, source: 'local' as const, buyCondition: s.buyCondition, sellCondition: s.sellCondition }));
} catch { return []; }
})
.catch(() => {
try {
const local: { id?: number; name: string; buyCondition?: unknown; sellCondition?: unknown }[] =
JSON.parse(localStorage.getItem(LOCAL_BT_KEY) ?? '[]');
return local.map(s => ({ id: s.id, name: s.name, source: 'local' as const, buyCondition: s.buyCondition, sellCondition: s.sellCondition }));
} catch { return []; }
});
.then(list => (list ?? []).map(s => ({
id: s.id,
name: s.name,
source: 'db' as const,
buyCondition: s.buyCondition,
sellCondition: s.sellCondition,
})))
.catch(() => []);
}
export interface ToolbarProps {
@@ -98,6 +86,8 @@ export interface ToolbarProps {
/** 매매 시그널 알림 (미확인 개수) */
tradeNotifyUnread?: number;
onOpenTradeNotifications?: () => void;
/** 모바일 앱 다운로드 */
onOpenAppDownload?: () => void;
/** 자석모드 현재 상태 */
magnetMode?: 'off' | 'weak' | 'strong';
/** 자석모드 토글 (off↔strong) */
@@ -233,6 +223,14 @@ const IcBell = () => (
<circle cx="7.5" cy="13" r="0.8" fill="currentColor" stroke="none"/>
</svg>
);
/** 모바일 앱 다운로드 */
const IcAppDownload = () => (
<svg width="15" height="15" viewBox="0 0 15 15" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round">
<rect x="4" y="1.5" width="7" height="12" rx="1.5"/>
<line x1="7.5" y1="9" x2="7.5" y2="12"/>
<polyline points="5.5,10.5 7.5,12.5 9.5,10.5"/>
</svg>
);
/** 매매 시그널 알림 (번개 아이콘으로 가격 알림 벨과 구분) */
const IcTradeSignal = () => (
<svg width="15" height="15" viewBox="0 0 15 15" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
@@ -785,7 +783,7 @@ const Toolbar: React.FC<ToolbarProps> = ({
onFitContent, onScrollToNow, onAddIndicator, onAddIndicators, onIndicatorOrderChange, onRemoveByType,
onAutoFib, onClearFib, onScreenshot,
onToggleStats, onToggleWatch, onToggleAlert,
tradeNotifyUnread = 0, onOpenTradeNotifications,
tradeNotifyUnread = 0, onOpenTradeNotifications, onOpenAppDownload,
magnetMode = 'off', onToggleMagnet,
onFullscreen, onUndo, onRedo, canUndo, canRedo, onClearDrawings,
onToggleGrid,
@@ -1091,7 +1089,6 @@ const Toolbar: React.FC<ToolbarProps> = ({
<li key={i} className="bt-drop-item">
<span className="bt-drop-name">
{s.name}
{s.source === 'local' && <span className="bt-drop-local-badge"></span>}
</span>
<button
className={`bt-drop-run${isRunning ? ' running' : ''}`}
@@ -1283,6 +1280,16 @@ const Toolbar: React.FC<ToolbarProps> = ({
</svg>
</button>
)}
{onOpenAppDownload && (
<button
className="tv-icon-btn"
onClick={onOpenAppDownload}
title="GoldenChart 모바일 앱 다운로드"
aria-label="앱 다운로드"
>
<IcAppDownload />
</button>
)}
{onOpenTradeNotifications && (
<span className="tv-trade-notify-wrap">
<button
+22
View File
@@ -21,6 +21,8 @@ interface TopMenuBarProps {
/** 미확인 매매 시그널 알림 수 */
tradeNotifyUnread?: number;
onOpenNotifications?: () => void;
/** 모바일 앱 다운로드 모달 */
onOpenAppDownload?: () => void;
/** 우측에 떠 있는 알림 팝업(토스트) 개수 */
tradeNotifyToastCount?: number;
/** 모든 알림 팝업 닫기 */
@@ -79,6 +81,14 @@ const IcSettings = () => (
</svg>
);
const IcAppDownload = () => (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<rect x="4.5" y="1.5" width="7" height="13" rx="1.5"/>
<line x1="8" y1="9" x2="8" y2="12.5"/>
<polyline points="6,10.5 8,12.5 10,10.5"/>
</svg>
);
const IcNotify = () => (
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
<path d="M8 1.5a4.5 4.5 0 0 0-4.5 4.5c0 3.5-1 4.5-1.5 4.5h12c-.5 0-1.5-1-1.5-4.5A4.5 4.5 0 0 0 8 1.5z"/>
@@ -160,6 +170,7 @@ export const TopMenuBar = memo(function TopMenuBar({
activePage, theme, onPage, onTheme,
tradeNotifyUnread = 0,
onOpenNotifications,
onOpenAppDownload,
tradeNotifyToastCount = 0,
onDismissAllNotifyPopups,
tradeAlertPopupPosition = 'right',
@@ -209,6 +220,17 @@ export const TopMenuBar = memo(function TopMenuBar({
{/* 우측 영역 */}
<div className="tmb-right">
{onOpenAppDownload && (
<button
type="button"
className="tmb-app-download-btn"
onClick={onOpenAppDownload}
title="GoldenChart 모바일 앱 다운로드"
aria-label="앱 다운로드"
>
<IcAppDownload />
</button>
)}
{onOpenNotifications && showNotifications && (
<div className="tmb-notify-group">
<button
+2 -2
View File
@@ -221,8 +221,8 @@ const TradingChart: React.FC<TradingChartProps> = ({
const prevTheme = useRef<Theme>(theme);
const prevLogScale = useRef<boolean>(logScale);
const indicatorsRef = useRef(indicators);
const recoveryTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const resizeDebounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const recoveryTimerRef = useRef<number | null>(null);
const resizeDebounceRef = useRef<number | null>(null);
const chartVisibleRef = useRef(chartVisible);
const [chartMgr, setChartMgr] = useState<ChartManager | null>(null);
+8 -13
View File
@@ -33,16 +33,6 @@ import { autoAddTrendSearchTargets } from '../utils/trendSearchAutoAddTargets';
import '../styles/trendSearchDashboard.css';
import '../styles/virtualTradingDashboard.css';
const DISPLAY_MODE_KEY = 'tsd-display-mode';
function loadDisplayMode(): TrendSearchDisplayMode {
try {
const v = localStorage.getItem(DISPLAY_MODE_KEY);
if (v === 'chart' || v === 'summary') return v;
} catch { /* ignore */ }
return 'summary';
}
interface Props {
theme?: Theme;
chartRealtimeSource?: ChartRealtimeSource;
@@ -68,7 +58,9 @@ const TrendSearchPage: React.FC<Props> = ({
const [results, setResults] = useState<TrendSearchResultDto[]>([]);
const [selectedMarket, setSelectedMarket] = useState<string | null>(null);
const [searching, setSearching] = useState(false);
const [displayMode, setDisplayMode] = useState<TrendSearchDisplayMode>(() => loadDisplayMode());
const [displayMode, setDisplayMode] = useState<TrendSearchDisplayMode>(
() => tsSettings.displayMode ?? 'summary',
);
const [flashMarkets, setFlashMarkets] = useState<Set<string>>(new Set());
const [lastUpdatedAt, setLastUpdatedAt] = useState<number>(Date.now());
const filtersRef = useRef(filters);
@@ -84,6 +76,7 @@ const TrendSearchPage: React.FC<Props> = ({
if (lastSyncedSettingsSigRef.current === tsSettingsSig) return;
lastSyncedSettingsSigRef.current = tsSettingsSig;
setFilters(prev => mergeFiltersFromTrendSearchSettings(prev, tsSettings));
if (tsSettings.displayMode) setDisplayMode(tsSettings.displayMode);
}, [appSettingsLoaded, tsSettingsSig, tsSettings]);
useEffect(() => {
@@ -168,8 +161,10 @@ const TrendSearchPage: React.FC<Props> = ({
}, [tsSettings.autoRefreshEnabled, tsSettings.autoRefreshSeconds, loadFromDb]);
useEffect(() => {
try { localStorage.setItem(DISPLAY_MODE_KEY, displayMode); } catch { /* ignore */ }
}, [displayMode]);
if (!appSettingsLoaded) return;
if (tsSettings.displayMode === displayMode) return;
persistTrendSettings({ displayMode });
}, [displayMode, appSettingsLoaded, tsSettings.displayMode, persistTrendSettings]);
const handleSelect = useCallback(async (row: TrendSearchResultDto) => {
setSelectedMarket(row.market);
@@ -1,4 +1,5 @@
import { useCallback } from 'react';
import { getUiPreferences, patchUiPreferences } from '../../utils/uiPreferencesDb';
function clamp(value: number, min: number, max: number): number {
return Math.min(max, Math.max(min, value));
@@ -49,9 +50,9 @@ export function usePanelResize(
export function readStoredSize(key: string, fallback: number): number {
try {
const raw = localStorage.getItem(key);
if (!raw) return fallback;
const n = Number(raw);
const raw = getUiPreferences().panels?.[key];
if (raw == null) return fallback;
const n = typeof raw === 'number' ? raw : Number(raw);
return Number.isFinite(n) ? n : fallback;
} catch {
return fallback;
@@ -59,22 +60,20 @@ export function readStoredSize(key: string, fallback: number): number {
}
export function storeSize(key: string, value: number): void {
try {
localStorage.setItem(key, String(Math.round(value)));
} catch { /* ignore */ }
const panels = { ...getUiPreferences().panels, [key]: Math.round(value) };
patchUiPreferences({ panels });
}
export function readStoredBool(key: string, fallback: boolean): boolean {
try {
const raw = localStorage.getItem(key);
if (raw === '1' || raw === 'true') return true;
if (raw === '0' || raw === 'false') return false;
const raw = getUiPreferences().panels?.[key];
if (raw === true || raw === '1' || raw === 'true') return true;
if (raw === false || raw === '0' || raw === 'false') return false;
} catch { /* ignore */ }
return fallback;
}
export function storeBool(key: string, value: boolean): void {
try {
localStorage.setItem(key, value ? '1' : '0');
} catch { /* ignore */ }
const panels = { ...getUiPreferences().panels, [key]: value };
patchUiPreferences({ panels });
}