diff --git a/.env b/.env index ae266b0..c554530 100644 --- a/.env +++ b/.env @@ -1,3 +1,8 @@ -VITE_API_URL='https://wn.krbl.ru' -VITE_REACT_APP ='https://wn.krbl.ru/' -VITE_KRBL_MEDIA='https://wn.krbl.ru/media/' \ No newline at end of file +VITE_API_URL='https://wn.st.unprism.ru' +VITE_REACT_APP ='https://wn.st.unprism.ru/' +VITE_KRBL_MEDIA='https://wn.st.unprism.ru/media/' +VITE_NEED_AUTH='true' +# VITE_API_URL='https://wn.krbl.ru' +# VITE_REACT_APP ='https://wn.krbl.ru/' +# VITE_KRBL_MEDIA='https://wn.krbl.ru/media/' +# VITE_NEED_AUTH='true' diff --git a/.gitignore b/.gitignore index 0a64c45..8e86fb1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,14 +1,7 @@ # Logs -<<<<<<< HEAD - -logs -_.log -npm-debug.log_ -======= logs *.log npm-debug.log* ->>>>>>> 6b9aa78 (init: Init React Application) yarn-debug.log* yarn-error.log* pnpm-debug.log* @@ -17,23 +10,11 @@ lerna-debug.log* node_modules dist dist-ssr -<<<<<<< HEAD -\*.local - -# Editor directories and files - -.vscode/_ -!.vscode/extensions.json -.idea -.DS_Store -_.suo -_.ntvs_ -_.njsproj -_.sln -\*.sw? -======= *.local +# Build artifacts +*.tsbuildinfo + # Editor directories and files .vscode/* !.vscode/extensions.json @@ -44,4 +25,3 @@ _.sln *.njsproj *.sln *.sw? ->>>>>>> 6b9aa78 (init: Init React Application) diff --git a/Subtract.svg b/Subtract.svg new file mode 100644 index 0000000..afbba3a --- /dev/null +++ b/Subtract.svg @@ -0,0 +1,4 @@ + + + + diff --git a/index.html b/index.html index 486e7ff..b17b45d 100644 --- a/index.html +++ b/index.html @@ -2,7 +2,7 @@ - + Белые ночи diff --git a/package-lock.json b/package-lock.json index 9c322ee..be546a3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,13 @@ { "name": "white-nights", - "version": "0.0.0", + "version": "1.0.8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "white-nights", - "version": "0.0.0", + "version": "1.0.8", + "license": "UNLICENSED", "dependencies": { "@emotion/react": "^11.14.0", "@emotion/styled": "^11.14.0", @@ -21,28 +22,34 @@ "@tailwindcss/vite": "^4.1.8", "axios": "^1.9.0", "easymde": "^2.20.0", + "i18n-iso-countries": "^7.14.0", + "lodash": "^4.18.1", "lucide-react": "^0.511.0", "mobx": "^6.13.7", "mobx-react-lite": "^4.1.0", "ol": "^10.5.0", + "overlayscrollbars": "^2.15.1", + "overlayscrollbars-react": "^0.5.6", "path": "^0.12.7", "pixi.js": "^8.10.1", "react": "^19.1.0", - "react-colorful": "^5.6.1", "react-dom": "^19.1.0", - "react-dropzone": "^14.3.8", "react-markdown": "^10.1.0", "react-photo-sphere-viewer": "^6.2.3", + "react-router": "^7.9.4", "react-router-dom": "^7.6.1", "react-simplemde-editor": "^5.2.0", "react-toastify": "^11.0.5", + "react-transition-group": "^4.4.5", "rehype-raw": "^7.0.0", "tailwindcss": "^4.1.8", - "three": "^0.177.0" + "three": "^0.177.0", + "uuid": "^13.0.0" }, "devDependencies": { "@eslint/js": "^9.25.0", "@tailwindcss/typography": "^0.5.16", + "@types/lodash": "^4.17.24", "@types/node": "^22.15.24", "@types/react": "^19.1.2", "@types/react-dom": "^19.1.2", @@ -51,22 +58,11 @@ "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.4.19", "globals": "^16.0.0", + "rollup-plugin-visualizer": "^6.0.5", "typescript": "~5.8.3", "typescript-eslint": "^8.30.1", - "vite": "^6.3.5" - } - }, - "node_modules/@ampproject/remapping": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", - "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", - "license": "Apache-2.0", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" - }, - "engines": { - "node": ">=6.0.0" + "vite": "^6.3.5", + "vite-plugin-svgr": "^4.5.0" } }, "node_modules/@babel/code-frame": { @@ -84,9 +80,9 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.27.3", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.27.3.tgz", - "integrity": "sha512-V42wFfx1ymFte+ecf6iXghnnP8kWTO+ZLXIyZq+1LAXHHvTZdVxicn4yiVYdYMGaCO3tmqub11AorKkv+iodqw==", + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.5.tgz", + "integrity": "sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA==", "dev": true, "license": "MIT", "engines": { @@ -94,22 +90,22 @@ } }, "node_modules/@babel/core": { - "version": "7.27.3", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.27.3.tgz", - "integrity": "sha512-hyrN8ivxfvJ4i0fIJuV4EOlV0WDMz5Ui4StRTgVaAvWeiRCilXgwVvxJKtFQ3TKtHgJscB2YiXKGNJuVwhQMtA==", + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz", + "integrity": "sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw==", "dev": true, "license": "MIT", "dependencies": { - "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.27.3", + "@babel/generator": "^7.28.5", "@babel/helper-compilation-targets": "^7.27.2", - "@babel/helper-module-transforms": "^7.27.3", - "@babel/helpers": "^7.27.3", - "@babel/parser": "^7.27.3", + "@babel/helper-module-transforms": "^7.28.3", + "@babel/helpers": "^7.28.4", + "@babel/parser": "^7.28.5", "@babel/template": "^7.27.2", - "@babel/traverse": "^7.27.3", - "@babel/types": "^7.27.3", + "@babel/traverse": "^7.28.5", + "@babel/types": "^7.28.5", + "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", @@ -132,15 +128,15 @@ "license": "MIT" }, "node_modules/@babel/generator": { - "version": "7.27.3", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.27.3.tgz", - "integrity": "sha512-xnlJYj5zepml8NXtjkG0WquFUv8RskFqyFcVgTBp5k+NaA/8uw/K+OSVf8AMGw5e9HKP2ETd5xpK5MLZQD6b4Q==", + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.28.5.tgz", + "integrity": "sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ==", "license": "MIT", "dependencies": { - "@babel/parser": "^7.27.3", - "@babel/types": "^7.27.3", - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.25", + "@babel/parser": "^7.28.5", + "@babel/types": "^7.28.5", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" }, "engines": { @@ -164,6 +160,15 @@ "node": ">=6.9.0" } }, + "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==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/helper-module-imports": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz", @@ -178,15 +183,15 @@ } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.27.3", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.27.3.tgz", - "integrity": "sha512-dSOvYwvyLsWBeIRyOeHXp5vPj5l1I011r52FM1+r1jCERv+aFXYk4whgQccYEGYxK2H3ZAIA8nuPkQ0HaUo3qg==", + "version": "7.28.3", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz", + "integrity": "sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw==", "dev": true, "license": "MIT", "dependencies": { "@babel/helper-module-imports": "^7.27.1", "@babel/helper-validator-identifier": "^7.27.1", - "@babel/traverse": "^7.27.3" + "@babel/traverse": "^7.28.3" }, "engines": { "node": ">=6.9.0" @@ -215,9 +220,9 @@ } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", - "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==", + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", "license": "MIT", "engines": { "node": ">=6.9.0" @@ -234,26 +239,26 @@ } }, "node_modules/@babel/helpers": { - "version": "7.27.3", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.27.3.tgz", - "integrity": "sha512-h/eKy9agOya1IGuLaZ9tEUgz+uIRXcbtOhRtUyyMf8JFmn1iT13vnl/IGVWSkdOCG/pC57U4S1jnAabAavTMwg==", + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz", + "integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==", "dev": true, "license": "MIT", "dependencies": { "@babel/template": "^7.27.2", - "@babel/types": "^7.27.3" + "@babel/types": "^7.28.4" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.27.3", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.27.3.tgz", - "integrity": "sha512-xyYxRj6+tLNDTWi0KCBcZ9V7yg3/lwL9DWh9Uwh/RIVlIfFidggcgxKX3GCXwCiswwcGRawBKbEg2LG/Y8eJhw==", + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz", + "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==", "license": "MIT", "dependencies": { - "@babel/types": "^7.27.3" + "@babel/types": "^7.28.5" }, "bin": { "parser": "bin/babel-parser.js" @@ -295,9 +300,9 @@ } }, "node_modules/@babel/runtime": { - "version": "7.27.6", - "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.6.tgz", - "integrity": "sha512-vbavdySgbTTrmFE+EsiqUTzlOr5bzlnJtUv9PynGCAKvfQqjIXbvFdumPM/GxMDfyuGMJaJAU6TO4zc1Jf1i8Q==", + "version": "7.28.4", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", + "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", "license": "MIT", "engines": { "node": ">=6.9.0" @@ -318,40 +323,31 @@ } }, "node_modules/@babel/traverse": { - "version": "7.27.3", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.27.3.tgz", - "integrity": "sha512-lId/IfN/Ye1CIu8xG7oKBHXd2iNb2aW1ilPszzGcJug6M8RCKfVNcYhpI5+bMvFYjK7lXIM0R+a+6r8xhHp2FQ==", + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.5.tgz", + "integrity": "sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ==", "license": "MIT", "dependencies": { "@babel/code-frame": "^7.27.1", - "@babel/generator": "^7.27.3", - "@babel/parser": "^7.27.3", + "@babel/generator": "^7.28.5", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.28.5", "@babel/template": "^7.27.2", - "@babel/types": "^7.27.3", - "debug": "^4.3.1", - "globals": "^11.1.0" + "@babel/types": "^7.28.5", + "debug": "^4.3.1" }, "engines": { "node": ">=6.9.0" } }, - "node_modules/@babel/traverse/node_modules/globals": { - "version": "11.12.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", - "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", - "license": "MIT", - "engines": { - "node": ">=4" - } - }, "node_modules/@babel/types": { - "version": "7.27.3", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.27.3.tgz", - "integrity": "sha512-Y1GkI4ktrtvmawoSq+4FCVHNryea6uR+qUQy0AGxLSsjCX0nVmkYQMBLHDkXZuo5hGx7eYdnIaslsdBFm7zbUw==", + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz", + "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==", "license": "MIT", "dependencies": { "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.27.1" + "@babel/helper-validator-identifier": "^7.28.5" }, "engines": { "node": ">=6.9.0" @@ -402,9 +398,9 @@ "license": "MIT" }, "node_modules/@emotion/is-prop-valid": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.3.1.tgz", - "integrity": "sha512-/ACwoqx7XQi9knQs/G0qKvv5teDMhD7bXYns9N/wM8ah8iNb8jZ2uNO0YOgiq2o2poIvVtJS2YALasQuMSQ7Kw==", + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.4.0.tgz", + "integrity": "sha512-QgD4fyscGcbbKwJmqNvUMSE02OsHUa+lAWKdEUIJKgqe5IwRSKd7+KhibEWdaKwgjLj0DRSHA9biAIqGBk05lw==", "license": "MIT", "dependencies": { "@emotion/memoize": "^0.9.0" @@ -460,9 +456,9 @@ "license": "MIT" }, "node_modules/@emotion/styled": { - "version": "11.14.0", - "resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.14.0.tgz", - "integrity": "sha512-XxfOnXFffatap2IyCeJyNov3kiDQWoR08gPUQxvbL7fxKryGBKUZUkG6Hz48DZwVrJSVh9sJboyV1Ds4OW6SgA==", + "version": "11.14.1", + "resolved": "https://registry.npmjs.org/@emotion/styled/-/styled-11.14.1.tgz", + "integrity": "sha512-qEEJt42DuToa3gurlH4Qqc1kVpNq8wO8cJtDzU46TjlzWjDlsVyevtYCRijVq3SrHsROS+gVQ8Fnea108GnKzw==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.18.3", @@ -509,10 +505,74 @@ "integrity": "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==", "license": "MIT" }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.11.tgz", + "integrity": "sha512-Xt1dOL13m8u0WE8iplx9Ibbm+hFAO0GsU2P34UNoDGvZYkY8ifSiy6Zuc1lYxfG7svWE2fzqCUmFp5HCn51gJg==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.11.tgz", + "integrity": "sha512-uoa7dU+Dt3HYsethkJ1k6Z9YdcHjTrSb5NUy66ZfZaSV8hEYGD5ZHbEMXnqLFlbBflLsl89Zke7CAdDJ4JI+Gg==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.11.tgz", + "integrity": "sha512-9slpyFBc4FPPz48+f6jyiXOx/Y4v34TUeDDXJpZqAWQn/08lKGeD8aDp9TMn9jDz2CiEuHwfhRmGBvpnd/PWIQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.11.tgz", + "integrity": "sha512-Sgiab4xBjPU1QoPEIqS3Xx+R2lezu0LKIEcYe6pftr56PqPygbB7+szVnzoShbx64MUupqoE0KyRlN7gezbl8g==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@esbuild/darwin-arm64": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.5.tgz", - "integrity": "sha512-GtaBgammVvdF7aPIgH2jxMDdivezgFu6iKpmT+48+F8Hhg5J/sfnDieg0aeG/jfSvkYQU2/pceFPDKlqZzwnfQ==", + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.11.tgz", + "integrity": "sha512-VekY0PBCukppoQrycFxUqkCojnTQhdec0vevUL/EDOCnXd9LKWqD/bHwMPzigIJXPhC59Vd1WFIL57SKs2mg4w==", "cpu": [ "arm64" ], @@ -525,10 +585,346 @@ "node": ">=18" } }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.11.tgz", + "integrity": "sha512-+hfp3yfBalNEpTGp9loYgbknjR695HkqtY3d3/JjSRUyPg/xd6q+mQqIb5qdywnDxRZykIHs3axEqU6l1+oWEQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.11.tgz", + "integrity": "sha512-CmKjrnayyTJF2eVuO//uSjl/K3KsMIeYeyN7FyDBjsR3lnSJHaXlVoAK8DZa7lXWChbuOk7NjAc7ygAwrnPBhA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.11.tgz", + "integrity": "sha512-Dyq+5oscTJvMaYPvW3x3FLpi2+gSZTCE/1ffdwuM6G1ARang/mb3jvjxs0mw6n3Lsw84ocfo9CrNMqc5lTfGOw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.11.tgz", + "integrity": "sha512-TBMv6B4kCfrGJ8cUPo7vd6NECZH/8hPpBHHlYI3qzoYFvWu2AdTvZNuU/7hsbKWqu/COU7NIK12dHAAqBLLXgw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.11.tgz", + "integrity": "sha512-Qr8AzcplUhGvdyUF08A1kHU3Vr2O88xxP0Tm8GcdVOUm25XYcMPp2YqSVHbLuXzYQMf9Bh/iKx7YPqECs6ffLA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.11.tgz", + "integrity": "sha512-TmnJg8BMGPehs5JKrCLqyWTVAvielc615jbkOirATQvWWB1NMXY77oLMzsUjRLa0+ngecEmDGqt5jiDC6bfvOw==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.11.tgz", + "integrity": "sha512-DIGXL2+gvDaXlaq8xruNXUJdT5tF+SBbJQKbWy/0J7OhU8gOHOzKmGIlfTTl6nHaCOoipxQbuJi7O++ldrxgMw==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.11.tgz", + "integrity": "sha512-Osx1nALUJu4pU43o9OyjSCXokFkFbyzjXb6VhGIJZQ5JZi8ylCQ9/LFagolPsHtgw6himDSyb5ETSfmp4rpiKQ==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.11.tgz", + "integrity": "sha512-nbLFgsQQEsBa8XSgSTSlrnBSrpoWh7ioFDUmwo158gIm5NNP+17IYmNWzaIzWmgCxq56vfr34xGkOcZ7jX6CPw==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.11.tgz", + "integrity": "sha512-HfyAmqZi9uBAbgKYP1yGuI7tSREXwIb438q0nqvlpxAOs3XnZ8RsisRfmVsgV486NdjD7Mw2UrFSw51lzUk1ww==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.11.tgz", + "integrity": "sha512-HjLqVgSSYnVXRisyfmzsH6mXqyvj0SA7pG5g+9W7ESgwA70AXYNpfKBqh1KbTxmQVaYxpzA/SvlB9oclGPbApw==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.11.tgz", + "integrity": "sha512-HSFAT4+WYjIhrHxKBwGmOOSpphjYkcswF449j6EjsjbinTZbp8PJtjsVK1XFJStdzXdy/jaddAep2FGY+wyFAQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.11.tgz", + "integrity": "sha512-hr9Oxj1Fa4r04dNpWr3P8QKVVsjQhqrMSUzZzf+LZcYjZNqhA3IAfPQdEh1FLVUJSiu6sgAwp3OmwBfbFgG2Xg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.11.tgz", + "integrity": "sha512-u7tKA+qbzBydyj0vgpu+5h5AeudxOAGncb8N6C9Kh1N4n7wU1Xw1JDApsRjpShRpXRQlJLb9wY28ELpwdPcZ7A==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.11.tgz", + "integrity": "sha512-Qq6YHhayieor3DxFOoYM1q0q1uMFYb7cSpLD2qzDSvK1NAvqFi8Xgivv0cFC6J+hWVw2teCYltyy9/m/14ryHg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.11.tgz", + "integrity": "sha512-CN+7c++kkbrckTOz5hrehxWN7uIhFFlmS/hqziSFVWpAzpWrQoAG4chH+nN3Be+Kzv/uuo7zhX716x3Sn2Jduw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.11.tgz", + "integrity": "sha512-rOREuNIQgaiR+9QuNkbkxubbp8MSO9rONmwP5nKncnWJ9v5jQ4JxFnLu4zDSRPf3x4u+2VN4pM4RdyIzDty/wQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.11.tgz", + "integrity": "sha512-nq2xdYaWxyg9DcIyXkZhcYulC6pQ2FuCgem3LI92IwMgIZ69KHeY8T4Y88pcwoLIjbed8n36CyKoYRDygNSGhA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.11.tgz", + "integrity": "sha512-3XxECOWJq1qMZ3MN8srCJ/QfoLpL+VaxD/WfNRm1O3B4+AZ/BnLVgFbUV3eiRYDMXetciH16dwPbbHqwe1uU0Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.11.tgz", + "integrity": "sha512-3ukss6gb9XZ8TlRyJlgLn17ecsK4NSQTmdIXRASVsiS2sQ6zPPZklNJT5GR5tE/MUarymmy8kCEf5xPCNCqVOA==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.11.tgz", + "integrity": "sha512-D7Hpz6A2L4hzsRpPaCYkQnGOotdUpDzSGRIv9I+1ITdHROSFUWW95ZPZWQmGka1Fg7W3zFJowyn9WGwMJ0+KPA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, "node_modules/@eslint-community/eslint-utils": { - "version": "4.7.0", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz", - "integrity": "sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw==", + "version": "4.9.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz", + "integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==", "dev": true, "license": "MIT", "dependencies": { @@ -558,9 +954,9 @@ } }, "node_modules/@eslint-community/regexpp": { - "version": "4.12.1", - "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz", - "integrity": "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ==", + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", "dev": true, "license": "MIT", "engines": { @@ -568,13 +964,13 @@ } }, "node_modules/@eslint/config-array": { - "version": "0.20.0", - "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.20.0.tgz", - "integrity": "sha512-fxlS1kkIjx8+vy2SjuCB94q3htSNrufYTXubwiBFeaQHbH6Ipi43gFJq2zCMt6PHhImH3Xmr0NksKDvchWlpQQ==", + "version": "0.21.1", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz", + "integrity": "sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/object-schema": "^2.1.6", + "@eslint/object-schema": "^2.1.7", "debug": "^4.3.1", "minimatch": "^3.1.2" }, @@ -583,19 +979,22 @@ } }, "node_modules/@eslint/config-helpers": { - "version": "0.2.2", - "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.2.2.tgz", - "integrity": "sha512-+GPzk8PlG0sPpzdU5ZvIRMPidzAnZDl/s9L+y13iodqvb8leL53bTannOrQ/Im7UkpsmFU5Ily5U60LWixnmLg==", + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.1.tgz", + "integrity": "sha512-csZAzkNhsgwb0I/UAV6/RGFTbiakPCf0ZrGmrIxQpYvGZ00PhTkSnyKNolphgIvmnJeGw6rcGVEXfTzUnFuEvw==", "dev": true, "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.16.0" + }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" } }, "node_modules/@eslint/core": { - "version": "0.14.0", - "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.14.0.tgz", - "integrity": "sha512-qIbV0/JZr7iSDjqAc60IqbLdsj9GDt16xQtWD+B78d/HAlvysGdZZ6rpJHGAc2T0FQx1X6thsSPdnoiGKdNtdg==", + "version": "0.16.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.16.0.tgz", + "integrity": "sha512-nmC8/totwobIiFcGkDza3GIKfAw1+hLiYVrh3I1nIomQ8PEr5cxg34jnkmGawul/ep52wGRAcyeDCNtWKSOj4Q==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -643,9 +1042,9 @@ } }, "node_modules/@eslint/js": { - "version": "9.27.0", - "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.27.0.tgz", - "integrity": "sha512-G5JD9Tu5HJEu4z2Uo4aHY2sLV64B7CDMXxFzqzjl3NKd6RVzSXNoE80jk7Y0lJkTTkjiIhBAqmlYwjuBY3tvpA==", + "version": "9.38.0", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.38.0.tgz", + "integrity": "sha512-UZ1VpFvXf9J06YG9xQBdnzU+kthors6KjhMAl6f4gH4usHyh31rUf2DLGInT8RFYIReYXNSydgPY0V2LuWgl7A==", "dev": true, "license": "MIT", "engines": { @@ -656,9 +1055,9 @@ } }, "node_modules/@eslint/object-schema": { - "version": "2.1.6", - "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.6.tgz", - "integrity": "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA==", + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", "dev": true, "license": "Apache-2.0", "engines": { @@ -666,13 +1065,13 @@ } }, "node_modules/@eslint/plugin-kit": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.1.tgz", - "integrity": "sha512-0J+zgWxHN+xXONWIyPWKFMgVuJoZuGiIFu8yxk7RJjxkzpGmyja5wRFqZIVtjDVOQpV+Rw0iOAjYPE2eQyjr0w==", + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.0.tgz", + "integrity": "sha512-sB5uyeq+dwCWyPi31B2gQlVlo+j5brPlWx4yZBrEaRo/nhdDE8Xke1gsGgtiBdaBTxuTkceLVuVt/pclrasb0A==", "dev": true, "license": "Apache-2.0", "dependencies": { - "@eslint/core": "^0.14.0", + "@eslint/core": "^0.16.0", "levn": "^0.4.1" }, "engines": { @@ -707,33 +1106,19 @@ } }, "node_modules/@humanfs/node": { - "version": "0.16.6", - "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.6.tgz", - "integrity": "sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw==", + "version": "0.16.7", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz", + "integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==", "dev": true, "license": "Apache-2.0", "dependencies": { "@humanfs/core": "^0.19.1", - "@humanwhocodes/retry": "^0.3.0" + "@humanwhocodes/retry": "^0.4.0" }, "engines": { "node": ">=18.18.0" } }, - "node_modules/@humanfs/node/node_modules/@humanwhocodes/retry": { - "version": "0.3.1", - "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.3.1.tgz", - "integrity": "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA==", - "dev": true, - "license": "Apache-2.0", - "engines": { - "node": ">=18.18" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/nzakas" - } - }, "node_modules/@humanwhocodes/module-importer": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", @@ -762,30 +1147,24 @@ "url": "https://github.com/sponsors/nzakas" } }, - "node_modules/@isaacs/fs-minipass": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", - "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", - "license": "ISC", - "dependencies": { - "minipass": "^7.0.4" - }, - "engines": { - "node": ">=18.0.0" - } - }, "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.8", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz", - "integrity": "sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA==", + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", "license": "MIT", "dependencies": { - "@jridgewell/set-array": "^1.2.1", - "@jridgewell/sourcemap-codec": "^1.4.10", + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" - }, - "engines": { - "node": ">=6.0.0" } }, "node_modules/@jridgewell/resolve-uri": { @@ -797,25 +1176,16 @@ "node": ">=6.0.0" } }, - "node_modules/@jridgewell/set-array": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz", - "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } - }, "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", - "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.25", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", - "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", @@ -841,9 +1211,9 @@ } }, "node_modules/@mui/core-downloads-tracker": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-7.1.1.tgz", - "integrity": "sha512-yBckQs4aQ8mqukLnPC6ivIRv6guhaXi8snVl00VtyojBbm+l6VbVhyTSZ68Abcx7Ah8B+GZhrB7BOli+e+9LkQ==", + "version": "7.3.4", + "resolved": "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-7.3.4.tgz", + "integrity": "sha512-BIktMapG3r4iXwIhYNpvk97ZfYWTreBBQTWjQKbNbzI64+ULHfYavQEX2w99aSWHS58DvXESWIgbD9adKcUOBw==", "license": "MIT", "funding": { "type": "opencollective", @@ -851,12 +1221,12 @@ } }, "node_modules/@mui/icons-material": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/@mui/icons-material/-/icons-material-7.1.1.tgz", - "integrity": "sha512-X37+Yc8QpEnl0sYmz+WcLFy2dWgNRzbswDzLPXG7QU1XDVlP5TPp1HXjdmCupOWLL/I9m1fyhcyZl8/HPpp/Cg==", + "version": "7.3.4", + "resolved": "https://registry.npmjs.org/@mui/icons-material/-/icons-material-7.3.4.tgz", + "integrity": "sha512-9n6Xcq7molXWYb680N2Qx+FRW8oT6j/LXF5PZFH3ph9X/Rct0B/BlLAsFI7iL9ySI6LVLuQIVtrLiPT82R7OZw==", "license": "MIT", "dependencies": { - "@babel/runtime": "^7.27.1" + "@babel/runtime": "^7.28.4" }, "engines": { "node": ">=14.0.0" @@ -866,7 +1236,7 @@ "url": "https://opencollective.com/mui-org" }, "peerDependencies": { - "@mui/material": "^7.1.1", + "@mui/material": "^7.3.4", "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", "react": "^17.0.0 || ^18.0.0 || ^19.0.0" }, @@ -877,22 +1247,22 @@ } }, "node_modules/@mui/material": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/@mui/material/-/material-7.1.1.tgz", - "integrity": "sha512-mTpdmdZCaHCGOH3SrYM41+XKvNL0iQfM9KlYgpSjgadXx/fEKhhvOktxm8++Xw6FFeOHoOiV+lzOI8X1rsv71A==", + "version": "7.3.4", + "resolved": "https://registry.npmjs.org/@mui/material/-/material-7.3.4.tgz", + "integrity": "sha512-gEQL9pbJZZHT7lYJBKQCS723v1MGys2IFc94COXbUIyCTWa+qC77a7hUax4Yjd5ggEm35dk4AyYABpKKWC4MLw==", "license": "MIT", "dependencies": { - "@babel/runtime": "^7.27.1", - "@mui/core-downloads-tracker": "^7.1.1", - "@mui/system": "^7.1.1", - "@mui/types": "^7.4.3", - "@mui/utils": "^7.1.1", + "@babel/runtime": "^7.28.4", + "@mui/core-downloads-tracker": "^7.3.4", + "@mui/system": "^7.3.3", + "@mui/types": "^7.4.7", + "@mui/utils": "^7.3.3", "@popperjs/core": "^2.11.8", "@types/react-transition-group": "^4.4.12", "clsx": "^2.1.1", "csstype": "^3.1.3", "prop-types": "^15.8.1", - "react-is": "^19.1.0", + "react-is": "^19.1.1", "react-transition-group": "^4.4.5" }, "engines": { @@ -905,7 +1275,7 @@ "peerDependencies": { "@emotion/react": "^11.5.0", "@emotion/styled": "^11.3.0", - "@mui/material-pigment-css": "^7.1.1", + "@mui/material-pigment-css": "^7.3.3", "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0", "react": "^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" @@ -925,20 +1295,14 @@ } } }, - "node_modules/@mui/material/node_modules/react-is": { - "version": "19.1.0", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.1.0.tgz", - "integrity": "sha512-Oe56aUPnkHyyDxxkvqtd7KkdQP5uIUfHxd5XTb3wE9d/kRnZLmKbDB0GWk919tdQ+mxxPtG6EAs6RMT6i1qtHg==", - "license": "MIT" - }, "node_modules/@mui/private-theming": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-7.1.1.tgz", - "integrity": "sha512-M8NbLUx+armk2ZuaxBkkMk11ultnWmrPlN0Xe3jUEaBChg/mcxa5HWIWS1EE4DF36WRACaAHVAvyekWlDQf0PQ==", + "version": "7.3.3", + "resolved": "https://registry.npmjs.org/@mui/private-theming/-/private-theming-7.3.3.tgz", + "integrity": "sha512-OJM+9nj5JIyPUvsZ5ZjaeC9PfktmK+W5YaVLToLR8L0lB/DGmv1gcKE43ssNLSvpoW71Hct0necfade6+kW3zQ==", "license": "MIT", "dependencies": { - "@babel/runtime": "^7.27.1", - "@mui/utils": "^7.1.1", + "@babel/runtime": "^7.28.4", + "@mui/utils": "^7.3.3", "prop-types": "^15.8.1" }, "engines": { @@ -959,13 +1323,13 @@ } }, "node_modules/@mui/styled-engine": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-7.1.1.tgz", - "integrity": "sha512-R2wpzmSN127j26HrCPYVQ53vvMcT5DaKLoWkrfwUYq3cYytL6TQrCH8JBH3z79B6g4nMZZVoaXrxO757AlShaw==", + "version": "7.3.3", + "resolved": "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-7.3.3.tgz", + "integrity": "sha512-CmFxvRJIBCEaWdilhXMw/5wFJ1+FT9f3xt+m2pPXhHPeVIbBg9MnMvNSJjdALvnQJMPw8jLhrUtXmN7QAZV2fw==", "license": "MIT", "dependencies": { - "@babel/runtime": "^7.27.1", - "@emotion/cache": "^11.13.5", + "@babel/runtime": "^7.28.4", + "@emotion/cache": "^11.14.0", "@emotion/serialize": "^1.3.3", "@emotion/sheet": "^1.4.0", "csstype": "^3.1.3", @@ -993,16 +1357,16 @@ } }, "node_modules/@mui/system": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/@mui/system/-/system-7.1.1.tgz", - "integrity": "sha512-Kj1uhiqnj4Zo7PDjAOghtXJtNABunWvhcRU0O7RQJ7WOxeynoH6wXPcilphV8QTFtkKaip8EiNJRiCD+B3eROA==", + "version": "7.3.3", + "resolved": "https://registry.npmjs.org/@mui/system/-/system-7.3.3.tgz", + "integrity": "sha512-Lqq3emZr5IzRLKaHPuMaLBDVaGvxoh6z7HMWd1RPKawBM5uMRaQ4ImsmmgXWtwJdfZux5eugfDhXJUo2mliS8Q==", "license": "MIT", "dependencies": { - "@babel/runtime": "^7.27.1", - "@mui/private-theming": "^7.1.1", - "@mui/styled-engine": "^7.1.1", - "@mui/types": "^7.4.3", - "@mui/utils": "^7.1.1", + "@babel/runtime": "^7.28.4", + "@mui/private-theming": "^7.3.3", + "@mui/styled-engine": "^7.3.3", + "@mui/types": "^7.4.7", + "@mui/utils": "^7.3.3", "clsx": "^2.1.1", "csstype": "^3.1.3", "prop-types": "^15.8.1" @@ -1033,12 +1397,12 @@ } }, "node_modules/@mui/types": { - "version": "7.4.3", - "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.4.3.tgz", - "integrity": "sha512-2UCEiK29vtiZTeLdS2d4GndBKacVyxGvReznGXGr+CzW/YhjIX+OHUdCIczZjzcRAgKBGmE9zCIgoV9FleuyRQ==", + "version": "7.4.7", + "resolved": "https://registry.npmjs.org/@mui/types/-/types-7.4.7.tgz", + "integrity": "sha512-8vVje9rdEr1rY8oIkYgP+Su5Kwl6ik7O3jQ0wl78JGSmiZhRHV+vkjooGdKD8pbtZbutXFVTWQYshu2b3sG9zw==", "license": "MIT", "dependencies": { - "@babel/runtime": "^7.27.1" + "@babel/runtime": "^7.28.4" }, "peerDependencies": { "@types/react": "^17.0.0 || ^18.0.0 || ^19.0.0" @@ -1050,17 +1414,17 @@ } }, "node_modules/@mui/utils": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-7.1.1.tgz", - "integrity": "sha512-BkOt2q7MBYl7pweY2JWwfrlahhp+uGLR8S+EhiyRaofeRYUWL2YKbSGQvN4hgSN1i8poN0PaUiii1kEMrchvzg==", + "version": "7.3.3", + "resolved": "https://registry.npmjs.org/@mui/utils/-/utils-7.3.3.tgz", + "integrity": "sha512-kwNAUh7bLZ7mRz9JZ+6qfRnnxbE4Zuc+RzXnhSpRSxjTlSTj7b4JxRLXpG+MVtPVtqks5k/XC8No1Vs3x4Z2gg==", "license": "MIT", "dependencies": { - "@babel/runtime": "^7.27.1", - "@mui/types": "^7.4.3", - "@types/prop-types": "^15.7.14", + "@babel/runtime": "^7.28.4", + "@mui/types": "^7.4.7", + "@types/prop-types": "^15.7.15", "clsx": "^2.1.1", "prop-types": "^15.8.1", - "react-is": "^19.1.0" + "react-is": "^19.1.1" }, "engines": { "node": ">=14.0.0" @@ -1079,25 +1443,19 @@ } } }, - "node_modules/@mui/utils/node_modules/react-is": { - "version": "19.1.0", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.1.0.tgz", - "integrity": "sha512-Oe56aUPnkHyyDxxkvqtd7KkdQP5uIUfHxd5XTb3wE9d/kRnZLmKbDB0GWk919tdQ+mxxPtG6EAs6RMT6i1qtHg==", - "license": "MIT" - }, "node_modules/@mui/x-data-grid": { - "version": "8.5.1", - "resolved": "https://registry.npmjs.org/@mui/x-data-grid/-/x-data-grid-8.5.1.tgz", - "integrity": "sha512-Ukodx8cOc/GR4+2zr4DRNBJJOlyeJNaxK4hggWv7VchDL7Jf70dLZO7oLXPhEFG05Yna81RatL/UFsRYIdWI1Q==", + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/@mui/x-data-grid/-/x-data-grid-8.15.0.tgz", + "integrity": "sha512-JNPG2WSYJVKbUAbDpLCbWmIY25k9hyfUjAVnzDREbJMwPL+/5B9pIK0ikRQEXc0wRKY2T59SeR/Um2FZjBeeWQ==", "license": "MIT", "dependencies": { - "@babel/runtime": "^7.27.4", - "@mui/utils": "^7.1.1", - "@mui/x-internals": "8.5.1", + "@babel/runtime": "^7.28.4", + "@mui/utils": "^7.3.3", + "@mui/x-internals": "8.14.0", + "@mui/x-virtualizer": "0.2.5", "clsx": "^2.1.1", "prop-types": "^15.8.1", - "reselect": "^5.1.1", - "use-sync-external-store": "^1.5.0" + "use-sync-external-store": "^1.6.0" }, "engines": { "node": ">=14.0.0" @@ -1124,13 +1482,15 @@ } }, "node_modules/@mui/x-internals": { - "version": "8.5.1", - "resolved": "https://registry.npmjs.org/@mui/x-internals/-/x-internals-8.5.1.tgz", - "integrity": "sha512-7rAWK7SB6FxEIXKgsHsJjIzeeKOLxFJ16gePgZVWlvyew+xDb4P0fgjwW3ThcJjgvkUm0UhGGfLh/JP8l514IA==", + "version": "8.14.0", + "resolved": "https://registry.npmjs.org/@mui/x-internals/-/x-internals-8.14.0.tgz", + "integrity": "sha512-esYyl61nuuFXiN631TWuPh2tqdoyTdBI/4UXgwH3rytF8jiWvy6prPBPRHEH1nvW3fgw9FoBI48FlOO+yEI8xg==", "license": "MIT", "dependencies": { - "@babel/runtime": "^7.27.4", - "@mui/utils": "^7.1.1" + "@babel/runtime": "^7.28.4", + "@mui/utils": "^7.3.3", + "reselect": "^5.1.1", + "use-sync-external-store": "^1.6.0" }, "engines": { "node": ">=14.0.0" @@ -1140,10 +1500,31 @@ "url": "https://opencollective.com/mui-org" }, "peerDependencies": { - "@mui/system": "^5.15.14 || ^6.0.0 || ^7.0.0", "react": "^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/@mui/x-virtualizer": { + "version": "0.2.5", + "resolved": "https://registry.npmjs.org/@mui/x-virtualizer/-/x-virtualizer-0.2.5.tgz", + "integrity": "sha512-kCo/i9YfNavbupqZGO1649CHwIABrwUDHVZh+GvGierHhIglUc9MHxYKsPhuojOg6izWa2HP+klt3nq2n/arOw==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4", + "@mui/utils": "^7.3.3", + "@mui/x-internals": "8.14.0" + }, + "engines": { + "node": ">=14.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/mui-org" + }, + "peerDependencies": { + "react": "^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -1183,24 +1564,24 @@ } }, "node_modules/@petamoriken/float16": { - "version": "3.9.2", - "resolved": "https://registry.npmjs.org/@petamoriken/float16/-/float16-3.9.2.tgz", - "integrity": "sha512-VgffxawQde93xKxT3qap3OH+meZf7VaSB5Sqd4Rqc+FP5alWbpOyan/7tRbOAvynjpG3GpdtAuGU/NdhQpmrog==", + "version": "3.9.3", + "resolved": "https://registry.npmjs.org/@petamoriken/float16/-/float16-3.9.3.tgz", + "integrity": "sha512-8awtpHXCx/bNpFt4mt2xdkgtgVvKqty8VbjHI/WWWQuEw+KLzFot3f4+LkQY9YmOtq7A5GdOnqoIC8Pdygjk2g==", "license": "MIT" }, "node_modules/@photo-sphere-viewer/core": { - "version": "5.13.2", - "resolved": "https://registry.npmjs.org/@photo-sphere-viewer/core/-/core-5.13.2.tgz", - "integrity": "sha512-rL4Ey39Prx4Iyxt1f2tAqlXvqu4/ovXfUvIpLt540OpZJiFjWccs6qLywof9vuhBJ7PXHudHWCjRPce0W8kx8w==", + "version": "5.14.0", + "resolved": "https://registry.npmjs.org/@photo-sphere-viewer/core/-/core-5.14.0.tgz", + "integrity": "sha512-V0JeDSB1D2Q60Zqn7+0FPjq8gqbKEwuxMzNdTLydefkQugVztLvdZykO+4k5XTpweZ2QAWPH/QOI1xZbsdvR9A==", "license": "MIT", "dependencies": { - "three": "^0.175.0" + "three": "^0.179.0" } }, "node_modules/@photo-sphere-viewer/core/node_modules/three": { - "version": "0.175.0", - "resolved": "https://registry.npmjs.org/three/-/three-0.175.0.tgz", - "integrity": "sha512-nNE3pnTHxXN/Phw768u0Grr7W4+rumGg/H6PgeseNJojkJtmeHJfZWi41Gp2mpXl1pg1pf1zjwR4McM1jTqkpg==", + "version": "0.179.1", + "resolved": "https://registry.npmjs.org/three/-/three-0.179.1.tgz", + "integrity": "sha512-5y/elSIQbrvKOISxpwXCR4sQqHtGiOI+MKLc3SsBdDXA2hz3Mdp3X59aUp8DyybMa34aeBwbFTpdoLJaUDEWSw==", "license": "MIT" }, "node_modules/@pixi/colord": { @@ -1210,9 +1591,9 @@ "license": "MIT" }, "node_modules/@pixi/react": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/@pixi/react/-/react-8.0.2.tgz", - "integrity": "sha512-A42Bw/1YlxxCXhb+nDIgzPpACx5dOh7Yi+ZfBMlZ1sBB/qc7qyX9k7bdDtrnMqz8OofyF2FXg9gPw9MISFsQTA==", + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/@pixi/react/-/react-8.0.3.tgz", + "integrity": "sha512-k0feY0Vj4WIWpaLaRQY8NBKlDqVCPPyEUu5EibQVqdtgdMx1brRWeJC+ruq7Im08vtZWx7iZwNGhgHNubO/OMQ==", "license": "MIT", "workspaces": [ "docs" @@ -1237,16 +1618,16 @@ } }, "node_modules/@react-three/drei": { - "version": "10.1.2", - "resolved": "https://registry.npmjs.org/@react-three/drei/-/drei-10.1.2.tgz", - "integrity": "sha512-CCcLAqZEvYiUErOcJgGzovY3RH6KgdrqD4ubeAx1nyGbSPLnKR9a8ynYbPdtZhIyiWqGc07z+RYQzpaOfN4ZIA==", + "version": "10.7.6", + "resolved": "https://registry.npmjs.org/@react-three/drei/-/drei-10.7.6.tgz", + "integrity": "sha512-ZSFwRlRaa4zjtB7yHO6Q9xQGuyDCzE7whXBhum92JslcMRC3aouivp0rAzszcVymIoJx6PXmibyP+xr+zKdwLg==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.26.0", "@mediapipe/tasks-vision": "0.10.17", "@monogrid/gainmap-js": "^3.0.6", "@use-gesture/react": "^10.3.1", - "camera-controls": "^2.9.0", + "camera-controls": "^3.1.0", "cross-env": "^7.0.3", "detect-gpu": "^5.0.56", "glsl-noise": "^0.0.0", @@ -1277,13 +1658,13 @@ } }, "node_modules/@react-three/fiber": { - "version": "9.1.2", - "resolved": "https://registry.npmjs.org/@react-three/fiber/-/fiber-9.1.2.tgz", - "integrity": "sha512-k8FR9yVHV9kIF3iuOD0ds5hVymXYXfgdKklqziBVod9ZEJ8uk05Zjw29J/omU3IKeUfLNAIHfxneN3TUYM4I2w==", + "version": "9.4.0", + "resolved": "https://registry.npmjs.org/@react-three/fiber/-/fiber-9.4.0.tgz", + "integrity": "sha512-k4iu1R6e5D54918V4sqmISUkI5OgTw3v7/sDRKEC632Wd5g2WBtUS5gyG63X0GJO/HZUj1tsjSXfyzwrUHZl1g==", "license": "MIT", "dependencies": { "@babel/runtime": "^7.17.8", - "@types/react-reconciler": "^0.28.9", + "@types/react-reconciler": "^0.32.0", "@types/webxr": "*", "base64-js": "^1.5.1", "buffer": "^6.0.3", @@ -1326,23 +1707,79 @@ } } }, - "node_modules/@react-three/fiber/node_modules/scheduler": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.25.0.tgz", - "integrity": "sha512-xFVuu11jh+xcO7JOAGJNOXld8/TcEHK/4CituBUeUb5hqxJLj9YuemAEuvm9gQ/+pgXYfbQuqAkiYu+u7YEsNA==", - "license": "MIT" - }, "node_modules/@rolldown/pluginutils": { - "version": "1.0.0-beta.9", - "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.9.tgz", - "integrity": "sha512-e9MeMtVWo186sgvFFJOPGy7/d2j2mZhLJIdVW0C/xDluuOvymEATqz6zKsP0ZmXGzQtqlyjz5sC1sYQUoJG98w==", + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", "dev": true, "license": "MIT" }, + "node_modules/@rollup/pluginutils": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz", + "integrity": "sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "estree-walker": "^2.0.2", + "picomatch": "^4.0.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "rollup": "^1.20.0||^2.0.0||^3.0.0||^4.0.0" + }, + "peerDependenciesMeta": { + "rollup": { + "optional": true + } + } + }, + "node_modules/@rollup/pluginutils/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.52.5.tgz", + "integrity": "sha512-8c1vW4ocv3UOMp9K+gToY5zL2XiiVw3k7f1ksf4yO1FlDFQ1C2u72iACFnSOceJFsWskc2WZNqeRhFRPzv+wtQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.52.5.tgz", + "integrity": "sha512-mQGfsIEFcu21mvqkEKKu2dYmtuSZOBMmAl5CFlPGLY94Vlcm+zWApK7F/eocsNzp8tKmbeBP8yXyAbx0XHsFNA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.41.1", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.41.1.tgz", - "integrity": "sha512-5afxvwszzdulsU2w8JKWwY8/sJOLPzf0e1bFuvcW5h9zsEg+RQAojdW0ux2zyYAz7R8HvvzKCjLNJhVq965U7w==", + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.52.5.tgz", + "integrity": "sha512-takF3CR71mCAGA+v794QUZ0b6ZSrgJkArC+gUiG6LB6TQty9T0Mqh3m2ImRBOxS2IeYBo4lKWIieSvnEk2OQWA==", "cpu": [ "arm64" ], @@ -1352,53 +1789,576 @@ "darwin" ] }, - "node_modules/@tailwindcss/node": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.8.tgz", - "integrity": "sha512-OWwBsbC9BFAJelmnNcrKuf+bka2ZxCE2A4Ft53Tkg4uoiE67r/PMEYwCsourC26E+kmxfwE0hVzMdxqeW+xu7Q==", + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.52.5.tgz", + "integrity": "sha512-W901Pla8Ya95WpxDn//VF9K9u2JbocwV/v75TE0YIHNTbhqUTv9w4VuQ9MaWlNOkkEfFwkdNhXgcLqPSmHy0fA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.52.5.tgz", + "integrity": "sha512-QofO7i7JycsYOWxe0GFqhLmF6l1TqBswJMvICnRUjqCx8b47MTo46W8AoeQwiokAx3zVryVnxtBMcGcnX12LvA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.52.5.tgz", + "integrity": "sha512-jr21b/99ew8ujZubPo9skbrItHEIE50WdV86cdSoRkKtmWa+DDr6fu2c/xyRT0F/WazZpam6kk7IHBerSL7LDQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.52.5.tgz", + "integrity": "sha512-PsNAbcyv9CcecAUagQefwX8fQn9LQ4nZkpDboBOttmyffnInRy8R8dSg6hxxl2Re5QhHBf6FYIDhIj5v982ATQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.52.5.tgz", + "integrity": "sha512-Fw4tysRutyQc/wwkmcyoqFtJhh0u31K+Q6jYjeicsGJJ7bbEq8LwPWV/w0cnzOqR2m694/Af6hpFayLJZkG2VQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.52.5.tgz", + "integrity": "sha512-a+3wVnAYdQClOTlyapKmyI6BLPAFYs0JM8HRpgYZQO02rMR09ZcV9LbQB+NL6sljzG38869YqThrRnfPMCDtZg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.52.5.tgz", + "integrity": "sha512-AvttBOMwO9Pcuuf7m9PkC1PUIKsfaAJ4AYhy944qeTJgQOqJYJ9oVl2nYgY7Rk0mkbsuOpCAYSs6wLYB2Xiw0Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.52.5.tgz", + "integrity": "sha512-DkDk8pmXQV2wVrF6oq5tONK6UHLz/XcEVow4JTTerdeV1uqPeHxwcg7aFsfnSm9L+OO8WJsWotKM2JJPMWrQtA==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.52.5.tgz", + "integrity": "sha512-W/b9ZN/U9+hPQVvlGwjzi+Wy4xdoH2I8EjaCkMvzpI7wJUs8sWJ03Rq96jRnHkSrcHTpQe8h5Tg3ZzUPGauvAw==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.52.5.tgz", + "integrity": "sha512-sjQLr9BW7R/ZiXnQiWPkErNfLMkkWIoCz7YMn27HldKsADEKa5WYdobaa1hmN6slu9oWQbB6/jFpJ+P2IkVrmw==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.52.5.tgz", + "integrity": "sha512-hq3jU/kGyjXWTvAh2awn8oHroCbrPm8JqM7RUpKjalIRWWXE01CQOf/tUNWNHjmbMHg/hmNCwc/Pz3k1T/j/Lg==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.52.5.tgz", + "integrity": "sha512-gn8kHOrku8D4NGHMK1Y7NA7INQTRdVOntt1OCYypZPRt6skGbddska44K8iocdpxHTMMNui5oH4elPH4QOLrFQ==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.52.5.tgz", + "integrity": "sha512-hXGLYpdhiNElzN770+H2nlx+jRog8TyynpTVzdlc6bndktjKWyZyiCsuDAlpd+j+W+WNqfcyAWz9HxxIGfZm1Q==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.52.5.tgz", + "integrity": "sha512-arCGIcuNKjBoKAXD+y7XomR9gY6Mw7HnFBv5Rw7wQRvwYLR7gBAgV7Mb2QTyjXfTveBNFAtPt46/36vV9STLNg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.52.5.tgz", + "integrity": "sha512-QoFqB6+/9Rly/RiPjaomPLmR/13cgkIGfA40LHly9zcH1S0bN2HVFYk3a1eAyHQyjs3ZJYlXvIGtcCs5tko9Cw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.52.5.tgz", + "integrity": "sha512-w0cDWVR6MlTstla1cIfOGyl8+qb93FlAVutcor14Gf5Md5ap5ySfQ7R9S/NjNaMLSFdUnKGEasmVnu3lCMqB7w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.52.5.tgz", + "integrity": "sha512-Aufdpzp7DpOTULJCuvzqcItSGDH73pF3ko/f+ckJhxQyHtp67rHw3HMNxoIdDMUITJESNE6a8uh4Lo4SLouOUg==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.52.5.tgz", + "integrity": "sha512-UGBUGPFp1vkj6p8wCRraqNhqwX/4kNQPS57BCFc8wYh0g94iVIW33wJtQAx3G7vrjjNtRaxiMUylM0ktp/TRSQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.52.5.tgz", + "integrity": "sha512-TAcgQh2sSkykPRWLrdyy2AiceMckNf5loITqXxFI5VuQjS5tSuw3WlwdN8qv8vzjLAUTvYaH/mVjSFpbkFbpTg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@svgr/babel-plugin-add-jsx-attribute": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-add-jsx-attribute/-/babel-plugin-add-jsx-attribute-8.0.0.tgz", + "integrity": "sha512-b9MIk7yhdS1pMCZM8VeNfUlSKVRhsHZNMl5O9SfaX0l0t5wjdgu4IDzGB8bpnGBBOjGST3rRFVsaaEtI4W6f7g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/babel-plugin-remove-jsx-attribute": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-remove-jsx-attribute/-/babel-plugin-remove-jsx-attribute-8.0.0.tgz", + "integrity": "sha512-BcCkm/STipKvbCl6b7QFrMh/vx00vIP63k2eM66MfHJzPr6O2U0jYEViXkHJWqXqQYjdeA9cuCl5KWmlwjDvbA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/babel-plugin-remove-jsx-empty-expression": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-remove-jsx-empty-expression/-/babel-plugin-remove-jsx-empty-expression-8.0.0.tgz", + "integrity": "sha512-5BcGCBfBxB5+XSDSWnhTThfI9jcO5f0Ai2V24gZpG+wXF14BzwxxdDb4g6trdOux0rhibGs385BeFMSmxtS3uA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/babel-plugin-replace-jsx-attribute-value": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-replace-jsx-attribute-value/-/babel-plugin-replace-jsx-attribute-value-8.0.0.tgz", + "integrity": "sha512-KVQ+PtIjb1BuYT3ht8M5KbzWBhdAjjUPdlMtpuw/VjT8coTrItWX6Qafl9+ji831JaJcu6PJNKCV0bp01lBNzQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/babel-plugin-svg-dynamic-title": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-svg-dynamic-title/-/babel-plugin-svg-dynamic-title-8.0.0.tgz", + "integrity": "sha512-omNiKqwjNmOQJ2v6ge4SErBbkooV2aAWwaPFs2vUY7p7GhVkzRkJ00kILXQvRhA6miHnNpXv7MRnnSjdRjK8og==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/babel-plugin-svg-em-dimensions": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-svg-em-dimensions/-/babel-plugin-svg-em-dimensions-8.0.0.tgz", + "integrity": "sha512-mURHYnu6Iw3UBTbhGwE/vsngtCIbHE43xCRK7kCw4t01xyGqb2Pd+WXekRRoFOBIY29ZoOhUCTEweDMdrjfi9g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/babel-plugin-transform-react-native-svg": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-transform-react-native-svg/-/babel-plugin-transform-react-native-svg-8.1.0.tgz", + "integrity": "sha512-Tx8T58CHo+7nwJ+EhUwx3LfdNSG9R2OKfaIXXs5soiy5HtgoAEkDay9LIimLOcG8dJQH1wPZp/cnAv6S9CrR1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/babel-plugin-transform-svg-component": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-plugin-transform-svg-component/-/babel-plugin-transform-svg-component-8.0.0.tgz", + "integrity": "sha512-DFx8xa3cZXTdb/k3kfPeaixecQLgKh5NVBMwD0AQxOzcZawK4oo1Jh9LbrcACUivsCA7TLG8eeWgrDXjTMhRmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/babel-preset": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/@svgr/babel-preset/-/babel-preset-8.1.0.tgz", + "integrity": "sha512-7EYDbHE7MxHpv4sxvnVPngw5fuR6pw79SkcrILHJ/iMpuKySNCl5W1qcwPEpU+LgyRXOaAFgH0KhwD18wwg6ug==", + "dev": true, "license": "MIT", "dependencies": { - "@ampproject/remapping": "^2.3.0", - "enhanced-resolve": "^5.18.1", - "jiti": "^2.4.2", - "lightningcss": "1.30.1", - "magic-string": "^0.30.17", + "@svgr/babel-plugin-add-jsx-attribute": "8.0.0", + "@svgr/babel-plugin-remove-jsx-attribute": "8.0.0", + "@svgr/babel-plugin-remove-jsx-empty-expression": "8.0.0", + "@svgr/babel-plugin-replace-jsx-attribute-value": "8.0.0", + "@svgr/babel-plugin-svg-dynamic-title": "8.0.0", + "@svgr/babel-plugin-svg-em-dimensions": "8.0.0", + "@svgr/babel-plugin-transform-react-native-svg": "8.1.0", + "@svgr/babel-plugin-transform-svg-component": "8.0.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@svgr/core": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/@svgr/core/-/core-8.1.0.tgz", + "integrity": "sha512-8QqtOQT5ACVlmsvKOJNEaWmRPmcojMOzCz4Hs2BGG/toAp/K38LcsMRyLp349glq5AzJbCEeimEoxaX6v/fLrA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.21.3", + "@svgr/babel-preset": "8.1.0", + "camelcase": "^6.2.0", + "cosmiconfig": "^8.1.3", + "snake-case": "^3.0.4" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + } + }, + "node_modules/@svgr/core/node_modules/cosmiconfig": { + "version": "8.3.6", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-8.3.6.tgz", + "integrity": "sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "import-fresh": "^3.3.0", + "js-yaml": "^4.1.0", + "parse-json": "^5.2.0", + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/d-fischer" + }, + "peerDependencies": { + "typescript": ">=4.9.5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/@svgr/hast-util-to-babel-ast": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@svgr/hast-util-to-babel-ast/-/hast-util-to-babel-ast-8.0.0.tgz", + "integrity": "sha512-EbDKwO9GpfWP4jN9sGdYwPBU0kdomaPIL2Eu4YwmgP+sJeXT+L7bMwJUBnhzfH8Q2qMBqZ4fJwpCyYsAN3mt2Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.21.3", + "entities": "^4.4.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + } + }, + "node_modules/@svgr/hast-util-to-babel-ast/node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/@svgr/plugin-jsx": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/@svgr/plugin-jsx/-/plugin-jsx-8.1.0.tgz", + "integrity": "sha512-0xiIyBsLlr8quN+WyuxooNW9RJ0Dpr8uOnH/xrCVO8GLUcwHISwj1AG0k+LFzteTkAA0GbX0kj9q6Dk70PTiPA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.21.3", + "@svgr/babel-preset": "8.1.0", + "@svgr/hast-util-to-babel-ast": "8.0.0", + "svg-parser": "^2.0.4" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/gregberge" + }, + "peerDependencies": { + "@svgr/core": "*" + } + }, + "node_modules/@tailwindcss/node": { + "version": "4.1.16", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.16.tgz", + "integrity": "sha512-BX5iaSsloNuvKNHRN3k2RcCuTEgASTo77mofW0vmeHkfrDWaoFAFvNHpEgtu0eqyypcyiBkDWzSMxJhp3AUVcw==", + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.4", + "enhanced-resolve": "^5.18.3", + "jiti": "^2.6.1", + "lightningcss": "1.30.2", + "magic-string": "^0.30.19", "source-map-js": "^1.2.1", - "tailwindcss": "4.1.8" + "tailwindcss": "4.1.16" } }, "node_modules/@tailwindcss/oxide": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.8.tgz", - "integrity": "sha512-d7qvv9PsM5N3VNKhwVUhpK6r4h9wtLkJ6lz9ZY9aeZgrUWk1Z8VPyqyDT9MZlem7GTGseRQHkeB1j3tC7W1P+A==", - "hasInstallScript": true, + "version": "4.1.16", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.16.tgz", + "integrity": "sha512-2OSv52FRuhdlgyOQqgtQHuCgXnS8nFSYRp2tJ+4WZXKgTxqPy7SMSls8c3mPT5pkZ17SBToGM5LHEJBO7miEdg==", "license": "MIT", - "dependencies": { - "detect-libc": "^2.0.4", - "tar": "^7.4.3" - }, "engines": { "node": ">= 10" }, "optionalDependencies": { - "@tailwindcss/oxide-android-arm64": "4.1.8", - "@tailwindcss/oxide-darwin-arm64": "4.1.8", - "@tailwindcss/oxide-darwin-x64": "4.1.8", - "@tailwindcss/oxide-freebsd-x64": "4.1.8", - "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.8", - "@tailwindcss/oxide-linux-arm64-gnu": "4.1.8", - "@tailwindcss/oxide-linux-arm64-musl": "4.1.8", - "@tailwindcss/oxide-linux-x64-gnu": "4.1.8", - "@tailwindcss/oxide-linux-x64-musl": "4.1.8", - "@tailwindcss/oxide-wasm32-wasi": "4.1.8", - "@tailwindcss/oxide-win32-arm64-msvc": "4.1.8", - "@tailwindcss/oxide-win32-x64-msvc": "4.1.8" + "@tailwindcss/oxide-android-arm64": "4.1.16", + "@tailwindcss/oxide-darwin-arm64": "4.1.16", + "@tailwindcss/oxide-darwin-x64": "4.1.16", + "@tailwindcss/oxide-freebsd-x64": "4.1.16", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.1.16", + "@tailwindcss/oxide-linux-arm64-gnu": "4.1.16", + "@tailwindcss/oxide-linux-arm64-musl": "4.1.16", + "@tailwindcss/oxide-linux-x64-gnu": "4.1.16", + "@tailwindcss/oxide-linux-x64-musl": "4.1.16", + "@tailwindcss/oxide-wasm32-wasi": "4.1.16", + "@tailwindcss/oxide-win32-arm64-msvc": "4.1.16", + "@tailwindcss/oxide-win32-x64-msvc": "4.1.16" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.1.16", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.16.tgz", + "integrity": "sha512-8+ctzkjHgwDJ5caq9IqRSgsP70xhdhJvm+oueS/yhD5ixLhqTw9fSL1OurzMUhBwE5zK26FXLCz2f/RtkISqHA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" } }, "node_modules/@tailwindcss/oxide-darwin-arm64": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.8.tgz", - "integrity": "sha512-RdRvedGsT0vwVVDztvyXhKpsU2ark/BjgG0huo4+2BluxdXo8NDgzl77qh0T1nUxmM11eXwR8jA39ibvSTbi7A==", + "version": "4.1.16", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.16.tgz", + "integrity": "sha512-C3oZy5042v2FOALBZtY0JTDnGNdS6w7DxL/odvSny17ORUnaRKhyTse8xYi3yKGyfnTUOdavRCdmc8QqJYwFKA==", "cpu": [ "arm64" ], @@ -1411,16 +2371,186 @@ "node": ">= 10" } }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.1.16", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.16.tgz", + "integrity": "sha512-vjrl/1Ub9+JwU6BP0emgipGjowzYZMjbWCDqwA2Z4vCa+HBSpP4v6U2ddejcHsolsYxwL5r4bPNoamlV0xDdLg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.1.16", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.16.tgz", + "integrity": "sha512-TSMpPYpQLm+aR1wW5rKuUuEruc/oOX3C7H0BTnPDn7W/eMw8W+MRMpiypKMkXZfwH8wqPIRKppuZoedTtNj2tg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.1.16", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.16.tgz", + "integrity": "sha512-p0GGfRg/w0sdsFKBjMYvvKIiKy/LNWLWgV/plR4lUgrsxFAoQBFrXkZ4C0w8IOXfslB9vHK/JGASWD2IefIpvw==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.1.16", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.16.tgz", + "integrity": "sha512-DoixyMmTNO19rwRPdqviTrG1rYzpxgyYJl8RgQvdAQUzxC1ToLRqtNJpU/ATURSKgIg6uerPw2feW0aS8SNr/w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.1.16", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.16.tgz", + "integrity": "sha512-H81UXMa9hJhWhaAUca6bU2wm5RRFpuHImrwXBUvPbYb+3jo32I9VIwpOX6hms0fPmA6f2pGVlybO6qU8pF4fzQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.1.16", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.16.tgz", + "integrity": "sha512-ZGHQxDtFC2/ruo7t99Qo2TTIvOERULPl5l0K1g0oK6b5PGqjYMga+FcY1wIUnrUxY56h28FxybtDEla+ICOyew==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.1.16", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.16.tgz", + "integrity": "sha512-Oi1tAaa0rcKf1Og9MzKeINZzMLPbhxvm7rno5/zuP1WYmpiG0bEHq4AcRUiG2165/WUzvxkW4XDYCscZWbTLZw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.1.16", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.16.tgz", + "integrity": "sha512-B01u/b8LteGRwucIBmCQ07FVXLzImWESAIMcUU6nvFt/tYsQ6IHz8DmZ5KtvmwxD+iTYBtM1xwoGXswnlu9v0Q==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.5.0", + "@emnapi/runtime": "^1.5.0", + "@emnapi/wasi-threads": "^1.1.0", + "@napi-rs/wasm-runtime": "^1.0.7", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.4.0" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.1.16", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.16.tgz", + "integrity": "sha512-zX+Q8sSkGj6HKRTMJXuPvOcP8XfYON24zJBRPlszcH1Np7xuHXhWn8qfFjIujVzvH3BHU+16jBXwgpl20i+v9A==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.1.16", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.16.tgz", + "integrity": "sha512-m5dDFJUEejbFqP+UXVstd4W/wnxA4F61q8SoL+mqTypId2T2ZpuxosNSgowiCnLp2+Z+rivdU0AqpfgiD7yCBg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, "node_modules/@tailwindcss/typography": { - "version": "0.5.16", - "resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.16.tgz", - "integrity": "sha512-0wDLwCVF5V3x3b1SGXPCDcdsbDHMBe+lkFzBRaHeLvNi+nrrnZ1lA18u+OTWO8iSWU2GxUOCvlXtDuqftc1oiA==", + "version": "0.5.19", + "resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.19.tgz", + "integrity": "sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg==", "dev": true, "license": "MIT", "dependencies": { - "lodash.castarray": "^4.4.0", - "lodash.isplainobject": "^4.0.6", - "lodash.merge": "^4.6.2", "postcss-selector-parser": "6.0.10" }, "peerDependencies": { @@ -1428,17 +2558,17 @@ } }, "node_modules/@tailwindcss/vite": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.1.8.tgz", - "integrity": "sha512-CQ+I8yxNV5/6uGaJjiuymgw0kEQiNKRinYbZXPdx1fk5WgiyReG0VaUx/Xq6aVNSUNJFzxm6o8FNKS5aMaim5A==", + "version": "4.1.16", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.1.16.tgz", + "integrity": "sha512-bbguNBcDxsRmi9nnlWJxhfDWamY3lmcyACHcdO1crxfzuLpOhHLLtEIN/nCbbAtj5rchUgQD17QVAKi1f7IsKg==", "license": "MIT", "dependencies": { - "@tailwindcss/node": "4.1.8", - "@tailwindcss/oxide": "4.1.8", - "tailwindcss": "4.1.8" + "@tailwindcss/node": "4.1.16", + "@tailwindcss/oxide": "4.1.16", + "tailwindcss": "4.1.16" }, "peerDependencies": { - "vite": "^5.2.0 || ^6" + "vite": "^5.2.0 || ^6 || ^7" } }, "node_modules/@tweenjs/tween.js": { @@ -1483,19 +2613,19 @@ } }, "node_modules/@types/babel__traverse": { - "version": "7.20.7", - "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.20.7.tgz", - "integrity": "sha512-dkO5fhS7+/oos4ciWxyEyjWe48zmG6wbCheo/G2ZnHx4fs3EU6YC6UM8rk56gAjNJ9P3MTH2jo5jb92/K6wbng==", + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.20.7" + "@babel/types": "^7.28.2" } }, "node_modules/@types/codemirror": { - "version": "5.60.15", - "resolved": "https://registry.npmjs.org/@types/codemirror/-/codemirror-5.60.15.tgz", - "integrity": "sha512-dTOvwEQ+ouKJ/rE9LT1Ue2hmP6H1mZv5+CCnNWu2qtiOe2LQa9lCprEY20HxiDmV/Bxh+dXjywmy5aKvoGjULA==", + "version": "5.60.17", + "resolved": "https://registry.npmjs.org/@types/codemirror/-/codemirror-5.60.17.tgz", + "integrity": "sha512-AZq2FIsUHVMlp7VSe2hTfl5w4pcUkoFkM3zVsRKsn1ca8CXRDYvnin04+HP2REkwsxemuHqvDofdlhUWNpbwfw==", "license": "MIT", "dependencies": { "@types/tern": "*" @@ -1529,9 +2659,9 @@ "license": "MIT" }, "node_modules/@types/estree": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz", - "integrity": "sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==", + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", "license": "MIT" }, "node_modules/@types/estree-jsx": { @@ -1559,6 +2689,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/lodash": { + "version": "4.17.24", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.24.tgz", + "integrity": "sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/marked": { "version": "4.3.2", "resolved": "https://registry.npmjs.org/@types/marked/-/marked-4.3.2.tgz", @@ -1581,9 +2718,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "22.15.24", - "resolved": "https://registry.npmjs.org/@types/node/-/node-22.15.24.tgz", - "integrity": "sha512-w9CZGm9RDjzTh/D+hFwlBJ3ziUaVw7oufKA3vOFSOZlzmW9AkZnfjPb+DLnrV6qtgL/LNmP0/2zBNCFHL3F0ng==", + "version": "22.18.13", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.18.13.tgz", + "integrity": "sha512-Bo45YKIjnmFtv6I1TuC8AaHBbqXtIo+Om5fE4QiU1Tj8QR/qt+8O3BAtOimG5IFmwaWiPmB3Mv3jtYzBA4Us2A==", "devOptional": true, "license": "MIT", "dependencies": { @@ -1603,9 +2740,9 @@ "license": "MIT" }, "node_modules/@types/prop-types": { - "version": "15.7.14", - "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.14.tgz", - "integrity": "sha512-gNMvNH49DJ7OJYv+KAKn0Xp45p8PLl6zo2YnvDIbTd4J6MER2BmWN49TG7n9LvkyihINxeKW8+3bfS2yDC9dzQ==", + "version": "15.7.15", + "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz", + "integrity": "sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==", "license": "MIT" }, "node_modules/@types/rbush": { @@ -1615,28 +2752,28 @@ "license": "MIT" }, "node_modules/@types/react": { - "version": "19.1.6", - "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.6.tgz", - "integrity": "sha512-JeG0rEWak0N6Itr6QUx+X60uQmN+5t3j9r/OVDtWzFXKaj6kD1BwJzOksD0FF6iWxZlbE1kB0q9vtnU2ekqa1Q==", + "version": "19.2.2", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz", + "integrity": "sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA==", "license": "MIT", "dependencies": { "csstype": "^3.0.2" } }, "node_modules/@types/react-dom": { - "version": "19.1.5", - "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.1.5.tgz", - "integrity": "sha512-CMCjrWucUBZvohgZxkjd6S9h0nZxXjzus6yDfUb+xLxYM7VvjKNH1tQrE9GWLql1XoOP4/Ds3bwFqShHUYraGg==", + "version": "19.2.2", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.2.tgz", + "integrity": "sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw==", "dev": true, "license": "MIT", "peerDependencies": { - "@types/react": "^19.0.0" + "@types/react": "^19.2.0" } }, "node_modules/@types/react-reconciler": { - "version": "0.28.9", - "resolved": "https://registry.npmjs.org/@types/react-reconciler/-/react-reconciler-0.28.9.tgz", - "integrity": "sha512-HHM3nxyUZ3zAylX8ZEyrDNd2XZOnQ0D5XfunJF5FLQnZbHHYq4UWvW1QfelQNXv1ICNkwYhfxjwfnqivYB6bFg==", + "version": "0.32.2", + "resolved": "https://registry.npmjs.org/@types/react-reconciler/-/react-reconciler-0.32.2.tgz", + "integrity": "sha512-gjcm6O0aUknhYaogEl8t5pecPfiOTD8VQkbjOhgbZas/E6qGY+veW9iuJU/7p4Y1E0EuQ0mArga7VEOUWSlVRA==", "license": "MIT", "peerDependencies": { "@types/react": "*" @@ -1667,9 +2804,9 @@ } }, "node_modules/@types/three": { - "version": "0.177.0", - "resolved": "https://registry.npmjs.org/@types/three/-/three-0.177.0.tgz", - "integrity": "sha512-/ZAkn4OLUijKQySNci47lFO+4JLE1TihEjsGWPUT+4jWqxtwOPPEwJV1C3k5MEx0mcBPCdkFjzRzDOnHEI1R+A==", + "version": "0.180.0", + "resolved": "https://registry.npmjs.org/@types/three/-/three-0.180.0.tgz", + "integrity": "sha512-ykFtgCqNnY0IPvDro7h+9ZeLY+qjgUWv+qEvUt84grhenO60Hqd4hScHE7VTB9nOQ/3QM8lkbNE+4vKjEpUxKg==", "license": "MIT", "dependencies": { "@dimforge/rapier3d-compat": "~0.12.0", @@ -1678,7 +2815,7 @@ "@types/webxr": "*", "@webgpu/types": "*", "fflate": "~0.8.2", - "meshoptimizer": "~0.18.1" + "meshoptimizer": "~0.22.0" } }, "node_modules/@types/unist": { @@ -1694,23 +2831,23 @@ "license": "MIT" }, "node_modules/@types/webxr": { - "version": "0.5.22", - "resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.22.tgz", - "integrity": "sha512-Vr6Stjv5jPRqH690f5I5GLjVk8GSsoQSYJ2FVd/3jJF7KaqfwPi3ehfBS96mlQ2kPCwZaX6U0rG2+NGHBKkA/A==", + "version": "0.5.24", + "resolved": "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz", + "integrity": "sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg==", "license": "MIT" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.33.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.33.0.tgz", - "integrity": "sha512-CACyQuqSHt7ma3Ns601xykeBK/rDeZa3w6IS6UtMQbixO5DWy+8TilKkviGDH6jtWCo8FGRKEK5cLLkPvEammQ==", + "version": "8.46.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.46.2.tgz", + "integrity": "sha512-ZGBMToy857/NIPaaCucIUQgqueOiq7HeAKkhlvqVV4lm089zUFW6ikRySx2v+cAhKeUCPuWVHeimyk6Dw1iY3w==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.33.0", - "@typescript-eslint/type-utils": "8.33.0", - "@typescript-eslint/utils": "8.33.0", - "@typescript-eslint/visitor-keys": "8.33.0", + "@typescript-eslint/scope-manager": "8.46.2", + "@typescript-eslint/type-utils": "8.46.2", + "@typescript-eslint/utils": "8.46.2", + "@typescript-eslint/visitor-keys": "8.46.2", "graphemer": "^1.4.0", "ignore": "^7.0.0", "natural-compare": "^1.4.0", @@ -1724,15 +2861,15 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.33.0", + "@typescript-eslint/parser": "^8.46.2", "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <5.9.0" + "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.4.tgz", - "integrity": "sha512-gJzzk+PQNznz8ysRrC0aOkBNVRBDtE1n53IqyqEf3PXrYwomFs5q4pGMizBMJF+ykh03insJ27hB8gSrD2Hn8A==", + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", "dev": true, "license": "MIT", "engines": { @@ -1740,16 +2877,16 @@ } }, "node_modules/@typescript-eslint/parser": { - "version": "8.33.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.33.0.tgz", - "integrity": "sha512-JaehZvf6m0yqYp34+RVnihBAChkqeH+tqqhS0GuX1qgPpwLvmTPheKEs6OeCK6hVJgXZHJ2vbjnC9j119auStQ==", + "version": "8.46.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.46.2.tgz", + "integrity": "sha512-BnOroVl1SgrPLywqxyqdJ4l3S2MsKVLDVxZvjI1Eoe8ev2r3kGDo+PcMihNmDE+6/KjkTubSJnmqGZZjQSBq/g==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.33.0", - "@typescript-eslint/types": "8.33.0", - "@typescript-eslint/typescript-estree": "8.33.0", - "@typescript-eslint/visitor-keys": "8.33.0", + "@typescript-eslint/scope-manager": "8.46.2", + "@typescript-eslint/types": "8.46.2", + "@typescript-eslint/typescript-estree": "8.46.2", + "@typescript-eslint/visitor-keys": "8.46.2", "debug": "^4.3.4" }, "engines": { @@ -1761,18 +2898,18 @@ }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <5.9.0" + "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/@typescript-eslint/project-service": { - "version": "8.33.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.33.0.tgz", - "integrity": "sha512-d1hz0u9l6N+u/gcrk6s6gYdl7/+pp8yHheRTqP6X5hVDKALEaTn8WfGiit7G511yueBEL3OpOEpD+3/MBdoN+A==", + "version": "8.46.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.46.2.tgz", + "integrity": "sha512-PULOLZ9iqwI7hXcmL4fVfIsBi6AN9YxRc0frbvmg8f+4hQAjQ5GYNKK0DIArNo+rOKmR/iBYwkpBmnIwin4wBg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/tsconfig-utils": "^8.33.0", - "@typescript-eslint/types": "^8.33.0", + "@typescript-eslint/tsconfig-utils": "^8.46.2", + "@typescript-eslint/types": "^8.46.2", "debug": "^4.3.4" }, "engines": { @@ -1781,17 +2918,20 @@ "funding": { "type": "opencollective", "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/@typescript-eslint/scope-manager": { - "version": "8.33.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.33.0.tgz", - "integrity": "sha512-LMi/oqrzpqxyO72ltP+dBSP6V0xiUb4saY7WLtxSfiNEBI8m321LLVFU9/QDJxjDQG9/tjSqKz/E3380TEqSTw==", + "version": "8.46.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.46.2.tgz", + "integrity": "sha512-LF4b/NmGvdWEHD2H4MsHD8ny6JpiVNDzrSZr3CsckEgCbAGZbYM4Cqxvi9L+WqDMT+51Ozy7lt2M+d0JLEuBqA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.33.0", - "@typescript-eslint/visitor-keys": "8.33.0" + "@typescript-eslint/types": "8.46.2", + "@typescript-eslint/visitor-keys": "8.46.2" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1802,9 +2942,9 @@ } }, "node_modules/@typescript-eslint/tsconfig-utils": { - "version": "8.33.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.33.0.tgz", - "integrity": "sha512-sTkETlbqhEoiFmGr1gsdq5HyVbSOF0145SYDJ/EQmXHtKViCaGvnyLqWFFHtEXoS0J1yU8Wyou2UGmgW88fEug==", + "version": "8.46.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.46.2.tgz", + "integrity": "sha512-a7QH6fw4S57+F5y2FIxxSDyi5M4UfGF+Jl1bCGd7+L4KsaUY80GsiF/t0UoRFDHAguKlBaACWJRmdrc6Xfkkag==", "dev": true, "license": "MIT", "engines": { @@ -1815,18 +2955,19 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "typescript": ">=4.8.4 <5.9.0" + "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.33.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.33.0.tgz", - "integrity": "sha512-lScnHNCBqL1QayuSrWeqAL5GmqNdVUQAAMTaCwdYEdWfIrSrOGzyLGRCHXcCixa5NK6i5l0AfSO2oBSjCjf4XQ==", + "version": "8.46.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.46.2.tgz", + "integrity": "sha512-HbPM4LbaAAt/DjxXaG9yiS9brOOz6fabal4uvUmaUYe6l3K1phQDMQKBRUrr06BQkxkvIZVVHttqiybM9nJsLA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/typescript-estree": "8.33.0", - "@typescript-eslint/utils": "8.33.0", + "@typescript-eslint/types": "8.46.2", + "@typescript-eslint/typescript-estree": "8.46.2", + "@typescript-eslint/utils": "8.46.2", "debug": "^4.3.4", "ts-api-utils": "^2.1.0" }, @@ -1839,13 +2980,13 @@ }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <5.9.0" + "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/@typescript-eslint/types": { - "version": "8.33.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.33.0.tgz", - "integrity": "sha512-DKuXOKpM5IDT1FA2g9x9x1Ug81YuKrzf4mYX8FAVSNu5Wo/LELHWQyM1pQaDkI42bX15PWl0vNPt1uGiIFUOpg==", + "version": "8.46.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.46.2.tgz", + "integrity": "sha512-lNCWCbq7rpg7qDsQrd3D6NyWYu+gkTENkG5IKYhUIcxSb59SQC/hEQ+MrG4sTgBVghTonNWq42bA/d4yYumldQ==", "dev": true, "license": "MIT", "engines": { @@ -1857,16 +2998,16 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.33.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.33.0.tgz", - "integrity": "sha512-vegY4FQoB6jL97Tu/lWRsAiUUp8qJTqzAmENH2k59SJhw0Th1oszb9Idq/FyyONLuNqT1OADJPXfyUNOR8SzAQ==", + "version": "8.46.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.46.2.tgz", + "integrity": "sha512-f7rW7LJ2b7Uh2EiQ+7sza6RDZnajbNbemn54Ob6fRwQbgcIn+GWfyuHDHRYgRoZu1P4AayVScrRW+YfbTvPQoQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/project-service": "8.33.0", - "@typescript-eslint/tsconfig-utils": "8.33.0", - "@typescript-eslint/types": "8.33.0", - "@typescript-eslint/visitor-keys": "8.33.0", + "@typescript-eslint/project-service": "8.46.2", + "@typescript-eslint/tsconfig-utils": "8.46.2", + "@typescript-eslint/types": "8.46.2", + "@typescript-eslint/visitor-keys": "8.46.2", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", @@ -1882,13 +3023,13 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "typescript": ">=4.8.4 <5.9.0" + "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", "dev": true, "license": "MIT", "dependencies": { @@ -1912,9 +3053,9 @@ } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { - "version": "7.7.2", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz", - "integrity": "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA==", + "version": "7.7.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", + "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==", "dev": true, "license": "ISC", "bin": { @@ -1925,16 +3066,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.33.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.33.0.tgz", - "integrity": "sha512-lPFuQaLA9aSNa7D5u2EpRiqdAUhzShwGg/nhpBlc4GR6kcTABttCuyjFs8BcEZ8VWrjCBof/bePhP3Q3fS+Yrw==", + "version": "8.46.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.46.2.tgz", + "integrity": "sha512-sExxzucx0Tud5tE0XqR0lT0psBQvEpnpiul9XbGUB1QwpWJJAps1O/Z7hJxLGiZLBKMCutjTzDgmd1muEhBnVg==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/eslint-utils": "^4.7.0", - "@typescript-eslint/scope-manager": "8.33.0", - "@typescript-eslint/types": "8.33.0", - "@typescript-eslint/typescript-estree": "8.33.0" + "@typescript-eslint/scope-manager": "8.46.2", + "@typescript-eslint/types": "8.46.2", + "@typescript-eslint/typescript-estree": "8.46.2" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1945,18 +3086,18 @@ }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <5.9.0" + "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.33.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.33.0.tgz", - "integrity": "sha512-7RW7CMYoskiz5OOGAWjJFxgb7c5UNjTG292gYhWeOAcFmYCtVCSqjqSBj5zMhxbXo2JOW95YYrUWJfU0zrpaGQ==", + "version": "8.46.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.46.2.tgz", + "integrity": "sha512-tUFMXI4gxzzMXt4xpGJEsBsTox0XbNQ1y94EwlD/CuZwFcQP79xfQqMhau9HsRc/J0cAPA/HZt1dZPtGn9V/7w==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.33.0", - "eslint-visitor-keys": "^4.2.0" + "@typescript-eslint/types": "8.46.2", + "eslint-visitor-keys": "^4.2.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -1991,16 +3132,16 @@ } }, "node_modules/@vitejs/plugin-react": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.5.0.tgz", - "integrity": "sha512-JuLWaEqypaJmOJPLWwO335Ig6jSgC1FTONCWAxnqcQthLTK/Yc9aH6hr9z/87xciejbQcnP3GnA1FWUSWeXaeg==", + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/core": "^7.26.10", - "@babel/plugin-transform-react-jsx-self": "^7.25.9", - "@babel/plugin-transform-react-jsx-source": "^7.25.9", - "@rolldown/pluginutils": "1.0.0-beta.9", + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, @@ -2008,28 +3149,28 @@ "node": "^14.18.0 || >=16.0.0" }, "peerDependencies": { - "vite": "^4.2.0 || ^5.0.0 || ^6.0.0" + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "node_modules/@webgpu/types": { - "version": "0.1.61", - "resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.61.tgz", - "integrity": "sha512-w2HbBvH+qO19SB5pJOJFKs533CdZqxl3fcGonqL321VHkW7W/iBo6H8bjDy6pr/+pbMwIu5dnuaAxH7NxBqUrQ==", + "version": "0.1.66", + "resolved": "https://registry.npmjs.org/@webgpu/types/-/types-0.1.66.tgz", + "integrity": "sha512-YA2hLrwLpDsRueNDXIMqN9NTzD6bCDkuXbOSe0heS+f8YE8usA6Gbv1prj81pzVHrbaAma7zObnIC+I6/sXJgA==", "license": "BSD-3-Clause" }, "node_modules/@xmldom/xmldom": { - "version": "0.8.10", - "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.10.tgz", - "integrity": "sha512-2WALfTl4xo2SkGCYRt6rDTFfk9R1czmBvUQy12gK2KuRKIpWEhcbbzy8EZXtz/jkRqHX8bFEc6FC1HjX4TUWYw==", + "version": "0.8.11", + "resolved": "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.11.tgz", + "integrity": "sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw==", "license": "MIT", "engines": { "node": ">=10.0.0" } }, "node_modules/acorn": { - "version": "8.14.1", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz", - "integrity": "sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg==", + "version": "8.15.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", + "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", "bin": { @@ -2066,6 +3207,16 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "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==", + "dev": true, + "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", @@ -2095,23 +3246,14 @@ "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", "license": "MIT" }, - "node_modules/attr-accept": { - "version": "2.2.5", - "resolved": "https://registry.npmjs.org/attr-accept/-/attr-accept-2.2.5.tgz", - "integrity": "sha512-0bDNnY/u6pPwHDMoF0FieU354oBi0a8rD9FcsLwzcGWbc8KS8KPIi7y+s13OlVY+gMWc/9xEMUgNE6Qm8ZllYQ==", - "license": "MIT", - "engines": { - "node": ">=4" - } - }, "node_modules/axios": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.9.0.tgz", - "integrity": "sha512-re4CqKTJaURpzbLHtIi6XpDv20/CnpXOtjRY5/CU32L8gU8ek9UIivcfvSWvmKEngmVbrUtPpdDwWDWL7DNHvg==", + "version": "1.13.1", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.13.1.tgz", + "integrity": "sha512-hU4EGxxt+j7TQijx1oYdAjw4xuIp1wRQSsbMFwSthCWeBQur1eF+qJ5iQ5sN3Tw8YRzQNKb8jszgBdMDVqwJcw==", "license": "MIT", "dependencies": { "follow-redirects": "^1.15.6", - "form-data": "^4.0.0", + "form-data": "^4.0.4", "proxy-from-env": "^1.1.0" } }, @@ -2167,6 +3309,16 @@ ], "license": "MIT" }, + "node_modules/baseline-browser-mapping": { + "version": "2.8.20", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.20.tgz", + "integrity": "sha512-JMWsdF+O8Orq3EMukbUN1QfbLK9mX2CkUmQBcW2T0s8OmdAUL5LLM/6wFwSrqXzlXB13yhyK9gTKS1rIizOduQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.js" + } + }, "node_modules/bidi-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", @@ -2177,9 +3329,9 @@ } }, "node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "dev": true, "license": "MIT", "dependencies": { @@ -2201,9 +3353,9 @@ } }, "node_modules/browserslist": { - "version": "4.24.5", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.5.tgz", - "integrity": "sha512-FDToo4Wo82hIdgc1CQ+NQD0hEhmpPjrZ3hiUgwgOG6IuTdlpr8jdjyG24P6cNP1yJpTLzS5OcGgSw0xmDU1/Tw==", + "version": "4.27.0", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.27.0.tgz", + "integrity": "sha512-AXVQwdhot1eqLihwasPElhX2tAZiBjWdJ9i/Zcj2S6QYIjkx62OKSfnobkriB81C3l4w0rVy3Nt4jaTBltYEpw==", "dev": true, "funding": [ { @@ -2221,10 +3373,11 @@ ], "license": "MIT", "dependencies": { - "caniuse-lite": "^1.0.30001716", - "electron-to-chromium": "^1.5.149", - "node-releases": "^2.0.19", - "update-browserslist-db": "^1.1.3" + "baseline-browser-mapping": "^2.8.19", + "caniuse-lite": "^1.0.30001751", + "electron-to-chromium": "^1.5.238", + "node-releases": "^2.0.26", + "update-browserslist-db": "^1.1.4" }, "bin": { "browserslist": "cli.js" @@ -2279,19 +3432,36 @@ "node": ">=6" } }, - "node_modules/camera-controls": { - "version": "2.10.1", - "resolved": "https://registry.npmjs.org/camera-controls/-/camera-controls-2.10.1.tgz", - "integrity": "sha512-KnaKdcvkBJ1Irbrzl8XD6WtZltkRjp869Jx8c0ujs9K+9WD+1D7ryBsCiVqJYUqt6i/HR5FxT7RLASieUD+Q5w==", + "node_modules/camelcase": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz", + "integrity": "sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==", + "dev": true, "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/camera-controls": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/camera-controls/-/camera-controls-3.1.1.tgz", + "integrity": "sha512-zC3DcoQPJ0CbTZ8WHthzi8nMvVF71cppOTBcH4cMLreMkU3y3fzBPViGvz1BefWPo9+kv9BP41tvIsabsXTz+Q==", + "license": "MIT", + "engines": { + "node": ">=24.4.0", + "npm": ">=11.4.2" + }, "peerDependencies": { "three": ">=0.126.1" } }, "node_modules/caniuse-lite": { - "version": "1.0.30001718", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001718.tgz", - "integrity": "sha512-AflseV1ahcSunK53NfEs9gFWgOEmzr0f+kaMFA4xiLZlr9Hzt7HxcSpIFcnNCUkz6R6dWKa54rUz3HUmI3nVcw==", + "version": "1.0.30001751", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001751.tgz", + "integrity": "sha512-A0QJhug0Ly64Ii3eIqHu5X51ebln3k4yTUkY1j8drqpWHVreg/VLijN48cZ1bYPiqOQuqpkIKnzr/Ul8V+p6Cw==", "dev": true, "funding": [ { @@ -2376,13 +3546,19 @@ "url": "https://github.com/sponsors/wooorm" } }, - "node_modules/chownr": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", - "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", - "license": "BlueOak-1.0.0", + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, "engines": { - "node": ">=18" + "node": ">=12" } }, "node_modules/clsx": { @@ -2395,9 +3571,9 @@ } }, "node_modules/codemirror": { - "version": "5.65.19", - "resolved": "https://registry.npmjs.org/codemirror/-/codemirror-5.65.19.tgz", - "integrity": "sha512-+aFkvqhaAVr1gferNMuN8vkTSrWIFvzlMV9I2KBLCWS2WpZ2+UAkZjlMZmEuT+gcXTi6RrGQCkWq1/bDtGqhIA==", + "version": "5.65.20", + "resolved": "https://registry.npmjs.org/codemirror/-/codemirror-5.65.20.tgz", + "integrity": "sha512-i5dLDDxwkFCbhjvL2pNjShsojoL3XHyDwsGv1jqETUoW+lzpBKKqNTUWgQwVAOa0tUm4BwekT455ujafi8payA==", "license": "MIT" }, "node_modules/codemirror-spell-checker": { @@ -2559,9 +3735,9 @@ "license": "MIT" }, "node_modules/debug": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz", - "integrity": "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ==", + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -2576,9 +3752,9 @@ } }, "node_modules/decode-named-character-reference": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.1.0.tgz", - "integrity": "sha512-Wy+JTSbFThEOXQIR2L6mxJvEs+veIzpmqD7ynWxMXGpnk3smkHQOp6forLdHsKpAMW9iJpaBBIxz285t1n1C3w==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.2.0.tgz", + "integrity": "sha512-c6fcElNV6ShtZXmsgNgFFV5tVX2PaV4g+MOAkb8eXHvn6sryJBrZa9r0zV6+dtTyoCKxtDy5tyQ5ZwQuidtd+Q==", "license": "MIT", "dependencies": { "character-entities": "^2.0.0" @@ -2595,6 +3771,16 @@ "dev": true, "license": "MIT" }, + "node_modules/define-lazy-prop": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz", + "integrity": "sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/delayed-stream": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", @@ -2623,9 +3809,9 @@ } }, "node_modules/detect-libc": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz", - "integrity": "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==", + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", "license": "Apache-2.0", "engines": { "node": ">=8" @@ -2644,6 +3830,12 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/diacritics": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/diacritics/-/diacritics-1.3.0.tgz", + "integrity": "sha512-wlwEkqcsaxvPJML+rDh/2iS824jbREk6DUMUKkEaSlxdYHeS43cClJtsWglvw2RfeXGm6ohKDqsXteJ5sP5enA==", + "license": "MIT" + }, "node_modules/dom-helpers": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", @@ -2654,6 +3846,17 @@ "csstype": "^3.0.2" } }, + "node_modules/dot-case": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/dot-case/-/dot-case-3.0.4.tgz", + "integrity": "sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "no-case": "^3.0.4", + "tslib": "^2.0.3" + } + }, "node_modules/draco3d": { "version": "1.5.7", "resolved": "https://registry.npmjs.org/draco3d/-/draco3d-1.5.7.tgz", @@ -2675,9 +3878,9 @@ } }, "node_modules/earcut": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/earcut/-/earcut-3.0.1.tgz", - "integrity": "sha512-0l1/0gOjESMeQyYaK5IDiPNvFeu93Z/cO0TjZh9eZ1vyCtZnA7KMZ8rQggpsJHIbGSdrqYq9OhuveadOVHCshw==", + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/earcut/-/earcut-3.0.2.tgz", + "integrity": "sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ==", "license": "ISC" }, "node_modules/easymde": { @@ -2694,16 +3897,23 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.5.160", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.160.tgz", - "integrity": "sha512-8yQk54/CoCQT8GX3zuxqPBwMAQuIr6dWI/qO8Aah/JAZwB5XmCbEElsqb1n4pzc2vpkTdfc/kbyNPJOjswfbgg==", + "version": "1.5.241", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.241.tgz", + "integrity": "sha512-ILMvKX/ZV5WIJzzdtuHg8xquk2y0BOGlFOxBVwTpbiXqWIH0hamG45ddU4R3PQ0gYu+xgo0vdHXHli9sHIGb4w==", "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==", + "dev": true, + "license": "MIT" + }, "node_modules/enhanced-resolve": { - "version": "5.18.1", - "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.1.tgz", - "integrity": "sha512-ZSW3ma5GkcQBIpwZTSRAI8N71Uuwgs93IezB7mf7R60tC8ZbJideoDNKjHn2O9KIlx6rkGTTEk1xUCK2E1Y2Yg==", + "version": "5.18.3", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz", + "integrity": "sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==", "license": "MIT", "dependencies": { "graceful-fs": "^4.2.4", @@ -2714,9 +3924,9 @@ } }, "node_modules/entities": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.0.tgz", - "integrity": "sha512-aKstq2TDOndCn4diEyp9Uq/Flu2i1GlLkc6XIDQSDMuaFE3OPW5OphLCyQ5SpSJZTb4reN+kTcYru5yIfXoRPw==", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", "license": "BSD-2-Clause", "engines": { "node": ">=0.12" @@ -2726,9 +3936,9 @@ } }, "node_modules/error-ex": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", - "integrity": "sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", "license": "MIT", "dependencies": { "is-arrayish": "^0.2.1" @@ -2780,9 +3990,9 @@ } }, "node_modules/esbuild": { - "version": "0.25.5", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.5.tgz", - "integrity": "sha512-P8OtKZRv/5J5hhz0cUAdu/cLuPIKXpQl1R9pZtvmHWQvrAUVd0UNIPT4IB4W3rNOqVO0rlqHmCIbSwxh/c9yUQ==", + "version": "0.25.11", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.11.tgz", + "integrity": "sha512-KohQwyzrKTQmhXDW1PjCv3Tyspn9n5GcY2RTDqeORIdIJY8yKIF7sTSopFmn/wpMPW4rdPXI0UE5LJLuq3bx0Q==", "hasInstallScript": true, "license": "MIT", "bin": { @@ -2792,31 +4002,32 @@ "node": ">=18" }, "optionalDependencies": { - "@esbuild/aix-ppc64": "0.25.5", - "@esbuild/android-arm": "0.25.5", - "@esbuild/android-arm64": "0.25.5", - "@esbuild/android-x64": "0.25.5", - "@esbuild/darwin-arm64": "0.25.5", - "@esbuild/darwin-x64": "0.25.5", - "@esbuild/freebsd-arm64": "0.25.5", - "@esbuild/freebsd-x64": "0.25.5", - "@esbuild/linux-arm": "0.25.5", - "@esbuild/linux-arm64": "0.25.5", - "@esbuild/linux-ia32": "0.25.5", - "@esbuild/linux-loong64": "0.25.5", - "@esbuild/linux-mips64el": "0.25.5", - "@esbuild/linux-ppc64": "0.25.5", - "@esbuild/linux-riscv64": "0.25.5", - "@esbuild/linux-s390x": "0.25.5", - "@esbuild/linux-x64": "0.25.5", - "@esbuild/netbsd-arm64": "0.25.5", - "@esbuild/netbsd-x64": "0.25.5", - "@esbuild/openbsd-arm64": "0.25.5", - "@esbuild/openbsd-x64": "0.25.5", - "@esbuild/sunos-x64": "0.25.5", - "@esbuild/win32-arm64": "0.25.5", - "@esbuild/win32-ia32": "0.25.5", - "@esbuild/win32-x64": "0.25.5" + "@esbuild/aix-ppc64": "0.25.11", + "@esbuild/android-arm": "0.25.11", + "@esbuild/android-arm64": "0.25.11", + "@esbuild/android-x64": "0.25.11", + "@esbuild/darwin-arm64": "0.25.11", + "@esbuild/darwin-x64": "0.25.11", + "@esbuild/freebsd-arm64": "0.25.11", + "@esbuild/freebsd-x64": "0.25.11", + "@esbuild/linux-arm": "0.25.11", + "@esbuild/linux-arm64": "0.25.11", + "@esbuild/linux-ia32": "0.25.11", + "@esbuild/linux-loong64": "0.25.11", + "@esbuild/linux-mips64el": "0.25.11", + "@esbuild/linux-ppc64": "0.25.11", + "@esbuild/linux-riscv64": "0.25.11", + "@esbuild/linux-s390x": "0.25.11", + "@esbuild/linux-x64": "0.25.11", + "@esbuild/netbsd-arm64": "0.25.11", + "@esbuild/netbsd-x64": "0.25.11", + "@esbuild/openbsd-arm64": "0.25.11", + "@esbuild/openbsd-x64": "0.25.11", + "@esbuild/openharmony-arm64": "0.25.11", + "@esbuild/sunos-x64": "0.25.11", + "@esbuild/win32-arm64": "0.25.11", + "@esbuild/win32-ia32": "0.25.11", + "@esbuild/win32-x64": "0.25.11" } }, "node_modules/escalade": { @@ -2842,33 +4053,32 @@ } }, "node_modules/eslint": { - "version": "9.27.0", - "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.27.0.tgz", - "integrity": "sha512-ixRawFQuMB9DZ7fjU3iGGganFDp3+45bPOdaRurcFHSXO1e/sYwUX/FtQZpLZJR6SjMoJH8hR2pPEAfDyCoU2Q==", + "version": "9.38.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.38.0.tgz", + "integrity": "sha512-t5aPOpmtJcZcz5UJyY2GbvpDlsK5E8JqRqoKtfiKE3cNh437KIqfJr3A3AKf5k64NPx6d0G3dno6XDY05PqPtw==", "dev": true, "license": "MIT", "dependencies": { - "@eslint-community/eslint-utils": "^4.2.0", + "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", - "@eslint/config-array": "^0.20.0", - "@eslint/config-helpers": "^0.2.1", - "@eslint/core": "^0.14.0", + "@eslint/config-array": "^0.21.1", + "@eslint/config-helpers": "^0.4.1", + "@eslint/core": "^0.16.0", "@eslint/eslintrc": "^3.3.1", - "@eslint/js": "9.27.0", - "@eslint/plugin-kit": "^0.3.1", + "@eslint/js": "9.38.0", + "@eslint/plugin-kit": "^0.4.0", "@humanfs/node": "^0.16.6", "@humanwhocodes/module-importer": "^1.0.1", "@humanwhocodes/retry": "^0.4.2", "@types/estree": "^1.0.6", - "@types/json-schema": "^7.0.15", "ajv": "^6.12.4", "chalk": "^4.0.0", "cross-spawn": "^7.0.6", "debug": "^4.3.2", "escape-string-regexp": "^4.0.0", - "eslint-scope": "^8.3.0", - "eslint-visitor-keys": "^4.2.0", - "espree": "^10.3.0", + "eslint-scope": "^8.4.0", + "eslint-visitor-keys": "^4.2.1", + "espree": "^10.4.0", "esquery": "^1.5.0", "esutils": "^2.0.2", "fast-deep-equal": "^3.1.3", @@ -2916,9 +4126,9 @@ } }, "node_modules/eslint-plugin-react-refresh": { - "version": "0.4.20", - "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.20.tgz", - "integrity": "sha512-XpbHQ2q5gUF8BGOX4dHe+71qoirYMhApEPZ7sfhF/dNnOF1UXnCMGZf79SFTBO7Bz5YEIT4TMieSlJBWhP9WBA==", + "version": "0.4.24", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.24.tgz", + "integrity": "sha512-nLHIW7TEq3aLrEYWpVaJ1dRgFR+wLDPN8e8FpYAql/bMV2oBEfC37K0gLEGgv9fy66juNShSMV8OkTqzltcG/w==", "dev": true, "license": "MIT", "peerDependencies": { @@ -2926,9 +4136,9 @@ } }, "node_modules/eslint-scope": { - "version": "8.3.0", - "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.3.0.tgz", - "integrity": "sha512-pUNxi75F8MJ/GdeKtVLSbYg4ZI34J6C0C7sbL4YOp2exGwen7ZsuBqKzUhXd0qMQ362yET3z+uPwKeg/0C2XCQ==", + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", "dev": true, "license": "BSD-2-Clause", "dependencies": { @@ -2943,9 +4153,9 @@ } }, "node_modules/eslint-visitor-keys": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz", - "integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==", + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", "dev": true, "license": "Apache-2.0", "engines": { @@ -2956,15 +4166,15 @@ } }, "node_modules/espree": { - "version": "10.3.0", - "resolved": "https://registry.npmjs.org/espree/-/espree-10.3.0.tgz", - "integrity": "sha512-0QYC8b24HWY8zjRnDTL6RiHfDbAWn63qb4LMj1Z4b076A4une81+z03Kg7l7mn/48PUTqoLptSXez8oknU8Clg==", + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", "dev": true, "license": "BSD-2-Clause", "dependencies": { - "acorn": "^8.14.0", + "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", - "eslint-visitor-keys": "^4.2.0" + "eslint-visitor-keys": "^4.2.1" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -3019,6 +4229,13 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/estree-walker": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", + "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", + "dev": true, + "license": "MIT" + }, "node_modules/esutils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", @@ -3102,20 +4319,6 @@ "reusify": "^1.0.4" } }, - "node_modules/fdir": { - "version": "6.4.5", - "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.4.5.tgz", - "integrity": "sha512-4BG7puHpVsIYxZUbiUE3RqGloLaSSwzYie5jvasC4LWuBWzZawynvYouhjbQKw2JuIGYdm0DzIxl8iVidKlUEw==", - "license": "MIT", - "peerDependencies": { - "picomatch": "^3 || ^4" - }, - "peerDependenciesMeta": { - "picomatch": { - "optional": true - } - } - }, "node_modules/fflate": { "version": "0.8.2", "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz", @@ -3135,18 +4338,6 @@ "node": ">=16.0.0" } }, - "node_modules/file-selector": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/file-selector/-/file-selector-2.1.2.tgz", - "integrity": "sha512-QgXo+mXTe8ljeqUFaX3QVHc5osSItJ/Km+xpocx0aSqWGMSCf6qYs/VnzZgS864Pjn5iceMRFigeAV7AfTlaig==", - "license": "MIT", - "dependencies": { - "tslib": "^2.7.0" - }, - "engines": { - "node": ">= 12" - } - }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -3205,9 +4396,9 @@ "license": "ISC" }, "node_modules/follow-redirects": { - "version": "1.15.9", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz", - "integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==", + "version": "1.15.11", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz", + "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==", "funding": [ { "type": "individual", @@ -3225,14 +4416,15 @@ } }, "node_modules/form-data": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.2.tgz", - "integrity": "sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz", + "integrity": "sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==", "license": "MIT", "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", "mime-types": "^2.1.12" }, "engines": { @@ -3243,6 +4435,7 @@ "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, "license": "MIT", "optional": true, "os": [ @@ -3290,6 +4483,16 @@ "node": ">=10.19" } }, + "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==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, "node_modules/get-intrinsic": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", @@ -3350,9 +4553,9 @@ } }, "node_modules/globals": { - "version": "16.2.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-16.2.0.tgz", - "integrity": "sha512-O+7l9tPdHCU320IigZZPj5zmRCFG9xHmx9cU8FqU2Rp+JN714seHV+2S9+JslCpY4gJwU2vOGox0wzgae/MCEg==", + "version": "16.4.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.4.0.tgz", + "integrity": "sha512-ob/2LcVVaVGCYN+r14cnwnoDPUufjiYgSqRhiFD0Q1iI4Odora5RE8Iv1D24hAz5oMophRGkGz+yuvQmmUMnMw==", "dev": true, "license": "MIT", "engines": { @@ -3587,9 +4790,9 @@ } }, "node_modules/hls.js": { - "version": "1.6.5", - "resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.5.tgz", - "integrity": "sha512-KMn5n7JBK+olC342740hDPHnGWfE8FiHtGMOdJPfUjRdARTWj9OB+8c13fnsf9sk1VtpuU2fKSgUjHvg4rNbzQ==", + "version": "1.6.13", + "resolved": "https://registry.npmjs.org/hls.js/-/hls.js-1.6.13.tgz", + "integrity": "sha512-hNEzjZNHf5bFrUNvdS4/1RjIanuJ6szpWNfTaX5I6WfGynWXGT7K/YQLYtemSvFExzeMdgdE4SsyVLJbd5PcZA==", "license": "Apache-2.0" }, "node_modules/hoist-non-react-statics": { @@ -3601,6 +4804,12 @@ "react-is": "^16.7.0" } }, + "node_modules/hoist-non-react-statics/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, "node_modules/html-url-attributes": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", @@ -3621,6 +4830,18 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/i18n-iso-countries": { + "version": "7.14.0", + "resolved": "https://registry.npmjs.org/i18n-iso-countries/-/i18n-iso-countries-7.14.0.tgz", + "integrity": "sha512-nXHJZYtNrfsi1UQbyRqm3Gou431elgLjKl//CYlnBGt5aTWdRPH1PiS2T/p/n8Q8LnqYqzQJik3Q7mkwvLokeg==", + "license": "MIT", + "dependencies": { + "diacritics": "1.3.0" + }, + "engines": { + "node": ">= 12" + } + }, "node_modules/ieee754": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", @@ -3750,6 +4971,22 @@ "url": "https://github.com/sponsors/wooorm" } }, + "node_modules/is-docker": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz", + "integrity": "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==", + "dev": true, + "license": "MIT", + "bin": { + "is-docker": "cli.js" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/is-extglob": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", @@ -3760,6 +4997,16 @@ "node": ">=0.10.0" } }, + "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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-glob": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", @@ -3811,6 +5058,19 @@ "integrity": "sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ==", "license": "MIT" }, + "node_modules/is-wsl": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz", + "integrity": "sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-docker": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/isexe": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", @@ -3835,10 +5095,19 @@ "react": "^19.0.0" } }, + "node_modules/its-fine/node_modules/@types/react-reconciler": { + "version": "0.28.9", + "resolved": "https://registry.npmjs.org/@types/react-reconciler/-/react-reconciler-0.28.9.tgz", + "integrity": "sha512-HHM3nxyUZ3zAylX8ZEyrDNd2XZOnQ0D5XfunJF5FLQnZbHHYq4UWvW1QfelQNXv1ICNkwYhfxjwfnqivYB6bFg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*" + } + }, "node_modules/jiti": { - "version": "2.4.2", - "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.4.2.tgz", - "integrity": "sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==", + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", + "integrity": "sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ==", "license": "MIT", "bin": { "jiti": "lib/jiti-cli.mjs" @@ -3961,9 +5230,9 @@ } }, "node_modules/lightningcss": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.1.tgz", - "integrity": "sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg==", + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz", + "integrity": "sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ==", "license": "MPL-2.0", "dependencies": { "detect-libc": "^2.0.3" @@ -3976,22 +5245,43 @@ "url": "https://opencollective.com/parcel" }, "optionalDependencies": { - "lightningcss-darwin-arm64": "1.30.1", - "lightningcss-darwin-x64": "1.30.1", - "lightningcss-freebsd-x64": "1.30.1", - "lightningcss-linux-arm-gnueabihf": "1.30.1", - "lightningcss-linux-arm64-gnu": "1.30.1", - "lightningcss-linux-arm64-musl": "1.30.1", - "lightningcss-linux-x64-gnu": "1.30.1", - "lightningcss-linux-x64-musl": "1.30.1", - "lightningcss-win32-arm64-msvc": "1.30.1", - "lightningcss-win32-x64-msvc": "1.30.1" + "lightningcss-android-arm64": "1.30.2", + "lightningcss-darwin-arm64": "1.30.2", + "lightningcss-darwin-x64": "1.30.2", + "lightningcss-freebsd-x64": "1.30.2", + "lightningcss-linux-arm-gnueabihf": "1.30.2", + "lightningcss-linux-arm64-gnu": "1.30.2", + "lightningcss-linux-arm64-musl": "1.30.2", + "lightningcss-linux-x64-gnu": "1.30.2", + "lightningcss-linux-x64-musl": "1.30.2", + "lightningcss-win32-arm64-msvc": "1.30.2", + "lightningcss-win32-x64-msvc": "1.30.2" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.30.2.tgz", + "integrity": "sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" } }, "node_modules/lightningcss-darwin-arm64": { - "version": "1.30.1", - "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.1.tgz", - "integrity": "sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ==", + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.2.tgz", + "integrity": "sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA==", "cpu": [ "arm64" ], @@ -4008,6 +5298,186 @@ "url": "https://opencollective.com/parcel" } }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.2.tgz", + "integrity": "sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.2.tgz", + "integrity": "sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.2.tgz", + "integrity": "sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA==", + "cpu": [ + "arm" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.2.tgz", + "integrity": "sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.2.tgz", + "integrity": "sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.2.tgz", + "integrity": "sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.2.tgz", + "integrity": "sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.2.tgz", + "integrity": "sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ==", + "cpu": [ + "arm64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.30.2", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.2.tgz", + "integrity": "sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw==", + "cpu": [ + "x64" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, "node_modules/lines-and-columns": { "version": "1.2.4", "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", @@ -4030,18 +5500,10 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/lodash.castarray": { - "version": "4.4.0", - "resolved": "https://registry.npmjs.org/lodash.castarray/-/lodash.castarray-4.4.0.tgz", - "integrity": "sha512-aVx8ztPv7/2ULbArGJ2Y42bG1mEQ5mGjpdvrbJcJFU3TbYybe+QlLS4pst9zV52ymy2in1KpFPiZnAOATxD4+Q==", - "dev": true, - "license": "MIT" - }, - "node_modules/lodash.isplainobject": { - "version": "4.0.6", - "resolved": "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz", - "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", - "dev": true, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", "license": "MIT" }, "node_modules/lodash.merge": { @@ -4073,6 +5535,16 @@ "loose-envify": "cli.js" } }, + "node_modules/lower-case": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/lower-case/-/lower-case-2.0.2.tgz", + "integrity": "sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tslib": "^2.0.3" + } + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -4083,13 +5555,6 @@ "yallist": "^3.0.2" } }, - "node_modules/lru-cache/node_modules/yallist": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", - "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", - "dev": true, - "license": "ISC" - }, "node_modules/lucide-react": { "version": "0.511.0", "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.511.0.tgz", @@ -4110,12 +5575,12 @@ } }, "node_modules/magic-string": { - "version": "0.30.17", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz", - "integrity": "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==", + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", "license": "MIT", "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0" + "@jridgewell/sourcemap-codec": "^1.5.5" } }, "node_modules/marked": { @@ -4312,9 +5777,9 @@ } }, "node_modules/meshoptimizer": { - "version": "0.18.1", - "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-0.18.1.tgz", - "integrity": "sha512-ZhoIoL7TNV4s5B6+rx5mC//fw8/POGyNxS/DZyCJeiZ12ScLfVwRE/GfsxwiTkMYYD5DmK2/JXnEVXqL4rF+Sw==", + "version": "0.22.0", + "resolved": "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-0.22.0.tgz", + "integrity": "sha512-IebiK79sqIy+E4EgOr+CAw+Ke8hAspXKzBd0JdgEmPHiAwmvEj2S4h1rfvo+o/BnfEYd/jAOg5IeeIjzlzSnDg==", "license": "MIT" }, "node_modules/micromark": { @@ -4773,19 +6238,6 @@ "node": ">=8.6" } }, - "node_modules/micromatch/node_modules/picomatch": { - "version": "2.3.1", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", - "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, "node_modules/mime-db": { "version": "1.52.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", @@ -4820,46 +6272,10 @@ "node": "*" } }, - "node_modules/minipass": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", - "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", - "license": "ISC", - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, - "node_modules/minizlib": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.0.2.tgz", - "integrity": "sha512-oG62iEk+CYt5Xj2YqI5Xi9xWUeZhDI8jjQmC5oThVH5JGCTgIjr7ciJDzC7MBzYd//WvR1OTmP5Q38Q8ShQtVA==", - "license": "MIT", - "dependencies": { - "minipass": "^7.1.2" - }, - "engines": { - "node": ">= 18" - } - }, - "node_modules/mkdirp": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-3.0.1.tgz", - "integrity": "sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==", - "license": "MIT", - "bin": { - "mkdirp": "dist/cjs/src/bin.js" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/mobx": { - "version": "6.13.7", - "resolved": "https://registry.npmjs.org/mobx/-/mobx-6.13.7.tgz", - "integrity": "sha512-aChaVU/DO5aRPmk1GX8L+whocagUUpBQqoPtJk+cm7UOXUk87J4PeWCh6nNmTTIfEhiR9DI/+FnA8dln/hTK7g==", + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/mobx/-/mobx-6.15.0.tgz", + "integrity": "sha512-UczzB+0nnwGotYSgllfARAqWCJ5e/skuV2K/l+Zyck/H6pJIhLXuBnz+6vn2i211o7DtbE78HQtsYEKICHGI+g==", "license": "MIT", "funding": { "type": "opencollective", @@ -4867,9 +6283,9 @@ } }, "node_modules/mobx-react-lite": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/mobx-react-lite/-/mobx-react-lite-4.1.0.tgz", - "integrity": "sha512-QEP10dpHHBeQNv1pks3WnHRCem2Zp636lq54M2nKO2Sarr13pL4u6diQXf65yzXUn0mkk18SyIDCm9UOJYTi1w==", + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/mobx-react-lite/-/mobx-react-lite-4.1.1.tgz", + "integrity": "sha512-iUxiMpsvNraCKXU+yPotsOncNNmyeS2B5DKL+TL6Tar/xm+wwNJAubJmtRSeAoYawdZqwv8Z/+5nPRHeQxTiXg==", "license": "MIT", "dependencies": { "use-sync-external-store": "^1.4.0" @@ -4922,10 +6338,21 @@ "dev": true, "license": "MIT" }, + "node_modules/no-case": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/no-case/-/no-case-3.0.4.tgz", + "integrity": "sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "lower-case": "^2.0.2", + "tslib": "^2.0.3" + } + }, "node_modules/node-releases": { - "version": "2.0.19", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz", - "integrity": "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw==", + "version": "2.0.26", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.26.tgz", + "integrity": "sha512-S2M9YimhSjBSvYnlr5/+umAnPHE++ODwt5e2Ij6FoX45HA/s4vHdkDx1eax2pAPeAOqu4s9b7ppahsyEFdVqQA==", "dev": true, "license": "MIT" }, @@ -4939,9 +6366,9 @@ } }, "node_modules/ol": { - "version": "10.5.0", - "resolved": "https://registry.npmjs.org/ol/-/ol-10.5.0.tgz", - "integrity": "sha512-nHFx8gkGmvYImsa7iKkwUnZidd5gn1XbMZd9GNOorvm9orjW9gQvT3Naw/MjIasVJ3cB9EJUdCGR2EFAulMHsQ==", + "version": "10.6.1", + "resolved": "https://registry.npmjs.org/ol/-/ol-10.6.1.tgz", + "integrity": "sha512-xp174YOwPeLj7c7/8TCIEHQ4d41tgTDDhdv6SqNdySsql5/MaFJEJkjlsYcvOPt7xA6vrum/QG4UdJ0iCGT1cg==", "license": "BSD-2-Clause", "dependencies": { "@types/rbush": "4.0.0", @@ -4955,6 +6382,24 @@ "url": "https://opencollective.com/openlayers" } }, + "node_modules/open": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/open/-/open-8.4.2.tgz", + "integrity": "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-lazy-prop": "^2.0.0", + "is-docker": "^2.1.1", + "is-wsl": "^2.2.0" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -4973,6 +6418,22 @@ "node": ">= 0.8.0" } }, + "node_modules/overlayscrollbars": { + "version": "2.15.1", + "resolved": "https://registry.npmjs.org/overlayscrollbars/-/overlayscrollbars-2.15.1.tgz", + "integrity": "sha512-glX26JwjL+Tkzv0JNOWdW4VozP5dGXO+Wx8+TPrdTEJTSYT/8eJS8yXM+fewjU0nFq/JeCa+X+BqABNjC4YZSA==", + "license": "MIT" + }, + "node_modules/overlayscrollbars-react": { + "version": "0.5.6", + "resolved": "https://registry.npmjs.org/overlayscrollbars-react/-/overlayscrollbars-react-0.5.6.tgz", + "integrity": "sha512-E5To04bL5brn9GVCZ36SnfGanxa2I2MDkWoa4Cjo5wol7l+diAgi4DBc983V7l2nOk/OLJ6Feg4kySspQEGDBw==", + "license": "MIT", + "peerDependencies": { + "overlayscrollbars": "^2.0.0", + "react": ">=16.8.0" + } + }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -5153,21 +6614,22 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz", - "integrity": "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==", + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", + "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "dev": true, "license": "MIT", "engines": { - "node": ">=12" + "node": ">=8.6" }, "funding": { "url": "https://github.com/sponsors/jonschlinkert" } }, "node_modules/pixi.js": { - "version": "8.11.0", - "resolved": "https://registry.npmjs.org/pixi.js/-/pixi.js-8.11.0.tgz", - "integrity": "sha512-dyuThzncsgEgJZnvd/A/5x6IkUERbK+phXqUQrI+0C6WE+8xqGH5VChRTLecemhgZF0kQ+gZOM3tJTX9937xpg==", + "version": "8.14.0", + "resolved": "https://registry.npmjs.org/pixi.js/-/pixi.js-8.14.0.tgz", + "integrity": "sha512-ituDiEBb1Oqx56RYwTtC6MjPUhPfF/i15fpUv5oEqmzC/ce3SaSumulJcOjKG7+y0J0Ekl9Rl4XTxaUw+MVFZw==", "license": "MIT", "dependencies": { "@pixi/colord": "^2.9.6", @@ -5175,11 +6637,12 @@ "@types/earcut": "^3.0.0", "@webgpu/types": "^0.1.40", "@xmldom/xmldom": "^0.8.10", - "earcut": "^3.0.1", + "earcut": "^3.0.2", "eventemitter3": "^5.0.1", "gifuct-js": "^2.1.2", "ismobilejs": "^1.1.1", - "parse-svg-path": "^0.1.2" + "parse-svg-path": "^0.1.2", + "tiny-lru": "^11.4.5" }, "funding": { "type": "opencollective", @@ -5187,9 +6650,9 @@ } }, "node_modules/postcss": { - "version": "8.5.3", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.3.tgz", - "integrity": "sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==", + "version": "8.5.6", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", + "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", "funding": [ { "type": "opencollective", @@ -5206,7 +6669,7 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.8", + "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" }, @@ -5274,6 +6737,12 @@ "react-is": "^16.13.1" } }, + "node_modules/prop-types/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, "node_modules/property-information": { "version": "7.1.0", "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", @@ -5361,57 +6830,36 @@ } }, "node_modules/react": { - "version": "19.1.0", - "resolved": "https://registry.npmjs.org/react/-/react-19.1.0.tgz", - "integrity": "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg==", + "version": "19.2.0", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", + "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", "license": "MIT", "engines": { "node": ">=0.10.0" } }, - "node_modules/react-colorful": { - "version": "5.6.1", - "resolved": "https://registry.npmjs.org/react-colorful/-/react-colorful-5.6.1.tgz", - "integrity": "sha512-1exovf0uGTGyq5mXQT0zgQ80uvj2PCwvF8zY1RN9/vbJVSjSo3fsB/4L3ObbF7u70NduSiK4xu4Y6q1MHoUGEw==", - "license": "MIT", - "peerDependencies": { - "react": ">=16.8.0", - "react-dom": ">=16.8.0" - } - }, "node_modules/react-dom": { - "version": "19.1.0", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz", - "integrity": "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g==", + "version": "19.2.0", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz", + "integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==", "license": "MIT", "dependencies": { - "scheduler": "^0.26.0" + "scheduler": "^0.27.0" }, "peerDependencies": { - "react": "^19.1.0" + "react": "^19.2.0" } }, - "node_modules/react-dropzone": { - "version": "14.3.8", - "resolved": "https://registry.npmjs.org/react-dropzone/-/react-dropzone-14.3.8.tgz", - "integrity": "sha512-sBgODnq+lcA4P296DY4wacOZz3JFpD99fp+hb//iBO2HHnyeZU3FwWyXJ6salNpqQdsZrgMrotuko/BdJMV8Ug==", - "license": "MIT", - "dependencies": { - "attr-accept": "^2.2.4", - "file-selector": "^2.1.0", - "prop-types": "^15.8.1" - }, - "engines": { - "node": ">= 10.13" - }, - "peerDependencies": { - "react": ">= 16.8 || 18.0.0" - } + "node_modules/react-dom/node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" }, "node_modules/react-is": { - "version": "16.13.1", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", - "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "version": "19.2.0", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-19.2.0.tgz", + "integrity": "sha512-x3Ax3kNSMIIkyVYhWPyO09bu0uttcAIoecO/um/rKGQ4EltYWVYtyiGkS/3xMynrbVQdS69Jhlv8FXUEZehlzA==", "license": "MIT" }, "node_modules/react-markdown": { @@ -5475,12 +6923,6 @@ "react": "^19.0.0" } }, - "node_modules/react-reconciler/node_modules/scheduler": { - "version": "0.25.0", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.25.0.tgz", - "integrity": "sha512-xFVuu11jh+xcO7JOAGJNOXld8/TcEHK/4CituBUeUb5hqxJLj9YuemAEuvm9gQ/+pgXYfbQuqAkiYu+u7YEsNA==", - "license": "MIT" - }, "node_modules/react-redux": { "version": "9.2.0", "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", @@ -5515,9 +6957,9 @@ } }, "node_modules/react-router": { - "version": "7.6.2", - "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.6.2.tgz", - "integrity": "sha512-U7Nv3y+bMimgWjhlT5CRdzHPu2/KVmqPwKUCChW8en5P3znxUqwlYFlbmyj8Rgp1SF6zs5X4+77kBVknkg6a0w==", + "version": "7.9.4", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.9.4.tgz", + "integrity": "sha512-SD3G8HKviFHg9xj7dNODUKDFgpG4xqD5nhyd0mYoB5iISepuZAvzSr8ywxgxKJ52yRzf/HWtVHc9AWwoTbljvA==", "license": "MIT", "dependencies": { "cookie": "^1.0.1", @@ -5537,12 +6979,12 @@ } }, "node_modules/react-router-dom": { - "version": "7.6.2", - "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.6.2.tgz", - "integrity": "sha512-Q8zb6VlTbdYKK5JJBLQEN06oTUa/RAbG/oQS1auK1I0TbJOXktqm+QENEVJU6QvWynlXPRBXI3fiOQcSEA78rA==", + "version": "7.9.4", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.9.4.tgz", + "integrity": "sha512-f30P6bIkmYvnHHa5Gcu65deIXoA2+r3Eb6PJIAddvsT9aGlchMatJ51GgpU470aSqRRbFX22T70yQNUGuW3DfA==", "license": "MIT", "dependencies": { - "react-router": "7.6.2" + "react-router": "7.9.4" }, "engines": { "node": ">=20.0.0" @@ -5664,6 +7106,16 @@ "url": "https://opencollective.com/unified" } }, + "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==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", @@ -5680,12 +7132,12 @@ "license": "MIT" }, "node_modules/resolve": { - "version": "1.22.10", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz", - "integrity": "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w==", + "version": "1.22.11", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz", + "integrity": "sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ==", "license": "MIT", "dependencies": { - "is-core-module": "^2.16.0", + "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, @@ -5729,12 +7181,12 @@ } }, "node_modules/rollup": { - "version": "4.41.1", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.41.1.tgz", - "integrity": "sha512-cPmwD3FnFv8rKMBc1MxWCwVQFxwf1JEmSX3iQXrRVVG15zerAIXRjMFVWnd5Q5QvgKF7Aj+5ykXFhUl+QGnyOw==", + "version": "4.52.5", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.52.5.tgz", + "integrity": "sha512-3GuObel8h7Kqdjt0gxkEzaifHTqLVW56Y/bjN7PSQtkKr0w3V/QYSdt6QWYtd7A1xUtYQigtdUfgj1RvWVtorw==", "license": "MIT", "dependencies": { - "@types/estree": "1.0.7" + "@types/estree": "1.0.8" }, "bin": { "rollup": "dist/bin/rollup" @@ -5744,29 +7196,85 @@ "npm": ">=8.0.0" }, "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.41.1", - "@rollup/rollup-android-arm64": "4.41.1", - "@rollup/rollup-darwin-arm64": "4.41.1", - "@rollup/rollup-darwin-x64": "4.41.1", - "@rollup/rollup-freebsd-arm64": "4.41.1", - "@rollup/rollup-freebsd-x64": "4.41.1", - "@rollup/rollup-linux-arm-gnueabihf": "4.41.1", - "@rollup/rollup-linux-arm-musleabihf": "4.41.1", - "@rollup/rollup-linux-arm64-gnu": "4.41.1", - "@rollup/rollup-linux-arm64-musl": "4.41.1", - "@rollup/rollup-linux-loongarch64-gnu": "4.41.1", - "@rollup/rollup-linux-powerpc64le-gnu": "4.41.1", - "@rollup/rollup-linux-riscv64-gnu": "4.41.1", - "@rollup/rollup-linux-riscv64-musl": "4.41.1", - "@rollup/rollup-linux-s390x-gnu": "4.41.1", - "@rollup/rollup-linux-x64-gnu": "4.41.1", - "@rollup/rollup-linux-x64-musl": "4.41.1", - "@rollup/rollup-win32-arm64-msvc": "4.41.1", - "@rollup/rollup-win32-ia32-msvc": "4.41.1", - "@rollup/rollup-win32-x64-msvc": "4.41.1", + "@rollup/rollup-android-arm-eabi": "4.52.5", + "@rollup/rollup-android-arm64": "4.52.5", + "@rollup/rollup-darwin-arm64": "4.52.5", + "@rollup/rollup-darwin-x64": "4.52.5", + "@rollup/rollup-freebsd-arm64": "4.52.5", + "@rollup/rollup-freebsd-x64": "4.52.5", + "@rollup/rollup-linux-arm-gnueabihf": "4.52.5", + "@rollup/rollup-linux-arm-musleabihf": "4.52.5", + "@rollup/rollup-linux-arm64-gnu": "4.52.5", + "@rollup/rollup-linux-arm64-musl": "4.52.5", + "@rollup/rollup-linux-loong64-gnu": "4.52.5", + "@rollup/rollup-linux-ppc64-gnu": "4.52.5", + "@rollup/rollup-linux-riscv64-gnu": "4.52.5", + "@rollup/rollup-linux-riscv64-musl": "4.52.5", + "@rollup/rollup-linux-s390x-gnu": "4.52.5", + "@rollup/rollup-linux-x64-gnu": "4.52.5", + "@rollup/rollup-linux-x64-musl": "4.52.5", + "@rollup/rollup-openharmony-arm64": "4.52.5", + "@rollup/rollup-win32-arm64-msvc": "4.52.5", + "@rollup/rollup-win32-ia32-msvc": "4.52.5", + "@rollup/rollup-win32-x64-gnu": "4.52.5", + "@rollup/rollup-win32-x64-msvc": "4.52.5", "fsevents": "~2.3.2" } }, + "node_modules/rollup-plugin-visualizer": { + "version": "6.0.5", + "resolved": "https://registry.npmjs.org/rollup-plugin-visualizer/-/rollup-plugin-visualizer-6.0.5.tgz", + "integrity": "sha512-9+HlNgKCVbJDs8tVtjQ43US12eqaiHyyiLMdBwQ7vSZPiHMysGNo2E88TAp1si5wx8NAoYriI2A5kuKfIakmJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "open": "^8.0.0", + "picomatch": "^4.0.2", + "source-map": "^0.7.4", + "yargs": "^17.5.1" + }, + "bin": { + "rollup-plugin-visualizer": "dist/bin/cli.js" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "rolldown": "1.x || ^1.0.0-beta", + "rollup": "2.x || 3.x || 4.x" + }, + "peerDependenciesMeta": { + "rolldown": { + "optional": true + }, + "rollup": { + "optional": true + } + } + }, + "node_modules/rollup-plugin-visualizer/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/rollup-plugin-visualizer/node_modules/source-map": { + "version": "0.7.6", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", + "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 12" + } + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -5792,9 +7300,9 @@ } }, "node_modules/scheduler": { - "version": "0.26.0", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz", - "integrity": "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA==", + "version": "0.25.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.25.0.tgz", + "integrity": "sha512-xFVuu11jh+xcO7JOAGJNOXld8/TcEHK/4CituBUeUb5hqxJLj9YuemAEuvm9gQ/+pgXYfbQuqAkiYu+u7YEsNA==", "license": "MIT" }, "node_modules/semver": { @@ -5808,9 +7316,9 @@ } }, "node_modules/set-cookie-parser": { - "version": "2.7.1", - "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.1.tgz", - "integrity": "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==", + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", "license": "MIT" }, "node_modules/shebang-command": { @@ -5834,6 +7342,17 @@ "node": ">=8" } }, + "node_modules/snake-case": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/snake-case/-/snake-case-3.0.4.tgz", + "integrity": "sha512-LAOh4z89bGQvl9pFfNF8V146i7o7/CqFPbqzYgP+yYzDIDeS9HaNFtXABamRW+AQzEVODcvE79ljJ+8a9YSdMg==", + "dev": true, + "license": "MIT", + "dependencies": { + "dot-case": "^3.0.4", + "tslib": "^2.0.3" + } + }, "node_modules/source-map": { "version": "0.5.7", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", @@ -5888,6 +7407,21 @@ "integrity": "sha512-hNKz8phvYLPEcRkeG1rsGmV5ChMjKDAWU7/OJJdDErPBNChQXxCo3WZurGpnWc6gZhAzEPFad1aVgyOANH1sMw==", "license": "MIT" }, + "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==", + "dev": true, + "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/stringify-entities": { "version": "4.0.4", "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", @@ -5902,6 +7436,19 @@ "url": "https://github.com/sponsors/wooorm" } }, + "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==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/strip-json-comments": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", @@ -5916,18 +7463,18 @@ } }, "node_modules/style-to-js": { - "version": "1.1.16", - "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.16.tgz", - "integrity": "sha512-/Q6ld50hKYPH3d/r6nr117TZkHR0w0kGGIVfpG9N6D8NymRPM9RqCUv4pRpJ62E5DqOYx2AFpbZMyCPnjQCnOw==", + "version": "1.1.18", + "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.18.tgz", + "integrity": "sha512-JFPn62D4kJaPTnhFUI244MThx+FEGbi+9dw1b9yBBQ+1CZpV7QAT8kUtJ7b7EUNdHajjF/0x8fT+16oLJoojLg==", "license": "MIT", "dependencies": { - "style-to-object": "1.0.8" + "style-to-object": "1.0.11" } }, "node_modules/style-to-object": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.8.tgz", - "integrity": "sha512-xT47I/Eo0rwJmaXC4oilDGDWLohVhR6o/xAQcPQN8q6QBuZVL8qMYL85kLmST5cPjAorwvqIA4qXTRQoYHaL6g==", + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.11.tgz", + "integrity": "sha512-5A560JmXr7wDyGLK12Nq/EYS38VkGlglVzkis1JEdbGWSnbQIEhZzTJhzURXN5/8WwwFCs/f/VVcmkTppbXLow==", "license": "MIT", "dependencies": { "inline-style-parser": "0.2.4" @@ -5973,36 +7520,30 @@ "react": ">=17.0" } }, + "node_modules/svg-parser": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/svg-parser/-/svg-parser-2.0.4.tgz", + "integrity": "sha512-e4hG1hRwoOdRb37cIMSgzNsxyzKfayW6VOflrwvR+/bzrkyxY/31WkbgnQpgtrNp1SdpJvpUAGTa/ZoiPNDuRQ==", + "dev": true, + "license": "MIT" + }, "node_modules/tailwindcss": { - "version": "4.1.8", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.8.tgz", - "integrity": "sha512-kjeW8gjdxasbmFKpVGrGd5T4i40mV5J2Rasw48QARfYeQ8YS9x02ON9SFWax3Qf616rt4Cp3nVNIj6Hd1mP3og==", + "version": "4.1.16", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.16.tgz", + "integrity": "sha512-pONL5awpaQX4LN5eiv7moSiSPd/DLDzKVRJz8Q9PgzmAdd1R4307GQS2ZpfiN7ZmekdQrfhZZiSE5jkLR4WNaA==", "license": "MIT" }, "node_modules/tapable": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.2.tgz", - "integrity": "sha512-Re10+NauLTMCudc7T5WLFLAwDhQ0JWdrMK+9B2M8zR5hRExKmsRDCBA7/aV/pNJFltmBFO5BAMlQFi/vq3nKOg==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", + "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", "license": "MIT", "engines": { "node": ">=6" - } - }, - "node_modules/tar": { - "version": "7.4.3", - "resolved": "https://registry.npmjs.org/tar/-/tar-7.4.3.tgz", - "integrity": "sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw==", - "license": "ISC", - "dependencies": { - "@isaacs/fs-minipass": "^4.0.0", - "chownr": "^3.0.0", - "minipass": "^7.1.2", - "minizlib": "^3.0.1", - "mkdirp": "^3.0.1", - "yallist": "^5.0.0" }, - "engines": { - "node": ">=18" + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" } }, "node_modules/three": { @@ -6049,14 +7590,23 @@ "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", "license": "MIT" }, + "node_modules/tiny-lru": { + "version": "11.4.5", + "resolved": "https://registry.npmjs.org/tiny-lru/-/tiny-lru-11.4.5.tgz", + "integrity": "sha512-hkcz3FjNJfKXjV4mjQ1OrXSLAehg8Hw+cEZclOVT+5c/cWQWImQ9wolzTjth+dmmDe++p3bme3fTxz6Q4Etsqw==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, "node_modules/tinyglobby": { - "version": "0.2.14", - "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz", - "integrity": "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ==", + "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", "license": "MIT", "dependencies": { - "fdir": "^6.4.4", - "picomatch": "^4.0.2" + "fdir": "^6.5.0", + "picomatch": "^4.0.3" }, "engines": { "node": ">=12.0.0" @@ -6065,6 +7615,35 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -6145,6 +7724,7 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "devOptional": true, "license": "0BSD" }, "node_modules/tunnel-rat": { @@ -6212,15 +7792,16 @@ } }, "node_modules/typescript-eslint": { - "version": "8.33.0", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.33.0.tgz", - "integrity": "sha512-5YmNhF24ylCsvdNW2oJwMzTbaeO4bg90KeGtMjUw0AGtHksgEPLRTUil+coHwCfiu4QjVJFnjp94DmU6zV7DhQ==", + "version": "8.46.2", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.46.2.tgz", + "integrity": "sha512-vbw8bOmiuYNdzzV3lsiWv6sRwjyuKJMQqWulBOU7M0RrxedXledX8G8kBbQeiOYDnTfiXz0Y4081E1QMNB6iQg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.33.0", - "@typescript-eslint/parser": "8.33.0", - "@typescript-eslint/utils": "8.33.0" + "@typescript-eslint/eslint-plugin": "8.46.2", + "@typescript-eslint/parser": "8.46.2", + "@typescript-eslint/typescript-estree": "8.46.2", + "@typescript-eslint/utils": "8.46.2" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -6231,13 +7812,13 @@ }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <5.9.0" + "typescript": ">=4.8.4 <6.0.0" } }, "node_modules/typo-js": { - "version": "1.2.5", - "resolved": "https://registry.npmjs.org/typo-js/-/typo-js-1.2.5.tgz", - "integrity": "sha512-F45vFWdGX8xahIk/sOp79z2NJs8ETMYsmMChm9D5Hlx3+9j7VnCyQyvij5MOCrNY3NNe8noSyokRjQRfq+Bc7A==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/typo-js/-/typo-js-1.3.1.tgz", + "integrity": "sha512-elJkpCL6Z77Ghw0Lv0lGnhBAjSTOQ5FhiVOCfOuxhaoTT2xtLVbqikYItK5HHchzPbHEUFAcjOH669T2ZzeCbg==", "license": "BSD-3-Clause" }, "node_modules/undici-types": { @@ -6267,9 +7848,9 @@ } }, "node_modules/unist-util-is": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.0.tgz", - "integrity": "sha512-2qCTHimwdxLfz+YzdGfkqNlH0tLi9xjTnHddPmJwtIG9MGsdbutfTc4P+haPD7l7Cjxf/WZj+we5qfVPvvxfYw==", + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", "license": "MIT", "dependencies": { "@types/unist": "^3.0.0" @@ -6321,9 +7902,9 @@ } }, "node_modules/unist-util-visit-parents": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.1.tgz", - "integrity": "sha512-L/PqWzfTP9lzzEa6CKs0k2nARxTdZduw3zyh8d2NVBnsyvHjSX4TWse388YrrQKbvI8w20fGjGlhgT96WwKykw==", + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", "license": "MIT", "dependencies": { "@types/unist": "^3.0.0", @@ -6335,9 +7916,9 @@ } }, "node_modules/update-browserslist-db": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz", - "integrity": "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.4.tgz", + "integrity": "sha512-q0SPT4xyU84saUX+tomz1WLkxUbuaJnR1xWt17M7fJtEJigJeWUNGUqrauFXsHnqev9y9JTRGwk13tFBuKby4A==", "dev": true, "funding": [ { @@ -6376,9 +7957,9 @@ } }, "node_modules/use-sync-external-store": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.5.0.tgz", - "integrity": "sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A==", + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", "license": "MIT", "peerDependencies": { "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" @@ -6409,6 +7990,19 @@ "node": ">= 4" } }, + "node_modules/uuid": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz", + "integrity": "sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w==", + "funding": [ + "https://github.com/sponsors/broofa", + "https://github.com/sponsors/ctavan" + ], + "license": "MIT", + "bin": { + "uuid": "dist-node/bin/uuid" + } + }, "node_modules/vfile": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", @@ -6438,9 +8032,9 @@ } }, "node_modules/vfile-message": { - "version": "4.0.2", - "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.2.tgz", - "integrity": "sha512-jRDZ1IMLttGj41KcZvlrYAaI3CfqpLpfpf+Mfig13viT6NKvRzWZ+lXz0Y5D60w6uJIBAOGq9mSHf0gktF0duw==", + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", "license": "MIT", "dependencies": { "@types/unist": "^3.0.0", @@ -6452,9 +8046,9 @@ } }, "node_modules/vite": { - "version": "6.3.5", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.3.5.tgz", - "integrity": "sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ==", + "version": "6.4.1", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", + "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", "license": "MIT", "dependencies": { "esbuild": "^0.25.0", @@ -6525,6 +8119,50 @@ } } }, + "node_modules/vite-plugin-svgr": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/vite-plugin-svgr/-/vite-plugin-svgr-4.5.0.tgz", + "integrity": "sha512-W+uoSpmVkSmNOGPSsDCWVW/DDAyv+9fap9AZXBvWiQqrboJ08j2vh0tFxTD/LjwqwAd3yYSVJgm54S/1GhbdnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rollup/pluginutils": "^5.2.0", + "@svgr/core": "^8.1.0", + "@svgr/plugin-jsx": "^8.1.0" + }, + "peerDependencies": { + "vite": ">=2.6.0" + } + }, + "node_modules/vite/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/vite/node_modules/picomatch": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, "node_modules/web-namespaces": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz", @@ -6577,19 +8215,74 @@ "node": ">=0.10.0" } }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, "node_modules/xml-utils": { "version": "1.10.2", "resolved": "https://registry.npmjs.org/xml-utils/-/xml-utils-1.10.2.tgz", "integrity": "sha512-RqM+2o1RYs6T8+3DzDSoTRAUfrvaejbVHcp3+thnAtDKo8LskR+HomLajEy5UjTz24rpka7AxVBRR3g2wTUkJA==", "license": "CC0-1.0" }, - "node_modules/yallist": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", - "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", - "license": "BlueOak-1.0.0", + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", "engines": { - "node": ">=18" + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "cliui": "^8.0.1", + "escalade": "^3.1.1", + "get-caller-file": "^2.0.5", + "require-directory": "^2.1.1", + "string-width": "^4.2.3", + "y18n": "^5.0.5", + "yargs-parser": "^21.1.1" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" } }, "node_modules/yocto-queue": { @@ -6612,9 +8305,9 @@ "license": "MIT AND BSD-3-Clause" }, "node_modules/zustand": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.5.tgz", - "integrity": "sha512-mILtRfKW9xM47hqxGIxCv12gXusoY/xTSHBYApXozR0HmQv299whhBeeAcRy+KrPPybzosvJBCOmVjq6x12fCg==", + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.8.tgz", + "integrity": "sha512-gyPKpIaxY9XcO2vSMrLbiER7QMAMGOQZVRdJ6Zi782jkbzZygq5GI9nG8g+sMgitRtndwaBSl7uiqC49o1SSiw==", "license": "MIT", "engines": { "node": ">=12.20.0" diff --git a/package.json b/package.json index 19c708f..bf6a1b5 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,9 @@ { "name": "white-nights", "private": true, - "version": "0.0.0", + "version": "1.0.8", "type": "module", + "license": "UNLICENSED", "scripts": { "dev": "vite", "build": "tsc -b && vite build", @@ -17,34 +18,38 @@ "@mui/material": "^7.1.0", "@mui/x-data-grid": "^8.5.1", "@photo-sphere-viewer/core": "^5.13.2", - "@pixi/react": "^8.0.2", "@react-three/drei": "^10.1.2", "@react-three/fiber": "^9.1.2", "@tailwindcss/vite": "^4.1.8", "axios": "^1.9.0", "easymde": "^2.20.0", + "i18n-iso-countries": "^7.14.0", + "lodash": "^4.18.1", "lucide-react": "^0.511.0", "mobx": "^6.13.7", "mobx-react-lite": "^4.1.0", "ol": "^10.5.0", + "overlayscrollbars": "^2.15.1", + "overlayscrollbars-react": "^0.5.6", "path": "^0.12.7", - "pixi.js": "^8.10.1", "react": "^19.1.0", - "react-colorful": "^5.6.1", "react-dom": "^19.1.0", - "react-dropzone": "^14.3.8", "react-markdown": "^10.1.0", "react-photo-sphere-viewer": "^6.2.3", + "react-router": "^7.9.4", "react-router-dom": "^7.6.1", "react-simplemde-editor": "^5.2.0", "react-toastify": "^11.0.5", + "react-transition-group": "^4.4.5", "rehype-raw": "^7.0.0", "tailwindcss": "^4.1.8", - "three": "^0.177.0" + "three": "^0.177.0", + "uuid": "^13.0.0" }, "devDependencies": { "@eslint/js": "^9.25.0", "@tailwindcss/typography": "^0.5.16", + "@types/lodash": "^4.17.24", "@types/node": "^22.15.24", "@types/react": "^19.1.2", "@types/react-dom": "^19.1.2", @@ -53,9 +58,11 @@ "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.4.19", "globals": "^16.0.0", + "rollup-plugin-visualizer": "^6.0.5", "typescript": "~5.8.3", "typescript-eslint": "^8.30.1", - "vite": "^6.3.5" + "vite": "^6.3.5", + "vite-plugin-svgr": "^4.5.0" }, "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e" } diff --git a/public/Emblem.svg b/public/Emblem.svg deleted file mode 100644 index 724f0ee..0000000 --- a/public/Emblem.svg +++ /dev/null @@ -1,373 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/public/GET.png b/public/GET.png deleted file mode 100644 index d224f3f..0000000 Binary files a/public/GET.png and /dev/null differ diff --git a/public/Roboto.ttf b/public/Roboto.ttf new file mode 100644 index 0000000..175c7b7 Binary files /dev/null and b/public/Roboto.ttf differ diff --git a/public/SightIcon.png b/public/SightIcon.png deleted file mode 100644 index 02e8adb..0000000 Binary files a/public/SightIcon.png and /dev/null differ diff --git a/public/favicon_ship.png b/public/favicon_ship.png deleted file mode 100644 index 3487dd6..0000000 Binary files a/public/favicon_ship.png and /dev/null differ diff --git a/public/favicon_ship.svg b/public/favicon_ship.svg new file mode 100644 index 0000000..8bbabba --- /dev/null +++ b/public/favicon_ship.svg @@ -0,0 +1,3 @@ + + + diff --git a/public/loader.gif b/public/loader.gif new file mode 100644 index 0000000..0d951ad Binary files /dev/null and b/public/loader.gif differ diff --git a/public/side-menu-photo.png b/public/side-menu-photo.png new file mode 100644 index 0000000..a71d739 Binary files /dev/null and b/public/side-menu-photo.png differ diff --git a/public/sight_icon.svg b/public/sight_icon.svg new file mode 100644 index 0000000..0d95d77 --- /dev/null +++ b/public/sight_icon.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/public/to_video.mp4 b/public/to_video.mp4 new file mode 100644 index 0000000..1ac287a Binary files /dev/null and b/public/to_video.mp4 differ diff --git a/public/vite.svg b/public/vite.svg deleted file mode 100644 index e7b8dfb..0000000 --- a/public/vite.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/app/index.tsx b/src/app/index.tsx index 89e157d..67356e5 100644 --- a/src/app/index.tsx +++ b/src/app/index.tsx @@ -1,16 +1,25 @@ import * as React from "react"; import { Router } from "./router"; -import { CustomTheme } from "@shared"; +import { CustomTheme, languageStore } from "@shared"; import { ThemeProvider } from "@mui/material/styles"; import { ToastContainer } from "react-toastify"; import { GlobalErrorBoundary } from "./GlobalErrorBoundary"; +import { TestingModeBanner } from "@widgets"; +import { observer } from "mobx-react-lite"; -export const App: React.FC = () => ( - - - - - - -); +export const App: React.FC = observer(() => { + React.useEffect(() => { + document.documentElement.lang = languageStore.language; + }, [languageStore.language]); + + return ( + + + + + + + + ); +}); diff --git a/src/app/router/index.tsx b/src/app/router/index.tsx index 6082418..a287045 100644 --- a/src/app/router/index.tsx +++ b/src/app/router/index.tsx @@ -16,33 +16,35 @@ import { SnapshotListPage, CarrierListPage, StationListPage, - // VehicleListPage, ArticleListPage, - - // CountryPreviewPage, - // VehiclePreviewPage, - // CarrierPreviewPage, SnapshotCreatePage, CountryCreatePage, CityCreatePage, CarrierCreatePage, VehicleCreatePage, + VehicleEditPage, CountryEditPage, CityEditPage, UserCreatePage, UserEditPage, - // VehicleEditPage, CarrierEditPage, StationCreatePage, StationPreviewPage, StationEditPage, RouteCreatePage, RoutePreview, + DemoPage, RouteEditPage, ArticlePreviewPage, CountryAddPage, } from "@pages"; -import { authStore, createSightStore, editSightStore } from "@shared"; +import { + authStore, + createSightStore, + editSightStore, + languageStore, + ROUTE_REQUIRED_RESOURCES, +} from "@shared"; import { Layout } from "@widgets"; import { runInAction } from "mobx"; import React, { useEffect } from "react"; @@ -53,11 +55,14 @@ import { Navigate, Outlet, useLocation, + useMatches, } from "react-router-dom"; const PublicRoute = ({ children }: { children: React.ReactNode }) => { const { isAuthenticated } = authStore; - if (isAuthenticated) { + const need_auth = import.meta.env.VITE_NEED_AUTH == "true"; + + if (isAuthenticated || !need_auth) { return ; } return <>{children}; @@ -65,17 +70,37 @@ const PublicRoute = ({ children }: { children: React.ReactNode }) => { const ProtectedRoute = ({ children }: { children: React.ReactNode }) => { const { isAuthenticated } = authStore; + const need_auth = import.meta.env.VITE_NEED_AUTH == "true"; + const location = useLocation(); - if (!isAuthenticated) { + const matches = useMatches(); + + if (!isAuthenticated && need_auth) { return ; } - if (location.pathname === "/") { + + if (location.pathname === "/" && authStore.canRead("map")) { return ; } + + const lastMatch = matches[matches.length - 1] as + | { handle?: { permissions?: string[] } } + | undefined; + const requiredPermissions = lastMatch?.handle?.permissions ?? []; + + if ( + requiredPermissions.length > 0 && + !requiredPermissions.every((permission) => authStore.canAccess(permission)) + ) { + if (location.pathname === "/devices" && authStore.hasRole("devices_maintenance_rw")) { + return <>{children}; + } + return ; + } + return <>{children}; }; -// Чтобы очистка сторов происходила при смене локации const ClearStoresWrapper: React.FC<{ children: React.ReactNode }> = ({ children, }) => { @@ -87,6 +112,14 @@ const ClearStoresWrapper: React.FC<{ children: React.ReactNode }> = ({ runInAction(() => { editSightStore.hasLoadedCommon = false; }); + + if ( + location.pathname.includes("create") || + location.pathname.includes("edit") || + location.pathname.includes("add") + ) { + languageStore.setLanguage("ru"); + } }, [location]); return <>{children}; @@ -101,7 +134,14 @@ const router = createBrowserRouter([ ), }, - { path: "route-preview/:id", element: }, + { + path: "route-preview/:id", + element: , + }, + { + path: "demo/:id", + element: , + }, { path: "/", element: ( @@ -114,67 +154,258 @@ const router = createBrowserRouter([ ), children: [ - { index: true, element: }, + { + index: true, + element: , + handle: { + permissions: ROUTE_REQUIRED_RESOURCES["/"], + }, + }, - // Sight - { path: "sight", element: }, - { path: "sight/create", element: }, - { path: "sight/:id/edit", element: }, + { + path: "sight", + element: , + handle: { + permissions: ROUTE_REQUIRED_RESOURCES["/sight"], + }, + }, + { + path: "sight/create", + element: , + handle: { + permissions: ROUTE_REQUIRED_RESOURCES["/sight/create"], + }, + }, + { + path: "sight/:id/edit", + element: , + handle: { + permissions: ROUTE_REQUIRED_RESOURCES["/sight/:id/edit"], + }, + }, - // Device - { path: "devices", element: }, + { + path: "devices", + element: , + handle: { + permissions: ROUTE_REQUIRED_RESOURCES["/devices"], + }, + }, - // Map - { path: "map", element: }, + { + path: "map", + element: , + handle: { + permissions: ROUTE_REQUIRED_RESOURCES["/map"], + }, + }, - // Media - { path: "media", element: }, - { path: "media/:id", element: }, - { path: "media/:id/edit", element: }, + { + path: "media", + element: , + handle: { + permissions: ROUTE_REQUIRED_RESOURCES["/media"], + }, + }, + { + path: "media/:id", + element: , + handle: { + permissions: ROUTE_REQUIRED_RESOURCES["/media/:id"], + }, + }, + { + path: "media/:id/edit", + element: , + handle: { + permissions: ROUTE_REQUIRED_RESOURCES["/media/:id/edit"], + }, + }, - // Country - { path: "country", element: }, - { path: "country/create", element: }, - { path: "country/add", element: }, - // { path: "country/:id", element: }, - { path: "country/:id/edit", element: }, - // City - { path: "city", element: }, - { path: "city/create", element: }, - // { path: "city/:id", element: }, - { path: "city/:id/edit", element: }, - // Route - { path: "route", element: }, - { path: "route/create", element: }, - { path: "route/:id/edit", element: }, + { + path: "country", + element: , + handle: { + permissions: ROUTE_REQUIRED_RESOURCES["/country"], + }, + }, + { + path: "country/create", + element: , + handle: { + permissions: ROUTE_REQUIRED_RESOURCES["/country/create"], + }, + }, + { + path: "country/add", + element: , + handle: { + permissions: ROUTE_REQUIRED_RESOURCES["/country/add"], + }, + }, + { + path: "country/:id/edit", + element: , + handle: { + permissions: ROUTE_REQUIRED_RESOURCES["/country/:id/edit"], + }, + }, + { + path: "city", + element: , + handle: { + permissions: ROUTE_REQUIRED_RESOURCES["/city"], + }, + }, + { + path: "city/create", + element: , + handle: { + permissions: ROUTE_REQUIRED_RESOURCES["/city/create"], + }, + }, + { + path: "city/:id/edit", + element: , + handle: { + permissions: ROUTE_REQUIRED_RESOURCES["/city/:id/edit"], + }, + }, + { + path: "route", + element: , + handle: { + permissions: ROUTE_REQUIRED_RESOURCES["/route"], + }, + }, + { + path: "route/create", + element: , + handle: { + permissions: ROUTE_REQUIRED_RESOURCES["/route/create"], + }, + }, + { + path: "route/:id/edit", + element: , + handle: { + permissions: ROUTE_REQUIRED_RESOURCES["/route/:id/edit"], + }, + }, - // User - { path: "user", element: }, - { path: "user/create", element: }, - { path: "user/:id/edit", element: }, - // Snapshot - { path: "snapshot", element: }, - { path: "snapshot/create", element: }, + { + path: "user", + element: , + handle: { + permissions: ROUTE_REQUIRED_RESOURCES["/user"], + }, + }, + { + path: "user/create", + element: , + handle: { + permissions: ROUTE_REQUIRED_RESOURCES["/user/create"], + }, + }, + { + path: "user/:id/edit", + element: , + handle: { + permissions: ROUTE_REQUIRED_RESOURCES["/user/:id/edit"], + }, + }, + { + path: "snapshot", + element: , + handle: { + permissions: ROUTE_REQUIRED_RESOURCES["/snapshot"], + }, + }, + { + path: "snapshot/create", + element: , + handle: { + permissions: ROUTE_REQUIRED_RESOURCES["/snapshot/create"], + }, + }, - // Carrier - { path: "carrier", element: }, - { path: "carrier/create", element: }, - // { path: "carrier/:id", element: }, - { path: "carrier/:id/edit", element: }, - // Station - { path: "station", element: }, - { path: "station/create", element: }, - { path: "station/:id", element: }, - { path: "station/:id/edit", element: }, - // Vehicle - // { path: "vehicle", element: }, - { path: "vehicle/create", element: }, - // { path: "vehicle/:id", element: }, - // { path: "vehicle/:id/edit", element: }, - // Article - { path: "article", element: }, - { path: "article/:id", element: }, - // { path: "media/create", element: }, + { + path: "carrier", + element: , + handle: { + permissions: ROUTE_REQUIRED_RESOURCES["/carrier"], + }, + }, + { + path: "carrier/create", + element: , + handle: { + permissions: ROUTE_REQUIRED_RESOURCES["/carrier/create"], + }, + }, + { + path: "carrier/:id/edit", + element: , + handle: { + permissions: ROUTE_REQUIRED_RESOURCES["/carrier/:id/edit"], + }, + }, + { + path: "station", + element: , + handle: { + permissions: ROUTE_REQUIRED_RESOURCES["/station"], + }, + }, + { + path: "station/create", + element: , + handle: { + permissions: ROUTE_REQUIRED_RESOURCES["/station/create"], + }, + }, + { + path: "station/:id", + element: , + handle: { + permissions: ROUTE_REQUIRED_RESOURCES["/station/:id"], + }, + }, + { + path: "station/:id/edit", + element: , + handle: { + permissions: ROUTE_REQUIRED_RESOURCES["/station/:id/edit"], + }, + }, + { + path: "vehicle/create", + element: , + handle: { + permissions: ROUTE_REQUIRED_RESOURCES["/vehicle/create"], + }, + }, + { + path: "vehicle/:id/edit", + element: , + handle: { + permissions: ROUTE_REQUIRED_RESOURCES["/vehicle/:id/edit"], + }, + }, + { + path: "article", + element: , + handle: { + permissions: ROUTE_REQUIRED_RESOURCES["/article"], + }, + }, + { + path: "article/:id", + element: , + handle: { + permissions: ROUTE_REQUIRED_RESOURCES["/article/:id"], + }, + }, ], }, ]); diff --git a/src/assets/react.svg b/src/assets/react.svg deleted file mode 100644 index 6c87de9..0000000 --- a/src/assets/react.svg +++ /dev/null @@ -1 +0,0 @@ - \ No newline at end of file diff --git a/src/client/src/App.css b/src/client/src/App.css new file mode 100644 index 0000000..5fb5553 --- /dev/null +++ b/src/client/src/App.css @@ -0,0 +1,49 @@ +@font-face { + font-family: "Roboto"; + src: url("/Roboto.ttf") format("truetype"); + font-weight: 400 900; + font-style: normal; +} + +.client-app-scoped * { + color: white; + margin: 0px; + padding: 0px; + box-sizing: border-box; +} + +.client-app-scoped { + font-family: "Roboto", sans-serif; + background-color: black; + width: 100vw; + height: 100vh; + overflow: hidden; + position: fixed; + inset: 0; +} + +.app-root { + position: relative; +} + +.widget-layer { + position: absolute; + display: inline-flex; + left: 0; + top: 0; + z-index: 10; + pointer-events: none; +} + +.maintenance-overlay { + position: fixed; + inset: 0; + z-index: 2147483647; + background: #000; +} + +.maintenance-video { + width: 100vw; + height: 100vh; + object-fit: cover; +} diff --git a/src/client/src/App.d.ts b/src/client/src/App.d.ts new file mode 100644 index 0000000..7c021e6 --- /dev/null +++ b/src/client/src/App.d.ts @@ -0,0 +1,4 @@ +import React from "react"; + +declare const App: React.FC; +export default App; diff --git a/src/client/src/App.jsx b/src/client/src/App.jsx new file mode 100644 index 0000000..af597f2 --- /dev/null +++ b/src/client/src/App.jsx @@ -0,0 +1,239 @@ +import "./App.css"; +import SideMenu from "./components/side-menu/SideMenu"; +import Loader from "./components/Loader"; +import { Map } from "./components/map/Map"; +import ListOfSights from "./components/ListOfSights"; +import VideoPreviewWidget from "./components/widgets/VideoPreviewWidget"; +import StoreDebugInfo from "./components/StoreDebugInfo"; +import { SimulationSettings } from "./components/SimulationSettings"; +import { observer } from "mobx-react-lite"; + +import { apiStore } from "./api/ApiStore/store"; +import { useCallback, useEffect, useState } from "react"; +import { useGeolocationStore } from "./stores/hooks/useGeolocationStore"; +import { geolocationStore } from "./stores/GeolocationStore"; + +const App = observer(() => { + const [isDebugVisible, setIsDebugVisible] = useState(false); + const [isMenuOpen, setIsMenuOpen] = useState(false); + const store = useGeolocationStore(); + const { setSelectedSightId } = store; + const { + isLoading, + loadingStatus, + loadingProgress, + context, + carrier, + setIsLoading, + setLoadingStatus, + setLoadingProgress, + getRouteSights, + getRouteStations, + getAllSightArticles, + getRoute, + getCarrier, + getCity, + preloadSightStationsAndStationSights, + startPositionSimulation, + stopPositionSimulation, + } = apiStore; + + const handleGetAll = useCallback(async () => { + const totalSteps = 7; + let currentStep = 0; + + try { + setIsLoading(true); + + currentStep = 1; + setLoadingStatus("Загрузка маршрута..."); + setLoadingProgress(currentStep, totalSteps); + await getRoute(); + + currentStep = 2; + setLoadingStatus("Загрузка перевозчика..."); + setLoadingProgress(currentStep, totalSteps); + await getCarrier(); + + currentStep = 3; + setLoadingStatus("Загрузка города..."); + setLoadingProgress(currentStep, totalSteps); + await getCity(); + + currentStep = 4; + setLoadingStatus("Загрузка достопримечательностей..."); + setLoadingProgress(currentStep, totalSteps); + await getRouteSights(); + + currentStep = 5; + setLoadingStatus("Загрузка остановок маршрута..."); + setLoadingProgress(currentStep, totalSteps); + await getRouteStations(); + + currentStep = 6; + setLoadingStatus("Загрузка связанных данных..."); + setLoadingProgress(currentStep, totalSteps); + await preloadSightStationsAndStationSights(); + + currentStep = 7; + setLoadingStatus("Загрузка статей..."); + setLoadingProgress(currentStep, totalSteps); + await getAllSightArticles(); + + startPositionSimulation(); + } catch (error) { + console.error("Ошибка полной загрузки приложения:", error); + } finally { + setIsLoading(false); + setLoadingStatus(null); + setLoadingProgress(null); + } + }, []); + + useEffect(() => { + const handleKeyDown = (event) => { + if (event.key === "F4") { + event.preventDefault(); + setIsDebugVisible((prev) => !prev); + } + }; + + window.addEventListener("keydown", handleKeyDown); + return () => { + window.removeEventListener("keydown", handleKeyDown); + stopPositionSimulation(); + }; + }, []); + + useEffect(() => { + handleGetAll(); + }, []); + + // Синхронизация currentStationId с симуляцией для корректной раскраски станций + useEffect(() => { + const nearestStationId = context?.nearestStationId; + if (nearestStationId) { + geolocationStore.setCurrentStationId(nearestStationId); + } + }, [context?.nearestStationId]); + + useEffect(() => { + let idleSeconds = 0; + + const checkIdle = () => { + idleSeconds++; + + if (idleSeconds === 60) { + if (geolocationStore.isLeftWidgetOpen) { + geolocationStore.setIsLeftWidgetOpen(false); + } + if (geolocationStore.isTransferWidgetOpen) { + geolocationStore.setIsTransferWidgetOpen(false); + } + } + + if (idleSeconds === 120) { + if (context?.nearestSightId) { + setSelectedSightId(context.nearestSightId); + } + } + }; + + const intervalId = setInterval(checkIdle, 1000); + + const resetIdle = () => { + idleSeconds = 0; + }; + + const events = [ + "mousedown", + "mousemove", + "keypress", + "scroll", + "touchstart", + "click", + ]; + + events.forEach((event) => { + window.addEventListener(event, resetIdle, { passive: true }); + }); + + return () => { + clearInterval(intervalId); + events.forEach((event) => { + window.removeEventListener(event, resetIdle); + }); + }; + }, []); + + return ( +
+ {isLoading ? ( + setIsDebugVisible((prev) => !prev)} + videoId={carrier?.video_preview} + /> + ) : ( +
+ + + + + + + + + + + + + + + +
+ +
+ + +
+ )} + +
+ ); +}); + +export default App; diff --git a/src/client/src/api/ApiStore/api.ts b/src/client/src/api/ApiStore/api.ts new file mode 100644 index 0000000..ad6944f --- /dev/null +++ b/src/client/src/api/ApiStore/api.ts @@ -0,0 +1,96 @@ +import { apiInstance } from "../apiConfig"; +import { + GetRouteSightsResponse, + GetRouteStationsResponse, + GetMediaResponse, + GetRouteResponse, + GetCarrierResponse, + GetCityResponse, + GetSightArticleResponse, + GetArticleResponse, +} from "./types"; + +export const getRoute = async (routeId: string): Promise => { + const response = await apiInstance.get(`/route/${routeId}`); + return response.data; +}; + +export const getCarrier = async ( + carrierId: number, +): Promise => { + const response = await apiInstance.get(`/carrier/${carrierId}`); + return response.data; +}; + +export const getCity = async (cityId: number): Promise => { + const response = await apiInstance.get(`/city/${cityId}`); + return response.data; +}; + +export const getRouteSights = async ( + routeId: string, + lang: string = "ru", +): Promise => { + const response = await apiInstance.get( + `/route/${routeId}/sight?lang=${lang}`, + ); + return response.data; +}; + +export const getRouteStations = async ( + routeId: string, + lang: string = "ru", +): Promise => { + const response = await apiInstance.get( + `/route/${routeId}/station?lang=${lang}`, + ); + return response.data; +}; + +export const getArticleMedia = async ( + articleId: number, +): Promise => { + const response = await apiInstance.get(`/article/${articleId}/media`); + return response.data; +}; + +export const getMedia = async (): Promise => { + const response = await apiInstance.get("/media"); + return response.data; +}; + +export const getArticle = async ( + articleId: number, + lang: string = "ru", +): Promise => { + const response = await apiInstance.get(`/article/${articleId}?lang=${lang}`); + return response.data; +}; + +export const getSightArticlesIds = async ( + sightId: number, +): Promise => { + const response = await apiInstance.get(`/sight/${sightId}/article`); + const articles = response.data; + return articles.map((article: GetSightArticleResponse) => article.id); +}; + +export const getSightStations = async ( + sightId: number, + lang: string = "ru", +): Promise => { + const response = await apiInstance.get( + `/sight/${sightId}/station?lang=${lang}`, + ); + return response.data; +}; + +export const getStationSights = async ( + stationId: number, + lang: string = "ru", +): Promise => { + const response = await apiInstance.get( + `/station/${stationId}/sight?lang=${lang}`, + ); + return response.data; +}; diff --git a/src/client/src/api/ApiStore/index.ts b/src/client/src/api/ApiStore/index.ts new file mode 100644 index 0000000..3c20093 --- /dev/null +++ b/src/client/src/api/ApiStore/index.ts @@ -0,0 +1,8 @@ +export { apiStore } from "./store"; +export type { + GetContextResponse, + GetWeatherResponse, + GetRouteSightsResponse, + GetRouteStationsResponse, + GetMediaResponse, +} from "./types"; diff --git a/src/client/src/api/ApiStore/store.ts b/src/client/src/api/ApiStore/store.ts new file mode 100644 index 0000000..1b75404 --- /dev/null +++ b/src/client/src/api/ApiStore/store.ts @@ -0,0 +1,498 @@ +import { makeAutoObservable, runInAction } from "mobx"; +import { + getRouteSights, + getRouteStations, + getRoute, + getCarrier, + getCity, + getArticle, + getArticleMedia, + getSightStations, + getStationSights, + getSightArticlesIds, +} from "./api"; +import { + GetContextResponse, + GetRouteSightsResponse, + GetRouteStationsResponse, + GetMediaResponse, + GetRouteResponse, + GetCarrierResponse, + GetCityResponse, + GetSightArticleResponse, +} from "./types"; +// @ts-ignore +import { orderStationsByRoute } from "../../utils/routeStationsUtils"; +import { resamplePath } from "../../utils/animationUtils"; +import { colorStore } from "../../stores/ColorStore"; + +function hexToRgbString(hex: string): string | null { + const clean = hex.trim().replace(/^#/, ""); + const full = clean.length === 3 ? clean.split("").map((c) => c + c).join("") : clean; + if (full.length !== 6) return null; + const r = parseInt(full.slice(0, 2), 16); + const g = parseInt(full.slice(2, 4), 16); + const b = parseInt(full.slice(4, 6), 16); + return `${r}, ${g}, ${b}`; +} + +function darkenHex(hex: string, amount: number): string { + const rgb = hexToRgbString(hex); + if (!rgb) return hex; + const [r, g, b] = rgb.split(",").map(Number); + const factor = 1 - amount; + const dr = Math.round(r * factor); + const dg = Math.round(g * factor); + const db = Math.round(b * factor); + return `#${dr.toString(16).padStart(2, "0")}${dg.toString(16).padStart(2, "0")}${db.toString(16).padStart(2, "0")}`; +} + +function lightenRgbString(hex: string, amount: number): string | null { + const rgb = hexToRgbString(hex); + if (!rgb) return null; + const [r, g, b] = rgb.split(",").map(Number); + const lr = Math.round(r + (255 - r) * amount); + const lg = Math.round(g + (255 - g) * amount); + const lb = Math.round(b + (255 - b) * amount); + return `${lr}, ${lg}, ${lb}`; +} + +function applyCarrierColors(carrier: { main_color?: string; left_color?: string; right_color?: string }) { + const mainColor = carrier.main_color || "#006f3a"; + const leftColor = carrier.left_color || "#006f3a"; + const rightColor = carrier.right_color || "#006f3a"; + const mainDark = darkenHex(mainColor, 0.3); + + document.documentElement.style.setProperty("--carrier-main", mainColor); + document.documentElement.style.setProperty("--carrier-main-rgb", hexToRgbString(mainColor) ?? "0, 111, 58"); + document.documentElement.style.setProperty("--carrier-main-dark", mainDark); + document.documentElement.style.setProperty("--carrier-left", leftColor); + document.documentElement.style.setProperty("--carrier-left-rgb", hexToRgbString(leftColor) ?? "0, 111, 58"); + document.documentElement.style.setProperty("--carrier-right", rightColor); + document.documentElement.style.setProperty("--carrier-right-rgb", hexToRgbString(rightColor) ?? "0, 111, 58"); + document.documentElement.style.setProperty("--carrier-right-menu-rgb", lightenRgbString(rightColor, 0.38) ?? "179, 165, 152"); +} + +class ApiStore { + isLoading = true; + loadingStatus: string | null = null; + loadingProgress: { current: number; total: number } | null = null; + + routeId: string | null = null; + + context: GetContextResponse | null = null; + + routeSights: GetRouteSightsResponse | null = null; + routeSightsEn: GetRouteSightsResponse | null = null; + routeSightsZh: GetRouteSightsResponse | null = null; + + routeStations: GetRouteStationsResponse | null = null; + routeStationsEn: GetRouteStationsResponse | null = null; + routeStationsZh: GetRouteStationsResponse | null = null; + orderedRouteStations: GetRouteStationsResponse | null = null; + + sightArticles: Map = new Map(); + sightArticlesEn: Map = new Map(); + sightArticlesZh: Map = new Map(); + + sightArticlesIds: Map = new Map(); + + sightStationsCache: Map = new Map(); + stationSightsCache: Map = new Map(); + + route: GetRouteResponse | null = null; + carrier: GetCarrierResponse | null = null; + city: GetCityResponse | null = null; + + positionIndex = 0; + private positionInterval: ReturnType | null = null; + + simulationSpeed = 1; + simulationDirection: 1 | -1 = 1; + simulationPaused = false; + simulationInstantMove = false; + showHitboxes = false; + + constructor() { + makeAutoObservable(this); + } + + setRouteId = (id: string) => { + this.routeId = id; + }; + + setIsLoading = (isLoading: boolean) => { + this.isLoading = isLoading; + if (!isLoading) { + this.loadingStatus = null; + this.loadingProgress = null; + } + }; + + setLoadingStatus = (status: string | null) => { + this.loadingStatus = status; + }; + + setLoadingProgress = (current: number, total: number) => { + this.loadingProgress = { current, total }; + }; + + getRoute = async () => { + const route = await getRoute(this.routeId!); + if (route.path && route.path.length > 1) { + // Рассчитываем общую дистанцию для выбора адекватного шага ресемплинга + let totalDist = 0; + for (let i = 0; i < route.path.length - 1; i++) { + const p1 = route.path[i]; + const p2 = route.path[i + 1]; + totalDist += Math.sqrt( + Math.pow(p2[0] - p1[0], 2) + Math.pow(p2[1] - p1[1], 2) + ); + } + // Хотим иметь примерно 2000 точек для равномерности и плавности + const segmentLength = totalDist / 2000; + if (segmentLength > 0) { + route.path = resamplePath(route.path as [number, number][], segmentLength); + } + } + runInAction(() => { + this.route = route; + }); + this.updateOrderedRouteStations(); + }; + + getCarrier = async () => { + this.carrier = await getCarrier(this.route!.carrier_id!); + applyCarrierColors(this.carrier); + if (this.carrier.main_color) { + colorStore.setMainColor(this.carrier.main_color); + } + }; + + getCity = async () => { + this.city = await getCity(this.carrier!.city_id!); + }; + + getRouteSights = async () => { + this.routeSights = await getRouteSights(this.routeId!); + this.routeSightsEn = await getRouteSights(this.routeId!, "en"); + this.routeSightsZh = await getRouteSights(this.routeId!, "zh"); + }; + + getRouteStations = async () => { + this.routeStations = await getRouteStations(this.routeId!); + this.routeStationsEn = await getRouteStations(this.routeId!, "en"); + this.routeStationsZh = await getRouteStations(this.routeId!, "zh"); + this.updateOrderedRouteStations(); + }; + + updateOrderedRouteStations = () => { + if ( + !this.routeStations || + !this.route?.path || + !this.context?.startStopId || + !this.context?.endStopId + ) { + runInAction(() => { + this.orderedRouteStations = null; + }); + return; + } + + const ordered = orderStationsByRoute( + this.routeStations, + this.route.path, + this.context.startStopId, + this.context.endStopId + ); + + runInAction(() => { + this.orderedRouteStations = ordered; + }); + }; + + setSimulationSpeed = (speed: number) => { + this.simulationSpeed = Math.max(0.25, Math.min(5, speed)); + if (this.positionInterval) { + clearInterval(this.positionInterval); + const intervalMs = Math.round(3000 / this.simulationSpeed); + this.positionInterval = setInterval(() => { + if (!this.simulationPaused) { + this.updateSimulatedPosition(); + } + }, intervalMs); + } + }; + + setSimulationDirection = (direction: 1 | -1) => { + this.simulationDirection = direction; + }; + + toggleSimulationDirection = () => { + this.simulationDirection = this.simulationDirection === 1 ? -1 : 1; + }; + + setSimulationPaused = (paused: boolean) => { + this.simulationPaused = paused; + }; + + toggleSimulationPaused = () => { + this.simulationPaused = !this.simulationPaused; + }; + + toggleSimulationInstantMove = () => { + this.simulationInstantMove = !this.simulationInstantMove; + }; + + toggleShowHitboxes = () => { + this.showHitboxes = !this.showHitboxes; + }; + + startPositionSimulation = () => { + if (this.positionInterval) return; + + this.updateSimulatedPosition(); + + const intervalMs = Math.round(3000 / this.simulationSpeed); + this.positionInterval = setInterval(() => { + if (!this.simulationPaused) { + this.updateSimulatedPosition(); + } + }, intervalMs); + }; + + stopPositionSimulation = () => { + if (this.positionInterval) { + clearInterval(this.positionInterval); + this.positionInterval = null; + } + }; + + private updateSimulatedPosition = () => { + if (!this.route?.path?.length) return; + + const path = this.route.path; + const step = Math.max(1, Math.floor(path.length / 120)); + this.positionIndex = this.positionIndex + step * this.simulationDirection; + if (this.positionIndex >= path.length) this.positionIndex = 0; + if (this.positionIndex < 0) this.positionIndex = path.length - 1; + + const [lat, lon] = path[this.positionIndex]; + + let nearestStationId = this.context?.nearestStationId ?? null; + if (this.routeStations?.length) { + let minDist = Infinity; + for (const station of this.routeStations) { + const d = + Math.pow(station.latitude - lat, 2) + + Math.pow(station.longitude - lon, 2); + if (d < minDist) { + minDist = d; + nearestStationId = String(station.id); + } + } + } + + let nearestSightId = this.context?.nearestSightId ?? null; + if (this.routeSights?.length) { + let minDist = Infinity; + for (const sight of this.routeSights) { + const d = + Math.pow(sight.latitude - lat, 2) + + Math.pow(sight.longitude - lon, 2); + if (d < minDist) { + minDist = d; + nearestSightId = String(sight.id); + } + } + } + + const percentageCompleted = + (this.positionIndex / path.length) * 100; + + const startStopId = + this.routeStations?.[0] + ? String(this.routeStations[0].id) + : this.context?.startStopId ?? ""; + const endStopId = + this.routeStations?.length + ? String(this.routeStations[this.routeStations.length - 1].id) + : this.context?.endStopId ?? ""; + + runInAction(() => { + this.context = { + routeId: this.routeId!, + routeNumber: this.route?.route_number ?? "", + currentCoordinates: { latitude: lat, longitude: lon }, + rawCoordinates: { latitude: lat, longitude: lon }, + startStopId, + endStopId, + nearestSightId: nearestSightId ?? "", + nearestStationId: nearestStationId ?? undefined, + routeProgress: { + startStopId, + endStopId: nearestStationId ?? endStopId, + percentageCompleted, + }, + maintenanceModeOn: false, + } as GetContextResponse; + + if (!this.orderedRouteStations) { + this.updateOrderedRouteStations(); + } + }); + }; + + private getArticle = async (articleId: number, lang: string) => { + const article = await getArticle(articleId, lang); + + const isArticleExists = this.sightArticles.has( + articleId.toString() + "_" + "ru" + ); + + let media: GetMediaResponse; + if (isArticleExists) { + const ruArticle = this.sightArticles.get( + articleId.toString() + "_" + "ru" + ); + media = ruArticle?.media!; + } else { + media = await getArticleMedia(articleId); + } + + runInAction(() => { + if (lang === "ru") { + this.sightArticles.set(articleId.toString() + "_" + "ru", { + id: articleId, + heading: article.heading, + body: article.body, + media, + }); + } else if (lang === "en") { + this.sightArticlesEn.set(articleId.toString() + "_" + "en", { + id: articleId, + heading: article.heading, + body: article.body, + media, + }); + } else if (lang === "zh") { + this.sightArticlesZh.set(articleId.toString() + "_" + "zh", { + id: articleId, + heading: article.heading, + body: article.body, + media, + }); + } + }); + }; + + private getSightArticles = async (sightId: number) => { + const leftArticle = this.routeSights?.find( + (sight) => sight.id === sightId + )?.left_article; + + const rightArticles = await getSightArticlesIds(sightId); + + runInAction(() => { + this.sightArticlesIds.set(sightId, rightArticles); + }); + + if (leftArticle) { + this.setLoadingStatus(`Загрузка статьи ${leftArticle} (ru)...`); + await this.getArticle(leftArticle, "ru"); + this.setLoadingStatus(`Загрузка статьи ${leftArticle} (en)...`); + await this.getArticle(leftArticle, "en"); + this.setLoadingStatus(`Загрузка статьи ${leftArticle} (zh)...`); + await this.getArticle(leftArticle, "zh"); + } + + for (const article of rightArticles) { + this.setLoadingStatus(`Загрузка статьи ${article} (ru)...`); + await this.getArticle(article, "ru"); + this.setLoadingStatus(`Загрузка статьи ${article} (en)...`); + await this.getArticle(article, "en"); + this.setLoadingStatus(`Загрузка статьи ${article} (zh)...`); + await this.getArticle(article, "zh"); + } + }; + + getAllSightArticles = async () => { + const totalSights = this.routeSights!.length; + let currentSight = 0; + + for (const sight of this.routeSights!) { + currentSight++; + this.setLoadingStatus( + `Загрузка статей для достопримечательности ${currentSight}/${totalSights}...` + ); + this.setLoadingProgress(currentSight, totalSights); + await this.getSightArticles(sight.id); + } + }; + + getSightStations = async (sightId: number, lang: string = "ru") => { + const cacheKey = `${sightId}_${lang}`; + + if (this.sightStationsCache.has(cacheKey)) { + return this.sightStationsCache.get(cacheKey)!; + } + + const stations = await getSightStations(sightId, lang); + + runInAction(() => { + this.sightStationsCache.set(cacheKey, stations); + }); + + return stations; + }; + + getStationSights = async (stationId: number, lang: string = "ru") => { + const cacheKey = `${stationId}_${lang}`; + + if (this.stationSightsCache.has(cacheKey)) { + return this.stationSightsCache.get(cacheKey)!; + } + + const sights = await getStationSights(stationId, lang); + + runInAction(() => { + this.stationSightsCache.set(cacheKey, sights); + }); + + return sights; + }; + + preloadSightStationsAndStationSights = async () => { + if (!this.routeSights || !this.routeStations) return; + + const languages = ["ru", "en", "zh"]; + + for (const sight of this.routeSights) { + for (const lang of languages) { + try { + await this.getSightStations(sight.id, lang); + } catch (error) { + console.warn( + `Failed to preload stations for sight ${sight.id} (${lang}):`, + error + ); + } + } + } + + for (const station of this.routeStations) { + for (const lang of languages) { + try { + await this.getStationSights(station.id, lang); + } catch (error) { + console.warn( + `Failed to preload sights for station ${station.id} (${lang}):`, + error + ); + } + } + } + }; +} + +export const apiStore = new ApiStore(); diff --git a/src/client/src/api/ApiStore/types.ts b/src/client/src/api/ApiStore/types.ts new file mode 100644 index 0000000..6c96efc --- /dev/null +++ b/src/client/src/api/ApiStore/types.ts @@ -0,0 +1,164 @@ +export type GetContextResponse = { + currentCoordinates: { + latitude: number; + longitude: number; + }; + endStopId: string; + nearestSightId: string; + nearestStationId?: string | null; + rawCoordinates: { + latitude: number; + longitude: number; + }; + routeId: string; + route_id?: number | string; + routeNumber: string; + routeProgress: { + startStopId: string; + endStopId: string; + percentageCompleted: number; + }; + startStopId: string; + maintenanceModeOn: boolean; + error?: string | boolean | { message?: string }; + message?: string; + status?: string; +}; + +export type GetContextStationsResponse = { + stationId: string[]; +}; + +export type GetWeatherResponse = { + currentWeather: { + temperatureCelsius: number; + description: string; + humidity?: number; + windSpeed: number; + windDegree: number; + }; + forecast: { + date: string; + description: string; + humidity?: number; + + minTemperatureCelsius: number; + maxTemperatureCelsius: number; + windDegree: number; + windSpeed: number; + }[]; +}; + +export type GetRouteResponse = { + carrier: string; + carrier_id: number; + center_latitude: number; + center_longitude: number; + governor_appeal: number; + button_text?: string; + id: number; + path: [number, number][]; + rotate: number; + route_direction: boolean; + route_number: string; + route_sys_number: string; + scale_max: number; + scale_min: number; + video_preview: string; + video_timer?: number; + icon?: string; + icon_size?: number; + font_size?: number; +}; + +export type GetCarrierResponse = { + city: string; + city_id: number; + full_name: string; + id: number; + logo: string; + preview_media?: string; + video_preview?: string; + short_name: string; + slogan: string; + main_color?: string; + left_color?: string; + right_color?: string; +}; + +export type GetCityResponse = { + arms: string; + country: string; + country_code: string; + id: number; + name: string; +}; + +export type GetRouteSightsResponse = { + id: number; + name: string; + description: string; + city_id: string; + address: string; + latitude: number; + longitude: number; + thumbnail: string; + watermark_lu: string; + watermark_rd: string; + left_article: number; + preview_media: string; + video_preview: string; + icon_size?: number; + icon?: string; + alt_icon?: string; + is_default_icon?: boolean; + short_name?: string; +}[]; + +export type GetRouteStationsResponse = { + id: number; + name: string; + system_name: string; + description: string; + address: string; + latitude: number; + longitude: number; + direction: boolean; + city_id: string; + offset_x: number; + offset_y: number; + align: number; + stick: number; + icon?: string; + transfers: { + tram: string; + trolleybus: string; + bus: string; + train: string; + metro_red: string; + metro_green: string; + metro_blue: string; + metro_purple: string; + metro_orange: string; + }; +}[]; + +export type GetMediaResponse = { + id: string; + filename: string; + media_name: string; + media_type: number; +}; + +export type GetArticleResponse = { + id: number; + heading: string; + body: string; +}; + +export type GetSightArticleResponse = { + id: number; + heading: string; + body: string; + media: GetMediaResponse; +}; diff --git a/src/client/src/api/apiConfig.d.ts b/src/client/src/api/apiConfig.d.ts new file mode 100644 index 0000000..09a2263 --- /dev/null +++ b/src/client/src/api/apiConfig.d.ts @@ -0,0 +1,9 @@ +import { AxiosInstance } from "axios"; + +export declare const apiBaseURL: string; +export declare const geoBaseURL: string; +export declare const weatherBaseURL: string; +export declare const getMediaUrl: (id: string) => string; +export declare const apiInstance: AxiosInstance; +export declare const geoInstance: AxiosInstance; +export declare const weatherInstance: AxiosInstance; diff --git a/src/client/src/api/apiConfig.js b/src/client/src/api/apiConfig.js new file mode 100644 index 0000000..85f0b45 --- /dev/null +++ b/src/client/src/api/apiConfig.js @@ -0,0 +1,37 @@ +import axios from "axios"; + +const envUrl = import.meta.env.VITE_API_URL || "localhost"; +const baseURL = envUrl.includes("://") ? envUrl : `http://${envUrl}`; +const isFullUrl = + baseURL.includes("://") && + !envUrl.includes("localhost") && + !envUrl.includes("127.0.0.1"); + +const addAuthInterceptor = (instance) => { + instance.interceptors.request.use((config) => { + const token = localStorage.getItem("token"); + if (token) { + config.headers.Authorization = `Bearer ${token}`; + } + return config; + }); +}; + +export const apiBaseURL = isFullUrl ? baseURL : `${baseURL}:8080`; +export const geoBaseURL = isFullUrl ? baseURL : `${baseURL}:6001`; +export const weatherBaseURL = isFullUrl ? baseURL : `${baseURL}:6002`; + +export const getMediaUrl = (id) => { + const token = localStorage.getItem("token"); + const base = `${apiBaseURL}/media/${id}/download`; + return token ? `${base}?token=${token}` : base; +}; + +export const apiInstance = axios.create({ baseURL: apiBaseURL }); +addAuthInterceptor(apiInstance); + +export const geoInstance = axios.create({ baseURL: geoBaseURL }); +addAuthInterceptor(geoInstance); + +export const weatherInstance = axios.create({ baseURL: weatherBaseURL }); +addAuthInterceptor(weatherInstance); diff --git a/src/client/src/api/content/content.api.js b/src/client/src/api/content/content.api.js new file mode 100644 index 0000000..cd17c5a --- /dev/null +++ b/src/client/src/api/content/content.api.js @@ -0,0 +1,224 @@ +import { apiInstance, getMediaUrl } from "../apiConfig"; + +const ContentAPI = { + async getStartStation(startStopId, lang = "ru") { + if (!startStopId) { + console.warn("startStopId не указан."); + return null; + } + + try { + const response = await apiInstance.get( + `/station/${startStopId}?lang=${lang}` + ); + return response.data; + } catch (error) { + console.error("Ошибка при запросе:", error.message); + throw error; + } + }, + + async getEndStation(endStopId, lang = "ru") { + if (!endStopId) { + console.warn("endStopId не указан."); + return null; + } + + try { + const response = await apiInstance.get( + `/station/${endStopId}?lang=${lang}` + ); + return response.data; + } catch (error) { + console.error("Ошибка при запросе:", error.message); + throw error; + } + }, + + async getListOfStations(id, lang = "ru") { + try { + const responce = await apiInstance.get( + `/route/${id}/station?lang=${lang}` + ); + + return responce.data; + } catch (error) { + console.error("Ошибка при запросе:", error.message); + throw error; + } + }, + + async getRoutePath(id, lang = "ru") { + try { + const responce = await apiInstance.get(`/route/${id}?lang=${lang}`); + return responce.data; + } catch (error) { + console.error("Ошибка при запросе:", error.message); + throw error; + } + }, + + async getRouteSights(id, lang = "ru") { + try { + const responce = await apiInstance.get( + `/route/${id}/sight?lang=${lang}` + ); + return responce.data; + } catch (error) { + console.error("Ошибка при запросе:", error.message); + throw error; + } + }, + + async getSightData(id, lang = "ru") { + try { + const responce = await apiInstance.get(`/sight/${id}?lang=${lang}`); + return responce.data; + } catch (error) { + console.error("Ошибка при запросе:", error.message); + throw error; + } + }, + + async getSightArticle(id, lang = "ru") { + try { + const responce = await apiInstance.get( + `/sight/${id}/article?lang=${lang}` + ); + return responce.data; + } catch (error) { + console.error("Ошибка при запросе:", error.message); + throw error; + } + }, + + async getMedia(id, lang = "ru", signal) { + try { + const responce = await apiInstance.get( + `/article/${id}/media?lang=${lang}`, + { signal } + ); + return { + path: getMediaUrl(responce.data[0].id), + type: responce.data[0].media_type, + }; + } catch (error) { + console.error("Ошибка в получении медиа:", error.message); + throw error; + } + }, + + async getMediaPreview(id, lang = "ru", signal) { + try { + const responce = await apiInstance.get( + `/media/${id}?lang=${lang}`, + { signal } + ); + return { + path: getMediaUrl(id), + type: responce.data.media_type, + }; + } catch (error) { + console.error("Ошибка в получении медиа-превью:", error.message); + throw error; + } + }, + + async getCarrierAndCrestByRoute(id, lang = "ru") { + try { + const responce = await apiInstance.get(`/route/${id}?lang=${lang}`); + const carrier = await apiInstance.get( + `/carrier/${responce.data.carrier_id}?lang=${lang}` + ); + const creast = await apiInstance.get( + `/city/${carrier.data.city_id}?lang=${lang}` + ); + return { + carrierPath: getMediaUrl(carrier.data.logo), + creastPath: getMediaUrl(creast.data.arms), + }; + } catch (error) { + console.error("Ошибка в получении герба:", error.message); + throw error; + } + }, + + async getLeftArticle(id, lang = "ru") { + try { + const responce = await apiInstance.get(`/sight/${id}?lang=${lang}`); + const leftArticle = await apiInstance.get( + `/article/${responce.data.left_article}?lang=${lang}` + ); + const leftArticleMedia = await apiInstance.get( + `/article/${leftArticle.data.id}/media?lang=${lang}` + ); + if (leftArticleMedia.data.length == 0) { + return { + mediaPath: null, + mediaType: null, + title: leftArticle.data.heading, + text: leftArticle.data.body, + address: responce.data.address, + }; + } + return { + mediaPath: getMediaUrl(leftArticleMedia.data[0].id), + mediaType: leftArticleMedia.data[0].media_type, + title: leftArticle.data.heading, + text: leftArticle.data.body, + address: responce.data.address, + }; + } catch (error) { + console.error( + "Ошибка в получении информации для левого виджета:", + error.message + ); + throw error; + } + }, + + async getListOfTransfer(routeId, stationId, lang = "ru") { + try { + const response = await apiInstance.get( + `/route/${routeId}/station?lang=${lang}` + ); + const stationData = response.data.find((s) => s.id == stationId); + if (!stationData || !stationData.transfers) { + return "no data"; + } + + const result = { + tram: [], + trolleybus: [], + bus: [], + train: [], + metro_red: [], + metro_green: [], + metro_blue: [], + metro_purple: [], + metro_orange: [], + }; + for (const type in stationData.transfers) { + if ( + Object.hasOwnProperty.call(stationData.transfers, type) && + result[type] !== undefined + ) { + result[type] = stationData.transfers[type]; + } + } + return result; + } catch (error) { + console.error( + "Ошибка в получении информации о пересадках:", + error.message + ); + throw error; + } + }, + + getMediaPath(path) { + return getMediaUrl(path); + }, +}; + +export default ContentAPI; diff --git a/src/client/src/api/geo/geo.api.js b/src/client/src/api/geo/geo.api.js new file mode 100644 index 0000000..150e2dc --- /dev/null +++ b/src/client/src/api/geo/geo.api.js @@ -0,0 +1,13 @@ +import { geoInstance } from "../apiConfig"; + +export default { + async GetContext() { + try { + const response = await geoInstance.get("/v1/geolocation/context"); + return response.data; + } catch (error) { + console.error("Geoposition API error:", error); + throw new Error("Не удалось получить данные о маршруте"); + } + }, +}; diff --git a/src/client/src/api/weather/weather.api.js b/src/client/src/api/weather/weather.api.js new file mode 100644 index 0000000..a841d8c --- /dev/null +++ b/src/client/src/api/weather/weather.api.js @@ -0,0 +1,139 @@ +import { weatherInstance } from "../apiConfig"; + +const WEATHER_STATUS_MAP = { + Rain: "дождливо", + Clouds: "облачно", + Clear: "солнечно", + Thunderstorm: "гроза", + Snow: "снег", + Drizzle: "мелкий дождь", + Fog: "туман", +}; + +export default { + async getFormattedWeather(lat = 59.938784, lng = 30.314997) { + try { + const response = await weatherInstance.post("/v1/weather", { + coordinates: { latitude: lat, longitude: lng }, + }); + + return this._formatWeatherData(response.data); + } catch (error) { + console.error("Weather API error:", error); + throw new Error("Не удалось получить данные о погоде"); + } + }, + + _formatWeatherData(data) { + const today = new Date(); + const tomorrow = new Date(); + const dayAfterTomorrow = new Date(); + tomorrow.setDate(today.getDate() + 1); + dayAfterTomorrow.setDate(today.getDate() + 2); + + const formatDate = (date) => { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + return `${year}-${month}-${day}`; + }; + + const tomorrowDateStr = formatDate(tomorrow); + const dayAfterTomorrowDateStr = formatDate(dayAfterTomorrow); + + const todayForecast = data.currentWeather; + + const tomorrowForecast = (data.forecast || []).find((item) => { + if (!item || !item.date) return false; + + const itemDateStr = item.date; + return itemDateStr === tomorrowDateStr; + }); + + const dayAfterTomorrowForecast = (data.forecast || []).find((item) => { + if (!item || !item.date) return false; + + const itemDateStr = item.date; + return itemDateStr === dayAfterTomorrowDateStr; + }); + + const getTemperature = (forecast) => { + if (!forecast) return "N/A"; + + if ( + forecast.minTemperatureCelsius != null && + forecast.maxTemperatureCelsius != null + ) { + const max = Math.round(forecast.maxTemperatureCelsius); + const min = Math.round(forecast.minTemperatureCelsius); + + return `${max} / ${min}`; + } + + if (forecast.minTemperatureCelsius != null) { + return Math.round(forecast.minTemperatureCelsius); + } + + if (forecast.maxTemperatureCelsius != null) { + return Math.round(forecast.maxTemperatureCelsius); + } + + if (forecast.temperatureCelsius != null) { + return Math.round(forecast.temperatureCelsius); + } + + return "N/A"; + }; + + return { + today: { + temperature: todayForecast ? getTemperature(todayForecast) : "N/A", + status: + todayForecast && todayForecast.description + ? WEATHER_STATUS_MAP[todayForecast.description] || + todayForecast.description + : "данные отсутствуют", + precipitation: + todayForecast && todayForecast.humidity != null + ? todayForecast.humidity + : "N/A", + windSpeed: + todayForecast && todayForecast.windSpeed != null + ? todayForecast.windSpeed + : "N/A", + }, + tomorrow: { + temperature: getTemperature(tomorrowForecast), + status: + tomorrowForecast && tomorrowForecast.description + ? WEATHER_STATUS_MAP[tomorrowForecast.description] || + tomorrowForecast.description + : "данные отсутствуют", + precipitation: + tomorrowForecast && tomorrowForecast.humidity != null + ? tomorrowForecast.humidity + : "N/A", + windSpeed: + tomorrowForecast && tomorrowForecast.windSpeed != null + ? tomorrowForecast.windSpeed + : "N/A", + }, + dayAfterTomorrow: { + temperature: getTemperature(dayAfterTomorrowForecast), + status: + dayAfterTomorrowForecast && dayAfterTomorrowForecast.description + ? WEATHER_STATUS_MAP[dayAfterTomorrowForecast.description] || + dayAfterTomorrowForecast.description + : "данные отсутствуют", + precipitation: + dayAfterTomorrowForecast && dayAfterTomorrowForecast.humidity != null + ? dayAfterTomorrowForecast.humidity + : "N/A", + windSpeed: + dayAfterTomorrowForecast && dayAfterTomorrowForecast.windSpeed != null + ? dayAfterTomorrowForecast.windSpeed + : "N/A", + }, + }; + }, +}; diff --git a/src/client/src/assets/Constants.d.ts b/src/client/src/assets/Constants.d.ts new file mode 100644 index 0000000..2d9c18e --- /dev/null +++ b/src/client/src/assets/Constants.d.ts @@ -0,0 +1,8 @@ +export declare const UP_SCALE: number; +export declare const PATH_WIDTH: number; +export declare const STATION_RADIUS: number; +export declare const STATION_OUTLINE_WIDTH: number; +export declare const SIGHT_SIZE: number; +export declare const SCALE_FACTOR: number; +export declare const BACKGROUND_COLOR: number; +export declare const PATH_COLOR: number; diff --git a/src/client/src/assets/Constants.jsx b/src/client/src/assets/Constants.jsx new file mode 100644 index 0000000..bac9df4 --- /dev/null +++ b/src/client/src/assets/Constants.jsx @@ -0,0 +1,9 @@ +export const UP_SCALE = 30000; +export const PATH_WIDTH = 15; +export const STATION_RADIUS = 20; +export const STATION_OUTLINE_WIDTH = 10; +export const SIGHT_SIZE = 30; +export const SCALE_FACTOR = 50; + +export const BACKGROUND_COLOR = 0x111111; +export const PATH_COLOR = 0xff4d4d; diff --git a/src/client/src/assets/fullscreen-modal-actions/close.png b/src/client/src/assets/fullscreen-modal-actions/close.png new file mode 100644 index 0000000..c6ae874 Binary files /dev/null and b/src/client/src/assets/fullscreen-modal-actions/close.png differ diff --git a/src/client/src/assets/fullscreen-modal-actions/scale_minus.png b/src/client/src/assets/fullscreen-modal-actions/scale_minus.png new file mode 100644 index 0000000..7687481 Binary files /dev/null and b/src/client/src/assets/fullscreen-modal-actions/scale_minus.png differ diff --git a/src/client/src/assets/fullscreen-modal-actions/scale_plus.png b/src/client/src/assets/fullscreen-modal-actions/scale_plus.png new file mode 100644 index 0000000..f3b61d7 Binary files /dev/null and b/src/client/src/assets/fullscreen-modal-actions/scale_plus.png differ diff --git a/src/client/src/assets/icons/bus.svg b/src/client/src/assets/icons/bus.svg new file mode 100644 index 0000000..2d98c63 --- /dev/null +++ b/src/client/src/assets/icons/bus.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/src/client/src/assets/icons/clear-day.svg b/src/client/src/assets/icons/clear-day.svg new file mode 100644 index 0000000..b7cac9d --- /dev/null +++ b/src/client/src/assets/icons/clear-day.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/src/client/src/assets/icons/cloudy.svg b/src/client/src/assets/icons/cloudy.svg new file mode 100644 index 0000000..e45d371 --- /dev/null +++ b/src/client/src/assets/icons/cloudy.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/client/src/assets/icons/default.svg b/src/client/src/assets/icons/default.svg new file mode 100644 index 0000000..b7cac9d --- /dev/null +++ b/src/client/src/assets/icons/default.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/src/client/src/assets/icons/fog.svg b/src/client/src/assets/icons/fog.svg new file mode 100644 index 0000000..12a65ed --- /dev/null +++ b/src/client/src/assets/icons/fog.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/client/src/assets/icons/metro_blue.svg b/src/client/src/assets/icons/metro_blue.svg new file mode 100644 index 0000000..2e7a2a1 --- /dev/null +++ b/src/client/src/assets/icons/metro_blue.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/client/src/assets/icons/metro_green.svg b/src/client/src/assets/icons/metro_green.svg new file mode 100644 index 0000000..f50babf --- /dev/null +++ b/src/client/src/assets/icons/metro_green.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/client/src/assets/icons/metro_orange.svg b/src/client/src/assets/icons/metro_orange.svg new file mode 100644 index 0000000..fe22746 --- /dev/null +++ b/src/client/src/assets/icons/metro_orange.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/client/src/assets/icons/metro_purple.svg b/src/client/src/assets/icons/metro_purple.svg new file mode 100644 index 0000000..31b1a17 --- /dev/null +++ b/src/client/src/assets/icons/metro_purple.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/client/src/assets/icons/metro_red.svg b/src/client/src/assets/icons/metro_red.svg new file mode 100644 index 0000000..59c2db0 --- /dev/null +++ b/src/client/src/assets/icons/metro_red.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/client/src/assets/icons/rainy.svg b/src/client/src/assets/icons/rainy.svg new file mode 100644 index 0000000..92b255d --- /dev/null +++ b/src/client/src/assets/icons/rainy.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/client/src/assets/icons/snowy.svg b/src/client/src/assets/icons/snowy.svg new file mode 100644 index 0000000..46dc49f --- /dev/null +++ b/src/client/src/assets/icons/snowy.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/client/src/assets/icons/subtract-home.svg b/src/client/src/assets/icons/subtract-home.svg new file mode 100644 index 0000000..afbba3a --- /dev/null +++ b/src/client/src/assets/icons/subtract-home.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/client/src/assets/icons/three-view-pan.svg b/src/client/src/assets/icons/three-view-pan.svg new file mode 100644 index 0000000..d556958 --- /dev/null +++ b/src/client/src/assets/icons/three-view-pan.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/client/src/assets/icons/three-view-rotate.svg b/src/client/src/assets/icons/three-view-rotate.svg new file mode 100644 index 0000000..f802b0c --- /dev/null +++ b/src/client/src/assets/icons/three-view-rotate.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/client/src/assets/icons/three-view-zoom.svg b/src/client/src/assets/icons/three-view-zoom.svg new file mode 100644 index 0000000..f6644d2 --- /dev/null +++ b/src/client/src/assets/icons/three-view-zoom.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/client/src/assets/icons/thunderstorms.svg b/src/client/src/assets/icons/thunderstorms.svg new file mode 100644 index 0000000..f9388d4 --- /dev/null +++ b/src/client/src/assets/icons/thunderstorms.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/client/src/assets/icons/train.svg b/src/client/src/assets/icons/train.svg new file mode 100644 index 0000000..03c2e9f --- /dev/null +++ b/src/client/src/assets/icons/train.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/client/src/assets/icons/tram-bottom-left.svg b/src/client/src/assets/icons/tram-bottom-left.svg new file mode 100644 index 0000000..26147ff --- /dev/null +++ b/src/client/src/assets/icons/tram-bottom-left.svg @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/client/src/assets/icons/tram-bottom-right.svg b/src/client/src/assets/icons/tram-bottom-right.svg new file mode 100644 index 0000000..b801670 --- /dev/null +++ b/src/client/src/assets/icons/tram-bottom-right.svg @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/client/src/assets/icons/tram-left.svg b/src/client/src/assets/icons/tram-left.svg new file mode 100644 index 0000000..993b9fa --- /dev/null +++ b/src/client/src/assets/icons/tram-left.svg @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/client/src/assets/icons/tram-right.svg b/src/client/src/assets/icons/tram-right.svg new file mode 100644 index 0000000..9d93d79 --- /dev/null +++ b/src/client/src/assets/icons/tram-right.svg @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/client/src/assets/icons/tram-top-left.svg b/src/client/src/assets/icons/tram-top-left.svg new file mode 100644 index 0000000..30c2604 --- /dev/null +++ b/src/client/src/assets/icons/tram-top-left.svg @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/client/src/assets/icons/tram-top-right.svg b/src/client/src/assets/icons/tram-top-right.svg new file mode 100644 index 0000000..bdb0f74 --- /dev/null +++ b/src/client/src/assets/icons/tram-top-right.svg @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/client/src/assets/icons/tram.svg b/src/client/src/assets/icons/tram.svg new file mode 100644 index 0000000..1107938 --- /dev/null +++ b/src/client/src/assets/icons/tram.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/src/client/src/assets/icons/trolleybus.svg b/src/client/src/assets/icons/trolleybus.svg new file mode 100644 index 0000000..dc5c60d --- /dev/null +++ b/src/client/src/assets/icons/trolleybus.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/src/client/src/assets/images/GAT.png b/src/client/src/assets/images/GAT.png new file mode 100644 index 0000000..737ba11 Binary files /dev/null and b/src/client/src/assets/images/GAT.png differ diff --git a/src/client/src/assets/images/sight.png b/src/client/src/assets/images/sight.png new file mode 100644 index 0000000..b235fd3 Binary files /dev/null and b/src/client/src/assets/images/sight.png differ diff --git a/src/client/src/assets/images/sight.svg b/src/client/src/assets/images/sight.svg new file mode 100644 index 0000000..5bdc018 --- /dev/null +++ b/src/client/src/assets/images/sight.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/src/client/src/assets/images/test-image.png b/src/client/src/assets/images/test-image.png new file mode 100644 index 0000000..1a66fdc Binary files /dev/null and b/src/client/src/assets/images/test-image.png differ diff --git a/src/client/src/assets/images/transfer-button.png b/src/client/src/assets/images/transfer-button.png new file mode 100644 index 0000000..d66c002 Binary files /dev/null and b/src/client/src/assets/images/transfer-button.png differ diff --git a/src/client/src/assets/images/Герб.png b/src/client/src/assets/images/Герб.png new file mode 100644 index 0000000..5a26407 Binary files /dev/null and b/src/client/src/assets/images/Герб.png differ diff --git a/src/client/src/assets/tramPosition/Tram Base.svg b/src/client/src/assets/tramPosition/Tram Base.svg new file mode 100644 index 0000000..5f7e478 --- /dev/null +++ b/src/client/src/assets/tramPosition/Tram Base.svg @@ -0,0 +1,15 @@ + + + + + + + + + + + + + + + diff --git a/src/client/src/assets/tramPosition/Tram Bottom Left.svg b/src/client/src/assets/tramPosition/Tram Bottom Left.svg new file mode 100644 index 0000000..26147ff --- /dev/null +++ b/src/client/src/assets/tramPosition/Tram Bottom Left.svg @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/client/src/assets/tramPosition/Tram Bottom Right.svg b/src/client/src/assets/tramPosition/Tram Bottom Right.svg new file mode 100644 index 0000000..b801670 --- /dev/null +++ b/src/client/src/assets/tramPosition/Tram Bottom Right.svg @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/client/src/assets/tramPosition/Tram Left.svg b/src/client/src/assets/tramPosition/Tram Left.svg new file mode 100644 index 0000000..993b9fa --- /dev/null +++ b/src/client/src/assets/tramPosition/Tram Left.svg @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/client/src/assets/tramPosition/Tram Right.svg b/src/client/src/assets/tramPosition/Tram Right.svg new file mode 100644 index 0000000..9d93d79 --- /dev/null +++ b/src/client/src/assets/tramPosition/Tram Right.svg @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/client/src/assets/tramPosition/Tram Top Left.svg b/src/client/src/assets/tramPosition/Tram Top Left.svg new file mode 100644 index 0000000..30c2604 --- /dev/null +++ b/src/client/src/assets/tramPosition/Tram Top Left.svg @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/client/src/assets/tramPosition/Tram Top Right.svg b/src/client/src/assets/tramPosition/Tram Top Right.svg new file mode 100644 index 0000000..bdb0f74 --- /dev/null +++ b/src/client/src/assets/tramPosition/Tram Top Right.svg @@ -0,0 +1,35 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/client/src/assets/tramPosition/Tram.svg b/src/client/src/assets/tramPosition/Tram.svg new file mode 100644 index 0000000..f41a2ee --- /dev/null +++ b/src/client/src/assets/tramPosition/Tram.svg @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/client/src/assets/tramPosition/Tram_Second.svg b/src/client/src/assets/tramPosition/Tram_Second.svg new file mode 100644 index 0000000..c0f4388 --- /dev/null +++ b/src/client/src/assets/tramPosition/Tram_Second.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/src/client/src/assets/transport-icons/bus.svg b/src/client/src/assets/transport-icons/bus.svg new file mode 100644 index 0000000..00f52e2 --- /dev/null +++ b/src/client/src/assets/transport-icons/bus.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/src/client/src/assets/transport-icons/metroBlue.svg b/src/client/src/assets/transport-icons/metroBlue.svg new file mode 100644 index 0000000..884b565 --- /dev/null +++ b/src/client/src/assets/transport-icons/metroBlue.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/client/src/assets/transport-icons/metroGreen.svg b/src/client/src/assets/transport-icons/metroGreen.svg new file mode 100644 index 0000000..039f95a --- /dev/null +++ b/src/client/src/assets/transport-icons/metroGreen.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/client/src/assets/transport-icons/metroOrange.svg b/src/client/src/assets/transport-icons/metroOrange.svg new file mode 100644 index 0000000..8b27624 --- /dev/null +++ b/src/client/src/assets/transport-icons/metroOrange.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/client/src/assets/transport-icons/metroPurple.svg b/src/client/src/assets/transport-icons/metroPurple.svg new file mode 100644 index 0000000..6813ae1 --- /dev/null +++ b/src/client/src/assets/transport-icons/metroPurple.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/client/src/assets/transport-icons/metroRed.svg b/src/client/src/assets/transport-icons/metroRed.svg new file mode 100644 index 0000000..9db2a0e --- /dev/null +++ b/src/client/src/assets/transport-icons/metroRed.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/client/src/assets/transport-icons/station.svg b/src/client/src/assets/transport-icons/station.svg new file mode 100644 index 0000000..0609994 --- /dev/null +++ b/src/client/src/assets/transport-icons/station.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + \ No newline at end of file diff --git a/src/client/src/assets/transport-icons/train.svg b/src/client/src/assets/transport-icons/train.svg new file mode 100644 index 0000000..60bc427 --- /dev/null +++ b/src/client/src/assets/transport-icons/train.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/client/src/assets/transport-icons/tram.svg b/src/client/src/assets/transport-icons/tram.svg new file mode 100644 index 0000000..b01c9fc --- /dev/null +++ b/src/client/src/assets/transport-icons/tram.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/src/client/src/assets/transport-icons/trolley.svg b/src/client/src/assets/transport-icons/trolley.svg new file mode 100644 index 0000000..eab02b6 --- /dev/null +++ b/src/client/src/assets/transport-icons/trolley.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/src/client/src/assets/video/taganai.mp4 b/src/client/src/assets/video/taganai.mp4 new file mode 100644 index 0000000..515cecc Binary files /dev/null and b/src/client/src/assets/video/taganai.mp4 differ diff --git a/src/client/src/assets/weather-icons/humidity.png b/src/client/src/assets/weather-icons/humidity.png new file mode 100644 index 0000000..19a0cf0 Binary files /dev/null and b/src/client/src/assets/weather-icons/humidity.png differ diff --git a/src/client/src/assets/weather-icons/icon1.png b/src/client/src/assets/weather-icons/icon1.png new file mode 100644 index 0000000..18a7817 Binary files /dev/null and b/src/client/src/assets/weather-icons/icon1.png differ diff --git a/src/client/src/assets/weather-icons/icon2.png b/src/client/src/assets/weather-icons/icon2.png new file mode 100644 index 0000000..03cbe14 Binary files /dev/null and b/src/client/src/assets/weather-icons/icon2.png differ diff --git a/src/client/src/assets/weather-icons/icon3.png b/src/client/src/assets/weather-icons/icon3.png new file mode 100644 index 0000000..18a7817 Binary files /dev/null and b/src/client/src/assets/weather-icons/icon3.png differ diff --git a/src/client/src/assets/weather-icons/wind.png b/src/client/src/assets/weather-icons/wind.png new file mode 100644 index 0000000..4589227 Binary files /dev/null and b/src/client/src/assets/weather-icons/wind.png differ diff --git a/src/client/src/assets/weather-status/sunny.png b/src/client/src/assets/weather-status/sunny.png new file mode 100644 index 0000000..a726ce7 Binary files /dev/null and b/src/client/src/assets/weather-status/sunny.png differ diff --git a/src/client/src/components/ErrorBoundary.jsx b/src/client/src/components/ErrorBoundary.jsx new file mode 100644 index 0000000..e69de29 diff --git a/src/client/src/components/ErrorOverlay.css b/src/client/src/components/ErrorOverlay.css new file mode 100644 index 0000000..d07d520 --- /dev/null +++ b/src/client/src/components/ErrorOverlay.css @@ -0,0 +1,6 @@ +.error-gif { + position: fixed; + width: 100vw; + height: 100vh; + z-index: 999999; +} diff --git a/src/client/src/components/ErrorOverlay.jsx b/src/client/src/components/ErrorOverlay.jsx new file mode 100644 index 0000000..7e8cbab --- /dev/null +++ b/src/client/src/components/ErrorOverlay.jsx @@ -0,0 +1,24 @@ +import React from "react"; +import { observer } from "mobx-react-lite"; +import { useGeolocationStore } from "../stores/hooks/useGeolocationStore"; +import "./ErrorOverlay.css"; + +const ErrorOverlay = observer(() => { + const { error, isLoading } = useGeolocationStore(); + + // Показываем GIF только если есть ошибка и загрузка завершена + if (!error || isLoading) { + return null; + } + + return ( +
+
+ Error +

{error}

+
+
+ ); +}); + +export default ErrorOverlay; diff --git a/src/client/src/components/Fullscreen3DModal.jsx b/src/client/src/components/Fullscreen3DModal.jsx new file mode 100644 index 0000000..989c088 --- /dev/null +++ b/src/client/src/components/Fullscreen3DModal.jsx @@ -0,0 +1,56 @@ +import React, { useMemo, useState } from "react"; +import { createPortal } from "react-dom"; +import { ThreeView } from "./widgets/ThreeView"; +import { ThreeViewErrorBoundary } from "./ThreeViewErrorBoundary"; +import scale_plus from "../assets/fullscreen-modal-actions/scale_plus.png"; +import scale_minus from "../assets/fullscreen-modal-actions/scale_minus.png"; +import closeIcon from "../assets/fullscreen-modal-actions/close.png"; + +const Fullscreen3DModal = ({ isOpen, onClose, fileUrl }) => { + if (!isOpen || !fileUrl) return null; + + const [scale, setScale] = useState(0.1); + const [fullscreenResetKey, setFullscreenResetKey] = useState(0); + + const handleResetScale = (e) => { + e.stopPropagation(); + setScale(0.1); + }; + + return createPortal( +
+
+
+ { + setFullscreenResetKey((prev) => prev + 1); + }} + > + + +
+
+
+ + + + +
+
, + document.body + ); +}; + +export default Fullscreen3DModal; diff --git a/src/client/src/components/ListOfSights.jsx b/src/client/src/components/ListOfSights.jsx new file mode 100644 index 0000000..06e2957 --- /dev/null +++ b/src/client/src/components/ListOfSights.jsx @@ -0,0 +1,521 @@ +import React, { + useState, + useEffect, + useRef, + useCallback, + useMemo, +} from "react"; +import { observer } from "mobx-react-lite"; +import "../styles/ListOfSights.css"; +import { useGeolocationStore, useColorStore } from "../stores"; +import ContentAPI from "../api/content/content.api"; +import { getMediaType } from "../utils/getMediaType"; + +// Импортируем новые компоненты +import SightFrame from "./ListOfSights/SightFrame"; +import ListHeader from "./ListOfSights/ListHeader"; +import SightsList from "./ListOfSights/SightsList"; +import AlphabetNavigator from "./ListOfSights/AlphabetNavigator"; +import LanguageSelector from "./ListOfSights/LanguageSelector"; +import TransferWidget from "./ListOfSights/TransferWidget"; +import { apiStore } from "../api/ApiStore/store"; + +const LANGUAGES = { + EN: "en", + RU: "ru", + ZH: "zh", +}; + +const ListOfSights = observer(() => { + const [sightData, setSightData] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + + const store = useGeolocationStore(); + const colorStore = useColorStore(); + const { currentColor, startColorAnimation } = colorStore; + const { routeSights, routeSightsEn, routeSightsZh } = apiStore; + const { + nearestSightId, + selectedSightId, + setSelectedSightId, + selectedLanguage, + selectedLanguageRight, + setSelectedLanguageRight, + nearestStationId, + isInteractingWith3D, + isManualSelection, + setIsManualSelection, + isRightWidgetSelectorOpen, + setIsRightWidgetSelectorOpen, + isLeftWidgetOpen, + isTransferWidgetOpen, + setIsTransferWidgetOpen, + } = store; + const { context: contextData, routeStations } = apiStore; + + const [isLangMenuOpen, setIsLangMenuOpen] = useState(false); + const [selectedLetter, setSelectedLetter] = useState(null); + const [TransferWidgetData, setTransferWidgetData] = useState(null); + const [isTransferButtonVisible, setIsTransferButtonVisible] = useState(false); + const [showTransferButton, setShowTransferButton] = useState(false); + const [highlightedSights, setHighlightedSights] = useState(new Set()); + const [isAlphabetDisabled, setIsAlphabetDisabled] = useState(false); + + const sightRefs = useRef({}); + const sightsListRef = useRef(null); + const alphabetRef = useRef(null); + const isInitialSelectionDone = useRef(false); + + const handleIconClick = () => { + setIsRightWidgetSelectorOpen(!isRightWidgetSelectorOpen); + }; + + const handleSightClick = useCallback( + (id) => { + setSelectedSightId(String(id)); + setIsManualSelection(true); + setIsRightWidgetSelectorOpen(false); + // Закрываем виджет губернатора при выборе достопримечательности + store.closeGovernorModal(); + }, + [setSelectedSightId, setIsRightWidgetSelectorOpen, store], + ); + + const handleLetterClick = (letter) => { + // Если алфавит заблокирован, игнорируем клик + if (isAlphabetDisabled) return; + + setSelectedLetter((prevLetter) => { + const newLetter = prevLetter === letter ? null : letter; + + if (newLetter && sightData) { + // Блокируем алфавит на время анимации + setIsAlphabetDisabled(true); + + // Находим все достопримечательности, начинающиеся с выбранной буквы + const sightsForLetter = sightData.filter((sight) => { + const name = sight.name; + if (!name) return false; + + let sightFirstChar = name.trim().charAt(0); + + // Для китайского языка используем пиньинь или иероглиф + if (selectedLanguageRight === "zh") { + if (sight.name_pinyin) { + sightFirstChar = sight.name_pinyin.trim().toUpperCase().charAt(0); + } else { + // Если пиньинь нет, используем первый иероглиф + sightFirstChar = name.trim().charAt(0); + } + } else { + sightFirstChar = sightFirstChar.toUpperCase(); + } + + return sightFirstChar === newLetter; + }); + + const sightIds = new Set(sightsForLetter.map((sight) => sight.id)); + + // Скроллим к первой достопримечательности + const firstSightForLetter = sightsForLetter[0]; + if (firstSightForLetter && sightRefs.current[firstSightForLetter.id]) { + const targetElement = sightRefs.current[firstSightForLetter.id]; + + if (targetElement) { + // Используем setTimeout для гарантии, что OverlayScrollbars инициализирован + setTimeout(() => { + try { + // Используем scrollIntoView, чтобы scroll-margin-top из CSS работал правильно + targetElement.scrollIntoView({ + behavior: "smooth", + block: "start", + }); + + // Fallback mechanism if scrollIntoView fails or is intercepted + setTimeout(() => { + if (sightsListRef.current) { + const container = sightsListRef.current; + const rect = targetElement.getBoundingClientRect(); + const containerRect = container.getBoundingClientRect(); + + // Check if element is visible in container + const isVisible = + rect.top >= containerRect.top && + rect.bottom <= containerRect.bottom; + + if (!isVisible && container.scrollTo) { + // Calculate relative position + const relativeTop = targetElement.offsetTop; + container.scrollTo({ + top: relativeTop - 15, + behavior: "smooth", + }); + } + } + }, 100); + } catch (error) { + console.error("Ошибка при скролле к элементу:", error); + } + }, 100); + + // Запускаем анимацию после завершения скролла (примерно 500-600ms для smooth scroll) + setTimeout(() => { + setHighlightedSights(sightIds); + + // Через 1.5 секунды убираем подсветку и разблокируем алфавит + setTimeout(() => { + setHighlightedSights(new Set()); + setIsAlphabetDisabled(false); + }, 1500); + }, 600); + } + } else { + setIsAlphabetDisabled(false); + } + } else { + // Если буква отменена, убираем подсветку + setHighlightedSights(new Set()); + } + return newLetter; + }); + }; + + const handleLanguageChange = (val) => { + setIsLangMenuOpen(false); + setSelectedLanguageRight(val); + }; + + const handleTransferToggle = () => { + setIsTransferWidgetOpen(!isTransferWidgetOpen); + }; + + const handleBackToNearest = useCallback(() => { + setIsManualSelection(false); + const sightData = + selectedLanguageRight === "ru" + ? routeSights + : selectedLanguageRight === "en" + ? routeSightsEn + : routeSightsZh; + + if (sightData && sightData.length > 0) { + const nearestSightInList = sightData.find((s) => s.id == nearestSightId); + if (nearestSightInList) { + setSelectedSightId(String(nearestSightInList.id)); + } else { + setSelectedSightId(String(sightData[0].id)); + } + } else { + setSelectedSightId(null); + } + setIsRightWidgetSelectorOpen(false); + // Закрываем виджет губернатора при возврате к ближайшей достопримечательности + store.closeGovernorModal(); + }, [ + setSelectedSightId, + routeSights, + routeSightsEn, + routeSightsZh, + nearestSightId, + selectedLanguageRight, + setIsRightWidgetSelectorOpen, + store, + ]); + + // Запуск анимации цвета при монтировании компонента + useEffect(() => { + startColorAnimation(); + }, [startColorAnimation]); + + // Загрузка достопримечательностей + useEffect(() => { + setError(null); + setIsLoading(true); + + const fetchSights = async () => { + try { + const sights = + selectedLanguageRight === "ru" + ? routeSights + : selectedLanguageRight === "en" + ? routeSightsEn + : routeSightsZh; + + const processedSights = sights.map((sight) => ({ + ...sight, + preview_media_type: getMediaType(sight.preview_media_type_raw), + thumbnail_type: getMediaType(sight.thumbnail_type_raw), + })); + + const sortedSights = [...processedSights].sort((a, b) => { + if (a.name && b.name) { + return a.name.localeCompare(b.name, selectedLanguageRight, { + sensitivity: "base", + }); + } + return 0; + }); + + setSightData(sortedSights); + } catch (err) { + console.error("Ошибка загрузки достопримечательностей:", err); + setError( + "Не удалось загрузить достопримечательности. Пожалуйста, попробуйте позже.", + ); + setSightData([]); + } finally { + setIsLoading(false); + } + }; + + fetchSights(); + }, [contextData?.routeId, selectedLanguageRight]); + + // Анимация кнопки пересадок при открытии/закрытии левого меню + useEffect(() => { + if (isLeftWidgetOpen) { + // При открытии левого меню - сначала показываем кнопку пересадок + setIsTransferButtonVisible(true); + } else { + // При закрытии левого меню - сначала скрываем кнопку пересадок + setIsTransferButtonVisible(false); + } + }, [isLeftWidgetOpen]); + + // Логика выбора достопримечательности + useEffect(() => { + if (!isLoading && sightData && sightData.length > 0) { + if (!isInitialSelectionDone.current && !isManualSelection) { + const nearestSightInList = sightData.find( + (s) => s.id == nearestSightId, + ); + if (nearestSightInList) { + setSelectedSightId(String(nearestSightInList.id)); + } else { + setSelectedSightId(String(sightData[0].id)); + } + isInitialSelectionDone.current = true; + } else if (!isInitialSelectionDone.current && isManualSelection) { + const nearestSightInList = sightData.find( + (s) => s.id == nearestSightId, + ); + if ( + isManualSelection && + nearestSightInList && + nearestSightInList.id === selectedSightId + ) { + // Все ОК, продолжаем использовать выбранную вручную, если она совпадает с ближайшей + } else if (isManualSelection && selectedSightId) { + // Ничего не делаем здесь, выбранная вручную достопримечательность уже установлена. + } else { + if (nearestSightInList) { + setSelectedSightId(String(nearestSightInList.id)); + } else { + setSelectedSightId(String(sightData[0].id)); + } + } + isInitialSelectionDone.current = true; + setIsManualSelection(false); + } + + if ( + nearestSightId && + selectedSightId !== nearestSightId && + !isManualSelection && + !isInteractingWith3D + ) { + const newNearestSight = sightData.find((s) => s.id == nearestSightId); + if (newNearestSight) { + setSelectedSightId(String(newNearestSight.id)); + } + } + } else if ( + !isLoading && + (!sightData || sightData.length === 0) && + isInitialSelectionDone.current + ) { + setSelectedSightId(null); + isInitialSelectionDone.current = false; + setIsManualSelection(false); + } + }, [ + isLoading, + sightData, + nearestSightId, + setSelectedSightId, + selectedSightId, + isManualSelection, + isInteractingWith3D, + ]); + + // Загрузка данных о пересадках для ближайшей станции + useEffect(() => { + if (!isLoading && nearestStationId != null) { + const fetchTransferWidgetData = async () => { + try { + const station = routeStations.find((s) => s.id == nearestStationId); + const response = station.transfers; + + setTransferWidgetData(response); + } catch (err) { + console.error("Ошибка в получении пересадок:", err); + setTransferWidgetData(null); + } + }; + fetchTransferWidgetData(); + } else if (!isLoading && (!contextData?.routeId || !nearestStationId)) { + setTransferWidgetData(null); + } + }, [isLoading, contextData?.routeId, nearestStationId, routeStations]); + + const currentSelectedSight = sightData + ? sightData.find((sight) => sight.id == selectedSightId) + : null; + + const sightFrameMedia = useMemo(() => { + if (!currentSelectedSight) return null; + + // Если preview_media это строка (URL), используем её как path + if (typeof currentSelectedSight.preview_media === "string") { + return { + id: currentSelectedSight.preview_media, + path: currentSelectedSight.preview_media, + media_type: currentSelectedSight.preview_media_type || null, + type: currentSelectedSight.preview_media_type || null, + }; + } + + if ( + typeof currentSelectedSight.preview_media === "object" && + currentSelectedSight.preview_media + ) { + return { + id: + currentSelectedSight.preview_media.id || + currentSelectedSight.preview_media, + path: + currentSelectedSight.preview_media.path || + currentSelectedSight.preview_media.url, + media_type: + currentSelectedSight.preview_media.type || + currentSelectedSight.preview_media.media_type, + type: + currentSelectedSight.preview_media.type || + currentSelectedSight.preview_media.media_type, + }; + } + + return { + id: currentSelectedSight.preview_media, + }; + }, [currentSelectedSight]); + + return ( +
+ {currentSelectedSight && ( + + )} + +
+ + + setIsLangMenuOpen(true)} + onBackToNearest={handleBackToNearest} + /> + +
+ +
+ + + +
+
+ + + +
+
+ + + +
+
+
+ ); +}); + +export default ListOfSights; diff --git a/src/client/src/components/ListOfSights/AlphabetNavigator.jsx b/src/client/src/components/ListOfSights/AlphabetNavigator.jsx new file mode 100644 index 0000000..cdd79d7 --- /dev/null +++ b/src/client/src/components/ListOfSights/AlphabetNavigator.jsx @@ -0,0 +1,100 @@ +import React, { useMemo, forwardRef } from "react"; + +const AlphabetNavigator = forwardRef(function AlphabetNavigator( + { selectedLetter, onLetterClick, sightData, selectedLanguage, isDisabled }, + ref +) { + // Генерируем алфавит на основе доступных достопримечательностей + const availableLetters = useMemo(() => { + if (!sightData || sightData.length === 0) { + return []; + } + + // Получаем уникальные первые символы из названий достопримечательностей + const letters = new Set(); + + sightData.forEach((sight) => { + const name = sight.short_name || sight.name; + if (name && name.trim()) { + let firstChar = name.trim().charAt(0); + + // Для китайского языка используем пиньинь (если доступен) или иероглиф + if (selectedLanguage === "zh") { + if (sight.name_pinyin) { + firstChar = sight.name_pinyin.trim().toUpperCase().charAt(0); + } else { + // Если пиньинь нет, используем первый иероглиф + firstChar = name.trim().charAt(0); + } + } else { + firstChar = firstChar.toUpperCase(); + } + + // Проверяем, что символ подходит для алфавита + if ( + firstChar && + (/[A-ZА-ЯЁ]/.test(firstChar) || + (selectedLanguage === "zh" && /[\u4e00-\u9fff]/.test(firstChar))) + ) { + letters.add(firstChar); + } + } + }); + + // Сортируем символы в зависимости от языка + const sortedLetters = Array.from(letters).sort((a, b) => { + if (selectedLanguage === "zh") { + // Для китайского языка сортируем по пиньинь или по Unicode + if (/[A-Z]/.test(a) && /[A-Z]/.test(b)) { + // Если оба символа - латинские буквы (пиньинь) + return a.localeCompare(b, "en", { sensitivity: "base" }); + } else if (/[\u4e00-\u9fff]/.test(a) && /[\u4e00-\u9fff]/.test(b)) { + // Если оба символа - иероглифы, сортируем по Unicode + return a.localeCompare(b, "zh", { sensitivity: "base" }); + } else { + // Смешанные символы: сначала латинские, потом иероглифы + return /[A-Z]/.test(a) ? -1 : 1; + } + } + return a.localeCompare(b, selectedLanguage, { sensitivity: "base" }); + }); + + return sortedLetters; + }, [sightData, selectedLanguage]); + + // Если нет доступных букв, не показываем компонент + if (availableLetters.length === 0) { + return null; + } + + return ( +
+ {availableLetters.map((letter) => ( + !isDisabled && onLetterClick(letter)} + role="button" + tabIndex="0" + data-lang={selectedLanguage === "zh" ? "zh" : undefined} + aria-label={ + selectedLanguage === "en" + ? `Scroll to sights starting with letter ${letter}` + : selectedLanguage === "zh" + ? `滚动到以字母 ${letter} 开头的景点` + : `Прокрутить к достопримечательностям на букву ${letter}` + } + > + {letter} + + ))} +
+ ); +}); + +export default AlphabetNavigator; diff --git a/src/client/src/components/ListOfSights/LanguageSelector.jsx b/src/client/src/components/ListOfSights/LanguageSelector.jsx new file mode 100644 index 0000000..d2d6a46 --- /dev/null +++ b/src/client/src/components/ListOfSights/LanguageSelector.jsx @@ -0,0 +1,172 @@ +import React from "react"; +import { useGeolocationStore } from "../../stores"; +import { observer } from "mobx-react-lite"; +const LANGUAGES = { + EN: "en", + RU: "ru", + ZH: "zh", +}; + +const LANGUAGE_NAMES = { + [LANGUAGES.EN]: "en", + [LANGUAGES.RU]: "ru", + [LANGUAGES.ZH]: "zh", +}; + +const LanguageSelector = observer(function LanguageSelector({ + selectedLanguageRight, + onLanguageChange, + isOpen, + onToggle, + onBackToNearest, +}) { + const store = useGeolocationStore(); + const { isManualSelection, selectedSightId, nearestSightId } = store; + + const SelectLangHandler = (val) => { + onLanguageChange(val); + }; + + const handleBackClick = () => { + if (onBackToNearest) { + onBackToNearest(); + } + }; + + if (isOpen) { + return ( +
+
SelectLangHandler(LANGUAGES.EN)} + > + + + +
+
SelectLangHandler(LANGUAGES.RU)} + > + + + + +
+
SelectLangHandler(LANGUAGES.ZH)} + > + + + + + + +
+
+ ); + } + + return ( +
+
+ + + +
+
+ ); +}); + +export default LanguageSelector; diff --git a/src/client/src/components/ListOfSights/ListHeader.jsx b/src/client/src/components/ListOfSights/ListHeader.jsx new file mode 100644 index 0000000..d026881 --- /dev/null +++ b/src/client/src/components/ListOfSights/ListHeader.jsx @@ -0,0 +1,106 @@ +import React, { useState, useEffect, useRef } from "react"; + +const LANGUAGES = { + EN: "en", + RU: "ru", + ZH: "zh", +}; + +const ListHeader = function ListHeader({ + isLangOpen, + isOpen, + onIconClick, + selectedLanguageRight, + isManualSelection, + selectedSightId, + nearestSightId, + onTransferToggle, + isTransferWidgetOpen, + onBackToNearest, +}) { + const [isIdle, setIsIdle] = useState(false); + const timerRef = useRef(null); + + useEffect(() => { + const resetTimer = () => { + setIsIdle(false); + clearTimeout(timerRef.current); + timerRef.current = setTimeout(() => setIsIdle(true), 15000); + }; + + const events = ["pointermove", "pointerdown", "touchstart", "keydown"]; + events.forEach((e) => window.addEventListener(e, resetTimer)); + resetTimer(); + + return () => { + clearTimeout(timerRef.current); + events.forEach((e) => window.removeEventListener(e, resetTimer)); + }; + }, []); + + const getTitle = () => { + return selectedLanguageRight === LANGUAGES.RU + ? "Достопримечательности" + : selectedLanguageRight === LANGUAGES.ZH + ? "景点" + : "Attractions"; + }; + + return ( +
+
+ + + + + + + + + + +
+ +
+ {getTitle()} +
+
+ ); +}; + +export default ListHeader; diff --git a/src/client/src/components/ListOfSights/SightComponent.jsx b/src/client/src/components/ListOfSights/SightComponent.jsx new file mode 100644 index 0000000..18902d5 --- /dev/null +++ b/src/client/src/components/ListOfSights/SightComponent.jsx @@ -0,0 +1,46 @@ +import React from "react"; +import { getMediaUrl } from "../../api/apiConfig"; +import { useClickDetection } from "../../hooks/useClickDetection"; + +const SightComponent = function SightComponent({ + media, + title, + onPointerUp, + sightId, + componentRef, + isHighlighted, +}) { + // Используем общий хук для различения клика и скролла + const { handlePointerDown, handlePointerUp, handleScroll } = + useClickDetection(); + + const renderThumbnail = () => { + if (!media || !media.id) { + return
No Media
; + } + + const mediaUrl = getMediaUrl(media.id); + + return {title}; + }; + + return ( +
handlePointerDown(e, sightId)} + onPointerUp={(e) => handlePointerUp(e, sightId, onPointerUp)} + onScroll={handleScroll} + ref={componentRef} + role="button" + tabIndex="0" + aria-label={`Выбрать достопримечательность ${title}`} + > +
{renderThumbnail()}
+
+ {title} +
+
+ ); +}; + +export default SightComponent; diff --git a/src/client/src/components/ListOfSights/SightFrame.jsx b/src/client/src/components/ListOfSights/SightFrame.jsx new file mode 100644 index 0000000..4d20d42 --- /dev/null +++ b/src/client/src/components/ListOfSights/SightFrame.jsx @@ -0,0 +1,802 @@ +import React, { useState, useEffect, useRef, useMemo, useLayoutEffect, useCallback } from "react"; +import axios from "axios"; +import { observer } from "mobx-react-lite"; +import { useGeolocationStore } from "../../stores"; +import ContentAPI from "../../api/content/content.api"; +import { getMediaType } from "../../utils/getMediaType"; +import { ReactPhotoSphereViewer } from "react-photo-sphere-viewer"; +import { ThreeView } from "../widgets/ThreeView"; +import { + MinusIcon, + PlusIcon, + SizeIcon, + SizeOpenIcon, +} from "../widgets/ThreeViewIcons"; +import { ThreeViewErrorBoundary } from "../ThreeViewErrorBoundary"; +import { apiStore } from "../../api/ApiStore/store"; +import { ReactMarkdownComponent } from "../ReactMarkdown"; +import { TouchableLayout } from "../TouchableLayout"; +import rotate3DIcon from "../../assets/icons/three-view-rotate.svg"; +import zoom3DIcon from "../../assets/icons/three-view-zoom.svg"; +import pan3DIcon from "../../assets/icons/three-view-pan.svg"; +import subtractHomeIcon from "../../assets/icons/subtract-home.svg"; + +const Watermark = ({ path }) => { + if (!path) return null; + return watermark; +}; + +const SightFrame = observer(({ media, sight_id, sight_name }) => { + const store = useGeolocationStore(); + const { selectedLanguageRight } = store; + + const [articleSections, setArticleSections] = useState(null); + const [isLoadingContent, setIsLoadingContent] = useState(true); + const [contentError, setContentError] = useState(null); + const [selectedSection, setSelectedSection] = useState(0); + const [modelAspectRatio, setModelAspectRatio] = useState(null); + const [isVisible, setIsVisible] = useState(false); + const [sightData, setSightData] = useState(null); + const [mediaData, setMediaData] = useState({}); + const [isFullscreen3D, setIsFullscreen3D] = useState(false); + const [fullscreenFileUrl, setFullscreenFileUrl] = useState(""); + const [threeViewResetKey, setThreeViewResetKey] = useState(0); + const [isSwitching, setIsSwitching] = useState(false); + const threeViewControlRef = useRef(null); + const mediaCache = useRef({}); + const prevSightIdRef = useRef(null); + + const textWrapperRef = useRef(null); + const mediaStackRef = useRef(null); + const titleRef = useRef(null); + const switchTimerRef = useRef(null); + const homeButtonRef = useRef(null); + const menuRef = useRef(null); + const [menuNeedsScroll, setMenuNeedsScroll] = useState(false); + const [canScrollLeft, setCanScrollLeft] = useState(false); + const [canScrollRight, setCanScrollRight] = useState(false); + + const currentSection = articleSections?.[selectedSection] ?? null; + + const requestSection = (target) => { + setIsFullscreen3D(false); + if (target === selectedSection) return; + if (switchTimerRef.current) clearTimeout(switchTimerRef.current); + setIsSwitching(true); + switchTimerRef.current = setTimeout(() => { + setSelectedSection(target); + setIsSwitching(false); + switchTimerRef.current = null; + }, 180); + }; + + const updateScrollState = useCallback(() => { + const menu = menuRef.current; + if (!menu) return; + setCanScrollLeft(menu.scrollLeft > 2); + setCanScrollRight(menu.scrollLeft + menu.clientWidth < menu.scrollWidth - 2); + }, []); + + useEffect(() => { + const menu = menuRef.current; + if (!menu || !menuNeedsScroll) { + setCanScrollLeft(false); + setCanScrollRight(false); + return; + } + updateScrollState(); + menu.addEventListener('scroll', updateScrollState); + return () => menu.removeEventListener('scroll', updateScrollState); + }, [menuNeedsScroll, updateScrollState]); + + useLayoutEffect(() => { + const menu = menuRef.current; + if (!menu) return; + const children = Array.from(menu.querySelectorAll('.sight-frame-menu-point')); + if (children.length < 2) { + setMenuNeedsScroll(false); + return; + } + const style = getComputedStyle(menu); + const availableWidth = menu.clientWidth - parseFloat(style.paddingLeft) - parseFloat(style.paddingRight); + const totalChildrenWidth = children.reduce((sum, el) => sum + el.offsetWidth, 0); + const evenGap = (availableWidth - totalChildrenWidth) / (children.length + 1); + setMenuNeedsScroll(evenGap < 10); + }, [articleSections]); + + const { + routeSights, + routeSightsEn, + routeSightsZh, + sightArticles, + sightArticlesEn, + sightArticlesZh, + sightArticlesIds, + } = apiStore; + + const isStringUuid = (id) => + typeof id === "string" && + id.match(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i); + + const fetchMediaForSection = async (sectionId, signal) => { + if (!sectionId || sectionId === "intro-title") return null; + + try { + const usePreviewApi = isStringUuid(sectionId); + const fetchedMediaObj = usePreviewApi + ? await ContentAPI.getMediaPreview(sectionId, "ru", signal) + : await ContentAPI.getMedia(sectionId, "ru", signal); + + return { + path: fetchedMediaObj.path, + type: getMediaType(fetchedMediaObj.type), + }; + } catch (err) { + if (axios.isCancel(err) || err.name === "AbortError") return null; + console.error(`Failed to fetch media for section ${sectionId}:`, err); + return null; + } + }; + + useEffect(() => { + const controller = new AbortController(); + const { signal } = controller; + + const isSameSight = prevSightIdRef.current === sight_id; + prevSightIdRef.current = sight_id; + + if (!isSameSight) { + setIsLoadingContent(true); + setContentError(null); + setModelAspectRatio(null); + setIsVisible(false); + setMediaData({}); + } + + if (!sight_id) { + setContentError("Не указан ID статьи."); + setIsLoadingContent(false); + return () => controller.abort(); + } + + const fetchContent = async () => { + try { + const sight = + selectedLanguageRight === "ru" + ? routeSights.find((sight) => sight.id === sight_id) + : selectedLanguageRight === "en" + ? routeSightsEn.find((sight) => sight.id === sight_id) + : routeSightsZh.find((sight) => sight.id === sight_id); + + if (!sight) { + setContentError("Достопримечательность не найдена."); + setIsLoadingContent(false); + return; + } + + const rightArticleData = sightArticlesIds.get(sight_id); + + if (!rightArticleData) { + setContentError("Данные о статьях не найдены."); + setIsLoadingContent(false); + return; + } + + const rightArticles = rightArticleData.map((articleId) => { + const article = + selectedLanguageRight === "ru" + ? sightArticles.get(articleId.toString() + "_ru") + : selectedLanguageRight === "en" + ? sightArticlesEn.get(articleId.toString() + "_en") + : sightArticlesZh.get(articleId.toString() + "_zh"); + + if (!article) { + console.warn( + `Article not found for ID: ${articleId} in language: ${selectedLanguageRight}`, + ); + return { + id: articleId, + heading: "Статья не найдена", + body: "Содержание статьи недоступно.", + }; + } + + const processBodyText = (text) => { + if (!text) return text; + + const namePattern = + /[А-Яа-яA-Za-z0-9]\.[А-Яа-яA-Za-z0-9]\.[А-Яа-яA-Za-z0-9]+|[А-Яа-яA-Za-z0-9]\. [А-Яа-яA-Za-z0-9]\. [А-Яа-яA-Za-z0-9]+/g; + + const hasNamePattern = namePattern.test(text); + + if (hasNamePattern) { + return text.replace(/\n/g, " "); + } + + return text; + }; + + return { + id: articleId, + heading: article.heading, + body: processBodyText(article.body), + }; + }); + + if (signal.aborted) return; + + setSightData(sight); + const introSection = { + id: media?.id || "intro-title", + heading: + sight?.name || sight_name || "Название достопримечательности", + body: "", + }; + const allSections = [introSection, ...rightArticles]; + setArticleSections(allSections); + + if (isSameSight) { + setIsLoadingContent(false); + setIsVisible(true); + return; + } + + const cacheKey = `${sight_id}_${selectedLanguageRight}`; + if (mediaCache.current[cacheKey]) { + setMediaData(mediaCache.current[cacheKey]); + setIsLoadingContent(false); + setIsVisible(true); + return; + } + + const mediaSections = allSections.filter( + (s) => s.id && s.id !== "intro-title", + ); + const results = await Promise.all( + mediaSections.map((s) => fetchMediaForSection(s.id, signal)), + ); + + if (signal.aborted) return; + + const newMediaData = {}; + mediaSections.forEach((s, i) => { + if (results[i]) newMediaData[s.id] = results[i]; + }); + + mediaCache.current[cacheKey] = newMediaData; + setMediaData(newMediaData); + setIsLoadingContent(false); + setIsVisible(true); + } catch (err) { + if (axios.isCancel(err) || signal.aborted) return; + console.error("Error fetching content:", err); + setContentError("Не удалось загрузить информацию."); + setIsLoadingContent(false); + } + }; + + fetchContent(); + return () => controller.abort(); + }, [sight_id, selectedLanguageRight, media]); + + useEffect(() => { + setSelectedSection(0); + setIsSwitching(false); + if (switchTimerRef.current) { + clearTimeout(switchTimerRef.current); + switchTimerRef.current = null; + } + }, [sight_id]); + + useLayoutEffect(() => { + const scrollable = textWrapperRef.current?.querySelector(".scrollable"); + if (scrollable) scrollable.scrollTop = 0; + }, [selectedSection]); + + useEffect(() => { + if (!isFullscreen3D) return; + + let idleSeconds = 0; + const intervalId = setInterval(() => { + idleSeconds += 1; + + if (idleSeconds >= 60) { + setIsFullscreen3D(false); + } + }, 1000); + + const resetIdle = () => { + idleSeconds = 0; + }; + + const events = ["pointerdown", "pointermove", "touchstart", "keydown"]; + events.forEach((e) => window.addEventListener(e, resetIdle, { passive: true })); + + return () => { + clearInterval(intervalId); + events.forEach((e) => window.removeEventListener(e, resetIdle)); + }; + }, [isFullscreen3D]); + + + const renderCurrentMedia = () => { + if (!articleSections || Object.keys(mediaData).length === 0) { + return ( +
+ {isLoadingContent ? "Загрузка медиа..." : "Нет медиа для отображения"} +
+ ); + } + + const section = articleSections[selectedSection]; + if (!section) return null; + + const sectionId = section.id?.toString(); + const currentMediaData = mediaData[sectionId]; + if (!currentMediaData) return null; + + const className = "sight-frame-media-item visible"; + + try { + switch (currentMediaData.type) { + case "image": + return ( + { + console.warn(`Failed to load image: ${currentMediaData.path}`); + e.target.style.display = "none"; + }} + /> + ); + case "video": + return ( + + ); + case "panorama": + return ( +
+ +
+ ); + case "3d": + return ( +
+ { + setThreeViewResetKey((prev) => prev + 1); + }} + > + + +
+ + + +
+ + {isFullscreen3D && ( +
+
+ {[ + { + label: "Вращать", + icon: ( + + ), + }, + { + label: "Приблизить / Отдалить", + icon: ( + + ), + }, + { + label: "Переместить", + icon: ( + + ), + }, + ].map((item, index, arr) => ( +
+ + {item.icon} + + + {item.label} + +
+ ))} +
+
+ )} +
+ ); + default: + return ( +
+
+ Неподдерживаемый тип медиа: {currentMediaData.type} +
+
+ ); + } + } catch (error) { + console.error(`Error rendering media ${sectionId}:`, error); + return ( +
+
+ Ошибка отображения медиа +
+
+ ); + } + }; + + const isCurrentMedia3D = + currentSection && mediaData[currentSection.id]?.type === "3d"; + + const getMediaStackStyle = () => { + if (isCurrentMedia3D && isFullscreen3D) { + const fullHeight = window.innerHeight * 0.8; + + const width = modelAspectRatio + ? fullHeight * modelAspectRatio + : fullHeight * 1.3; + + const finalWidth = Math.max(width, 550); + const containerWidth = 550; + const extraWidth = Math.max(0, finalWidth - containerWidth); + + return { + height: `${fullHeight}px`, + width: `${finalWidth}px`, + maxWidth: "100vw", + overflow: "visible", + marginLeft: `-${extraWidth}px`, + marginTop: `-2px`, + }; + } + if (isCurrentMedia3D) { + if (modelAspectRatio) { + const width = 400; + const height = Math.min( + width / modelAspectRatio, + window.innerHeight * 0.7, + ); + return { height: `${height}px` }; + } + return { height: "500px" }; + } + return {}; + }; + + const processedSightName = useMemo(() => { + if (!sight_name) return sight_name; + + if (sight_name.includes("\n")) { + return sight_name.split("\n").map((line, i) => ( + + {i > 0 &&
} + {line} +
+ )); + } + + const namePattern = + /([А-Яа-яA-Za-z0-9]\. [А-Яа-яA-Za-z0-9]\. [А-Яа-яA-Za-z0-9]+)/g; + + const parts = sight_name.split(namePattern); + + if (parts.length > 1) { + return parts.map((part, index) => { + const initialsPattern = + /^[А-Яа-яA-Za-z0-9]\. [А-Яа-яA-Za-z0-9]\. [А-Яа-яA-Za-z0-9]+$/; + if (initialsPattern.test(part)) { + return ( + +
+ {part} +
+ ); + } + return {part}; + }); + } + + return sight_name; + }, [sight_name]); + + const titleLineHeight = useMemo(() => { + if (!sight_name) return "120%"; + const textLength = sight_name.replace(/\n/g, "").length; + const calculatedLineHeight = Math.max( + 100, + Math.min(120, 120 - (textLength / 10) * 1), + ); + return `${calculatedLineHeight}%`; + }, [sight_name]); + + return ( + <> +
+ {sightData?.watermark_lu && !isFullscreen3D && ( + + )} +
+ {contentError ? ( +
+ {contentError} +
+ ) : isLoadingContent || !articleSections ? ( +
+ Загрузка контента... +
+ ) : ( + renderCurrentMedia() + )} +
+
+ {contentError ? ( +

{contentError}

+ ) : ( + <> + {!isFullscreen3D && ( +
+

+ {selectedSection === 0 + ? processedSightName || sightData?.name + : sightData?.short_name || sightData?.name} +

+
+ )} + +
+ {isLoadingContent || !currentSection ? ( +
+ ) : ( + selectedSection !== 0 && ( + + ) + )} +
+ + + )} +
+
+
+
+
+
+ {contentError ? ( +

{contentError}

+ ) : ( + articleSections && + articleSections.length > 1 && + articleSections.slice(1).map((section, index) => ( +
requestSection(index + 1)} + key={section.id || section.heading || index} + data-label={section.heading} + className={`sight-frame-menu-point ${ + index + 1 === selectedSection ? "active" : "" + }`} + role="button" + tabIndex="0" + > + {section.heading} +
+ )) + )} +
+
+
requestSection(0)} + > + +
+ + ); +}); + +export default SightFrame; diff --git a/src/client/src/components/ListOfSights/SightsList.jsx b/src/client/src/components/ListOfSights/SightsList.jsx new file mode 100644 index 0000000..3c31462 --- /dev/null +++ b/src/client/src/components/ListOfSights/SightsList.jsx @@ -0,0 +1,50 @@ +import React from "react"; +import SightComponent from "./SightComponent"; +import { TouchableLayout } from "../TouchableLayout"; + +const SightsList = function SightsList({ + isOpen, + isLoading, + error, + sightData, + onSightClick, + sightRefs, + sightsListRef, + highlightedSights, +}) { + return ( + +
+ {isLoading ? ( +

Загрузка достопримечательностей...

+ ) : error ? ( +

{error}

+ ) : !sightData || sightData.length === 0 ? ( +

Достопримечательности не найдены.

+ ) : ( + sightData.map((sight) => ( + (sightRefs.current[sight.id] = el)} + isHighlighted={ + highlightedSights && highlightedSights.has(sight.id) + } + /> + )) + )} +
+
+ ); +}; + +export default SightsList; diff --git a/src/client/src/components/ListOfSights/TransferWidget.jsx b/src/client/src/components/ListOfSights/TransferWidget.jsx new file mode 100644 index 0000000..e53d20c --- /dev/null +++ b/src/client/src/components/ListOfSights/TransferWidget.jsx @@ -0,0 +1,322 @@ +import React, { useState, useEffect } from "react"; +import "../../styles/TransferWidget.css"; +import { observer } from "mobx-react-lite"; +import { useGeolocationStore } from "../../stores"; +import { apiStore } from "../../api/ApiStore/store"; +import { getTargetStation } from "../../utils/routeStationsUtils"; +import busIcon from "../../assets/transport-icons/bus.svg"; +import metroBlueIcon from "../../assets/transport-icons/metroBlue.svg"; +import metroGreenIcon from "../../assets/transport-icons/metroGreen.svg"; +import metroOrangeIcon from "../../assets/transport-icons/metroOrange.svg"; +import metroPurpleIcon from "../../assets/transport-icons/metroPurple.svg"; +import metroRedIcon from "../../assets/transport-icons/metroRed.svg"; +import trainIcon from "../../assets/transport-icons/train.svg"; +import tramIcon from "../../assets/transport-icons/tram.svg"; +import trolleyIcon from "../../assets/transport-icons/trolley.svg"; + +const TransferWidget = observer(function TransferWidget({ + isOpen, + selectedLanguageRight, +}) { + const { nearestStationId, currentStationId, setCurrentStationId } = + useGeolocationStore(); + const { + routeStations, + routeStationsEn, + routeStationsZh, + orderedRouteStations, + context, + route, + } = apiStore; + const [currentTransferData, setCurrentTransferData] = useState(null); + + useEffect(() => { + if (nearestStationId != null) { + setCurrentStationId(nearestStationId); + } + }, [nearestStationId, setCurrentStationId]); + + useEffect(() => { + if (!routeStations || !context?.currentCoordinates) { + setCurrentTransferData(null); + return; + } + + let targetStation = null; + let targetStationId = null; + + if (nearestStationId != null) { + targetStation = routeStations.find((s) => s.id == nearestStationId); + targetStationId = nearestStationId; + } else if ( + orderedRouteStations && + orderedRouteStations.length > 0 && + route?.path + ) { + targetStation = getTargetStation( + context.currentCoordinates, + orderedRouteStations, + route.path + ); + + if (targetStation) { + targetStationId = targetStation.id; + } + } + + if (targetStation) { + setCurrentTransferData(targetStation.transfers); + setCurrentStationId(targetStationId); + } else { + setCurrentTransferData(null); + setCurrentStationId(null); + } + }, [ + nearestStationId, + routeStations, + orderedRouteStations, + route?.path, + context?.currentCoordinates, + ]); + + let stationName = null; + if (currentStationId != null) { + const stationsByLang = + selectedLanguageRight === "ru" + ? routeStations + : selectedLanguageRight === "en" + ? routeStationsEn + : routeStationsZh; + + const station = + stationsByLang && + stationsByLang.find((s) => String(s.id) === String(currentStationId)); + stationName = station ? station.name : null; + } + + const getTransferLabel = () => { + if (selectedLanguageRight === "ru") { + return stationName ? ( + <> +
Пересадка на остановке
+
«{stationName}»:
+ + ) : ( + "Ближайшая остановка не обнаружена" + ); + } + + if (selectedLanguageRight === "en") { + return stationName ? ( + <> +
Transfer at stop
+
«{stationName}»:
+ + ) : ( + "Nearest station not found" + ); + } + + return stationName ? ( + <> +
在站点换乘
+
«{stationName}»:
+ + ) : ( + "最近的站点未找到" + ); + }; + + const getNoTransfersMessage = () => { + return selectedLanguageRight === "ru" + ? "Нет доступных пересадок" + : selectedLanguageRight === "en" + ? "No transfers available" + : "没有可用的中转"; + }; + + return ( +
+
+ {getTransferLabel()} + {currentTransferData != null && + !Object.values(currentTransferData).every((value) => value === "") ? ( + <> +
+ {currentTransferData?.metro_red?.length > 0 ? ( +
+ Metro Red Icon + {currentTransferData.metro_red} +
+ ) : ( + "" + )} +
+
+ {currentTransferData?.metro_green?.length > 0 ? ( +
+ Metro Green Icon + {currentTransferData.metro_green} +
+ ) : ( + "" + )} +
+
+ {currentTransferData?.metro_blue?.length > 0 ? ( +
+ Metro Blue Icon + {currentTransferData.metro_blue} +
+ ) : ( + "" + )} +
+
+ {currentTransferData?.metro_orange?.length > 0 ? ( +
+ Metro Orange Icon + {currentTransferData.metro_orange} +
+ ) : ( + "" + )} +
+
+ {currentTransferData?.metro_purple?.length > 0 ? ( +
+ Metro Purple Icon + {currentTransferData.metro_purple} +
+ ) : ( + "" + )} +
+
+ {currentTransferData?.tram?.length > 0 ? ( +
+ Tram Icon + {currentTransferData.tram} +
+ ) : ( + "" + )} +
+
+ {currentTransferData?.trolleybus?.length > 0 ? ( +
+ Trolleybus Icon + {currentTransferData.trolleybus} +
+ ) : ( + "" + )} +
+
+ {currentTransferData?.bus?.length > 0 ? ( +
+ Bus Icon + {currentTransferData.bus} +
+ ) : ( + "" + )} +
+
+ {currentTransferData?.train?.length > 0 ? ( +
+ Train Icon + {currentTransferData.train} +
+ ) : ( + "" + )} +
+ + ) : ( +
{getNoTransfersMessage()}
+ )} +
+
+ ); +}); + +export default TransferWidget; diff --git a/src/client/src/components/ListOfSights/index.js b/src/client/src/components/ListOfSights/index.js new file mode 100644 index 0000000..76a97bc --- /dev/null +++ b/src/client/src/components/ListOfSights/index.js @@ -0,0 +1,7 @@ +export { default as SightFrame } from "./SightFrame"; +export { default as SightComponent } from "./SightComponent"; +export { default as LanguageSelector } from "./LanguageSelector"; +export { default as TransferWidget } from "./TransferWidget"; +export { default as AlphabetNavigator } from "./AlphabetNavigator"; +export { default as SightsList } from "./SightsList"; +export { default as ListHeader } from "./ListHeader"; diff --git a/src/client/src/components/Loader.jsx b/src/client/src/components/Loader.jsx new file mode 100644 index 0000000..765df0b --- /dev/null +++ b/src/client/src/components/Loader.jsx @@ -0,0 +1,80 @@ +import React, { useState, useEffect } from "react"; +import { createPortal } from "react-dom"; +import "../styles/Loader.css"; +import { getMediaUrl } from "../api/apiConfig"; + +const loaderVideoPath = new URL("../assets/video/taganai.mp4", import.meta.url) + .href; + +const Loader = ({ + loadingStatus = null, + loadingProgress = null, + onToggleDebug, + videoId = null, +}) => { + const [showLoadingInfo, setShowLoadingInfo] = useState(false); + const [isCustomVideoError, setIsCustomVideoError] = useState(false); + + useEffect(() => { + setIsCustomVideoError(false); + }, [videoId]); + + useEffect(() => { + const handleKeyDown = (event) => { + if (event.key === "F3") { + event.preventDefault(); + setShowLoadingInfo((prev) => !prev); + } + if (event.key === "F4") { + event.preventDefault(); + onToggleDebug?.(); + } + }; + + window.addEventListener("keydown", handleKeyDown); + + return () => { + window.removeEventListener("keydown", handleKeyDown); + }; + }, [onToggleDebug]); + + const getStatusText = () => { + if (!loadingStatus) return "Данные не загружены"; + + if (loadingProgress) { + const percentage = Math.round( + (loadingProgress.current / loadingProgress.total) * 100, + ); + return `${loadingStatus} [${loadingProgress.current}/${loadingProgress.total}] ${percentage}%`; + } + + return loadingStatus; + }; + + return createPortal( +
+ {showLoadingInfo &&
{getStatusText()}
} +
, + document.body, + ); +}; + +export default Loader; diff --git a/src/client/src/components/OverlayScrollbarsWrapper.d.ts b/src/client/src/components/OverlayScrollbarsWrapper.d.ts new file mode 100644 index 0000000..26e322e --- /dev/null +++ b/src/client/src/components/OverlayScrollbarsWrapper.d.ts @@ -0,0 +1,13 @@ +import React from "react"; + +export declare const OverlayScrollbarsWrapper: React.ForwardRefExoticComponent< + React.PropsWithChildren<{ + className?: string; + onScroll?: (event: Event) => void; + overflowX?: string; + overflowY?: string; + scrollbarVisibility?: string; + [key: string]: any; + }> & + React.RefAttributes +>; diff --git a/src/client/src/components/OverlayScrollbarsWrapper.jsx b/src/client/src/components/OverlayScrollbarsWrapper.jsx new file mode 100644 index 0000000..8395c73 --- /dev/null +++ b/src/client/src/components/OverlayScrollbarsWrapper.jsx @@ -0,0 +1,326 @@ +import React, { useMemo, useEffect, useRef, useCallback } from "react"; +import { OverlayScrollbarsComponent } from "overlayscrollbars-react"; +import "overlayscrollbars/overlayscrollbars.css"; + +/** + * Обертка для OverlayScrollbars с правильной интеграцией с React + */ +export const OverlayScrollbarsWrapper = React.forwardRef( + ( + { + children, + className, + onScroll, + overflowX = "hidden", + overflowY = "scroll", + scrollbarVisibility = "auto", + ...props + }, + ref + ) => { + const internalRef = useRef(null); + const scrollHandlerRef = useRef(onScroll); + + // Обновляем ref обработчика при изменении + useEffect(() => { + scrollHandlerRef.current = onScroll; + }, [onScroll]); + + // Получаем DOM-элемент из OverlayScrollbars компонента + const getDOMElement = (element) => { + if (!element) return null; + + // Если это уже DOM элемент + if (typeof element.querySelector === "function") { + return element; + } + + // Если это экземпляр OverlayScrollbars, получаем host элемент + if (element.osInstance && typeof element.osInstance === "function") { + const instance = element.osInstance(); + if (instance && instance.getElements) { + const elements = instance.getElements(); + return elements?.host || null; + } + } + + // Пытаемся получить DOM-элемент через различные методы + if (element?.getElement && typeof element.getElement === "function") { + return element.getElement(); + } + + if (element?.$el) { + return element.$el; + } + + if (element?.current) { + return element.current; + } + + // Если это объект с host свойством (может быть экземпляр) + if (element?.host && typeof element.host.querySelector === "function") { + return element.host; + } + + return null; + }; + + // Получаем viewport элемент (scrollable контейнер) + const getViewportElement = (element) => { + const domElement = getDOMElement(element); + if (!domElement || typeof domElement.querySelector !== "function") { + return null; + } + return domElement.querySelector(".os-viewport"); + }; + + // Объединяем внешний ref с viewport элементом + useEffect(() => { + if (!internalRef.current) return; + + // Функция для обновления ref с viewport элементом + const updateRef = () => { + const viewport = getViewportElement(internalRef.current); + if (viewport) { + if (typeof ref === "function") { + ref(viewport); + } else if (ref) { + ref.current = viewport; + } + return true; + } + return false; + }; + + // Пытаемся получить viewport сразу + const found = updateRef(); + if (found) return; // Если нашли сразу, выходим + + // Если viewport еще не создан, ждем его появления + const domElement = getDOMElement(internalRef.current); + if (domElement && typeof domElement.observe === "function") { + const observer = new MutationObserver(() => { + if (updateRef()) { + observer.disconnect(); + } + }); + + observer.observe(domElement, { + childList: true, + subtree: true, + }); + + // Также проверяем периодически + const checkInterval = setInterval(() => { + if (updateRef()) { + clearInterval(checkInterval); + observer.disconnect(); + } + }, 100); + + // Очищаем через 2 секунды + setTimeout(() => { + clearInterval(checkInterval); + observer.disconnect(); + }, 2000); + + return () => { + clearInterval(checkInterval); + observer.disconnect(); + }; + } else { + // Fallback: периодическая проверка + const checkInterval = setInterval(() => { + if (updateRef()) { + clearInterval(checkInterval); + } + }, 100); + + setTimeout(() => { + clearInterval(checkInterval); + }, 2000); + + return () => { + clearInterval(checkInterval); + }; + } + }, [ref]); + + // Подключаем обработчик события скролла напрямую на viewport элемент + useEffect(() => { + const element = internalRef.current; + if (!element || !scrollHandlerRef.current) return; + + const handleScroll = (event) => { + if (scrollHandlerRef.current) { + scrollHandlerRef.current(event); + } + }; + + // Ждем, пока OverlayScrollbars создаст viewport элемент + const setupScrollHandler = () => { + const viewport = getViewportElement(element); + if (viewport) { + viewport.addEventListener("scroll", handleScroll); + return () => { + viewport.removeEventListener("scroll", handleScroll); + }; + } + return null; + }; + + // Пытаемся подключить сразу + let cleanup = setupScrollHandler(); + + // Если viewport еще не создан, ждем его появления + if (!cleanup) { + const domElement = getDOMElement(element); + if (!domElement || typeof domElement.observe !== "function") { + // Если не можем наблюдать за изменениями, просто периодически проверяем + const checkInterval = setInterval(() => { + if (!cleanup) { + cleanup = setupScrollHandler(); + if (cleanup) { + clearInterval(checkInterval); + } + } + }, 100); + + setTimeout(() => { + clearInterval(checkInterval); + }, 2000); + + return () => { + clearInterval(checkInterval); + if (cleanup) cleanup(); + }; + } + + const observer = new MutationObserver(() => { + cleanup = setupScrollHandler(); + if (cleanup) { + observer.disconnect(); + } + }); + + observer.observe(domElement, { + childList: true, + subtree: true, + }); + + // Также проверяем периодически на случай, если MutationObserver не сработал + const checkInterval = setInterval(() => { + if (!cleanup) { + cleanup = setupScrollHandler(); + if (cleanup) { + clearInterval(checkInterval); + observer.disconnect(); + } + } + }, 100); + + // Очищаем через 2 секунды, если viewport так и не появился + setTimeout(() => { + clearInterval(checkInterval); + observer.disconnect(); + }, 2000); + + return () => { + clearInterval(checkInterval); + observer.disconnect(); + if (cleanup) cleanup(); + }; + } + + return cleanup; + }, []); + + const options = useMemo( + () => ({ + scrollbars: { + theme: "os-theme-custom", + visibility: scrollbarVisibility, + autoHide: "never", + autoHideDelay: 0, + dragScrolling: true, + clickScrolling: false, + touchSupport: true, + snapHandle: false, + }, + overflow: { + x: overflowX, + y: overflowY, + }, + }), + [overflowX, overflowY, scrollbarVisibility] + ); + + // Обработчик инициализации OverlayScrollbars + const handleInitialized = useCallback( + (instance) => { + if (!instance) { + console.warn("OverlayScrollbars: instance is null"); + return; + } + + // Получаем DOM элементы из экземпляра + // Метод может называться elements() или getElements() + let elements; + if (typeof instance.elements === "function") { + elements = instance.elements(); + } else if (typeof instance.getElements === "function") { + elements = instance.getElements(); + } else { + console.warn("OverlayScrollbars: cannot get elements from instance"); + return; + } + + const viewport = elements?.viewport; + + if (!viewport) { + console.warn("OverlayScrollbars: viewport element not found"); + return; + } + + if (ref) { + // Обновляем внешний ref с viewport элементом + if (typeof ref === "function") { + ref(viewport); + } else if (ref) { + ref.current = viewport; + } + } + }, + [ref] + ); + + const events = useMemo( + () => ({ + initialized: () => { + // После инициализации получаем instance через ref + if (!internalRef.current) return; + + const instance = internalRef.current.osInstance?.(); + if (instance) { + handleInitialized(instance); + } + }, + }), + [handleInitialized] + ); + + return ( + + {children} + + ); + } +); + +OverlayScrollbarsWrapper.displayName = "OverlayScrollbarsWrapper"; diff --git a/src/client/src/components/ReactMarkdown/ReactMarkdown.css b/src/client/src/components/ReactMarkdown/ReactMarkdown.css new file mode 100644 index 0000000..3f4e7a1 --- /dev/null +++ b/src/client/src/components/ReactMarkdown/ReactMarkdown.css @@ -0,0 +1,149 @@ +.react-markdown-container { + width: 100%; + max-width: 100%; + font-size: 0.875rem; + line-height: 150%; + color: white; +} + +.react-markdown-container img { + max-width: 100%; + height: auto; + border-radius: 4px; +} + +.react-markdown-container h1, +.react-markdown-container h2, +.react-markdown-container h3, +.react-markdown-container h4, +.react-markdown-container h5, +.react-markdown-container h6 { + color: white; + margin-top: 16px; + margin-bottom: 8px; + font-weight: 600; +} + +.react-markdown-container h1 { + font-size: 36px; + line-height: 150%; +} + +.react-markdown-container h2 { + font-size: 30px; + line-height: 150%; +} + +.react-markdown-container h3 { + font-size: 24px; + line-height: 150%; +} + +.react-markdown-container h4 { + font-size: 20px; + line-height: 150%; +} + +.react-markdown-container h5 { + font-size: 18px; + line-height: 150%; +} + +.react-markdown-container h6 { + line-height: 150%; + font-size: 16px; +} + +.react-markdown-container p { + margin-bottom: 16px; + font-size: 18px; + line-height: 150%; + color: white; +} + +.react-markdown-container a { + color: white; + text-decoration: none; +} + +.react-markdown-container a:hover { + text-decoration: underline; +} + +.react-markdown-container blockquote { + border-left: 4px solid var(--carrier-main, #006F3A); + padding-left: 16px; + margin-top: 16px; + margin-bottom: 16px; + color: rgba(255, 255, 255, 0.7); + font-style: italic; +} + +.react-markdown-container code { + background-color: rgba(0, 0, 0, 0.2); + padding: 4px; + border-radius: 4px; + color: #fcd500; + font-family: "Courier New", monospace; + font-size: 0.875em; +} + +.react-markdown-container pre { + background-color: rgba(0, 0, 0, 0.2); + padding: 16px; + border-radius: 4px; + overflow-x: auto; + margin-bottom: 16px; +} + +.react-markdown-container pre code { + background-color: transparent; + padding: 0; +} + +.react-markdown-container ul, +.react-markdown-container ol { + margin-bottom: 16px; + padding-left: 24px; + color: white; +} + +.react-markdown-container li { + margin-bottom: 8px; + color: white; +} + +.react-markdown-container strong { + font-weight: 600; + color: white; +} + +.react-markdown-container em { + font-style: italic; + color: white; +} + +.react-markdown-container hr { + border: none; + border-top: 1px solid rgba(255, 255, 255, 0.2); + margin: 24px 0; +} + +.react-markdown-container table { + width: 100%; + border-collapse: collapse; + margin-bottom: 16px; +} + +.react-markdown-container th, +.react-markdown-container td { + border: 1px solid rgba(255, 255, 255, 0.2); + padding: 8px; + text-align: left; + color: white; +} + +.react-markdown-container th { + font-weight: 600; + background-color: rgba(0, 0, 0, 0.2); +} diff --git a/src/client/src/components/ReactMarkdown/index.tsx b/src/client/src/components/ReactMarkdown/index.tsx new file mode 100644 index 0000000..53da0b7 --- /dev/null +++ b/src/client/src/components/ReactMarkdown/index.tsx @@ -0,0 +1,12 @@ +import "react"; +import ReactMarkdown from "react-markdown"; +import rehypeRaw from "rehype-raw"; +import "./ReactMarkdown.css"; + +export const ReactMarkdownComponent = ({ value }: { value: string }) => { + return ( +
+ {value} +
+ ); +}; diff --git a/src/client/src/components/SimulationSettings.tsx b/src/client/src/components/SimulationSettings.tsx new file mode 100644 index 0000000..c99fc06 --- /dev/null +++ b/src/client/src/components/SimulationSettings.tsx @@ -0,0 +1,135 @@ +import { useState } from "react"; +import { observer } from "mobx-react-lite"; +import { apiStore } from "../api/ApiStore/store"; + +export const SimulationSettings = observer(() => { + const [open, setOpen] = useState(false); + + return ( +
+ + + {open && ( +
+ {/* Пауза */} + + Пауза + + + + {/* Направление */} + + Направление + + + + {/* Скорость */} + + Скорость +
+ + + {apiStore.simulationSpeed}x + + +
+
+ + {/* Без анимации */} + + Без анимации + + + + {/* Хитбоксы */} + + Хитбоксы + + +
+ )} +
+ ); +}); + +function Row({ children }: { children: React.ReactNode }) { + return ( +
+ {children} +
+ ); +} + +function Toggle({ on, onClick }: { on: boolean; onClick: () => void }) { + return ( + + ); +} + +const btnStyle: React.CSSProperties = { + width: 28, height: 24, borderRadius: 4, + border: "1px solid rgba(255,255,255,0.2)", + background: "rgba(255,255,255,0.08)", + color: "white", cursor: "pointer", fontSize: 14, + display: "flex", alignItems: "center", justifyContent: "center", +}; diff --git a/src/client/src/components/StoreDebugInfo.jsx b/src/client/src/components/StoreDebugInfo.jsx new file mode 100644 index 0000000..9a67726 --- /dev/null +++ b/src/client/src/components/StoreDebugInfo.jsx @@ -0,0 +1,118 @@ +import React from "react"; +import { createPortal } from "react-dom"; +import { observer } from "mobx-react-lite"; +import { apiStore } from "../api/ApiStore/store"; +import { geolocationStore } from "../stores/GeolocationStore"; +import { apiBaseURL, geoBaseURL, weatherBaseURL } from "../api/apiConfig"; + +const StoreDebugInfo = observer(({ isVisible }) => { + if (!isVisible) return null; + + const serializeMap = (map) => { + if (!map || !(map instanceof Map)) return map; + const obj = {}; + map.forEach((value, key) => { + obj[key] = value; + }); + return obj; + }; + + const serializeSet = (set) => { + if (!set || !(set.forEach)) return set; + const arr = []; + set.forEach((value) => arr.push(value)); + return arr; + }; + + const debugData = { + apiStore: { + isLoading: apiStore.isLoading, + loadingStatus: apiStore.loadingStatus, + loadingProgress: apiStore.loadingProgress, + context: apiStore.context, + weather: apiStore.weather, + route: apiStore.route, + carrier: apiStore.carrier, + city: apiStore.city, + routeId: apiStore.routeId, + routeSights: { count: apiStore.routeSights?.length ?? 0, items: apiStore.routeSights ?? [] }, + routeSightsEn: { count: apiStore.routeSightsEn?.length ?? 0 }, + routeSightsZh: { count: apiStore.routeSightsZh?.length ?? 0 }, + routeStations: { count: apiStore.routeStations?.length ?? 0, items: apiStore.routeStations ?? [] }, + routeStationsEn: { count: apiStore.routeStationsEn?.length ?? 0 }, + routeStationsZh: { count: apiStore.routeStationsZh?.length ?? 0 }, + orderedRouteStations: { count: apiStore.orderedRouteStations?.length ?? 0 }, + sightArticlesIds: serializeMap(apiStore.sightArticlesIds), + sightArticles: serializeMap(apiStore.sightArticles), + media: { count: apiStore.media?.length ?? 0, items: apiStore.media ?? [] }, + }, + geolocationStore: { + contextData: geolocationStore.contextData, + nearestSightId: geolocationStore.nearestSightId, + selectedSightId: geolocationStore.selectedSightId, + selectedLanguage: geolocationStore.selectedLanguage, + selectedLanguageRight: geolocationStore.selectedLanguageRight, + nearestStationId: geolocationStore.nearestStationId, + currentStationId: geolocationStore.currentStationId, + isLeftWidgetOpen: geolocationStore.isLeftWidgetOpen, + isTransferWidgetOpen: geolocationStore.isTransferWidgetOpen, + isGovernorWidgetOpen: geolocationStore.isGovernorWidgetOpen, + isManualSelection: geolocationStore.isManualSelection, + isRightWidgetSelectorOpen: geolocationStore.isRightWidgetSelectorOpen, + activeClusterId: geolocationStore.activeClusterId, + isLoading: geolocationStore.isLoading, + error: geolocationStore.error, + }, + }; + + return createPortal( +
+
+
+ Store Debug (F4 — закрыть) +
+
+
API: {apiBaseURL}
+
GEO: {geoBaseURL}
+
WEATHER: {weatherBaseURL}
+
+ {JSON.stringify(debugData, null, 2)} +
+
, + document.body + ); +}); + +export default StoreDebugInfo; diff --git a/src/client/src/components/ThreeViewErrorBoundary.css b/src/client/src/components/ThreeViewErrorBoundary.css new file mode 100644 index 0000000..9605852 --- /dev/null +++ b/src/client/src/components/ThreeViewErrorBoundary.css @@ -0,0 +1,147 @@ +.three-view-error-boundary { + width: 100%; + height: 100%; + display: flex; + justify-content: center; + align-items: center; + padding: 20px; + background-color: rgba(0, 0, 0, 0.05); + min-height: 300px; +} + +.error-container { + max-width: 500px; + width: 100%; + padding: 24px; + background: linear-gradient( + 114deg, + rgba(255, 255, 255, 0.1) 8.71%, + rgba(255, 255, 255, 0.05) 69.69% + ), var(--carrier-main, #006F3A); + border-radius: 12px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.1); + color: white; + position: relative; +} + +.error-header { + display: flex; + align-items: center; + margin-bottom: 16px; +} + +.error-icon { + font-size: 32px; + margin-right: 12px; +} + +.error-title { + margin: 0; + font-size: 20px; + font-weight: 600; + color: white; +} + +.error-message { + margin-bottom: 16px; + font-size: 16px; + line-height: 1.5; + color: rgba(255, 255, 255, 0.9); +} + +.error-reasons { + margin-bottom: 16px; +} + +.error-reasons p { + margin: 0 0 8px 0; + font-size: 14px; + font-weight: 500; + color: rgba(255, 255, 255, 0.8); +} + +.error-reasons ul { + margin: 8px 0; + padding-left: 20px; + color: rgba(255, 255, 255, 0.7); +} + +.error-reasons li { + margin-bottom: 4px; + font-size: 14px; +} + +.error-details { + margin-bottom: 16px; + background-color: rgba(0, 0, 0, 0.2); + padding: 12px; + border-radius: 6px; + border-left: 3px solid rgba(255, 255, 255, 0.3); +} + +.error-details pre { + margin: 0; + font-family: 'Courier New', monospace; + font-size: 12px; + color: rgba(255, 255, 255, 0.8); + word-break: break-word; + white-space: pre-wrap; + max-height: 100px; + overflow-y: auto; +} + +.error-actions { + display: flex; + gap: 12px; +} + +.retry-button { + background: linear-gradient(135deg, rgba(255, 255, 255, 0.2), rgba(255, 255, 255, 0.1)); + border: 1px solid rgba(255, 255, 255, 0.3); + color: white; + padding: 12px 24px; + border-radius: 8px; + font-size: 14px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; + display: flex; + align-items: center; + gap: 8px; +} + +.retry-button:hover { + background: linear-gradient(135deg, rgba(255, 255, 255, 0.3), rgba(255, 255, 255, 0.2)); + border-color: rgba(255, 255, 255, 0.5); + transform: translateY(-1px); +} + +.retry-button:active { + transform: translateY(0); + background: linear-gradient(135deg, rgba(255, 255, 255, 0.1), rgba(255, 255, 255, 0.05)); +} + +/* Адаптивность */ +@media (max-width: 768px) { + .three-view-error-boundary { + padding: 16px; + } + + .error-container { + padding: 20px; + max-width: 100%; + } + + .error-title { + font-size: 18px; + } + + .error-message { + font-size: 14px; + } + + .retry-button { + padding: 10px 20px; + font-size: 13px; + } +} diff --git a/src/client/src/components/ThreeViewErrorBoundary.tsx b/src/client/src/components/ThreeViewErrorBoundary.tsx new file mode 100644 index 0000000..075bd69 --- /dev/null +++ b/src/client/src/components/ThreeViewErrorBoundary.tsx @@ -0,0 +1,177 @@ +import React, { Component, ReactNode } from "react"; +import "./ThreeViewErrorBoundary.css"; + +interface Props { + children: ReactNode; + onReset?: () => void; + resetKey?: number | string; +} + +interface State { + hasError: boolean; + error: Error | null; + lastResetKey?: number | string; +} + +export class ThreeViewErrorBoundary extends Component { + constructor(props: Props) { + super(props); + this.state = { + hasError: false, + error: null, + lastResetKey: props.resetKey, + }; + } + + static getDerivedStateFromError(error: Error): Partial { + return { + hasError: true, + error, + }; + } + + static getDerivedStateFromProps( + props: Props, + state: State + ): Partial | null { + // Сбрасываем ошибку ТОЛЬКО при смене медиа (когда меняется ID в resetKey) + if ( + props.resetKey !== state.lastResetKey && + state.lastResetKey !== undefined + ) { + const oldMediaId = String(state.lastResetKey).split("-")[0]; + const newMediaId = String(props.resetKey).split("-")[0]; + + // Сбрасываем ошибку только если изменился ID медиа (пользователь выбрал другую модель) + if (oldMediaId !== newMediaId) { + return { + hasError: false, + error: null, + lastResetKey: props.resetKey, + }; + } + + // Если изменился только счетчик (нажата кнопка "Попробовать снова"), обновляем lastResetKey + // но не сбрасываем ошибку автоматически - ждем результата загрузки + + return { + lastResetKey: props.resetKey, + }; + } + + if (state.lastResetKey === undefined && props.resetKey !== undefined) { + return { + lastResetKey: props.resetKey, + }; + } + + return null; + } + + componentDidCatch(error: Error, errorInfo: React.ErrorInfo) { + console.error("❌ ThreeViewErrorBoundary: Ошибка загрузки 3D модели", { + error: error.message, + stack: error.stack, + componentStack: errorInfo.componentStack, + }); + } + + getErrorMessage = () => { + const errorMessage = this.state.error?.message || ""; + + if ( + errorMessage.includes("not valid JSON") || + errorMessage.includes("Unexpected token") + ) { + return "Неверный формат файла. Убедитесь, что файл является корректной 3D моделью в формате GLB/GLTF."; + } + + if (errorMessage.includes("Could not load")) { + return "Не удалось загрузить файл 3D модели. Проверьте, что файл существует и доступен."; + } + + if (errorMessage.includes("404") || errorMessage.includes("Not Found")) { + return "Файл 3D модели не найден на сервере."; + } + + if (errorMessage.includes("Network") || errorMessage.includes("fetch")) { + return "Ошибка сети при загрузке 3D модели. Проверьте интернет-соединение."; + } + + return ( + errorMessage || "Произошла неизвестная ошибка при загрузке 3D модели" + ); + }; + + getErrorReasons = () => { + const errorMessage = this.state.error?.message || ""; + + if ( + errorMessage.includes("not valid JSON") || + errorMessage.includes("Unexpected token") + ) { + return [ + "Файл не является 3D моделью", + "Загружен файл неподдерживаемого формата", + "Файл поврежден или не полностью загружен", + "Используйте только GLB или GLTF форматы", + ]; + } + + return [ + "Поврежденный файл модели", + "Неподдерживаемый формат", + "Проблемы с загрузкой файла", + ]; + }; + + handleReset = () => { + // Сначала сбрасываем состояние ошибки + this.setState( + { + hasError: false, + error: null, + }, + () => { + // После того как состояние обновилось, вызываем callback для изменения resetKey + // Это приведет к пересозданию компонента и новой попытке загрузки + this.props.onReset?.(); + } + ); + }; + + handleClose = () => { + this.setState({ + hasError: false, + error: null, + }); + }; + + render() { + if (this.state.hasError) { + return ( +
+
+
+
⚠️
+

Ошибка загрузки 3D модели

+
+ +
+ +
+
+
+ ); + } + + return this.props.children; + } +} diff --git a/src/client/src/components/TouchableLayout/index.tsx b/src/client/src/components/TouchableLayout/index.tsx new file mode 100644 index 0000000..dc15894 --- /dev/null +++ b/src/client/src/components/TouchableLayout/index.tsx @@ -0,0 +1,304 @@ +import React, { + useEffect, + useRef, + useState, + useCallback, + ReactNode, + forwardRef, +} from "react"; +import "../../styles/TouchableLayout.css"; + +interface TouchableLayoutProps { + children?: ReactNode; + className?: string; + maxHeight?: string | number; + style?: React.CSSProperties; +} + +function useThumbSync(scrollableRef: React.RefObject) { + const [state, setState] = useState({ + height: 60, + top: 0, + hasScroll: false, + isAtTop: true, + isAtBottom: false, + }); + const [visible, setVisible] = useState(false); + const rafRef = useRef(null); + + const update = useCallback(() => { + const el = scrollableRef.current; + if (!el) return; + + if (el.closest('[data-height-animating="true"]')) { + return; + } + + const sh = el.scrollHeight; + const ch = el.clientHeight; + const st = el.scrollTop; + const th = ch; + + const isAtTop = st <= 0; + const isAtBottom = st + ch >= sh - 1; + + if (sh <= ch + 2) { + setState((prev) => ({ ...prev, hasScroll: false, isAtTop: true, isAtBottom: true })); + return; + } + + const thumbHeight = Math.max(60, (ch / sh) * th); + const range = th - thumbHeight; + const scrollRange = sh - ch; + const top = range <= 0 ? 0 : (st / scrollRange) * range; + + setState({ height: thumbHeight, top, hasScroll: true, isAtTop, isAtBottom }); + }, []); + + useEffect(() => { + const el = scrollableRef.current; + if (!el) return; + + const schedule = () => { + if (rafRef.current != null) return; + rafRef.current = requestAnimationFrame(() => { + rafRef.current = null; + update(); + }); + }; + + el.addEventListener("scroll", schedule, { passive: true }); + el.addEventListener("layoutchange", schedule); + const ro = new ResizeObserver(schedule); + ro.observe(el); + const mo = new MutationObserver(schedule); + mo.observe(el, { childList: true, subtree: true, characterData: true }); + schedule(); + + return () => { + el.removeEventListener("scroll", schedule); + el.removeEventListener("layoutchange", schedule); + ro.disconnect(); + mo.disconnect(); + if (rafRef.current != null) cancelAnimationFrame(rafRef.current); + }; + }, [update]); + + useEffect(() => { + if (!state.hasScroll) { + setVisible(false); + return; + } + const t = setTimeout(() => setVisible(true), 250); + return () => clearTimeout(t); + }, [state.hasScroll]); + + return { ...state, visible }; +} + +export const TouchableLayout = forwardRef( + ({ children, className, maxHeight, style }, ref) => { + const containerRef = useRef(null); + const scrollableRef = useRef(null); + const trackRef = useRef(null); + const thumbRef = useRef(null); + + const thumb = useThumbSync(scrollableRef); + + const setRefs = useCallback( + (node: HTMLDivElement | null) => { + ( + containerRef as React.MutableRefObject + ).current = node; + if (typeof ref === "function") { + ref(node); + } else if (ref) { + (ref as React.MutableRefObject).current = node; + } + }, + [ref], + ); + + useEffect(() => { + const scrollable = scrollableRef.current; + if (!scrollable) return; + + let isDown = false; + let isDragging = false; + let startY = 0; + let startScrollTop = 0; + const DRAG_THRESHOLD = 5; + + const handlePointerDown = (e: Event) => { + const pe = e as PointerEvent; + + isDown = true; + isDragging = false; + startY = pe.clientY; + startScrollTop = scrollable.scrollTop; + }; + + const handlePointerMove = (e: Event) => { + if (!isDown) return; + const pe = e as PointerEvent; + + const walk = pe.clientY - startY; + + if (!isDragging && Math.abs(walk) < DRAG_THRESHOLD) { + return; + } + + if (!isDragging) { + isDragging = true; + scrollable.setPointerCapture(pe.pointerId); + } + + pe.preventDefault(); + + scrollable.scrollTop = Math.max( + 0, + Math.min( + scrollable.scrollHeight - scrollable.clientHeight, + startScrollTop - walk, + ), + ); + }; + + const stopScrolling = (e: Event) => { + if (!isDown) return; + const pe = e as PointerEvent; + + if (isDragging) { + pe.preventDefault(); + isDragging = false; + } + + isDown = false; + try { + scrollable.releasePointerCapture(pe.pointerId); + } catch { + } + }; + + scrollable.addEventListener("pointerdown", handlePointerDown); + scrollable.addEventListener("pointermove", handlePointerMove); + scrollable.addEventListener("pointerup", stopScrolling); + scrollable.addEventListener("pointercancel", stopScrolling); + + return () => { + scrollable.removeEventListener("pointerdown", handlePointerDown); + scrollable.removeEventListener("pointermove", handlePointerMove); + scrollable.removeEventListener("pointerup", stopScrolling); + scrollable.removeEventListener("pointercancel", stopScrolling); + }; + }, []); + + useEffect(() => { + if (!thumb.hasScroll) return; + + const scrollable = scrollableRef.current; + const track = trackRef.current; + const thumbEl = thumbRef.current; + if (!scrollable || !track || !thumbEl) return; + + let isDragging = false; + let startY = 0; + let startScrollTop = 0; + + const onPointerDown = (e: PointerEvent) => { + e.preventDefault(); + isDragging = true; + thumbEl.setPointerCapture(e.pointerId); + startY = e.clientY; + startScrollTop = scrollable.scrollTop; + }; + + const onPointerMove = (e: PointerEvent) => { + if (!isDragging) return; + e.preventDefault(); + const th = track.offsetHeight; + const thumbH = thumbEl.offsetHeight; + const range = th - thumbH; + const scrollRange = scrollable.scrollHeight - scrollable.clientHeight; + if (range <= 0 || scrollRange <= 0) return; + + const dy = e.clientY - startY; + const dScroll = (dy / range) * scrollRange; + scrollable.scrollTop = Math.max( + 0, + Math.min(scrollRange, startScrollTop + dScroll), + ); + }; + + const onPointerUp = (e: PointerEvent) => { + if (isDragging) { + isDragging = false; + thumbEl.releasePointerCapture(e.pointerId); + } + }; + + thumbEl.addEventListener("pointerdown", onPointerDown as EventListener); + thumbEl.addEventListener("pointermove", onPointerMove as EventListener); + thumbEl.addEventListener("pointerup", onPointerUp as EventListener); + thumbEl.addEventListener("pointercancel", onPointerUp as EventListener); + + return () => { + thumbEl.removeEventListener( + "pointerdown", + onPointerDown as EventListener, + ); + thumbEl.removeEventListener( + "pointermove", + onPointerMove as EventListener, + ); + thumbEl.removeEventListener("pointerup", onPointerUp as EventListener); + thumbEl.removeEventListener( + "pointercancel", + onPointerUp as EventListener, + ); + }; + }, [thumb.hasScroll]); + + const containerClassName = [ + "scrollable-container", + className, + thumb.isAtTop ? "is-at-top" : "", + thumb.isAtBottom ? "is-at-bottom" : "", + ].filter(Boolean).join(" "); + + const viewportStyle: React.CSSProperties = maxHeight + ? { + maxHeight: + typeof maxHeight === "number" ? `${maxHeight}px` : maxHeight, + } + : {}; + + return ( +
+
+
+ {children} +
+
+
+
+
+
+ ); + }, +); + +TouchableLayout.displayName = "TouchableLayout"; diff --git a/src/client/src/components/WeatherWidget.jsx b/src/client/src/components/WeatherWidget.jsx new file mode 100644 index 0000000..30a0632 --- /dev/null +++ b/src/client/src/components/WeatherWidget.jsx @@ -0,0 +1,197 @@ +import "../styles/WeatherWidget.css"; +import { useState, useEffect, useRef } from "react"; +import { observer } from "mobx-react-lite"; +import weatherApi from "../api/weather/weather.api"; + +import getWeatherIcon from "../utils/getWeatherIcon"; +import windIcon from "../assets/weather-icons/wind.png"; +import humidityIcon from "../assets/weather-icons/humidity.png"; +import { useGeolocationStore } from "../stores"; +import { translateWeatherStatus } from "../utils/translateWeatherStatus"; +import { apiStore } from "../api/ApiStore"; + +function WeatherDataLine({ icon, value, unit }) { + return ( +
+ Weather icon +
+ {unit ? ( + <> + {value} + {unit} + + ) : ( + value + )} +
+
+ ); +} + +const WeatherWidget = observer(() => { + const [currentTime, setCurrentTime] = useState(new Date()); + const [weather, setWeather] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [hasLoadedOnce, setHasLoadedOnce] = useState(false); + const lastDateRef = useRef(() => { + const today = new Date(); + return `${today.getFullYear()}-${today.getMonth()}-${today.getDate()}`; + }); + + const store = useGeolocationStore(); + const { selectedLanguage, setError: setErrorStore } = store; + + const fetchWeather = async () => { + try { + const data = await weatherApi.getFormattedWeather({ + lat: apiStore.context?.currentCoordinates?.latitude, + lng: apiStore.context?.currentCoordinates?.longitude, + cityCode: apiStore.city?.weather_city_code, + }); + setWeather(data); + setError(null); + setHasLoadedOnce(true); + } catch (err) { + setError(err.message); + setErrorStore(err.message); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + fetchWeather(); + }, []); + + useEffect(() => { + const timer = setInterval(() => { + const now = new Date(); + setCurrentTime(now); + + const newDate = `${now.getFullYear()}-${now.getMonth()}-${now.getDate()}`; + if (newDate !== lastDateRef.current) { + lastDateRef.current = newDate; + + fetchWeather(); + } + }, 1000); + + return () => clearInterval(timer); + }, []); + + useEffect(() => { + const intervalId = setInterval( + () => { + fetchWeather(); + }, + 5 * 60 * 1000, + ); + + return () => clearInterval(intervalId); + }, [setErrorStore]); + + const formattedTime = currentTime.toLocaleTimeString( + selectedLanguage === "ru" + ? "ru-RU" + : selectedLanguage === "zh" + ? "zh-ZH" + : "en-GB", + { hour: "2-digit", minute: "2-digit" }, + ); + const day = currentTime.getDate().toString().padStart(2, "0"); + const month = (currentTime.getMonth() + 1).toString().padStart(2, "0"); + const weekday = currentTime.toLocaleDateString( + selectedLanguage === "ru" + ? "ru-RU" + : selectedLanguage === "zh" + ? "zh-ZH" + : "en-GB", + { weekday: "long" }, + ); + const formattedDate = `${day}.${month}, ${weekday}`; + + const formatDayOfWeek = (daysToAdd) => { + const date = new Date(); + date.setDate(date.getDate() + daysToAdd); + const dayName = date.toLocaleDateString( + selectedLanguage === "ru" + ? "ru-RU" + : selectedLanguage === "zh" + ? "zh-ZH" + : "en-US", + { weekday: "short" }, + ); + return dayName.charAt(0).toUpperCase() + dayName.slice(1); + }; + + if (!hasLoadedOnce) { + return null; + } + + if (!weather) { + return null; + } + + return ( +
+
{formattedTime}
+
{formattedDate}
+
+
+
+ Weather condition +
+ {weather.today.temperature}° +
+
+ {translateWeatherStatus(weather.today.status, selectedLanguage)} +
+
+
+ {weather.tomorrow.temperature !== "N/A" && ( + + )} + {weather.dayAfterTomorrow.temperature !== "N/A" && ( + + )} +
+ + +
+
+
+ ); +}); + +export default WeatherWidget; diff --git a/src/client/src/components/map/Constants.tsx b/src/client/src/components/map/Constants.tsx new file mode 100644 index 0000000..d69c2ef --- /dev/null +++ b/src/client/src/components/map/Constants.tsx @@ -0,0 +1,12 @@ +export const UP_SCALE = 10000; +export const PATH_WIDTH = 5; +export const STATION_RADIUS = 8; +export const STATION_OUTLINE_WIDTH = 4; +export const BACKGROUND_COLOR = 0x111111; +export const PATH_COLOR = 0xed1c24; +export const BUS_COLOR = 0xfcd500; +export const UNPASSED_STATION_COLOR = 0xcccccc; +export const BASE_ICON_SIZE = 30; +export const CLUSTER_RADIUS_BASE = 10; +export const CLUSTER_COLOR = 0x896f58; +export const ACTIVE_STATION_COLOR = 0xffa500; diff --git a/src/client/src/components/map/Map.tsx b/src/client/src/components/map/Map.tsx new file mode 100644 index 0000000..ecf745e --- /dev/null +++ b/src/client/src/components/map/Map.tsx @@ -0,0 +1,237 @@ +import { + useCallback, + useEffect, + useState, + useMemo, +} from "react"; +import { observer } from "mobx-react-lite"; +import { MapDataProvider, useMapData } from "./MapDataContext"; +import { TransformProvider, useTransform } from "./transformContext"; +// @ts-ignore +import Loader from "../Loader"; +import { UP_SCALE } from "./Constants"; +import "../../styles/MapLayer.css"; +import { useCameraAnimationStore } from "../../stores"; +import { coordinatesToLocal } from "./utils"; +import { SCALE_FACTOR } from "../../assets/Constants"; +import { apiStore } from "../../api/ApiStore/store"; +import WebGLMap from "./WebGLMap"; + +export function Map() { + return ( + + + + + + ); +} + +function rotatePoint(x: number, y: number, originX: number, originY: number, angle: number) { + const cos = Math.cos(angle); + const sin = Math.sin(angle); + const dx = x - originX; + const dy = y - originY; + const rotatedX = dx * cos - dy * sin + originX; + const rotatedY = dx * sin + dy * cos + originY; + return { x: rotatedX, y: rotatedY }; +} + +const RouteMap = observer(() => { + const { + routeData, + stationData, + stationDataEn, + stationDataZh, + sightData, + setRouteData, + setStationData, + setStationDataEn, + setStationDataZh, + setSightData, + } = useMapData(); + const { + setPosition, + setScreenCenter, + position, + screenCenter, + isAutoMode, + setScale, + scale, + } = useTransform(); + const cameraAnimationStore = useCameraAnimationStore(); + + const [rotationAngle, setRotationAngle] = useState(0); + + const { + routeStations, + context, + routeStationsEn, + routeStationsZh, + routeSights, + route, + isLoading, + } = apiStore; + + useEffect(() => { + if (!isLoading) { + setStationData(routeStations!); + setStationDataEn(routeStationsEn!); + setStationDataZh(routeStationsZh!); + setSightData(routeSights!); + setRouteData(route!); + setRotationAngle(route?.rotate! * (Math.PI / 180)); + cameraAnimationStore.setMaxZoom(route?.scale_max! / SCALE_FACTOR); + cameraAnimationStore.setMinZoom(route?.scale_min! / SCALE_FACTOR); + } + }, [isLoading]); + + useEffect(() => { + cameraAnimationStore.setUpdateCallback((newPos, newZoom) => { + setPosition(newPos); + setScale(newZoom); + }); + + cameraAnimationStore.syncState(position, scale); + + return () => { + cameraAnimationStore.setUpdateCallback(null); + }; + }, [cameraAnimationStore, setPosition, setScale]); + + useEffect(() => { + const canvasRect = { + left: 0, + top: 0, + width: window.innerWidth, + height: window.innerHeight, + }; + const centerX = canvasRect.width / 2; + const centerY = canvasRect.height / 2; + setScreenCenter({ x: centerX, y: centerY }); + + if (routeData?.center_latitude && routeData?.center_longitude) { + const initialCameraPosition = { x: centerX, y: centerY }; + const initialZoom = cameraAnimationStore.minZoom; + cameraAnimationStore.syncState(initialCameraPosition, initialZoom); + } else { + setPosition({ x: centerX, y: centerY }); + } + }, [routeData, cameraAnimationStore]); + + const centerLat = routeData?.center_latitude; + const centerLon = routeData?.center_longitude; + const rotationOriginX = 0; + const rotationOriginY = 0; + + const transformGeoToMapLocal = useCallback( + (latitude: number, longitude: number) => { + if (centerLat === undefined || centerLon === undefined) { + return { x: 0, y: 0 }; + } + const local = coordinatesToLocal( + latitude - centerLat, + longitude - centerLon + ); + return { x: local.x * UP_SCALE, y: local.y * UP_SCALE }; + }, + [centerLat, centerLon] + ); + + const transformedCurrentCoordinates = useMemo(() => { + if ( + !context?.currentCoordinates || + centerLat === undefined || + centerLon === undefined + ) { + return undefined; + } + + return rotatePoint( + transformGeoToMapLocal( + context.currentCoordinates.latitude, + context.currentCoordinates.longitude + ).x, + transformGeoToMapLocal( + context.currentCoordinates.latitude, + context.currentCoordinates.longitude + ).y, + rotationOriginX, + rotationOriginY, + rotationAngle + ); + }, [ + context?.currentCoordinates?.latitude, + context?.currentCoordinates?.longitude, + centerLat, + centerLon, + transformGeoToMapLocal, + rotationOriginX, + rotationOriginY, + rotationAngle, + ]); + + const transformedStations = useMemo(() => { + if (!stationData) return []; + + return stationData.map((station) => { + const { x, y } = transformGeoToMapLocal( + station.latitude, + station.longitude + ); + const rotatedCoords = rotatePoint( + x, + y, + rotationOriginX, + rotationOriginY, + rotationAngle + ); + return { + ...station, + longitude: rotatedCoords.x, + latitude: rotatedCoords.y, + }; + }); + }, [ + stationData, + transformGeoToMapLocal, + rotationOriginX, + rotationOriginY, + rotationAngle, + ]); + + useEffect(() => { + if (isAutoMode && transformedCurrentCoordinates && screenCenter) { + cameraAnimationStore.followTram( + { + x: transformedCurrentCoordinates.x, + y: transformedCurrentCoordinates.y, + }, + { x: screenCenter.x, y: screenCenter.y }, + transformedStations + ); + } else if (!isAutoMode) { + cameraAnimationStore.stopAnimation(); + } + }, [ + isAutoMode, + transformedCurrentCoordinates, + screenCenter, + cameraAnimationStore, + transformedStations, + ]); + + if ( + !routeData || + !stationData || + !stationDataEn || + !stationDataZh || + !sightData + ) { + return ; + } + + if (true) { + return ; + } +}); diff --git a/src/client/src/components/map/MapDataContext.tsx b/src/client/src/components/map/MapDataContext.tsx new file mode 100644 index 0000000..d1196ba --- /dev/null +++ b/src/client/src/components/map/MapDataContext.tsx @@ -0,0 +1,64 @@ +import { createContext, ReactNode, useContext, useState } from "react"; +import { RouteData, StationData, SightData } from "./types"; + +const MapDataContext = createContext<{ + routeData?: RouteData; + stationData?: StationData[]; + stationDataEn?: StationData[]; + stationDataZh?: StationData[]; + sightData?: SightData[]; // <--- ИЗМЕНЕНО: теперь это массив SightData[] + setRouteData: React.Dispatch>; + setStationData: React.Dispatch< + React.SetStateAction + >; + setStationDataEn: React.Dispatch< + React.SetStateAction + >; + setStationDataZh: React.Dispatch< + React.SetStateAction + >; + setSightData: React.Dispatch>; // <--- ИЗМЕНЕНО: теперь это массив SightData[] +}>({ + routeData: undefined, + stationData: undefined, + stationDataEn: undefined, + sightData: undefined, // Инициализация sightData + setRouteData: () => {}, + setStationData: () => {}, + setStationDataEn: () => {}, + setStationDataZh: () => {}, + setSightData: () => {}, +}); + +export const MapDataProvider = ({ children }: { children: ReactNode }) => { + const [routeData, setRouteData] = useState(); + const [stationData, setStationData] = useState(); + const [stationDataEn, setStationDataEn] = useState(); + const [stationDataZh, setStationDataZh] = useState(); + const [sightData, setSightData] = useState(); // <--- ИЗМЕНЕНО: теперь это массив SightData[] + + const value = { + routeData, + stationData, + stationDataEn, + stationDataZh, + sightData, + setRouteData, + setStationData, + setStationDataEn, + setSightData, + setStationDataZh, + }; + + return ( + {children} + ); +}; + +export const useMapData = () => { + const context = useContext(MapDataContext); + if (!context) { + throw new Error("useMapData must be used within a MapDataProvider"); + } + return context; +}; diff --git a/src/client/src/components/map/TramIconWebGL.tsx b/src/client/src/components/map/TramIconWebGL.tsx new file mode 100644 index 0000000..df7a452 --- /dev/null +++ b/src/client/src/components/map/TramIconWebGL.tsx @@ -0,0 +1,123 @@ +import React, { useEffect, useState, useRef } from "react"; +import { lerpAngle } from "../../utils/animationUtils"; +import tramBase from "../../assets/tramPosition/Tram Base.svg"; +import tramSvg from "../../assets/tramPosition/Tram_Second.svg"; +import { getMediaUrl } from "../../api/apiConfig"; + +const LERP_SPEED = 0.1; + +interface TramIconWebGLProps { + x: number; + y: number; + optimalAngle: number; + scale: number; + routeIcon?: string | null; +} + +export const TramIconWebGL: React.FC = ({ + x, + y, + optimalAngle, + scale, + routeIcon, +}) => { + const tramIconSrc = routeIcon + ? getMediaUrl(routeIcon) + : tramSvg; + const [smoothOptimalAngle, setSmoothOptimalAngle] = useState(optimalAngle); + const animationRef = useRef(undefined); + + useEffect(() => { + const animate = () => { + setSmoothOptimalAngle((prev) => { + const newAngle = lerpAngle(prev, optimalAngle, LERP_SPEED); + return Math.abs(newAngle - optimalAngle) < 0.01 + ? optimalAngle + : newAngle; + }); + + if (Math.abs(smoothOptimalAngle - optimalAngle) > 0.01) { + animationRef.current = requestAnimationFrame(animate); + } + }; + + animationRef.current = requestAnimationFrame(animate); + + return () => { + if (animationRef.current !== undefined) { + cancelAnimationFrame(animationRef.current); + } + }; + }, [optimalAngle, smoothOptimalAngle]); + + const backgroundWidth = 111 / (scale * 12); + const backgroundHeight = 82 / (scale * 12); + const tramWidth = 31 / (scale * 12); + const tramHeight = 52 / (scale * 12); + + const outerRotation = ((smoothOptimalAngle + Math.PI) * 180) / Math.PI; + const innerRotation = ((-smoothOptimalAngle - Math.PI) * 180) / Math.PI; + + const bgWidth = Math.max(backgroundWidth, 100); + const bgHeight = Math.max(backgroundHeight, 100); + + const minTramWidth = Math.max(tramWidth, 31); + const minTramHeight = Math.max(tramHeight, 52); + + return ( +
+
+ Tram Base + +
53.7 ? -bgWidth / 1.42 : -bgWidth / 0.98, + top: 0, + transform: `translate(-50%, -50%) rotate(${innerRotation}deg)`, + width: minTramWidth, + height: minTramHeight, + }} + > + Tram +
+
+
+ ); +}; diff --git a/src/client/src/components/map/WebGLMap.tsx b/src/client/src/components/map/WebGLMap.tsx new file mode 100644 index 0000000..7c020f4 --- /dev/null +++ b/src/client/src/components/map/WebGLMap.tsx @@ -0,0 +1,3217 @@ +import React, { + useEffect, + useMemo, + useRef, + useCallback, + useState, +} from "react"; +import { observer } from "mobx-react-lite"; +import { useMapData } from "./MapDataContext"; +import { SightData } from "./types"; +import { useTransform } from "./transformContext"; +import { coordinatesToLocal } from "./utils"; +import { + UP_SCALE, + PATH_COLOR, + BACKGROUND_COLOR, + UNPASSED_STATION_COLOR, + BUS_COLOR, + BASE_ICON_SIZE, +} from "./Constants"; +import { SCALE_FACTOR } from "../../assets/Constants"; +import { apiStore } from "../../api/ApiStore/store"; +import { useGeolocationStore } from "../../stores/hooks/useGeolocationStore"; +import { useRouteFollowingPosition } from "../../hooks/useRouteFollowingPosition"; +import { useCameraAnimationStore } from "../../stores"; +import { TramIconWebGL } from "./TramIconWebGL"; +import { OverlayScrollbarsWrapper } from "../OverlayScrollbarsWrapper"; +import { apiBaseURL } from "../../api/apiConfig"; +import "../../styles/OverlayScrollbars.css"; +const SIGHT_ICON_URL = new URL("../../assets/images/sight.svg", import.meta.url) + .href; +const SIGHT_ICON_BASE_SIZE = 30; +const CUSTOM_SIGHT_ICON_BASE_SCALE = 0.1; +const DEBUG_WEBGL_ROUTE_MAP = true; +const YELLOW_ICON_FILTER = + "brightness(0) saturate(100%) invert(84%) sepia(99%) saturate(3457%) hue-rotate(358deg) brightness(104%) contrast(99%)"; +const clamp = (value: number, min: number, max: number) => + Math.min(max, Math.max(min, value)); + +const debugWebglLog = (..._args: unknown[]) => { + if (!DEBUG_WEBGL_ROUTE_MAP) return; +}; + +const isMediaIdEmpty = (id: string | null) => { + if (id == null || id === "") return true; + const digits = id.replace(/-/g, ""); + return digits === "" || /^0+$/.test(digits); +}; + +const appendTokenToUrl = (url: string, token: string): string => { + if (!token) return url; + if (/([?&])token=/.test(url)) return url; + const separator = url.includes("?") ? "&" : "?"; + return `${url}${separator}token=${encodeURIComponent(token)}`; +}; + +const buildMediaDownloadUrl = ( + baseUrl: string, + mediaId: string, + token: string, +): string => { + if (/^https?:\/\//i.test(mediaId)) { + return appendTokenToUrl(mediaId, token); + } + + const normalizedBase = baseUrl.replace(/\/+$/, ""); + const normalizedMediaId = mediaId.replace(/^\/+/, ""); + + if (/^media\/.+\/download$/i.test(normalizedMediaId)) { + return appendTokenToUrl(`${normalizedBase}/${normalizedMediaId}`, token); + } + + if (/^media$/i.test(normalizedBase.split("/").pop() ?? "")) { + return appendTokenToUrl( + `${normalizedBase}/${normalizedMediaId}/download`, + token, + ); + } + + return appendTokenToUrl( + `${normalizedBase}/media/${normalizedMediaId}/download`, + token, + ); +}; + +type Cluster = { + id: string; + longitude: number; + latitude: number; + count: number; + sights: SightData[]; +}; + +type PointItem = { type: "point"; id: string; data: SightData }; +type ClusterItem = { type: "cluster"; id: string; data: Cluster }; +type ClusteredItem = PointItem | ClusterItem; + +const getDistance = ( + p1: { longitude: number; latitude: number }, + p2: { longitude: number; latitude: number }, +) => { + return Math.sqrt( + Math.pow(p1.longitude - p2.longitude, 2) + + Math.pow(p1.latitude - p2.latitude, 2), + ); +}; + +const getDistanceFromPointToPath = ( + point: { longitude: number; latitude: number }, + pathPoints: { x: number; y: number }[], +): number => { + if (!pathPoints || pathPoints.length < 2) { + return Infinity; + } + + let minDistance = Infinity; + + for (let i = 0; i < pathPoints.length - 1; i++) { + const p1 = pathPoints[i]; + const p2 = pathPoints[i + 1]; + + const lineLengthSq = (p2.x - p1.x) ** 2 + (p2.y - p1.y) ** 2; + if (lineLengthSq === 0) continue; + + const t = + ((point.longitude - p1.x) * (p2.x - p1.x) + + (point.latitude - p1.y) * (p2.y - p1.y)) / + lineLengthSq; + const projectionT = Math.max(0, Math.min(1, t)); + + const projectedX = p1.x + projectionT * (p2.x - p1.x); + const projectedY = p1.y + projectionT * (p2.y - p1.y); + + const dist = Math.sqrt( + (point.longitude - projectedX) ** 2 + (point.latitude - projectedY) ** 2, + ); + minDistance = Math.min(minDistance, dist); + } + return minDistance; +}; + +const useSightClustering = ( + sights: SightData[], + distanceThreshold: number, + pathPoints: { x: number; y: number }[], +): ClusteredItem[] => { + return useMemo(() => { + const unclusteredSights: (SightData & { visited?: boolean })[] = sights.map( + (s) => ({ ...s }), + ); + const clusteredResult: ClusteredItem[] = []; + + for (const sight of unclusteredSights) { + if (sight.visited) { + continue; + } + + const hasCustomIcon = + sight.is_default_icon === false && !isMediaIdEmpty(sight.icon ?? null); + + if (hasCustomIcon) { + sight.visited = true; + clusteredResult.push({ + type: "point", + id: String(sight.id), + data: sight, + }); + continue; + } + + const clusterSights: SightData[] = []; + const queue = [sight]; + sight.visited = true; + + while (queue.length > 0 && clusterSights.length < 4) { + const current = queue.shift()!; + clusterSights.push(current); + + for (const potentialNeighbor of unclusteredSights) { + const neighborHasCustomIcon = + potentialNeighbor.is_default_icon === false && + !isMediaIdEmpty(potentialNeighbor.icon ?? null); + if ( + !potentialNeighbor.visited && + !neighborHasCustomIcon && + clusterSights.length < 4 && + getDistance(current, potentialNeighbor) < distanceThreshold + ) { + potentialNeighbor.visited = true; + queue.push(potentialNeighbor); + } + } + } + + for (const leftover of queue) { + leftover.visited = false; + } + + if (clusterSights.length > 1) { + let furthestSight: SightData | null = null; + let maxDistanceToPath = -1; + + for (const s of clusterSights) { + const dist = getDistanceFromPointToPath(s, pathPoints); + if (dist > maxDistanceToPath) { + maxDistanceToPath = dist; + furthestSight = s; + } + } + + const count = clusterSights.length; + const longitude = furthestSight + ? furthestSight.longitude + : clusterSights.reduce((sum, s) => sum + s.longitude, 0) / count; + const latitude = furthestSight + ? furthestSight.latitude + : clusterSights.reduce((sum, s) => sum + s.latitude, 0) / count; + + const id = `cluster-${clusterSights[0].id}`; + clusteredResult.push({ + type: "cluster", + id, + data: { id, count, longitude, latitude, sights: clusterSights }, + }); + } else { + const singleSight = clusterSights[0]; + clusteredResult.push({ + type: "point", + id: String(singleSight.id), + data: singleSight, + }); + } + } + return clusteredResult; + }, [sights, distanceThreshold, pathPoints]); +}; + +const getAnchorFromOffset = (align: number): { x: number; y: number } => { + let anchorX: number; + if (align === 1) { + anchorX = 0; + } else if (align === 3) { + anchorX = 1; + } else { + anchorX = 0.5; + } + + const anchorY = 0.5; + + return { x: anchorX, y: anchorY }; +}; + +function initWebGLContext( + canvas: HTMLCanvasElement, +): WebGLRenderingContext | null { + const gl = + (canvas.getContext("webgl") as WebGLRenderingContext | null) || + (canvas.getContext("experimental-webgl") as WebGLRenderingContext | null); + return gl; +} + +function resizeCanvasToDisplaySize(canvas: HTMLCanvasElement): boolean { + const dpr = Math.max(1, window.devicePixelRatio || 1); + const displayWidth = Math.floor(canvas.clientWidth * dpr); + const displayHeight = Math.floor(canvas.clientHeight * dpr); + if (canvas.width !== displayWidth || canvas.height !== displayHeight) { + canvas.width = displayWidth; + canvas.height = displayHeight; + return true; + } + return false; +} + +export const WebGLMap = observer(() => { + const canvasRef = useRef(null); + const glRef = useRef(null); + const programRef = useRef(null); + const bufferRef = useRef(null); + const pointProgramRef = useRef(null); + const pointBufferRef = useRef(null); + const screenLineProgramRef = useRef(null); + const screenLineBufferRef = useRef(null); + const attribsRef = useRef<{ a_pos: number } | null>(null); + const uniformsRef = useRef<{ + u_cameraPos: WebGLUniformLocation | null; + u_scale: WebGLUniformLocation | null; + u_resolution: WebGLUniformLocation | null; + u_color: WebGLUniformLocation | null; + } | null>(null); + const customSightIconBaseScaleRef = useRef(null); + const liveSightIconSizesRef = useRef>(new Map()); + const lastCameraLogScaleRef = useRef(null); + const lastDrawLogSignatureRef = useRef(null); + const [, forceSightIconScaleRender] = useState(0); + + const { routeData, stationData, stationDataEn, stationDataZh, sightData } = + useMapData() as any; + const { + position, + scale, + setPosition, + setScale, + isAutoMode, + setIsAutoMode, + screenCenter, + setScreenCenter, + userActivityTimestamp, + updateUserActivity, + } = useTransform(); + + const cameraAnimationStore = useCameraAnimationStore(); + + const scaleLimitsRef = useRef({ + min: null as number | null, + max: null as number | null, + }); + + const routeBoundsRef = useRef<{ + minX: number; + maxX: number; + minY: number; + maxY: number; + } | null>(null); + + const store = useGeolocationStore(); + const { + selectedSightId, + setSelectedSightId, + setIsManualSelection, + setIsRightWidgetSelectorOpen, + setIsGovernorWidgetOpen, + setIsLeftWidgetOpen, + activeClusterId, + setActiveClusterId, + nearestStationId, + setNearestStationId, + setIsTransferWidgetOpen, + selectedLanguage, + currentStationId, + } = store; + + const { + routeSights, + routeSightsEn, + routeSightsZh, + sightArticles, + sightArticlesEn, + sightArticlesZh, + orderedRouteStations, + route: originalRouteData, + } = apiStore; + + const mediaBaseUrl = apiBaseURL; + const mediaToken = localStorage.getItem("token") || ""; + + const clusterAutoCloseTimerRef = useRef | null>( + null, + ); + const hasInitializedCameraRef = useRef(false); + + useEffect(() => { + if ( + routeData?.scale_min !== undefined && + routeData?.scale_max !== undefined + ) { + scaleLimitsRef.current = { + min: routeData.scale_min / SCALE_FACTOR, + max: routeData.scale_max / SCALE_FACTOR, + }; + } + }, [routeData?.scale_min, routeData?.scale_max]); + + useEffect(() => { + customSightIconBaseScaleRef.current = null; + liveSightIconSizesRef.current.clear(); + }, [routeData?.id]); + + useEffect(() => { + if (!hasInitializedCameraRef.current) { + return; + } + + if ( + customSightIconBaseScaleRef.current == null && + Number.isFinite(scale) && + scale > 0 + ) { + customSightIconBaseScaleRef.current = CUSTOM_SIGHT_ICON_BASE_SCALE; + debugWebglLog("custom sight base scale initialized", { + baseScale: CUSTOM_SIGHT_ICON_BASE_SCALE, + currentScale: scale, + }); + forceSightIconScaleRender((version) => version + 1); + } + }, [scale, routeData?.id, routeData?.scale_min, forceSightIconScaleRender]); + + const resolveSightIconSizePercent = useCallback( + (sight?: SightData) => { + const livePercent = + sight != null ? liveSightIconSizesRef.current.get(sight.id) : undefined; + if (typeof livePercent === "number" && Number.isFinite(livePercent)) { + return livePercent; + } + + if (sight?.is_default_icon === false) { + if ( + typeof sight.icon_size === "number" && + Number.isFinite(sight.icon_size) + ) { + return sight.icon_size; + } + return 100; + } + + if ( + typeof routeData?.icon_size === "number" && + Number.isFinite(routeData.icon_size) + ) { + return routeData.icon_size; + } + + if ( + typeof originalRouteData?.icon_size === "number" && + Number.isFinite(originalRouteData.icon_size) + ) { + return originalRouteData.icon_size; + } + + return 100; + }, + [routeData?.icon_size, originalRouteData?.icon_size], + ); + + const clampScale = useCallback((value: number) => { + const { min, max } = scaleLimitsRef.current; + + if (min === null || max === null) { + return value; + } + + const clampedValue = Math.max(min, Math.min(max, value)); + + return clampedValue; + }, []); + + const clampPosition = useCallback( + (pos: { x: number; y: number }, _currentScale: number) => { + return pos; + }, + [], + ); + const positionRef = useRef(position); + const scaleRef = useRef(scale); + const setPositionRef = useRef(setPosition); + const setScaleRef = useRef(setScale); + + useEffect(() => { + setPositionRef.current = setPosition; + }, [setPosition]); + + useEffect(() => { + setScaleRef.current = setScale; + }, [setScale]); + + useEffect(() => { + if (routeData) { + } + }, [routeData]); + + useEffect(() => { + positionRef.current = position; + }, [position]); + + useEffect(() => { + scaleRef.current = scale; + }, [scale]); + + useEffect(() => { + if ( + lastCameraLogScaleRef.current != null && + Math.abs(lastCameraLogScaleRef.current - scale) < 1e-4 + ) { + return; + } + + lastCameraLogScaleRef.current = scale; + debugWebglLog("camera state", { + scale, + position, + scaleLimits: scaleLimitsRef.current, + }); + }, [scale, position.x, position.y]); + + const rotationAngle = useMemo(() => { + const deg = (routeData as any)?.rotate ?? 0; + return (deg * Math.PI) / 180; + }, [routeData]); + + const { + position: animatedYellowDotPosition, + animateTo: animateYellowDotTo, + setPositionImmediate: setYellowDotImmediate, + setRoutePath: setAnimatorRoutePath, + getCurrentSegIndex, + } = useRouteFollowingPosition(800); + + const routePath = useMemo(() => { + if (!routeData?.path || routeData?.path.length === 0) + return new Float32Array(); + const centerLat = routeData.center_latitude; + const centerLon = routeData.center_longitude; + if (centerLat === undefined || centerLon === undefined) + return new Float32Array(); + + const cos = Math.cos(rotationAngle); + const sin = Math.sin(rotationAngle); + + const verts: number[] = []; + for (const [lat, lon] of routeData.path) { + const local = coordinatesToLocal(lat - centerLat, lon - centerLon); + const x = local.x * UP_SCALE; + const y = local.y * UP_SCALE; + const rx = x * cos - y * sin; + const ry = x * sin + y * cos; + verts.push(rx, ry); + } + return new Float32Array(verts); + }, [ + routeData?.path, + routeData?.center_latitude, + routeData?.center_longitude, + rotationAngle, + ]); + + const transformedTramCoords = useMemo(() => { + const centerLat = routeData?.center_latitude; + const centerLon = routeData?.center_longitude; + if (centerLat === undefined || centerLon === undefined) return null; + + const coords: any = apiStore?.context?.currentCoordinates; + if (!coords) return null; + + const local = coordinatesToLocal( + coords.latitude - centerLat, + coords.longitude - centerLon, + ); + const x = local.x * UP_SCALE; + const y = local.y * UP_SCALE; + const cos = Math.cos(rotationAngle); + const sin = Math.sin(rotationAngle); + const rx = x * cos - y * sin; + const ry = x * sin + y * cos; + + return { x: rx, y: ry }; + }, [ + routeData?.center_latitude, + routeData?.center_longitude, + apiStore?.context?.currentCoordinates, + rotationAngle, + ]); + + const transformedTramCoordsRef = useRef<{ + x: number; + y: number; + } | null>(null); + transformedTramCoordsRef.current = transformedTramCoords; + + useEffect(() => { + if (routePath.length >= 4) { + const t = transformedTramCoordsRef.current; + setAnimatorRoutePath(routePath, t ? { x: t.x, y: t.y } : undefined); + } + }, [routePath, setAnimatorRoutePath]); + + useEffect(() => { + const callback = (newPos: { x: number; y: number }, newZoom: number) => { + setPosition(newPos); + setScale(newZoom); + }; + + cameraAnimationStore.setUpdateCallback(callback); + + return () => { + cameraAnimationStore.setUpdateCallback(null); + }; + }, []); + + useEffect(() => { + if ( + routeData?.scale_min !== undefined && + routeData?.scale_max !== undefined + ) { + cameraAnimationStore.setMaxZoom(routeData.scale_max / SCALE_FACTOR); + cameraAnimationStore.setMinZoom(routeData.scale_min / SCALE_FACTOR); + } + }, [routeData?.scale_min, routeData?.scale_max, cameraAnimationStore]); + + useEffect(() => { + const interval = setInterval(() => { + const timeSinceActivity = Date.now() - userActivityTimestamp; + if (timeSinceActivity >= 120000 && !isAutoMode) { + setIsAutoMode(true); + + setIsLeftWidgetOpen(false); + setIsRightWidgetSelectorOpen(false); + setIsTransferWidgetOpen(false); + } + }, 1000); + + return () => clearInterval(interval); + }, [ + userActivityTimestamp, + isAutoMode, + setIsAutoMode, + setIsLeftWidgetOpen, + setIsRightWidgetSelectorOpen, + setIsTransferWidgetOpen, + ]); + + useEffect(() => { + if (activeClusterId === null) { + if (clusterAutoCloseTimerRef.current) { + clearTimeout(clusterAutoCloseTimerRef.current); + clusterAutoCloseTimerRef.current = null; + } + return; + } + + if (clusterAutoCloseTimerRef.current) { + clearTimeout(clusterAutoCloseTimerRef.current); + } + clusterAutoCloseTimerRef.current = setTimeout(() => { + setActiveClusterId(null); + }, 20000); + + return () => { + if (clusterAutoCloseTimerRef.current) { + clearTimeout(clusterAutoCloseTimerRef.current); + clusterAutoCloseTimerRef.current = null; + } + }; + }, [activeClusterId, setActiveClusterId]); + + useEffect(() => { + if (activeClusterId === null) { + return; + } + + const handleClickOutside = (e: MouseEvent | TouchEvent) => { + const target = e.target as HTMLElement; + + const clusterElements = document.querySelectorAll("[data-cluster-id]"); + let clickedOnCluster = false; + + clusterElements.forEach((element) => { + if (element.contains(target) || element === target) { + clickedOnCluster = true; + } + }); + + const expandedCircle = target.closest("[data-expanded-cluster]"); + if (expandedCircle) { + clickedOnCluster = true; + } + + if (!clickedOnCluster) { + setActiveClusterId(null); + if (clusterAutoCloseTimerRef.current) { + clearTimeout(clusterAutoCloseTimerRef.current); + clusterAutoCloseTimerRef.current = null; + } + } + }; + + document.addEventListener("mousedown", handleClickOutside); + document.addEventListener("touchstart", handleClickOutside); + + return () => { + document.removeEventListener("mousedown", handleClickOutside); + document.removeEventListener("touchstart", handleClickOutside); + }; + }, [activeClusterId, setActiveClusterId]); + + useEffect(() => { + if (!hasInitializedCameraRef.current) { + return; + } + + if (cameraAnimationStore.isActivelyAnimating) { + return; + } + + if (isAutoMode && transformedTramCoords && screenCenter) { + const transformedStations = stationData + ? stationData + .map((station: any) => { + const centerLat = routeData?.center_latitude; + const centerLon = routeData?.center_longitude; + if (centerLat === undefined || centerLon === undefined) + return null; + + const local = coordinatesToLocal( + station.latitude - centerLat, + station.longitude - centerLon, + ); + const x = local.x * UP_SCALE; + const y = local.y * UP_SCALE; + const cos = Math.cos(rotationAngle); + const sin = Math.sin(rotationAngle); + const rx = x * cos - y * sin; + const ry = x * sin + y * cos; + + return { + longitude: rx, + latitude: ry, + id: station.id, + }; + }) + .filter(Boolean) + : []; + + if ( + transformedTramCoords && + screenCenter && + transformedStations && + scaleLimitsRef.current !== null && + scaleLimitsRef.current.max !== null && + scaleLimitsRef.current.min && + scaleLimitsRef.current.min !== null + ) { + cameraAnimationStore.setMaxZoom(scaleLimitsRef.current!.max); + cameraAnimationStore.setMinZoom(scaleLimitsRef.current!.min); + + cameraAnimationStore.syncState(positionRef.current, scaleRef.current); + + cameraAnimationStore.followTram( + transformedTramCoords, + screenCenter, + transformedStations, + ); + } + } else if (!isAutoMode) { + cameraAnimationStore.stopAnimation(); + } + }, [ + isAutoMode, + transformedTramCoords, + screenCenter, + cameraAnimationStore, + stationData, + routeData, + rotationAngle, + ]); + + const stationLabels = useMemo(() => { + if (!stationData || !routeData) + return [] as Array<{ + x: number; + y: number; + name: string; + sub?: string; + anchorX: number; + anchorY: number; + distance: number; + }>; + const centerLat = routeData.center_latitude; + const centerLon = routeData.center_longitude; + if (centerLat === undefined || centerLon === undefined) return []; + const cos = Math.cos(rotationAngle); + const sin = Math.sin(rotationAngle); + const result: Array<{ + x: number; + y: number; + name: string; + sub?: string; + anchorX: number; + anchorY: number; + distance: number; + }> = []; + for (let i = 0; i < stationData.length; i++) { + const st = stationData[i]; + const local = coordinatesToLocal( + st.latitude - centerLat, + st.longitude - centerLon, + ); + const x = local.x * UP_SCALE; + const y = local.y * UP_SCALE; + const rx = x * cos - y * sin; + const ry = x * sin + y * cos; + + const dpr = Math.max( + 1, + (typeof window !== "undefined" && window.devicePixelRatio) || 1, + ); + + const DEFAULT_LABEL_OFFSET_X = 25; + const DEFAULT_LABEL_OFFSET_Y = 0; + const offsetXPixels = + st.offset_x === 0 && st.offset_y === 0 + ? DEFAULT_LABEL_OFFSET_X + : st.offset_x; + const offsetYPixels = + st.offset_x === 0 && st.offset_y === 0 + ? DEFAULT_LABEL_OFFSET_Y + : st.offset_y; + + const backendAlign = st?.align ?? 3; + + const fontSizePercent = routeData?.font_size ?? 100; + const fontScale = fontSizePercent / 100; + const fontSize = 16 * fontScale; + + const lineHeight = fontSize * 1.2; + const mainLabelHeight = lineHeight; + + const labelOffsetX = offsetXPixels / scale; + const labelOffsetY = (offsetYPixels + mainLabelHeight / 2) / scale; + + const textBlockPositionX = rx + labelOffsetX; + const textBlockPositionY = ry + labelOffsetY; + + const normalizedName = st.name.replace(/\\n|\n/g, ""); + const approximateTextWidth = normalizedName.length * fontSize * 0.6; + const textWidthInMapCoords = approximateTextWidth / scale; + + let anchorXOffset = 0; + if (backendAlign === 1) { + anchorXOffset = 0; + } else if (backendAlign === 3) { + anchorXOffset = textWidthInMapCoords; + } else { + anchorXOffset = textWidthInMapCoords / 2; + } + + const anchorX = textBlockPositionX + anchorXOffset; + const anchorY = textBlockPositionY; + + const distanceInPixels = Math.hypot(offsetXPixels, offsetYPixels); + + const sx = (textBlockPositionX * scale + position.x) / dpr; + const sy = (textBlockPositionY * scale + position.y) / dpr; + let sub: string | undefined; + if ((selectedLanguage as any) === "zh") + sub = (stationDataZh as any)?.[i]?.name; + else if ( + (selectedLanguage as any) === "en" || + (selectedLanguage as any) === "ru" + ) + sub = (stationDataEn as any)?.[i]?.name; + result.push({ + x: sx, + y: sy, + name: normalizedName, + sub: sub ? sub.replace(/\\n|\n/g, "") : sub, + anchorX: anchorX, + anchorY: anchorY, + distance: distanceInPixels, + }); + } + return result; + }, [ + stationData, + stationDataEn as any, + stationDataZh as any, + position.x, + position.y, + scale, + routeData?.center_latitude, + routeData?.center_longitude, + routeData?.font_size, + rotationAngle, + selectedLanguage as any, + ]); + + const stationPoints = useMemo(() => { + if (!stationData || !routeData) return new Float32Array(); + const centerLat = routeData.center_latitude; + const centerLon = routeData.center_longitude; + if (centerLat === undefined || centerLon === undefined) + return new Float32Array(); + + const cos = Math.cos(rotationAngle); + const sin = Math.sin(rotationAngle); + const verts: number[] = []; + for (const s of stationData) { + const local = coordinatesToLocal( + s.latitude - centerLat, + s.longitude - centerLon, + ); + const x = local.x * UP_SCALE; + const y = local.y * UP_SCALE; + const rx = x * cos - y * sin; + const ry = x * sin + y * cos; + verts.push(rx, ry); + } + return new Float32Array(verts); + }, [ + stationData, + routeData?.center_latitude, + routeData?.center_longitude, + rotationAngle, + ]); + + // Сегментный индекс каждой упорядоченной станции на routePath (canvas-пространство) + const orderedStationSegs = useMemo(() => { + if (!orderedRouteStations || !stationData || routePath.length < 4) + return [] as number[]; + return (orderedRouteStations as any[]).map((ordStation) => { + const stIdx = stationData.findIndex( + (s: any) => String(s.id) === String(ordStation.id), + ); + if (stIdx < 0) return -1; + const sx = stationPoints[stIdx * 2]; + const sy = stationPoints[stIdx * 2 + 1]; + if (sx === undefined || sy === undefined) return -1; + let best = -1, + bestD = Infinity; + for (let i = 0; i < routePath.length - 2; i += 2) { + const p1x = routePath[i], + p1y = routePath[i + 1]; + const p2x = routePath[i + 2], + p2y = routePath[i + 3]; + const dx = p2x - p1x, + dy = p2y - p1y; + const len2 = dx * dx + dy * dy; + if (!len2) continue; + const t = ((sx - p1x) * dx + (sy - p1y) * dy) / len2; + const cl = Math.max(0, Math.min(1, t)); + const px = p1x + cl * dx, + py = p1y + cl * dy; + const d = Math.hypot(sx - px, sy - py); + if (d < bestD) { + bestD = d; + best = i / 2; + } + } + return best; + }); + }, [orderedRouteStations, stationData, stationPoints, routePath]); + + const sightPoints = useMemo(() => { + if (!sightData || !routeData) return new Float32Array(); + const centerLat = routeData.center_latitude; + const centerLon = routeData.center_longitude; + if (centerLat === undefined || centerLon === undefined) + return new Float32Array(); + + const cos = Math.cos(rotationAngle); + const sin = Math.sin(rotationAngle); + const verts: number[] = []; + for (const s of sightData) { + const local = coordinatesToLocal( + s.latitude - centerLat, + s.longitude - centerLon, + ); + const x = local.x * UP_SCALE; + const y = local.y * UP_SCALE; + const rx = x * cos - y * sin; + const ry = x * sin + y * cos; + verts.push(rx, ry); + } + return new Float32Array(verts); + }, [ + sightData, + routeData?.center_latitude, + routeData?.center_longitude, + rotationAngle, + ]); + + const transformedSightData = useMemo(() => { + if (!sightData || !routeData) return []; + const centerLat = routeData.center_latitude; + const centerLon = routeData.center_longitude; + if (centerLat === undefined || centerLon === undefined) return []; + + const cos = Math.cos(rotationAngle); + const sin = Math.sin(rotationAngle); + + return sightData.map((s: any) => { + const local = coordinatesToLocal( + s.latitude - centerLat, + s.longitude - centerLon, + ); + const x = local.x * UP_SCALE; + const y = local.y * UP_SCALE; + const rx = x * cos - y * sin; + const ry = x * sin + y * cos; + + return { + ...s, + longitude: rx, + latitude: ry, + }; + }); + }, [ + sightData, + routeData?.center_latitude, + routeData?.center_longitude, + rotationAngle, + ]); + + const pathPointsForClustering = useMemo(() => { + const points: { x: number; y: number }[] = []; + for (let i = 0; i < routePath.length; i += 2) { + points.push({ x: routePath[i], y: routePath[i + 1] }); + } + return points; + }, [routePath]); + + const distanceThreshold = BASE_ICON_SIZE * 3; + const clusteredSights = useSightClustering( + transformedSightData, + distanceThreshold, + pathPointsForClustering, + ); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + const gl = initWebGLContext(canvas); + glRef.current = gl; + if (!gl) return; + + const vertSrc = ` + attribute vec2 a_pos; + uniform vec2 u_cameraPos; + uniform float u_scale; + uniform vec2 u_resolution; + void main() { + vec2 screen = a_pos * u_scale + u_cameraPos; + vec2 zeroToOne = screen / u_resolution; + vec2 zeroToTwo = zeroToOne * 2.0; + vec2 clip = zeroToTwo - 1.0; + gl_Position = vec4(clip.x, -clip.y, 0.0, 1.0); + } + `; + const fragSrc = ` + precision mediump float; + uniform vec4 u_color; + void main() { + gl_FragColor = u_color; + } + `; + + const compile = (type: number, src: string) => { + const s = gl.createShader(type)!; + gl.shaderSource(s, src); + gl.compileShader(s); + return s; + }; + const vs = compile(gl.VERTEX_SHADER, vertSrc); + const fs = compile(gl.FRAGMENT_SHADER, fragSrc); + const prog = gl.createProgram()!; + gl.attachShader(prog, vs); + gl.attachShader(prog, fs); + gl.linkProgram(prog); + programRef.current = prog; + gl.useProgram(prog); + + const a_pos = gl.getAttribLocation(prog, "a_pos"); + const u_cameraPos = gl.getUniformLocation(prog, "u_cameraPos"); + const u_scale = gl.getUniformLocation(prog, "u_scale"); + const u_resolution = gl.getUniformLocation(prog, "u_resolution"); + const u_color = gl.getUniformLocation(prog, "u_color"); + attribsRef.current = { a_pos }; + uniformsRef.current = { u_cameraPos, u_scale, u_resolution, u_color }; + + const buffer = gl.createBuffer(); + bufferRef.current = buffer; + + const pointVert = ` + attribute vec2 a_pos; + uniform vec2 u_cameraPos; + uniform float u_scale; + uniform vec2 u_resolution; + uniform float u_pointSize; + void main() { + vec2 screen = a_pos * u_scale + u_cameraPos; + vec2 zeroToOne = screen / u_resolution; + vec2 zeroToTwo = zeroToOne * 2.0; + vec2 clip = zeroToTwo - 1.0; + gl_Position = vec4(clip.x, -clip.y, 0.0, 1.0); + gl_PointSize = u_pointSize; + } + `; + const pointFrag = ` + precision mediump float; + uniform vec4 u_color; + void main() { + vec2 c = gl_PointCoord * 2.0 - 1.0; + float d = dot(c, c); + if (d > 1.0) discard; + gl_FragColor = u_color; + } + `; + const vs2 = compile(gl.VERTEX_SHADER, pointVert); + const fs2 = compile(gl.FRAGMENT_SHADER, pointFrag); + const pprog = gl.createProgram()!; + gl.attachShader(pprog, vs2); + gl.attachShader(pprog, fs2); + gl.linkProgram(pprog); + pointProgramRef.current = pprog; + pointBufferRef.current = gl.createBuffer(); + + const lineVert = ` + attribute vec2 a_screen; + uniform vec2 u_resolution; + void main(){ + vec2 zeroToOne = a_screen / u_resolution; + vec2 clip = zeroToOne * 2.0 - 1.0; + gl_Position = vec4(clip.x, -clip.y, 0.0, 1.0); + } + `; + const lineFrag = ` + precision mediump float; + uniform vec4 u_color; + void main(){ gl_FragColor = u_color; } + `; + const lv = compile(gl.VERTEX_SHADER, lineVert); + const lf = compile(gl.FRAGMENT_SHADER, lineFrag); + const lprog = gl.createProgram()!; + gl.attachShader(lprog, lv); + gl.attachShader(lprog, lf); + gl.linkProgram(lprog); + screenLineProgramRef.current = lprog; + screenLineBufferRef.current = gl.createBuffer(); + + const handleResize = () => { + const changed = resizeCanvasToDisplaySize(canvas); + if (!gl) return; + const dpr = Math.max(1, window.devicePixelRatio || 1); + debugWebglLog("container resize", { + changed, + cssWidth: canvas.clientWidth, + cssHeight: canvas.clientHeight, + dpr, + displayWidth: canvas.width, + displayHeight: canvas.height, + }); + setScreenCenter({ + x: canvas.width / 2, + y: canvas.height / 2, + }); + if (changed) { + gl.viewport(0, 0, canvas.width, canvas.height); + } + gl.clearColor(0, 0, 0, 1); + gl.clear(gl.COLOR_BUFFER_BIT); + }; + + handleResize(); + window.addEventListener("resize", handleResize); + return () => { + window.removeEventListener("resize", handleResize); + }; + }, []); + + const prevPositionIndexRef = useRef(-1); + + useEffect(() => { + const centerLat = routeData?.center_latitude; + const centerLon = routeData?.center_longitude; + if (centerLat !== undefined && centerLon !== undefined) { + const coords: any = apiStore?.context?.currentCoordinates; + if (coords) { + const local = coordinatesToLocal( + coords.latitude - centerLat, + coords.longitude - centerLon, + ); + const x = local.x * UP_SCALE; + const y = local.y * UP_SCALE; + const cos = Math.cos(rotationAngle), + sin = Math.sin(rotationAngle); + const rx = x * cos - y * sin; + const ry = x * sin + y * cos; + + const curIdx = apiStore.positionIndex; + const prevIdx = prevPositionIndexRef.current; + const pathLen = apiStore.route?.path?.length ?? 0; + const isWrap = + prevIdx >= 0 && + pathLen > 0 && + Math.abs(curIdx - prevIdx) > pathLen / 4; + prevPositionIndexRef.current = curIdx; + + if (apiStore.simulationInstantMove || isWrap) { + setYellowDotImmediate(rx, ry); + } else { + animateYellowDotTo(rx, ry); + } + } + } + }, [ + routeData?.center_latitude, + routeData?.center_longitude, + rotationAngle, + apiStore?.context?.currentCoordinates?.latitude, + apiStore?.context?.currentCoordinates?.longitude, + animateYellowDotTo, + setYellowDotImmediate, + ]); + + useEffect(() => { + const gl = glRef.current; + const canvas = canvasRef.current; + const prog = programRef.current; + const buffer = bufferRef.current; + const attribs = attribsRef.current; + const uniforms = uniformsRef.current; + const pprog = pointProgramRef.current; + const pbuffer = pointBufferRef.current; + if ( + !gl || + !canvas || + !prog || + !buffer || + !attribs || + !uniforms || + !pprog || + !pbuffer + ) + return; + + gl.viewport(0, 0, canvas.width, canvas.height); + gl.clearColor(0, 0, 0, 1); + gl.clear(gl.COLOR_BUFFER_BIT); + + gl.useProgram(prog); + gl.uniform2f(uniforms.u_cameraPos, position.x, position.y); + gl.uniform1f(uniforms.u_scale, scale); + gl.uniform2f(uniforms.u_resolution, canvas.width, canvas.height); + gl.bindBuffer(gl.ARRAY_BUFFER, buffer); + gl.bufferData(gl.ARRAY_BUFFER, routePath, gl.STATIC_DRAW); + gl.enableVertexAttribArray(attribs.a_pos); + gl.vertexAttribPointer(attribs.a_pos, 2, gl.FLOAT, false, 0, 0); + + let tramSegIndex = getCurrentSegIndex(); + const simulationDirection = apiStore.simulationDirection; + + const dpr = Math.max(1, window.devicePixelRatio || 1); + const desiredRouteWidthCss = 7; + const desiredStationDiameterCss = 12; + const pointOuterSizePx = desiredStationDiameterCss * dpr; + const pointInnerSizePx = pointOuterSizePx * 0.8; + const lineWidthPreview = + (desiredRouteWidthCss * dpr) / Math.max(scale, 1e-6); + const drawSignature = [ + canvas.width, + canvas.height, + routePath.length, + stationPoints.length, + sightPoints.length, + Math.round(scale * 1e4) / 1e4, + Math.round(lineWidthPreview * 1e4) / 1e4, + ].join("|"); + const shouldLogDraw = lastDrawLogSignatureRef.current !== drawSignature; + if (shouldLogDraw) { + lastDrawLogSignatureRef.current = drawSignature; + debugWebglLog("drawScene:start", { + dpr, + canvasWidth: canvas.width, + canvasHeight: canvas.height, + routeVerticesCount: routePath.length / 2, + stationVerticesCount: stationPoints.length / 2, + sightVerticesCount: sightPoints.length / 2, + scale, + position, + }); + } + + const vertexCount = routePath.length / 2; + if (vertexCount > 1) { + const generateThickLine = (points: Float32Array, width: number) => { + const vertices: number[] = []; + const halfWidth = width / 2; + + if (points.length < 4) return new Float32Array(); + + for (let i = 0; i < points.length - 2; i += 2) { + const x1 = points[i]; + const y1 = points[i + 1]; + const x2 = points[i + 2]; + const y2 = points[i + 3]; + + const dx = x2 - x1; + const dy = y2 - y1; + const length = Math.sqrt(dx * dx + dy * dy); + if (length === 0) continue; + + const perpX = (-dy / length) * halfWidth; + const perpY = (dx / length) * halfWidth; + + vertices.push(x1 + perpX, y1 + perpY); + vertices.push(x1 - perpX, y1 - perpY); + vertices.push(x2 + perpX, y2 + perpY); + + vertices.push(x1 - perpX, y1 - perpY); + vertices.push(x2 - perpX, y2 - perpY); + vertices.push(x2 + perpX, y2 + perpY); + + if (i < points.length - 4) { + const x3 = points[i + 4]; + const y3 = points[i + 5]; + + const dx2 = x3 - x2; + const dy2 = y3 - y2; + const length2 = Math.sqrt(dx2 * dx2 + dy2 * dy2); + if (length2 > 0) { + const perpX2 = (-dy2 / length2) * halfWidth; + const perpY2 = (dx2 / length2) * halfWidth; + + vertices.push(x2 + perpX, y2 + perpY); + vertices.push(x2 - perpX, y2 - perpY); + vertices.push(x2 + perpX2, y2 + perpY2); + + vertices.push(x2 - perpX, y2 - perpY); + vertices.push(x2 - perpX2, y2 - perpY2); + vertices.push(x2 + perpX2, y2 + perpY2); + } + } + } + + return new Float32Array(vertices); + }; + + const lineWidth = (desiredRouteWidthCss * dpr) / scale; + if (shouldLogDraw) { + debugWebglLog("drawScene:route", { + desiredRouteWidthCss, + lineWidthWorldUnits: lineWidth, + }); + } + const r1 = ((PATH_COLOR >> 16) & 0xff) / 255; + const g1 = ((PATH_COLOR >> 8) & 0xff) / 255; + const b1 = (PATH_COLOR & 0xff) / 255; + const r2 = ((UNPASSED_STATION_COLOR >> 16) & 0xff) / 255; + const g2 = ((UNPASSED_STATION_COLOR >> 8) & 0xff) / 255; + const b2 = (UNPASSED_STATION_COLOR & 0xff) / 255; + + const animatedPos = animatedYellowDotPosition; + if ( + tramSegIndex >= 0 && + animatedPos && + animatedPos.x !== undefined && + animatedPos.y !== undefined + ) { + if (simulationDirection === 1) { + // Вперёд: закрашено от начала до трамвая + gl.uniform4f(uniforms.u_color, r1, g1, b1, 1); + const passedPoints: number[] = []; + for (let i = 0; i <= tramSegIndex; i++) { + passedPoints.push(routePath[i * 2], routePath[i * 2 + 1]); + } + passedPoints.push(animatedPos.x, animatedPos.y); + if (passedPoints.length >= 4) { + const thickLineVertices = generateThickLine( + new Float32Array(passedPoints), + lineWidth, + ); + gl.bufferData(gl.ARRAY_BUFFER, thickLineVertices, gl.STATIC_DRAW); + gl.drawArrays(gl.TRIANGLES, 0, thickLineVertices.length / 2); + } + gl.uniform4f(uniforms.u_color, r2, g2, b2, 1); + const unpassedPoints: number[] = []; + unpassedPoints.push(animatedPos.x, animatedPos.y); + for (let i = tramSegIndex + 1; i < vertexCount; i++) { + unpassedPoints.push(routePath[i * 2], routePath[i * 2 + 1]); + } + if (unpassedPoints.length >= 4) { + const thickLineVertices = generateThickLine( + new Float32Array(unpassedPoints), + lineWidth, + ); + gl.bufferData(gl.ARRAY_BUFFER, thickLineVertices, gl.STATIC_DRAW); + gl.drawArrays(gl.TRIANGLES, 0, thickLineVertices.length / 2); + } + } else { + // Назад: закрашено от трамвая до конца + gl.uniform4f(uniforms.u_color, r2, g2, b2, 1); + const unpassedPoints: number[] = []; + for (let i = 0; i <= tramSegIndex; i++) { + unpassedPoints.push(routePath[i * 2], routePath[i * 2 + 1]); + } + unpassedPoints.push(animatedPos.x, animatedPos.y); + if (unpassedPoints.length >= 4) { + const thickLineVertices = generateThickLine( + new Float32Array(unpassedPoints), + lineWidth, + ); + gl.bufferData(gl.ARRAY_BUFFER, thickLineVertices, gl.STATIC_DRAW); + gl.drawArrays(gl.TRIANGLES, 0, thickLineVertices.length / 2); + } + gl.uniform4f(uniforms.u_color, r1, g1, b1, 1); + const passedPoints: number[] = []; + passedPoints.push(animatedPos.x, animatedPos.y); + for (let i = tramSegIndex + 1; i < vertexCount; i++) { + passedPoints.push(routePath[i * 2], routePath[i * 2 + 1]); + } + if (passedPoints.length >= 4) { + const thickLineVertices = generateThickLine( + new Float32Array(passedPoints), + lineWidth, + ); + gl.bufferData(gl.ARRAY_BUFFER, thickLineVertices, gl.STATIC_DRAW); + gl.drawArrays(gl.TRIANGLES, 0, thickLineVertices.length / 2); + } + } + } else { + // Позиция трамвая неизвестна — рисуем весь маршрут серым + gl.uniform4f(uniforms.u_color, r2, g2, b2, 1); + const allPoints: number[] = []; + for (let i = 0; i < vertexCount; i++) { + allPoints.push(routePath[i * 2], routePath[i * 2 + 1]); + } + if (allPoints.length >= 4) { + const thickLineVertices = generateThickLine( + new Float32Array(allPoints), + lineWidth, + ); + gl.bufferData(gl.ARRAY_BUFFER, thickLineVertices, gl.STATIC_DRAW); + gl.drawArrays(gl.TRIANGLES, 0, thickLineVertices.length / 2); + } + } + } + + if (stationPoints.length > 0) { + gl.useProgram(pprog); + const a_pos_pts = gl.getAttribLocation(pprog, "a_pos"); + const u_cameraPos_pts = gl.getUniformLocation(pprog, "u_cameraPos"); + const u_scale_pts = gl.getUniformLocation(pprog, "u_scale"); + const u_resolution_pts = gl.getUniformLocation(pprog, "u_resolution"); + const u_pointSize = gl.getUniformLocation(pprog, "u_pointSize"); + const u_color_pts = gl.getUniformLocation(pprog, "u_color"); + + gl.uniform2f(u_cameraPos_pts, position.x, position.y); + gl.uniform1f(u_scale_pts, scale); + gl.uniform2f(u_resolution_pts, canvas.width, canvas.height); + gl.bindBuffer(gl.ARRAY_BUFFER, pbuffer); + gl.bufferData(gl.ARRAY_BUFFER, stationPoints, gl.STATIC_DRAW); + gl.enableVertexAttribArray(a_pos_pts); + gl.vertexAttribPointer(a_pos_pts, 2, gl.FLOAT, false, 0, 0); + + gl.uniform1f(u_pointSize, pointOuterSizePx); + const r_outline = ((BACKGROUND_COLOR >> 16) & 0xff) / 255; + const g_outline = ((BACKGROUND_COLOR >> 8) & 0xff) / 255; + const b_outline = (BACKGROUND_COLOR & 0xff) / 255; + gl.uniform4f(u_color_pts, r_outline, g_outline, b_outline, 1); + gl.drawArrays(gl.POINTS, 0, stationPoints.length / 2); + + gl.uniform1f(u_pointSize, pointInnerSizePx); + + if ( + tramSegIndex >= 0 && + orderedRouteStations && + stationData && + orderedStationSegs.length > 0 + ) { + const passedPts1: number[] = []; + const unpassedPts1: number[] = []; + for (let i = 0; i < orderedRouteStations.length; i++) { + const orderedStation = (orderedRouteStations as any[])[i]; + const stationSeg = orderedStationSegs[i] ?? -1; + if (!orderedStation || stationSeg < 0) continue; + const isPassed = + simulationDirection === 1 + ? stationSeg < tramSegIndex + : stationSeg > tramSegIndex; + const stIdx = stationData.findIndex( + (s: any) => String(s.id) === String(orderedStation.id), + ); + if (stIdx < 0) continue; + const sx = stationPoints[stIdx * 2] as number; + const sy = stationPoints[stIdx * 2 + 1] as number; + if (isPassed) { + passedPts1.push(sx, sy); + } else { + unpassedPts1.push(sx, sy); + } + } + if (passedPts1.length > 0) { + gl.uniform4f( + u_color_pts, + ((PATH_COLOR >> 16) & 0xff) / 255, + ((PATH_COLOR >> 8) & 0xff) / 255, + (PATH_COLOR & 0xff) / 255, + 1, + ); + gl.bufferData( + gl.ARRAY_BUFFER, + new Float32Array(passedPts1), + gl.STATIC_DRAW, + ); + gl.drawArrays(gl.POINTS, 0, passedPts1.length / 2); + } + if (unpassedPts1.length > 0) { + gl.uniform4f( + u_color_pts, + ((UNPASSED_STATION_COLOR >> 16) & 0xff) / 255, + ((UNPASSED_STATION_COLOR >> 8) & 0xff) / 255, + (UNPASSED_STATION_COLOR & 0xff) / 255, + 1, + ); + gl.bufferData( + gl.ARRAY_BUFFER, + new Float32Array(unpassedPts1), + gl.STATIC_DRAW, + ); + gl.drawArrays(gl.POINTS, 0, unpassedPts1.length / 2); + } + } else { + gl.uniform4f( + u_color_pts, + ((UNPASSED_STATION_COLOR >> 16) & 0xff) / 255, + ((UNPASSED_STATION_COLOR >> 8) & 0xff) / 255, + (UNPASSED_STATION_COLOR & 0xff) / 255, + 1, + ); + gl.bufferData(gl.ARRAY_BUFFER, stationPoints, gl.STATIC_DRAW); + gl.drawArrays(gl.POINTS, 0, stationPoints.length / 2); + } + } + + const a_pos_pts = gl.getAttribLocation(pprog, "a_pos"); + const u_cameraPos_pts = gl.getUniformLocation(pprog, "u_cameraPos"); + const u_scale_pts = gl.getUniformLocation(pprog, "u_scale"); + const u_resolution_pts = gl.getUniformLocation(pprog, "u_resolution"); + const u_pointSize = gl.getUniformLocation(pprog, "u_pointSize"); + const u_color_pts = gl.getUniformLocation(pprog, "u_color"); + + gl.uniform2f(u_cameraPos_pts, position.x, position.y); + gl.uniform1f(u_scale_pts, scale); + gl.uniform2f(u_resolution_pts, canvas.width, canvas.height); + + const toPointsArray = (arr: number[]) => new Float32Array(arr); + + let activeStationIndex = -1; + const tramCoords = apiStore?.context?.currentCoordinates; + if ( + tramCoords && + stationData && + stationData.length > 0 && + Number.isFinite(tramCoords.latitude) && + Number.isFinite(tramCoords.longitude) + ) { + let bestD = Infinity; + + const DISTANCE_THRESHOLD = 0.00015; + + for (let i = 0; i < stationData.length; i++) { + const station = stationData[i]; + if ( + Number.isFinite(station.latitude) && + Number.isFinite(station.longitude) + ) { + const d = getDistance( + { + latitude: tramCoords.latitude, + longitude: tramCoords.longitude, + }, + { + latitude: station.latitude, + longitude: station.longitude, + }, + ); + if (d < bestD) { + bestD = d; + activeStationIndex = i; + } + } + } + + if (bestD > DISTANCE_THRESHOLD) { + activeStationIndex = -1; + } + } + + if (stationData && stationData.length > 0) { + if (activeStationIndex >= 0 && stationData[activeStationIndex]) { + const activeId = String(stationData[activeStationIndex].id); + if (activeId !== nearestStationId) { + setNearestStationId(activeId); + } + } else if (nearestStationId !== null) { + setNearestStationId(null); + } + } + + const passedStationIds = new Set(); + const unpassedStationIds = new Set(); + + if ( + tramSegIndex >= 0 && + orderedRouteStations && + orderedStationSegs.length === orderedRouteStations.length + ) { + for (let i = 0; i < orderedRouteStations.length; i++) { + const station = (orderedRouteStations as any[])[i]; + const seg = orderedStationSegs[i] ?? -1; + if (!station || seg < 0) continue; + const isPassed = + simulationDirection === 1 ? seg < tramSegIndex : seg > tramSegIndex; + if (isPassed) passedStationIds.add(String(station.id)); + else unpassedStationIds.add(String(station.id)); + } + } else { + if (orderedRouteStations) { + (orderedRouteStations as any[]).forEach((station) => { + unpassedStationIds.add(String(station.id)); + }); + } + } + + const passedStations: number[] = []; + const unpassedStations: number[] = []; + const activeStations: number[] = []; + for ( + let i = 0, idx = 0; + i < stationData.length && idx < stationPoints.length; + i++, idx += 2 + ) { + const sx = stationPoints[idx]; + const sy = stationPoints[idx + 1]; + const stationId = String(stationData[i].id); + + if (i === activeStationIndex) { + activeStations.push(sx, sy); + } else if (passedStationIds.has(stationId)) { + passedStations.push(sx, sy); + } else { + unpassedStations.push(sx, sy); + } + } + + const outlineSize = pointOuterSizePx; + const coreSize = pointInnerSizePx; + if (shouldLogDraw) { + debugWebglLog("drawScene:stations", { + pointOuterSizePx: outlineSize, + pointInnerSizePx: coreSize, + }); + } + + gl.bindBuffer(gl.ARRAY_BUFFER, pbuffer); + gl.bufferData( + gl.ARRAY_BUFFER, + toPointsArray(unpassedStations), + gl.STREAM_DRAW, + ); + gl.enableVertexAttribArray(a_pos_pts); + gl.vertexAttribPointer(a_pos_pts, 2, gl.FLOAT, false, 0, 0); + gl.uniform1f(u_pointSize, outlineSize); + gl.uniform4f( + u_color_pts, + ((BACKGROUND_COLOR >> 16) & 255) / 255, + ((BACKGROUND_COLOR >> 8) & 255) / 255, + (BACKGROUND_COLOR & 255) / 255, + 1, + ); + if (unpassedStations.length) + gl.drawArrays(gl.POINTS, 0, unpassedStations.length / 2); + gl.uniform1f(u_pointSize, coreSize); + gl.uniform4f( + u_color_pts, + ((UNPASSED_STATION_COLOR >> 16) & 255) / 255, + ((UNPASSED_STATION_COLOR >> 8) & 255) / 255, + (UNPASSED_STATION_COLOR & 255) / 255, + 1, + ); + if (unpassedStations.length) + gl.drawArrays(gl.POINTS, 0, unpassedStations.length / 2); + + gl.bindBuffer(gl.ARRAY_BUFFER, pbuffer); + gl.bufferData( + gl.ARRAY_BUFFER, + toPointsArray(passedStations), + gl.STREAM_DRAW, + ); + gl.enableVertexAttribArray(a_pos_pts); + gl.vertexAttribPointer(a_pos_pts, 2, gl.FLOAT, false, 0, 0); + gl.uniform1f(u_pointSize, outlineSize); + gl.uniform4f( + u_color_pts, + ((BACKGROUND_COLOR >> 16) & 255) / 255, + ((BACKGROUND_COLOR >> 8) & 255) / 255, + (BACKGROUND_COLOR & 255) / 255, + 1, + ); + if (passedStations.length) + gl.drawArrays(gl.POINTS, 0, passedStations.length / 2); + gl.uniform1f(u_pointSize, coreSize); + gl.uniform4f( + u_color_pts, + ((PATH_COLOR >> 16) & 255) / 255, + ((PATH_COLOR >> 8) & 255) / 255, + (PATH_COLOR & 255) / 255, + 1, + ); + if (passedStations.length) + gl.drawArrays(gl.POINTS, 0, passedStations.length / 2); + + if (activeStations.length) { + gl.bindBuffer(gl.ARRAY_BUFFER, pbuffer); + gl.bufferData( + gl.ARRAY_BUFFER, + toPointsArray(activeStations), + gl.STREAM_DRAW, + ); + gl.enableVertexAttribArray(a_pos_pts); + gl.vertexAttribPointer(a_pos_pts, 2, gl.FLOAT, false, 0, 0); + + gl.uniform1f(u_pointSize, outlineSize + 1); + gl.uniform4f( + u_color_pts, + ((BACKGROUND_COLOR >> 16) & 255) / 255, + ((BACKGROUND_COLOR >> 8) & 255) / 255, + (BACKGROUND_COLOR & 255) / 255, + 1, + ); + gl.drawArrays(gl.POINTS, 0, activeStations.length / 2); + + gl.uniform1f(u_pointSize, coreSize + 1); + gl.uniform4f( + u_color_pts, + ((BUS_COLOR >> 16) & 255) / 255, + ((BUS_COLOR >> 8) & 255) / 255, + (BUS_COLOR & 255) / 255, + 1, + ); + gl.drawArrays(gl.POINTS, 0, activeStations.length / 2); + } + + if ( + stationData && + stationData.length > 0 && + routeData && + apiStore?.context + ) { + const centerLat = routeData.center_latitude; + const centerLon = routeData.center_longitude; + if (centerLat !== undefined && centerLon !== undefined) { + const cos = Math.cos(rotationAngle); + const sin = Math.sin(rotationAngle); + + const startStationData = orderedRouteStations?.[0] + ? stationData.find( + (station: any) => + station.id.toString() === String(orderedRouteStations[0].id), + ) + : stationData.find( + (station: any) => + station.id.toString() === apiStore.context?.startStopId, + ); + const endStationData = orderedRouteStations?.length + ? stationData.find( + (station: any) => + station.id.toString() === + String( + orderedRouteStations[orderedRouteStations.length - 1].id, + ), + ) + : stationData.find( + (station: any) => + station.id.toString() === apiStore.context?.endStopId, + ); + + const terminalStations: number[] = []; + + if (startStationData) { + const startLocal = coordinatesToLocal( + startStationData.latitude - centerLat, + startStationData.longitude - centerLon, + ); + const startX = startLocal.x * UP_SCALE; + const startY = startLocal.y * UP_SCALE; + const startRx = startX * cos - startY * sin; + const startRy = startX * sin + startY * cos; + terminalStations.push(startRx, startRy); + } + + if (endStationData) { + const endLocal = coordinatesToLocal( + endStationData.latitude - centerLat, + endStationData.longitude - centerLon, + ); + const endX = endLocal.x * UP_SCALE; + const endY = endLocal.y * UP_SCALE; + const endRx = endX * cos - endY * sin; + const endRy = endX * sin + endY * cos; + terminalStations.push(endRx, endRy); + } + + if (terminalStations.length > 0) { + const terminalStationData: any[] = []; + if (startStationData) terminalStationData.push(startStationData); + if (endStationData) terminalStationData.push(endStationData); + + let tramSegIndex = -1; + const coords: any = apiStore?.context?.currentCoordinates; + if (coords && centerLat !== undefined && centerLon !== undefined) { + const local = coordinatesToLocal( + coords.latitude - centerLat, + coords.longitude - centerLon, + ); + const wx = local.x * UP_SCALE; + const wy = local.y * UP_SCALE; + const cosR = Math.cos(rotationAngle); + const sinR = Math.sin(rotationAngle); + const tx = wx * cosR - wy * sinR; + const ty = wx * sinR + wy * cosR; + + let best = -1; + let bestD = Infinity; + for (let i = 0; i < routePath.length - 2; i += 2) { + const p1x = routePath[i]; + const p1y = routePath[i + 1]; + const p2x = routePath[i + 2]; + const p2y = routePath[i + 3]; + const dx = p2x - p1x; + const dy = p2y - p1y; + const len2 = dx * dx + dy * dy; + if (!len2) continue; + const t = ((tx - p1x) * dx + (ty - p1y) * dy) / len2; + const cl = Math.max(0, Math.min(1, t)); + const px = p1x + cl * dx; + const py = p1y + cl * dy; + const d = Math.hypot(tx - px, ty - py); + if (d < bestD) { + bestD = d; + best = i / 2; + } + } + tramSegIndex = best; + } + + const isStartPassed = startStationData + ? (() => { + const sx = terminalStations[0]; + const sy = terminalStations[1]; + const seg = (() => { + if (routePath.length < 4) return -1; + let best = -1; + let bestD = Infinity; + for (let i = 0; i < routePath.length - 2; i += 2) { + const p1x = routePath[i]; + const p1y = routePath[i + 1]; + const p2x = routePath[i + 2]; + const p2y = routePath[i + 3]; + const dx = p2x - p1x; + const dy = p2y - p1y; + const len2 = dx * dx + dy * dy; + if (!len2) continue; + const t = ((sx - p1x) * dx + (sy - p1y) * dy) / len2; + const cl = Math.max(0, Math.min(1, t)); + const px = p1x + cl * dx; + const py = p1y + cl * dy; + const d = Math.hypot(sx - px, sy - py); + if (d < bestD) { + bestD = d; + best = i / 2; + } + } + return best; + })(); + return ( + tramSegIndex !== -1 && + seg !== -1 && + (simulationDirection === 1 + ? seg < tramSegIndex + : seg > tramSegIndex) + ); + })() + : false; + + const isEndPassed = endStationData + ? (() => { + const ex = terminalStations[terminalStations.length - 2]; + const ey = terminalStations[terminalStations.length - 1]; + const seg = (() => { + if (routePath.length < 4) return -1; + let best = -1; + let bestD = Infinity; + for (let i = 0; i < routePath.length - 2; i += 2) { + const p1x = routePath[i]; + const p1y = routePath[i + 1]; + const p2x = routePath[i + 2]; + const p2y = routePath[i + 3]; + const dx = p2x - p1x; + const dy = p2y - p1y; + const len2 = dx * dx + dy * dy; + if (!len2) continue; + const t = ((ex - p1x) * dx + (ey - p1y) * dy) / len2; + const cl = Math.max(0, Math.min(1, t)); + const px = p1x + cl * dx; + const py = p1y + cl * dy; + const d = Math.hypot(ex - px, ey - py); + if (d < bestD) { + bestD = d; + best = i / 2; + } + } + return best; + })(); + return ( + tramSegIndex !== -1 && + seg !== -1 && + (simulationDirection === 1 + ? seg < tramSegIndex + : seg > tramSegIndex) + ); + })() + : false; + + gl.bindBuffer(gl.ARRAY_BUFFER, pbuffer); + gl.bufferData( + gl.ARRAY_BUFFER, + new Float32Array(terminalStations), + gl.STREAM_DRAW, + ); + gl.enableVertexAttribArray(a_pos_pts); + gl.vertexAttribPointer(a_pos_pts, 2, gl.FLOAT, false, 0, 0); + + const terminalOuterSizePx = pointOuterSizePx * 0.9; + const terminalInnerSizePx = pointOuterSizePx * 0.55; + gl.uniform1f(u_pointSize, terminalOuterSizePx); + + const r_passed = ((PATH_COLOR >> 16) & 0xff) / 255; + const g_passed = ((PATH_COLOR >> 8) & 0xff) / 255; + const b_passed = (PATH_COLOR & 0xff) / 255; + + const r_unpassed = ((UNPASSED_STATION_COLOR >> 16) & 0xff) / 255; + const g_unpassed = ((UNPASSED_STATION_COLOR >> 8) & 0xff) / 255; + const b_unpassed = (UNPASSED_STATION_COLOR & 0xff) / 255; + + if (startStationData && endStationData) { + const startIsPassed = + simulationDirection === 1 ? true : isStartPassed; + const endIsPassed = simulationDirection === -1 ? true : isEndPassed; + gl.uniform4f( + u_color_pts, + startIsPassed ? r_passed : r_unpassed, + startIsPassed ? g_passed : g_unpassed, + startIsPassed ? b_passed : b_unpassed, + 1.0, + ); + gl.drawArrays(gl.POINTS, 0, 1); + gl.uniform4f( + u_color_pts, + endIsPassed ? r_passed : r_unpassed, + endIsPassed ? g_passed : g_unpassed, + endIsPassed ? b_passed : b_unpassed, + 1.0, + ); + gl.drawArrays(gl.POINTS, 1, 1); + } else { + const isStartStation = startStationData !== undefined; + const isPassed = startStationData ? isStartPassed : isEndPassed; + if (isStartStation || isPassed) { + gl.uniform4f(u_color_pts, r_passed, g_passed, b_passed, 1.0); + } else { + gl.uniform4f( + u_color_pts, + r_unpassed, + g_unpassed, + b_unpassed, + 1.0, + ); + } + gl.drawArrays(gl.POINTS, 0, terminalStations.length / 2); + } + + gl.uniform1f(u_pointSize, terminalInnerSizePx); + const r_center = ((BACKGROUND_COLOR >> 16) & 0xff) / 255; + const g_center = ((BACKGROUND_COLOR >> 8) & 0xff) / 255; + const b_center = (BACKGROUND_COLOR & 0xff) / 255; + gl.uniform4f(u_color_pts, r_center, g_center, b_center, 1.0); + gl.drawArrays(gl.POINTS, 0, terminalStations.length / 2); + } + } + } + }, [ + routePath, + stationPoints, + sightPoints, + position.x, + position.y, + scale, + animatedYellowDotPosition?.x, + animatedYellowDotPosition?.y, + nearestStationId, + currentStationId, + orderedRouteStations, + orderedStationSegs, + apiStore.simulationDirection, + ]); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas || hasInitializedCameraRef.current) return; + if (!routePath || routePath.length < 4) return; + + let minX = Infinity, + minY = Infinity, + maxX = -Infinity, + maxY = -Infinity; + for (let i = 0; i < routePath.length; i += 2) { + const x = routePath[i]; + const y = routePath[i + 1]; + if (x < minX) minX = x; + if (y < minY) minY = y; + if (x > maxX) maxX = x; + if (y > maxY) maxY = y; + } + if ( + !isFinite(minX) || + !isFinite(minY) || + !isFinite(maxX) || + !isFinite(maxY) + ) + return; + + routeBoundsRef.current = { minX, maxX, minY, maxY }; + + const centerX = (minX + maxX) / 2; + const centerY = (minY + maxY) / 2; + const worldWidth = Math.max(1, maxX - minX); + const worldHeight = Math.max(1, maxY - minY); + const fitPadding = 0.1; + const fitScale = Math.min( + (canvas.width * (1 - fitPadding)) / worldWidth, + (canvas.height * (1 - fitPadding)) / worldHeight, + ); + + const maxRouteScale = scaleLimitsRef.current.max; + if ( + !( + typeof maxRouteScale === "number" && + Number.isFinite(maxRouteScale) && + maxRouteScale > 0 + ) + ) { + return; + } + const initialScale = maxRouteScale; + const clampedScale = clampScale(initialScale); + + const initialPosition = { + x: canvas.width / 2 - centerX * clampedScale, + y: canvas.height / 2 - centerY * clampedScale, + }; + setScaleRef.current(clampedScale); + setPositionRef.current(initialPosition); + hasInitializedCameraRef.current = true; + if ( + customSightIconBaseScaleRef.current == null && + Number.isFinite(fitScale) && + fitScale > 0 + ) { + customSightIconBaseScaleRef.current = CUSTOM_SIGHT_ICON_BASE_SCALE; + debugWebglLog("custom sight base scale initialized", { + baseScale: CUSTOM_SIGHT_ICON_BASE_SCALE, + currentScale: clampedScale, + }); + forceSightIconScaleRender((version) => version + 1); + } + cameraAnimationStore.syncState(initialPosition, clampedScale); + }, [routePath, cameraAnimationStore]); + + useEffect(() => { + if (!stationData?.length) return; + + const fontSizePercent = + routeData?.font_size ?? originalRouteData?.font_size ?? 100; + const fontScale = fontSizePercent / 100; + const baseStationIconSizePx = 16 * fontScale * 1.2; + + for (const station of stationData) { + const hasCustomStationIcon = !isMediaIdEmpty(station?.icon ?? null); + if (!hasCustomStationIcon) continue; + + const iconSizePx = Math.round(baseStationIconSizePx); + debugWebglLog("custom station icon size", { + stationId: station?.id, + fontSizePercent, + baseStationIconSizePx, + iconSizePx, + }); + } + }, [stationData, routeData?.font_size, originalRouteData?.font_size]); + + useEffect(() => { + if (!transformedSightData?.length) return; + + for (const sight of transformedSightData) { + const shouldUseCustomSightIcon = + sight.is_default_icon === false && !isMediaIdEmpty(sight.icon ?? null); + if (!shouldUseCustomSightIcon) continue; + + const customSightIconScaleFactor = + scale / Math.max(customSightIconBaseScaleRef.current ?? 1, 1e-6); + const sightIconSizePercent = resolveSightIconSizePercent(sight); + const iconSizePx = + SIGHT_ICON_BASE_SIZE * + clamp(sightIconSizePercent / 100, 0.1, 10) * + customSightIconScaleFactor; + + debugWebglLog("custom sight icon size", { + sightId: sight.id, + cameraScale: scale, + baseScale: customSightIconBaseScaleRef.current, + scaleFactor: customSightIconScaleFactor, + iconPercent: sightIconSizePercent, + iconSizePx, + }); + } + }, [transformedSightData, scale, resolveSightIconSizePercent]); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + + let isDragging = false; + let startMouse = { x: 0, y: 0 }; + let startPos = { x: 0, y: 0 }; + + const activePointers = new Map(); + let isPinching = false; + let pinchStart: { + distance: number; + midpoint: { x: number; y: number }; + scale: number; + position: { x: number; y: number }; + } | null = null; + + const getDistance = ( + p1: { x: number; y: number }, + p2: { x: number; y: number }, + ) => Math.hypot(p2.x - p1.x, p2.y - p1.y); + + const getMidpoint = ( + p1: { x: number; y: number }, + p2: { x: number; y: number }, + ) => ({ + x: (p1.x + p2.x) / 2, + y: (p1.y + p2.y) / 2, + }); + + const onPointerDown = (e: PointerEvent) => { + updateUserActivity(); + if (isAutoMode) { + setIsAutoMode(false); + } + cameraAnimationStore.stopAnimation(); + + canvas.setPointerCapture(e.pointerId); + const rect = canvas.getBoundingClientRect(); + activePointers.set(e.pointerId, { + x: e.clientX - rect.left, + y: e.clientY - rect.top, + }); + if (activePointers.size === 1) { + isPinching = false; + pinchStart = null; + isDragging = true; + startMouse = { x: e.clientX - rect.left, y: e.clientY - rect.top }; + startPos = { x: positionRef.current.x, y: positionRef.current.y }; + } else if (activePointers.size === 2) { + isDragging = false; + const [p1, p2] = Array.from(activePointers.values()); + pinchStart = { + distance: getDistance(p1, p2), + midpoint: getMidpoint(p1, p2), + scale: scaleRef.current, + position: { x: positionRef.current.x, y: positionRef.current.y }, + }; + isPinching = true; + } + }; + + const onPointerMove = (e: PointerEvent) => { + if (!activePointers.has(e.pointerId)) return; + + updateUserActivity(); + + const rect = canvas.getBoundingClientRect(); + activePointers.set(e.pointerId, { + x: e.clientX - rect.left, + y: e.clientY - rect.top, + }); + + if (activePointers.size === 2) { + isDragging = false; + + const pointersArray = Array.from(activePointers.values()); + if (pointersArray.length === 2) { + const [p1, p2] = pointersArray; + + if (!isPinching || pinchStart === null) { + isPinching = true; + pinchStart = { + distance: getDistance(p1, p2), + midpoint: getMidpoint(p1, p2), + scale: scaleRef.current, + position: { x: positionRef.current.x, y: positionRef.current.y }, + }; + } + + if (pinchStart) { + const currentDistance = getDistance(p1, p2); + const zoomFactor = currentDistance / pinchStart.distance; + const unclampedScale = pinchStart.scale * zoomFactor; + const newScale = clampScale(Math.max(0.1, unclampedScale)); + + const k = newScale / pinchStart.scale; + const newPosition = { + x: pinchStart.position.x * k + pinchStart.midpoint.x * (1 - k), + y: pinchStart.position.y * k + pinchStart.midpoint.y * (1 - k), + }; + const clampedPos = clampPosition(newPosition, newScale); + setPositionRef.current(clampedPos); + setScaleRef.current(newScale); + positionRef.current = clampedPos; + scaleRef.current = newScale; + } + } + } else if (activePointers.size === 1) { + isPinching = false; + pinchStart = null; + + if (isDragging) { + const p = Array.from(activePointers.values())[0]; + + if ( + !startMouse || + !startPos || + typeof startMouse.x !== "number" || + typeof startMouse.y !== "number" || + typeof startPos.x !== "number" || + typeof startPos.y !== "number" + ) { + return; + } + + const dx = p.x - startMouse.x; + const dy = p.y - startMouse.y; + + const newPos = { x: startPos.x + dx, y: startPos.y + dy }; + const clampedPos = clampPosition(newPos, scaleRef.current); + setPositionRef.current(clampedPos); + positionRef.current = clampedPos; + } + } + }; + + const onPointerUp = (e: PointerEvent) => { + updateUserActivity(); + + canvas.releasePointerCapture(e.pointerId); + activePointers.delete(e.pointerId); + if (activePointers.size < 2) { + isPinching = false; + pinchStart = null; + } + if (activePointers.size === 0) { + isDragging = false; + } else if (activePointers.size === 1) { + const p = Array.from(activePointers.values())[0]; + startPos = { x: positionRef.current.x, y: positionRef.current.y }; + startMouse = { x: p.x, y: p.y }; + isDragging = true; + } + }; + + const onPointerCancel = (e: PointerEvent) => { + updateUserActivity(); + canvas.releasePointerCapture(e.pointerId); + activePointers.delete(e.pointerId); + isPinching = false; + pinchStart = null; + if (activePointers.size === 0) { + isDragging = false; + } + }; + + const onWheel = (e: WheelEvent) => { + e.preventDefault(); + + updateUserActivity(); + if (isAutoMode) { + setIsAutoMode(false); + } + cameraAnimationStore.stopAnimation(); + + const rect = canvas.getBoundingClientRect(); + + const mouseX = + (e.clientX - rect.left) * (canvas.width / canvas.clientWidth); + const mouseY = + (e.clientY - rect.top) * (canvas.height / canvas.clientHeight); + const delta = e.deltaY > 0 ? 0.9 : 1.1; + const unclampedScale = scaleRef.current * delta; + const newScale = clampScale(Math.max(0.1, unclampedScale)); + + const k = newScale / scaleRef.current; + const newPosition = { + x: positionRef.current.x * k + mouseX * (1 - k), + y: positionRef.current.y * k + mouseY * (1 - k), + }; + const clampedPos = clampPosition(newPosition, newScale); + setScaleRef.current(newScale); + setPositionRef.current(clampedPos); + scaleRef.current = newScale; + positionRef.current = clampedPos; + }; + + canvas.addEventListener("pointerdown", onPointerDown); + canvas.addEventListener("pointermove", onPointerMove); + canvas.addEventListener("pointerup", onPointerUp); + canvas.addEventListener("pointercancel", onPointerCancel); + canvas.addEventListener("pointerleave", onPointerUp); + canvas.addEventListener("wheel", onWheel, { passive: false }); + + return () => { + canvas.removeEventListener("pointerdown", onPointerDown); + canvas.removeEventListener("pointermove", onPointerMove); + canvas.removeEventListener("pointerup", onPointerUp); + canvas.removeEventListener("pointercancel", onPointerCancel); + canvas.removeEventListener("pointerleave", onPointerUp); + canvas.removeEventListener("wheel", onWheel as any); + }; + }, [ + updateUserActivity, + setIsAutoMode, + cameraAnimationStore, + isAutoMode, + clampScale, + clampPosition, + ]); + + return ( +
+ +
+ {stationLabels.map((l, idx) => { + let station = stationData?.[idx]; + + const backendAlign = station?.align ?? 3; + const anchor = getAnchorFromOffset(backendAlign); + const transformCss = `translate(${-anchor.x * 100}%, ${ + -anchor.y * 100 + }%)`; + + const fontSizePercent = routeData?.font_size ?? 100; + const fontScale = fontSizePercent / 100; + + const clampedScale = Math.min(Math.max(scale, 1), 3); + const scaleFactor = 1 + (clampedScale - 1) * 0.4; + + const primaryFontSize = 16 * fontScale * scaleFactor; + const secondaryFontSize = 13 * fontScale * scaleFactor; + const secondaryMarginTop = 5 * fontScale * scaleFactor; + const secondaryLineHeight = 1.2 * scaleFactor; + + const alignment = + backendAlign === 1 + ? "left" + : backendAlign === 3 + ? "right" + : "center"; + + const secondaryPositionStyle = + alignment === "left" + ? { left: 0, transform: "none" } + : alignment === "right" + ? { right: 0, transform: "none" } + : { left: "50%", transform: "translateX(-50%)" }; + + const isMediaIdEmptyResult = isMediaIdEmpty(station?.icon); + const iconSrc = isMediaIdEmptyResult + ? null + : buildMediaDownloadUrl(mediaBaseUrl, station!.icon!, mediaToken); + const iconSizePx = Math.round(primaryFontSize * 1.2); + + return ( +
+
+ {iconSrc ? ( + + ) : null} +
+ {l.name} +
+ {l.sub ? ( +
+ {l.sub} +
+ ) : null} +
+
+ ); + })} + {clusteredSights.map((item) => { + const dpr = Math.max( + 1, + (typeof window !== "undefined" && window.devicePixelRatio) || 1, + ); + + if (item.type === "point") { + const s = item.data; + const rx = s.longitude; + const ry = s.latitude; + const isSelected = selectedSightId === String(s.id); + const hasIcon = !isMediaIdEmpty(s.icon ?? null); + const isCustomIcon = s.is_default_icon === false && hasIcon; + const hasSelectedAltIcon = + isSelected && isCustomIcon && !isMediaIdEmpty(s.alt_icon ?? null); + const defaultCustomIcon = isCustomIcon; + const shouldUseCustomSightIcon = + hasSelectedAltIcon || defaultCustomIcon; + const customSightIconScaleFactor = shouldUseCustomSightIcon + ? scale / Math.max(customSightIconBaseScaleRef.current ?? 1, 1e-6) + : 1; + const sightIconSizePercent = resolveSightIconSizePercent(s); + const sightIconUrl = hasSelectedAltIcon + ? buildMediaDownloadUrl( + mediaBaseUrl, + String(s.alt_icon), + mediaToken, + ) + : defaultCustomIcon + ? buildMediaDownloadUrl( + mediaBaseUrl, + String(s.icon), + mediaToken, + ) + : SIGHT_ICON_URL; + const iconSize = + SIGHT_ICON_BASE_SIZE * + clamp(sightIconSizePercent / 100, 0.1, 10) * + customSightIconScaleFactor; + + const screenX = (rx * scale + position.x) / dpr; + const screenY = (ry * scale + position.y) / dpr; + const iconLeft = screenX - iconSize; + const iconTop = screenY - iconSize; + + const handleSightClick = () => { + setIsGovernorWidgetOpen(false); + setSelectedSightId(String(s.id)); + setIsManualSelection(true); + setIsRightWidgetSelectorOpen(false); + store.closeGovernorModal(); + }; + + const handleSightTouch = (e: React.TouchEvent) => { + e.stopPropagation(); + handleSightClick(); + }; + + return ( +
+
+ +
+
+ ); + } else { + const cluster = item.data; + const rx = cluster.longitude; + const ry = cluster.latitude; + + const screenX = (rx * scale + position.x) / dpr; + const screenY = (ry * scale + position.y) / dpr; + + const isExpanded = activeClusterId === cluster.id; + const selectedSightInCluster = cluster.sights.find( + (sight) => selectedSightId === String(sight.id), + ); + const selectedHasIconInCluster = + selectedSightInCluster != null && + !isMediaIdEmpty(selectedSightInCluster.icon ?? null); + const selectedIsCustomInCluster = + selectedSightInCluster?.is_default_icon === false && + selectedHasIconInCluster; + + const iconSizePercent = resolveSightIconSizePercent( + cluster.sights[0], + ); + const clusterCustomScaleFactor = selectedIsCustomInCluster + ? scale / Math.max(customSightIconBaseScaleRef.current ?? 1, 1e-6) + : 1; + const iconSize = + SIGHT_ICON_BASE_SIZE * + clamp(iconSizePercent / 100, 0.1, 10) * + clusterCustomScaleFactor; + + const iconLeft = screenX - iconSize; + const iconTop = screenY - iconSize; + const hasSelectedAltIconInCluster = + selectedSightInCluster != null && + selectedIsCustomInCluster && + !isMediaIdEmpty(selectedSightInCluster.alt_icon ?? null); + const clusterIconUrl = hasSelectedAltIconInCluster + ? buildMediaDownloadUrl( + mediaBaseUrl, + String(selectedSightInCluster?.alt_icon), + mediaToken, + ) + : selectedIsCustomInCluster + ? buildMediaDownloadUrl( + mediaBaseUrl, + String(selectedSightInCluster?.icon), + mediaToken, + ) + : SIGHT_ICON_URL; + + const handleClusterClick = ( + e: React.MouseEvent | React.TouchEvent, + ) => { + e.stopPropagation(); + if (isExpanded) { + setActiveClusterId(null); + } else { + setActiveClusterId(cluster.id); + } + }; + + const handleCircleInteraction = () => { + if (isExpanded && clusterAutoCloseTimerRef.current) { + clearTimeout(clusterAutoCloseTimerRef.current); + clusterAutoCloseTimerRef.current = setTimeout(() => { + setActiveClusterId(null); + }, 20000); + } + }; + + const handleSightInClusterClick = ( + sightId: string, + e: React.MouseEvent | React.TouchEvent, + ) => { + e.stopPropagation(); + setIsGovernorWidgetOpen(false); + setSelectedSightId(sightId); + setIsManualSelection(true); + setIsRightWidgetSelectorOpen(false); + store.closeGovernorModal(); + setActiveClusterId(null); + }; + + const hasSelectedSight = selectedSightId + ? cluster.sights.some( + (sight) => String(sight.id) === selectedSightId, + ) + : false; + + const badgeColor = "var(--carrier-main, #006F3A)"; + const listPanelWidth = 200; + const listItemHeight = 30; + const listMaxHeight = 250; + const hasMoreThanTwo = cluster.sights.length > 2; + const shouldShowScrollbar = hasMoreThanTwo; + + return ( + + {!isExpanded ? ( +
+
+ +
+ {cluster.count} +
+
+
+ ) : ( +
+
+ +
+ {cluster.count} +
+
+ +
e.stopPropagation()} + > + + {cluster.sights.map((sight, index) => { + const isSelected = + selectedSightId === String(sight.id); + const getSightName = () => { + const originalSight = + selectedLanguage === "ru" + ? routeSights?.find((s) => s.id === sight.id) + : selectedLanguage === "en" + ? routeSightsEn?.find( + (s) => s.id === sight.id, + ) + : routeSightsZh?.find( + (s) => s.id === sight.id, + ); + + if (originalSight?.short_name) { + return originalSight.short_name; + } + + if (!originalSight?.left_article) { + return ( + sight.name || + `Достопримечательность ${index + 1}` + ); + } + + const leftArticleData = + selectedLanguage === "ru" + ? sightArticles.get( + originalSight.left_article + "_ru", + ) + : selectedLanguage === "en" + ? sightArticlesEn.get( + originalSight.left_article + "_en", + ) + : sightArticlesZh.get( + originalSight.left_article + "_zh", + ); + + return ( + leftArticleData?.heading || + sight.name || + `Достопримечательность ${index + 1}` + ); + }; + + const sightName = getSightName(); + + return ( +
+ handleSightInClusterClick(String(sight.id), e) + } + onTouchEnd={(e) => + handleSightInClusterClick(String(sight.id), e) + } + style={{ + display: "flex", + alignItems: "center", + height: `${listItemHeight}px`, + cursor: "pointer", + userSelect: "none", + touchAction: "none", + padding: "0 4px", + zIndex: 1000000000000, + borderBottom: + "1px solid rgba(255, 255, 255, 0.1)", + transition: "background-color 0.2s", + }} + > + {(() => { + const hasRowIcon = !isMediaIdEmpty( + sight.icon ?? null, + ); + const isRowCustomIcon = + sight.is_default_icon === false && hasRowIcon; + const hasRowAltIcon = + isSelected && + isRowCustomIcon && + !isMediaIdEmpty(sight.alt_icon ?? null); + const rowIconUrl = hasRowAltIcon + ? buildMediaDownloadUrl( + mediaBaseUrl, + String(sight.alt_icon), + mediaToken, + ) + : isRowCustomIcon + ? buildMediaDownloadUrl( + mediaBaseUrl, + String(sight.icon), + mediaToken, + ) + : SIGHT_ICON_URL; + return ( + + ); + })()} + + {sightName} + +
+ ); + })} +
+
+
+ )} +
+ ); + } + })} + + {(() => { + if (!routeData) return null; + const centerLat = routeData.center_latitude; + const centerLon = routeData.center_longitude; + if (centerLat === undefined || centerLon === undefined) return null; + + if (!apiStore?.context?.currentCoordinates) return null; + + const rx = animatedYellowDotPosition.x; + const ry = animatedYellowDotPosition.y; + if (rx === 0 && ry === 0) return null; + + const dpr2 = Math.max( + 1, + (typeof window !== "undefined" && window.devicePixelRatio) || 1, + ); + const cosR = Math.cos(rotationAngle); + const sinR = Math.sin(rotationAngle); + const pathPts: { x: number; y: number }[] = []; + for (let i = 0; i < routePath.length; i += 2) + pathPts.push({ x: routePath[i], y: routePath[i + 1] }); + const stationsForAngle = (stationData || []).map((st: any) => { + const loc = coordinatesToLocal( + st.latitude - centerLat, + st.longitude - centerLon, + ); + const x = loc.x * UP_SCALE, + y = loc.y * UP_SCALE; + const rx2 = x * cosR - y * sinR, + ry2 = x * sinR + y * cosR; + return { + longitude: rx2, + latitude: ry2, + offset_x: st.offset_x, + offset_y: st.offset_y, + align: st.align, + }; + }); + let tramSegIndex = getCurrentSegIndex(); + if (tramSegIndex < 0 && routePath.length >= 4) { + let best = -1, + bestD = Infinity; + for (let i = 0; i < routePath.length - 2; i += 2) { + const p1x = routePath[i], + p1y = routePath[i + 1]; + const p2x = routePath[i + 2], + p2y = routePath[i + 3]; + const dx = p2x - p1x, + dy = p2y - p1y; + const len2 = dx * dx + dy * dy; + if (!len2) continue; + const t = ((rx - p1x) * dx + (ry - p1y) * dy) / len2; + const cl = Math.max(0, Math.min(1, t)); + const px = p1x + cl * dx, + py = p1y + cl * dy; + const d = Math.hypot(rx - px, ry - py); + if (d < bestD) { + bestD = d; + best = i / 2; + } + } + tramSegIndex = best; + } + const optimalAngle = (() => { + const testRadiusInMap = 100 / scale; + const minPath = 60, + minPassed = 60, + minStation = 100; + let bestAng = 0, + bestScore = Infinity; + for (let i = 0; i < 12; i++) { + const ang = (i * Math.PI * 2) / 12; + const tx = rx + Math.cos(ang) * testRadiusInMap; + const ty = ry + Math.sin(ang) * testRadiusInMap; + const distPath = (function () { + if (pathPts.length < 2) return Infinity; + let md = Infinity; + for (let k = 0; k < pathPts.length - 1; k++) { + const p1 = pathPts[k], + p2 = pathPts[k + 1]; + const L2 = (p2.x - p1.x) ** 2 + (p2.y - p1.y) ** 2; + if (!L2) continue; + const tt = + ((tx - p1.x) * (p2.x - p1.x) + + (ty - p1.y) * (p2.y - p1.y)) / + L2; + const cl = Math.max(0, Math.min(1, tt)); + const px = p1.x + cl * (p2.x - p1.x), + py = p1.y + cl * (p2.y - p1.y); + const d = Math.hypot(tx - px, ty - py); + if (d < md) md = d; + } + return md * scale; + })(); + const distPassed = (function () { + if (pathPts.length < 2 || tramSegIndex < 0) return Infinity; + let md = Infinity; + for ( + let k = 0; + k <= Math.min(tramSegIndex, pathPts.length - 2); + k++ + ) { + const p1 = pathPts[k], + p2 = pathPts[k + 1]; + const L2 = (p2.x - p1.x) ** 2 + (p2.y - p1.y) ** 2; + if (!L2) continue; + const tt = + ((tx - p1.x) * (p2.x - p1.x) + + (ty - p1.y) * (p2.y - p1.y)) / + L2; + const cl = Math.max(0, Math.min(1, tt)); + const px = p1.x + cl * (p2.x - p1.x), + py = p1.y + cl * (p2.y - p1.y); + const d = Math.hypot(tx - px, ty - py); + if (d < md) md = d; + } + return md * scale; + })(); + const distStation = (function () { + if (!stationsForAngle.length) return Infinity; + let md = Infinity; + + const LABEL_WIDTH_PX = 120; + + const labelWidthMap = LABEL_WIDTH_PX / scale; + + for (const st of stationsForAngle) { + const align = st.align ?? 3; + + let lx = st.longitude; + let ly = st.latitude; + + if (align === 1 || align === 0) { + lx += labelWidthMap / 2; + } else if (align === 3) { + lx -= labelWidthMap / 2; + } + + const d = Math.hypot(tx - lx, ty - ly); + if (d < md) md = d; + } + return md * scale; + })(); + let weight = 0; + if (distPath < minPath) weight += 100 * (1 - distPath / minPath); + if (distPassed < minPassed) + weight += 10 * (1 - distPassed / minPassed); + if (distStation < minStation) + weight += 1000 * (1 - distStation / minStation); + if (weight < bestScore) { + bestScore = weight; + bestAng = ang; + } + } + return bestAng; + })(); + + const offsetBack = 5 / scale; + const adjustedRx = rx - Math.cos(optimalAngle) * offsetBack; + const adjustedRy = ry - Math.sin(optimalAngle) * offsetBack; + const screenX = (adjustedRx * scale + position.x) / dpr2; + const screenY = (adjustedRy * scale + position.y) / dpr2; + + const screenXDot = (rx * scale + position.x) / dpr2; + const screenYDot = (ry * scale + position.y) / dpr2; + + let activeStationIndex = -1; + const tramCoords = apiStore?.context?.currentCoordinates; + + if ( + tramCoords && + stationData && + stationData.length > 0 && + Number.isFinite(tramCoords.latitude) && + Number.isFinite(tramCoords.longitude) + ) { + let bestD = Infinity; + const DISTANCE_THRESHOLD = 0.00015; + + for (let i = 0; i < stationData.length; i++) { + const station = stationData[i]; + if ( + Number.isFinite(station.latitude) && + Number.isFinite(station.longitude) + ) { + const d = getDistance( + { + latitude: tramCoords.latitude, + longitude: tramCoords.longitude, + }, + { + latitude: station.latitude, + longitude: station.longitude, + }, + ); + if (d < bestD) { + bestD = d; + activeStationIndex = i; + } + } + } + + if (bestD > DISTANCE_THRESHOLD) { + activeStationIndex = -1; + } + } + + const isAtStation = activeStationIndex !== -1; + + const dotColor = isAtStation ? "#fcd500" : "white"; + + return ( + <> +
+ + + ); + })()} +
+
+ ); +}); + +export default WebGLMap; diff --git a/src/client/src/components/map/custom.d.ts b/src/client/src/components/map/custom.d.ts new file mode 100644 index 0000000..6dabad2 --- /dev/null +++ b/src/client/src/components/map/custom.d.ts @@ -0,0 +1,26 @@ +declare module "*.svg" { + import React = require("react"); + export const ReactComponent: React.FC>; + const src: string; + export default src; +} + +declare module "*.png" { + const value: string; + export default value; +} + +declare module "*.jpg" { + const value: string; + export default value; +} + +declare module "*.jpeg" { + const value: string; + export default value; +} + +declare module "*.gif" { + const value: string; + export default value; +} diff --git a/src/client/src/components/map/transformContext.tsx b/src/client/src/components/map/transformContext.tsx new file mode 100644 index 0000000..0bde258 --- /dev/null +++ b/src/client/src/components/map/transformContext.tsx @@ -0,0 +1,87 @@ +import React, { + createContext, + ReactNode, + useContext, + useState, + useCallback, +} from "react"; + +const TransformContext = createContext<{ + position: { x: number; y: number }; + scale: number; + setPosition: React.Dispatch>; + setScale: React.Dispatch>; + screenCenter?: { x: number; y: number }; + setScreenCenter: React.Dispatch< + React.SetStateAction<{ x: number; y: number } | undefined> + >; + isAutoMode: boolean; + setIsAutoMode: React.Dispatch>; + userActivityTimestamp: number; + updateUserActivity: () => void; + autoModeStartTimestamp: number | null; + setAutoModeStartTimestamp: React.Dispatch< + React.SetStateAction + >; +}>({ + position: { x: 0, y: 0 }, + scale: 1, + setPosition: () => {}, + setScale: () => {}, + screenCenter: undefined, + setScreenCenter: () => {}, + isAutoMode: false, + setIsAutoMode: () => {}, + userActivityTimestamp: Date.now(), + updateUserActivity: () => {}, + autoModeStartTimestamp: null, + setAutoModeStartTimestamp: () => {}, +}); + +export const TransformProvider = ({ children }: { children: ReactNode }) => { + const [position, setPosition] = useState({ x: 0, y: 0 }); + const [scale, setScale] = useState(1); + const [screenCenter, setScreenCenter] = useState<{ x: number; y: number }>(); + const [isAutoMode, setIsAutoMode] = useState(false); + const [userActivityTimestamp, setUserActivityTimestamp] = useState( + Date.now() + ); + + const [autoModeStartTimestamp, setAutoModeStartTimestamp] = useState< + number | null + >(null); + + const updateUserActivity = useCallback(() => { + setUserActivityTimestamp(Date.now()); + // Убираем автоматическое отключение авто режима - это будет делаться в InfiniteCanvas + }, []); + + const value = { + position, + scale, + setPosition, + setScale, + screenCenter, + setScreenCenter, + isAutoMode, + setIsAutoMode, + userActivityTimestamp, + updateUserActivity, + autoModeStartTimestamp, + setAutoModeStartTimestamp, + }; + + return ( + + {children} + + ); +}; + +export const useTransform = () => { + const context = useContext(TransformContext); + if (!context) { + throw new Error("useTransform must be used within a TransformProvider"); + } + return context; +}; diff --git a/src/client/src/components/map/types.tsx b/src/client/src/components/map/types.tsx new file mode 100644 index 0000000..6613f70 --- /dev/null +++ b/src/client/src/components/map/types.tsx @@ -0,0 +1,41 @@ +export interface RouteData { + center_longitude: any; + center_latitude: any; + path: [number, number][]; + scale_min: number; + scale_max: number; + rotate?: number; +} + +export interface StationData { + id: number; + latitude: number; + longitude: number; + offset_x: number; + offset_y: number; + name: string; +} + +export interface StationDataEn extends StationData { + name: string; // Имя на английском +} + +export interface SightData { + id: number; + latitude: number; + longitude: number; + name: string; + description?: string; + city_id?: string; + address?: string; + thumbnail?: string; + watermark_lu?: string; + watermark_rd?: string; + left_article?: number; + preview_media?: string; + video_preview?: string; + icon_size?: number; + icon?: string; + alt_icon?: string; + is_default_icon?: boolean; +} diff --git a/src/client/src/components/map/utils.tsx b/src/client/src/components/map/utils.tsx new file mode 100644 index 0000000..9aab309 --- /dev/null +++ b/src/client/src/components/map/utils.tsx @@ -0,0 +1,13 @@ +export function coordinatesToLocal(latitude: number, longitude: number) { + return { + x: longitude, + y: -latitude*2, + } +} + +export function localToCoordinates(x: number, y: number) { + return { + longitude: x, + latitude: -y/2, + } +} diff --git a/src/client/src/components/side-menu/BackButtonSVG.jsx b/src/client/src/components/side-menu/BackButtonSVG.jsx new file mode 100644 index 0000000..52eaa30 --- /dev/null +++ b/src/client/src/components/side-menu/BackButtonSVG.jsx @@ -0,0 +1,17 @@ +import subtractHomeIcon from "../../assets/icons/subtract-home.svg"; + +function BackButtonSVG({ onPointerUp }) { + return ( + + ); +} + +export default BackButtonSVG; diff --git a/src/client/src/components/side-menu/Collapsible.jsx b/src/client/src/components/side-menu/Collapsible.jsx new file mode 100644 index 0000000..6e31650 --- /dev/null +++ b/src/client/src/components/side-menu/Collapsible.jsx @@ -0,0 +1,39 @@ +import { useRef, useEffect } from "react"; + +const Collapsible = ({ open, className = "", children }) => { + const ref = useRef(null); + const isFirst = useRef(true); + + useEffect(() => { + const el = ref.current; + if (!el) return; + + if (isFirst.current) { + isFirst.current = false; + el.style.height = open ? "auto" : "0px"; + return; + } + + if (open) { + el.style.height = `${el.scrollHeight}px`; + const onEnd = (e) => { + if (e.target !== el || e.propertyName !== "height") return; + el.style.height = "auto"; + el.removeEventListener("transitionend", onEnd); + }; + el.addEventListener("transitionend", onEnd); + } else { + el.style.height = `${el.scrollHeight}px`; + void el.offsetHeight; + el.style.height = "0px"; + } + }, [open]); + + return ( +
+ {children} +
+ ); +}; + +export default Collapsible; diff --git a/src/client/src/components/side-menu/LeftWidget.jsx b/src/client/src/components/side-menu/LeftWidget.jsx new file mode 100644 index 0000000..079c7c1 --- /dev/null +++ b/src/client/src/components/side-menu/LeftWidget.jsx @@ -0,0 +1,209 @@ +import { useEffect, useState, useRef, useLayoutEffect } from "react"; +import { observer } from "mobx-react-lite"; +import ContentAPI from "../../api/content/content.api"; +import { useGeolocationStore } from "../../stores"; +import "../../styles/LeftWidget.css"; +import { apiStore } from "../../api/ApiStore/store"; +import { apiBaseURL } from "../../api/apiConfig"; +import { ReactMarkdownComponent } from "../ReactMarkdown"; +import { TouchableLayout } from "../TouchableLayout"; + +const LeftWidget = observer( + ({ selectedSightId, onClose, isVisible, sightTop }) => { + const [selectedSightData, setSelectedSightData] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [shouldAnimateIn, setShouldAnimateIn] = useState(false); + const [isImageLoaded, setIsImageLoaded] = useState(false); + const [widgetHeight, setWidgetHeight] = useState(0); + + const layoutRef = useRef(null); + const widgetRef = useRef(null); + + const store = useGeolocationStore(); + const { selectedLanguage, isLeftWidgetOpen } = store; + const { + sightArticles, + sightArticlesEn, + sightArticlesZh, + routeSights, + routeSightsEn, + routeSightsZh, + } = apiStore; + + useLayoutEffect(() => { + if (widgetRef.current) { + setWidgetHeight(widgetRef.current.getBoundingClientRect().height); + } + }, [selectedSightData, isImageLoaded, isVisible, isLoading, error]); + + useEffect(() => { + if (layoutRef.current) { + const scrollable = layoutRef.current.querySelector(".scrollable"); + if (scrollable) scrollable.scrollTop = 0; + } + }, [selectedSightData]); + + useEffect(() => { + if (!selectedSightId) { + setSelectedSightData(null); + setError(null); + setIsLoading(false); + setShouldAnimateIn(false); + setIsImageLoaded(false); + return; + } + + async function fetchData() { + if (!isLeftWidgetOpen) { + setIsLoading(true); + setError(null); + setIsImageLoaded(false); + setShouldAnimateIn(false); + } + + try { + const sight = + selectedLanguage === "ru" + ? routeSights.find((sight) => sight.id === selectedSightId) + : selectedLanguage === "en" + ? routeSightsEn.find((sight) => sight.id === selectedSightId) + : routeSightsZh.find((sight) => sight.id === selectedSightId); + + if (!sight) return; + + const leftArticle = sight.left_article; + + const leftArticleData = + selectedLanguage === "ru" + ? sightArticles.get(leftArticle + "_" + selectedLanguage) + : selectedLanguage === "en" + ? sightArticlesEn.get(leftArticle + "_" + selectedLanguage) + : sightArticlesZh.get(leftArticle + "_" + selectedLanguage); + + if (!leftArticleData?.media?.length) return; + + const media = await ContentAPI.getMediaPreview( + leftArticleData.media[0].id, + selectedLanguage, + ); + + const response = { + mediaPath: media.path, + mediaType: media.type, + title: leftArticleData.heading, + text: leftArticleData.body, + address: sight.address, + }; + + setSelectedSightData(response); + } catch (err) { + console.error("Ошибка отображения левого виджета:", err); + setError("Не удалось загрузить информацию о достопримечательности."); + setSelectedSightData(null); + setShouldAnimateIn(false); + } finally { + if (!isLeftWidgetOpen) { + setIsLoading(false); + } + } + } + + if (isLeftWidgetOpen && isVisible) { + fetchData(); + } else if (!isLeftWidgetOpen) { + setShouldAnimateIn(false); + } + }, [selectedSightId, isLeftWidgetOpen, selectedLanguage, isVisible]); + + const handleImageLoad = () => { + setIsImageLoaded(true); + if (isVisible) { + setTimeout(() => { + setShouldAnimateIn(true); + }, 50); + } + }; + + const handleImageError = () => { + setIsImageLoaded(false); + console.error( + "Ошибка загрузки изображения для достопримечательности:", + selectedSightId, + ); + if (isVisible) { + setTimeout(() => { + setShouldAnimateIn(true); + }, 50); + } + }; + + const getTopPosition = () => { + if (!sightTop) return "500px"; + if (!widgetHeight) return `${sightTop}px`; + + const windowHeight = window.innerHeight; + const margin = 90; + const maxBottom = windowHeight - margin; + + const projectedBottom = sightTop + widgetHeight; + + if (projectedBottom > maxBottom) { + return `${maxBottom - widgetHeight}px`; + } + return `${sightTop}px`; + }; + + const widgetTransformStyle = { + top: getTopPosition(), + }; + + return ( +
+ {isLoading ? ( +
Загрузка информации...
+ ) : error ? ( +
{error}
+ ) : selectedSightData ? ( + <> + {selectedSightData.mediaType == 1 || + selectedSightData.mediaType == 3 ? ( + Sight image + ) : ( + <> + )} +
+
{selectedSightData.title}
+
+ {selectedSightData.address} +
+ +
+ +
+
+
+ + ) : (isVisible || selectedSightData) && !isLoading ? ( +
+ {selectedLanguage === "ru" + ? "Выберите достопримечательность для просмотра деталей." + : selectedLanguage === "zh" + ? "选择一个地标来查看详细信息。" + : "Select a landmark to view details."} +
+ ) : null} +
+ ); + }, +); + +export default LeftWidget; diff --git a/src/client/src/components/side-menu/SideMenu.jsx b/src/client/src/components/side-menu/SideMenu.jsx new file mode 100644 index 0000000..437cb69 --- /dev/null +++ b/src/client/src/components/side-menu/SideMenu.jsx @@ -0,0 +1,834 @@ +import "../../styles/SideMenu.css"; +import AppealWidget from "../widgets/AppealWidget"; +import { useEffect, useState, useCallback, useRef } from "react"; +import { observer } from "mobx-react-lite"; +import sideMenuPhoto from "/side-menu-photo.png"; +import RouteWidget from "../widgets/RouteWidget"; +import WeatherWidget from "../WeatherWidget"; +import ContentAPI from "../../api/content/content.api"; +import { useGeolocationStore, useColorStore } from "../../stores"; +import "../../styles/LeftWidget.css"; +import SightsList from "./SightsList"; +import StationsList from "./StationsList"; +import LeftWidget from "./LeftWidget"; +import { apiStore } from "../../api/ApiStore/store"; +import { getMediaUrl } from "../../api/apiConfig"; + +const SideMenu = observer(({ onMenuToggle }) => { + const { + carrier, + route, + getArticle, + sightArticles, + sightArticlesEn, + sightArticlesZh, + routeSights, + routeSightsEn, + routeSightsZh, + } = apiStore; + const [isMenuOpen, openMenu] = useState(false); + const [isSightsOpen, openSights] = useState(false); + const [isStationOpen, openStation] = useState(false); + const [isWidgetOpen, openWidget] = useState(false); + const [isWeatherVisible, setIsWeatherVisible] = useState(true); + const [isPreviewMediaError, setIsPreviewMediaError] = useState(false); + const [isLeftWidgetVisible, setIsLeftWidgetVisible] = useState(false); + const [localSelectedSightId, setLocalSelectedSightId] = useState(null); + const [isSightsAnimating, setIsSightsAnimating] = useState(false); + const [isStationAnimating, setIsStationAnimating] = useState(false); + const [shouldRenderSights, setShouldRenderSights] = useState(false); + const [shouldRenderStation, setShouldRenderStation] = useState(false); + const [sightTop, setSightTop] = useState(null); + const isSortingChangingRef = useRef(false); + + const store = useGeolocationStore(); + const { setIsGovernorWidgetOpen, sortingBy, setSortingBy } = store; + const colorStore = useColorStore(); + const { currentColor, startColorAnimation } = colorStore; + const { + contextData, + isGovernorWidgetOpen, + isLoading: isGeolocationLoading, + selectedLanguage, + setSelectedLanguage, + setIsLeftWidgetOpen, + nearestSightId, + selectedSightId, + isManualSelection, + setSelectedSightId, + setIsManualSelection, + setIsRightWidgetSelectorOpen, + isLeftWidgetOpen, + } = store; + + const sideMenuRef = useRef(null); + const sightsListRef = useRef(null); + const stationsListRef = useRef(null); + const activeTouches = useRef(new Map()); + + // Запуск анимации цвета при монтировании компонента + useEffect(() => { + startColorAnimation(); + }, [startColorAnimation]); + + useEffect(() => { + const menuContainer = sideMenuRef.current; + if (!menuContainer) return; + + const SCROLL_THRESHOLD = 10; + + const handleTouchStart = (e) => { + const sightsContainer = sightsListRef.current; + const stationsContainer = stationsListRef.current; + + for (const touch of e.changedTouches) { + let scrollTarget = null; + const targetElement = document.elementFromPoint( + touch.clientX, + touch.clientY, + ); + + // Определяем, над каким из скролл-контейнеров находится палец + if (sightsContainer && sightsContainer.contains(targetElement)) { + scrollTarget = sightsContainer; + } else if ( + stationsContainer && + stationsContainer.contains(targetElement) + ) { + scrollTarget = stationsContainer; + } + + if (scrollTarget) { + activeTouches.current.set(touch.identifier, { + target: scrollTarget, + startY: touch.clientY, + lastY: touch.clientY, + isScrolling: false, // Флаг, что скролл еще не начался + }); + } + } + }; + + const handleTouchMove = (e) => { + for (const touch of e.changedTouches) { + const activeTouch = activeTouches.current.get(touch.identifier); + if (activeTouch) { + const deltaY = touch.clientY - activeTouch.startY; + + if (!activeTouch.isScrolling && Math.abs(deltaY) > SCROLL_THRESHOLD) { + activeTouch.isScrolling = true; + } + + if (activeTouch.isScrolling) { + e.preventDefault(); // Предотвращаем стандартное поведение только при скролле + const moveDelta = touch.clientY - activeTouch.lastY; + activeTouch.target.scrollTop -= moveDelta; + activeTouch.lastY = touch.clientY; + } + } + } + }; + + const handleTouchEnd = (e) => { + for (const touch of e.changedTouches) { + activeTouches.current.delete(touch.identifier); + } + }; + + menuContainer.addEventListener("touchstart", handleTouchStart, { + passive: true, + }); + menuContainer.addEventListener("touchmove", handleTouchMove, { + passive: false, + }); + menuContainer.addEventListener("touchend", handleTouchEnd, { + passive: true, + }); + menuContainer.addEventListener("touchcancel", handleTouchEnd, { + passive: true, + }); + + return () => { + menuContainer.removeEventListener("touchstart", handleTouchStart); + menuContainer.removeEventListener("touchmove", handleTouchMove); + menuContainer.removeEventListener("touchend", handleTouchEnd); + menuContainer.removeEventListener("touchcancel", handleTouchEnd); + }; + }, [isSightsOpen, isStationOpen]); // Перезапускаем эффект, когда списки появляются/исчезают + // --- КОНЕЦ: Улучшенная логика --- + + useEffect(() => { + if (isGovernorWidgetOpen) { + openWidget(true); + } else { + openWidget(false); + } + }, [isGovernorWidgetOpen]); + + useEffect(() => { + setIsLeftWidgetVisible(isMenuOpen && isLeftWidgetOpen); + }, [isMenuOpen, isLeftWidgetOpen]); + + useEffect(() => {}, [ + localSelectedSightId, + sightTop, + isLeftWidgetVisible, + isLeftWidgetOpen, + isMenuOpen, + ]); + + useEffect(() => { + if (isLeftWidgetVisible) { + setIsWeatherVisible(false); + } else { + setIsWeatherVisible(true); + } + }, [isLeftWidgetVisible]); + + const [routeData, setRouteData] = useState(null); + const [isLangMenuOpen, setIsLangMenuOpen] = useState(false); + + useEffect(() => { + setIsPreviewMediaError(false); + }, [carrier?.preview_media]); + + useEffect(() => { + if (isGeolocationLoading || !contextData?.routeId) { + return; + } + async function fetchRoutePathData() { + try { + setRouteData(route); + } catch (err) { + console.error("Ошибка в получении данных маршрута:", err); + } + } + fetchRoutePathData(); + }, [contextData?.routeId, isGeolocationLoading, selectedLanguage]); + + useEffect(() => { + const fetchArticles = async () => { + if (route && route?.governor_appeal > 0) { + await getArticle(route?.governor_appeal, "ru"); + await getArticle(route?.governor_appeal, "en"); + await getArticle(route?.governor_appeal, "zh"); + } + }; + fetchArticles(); + }, [route, route?.governor_appeal]); + + const handleLanguageChange = (langCode) => { + setSelectedLanguage(langCode); + setIsLangMenuOpen(false); + }; + + const handleMenuToggle = (newMenuState) => { + openWidget(false); + store.closeGovernorModal(); + if (onMenuToggle) onMenuToggle(newMenuState); + if (newMenuState) { + openMenu(newMenuState); + } else { + setIsLeftWidgetVisible(false); + // Закрываем списки мгновенно + if (isSightsOpen) { + openSights(false); + setLocalSelectedSightId(null); + setSightTop(null); + setIsSightsAnimating(false); + setShouldRenderSights(false); + } + if (isStationOpen) { + openStation(false); + setIsStationAnimating(false); + setShouldRenderStation(false); + } + openMenu(newMenuState); + setIsLeftWidgetOpen(false); + } + }; + + const handleMenuToggleRef = useRef(handleMenuToggle); + handleMenuToggleRef.current = handleMenuToggle; + + const isMenuOpenRef = useRef(isMenuOpen); + isMenuOpenRef.current = isMenuOpen; + + useEffect(() => { + let idleSeconds = 0; + + const checkIdle = () => { + idleSeconds += 1; + if (idleSeconds >= 60 && isMenuOpenRef.current) { + handleMenuToggleRef.current(false); + } + }; + + const intervalId = setInterval(checkIdle, 1000); + + const resetIdle = () => { + idleSeconds = 0; + }; + + const events = [ + "mousedown", + "mousemove", + "keypress", + "scroll", + "touchstart", + "click", + ]; + + events.forEach((event) => { + window.addEventListener(event, resetIdle, { passive: true }); + }); + + return () => { + clearInterval(intervalId); + events.forEach((event) => { + window.removeEventListener(event, resetIdle); + }); + }; + }, []); + + // Автоматическое скрытие левого виджета через 60 секунд бездействия + useEffect(() => { + if (!isLeftWidgetVisible) return; + + let idleSeconds = 0; + + const checkIdle = () => { + idleSeconds += 1; + if (idleSeconds >= 60) { + setIsLeftWidgetVisible(false); + setTimeout(() => { + setIsLeftWidgetOpen(false); + }, 1000); + } + }; + + const intervalId = setInterval(checkIdle, 1000); + + const resetIdle = () => { + idleSeconds = 0; + }; + + const events = [ + "mousedown", + "mousemove", + "keypress", + "scroll", + "touchstart", + "click", + ]; + + events.forEach((event) => + window.addEventListener(event, resetIdle, { passive: true }), + ); + + return () => { + clearInterval(intervalId); + events.forEach((event) => window.removeEventListener(event, resetIdle)); + }; + }, [isLeftWidgetVisible, setIsLeftWidgetOpen]); + + // Закрываем и открываем список достопримечательностей при изменении сортировки + const prevSortingByRef = useRef(sortingBy); + const isFirstRenderRef = useRef(true); + useEffect(() => { + // Пропускаем первый рендер + if (isFirstRenderRef.current) { + isFirstRenderRef.current = false; + prevSortingByRef.current = sortingBy; + return; + } + + // Если список достопримечательностей открыт и сортировка изменилась, закрываем и открываем заново + if (isSightsOpen && prevSortingByRef.current !== sortingBy) { + // Блокируем обновление позиции виджета во время процесса + isSortingChangingRef.current = true; + + setIsSightsAnimating(true); + openSights(false); + + setTimeout(() => { + setIsSightsAnimating(false); + setShouldRenderSights(false); + + // Открываем список заново + setShouldRenderSights(true); + setTimeout(() => { + openSights(true); + // Разблокируем обновление позиции после полного открытия + setTimeout(() => { + isSortingChangingRef.current = false; + }, 350); + }, 10); + }, 300); + } + + prevSortingByRef.current = sortingBy; + }, [sortingBy, isSightsOpen]); + + return ( +
+
+ {shouldRenderSights && ( + { + setIsSightsAnimating(true); + openSights(false); + setIsLeftWidgetVisible(false); + setTimeout(() => { + setIsLeftWidgetOpen(false); + setIsSightsAnimating(false); + setShouldRenderSights(false); + }, 300); + }} + setWeatherVisible={setIsWeatherVisible} + setLocalSelectedSightId={setLocalSelectedSightId} + localSelectedSightId={localSelectedSightId} + onSightTopChange={(top) => { + // Не обновляем позицию виджета во время смены сортировки + if (isSortingChangingRef.current) { + return; + } + setSightTop(top); + }} + onSightSelected={(sightId) => { + // Обработка выбора достопримечательности уже в компоненте + }} + /> + )} + {shouldRenderStation && ( + { + setIsStationAnimating(true); + openStation(false); + setIsLeftWidgetVisible(false); + setTimeout(() => { + setIsLeftWidgetOpen(false); + setIsStationAnimating(false); + setShouldRenderStation(false); + setIsWeatherVisible(true); + }, 300); + }} + onStationSelected={(stationId) => { + // Обработка выбора остановки уже в компоненте + }} + onSightClick={(sightId, top) => { + setLocalSelectedSightId(sightId); + if (top !== undefined) { + setSightTop(top); + } + setIsLeftWidgetOpen(true); + }} + /> + )} + {route?.governor_appeal > 0 && ( +
{ + openWidget(!isWidgetOpen); + store.setIsGovernorWidgetOpen(!isWidgetOpen); + }} + className="appeal-button" + > + {route?.button_text + ? route.button_text + : selectedLanguage == "ru" + ? "Обращение губернатора" + : selectedLanguage == "zh" + ? "州长致辞" + : "Governor's appeal"} +
+ )} +
0 ? '40px' : '260px' }}> +
{ + if (!isSightsOpen) { + if (isStationOpen) { + setIsStationAnimating(true); + openStation(false); + setTimeout(() => { + setIsStationAnimating(false); + setShouldRenderStation(false); + + setLocalSelectedSightId(null); + setSightTop(null); + setShouldRenderSights(true); + setTimeout(() => { + openSights(true); + }, 10); + }, 300); + } else { + setLocalSelectedSightId(null); + setSightTop(null); + setShouldRenderSights(true); + setTimeout(() => { + openSights(true); + }, 10); + } + openWidget(false); + store.closeGovernorModal(); + setIsLeftWidgetOpen(false); + } else { + setIsSightsAnimating(true); + openSights(false); + setIsLeftWidgetVisible(false); + setLocalSelectedSightId(null); + setSightTop(null); + setTimeout(() => { + setIsLeftWidgetOpen(false); + setIsSightsAnimating(false); + setShouldRenderSights(false); + }, 300); + } + }} + className={`side-menu-button side-menu-button--sights ${ + isSightsOpen ? "side-menu-button--active" : "" + }`} + > + {selectedLanguage == "ru" + ? "Достопримечательности" + : selectedLanguage == "zh" + ? "景点" + : "Attractions"} +
+
{ + if (!isStationOpen) { + if (isSightsOpen) { + setIsSightsAnimating(true); + openSights(false); + setIsLeftWidgetVisible(false); + setLocalSelectedSightId(null); + setSightTop(null); + setTimeout(() => { + setIsLeftWidgetOpen(false); + setIsSightsAnimating(false); + setShouldRenderSights(false); + setShouldRenderStation(true); + setTimeout(() => { + openStation(true); + }, 10); + }, 300); + } else { + setLocalSelectedSightId(null); + setSightTop(null); + setShouldRenderStation(true); + setTimeout(() => { + openStation(true); + }, 10); + } + openWidget(false); + store.closeGovernorModal(); + setIsLeftWidgetOpen(false); + } else { + setIsStationAnimating(true); + openStation(false); + setTimeout(() => { + setIsStationAnimating(false); + setShouldRenderStation(false); + }, 300); + } + }} + className={`side-menu-button ${ + isStationOpen ? "side-menu-button--active" : "" + }`} + > + {selectedLanguage == "ru" + ? "Остановки" + : selectedLanguage == "zh" + ? "车站" + : "Stations"} +
+
+
+ {/* {selectedLanguage == "ru" + ? "#ВсемПоПути" + : selectedLanguage == "zh" + ? "#每个人都在路上" + : "#EveryoneOnTheWay"} */} +
+
+ setIsPreviewMediaError(true)} + alt="" + /> +
+
+
+ + +
+ +
+ + { + const m = sightArticles.get(route?.governor_appeal + "_ru")?.media; + const mediaId = Array.isArray(m) ? m[0]?.id : m?.id; + return mediaId ? ContentAPI.getMediaPath(mediaId) : undefined; + })()} + isOpen={isWidgetOpen} + style={{ + transform: isWidgetOpen ? "translateX(0)" : "translateX(-200%)", + transition: "transform 0.5s ease", + zIndex: -1, + pointerEvents: isWidgetOpen ? "auto" : "none", + }} + widgetLabel={ + selectedLanguage == "ru" + ? sightArticles.get(route?.governor_appeal + "_ru")?.heading + : selectedLanguage == "zh" + ? sightArticlesZh.get(route?.governor_appeal + "_zh")?.heading + : sightArticlesEn.get(route?.governor_appeal + "_en")?.heading + } + widgetText={ + selectedLanguage == "ru" + ? sightArticles.get(route?.governor_appeal + "_ru")?.body + : selectedLanguage == "zh" + ? sightArticlesZh.get(route?.governor_appeal + "_zh")?.body + : sightArticlesEn.get(route?.governor_appeal + "_en")?.body + } + /> + +
+ { + handleMenuToggle(!isMenuOpen); + }} + width="48" + height="48" + viewBox="0 0 48 48" + fill="none" + xmlns="http://www.w3.org/2000/svg" + > + + + + + {isLangMenuOpen ? ( +
+ handleLanguageChange("ru")} + className={`side-menu-control-lang icon-color-animated ${ + selectedLanguage === "ru" ? "active" : "" + }`} + width="48" + height="48" + viewBox="0 0 48 48" + fill="none" + xmlns="http://www.w3.org/2000/svg" + style={{ "--animated-color": currentColor }} + > + + + + + handleLanguageChange("zh")} + className={`side-menu-control-lang icon-color-animated ${ + selectedLanguage === "zh" ? "active" : "" + }`} + width="48" + height="48" + viewBox="0 0 48 48" + style={{ "--animated-color": currentColor }} + fill="none" + xmlns="http://www.w3.org/2000/svg" + > + + + + + + + handleLanguageChange("en")} + className={`side-menu-control-lang icon-color-animated ${ + selectedLanguage === "en" ? "active" : "" + }`} + width="48" + height="48" + viewBox="0 0 48 48" + style={{ "--animated-color": currentColor }} + fill="none" + xmlns="http://www.w3.org/2000/svg" + > + + + +
+ ) : ( + { + setIsLangMenuOpen(true); + }} + width="48" + height="48" + style={{ "--animated-color": currentColor }} + viewBox="0 0 48 48" + fill="none" + xmlns="http://www.w3.org/2000/svg" + > + + + + )} + + {isSightsOpen && ( + { + setSortingBy(sortingBy === "asc" ? "desc" : "asc"); + }} + > + + + + + + + + + + )} +
+
+
+ { + setIsLeftWidgetVisible(false); + setTimeout(() => { + setIsLeftWidgetOpen(false); + }, 1000); + }} + isVisible={isLeftWidgetVisible} + sightTop={sightTop} + /> +
+
+ ); +}); + +export default SideMenu; diff --git a/src/client/src/components/side-menu/SightStationsList.jsx b/src/client/src/components/side-menu/SightStationsList.jsx new file mode 100644 index 0000000..f59d01e --- /dev/null +++ b/src/client/src/components/side-menu/SightStationsList.jsx @@ -0,0 +1,286 @@ +import { + useEffect, + useState, + forwardRef, + useRef, + useLayoutEffect, +} from "react"; +import { observer } from "mobx-react-lite"; +import { useGeolocationStore } from "../../stores"; +import { apiStore } from "../../api/ApiStore/store"; +import { useClickDetection } from "../../hooks/useClickDetection"; +import { OverlayScrollbarsWrapper } from "../OverlayScrollbarsWrapper"; +import "../../styles/OverlayScrollbars.css"; +import { getSightStations } from "../../api/ApiStore/api"; +import busIcon from "../../assets/transport-icons/bus.svg"; +import metroBlueIcon from "../../assets/transport-icons/metroBlue.svg"; +import metroGreenIcon from "../../assets/transport-icons/metroGreen.svg"; +import metroOrangeIcon from "../../assets/transport-icons/metroOrange.svg"; +import metroPurpleIcon from "../../assets/transport-icons/metroPurple.svg"; +import metroRedIcon from "../../assets/transport-icons/metroRed.svg"; +import trainIcon from "../../assets/transport-icons/train.svg"; +import tramIcon from "../../assets/transport-icons/tram.svg"; +import trolleyIcon from "../../assets/transport-icons/trolley.svg"; + +const StationItem = ({ + station, + handlePointerDown, + handlePointerUp, + handleStationClick, + selectedStationId, + transferIcons, + getNoTransfersMessage, + isLast, +}) => { + const containerRef = useRef(null); + const textRef = useRef(null); + const [shouldAnimate, setShouldAnimate] = useState(false); + + useLayoutEffect(() => { + const checkWidth = () => { + if (containerRef.current && textRef.current) { + const containerWidth = containerRef.current.offsetWidth; + const textWidth = textRef.current.scrollWidth; + const threshold = containerWidth * 1.1; + setShouldAnimate(textWidth > threshold); + } + }; + + checkWidth(); + window.addEventListener("resize", checkWidth); + return () => window.removeEventListener("resize", checkWidth); + }, [station.name]); + + return ( +
+
handlePointerDown(e, station.id)} + onPointerUp={(e) => handlePointerUp(e, station.id, handleStationClick)} + > + + {station.name} + +
+
+ {station.transfers != null && + !Object.values(station.transfers).every((value) => value === "") ? ( + Object.entries(station.transfers).map( + ([transferType, transferValue]) => { + const IconComponent = transferIcons[transferType]; + + if (transferValue && transferValue.length > 0) { + return ( +
+ {IconComponent && ( + {`${transferType} + )} + {transferValue} +
+ ); + } + return null; + } + ) + ) : ( +
+ {getNoTransfersMessage()} +
+ )} +
+
+ ); +}; + +const SightStationsList = observer( + forwardRef( + ({ style, className, onClose, sightId, selectedLanguage }, ref) => { + const [stationList, setStationList] = useState([]); + const [error, setError] = useState(null); + const [isStationListLoading, setIsStationListLoading] = useState(true); + const [selectedStationId, setSelectedStationId] = useState(null); + + const { handlePointerDown, handlePointerUp, handleScroll } = + useClickDetection(); + + const getNoTransfersMessage = () => { + if (selectedLanguage === "ru") return "Нет пересадок"; + if (selectedLanguage === "zh") return "没有转移"; + return "No transfers"; + }; + + const transferIcons = { + metro_red: metroRedIcon, + metro_green: metroGreenIcon, + metro_blue: metroBlueIcon, + metro_orange: metroOrangeIcon, + metro_purple: metroPurpleIcon, + tram: tramIcon, + trolleybus: trolleyIcon, + bus: busIcon, + train: trainIcon, + }; + + useEffect(() => { + async function fetchStationList() { + if (!sightId) { + setIsStationListLoading(false); + return; + } + + setIsStationListLoading(true); + setError(null); + try { + const response = await getSightStations(sightId, selectedLanguage); + + const sortedStations = [...(response || [])].sort((a, b) => { + const nameA = a.name.trim(); + const nameB = b.name.trim(); + + return nameA.localeCompare( + nameB, + selectedLanguage === "ru" + ? "ru" + : selectedLanguage === "zh" + ? "zh" + : "en", + { + sensitivity: "base", + } + ); + }); + + setStationList(sortedStations); + } catch (err) { + setError( + "Не удалось получить остановки. Пожалуйста, попробуйте позже." + ); + setStationList([]); + } finally { + setIsStationListLoading(false); + } + } + + fetchStationList(); + }, [sightId, selectedLanguage]); + + const handleStationClick = (stationId) => { + setSelectedStationId((prevId) => + prevId === stationId ? null : stationId + ); + }; + + return ( +
+
{ + if (onClose) { + onClose(); + } + }} + > +

+ {selectedLanguage === "ru" + ? "Остановки" + : selectedLanguage === "zh" + ? "车站" + : "Stations"} +

+ + + + + + + + + + +
+ + {isStationListLoading ? ( +
+ {selectedLanguage === "ru" + ? "Загрузка остановок..." + : selectedLanguage === "zh" + ? "加载停止..." + : "Loading stops..."} +
+ ) : error ? ( +
{error}
+ ) : stationList.length === 0 ? ( +
+ {selectedLanguage === "ru" + ? "Остановки не найдены" + : selectedLanguage === "zh" + ? "没有找到停靠站" + : "No stops found"} +
+ ) : ( + stationList.map((station, index) => ( + + )) + )} +
+
+ ); + } + ) +); + +export default SightStationsList; diff --git a/src/client/src/components/side-menu/SightsList.jsx b/src/client/src/components/side-menu/SightsList.jsx new file mode 100644 index 0000000..a31ec8e --- /dev/null +++ b/src/client/src/components/side-menu/SightsList.jsx @@ -0,0 +1,458 @@ +import { + useEffect, + useState, + forwardRef, + useRef, + useLayoutEffect, +} from "react"; +import { observer } from "mobx-react-lite"; +import { useGeolocationStore } from "../../stores"; +import { apiStore } from "../../api/ApiStore/store"; +import { useClickDetection } from "../../hooks/useClickDetection"; +import { TouchableLayout } from "../TouchableLayout"; +import Collapsible from "./Collapsible"; +import { getMediaUrl } from "../../api/apiConfig"; +import stationIcon from "../../assets/transport-icons/station.svg"; + +export const isMediaIdEmpty = (id) => { + if (id == null || id === "") return true; + const digits = id.replace(/-/g, ""); + return digits === "" || /^0+$/.test(digits); +}; + +const SightItem = ({ + sight, + handlePointerDown, + handlePointerUp, + handleSightClick, + localSelectedSightId, + selectedLanguage, + sightArticles, + sightArticlesEn, + sightArticlesZh, + selectedSightId, + onSightSelected, + sightStationsCache, +}) => { + const containerRef = useRef(null); + const textRef = useRef(null); + const [shouldAnimate, setShouldAnimate] = useState(false); + const [isExpanded, setIsExpanded] = useState(false); + + const getSightName = () => { + return sight.short_name || sight.name; + }; + + const sightName = getSightName(); + + useLayoutEffect(() => { + const checkWidth = () => { + if (containerRef.current && textRef.current) { + const containerWidth = containerRef.current.offsetWidth; + const textWidth = textRef.current.scrollWidth; + const shouldAnimateValue = textWidth > containerWidth; + setShouldAnimate(shouldAnimateValue); + + // Устанавливаем CSS переменную для анимации + if (shouldAnimateValue && containerRef.current) { + containerRef.current.style.setProperty( + "--container-width", + `${containerWidth}px`, + ); + } + } + }; + + checkWidth(); + window.addEventListener("resize", checkWidth); + return () => window.removeEventListener("resize", checkWidth); + }, [sightName]); + + const handleClick = (e) => { + const newExpanded = !isExpanded; + setIsExpanded(newExpanded); + + // Всегда открываем виджет при клике на достопримечательность + handleSightClick(sight.id); + }; + + const cacheKey = `${sight.id}_${selectedLanguage}`; + const stations = sightStationsCache.get(cacheKey) || []; + + useEffect(() => { + if (localSelectedSightId !== sight.id) { + setIsExpanded(false); + } + }, [localSelectedSightId, sight.id]); + + // Логирование при открытии списка станций для достопримечательности (только один раз) + const wasExpandedRef = useRef(false); + useEffect(() => { + // Выводим логи только когда список открывается (переход из закрытого в открытое состояние) + if (isExpanded && !wasExpandedRef.current && stations.length > 0) { + wasExpandedRef.current = true; + } + + // Сбрасываем флаг когда список закрывается + if (!isExpanded) { + wasExpandedRef.current = false; + } + }, [isExpanded, sight.id, selectedLanguage, stations.length]); + + return ( +
+
handlePointerDown(e, sight.id)} + onPointerUp={(e) => handlePointerUp(e, sight.id, handleClick)} + className="side-menu-sight pointer" + > + + {sightName} + +
+ + {stations.length > 0 ? ( + stations.map((station, index) => { + let isMediaIdEmptyResult = isMediaIdEmpty(station.icon); + const iconSrc = isMediaIdEmptyResult + ? stationIcon + : getMediaUrl(station.icon); + + return ( +
+ + {station.name} +
+ ); + }) + ) : ( +
+ {selectedLanguage === "ru" + ? "Нет остановок" + : selectedLanguage === "zh" + ? "没有站" + : "No stations"} +
+ )} +
+
+ ); +}; + +const SightsList = observer( + forwardRef( + ( + { + style, + className, + onCloseSights, + setWeatherVisible, + setLocalSelectedSightId, + localSelectedSightId, + onSightTopChange, + onSightSelected, + }, + ref, + ) => { + const containerRef = useRef(null); + const store = useGeolocationStore(); + const { + sortingBy, + contextData, + isLoading: isGeolocationLoading, + selectedLanguage, + isLeftWidgetOpen, + setIsLeftWidgetOpen, + } = store; + + useLayoutEffect(() => { + if (containerRef.current) { + if (className === "slide-in") { + // Применяем стили для slide-in + requestAnimationFrame(() => { + if (containerRef.current) { + containerRef.current.style.transform = "translateY(0)"; + containerRef.current.style.opacity = "1"; + } + }); + } else if (className === "slide-out") { + // Очищаем inline стили, чтобы CSS transition работал + containerRef.current.style.transform = ""; + // Принудительно применяем reflow для запуска transition + void containerRef.current.offsetHeight; + } + } + }, [className]); + const { + routeSights, + routeSightsEn, + routeSightsZh, + sightArticles, + sightArticlesEn, + sightArticlesZh, + sightStationsCache, + } = apiStore; + const [sightList, setSightList] = useState([]); + const [error, setError] = useState(null); + const [isSightListLoading, setIsSightListLoading] = useState(true); + + const { handlePointerDown, handlePointerUp, handleScroll } = + useClickDetection(); + + useEffect(() => { + async function fetchSightList() { + setIsSightListLoading(true); + setError(null); + try { + const sights = + selectedLanguage === "ru" + ? routeSights + : selectedLanguage === "en" + ? routeSightsEn + : routeSightsZh; + const getSightNameForSort = (sight) => { + return (sight.short_name || sight.name).trim(); + }; + + const sortedSights = [...(sights || [])].sort((a, b) => { + const nameA = getSightNameForSort(a); + const nameB = getSightNameForSort(b); + + if (sortingBy === "asc") { + return nameA.localeCompare( + nameB, + selectedLanguage === "ru" + ? "ru" + : selectedLanguage === "zh" + ? "zh" + : "en", + { + sensitivity: "base", + }, + ); + } else { + return nameB.localeCompare( + nameA, + selectedLanguage === "ru" + ? "ru" + : selectedLanguage === "zh" + ? "zh" + : "en", + { + sensitivity: "base", + }, + ); + } + }); + setSightList(sortedSights); + } catch (err) { + setError( + "Не удалось получить достопримечательности. Пожалуйста, попробуйте позже.", + ); + setSightList([]); + setIsLeftWidgetOpen(false); + } finally { + setIsSightListLoading(false); + } + } + + fetchSightList(); + }, [ + contextData?.routeId, + isGeolocationLoading, + selectedLanguage, + sortingBy, + setIsLeftWidgetOpen, + setLocalSelectedSightId, + sightArticles, + sightArticlesEn, + sightArticlesZh, + routeSights, + routeSightsEn, + routeSightsZh, + ]); + + const measureSelectedSight = () => { + if (localSelectedSightId && onSightTopChange) { + const element = document.getElementById( + `sight-${localSelectedSightId}`, + ); + if (element && containerRef.current) { + const elementRect = element.getBoundingClientRect(); + const containerRect = containerRef.current.getBoundingClientRect(); + + // Используем позицию элемента относительно viewport (elementRect.top) + // чтобы верхняя граница виджета совпадала с верхней границей элемента + const elementTop = elementRect.top; + onSightTopChange(elementTop); + } else { + console.warn( + "[SightsList] Element or container not found:", + `sight-${localSelectedSightId}`, + { element: !!element, container: !!containerRef.current }, + ); + } + } + }; + + useEffect(() => { + if (!localSelectedSightId) return; + + // Run measurement after render/update + // Используем requestAnimationFrame для точности измерения после рендеринга + const timer = setTimeout(() => { + requestAnimationFrame(() => { + requestAnimationFrame(measureSelectedSight); + }); + }, 0); + + return () => clearTimeout(timer); + }, [localSelectedSightId, sightList, className]); + + const handleSightClick = (sightId) => { + // Если кликнули на уже выбранную достопримечательность — закрываем виджет и сбрасываем цвет + if (isLeftWidgetOpen && localSelectedSightId === sightId) { + setLocalSelectedSightId(null); + setIsLeftWidgetOpen(false); + return; + } + // Открываем левый виджет + setLocalSelectedSightId(sightId); + setIsLeftWidgetOpen(true); + if (onSightSelected) { + onSightSelected(sightId); + } + }; + + const handleClose = () => { + setIsLeftWidgetOpen(false); + setWeatherVisible(true); + onCloseSights(); + }; + + return ( +
+
{ + handleClose(); + }} + > +

+ {" "} + {selectedLanguage === "ru" + ? "Достопримечательности" + : selectedLanguage === "zh" + ? "景点" + : "Attractions"} +

+ + + + + + + + + + + +
+ + {isSightListLoading ? ( +
+ {selectedLanguage === "ru" + ? "Загрузка..." + : selectedLanguage === "zh" + ? "正在加载..." + : "Loading..."} +
+ ) : error ? ( +
{error}
+ ) : sightList.length === 0 ? ( +
+ {selectedLanguage === "ru" + ? "Ничего не найдено" + : selectedLanguage === "zh" + ? "没有找到" + : "Nothing found"} +
+ ) : ( + sightList.map((sight) => ( + + )) + )} +
+
+ ); + }, + ), +); + +export default SightsList; diff --git a/src/client/src/components/side-menu/StationSightsList.jsx b/src/client/src/components/side-menu/StationSightsList.jsx new file mode 100644 index 0000000..0298231 --- /dev/null +++ b/src/client/src/components/side-menu/StationSightsList.jsx @@ -0,0 +1,312 @@ +import { + useEffect, + useState, + forwardRef, + useRef, + useLayoutEffect, +} from "react"; +import { observer } from "mobx-react-lite"; +import { useGeolocationStore } from "../../stores"; +import { apiStore } from "../../api/ApiStore/store"; +import { useClickDetection } from "../../hooks/useClickDetection"; +import { OverlayScrollbarsWrapper } from "../OverlayScrollbarsWrapper"; +import "../../styles/OverlayScrollbars.css"; +import { getStationSights } from "../../api/ApiStore/api"; + +const SightItem = ({ + sight, + handlePointerDown, + handlePointerUp, + handleSightClick, + localSelectedSightId, + selectedLanguage, + sightArticles, + sightArticlesEn, + sightArticlesZh, +}) => { + const containerRef = useRef(null); + const textRef = useRef(null); + const [shouldAnimate, setShouldAnimate] = useState(false); + + // Получаем название из left_article или используем short_name/name + const getSightName = () => { + if (!sight.left_article) { + return sight.short_name || sight.name; + } + + const leftArticleData = + selectedLanguage === "ru" + ? sightArticles.get(sight.left_article + "_ru") + : selectedLanguage === "en" + ? sightArticlesEn.get(sight.left_article + "_en") + : sightArticlesZh.get(sight.left_article + "_zh"); + + return leftArticleData?.heading || sight.short_name || sight.name; + }; + + const sightName = getSightName(); + + useLayoutEffect(() => { + const checkWidth = () => { + if (containerRef.current && textRef.current) { + const containerWidth = containerRef.current.offsetWidth; + const textWidth = textRef.current.scrollWidth; + const threshold = containerWidth; + setShouldAnimate(textWidth > threshold); + } + }; + + checkWidth(); + window.addEventListener("resize", checkWidth); + return () => window.removeEventListener("resize", checkWidth); + }, [sightName]); + + return ( +
handlePointerDown(e, sight.id)} + onPointerUp={(e) => handlePointerUp(e, sight.id, handleSightClick)} + className={`side-menu-sight pointer ${ + localSelectedSightId === sight.id ? "selected" : "" + }`} + > + + {sightName} + +
+ ); +}; + +const StationSightsList = observer( + forwardRef( + ( + { + style, + className, + onClose, + stationId, + selectedLanguage, + setLocalSelectedSightId, + setIsLeftWidgetOpen, + isLeftWidgetOpen, + localSelectedSightId, + onSightTopChange, + onSightSelected, + }, + ref + ) => { + const { sightArticles, sightArticlesEn, sightArticlesZh } = apiStore; + const [sightList, setSightList] = useState([]); + const [error, setError] = useState(null); + const [isSightListLoading, setIsSightListLoading] = useState(true); + + const { handlePointerDown, handlePointerUp, handleScroll } = + useClickDetection(); + + useEffect(() => { + async function fetchSightList() { + if (!stationId) { + setIsSightListLoading(false); + return; + } + + setIsSightListLoading(true); + setError(null); + try { + const response = await getStationSights( + stationId, + selectedLanguage + ); + + const getSightNameForSort = (sight) => { + if (!sight.left_article) { + return (sight.short_name || sight.name).trim(); + } + + const leftArticleData = + selectedLanguage === "ru" + ? sightArticles.get(sight.left_article + "_ru") + : selectedLanguage === "en" + ? sightArticlesEn.get(sight.left_article + "_en") + : sightArticlesZh.get(sight.left_article + "_zh"); + + return (leftArticleData?.heading || sight.short_name || sight.name).trim(); + }; + + const sortedSights = [...(response || [])].sort((a, b) => { + const nameA = getSightNameForSort(a); + const nameB = getSightNameForSort(b); + + return nameA.localeCompare( + nameB, + selectedLanguage === "ru" + ? "ru" + : selectedLanguage === "zh" + ? "zh" + : "en", + { + sensitivity: "base", + } + ); + }); + setSightList(sortedSights); + } catch (err) { + setError( + "Не удалось получить достопримечательности. Пожалуйста, попробуйте позже." + ); + setSightList([]); + } finally { + setIsSightListLoading(false); + } + } + + fetchSightList(); + }, [ + stationId, + selectedLanguage, + sightArticles, + sightArticlesEn, + sightArticlesZh, + ]); + + const measureSelectedSight = () => { + if (localSelectedSightId && onSightTopChange) { + const element = document.getElementById( + `sight-${localSelectedSightId}` + ); + if (element) { + const rect = element.getBoundingClientRect(); + onSightTopChange(rect.top); + } + } + }; + + useEffect(() => { + const timer = setTimeout(measureSelectedSight, 0); + return () => clearTimeout(timer); + }, [localSelectedSightId, sightList]); + + const handleSightClick = (sightId) => { + // Открываем левый виджет + if (isLeftWidgetOpen && localSelectedSightId !== sightId) { + setTimeout(() => { + setLocalSelectedSightId(sightId); + setIsLeftWidgetOpen(true); + if (onSightSelected) { + onSightSelected(sightId); + } + }, 0); + } else if (!isLeftWidgetOpen) { + setLocalSelectedSightId(sightId); + setIsLeftWidgetOpen(true); + if (onSightSelected) { + onSightSelected(sightId); + } + } else { + if (onSightSelected) { + onSightSelected(sightId); + } + } + }; + + return ( +
+
{ + if (onClose) { + onClose(); + } + }} + > +

+ {selectedLanguage === "ru" + ? "Достопримечательности" + : selectedLanguage === "zh" + ? "景点" + : "Attractions"} +

+ + + + + + + + + + +
+ { + handleScroll(e); + measureSelectedSight(); + }} + > + {isSightListLoading ? ( +
+ {selectedLanguage === "ru" + ? "Загрузка..." + : selectedLanguage === "zh" + ? "正在加载..." + : "Loading..."} +
+ ) : error ? ( +
{error}
+ ) : sightList.length === 0 ? ( +
+ {selectedLanguage === "ru" + ? "Ничего не найдено" + : selectedLanguage === "zh" + ? "没有找到" + : "Nothing found"} +
+ ) : ( + sightList.map((sight) => ( + + )) + )} +
+
+ ); + } + ) +); + +export default StationSightsList; +nSightsList; diff --git a/src/client/src/components/side-menu/StationsList.jsx b/src/client/src/components/side-menu/StationsList.jsx new file mode 100644 index 0000000..9d9cf4b --- /dev/null +++ b/src/client/src/components/side-menu/StationsList.jsx @@ -0,0 +1,384 @@ +import { + useEffect, + useState, + forwardRef, + useRef, + useLayoutEffect, +} from "react"; +import { observer } from "mobx-react-lite"; +import { useGeolocationStore } from "../../stores"; +import { apiStore } from "../../api/ApiStore/store"; +import { useClickDetection } from "../../hooks/useClickDetection"; +import { TouchableLayout } from "../TouchableLayout"; +import Collapsible from "./Collapsible"; + +const SightTransferItem = ({ name, style, onPointerUp }) => { + const containerRef = useRef(null); + const textRef = useRef(null); + const [shouldAnimate, setShouldAnimate] = useState(false); + + useLayoutEffect(() => { + const checkWidth = () => { + if (containerRef.current && textRef.current) { + const containerWidth = containerRef.current.offsetWidth; + const textWidth = textRef.current.scrollWidth; + const shouldAnimateValue = textWidth > containerWidth; + setShouldAnimate(shouldAnimateValue); + if (shouldAnimateValue) { + containerRef.current.style.setProperty("--container-width", `${containerWidth}px`); + } + } + }; + checkWidth(); + window.addEventListener("resize", checkWidth); + return () => window.removeEventListener("resize", checkWidth); + }, [name]); + + return ( +
+ + {name} + +
+ ); +}; + +const StationItem = ({ + station, + handlePointerDown, + handlePointerUp, + handleStationClick, + selectedStationId, + selectedLanguage, + stationSightsCache, + sightArticles, + sightArticlesEn, + sightArticlesZh, + onSightClick, +}) => { + const containerRef = useRef(null); + const textRef = useRef(null); + const [shouldAnimate, setShouldAnimate] = useState(false); + + useLayoutEffect(() => { + const checkWidth = () => { + if (containerRef.current && textRef.current) { + const containerWidth = containerRef.current.offsetWidth; + const textWidth = textRef.current.scrollWidth; + const threshold = containerWidth * 1.1; // 110% от ширины контейнера + const shouldAnimateValue = textWidth > threshold; + setShouldAnimate(shouldAnimateValue); + + // Устанавливаем CSS переменную для анимации + if (shouldAnimateValue && containerRef.current) { + containerRef.current.style.setProperty( + "--container-width", + `${containerWidth}px`, + ); + } + } + }; + + checkWidth(); + window.addEventListener("resize", checkWidth); + return () => window.removeEventListener("resize", checkWidth); + }, [station.name]); + + const cacheKey = `${station.id}_${selectedLanguage}`; + const sights = stationSightsCache.get(cacheKey) || []; + + const getSightName = (sight) => { + return sight.short_name || sight.name; + }; + + return ( +
+
handlePointerDown(e, station.id)} + onPointerUp={(e) => { + // Если у станции нет достопримечательностей, не открываем список + if (sights.length > 0) { + handlePointerUp(e, station.id, () => + handleStationClick(station.id), + ); + } + }} + > + + {station.name} + +
+ + {sights.length > 0 ? ( + sights.map((sight, index) => ( + { + e.stopPropagation(); + if (onSightClick) { + const elementRect = e.currentTarget.getBoundingClientRect(); + onSightClick(sight.id, elementRect.top); + } + }} + /> + )) + ) : ( +
+ {selectedLanguage === "ru" + ? "Нет достопримечательностей" + : selectedLanguage === "zh" + ? "没有景点" + : "No sights"} +
+ )} +
+
+ ); +}; + +const StationsList = observer( + forwardRef( + ( + { + style, + className, + onCloseStation, + isOpen, + onStationSelected, + onSightClick, + setWeatherVisible, + }, + ref, + ) => { + const containerRef = useRef(null); + const store = useGeolocationStore(); + const { + contextData, + isLoading: isGeolocationLoading, + selectedLanguage, + isLeftWidgetOpen, + setIsLeftWidgetOpen, + } = store; + const [stationList, setStationList] = useState([]); + const [error, setError] = useState(null); + const [isStationListLoading, setIsStationListLoading] = useState(true); + const [selectedStationId, setSelectedStationId] = useState(null); + + useLayoutEffect(() => { + if (containerRef.current) { + if (className === "slide-in") { + // Применяем стили для slide-in + requestAnimationFrame(() => { + if (containerRef.current) { + containerRef.current.style.transform = "translateY(0)"; + containerRef.current.style.opacity = "1"; + } + }); + } else if (className === "slide-out") { + // Очищаем inline стили, чтобы CSS transition работал + containerRef.current.style.transform = ""; + // Принудительно применяем reflow для запуска transition + void containerRef.current.offsetHeight; + } + } + }, [className]); + const { + routeStations, + routeStationsEn, + routeStationsZh, + stationSightsCache, + sightArticles, + sightArticlesEn, + sightArticlesZh, + } = apiStore; + + const { handlePointerDown, handlePointerUp, handleScroll } = + useClickDetection(); + + useEffect(() => { + async function fetchStationList() { + setIsStationListLoading(true); + setError(null); + try { + const response = + selectedLanguage === "ru" + ? routeStations + : selectedLanguage === "en" + ? routeStationsEn + : routeStationsZh; + + // Сортировка по умолчанию по возрастанию (не зависит от sortingBy) + const sortedStations = [...(response || [])].sort((a, b) => { + const nameA = a.name.trim(); + const nameB = b.name.trim(); + + return nameA.localeCompare( + nameB, + selectedLanguage === "ru" + ? "ru" + : selectedLanguage === "zh" + ? "zh" + : "en", + { + sensitivity: "base", + }, + ); + }); + + setStationList(sortedStations); + } catch (err) { + setError( + "Не удалось получить остановки. Пожалуйста, попробуйте позже.", + ); + setStationList([]); + } finally { + setIsStationListLoading(false); + } + } + + fetchStationList(); + }, [ + contextData?.routeId, + isGeolocationLoading, + selectedLanguage, + routeStations, + routeStationsEn, + routeStationsZh, + ]); + + const handleStationClick = (stationId) => { + const newSelectedId = + selectedStationId === stationId ? null : stationId; + setSelectedStationId(newSelectedId); + + // Если остановка выбрана, вызываем callback для открытия списка достопримечательностей + if (newSelectedId !== null && onStationSelected) { + onStationSelected(newSelectedId); + } + }; + + const handleClose = () => { + setIsLeftWidgetOpen(false); + setWeatherVisible(true); + onCloseStation(); + }; + + return ( +
+
{ + handleClose(); + }} + > +

+ {selectedLanguage === "ru" + ? "Остановки" + : selectedLanguage === "zh" + ? "车站" + : "Stations"} +

+ + + + + + + + + + +
+ + {isStationListLoading ? ( +
+ {selectedLanguage === "ru" + ? "Загрузка остановок..." + : selectedLanguage === "zh" + ? "加载停止..." + : "Loading stops..."} +
+ ) : error ? ( +
{error}
+ ) : stationList.length === 0 ? ( +
+ {selectedLanguage === "ru" + ? "Остановки не найдены" + : selectedLanguage === "zh" + ? "没有找到停靠站" + : "No stops found"} +
+ ) : ( + stationList.map((station) => ( + + )) + )} +
+
+ ); + }, + ), +); + +export default StationsList; diff --git a/src/client/src/components/widgets/AppealWidget.jsx b/src/client/src/components/widgets/AppealWidget.jsx new file mode 100644 index 0000000..06c91cb --- /dev/null +++ b/src/client/src/components/widgets/AppealWidget.jsx @@ -0,0 +1,50 @@ +import { useRef, useEffect } from "react"; +import "../../styles/AppealWidget.css"; +import { TouchableLayout } from "../TouchableLayout"; +import { ReactMarkdownComponent } from "../ReactMarkdown"; + +function AppealWidget({ + widgetImgPath, + widgetLabel, + widgetText, + style, + isOpen, +}) { + const stopProp = (e) => { + e.stopPropagation(); + e.preventDefault(); + }; + const layoutRef = useRef(null); + + useEffect(() => { + if (isOpen && layoutRef.current) { + const scrollable = layoutRef.current.querySelector(".scrollable"); + if (scrollable) scrollable.scrollTop = 0; + } + }, [isOpen]); + + return ( +
+ {widgetImgPath && ( + + )} +
{widgetLabel}
+ +
+ +
+
+
+ ); +} + +export default AppealWidget; diff --git a/src/client/src/components/widgets/PanoramView.tsx b/src/client/src/components/widgets/PanoramView.tsx new file mode 100644 index 0000000..5116112 --- /dev/null +++ b/src/client/src/components/widgets/PanoramView.tsx @@ -0,0 +1,147 @@ +import React from "react"; +import { Viewer, ViewerConfig, AnimateOptions, CssSize, ExtendedPosition, UpdatableViewerConfig, events, PluginConstructor, NavbarCustomButton, TooltipConfig, Tooltip, Position, Size, PanoramaOptions, utils, AbstractPlugin } from "@photo-sphere-viewer/core"; +import "./styles.css"; +import "@photo-sphere-viewer/core/index.css"; + +type MakeOptional = Omit & Partial>; +export interface CubeMapSrc { + left: string; + front: string; + right: string; + back: string; + top: string; + bottom: string; +} +export interface TilesAdapterSrc { + width: number; + cols: number; + rows: number; + baseUrl: string; + tileUrl: (col: number, row: number) => string; +} +type PluginEntry = PluginConstructor | [PluginConstructor, Record]; +export type PluginConfig = PluginEntry; +/** + * Props interface for the Viewer component. + * + * @interface + * @property {string} src - The source of the image to be viewed. + * @property {boolean | string | Array} [navbar] - Configuration for the navbar. Can be a boolean, string, or an array of strings or NavbarCustomButton. + * @property {string} height - The height of the viewer. + * @property {string} [width] - The width of the viewer. + * @property {string} [containerClass] - The CSS class for the viewer container. + * @property {boolean} [littlePlanet] - Enable or disable the little planet effect. + * @property {boolean | number} [fishEye] - Enable or disable the fisheye effect, or set the fisheye level. + * @property {boolean} [hideNavbarButton] - Show/hide the button that hides the navbar. + * @property {Object} [lang] - Language configuration for the viewer. Each property is a string that represents the text for a specific action. + * @property {Function} [onPositionChange] - Event handler for when the position changes. Receives the latitude, longitude, and the Viewer instance. + * @property {Function} [onZoomChange] - Event handler for when the zoom level changes. Receives the ZoomUpdatedEvent and the Viewer instance. + * @property {Function} [onClick] - Event handler for when the viewer is clicked. Receives the ClickEvent and the Viewer instance. + * @property {Function} [onDblclick] - Event handler for when the viewer is double clicked. Receives the ClickEvent and the Viewer instance. + * @property {Function} [onReady] - Event handler for when the viewer is ready. Receives the Viewer instance. + */ +export interface Props extends MakeOptional { + src: string | CubeMapSrc | TilesAdapterSrc; + navbar?: boolean | string | Array; + height: string; + width?: string; + containerClass?: string; + littlePlanet?: boolean; + fishEye?: boolean | number; + hideNavbarButton?: boolean; + lang?: Record; + plugins?: PluginEntry[]; + onPositionChange?(lat: number, lng: number, instance: Viewer): void; + onZoomChange?(data: events.ZoomUpdatedEvent & { + type: "zoom-updated"; + }, instance: Viewer): void; + onClick?(data: events.ClickEvent & { + type: "click"; + }, instance: Viewer): void; + onDblclick?(data: events.ClickEvent & { + type: "dblclick"; + }, instance: Viewer): void; + onReady?(instance: Viewer): void; +} +/** + * Interface for the Viewer API. + * + * @interface + * @property {Function} animate - Starts an animation. Receives an object of AnimateOptions. + * @property {Function} destroy - Destroys the viewer. + * @property {Function} createTooltip - Creates a tooltip. Receives a TooltipConfig object. + * @property {Function} needsContinuousUpdate - Enables or disables continuous updates. Receives a boolean. + * @property {Function} observeObjects - Starts observing objects. Receives a string key. + * @property {Function} unobserveObjects - Stops observing objects. Receives a string key. + * @property {Function} setCursor - Sets the cursor. Receives a string. + * @property {Function} stopAnimation - Stops the current animation. Returns a Promise. + * @property {Function} rotate - Rotates the viewer. Receives an ExtendedPosition object. + * @property {Function} setOption - Sets a single option. Receives an option key and a value. + * @property {Function} setOptions - Sets multiple options. Receives an object of options. + * @property {Function} getCurrentNavbar - Returns the current navbar. + * @property {Function} zoom - Sets the zoom level. Receives a number. + * @property {Function} zoomIn - Increases the zoom level. Receives a number. + * @property {Function} zoomOut - Decreases the zoom level. Receives a number. + * @property {Function} resize - Resizes the viewer. Receives a CssSize object. + * @property {Function} enterFullscreen - Enters fullscreen mode. + * @property {Function} exitFullscreen - Exits fullscreen mode. + * @property {Function} toggleFullscreen - Toggles fullscreen mode. + * @property {Function} isFullscreenEnabled - Returns whether fullscreen is enabled. + * @property {Function} getPlugin - Returns a plugin. Receives a plugin ID or a PluginConstructor. + * @property {Function} getPosition - Returns the current position. + * @property {Function} getZoomLevel - Returns the current zoom level. + * @property {Function} getSize - Returns the current size. + * @property {Function} needsUpdate - Updates the viewer. + * @property {Function} autoSize - Sets the size to auto. + * @property {Function} setPanorama - Sets the panorama. Receives a path and an optional PanoramaOptions object. Returns a Promise. + * @property {Function} showError - Shows an error message. Receives a string. + * @property {Function} hideError - Hides the error message. + * @property {Function} startKeyboardControl - Starts keyboard control. + * @property {Function} stopKeyboardControl - Stops keyboard control. + */ +export interface ViewerAPI { + animate(options: AnimateOptions): utils.Animation; + destroy(): void; + createTooltip(config: TooltipConfig): Tooltip; + needsContinuousUpdate(enabled: boolean): void; + observeObjects(userDataKey: string): void; + unobserveObjects(userDataKey: string): void; + setCursor(cursor: string): void; + stopAnimation(): PromiseLike; + rotate(position: ExtendedPosition): void; + setOption(option: T, value: UpdatableViewerConfig[T]): void; + setOptions(options: Partial): void; + getCurrentNavbar(): (string | object)[] | void; + zoom(value: number): void; + zoomIn(step: number): void; + zoomOut(step: number): void; + resize(size: CssSize): void; + enterFullscreen(): void; + exitFullscreen(): void; + toggleFullscreen(): void; + isFullscreenEnabled(): boolean | void; + /** + * Returns the instance of a plugin if it exists + * @example By plugin identifier + * ```js + * viewer.getPlugin('markers') + * ``` + * @example By plugin class with TypeScript support + * ```ts + * viewer.getPlugin(MarkersPlugin) + * ``` + */ + getPlugin>(pluginId: string | PluginConstructor): T; + getPosition(): Position; + getZoomLevel(): number; + getSize(): Size; + needsUpdate(): void; + autoSize(): void; + setPanorama(path: any, options?: PanoramaOptions): Promise; + showError(message: string): void; + hideError(): void; + startKeyboardControl(): void; + stopKeyboardControl(): void; +} +declare const PanoramView: React.ForwardRefExoticComponent & React.RefAttributes>; +export { PanoramView }; diff --git a/src/client/src/components/widgets/RouteWidget.jsx b/src/client/src/components/widgets/RouteWidget.jsx new file mode 100644 index 0000000..1b8a872 --- /dev/null +++ b/src/client/src/components/widgets/RouteWidget.jsx @@ -0,0 +1,128 @@ +import React, { useEffect, useState } from "react"; +import { observer } from "mobx-react-lite"; +import "../../styles/RouteWidget.css"; +import { useGeolocationStore } from "../../stores"; +import ContentAPI from "../../api/content/content.api"; +import { apiStore } from "../../api/ApiStore/store"; + +const RouteWidget = observer(() => { + const store = useGeolocationStore(); + const { contextData, isLoading, error, selectedLanguage } = store; + const { routeStations, routeStationsEn, routeStationsZh, context } = apiStore; + const [startStation, setStartStation] = useState(null); + const [startStationEn, setStartStationEn] = useState(null); + const [endStation, setEndStation] = useState(null); + const [endStationEn, setEndStationEn] = useState(null); + const [startStationZh, setStartStationZh] = useState(null); + const [endStationZh, setEndStationZh] = useState(null); + + useEffect(() => { + async function fetchStartStation() { + try { + const data = routeStations.find( + (station) => station.id == context?.startStopId, + ); + + const dataEn = routeStationsEn.find( + (station) => station.id == context?.startStopId, + ); + const dataZh = routeStationsZh.find( + (station) => station.id == context?.startStopId, + ); + setStartStation(data); + setStartStationEn(dataEn); + setStartStationZh(dataZh); + } catch (err) { + console.error("Ошибка при загрузке начальной станции", err); + } + } + + fetchStartStation(); + }, [context?.startStopId, isLoading, selectedLanguage]); + + useEffect(() => { + async function fetchEndStation() { + try { + const data = routeStations.find( + (station) => station.id == context?.endStopId, + ); + const dataEn = routeStationsEn.find( + (station) => station.id == context?.endStopId, + ); + const dataZh = routeStationsZh.find( + (station) => station.id == context?.endStopId, + ); + setEndStation(data); + setEndStationEn(dataEn); + setEndStationZh(dataZh); + } catch (err) { + console.error("Ошибка при загрузке конечной станции", err); + } + } + + fetchEndStation(); + }, [context?.endStopId, isLoading, selectedLanguage]); + + const shouldAnimate = (text, maxLength) => text?.length > maxLength; + const getNumberSizeClass = (text) => { + const length = text?.length || 0; + if (length <= 2) return ""; + if (length === 3) return "route-widget-number--3"; + if (length === 4) return "route-widget-number--4"; + return "route-widget-number--5"; + }; + const getLabelSizeClass = (text) => { + const length = text?.length || 0; + + if (length <= 40) return ""; + if (length <= 60) return "route-widget-label--medium"; + if (length <= 80) return "route-widget-label--small"; + + return "route-widget-label--xsmall"; + }; + const routeEnSubtitle = `${startStationEn?.name} - ${endStationEn?.name}`; + const routeZhSubtitle = `${startStationZh?.name} - ${endStationZh?.name}`; + return ( +
+
+ {context?.routeNumber || "No number"} +
+
+
+ {startStation?.name} +
+
+ {endStation?.name} +
+ {(selectedLanguage === "en" || selectedLanguage === "ru") && ( +
+ {routeEnSubtitle} +
+ )} + {selectedLanguage === "zh" && ( +
+ {routeZhSubtitle} +
+ )} +
+
+ ); +}); + +export default RouteWidget; diff --git a/src/client/src/components/widgets/ThreeView.tsx b/src/client/src/components/widgets/ThreeView.tsx new file mode 100644 index 0000000..2a4c634 --- /dev/null +++ b/src/client/src/components/widgets/ThreeView.tsx @@ -0,0 +1,345 @@ +import { Canvas, useThree, useFrame } from "@react-three/fiber"; +import { OrbitControls, Center, useGLTF } from "@react-three/drei"; +import React, { useEffect, useRef, Suspense, useCallback } from "react"; +import { BACKGROUND_COLOR } from "../../assets/Constants"; +import * as THREE from "three"; +import type { OrbitControls as OrbitControlsImpl } from "three-stdlib"; + +export interface ThreeViewHandle { + zoomIn: () => void; + zoomOut: () => void; +} + +interface ThreeViewProps { + fileUrl: string; + width?: string; + height?: string; + onLoad?: () => void; + onError?: (error: string) => void; + onAspectRatioCalculated?: (ratio: number) => void; + controlRef?: React.MutableRefObject; +} + +const ZOOM_FACTOR = 1.2; +const MIN_DISTANCE = 1; +const MAX_DISTANCE = 100; +const CAMERA_FOV = 40; + +const TouchController = () => { + const { camera, controls, gl } = useThree(); + + useEffect(() => { + if (!controls) return; + + const orbit = controls as unknown as OrbitControlsImpl; + + // Отключаем встроенную обработку 2-пальцевых жестов — делаем вручную + orbit.touches.TWO = undefined as unknown as number; + + const domElement = gl.domElement; + let panStart: { x: number; y: number } | null = null; + let pinchStartDist: number | null = null; + let pinchStartCamDist: number | null = null; + + const getPinchDist = (touches: TouchList) => { + const dx = touches[0].clientX - touches[1].clientX; + const dy = touches[0].clientY - touches[1].clientY; + return Math.sqrt(dx * dx + dy * dy); + }; + + const getCenter3 = (touches: TouchList) => ({ + x: (touches[0].clientX + touches[1].clientX + touches[2].clientX) / 3, + y: (touches[0].clientY + touches[1].clientY + touches[2].clientY) / 3, + }); + + const handleTouchStart = (e: TouchEvent) => { + if (e.touches.length === 2) { + e.preventDefault(); + orbit.enabled = false; + pinchStartDist = getPinchDist(e.touches); + pinchStartCamDist = camera.position.distanceTo(orbit.target); + } else if (e.touches.length === 3) { + e.preventDefault(); + orbit.enabled = false; + panStart = getCenter3(e.touches); + } + }; + + const handleTouchMove = (e: TouchEvent) => { + if (e.touches.length === 2 && pinchStartDist !== null && pinchStartCamDist !== null) { + e.preventDefault(); + const currentDist = getPinchDist(e.touches); + const scale = pinchStartDist / currentDist; + const newDist = Math.min(MAX_DISTANCE, Math.max(MIN_DISTANCE, pinchStartCamDist * scale)); + const dir = new THREE.Vector3() + .subVectors(camera.position, orbit.target) + .normalize(); + camera.position.copy(orbit.target).addScaledVector(dir, newDist); + camera.updateProjectionMatrix(); + orbit.update(); + } else if (e.touches.length === 3 && panStart) { + e.preventDefault(); + const center = getCenter3(e.touches); + const deltaX = center.x - panStart.x; + const deltaY = center.y - panStart.y; + panStart = center; + + const perspCamera = camera as THREE.PerspectiveCamera; + const targetDistance = camera.position.distanceTo(orbit.target); + const height = + 2 * + Math.tan(THREE.MathUtils.degToRad(perspCamera.fov / 2)) * + targetDistance; + const worldPerPixel = height / domElement.clientHeight; + + const panOffset = new THREE.Vector3( + -deltaX * worldPerPixel, + deltaY * worldPerPixel, + 0 + ); + panOffset.applyQuaternion(camera.quaternion); + + camera.position.add(panOffset); + orbit.target.add(panOffset); + orbit.update(); + } + }; + + const handleTouchEnd = (e: TouchEvent) => { + if (e.touches.length < 2) { + pinchStartDist = null; + pinchStartCamDist = null; + } + if (e.touches.length < 3) { + panStart = null; + } + if (e.touches.length <= 1) { + orbit.enabled = true; + } + }; + + domElement.addEventListener("touchstart", handleTouchStart, { + passive: false, + }); + domElement.addEventListener("touchmove", handleTouchMove, { + passive: false, + }); + domElement.addEventListener("touchend", handleTouchEnd); + + return () => { + domElement.removeEventListener("touchstart", handleTouchStart); + domElement.removeEventListener("touchmove", handleTouchMove); + domElement.removeEventListener("touchend", handleTouchEnd); + }; + }, [camera, controls, gl]); + + return null; +}; + +const ZoomController = ({ + controlRef, +}: { + controlRef?: React.MutableRefObject; +}) => { + const { camera, controls } = useThree(); + + useEffect(() => { + if (!controlRef || !controls) return; + const orbit = controls as unknown as { + target: THREE.Vector3; + getDistance: () => number; + setDistance?: (d: number) => void; + }; + const zoomIn = () => { + const target = orbit.target; + const dir = new THREE.Vector3() + .subVectors(camera.position, target) + .normalize(); + const dist = camera.position.distanceTo(target); + const newDist = Math.max(MIN_DISTANCE, dist / ZOOM_FACTOR); + camera.position.copy(target).addScaledVector(dir, newDist); + camera.updateProjectionMatrix(); + }; + const zoomOut = () => { + const target = orbit.target; + const dir = new THREE.Vector3() + .subVectors(camera.position, target) + .normalize(); + const dist = camera.position.distanceTo(target); + const newDist = Math.min(MAX_DISTANCE, dist * ZOOM_FACTOR); + camera.position.copy(target).addScaledVector(dir, newDist); + camera.updateProjectionMatrix(); + }; + controlRef.current = { zoomIn, zoomOut }; + return () => { + controlRef.current = null; + }; + }, [camera, controls, controlRef]); + + return null; +}; + +const AutoResize = () => { + const { gl, camera } = useThree(); + useEffect(() => { + const handleResize = () => { + const parent = gl.domElement.parentElement; + if (!parent) return; + gl.setSize(parent.clientWidth, parent.clientHeight); + if (camera instanceof THREE.PerspectiveCamera) { + camera.aspect = parent.clientWidth / parent.clientHeight; + camera.updateProjectionMatrix(); + } + }; + handleResize(); + window.addEventListener("resize", handleResize); + return () => window.removeEventListener("resize", handleResize); + }, [gl, camera]); + return null; +}; + +const FitCamera = ({ + groupRef, + onReady, +}: { + groupRef: React.RefObject; + onReady: () => void; +}) => { + const { camera, controls } = useThree(); + const fitted = useRef(false); + + useFrame(() => { + if (fitted.current) return; + const group = groupRef.current; + if (!group || group.children.length === 0) return; + + const box = new THREE.Box3().setFromObject(group); + const sphere = new THREE.Sphere(); + box.getBoundingSphere(sphere); + + if (sphere.radius === 0) return; + + const fov = THREE.MathUtils.degToRad(CAMERA_FOV); + const dist = sphere.radius / Math.sin(fov / 2); + + camera.position.set(0, 0, dist); + camera.lookAt(0, 0, 0); + camera.updateProjectionMatrix(); + + if (controls) { + const orbit = controls as unknown as OrbitControlsImpl; + orbit.target.set(0, 0, 0); + orbit.update(); + } + + fitted.current = true; + onReady(); + }); + + return null; +}; + +const Model = ({ + fileUrl, + onLoad, +}: { + fileUrl: string; + onLoad?: () => void; +}) => { + const { scene } = useGLTF(fileUrl); + + useEffect(() => { + if (scene) { + scene.traverse((child) => { + if ((child as THREE.Mesh).isMesh) { + const m = (child as THREE.Mesh) + .material as THREE.MeshStandardMaterial; + if (m) { + m.envMapIntensity = 1.5; + m.needsUpdate = true; + } + } + }); + onLoad?.(); + } + }, [scene, onLoad]); + + return ; +}; + +export const ThreeView: React.FC = ({ + fileUrl, + width = "100%", + height = "100%", + onLoad, + onError, + controlRef, +}) => { + const [isReady, setIsReady] = React.useState(false); + const groupRef = useRef(null!); + + const handleReady = useCallback(() => { + setIsReady(true); + onLoad?.(); + }, [onLoad]); + + return ( +
+ {!isReady && ( +
+ )} + onError?.(e.message)} + > + + + {controlRef && } + + + + + + + + + + + + + + + + + + +
+ + + +
+
+ + +
+
+ ); +}; diff --git a/src/client/src/components/widgets/ThreeViewIcons.tsx b/src/client/src/components/widgets/ThreeViewIcons.tsx new file mode 100644 index 0000000..d81ab04 --- /dev/null +++ b/src/client/src/components/widgets/ThreeViewIcons.tsx @@ -0,0 +1,87 @@ +import React from "react"; + +export const MinusIcon: React.FC> = (props) => ( + + + + + + + + + + + +); + +export const PlusIcon: React.FC> = (props) => ( + + + + + + + + + + + +); + +export const SizeIcon: React.FC> = (props) => ( + + + +); + +export const SizeOpenIcon: React.FC> = (props) => ( + + + +); diff --git a/src/client/src/components/widgets/VideoPreviewWidget.jsx b/src/client/src/components/widgets/VideoPreviewWidget.jsx new file mode 100644 index 0000000..474a980 --- /dev/null +++ b/src/client/src/components/widgets/VideoPreviewWidget.jsx @@ -0,0 +1,188 @@ +import { useEffect, useState, useMemo, useCallback, useRef } from "react"; +import { createPortal } from "react-dom"; +import { observer } from "mobx-react-lite"; +import "../../styles/VideoPreview.css"; +import { apiStore } from "../../api/ApiStore/store"; +import { apiBaseURL } from "../../api/apiConfig"; +import { useGeolocationStore } from "../../stores/hooks/useGeolocationStore"; + +const defaultVideoPath = new URL( + "../../assets/video/taganai.mp4", + import.meta.url, +).href; + +const VideoPreviewWidget = observer(() => { + const { context, isLoading, routeSights, route } = apiStore; + const { selectedSightId } = useGeolocationStore(); + const [showVideo, setShowVideo] = useState(false); + const [isExiting, setIsExiting] = useState(false); + const [videoError, setVideoError] = useState(false); + const inactivityTimer = useRef(null); + const videoRef = useRef(null); + + const videoTimer = route?.video_timer + ? route.video_timer * 1000 + : 60000 * 60 * 7; + + const apiBaseUrl = apiBaseURL; + const mediaPath = "/media"; + + // Используем selectedSightId если есть, иначе nearestSightId + const nearestSightId = context?.nearestSightId; + const activeSightId = selectedSightId || nearestSightId; + + const videoToShow = useMemo(() => { + if (!activeSightId) return null; + return routeSights?.find((sight) => sight.id === Number(activeSightId)) + ?.video_preview; + }, [routeSights, activeSightId]); + + const videoSrc = useMemo(() => { + // Если есть видео из API и не было ошибки - используем его + if (videoToShow && !videoError) { + return `${apiBaseUrl}${mediaPath}/${videoToShow}/download`; + } + // Иначе используем видео по умолчанию + return defaultVideoPath; + }, [videoToShow, videoError, apiBaseUrl, mediaPath]); + + const [currentVideoId, setCurrentVideoId] = useState(null); + const [currentSightId, setCurrentSightId] = useState(null); + const [isVideoFinished, setIsVideoFinished] = useState(false); + + const handleHideVideo = useCallback(() => { + setIsExiting(true); + setTimeout(() => { + setShowVideo(false); + setIsExiting(false); + setIsVideoFinished(true); + }, 500); + }, []); + + const handleVideoEnded = useCallback(() => { + setIsVideoFinished(true); + setShowVideo(false); + }, []); + + const resetInactivityTimer = useCallback(() => { + if (inactivityTimer.current) { + clearTimeout(inactivityTimer.current); + } + + inactivityTimer.current = setTimeout(() => { + if (!showVideo && activeSightId) { + // Показываем видео после 10 секунд бездействия + setShowVideo(true); + setVideoError(false); + setIsVideoFinished(false); + } + }, videoTimer); + }, [videoTimer, showVideo, activeSightId]); + + const handleUserActivity = useCallback(() => { + resetInactivityTimer(); + if (showVideo) { + handleHideVideo(); + } + }, [showVideo, resetInactivityTimer, handleHideVideo]); + + // Сброс состояния при изменении достопримечательности или видео + useEffect(() => { + if (!activeSightId) return; + + // Проверяем изменение достопримечательности (selectedSightId или nearestSightId) + if (activeSightId !== currentSightId) { + setCurrentSightId(activeSightId); + setCurrentVideoId(videoToShow || null); + setIsVideoFinished(false); + setVideoError(false); + setIsExiting(false); + // Скрываем видео при смене достопримечательности - оно появится после 10 секунд бездействия + if (showVideo) { + setShowVideo(false); + } + // Перезапускаем таймер при смене достопримечательности + resetInactivityTimer(); + return; + } + + // Проверяем изменение видео для той же достопримечательности + if (videoToShow !== currentVideoId) { + setCurrentVideoId(videoToShow || null); + setIsVideoFinished(false); + setVideoError(false); + setIsExiting(false); + // Видео обновится автоматически через key при следующем показе + } + }, [ + activeSightId, + videoToShow, + currentSightId, + currentVideoId, + showVideo, + resetInactivityTimer, + ]); + + useEffect(() => { + const events = ["mousemove", "keydown", "scroll", "touchstart"]; + + events.forEach((event) => + window.addEventListener(event, handleUserActivity), + ); + resetInactivityTimer(); + + return () => { + events.forEach((event) => + window.removeEventListener(event, handleUserActivity), + ); + if (inactivityTimer.current) { + clearTimeout(inactivityTimer.current); + } + }; + }, [handleUserActivity, resetInactivityTimer]); + + return createPortal( +
+ {showVideo && activeSightId && ( +
+
+ )} +
, + document.body, + ); +}); + +export default VideoPreviewWidget; diff --git a/src/client/src/context/GeolocationContext.tsx b/src/client/src/context/GeolocationContext.tsx new file mode 100644 index 0000000..ec63836 --- /dev/null +++ b/src/client/src/context/GeolocationContext.tsx @@ -0,0 +1,101 @@ +import React, { createContext, useContext, useEffect, useState } from "react"; + +import { observer } from "mobx-react-lite"; +import { apiStore } from "../api/ApiStore/store"; + +// Обновленный тип контекста +type GeolocationContextType = { + contextData: any; // Сохраняем для других данных (например, routeId) + setContextData: React.Dispatch>; + nearestSightId: string | null; // Отдельное состояние для nearestSightId + setNearestSightId: React.Dispatch>; // Сеттер для nearestSightId + isLoading: boolean; + error: string | null; + selectedSightId: string | null; + setSelectedSightId: React.Dispatch>; + selectedLanguage: string; + setSelectedLanguage: React.Dispatch>; + selectedLanguageRight: string; + setSelectedLanguageRight: React.Dispatch>; + nearestStationId: string | null; + setNearestStationId: React.Dispatch>; +}; + +const GeolocationContext = createContext({ + contextData: null, + setContextData: () => {}, + nearestSightId: null, + setNearestSightId: () => {}, + isLoading: false, + error: null, + selectedSightId: null, + setSelectedSightId: () => {}, + selectedLanguage: "ru", + setSelectedLanguage: () => {}, + selectedLanguageRight: "ru", + setSelectedLanguageRight: () => {}, + nearestStationId: null, + setNearestStationId: () => {}, +}); + +export const GeolocationProvider = observer( + ({ children }: { children: React.ReactNode }) => { + const { context } = apiStore; + const [contextData, setContextData] = useState(null); // Общее состояние для контекстных данных + const [nearestSightId, setNearestSightId] = useState(null); // Отдельное состояние для nearestSightId + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const [selectedSightId, setSelectedSightId] = useState(null); + const [selectedLanguage, setSelectedLanguage] = useState("ru"); + const [selectedLanguageRight, setSelectedLanguageRight] = + useState("ru"); + const [nearestStationId, setNearestStationId] = useState( + null + ); + + // Эффект для первоначальной загрузки contextData (выполняется один раз) + useEffect(() => { + const fetchInitialContext = async () => { + setIsLoading(true); + try { + setContextData(context); + setNearestSightId(context?.nearestSightId!); // Инициализируем nearestSightId также + setNearestStationId(context?.routeProgress?.endStopId || null); + setError(null); + } catch (err) { + setError("Не удалось загрузить геоданные при инициализации"); + console.error("Ошибка при инициализации контекста:", err); + } finally { + setIsLoading(false); + } + }; + + fetchInitialContext(); + }, [context]); // Пустой массив зависимостей: эффект запускается только один раз при монтировании + + return ( + + {children} + + ); + } +); + +export const useGeolocation = () => useContext(GeolocationContext); diff --git a/src/client/src/hooks/useAnimatedPosition.ts b/src/client/src/hooks/useAnimatedPosition.ts new file mode 100644 index 0000000..5da3e08 --- /dev/null +++ b/src/client/src/hooks/useAnimatedPosition.ts @@ -0,0 +1,221 @@ +import { useState, useRef, useEffect, useCallback } from "react"; +import { PositionAnimator } from "../utils/animationUtils"; + +interface AnimatedPositionState { + x: number; + y: number; + isAnimating: boolean; +} + +/** + * Хук для управления анимированной позицией элемента + * Основано на логике анимации трамвая + */ +export const useAnimatedPosition = ( + initialX: number = 0, + initialY: number = 0, + animationDuration: number = 1000 +) => { + const [position, setPosition] = useState({ + x: initialX, + y: initialY, + isAnimating: false, + }); + + const animatorRef = useRef(null); + + // Создаем аниматор при первом рендере + useEffect(() => { + if (!animatorRef.current) { + animatorRef.current = new PositionAnimator( + (newPosition) => { + setPosition((prev) => ({ + ...prev, + x: newPosition.x, + y: newPosition.y, + })); + }, + () => { + setPosition((prev) => ({ + ...prev, + isAnimating: false, + })); + }, + animationDuration + ); + } + + return () => { + if (animatorRef.current) { + animatorRef.current.stop(); + } + }; + }, [animationDuration]); + + // Функция для анимации к новой позиции + const animateTo = useCallback( + (targetX: number, targetY: number, duration?: number) => { + if (animatorRef.current) { + setPosition((prev) => ({ ...prev, isAnimating: true })); + animatorRef.current.animateTo(targetX, targetY, duration); + } + }, + [] + ); + + // Функция для мгновенной установки позиции + const setPositionImmediate = useCallback((x: number, y: number) => { + if (animatorRef.current) { + animatorRef.current.stop(); + animatorRef.current.setPosition(x, y); + setPosition({ + x, + y, + isAnimating: false, + }); + } + }, []); + + // Функция для остановки анимации + const stopAnimation = useCallback(() => { + if (animatorRef.current) { + animatorRef.current.stop(); + setPosition((prev) => ({ ...prev, isAnimating: false })); + } + }, []); + + return { + position: { x: position.x, y: position.y }, + isAnimating: position.isAnimating, + animateTo, + setPositionImmediate, + stopAnimation, + }; +}; + +/** + * Хук для управления анимированной позицией с поддержкой полярных координат + */ +export const useAnimatedPolarPosition = ( + centerX: number = 0, + centerY: number = 0, + initialAngle: number = 0, + initialDistance: number = 0, + animationDuration: number = 1000 +) => { + const [position, setPosition] = useState({ + x: centerX + initialDistance * Math.cos(initialAngle), + y: centerY - initialDistance * Math.sin(initialAngle), + isAnimating: false, + }); + + const [polarPosition, setPolarPosition] = useState({ + angle: initialAngle, + distance: initialDistance, + }); + + const animatorRef = useRef(null); + + useEffect(() => { + if (!animatorRef.current) { + animatorRef.current = new PositionAnimator( + (newPosition) => { + setPosition((prev) => ({ + ...prev, + x: newPosition.x, + y: newPosition.y, + })); + }, + () => { + setPosition((prev) => ({ + ...prev, + isAnimating: false, + })); + }, + animationDuration + ); + } + + return () => { + if (animatorRef.current) { + animatorRef.current.stop(); + } + }; + }, [animationDuration]); + + // Функция для анимации к новой позиции по полярным координатам + const animateToPolar = useCallback( + (targetAngle: number, targetDistance: number, duration?: number) => { + if (animatorRef.current) { + const targetX = centerX + targetDistance * Math.cos(targetAngle); + const targetY = centerY - targetDistance * Math.sin(targetAngle); + + setPosition((prev) => ({ ...prev, isAnimating: true })); + setPolarPosition({ angle: targetAngle, distance: targetDistance }); + animatorRef.current.animateTo(targetX, targetY, duration); + } + }, + [centerX, centerY] + ); + + // Функция для анимации к новой позиции по декартовым координатам + const animateTo = useCallback( + (targetX: number, targetY: number, duration?: number) => { + if (animatorRef.current) { + const dx = targetX - centerX; + const dy = centerY - targetY; + const targetDistance = Math.sqrt(dx * dx + dy * dy); + const targetAngle = Math.atan2(dy, dx); + + setPosition((prev) => ({ ...prev, isAnimating: true })); + setPolarPosition({ angle: targetAngle, distance: targetDistance }); + animatorRef.current.animateTo(targetX, targetY, duration); + } + }, + [centerX, centerY] + ); + + // Функция для мгновенной установки позиции + const setPositionImmediate = useCallback( + (x: number, y: number) => { + if (animatorRef.current) { + animatorRef.current.stop(); + animatorRef.current.setPosition(x, y); + + const dx = x - centerX; + const dy = centerY - y; + const distance = Math.sqrt(dx * dx + dy * dy); + const angle = Math.atan2(dy, dx); + + setPosition({ + x, + y, + isAnimating: false, + }); + setPolarPosition({ angle, distance }); + } + }, + [centerX, centerY] + ); + + // Функция для остановки анимации + const stopAnimation = useCallback(() => { + if (animatorRef.current) { + animatorRef.current.stop(); + setPosition((prev) => ({ ...prev, isAnimating: false })); + } + }, []); + + return { + position: { x: position.x, y: position.y }, + polarPosition: { + angle: polarPosition.angle, + distance: polarPosition.distance, + }, + isAnimating: position.isAnimating, + animateTo, + animateToPolar, + setPositionImmediate, + stopAnimation, + }; +}; diff --git a/src/client/src/hooks/useClickDetection.js b/src/client/src/hooks/useClickDetection.js new file mode 100644 index 0000000..27d7ad4 --- /dev/null +++ b/src/client/src/hooks/useClickDetection.js @@ -0,0 +1,51 @@ +import { useRef } from "react"; + +export const useClickDetection = () => { + const pointerDownRef = useRef(null); + const isScrollingRef = useRef(false); + + const handlePointerDown = (e, id) => { + pointerDownRef.current = { + x: e.clientX, + y: e.clientY, + time: Date.now(), + id, + }; + isScrollingRef.current = false; + }; + + const handlePointerUp = (e, id, callback) => { + if (!pointerDownRef.current || isScrollingRef.current) { + return; + } + + const { x: startX, y: startY, time: startTime } = pointerDownRef.current; + const endX = e.clientX; + const endY = e.clientY; + const endTime = Date.now(); + + // Проверяем расстояние и время + const distance = Math.sqrt( + Math.pow(endX - startX, 2) + Math.pow(endY - startY, 2) + ); + const duration = endTime - startTime; + + // Если расстояние маленькое и время короткое - это клик + if (distance < 10 && duration < 300) { + callback(id); + } + + pointerDownRef.current = null; + }; + + const handleScroll = () => { + isScrollingRef.current = true; + pointerDownRef.current = null; + }; + + return { + handlePointerDown, + handlePointerUp, + handleScroll, + }; +}; diff --git a/src/client/src/hooks/useNetworkStatus.js b/src/client/src/hooks/useNetworkStatus.js new file mode 100644 index 0000000..3f0b04b --- /dev/null +++ b/src/client/src/hooks/useNetworkStatus.js @@ -0,0 +1,53 @@ +import { useState, useEffect } from "react"; + +const useNetworkStatus = () => { + const [isOnline, setIsOnline] = useState(navigator.onLine); + const [wasOffline, setWasOffline] = useState(false); + + useEffect(() => { + const handleOnline = () => { + setIsOnline(true); + // Если пользователь был офлайн и теперь онлайн, сбрасываем флаг + if (wasOffline) { + setWasOffline(false); + } + }; + + const handleOffline = () => { + setIsOnline(false); + setWasOffline(true); + }; + + // Добавляем слушатели событий + window.addEventListener("online", handleOnline); + window.addEventListener("offline", handleOffline); + + // Очистка слушателей при размонтировании + return () => { + window.removeEventListener("online", handleOnline); + window.removeEventListener("offline", handleOffline); + }; + }, [wasOffline]); + + // Дополнительная проверка через fetch для более надежного определения + const checkNetworkConnection = async () => { + try { + const response = await fetch("/favicon.ico", { + method: "HEAD", + mode: "no-cors", + cache: "no-cache", + }); + return true; + } catch (error) { + return false; + } + }; + + return { + isOnline, + wasOffline, + checkNetworkConnection, + }; +}; + +export default useNetworkStatus; diff --git a/src/client/src/hooks/useOverlayScrollbars.js b/src/client/src/hooks/useOverlayScrollbars.js new file mode 100644 index 0000000..b08feda --- /dev/null +++ b/src/client/src/hooks/useOverlayScrollbars.js @@ -0,0 +1,86 @@ +import { useEffect, useRef, useCallback } from "react"; +import { OverlayScrollbars } from "overlayscrollbars"; +import "overlayscrollbars/overlayscrollbars.css"; + +/** + * Хук для инициализации OverlayScrollbars на элементе + * @param {Object} scrollbarOptions - Опции стилизации скроллбара + * @returns {Function} callback ref для элемента + */ +export const useOverlayScrollbars = (scrollbarOptions = {}) => { + const instanceRef = useRef(null); + const elementRef = useRef(null); + + // Callback ref для правильной обработки жизненного цикла + const setRef = useCallback((element) => { + // Если элемент был удален, уничтожаем экземпляр + if (!element && instanceRef.current) { + try { + instanceRef.current.destroy(); + } catch (e) { + // Игнорируем ошибки при уничтожении + } + instanceRef.current = null; + elementRef.current = null; + return; + } + + // Если элемент изменился, уничтожаем старый экземпляр + if (elementRef.current && elementRef.current !== element && instanceRef.current) { + try { + instanceRef.current.destroy(); + } catch (e) { + // Игнорируем ошибки при уничтожении + } + instanceRef.current = null; + } + + // Если элемент существует и еще не инициализирован + if (element && !instanceRef.current) { + elementRef.current = element; + + // Дефолтные опции для скроллбара + const defaultOptions = { + scrollbars: { + theme: "os-theme-custom", + visibility: "auto", + autoHide: "never", + autoHideDelay: 0, + dragScrolling: true, + clickScrolling: false, + touchSupport: true, + snapHandle: false, + }, + overflow: { + x: scrollbarOptions.overflowX || "hidden", + y: scrollbarOptions.overflowY || "scroll", + }, + }; + + // Инициализируем OverlayScrollbars + try { + instanceRef.current = OverlayScrollbars(element, defaultOptions); + } catch (e) { + console.error("Failed to initialize OverlayScrollbars:", e); + } + } + }, [scrollbarOptions.overflowX, scrollbarOptions.overflowY]); + + // Cleanup при размонтировании компонента + useEffect(() => { + return () => { + if (instanceRef.current) { + try { + instanceRef.current.destroy(); + } catch (e) { + // Игнорируем ошибки при уничтожении + } + instanceRef.current = null; + elementRef.current = null; + } + }; + }, []); + + return setRef; +}; + diff --git a/src/client/src/hooks/useRouteFollowingPosition.ts b/src/client/src/hooks/useRouteFollowingPosition.ts new file mode 100644 index 0000000..5ba010f --- /dev/null +++ b/src/client/src/hooks/useRouteFollowingPosition.ts @@ -0,0 +1,76 @@ +import { useState, useRef, useEffect, useCallback } from "react"; +import { RoutePathAnimator } from "../utils/RoutePathAnimator"; + +interface RouteFollowingPositionState { + x: number; + y: number; + isAnimating: boolean; +} + +export const useRouteFollowingPosition = (defaultDuration: number = 800) => { + const [position, setPosition] = useState({ + x: 0, + y: 0, + isAnimating: false, + }); + + const animatorRef = useRef(null); + + useEffect(() => { + animatorRef.current = new RoutePathAnimator( + (newPos) => { + setPosition((prev) => ({ ...prev, x: newPos.x, y: newPos.y })); + }, + () => { + setPosition((prev) => ({ ...prev, isAnimating: false })); + }, + defaultDuration, + ); + + return () => { + animatorRef.current?.stop(); + }; + }, [defaultDuration]); + + const setRoutePath = useCallback( + (path: Float32Array, syncMapXY?: { x: number; y: number }) => { + animatorRef.current?.setRoutePath(path, syncMapXY); + }, + [], + ); + + const animateTo = useCallback( + (targetX: number, targetY: number, duration?: number) => { + if (animatorRef.current) { + setPosition((prev) => ({ ...prev, isAnimating: true })); + animatorRef.current.animateTo(targetX, targetY, duration); + } + }, + [], + ); + + const setPositionImmediate = useCallback((x: number, y: number) => { + animatorRef.current?.stop(); + animatorRef.current?.setPosition(x, y); + setPosition({ x, y, isAnimating: false }); + }, []); + + const stopAnimation = useCallback(() => { + animatorRef.current?.stop(); + setPosition((prev) => ({ ...prev, isAnimating: false })); + }, []); + + const getCurrentSegIndex = useCallback((): number => { + return animatorRef.current?.getCurrentSegIndex() ?? -1; + }, []); + + return { + position: { x: position.x, y: position.y }, + isAnimating: position.isAnimating, + setRoutePath, + animateTo, + setPositionImmediate, + stopAnimation, + getCurrentSegIndex, + }; +}; diff --git a/src/client/src/index.css b/src/client/src/index.css new file mode 100644 index 0000000..a789f1e --- /dev/null +++ b/src/client/src/index.css @@ -0,0 +1,25 @@ +.client-app-scoped { + overflow: hidden; + -webkit-user-select: none; + user-select: none; +} + +.zoom-icon { + position: absolute; + z-index: 99; + top: 32px; + right: 600px; +} + +.side-menu-sights-block { + height: calc(60%); + overflow-y: scroll; + margin-left: 0; + margin-top: 8px; + padding-right: 5px; + touch-action: none; /* Отключаем стандартные действия */ + overscroll-behavior: contain; /* Предотвращаем прокрутку родительских элементов */ +} +.side-menu-sight-transfer-empty { + padding-left: 20px; +} diff --git a/src/client/src/stores/CameraAnimationStore.ts b/src/client/src/stores/CameraAnimationStore.ts new file mode 100644 index 0000000..e209454 --- /dev/null +++ b/src/client/src/stores/CameraAnimationStore.ts @@ -0,0 +1,185 @@ +import { makeAutoObservable, runInAction } from "mobx"; + +interface CameraPosition { + x: number; + y: number; +} + +interface AnimationConfig { + duration: number; + easing: (t: number) => number; +} + +interface Station { + longitude: number; + latitude: number; + id?: number; +} + +class CameraAnimationStore { + public maxZoom: number = 8; + public minZoom: number = 1; + + // Актуальные, анимируемые значения, которые используются для рендеринга + public animatedPosition: CameraPosition = { x: 0, y: 0 }; + public animatedZoom: number = 1; + + // Цель, к которой мы стремимся + private targetPosition: CameraPosition | null = null; + private targetZoom: number | null = null; + + // Начальные значения для текущей интерполяции + private startPosition: CameraPosition = { x: 0, y: 0 }; + private startZoom: number = 1; + + private isAnimating: boolean = false; + private animationStartTime: number = 0; + private animationFrameId: number | null = null; + + public animationConfig: AnimationConfig = { + duration: 3000, // 3 секунды для максимально плавной анимации + easing: this.easeInOutCubic, + }; + + // Единый колбэк для обновления камеры в Pixi + private onUpdate: ((pos: CameraPosition, zoom: number) => void) | null = null; + + constructor() { + makeAutoObservable(this); + } + + private easeInOutCubic(t: number): number { + return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2; + } + + public setUpdateCallback( + callback: ((pos: CameraPosition, zoom: number) => void) | null + ) { + this.onUpdate = callback; + } + + public setMaxZoom(maxZoom: number) { + this.maxZoom = maxZoom; + } + public setMinZoom(minZoom: number) { + this.minZoom = minZoom; + } + + // Метод для ручной синхронизации (когда пользователь управляет камерой) + public syncState(pos: CameraPosition, zoom: number) { + this.stopAnimation(); + runInAction(() => { + // Сохраняем точные значения без округления для плавной анимации + this.animatedPosition = { + x: pos.x, + y: pos.y, + }; + this.animatedZoom = zoom; + }); + } + + // Метод для обновления целевой позиции без перезапуска анимации + public updateTarget(targetPos: CameraPosition, targetZoom: number) { + if (this.isAnimating) { + // Если анимация уже идет, обновляем стартовую позицию на текущую и целевую на новую + // Это даст плавную корректировку курса без рывков + runInAction(() => { + this.startPosition = { ...this.animatedPosition }; + this.startZoom = this.animatedZoom; + this.targetPosition = targetPos; + this.targetZoom = targetZoom; + this.animationStartTime = performance.now(); + }); + } else { + // Если анимация не идет, запускаем новую + this.animateTo(targetPos, targetZoom); + } + } + + // Главный метод для запуска анимации + public animateTo(targetPos: CameraPosition, targetZoom: number) { + this.stopAnimation(); // Прерываем текущую анимацию + runInAction(() => { + this.startPosition = { ...this.animatedPosition }; + this.startZoom = this.animatedZoom; + this.targetPosition = targetPos; + this.targetZoom = targetZoom; + this.animationStartTime = performance.now(); + this.isAnimating = true; + this.animationFrameId = requestAnimationFrame(this.animationLoop); + }); + } + + public followTram( + tramMapPos: CameraPosition, + screenCenter: CameraPosition, + _stations: Station[] = [] + ) { + // Анимация начинается с текущего зума и плавно переходит к максимальному зуму + // для плавного приближения к желтой точке при слежении + const targetZoom = this.maxZoom; + + // Правильная формула для позиционирования камеры: центр экрана минус позиция точки умноженная на зум + // Это гарантирует, что точка tramMapPos будет в центре экрана + const targetCameraPosition = { + x: screenCenter.x - tramMapPos.x * targetZoom, + y: screenCenter.y - tramMapPos.y * targetZoom, + }; + + // Используем updateTarget чтобы не останавливать анимацию при обновлении координат + this.updateTarget(targetCameraPosition, targetZoom); + } + + private animationLoop = (timestamp: number) => { + if (!this.isAnimating || !this.targetPosition || this.targetZoom === null) + return; + + const elapsed = timestamp - this.animationStartTime; + const progress = Math.min(elapsed / this.animationConfig.duration, 1); + const easedProgress = this.animationConfig.easing(progress); + + runInAction(() => { + // Плавная интерполяция без округления для максимально плавной анимации + const newPos = { + x: + this.startPosition.x + + (this.targetPosition!.x - this.startPosition.x) * easedProgress, + y: + this.startPosition.y + + (this.targetPosition!.y - this.startPosition.y) * easedProgress, + }; + const newZoom = + this.startZoom + (this.targetZoom! - this.startZoom) * easedProgress; + + this.animatedPosition = newPos; + this.animatedZoom = newZoom; + + this.onUpdate?.(this.animatedPosition, this.animatedZoom); + }); + + if (progress < 1) { + this.animationFrameId = requestAnimationFrame(this.animationLoop); + } else { + this.stopAnimation(); + } + }; + + public stopAnimation = () => { + if (this.animationFrameId) { + cancelAnimationFrame(this.animationFrameId); + } + runInAction(() => { + this.isAnimating = false; + this.animationFrameId = null; + this.targetPosition = null; + this.targetZoom = null; + }); + }; + + public get isActivelyAnimating(): boolean { + return this.isAnimating; + } +} + +export const cameraAnimationStore = new CameraAnimationStore(); +export default CameraAnimationStore; diff --git a/src/client/src/stores/ColorStore.ts b/src/client/src/stores/ColorStore.ts new file mode 100644 index 0000000..d1d8da8 --- /dev/null +++ b/src/client/src/stores/ColorStore.ts @@ -0,0 +1,95 @@ +import { makeAutoObservable, runInAction } from "mobx"; + +const TRANSITION_DURATION = 60000; +const TICK_INTERVAL = 100; +const TICK_STEP = TICK_INTERVAL / TRANSITION_DURATION; + +function hexToRgb(hex: string): { r: number; g: number; b: number } | null { + const clean = hex.trim().replace(/^#/, ""); + const full = clean.length === 3 ? clean.split("").map((c) => c + c).join("") : clean; + if (full.length !== 6) return null; + return { + r: parseInt(full.slice(0, 2), 16), + g: parseInt(full.slice(2, 4), 16), + b: parseInt(full.slice(4, 6), 16), + }; +} + +function interpolateRgb( + from: { r: number; g: number; b: number }, + to: { r: number; g: number; b: number }, + t: number +): string { + const r = Math.round(from.r + (to.r - from.r) * t); + const g = Math.round(from.g + (to.g - from.g) * t); + const b = Math.round(from.b + (to.b - from.b) * t); + return `rgb(${r}, ${g}, ${b})`; +} + +const WHITE = { r: 255, g: 255, b: 255 }; +const DEFAULT_MAIN = { r: 0, g: 111, b: 58 }; + +interface ColorStore { + currentColor: string; + setCurrentColor: (color: string) => void; + setMainColor: (hex: string) => void; + startColorAnimation: () => void; + stopColorAnimation: () => void; +} + +class ColorStore implements ColorStore { + currentColor: string = "#fff"; + private mainColor: { r: number; g: number; b: number } = DEFAULT_MAIN; + private progress: number = 0; + private direction: number = 1; + private tickInterval: ReturnType | null = null; + + constructor() { + makeAutoObservable(this); + } + + setCurrentColor = (color: string) => { + this.currentColor = color; + }; + + setMainColor = (hex: string) => { + const parsed = hexToRgb(hex); + if (parsed) { + this.mainColor = parsed; + } + }; + + startColorAnimation = () => { + if (this.tickInterval) return; + + this.tickInterval = globalThis.setInterval(() => { + runInAction(() => { + this.progress += TICK_STEP * this.direction; + + if (this.progress >= 1) { + this.progress = 1; + this.direction = -1; + } else if (this.progress <= 0) { + this.progress = 0; + this.direction = 1; + } + + this.currentColor = interpolateRgb(WHITE, this.mainColor, this.progress); + }); + }, TICK_INTERVAL); + }; + + stopColorAnimation = () => { + if (this.tickInterval) { + clearInterval(this.tickInterval); + this.tickInterval = null; + } + }; + + dispose = () => { + this.stopColorAnimation(); + }; +} + +export const colorStore = new ColorStore(); +export { ColorStore }; diff --git a/src/client/src/stores/GeolocationStore.ts b/src/client/src/stores/GeolocationStore.ts new file mode 100644 index 0000000..05618a1 --- /dev/null +++ b/src/client/src/stores/GeolocationStore.ts @@ -0,0 +1,146 @@ +import { makeAutoObservable } from "mobx"; + +interface ContextData { + routeId?: string; + routeNumber?: string; + startStopId?: string; + endStopId?: string; + nearestSightId?: string; + nearestStationId?: string; + maintenanceModeOn?: boolean; + routeProgress?: { + startStopId: string; + endStopId: string; + percentageCompleted: number; + }; + rawCoordinates?: { + latitude: number; + longitude: number; + }; + currentCoordinates?: { + latitude: number; + longitude: number; + }; + error?: string | boolean | { message?: string }; + message?: string; + status?: string; +} + +interface GeolocationStore { + contextData: ContextData | null; + nearestSightId: string | null; + selectedSightId: string | null; + selectedLanguage: string; + selectedLanguageRight: string; + nearestStationId: string | null; + currentStationId: string | null; + isLoading: boolean; + error: string | null; + isLeftWidgetOpen: boolean; + isTransferWidgetOpen: boolean; + isManualSelection: boolean; + isRightWidgetSelectorOpen: boolean; + isGovernorWidgetOpen: boolean; + activeClusterId: string | null; + setIsManualSelection: (val: boolean) => void; + setIsRightWidgetSelectorOpen: (isOpen: boolean) => void; + setIsGovernorWidgetOpen: (isOpen: boolean) => void; + setActiveClusterId: (id: string | null) => void; + setIsTransferWidgetOpen: (isOpen: boolean) => void; + setCurrentStationId: (id: string | null) => void; +} + +class GeolocationStore implements GeolocationStore { + contextData: ContextData | null = null; + nearestSightId: string | null = null; + selectedSightId: string | null = null; + selectedLanguage: string = "ru"; + selectedLanguageRight: string = "ru"; + nearestStationId: string | null = null; + currentStationId: string | null = null; + isLoading: boolean = true; + error: string | null = null; + isLeftWidgetOpen: boolean = false; + isTransferWidgetOpen: boolean = false; + isManualSelection: boolean = false; + isRightWidgetSelectorOpen: boolean = false; + isGovernorWidgetOpen: boolean = false; + activeClusterId: string | null = null; + + constructor() { + makeAutoObservable(this); + } + + setContextData = (data: ContextData) => { + this.contextData = data; + }; + + setNearestSightId = (id: string | null) => { + this.nearestSightId = id; + }; + + setSelectedSightId = (id: string | null) => { + this.selectedSightId = id; + }; + + setSelectedLanguage = (language: string) => { + this.selectedLanguage = language; + }; + + setSelectedLanguageRight = (language: string) => { + this.selectedLanguageRight = language; + }; + + setNearestStationId = (id: string | null) => { + this.nearestStationId = id; + }; + + setCurrentStationId = (id: string | null) => { + this.currentStationId = id; + }; + + setIsLoading = (loading: boolean) => { + this.isLoading = loading; + }; + + setError = (error: string | null) => { + this.error = error; + }; + + setIsLeftWidgetOpen = (isOpen: boolean) => { + this.isLeftWidgetOpen = isOpen; + }; + + setIsTransferWidgetOpen = (isOpen: boolean) => { + this.isTransferWidgetOpen = isOpen; + }; + + setIsManualSelection = (val: boolean) => { + this.isManualSelection = val; + }; + + setIsRightWidgetSelectorOpen = (isOpen: boolean) => { + this.isRightWidgetSelectorOpen = isOpen; + }; + + setIsGovernorWidgetOpen = (isOpen: boolean) => { + this.isGovernorWidgetOpen = isOpen; + }; + + closeGovernorModal = () => { + this.isGovernorWidgetOpen = false; + }; + + setActiveClusterId = (id: string | null) => { + this.activeClusterId = id; + }; + + sortingBy: string = "asc"; + + setSortingBy = (sorting: string) => { + this.sortingBy = sorting; + }; +} + +export const geolocationStore = new GeolocationStore(); +export { GeolocationStore }; diff --git a/src/client/src/stores/hooks/useCameraAnimationStore.ts b/src/client/src/stores/hooks/useCameraAnimationStore.ts new file mode 100644 index 0000000..6585f61 --- /dev/null +++ b/src/client/src/stores/hooks/useCameraAnimationStore.ts @@ -0,0 +1,6 @@ +import { cameraAnimationStore } from "../CameraAnimationStore"; +import CameraAnimationStore from "../CameraAnimationStore"; + +export const useCameraAnimationStore = (): CameraAnimationStore => { + return cameraAnimationStore; +}; diff --git a/src/client/src/stores/hooks/useColorStore.ts b/src/client/src/stores/hooks/useColorStore.ts new file mode 100644 index 0000000..3125256 --- /dev/null +++ b/src/client/src/stores/hooks/useColorStore.ts @@ -0,0 +1,6 @@ +import { colorStore } from "../ColorStore"; + +// Хук для использования стора цвета +export const useColorStore = () => { + return colorStore; +}; \ No newline at end of file diff --git a/src/client/src/stores/hooks/useGeolocationStore.ts b/src/client/src/stores/hooks/useGeolocationStore.ts new file mode 100644 index 0000000..a1c6e13 --- /dev/null +++ b/src/client/src/stores/hooks/useGeolocationStore.ts @@ -0,0 +1,6 @@ +import { geolocationStore } from "../GeolocationStore"; + +// Хук для использования стора +export const useGeolocationStore = () => { + return geolocationStore; +}; diff --git a/src/client/src/stores/index.ts b/src/client/src/stores/index.ts new file mode 100644 index 0000000..9004239 --- /dev/null +++ b/src/client/src/stores/index.ts @@ -0,0 +1,9 @@ +export { geolocationStore, GeolocationStore } from "./GeolocationStore"; +export { useGeolocationStore } from "./hooks/useGeolocationStore"; +export { + cameraAnimationStore, + default as CameraAnimationStore, +} from "./CameraAnimationStore"; +export { useCameraAnimationStore } from "./hooks/useCameraAnimationStore"; +export { colorStore, ColorStore } from "./ColorStore"; +export { useColorStore } from "./hooks/useColorStore"; diff --git a/src/client/src/styles/AppealWidget.css b/src/client/src/styles/AppealWidget.css new file mode 100644 index 0000000..c4de46d --- /dev/null +++ b/src/client/src/styles/AppealWidget.css @@ -0,0 +1,128 @@ +.dynamic-widget { + position: fixed; + top: 150px; + display: flex; + flex-direction: column; + align-items: center; + width: 420px; + max-height: calc(100vh - 150px - 98px); + border-radius: 10px; + background: + linear-gradient( + 114deg, + rgba(255, 255, 255, 0) 8.71%, + rgba(255, 255, 255, 0.16) 69.69% + ), + var(--carrier-left, #006f3a); + box-sizing: border-box; + overflow: hidden; + touch-action: none; +} + +.dynamic-widget-image { + padding-top: 2px; + margin-left: 2px; + margin-right: 2px; + width: 416px; + border-radius: 10px 10px 0 0; + object-fit: cover; +} + +.dynamic-widget-label { + width: 100%; + padding: 10px 20px; + box-sizing: border-box; + font-size: 20px; + font-weight: 500; + color: #fff; + border-bottom: 1px solid var(--Glass-stroke, rgba(255, 255, 255, 0.8)); + background: + linear-gradient( + 180deg, + rgba(255, 255, 255, 0.2) 0%, + rgba(255, 255, 255, 0) 100% + ), + rgba(var(--carrier-right-menu-rgb, 179, 165, 152), 0.4); +} + +.dynamic-widget-text-scroll.scrollable-container { + flex: 1; + min-height: 0; + align-self: stretch; + margin: 15px 20px; + overflow: hidden; +} + +.dynamic-widget-text-scroll .scrollable-viewport { + flex: 1; + min-height: 0; + overflow: hidden; +} + +.dynamic-widget-text-scroll .scrollable { + flex: 1; + min-height: 0; +} + + +.dynamic-widget-text { + font-size: 16px; + font-weight: 300; + line-height: 135%; + padding-right: 5px; +} + +.dynamic-widget-text .react-markdown-container { + font-size: 16px; + line-height: 135%; + font-weight: 300; +} + +.dynamic-widget-text .react-markdown-container p { + font-size: 16px; + line-height: 135%; + margin-bottom: 8px; +} + +.dynamic-widget-text .react-markdown-container p:last-child { + margin-bottom: 0; +} + +.dynamic-widget-text .react-markdown-container h1, +.dynamic-widget-text .react-markdown-container h2, +.dynamic-widget-text .react-markdown-container h3, +.dynamic-widget-text .react-markdown-container h4, +.dynamic-widget-text .react-markdown-container h5, +.dynamic-widget-text .react-markdown-container h6 { + font-size: 18px; + margin-top: 10px; + margin-bottom: 4px; + font-weight: 600; +} + +.dynamic-widget-text .react-markdown-container ul, +.dynamic-widget-text .react-markdown-container ol { + margin-bottom: 8px; + padding-left: 20px; +} + +.dynamic-widget-text .react-markdown-container li { + margin-bottom: 4px; +} + +.dynamic-widget-text .react-markdown-container blockquote { + margin-top: 8px; + margin-bottom: 8px; + padding-left: 12px; + border-left: 3px solid rgba(255, 255, 255, 0.4); +} + +.dynamic-widget-text .react-markdown-container img { + max-width: 100%; + border-radius: 6px; +} + +.dynamic-widget-text .react-markdown-container a { + color: rgba(255, 255, 255, 0.9); + text-decoration: underline; +} diff --git a/src/client/src/styles/LeftWidget.css b/src/client/src/styles/LeftWidget.css new file mode 100644 index 0000000..340a19e --- /dev/null +++ b/src/client/src/styles/LeftWidget.css @@ -0,0 +1,180 @@ +.left-widget { + display: flex; + flex-direction: column; + align-items: center; + position: fixed; + left: 316px; + top: 500px; + z-index: 10; + width: 316px; + min-height: 350px; + border-radius: 10px; + background: linear-gradient( + 114deg, + rgba(255, 255, 255, 0) 8.71%, + rgba(255, 255, 255, 0.16) 69.69% + ), + var(--carrier-left, #006F3A); + will-change: transform, opacity; + backface-visibility: hidden; +} + +.left-widget-header { + display: flex; + justify-content: flex-end; + width: 100%; + padding: 8px 8px 0 0; +} + +.left-widget-close-btn { + background: rgba(255, 255, 255, 0.2); + border: none; + border-radius: 50%; + width: 24px; + height: 24px; + color: white; + font-size: 14px; + font-weight: bold; + + display: flex; + align-items: center; + justify-content: center; + transition: background-color 0.1s ease; +} + +.left-widget-close-btn:active { + background: rgba(255, 255, 255, 0.4); +} + +.left-widget-content { + padding: 0px 10px 20px 10px; +} + +.left-widget-title { + color: #fff; + font-family: "Roboto"; + font-size: 20px; + font-weight: 600; + line-height: 150%; +} + +.left-widget-address { + margin-top: 2px; + color: #fff; + font-family: "Roboto"; + font-size: 16px; + font-weight: 400; + line-height: 150%; +} + +.left-widget-text-scroll.scrollable-container { + margin-top: 15px; + overflow: hidden; + width: 100%; +} + +.left-widget-text-scroll .scrollable-viewport { + max-height: 200px; +} + +.left-widget-text { + color: #fff; + font-family: "Roboto"; + font-size: 16px; + font-weight: 300; + line-height: 135%; + padding-right: 3px; +} + +.left-widget-text .react-markdown-container { + font-size: 16px; + line-height: 135%; + font-weight: 300; +} + +.left-widget-text .react-markdown-container p { + font-size: 16px; + line-height: 135%; + margin-bottom: 8px; +} + +.left-widget-text .react-markdown-container p:last-child { + margin-bottom: 0; +} + +.left-widget-text .react-markdown-container h1, +.left-widget-text .react-markdown-container h2, +.left-widget-text .react-markdown-container h3, +.left-widget-text .react-markdown-container h4, +.left-widget-text .react-markdown-container h5, +.left-widget-text .react-markdown-container h6 { + font-size: 18px; + margin-top: 10px; + margin-bottom: 4px; + font-weight: 600; +} + +.left-widget-text .react-markdown-container ul, +.left-widget-text .react-markdown-container ol { + margin-bottom: 8px; + padding-left: 20px; +} + +.left-widget-text .react-markdown-container li { + margin-bottom: 4px; +} + +.left-widget-text .react-markdown-container blockquote { + margin-top: 8px; + margin-bottom: 8px; + padding-left: 12px; + border-left: 3px solid rgba(255, 255, 255, 0.4); +} + +.left-widget-text .react-markdown-container img { + max-width: 100%; + border-radius: 6px; +} + +.left-widget-text .react-markdown-container a { + color: rgba(255, 255, 255, 0.9); + text-decoration: underline; +} + +.left-widget-image { + border-radius: 10px 10px 0 0; + margin: 2px 0px 2px 0px; + width: 312px; + height: 175px; +} + +.side-menu-sight-transfer { + display: flex; + align-items: center; + padding-left: 10px; + padding-bottom: 6px; + width: 100%; + overflow: hidden; +} + +.side-menu-sight-transfer span { + display: inline-block; + white-space: nowrap; +} + +.side-menu-sight-transfer span.marquee-text { + animation: side-menu-marquee 14s linear infinite; +} + +.side-menu-sight-transfer-list { + height: 0; + opacity: 0; + overflow: hidden; + transition: + height 0.3s cubic-bezier(0.16, 1, 0.3, 1), + opacity 0.3s ease; +} + +.side-menu-sight-transfer-list.open { + opacity: 1; +} diff --git a/src/client/src/styles/ListOfSights.css b/src/client/src/styles/ListOfSights.css new file mode 100644 index 0000000..d9b00c6 --- /dev/null +++ b/src/client/src/styles/ListOfSights.css @@ -0,0 +1,1032 @@ +@property --fade-top { + syntax: ""; + inherits: false; + initial-value: 0px; +} + +@property --fade-bottom { + syntax: ""; + inherits: false; + initial-value: 45px; +} + +@keyframes pulse-chevron { + 0% { + transform: rotate(var(--r, 0deg)) translateY(0px) scale(1); + } + 40% { + transform: rotate(var(--r, 0deg)) translateY(-4px) scale(1.12); + } + 60% { + transform: rotate(var(--r, 0deg)) translateY(-5px) scale(1.14); + } + 100% { + transform: rotate(var(--r, 0deg)) translateY(0px) scale(1); + } +} + +.chevron-svg { + font-size: 20px; + animation: pulse-chevron 1.2s ease-in-out infinite; + animation-play-state: paused; + will-change: transform; +} + +.chevron-svg.is-idle { + animation-play-state: running; +} + +.chevron-svg.is-open { + --r: 180deg; +} + +.right-widget { + position: fixed; + right: 32px; + bottom: 0px; + z-index: 300000; +} + +.list-of-sights { + position: relative; + z-index: 10; + -webkit-box-shadow: 0px -8px 17px 2px rgba(34, 60, 80, 0.2); + -moz-box-shadow: 0px -8px 17px 2px rgba(34, 60, 80, 0.2); + box-shadow: 0px -8px 17px 2px rgba(34, 60, 80, 0.2); + width: 550px; + border-radius: 10px 10px 0px 0px; + background: + linear-gradient( + 114deg, + rgba(255, 255, 255, 0) 8.71%, + rgba(255, 255, 255, 0.16) 69.69% + ), + var(--carrier-right, #806c59); + color: white; + + max-height: 68px; + transition: max-height 0.35s cubic-bezier(0.16, 1, 0.3, 1); + overflow: hidden; +} + +.list-of-sights.is-open { + max-height: 890px; +} + +.list-of-sights:not(.is-open) .sights-line { + height: 0; + margin: 0; + padding: 0; + opacity: 0; + overflow: hidden; + pointer-events: none; +} + +.list-of-sights-control { + position: relative; + width: 100%; + display: inline-flex; + justify-content: space-around; + align-items: center; + padding-top: 18px; + padding-bottom: 24px; + height: 68px; + flex-shrink: 0; + + user-select: none; +} + +.list-of-sights-control::after { + content: ""; + position: absolute; + left: 50%; + transform: translate(-50%, -50%); + + top: 0; + height: 10px; + border-radius: 10px; + width: 128px; + + background-color: var(--carrier-right-dark, #005a2f); +} + +.list-of-sights-title { + font-family: "Roboto"; + font-size: 18px; + font-weight: 600; + line-height: normal; + color: white; +} + +.list-of-sights-content { + margin-right: 12px; + overflow-x: visible; + height: 700px; + + opacity: 0; + pointer-events: none; + transition: opacity 0.25s cubic-bezier(0.16, 1, 0.3, 1) 0.1s; + + overscroll-behavior: contain; + touch-action: pan-y; + will-change: transform; + transform: translateZ(0); + backface-visibility: hidden; +} + +.list-of-sights-content .scrollable { + --fade-top: 0px; + --fade-bottom: 45px; + mask-image: linear-gradient( + to bottom, + transparent 0px, + black var(--fade-top), + black calc(100% - var(--fade-bottom)), + transparent 100% + ); + transition: + --fade-top 0.5s ease, + --fade-bottom 0.5s ease; +} + +.list-of-sights-content:not(.is-at-top) .scrollable { + --fade-top: 15px; +} + +.list-of-sights-content.is-at-bottom .scrollable { + --fade-bottom: 0px; +} + +.list-of-sights-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 13px; + padding: 20px 13px 30px 13px; + align-content: start; +} + +.list-of-sights-content.is-open { + opacity: 1; + pointer-events: auto; +} + +.list-of-sights-content .custom-scrollbar-track { + margin-top: 14px; + margin-bottom: 10px; + overflow: hidden; +} + +.sight-component { + display: flex; + flex-direction: column; + align-items: center; + justify-content: start; + text-align: center; + touch-action: pan-y; + position: relative; + z-index: 1; + scroll-margin-top: 15px; +} + +.sight-component.sight-highlighted { + z-index: 10; +} + +.sight-image { + display: flex; + justify-content: center; + align-items: center; + width: 96px; + height: 96px; + border-radius: 100%; + background-color: #fff; + flex-shrink: 0; + position: relative; + overflow: visible; +} + +.sight-title { + margin-top: 8px; + text-align: center; + font-family: "Roboto"; + font-size: 14px; + font-weight: 500; + line-height: 1.4; + letter-spacing: 0.32px; + word-break: break-word; + overflow-wrap: break-word; + color: white; + overflow: hidden; + max-height: 2.8em; + position: relative; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + text-overflow: ellipsis; +} + +.sight-image-svg { + width: 100%; + height: 100%; + object-fit: contain; +} + +.no-image-placeholder { + width: 96px; + height: 96px; + border-radius: 100%; + background-color: #ccc; + display: flex; + justify-content: center; + align-items: center; + color: #666; + font-size: 12px; + text-align: center; +} + +.sights-line { + height: 1px; + width: 100%; + background-color: rgba(255, 255, 255, 0.3); + margin-bottom: 0; +} + +.sight-frame { + z-index: -1; + position: absolute; + bottom: 100px; + left: 0; + display: flex; + flex-direction: column; + align-items: center; + width: 100%; + border-radius: 10px; + background: + linear-gradient( + 114deg, + rgba(255, 255, 255, 0) 8.71%, + rgba(255, 255, 255, 0.16) 69.69% + ), + var(--carrier-right, #806c59); + max-height: calc(100vh - 128px); + transform: translate3d(0, 0, 0); + overflow: hidden; +} + +.sight-frame.three-d-fullscreen { + overflow: visible; +} + +.sight-frame-image { + margin-top: 2px; + width: 546px; + height: 342px; + border-radius: 8px 8px 0px 0px; + background: #d9d9d9; + object-fit: contain; +} + +.sight-frame-content { + display: flex; + flex-direction: column; + flex-grow: 1; + width: 100%; + transition: opacity 0.18s ease; +} + +.sight-frame-content.is-switching { + opacity: 0; +} + +.sight-frame-get-back-wrapper { + display: flex; + align-items: center; + justify-content: center; +} + +.sight-frame-title { + display: flex; + align-items: center; + padding: 10px 20px; + width: 100%; + text-align: left; + font-family: "Roboto"; + font-size: 24px; + font-weight: 600; + line-height: 120%; + border-bottom: 1px solid var(--Glass-stroke, rgba(255, 255, 255, 0.8)); + background: + linear-gradient( + 180deg, + rgba(255, 255, 255, 0.22) 0%, + rgba(255, 255, 255, 0.04) 100% + ), + rgba(var(--carrier-right-rgb, 128, 108, 89), 0.72); + box-shadow: 4px 4px 12px 0px rgba(255, 255, 255, 0.12) inset; + box-sizing: border-box; + color: white; + word-wrap: break-word; + min-width: 0; +} + +.fade-in-text { + animation: fadeInText 0.35s cubic-bezier(0.16, 1, 0.3, 1) forwards; +} + +@keyframes fadeInText { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +.sight-frame-title:not(.intro-title) { + background: + linear-gradient( + 180deg, + rgba(255, 255, 255, 0.2) 0%, + rgba(255, 255, 255, 0) 100% + ), + rgba(var(--carrier-right-menu-rgb, 179, 165, 152), 0.4); +} + +.sight-frame-title p { + word-wrap: break-word; + overflow-wrap: break-word; + word-break: break-word; + white-space: normal !important; + margin: 0; + width: 100%; + flex: 1; + min-width: 0; + max-width: 100%; +} + +.sight-frame-get-back { + margin-right: 12px; +} + +.sight-frame-text-wrapper { + max-height: calc(80vh - 354px); + min-height: 0; + flex-shrink: 0; + padding: 16px 16px 76px 16px; + box-sizing: border-box; + overflow: hidden; + display: flex; + flex-direction: column; + transition: opacity 0.3s ease; +} + +.sight-frame-text-wrapper.scrollable-container { + padding: 16px 16px 76px 16px; + transition: opacity 0.3s ease; +} + +.sight-frame-text-wrapper.is-intro { + height: 0; + padding-top: 0; + padding-bottom: 0; + border: none; + opacity: 0; + pointer-events: none; + overflow: hidden; +} + +.sight-frame-loading-placeholder { + flex-shrink: 0; +} + + +.sight-frame-text-wrapper[data-scrollbar-no-transition="true"] .custom-scrollbar-track, +.sight-frame-text-wrapper[data-scrollbar-no-transition="true"] .custom-scrollbar-thumb { + transition: opacity 0.3s ease !important; +} +.sight-frame-text-wrapper .scrollable-viewport { + flex: 1; + min-height: 0; +} +.sight-frame-text-wrapper .scrollable { + padding-right: 10px; +} + +.sight-frame-text { + display: flow-root; /* BFC prevents margin collapsing for correct scrollHeight measurement */ + padding-right: 10px; + text-align: left; + color: #fff; + font-family: "Roboto"; + font-size: 20px; + font-style: normal; + font-weight: 400; + line-height: 150%; + margin-bottom: 0; +} + +.sight-frame-text::-webkit-scrollbar { + width: 5px; +} + +.sight-frame-text::-webkit-scrollbar-track { + background: linear-gradient( + to right, + transparent 35%, + #0e8953 50%, + transparent 65% + ); + border-radius: 3px; +} + +.sight-frame-text::-webkit-scrollbar-thumb { + background: #fff; + border-radius: 3px; +} + +.sight-frame-text { + text-align: left; + color: #fff; + font-family: "Roboto"; + font-size: 18px; + font-style: normal; + font-weight: 400; + line-height: 150%; + margin-bottom: 0; +} + +.sight-frame-menu-wrapper { + position: absolute; + bottom: 100px; + left: 0; + width: 100%; + height: 60px; + z-index: 2; + border-radius: 0 0 10px 10px; + overflow: hidden; + flex-shrink: 0; +} + +.sight-frame-menu-fade { + position: absolute; + top: 0; + bottom: 0; + width: 120px; + z-index: 100001; + pointer-events: none; + transition: opacity 0.4s ease; +} + +.sight-frame-menu-fade.left { + left: 0; + background: linear-gradient( + to right, + rgba(var(--carrier-right-menu-rgb, 179, 165, 152), 0.95), + transparent + ); + border-radius: 0 0 0 10px; +} + +.sight-frame-menu-fade.right { + right: 0; + background: linear-gradient( + to left, + rgba(var(--carrier-right-menu-rgb, 179, 165, 152), 0.95), + transparent + ); + border-radius: 0 0 10px 0; +} + +.sight-frame-menu { + z-index: 100000; + position: relative; + padding: 7px 60px; + width: 100%; + height: 60px; + display: flex; + align-items: center; + justify-content: space-evenly; + border-radius: 0px 0px 10px 10px; + border-top: 1px solid rgba(255, 255, 255, 0.8); + background: + linear-gradient( + 180deg, + rgba(255, 255, 255, 0.2) 0%, + rgba(255, 255, 255, 0) 100% + ), + rgba(var(--carrier-right-menu-rgb, 179, 165, 152), 0.4); + box-shadow: 4px 4px 12px 0px rgba(255, 255, 255, 0.12) inset; + backdrop-filter: blur(10px); + box-sizing: border-box; + flex-shrink: 0; + overflow-x: auto; + overflow-y: hidden; +} + +.sight-frame-menu::-webkit-scrollbar { + display: none; +} + +.sight-frame-menu { + -ms-overflow-style: none; + scrollbar-width: none; +} + +.sight-frame-menu-point { + color: #fff; + text-align: center; + font-family: "Roboto"; + font-size: 18px; + font-style: normal; + font-weight: 400; + padding: 8px 12px; + white-space: nowrap; + flex-shrink: 0; + border-bottom: 2px solid transparent; + transition: + background-color 0.1s ease, + color 0.1s ease; +} + +.sight-frame-menu-point.active { + text-shadow: 0 0 0.4px #fff, 0 0 0.4px #fff; + border-bottom-color: #fff; +} + +.sight-frame-text-wrapper::-webkit-scrollbar-track { + background: rgba(255, 255, 255, 0.1); + border-radius: 4px; + width: 4px; +} + +.sight-frame-text-wrapper::-webkit-scrollbar-thumb { + background: #fff; + border-radius: 4px; + width: 2px; +} + +.sight-frame-text-wrapper::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.4); + border-radius: 1px; +} + +.sight-frame-text-wrapper::-webkit-scrollbar-track { + background: rgba(255, 255, 255, 0.1); +} +.sight-frame { + opacity: 0; + transition: opacity 0.9s ease-out; +} + +.sight-frame.is-visible { + opacity: 1; + animation: fadeInScale 0.9s cubic-bezier(0.16, 1, 0.3, 1) forwards; +} + +@keyframes fadeInScale { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@keyframes slideInFromRight { + 0% { + opacity: 0; + transform: translateX(50px); + } + 100% { + opacity: 1; + transform: translateX(0); + } +} + +.sight-component { + transition: transform 0.1s ease-in-out; +} + +.sight-component.sight-highlighted .sight-image { + animation: pulseHighlight 1.5s ease-in-out; + + animation-fill-mode: forwards; + will-change: transform, box-shadow; +} + +@keyframes pulseHighlight { + 0% { + box-shadow: 0 0 0 0 rgba(255, 255, 255, 0.7); + transform: scale(1); + } + + 50% { + box-shadow: 0 0 15px 10px rgba(255, 255, 255, 0.3); + transform: scale(1.05); + } + + 100% { + box-shadow: 0 0 0 0 rgba(255, 255, 255, 0); + transform: scale(1); + } +} + +.intro-title { + height: 300px; + justify-content: center; + align-items: center; + border-bottom: none; + text-align: center; + font-size: 40px; + font-weight: 600; + line-height: 150%; + padding-bottom: 60px; +} + +.svg-container { + position: absolute; + right: 20px; + width: 60px; + height: 50px; + display: flex; + align-items: center; + justify-content: center; +} + +.list-of-sights-lang-open { + position: absolute; + left: 20px; + display: flex; + align-items: center; + justify-content: center; + top: 18px; + gap: 4px; + flex-shrink: 0; +} + +.list-of-sights-lang-open .active { + fill: #fff; +} + +.list-of-sights-lang-open svg { + cursor: pointer; + transition: opacity 0.2s ease; + flex-shrink: 0; +} + +.list-of-sights-lang-open svg:hover { + opacity: 0.8; +} + +.list-of-sights-lang-closed { + position: absolute; + left: 20px; + top: 18px; +} + +.back-to-nearest-button { + cursor: pointer; + transition: opacity 0.2s ease; + flex-shrink: 0; +} + +.back-to-nearest-button:hover { + opacity: 0.8; +} + +.alphabet { + width: 40px; + flex-shrink: 0; + margin-right: 10px; + margin-top: 14px; + margin-bottom: 10px; + padding-top: 10px; + padding-bottom: 10px; + display: flex; + align-items: center; + flex-direction: column; + gap: 20px; + overflow-y: auto; + overflow-x: hidden; + height: 676px; + touch-action: pan-y; + overscroll-behavior: contain; + scrollbar-width: none; + + mask-image: linear-gradient( + to bottom, + transparent 0%, + black 20px, + black calc(100% - 30px), + transparent 100% + ); +} + +.alphabet::-webkit-scrollbar { + display: none; +} + +.alphabet-letter { + font-size: 16px; + color: #cccccc; + padding: 4px 10px; + transition: + color 0.1s ease, + font-weight 0.1s ease, + opacity 0.2s ease; + cursor: pointer; + user-select: none; + min-width: 20px; + text-align: center; + border-radius: 4px; + font-family: "Roboto", "Microsoft YaHei", "PingFang SC", sans-serif; +} + +.alphabet-letter[data-lang="zh"] { + font-size: 14px; + padding: 6px 8px; + min-width: 24px; +} + +.alphabet-disabled { + pointer-events: none; +} + +.alphabet-disabled .alphabet-letter { + opacity: 0.4; + cursor: default; +} + +.alphabet-position { + display: flex; + justify-content: space-between; + width: 100%; +} + +.transfer-button-container { + position: absolute; + left: -80px; + bottom: 32px; + z-index: 10000000; + pointer-events: auto; +} + +.transfer-button { + width: 48px; + height: 48px; + border-radius: 50%; +} + +.transfer-widget { + left: -495px; + bottom: -168px; + padding: 16px; + box-sizing: border-box; + width: 382px; + min-height: 186px; + + position: absolute; + border-radius: 10px; + border: 1px solid var(--carrier-main, #006f3a); + background: + linear-gradient( + 180deg, + rgba(255, 255, 255, 0.2) 0%, + rgba(255, 255, 255, 0) 100% + ), + rgba(var(--carrier-main-rgb, 0, 111, 58), 0.4); + + box-shadow: 4px 4px 12px 0px rgba(255, 255, 255, 0.12) inset; + backdrop-filter: blur(10px); +} + +.transferLabel { + font-family: "Roboto"; + font-size: 18px; + font-weight: 600; + line-height: 150%; +} + +.transferText { + font-family: "Roboto"; + font-size: 18px; + font-weight: 600; + line-height: 150%; + letter-spacing: 0.36px; +} + +.transfer-line { + display: inline-flex; + gap: 12px; +} + +.sight-frame-media-stack { + margin-top: 2px; + border-radius: 10px 10px 0 0; + position: relative; + width: calc(100% - 4px); + height: 300px; + overflow: hidden; +} + +.sight-frame-media-item { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + object-fit: fill; + + opacity: 0; + transition: opacity 0.2s ease; + pointer-events: none; + z-index: 0; +} + +.sight-frame-media-item.visible { + opacity: 1; + z-index: 1; + pointer-events: auto; +} + +.watermark { + position: absolute; + width: 51px; + height: auto; + left: 8px; + top: 8px; + z-index: 100000; +} + +.transfers-body { + font-family: "Roboto"; + font-size: 16px; + font-weight: 400; + line-height: 150%; + color: #fff; + overflow-y: auto; +} + +.panorama-container { + isolation: isolate; + contain: layout style paint; +} + +.panorama-container * { + box-sizing: border-box; +} + +.panorama-container .psv-container, +.panorama-container .psv-viewer, +.panorama-container .psv-canvas-container { + isolation: isolate; +} + +@supports (overscroll-behavior: contain) { + .list-of-sights-content { + overscroll-behavior: contain; + -webkit-overflow-scrolling: touch; + } +} + +.fullscreen-3d-button { + position: absolute; + top: 10px; + right: 10px; + background: transparent; + border: none; + border-radius: 6px; + color: white; + padding: 8px; + + z-index: 10; + display: flex; + align-items: center; + justify-content: center; + transition: background-color 0.2s ease; +} + +.fullscreen-3d-modal { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.5); + z-index: 9999; + display: flex; + align-items: center; + justify-content: center; +} + +.fullscreen-3d-actions { + position: fixed; + display: flex; + gap: 20px; + padding: 22px; + border-radius: 32px; + right: 20px; + bottom: 20px; + background: var(--carrier-main, #006f3a); + z-index: 9999; + display: flex; +} + +.fullscreen-3d-actions button { + background: none; + border: none; + cursor: pointer; + transition: opacity 0.2s ease; +} + +.fullscreen-3d-actions button:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.fullscreen-3d-actions button:hover:not(:disabled) { + opacity: 0.8; +} + +.fullscreen-3d-content { + position: relative; + width: 80vw; + height: 80vh; + border-radius: 50px; + background: rgba(255, 255, 255, 0.1); + overflow: hidden; + backdrop-filter: blur(10px); +} + +.fullscreen-3d-viewer { + width: 100%; + height: 100%; + border-radius: 8px; + overflow: hidden; +} + +.fullscreen-3d-close { + position: absolute; + top: 10px; + right: 10px; + background: red; + border: none; + border-radius: 6px; + color: white; + padding: 8px; + z-index: 10; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s ease; + cursor: pointer; +} + +.fullscreen-3d-close:hover { + background: rgba(0, 0, 0, 0.9); +} + +.sight-frame-media-stack.three-d-view { + background-color: #111 !important; +} + +.three-d-controls-bar { + position: absolute; + bottom: 0; + left: 0; + right: 0; + height: 44px; + display: flex; + flex-direction: row; + align-items: center; + gap: 15px; + padding: 0 30px; + background: rgba(64, 64, 64, 0.6); + z-index: 10; + pointer-events: none; +} + +.three-d-controls-bar .three-d-control-btn { + display: flex; + align-items: center; + justify-content: center; + background: transparent; + border: none; + cursor: pointer; + padding: 0; + width: min-content; + + pointer-events: auto; + color: white; + transition: opacity 0.2s ease; +} + +.three-d-controls-bar .three-d-control-btn:hover { + opacity: 0.85; +} + +.three-d-controls-bar .three-d-control-btn svg { + width: 28px; + height: 28px; + flex-shrink: 0; +} diff --git a/src/client/src/styles/Loader.css b/src/client/src/styles/Loader.css new file mode 100644 index 0000000..a95eccc --- /dev/null +++ b/src/client/src/styles/Loader.css @@ -0,0 +1,29 @@ +.loading-gif { + position: fixed; + width: 100vw; + height: 100vh; + z-index: 999999; + display: flex; + flex-direction: column; + justify-content: center; + align-items: center; + background-color: rgba(0, 0, 0, 0.8); +} + +.loading-info { + position: absolute; + top: 20px; + left: 20px; + color: white; + font-size: 13px; + font-weight: 500; + padding: 10px 14px; + background-color: rgba(0, 0, 0, 0.75); + border-radius: 6px; + z-index: 1000000; + font-family: monospace; + line-height: 1.4; + max-width: 500px; + word-wrap: break-word; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3); +} diff --git a/src/client/src/styles/MapLayer.css b/src/client/src/styles/MapLayer.css new file mode 100644 index 0000000..55b0724 --- /dev/null +++ b/src/client/src/styles/MapLayer.css @@ -0,0 +1,5 @@ +.map-layer { + position: absolute; + width: 100vw; + height: 100vh; +} diff --git a/src/client/src/styles/OverlayScrollbars.css b/src/client/src/styles/OverlayScrollbars.css new file mode 100644 index 0000000..a67ce46 --- /dev/null +++ b/src/client/src/styles/OverlayScrollbars.css @@ -0,0 +1,124 @@ +/* Стили для OverlayScrollbars, соответствующие текущим webkit scrollbar стилям */ + +/* Общие стили для всех скроллбаров */ +.os-scrollbar { + --os-size: 5px; +} + +.os-scrollbar-vertical { + width: 5px; +} + +.os-scrollbar-track { + background: rgba(255, 255, 255, 0.1) !important; + border-radius: 4px !important; +} + +.os-scrollbar-handle { + background: rgba(255, 255, 255, 0.9) !important; + border-radius: 1px !important; +} + +/* Специфичные стили для разных контейнеров */ + +/* .side-menu-sights-block */ +.side-menu-sights-block .os-scrollbar-track { + background: rgba(255, 255, 255, 0.1) !important; + border-radius: 4px !important; +} + +.side-menu-sights-block .os-scrollbar-handle { + background: rgba(255, 255, 255, 0.9) !important; + border-radius: 1px !important; +} + +/* .list-of-sights-content */ +.list-of-sights-content .os-scrollbar-track { + background: rgba(255, 255, 255, 0.1) !important; + border-radius: 4px !important; +} + +.list-of-sights-content .os-scrollbar-handle { + background: rgba(255, 255, 255, 0.9) !important; + border-radius: 1px !important; +} + +/* .sight-frame-text-wrapper */ +.sight-frame-text-wrapper .os-scrollbar-vertical { + padding: 7px 0px; + right: 7px !important; +} + +.sight-frame-text-wrapper .os-scrollbar-track { + background: rgba(255, 255, 255, 0.1) !important; + border-radius: 4px !important; +} + +.sight-frame-text-wrapper .os-scrollbar-handle { + background: rgba(255, 255, 255, 0.9) !important; + border-radius: 1px !important; +} + +/* .sight-frame-text (если используется отдельно) */ +.sight-frame-text .os-scrollbar-track { + background: linear-gradient( + to right, + transparent 35%, + #a6a6a6 50%, + transparent 65% + ) !important; + border-radius: 3px !important; +} + +.sight-frame-text .os-scrollbar-handle { + background: #fff !important; + border-radius: 3px !important; +} + +/* .alphabet - скрываем скроллбар */ +.alphabet .os-scrollbar { + display: none !important; +} + +/* .transfers-body */ +.transfers-body .os-scrollbar-track { + background: rgba(255, 255, 255, 0.1) !important; + border-radius: 4px !important; +} + +.transfers-body .os-scrollbar-handle { + background: rgba(255, 255, 255, 0.9) !important; + border-radius: 1px !important; +} + +/* .left-widget-text */ +.left-widget-text .os-scrollbar-track { + background: rgba(255, 255, 255, 0.1) !important; + border-radius: 4px !important; +} + +.left-widget-text .os-scrollbar-handle { + background: rgba(255, 255, 255, 0.9) !important; + border-radius: 1px !important; +} + +/* .cluster-sights-list */ +.cluster-sights-list .os-scrollbar-vertical { + width: 4px; + padding: 4px 0; + right: 4px !important; +} + +.cluster-sights-list .os-scrollbar-track { + background: transparent !important; + border-radius: 2px !important; +} + +.cluster-sights-list .os-scrollbar-handle { + background: rgba(255, 255, 255, 0.5) !important; + border-radius: 2px !important; +} + +.cluster-sights-list .os-scrollbar-handle:hover { + background: rgba(255, 255, 255, 0.7) !important; +} diff --git a/src/client/src/styles/RouteWidget.css b/src/client/src/styles/RouteWidget.css new file mode 100644 index 0000000..8ae2ef2 --- /dev/null +++ b/src/client/src/styles/RouteWidget.css @@ -0,0 +1,118 @@ +.route-widget-label.marquee { + display: inline-block; + animation: marquee 14s linear infinite; +} + +.route-widget-subtitle.marquee { + display: inline-block; + animation: marquee 14s linear infinite; +} + +@keyframes marquee { + 0% { + transform: translateX(0); + } + 50% { + transform: translateX(-50%); + } + 100% { + transform: translateX(0); + } +} + +.route-widget { + width: 361px; + height: 96px; + position: fixed; + display: inline-flex; + border-radius: 10px; + box-shadow: + 0 0 0 1px rgba(255, 255, 255, 0.3) inset, + /* Внутренняя рамка */ 4px 4px 12px 0 rgba(255, 255, 255, 0.12) inset; /* Ваш существующий внутренний shadow */ + padding: 1px; /* Чтобы контент не прилипал к рамке */ + background: + linear-gradient( + to bottom right, + rgba(255, 255, 255, 0.2) 0%, + rgba(255, 255, 255, 0) 100% + ), + rgba(179, 165, 152, 0.4); + backdrop-filter: blur(10px); + pointer-events: auto; + z-index: 10000001; +} + +.route-widget-number { + position: absolute; + width: fit-content; + left: 0px; + top: 0px; + min-width: 94px; + max-width: 100px; + height: 96px; + background-color: #fcd500; + color: black; + border-radius: 10px 0 0 10px; + display: flex; + justify-content: center; + align-items: center; + font-size: 70px; + padding: 14px; + font-weight: 900; +} + +.route-widget-number--3 { + font-size: 50px; +} + +.route-widget-number--4 { + font-size: 38px; +} + +.route-widget-number--5 { + font-size: 30px; +} + +.route-widget-content { + overflow: hidden; + width: 257px; + display: flex; + flex-direction: column; + margin-top: 13px; + margin-left: 109px; + margin-right: 9px; +} + +.route-widget-label { + white-space: nowrap; + font-size: 24px; + margin: 1px 0; + font-style: normal; + font-weight: 700; + line-height: 24px; +} + +.route-widget-label--medium { + font-size: 22px; + line-height: 22px; +} + +.route-widget-label--small { + font-size: 20px; + line-height: 20px; +} + +.route-widget-label--xsmall { + font-size: 18px; + line-height: 18px; +} + +.route-widget-subtitle { + white-space: nowrap; + color: #cbcbcb; + font-size: 12px; + font-style: normal; + font-weight: 400; + line-height: normal; + margin-top: 4px; +} diff --git a/src/client/src/styles/SideMenu.css b/src/client/src/styles/SideMenu.css new file mode 100644 index 0000000..e40e864 --- /dev/null +++ b/src/client/src/styles/SideMenu.css @@ -0,0 +1,287 @@ +.side-menu { + box-sizing: border-box; + padding-top: 46px; + display: flex; + flex-direction: column; + align-items: center; + height: 100vh; + width: 288px; + position: relative; + background: + linear-gradient( + 180deg, + rgba(255, 255, 255, 0.2) 0%, + rgba(255, 255, 255, 0) 100% + ), + var(--carrier-left, #006f3a); +} + +.appeal-button { + color: black; + padding: 8px 16px; + border-radius: 10px; + background: #fcd500; + font-size: 16px; + margin-top: 120px; + font-weight: 500; + width: 220px; + text-align: center; +} + +.side-menu-buttons { + width: 220px; +} + +.side-menu-button { + background-color: #fff; + color: #000; + text-align: center; + padding: 8px 16px; + margin-bottom: 16px; + border-radius: 10px; +} + +.side-menu-button--active { + background-color: #fcd500; + color: #000; +} + +.side-menu-bottom-section { + position: absolute; + bottom: 0; + left: 0; + width: 100%; + display: flex; + flex-direction: column; +} + +.side-menu-bottom-photo { + width: 100%; + margin-top: 32px; + pointer-events: none; + display: block; +} + +.side-menu-description { + margin-top: 36px; + text-align: center; + font-size: 16px; + font-style: italic; + font-weight: 300; + line-height: 150%; +} + +.side-menu-tag { + position: absolute; + z-index: 1; + bottom: 32px; + font-size: 16px; + font-style: normal; + font-weight: 300; + line-height: 130%; +} + +.side-menu-control-panel { + margin-left: 30px; + display: inline-flex; + flex-direction: column; + justify-content: space-between; + margin: 32px; + pointer-events: none; +} + +.side-menu-open { + margin-right: 10px; +} + +.side-menu-control-buttons { + position: fixed; + display: flex; + bottom: 32px; +} + +@keyframes icon-color-change { + 0% { + fill: #ffffff; + } + + 3.33% { + fill: var(--carrier-left, rgb(76, 175, 75)); + } + 50% { + fill: var(--carrier-left, rgb(76, 175, 75)); + } + 53.33% { + fill: #ffffff; + } + 100% { + fill: #ffffff; + } +} + +.icon-color-animated path { + fill: var(--animated-color, #ffffff); +} + +.side-menu-control-buttons { + position: fixed; + display: flex; + bottom: 32px; +} + +.side-menu-sights-title { + overflow: hidden; + z-index: 10; + display: flex; + position: relative; + align-items: center; + justify-content: center; + padding-bottom: 13px; + padding-top: 13px; + border-bottom: 2px solid #fff; + text-align: center; + font-family: "Roboto"; + font-size: 16px; + font-style: normal; + font-weight: 600; + line-height: 150%; +} + +.side-menu-sights-title svg { + width: 15px; + height: 10px; +} +.side-menu-sights-title:after { + content: ""; + position: absolute; + left: 50%; + transform: translateX(-50%); + top: -2px; + width: 100px; + height: 7px; + background-color: color-mix(in srgb, var(--carrier-left, #006f3a) 80%, black); + border-radius: 10px; +} + +.side-menu-sights { + z-index: 10; + overflow: hidden; + top: 250px; + bottom: 0; + display: flex; + flex-direction: column; + border-radius: 10px 10px 0px 0px; + background: + linear-gradient( + 114deg, + rgba(255, 255, 255, 0) 8.71%, + rgba(255, 255, 255, 0.16) 69.69% + ), + var(--carrier-left, #006f3a); + position: absolute; + width: 288px; + transform: translateY(100%); + opacity: 0; + transition: + transform 0.3s ease-out, + opacity 0.3s ease-out; +} + +.side-menu-sights.slide-in { + transform: translateY(0); + opacity: 1; +} + +.side-menu-sights.slide-out { + transform: translateY(100%); +} + +.side-menu-sights-block { + flex: 1; + min-height: 0; + margin-top: 8px; + touch-action: none; + overscroll-behavior: contain; + width: 100%; + box-sizing: border-box; + overflow-x: hidden; + overflow-y: auto; +} + +.side-menu-sight { + padding-bottom: 2px; + margin-bottom: 6px; + margin-top: 6px; + border-bottom: 1px solid + color-mix(in srgb, var(--carrier-left, #006f3a) 80%, black); + font-family: "Roboto"; + font-size: 16px; + font-weight: 300; + line-height: 150%; + overflow-x: hidden; + scrollbar-width: none; + position: relative; +} + +.side-menu-sight-wrapper { + margin-left: -20px; + padding-left: 20px; + background-color: transparent; + transition: background-color 0.3s ease; +} + +.side-menu-sight-selected-wrapper { + background-color: rgba(0, 0, 0, 0.2); +} + +.side-menu-sight > span { + display: inline-block; + white-space: nowrap; +} + +.side-menu-sight > span.marquee-text { + animation: side-menu-marquee 14s linear infinite; +} + +@keyframes side-menu-marquee { + 0% { + transform: translateX(0); + } + 50% { + transform: translateX(calc(-100% + var(--container-width, 248px))); + } + 100% { + transform: translateX(0); + } +} + +.side-menu-control-lang-open { + gap: 10px; + display: inline-flex; +} + +.side-menu-control-lang-open .active { + fill: var(--animated-color, #fff); +} + +.back-to-nearest-button { + width: 30px; + height: 30px; + position: absolute; + left: 70px; + bottom: 20px; + + transition: transform 0.1s ease; +} + +.side-menu-sorted { + width: 48px; + margin-left: 10px; + height: 48px; + background-color: #fff; + display: flex; + align-items: center; + justify-content: center; + border-radius: 50%; + cursor: pointer; +} diff --git a/src/client/src/styles/TouchableLayout.css b/src/client/src/styles/TouchableLayout.css new file mode 100644 index 0000000..dc79d6b --- /dev/null +++ b/src/client/src/styles/TouchableLayout.css @@ -0,0 +1,82 @@ +.scrollable-container { + display: flex; + flex-direction: column; + align-items: flex-start; + width: auto; + max-width: 100%; + box-sizing: border-box; +} + +.scrollable-viewport { + display: flex; + align-items: stretch; + width: 100%; + position: relative; +} + +.scrollable { + flex: 1; + min-width: 0; + min-height: 0; /* позволяет flex-элементу сжиматься и создавать overflow для скролла */ + overflow-y: auto; + overflow-x: hidden; + touch-action: none; + cursor: grab; + /* Скрыть нативный скроллбар */ + scrollbar-width: none; + -ms-overflow-style: none; +} + +.scrollable::-webkit-scrollbar { + display: none; + width: 0; + height: 0; +} + +.scrollable:active { + cursor: grabbing; +} + +.custom-scrollbar-track { + width: 5px; + flex-shrink: 0; + position: relative; + background: rgba(255, 255, 255, 0.2); + border-radius: 3px; + overflow: hidden; + transition: opacity 0.5s ease; +} + +.custom-scrollbar-thumb { + position: absolute; + right: 0; + width: 5px; + min-height: 60px; + background: #fff; + border-radius: 3px; + pointer-events: auto; + cursor: grab; + touch-action: none; + transition: opacity 0.5s ease, height 0.5s ease; +} + +.custom-scrollbar-thumb:active { + cursor: grabbing; +} + +.side-menu-sights-block .scrollable-viewport { + height: calc(98%); +} + +.side-menu-sights-block .scrollable { + height: 100%; + padding-left: 20px; +} + +.list-of-sights-content .scrollable-viewport { + height: 700px; +} + +.list-of-sights-content .scrollable { + height: 100%; +} diff --git a/src/client/src/styles/TransferWidget.css b/src/client/src/styles/TransferWidget.css new file mode 100644 index 0000000..25e16c2 --- /dev/null +++ b/src/client/src/styles/TransferWidget.css @@ -0,0 +1,13 @@ +.transferLabel { + color: #fff; + font-size: 18px; + font-weight: 600; + line-height: 150%; +} + +.transfer-line { + gap: 0px; + display: flex; + align-items: center; + margin-top: 8px; +} diff --git a/src/client/src/styles/VideoPreview.css b/src/client/src/styles/VideoPreview.css new file mode 100644 index 0000000..0b7c96a --- /dev/null +++ b/src/client/src/styles/VideoPreview.css @@ -0,0 +1,72 @@ +/* Основные стили */ +.video-preview-wrapper { +} + +.loading-message, +.error-message { + padding: 20px; + text-align: center; + color: #666; +} + +/* Контейнер видео */ +.video-container { + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + z-index: 2147483646; + background-color: #000; + display: flex; + justify-content: center; + align-items: center; + animation-duration: 0.5s; + animation-fill-mode: forwards; +} + +/* Видео на весь экран */ +.video-fullscreen { + width: 100%; + height: 100%; + object-fit: cover; +} + +/* Анимации */ +@keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +@keyframes fadeOut { + from { + opacity: 1; + } + to { + opacity: 0; + } +} + +.video-enter { + animation-name: fadeIn; + pointer-events: auto; +} + +.video-exit { + animation-name: fadeOut; + pointer-events: none; +} + +/* Сообщение об ошибке */ +.video-error-message { + position: absolute; + color: white; + background-color: rgba(0, 0, 0, 0.7); + padding: 15px 25px; + border-radius: 5px; + font-size: 1.2rem; +} diff --git a/src/client/src/styles/WeatherWidget.css b/src/client/src/styles/WeatherWidget.css new file mode 100644 index 0000000..8e6536f --- /dev/null +++ b/src/client/src/styles/WeatherWidget.css @@ -0,0 +1,108 @@ +.weather-widget { + padding: 12px; + display: inline-flex; + flex-direction: column; + align-items: center; + position: fixed; + margin-top: 130px; + z-index: -11; + border-radius: 10px; + width: 230px; + backdrop-filter: blur(10px); + + box-shadow: + 0 0 0 1px rgba(255, 255, 255, 0.3) inset, + 4px 4px 12px 0 rgba(255, 255, 255, 0.12) inset; + background: + linear-gradient( + to bottom right, + rgba(255, 255, 255, 0.2) 0%, + rgba(255, 255, 255, 0) 100% + ), + rgba(179, 165, 152, 0.4); +} + +.weather-widget-time { + font-size: 48px; + font-weight: 600; + line-height: normal; +} + +.weather-widget-date { + font-size: 14px; + font-style: normal; + font-weight: 300; + line-height: normal; +} + +.weather-widget-line { + margin: 8px 0; + width: 100%; + height: 2px; + background-color: #999; +} + +.weather-widget-content { + width: 100%; + display: flex; + gap: 10px; + justify-content: space-between; + align-items: center; +} + +.weather-widget-visual { + gap: 4px; + display: flex; + flex-direction: column; + justify-content: center; +} + +.weather-icon { + width: 72px; + height: 72px; +} + +.weather-widget-temp { + text-align: center; + font-size: 52px; + font-weight: 600; + line-height: normal; + letter-spacing: -3.9px; +} + +.weather-widget-temp-state { + text-align: center; + font-size: 16px; + font-style: normal; + font-weight: 400; + line-height: normal; +} + +.weather-widget-data-line { + display: flex; + align-items: center; +} + +.weather-small-icon { + width: 16px; + height: 16px; +} + +.weather-widget-data { + display: flex; + flex-direction: column; + gap: 8px; +} + +.weather-widget-data-value { + margin-left: 9px; +} + +.weather-bold-text { + font-weight: 900; +} + +.weather-widget-data-value { + font-weight: 400; + white-space: nowrap; +} diff --git a/src/client/src/utils/RoutePathAnimator.ts b/src/client/src/utils/RoutePathAnimator.ts new file mode 100644 index 0000000..30e44d1 --- /dev/null +++ b/src/client/src/utils/RoutePathAnimator.ts @@ -0,0 +1,308 @@ +export class RoutePathAnimator { + private routePath: Float32Array = new Float32Array(); + private routeDistances: Float64Array = new Float64Array(); + private totalRouteLength: number = 0; + + private targetDistance: number = 0; + private currentDistance: number = 0; + private currentSegIndex: number = -1; + + private isAnimating: boolean = false; + private animationId: number | null = null; + private lastFrameTime: number | null = null; + private duration: number; + + private fallbackCurX: number = 0; + private fallbackCurY: number = 0; + private fallbackTgtX: number = 0; + private fallbackTgtY: number = 0; + private fallbackInitialized: boolean = false; + + private onUpdate: (pos: { x: number; y: number }) => void; + private onComplete: (() => void) | null; + + private static readonly BASE_DURATION = 800; + private static readonly MAX_DURATION = 2500; + private static readonly MIN_SMOOTHING_MS = 400; + private static readonly NORMAL_DISTANCE = 30; + private static readonly SETTLE_EPS = 0.02; + private static readonly MAX_DT_MS = 100; + private static readonly MAX_SPEED_UNITS_PER_SEC = 140; + + constructor( + onUpdate: (pos: { x: number; y: number }) => void, + onComplete?: () => void, + duration: number = 800, + ) { + this.onUpdate = onUpdate; + this.onComplete = onComplete ?? null; + this.duration = duration; + } + + setRoutePath(routePath: Float32Array, syncMapXY?: { x: number; y: number }): void { + this.routePath = routePath; + const n = routePath.length / 2; + if (n < 2) { + this.routeDistances = new Float64Array(); + this.totalRouteLength = 0; + return; + } + this.routeDistances = new Float64Array(n); + this.routeDistances[0] = 0; + for (let i = 1; i < n; i++) { + const dx = routePath[i * 2] - routePath[(i - 1) * 2]; + const dy = routePath[i * 2 + 1] - routePath[(i - 1) * 2 + 1]; + this.routeDistances[i] = this.routeDistances[i - 1] + Math.hypot(dx, dy); + } + this.totalRouteLength = this.routeDistances[n - 1]; + + if (syncMapXY) { + const proj = this.projectToRoute(syncMapXY.x, syncMapXY.y); + this.currentDistance = proj.distance; + this.targetDistance = proj.distance; + const pt = this.distanceToPoint(this.currentDistance); + this.currentSegIndex = pt.segIndex; + this.onUpdate({ x: pt.x, y: pt.y }); + } + } + + projectToRoute( + x: number, + y: number, + ): { segIndex: number; t: number; distance: number } { + const path = this.routePath; + const n = path.length / 2; + if (n < 2) return { segIndex: 0, t: 0, distance: 0 }; + + let bestIndex = 0; + let bestT = 0; + let bestDist = Infinity; + + for (let i = 0; i < n - 1; i++) { + const p1x = path[i * 2], + p1y = path[i * 2 + 1]; + const p2x = path[(i + 1) * 2], + p2y = path[(i + 1) * 2 + 1]; + const dx = p2x - p1x, + dy = p2y - p1y; + const len2 = dx * dx + dy * dy; + if (len2 === 0) continue; + + const t = Math.max(0, Math.min(1, ((x - p1x) * dx + (y - p1y) * dy) / len2)); + const px = p1x + t * dx, + py = p1y + t * dy; + const d = Math.hypot(x - px, y - py); + + if (d < bestDist) { + bestDist = d; + bestIndex = i; + bestT = t; + } + } + + const segLength = + this.routeDistances[bestIndex + 1] - this.routeDistances[bestIndex]; + const distance = this.routeDistances[bestIndex] + bestT * segLength; + + return { segIndex: bestIndex, t: bestT, distance }; + } + + distanceToPoint(distance: number): { x: number; y: number; segIndex: number } { + const dists = this.routeDistances; + const path = this.routePath; + const n = dists.length; + + if (n < 2) return { x: 0, y: 0, segIndex: -1 }; + + distance = Math.max(0, Math.min(distance, this.totalRouteLength)); + + let lo = 0, + hi = n - 1; + while (lo < hi - 1) { + const mid = (lo + hi) >> 1; + if (dists[mid] <= distance) lo = mid; + else hi = mid; + } + + const segStart = dists[lo]; + const segEnd = dists[hi]; + const segLen = segEnd - segStart; + const t = segLen > 0 ? (distance - segStart) / segLen : 0; + + const x = path[lo * 2] + t * (path[hi * 2] - path[lo * 2]); + const y = path[lo * 2 + 1] + t * (path[hi * 2 + 1] - path[lo * 2 + 1]); + + return { x, y, segIndex: lo }; + } + + private computeSmoothingTau(routeDistDelta: number, durationOverride?: number): number { + if (durationOverride !== undefined) { + return Math.max(durationOverride, RoutePathAnimator.MIN_SMOOTHING_MS); + } + return Math.min( + Math.max( + RoutePathAnimator.BASE_DURATION * + Math.max(1, routeDistDelta / RoutePathAnimator.NORMAL_DISTANCE), + RoutePathAnimator.MIN_SMOOTHING_MS, + ), + RoutePathAnimator.MAX_DURATION, + ); + } + + animateTo(x: number, y: number, duration?: number): void { + if (this.routeDistances.length < 2) { + this.fallbackTgtX = x; + this.fallbackTgtY = y; + if (!this.fallbackInitialized) { + this.fallbackCurX = x; + this.fallbackCurY = y; + this.fallbackInitialized = true; + this.onUpdate({ x, y }); + } + this.duration = this.computeSmoothingTau( + Math.hypot(this.fallbackTgtX - this.fallbackCurX, this.fallbackTgtY - this.fallbackCurY), + duration, + ); + this.isAnimating = true; + this.ensureTickLoop(); + return; + } + + const target = this.projectToRoute(x, y); + this.targetDistance = target.distance; + + const routeDistDelta = Math.abs(this.targetDistance - this.currentDistance); + this.duration = this.computeSmoothingTau(routeDistDelta, duration); + + this.isAnimating = true; + this.ensureTickLoop(); + } + + private ensureTickLoop(): void { + if (!this.animationId) { + this.lastFrameTime = null; + this.animationId = requestAnimationFrame(this.tick); + } + } + + setPosition(x: number, y: number): void { + this.stop(); + if (this.routeDistances.length >= 2) { + const proj = this.projectToRoute(x, y); + this.currentDistance = proj.distance; + this.targetDistance = proj.distance; + this.currentSegIndex = proj.segIndex; + const pt = this.distanceToPoint(proj.distance); + this.onUpdate({ x: pt.x, y: pt.y }); + } else { + this.fallbackCurX = x; + this.fallbackCurY = y; + this.fallbackTgtX = x; + this.fallbackTgtY = y; + this.fallbackInitialized = true; + this.onUpdate({ x, y }); + } + } + + stop(): void { + this.isAnimating = false; + this.lastFrameTime = null; + if (this.animationId) { + cancelAnimationFrame(this.animationId); + this.animationId = null; + } + } + + getCurrentPosition(): { x: number; y: number } { + if (this.routeDistances.length < 2) { + return { x: this.fallbackCurX, y: this.fallbackCurY }; + } + const pt = this.distanceToPoint(this.currentDistance); + return { x: pt.x, y: pt.y }; + } + + getCurrentSegIndex(): number { + return this.currentSegIndex; + } + + private stepRoute(alpha: number, dt: number): boolean { + const delta = this.targetDistance - this.currentDistance; + let step = Math.abs(delta) < RoutePathAnimator.SETTLE_EPS ? 0 : delta * alpha; + const maxStep = + RoutePathAnimator.MAX_SPEED_UNITS_PER_SEC * Math.max(dt / 1000, 1e-6); + if (Math.abs(step) > maxStep) { + step = Math.sign(step) * maxStep; + } + if (Math.abs(delta) < RoutePathAnimator.SETTLE_EPS) { + this.currentDistance = this.targetDistance; + } else { + this.currentDistance += step; + } + const pt = this.distanceToPoint(this.currentDistance); + this.currentSegIndex = pt.segIndex; + this.onUpdate({ x: pt.x, y: pt.y }); + return Math.abs(this.targetDistance - this.currentDistance) < RoutePathAnimator.SETTLE_EPS; + } + + private stepFallback(alpha: number, dt: number): boolean { + const dx = this.fallbackTgtX - this.fallbackCurX; + const dy = this.fallbackTgtY - this.fallbackCurY; + const dist = Math.hypot(dx, dy); + if (dist < RoutePathAnimator.SETTLE_EPS) { + this.fallbackCurX = this.fallbackTgtX; + this.fallbackCurY = this.fallbackTgtY; + } else { + let sx = dx * alpha; + let sy = dy * alpha; + const maxStep = + RoutePathAnimator.MAX_SPEED_UNITS_PER_SEC * Math.max(dt / 1000, 1e-6); + const stepLen = Math.hypot(sx, sy); + if (stepLen > maxStep && stepLen > 0) { + const k = maxStep / stepLen; + sx *= k; + sy *= k; + } + this.fallbackCurX += sx; + this.fallbackCurY += sy; + } + this.onUpdate({ x: this.fallbackCurX, y: this.fallbackCurY }); + return ( + Math.hypot(this.fallbackTgtX - this.fallbackCurX, this.fallbackTgtY - this.fallbackCurY) < + RoutePathAnimator.SETTLE_EPS + ); + } + + private tick = (timestamp: number): void => { + if (!this.isAnimating) return; + + let dt: number; + if (this.lastFrameTime === null) { + dt = 16.67; + this.lastFrameTime = timestamp; + } else { + dt = Math.min(Math.max(timestamp - this.lastFrameTime, 0), RoutePathAnimator.MAX_DT_MS); + this.lastFrameTime = timestamp; + } + + const tau = Math.max(this.duration, 50); + const alpha = 1 - Math.exp(-dt / tau); + + const settled = + this.routeDistances.length >= 2 + ? this.stepRoute(alpha, dt) + : this.stepFallback(alpha, dt); + + if (settled) { + this.finishTickLoop(); + } else { + this.animationId = requestAnimationFrame(this.tick); + } + }; + + private finishTickLoop(): void { + this.isAnimating = false; + this.animationId = null; + this.lastFrameTime = null; + this.onComplete?.(); + } +} diff --git a/src/client/src/utils/animationUtils.ts b/src/client/src/utils/animationUtils.ts new file mode 100644 index 0000000..0b01130 --- /dev/null +++ b/src/client/src/utils/animationUtils.ts @@ -0,0 +1,302 @@ +/** + * Утилиты для анимации + * Основано на логике анимации из HTML файла (a.html) + */ + +/** + * Линейная интерполяция между двумя значениями + * @param from - начальное значение + * @param to - конечное значение + * @param t - коэффициент интерполяции (0-1) + * @returns интерполированное значение + */ +export const lerp = (from: number, to: number, t: number): number => { + return from + (to - from) * t; +}; + +/** + * Интерполяция угла с учетом кратчайшего пути + * @param from - начальный угол в радианах + * @param to - конечный угол в радианах + * @param t - коэффициент интерполяции (0-1) + * @returns интерполированный угол в радианах + */ +export const lerpAngle = (from: number, to: number, t: number): number => { + // Нормализуем углы к диапазону 0-2π + from = ((from % (Math.PI * 2)) + Math.PI * 2) % (Math.PI * 2); + to = ((to % (Math.PI * 2)) + Math.PI * 2) % (Math.PI * 2); + + // Найдем кратчайший путь между углами + let diff = to - from; + if (diff > Math.PI) { + diff -= Math.PI * 2; + } else if (diff < -Math.PI) { + diff += Math.PI * 2; + } + + return from + diff * t; +}; + +/** + * Класс для анимации позиции + * Основано на логике анимации из HTML файла (a.html) + */ +export class PositionAnimator { + private currentX: number; + private currentY: number; + private targetX: number; + private targetY: number; + private isAnimating: boolean = false; + private animationId: number | null = null; + private onUpdate: (position: { x: number; y: number }) => void; + private onComplete: () => void; + private duration: number; + private startTime: number | null = null; + + constructor( + onUpdate: (position: { x: number; y: number }) => void, + onComplete: () => void = () => {}, + duration: number = 500 + ) { + this.currentX = 0; + this.currentY = 0; + this.targetX = 0; + this.targetY = 0; + this.onUpdate = onUpdate; + this.onComplete = onComplete; + this.duration = duration; + } + + /** + * Анимировать к новой позиции + */ + animateTo(x: number, y: number, duration?: number): void { + this.targetX = x; + this.targetY = y; + this.duration = duration || this.duration; + this.startTime = null; + this.isAnimating = true; + + if (this.animationId) { + cancelAnimationFrame(this.animationId); + } + + this.animationId = requestAnimationFrame(this.animate); + } + + /** + * Установить позицию мгновенно + */ + setPosition(x: number, y: number): void { + this.currentX = x; + this.currentY = y; + this.targetX = x; + this.targetY = y; + this.isAnimating = false; + this.onUpdate({ x, y }); + } + + /** + * Остановить анимацию + */ + stop(): void { + this.isAnimating = false; + if (this.animationId) { + cancelAnimationFrame(this.animationId); + this.animationId = null; + } + } + + private animate = (timestamp: number): void => { + if (!this.isAnimating) return; + + if (!this.startTime) { + this.startTime = timestamp; + } + + const elapsed = timestamp - this.startTime; + const progress = Math.min(elapsed / this.duration, 1); + + // Используем easeOutCubic для плавной анимации + const easedProgress = 1 - Math.pow(1 - progress, 3); + + this.currentX = lerp(this.currentX, this.targetX, easedProgress); + this.currentY = lerp(this.currentY, this.targetY, easedProgress); + + this.onUpdate({ x: this.currentX, y: this.currentY }); + + if (progress < 1) { + this.animationId = requestAnimationFrame(this.animate); + } else { + this.isAnimating = false; + this.onComplete(); + } + }; +} + +/** + * Передискретизация пути для обеспечения равномерного расстояния между точками + * @param path - массив [lat, lon] или [x, y] + * @param segmentLength - желаемое расстояние между точками (в единицах координат) + * @returns новый массив точек + */ +export const resamplePath = (path: T[], segmentLength: number): T[] => { + if (path.length < 2) return path; + + const newPath: T[] = [path[0]]; + let leftover = 0; + + for (let i = 0; i < path.length - 1; i++) { + const p1 = path[i]; + const p2 = path[i + 1]; + const dx = p2[0] - p1[0]; + const dy = p2[1] - p1[1]; + const dist = Math.sqrt(dx * dx + dy * dy); + + if (dist === 0) continue; + + let currentDist = segmentLength - leftover; + while (currentDist <= dist) { + const t = currentDist / dist; + const point = new Array(p1.length) as T; + for (let j = 0; j < p1.length; j++) { + point[j] = p1[j] + (p2[j] - p1[j]) * t; + } + newPath.push(point); + currentDist += segmentLength; + } + leftover = dist - (currentDist - segmentLength); + } + + // Добавляем последнюю точку, если она существенно отличается от последней добавленной + const lastP = path[path.length - 1]; + const lastNewP = newPath[newPath.length - 1]; + let isDifferent = false; + for (let j = 0; j < lastP.length; j++) { + if (Math.abs(lastP[j] - lastNewP[j]) > 0.0000001) { + isDifferent = true; + break; + } + } + + if (isDifferent) { + newPath.push(lastP); + } + + return newPath; +}; + +/** + * Класс для анимации по полярным координатам + * Основано на логике анимации из HTML файла (a.html) + */ +export class PolarAnimator { + private currentAngle: number; + private currentDistance: number; + private targetAngle: number; + private targetDistance: number; + private centerX: number; + private centerY: number; + private isAnimating: boolean = false; + private animationId: number | null = null; + private onUpdate: (position: { x: number; y: number }) => void; + private onComplete: () => void; + private duration: number; + private startTime: number | null = null; + + constructor( + onUpdate: (position: { x: number; y: number }) => void, + onComplete: () => void = () => {}, + duration: number = 500 + ) { + this.currentAngle = 0; + this.currentDistance = 0; + this.targetAngle = 0; + this.targetDistance = 0; + this.centerX = 0; + this.centerY = 0; + this.onUpdate = onUpdate; + this.onComplete = onComplete; + this.duration = duration; + } + + /** + * Анимировать к новой позиции по полярным координатам + */ + animateToPolar( + centerX: number, + centerY: number, + targetAngle: number, + targetDistance: number, + duration?: number + ): void { + this.centerX = centerX; + this.centerY = centerY; + this.targetAngle = targetAngle; + this.targetDistance = targetDistance; + this.duration = duration || this.duration; + this.startTime = null; + this.isAnimating = true; + + if (this.animationId) { + cancelAnimationFrame(this.animationId); + } + + this.animationId = requestAnimationFrame(this.animate); + } + + /** + * Установить позицию мгновенно + */ + setPosition(angle: number, distance: number): void { + this.currentAngle = angle; + this.currentDistance = distance; + this.targetAngle = angle; + this.targetDistance = distance; + this.isAnimating = false; + + const x = this.centerX + distance * Math.cos(angle); + const y = this.centerY - distance * Math.sin(angle); + this.onUpdate({ x, y }); + } + + /** + * Остановить анимацию + */ + stop(): void { + this.isAnimating = false; + if (this.animationId) { + cancelAnimationFrame(this.animationId); + this.animationId = null; + } + } + + private animate = (timestamp: number): void => { + if (!this.isAnimating) return; + + if (!this.startTime) { + this.startTime = timestamp; + } + + const elapsed = timestamp - this.startTime; + const progress = Math.min(elapsed / this.duration, 1); + + // Используем easeOutCubic для плавной анимации + const easedProgress = 1 - Math.pow(1 - progress, 3); + + this.currentAngle = lerpAngle(this.currentAngle, this.targetAngle, easedProgress); + this.currentDistance = lerp(this.currentDistance, this.targetDistance, easedProgress); + + const x = this.centerX + this.currentDistance * Math.cos(this.currentAngle); + const y = this.centerY - this.currentDistance * Math.sin(this.currentAngle); + + this.onUpdate({ x, y }); + + if (progress < 1) { + this.animationId = requestAnimationFrame(this.animate); + } else { + this.isAnimating = false; + this.onComplete(); + } + }; +} diff --git a/src/client/src/utils/getMediaType.js b/src/client/src/utils/getMediaType.js new file mode 100644 index 0000000..0c4612b --- /dev/null +++ b/src/client/src/utils/getMediaType.js @@ -0,0 +1,16 @@ +export const getMediaType = (typeNumber) => { + switch (typeNumber) { + case 2: + return "video"; + case 1: + case 3: + return "image"; + + case 5: + return "panorama"; + case 6: + return "3d"; + default: + return "unknown"; + } +}; diff --git a/src/client/src/utils/getWeatherIcon.js b/src/client/src/utils/getWeatherIcon.js new file mode 100644 index 0000000..a78174a --- /dev/null +++ b/src/client/src/utils/getWeatherIcon.js @@ -0,0 +1,45 @@ + +import sunnyIcon from '../assets/icons/clear-day.svg' +import cloudyIcon from '../assets/icons/cloudy.svg' +import rainyIcon from '../assets/icons/rainy.svg' +import snowyIcon from '../assets/icons/snowy.svg' +import thunderIcon from '../assets/icons/thunderstorms.svg' +import fogIcon from '../assets/icons/fog.svg' + +const getWeatherIcon = (status) => { + const statusMap = { + 'солнечно': 'clear-day', + 'облачно': 'cloudy', + 'мелкий дождь': 'rainy', + 'дождливо': 'rainy', + 'снег': 'snowy', + 'гроза': 'thunderstorms', + 'туман': 'fog', + 'clear-day': 'clear-day', + 'cloudy': 'cloudy', + 'rainy': 'rainy', + 'snowy': 'snowy', + 'thunderstorms': 'thunderstorms', + 'fog': 'fog', + }; + const normalizedStatus = statusMap[status] || status; + + switch (normalizedStatus) { + case 'clear-day': + return sunnyIcon; + case 'cloudy': + return cloudyIcon; + case 'rainy': + return rainyIcon; + case 'snowy': + return snowyIcon; + case 'thunderstorms': + return thunderIcon; + case 'fog': + return fogIcon; + default: + return sunnyIcon; + } +} + +export default getWeatherIcon; diff --git a/src/client/src/utils/routeStationsUtils.js b/src/client/src/utils/routeStationsUtils.js new file mode 100644 index 0000000..875afd6 --- /dev/null +++ b/src/client/src/utils/routeStationsUtils.js @@ -0,0 +1,197 @@ +const getDistance = (p1, p2) => { + return Math.sqrt( + Math.pow(p1.longitude - p2.longitude, 2) + + Math.pow(p1.latitude - p2.latitude, 2) + ); +}; + +const findNearestPointOnPath = (point, path) => { + if (!path || path.length < 2) { + return { index: -1, distance: Infinity, progress: 0 }; + } + + let minDistance = Infinity; + let bestIndex = -1; + let bestProgress = 0; + + for (let i = 0; i < path.length - 1; i++) { + const p1 = { latitude: path[i][0], longitude: path[i][1] }; + const p2 = { latitude: path[i + 1][0], longitude: path[i + 1][1] }; + + const dx = p2.longitude - p1.longitude; + const dy = p2.latitude - p1.latitude; + const lineLengthSq = dx * dx + dy * dy; + + if (lineLengthSq === 0) continue; + + const t = + ((point.longitude - p1.longitude) * dx + + (point.latitude - p1.latitude) * dy) / + lineLengthSq; + const projectionT = Math.max(0, Math.min(1, t)); + + const projectedX = p1.longitude + projectionT * dx; + const projectedY = p1.latitude + projectionT * dy; + + const dist = getDistance( + point, + { longitude: projectedX, latitude: projectedY } + ); + + if (dist < minDistance) { + minDistance = dist; + bestIndex = i; + bestProgress = projectionT; + } + } + + return { index: bestIndex, distance: minDistance, progress: bestProgress }; +}; + +const getStationPositionOnPath = (station, path) => { + const result = findNearestPointOnPath( + { latitude: station.latitude, longitude: station.longitude }, + path + ); + return result.index + result.progress; +}; + +export const orderStationsByRoute = (stations, routePath, startStopId, endStopId) => { + if (!stations || !routePath || stations.length === 0 || routePath.length === 0) { + return stations || []; + } + + const startStation = stations.find( + (s) => String(s.id) === String(startStopId) + ); + const endStation = stations.find( + (s) => String(s.id) === String(endStopId) + ); + + if (!startStation || !endStation) { + console.warn("Не найдены начальная или конечная остановки"); + return stations; + } + + const stationsWithPosition = stations.map((station) => ({ + station, + position: getStationPositionOnPath(station, routePath), + })); + + stationsWithPosition.sort((a, b) => a.position - b.position); + + const startPosition = stationsWithPosition.find( + (s) => s.station.id === startStation.id + )?.position; + const endPosition = stationsWithPosition.find( + (s) => s.station.id === endStation.id + )?.position; + + if (startPosition > endPosition) { + stationsWithPosition.reverse(); + } + + return stationsWithPosition.map((item) => item.station); +}; + +export const getNextStation = (currentPosition, orderedStations, currentStationId = null) => { + if (!orderedStations || orderedStations.length === 0 || !currentPosition) { + return null; + } + + let currentIndex = -1; + if (currentStationId) { + currentIndex = orderedStations.findIndex( + (s) => String(s.id) === String(currentStationId) + ); + } + + if (currentIndex >= 0 && currentIndex < orderedStations.length - 1) { + return orderedStations[currentIndex + 1]; + } + + if (currentIndex < 0) { + let nearestIndex = -1; + let minDistance = Infinity; + + for (let i = 0; i < orderedStations.length; i++) { + const distance = getDistance(currentPosition, { + latitude: orderedStations[i].latitude, + longitude: orderedStations[i].longitude, + }); + if (distance < minDistance) { + minDistance = distance; + nearestIndex = i; + } + } + + if (nearestIndex >= 0) { + const nearestStation = orderedStations[nearestIndex]; + const nextStation = orderedStations[nearestIndex + 1]; + + if (nextStation) { + const distanceToNearest = getDistance(currentPosition, { + latitude: nearestStation.latitude, + longitude: nearestStation.longitude, + }); + const distanceToNext = getDistance(currentPosition, { + latitude: nextStation.latitude, + longitude: nextStation.longitude, + }); + + if (distanceToNext < distanceToNearest) { + return nextStation; + } + } + + if (nearestIndex < orderedStations.length - 1) { + return orderedStations[nearestIndex + 1]; + } + } + } + + return null; +}; + +export const getTargetStation = (currentPosition, orderedStations, routePath) => { + if (!orderedStations || orderedStations.length === 0 || !currentPosition || !routePath || routePath.length < 2) { + return null; + } + + let nearestIndex = -1; + let minDistance = Infinity; + + for (let i = 0; i < orderedStations.length; i++) { + const distance = getDistance(currentPosition, { + latitude: orderedStations[i].latitude, + longitude: orderedStations[i].longitude, + }); + if (distance < minDistance) { + minDistance = distance; + nearestIndex = i; + } + } + + if (nearestIndex < 0) { + return null; + } + + const nearestStation = orderedStations[nearestIndex]; + const nextStation = orderedStations[nearestIndex + 1]; + + const tramPosition = findNearestPointOnPath(currentPosition, routePath); + const tramPosOnRoute = tramPosition.index + tramPosition.progress; + + const stationPosition = findNearestPointOnPath( + { latitude: nearestStation.latitude, longitude: nearestStation.longitude }, + routePath + ); + const stationPosOnRoute = stationPosition.index + stationPosition.progress; + + if (tramPosOnRoute > stationPosOnRoute && nextStation) { + return nextStation; + } + + return nearestStation; +}; + diff --git a/src/client/src/utils/translateWeatherStatus.js b/src/client/src/utils/translateWeatherStatus.js new file mode 100644 index 0000000..f4d9fbc --- /dev/null +++ b/src/client/src/utils/translateWeatherStatus.js @@ -0,0 +1,40 @@ +export const translateWeatherStatus = (status, selectedLanguage) => { + const translations = { + 'солнечно': { + 'ru': 'солнечно', + 'en': 'Sunny', + 'zh': '晴朗' + }, + 'облачно': { + 'ru': 'облачно', + 'en': 'Cloudy', + 'zh': '多云' + }, + 'мелкий дождь': { + 'ru': 'мелкий дождь', + 'en': 'Light Rain', + 'zh': '小雨' + }, + 'дождливо': { + 'ru': 'дождливо', + 'en': 'Rainy', + 'zh': '有雨' + }, + 'снег': { + 'ru': 'снег', + 'en': 'Snowy', + 'zh': '下雪' + }, + 'гроза': { + 'ru': 'гроза', + 'en': 'Thunderstorm', + 'zh': '雷暴' + }, + 'туман': { + 'ru': 'туман', + 'en': 'Foggy', + 'zh': '有雾' + }, + }; + return translations[status]?.[selectedLanguage] || status; + }; diff --git a/src/client/src/utils/truncateTitle.js b/src/client/src/utils/truncateTitle.js new file mode 100644 index 0000000..29a6031 --- /dev/null +++ b/src/client/src/utils/truncateTitle.js @@ -0,0 +1,7 @@ +export const truncateTitle = (text, maxLength) => { + if (!text) return ''; + if (text.length > maxLength) { + return text.substring(0, maxLength) + '...'; + } + return text; +}; diff --git a/src/entities/navigation/model/index.ts b/src/entities/navigation/model/index.ts index 5048640..5fe2161 100644 --- a/src/entities/navigation/model/index.ts +++ b/src/entities/navigation/model/index.ts @@ -6,6 +6,7 @@ export interface NavigationItem { icon: LucideIcon; path?: string; for_admin?: boolean; + required_resource?: string; onClick?: () => void; nestedItems?: NavigationItem[]; } diff --git a/src/entities/navigation/ui/index.tsx b/src/entities/navigation/ui/index.tsx index fb9b91f..c5e8e77 100644 --- a/src/entities/navigation/ui/index.tsx +++ b/src/entities/navigation/ui/index.tsx @@ -10,7 +10,6 @@ import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; import type { NavigationItem } from "../model"; import { useNavigate, useLocation } from "react-router-dom"; import { Plus } from "lucide-react"; -import { authStore } from "@shared"; interface NavigationItemProps { item: NavigationItem; @@ -31,20 +30,10 @@ export const NavigationItemComponent: React.FC = ({ const navigate = useNavigate(); const location = useLocation(); const [isExpanded, setIsExpanded] = React.useState(false); - const { payload } = authStore; - - // @ts-ignore - const isAdmin = payload?.is_admin || false; const isActive = item.path ? location.pathname.startsWith(item.path) : false; - const filteredNestedItems = item.nestedItems?.filter((nestedItem) => { - if (nestedItem.for_admin) { - return isAdmin; - } - - return true; - }); + const filteredNestedItems = item.nestedItems; const handleClick = () => { if (item.id === "all" && !open) { diff --git a/src/features/navigation/ui/index.tsx b/src/features/navigation/ui/index.tsx index 0cdbac0..2884819 100644 --- a/src/features/navigation/ui/index.tsx +++ b/src/features/navigation/ui/index.tsx @@ -1,6 +1,6 @@ import List from "@mui/material/List"; import Divider from "@mui/material/Divider"; -import { authStore, NAVIGATION_ITEMS } from "@shared"; +import { authStore, NAVIGATION_ITEMS, ROUTE_REQUIRED_RESOURCES } from "@shared"; import { NavigationItem, NavigationItemComponent } from "@entities"; import { observer } from "mobx-react-lite"; @@ -9,28 +9,53 @@ interface NavigationListProps { onDrawerOpen?: () => void; } +const isItemVisible = (item: (typeof NAVIGATION_ITEMS.primary)[number]): boolean => { + // Для карты в навигации требуем наличие ВСЕХ трёх rw-ролей: routes/stations/sights + if (item.id === "map") { + return ["routes", "stations", "sights"].every((resource) => + authStore.canWrite(resource), + ); + } + + // Пользователь с ролью ТО всегда видит раздел устройств + if (item.path === "/devices" && authStore.hasRole("devices_maintenance_rw")) { + return true; + } + + const routePermissions = item.path ? ROUTE_REQUIRED_RESOURCES[item.path] ?? [] : []; + const canAccessRoute = routePermissions.every((permission) => + authStore.canAccess(permission), + ); + + if (!canAccessRoute) { + return false; + } + + if (!item.requiredRoles || item.requiredRoles.length === 0) { + return true; + } + + return item.requiredRoles.some((role) => { + const match = role.match(/^(.+)_r[ow]$/); + if (match) { + return authStore.canRead(match[1]); + } + return authStore.hasRole(role); + }); +}; + export const NavigationList = observer( ({ open, onDrawerOpen }: NavigationListProps) => { - const { payload } = authStore; - // @ts-ignore - const isAdmin = Boolean(payload?.is_admin) || false; - - const primaryItems = NAVIGATION_ITEMS.primary.filter((item) => { - if (item.for_admin) { - return isAdmin; - } - - if (item.nestedItems && item.nestedItems.length > 0) { - return item.nestedItems.some((nestedItem) => { - if (nestedItem.for_admin) { - return isAdmin; - } - return true; - }); - } - - return true; - }); + const primaryItems = NAVIGATION_ITEMS.primary + .filter(isItemVisible) + .map((item) => { + if (!item.nestedItems) return item; + return { + ...item, + nestedItems: item.nestedItems.filter(isItemVisible), + }; + }) + .filter((item) => !item.nestedItems || item.nestedItems.length > 0); return ( <> @@ -51,7 +76,7 @@ export const NavigationList = observer( key={item.id} item={item as NavigationItem} open={open} - onClick={item.onClick ? item.onClick : undefined} + onClick={item.onClick ?? undefined} onDrawerOpen={onDrawerOpen} /> ))} diff --git a/src/index.css b/src/index.css index 1115274..5cdf02c 100644 --- a/src/index.css +++ b/src/index.css @@ -1,5 +1,15 @@ @import "tailwindcss"; @plugin "@tailwindcss/typography"; + +html, +body, +#root { + margin: 0; + height: 100%; + min-height: 100%; + overflow: hidden; +} + button { cursor: pointer; } diff --git a/src/pages/Article/ArticleCreatePage/index.tsx b/src/pages/Article/ArticleCreatePage/index.tsx index ac5cfaf..6783858 100644 --- a/src/pages/Article/ArticleCreatePage/index.tsx +++ b/src/pages/Article/ArticleCreatePage/index.tsx @@ -1,12 +1,17 @@ -import React from "react"; +import React, { useEffect } from "react"; import { useNavigate } from "react-router-dom"; import { ArrowLeft } from "lucide-react"; import { LanguageSwitcher } from "@widgets"; -import { articlesStore } from "@shared"; +import { articlesStore, selectedCityStore } from "@shared"; const ArticleCreatePage: React.FC = () => { const navigate = useNavigate(); + useEffect(() => { + selectedCityStore.setIsLocked(true); + return () => selectedCityStore.setIsLocked(false); + }, []); + const { articleData } = articlesStore; return ( diff --git a/src/pages/Article/ArticleEditPage/index.tsx b/src/pages/Article/ArticleEditPage/index.tsx index 47cd0ff..8ab546a 100644 --- a/src/pages/Article/ArticleEditPage/index.tsx +++ b/src/pages/Article/ArticleEditPage/index.tsx @@ -2,7 +2,7 @@ import React, { useEffect } from "react"; import { useNavigate, useParams } from "react-router-dom"; import { ArrowLeft } from "lucide-react"; import { LanguageSwitcher } from "@widgets"; -import { articlesStore, languageStore } from "@shared"; +import { articlesStore, languageStore, selectedCityStore } from "@shared"; import { observer } from "mobx-react-lite"; const ArticleEditPage: React.FC = observer(() => { @@ -11,6 +11,11 @@ const ArticleEditPage: React.FC = observer(() => { const { articleData, getArticle } = articlesStore; + useEffect(() => { + selectedCityStore.setIsLocked(true); + return () => selectedCityStore.setIsLocked(false); + }, []); + useEffect(() => { // Устанавливаем русский язык при загрузке страницы languageStore.setLanguage("ru"); diff --git a/src/pages/Article/ArticleListPage/index.tsx b/src/pages/Article/ArticleListPage/index.tsx index c08f73d..6c23c19 100644 --- a/src/pages/Article/ArticleListPage/index.tsx +++ b/src/pages/Article/ArticleListPage/index.tsx @@ -1,7 +1,7 @@ import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid"; import { ruRU } from "@mui/x-data-grid/locales"; -import { articlesStore, languageStore } from "@shared"; -import { useEffect, useState } from "react"; +import { authStore, articlesStore, languageStore, SearchInput, selectedCityStore } from "@shared"; +import { useEffect, useState, useMemo } from "react"; import { observer } from "mobx-react-lite"; import { Trash2, Eye, Minus } from "lucide-react"; import { useNavigate } from "react-router-dom"; @@ -17,6 +17,12 @@ export const ArticleListPage = observer(() => { const { language } = languageStore; const [ids, setIds] = useState([]); const [isLoading, setIsLoading] = useState(false); + const [paginationModel, setPaginationModel] = useState({ + page: 0, + pageSize: 50, + }); + const canWriteArticles = authStore.canWrite("sights"); + const [searchQuery, setSearchQuery] = useState(""); useEffect(() => { const fetchArticles = async () => { @@ -25,7 +31,7 @@ export const ArticleListPage = observer(() => { setIsLoading(false); }; fetchArticles(); - }, [language]); + }, [language, selectedCityStore.cityVersion]); const columns: GridColDef[] = [ { @@ -47,14 +53,14 @@ export const ArticleListPage = observer(() => { field: "actions", headerName: "Действия", sortable: false, - - renderCell: (params: GridRenderCellParams) => { - return ( -
- + renderCell: (params: GridRenderCellParams) => ( +
+ + {canWriteArticles && ( -
- ); - }, + )} +
+ ), }, ]; - const rows = articleList[language].data.map((article) => ({ - id: article.id, - heading: article.heading, - body: article.body, - })); + const rows = useMemo(() => { + const query = searchQuery.trim().toLowerCase(); + const cityId = selectedCityStore.selectedCityId; + return articleList[language].data + .filter((article) => !query || (article.heading ?? "").toLowerCase().includes(query)) + .filter((article) => !cityId || article.city_id === cityId) + .map((article) => ({ + id: article.id, + heading: article.heading, + body: article.body, + })); + }, [articleList[language].data, searchQuery, selectedCityStore.selectedCityId]); return ( <> @@ -83,31 +96,58 @@ export const ArticleListPage = observer(() => {

Статьи

-
0 ? 1 : 0 }} - > - -
+ {canWriteArticles && ids.length > 0 && ( +
+ +
+ )} + + {(rows.length > 0 || searchQuery) && ( + + )}
{ - setIds(Array.from(newSelection.ids) as number[]); - }} - hideFooter + onRowSelectionModelChange={ + canWriteArticles + ? (newSelection: any) => { + if (Array.isArray(newSelection)) { + const selectedIds = newSelection.map( + (id: string | number) => Number(id) + ); + setIds(selectedIds); + } else if ( + newSelection && + typeof newSelection === "object" && + "ids" in newSelection + ) { + const idsSet = newSelection.ids as Set; + const selectedIds = Array.from(idsSet).map( + (id: string | number) => Number(id) + ); + setIds(selectedIds); + } else { + setIds([]); + } + } + : undefined + } slots={{ noRowsOverlay: () => ( { const { articleMedia, articleData } = articlesStore; const { language } = languageStore; + const body = articleData?.[language]?.body; return ( { {articleData?.[language]?.heading || "Название информации"} - {articleData?.[language]?.body && ( + {body && ( { flexGrow: 1, }} > - + )} diff --git a/src/pages/Article/ArticlePreviewPage/index.tsx b/src/pages/Article/ArticlePreviewPage/index.tsx index bce26ed..40b8953 100644 --- a/src/pages/Article/ArticlePreviewPage/index.tsx +++ b/src/pages/Article/ArticlePreviewPage/index.tsx @@ -1,9 +1,9 @@ import { useNavigate, useParams } from "react-router-dom"; -import { useEffect } from "react"; +import { useEffect, useState } from "react"; import { Box } from "@mui/material"; import { PreviewLeftWidget } from "./PreviewLeftWidget"; import { PreviewRightWidget } from "./PreviewRightWidget"; -import { articlesStore, languageStore } from "@shared"; +import { articlesStore, languageStore, LoadingSpinner } from "@shared"; import { ArrowLeft } from "lucide-react"; export const ArticlePreviewPage = () => { @@ -11,18 +11,41 @@ export const ArticlePreviewPage = () => { const { id } = useParams(); const { getArticle, getArticleMedia, getArticlePreview } = articlesStore; const { language } = languageStore; + const [isLoadingData, setIsLoadingData] = useState(true); useEffect(() => { const fetchData = async () => { if (id) { - await getArticle(Number(id), language); - await getArticleMedia(Number(id)); - await getArticlePreview(Number(id)); + setIsLoadingData(true); + try { + await getArticle(Number(id), language); + await getArticleMedia(Number(id)); + await getArticlePreview(Number(id)); + } finally { + setIsLoadingData(false); + } + } else { + setIsLoadingData(false); } }; fetchData(); }, [id, language]); + if (isLoadingData) { + return ( + + + + ); + } + return ( <>
diff --git a/src/pages/Carrier/CarrierCreatePage/index.tsx b/src/pages/Carrier/CarrierCreatePage/index.tsx index 98e795a..fb055e1 100644 --- a/src/pages/Carrier/CarrierCreatePage/index.tsx +++ b/src/pages/Carrier/CarrierCreatePage/index.tsx @@ -1,6 +1,5 @@ import { Button, - Paper, TextField, Select, MenuItem, @@ -8,59 +7,157 @@ import { InputLabel, } from "@mui/material"; import { observer } from "mobx-react-lite"; -import { ArrowLeft, Save } from "lucide-react"; -import { Loader2 } from "lucide-react"; +import { ArrowLeft, Loader2, Save } from "lucide-react"; + import { useNavigate } from "react-router-dom"; import { toast } from "react-toastify"; import { carrierStore, cityStore, + authStore, mediaStore, languageStore, + isMediaIdEmpty, useSelectedCity, -} from "@shared"; -import { useState, useEffect } from "react"; -import { ImageUploadCard, LanguageSwitcher } from "@widgets"; -import { + selectedCityStore, SelectMediaDialog, UploadMediaDialog, PreviewMediaDialog, + editSightStore, } from "@shared"; +import { useState, useEffect } from "react"; +import { ImageUploadCard, LanguageSwitcher, VideoPreviewCard } from "@widgets"; + +type ColorFields = { main_color: string; left_color: string; right_color: string }; + +const colorFields = (data: ColorFields) => ({ + main_color: data.main_color, + left_color: data.left_color, + right_color: data.right_color, +}); + +const ColorPickerField = ({ + label, + value, + onChange, +}: { + label: string; + value: string; + onChange: (val: string) => void; +}) => ( +
+
+ onChange(e.target.value)} + className="absolute inset-0 w-full h-full opacity-0 cursor-pointer" + /> +
+ onChange(e.target.value)} + InputProps={{ + endAdornment: value ? ( + + ) : undefined, + }} + /> +
+); export const CarrierCreatePage = observer(() => { const navigate = useNavigate(); - const { createCarrierData, setCreateCarrierData } = carrierStore; + const { + createCarrierData, + setCreateCarrierData, + setCreateCarrierMedia, + } = carrierStore; const { language } = languageStore; - const { selectedCityId } = useSelectedCity(); - const [selectedMediaId, setSelectedMediaId] = useState(null); + const canReadCities = authStore.canRead("cities"); + const { selectedCityId, selectedCity } = useSelectedCity(); const [isLoading, setIsLoading] = useState(false); const [isSelectMediaOpen, setIsSelectMediaOpen] = useState(false); const [isUploadMediaOpen, setIsUploadMediaOpen] = useState(false); const [isPreviewMediaOpen, setIsPreviewMediaOpen] = useState(false); const [mediaId, setMediaId] = useState(""); + const [activeMediaField, setActiveMediaField] = useState< + "preview_media" | "video_preview" + >("preview_media"); const [activeMenuType, setActiveMenuType] = useState< - "thumbnail" | "watermark_lu" | "watermark_rd" | "image" | null + | "thumbnail" + | "watermark_lu" + | "watermark_rd" + | "image" + | "video_preview" + | null >(null); useEffect(() => { - cityStore.getCities("ru"); + selectedCityStore.setIsLocked(true); + return () => selectedCityStore.setIsLocked(false); + }, []); + + useEffect(() => { + const fetchCities = async () => { + if (!authStore.me) { + await authStore.getMeAction().catch(() => undefined); + } + if (authStore.canRead("cities")) { + await cityStore.getCities("ru"); + return; + } + await authStore.fetchMeCities().catch(() => undefined); + }; + + fetchCities(); mediaStore.getMedia(); languageStore.setLanguage("ru"); }, []); - // Автоматически устанавливаем выбранный город при загрузке страницы + const baseCities = canReadCities + ? cityStore.cities["ru"].data + : authStore.meCities["ru"].map((city) => ({ + id: city.city_id, + name: city.name, + country: "", + country_code: "", + arms: "", + })); + + const availableCities = + selectedCity?.id && !baseCities.some((city) => city.id === selectedCity.id) + ? [selectedCity, ...baseCities] + : baseCities; + useEffect(() => { if (selectedCityId && !createCarrierData.city_id) { setCreateCarrierData( createCarrierData[language].full_name, createCarrierData[language].short_name, selectedCityId, - createCarrierData[language].slogan, - selectedMediaId || "", - language + language, ); } - }, [selectedCityId, createCarrierData.city_id]); + }, [ + selectedCityId, + createCarrierData, + createCarrierData.city_id, + language, + setCreateCarrierData, + ]); const handleCreate = async () => { try { @@ -69,7 +166,7 @@ export const CarrierCreatePage = observer(() => { toast.success("Перевозчик успешно создан"); navigate("/carrier"); - } catch (error) { + } catch { toast.error("Ошибка при создании перевозчика"); } finally { setIsLoading(false); @@ -82,23 +179,22 @@ export const CarrierCreatePage = observer(() => { media_name?: string; media_type: number; }) => { - setSelectedMediaId(media.id); - setCreateCarrierData( - createCarrierData[language].full_name, - createCarrierData[language].short_name, - createCarrierData.city_id, - createCarrierData[language].slogan, - media.id, - language - ); + setCreateCarrierMedia(activeMediaField, media.id); }; - const selectedMedia = selectedMediaId - ? mediaStore.media.find((m) => m.id === selectedMediaId) - : null; + const effectivePreviewMediaUrl = isMediaIdEmpty( + createCarrierData.preview_media, + ) + ? null + : createCarrierData.preview_media; + const effectiveVideoPreviewId = isMediaIdEmpty( + createCarrierData.video_preview, + ) + ? null + : createCarrierData.video_preview; return ( - +
); }); diff --git a/src/pages/Carrier/CarrierEditPage/index.tsx b/src/pages/Carrier/CarrierEditPage/index.tsx index e914ba2..792faab 100644 --- a/src/pages/Carrier/CarrierEditPage/index.tsx +++ b/src/pages/Carrier/CarrierEditPage/index.tsx @@ -1,82 +1,192 @@ import { Button, - Paper, TextField, Select, MenuItem, FormControl, InputLabel, + Box, } from "@mui/material"; import { observer } from "mobx-react-lite"; import { ArrowLeft, Save } from "lucide-react"; import { Loader2 } from "lucide-react"; import { useNavigate, useParams } from "react-router-dom"; import { toast } from "react-toastify"; -import { carrierStore, cityStore, mediaStore, languageStore } from "@shared"; +import { + carrierStore, + cityStore, + authStore, + mediaStore, + languageStore, + isMediaIdEmpty, + LoadingSpinner, + selectedCityStore, + editSightStore, +} from "@shared"; import { useState, useEffect } from "react"; -import { ImageUploadCard, LanguageSwitcher, DeleteModal } from "@widgets"; +import { + ImageUploadCard, + LanguageSwitcher, + VideoPreviewCard, +} from "@widgets"; import { SelectMediaDialog, PreviewMediaDialog, UploadMediaDialog, } from "@shared"; +type ColorFields = { + main_color: string; + left_color: string; + right_color: string; +}; + +const colorFields = (data: ColorFields) => ({ + main_color: data.main_color, + left_color: data.left_color, + right_color: data.right_color, +}); + +const ColorPickerField = ({ + label, + value, + onChange, +}: { + label: string; + value: string; + onChange: (val: string) => void; +}) => ( +
+
+ onChange(e.target.value)} + className="absolute inset-0 w-full h-full opacity-0 cursor-pointer" + /> +
+ onChange(e.target.value)} + InputProps={{ + endAdornment: value ? ( + + ) : undefined, + }} + /> +
+); + export const CarrierEditPage = observer(() => { const navigate = useNavigate(); const { id } = useParams(); - const { getCarrier, setEditCarrierData, editCarrierData } = carrierStore; + const { + getCarrier, + setEditCarrierData, + setEditCarrierMedia, + editCarrierData, + } = carrierStore; const { language } = languageStore; + const canReadCities = authStore.canRead("cities"); const [isLoading, setIsLoading] = useState(false); + const [isLoadingData, setIsLoadingData] = useState(true); const [isSelectMediaOpen, setIsSelectMediaOpen] = useState(false); const [isUploadMediaOpen, setIsUploadMediaOpen] = useState(false); const [isPreviewMediaOpen, setIsPreviewMediaOpen] = useState(false); const [mediaId, setMediaId] = useState(""); - const [isDeleteLogoModalOpen, setIsDeleteLogoModalOpen] = useState(false); + const [initialCityName, setInitialCityName] = useState(""); + const [activeMediaField, setActiveMediaField] = useState< + "preview_media" | "video_preview" + >("preview_media"); const [activeMenuType, setActiveMenuType] = useState< - "thumbnail" | "watermark_lu" | "watermark_rd" | "image" | null + | "thumbnail" + | "watermark_lu" + | "watermark_rd" + | "image" + | "video_preview" + | null >(null); + useEffect(() => { + selectedCityStore.setIsLocked(true); + return () => selectedCityStore.setIsLocked(false); + }, []); + useEffect(() => { (async () => { - await cityStore.getCities("ru"); - await cityStore.getCities("en"); - await cityStore.getCities("zh"); - const carrierData = await getCarrier(Number(id)); - - if (carrierData) { - setEditCarrierData( - carrierData.ru?.full_name || "", - carrierData.ru?.short_name || "", - carrierData.ru?.city_id || 0, - carrierData.ru?.slogan || "", - carrierData.ru?.logo || "", - "ru" - ); - setEditCarrierData( - carrierData.en?.full_name || "", - carrierData.en?.short_name || "", - carrierData.en?.city_id || 0, - carrierData.en?.slogan || "", - carrierData.en?.logo || "", - "en" - ); - setEditCarrierData( - carrierData.zh?.full_name || "", - carrierData.zh?.short_name || "", - carrierData.zh?.city_id || 0, - carrierData.zh?.slogan || "", - carrierData.zh?.logo || "", - "zh" - ); + if (!id) { + setIsLoadingData(false); + return; } + setIsLoadingData(true); + try { + if (!authStore.me) { + await authStore.getMeAction().catch(() => undefined); + } + if (authStore.canRead("cities")) { + await cityStore.getCities("ru"); + } else { + await authStore.fetchMeCities().catch(() => undefined); + } + const carrierData = await getCarrier(Number(id)); - mediaStore.getMedia(); + if (carrierData) { + const colors = { + main_color: carrierData.ru?.main_color || "", + left_color: carrierData.ru?.left_color || "", + right_color: carrierData.ru?.right_color || "", + }; + setEditCarrierData( + carrierData.ru?.full_name || "", + carrierData.ru?.short_name || "", + carrierData.ru?.city_id || 0, + "ru", + colors, + ); + setEditCarrierData( + carrierData.en?.full_name || "", + carrierData.en?.short_name || "", + carrierData.en?.city_id || 0, + "en", + ); + setEditCarrierData( + carrierData.zh?.full_name || "", + carrierData.zh?.short_name || "", + carrierData.zh?.city_id || 0, + "zh", + ); + setEditCarrierMedia( + "video_preview", + carrierData.ru?.video_preview || "", + ); + setEditCarrierMedia( + "preview_media", + carrierData.ru?.preview_media || "", + ); + setInitialCityName(carrierData.ru?.city || ""); + } + + await mediaStore.getMedia(); + } finally { + setIsLoadingData(false); + } })(); - // Устанавливаем русский язык при загрузке страницы languageStore.setLanguage("ru"); - }, [id]); + }, [id, getCarrier, setEditCarrierData, setEditCarrierMedia]); const handleEdit = async () => { try { @@ -84,7 +194,7 @@ export const CarrierEditPage = observer(() => { await carrierStore.editCarrier(Number(id)); toast.success("Перевозчик успешно обновлен"); navigate("/carrier"); - } catch (error) { + } catch { toast.error("Ошибка при обновлении перевозчика"); } finally { setIsLoading(false); @@ -97,22 +207,62 @@ export const CarrierEditPage = observer(() => { media_name?: string; media_type: number; }) => { - setEditCarrierData( - editCarrierData[language].full_name, - editCarrierData[language].short_name, - editCarrierData.city_id, - editCarrierData[language].slogan, - media.id, - language - ); + setEditCarrierMedia(activeMediaField, media.id); }; - const selectedMedia = editCarrierData.logo - ? mediaStore.media.find((m) => m.id === editCarrierData.logo) - : null; + const effectivePreviewMediaUrl = isMediaIdEmpty( + editCarrierData.preview_media, + ) + ? null + : editCarrierData.preview_media; + const effectiveVideoPreviewId = isMediaIdEmpty( + editCarrierData.video_preview, + ) + ? null + : editCarrierData.video_preview; + + const baseCities = canReadCities + ? cityStore.cities["ru"].data + : authStore.meCities["ru"].map((city) => ({ + id: city.city_id, + name: city.name, + country: "", + country_code: "", + arms: "", + })); + + const availableCities = + editCarrierData.city_id && + !baseCities.some((city) => city.id === editCarrierData.city_id) + ? [ + { + id: editCarrierData.city_id, + name: initialCityName || `Город ${editCarrierData.city_id}`, + country: "", + country_code: "", + arms: "", + }, + ...baseCities, + ] + : baseCities; + + if (isLoadingData) { + return ( + + + + ); + } return ( - +
); }); diff --git a/src/pages/Carrier/CarrierListPage/index.tsx b/src/pages/Carrier/CarrierListPage/index.tsx index a20c89a..acad983 100644 --- a/src/pages/Carrier/CarrierListPage/index.tsx +++ b/src/pages/Carrier/CarrierListPage/index.tsx @@ -1,7 +1,7 @@ import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid"; import { ruRU } from "@mui/x-data-grid/locales"; -import { carrierStore, cityStore, languageStore } from "@shared"; -import { useEffect, useState } from "react"; +import { authStore, carrierStore, cityStore, languageStore, selectedCityStore, SearchInput } from "@shared"; +import { useEffect, useState, useMemo } from "react"; import { observer } from "mobx-react-lite"; import { Pencil, Trash2, Minus } from "lucide-react"; import { useNavigate } from "react-router-dom"; @@ -10,26 +10,36 @@ import { Box, CircularProgress } from "@mui/material"; export const CarrierListPage = observer(() => { const { carriers, getCarriers, deleteCarrier } = carrierStore; - const { getCities, cities } = cityStore; const navigate = useNavigate(); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [isBulkDeleteModalOpen, setIsBulkDeleteModalOpen] = useState(false); const [rowId, setRowId] = useState(null); const [ids, setIds] = useState([]); const [isLoading, setIsLoading] = useState(false); + const [paginationModel, setPaginationModel] = useState({ + page: 0, + pageSize: 50, + }); const { language } = languageStore; + const canReadCities = authStore.canRead("cities"); + const [searchQuery, setSearchQuery] = useState(""); useEffect(() => { const fetchData = async () => { setIsLoading(true); - await getCities("ru"); - await getCities("en"); - await getCities("zh"); + if (!authStore.me) { + await authStore.getMeAction().catch(() => undefined); + } + if (authStore.canRead("cities")) { + await cityStore.getCities(language); + } else { + await authStore.fetchMeCities().catch(() => undefined); + } await getCarriers(language); setIsLoading(false); }; fetchData(); - }, [language]); + }, [language, selectedCityStore.cityVersion]); const columns: GridColDef[] = [ { @@ -69,56 +79,65 @@ export const CarrierListPage = observer(() => { headerName: "Город", flex: 1, renderCell: (params: GridRenderCellParams) => { - const city = cities[language]?.data.find( - (city) => city.id == params.value - ); + const lang = language as "ru" | "en" | "zh"; + const cityName = canReadCities + ? cityStore.cities[lang]?.data.find((c) => c.id === params.value)?.name + : authStore.meCities[lang]?.find((c) => c.city_id === params.value)?.name; return (
- {city && city.name ? ( - city.name - ) : ( - - )} + {cityName ?? }
); }, }, - { + ...(authStore.canWrite("carriers") ? [{ field: "actions", headerName: "Действия", - headerAlign: "center", + headerAlign: "center" as const, width: 200, sortable: false, - - renderCell: (params: GridRenderCellParams) => { - return ( -
- - {/* */} - -
- ); - }, - }, + renderCell: (params: GridRenderCellParams) => ( +
+ + +
+ ), + }] : []), ]; - const rows = carriers[language].data?.map((carrier) => ({ - id: carrier.id, - full_name: carrier.full_name, - short_name: carrier.short_name, - city_id: carrier.city_id, - })); + const allowedCityIds = canReadCities + ? null + : authStore.meCities["ru"].map((c) => c.city_id); + + const canWriteCarriers = authStore.canWrite("carriers"); + + const rows = useMemo(() => { + const query = searchQuery.trim().toLowerCase(); + return (carriers[language].data ?? []) + .filter((carrier) => !allowedCityIds || allowedCityIds.includes(carrier.city_id)) + .filter( + (carrier) => + !query || + (carrier.full_name ?? "").toLowerCase().includes(query) || + (carrier.short_name ?? "").toLowerCase().includes(query) + ) + .map((carrier) => ({ + id: carrier.id, + full_name: carrier.full_name, + short_name: carrier.short_name, + city_id: carrier.city_id, + })); + }, [carriers[language].data, searchQuery, allowedCityIds]); return ( <> @@ -126,32 +145,63 @@ export const CarrierListPage = observer(() => {

Перевозчики

- + {canWriteCarriers && ( + + )}
-
0 ? 1 : 0 }} - > - -
+ {canWriteCarriers && ids.length > 0 && ( +
+ +
+ )} + + {(rows.length > 0 || searchQuery) && ( + + )} { - setIds(Array.from(newSelection.ids as unknown as number[])); + checkboxSelection={canWriteCarriers} + disableRowSelectionExcludeModel + disableRowSelectionOnClick + onRowDoubleClick={(params) => { + if (canWriteCarriers) { + navigate(`/carrier/${params.id}/edit`); + } }} + loading={isLoading} + paginationModel={paginationModel} + onPaginationModelChange={setPaginationModel} + pageSizeOptions={[50]} + localeText={ruRU.components.MuiDataGrid.defaultProps.localeText} + onRowSelectionModelChange={ + canWriteCarriers + ? (newSelection: any) => { + if (Array.isArray(newSelection)) { + const selectedIds = newSelection.map(Number); + setIds(selectedIds); + } else if ( + newSelection && + typeof newSelection === "object" && + "ids" in newSelection + ) { + const idsSet = newSelection.ids as Set; + const selectedIds = Array.from(idsSet).map(Number); + setIds(selectedIds); + } else { + setIds([]); + } + } + : undefined + } slots={{ noRowsOverlay: () => ( diff --git a/src/pages/City/CityCreatePage/index.tsx b/src/pages/City/CityCreatePage/index.tsx index 0f26a89..0a73b8c 100644 --- a/src/pages/City/CityCreatePage/index.tsx +++ b/src/pages/City/CityCreatePage/index.tsx @@ -12,19 +12,32 @@ import { ArrowLeft, Save } from "lucide-react"; import { Loader2 } from "lucide-react"; import { useNavigate } from "react-router-dom"; import { toast } from "react-toastify"; -import { cityStore, countryStore, languageStore, mediaStore } from "@shared"; -import { useState, useEffect } from "react"; -import { LanguageSwitcher, ImageUploadCard } from "@widgets"; import { + cityStore, + countryStore, + languageStore, + mediaStore, + snapshotStore, + isMediaIdEmpty, SelectMediaDialog, UploadMediaDialog, PreviewMediaDialog, + selectedCityStore, } from "@shared"; +import { useState, useEffect } from "react"; +import { LanguageSwitcher, ImageUploadCard } from "@widgets"; export const CityCreatePage = observer(() => { const navigate = useNavigate(); + + useEffect(() => { + selectedCityStore.setIsLocked(true); + return () => selectedCityStore.setIsLocked(false); + }, []); + const { language } = languageStore; - const { createCityData, setCreateCityData } = cityStore; + const { createCityData, setCreateCityData, setCreateCityWeatherCode } = + cityStore; const [isLoading, setIsLoading] = useState(false); const [isSelectMediaOpen, setIsSelectMediaOpen] = useState(false); const [isUploadMediaOpen, setIsUploadMediaOpen] = useState(false); @@ -48,7 +61,13 @@ export const CityCreatePage = observer(() => { const handleCreate = async () => { try { setIsLoading(true); + const ruCityName = createCityData.ru.name.trim(); await cityStore.createCity(); + try { + await snapshotStore.createEmptySnapshot(`${ruCityName}_Пустой_Экспорт`); + } catch (e) { + console.warn("Failed to create empty snapshot for city:", e); + } toast.success("Город успешно создан"); navigate("/city"); } catch (error) { @@ -68,13 +87,17 @@ export const CityCreatePage = observer(() => { createCityData[language].name, createCityData.country_code, media.id, - language + language, ); }; - const selectedMedia = createCityData.arms - ? mediaStore.media.find((m) => m.id === createCityData.arms) - : null; + const selectedMedia = + createCityData.arms && !isMediaIdEmpty(createCityData.arms) + ? mediaStore.media.find((m) => m.id === createCityData.arms) + : null; + const effectiveArmsUrl = isMediaIdEmpty(createCityData.arms) + ? null + : (selectedMedia?.id ?? createCityData.arms); return ( @@ -103,7 +126,7 @@ export const CityCreatePage = observer(() => { e.target.value, createCityData.country_code, createCityData.arms, - language + language, ) } /> @@ -119,7 +142,7 @@ export const CityCreatePage = observer(() => { createCityData[language].name, e.target.value, createCityData.arms, - language + language, ); }} > @@ -131,21 +154,29 @@ export const CityCreatePage = observer(() => { + setCreateCityWeatherCode(Number(e.target.value))} + helperText="ID города брать с ресурса openweathermap.org" + /> +
{ setIsPreviewMediaOpen(true); - setMediaId(selectedMedia?.id ?? ""); + setMediaId(effectiveArmsUrl ?? ""); }} onDeleteImageClick={() => { setCreateCityData( createCityData[language].name, createCityData.country_code, "", - language + language, ); setActiveMenuType(null); }} diff --git a/src/pages/City/CityEditPage/index.tsx b/src/pages/City/CityEditPage/index.tsx index 4d7b0b4..f765aba 100644 --- a/src/pages/City/CityEditPage/index.tsx +++ b/src/pages/City/CityEditPage/index.tsx @@ -6,10 +6,10 @@ import { MenuItem, FormControl, InputLabel, + Box, } from "@mui/material"; import { observer } from "mobx-react-lite"; -import { ArrowLeft, Save } from "lucide-react"; -import { Loader2 } from "lucide-react"; +import { ArrowLeft, Loader2, Save } from "lucide-react"; import { useNavigate, useParams } from "react-router-dom"; import { toast } from "react-toastify"; import { @@ -17,19 +17,27 @@ import { countryStore, languageStore, mediaStore, + isMediaIdEmpty, CashedCities, -} from "@shared"; -import { useEffect, useState } from "react"; -import { LanguageSwitcher, ImageUploadCard } from "@widgets"; -import { + LoadingSpinner, SelectMediaDialog, UploadMediaDialog, PreviewMediaDialog, + selectedCityStore, } from "@shared"; +import { useEffect, useState } from "react"; +import { LanguageSwitcher, ImageUploadCard } from "@widgets"; export const CityEditPage = observer(() => { const navigate = useNavigate(); + + useEffect(() => { + selectedCityStore.setIsLocked(true); + return () => selectedCityStore.setIsLocked(false); + }, []); + const [isLoading, setIsLoading] = useState(false); + const [isLoadingData, setIsLoadingData] = useState(true); const [isSelectMediaOpen, setIsSelectMediaOpen] = useState(false); const [isUploadMediaOpen, setIsUploadMediaOpen] = useState(false); const [isPreviewMediaOpen, setIsPreviewMediaOpen] = useState(false); @@ -39,12 +47,17 @@ export const CityEditPage = observer(() => { >(null); const { language } = languageStore; const { id } = useParams(); - const { editCityData, editCity, getCity, setEditCityData } = cityStore; + const { + editCityData, + editCity, + getCity, + setEditCityData, + setEditCityWeatherCode, + } = cityStore; const { getCountries } = countryStore; const { getMedia, getOneMedia } = mediaStore; useEffect(() => { - // Устанавливаем русский язык при загрузке страницы languageStore.setLanguage("ru"); }, []); @@ -63,20 +76,27 @@ export const CityEditPage = observer(() => { useEffect(() => { (async () => { if (id) { - await getCountries("ru"); - // Fetch data for all languages - const ruData = await getCity(id as string, "ru"); - const enData = await getCity(id as string, "en"); - const zhData = await getCity(id as string, "zh"); + setIsLoadingData(true); + try { + await getCountries("ru"); - // Set data for each language - setEditCityData(ruData.name, ruData.country_code, ruData.arms, "ru"); - setEditCityData(enData.name, enData.country_code, enData.arms, "en"); - setEditCityData(zhData.name, zhData.country_code, zhData.arms, "zh"); + const ruData = await getCity(id as string, "ru"); + const enData = await getCity(id as string, "en"); + const zhData = await getCity(id as string, "zh"); - await getOneMedia(ruData.arms as string); + setEditCityData(ruData.name, ruData.country_code, ruData.arms, "ru"); + setEditCityData(enData.name, enData.country_code, enData.arms, "en"); + setEditCityData(zhData.name, zhData.country_code, zhData.arms, "zh"); + setEditCityWeatherCode(ruData.weather_city_code ?? 0); - await getMedia(); + await getOneMedia(ruData.arms as string); + + await getMedia(); + } finally { + setIsLoadingData(false); + } + } else { + setIsLoadingData(false); } })(); }, [id]); @@ -91,13 +111,32 @@ export const CityEditPage = observer(() => { editCityData[language].name, editCityData.country_code, media.id, - language + language, ); }; - const selectedMedia = editCityData.arms - ? mediaStore.media.find((m) => m.id === editCityData.arms) - : null; + const selectedMedia = + editCityData.arms && !isMediaIdEmpty(editCityData.arms) + ? mediaStore.media.find((m) => m.id === editCityData.arms) + : null; + const effectiveArmsUrl = isMediaIdEmpty(editCityData.arms) + ? null + : (selectedMedia?.id ?? editCityData.arms); + + if (isLoadingData) { + return ( + + + + ); + } return ( @@ -126,7 +165,7 @@ export const CityEditPage = observer(() => { e.target.value, editCityData.country_code, editCityData.arms, - language + language, ) } /> @@ -142,7 +181,7 @@ export const CityEditPage = observer(() => { editCityData[language].name, e.target.value, editCityData.arms, - language + language, ); }} > @@ -154,21 +193,29 @@ export const CityEditPage = observer(() => { + setEditCityWeatherCode(Number(e.target.value))} + helperText="ID города брать с ресурса openweathermap.org" + /> +
{ setIsPreviewMediaOpen(true); - setMediaId(selectedMedia?.id ?? ""); + setMediaId(effectiveArmsUrl ?? ""); }} onDeleteImageClick={() => { setEditCityData( editCityData[language].name, editCityData.country_code, "", - language + language, ); setActiveMenuType(null); }} @@ -207,7 +254,7 @@ export const CityEditPage = observer(() => { open={isSelectMediaOpen} onClose={() => setIsSelectMediaOpen(false)} onSelectMedia={handleMediaSelect} - mediaType={1} // Тип медиа для иконок + mediaType={1} /> { const [ids, setIds] = useState([]); const [isLoading, setIsLoading] = useState(false); const [rows, setRows] = useState([]); + const [paginationModel, setPaginationModel] = useState({ + page: 0, + pageSize: 50, + }); const { language } = languageStore; + const canWriteCities = authStore.canWrite("cities"); + const [searchQuery, setSearchQuery] = useState(""); useEffect(() => { const fetchData = async () => { @@ -52,6 +58,18 @@ export const CityListPage = observer(() => { setRows(newRows2 || []); }, [cities, countryStore.countries, language, isLoading]); + const filteredRows = useMemo(() => { + const query = searchQuery.trim().toLowerCase(); + if (!query) return rows; + return rows.filter((row) => { + const cityName = (row.name ?? "").toLowerCase(); + const countryName = ( + countryStore.countries[language]?.data?.find((c) => c.code === row.country)?.name ?? "" + ).toLowerCase(); + return cityName.includes(query) || countryName.includes(query); + }); + }, [rows, searchQuery, countryStore.countries, language]); + const columns: GridColDef[] = [ { field: "country", @@ -87,35 +105,31 @@ export const CityListPage = observer(() => { ); }, }, - { + ...(authStore.canWrite("cities") ? [{ field: "actions", headerName: "Действия", - align: "center", - headerAlign: "center", + align: "center" as const, + headerAlign: "center" as const, width: 200, sortable: false, - renderCell: (params: GridRenderCellParams) => { - return ( -
- - {/* */} - -
- ); - }, - }, + renderCell: (params: GridRenderCellParams) => ( +
+ + +
+ ), + }] : []), ]; return ( @@ -125,36 +139,78 @@ export const CityListPage = observer(() => {

Города

- + {canWriteCities && ( + + )}
-
0 ? 1 : 0 }} - > - -
+ {canWriteCities && ids.length > 0 && ( +
+ +
+ )} + + {(rows.length > 0 || searchQuery) && ( + + )} { - setIds(Array.from(newSelection.ids as unknown as number[])); + checkboxSelection={canWriteCities} + disableRowSelectionExcludeModel + disableRowSelectionOnClick + onRowDoubleClick={(params) => { + if (canWriteCities) { + navigate(`/city/${params.id}/edit`); + } }} + loading={isLoading} + paginationModel={paginationModel} + onPaginationModelChange={setPaginationModel} + pageSizeOptions={[50]} + localeText={ruRU.components.MuiDataGrid.defaultProps.localeText} + onRowSelectionModelChange={ + canWriteCities + ? (newSelection: any) => { + if (Array.isArray(newSelection)) { + const selectedIds = newSelection.map( + (id: string | number) => Number(id) + ); + setIds(selectedIds); + } else if ( + newSelection && + typeof newSelection === "object" && + "ids" in newSelection + ) { + const idsSet = newSelection.ids as Set; + const selectedIds = Array.from(idsSet).map( + (id: string | number) => Number(id) + ); + setIds(selectedIds); + } else { + setIds([]); + } + } + : undefined + } slots={{ noRowsOverlay: () => ( - {isLoading ? : "Нет городов"} + {isLoading ? ( + + ) : ( + "Нет городов" + )} ), }} diff --git a/src/pages/Country/CountryAddPage/index.tsx b/src/pages/Country/CountryAddPage/index.tsx index d6ef176..b7bcea1 100644 --- a/src/pages/Country/CountryAddPage/index.tsx +++ b/src/pages/Country/CountryAddPage/index.tsx @@ -15,11 +15,18 @@ import { RU_COUNTRIES, EN_COUNTRIES, ZH_COUNTRIES, + selectedCityStore, } from "@shared"; -import { useState } from "react"; +import { useState, useEffect } from "react"; export const CountryAddPage = observer(() => { const navigate = useNavigate(); + + useEffect(() => { + selectedCityStore.setIsLocked(true); + return () => selectedCityStore.setIsLocked(false); + }, []); + const [isLoading, setIsLoading] = useState(false); const { createCountryData, setCountryData, createCountry } = countryStore; diff --git a/src/pages/Country/CountryCreatePage/index.tsx b/src/pages/Country/CountryCreatePage/index.tsx index 4601465..3de4d13 100644 --- a/src/pages/Country/CountryCreatePage/index.tsx +++ b/src/pages/Country/CountryCreatePage/index.tsx @@ -4,12 +4,18 @@ import { ArrowLeft, Save } from "lucide-react"; import { Loader2 } from "lucide-react"; import { useNavigate } from "react-router-dom"; import { toast } from "react-toastify"; -import { countryStore, languageStore } from "@shared"; -import { useState } from "react"; +import { countryStore, languageStore, selectedCityStore } from "@shared"; +import { useState, useEffect } from "react"; import { LanguageSwitcher } from "@widgets"; export const CountryCreatePage = observer(() => { const navigate = useNavigate(); + + useEffect(() => { + selectedCityStore.setIsLocked(true); + return () => selectedCityStore.setIsLocked(false); + }, []); + const [isLoading, setIsLoading] = useState(false); const { language } = languageStore; const { createCountryData, setCountryData, createCountry } = countryStore; diff --git a/src/pages/Country/CountryEditPage/index.tsx b/src/pages/Country/CountryEditPage/index.tsx index e763aaa..b20ab8e 100644 --- a/src/pages/Country/CountryEditPage/index.tsx +++ b/src/pages/Country/CountryEditPage/index.tsx @@ -1,23 +1,29 @@ -import { Button, Paper, TextField } from "@mui/material"; +import { Button, Paper, TextField, Box } from "@mui/material"; import { observer } from "mobx-react-lite"; import { ArrowLeft, Save } from "lucide-react"; import { Loader2 } from "lucide-react"; import { useNavigate, useParams } from "react-router-dom"; import { toast } from "react-toastify"; -import { countryStore, languageStore } from "@shared"; +import { countryStore, languageStore, LoadingSpinner, selectedCityStore } from "@shared"; import { useEffect, useState } from "react"; import { LanguageSwitcher } from "@widgets"; export const CountryEditPage = observer(() => { const navigate = useNavigate(); + + useEffect(() => { + selectedCityStore.setIsLocked(true); + return () => selectedCityStore.setIsLocked(false); + }, []); + const [isLoading, setIsLoading] = useState(false); + const [isLoadingData, setIsLoadingData] = useState(true); const { language } = languageStore; const { id } = useParams(); const { editCountryData, editCountry, getCountry, setEditCountryData } = countryStore; useEffect(() => { - // Устанавливаем русский язык при загрузке страницы languageStore.setLanguage("ru"); }, []); @@ -36,19 +42,39 @@ export const CountryEditPage = observer(() => { useEffect(() => { (async () => { if (id) { - // Fetch data for all languages - const ruData = await getCountry(id as string, "ru"); - const enData = await getCountry(id as string, "en"); - const zhData = await getCountry(id as string, "zh"); + setIsLoadingData(true); + try { + const ruData = await getCountry(id as string, "ru"); + const enData = await getCountry(id as string, "en"); + const zhData = await getCountry(id as string, "zh"); - // Set data for each language - setEditCountryData(ruData.name, "ru"); - setEditCountryData(enData.name, "en"); - setEditCountryData(zhData.name, "zh"); + setEditCountryData(ruData.name, "ru"); + setEditCountryData(enData.name, "en"); + setEditCountryData(zhData.name, "zh"); + } finally { + setIsLoadingData(false); + } + } else { + setIsLoadingData(false); } })(); }, [id]); + if (isLoadingData) { + return ( + + + + ); + } + return ( diff --git a/src/pages/Country/CountryListPage/index.tsx b/src/pages/Country/CountryListPage/index.tsx index b486c2f..e3bd7d2 100644 --- a/src/pages/Country/CountryListPage/index.tsx +++ b/src/pages/Country/CountryListPage/index.tsx @@ -1,7 +1,7 @@ import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid"; import { ruRU } from "@mui/x-data-grid/locales"; -import { countryStore, languageStore } from "@shared"; -import { useEffect, useState } from "react"; +import { authStore, countryStore, languageStore, SearchInput } from "@shared"; +import { useEffect, useState, useMemo } from "react"; import { observer } from "mobx-react-lite"; import { Trash2, Minus } from "lucide-react"; @@ -16,7 +16,13 @@ export const CountryListPage = observer(() => { const [rowId, setRowId] = useState(null); const [ids, setIds] = useState([]); const [isLoading, setIsLoading] = useState(false); + const [paginationModel, setPaginationModel] = useState({ + page: 0, + pageSize: 50, + }); const { language } = languageStore; + const canWriteCountries = authStore.canWrite("countries"); + const [searchQuery, setSearchQuery] = useState(""); useEffect(() => { const fetchCountries = async () => { @@ -44,44 +50,39 @@ export const CountryListPage = observer(() => { ); }, }, - { + ...(authStore.canWrite("countries") ? [{ field: "actions", headerName: "Действия", - align: "center", - headerAlign: "center", + align: "center" as const, + headerAlign: "center" as const, width: 200, sortable: false, - renderCell: (params: GridRenderCellParams) => { - return ( -
- {/* */} - {/* */} - -
- ); - }, - }, + renderCell: (params: GridRenderCellParams) => ( +
+ +
+ ), + }] : []), ]; - const rows = countries[language]?.data.map((country) => ({ - id: country.code, - code: country.code, - name: country.name, - })); + const rows = useMemo(() => { + const query = searchQuery.trim().toLowerCase(); + return (countries[language]?.data ?? []) + .filter((country) => !query || (country.name ?? "").toLowerCase().includes(query)) + .map((country) => ({ + id: country.code, + code: country.code, + name: country.name, + })); + }, [countries[language]?.data, searchQuery]); return ( <> @@ -90,36 +91,73 @@ export const CountryListPage = observer(() => {

Страны

- + {canWriteCountries && ( + + )}
-
0 ? 1 : 0 }} - > - -
+ {canWriteCountries && ids.length > 0 && ( +
+ +
+ )} + + {(rows.length > 0 || searchQuery) && ( + + )} { - setIds(Array.from(newSelection.ids as unknown as number[])); - }} + onRowSelectionModelChange={ + canWriteCountries + ? (newSelection: any) => { + if (Array.isArray(newSelection)) { + const selectedIds = newSelection.map( + (id: string | number) => Number(id) + ); + setIds(selectedIds); + } else if ( + newSelection && + typeof newSelection === "object" && + "ids" in newSelection + ) { + const idsSet = newSelection.ids as Set; + const selectedIds = Array.from(idsSet).map( + (id: string | number) => Number(id) + ); + setIds(selectedIds); + } else { + setIds([]); + } + } + : undefined + } slots={{ noRowsOverlay: () => ( - {isLoading ? : "Нет стран"} + {isLoading ? ( + + ) : ( + "Нет стран" + )} ), }} diff --git a/src/pages/CreateSightPage/index.tsx b/src/pages/CreateSightPage/index.tsx index 0820d96..0f0803e 100644 --- a/src/pages/CreateSightPage/index.tsx +++ b/src/pages/CreateSightPage/index.tsx @@ -1,9 +1,11 @@ import { Box, Tab, Tabs } from "@mui/material"; import { articlesStore, + authStore, cityStore, createSightStore, languageStore, + selectedCityStore, } from "@shared"; import { CreateInformationTab, @@ -13,6 +15,7 @@ import { } from "@widgets"; import { useEffect, useState } from "react"; import { observer } from "mobx-react-lite"; +import { runInAction } from "mobx"; function a11yProps(index: number) { return { @@ -27,7 +30,12 @@ export const CreateSightPage = observer(() => { const [value, setValue] = useState(0); const { getCities } = cityStore; const { getArticles } = articlesStore; - const { needLeaveAgree } = createSightStore; + const needLeave = createSightStore.needLeaveAgree; + + useEffect(() => { + selectedCityStore.setIsLocked(true); + return () => selectedCityStore.setIsLocked(false); + }, []); const handleChange = (_: React.SyntheticEvent, newValue: number) => { setValue(newValue); @@ -35,13 +43,34 @@ export const CreateSightPage = observer(() => { let blocker = useBlocker( ({ currentLocation, nextLocation }) => - needLeaveAgree && currentLocation.pathname !== nextLocation.pathname + needLeave && currentLocation.pathname !== nextLocation.pathname, ); + useEffect(() => { + if (blocker.state === "blocked" && !needLeave) { + blocker.proceed(); + } + }, [blocker.state, needLeave]); + useEffect(() => { const fetchData = async () => { - await getCities("ru"); + if (!authStore.me) { + await authStore.getMeAction().catch(() => undefined); + } + if (authStore.canRead("cities")) { + await getCities("ru"); + } else { + await authStore.fetchMeCities().catch(() => undefined); + } await getArticles(languageStore.language); + + const { selectedCityId, selectedCity } = selectedCityStore; + if (selectedCityId && selectedCity && !createSightStore.sight.city_id) { + runInAction(() => { + createSightStore.sight.city_id = selectedCityId; + createSightStore.sight.city = selectedCity.name; + }); + } }; fetchData(); }, []); diff --git a/src/pages/EditSightPage/index.tsx b/src/pages/EditSightPage/index.tsx index 48c3f42..6c94a6c 100644 --- a/src/pages/EditSightPage/index.tsx +++ b/src/pages/EditSightPage/index.tsx @@ -3,7 +3,14 @@ import { InformationTab, LeaveAgree, RightWidgetTab } from "@widgets"; import { LeftWidgetTab } from "@widgets"; import { useEffect, useState } from "react"; import { observer } from "mobx-react-lite"; -import { articlesStore, cityStore, editSightStore } from "@shared"; +import { + articlesStore, + authStore, + cityStore, + editSightStore, + LoadingSpinner, + selectedCityStore, +} from "@shared"; import { useBlocker, useParams } from "react-router-dom"; function a11yProps(index: number) { @@ -15,9 +22,15 @@ function a11yProps(index: number) { export const EditSightPage = observer(() => { const [value, setValue] = useState(0); - const { sight, getSightInfo, needLeaveAgree } = editSightStore; + const [isLoadingData, setIsLoadingData] = useState(true); + const { sight, getSightInfo, needLeaveAgree, getRightArticles } = editSightStore; const { getArticles } = articlesStore; + useEffect(() => { + selectedCityStore.setIsLocked(true); + return () => selectedCityStore.setIsLocked(false); + }, []); + const { id } = useParams(); const { getCities } = cityStore; @@ -33,13 +46,29 @@ export const EditSightPage = observer(() => { useEffect(() => { const fetchData = async () => { if (id) { - await getCities("ru"); - await getSightInfo(+id, "ru"); - await getSightInfo(+id, "en"); - await getSightInfo(+id, "zh"); - await getArticles("ru"); - await getArticles("en"); - await getArticles("zh"); + setIsLoadingData(true); + try { + if (!authStore.me) { + await authStore.getMeAction().catch(() => undefined); + } + if (authStore.canRead("cities")) { + await getCities("ru"); + } else { + await authStore.fetchMeCities().catch(() => undefined); + } + await getSightInfo(+id, "ru"); + await getSightInfo(+id, "en"); + await getSightInfo(+id, "zh"); + await getArticles("ru"); + await getArticles("en"); + await getArticles("zh"); + // Загружаем данные правого виджета перед завершением загрузки + await getRightArticles(+id); + } finally { + setIsLoadingData(false); + } + } else { + setIsLoadingData(false); } }; fetchData(); @@ -79,12 +108,25 @@ export const EditSightPage = observer(() => { - {sight.common.id !== 0 && ( -
- - - -
+ {isLoadingData ? ( + + + + ) : ( + sight.common.id !== 0 && ( +
+ + + +
+ ) )} {blocker.state === "blocked" ? : null} diff --git a/src/pages/LoginPage/index.tsx b/src/pages/LoginPage/index.tsx index ef14c28..d69157a 100644 --- a/src/pages/LoginPage/index.tsx +++ b/src/pages/LoginPage/index.tsx @@ -24,7 +24,6 @@ export const LoginPage = () => { const { login } = authStore; const { getUsers } = userStore; useEffect(() => { - // Load saved credentials if they exist const savedEmail = localStorage.getItem("rememberedEmail"); const savedPassword = localStorage.getItem("rememberedPassword"); if (savedEmail && savedPassword) { @@ -42,7 +41,6 @@ export const LoginPage = () => { try { await login(email, password); - // Save or clear credentials based on remember me checkbox if (rememberMe) { localStorage.setItem("rememberedEmail", email); localStorage.setItem("rememberedPassword", password); diff --git a/src/pages/MainPage/index.tsx b/src/pages/MainPage/index.tsx index 1ca9b32..4aecc6c 100644 --- a/src/pages/MainPage/index.tsx +++ b/src/pages/MainPage/index.tsx @@ -1,37 +1,5 @@ import * as React from "react"; -import Typography from "@mui/material/Typography"; export const MainPage: React.FC = () => { - return ( - <> - - Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod - tempor incididunt ut labore et dolore magna aliqua. Rhoncus dolor purus - non enim praesent elementum facilisis leo vel. Risus at ultrices mi - tempus imperdiet. Semper risus in hendrerit gravida rutrum quisque non - tellus. Convallis convallis tellus id interdum velit laoreet id donec - ultrices. Odio morbi quis commodo odio aenean sed adipiscing. Amet nisl - suscipit adipiscing bibendum est ultricies integer quis. Cursus euismod - quis viverra nibh cras. Metus vulputate eu scelerisque felis imperdiet - proin fermentum leo. Mauris commodo quis imperdiet massa tincidunt. Cras - tincidunt lobortis feugiat vivamus at augue. At augue eget arcu dictum - varius duis at consectetur lorem. Velit sed ullamcorper morbi tincidunt. - Lorem donec massa sapien faucibus et molestie ac. - - - Consequat mauris nunc congue nisi vitae suscipit. Fringilla est - ullamcorper eget nulla facilisi etiam dignissim diam. Pulvinar elementum - integer enim neque volutpat ac tincidunt. Ornare suspendisse sed nisi - lacus sed viverra tellus. Purus sit amet volutpat consequat mauris. - Elementum eu facilisis sed odio morbi. Euismod lacinia at quis risus sed - vulputate odio. Morbi tincidunt ornare massa eget egestas purus viverra - accumsan in. In hendrerit gravida rutrum quisque non tellus orci ac. - Pellentesque nec nam aliquam sem et tortor. Habitant morbi tristique - senectus et. Adipiscing elit duis tristique sollicitudin nibh sit. - Ornare aenean euismod elementum nisi quis eleifend. Commodo viverra - maecenas accumsan lacus vel facilisis. Nulla posuere sollicitudin - aliquam ultrices sagittis orci a. - - - ); + return null; }; diff --git a/src/pages/MapPage/index.tsx b/src/pages/MapPage/index.tsx index 9d1fc12..2c01e72 100644 --- a/src/pages/MapPage/index.tsx +++ b/src/pages/MapPage/index.tsx @@ -6,12 +6,13 @@ import React, { useMemo, } from "react"; import { useNavigate } from "react-router-dom"; -import { Map, View, Overlay, MapBrowserEvent } from "ol"; +import { Map as OLMap, View, Overlay, MapBrowserEvent } from "ol"; import TileLayer from "ol/layer/Tile"; import OSM from "ol/source/OSM"; import VectorLayer from "ol/layer/Vector"; import VectorSource, { VectorSourceEvent } from "ol/source/Vector"; import Cluster from "ol/source/Cluster"; + import { Draw, Modify, @@ -47,6 +48,9 @@ import { InfoIcon, X, Loader2, + EyeOff, + Eye, + Map as MapIcon, } from "lucide-react"; import { toast } from "react-toastify"; import { singleClick, doubleClick } from "ol/events/condition"; @@ -56,7 +60,6 @@ import Source from "ol/source/Source"; import { FeatureLike } from "ol/Feature"; import { createEmpty, extend, getCenter } from "ol/extent"; -// --- CUSTOM SCROLLBAR STYLES --- const scrollbarStyles = ` .scrollbar-hide { -ms-overflow-style: none; @@ -96,8 +99,6 @@ if (typeof document !== "undefined") { document.head.appendChild(styleElement); } -// --- MAP STORE --- -// @ts-ignore import { languageInstance } from "@shared"; import { makeAutoObservable } from "mobx"; @@ -110,14 +111,11 @@ import { carrierStore, } from "@shared"; -// Функция для сброса кешей карты export const clearMapCaches = () => { - // Сброс кешей маршрутов mapStore.routes = []; mapStore.stations = []; mapStore.sights = []; - // Сброс кешей MapService если он доступен if (typeof window !== "undefined" && (window as any).mapServiceInstance) { (window as any).mapServiceInstance.clearCaches(); } @@ -126,6 +124,7 @@ export const clearMapCaches = () => { interface ApiRoute { id: number; route_number: string; + route_name: string; path: [number, number][]; center_latitude: number; center_longitude: number; @@ -135,6 +134,7 @@ interface ApiRoute { interface ApiStation { id: number; name: string; + description?: string; latitude: number; longitude: number; city_id: number; @@ -161,20 +161,74 @@ export type SortType = | "updated_asc" | "updated_desc"; +const HIDDEN_ROUTES_KEY = "mapHiddenRoutes"; +const HIDE_SIGHTS_BY_HIDDEN_ROUTES_KEY = "mapHideSightsByHiddenRoutes"; + +const getStoredHiddenRoutes = (): Set => { + try { + const stored = localStorage.getItem(HIDDEN_ROUTES_KEY); + if (stored) { + const routes = JSON.parse(stored); + if ( + Array.isArray(routes) && + routes.every((id) => typeof id === "number") + ) { + return new Set(routes); + } + } + } catch (error) { + console.warn("Failed to parse stored hidden routes:", error); + } + return new Set(); +}; + +const saveHiddenRoutes = (hiddenRoutes: Set): void => { + try { + localStorage.setItem( + HIDDEN_ROUTES_KEY, + JSON.stringify(Array.from(hiddenRoutes)), + ); + } catch (error) { + console.warn("Failed to save hidden routes:", error); + } +}; + class MapStore { constructor() { makeAutoObservable(this); + + this.hiddenRoutes = getStoredHiddenRoutes(); + + try { + const stored = localStorage.getItem(HIDE_SIGHTS_BY_HIDDEN_ROUTES_KEY); + this.hideSightsByHiddenRoutes = stored + ? JSON.parse(stored) === true + : false; + } catch (e) { + this.hideSightsByHiddenRoutes = false; + } } routes: ApiRoute[] = []; stations: ApiStation[] = []; sights: ApiSight[] = []; + hiddenRoutes: Set; + hideSightsByHiddenRoutes: boolean = false; + routeStationsCache: Map = new Map(); + routeSightsCache: Map = new Map(); + setHideSightsByHiddenRoutes(val: boolean) { + this.hideSightsByHiddenRoutes = val; + try { + localStorage.setItem( + HIDE_SIGHTS_BY_HIDDEN_ROUTES_KEY, + JSON.stringify(!!val), + ); + } catch (e) {} + } - // НОВЫЕ ПОЛЯ ДЛЯ СОРТИРОВКИ stationSort: SortType = "name_asc"; sightSort: SortType = "name_asc"; - // НОВЫЕ МЕТОДЫ-СЕТТЕРЫ setStationSort = (sortType: SortType) => { this.stationSort = sortType; }; @@ -183,17 +237,16 @@ class MapStore { this.sightSort = sortType; }; - // ПРИВАТНЫЙ МЕТОД ДЛЯ ОБЩЕЙ ЛОГИКИ СОРТИРОВКИ private sortFeatures( features: T[], - sortType: SortType + sortType: SortType, ): T[] { const sorted = [...features]; switch (sortType) { case "name_asc": - return sorted.sort((a, b) => a.name.localeCompare(b.name)); + return sorted.sort((a, b) => a.name.trim().localeCompare(b.name.trim())); case "name_desc": - return sorted.sort((a, b) => b.name.localeCompare(a.name)); + return sorted.sort((a, b) => b.name.trim().localeCompare(a.name.trim())); case "created_asc": return sorted.sort((a, b) => { if ( @@ -207,7 +260,7 @@ class MapStore { new Date(b.created_at).getTime() ); } - // Фоллбэк: сортировка по ID, если дата недоступна + return a.id - b.id; }); case "created_desc": @@ -223,7 +276,7 @@ class MapStore { new Date(a.created_at).getTime() ); } - // Фоллбэк: сортировка по ID, если дата недоступна + return b.id - a.id; }); case "updated_asc": @@ -257,7 +310,6 @@ class MapStore { } } - // НОВЫЕ ГЕТТЕРЫ, ВОЗВРАЩАЮЩИЕ ОТСОРТИРОВАННЫЕ СПИСКИ get sortedStations(): ApiStation[] { return this.sortFeatures(this.stations, this.stationSort); } @@ -266,14 +318,13 @@ class MapStore { return this.sortFeatures(this.sights, this.sightSort); } - // ГЕТТЕРЫ ДЛЯ ФИЛЬТРАЦИИ ПО ГОРОДУ get filteredStations(): ApiStation[] { const selectedCityId = selectedCityStore.selectedCityId; if (!selectedCityId) { return this.sortedStations; } return this.sortedStations.filter( - (station) => station.city_id === selectedCityId + (station) => station.city_id === selectedCityId, ); } @@ -283,12 +334,9 @@ class MapStore { return this.routes; } - // Получаем carriers для текущего языка const carriers = carrierStore.carriers.ru.data; - // Фильтруем маршруты по городу через carriers return this.routes.filter((route: ApiRoute) => { - // Находим carrier для маршрута const carrier = carriers.find((c: any) => c.id === route.carrier_id); return carrier && carrier.city_id === selectedCityId; }); @@ -296,24 +344,34 @@ class MapStore { get filteredSights(): ApiSight[] { const selectedCityId = selectedCityStore.selectedCityId; - if (!selectedCityId) { - return this.sortedSights; + const cityFiltered = !selectedCityId + ? this.sortedSights + : this.sortedSights.filter((sight) => sight.city_id === selectedCityId); + + if (!this.hideSightsByHiddenRoutes || this.hiddenRoutes.size === 0) { + return cityFiltered; } - return this.sortedSights.filter( - (sight) => sight.city_id === selectedCityId - ); + + const hiddenSightIds = new Set(); + this.hiddenRoutes.forEach((routeId) => { + const sightIds = this.routeSightsCache.get(routeId) || []; + sightIds.forEach((id) => hiddenSightIds.add(id)); + }); + + return cityFiltered.filter((s) => !hiddenSightIds.has(s.id)); } getRoutes = async () => { const response = await languageInstance("ru").get("/route"); const routesIds = response.data.map((route: any) => route.id); const routePromises = routesIds.map((id: number) => - languageInstance("ru").get(`/route/${id}`) + languageInstance("ru").get(`/route/${id}`), ); const routeResponses = await Promise.all(routePromises); this.routes = routeResponses.map((res) => ({ id: res.data.id, route_number: res.data.route_number, + route_name: res.data.route_name || "", path: res.data.path, center_latitude: res.data.center_latitude, center_longitude: res.data.center_longitude, @@ -321,8 +379,45 @@ class MapStore { })); this.routes = this.routes.sort((a, b) => - a.route_number.localeCompare(b.route_number) + a.route_number.trim().localeCompare(b.route_number.trim()), ); + + await this.preloadRouteStations(routesIds); + + await this.preloadRouteSights(routesIds); + }; + + preloadRouteStations = async (routesIds: number[]) => { + const stationPromises = routesIds.map(async (routeId) => { + try { + const stationsResponse = await languageInstance("ru").get( + `/route/${routeId}/station`, + ); + const stationIds = stationsResponse.data.map((s: any) => s.id); + this.routeStationsCache.set(routeId, stationIds); + } catch (error) { + console.error( + `Failed to preload stations for route ${routeId}:`, + error, + ); + } + }); + await Promise.all(stationPromises); + }; + + preloadRouteSights = async (routesIds: number[]) => { + const sightPromises = routesIds.map(async (routeId) => { + try { + const sightsResponse = await languageInstance("ru").get( + `/route/${routeId}/sight`, + ); + const sightIds = sightsResponse.data.map((s: any) => s.id); + this.routeSightsCache.set(routeId, sightIds); + } catch (error) { + console.error(`Failed to preload sights for route ${routeId}:`, error); + } + }); + await Promise.all(sightPromises); }; getStations = async () => { @@ -352,7 +447,7 @@ class MapStore { createFeature = async (featureType: string, geoJsonFeature: any) => { const { geometry, properties } = geoJsonFeature; - let createdItem; + let createdItem: any; if (featureType === "station") { const name = properties.name || "Остановка 1"; @@ -393,23 +488,12 @@ class MapStore { const route_number = properties.name || "Маршрут 1"; const path = geometry.coordinates.map((c: any) => [c[1], c[0]]); - const lineGeom = new GeoJSON().readGeometry(geometry, { - dataProjection: "EPSG:4326", - featureProjection: "EPSG:3857", - }); - const centerCoords = getCenter(lineGeom.getExtent()); - const [center_longitude, center_latitude] = toLonLat( - centerCoords, - "EPSG:3857" - ); - - // Автоматически назначаем перевозчика из выбранного города let carrier_id = 0; let carrier = ""; if (selectedCityStore.selectedCityId) { const carriersInCity = carrierStore.carriers.ru.data.filter( - (c: any) => c.city_id === selectedCityStore.selectedCityId + (c: any) => c.city_id === selectedCityStore.selectedCityId, ); if (carriersInCity.length > 0) { @@ -421,23 +505,23 @@ class MapStore { const routeData = { route_number, path, - center_latitude, - center_longitude, + center_latitude: path[0][0], + center_longitude: path[0][1], carrier, carrier_id, governor_appeal: 0, rotate: 0, route_direction: false, route_sys_number: route_number, - scale_max: 0, - scale_min: 0, + scale_max: 100, + scale_min: 10, }; await routeStore.createRoute(routeData); if (!carrier_id && selectedCityStore.selectedCityId) { toast.error( - "В выбранном городе нет доступных перевозчиков, маршрут отображается в общем списке" + "В выбранном городе нет доступных перевозчиков, маршрут отображается в общем списке", ); } createdItem = routeStore.routes.data[routeStore.routes.data.length - 1]; @@ -460,11 +544,8 @@ class MapStore { throw new Error(`Unknown feature type for creation: ${featureType}`); } - // @ts-ignore if (featureType === "route") this.routes.push(createdItem); - // @ts-ignore else if (featureType === "station") this.stations.push(createdItem); - // @ts-ignore else if (featureType === "sight") this.sights.push(createdItem); return createdItem; @@ -492,7 +573,7 @@ class MapStore { const centerCoords = getCenter(lineGeom.getExtent()); const [center_longitude, center_latitude] = toLonLat( centerCoords, - "EPSG:3857" + "EPSG:3857", ); data = { route_number: properties.name, @@ -525,7 +606,7 @@ class MapStore { return; } throw new Error( - `Could not find old data for ${featureType} with id ${numericId}` + `Could not find old data for ${featureType} with id ${numericId}`, ); } @@ -545,7 +626,7 @@ class MapStore { const response = await languageInstance("ru").patch( `/${featureType}/${numericId}`, - requestBody + requestBody, ); const updateStore = (store: any[], updatedItem: any) => { @@ -565,18 +646,15 @@ class MapStore { const mapStore = new MapStore(); -// Делаем mapStore доступным глобально для сброса кешей if (typeof window !== "undefined") { (window as any).mapStore = mapStore; } -// --- CONFIGURATION --- export const mapConfig = { center: [30.311, 59.94] as [number, number], zoom: 13, }; -// --- MAP POSITION STORAGE --- const MAP_POSITION_KEY = "mapPosition"; const ACTIVE_SECTION_KEY = "mapActiveSection"; @@ -615,7 +693,6 @@ const saveMapPosition = (position: MapPosition): void => { } }; -// --- ACTIVE SECTION STORAGE --- const getStoredActiveSection = (): string | null => { try { const stored = localStorage.getItem(ACTIVE_SECTION_KEY); @@ -640,41 +717,6 @@ const saveActiveSection = (section: string | null): void => { } }; -// --- SVG ICONS --- -const EditIcon = () => ( - - - -); -const LineIconSvg = () => ( - - - -); - -// --- TYPE DEFINITIONS --- interface MapServiceConfig { target: HTMLElement; center: [number, number]; @@ -684,18 +726,18 @@ interface MapServiceConfig { type FeatureType = "station" | "route" | "sight"; class MapService { - private map: Map | null; + private map: OLMap | null; public pointSource: VectorSource>; public lineSource: VectorSource>; - public clusterLayer: VectorLayer; // Public for the deselect handler - public routeLayer: VectorLayer>>; // Public for deselect + public clusterLayer: VectorLayer; + public routeLayer: VectorLayer>>; private clusterSource: Cluster; private clusterStyleCache: { [key: number]: Style }; private tooltipElement: HTMLElement; private tooltipOverlay: Overlay | null; private mode: string | null; - // @ts-ignore + private currentDrawingType: "Point" | "LineString" | null; private currentDrawingFeatureType: FeatureType | null; private currentInteraction: Draw | null; @@ -703,7 +745,7 @@ class MapService { private selectInteraction: Select; private hoveredFeatureId: string | number | null; private boundHandlePointerMove: ( - event: MapBrowserEvent + event: MapBrowserEvent, ) => void; private boundHandlePointerLeave: () => void; private boundHandleContextMenu: (event: MouseEvent) => void; @@ -714,7 +756,6 @@ class MapService { null; private isCreating: boolean = false; - // Styles private defaultStyle: Style; private selectedStyle: Style; private drawStyle: Style; @@ -729,7 +770,6 @@ class MapService { private hoverSightIconStyle: Style; private universalHoverStyleLine: Style; - // Callbacks private setLoading: (loading: boolean) => void; private setError: (error: string | null) => void; private onModeChangeCallback: (mode: string) => void; @@ -744,7 +784,7 @@ class MapService { onFeaturesChange: (features: Feature[]) => void, onFeatureSelect: (feature: Feature | null) => void, tooltipElement: HTMLElement, - onSelectionChange?: (ids: Set) => void + onSelectionChange?: (ids: Set) => void, ) { this.map = null; this.tooltipElement = tooltipElement; @@ -871,13 +911,12 @@ class MapService { this.routeLayer = new VectorLayer({ source: this.lineSource, - // @ts-ignore + style: (featureLike: FeatureLike) => { const feature = featureLike as Feature; if (!feature) return this.defaultStyle; const fId = feature.getId(); - // Все маршруты всегда отображаются, так как они не кластеризуются const isSelected = this.selectInteraction?.getFeatures().getArray().includes(feature) || (fId !== undefined && this.selectedIds.has(fId)); @@ -894,7 +933,7 @@ class MapService { style: (featureLike: FeatureLike) => { const clusterFeature = featureLike as Feature; const featuresInCluster = clusterFeature.get( - "features" + "features", ) as Feature[]; const size = featuresInCluster.length; @@ -942,9 +981,6 @@ class MapService { }); this.clusterSource.on("change", () => { - // Поскольку маршруты больше не добавляются как точки, - // нам не нужно отслеживать unclusteredRouteIds - // Все маршруты всегда отображаются как линии this.routeLayer.changed(); }); @@ -955,18 +991,18 @@ class MapService { this.pointSource.on( "addfeature", - this.handleFeatureEvent.bind(this) as any + this.handleFeatureEvent.bind(this) as any, ); this.pointSource.on("removefeature", () => this.updateFeaturesInReact()); this.pointSource.on( "changefeature", - this.handleFeatureChange.bind(this) as any + this.handleFeatureChange.bind(this) as any, ); this.lineSource.on("addfeature", this.handleFeatureEvent.bind(this) as any); this.lineSource.on("removefeature", () => this.updateFeaturesInReact()); this.lineSource.on( "changefeature", - this.handleFeatureChange.bind(this) as any + this.handleFeatureChange.bind(this) as any, ); let renderCompleteHandled = false; @@ -976,7 +1012,7 @@ class MapService { const initialCenter = storedPosition?.center || config.center; const initialZoom = storedPosition?.zoom || config.zoom; - this.map = new Map({ + this.map = new OLMap({ target: config.target, layers: [ new TileLayer({ source: new OSM() }), @@ -993,21 +1029,18 @@ class MapService { new KeyboardZoom(), new PinchZoom(), new PinchRotate(), - // Отключаем DoubleClickZoom как было изначально - // new DoubleClickZoom(), + new DragPan({ condition: (event) => { - // Разрешаем перетаскивание только при нажатии средней кнопки мыши (колёсико) const originalEvent = event.originalEvent; if (!originalEvent) return false; - // Проверяем, что это событие мыши и нажата средняя кнопка if ( originalEvent.type === "pointerdown" || originalEvent.type === "pointermove" ) { const pointerEvent = originalEvent as PointerEvent; - return pointerEvent.buttons === 4; // 4 = средняя кнопка мыши + return pointerEvent.buttons === 4; } return false; @@ -1023,7 +1056,7 @@ class MapService { if (center && zoom !== undefined && this.map) { const [lon, lat] = toLonLat( center, - this.map.getView().getProjection() + this.map.getView().getProjection(), ); saveMapPosition({ center: [lon, lat], zoom }); } @@ -1035,7 +1068,7 @@ class MapService { if (center && zoom !== undefined && this.map) { const [lon, lat] = toLonLat( center, - this.map.getView().getProjection() + this.map.getView().getProjection(), ); saveMapPosition({ center: [lon, lat], zoom }); } @@ -1080,7 +1113,7 @@ class MapService { originalFeatures.length === 1 && originalFeatures[0].get("isProxy") ) - return false; // Ignore proxy points + return false; return true; }, multi: true, @@ -1156,7 +1189,7 @@ class MapService { const feature = this.map?.forEachFeatureAtPixel( event.pixel, (f: FeatureLike) => f as Feature, - { layerFilter, hitTolerance: 5 } + { layerFilter, hitTolerance: 5 }, ); if (!feature) return; @@ -1194,7 +1227,7 @@ class MapService { } const newCoordinates = coordinates.filter( - (_, index) => index !== closestIndex + (_, index) => index !== closestIndex, ); lineString.setCoordinates(newCoordinates); this.saveModifiedFeature(feature); @@ -1215,7 +1248,7 @@ class MapService { const selected = new Set(); this.pointSource.forEachFeatureInExtent(extent, (f) => { - if (f.get("isProxy")) return; // Ignore proxy in lasso + if (f.get("isProxy")) return; const geom = f.getGeometry(); if (geom && geom.getType() === "Point") { const pointCoords = (geom as Point).getCoordinates(); @@ -1237,7 +1270,7 @@ class MapService { selected.add(f.getId()!); } } - } + }, ); this.setSelectedIds(selected); @@ -1252,7 +1285,6 @@ class MapService { this.selectInteraction.setActive(false); this.lassoInteraction.setActive(false); - // --- ИСПРАВЛЕНИЕ: Главный обработчик выбора объектов и кластеров this.selectInteraction.on("select", (e: SelectEvent) => { if (this.mode !== "edit" || !this.map) return; @@ -1260,13 +1292,11 @@ class MapService { e.mapBrowserEvent.originalEvent.ctrlKey || e.mapBrowserEvent.originalEvent.metaKey; - // Проверяем, был ли клик по кластеру (группе) if (e.selected.length === 1 && !ctrlKey) { const clickedFeature = e.selected[0]; const originalFeatures = clickedFeature.get("features"); if (originalFeatures && originalFeatures.length > 1) { - // Если да, то приближаем карту const extent = createEmpty(); originalFeatures.forEach((feat: Feature) => { const geom = feat.getGeometry(); @@ -1277,14 +1307,13 @@ class MapService { padding: [60, 60, 60, 60], maxZoom: 18, }); - // Сбрасываем выделение, так как мы не хотим "выделять" сам кластер + this.selectInteraction.getFeatures().clear(); this.setSelectedIds(new Set()); - return; // Завершаем обработку + return; } } - // Стандартная логика выделения для одиночных объектов (или с Ctrl) const newSelectedIds = ctrlKey ? new Set(this.selectedIds) : new Set(); @@ -1293,23 +1322,6 @@ class MapService { const originalFeatures = feature.get("features"); let targetId: string | number | undefined; - if (originalFeatures && originalFeatures.length > 0) { - // Это фича из кластера (может быть и одна) - targetId = originalFeatures[0].getId(); - } else { - // Это линия или что-то не из кластера - targetId = feature.getId(); - } - - if (targetId !== undefined) { - newSelectedIds.add(targetId); - } - }); - - e.deselected.forEach((feature) => { - const originalFeatures = feature.get("features"); - let targetId: string | number | undefined; - if (originalFeatures && originalFeatures.length > 0) { targetId = originalFeatures[0].getId(); } else { @@ -1317,60 +1329,72 @@ class MapService { } if (targetId !== undefined) { - newSelectedIds.delete(targetId); + if (ctrlKey && newSelectedIds.has(targetId)) { + newSelectedIds.delete(targetId); + } else { + newSelectedIds.add(targetId); + } } }); + if (!ctrlKey) { + e.deselected.forEach((feature) => { + const originalFeatures = feature.get("features"); + let targetId: string | number | undefined; + + if (originalFeatures && originalFeatures.length > 0) { + targetId = originalFeatures[0].getId(); + } else { + targetId = feature.getId(); + } + + if (targetId !== undefined) { + newSelectedIds.delete(targetId); + } + }); + } + this.setSelectedIds(newSelectedIds); }); this.map.on("pointermove", this.boundHandlePointerMove as any); const targetEl = this.map.getTargetElement(); if (targetEl instanceof HTMLElement) { - // Устанавливаем курсор pointer по умолчанию для всей карты targetEl.style.cursor = "pointer"; targetEl.addEventListener("contextmenu", this.boundHandleContextMenu); targetEl.addEventListener("pointerleave", this.boundHandlePointerLeave); - // Добавляем обработчики для изменения курсора при нажатии средней кнопки мыши targetEl.addEventListener("pointerdown", (e) => { if (e.buttons === 4) { - // Средняя кнопка мыши - e.preventDefault(); // Предотвращаем скролл страницы + e.preventDefault(); targetEl.style.cursor = "grabbing"; } }); targetEl.addEventListener("pointerup", (e) => { if (e.button === 1) { - // Средняя кнопка мыши отпущена - e.preventDefault(); // Предотвращаем скролл страницы + e.preventDefault(); targetEl.style.cursor = "pointer"; } }); - // Также добавляем обработчик для mousedown/mouseup для совместимости targetEl.addEventListener("mousedown", (e) => { if (e.button === 1) { - // Средняя кнопка мыши - e.preventDefault(); // Предотвращаем скролл страницы + e.preventDefault(); targetEl.style.cursor = "grabbing"; } }); targetEl.addEventListener("mouseup", (e) => { if (e.button === 1) { - // Средняя кнопка мыши отпущена - e.preventDefault(); // Предотвращаем скролл страницы + e.preventDefault(); targetEl.style.cursor = "pointer"; } }); - // Дополнительная защита от нежелательного поведения средней кнопки мыши targetEl.addEventListener("auxclick", (e) => { if (e.button === 1) { - // Средняя кнопка мыши - e.preventDefault(); // Предотвращаем открытие ссылки в новой вкладке + e.preventDefault(); } }); } @@ -1393,7 +1417,7 @@ class MapService { public loadFeaturesFromApi( _apiStations: typeof mapStore.stations, _apiRoutes: typeof mapStore.routes, - _apiSights: typeof mapStore.sights + _apiSights: typeof mapStore.sights, ): void { if (!this.map) return; @@ -1401,19 +1425,33 @@ class MapService { const pointFeatures: Feature[] = []; const lineFeatures: Feature[] = []; - // Используем фильтрованные данные из mapStore const filteredStations = mapStore.filteredStations; const filteredSights = mapStore.filteredSights; const filteredRoutes = mapStore.filteredRoutes; + const stationsInVisibleRoutes = new Set(); + filteredRoutes + .filter((route) => !mapStore.hiddenRoutes.has(route.id)) + .forEach((route) => { + const stationIds = mapStore.routeStationsCache.get(route.id) || []; + stationIds.forEach((id) => stationsInVisibleRoutes.add(id)); + }); + + let skippedStations = 0; filteredStations.forEach((station) => { if (station.longitude == null || station.latitude == null) return; + + if (!stationsInVisibleRoutes.has(station.id)) { + skippedStations++; + return; + } + const point = new Point( transform( [station.longitude, station.latitude], "EPSG:4326", - projection - ) + projection, + ), ); const feature = new Feature({ geometry: point, name: station.name }); feature.setId(`station-${station.id}`); @@ -1424,7 +1462,7 @@ class MapService { filteredSights.forEach((sight) => { if (sight.longitude == null || sight.latitude == null) return; const point = new Point( - transform([sight.longitude, sight.latitude], "EPSG:4326", projection) + transform([sight.longitude, sight.latitude], "EPSG:4326", projection), ); const feature = new Feature({ geometry: point, @@ -1438,10 +1476,13 @@ class MapService { filteredRoutes.forEach((route) => { if (!route.path || route.path.length === 0) return; + + if (mapStore.hiddenRoutes.has(route.id)) return; + const coordinates = route.path .filter((c) => c && c[0] != null && c[1] != null) .map((c: [number, number]) => - transform([c[1], c[0]], "EPSG:4326", projection) + transform([c[1], c[0]], "EPSG:4326", projection), ); if (coordinates.length === 0) return; @@ -1480,7 +1521,6 @@ class MapService { } if (this.tooltipOverlay) this.tooltipOverlay.setPosition(undefined); - // Сбрасываем курсор при покидании области карты if (this.map) { const targetEl = this.map.getTargetElement(); if (targetEl instanceof HTMLElement) { @@ -1528,7 +1568,7 @@ class MapService { public startDrawing( type: "Point" | "LineString", - featureType: FeatureType + featureType: FeatureType, ): void { if (!this.map) return; @@ -1561,10 +1601,9 @@ class MapService { const fType = this.currentDrawingFeatureType; if (!fType) return; - // Проверяем, не идет ли уже процесс создания if (this.isCreating) { toast.warning("Дождитесь завершения создания предыдущего объекта."); - // Удаляем созданный объект из источника + const sourceForDrawing = type === "Point" ? this.pointSource : this.lineSource; setTimeout(() => { @@ -1581,7 +1620,6 @@ class MapService { switch (fType) { case "station": - // Используем полный список из mapStore, а не отфильтрованный const stationNumbers = mapStore.stations .map((station) => { const match = station.name?.match(/^Остановка (\d+)$/); @@ -1593,7 +1631,6 @@ class MapService { resourceName = `Остановка ${nextStationNumber}`; break; case "sight": - // Используем полный список из mapStore, а не отфильтрованный const sightNumbers = mapStore.sights .map((sight) => { const match = sight.name?.match(/^Достопримечательность (\d+)$/); @@ -1605,7 +1642,6 @@ class MapService { resourceName = `Достопримечательность ${nextSightNumber}`; break; case "route": - // Используем полный список из mapStore, а не отфильтрованный const routeNumbers = mapStore.routes .map((route) => { const match = route.route_number?.match(/^Маршрут (\d+)$/); @@ -1647,11 +1683,8 @@ class MapService { private stopDrawing() { if (this.map && this.currentInteraction) { try { - // @ts-ignore this.currentInteraction.abortDrawing(); - } catch (e) { - /* ignore */ - } + } catch (e) {} this.map.removeInteraction(this.currentInteraction); } this.currentInteraction = null; @@ -1662,7 +1695,6 @@ class MapService { public finishDrawing(): void { if (!this.currentInteraction) return; - // Блокируем завершение рисования, если идет процесс создания if (this.isCreating) { toast.warning("Дождитесь завершения создания предыдущего объекта."); return; @@ -1693,21 +1725,21 @@ class MapService { layerFilter, hitTolerance: 5, }); - // Устанавливаем курсор pointer для всей карты, чтобы показать возможность перетаскивания колёсиком + this.map.getTargetElement().style.cursor = hit ? "pointer" : "pointer"; const featureAtPixel: Feature | undefined = this.map.forEachFeatureAtPixel( event.pixel, (f: FeatureLike) => f as Feature, - { layerFilter, hitTolerance: 5 } + { layerFilter, hitTolerance: 5 }, ); let finalFeature: Feature | null = null; if (featureAtPixel) { const originalFeatures = featureAtPixel.get("features"); if (originalFeatures && originalFeatures.length > 0) { - if (originalFeatures[0].get("isProxy")) return; // Ignore hover on proxy + if (originalFeatures[0].get("isProxy")) return; finalFeature = originalFeatures[0]; } else { finalFeature = featureAtPixel; @@ -1775,7 +1807,7 @@ class MapService { public deleteFeature( featureId: string | number | undefined, - recourse: string + recourse: string, ): void { if (featureId === undefined) return; @@ -1831,7 +1863,7 @@ class MapService { const lineFeature = this.lineSource.getFeatureById(id); if (lineFeature) this.lineSource.removeFeature( - lineFeature as Feature + lineFeature as Feature, ); const pointFeature = this.pointSource.getFeatureById(id); if (pointFeature) @@ -1858,11 +1890,11 @@ class MapService { if (targetEl instanceof HTMLElement) { targetEl.removeEventListener( "contextmenu", - this.boundHandleContextMenu + this.boundHandleContextMenu, ); targetEl.removeEventListener( "pointerleave", - this.boundHandlePointerLeave + this.boundHandlePointerLeave, ); } this.map.un("pointermove", this.boundHandlePointerMove as any); @@ -1875,7 +1907,7 @@ class MapService { } private handleFeatureEvent( - event: VectorSourceEvent> + event: VectorSourceEvent>, ): void { if (!event.feature) return; const feature = event.feature; @@ -1886,7 +1918,7 @@ class MapService { } private handleFeatureChange( - event: VectorSourceEvent> + event: VectorSourceEvent>, ): void { if (!event.feature) return; this.updateFeaturesInReact(); @@ -1913,14 +1945,18 @@ class MapService { this.selectInteraction.getFeatures().clear(); ids.forEach((id) => { const lineFeature = this.lineSource.getFeatureById(id); - if (lineFeature) this.selectInteraction.getFeatures().push(lineFeature); + if (lineFeature) { + this.selectInteraction.getFeatures().push(lineFeature); + } const pointFeature = this.pointSource.getFeatureById(id); - if (pointFeature) this.selectInteraction.getFeatures().push(pointFeature); + if (pointFeature) { + this.selectInteraction.getFeatures().push(pointFeature); + } }); this.modifyInteraction.setActive( - this.selectInteraction.getFeatures().getLength() > 0 + this.selectInteraction.getFeatures().getLength() > 0, ); this.clusterLayer.changed(); this.routeLayer.changed(); @@ -1948,17 +1984,15 @@ class MapService { if (this.mode === "lasso") this.deactivateLasso(); else this.activateLasso(); } - public getMap(): Map | null { + public getMap(): OLMap | null { return this.map; } - // Метод для сброса кешей карты public clearCaches() { this.clusterStyleCache = {}; this.hoveredFeatureId = null; this.selectedIds.clear(); - // Очищаем источники данных if (this.pointSource) { this.pointSource.clear(); } @@ -1966,7 +2000,6 @@ class MapService { this.lineSource.clear(); } - // Обновляем слои if (this.clusterLayer) { this.clusterLayer.changed(); } @@ -1993,7 +2026,7 @@ class MapService { if (typeof featureId === "number" || !String(featureId).includes("-")) { console.warn( "Skipping save for feature with non-standard ID:", - featureId + featureId, ); return; } @@ -2016,10 +2049,9 @@ class MapService { const featureType = feature.get("featureType") as FeatureType; if (!featureType || !this.map) return; - // Проверяем, не идет ли уже процесс создания if (this.isCreating) { toast.warning("Дождитесь завершения создания предыдущего объекта."); - // Удаляем незавершенный объект с карты + if (feature.getGeometry()?.getType() === "LineString") { if (this.lineSource.hasFeature(feature as Feature)) this.lineSource.removeFeature(feature as Feature); @@ -2042,41 +2074,32 @@ class MapService { try { const createdFeatureData = await mapStore.createFeature( featureType, - featureGeoJSON + featureGeoJSON, ); const newFeatureId = `${featureType}-${createdFeatureData.id}`; - // @ts-ignore + const displayName = featureType === "route" - ? // @ts-ignore - createdFeatureData.route_number - : // @ts-ignore - createdFeatureData.name; + ? createdFeatureData.route_number + : createdFeatureData.name; if (featureType === "route") { - // @ts-ignore const routeData = createdFeatureData as ApiRoute; const projection = this.map.getView().getProjection(); - // Update existing line feature feature.setId(newFeatureId); feature.set("name", displayName); - // Optionally update geometry if server modified it const lineGeom = new LineString( routeData.path.map((c) => - transform([c[1], c[0]], "EPSG:4326", projection) - ) + transform([c[1], c[0]], "EPSG:4326", projection), + ), ); feature.setGeometry(lineGeom); - - // Не создаем прокси-точку для маршрута - только линия } else { - // For points: update existing feature.setId(newFeatureId); feature.set("name", displayName); - // No need to remove and re-add since it's already in the source } this.updateFeaturesInReact(); @@ -2098,7 +2121,6 @@ class MapService { } } -// --- MAP CONTROLS COMPONENT --- interface MapControlsProps { mapService: MapService | null; activeMode: string; @@ -2130,7 +2152,7 @@ const MapControls: React.FC = ({ mode: "edit", title: "Редактировать", longTitle: "Редактирование", - icon: , + icon: , action: () => mapService.activateEditMode(), }, { @@ -2151,7 +2173,7 @@ const MapControls: React.FC = ({ mode: "drawing-route", title: "Маршрут", longTitle: "Добавить маршрут (Правый клик для завершения)", - icon: , + icon: , action: () => mapService.startDrawing("LineString", "route"), }, @@ -2174,8 +2196,8 @@ const MapControls: React.FC = ({ isDisabled ? "bg-gray-200 text-gray-400 cursor-not-allowed" : isActive - ? "bg-blue-600 text-white shadow-md hover:bg-blue-700" - : "bg-gray-100 text-gray-700 hover:bg-blue-100 hover:text-blue-700" + ? "bg-blue-600 text-white shadow-md hover:bg-blue-700" + : "bg-gray-100 text-gray-700 hover:bg-blue-100 hover:text-blue-700" }`; return ( + {featureType === "route" && ( + + )} + {featureType === "route" && ( + + )} + {showSightsOptions && ( +
+
+ Будут скрыты все достопримечательности, привязанные к + скрытым маршрутам. +
+
+ )} +
), content: renderFeatureList(sortedSights, "sight", Landmark), @@ -2661,7 +2992,7 @@ const MapSightbar: React.FC = observer( )}
- ) + ), ) )}
@@ -2678,9 +3009,9 @@ const MapSightbar: React.FC = observer( )}
); - } + }, ); -// --- MAP PAGE COMPONENT --- + export const MapPage: React.FC = observer(() => { const mapRef = useRef(null); const tooltipRef = useRef(null); @@ -2694,7 +3025,7 @@ export const MapPage: React.FC = observer(() => { const [selectedFeatureForSidebar, setSelectedFeatureForSidebar] = useState | null>(null); const [selectedIds, setSelectedIds] = useState>( - new Set() + new Set(), ); const [isLassoActive, setIsLassoActive] = useState(false); const [showHelp, setShowHelp] = useState(false); @@ -2706,12 +3037,11 @@ export const MapPage: React.FC = observer(() => { const handleFeaturesChange = useCallback( (feats: Feature[]) => setMapFeatures([...feats]), - [] + [], ); const handleFeatureSelectForSidebar = useCallback( (feat: Feature | null) => { - // Logic to sync sidebar selection with map setSelectedFeatureForSidebar(feat); if (feat) { const featureType = feat.get("featureType"); @@ -2719,8 +3049,8 @@ export const MapPage: React.FC = observer(() => { featureType === "sight" ? "sights" : featureType === "route" - ? "lines" - : "layers"; + ? "lines" + : "layers"; setActiveSectionFromParent(sectionId); setTimeout(() => { document @@ -2729,7 +3059,7 @@ export const MapPage: React.FC = observer(() => { }, 100); } }, - [] + [], ); useEffect(() => { @@ -2750,7 +3080,7 @@ export const MapPage: React.FC = observer(() => { mapService.loadFeaturesFromApi( mapStore.stations, mapStore.routes, - mapStore.sights + mapStore.sights, ); } catch (e) { console.error("Failed to load initial map data:", e); @@ -2769,11 +3099,10 @@ export const MapPage: React.FC = observer(() => { handleFeaturesChange, handleFeatureSelectForSidebar, tooltipRef.current, - setSelectedIds + setSelectedIds, ); setMapServiceInstance(service); - // Делаем mapServiceInstance доступным глобально для сброса кешей if (typeof window !== "undefined") { (window as any).mapServiceInstance = service; } @@ -2781,7 +3110,7 @@ export const MapPage: React.FC = observer(() => { loadInitialData(service); } catch (e: any) { setError( - `Ошибка инициализации карты: ${e.message || "Неизвестная ошибка"}.` + `Ошибка инициализации карты: ${e.message || "Неизвестная ошибка"}.`, ); setIsMapLoading(false); setIsDataLoading(false); @@ -2791,15 +3120,12 @@ export const MapPage: React.FC = observer(() => { service?.destroy(); setMapServiceInstance(null); - // Удаляем глобальную ссылку if (typeof window !== "undefined") { delete (window as any).mapServiceInstance; } }; - // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - // --- ИСПРАВЛЕНИЕ: Этот хук отвечает ТОЛЬКО за клики по ПУСТОМУ месту useEffect(() => { const olMap = mapServiceInstance?.getMap(); if (!olMap || !mapServiceInstance) return; @@ -2814,11 +3140,9 @@ export const MapPage: React.FC = observer(() => { hitTolerance: 5, }); - // Если клик был НЕ по объекту, снимаем выделение if (!hit) { mapServiceInstance.unselect(); } - // Если клик был ПО объекту, НИЧЕГО не делаем. За это отвечает selectInteraction. }; olMap.on("click", handleMapClickForDeselect); @@ -2871,29 +3195,44 @@ export const MapPage: React.FC = observer(() => { saveActiveSection(activeSectionFromParent); }, [activeSectionFromParent]); - // Перезагружаем данные при изменении города useEffect(() => { if (mapServiceInstance && !isDataLoading) { - // Очищаем текущие объекты на карте mapServiceInstance.pointSource.clear(); mapServiceInstance.lineSource.clear(); - // Загружаем новые данные с фильтрацией по городу mapServiceInstance.loadFeaturesFromApi( mapStore.stations, mapStore.routes, - mapStore.sights + mapStore.sights, ); } }, [selectedCityId, mapServiceInstance, isDataLoading]); + useEffect(() => { + if (mapServiceInstance && !isDataLoading) { + mapServiceInstance.pointSource.clear(); + mapServiceInstance.lineSource.clear(); + + mapServiceInstance.loadFeaturesFromApi( + mapStore.stations, + mapStore.routes, + mapStore.sights, + ); + } + }, [ + mapStore.hideSightsByHiddenRoutes, + mapStore.hiddenRoutes.size, + mapServiceInstance, + isDataLoading, + ]); + const showLoader = isMapLoading || isDataLoading; const showContent = mapServiceInstance && !showLoader && !error; const isAnythingSelected = selectedFeatureForSidebar !== null || selectedIds.size > 0; return ( -
+
{ /> )} - - {showHelp && ( -
-

Горячие клавиши:

-
    -
  • - - Shift - {" "} - - Режим выделения (лассо) -
  • -
  • - - Ctrl + клик - {" "} - - Добавить/убрать из выделения -
  • -
  • - Esc{" "} - - Отменить выделение -
  • -
+
+

Управление картой

+
+
+

Перемещение и масштаб:

+
    +
  • Колесо мыши — приблизить / отдалить.
  • +
  • + Средняя кнопка мыши (колесо зажато) — перетаскивание карты. +
  • +
+
+ +
+

Выделение объектов:

+
    +
  • Одинарный клик по объекту — выделить и центрировать.
  • +
  • + + Ctrl + {" "} + + клик — добавить / убрать объект из выделения. +
  • +
  • + + Shift + {" "} + — временно включить режим лассо (выделение области). +
  • +
  • Клик по пустому месту карты — снять выделение.
  • +
  • + + Esc + {" "} + — снять выделение всех объектов. +
  • +
+
+ +
+

+ Рисование и редактирование: +

+
    +
  • + Кнопки в верхней панели — выбор режима: редактирование, + добавление остановки, достопримечательности или маршрута. +
  • +
  • + При рисовании маршрута: правый клик — завершить линию. +
  • +
  • + В режиме редактирования: перетаскивайте точки маршрута для + изменения траектории. +
  • +
  • + Двойной клик по внутренней точке маршрута — удалить эту + точку (при наличии не менее 2 точек). +
  • +
+
+ +
+

Боковая панель:

+
    +
  • Клик по строке в списке — перейти к объекту на карте.
  • +
  • + Иконка карандаша — открыть объект в режиме редактирования. +
  • +
  • + Иконка карты у маршрутов — открыть предпросмотр маршрута. +
  • +
  • + Иконка глаза у маршрутов — скрыть / показать маршрут и + связанные остановки. +
  • +
  • Иконка корзины — удалить объект.
  • +
+
+
)} + +
{showContent && ( - a.route_number.localeCompare(b.route_number) + a.route_number.trim().localeCompare(b.route_number.trim()) ); }; @@ -136,7 +134,6 @@ class MapStore { longitude: geometry.coordinates[0], }; - // ИЗМЕНЕНИЕ: Сравнение координат с допуском if ( originalStation.name !== currentStation.name || Math.abs(originalStation.latitude - currentStation.latitude) > @@ -155,7 +152,6 @@ class MapStore { path: geometry.coordinates, }; - // ИЗМЕНЕНИЕ: Используется новая функция arePathsEqual с допуском if ( originalRoute.route_number !== currentRoute.route_number || !arePathsEqual(originalRoute.path, currentRoute.path) @@ -173,7 +169,6 @@ class MapStore { longitude: geometry.coordinates[0], }; - // ИЗМЕНЕНИЕ: Сравнение координат с допуском if ( originalSight.name !== currentSight.name || originalSight.description !== currentSight.description || diff --git a/src/pages/Media/MediaCreatePage/index.tsx b/src/pages/Media/MediaCreatePage/index.tsx index c283a4a..8a934bb 100644 --- a/src/pages/Media/MediaCreatePage/index.tsx +++ b/src/pages/Media/MediaCreatePage/index.tsx @@ -7,10 +7,10 @@ import { FormControl, InputLabel, } from "@mui/material"; -import { mediaStore, MEDIA_TYPE_LABELS } from "@shared"; +import { mediaStore, MEDIA_TYPE_LABELS, selectedCityStore } from "@shared"; import { observer } from "mobx-react-lite"; import { ArrowLeft, Loader2, Save } from "lucide-react"; -import { useState } from "react"; +import { useState, useEffect } from "react"; import { useNavigate } from "react-router-dom"; import { toast } from "react-toastify"; @@ -20,10 +20,15 @@ export const MediaCreatePage = observer(() => { const [type, setType] = useState(""); const [isLoading, setIsLoading] = useState(false); + useEffect(() => { + selectedCityStore.setIsLocked(true); + return () => selectedCityStore.setIsLocked(false); + }, []); + const handleCreate = async () => { try { setIsLoading(true); - await mediaStore.createMedia(name, type); + await mediaStore.createMedia(name, type, selectedCityStore.selectedCityId); toast.success("Медиа успешно создано"); navigate("/media"); } catch (error) { diff --git a/src/pages/Media/MediaEditPage/index.tsx b/src/pages/Media/MediaEditPage/index.tsx index 1f4b6e7..934d093 100644 --- a/src/pages/Media/MediaEditPage/index.tsx +++ b/src/pages/Media/MediaEditPage/index.tsx @@ -21,6 +21,8 @@ import { mediaStore, MEDIA_TYPE_LABELS, languageStore, + LoadingSpinner, + selectedCityStore, } from "@shared"; import { MediaViewer } from "@widgets"; @@ -33,7 +35,7 @@ export const MediaEditPage = observer(() => { const fileInputRef = useRef(null); const [newFile, setNewFile] = useState(null); - const [uploadDialogOpen, setUploadDialogOpen] = useState(false); // State for the upload dialog + const [uploadDialogOpen, setUploadDialogOpen] = useState(false); const media = id ? mediaStore.media.find((m) => m.id === id) : null; const [mediaName, setMediaName] = useState(media?.media_name ?? ""); @@ -41,6 +43,11 @@ export const MediaEditPage = observer(() => { const [mediaType, setMediaType] = useState(media?.media_type ?? 1); const [availableMediaTypes, setAvailableMediaTypes] = useState([]); + useEffect(() => { + selectedCityStore.setIsLocked(true); + return () => selectedCityStore.setIsLocked(false); + }, []); + useEffect(() => { if (id) { mediaStore.getOneMedia(id); @@ -55,51 +62,27 @@ export const MediaEditPage = observer(() => { setMediaFilename(media.filename); setMediaType(media.media_type); - // Set available media types based on current file extension const extension = media.filename.split(".").pop()?.toLowerCase(); if (extension) { if (["glb", "gltf"].includes(extension)) { - setAvailableMediaTypes([6]); // 3D model + setAvailableMediaTypes([6]); } else if ( ["jpg", "jpeg", "png", "gif", "svg", "webp", "bmp"].includes( extension ) ) { - setAvailableMediaTypes([1, 3, 4, 5]); // Photo, Icon, Watermark, Panorama + setAvailableMediaTypes([1, 3, 4, 5]); } else if (["mp4", "webm", "mov"].includes(extension)) { - setAvailableMediaTypes([2]); // Video + setAvailableMediaTypes([2]); } } } }, [media]); useEffect(() => { - // Устанавливаем русский язык при загрузке страницы languageStore.setLanguage("ru"); }, []); - // const handleDrop = (e: DragEvent) => { - // e.preventDefault(); - // e.stopPropagation(); - // setIsDragging(false); - - // const files = Array.from(e.dataTransfer.files); - // if (files.length > 0) { - // setNewFile(files[0]); - // setMediaFilename(files[0].name); - // setUploadDialogOpen(true); // Open dialog on file drop - // } - // }; - - // const handleDragOver = (e: DragEvent) => { - // e.preventDefault(); - // setIsDragging(true); - // }; - - // const handleDragLeave = () => { - // setIsDragging(false); - // }; - const handleFileSelect = (e: React.ChangeEvent) => { const files = e.target.files; if (files && files.length > 0) { @@ -107,26 +90,25 @@ export const MediaEditPage = observer(() => { setNewFile(file); setMediaFilename(file.name); - // Determine media type based on file extension const extension = file.name.split(".").pop()?.toLowerCase(); if (extension) { if (["glb", "gltf"].includes(extension)) { - setAvailableMediaTypes([6]); // 3D model + setAvailableMediaTypes([6]); setMediaType(6); } else if ( ["jpg", "jpeg", "png", "gif", "svg", "webp", "bmp"].includes( extension ) ) { - setAvailableMediaTypes([1, 3, 4, 5]); // Photo, Icon, Watermark, Panorama - setMediaType(1); // Default to Photo + setAvailableMediaTypes([1, 3, 4, 5]); + setMediaType(1); } else if (["mp4", "webm", "mov"].includes(extension)) { - setAvailableMediaTypes([2]); // Video + setAvailableMediaTypes([2]); setMediaType(2); } } - setUploadDialogOpen(true); // Open dialog on file selection + setUploadDialogOpen(true); } }; @@ -143,11 +125,6 @@ export const MediaEditPage = observer(() => { type: mediaType, }); - // If a new file was selected, the actual file upload will happen - // via the UploadMediaDialog. We just need to make sure the metadata - // is updated correctly before or after. - // Since the dialog handles the actual upload, we don't call updateMediaFile here. - setSuccess(true); handleUploadSuccess(); } catch (err) { @@ -158,20 +135,25 @@ export const MediaEditPage = observer(() => { }; const handleUploadSuccess = () => { - // After successful upload in the dialog, refresh media data if needed if (id) { mediaStore.getOneMedia(id); } - setNewFile(null); // Clear the new file state after successful upload + setNewFile(null); setUploadDialogOpen(false); setSuccess(true); }; if (!media && id) { - // Only show loading if an ID is present and media is not yet loaded return ( - - + + ); } diff --git a/src/pages/Media/MediaListPage/index.tsx b/src/pages/Media/MediaListPage/index.tsx index 51916ac..72536fa 100644 --- a/src/pages/Media/MediaListPage/index.tsx +++ b/src/pages/Media/MediaListPage/index.tsx @@ -1,7 +1,7 @@ import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid"; import { ruRU } from "@mui/x-data-grid/locales"; -import { languageStore, MEDIA_TYPE_LABELS, mediaStore } from "@shared"; -import { useEffect, useState } from "react"; +import { authStore, languageStore, MEDIA_TYPE_LABELS, mediaStore, SearchInput, selectedCityStore } from "@shared"; +import { useEffect, useState, useMemo } from "react"; import { observer } from "mobx-react-lite"; import { Eye, Trash2, Minus } from "lucide-react"; import { useNavigate } from "react-router-dom"; @@ -16,7 +16,13 @@ export const MediaListPage = observer(() => { const [rowId, setRowId] = useState(null); const [ids, setIds] = useState([]); const [isLoading, setIsLoading] = useState(false); + const [paginationModel, setPaginationModel] = useState({ + page: 0, + pageSize: 50, + }); const { language } = languageStore; + const canWriteMedia = authStore.canWrite("sights"); + const [searchQuery, setSearchQuery] = useState(""); useEffect(() => { const fetchMedia = async () => { @@ -67,17 +73,17 @@ export const MediaListPage = observer(() => { field: "actions", headerName: "Действия", width: 200, - align: "center", - headerAlign: "center", + align: "center" as const, + headerAlign: "center" as const, sortable: false, - - renderCell: (params: GridRenderCellParams) => { - return ( -
- + renderCell: (params: GridRenderCellParams) => ( +
+ + {canWriteMedia && ( -
- ); - }, + )} +
+ ), }, ]; - const rows = media.map((media) => ({ - id: media.id, - media_name: media.media_name, - media_type: media.media_type, - })); + const rows = useMemo(() => { + const query = searchQuery.trim().toLowerCase(); + const cityId = selectedCityStore.selectedCityId; + return media + .filter((item) => !query || (item.media_name ?? "").toLowerCase().includes(query)) + .filter((item) => !cityId || item.city_id === cityId) + .map((item) => ({ + id: item.id, + media_name: item.media_name, + media_type: item.media_type, + })); + }, [media, searchQuery, selectedCityStore.selectedCityId]); return ( <>
-
0 ? 1 : 0 }} - > - -
+ {(rows.length > 0 || searchQuery) && ( + + )} + + {canWriteMedia && ids.length > 0 && ( +
+ +
+ )} { - setIds(Array.from(newSelection.ids) as string[]); - }} - hideFooter + paginationModel={paginationModel} + onPaginationModelChange={setPaginationModel} + pageSizeOptions={[50]} + onRowSelectionModelChange={ + canWriteMedia + ? (newSelection: any) => { + if (Array.isArray(newSelection)) { + const selectedIds = newSelection.map( + (id: string | number) => String(id) + ); + setIds(selectedIds); + } else if ( + newSelection && + typeof newSelection === "object" && + "ids" in newSelection + ) { + const idsSet = newSelection.ids as Set; + const selectedIds = Array.from(idsSet).map( + (id: string | number) => String(id) + ); + setIds(selectedIds); + } else { + setIds([]); + } + } + : undefined + } localeText={ruRU.components.MuiDataGrid.defaultProps.localeText} slots={{ noRowsOverlay: () => ( diff --git a/src/pages/Route/DemoPage/index.tsx b/src/pages/Route/DemoPage/index.tsx new file mode 100644 index 0000000..b786642 --- /dev/null +++ b/src/pages/Route/DemoPage/index.tsx @@ -0,0 +1,26 @@ +import { useEffect } from "react"; +import { useParams } from "react-router-dom"; +import { apiStore } from "../../../client/src/api/ApiStore/store"; +import App from "../../../client/src/App"; +import "../../../client/src/index.css"; +import "../../../client/src/App.css"; + +export const DemoPage = () => { + const { id } = useParams<{ id: string }>(); + + // Устанавливаем routeId синхронно при рендере, + // чтобы App уже имел его при монтировании + if (id && apiStore.routeId !== id) { + apiStore.setRouteId(id); + } + + useEffect(() => { + return () => { + apiStore.stopPositionSimulation(); + }; + }, []); + + if (!id) return null; + + return ; +}; diff --git a/src/pages/Route/LinekedStations.tsx b/src/pages/Route/LinekedStations.tsx index edb0926..c4fc730 100644 --- a/src/pages/Route/LinekedStations.tsx +++ b/src/pages/Route/LinekedStations.tsx @@ -2,7 +2,6 @@ import { useState, useEffect } from "react"; import { Stack, Typography, - Button, FormControl, Accordion, AccordionSummary, @@ -35,14 +34,15 @@ import { } from "@hello-pangea/dnd"; import { + AnimatedCircleButton, authInstance, languageStore, routeStore, selectedCityStore, + stationsStore, } from "@shared"; import { EditStationModal } from "../../widgets/modals/EditStationModal"; -// Helper function to insert an item at a specific position (1-based index) function insertAtPosition(arr: T[], pos: number, value: T): T[] { const index = pos - 1; const result = [...arr]; @@ -54,7 +54,6 @@ function insertAtPosition(arr: T[], pos: number, value: T): T[] { return result; } -// Helper function to reorder items after drag and drop const reorder = (list: T[], startIndex: number, endIndex: number): T[] => { const result = Array.from(list); const [removed] = result.splice(startIndex, 1); @@ -79,7 +78,6 @@ type LinkedItemsProps = { disableCreation?: boolean; updatedLinkedItems?: T[]; refresh?: number; - routeDirection?: boolean; }; export const LinkedItems = < @@ -101,7 +99,7 @@ export const LinkedItems = < }} > - Привязанные станции + Привязанные остановки @@ -129,7 +127,6 @@ const LinkedItemsContentsInner = < disableCreation = false, updatedLinkedItems, refresh, - routeDirection, }: LinkedItemsProps) => { const { language } = languageStore; @@ -143,6 +140,9 @@ const LinkedItemsContentsInner = < const [selectedItems, setSelectedItems] = useState>(new Set()); const [activeTab, setActiveTab] = useState(0); const [searchQuery, setSearchQuery] = useState(""); + const [isLinkingSingle, setIsLinkingSingle] = useState(false); + const [isLinkingBulk, setIsLinkingBulk] = useState(false); + const [detachingIds, setDetachingIds] = useState>(new Set()); useEffect(() => {}, [error]); @@ -152,13 +152,6 @@ const LinkedItemsContentsInner = < const availableItems = allItems .filter((item) => !linkedItems.some((linked) => linked.id === item.id)) .filter((item) => { - // Если направление маршрута не указано, показываем все станции - if (routeDirection === undefined) return true; - // Фильтруем станции по направлению маршрута - return item.direction === routeDirection; - }) - .filter((item) => { - // Фильтруем по городу из навбара const selectedCityId = selectedCityStore.selectedCityId; if (selectedCityId && "city_id" in item) { return item.city_id === selectedCityId; @@ -167,10 +160,12 @@ const LinkedItemsContentsInner = < }) .sort((a, b) => a.name.localeCompare(b.name)); - // Фильтрация по поиску для массового режима const filteredAvailableItems = availableItems.filter((item) => { if (!searchQuery.trim()) return true; - return String(item.name).toLowerCase().includes(searchQuery.toLowerCase()); + const query = searchQuery.toLowerCase(); + const name = String(item.name || "").toLowerCase(); + const description = String(item.description || "").toLowerCase(); + return name.includes(query) || description.includes(query); }); useEffect(() => { @@ -187,6 +182,19 @@ const LinkedItemsContentsInner = < setPosition(linkedItems.length + 1); }, [linkedItems.length]); + const getStationTransfers = (stationId: number, fallbackTransfers?: any) => { + const { stationLists } = stationsStore; + for (const lang of ["ru", "en", "zh"] as const) { + const station = stationLists[lang].data.find( + (s: any) => s.id === stationId + ); + if (station?.transfers) { + return station.transfers; + } + } + return fallbackTransfers; + }; + const onDragEnd = (result: DropResult) => { if (!result.destination) return; @@ -200,7 +208,13 @@ const LinkedItemsContentsInner = < authInstance .post(`/${parentResource}/${parentId}/${childResource}`, { - stations: reorderedItems.map((item) => ({ id: item.id })), + stations: reorderedItems.map((item) => { + const transfers = getStationTransfers(item.id, item.transfers); + return { + ...item, + transfers: transfers || item.transfers, + }; + }), }) .catch((error) => { console.error("Error updating station order:", error); @@ -238,7 +252,7 @@ const LinkedItemsContentsInner = < }) .catch((error) => { console.error("Error fetching all items:", error); - setError("Failed to load available stations"); + setError(null); setAllItems([]); }); } @@ -247,14 +261,32 @@ const LinkedItemsContentsInner = < const linkItem = () => { if (selectedItemId !== null) { setError(null); + const selectedItem = allItems.find((item) => item.id === selectedItemId); const requestData = { stations: insertAtPosition( - linkedItems.map((item) => ({ id: item.id })), + linkedItems.map((item) => { + const transfers = getStationTransfers(item.id, item.transfers); + return { + ...item, + transfers: transfers || item.transfers, + }; + }), position, - { id: selectedItemId } + (() => { + if (!selectedItem) return { id: selectedItemId }; + const transfers = getStationTransfers( + selectedItemId, + selectedItem.transfers + ); + return { + ...selectedItem, + transfers: transfers || selectedItem.transfers, + }; + })() ), }; + setIsLinkingSingle(true); authInstance .post(`/${parentResource}/${parentId}/${childResource}`, requestData) .then(() => { @@ -273,12 +305,20 @@ const LinkedItemsContentsInner = < .catch((error) => { console.error("Error linking item:", error); setError("Failed to link station"); + }) + .finally(() => { + setIsLinkingSingle(false); }); } }; const deleteItem = (itemId: number) => { setError(null); + setDetachingIds((prev) => { + const next = new Set(prev); + next.add(itemId); + return next; + }); authInstance .delete(`/${parentResource}/${parentId}/${childResource}`, { data: { [`${childResource}_id`]: itemId }, @@ -290,6 +330,13 @@ const LinkedItemsContentsInner = < .catch((error) => { console.error("Error deleting item:", error); setError("Failed to delete station"); + }) + .finally(() => { + setDetachingIds((prev) => { + const next = new Set(prev); + next.delete(itemId); + return next; + }); }); }; @@ -316,10 +363,25 @@ const LinkedItemsContentsInner = < if (selectedItems.size === 0) return; setError(null); - const selectedStations = Array.from(selectedItems).map((id) => ({ id })); + setIsLinkingBulk(true); + const selectedStations = Array.from(selectedItems).map((id) => { + const item = allItems.find((item) => item.id === id); + if (!item) return { id }; + const transfers = getStationTransfers(id, item.transfers); + return { + ...item, + transfers: transfers || item.transfers, + }; + }); const requestData = { stations: [ - ...linkedItems.map((item) => ({ id: item.id })), + ...linkedItems.map((item) => { + const transfers = getStationTransfers(item.id, item.transfers); + return { + ...item, + transfers: transfers || item.transfers, + }; + }), ...selectedStations, ], }; @@ -335,6 +397,9 @@ const LinkedItemsContentsInner = < .catch((error) => { console.error("Error linking items:", error); setError("Failed to link stations"); + }) + .finally(() => { + setIsLinkingBulk(false); }); }; @@ -404,7 +469,7 @@ const LinkedItemsContentsInner = < ))} {type === "edit" && ( - + )} @@ -432,19 +499,13 @@ const LinkedItemsContentsInner = < {linkedItems.length === 0 && !isLoading && ( - Станции не найдены + Остановки не найдены )} {type === "edit" && !disableCreation && ( Добавить остановки - {routeDirection !== undefined && ( - - Показываются только остановки для{" "} - {routeDirection ? "прямого" : "обратного"} направления - - )} )} @@ -480,16 +542,15 @@ const LinkedItemsContentsInner = < option.id === value?.id } filterOptions={(options, { inputValue }) => { - const searchWords = inputValue - .toLowerCase() - .split(" ") - .filter(Boolean); + if (!inputValue.trim()) return options; + const query = inputValue.toLowerCase(); return options.filter((option) => { - const optionWords = String(option.name) - .toLowerCase() - .split(" "); - return searchWords.every((searchWord) => - optionWords.some((word) => word.startsWith(searchWord)) + const name = String(option.name || "").toLowerCase(); + const description = String( + option.description || "" + ).toLowerCase(); + return ( + name.includes(query) || description.includes(query) ); }); }} @@ -525,14 +586,15 @@ const LinkedItemsContentsInner = < /> - + )} @@ -562,7 +624,14 @@ const LinkedItemsContentsInner = < size="small" /> } - label={String(item.name)} + label={ +
+

{String(item.name)}

+

+ {String(item.description)} +

+
+ } sx={{ margin: 0, "& .MuiFormControlLabel-label": { @@ -585,14 +654,15 @@ const LinkedItemsContentsInner = < - + )} diff --git a/src/pages/Route/RouteCreatePage/index.tsx b/src/pages/Route/RouteCreatePage/index.tsx index bc0c5d6..97e8b27 100644 --- a/src/pages/Route/RouteCreatePage/index.tsx +++ b/src/pages/Route/RouteCreatePage/index.tsx @@ -1,6 +1,5 @@ import { Button, - Paper, TextField, Select, MenuItem, @@ -13,49 +12,73 @@ import { DialogContent, DialogActions, } from "@mui/material"; -import { MediaViewer } from "@widgets"; +import { MediaViewer, VideoPreviewCard, ImageUploadCard } from "@widgets"; import { observer } from "mobx-react-lite"; -import { ArrowLeft, Loader2, Save, Plus } from "lucide-react"; +import { ArrowLeft, Loader2, Save, Plus, X } from "lucide-react"; import { useEffect, useState, useMemo } from "react"; import { useNavigate } from "react-router-dom"; import { toast } from "react-toastify"; -import { carrierStore } from "../../../shared/store/CarrierStore"; -import { articlesStore } from "../../../shared/store/ArticlesStore"; -import { Route, routeStore } from "../../../shared/store/RouteStore"; import { + carrierStore, + articlesStore, + routeStore, languageStore, - SelectArticleModal, + mediaStore, + isMediaIdEmpty, + ArticleSelectOrCreateDialog, SelectMediaDialog, selectedCityStore, + UploadMediaDialog, + PreviewMediaDialog, } from "@shared"; +import type { Route } from "@shared"; export const RouteCreatePage = observer(() => { const navigate = useNavigate(); + + useEffect(() => { + selectedCityStore.setIsLocked(true); + return () => selectedCityStore.setIsLocked(false); + }, []); + const [carrier, setCarrier] = useState(""); const [routeNumber, setRouteNumber] = useState(""); const [routeCoords, setRouteCoords] = useState(""); const [govRouteNumber, setGovRouteNumber] = useState(""); const [governorAppeal, setGovernorAppeal] = useState(""); + const [buttonText, setButtonText] = useState(""); const [direction, setDirection] = useState("backward"); - const [scaleMin, setScaleMin] = useState(""); - const [scaleMax, setScaleMax] = useState(""); + const [scaleMin, setScaleMin] = useState("10"); + const [scaleMax, setScaleMax] = useState("100"); + const [routeName, setRouteName] = useState(""); const [turn, setTurn] = useState(""); const [centerLat, setCenterLat] = useState(""); const [centerLng, setCenterLng] = useState(""); + const [videoTimer, setVideoTimer] = useState(420); const [videoPreview, setVideoPreview] = useState(""); + const [icon, setIcon] = useState(""); const [isLoading, setIsLoading] = useState(false); const [isSelectArticleDialogOpen, setIsSelectArticleDialogOpen] = useState(false); const [isSelectVideoDialogOpen, setIsSelectVideoDialogOpen] = useState(false); const [isVideoPreviewOpen, setIsVideoPreviewOpen] = useState(false); + const [isUploadVideoDialogOpen, setIsUploadVideoDialogOpen] = useState(false); + const [isSelectIconDialogOpen, setIsSelectIconDialogOpen] = useState(false); + const [isUploadIconDialogOpen, setIsUploadIconDialogOpen] = useState(false); + const [isPreviewIconOpen, setIsPreviewIconOpen] = useState(false); + const [previewIconId, setPreviewIconId] = useState(""); + const [activeIconMenuType, setActiveIconMenuType] = useState< + "thumbnail" | "watermark_lu" | "watermark_rd" | "image" | null + >(null); + const [fileToUpload, setFileToUpload] = useState(null); const { language } = languageStore; useEffect(() => { carrierStore.getCarriers(language); articlesStore.getArticleList(); + mediaStore.getMedia(); }, [language]); - // Фильтруем перевозчиков только из выбранного города const filteredCarriers = useMemo(() => { const carriers = carrierStore.carriers[language as keyof typeof carrierStore.carriers] @@ -110,6 +133,17 @@ export const RouteCreatePage = observer(() => { const handleArticleSelect = (articleId: number) => { setGovernorAppeal(articleId.toString()); setIsSelectArticleDialogOpen(false); + articlesStore.getArticleList(); + }; + + const handleIconSelect = (media: { + id: string; + filename: string; + media_name?: string; + media_type: number; + }) => { + setIcon(media.id); + setIsSelectIconDialogOpen(false); }; const handleVideoSelect = (media: { @@ -122,30 +156,99 @@ export const RouteCreatePage = observer(() => { setIsSelectVideoDialogOpen(false); }; - const handleVideoPreviewClick = () => { - setIsVideoPreviewOpen(true); + const handleVideoFileSelect = (file?: File) => { + if (file) { + setFileToUpload(file); + setIsUploadVideoDialogOpen(true); + } else { + setIsSelectVideoDialogOpen(true); + } }; + const handleVideoUpload = (media: { + id: string; + filename: string; + media_name?: string; + media_type: number; + }) => { + setVideoPreview(media.id); + setIsUploadVideoDialogOpen(false); + setFileToUpload(null); + }; + + const selectedIconMedia = + icon && !isMediaIdEmpty(icon) + ? mediaStore.media.find((m) => m.id === icon) + : null; + const effectiveIconUrl = isMediaIdEmpty(icon) ? null : selectedIconMedia?.id ?? icon; + const effectiveVideoId = isMediaIdEmpty(videoPreview) ? null : videoPreview; + const handleCreateRoute = async () => { try { setIsLoading(true); - // Преобразуем значения в нужные типы + + if (!routeName.trim()) { + toast.error("Заполните название маршрута"); + setIsLoading(false); + return; + } + if (!carrier) { + toast.error("Выберите перевозчика"); + setIsLoading(false); + return; + } + if (!routeNumber.trim()) { + toast.error("Заполните номер маршрута"); + setIsLoading(false); + return; + } + if (!govRouteNumber.trim()) { + toast.error("Заполните номер маршрута в Говорящем Городе"); + setIsLoading(false); + return; + } + + const validationResult = validateCoordinates(routeCoords); + if (validationResult !== true) { + toast.error(validationResult); + setIsLoading(false); + return; + } + + const scale_min = scaleMin ? Number(scaleMin) : null; + const scale_max = scaleMax ? Number(scaleMax) : null; + + if ( + scale_min === 0 || + scale_max === 0 || + scale_min === null || + scale_max === null + ) { + toast.error("Масштабы не могут быть равны 0"); + setIsLoading(false); + return; + } + + if ( + scale_min !== null && + scale_max !== null && + scale_max !== undefined && + scale_min > scale_max + ) { + toast.error("Максимальный масштаб не может быть меньше минимального"); + setIsLoading(false); + return; + } + const carrier_id = Number(carrier); - const governor_appeal = Number(governorAppeal); - const scale_min = scaleMin ? Number(scaleMin) : undefined; - const scale_max = scaleMax ? Number(scaleMax) : undefined; + const governor_appeal = governorAppeal + ? Number(governorAppeal) + : undefined; const rotate = turn ? Number(turn) : undefined; const center_latitude = centerLat ? Number(centerLat) : undefined; const center_longitude = centerLng ? Number(centerLng) : undefined; const route_direction = direction === "forward"; - const validationResult = validateCoordinates(routeCoords); - if (validationResult !== true) { - toast.error(validationResult); - return; - } - - // Координаты маршрута как массив массивов чисел const path = routeCoords .trim() .split("\n") @@ -157,30 +260,38 @@ export const RouteCreatePage = observer(() => { return [lat, lon]; }); - // Собираем объект маршрута const newRoute: Partial = { carrier: carrierStore.carriers[ language as keyof typeof carrierStore.carriers ].data?.find((c: any) => c.id === carrier_id)?.full_name || "", carrier_id, - route_number: routeNumber, - route_sys_number: govRouteNumber, - governor_appeal, + route_number: routeNumber.trim(), + route_sys_number: govRouteNumber.trim(), + route_name: routeName.trim(), route_direction, - scale_min, - scale_max, + scale_min: scale_min !== null ? scale_min : 0, + scale_max: scale_max !== null ? scale_max : 0, rotate, center_latitude, center_longitude, path, - video_preview: - videoPreview && videoPreview !== "" ? videoPreview : undefined, + video_preview: !isMediaIdEmpty(videoPreview) ? videoPreview : undefined, + icon: !isMediaIdEmpty(icon) ? icon : undefined, + video_timer: videoTimer, }; - await routeStore.createRoute(newRoute); + if (governor_appeal !== undefined) { + newRoute.governor_appeal = governor_appeal; + } + + if (buttonText.trim()) { + newRoute.button_text = buttonText.trim(); + } + + const newId = await routeStore.createRoute(newRoute); toast.success("Маршрут успешно создан"); - navigate(-1); + navigate(`/route/${newId}/edit`); } catch (error) { console.error(error); toast.error("Произошла ошибка при создании маршрута"); @@ -189,13 +300,12 @@ export const RouteCreatePage = observer(() => { } }; - // Получаем название выбранной статьи для отображения const selectedArticle = articlesStore.articleList.ru.data.find( (article) => article.id === Number(governorAppeal) ); return ( - +
- - - - {/* Селектор видеозаставки */} - - - - - - - {editRouteData.video_preview && - editRouteData.video_preview !== "" - ? "Видео выбрано" - : "Видео не выбрано"} - - {editRouteData.video_preview && - editRouteData.video_preview !== "" && ( - { - e.stopPropagation(); - routeStore.setEditRouteData({ video_preview: "" }); - }} - sx={{ - cursor: "pointer", - color: "#999", - "&:hover": { - color: "#666", - }, - }} - > - - × - - - )} - - - - - - Прямой/обратный маршрут - setCreateCommonData({ - direction: e.target.value === "Прямой", - }) - } - > - Прямой - Обратный - - - { value={createStationData.common.city_id || ""} label="Город" onChange={(e) => { - const selectedCity = cities["ru"].data.find( + const selectedCity = availableCities.find( (city) => city.id === e.target.value ); setCreateCommonData({ @@ -224,7 +267,7 @@ export const StationCreatePage = observer(() => { }); }} > - {cities["ru"].data.map((city) => ( + {availableCities.map((city) => ( {city.name} @@ -232,12 +275,36 @@ export const StationCreatePage = observer(() => { +
+ { + setIsPreviewMediaOpen(true); + setMediaId(effectiveIconUrl ?? ""); + }} + onDeleteImageClick={() => { + setCreateCommonData({ icon: "" }); + setActiveMenuType(null); + }} + onSelectFileClick={() => { + setActiveMenuType("image"); + setIsSelectMediaOpen(true); + }} + setUploadMediaOpen={() => { + setIsUploadMediaOpen(true); + setActiveMenuType("image"); + }} + /> +
+
{/* ИНТЕГРИРОВАННОЕ ПРЕДУПРЕЖДАЮЩЕЕ ОКНО */} + setIsSelectMediaOpen(false)} + onSelectMedia={handleMediaSelect} + mediaType={1} + /> + + setIsUploadMediaOpen(false)} + contextObjectName={createStationData[language].name || "Остановка"} + contextType="station" + afterUpload={handleMediaSelect} + hardcodeType={activeMenuType} + /> + + setIsPreviewMediaOpen(false)} + mediaId={mediaId} + /> + {isSaveWarningOpen && ( { }} /> )} - + ); }); diff --git a/src/pages/Station/StationEditPage/index.tsx b/src/pages/Station/StationEditPage/index.tsx index 03ba50e..5ef875a 100644 --- a/src/pages/Station/StationEditPage/index.tsx +++ b/src/pages/Station/StationEditPage/index.tsx @@ -1,26 +1,48 @@ import { Button, - Paper, TextField, Select, MenuItem, FormControl, InputLabel, + Box, } from "@mui/material"; import { observer } from "mobx-react-lite"; -import { ArrowLeft, Save } from "lucide-react"; -import { Loader2 } from "lucide-react"; +import { ArrowLeft, Loader2, Save } from "lucide-react"; import { useNavigate, useParams } from "react-router-dom"; import { toast } from "react-toastify"; -import { stationsStore, languageStore, cityStore } from "@shared"; +import { + stationsStore, + languageStore, + cityStore, + authStore, + mediaStore, + isMediaIdEmpty, + LoadingSpinner, + selectedCityStore, + SelectMediaDialog, + PreviewMediaDialog, + UploadMediaDialog, +} from "@shared"; import { useEffect, useState } from "react"; -import { LanguageSwitcher } from "@widgets"; +import { + ImageUploadCard, + LanguageSwitcher, + SaveWithoutCityAgree, + DeleteModal, +} from "@widgets"; import { LinkedSights } from "../LinkedSights"; -import { SaveWithoutCityAgree } from "@widgets"; export const StationEditPage = observer(() => { const navigate = useNavigate(); + + useEffect(() => { + selectedCityStore.setIsLocked(true); + return () => selectedCityStore.setIsLocked(false); + }, []); + const [isLoading, setIsLoading] = useState(false); + const [isLoadingData, setIsLoadingData] = useState(true); const { language } = languageStore; const { id } = useParams(); const { @@ -30,9 +52,18 @@ export const StationEditPage = observer(() => { editStation, setLanguageEditStationData, } = stationsStore; - const { cities, getCities } = cityStore; + const { getCities } = cityStore; + const canReadCities = authStore.canRead("cities"); const [coordinates, setCoordinates] = useState(""); - // НОВОЕ СОСТОЯНИЕ ДЛЯ ПРЕДУПРЕЖДАЮЩЕГО ОКНА + const [isSelectMediaOpen, setIsSelectMediaOpen] = useState(false); + const [isUploadMediaOpen, setIsUploadMediaOpen] = useState(false); + const [isPreviewMediaOpen, setIsPreviewMediaOpen] = useState(false); + const [mediaId, setMediaId] = useState(""); + const [isDeleteIconModalOpen, setIsDeleteIconModalOpen] = useState(false); + const [activeMenuType, setActiveMenuType] = useState< + "thumbnail" | "watermark_lu" | "watermark_rd" | "image" | null + >(null); + const [isSaveWarningOpen, setIsSaveWarningOpen] = useState(false); useEffect(() => { @@ -50,7 +81,6 @@ export const StationEditPage = observer(() => { } }, [editStationData.common.latitude, editStationData.common.longitude]); - // НОВАЯ ФУНКЦИЯ: Фактическое редактирование (вызывается после подтверждения) const executeEdit = async () => { try { setIsLoading(true); @@ -58,16 +88,15 @@ export const StationEditPage = observer(() => { toast.success("Остановка успешно обновлена"); } catch (error) { console.error("Error updating station:", error); - toast.error("Ошибка при обновлении станции"); + toast.error("Ошибка при обновлении остановки"); } finally { setIsLoading(false); } }; - // ОБНОВЛЕННАЯ ФУНКЦИЯ: Проверка и вызов окна или редактирования const handleEdit = async () => { const isCityMissing = !editStationData.common.city_id; - // Проверяем названия на всех языках + const isNameMissing = !editStationData.ru.name || !editStationData.en.name || @@ -81,33 +110,102 @@ export const StationEditPage = observer(() => { await executeEdit(); }; - // Обработчик "Да" в предупреждающем окне const handleConfirmEdit = async () => { setIsSaveWarningOpen(false); await executeEdit(); }; - // Обработчик "Нет" в предупреждающем окне const handleCancelEdit = () => { setIsSaveWarningOpen(false); }; + const handleMediaSelect = (media: { + id: string; + filename: string; + media_name?: string; + media_type: number; + }) => { + setEditCommonData({ icon: media.id }); + }; + + const selectedMedia = + editStationData.common.icon && !isMediaIdEmpty(editStationData.common.icon) + ? mediaStore.media.find((m) => m.id === editStationData.common.icon) + : null; + const effectiveIconUrl = isMediaIdEmpty(editStationData.common.icon) + ? null + : selectedMedia?.id ?? editStationData.common.icon; + useEffect(() => { const fetchAndSetStationData = async () => { - if (!id) return; + if (!id) { + setIsLoadingData(false); + return; + } - const stationId = Number(id); - await getEditStation(stationId); - await getCities("ru"); - await getCities("en"); - await getCities("zh"); + setIsLoadingData(true); + try { + const stationId = Number(id); + await getEditStation(stationId); + if (!authStore.me) { + await authStore.getMeAction().catch(() => undefined); + } + if (authStore.canRead("cities")) { + await getCities("ru"); + } else { + await authStore.fetchMeCities().catch(() => undefined); + } + await mediaStore.getMedia(); + } finally { + setIsLoadingData(false); + } }; fetchAndSetStationData(); }, [id]); + const baseCities = canReadCities + ? cityStore.cities["ru"].data + : authStore.meCities["ru"].map((city) => ({ + id: city.city_id, + name: city.name, + country: "", + country_code: "", + arms: "", + })); + + const availableCities = + editStationData.common.city_id && + !baseCities.some((city) => city.id === editStationData.common.city_id) + ? [ + { + id: editStationData.common.city_id, + name: editStationData.common.city || `Город ${editStationData.common.city_id}`, + country: "", + country_code: "", + arms: "", + }, + ...baseCities, + ] + : baseCities; + + if (isLoadingData) { + return ( + + + + ); + } + return ( - +
@@ -136,23 +234,6 @@ export const StationEditPage = observer(() => { } /> - - Прямой/обратный маршрут - - - { value={editStationData.common.city_id || ""} label="Город" onChange={(e) => { - const selectedCity = cities["ru"].data.find( + const selectedCity = availableCities.find( (city) => city.id === e.target.value ); setEditCommonData({ @@ -222,7 +303,7 @@ export const StationEditPage = observer(() => { }); }} > - {cities["ru"].data.map((city) => ( + {availableCities.map((city) => ( {city.name} @@ -230,6 +311,29 @@ export const StationEditPage = observer(() => { +
+ { + setIsPreviewMediaOpen(true); + setMediaId(effectiveIconUrl ?? ""); + }} + onDeleteImageClick={() => { + setIsDeleteIconModalOpen(true); + }} + onSelectFileClick={() => { + setActiveMenuType("image"); + setIsSelectMediaOpen(true); + }} + setUploadMediaOpen={() => { + setIsUploadMediaOpen(true); + setActiveMenuType("image"); + }} + /> +
+ {id && ( { className="w-min flex gap-2 items-center" startIcon={} onClick={handleEdit} - disabled={isLoading} // Убрал проверку name/city_id отсюда, чтобы ее обрабатывал handleEdit + disabled={isLoading} > {isLoading ? ( @@ -253,6 +357,38 @@ export const StationEditPage = observer(() => {
+ setIsSelectMediaOpen(false)} + onSelectMedia={handleMediaSelect} + mediaType={1} + /> + + setIsUploadMediaOpen(false)} + contextObjectName={editStationData[language].name || "Остановка"} + contextType="station" + afterUpload={handleMediaSelect} + hardcodeType={activeMenuType} + /> + + setIsPreviewMediaOpen(false)} + mediaId={mediaId} + /> + + { + setEditCommonData({ icon: "" }); + setIsDeleteIconModalOpen(false); + }} + onCancel={() => setIsDeleteIconModalOpen(false)} + edit + /> + {/* ИНТЕГРИРОВАННОЕ ПРЕДУПРЕЖДАЮЩЕЕ ОКНО */} {isSaveWarningOpen && ( { }} /> )} -
+ ); }); diff --git a/src/pages/Station/StationListPage/index.tsx b/src/pages/Station/StationListPage/index.tsx index bbd2374..475dc17 100644 --- a/src/pages/Station/StationListPage/index.tsx +++ b/src/pages/Station/StationListPage/index.tsx @@ -1,37 +1,55 @@ import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid"; import { ruRU } from "@mui/x-data-grid/locales"; import { + authStore, languageStore, stationsStore, selectedCityStore, - cityStore, + SearchInput, } from "@shared"; -import { useEffect, useState } from "react"; +import { useEffect, useState, useMemo } from "react"; import { observer } from "mobx-react-lite"; -import { Eye, Pencil, Trash2, Minus } from "lucide-react"; +import { Pencil, Trash2, Minus, Route, Check, X } from "lucide-react"; import { useNavigate } from "react-router-dom"; -import { CreateButton, DeleteModal, LanguageSwitcher } from "@widgets"; -import { Box, CircularProgress } from "@mui/material"; +import { + CreateButton, + DeleteModal, + LanguageSwitcher, + EditStationTransfersModal, +} from "@widgets"; +import { Box, CircularProgress, Tooltip } from "@mui/material"; export const StationListPage = observer(() => { - const { stationLists, getStationList, deleteStation } = stationsStore; + const { stationLists, getStationList, deleteStation, sightCounts, sightCountsLoading, loadSightCounts } = stationsStore; const navigate = useNavigate(); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [isBulkDeleteModalOpen, setIsBulkDeleteModalOpen] = useState(false); + const [isTransfersModalOpen, setIsTransfersModalOpen] = useState(false); const [rowId, setRowId] = useState(null); + const [selectedStationId, setSelectedStationId] = useState( + null + ); const [ids, setIds] = useState([]); const [isLoading, setIsLoading] = useState(false); + const [paginationModel, setPaginationModel] = useState({ + page: 0, + pageSize: 50, + }); const { language } = languageStore; + const canWriteStations = authStore.canWrite("stations"); + const [searchQuery, setSearchQuery] = useState(""); useEffect(() => { const fetchStations = async () => { setIsLoading(true); - await cityStore.getCities(language); await getStationList(); setIsLoading(false); + + const stationIds = stationLists[language].data.map((s: any) => s.id); + loadSightCounts(stationIds); }; fetchStations(); - }, [language]); + }, [language, selectedCityStore.cityVersion]); const columns: GridColDef[] = [ { @@ -51,8 +69,8 @@ export const StationListPage = observer(() => { }, }, { - field: "system_name", - headerName: "Системное название", + field: "description", + headerName: "Описание", flex: 1, renderCell: (params: GridRenderCellParams) => { return ( @@ -67,72 +85,118 @@ export const StationListPage = observer(() => { }, }, { - field: "direction", - headerName: "Направление", - width: 200, - align: "center", - headerAlign: "center", - - renderCell: (params: GridRenderCellParams) => { - return ( -

- {params.row.direction ? "Прямой" : "Обратный"} -

- ); - }, + field: "sightCount", + headerName: "Привязки", + width: 180, + align: "center" as const, + headerAlign: "center" as const, + sortable: true, + renderHeader: (params) => ( + + {params.colDef.headerName} + + ), + renderCell: (params: GridRenderCellParams) => ( +
+ {params.value === null ? ( + + ) : ( + params.value + )} +
+ ), + }, + { + field: "hasTransfers", + headerName: "Пересадки", + width: 120, + align: "center" as const, + headerAlign: "center" as const, + sortable: true, + renderHeader: (params) => ( + + {params.colDef.headerName} + + ), + renderCell: (params: GridRenderCellParams) => ( + + {params.value ? ( + + ) : ( + + )} + + ), }, { field: "actions", headerName: "Действия", - width: 140, - align: "center", - headerAlign: "center", + width: 200, + align: "center" as const, + headerAlign: "center" as const, sortable: false, - renderCell: (params: GridRenderCellParams) => { return (
- - - + {canWriteStations && ( + + )} + {canWriteStations && ( + + )} + {canWriteStations && ( + + )}
); }, }, ]; - // Фильтрация станций по выбранному городу - const filteredStations = () => { + const rows = useMemo(() => { const { selectedCityId } = selectedCityStore; if (!selectedCityId) { - return stationLists[language].data; + return []; } - return stationLists[language].data.filter( - (station: any) => station.city_id === selectedCityId - ); - }; - - const rows = filteredStations().map((station: any) => ({ - id: station.id, - name: station.name, - system_name: station.system_name, - direction: station.direction, - })); + const query = searchQuery.trim().toLowerCase(); + return stationLists[language].data + .filter((station: any) => station.city_id === selectedCityId) + .filter( + (station: any) => + !query || + (station.name ?? "").toLowerCase().includes(query) || + (station.description ?? "").toLowerCase().includes(query) + ) + .map((station: any) => ({ + id: station.id, + name: station.name, + description: station.description, + sightCount: sightCounts.has(station.id) ? sightCounts.get(station.id) : null, + hasTransfers: station.transfers + ? Object.values(station.transfers).some((v: any) => v != null && v !== "") + : false, + })); + }, [stationLists[language].data, selectedCityStore.selectedCityId, searchQuery, sightCounts.size, sightCountsLoading]); return ( <> @@ -140,38 +204,95 @@ export const StationListPage = observer(() => {
-

Станции

- +

Остановки

+ {canWriteStations && ( + + )}
-
0 ? 1 : 0 }} - > - -
+ {canWriteStations && ids.length > 0 && ( +
+ +
+ )} + + {(rows.length > 0 || searchQuery) && ( + + )} canWriteStations && navigate(`/station/${params.row.id}/edit`)} + checkboxSelection={canWriteStations} + disableRowSelectionExcludeModel + disableRowSelectionOnClick loading={isLoading} - onRowSelectionModelChange={(newSelection) => { - setIds(Array.from(newSelection.ids) as number[]); - }} - hideFooter + paginationModel={paginationModel} + onPaginationModelChange={setPaginationModel} + pageSizeOptions={[50]} + onRowSelectionModelChange={ + canWriteStations + ? (newSelection: any) => { + if (Array.isArray(newSelection)) { + const selectedIds = newSelection + .map((id: string | number) => { + const numId = + typeof id === "string" + ? Number.parseInt(id, 10) + : Number(id); + return numId; + }) + .filter( + (id: number) => + !Number.isNaN(id) && id !== null && id !== undefined + ); + setIds(selectedIds); + } else if ( + newSelection && + typeof newSelection === "object" && + "ids" in newSelection + ) { + const idsSet = newSelection.ids as Set; + const selectedIds = Array.from(idsSet) + .map((id: string | number) => { + const numId = + typeof id === "string" + ? Number.parseInt(id, 10) + : Number(id); + return numId; + }) + .filter( + (id: number) => + !Number.isNaN(id) && id !== null && id !== undefined + ); + setIds(selectedIds); + } else { + setIds([]); + } + } + : undefined + } localeText={ruRU.components.MuiDataGrid.defaultProps.localeText} slots={{ noRowsOverlay: () => ( - {isLoading ? : "Нет станций"} + {isLoading ? ( + + ) : !selectedCityStore.selectedCityId ? ( + "Выберите город" + ) : ( + "Нет остановок" + )} ), }} @@ -205,6 +326,15 @@ export const StationListPage = observer(() => { setIsBulkDeleteModalOpen(false); }} /> + + { + setIsTransfersModalOpen(false); + setSelectedStationId(null); + }} + stationId={selectedStationId} + /> ); }); diff --git a/src/pages/Station/StationPreviewPage/index.tsx b/src/pages/Station/StationPreviewPage/index.tsx index d45dc72..0639087 100644 --- a/src/pages/Station/StationPreviewPage/index.tsx +++ b/src/pages/Station/StationPreviewPage/index.tsx @@ -1,9 +1,9 @@ -import { Paper } from "@mui/material"; -import { languageStore, stationsStore } from "@shared"; +import { Paper, Box } from "@mui/material"; +import { languageStore, stationsStore, LoadingSpinner } from "@shared"; import { LanguageSwitcher } from "@widgets"; import { observer } from "mobx-react-lite"; import { ArrowLeft } from "lucide-react"; -import { useEffect } from "react"; +import { useEffect, useState } from "react"; import { useNavigate, useParams } from "react-router-dom"; import { LinkedSights } from "../LinkedSights"; @@ -12,15 +12,38 @@ export const StationPreviewPage = observer(() => { const { stationPreview, getStationPreview } = stationsStore; const navigate = useNavigate(); const { language } = languageStore; + const [isLoadingData, setIsLoadingData] = useState(true); useEffect(() => { (async () => { if (id) { - await getStationPreview(Number(id)); + setIsLoadingData(true); + try { + await getStationPreview(Number(id)); + } finally { + setIsLoadingData(false); + } + } else { + setIsLoadingData(false); } })(); }, [id, language]); + if (isLoadingData) { + return ( + + + + ); + } + return ( @@ -44,21 +67,6 @@ export const StationPreviewPage = observer(() => {

{stationPreview[id!]?.[language]?.data.system_name}

-
-

Направление

-

- {stationPreview[id!]?.[language]?.data.direction - ? "Прямой" - : "Обратный"} -

-
- {stationPreview[id!]?.[language]?.data.address && (

Адрес

diff --git a/src/pages/User/UserCreatePage/index.tsx b/src/pages/User/UserCreatePage/index.tsx index 219ab77..af2dc45 100644 --- a/src/pages/User/UserCreatePage/index.tsx +++ b/src/pages/User/UserCreatePage/index.tsx @@ -2,25 +2,70 @@ import { Button, Paper, TextField, - Checkbox, - FormControlLabel, + Typography, + Box, + Divider, } from "@mui/material"; import { observer } from "mobx-react-lite"; -import { ArrowLeft, Save } from "lucide-react"; -import { Loader2 } from "lucide-react"; +import { ArrowLeft, Loader2, Save } from "lucide-react"; import { useNavigate } from "react-router-dom"; import { toast } from "react-toastify"; -import { userStore } from "@shared"; -import { useState } from "react"; +import { + userStore, + mediaStore, + isMediaIdEmpty, + SelectMediaDialog, + UploadMediaDialog, + PreviewMediaDialog, + selectedCityStore, +} from "@shared"; +import { useState, useEffect } from "react"; +import { ImageUploadCard, PermissionsTable, RolesHintTable, ROLE_RESOURCES } from "@widgets"; export const UserCreatePage = observer(() => { const navigate = useNavigate(); const { createUserData, setCreateUserData, createUser } = userStore; const [isLoading, setIsLoading] = useState(false); + const [isSelectMediaOpen, setIsSelectMediaOpen] = useState(false); + const [isUploadMediaOpen, setIsUploadMediaOpen] = useState(false); + const [isPreviewMediaOpen, setIsPreviewMediaOpen] = useState(false); + const [mediaId, setMediaId] = useState(""); + const [activeMenuType, setActiveMenuType] = useState< + "thumbnail" | "watermark_lu" | "watermark_rd" | "image" | null + >(null); + + const [localRoles, setLocalRoles] = useState( + createUserData.roles ?? ["articles_ro", "articles_rw", "media_ro", "media_rw"] + ); + + useEffect(() => { + selectedCityStore.setIsLocked(true); + return () => selectedCityStore.setIsLocked(false); + }, []); + + useEffect(() => { + mediaStore.getMedia(); + }, []); + + useEffect(() => { + const allRw = ROLE_RESOURCES.every(({ key }) => localRoles.includes(`${key}_rw`)); + const isAdmin = allRw && !localRoles.includes("devices_maintenance_rw"); + if (isAdmin !== createUserData.is_admin) { + setCreateUserData( + createUserData.name || "", + createUserData.email || "", + createUserData.password || "", + isAdmin, + createUserData.icon + ); + } + }, [localRoles]); const handleCreate = async () => { try { setIsLoading(true); + // Убеждаемся, что роли в сторе обновлены перед созданием + userStore.createUserData.roles = localRoles; await createUser(); toast.success("Пользователь успешно создан"); navigate("/user"); @@ -31,19 +76,39 @@ export const UserCreatePage = observer(() => { } }; - return ( - -
- -
+ const handleMediaSelect = (media: { + id: string; + filename: string; + media_name?: string; + media_type: number; + }) => { + setCreateUserData( + createUserData.name || "", + createUserData.email || "", + createUserData.password || "", + createUserData.is_admin || false, + media.id + ); + }; + + const selectedMedia = + createUserData.icon && !isMediaIdEmpty(createUserData.icon) + ? mediaStore.media.find((m) => m.id === createUserData.icon) + : null; + const effectiveIconUrl = isMediaIdEmpty(createUserData.icon) + ? null + : selectedMedia?.id ?? createUserData.icon ?? null; + + return ( + + + +
+ Основные данные -
{ e.target.value, createUserData.email || "", createUserData.password || "", - createUserData.is_admin || false + createUserData.is_admin || false, + createUserData.icon ) } /> @@ -69,7 +135,8 @@ export const UserCreatePage = observer(() => { createUserData.name || "", e.target.value, createUserData.password || "", - createUserData.is_admin || false + createUserData.is_admin || false, + createUserData.icon ) } /> @@ -79,51 +146,135 @@ export const UserCreatePage = observer(() => { label="Пароль" value={createUserData.password || ""} required + type="password" onChange={(e) => setCreateUserData( createUserData.name || "", createUserData.email || "", e.target.value, - createUserData.is_admin || false + createUserData.is_admin || false, + createUserData.icon ) } /> -
- { - setCreateUserData( - createUserData.name || "", - createUserData.email || "", - createUserData.password || "", - e.target.checked - ); - }} - /> - } - label="Администратор" +
+ { + setIsPreviewMediaOpen(true); + setMediaId(effectiveIconUrl ?? ""); + }} + onDeleteImageClick={() => { + setCreateUserData( + createUserData.name || "", + createUserData.email || "", + createUserData.password || "", + createUserData.is_admin || false, + "" + ); + setActiveMenuType(null); + }} + onSelectFileClick={() => { + setActiveMenuType("image"); + setIsSelectMediaOpen(true); + }} + setUploadMediaOpen={() => { + setIsUploadMediaOpen(true); + setActiveMenuType("image"); + }} />
+
- -
+ + +
+ Права доступа + + + + + + + + +
+ + + + setIsSelectMediaOpen(false)} + onSelectMedia={handleMediaSelect} + mediaType={1} + /> + + setIsUploadMediaOpen(false)} + contextObjectName={createUserData.name || "Пользователь"} + contextType="user" + afterUpload={handleMediaSelect} + hardcodeType={activeMenuType} + /> + + setIsPreviewMediaOpen(false)} + mediaId={mediaId} + /> ); }); diff --git a/src/pages/User/UserEditPage/index.tsx b/src/pages/User/UserEditPage/index.tsx index b6def48..e795d36 100644 --- a/src/pages/User/UserEditPage/index.tsx +++ b/src/pages/User/UserEditPage/index.tsx @@ -1,71 +1,219 @@ import { Button, - FormControlLabel, - Checkbox, Paper, TextField, + Box, + Typography, + Divider, } from "@mui/material"; import { observer } from "mobx-react-lite"; -import { ArrowLeft, Save } from "lucide-react"; -import { Loader2 } from "lucide-react"; +import { ArrowLeft, Loader2, Save } from "lucide-react"; import { useNavigate, useParams } from "react-router-dom"; import { toast } from "react-toastify"; -import { userStore, languageStore } from "@shared"; +import { + userStore, + languageStore, + LoadingSpinner, + mediaStore, + isMediaIdEmpty, + SelectMediaDialog, + UploadMediaDialog, + PreviewMediaDialog, + authStore, + cityStore, + MultiSelect, + selectedCityStore, + type User, + type UserCity, +} from "@shared"; import { useEffect, useState } from "react"; +import { ImageUploadCard, DeleteModal, PermissionsTable, RolesHintTable, ROLE_RESOURCES } from "@widgets"; export const UserEditPage = observer(() => { const navigate = useNavigate(); const [isLoading, setIsLoading] = useState(false); + const [isLoadingData, setIsLoadingData] = useState(true); const { id } = useParams(); - const { editUserData, editUser, getUser, setEditUserData } = userStore; + const { editUserData, editUser, getUser, setEditUserData, setEditUserRoles } = userStore; + const canReadCities = authStore.canRead("cities"); + + const [localRoles, setLocalRoles] = useState([]); + const [localCityIds, setLocalCityIds] = useState([]); + const [initialUserCities, setInitialUserCities] = useState([]); + + const [isSelectMediaOpen, setIsSelectMediaOpen] = useState(false); + const [isUploadMediaOpen, setIsUploadMediaOpen] = useState(false); + const [isPreviewMediaOpen, setIsPreviewMediaOpen] = useState(false); + const [mediaId, setMediaId] = useState(""); + const [isDeleteIconModalOpen, setIsDeleteIconModalOpen] = useState(false); + const [activeMenuType, setActiveMenuType] = useState< + "thumbnail" | "watermark_lu" | "watermark_rd" | "image" | null + >(null); + + useEffect(() => { + selectedCityStore.setIsLocked(true); + return () => selectedCityStore.setIsLocked(false); + }, []); useEffect(() => { - // Устанавливаем русский язык при загрузке страницы languageStore.setLanguage("ru"); }, []); - const handleEdit = async () => { + useEffect(() => { + const allRw = ROLE_RESOURCES.every(({ key }) => localRoles.includes(`${key}_rw`)); + const isAdmin = allRw && !localRoles.includes("devices_maintenance_rw"); + if (isAdmin !== editUserData.is_admin) { + setEditUserData( + editUserData.name || "", + editUserData.email || "", + editUserData.password || "", + isAdmin, + editUserData.icon || "" + ); + } + }, [localRoles]); + + useEffect(() => { + (async () => { + if (id) { + setIsLoadingData(true); + try { + if (!authStore.me) { + await authStore.getMeAction().catch(() => undefined); + } + await Promise.all([ + mediaStore.getMedia(), + authStore.canRead("cities") + ? cityStore.getRuCities() + : authStore.fetchMeCities().catch(() => undefined), + ]); + const data = (await getUser(Number(id))) as User | undefined; + + if (data) { + setEditUserData( + data.name || "", + data.email || "", + data.password || "", + data.is_admin || false, + data.icon || "", + ); + + const roles = data.roles ?? []; + setLocalRoles(roles); + setEditUserRoles(roles); + + const cityIds = (data.cities ?? []).map((c) => c.city_id); + setLocalCityIds(cityIds); + setInitialUserCities(data.cities ?? []); + } + } finally { + setIsLoadingData(false); + } + } else { + setIsLoadingData(false); + } + })(); + }, [id]); + + const handleSave = async () => { try { setIsLoading(true); + + const mandatoryRoles = ["articles_ro", "articles_rw", "media_ro", "media_rw"]; + const rolesToSave = Array.from(new Set([...localRoles, ...mandatoryRoles])); + setEditUserRoles(rolesToSave); await editUser(Number(id)); - toast.success("Пользователь успешно обновлен"); + + await userStore.addUserCityAction({ + id: Number(id), + city_ids: localCityIds, + }); + + toast.success("Пользователь успешно обновлён"); navigate("/user"); - } catch (error) { + } catch { toast.error("Ошибка при обновлении пользователя"); } finally { setIsLoading(false); } }; - useEffect(() => { - (async () => { - if (id) { - const data = await getUser(Number(id)); + const handleMediaSelect = (media: { + id: string; + filename: string; + media_name?: string; + media_type: number; + }) => { + setEditUserData( + editUserData.name || "", + editUserData.email || "", + editUserData.password || "", + editUserData.is_admin || false, + media.id, + ); + }; - setEditUserData( - data?.name || "", - data?.email || "", - data?.password || "", - data?.is_admin || false - ); - } - })(); - }, [id]); + const selectedMedia = + editUserData.icon && !isMediaIdEmpty(editUserData.icon) + ? mediaStore.media.find((m) => m.id === editUserData.icon) + : null; + const effectiveIconUrl = isMediaIdEmpty(editUserData.icon) + ? null + : (selectedMedia?.id ?? editUserData.icon ?? null); + + const cityOptionsMap = new Map(); + + const sourceCities: UserCity[] = canReadCities + ? cityStore.ruCities.data + .filter((city) => city.id !== undefined) + .map((city) => ({ + city_id: city.id as number, + name: city.name, + })) + : authStore.meCities.ru; + + for (const city of sourceCities) { + cityOptionsMap.set(city.city_id, city.name); + } + + for (const city of initialUserCities) { + if (!cityOptionsMap.has(city.city_id)) { + cityOptionsMap.set(city.city_id, city.name); + } + } + + const cityOptions = Array.from(cityOptionsMap.entries()).map(([value, label]) => ({ + value, + label, + })); + + if (isLoadingData) { + return ( + + + + ); + } return ( - -
- -
+ + + + {/* ── Основные данные ── */} +
+ Основные данные -
{ e.target.value, editUserData.email || "", editUserData.password || "", - editUserData.is_admin || false + editUserData.is_admin || false, + editUserData.icon, ) } /> @@ -90,56 +239,146 @@ export const UserEditPage = observer(() => { editUserData.name || "", e.target.value, editUserData.password || "", - editUserData.is_admin || false + editUserData.is_admin || false, + editUserData.icon, ) } /> - setEditUserData( editUserData.name || "", editUserData.email || "", e.target.value, - editUserData.is_admin || false + editUserData.is_admin || false, + editUserData.icon, ) } /> - - setEditUserData( - editUserData.name || "", - editUserData.email || "", - editUserData.password || "", - e.target.checked - ) - } - /> - } - label="Администратор" - /> - -
+
+ { + setIsPreviewMediaOpen(true); + setMediaId(effectiveIconUrl ?? ""); + }} + onDeleteImageClick={() => setIsDeleteIconModalOpen(true)} + onSelectFileClick={() => { + setActiveMenuType("image"); + setIsSelectMediaOpen(true); + }} + setUploadMediaOpen={() => { + setIsUploadMediaOpen(true); + setActiveMenuType("image"); + }} + /> +
+
+ + + + {/* ── Права доступа ── */} +
+ Права доступа + + + + + + + + +
+ + + + {/* ── Города ── */} +
+ Города + setLocalCityIds(ids as number[])} + label="Города" + placeholder="Выберите города" + loading={canReadCities ? !cityStore.ruCities.loaded : isLoadingData} + /> +
+ + + + setIsSelectMediaOpen(false)} + onSelectMedia={handleMediaSelect} + mediaType={1} + /> + setIsUploadMediaOpen(false)} + contextObjectName={editUserData.name || "Пользователь"} + contextType="user" + afterUpload={handleMediaSelect} + hardcodeType={activeMenuType} + /> + setIsPreviewMediaOpen(false)} + mediaId={mediaId} + /> + { + setEditUserData( + editUserData.name || "", + editUserData.email || "", + editUserData.password || "", + editUserData.is_admin || false, + "", + ); + setIsDeleteIconModalOpen(false); + }} + onCancel={() => setIsDeleteIconModalOpen(false)} + edit + />
); }); diff --git a/src/pages/User/UserListPage/index.tsx b/src/pages/User/UserListPage/index.tsx index ae87cd8..97c46ef 100644 --- a/src/pages/User/UserListPage/index.tsx +++ b/src/pages/User/UserListPage/index.tsx @@ -1,7 +1,7 @@ import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid"; import { ruRU } from "@mui/x-data-grid/locales"; -import { userStore } from "@shared"; -import { useEffect, useState } from "react"; +import { authStore, userStore, SearchInput } from "@shared"; +import { useEffect, useState, useMemo } from "react"; import { observer } from "mobx-react-lite"; import { Pencil, Trash2, Minus } from "lucide-react"; import { useNavigate } from "react-router-dom"; @@ -16,6 +16,12 @@ export const UserListPage = observer(() => { const [rowId, setRowId] = useState(null); const [ids, setIds] = useState([]); const [isLoading, setIsLoading] = useState(false); + const [paginationModel, setPaginationModel] = useState({ + page: 0, + pageSize: 50, + }); + const canWriteUsers = authStore.canWrite("users"); + const [searchQuery, setSearchQuery] = useState(""); useEffect(() => { const fetchUsers = async () => { @@ -77,78 +83,113 @@ export const UserListPage = observer(() => { }, }, - { + ...(canWriteUsers ? [{ field: "actions", headerName: "Действия", flex: 1, - align: "center", - headerAlign: "center", + align: "center" as const, + headerAlign: "center" as const, sortable: false, - - renderCell: (params: GridRenderCellParams) => { - return ( -
- - -
- ); - }, - }, + renderCell: (params: GridRenderCellParams) => ( +
+ + +
+ ), + }] : []), ]; - const rows = users.data?.map((user) => ({ - id: user.id, - email: user.email, - is_admin: user.is_admin, - name: user.name, - })); + const rows = useMemo(() => { + const query = searchQuery.trim().toLowerCase(); + return (users.data ?? []) + .filter((user) => + !query || + (user.name ?? "").toLowerCase().includes(query) || + (user.email ?? "").toLowerCase().includes(query) + ) + .map((user) => ({ + id: user.id, + email: user.email, + is_admin: user.is_admin || (user.roles ?? []).includes("admin"), + name: user.name, + })); + }, [users.data, searchQuery]); return ( <>

Пользователи

- + {canWriteUsers && ( + + )}
-
0 ? 1 : 0 }} - > - -
+ {canWriteUsers && ids.length > 0 && ( +
+ +
+ )} + + {(rows.length > 0 || searchQuery) && ( + + )} { - setIds(Array.from(newSelection.ids) as number[]); + checkboxSelection={canWriteUsers} + disableRowSelectionExcludeModel + disableRowSelectionOnClick + onRowDoubleClick={(params) => { + if (canWriteUsers) { + navigate(`/user/${params.row.id}/edit`); + } }} - hideFooter + loading={isLoading} + paginationModel={paginationModel} + onPaginationModelChange={setPaginationModel} + pageSizeOptions={[50]} + onRowSelectionModelChange={ + canWriteUsers + ? (newSelection: any) => { + if (Array.isArray(newSelection)) { + const selectedIds = newSelection.map( + (id: string | number) => Number(id) + ); + setIds(selectedIds); + } else if ( + newSelection && + typeof newSelection === "object" && + "ids" in newSelection + ) { + const idsSet = newSelection.ids as Set; + const selectedIds = Array.from(idsSet).map( + (id: string | number) => Number(id) + ); + setIds(selectedIds); + } else { + setIds([]); + } + } + : undefined + } localeText={ruRU.components.MuiDataGrid.defaultProps.localeText} slots={{ noRowsOverlay: () => ( diff --git a/src/pages/Vehicle/VehicleCreatePage/index.tsx b/src/pages/Vehicle/VehicleCreatePage/index.tsx index 85f3723..7395af8 100644 --- a/src/pages/Vehicle/VehicleCreatePage/index.tsx +++ b/src/pages/Vehicle/VehicleCreatePage/index.tsx @@ -12,6 +12,8 @@ import { VEHICLE_TYPES, carrierStore, languageStore, + cityStore, + selectedCityStore, } from "@shared"; import { observer } from "mobx-react-lite"; import { ArrowLeft, Save } from "lucide-react"; @@ -25,22 +27,32 @@ export const VehicleCreatePage = observer(() => { const [tailNumber, setTailNumber] = useState(""); const [type, setType] = useState(""); const [carrierId, setCarrierId] = useState(null); + const [model, setModel] = useState(""); + const [cityId, setCityId] = useState(selectedCityStore.selectedCityId); const [isLoading, setIsLoading] = useState(false); const { language } = languageStore; + useEffect(() => { + selectedCityStore.setIsLocked(true); + return () => selectedCityStore.setIsLocked(false); + }, []); + useEffect(() => { carrierStore.getCarriers(language); + cityStore.getCities("ru"); }, [language]); const handleCreate = async () => { try { setIsLoading(true); await vehicleStore.createVehicle( - Number(tailNumber), + tailNumber, Number(type), carrierStore.carriers[language].data?.find((c) => c.id === carrierId) ?.full_name as string, - carrierId! + carrierId!, + model || undefined, + cityId ?? undefined, ); toast.success("Транспорт успешно создан"); } catch (error) { @@ -71,12 +83,11 @@ export const VehicleCreatePage = observer(() => { onChange={(e) => setTailNumber(e.target.value)} /> - + Тип - + Перевозчик + setModel(e.target.value)} + placeholder="Произвольное название модели" + /> + + + Город + + + - + )} + - + {canWrite && ( + + )}
); }, }, ]; - const rows = vehicles.data?.map((vehicle) => ({ - id: vehicle.vehicle.id, - tail_number: vehicle.vehicle.tail_number, - type: vehicle.vehicle.type, - carrier: vehicle.vehicle.carrier, - city: carriers[language].data?.find( - (carrier) => carrier.id === vehicle.vehicle.carrier_id - )?.city, - })); + const { selectedCityId } = selectedCityStore; + + const rows = useMemo(() => { + if (!selectedCityId) return []; + const query = searchQuery.trim().toLowerCase(); + return (vehicles.data ?? []) + .filter((vehicle) => vehicle.vehicle.city_id === selectedCityId) + .filter( + (vehicle) => + !query || + (vehicle.vehicle.tail_number ?? "").toLowerCase().includes(query) || + (vehicle.vehicle.carrier ?? "").toLowerCase().includes(query) + ) + .map((vehicle) => ({ + id: vehicle.vehicle.id, + tail_number: vehicle.vehicle.tail_number, + type: vehicle.vehicle.type, + carrier: vehicle.vehicle.carrier, + city: cityStore.cities.ru.data.find((c) => c.id === vehicle.vehicle.city_id)?.name, + })); + }, [vehicles.data, selectedCityId, searchQuery]); return ( <>

Транспортные средства

+
-
0 ? 1 : 0 }} - > - -
+ {(rows.length > 0 || searchQuery) && ( + + )} + + {canWriteVehicles && ids.length > 0 && ( +
+ +
+ )} { - setIds(Array.from(newSelection.ids) as number[]); - }} - hideFooter + paginationModel={paginationModel} + onPaginationModelChange={setPaginationModel} + pageSizeOptions={[50]} + onRowSelectionModelChange={ + canWriteVehicles + ? (newSelection: any) => { + if (Array.isArray(newSelection)) { + const selectedIds = newSelection.map( + (id: string | number) => Number(id) + ); + setIds(selectedIds); + } else if ( + newSelection && + typeof newSelection === "object" && + "ids" in newSelection + ) { + const idsSet = newSelection.ids as Set; + const selectedIds = Array.from(idsSet).map( + (id: string | number) => Number(id) + ); + setIds(selectedIds); + } else { + setIds([]); + } + } + : undefined + } localeText={ruRU.components.MuiDataGrid.defaultProps.localeText} slots={{ noRowsOverlay: () => ( {isLoading ? ( + ) : !selectedCityId ? ( + "Выберите город" ) : ( "Нет транспортных средств" )} diff --git a/src/shared/api/index.tsx b/src/shared/api/index.tsx index a5821b6..a49a78f 100644 --- a/src/shared/api/index.tsx +++ b/src/shared/api/index.tsx @@ -35,3 +35,4 @@ const languageInstance = (language: Language) => { }; export { authInstance, languageInstance }; +export { mobxFetch } from "./mobxFetch"; diff --git a/src/shared/api/mobxFetch/index.ts b/src/shared/api/mobxFetch/index.ts new file mode 100644 index 0000000..2ce9971 --- /dev/null +++ b/src/shared/api/mobxFetch/index.ts @@ -0,0 +1,183 @@ +import { runInAction } from "mobx"; + +type mobxFetchOptions = { + store: Store; + value?: keyof Store; + values?: Array; + loading?: keyof Store; + error?: keyof Store; + fn: RequestType extends void + ? (signal?: AbortSignal) => Promise + : (request: RequestType, signal?: AbortSignal) => Promise; + + pollingInterval?: number; + resetValue?: boolean; + transform?: (response: ResponseType) => Partial>; + onSuccess?: (response: ResponseType) => void; +}; + +type FetchFunction = RequestType extends void + ? { + (): Promise; + stopPolling?: () => void; + } + : { + (request: RequestType): Promise; + stopPolling?: () => void; + }; + +export function mobxFetch>( + options: mobxFetchOptions +): FetchFunction; + +export function mobxFetch< + RequestType, + ResponseType, + Store extends Record, +>( + options: mobxFetchOptions +): FetchFunction; + +export function mobxFetch< + RequestType, + ResponseType, + Store extends Record, +>( + options: mobxFetchOptions +): FetchFunction { + const { + store, + value, + values, + loading, + error, + fn, + pollingInterval, + resetValue, + transform, + onSuccess, + } = options; + + let abortController: AbortController | undefined; + let pollingTimer: ReturnType | undefined; + let currentRequest: RequestType | undefined; + + const stopPolling = () => { + if (pollingTimer) { + clearInterval(pollingTimer); + pollingTimer = undefined; + } + abortController?.abort(); + }; + + const fetch = async (request?: RequestType): Promise => { + abortController?.abort(); + abortController = new AbortController(); + currentRequest = request as RequestType; + + runInAction(() => { + if (value) { + (store[value] as any) = resetValue ? null : store[value]; + } + + if (values) { + values.forEach((key) => { + (store[key] as any) = resetValue ? null : store[key]; + }); + } + + if (error) { + (store[error] as any) = null; + } + + if (loading) { + (store[loading] as any) = true; + } + }); + + try { + const result = await ( + fn as ( + request?: RequestType, + signal?: AbortSignal + ) => Promise + )(request, abortController.signal); + + runInAction(() => { + if (values && transform) { + const transformed = transform(result) as Record; + values.forEach((key) => { + const k = key as string; + if (k in transformed) { + (store[key] as any) = transformed[k]; + } + }); + } else if (value) { + (store[value] as any) = result as ResponseType; + } + + if (loading) { + (store[loading] as any) = false; + } + + if (error) { + (store[error] as any) = null; + } + }); + + if (pollingInterval && !pollingTimer) { + pollingTimer = setInterval(() => { + if (currentRequest !== undefined) { + fetch(currentRequest); + } else { + fetch(); + } + }, pollingInterval); + } + + if (onSuccess) { + onSuccess(result); + } + + return result; + } catch (err) { + if (!(err instanceof Error && err.name === "CanceledError")) { + runInAction(() => { + if (error) { + (store[error] as any) = + err instanceof Error ? err.message : String(err); + } + + if (loading) { + (store[loading] as any) = false; + } + + if (value) { + (store[value] as any) = null; + } + + if (values) { + values.forEach((key) => { + (store[key] as any) = null; + }); + } + }); + + throw err; + } + + return null; + } + }; + + const fetchWithStopPolling = fetch as FetchFunction< + RequestType, + ResponseType + >; + + if (pollingInterval) { + fetchWithStopPolling.stopPolling = stopPolling; + } + + return fetchWithStopPolling; +} diff --git a/src/shared/config/CarrierSvg.tsx b/src/shared/config/CarrierSvg.tsx deleted file mode 100644 index 6d4facc..0000000 --- a/src/shared/config/CarrierSvg.tsx +++ /dev/null @@ -1,62 +0,0 @@ -export const CarrierSvg = () => { - return ( - - - - - - - - - - ); -}; diff --git a/src/shared/config/carrier.svg b/src/shared/config/carrier.svg new file mode 100644 index 0000000..ba8279b --- /dev/null +++ b/src/shared/config/carrier.svg @@ -0,0 +1,58 @@ + + + + + + + + + diff --git a/src/shared/config/constants.tsx b/src/shared/config/constants.tsx index 7301060..aa424e2 100644 --- a/src/shared/config/constants.tsx +++ b/src/shared/config/constants.tsx @@ -1,4 +1,4 @@ -import { authStore } from "@shared"; +import { authStore, snapshotStore } from "@shared"; import { Power, LucideIcon, @@ -8,15 +8,15 @@ import { Earth, Landmark, GitBranch, - // Car, Table, Split, - // Newspaper, PersonStanding, Cpu, - // BookImage, + RefreshCcw, } from "lucide-react"; -import { CarrierSvg } from "./CarrierSvg"; +import { toast } from "react-toastify"; + +import carrierIcon from "./carrier.svg"; export const DRAWER_WIDTH = 300; @@ -25,12 +25,63 @@ interface NavigationItem { label: string; icon?: LucideIcon | React.ReactNode; path?: string; - for_admin?: boolean; + requiredRoles?: string[]; onClick?: () => void; nestedItems?: NavigationItem[]; isActive?: boolean; } +export const ROUTE_REQUIRED_RESOURCES: Record = { + "/": [], + + "/sight": ["sights"], + "/sight/create": ["sights"], + "/sight/:id/edit": ["sights"], + + "/devices": ["devices", "vehicles", "routes", "carriers", "snapshot_rw"], + + "/map": ["map"], + + "/media": ["sights"], + "/media/:id": ["sights"], + "/media/:id/edit": ["sights"], + + "/country": ["countries"], + "/country/create": ["countries"], + "/country/add": ["countries"], + "/country/:id/edit": ["countries"], + + "/city": ["cities", "countries"], + "/city/create": ["cities", "countries"], + "/city/:id/edit": ["cities", "countries"], + + "/route": ["routes", "carriers"], + "/route/create": ["routes", "carriers"], + "/route/:id/edit": ["routes", "carriers"], + + "/user": ["users"], + "/user/create": ["users"], + "/user/:id/edit": ["users"], + + "/snapshot": ["snapshot_rw"], + "/snapshot/create": ["snapshot_create", "devices_rw"], + + "/carrier": ["carriers"], + "/carrier/create": ["carriers"], + "/carrier/:id/edit": ["carriers"], + + "/station": ["stations"], + "/station/create": ["stations"], + "/station/:id": ["stations"], + "/station/:id/edit": ["stations"], + + "/vehicle/create": ["devices"], + "/vehicle/:id/edit": ["devices"], + + "/article": ["sights"], + "/article/:id": ["sights"], +}; + export const NAVIGATION_ITEMS: { primary: NavigationItem[]; secondary: NavigationItem[]; @@ -38,10 +89,10 @@ export const NAVIGATION_ITEMS: { primary: [ { id: "snapshots", - label: "Снапшоты", + label: "Экспорт", icon: GitBranch, path: "/snapshot", - for_admin: true, + requiredRoles: ["snapshot_rw", "snapshot_create"], }, { id: "map", @@ -54,55 +105,40 @@ export const NAVIGATION_ITEMS: { label: "Устройства", icon: Cpu, path: "/devices", - for_admin: true, + requiredRoles: ["devices_ro", "devices_rw"], }, - // { - // id: "vehicles", - // label: "Транспорт", - // icon: Car, - // path: "/vehicle", - // }, { id: "users", label: "Пользователи", icon: Users, path: "/user", - for_admin: true, + requiredRoles: ["users_ro", "users_rw"], }, { id: "all", label: "Справочник", icon: Table, nestedItems: [ - // { - // id: "media", - // label: "Медиа", - // icon: BookImage, - // path: "/media", - // }, - // { - // id: "articles", - // label: "Статьи", - // icon: Newspaper, - // path: "/article", - // }, { id: "attractions", label: "Достопримечательности", icon: Landmark, path: "/sight", + requiredRoles: ["sights_ro", "sights_rw"], }, { id: "stations", label: "Остановки", icon: PersonStanding, path: "/station", + requiredRoles: ["stations_ro", "stations_rw"], }, { id: "routes", label: "Маршруты", icon: Split, path: "/route", + requiredRoles: ["routes_ro", "routes_rw"], }, { @@ -110,27 +146,36 @@ export const NAVIGATION_ITEMS: { label: "Страны", icon: Earth, path: "/country", - for_admin: true, + requiredRoles: ["countries_ro", "countries_rw"], }, { id: "cities", label: "Города", icon: Building2, path: "/city", - for_admin: true, + requiredRoles: ["cities_ro", "cities_rw"], }, { id: "carriers", label: "Перевозчики", // @ts-ignore - icon: CarrierSvg, + icon: () => Перевозчики, path: "/carrier", - for_admin: true, + requiredRoles: ["carriers_ro", "carriers_rw"], }, ], }, ], secondary: [ + { + id: "clear-cache", + label: "Очистить кэш", + icon: RefreshCcw, + onClick: () => { + snapshotStore.clearStoreCache(); + toast.success("Кэш очищен"); + }, + }, { id: "logout", label: "Выйти", @@ -143,7 +188,31 @@ export const NAVIGATION_ITEMS: { ], }; +function collectRoles(list: NavigationItem[]): string[] { + const roles = new Set(["admin"]); + const walk = (items: NavigationItem[]) => { + for (const item of items) { + item.requiredRoles?.forEach((r) => roles.add(r)); + item.nestedItems && walk(item.nestedItems); + } + }; + walk(list); + return Array.from(roles); +} + +export const ALL_ROLES = collectRoles(NAVIGATION_ITEMS.primary); + export const VEHICLE_TYPES = [ - { label: "Трамвай", value: 1 }, + { label: "Автобус", value: 3 }, { label: "Троллейбус", value: 2 }, + { label: "Трамвай", value: 1 }, + { label: "Электробус", value: 4 }, + { label: "Электричка", value: 5 }, + { label: "Вагон метро", value: 6 }, + { label: "Вагон ЖД", value: 7 }, ]; + +export const VEHICLE_MODELS = [ + { label: "71-431P «Довлатов»", value: "71-431P «Довлатов»" }, + { label: "71-638M-02 «Альтаир»", value: "71-638M-02 «Альтаир»" }, +] as const; diff --git a/src/shared/const/index.ts b/src/shared/const/index.ts index 32e72cc..4da3523 100644 --- a/src/shared/const/index.ts +++ b/src/shared/const/index.ts @@ -1,4 +1,22 @@ +import * as countries from "i18n-iso-countries"; +import * as ru from "i18n-iso-countries/langs/ru.json"; +import * as en from "i18n-iso-countries/langs/en.json"; +import * as zh from "i18n-iso-countries/langs/zh.json"; + +countries.registerLocale(ru); +countries.registerLocale(en); +countries.registerLocale(zh); + +const generateCountriesList = (locale: string) => { + const names = countries.getNames(locale); + return Object.entries(names).map(([code, name]) => ({ + code: code, + name: name, + })); +}; + export const API_URL = import.meta.env.VITE_API_URL; + export const MEDIA_TYPE_LABELS = { 1: "Фото", 2: "Видео", @@ -15,6 +33,7 @@ export const MEDIA_TYPE_VALUES = { video: 2, icon: 3, thumbnail: 3, + alt_icon: 3, watermark_lu: 4, watermark_rd: 4, panorama: 5, @@ -22,751 +41,6 @@ export const MEDIA_TYPE_VALUES = { video_preview: 2, }; -export const RU_COUNTRIES = [ - { code: "AF", name: "Афганистан" }, - { code: "AX", name: "Аландские острова" }, - { code: "AL", name: "Албания" }, - { code: "DZ", name: "Алжир" }, - { code: "AS", name: "Американское Самоа" }, - { code: "AD", name: "Андорра" }, - { code: "AO", name: "Ангола" }, - { code: "AI", name: "Ангилья" }, - { code: "AQ", name: "Антарктида" }, - { code: "AG", name: "Антигуа и Барбуда" }, - { code: "AR", name: "Аргентина" }, - { code: "AM", name: "Армения" }, - { code: "AW", name: "Аруба" }, - { code: "AU", name: "Австралия" }, - { code: "AT", name: "Австрия" }, - { code: "AZ", name: "Азербайджан" }, - { code: "BS", name: "Багамы" }, - { code: "BH", name: "Бахрейн" }, - { code: "BD", name: "Бангладеш" }, - { code: "BB", name: "Барбадос" }, - { code: "BY", name: "Беларусь" }, - { code: "BE", name: "Бельгия" }, - { code: "BZ", name: "Белиз" }, - { code: "BJ", name: "Бенин" }, - { code: "BM", name: "Бермуды" }, - { code: "BT", name: "Бутан" }, - { code: "BO", name: "Боливия" }, - { code: "BA", name: "Босния и Герцеговина" }, - { code: "BW", name: "Ботсвана" }, - { code: "BV", name: "Остров Буве" }, - { code: "BR", name: "Бразилия" }, - { code: "IO", name: "Британская территория в Индийском океане" }, - { code: "BN", name: "Бруней-Даруссалам" }, - { code: "BG", name: "Болгария" }, - { code: "BF", name: "Буркина-Фасо" }, - { code: "BI", name: "Бурунди" }, - { code: "KH", name: "Камбоджа" }, - { code: "CM", name: "Камерун" }, - { code: "CA", name: "Канада" }, - { code: "CV", name: "Кабо-Верде" }, - { code: "KY", name: "Каймановы острова" }, - { code: "CF", name: "Центральноафриканская Республика" }, - { code: "TD", name: "Чад" }, - { code: "CL", name: "Чили" }, - { code: "CN", name: "Китай" }, - { code: "CX", name: "Остров Рождества" }, - { code: "CC", name: "Кокосовые (Килинг) острова" }, - { code: "CO", name: "Колумбия" }, - { code: "KM", name: "Коморы" }, - { code: "CG", name: "Конго" }, - { code: "CD", name: "Демократическая Республика Конго" }, - { code: "CK", name: "Острова Кука" }, - { code: "CR", name: "Коста-Рика" }, - { code: "CI", name: "Кот-д'Ивуар" }, - { code: "HR", name: "Хорватия" }, - { code: "CU", name: "Куба" }, - { code: "CY", name: "Кипр" }, - { code: "CZ", name: "Чехия" }, - { code: "DK", name: "Дания" }, - { code: "DJ", name: "Джибути" }, - { code: "DM", name: "Доминика" }, - { code: "DO", name: "Доминиканская Республика" }, - { code: "EC", name: "Эквадор" }, - { code: "EG", name: "Египет" }, - { code: "SV", name: "Сальвадор" }, - { code: "GQ", name: "Экваториальная Гвинея" }, - { code: "ER", name: "Эритрея" }, - { code: "EE", name: "Эстония" }, - { code: "ET", name: "Эфиопия" }, - { code: "FK", name: "Фолклендские острова (Мальвинские)" }, - { code: "FO", name: "Фарерские острова" }, - { code: "FJ", name: "Фиджи" }, - { code: "FI", name: "Финляндия" }, - { code: "FR", name: "Франция" }, - { code: "GF", name: "Французская Гвиана" }, - { code: "PF", name: "Французская Полинезия" }, - { code: "TF", name: "Французские Южные территории" }, - { code: "GA", name: "Габон" }, - { code: "GM", name: "Гамбия" }, - { code: "GE", name: "Грузия" }, - { code: "DE", name: "Германия" }, - { code: "GH", name: "Гана" }, - { code: "GI", name: "Гибралтар" }, - { code: "GR", name: "Греция" }, - { code: "GL", name: "Гренландия" }, - { code: "GD", name: "Гренада" }, - { code: "GP", name: "Гваделупа" }, - { code: "GU", name: "Гуам" }, - { code: "GT", name: "Гватемала" }, - { code: "GG", name: "Гернси" }, - { code: "GN", name: "Гвинея" }, - { code: "GW", name: "Гвинея-Бисау" }, - { code: "GY", name: "Гайана" }, - { code: "HT", name: "Гаити" }, - { code: "HM", name: "Остров Херд и острова Макдональд" }, - { code: "VA", name: "Ватикан" }, - { code: "HN", name: "Гондурас" }, - { code: "HK", name: "Гонконг" }, - { code: "HU", name: "Венгрия" }, - { code: "IS", name: "Исландия" }, - { code: "IN", name: "Индия" }, - { code: "ID", name: "Индонезия" }, - { code: "IR", name: "Иран" }, - { code: "IQ", name: "Ирак" }, - { code: "IE", name: "Ирландия" }, - { code: "IM", name: "Остров Мэн" }, - { code: "IL", name: "Израиль" }, - { code: "IT", name: "Италия" }, - { code: "JM", name: "Ямайка" }, - { code: "JP", name: "Япония" }, - { code: "JE", name: "Джерси" }, - { code: "JO", name: "Иордания" }, - { code: "KZ", name: "Казахстан" }, - { code: "KE", name: "Кения" }, - { code: "KI", name: "Кирибати" }, - { code: "KR", name: "Корея" }, - { code: "KP", name: "Северная Корея" }, - { code: "KW", name: "Кувейт" }, - { code: "KG", name: "Киргизия" }, - { code: "LA", name: "Лаос" }, - { code: "LV", name: "Латвия" }, - { code: "LB", name: "Ливан" }, - { code: "LS", name: "Лесото" }, - { code: "LR", name: "Либерия" }, - { code: "LY", name: "Ливия" }, - { code: "LI", name: "Лихтенштейн" }, - { code: "LT", name: "Литва" }, - { code: "LU", name: "Люксембург" }, - { code: "MO", name: "Макао" }, - { code: "MK", name: "Северная Македония" }, - { code: "MG", name: "Мадагаскар" }, - { code: "MW", name: "Малави" }, - { code: "MY", name: "Малайзия" }, - { code: "MV", name: "Мальдивы" }, - { code: "ML", name: "Мали" }, - { code: "MT", name: "Мальта" }, - { code: "MH", name: "Маршалловы Острова" }, - { code: "MQ", name: "Мартиника" }, - { code: "MR", name: "Мавритания" }, - { code: "MU", name: "Маврикий" }, - { code: "YT", name: "Майотта" }, - { code: "MX", name: "Мексика" }, - { code: "FM", name: "Микронезия" }, - { code: "MD", name: "Молдова" }, - { code: "MC", name: "Монако" }, - { code: "MN", name: "Монголия" }, - { code: "ME", name: "Черногория" }, - { code: "MS", name: "Монтсеррат" }, - { code: "MA", name: "Марокко" }, - { code: "MZ", name: "Мозамбик" }, - { code: "MM", name: "Мьянма" }, - { code: "NA", name: "Намибия" }, - { code: "NR", name: "Науру" }, - { code: "NP", name: "Непал" }, - { code: "NL", name: "Нидерланды" }, - { code: "AN", name: "Нидерландские Антильские острова" }, - { code: "NC", name: "Новая Каледония" }, - { code: "NZ", name: "Новая Зеландия" }, - { code: "NI", name: "Никарагуа" }, - { code: "NE", name: "Нигер" }, - { code: "NG", name: "Нигерия" }, - { code: "NU", name: "Ниуэ" }, - { code: "NF", name: "Остров Норфолк" }, - { code: "MP", name: "Северные Марианские острова" }, - { code: "NO", name: "Норвегия" }, - { code: "OM", name: "Оман" }, - { code: "PK", name: "Пакистан" }, - { code: "PW", name: "Палау" }, - { code: "PS", name: "Палестинская территория" }, - { code: "PA", name: "Панама" }, - { code: "PG", name: "Папуа — Новая Гвинея" }, - { code: "PY", name: "Парагвай" }, - { code: "PE", name: "Перу" }, - { code: "PH", name: "Филиппины" }, - { code: "PN", name: "Питкэрн" }, - { code: "PL", name: "Польша" }, - { code: "PT", name: "Португалия" }, - { code: "PR", name: "Пуэрто-Рико" }, - { code: "QA", name: "Катар" }, - { code: "RE", name: "Реюньон" }, - { code: "RO", name: "Румыния" }, - { code: "RU", name: "Россия" }, - { code: "RW", name: "Руанда" }, - { code: "BL", name: "Сен-Бартелеми" }, - { code: "SH", name: "Остров Святой Елены" }, - { code: "KN", name: "Сент-Китс и Невис" }, - { code: "LC", name: "Сент-Люсия" }, - { code: "MF", name: "Сен-Мартен" }, - { code: "PM", name: "Сен-Пьер и Микелон" }, - { code: "VC", name: "Сент-Винсент и Гренадины" }, - { code: "WS", name: "Самоа" }, - { code: "SM", name: "Сан-Марино" }, - { code: "ST", name: "Сан-Томе и Принсипи" }, - { code: "SA", name: "Саудовская Аравия" }, - { code: "SN", name: "Сенегал" }, - { code: "RS", name: "Сербия" }, - { code: "SC", name: "Сейшельские Острова" }, - { code: "SL", name: "Сьерра-Леоне" }, - { code: "SG", name: "Сингапур" }, - { code: "SK", name: "Словакия" }, - { code: "SI", name: "Словения" }, - { code: "SB", name: "Соломоновы Острова" }, - { code: "SO", name: "Сомали" }, - { code: "ZA", name: "Южная Африка" }, - { code: "GS", name: "Южная Георгия и Южные Сандвичевы острова" }, - { code: "ES", name: "Испания" }, - { code: "LK", name: "Шри-Ланка" }, - { code: "SD", name: "Судан" }, - { code: "SR", name: "Суринам" }, - { code: "SJ", name: "Шпицберген и Ян-Майен" }, - { code: "SZ", name: "Свазиленд" }, - { code: "SE", name: "Швеция" }, - { code: "CH", name: "Швейцария" }, - { code: "SY", name: "Сирия" }, - { code: "TW", name: "Тайвань" }, - { code: "TJ", name: "Таджикистан" }, - { code: "TZ", name: "Танзания" }, - { code: "TH", name: "Таиланд" }, - { code: "TL", name: "Восточный Тимор" }, - { code: "TG", name: "Того" }, - { code: "TK", name: "Токелау" }, - { code: "TO", name: "Тонга" }, - { code: "TT", name: "Тринидад и Тобаго" }, - { code: "TN", name: "Тунис" }, - { code: "TR", name: "Турция" }, - { code: "TM", name: "Туркмения" }, - { code: "TC", name: "Теркс и Кайкос" }, - { code: "TV", name: "Тувалу" }, - { code: "UG", name: "Уганда" }, - { code: "UA", name: "Украина" }, - { code: "AE", name: "Объединённые Арабские Эмираты" }, - { code: "GB", name: "Великобритания" }, - { code: "US", name: "США" }, - { code: "UM", name: "Внешние малые острова США" }, - { code: "UY", name: "Уругвай" }, - { code: "UZ", name: "Узбекистан" }, - { code: "VU", name: "Вануату" }, - { code: "VE", name: "Венесуэла" }, - { code: "VN", name: "Вьетнам" }, - { code: "VG", name: "Британские Виргинские острова" }, - { code: "VI", name: "Виргинские острова (США)" }, - { code: "WF", name: "Уоллис и Футуна" }, - { code: "EH", name: "Западная Сахара" }, - { code: "YE", name: "Йемен" }, - { code: "ZM", name: "Замбия" }, - { code: "ZW", name: "Зимбабве" }, -]; - -// countries-en.js -export const EN_COUNTRIES = [ - { code: "AF", name: "Afghanistan" }, - { code: "AX", name: "Aland Islands" }, - { code: "AL", name: "Albania" }, - { code: "DZ", name: "Algeria" }, - { code: "AS", name: "American Samoa" }, - { code: "AD", name: "Andorra" }, - { code: "AO", name: "Angola" }, - { code: "AI", name: "Anguilla" }, - { code: "AQ", name: "Antarctica" }, - { code: "AG", name: "Antigua And Barbuda" }, - { code: "AR", name: "Argentina" }, - { code: "AM", name: "Armenia" }, - { code: "AW", name: "Aruba" }, - { code: "AU", name: "Australia" }, - { code: "AT", name: "Austria" }, - { code: "AZ", name: "Azerbaijan" }, - { code: "BS", name: "Bahamas" }, - { code: "BH", name: "Bahrain" }, - { code: "BD", name: "Bangladesh" }, - { code: "BB", name: "Barbados" }, - { code: "BY", name: "Belarus" }, - { code: "BE", name: "Belgium" }, - { code: "BZ", name: "Belize" }, - { code: "BJ", name: "Benin" }, - { code: "BM", name: "Bermuda" }, - { code: "BT", name: "Bhutan" }, - { code: "BO", name: "Bolivia" }, - { code: "BA", name: "Bosnia And Herzegovina" }, - { code: "BW", name: "Botswana" }, - { code: "BV", name: "Bouvet Island" }, - { code: "BR", name: "Brazil" }, - { code: "IO", name: "British Indian Ocean Territory" }, - { code: "BN", name: "Brunei Darussalam" }, - { code: "BG", name: "Bulgaria" }, - { code: "BF", name: "Burkina Faso" }, - { code: "BI", name: "Burundi" }, - { code: "KH", name: "Cambodia" }, - { code: "CM", name: "Cameroon" }, - { code: "CA", name: "Canada" }, - { code: "CV", name: "Cape Verde" }, - { code: "KY", name: "Cayman Islands" }, - { code: "CF", name: "Central African Republic" }, - { code: "TD", name: "Chad" }, - { code: "CL", name: "Chile" }, - { code: "CN", name: "China" }, - { code: "CX", name: "Christmas Island" }, - { code: "CC", name: "Cocos (Keeling) Islands" }, - { code: "CO", name: "Colombia" }, - { code: "KM", name: "Comoros" }, - { code: "CG", name: "Congo" }, - { code: "CD", name: "Congo, Democratic Republic" }, - { code: "CK", name: "Cook Islands" }, - { code: "CR", name: "Costa Rica" }, - { code: "CI", name: "Cote D'Ivoire" }, - { code: "HR", name: "Croatia" }, - { code: "CU", name: "Cuba" }, - { code: "CY", name: "Cyprus" }, - { code: "CZ", name: "Czech Republic" }, - { code: "DK", name: "Denmark" }, - { code: "DJ", name: "Djibouti" }, - { code: "DM", name: "Dominica" }, - { code: "DO", name: "Dominican Republic" }, - { code: "EC", name: "Ecuador" }, - { code: "EG", name: "Egypt" }, - { code: "SV", name: "El Salvador" }, - { code: "GQ", name: "Equatorial Guinea" }, - { code: "ER", name: "Eritrea" }, - { code: "EE", name: "Estonia" }, - { code: "ET", name: "Ethiopia" }, - { code: "FK", name: "Falkland Islands (Malvinas)" }, - { code: "FO", name: "Faroe Islands" }, - { code: "FJ", name: "Fiji" }, - { code: "FI", name: "Finland" }, - { code: "FR", name: "France" }, - { code: "GF", name: "French Guiana" }, - { code: "PF", name: "French Polynesia" }, - { code: "TF", name: "French Southern Territories" }, - { code: "GA", name: "Gabon" }, - { code: "GM", name: "Gambia" }, - { code: "GE", name: "Georgia" }, - { code: "DE", name: "Germany" }, - { code: "GH", name: "Ghana" }, - { code: "GI", name: "Gibraltar" }, - { code: "GR", name: "Greece" }, - { code: "GL", name: "Greenland" }, - { code: "GD", name: "Grenada" }, - { code: "GP", name: "Guadeloupe" }, - { code: "GU", name: "Guam" }, - { code: "GT", name: "Guatemala" }, - { code: "GG", name: "Guernsey" }, - { code: "GN", name: "Guinea" }, - { code: "GW", name: "Guinea-Bissau" }, - { code: "GY", name: "Guyana" }, - { code: "HT", name: "Haiti" }, - { code: "HM", name: "Heard Island & Mcdonald Islands" }, - { code: "VA", name: "Holy See (Vatican City State)" }, - { code: "HN", name: "Honduras" }, - { code: "HK", name: "Hong Kong" }, - { code: "HU", name: "Hungary" }, - { code: "IS", name: "Iceland" }, - { code: "IN", name: "India" }, - { code: "ID", name: "Indonesia" }, - { code: "IR", name: "Iran, Islamic Republic Of" }, - { code: "IQ", name: "Iraq" }, - { code: "IE", name: "Ireland" }, - { code: "IM", name: "Isle Of Man" }, - { code: "IL", name: "Israel" }, - { code: "IT", name: "Italy" }, - { code: "JM", name: "Jamaica" }, - { code: "JP", name: "Japan" }, - { code: "JE", name: "Jersey" }, - { code: "JO", name: "Jordan" }, - { code: "KZ", name: "Kazakhstan" }, - { code: "KE", name: "Kenya" }, - { code: "KI", name: "Kiribati" }, - { code: "KR", name: "Korea" }, - { code: "KP", name: "North Korea" }, - { code: "KW", name: "Kuwait" }, - { code: "KG", name: "Kyrgyzstan" }, - { code: "LA", name: "Lao People's Democratic Republic" }, - { code: "LV", name: "Latvia" }, - { code: "LB", name: "Lebanon" }, - { code: "LS", name: "Lesotho" }, - { code: "LR", name: "Liberia" }, - { code: "LY", name: "Libyan Arab Jamahiriya" }, - { code: "LI", name: "Liechtenstein" }, - { code: "LT", name: "Lithuania" }, - { code: "LU", name: "Luxembourg" }, - { code: "MO", name: "Macao" }, - { code: "MK", name: "Macedonia" }, - { code: "MG", name: "Madagascar" }, - { code: "MW", name: "Malawi" }, - { code: "MY", name: "Malaysia" }, - { code: "MV", name: "Maldives" }, - { code: "ML", name: "Mali" }, - { code: "MT", name: "Malta" }, - { code: "MH", name: "Marshall Islands" }, - { code: "MQ", name: "Martinique" }, - { code: "MR", name: "Mauritania" }, - { code: "MU", name: "Mauritius" }, - { code: "YT", name: "Mayotte" }, - { code: "MX", name: "Mexico" }, - { code: "FM", name: "Micronesia, Federated States Of" }, - { code: "MD", name: "Moldova" }, - { code: "MC", name: "Monaco" }, - { code: "MN", name: "Mongolia" }, - { code: "ME", name: "Montenegro" }, - { code: "MS", name: "Montserrat" }, - { code: "MA", name: "Morocco" }, - { code: "MZ", name: "Mozambique" }, - { code: "MM", name: "Myanmar" }, - { code: "NA", name: "Namibia" }, - { code: "NR", name: "Nauru" }, - { code: "NP", name: "Nepal" }, - { code: "NL", name: "Netherlands" }, - { code: "AN", name: "Netherlands Antilles" }, - { code: "NC", name: "New Caledonia" }, - { code: "NZ", name: "New Zealand" }, - { code: "NI", name: "Nicaragua" }, - { code: "NE", name: "Niger" }, - { code: "NG", name: "Nigeria" }, - { code: "NU", name: "Niue" }, - { code: "NF", name: "Norfolk Island" }, - { code: "MP", name: "Northern Mariana Islands" }, - { code: "NO", name: "Norway" }, - { code: "OM", name: "Oman" }, - { code: "PK", name: "Pakistan" }, - { code: "PW", name: "Palau" }, - { code: "PS", name: "Palestinian Territory, Occupied" }, - { code: "PA", name: "Panama" }, - { code: "PG", name: "Papua New Guinea" }, - { code: "PY", name: "Paraguay" }, - { code: "PE", name: "Peru" }, - { code: "PH", name: "Philippines" }, - { code: "PN", name: "Pitcairn" }, - { code: "PL", name: "Poland" }, - { code: "PT", name: "Portugal" }, - { code: "PR", name: "Puerto Rico" }, - { code: "QA", name: "Qatar" }, - { code: "RE", name: "Reunion" }, - { code: "RO", name: "Romania" }, - { code: "RU", name: "Russian Federation" }, - { code: "RW", name: "Rwanda" }, - { code: "BL", name: "Saint Barthelemy" }, - { code: "SH", name: "Saint Helena" }, - { code: "KN", name: "Saint Kitts And Nevis" }, - { code: "LC", name: "Saint Lucia" }, - { code: "MF", name: "Saint Martin" }, - { code: "PM", name: "Saint Pierre And Miquelon" }, - { code: "VC", name: "Saint Vincent And Grenadines" }, - { code: "WS", name: "Samoa" }, - { code: "SM", name: "San Marino" }, - { code: "ST", name: "Sao Tome And Principe" }, - { code: "SA", name: "Saudi Arabia" }, - { code: "SN", name: "Senegal" }, - { code: "RS", name: "Serbia" }, - { code: "SC", name: "Seychelles" }, - { code: "SL", name: "Sierra Leone" }, - { code: "SG", name: "Singapore" }, - { code: "SK", name: "Slovakia" }, - { code: "SI", name: "Slovenia" }, - { code: "SB", name: "Solomon Islands" }, - { code: "SO", name: "Somalia" }, - { code: "ZA", name: "South Africa" }, - { code: "GS", name: "South Georgia And Sandwich Isl." }, - { code: "ES", name: "Spain" }, - { code: "LK", name: "Sri Lanka" }, - { code: "SD", name: "Sudan" }, - { code: "SR", name: "Suriname" }, - { code: "SJ", name: "Svalbard And Jan Mayen" }, - { code: "SZ", name: "Swaziland" }, - { code: "SE", name: "Sweden" }, - { code: "CH", name: "Switzerland" }, - { code: "SY", name: "Syrian Arab Republic" }, - { code: "TW", name: "Taiwan" }, - { code: "TJ", name: "Tajikistan" }, - { code: "TZ", name: "Tanzania" }, - { code: "TH", name: "Thailand" }, - { code: "TL", name: "Timor-Leste" }, - { code: "TG", name: "Togo" }, - { code: "TK", name: "Tokelau" }, - { code: "TO", name: "Tonga" }, - { code: "TT", name: "Trinidad And Tobago" }, - { code: "TN", name: "Tunisia" }, - { code: "TR", name: "Turkey" }, - { code: "TM", name: "Turkmenistan" }, - { code: "TC", name: "Turks And Caicos Islands" }, - { code: "TV", name: "Tuvalu" }, - { code: "UG", name: "Uganda" }, - { code: "UA", name: "Ukraine" }, - { code: "AE", name: "United Arab Emirates" }, - { code: "GB", name: "United Kingdom" }, - { code: "US", name: "United States" }, - { code: "UM", name: "United States Outlying Islands" }, - { code: "UY", name: "Uruguay" }, - { code: "UZ", name: "Uzbekistan" }, - { code: "VU", name: "Vanuatu" }, - { code: "VE", name: "Venezuela" }, - { code: "VN", name: "Vietnam" }, - { code: "VG", name: "Virgin Islands, British" }, - { code: "VI", name: "Virgin Islands, U.S." }, - { code: "WF", name: "Wallis And Futuna" }, - { code: "EH", name: "Western Sahara" }, - { code: "YE", name: "Yemen" }, - { code: "ZM", name: "Zambia" }, - { code: "ZW", name: "Zimbabwe" }, -]; - -// countries-zh.js -export const ZH_COUNTRIES = [ - { code: "AF", name: "阿富汗" }, - { code: "AX", name: "奥兰群岛" }, - { code: "AL", name: "阿尔巴尼亚" }, - { code: "DZ", name: "阿尔及利亚" }, - { code: "AS", name: "美属萨摩亚" }, - { code: "AD", name: "安道尔" }, - { code: "AO", name: "安哥拉" }, - { code: "AI", name: "安圭拉" }, - { code: "AQ", name: "南极洲" }, - { code: "AG", name: "安提瓜和巴布达" }, - { code: "AR", name: "阿根廷" }, - { code: "AM", name: "亚美尼亚" }, - { code: "AW", name: "阿鲁巴" }, - { code: "AU", name: "澳大利亚" }, - { code: "AT", name: "奥地利" }, - { code: "AZ", name: "阿塞拜疆" }, - { code: "BS", name: "巴哈马" }, - { code: "BH", name: "巴林" }, - { code: "BD", name: "孟加拉国" }, - { code: "BB", name: "巴巴多斯" }, - { code: "BY", name: "白俄罗斯" }, - { code: "BE", name: "比利时" }, - { code: "BZ", name: "伯利兹" }, - { code: "BJ", name: "贝宁" }, - { code: "BM", name: "百慕大" }, - { code: "BT", name: "不丹" }, - { code: "BO", name: "玻利维亚" }, - { code: "BA", name: "波斯尼亚和黑塞哥维那" }, - { code: "BW", name: "博茨瓦纳" }, - { code: "BV", name: "布韦岛" }, - { code: "BR", name: "巴西" }, - { code: "IO", name: "英属印度洋领地" }, - { code: "BN", name: "文莱" }, - { code: "BG", name: "保加利亚" }, - { code: "BF", name: "布基纳法索" }, - { code: "BI", name: "布隆迪" }, - { code: "KH", name: "柬埔寨" }, - { code: "CM", name: "喀麦隆" }, - { code: "CA", name: "加拿大" }, - { code: "CV", name: "佛得角" }, - { code: "KY", name: "开曼群岛" }, - { code: "CF", name: "中非共和国" }, - { code: "TD", name: "乍得" }, - { code: "CL", name: "智利" }, - { code: "CN", name: "中国" }, - { code: "CX", name: "圣诞岛" }, - { code: "CC", name: "科科斯(基林)群岛" }, - { code: "CO", name: "哥伦比亚" }, - { code: "KM", name: "科摩罗" }, - { code: "CG", name: "刚果" }, - { code: "CD", name: "刚果(金)" }, - { code: "CK", name: "库克群岛" }, - { code: "CR", name: "哥斯达黎加" }, - { code: "CI", name: "科特迪瓦" }, - { code: "HR", name: "克罗地亚" }, - { code: "CU", name: "古巴" }, - { code: "CY", name: "塞浦路斯" }, - { code: "CZ", name: "捷克" }, - { code: "DK", name: "丹麦" }, - { code: "DJ", name: "吉布提" }, - { code: "DM", name: "多米尼克" }, - { code: "DO", name: "多米尼加共和国" }, - { code: "EC", name: "厄瓜多尔" }, - { code: "EG", name: "埃及" }, - { code: "SV", name: "萨尔瓦多" }, - { code: "GQ", name: "赤道几内亚" }, - { code: "ER", name: "厄立特里亚" }, - { code: "EE", name: "爱沙尼亚" }, - { code: "ET", name: "埃塞俄比亚" }, - { code: "FK", name: "福克兰群岛" }, - { code: "FO", name: "法罗群岛" }, - { code: "FJ", name: "斐济" }, - { code: "FI", name: "芬兰" }, - { code: "FR", name: "法国" }, - { code: "GF", name: "法属圭亚那" }, - { code: "PF", name: "法属波利尼西亚" }, - { code: "TF", name: "法属南部领地" }, - { code: "GA", name: "加蓬" }, - { code: "GM", name: "冈比亚" }, - { code: "GE", name: "格鲁吉亚" }, - { code: "DE", name: "德国" }, - { code: "GH", name: "加纳" }, - { code: "GI", name: "直布罗陀" }, - { code: "GR", name: "希腊" }, - { code: "GL", name: "格陵兰" }, - { code: "GD", name: "格林纳达" }, - { code: "GP", name: "瓜德罗普" }, - { code: "GU", name: "关岛" }, - { code: "GT", name: "危地马拉" }, - { code: "GG", name: "根西岛" }, - { code: "GN", name: "几内亚" }, - { code: "GW", name: "几内亚比绍" }, - { code: "GY", name: "圭亚那" }, - { code: "HT", name: "海地" }, - { code: "HM", name: "赫德岛和麦克唐纳群岛" }, - { code: "VA", name: "梵蒂冈" }, - { code: "HN", name: "洪都拉斯" }, - { code: "HK", name: "中国香港" }, - { code: "HU", name: "匈牙利" }, - { code: "IS", name: "冰岛" }, - { code: "IN", name: "印度" }, - { code: "ID", name: "印度尼西亚" }, - { code: "IR", name: "伊朗" }, - { code: "IQ", name: "伊拉克" }, - { code: "IE", name: "爱尔兰" }, - { code: "IM", name: "马恩岛" }, - { code: "IL", name: "以色列" }, - { code: "IT", name: "意大利" }, - { code: "JM", name: "牙买加" }, - { code: "JP", name: "日本" }, - { code: "JE", name: "泽西岛" }, - { code: "JO", name: "约旦" }, - { code: "KZ", name: "哈萨克斯坦" }, - { code: "KE", name: "肯尼亚" }, - { code: "KI", name: "基里巴斯" }, - { code: "KR", name: "韩国" }, - { code: "KP", name: "朝鲜" }, - { code: "KW", name: "科威特" }, - { code: "KG", name: "吉尔吉斯斯坦" }, - { code: "LA", name: "老挝" }, - { code: "LV", name: "拉脱维亚" }, - { code: "LB", name: "黎巴嫩" }, - { code: "LS", name: "莱索托" }, - { code: "LR", name: "利比里亚" }, - { code: "LY", name: "利比亚" }, - { code: "LI", name: "列支敦士登" }, - { code: "LT", name: "立陶宛" }, - { code: "LU", name: "卢森堡" }, - { code: "MO", name: "中国澳门" }, - { code: "MK", name: "北马其顿" }, - { code: "MG", name: "马达加斯加" }, - { code: "MW", name: "马拉维" }, - { code: "MY", name: "马来西亚" }, - { code: "MV", name: "马尔代夫" }, - { code: "ML", name: "马里" }, - { code: "MT", name: "马耳他" }, - { code: "MH", name: "马绍尔群岛" }, - { code: "MQ", name: "马提尼克" }, - { code: "MR", name: "毛里塔尼亚" }, - { code: "MU", name: "毛里求斯" }, - { code: "YT", name: "马约特" }, - { code: "MX", name: "墨西哥" }, - { code: "FM", name: "密克罗尼西亚" }, - { code: "MD", name: "摩尔多瓦" }, - { code: "MC", name: "摩纳哥" }, - { code: "MN", name: "蒙古" }, - { code: "ME", name: "黑山" }, - { code: "MS", name: "蒙特塞拉特" }, - { code: "MA", name: "摩洛哥" }, - { code: "MZ", name: "莫桑比克" }, - { code: "MM", name: "缅甸" }, - { code: "NA", name: "纳米比亚" }, - { code: "NR", name: "瑙鲁" }, - { code: "NP", name: "尼泊尔" }, - { code: "NL", name: "荷兰" }, - { code: "AN", name: "荷属安的列斯" }, - { code: "NC", name: "新喀里多尼亚" }, - { code: "NZ", name: "新西兰" }, - { code: "NI", name: "尼加拉瓜" }, - { code: "NE", name: "尼日尔" }, - { code: "NG", name: "尼日利亚" }, - { code: "NU", name: "纽埃" }, - { code: "NF", name: "诺福克岛" }, - { code: "MP", name: "北马里亚纳群岛" }, - { code: "NO", name: "挪威" }, - { code: "OM", name: "阿曼" }, - { code: "PK", name: "巴基斯坦" }, - { code: "PW", name: "帕劳" }, - { code: "PS", name: "巴勒斯坦" }, - { code: "PA", name: "巴拿马" }, - { code: "PG", name: "巴布亚新几内亚" }, - { code: "PY", name: "巴拉圭" }, - { code: "PE", name: "秘鲁" }, - { code: "PH", name: "菲律宾" }, - { code: "PN", name: "皮特凯恩群岛" }, - { code: "PL", name: "波兰" }, - { code: "PT", name: "葡萄牙" }, - { code: "PR", name: "波多黎各" }, - { code: "QA", name: "卡塔尔" }, - { code: "RE", name: "留尼汪" }, - { code: "RO", name: "罗马尼亚" }, - { code: "RU", name: "俄罗斯" }, - { code: "RW", name: "卢旺达" }, - { code: "BL", name: "圣巴泰勒米" }, - { code: "SH", name: "圣赫勒拿" }, - { code: "KN", name: "圣基茨和尼维斯" }, - { code: "LC", name: "圣卢西亚" }, - { code: "MF", name: "法属圣马丁" }, - { code: "PM", name: "圣皮埃尔和密克隆" }, - { code: "VC", name: "圣文森特和格林纳丁斯" }, - { code: "WS", name: "萨摩亚" }, - { code: "SM", name: "圣马力诺" }, - { code: "ST", name: "圣多美和普林西比" }, - { code: "SA", name: "沙特阿拉伯" }, - { code: "SN", name: "塞内加尔" }, - { code: "RS", name: "塞尔维亚" }, - { code: "SC", name: "塞舌尔" }, - { code: "SL", name: "塞拉利昂" }, - { code: "SG", name: "新加坡" }, - { code: "SK", name: "斯洛伐克" }, - { code: "SI", name: "斯洛文尼亚" }, - { code: "SB", name: "所罗门群岛" }, - { code: "SO", name: "索马里" }, - { code: "ZA", name: "南非" }, - { code: "GS", name: "南乔治亚和南桑威奇群岛" }, - { code: "ES", name: "西班牙" }, - { code: "LK", name: "斯里兰卡" }, - { code: "SD", name: "苏丹" }, - { code: "SR", name: "苏里南" }, - { code: "SJ", name: "斯瓦尔巴和扬马延" }, - { code: "SZ", name: "斯威士兰" }, - { code: "SE", name: "瑞典" }, - { code: "CH", name: "瑞士" }, - { code: "SY", name: "叙利亚" }, - { code: "TW", name: "中国台湾" }, - { code: "TJ", name: "塔吉克斯坦" }, - { code: "TZ", name: "坦桑尼亚" }, - { code: "TH", name: "泰国" }, - { code: "TL", name: "东帝汶" }, - { code: "TG", name: "多哥" }, - { code: "TK", name: "托克劳" }, - { code: "TO", name: "汤加" }, - { code: "TT", name: "特立尼达和多巴哥" }, - { code: "TN", name: "突尼斯" }, - { code: "TR", name: "土耳其" }, - { code: "TM", name: "土库曼斯坦" }, - { code: "TC", name: "特克斯和凯科斯群岛" }, - { code: "TV", name: "图瓦卢" }, - { code: "UG", name: "乌干达" }, - { code: "UA", name: "乌克兰" }, - { code: "AE", name: "阿联酋" }, - { code: "GB", name: "英国" }, - { code: "US", name: "美国" }, - { code: "UM", name: "美国本土外小岛屿" }, - { code: "UY", name: "乌拉圭" }, - { code: "UZ", name: "乌兹别克斯坦" }, - { code: "VU", name: "瓦努阿图" }, - { code: "VE", name: "委内瑞拉" }, - { code: "VN", name: "越南" }, - { code: "VG", name: "英属维尔京群岛" }, - { code: "VI", name: "美属维尔京群岛" }, - { code: "WF", name: "瓦利斯和富图纳" }, - { code: "EH", name: "西撒哈拉" }, - { code: "YE", name: "也门" }, - { code: "ZM", name: "赞比亚" }, - { code: "ZW", name: "津巴布韦" }, -]; +export const RU_COUNTRIES = generateCountriesList("ru"); +export const EN_COUNTRIES = generateCountriesList("en"); +export const ZH_COUNTRIES = generateCountriesList("zh"); diff --git a/src/shared/lib/gltfCacheManager.ts b/src/shared/lib/gltfCacheManager.ts index b02acd7..f9d0b66 100644 --- a/src/shared/lib/gltfCacheManager.ts +++ b/src/shared/lib/gltfCacheManager.ts @@ -1,8 +1,3 @@ -/** - * Утилита для управления кешем GLTF и blob URL - */ - -// Динамический импорт useGLTF для избежания проблем с SSR let useGLTF: any = null; const initializeUseGLTF = async () => { @@ -20,9 +15,6 @@ const initializeUseGLTF = async () => { return useGLTF; }; -/** - * Очищает кеш GLTF для конкретного URL - */ export const clearGLTFCacheForUrl = async (url: string) => { try { const gltf = await initializeUseGLTF(); @@ -32,9 +24,6 @@ export const clearGLTFCacheForUrl = async (url: string) => { } catch (error) {} }; -/** - * Очищает весь кеш GLTF - */ export const clearAllGLTFCache = async () => { try { const gltf = await initializeUseGLTF(); @@ -44,9 +33,6 @@ export const clearAllGLTFCache = async () => { } catch (error) {} }; -/** - * Очищает blob URL из памяти браузера - */ export const revokeBlobURL = (url: string) => { if (url && url.startsWith("blob:")) { try { @@ -55,27 +41,16 @@ export const revokeBlobURL = (url: string) => { } }; -/** - * Комплексная очистка: blob URL + кеш GLTF - */ export const clearBlobAndGLTFCache = async (url: string) => { - // Сначала отзываем blob URL revokeBlobURL(url); - // Затем очищаем кеш GLTF await clearGLTFCacheForUrl(url); }; -/** - * Очистка при смене медиа (для предотвращения конфликтов) - */ export const clearMediaTransitionCache = async ( previousMediaId: string | number | null, - newMediaId: string | number | null, newMediaType?: number ) => { - console.log(newMediaId, newMediaType); - // Если переключаемся с/на 3D модель, очищаем весь кеш if (newMediaType === 6 || previousMediaId) { await clearAllGLTFCache(); } diff --git a/src/shared/lib/index.ts b/src/shared/lib/index.ts index e21c768..471ab14 100644 --- a/src/shared/lib/index.ts +++ b/src/shared/lib/index.ts @@ -1,35 +1,19 @@ export * from "./mui/theme"; export * from "./DecodeJWT"; export * from "./gltfCacheManager"; +export * from "./permissions"; -/** - * Генерирует название медиа по умолчанию в разных форматах - * - * Примеры использования: - * - Для достопримечательности с названием: "Михайловский замок_mikhail-zamok_Фото" - * - Для достопримечательности без названия: "Название_mikhail-zamok_Фото" - * - Для статьи: "Михайловский замок_mikhail-zamok_Факты" (где "Факты" - название статьи) - * - * @param objectName - Название объекта (достопримечательности, города и т.д.) - * @param fileName - Название файла - * @param mediaType - Тип медиа (число) или название статьи - * @param isArticle - Флаг, указывающий что медиа добавляется к статье - * @returns Строка в нужном формате - */ export const generateDefaultMediaName = ( objectName: string, fileName: string, mediaType: number | string, isArticle: boolean = false ): string => { - // Убираем расширение из названия файла const fileNameWithoutExtension = fileName.split(".").slice(0, -1).join("."); if (isArticle && typeof mediaType === "string") { - // Для статей: "Название достопримечательности_название файла_название статьи" return `${objectName}_${fileNameWithoutExtension}_${mediaType}`; } else if (typeof mediaType === "number") { - // Получаем название типа медиа const mediaTypeLabels: Record = { 1: "Фото", 2: "Видео", @@ -42,14 +26,20 @@ export const generateDefaultMediaName = ( const mediaTypeLabel = mediaTypeLabels[mediaType] || "Медиа"; if (objectName && objectName.trim() !== "") { - // Если есть название объекта: "Название объекта_название файла_тип медиа" return `${objectName}_${fileNameWithoutExtension}_${mediaTypeLabel}`; } else { - // Если нет названия объекта: "Название_название файла_тип медиа" return `Название_${fileNameWithoutExtension}_${mediaTypeLabel}`; } } - // Fallback return `${objectName || "Название"}_${fileNameWithoutExtension}_Медиа`; }; + +/** Медиа-id считается пустым, если строка пустая или состоит только из нулей (с дефисами или без). */ +export const isMediaIdEmpty = ( + id: string | null | undefined +): boolean => { + if (id == null || id === "") return true; + const digits = id.replace(/-/g, ""); + return digits === "" || /^0+$/.test(digits); +}; diff --git a/src/shared/lib/permissions/index.ts b/src/shared/lib/permissions/index.ts new file mode 100644 index 0000000..2d46414 --- /dev/null +++ b/src/shared/lib/permissions/index.ts @@ -0,0 +1,18 @@ +export const canRead = (roles: string[] | undefined, resource: string): boolean => { + if (!roles || roles.length === 0) return false; + return ( + roles.includes("admin") || + roles.includes(`${resource}_ro`) || + roles.includes(`${resource}_rw`) + ); +}; + +export const canWrite = (roles: string[] | undefined, resource: string): boolean => { + if (!roles || roles.length === 0) return false; + return roles.includes("admin") || roles.includes(`${resource}_rw`); +}; + +export const createPermissions = (roles: string[] | undefined) => ({ + canRead: (resource: string) => canRead(roles, resource), + canWrite: (resource: string) => canWrite(roles, resource), +}); diff --git a/src/shared/modals/ArticleSelectOrCreateDialog/index.tsx b/src/shared/modals/ArticleSelectOrCreateDialog/index.tsx new file mode 100644 index 0000000..fd39cb1 --- /dev/null +++ b/src/shared/modals/ArticleSelectOrCreateDialog/index.tsx @@ -0,0 +1,1073 @@ +import { + articlesStore, + languageStore, + authInstance, + SelectMediaDialog, + UploadMediaDialog, + Language, + selectedCityStore, +} from "@shared"; +import { observer } from "mobx-react-lite"; +import { useEffect, useState } from "react"; +import { runInAction } from "mobx"; +import { + Dialog, + DialogTitle, + DialogContent, + DialogActions, + Button, + TextField, + List, + ListItemButton, + ListItemText, + Box, + Tabs, + Tab, + Typography, + InputAdornment, + Paper, +} from "@mui/material"; +import { Search, Plus, ImagePlus, Save } from "lucide-react"; +import { + ReactMarkdownEditor, + ReactMarkdownComponent, + MediaViewer, + MediaArea, +} from "@widgets"; +import { toast } from "react-toastify"; + +interface ArticleSelectOrCreateDialogProps { + open: boolean; + onClose: () => void; + onSelectArticle: (articleId: number) => void; +} + +export const ArticleSelectOrCreateDialog = observer( + ({ open, onClose, onSelectArticle }: ArticleSelectOrCreateDialogProps) => { + const { articles, getArticles, getArticle, getArticleMedia } = + articlesStore; + const [modalLanguage, setModalLanguage] = useState("ru"); + const [searchQuery, setSearchQuery] = useState(""); + const [tabValue, setTabValue] = useState(0); + const [isCreating, setIsCreating] = useState(false); + const [isSaving, setIsSaving] = useState(false); + + const [selectedArticleId, setSelectedArticleId] = useState( + null + ); + const [editedArticleData, setEditedArticleData] = useState({ + ru: { heading: "", body: "" }, + en: { heading: "", body: "" }, + zh: { heading: "", body: "" }, + }); + const [editedArticleMedia, setEditedArticleMedia] = useState< + { + id: string; + filename: string; + media_type: number; + }[] + >([]); + + const [newArticleData, setNewArticleData] = useState({ + ru: { heading: "", body: "" }, + en: { heading: "", body: "" }, + zh: { heading: "", body: "" }, + }); + const [createdArticleMedia, setCreatedArticleMedia] = useState< + { + id: string; + filename: string; + media_name?: string; + media_type: number; + }[] + >([]); + const [tempArticleId, setTempArticleId] = useState(null); + + const [isUploadMediaDialogOpen, setIsUploadMediaDialogOpen] = + useState(false); + const [isSelectMediaDialogOpen, setIsSelectMediaDialogOpen] = + useState(false); + const [fileToUpload, setFileToUpload] = useState(null); + + const currentArticleId = selectedArticleId || tempArticleId; + const currentArticleData = selectedArticleId + ? editedArticleData + : newArticleData; + const currentMedia = selectedArticleId + ? editedArticleMedia + : createdArticleMedia; + const isEditMode = + selectedArticleId !== null || tempArticleId !== null || tabValue === 1; + + useEffect(() => { + if (open) { + setModalLanguage("ru"); + (async () => { + await Promise.all([ + getArticles("ru"), + getArticles("en"), + getArticles("zh"), + ]); + })(); + setSearchQuery(""); + setTabValue(0); + setIsCreating(false); + setIsSaving(false); + setSelectedArticleId(null); + setTempArticleId(null); + setEditedArticleData({ + ru: { heading: "", body: "" }, + en: { heading: "", body: "" }, + zh: { heading: "", body: "" }, + }); + setNewArticleData({ + ru: { heading: "", body: "" }, + en: { heading: "", body: "" }, + zh: { heading: "", body: "" }, + }); + setEditedArticleMedia([]); + setCreatedArticleMedia([]); + } + }, [open, getArticles]); + + useEffect(() => { + if (!open) { + languageStore.setLanguage("ru"); + } + }, [open]); + + const loadArticleForEdit = async (articleId: number) => { + try { + const [ruArticle, enArticle, zhArticle, mediaResponse] = + await Promise.all([ + articlesStore.getArticle(articleId, "ru"), + articlesStore.getArticle(articleId, "en"), + articlesStore.getArticle(articleId, "zh"), + authInstance.get(`/article/${articleId}/media`), + ]); + + setEditedArticleData({ + ru: { + heading: ruArticle.data.heading, + body: ruArticle.data.body, + }, + en: { + heading: enArticle.data.heading, + body: enArticle.data.body, + }, + zh: { + heading: zhArticle.data.heading, + body: zhArticle.data.body, + }, + }); + + setEditedArticleMedia( + (mediaResponse.data || []).map((m: any) => ({ + id: m.id, + filename: m.filename, + media_type: m.media_type, + })) + ); + + setSelectedArticleId(articleId); + await getArticleMedia(articleId); + } catch (error) { + console.error("Error loading article:", error); + toast.error("Ошибка при загрузке статьи"); + } + }; + + const handleArticleSelect = async (articleId: number) => { + await loadArticleForEdit(articleId); + }; + + const handleSaveArticle = async () => { + if (!currentArticleId) return; + + try { + setIsSaving(true); + await authInstance.patch(`/article/${currentArticleId}`, { + translations: { + heading: { + ru: currentArticleData.ru.heading, + en: currentArticleData.en.heading, + zh: currentArticleData.zh.heading, + }, + body: { + ru: currentArticleData.ru.body, + en: currentArticleData.en.body, + zh: currentArticleData.zh.body, + }, + }, + }); + + await loadArticleForEdit(currentArticleId); + + toast.success("Статья успешно сохранена"); + } catch (error) { + console.error("Error saving article:", error); + toast.error("Ошибка при сохранении статьи"); + } finally { + setIsSaving(false); + } + }; + + const handleSelectAndClose = () => { + if (currentArticleId) { + onSelectArticle(currentArticleId); + onClose(); + } + }; + + const handleCreateArticle = async () => { + try { + setIsCreating(true); + + const hasData = Object.values(newArticleData).some( + (langData) => langData.heading.trim() || langData.body.trim() + ); + + if (!hasData) { + toast.error("Заполните хотя бы одно поле для любого языка"); + setIsCreating(false); + return; + } + + const cityId = selectedCityStore.selectedCityId; + const response = await authInstance.post("/article", { + translations: { + heading: { + ru: newArticleData.ru.heading || "Новый заголовок (RU)", + en: newArticleData.en.heading || "New Heading (EN)", + zh: newArticleData.zh.heading || "Новый заголовок (ZH)", + }, + body: { + ru: newArticleData.ru.body || "Новый текст (RU)", + en: newArticleData.en.body || "New Text (EN)", + zh: newArticleData.zh.body || "Новый текст (ZH)", + }, + }, + ...(cityId ? { city_id: cityId } : {}), + }); + + const { id } = response.data; + setTempArticleId(id); + + const ruHeading = newArticleData.ru.heading || "Новый заголовок (RU)"; + const enHeading = newArticleData.en.heading || "New Heading (EN)"; + const zhHeading = newArticleData.zh.heading || "Новый заголовок (ZH)"; + const ruBody = newArticleData.ru.body || "Новый текст (RU)"; + const enBody = newArticleData.en.body || "New Text (EN)"; + const zhBody = newArticleData.zh.body || "Новый текст (ZH)"; + + runInAction(() => { + articlesStore.articleList.ru.data.unshift({ + id, + heading: ruHeading, + body: ruBody, + service_name: ruHeading, + } as any); + articlesStore.articleList.en.data.unshift({ + id, + heading: enHeading, + body: enBody, + service_name: enHeading, + } as any); + articlesStore.articleList.zh.data.unshift({ + id, + heading: zhHeading, + body: zhBody, + service_name: zhHeading, + } as any); + articlesStore.articleList.ru.loaded = true; + articlesStore.articleList.en.loaded = true; + articlesStore.articleList.zh.loaded = true; + if (articlesStore.articles) { + articlesStore.articles.ru.unshift({ + id, + heading: ruHeading, + body: ruBody, + service_name: ruHeading, + } as any); + articlesStore.articles.en.unshift({ + id, + heading: enHeading, + body: enBody, + service_name: enHeading, + } as any); + articlesStore.articles.zh.unshift({ + id, + heading: zhHeading, + body: zhBody, + service_name: zhHeading, + } as any); + } + }); + + if (createdArticleMedia.length > 0) { + try { + for (let i = 0; i < createdArticleMedia.length; i++) { + await authInstance.post(`/article/${id}/media`, { + media_id: createdArticleMedia[i].id, + media_order: i + 1, + }); + } + } catch (error) { + console.error("Error linking media:", error); + toast.warning( + "Статья создана, но не удалось добавить некоторые медиа" + ); + } + } + + const mediaResponse = await authInstance.get(`/article/${id}/media`); + if (mediaResponse.data && mediaResponse.data.length > 0) { + setCreatedArticleMedia( + mediaResponse.data.map((m: any) => ({ + id: m.id, + filename: m.filename, + media_name: m.media_name, + media_type: m.media_type, + })) + ); + } + + await getArticle(id); + await getArticleMedia(id); + + toast.success("Статья успешно создана"); + + onSelectArticle(id); + onClose(); + } catch (error) { + console.error("Error creating article:", error); + toast.error("Ошибка при создании статьи"); + } finally { + setIsCreating(false); + } + }; + + const handleMediaSelect = async (media: { + id: string; + filename: string; + media_name?: string; + media_type: number; + }) => { + if (!currentArticleId) { + if (tabValue === 1) { + setCreatedArticleMedia((prev) => [...prev, media]); + } + setIsSelectMediaDialogOpen(false); + return; + } + + try { + const currentMediaCount = currentMedia.length; + await authInstance.post(`/article/${currentArticleId}/media`, { + media_id: media.id, + media_order: currentMediaCount + 1, + }); + + const mediaResponse = await authInstance.get( + `/article/${currentArticleId}/media` + ); + const mediaList = mediaResponse.data.map((m: any) => ({ + id: m.id, + filename: m.filename, + media_type: m.media_type, + })); + + if (selectedArticleId) { + setEditedArticleMedia(mediaList); + } else { + setCreatedArticleMedia( + mediaResponse.data.map((m: any) => ({ + id: m.id, + filename: m.filename, + media_name: m.media_name, + media_type: m.media_type, + })) + ); + } + } catch (error) { + console.error("Error linking media:", error); + toast.error("Ошибка при добавлении медиа"); + } + setIsSelectMediaDialogOpen(false); + }; + + const handleDeleteMedia = async (mediaId: string) => { + if (!currentArticleId) { + if (tabValue === 1) { + setCreatedArticleMedia((prev) => + prev.filter((m) => m.id !== mediaId) + ); + } + return; + } + + try { + await authInstance.delete( + `/article/${currentArticleId}/media/${mediaId}` + ); + const response = await authInstance.get( + `/article/${currentArticleId}/media` + ); + const mediaList = response.data.map((m: any) => ({ + id: m.id, + filename: m.filename, + media_type: m.media_type, + })); + + if (selectedArticleId) { + setEditedArticleMedia(mediaList); + } else { + setCreatedArticleMedia( + response.data.map((m: any) => ({ + id: m.id, + filename: m.filename, + media_name: m.media_name, + media_type: m.media_type, + })) + ); + } + } catch (error) { + console.error("Error deleting media:", error); + toast.error("Ошибка при удалении медиа"); + } + }; + + const handleMediaFilesDrop = (files: File[]) => { + if (files.length > 0) { + setFileToUpload(files[0]); + setIsUploadMediaDialogOpen(true); + } + }; + + const handleMediaUpload = async (media: { + id: string; + filename: string; + media_name?: string; + media_type: number; + }) => { + if (!currentArticleId) { + await handleMediaSelect(media); + return; + } + + try { + const currentMediaCount = currentMedia.length; + await authInstance.post(`/article/${currentArticleId}/media`, { + media_id: media.id, + media_order: currentMediaCount + 1, + }); + + const mediaResponse = await authInstance.get( + `/article/${currentArticleId}/media` + ); + const mediaList = mediaResponse.data.map((m: any) => ({ + id: m.id, + filename: m.filename, + media_type: m.media_type, + })); + + if (selectedArticleId) { + setEditedArticleMedia(mediaList); + } else { + setCreatedArticleMedia( + mediaResponse.data.map((m: any) => ({ + id: m.id, + filename: m.filename, + media_name: m.media_name, + media_type: m.media_type, + })) + ); + } + } catch (error) { + console.error("Error linking media:", error); + toast.error("Ошибка при добавлении медиа"); + } + setIsUploadMediaDialogOpen(false); + setFileToUpload(null); + }; + + const handleBack = () => { + if (selectedArticleId) { + setSelectedArticleId(null); + setEditedArticleData({ + ru: { heading: "", body: "" }, + en: { heading: "", body: "" }, + zh: { heading: "", body: "" }, + }); + setEditedArticleMedia([]); + } else if (tempArticleId) { + setTempArticleId(null); + setNewArticleData({ + ru: { heading: "", body: "" }, + en: { heading: "", body: "" }, + zh: { heading: "", body: "" }, + }); + setCreatedArticleMedia([]); + setTabValue(0); + } else if (tabValue === 1) { + setTabValue(0); + setNewArticleData({ + ru: { heading: "", body: "" }, + en: { heading: "", body: "" }, + zh: { heading: "", body: "" }, + }); + setCreatedArticleMedia([]); + } + setModalLanguage("ru"); + languageStore.setLanguage("ru"); + }; + + const cityId = selectedCityStore.selectedCityId; + const filteredArticles = articles[modalLanguage].filter((article) => { + if (!article?.service_name?.toLowerCase().includes(searchQuery.toLowerCase())) return false; + if (cityId && article.city_id !== cityId) return false; + return true; + }); + + const [isPreviewLoading, setIsPreviewLoading] = useState(false); + const [queuedPreviewId, setQueuedPreviewId] = useState(null); + const clickTimerRef = (typeof window !== "undefined" + ? (window as any) + : {}) as { + current?: any; + } as React.MutableRefObject; + if (clickTimerRef.current === undefined) { + (clickTimerRef as any).current = null; + } + + const runPreviewFetch = async (articleId: number) => { + if (isPreviewLoading) { + setQueuedPreviewId(articleId); + return; + } + setIsPreviewLoading(true); + try { + await Promise.all([ + getArticle(articleId, modalLanguage), + getArticleMedia(articleId), + ]); + } finally { + setIsPreviewLoading(false); + if (queuedPreviewId && queuedPreviewId !== articleId) { + const nextId = queuedPreviewId; + setQueuedPreviewId(null); + + runPreviewFetch(nextId); + } else { + setQueuedPreviewId(null); + } + } + }; + + const handleListItemClick = (articleId: number) => { + if (clickTimerRef.current) clearTimeout(clickTimerRef.current); + clickTimerRef.current = setTimeout(() => { + if (tabValue === 0 && !selectedArticleId && !tempArticleId) { + runPreviewFetch(articleId); + } + }, 200); + }; + + const handleListItemDoubleClick = (articleId: number) => { + if (clickTimerRef.current) { + clearTimeout(clickTimerRef.current); + (clickTimerRef as any).current = null; + } + handleArticleSelect(articleId); + }; + + const previewData = { + heading: currentArticleData[modalLanguage].heading || "", + body: currentArticleData[modalLanguage].body || "", + }; + + const previewMedia = + currentMedia.length > 0 + ? { + id: currentMedia[0].id, + media_type: currentMedia[0].media_type, + filename: currentMedia[0].filename, + } + : null; + + const selectionPreviewHeading = + (articlesStore.articleData as any)?.[modalLanguage]?.heading || + (articlesStore.articleData as any)?.heading || + ""; + const selectionPreviewBody = + (articlesStore.articleData as any)?.[modalLanguage]?.body || + (articlesStore.articleData as any)?.body || + ""; + + return ( + + + {tabValue === 0 && !isEditMode + ? "Выберите существующую статью" + : selectedArticleId + ? "Редактирование статьи" + : "Создать новую статью"} + + + {tabValue === 0 && !isEditMode ? ( + <> + + setTabValue(newValue)} + > + + + + + + + + + setSearchQuery(e.target.value)} + sx={{ mb: 2 }} + InputProps={{ + startAdornment: ( + + + + ), + }} + /> + + {filteredArticles.length === 0 ? ( + + + {searchQuery + ? "Статьи не найдены" + : "Нет доступных статей"} + + + ) : ( + filteredArticles.map((article) => ( + handleListItemClick(article.id)} + onDoubleClick={() => + handleListItemDoubleClick(article.id) + } + sx={{ + borderRadius: 1, + mb: 0.5, + "&:hover": { + backgroundColor: "action.hover", + }, + }} + > + + + )) + )} + + + + + + + {tabValue === 0 && !isEditMode ? ( + articlesStore.articleMedia ? ( + + ) : ( + + ) + ) : previewMedia ? ( + + ) : ( + + )} + + + + + {tabValue === 0 && !isEditMode + ? selectionPreviewHeading || "Название информации" + : previewData.heading || "Название информации"} + + + + {(tabValue === 0 && !isEditMode + ? selectionPreviewBody + : previewData.body) && ( + + + + )} + + + + ) : ( + + + + setModalLanguage(newValue)} + > + + + + + + + { + if (selectedArticleId) { + setEditedArticleData({ + ...editedArticleData, + [modalLanguage]: { + ...editedArticleData[modalLanguage], + heading: e.target.value, + }, + }); + } else { + setNewArticleData({ + ...newArticleData, + [modalLanguage]: { + ...newArticleData[modalLanguage], + heading: e.target.value, + }, + }); + } + }} + variant="outlined" + fullWidth + /> + + { + if (selectedArticleId) { + setEditedArticleData({ + ...editedArticleData, + [modalLanguage]: { + ...editedArticleData[modalLanguage], + body: value, + }, + }); + } else { + setNewArticleData({ + ...newArticleData, + [modalLanguage]: { + ...newArticleData[modalLanguage], + body: value, + }, + }); + } + }} + /> + + + handleDeleteMedia(mediaId) + } + setSelectMediaDialogOpen={setIsSelectMediaDialogOpen} + onFilesDrop={handleMediaFilesDrop} + /> + + + + + + {previewMedia ? ( + + ) : ( + + )} + + + + + {previewData.heading || "Название информации"} + + + + {previewData.body && ( + + + + )} + + + + )} + + + {!(tabValue === 0 && !isEditMode) && ( + + )} + + + {tabValue === 1 && !tempArticleId && !selectedArticleId && ( + + )} + {(tempArticleId || selectedArticleId) && ( + <> + {selectedArticleId && ( + + )} + + + )} + + + setIsSelectMediaDialogOpen(false)} + onSelectMedia={handleMediaSelect} + /> + + { + setIsUploadMediaDialogOpen(false); + setFileToUpload(null); + }} + contextObjectName="Статья" + contextType="sight" + isArticle={true} + articleName={currentArticleData[modalLanguage].heading || "Статья"} + initialFile={fileToUpload || undefined} + afterUpload={handleMediaUpload} + /> + + ); + } +); diff --git a/src/shared/modals/PreviewMediaDialog/index.tsx b/src/shared/modals/PreviewMediaDialog/index.tsx index 82fe7c2..85f7ef8 100644 --- a/src/shared/modals/PreviewMediaDialog/index.tsx +++ b/src/shared/modals/PreviewMediaDialog/index.tsx @@ -1,4 +1,8 @@ -import { mediaStore, MEDIA_TYPE_LABELS } from "@shared"; +import { + mediaStore, + MEDIA_TYPE_LABELS, + PreviewBackgroundToggle, +} from "@shared"; import { observer } from "mobx-react-lite"; import { useEffect, useState } from "react"; import { @@ -29,6 +33,8 @@ export const PreviewMediaDialog = observer( const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); const [success, setSuccess] = useState(false); + const [isPreviewBackgroundDark, setIsPreviewBackgroundDark] = + useState(false); const media = mediaId ? mediaStore.media.find((m) => m.id === mediaId) : null; @@ -40,6 +46,7 @@ export const PreviewMediaDialog = observer( if (media) { setMediaName(media.media_name); setMediaFilename(media.filename); + setIsPreviewBackgroundDark(false); } }, [media]); @@ -141,8 +148,19 @@ export const PreviewMediaDialog = observer( display: "flex", alignItems: "center", justifyContent: "center", + position: "relative", + backgroundColor: isPreviewBackgroundDark ? "#000" : "#fff", + transition: "background-color 150ms ease", }} > + {[1, 3, 4, 5].includes(media.media_type) && ( + + setIsPreviewBackgroundDark((value) => !value) + } + /> + )} !linkedArticleIds.includes(article.id) + (article) => { + if (linkedArticleIds.includes(article.id)) return false; + if (searchQuery && !article.service_name?.toLowerCase().includes(searchQuery.toLowerCase())) return false; + if (cityId && article.city_id !== cityId) return false; + return true; + } ); - // .filter((article) => - // article.service_name.toLowerCase().includes(searchQuery.toLowerCase()) - // ); return ( !linkedMediaIds.includes(mediaItem.id)) // Use mediaItem to avoid name collision + .filter((mediaItem) => !linkedMediaIds.includes(mediaItem.id)) .filter((mediaItem) => mediaItem.media_name.toLowerCase().includes(searchQuery.toLowerCase()) - ); + ) + .filter((mediaItem) => { + if (!cityId) return true; + return mediaItem.city_id === cityId; + }); if (mediaType) { filteredMedia = filteredMedia.filter( diff --git a/src/shared/modals/UploadMediaDialog/index.tsx b/src/shared/modals/UploadMediaDialog/index.tsx index fa9efd4..68d1a26 100644 --- a/src/shared/modals/UploadMediaDialog/index.tsx +++ b/src/shared/modals/UploadMediaDialog/index.tsx @@ -4,9 +4,13 @@ import { editSightStore, generateDefaultMediaName, clearBlobAndGLTFCache, + authStore, + snapshotStore, + PreviewBackgroundToggle, } from "@shared"; import { observer } from "mobx-react-lite"; import { useEffect, useState, useRef } from "react"; +import { toast } from "react-toastify"; import { Dialog, DialogTitle, @@ -39,6 +43,8 @@ interface UploadMediaDialogProps { afterUploadSight?: (id: string) => void; hardcodeType?: | "thumbnail" + | "icon" + | "alt_icon" | "watermark_lu" | "watermark_rd" | "image" @@ -51,10 +57,12 @@ interface UploadMediaDialogProps { | "carrier" | "country" | "vehicle" - | "station"; + | "station" + | "route" + | "user"; isArticle?: boolean; articleName?: string; - initialFile?: File; // <--- добавлено + initialFile?: File; } export const UploadMediaDialog = observer( @@ -68,7 +76,7 @@ export const UploadMediaDialog = observer( isArticle, articleName, - initialFile, // <--- добавлено + initialFile, }: UploadMediaDialogProps) => { const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); @@ -83,11 +91,12 @@ export const UploadMediaDialog = observer( [] ); const [isPreviewLoaded, setIsPreviewLoaded] = useState(false); + const [isPreviewBackgroundDark, setIsPreviewBackgroundDark] = + useState(false); const previousMediaUrlRef = useRef(null); useEffect(() => { if (initialFile) { - // Очищаем предыдущий blob URL если он существует if ( previousMediaUrlRef.current && previousMediaUrlRef.current.startsWith("blob:") @@ -97,8 +106,26 @@ export const UploadMediaDialog = observer( setMediaFile(initialFile); setMediaFilename(initialFile.name); - setAvailableMediaTypes([2]); - setMediaType(2); + + const extension = initialFile.name.split(".").pop()?.toLowerCase(); + if (extension) { + if (["glb", "gltf"].includes(extension)) { + setAvailableMediaTypes([6]); + setMediaType(6); + } + if ( + ["jpg", "jpeg", "png", "gif", "svg", "webp", "bmp"].includes( + extension + ) + ) { + setAvailableMediaTypes([1, 3, 4, 5]); + setMediaType(1); + } else if (["mp4", "webm", "mov"].includes(extension)) { + setAvailableMediaTypes([2]); + setMediaType(2); + } + } + const newBlobUrl = URL.createObjectURL(initialFile); setMediaUrl(newBlobUrl); previousMediaUrlRef.current = newBlobUrl; @@ -106,7 +133,6 @@ export const UploadMediaDialog = observer( } }, [initialFile]); - // Очистка blob URL при размонтировании компонента useEffect(() => { return () => { if ( @@ -116,13 +142,13 @@ export const UploadMediaDialog = observer( clearBlobAndGLTFCache(previousMediaUrlRef.current); } }; - }, []); // Пустой массив зависимостей - выполняется только при размонтировании + }, []); useEffect(() => { if (fileToUpload) { setMediaFile(fileToUpload); setMediaFilename(fileToUpload.name); - // Try to determine media type from file extension + const extension = fileToUpload.name.split(".").pop()?.toLowerCase(); if (extension) { if (["glb", "gltf"].includes(extension)) { @@ -134,22 +160,18 @@ export const UploadMediaDialog = observer( extension ) ) { - // Для изображений доступны все типы кроме видео - setAvailableMediaTypes([1, 3, 4, 5]); // Фото, Иконка, Водяной знак, Панорама, 3Д-модель - setMediaType(1); // По умолчанию Фото + setAvailableMediaTypes([1, 3, 4, 5]); + setMediaType(1); } else if (["mp4", "webm", "mov"].includes(extension)) { - // Для видео только тип Видео setAvailableMediaTypes([2]); setMediaType(2); } } - // Генерируем название по умолчанию если есть контекст if (fileToUpload.name) { let defaultName = ""; if (isArticle && articleName && contextObjectName) { - // Для статей: "Название достопримечательности_название файла_название статьи" defaultName = generateDefaultMediaName( contextObjectName, fileToUpload.name, @@ -157,10 +179,9 @@ export const UploadMediaDialog = observer( true ); } else if (contextObjectName && contextObjectName.trim() !== "") { - // Для обычных медиа с названием объекта const currentMediaType = hardcodeType ? MEDIA_TYPE_VALUES[hardcodeType] - : 1; // По умолчанию фото + : 1; defaultName = generateDefaultMediaName( contextObjectName, fileToUpload.name, @@ -168,10 +189,9 @@ export const UploadMediaDialog = observer( false ); } else { - // Для медиа без названия объекта const currentMediaType = hardcodeType ? MEDIA_TYPE_VALUES[hardcodeType] - : 1; // По умолчанию фото + : 1; defaultName = generateDefaultMediaName( "", fileToUpload.name, @@ -185,13 +205,11 @@ export const UploadMediaDialog = observer( } }, [fileToUpload, contextObjectName, hardcodeType, isArticle, articleName]); - // Обновляем название при изменении типа медиа useEffect(() => { if (mediaFilename && mediaType > 0) { let defaultName = ""; if (isArticle && articleName && contextObjectName) { - // Для статей: "Название достопримечательности_название файла_название статьи" defaultName = generateDefaultMediaName( contextObjectName, mediaFilename, @@ -199,7 +217,6 @@ export const UploadMediaDialog = observer( true ); } else if (contextObjectName && contextObjectName.trim() !== "") { - // Для обычных медиа с названием объекта const currentMediaType = hardcodeType ? MEDIA_TYPE_VALUES[hardcodeType] : mediaType; @@ -210,7 +227,6 @@ export const UploadMediaDialog = observer( false ); } else { - // Для медиа без названия объекта const currentMediaType = hardcodeType ? MEDIA_TYPE_VALUES[hardcodeType] : mediaType; @@ -235,7 +251,6 @@ export const UploadMediaDialog = observer( useEffect(() => { if (mediaFile) { - // Очищаем предыдущий blob URL и кеш GLTF если он существует if ( previousMediaUrlRef.current && previousMediaUrlRef.current.startsWith("blob:") @@ -245,22 +260,10 @@ export const UploadMediaDialog = observer( const newBlobUrl = URL.createObjectURL(mediaFile as Blob); setMediaUrl(newBlobUrl); - previousMediaUrlRef.current = newBlobUrl; // Сохраняем новый URL в ref - setIsPreviewLoaded(false); // Сбрасываем состояние загрузки при смене файла + previousMediaUrlRef.current = newBlobUrl; + setIsPreviewLoaded(false); } - }, [mediaFile]); // Убираем mediaUrl из зависимостей чтобы избежать зацикливания - - // const fileFormat = useEffect(() => { - // const handleKeyPress = (event: KeyboardEvent) => { - // if (event.key.toLowerCase() === "enter" && !event.ctrlKey) { - // event.preventDefault(); - // onClose(); - // } - // }; - - // window.addEventListener("keydown", handleKeyPress); - // return () => window.removeEventListener("keydown", handleKeyPress); - // }, [onClose]); + }, [mediaFile]); const handleSave = async () => { if (!mediaFile) return; @@ -268,12 +271,16 @@ export const UploadMediaDialog = observer( setIsLoading(true); setError(null); + const uploadStartTime = Date.now(); + try { + const effectiveMediaType = hardcodeType + ? (MEDIA_TYPE_VALUES[hardcodeType] as number) + : mediaType; + const media = await uploadMedia( mediaFilename, - hardcodeType - ? (MEDIA_TYPE_VALUES[hardcodeType] as number) - : mediaType, + effectiveMediaType, mediaFile, mediaName ); @@ -284,11 +291,45 @@ export const UploadMediaDialog = observer( await afterUpload(media); } } + + if (effectiveMediaType === 2) { + const uploadDurationSec = Math.round((Date.now() - uploadStartTime) / 1000); + const minutes = Math.floor(uploadDurationSec / 60); + const seconds = uploadDurationSec % 60; + const durationStr = minutes > 0 + ? `${minutes} мин ${seconds} сек` + : `${seconds} сек`; + + const fileSizeMb = mediaFile.size / (1024 * 1024); + const fileSizeStr = fileSizeMb >= 1024 + ? `${(fileSizeMb / 1024).toFixed(2)} ГБ` + : `${fileSizeMb.toFixed(1)} МБ`; + + if (authStore.canRead("snapshots")) { + try { + await snapshotStore.getStorageInfo(); + const storage = snapshotStore.storageInfo; + if (storage) { + toast.success( + `Видео (${fileSizeStr}) загружено за ${durationStr}. Свободно на диске: ${storage.available_disk_space_gb.toFixed(2)} ГБ из ${storage.total_disk_space_gb.toFixed(2)} ГБ`, + { autoClose: 8000 } + ); + } else { + toast.success(`Видео (${fileSizeStr}) загружено за ${durationStr}`, { autoClose: 6000 }); + } + } catch { + toast.success(`Видео (${fileSizeStr}) загружено за ${durationStr}`, { autoClose: 6000 }); + } + } else { + toast.success(`Видео (${fileSizeStr}) загружено за ${durationStr}`, { autoClose: 6000 }); + } + } + setSuccess(true); - // Закрываем модальное окно после успешного сохранения + setTimeout(() => { handleClose(); - }, 1000); // Небольшая задержка, чтобы пользователь увидел сообщение об успехе + }, 1000); } catch (err) { setError(err instanceof Error ? err.message : "Failed to save media"); } finally { @@ -297,7 +338,6 @@ export const UploadMediaDialog = observer( }; const handleClose = () => { - // Очищаем blob URL и кеш GLTF при закрытии диалога if ( previousMediaUrlRef.current && previousMediaUrlRef.current.startsWith("blob:") @@ -310,7 +350,8 @@ export const UploadMediaDialog = observer( setMediaUrl(null); setMediaFile(null); setIsPreviewLoaded(false); - previousMediaUrlRef.current = null; // Очищаем ref + setIsPreviewBackgroundDark(false); + previousMediaUrlRef.current = null; onClose(); }; @@ -379,8 +420,18 @@ export const UploadMediaDialog = observer( justifyContent: "center", height: "100%", position: "relative", + backgroundColor: isPreviewBackgroundDark ? "#000" : "#fff", + transition: "background-color 150ms ease", }} > + {mediaUrl && mediaType !== 2 && mediaType !== 6 && ( + + setIsPreviewBackgroundDark((value) => !value) + } + /> + )} {!isPreviewLoaded && mediaUrl && ( => { + const response = await languageInstance("ru").get("/auth/me"); + return response.data as User; +}; + +export const getMeCitiesApi = async (): Promise<{ + ru: UserCity[]; + en: UserCity[]; + zh: UserCity[]; +}> => { + const [ru, en, zh] = await Promise.all([ + languageInstance("ru").get("/auth/me"), + languageInstance("en").get("/auth/me"), + languageInstance("zh").get("/auth/me"), + ]); + + return { + ru: ((ru.data as User).cities ?? []), + en: ((en.data as User).cities ?? []), + zh: ((zh.data as User).cities ?? []), + }; +}; diff --git a/src/shared/store/AuthStore/index.tsx b/src/shared/store/AuthStore/index.tsx index 21cbd0f..9078e3a 100644 --- a/src/shared/store/AuthStore/index.tsx +++ b/src/shared/store/AuthStore/index.tsx @@ -1,15 +1,13 @@ -import { API_URL, decodeJWT } from "@shared"; +import { API_URL, decodeJWT, mobxFetch } from "@shared"; +import { canRead as checkCanRead, canWrite as checkCanWrite } from "../../lib/permissions"; import { makeAutoObservable, runInAction } from "mobx"; import axios, { AxiosError } from "axios"; +import { User, UserCity } from "../UserStore"; +import { getMeApi, getMeCitiesApi } from "./api"; type LoginResponse = { token: string; - user: { - id: number; - name: string; - email: string; - is_admin: boolean; - }; + user: Pick; }; class AuthStore { @@ -48,7 +46,7 @@ class AuthStore { { email, password, - } + }, ); const data = response.data; @@ -89,6 +87,76 @@ class AuthStore { get user() { return this.payload?.user; } + + get isAdmin(): boolean { + return ( + this.me?.is_admin === true || + (this.me?.roles ?? []).includes("admin") + ); + } + + me: User | null = null; + meLoading = false; + meError: string | null = null; + + meCities: { ru: UserCity[]; en: UserCity[]; zh: UserCity[] } = { + ru: [], + en: [], + zh: [], + }; + + getMeAction = mobxFetch({ + store: this, + value: "me", + loading: "meLoading", + error: "meError", + fn: getMeApi, + onSuccess: () => { + this.fetchMeCities(); + }, + }); + + fetchMeCities = async () => { + const cities = await getMeCitiesApi(); + runInAction(() => { + this.meCities = cities; + }); + }; + + canWrite = (resource: string): boolean => { + const roles = this.me?.roles ?? []; + + if (roles.includes("admin")) { + return true; + } + + if (resource === "map") { + return roles.some((role) => + ["routes_rw", "stations_rw", "sights_rw"].includes(role), + ); + } + + return checkCanWrite(roles, resource); + }; + + hasRole = (role: string): boolean => { + const roles = this.me?.roles ?? []; + return roles.includes("admin") || roles.includes(role); + }; + + canRead = (resource: string): boolean => { + if (resource === "map") { + return this.canWrite("map"); + } + return checkCanRead(this.me?.roles, resource); + }; + + canAccess = (permission: string): boolean => { + if (permission.includes("_")) { + return this.hasRole(permission); + } + return this.canRead(permission); + }; } export const authStore = new AuthStore(); diff --git a/src/shared/store/CarrierStore/index.tsx b/src/shared/store/CarrierStore/index.tsx index 273a3c4..54964cd 100644 --- a/src/shared/store/CarrierStore/index.tsx +++ b/src/shared/store/CarrierStore/index.tsx @@ -1,5 +1,6 @@ import { authInstance, + authStore, cityStore, languageStore, languageInstance, @@ -11,13 +12,13 @@ export type Carrier = { id: number; short_name: string; full_name: string; - slogan: string; city: string; city_id: number; - logo: string; - // main_color: string; - // left_color: string; - // right_color: string; + preview_media?: string; + video_preview?: string; + main_color: string; + left_color: string; + right_color: string; }; type CarrierData = { @@ -110,57 +111,111 @@ class CarrierStore { createCarrierData = { city_id: 0, - logo: "", + preview_media: "", + video_preview: "", + main_color: "", + left_color: "", + right_color: "", ru: { full_name: "", short_name: "", - slogan: "", }, en: { full_name: "", short_name: "", - slogan: "", }, zh: { full_name: "", short_name: "", - slogan: "", }, }; + setCreateCarrierMedia = ( + field: "preview_media" | "video_preview", + mediaId: string + ) => { + this.createCarrierData[field] = mediaId; + }; + setCreateCarrierData = ( fullName: string, shortName: string, cityId: number, - slogan: string, - logoId: string, - language: Language + language: Language, + colors?: { main_color?: string; left_color?: string; right_color?: string } ) => { this.createCarrierData.city_id = cityId; - this.createCarrierData.logo = logoId; + if (colors) { + if (colors.main_color !== undefined) this.createCarrierData.main_color = colors.main_color; + if (colors.left_color !== undefined) this.createCarrierData.left_color = colors.left_color; + if (colors.right_color !== undefined) this.createCarrierData.right_color = colors.right_color; + } this.createCarrierData[language] = { full_name: fullName, short_name: shortName, - slogan: slogan, }; }; + private resolveCityName = (cityId: number, preferredLanguage: Language) => { + if (!cityId) { + return ""; + } + + const languages: Language[] = ["ru", "en", "zh"]; + + const fromCityStorePreferred = cityStore.cities[preferredLanguage].data.find( + (city) => city.id === cityId + )?.name; + if (fromCityStorePreferred) { + return fromCityStorePreferred; + } + + for (const language of languages) { + const cityName = cityStore.cities[language].data.find( + (city) => city.id === cityId + )?.name; + if (cityName) { + return cityName; + } + } + + const fromMePreferred = authStore.meCities[preferredLanguage].find( + (city) => city.city_id === cityId + )?.name; + if (fromMePreferred) { + return fromMePreferred; + } + + for (const language of languages) { + const cityName = authStore.meCities[language].find( + (city) => city.city_id === cityId + )?.name; + if (cityName) { + return cityName; + } + } + + return ""; + }; + createCarrier = async () => { const { language } = languageStore; - const cityName = - cityStore.cities[language].data.find( - (city) => city.id === this.createCarrierData.city_id - )?.name || ""; + const cityName = this.resolveCityName(this.createCarrierData.city_id, language); const payload = { - full_name: this.createCarrierData[language].full_name, - short_name: this.createCarrierData[language].short_name, + full_name: (this.createCarrierData[language].full_name || "").trim(), + short_name: (this.createCarrierData[language].short_name || "").trim(), city: cityName, city_id: this.createCarrierData.city_id, - slogan: this.createCarrierData[language].slogan, - ...(this.createCarrierData.logo - ? { logo: this.createCarrierData.logo } + ...(this.createCarrierData.preview_media + ? { preview_media: this.createCarrierData.preview_media } : {}), + ...(this.createCarrierData.video_preview + ? { video_preview: this.createCarrierData.video_preview } + : {}), + ...(this.createCarrierData.main_color ? { main_color: this.createCarrierData.main_color } : {}), + ...(this.createCarrierData.left_color ? { left_color: this.createCarrierData.left_color } : {}), + ...(this.createCarrierData.right_color ? { right_color: this.createCarrierData.right_color } : {}), }; const response = await languageInstance(language).post("/carrier", payload); @@ -171,49 +226,55 @@ class CarrierStore { this.carriers[language].data.push(response.data); }); - // Create translations for other languages - for (const lang of ["ru", "en", "zh"].filter((l) => l !== language)) { + for (const lang of (["ru", "en", "zh"] as const).filter( + (item) => item !== language + )) { + const cityNameForLang = this.resolveCityName( + this.createCarrierData.city_id, + lang + ); + const localizedData = this.createCarrierData[lang]; const patchPayload = { - // @ts-ignore - full_name: this.createCarrierData[lang as any].full_name as string, - // @ts-ignore - short_name: this.createCarrierData[lang as any].short_name as string, - city: cityName, + full_name: (localizedData.full_name || "").trim(), + short_name: (localizedData.short_name || "").trim(), + city: cityNameForLang || cityName, city_id: this.createCarrierData.city_id, - // @ts-ignore - slogan: this.createCarrierData[lang as any].slogan as string, - ...(this.createCarrierData.logo - ? { logo: this.createCarrierData.logo } + ...(this.createCarrierData.preview_media + ? { preview_media: this.createCarrierData.preview_media } + : {}), + ...(this.createCarrierData.video_preview + ? { video_preview: this.createCarrierData.video_preview } : {}), }; - const response = await languageInstance(lang as Language).patch( + const response = await languageInstance(lang).patch( `/carrier/${carrierId}`, patchPayload ); runInAction(() => { - this.carriers[lang as keyof Carriers].data.push(response.data); + this.carriers[lang].data.push(response.data); }); } this.createCarrierData = { city_id: 0, - logo: "", + preview_media: "", + video_preview: "", + main_color: "", + left_color: "", + right_color: "", ru: { full_name: "", short_name: "", - slogan: "", }, en: { full_name: "", short_name: "", - slogan: "", }, zh: { full_name: "", short_name: "", - slogan: "", }, }; }; @@ -222,72 +283,63 @@ class CarrierStore { ru: { full_name: "", short_name: "", - - // main_color: "", - // left_color: "", - // right_color: "", - slogan: "", }, en: { full_name: "", short_name: "", - - // main_color: "", - // left_color: "", - // right_color: "", - slogan: "", }, - city_id: 0, - logo: "", zh: { full_name: "", short_name: "", - - // main_color: "", - // left_color: "", - // right_color: "", - slogan: "", }, + city_id: 0, + preview_media: "", + video_preview: "", + main_color: "", + left_color: "", + right_color: "", + }; + + setEditCarrierMedia = ( + field: "preview_media" | "video_preview", + mediaId: string + ) => { + this.editCarrierData[field] = mediaId; }; setEditCarrierData = ( fullName: string, shortName: string, cityId: number, - // main_color: string, - // left_color: string, - // right_color: string, - slogan: string, - logoId: string, - language: Language + language: Language, + colors?: { main_color?: string; left_color?: string; right_color?: string } ) => { this.editCarrierData.city_id = cityId; - this.editCarrierData.logo = logoId; + if (colors) { + if (colors.main_color !== undefined) this.editCarrierData.main_color = colors.main_color; + if (colors.left_color !== undefined) this.editCarrierData.left_color = colors.left_color; + if (colors.right_color !== undefined) this.editCarrierData.right_color = colors.right_color; + } this.editCarrierData[language] = { full_name: fullName, short_name: shortName, - // main_color: main_color, - // left_color: left_color, - // right_color: right_color, - slogan: slogan, }; }; editCarrier = async (id: number) => { - const { language } = languageStore; - const cityName = - cityStore.cities[language].data.find( - (city) => city.id === this.editCarrierData.city_id - )?.name || ""; - for (const lang of ["ru", "en", "zh"] as const) { + const cityName = this.resolveCityName(this.editCarrierData.city_id, lang); const response = await languageInstance(lang).patch(`/carrier/${id}`, { ...this.editCarrierData[lang], + full_name: (this.editCarrierData[lang].full_name || "").trim(), + short_name: (this.editCarrierData[lang].short_name || "").trim(), city: cityName, city_id: this.editCarrierData.city_id, - ...(this.editCarrierData.logo - ? { logo: this.editCarrierData.logo } - : {}), + preview_media: this.editCarrierData.preview_media || null, + video_preview: this.editCarrierData.video_preview || null, + ...(this.editCarrierData.main_color ? { main_color: this.editCarrierData.main_color } : {}), + ...(this.editCarrierData.left_color ? { left_color: this.editCarrierData.left_color } : {}), + ...(this.editCarrierData.right_color ? { right_color: this.editCarrierData.right_color } : {}), }); runInAction(() => { diff --git a/src/shared/store/CityStore/index.ts b/src/shared/store/CityStore/index.ts index 6cbcce6..d5d2aa2 100644 --- a/src/shared/store/CityStore/index.ts +++ b/src/shared/store/CityStore/index.ts @@ -14,6 +14,7 @@ export type City = { country: string; country_code: string; arms: string; + weather_city_code?: number; }; export type CashedCities = { @@ -132,6 +133,7 @@ class CityStore { createCityData = { country_code: "", arms: "", + weather_city_code: 0, ru: { name: "", }, @@ -159,9 +161,13 @@ class CityStore { }; }; + setCreateCityWeatherCode = (weather_city_code: number) => { + this.createCityData = { ...this.createCityData, weather_city_code }; + }; + async createCity() { const language = languageStore.language as Language; - const { country_code, arms } = this.createCityData; + const { country_code, arms, weather_city_code } = this.createCityData; const { name } = this.createCityData[language]; if (!name || !country_code) { @@ -171,13 +177,14 @@ class CityStore { try { // Create city in primary language const cityPayload = { - name, + name: name.trim(), country: countryStore.countries[language as keyof CashedCountries]?.data.find( (c) => c.code === country_code )?.name || "", country_code, ...(arms ? { arms } : {}), + weather_city_code: weather_city_code ?? 0, }; const cityResponse = await languageInstance(language).post( @@ -200,7 +207,7 @@ class CityStore { )?.name || ""; const patchPayload = { - name: secondaryName || "", + name: (secondaryName || "").trim(), country: countryName, country_code: country_code || "", ...(arms ? { arms } : {}), @@ -232,6 +239,7 @@ class CityStore { this.createCityData = { country_code: "", arms: "", + weather_city_code: 0, ru: { name: "" }, en: { name: "" }, zh: { name: "" }, @@ -246,6 +254,7 @@ class CityStore { editCityData = { country_code: "", arms: "", + weather_city_code: 0, ru: { name: "", }, @@ -267,16 +276,19 @@ class CityStore { ...this.editCityData, country_code: country_code, arms: arms, - [language]: { name: name, }, }; }; + setEditCityWeatherCode = (weather_city_code: number) => { + this.editCityData = { ...this.editCityData, weather_city_code }; + }; + editCity = async (code: string) => { for (const language of ["ru", "en", "zh"]) { - const { country_code, arms } = this.editCityData; + const { country_code, arms, weather_city_code } = this.editCityData; const { name } = this.editCityData[language as keyof CashedCities]; const { countries } = countryStore; @@ -285,10 +297,11 @@ class CityStore { ); await languageInstance(language as Language).patch(`/city/${code}`, { - name, + name: (name || "").trim(), country: country?.name || "", country_code: country_code, arms, + weather_city_code: weather_city_code ?? 0, }); runInAction(() => { diff --git a/src/shared/store/CountryStore/index.ts b/src/shared/store/CountryStore/index.ts index f130949..2453e23 100644 --- a/src/shared/store/CountryStore/index.ts +++ b/src/shared/store/CountryStore/index.ts @@ -136,7 +136,7 @@ class CountryStore { if (code && this.createCountryData[language].name) { await languageInstance(language as Language).post("/country", { code: code, - name: name, + name: name.trim(), }); runInAction(() => { @@ -156,7 +156,7 @@ class CountryStore { await languageInstance(secondaryLanguage as Language).patch( `/country/${code}`, { - name: name, + name: name.trim(), } ); } @@ -212,7 +212,7 @@ class CountryStore { if (name) { await languageInstance(language as Language).patch(`/country/${code}`, { - name: name, + name: name.trim(), }); runInAction(() => { diff --git a/src/shared/store/CreateSightStore/index.tsx b/src/shared/store/CreateSightStore/index.tsx index 9b67b6a..144c3c5 100644 --- a/src/shared/store/CreateSightStore/index.tsx +++ b/src/shared/store/CreateSightStore/index.tsx @@ -1,10 +1,10 @@ -// @shared/stores/createSightStore.ts import { articlesStore, Language, authInstance, languageInstance, mediaStore, + selectedCityStore, } from "@shared"; import { makeAutoObservable, runInAction } from "mobx"; @@ -17,6 +17,7 @@ type MediaItem = { type SightLanguageInfo = { name: string; + short_name: string; address: string; left: { heading: string; @@ -27,21 +28,22 @@ type SightLanguageInfo = { }; type SightCommonInfo = { - // id: number; // ID is 0 until created city_id: number; city: string; latitude: number; longitude: number; + is_default_icon: boolean; thumbnail: string | null; + icon: string | null; + alt_icon: string | null; watermark_lu: string | null; watermark_rd: string | null; - left_article: number; // Can be 0 or a real ID, or placeholder like 10000000 + left_article: number; preview_media: string | null; video_preview: string | null; + preview_font_size?: number; }; -// SightBaseInfo combines common info with language-specific info -// The 'id' for the sight itself will be assigned upon creation by the backend. type SightBaseInfo = SightCommonInfo & { [key in Language]: SightLanguageInfo; }; @@ -51,7 +53,10 @@ const initialSightState: SightBaseInfo = { city: "", latitude: 0, longitude: 0, + is_default_icon: false, thumbnail: null, + icon: null, + alt_icon: null, watermark_lu: null, watermark_rd: null, left_article: 0, @@ -59,18 +64,21 @@ const initialSightState: SightBaseInfo = { video_preview: null, ru: { name: "", + short_name: "", address: "", left: { heading: "", body: "", media: [] }, right: [], }, en: { name: "", + short_name: "", address: "", left: { heading: "", body: "", media: [] }, right: [], }, zh: { name: "", + short_name: "", address: "", left: { heading: "", body: "", media: [] }, right: [], @@ -78,7 +86,7 @@ const initialSightState: SightBaseInfo = { }; class CreateSightStore { - sight: SightBaseInfo = JSON.parse(JSON.stringify(initialSightState)); // Deep copy for reset + sight: SightBaseInfo = JSON.parse(JSON.stringify(initialSightState)); uploadMediaOpen = false; setUploadMediaOpen = (open: boolean) => { @@ -93,9 +101,7 @@ class CreateSightStore { makeAutoObservable(this); } - // --- Right Article Management --- createNewRightArticle = async () => { - // Create article in DB for all languages const articleRuData = { heading: "Новый заголовок (RU)", body: "Новый текст (RU)", @@ -124,8 +130,9 @@ class CreateSightStore { zh: articleZhData.body, }, }, + ...(selectedCityStore.selectedCityId ? { city_id: selectedCityStore.selectedCityId } : {}), }); - const { id } = articleRes.data; // New article's ID + const { id } = articleRes.data; runInAction(() => { const newArticleEntry = { id, media: [] }; @@ -133,7 +140,7 @@ class CreateSightStore { this.sight.en.right.push({ ...newArticleEntry, ...articleEnData }); this.sight.zh.right.push({ ...newArticleEntry, ...articleZhData }); }); - return id; // Return ID for potential immediate use + return id; } catch (error) { console.error("Error creating new right article:", error); throw error; @@ -169,7 +176,7 @@ class CreateSightStore { }); }); - return articleId; // Return the linked article ID + return articleId; } catch (error) { console.error("Error linking existing right article:", error); throw error; @@ -180,7 +187,7 @@ class CreateSightStore { index: number, language: Language, heading: string, - body: string + body: string, ) => { if (this.sight[language].right[index]) { this.sight[language].right[index].heading = heading; @@ -188,38 +195,32 @@ class CreateSightStore { } }; - // "Unlink" in create mode means just removing from the list to be created with the sight unlinkRightAritcle = (articleId: number) => { - // Changed from 'unlinkRightAritcle' spelling runInAction(() => { this.sight.ru.right = this.sight.ru.right.filter( - (article) => article.id !== articleId + (article) => article.id !== articleId, ); this.sight.en.right = this.sight.en.right.filter( - (article) => article.id !== articleId + (article) => article.id !== articleId, ); this.sight.zh.right = this.sight.zh.right.filter( - (article) => article.id !== articleId + (article) => article.id !== articleId, ); }); - // Note: If this article was created via createNewRightArticle, it still exists in the DB. - // Consider if an orphaned article should be deleted here or managed separately. - // For now, it just removes it from the list associated with *this specific sight creation process*. }; deleteRightArticle = async (articleId: number) => { try { - await authInstance.delete(`/article/${articleId}`); // Delete from backend + await authInstance.delete(`/article/${articleId}`); runInAction(() => { - // Remove from local store for all languages this.sight.ru.right = this.sight.ru.right.filter( - (article) => article.id !== articleId + (article) => article.id !== articleId, ); this.sight.en.right = this.sight.en.right.filter( - (article) => article.id !== articleId + (article) => article.id !== articleId, ); this.sight.zh.right = this.sight.zh.right.filter( - (article) => article.id !== articleId + (article) => article.id !== articleId, ); }); } catch (error) { @@ -228,21 +229,20 @@ class CreateSightStore { } }; - // --- Right Article Media Management --- createLinkWithRightArticle = async (media: MediaItem, articleId: number) => { try { await authInstance.post(`/article/${articleId}/media`, { media_id: media.id, - media_order: 1, // Or calculate based on existing media.length + 1 + media_order: 1, }); runInAction(() => { (["ru", "en", "zh"] as Language[]).forEach((lang) => { const article = this.sight[lang].right.find( - (a) => a.id === articleId + (a) => a.id === articleId, ); if (article) { if (!article.media) article.media = []; - article.media.unshift(media); // Add to the beginning + article.media.unshift(media); } }); }); @@ -260,7 +260,7 @@ class CreateSightStore { runInAction(() => { (["ru", "en", "zh"] as Language[]).forEach((lang) => { const article = this.sight[lang].right.find( - (a) => a.id === articleId + (a) => a.id === articleId, ); if (article && article.media) { article.media = article.media.filter((m) => m.id !== mediaId); @@ -273,7 +273,6 @@ class CreateSightStore { } }; - // --- Left Article Management (largely unchanged from your provided store) --- updateLeftInfo = (language: Language, heading: string, body: string) => { this.sight[language].left.heading = heading; this.sight[language].left.body = body; @@ -323,16 +322,16 @@ class CreateSightStore { deleteLeftArticle = async (articleId: number) => { /* ... your existing logic ... */ await authInstance.delete(`/article/${articleId}`); - // articlesStore.getArticles(languageStore.language); // If still neede + runInAction(() => { articlesStore.articles.ru = articlesStore.articles.ru.filter( - (article) => article.id !== articleId + (article) => article.id !== articleId, ); articlesStore.articles.en = articlesStore.articles.en.filter( - (article) => article.id !== articleId + (article) => article.id !== articleId, ); articlesStore.articles.zh = articlesStore.articles.zh.filter( - (article) => article.id !== articleId + (article) => article.id !== articleId, ); }); this.unlinkLeftArticle(); @@ -340,63 +339,70 @@ class CreateSightStore { createLeftArticle = async () => { /* ... your existing logic to create a new left article (placeholder or DB) ... */ + const ruName = (this.sight.ru.name || "").trim(); + const enName = (this.sight.en.name || "").trim(); + const zhName = (this.sight.zh.name || "").trim(); + + const hasAnyName = !!(ruName || enName || zhName); + const response = await languageInstance("ru").post("/article", { - heading: "Новая левая статья", - body: "Заполните контентом", + heading: hasAnyName ? ruName : "", + body: "", + ...(selectedCityStore.selectedCityId ? { city_id: selectedCityStore.selectedCityId } : {}), }); const newLeftArticleId = response.data.id; + await languageInstance("en").patch(`/article/${newLeftArticleId}`, { - heading: "New Left Article", - body: "Fill with content", + heading: hasAnyName ? enName : "", + body: "", }); await languageInstance("zh").patch(`/article/${newLeftArticleId}`, { - heading: "新的左侧文章", - body: "填写内容", + heading: hasAnyName ? zhName : "", + body: "", }); runInAction(() => { - this.sight.left_article = newLeftArticleId; // Store the actual ID + this.sight.left_article = newLeftArticleId; this.sight.ru.left = { - heading: "Новая левая статья", - body: "Заполните контентом", + heading: hasAnyName ? ruName : "", + body: "", media: [], }; this.sight.en.left = { - heading: "New Left Article", - body: "Fill with content", + heading: hasAnyName ? enName : "", + body: "", media: [], }; this.sight.zh.left = { - heading: "新的左侧文章", - body: "填写内容", + heading: hasAnyName ? zhName : "", + body: "", media: [], }; articlesStore.articles.ru.push({ id: newLeftArticleId, - heading: "Новая левая статья", - body: "Заполните контентом", - service_name: "Новая левая статья", + heading: hasAnyName ? ruName : "", + body: "", + service_name: hasAnyName ? ruName : "", }); articlesStore.articles.en.push({ id: newLeftArticleId, - heading: "New Left Article", - body: "Fill with content", - service_name: "New Left Article", + heading: hasAnyName ? enName : "", + body: "", + service_name: hasAnyName ? enName : "", }); articlesStore.articles.zh.push({ id: newLeftArticleId, - heading: "新的左侧文章", - body: "填写内容", - service_name: "新的左侧文章", + heading: hasAnyName ? zhName : "", + body: "", + service_name: hasAnyName ? zhName : "", }); }); return newLeftArticleId; }; - // Placeholder for a "new" unsaved left article setNewLeftArticlePlaceholder = () => { - this.sight.left_article = 10000000; // Special placeholder ID + this.sight.left_article = 10000000; this.sight.ru.left = { heading: "Новая левая статья", body: "Заполните контентом", @@ -414,7 +420,6 @@ class CreateSightStore { }; }; - // --- Sight Preview Media --- linkPreviewMedia = (mediaId: string) => { this.sight.preview_media = mediaId; }; @@ -423,35 +428,31 @@ class CreateSightStore { this.sight.preview_media = null; }; - // --- General Store Methods --- clearCreateSight = () => { this.needLeaveAgree = false; - this.sight = JSON.parse(JSON.stringify(initialSightState)); // Reset to initial + this.sight = JSON.parse(JSON.stringify(initialSightState)); }; updateSightInfo = ( - content: Partial, // Corrected types - language?: Language + content: Partial, + language?: Language, ) => { this.needLeaveAgree = true; if (language) { this.sight[language] = { ...this.sight[language], ...content }; } else { - // Assuming content here is for SightCommonInfo this.sight = { ...this.sight, ...(content as Partial) }; } }; - // --- Main Sight Creation Logic --- createSight = async (primaryLanguage: Language) => { let finalLeftArticleId = this.sight.left_article; - // 1. Handle Left Article (Create if new, or use existing ID) if (this.sight.left_article === 10000000) { - // Placeholder for new const res = await languageInstance("ru").post("/article", { heading: this.sight.ru.left.heading, body: this.sight.ru.left.body, + ...(selectedCityStore.selectedCityId ? { city_id: selectedCityStore.selectedCityId } : {}), }); finalLeftArticleId = res.data.id; await languageInstance("en").patch(`/article/${finalLeftArticleId}`, { @@ -466,24 +467,20 @@ class CreateSightStore { this.sight.left_article !== 0 && this.sight.left_article !== null ) { - // Existing, ensure it's up-to-date await languageInstance("ru").patch( `/article/${this.sight.left_article}`, - { heading: this.sight.ru.left.heading, body: this.sight.ru.left.body } + { heading: this.sight.ru.left.heading, body: this.sight.ru.left.body }, ); await languageInstance("en").patch( `/article/${this.sight.left_article}`, - { heading: this.sight.en.left.heading, body: this.sight.en.left.body } + { heading: this.sight.en.left.heading, body: this.sight.en.left.body }, ); await languageInstance("zh").patch( `/article/${this.sight.left_article}`, - { heading: this.sight.zh.left.heading, body: this.sight.zh.left.body } + { heading: this.sight.zh.left.heading, body: this.sight.zh.left.body }, ); } - // else: left_article is 0, so no left article - // 2. Right articles are already created in DB and their IDs are in this.sight[lang].right. - // We just need to update their content if changed before saving the sight. for (const lang of ["ru", "en", "zh"] as Language[]) { for (const article of this.sight[lang].right) { if (article.id == 0 || article.id == null) { @@ -493,38 +490,40 @@ class CreateSightStore { heading: article.heading, body: article.body, }); - // Media for these articles are already linked via createLinkWithRightArticle } } const rightArticleIdsForLink = this.sight[primaryLanguage].right.map( - (a) => a.id + (a) => a.id, ); - // 3. Create Sight object in DB const sightPayload = { city_id: this.sight.city_id, city: this.sight.city, latitude: this.sight.latitude, longitude: this.sight.longitude, - name: this.sight[primaryLanguage].name, + is_default_icon: this.sight.is_default_icon, + name: (this.sight[primaryLanguage].name || "").trim(), + short_name: (this.sight[primaryLanguage].short_name || "").trim(), address: this.sight[primaryLanguage].address, thumbnail: this.sight.thumbnail, + icon: this.sight.icon, + alt_icon: this.sight.alt_icon, watermark_lu: this.sight.watermark_lu, watermark_rd: this.sight.watermark_rd, left_article: finalLeftArticleId === 0 ? null : finalLeftArticleId, preview_media: this.sight.preview_media, video_preview: this.sight.video_preview, + preview_font_size: this.sight.preview_font_size, }; const response = await languageInstance(primaryLanguage).post( "/sight", - sightPayload + sightPayload, ); - const newSightId = response.data.id; // ID of the newly created sight + const newSightId = response.data.id; - // 4. Update other languages for the sight const otherLanguages = (["ru", "en", "zh"] as Language[]).filter( - (l) => l !== primaryLanguage + (l) => l !== primaryLanguage, ); for (const lang of otherLanguages) { await languageInstance(lang).patch(`/sight/${newSightId}`, { @@ -532,9 +531,13 @@ class CreateSightStore { city: this.sight.city, latitude: this.sight.latitude, longitude: this.sight.longitude, - name: this.sight[lang].name, + is_default_icon: this.sight.is_default_icon, + name: (this.sight[lang].name || "").trim(), + short_name: (this.sight[lang].short_name || "").trim(), address: this.sight[lang].address, thumbnail: this.sight.thumbnail, + icon: this.sight.icon, + alt_icon: this.sight.alt_icon, watermark_lu: this.sight.watermark_lu, watermark_rd: this.sight.watermark_rd, left_article: finalLeftArticleId === 0 ? null : finalLeftArticleId, @@ -543,31 +546,34 @@ class CreateSightStore { }); } - // 5. Link Right Articles to the new Sight for (let i = 0; i < rightArticleIdsForLink.length; i++) { await authInstance.post(`/sight/${newSightId}/article`, { article_id: rightArticleIdsForLink[i], - page_num: i + 1, // Or other logic for page_num + page_num: i + 1, }); } - // Optionally: this.clearCreateSight(); // To reset form after successful creation - this.needLeaveAgree = false; + runInAction(() => { + this.needLeaveAgree = false; + }); + return newSightId; }; - // --- Media Upload (Generic, used by dialogs) --- uploadMedia = async ( filename: string, type: number, file: File, - media_name?: string + media_name?: string, ): Promise => { const formData = new FormData(); formData.append("file", file); formData.append("filename", filename); if (media_name) formData.append("media_name", media_name); formData.append("type", type.toString()); + if (selectedCityStore.selectedCityId) { + formData.append("city_id", selectedCityStore.selectedCityId.toString()); + } try { const response = await authInstance.post(`/media`, formData); @@ -575,12 +581,12 @@ class CreateSightStore { this.fileToUpload = null; this.uploadMediaOpen = false; }); - mediaStore.getMedia(); // Refresh global media list + mediaStore.getMedia(); return { id: response.data.id, - filename: filename, // Or response.data.filename if backend returns it - media_name: media_name, // Or response.data.media_name - media_type: type, // Or response.data.type + filename: filename, + media_name: media_name, + media_type: type, }; } catch (error) { console.error("Error uploading media:", error); @@ -588,15 +594,12 @@ class CreateSightStore { } }; - // For Left Article Media createLinkWithLeftArticle = async (media: MediaItem) => { if (!this.sight.left_article || this.sight.left_article === 10000000) { console.warn( - "Left article not selected or is a placeholder. Cannot link media yet." + "Left article not selected or is a placeholder. Cannot link media yet.", ); - // If it's a placeholder, we could store the media temporarily and link it after the article is created. - // For simplicity, we'll assume the article must exist. - // A more robust solution might involve creating the article first if it's a placeholder. + return; } try { @@ -627,7 +630,7 @@ class CreateSightStore { (["ru", "en", "zh"] as Language[]).forEach((lang) => { if (this.sight[lang].left.media) { this.sight[lang].left.media = this.sight[lang].left.media.filter( - (m) => m.id !== mediaId + (m) => m.id !== mediaId, ); } }); @@ -643,19 +646,19 @@ class CreateSightStore { const sortArticles = (existing: any[]) => { const articleMap = new Map( - existing.map((article) => [article.id, article]) + existing.map((article) => [article.id, article]), ); return articlesIds .map((id) => articleMap.get(id)) .filter( (article): article is (typeof existing)[number] => - article !== undefined + article !== undefined, ); }; this.sight.ru.right = sortArticles(this.sight.ru.right); this.sight.en.right = sortArticles(this.sight.en.right); - this.sight.zh.right = sortArticles(this.sight.zh.right); // теперь zh тоже сортируется одинаково + this.sight.zh.right = sortArticles(this.sight.zh.right); this.needLeaveAgree = true; }; diff --git a/src/shared/store/EditSightStore/index.tsx b/src/shared/store/EditSightStore/index.tsx index 2fbfe1b..e0e1eaa 100644 --- a/src/shared/store/EditSightStore/index.tsx +++ b/src/shared/store/EditSightStore/index.tsx @@ -1,16 +1,17 @@ -// @shared/stores/editSightStore.ts import { articlesStore, authInstance, Language, languageInstance, mediaStore, + selectedCityStore, } from "@shared"; import { makeAutoObservable, runInAction } from "mobx"; export type SightLanguageInfo = { id: number; name: string; + short_name: string; address: string; left: { heading: string; @@ -31,12 +32,16 @@ export type SightCommonInfo = { city: string; latitude: number; longitude: number; + is_default_icon: boolean; thumbnail: string | null; + icon: string | null; + alt_icon: string | null; watermark_lu: string | null; watermark_rd: string | null; left_article: number; preview_media: string | null; video_preview: string | null; + preview_font_size?: number; }; export type SightBaseInfo = { @@ -52,7 +57,10 @@ class EditSightStore { city: "", latitude: 0, longitude: 0, + is_default_icon: false, thumbnail: null, + icon: null, + alt_icon: null, watermark_lu: null, watermark_rd: null, left_article: 0, @@ -62,6 +70,7 @@ class EditSightStore { ru: { id: 0, name: "", + short_name: "", address: "", left: { heading: "", body: "", media: [] }, right: [], @@ -69,6 +78,7 @@ class EditSightStore { en: { id: 0, name: "", + short_name: "", address: "", left: { heading: "", body: "", media: [] }, right: [], @@ -76,6 +86,7 @@ class EditSightStore { zh: { id: 0, name: "", + short_name: "", address: "", left: { heading: "", body: "", media: [] }, right: [], @@ -87,30 +98,35 @@ class EditSightStore { } hasLoadedCommon = false; + isLoading = false; + getSightInfo = async (id: number, language: Language) => { - const response = await languageInstance(language).get(`/sight/${id}`); - const data = response.data; + this.isLoading = true; + try { + const response = await languageInstance(language).get(`/sight/${id}`); + const data = response.data; - if (data.left_article != 0 && data.left_article != null) { - await this.getLeftArticle(data.left_article); - } + if (data.left_article != 0 && data.left_article != null) { + await this.getLeftArticle(data.left_article); + } - runInAction(() => { - // Обновляем языковую часть - this.sight[language] = { - ...this.sight[language], - ...data, - }; - - // Только при первом запросе обновляем общую часть - if (!this.hasLoadedCommon) { - this.sight.common = { - ...this.sight.common, + runInAction(() => { + this.sight[language] = { + ...this.sight[language], ...data, }; - this.hasLoadedCommon = true; - } - }); + + if (!this.hasLoadedCommon) { + this.sight.common = { + ...this.sight.common, + ...data, + }; + this.hasLoadedCommon = true; + } + }); + } finally { + this.isLoading = false; + } }; updateLeftInfo = (language: Language, heading: string, body: string) => { @@ -123,7 +139,6 @@ class EditSightStore { let responseEn = await languageInstance("en").get(`/sight/${id}/article`); let responseZh = await languageInstance("zh").get(`/sight/${id}/article`); - // Create a map of article IDs to their media const mediaMap = new Map(); for (const article of responseRu.data) { const responseMedia = await authInstance.get( @@ -132,7 +147,6 @@ class EditSightStore { mediaMap.set(article.id, responseMedia.data); } - // Function to add media to articles const addMediaToArticles = (articles: any[]) => { return articles.map((article) => ({ ...article, @@ -173,6 +187,8 @@ class EditSightStore { clearSightInfo = () => { this.needLeaveAgree = false; + this.hasLoadedCommon = false; + this.isLoading = false; this.sight = { common: { id: 0, @@ -180,7 +196,10 @@ class EditSightStore { city: "", latitude: 0, longitude: 0, + is_default_icon: false, thumbnail: null, + icon: null, + alt_icon: null, watermark_lu: null, watermark_rd: null, left_article: 0, @@ -190,6 +209,7 @@ class EditSightStore { ru: { id: 0, name: "", + short_name: "", address: "", left: { heading: "", body: "", media: [] }, right: [], @@ -198,6 +218,7 @@ class EditSightStore { en: { id: 0, name: "", + short_name: "", address: "", left: { heading: "", body: "", media: [] }, right: [], @@ -206,6 +227,7 @@ class EditSightStore { zh: { id: 0, name: "", + short_name: "", address: "", left: { heading: "", body: "", media: [] }, right: [], @@ -249,6 +271,7 @@ class EditSightStore { const response = await languageInstance("ru").post(`/article`, { heading: this.sight.ru.left.heading, body: this.sight.ru.left.body, + ...(selectedCityStore.selectedCityId ? { city_id: selectedCityStore.selectedCityId } : {}), }); createdLeftArticleId = response.data.id; await languageInstance("en").patch(`/article/${createdLeftArticleId}`, { @@ -286,9 +309,14 @@ class EditSightStore { ...this.sight.common, translations: { name: { - ru: this.sight.ru.name, - en: this.sight.en.name, - zh: this.sight.zh.name, + ru: (this.sight.ru.name || "").trim(), + en: (this.sight.en.name || "").trim(), + zh: (this.sight.zh.name || "").trim(), + }, + short_name: { + ru: (this.sight.ru.short_name || "").trim(), + en: (this.sight.en.short_name || "").trim(), + zh: (this.sight.zh.short_name || "").trim(), }, address: { ru: this.sight.ru.address, @@ -327,28 +355,6 @@ class EditSightStore { articles: articleIdsInObject, }); - // await languageInstance("ru").patch( - // `/sight/${this.sight.common.left_article}/article`, - // { - // heading: this.sight.ru.left.heading, - // body: this.sight.ru.left.body, - // } - // ); - // await languageInstance("en").patch( - // `/sight/${this.sight.common.left_article}/article`, - // { - // heading: this.sight.en.left.heading, - // body: this.sight.en.left.body, - // } - // ); - // await languageInstance("zh").patch( - // `/sight/${this.sight.common.left_article}/article`, - // { - // heading: this.sight.zh.left.heading, - // body: this.sight.zh.left.body, - // } - // ); - this.needLeaveAgree = false; }; @@ -400,16 +406,37 @@ class EditSightStore { }; createLeftArticle = async () => { + const ruName = (this.sight.ru.name || "").trim(); + const enName = (this.sight.en.name || "").trim(); + const zhName = (this.sight.zh.name || "").trim(); + const hasAnyName = !!(ruName || enName || zhName); + const response = await languageInstance("ru").post(`/article`, { - heading: "", + heading: hasAnyName ? ruName : "", body: "", + ...(selectedCityStore.selectedCityId ? { city_id: selectedCityStore.selectedCityId } : {}), }); this.sight.common.left_article = response.data.id; - this.sight.ru.left.heading = ""; - this.sight.en.left.heading = ""; - this.sight.zh.left.heading = ""; + await languageInstance("en").patch( + `/article/${this.sight.common.left_article}`, + { + heading: hasAnyName ? enName : "", + body: "", + } + ); + await languageInstance("zh").patch( + `/article/${this.sight.common.left_article}`, + { + heading: hasAnyName ? zhName : "", + body: "", + } + ); + + this.sight.ru.left.heading = hasAnyName ? ruName : ""; + this.sight.en.left.heading = hasAnyName ? enName : ""; + this.sight.zh.left.heading = hasAnyName ? zhName : ""; this.sight.ru.left.body = ""; this.sight.en.left.body = ""; this.sight.zh.left.body = ""; @@ -486,18 +513,22 @@ class EditSightStore { formData.append("media_name", media_name); } formData.append("type", type.toString()); - try { - const response = await authInstance.post(`/media`, formData); - this.fileToUpload = null; - this.uploadMediaOpen = false; - mediaStore.getMedia(); - return { - id: response.data.id, - filename: filename, - media_name: media_name, - media_type: type, - }; - } catch (error) {} + if (selectedCityStore.selectedCityId) { + formData.append("city_id", selectedCityStore.selectedCityId.toString()); + } + + const response = await authInstance.post(`/media`, formData); + this.fileToUpload = null; + this.uploadMediaOpen = false; + + mediaStore.getMedia(); + + return { + id: response.data.id, + filename: filename, + media_name: media_name, + media_type: type, + }; }; createLinkWithArticle = async (media: { @@ -569,7 +600,7 @@ class EditSightStore { }); }); - return article_id; // Return the linked article ID + return article_id; }; deleteRightArticleMedia = async (article_id: number, media_id: string) => { @@ -627,6 +658,7 @@ class EditSightStore { zh: articleZhData.body, }, }, + ...(selectedCityStore.selectedCityId ? { city_id: selectedCityStore.selectedCityId } : {}), }); const { id } = articleId.data; @@ -675,7 +707,7 @@ class EditSightStore { }); }); - return id; // Return the ID of the newly created article + return id; }; createLinkWithRightArticle = async ( @@ -750,7 +782,7 @@ class EditSightStore { this.sight.ru.right = sortArticles(this.sight.ru.right); this.sight.en.right = sortArticles(this.sight.en.right); - this.sight.zh.right = sortArticles(this.sight.zh.right); // теперь zh тоже сортируется одинаково + this.sight.zh.right = sortArticles(this.sight.zh.right); this.needLeaveAgree = true; }; diff --git a/src/shared/store/LanguageStore/index.tsx b/src/shared/store/LanguageStore/index.tsx index dab5420..16c48f2 100644 --- a/src/shared/store/LanguageStore/index.tsx +++ b/src/shared/store/LanguageStore/index.tsx @@ -6,10 +6,24 @@ class LanguageStore { constructor() { makeAutoObservable(this); + + if (typeof window !== "undefined") { + const storedLanguage = window.localStorage.getItem("appLanguage"); + if ( + storedLanguage && + ["ru", "en", "zh"].includes(storedLanguage.toLowerCase()) + ) { + this.language = storedLanguage.toLowerCase() as Language; + } + } } setLanguage = (language: Language) => { this.language = language; + + if (typeof window !== "undefined") { + window.localStorage.setItem("appLanguage", language); + } }; } diff --git a/src/shared/store/MediaStore/index.tsx b/src/shared/store/MediaStore/index.tsx index 7daeb26..448d44d 100644 --- a/src/shared/store/MediaStore/index.tsx +++ b/src/shared/store/MediaStore/index.tsx @@ -6,6 +6,7 @@ export type Media = { filename: string; media_name: string; media_type: number; + city_id?: number | null; }; class MediaStore { @@ -39,12 +40,11 @@ class MediaStore { updateMedia = async (id: string, data: Partial) => { const response = await authInstance.patch(`/media/${id}`, data); runInAction(() => { - // Update in media array const index = this.media.findIndex((m) => m.id === id); if (index !== -1) { this.media[index] = { ...this.media[index], ...response.data }; } - // Update oneMedia if it's the current media being viewed + if (this.oneMedia?.id === id) { this.oneMedia = { ...this.oneMedia, ...response.data }; } @@ -64,12 +64,11 @@ class MediaStore { }); runInAction(() => { - // Update in media array const index = this.media.findIndex((m) => m.id === id); if (index !== -1) { this.media[index] = { ...this.media[index], ...response.data }; } - // Update oneMedia if it's the current media being viewed + if (this.oneMedia?.id === id) { this.oneMedia = { ...this.oneMedia, ...response.data }; } @@ -77,10 +76,11 @@ class MediaStore { return response.data; }; - createMedia = async (name: string, type: string) => { + createMedia = async (name: string, type: string, cityId?: number | null) => { const response = await authInstance.post("/media", { media_name: name, media_type: type, + ...(cityId ? { city_id: cityId } : {}), }); runInAction(() => { this.media.push(response.data); diff --git a/src/shared/store/ModelLoadingStore/index.ts b/src/shared/store/ModelLoadingStore/index.ts index a55906b..81c0e27 100644 --- a/src/shared/store/ModelLoadingStore/index.ts +++ b/src/shared/store/ModelLoadingStore/index.ts @@ -15,7 +15,6 @@ class ModelLoadingStore { makeAutoObservable(this); } - // Начать отслеживание загрузки модели startLoading(modelId: string) { this.loadingStates.set(modelId, { isLoading: true, @@ -25,7 +24,6 @@ class ModelLoadingStore { }); } - // Обновить прогресс загрузки updateProgress(modelId: string, progress: number) { const state = this.loadingStates.get(modelId); if (state) { @@ -33,7 +31,6 @@ class ModelLoadingStore { } } - // Завершить загрузку модели finishLoading(modelId: string) { const state = this.loadingStates.get(modelId); if (state) { @@ -42,12 +39,10 @@ class ModelLoadingStore { } } - // Остановить загрузку (в случае ошибки) stopLoading(modelId: string) { this.loadingStates.delete(modelId); } - // Обработать ошибку загрузки handleError(modelId: string, error?: string) { const state = this.loadingStates.get(modelId); if (state) { @@ -56,26 +51,22 @@ class ModelLoadingStore { } } - // Получить состояние загрузки для конкретной модели getLoadingState(modelId: string): ModelLoadingState | undefined { return this.loadingStates.get(modelId); } - // Проверить, загружается ли какая-либо модель get isAnyModelLoading(): boolean { return Array.from(this.loadingStates.values()).some( (state) => state.isLoading ); } - // Получить все загружающиеся модели get loadingModels(): ModelLoadingState[] { return Array.from(this.loadingStates.values()).filter( (state) => state.isLoading ); } - // Получить общий прогресс всех загружающихся моделей get overallProgress(): number { const loadingModels = this.loadingModels; if (loadingModels.length === 0) return 100; @@ -87,12 +78,10 @@ class ModelLoadingStore { return Math.round(totalProgress / loadingModels.length); } - // Проверить, заблокировано ли сохранение (есть ли загружающиеся модели) get isSaveBlocked(): boolean { return this.isAnyModelLoading; } - // Очистить все состояния загрузки clearAll() { this.loadingStates.clear(); } diff --git a/src/shared/store/RouteStore/index.ts b/src/shared/store/RouteStore/index.ts index 9d7ec59..edb2bbb 100644 --- a/src/shared/store/RouteStore/index.ts +++ b/src/shared/store/RouteStore/index.ts @@ -1,13 +1,21 @@ import { makeAutoObservable, runInAction } from "mobx"; -import { authInstance } from "@shared"; +import { + authInstance, + languageInstance, + languageStore, + isMediaIdEmpty, +} from "@shared"; export type Route = { + route_name: string; carrier: string; carrier_id: number; center_latitude: number; center_longitude: number; governor_appeal: number; + button_text?: string; id: number; + icon: string; path: number[][]; rotate: number; route_direction: boolean; @@ -16,6 +24,7 @@ export type Route = { scale_max: number; scale_min: number; video_preview: string; + video_timer: number; }; class RouteStore { @@ -45,7 +54,7 @@ class RouteStore { }); }; - createRoute = async (route: any) => { + createRoute = async (route: any): Promise => { const response = await authInstance.post("/route", route); const id = response.data.id; @@ -53,6 +62,8 @@ class RouteStore { this.route[id] = { ...route, id }; this.routes.data = [...this.routes.data, { ...route, id }]; }); + + return id; }; deleteRoute = async (id: number) => { @@ -88,21 +99,54 @@ class RouteStore { }; saveRouteStations = async (routeId: number, stationId: number) => { - await authInstance.patch(`/route/${routeId}/station`, { - ...this.routeStations[routeId]?.find( - (station) => station.id === stationId - ), + const { language } = languageStore; + + const stationResponse = await languageInstance(language).get( + `/station/${stationId}` + ); + const fullStationData = stationResponse.data; + + // Получаем отредактированные данные из локального кеша + const editedStationData = this.routeStations[routeId]?.find( + (station) => station.id === stationId + ); + + const dataToSend: any = { station_id: stationId, + offset_x: editedStationData?.offset_x ?? fullStationData.offset_x ?? 0, + offset_y: editedStationData?.offset_y ?? fullStationData.offset_y ?? 0, + align: editedStationData?.align ?? fullStationData.align ?? 0, + transfers: fullStationData.transfers || {}, + }; + + await authInstance.patch(`/route/${routeId}/station`, dataToSend); + + // Обновляем локальный кеш после успешного сохранения + runInAction(() => { + if (this.routeStations[routeId]) { + this.routeStations[routeId] = this.routeStations[routeId].map( + (station) => + station.id === stationId + ? { + ...station, + ...dataToSend, + } + : station + ); + } }); }; editRouteData = { + route_name: "", carrier: "", carrier_id: 0, center_latitude: "", center_longitude: "", governor_appeal: 0, + button_text: "" as string | undefined, id: 0, + icon: "", path: [] as number[][], rotate: 0, route_direction: false, @@ -110,7 +154,8 @@ class RouteStore { route_sys_number: "", scale_max: 0, scale_min: 0, - video_preview: "", + video_preview: "" as string | undefined, + video_timer: 420, }; setEditRouteData = (data: any) => { @@ -118,11 +163,30 @@ class RouteStore { }; editRoute = async (id: number) => { - const response = await authInstance.patch(`/route/${id}`, { + if ( + !this.editRouteData.video_preview || + isMediaIdEmpty(this.editRouteData.video_preview) + ) { + delete this.editRouteData.video_preview; + } + if (!this.editRouteData.icon || isMediaIdEmpty(this.editRouteData.icon)) { + delete (this.editRouteData as any).icon; + } + const dataToSend: any = { ...this.editRouteData, + route_name: (this.editRouteData.route_name || "").trim(), + route_number: (this.editRouteData.route_number || "").trim(), + route_sys_number: (this.editRouteData.route_sys_number || "").trim(), center_latitude: parseFloat(this.editRouteData.center_latitude), center_longitude: parseFloat(this.editRouteData.center_longitude), - }); + }; + if ( + this.editRouteData.governor_appeal === 0 || + !this.editRouteData.governor_appeal + ) { + dataToSend.governor_appeal = null; + } + const response = await authInstance.patch(`/route/${id}`, dataToSend); runInAction(() => { this.route[id] = response.data; @@ -140,6 +204,49 @@ class RouteStore { }); }; + sightCounts: Map = new Map(); + stationCounts: Map = new Map(); + countsLoading = false; + + loadCounts = async (routeIds: number[]) => { + if (routeIds.length === 0) return; + + runInAction(() => { + this.countsLoading = true; + }); + + const batchSize = 20; + for (let i = 0; i < routeIds.length; i += batchSize) { + const batch = routeIds.slice(i, i + batchSize); + const results = await Promise.allSettled( + batch.flatMap((id) => [ + authInstance.get(`/route/${id}/sight/count`).then((res) => ({ id, type: "sight", data: res.data })), + authInstance.get(`/route/${id}/station/count`).then((res) => ({ id, type: "station", data: res.data })), + ]) + ); + + runInAction(() => { + for (const result of results) { + if (result.status === "fulfilled") { + const { id, type, data } = result.value; + let count = 0; + if (typeof data === "number") { + count = data; + } else if (data && typeof data === "object") { + count = Object.values(data).reduce((sum: number, v: any) => sum + (Number(v) || 0), 0); + } + if (type === "sight") this.sightCounts.set(id, count); + else this.stationCounts.set(id, count); + } + } + }); + } + + runInAction(() => { + this.countsLoading = false; + }); + }; + selectedStationId = 0; setSelectedStationId = (id: number) => { diff --git a/src/shared/store/SelectedCityStore/index.ts b/src/shared/store/SelectedCityStore/index.ts index 270cc20..47cb53c 100644 --- a/src/shared/store/SelectedCityStore/index.ts +++ b/src/shared/store/SelectedCityStore/index.ts @@ -3,6 +3,8 @@ import { City } from "../CityStore"; class SelectedCityStore { selectedCity: City | null = null; + isLocked: boolean = false; + cityVersion: number = 0; constructor() { makeAutoObservable(this); @@ -24,6 +26,7 @@ class SelectedCityStore { setSelectedCity = (city: City | null) => { runInAction(() => { this.selectedCity = city; + this.cityVersion += 1; if (city) { localStorage.setItem("selectedCity", JSON.stringify(city)); } else { @@ -32,6 +35,12 @@ class SelectedCityStore { }); }; + setIsLocked = (locked: boolean) => { + runInAction(() => { + this.isLocked = locked; + }); + }; + clearSelectedCity = () => { this.setSelectedCity(null); }; diff --git a/src/shared/store/SightsStore/index.tsx b/src/shared/store/SightsStore/index.tsx index 8515ae5..e7e9008 100644 --- a/src/shared/store/SightsStore/index.tsx +++ b/src/shared/store/SightsStore/index.tsx @@ -11,6 +11,7 @@ export type Language = "ru" | "en" | "zh"; export type MultilingualContent = { [key in Language]: { name: string; + short_name: string; address: string; }; }; @@ -18,12 +19,16 @@ export type MultilingualContent = { export type Sight = { id: number; name: string; + short_name: string | null; city_id: number; city: string; address: string; latitude: number; longitude: number; + is_default_icon: boolean; thumbnail: string | null; + icon: string | null; + alt_icon: string | null; watermark_lu: string | null; watermark_rd: string | null; left_article: number; @@ -34,6 +39,7 @@ export type Sight = { export type CreateSight = { [key in Language]: { name: string; + short_name: string; address: string; }; }; @@ -42,9 +48,9 @@ class SightsStore { sights: Sight[] = []; sight: Sight | null = null; createSight: CreateSight = { - ru: { name: "", address: "" }, - en: { name: "", address: "" }, - zh: { name: "", address: "" }, + ru: { name: "", short_name: "", address: "" }, + en: { name: "", short_name: "", address: "" }, + zh: { name: "", short_name: "", address: "" }, }; constructor() { @@ -58,47 +64,13 @@ class SightsStore { }); }; - // getSight = async (id: number) => { - // const response = await authInstance.get(`/sight/${id}`); - - // runInAction(() => { - // this.sight = response.data; - // editSightStore.sightInfo = { - // ...editSightStore.sightInfo, - // id: response.data.id, - // city_id: response.data.city_id, - // city: response.data.city, - // latitude: response.data.latitude, - // longitude: response.data.longitude, - // thumbnail: response.data.thumbnail, - // watermark_lu: response.data.watermark_lu, - // watermark_rd: response.data.watermark_rd, - // left_article: response.data.left_article, - // preview_media: response.data.preview_media, - // video_preview: response.data.video_preview, - - // [languageStore.language]: { - // info: { - // name: response.data.name, - // address: response.data.address, - // }, - // left: { - // heading: articlesStore.articles[languageStore.language].find( - // (article) => article.id === response.data.left_article - // )?.heading, - // body: articlesStore.articles[languageStore.language].find( - // }, - // }, - // }; - // }); - // }; - createSightAction = async ( city: number, coordinates: { latitude: number; longitude: number } ) => { const response = await authInstance.post("/sight", { name: this.createSight[languageStore.language].name, + short_name: this.createSight[languageStore.language].short_name, address: this.createSight[languageStore.language].address, city_id: city, latitude: coordinates.latitude, @@ -119,6 +91,7 @@ class SightsStore { `/sight/${id}`, { name: this.createSight[anotherLanguages[0] as Language].name, + short_name: this.createSight[anotherLanguages[0] as Language].short_name, address: this.createSight[anotherLanguages[0] as Language].address, city_id: city, latitude: coordinates.latitude, @@ -130,6 +103,7 @@ class SightsStore { `/sight/${id}`, { name: this.createSight[anotherLanguages[1] as Language].name, + short_name: this.createSight[anotherLanguages[1] as Language].short_name, address: this.createSight[anotherLanguages[1] as Language].address, city_id: city, latitude: coordinates.latitude, @@ -139,9 +113,9 @@ class SightsStore { runInAction(() => { this.createSight = { - ru: { name: "", address: "" }, - en: { name: "", address: "" }, - zh: { name: "", address: "" }, + ru: { name: "", short_name: "", address: "" }, + en: { name: "", short_name: "", address: "" }, + zh: { name: "", short_name: "", address: "" }, }; }); }; @@ -188,14 +162,17 @@ class SightsStore { this.createSight = { ru: { name: "", + short_name: "", address: "", }, en: { name: "", + short_name: "", address: "", }, zh: { name: "", + short_name: "", address: "", }, }; @@ -209,7 +186,10 @@ class SightsStore { city_id: this.sight?.city_id, latitude: this.sight?.latitude, longitude: this.sight?.longitude, + is_default_icon: this.sight?.is_default_icon, thumbnail: this.sight?.thumbnail, + icon: this.sight?.icon, + alt_icon: this.sight?.alt_icon, watermark_lu: this.sight?.watermark_lu, watermark_rd: this.sight?.watermark_rd, left_article: this.sight?.left_article, diff --git a/src/shared/store/SnapshotStore/index.ts b/src/shared/store/SnapshotStore/index.ts index 41cc382..0cd670b 100644 --- a/src/shared/store/SnapshotStore/index.ts +++ b/src/shared/store/SnapshotStore/index.ts @@ -1,8 +1,6 @@ import { authInstance } from "@shared"; - +import { v4 as uuidv4 } from "uuid"; import { makeAutoObservable, runInAction } from "mobx"; -// Импорт функции сброса кешей карты -// import { clearMapCaches } from "../../pages/MapPage"; import { articlesStore, cityStore, @@ -25,19 +23,71 @@ type Snapshot = { Name: string; ParentID: string; CreationTime: string; + occupied_disk_space_gb: number; +}; + +type SnapshotStatus = { + ID: string; + Status: string; + Progress: number; + Error: string; +}; + +type StorageInfo = { + available_disk_space_gb: number; + total_disk_space_gb: number; }; class SnapshotStore { snapshots: Snapshot[] = []; snapshot: Snapshot | null = null; + lastRequestId: string | null = null; + snapshotStatus: SnapshotStatus | null = null; + storageInfo: StorageInfo | null = null; constructor() { makeAutoObservable(this); } - // Функция для сброса всех кешей в приложении + clearStoreCache = () => { + runInAction(() => { + articlesStore.articleList = { + ru: { data: [], loaded: false }, + en: { data: [], loaded: false }, + zh: { data: [], loaded: false }, + }; + articlesStore.articlePreview = {}; + articlesStore.articleData = null; + articlesStore.articleMedia = null; + + carrierStore.carriers = { + ru: { data: [], loaded: false }, + en: { data: [], loaded: false }, + zh: { data: [], loaded: false }, + }; + + stationsStore.stationLists = { + ru: { data: [], loaded: false }, + en: { data: [], loaded: false }, + zh: { data: [], loaded: false }, + }; + stationsStore.stationPreview = {}; + + sightsStore.sights = []; + sightsStore.sight = null; + + routeStore.routes = { data: [], loaded: false }; + + vehicleStore.vehicles = { data: [], loaded: false }; + + userStore.users = { data: [], loaded: false }; + + mediaStore.media = []; + mediaStore.oneMedia = null; + }); + }; + private clearAllCaches = () => { - // Сброс кешей статей articlesStore.articleList = { ru: { data: [], loaded: false }, en: { data: [], loaded: false }, @@ -47,7 +97,6 @@ class SnapshotStore { articlesStore.articleData = null; articlesStore.articleMedia = null; - // Сброс кешей городов cityStore.cities = { ru: { data: [], loaded: false }, en: { data: [], loaded: false }, @@ -56,21 +105,18 @@ class SnapshotStore { cityStore.ruCities = { data: [], loaded: false }; cityStore.city = {}; - // Сброс кешей стран countryStore.countries = { ru: { data: [], loaded: false }, en: { data: [], loaded: false }, zh: { data: [], loaded: false }, }; - // Сброс кешей перевозчиков carrierStore.carriers = { ru: { data: [], loaded: false }, en: { data: [], loaded: false }, zh: { data: [], loaded: false }, }; - // Сброс кешей станций stationsStore.stationLists = { ru: { data: [], loaded: false }, en: { data: [], loaded: false }, @@ -78,31 +124,28 @@ class SnapshotStore { }; stationsStore.stationPreview = {}; - // Сброс кешей достопримечательностей sightsStore.sights = []; sightsStore.sight = null; - // Сброс кешей маршрутов routeStore.routes = { data: [], loaded: false }; - // Сброс кешей транспорта vehicleStore.vehicles = { data: [], loaded: false }; - // Сброс кешей пользователей userStore.users = { data: [], loaded: false }; - // Сброс кешей медиа mediaStore.media = []; mediaStore.oneMedia = null; - // Сброс кешей создания и редактирования достопримечательностей createSightStore.sight = JSON.parse( JSON.stringify({ city_id: 0, city: "", latitude: 0, longitude: 0, + is_default_icon: false, thumbnail: null, + icon: null, + alt_icon: null, watermark_lu: null, watermark_rd: null, left_article: 0, @@ -139,7 +182,10 @@ class SnapshotStore { city: "", latitude: 0, longitude: 0, + is_default_icon: false, thumbnail: null, + icon: null, + alt_icon: null, watermark_lu: null, watermark_rd: null, left_article: 0, @@ -149,6 +195,7 @@ class SnapshotStore { ru: { id: 0, name: "", + short_name: "", address: "", left: { heading: "", body: "", media: [] }, right: [], @@ -156,6 +203,7 @@ class SnapshotStore { en: { id: 0, name: "", + short_name: "", address: "", left: { heading: "", body: "", media: [] }, right: [], @@ -163,6 +211,7 @@ class SnapshotStore { zh: { id: 0, name: "", + short_name: "", address: "", left: { heading: "", body: "", media: [] }, right: [], @@ -173,26 +222,21 @@ class SnapshotStore { editSightStore.fileToUpload = null; editSightStore.needLeaveAgree = false; - // Сброс кешей устройств devicesStore.devices = []; devicesStore.uuid = null; devicesStore.sendSnapshotModalOpen = false; - // Сброс кешей авторизации (кроме токена) authStore.payload = null; authStore.error = null; authStore.isLoading = false; - // Сброс кешей карты (если они загружены) try { - // Сбрасываем кеши mapStore если он доступен if (typeof window !== "undefined" && (window as any).mapStore) { (window as any).mapStore.routes = []; (window as any).mapStore.stations = []; (window as any).mapStore.sights = []; } - // Сбрасываем кеши MapService если он доступен if (typeof window !== "undefined" && (window as any).mapServiceInstance) { (window as any).mapServiceInstance.clearCaches(); } @@ -200,7 +244,6 @@ class SnapshotStore { console.warn("Не удалось сбросить кеши карты:", error); } - // Сброс localStorage кешей (кроме токена авторизации) const token = localStorage.getItem("token"); const rememberedEmail = localStorage.getItem("rememberedEmail"); const rememberedPassword = localStorage.getItem("rememberedPassword"); @@ -208,14 +251,12 @@ class SnapshotStore { localStorage.clear(); sessionStorage.clear(); - // Восстанавливаем важные данные if (token) localStorage.setItem("token", token); if (rememberedEmail) localStorage.setItem("rememberedEmail", rememberedEmail); if (rememberedPassword) localStorage.setItem("rememberedPassword", rememberedPassword); - // Сброс кешей карты (если они есть) const mapPositionKey = "mapPosition"; const activeSectionKey = "mapActiveSection"; if (localStorage.getItem(mapPositionKey)) { @@ -225,7 +266,6 @@ class SnapshotStore { localStorage.removeItem(activeSectionKey); } - // Попытка очистить кеш браузера (если поддерживается) if ("caches" in window) { try { caches.keys().then((cacheNames) => { @@ -240,7 +280,6 @@ class SnapshotStore { } } - // Попытка очистить IndexedDB (если поддерживается) if ("indexedDB" in window) { try { indexedDB.databases().then((databases) => { @@ -268,10 +307,17 @@ class SnapshotStore { }; deleteSnapshot = async (id: string) => { + const snapshot = this.snapshots.find((s) => s.ID === id); await authInstance.delete(`/snapshots/${id}`); runInAction(() => { - this.snapshots = this.snapshots.filter((snapshot) => snapshot.ID !== id); + this.snapshots = this.snapshots.filter((s) => s.ID !== id); + if (this.storageInfo && snapshot?.occupied_disk_space_gb) { + this.storageInfo = { + ...this.storageInfo, + available_disk_space_gb: this.storageInfo.available_disk_space_gb + snapshot.occupied_disk_space_gb, + }; + } }); }; @@ -284,15 +330,43 @@ class SnapshotStore { }; restoreSnapshot = async (id: string) => { - // Сначала сбрасываем все кеши this.clearAllCaches(); - // Затем восстанавливаем снапшот await authInstance.post(`/snapshots/${id}/restore`); }; + createEmptySnapshot = async (name: string) => { + await authInstance.post(`/snapshots/empty`, { + name: name.trim(), + }); + }; + createSnapshot = async (name: string) => { - await authInstance.post(`/snapshots`, { name }); + this.lastRequestId = uuidv4(); + + const response = await authInstance.post( + `/snapshots`, + { name: name.trim() }, + { headers: { "X-Request-ID": this.lastRequestId } } + ); + + return response.data.ID; + }; + + getSnapshotStatus = async (id: string) => { + const response = await authInstance.get(`/snapshots/status/${id}`); + + runInAction(() => { + this.snapshotStatus = response.data; + }); + }; + + getStorageInfo = async () => { + const response = await authInstance.get(`/snapshots/storage`); + + runInAction(() => { + this.storageInfo = response.data; + }); }; } diff --git a/src/shared/store/StationsStore/index.ts b/src/shared/store/StationsStore/index.ts index 8ad2778..f0f171d 100644 --- a/src/shared/store/StationsStore/index.ts +++ b/src/shared/store/StationsStore/index.ts @@ -1,5 +1,6 @@ import { authInstance, languageInstance, languageStore } from "@shared"; import { makeAutoObservable, runInAction } from "mobx"; +import { routeStore } from "../RouteStore"; type Language = "ru" | "en" | "zh"; @@ -7,12 +8,11 @@ type StationLanguageData = { name: string; system_name: string; address: string; - loaded: boolean; // Indicates if this language's data has been loaded/modified + loaded: boolean; }; type StationCommonData = { city_id: number; - direction: boolean; description: string; icon: string; latitude: number; @@ -43,7 +43,6 @@ type Station = { city: string; city_id: number; description: string; - direction: boolean; icon: string; latitude: number; longitude: number; @@ -72,6 +71,9 @@ class StationsStore { stations: Station[] = []; station: Station | null = null; + sightCounts: Map = new Map(); + sightCountsLoading = false; + stationLists: { [key in Language]: { data: Station[]; @@ -92,7 +94,6 @@ class StationsStore { }, }; - // This will store the full station data, keyed by ID and then by language stationPreview: Record< string, Record @@ -123,7 +124,6 @@ class StationsStore { common: { city: "", city_id: 0, - direction: false, description: "", icon: "", latitude: 0, @@ -169,7 +169,6 @@ class StationsStore { common: { city: "", city_id: 0, - direction: false, description: "", icon: "", latitude: 0, @@ -215,6 +214,45 @@ class StationsStore { }); }; + loadSightCounts = async (stationIds: number[]) => { + if (stationIds.length === 0) return; + + runInAction(() => { + this.sightCountsLoading = true; + }); + + const batchSize = 20; + for (let i = 0; i < stationIds.length; i += batchSize) { + const batch = stationIds.slice(i, i + batchSize); + const results = await Promise.allSettled( + batch.map((id) => + authInstance + .get(`/station/${id}/sight/count`) + .then((res) => ({ id, data: res.data })) + ) + ); + + runInAction(() => { + for (const result of results) { + if (result.status === "fulfilled") { + const { id, data } = result.value; + let count = 0; + if (typeof data === "number") { + count = data; + } else if (data && typeof data === "object") { + count = Object.values(data).reduce((sum: number, v: any) => sum + (Number(v) || 0), 0); + } + this.sightCounts.set(id, count); + } + } + }); + } + + runInAction(() => { + this.sightCountsLoading = false; + }); + }; + setEditCommonData = (data: Partial) => { this.editStationData.common = { ...this.editStationData.common, @@ -252,7 +290,6 @@ class StationsStore { common: { city: ruResponse.data.city, city_id: ruResponse.data.city_id, - direction: ruResponse.data.direction, description: ruResponse.data.description, icon: ruResponse.data.icon, latitude: ruResponse.data.latitude, @@ -264,7 +301,6 @@ class StationsStore { }; }; - // Sets language-specific station data setLanguageEditStationData = ( language: Language, data: Partial @@ -278,7 +314,6 @@ class StationsStore { editStation = async (id: number) => { const commonDataPayload = { city_id: this.editStationData.common.city_id, - direction: this.editStationData.common.direction, icon: this.editStationData.common.icon, latitude: this.editStationData.common.latitude, longitude: this.editStationData.common.longitude, @@ -294,16 +329,15 @@ class StationsStore { const response = await languageInstance(language).patch( `/station/${id}`, { - name: name || "", - system_name: name || "", // system_name is often derived from name - description: description || "", - address: address || "", + name: (name || "").trim(), + system_name: (name || "").trim(), + description: (description || "").trim(), + address: (address || "").trim(), ...commonDataPayload, } ); runInAction(() => { - // Update the cached preview data and station lists after successful patch if (this.stationPreview[id]) { this.stationPreview[id][language] = { loaded: true, @@ -343,11 +377,11 @@ class StationsStore { runInAction(() => { this.stations = this.stations.filter((station) => station.id !== id); - // Also clear from stationPreview cache + if (this.stationPreview[id]) { delete this.stationPreview[id]; } - // Clear from stationLists as well for all languages + for (const lang of ["ru", "en", "zh"] as const) { if (this.stationLists[lang].data) { this.stationLists[lang].data = this.stationLists[lang].data.filter( @@ -407,7 +441,6 @@ class StationsStore { const { language } = languageStore; let commonDataPayload: Partial = { city_id: this.createStationData.common.city_id, - direction: this.createStationData.common.direction, icon: this.createStationData.common.icon, latitude: this.createStationData.common.latitude, longitude: this.createStationData.common.longitude, @@ -421,14 +454,13 @@ class StationsStore { delete commonDataPayload.icon; } - // First create station in Russian const { name, address } = this.createStationData[language]; const description = this.createStationData.common.description; const response = await languageInstance(language).post("/station", { - name: name || "", - system_name: name || "", // system_name is often derived from name - description: description || "", - address: address || "", + name: (name || "").trim(), + system_name: (name || "").trim(), + description: (description || "").trim(), + address: (address || "").trim(), ...commonDataPayload, }); @@ -436,9 +468,8 @@ class StationsStore { this.stationLists[language].data.push(response.data); }); - const stationId = response.data.id; + const stationId: number = response.data.id; - // Then update for other languages for (const lang of ["ru", "en", "zh"].filter( (lang) => lang !== language ) as Language[]) { @@ -447,10 +478,10 @@ class StationsStore { const response = await languageInstance(lang).patch( `/station/${stationId}`, { - name: name || "", - system_name: name || "", // system_name is often derived from name - description: description || "", - address: address || "", + name: (name || "").trim(), + system_name: (name || "").trim(), + description: (description || "").trim(), + address: (address || "").trim(), ...commonDataPayload, } ); @@ -483,7 +514,6 @@ class StationsStore { common: { city: "", city_id: 0, - direction: false, icon: "", latitude: 0, description: "", @@ -507,7 +537,6 @@ class StationsStore { return response.data; }; - // Reset editStationData when navigating away or after saving resetEditStationData = () => { this.editStationData = { ru: { @@ -531,7 +560,6 @@ class StationsStore { common: { city: "", city_id: 0, - direction: false, description: "", icon: "", latitude: 0, @@ -552,6 +580,97 @@ class StationsStore { }, }; }; + + updateStationTransfers = async ( + id: number, + transfers: { + bus: string; + metro_blue: string; + metro_green: string; + metro_orange: string; + metro_purple: string; + metro_red: string; + train: string; + tram: string; + trolleybus: string; + } + ) => { + const { language } = languageStore; + + const response = await languageInstance(language).get(`/station/${id}`); + const stationData = response.data as Station; + + if (!stationData) { + throw new Error("Station not found"); + } + + // Формируем commonDataPayload как в editStation, с обновленными transfers + const commonDataPayload = { + city_id: stationData.city_id, + latitude: stationData.latitude, + longitude: stationData.longitude, + offset_x: stationData.offset_x, + offset_y: stationData.offset_y, + transfers: transfers, + city: stationData.city || "", + }; + + // Отправляем один PATCH запрос, так как пересадки общие для всех языков + const patchResponse = await languageInstance(language).patch( + `/station/${id}`, + { + name: stationData.name || "", + system_name: stationData.system_name || "", + description: stationData.description || "", + address: stationData.address || "", + ...commonDataPayload, + } + ); + + // Обновляем данные для всех языков в локальном состоянии + runInAction(() => { + const updatedTransfers = patchResponse.data.transfers; + + for (const lang of ["ru", "en", "zh"] as const) { + if (this.stationPreview[id]) { + this.stationPreview[id][lang] = { + ...this.stationPreview[id][lang], + data: { + ...this.stationPreview[id][lang].data, + transfers: updatedTransfers, + }, + }; + } + if (this.stationLists[lang].data) { + this.stationLists[lang].data = this.stationLists[lang].data.map( + (station: Station) => + station.id === id + ? { + ...station, + transfers: updatedTransfers, + } + : station + ); + } + } + + // Обновляем пересадки в RouteStore.routeStations для всех маршрутов + if (routeStore?.routeStations) { + for (const routeId in routeStore.routeStations) { + routeStore.routeStations[routeId] = routeStore.routeStations[ + routeId + ].map((station: any) => + station.id === id + ? { + ...station, + transfers: updatedTransfers, + } + : station + ); + } + } + }); + }; } export const stationsStore = new StationsStore(); diff --git a/src/shared/store/TestingModeStore/api.ts b/src/shared/store/TestingModeStore/api.ts new file mode 100644 index 0000000..b855854 --- /dev/null +++ b/src/shared/store/TestingModeStore/api.ts @@ -0,0 +1,18 @@ +import { authInstance } from "@shared"; + +export type TestingModeResponse = { + enabled: boolean; + updated_at: string; +}; + +export const getTestingModeApi = async (): Promise => { + const response = await authInstance.get("/testing-mode"); + return response.data as TestingModeResponse; +}; + +export const setTestingModeApi = async (request: { + enabled: boolean; +}): Promise => { + const response = await authInstance.post("/testing-mode", request); + return response.data as TestingModeResponse; +}; diff --git a/src/shared/store/TestingModeStore/index.ts b/src/shared/store/TestingModeStore/index.ts new file mode 100644 index 0000000..8fa7d9e --- /dev/null +++ b/src/shared/store/TestingModeStore/index.ts @@ -0,0 +1,62 @@ +import { makeAutoObservable } from "mobx"; +import { mobxFetch } from "@shared"; +import { + TestingModeResponse, + getTestingModeApi, + setTestingModeApi, +} from "./api"; + +const POLLING_INTERVAL = 10_000; + +class TestingModeStore { + testingMode: TestingModeResponse | null = null; + testingModeLoading = false; + testingModeError: string | null = null; + + setTestingModeResult: TestingModeResponse | null = null; + setTestingModeLoading = false; + setTestingModeError: string | null = null; + + constructor() { + makeAutoObservable(this); + } + + get isEnabled(): boolean { + return this.testingMode?.enabled ?? false; + } + + fetchTestingModeAction = mobxFetch({ + store: this, + value: "testingMode", + loading: "testingModeLoading", + error: "testingModeError", + fn: getTestingModeApi, + pollingInterval: POLLING_INTERVAL, + }); + + setTestingModeAction = mobxFetch< + { enabled: boolean }, + TestingModeResponse, + TestingModeStore + >({ + store: this, + value: "setTestingModeResult", + loading: "setTestingModeLoading", + error: "setTestingModeError", + fn: setTestingModeApi, + onSuccess: (result) => { + this.testingMode = result; + }, + }); + + startPolling() { + this.fetchTestingModeAction(); + } + + stopPolling() { + this.fetchTestingModeAction.stopPolling?.(); + } +} + +export const testingModeStore = new TestingModeStore(); +export { TestingModeStore }; diff --git a/src/shared/store/UserStore/api.ts b/src/shared/store/UserStore/api.ts new file mode 100644 index 0000000..d2fb412 --- /dev/null +++ b/src/shared/store/UserStore/api.ts @@ -0,0 +1,14 @@ +import { authInstance } from "@shared"; +import { User } from "./index"; + +export const addUserCityApi = async ( + { id, city_ids }: { id: number; city_ids: number[] }, + signal?: AbortSignal, +): Promise => { + const response = await authInstance.patch( + `/user/${id}/city`, + { city_ids }, + { signal }, + ); + return response.data as User; +}; diff --git a/src/shared/store/UserStore/index.ts b/src/shared/store/UserStore/index.ts index 8a2da6d..ba94e80 100644 --- a/src/shared/store/UserStore/index.ts +++ b/src/shared/store/UserStore/index.ts @@ -1,5 +1,11 @@ -import { authInstance } from "@shared"; +import { authInstance, mobxFetch } from "@shared"; import { makeAutoObservable, runInAction } from "mobx"; +import { addUserCityApi } from "./api"; + +export type UserCity = { + city_id: number; + name: string; +}; export type User = { id: number; @@ -7,6 +13,9 @@ export type User = { is_admin: boolean; name: string; password?: string; + icon?: string; + roles?: string[]; + cities?: UserCity[]; }; class UserStore { @@ -57,15 +66,25 @@ class UserStore { email: "", password: "", is_admin: false, + icon: "", + roles: ["articles_ro", "articles_rw", "media_ro", "media_rw"], }; setCreateUserData = ( name: string, email: string, password: string, - is_admin: boolean + is_admin: boolean, + icon?: string, ) => { - this.createUserData = { name, email, password, is_admin }; + this.createUserData = { + ...this.createUserData, + name, + email, + password, + is_admin, + icon: icon ?? "", + }; }; createUser = async () => { @@ -73,7 +92,19 @@ class UserStore { if (this.users.data.length > 0) { id = this.users.data[this.users.data.length - 1].id + 1; } - const response = await authInstance.post("/user", this.createUserData); + const payload: Partial = { + ...this.createUserData, + name: (this.createUserData.name || "").trim(), + email: (this.createUserData.email || "").trim(), + }; + const baseRoles = new Set(payload.roles ?? []); + baseRoles.add("articles_ro"); + baseRoles.add("articles_rw"); + baseRoles.add("media_ro"); + baseRoles.add("media_rw"); + payload.roles = Array.from(baseRoles); + if (!payload.icon) delete payload.icon; + const response = await authInstance.post("/user", payload); runInAction(() => { this.users.data.push({ @@ -88,27 +119,75 @@ class UserStore { email: "", password: "", is_admin: false, + icon: "", + roles: [], }; setEditUserData = ( name: string, email: string, password: string, - is_admin: boolean + is_admin: boolean, + icon?: string, ) => { - this.editUserData = { name, email, password, is_admin }; + this.editUserData = { + ...this.editUserData, + name, + email, + password, + is_admin, + icon: icon ?? "", + }; + }; + + setEditUserRoles = (roles: string[]) => { + this.editUserData = { ...this.editUserData, roles }; }; editUser = async (id: number) => { - const response = await authInstance.patch(`/user/${id}`, this.editUserData); + const payload = { + ...this.editUserData, + name: (this.editUserData.name || "").trim(), + email: (this.editUserData.email || "").trim(), + }; + if (!payload.icon) delete payload.icon; + if (!payload.password?.trim()) delete payload.password; + + const response = await authInstance.patch(`/user/${id}`, payload); runInAction(() => { this.users.data = this.users.data.map((user) => - user.id === id ? { ...user, ...response.data } : user + user.id === id ? { ...user, ...response.data } : user, ); this.user[id] = { ...this.user[id], ...response.data }; }); }; + + addUserCityResult: User | null = null; + addUserCityLoading = false; + addUserCityError: string | null = null; + + addUserCityAction = mobxFetch< + { id: number; city_ids: number[] }, + User, + UserStore + >({ + store: this, + value: "addUserCityResult", + loading: "addUserCityLoading", + error: "addUserCityError", + fn: addUserCityApi, + onSuccess: (result) => { + runInAction(() => { + this.users.data = this.users.data.map((user) => + user.id === result.id ? { ...user, ...result } : user, + ); + if (this.user[result.id]) { + this.user[result.id] = { ...this.user[result.id], ...result }; + } + }); + }, + }); } export const userStore = new UserStore(); diff --git a/src/shared/store/VehicleStore/api.ts b/src/shared/store/VehicleStore/api.ts new file mode 100644 index 0000000..25aa22f --- /dev/null +++ b/src/shared/store/VehicleStore/api.ts @@ -0,0 +1,13 @@ +import { languageInstance } from "@shared"; +import { VehicleMaintenanceSession } from "./types"; + +export const getVehicleSessionsApi = async ( + id: number, + signal?: AbortSignal, +): Promise => { + const response = await languageInstance("ru").get(`/vehicle/${id}/sessions`, { + signal, + }); + + return Array.isArray(response.data) ? response.data : []; +}; diff --git a/src/shared/store/VehicleStore/index.ts b/src/shared/store/VehicleStore/index.ts index 3c9993e..49a424c 100644 --- a/src/shared/store/VehicleStore/index.ts +++ b/src/shared/store/VehicleStore/index.ts @@ -1,24 +1,9 @@ -import { languageInstance } from "@shared"; +import { authInstance, languageInstance, mobxFetch } from "@shared"; import { makeAutoObservable, runInAction } from "mobx"; +import { getVehicleSessionsApi } from "./api"; +import { Vehicle, VehicleMaintenanceSession } from "./types"; -export type Vehicle = { - vehicle: { - id: number; - tail_number: number; - type: number; - carrier_id: number; - carrier: string; - uuid?: string; - }; - device_status?: { - device_uuid: string; - online: boolean; - gps_ok: boolean; - media_service_ok: boolean; - last_update: string; - is_connected: boolean; - }; -}; +export type { Vehicle, VehicleMaintenanceSession } from "./types"; class VehicleStore { vehicles: { @@ -29,16 +14,83 @@ class VehicleStore { loaded: false, }; vehicle: Record = {}; + vehicleSessions: VehicleMaintenanceSession[] | null = null; + vehicleSessionsLoading = false; + vehicleSessionsError: string | null = null; constructor() { makeAutoObservable(this); } + private normalizeVehicleItem = (item: any): Vehicle => { + if (item && typeof item === "object" && "vehicle" in item) { + return { + vehicle: item.vehicle ?? {}, + device_status: item.device_status, + } as Vehicle; + } + + return { + vehicle: item ?? {}, + } as Vehicle; + }; + + private mergeVehicleInCaches = (updatedVehicle: any) => { + if (!updatedVehicle) return; + + const updatedId = updatedVehicle.id; + const updatedUuid = updatedVehicle.uuid; + + const mergeItem = (item: Vehicle): Vehicle => ({ + ...item, + vehicle: { + ...item.vehicle, + ...updatedVehicle, + }, + }); + + this.vehicles.data = this.vehicles.data.map((item) => { + const sameId = updatedId != null && item.vehicle.id === updatedId; + const sameUuid = + updatedUuid != null && + item.vehicle.uuid != null && + item.vehicle.uuid === updatedUuid; + + if (!sameId && !sameUuid) return item; + + return mergeItem(item); + }); + + if (updatedId != null) { + const existing = this.vehicle[updatedId]; + this.vehicle[updatedId] = existing + ? mergeItem(existing) + : ({ vehicle: updatedVehicle } as Vehicle); + return; + } + + if (updatedUuid != null) { + const entry = Object.entries(this.vehicle).find( + ([, item]) => item.vehicle.uuid === updatedUuid, + ); + + if (entry) { + const [key, item] = entry; + this.vehicle[key] = mergeItem(item); + } + } + }; + getVehicles = async () => { const response = await languageInstance("ru").get(`/vehicle`); + const vehiclesList = Array.isArray(response.data) + ? response.data + : Array.isArray(response.data?.vehicles) + ? response.data.vehicles + : []; runInAction(() => { - this.vehicles.data = response.data; + this.vehicles.data = vehiclesList.map(this.normalizeVehicleItem); this.vehicles.loaded = true; }); }; @@ -48,63 +100,74 @@ class VehicleStore { runInAction(() => { this.vehicles.data = this.vehicles.data.filter( - (vehicle) => vehicle.vehicle.id !== id + (vehicle) => vehicle.vehicle.id !== id, ); }); }; getVehicle = async (id: number) => { const response = await languageInstance("ru").get(`/vehicle/${id}`); + const normalizedVehicle = this.normalizeVehicleItem(response.data); runInAction(() => { - this.vehicle[id] = response.data; + this.vehicle[id] = normalizedVehicle; }); }; createVehicle = async ( - tailNumber: number, + tailNumber: string, type: number, carrier: string, - carrierId: number + carrierId: number, + model?: string, + cityId?: number, ) => { - const response = await languageInstance("ru").post("/vehicle", { - tail_number: tailNumber, + const payload: Record = { + tail_number: tailNumber.trim(), type, - carrier, + carrier: carrier.trim(), carrier_id: carrierId, - }); + }; + // TODO: когда будет бекенд — добавить model в payload и в ответ + if (model != null && model !== "") payload.model = model; + if (cityId != null) payload.city_id = cityId; + const response = await languageInstance("ru").post("/vehicle", payload); + const normalizedVehicle = this.normalizeVehicleItem(response.data); runInAction(() => { - this.vehicles.data.push({ - vehicle: { - id: response.data.id, - tail_number: response.data.tail_number, - type: response.data.type, - carrier_id: response.data.carrier_id, - carrier: response.data.carrier, - uuid: response.data.uuid, - }, - }); + this.vehicles.data.push(normalizedVehicle); + if (normalizedVehicle.vehicle?.id != null) { + this.vehicle[normalizedVehicle.vehicle.id] = normalizedVehicle; + } }); }; editVehicleData: { - tail_number: number; + tail_number: string; type: number; carrier: string; carrier_id: number; + model: string; + snapshot_update_blocked: boolean; + city_id?: number; } = { - tail_number: 0, + tail_number: "", type: 0, carrier: "", carrier_id: 0, + model: "", + snapshot_update_blocked: false, + city_id: undefined, }; setEditVehicleData = (data: { - tail_number: number; + tail_number: string; type: number; carrier: string; carrier_id: number; + model?: string; + snapshot_update_blocked?: boolean; + city_id?: number; }) => { this.editVehicleData = { ...this.editVehicleData, @@ -115,36 +178,93 @@ class VehicleStore { editVehicle = async ( id: number, data: { - tail_number: number; + tail_number: string; type: number; carrier: string; carrier_id: number; - } + model?: string; + snapshot_update_blocked?: boolean; + city_id?: number; + }, ) => { - const response = await languageInstance("ru").patch(`/vehicle/${id}`, { - tail_number: data.tail_number, + const payload: Record = { + tail_number: data.tail_number.trim(), type: data.type, - carrier: data.carrier, + carrier: data.carrier.trim(), carrier_id: data.carrier_id, - }); + }; + if (data.model != null && data.model !== "") payload.model = data.model; + if (data.snapshot_update_blocked != null) + payload.snapshot_update_blocked = data.snapshot_update_blocked; + if (data.city_id != null) payload.city_id = data.city_id; + const response = await languageInstance("ru").patch( + `/vehicle/${id}`, + payload, + ); + const normalizedVehicle = this.normalizeVehicleItem(response.data); + const updatedVehiclePayload = { + ...normalizedVehicle.vehicle, + model: normalizedVehicle.vehicle.model ?? data.model, + snapshot_update_blocked: + normalizedVehicle.vehicle.snapshot_update_blocked ?? + data.snapshot_update_blocked, + }; runInAction(() => { - this.vehicle[id] = { - vehicle: { - ...this.vehicle[id].vehicle, - ...response.data, - }, - }; - this.vehicles.data = this.vehicles.data.map((vehicle) => - vehicle.vehicle.id === id - ? { - ...vehicle, - ...response.data, - } - : vehicle - ); + this.mergeVehicleInCaches({ + ...updatedVehiclePayload, + id, + }); }); }; + + setMaintenanceMode = async (uuid: string, enabled: boolean) => { + const response = await authInstance.post( + `/devices/${uuid}/maintenance-mode`, + { + enabled, + }, + ); + const normalizedVehicle = this.normalizeVehicleItem(response.data); + + runInAction(() => { + this.mergeVehicleInCaches({ + ...normalizedVehicle.vehicle, + uuid, + maintenance_mode_on: + normalizedVehicle.vehicle.maintenance_mode_on ?? enabled, + }); + }); + }; + + setDemoMode = async (uuid: string, enabled: boolean) => { + const response = await authInstance.post(`/devices/${uuid}/demo-mode`, { + enabled, + }); + const normalizedVehicle = this.normalizeVehicleItem(response.data); + + runInAction(() => { + this.mergeVehicleInCaches({ + ...normalizedVehicle.vehicle, + uuid, + demo_mode_enabled: + normalizedVehicle.vehicle.demo_mode_enabled ?? enabled, + }); + }); + }; + + getVehicleSessions = mobxFetch< + number, + VehicleMaintenanceSession[], + VehicleStore + >({ + store: this, + value: "vehicleSessions", + loading: "vehicleSessionsLoading", + error: "vehicleSessionsError", + resetValue: true, + fn: getVehicleSessionsApi, + }); } export const vehicleStore = new VehicleStore(); diff --git a/src/shared/store/VehicleStore/types.ts b/src/shared/store/VehicleStore/types.ts new file mode 100644 index 0000000..11bd39e --- /dev/null +++ b/src/shared/store/VehicleStore/types.ts @@ -0,0 +1,33 @@ +export type Vehicle = { + vehicle: { + id: number; + tail_number: string; + type: number; + carrier_id: number; + carrier: string; + uuid?: string; + model?: string; + current_snapshot_uuid?: string; + snapshot_update_blocked?: boolean; + demo_mode_enabled?: boolean; + maintenance_mode_on?: boolean; + city_id?: number; + }; + device_status?: { + device_uuid: string; + online: boolean; + gps_ok: boolean; + media_service_ok: boolean; + last_update: string; + is_connected: boolean; + current_route_id?: number; + }; +}; + +export type VehicleMaintenanceSession = { + duration_seconds: number; + ended_at: string; + id: number; + started_at: string; + vehicle_id: number; +}; diff --git a/src/shared/store/index.ts b/src/shared/store/index.ts index 41ddbc4..29defa5 100644 --- a/src/shared/store/index.ts +++ b/src/shared/store/index.ts @@ -16,3 +16,4 @@ export * from "./CarrierStore"; export * from "./StationsStore"; export * from "./MenuStore"; export * from "./SelectedCityStore"; +export * from "./TestingModeStore"; diff --git a/src/shared/ui/AnimatedCircleButton.tsx b/src/shared/ui/AnimatedCircleButton.tsx new file mode 100644 index 0000000..b6bee0f --- /dev/null +++ b/src/shared/ui/AnimatedCircleButton.tsx @@ -0,0 +1,171 @@ +import { forwardRef } from "react"; +import { Button, ButtonProps, CircularProgress } from "@mui/material"; +import { alpha, keyframes, styled } from "@mui/material/styles"; +import type { Theme } from "@mui/material/styles"; + +type AnimatedCircleButtonProps = ButtonProps & { + disableAnimation?: boolean; + loading?: boolean; +}; + +type StyledButtonProps = AnimatedCircleButtonProps & { theme: Theme }; + +const loadingPulse = keyframes` + 0% { + transform: translate(-50%, -50%) scale(0.6); + opacity: 0.35; + } + 50% { + transform: translate(-50%, -50%) scale(1.45); + opacity: 0.15; + } + 100% { + transform: translate(-50%, -50%) scale(0.6); + opacity: 0; + } +`; + +const StyledButton = styled(Button, { + shouldForwardProp: (prop) => + prop !== "disableAnimation" && prop !== "loading", +})((props: StyledButtonProps) => { + const { + theme, + disableAnimation = false, + color, + variant = "text", + disabled = false, + loading = false, + } = props; + + const shouldAnimate = !disableAnimation && (!disabled || loading); + const pointerBlocked = loading; + + const paletteMainMap: Record = { + primary: theme.palette.primary.main, + secondary: theme.palette.secondary.main, + error: theme.palette.error.main, + warning: theme.palette.warning.main, + info: theme.palette.info.main, + success: theme.palette.success.main, + inherit: theme.palette.primary.main, + }; + + const paletteMain = + (color && paletteMainMap[String(color)]) ?? theme.palette.primary.main; + + const pulseColor = + variant === "outlined" || variant === "text" + ? alpha(paletteMain, 0.18) + : alpha(paletteMain, 0.3); + + return { + position: "relative", + overflow: "hidden", + borderRadius: 5, + zIndex: 0, + transition: "transform 0.2s ease, box-shadow 0.2s ease", + pointerEvents: pointerBlocked ? "none" : undefined, + "&::after": shouldAnimate + ? { + content: '""', + position: "absolute", + width: "12px", + height: "12px", + backgroundColor: pulseColor, + borderRadius: "50%", + top: "50%", + left: "50%", + pointerEvents: "none", + zIndex: 0, + ...(loading + ? { + opacity: 0.35, + transform: "translate(-50%, -50%) scale(0.6)", + animation: `${loadingPulse} 1.2s ease-in-out infinite`, + } + : { + opacity: 0, + transform: "translate(-50%, -50%) scale(0)", + transition: "transform 0.45s ease, opacity 0.45s ease", + }), + } + : {}, + ...(loading + ? {} + : { + "&:hover": { + transform: "translateY(-1px)", + boxShadow: theme.shadows[4], + }, + "&:hover::after": shouldAnimate + ? { + transform: "translate(-50%, -50%) scale(15)", + opacity: 1, + } + : {}, + "&:active": { + transform: "translateY(0)", + boxShadow: theme.shadows[2], + }, + "&:active::after": shouldAnimate + ? { + transform: "translate(-50%, -50%) scale(18)", + opacity: 0.4, + } + : {}, + }), + "&.Mui-disabled": { + boxShadow: "none", + transform: "none", + ...(loading && shouldAnimate + ? {} + : { + "&::after": { + opacity: 0, + }, + }), + }, + ...(disabled && { + boxShadow: "none", + transform: "none", + }), + "& > *": { + position: "relative", + zIndex: 1, + }, + }; +}); + +export const AnimatedCircleButton = forwardRef< + HTMLButtonElement, + AnimatedCircleButtonProps +>((props, ref) => { + const { + loading = false, + disabled, + children, + startIcon, + endIcon, + ...rest + } = props; + + const effectiveStartIcon = loading ? ( + + ) : ( + startIcon + ); + + return ( + + {children} + + ); +}); diff --git a/src/shared/ui/Modal/index.tsx b/src/shared/ui/Modal/index.tsx index c77d021..80436d2 100644 --- a/src/shared/ui/Modal/index.tsx +++ b/src/shared/ui/Modal/index.tsx @@ -1,10 +1,11 @@ -import { Modal as MuiModal, Typography, Box } from "@mui/material"; +import { Modal as MuiModal, Typography, Box, SxProps, Theme } from "@mui/material"; interface ModalProps { open: boolean; onClose: () => void; children: React.ReactNode; title?: string; + sx?: SxProps; } const style = { @@ -19,7 +20,7 @@ const style = { borderRadius: 2, }; -export const Modal = ({ open, onClose, children, title }: ModalProps) => { +export const Modal = ({ open, onClose, children, title, sx }: ModalProps) => { return ( { aria-labelledby="modal-modal-title" aria-describedby="modal-modal-description" > - + {title && ( { + readonly value: TValue; + readonly label: string; +} + +interface MultiSelectProps { + readonly options: MultiSelectOption[]; + readonly value: TValue[]; + readonly onChange: (values: TValue[]) => void; + readonly label?: string; + readonly placeholder?: string; + readonly loading?: boolean; + readonly disabled?: boolean; + readonly error?: boolean; + readonly helperText?: string; + readonly size?: "small" | "medium"; + readonly fullWidth?: boolean; +} + +export function MultiSelect({ + options, + value, + onChange, + label, + placeholder, + loading = false, + disabled = false, + error = false, + helperText, + size = "small", + fullWidth = true, +}: MultiSelectProps) { + const selectedOptions = options.filter((opt) => value.includes(opt.value)); + + return ( + option.label} + isOptionEqualToValue={(option, selected) => option.value === selected.value} + onChange={(_, newSelected) => { + onChange(newSelected.map((opt) => opt.value)); + }} + renderOption={(props, option, { selected }) => { + const { key, ...rest } = props as React.HTMLAttributes & { key: React.Key }; + return ( +
  • + } + checkedIcon={} + style={{ marginRight: 8 }} + checked={selected} + /> + {option.label} +
  • + ); + }} + renderInput={(params) => ( + + {loading && } + {params.InputProps.endAdornment} + + ), + }, + }} + /> + )} + /> + ); +} diff --git a/src/shared/ui/PreviewBackgroundToggle/index.tsx b/src/shared/ui/PreviewBackgroundToggle/index.tsx new file mode 100644 index 0000000..864dae0 --- /dev/null +++ b/src/shared/ui/PreviewBackgroundToggle/index.tsx @@ -0,0 +1,40 @@ +import { IconButton, Tooltip } from "@mui/material"; +import { Contrast } from "lucide-react"; + +interface PreviewBackgroundToggleProps { + dark: boolean; + onToggle: () => void; +} + +export const PreviewBackgroundToggle = ({ + dark, + onToggle, +}: PreviewBackgroundToggleProps) => ( + + { + event.stopPropagation(); + onToggle(); + }} + sx={{ + position: "absolute", + top: 8, + left: 8, + zIndex: 2, + color: dark ? "#fff" : "#000", + backgroundColor: dark + ? "rgba(255, 255, 255, 0.16)" + : "rgba(255, 255, 255, 0.8)", + "&:hover": { + backgroundColor: dark + ? "rgba(255, 255, 255, 0.28)" + : "rgba(255, 255, 255, 1)", + }, + }} + > + + + +); diff --git a/src/shared/ui/SearchInput/index.tsx b/src/shared/ui/SearchInput/index.tsx new file mode 100644 index 0000000..fa6f5b3 --- /dev/null +++ b/src/shared/ui/SearchInput/index.tsx @@ -0,0 +1,37 @@ +import { Search, X } from "lucide-react"; + +interface SearchInputProps { + value: string; + onChange: (value: string) => void; + placeholder?: string; +} + +export const SearchInput = ({ + value, + onChange, + placeholder = "Поиск...", +}: SearchInputProps) => { + return ( +
    + + onChange(e.target.value)} + placeholder={placeholder} + className="w-full pl-9 pr-8 py-2 border border-gray-300 rounded-md text-sm outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500 bg-white" + /> + {value && ( + + )} +
    + ); +}; diff --git a/src/shared/ui/index.ts b/src/shared/ui/index.ts index 584c712..aef6a71 100644 --- a/src/shared/ui/index.ts +++ b/src/shared/ui/index.ts @@ -2,3 +2,8 @@ export * from "./TabPanel"; export * from "./BackButton"; export * from "./Modal"; export * from "./CoordinatesInput"; +export * from "./AnimatedCircleButton"; +export * from "./LoadingSpinner"; +export * from "./MultiSelect"; +export * from "./SearchInput"; +export * from "./PreviewBackgroundToggle"; diff --git a/src/vite-env.d.ts b/src/vite-env.d.ts index 11f02fe..d5337f4 100644 --- a/src/vite-env.d.ts +++ b/src/vite-env.d.ts @@ -1 +1,2 @@ /// +declare const __APP_VERSION__: string; diff --git a/src/widgets/CitySelector/index.tsx b/src/widgets/CitySelector/index.tsx index 6ac2771..6401149 100644 --- a/src/widgets/CitySelector/index.tsx +++ b/src/widgets/CitySelector/index.tsx @@ -6,71 +6,140 @@ import { SelectChangeEvent, Typography, Box, + keyframes, } from "@mui/material"; import { observer } from "mobx-react-lite"; -import { cityStore, selectedCityStore } from "@shared"; +import { authStore, cityStore, selectedCityStore, snapshotStore, type City } from "@shared"; import { MapPin } from "lucide-react"; +const borderSpin = keyframes` + 0% { + background-position: 0% 50%; + } + 100% { + background-position: 200% 50%; + } +`; + export const CitySelector: React.FC = observer(() => { - const { getCities, cities } = cityStore; - const { selectedCity, setSelectedCity } = selectedCityStore; + const { selectedCity, setSelectedCity, isLocked } = selectedCityStore; + const canLoadAllCities = authStore.isAdmin && authStore.canRead("cities"); useEffect(() => { - getCities("ru"); - }, []); + if (canLoadAllCities) { + cityStore.getCities("ru"); + return; + } + authStore.fetchMeCities().catch(() => undefined); + }, [canLoadAllCities]); + + const baseCities: City[] = canLoadAllCities + ? cityStore.cities["ru"].data + : authStore.meCities["ru"].map((uc) => ({ + id: uc.city_id, + name: uc.name, + country: "", + country_code: "", + arms: "", + })); + + const currentCities: City[] = selectedCity?.id + ? (() => { + const exists = baseCities.some((city) => city.id === selectedCity.id); + if (exists) { + return baseCities; + } + return [selectedCity, ...baseCities]; + })() + : baseCities; + + const noCitySelected = !selectedCity?.id; const handleCityChange = (event: SelectChangeEvent) => { const cityId = event.target.value; if (cityId === "") { + snapshotStore.clearStoreCache(); setSelectedCity(null); return; } - const city = cities["ru"].data.find((c) => c.id === Number(cityId)); + const city = currentCities.find((c) => c.id === Number(cityId)); if (city) { + snapshotStore.clearStoreCache(); setSelectedCity(city); } }; - const currentCities = cities["ru"].data; + const selectElement = ( + + ); return ( - + - + {noCitySelected && !isLocked ? ( + + {selectElement} + + ) : ( + selectElement + )} ); diff --git a/src/widgets/CreateButton/index.tsx b/src/widgets/CreateButton/index.tsx index b3b5011..09d3e52 100644 --- a/src/widgets/CreateButton/index.tsx +++ b/src/widgets/CreateButton/index.tsx @@ -5,9 +5,10 @@ import { useNavigate } from "react-router-dom"; interface CreateButtonProps { label: string; path: string; + disabled?: boolean; } -export const CreateButton = ({ label, path }: CreateButtonProps) => { +export const CreateButton = ({ label, path, disabled }: CreateButtonProps) => { const navigate = useNavigate(); return ( @@ -18,6 +19,7 @@ export const CreateButton = ({ label, path }: CreateButtonProps) => { navigate(path); }} startIcon={} + disabled={disabled} > {label} diff --git a/src/widgets/DevicesTable/DeviceLogsModal.tsx b/src/widgets/DevicesTable/DeviceLogsModal.tsx new file mode 100644 index 0000000..bb2349e --- /dev/null +++ b/src/widgets/DevicesTable/DeviceLogsModal.tsx @@ -0,0 +1,403 @@ +import { API_URL, authInstance, Modal } from "@shared"; +import { Button, CircularProgress, TextField } from "@mui/material"; +import { useEffect, useState, useMemo } from "react"; +import { toast } from "react-toastify"; + +interface DeviceLogChunk { + date?: string; + lines?: string[]; +} + +interface DeviceLogsModalProps { + open: boolean; + deviceUuid: string | null; + onClose: () => void; +} + +const toYYYYMMDD = (d: Date) => d.toISOString().slice(0, 10); +const shiftYYYYMMDD = (value: string, days: number) => { + const d = new Date(`${value}T00:00:00Z`); + if (Number.isNaN(d.getTime())) return value; + d.setUTCDate(d.getUTCDate() + days); + return toYYYYMMDD(d); +}; + +type LogLevel = "info" | "warn" | "error" | "debug" | "fatal" | "unknown"; + +const LOG_LEVEL_STYLES: Record< + LogLevel, + { badge: string; text: string; bg: string; color: string; borderColor: string } +> = { + info: { + badge: "bg-blue-100 text-blue-700", + text: "text-[#000000BF]", + bg: "#DBEAFE", + color: "#1D4ED8", + borderColor: "#93C5FD", + }, + debug: { + badge: "bg-gray-100 text-gray-600", + text: "text-gray-600", + bg: "#F3F4F6", + color: "#4B5563", + borderColor: "#D1D5DB", + }, + warn: { + badge: "bg-amber-100 text-amber-700", + text: "text-amber-800", + bg: "#FEF3C7", + color: "#B45309", + borderColor: "#FCD34D", + }, + error: { + badge: "bg-red-100 text-red-700", + text: "text-red-700", + bg: "#FEE2E2", + color: "#B91C1C", + borderColor: "#FCA5A5", + }, + fatal: { + badge: "bg-red-200 text-red-900", + text: "text-red-900 font-semibold", + bg: "#FECACA", + color: "#7F1D1D", + borderColor: "#F87171", + }, + unknown: { + badge: "bg-gray-100 text-gray-500", + text: "text-[#000000BF]", + bg: "#F3F4F6", + color: "#6B7280", + borderColor: "#D1D5DB", + }, +}; + +const formatTs = (raw: string): string => { + try { + const d = new Date(raw); + if (Number.isNaN(d.getTime())) return raw; + const pad = (n: number) => String(n).padStart(2, "0"); + return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`; + } catch { + return raw; + } +}; + +const parseJsonLogLine = (line: string) => { + try { + const obj = JSON.parse(line); + if (obj && typeof obj === "object") { + const level: LogLevel = + obj.level && obj.level in LOG_LEVEL_STYLES + ? (obj.level as LogLevel) + : "unknown"; + const ts: string = obj.ts ? formatTs(obj.ts) : ""; + const msg: string = obj.msg ?? ""; + + const extra: Record = { ...obj }; + delete extra.level; + delete extra.ts; + delete extra.msg; + delete extra.caller; + const extraStr = Object.keys(extra).length + ? " " + + Object.entries(extra) + .map(([k, v]) => `${k}=${typeof v === "string" ? v : JSON.stringify(v)}`) + .join(" ") + : ""; + + return { ts, level, msg, extraStr }; + } + } catch { + return null; + } + return null; +}; + +const parseLogLine = (line: string, index: number) => { + const json = parseJsonLogLine(line); + if (json) { + return { + id: index, + time: json.ts, + text: json.msg + json.extraStr, + level: json.level, + sortKey: json.ts, + }; + } + + const bracketMatch = line.match(/^(\[[^\]]+\])\s*(.*)$/); + if (bracketMatch) { + const rawTime = bracketMatch[1].replace(/^\[|\]$/g, "").trim(); + return { + id: index, + time: formatTs(rawTime), + text: bracketMatch[2].trim() || line, + level: "unknown" as LogLevel, + sortKey: rawTime, + }; + } + + return { + id: index, + time: "", + text: line, + level: "unknown" as LogLevel, + sortKey: "", + }; +}; + +export const DeviceLogsModal = ({ + open, + deviceUuid, + onClose, +}: DeviceLogsModalProps) => { + const [chunks, setChunks] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const today = new Date(); + const yesterday = new Date(today.getTime() - 24 * 60 * 60 * 1000); + const [dateFrom, setDateFrom] = useState(toYYYYMMDD(yesterday)); + const [dateTo, setDateTo] = useState(toYYYYMMDD(today)); + + const ALL_LEVELS: LogLevel[] = ["debug", "info", "warn", "error", "fatal"]; + const [activeLevels, setActiveLevels] = useState>( + new Set(ALL_LEVELS) + ); + + const toggleLevel = (level: LogLevel) => { + setActiveLevels((prev) => { + const next = new Set(prev); + if (next.has(level)) { + next.delete(level); + } else { + next.add(level); + } + return next; + }); + }; + const dateToMin = shiftYYYYMMDD(dateFrom, 1); + const dateFromMax = shiftYYYYMMDD(dateTo, -1); + + const handleDateFromChange = (value: string) => { + setDateFrom(value); + if (!dateTo || dateTo <= value) { + setDateTo(shiftYYYYMMDD(value, 1)); + } + }; + + const handleDateToChange = (value: string) => { + if (value <= dateFrom) { + toast.info("Дата 'До' должна быть позже даты 'От'"); + setDateTo(shiftYYYYMMDD(dateFrom, 1)); + return; + } + setDateTo(value); + }; + + useEffect(() => { + if (!open || !deviceUuid) return; + + const fetchLogs = async () => { + setIsLoading(true); + setError(null); + try { + const { data } = await authInstance.get( + `${API_URL}/devices/${deviceUuid}/logs`, + { + params: { + from: dateFrom, + to: toYYYYMMDD(new Date(new Date(dateTo).getTime() + 86400000)), + }, + } + ); + setChunks(Array.isArray(data) ? data : []); + } catch (err: unknown) { + const msg = + err && typeof err === "object" && "message" in err + ? String((err as { message?: string }).message) + : "Ошибка загрузки логов"; + setError(msg); + toast.error(msg); + } finally { + setIsLoading(false); + } + }; + + fetchLogs(); + }, [open, deviceUuid, dateFrom, dateTo]); + + const logs = useMemo(() => { + const parsed = chunks.flatMap((chunk, chunkIdx) => + (chunk.lines ?? []).map((line, i) => + parseLogLine(line, chunkIdx * 10000 + i) + ) + ); + parsed.sort((a, b) => { + if (!a.sortKey && !b.sortKey) return 0; + if (!a.sortKey) return 1; + if (!b.sortKey) return -1; + return b.sortKey.localeCompare(a.sortKey); + }); + return parsed; + }, [chunks]); + + const filteredLogs = useMemo( + () => logs.filter((log) => activeLevels.has(log.level)), + [logs, activeLevels] + ); + + const logsText = useMemo( + () => + filteredLogs + .map((log) => { + const level = log.level === "unknown" ? "LOG" : log.level.toUpperCase(); + const time = log.time ? `[${log.time}] ` : ""; + return `${time}${level}: ${log.text}`; + }) + .join("\n"), + [filteredLogs] + ); + + const handleDownloadLogs = () => { + if (!logsText) { + toast.info("Нет логов для сохранения"); + return; + } + + try { + const safeDeviceUuid = (deviceUuid ?? "device").replace( + /[^a-zA-Z0-9_-]/g, + "_" + ); + const fileName = `logs_${safeDeviceUuid}_${dateFrom}_${dateTo}.txt`; + const blob = new Blob([`\uFEFF${logsText}`], { + type: "text/plain;charset=utf-8", + }); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + + link.href = url; + link.download = fileName; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + URL.revokeObjectURL(url); + + toast.success("Логи сохранены в .txt"); + } catch { + toast.error("Не удалось сохранить логи"); + } + }; + + return ( + +
    +
    +

    Логи

    +
    + {ALL_LEVELS.map((level) => { + const active = activeLevels.has(level); + const s = LOG_LEVEL_STYLES[level]; + return ( + + ); + })} +
    +
    + handleDateFromChange(e.target.value)} + slotProps={{ + inputLabel: { shrink: true }, + htmlInput: { max: dateFromMax }, + }} + /> + handleDateToChange(e.target.value)} + slotProps={{ + inputLabel: { shrink: true }, + htmlInput: { min: dateToMin }, + }} + /> + +
    +
    + +
    + {isLoading && ( +
    + +
    + )} + + {!isLoading && error && ( +
    + {error} +
    + )} + + {!isLoading && !error && ( +
    +
    + {filteredLogs.length > 0 ? ( + filteredLogs.map((log) => { + const style = LOG_LEVEL_STYLES[log.level]; + return ( +
    + + {log.level === "unknown" ? "LOG" : log.level} + + + {log.time || null} + + {log.text} +
    + ); + }) + ) : ( +
    + Логи отсутствуют. +
    + )} +
    +
    + )} +
    +
    +
    + ); +}; diff --git a/src/widgets/DevicesTable/VehicleSessionsModal.tsx b/src/widgets/DevicesTable/VehicleSessionsModal.tsx new file mode 100644 index 0000000..901a075 --- /dev/null +++ b/src/widgets/DevicesTable/VehicleSessionsModal.tsx @@ -0,0 +1,180 @@ +import { Modal, vehicleStore } from "@shared"; +import { + Box, + Button, + CircularProgress, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Typography, +} from "@mui/material"; +import { observer } from "mobx-react-lite"; +import { useEffect } from "react"; + +interface VehicleSessionsModalProps { + open: boolean; + vehicleId: number | null; + tailNumber?: string | null; + onClose: () => void; +} + +const formatDateTime = (value: string) => { + if (!value) return "-"; + + const date = new Date(value); + if (Number.isNaN(date.getTime())) return value; + + return new Intl.DateTimeFormat("ru-RU", { + day: "2-digit", + month: "2-digit", + year: "numeric", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hour12: false, + }).format(date); +}; + +const formatDuration = (durationSeconds: number) => { + if (!Number.isFinite(durationSeconds) || durationSeconds < 0) { + return "-"; + } + + const hours = Math.floor(durationSeconds / 3600); + const minutes = Math.floor((durationSeconds % 3600) / 60); + const seconds = durationSeconds % 60; + + return [hours, minutes, seconds] + .map((part) => String(part).padStart(2, "0")) + .join(":"); +}; + +export const VehicleSessionsModal = observer( + ({ open, vehicleId, tailNumber, onClose }: VehicleSessionsModalProps) => { + const { + vehicleSessions, + vehicleSessionsLoading, + vehicleSessionsError, + getVehicleSessions, + } = vehicleStore; + + useEffect(() => { + if (!open || vehicleId == null) return; + + getVehicleSessions(vehicleId).catch(() => undefined); + }, [open, vehicleId, getVehicleSessions]); + + const title = + tailNumber && tailNumber !== "" + ? `Сессии ТО: ${tailNumber}` + : "Сессии ТО"; + + return ( + +
    + + {title} + + + + {vehicleSessionsLoading && ( + + + + )} + + {!vehicleSessionsLoading && vehicleSessionsError && ( + + {vehicleSessionsError} + + )} + + {!vehicleSessionsLoading && + !vehicleSessionsError && + vehicleSessions && + vehicleSessions.length === 0 && ( + + По этому транспорту нет сессий ТО. + + )} + + {!vehicleSessionsLoading && + !vehicleSessionsError && + vehicleSessions && + vehicleSessions.length > 0 && ( + + + + + ID + Начало + Окончание + Длительность + + + + {vehicleSessions.map((session) => ( + + {session.id} + + {formatDateTime(session.started_at)} + + + {formatDateTime(session.ended_at)} + + + {formatDuration(session.duration_seconds)} + + + ))} + +
    +
    + )} +
    + + +
    +
    + ); + }, +); diff --git a/src/widgets/DevicesTable/index.tsx b/src/widgets/DevicesTable/index.tsx index 5cfbcc9..d0a3008 100644 --- a/src/widgets/DevicesTable/index.tsx +++ b/src/widgets/DevicesTable/index.tsx @@ -1,34 +1,53 @@ -import Table from "@mui/material/Table"; -import TableBody from "@mui/material/TableBody"; -import TableCell from "@mui/material/TableCell"; -import TableContainer from "@mui/material/TableContainer"; -import TableHead from "@mui/material/TableHead"; -import TableRow from "@mui/material/TableRow"; -import Paper from "@mui/material/Paper"; -import { Check, Copy, RotateCcw, X } from "lucide-react"; +import { DataGrid, GridColDef, GridRenderCellParams } from "@mui/x-data-grid"; +import { ruRU } from "@mui/x-data-grid/locales"; import { + authStore, authInstance, devicesStore, Modal, snapshotStore, - vehicleStore, // Not directly used in this component's rendering logic anymore -} from "@shared"; // Assuming @shared exports these -import { useEffect, useState } from "react"; + vehicleStore, + routeStore, + Vehicle, + selectedCityStore, + menuStore, + VEHICLE_TYPES, +} from "@shared"; +import { useEffect, useMemo, useState } from "react"; import { observer } from "mobx-react-lite"; -import { Button, Checkbox, Typography } from "@mui/material"; -import { Vehicle } from "@shared"; +import { + Check, + ChevronDown, + ChevronRight, + Copy, + Pencil, + RotateCcw, + ScrollText, + Trash2, + Wrench, + X, +} from "lucide-react"; +import { + Button, + Box, + CircularProgress, + Typography, + Checkbox, + Tooltip, +} from "@mui/material"; import { toast } from "react-toastify"; import { useNavigate } from "react-router-dom"; +import { DeleteModal } from "@widgets"; +import { DeviceLogsModal } from "./DeviceLogsModal"; +import { VehicleSessionsModal } from "./VehicleSessionsModal"; export type ConnectedDevice = string; interface Snapshot { - ID: string; // Assuming ID is string based on usage + ID: string; Name: string; - // Add other snapshot properties if needed } -// --- HELPER FUNCTIONS --- const formatDate = (dateString: string | null) => { if (!dateString) return "Нет данных"; try { @@ -42,437 +61,1055 @@ const formatDate = (dateString: string | null) => { second: "2-digit", hour12: false, }).format(date); - } catch (error) { + } catch { return "Некорректная дата"; } }; -type TableRowData = { - tail_number: number; +type RowData = { + id: number; + vehicle_id: number; + type: string; + model: string; + tail_number: string; online: boolean; lastUpdate: string | null; gps: boolean; media: boolean; connection: boolean; device_uuid: string | null; + current_snapshot_uuid: string | null; + snapshot_update_blocked: boolean; + maintenance_mode_on: boolean; + demo_mode_enabled: boolean; + current_route_id: number | null; }; -function createData( - tail_number: number, - online: boolean, - lastUpdate: string | null, - gps: boolean, - media: boolean, - connection: boolean, - device_uuid: string | null -): TableRowData { - return { - tail_number, - online, - lastUpdate, - gps, - media, - connection, - device_uuid, - }; + +type PendingModeToggle = { + deviceUuid: string; + nextEnabled: boolean; + tailNumber: string; +}; + +function getVehicleTypeLabel(vehicle: Vehicle): string { + return ( + VEHICLE_TYPES.find((t) => t.value === vehicle.vehicle.type)?.label ?? "—" + ); } -// This function transforms the raw device data (which includes vehicle and device_status) -// into the format expected by the table. It now filters for devices that have a UUID. -const transformDevicesToRows = ( - vehicles: Vehicle[] - // devices: ConnectedDevice[] -): TableRowData[] => { +const transformToRows = (vehicles: Vehicle[]): RowData[] => { return vehicles.map((vehicle) => { const uuid = vehicle.vehicle.uuid; - if (!uuid) - return { - tail_number: vehicle.vehicle.tail_number, - online: false, - lastUpdate: null, - gps: false, - media: false, - connection: false, - device_uuid: null, - }; - return createData( - vehicle.vehicle.tail_number, - vehicle.device_status?.online ?? false, - vehicle.device_status?.last_update ?? null, - vehicle.device_status?.gps_ok ?? false, - vehicle.device_status?.media_service_ok ?? false, - vehicle.device_status?.is_connected ?? false, - uuid - ); + const model = + vehicle.vehicle.model != null && vehicle.vehicle.model !== "" + ? vehicle.vehicle.model + : "—"; + const type = getVehicleTypeLabel(vehicle); + return { + id: vehicle.vehicle.id, + vehicle_id: vehicle.vehicle.id, + type, + model, + tail_number: vehicle.vehicle.tail_number, + online: uuid ? (vehicle.device_status?.online ?? false) : false, + lastUpdate: vehicle.device_status?.last_update ?? null, + gps: uuid ? (vehicle.device_status?.gps_ok ?? false) : false, + media: uuid ? (vehicle.device_status?.media_service_ok ?? false) : false, + connection: uuid ? (vehicle.device_status?.is_connected ?? false) : false, + device_uuid: uuid ?? null, + current_snapshot_uuid: vehicle.vehicle.current_snapshot_uuid ?? null, + snapshot_update_blocked: vehicle.vehicle.snapshot_update_blocked ?? false, + maintenance_mode_on: vehicle.vehicle.maintenance_mode_on ?? false, + demo_mode_enabled: vehicle.vehicle.demo_mode_enabled ?? false, + current_route_id: vehicle.device_status?.current_route_id ?? null, + }; }); }; export const DevicesTable = observer(() => { + const canWriteDevices = authStore.canWrite("devices"); + const canMaintenanceDevices = authStore.hasRole("devices_maintenance_rw"); + const isMaintenanceOnly = canMaintenanceDevices && !canWriteDevices; const { getDevices, setSelectedDevice, sendSnapshotModalOpen, toggleSendSnapshotModal, + devices, } = devicesStore; const { snapshots, getSnapshots } = snapshotStore; - const { getVehicles, vehicles } = vehicleStore; // Removed as devicesStore.devices should be the source of truth - const { devices } = devicesStore; + const { routes, getRoutes } = routeStore; + const { + getVehicles, + vehicles, + deleteVehicle, + setMaintenanceMode, + setDemoMode, + } = vehicleStore; const navigate = useNavigate(); - const [selectedDeviceUuids, setSelectedDeviceUuids] = useState([]); - - // Transform the raw devices data into rows suitable for the table - // This will also filter out devices without a UUID, as those cannot be acted upon. - const currentTableRows = transformDevicesToRows( - vehicles.data as Vehicle[] - // devices as ConnectedDevice[] + const [selectedIds, setSelectedIds] = useState([]); + const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); + const [logsModalOpen, setLogsModalOpen] = useState(false); + const [logsModalDeviceUuid, setLogsModalDeviceUuid] = useState( + null, ); + const [sessionsModalOpen, setSessionsModalOpen] = useState(false); + const [sessionsModalVehicleId, setSessionsModalVehicleId] = useState< + number | null + >(null); + const [sessionsModalVehicleTailNumber, setSessionsModalVehicleTailNumber] = + useState(null); + const [isLoading, setIsLoading] = useState(false); + const [maintenanceLoadingUuids, setMaintenanceLoadingUuids] = useState< + Set + >(new Set()); + const [demoLoadingUuids, setDemoLoadingUuids] = useState>( + new Set(), + ); + const [maintenanceConfirm, setMaintenanceConfirm] = + useState(null); + const [demoConfirm, setDemoConfirm] = useState( + null, + ); + const [maintenanceConfirmSubmitting, setMaintenanceConfirmSubmitting] = + useState(false); + const [demoConfirmSubmitting, setDemoConfirmSubmitting] = useState(false); + const [collapsedModels, setCollapsedModels] = useState>( + new Set(), + ); + const [paginationModel, setPaginationModel] = useState({ + page: 0, + pageSize: 50, + }); + + const { selectedCityId } = selectedCityStore; + + const filteredVehicles = useMemo((): Vehicle[] => { + if (!selectedCityId) return []; + return (vehicles.data as Vehicle[]).filter( + (vehicle) => vehicle.vehicle.city_id === selectedCityId, + ); + }, [selectedCityId, vehicles.data]); + + const rows = useMemo( + () => transformToRows(filteredVehicles), + [filteredVehicles], + ); + + const groupsByModel = useMemo(() => { + const map = new Map(); + for (const row of rows) { + const list = map.get(row.model) ?? []; + list.push(row); + map.set(row.model, list); + } + return Array.from(map.entries()).map(([model, groupRows]) => ({ + model, + rows: groupRows, + })); + }, [rows]); + + const toggleModelCollapsed = (model: string) => { + setCollapsedModels((prev) => { + const next = new Set(prev); + if (next.has(model)) next.delete(model); + else next.add(model); + return next; + }); + }; + + const selectedDeviceUuids = useMemo(() => { + return rows + .filter((r) => selectedIds.includes(r.id)) + .map((r) => r.device_uuid) + .filter((u): u is string => u != null); + }, [rows, selectedIds]); + + const selectedDeviceUuidsAllowed = useMemo(() => { + return rows + .filter( + (r) => + selectedIds.includes(r.id) && + r.device_uuid != null && + !r.snapshot_update_blocked, + ) + .map((r) => r.device_uuid as string); + }, [rows, selectedIds]); + + const applyMaintenanceMode = async (toggle: PendingModeToggle) => { + setMaintenanceLoadingUuids((prev) => { + const next = new Set(prev); + next.add(toggle.deviceUuid); + return next; + }); + + try { + await setMaintenanceMode(toggle.deviceUuid, toggle.nextEnabled); + await getVehicles(); + await getDevices(); + toast.success( + toggle.nextEnabled + ? "Устройство отправлено на ТО" + : "Режим ТО отключен", + ); + } catch (error) { + console.error( + `Error toggling maintenance mode for ${toggle.deviceUuid}:`, + error, + ); + toast.error("Не удалось изменить режим ТО"); + } finally { + setMaintenanceLoadingUuids((prev) => { + const next = new Set(prev); + next.delete(toggle.deviceUuid); + return next; + }); + } + }; + + const applyDemoMode = async (toggle: PendingModeToggle) => { + setDemoLoadingUuids((prev) => { + const next = new Set(prev); + next.add(toggle.deviceUuid); + return next; + }); + + try { + await setDemoMode(toggle.deviceUuid, toggle.nextEnabled); + await getVehicles(); + await getDevices(); + toast.success( + toggle.nextEnabled ? "Демо-режим включен" : "Демо-режим отключен", + ); + } catch (error) { + console.error( + `Error toggling demo mode for ${toggle.deviceUuid}:`, + error, + ); + toast.error("Не удалось изменить демо-режим"); + } finally { + setDemoLoadingUuids((prev) => { + const next = new Set(prev); + next.delete(toggle.deviceUuid); + return next; + }); + } + }; + + const openMaintenanceConfirm = (row: RowData) => { + if (!row.device_uuid) return; + setMaintenanceConfirm({ + deviceUuid: row.device_uuid, + nextEnabled: !row.maintenance_mode_on, + tailNumber: row.tail_number, + }); + }; + + const openDemoConfirm = (row: RowData) => { + if (!row.device_uuid) return; + setDemoConfirm({ + deviceUuid: row.device_uuid, + nextEnabled: !row.demo_mode_enabled, + tailNumber: row.tail_number, + }); + }; + + const handleConfirmMaintenanceToggle = async () => { + if (!maintenanceConfirm) return; + + setMaintenanceConfirmSubmitting(true); + try { + await applyMaintenanceMode(maintenanceConfirm); + setMaintenanceConfirm(null); + } finally { + setMaintenanceConfirmSubmitting(false); + } + }; + + const handleConfirmDemoToggle = async () => { + if (!demoConfirm) return; + + setDemoConfirmSubmitting(true); + try { + await applyDemoMode(demoConfirm); + setDemoConfirm(null); + } finally { + setDemoConfirmSubmitting(false); + } + }; + + const columns: GridColDef[] = useMemo( + () => [ + { + field: "model", + headerName: "Модель", + flex: 1, + minWidth: 120, + filterable: true, + }, + { + field: "tail_number", + headerName: "Бортовой номер", + flex: 1, + minWidth: 90, + filterable: true, + }, + { + field: "snapshot_update_blocked", + headerName: "Запрет", + width: 90, + align: "center", + headerAlign: "center", + sortable: false, + disableColumnMenu: true, + renderHeader: (params) => ( + + {params.colDef.headerName} + + ), + renderCell: (params: GridRenderCellParams) => { + const rowData = params.row as RowData; + return ( + + + + ); + }, + }, + { + field: "online", + headerName: "Онлайн", + width: menuStore.isOpen ? 90 : undefined, + flex: menuStore.isOpen ? undefined : 1, + align: "center", + headerAlign: "center", + type: "boolean", + filterable: true, + renderHeader: (params) => ( + + {params.colDef.headerName} + + ), + renderCell: (params: GridRenderCellParams) => ( + + {params.value ? ( + + ) : ( + + )} + + ), + }, + { + field: "maintenance_mode_on", + headerName: "Режим ТО", + width: 90, + align: "center", + headerAlign: "center", + type: "boolean", + filterable: true, + renderHeader: (params) => ( + + {params.colDef.headerName} + + ), + renderCell: (params: GridRenderCellParams) => { + const rowData = params.row as RowData; + const isMaintenanceLoading = + !!rowData.device_uuid && + maintenanceLoadingUuids.has(rowData.device_uuid); + + return ( + e.stopPropagation()} + > + openMaintenanceConfirm(rowData)} + /> + + ); + }, + }, + { + field: "demo_mode_enabled", + headerName: "Режим Демо", + width: 120, + align: "center", + headerAlign: "center", + type: "boolean", + filterable: true, + renderHeader: (params) => ( + + {params.colDef.headerName} + + ), + renderCell: (params: GridRenderCellParams) => { + const rowData = params.row as RowData; + const isDemoLoading = + !!rowData.device_uuid && demoLoadingUuids.has(rowData.device_uuid); + + return ( + e.stopPropagation()} + > + openDemoConfirm(rowData)} + /> + + ); + }, + }, + { + field: "lastUpdate", + headerName: "Дата последнего статуса", + flex: 1, + minWidth: 200, + filterable: true, + renderHeader: (params) => ( + + {params.colDef.headerName} + + ), + renderCell: (params: GridRenderCellParams) => + formatDate(params.value as string | null), + }, + { + field: "snapshot_name", + headerName: "Экспорт на устройстве", + flex: menuStore.isOpen ? 1 : 2, + minWidth: 140, + filterable: true, + renderHeader: (params) => ( + + {params.colDef.headerName} + + ), + valueGetter: (_value, row, _apiRef) => { + const rowData = row as RowData; + const uuid = rowData.current_snapshot_uuid; + if (!uuid) return "—"; + const snapshot = (snapshots as Snapshot[]).find((s) => s.ID === uuid); + return snapshot?.Name ?? uuid; + }, + }, + { + field: "current_route", + headerName: "Текущий маршрут", + flex: 1, + minWidth: 140, + filterable: true, + renderHeader: (params) => ( + + {params.colDef.headerName} + + ), + valueGetter: (_value, row) => { + const rowData = row as RowData; + const routeId = rowData.current_route_id; + if (!routeId) return "—"; + const route = routes.data.find((r) => r.id === routeId); + return route?.route_sys_number || "—"; + }, + }, + { + field: "gps", + headerName: "GPS", + width: menuStore.isOpen ? 70 : undefined, + flex: menuStore.isOpen ? undefined : 1, + align: "center", + headerAlign: "center", + type: "boolean", + filterable: true, + renderHeader: (params) => ( + + {params.colDef.headerName} + + ), + renderCell: (params: GridRenderCellParams) => ( + + {params.value ? ( + + ) : ( + + )} + + ), + }, + { + field: "actions", + headerName: "Действия", + width: 160, + align: "center", + headerAlign: "center", + sortable: false, + filterable: false, + renderCell: (params: GridRenderCellParams) => { + const row = params.row as RowData; + + const handleReloadStatus = async () => { + if (!row.device_uuid) return; + setSelectedDevice(row.device_uuid); + try { + await authInstance.post( + `/devices/${row.device_uuid}/request-status`, + ); + await getVehicles(); + await getDevices(); + toast.success("Статус устройства обновлен"); + } catch (error) { + console.error( + `Error requesting status for device ${row.device_uuid}:`, + error, + ); + toast.error("Ошибка сервера"); + } + }; + + return ( + + {!isMaintenanceOnly && ( + <> + {canWriteDevices && ( + + )} + + + + + )} + + + ); + }, + }, + ], + [ + devices, + getDevices, + getVehicles, + navigate, + setSelectedDevice, + snapshots, + setLogsModalDeviceUuid, + setLogsModalOpen, + maintenanceLoadingUuids, + demoLoadingUuids, + openMaintenanceConfirm, + openDemoConfirm, + maintenanceConfirmSubmitting, + demoConfirmSubmitting, + routes, + canWriteDevices, + isMaintenanceOnly, + ], + ); + + const visibleColumns = useMemo(() => { + if (isMaintenanceOnly) { + return columns + .filter((c) => + ["model", "tail_number", "maintenance_mode_on", "actions"].includes(c.field), + ) + .map((c) => ({ ...c, flex: 1, width: undefined, minWidth: undefined })); + } + if (!canWriteDevices) { + return columns.filter( + (c) => c.field !== "maintenance_mode_on" && c.field !== "demo_mode_enabled", + ); + } + return columns; + }, [columns, isMaintenanceOnly, canWriteDevices]); useEffect(() => { const fetchData = async () => { - await getVehicles(); // Not strictly needed if devicesStore.devices is populated correctly by getDevices - await getDevices(); // This should fetch the combined vehicle/device_status data - await getSnapshots(); + setIsLoading(true); + if (isMaintenanceOnly) { + await Promise.all([getVehicles(), getDevices()]); + } else { + await Promise.all([ + getVehicles(), + getDevices(), + getSnapshots(), + getRoutes(), + ]); + } + setIsLoading(false); }; fetchData(); - }, [getDevices, getSnapshots]); // Added dependencies + }, [getDevices, getSnapshots, getVehicles, getRoutes, isMaintenanceOnly, selectedCityStore.cityVersion]); - const isAllSelected = - currentTableRows.length > 0 && - selectedDeviceUuids.length === currentTableRows.length; - - const handleSelectAllDevices = () => { - if (isAllSelected) { - setSelectedDeviceUuids([]); - } else { - // Select all device UUIDs from the *currently visible and selectable* rows - setSelectedDeviceUuids( - currentTableRows.map((row) => row.device_uuid ?? "") - ); - } - }; - - const handleSelectDevice = ( - event: React.ChangeEvent, - deviceUuid: string - ) => { - if (event.target.checked) { - setSelectedDeviceUuids((prevSelected) => [...prevSelected, deviceUuid]); - } else { - setSelectedDeviceUuids((prevSelected) => - prevSelected.filter((uuid) => uuid !== deviceUuid) - ); - } - }; const handleOpenSendSnapshotModal = () => { - if (selectedDeviceUuids.length > 0) { + if (!canWriteDevices) { + return; + } + if (selectedDeviceUuidsAllowed.length > 0) { toggleSendSnapshotModal(); } }; - const handleReloadStatus = async (uuid: string) => { - setSelectedDevice(uuid); // Sets the device in the store, useful for context elsewhere - try { - await authInstance.post(`/devices/${uuid}/request-status`); - await getVehicles(); - await getDevices(); // Refresh devices to show updated status - } catch (error) { - console.error(`Error requesting status for device ${uuid}:`, error); - // Optionally: show a user-facing error message - } - }; - const handleSendSnapshotAction = async (snapshotId: string) => { - if (selectedDeviceUuids.length === 0) return; + if (!canWriteDevices) return; + if (selectedDeviceUuidsAllowed.length === 0) return; + + const blockedCount = + selectedDeviceUuids.length - selectedDeviceUuidsAllowed.length; + if (blockedCount > 0) { + toast.info( + `Экспорт медиа не отправлен на ${blockedCount} устройств (блокировка)`, + ); + } const send = async (deviceUuid: string) => { try { await authInstance.post( `/devices/${deviceUuid}/force-snapshot-update`, - { - snapshot_id: snapshotId, - } + { snapshot_id: snapshotId }, ); - toast.success(`Снапшот отправлен на устройство `); + toast.success("Экспорт медиа отправлен на устройство"); } catch (error) { console.error(`Error sending snapshot to device ${deviceUuid}:`, error); - toast.error(`Не удалось отправить снапшот на устройство`); + toast.error("Не удалось отправить экспорт медиа на устройство"); } }; + + await Promise.allSettled(selectedDeviceUuidsAllowed.map(send)); + await getDevices(); + setSelectedIds([]); + toggleSendSnapshotModal(); + }; + + const handleDeleteVehicles = async () => { + if (selectedIds.length === 0) return; + try { - // Create an array of promises for all snapshot requests - const snapshotPromises = selectedDeviceUuids.map((deviceUuid) => { - return send(deviceUuid); - }); - - // Wait for all promises to settle (either resolve or reject) - await Promise.allSettled(snapshotPromises); - - // After all requests are attempted - await getDevices(); // Refresh the device list - setSelectedDeviceUuids([]); // Clear the selection - toggleSendSnapshotModal(); // Close the modal + await Promise.all(selectedIds.map((id) => deleteVehicle(id))); + await getVehicles(); + await getDevices(); + setSelectedIds([]); + setIsDeleteModalOpen(false); + toast.success(`Удалено устройств: ${selectedIds.length}`); } catch (error) { - // This catch block might not be hit if Promise.allSettled is used, - // as it doesn't reject on individual promise failures. - // Individual errors should be handled if needed within the .map or by checking results. - console.error("Error in snapshot sending process:", error); + console.error("Error deleting vehicles:", error); + toast.error("Ошибка при удалении устройств"); } }; + const createSelectionHandler = (groupRowIds: number[]) => { + const groupIdSet = new Set(groupRowIds); + return (newSelection: { ids: Set } | number[]) => { + let newIds: number[] = []; + if (Array.isArray(newSelection)) { + newIds = newSelection.map((id) => Number(id)); + } else if ( + newSelection && + typeof newSelection === "object" && + "ids" in newSelection + ) { + const idsSet = newSelection.ids as Set; + newIds = Array.from(idsSet) + .map((id) => (typeof id === "string" ? Number.parseInt(id, 10) : id)) + .filter((id) => !Number.isNaN(id)); + } + setSelectedIds((prev) => { + const fromOtherGroups = prev.filter((id) => !groupIdSet.has(id)); + return [...fromOtherGroups, ...newIds]; + }); + }; + }; + return ( <> - -
    - -
    -
    - - -
    - - - - - 0 && - selectedDeviceUuids.length < currentTableRows.length - } - checked={isAllSelected} - onChange={handleSelectAllDevices} - inputProps={{ "aria-label": "select all devices" }} - size="small" - /> - - Борт. номер - Онлайн - Обновлено - GPS - Медиа - Связь - Действия - - - - {currentTableRows.map((row) => ( - { - // Allow clicking row to toggle checkbox, if not clicking on button - if ( - (event.target as HTMLElement).closest("button") === null && - (event.target as HTMLElement).closest( - 'input[type="checkbox"]' - ) === null - ) { - if (event.shiftKey) { - if (row.device_uuid) { - navigator.clipboard - .writeText(row.device_uuid) - .then(() => { - toast.success(`UUID скопирован`); - }) - .catch(() => { - toast.error("Не удалось скопировать UUID"); - }); - } else { - toast.warning("Устройство не имеет UUID"); - } - } - - // Only toggle checkbox if Shift key is not pressed - if (!event.shiftKey) { - handleSelectDevice( - { - target: { - checked: !selectedDeviceUuids.includes( - row.device_uuid ?? "" - ), - }, - } as React.ChangeEvent, // Simulate event - row.device_uuid ?? "" - ); - } - } - }} - sx={{ - cursor: "pointer", - "&:last-child td, &:last-child th": { border: 0 }, - }} - > - - - handleSelectDevice(event, row.device_uuid ?? "") - } - size="small" - /> - - - {row.tail_number} - - -
    - {row.online ? ( - - ) : ( - - )} -
    -
    - -
    - {formatDate(row.lastUpdate)} -
    -
    - -
    - {row.gps ? ( - - ) : ( - - )} -
    -
    - -
    - {row.media ? ( - - ) : ( - - )} -
    -
    - -
    - {row.connection ? ( - - ) : ( - - )} -
    -
    - - - - -
    - ))} - {currentTableRows.length === 0 && ( - - - Нет устройств для отображения. - - - )} -
    -
    -
    - - - - Отправить снапшот - - - Выбрано устройств:{" "} - - {selectedDeviceUuids.length} - - -
    - {snapshots && (snapshots as Snapshot[]).length > 0 ? ( // Cast snapshots - (snapshots as Snapshot[]).map((snapshot) => ( - - )) - ) : ( - - Нет доступных снапшотов. - +
    +
    + {canWriteDevices && ( + + )} + {canWriteDevices && selectedIds.length > 0 && ( + + )} + {canWriteDevices && ( + )}
    - + + {!isCollapsed && ( + + { + if (canWriteDevices) { + navigate(`/vehicle/${params.row.vehicle_id}/edit`); + } + }} + loading={isLoading} paginationModel={paginationModel} + onPaginationModelChange={setPaginationModel} + pageSizeOptions={[50]} + onRowSelectionModelChange={ + canWriteDevices + ? (createSelectionHandler(groupRowIds) as ( + ids: unknown, + ) => void) + : undefined + } + rowSelectionModel={{ + type: "include", + ids: new Set(selectedInGroup), + }} + localeText={ + ruRU.components.MuiDataGrid.defaultProps.localeText + } + autoHeight + slots={{ + noRowsOverlay: () => null, + }} + sx={{ + border: "none", + "& .MuiDataGrid-columnHeaders": { + borderBottom: 1, + borderColor: "divider", + }, + "& .MuiDataGrid-cell": { + borderBottom: 1, + borderColor: "divider", + }, + }} + /> + + )} +
    + ); + })} +
    + )} +
    + + {canWriteDevices && ( + - Отмена - + + Экспорт медиа + + + Выбрано устройств для обновления:{" "} + + {selectedDeviceUuidsAllowed.length} + + {selectedDeviceUuids.length !== + selectedDeviceUuidsAllowed.length && ( + + (пропущено{" "} + {selectedDeviceUuids.length - selectedDeviceUuidsAllowed.length}{" "} + с блокировкой) + + )} + +
    + {snapshots && (snapshots as Snapshot[]).length > 0 ? ( + (snapshots as Snapshot[]).map((snapshot) => ( + + )) + ) : ( + + Нет доступных экспортов медиа. + + )} +
    + +
    + )} + + setIsDeleteModalOpen(false)} + /> + + { + if (!maintenanceConfirmSubmitting) setMaintenanceConfirm(null); + }} + sx={{ width: "min(640px, 92vw)", p: 3 }} + > + + Подтверждение режима ТО + + + {maintenanceConfirm?.nextEnabled + ? `Включить режим ТО для устройства ${maintenanceConfirm.tailNumber}?` + : `Отключить режим ТО для устройства ${maintenanceConfirm?.tailNumber}?`} + + + + + + + { + if (!demoConfirmSubmitting) setDemoConfirm(null); + }} + sx={{ width: "min(640px, 92vw)", p: 3 }} + > + + Подтверждение демо-режима + + + {demoConfirm?.nextEnabled + ? `Включить демо-режим для устройства ${demoConfirm.tailNumber}?` + : `Отключить демо-режим для устройства ${demoConfirm?.tailNumber}?`} + + + + + + + + { + setLogsModalOpen(false); + setLogsModalDeviceUuid(null); + }} + /> + + { + setSessionsModalOpen(false); + setSessionsModalVehicleId(null); + setSessionsModalVehicleTailNumber(null); + }} + /> ); }); diff --git a/src/widgets/ImageUploadCard/index.tsx b/src/widgets/ImageUploadCard/index.tsx index c4bea80..22d81e5 100644 --- a/src/widgets/ImageUploadCard/index.tsx +++ b/src/widgets/ImageUploadCard/index.tsx @@ -1,11 +1,17 @@ -import React, { useRef, useState, DragEvent, useEffect } from "react"; +import React, { useRef, useState, DragEvent } from "react"; import { Paper, Box, Typography, Button, Tooltip } from "@mui/material"; -import { X, Info, Plus } from "lucide-react"; // Assuming lucide-react for icons -import { editSightStore } from "@shared"; +import { X, Info, Plus } from "lucide-react"; +import { editSightStore, PreviewBackgroundToggle } from "@shared"; import { toast } from "react-toastify"; interface ImageUploadCardProps { title: string; - imageKey?: "thumbnail" | "watermark_lu" | "watermark_rd" | "image"; + imageKey?: + | "thumbnail" + | "icon" + | "alt_icon" + | "watermark_lu" + | "watermark_rd" + | "image"; imageUrl: string | null | undefined; onImageClick: () => void; onDeleteImageClick: () => void; @@ -27,18 +33,10 @@ export const ImageUploadCard: React.FC = ({ tooltipText, }) => { const fileInputRef = useRef(null); - const [isDragOver, setIsDragOver] = useState(false); + const [isPreviewBackgroundDark, setIsPreviewBackgroundDark] = useState(false); const { setFileToUpload } = editSightStore; - useEffect(() => { - if (isDragOver) { - console.log("isDragOver"); - } - }, [isDragOver]); - - // --- Click to select file --- const handleZoneClick = () => { - // Trigger the hidden file input click fileInputRef.current?.click(); }; @@ -59,28 +57,25 @@ export const ImageUploadCard: React.FC = ({ toast.error("Пожалуйста, выберите изображение"); } } - // Reset the input value so selecting the same file again triggers change + event.target.value = ""; }; const token = localStorage.getItem("token"); - // --- Drag and Drop Handlers --- + const handleDragOver = (event: DragEvent) => { - event.preventDefault(); // Crucial to allow a drop + event.preventDefault(); event.stopPropagation(); - setIsDragOver(true); }; const handleDragLeave = (event: DragEvent) => { event.preventDefault(); event.stopPropagation(); - setIsDragOver(false); }; const handleDrop = async (event: DragEvent) => { - event.preventDefault(); // Crucial to allow a drop + event.preventDefault(); event.stopPropagation(); - setIsDragOver(false); const files = event.dataTransfer.files; if (files && files.length > 0) { @@ -130,10 +125,21 @@ export const ImageUploadCard: React.FC = ({ borderRadius: 1, mb: 1, cursor: imageUrl ? "pointer" : "default", + backgroundColor: imageUrl + ? isPreviewBackgroundDark + ? "#000" + : "#fff" + : "transparent", + transition: "background-color 150ms ease", }} onClick={onImageClick} - // Removed onClick on the main Box to avoid conflicts > + {imageUrl && ( + setIsPreviewBackgroundDark((value) => !value)} + /> + )} {imageUrl && (
    )} diff --git a/src/widgets/Layout/index.tsx b/src/widgets/Layout/index.tsx index 28ae5b2..81c1d5a 100644 --- a/src/widgets/Layout/index.tsx +++ b/src/widgets/Layout/index.tsx @@ -8,7 +8,7 @@ import { AppBar } from "./ui/AppBar"; import { Drawer } from "./ui/Drawer"; import { DrawerHeader } from "./ui/DrawerHeader"; import { NavigationList } from "@features"; -import { authStore, userStore, menuStore } from "@shared"; +import { authStore, menuStore, isMediaIdEmpty } from "@shared"; import { observer } from "mobx-react-lite"; import { useEffect } from "react"; import { Typography } from "@mui/material"; @@ -27,13 +27,10 @@ export const Layout: React.FC = observer(({ children }) => { setIsMenuOpen(open); }, [open]); - const { getUsers, users } = userStore; + const { getMeAction, me } = authStore; useEffect(() => { - const fetchUsers = async () => { - await getUsers(); - }; - fetchUsers(); + getMeAction(); }, []); const handleDrawerOpen = () => { @@ -45,37 +42,43 @@ export const Layout: React.FC = observer(({ children }) => { }; return ( - + - - - - +
    + + + + +
    +
    -
    - {(() => { - return ( - <> -

    - { - users?.data?.find( - // @ts-ignore - (user) => user.id === authStore.payload?.user_id - )?.name - } -

    + {(() => { + const hasAvatar = me?.icon && !isMediaIdEmpty(me.icon); + const token = localStorage.getItem("token"); + + return ( + <> +
    +

    {me?.name}

    = observer(({ children }) => { padding: "2px 10px", }} > - {/* @ts-ignore */} - {authStore.payload?.is_admin + {me?.roles?.includes("admin") ? "Администратор" : "Режим пользователя"}
    - - ); - })()} -
    -
    - -
    +
    +
    + {hasAvatar ? ( + Аватар + ) : ( + + )} +
    + + ); + })()}
    @@ -114,7 +126,7 @@ export const Layout: React.FC = observer(({ children }) => { }} > logo = observer(({ children }) => { )} +
    + v.{__APP_VERSION__} +
    diff --git a/src/widgets/MediaArea/index.tsx b/src/widgets/MediaArea/index.tsx index f0696bb..d0938d6 100644 --- a/src/widgets/MediaArea/index.tsx +++ b/src/widgets/MediaArea/index.tsx @@ -10,23 +10,25 @@ import { observer } from "mobx-react-lite"; import { useState, DragEvent, useRef } from "react"; import { toast } from "react-toastify"; +interface MediaAreaProps { + articleId: number; + mediaIds: { id: string; media_type: number; filename: string }[]; + deleteMedia: (id: number, media_id: string) => void; + onFilesDrop?: (files: File[]) => void; + setSelectMediaDialogOpen: (open: boolean) => void; +} + export const MediaArea = observer( ({ articleId, mediaIds, deleteMedia, - onFilesDrop, // 👈 Проп для обработки загруженных файлов + onFilesDrop, setSelectMediaDialogOpen, - }: { - articleId: number; - mediaIds: { id: string; media_type: number; filename: string }[]; - deleteMedia: (id: number, media_id: string) => void; - onFilesDrop?: (files: File[]) => void; - setSelectMediaDialogOpen: (open: boolean) => void; - }) => { + }: MediaAreaProps) => { const [mediaModal, setMediaModal] = useState(false); const [mediaId, setMediaId] = useState(""); - const [isDragging, setIsDragging] = useState(false); + const [isDragging, setIsDragging] = useState(false); const fileInputRef = useRef(null); const handleMediaModal = (mediaId: string) => { @@ -34,23 +36,29 @@ export const MediaArea = observer( setMediaId(mediaId); }; + const processFiles = (files: File[]) => { + if (!files.length || !onFilesDrop) { + return; + } + + const { validFiles, errors } = filterValidFiles(files); + + if (errors.length > 0) { + errors.forEach((error) => toast.error(error)); + } + + if (validFiles.length > 0) { + onFilesDrop(validFiles); + } + }; + const handleDrop = (e: DragEvent) => { e.preventDefault(); e.stopPropagation(); setIsDragging(false); const files = Array.from(e.dataTransfer.files); - if (files.length && onFilesDrop) { - const { validFiles, errors } = filterValidFiles(files); - - if (errors.length > 0) { - errors.forEach((error) => toast.error(error)); - } - - if (validFiles.length > 0) { - onFilesDrop(validFiles); - } - } + processFiles(files); }; const handleDragOver = (e: DragEvent) => { @@ -68,19 +76,11 @@ export const MediaArea = observer( const handleFileSelect = (event: React.ChangeEvent) => { const files = Array.from(event.target.files || []); - if (files.length && onFilesDrop) { - const { validFiles, errors } = filterValidFiles(files); + processFiles(files); - if (errors.length > 0) { - errors.forEach((error) => toast.error(error)); - } - - if (validFiles.length > 0) { - onFilesDrop(validFiles); - } + if (event.target) { + event.target.value = ""; } - // Сбрасываем значение input, чтобы можно было выбрать тот же файл снова - event.target.value = ""; }; return ( @@ -96,7 +96,7 @@ export const MediaArea = observer(
    - Перетащите медиа файлы сюда или нажмите для выбора + + Перетащите медиа файлы сюда или нажмите для выбора +
    -
    или
    +
    или
    -
    - {mediaIds.map((m) => ( - - - ))} -
    + ))} +
    + )}
    void; + onFinishUpload?: (mediaId: string) => void; + contextObjectName?: string; + contextType?: ContextType; + isArticle?: boolean; + articleName?: string; +} + export const MediaAreaForSight = observer( ({ - onFilesDrop, // 👈 Проп для обработки загруженных файлов + onFilesDrop, onFinishUpload, contextObjectName, contextType, isArticle, articleName, - }: { - onFilesDrop?: (files: File[]) => void; - onFinishUpload?: (mediaId: string) => void; - contextObjectName?: string; - contextType?: - | "sight" - | "city" - | "carrier" - | "country" - | "vehicle" - | "station"; - isArticle?: boolean; - articleName?: string; - }) => { - const [selectMediaDialogOpen, setSelectMediaDialogOpen] = useState(false); - const [uploadMediaDialogOpen, setUploadMediaDialogOpen] = useState(false); - const [isDragging, setIsDragging] = useState(false); + }: MediaAreaForSightProps) => { + const [selectMediaDialogOpen, setSelectMediaDialogOpen] = + useState(false); + const [uploadMediaDialogOpen, setUploadMediaDialogOpen] = + useState(false); + const [isDragging, setIsDragging] = useState(false); const fileInputRef = useRef(null); const { setFileToUpload } = editSightStore; + const processFiles = (files: File[]) => { + if (!files.length) { + return; + } + + const { validFiles, errors } = filterValidFiles(files); + + if (errors.length > 0) { + errors.forEach((error: string) => toast.error(error)); + } + + if (validFiles.length > 0) { + // Сохраняем первый файл для загрузки + setFileToUpload(validFiles[0]); + + // Вызываем колбэк, если он передан + if (onFilesDrop) { + onFilesDrop(validFiles); + } + + // Открываем диалог загрузки + setUploadMediaDialogOpen(true); + } + }; + const handleDrop = (e: DragEvent) => { e.preventDefault(); e.stopPropagation(); setIsDragging(false); const files = Array.from(e.dataTransfer.files); - if (files.length) { - const { validFiles, errors } = filterValidFiles(files); - - if (errors.length > 0) { - errors.forEach((error: string) => toast.error(error)); - } - - if (validFiles.length > 0 && onFilesDrop) { - setFileToUpload(validFiles[0]); - setUploadMediaDialogOpen(true); - } - } + processFiles(files); }; const handleDragOver = (e: DragEvent) => { @@ -74,22 +94,12 @@ export const MediaAreaForSight = observer( const handleFileSelect = (event: React.ChangeEvent) => { const files = Array.from(event.target.files || []); - if (files.length) { - const { validFiles, errors } = filterValidFiles(files); - - if (errors.length > 0) { - errors.forEach((error: string) => toast.error(error)); - } - - if (validFiles.length > 0 && onFilesDrop) { - setFileToUpload(validFiles[0]); - onFilesDrop(validFiles); - setUploadMediaDialogOpen(true); - } - } + processFiles(files); // Сбрасываем значение input, чтобы можно было выбрать тот же файл снова - event.target.value = ""; + if (event.target) { + event.target.value = ""; + } }; return ( @@ -105,7 +115,7 @@ export const MediaAreaForSight = observer(
    - Перетащите медиа файлы сюда или нажмите для выбора + + Перетащите медиа файлы сюда или нажмите для выбора +
    -
    или
    +
    или
    -// )} -// {mediaId ? ( -// {title} { -// e.stopPropagation(); -// onPreviewClick(mediaId); -// }} -// /> -// ) : ( -//
    -//
    -// -//

    -// {isDragActive ? "Отпустите файл здесь" : "Перетащите файл"} -//

    -//
    -//

    или

    -// -//
    -// )} -// -// - -// { -// setUploadMediaOpen(false); -// setFileToUpload(null); -// }} -// afterUpload={handleUploadComplete} -// /> -// -// ); -// }; diff --git a/src/widgets/SightTabs/CreateInformationTab/index.tsx b/src/widgets/SightTabs/CreateInformationTab/index.tsx index 05fa9f9..9c87680 100644 --- a/src/widgets/SightTabs/CreateInformationTab/index.tsx +++ b/src/widgets/SightTabs/CreateInformationTab/index.tsx @@ -9,13 +9,17 @@ import { DialogTitle, DialogContent, DialogActions, + Checkbox, + FormControlLabel, } from "@mui/material"; import { BackButton, TabPanel, languageStore, + authStore, Language, cityStore, + isMediaIdEmpty, SelectMediaDialog, PreviewMediaDialog, SightLanguageInfo, @@ -34,35 +38,73 @@ import { Save } from "lucide-react"; import { observer } from "mobx-react-lite"; import { useEffect, useState } from "react"; +import { useNavigate } from "react-router-dom"; import { toast } from "react-toastify"; import { SaveWithoutCityAgree } from "@widgets"; export const CreateInformationTab = observer( ({ value, index }: { value: number; index: number }) => { - const { cities } = cityStore; + const canReadCities = authStore.canRead("cities"); const [mediaId, setMediaId] = useState(""); const [isPreviewMediaOpen, setIsPreviewMediaOpen] = useState(false); const [isUploadMediaOpen, setIsUploadMediaOpen] = useState(false); const { language } = languageStore; const { sight, updateSightInfo, createSight } = createSightStore; const data = sight[language]; + const navigate = useNavigate(); const [, setCity] = useState(sight.city_id ?? 0); const [coordinates, setCoordinates] = useState(`0, 0`); const [menuAnchorEl, setMenuAnchorEl] = useState(null); const [activeMenuType, setActiveMenuType] = useState< - "thumbnail" | "watermark_lu" | "watermark_rd" | "video_preview" | null + | "thumbnail" + | "icon" + | "alt_icon" + | "watermark_lu" + | "watermark_rd" + | "video_preview" + | null >(null); const [isAddMediaOpen, setIsAddMediaOpen] = useState(false); const [isVideoPreviewOpen, setIsVideoPreviewOpen] = useState(false); const [hardcodeType, setHardcodeType] = useState< - "thumbnail" | "watermark_lu" | "watermark_rd" | "video_preview" | null + | "thumbnail" + | "icon" + | "alt_icon" + | "watermark_lu" + | "watermark_rd" + | "video_preview" + | null >(null); // НОВОЕ СОСТОЯНИЕ ДЛЯ ПРЕДУПРЕЖДАЮЩЕГО ОКНА const [isSaveWarningOpen, setIsSaveWarningOpen] = useState(false); + const baseCities = canReadCities + ? cityStore.cities["ru"]?.data ?? [] + : authStore.meCities["ru"].map((city) => ({ + id: city.city_id, + name: city.name, + country: "", + country_code: "", + arms: "", + })); + + const availableCities = + sight.city_id && !baseCities.some((city) => city.id === sight.city_id) + ? [ + { + id: sight.city_id, + name: sight.city || `Город ${sight.city_id}`, + country: "", + country_code: "", + arms: "", + }, + ...baseCities, + ] + : baseCities; + useEffect(() => {}, [hardcodeType]); useEffect(() => { @@ -103,7 +145,13 @@ export const CreateInformationTab = observer( media_name?: string; media_type: number; }, - type: "thumbnail" | "watermark_lu" | "watermark_rd" | "video_preview" + type: + | "thumbnail" + | "icon" + | "alt_icon" + | "watermark_lu" + | "watermark_rd" + | "video_preview" ) => { handleChange({ [type]: media.id, @@ -127,14 +175,16 @@ export const CreateInformationTab = observer( return; } - await createSight(language); + const newSightId = await createSight(language); toast.success("Достопримечательность создана"); + navigate(`/sight/${newSightId}/edit`); }; const handleConfirmSave = async () => { setIsSaveWarningOpen(false); - await createSight(language); + const newSightId = await createSight(language); toast.success("Достопримечательность создана"); + navigate(`/sight/${newSightId}/edit`); }; const handleCancelSave = () => { @@ -155,7 +205,7 @@ export const CreateInformationTab = observer( >
    -

    {sight[language].name}

    +

    {sight[language].name.replace(/\n/g, " ")}

    + { + handleChange( + { + short_name: e.target.value, + }, + language + ); + }} + fullWidth + variant="outlined" + /> + city.id === sight.city_id - ) ?? null + availableCities.find((city) => city.id === sight.city_id) ?? null } getOptionLabel={(option) => option.name} onChange={(_, value) => { setCity(value?.id ?? 0); handleChange({ city_id: value?.id ?? 0, + city: value?.name ?? "", }); }} renderInput={(params) => ( @@ -259,6 +323,20 @@ export const CreateInformationTab = observer( variant="outlined" placeholder="Введите координаты в формате: широта долгота" /> + + + handleChange({ + is_default_icon: e.target.checked, + }) + } + /> + } + label="Использовать иконку по умолчанию" + /> + {!sight.is_default_icon && ( + <> + { + setIsPreviewMediaOpen(true); + setMediaId(sight.icon ?? ""); + }} + onDeleteImageClick={() => { + handleChange({ + icon: null, + }); + setActiveMenuType(null); + }} + onSelectFileClick={() => { + setActiveMenuType("icon"); + setIsAddMediaOpen(true); + }} + setUploadMediaOpen={() => { + setIsUploadMediaOpen(true); + setActiveMenuType("icon"); + setHardcodeType("icon"); + }} + setHardcodeType={() => { + setHardcodeType("icon"); + }} + /> + + { + setIsPreviewMediaOpen(true); + setMediaId(sight.alt_icon ?? ""); + }} + onDeleteImageClick={() => { + handleChange({ + alt_icon: null, + }); + setActiveMenuType(null); + }} + onSelectFileClick={() => { + setActiveMenuType("alt_icon"); + setIsAddMediaOpen(true); + }} + setUploadMediaOpen={() => { + setIsUploadMediaOpen(true); + setActiveMenuType("alt_icon"); + setHardcodeType("alt_icon"); + }} + setHardcodeType={() => { + setHardcodeType("alt_icon"); + }} + /> + + )} + { setIsPreviewMediaOpen(true); setMediaId(sight.watermark_lu ?? ""); @@ -363,7 +504,7 @@ export const CreateInformationTab = observer( { handleChange({ diff --git a/src/widgets/SightTabs/CreateLeftTab/index.tsx b/src/widgets/SightTabs/CreateLeftTab/index.tsx index f18d1b6..0b91a40 100644 --- a/src/widgets/SightTabs/CreateLeftTab/index.tsx +++ b/src/widgets/SightTabs/CreateLeftTab/index.tsx @@ -1,4 +1,3 @@ -// @widgets/LeftWidgetTab.tsx import { Box, Button, TextField, Paper, Typography } from "@mui/material"; import { BackButton, @@ -21,6 +20,7 @@ import { } from "@widgets"; import { Trash2, ImagePlus, Unlink, Plus, Save, Search } from "lucide-react"; import { useState, useCallback } from "react"; +import { useNavigate } from "react-router-dom"; import { observer } from "mobx-react-lite"; import { toast } from "react-toastify"; @@ -42,6 +42,7 @@ export const CreateLeftTab = observer( uploadMediaOpen, setUploadMediaOpen, } = editSightStore; + const navigate = useNavigate(); const { language } = languageStore; const token = localStorage.getItem("token"); @@ -50,17 +51,6 @@ export const CreateLeftTab = observer( const [isSelectMediaDialogOpen, setIsSelectMediaDialogOpen] = useState(false); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); - // const handleMediaSelected = useCallback(() => { - // // При выборе медиа, обновляем данные для ТЕКУЩЕГО ЯЗЫКА - // // сохраняя текущие heading и body. - // updateSightInfo(language, { - // left: { - // heading: data.left.heading, - // body: data.left.body, - // }, - // }); - // setIsSelectMediaDialogOpen(false); - // }, [language, data.left.heading, data.left.body]); const handleCloseArticleDialog = useCallback(() => { setIsSelectArticleDialogOpen(false); @@ -104,7 +94,7 @@ export const CreateLeftTab = observer( >
    -

    {sight[language].name}

    +

    {sight[language].name.replace(/\n/g, " ")}

    } onClick={async () => { try { - await createSight(language); + const newSightId = await createSight(language); toast.success("Страница создана"); + navigate(`/sight/${newSightId}/edit`); } catch (error) { console.error(error); } diff --git a/src/widgets/SightTabs/CreateRightTab/index.tsx b/src/widgets/SightTabs/CreateRightTab/index.tsx index bf60797..da4e9ed 100644 --- a/src/widgets/SightTabs/CreateRightTab/index.tsx +++ b/src/widgets/SightTabs/CreateRightTab/index.tsx @@ -1,40 +1,39 @@ import { Box, Button, - Paper, Typography, - Menu, - MenuItem, TextField, + Slider, + Stack, } from "@mui/material"; import { BackButton, createSightStore, + editSightStore, languageStore, - SelectArticleModal, TabPanel, - SelectMediaDialog, // Import + SelectMediaDialog, UploadMediaDialog, - Media, // Import + Media, } from "@shared"; import { LanguageSwitcher, - MediaArea, // Import - MediaAreaForSight, // Import - ReactMarkdownComponent, + MediaArea, + MediaAreaForSight, ReactMarkdownEditor, DeleteModal, } from "@widgets"; -import { ImagePlus, Plus, Save, Trash2, Unlink, X } from "lucide-react"; // Import X +import { Plus, Save, Trash2, Unlink, X } from "lucide-react"; import { observer } from "mobx-react-lite"; -import { useState, useEffect } from "react"; // Added useEffect +import { useState, useEffect, useRef } from "react"; +import { useNavigate } from "react-router-dom"; import { MediaViewer } from "../../MediaViewer/index"; import { toast } from "react-toastify"; import { authInstance } from "@shared"; import { DragDropContext, Droppable, Draggable } from "@hello-pangea/dnd"; +import { SightFramePreview } from "../RightWidgetTab/SightFramePreview"; type MediaItemShared = { - // Define if not already available from @shared id: string; filename: string; media_name?: string; @@ -43,7 +42,6 @@ type MediaItemShared = { export const CreateRightTab = observer( ({ value, index }: { value: number; index: number }) => { - const [anchorEl, setAnchorEl] = useState(null); const { sight, createNewRightArticle, @@ -52,24 +50,22 @@ export const CreateRightTab = observer( unlinkPreviewMedia, createLinkWithRightArticle, deleteRightArticleMedia, - setFileToUpload, // From store - setUploadMediaOpen, // From store - uploadMediaOpen, // From store - unlinkRightAritcle, // Corrected spelling + unlinkRightAritcle, deleteRightArticle, - linkExistingRightArticle, createSight, - clearCreateSight, // For resetting form updateRightArticles, + updateSightInfo, } = createSightStore; const { language } = languageStore; + const navigate = useNavigate(); + const { setFileToUpload, setUploadMediaOpen, uploadMediaOpen } = + editSightStore; - const [selectArticleDialogOpen, setSelectArticleDialogOpen] = - useState(false); const [activeArticleIndex, setActiveArticleIndex] = useState( - null + null, ); const [type, setType] = useState<"article" | "media">("media"); + const [previewSection, setPreviewSection] = useState(-1); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); const [isSelectMediaDialogOpen, setIsSelectMediaDialogOpen] = useState(false); @@ -78,12 +74,34 @@ export const CreateRightTab = observer( >(null); const [previewMedia, setPreviewMedia] = useState(null); - // Reset activeArticleIndex if language changes and index is out of bounds + const shortNameRef = useRef(null); + + const insertNewline = () => { + const input = shortNameRef.current; + const currentValue = sight[language].name || ""; + if (!input) { + updateSightInfo({ name: currentValue + "\n" }, language); + return; + } + const start = input.selectionStart ?? currentValue.length; + const end = input.selectionEnd ?? start; + const newValue = + currentValue.slice(0, start) + "\n" + currentValue.slice(end); + updateSightInfo({ name: newValue }, language); + requestAnimationFrame(() => { + if (shortNameRef.current) { + shortNameRef.current.selectionStart = start + 1; + shortNameRef.current.selectionEnd = start + 1; + shortNameRef.current.focus(); + } + }); + }; + useEffect(() => { if (sight.preview_media) { const fetchMedia = async () => { const response = await authInstance.get( - `/media/${sight.preview_media}` + `/media/${sight.preview_media}`, ); setPreviewMedia(response.data); }; @@ -97,26 +115,18 @@ export const CreateRightTab = observer( activeArticleIndex >= sight[language].right.length ) { setActiveArticleIndex(null); - setType("media"); // Default back to media preview if selected article disappears + setType("media"); + setPreviewSection(-1); } }, [language, sight[language].right, activeArticleIndex]); - const openMenu = Boolean(anchorEl); - const handleClickMenu = (event: React.MouseEvent) => { - setAnchorEl(event.currentTarget); - }; - const handleCloseMenu = () => { - setAnchorEl(null); - }; - const handleSave = async () => { try { - await createSight(language); + const newSightId = await createSight(language); + console.log("[handleSave] newSightId:", newSightId, "needLeaveAgree:", createSightStore.needLeaveAgree); toast.success("Достопримечательность успешно создана!"); - clearCreateSight(); // Reset form - setActiveArticleIndex(null); - setType("media"); - // Potentially navigate away: history.push('/sights-list'); + navigate(`/sight/${newSightId}/edit`); + console.log("[handleSave] navigate called to:", `/sight/${newSightId}/edit`); } catch (error) { console.error("Failed to save sight:", error); toast.error("Ошибка при создании достопримечательности."); @@ -126,61 +136,37 @@ export const CreateRightTab = observer( const handleDisplayArticleFromList = (idx: number) => { setActiveArticleIndex(idx); setType("article"); + setPreviewSection(idx); }; const handleCreateNewLocalArticle = async () => { - handleCloseMenu(); try { const newArticleId = await createNewRightArticle(); - // Automatically select the new article if ID is returned + const newIndex = sight[language].right.findIndex( - (a) => a.id === newArticleId + (a) => a.id === newArticleId, ); - if (newIndex > -1) { - setActiveArticleIndex(newIndex); - setType("article"); - } else { - // Fallback if findIndex fails (should not happen if store updates correctly) - setActiveArticleIndex(sight[language].right.length - 1); - setType("article"); - } + const resolvedIndex = + newIndex > -1 ? newIndex : sight[language].right.length - 1; + setActiveArticleIndex(resolvedIndex); + setType("article"); + setPreviewSection(resolvedIndex); } catch (error) { toast.error("Не удалось создать новую статью."); } }; - const handleSelectExistingArticleAndLink = async ( - selectedArticleId: number - ) => { - try { - const linkedArticleId = await linkExistingRightArticle( - selectedArticleId - ); - setSelectArticleDialogOpen(false); // Close dialog - const newIndex = sight[language].right.findIndex( - (a) => a.id === linkedArticleId - ); - if (newIndex > -1) { - setActiveArticleIndex(newIndex); - setType("article"); - } - } catch (error) { - toast.error("Не удалось привязать существующую статью."); - } - }; - const currentRightArticle = activeArticleIndex !== null && sight[language].right[activeArticleIndex] ? sight[language].right[activeArticleIndex] : null; - // Media Handling for Dialogs const handleOpenUploadMedia = () => { setUploadMediaOpen(true); }; const handleOpenSelectMediaDialog = ( - target: "sightPreview" | "rightArticle" + target: "sightPreview" | "rightArticle", ) => { setMediaTarget(target); setIsSelectMediaDialogOpen(true); @@ -203,7 +189,6 @@ export const CreateRightTab = observer( }; const handleMediaUploaded = async (media: MediaItemShared) => { - // After UploadMediaDialog finishes setUploadMediaOpen(false); setFileToUpload(null); if (mediaTarget === "sightPreview") { @@ -211,36 +196,22 @@ export const CreateRightTab = observer( } else if (mediaTarget === "rightArticle" && currentRightArticle) { await createLinkWithRightArticle(media, currentRightArticle.id); } - setMediaTarget(null); // Reset target + setMediaTarget(null); }; const handleDragEnd = (result: any) => { const { source, destination } = result; - // 1. Guard clause: If dropped outside any droppable area, do nothing. if (!destination) return; - // Extract source and destination indices const sourceIndex = source.index; const destinationIndex = destination.index; - // 2. Guard clause: If dropped in the same position, do nothing. if (sourceIndex === destinationIndex) return; - // 3. Create a new array with reordered articles: - // - Create a shallow copy of the current articles array. - // This is important for immutability and triggering re-renders. const newRightArticles = [...sight[language].right]; - - // - Remove the dragged article from its original position. - // `splice` returns an array of removed items, so we destructure the first (and only) one. const [movedArticle] = newRightArticles.splice(sourceIndex, 1); - - // - Insert the moved article into its new position. newRightArticles.splice(destinationIndex, 0, movedArticle); - - // 4. Update the store with the new order: - // This will typically trigger a re-render of the component with the updated list. updateRightArticles(newRightArticles); }; @@ -254,30 +225,32 @@ export const CreateRightTab = observer( height: "100%", minHeight: "calc(100vh - 200px)", gap: 2, - paddingBottom: "70px", // Space for the save button + paddingBottom: "70px", position: "relative", }} >
    -

    {sight[language].name}

    +

    {sight[language].name.replace(/\n/g, " ")}

    - {/* Left Column: Navigation & Article List */} - - + + { setType("media"); - // setActiveArticleIndex(null); // Optional: deselect article when switching to general media view + setPreviewSection(-1); }} - className={`w-full p-4 rounded-2xl cursor-pointer text-sm hover:bg-gray-300 transition-all duration-300 ${ + className={`w-full p-4 rounded-2xl cursor-pointer text-sm transition-all duration-300 ${ type === "media" - ? "bg-green-300 font-semibold" - : "bg-green-200" + ? "bg-blue-400 text-white" + : "bg-gray-200 hover:bg-gray-300" }`} > Предпросмотр медиа @@ -303,16 +276,19 @@ export const CreateRightTab = observer( { handleDisplayArticleFromList( - index + index, ); - setType("article"); }} > @@ -323,7 +299,7 @@ export const CreateRightTab = observer( )} - ) + ), ) : null} {provided.placeholder} @@ -335,36 +311,12 @@ export const CreateRightTab = observer( - - - Создать новую - - { - setSelectArticleDialogOpen(true); - handleCloseMenu(); - }} - > - Выбрать существующую статью - - - {/* Main content area: Article Editor or Sight Media Preview */} {type === "article" && currentRightArticle ? ( @@ -375,9 +327,10 @@ export const CreateRightTab = observer( startIcon={} onClick={() => { if (currentRightArticle) { - unlinkRightAritcle(currentRightArticle.id); // Corrected function name + unlinkRightAritcle(currentRightArticle.id); setActiveArticleIndex(null); setType("media"); + setPreviewSection(-1); } }} > @@ -388,9 +341,7 @@ export const CreateRightTab = observer( color="error" size="small" startIcon={} - onClick={async () => { - setIsDeleteModalOpen(true); - }} + onClick={() => setIsDeleteModalOpen(true)} > Удалить @@ -414,7 +365,7 @@ export const CreateRightTab = observer( activeArticleIndex, language, e.target.value, - currentRightArticle.body + currentRightArticle.body, ) } variant="outlined" @@ -429,13 +380,13 @@ export const CreateRightTab = observer( activeArticleIndex, language, currentRightArticle.heading, - mdValue || "" + mdValue || "", ) } /> { if (files.length > 0) { @@ -451,227 +402,179 @@ export const CreateRightTab = observer( /> - ) : type === "media" ? ( - - {sight.preview_media && ( - <> - {type === "media" && ( - - {previewMedia && ( - <> - - - - - - - - - )} - - )} - - )} - {!previewMedia && ( - { - linkPreviewMedia(mediaId); - }} - onFilesDrop={() => {}} - contextObjectName={sight[language].name} - contextType="sight" - isArticle={false} - /> - )} - ) : ( - - - Выберите статью слева или секцию "Предпросмотр медиа" - + + {sight.preview_media && ( + + {previewMedia && ( + <> + + + + + + + + )} + + )} + + {!sight.preview_media && ( + + + { + linkPreviewMedia(mediaId); + }} + onFilesDrop={() => {}} + contextObjectName={sight[language].name} + contextType="sight" + isArticle={false} + /> + + + )} )} - {/* Right Column: Live Preview */} - - {type === "article" && activeArticleIndex !== null && ( - - + {type === "media" && ( + + { + const raw = e.target.value; + if (raw === "") { + updateSightInfo({ preview_font_size: undefined }); + return; + } + const val = Math.max( + 1, + Math.min(300, Math.round(Number(raw))), + ); + if (Number.isFinite(val)) { + updateSightInfo({ preview_font_size: val }); + } + }} + slotProps={{ input: { min: 1, max: 300 } }} + sx={{ width: "200px" }} + /> + { + if (typeof newValue === "number") { + updateSightInfo({ preview_font_size: newValue }); + } + }} + sx={{ flexGrow: 1 }} + /> + + )} + {type === "media" && ( + + + updateSightInfo({ name: e.target.value }, language) + } + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + insertNewline(); + } + }} + inputRef={shortNameRef} + sx={{ flexGrow: 1 }} + /> + - ))} - - -
    + ↵ + + )} + { + handleDisplayArticleFromList(idx); + }} + previewFontSize={sight.preview_font_size} + selectedSection={previewSection} + onSectionChange={(section) => { + setPreviewSection(section); + if (section === -1) { + setType("media"); + } else { + handleDisplayArticleFromList(section); + } + }} + />
    - {/* Sticky Save Button Footer */} - {/* Modals */} - setSelectArticleDialogOpen(false)} - onSelectArticle={handleSelectExistingArticleAndLink} - // Pass IDs of already linked/added right articles to exclude them from selection - linkedArticleIds={sight[language].right.map((article) => article.id)} - /> { setUploadMediaOpen(false); - setFileToUpload(null); // Clear file if dialog is closed without upload + setFileToUpload(null); setMediaTarget(null); }} contextObjectName={sight[language].name} @@ -712,7 +607,7 @@ export const CreateRightTab = observer( ? sight[language].right[activeArticleIndex].heading : undefined } - afterUpload={handleMediaUploaded} // This will use the mediaTarget + afterUpload={handleMediaUploaded} /> { try { + const idx = activeArticleIndex ?? 0; await deleteRightArticle(currentRightArticle?.id || 0); - setActiveArticleIndex(null); - setType("media"); + setIsDeleteModalOpen(false); + if (idx > 0) { + setActiveArticleIndex(idx - 1); + setPreviewSection(idx - 1); + setType("article"); + } else { + setActiveArticleIndex(null); + setPreviewSection(-1); + setType("media"); + } toast.success("Статья удалена"); } catch { toast.error("Не удалось удалить статью"); @@ -738,5 +642,5 @@ export const CreateRightTab = observer( /> ); - } + }, ); diff --git a/src/widgets/SightTabs/InformationTab/index.tsx b/src/widgets/SightTabs/InformationTab/index.tsx index af41555..cc3ce5f 100644 --- a/src/widgets/SightTabs/InformationTab/index.tsx +++ b/src/widgets/SightTabs/InformationTab/index.tsx @@ -9,14 +9,18 @@ import { DialogTitle, DialogContent, DialogActions, + Checkbox, + FormControlLabel, } from "@mui/material"; import { BackButton, TabPanel, languageStore, + authStore, Language, cityStore, editSightStore, + isMediaIdEmpty, SelectMediaDialog, PreviewMediaDialog, SightLanguageInfo, @@ -37,8 +41,8 @@ import { useEffect, useState } from "react"; import { toast } from "react-toastify"; -// Компонент предупреждающего окна (перенесен сюда) import { SaveWithoutCityAgree } from "@widgets"; +import { LinkedStations } from "@pages"; export const InformationTab = observer( ({ value, index }: { value: number; index: number }) => { @@ -54,17 +58,54 @@ export const InformationTab = observer( const [menuAnchorEl, setMenuAnchorEl] = useState(null); const [activeMenuType, setActiveMenuType] = useState< - "thumbnail" | "watermark_lu" | "watermark_rd" | "video_preview" | null + | "thumbnail" + | "icon" + | "alt_icon" + | "watermark_lu" + | "watermark_rd" + | "video_preview" + | null >(null); const [isAddMediaOpen, setIsAddMediaOpen] = useState(false); const [isVideoPreviewOpen, setIsVideoPreviewOpen] = useState(false); const [hardcodeType, setHardcodeType] = useState< - "thumbnail" | "watermark_lu" | "watermark_rd" | "video_preview" | null + | "thumbnail" + | "icon" + | "alt_icon" + | "watermark_lu" + | "watermark_rd" + | "video_preview" + | null >(null); - const { cities } = cityStore; - // НОВОЕ СОСТОЯНИЕ ДЛЯ ПРЕДУПРЕЖДАЮЩЕГО ОКНА + const canReadCities = authStore.canRead("cities"); + const [isSaveWarningOpen, setIsSaveWarningOpen] = useState(false); + const baseCities = canReadCities + ? cityStore.cities["ru"]?.data ?? [] + : authStore.meCities["ru"].map((city) => ({ + id: city.city_id, + name: city.name, + country: "", + country_code: "", + arms: "", + })); + + const availableCities = + sight.common.city_id && + !baseCities.some((city) => city.id === sight.common.city_id) + ? [ + { + id: sight.common.city_id, + name: sight.common.city || `Город ${sight.common.city_id}`, + country: "", + country_code: "", + arms: "", + }, + ...baseCities, + ] + : baseCities; + useEffect(() => {}, [hardcodeType]); useEffect(() => { @@ -94,7 +135,13 @@ export const InformationTab = observer( media_name?: string; media_type: number; }, - type: "thumbnail" | "watermark_lu" | "watermark_rd" | "video_preview" + type: + | "thumbnail" + | "icon" + | "alt_icon" + | "watermark_lu" + | "watermark_rd" + | "video_preview" ) => { handleChange( language as Language, @@ -119,16 +166,14 @@ export const InformationTab = observer( updateSightInfo(language, content, common); }; - // НОВАЯ ФУНКЦИЯ: Фактическое сохранение (вызывается после подтверждения) const executeSave = async () => { await updateSight(); toast.success("Достопримечательность сохранена"); }; - // ОБНОВЛЕННАЯ ФУНКЦИЯ: Проверка и вызов окна или сохранения const handleSave = async () => { const isCityMissing = !sight.common.city_id; - // Проверяем названия на всех языках + const isNameMissing = !sight.ru.name || !sight.en.name || !sight.zh.name; if (isCityMissing || isNameMissing) { @@ -139,13 +184,11 @@ export const InformationTab = observer( await executeSave(); }; - // Обработчик "Да" в предупреждающем окне const handleConfirmSave = async () => { setIsSaveWarningOpen(false); await executeSave(); }; - // Обработчик "Нет" в предупреждающем окне const handleCancelSave = () => { setIsSaveWarningOpen(false); }; @@ -164,7 +207,7 @@ export const InformationTab = observer( >
    -

    {sight[language].name}

    +

    {sight[language].name.replace(/\n/g, " ")}

    @@ -198,6 +241,18 @@ export const InformationTab = observer( variant="outlined" /> + { + handleChange(language as Language, { + short_name: e.target.value, + }); + }} + fullWidth + variant="outlined" + /> + city.id === sight.common.city_id - ) ?? null + availableCities.find((city) => city.id === sight.common.city_id) ?? null } getOptionLabel={(option) => option.name} onChange={(_, value) => { @@ -224,6 +277,7 @@ export const InformationTab = observer( language as Language, { city_id: value?.id ?? 0, + city: value?.name ?? "", }, true ); @@ -273,6 +327,37 @@ export const InformationTab = observer( variant="outlined" placeholder="Введите координаты в формате: широта долгота (можно использовать запятые или пробелы)" /> + + + handleChange( + language as Language, + { + is_default_icon: e.target.checked, + }, + true + ) + } + /> + } + label="Использовать иконку по умолчанию" + /> + + + + {sight.common.id !== 0 && ( + + )} + {!sight.common.is_default_icon && ( + <> + { + setIsPreviewMediaOpen(true); + setMediaId(sight.common.icon ?? ""); + }} + onDeleteImageClick={() => { + handleChange( + language as Language, + { + icon: null, + }, + true + ); + setActiveMenuType(null); + }} + onSelectFileClick={() => { + setActiveMenuType("icon"); + setIsAddMediaOpen(true); + }} + setUploadMediaOpen={() => { + setIsUploadMediaOpen(true); + setActiveMenuType("icon"); + setHardcodeType("icon"); + }} + setHardcodeType={() => { + setHardcodeType("icon"); + }} + /> + { + setIsPreviewMediaOpen(true); + setMediaId(sight.common.alt_icon ?? ""); + }} + onDeleteImageClick={() => { + handleChange( + language as Language, + { + alt_icon: null, + }, + true + ); + setActiveMenuType(null); + }} + onSelectFileClick={() => { + setActiveMenuType("alt_icon"); + setIsAddMediaOpen(true); + }} + setUploadMediaOpen={() => { + setIsUploadMediaOpen(true); + setActiveMenuType("alt_icon"); + setHardcodeType("alt_icon"); + }} + setHardcodeType={() => { + setHardcodeType("alt_icon"); + }} + /> + + )} { setIsPreviewMediaOpen(true); setMediaId(sight.common.watermark_lu ?? ""); @@ -387,7 +541,7 @@ export const InformationTab = observer( { handleChange( @@ -431,7 +585,7 @@ export const InformationTab = observer( variant="contained" color="success" startIcon={} - onClick={handleSave} // Используем новую функцию-обработчик + onClick={handleSave} > Сохранить @@ -538,7 +692,6 @@ export const InformationTab = observer( )} - {/* ИНТЕГРИРОВАННОЕ ПРЕДУПРЕЖДАЮЩЕЕ ОКНО */} {isSaveWarningOpen && ( ); } -); \ No newline at end of file +); diff --git a/src/widgets/SightTabs/LeftWidgetTab/index.tsx b/src/widgets/SightTabs/LeftWidgetTab/index.tsx index fd3b407..eb23932 100644 --- a/src/widgets/SightTabs/LeftWidgetTab/index.tsx +++ b/src/widgets/SightTabs/LeftWidgetTab/index.tsx @@ -9,7 +9,6 @@ import { SelectArticleModal, UploadMediaDialog, Language, - articlesStore, } from "@shared"; import { LanguageSwitcher, @@ -85,31 +84,7 @@ export const LeftWidgetTab = observer( ) => { setIsSelectArticleDialogOpen(false); - const ruArticle = await articlesStore.getArticle(articleId, "ru"); - const enArticle = await articlesStore.getArticle(articleId, "en"); - const zhArticle = await articlesStore.getArticle(articleId, "zh"); - - updateSightInfo("ru", { - left: { - heading: ruArticle.data.heading, - body: ruArticle.data.body, - media: ruArticle.data.media || [], - }, - }); - updateSightInfo("en", { - left: { - heading: enArticle.data.heading, - body: enArticle.data.body, - media: enArticle.data.media || [], - }, - }); - updateSightInfo("zh", { - left: { - heading: zhArticle.data.heading, - body: zhArticle.data.body, - media: zhArticle.data.media || [], - }, - }); + await editSightStore.getLeftArticle(articleId); updateSightInfo( languageStore.language, @@ -137,7 +112,7 @@ export const LeftWidgetTab = observer( >
    -

    {sight[language].name}

    +

    {sight[language].name.replace(/\n/g, " ")}

    - - - {data.left.media.length > 0 ? ( - <> - - {sight.common.watermark_lu && ( - preview - )} - - {sight.common.watermark_rd && ( - preview - )} - - ) : ( - - )} - - - - 0 ? ( + + + {sight.common.watermark_lu && ( + preview + )} + {sight.common.watermark_rd && ( + preview + )} + + ) : ( + + + + )} + + + {data?.left?.heading || "Название информации"} - - + {sight[language as Language].address} - - - - {data?.left?.body && ( - - - )} - + + {data?.left?.body && ( + + + + )} +
    +
    )} diff --git a/src/widgets/SightTabs/RightWidgetTab/SightFramePreview.css b/src/widgets/SightTabs/RightWidgetTab/SightFramePreview.css new file mode 100644 index 0000000..b23c962 --- /dev/null +++ b/src/widgets/SightTabs/RightWidgetTab/SightFramePreview.css @@ -0,0 +1,270 @@ +@import url("https://fonts.googleapis.com/css2?family=Roboto:wght@400;600&display=swap"); + +.sfp-sight-frame { + display: flex; + flex-direction: column; + align-items: center; + width: 550px; + border-radius: 10px; + background: + linear-gradient( + 114deg, + rgba(255, 255, 255, 0) 8.71%, + rgba(255, 255, 255, 0.16) 69.69% + ), + #006f3a; +} + +.sfp-sight-frame-media-stack { + margin-top: 2px; + border-radius: 10px 10px 0 0; + position: relative; + width: calc(100% - 4px); + height: 300px; + overflow: hidden; +} + +.sfp-sight-frame-media-item { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + object-fit: fill; + opacity: 1; + z-index: 1; + pointer-events: auto; +} + +.sfp-sight-frame-media-item video { + width: 100%; + height: 100%; + object-fit: fill; +} + +.sfp-sight-frame-media-placeholder { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + display: flex; + align-items: center; + justify-content: center; + color: rgba(255, 255, 255, 0.3); + font-family: "Roboto"; + font-size: 14px; +} + +.sfp-sight-frame-content { + display: flex; + flex-direction: column; + flex-grow: 1; + width: 100%; +} + +.sfp-sight-frame-title { + display: flex; + align-items: center; + padding: 10px 20px; + width: 100%; + text-align: left; + font-family: "Roboto"; + font-size: 24px; + font-weight: 600; + line-height: 120%; + border-bottom: 1px solid var(--Glass-stroke, rgba(255, 255, 255, 0.8)); + background: + linear-gradient( + 180deg, + rgba(255, 255, 255, 0.22) 0%, + rgba(255, 255, 255, 0.04) 100% + ), + rgba(0, 111, 58, 0.72); + box-shadow: 4px 4px 12px 0px rgba(255, 255, 255, 0.12) inset; + box-sizing: border-box; + color: white; + word-wrap: break-word; + overflow-wrap: break-word; + min-width: 0; +} + +.sfp-sight-frame-title p { + word-wrap: break-word; + overflow-wrap: break-word; + word-break: break-word; + white-space: normal !important; + margin: 0; + width: 100%; + flex: 1; + min-width: 0; + max-width: 100%; +} + +.sfp-intro-title { + height: 300px; + justify-content: center; + align-items: center; + border-bottom: none; + text-align: center; + font-size: 40px; + font-weight: 600; + line-height: 150%; +} + +.sfp-sight-frame-text-wrapper { + flex-grow: 1; + padding: 16px; + box-sizing: border-box; + max-height: calc(80vh - 354px); + min-height: 0; + overflow-y: auto; + display: flex; + flex-direction: column; +} + +.sfp-sight-frame-text-wrapper::-webkit-scrollbar { + width: 5px; +} + +.sfp-sight-frame-text-wrapper::-webkit-scrollbar-track { + background: rgba(255, 255, 255, 0.1); + border-radius: 4px; +} + +.sfp-sight-frame-text-wrapper::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.4); + border-radius: 1px; +} + +.sfp-sight-frame-text { + padding-right: 10px; + text-align: left; + color: #fff; + font-family: "Roboto"; + font-size: 18px; + font-style: normal; + font-weight: 400; + line-height: 150%; + margin-bottom: 0; +} + +.sfp-sight-frame-text p, +.sfp-sight-frame-text h1, +.sfp-sight-frame-text h2, +.sfp-sight-frame-text h3, +.sfp-sight-frame-text li { + color: #fff; + font-size: 18px; + line-height: 150%; + font-family: "Roboto"; +} + +.sfp-sight-frame-menu { + position: relative; + padding: 7px; + width: 100%; + height: 60px; + display: flex; + align-items: center; + justify-content: space-around; + border-radius: 0px 0px 10px 10px; + border-top: 1px solid rgba(255, 255, 255, 0.8); + background: + linear-gradient( + 180deg, + rgba(255, 255, 255, 0.2) 0%, + rgba(255, 255, 255, 0) 100% + ), + rgba(0, 111, 58, 0.4); + box-shadow: 4px 4px 12px 0px rgba(255, 255, 255, 0.12) inset; + backdrop-filter: blur(10px); + box-sizing: border-box; + flex-shrink: 0; +} + +.sfp-sight-frame-menu-point { + background: none; + border: none; + color: #fff; + text-align: center; + font-family: "Roboto"; + font-size: 18px; + font-style: normal; + font-weight: 400; + padding: 8px 12px; + white-space: nowrap; + cursor: pointer; + transition: + background-color 0.1s ease, + color 0.1s ease; +} + +.sfp-sight-frame-menu-point.active { + border-bottom: 2px solid #fff; + font-weight: 600; +} + +.sfp-back-btn { + position: absolute; + left: 10px; + margin-top: -4.5px; + z-index: 1; + padding: 4.5px 7.5px 4.5px 15px; + cursor: pointer; + background: none; + border: none; +} + +.sfp-watermark { + position: absolute; + width: 51px; + height: auto; + left: 8px; + top: 8px; + z-index: 10; +} + +.sfp-three-d-controls-bar { + position: absolute; + bottom: 0; + left: 0; + right: 0; + height: 44px; + display: flex; + flex-direction: row; + align-items: center; + gap: 15px; + padding: 0 30px; + background: rgba(64, 64, 64, 0.6); + z-index: 10; + pointer-events: none; +} + +.sfp-three-d-controls-bar .sfp-three-d-control-btn { + display: flex; + align-items: center; + justify-content: center; + background: transparent; + border: none; + cursor: pointer; + padding: 0; + width: min-content; + pointer-events: auto; + color: white; + transition: opacity 0.2s ease; +} + +.sfp-three-d-controls-bar .sfp-three-d-control-btn:hover { + opacity: 0.85; +} + +.sfp-three-d-controls-bar .sfp-three-d-control-btn svg { + width: 28px; + height: 28px; + flex-shrink: 0; +} + +.sfp-sight-frame-media-stack.three-d-view { + background-color: #111 !important; +} diff --git a/src/widgets/SightTabs/RightWidgetTab/SightFramePreview.tsx b/src/widgets/SightTabs/RightWidgetTab/SightFramePreview.tsx new file mode 100644 index 0000000..8ea5bb7 --- /dev/null +++ b/src/widgets/SightTabs/RightWidgetTab/SightFramePreview.tsx @@ -0,0 +1,310 @@ +import React, { useMemo, useRef, useState } from "react"; +import subtractHomeIcon from "./subtract-home.svg"; +import { ReactPhotoSphereViewer } from "react-photo-sphere-viewer"; +import { ReactMarkdownComponent } from "../../ReactMarkdown"; +import { ThreeViewErrorBoundary } from "../../MediaViewer/ThreeViewErrorBoundary"; +import { + SightFrameThreeView, + ThreeViewHandle, +} from "./SightFrameThreeView"; +import { MinusIcon, PlusIcon } from "./SightFrameThreeViewIcons"; +import "./SightFramePreview.css"; + +interface ArticleMedia { + id: string; + media_type: number; + filename: string; +} + +interface Article { + id: number; + heading: string; + body: string; + media: ArticleMedia[]; +} + +interface PreviewMediaData { + id: string; + media_type: number; + filename?: string; +} + +interface SightFramePreviewProps { + sightName: string; + previewMedia: PreviewMediaData | null; + articles: Article[]; + onArticleSelect: (index: number) => void; + previewFontSize?: number; + selectedSection: number; + onSectionChange: (section: number) => void; +} + +// Matches SightFrame.jsx renderCurrentMedia — same structure, same CSS classes +function renderCurrentMedia( + media: PreviewMediaData | ArticleMedia | null, + token: string, + threeViewResetKey: number, + threeViewControlRef: React.MutableRefObject, + onZoomIn: () => void, + onZoomOut: () => void, + onThreeViewReset: () => void +) { + if (!media) return null; + + const src = `${import.meta.env.VITE_KRBL_MEDIA}${media.id}/download?token=${token}`; + const className = "sfp-sight-frame-media-item"; + + switch (media.media_type) { + // image (1), gif (3), sprite (4) + case 1: + case 3: + case 4: + return ( + { + (e.target as HTMLImageElement).style.display = "none"; + }} + /> + ); + + // video + case 2: + return ( + + ); + + // panorama + case 5: + return ( +
    + +
    + ); + + // 3d + case 6: + return ( +
    + + + +
    + + +
    +
    + ); + + default: + return ( +
    +
    + Неподдерживаемый тип медиа +
    +
    + ); + } +} + +export function SightFramePreview({ + sightName, + previewMedia, + articles, + onArticleSelect, + previewFontSize, + selectedSection, + onSectionChange, +}: SightFramePreviewProps) { + const token = localStorage.getItem("token") ?? ""; + const [threeViewResetKey, setThreeViewResetKey] = useState(0); + const threeViewControlRef = useRef(null); + + const currentArticle = + selectedSection >= 0 && selectedSection < articles.length + ? articles[selectedSection] + : null; + + const currentMedia: PreviewMediaData | ArticleMedia | null = currentArticle + ? (currentArticle.media[0] ?? null) + : previewMedia; + + const isCurrentMedia3D = currentMedia?.media_type === 6; + + // Replicates processedSightName from SightFrame.jsx + const processedSightName = useMemo(() => { + if (!sightName) return sightName; + + // Handle \n line breaks + if (sightName.includes("\n")) { + return sightName.split("\n").map((line, i) => ( + + {i > 0 &&
    } + {line} +
    + )); + } + + const namePattern = + /([А-Яа-яA-Za-z0-9]\. [А-Яа-яA-Za-z0-9]\. [А-Яа-яA-Za-z0-9]+)/g; + const parts = sightName.split(namePattern); + if (parts.length > 1) { + return parts.map((part, index) => { + const initialsPattern = + /^[А-Яа-яA-Za-z0-9]\. [А-Яа-яA-Za-z0-9]\. [А-Яа-яA-Za-z0-9]+$/; + if (initialsPattern.test(part)) { + return ( + +
    + {part} +
    + ); + } + return {part}; + }); + } + return sightName; + }, [sightName]); + + // Replicates titleLineHeight from SightFrame.jsx + const titleLineHeight = useMemo(() => { + if (!sightName) return "120%"; + const calculatedLineHeight = Math.max( + 100, + Math.min(120, 120 - (sightName.replace(/\n/g, "").length / 10) * 1) + ); + return `${calculatedLineHeight}%`; + }, [sightName]); + + const isIntro = selectedSection === -1; + + return ( +
    + {/* media stack — always rendered, matches .sight-frame-media-stack */} +
    + {currentMedia ? ( + renderCurrentMedia( + currentMedia, + token, + threeViewResetKey, + threeViewControlRef, + () => threeViewControlRef.current?.zoomIn?.(), + () => threeViewControlRef.current?.zoomOut?.(), + () => setThreeViewResetKey((k) => k + 1) + ) + ) : ( +
    + {isIntro ? "Медиа не добавлено" : "Нет медиа"} +
    + )} +
    + + {/* content */} +
    + {/* title: intro-title (300px, 40px centered) or regular (24px with border) */} +
    +

    + {isIntro ? processedSightName : sightName} +

    +
    + + {/* text body — only for article sections */} + {!isIntro && currentArticle && ( +
    +
    + +
    +
    + )} +
    + + {/* menu — matches .sight-frame-menu */} +
    + {/* back arrow — shown when not on intro */} + {!isIntro && ( + + )} + + {/* article nav buttons — matches .sight-frame-menu-point */} + {articles.map((article, index) => ( + + ))} +
    +
    + ); +} diff --git a/src/widgets/SightTabs/RightWidgetTab/SightFrameThreeView.tsx b/src/widgets/SightTabs/RightWidgetTab/SightFrameThreeView.tsx new file mode 100644 index 0000000..417ed68 --- /dev/null +++ b/src/widgets/SightTabs/RightWidgetTab/SightFrameThreeView.tsx @@ -0,0 +1,178 @@ +import { Canvas, useThree } from "@react-three/fiber"; +import { OrbitControls, Stage, useGLTF } from "@react-three/drei"; +import React, { useEffect, Suspense } from "react"; +import * as THREE from "three"; + +const BACKGROUND_COLOR = 0x111111; + +export interface ThreeViewHandle { + zoomIn: () => void; + zoomOut: () => void; +} + +interface ThreeViewProps { + fileUrl: string; + width?: string; + height?: string; + onLoad?: () => void; + onError?: (error: string) => void; + onAspectRatioCalculated?: (ratio: number) => void; + controlRef?: React.MutableRefObject; +} + +const ZOOM_FACTOR = 1.2; +const MIN_DISTANCE = 1; +const MAX_DISTANCE = 100; + +const ZoomController = ({ + controlRef, +}: { + controlRef?: React.MutableRefObject; +}) => { + const { camera, controls } = useThree(); + + useEffect(() => { + if (!controlRef || !controls) return; + const orbit = controls as unknown as { + target: THREE.Vector3; + getDistance: () => number; + setDistance?: (d: number) => void; + }; + const zoomIn = () => { + const target = orbit.target; + const dir = new THREE.Vector3() + .subVectors(camera.position, target) + .normalize(); + const dist = camera.position.distanceTo(target); + const newDist = Math.max(MIN_DISTANCE, dist / ZOOM_FACTOR); + camera.position.copy(target).addScaledVector(dir, newDist); + camera.updateProjectionMatrix(); + }; + const zoomOut = () => { + const target = orbit.target; + const dir = new THREE.Vector3() + .subVectors(camera.position, target) + .normalize(); + const dist = camera.position.distanceTo(target); + const newDist = Math.min(MAX_DISTANCE, dist * ZOOM_FACTOR); + camera.position.copy(target).addScaledVector(dir, newDist); + camera.updateProjectionMatrix(); + }; + controlRef.current = { zoomIn, zoomOut }; + return () => { + controlRef.current = null; + }; + }, [camera, controls, controlRef]); + + return null; +}; + +const AutoResize = () => { + const { gl, camera } = useThree(); + useEffect(() => { + const handleResize = () => { + const parent = gl.domElement.parentElement; + if (!parent) return; + gl.setSize(parent.clientWidth, parent.clientHeight); + if (camera instanceof THREE.PerspectiveCamera) { + camera.aspect = parent.clientWidth / parent.clientHeight; + camera.updateProjectionMatrix(); + } + }; + handleResize(); + window.addEventListener("resize", handleResize); + return () => window.removeEventListener("resize", handleResize); + }, [gl, camera]); + return null; +}; + +const Model = ({ + fileUrl, + onLoad, +}: { + fileUrl: string; + onLoad?: () => void; +}) => { + const { scene } = useGLTF(fileUrl); + + useEffect(() => { + if (scene) { + scene.traverse((child) => { + if ((child as THREE.Mesh).isMesh) { + const m = (child as THREE.Mesh) + .material as THREE.MeshStandardMaterial; + if (m) { + m.envMapIntensity = 1.5; + m.needsUpdate = true; + } + } + }); + onLoad?.(); + } + }, [scene, onLoad]); + + return ; +}; + +export const SightFrameThreeView: React.FC = ({ + fileUrl, + width = "100%", + height = "100%", + onLoad, + onError, + controlRef, +}) => { + return ( +
    + onError?.("Canvas error")} + > + + {controlRef && } + + + + + + + + + + + + + + + + + + + + + + + + +
    + ); +}; diff --git a/src/widgets/SightTabs/RightWidgetTab/SightFrameThreeViewIcons.tsx b/src/widgets/SightTabs/RightWidgetTab/SightFrameThreeViewIcons.tsx new file mode 100644 index 0000000..a854ffa --- /dev/null +++ b/src/widgets/SightTabs/RightWidgetTab/SightFrameThreeViewIcons.tsx @@ -0,0 +1,55 @@ +import React from "react"; + +export const MinusIcon: React.FC> = (props) => ( + + + + + + + + + + + +); + +export const PlusIcon: React.FC> = (props) => ( + + + + + + + + + + + +); diff --git a/src/widgets/SightTabs/RightWidgetTab/index.tsx b/src/widgets/SightTabs/RightWidgetTab/index.tsx index 95265cb..14bf0b0 100644 --- a/src/widgets/SightTabs/RightWidgetTab/index.tsx +++ b/src/widgets/SightTabs/RightWidgetTab/index.tsx @@ -1,18 +1,16 @@ import { Box, Button, - Paper, Typography, - Menu, - MenuItem, TextField, + Slider, + Stack, } from "@mui/material"; import { authInstance, BackButton, editSightStore, languageStore, - SelectArticleModal, SelectMediaDialog, TabPanel, UploadMediaDialog, @@ -22,12 +20,11 @@ import { LanguageSwitcher, MediaArea, MediaAreaForSight, - ReactMarkdownComponent, ReactMarkdownEditor, } from "@widgets"; -import { ImagePlus, Plus, Save, Trash2, Unlink, X } from "lucide-react"; +import { Plus, Save, Trash2, Unlink, X } from "lucide-react"; import { observer } from "mobx-react-lite"; -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { toast } from "react-toastify"; import { MediaViewer } from "../../MediaViewer/index"; import { @@ -36,21 +33,20 @@ import { Draggable, DropResult, } from "@hello-pangea/dnd"; +import { SightFramePreview } from "./SightFramePreview"; export const RightWidgetTab = observer( ({ value, index }: { value: number; index: number }) => { - const [anchorEl, setAnchorEl] = useState(null); - const { sight, updateRightArticleInfo, getRightArticles, updateSight, + updateSightInfo, unlinkPreviewMedia, linkPreviewMedia, unlinkRightArticle, deleteRightArticle, - linkArticle, deleteRightArticleMedia, createLinkWithRightArticle, setFileToUpload, @@ -59,6 +55,27 @@ export const RightWidgetTab = observer( } = editSightStore; const [previewMedia, setPreviewMedia] = useState(null); + const shortNameRef = useRef(null); + + const insertNewline = () => { + const input = shortNameRef.current; + const currentValue = sight[language].name || ""; + if (!input) { + updateSightInfo(language, { name: currentValue + "\n" }); + return; + } + const start = input.selectionStart ?? currentValue.length; + const end = input.selectionEnd ?? start; + const newValue = currentValue.slice(0, start) + "\n" + currentValue.slice(end); + updateSightInfo(language, { name: newValue }); + requestAnimationFrame(() => { + if (shortNameRef.current) { + shortNameRef.current.selectionStart = start + 1; + shortNameRef.current.selectionEnd = start + 1; + shortNameRef.current.focus(); + } + }); + }; useEffect(() => { const fetchPreviewMedia = async () => { @@ -92,22 +109,23 @@ export const RightWidgetTab = observer( const [activeArticleIndex, setActiveArticleIndex] = useState( null ); - const [isSelectModalOpen, setIsSelectModalOpen] = useState(false); + const [previewSection, setPreviewSection] = useState(-1); const [isSelectMediaModalOpen, setIsSelectMediaModalOpen] = useState(false); const [isDeleteArticleModalOpen, setIsDeleteArticleModalOpen] = useState(false); - const open = Boolean(anchorEl); const handleDeleteArticle = () => { - deleteRightArticle(sight[language].right[activeArticleIndex || 0].id); - setActiveArticleIndex(null); - }; - - const handleClick = (event: React.MouseEvent) => { - setAnchorEl(event.currentTarget); - }; - const handleClose = () => { - setAnchorEl(null); + const idx = activeArticleIndex || 0; + deleteRightArticle(sight[language].right[idx].id); + if (idx > 0) { + setActiveArticleIndex(idx - 1); + setPreviewSection(idx - 1); + setType("article"); + } else { + setActiveArticleIndex(null); + setPreviewSection(-1); + setType("media"); + } }; const handleSelectArticle = (index: number) => { @@ -117,46 +135,20 @@ export const RightWidgetTab = observer( const handleCreateNew = async () => { try { const newArticleId = await createNewRightArticle(); - handleClose(); - // Automatically select the newly created article + const newIndex = sight[language].right.findIndex( (article) => article.id === newArticleId ); if (newIndex > -1) { setActiveArticleIndex(newIndex); setType("article"); + setPreviewSection(newIndex); } } catch (error) { console.error("Error creating new article:", error); } }; - const handleSelectExisting = () => { - setIsSelectModalOpen(true); - handleClose(); - }; - - const handleCloseSelectModal = () => { - setIsSelectModalOpen(false); - }; - - const handleArticleSelect = async (id: number) => { - try { - const linkedArticleId = await linkArticle(id); - handleCloseSelectModal(); - // Automatically select the newly linked article - const newIndex = sight[language].right.findIndex( - (article) => article.id === linkedArticleId - ); - if (newIndex > -1) { - setActiveArticleIndex(newIndex); - setType("article"); - } - } catch (error) { - console.error("Error linking article:", error); - } - }; - const handleMediaSelected = async (media: { id: string; filename: string; @@ -177,30 +169,19 @@ export const RightWidgetTab = observer( const handleDragEnd = (result: DropResult) => { const { source, destination } = result; - // 1. Guard clause: If dropped outside any droppable area, do nothing. if (!destination) return; - // Extract source and destination indices const sourceIndex = source.index; const destinationIndex = destination.index; - // 2. Guard clause: If dropped in the same position, do nothing. if (sourceIndex === destinationIndex) return; - // 3. Create a new array with reordered articles: - // - Create a shallow copy of the current articles array. - // This is important for immutability and triggering re-renders. const newRightArticles = [...sight[language].right]; - // - Remove the dragged article from its original position. - // `splice` returns an array of removed items, so we destructure the first (and only) one. const [movedArticle] = newRightArticles.splice(sourceIndex, 1); - // - Insert the moved article into its new position. newRightArticles.splice(destinationIndex, 0, movedArticle); - // 4. Update the store with the new order: - // This will typically trigger a re-render of the component with the updated list. updateRightArticles(newRightArticles); }; @@ -220,17 +201,24 @@ export const RightWidgetTab = observer( >
    -

    {sight[language].name}

    +

    {sight[language].name.replace(/\n/g, " ")}

    - + setType("media")} - className="w-full bg-green-200 p-4 rounded-2xl cursor-pointer text-sm hover:bg-gray-300 transition-all duration-300" + onClick={() => { + setType("media"); + setPreviewSection(-1); + }} + className={`w-full p-4 rounded-2xl cursor-pointer text-sm transition-all duration-300 ${ + type === "media" + ? "bg-blue-400 text-white" + : "bg-gray-200 hover:bg-gray-300" + }`} > Предпросмотр медиа @@ -255,14 +243,17 @@ export const RightWidgetTab = observer( { handleSelectArticle(index); setType("article"); + setPreviewSection(index); }} > @@ -285,27 +276,10 @@ export const RightWidgetTab = observer( - - - Создать новую - - - Выбрать существующую статью - - {type === "article" && ( @@ -426,21 +400,12 @@ export const RightWidgetTab = observer( media_type: previewMedia.media_type, filename: previewMedia.filename || "", }} + fullWidth + fullHeight /> )} - {!previewMedia && ( - { - linkPreviewMedia(mediaId); - }} - onFilesDrop={() => {}} - contextObjectName={sight[language].name} - contextType="sight" - isArticle={false} - /> - )} )} @@ -475,163 +440,94 @@ export const RightWidgetTab = observer( - {type === "article" && ( - - {activeArticleIndex !== null && ( - + {type === "media" && ( + + { + const raw = e.target.value; + if (raw === "") { + updateSightInfo(language, { preview_font_size: undefined }, true); + return; + } + const val = Math.max(1, Math.min(300, Math.round(Number(raw)))); + if (Number.isFinite(val)) { + updateSightInfo(language, { preview_font_size: val }, true); + } }} - elevation={2} + slotProps={{ input: { min: 1, max: 300 } }} + sx={{ width: "200px" }} + /> + { + if (typeof newValue === "number") { + updateSightInfo(language, { preview_font_size: newValue }, true); + } + }} + sx={{ flexGrow: 1 }} + /> + + )} + {type === "media" && ( + + updateSightInfo(language, { name: e.target.value })} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + insertNewline(); + } + }} + inputRef={shortNameRef} + sx={{ flexGrow: 1 }} + /> + - ))} - - - - )} - - )} + ↵ + + + )} + { + handleSelectArticle(idx); + setType("article"); + setPreviewSection(idx); + }} + previewFontSize={sight.common.preview_font_size} + selectedSection={previewSection} + onSectionChange={(section) => { + setPreviewSection(section); + if (section === -1) { + setType("media"); + } else { + handleSelectArticle(section); + setType("article"); + } + }} + /> + - article.id)} - /> setIsSelectMediaModalOpen(false)} diff --git a/src/widgets/SightTabs/RightWidgetTab/subtract-home.svg b/src/widgets/SightTabs/RightWidgetTab/subtract-home.svg new file mode 100644 index 0000000..afbba3a --- /dev/null +++ b/src/widgets/SightTabs/RightWidgetTab/subtract-home.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/widgets/SightsTable/index.tsx b/src/widgets/SightsTable/index.tsx index 796c471..363749c 100644 --- a/src/widgets/SightsTable/index.tsx +++ b/src/widgets/SightsTable/index.tsx @@ -87,12 +87,14 @@ export const SightsTable = observer(() => {
    )} {videoId ? ( - +
    )} diff --git a/src/widgets/index.ts b/src/widgets/index.ts index 03fd69b..3351413 100644 --- a/src/widgets/index.ts +++ b/src/widgets/index.ts @@ -20,3 +20,5 @@ export * from "./CreateButton"; export * from "./SaveWithoutCityAgree"; export * from "./CitySelector"; export * from "./modals"; +export * from "./TestingModeBanner"; +export * from "./PermissionsTable"; diff --git a/src/widgets/modals/EditStationModal.tsx b/src/widgets/modals/EditStationModal.tsx index 8847bdf..7f814dc 100644 --- a/src/widgets/modals/EditStationModal.tsx +++ b/src/widgets/modals/EditStationModal.tsx @@ -20,18 +20,6 @@ interface EditStationModalProps { onClose: () => void; } -const transferFields = [ - { key: "bus", label: "Автобус" }, - { key: "metro_blue", label: "Метро (синяя)" }, - { key: "metro_green", label: "Метро (зеленая)" }, - { key: "metro_orange", label: "Метро (оранжевая)" }, - { key: "metro_purple", label: "Метро (фиолетовая)" }, - { key: "metro_red", label: "Метро (красная)" }, - { key: "train", label: "Электричка" }, - { key: "tram", label: "Трамвай" }, - { key: "trolleybus", label: "Троллейбус" }, -]; - export const EditStationModal = observer( ({ open, onClose }: EditStationModalProps) => { const { id: routeId } = useParams<{ id: string }>(); @@ -95,37 +83,6 @@ export const EditStationModal = observer( defaultValue={station?.offset_y} />
    - - - Пересадки - - - {transferFields.map(({ key, label }) => ( - { - setRouteStations(Number(routeId), selectedStationId, { - ...station, - transfers: { - ...station?.transfers, - [key]: e.target.value, - }, - }); - }} - /> - ))} - - + + + + ); + } +); diff --git a/src/widgets/modals/index.ts b/src/widgets/modals/index.ts index 65ce130..4ac3693 100644 --- a/src/widgets/modals/index.ts +++ b/src/widgets/modals/index.ts @@ -1,2 +1,3 @@ export * from "./SelectArticleDialog"; export * from "./EditStationModal"; +export * from "./EditStationTransfersModal"; diff --git a/tsconfig.tsbuildinfo b/tsconfig.tsbuildinfo deleted file mode 100644 index 822f444..0000000 --- a/tsconfig.tsbuildinfo +++ /dev/null @@ -1 +0,0 @@ -{"root":["./src/main.tsx","./src/vite-env.d.ts","./src/app/globalerrorboundary.tsx","./src/app/index.tsx","./src/app/router/index.tsx","./src/entities/index.ts","./src/entities/navigation/index.ts","./src/entities/navigation/model/index.ts","./src/entities/navigation/ui/index.tsx","./src/features/index.ts","./src/features/navigation/index.ts","./src/features/navigation/ui/index.tsx","./src/pages/index.ts","./src/pages/article/index.ts","./src/pages/article/articlecreatepage/index.tsx","./src/pages/article/articleeditpage/index.tsx","./src/pages/article/articlelistpage/index.tsx","./src/pages/article/articlepreviewpage/previewleftwidget.tsx","./src/pages/article/articlepreviewpage/previewrightwidget.tsx","./src/pages/article/articlepreviewpage/index.tsx","./src/pages/carrier/index.ts","./src/pages/carrier/carriercreatepage/index.tsx","./src/pages/carrier/carriereditpage/index.tsx","./src/pages/carrier/carrierlistpage/index.tsx","./src/pages/city/index.ts","./src/pages/city/citycreatepage/index.tsx","./src/pages/city/cityeditpage/index.tsx","./src/pages/city/citylistpage/index.tsx","./src/pages/city/citypreviewpage/index.tsx","./src/pages/country/index.ts","./src/pages/country/countryaddpage/index.tsx","./src/pages/country/countrycreatepage/index.tsx","./src/pages/country/countryeditpage/index.tsx","./src/pages/country/countrylistpage/index.tsx","./src/pages/country/countrypreviewpage/index.tsx","./src/pages/createsightpage/index.tsx","./src/pages/devicespage/index.tsx","./src/pages/editsightpage/index.tsx","./src/pages/loginpage/index.tsx","./src/pages/mainpage/index.tsx","./src/pages/mappage/index.tsx","./src/pages/mappage/mapstore.ts","./src/pages/media/index.ts","./src/pages/media/mediacreatepage/index.tsx","./src/pages/media/mediaeditpage/index.tsx","./src/pages/media/medialistpage/index.tsx","./src/pages/media/mediapreviewpage/index.tsx","./src/pages/route/linekedstations.tsx","./src/pages/route/index.ts","./src/pages/route/routecreatepage/index.tsx","./src/pages/route/routeeditpage/index.tsx","./src/pages/route/routelistpage/index.tsx","./src/pages/route/route-preview/constants.ts","./src/pages/route/route-preview/infinitecanvas.tsx","./src/pages/route/route-preview/leftsidebar.tsx","./src/pages/route/route-preview/mapdatacontext.tsx","./src/pages/route/route-preview/rightsidebar.tsx","./src/pages/route/route-preview/sight.tsx","./src/pages/route/route-preview/sightinfowidget.tsx","./src/pages/route/route-preview/station.tsx","./src/pages/route/route-preview/transformcontext.tsx","./src/pages/route/route-preview/travelpath.tsx","./src/pages/route/route-preview/widgets.tsx","./src/pages/route/route-preview/index.tsx","./src/pages/route/route-preview/types.ts","./src/pages/route/route-preview/utils.ts","./src/pages/sight/index.ts","./src/pages/sight/sightlistpage/index.tsx","./src/pages/sightpage/index.tsx","./src/pages/snapshot/index.ts","./src/pages/snapshot/snapshotcreatepage/index.tsx","./src/pages/snapshot/snapshotlistpage/index.tsx","./src/pages/station/linkedsights.tsx","./src/pages/station/index.ts","./src/pages/station/stationcreatepage/index.tsx","./src/pages/station/stationeditpage/index.tsx","./src/pages/station/stationlistpage/index.tsx","./src/pages/station/stationpreviewpage/index.tsx","./src/pages/user/index.ts","./src/pages/user/usercreatepage/index.tsx","./src/pages/user/usereditpage/index.tsx","./src/pages/user/userlistpage/index.tsx","./src/pages/vehicle/index.ts","./src/pages/vehicle/vehiclecreatepage/index.tsx","./src/pages/vehicle/vehicleeditpage/index.tsx","./src/pages/vehicle/vehiclelistpage/index.tsx","./src/pages/vehicle/vehiclepreviewpage/index.tsx","./src/shared/index.tsx","./src/shared/api/index.tsx","./src/shared/config/carriersvg.tsx","./src/shared/config/constants.tsx","./src/shared/config/index.ts","./src/shared/const/index.ts","./src/shared/const/mediatypes.ts","./src/shared/hooks/index.ts","./src/shared/hooks/useselectedcity.ts","./src/shared/lib/gltfcachemanager.ts","./src/shared/lib/index.ts","./src/shared/lib/decodejwt/index.ts","./src/shared/lib/mui/theme.ts","./src/shared/modals/index.ts","./src/shared/modals/previewmediadialog/index.tsx","./src/shared/modals/selectarticledialog/index.tsx","./src/shared/modals/selectmediadialog/index.tsx","./src/shared/modals/uploadmediadialog/index.tsx","./src/shared/store/index.ts","./src/shared/store/articlesstore/index.tsx","./src/shared/store/authstore/index.tsx","./src/shared/store/carrierstore/index.tsx","./src/shared/store/citystore/index.ts","./src/shared/store/countrystore/index.ts","./src/shared/store/createsightstore/index.tsx","./src/shared/store/devicesstore/index.tsx","./src/shared/store/editsightstore/index.tsx","./src/shared/store/languagestore/index.tsx","./src/shared/store/mediastore/index.tsx","./src/shared/store/menustore/index.ts","./src/shared/store/modelloadingstore/index.ts","./src/shared/store/routestore/index.ts","./src/shared/store/selectedcitystore/index.ts","./src/shared/store/sightsstore/index.tsx","./src/shared/store/snapshotstore/index.ts","./src/shared/store/stationsstore/index.ts","./src/shared/store/userstore/index.ts","./src/shared/store/vehiclestore/index.ts","./src/shared/ui/index.ts","./src/shared/ui/backbutton/index.tsx","./src/shared/ui/coordinatesinput/index.tsx","./src/shared/ui/input/index.tsx","./src/shared/ui/loadingspinner/index.tsx","./src/shared/ui/modal/index.tsx","./src/shared/ui/modelloadingindicator/index.tsx","./src/shared/ui/tabpanel/index.tsx","./src/widgets/index.ts","./src/widgets/cityselector/index.tsx","./src/widgets/createbutton/index.tsx","./src/widgets/deletemodal/index.tsx","./src/widgets/devicestable/index.tsx","./src/widgets/imageuploadcard/index.tsx","./src/widgets/languageswitcher/index.tsx","./src/widgets/layout/index.tsx","./src/widgets/layout/ui/appbar.tsx","./src/widgets/layout/ui/drawer.tsx","./src/widgets/layout/ui/drawerheader.tsx","./src/widgets/leaveagree/index.tsx","./src/widgets/mediaarea/index.tsx","./src/widgets/mediaareaforsight/index.tsx","./src/widgets/mediaviewer/threeview.tsx","./src/widgets/mediaviewer/threeviewerrorboundary.tsx","./src/widgets/mediaviewer/index.tsx","./src/widgets/modelviewer3d/index.tsx","./src/widgets/reactmarkdown/index.tsx","./src/widgets/reactmarkdowneditor/index.tsx","./src/widgets/savewithoutcityagree/index.tsx","./src/widgets/sightedit/index.tsx","./src/widgets/sightheader/index.ts","./src/widgets/sightheader/ui/index.tsx","./src/widgets/sighttabs/index.ts","./src/widgets/sighttabs/createinformationtab/mediauploadbox.tsx","./src/widgets/sighttabs/createinformationtab/index.tsx","./src/widgets/sighttabs/createlefttab/index.tsx","./src/widgets/sighttabs/createrighttab/index.tsx","./src/widgets/sighttabs/informationtab/index.tsx","./src/widgets/sighttabs/leftwidgettab/index.tsx","./src/widgets/sighttabs/rightwidgettab/index.tsx","./src/widgets/sightstable/index.tsx","./src/widgets/snapshotrestore/index.tsx","./src/widgets/videopreviewcard/index.tsx","./src/widgets/modals/editstationmodal.tsx","./src/widgets/modals/index.ts","./src/widgets/modals/selectarticledialog/index.tsx"],"version":"5.8.3"} \ No newline at end of file diff --git a/vite.config.ts b/vite.config.ts index ca80bfc..c385836 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,9 +1,9 @@ -import { defineConfig } from "vite"; +import { defineConfig, type UserConfigExport } from "vite"; import react from "@vitejs/plugin-react"; import tailwindcss from "@tailwindcss/vite"; import path from "path"; +import pkg from "./package.json"; -// https://vite.dev/config/ export default defineConfig({ plugins: [react(), tailwindcss()], resolve: { @@ -16,4 +16,11 @@ export default defineConfig({ "@app": path.resolve(__dirname, "src/app"), }, }, + define: { + __APP_VERSION__: JSON.stringify(pkg.version), + }, + + build: { + chunkSizeWarningLimit: 5000, + }, }); diff --git a/yarn.lock b/yarn.lock index 55b6b04..488eb9e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2,14 +2,6 @@ # yarn lockfile v1 -"@ampproject/remapping@^2.2.0", "@ampproject/remapping@^2.3.0": - version "2.3.0" - resolved "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz" - integrity sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw== - dependencies: - "@jridgewell/gen-mapping" "^0.3.5" - "@jridgewell/trace-mapping" "^0.3.24" - "@babel/code-frame@^7.0.0", "@babel/code-frame@^7.27.1": version "7.27.1" resolved "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz" @@ -20,40 +12,40 @@ picocolors "^1.1.1" "@babel/compat-data@^7.27.2": - version "7.27.3" - resolved "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.27.3.tgz" - integrity sha512-V42wFfx1ymFte+ecf6iXghnnP8kWTO+ZLXIyZq+1LAXHHvTZdVxicn4yiVYdYMGaCO3tmqub11AorKkv+iodqw== + version "7.28.5" + resolved "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.5.tgz" + integrity sha512-6uFXyCayocRbqhZOB+6XcuZbkMNimwfVGFji8CTZnCzOHVGvDqzvitu1re2AU5LROliz7eQPhB8CpAMvnx9EjA== -"@babel/core@^7.26.10": - version "7.27.3" - resolved "https://registry.npmjs.org/@babel/core/-/core-7.27.3.tgz" - integrity sha512-hyrN8ivxfvJ4i0fIJuV4EOlV0WDMz5Ui4StRTgVaAvWeiRCilXgwVvxJKtFQ3TKtHgJscB2YiXKGNJuVwhQMtA== +"@babel/core@^7.0.0", "@babel/core@^7.0.0-0", "@babel/core@^7.21.3", "@babel/core@^7.28.0": + version "7.28.5" + resolved "https://registry.npmjs.org/@babel/core/-/core-7.28.5.tgz" + integrity sha512-e7jT4DxYvIDLk1ZHmU/m/mB19rex9sv0c2ftBtjSBv+kVM/902eh0fINUzD7UwLLNR+jU585GxUJ8/EBfAM5fw== dependencies: - "@ampproject/remapping" "^2.2.0" "@babel/code-frame" "^7.27.1" - "@babel/generator" "^7.27.3" + "@babel/generator" "^7.28.5" "@babel/helper-compilation-targets" "^7.27.2" - "@babel/helper-module-transforms" "^7.27.3" - "@babel/helpers" "^7.27.3" - "@babel/parser" "^7.27.3" + "@babel/helper-module-transforms" "^7.28.3" + "@babel/helpers" "^7.28.4" + "@babel/parser" "^7.28.5" "@babel/template" "^7.27.2" - "@babel/traverse" "^7.27.3" - "@babel/types" "^7.27.3" + "@babel/traverse" "^7.28.5" + "@babel/types" "^7.28.5" + "@jridgewell/remapping" "^2.3.5" convert-source-map "^2.0.0" debug "^4.1.0" gensync "^1.0.0-beta.2" json5 "^2.2.3" semver "^6.3.1" -"@babel/generator@^7.27.3": - version "7.27.3" - resolved "https://registry.npmjs.org/@babel/generator/-/generator-7.27.3.tgz" - integrity sha512-xnlJYj5zepml8NXtjkG0WquFUv8RskFqyFcVgTBp5k+NaA/8uw/K+OSVf8AMGw5e9HKP2ETd5xpK5MLZQD6b4Q== +"@babel/generator@^7.28.5": + version "7.28.5" + resolved "https://registry.npmjs.org/@babel/generator/-/generator-7.28.5.tgz" + integrity sha512-3EwLFhZ38J4VyIP6WNtt2kUdW9dokXA9Cr4IVIFHuCpZ3H8/YFOl5JjZHisrn1fATPBmKKqXzDFvh9fUwHz6CQ== dependencies: - "@babel/parser" "^7.27.3" - "@babel/types" "^7.27.3" - "@jridgewell/gen-mapping" "^0.3.5" - "@jridgewell/trace-mapping" "^0.3.25" + "@babel/parser" "^7.28.5" + "@babel/types" "^7.28.5" + "@jridgewell/gen-mapping" "^0.3.12" + "@jridgewell/trace-mapping" "^0.3.28" jsesc "^3.0.2" "@babel/helper-compilation-targets@^7.27.2": @@ -67,6 +59,11 @@ lru-cache "^5.1.1" semver "^6.3.1" +"@babel/helper-globals@^7.28.0": + version "7.28.0" + resolved "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz" + integrity sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw== + "@babel/helper-module-imports@^7.16.7", "@babel/helper-module-imports@^7.27.1": version "7.27.1" resolved "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.27.1.tgz" @@ -75,14 +72,14 @@ "@babel/traverse" "^7.27.1" "@babel/types" "^7.27.1" -"@babel/helper-module-transforms@^7.27.3": - version "7.27.3" - resolved "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.27.3.tgz" - integrity sha512-dSOvYwvyLsWBeIRyOeHXp5vPj5l1I011r52FM1+r1jCERv+aFXYk4whgQccYEGYxK2H3ZAIA8nuPkQ0HaUo3qg== +"@babel/helper-module-transforms@^7.28.3": + version "7.28.3" + resolved "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.3.tgz" + integrity sha512-gytXUbs8k2sXS9PnQptz5o0QnpLL51SwASIORY6XaBKF88nsOT0Zw9szLqlSGQDP/4TljBAD5y98p2U1fqkdsw== dependencies: "@babel/helper-module-imports" "^7.27.1" "@babel/helper-validator-identifier" "^7.27.1" - "@babel/traverse" "^7.27.3" + "@babel/traverse" "^7.28.3" "@babel/helper-plugin-utils@^7.27.1": version "7.27.1" @@ -94,49 +91,49 @@ resolved "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz" integrity sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA== -"@babel/helper-validator-identifier@^7.27.1": - version "7.27.1" - resolved "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz" - integrity sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow== +"@babel/helper-validator-identifier@^7.27.1", "@babel/helper-validator-identifier@^7.28.5": + version "7.28.5" + resolved "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz" + integrity sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q== "@babel/helper-validator-option@^7.27.1": version "7.27.1" resolved "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz" integrity sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg== -"@babel/helpers@^7.27.3": - version "7.27.3" - resolved "https://registry.npmjs.org/@babel/helpers/-/helpers-7.27.3.tgz" - integrity sha512-h/eKy9agOya1IGuLaZ9tEUgz+uIRXcbtOhRtUyyMf8JFmn1iT13vnl/IGVWSkdOCG/pC57U4S1jnAabAavTMwg== +"@babel/helpers@^7.28.4": + version "7.28.4" + resolved "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz" + integrity sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w== dependencies: "@babel/template" "^7.27.2" - "@babel/types" "^7.27.3" + "@babel/types" "^7.28.4" -"@babel/parser@^7.1.0", "@babel/parser@^7.20.7", "@babel/parser@^7.27.2", "@babel/parser@^7.27.3": - version "7.27.3" - resolved "https://registry.npmjs.org/@babel/parser/-/parser-7.27.3.tgz" - integrity sha512-xyYxRj6+tLNDTWi0KCBcZ9V7yg3/lwL9DWh9Uwh/RIVlIfFidggcgxKX3GCXwCiswwcGRawBKbEg2LG/Y8eJhw== +"@babel/parser@^7.1.0", "@babel/parser@^7.20.7", "@babel/parser@^7.27.2", "@babel/parser@^7.28.5": + version "7.28.5" + resolved "https://registry.npmjs.org/@babel/parser/-/parser-7.28.5.tgz" + integrity sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ== dependencies: - "@babel/types" "^7.27.3" + "@babel/types" "^7.28.5" -"@babel/plugin-transform-react-jsx-self@^7.25.9": +"@babel/plugin-transform-react-jsx-self@^7.27.1": 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== dependencies: "@babel/helper-plugin-utils" "^7.27.1" -"@babel/plugin-transform-react-jsx-source@^7.25.9": +"@babel/plugin-transform-react-jsx-source@^7.27.1": 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== dependencies: "@babel/helper-plugin-utils" "^7.27.1" -"@babel/runtime@^7.12.5", "@babel/runtime@^7.17.8", "@babel/runtime@^7.18.3", "@babel/runtime@^7.26.0", "@babel/runtime@^7.26.7", "@babel/runtime@^7.27.1", "@babel/runtime@^7.27.4", "@babel/runtime@^7.5.5", "@babel/runtime@^7.8.7": - version "7.27.6" - resolved "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.6.tgz" - integrity sha512-vbavdySgbTTrmFE+EsiqUTzlOr5bzlnJtUv9PynGCAKvfQqjIXbvFdumPM/GxMDfyuGMJaJAU6TO4zc1Jf1i8Q== +"@babel/runtime@^7.12.5", "@babel/runtime@^7.17.8", "@babel/runtime@^7.18.3", "@babel/runtime@^7.26.0", "@babel/runtime@^7.26.7", "@babel/runtime@^7.28.4", "@babel/runtime@^7.5.5", "@babel/runtime@^7.8.7": + version "7.28.4" + resolved "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz" + integrity sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ== "@babel/template@^7.27.2": version "7.27.2" @@ -147,54 +144,32 @@ "@babel/parser" "^7.27.2" "@babel/types" "^7.27.1" -"@babel/traverse@^7.27.1", "@babel/traverse@^7.27.3": - version "7.27.3" - resolved "https://registry.npmjs.org/@babel/traverse/-/traverse-7.27.3.tgz" - integrity sha512-lId/IfN/Ye1CIu8xG7oKBHXd2iNb2aW1ilPszzGcJug6M8RCKfVNcYhpI5+bMvFYjK7lXIM0R+a+6r8xhHp2FQ== +"@babel/traverse@^7.27.1", "@babel/traverse@^7.28.3", "@babel/traverse@^7.28.5": + version "7.28.5" + resolved "https://registry.npmjs.org/@babel/traverse/-/traverse-7.28.5.tgz" + integrity sha512-TCCj4t55U90khlYkVV/0TfkJkAkUg3jZFA3Neb7unZT8CPok7iiRfaX0F+WnqWqt7OxhOn0uBKXCw4lbL8W0aQ== dependencies: "@babel/code-frame" "^7.27.1" - "@babel/generator" "^7.27.3" - "@babel/parser" "^7.27.3" + "@babel/generator" "^7.28.5" + "@babel/helper-globals" "^7.28.0" + "@babel/parser" "^7.28.5" "@babel/template" "^7.27.2" - "@babel/types" "^7.27.3" + "@babel/types" "^7.28.5" debug "^4.3.1" - globals "^11.1.0" -"@babel/types@^7.0.0", "@babel/types@^7.20.7", "@babel/types@^7.27.1", "@babel/types@^7.27.3": - version "7.27.3" - resolved "https://registry.npmjs.org/@babel/types/-/types-7.27.3.tgz" - integrity sha512-Y1GkI4ktrtvmawoSq+4FCVHNryea6uR+qUQy0AGxLSsjCX0nVmkYQMBLHDkXZuo5hGx7eYdnIaslsdBFm7zbUw== +"@babel/types@^7.0.0", "@babel/types@^7.20.7", "@babel/types@^7.21.3", "@babel/types@^7.27.1", "@babel/types@^7.28.2", "@babel/types@^7.28.4", "@babel/types@^7.28.5": + version "7.28.5" + resolved "https://registry.npmjs.org/@babel/types/-/types-7.28.5.tgz" + integrity sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA== dependencies: "@babel/helper-string-parser" "^7.27.1" - "@babel/helper-validator-identifier" "^7.27.1" + "@babel/helper-validator-identifier" "^7.28.5" "@dimforge/rapier3d-compat@~0.12.0": version "0.12.0" resolved "https://registry.npmjs.org/@dimforge/rapier3d-compat/-/rapier3d-compat-0.12.0.tgz" integrity sha512-uekIGetywIgopfD97oDL5PfeezkFpNhwlzlaEYNOA0N6ghdsOvh/HYjSMek5Q2O1PYvRSDFcqFVJl4r4ZBwOow== -"@emnapi/core@^1.4.3": - version "1.6.0" - resolved "https://registry.yarnpkg.com/@emnapi/core/-/core-1.6.0.tgz#517f65d1c8270d5d5aa1aad660d5acb897430dca" - integrity sha512-zq/ay+9fNIJJtJiZxdTnXS20PllcYMX3OE23ESc4HK/bdYu3cOWYVhsOhVnXALfU/uqJIxn5NBPd9z4v+SfoSg== - dependencies: - "@emnapi/wasi-threads" "1.1.0" - tslib "^2.4.0" - -"@emnapi/runtime@^1.4.3": - version "1.6.0" - resolved "https://registry.yarnpkg.com/@emnapi/runtime/-/runtime-1.6.0.tgz#8fe297e0090f6e89a57a1f31f1c440bdbc3c01d8" - integrity sha512-obtUmAHTMjll499P+D9A3axeJFlhdjOWdKUNs/U6QIGT7V5RjcUW1xToAzjvmgTSQhDbYn/NwfTRoJcQ2rNBxA== - dependencies: - tslib "^2.4.0" - -"@emnapi/wasi-threads@1.1.0", "@emnapi/wasi-threads@^1.0.2": - version "1.1.0" - resolved "https://registry.yarnpkg.com/@emnapi/wasi-threads/-/wasi-threads-1.1.0.tgz#60b2102fddc9ccb78607e4a3cf8403ea69be41bf" - integrity sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ== - dependencies: - tslib "^2.4.0" - "@emotion/babel-plugin@^11.13.5": version "11.13.5" resolved "https://registry.npmjs.org/@emotion/babel-plugin/-/babel-plugin-11.13.5.tgz" @@ -212,7 +187,7 @@ source-map "^0.5.7" stylis "4.2.0" -"@emotion/cache@^11.13.5", "@emotion/cache@^11.14.0": +"@emotion/cache@^11.14.0": version "11.14.0" resolved "https://registry.npmjs.org/@emotion/cache/-/cache-11.14.0.tgz" integrity sha512-L/B1lc/TViYk4DcpGxtAVbx0ZyiKM5ktoIyafGkH6zg/tj+mA+NE//aPYKG0k8kCHSHVJrpLpcAlOBEXQ3SavA== @@ -229,9 +204,9 @@ integrity sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g== "@emotion/is-prop-valid@^1.3.0": - version "1.3.1" - resolved "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.3.1.tgz" - integrity sha512-/ACwoqx7XQi9knQs/G0qKvv5teDMhD7bXYns9N/wM8ah8iNb8jZ2uNO0YOgiq2o2poIvVtJS2YALasQuMSQ7Kw== + version "1.4.0" + resolved "https://registry.npmjs.org/@emotion/is-prop-valid/-/is-prop-valid-1.4.0.tgz" + integrity sha512-QgD4fyscGcbbKwJmqNvUMSE02OsHUa+lAWKdEUIJKgqe5IwRSKd7+KhibEWdaKwgjLj0DRSHA9biAIqGBk05lw== dependencies: "@emotion/memoize" "^0.9.0" @@ -240,7 +215,7 @@ resolved "https://registry.npmjs.org/@emotion/memoize/-/memoize-0.9.0.tgz" integrity sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ== -"@emotion/react@^11.14.0": +"@emotion/react@^11.0.0-rc.0", "@emotion/react@^11.14.0", "@emotion/react@^11.4.1", "@emotion/react@^11.5.0", "@emotion/react@^11.9.0": version "11.14.0" resolved "https://registry.npmjs.org/@emotion/react/-/react-11.14.0.tgz" integrity sha512-O000MLDBDdk/EohJPFUqvnp4qnHeYkVP5B0xEG0D/L7cOKP9kefu2DXn8dj74cQfsEzUqh+sr1RzFqiL1o+PpA== @@ -270,10 +245,10 @@ resolved "https://registry.npmjs.org/@emotion/sheet/-/sheet-1.4.0.tgz" integrity sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg== -"@emotion/styled@^11.14.0": - version "11.14.0" - resolved "https://registry.npmjs.org/@emotion/styled/-/styled-11.14.0.tgz" - integrity sha512-XxfOnXFffatap2IyCeJyNov3kiDQWoR08gPUQxvbL7fxKryGBKUZUkG6Hz48DZwVrJSVh9sJboyV1Ds4OW6SgA== +"@emotion/styled@^11.14.0", "@emotion/styled@^11.3.0", "@emotion/styled@^11.8.1": + version "11.14.1" + resolved "https://registry.npmjs.org/@emotion/styled/-/styled-11.14.1.tgz" + integrity sha512-qEEJt42DuToa3gurlH4Qqc1kVpNq8wO8cJtDzU46TjlzWjDlsVyevtYCRijVq3SrHsROS+gVQ8Fnea108GnKzw== dependencies: "@babel/runtime" "^7.18.3" "@emotion/babel-plugin" "^11.13.5" @@ -302,161 +277,43 @@ resolved "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.4.0.tgz" integrity sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg== -"@esbuild/aix-ppc64@0.25.5": - version "0.25.5" - resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.25.5.tgz#4e0f91776c2b340e75558f60552195f6fad09f18" - integrity sha512-9o3TMmpmftaCMepOdA5k/yDw8SfInyzWWTjYTFCX3kPSDJMROQTb8jg+h9Cnwnmm1vOzvxN7gIfB5V2ewpjtGA== +"@esbuild/darwin-arm64@0.25.11": + version "0.25.11" + resolved "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.11.tgz" + integrity sha512-VekY0PBCukppoQrycFxUqkCojnTQhdec0vevUL/EDOCnXd9LKWqD/bHwMPzigIJXPhC59Vd1WFIL57SKs2mg4w== -"@esbuild/android-arm64@0.25.5": - version "0.25.5" - resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.25.5.tgz#bc766407f1718923f6b8079c8c61bf86ac3a6a4f" - integrity sha512-VGzGhj4lJO+TVGV1v8ntCZWJktV7SGCs3Pn1GRWI1SBFtRALoomm8k5E9Pmwg3HOAal2VDc2F9+PM/rEY6oIDg== - -"@esbuild/android-arm@0.25.5": - version "0.25.5" - resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.25.5.tgz#4290d6d3407bae3883ad2cded1081a234473ce26" - integrity sha512-AdJKSPeEHgi7/ZhuIPtcQKr5RQdo6OO2IL87JkianiMYMPbCtot9fxPbrMiBADOWWm3T2si9stAiVsGbTQFkbA== - -"@esbuild/android-x64@0.25.5": - version "0.25.5" - resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.25.5.tgz#40c11d9cbca4f2406548c8a9895d321bc3b35eff" - integrity sha512-D2GyJT1kjvO//drbRT3Hib9XPwQeWd9vZoBJn+bu/lVsOZ13cqNdDeqIF/xQ5/VmWvMduP6AmXvylO/PIc2isw== - -"@esbuild/darwin-arm64@0.25.5": - version "0.25.5" - resolved "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.5.tgz" - integrity sha512-GtaBgammVvdF7aPIgH2jxMDdivezgFu6iKpmT+48+F8Hhg5J/sfnDieg0aeG/jfSvkYQU2/pceFPDKlqZzwnfQ== - -"@esbuild/darwin-x64@0.25.5": - version "0.25.5" - resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.25.5.tgz#e27a5d92a14886ef1d492fd50fc61a2d4d87e418" - integrity sha512-1iT4FVL0dJ76/q1wd7XDsXrSW+oLoquptvh4CLR4kITDtqi2e/xwXwdCVH8hVHU43wgJdsq7Gxuzcs6Iq/7bxQ== - -"@esbuild/freebsd-arm64@0.25.5": - version "0.25.5" - resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.5.tgz#97cede59d638840ca104e605cdb9f1b118ba0b1c" - integrity sha512-nk4tGP3JThz4La38Uy/gzyXtpkPW8zSAmoUhK9xKKXdBCzKODMc2adkB2+8om9BDYugz+uGV7sLmpTYzvmz6Sw== - -"@esbuild/freebsd-x64@0.25.5": - version "0.25.5" - resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.25.5.tgz#71c77812042a1a8190c3d581e140d15b876b9c6f" - integrity sha512-PrikaNjiXdR2laW6OIjlbeuCPrPaAl0IwPIaRv+SMV8CiM8i2LqVUHFC1+8eORgWyY7yhQY+2U2fA55mBzReaw== - -"@esbuild/linux-arm64@0.25.5": - version "0.25.5" - resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.25.5.tgz#f7b7c8f97eff8ffd2e47f6c67eb5c9765f2181b8" - integrity sha512-Z9kfb1v6ZlGbWj8EJk9T6czVEjjq2ntSYLY2cw6pAZl4oKtfgQuS4HOq41M/BcoLPzrUbNd+R4BXFyH//nHxVg== - -"@esbuild/linux-arm@0.25.5": - version "0.25.5" - resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.25.5.tgz#2a0be71b6cd8201fa559aea45598dffabc05d911" - integrity sha512-cPzojwW2okgh7ZlRpcBEtsX7WBuqbLrNXqLU89GxWbNt6uIg78ET82qifUy3W6OVww6ZWobWub5oqZOVtwolfw== - -"@esbuild/linux-ia32@0.25.5": - version "0.25.5" - resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.25.5.tgz#763414463cd9ea6fa1f96555d2762f9f84c61783" - integrity sha512-sQ7l00M8bSv36GLV95BVAdhJ2QsIbCuCjh/uYrWiMQSUuV+LpXwIqhgJDcvMTj+VsQmqAHL2yYaasENvJ7CDKA== - -"@esbuild/linux-loong64@0.25.5": - version "0.25.5" - resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.25.5.tgz#428cf2213ff786a502a52c96cf29d1fcf1eb8506" - integrity sha512-0ur7ae16hDUC4OL5iEnDb0tZHDxYmuQyhKhsPBV8f99f6Z9KQM02g33f93rNH5A30agMS46u2HP6qTdEt6Q1kg== - -"@esbuild/linux-mips64el@0.25.5": - version "0.25.5" - resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.25.5.tgz#5cbcc7fd841b4cd53358afd33527cd394e325d96" - integrity sha512-kB/66P1OsHO5zLz0i6X0RxlQ+3cu0mkxS3TKFvkb5lin6uwZ/ttOkP3Z8lfR9mJOBk14ZwZ9182SIIWFGNmqmg== - -"@esbuild/linux-ppc64@0.25.5": - version "0.25.5" - resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.25.5.tgz#0d954ab39ce4f5e50f00c4f8c4fd38f976c13ad9" - integrity sha512-UZCmJ7r9X2fe2D6jBmkLBMQetXPXIsZjQJCjgwpVDz+YMcS6oFR27alkgGv3Oqkv07bxdvw7fyB71/olceJhkQ== - -"@esbuild/linux-riscv64@0.25.5": - version "0.25.5" - resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.25.5.tgz#0e7dd30730505abd8088321e8497e94b547bfb1e" - integrity sha512-kTxwu4mLyeOlsVIFPfQo+fQJAV9mh24xL+y+Bm6ej067sYANjyEw1dNHmvoqxJUCMnkBdKpvOn0Ahql6+4VyeA== - -"@esbuild/linux-s390x@0.25.5": - version "0.25.5" - resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.25.5.tgz#5669af81327a398a336d7e40e320b5bbd6e6e72d" - integrity sha512-K2dSKTKfmdh78uJ3NcWFiqyRrimfdinS5ErLSn3vluHNeHVnBAFWC8a4X5N+7FgVE1EjXS1QDZbpqZBjfrqMTQ== - -"@esbuild/linux-x64@0.25.5": - version "0.25.5" - resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.25.5.tgz#b2357dd153aa49038967ddc1ffd90c68a9d2a0d4" - integrity sha512-uhj8N2obKTE6pSZ+aMUbqq+1nXxNjZIIjCjGLfsWvVpy7gKCOL6rsY1MhRh9zLtUtAI7vpgLMK6DxjO8Qm9lJw== - -"@esbuild/netbsd-arm64@0.25.5": - version "0.25.5" - resolved "https://registry.yarnpkg.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.5.tgz#53b4dfb8fe1cee93777c9e366893bd3daa6ba63d" - integrity sha512-pwHtMP9viAy1oHPvgxtOv+OkduK5ugofNTVDilIzBLpoWAM16r7b/mxBvfpuQDpRQFMfuVr5aLcn4yveGvBZvw== - -"@esbuild/netbsd-x64@0.25.5": - version "0.25.5" - resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.25.5.tgz#a0206f6314ce7dc8713b7732703d0f58de1d1e79" - integrity sha512-WOb5fKrvVTRMfWFNCroYWWklbnXH0Q5rZppjq0vQIdlsQKuw6mdSihwSo4RV/YdQ5UCKKvBy7/0ZZYLBZKIbwQ== - -"@esbuild/openbsd-arm64@0.25.5": - version "0.25.5" - resolved "https://registry.yarnpkg.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.5.tgz#2a796c87c44e8de78001d808c77d948a21ec22fd" - integrity sha512-7A208+uQKgTxHd0G0uqZO8UjK2R0DDb4fDmERtARjSHWxqMTye4Erz4zZafx7Di9Cv+lNHYuncAkiGFySoD+Mw== - -"@esbuild/openbsd-x64@0.25.5": - version "0.25.5" - resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.25.5.tgz#28d0cd8909b7fa3953af998f2b2ed34f576728f0" - integrity sha512-G4hE405ErTWraiZ8UiSoesH8DaCsMm0Cay4fsFWOOUcz8b8rC6uCvnagr+gnioEjWn0wC+o1/TAHt+It+MpIMg== - -"@esbuild/sunos-x64@0.25.5": - version "0.25.5" - resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.25.5.tgz#a28164f5b997e8247d407e36c90d3fd5ddbe0dc5" - integrity sha512-l+azKShMy7FxzY0Rj4RCt5VD/q8mG/e+mDivgspo+yL8zW7qEwctQ6YqKX34DTEleFAvCIUviCFX1SDZRSyMQA== - -"@esbuild/win32-arm64@0.25.5": - version "0.25.5" - resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.25.5.tgz#6eadbead38e8bd12f633a5190e45eff80e24007e" - integrity sha512-O2S7SNZzdcFG7eFKgvwUEZ2VG9D/sn/eIiz8XRZ1Q/DO5a3s76Xv0mdBzVM5j5R639lXQmPmSo0iRpHqUUrsxw== - -"@esbuild/win32-ia32@0.25.5": - version "0.25.5" - resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.25.5.tgz#bab6288005482f9ed2adb9ded7e88eba9a62cc0d" - integrity sha512-onOJ02pqs9h1iMJ1PQphR+VZv8qBMQ77Klcsqv9CNW2w6yLqoURLcgERAIurY6QE63bbLuqgP9ATqajFLK5AMQ== - -"@esbuild/win32-x64@0.25.5": - version "0.25.5" - resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.25.5.tgz#7fc114af5f6563f19f73324b5d5ff36ece0803d1" - integrity sha512-TXv6YnJ8ZMVdX+SXWVBo/0p8LTcrUYngpWjvm91TMjjBQii7Oz11Lw5lbDV5Y0TzuhSJHwiH4hEtC1I42mMS0g== - -"@eslint-community/eslint-utils@^4.2.0", "@eslint-community/eslint-utils@^4.7.0": - version "4.7.0" - resolved "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz" - integrity sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw== +"@eslint-community/eslint-utils@^4.7.0", "@eslint-community/eslint-utils@^4.8.0": + version "4.9.0" + resolved "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz" + integrity sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g== dependencies: eslint-visitor-keys "^3.4.3" "@eslint-community/regexpp@^4.10.0", "@eslint-community/regexpp@^4.12.1": - version "4.12.1" - resolved "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.1.tgz" - integrity sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ== + version "4.12.2" + resolved "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz" + integrity sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew== -"@eslint/config-array@^0.20.0": - version "0.20.0" - resolved "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.20.0.tgz" - integrity sha512-fxlS1kkIjx8+vy2SjuCB94q3htSNrufYTXubwiBFeaQHbH6Ipi43gFJq2zCMt6PHhImH3Xmr0NksKDvchWlpQQ== +"@eslint/config-array@^0.21.1": + version "0.21.1" + resolved "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.1.tgz" + integrity sha512-aw1gNayWpdI/jSYVgzN5pL0cfzU02GT3NBpeT/DXbx1/1x7ZKxFPd9bwrzygx/qiwIQiJ1sw/zD8qY/kRvlGHA== dependencies: - "@eslint/object-schema" "^2.1.6" + "@eslint/object-schema" "^2.1.7" debug "^4.3.1" minimatch "^3.1.2" -"@eslint/config-helpers@^0.2.1": - version "0.2.2" - resolved "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.2.2.tgz" - integrity sha512-+GPzk8PlG0sPpzdU5ZvIRMPidzAnZDl/s9L+y13iodqvb8leL53bTannOrQ/Im7UkpsmFU5Ily5U60LWixnmLg== +"@eslint/config-helpers@^0.4.1": + version "0.4.1" + resolved "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.1.tgz" + integrity sha512-csZAzkNhsgwb0I/UAV6/RGFTbiakPCf0ZrGmrIxQpYvGZ00PhTkSnyKNolphgIvmnJeGw6rcGVEXfTzUnFuEvw== + dependencies: + "@eslint/core" "^0.16.0" -"@eslint/core@^0.14.0": - version "0.14.0" - resolved "https://registry.npmjs.org/@eslint/core/-/core-0.14.0.tgz" - integrity sha512-qIbV0/JZr7iSDjqAc60IqbLdsj9GDt16xQtWD+B78d/HAlvysGdZZ6rpJHGAc2T0FQx1X6thsSPdnoiGKdNtdg== +"@eslint/core@^0.16.0": + version "0.16.0" + resolved "https://registry.npmjs.org/@eslint/core/-/core-0.16.0.tgz" + integrity sha512-nmC8/totwobIiFcGkDza3GIKfAw1+hLiYVrh3I1nIomQ8PEr5cxg34jnkmGawul/ep52wGRAcyeDCNtWKSOj4Q== dependencies: "@types/json-schema" "^7.0.15" @@ -475,22 +332,22 @@ minimatch "^3.1.2" strip-json-comments "^3.1.1" -"@eslint/js@9.27.0", "@eslint/js@^9.25.0": - version "9.27.0" - resolved "https://registry.npmjs.org/@eslint/js/-/js-9.27.0.tgz" - integrity sha512-G5JD9Tu5HJEu4z2Uo4aHY2sLV64B7CDMXxFzqzjl3NKd6RVzSXNoE80jk7Y0lJkTTkjiIhBAqmlYwjuBY3tvpA== +"@eslint/js@^9.25.0", "@eslint/js@9.38.0": + version "9.38.0" + resolved "https://registry.npmjs.org/@eslint/js/-/js-9.38.0.tgz" + integrity sha512-UZ1VpFvXf9J06YG9xQBdnzU+kthors6KjhMAl6f4gH4usHyh31rUf2DLGInT8RFYIReYXNSydgPY0V2LuWgl7A== -"@eslint/object-schema@^2.1.6": - version "2.1.6" - resolved "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.6.tgz" - integrity sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA== +"@eslint/object-schema@^2.1.7": + version "2.1.7" + resolved "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz" + integrity sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA== -"@eslint/plugin-kit@^0.3.1": - version "0.3.1" - resolved "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.1.tgz" - integrity sha512-0J+zgWxHN+xXONWIyPWKFMgVuJoZuGiIFu8yxk7RJjxkzpGmyja5wRFqZIVtjDVOQpV+Rw0iOAjYPE2eQyjr0w== +"@eslint/plugin-kit@^0.4.0": + version "0.4.0" + resolved "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.0.tgz" + integrity sha512-sB5uyeq+dwCWyPi31B2gQlVlo+j5brPlWx4yZBrEaRo/nhdDE8Xke1gsGgtiBdaBTxuTkceLVuVt/pclrasb0A== dependencies: - "@eslint/core" "^0.14.0" + "@eslint/core" "^0.16.0" levn "^0.4.1" "@hello-pangea/dnd@^18.0.1": @@ -510,42 +367,37 @@ integrity sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA== "@humanfs/node@^0.16.6": - version "0.16.6" - resolved "https://registry.npmjs.org/@humanfs/node/-/node-0.16.6.tgz" - integrity sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw== + version "0.16.7" + resolved "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz" + integrity sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ== dependencies: "@humanfs/core" "^0.19.1" - "@humanwhocodes/retry" "^0.3.0" + "@humanwhocodes/retry" "^0.4.0" "@humanwhocodes/module-importer@^1.0.1": version "1.0.1" resolved "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz" integrity sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA== -"@humanwhocodes/retry@^0.3.0": - version "0.3.1" - resolved "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.3.1.tgz" - integrity sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA== - -"@humanwhocodes/retry@^0.4.2": +"@humanwhocodes/retry@^0.4.0", "@humanwhocodes/retry@^0.4.2": version "0.4.3" resolved "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz" integrity sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ== -"@isaacs/fs-minipass@^4.0.0": - version "4.0.1" - resolved "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz" - integrity sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w== +"@jridgewell/gen-mapping@^0.3.12", "@jridgewell/gen-mapping@^0.3.5": + version "0.3.13" + resolved "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz" + integrity sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA== dependencies: - minipass "^7.0.4" + "@jridgewell/sourcemap-codec" "^1.5.0" + "@jridgewell/trace-mapping" "^0.3.24" -"@jridgewell/gen-mapping@^0.3.5": - version "0.3.8" - resolved "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.8.tgz" - integrity sha512-imAbBGkb+ebQyxKgzv5Hu2nmROxoDOXHh80evxdoXNOrvAnVx7zimzc1Oo5h9RlfV4vPXaE2iM5pOFbvOCClWA== +"@jridgewell/remapping@^2.3.4", "@jridgewell/remapping@^2.3.5": + version "2.3.5" + resolved "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz" + integrity sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ== dependencies: - "@jridgewell/set-array" "^1.2.1" - "@jridgewell/sourcemap-codec" "^1.4.10" + "@jridgewell/gen-mapping" "^0.3.5" "@jridgewell/trace-mapping" "^0.3.24" "@jridgewell/resolve-uri@^3.1.0": @@ -553,20 +405,15 @@ resolved "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz" integrity sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw== -"@jridgewell/set-array@^1.2.1": - version "1.2.1" - resolved "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz" - integrity sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A== +"@jridgewell/sourcemap-codec@^1.4.14", "@jridgewell/sourcemap-codec@^1.5.0", "@jridgewell/sourcemap-codec@^1.5.5": + version "1.5.5" + resolved "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz" + integrity sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og== -"@jridgewell/sourcemap-codec@^1.4.10", "@jridgewell/sourcemap-codec@^1.4.14", "@jridgewell/sourcemap-codec@^1.5.0": - version "1.5.0" - resolved "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz" - integrity sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ== - -"@jridgewell/trace-mapping@^0.3.24", "@jridgewell/trace-mapping@^0.3.25": - version "0.3.25" - resolved "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz" - integrity sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ== +"@jridgewell/trace-mapping@^0.3.24", "@jridgewell/trace-mapping@^0.3.28": + version "0.3.31" + resolved "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz" + integrity sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw== dependencies: "@jridgewell/resolve-uri" "^3.1.0" "@jridgewell/sourcemap-codec" "^1.4.14" @@ -583,119 +430,121 @@ dependencies: promise-worker-transferable "^1.0.4" -"@mui/core-downloads-tracker@^7.1.1": - version "7.1.1" - resolved "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-7.1.1.tgz" - integrity sha512-yBckQs4aQ8mqukLnPC6ivIRv6guhaXi8snVl00VtyojBbm+l6VbVhyTSZ68Abcx7Ah8B+GZhrB7BOli+e+9LkQ== +"@mui/core-downloads-tracker@^7.3.4": + version "7.3.4" + resolved "https://registry.npmjs.org/@mui/core-downloads-tracker/-/core-downloads-tracker-7.3.4.tgz" + integrity sha512-BIktMapG3r4iXwIhYNpvk97ZfYWTreBBQTWjQKbNbzI64+ULHfYavQEX2w99aSWHS58DvXESWIgbD9adKcUOBw== "@mui/icons-material@^7.1.1": - version "7.1.1" - resolved "https://registry.npmjs.org/@mui/icons-material/-/icons-material-7.1.1.tgz" - integrity sha512-X37+Yc8QpEnl0sYmz+WcLFy2dWgNRzbswDzLPXG7QU1XDVlP5TPp1HXjdmCupOWLL/I9m1fyhcyZl8/HPpp/Cg== + version "7.3.4" + resolved "https://registry.npmjs.org/@mui/icons-material/-/icons-material-7.3.4.tgz" + integrity sha512-9n6Xcq7molXWYb680N2Qx+FRW8oT6j/LXF5PZFH3ph9X/Rct0B/BlLAsFI7iL9ySI6LVLuQIVtrLiPT82R7OZw== dependencies: - "@babel/runtime" "^7.27.1" + "@babel/runtime" "^7.28.4" -"@mui/material@^7.1.0": - version "7.1.1" - resolved "https://registry.npmjs.org/@mui/material/-/material-7.1.1.tgz" - integrity sha512-mTpdmdZCaHCGOH3SrYM41+XKvNL0iQfM9KlYgpSjgadXx/fEKhhvOktxm8++Xw6FFeOHoOiV+lzOI8X1rsv71A== +"@mui/material@^5.15.14 || ^6.0.0 || ^7.0.0", "@mui/material@^7.1.0", "@mui/material@^7.3.4": + version "7.3.4" + resolved "https://registry.npmjs.org/@mui/material/-/material-7.3.4.tgz" + integrity sha512-gEQL9pbJZZHT7lYJBKQCS723v1MGys2IFc94COXbUIyCTWa+qC77a7hUax4Yjd5ggEm35dk4AyYABpKKWC4MLw== dependencies: - "@babel/runtime" "^7.27.1" - "@mui/core-downloads-tracker" "^7.1.1" - "@mui/system" "^7.1.1" - "@mui/types" "^7.4.3" - "@mui/utils" "^7.1.1" + "@babel/runtime" "^7.28.4" + "@mui/core-downloads-tracker" "^7.3.4" + "@mui/system" "^7.3.3" + "@mui/types" "^7.4.7" + "@mui/utils" "^7.3.3" "@popperjs/core" "^2.11.8" "@types/react-transition-group" "^4.4.12" clsx "^2.1.1" csstype "^3.1.3" prop-types "^15.8.1" - react-is "^19.1.0" + react-is "^19.1.1" react-transition-group "^4.4.5" -"@mui/private-theming@^7.1.1": - version "7.1.1" - resolved "https://registry.npmjs.org/@mui/private-theming/-/private-theming-7.1.1.tgz" - integrity sha512-M8NbLUx+armk2ZuaxBkkMk11ultnWmrPlN0Xe3jUEaBChg/mcxa5HWIWS1EE4DF36WRACaAHVAvyekWlDQf0PQ== +"@mui/private-theming@^7.3.3": + version "7.3.3" + resolved "https://registry.npmjs.org/@mui/private-theming/-/private-theming-7.3.3.tgz" + integrity sha512-OJM+9nj5JIyPUvsZ5ZjaeC9PfktmK+W5YaVLToLR8L0lB/DGmv1gcKE43ssNLSvpoW71Hct0necfade6+kW3zQ== dependencies: - "@babel/runtime" "^7.27.1" - "@mui/utils" "^7.1.1" + "@babel/runtime" "^7.28.4" + "@mui/utils" "^7.3.3" prop-types "^15.8.1" -"@mui/styled-engine@^7.1.1": - version "7.1.1" - resolved "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-7.1.1.tgz" - integrity sha512-R2wpzmSN127j26HrCPYVQ53vvMcT5DaKLoWkrfwUYq3cYytL6TQrCH8JBH3z79B6g4nMZZVoaXrxO757AlShaw== +"@mui/styled-engine@^7.3.3": + version "7.3.3" + resolved "https://registry.npmjs.org/@mui/styled-engine/-/styled-engine-7.3.3.tgz" + integrity sha512-CmFxvRJIBCEaWdilhXMw/5wFJ1+FT9f3xt+m2pPXhHPeVIbBg9MnMvNSJjdALvnQJMPw8jLhrUtXmN7QAZV2fw== dependencies: - "@babel/runtime" "^7.27.1" - "@emotion/cache" "^11.13.5" + "@babel/runtime" "^7.28.4" + "@emotion/cache" "^11.14.0" "@emotion/serialize" "^1.3.3" "@emotion/sheet" "^1.4.0" csstype "^3.1.3" prop-types "^15.8.1" -"@mui/system@^7.1.1": - version "7.1.1" - resolved "https://registry.npmjs.org/@mui/system/-/system-7.1.1.tgz" - integrity sha512-Kj1uhiqnj4Zo7PDjAOghtXJtNABunWvhcRU0O7RQJ7WOxeynoH6wXPcilphV8QTFtkKaip8EiNJRiCD+B3eROA== +"@mui/system@^5.15.14 || ^6.0.0 || ^7.0.0", "@mui/system@^7.3.3": + version "7.3.3" + resolved "https://registry.npmjs.org/@mui/system/-/system-7.3.3.tgz" + integrity sha512-Lqq3emZr5IzRLKaHPuMaLBDVaGvxoh6z7HMWd1RPKawBM5uMRaQ4ImsmmgXWtwJdfZux5eugfDhXJUo2mliS8Q== dependencies: - "@babel/runtime" "^7.27.1" - "@mui/private-theming" "^7.1.1" - "@mui/styled-engine" "^7.1.1" - "@mui/types" "^7.4.3" - "@mui/utils" "^7.1.1" + "@babel/runtime" "^7.28.4" + "@mui/private-theming" "^7.3.3" + "@mui/styled-engine" "^7.3.3" + "@mui/types" "^7.4.7" + "@mui/utils" "^7.3.3" clsx "^2.1.1" csstype "^3.1.3" prop-types "^15.8.1" -"@mui/types@^7.4.3": - version "7.4.3" - resolved "https://registry.npmjs.org/@mui/types/-/types-7.4.3.tgz" - integrity sha512-2UCEiK29vtiZTeLdS2d4GndBKacVyxGvReznGXGr+CzW/YhjIX+OHUdCIczZjzcRAgKBGmE9zCIgoV9FleuyRQ== +"@mui/types@^7.4.7": + version "7.4.7" + resolved "https://registry.npmjs.org/@mui/types/-/types-7.4.7.tgz" + integrity sha512-8vVje9rdEr1rY8oIkYgP+Su5Kwl6ik7O3jQ0wl78JGSmiZhRHV+vkjooGdKD8pbtZbutXFVTWQYshu2b3sG9zw== dependencies: - "@babel/runtime" "^7.27.1" + "@babel/runtime" "^7.28.4" -"@mui/utils@^7.1.1": - version "7.1.1" - resolved "https://registry.npmjs.org/@mui/utils/-/utils-7.1.1.tgz" - integrity sha512-BkOt2q7MBYl7pweY2JWwfrlahhp+uGLR8S+EhiyRaofeRYUWL2YKbSGQvN4hgSN1i8poN0PaUiii1kEMrchvzg== +"@mui/utils@^7.3.3": + version "7.3.3" + resolved "https://registry.npmjs.org/@mui/utils/-/utils-7.3.3.tgz" + integrity sha512-kwNAUh7bLZ7mRz9JZ+6qfRnnxbE4Zuc+RzXnhSpRSxjTlSTj7b4JxRLXpG+MVtPVtqks5k/XC8No1Vs3x4Z2gg== dependencies: - "@babel/runtime" "^7.27.1" - "@mui/types" "^7.4.3" - "@types/prop-types" "^15.7.14" + "@babel/runtime" "^7.28.4" + "@mui/types" "^7.4.7" + "@types/prop-types" "^15.7.15" clsx "^2.1.1" prop-types "^15.8.1" - react-is "^19.1.0" + react-is "^19.1.1" "@mui/x-data-grid@^8.5.1": - version "8.5.1" - resolved "https://registry.npmjs.org/@mui/x-data-grid/-/x-data-grid-8.5.1.tgz" - integrity sha512-Ukodx8cOc/GR4+2zr4DRNBJJOlyeJNaxK4hggWv7VchDL7Jf70dLZO7oLXPhEFG05Yna81RatL/UFsRYIdWI1Q== + version "8.15.0" + resolved "https://registry.npmjs.org/@mui/x-data-grid/-/x-data-grid-8.15.0.tgz" + integrity sha512-JNPG2WSYJVKbUAbDpLCbWmIY25k9hyfUjAVnzDREbJMwPL+/5B9pIK0ikRQEXc0wRKY2T59SeR/Um2FZjBeeWQ== dependencies: - "@babel/runtime" "^7.27.4" - "@mui/utils" "^7.1.1" - "@mui/x-internals" "8.5.1" + "@babel/runtime" "^7.28.4" + "@mui/utils" "^7.3.3" + "@mui/x-internals" "8.14.0" + "@mui/x-virtualizer" "0.2.5" clsx "^2.1.1" prop-types "^15.8.1" + use-sync-external-store "^1.6.0" + +"@mui/x-internals@8.14.0": + version "8.14.0" + resolved "https://registry.npmjs.org/@mui/x-internals/-/x-internals-8.14.0.tgz" + integrity sha512-esYyl61nuuFXiN631TWuPh2tqdoyTdBI/4UXgwH3rytF8jiWvy6prPBPRHEH1nvW3fgw9FoBI48FlOO+yEI8xg== + dependencies: + "@babel/runtime" "^7.28.4" + "@mui/utils" "^7.3.3" reselect "^5.1.1" - use-sync-external-store "^1.5.0" + use-sync-external-store "^1.6.0" -"@mui/x-internals@8.5.1": - version "8.5.1" - resolved "https://registry.npmjs.org/@mui/x-internals/-/x-internals-8.5.1.tgz" - integrity sha512-7rAWK7SB6FxEIXKgsHsJjIzeeKOLxFJ16gePgZVWlvyew+xDb4P0fgjwW3ThcJjgvkUm0UhGGfLh/JP8l514IA== +"@mui/x-virtualizer@0.2.5": + version "0.2.5" + resolved "https://registry.npmjs.org/@mui/x-virtualizer/-/x-virtualizer-0.2.5.tgz" + integrity sha512-kCo/i9YfNavbupqZGO1649CHwIABrwUDHVZh+GvGierHhIglUc9MHxYKsPhuojOg6izWa2HP+klt3nq2n/arOw== dependencies: - "@babel/runtime" "^7.27.4" - "@mui/utils" "^7.1.1" - -"@napi-rs/wasm-runtime@^0.2.10": - version "0.2.12" - resolved "https://registry.yarnpkg.com/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz#3e78a8b96e6c33a6c517e1894efbd5385a7cb6f2" - integrity sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ== - dependencies: - "@emnapi/core" "^1.4.3" - "@emnapi/runtime" "^1.4.3" - "@tybys/wasm-util" "^0.10.0" + "@babel/runtime" "^7.28.4" + "@mui/utils" "^7.3.3" + "@mui/x-internals" "8.14.0" "@nodelib/fs.scandir@2.1.5": version "2.1.5" @@ -705,7 +554,7 @@ "@nodelib/fs.stat" "2.0.5" run-parallel "^1.1.9" -"@nodelib/fs.stat@2.0.5", "@nodelib/fs.stat@^2.0.2": +"@nodelib/fs.stat@^2.0.2", "@nodelib/fs.stat@2.0.5": version "2.0.5" resolved "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz" integrity sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A== @@ -719,16 +568,16 @@ fastq "^1.6.0" "@petamoriken/float16@^3.4.7": - version "3.9.2" - resolved "https://registry.npmjs.org/@petamoriken/float16/-/float16-3.9.2.tgz" - integrity sha512-VgffxawQde93xKxT3qap3OH+meZf7VaSB5Sqd4Rqc+FP5alWbpOyan/7tRbOAvynjpG3GpdtAuGU/NdhQpmrog== + version "3.9.3" + resolved "https://registry.npmjs.org/@petamoriken/float16/-/float16-3.9.3.tgz" + integrity sha512-8awtpHXCx/bNpFt4mt2xdkgtgVvKqty8VbjHI/WWWQuEw+KLzFot3f4+LkQY9YmOtq7A5GdOnqoIC8Pdygjk2g== -"@photo-sphere-viewer/core@^5.13.2": - version "5.13.2" - resolved "https://registry.npmjs.org/@photo-sphere-viewer/core/-/core-5.13.2.tgz" - integrity sha512-rL4Ey39Prx4Iyxt1f2tAqlXvqu4/ovXfUvIpLt540OpZJiFjWccs6qLywof9vuhBJ7PXHudHWCjRPce0W8kx8w== +"@photo-sphere-viewer/core@^5.13.2", "@photo-sphere-viewer/core@>=5.13.1": + version "5.14.0" + resolved "https://registry.npmjs.org/@photo-sphere-viewer/core/-/core-5.14.0.tgz" + integrity sha512-V0JeDSB1D2Q60Zqn7+0FPjq8gqbKEwuxMzNdTLydefkQugVztLvdZykO+4k5XTpweZ2QAWPH/QOI1xZbsdvR9A== dependencies: - three "^0.175.0" + three "^0.179.0" "@pixi/colord@^2.9.6": version "2.9.6" @@ -736,9 +585,9 @@ integrity sha512-nezytU2pw587fQstUu1AsJZDVEynjskwOL+kibwcdxsMBFqPsFFNA7xl0ii/gXuDi6M0xj3mfRJj8pBSc2jCfA== "@pixi/react@^8.0.2": - version "8.0.2" - resolved "https://registry.npmjs.org/@pixi/react/-/react-8.0.2.tgz" - integrity sha512-A42Bw/1YlxxCXhb+nDIgzPpACx5dOh7Yi+ZfBMlZ1sBB/qc7qyX9k7bdDtrnMqz8OofyF2FXg9gPw9MISFsQTA== + version "8.0.3" + resolved "https://registry.npmjs.org/@pixi/react/-/react-8.0.3.tgz" + integrity sha512-k0feY0Vj4WIWpaLaRQY8NBKlDqVCPPyEUu5EibQVqdtgdMx1brRWeJC+ruq7Im08vtZWx7iZwNGhgHNubO/OMQ== dependencies: its-fine "^2.0.0" react-reconciler "0.31.0" @@ -749,15 +598,15 @@ integrity sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A== "@react-three/drei@^10.1.2": - version "10.1.2" - resolved "https://registry.npmjs.org/@react-three/drei/-/drei-10.1.2.tgz" - integrity sha512-CCcLAqZEvYiUErOcJgGzovY3RH6KgdrqD4ubeAx1nyGbSPLnKR9a8ynYbPdtZhIyiWqGc07z+RYQzpaOfN4ZIA== + version "10.7.6" + resolved "https://registry.npmjs.org/@react-three/drei/-/drei-10.7.6.tgz" + integrity sha512-ZSFwRlRaa4zjtB7yHO6Q9xQGuyDCzE7whXBhum92JslcMRC3aouivp0rAzszcVymIoJx6PXmibyP+xr+zKdwLg== dependencies: "@babel/runtime" "^7.26.0" "@mediapipe/tasks-vision" "0.10.17" "@monogrid/gainmap-js" "^3.0.6" "@use-gesture/react" "^10.3.1" - camera-controls "^2.9.0" + camera-controls "^3.1.0" cross-env "^7.0.3" detect-gpu "^5.0.56" glsl-noise "^0.0.0" @@ -775,13 +624,13 @@ utility-types "^3.11.0" zustand "^5.0.1" -"@react-three/fiber@^9.1.2": - version "9.1.2" - resolved "https://registry.npmjs.org/@react-three/fiber/-/fiber-9.1.2.tgz" - integrity sha512-k8FR9yVHV9kIF3iuOD0ds5hVymXYXfgdKklqziBVod9ZEJ8uk05Zjw29J/omU3IKeUfLNAIHfxneN3TUYM4I2w== +"@react-three/fiber@^9.0.0", "@react-three/fiber@^9.1.2": + version "9.4.0" + resolved "https://registry.npmjs.org/@react-three/fiber/-/fiber-9.4.0.tgz" + integrity sha512-k4iu1R6e5D54918V4sqmISUkI5OgTw3v7/sDRKEC632Wd5g2WBtUS5gyG63X0GJO/HZUj1tsjSXfyzwrUHZl1g== dependencies: "@babel/runtime" "^7.17.8" - "@types/react-reconciler" "^0.28.9" + "@types/react-reconciler" "^0.32.0" "@types/webxr" "*" base64-js "^1.5.1" buffer "^6.0.3" @@ -793,250 +642,165 @@ use-sync-external-store "^1.4.0" zustand "^5.0.3" -"@rolldown/pluginutils@1.0.0-beta.9": - version "1.0.0-beta.9" - resolved "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.9.tgz" - integrity sha512-e9MeMtVWo186sgvFFJOPGy7/d2j2mZhLJIdVW0C/xDluuOvymEATqz6zKsP0ZmXGzQtqlyjz5sC1sYQUoJG98w== +"@rolldown/pluginutils@1.0.0-beta.27": + version "1.0.0-beta.27" + resolved "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz" + integrity sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA== -"@rollup/rollup-android-arm-eabi@4.41.1": - version "4.41.1" - resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.41.1.tgz#f39f09f60d4a562de727c960d7b202a2cf797424" - integrity sha512-NELNvyEWZ6R9QMkiytB4/L4zSEaBC03KIXEghptLGLZWJ6VPrL63ooZQCOnlx36aQPGhzuOMwDerC1Eb2VmrLw== - -"@rollup/rollup-android-arm64@4.41.1": - version "4.41.1" - resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.41.1.tgz#d19af7e23760717f1d879d4ca3d2cd247742dff2" - integrity sha512-DXdQe1BJ6TK47ukAoZLehRHhfKnKg9BjnQYUu9gzhI8Mwa1d2fzxA1aw2JixHVl403bwp1+/o/NhhHtxWJBgEA== - -"@rollup/rollup-darwin-arm64@4.41.1": - version "4.41.1" - resolved "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.41.1.tgz" - integrity sha512-5afxvwszzdulsU2w8JKWwY8/sJOLPzf0e1bFuvcW5h9zsEg+RQAojdW0ux2zyYAz7R8HvvzKCjLNJhVq965U7w== - -"@rollup/rollup-darwin-x64@4.41.1": - version "4.41.1" - resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.41.1.tgz#aa66d2ba1a25e609500e13bef06dc0e71cc0c0d4" - integrity sha512-egpJACny8QOdHNNMZKf8xY0Is6gIMz+tuqXlusxquWu3F833DcMwmGM7WlvCO9sB3OsPjdC4U0wHw5FabzCGZg== - -"@rollup/rollup-freebsd-arm64@4.41.1": - version "4.41.1" - resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.41.1.tgz#df10a7b6316a0ef1028c6ca71a081124c537e30d" - integrity sha512-DBVMZH5vbjgRk3r0OzgjS38z+atlupJ7xfKIDJdZZL6sM6wjfDNo64aowcLPKIx7LMQi8vybB56uh1Ftck/Atg== - -"@rollup/rollup-freebsd-x64@4.41.1": - version "4.41.1" - resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.41.1.tgz#a3fdce8a05e95b068cbcb46e4df5185e407d0c35" - integrity sha512-3FkydeohozEskBxNWEIbPfOE0aqQgB6ttTkJ159uWOFn42VLyfAiyD9UK5mhu+ItWzft60DycIN1Xdgiy8o/SA== - -"@rollup/rollup-linux-arm-gnueabihf@4.41.1": - version "4.41.1" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.41.1.tgz#49f766c55383bd0498014a9d76924348c2f3890c" - integrity sha512-wC53ZNDgt0pqx5xCAgNunkTzFE8GTgdZ9EwYGVcg+jEjJdZGtq9xPjDnFgfFozQI/Xm1mh+D9YlYtl+ueswNEg== - -"@rollup/rollup-linux-arm-musleabihf@4.41.1": - version "4.41.1" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.41.1.tgz#1d4d7d32fc557e17d52e1857817381ea365e2959" - integrity sha512-jwKCca1gbZkZLhLRtsrka5N8sFAaxrGz/7wRJ8Wwvq3jug7toO21vWlViihG85ei7uJTpzbXZRcORotE+xyrLA== - -"@rollup/rollup-linux-arm64-gnu@4.41.1": - version "4.41.1" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.41.1.tgz#f4fc317268441e9589edad3be8f62b6c03009bc1" - integrity sha512-g0UBcNknsmmNQ8V2d/zD2P7WWfJKU0F1nu0k5pW4rvdb+BIqMm8ToluW/eeRmxCared5dD76lS04uL4UaNgpNA== - -"@rollup/rollup-linux-arm64-musl@4.41.1": - version "4.41.1" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.41.1.tgz#63a1f1b0671cb17822dabae827fef0e443aebeb7" - integrity sha512-XZpeGB5TKEZWzIrj7sXr+BEaSgo/ma/kCgrZgL0oo5qdB1JlTzIYQKel/RmhT6vMAvOdM2teYlAaOGJpJ9lahg== - -"@rollup/rollup-linux-loongarch64-gnu@4.41.1": - version "4.41.1" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.41.1.tgz#c659b01cc6c0730b547571fc3973e1e955369f98" - integrity sha512-bkCfDJ4qzWfFRCNt5RVV4DOw6KEgFTUZi2r2RuYhGWC8WhCA8lCAJhDeAmrM/fdiAH54m0mA0Vk2FGRPyzI+tw== - -"@rollup/rollup-linux-powerpc64le-gnu@4.41.1": - version "4.41.1" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.41.1.tgz#612e746f9ad7e58480f964d65e0d6c3f4aae69a8" - integrity sha512-3mr3Xm+gvMX+/8EKogIZSIEF0WUu0HL9di+YWlJpO8CQBnoLAEL/roTCxuLncEdgcfJcvA4UMOf+2dnjl4Ut1A== - -"@rollup/rollup-linux-riscv64-gnu@4.41.1": - version "4.41.1" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.41.1.tgz#4610dbd1dcfbbae32fbc10c20ae7387acb31110c" - integrity sha512-3rwCIh6MQ1LGrvKJitQjZFuQnT2wxfU+ivhNBzmxXTXPllewOF7JR1s2vMX/tWtUYFgphygxjqMl76q4aMotGw== - -"@rollup/rollup-linux-riscv64-musl@4.41.1": - version "4.41.1" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.41.1.tgz#054911fab40dc83fafc21e470193c058108f19d8" - integrity sha512-LdIUOb3gvfmpkgFZuccNa2uYiqtgZAz3PTzjuM5bH3nvuy9ty6RGc/Q0+HDFrHrizJGVpjnTZ1yS5TNNjFlklw== - -"@rollup/rollup-linux-s390x-gnu@4.41.1": - version "4.41.1" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.41.1.tgz#98896eca8012547c7f04bd07eaa6896825f9e1a5" - integrity sha512-oIE6M8WC9ma6xYqjvPhzZYk6NbobIURvP/lEbh7FWplcMO6gn7MM2yHKA1eC/GvYwzNKK/1LYgqzdkZ8YFxR8g== - -"@rollup/rollup-linux-x64-gnu@4.41.1": - version "4.41.1" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.41.1.tgz#01cf56844a1e636ee80dfb364e72c2b7142ad896" - integrity sha512-cWBOvayNvA+SyeQMp79BHPK8ws6sHSsYnK5zDcsC3Hsxr1dgTABKjMnMslPq1DvZIp6uO7kIWhiGwaTdR4Og9A== - -"@rollup/rollup-linux-x64-musl@4.41.1": - version "4.41.1" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.41.1.tgz#e67c7531df6dff0b4c241101d4096617fbca87c3" - integrity sha512-y5CbN44M+pUCdGDlZFzGGBSKCA4A/J2ZH4edTYSSxFg7ce1Xt3GtydbVKWLlzL+INfFIZAEg1ZV6hh9+QQf9YQ== - -"@rollup/rollup-win32-arm64-msvc@4.41.1": - version "4.41.1" - resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.41.1.tgz#7eeada98444e580674de6989284e4baacd48ea65" - integrity sha512-lZkCxIrjlJlMt1dLO/FbpZbzt6J/A8p4DnqzSa4PWqPEUUUnzXLeki/iyPLfV0BmHItlYgHUqJe+3KiyydmiNQ== - -"@rollup/rollup-win32-ia32-msvc@4.41.1": - version "4.41.1" - resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.41.1.tgz#516c4b54f80587b4a390aaf4940b40870271d35d" - integrity sha512-+psFT9+pIh2iuGsxFYYa/LhS5MFKmuivRsx9iPJWNSGbh2XVEjk90fmpUEjCnILPEPJnikAU6SFDiEUyOv90Pg== - -"@rollup/rollup-win32-x64-msvc@4.41.1": - version "4.41.1" - resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.41.1.tgz#848f99b0d9936d92221bb6070baeff4db6947a30" - integrity sha512-Wq2zpapRYLfi4aKxf2Xff0tN+7slj2d4R87WEzqw7ZLsVvO5zwYCIuEGSZYiK41+GlwUo1HiR+GdkLEJnCKTCw== - -"@tailwindcss/node@4.1.8": - version "4.1.8" - resolved "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.8.tgz" - integrity sha512-OWwBsbC9BFAJelmnNcrKuf+bka2ZxCE2A4Ft53Tkg4uoiE67r/PMEYwCsourC26E+kmxfwE0hVzMdxqeW+xu7Q== +"@rollup/pluginutils@^5.2.0": + version "5.3.0" + resolved "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz" + integrity sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q== dependencies: - "@ampproject/remapping" "^2.3.0" - enhanced-resolve "^5.18.1" - jiti "^2.4.2" - lightningcss "1.30.1" - magic-string "^0.30.17" + "@types/estree" "^1.0.0" + estree-walker "^2.0.2" + picomatch "^4.0.2" + +"@rollup/rollup-darwin-arm64@4.52.5": + version "4.52.5" + resolved "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.52.5.tgz" + integrity sha512-takF3CR71mCAGA+v794QUZ0b6ZSrgJkArC+gUiG6LB6TQty9T0Mqh3m2ImRBOxS2IeYBo4lKWIieSvnEk2OQWA== + +"@svgr/babel-plugin-add-jsx-attribute@8.0.0": + version "8.0.0" + resolved "https://registry.npmjs.org/@svgr/babel-plugin-add-jsx-attribute/-/babel-plugin-add-jsx-attribute-8.0.0.tgz" + integrity sha512-b9MIk7yhdS1pMCZM8VeNfUlSKVRhsHZNMl5O9SfaX0l0t5wjdgu4IDzGB8bpnGBBOjGST3rRFVsaaEtI4W6f7g== + +"@svgr/babel-plugin-remove-jsx-attribute@8.0.0": + version "8.0.0" + resolved "https://registry.npmjs.org/@svgr/babel-plugin-remove-jsx-attribute/-/babel-plugin-remove-jsx-attribute-8.0.0.tgz" + integrity sha512-BcCkm/STipKvbCl6b7QFrMh/vx00vIP63k2eM66MfHJzPr6O2U0jYEViXkHJWqXqQYjdeA9cuCl5KWmlwjDvbA== + +"@svgr/babel-plugin-remove-jsx-empty-expression@8.0.0": + version "8.0.0" + resolved "https://registry.npmjs.org/@svgr/babel-plugin-remove-jsx-empty-expression/-/babel-plugin-remove-jsx-empty-expression-8.0.0.tgz" + integrity sha512-5BcGCBfBxB5+XSDSWnhTThfI9jcO5f0Ai2V24gZpG+wXF14BzwxxdDb4g6trdOux0rhibGs385BeFMSmxtS3uA== + +"@svgr/babel-plugin-replace-jsx-attribute-value@8.0.0": + version "8.0.0" + resolved "https://registry.npmjs.org/@svgr/babel-plugin-replace-jsx-attribute-value/-/babel-plugin-replace-jsx-attribute-value-8.0.0.tgz" + integrity sha512-KVQ+PtIjb1BuYT3ht8M5KbzWBhdAjjUPdlMtpuw/VjT8coTrItWX6Qafl9+ji831JaJcu6PJNKCV0bp01lBNzQ== + +"@svgr/babel-plugin-svg-dynamic-title@8.0.0": + version "8.0.0" + resolved "https://registry.npmjs.org/@svgr/babel-plugin-svg-dynamic-title/-/babel-plugin-svg-dynamic-title-8.0.0.tgz" + integrity sha512-omNiKqwjNmOQJ2v6ge4SErBbkooV2aAWwaPFs2vUY7p7GhVkzRkJ00kILXQvRhA6miHnNpXv7MRnnSjdRjK8og== + +"@svgr/babel-plugin-svg-em-dimensions@8.0.0": + version "8.0.0" + resolved "https://registry.npmjs.org/@svgr/babel-plugin-svg-em-dimensions/-/babel-plugin-svg-em-dimensions-8.0.0.tgz" + integrity sha512-mURHYnu6Iw3UBTbhGwE/vsngtCIbHE43xCRK7kCw4t01xyGqb2Pd+WXekRRoFOBIY29ZoOhUCTEweDMdrjfi9g== + +"@svgr/babel-plugin-transform-react-native-svg@8.1.0": + version "8.1.0" + resolved "https://registry.npmjs.org/@svgr/babel-plugin-transform-react-native-svg/-/babel-plugin-transform-react-native-svg-8.1.0.tgz" + integrity sha512-Tx8T58CHo+7nwJ+EhUwx3LfdNSG9R2OKfaIXXs5soiy5HtgoAEkDay9LIimLOcG8dJQH1wPZp/cnAv6S9CrR1Q== + +"@svgr/babel-plugin-transform-svg-component@8.0.0": + version "8.0.0" + resolved "https://registry.npmjs.org/@svgr/babel-plugin-transform-svg-component/-/babel-plugin-transform-svg-component-8.0.0.tgz" + integrity sha512-DFx8xa3cZXTdb/k3kfPeaixecQLgKh5NVBMwD0AQxOzcZawK4oo1Jh9LbrcACUivsCA7TLG8eeWgrDXjTMhRmw== + +"@svgr/babel-preset@8.1.0": + version "8.1.0" + resolved "https://registry.npmjs.org/@svgr/babel-preset/-/babel-preset-8.1.0.tgz" + integrity sha512-7EYDbHE7MxHpv4sxvnVPngw5fuR6pw79SkcrILHJ/iMpuKySNCl5W1qcwPEpU+LgyRXOaAFgH0KhwD18wwg6ug== + dependencies: + "@svgr/babel-plugin-add-jsx-attribute" "8.0.0" + "@svgr/babel-plugin-remove-jsx-attribute" "8.0.0" + "@svgr/babel-plugin-remove-jsx-empty-expression" "8.0.0" + "@svgr/babel-plugin-replace-jsx-attribute-value" "8.0.0" + "@svgr/babel-plugin-svg-dynamic-title" "8.0.0" + "@svgr/babel-plugin-svg-em-dimensions" "8.0.0" + "@svgr/babel-plugin-transform-react-native-svg" "8.1.0" + "@svgr/babel-plugin-transform-svg-component" "8.0.0" + +"@svgr/core@*", "@svgr/core@^8.1.0": + version "8.1.0" + resolved "https://registry.npmjs.org/@svgr/core/-/core-8.1.0.tgz" + integrity sha512-8QqtOQT5ACVlmsvKOJNEaWmRPmcojMOzCz4Hs2BGG/toAp/K38LcsMRyLp349glq5AzJbCEeimEoxaX6v/fLrA== + dependencies: + "@babel/core" "^7.21.3" + "@svgr/babel-preset" "8.1.0" + camelcase "^6.2.0" + cosmiconfig "^8.1.3" + snake-case "^3.0.4" + +"@svgr/hast-util-to-babel-ast@8.0.0": + version "8.0.0" + resolved "https://registry.npmjs.org/@svgr/hast-util-to-babel-ast/-/hast-util-to-babel-ast-8.0.0.tgz" + integrity sha512-EbDKwO9GpfWP4jN9sGdYwPBU0kdomaPIL2Eu4YwmgP+sJeXT+L7bMwJUBnhzfH8Q2qMBqZ4fJwpCyYsAN3mt2Q== + dependencies: + "@babel/types" "^7.21.3" + entities "^4.4.0" + +"@svgr/plugin-jsx@^8.1.0": + version "8.1.0" + resolved "https://registry.npmjs.org/@svgr/plugin-jsx/-/plugin-jsx-8.1.0.tgz" + integrity sha512-0xiIyBsLlr8quN+WyuxooNW9RJ0Dpr8uOnH/xrCVO8GLUcwHISwj1AG0k+LFzteTkAA0GbX0kj9q6Dk70PTiPA== + dependencies: + "@babel/core" "^7.21.3" + "@svgr/babel-preset" "8.1.0" + "@svgr/hast-util-to-babel-ast" "8.0.0" + svg-parser "^2.0.4" + +"@tailwindcss/node@4.1.16": + version "4.1.16" + resolved "https://registry.npmjs.org/@tailwindcss/node/-/node-4.1.16.tgz" + integrity sha512-BX5iaSsloNuvKNHRN3k2RcCuTEgASTo77mofW0vmeHkfrDWaoFAFvNHpEgtu0eqyypcyiBkDWzSMxJhp3AUVcw== + dependencies: + "@jridgewell/remapping" "^2.3.4" + enhanced-resolve "^5.18.3" + jiti "^2.6.1" + lightningcss "1.30.2" + magic-string "^0.30.19" source-map-js "^1.2.1" - tailwindcss "4.1.8" + tailwindcss "4.1.16" -"@tailwindcss/oxide-android-arm64@4.1.8": - version "4.1.8" - resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.8.tgz#4cb4b464636fc7e3154a1bb7df38a828291b3e9a" - integrity sha512-Fbz7qni62uKYceWYvUjRqhGfZKwhZDQhlrJKGtnZfuNtHFqa8wmr+Wn74CTWERiW2hn3mN5gTpOoxWKk0jRxjg== +"@tailwindcss/oxide-darwin-arm64@4.1.16": + version "4.1.16" + resolved "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.16.tgz" + integrity sha512-C3oZy5042v2FOALBZtY0JTDnGNdS6w7DxL/odvSny17ORUnaRKhyTse8xYi3yKGyfnTUOdavRCdmc8QqJYwFKA== -"@tailwindcss/oxide-darwin-arm64@4.1.8": - version "4.1.8" - resolved "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.8.tgz" - integrity sha512-RdRvedGsT0vwVVDztvyXhKpsU2ark/BjgG0huo4+2BluxdXo8NDgzl77qh0T1nUxmM11eXwR8jA39ibvSTbi7A== - -"@tailwindcss/oxide-darwin-x64@4.1.8": - version "4.1.8" - resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.8.tgz#d0f3fa4c3bde21a772e29e31c9739d91db79de12" - integrity sha512-t6PgxjEMLp5Ovf7uMb2OFmb3kqzVTPPakWpBIFzppk4JE4ix0yEtbtSjPbU8+PZETpaYMtXvss2Sdkx8Vs4XRw== - -"@tailwindcss/oxide-freebsd-x64@4.1.8": - version "4.1.8" - resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.8.tgz#545c94c941007ed1aa2e449465501b70d59cb3da" - integrity sha512-g8C8eGEyhHTqwPStSwZNSrOlyx0bhK/V/+zX0Y+n7DoRUzyS8eMbVshVOLJTDDC+Qn9IJnilYbIKzpB9n4aBsg== - -"@tailwindcss/oxide-linux-arm-gnueabihf@4.1.8": - version "4.1.8" - resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.8.tgz#e1bdbf63a179081669b8cd1c9523889774760eb9" - integrity sha512-Jmzr3FA4S2tHhaC6yCjac3rGf7hG9R6Gf2z9i9JFcuyy0u79HfQsh/thifbYTF2ic82KJovKKkIB6Z9TdNhCXQ== - -"@tailwindcss/oxide-linux-arm64-gnu@4.1.8": - version "4.1.8" - resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.8.tgz#8d28093bbd43bdae771a2dcca720e926baa57093" - integrity sha512-qq7jXtO1+UEtCmCeBBIRDrPFIVI4ilEQ97qgBGdwXAARrUqSn/L9fUrkb1XP/mvVtoVeR2bt/0L77xx53bPZ/Q== - -"@tailwindcss/oxide-linux-arm64-musl@4.1.8": - version "4.1.8" - resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.8.tgz#cc6cece814d813885ead9cd8b9d55aeb3db56c97" - integrity sha512-O6b8QesPbJCRshsNApsOIpzKt3ztG35gfX9tEf4arD7mwNinsoCKxkj8TgEE0YRjmjtO3r9FlJnT/ENd9EVefQ== - -"@tailwindcss/oxide-linux-x64-gnu@4.1.8": - version "4.1.8" - resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.8.tgz#4cac14fa71382574773fb7986d9f0681ad89e3de" - integrity sha512-32iEXX/pXwikshNOGnERAFwFSfiltmijMIAbUhnNyjFr3tmWmMJWQKU2vNcFX0DACSXJ3ZWcSkzNbaKTdngH6g== - -"@tailwindcss/oxide-linux-x64-musl@4.1.8": - version "4.1.8" - resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.8.tgz#e085f1ccbc8f97625773a6a3afc2a6f88edf59da" - integrity sha512-s+VSSD+TfZeMEsCaFaHTaY5YNj3Dri8rST09gMvYQKwPphacRG7wbuQ5ZJMIJXN/puxPcg/nU+ucvWguPpvBDg== - -"@tailwindcss/oxide-wasm32-wasi@4.1.8": - version "4.1.8" - resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.8.tgz#c5e19fffe67f25cabf12a357bba4e87128151ea0" - integrity sha512-CXBPVFkpDjM67sS1psWohZ6g/2/cd+cq56vPxK4JeawelxwK4YECgl9Y9TjkE2qfF+9/s1tHHJqrC4SS6cVvSg== - dependencies: - "@emnapi/core" "^1.4.3" - "@emnapi/runtime" "^1.4.3" - "@emnapi/wasi-threads" "^1.0.2" - "@napi-rs/wasm-runtime" "^0.2.10" - "@tybys/wasm-util" "^0.9.0" - tslib "^2.8.0" - -"@tailwindcss/oxide-win32-arm64-msvc@4.1.8": - version "4.1.8" - resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.8.tgz#77521f23f91604c587736927fd2cb526667b7344" - integrity sha512-7GmYk1n28teDHUjPlIx4Z6Z4hHEgvP5ZW2QS9ygnDAdI/myh3HTHjDqtSqgu1BpRoI4OiLx+fThAyA1JePoENA== - -"@tailwindcss/oxide-win32-x64-msvc@4.1.8": - version "4.1.8" - resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.8.tgz#55c876ab35f8779d1dceec61483cd9834d7365ac" - integrity sha512-fou+U20j+Jl0EHwK92spoWISON2OBnCazIc038Xj2TdweYV33ZRkS9nwqiUi2d/Wba5xg5UoHfvynnb/UB49cQ== - -"@tailwindcss/oxide@4.1.8": - version "4.1.8" - resolved "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.8.tgz" - integrity sha512-d7qvv9PsM5N3VNKhwVUhpK6r4h9wtLkJ6lz9ZY9aeZgrUWk1Z8VPyqyDT9MZlem7GTGseRQHkeB1j3tC7W1P+A== - dependencies: - detect-libc "^2.0.4" - tar "^7.4.3" +"@tailwindcss/oxide@4.1.16": + version "4.1.16" + resolved "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.16.tgz" + integrity sha512-2OSv52FRuhdlgyOQqgtQHuCgXnS8nFSYRp2tJ+4WZXKgTxqPy7SMSls8c3mPT5pkZ17SBToGM5LHEJBO7miEdg== optionalDependencies: - "@tailwindcss/oxide-android-arm64" "4.1.8" - "@tailwindcss/oxide-darwin-arm64" "4.1.8" - "@tailwindcss/oxide-darwin-x64" "4.1.8" - "@tailwindcss/oxide-freebsd-x64" "4.1.8" - "@tailwindcss/oxide-linux-arm-gnueabihf" "4.1.8" - "@tailwindcss/oxide-linux-arm64-gnu" "4.1.8" - "@tailwindcss/oxide-linux-arm64-musl" "4.1.8" - "@tailwindcss/oxide-linux-x64-gnu" "4.1.8" - "@tailwindcss/oxide-linux-x64-musl" "4.1.8" - "@tailwindcss/oxide-wasm32-wasi" "4.1.8" - "@tailwindcss/oxide-win32-arm64-msvc" "4.1.8" - "@tailwindcss/oxide-win32-x64-msvc" "4.1.8" + "@tailwindcss/oxide-android-arm64" "4.1.16" + "@tailwindcss/oxide-darwin-arm64" "4.1.16" + "@tailwindcss/oxide-darwin-x64" "4.1.16" + "@tailwindcss/oxide-freebsd-x64" "4.1.16" + "@tailwindcss/oxide-linux-arm-gnueabihf" "4.1.16" + "@tailwindcss/oxide-linux-arm64-gnu" "4.1.16" + "@tailwindcss/oxide-linux-arm64-musl" "4.1.16" + "@tailwindcss/oxide-linux-x64-gnu" "4.1.16" + "@tailwindcss/oxide-linux-x64-musl" "4.1.16" + "@tailwindcss/oxide-wasm32-wasi" "4.1.16" + "@tailwindcss/oxide-win32-arm64-msvc" "4.1.16" + "@tailwindcss/oxide-win32-x64-msvc" "4.1.16" "@tailwindcss/typography@^0.5.16": - version "0.5.16" - resolved "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.16.tgz" - integrity sha512-0wDLwCVF5V3x3b1SGXPCDcdsbDHMBe+lkFzBRaHeLvNi+nrrnZ1lA18u+OTWO8iSWU2GxUOCvlXtDuqftc1oiA== + version "0.5.19" + resolved "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.19.tgz" + integrity sha512-w31dd8HOx3k9vPtcQh5QHP9GwKcgbMp87j58qi6xgiBnFFtKEAgCWnDw4qUT8aHwkCp8bKvb/KGKWWHedP0AAg== dependencies: - lodash.castarray "^4.4.0" - lodash.isplainobject "^4.0.6" - lodash.merge "^4.6.2" postcss-selector-parser "6.0.10" "@tailwindcss/vite@^4.1.8": - version "4.1.8" - resolved "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.1.8.tgz" - integrity sha512-CQ+I8yxNV5/6uGaJjiuymgw0kEQiNKRinYbZXPdx1fk5WgiyReG0VaUx/Xq6aVNSUNJFzxm6o8FNKS5aMaim5A== + version "4.1.16" + resolved "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.1.16.tgz" + integrity sha512-bbguNBcDxsRmi9nnlWJxhfDWamY3lmcyACHcdO1crxfzuLpOhHLLtEIN/nCbbAtj5rchUgQD17QVAKi1f7IsKg== dependencies: - "@tailwindcss/node" "4.1.8" - "@tailwindcss/oxide" "4.1.8" - tailwindcss "4.1.8" + "@tailwindcss/node" "4.1.16" + "@tailwindcss/oxide" "4.1.16" + tailwindcss "4.1.16" "@tweenjs/tween.js@~23.1.3": version "23.1.3" resolved "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-23.1.3.tgz" integrity sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA== -"@tybys/wasm-util@^0.10.0": - version "0.10.1" - resolved "https://registry.yarnpkg.com/@tybys/wasm-util/-/wasm-util-0.10.1.tgz#ecddd3205cf1e2d5274649ff0eedd2991ed7f414" - integrity sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg== - dependencies: - tslib "^2.4.0" - -"@tybys/wasm-util@^0.9.0": - version "0.9.0" - resolved "https://registry.yarnpkg.com/@tybys/wasm-util/-/wasm-util-0.9.0.tgz#3e75eb00604c8d6db470bf18c37b7d984a0e3355" - integrity sha512-6+7nlbMVX/PVDCwaIQ8nTOPveOcFLSt8GcXdx8hD0bt39uWxYT88uXzqTd4fTvqta7oeUJqudepapKNt2DYJFw== - dependencies: - tslib "^2.4.0" - "@types/babel__core@^7.20.5": version "7.20.5" resolved "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz" @@ -1064,16 +828,16 @@ "@babel/types" "^7.0.0" "@types/babel__traverse@*": - version "7.20.7" - resolved "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.20.7.tgz" - integrity sha512-dkO5fhS7+/oos4ciWxyEyjWe48zmG6wbCheo/G2ZnHx4fs3EU6YC6UM8rk56gAjNJ9P3MTH2jo5jb92/K6wbng== + version "7.28.0" + resolved "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz" + integrity sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q== dependencies: - "@babel/types" "^7.20.7" + "@babel/types" "^7.28.2" "@types/codemirror@^5.60.10", "@types/codemirror@~5.60.5": - version "5.60.15" - resolved "https://registry.npmjs.org/@types/codemirror/-/codemirror-5.60.15.tgz" - integrity sha512-dTOvwEQ+ouKJ/rE9LT1Ue2hmP6H1mZv5+CCnNWu2qtiOe2LQa9lCprEY20HxiDmV/Bxh+dXjywmy5aKvoGjULA== + version "5.60.17" + resolved "https://registry.npmjs.org/@types/codemirror/-/codemirror-5.60.17.tgz" + integrity sha512-AZq2FIsUHVMlp7VSe2hTfl5w4pcUkoFkM3zVsRKsn1ca8CXRDYvnin04+HP2REkwsxemuHqvDofdlhUWNpbwfw== dependencies: "@types/tern" "*" @@ -1106,10 +870,10 @@ dependencies: "@types/estree" "*" -"@types/estree@*", "@types/estree@1.0.7", "@types/estree@^1.0.0", "@types/estree@^1.0.6": - version "1.0.7" - resolved "https://registry.npmjs.org/@types/estree/-/estree-1.0.7.tgz" - integrity sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ== +"@types/estree@*", "@types/estree@^1.0.0", "@types/estree@^1.0.6", "@types/estree@1.0.8": + version "1.0.8" + resolved "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz" + integrity sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w== "@types/hast@^3.0.0": version "3.0.4" @@ -1123,6 +887,11 @@ resolved "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz" integrity sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA== +"@types/lodash@^4.17.24": + version "4.17.24" + resolved "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.24.tgz" + integrity sha512-gIW7lQLZbue7lRSWEFql49QJJWThrTFFeIMJdp3eH4tKoxm1OvEPg02rm4wCCSHS0cL3/Fizimb35b7k8atwsQ== + "@types/marked@^4.0.7": version "4.3.2" resolved "https://registry.npmjs.org/@types/marked/-/marked-4.3.2.tgz" @@ -1140,10 +909,10 @@ resolved "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz" integrity sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA== -"@types/node@^22.15.24": - version "22.15.24" - resolved "https://registry.npmjs.org/@types/node/-/node-22.15.24.tgz" - integrity sha512-w9CZGm9RDjzTh/D+hFwlBJ3ziUaVw7oufKA3vOFSOZlzmW9AkZnfjPb+DLnrV6qtgL/LNmP0/2zBNCFHL3F0ng== +"@types/node@^18.0.0 || ^20.0.0 || >=22.0.0", "@types/node@^22.15.24": + version "22.18.13" + resolved "https://registry.npmjs.org/@types/node/-/node-22.18.13.tgz" + integrity sha512-Bo45YKIjnmFtv6I1TuC8AaHBbqXtIo+Om5fE4QiU1Tj8QR/qt+8O3BAtOimG5IFmwaWiPmB3Mv3jtYzBA4Us2A== dependencies: undici-types "~6.21.0" @@ -1157,10 +926,10 @@ resolved "https://registry.npmjs.org/@types/parse-json/-/parse-json-4.0.2.tgz" integrity sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw== -"@types/prop-types@^15.7.14": - version "15.7.14" - resolved "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.14.tgz" - integrity sha512-gNMvNH49DJ7OJYv+KAKn0Xp45p8PLl6zo2YnvDIbTd4J6MER2BmWN49TG7n9LvkyihINxeKW8+3bfS2yDC9dzQ== +"@types/prop-types@^15.7.15": + version "15.7.15" + resolved "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.15.tgz" + integrity sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw== "@types/rbush@4.0.0": version "4.0.0" @@ -1168,24 +937,29 @@ integrity sha512-+N+2H39P8X+Hy1I5mC6awlTX54k3FhiUmvt7HWzGJZvF+syUAAxP/stwppS8JE84YHqFgRMv6fCy31202CMFxQ== "@types/react-dom@^19.1.2": - version "19.1.5" - resolved "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.1.5.tgz" - integrity sha512-CMCjrWucUBZvohgZxkjd6S9h0nZxXjzus6yDfUb+xLxYM7VvjKNH1tQrE9GWLql1XoOP4/Ds3bwFqShHUYraGg== + version "19.2.2" + resolved "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.2.tgz" + integrity sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw== "@types/react-reconciler@^0.28.9": version "0.28.9" resolved "https://registry.npmjs.org/@types/react-reconciler/-/react-reconciler-0.28.9.tgz" integrity sha512-HHM3nxyUZ3zAylX8ZEyrDNd2XZOnQ0D5XfunJF5FLQnZbHHYq4UWvW1QfelQNXv1ICNkwYhfxjwfnqivYB6bFg== +"@types/react-reconciler@^0.32.0": + version "0.32.2" + resolved "https://registry.npmjs.org/@types/react-reconciler/-/react-reconciler-0.32.2.tgz" + integrity sha512-gjcm6O0aUknhYaogEl8t5pecPfiOTD8VQkbjOhgbZas/E6qGY+veW9iuJU/7p4Y1E0EuQ0mArga7VEOUWSlVRA== + "@types/react-transition-group@^4.4.12": version "4.4.12" resolved "https://registry.npmjs.org/@types/react-transition-group/-/react-transition-group-4.4.12.tgz" integrity sha512-8TV6R3h2j7a91c+1DXdJi3Syo69zzIZbz7Lg5tORM5LEJG7X/E6a1V3drRyBRZq7/utz7A+c4OgYLiLcYGHG6w== -"@types/react@^19.1.2": - version "19.1.6" - resolved "https://registry.npmjs.org/@types/react/-/react-19.1.6.tgz" - integrity sha512-JeG0rEWak0N6Itr6QUx+X60uQmN+5t3j9r/OVDtWzFXKaj6kD1BwJzOksD0FF6iWxZlbE1kB0q9vtnU2ekqa1Q== +"@types/react@*", "@types/react@^17.0.0 || ^18.0.0 || ^19.0.0", "@types/react@^18.2.25 || ^19", "@types/react@^19.1.2", "@types/react@^19.2.0", "@types/react@>=16.8", "@types/react@>=18", "@types/react@>=18.0.0": + version "19.2.2" + resolved "https://registry.npmjs.org/@types/react/-/react-19.2.2.tgz" + integrity sha512-6mDvHUFSjyT2B2yeNx2nUgMxh9LtOWvkhIU3uePn2I2oyNymUAX1NIsdgviM4CH+JSrp2D2hsMvJOkxY+0wNRA== dependencies: csstype "^3.0.2" @@ -1201,10 +975,10 @@ dependencies: "@types/estree" "*" -"@types/three@*": - version "0.177.0" - resolved "https://registry.npmjs.org/@types/three/-/three-0.177.0.tgz" - integrity sha512-/ZAkn4OLUijKQySNci47lFO+4JLE1TihEjsGWPUT+4jWqxtwOPPEwJV1C3k5MEx0mcBPCdkFjzRzDOnHEI1R+A== +"@types/three@*", "@types/three@>=0.134.0": + version "0.180.0" + resolved "https://registry.npmjs.org/@types/three/-/three-0.180.0.tgz" + integrity sha512-ykFtgCqNnY0IPvDro7h+9ZeLY+qjgUWv+qEvUt84grhenO60Hqd4hScHE7VTB9nOQ/3QM8lkbNE+4vKjEpUxKg== dependencies: "@dimforge/rapier3d-compat" "~0.12.0" "@tweenjs/tween.js" "~23.1.3" @@ -1212,7 +986,7 @@ "@types/webxr" "*" "@webgpu/types" "*" fflate "~0.8.2" - meshoptimizer "~0.18.1" + meshoptimizer "~0.22.0" "@types/unist@*", "@types/unist@^3.0.0": version "3.0.3" @@ -1230,82 +1004,83 @@ integrity sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg== "@types/webxr@*", "@types/webxr@^0.5.2": - version "0.5.22" - resolved "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.22.tgz" - integrity sha512-Vr6Stjv5jPRqH690f5I5GLjVk8GSsoQSYJ2FVd/3jJF7KaqfwPi3ehfBS96mlQ2kPCwZaX6U0rG2+NGHBKkA/A== + version "0.5.24" + resolved "https://registry.npmjs.org/@types/webxr/-/webxr-0.5.24.tgz" + integrity sha512-h8fgEd/DpoS9CBrjEQXR+dIDraopAEfu4wYVNY2tEPwk60stPWhvZMf4Foo5FakuQ7HFZoa8WceaWFervK2Ovg== -"@typescript-eslint/eslint-plugin@8.33.0": - version "8.33.0" - resolved "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.33.0.tgz" - integrity sha512-CACyQuqSHt7ma3Ns601xykeBK/rDeZa3w6IS6UtMQbixO5DWy+8TilKkviGDH6jtWCo8FGRKEK5cLLkPvEammQ== +"@typescript-eslint/eslint-plugin@8.46.2": + version "8.46.2" + resolved "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.46.2.tgz" + integrity sha512-ZGBMToy857/NIPaaCucIUQgqueOiq7HeAKkhlvqVV4lm089zUFW6ikRySx2v+cAhKeUCPuWVHeimyk6Dw1iY3w== dependencies: "@eslint-community/regexpp" "^4.10.0" - "@typescript-eslint/scope-manager" "8.33.0" - "@typescript-eslint/type-utils" "8.33.0" - "@typescript-eslint/utils" "8.33.0" - "@typescript-eslint/visitor-keys" "8.33.0" + "@typescript-eslint/scope-manager" "8.46.2" + "@typescript-eslint/type-utils" "8.46.2" + "@typescript-eslint/utils" "8.46.2" + "@typescript-eslint/visitor-keys" "8.46.2" graphemer "^1.4.0" ignore "^7.0.0" natural-compare "^1.4.0" ts-api-utils "^2.1.0" -"@typescript-eslint/parser@8.33.0": - version "8.33.0" - resolved "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.33.0.tgz" - integrity sha512-JaehZvf6m0yqYp34+RVnihBAChkqeH+tqqhS0GuX1qgPpwLvmTPheKEs6OeCK6hVJgXZHJ2vbjnC9j119auStQ== +"@typescript-eslint/parser@^8.46.2", "@typescript-eslint/parser@8.46.2": + version "8.46.2" + resolved "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.46.2.tgz" + integrity sha512-BnOroVl1SgrPLywqxyqdJ4l3S2MsKVLDVxZvjI1Eoe8ev2r3kGDo+PcMihNmDE+6/KjkTubSJnmqGZZjQSBq/g== dependencies: - "@typescript-eslint/scope-manager" "8.33.0" - "@typescript-eslint/types" "8.33.0" - "@typescript-eslint/typescript-estree" "8.33.0" - "@typescript-eslint/visitor-keys" "8.33.0" + "@typescript-eslint/scope-manager" "8.46.2" + "@typescript-eslint/types" "8.46.2" + "@typescript-eslint/typescript-estree" "8.46.2" + "@typescript-eslint/visitor-keys" "8.46.2" debug "^4.3.4" -"@typescript-eslint/project-service@8.33.0": - version "8.33.0" - resolved "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.33.0.tgz" - integrity sha512-d1hz0u9l6N+u/gcrk6s6gYdl7/+pp8yHheRTqP6X5hVDKALEaTn8WfGiit7G511yueBEL3OpOEpD+3/MBdoN+A== +"@typescript-eslint/project-service@8.46.2": + version "8.46.2" + resolved "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.46.2.tgz" + integrity sha512-PULOLZ9iqwI7hXcmL4fVfIsBi6AN9YxRc0frbvmg8f+4hQAjQ5GYNKK0DIArNo+rOKmR/iBYwkpBmnIwin4wBg== dependencies: - "@typescript-eslint/tsconfig-utils" "^8.33.0" - "@typescript-eslint/types" "^8.33.0" + "@typescript-eslint/tsconfig-utils" "^8.46.2" + "@typescript-eslint/types" "^8.46.2" debug "^4.3.4" -"@typescript-eslint/scope-manager@8.33.0": - version "8.33.0" - resolved "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.33.0.tgz" - integrity sha512-LMi/oqrzpqxyO72ltP+dBSP6V0xiUb4saY7WLtxSfiNEBI8m321LLVFU9/QDJxjDQG9/tjSqKz/E3380TEqSTw== +"@typescript-eslint/scope-manager@8.46.2": + version "8.46.2" + resolved "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.46.2.tgz" + integrity sha512-LF4b/NmGvdWEHD2H4MsHD8ny6JpiVNDzrSZr3CsckEgCbAGZbYM4Cqxvi9L+WqDMT+51Ozy7lt2M+d0JLEuBqA== dependencies: - "@typescript-eslint/types" "8.33.0" - "@typescript-eslint/visitor-keys" "8.33.0" + "@typescript-eslint/types" "8.46.2" + "@typescript-eslint/visitor-keys" "8.46.2" -"@typescript-eslint/tsconfig-utils@8.33.0", "@typescript-eslint/tsconfig-utils@^8.33.0": - version "8.33.0" - resolved "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.33.0.tgz" - integrity sha512-sTkETlbqhEoiFmGr1gsdq5HyVbSOF0145SYDJ/EQmXHtKViCaGvnyLqWFFHtEXoS0J1yU8Wyou2UGmgW88fEug== +"@typescript-eslint/tsconfig-utils@^8.46.2", "@typescript-eslint/tsconfig-utils@8.46.2": + version "8.46.2" + resolved "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.46.2.tgz" + integrity sha512-a7QH6fw4S57+F5y2FIxxSDyi5M4UfGF+Jl1bCGd7+L4KsaUY80GsiF/t0UoRFDHAguKlBaACWJRmdrc6Xfkkag== -"@typescript-eslint/type-utils@8.33.0": - version "8.33.0" - resolved "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.33.0.tgz" - integrity sha512-lScnHNCBqL1QayuSrWeqAL5GmqNdVUQAAMTaCwdYEdWfIrSrOGzyLGRCHXcCixa5NK6i5l0AfSO2oBSjCjf4XQ== +"@typescript-eslint/type-utils@8.46.2": + version "8.46.2" + resolved "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.46.2.tgz" + integrity sha512-HbPM4LbaAAt/DjxXaG9yiS9brOOz6fabal4uvUmaUYe6l3K1phQDMQKBRUrr06BQkxkvIZVVHttqiybM9nJsLA== dependencies: - "@typescript-eslint/typescript-estree" "8.33.0" - "@typescript-eslint/utils" "8.33.0" + "@typescript-eslint/types" "8.46.2" + "@typescript-eslint/typescript-estree" "8.46.2" + "@typescript-eslint/utils" "8.46.2" debug "^4.3.4" ts-api-utils "^2.1.0" -"@typescript-eslint/types@8.33.0", "@typescript-eslint/types@^8.33.0": - version "8.33.0" - resolved "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.33.0.tgz" - integrity sha512-DKuXOKpM5IDT1FA2g9x9x1Ug81YuKrzf4mYX8FAVSNu5Wo/LELHWQyM1pQaDkI42bX15PWl0vNPt1uGiIFUOpg== +"@typescript-eslint/types@^8.46.2", "@typescript-eslint/types@8.46.2": + version "8.46.2" + resolved "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.46.2.tgz" + integrity sha512-lNCWCbq7rpg7qDsQrd3D6NyWYu+gkTENkG5IKYhUIcxSb59SQC/hEQ+MrG4sTgBVghTonNWq42bA/d4yYumldQ== -"@typescript-eslint/typescript-estree@8.33.0": - version "8.33.0" - resolved "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.33.0.tgz" - integrity sha512-vegY4FQoB6jL97Tu/lWRsAiUUp8qJTqzAmENH2k59SJhw0Th1oszb9Idq/FyyONLuNqT1OADJPXfyUNOR8SzAQ== +"@typescript-eslint/typescript-estree@8.46.2": + version "8.46.2" + resolved "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.46.2.tgz" + integrity sha512-f7rW7LJ2b7Uh2EiQ+7sza6RDZnajbNbemn54Ob6fRwQbgcIn+GWfyuHDHRYgRoZu1P4AayVScrRW+YfbTvPQoQ== dependencies: - "@typescript-eslint/project-service" "8.33.0" - "@typescript-eslint/tsconfig-utils" "8.33.0" - "@typescript-eslint/types" "8.33.0" - "@typescript-eslint/visitor-keys" "8.33.0" + "@typescript-eslint/project-service" "8.46.2" + "@typescript-eslint/tsconfig-utils" "8.46.2" + "@typescript-eslint/types" "8.46.2" + "@typescript-eslint/visitor-keys" "8.46.2" debug "^4.3.4" fast-glob "^3.3.2" is-glob "^4.0.3" @@ -1313,23 +1088,23 @@ semver "^7.6.0" ts-api-utils "^2.1.0" -"@typescript-eslint/utils@8.33.0": - version "8.33.0" - resolved "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.33.0.tgz" - integrity sha512-lPFuQaLA9aSNa7D5u2EpRiqdAUhzShwGg/nhpBlc4GR6kcTABttCuyjFs8BcEZ8VWrjCBof/bePhP3Q3fS+Yrw== +"@typescript-eslint/utils@8.46.2": + version "8.46.2" + resolved "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.46.2.tgz" + integrity sha512-sExxzucx0Tud5tE0XqR0lT0psBQvEpnpiul9XbGUB1QwpWJJAps1O/Z7hJxLGiZLBKMCutjTzDgmd1muEhBnVg== dependencies: "@eslint-community/eslint-utils" "^4.7.0" - "@typescript-eslint/scope-manager" "8.33.0" - "@typescript-eslint/types" "8.33.0" - "@typescript-eslint/typescript-estree" "8.33.0" + "@typescript-eslint/scope-manager" "8.46.2" + "@typescript-eslint/types" "8.46.2" + "@typescript-eslint/typescript-estree" "8.46.2" -"@typescript-eslint/visitor-keys@8.33.0": - version "8.33.0" - resolved "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.33.0.tgz" - integrity sha512-7RW7CMYoskiz5OOGAWjJFxgb7c5UNjTG292gYhWeOAcFmYCtVCSqjqSBj5zMhxbXo2JOW95YYrUWJfU0zrpaGQ== +"@typescript-eslint/visitor-keys@8.46.2": + version "8.46.2" + resolved "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.46.2.tgz" + integrity sha512-tUFMXI4gxzzMXt4xpGJEsBsTox0XbNQ1y94EwlD/CuZwFcQP79xfQqMhau9HsRc/J0cAPA/HZt1dZPtGn9V/7w== dependencies: - "@typescript-eslint/types" "8.33.0" - eslint-visitor-keys "^4.2.0" + "@typescript-eslint/types" "8.46.2" + eslint-visitor-keys "^4.2.1" "@ungap/structured-clone@^1.0.0": version "1.3.0" @@ -1349,36 +1124,36 @@ "@use-gesture/core" "10.3.1" "@vitejs/plugin-react@^4.4.1": - version "4.5.0" - resolved "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.5.0.tgz" - integrity sha512-JuLWaEqypaJmOJPLWwO335Ig6jSgC1FTONCWAxnqcQthLTK/Yc9aH6hr9z/87xciejbQcnP3GnA1FWUSWeXaeg== + version "4.7.0" + resolved "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz" + integrity sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA== dependencies: - "@babel/core" "^7.26.10" - "@babel/plugin-transform-react-jsx-self" "^7.25.9" - "@babel/plugin-transform-react-jsx-source" "^7.25.9" - "@rolldown/pluginutils" "1.0.0-beta.9" + "@babel/core" "^7.28.0" + "@babel/plugin-transform-react-jsx-self" "^7.27.1" + "@babel/plugin-transform-react-jsx-source" "^7.27.1" + "@rolldown/pluginutils" "1.0.0-beta.27" "@types/babel__core" "^7.20.5" react-refresh "^0.17.0" "@webgpu/types@*", "@webgpu/types@^0.1.40": - version "0.1.61" - resolved "https://registry.npmjs.org/@webgpu/types/-/types-0.1.61.tgz" - integrity sha512-w2HbBvH+qO19SB5pJOJFKs533CdZqxl3fcGonqL321VHkW7W/iBo6H8bjDy6pr/+pbMwIu5dnuaAxH7NxBqUrQ== + version "0.1.66" + resolved "https://registry.npmjs.org/@webgpu/types/-/types-0.1.66.tgz" + integrity sha512-YA2hLrwLpDsRueNDXIMqN9NTzD6bCDkuXbOSe0heS+f8YE8usA6Gbv1prj81pzVHrbaAma7zObnIC+I6/sXJgA== "@xmldom/xmldom@^0.8.10": - version "0.8.10" - resolved "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.10.tgz" - integrity sha512-2WALfTl4xo2SkGCYRt6rDTFfk9R1czmBvUQy12gK2KuRKIpWEhcbbzy8EZXtz/jkRqHX8bFEc6FC1HjX4TUWYw== + version "0.8.11" + resolved "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.11.tgz" + integrity sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw== acorn-jsx@^5.3.2: version "5.3.2" resolved "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz" integrity sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ== -acorn@^8.14.0: - version "8.14.1" - resolved "https://registry.npmjs.org/acorn/-/acorn-8.14.1.tgz" - integrity sha512-OvQ/2pUDKmgfCg++xsTX1wGxfTaszcHVcTctW4UJB4hibJx2HXxxO5UmVgyjMa+ZDsiaf5wWLXYpRWMmBI0QHg== +"acorn@^6.0.0 || ^7.0.0 || ^8.0.0", acorn@^8.15.0: + version "8.15.0" + resolved "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz" + integrity sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg== ajv@^6.12.4: version "6.12.6" @@ -1390,7 +1165,12 @@ ajv@^6.12.4: json-schema-traverse "^0.4.1" uri-js "^4.2.2" -ansi-styles@^4.1.0: +ansi-regex@^5.0.1: + version "5.0.1" + resolved "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz" + integrity sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ== + +ansi-styles@^4.0.0, ansi-styles@^4.1.0: version "4.3.0" resolved "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz" integrity sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg== @@ -1407,18 +1187,13 @@ asynckit@^0.4.0: resolved "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz" integrity sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q== -attr-accept@^2.2.4: - version "2.2.5" - resolved "https://registry.npmjs.org/attr-accept/-/attr-accept-2.2.5.tgz" - integrity sha512-0bDNnY/u6pPwHDMoF0FieU354oBi0a8rD9FcsLwzcGWbc8KS8KPIi7y+s13OlVY+gMWc/9xEMUgNE6Qm8ZllYQ== - axios@^1.9.0: - version "1.9.0" - resolved "https://registry.npmjs.org/axios/-/axios-1.9.0.tgz" - integrity sha512-re4CqKTJaURpzbLHtIi6XpDv20/CnpXOtjRY5/CU32L8gU8ek9UIivcfvSWvmKEngmVbrUtPpdDwWDWL7DNHvg== + version "1.13.1" + resolved "https://registry.npmjs.org/axios/-/axios-1.13.1.tgz" + integrity sha512-hU4EGxxt+j7TQijx1oYdAjw4xuIp1wRQSsbMFwSthCWeBQur1eF+qJ5iQ5sN3Tw8YRzQNKb8jszgBdMDVqwJcw== dependencies: follow-redirects "^1.15.6" - form-data "^4.0.0" + form-data "^4.0.4" proxy-from-env "^1.1.0" babel-plugin-macros@^3.1.0: @@ -1445,6 +1220,11 @@ base64-js@^1.3.1, base64-js@^1.5.1: resolved "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz" integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA== +baseline-browser-mapping@^2.8.19: + version "2.8.20" + resolved "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.20.tgz" + integrity sha512-JMWsdF+O8Orq3EMukbUN1QfbLK9mX2CkUmQBcW2T0s8OmdAUL5LLM/6wFwSrqXzlXB13yhyK9gTKS1rIizOduQ== + bidi-js@^1.0.2: version "1.0.3" resolved "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz" @@ -1453,17 +1233,17 @@ bidi-js@^1.0.2: require-from-string "^2.0.2" brace-expansion@^1.1.7: - version "1.1.11" - resolved "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz" - integrity sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA== + version "1.1.12" + resolved "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz" + integrity sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg== dependencies: balanced-match "^1.0.0" concat-map "0.0.1" brace-expansion@^2.0.1: - version "2.0.1" - resolved "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz" - integrity sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA== + version "2.0.2" + resolved "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz" + integrity sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ== dependencies: balanced-match "^1.0.0" @@ -1474,15 +1254,16 @@ braces@^3.0.3: dependencies: fill-range "^7.1.1" -browserslist@^4.24.0: - version "4.24.5" - resolved "https://registry.npmjs.org/browserslist/-/browserslist-4.24.5.tgz" - integrity sha512-FDToo4Wo82hIdgc1CQ+NQD0hEhmpPjrZ3hiUgwgOG6IuTdlpr8jdjyG24P6cNP1yJpTLzS5OcGgSw0xmDU1/Tw== +browserslist@^4.24.0, "browserslist@>= 4.21.0": + version "4.27.0" + resolved "https://registry.npmjs.org/browserslist/-/browserslist-4.27.0.tgz" + integrity sha512-AXVQwdhot1eqLihwasPElhX2tAZiBjWdJ9i/Zcj2S6QYIjkx62OKSfnobkriB81C3l4w0rVy3Nt4jaTBltYEpw== dependencies: - caniuse-lite "^1.0.30001716" - electron-to-chromium "^1.5.149" - node-releases "^2.0.19" - update-browserslist-db "^1.1.3" + baseline-browser-mapping "^2.8.19" + caniuse-lite "^1.0.30001751" + electron-to-chromium "^1.5.238" + node-releases "^2.0.26" + update-browserslist-db "^1.1.4" buffer@^6.0.3: version "6.0.3" @@ -1505,15 +1286,20 @@ callsites@^3.0.0: resolved "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz" integrity sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ== -camera-controls@^2.9.0: - version "2.10.1" - resolved "https://registry.npmjs.org/camera-controls/-/camera-controls-2.10.1.tgz" - integrity sha512-KnaKdcvkBJ1Irbrzl8XD6WtZltkRjp869Jx8c0ujs9K+9WD+1D7ryBsCiVqJYUqt6i/HR5FxT7RLASieUD+Q5w== +camelcase@^6.2.0: + version "6.3.0" + resolved "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz" + integrity sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA== -caniuse-lite@^1.0.30001716: - version "1.0.30001718" - resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001718.tgz" - integrity sha512-AflseV1ahcSunK53NfEs9gFWgOEmzr0f+kaMFA4xiLZlr9Hzt7HxcSpIFcnNCUkz6R6dWKa54rUz3HUmI3nVcw== +camera-controls@^3.1.0: + version "3.1.1" + resolved "https://registry.npmjs.org/camera-controls/-/camera-controls-3.1.1.tgz" + integrity sha512-zC3DcoQPJ0CbTZ8WHthzi8nMvVF71cppOTBcH4cMLreMkU3y3fzBPViGvz1BefWPo9+kv9BP41tvIsabsXTz+Q== + +caniuse-lite@^1.0.30001751: + version "1.0.30001751" + resolved "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001751.tgz" + integrity sha512-A0QJhug0Ly64Ii3eIqHu5X51ebln3k4yTUkY1j8drqpWHVreg/VLijN48cZ1bYPiqOQuqpkIKnzr/Ul8V+p6Cw== ccount@^2.0.0: version "2.0.1" @@ -1548,10 +1334,14 @@ character-reference-invalid@^2.0.0: resolved "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz" integrity sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw== -chownr@^3.0.0: - version "3.0.0" - resolved "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz" - integrity sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g== +cliui@^8.0.1: + version "8.0.1" + resolved "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz" + integrity sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ== + dependencies: + string-width "^4.2.0" + strip-ansi "^6.0.1" + wrap-ansi "^7.0.0" clsx@^2.1.1: version "2.1.1" @@ -1566,9 +1356,9 @@ codemirror-spell-checker@1.1.2: typo-js "*" codemirror@^5.65.15: - version "5.65.19" - resolved "https://registry.npmjs.org/codemirror/-/codemirror-5.65.19.tgz" - integrity sha512-+aFkvqhaAVr1gferNMuN8vkTSrWIFvzlMV9I2KBLCWS2WpZ2+UAkZjlMZmEuT+gcXTi6RrGQCkWq1/bDtGqhIA== + version "5.65.20" + resolved "https://registry.npmjs.org/codemirror/-/codemirror-5.65.20.tgz" + integrity sha512-i5dLDDxwkFCbhjvL2pNjShsojoL3XHyDwsGv1jqETUoW+lzpBKKqNTUWgQwVAOa0tUm4BwekT455ujafi8payA== color-convert@^2.0.1: version "2.0.1" @@ -1625,6 +1415,16 @@ cosmiconfig@^7.0.0: path-type "^4.0.0" yaml "^1.10.0" +cosmiconfig@^8.1.3: + version "8.3.6" + resolved "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-8.3.6.tgz" + integrity sha512-kcZ6+W5QzcJ3P1Mt+83OUv/oHFqZHIx8DuxG6eZ5RGMERoLqp4BuGjhHLYGK+Kf5XVkQvqBSmAy/nGWN3qDgEA== + dependencies: + import-fresh "^3.3.0" + js-yaml "^4.1.0" + parse-json "^5.2.0" + path-type "^4.0.0" + cross-env@^7.0.3: version "7.0.3" resolved "https://registry.npmjs.org/cross-env/-/cross-env-7.0.3.tgz" @@ -1659,16 +1459,16 @@ csstype@^3.0.2, csstype@^3.1.3: integrity sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw== debug@^4.0.0, debug@^4.1.0, debug@^4.3.1, debug@^4.3.2, debug@^4.3.4: - version "4.4.1" - resolved "https://registry.npmjs.org/debug/-/debug-4.4.1.tgz" - integrity sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ== + version "4.4.3" + resolved "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz" + integrity sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA== dependencies: ms "^2.1.3" decode-named-character-reference@^1.0.0: - version "1.1.0" - resolved "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.1.0.tgz" - integrity sha512-Wy+JTSbFThEOXQIR2L6mxJvEs+veIzpmqD7ynWxMXGpnk3smkHQOp6forLdHsKpAMW9iJpaBBIxz285t1n1C3w== + version "1.2.0" + resolved "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.2.0.tgz" + integrity sha512-c6fcElNV6ShtZXmsgNgFFV5tVX2PaV4g+MOAkb8eXHvn6sryJBrZa9r0zV6+dtTyoCKxtDy5tyQ5ZwQuidtd+Q== dependencies: character-entities "^2.0.0" @@ -1677,6 +1477,11 @@ deep-is@^0.1.3: resolved "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz" integrity sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ== +define-lazy-prop@^2.0.0: + version "2.0.0" + resolved "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-2.0.0.tgz" + integrity sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og== + delayed-stream@~1.0.0: version "1.0.0" resolved "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz" @@ -1694,10 +1499,10 @@ detect-gpu@^5.0.56: dependencies: webgl-constants "^1.1.1" -detect-libc@^2.0.3, detect-libc@^2.0.4: - version "2.0.4" - resolved "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz" - integrity sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA== +detect-libc@^2.0.3: + version "2.1.2" + resolved "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz" + integrity sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ== devlop@^1.0.0, devlop@^1.1.0: version "1.1.0" @@ -1706,6 +1511,11 @@ devlop@^1.0.0, devlop@^1.1.0: dependencies: dequal "^2.0.0" +diacritics@1.3.0: + version "1.3.0" + resolved "https://registry.npmjs.org/diacritics/-/diacritics-1.3.0.tgz" + integrity sha512-wlwEkqcsaxvPJML+rDh/2iS824jbREk6DUMUKkEaSlxdYHeS43cClJtsWglvw2RfeXGm6ohKDqsXteJ5sP5enA== + dom-helpers@^5.0.1: version "5.2.1" resolved "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz" @@ -1714,6 +1524,14 @@ dom-helpers@^5.0.1: "@babel/runtime" "^7.8.7" csstype "^3.0.2" +dot-case@^3.0.4: + version "3.0.4" + resolved "https://registry.npmjs.org/dot-case/-/dot-case-3.0.4.tgz" + integrity sha512-Kv5nKlh6yRrdrGvxeJ2e5y2eRUpkUosIW4A2AS38zwSz27zu7ufDwQPi5Jhs3XAlGNetl3bmnGhQsMtkKJnj3w== + dependencies: + no-case "^3.0.4" + tslib "^2.0.3" + draco3d@^1.4.1: version "1.5.7" resolved "https://registry.npmjs.org/draco3d/-/draco3d-1.5.7.tgz" @@ -1728,12 +1546,12 @@ dunder-proto@^1.0.1: es-errors "^1.3.0" gopd "^1.2.0" -earcut@^3.0.0, earcut@^3.0.1: - version "3.0.1" - resolved "https://registry.npmjs.org/earcut/-/earcut-3.0.1.tgz" - integrity sha512-0l1/0gOjESMeQyYaK5IDiPNvFeu93Z/cO0TjZh9eZ1vyCtZnA7KMZ8rQggpsJHIbGSdrqYq9OhuveadOVHCshw== +earcut@^3.0.0, earcut@^3.0.2: + version "3.0.2" + resolved "https://registry.npmjs.org/earcut/-/earcut-3.0.2.tgz" + integrity sha512-X7hshQbLyMJ/3RPhyObLARM2sNxxmRALLKx1+NVFFnQ9gKzmCrxm9+uLIAdBcvc8FNLpctqlQ2V6AE92Ol9UDQ== -easymde@^2.20.0: +easymde@^2.20.0, "easymde@>= 2.0.0 < 3.0.0": version "2.20.0" resolved "https://registry.npmjs.org/easymde/-/easymde-2.20.0.tgz" integrity sha512-V1Z5f92TfR42Na852OWnIZMbM7zotWQYTddNaLYZFVKj7APBbyZ3FYJ27gBw2grMW3R6Qdv9J8n5Ij7XRSIgXQ== @@ -1744,28 +1562,38 @@ easymde@^2.20.0: codemirror-spell-checker "1.1.2" marked "^4.1.0" -electron-to-chromium@^1.5.149: - version "1.5.160" - resolved "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.160.tgz" - integrity sha512-8yQk54/CoCQT8GX3zuxqPBwMAQuIr6dWI/qO8Aah/JAZwB5XmCbEElsqb1n4pzc2vpkTdfc/kbyNPJOjswfbgg== +electron-to-chromium@^1.5.238: + version "1.5.241" + resolved "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.241.tgz" + integrity sha512-ILMvKX/ZV5WIJzzdtuHg8xquk2y0BOGlFOxBVwTpbiXqWIH0hamG45ddU4R3PQ0gYu+xgo0vdHXHli9sHIGb4w== -enhanced-resolve@^5.18.1: - version "5.18.1" - resolved "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.1.tgz" - integrity sha512-ZSW3ma5GkcQBIpwZTSRAI8N71Uuwgs93IezB7mf7R60tC8ZbJideoDNKjHn2O9KIlx6rkGTTEk1xUCK2E1Y2Yg== +emoji-regex@^8.0.0: + version "8.0.0" + resolved "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz" + integrity sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A== + +enhanced-resolve@^5.18.3: + version "5.18.3" + resolved "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.3.tgz" + integrity sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww== dependencies: graceful-fs "^4.2.4" tapable "^2.2.0" +entities@^4.4.0: + version "4.5.0" + resolved "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz" + integrity sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw== + entities@^6.0.0: - version "6.0.0" - resolved "https://registry.npmjs.org/entities/-/entities-6.0.0.tgz" - integrity sha512-aKstq2TDOndCn4diEyp9Uq/Flu2i1GlLkc6XIDQSDMuaFE3OPW5OphLCyQ5SpSJZTb4reN+kTcYru5yIfXoRPw== + version "6.0.1" + resolved "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz" + integrity sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g== error-ex@^1.3.1: - version "1.3.2" - resolved "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz" - integrity sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g== + version "1.3.4" + resolved "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz" + integrity sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ== dependencies: is-arrayish "^0.2.1" @@ -1797,37 +1625,38 @@ es-set-tostringtag@^2.1.0: hasown "^2.0.2" esbuild@^0.25.0: - version "0.25.5" - resolved "https://registry.npmjs.org/esbuild/-/esbuild-0.25.5.tgz" - integrity sha512-P8OtKZRv/5J5hhz0cUAdu/cLuPIKXpQl1R9pZtvmHWQvrAUVd0UNIPT4IB4W3rNOqVO0rlqHmCIbSwxh/c9yUQ== + version "0.25.11" + resolved "https://registry.npmjs.org/esbuild/-/esbuild-0.25.11.tgz" + integrity sha512-KohQwyzrKTQmhXDW1PjCv3Tyspn9n5GcY2RTDqeORIdIJY8yKIF7sTSopFmn/wpMPW4rdPXI0UE5LJLuq3bx0Q== optionalDependencies: - "@esbuild/aix-ppc64" "0.25.5" - "@esbuild/android-arm" "0.25.5" - "@esbuild/android-arm64" "0.25.5" - "@esbuild/android-x64" "0.25.5" - "@esbuild/darwin-arm64" "0.25.5" - "@esbuild/darwin-x64" "0.25.5" - "@esbuild/freebsd-arm64" "0.25.5" - "@esbuild/freebsd-x64" "0.25.5" - "@esbuild/linux-arm" "0.25.5" - "@esbuild/linux-arm64" "0.25.5" - "@esbuild/linux-ia32" "0.25.5" - "@esbuild/linux-loong64" "0.25.5" - "@esbuild/linux-mips64el" "0.25.5" - "@esbuild/linux-ppc64" "0.25.5" - "@esbuild/linux-riscv64" "0.25.5" - "@esbuild/linux-s390x" "0.25.5" - "@esbuild/linux-x64" "0.25.5" - "@esbuild/netbsd-arm64" "0.25.5" - "@esbuild/netbsd-x64" "0.25.5" - "@esbuild/openbsd-arm64" "0.25.5" - "@esbuild/openbsd-x64" "0.25.5" - "@esbuild/sunos-x64" "0.25.5" - "@esbuild/win32-arm64" "0.25.5" - "@esbuild/win32-ia32" "0.25.5" - "@esbuild/win32-x64" "0.25.5" + "@esbuild/aix-ppc64" "0.25.11" + "@esbuild/android-arm" "0.25.11" + "@esbuild/android-arm64" "0.25.11" + "@esbuild/android-x64" "0.25.11" + "@esbuild/darwin-arm64" "0.25.11" + "@esbuild/darwin-x64" "0.25.11" + "@esbuild/freebsd-arm64" "0.25.11" + "@esbuild/freebsd-x64" "0.25.11" + "@esbuild/linux-arm" "0.25.11" + "@esbuild/linux-arm64" "0.25.11" + "@esbuild/linux-ia32" "0.25.11" + "@esbuild/linux-loong64" "0.25.11" + "@esbuild/linux-mips64el" "0.25.11" + "@esbuild/linux-ppc64" "0.25.11" + "@esbuild/linux-riscv64" "0.25.11" + "@esbuild/linux-s390x" "0.25.11" + "@esbuild/linux-x64" "0.25.11" + "@esbuild/netbsd-arm64" "0.25.11" + "@esbuild/netbsd-x64" "0.25.11" + "@esbuild/openbsd-arm64" "0.25.11" + "@esbuild/openbsd-x64" "0.25.11" + "@esbuild/openharmony-arm64" "0.25.11" + "@esbuild/sunos-x64" "0.25.11" + "@esbuild/win32-arm64" "0.25.11" + "@esbuild/win32-ia32" "0.25.11" + "@esbuild/win32-x64" "0.25.11" -escalade@^3.2.0: +escalade@^3.1.1, escalade@^3.2.0: version "3.2.0" resolved "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz" integrity sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA== @@ -1843,14 +1672,14 @@ eslint-plugin-react-hooks@^5.2.0: integrity sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg== eslint-plugin-react-refresh@^0.4.19: - version "0.4.20" - resolved "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.20.tgz" - integrity sha512-XpbHQ2q5gUF8BGOX4dHe+71qoirYMhApEPZ7sfhF/dNnOF1UXnCMGZf79SFTBO7Bz5YEIT4TMieSlJBWhP9WBA== + version "0.4.24" + resolved "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.4.24.tgz" + integrity sha512-nLHIW7TEq3aLrEYWpVaJ1dRgFR+wLDPN8e8FpYAql/bMV2oBEfC37K0gLEGgv9fy66juNShSMV8OkTqzltcG/w== -eslint-scope@^8.3.0: - version "8.3.0" - resolved "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.3.0.tgz" - integrity sha512-pUNxi75F8MJ/GdeKtVLSbYg4ZI34J6C0C7sbL4YOp2exGwen7ZsuBqKzUhXd0qMQ362yET3z+uPwKeg/0C2XCQ== +eslint-scope@^8.4.0: + version "8.4.0" + resolved "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz" + integrity sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg== dependencies: esrecurse "^4.3.0" estraverse "^5.2.0" @@ -1860,37 +1689,36 @@ eslint-visitor-keys@^3.4.3: resolved "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz" integrity sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag== -eslint-visitor-keys@^4.2.0: - version "4.2.0" - resolved "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz" - integrity sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw== +eslint-visitor-keys@^4.2.1: + version "4.2.1" + resolved "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz" + integrity sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ== -eslint@^9.25.0: - version "9.27.0" - resolved "https://registry.npmjs.org/eslint/-/eslint-9.27.0.tgz" - integrity sha512-ixRawFQuMB9DZ7fjU3iGGganFDp3+45bPOdaRurcFHSXO1e/sYwUX/FtQZpLZJR6SjMoJH8hR2pPEAfDyCoU2Q== +"eslint@^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0", "eslint@^6.0.0 || ^7.0.0 || >=8.0.0", "eslint@^8.57.0 || ^9.0.0", eslint@^9.25.0, eslint@>=8.40: + version "9.38.0" + resolved "https://registry.npmjs.org/eslint/-/eslint-9.38.0.tgz" + integrity sha512-t5aPOpmtJcZcz5UJyY2GbvpDlsK5E8JqRqoKtfiKE3cNh437KIqfJr3A3AKf5k64NPx6d0G3dno6XDY05PqPtw== dependencies: - "@eslint-community/eslint-utils" "^4.2.0" + "@eslint-community/eslint-utils" "^4.8.0" "@eslint-community/regexpp" "^4.12.1" - "@eslint/config-array" "^0.20.0" - "@eslint/config-helpers" "^0.2.1" - "@eslint/core" "^0.14.0" + "@eslint/config-array" "^0.21.1" + "@eslint/config-helpers" "^0.4.1" + "@eslint/core" "^0.16.0" "@eslint/eslintrc" "^3.3.1" - "@eslint/js" "9.27.0" - "@eslint/plugin-kit" "^0.3.1" + "@eslint/js" "9.38.0" + "@eslint/plugin-kit" "^0.4.0" "@humanfs/node" "^0.16.6" "@humanwhocodes/module-importer" "^1.0.1" "@humanwhocodes/retry" "^0.4.2" "@types/estree" "^1.0.6" - "@types/json-schema" "^7.0.15" ajv "^6.12.4" chalk "^4.0.0" cross-spawn "^7.0.6" debug "^4.3.2" escape-string-regexp "^4.0.0" - eslint-scope "^8.3.0" - eslint-visitor-keys "^4.2.0" - espree "^10.3.0" + eslint-scope "^8.4.0" + eslint-visitor-keys "^4.2.1" + espree "^10.4.0" esquery "^1.5.0" esutils "^2.0.2" fast-deep-equal "^3.1.3" @@ -1906,14 +1734,14 @@ eslint@^9.25.0: natural-compare "^1.4.0" optionator "^0.9.3" -espree@^10.0.1, espree@^10.3.0: - version "10.3.0" - resolved "https://registry.npmjs.org/espree/-/espree-10.3.0.tgz" - integrity sha512-0QYC8b24HWY8zjRnDTL6RiHfDbAWn63qb4LMj1Z4b076A4une81+z03Kg7l7mn/48PUTqoLptSXez8oknU8Clg== +espree@^10.0.1, espree@^10.4.0: + version "10.4.0" + resolved "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz" + integrity sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ== dependencies: - acorn "^8.14.0" + acorn "^8.15.0" acorn-jsx "^5.3.2" - eslint-visitor-keys "^4.2.0" + eslint-visitor-keys "^4.2.1" esquery@^1.5.0: version "1.6.0" @@ -1939,6 +1767,11 @@ estree-util-is-identifier-name@^3.0.0: resolved "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz" integrity sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg== +estree-walker@^2.0.2: + version "2.0.2" + resolved "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz" + integrity sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w== + esutils@^2.0.2: version "2.0.3" resolved "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz" @@ -1988,9 +1821,14 @@ fastq@^1.6.0: reusify "^1.0.4" fdir@^6.4.4: - version "6.4.5" - resolved "https://registry.npmjs.org/fdir/-/fdir-6.4.5.tgz" - integrity sha512-4BG7puHpVsIYxZUbiUE3RqGloLaSSwzYie5jvasC4LWuBWzZawynvYouhjbQKw2JuIGYdm0DzIxl8iVidKlUEw== + version "6.5.0" + resolved "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz" + integrity sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg== + +fdir@^6.5.0: + version "6.5.0" + resolved "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz" + integrity sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg== fflate@^0.6.9: version "0.6.10" @@ -2009,13 +1847,6 @@ file-entry-cache@^8.0.0: dependencies: flat-cache "^4.0.0" -file-selector@^2.1.0: - version "2.1.2" - resolved "https://registry.npmjs.org/file-selector/-/file-selector-2.1.2.tgz" - integrity sha512-QgXo+mXTe8ljeqUFaX3QVHc5osSItJ/Km+xpocx0aSqWGMSCf6qYs/VnzZgS864Pjn5iceMRFigeAV7AfTlaig== - dependencies: - tslib "^2.7.0" - fill-range@^7.1.1: version "7.1.1" resolved "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz" @@ -2050,18 +1881,19 @@ flatted@^3.2.9: integrity sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg== follow-redirects@^1.15.6: - version "1.15.9" - resolved "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz" - integrity sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ== + version "1.15.11" + resolved "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz" + integrity sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ== -form-data@^4.0.0: - version "4.0.2" - resolved "https://registry.npmjs.org/form-data/-/form-data-4.0.2.tgz" - integrity sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w== +form-data@^4.0.4: + version "4.0.4" + resolved "https://registry.npmjs.org/form-data/-/form-data-4.0.4.tgz" + integrity sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow== dependencies: asynckit "^0.4.0" combined-stream "^1.0.8" es-set-tostringtag "^2.1.0" + hasown "^2.0.2" mime-types "^2.1.12" fsevents@~2.3.2, fsevents@~2.3.3: @@ -2093,6 +1925,11 @@ geotiff@^2.1.3: xml-utils "^1.0.2" zstddec "^0.1.0" +get-caller-file@^2.0.5: + version "2.0.5" + resolved "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz" + integrity sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg== + get-intrinsic@^1.2.6: version "1.3.0" resolved "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz" @@ -2138,20 +1975,15 @@ glob-parent@^6.0.2: dependencies: is-glob "^4.0.3" -globals@^11.1.0: - version "11.12.0" - resolved "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz" - integrity sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA== - globals@^14.0.0: version "14.0.0" resolved "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz" integrity sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ== globals@^16.0.0: - version "16.2.0" - resolved "https://registry.npmjs.org/globals/-/globals-16.2.0.tgz" - integrity sha512-O+7l9tPdHCU320IigZZPj5zmRCFG9xHmx9cU8FqU2Rp+JN714seHV+2S9+JslCpY4gJwU2vOGox0wzgae/MCEg== + version "16.4.0" + resolved "https://registry.npmjs.org/globals/-/globals-16.4.0.tgz" + integrity sha512-ob/2LcVVaVGCYN+r14cnwnoDPUufjiYgSqRhiFD0Q1iI4Odora5RE8Iv1D24hAz5oMophRGkGz+yuvQmmUMnMw== glsl-noise@^0.0.0: version "0.0.0" @@ -2290,9 +2122,9 @@ hastscript@^9.0.0: space-separated-tokens "^2.0.0" hls.js@^1.5.17: - version "1.6.5" - resolved "https://registry.npmjs.org/hls.js/-/hls.js-1.6.5.tgz" - integrity sha512-KMn5n7JBK+olC342740hDPHnGWfE8FiHtGMOdJPfUjRdARTWj9OB+8c13fnsf9sk1VtpuU2fKSgUjHvg4rNbzQ== + version "1.6.13" + resolved "https://registry.npmjs.org/hls.js/-/hls.js-1.6.13.tgz" + integrity sha512-hNEzjZNHf5bFrUNvdS4/1RjIanuJ6szpWNfTaX5I6WfGynWXGT7K/YQLYtemSvFExzeMdgdE4SsyVLJbd5PcZA== hoist-non-react-statics@^3.3.1: version "3.3.2" @@ -2311,6 +2143,13 @@ html-void-elements@^3.0.0: resolved "https://registry.npmjs.org/html-void-elements/-/html-void-elements-3.0.0.tgz" integrity sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg== +i18n-iso-countries@^7.14.0: + version "7.14.0" + resolved "https://registry.npmjs.org/i18n-iso-countries/-/i18n-iso-countries-7.14.0.tgz" + integrity sha512-nXHJZYtNrfsi1UQbyRqm3Gou431elgLjKl//CYlnBGt5aTWdRPH1PiS2T/p/n8Q8LnqYqzQJik3Q7mkwvLokeg== + dependencies: + diacritics "1.3.0" + ieee754@^1.2.1: version "1.2.1" resolved "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz" @@ -2322,16 +2161,16 @@ ignore@^5.2.0: integrity sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g== ignore@^7.0.0: - version "7.0.4" - resolved "https://registry.npmjs.org/ignore/-/ignore-7.0.4.tgz" - integrity sha512-gJzzk+PQNznz8ysRrC0aOkBNVRBDtE1n53IqyqEf3PXrYwomFs5q4pGMizBMJF+ykh03insJ27hB8gSrD2Hn8A== + version "7.0.5" + resolved "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz" + integrity sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg== immediate@~3.0.5: version "3.0.6" resolved "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz" integrity sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ== -import-fresh@^3.2.1: +import-fresh@^3.2.1, import-fresh@^3.3.0: version "3.3.1" resolved "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz" integrity sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ== @@ -2372,7 +2211,7 @@ is-arrayish@^0.2.1: resolved "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz" integrity sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg== -is-core-module@^2.16.0: +is-core-module@^2.16.1: version "2.16.1" resolved "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz" integrity sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w== @@ -2384,11 +2223,21 @@ is-decimal@^2.0.0: resolved "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz" integrity sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A== +is-docker@^2.0.0, is-docker@^2.1.1: + version "2.2.1" + resolved "https://registry.npmjs.org/is-docker/-/is-docker-2.2.1.tgz" + integrity sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ== + is-extglob@^2.1.1: version "2.1.1" resolved "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz" integrity sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ== +is-fullwidth-code-point@^3.0.0: + 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== + is-glob@^4.0.0, is-glob@^4.0.1, is-glob@^4.0.3: version "4.0.3" resolved "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz" @@ -2416,6 +2265,13 @@ is-promise@^2.1.0: resolved "https://registry.npmjs.org/is-promise/-/is-promise-2.2.2.tgz" integrity sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ== +is-wsl@^2.2.0: + version "2.2.0" + resolved "https://registry.npmjs.org/is-wsl/-/is-wsl-2.2.0.tgz" + integrity sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww== + dependencies: + is-docker "^2.0.0" + isexe@^2.0.0: version "2.0.0" resolved "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz" @@ -2433,10 +2289,10 @@ its-fine@^2.0.0: dependencies: "@types/react-reconciler" "^0.28.9" -jiti@^2.4.2: - version "2.4.2" - resolved "https://registry.npmjs.org/jiti/-/jiti-2.4.2.tgz" - integrity sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A== +jiti@*, jiti@^2.6.1, jiti@>=1.21.0: + version "2.6.1" + resolved "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz" + integrity sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ== js-binary-schema-parser@^2.0.3: version "2.0.3" @@ -2512,73 +2368,29 @@ lie@^3.0.2: dependencies: immediate "~3.0.5" -lightningcss-darwin-arm64@1.30.1: - version "1.30.1" - resolved "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.1.tgz" - integrity sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ== +lightningcss-darwin-arm64@1.30.2: + version "1.30.2" + resolved "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.2.tgz" + integrity sha512-ylTcDJBN3Hp21TdhRT5zBOIi73P6/W0qwvlFEk22fkdXchtNTOU4Qc37SkzV+EKYxLouZ6M4LG9NfZ1qkhhBWA== -lightningcss-darwin-x64@1.30.1: - version "1.30.1" - resolved "https://registry.yarnpkg.com/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.1.tgz#e81105d3fd6330860c15fe860f64d39cff5fbd22" - integrity sha512-k1EvjakfumAQoTfcXUcHQZhSpLlkAuEkdMBsI/ivWw9hL+7FtilQc0Cy3hrx0AAQrVtQAbMI7YjCgYgvn37PzA== - -lightningcss-freebsd-x64@1.30.1: - version "1.30.1" - resolved "https://registry.yarnpkg.com/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.1.tgz#a0e732031083ff9d625c5db021d09eb085af8be4" - integrity sha512-kmW6UGCGg2PcyUE59K5r0kWfKPAVy4SltVeut+umLCFoJ53RdCUWxcRDzO1eTaxf/7Q2H7LTquFHPL5R+Gjyig== - -lightningcss-linux-arm-gnueabihf@1.30.1: - version "1.30.1" - resolved "https://registry.yarnpkg.com/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.1.tgz#1f5ecca6095528ddb649f9304ba2560c72474908" - integrity sha512-MjxUShl1v8pit+6D/zSPq9S9dQ2NPFSQwGvxBCYaBYLPlCWuPh9/t1MRS8iUaR8i+a6w7aps+B4N0S1TYP/R+Q== - -lightningcss-linux-arm64-gnu@1.30.1: - version "1.30.1" - resolved "https://registry.yarnpkg.com/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.1.tgz#eee7799726103bffff1e88993df726f6911ec009" - integrity sha512-gB72maP8rmrKsnKYy8XUuXi/4OctJiuQjcuqWNlJQ6jZiWqtPvqFziskH3hnajfvKB27ynbVCucKSm2rkQp4Bw== - -lightningcss-linux-arm64-musl@1.30.1: - version "1.30.1" - resolved "https://registry.yarnpkg.com/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.1.tgz#f2e4b53f42892feeef8f620cbb889f7c064a7dfe" - integrity sha512-jmUQVx4331m6LIX+0wUhBbmMX7TCfjF5FoOH6SD1CttzuYlGNVpA7QnrmLxrsub43ClTINfGSYyHe2HWeLl5CQ== - -lightningcss-linux-x64-gnu@1.30.1: - version "1.30.1" - resolved "https://registry.yarnpkg.com/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.1.tgz#2fc7096224bc000ebb97eea94aea248c5b0eb157" - integrity sha512-piWx3z4wN8J8z3+O5kO74+yr6ze/dKmPnI7vLqfSqI8bccaTGY5xiSGVIJBDd5K5BHlvVLpUB3S2YCfelyJ1bw== - -lightningcss-linux-x64-musl@1.30.1: - version "1.30.1" - resolved "https://registry.yarnpkg.com/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.1.tgz#66dca2b159fd819ea832c44895d07e5b31d75f26" - integrity sha512-rRomAK7eIkL+tHY0YPxbc5Dra2gXlI63HL+v1Pdi1a3sC+tJTcFrHX+E86sulgAXeI7rSzDYhPSeHHjqFhqfeQ== - -lightningcss-win32-arm64-msvc@1.30.1: - version "1.30.1" - resolved "https://registry.yarnpkg.com/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.1.tgz#7d8110a19d7c2d22bfdf2f2bb8be68e7d1b69039" - integrity sha512-mSL4rqPi4iXq5YVqzSsJgMVFENoa4nGTT/GjO2c0Yl9OuQfPsIfncvLrEW6RbbB24WtZ3xP/2CCmI3tNkNV4oA== - -lightningcss-win32-x64-msvc@1.30.1: - version "1.30.1" - resolved "https://registry.yarnpkg.com/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.1.tgz#fd7dd008ea98494b85d24b4bea016793f2e0e352" - integrity sha512-PVqXh48wh4T53F/1CCu8PIPCxLzWyCnn/9T5W1Jpmdy5h9Cwd+0YQS6/LwhHXSafuc61/xg9Lv5OrCby6a++jg== - -lightningcss@1.30.1: - version "1.30.1" - resolved "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.1.tgz" - integrity sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg== +lightningcss@^1.21.0, lightningcss@1.30.2: + version "1.30.2" + resolved "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz" + integrity sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ== dependencies: detect-libc "^2.0.3" optionalDependencies: - lightningcss-darwin-arm64 "1.30.1" - lightningcss-darwin-x64 "1.30.1" - lightningcss-freebsd-x64 "1.30.1" - lightningcss-linux-arm-gnueabihf "1.30.1" - lightningcss-linux-arm64-gnu "1.30.1" - lightningcss-linux-arm64-musl "1.30.1" - lightningcss-linux-x64-gnu "1.30.1" - lightningcss-linux-x64-musl "1.30.1" - lightningcss-win32-arm64-msvc "1.30.1" - lightningcss-win32-x64-msvc "1.30.1" + lightningcss-android-arm64 "1.30.2" + lightningcss-darwin-arm64 "1.30.2" + lightningcss-darwin-x64 "1.30.2" + lightningcss-freebsd-x64 "1.30.2" + lightningcss-linux-arm-gnueabihf "1.30.2" + lightningcss-linux-arm64-gnu "1.30.2" + lightningcss-linux-arm64-musl "1.30.2" + lightningcss-linux-x64-gnu "1.30.2" + lightningcss-linux-x64-musl "1.30.2" + lightningcss-win32-arm64-msvc "1.30.2" + lightningcss-win32-x64-msvc "1.30.2" lines-and-columns@^1.1.6: version "1.2.4" @@ -2592,21 +2404,16 @@ locate-path@^6.0.0: dependencies: p-locate "^5.0.0" -lodash.castarray@^4.4.0: - version "4.4.0" - resolved "https://registry.npmjs.org/lodash.castarray/-/lodash.castarray-4.4.0.tgz" - integrity sha512-aVx8ztPv7/2ULbArGJ2Y42bG1mEQ5mGjpdvrbJcJFU3TbYybe+QlLS4pst9zV52ymy2in1KpFPiZnAOATxD4+Q== - -lodash.isplainobject@^4.0.6: - version "4.0.6" - resolved "https://registry.npmjs.org/lodash.isplainobject/-/lodash.isplainobject-4.0.6.tgz" - integrity sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA== - lodash.merge@^4.6.2: version "4.6.2" resolved "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz" integrity sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ== +lodash@^4.18.1: + version "4.18.1" + resolved "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz" + integrity sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q== + longest-streak@^3.0.0: version "3.1.0" resolved "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz" @@ -2619,6 +2426,13 @@ loose-envify@^1.4.0: dependencies: js-tokens "^3.0.0 || ^4.0.0" +lower-case@^2.0.2: + version "2.0.2" + resolved "https://registry.npmjs.org/lower-case/-/lower-case-2.0.2.tgz" + integrity sha512-7fm3l3NAF9WfN6W3JOmf5drwpVqX78JtoGJ3A6W0a6ZnldM41w2fV5D490psKFTpMds8TJse/eHLFFsNHHjHgg== + dependencies: + tslib "^2.0.3" + lru-cache@^5.1.1: version "5.1.1" resolved "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz" @@ -2636,12 +2450,12 @@ maath@^0.10.8: resolved "https://registry.npmjs.org/maath/-/maath-0.10.8.tgz" integrity sha512-tRvbDF0Pgqz+9XUa4jjfgAQ8/aPKmQdWXilFu2tMy4GWj4NOsx99HlULO4IeREfbO3a0sA145DZYyvXPkybm0g== -magic-string@^0.30.17: - version "0.30.17" - resolved "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz" - integrity sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA== +magic-string@^0.30.19: + version "0.30.21" + resolved "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz" + integrity sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ== dependencies: - "@jridgewell/sourcemap-codec" "^1.5.0" + "@jridgewell/sourcemap-codec" "^1.5.5" marked@^4.1.0: version "4.3.0" @@ -2768,10 +2582,10 @@ meshline@^3.3.1: resolved "https://registry.npmjs.org/meshline/-/meshline-3.3.1.tgz" integrity sha512-/TQj+JdZkeSUOl5Mk2J7eLcYTLiQm2IDzmlSvYm7ov15anEcDJ92GHqqazxTSreeNgfnYu24kiEvvv0WlbCdFQ== -meshoptimizer@~0.18.1: - version "0.18.1" - resolved "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-0.18.1.tgz" - integrity sha512-ZhoIoL7TNV4s5B6+rx5mC//fw8/POGyNxS/DZyCJeiZ12ScLfVwRE/GfsxwiTkMYYD5DmK2/JXnEVXqL4rF+Sw== +meshoptimizer@~0.22.0: + version "0.22.0" + resolved "https://registry.npmjs.org/meshoptimizer/-/meshoptimizer-0.22.0.tgz" + integrity sha512-IebiK79sqIy+E4EgOr+CAw+Ke8hAspXKzBd0JdgEmPHiAwmvEj2S4h1rfvo+o/BnfEYd/jAOg5IeeIjzlzSnDg== micromark-core-commonmark@^2.0.0: version "2.0.3" @@ -3001,41 +2815,24 @@ minimatch@^9.0.4: dependencies: brace-expansion "^2.0.1" -minipass@^7.0.4, minipass@^7.1.2: - version "7.1.2" - resolved "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz" - integrity sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw== - -minizlib@^3.0.1: - version "3.0.2" - resolved "https://registry.npmjs.org/minizlib/-/minizlib-3.0.2.tgz" - integrity sha512-oG62iEk+CYt5Xj2YqI5Xi9xWUeZhDI8jjQmC5oThVH5JGCTgIjr7ciJDzC7MBzYd//WvR1OTmP5Q38Q8ShQtVA== - dependencies: - minipass "^7.1.2" - -mkdirp@^3.0.1: - version "3.0.1" - resolved "https://registry.npmjs.org/mkdirp/-/mkdirp-3.0.1.tgz" - integrity sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg== - mobx-react-lite@^4.1.0: - version "4.1.0" - resolved "https://registry.npmjs.org/mobx-react-lite/-/mobx-react-lite-4.1.0.tgz" - integrity sha512-QEP10dpHHBeQNv1pks3WnHRCem2Zp636lq54M2nKO2Sarr13pL4u6diQXf65yzXUn0mkk18SyIDCm9UOJYTi1w== + version "4.1.1" + resolved "https://registry.npmjs.org/mobx-react-lite/-/mobx-react-lite-4.1.1.tgz" + integrity sha512-iUxiMpsvNraCKXU+yPotsOncNNmyeS2B5DKL+TL6Tar/xm+wwNJAubJmtRSeAoYawdZqwv8Z/+5nPRHeQxTiXg== dependencies: use-sync-external-store "^1.4.0" -mobx@^6.13.7: - version "6.13.7" - resolved "https://registry.npmjs.org/mobx/-/mobx-6.13.7.tgz" - integrity sha512-aChaVU/DO5aRPmk1GX8L+whocagUUpBQqoPtJk+cm7UOXUk87J4PeWCh6nNmTTIfEhiR9DI/+FnA8dln/hTK7g== +mobx@^6.13.7, mobx@^6.9.0: + version "6.15.0" + resolved "https://registry.npmjs.org/mobx/-/mobx-6.15.0.tgz" + integrity sha512-UczzB+0nnwGotYSgllfARAqWCJ5e/skuV2K/l+Zyck/H6pJIhLXuBnz+6vn2i211o7DtbE78HQtsYEKICHGI+g== ms@^2.1.3: version "2.1.3" resolved "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz" integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== -nanoid@^3.3.8: +nanoid@^3.3.11: version "3.3.11" resolved "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz" integrity sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w== @@ -3045,10 +2842,18 @@ natural-compare@^1.4.0: resolved "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz" integrity sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw== -node-releases@^2.0.19: - version "2.0.19" - resolved "https://registry.npmjs.org/node-releases/-/node-releases-2.0.19.tgz" - integrity sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw== +no-case@^3.0.4: + version "3.0.4" + resolved "https://registry.npmjs.org/no-case/-/no-case-3.0.4.tgz" + integrity sha512-fgAN3jGAh+RoxUGZHTSOLJIqUc2wmoBwGR4tbpNAKmmovFoWq0OdRkb0VkldReO2a2iBT/OEulG9XSUc10r3zg== + dependencies: + lower-case "^2.0.2" + tslib "^2.0.3" + +node-releases@^2.0.26: + version "2.0.26" + resolved "https://registry.npmjs.org/node-releases/-/node-releases-2.0.26.tgz" + integrity sha512-S2M9YimhSjBSvYnlr5/+umAnPHE++ODwt5e2Ij6FoX45HA/s4vHdkDx1eax2pAPeAOqu4s9b7ppahsyEFdVqQA== object-assign@^4.1.1: version "4.1.1" @@ -3056,9 +2861,9 @@ object-assign@^4.1.1: integrity sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg== ol@^10.5.0: - version "10.5.0" - resolved "https://registry.npmjs.org/ol/-/ol-10.5.0.tgz" - integrity sha512-nHFx8gkGmvYImsa7iKkwUnZidd5gn1XbMZd9GNOorvm9orjW9gQvT3Naw/MjIasVJ3cB9EJUdCGR2EFAulMHsQ== + version "10.6.1" + resolved "https://registry.npmjs.org/ol/-/ol-10.6.1.tgz" + integrity sha512-xp174YOwPeLj7c7/8TCIEHQ4d41tgTDDhdv6SqNdySsql5/MaFJEJkjlsYcvOPt7xA6vrum/QG4UdJ0iCGT1cg== dependencies: "@types/rbush" "4.0.0" earcut "^3.0.0" @@ -3066,6 +2871,15 @@ ol@^10.5.0: pbf "4.0.1" rbush "^4.0.0" +open@^8.0.0: + version "8.4.2" + resolved "https://registry.npmjs.org/open/-/open-8.4.2.tgz" + integrity sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ== + dependencies: + define-lazy-prop "^2.0.0" + is-docker "^2.1.1" + is-wsl "^2.2.0" + optionator@^0.9.3: version "0.9.4" resolved "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz" @@ -3078,6 +2892,16 @@ optionator@^0.9.3: type-check "^0.4.0" word-wrap "^1.2.5" +overlayscrollbars-react@^0.5.6: + version "0.5.6" + resolved "https://registry.npmjs.org/overlayscrollbars-react/-/overlayscrollbars-react-0.5.6.tgz" + integrity sha512-E5To04bL5brn9GVCZ36SnfGanxa2I2MDkWoa4Cjo5wol7l+diAgi4DBc983V7l2nOk/OLJ6Feg4kySspQEGDBw== + +overlayscrollbars@^2.0.0, overlayscrollbars@^2.15.1: + version "2.15.1" + resolved "https://registry.npmjs.org/overlayscrollbars/-/overlayscrollbars-2.15.1.tgz" + integrity sha512-glX26JwjL+Tkzv0JNOWdW4VozP5dGXO+Wx8+TPrdTEJTSYT/8eJS8yXM+fewjU0nFq/JeCa+X+BqABNjC4YZSA== + p-limit@^3.0.2: version "3.1.0" resolved "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz" @@ -3122,7 +2946,7 @@ parse-headers@^2.0.2: resolved "https://registry.npmjs.org/parse-headers/-/parse-headers-2.0.6.tgz" integrity sha512-Tz11t3uKztEW5FEVZnj1ox8GKblWn+PvHY9TmJV5Mll2uHEwRdR/5Li1OlXoECjLYkApdhWy44ocONwXLiKO5A== -parse-json@^5.0.0: +parse-json@^5.0.0, parse-json@^5.2.0: version "5.2.0" resolved "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz" integrity sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg== @@ -3189,26 +3013,27 @@ picomatch@^2.3.1: resolved "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz" integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA== -picomatch@^4.0.2: - version "4.0.2" - resolved "https://registry.npmjs.org/picomatch/-/picomatch-4.0.2.tgz" - integrity sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg== +"picomatch@^3 || ^4", picomatch@^4.0.2, picomatch@^4.0.3: + version "4.0.3" + resolved "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz" + integrity sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q== -pixi.js@^8.10.1: - version "8.11.0" - resolved "https://registry.npmjs.org/pixi.js/-/pixi.js-8.11.0.tgz" - integrity sha512-dyuThzncsgEgJZnvd/A/5x6IkUERbK+phXqUQrI+0C6WE+8xqGH5VChRTLecemhgZF0kQ+gZOM3tJTX9937xpg== +pixi.js@^8.10.1, pixi.js@^8.2.6: + version "8.14.0" + resolved "https://registry.npmjs.org/pixi.js/-/pixi.js-8.14.0.tgz" + integrity sha512-ituDiEBb1Oqx56RYwTtC6MjPUhPfF/i15fpUv5oEqmzC/ce3SaSumulJcOjKG7+y0J0Ekl9Rl4XTxaUw+MVFZw== dependencies: "@pixi/colord" "^2.9.6" "@types/css-font-loading-module" "^0.0.12" "@types/earcut" "^3.0.0" "@webgpu/types" "^0.1.40" "@xmldom/xmldom" "^0.8.10" - earcut "^3.0.1" + earcut "^3.0.2" eventemitter3 "^5.0.1" gifuct-js "^2.1.2" ismobilejs "^1.1.1" parse-svg-path "^0.1.2" + tiny-lru "^11.4.5" postcss-selector-parser@6.0.10: version "6.0.10" @@ -3219,11 +3044,11 @@ postcss-selector-parser@6.0.10: util-deprecate "^1.0.2" postcss@^8.5.3: - version "8.5.3" - resolved "https://registry.npmjs.org/postcss/-/postcss-8.5.3.tgz" - integrity sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A== + version "8.5.6" + resolved "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz" + integrity sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg== dependencies: - nanoid "^3.3.8" + nanoid "^3.3.11" picocolors "^1.1.1" source-map-js "^1.2.1" @@ -3250,7 +3075,7 @@ promise-worker-transferable@^1.0.4: is-promise "^2.1.0" lie "^3.0.2" -prop-types@^15.6.2, prop-types@^15.8.1: +prop-types@^15.5.4, prop-types@^15.6.2, prop-types@^15.8.1: version "15.8.1" resolved "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz" integrity sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg== @@ -3311,36 +3136,27 @@ rbush@^4.0.0: dependencies: quickselect "^3.0.0" -react-colorful@^5.6.1: - version "5.6.1" - resolved "https://registry.npmjs.org/react-colorful/-/react-colorful-5.6.1.tgz" - integrity sha512-1exovf0uGTGyq5mXQT0zgQ80uvj2PCwvF8zY1RN9/vbJVSjSo3fsB/4L3ObbF7u70NduSiK4xu4Y6q1MHoUGEw== - -react-dom@^19.1.0: - version "19.1.0" - resolved "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz" - integrity sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g== +"react-dom@^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom@^18 || ^19", "react-dom@^18.0.0 || ^19.0.0", react-dom@^19, react-dom@^19.0.0, react-dom@^19.1.0, react-dom@>=16.0.0, react-dom@>=16.13, react-dom@>=16.6.0, react-dom@>=16.8.2, react-dom@>=18: + version "19.2.0" + resolved "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz" + integrity sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ== dependencies: - scheduler "^0.26.0" + scheduler "^0.27.0" -react-dropzone@^14.3.8: - version "14.3.8" - resolved "https://registry.npmjs.org/react-dropzone/-/react-dropzone-14.3.8.tgz" - integrity sha512-sBgODnq+lcA4P296DY4wacOZz3JFpD99fp+hb//iBO2HHnyeZU3FwWyXJ6salNpqQdsZrgMrotuko/BdJMV8Ug== - dependencies: - attr-accept "^2.2.4" - file-selector "^2.1.0" - prop-types "^15.8.1" - -react-is@^16.13.1, react-is@^16.7.0: +react-is@^16.13.1: version "16.13.1" resolved "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz" integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ== -react-is@^19.1.0: - version "19.1.0" - resolved "https://registry.npmjs.org/react-is/-/react-is-19.1.0.tgz" - integrity sha512-Oe56aUPnkHyyDxxkvqtd7KkdQP5uIUfHxd5XTb3wE9d/kRnZLmKbDB0GWk919tdQ+mxxPtG6EAs6RMT6i1qtHg== +react-is@^16.7.0: + version "16.13.1" + resolved "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz" + integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ== + +react-is@^19.1.1: + version "19.2.0" + resolved "https://registry.npmjs.org/react-is/-/react-is-19.2.0.tgz" + integrity sha512-x3Ax3kNSMIIkyVYhWPyO09bu0uttcAIoecO/um/rKGQ4EltYWVYtyiGkS/3xMynrbVQdS69Jhlv8FXUEZehlzA== react-markdown@^10.1.0: version "10.1.0" @@ -3366,7 +3182,7 @@ react-photo-sphere-viewer@^6.2.3: dependencies: eventemitter3 "^5.0.1" -react-reconciler@0.31.0, react-reconciler@^0.31.0: +react-reconciler@^0.31.0, react-reconciler@0.31.0: version "0.31.0" resolved "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.31.0.tgz" integrity sha512-7Ob7Z+URmesIsIVRjnLoDGwBEG/tVitidU0nMsqX/eeJaLY89RISO/10ERe0MqmzuKUUB1rmY+h1itMbUHg9BQ== @@ -3387,16 +3203,16 @@ react-refresh@^0.17.0: integrity sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ== react-router-dom@^7.6.1: - version "7.6.2" - resolved "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.6.2.tgz" - integrity sha512-Q8zb6VlTbdYKK5JJBLQEN06oTUa/RAbG/oQS1auK1I0TbJOXktqm+QENEVJU6QvWynlXPRBXI3fiOQcSEA78rA== + version "7.9.4" + resolved "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.9.4.tgz" + integrity sha512-f30P6bIkmYvnHHa5Gcu65deIXoA2+r3Eb6PJIAddvsT9aGlchMatJ51GgpU470aSqRRbFX22T70yQNUGuW3DfA== dependencies: - react-router "7.6.2" + react-router "7.9.4" -react-router@7.6.2: - version "7.6.2" - resolved "https://registry.npmjs.org/react-router/-/react-router-7.6.2.tgz" - integrity sha512-U7Nv3y+bMimgWjhlT5CRdzHPu2/KVmqPwKUCChW8en5P3znxUqwlYFlbmyj8Rgp1SF6zs5X4+77kBVknkg6a0w== +react-router@^7.9.4, react-router@7.9.4: + version "7.9.4" + resolved "https://registry.npmjs.org/react-router/-/react-router-7.9.4.tgz" + integrity sha512-SD3G8HKviFHg9xj7dNODUKDFgpG4xqD5nhyd0mYoB5iISepuZAvzSr8ywxgxKJ52yRzf/HWtVHc9AWwoTbljvA== dependencies: cookie "^1.0.1" set-cookie-parser "^2.6.0" @@ -3430,12 +3246,12 @@ react-use-measure@^2.1.7: resolved "https://registry.npmjs.org/react-use-measure/-/react-use-measure-2.1.7.tgz" integrity sha512-KrvcAo13I/60HpwGO5jpW7E9DfusKyLPLvuHlUyP5zqnmAPhNc6qTRjUQrdTADl0lpPpDVU2/Gg51UlOGHXbdg== -react@^19.1.0: - version "19.1.0" - resolved "https://registry.npmjs.org/react/-/react-19.1.0.tgz" - integrity sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg== +"react@^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react@^16.8.0 || ^17 || ^18 || ^19", "react@^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react@^17.0.0 || ^18.0.0 || ^19.0.0", "react@^18 || ^19", "react@^18.0 || ^19", "react@^18.0.0 || ^19.0.0", react@^19, react@^19.0.0, react@^19.1.0, react@^19.2.0, "react@>= 16.8.0", react@>=16.0.0, react@>=16.13, react@>=16.6.0, react@>=16.8, react@>=16.8.0, react@>=16.8.2, react@>=17.0, react@>=18, react@>=18.0.0, react@>=19.0.0: + version "19.2.0" + resolved "https://registry.npmjs.org/react/-/react-19.2.0.tgz" + integrity sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ== -redux@^5.0.1: +redux@^5.0.0, redux@^5.0.1: version "5.0.1" resolved "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz" integrity sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w== @@ -3470,6 +3286,11 @@ remark-rehype@^11.0.0: unified "^11.0.0" vfile "^6.0.0" +require-directory@^2.1.1: + 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== + require-from-string@^2.0.2: version "2.0.2" resolved "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz" @@ -3493,11 +3314,11 @@ resolve-protobuf-schema@^2.1.0: protocol-buffers-schema "^3.3.1" resolve@^1.19.0: - version "1.22.10" - resolved "https://registry.npmjs.org/resolve/-/resolve-1.22.10.tgz" - integrity sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w== + version "1.22.11" + resolved "https://registry.npmjs.org/resolve/-/resolve-1.22.11.tgz" + integrity sha512-RfqAvLnMl313r7c9oclB1HhUEAezcpLjz95wFH4LVuhk9JF/r22qmVP9AMmOU4vMX7Q8pN8jwNg/CSpdFnMjTQ== dependencies: - is-core-module "^2.16.0" + is-core-module "^2.16.1" path-parse "^1.0.7" supports-preserve-symlinks-flag "^1.0.0" @@ -3506,33 +3327,45 @@ reusify@^1.0.4: resolved "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz" integrity sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw== -rollup@^4.34.9: - version "4.41.1" - resolved "https://registry.npmjs.org/rollup/-/rollup-4.41.1.tgz" - integrity sha512-cPmwD3FnFv8rKMBc1MxWCwVQFxwf1JEmSX3iQXrRVVG15zerAIXRjMFVWnd5Q5QvgKF7Aj+5ykXFhUl+QGnyOw== +rollup-plugin-visualizer@^6.0.5: + version "6.0.5" + resolved "https://registry.npmjs.org/rollup-plugin-visualizer/-/rollup-plugin-visualizer-6.0.5.tgz" + integrity sha512-9+HlNgKCVbJDs8tVtjQ43US12eqaiHyyiLMdBwQ7vSZPiHMysGNo2E88TAp1si5wx8NAoYriI2A5kuKfIakmJg== dependencies: - "@types/estree" "1.0.7" + open "^8.0.0" + picomatch "^4.0.2" + source-map "^0.7.4" + yargs "^17.5.1" + +rollup@^1.20.0||^2.0.0||^3.0.0||^4.0.0, rollup@^4.34.9, "rollup@2.x || 3.x || 4.x": + version "4.52.5" + resolved "https://registry.npmjs.org/rollup/-/rollup-4.52.5.tgz" + integrity sha512-3GuObel8h7Kqdjt0gxkEzaifHTqLVW56Y/bjN7PSQtkKr0w3V/QYSdt6QWYtd7A1xUtYQigtdUfgj1RvWVtorw== + dependencies: + "@types/estree" "1.0.8" optionalDependencies: - "@rollup/rollup-android-arm-eabi" "4.41.1" - "@rollup/rollup-android-arm64" "4.41.1" - "@rollup/rollup-darwin-arm64" "4.41.1" - "@rollup/rollup-darwin-x64" "4.41.1" - "@rollup/rollup-freebsd-arm64" "4.41.1" - "@rollup/rollup-freebsd-x64" "4.41.1" - "@rollup/rollup-linux-arm-gnueabihf" "4.41.1" - "@rollup/rollup-linux-arm-musleabihf" "4.41.1" - "@rollup/rollup-linux-arm64-gnu" "4.41.1" - "@rollup/rollup-linux-arm64-musl" "4.41.1" - "@rollup/rollup-linux-loongarch64-gnu" "4.41.1" - "@rollup/rollup-linux-powerpc64le-gnu" "4.41.1" - "@rollup/rollup-linux-riscv64-gnu" "4.41.1" - "@rollup/rollup-linux-riscv64-musl" "4.41.1" - "@rollup/rollup-linux-s390x-gnu" "4.41.1" - "@rollup/rollup-linux-x64-gnu" "4.41.1" - "@rollup/rollup-linux-x64-musl" "4.41.1" - "@rollup/rollup-win32-arm64-msvc" "4.41.1" - "@rollup/rollup-win32-ia32-msvc" "4.41.1" - "@rollup/rollup-win32-x64-msvc" "4.41.1" + "@rollup/rollup-android-arm-eabi" "4.52.5" + "@rollup/rollup-android-arm64" "4.52.5" + "@rollup/rollup-darwin-arm64" "4.52.5" + "@rollup/rollup-darwin-x64" "4.52.5" + "@rollup/rollup-freebsd-arm64" "4.52.5" + "@rollup/rollup-freebsd-x64" "4.52.5" + "@rollup/rollup-linux-arm-gnueabihf" "4.52.5" + "@rollup/rollup-linux-arm-musleabihf" "4.52.5" + "@rollup/rollup-linux-arm64-gnu" "4.52.5" + "@rollup/rollup-linux-arm64-musl" "4.52.5" + "@rollup/rollup-linux-loong64-gnu" "4.52.5" + "@rollup/rollup-linux-ppc64-gnu" "4.52.5" + "@rollup/rollup-linux-riscv64-gnu" "4.52.5" + "@rollup/rollup-linux-riscv64-musl" "4.52.5" + "@rollup/rollup-linux-s390x-gnu" "4.52.5" + "@rollup/rollup-linux-x64-gnu" "4.52.5" + "@rollup/rollup-linux-x64-musl" "4.52.5" + "@rollup/rollup-openharmony-arm64" "4.52.5" + "@rollup/rollup-win32-arm64-msvc" "4.52.5" + "@rollup/rollup-win32-ia32-msvc" "4.52.5" + "@rollup/rollup-win32-x64-gnu" "4.52.5" + "@rollup/rollup-win32-x64-msvc" "4.52.5" fsevents "~2.3.2" run-parallel@^1.1.9: @@ -3547,10 +3380,10 @@ scheduler@^0.25.0: resolved "https://registry.npmjs.org/scheduler/-/scheduler-0.25.0.tgz" integrity sha512-xFVuu11jh+xcO7JOAGJNOXld8/TcEHK/4CituBUeUb5hqxJLj9YuemAEuvm9gQ/+pgXYfbQuqAkiYu+u7YEsNA== -scheduler@^0.26.0: - version "0.26.0" - resolved "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz" - integrity sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA== +scheduler@^0.27.0: + version "0.27.0" + resolved "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz" + integrity sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q== semver@^6.3.1: version "6.3.1" @@ -3558,14 +3391,14 @@ semver@^6.3.1: integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA== semver@^7.6.0: - version "7.7.2" - resolved "https://registry.npmjs.org/semver/-/semver-7.7.2.tgz" - integrity sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA== + version "7.7.3" + resolved "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz" + integrity sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q== set-cookie-parser@^2.6.0: - version "2.7.1" - resolved "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.1.tgz" - integrity sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ== + version "2.7.2" + resolved "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz" + integrity sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw== shebang-command@^2.0.0: version "2.0.0" @@ -3579,6 +3412,14 @@ shebang-regex@^3.0.0: resolved "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz" integrity sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A== +snake-case@^3.0.4: + version "3.0.4" + resolved "https://registry.npmjs.org/snake-case/-/snake-case-3.0.4.tgz" + integrity sha512-LAOh4z89bGQvl9pFfNF8V146i7o7/CqFPbqzYgP+yYzDIDeS9HaNFtXABamRW+AQzEVODcvE79ljJ+8a9YSdMg== + dependencies: + dot-case "^3.0.4" + tslib "^2.0.3" + source-map-js@^1.2.1: version "1.2.1" resolved "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz" @@ -3589,6 +3430,11 @@ source-map@^0.5.7: resolved "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz" integrity sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ== +source-map@^0.7.4: + version "0.7.6" + resolved "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz" + integrity sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ== + space-separated-tokens@^2.0.0: version "2.0.2" resolved "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz" @@ -3607,6 +3453,15 @@ stats.js@^0.17.0: resolved "https://registry.npmjs.org/stats.js/-/stats.js-0.17.0.tgz" integrity sha512-hNKz8phvYLPEcRkeG1rsGmV5ChMjKDAWU7/OJJdDErPBNChQXxCo3WZurGpnWc6gZhAzEPFad1aVgyOANH1sMw== +string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: + version "4.2.3" + resolved "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz" + integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g== + dependencies: + emoji-regex "^8.0.0" + is-fullwidth-code-point "^3.0.0" + strip-ansi "^6.0.1" + stringify-entities@^4.0.0: version "4.0.4" resolved "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz" @@ -3615,22 +3470,29 @@ stringify-entities@^4.0.0: character-entities-html4 "^2.0.0" character-entities-legacy "^3.0.0" +strip-ansi@^6.0.0, strip-ansi@^6.0.1: + version "6.0.1" + resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz" + integrity sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A== + dependencies: + ansi-regex "^5.0.1" + strip-json-comments@^3.1.1: version "3.1.1" resolved "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz" integrity sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig== style-to-js@^1.0.0: - version "1.1.16" - resolved "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.16.tgz" - integrity sha512-/Q6ld50hKYPH3d/r6nr117TZkHR0w0kGGIVfpG9N6D8NymRPM9RqCUv4pRpJ62E5DqOYx2AFpbZMyCPnjQCnOw== + version "1.1.18" + resolved "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.18.tgz" + integrity sha512-JFPn62D4kJaPTnhFUI244MThx+FEGbi+9dw1b9yBBQ+1CZpV7QAT8kUtJ7b7EUNdHajjF/0x8fT+16oLJoojLg== dependencies: - style-to-object "1.0.8" + style-to-object "1.0.11" -style-to-object@1.0.8: - version "1.0.8" - resolved "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.8.tgz" - integrity sha512-xT47I/Eo0rwJmaXC4oilDGDWLohVhR6o/xAQcPQN8q6QBuZVL8qMYL85kLmST5cPjAorwvqIA4qXTRQoYHaL6g== +style-to-object@1.0.11: + version "1.0.11" + resolved "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.11.tgz" + integrity sha512-5A560JmXr7wDyGLK12Nq/EYS38VkGlglVzkis1JEdbGWSnbQIEhZzTJhzURXN5/8WwwFCs/f/VVcmkTppbXLow== dependencies: inline-style-parser "0.2.4" @@ -3656,27 +3518,20 @@ suspend-react@^0.1.3: resolved "https://registry.npmjs.org/suspend-react/-/suspend-react-0.1.3.tgz" integrity sha512-aqldKgX9aZqpoDp3e8/BZ8Dm7x1pJl+qI3ZKxDN0i/IQTWUwBx/ManmlVJ3wowqbno6c2bmiIfs+Um6LbsjJyQ== -tailwindcss@4.1.8, tailwindcss@^4.1.8: - version "4.1.8" - resolved "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.8.tgz" - integrity sha512-kjeW8gjdxasbmFKpVGrGd5T4i40mV5J2Rasw48QARfYeQ8YS9x02ON9SFWax3Qf616rt4Cp3nVNIj6Hd1mP3og== +svg-parser@^2.0.4: + version "2.0.4" + resolved "https://registry.npmjs.org/svg-parser/-/svg-parser-2.0.4.tgz" + integrity sha512-e4hG1hRwoOdRb37cIMSgzNsxyzKfayW6VOflrwvR+/bzrkyxY/31WkbgnQpgtrNp1SdpJvpUAGTa/ZoiPNDuRQ== + +tailwindcss@^4.1.8, "tailwindcss@>=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1", tailwindcss@4.1.16: + version "4.1.16" + resolved "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.16.tgz" + integrity sha512-pONL5awpaQX4LN5eiv7moSiSPd/DLDzKVRJz8Q9PgzmAdd1R4307GQS2ZpfiN7ZmekdQrfhZZiSE5jkLR4WNaA== tapable@^2.2.0: - version "2.2.2" - resolved "https://registry.npmjs.org/tapable/-/tapable-2.2.2.tgz" - integrity sha512-Re10+NauLTMCudc7T5WLFLAwDhQ0JWdrMK+9B2M8zR5hRExKmsRDCBA7/aV/pNJFltmBFO5BAMlQFi/vq3nKOg== - -tar@^7.4.3: - version "7.4.3" - resolved "https://registry.npmjs.org/tar/-/tar-7.4.3.tgz" - integrity sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw== - dependencies: - "@isaacs/fs-minipass" "^4.0.0" - chownr "^3.0.0" - minipass "^7.1.2" - minizlib "^3.0.1" - mkdirp "^3.0.1" - yallist "^5.0.0" + version "2.3.0" + resolved "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz" + integrity sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg== three-mesh-bvh@^0.8.3: version "0.8.3" @@ -3700,28 +3555,33 @@ three@^0.170.0: resolved "https://registry.npmjs.org/three/-/three-0.170.0.tgz" integrity sha512-FQK+LEpYc0fBD+J8g6oSEyyNzjp+Q7Ks1C568WWaoMRLW+TkNNWmenWeGgJjV105Gd+p/2ql1ZcjYvNiPZBhuQ== -three@^0.175.0: - version "0.175.0" - resolved "https://registry.npmjs.org/three/-/three-0.175.0.tgz" - integrity sha512-nNE3pnTHxXN/Phw768u0Grr7W4+rumGg/H6PgeseNJojkJtmeHJfZWi41Gp2mpXl1pg1pf1zjwR4McM1jTqkpg== - -three@^0.177.0: +three@^0.177.0, "three@>= 0.159.0", three@>=0.125.0, three@>=0.126.1, three@>=0.128.0, three@>=0.134.0, three@>=0.137, three@>=0.156, three@>=0.159: version "0.177.0" resolved "https://registry.npmjs.org/three/-/three-0.177.0.tgz" integrity sha512-EiXv5/qWAaGI+Vz2A+JfavwYCMdGjxVsrn3oBwllUoqYeaBO75J63ZfyaQKoiLrqNHoTlUc6PFgMXnS0kI45zg== +three@^0.179.0: + version "0.179.1" + resolved "https://registry.npmjs.org/three/-/three-0.179.1.tgz" + integrity sha512-5y/elSIQbrvKOISxpwXCR4sQqHtGiOI+MKLc3SsBdDXA2hz3Mdp3X59aUp8DyybMa34aeBwbFTpdoLJaUDEWSw== + tiny-invariant@^1.0.6: version "1.3.3" resolved "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz" integrity sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg== +tiny-lru@^11.4.5: + version "11.4.5" + resolved "https://registry.npmjs.org/tiny-lru/-/tiny-lru-11.4.5.tgz" + integrity sha512-hkcz3FjNJfKXjV4mjQ1OrXSLAehg8Hw+cEZclOVT+5c/cWQWImQ9wolzTjth+dmmDe++p3bme3fTxz6Q4Etsqw== + tinyglobby@^0.2.13: - version "0.2.14" - resolved "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.14.tgz" - integrity sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ== + version "0.2.15" + resolved "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz" + integrity sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ== dependencies: - fdir "^6.4.4" - picomatch "^4.0.2" + fdir "^6.5.0" + picomatch "^4.0.3" to-regex-range@^5.0.1: version "5.0.1" @@ -3765,9 +3625,9 @@ ts-api-utils@^2.1.0: resolved "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz" integrity sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ== -tslib@^2.4.0, tslib@^2.7.0, tslib@^2.8.0: +tslib@^2.0.3: version "2.8.1" - resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f" + resolved "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz" integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w== tunnel-rat@^0.1.2: @@ -3785,23 +3645,24 @@ type-check@^0.4.0, type-check@~0.4.0: prelude-ls "^1.2.1" typescript-eslint@^8.30.1: - version "8.33.0" - resolved "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.33.0.tgz" - integrity sha512-5YmNhF24ylCsvdNW2oJwMzTbaeO4bg90KeGtMjUw0AGtHksgEPLRTUil+coHwCfiu4QjVJFnjp94DmU6zV7DhQ== + version "8.46.2" + resolved "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.46.2.tgz" + integrity sha512-vbw8bOmiuYNdzzV3lsiWv6sRwjyuKJMQqWulBOU7M0RrxedXledX8G8kBbQeiOYDnTfiXz0Y4081E1QMNB6iQg== dependencies: - "@typescript-eslint/eslint-plugin" "8.33.0" - "@typescript-eslint/parser" "8.33.0" - "@typescript-eslint/utils" "8.33.0" + "@typescript-eslint/eslint-plugin" "8.46.2" + "@typescript-eslint/parser" "8.46.2" + "@typescript-eslint/typescript-estree" "8.46.2" + "@typescript-eslint/utils" "8.46.2" -typescript@~5.8.3: +typescript@>=4.8.4, "typescript@>=4.8.4 <6.0.0", typescript@>=4.9.5, typescript@~5.8.3: version "5.8.3" resolved "https://registry.npmjs.org/typescript/-/typescript-5.8.3.tgz" integrity sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ== typo-js@*: - version "1.2.5" - resolved "https://registry.npmjs.org/typo-js/-/typo-js-1.2.5.tgz" - integrity sha512-F45vFWdGX8xahIk/sOp79z2NJs8ETMYsmMChm9D5Hlx3+9j7VnCyQyvij5MOCrNY3NNe8noSyokRjQRfq+Bc7A== + version "1.3.1" + resolved "https://registry.npmjs.org/typo-js/-/typo-js-1.3.1.tgz" + integrity sha512-elJkpCL6Z77Ghw0Lv0lGnhBAjSTOQ5FhiVOCfOuxhaoTT2xtLVbqikYItK5HHchzPbHEUFAcjOH669T2ZzeCbg== undici-types@~6.21.0: version "6.21.0" @@ -3822,9 +3683,9 @@ unified@^11.0.0: vfile "^6.0.0" unist-util-is@^6.0.0: - version "6.0.0" - resolved "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.0.tgz" - integrity sha512-2qCTHimwdxLfz+YzdGfkqNlH0tLi9xjTnHddPmJwtIG9MGsdbutfTc4P+haPD7l7Cjxf/WZj+we5qfVPvvxfYw== + version "6.0.1" + resolved "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz" + integrity sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g== dependencies: "@types/unist" "^3.0.0" @@ -3843,9 +3704,9 @@ unist-util-stringify-position@^4.0.0: "@types/unist" "^3.0.0" unist-util-visit-parents@^6.0.0: - version "6.0.1" - resolved "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.1.tgz" - integrity sha512-L/PqWzfTP9lzzEa6CKs0k2nARxTdZduw3zyh8d2NVBnsyvHjSX4TWse388YrrQKbvI8w20fGjGlhgT96WwKykw== + version "6.0.2" + resolved "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz" + integrity sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ== dependencies: "@types/unist" "^3.0.0" unist-util-is "^6.0.0" @@ -3859,10 +3720,10 @@ unist-util-visit@^5.0.0: unist-util-is "^6.0.0" unist-util-visit-parents "^6.0.0" -update-browserslist-db@^1.1.3: - version "1.1.3" - resolved "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.3.tgz" - integrity sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw== +update-browserslist-db@^1.1.4: + version "1.1.4" + resolved "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.4.tgz" + integrity sha512-q0SPT4xyU84saUX+tomz1WLkxUbuaJnR1xWt17M7fJtEJigJeWUNGUqrauFXsHnqev9y9JTRGwk13tFBuKby4A== dependencies: escalade "^3.2.0" picocolors "^1.1.1" @@ -3874,10 +3735,10 @@ uri-js@^4.2.2: dependencies: punycode "^2.1.0" -use-sync-external-store@^1.2.2, use-sync-external-store@^1.4.0, use-sync-external-store@^1.5.0: - version "1.5.0" - resolved "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.5.0.tgz" - integrity sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A== +use-sync-external-store@^1.2.2, use-sync-external-store@^1.4.0, use-sync-external-store@^1.6.0, use-sync-external-store@>=1.2.0: + version "1.6.0" + resolved "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz" + integrity sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w== util-deprecate@^1.0.2: version "1.0.2" @@ -3896,6 +3757,11 @@ utility-types@^3.11.0: resolved "https://registry.npmjs.org/utility-types/-/utility-types-3.11.0.tgz" integrity sha512-6Z7Ma2aVEWisaL6TvBCy7P8rm2LQoPv6dJ7ecIaIixHcwfbJ0x7mWdbcwlIM5IGQxPZSFYeqRCqlOOeKoJYMkw== +uuid@^13.0.0: + version "13.0.0" + resolved "https://registry.npmjs.org/uuid/-/uuid-13.0.0.tgz" + integrity sha512-XQegIaBTVUjSHliKqcnFqYypAd4S+WCYt5NIeRs6w/UAry7z8Y9j5ZwRRL4kzq9U3sD6v+85er9FvkEaBpji2w== + vfile-location@^5.0.0: version "5.0.3" resolved "https://registry.npmjs.org/vfile-location/-/vfile-location-5.0.3.tgz" @@ -3905,9 +3771,9 @@ vfile-location@^5.0.0: vfile "^6.0.0" vfile-message@^4.0.0: - version "4.0.2" - resolved "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.2.tgz" - integrity sha512-jRDZ1IMLttGj41KcZvlrYAaI3CfqpLpfpf+Mfig13viT6NKvRzWZ+lXz0Y5D60w6uJIBAOGq9mSHf0gktF0duw== + version "4.0.3" + resolved "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz" + integrity sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw== dependencies: "@types/unist" "^3.0.0" unist-util-stringify-position "^4.0.0" @@ -3920,10 +3786,19 @@ vfile@^6.0.0: "@types/unist" "^3.0.0" vfile-message "^4.0.0" -vite@^6.3.5: - version "6.3.5" - resolved "https://registry.npmjs.org/vite/-/vite-6.3.5.tgz" - integrity sha512-cZn6NDFE7wdTpINgs++ZJ4N49W2vRp8LCKrn3Ob1kYNtOo21vfDoaV5GzBfLU4MovSAB8uNRm4jgzVQZ+mBzPQ== +vite-plugin-svgr@^4.5.0: + version "4.5.0" + resolved "https://registry.npmjs.org/vite-plugin-svgr/-/vite-plugin-svgr-4.5.0.tgz" + integrity sha512-W+uoSpmVkSmNOGPSsDCWVW/DDAyv+9fap9AZXBvWiQqrboJ08j2vh0tFxTD/LjwqwAd3yYSVJgm54S/1GhbdnA== + dependencies: + "@rollup/pluginutils" "^5.2.0" + "@svgr/core" "^8.1.0" + "@svgr/plugin-jsx" "^8.1.0" + +"vite@^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0", "vite@^5.2.0 || ^6 || ^7", vite@^6.3.5, vite@>=2.6.0: + version "6.4.1" + resolved "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz" + integrity sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g== dependencies: esbuild "^0.25.0" fdir "^6.4.4" @@ -3966,26 +3841,53 @@ word-wrap@^1.2.5: resolved "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz" integrity sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA== +wrap-ansi@^7.0.0: + version "7.0.0" + resolved "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz" + integrity sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q== + dependencies: + ansi-styles "^4.0.0" + string-width "^4.1.0" + strip-ansi "^6.0.0" + xml-utils@^1.0.2: version "1.10.2" resolved "https://registry.npmjs.org/xml-utils/-/xml-utils-1.10.2.tgz" integrity sha512-RqM+2o1RYs6T8+3DzDSoTRAUfrvaejbVHcp3+thnAtDKo8LskR+HomLajEy5UjTz24rpka7AxVBRR3g2wTUkJA== +y18n@^5.0.5: + version "5.0.8" + resolved "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz" + integrity sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA== + yallist@^3.0.2: version "3.1.1" resolved "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz" integrity sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g== -yallist@^5.0.0: - version "5.0.0" - resolved "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz" - integrity sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw== - yaml@^1.10.0: version "1.10.2" resolved "https://registry.npmjs.org/yaml/-/yaml-1.10.2.tgz" integrity sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg== +yargs-parser@^21.1.1: + version "21.1.1" + resolved "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz" + integrity sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw== + +yargs@^17.5.1: + version "17.7.2" + resolved "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz" + integrity sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w== + dependencies: + cliui "^8.0.1" + escalade "^3.1.1" + get-caller-file "^2.0.5" + require-directory "^2.1.1" + string-width "^4.2.3" + y18n "^5.0.5" + yargs-parser "^21.1.1" + yocto-queue@^0.1.0: version "0.1.0" resolved "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz" @@ -4004,9 +3906,9 @@ zustand@^4.3.2: use-sync-external-store "^1.2.2" zustand@^5.0.1, zustand@^5.0.3: - version "5.0.5" - resolved "https://registry.npmjs.org/zustand/-/zustand-5.0.5.tgz" - integrity sha512-mILtRfKW9xM47hqxGIxCv12gXusoY/xTSHBYApXozR0HmQv299whhBeeAcRy+KrPPybzosvJBCOmVjq6x12fCg== + version "5.0.8" + resolved "https://registry.npmjs.org/zustand/-/zustand-5.0.8.tgz" + integrity sha512-gyPKpIaxY9XcO2vSMrLbiER7QMAMGOQZVRdJ6Zi782jkbzZygq5GI9nG8g+sMgitRtndwaBSl7uiqC49o1SSiw== zwitch@^2.0.0: version "2.0.4"