From fe812389cc59136bee14b73bddc8706948843ff9 Mon Sep 17 00:00:00 2001 From: Macbook Date: Tue, 26 May 2026 23:50:13 +0900 Subject: [PATCH] =?UTF-8?q?=EC=A0=80=EC=9E=A5=ED=8C=9D=EC=97=85=20?= =?UTF-8?q?=ED=85=8D=EC=8A=A4=ED=8A=B8=20=EC=83=89=EC=83=81=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/App.css | 201 +++++++++++++++++- .../components/IndicatorCustomTabEditor.tsx | 165 ++++++++++++++ .../src/components/StrategyEditorPage.tsx | 22 +- frontend/src/components/Toolbar.tsx | 175 ++++++++++++--- .../components/strategyEditor/FlowNodes.tsx | 6 +- .../strategyEditor/LogicExpressionPreview.tsx | 11 +- .../strategyEditor/StrategyListEditor.tsx | 5 +- frontend/src/styles/strategyEditor.css | 25 ++- frontend/src/utils/ChartManager.ts | 65 ++++++ .../src/utils/indicatorCustomTabsStorage.ts | 73 +++++++ frontend/src/utils/indicatorMainTab.ts | 16 +- frontend/src/utils/strategyEditorShared.tsx | 10 +- frontend/src/utils/strategyPaletteStorage.ts | 16 ++ 13 files changed, 728 insertions(+), 62 deletions(-) create mode 100644 frontend/src/components/IndicatorCustomTabEditor.tsx create mode 100644 frontend/src/utils/indicatorCustomTabsStorage.ts diff --git a/frontend/src/App.css b/frontend/src/App.css index 11b486a..f0257f6 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -1105,6 +1105,7 @@ html.theme-blue { /* 패널 본체 */ .ind-panel { + position: relative; width: min(520px, 95vw); max-height: calc(100vh - 48px); background: var(--bg2); @@ -1173,19 +1174,60 @@ html.theme-blue { .ind-panel-close:hover { background: var(--bg4); color: var(--text); } /* 카테고리 탭 */ +.ind-panel-cats-row { + display: flex; + align-items: stretch; + gap: 6px; + padding: 7px 10px; + border-bottom: 1px solid var(--border); + flex-shrink: 0; + min-height: 0; +} .ind-panel-cats { display: flex; gap: 2px; - padding: 7px 10px; - border-bottom: 1px solid var(--border); overflow-x: auto; overflow-y: hidden; - flex-shrink: 0; + flex: 1; + min-width: 0; flex-wrap: nowrap; scrollbar-width: thin; scrollbar-color: var(--bg4) transparent; -webkit-overflow-scrolling: touch; } +.ind-panel-cats-actions { + display: flex; + align-items: center; + gap: 4px; + flex-shrink: 0; + padding-left: 4px; + border-left: 1px solid var(--border); +} +.ind-panel-cat-action { + padding: 4px 8px; + background: var(--bg3); + color: var(--text2); + border: 1px solid var(--border); + border-radius: 4px; + cursor: pointer; + font-size: 11px; + font-weight: 500; + white-space: nowrap; + transition: all 0.1s; +} +.ind-panel-cat-action:hover:not(:disabled) { + background: var(--bg4); + color: var(--text); +} +.ind-panel-cat-action:disabled { + opacity: 0.35; + cursor: not-allowed; +} +.ind-panel-cat-action.danger:hover:not(:disabled) { + background: rgba(239, 68, 68, 0.15); + color: #ef4444; + border-color: rgba(239, 68, 68, 0.35); +} .ind-panel-cats::-webkit-scrollbar { height: 5px; } @@ -1210,6 +1252,159 @@ html.theme-blue { .ind-panel-cat.active { background: var(--accent); color: #fff; } .ind-panel-cat-cnt { font-size: 10px; opacity: 0.75; } +/* 사용자 정의 탭 편집 */ +.ind-tab-editor-overlay { + position: absolute; + inset: 0; + z-index: 10; + display: flex; + align-items: center; + justify-content: center; + background: rgba(0, 0, 0, 0.55); + border-radius: inherit; +} +.ind-tab-editor { + display: flex; + flex-direction: column; + width: min(420px, calc(100% - 24px)); + max-height: calc(100% - 24px); + background: var(--bg2); + border: 1px solid var(--border); + border-radius: 8px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.45); + overflow: hidden; +} +.ind-tab-editor-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 10px 12px; + border-bottom: 1px solid var(--border); + flex-shrink: 0; +} +.ind-tab-editor-title { + font-size: 14px; + font-weight: 600; + color: var(--text); +} +.ind-tab-editor-body { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + gap: 10px; + padding: 12px; + overflow: hidden; +} +.ind-tab-editor-field { + display: flex; + flex-direction: column; + gap: 6px; + flex-shrink: 0; +} +.ind-tab-editor-label-row { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} +.ind-tab-editor-label { + font-size: 11px; + font-weight: 600; + color: var(--text2); + flex: 1; +} +.ind-tab-editor-link { + background: none; + border: none; + color: var(--accent); + font-size: 11px; + cursor: pointer; + padding: 0; +} +.ind-tab-editor-link:hover { text-decoration: underline; } +.ind-tab-editor-input { + width: 100%; + padding: 7px 10px; + background: var(--bg3); + border: 1px solid var(--border); + border-radius: 4px; + color: var(--text); + font-size: 13px; + box-sizing: border-box; +} +.ind-tab-editor-input:focus { + outline: none; + border-color: var(--accent); +} +.ind-tab-editor-list { + flex: 1; + min-height: 120px; + overflow-y: auto; + border: 1px solid var(--border); + border-radius: 4px; + background: var(--bg1); +} +.ind-tab-editor-item { + display: flex; + align-items: flex-start; + gap: 8px; + padding: 8px 10px; + cursor: pointer; + border-bottom: 1px solid var(--border); + transition: background 0.1s; +} +.ind-tab-editor-item:last-child { border-bottom: none; } +.ind-tab-editor-item:hover { background: var(--bg3); } +.ind-tab-editor-item.checked { background: #2962ff12; } +.ind-tab-editor-item input { + margin-top: 3px; + flex-shrink: 0; + accent-color: var(--accent); +} +.ind-tab-editor-item-info { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} +.ind-tab-editor-item-name { + font-size: 13px; + font-weight: 500; + color: var(--text); +} +.ind-tab-editor-item-desc { + font-size: 11px; + color: var(--text2); +} +.ind-tab-editor-footer { + display: flex; + justify-content: flex-end; + gap: 8px; + padding: 10px 12px; + border-top: 1px solid var(--border); + flex-shrink: 0; +} +.ind-tab-editor-btn { + padding: 7px 16px; + border-radius: 4px; + font-size: 13px; + font-weight: 500; + cursor: pointer; + border: 1px solid var(--border); +} +.ind-tab-editor-btn.cancel { + background: var(--bg3); + color: var(--text2); +} +.ind-tab-editor-btn.cancel:hover { color: var(--text); } +.ind-tab-editor-btn.save { + background: var(--accent); + color: #fff; + border-color: var(--accent); +} +.ind-tab-editor-btn.save:hover { filter: brightness(1.08); } + /* 목록 */ .ind-panel-list { flex: 1; diff --git a/frontend/src/components/IndicatorCustomTabEditor.tsx b/frontend/src/components/IndicatorCustomTabEditor.tsx new file mode 100644 index 0000000..7487b5d --- /dev/null +++ b/frontend/src/components/IndicatorCustomTabEditor.tsx @@ -0,0 +1,165 @@ +import React, { useEffect, useMemo, useState } from 'react'; +import { INDICATOR_REGISTRY, type IndicatorDef } from '../utils/indicatorRegistry'; +import { MAIN_INDICATOR_LABELS } from '../utils/indicatorMainTab'; + +interface IndicatorCustomTabEditorProps { + mode: 'create' | 'edit'; + initialName: string; + initialTypes: string[]; + onSave: (name: string, indicatorTypes: string[]) => void; + onClose: () => void; +} + +function indicatorLabel(def: IndicatorDef): { ko: string; en: string } { + const main = MAIN_INDICATOR_LABELS[def.type]; + return main ?? { ko: def.description, en: def.name }; +} + +const IndicatorCustomTabEditor: React.FC = ({ + mode, + initialName, + initialTypes, + onSave, + onClose, +}) => { + const [name, setName] = useState(initialName); + const [selected, setSelected] = useState>(() => new Set(initialTypes)); + const [search, setSearch] = useState(''); + + useEffect(() => { + const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); }; + window.addEventListener('keydown', onKey); + return () => window.removeEventListener('keydown', onKey); + }, [onClose]); + + const filtered = useMemo(() => { + const q = search.toLowerCase().trim(); + if (!q) return INDICATOR_REGISTRY; + return INDICATOR_REGISTRY.filter(d => { + const lbl = indicatorLabel(d); + return ( + lbl.ko.toLowerCase().includes(q) + || lbl.en.toLowerCase().includes(q) + || d.shortName.toLowerCase().includes(q) + || d.type.toLowerCase().includes(q) + ); + }); + }, [search]); + + const toggle = (type: string) => { + setSelected(prev => { + const next = new Set(prev); + if (next.has(type)) next.delete(type); + else next.add(type); + return next; + }); + }; + + const handleSave = () => { + const trimmed = name.trim(); + if (!trimmed) { + window.alert('탭 이름을 입력해 주세요.'); + return; + } + if (selected.size === 0) { + window.alert('탭에 표시할 지표를 하나 이상 선택해 주세요.'); + return; + } + const ordered = INDICATOR_REGISTRY + .filter(d => selected.has(d.type)) + .map(d => d.type); + onSave(trimmed, ordered); + }; + + return ( +
{ if (e.target === e.currentTarget) onClose(); }} + > +
e.stopPropagation()}> +
+ + {mode === 'create' ? '탭 추가' : '탭 수정'} + + +
+ +
+ + +
+
+ + 지표 선택 ({selected.size}개) + + + +
+ setSearch(e.target.value)} + placeholder="지표 검색..." + /> +
+ +
+ {filtered.length === 0 ? ( +
검색 결과 없음
+ ) : ( + filtered.map(def => { + const lbl = indicatorLabel(def); + const checked = selected.has(def.type); + return ( + + ); + }) + )} +
+
+ +
+ + +
+
+
+ ); +}; + +export default IndicatorCustomTabEditor; diff --git a/frontend/src/components/StrategyEditorPage.tsx b/frontend/src/components/StrategyEditorPage.tsx index e2c5c51..407025e 100644 --- a/frontend/src/components/StrategyEditorPage.tsx +++ b/frontend/src/components/StrategyEditorPage.tsx @@ -145,6 +145,7 @@ export default function StrategyEditorPage({ theme }: Props) { const [stratDesc, setStratDesc] = useState(''); const [isSaving, setIsSaving] = useState(false); const [saveOpen, setSaveOpen] = useState(false); + const [saveNameError, setSaveNameError] = useState(false); const [deleteOpen, setDeleteOpen] = useState(false); const [descOpen, setDescOpen] = useState(false); const [deleteId, setDeleteId] = useState(null); @@ -493,7 +494,12 @@ export default function StrategyEditorPage({ theme }: Props) { }; const handleSave = async () => { - if (!stratName.trim()) { showSnack('전략 이름을 입력하세요', false); return; } + if (!stratName.trim()) { + setSaveNameError(true); + showSnack('전략 이름을 입력하세요', false); + return; + } + setSaveNameError(false); const encodedBuy = encodeConditionForSave(buyEditorState); const encodedSell = encodeConditionForSave(sellEditorState); if (!encodedBuy && !encodedSell) { showSnack('조건을 최소 1개 추가하세요', false); return; } @@ -830,7 +836,7 @@ export default function StrategyEditorPage({ theme }: Props) { > ⬆ - + @@ -1166,10 +1172,18 @@ export default function StrategyEditorPage({ theme }: Props) { {saveOpen && ( - setSaveOpen(false)} title="전략 저장"> + { setSaveNameError(false); setSaveOpen(false); }} title="전략 저장">
- setStratName(e.target.value)} placeholder="예: Golden_RSI_V1" /> + { setStratName(e.target.value); setSaveNameError(false); }} + placeholder="예: Golden_RSI_V1" + /> + {saveNameError && ( +

전략 이름을 입력하세요

+ )}