mobile download
This commit is contained in:
@@ -3,3 +3,6 @@
|
||||
|
||||
# Spring Boot 백엔드 URL (Vite 프록시 사용 시 /api 로 통일)
|
||||
VITE_API_BASE_URL=/api
|
||||
|
||||
# 로컬 dev: /api/mobile-app → exdev (QR·APK 다운로드 URL). vite.config.ts 기본값과 동일
|
||||
# VITE_MOBILE_APP_PROXY_TARGET=http://exdev.co.kr
|
||||
|
||||
@@ -109,6 +109,20 @@ server {
|
||||
proxy_buffering off;
|
||||
}
|
||||
|
||||
# ── 모바일 APK 다운로드 (대용량) ─────────────────────────────────────
|
||||
location /api/mobile-app/download/ {
|
||||
proxy_pass http://backend:8080;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_read_timeout 600s;
|
||||
proxy_send_timeout 600s;
|
||||
proxy_buffering off;
|
||||
client_max_body_size 200m;
|
||||
}
|
||||
|
||||
# ── React SPA 폴백 ────────────────────────────────────────────────────
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
|
||||
Generated
+449
-116
@@ -13,11 +13,13 @@
|
||||
"lightweight-charts": "^5.2.0",
|
||||
"lightweight-charts-indicators": "^0.4.1",
|
||||
"oakscriptjs": "^0.2.8",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"sockjs-client": "^1.6.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/react": "^18.3.1",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@types/sockjs-client": "^1.5.4",
|
||||
@@ -27,13 +29,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/code-frame": {
|
||||
"version": "7.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
|
||||
"integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
|
||||
"integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-validator-identifier": "^7.28.5",
|
||||
"@babel/helper-validator-identifier": "^7.29.7",
|
||||
"js-tokens": "^4.0.0",
|
||||
"picocolors": "^1.1.1"
|
||||
},
|
||||
@@ -42,9 +44,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/compat-data": {
|
||||
"version": "7.29.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.3.tgz",
|
||||
"integrity": "sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz",
|
||||
"integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -52,21 +54,21 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/core": {
|
||||
"version": "7.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz",
|
||||
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz",
|
||||
"integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.29.0",
|
||||
"@babel/generator": "^7.29.0",
|
||||
"@babel/helper-compilation-targets": "^7.28.6",
|
||||
"@babel/helper-module-transforms": "^7.28.6",
|
||||
"@babel/helpers": "^7.28.6",
|
||||
"@babel/parser": "^7.29.0",
|
||||
"@babel/template": "^7.28.6",
|
||||
"@babel/traverse": "^7.29.0",
|
||||
"@babel/types": "^7.29.0",
|
||||
"@babel/code-frame": "^7.29.7",
|
||||
"@babel/generator": "^7.29.7",
|
||||
"@babel/helper-compilation-targets": "^7.29.7",
|
||||
"@babel/helper-module-transforms": "^7.29.7",
|
||||
"@babel/helpers": "^7.29.7",
|
||||
"@babel/parser": "^7.29.7",
|
||||
"@babel/template": "^7.29.7",
|
||||
"@babel/traverse": "^7.29.7",
|
||||
"@babel/types": "^7.29.7",
|
||||
"@jridgewell/remapping": "^2.3.5",
|
||||
"convert-source-map": "^2.0.0",
|
||||
"debug": "^4.1.0",
|
||||
@@ -83,14 +85,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/generator": {
|
||||
"version": "7.29.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz",
|
||||
"integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz",
|
||||
"integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/parser": "^7.29.0",
|
||||
"@babel/types": "^7.29.0",
|
||||
"@babel/parser": "^7.29.7",
|
||||
"@babel/types": "^7.29.7",
|
||||
"@jridgewell/gen-mapping": "^0.3.12",
|
||||
"@jridgewell/trace-mapping": "^0.3.28",
|
||||
"jsesc": "^3.0.2"
|
||||
@@ -100,14 +102,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-compilation-targets": {
|
||||
"version": "7.28.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz",
|
||||
"integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz",
|
||||
"integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/compat-data": "^7.28.6",
|
||||
"@babel/helper-validator-option": "^7.27.1",
|
||||
"@babel/compat-data": "^7.29.7",
|
||||
"@babel/helper-validator-option": "^7.29.7",
|
||||
"browserslist": "^4.24.0",
|
||||
"lru-cache": "^5.1.1",
|
||||
"semver": "^6.3.1"
|
||||
@@ -117,9 +119,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-globals": {
|
||||
"version": "7.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz",
|
||||
"integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz",
|
||||
"integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -127,29 +129,29 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-module-imports": {
|
||||
"version": "7.28.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz",
|
||||
"integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz",
|
||||
"integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/traverse": "^7.28.6",
|
||||
"@babel/types": "^7.28.6"
|
||||
"@babel/traverse": "^7.29.7",
|
||||
"@babel/types": "^7.29.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-module-transforms": {
|
||||
"version": "7.28.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz",
|
||||
"integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz",
|
||||
"integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-module-imports": "^7.28.6",
|
||||
"@babel/helper-validator-identifier": "^7.28.5",
|
||||
"@babel/traverse": "^7.28.6"
|
||||
"@babel/helper-module-imports": "^7.29.7",
|
||||
"@babel/helper-validator-identifier": "^7.29.7",
|
||||
"@babel/traverse": "^7.29.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
@@ -159,9 +161,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-plugin-utils": {
|
||||
"version": "7.28.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz",
|
||||
"integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz",
|
||||
"integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -169,9 +171,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-string-parser": {
|
||||
"version": "7.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
|
||||
"integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
|
||||
"integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -179,9 +181,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-validator-identifier": {
|
||||
"version": "7.28.5",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
|
||||
"integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
|
||||
"integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -189,9 +191,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-validator-option": {
|
||||
"version": "7.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz",
|
||||
"integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz",
|
||||
"integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -199,27 +201,27 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helpers": {
|
||||
"version": "7.29.2",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz",
|
||||
"integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz",
|
||||
"integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/template": "^7.28.6",
|
||||
"@babel/types": "^7.29.0"
|
||||
"@babel/template": "^7.29.7",
|
||||
"@babel/types": "^7.29.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/parser": {
|
||||
"version": "7.29.3",
|
||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz",
|
||||
"integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz",
|
||||
"integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/types": "^7.29.0"
|
||||
"@babel/types": "^7.29.7"
|
||||
},
|
||||
"bin": {
|
||||
"parser": "bin/babel-parser.js"
|
||||
@@ -229,13 +231,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/plugin-transform-react-jsx-self": {
|
||||
"version": "7.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz",
|
||||
"integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz",
|
||||
"integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-plugin-utils": "^7.27.1"
|
||||
"@babel/helper-plugin-utils": "^7.29.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
@@ -245,13 +247,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/plugin-transform-react-jsx-source": {
|
||||
"version": "7.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz",
|
||||
"integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz",
|
||||
"integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-plugin-utils": "^7.27.1"
|
||||
"@babel/helper-plugin-utils": "^7.29.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
@@ -261,33 +263,33 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/template": {
|
||||
"version": "7.28.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
|
||||
"integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
|
||||
"integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.28.6",
|
||||
"@babel/parser": "^7.28.6",
|
||||
"@babel/types": "^7.28.6"
|
||||
"@babel/code-frame": "^7.29.7",
|
||||
"@babel/parser": "^7.29.7",
|
||||
"@babel/types": "^7.29.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/traverse": {
|
||||
"version": "7.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz",
|
||||
"integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz",
|
||||
"integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.29.0",
|
||||
"@babel/generator": "^7.29.0",
|
||||
"@babel/helper-globals": "^7.28.0",
|
||||
"@babel/parser": "^7.29.0",
|
||||
"@babel/template": "^7.28.6",
|
||||
"@babel/types": "^7.29.0",
|
||||
"@babel/code-frame": "^7.29.7",
|
||||
"@babel/generator": "^7.29.7",
|
||||
"@babel/helper-globals": "^7.29.7",
|
||||
"@babel/parser": "^7.29.7",
|
||||
"@babel/template": "^7.29.7",
|
||||
"@babel/types": "^7.29.7",
|
||||
"debug": "^4.3.1"
|
||||
},
|
||||
"engines": {
|
||||
@@ -295,14 +297,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/types": {
|
||||
"version": "7.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz",
|
||||
"integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz",
|
||||
"integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-string-parser": "^7.27.1",
|
||||
"@babel/helper-validator-identifier": "^7.28.5"
|
||||
"@babel/helper-string-parser": "^7.29.7",
|
||||
"@babel/helper-validator-identifier": "^7.29.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
@@ -1252,6 +1254,16 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "25.9.1",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz",
|
||||
"integrity": "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"undici-types": ">=7.24.0 <7.24.7"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/prop-types": {
|
||||
"version": "15.7.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz",
|
||||
@@ -1259,10 +1271,20 @@
|
||||
"devOptional": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/qrcode": {
|
||||
"version": "1.5.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz",
|
||||
"integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/react": {
|
||||
"version": "18.3.28",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.28.tgz",
|
||||
"integrity": "sha512-z9VXpC7MWrhfWipitjNdgCauoMLRdIILQsAEV+ZesIzBq/oUlxk0m3ApZuMFCXdnS4U7KrI+l3WRUEGQ8K1QKw==",
|
||||
"version": "18.3.29",
|
||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.29.tgz",
|
||||
"integrity": "sha512-ch0qJdr2JY0r04NXSprbK6TXOgnaJ1Tz23fm5W+z0/CBah6BSBc3n96h7K9GOtwh0HrilNWHIBzE1Ko4Dcw/Wg==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -1340,10 +1362,34 @@
|
||||
"d3-zoom": "^3.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-regex": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/ansi-styles": {
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
|
||||
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-convert": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/baseline-browser-mapping": {
|
||||
"version": "2.10.29",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.29.tgz",
|
||||
"integrity": "sha512-Asa2krT+XTPZINCS+2QcyS8WTkObE77RwkydwF7h6DmnKqbvlalz93m/dnphUyCa6SWSP51VgtEUf2FN+gelFQ==",
|
||||
"version": "2.10.32",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.32.tgz",
|
||||
"integrity": "sha512-wbPvpyjJPC0zdfdKXxqEL3Ea+bOMD/87X4lftiJkkaBiuG6ALQy1SLmEd7BSmVCuwCQsBrCamgBoLyfFDD1EPg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
@@ -1387,10 +1433,19 @@
|
||||
"node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7"
|
||||
}
|
||||
},
|
||||
"node_modules/camelcase": {
|
||||
"version": "5.3.1",
|
||||
"resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz",
|
||||
"integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/caniuse-lite": {
|
||||
"version": "1.0.30001792",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001792.tgz",
|
||||
"integrity": "sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw==",
|
||||
"version": "1.0.30001793",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001793.tgz",
|
||||
"integrity": "sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -1414,6 +1469,35 @@
|
||||
"integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cliui": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz",
|
||||
"integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"string-width": "^4.2.0",
|
||||
"strip-ansi": "^6.0.0",
|
||||
"wrap-ansi": "^6.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/color-convert": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"color-name": "~1.1.4"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=7.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/color-name": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
|
||||
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/convert-source-map": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
|
||||
@@ -1551,13 +1635,34 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/decamelize": {
|
||||
"version": "1.2.0",
|
||||
"resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz",
|
||||
"integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/dijkstrajs": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz",
|
||||
"integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/electron-to-chromium": {
|
||||
"version": "1.5.356",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.356.tgz",
|
||||
"integrity": "sha512-9NgFd7m5t5MCJ5rUSjJITUXAH9mEGlrlofnMf4YEr+pz6JlP7cWmTAH+JFmbPnaSW8koVTkuW7pacORWAnA5Yw==",
|
||||
"version": "1.5.363",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.363.tgz",
|
||||
"integrity": "sha512-VjUKPyWzGnT1fujlkEGC/BvN70Hh70KXtAqcmniXviYlJC/ivcT+BWGPyxWVbJZLfvtKR6dqg1L7T7pgAMBtWA==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/emoji-regex": {
|
||||
"version": "8.0.0",
|
||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
|
||||
@@ -1634,6 +1739,19 @@
|
||||
"node": ">=0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/find-up": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz",
|
||||
"integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"locate-path": "^5.0.0",
|
||||
"path-exists": "^4.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/fsevents": {
|
||||
"version": "2.3.3",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
|
||||
@@ -1659,6 +1777,15 @@
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/get-caller-file": {
|
||||
"version": "2.0.5",
|
||||
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
|
||||
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
|
||||
"license": "ISC",
|
||||
"engines": {
|
||||
"node": "6.* || 8.* || >= 10.*"
|
||||
}
|
||||
},
|
||||
"node_modules/http-parser-js": {
|
||||
"version": "0.5.10",
|
||||
"resolved": "https://registry.npmjs.org/http-parser-js/-/http-parser-js-0.5.10.tgz",
|
||||
@@ -1671,6 +1798,15 @@
|
||||
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/is-fullwidth-code-point": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
|
||||
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/js-tokens": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
|
||||
@@ -1714,13 +1850,23 @@
|
||||
},
|
||||
"node_modules/lightweight-charts-indicators": {
|
||||
"version": "0.4.1",
|
||||
"resolved": "https://registry.npmjs.org/lightweight-charts-indicators/-/lightweight-charts-indicators-0.4.1.tgz",
|
||||
"integrity": "sha512-l/ULwRz63xr1m9huktB0US/iginUQYDxEbeowqpPV/ZzLb9LRoSewlOEb0qJVygANUaXeaRmchPLDyMLmFYhAA==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"oakscriptjs": "^0.2.8"
|
||||
}
|
||||
},
|
||||
"node_modules/locate-path": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz",
|
||||
"integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-locate": "^4.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/loose-envify": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
||||
@@ -1769,16 +1915,17 @@
|
||||
}
|
||||
},
|
||||
"node_modules/node-releases": {
|
||||
"version": "2.0.44",
|
||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.44.tgz",
|
||||
"integrity": "sha512-5WUyunoPMsvvEhS8AxHtRzP+oA8UCkJ7YRxatWKjngndhDGLiqEVAQKWjFAiAiuL8zMRGzGSJxFnLetoa43qGQ==",
|
||||
"version": "2.0.46",
|
||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.46.tgz",
|
||||
"integrity": "sha512-GYVXHE2KnrzAfsAjl4uP++evGFCrAU1jta4ubEjIG7YWt/64Gqv66a30yKwWczVjA6j3bM4nBwH7Pk1JmDHaxQ==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/oakscriptjs": {
|
||||
"version": "0.2.8",
|
||||
"resolved": "https://registry.npmjs.org/oakscriptjs/-/oakscriptjs-0.2.8.tgz",
|
||||
"integrity": "sha512-SRLR1QpnO3k8MNFzT5ZLEauWeULAcDTJ+0L0JXtshMvfDc5f8ezXVwgQ3D9Lv2g4aiUbJNmK+sNuZ8NKeC1I7Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
@@ -1787,6 +1934,51 @@
|
||||
"lightweight-charts": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/p-limit": {
|
||||
"version": "2.3.0",
|
||||
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz",
|
||||
"integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-try": "^2.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/p-locate": {
|
||||
"version": "4.1.0",
|
||||
"resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz",
|
||||
"integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"p-limit": "^2.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/p-try": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz",
|
||||
"integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/path-exists": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
||||
"integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/picocolors": {
|
||||
"version": "1.1.1",
|
||||
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
|
||||
@@ -1794,10 +1986,19 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/pngjs": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz",
|
||||
"integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.14",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz",
|
||||
"integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==",
|
||||
"version": "8.5.15",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz",
|
||||
"integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -1815,7 +2016,7 @@
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.11",
|
||||
"nanoid": "^3.3.12",
|
||||
"picocolors": "^1.1.1",
|
||||
"source-map-js": "^1.2.1"
|
||||
},
|
||||
@@ -1823,6 +2024,23 @@
|
||||
"node": "^10 || ^12 || >=14"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode": {
|
||||
"version": "1.5.4",
|
||||
"resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz",
|
||||
"integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"dijkstrajs": "^1.0.1",
|
||||
"pngjs": "^5.0.0",
|
||||
"yargs": "^15.3.1"
|
||||
},
|
||||
"bin": {
|
||||
"qrcode": "bin/qrcode"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/querystringify": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz",
|
||||
@@ -1864,6 +2082,21 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/require-directory": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
|
||||
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/require-main-filename": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz",
|
||||
"integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/requires-port": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/requires-port/-/requires-port-1.0.0.tgz",
|
||||
@@ -1954,6 +2187,12 @@
|
||||
"semver": "bin/semver.js"
|
||||
}
|
||||
},
|
||||
"node_modules/set-blocking": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
|
||||
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/sockjs-client": {
|
||||
"version": "1.6.1",
|
||||
"resolved": "https://registry.npmjs.org/sockjs-client/-/sockjs-client-1.6.1.tgz",
|
||||
@@ -1992,6 +2231,32 @@
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/string-width": {
|
||||
"version": "4.2.3",
|
||||
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
|
||||
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"emoji-regex": "^8.0.0",
|
||||
"is-fullwidth-code-point": "^3.0.0",
|
||||
"strip-ansi": "^6.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/strip-ansi": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-regex": "^5.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/typescript": {
|
||||
"version": "5.9.3",
|
||||
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
|
||||
@@ -2006,6 +2271,13 @@
|
||||
"node": ">=14.17"
|
||||
}
|
||||
},
|
||||
"node_modules/undici-types": {
|
||||
"version": "7.24.6",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz",
|
||||
"integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/update-browserslist-db": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
|
||||
@@ -2139,6 +2411,32 @@
|
||||
"node": ">=0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/which-module": {
|
||||
"version": "2.0.1",
|
||||
"resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz",
|
||||
"integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/wrap-ansi": {
|
||||
"version": "6.2.0",
|
||||
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz",
|
||||
"integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-styles": "^4.0.0",
|
||||
"string-width": "^4.1.0",
|
||||
"strip-ansi": "^6.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/y18n": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz",
|
||||
"integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/yallist": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",
|
||||
@@ -2146,6 +2444,41 @@
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/yargs": {
|
||||
"version": "15.4.1",
|
||||
"resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz",
|
||||
"integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cliui": "^6.0.0",
|
||||
"decamelize": "^1.2.0",
|
||||
"find-up": "^4.1.0",
|
||||
"get-caller-file": "^2.0.1",
|
||||
"require-directory": "^2.1.1",
|
||||
"require-main-filename": "^2.0.0",
|
||||
"set-blocking": "^2.0.0",
|
||||
"string-width": "^4.2.0",
|
||||
"which-module": "^2.0.0",
|
||||
"y18n": "^4.0.0",
|
||||
"yargs-parser": "^18.1.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/yargs-parser": {
|
||||
"version": "18.1.3",
|
||||
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz",
|
||||
"integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"camelcase": "^5.0.0",
|
||||
"decamelize": "^1.2.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6"
|
||||
}
|
||||
},
|
||||
"node_modules/zustand": {
|
||||
"version": "4.5.7",
|
||||
"resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz",
|
||||
|
||||
@@ -14,11 +14,13 @@
|
||||
"lightweight-charts": "^5.2.0",
|
||||
"lightweight-charts-indicators": "^0.4.1",
|
||||
"oakscriptjs": "^0.2.8",
|
||||
"qrcode": "^1.5.4",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"sockjs-client": "^1.6.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/qrcode": "^1.5.6",
|
||||
"@types/react": "^18.3.1",
|
||||
"@types/react-dom": "^18.3.1",
|
||||
"@types/sockjs-client": "^1.5.4",
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="ko">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
<meta name="theme-color" content="#0f0f23" />
|
||||
<title>GoldenChart 앱 설치</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body {
|
||||
font-family: 'Noto Sans KR', -apple-system, BlinkMacSystemFont, sans-serif;
|
||||
background: linear-gradient(160deg, #1e1e3f 0%, #0f0f23 55%);
|
||||
color: #fff;
|
||||
min-height: 100vh;
|
||||
padding: 24px 20px 40px;
|
||||
}
|
||||
.wrap { max-width: 420px; margin: 0 auto; text-align: center; }
|
||||
.logo {
|
||||
width: 72px; height: 72px; border-radius: 18px;
|
||||
background: linear-gradient(135deg, #8b5cf6, #2196f3);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 28px; font-weight: 800; margin: 0 auto 16px;
|
||||
}
|
||||
h1 { font-size: 22px; margin-bottom: 8px; }
|
||||
.muted { color: #9ca3af; font-size: 14px; line-height: 1.5; margin-bottom: 24px; }
|
||||
.card {
|
||||
background: rgba(255,255,255,0.06);
|
||||
border: 1px solid rgba(255,255,255,0.1);
|
||||
border-radius: 20px;
|
||||
padding: 24px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
#qr { width: 200px; height: 200px; border-radius: 12px; background: #fff; padding: 8px; }
|
||||
.btn {
|
||||
display: block; width: 100%; padding: 16px;
|
||||
background: #8b5cf6; color: #fff; border: none; border-radius: 14px;
|
||||
font-size: 16px; font-weight: 700; text-decoration: none; margin-top: 16px;
|
||||
}
|
||||
.btn:disabled { opacity: 0.5; pointer-events: none; }
|
||||
ol { text-align: left; font-size: 13px; color: #d1d5db; line-height: 1.6; padding-left: 20px; }
|
||||
.err { color: #f87171; font-size: 14px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="wrap">
|
||||
<div class="logo">GC</div>
|
||||
<h1>GoldenChart 앱 설치</h1>
|
||||
<p class="muted">가상매매·전략편집기·푸시 알림을 모바일에서 사용하세요.</p>
|
||||
<div class="card" id="content">
|
||||
<p class="muted">로딩 중…</p>
|
||||
</div>
|
||||
</div>
|
||||
<script src="https://cdn.jsdelivr.net/npm/qrcode@1.5.4/build/qrcode.min.js"></script>
|
||||
<script>
|
||||
(async function () {
|
||||
const el = document.getElementById('content');
|
||||
try {
|
||||
const res = await fetch('/api/mobile-app/info');
|
||||
const info = await res.json();
|
||||
if (!info.available) {
|
||||
el.innerHTML = '<p class="err">설치 파일이 아직 준비되지 않았습니다.<br>관리자에게 APK 업로드를 요청하세요.</p>';
|
||||
return;
|
||||
}
|
||||
const url = info.downloadUrl;
|
||||
if (!url) {
|
||||
el.innerHTML = '<p class="err">다운로드 URL을 받지 못했습니다.</p>';
|
||||
return;
|
||||
}
|
||||
el.innerHTML =
|
||||
'<canvas id="qr"></canvas>' +
|
||||
'<p class="muted" style="margin-top:12px">' + (info.version ? 'v' + info.version + ' · ' : '') +
|
||||
(info.sizeBytes ? (info.sizeBytes / 1048576).toFixed(1) + ' MB' : '') + '</p>' +
|
||||
'<a class="btn" href="' + url + '" download>APK 다운로드</a>' +
|
||||
'<ol style="margin-top:20px"><li>다운로드 후 APK 파일을 열어 설치</li>' +
|
||||
'<li>「알 수 없는 출처」 허용 필요 시 설정에서 활성화</li>' +
|
||||
'<li>앱 실행 후 API URL 설정</li></ol>';
|
||||
QRCode.toCanvas(document.getElementById('qr'), url, { width: 200, margin: 1 });
|
||||
} catch (e) {
|
||||
el.innerHTML = '<p class="err">앱 정보를 불러오지 못했습니다.</p>';
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -11627,6 +11627,25 @@ html.theme-light .tam-disclaimer { color: #90a4ae; }
|
||||
}
|
||||
.tnl-row-chart:hover { background: var(--bg4); color: var(--text); }
|
||||
|
||||
/* TopMenuBar 앱 다운로드 버튼 */
|
||||
.tmb-app-download-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: var(--text2);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.tmb-app-download-btn:hover {
|
||||
background: var(--bg4);
|
||||
color: var(--accent, #8b5cf6);
|
||||
}
|
||||
|
||||
/* TopMenuBar 알림 버튼 */
|
||||
.tmb-notify-group {
|
||||
display: inline-flex;
|
||||
@@ -12517,3 +12536,58 @@ html.theme-light .tam-disclaimer { color: #90a4ae; }
|
||||
border-radius: 10px;
|
||||
background: rgba(255, 193, 7, 0.1);
|
||||
}
|
||||
|
||||
/* ── 앱 다운로드 모달 ── */
|
||||
.app-download-body { padding: 4px 2px 8px; }
|
||||
.app-download-muted { color: var(--text3); font-size: 13px; }
|
||||
.app-download-error { color: #ef4444; font-size: 13px; }
|
||||
.app-download-empty { text-align: center; padding: 16px 8px; }
|
||||
.app-download-empty-icon { font-size: 40px; margin-bottom: 8px; }
|
||||
.app-download-empty h3 { font-size: 16px; margin-bottom: 8px; }
|
||||
.app-download-empty p { font-size: 13px; color: var(--text2); line-height: 1.55; }
|
||||
.app-download-empty code { font-size: 11px; word-break: break-all; }
|
||||
.app-download-hero {
|
||||
display: flex; align-items: center; gap: 14px;
|
||||
padding: 12px 14px; margin-bottom: 16px;
|
||||
background: var(--bg3); border-radius: 12px; border: 1px solid var(--border);
|
||||
}
|
||||
.app-download-app-icon {
|
||||
width: 52px; height: 52px; border-radius: 14px;
|
||||
background: linear-gradient(135deg, #8b5cf6, #2196f3);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-weight: 800; font-size: 18px; color: #fff; flex-shrink: 0;
|
||||
}
|
||||
.app-download-app-name { font-size: 17px; font-weight: 700; margin: 0 0 4px; }
|
||||
.app-download-meta { display: flex; flex-wrap: wrap; gap: 8px; font-size: 12px; color: var(--text3); }
|
||||
.app-download-qr-block {
|
||||
display: flex; flex-direction: column; align-items: center; gap: 14px;
|
||||
padding: 16px; margin-bottom: 16px;
|
||||
background: var(--bg2); border-radius: 14px; border: 1px solid var(--border);
|
||||
}
|
||||
.app-download-qr {
|
||||
border-radius: 10px; background: #fff;
|
||||
box-shadow: 0 4px 20px rgba(0,0,0,0.15);
|
||||
}
|
||||
.app-download-qr--placeholder {
|
||||
width: 220px; height: 220px; display: flex; align-items: center; justify-content: center;
|
||||
color: var(--text3); font-size: 13px;
|
||||
}
|
||||
.app-download-qr-help { text-align: center; max-width: 320px; }
|
||||
.app-download-qr-help strong { display: block; font-size: 14px; margin-bottom: 6px; }
|
||||
.app-download-qr-help p { font-size: 12px; color: var(--text2); line-height: 1.5; margin: 0 0 6px; }
|
||||
.app-download-url { font-size: 10px; word-break: break-all; opacity: 0.85; }
|
||||
.app-download-btn-primary {
|
||||
width: 100%; padding: 14px; border: none; border-radius: 12px;
|
||||
background: linear-gradient(135deg, #8b5cf6, #7c3aed);
|
||||
color: #fff; font-size: 15px; font-weight: 700; cursor: pointer;
|
||||
}
|
||||
.app-download-btn-primary:hover { filter: brightness(1.06); }
|
||||
.app-download-steps {
|
||||
margin-top: 14px; font-size: 12px; color: var(--text2);
|
||||
border-top: 1px solid var(--border); padding-top: 12px;
|
||||
}
|
||||
.app-download-steps summary { cursor: pointer; font-weight: 600; color: var(--text); margin-bottom: 8px; }
|
||||
.app-download-steps ol { padding-left: 18px; line-height: 1.6; }
|
||||
.app-download-ios-note {
|
||||
margin-top: 12px; font-size: 11px; color: var(--text3); line-height: 1.45;
|
||||
}
|
||||
|
||||
+23
-22
@@ -39,7 +39,7 @@ import {
|
||||
import { normalizeSmaConfig, createDefaultSmaPlotVisibility } from './utils/smaConfig';
|
||||
import { normalizeIchimokuConfig } from './utils/ichimokuConfig';
|
||||
import { isUpbitMarket, UPBIT_MARKETS } from './utils/upbitApi';
|
||||
import { getFavorites, saveFavorites, FAVORITES_CHANGED_EVENT } from './utils/marketStorage';
|
||||
import { getFavorites, saveFavorites, initFavoritesFromDb, initHoldingsFromDb, FAVORITES_CHANGED_EVENT } from './utils/marketStorage';
|
||||
import { useChartRealtimeData, type WsStatus } from './hooks/useChartRealtimeData';
|
||||
import type { OHLCVBar, ChartType, Theme, Timeframe, IndicatorConfig, LegendData, Drawing, ChartMode, MainChartStyle, TradeOrderFillRequest } from './types';
|
||||
import type { ChartManager } from './utils/ChartManager';
|
||||
@@ -105,6 +105,7 @@ import { notifyPaperTradesChanged } from './utils/paperTradeEvents';
|
||||
import { useIsMobile } from './hooks/useMediaQuery';
|
||||
import MobileChartDock from './components/MobileChartDock';
|
||||
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';
|
||||
@@ -161,6 +162,7 @@ function App() {
|
||||
isAppEntered() && !getAuthSession(),
|
||||
);
|
||||
const [loginOpen, setLoginOpen] = useState(false);
|
||||
const [appDownloadOpen, setAppDownloadOpen] = useState(false);
|
||||
|
||||
// 전역 지표 파라미터 + 시각 설정 DB 동기화
|
||||
const {
|
||||
@@ -217,16 +219,8 @@ function App() {
|
||||
const { tickers: marketTickers, loading: marketLoading, usdRate } = useMarketTicker();
|
||||
|
||||
// ── 레이아웃 상태 ─────────────────────────────────────────────────────
|
||||
const [layoutDef, setLayoutDef] = useState<LayoutDef>(() => {
|
||||
const saved = localStorage.getItem('chartLayout');
|
||||
return LAYOUTS.find(l => l.id === saved) ?? LAYOUTS[0];
|
||||
});
|
||||
const [syncOptions, setSyncOptions] = useState<SyncOptions>(() => {
|
||||
try {
|
||||
const s = localStorage.getItem('chartLayoutSync');
|
||||
return s ? JSON.parse(s) : DEFAULT_SYNC;
|
||||
} catch { return DEFAULT_SYNC; }
|
||||
});
|
||||
const [layoutDef, setLayoutDef] = useState<LayoutDef>(() => LAYOUTS[0]);
|
||||
const [syncOptions, setSyncOptions] = useState<SyncOptions>(DEFAULT_SYNC);
|
||||
const [activeSlot, setActiveSlot] = useState(0);
|
||||
// 멀티 레이아웃에서 현재 활성 슬롯의 지표 목록 (IndicatorModal 표시용)
|
||||
const [activeSlotIndicators, setActiveSlotIndicators] = useState<IndicatorConfig[]>([]);
|
||||
@@ -387,13 +381,18 @@ function App() {
|
||||
saveAppDef({ defaultLayoutId: def.id });
|
||||
}, [layoutDef.count, activeSlot, symbol, timeframe, indicators, saveAppDef]);
|
||||
|
||||
// 레이아웃/싱크 변경 시 localStorage 저장
|
||||
const appLayoutHydratedRef = useRef(false);
|
||||
useEffect(() => {
|
||||
localStorage.setItem('chartLayout', layoutDef.id);
|
||||
}, [layoutDef]);
|
||||
useEffect(() => {
|
||||
localStorage.setItem('chartLayoutSync', JSON.stringify(syncOptions));
|
||||
}, [syncOptions]);
|
||||
if (!appSettingsLoaded || appLayoutHydratedRef.current) return;
|
||||
appLayoutHydratedRef.current = true;
|
||||
if (appDefaults.layoutId) {
|
||||
const def = LAYOUTS.find(l => l.id === appDefaults.layoutId);
|
||||
if (def) setLayoutDef(def);
|
||||
}
|
||||
if (appDefaults.syncOptions) {
|
||||
setSyncOptions(appDefaults.syncOptions);
|
||||
}
|
||||
}, [appSettingsLoaded, appDefaults.layoutId, appDefaults.syncOptions]);
|
||||
|
||||
// 다중 → 싱글 전환 시 activeSlotIndicators 초기화
|
||||
useEffect(() => {
|
||||
@@ -1118,10 +1117,6 @@ function App() {
|
||||
return map;
|
||||
}, [watchlist]);
|
||||
|
||||
// ── 상태 저장 (localStorage) ───────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
saveState({ symbol, timeframe, chartType, theme, indicators, watchlist, alertPrices: alerts.map(a => a.price) });
|
||||
}, [symbol, timeframe, chartType, theme, indicators, watchlist, alerts]);
|
||||
|
||||
// body 포털(알림 팝업 등) 테마 CSS 변수 동기화
|
||||
useEffect(() => {
|
||||
@@ -1201,10 +1196,10 @@ function App() {
|
||||
},
|
||||
onWatchlistLoad: (items: WatchlistItem[]) => {
|
||||
const syms = items.map(i => i.symbol);
|
||||
initFavoritesFromDb(syms);
|
||||
if (syms.length > 0) {
|
||||
setWatchlist(syms);
|
||||
setFavoritesList(syms);
|
||||
saveFavorites(syms, false);
|
||||
}
|
||||
},
|
||||
noAutoSave: !dbLoaded,
|
||||
@@ -1672,6 +1667,7 @@ function App() {
|
||||
theme={theme}
|
||||
onPage={guardedSetMenuPage}
|
||||
onTheme={handleThemeToggle}
|
||||
onOpenAppDownload={() => setAppDownloadOpen(true)}
|
||||
authUser={authUser}
|
||||
guestMode={guestMode}
|
||||
menuPermissions={menuPermissions}
|
||||
@@ -1691,6 +1687,10 @@ function App() {
|
||||
onSuccess={handleLoginSuccess}
|
||||
/>
|
||||
|
||||
{appDownloadOpen && (
|
||||
<AppDownloadModal onClose={() => setAppDownloadOpen(false)} />
|
||||
)}
|
||||
|
||||
{/* ── 투자전략 화면 ──────────────────────────────────────────────── */}
|
||||
{menuPage === 'dashboard' && (
|
||||
<DashboardPage theme={theme} onGoChart={m => { goToMarketChart(m); setMenuPage('chart'); }} />
|
||||
@@ -1900,6 +1900,7 @@ function App() {
|
||||
|
||||
<ChartToolbarNotify
|
||||
onOpenNotifyList={openNotificationsPage}
|
||||
onOpenAppDownload={() => setAppDownloadOpen(true)}
|
||||
symbol={symbol} timeframe={layoutDef.count > 1 ? activeSlotTf : timeframe} chartType={chartType}
|
||||
theme={theme} mode={mode} logScale={logScale} showGrid={showGrid}
|
||||
activeIndicators={toolbarActiveIndicators}
|
||||
|
||||
@@ -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;
|
||||
@@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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 임계값 (과열선/중앙선/침체선) */
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -27,8 +27,7 @@ import {
|
||||
type TradeAlertSoundId,
|
||||
} from '../utils/tradeAlertSound';
|
||||
|
||||
const STORAGE_KEY = 'gc_trade_notify_read_v1';
|
||||
const HIDDEN_STORAGE_KEY = 'gc_trade_notify_hidden_v1';
|
||||
import { getUiPreferences, patchUiPreferences } from '../utils/uiPreferencesDb';
|
||||
const MAX_HISTORY = 300;
|
||||
/** 팝업 대기열 최대 건수 (페이지 슬라이딩) */
|
||||
const MAX_TOAST_QUEUE = 50;
|
||||
@@ -94,35 +93,21 @@ function dtoToItem(dto: TradeSignalDto, readIds: Set<string>): TradeNotification
|
||||
}
|
||||
|
||||
function loadReadIds(): Set<string> {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return new Set();
|
||||
return new Set(JSON.parse(raw) as string[]);
|
||||
} catch {
|
||||
return new Set();
|
||||
}
|
||||
const ids = getUiPreferences().tradeNotifications?.readIds;
|
||||
return new Set(ids ?? []);
|
||||
}
|
||||
|
||||
function saveReadIds(ids: Set<string>) {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify([...ids]));
|
||||
} catch { /* ignore */ }
|
||||
patchUiPreferences({ tradeNotifications: { readIds: [...ids] } });
|
||||
}
|
||||
|
||||
function loadHiddenIds(): Set<string> {
|
||||
try {
|
||||
const raw = localStorage.getItem(HIDDEN_STORAGE_KEY);
|
||||
if (!raw) return new Set();
|
||||
return new Set(JSON.parse(raw) as string[]);
|
||||
} catch {
|
||||
return new Set();
|
||||
}
|
||||
const ids = getUiPreferences().tradeNotifications?.hiddenIds;
|
||||
return new Set(ids ?? []);
|
||||
}
|
||||
|
||||
function saveHiddenIds(ids: Set<string>) {
|
||||
try {
|
||||
localStorage.setItem(HIDDEN_STORAGE_KEY, JSON.stringify([...ids]));
|
||||
} catch { /* ignore */ }
|
||||
patchUiPreferences({ tradeNotifications: { hiddenIds: [...ids] } });
|
||||
}
|
||||
|
||||
export function useTradeNotification(): TradeNotificationContextValue {
|
||||
|
||||
@@ -49,6 +49,7 @@ import {
|
||||
resolveChartPaneSeparatorOptions,
|
||||
type ChartPaneSeparatorOptions,
|
||||
} from '../types/chartPaneSeparator';
|
||||
import { migrateLocalStorageToUiPreferences } from '../utils/uiPreferencesMigrate';
|
||||
|
||||
// 전역 캐시 — 여러 컴포넌트에서 공유하여 중복 요청 방지
|
||||
let _cache: AppSettingsDto | null = null;
|
||||
@@ -73,8 +74,18 @@ export function subscribeAppSettings(listener: AppSettingsListener): () => void
|
||||
function ensureLoaded(): Promise<AppSettingsDto> {
|
||||
if (_cache !== null) return Promise.resolve(_cache);
|
||||
if (_loadPromise) return _loadPromise;
|
||||
_loadPromise = loadAppSettings().then(data => {
|
||||
_loadPromise = loadAppSettings().then(async data => {
|
||||
_cache = data ?? {};
|
||||
const migrated = migrateLocalStorageToUiPreferences(_cache);
|
||||
if (migrated?.changed) {
|
||||
try {
|
||||
const saved = await saveAppSettings({ uiPreferences: migrated.uiPreferences });
|
||||
_cache = { ..._cache, ...saved, uiPreferences: migrated.uiPreferences };
|
||||
} catch (e) {
|
||||
console.warn('[useAppSettings] uiPreferences 마이그레이션 저장 실패:', e);
|
||||
_cache = { ..._cache, uiPreferences: migrated.uiPreferences };
|
||||
}
|
||||
}
|
||||
_loadPromise = null;
|
||||
notifyAppSettingsListeners();
|
||||
return _cache;
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import type { IndicatorConfig } from './index';
|
||||
import type { PaletteItem } from '../utils/strategyPaletteStorage';
|
||||
import type { IndicatorCustomTab } from '../utils/indicatorCustomTabsStorage';
|
||||
import type { VirtualCardViewMode, VirtualSessionConfig, VirtualTargetItem } from '../utils/virtualTradingStorage';
|
||||
import type { StrategyEditorMode } from '../utils/strategyEditorModeStorage';
|
||||
|
||||
/** gc_app_settings.ui_preferences_json 구조 */
|
||||
export interface UiPreferences {
|
||||
v?: number;
|
||||
strategyEditor?: {
|
||||
editorMode?: StrategyEditorMode;
|
||||
canvasInteraction?: string;
|
||||
paletteAuxiliary?: PaletteItem[];
|
||||
paletteComposite?: PaletteItem[];
|
||||
};
|
||||
indicator?: {
|
||||
customTabs?: IndicatorCustomTab[];
|
||||
mainTabOrder?: string[];
|
||||
listOrder?: string[];
|
||||
};
|
||||
chart?: {
|
||||
alertPrices?: number[];
|
||||
legacyIndicators?: IndicatorConfig[];
|
||||
};
|
||||
panels?: Record<string, number | boolean | string>;
|
||||
virtual?: {
|
||||
targets?: VirtualTargetItem[];
|
||||
session?: VirtualSessionConfig;
|
||||
cardViewMode?: VirtualCardViewMode;
|
||||
};
|
||||
tradeNotifications?: {
|
||||
readIds?: string[];
|
||||
hiddenIds?: string[];
|
||||
};
|
||||
}
|
||||
|
||||
export const UI_PREFERENCES_VERSION = 1;
|
||||
|
||||
export const EMPTY_UI_PREFERENCES: UiPreferences = { v: UI_PREFERENCES_VERSION };
|
||||
@@ -5,6 +5,8 @@
|
||||
* 환경변수 VITE_API_BASE_URL 로 백엔드 URL 설정 (기본: http://localhost:8080/api)
|
||||
*/
|
||||
|
||||
import type { StrategyFlowLayoutStore } from './strategyEditorLayoutStorage';
|
||||
|
||||
/** 백엔드 REST base (Docker 빌드: `/api`, 로컬 Vite: `.env` 또는 localhost:8080) */
|
||||
export const API_BASE = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:8080/api';
|
||||
|
||||
@@ -444,6 +446,8 @@ export interface AppSettingsDto {
|
||||
displayTimezone?: string;
|
||||
/** 추세검색 기본 설정 */
|
||||
trendSearchSettings?: import('./trendSearchAppSettings').TrendSearchAppSettings | null;
|
||||
/** UI 설정 통합 (편집기·팔레트·가상투자 목록·패널 크기 등) */
|
||||
uiPreferences?: import('../types/uiPreferences').UiPreferences | null;
|
||||
}
|
||||
|
||||
export interface LiveSummaryDto {
|
||||
@@ -555,6 +559,22 @@ export async function sendFcmTest(): Promise<{ ok: boolean; available: boolean }
|
||||
return request<{ ok: boolean; available: boolean }>('/fcm/test', { method: 'POST' });
|
||||
}
|
||||
|
||||
// ── 모바일 앱 배포 ─────────────────────────────────────────────────────────────
|
||||
|
||||
export interface MobileAppReleaseInfoDto {
|
||||
available: boolean;
|
||||
fileName?: string;
|
||||
version?: string;
|
||||
sizeBytes?: number;
|
||||
updatedAt?: string;
|
||||
downloadUrl?: string;
|
||||
installPageUrl?: string;
|
||||
}
|
||||
|
||||
export async function loadMobileAppReleaseInfo(): Promise<MobileAppReleaseInfoDto | null> {
|
||||
return request<MobileAppReleaseInfoDto>('/mobile-app/info');
|
||||
}
|
||||
|
||||
// ── 모의투자 ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export interface PaperPositionDto {
|
||||
@@ -811,6 +831,8 @@ export interface StrategyDto {
|
||||
description?: string;
|
||||
buyCondition?: unknown; // LogicNode DSL
|
||||
sellCondition?: unknown; // LogicNode DSL
|
||||
/** 편집기 캔버스·START 분봉·고아 노드 (gc_strategy.flow_layout_json) */
|
||||
flowLayout?: StrategyFlowLayoutStore;
|
||||
enabled?: boolean;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
/** 지표 추가 팝업 — 사용자 정의 탭 (localStorage) */
|
||||
/** 지표 추가 팝업 — 사용자 정의 탭 (DB) */
|
||||
import { getUiPreferences, patchUiPreferences } from './uiPreferencesDb';
|
||||
|
||||
export interface IndicatorCustomTab {
|
||||
id: string;
|
||||
@@ -7,33 +8,16 @@ export interface IndicatorCustomTab {
|
||||
indicatorTypes: string[];
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'gc-indicator-custom-tabs-v1';
|
||||
|
||||
function newId(): string {
|
||||
return `tab-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 7)}`;
|
||||
}
|
||||
|
||||
export function loadCustomTabs(): IndicatorCustomTab[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return [];
|
||||
const arr = JSON.parse(raw) as unknown;
|
||||
if (!Array.isArray(arr)) return [];
|
||||
return arr.filter(
|
||||
(t): t is IndicatorCustomTab =>
|
||||
t != null
|
||||
&& typeof t === 'object'
|
||||
&& typeof (t as IndicatorCustomTab).id === 'string'
|
||||
&& typeof (t as IndicatorCustomTab).name === 'string'
|
||||
&& Array.isArray((t as IndicatorCustomTab).indicatorTypes),
|
||||
);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
return getUiPreferences().indicator?.customTabs ?? [];
|
||||
}
|
||||
|
||||
export function saveCustomTabs(tabs: IndicatorCustomTab[]): void {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(tabs));
|
||||
patchUiPreferences({ indicator: { customTabs: tabs } });
|
||||
window.dispatchEvent(new CustomEvent('gc-indicator-custom-tabs-changed'));
|
||||
}
|
||||
|
||||
|
||||
@@ -1,30 +1,19 @@
|
||||
/**
|
||||
* 보조지표 설정 목록 표시·차트 pane 순서 (localStorage)
|
||||
* 보조지표 설정 목록 표시·차트 pane 순서 (DB)
|
||||
*/
|
||||
import { getSettingsIndicatorTypes } from './indicatorSettingsList';
|
||||
import type { IndicatorConfig } from '../types';
|
||||
import { getPaneHostId } from './indicatorPaneMerge';
|
||||
|
||||
const STORAGE_KEY = 'gc_indicator_settings_list_order';
|
||||
import { getUiPreferences, patchUiPreferences } from './uiPreferencesDb';
|
||||
|
||||
export function loadIndicatorListOrder(): string[] | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
if (!Array.isArray(parsed)) return null;
|
||||
return parsed.filter((t): t is string => typeof t === 'string' && t.length > 0);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const order = getUiPreferences().indicator?.listOrder;
|
||||
if (!order?.length) return null;
|
||||
return order.filter((t): t is string => typeof t === 'string' && t.length > 0);
|
||||
}
|
||||
|
||||
export function saveIndicatorListOrder(types: string[]): void {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(types));
|
||||
} catch {
|
||||
/* quota */
|
||||
}
|
||||
patchUiPreferences({ indicator: { listOrder: types } });
|
||||
}
|
||||
|
||||
/** 저장 순서 + registry 신규 type 병합 */
|
||||
|
||||
@@ -1,29 +1,18 @@
|
||||
/**
|
||||
* 지표 추가 팝업 — Main(주요지표) 탭 표시·적용 순서
|
||||
* 지표 추가 팝업 — Main(주요지표) 탭 표시·적용 순서 (DB)
|
||||
*/
|
||||
import { MAIN_INDICATOR_TYPES } from './indicatorMainTab';
|
||||
import { mergeIndicatorListOrder } from './indicatorListOrder';
|
||||
|
||||
const STORAGE_KEY = 'gc_indicator_main_tab_order';
|
||||
import { getUiPreferences, patchUiPreferences } from './uiPreferencesDb';
|
||||
|
||||
export function loadMainTabOrder(): string[] | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw) as unknown;
|
||||
if (!Array.isArray(parsed)) return null;
|
||||
return parsed.filter((t): t is string => typeof t === 'string' && t.length > 0);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
const order = getUiPreferences().indicator?.mainTabOrder;
|
||||
if (!order?.length) return null;
|
||||
return order.filter((t): t is string => typeof t === 'string' && t.length > 0);
|
||||
}
|
||||
|
||||
export function saveMainTabOrder(types: string[]): void {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(types));
|
||||
} catch {
|
||||
/* quota */
|
||||
}
|
||||
patchUiPreferences({ indicator: { mainTabOrder: types } });
|
||||
}
|
||||
|
||||
export function getOrderedMainIndicatorTypes(): string[] {
|
||||
|
||||
@@ -1,50 +1,36 @@
|
||||
/**
|
||||
* 관심종목(즐겨찾기) / 보유종목 관리
|
||||
* - DB(gc_watchlist)가 기준 저장소
|
||||
* - localStorage(gc_favorites)는 오프라인 미러·UI 동기화용
|
||||
* - DB(gc_watchlist / gc_holdings)가 기준 저장소
|
||||
* - 메모리 캐시만 사용 (localStorage 미사용)
|
||||
*/
|
||||
|
||||
import { addWatchlistItem, removeWatchlistItem } from './backendApi';
|
||||
|
||||
const FAV_KEY = 'gc_favorites';
|
||||
const LEGACY_FAV_KEY = 'upbit_market_favorites';
|
||||
const HOLD_KEY = 'gc_holdings';
|
||||
|
||||
/** 관심종목 변경 시 MarketSearchPanel·App 등이 동기화하도록 발행 */
|
||||
export const FAVORITES_CHANGED_EVENT = 'gc-favorites-changed';
|
||||
|
||||
// ── 즐겨찾기 (관심종목) ─────────────────────────────────────────────────────
|
||||
let favoritesCache: string[] | null = null;
|
||||
let holdingsCache: string[] | null = null;
|
||||
|
||||
/** 구버전 MarketSearchPanel 키 → gc_favorites 로 1회 마이그레이션 */
|
||||
function migrateLegacyFavorites(): void {
|
||||
try {
|
||||
const raw = localStorage.getItem(FAV_KEY);
|
||||
if (raw) {
|
||||
const current = JSON.parse(raw) as string[];
|
||||
if (Array.isArray(current) && current.length > 0) return;
|
||||
}
|
||||
const legacy = localStorage.getItem(LEGACY_FAV_KEY);
|
||||
if (!legacy) return;
|
||||
const parsed = JSON.parse(legacy) as string[];
|
||||
if (Array.isArray(parsed) && parsed.length > 0) {
|
||||
saveFavorites(parsed, false);
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
/** DB 로드 후 App·워크스페이스 훅에서 호출 */
|
||||
export function initFavoritesFromDb(symbols: string[]): void {
|
||||
favoritesCache = [...symbols];
|
||||
window.dispatchEvent(new CustomEvent(FAVORITES_CHANGED_EVENT, { detail: favoritesCache }));
|
||||
}
|
||||
|
||||
export function initHoldingsFromDb(symbols: string[]): void {
|
||||
holdingsCache = [...symbols];
|
||||
}
|
||||
|
||||
export function getFavorites(): string[] {
|
||||
migrateLegacyFavorites();
|
||||
try { return JSON.parse(localStorage.getItem(FAV_KEY) ?? '[]'); }
|
||||
catch { return []; }
|
||||
return favoritesCache ?? [];
|
||||
}
|
||||
|
||||
export function saveFavorites(markets: string[], notify = true): void {
|
||||
try {
|
||||
localStorage.setItem(FAV_KEY, JSON.stringify(markets));
|
||||
if (notify) {
|
||||
window.dispatchEvent(new CustomEvent(FAVORITES_CHANGED_EVENT, { detail: markets }));
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
favoritesCache = [...markets];
|
||||
if (notify) {
|
||||
window.dispatchEvent(new CustomEvent(FAVORITES_CHANGED_EVENT, { detail: markets }));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -76,15 +62,12 @@ export function isFavorite(market: string): boolean {
|
||||
return getFavorites().includes(market);
|
||||
}
|
||||
|
||||
// ── 보유종목 ─────────────────────────────────────────────────────────────────
|
||||
|
||||
export function getHoldings(): string[] {
|
||||
try { return JSON.parse(localStorage.getItem(HOLD_KEY) ?? '[]'); }
|
||||
catch { return []; }
|
||||
return holdingsCache ?? [];
|
||||
}
|
||||
|
||||
export function saveHoldings(markets: string[]): void {
|
||||
try { localStorage.setItem(HOLD_KEY, JSON.stringify(markets)); } catch { /* ignore */ }
|
||||
holdingsCache = [...markets];
|
||||
}
|
||||
|
||||
/** 추가 → 변경 후 전체 목록 반환 (중복 무시) */
|
||||
|
||||
@@ -12,8 +12,7 @@ interface ChartState {
|
||||
mainChartStyle?: MainChartStyle;
|
||||
}
|
||||
|
||||
const KEY = 'trading_chart_state';
|
||||
const CUR_VERSION = 8; // v8: 기본 지표 제거 — 초기 로딩 시 캔들차트만 표시
|
||||
const CUR_VERSION = 8;
|
||||
|
||||
/** 기본 보조지표 없음 — 처음 로딩 시 캔들차트만 표시 */
|
||||
const DEFAULT_INDICATORS: IndicatorConfig[] = [];
|
||||
@@ -25,8 +24,7 @@ const DEFAULT_WATCHLIST = [
|
||||
|
||||
/**
|
||||
* 절대적 최후 fallback 기본값.
|
||||
* 우선순위: DB(gc_app_settings) > localStorage > 이 값.
|
||||
* 타임프레임을 '1h' 에서 '1D' 로 통일 (ChartSlot 기본값과 일치).
|
||||
* 우선순위: DB(gc_app_settings + gc_chart_workspace) > 이 값.
|
||||
*/
|
||||
const DEFAULT_STATE: ChartState = {
|
||||
version: CUR_VERSION,
|
||||
@@ -39,31 +37,19 @@ const DEFAULT_STATE: ChartState = {
|
||||
alertPrices: [],
|
||||
};
|
||||
|
||||
export function saveState(state: Partial<ChartState>): void {
|
||||
try {
|
||||
const existing = loadState();
|
||||
localStorage.setItem(KEY, JSON.stringify({ ...existing, ...state, version: CUR_VERSION }));
|
||||
} catch { /* ignore */ }
|
||||
/** @deprecated DB·워크스페이스 사용 — no-op */
|
||||
export function saveState(_state: Partial<ChartState>): void {
|
||||
/* chart state는 gc_chart_workspace·gc_app_settings로 저장 */
|
||||
}
|
||||
|
||||
/** 초기 마운트 fallback (DB 로드 전 1회) */
|
||||
export function loadState(): ChartState {
|
||||
try {
|
||||
const raw = localStorage.getItem(KEY);
|
||||
if (raw) {
|
||||
const parsed = JSON.parse(raw) as ChartState;
|
||||
// 버전이 다르면 기본값으로 재설정 (마이그레이션)
|
||||
if (parsed.version !== CUR_VERSION) {
|
||||
localStorage.removeItem(KEY);
|
||||
return DEFAULT_STATE;
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
return DEFAULT_STATE;
|
||||
}
|
||||
|
||||
/** @deprecated */
|
||||
export function clearState(): void {
|
||||
localStorage.removeItem(KEY);
|
||||
/* no-op */
|
||||
}
|
||||
|
||||
export const DEFAULT_MAIN_CHART_STYLE: MainChartStyle = {
|
||||
|
||||
@@ -1,20 +1,14 @@
|
||||
export type StrategyCanvasInteractionMode = 'pan' | 'select';
|
||||
import { getUiPreferences, patchUiPreferences } from './uiPreferencesDb';
|
||||
|
||||
const STORAGE_KEY = 'gc_se_canvas_interaction';
|
||||
export type CanvasInteractionMode = 'pan' | 'select';
|
||||
/** @deprecated CanvasInteractionMode */
|
||||
export type StrategyCanvasInteractionMode = CanvasInteractionMode;
|
||||
|
||||
export function loadCanvasInteractionMode(): StrategyCanvasInteractionMode {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
return raw === 'pan' ? 'pan' : 'select';
|
||||
} catch {
|
||||
return 'select';
|
||||
}
|
||||
export function loadCanvasInteractionMode(): CanvasInteractionMode {
|
||||
const mode = getUiPreferences().strategyEditor?.canvasInteraction;
|
||||
return mode === 'pan' ? 'pan' : 'select';
|
||||
}
|
||||
|
||||
export function saveCanvasInteractionMode(mode: StrategyCanvasInteractionMode): void {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, mode);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
export function saveCanvasInteractionMode(mode: CanvasInteractionMode): void {
|
||||
patchUiPreferences({ strategyEditor: { canvasInteraction: mode } });
|
||||
}
|
||||
|
||||
@@ -432,35 +432,33 @@ export function collectDslBranches(dsl: LogicNode | null): ConditionBranch[] {
|
||||
}
|
||||
|
||||
/** DB가 1m 기본값만 갖고 편집기 localStorage에 다른 분봉이 있을 때 localStorage 우선 */
|
||||
function pickMergedStartCandleTypes(decoded: string[], stored?: string[]): string[] {
|
||||
const fromDb = normalizeCandleTypesList(decoded);
|
||||
const fromStored = stored?.length ? normalizeCandleTypesList(stored) : [];
|
||||
const dbIsLegacyDefault = fromDb.length === 1 && fromDb[0] === '1m';
|
||||
const storedDiffers = fromStored.length > 0
|
||||
&& (fromStored.length !== fromDb.length || fromStored.some((ct, i) => ct !== fromDb[i]));
|
||||
if (storedDiffers && (dbIsLegacyDefault || fromDb.length === 0)) {
|
||||
return fromStored;
|
||||
}
|
||||
return fromDb;
|
||||
}
|
||||
|
||||
/**
|
||||
* 전략 로드 시 startMeta 병합 — DB가 1m 기본값만 있고 localStorage에 다른 분봉이 있으면 localStorage 우선.
|
||||
* 전략 로드 시 startMeta 병합 — flow_layout_json(편집기)이 있으면 DSL TIMEFRAME보다 우선.
|
||||
*/
|
||||
export function mergeStartMetaForLoad(
|
||||
decoded: Record<string, StartNodeMeta>,
|
||||
stored?: Record<string, StartNodeMeta> | null,
|
||||
): Record<string, StartNodeMeta> {
|
||||
if (!stored || Object.keys(stored).length === 0) {
|
||||
const fromDsl: Record<string, StartNodeMeta> = { ...defaultStartMeta(), ...decoded };
|
||||
for (const [startId, meta] of Object.entries(decoded)) {
|
||||
const types = normalizeCandleTypesList(getStartCandleTypes(meta));
|
||||
fromDsl[startId] = { candleTypes: types, candleType: types[0] };
|
||||
}
|
||||
return fromDsl;
|
||||
}
|
||||
const merged: Record<string, StartNodeMeta> = {
|
||||
...defaultStartMeta(),
|
||||
...stored,
|
||||
};
|
||||
for (const [startId, meta] of Object.entries(decoded)) {
|
||||
const types = pickMergedStartCandleTypes(
|
||||
getStartCandleTypes(meta),
|
||||
stored?.[startId] ? getStartCandleTypes(stored[startId]) : undefined,
|
||||
);
|
||||
merged[startId] = { candleTypes: types, candleType: types[0] };
|
||||
if (stored[startId]) {
|
||||
const types = normalizeCandleTypesList(getStartCandleTypes(stored[startId]));
|
||||
merged[startId] = { candleTypes: types, candleType: types[0] };
|
||||
} else {
|
||||
const types = normalizeCandleTypesList(getStartCandleTypes(meta));
|
||||
merged[startId] = { candleTypes: types, candleType: types[0] };
|
||||
}
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
@@ -25,7 +25,8 @@ export type StrategyFlowLayoutStore = {
|
||||
sell: SignalFlowLayoutSnapshot;
|
||||
};
|
||||
|
||||
const STORAGE_KEY = 'gc_se_flow_layout_v1';
|
||||
/** @deprecated 레거시 localStorage 키 — 신규 저장은 DB flow_layout_json만 사용 */
|
||||
const LEGACY_STORAGE_KEY = 'gc_se_flow_layout_v1';
|
||||
|
||||
export function emptySignalFlowLayout(): SignalFlowLayoutSnapshot {
|
||||
return {
|
||||
@@ -42,44 +43,67 @@ export function emptyStrategyFlowLayout(): StrategyFlowLayoutStore {
|
||||
return { buy: emptySignalFlowLayout(), sell: emptySignalFlowLayout() };
|
||||
}
|
||||
|
||||
export function loadStrategyFlowLayout(strategyKey: string): StrategyFlowLayoutStore | null {
|
||||
export function normalizeSignalFlowLayout(
|
||||
snap: SignalFlowLayoutSnapshot | null | undefined,
|
||||
): SignalFlowLayoutSnapshot {
|
||||
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: snap.startCombineOp,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeStrategyFlowLayout(
|
||||
store: StrategyFlowLayoutStore | null | undefined,
|
||||
): StrategyFlowLayoutStore | null {
|
||||
if (!store) return null;
|
||||
return {
|
||||
buy: normalizeSignalFlowLayout(store.buy),
|
||||
sell: normalizeSignalFlowLayout(store.sell),
|
||||
};
|
||||
}
|
||||
|
||||
export function buildStrategyFlowLayoutStore(
|
||||
buy: Omit<SignalFlowLayoutSnapshot, 'orphans'>,
|
||||
sell: Omit<SignalFlowLayoutSnapshot, 'orphans'>,
|
||||
buyOrphans: LogicNode[],
|
||||
sellOrphans: LogicNode[],
|
||||
): StrategyFlowLayoutStore {
|
||||
return {
|
||||
buy: { ...buy, orphans: buyOrphans },
|
||||
sell: { ...sell, orphans: sellOrphans },
|
||||
};
|
||||
}
|
||||
|
||||
/** DB에 flow layout이 없을 때만 1회 읽기 — 이후 저장은 서버로 이전 */
|
||||
export function readLegacyFlowLayoutFromLocalStorage(
|
||||
strategyKey: string,
|
||||
): StrategyFlowLayoutStore | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
const raw = localStorage.getItem(LEGACY_STORAGE_KEY);
|
||||
if (!raw) return null;
|
||||
const all = JSON.parse(raw) as Record<string, StrategyFlowLayoutStore>;
|
||||
return all[strategyKey] ?? null;
|
||||
return normalizeStrategyFlowLayout(all[strategyKey] ?? null);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function saveStrategyFlowLayout(strategyKey: string, layout: StrategyFlowLayoutStore): void {
|
||||
export function clearLegacyFlowLayoutFromLocalStorage(strategyKey: string): void {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
const all: Record<string, StrategyFlowLayoutStore> = raw ? JSON.parse(raw) : {};
|
||||
all[strategyKey] = layout;
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(all));
|
||||
} catch {
|
||||
/* ignore quota / private mode */
|
||||
}
|
||||
}
|
||||
|
||||
export function deleteStrategyFlowLayout(strategyKey: string): void {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
const raw = localStorage.getItem(LEGACY_STORAGE_KEY);
|
||||
if (!raw) return;
|
||||
const all = JSON.parse(raw) as Record<string, StrategyFlowLayoutStore>;
|
||||
if (!(strategyKey in all)) return;
|
||||
delete all[strategyKey];
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(all));
|
||||
if (Object.keys(all).length === 0) localStorage.removeItem(LEGACY_STORAGE_KEY);
|
||||
else localStorage.setItem(LEGACY_STORAGE_KEY, JSON.stringify(all));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
export function migrateStrategyFlowLayout(fromKey: string, toKey: string): void {
|
||||
if (fromKey === toKey) return;
|
||||
const layout = loadStrategyFlowLayout(fromKey);
|
||||
if (!layout) return;
|
||||
saveStrategyFlowLayout(toKey, layout);
|
||||
deleteStrategyFlowLayout(fromKey);
|
||||
}
|
||||
|
||||
@@ -1,20 +1,12 @@
|
||||
import { getUiPreferences, patchUiPreferences } from './uiPreferencesDb';
|
||||
|
||||
export type StrategyEditorMode = 'graph' | 'list';
|
||||
|
||||
const STORAGE_KEY = 'gc_se_editor_mode';
|
||||
|
||||
export function loadEditorMode(): StrategyEditorMode {
|
||||
try {
|
||||
const raw = localStorage.getItem(STORAGE_KEY);
|
||||
return raw === 'list' ? 'list' : 'graph';
|
||||
} catch {
|
||||
return 'graph';
|
||||
}
|
||||
const mode = getUiPreferences().strategyEditor?.editorMode;
|
||||
return mode === 'list' ? 'list' : 'graph';
|
||||
}
|
||||
|
||||
export function saveEditorMode(mode: StrategyEditorMode): void {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, mode);
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
patchUiPreferences({ strategyEditor: { editorMode: mode } });
|
||||
}
|
||||
|
||||
@@ -56,12 +56,15 @@ import {
|
||||
} from '../utils/thresholdSymbols';
|
||||
import { STRATEGY_CANDLE_TYPE_OPTIONS } from '../utils/strategyStartNodes';
|
||||
|
||||
import type { StrategyFlowLayoutStore } from './strategyEditorLayoutStorage';
|
||||
|
||||
export interface StrategyDto {
|
||||
id: number;
|
||||
name: string;
|
||||
description?: string;
|
||||
buyCondition?: LogicNode | null;
|
||||
sellCondition?: LogicNode | null;
|
||||
flowLayout?: StrategyFlowLayoutStore;
|
||||
enabled: boolean;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
@@ -78,11 +81,10 @@ export type DefType = typeof DEF_DEFAULTS;
|
||||
let _cnt = 0;
|
||||
export const genId = () => `n_${++_cnt}_${Date.now()}`;
|
||||
|
||||
export const STORAGE_KEY = 'gc_strat_v2';
|
||||
export const loadStratsLocal = (): StrategyDto[] => {
|
||||
try { return JSON.parse(localStorage.getItem(STORAGE_KEY) ?? '[]'); } catch { return []; }
|
||||
};
|
||||
export const saveStratsLocal = (s: StrategyDto[]) => localStorage.setItem(STORAGE_KEY, JSON.stringify(s));
|
||||
/** @deprecated DB(gc_strategy)만 사용 */
|
||||
export const loadStratsLocal = (): StrategyDto[] => [];
|
||||
/** @deprecated */
|
||||
export const saveStratsLocal = (_s: StrategyDto[]) => { /* no-op */ };
|
||||
|
||||
// ─── 기본 파라미터 (IndicatorSettings 미사용 → 하드코딩 기본값) ─────────────
|
||||
/** 지표별 hline 임계값 (과열선/중앙선/침체선) */
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
/** 전략편집기 우측 팔레트 — 보조·복합 지표 목록 (localStorage) */
|
||||
/** 전략편집기 우측 팔레트 — 보조·복합 지표 목록 (DB ui_preferences) */
|
||||
import { getUiPreferences, patchUiPreferences } from './uiPreferencesDb';
|
||||
import { COMPOSITE_INDICATOR_ITEMS } from './compositeIndicators';
|
||||
|
||||
export type PaletteItemKind = 'auxiliary' | 'composite';
|
||||
@@ -18,9 +19,6 @@ export interface PaletteItem {
|
||||
builtIn?: boolean;
|
||||
}
|
||||
|
||||
const STORAGE_AUX = 'se-palette-auxiliary-v1';
|
||||
const STORAGE_COMP = 'se-palette-composite-v1';
|
||||
|
||||
export const DEFAULT_AUXILIARY_ITEMS: Omit<PaletteItem, 'id' | 'kind'>[] = [
|
||||
{ value: 'RSI', label: 'RSI', desc: '상대강도지수', builtIn: true },
|
||||
{ value: 'MACD', label: 'MACD', desc: '이동평균 수렴확산', builtIn: true },
|
||||
@@ -111,32 +109,32 @@ function mergeMissingBuiltIns(
|
||||
}
|
||||
|
||||
export function loadPaletteItems(kind: PaletteItemKind): PaletteItem[] {
|
||||
const key = kind === 'auxiliary' ? STORAGE_AUX : STORAGE_COMP;
|
||||
const defaults = kind === 'auxiliary' ? DEFAULT_AUXILIARY_ITEMS : DEFAULT_COMPOSITE_ITEMS;
|
||||
try {
|
||||
const stored = parseStored(localStorage.getItem(key));
|
||||
if (stored && stored.length > 0) {
|
||||
return mergeMissingBuiltIns(
|
||||
stored.map(i => ({
|
||||
...i,
|
||||
kind,
|
||||
...(i.builtIn
|
||||
? { period: undefined, shortPeriod: undefined, longPeriod: undefined }
|
||||
: {}),
|
||||
})),
|
||||
const prefs = getUiPreferences().strategyEditor;
|
||||
const raw = kind === 'auxiliary' ? prefs?.paletteAuxiliary : prefs?.paletteComposite;
|
||||
const stored = raw?.length ? raw : null;
|
||||
if (stored && stored.length > 0) {
|
||||
return mergeMissingBuiltIns(
|
||||
stored.map(i => ({
|
||||
...i,
|
||||
kind,
|
||||
defaults,
|
||||
);
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
...(i.builtIn
|
||||
? { period: undefined, shortPeriod: undefined, longPeriod: undefined }
|
||||
: {}),
|
||||
})),
|
||||
kind,
|
||||
defaults,
|
||||
);
|
||||
}
|
||||
return seedItems(kind, defaults);
|
||||
}
|
||||
|
||||
export function savePaletteItems(kind: PaletteItemKind, items: PaletteItem[]): void {
|
||||
const key = kind === 'auxiliary' ? STORAGE_AUX : STORAGE_COMP;
|
||||
try {
|
||||
localStorage.setItem(key, JSON.stringify(items));
|
||||
} catch { /* ignore */ }
|
||||
if (kind === 'auxiliary') {
|
||||
patchUiPreferences({ strategyEditor: { paletteAuxiliary: items } });
|
||||
} else {
|
||||
patchUiPreferences({ strategyEditor: { paletteComposite: items } });
|
||||
}
|
||||
}
|
||||
|
||||
export function resetPaletteItems(kind: PaletteItemKind): PaletteItem[] {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { loadStrategy, loadStrategyTimeframes, repairStrategyTimeframes, saveStrategy, type StrategyDto } from './backendApi';
|
||||
import { loadStrategyFlowLayout } from './strategyEditorLayoutStorage';
|
||||
import {
|
||||
alignBuySellStartCandleTypesForSave,
|
||||
collectDslBranches,
|
||||
@@ -14,8 +13,7 @@ import { getStartCandleTypes, normalizeStartCandleType } from './strategyStartNo
|
||||
import type { LogicNode } from './strategyTypes';
|
||||
import type { StrategyFlowLayoutStore } from './strategyEditorLayoutStorage';
|
||||
|
||||
function collectFromFlowLayout(strategyId: number): string[] {
|
||||
const layout = loadStrategyFlowLayout(String(strategyId));
|
||||
function collectFromFlowLayout(layout: StrategyFlowLayoutStore | null | undefined): string[] {
|
||||
if (!layout) return [];
|
||||
const set = new Set<string>();
|
||||
for (const side of ['buy', 'sell'] as const) {
|
||||
@@ -43,9 +41,9 @@ function collectFromStrategyDto(strategy: StrategyDto | null | undefined): strin
|
||||
return [...set];
|
||||
}
|
||||
|
||||
/** 편집기·flow layout·전략 DTO에서 UI 평가 분봉 수집 (서버 DSL보다 우선) */
|
||||
/** 편집기·flow layout·전략 DTO에서 UI 평가 분봉 수집 */
|
||||
export function collectUiEvaluationTimeframes(
|
||||
strategyId: number,
|
||||
_strategyId: number,
|
||||
strategy?: StrategyDto | null,
|
||||
editorState?: { buy: EditorConditionState; sell: EditorConditionState },
|
||||
): string[] {
|
||||
@@ -56,7 +54,7 @@ export function collectUiEvaluationTimeframes(
|
||||
].map(normalizeStartCandleType);
|
||||
if (fromEditor.length > 0) return [...new Set(fromEditor)];
|
||||
}
|
||||
const fromLayout = collectFromFlowLayout(strategyId);
|
||||
const fromLayout = collectFromFlowLayout(strategy?.flowLayout);
|
||||
if (fromLayout.length > 0) return fromLayout;
|
||||
return collectFromStrategyDto(strategy);
|
||||
}
|
||||
@@ -85,7 +83,6 @@ export async function warnStrategyTimeframeMismatch(
|
||||
const extraOnServer = [...serverSet].filter(tf => !uiSet.has(tf));
|
||||
|
||||
if (missingOnServer.length === 0 && extraOnServer.length === 0) return true;
|
||||
// 양쪽 모두 1m만 — 일치로 간주
|
||||
if (uiSet.size === 1 && uiSet.has('1m') && serverSet.size === 1 && serverSet.has('1m')) {
|
||||
return true;
|
||||
}
|
||||
@@ -94,14 +91,14 @@ export async function warnStrategyTimeframeMismatch(
|
||||
const serverLabel = [...serverSet].join(', ') || '1m';
|
||||
window.alert(
|
||||
`전략 화면에는 ${uiLabel} 봉으로 설정되어 있지만, 서버에는 ${serverLabel} 만 저장되어 있습니다.\n\n`
|
||||
+ '전략편집기에서 「저장」을 누르거나 START 분봉을 다시 선택해 자동 저장된 뒤 실시간 체크를 켜 주세요.',
|
||||
+ '전략편집기에서 「저장」을 누르거나 START 분봉을 다시 선택해 DB에 반영한 뒤 실시간 체크를 켜 주세요.',
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
function buildEditorStateFromStrategy(
|
||||
strategy: StrategyDto,
|
||||
layout: StrategyFlowLayoutStore | null,
|
||||
layout: StrategyFlowLayoutStore | null | undefined,
|
||||
side: 'buy' | 'sell',
|
||||
): EditorConditionState {
|
||||
const decoded = decodeConditionForEditor(
|
||||
@@ -118,14 +115,16 @@ function buildEditorStateFromStrategy(
|
||||
}
|
||||
|
||||
/**
|
||||
* flow layout(localStorage)의 START 분봉이 서버 DSL과 다르면 편집기 저장 없이 자동 동기화.
|
||||
* @returns false — layout 없음 등으로 동기화 불가
|
||||
* DB flow layout의 START 분봉이 서버 DSL과 다르면 자동 동기화.
|
||||
*/
|
||||
export async function syncStrategyTimeframesFromLayoutIfNeeded(
|
||||
strategyId: number,
|
||||
strategy?: StrategyDto | null,
|
||||
): Promise<boolean> {
|
||||
const uiTfs = collectUiEvaluationTimeframes(strategyId, strategy);
|
||||
const strat = strategy ?? await loadStrategy(strategyId);
|
||||
if (!strat) return true;
|
||||
|
||||
const uiTfs = collectUiEvaluationTimeframes(strategyId, strat);
|
||||
if (uiTfs.length === 0) return true;
|
||||
|
||||
let serverTfs: string[];
|
||||
@@ -139,14 +138,11 @@ export async function syncStrategyTimeframesFromLayoutIfNeeded(
|
||||
const missingOnServer = uiTfs.filter(tf => !serverSet.has(normalizeStartCandleType(tf)));
|
||||
if (missingOnServer.length === 0) return true;
|
||||
|
||||
const layout = loadStrategyFlowLayout(String(strategyId));
|
||||
const layout = strat.flowLayout;
|
||||
if (!layout) {
|
||||
return warnStrategyTimeframeMismatch(strategyId, strategy);
|
||||
return warnStrategyTimeframeMismatch(strategyId, strat);
|
||||
}
|
||||
|
||||
const strat = strategy ?? await loadStrategy(strategyId);
|
||||
if (!strat) return true;
|
||||
|
||||
const buyState = buildEditorStateFromStrategy(strat, layout, 'buy');
|
||||
const sellState = buildEditorStateFromStrategy(strat, layout, 'sell');
|
||||
|
||||
@@ -156,6 +152,7 @@ export async function syncStrategyTimeframesFromLayoutIfNeeded(
|
||||
strat.description ?? '',
|
||||
buyState,
|
||||
sellState,
|
||||
layout,
|
||||
);
|
||||
try {
|
||||
await repairStrategyTimeframes(strategyId);
|
||||
@@ -165,25 +162,46 @@ export async function syncStrategyTimeframesFromLayoutIfNeeded(
|
||||
return true;
|
||||
}
|
||||
|
||||
/** START 분봉 변경 시 전략 DSL을 서버에 즉시 반영 */
|
||||
/** 편집기 상태(DSL + flow layout)를 DB에 저장 — 실시간 평가 캐시는 서버에서 무효화됨 */
|
||||
export async function persistStrategyEditorState(
|
||||
strategyId: number,
|
||||
name: string,
|
||||
description: string,
|
||||
buy: EditorConditionState,
|
||||
sell: EditorConditionState,
|
||||
flowLayout: StrategyFlowLayoutStore,
|
||||
): Promise<StrategyDto> {
|
||||
const aligned = alignBuySellStartCandleTypesForSave(buy, sell);
|
||||
const encodedBuy = encodeConditionForSave(aligned.buy);
|
||||
const encodedSell = encodeConditionForSave(aligned.sell);
|
||||
const saved = await saveStrategy({
|
||||
id: strategyId,
|
||||
name,
|
||||
description,
|
||||
buyCondition: encodedBuy,
|
||||
sellCondition: encodedSell,
|
||||
flowLayout,
|
||||
enabled: true,
|
||||
});
|
||||
if (typeof window !== 'undefined') {
|
||||
window.dispatchEvent(new CustomEvent('gc:strategy-saved', {
|
||||
detail: { strategyId, buyCondition: encodedBuy, sellCondition: encodedSell, flowLayout },
|
||||
}));
|
||||
}
|
||||
return saved;
|
||||
}
|
||||
|
||||
/** @deprecated persistStrategyEditorState 사용 */
|
||||
export async function persistStrategyEvaluationTimeframes(
|
||||
strategyId: number,
|
||||
name: string,
|
||||
description: string,
|
||||
buy: EditorConditionState,
|
||||
sell: EditorConditionState,
|
||||
flowLayout?: StrategyFlowLayoutStore,
|
||||
): Promise<void> {
|
||||
const aligned = alignBuySellStartCandleTypesForSave(buy, sell);
|
||||
const encodedBuy = encodeConditionForSave(aligned.buy);
|
||||
const encodedSell = encodeConditionForSave(aligned.sell);
|
||||
await saveStrategy({
|
||||
id: strategyId,
|
||||
name,
|
||||
description,
|
||||
buyCondition: encodedBuy,
|
||||
sellCondition: encodedSell,
|
||||
enabled: true,
|
||||
});
|
||||
if (!flowLayout) return;
|
||||
await persistStrategyEditorState(strategyId, name, description, buy, sell, flowLayout);
|
||||
}
|
||||
|
||||
export type { StrategyFlowLayoutStore };
|
||||
|
||||
@@ -9,7 +9,12 @@ import {
|
||||
type BullishWeightKey,
|
||||
} from './trendSearchBullishWeights';
|
||||
|
||||
/** 결과 목록 표시: chart | summary(신호) */
|
||||
export type TrendSearchDisplayMode = 'chart' | 'summary';
|
||||
|
||||
export interface TrendSearchAppSettings {
|
||||
/** 결과 UI 표시 모드 (기본 summary) */
|
||||
displayMode?: TrendSearchDisplayMode;
|
||||
weightMaAlignment: number;
|
||||
weightMaSlope: number;
|
||||
weightAdxTrend: number;
|
||||
@@ -32,6 +37,7 @@ export interface TrendSearchAppSettings {
|
||||
}
|
||||
|
||||
export const DEFAULT_TREND_SEARCH_APP_SETTINGS: TrendSearchAppSettings = {
|
||||
displayMode: 'summary',
|
||||
weightMaAlignment: DEFAULT_BULLISH_WEIGHTS.weightMaAlignment,
|
||||
weightMaSlope: DEFAULT_BULLISH_WEIGHTS.weightMaSlope,
|
||||
weightAdxTrend: DEFAULT_BULLISH_WEIGHTS.weightAdxTrend,
|
||||
@@ -59,7 +65,11 @@ export function resolveTrendSearchAppSettings(
|
||||
const rank = Number(raw.autoAddTopRank);
|
||||
const limit = Number(raw.limit);
|
||||
const scanLimit = Number(raw.scanLimit);
|
||||
const displayMode = raw.displayMode === 'chart' || raw.displayMode === 'summary'
|
||||
? raw.displayMode
|
||||
: d.displayMode ?? 'summary';
|
||||
return {
|
||||
displayMode,
|
||||
weightMaAlignment: clampWeight(raw.weightMaAlignment, d.weightMaAlignment),
|
||||
weightMaSlope: clampWeight(raw.weightMaSlope, d.weightMaSlope),
|
||||
weightAdxTrend: clampWeight(raw.weightAdxTrend, d.weightAdxTrend),
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
/**
|
||||
* UI 설정 — gc_app_settings.ui_preferences_json (DB 단일 소스)
|
||||
*/
|
||||
import {
|
||||
getAppSettingsCache,
|
||||
subscribeAppSettings,
|
||||
} from '../hooks/useAppSettings';
|
||||
import { saveAppSettings, type AppSettingsDto } from './backendApi';
|
||||
import {
|
||||
EMPTY_UI_PREFERENCES,
|
||||
UI_PREFERENCES_VERSION,
|
||||
type UiPreferences,
|
||||
} from '../types/uiPreferences';
|
||||
|
||||
const SAVE_DEBOUNCE_MS = 400;
|
||||
let saveTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let pendingPatch: Partial<UiPreferences> | null = null;
|
||||
|
||||
function isPlainObject(v: unknown): v is Record<string, unknown> {
|
||||
return v != null && typeof v === 'object' && !Array.isArray(v);
|
||||
}
|
||||
|
||||
function deepMerge<T extends Record<string, unknown>>(base: T, patch: Partial<T>): T {
|
||||
const out = { ...base } as T;
|
||||
for (const key of Object.keys(patch) as (keyof T)[]) {
|
||||
const pv = patch[key];
|
||||
if (pv === undefined) continue;
|
||||
const bv = base[key];
|
||||
if (isPlainObject(bv) && isPlainObject(pv)) {
|
||||
out[key] = deepMerge(bv as Record<string, unknown>, pv as Record<string, unknown>) as T[keyof T];
|
||||
} else {
|
||||
out[key] = pv as T[keyof T];
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function resolveUiPreferences(raw: unknown): UiPreferences {
|
||||
if (!raw || typeof raw !== 'object') {
|
||||
return { ...EMPTY_UI_PREFERENCES };
|
||||
}
|
||||
const o = raw as UiPreferences;
|
||||
return {
|
||||
...EMPTY_UI_PREFERENCES,
|
||||
...o,
|
||||
v: UI_PREFERENCES_VERSION,
|
||||
strategyEditor: { ...EMPTY_UI_PREFERENCES.strategyEditor, ...o.strategyEditor },
|
||||
indicator: { ...EMPTY_UI_PREFERENCES.indicator, ...o.indicator },
|
||||
chart: { ...EMPTY_UI_PREFERENCES.chart, ...o.chart },
|
||||
panels: { ...EMPTY_UI_PREFERENCES.panels, ...o.panels },
|
||||
virtual: { ...EMPTY_UI_PREFERENCES.virtual, ...o.virtual },
|
||||
tradeNotifications: { ...EMPTY_UI_PREFERENCES.tradeNotifications, ...o.tradeNotifications },
|
||||
};
|
||||
}
|
||||
|
||||
/** DB 캐시 기준 UI 설정 (앱 설정 로드 후 유효) */
|
||||
export function getUiPreferences(): UiPreferences {
|
||||
return resolveUiPreferences(getAppSettingsCache().uiPreferences);
|
||||
}
|
||||
|
||||
/** UI 설정 부분 갱신 — 낙관적 캐시 + 디바운스 DB 저장 */
|
||||
export function patchUiPreferences(patch: Partial<UiPreferences>, immediate = false): void {
|
||||
const current = getUiPreferences();
|
||||
const next = deepMerge(current as Record<string, unknown>, patch as Record<string, unknown>) as UiPreferences;
|
||||
next.v = UI_PREFERENCES_VERSION;
|
||||
|
||||
const cache = getAppSettingsCache();
|
||||
const merged: AppSettingsDto = { ...cache, uiPreferences: next };
|
||||
Object.assign(cache, merged);
|
||||
|
||||
pendingPatch = pendingPatch
|
||||
? (deepMerge(pendingPatch as Record<string, unknown>, patch as Record<string, unknown>) as Partial<UiPreferences>)
|
||||
: patch;
|
||||
|
||||
const flush = () => {
|
||||
saveTimer = null;
|
||||
const toSave = pendingPatch
|
||||
? resolveUiPreferences(deepMerge(getUiPreferences() as Record<string, unknown>, pendingPatch as Record<string, unknown>))
|
||||
: next;
|
||||
pendingPatch = null;
|
||||
void saveAppSettings({ uiPreferences: toSave }).catch(err => {
|
||||
console.warn('[uiPreferences] DB 저장 실패:', err);
|
||||
});
|
||||
};
|
||||
|
||||
if (immediate) {
|
||||
if (saveTimer != null) clearTimeout(saveTimer);
|
||||
pendingPatch = null;
|
||||
void saveAppSettings({ uiPreferences: next }).catch(err => {
|
||||
console.warn('[uiPreferences] DB 저장 실패:', err);
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (saveTimer != null) clearTimeout(saveTimer);
|
||||
saveTimer = setTimeout(flush, SAVE_DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
export function subscribeUiPreferences(listener: () => void): () => void {
|
||||
return subscribeAppSettings(listener);
|
||||
}
|
||||
|
||||
/** 중첩 경로 헬퍼 */
|
||||
export function getUiPref<K extends keyof UiPreferences>(key: K): UiPreferences[K] {
|
||||
return getUiPreferences()[key];
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
/**
|
||||
* localStorage → gc_app_settings.ui_preferences_json 1회 이전
|
||||
*/
|
||||
import type { AppSettingsDto } from './backendApi';
|
||||
import type { UiPreferences } from '../types/uiPreferences';
|
||||
import { resolveUiPreferences } from './uiPreferencesDb';
|
||||
|
||||
const MIGRATED_FLAG = 'gc_ui_prefs_migrated_v1';
|
||||
|
||||
function readJson<T>(key: string): T | null {
|
||||
try {
|
||||
const raw = localStorage.getItem(key);
|
||||
if (!raw) return null;
|
||||
return JSON.parse(raw) as T;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function removeKeys(keys: string[]): void {
|
||||
for (const k of keys) {
|
||||
try { localStorage.removeItem(k); } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
/** 이미 DB에 uiPreferences가 있으면 스킵. 없으면 localStorage 병합 후 키 제거. */
|
||||
export function migrateLocalStorageToUiPreferences(
|
||||
current: AppSettingsDto,
|
||||
): { uiPreferences: UiPreferences; changed: boolean } | null {
|
||||
try {
|
||||
if (localStorage.getItem(MIGRATED_FLAG) === '1' && current.uiPreferences) {
|
||||
return null;
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
|
||||
const base = resolveUiPreferences(current.uiPreferences);
|
||||
const patch: UiPreferences = { ...base };
|
||||
let changed = false;
|
||||
|
||||
const editorMode = localStorage.getItem('gc_se_editor_mode');
|
||||
if (editorMode === 'list' || editorMode === 'graph') {
|
||||
patch.strategyEditor = { ...patch.strategyEditor, editorMode };
|
||||
changed = true;
|
||||
}
|
||||
|
||||
const canvasMode = localStorage.getItem('gc_se_canvas_interaction');
|
||||
if (canvasMode) {
|
||||
patch.strategyEditor = { ...patch.strategyEditor, canvasInteraction: canvasMode };
|
||||
changed = true;
|
||||
}
|
||||
|
||||
const paletteAux = readJson<unknown[]>('se-palette-auxiliary-v1');
|
||||
if (paletteAux?.length) {
|
||||
patch.strategyEditor = { ...patch.strategyEditor, paletteAuxiliary: paletteAux as NonNullable<UiPreferences['strategyEditor']>['paletteAuxiliary'] };
|
||||
changed = true;
|
||||
}
|
||||
const paletteComp = readJson<unknown[]>('se-palette-composite-v1');
|
||||
if (paletteComp?.length) {
|
||||
patch.strategyEditor = { ...patch.strategyEditor, paletteComposite: paletteComp as NonNullable<UiPreferences['strategyEditor']>['paletteComposite'] };
|
||||
changed = true;
|
||||
}
|
||||
|
||||
const customTabs = readJson<NonNullable<UiPreferences['indicator']>['customTabs']>('gc-indicator-custom-tabs-v1' as never);
|
||||
if (customTabs?.length) {
|
||||
patch.indicator = { ...patch.indicator, customTabs };
|
||||
changed = true;
|
||||
}
|
||||
|
||||
const mainTabOrder = readJson<string[]>('gc_indicator_main_tab_order');
|
||||
if (mainTabOrder?.length) {
|
||||
patch.indicator = { ...patch.indicator, mainTabOrder };
|
||||
changed = true;
|
||||
}
|
||||
|
||||
const listOrder = readJson<string[]>('gc_indicator_settings_list_order');
|
||||
if (listOrder?.length) {
|
||||
patch.indicator = { ...patch.indicator, listOrder };
|
||||
changed = true;
|
||||
}
|
||||
|
||||
const chartState = readJson<{ alertPrices?: number[]; indicators?: unknown[] }>('trading_chart_state');
|
||||
if (chartState?.alertPrices?.length || chartState?.indicators?.length) {
|
||||
patch.chart = {
|
||||
...patch.chart,
|
||||
alertPrices: chartState.alertPrices,
|
||||
legacyIndicators: chartState.indicators as NonNullable<UiPreferences['chart']>['legacyIndicators'],
|
||||
};
|
||||
changed = true;
|
||||
}
|
||||
|
||||
const panels: Record<string, number | boolean | string> = { ...patch.panels };
|
||||
for (const key of ['se-left-width', 'se-terminal-height', 'btd-left-width', 'btd-right-width']) {
|
||||
const v = localStorage.getItem(key);
|
||||
if (v != null) {
|
||||
const n = Number(v);
|
||||
panels[key] = Number.isFinite(n) ? n : v;
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
const virtualTargets = readJson<unknown[]>('gc_virtual_targets_v1');
|
||||
const virtualSession = readJson<unknown>('gc_virtual_session_v1');
|
||||
const cardView = localStorage.getItem('gc_virtual_card_view_v1');
|
||||
if (virtualTargets?.length || virtualSession || cardView) {
|
||||
patch.virtual = {
|
||||
...patch.virtual,
|
||||
...(virtualTargets?.length ? { targets: virtualTargets as NonNullable<UiPreferences['virtual']>['targets'] } : {}),
|
||||
...(virtualSession ? { session: virtualSession as NonNullable<UiPreferences['virtual']>['session'] } : {}),
|
||||
...(cardView === 'detail' || cardView === 'summary' ? { cardViewMode: cardView } : {}),
|
||||
};
|
||||
changed = true;
|
||||
}
|
||||
|
||||
const readIds = readJson<string[]>('gc_trade_notify_read_v1');
|
||||
const hiddenIds = readJson<string[]>('gc_trade_notify_hidden_v1');
|
||||
if (readIds?.length || hiddenIds?.length) {
|
||||
patch.tradeNotifications = {
|
||||
readIds: readIds ?? [],
|
||||
hiddenIds: hiddenIds ?? [],
|
||||
};
|
||||
changed = true;
|
||||
}
|
||||
|
||||
const displayMode = localStorage.getItem('tsd-display-mode');
|
||||
if (displayMode === 'chart' || displayMode === 'summary') {
|
||||
changed = true;
|
||||
}
|
||||
|
||||
if (!changed && current.uiPreferences) {
|
||||
try { localStorage.setItem(MIGRATED_FLAG, '1'); } catch { /* ignore */ }
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!changed) return null;
|
||||
|
||||
removeKeys([
|
||||
'gc_se_editor_mode',
|
||||
'gc_se_canvas_interaction',
|
||||
'se-palette-auxiliary-v1',
|
||||
'se-palette-composite-v1',
|
||||
'gc-indicator-custom-tabs-v1',
|
||||
'gc_indicator_main_tab_order',
|
||||
'gc_indicator_settings_list_order',
|
||||
'trading_chart_state',
|
||||
'chartLayout',
|
||||
'chartLayoutSync',
|
||||
'gc_virtual_targets_v1',
|
||||
'gc_virtual_session_v1',
|
||||
'gc_virtual_card_view_v1',
|
||||
'gc_trade_notify_read_v1',
|
||||
'gc_trade_notify_hidden_v1',
|
||||
'tsd-display-mode',
|
||||
'gc_se_flow_layout_v1',
|
||||
'gc_strat_v2',
|
||||
'gc_strategies_v1',
|
||||
'se-left-width',
|
||||
'se-terminal-height',
|
||||
'btd-left-width',
|
||||
'btd-right-width',
|
||||
]);
|
||||
|
||||
try { localStorage.setItem(MIGRATED_FLAG, '1'); } catch { /* ignore */ }
|
||||
|
||||
return { uiPreferences: resolveUiPreferences(patch), changed: true };
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import { normalizeStartCandleType } from './strategyStartNodes';
|
||||
import { resolveVirtualTargetNames } from './virtualTargetNames';
|
||||
import { getUiPreferences, patchUiPreferences } from './uiPreferencesDb';
|
||||
|
||||
/** 가상투자 대상 종목·세션 설정 localStorage */
|
||||
/** 가상투자 대상 종목·세션 설정 (DB ui_preferences + per-market live settings) */
|
||||
|
||||
export interface VirtualTargetItem {
|
||||
market: string;
|
||||
@@ -22,68 +23,9 @@ export interface VirtualSessionConfig {
|
||||
running: boolean;
|
||||
}
|
||||
|
||||
const TARGETS_KEY = 'gc_virtual_targets_v1';
|
||||
const SESSION_KEY = 'gc_virtual_session_v1';
|
||||
const CARD_VIEW_KEY = 'gc_virtual_card_view_v1';
|
||||
|
||||
/** 카드 표시: summary=이퀄라이저·신호등·푸터만, detail=지표 테이블 포함 전체 */
|
||||
export type VirtualCardViewMode = 'summary' | 'detail';
|
||||
|
||||
export function loadVirtualTargets(): VirtualTargetItem[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(TARGETS_KEY);
|
||||
if (!raw) return [];
|
||||
const parsed = JSON.parse(raw) as VirtualTargetItem[];
|
||||
if (!Array.isArray(parsed)) return [];
|
||||
return parsed.map(t => {
|
||||
const { koreanName, englishName } = resolveVirtualTargetNames(
|
||||
t.market,
|
||||
t.koreanName,
|
||||
t.englishName,
|
||||
);
|
||||
return { ...t, koreanName, englishName };
|
||||
});
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function saveVirtualTargets(items: VirtualTargetItem[]): void {
|
||||
try {
|
||||
localStorage.setItem(TARGETS_KEY, JSON.stringify(items));
|
||||
notifyVirtualSessionChanged();
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
export function loadVirtualSession(): VirtualSessionConfig {
|
||||
try {
|
||||
const raw = localStorage.getItem(SESSION_KEY);
|
||||
if (!raw) return defaultSession();
|
||||
const parsed = JSON.parse(raw) as Partial<VirtualSessionConfig>;
|
||||
return {
|
||||
globalStrategyId: parsed.globalStrategyId ?? null,
|
||||
executionType: parsed.executionType === 'REALTIME_TICK' ? 'REALTIME_TICK' : 'CANDLE_CLOSE',
|
||||
positionMode: parsed.positionMode === 'SIGNAL_ONLY' ? 'SIGNAL_ONLY' : 'LONG_ONLY',
|
||||
running: parsed.running === true,
|
||||
};
|
||||
} catch {
|
||||
return defaultSession();
|
||||
}
|
||||
}
|
||||
|
||||
export function saveVirtualSession(cfg: VirtualSessionConfig): void {
|
||||
try {
|
||||
localStorage.setItem(SESSION_KEY, JSON.stringify(cfg));
|
||||
notifyVirtualSessionChanged();
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
|
||||
export const VIRTUAL_SESSION_CHANGED_EVENT = 'gc_virtual_session_changed';
|
||||
|
||||
export function notifyVirtualSessionChanged(): void {
|
||||
window.dispatchEvent(new CustomEvent(VIRTUAL_SESSION_CHANGED_EVENT));
|
||||
}
|
||||
|
||||
function defaultSession(): VirtualSessionConfig {
|
||||
return {
|
||||
globalStrategyId: null,
|
||||
@@ -93,19 +35,57 @@ function defaultSession(): VirtualSessionConfig {
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeTargets(items: VirtualTargetItem[]): VirtualTargetItem[] {
|
||||
return items.map(t => {
|
||||
const { koreanName, englishName } = resolveVirtualTargetNames(
|
||||
t.market,
|
||||
t.koreanName,
|
||||
t.englishName,
|
||||
);
|
||||
return { ...t, koreanName, englishName };
|
||||
});
|
||||
}
|
||||
|
||||
export function loadVirtualTargets(): VirtualTargetItem[] {
|
||||
const raw = getUiPreferences().virtual?.targets;
|
||||
if (!raw?.length) return [];
|
||||
return normalizeTargets(raw);
|
||||
}
|
||||
|
||||
export function saveVirtualTargets(items: VirtualTargetItem[]): void {
|
||||
patchUiPreferences({ virtual: { targets: items } });
|
||||
notifyVirtualSessionChanged();
|
||||
}
|
||||
|
||||
export function loadVirtualSession(): VirtualSessionConfig {
|
||||
const parsed = getUiPreferences().virtual?.session;
|
||||
if (!parsed) return defaultSession();
|
||||
return {
|
||||
globalStrategyId: parsed.globalStrategyId ?? null,
|
||||
executionType: parsed.executionType === 'REALTIME_TICK' ? 'REALTIME_TICK' : 'CANDLE_CLOSE',
|
||||
positionMode: parsed.positionMode === 'SIGNAL_ONLY' ? 'SIGNAL_ONLY' : 'LONG_ONLY',
|
||||
running: parsed.running === true,
|
||||
};
|
||||
}
|
||||
|
||||
export function saveVirtualSession(cfg: VirtualSessionConfig): void {
|
||||
patchUiPreferences({ virtual: { session: cfg } });
|
||||
notifyVirtualSessionChanged();
|
||||
}
|
||||
|
||||
export const VIRTUAL_SESSION_CHANGED_EVENT = 'gc_virtual_session_changed';
|
||||
|
||||
export function notifyVirtualSessionChanged(): void {
|
||||
window.dispatchEvent(new CustomEvent(VIRTUAL_SESSION_CHANGED_EVENT));
|
||||
}
|
||||
|
||||
export function loadVirtualCardViewMode(): VirtualCardViewMode {
|
||||
try {
|
||||
const raw = localStorage.getItem(CARD_VIEW_KEY);
|
||||
return raw === 'detail' ? 'detail' : 'summary';
|
||||
} catch {
|
||||
return 'summary';
|
||||
}
|
||||
const mode = getUiPreferences().virtual?.cardViewMode;
|
||||
return mode === 'detail' ? 'detail' : 'summary';
|
||||
}
|
||||
|
||||
export function saveVirtualCardViewMode(mode: VirtualCardViewMode): void {
|
||||
try {
|
||||
localStorage.setItem(CARD_VIEW_KEY, mode);
|
||||
} catch { /* ignore */ }
|
||||
patchUiPreferences({ virtual: { cardViewMode: mode } });
|
||||
}
|
||||
|
||||
/** 카드 시간봉 드롭다운 기본값 — 저장값 → 스냅샷 → 1m */
|
||||
|
||||
@@ -21,6 +21,13 @@ export default defineConfig({
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
// ── 모바일 APK 정보/다운로드 → exdev (로컬 QR·PC 다운로드가 exdev APK 사용) ──
|
||||
'/api/mobile-app': {
|
||||
target: process.env.VITE_MOBILE_APP_PROXY_TARGET || 'http://exdev.co.kr',
|
||||
changeOrigin: true,
|
||||
secure: true,
|
||||
},
|
||||
|
||||
// ── GoldenChart Backend API 프록시 ───────────────────────────
|
||||
'/api': {
|
||||
target: 'http://localhost:8080',
|
||||
|
||||
Reference in New Issue
Block a user