diff --git a/client/.env b/client/.env index 101d1fc..809acb9 100644 --- a/client/.env +++ b/client/.env @@ -1,2 +1,4 @@ # VITE_API_URL=http://192.168.1.23:3000 -VITE_API_URL=http://192.168.1.224:3000 \ No newline at end of file +# VITE_API_URL=http://192.168.1.224:3000 +VITE_API_URL=http://localhost:3000 +VITE_WEBRTC_URL=http://localhost:3001 \ No newline at end of file diff --git a/client/package-lock.json b/client/package-lock.json index daf656a..79d02fd 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -8,19 +8,26 @@ "name": "client", "version": "0.0.0", "dependencies": { + "@epicgames-ps/lib-pixelstreamingfrontend-ue5.7": "^0.1.1", "@tanstack/react-query": "^5.90.2", + "@uidotdev/usehooks": "^2.4.1", "clsx": "^2.1.1", "ky": "^1.11.0", + "motion": "^12.23.24", "react": "^19.1.1", "react-dom": "^19.1.1", + "react-qr-code": "^2.0.18", "react-router": "^7.9.3", + "socket.io-client": "^4.8.1", + "uuid": "^13.0.0", "zustand": "^5.0.8" }, "devDependencies": { "@eslint/js": "^9.36.0", "@types/node": "^24.6.0", - "@types/react": "^19.2.2", + "@types/react": "^19.1.16", "@types/react-dom": "^19.1.9", + "@types/uuid": "^11.0.0", "@vitejs/plugin-react-swc": "^4.1.0", "autoprefixer": "^10.4.21", "eslint": "^9.36.0", @@ -47,6 +54,25 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/@epicgames-ps/lib-pixelstreamingcommon-ue5.7": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/@epicgames-ps/lib-pixelstreamingcommon-ue5.7/-/lib-pixelstreamingcommon-ue5.7-0.1.1.tgz", + "integrity": "sha512-5jOeiLJLFn+gjn0AWZetPM6z+665AIMAxr575hwCTxS9+1n5FxSxcjTVlyP5vEIM9rqYjTYeayMYDYfAIOketA==", + "dependencies": { + "@protobuf-ts/runtime": "^2.9.4", + "@types/ws": "^8.5.14", + "ws": "^8.18.0" + } + }, + "node_modules/@epicgames-ps/lib-pixelstreamingfrontend-ue5.7": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/@epicgames-ps/lib-pixelstreamingfrontend-ue5.7/-/lib-pixelstreamingfrontend-ue5.7-0.1.2.tgz", + "integrity": "sha512-jahQ7k6uzrYD89pRqEJIBWpGuCrGExs3mUBsbstV4Yx9JymMgaVbMY3xbq7kUh9bN6g0007el/2WFoFyMvm58A==", + "dependencies": { + "@epicgames-ps/lib-pixelstreamingcommon-ue5.7": "^0.1.0", + "sdp": "^3.2.0" + } + }, "node_modules/@esbuild/aix-ppc64": { "version": "0.25.10", "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.10.tgz", @@ -804,6 +830,11 @@ "node": ">=14" } }, + "node_modules/@protobuf-ts/runtime": { + "version": "2.11.1", + "resolved": "https://registry.npmjs.org/@protobuf-ts/runtime/-/runtime-2.11.1.tgz", + "integrity": "sha512-KuDaT1IfHkugM2pyz+FwiY80ejWrkH1pAtOBOZFuR6SXEFTsnb/jiQWQ1rCIrcKx2BtyxnxW6BWwsVSA/Ie+WQ==" + }, "node_modules/@rolldown/pluginutils": { "version": "1.0.0-beta.35", "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.35.tgz", @@ -1119,6 +1150,11 @@ "win32" ] }, + "node_modules/@socket.io/component-emitter": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", + "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==" + }, "node_modules/@swc/core": { "version": "1.13.5", "resolved": "https://registry.npmjs.org/@swc/core/-/core-1.13.5.tgz", @@ -1389,7 +1425,6 @@ "version": "24.7.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.7.0.tgz", "integrity": "sha512-IbKooQVqUBrlzWTi79E8Fw78l8k1RNtlDDNWsFZs7XonuQSJ8oNYfEeclhprUldXISRMLzBpILuKgPlIxm+/Yw==", - "dev": true, "license": "MIT", "dependencies": { "undici-types": "~7.14.0" @@ -1415,6 +1450,24 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/uuid": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/@types/uuid/-/uuid-11.0.0.tgz", + "integrity": "sha512-HVyk8nj2m+jcFRNazzqyVKiZezyhDKrGUA3jlEcg/nZ6Ms+qHwocba1Y/AaVaznJTAM9xpdFSh+ptbNrhOGvZA==", + "deprecated": "This is a stub types definition. uuid provides its own type definitions, so you do not need this installed.", + "dev": true, + "dependencies": { + "uuid": "*" + } + }, + "node_modules/@types/ws": { + "version": "8.18.1", + "resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.1.tgz", + "integrity": "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.46.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.46.0.tgz", @@ -1673,6 +1726,18 @@ "url": "https://opencollective.com/typescript-eslint" } }, + "node_modules/@uidotdev/usehooks": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/@uidotdev/usehooks/-/usehooks-2.4.1.tgz", + "integrity": "sha512-1I+RwWyS+kdv3Mv0Vmc+p0dPYH0DTRAo04HLyXReYBL9AeseDWUJyi4THuksBJcu9F0Pih69Ak150VDnqbVnXg==", + "engines": { + "node": ">=16" + }, + "peerDependencies": { + "react": ">=18.0.0", + "react-dom": ">=18.0.0" + } + }, "node_modules/@vitejs/plugin-react-swc": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/@vitejs/plugin-react-swc/-/plugin-react-swc-4.1.0.tgz", @@ -2166,6 +2231,62 @@ "dev": true, "license": "MIT" }, + "node_modules/engine.io-client": { + "version": "6.6.3", + "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.3.tgz", + "integrity": "sha512-T0iLjnyNWahNyv/lcjS2y4oE358tVS/SYQNxYXGAJ9/GLgH4VCvOQ/mhTjqU88mLZCQgiG8RIegFHYCdVC+j5w==", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.3.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.17.1", + "xmlhttprequest-ssl": "~2.1.1" + } + }, + "node_modules/engine.io-client/node_modules/debug": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/engine.io-client/node_modules/ws": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", + "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/engine.io-parser": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz", + "integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/esbuild": { "version": "0.25.10", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.10.tgz", @@ -2565,6 +2686,32 @@ "url": "https://github.com/sponsors/rawify" } }, + "node_modules/framer-motion": { + "version": "12.23.24", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.23.24.tgz", + "integrity": "sha512-HMi5HRoRCTou+3fb3h9oTLyJGBxHfW+HnNE25tAXOvVx/IvwMHK0cx7IR4a2ZU6sh3IX1Z+4ts32PcYBOqka8w==", + "dependencies": { + "motion-dom": "^12.23.23", + "motion-utils": "^12.23.6", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -2835,6 +2982,11 @@ "jiti": "bin/jiti.js" } }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==" + }, "node_modules/js-yaml": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", @@ -2948,6 +3100,17 @@ "dev": true, "license": "MIT" }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, "node_modules/lru-cache": { "version": "10.4.3", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", @@ -3002,11 +3165,48 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/motion": { + "version": "12.23.24", + "resolved": "https://registry.npmjs.org/motion/-/motion-12.23.24.tgz", + "integrity": "sha512-Rc5E7oe2YZ72N//S3QXGzbnXgqNrTESv8KKxABR20q2FLch9gHLo0JLyYo2hZ238bZ9Gx6cWhj9VO0IgwbMjCw==", + "dependencies": { + "framer-motion": "^12.23.24", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, + "node_modules/motion-dom": { + "version": "12.23.23", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.23.23.tgz", + "integrity": "sha512-n5yolOs0TQQBRUFImrRfs/+6X4p3Q4n1dUEqt/H58Vx7OW6RF+foWEgmTVDhIWJIMXOuNNL0apKH2S16en9eiA==", + "dependencies": { + "motion-utils": "^12.23.6" + } + }, + "node_modules/motion-utils": { + "version": "12.23.6", + "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.23.6.tgz", + "integrity": "sha512-eAWoPgr4eFEOFfg2WjIsMoqJTW6Z8MTUCgn/GZ3VRpClWBdnbjryiA3ZSNLyxCTmCQx4RmYX6jX1iWHbenUPNQ==" + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, "node_modules/mz": { @@ -3078,7 +3278,6 @@ "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -3421,6 +3620,16 @@ "node": ">= 0.8.0" } }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -3431,6 +3640,11 @@ "node": ">=6" } }, + "node_modules/qr.js": { + "version": "0.0.0", + "resolved": "https://registry.npmjs.org/qr.js/-/qr.js-0.0.0.tgz", + "integrity": "sha512-c4iYnWb+k2E+vYpRimHqSu575b1/wKl4XFeJGpFmrJQz5I88v9aY2czh7s0w36srfCM1sXgC/xpoJz5dJfq+OQ==" + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -3473,6 +3687,23 @@ "react": "^19.2.0" } }, + "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==" + }, + "node_modules/react-qr-code": { + "version": "2.0.18", + "resolved": "https://registry.npmjs.org/react-qr-code/-/react-qr-code-2.0.18.tgz", + "integrity": "sha512-v1Jqz7urLMhkO6jkgJuBYhnqvXagzceg3qJUWayuCK/c6LTIonpWbwxR1f1APGd4xrW/QcQEovNrAojbUz65Tg==", + "dependencies": { + "prop-types": "^15.8.1", + "qr.js": "0.0.0" + }, + "peerDependencies": { + "react": "*" + } + }, "node_modules/react-router": { "version": "7.9.3", "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.9.3.tgz", @@ -3632,6 +3863,11 @@ "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", "license": "MIT" }, + "node_modules/sdp": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/sdp/-/sdp-3.2.1.tgz", + "integrity": "sha512-lwsAIzOPlH8/7IIjjz3K0zYBk7aBVVcvjMwt3M4fLxpjMYyy7i3I97SLHebgn4YBjirkzfp3RvRDWSKsh/+WFw==" + }, "node_modules/semver": { "version": "7.7.3", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", @@ -3687,6 +3923,64 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/socket.io-client": { + "version": "4.8.1", + "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.1.tgz", + "integrity": "sha512-hJVXfu3E28NmzGk8o1sHhN3om52tRvwYeidbj7xKy2eIIse5IoKX3USlS6Tqt3BHAtflLIkCQBkzVrEEfWUyYQ==", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.3.2", + "engine.io-client": "~6.6.1", + "socket.io-parser": "~4.2.4" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/socket.io-client/node_modules/debug": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/socket.io-parser": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.4.tgz", + "integrity": "sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew==", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.3.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/socket.io-parser/node_modules/debug": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -4005,6 +4299,11 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" + }, "node_modules/type-check": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", @@ -4060,7 +4359,6 @@ "version": "7.14.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.14.0.tgz", "integrity": "sha512-QQiYxHuyZ9gQUIrmPo3IA+hUl4KYk8uSA7cHrcKd/l3p1OTpZcM0Tbp9x7FAtXdAYhlasd60ncPpgu6ihG6TOA==", - "dev": true, "license": "MIT" }, "node_modules/update-browserslist-db": { @@ -4111,6 +4409,18 @@ "dev": true, "license": "MIT" }, + "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" + ], + "bin": { + "uuid": "dist-node/bin/uuid" + } + }, "node_modules/vite": { "version": "7.1.9", "resolved": "https://registry.npmjs.org/vite/-/vite-7.1.9.tgz", @@ -4338,6 +4648,34 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xmlhttprequest-ssl": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz", + "integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/client/package.json b/client/package.json index b2409ef..2d7e007 100644 --- a/client/package.json +++ b/client/package.json @@ -20,6 +20,8 @@ "react-dom": "^19.1.1", "react-qr-code": "^2.0.18", "react-router": "^7.9.3", + "socket.io-client": "^4.8.1", + "uuid": "^13.0.0", "zustand": "^5.0.8" }, "devDependencies": { @@ -27,6 +29,7 @@ "@types/node": "^24.6.0", "@types/react": "^19.1.16", "@types/react-dom": "^19.1.9", + "@types/uuid": "^11.0.0", "@vitejs/plugin-react-swc": "^4.1.0", "autoprefixer": "^10.4.21", "eslint": "^9.36.0", diff --git a/client/src/components/SessionUsersPanel.tsx b/client/src/components/SessionUsersPanel.tsx deleted file mode 100644 index 95b50b3..0000000 --- a/client/src/components/SessionUsersPanel.tsx +++ /dev/null @@ -1,190 +0,0 @@ -import { useState, useRef, useEffect, useCallback } from "react"; -import UserCamera from "./ui/UserCamera"; -import UserDevicesControls from "./ui/UserDevicesControls"; -import clsx from "clsx"; - -const DRAG_THRESHOLD = 15; -const OFFSET = 0.01111; // 1.111vw -const TRANSITION = "all 0.5s cubic-bezier(.63,.08,.37,.89)"; - -export default function SessionUsersPanel() { - const users = [ - { - id: 1, - name: "John Doe", - isSpeaking: true, - isMuted: false, - isVideoOff: false, - isControlDisabled: false, - isAdmin: true, - }, - { - id: 2, - name: "Jane Doe", - isSpeaking: false, - isMuted: true, - isVideoOff: true, - isControlDisabled: true, - }, - { - id: 3, - name: "Jim Doe", - isSpeaking: false, - isMuted: false, - isVideoOff: false, - isControlDisabled: false, - }, - ]; - - const [corner, setCorner] = useState({ top: false, left: false }); - const [dragState, setDragState] = useState<"idle" | "dragging" | "snapping" | "released">("idle"); - const [position, setPosition] = useState({ x: 0, y: 0 }); - - const containerRef = useRef(null); - const dragDataRef = useRef({ offsetX: 0, offsetY: 0, startX: 0, startY: 0, hasStarted: false }); - - const getPointerPos = (e: MouseEvent | TouchEvent | React.MouseEvent | React.TouchEvent) => ({ - x: "touches" in e ? e.touches[0].clientX : e.clientX, - y: "touches" in e ? e.touches[0].clientY : e.clientY, - }); - - const handleMove = useCallback((e: MouseEvent | TouchEvent) => { - if (!containerRef.current) return; - - const pos = getPointerPos(e); - const { startX, startY, offsetX, offsetY, hasStarted } = dragDataRef.current; - - if (!hasStarted) { - const distance = Math.hypot(pos.x - startX, pos.y - startY); - if (distance < DRAG_THRESHOLD) return; - - dragDataRef.current.hasStarted = true; - setDragState("dragging"); - } - - setPosition({ x: pos.x - offsetX, y: pos.y - offsetY }); - }, []); - - const handleEnd = useCallback(() => { - if (!containerRef.current) return; - - const rect = containerRef.current.getBoundingClientRect(); - const centerX = rect.left + rect.width / 2; - const centerY = rect.top + rect.height / 2; - const shouldBeTop = centerY < window.innerHeight / 2; - const shouldBeLeft = centerX < window.innerWidth / 2; - - if (dragDataRef.current.hasStarted) { - // Фиксируем текущую позицию без transition - setPosition({ x: rect.left, y: rect.top }); - setDragState("released"); - - // Запускаем анимацию к углу - requestAnimationFrame(() => { - requestAnimationFrame(() => { - setDragState("snapping"); - setCorner({ top: shouldBeTop, left: shouldBeLeft }); - setTimeout(() => setDragState("idle"), 500); - }); - }); - } else { - setDragState("idle"); - } - - dragDataRef.current.hasStarted = false; - window.removeEventListener("mousemove", handleMove); - window.removeEventListener("touchmove", handleMove); - window.removeEventListener("mouseup", handleEnd); - window.removeEventListener("touchend", handleEnd); - }, [handleMove]); - - const handleStart = (e: React.MouseEvent | React.TouchEvent) => { - if (!containerRef.current) return; - - const rect = containerRef.current.getBoundingClientRect(); - const pos = getPointerPos(e); - - dragDataRef.current = { - startX: pos.x, - startY: pos.y, - offsetX: pos.x - rect.left, - offsetY: pos.y - rect.top, - hasStarted: false, - }; - - setPosition({ x: rect.left, y: rect.top }); - - window.addEventListener("mousemove", handleMove); - window.addEventListener("touchmove", handleMove); - window.addEventListener("mouseup", handleEnd); - window.addEventListener("touchend", handleEnd); - }; - - useEffect(() => { - return () => { - window.removeEventListener("mousemove", handleMove); - window.removeEventListener("touchmove", handleMove); - window.removeEventListener("mouseup", handleEnd); - window.removeEventListener("touchend", handleEnd); - }; - }, [handleMove, handleEnd]); - - const offset = window.innerWidth * OFFSET; - - // Вычисляем финальные координаты угла - const getCornerPosition = () => { - if (!containerRef.current) return { x: offset, y: offset }; - const rect = containerRef.current.getBoundingClientRect(); - return { - x: corner.left ? offset : window.innerWidth - offset - rect.width, - y: corner.top ? offset : window.innerHeight - offset - rect.height, - }; - }; - - let style: React.CSSProperties; - if (dragState === "dragging" || dragState === "released") { - // Во время перетаскивания или сразу после отпускания - style = { - left: position.x, - top: position.y, - transition: "none" - }; - } else { - // Анимация к углу или покой в углу - const cornerPos = getCornerPosition(); - style = { - left: cornerPos.x, - top: cornerPos.y, - transition: dragState === "snapping" ? TRANSITION : "none", - }; - } - - return ( -
-
- {users.map((user) => ( - console.log(`Mute user ${user.id}`)} - onVideoOff={() => console.log(`Video off user ${user.id}`)} - onCanControl={() => console.log(`Can control user ${user.id}`)} - {...user} - /> - ))} - -
-
- ); -} diff --git a/client/src/components/SessionUsersPanel2.tsx b/client/src/components/SessionUsersPanel2.tsx index 8650ec8..3d64f0b 100644 --- a/client/src/components/SessionUsersPanel2.tsx +++ b/client/src/components/SessionUsersPanel2.tsx @@ -1,36 +1,28 @@ import UserCamera from "./ui/UserCamera"; import UserDevicesControls from "./ui/UserDevicesControls"; import DraggableContainer from "./DraggableContainer"; +import { useWebRTC } from "../hooks/useWebRTC"; -const users = [ - { - id: 1, - name: "John Doe", - isSpeaking: true, - isMuted: false, - isVideoOff: false, - isControlDisabled: false, - isAdmin: true, - }, - { - id: 2, - name: "Jane Doe", - isSpeaking: false, - isMuted: true, - isVideoOff: true, - isControlDisabled: true, - }, - { - id: 3, - name: "Jim Doe", - isSpeaking: false, - isMuted: false, - isVideoOff: false, - isControlDisabled: false, - }, -]; +interface SessionUsersPanel2Props { + roomId: string; + autoJoin?: boolean; +} + +function SessionUsersPanel2({ + roomId, + autoJoin = false, +}: SessionUsersPanel2Props) { + const { + localStream, + participants, + isAudioMuted: isLocalAudioMuted, + isVideoMuted: isLocalVideoMuted, + toggleAudio, + toggleVideo, + } = useWebRTC(roomId, autoJoin); + + const hasLocalStream = localStream !== null; -function SessionUsersPanel2() { return ( - {users.map((user) => ( + {/* Локальная камера пользователя */} + console.log("Toggle control")} + /> + + {/* Камеры удаленных участников */} + {participants.map((participant) => ( console.log(`Mute user ${user.id}`)} - onVideoOff={() => console.log(`Video off user ${user.id}`)} - onCanControl={() => console.log(`Can control user ${user.id}`)} - {...user} + key={participant.id} + name={participant.id} + isSpeaking={false} + isMuted={false} + isVideoOff={false} + isControlDisabled={true} + isAdmin={true} // Локальный пользователь - админ своей сессии + mediaStream={participant.stream} + onMute={() => console.log(`Mute user ${participant.id}`)} + onVideoOff={() => console.log(`Video off user ${participant.id}`)} + onCanControl={() => console.log(`Can control user ${participant.id}`)} /> ))} - + ); } diff --git a/client/src/components/icons/VolumeIcon.tsx b/client/src/components/icons/VolumeIcon.tsx new file mode 100644 index 0000000..675fa41 --- /dev/null +++ b/client/src/components/icons/VolumeIcon.tsx @@ -0,0 +1,21 @@ +function VolumeIcon() { + return ( + + + + + + ); +} + +export default VolumeIcon; + diff --git a/client/src/components/icons/VolumeOffIcon.tsx b/client/src/components/icons/VolumeOffIcon.tsx new file mode 100644 index 0000000..cc3ba60 --- /dev/null +++ b/client/src/components/icons/VolumeOffIcon.tsx @@ -0,0 +1,17 @@ +function VolumeOffIcon() { + return ( + + + + + ); +} + +export default VolumeOffIcon; + diff --git a/client/src/components/popups/ChatPopup.tsx b/client/src/components/popups/ChatPopup.tsx index de918dd..7cd3b0d 100644 --- a/client/src/components/popups/ChatPopup.tsx +++ b/client/src/components/popups/ChatPopup.tsx @@ -1,36 +1,18 @@ -import { useRef, useState, useEffect } from "react"; +import { useRef, useEffect, useState } from "react"; import SendIcon from "../icons/SendIcon"; import Button from "../ui/Button"; import { useMe } from "../../hooks/useAuth"; import clsx from "clsx"; import PopupWrapper from "../PopupWrapper"; import DraggableContainer from "../DraggableContainer"; +import { useWebRTC } from "../../hooks/useWebRTC"; export default function ChatPopup() { const headerRef = useRef(null); - const [messages, setMessages] = useState([ - { - senderId: "1", - timestamp: "12:22", - content: - "У меня все сломалось, ничего не работает, картинки нет, все пошло по пизде, помогите мне кто-нибудь, пожалуйста", - }, - { - senderId: "2", - timestamp: "12:22", - content: "🤡🤡🤡", - }, - ]); + const { chatMessages, sendMessage, currentUserId } = useWebRTC(); function onMessageSend(message: string) { - setMessages([ - ...messages, - { - senderId: "2", - timestamp: "12:22", - content: message, - }, - ]); + sendMessage(message); } return ( @@ -43,7 +25,7 @@ export default function ChatPopup() { >
- +
@@ -51,7 +33,17 @@ export default function ChatPopup() { ); } -function MessageFeed({ messages }: { messages: MessageItemProps[] }) { +interface MessageFeedProps { + messages: Array<{ + id: string; + senderId: string; + content: string; + timestamp: Date; + }>; + currentUserId: string; +} + +function MessageFeed({ messages, currentUserId }: MessageFeedProps) { const messagesEndRef = useRef(null); // Скролл к концу при получении нового сообщения @@ -76,8 +68,16 @@ function MessageFeed({ messages }: { messages: MessageItemProps[] }) { ) : (
- {messages.map((message, index) => ( - + {messages.map((message) => ( + ))}
@@ -87,16 +87,14 @@ function MessageFeed({ messages }: { messages: MessageItemProps[] }) { } interface MessageItemProps { - senderId: string; timestamp: string; content: string; + isFromMe: boolean; } -function MessageItem({ senderId, timestamp, content }: MessageItemProps) { +function MessageItem({ timestamp, content, isFromMe }: MessageItemProps) { const { data: user } = useMe(); - const isFromMe = senderId === "1"; - return (
(null); + + // Добавляем локального пользователя в начало списка + const allParticipants: (Participant & { isLocal?: boolean })[] = [ + { + id: currentUserId, + stream: localStream || undefined, + isLocal: true, + }, + ...participants, + ]; return (
- {participants.map((participant, index) => ( - - -
-
- ))} + {allParticipants.length === 0 ? ( +
+ Нет участников +
+ ) : ( + allParticipants.map((participant) => ( + + +
+
+ )) + )}
+
+ )} + + {/* Элементы управления только для удаленных участников */} + {!isLocal && ( + + )}
); } @@ -89,12 +339,14 @@ function UserCameraControls({ isMuted, isVideoOff, isControlDisabled, + isAdmin, onMute, onVideoOff, onCanControl, }: UserCameraControlsProps) { return (
+ {/* Индикатор muted - показывается всегда */}
-
e.stopPropagation()} - > - : } - size={"small"} - disabled={isMuted} - onClick={onMute} - /> - : } - size={"small"} - disabled={isVideoOff} - onClick={onVideoOff} - /> - - ) : ( - - ) - } - size={"small"} - disabled={isControlDisabled} - onClick={onCanControl} - /> -
+ + {/* Кнопки управления - только для администраторов */} + {isAdmin && ( +
e.stopPropagation()} + > + : } + size={"small"} + disabled={false} + onClick={onMute} + /> + : } + size={"small"} + disabled={false} + onClick={onVideoOff} + /> + + ) : ( + + ) + } + size={"small"} + disabled={isControlDisabled} + onClick={onCanControl} + /> +
+ )}
); } diff --git a/client/src/components/ui/UserDevicesControls.tsx b/client/src/components/ui/UserDevicesControls.tsx index e5c9542..59044ec 100644 --- a/client/src/components/ui/UserDevicesControls.tsx +++ b/client/src/components/ui/UserDevicesControls.tsx @@ -1,23 +1,30 @@ import MicrophoneFilledIcon from "../icons/MicrophoneFilledIcon"; +import MicrophoneOffIcon from "../icons/MicrophoneOffIcon"; import ControlButton from "./ControlButton"; import VideoFilledIcon from "../icons/VideoFilledIcon"; +import VideoOffFilledIcon from "../icons/VideoOffFilledIcon"; import HandRaisedFilledIcon from "../icons/HandRaisedFilledIcon"; import CogFilledIcon from "../icons/CogFilledIcon"; import useModalStore from "../../store/modalStore"; import SettingsModal from "../modals/SettingsModal"; -export default function UserDevicesControls() { +export interface UserDevicesControlsProps { + toggleAudio: () => void; + toggleVideo: () => void; + isAudioMuted: boolean; + isVideoMuted: boolean; + hasLocalStream?: boolean; +} + +export default function UserDevicesControls({ + toggleAudio, + toggleVideo, + isAudioMuted, + isVideoMuted, + hasLocalStream = true, +}: UserDevicesControlsProps) { const { setModal } = useModalStore(); - function ToggleAudioDevice() { - console.log("Mute device"); - } - function ToggleVideoDevice() { - console.log("Video device"); - } - function ToggleCanControl() { - console.log("Can control device"); - } function ToggleSettings() { setModal(); } @@ -27,20 +34,22 @@ export default function UserDevicesControls() { e.stopPropagation()} size="large" - icon={} - onClick={ToggleAudioDevice} + icon={isAudioMuted ? : } + disabled={!hasLocalStream} + onClick={toggleAudio} /> e.stopPropagation()} size="large" - icon={} - onClick={ToggleVideoDevice} + icon={isVideoMuted ? : } + disabled={!hasLocalStream} + onClick={toggleVideo} /> e.stopPropagation()} size="large" icon={} - onClick={ToggleCanControl} + onClick={() => console.log("Toggle can control")} /> e.stopPropagation()} diff --git a/client/src/hooks/useWebRTC.ts b/client/src/hooks/useWebRTC.ts new file mode 100644 index 0000000..6447463 --- /dev/null +++ b/client/src/hooks/useWebRTC.ts @@ -0,0 +1,220 @@ +import { useEffect, useState, useRef } from "react"; +import { + createWebRTCService, + type Participant, + type ChatMessage, +} from "../lib/webrtc"; + +let webrtcServiceInstance: ReturnType | null = null; +let isInitializing = false; + +export const useWebRTC = (roomId?: string, autoJoin = false) => { + const callbacksRegisteredRef = useRef(false); + const hasJoinedRoomRef = useRef(false); + const [localStream, setLocalStream] = useState(null); + const [participants, setParticipants] = useState([]); + const [isAudioMuted, setIsAudioMuted] = useState(false); + const [isVideoMuted, setIsVideoMuted] = useState(false); + const [chatMessages, setChatMessages] = useState([]); + const [isConnected, setIsConnected] = useState(false); + const [isInitialized, setIsInitialized] = useState(false); + + // Мониторинг изменений участников + useEffect(() => { + console.log("[useWebRTC] Participants state updated:", participants.map(p => ({ + id: p.id, + hasStream: !!p.stream, + }))); + }, [participants]); + + useEffect(() => { + // Создаем сервис только один раз (синглтон) + if (!webrtcServiceInstance) { + webrtcServiceInstance = createWebRTCService({}); + } + + // Инициализируем состояние из существующего сервиса + const existingStream = webrtcServiceInstance.getLocalStream(); + if (existingStream) { + console.log("[useWebRTC] Initializing with existing local stream"); + setLocalStream(existingStream); + setIsInitialized(true); + } + + const existingParticipants = webrtcServiceInstance.getParticipants(); + console.log("[useWebRTC] Component mounted, existing participants:", existingParticipants.length); + if (existingParticipants.length > 0) { + console.log("[useWebRTC] Initializing with participants:", existingParticipants.map(p => p.id)); + setParticipants(existingParticipants); + } + + const existingMessages = webrtcServiceInstance.getChatMessages(); + if (existingMessages.length > 0) { + console.log("[useWebRTC] Initializing with existing messages:", existingMessages.length); + setChatMessages(existingMessages); + } + + // Добавляем коллбэки только один раз для этого компонента + if (callbacksRegisteredRef.current) { + return; + } + + callbacksRegisteredRef.current = true; + const removeCallbacks = webrtcServiceInstance.addCallbacks({ + onLocalStreamReady: (stream) => { + console.log("[useWebRTC] Local stream ready"); + setLocalStream(stream); + setIsInitialized(true); + }, + onRemoteStreamReady: (participantId, stream) => { + console.log("[useWebRTC] onRemoteStreamReady called for:", participantId); + setParticipants((prev) => { + const existing = prev.find((p) => p.id === participantId); + if (existing) { + console.log("[useWebRTC] Updating stream for existing participant:", participantId); + return prev.map((p) => + p.id === participantId ? { ...p, stream } : p + ); + } else { + console.log("[useWebRTC] Adding new participant with stream:", participantId); + return [...prev, { id: participantId, stream }]; + } + }); + }, + onRoomParticipants: () => { + setIsConnected(true); + }, + onParticipantJoined: (participant) => { + console.log("[useWebRTC] onParticipantJoined called for:", participant.id); + setParticipants((prev) => { + if (prev.find((p) => p.id === participant.id)) { + console.log("[useWebRTC] Participant already in list, skipping"); + return prev; + } + console.log("[useWebRTC] Adding participant to state"); + return [...prev, participant]; + }); + }, + onParticipantLeft: (participantId) => { + setParticipants((prev) => prev.filter((p) => p.id !== participantId)); + }, + onChatMessage: (message) => { + setChatMessages((prev) => [...prev, message]); + }, + onDataChannelOpen: () => { + // DataChannel opened + }, + onDataChannelClose: () => { + // DataChannel closed + }, + onError: (error) => { + console.error("[useWebRTC] Error:", error); + }, + }); + + const initWebRTC = async () => { + if (!webrtcServiceInstance || isInitializing) { + return; + } + + // Проверяем, есть ли уже localStream + if (webrtcServiceInstance.getLocalStream()) { + setIsInitialized(true); + return; + } + + try { + isInitializing = true; + const stream = await webrtcServiceInstance.initializeLocalStream(); + + // Даже если stream === null (пользователь отказался от разрешений), + // считаем инициализацию завершенной + if (stream === null) { + console.log("[useWebRTC] Initialized without local stream (user denied permissions)"); + setIsInitialized(true); + } + } catch (error) { + console.error("[useWebRTC] Initialization error:", error); + // Даже при ошибке разрешаем продолжить + setIsInitialized(true); + } finally { + isInitializing = false; + } + }; + + initWebRTC(); + + // Cleanup при размонтировании компонента + return () => { + callbacksRegisteredRef.current = false; + removeCallbacks(); + }; + }, []); // Пустой массив зависимостей - эффект срабатывает только при монтировании + + // Отдельный эффект для присоединения к комнате + // ВАЖНО: Присоединяемся только ПОСЛЕ инициализации localStream! + useEffect(() => { + if ( + !webrtcServiceInstance || + !autoJoin || + !roomId || + hasJoinedRoomRef.current || + !isInitialized + ) { + return; + } + + const joinRoomAsync = async () => { + await webrtcServiceInstance!.joinRoom(roomId); + hasJoinedRoomRef.current = true; + }; + + joinRoomAsync(); + }, [roomId, autoJoin, isInitialized]); + + const toggleAudio = () => { + if (!webrtcServiceInstance) return; + const newState = webrtcServiceInstance.toggleAudio(); + setIsAudioMuted(!newState); + }; + + const toggleVideo = () => { + if (!webrtcServiceInstance) return; + const newState = webrtcServiceInstance.toggleVideo(); + setIsVideoMuted(!newState); + }; + + const sendMessage = (content: string) => { + if (!webrtcServiceInstance) return; + webrtcServiceInstance.sendChatMessage(content); + }; + + const joinRoom = async (roomId: string) => { + if (!webrtcServiceInstance) return; + await webrtcServiceInstance.joinRoom(roomId); + setIsConnected(true); + }; + + const leaveRoom = () => { + if (!webrtcServiceInstance) return; + webrtcServiceInstance.leaveRoom(); + setIsConnected(false); + setParticipants([]); + }; + + return { + localStream, + participants, + isAudioMuted, + isVideoMuted, + chatMessages, + isConnected, + isInitialized, + currentUserId: webrtcServiceInstance?.getCurrentUserId() || "", + toggleAudio, + toggleVideo, + sendMessage, + joinRoom, + leaveRoom, + }; +}; diff --git a/client/src/lib/webrtc.ts b/client/src/lib/webrtc.ts new file mode 100644 index 0000000..1c5f458 --- /dev/null +++ b/client/src/lib/webrtc.ts @@ -0,0 +1,925 @@ +import { io, Socket } from "socket.io-client"; +import { v4 as uuidv4 } from "uuid"; + +export interface ChatMessage { + id: string; + senderId: string; + senderName?: string; + content: string; + timestamp: Date; + type: "text" | "system"; +} + +export interface Participant { + id: string; + name?: string; + stream?: MediaStream; + peerConnection?: RTCPeerConnection; + dataChannel?: RTCDataChannel; + isMuted?: boolean; + isVideoOff?: boolean; + isSpeaking?: boolean; + iceCandidateQueue?: RTCIceCandidate[]; +} + +export interface WebRTCCallbacks { + onParticipantJoined?: (participant: Participant) => void; + onParticipantLeft?: (participantId: string) => void; + onLocalStreamReady?: (stream: MediaStream) => void; + onRemoteStreamReady?: (participantId: string, stream: MediaStream) => void; + onRoomParticipants?: (participantIds: string[]) => void; + onChatMessage?: (message: ChatMessage) => void; + onDataChannelOpen?: (participantId: string) => void; + onDataChannelClose?: (participantId: string) => void; + onParticipantAudioToggle?: ( + participantId: string, + isEnabled: boolean + ) => void; + onParticipantVideoToggle?: ( + participantId: string, + isEnabled: boolean + ) => void; + onError?: (error: Error) => void; +} + +interface WebRTCState { + socket: Socket; + localStream: MediaStream | null; + participants: Map; + roomId: string | null; + userId: string; + isAudioEnabled: boolean; + isVideoEnabled: boolean; + callbacks: WebRTCCallbacks[]; // Изменено на массив + chatMessages: ChatMessage[]; +} + +let state: WebRTCState | null = null; + +const ICE_SERVERS = [ + { + urls: "turn:185.173.176.83:3478", + username: "username1", + credential: "password1", + }, + // { + // urls: "turn:openrelay.metered.ca:80", + // username: "openrelayproject", + // credential: "openrelayproject", + // }, + // { + // urls: "turn:openrelay.metered.ca:443", + // username: "openrelayproject", + // credential: "openrelayproject", + // }, + // { + // urls: "turn:openrelay.metered.ca:443?transport=tcp", + // username: "openrelayproject", + // credential: "openrelayproject", + // }, +]; + +// Для localhost можно использовать упрощенную конфигурацию +const isLocalhost = + window.location.hostname === "localhost" || + window.location.hostname === "127.0.0.1" || + window.location.hostname === "[::1]"; + +if (isLocalhost) { + console.log( + "[WebRTC] Running on localhost - using TURN server for reliable connection" + ); +} + +// Вспомогательная функция для вызова всех коллбэков +function callAllCallbacks( + eventName: K, + ...args: Parameters> +) { + if (!state) return; + state.callbacks.forEach((callbacks) => { + const callback = callbacks[eventName]; + if (callback) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (callback as any)(...args); + } + }); +} + +export function createWebRTCService(callbacks: WebRTCCallbacks = {}) { + if (state) { + cleanup(); + } + + console.log("Creating WebRTC service..."); + + // Подключаемся к WebRTC серверу на порту 3001 + const serverUrl = import.meta.env.VITE_WEBRTC_URL || "http://localhost:3001"; + console.log("Connecting to WebRTC server:", serverUrl); + + const socket = io(serverUrl, { + transports: ["websocket", "polling"], + }); + const userId = uuidv4(); + console.log("Generated user ID:", userId); + + state = { + socket, + localStream: null, + participants: new Map(), + roomId: null, + userId, + isAudioEnabled: true, + isVideoEnabled: true, + callbacks: [callbacks], // Массив коллбэков + chatMessages: [], + }; + + setupSocketListeners(); + + return { + initializeLocalStream, + joinRoom, + toggleAudio, + toggleVideo, + leaveRoom, + sendChatMessage, + getChatMessages: () => state?.chatMessages || [], + getCurrentUserId: () => state?.userId || "", + getParticipants: () => Array.from(state?.participants.values() || []), + getLocalStream: () => state?.localStream || null, + isAudioMuted: () => (state ? !state.isAudioEnabled : true), + isVideoMuted: () => (state ? !state.isVideoEnabled : true), + hasLocalStream: () => state?.localStream !== null, + addCallbacks: (newCallbacks: WebRTCCallbacks) => { + if (state) { + state.callbacks.push(newCallbacks); + console.log("Added callbacks, total count:", state.callbacks.length); + } + return () => { + if (state) { + const index = state.callbacks.indexOf(newCallbacks); + if (index > -1) { + state.callbacks.splice(index, 1); + console.log( + "Removed callbacks, remaining count:", + state.callbacks.length + ); + } + } + }; + }, + cleanup, + }; +} + +function setupSocketListeners() { + if (!state) return; + + const { socket } = state; + console.log("Setting up socket listeners..."); + + socket.on("connect", () => { + console.log("Socket connected with ID:", socket.id); + }); + + socket.on("disconnect", (reason) => { + console.log("Socket disconnected, reason:", reason); + }); + + socket.on("connect_error", (error) => { + console.error("Socket connection error:", error); + }); + + socket.on("reconnect", (attemptNumber) => { + console.log("Socket reconnected after", attemptNumber, "attempts"); + }); + + socket.on("reconnect_error", (error) => { + console.error("Socket reconnection error:", error); + }); + + socket.on("room-participants", (participants: string[]) => { + console.log("Room participants received:", participants); + console.log("Current user ID:", state?.userId); + console.log( + "[WebRTC] I am the new user, connecting to existing participants" + ); + callAllCallbacks("onRoomParticipants", participants); + + // Новый пользователь (мы) инициируем соединения со всеми существующими участниками + participants.forEach((participantId) => { + addParticipant(participantId); + createPeerConnection(participantId, true); // МЫ - инициаторы + }); + }); + + socket.on("user-joined", (userId: string) => { + console.log("[WebRTC] User joined event received:", userId); + console.log( + "[WebRTC] New user will initiate connection to us, waiting for offer" + ); + if (!state) return; + + // Только добавляем участника в UI, НЕ создаем peer connection + // Новый пользователь сам инициирует соединение с нами + addParticipant(userId); + // НЕ вызываем createPeerConnection - ждем входящий offer + }); + + socket.on("user-left", (userId: string) => { + console.log("User left event received:", userId); + if (!state) return; + + const participant = state.participants.get(userId); + if (participant) { + participant.peerConnection?.close(); + state.participants.delete(userId); + callAllCallbacks("onParticipantLeft", userId); + } + }); + + socket.on( + "offer", + async ({ + offer, + sender, + }: { + offer: RTCSessionDescriptionInit; + sender: string; + }) => { + console.log("Received offer from:", sender); + await handleOffer(sender, offer); + } + ); + + socket.on( + "answer", + async ({ + answer, + sender, + }: { + answer: RTCSessionDescriptionInit; + sender: string; + }) => { + console.log("Received answer from:", sender); + await handleAnswer(sender, answer); + } + ); + + socket.on( + "ice-candidate", + async ({ + candidate, + sender, + }: { + candidate: RTCIceCandidate; + sender: string; + }) => { + console.log("Received ICE candidate from:", sender); + await handleIceCandidate(sender, candidate); + } + ); + + console.log("Socket listeners set up complete"); +} + +async function initializeLocalStream(): Promise { + if (!state) throw new Error("WebRTC service not initialized"); + + try { + // Проверяем доступность WebRTC API + if (!navigator.mediaDevices) { + throw new Error("WebRTC не поддерживается в этом браузере."); + } + + if (!navigator.mediaDevices.getUserMedia) { + throw new Error("getUserMedia не поддерживается в этом браузере."); + } + + console.log("Requesting media access..."); + const stream = await navigator.mediaDevices.getUserMedia({ + video: true, + audio: true, + }); + + console.log("Local stream:", stream); + + stream.getTracks().forEach((track) => { + console.log("Track:", track.kind, track.label); + }); + + state.localStream = stream; + callAllCallbacks("onLocalStreamReady", stream); + return stream; + } catch (error) { + console.error("Error accessing media devices:", error); + + let errorMessage = "Не удалось получить доступ к камере или микрофону"; + + if (error instanceof Error) { + if (error.name === "NotAllowedError") { + errorMessage = + "Доступ к камере и микрофону запрещен. Вы можете продолжить без видео и аудио."; + } else if (error.name === "NotFoundError") { + errorMessage = + "Камера или микрофон не найдены. Вы можете продолжить без видео и аудио."; + } else if (error.name === "NotReadableError") { + errorMessage = + "Камера или микрофон заняты другим приложением. Вы можете продолжить без видео и аудио."; + } + } + + console.warn("Продолжаем без локального медиа-потока:", errorMessage); + callAllCallbacks("onError", new Error(errorMessage)); + + // Возвращаем null вместо выброса ошибки, чтобы пользователь мог продолжить + return null; + } +} + +async function joinRoom(roomId: string): Promise { + if (!state) throw new Error("WebRTC service not initialized"); + + console.log("Joining room:", roomId, "with user ID:", state.userId); + state.roomId = roomId; + state.socket.emit("join-room", { roomId, userId: state.userId }); +} + +// Вспомогательная функция для добавления участника +function addParticipant(participantId: string): Participant { + if (!state) throw new Error("WebRTC service not initialized"); + + let participant = state.participants.get(participantId); + if (!participant) { + participant = { id: participantId }; + state.participants.set(participantId, participant); + console.log("[WebRTC] Adding new participant:", participantId); + callAllCallbacks("onParticipantJoined", participant); + } + return participant; +} + +async function createPeerConnection( + participantId: string, + isInitiator: boolean +): Promise { + if (!state) return; + + const participant = state.participants.get(participantId); + if (!participant) { + console.error("[WebRTC] Participant not found:", participantId); + return; + } + + // Если peer connection уже существует, не создаем новый + if (participant.peerConnection) { + console.log("[WebRTC] Peer connection already exists for:", participantId); + return; + } + + console.log( + "[WebRTC] Creating new peer connection for:", + participantId, + "isInitiator:", + isInitiator + ); + const peerConnection = new RTCPeerConnection({ + iceServers: ICE_SERVERS, // Используем TURN сервер для всех соединений + iceTransportPolicy: "all", // Использовать все доступные транспорты + bundlePolicy: "max-bundle", // Объединить все медиа в один транспорт + rtcpMuxPolicy: "require", // Мультиплексировать RTP и RTCP + iceCandidatePoolSize: 10, // Предварительно собрать candidates + }); + participant.peerConnection = peerConnection; + + // Create DataChannel for chat (only initiator creates the channel) + if (isInitiator) { + const dataChannel = peerConnection.createDataChannel("chat", { + ordered: true, + }); + participant.dataChannel = dataChannel; + setupDataChannelListeners(dataChannel, participantId); + } + + // Handle incoming DataChannel + peerConnection.ondatachannel = (event) => { + const dataChannel = event.channel; + participant!.dataChannel = dataChannel; + setupDataChannelListeners(dataChannel, participantId); + }; + + // Add local stream tracks or create silent/black tracks + if (state.localStream) { + state.localStream.getTracks().forEach((track) => { + peerConnection.addTrack(track, state!.localStream!); + }); + console.log("[WebRTC] Added local stream tracks for:", participantId); + } else { + // Если нет локального потока, создаем пустые треки для совместимости + console.log( + "[WebRTC] No local stream, creating silent/black tracks for:", + participantId + ); + + // Создаем пустой audio track + const audioContext = new AudioContext(); + const oscillator = audioContext.createOscillator(); + const dst = audioContext.createMediaStreamDestination(); + oscillator.connect(dst); + oscillator.start(); + const audioTrack = dst.stream.getAudioTracks()[0]; + audioTrack.enabled = false; // Отключаем сразу + + // Создаем черный video track + const canvas = document.createElement("canvas"); + canvas.width = 640; + canvas.height = 480; + const ctx = canvas.getContext("2d"); + if (ctx) { + ctx.fillStyle = "black"; + ctx.fillRect(0, 0, canvas.width, canvas.height); + } + const canvasStream = canvas.captureStream(1); + const videoTrack = canvasStream.getVideoTracks()[0]; + videoTrack.enabled = false; // Отключаем сразу + + // Добавляем треки с MediaStream для лучшей совместимости + const dummyStream = new MediaStream([audioTrack, videoTrack]); + dummyStream.getTracks().forEach((track) => { + peerConnection.addTrack(track, dummyStream); + }); + } + + // Handle remote stream + peerConnection.ontrack = (event) => { + console.log( + "🎥 Received remote track from:", + participantId, + `(${event.track.kind})` + ); + const [remoteStream] = event.streams; + if (remoteStream) { + participant!.stream = remoteStream; + callAllCallbacks("onRemoteStreamReady", participantId, remoteStream); + } else { + console.error("No remote stream in track event!"); + } + }; + + // Handle ICE candidates + peerConnection.onicecandidate = (event) => { + if (event.candidate && state) { + const candidateStr = event.candidate.candidate || ""; + const type = candidateStr.includes("typ host") + ? "host" + : candidateStr.includes("typ srflx") + ? "srflx" + : candidateStr.includes("typ relay") + ? "relay" + : "unknown"; + console.log( + `[WebRTC] Sending ICE candidate to ${participantId}: type=${type}, candidate=${candidateStr.substring( + 0, + 50 + )}...` + ); + state.socket.emit("ice-candidate", { + target: participantId, + candidate: event.candidate, + }); + } else if (!event.candidate) { + console.log(`[WebRTC] ICE gathering complete for ${participantId}`); + } + }; + + peerConnection.onicegatheringstatechange = () => { + console.log( + `[WebRTC] ICE gathering state for ${participantId}:`, + peerConnection.iceGatheringState + ); + }; + + // Monitor connection state + peerConnection.onconnectionstatechange = () => { + console.log( + `[WebRTC] Connection state for ${participantId}:`, + peerConnection.connectionState + ); + }; + + peerConnection.oniceconnectionstatechange = () => { + console.log( + `[WebRTC] ICE connection state for ${participantId}:`, + peerConnection.iceConnectionState + ); + + // Автоматический restart при failed + if (peerConnection.iceConnectionState === "failed") { + console.warn( + `[WebRTC] ICE failed for ${participantId}, attempting restart...` + ); + peerConnection.restartIce(); + } + + // Уведомляем о disconnected + if (peerConnection.iceConnectionState === "disconnected") { + console.warn( + `[WebRTC] ICE disconnected for ${participantId}, may reconnect automatically` + ); + } + }; + + // Create offer if initiator + if (isInitiator) { + try { + console.log("[WebRTC] Creating offer for:", participantId); + console.log( + "[WebRTC] Current ICE gathering state:", + peerConnection.iceGatheringState + ); + console.log( + "[WebRTC] Current signaling state:", + peerConnection.signalingState + ); + + // Создаем offer с настройками на прием аудио и видео + // Используем старый формат для совместимости с Firefox + const offerOptions: RTCOfferOptions = { + offerToReceiveAudio: true, + offerToReceiveVideo: true, + }; + + const offer = await peerConnection.createOffer(offerOptions); + console.log("[WebRTC] Offer created, SDP type:", offer.type); + + await peerConnection.setLocalDescription(offer); + console.log( + "[WebRTC] Local description set, ICE gathering state:", + peerConnection.iceGatheringState + ); + console.log("[WebRTC] hasLocalStream:", !!state.localStream); + + // Используем Trickle ICE - отправляем offer сразу, candidates отправятся отдельно + console.log("[WebRTC] Sending offer to:", participantId); + state.socket.emit("offer", { + target: participantId, + offer: offer, + }); + console.log( + "[WebRTC] Offer sent, waiting for ICE candidates to be gathered and sent..." + ); + } catch (error) { + console.error("Error creating offer:", error); + callAllCallbacks("onError", error as Error); + } + } +} + +async function handleOffer( + senderId: string, + offer: RTCSessionDescriptionInit +): Promise { + if (!state) return; + + console.log("[WebRTC] Received offer from:", senderId); + + // Добавляем участника, если его еще нет + addParticipant(senderId); + + const participant = state.participants.get(senderId); + if (!participant) { + console.error("[WebRTC] Participant not found:", senderId); + return; + } + + // Создаем peer connection (мы НЕ инициаторы, отвечаем на offer) + await createPeerConnection(senderId, false); + + if (!participant.peerConnection) { + console.error("[WebRTC] Failed to create peer connection for:", senderId); + return; + } + + const peerConnection = participant.peerConnection; + + try { + console.log("[WebRTC] Setting remote description (offer) from:", senderId); + await peerConnection.setRemoteDescription(offer); + console.log( + "[WebRTC] Remote description set, ICE gathering state:", + peerConnection.iceGatheringState + ); + + // Обрабатываем очередь ICE candidates + if ( + participant.iceCandidateQueue && + participant.iceCandidateQueue.length > 0 + ) { + console.log( + "[WebRTC] Processing queued ICE candidates:", + participant.iceCandidateQueue.length + ); + for (const queuedCandidate of participant.iceCandidateQueue) { + try { + await peerConnection.addIceCandidate(queuedCandidate); + } catch (error) { + console.error("Error adding queued ICE candidate:", error); + } + } + participant.iceCandidateQueue = []; + } + + console.log("[WebRTC] Creating answer for:", senderId); + const answer = await peerConnection.createAnswer(); + console.log("[WebRTC] Answer created"); + + await peerConnection.setLocalDescription(answer); + console.log( + "[WebRTC] Local description (answer) set, ICE gathering state:", + peerConnection.iceGatheringState + ); + + state.socket.emit("answer", { + target: senderId, + answer: answer, + }); + console.log("[WebRTC] Answer sent to:", senderId); + } catch (error) { + console.error("Error handling offer:", error); + callAllCallbacks("onError", error as Error); + } +} + +async function handleAnswer( + senderId: string, + answer: RTCSessionDescriptionInit +): Promise { + if (!state) return; + + console.log("[WebRTC] Received answer from:", senderId); + + const participant = state.participants.get(senderId); + if (!participant?.peerConnection) { + console.warn( + "[WebRTC] No peer connection found for answer from:", + senderId + ); + return; + } + + const signalingState = participant.peerConnection.signalingState; + console.log("[WebRTC] Current signaling state:", signalingState); + + // Проверяем, что мы ожидаем answer + if (signalingState !== "have-local-offer") { + console.warn( + "[WebRTC] Ignoring answer - not in have-local-offer state:", + signalingState + ); + return; + } + + try { + await participant.peerConnection.setRemoteDescription(answer); + console.log("[WebRTC] Successfully set remote answer from:", senderId); + + // Обрабатываем очередь ICE candidates + if ( + participant.iceCandidateQueue && + participant.iceCandidateQueue.length > 0 + ) { + console.log( + "[WebRTC] Processing queued ICE candidates:", + participant.iceCandidateQueue.length + ); + for (const queuedCandidate of participant.iceCandidateQueue) { + try { + await participant.peerConnection.addIceCandidate(queuedCandidate); + } catch (error) { + console.error("Error adding queued ICE candidate:", error); + } + } + participant.iceCandidateQueue = []; + } + } catch (error) { + console.error("Error handling answer:", error); + callAllCallbacks("onError", error as Error); + } +} + +async function handleIceCandidate( + senderId: string, + candidate: RTCIceCandidate +): Promise { + if (!state) return; + + const participant = state.participants.get(senderId); + if (!participant?.peerConnection) { + console.warn( + "[WebRTC] No peer connection found for ICE candidate from:", + senderId + ); + return; + } + + // Проверяем, что у нас установлено remote description + if (!participant.peerConnection.remoteDescription) { + console.log("[WebRTC] Queuing ICE candidate - no remote description yet"); + // Добавляем в очередь для последующей обработки + if (!participant.iceCandidateQueue) { + participant.iceCandidateQueue = []; + } + participant.iceCandidateQueue.push(candidate); + console.log( + `[WebRTC] Queue size for ${senderId}:`, + participant.iceCandidateQueue.length + ); + return; + } + + try { + await participant.peerConnection.addIceCandidate(candidate); + const candidateStr = candidate.candidate || ""; + const type = candidateStr.includes("typ host") + ? "host" + : candidateStr.includes("typ srflx") + ? "srflx" + : candidateStr.includes("typ relay") + ? "relay" + : "unknown"; + console.log( + `[WebRTC] Added ICE candidate from ${senderId}, type: ${type}, candidate=${candidateStr.substring( + 0, + 50 + )}...` + ); + } catch (error) { + console.error("Error handling ICE candidate:", error); + // Не выбрасываем ошибку в callback - это нормально при переподключениях + } +} + +function toggleAudio(): boolean { + if (!state?.localStream) { + console.warn("Cannot toggle audio: no local stream available"); + return false; + } + + const audioTracks = state.localStream.getAudioTracks(); + if (audioTracks.length === 0) { + console.warn("Cannot toggle audio: no audio tracks available"); + return false; + } + + audioTracks.forEach((track) => { + track.enabled = !track.enabled; + }); + state.isAudioEnabled = !state.isAudioEnabled; + return state.isAudioEnabled; +} + +function toggleVideo(): boolean { + if (!state?.localStream) { + console.warn("Cannot toggle video: no local stream available"); + return false; + } + + const videoTracks = state.localStream.getVideoTracks(); + if (videoTracks.length === 0) { + console.warn("Cannot toggle video: no video tracks available"); + return false; + } + + videoTracks.forEach((track) => { + track.enabled = !track.enabled; + }); + state.isVideoEnabled = !state.isVideoEnabled; + return state.isVideoEnabled; +} + +function setupDataChannelListeners( + dataChannel: RTCDataChannel, + participantId: string +): void { + if (!state) return; + + dataChannel.onopen = () => { + console.log("DataChannel opened with participant:", participantId); + callAllCallbacks("onDataChannelOpen", participantId); + }; + + dataChannel.onclose = () => { + console.log("DataChannel closed with participant:", participantId); + callAllCallbacks("onDataChannelClose", participantId); + }; + + dataChannel.onmessage = (event) => { + try { + const message: ChatMessage = JSON.parse(event.data); + console.log("📨 Received chat message from DataChannel:", message); + + // Only add messages from other participants (not our own) + if (message.senderId !== state!.userId) { + // Add to local messages + state!.chatMessages.push(message); + console.log( + "📨 Added message to local state, total messages:", + state!.chatMessages.length + ); + + // Notify callback + callAllCallbacks("onChatMessage", message); + } + } catch (error) { + console.error("Error parsing chat message:", error); + } + }; + + dataChannel.onerror = (error) => { + console.error("DataChannel error with participant:", participantId, error); + callAllCallbacks("onError", new Error(`DataChannel error: ${error}`)); + }; +} + +function sendChatMessage(content: string): void { + if (!state || !content.trim()) return; + + const message: ChatMessage = { + id: uuidv4(), + senderId: state.userId, + content: content.trim(), + timestamp: new Date(), + type: "text", + }; + + // Add to local messages + state.chatMessages.push(message); + console.log( + "Added own message to local state, total messages:", + state.chatMessages.length + ); + + // Send to all participants via DataChannel + console.log( + "📤 Sending message to participants, total participants:", + state.participants.size + ); + state.participants.forEach((participant) => { + if ( + participant.dataChannel && + participant.dataChannel.readyState === "open" + ) { + try { + participant.dataChannel.send(JSON.stringify(message)); + console.log( + "📤 Successfully sent message to participant:", + participant.id + ); + } catch (error) { + console.error( + "📤 Error sending chat message to participant:", + participant.id, + error + ); + } + } + }); + + // Notify local callback (for own messages to update UI) + callAllCallbacks("onChatMessage", message); +} + +function leaveRoom(): void { + if (!state) return; + + // Close all peer connections + state.participants.forEach((participant) => { + participant.peerConnection?.close(); + }); + state.participants.clear(); + + // Stop local stream + if (state.localStream) { + state.localStream.getTracks().forEach((track) => track.stop()); + state.localStream = null; + } + + // Leave room via socket + if (state.socket.connected && state.roomId) { + state.socket.emit("leave-room", { + roomId: state.roomId, + userId: state.userId, + }); + } + + state.roomId = null; +} + +function cleanup(): void { + if (state) { + leaveRoom(); + state.socket.disconnect(); + state = null; + } +} diff --git a/client/src/pages/NewSessionPage.tsx b/client/src/pages/NewSessionPage.tsx index d429292..67383fd 100644 --- a/client/src/pages/NewSessionPage.tsx +++ b/client/src/pages/NewSessionPage.tsx @@ -22,7 +22,7 @@ import { PixelStreamingWrapper } from "../components/PixelStreamingWrapper"; import WarningIcon from "../components/icons/WarningIcon"; import Button from "../components/ui/Button"; import LoaderIcon from "../components/icons/LoaderIcon"; -import SessionUsersPanel from "../components/SessionUsersPanel2"; +import SessionUsersPanel2 from "../components/SessionUsersPanel2"; function NewSessionPage() { const { setPopup } = usePopupStore(); @@ -88,15 +88,15 @@ function NewSessionPage() { setPopup(); } - // Перенаправление на тестовую страницу при завершении сессии - useEffect(() => { - if (session?.status === "ended") { - const timer = setTimeout(() => { - navigate("/test"); - }, 5000); - return () => clearTimeout(timer); - } - }, [session?.status, navigate]); + // Не перенаправляем автоматически - пользователи могут продолжать общаться + // useEffect(() => { + // if (session?.status === "ended") { + // const timer = setTimeout(() => { + // navigate("/test"); + // }, 5000); + // return () => clearTimeout(timer); + // } + // }, [session?.status, navigate]); if (isLoading) { return ( @@ -140,6 +140,7 @@ function NewSessionPage() { return (
+ {/* Pixel Streaming - показывается только когда сессия активна */} {session.status === "started" && session.mode === "stream" && session.server?.localIp && @@ -153,6 +154,7 @@ function NewSessionPage() { StartVideoMuted: true, HoveringMouse: true, WaitForStreamer: true, + StreamerId: "DefaultStreamer", }} onVideoInitialized={() => { console.log("Video initialized"); @@ -160,6 +162,24 @@ function NewSessionPage() { />
)} + + {/* Сообщение когда Pixel Streaming завершён, но WebRTC чат работает */} + {session.status === "ended" && ( +
+
+
+ Сессия завершена +
+
+ Вы можете продолжать общаться через видеочат +
+
+ +
+ )} + - + + {/* WebRTC видеочат - работает всегда, пока пользователь на странице */} + ); } diff --git a/client/src/pages/SessionPage.tsx b/client/src/pages/SessionPage.tsx deleted file mode 100644 index 1c4fd6d..0000000 --- a/client/src/pages/SessionPage.tsx +++ /dev/null @@ -1,429 +0,0 @@ -import { useParams, useNavigate } from "react-router"; -import { useQuery } from "@tanstack/react-query"; -import { api } from "../lib/api"; -import LoaderIcon from "../components/icons/LoaderIcon"; -import CheckIcon from "../components/icons/CheckIcon"; -import ClockIcon from "../components/icons/ClockIcon"; -import WarningIcon from "../components/icons/WarningIcon"; -import StartIcon from "../components/icons/StartIcon"; -import Button from "../components/ui/Button"; -import clsx from "clsx"; -import { useEffect } from "react"; -import { PixelStreamingWrapper } from "../components/PixelStreamingWrapper"; - -interface Session { - id: string; - appId: string; - userId: string | null; - mode: "stream" | "local"; - status: "starting" | "started" | "ending" | "ended"; - tier: "demo" | "prod" | null; - serverId: string | null; - appPid: number | null; - cirrusPid: number | null; - streamerPort: number | null; - playerPort: number | null; - sfuPort: number | null; - startAt: string; - endAt: string | null; - createdAt: string; - updatedAt: string; - app?: { - id: string; - name: string; - title: string; - gpuLimitMb: number | null; - psVersion: number | null; - }; - server?: { - id: string; - localIp: string; - hostname: string; - type: "stream" | "local"; - tier: "demo" | "prod" | null; - location: "ru1" | "uae1" | null; - } | null; - user?: { - id: string; - email: string; - role: string; - displayName: string; - } | null; -} - -function SessionPage() { - const { id } = useParams(); - const navigate = useNavigate(); - - const { - data: sessionData, - isLoading, - error, - refetch, - } = useQuery({ - queryKey: ["session", id], - queryFn: async () => { - const response = await api.get(`sessions/${id}`).json<{ - session: Session; - }>(); - return response; - }, - refetchInterval: (query) => { - // Автоматически обновляем каждые 2 секунды, если сессия в процессе запуска - const data = query.state.data; - if ( - data?.session.status === "starting" || - data?.session.status === "ending" - ) { - return 2000; - } - return false; - }, - }); - - const session = sessionData?.session; - - // Перенаправление на тестовую страницу при завершении сессии - useEffect(() => { - if (session?.status === "ended") { - const timer = setTimeout(() => { - navigate("/test"); - }, 5000); - return () => clearTimeout(timer); - } - }, [session?.status, navigate]); - - if (isLoading) { - return ( -
-
-
- -
-

- Загрузка информации о сессии... -

-
-
- ); - } - - if (error || !session) { - return ( -
-
-
-
- -
-
-

Сессия не найдена

-

- {error instanceof Error - ? error.message - : "Не удалось загрузить информацию о сессии"} -

- -
-
-
-
- ); - } - - return ( -
-
- {/* Header с названием приложения */} -
-
-

- {session.app?.title || "Приложение"} -

-

- ID сессии: {session.id.slice(0, 8)}... -

-
- -
- - {/* Pixel Streaming Player - показывается когда сессия запущена */} - {session.status === "started" && - session.mode === "stream" && - session.server?.localIp && - session.playerPort && ( -
- { - console.log("Video initialized"); - }} - /> -
- )} - - {/* Основная карточка с информацией */} -
- {/* Информация о сессии */} -
-

-
- -
- Информация о сессии -

-
- - {session.tier && ( - - )} - - {session.endAt && ( - - )} - {session.appPid && ( - - )} - {session.cirrusPid && ( - - )} - {session.streamerPort && ( - - )} - {session.playerPort && ( - - )} - {session.sfuPort && ( - - )} -
-
- - {/* Информация о сервере */} - {session.server && ( -
-

-
- -
- Сервер -

-
- - - - {session.server.location && ( - - )} - {session.server.tier && ( - - )} -
-
- )} - - {/* Если сервер еще не назначен */} - {!session.server && session.status === "starting" && ( -
-

-
- -
- Сервер -

-

- Подбирается подходящий сервер... -

-
- )} - - {/* Информация о приложении */} - {session.app && ( -
-

-
- -
- Приложение -

-
- - - {session.app.gpuLimitMb && ( - - )} - {session.app.psVersion && ( - - )} -
-
- )} -
- - {/* Статусное сообщение */} - - - {/* Кнопки управления */} -
- - {(session.status === "starting" || session.status === "ending") && ( - - )} -
-
-
- ); -} - -// Компоненты помощники -function StatusBadge({ status }: { status: Session["status"] }) { - const config = { - starting: { - label: "Запускается", - color: "bg-yellow-100 text-yellow-800", - icon: , - animate: true, - }, - started: { - label: "Запущена", - color: "bg-green-100 text-green-800", - icon: , - animate: false, - }, - ending: { - label: "Завершается", - color: "bg-orange-100 text-orange-800", - icon: , - animate: true, - }, - ended: { - label: "Завершена", - color: "bg-gray-100 text-gray-800", - icon: , - animate: false, - }, - }; - - const statusConfig = config[status]; - - return ( -
-
- {statusConfig.icon} -
- {statusConfig.label} -
- ); -} - -function InfoRow({ label, value }: { label: string; value: string | number }) { - return ( -
- {label}: - - {value} - -
- ); -} - -function StatusMessage({ status }: { status: Session["status"] }) { - const messages = { - starting: { - text: "Сессия запускается. Пожалуйста, подождите...", - color: "bg-yellow-50 border-yellow-200 text-yellow-800", - icon: , - }, - started: { - text: "Сессия успешно запущена и готова к работе!", - color: "bg-green-50 border-green-200 text-green-800", - icon: , - }, - ending: { - text: "Сессия завершается...", - color: "bg-orange-50 border-orange-200 text-orange-800", - icon: , - }, - ended: { - text: "Сессия завершена. Через 5 секунд вы будете перенаправлены на главную страницу.", - color: "bg-gray-50 border-gray-200 text-gray-800", - icon: , - }, - }; - - const message = messages[status]; - - return ( -
-
{message.icon}
-

{message.text}

-
- ); -} - -// Утилиты форматирования -function formatDateTime(dateString: string): string { - const date = new Date(dateString); - return new Intl.DateTimeFormat("ru-RU", { - dateStyle: "short", - timeStyle: "medium", - }).format(date); -} - -function getModeLabel(mode: "stream" | "local"): string { - return mode === "stream" ? "Стриминг" : "Локальный"; -} - -function getTierLabel(tier: "demo" | "prod"): string { - return tier === "demo" ? "Демо" : "Продакшн"; -} - -function getLocationLabel(location: "ru1" | "uae1"): string { - const labels = { - ru1: "Россия (ru1)", - uae1: "ОАЭ (uae1)", - }; - return labels[location] || location; -} - -export default SessionPage; diff --git a/server/package.json b/server/package.json index a1a744a..8d6adaf 100644 --- a/server/package.json +++ b/server/package.json @@ -21,6 +21,7 @@ "got": "^14.4.8", "jose": "^6.1.0", "pg": "^8.16.3", + "socket.io": "^4.8.1", "uuid": "^11.1.0", "zod": "^4.1.11" }, diff --git a/server/src/index.ts b/server/src/index.ts index fe8f5d2..dc13dcc 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -6,6 +6,8 @@ import { companyController } from "./controllers/company"; import { branchController } from "./controllers/branch"; import { serverController } from "./controllers/server"; import { serverSessionService } from "./services/serverSession"; +import { Server } from "socket.io"; +import { createServer } from "http"; const app = new Elysia(); @@ -28,6 +30,199 @@ console.log( `🦊 Elysia is running at ${app.server?.hostname}:${app.server?.port}` ); +// Setup Socket.IO для WebRTC на отдельном порту +const httpServer = createServer(); +const io = new Server(httpServer, { + cors: { + origin: "*", + }, +}); + +httpServer.listen(3001, () => { + console.log("🎥 WebRTC Socket.IO server running on port 3001"); +}); + +interface Room { + id: string; + participants: Set; +} + +interface User { + id: string; + roomId?: string; + socketId: string; +} + +const rooms = new Map(); +const users = new Map(); + +// Вспомогательные функции +function findUserBySocketId(socketId: string): User | undefined { + for (const [userId, user] of users.entries()) { + if (user.socketId === socketId) { + return user; + } + } + return undefined; +} + +function findSocketIdByUserId(userId: string): string | undefined { + const user = users.get(userId); + return user?.socketId; +} + +io.on("connection", (socket) => { + console.log(`[WebRTC] User connected: ${socket.id}`); + + // Присоединение к комнате + socket.on("join-room", ({ roomId, userId }) => { + console.log(`[WebRTC] User ${userId} (socket: ${socket.id}) joining room ${roomId}`); + + // Покинуть предыдущую комнату если была + const existingUser = users.get(userId); + if (existingUser?.roomId) { + console.log( + `[WebRTC] User ${userId} leaving previous room ${existingUser.roomId}` + ); + socket.leave(existingUser.roomId); + const prevRoom = rooms.get(existingUser.roomId); + if (prevRoom) { + prevRoom.participants.delete(userId); + socket.to(existingUser.roomId).emit("user-left", userId); + } + } + + // Присоединиться к новой комнате + socket.join(roomId); + + // Создать комнату если не существует + if (!rooms.has(roomId)) { + console.log(`[WebRTC] Creating new room: ${roomId}`); + rooms.set(roomId, { + id: roomId, + participants: new Set(), + }); + } + + const room = rooms.get(roomId)!; + room.participants.add(userId); + + // Сохранить пользователя + users.set(userId, { + id: userId, + roomId, + socketId: socket.id, + }); + + console.log( + `[WebRTC] Room ${roomId} now has participants:`, + Array.from(room.participants) + ); + + // Уведомить других участников + socket.to(roomId).emit("user-joined", userId); + console.log(`[WebRTC] Notified room ${roomId} about user ${userId} joining`); + + // Отправить список участников новому пользователю + const participants = Array.from(room.participants).filter( + (id) => id !== userId + ); + console.log(`[WebRTC] Sending participant list to ${userId}:`, participants); + socket.emit("room-participants", participants); + }); + + // Покидание комнаты + socket.on("leave-room", ({ roomId, userId }) => { + console.log(`[WebRTC] User ${userId} leaving room ${roomId}`); + + socket.leave(roomId); + const room = rooms.get(roomId); + if (room) { + room.participants.delete(userId); + socket.to(roomId).emit("user-left", userId); + + // Удалить пустую комнату + if (room.participants.size === 0) { + rooms.delete(roomId); + console.log(`[WebRTC] Deleted empty room ${roomId}`); + } + } + + const user = users.get(userId); + if (user) { + user.roomId = undefined; + } + }); + + // WebRTC сигнализация + socket.on("offer", ({ target, offer }) => { + console.log(`[WebRTC] Offer from ${socket.id} to ${target}`); + const targetSocketId = findSocketIdByUserId(target); + const senderUser = findUserBySocketId(socket.id); + + if (targetSocketId && senderUser) { + socket.to(targetSocketId).emit("offer", { + offer, + sender: senderUser.id, + }); + } + }); + + socket.on("answer", ({ target, answer }) => { + console.log(`[WebRTC] Answer from ${socket.id} to ${target}`); + const targetSocketId = findSocketIdByUserId(target); + const senderUser = findUserBySocketId(socket.id); + + if (targetSocketId && senderUser) { + socket.to(targetSocketId).emit("answer", { + answer, + sender: senderUser.id, + }); + } + }); + + socket.on("ice-candidate", ({ target, candidate }) => { + console.log(`[WebRTC] ICE candidate from ${socket.id} to ${target}`); + const targetSocketId = findSocketIdByUserId(target); + const senderUser = findUserBySocketId(socket.id); + + if (targetSocketId && senderUser) { + socket.to(targetSocketId).emit("ice-candidate", { + candidate, + sender: senderUser.id, + }); + } + }); + + // Отключение + socket.on("disconnect", () => { + console.log(`[WebRTC] User disconnected: ${socket.id}`); + + // Найти пользователя по socket ID + const disconnectedUser = findUserBySocketId(socket.id); + + if (disconnectedUser) { + users.delete(disconnectedUser.id); + + if (disconnectedUser.roomId) { + const room = rooms.get(disconnectedUser.roomId); + if (room) { + room.participants.delete(disconnectedUser.id); + socket + .to(disconnectedUser.roomId) + .emit("user-left", disconnectedUser.id); + + // Удалить пустую комнату + if (room.participants.size === 0) { + rooms.delete(disconnectedUser.roomId); + console.log(`[WebRTC] Deleted empty room ${disconnectedUser.roomId}`); + } + } + } + } + }); +}); + // Запуск фоновой задачи для автоматического назначения серверов const AUTO_ASSIGN_INTERVAL_MS = parseInt( process.env.AUTO_ASSIGN_INTERVAL_MS || "1000", diff --git a/webrtc-video-chat/client/.env b/webrtc-video-chat/client/.env new file mode 100644 index 0000000..c7b43b6 --- /dev/null +++ b/webrtc-video-chat/client/.env @@ -0,0 +1,2 @@ +VITE_SERVER_URL=https://webrtc.graff.tech +# VITE_SERVER_URL=http://localhost:3000 \ No newline at end of file diff --git a/webrtc-video-chat/client/.gitignore b/webrtc-video-chat/client/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/webrtc-video-chat/client/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/webrtc-video-chat/client/README.md b/webrtc-video-chat/client/README.md new file mode 100644 index 0000000..7959ce4 --- /dev/null +++ b/webrtc-video-chat/client/README.md @@ -0,0 +1,69 @@ +# React + TypeScript + Vite + +This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. + +Currently, two official plugins are available: + +- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) for Fast Refresh +- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh + +## Expanding the ESLint configuration + +If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules: + +```js +export default tseslint.config([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + // Other configs... + + // Remove tseslint.configs.recommended and replace with this + ...tseslint.configs.recommendedTypeChecked, + // Alternatively, use this for stricter rules + ...tseslint.configs.strictTypeChecked, + // Optionally, add this for stylistic rules + ...tseslint.configs.stylisticTypeChecked, + + // Other configs... + ], + languageOptions: { + parserOptions: { + project: ['./tsconfig.node.json', './tsconfig.app.json'], + tsconfigRootDir: import.meta.dirname, + }, + // other options... + }, + }, +]) +``` + +You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules: + +```js +// eslint.config.js +import reactX from 'eslint-plugin-react-x' +import reactDom from 'eslint-plugin-react-dom' + +export default tseslint.config([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + // Other configs... + // Enable lint rules for React + reactX.configs['recommended-typescript'], + // Enable lint rules for React DOM + reactDom.configs.recommended, + ], + languageOptions: { + parserOptions: { + project: ['./tsconfig.node.json', './tsconfig.app.json'], + tsconfigRootDir: import.meta.dirname, + }, + // other options... + }, + }, +]) +``` diff --git a/webrtc-video-chat/client/bun.lock b/webrtc-video-chat/client/bun.lock new file mode 100644 index 0000000..b478401 --- /dev/null +++ b/webrtc-video-chat/client/bun.lock @@ -0,0 +1,641 @@ +{ + "lockfileVersion": 1, + "workspaces": { + "": { + "name": "client", + "dependencies": { + "lucide-react": "^0.525.0", + "react": "^19.1.0", + "react-dom": "^19.1.0", + "socket.io-client": "^4.7.5", + "uuid": "^9.0.1", + }, + "devDependencies": { + "@eslint/js": "^9.29.0", + "@types/react": "^19.1.8", + "@types/react-dom": "^19.1.6", + "@types/uuid": "^9.0.7", + "@vitejs/plugin-react-swc": "^3.10.2", + "autoprefixer": "^10.4.21", + "eslint": "^9.29.0", + "eslint-plugin-react-hooks": "^5.2.0", + "eslint-plugin-react-refresh": "^0.4.20", + "globals": "^16.2.0", + "postcss": "^8.5.6", + "tailwindcss": "3", + "typescript": "~5.8.3", + "typescript-eslint": "^8.34.1", + "vite": "^7.0.0", + }, + }, + }, + "packages": { + "@alloc/quick-lru": ["@alloc/quick-lru@5.2.0", "", {}, "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw=="], + + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.6", "", { "os": "aix", "cpu": "ppc64" }, "sha512-ShbM/3XxwuxjFiuVBHA+d3j5dyac0aEVVq1oluIDf71hUw0aRF59dV/efUsIwFnR6m8JNM2FjZOzmaZ8yG61kw=="], + + "@esbuild/android-arm": ["@esbuild/android-arm@0.25.6", "", { "os": "android", "cpu": "arm" }, "sha512-S8ToEOVfg++AU/bHwdksHNnyLyVM+eMVAOf6yRKFitnwnbwwPNqKr3srzFRe7nzV69RQKb5DgchIX5pt3L53xg=="], + + "@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.6", "", { "os": "android", "cpu": "arm64" }, "sha512-hd5zdUarsK6strW+3Wxi5qWws+rJhCCbMiC9QZyzoxfk5uHRIE8T287giQxzVpEvCwuJ9Qjg6bEjcRJcgfLqoA=="], + + "@esbuild/android-x64": ["@esbuild/android-x64@0.25.6", "", { "os": "android", "cpu": "x64" }, "sha512-0Z7KpHSr3VBIO9A/1wcT3NTy7EB4oNC4upJ5ye3R7taCc2GUdeynSLArnon5G8scPwaU866d3H4BCrE5xLW25A=="], + + "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-FFCssz3XBavjxcFxKsGy2DYK5VSvJqa6y5HXljKzhRZ87LvEi13brPrf/wdyl/BbpbMKJNOr1Sd0jtW4Ge1pAA=="], + + "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-GfXs5kry/TkGM2vKqK2oyiLFygJRqKVhawu3+DOCk7OxLy/6jYkWXhlHwOoTb0WqGnWGAS7sooxbZowy+pK9Yg=="], + + "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.6", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-aoLF2c3OvDn2XDTRvn8hN6DRzVVpDlj2B/F66clWd/FHLiHaG3aVZjxQX2DYphA5y/evbdGvC6Us13tvyt4pWg=="], + + "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.6", "", { "os": "freebsd", "cpu": "x64" }, "sha512-2SkqTjTSo2dYi/jzFbU9Plt1vk0+nNg8YC8rOXXea+iA3hfNJWebKYPs3xnOUf9+ZWhKAaxnQNUf2X9LOpeiMQ=="], + + "@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.6", "", { "os": "linux", "cpu": "arm" }, "sha512-SZHQlzvqv4Du5PrKE2faN0qlbsaW/3QQfUUc6yO2EjFcA83xnwm91UbEEVx4ApZ9Z5oG8Bxz4qPE+HFwtVcfyw=="], + + "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-b967hU0gqKd9Drsh/UuAm21Khpoh6mPBSgz8mKRq4P5mVK8bpA+hQzmm/ZwGVULSNBzKdZPQBRT3+WuVavcWsQ=="], + + "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.6", "", { "os": "linux", "cpu": "ia32" }, "sha512-aHWdQ2AAltRkLPOsKdi3xv0mZ8fUGPdlKEjIEhxCPm5yKEThcUjHpWB1idN74lfXGnZ5SULQSgtr5Qos5B0bPw=="], + + "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.6", "", { "os": "linux", "cpu": "none" }, "sha512-VgKCsHdXRSQ7E1+QXGdRPlQ/e08bN6WMQb27/TMfV+vPjjTImuT9PmLXupRlC90S1JeNNW5lzkAEO/McKeJ2yg=="], + + "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.6", "", { "os": "linux", "cpu": "none" }, "sha512-WViNlpivRKT9/py3kCmkHnn44GkGXVdXfdc4drNmRl15zVQ2+D2uFwdlGh6IuK5AAnGTo2qPB1Djppj+t78rzw=="], + + "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.6", "", { "os": "linux", "cpu": "ppc64" }, "sha512-wyYKZ9NTdmAMb5730I38lBqVu6cKl4ZfYXIs31Baf8aoOtB4xSGi3THmDYt4BTFHk7/EcVixkOV2uZfwU3Q2Jw=="], + + "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.6", "", { "os": "linux", "cpu": "none" }, "sha512-KZh7bAGGcrinEj4qzilJ4hqTY3Dg2U82c8bv+e1xqNqZCrCyc+TL9AUEn5WGKDzm3CfC5RODE/qc96OcbIe33w=="], + + "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.6", "", { "os": "linux", "cpu": "s390x" }, "sha512-9N1LsTwAuE9oj6lHMyyAM+ucxGiVnEqUdp4v7IaMmrwb06ZTEVCIs3oPPplVsnjPfyjmxwHxHMF8b6vzUVAUGw=="], + + "@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.6", "", { "os": "linux", "cpu": "x64" }, "sha512-A6bJB41b4lKFWRKNrWoP2LHsjVzNiaurf7wyj/XtFNTsnPuxwEBWHLty+ZE0dWBKuSK1fvKgrKaNjBS7qbFKig=="], + + "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.6", "", { "os": "none", "cpu": "arm64" }, "sha512-IjA+DcwoVpjEvyxZddDqBY+uJ2Snc6duLpjmkXm/v4xuS3H+3FkLZlDm9ZsAbF9rsfP3zeA0/ArNDORZgrxR/Q=="], + + "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.6", "", { "os": "none", "cpu": "x64" }, "sha512-dUXuZr5WenIDlMHdMkvDc1FAu4xdWixTCRgP7RQLBOkkGgwuuzaGSYcOpW4jFxzpzL1ejb8yF620UxAqnBrR9g=="], + + "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.6", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-l8ZCvXP0tbTJ3iaqdNf3pjaOSd5ex/e6/omLIQCVBLmHTlfXW3zAxQ4fnDmPLOB1x9xrcSi/xtCWFwCZRIaEwg=="], + + "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.6", "", { "os": "openbsd", "cpu": "x64" }, "sha512-hKrmDa0aOFOr71KQ/19JC7az1P0GWtCN1t2ahYAf4O007DHZt/dW8ym5+CUdJhQ/qkZmI1HAF8KkJbEFtCL7gw=="], + + "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.6", "", { "os": "none", "cpu": "arm64" }, "sha512-+SqBcAWoB1fYKmpWoQP4pGtx+pUUC//RNYhFdbcSA16617cchuryuhOCRpPsjCblKukAckWsV+aQ3UKT/RMPcA=="], + + "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.6", "", { "os": "sunos", "cpu": "x64" }, "sha512-dyCGxv1/Br7MiSC42qinGL8KkG4kX0pEsdb0+TKhmJZgCUDBGmyo1/ArCjNGiOLiIAgdbWgmWgib4HoCi5t7kA=="], + + "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-42QOgcZeZOvXfsCBJF5Afw73t4veOId//XD3i+/9gSkhSV6Gk3VPlWncctI+JcOyERv85FUo7RxuxGy+z8A43Q=="], + + "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.6", "", { "os": "win32", "cpu": "ia32" }, "sha512-4AWhgXmDuYN7rJI6ORB+uU9DHLq/erBbuMoAuB4VWJTu5KtCgcKYPynF0YI1VkBNuEfjNlLrFr9KZPJzrtLkrQ=="], + + "@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.6", "", { "os": "win32", "cpu": "x64" }, "sha512-NgJPHHbEpLQgDH2MjQu90pzW/5vvXIZ7KOnPyNBm92A6WgZ/7b6fJyUBjoumLqeOQQGqY2QjQxRo97ah4Sj0cA=="], + + "@eslint-community/eslint-utils": ["@eslint-community/eslint-utils@4.7.0", "", { "dependencies": { "eslint-visitor-keys": "^3.4.3" }, "peerDependencies": { "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" } }, "sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw=="], + + "@eslint-community/regexpp": ["@eslint-community/regexpp@4.12.1", "", {}, "sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ=="], + + "@eslint/config-array": ["@eslint/config-array@0.21.0", "", { "dependencies": { "@eslint/object-schema": "^2.1.6", "debug": "^4.3.1", "minimatch": "^3.1.2" } }, "sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ=="], + + "@eslint/config-helpers": ["@eslint/config-helpers@0.3.0", "", {}, "sha512-ViuymvFmcJi04qdZeDc2whTHryouGcDlaxPqarTD0ZE10ISpxGUVZGZDx4w01upyIynL3iu6IXH2bS1NhclQMw=="], + + "@eslint/core": ["@eslint/core@0.14.0", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-qIbV0/JZr7iSDjqAc60IqbLdsj9GDt16xQtWD+B78d/HAlvysGdZZ6rpJHGAc2T0FQx1X6thsSPdnoiGKdNtdg=="], + + "@eslint/eslintrc": ["@eslint/eslintrc@3.3.1", "", { "dependencies": { "ajv": "^6.12.4", "debug": "^4.3.2", "espree": "^10.0.1", "globals": "^14.0.0", "ignore": "^5.2.0", "import-fresh": "^3.2.1", "js-yaml": "^4.1.0", "minimatch": "^3.1.2", "strip-json-comments": "^3.1.1" } }, "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ=="], + + "@eslint/js": ["@eslint/js@9.30.1", "", {}, "sha512-zXhuECFlyep42KZUhWjfvsmXGX39W8K8LFb8AWXM9gSV9dQB+MrJGLKvW6Zw0Ggnbpw0VHTtrhFXYe3Gym18jg=="], + + "@eslint/object-schema": ["@eslint/object-schema@2.1.6", "", {}, "sha512-RBMg5FRL0I0gs51M/guSAj5/e14VQ4tpZnQNWwuDT66P14I43ItmPfIZRhO9fUVIPOAQXU47atlywZ/czoqFPA=="], + + "@eslint/plugin-kit": ["@eslint/plugin-kit@0.3.3", "", { "dependencies": { "@eslint/core": "^0.15.1", "levn": "^0.4.1" } }, "sha512-1+WqvgNMhmlAambTvT3KPtCl/Ibr68VldY2XY40SL1CE0ZXiakFR/cbTspaF5HsnpDMvcYYoJHfl4980NBjGag=="], + + "@humanfs/core": ["@humanfs/core@0.19.1", "", {}, "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA=="], + + "@humanfs/node": ["@humanfs/node@0.16.6", "", { "dependencies": { "@humanfs/core": "^0.19.1", "@humanwhocodes/retry": "^0.3.0" } }, "sha512-YuI2ZHQL78Q5HbhDiBA1X4LmYdXCKCMQIfw0pw7piHJwyREFebJUvrQN4cMssyES6x+vfUbx1CIpaQUKYdQZOw=="], + + "@humanwhocodes/module-importer": ["@humanwhocodes/module-importer@1.0.1", "", {}, "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA=="], + + "@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="], + + "@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="], + + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.12", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-OuLGC46TjB5BbN1dH8JULVVZY4WTdkF7tV9Ys6wLL1rubZnCMstOhNHueU5bLCrnRuDhKPDM4g6sw4Bel5Gzqg=="], + + "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], + + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.4", "", {}, "sha512-VT2+G1VQs/9oz078bLrYbecdZKs912zQlkelYpuf+SXF+QvZDYJlbx/LSx+meSAwdDFnF8FVXW92AVjjkVmgFw=="], + + "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.29", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ=="], + + "@nodelib/fs.scandir": ["@nodelib/fs.scandir@2.1.5", "", { "dependencies": { "@nodelib/fs.stat": "2.0.5", "run-parallel": "^1.1.9" } }, "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g=="], + + "@nodelib/fs.stat": ["@nodelib/fs.stat@2.0.5", "", {}, "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A=="], + + "@nodelib/fs.walk": ["@nodelib/fs.walk@1.2.8", "", { "dependencies": { "@nodelib/fs.scandir": "2.1.5", "fastq": "^1.6.0" } }, "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg=="], + + "@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="], + + "@rolldown/pluginutils": ["@rolldown/pluginutils@1.0.0-beta.11", "", {}, "sha512-L/gAA/hyCSuzTF1ftlzUSI/IKr2POHsv1Dd78GfqkR83KMNuswWD61JxGV2L7nRwBBBSDr6R1gCkdTmoN7W4ag=="], + + "@rollup/rollup-android-arm-eabi": ["@rollup/rollup-android-arm-eabi@4.44.2", "", { "os": "android", "cpu": "arm" }, "sha512-g0dF8P1e2QYPOj1gu7s/3LVP6kze9A7m6x0BZ9iTdXK8N5c2V7cpBKHV3/9A4Zd8xxavdhK0t4PnqjkqVmUc9Q=="], + + "@rollup/rollup-android-arm64": ["@rollup/rollup-android-arm64@4.44.2", "", { "os": "android", "cpu": "arm64" }, "sha512-Yt5MKrOosSbSaAK5Y4J+vSiID57sOvpBNBR6K7xAaQvk3MkcNVV0f9fE20T+41WYN8hDn6SGFlFrKudtx4EoxA=="], + + "@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.44.2", "", { "os": "darwin", "cpu": "arm64" }, "sha512-EsnFot9ZieM35YNA26nhbLTJBHD0jTwWpPwmRVDzjylQT6gkar+zenfb8mHxWpRrbn+WytRRjE0WKsfaxBkVUA=="], + + "@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.44.2", "", { "os": "darwin", "cpu": "x64" }, "sha512-dv/t1t1RkCvJdWWxQ2lWOO+b7cMsVw5YFaS04oHpZRWehI1h0fV1gF4wgGCTyQHHjJDfbNpwOi6PXEafRBBezw=="], + + "@rollup/rollup-freebsd-arm64": ["@rollup/rollup-freebsd-arm64@4.44.2", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-W4tt4BLorKND4qeHElxDoim0+BsprFTwb+vriVQnFFtT/P6v/xO5I99xvYnVzKWrK6j7Hb0yp3x7V5LUbaeOMg=="], + + "@rollup/rollup-freebsd-x64": ["@rollup/rollup-freebsd-x64@4.44.2", "", { "os": "freebsd", "cpu": "x64" }, "sha512-tdT1PHopokkuBVyHjvYehnIe20fxibxFCEhQP/96MDSOcyjM/shlTkZZLOufV3qO6/FQOSiJTBebhVc12JyPTA=="], + + "@rollup/rollup-linux-arm-gnueabihf": ["@rollup/rollup-linux-arm-gnueabihf@4.44.2", "", { "os": "linux", "cpu": "arm" }, "sha512-+xmiDGGaSfIIOXMzkhJ++Oa0Gwvl9oXUeIiwarsdRXSe27HUIvjbSIpPxvnNsRebsNdUo7uAiQVgBD1hVriwSQ=="], + + "@rollup/rollup-linux-arm-musleabihf": ["@rollup/rollup-linux-arm-musleabihf@4.44.2", "", { "os": "linux", "cpu": "arm" }, "sha512-bDHvhzOfORk3wt8yxIra8N4k/N0MnKInCW5OGZaeDYa/hMrdPaJzo7CSkjKZqX4JFUWjUGm88lI6QJLCM7lDrA=="], + + "@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.44.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-NMsDEsDiYghTbeZWEGnNi4F0hSbGnsuOG+VnNvxkKg0IGDvFh7UVpM/14mnMwxRxUf9AdAVJgHPvKXf6FpMB7A=="], + + "@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.44.2", "", { "os": "linux", "cpu": "arm64" }, "sha512-lb5bxXnxXglVq+7imxykIp5xMq+idehfl+wOgiiix0191av84OqbjUED+PRC5OA8eFJYj5xAGcpAZ0pF2MnW+A=="], + + "@rollup/rollup-linux-loongarch64-gnu": ["@rollup/rollup-linux-loongarch64-gnu@4.44.2", "", { "os": "linux", "cpu": "none" }, "sha512-Yl5Rdpf9pIc4GW1PmkUGHdMtbx0fBLE1//SxDmuf3X0dUC57+zMepow2LK0V21661cjXdTn8hO2tXDdAWAqE5g=="], + + "@rollup/rollup-linux-powerpc64le-gnu": ["@rollup/rollup-linux-powerpc64le-gnu@4.44.2", "", { "os": "linux", "cpu": "ppc64" }, "sha512-03vUDH+w55s680YYryyr78jsO1RWU9ocRMaeV2vMniJJW/6HhoTBwyyiiTPVHNWLnhsnwcQ0oH3S9JSBEKuyqw=="], + + "@rollup/rollup-linux-riscv64-gnu": ["@rollup/rollup-linux-riscv64-gnu@4.44.2", "", { "os": "linux", "cpu": "none" }, "sha512-iYtAqBg5eEMG4dEfVlkqo05xMOk6y/JXIToRca2bAWuqjrJYJlx/I7+Z+4hSrsWU8GdJDFPL4ktV3dy4yBSrzg=="], + + "@rollup/rollup-linux-riscv64-musl": ["@rollup/rollup-linux-riscv64-musl@4.44.2", "", { "os": "linux", "cpu": "none" }, "sha512-e6vEbgaaqz2yEHqtkPXa28fFuBGmUJ0N2dOJK8YUfijejInt9gfCSA7YDdJ4nYlv67JfP3+PSWFX4IVw/xRIPg=="], + + "@rollup/rollup-linux-s390x-gnu": ["@rollup/rollup-linux-s390x-gnu@4.44.2", "", { "os": "linux", "cpu": "s390x" }, "sha512-evFOtkmVdY3udE+0QKrV5wBx7bKI0iHz5yEVx5WqDJkxp9YQefy4Mpx3RajIVcM6o7jxTvVd/qpC1IXUhGc1Mw=="], + + "@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.44.2", "", { "os": "linux", "cpu": "x64" }, "sha512-/bXb0bEsWMyEkIsUL2Yt5nFB5naLAwyOWMEviQfQY1x3l5WsLKgvZf66TM7UTfED6erckUVUJQ/jJ1FSpm3pRQ=="], + + "@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.44.2", "", { "os": "linux", "cpu": "x64" }, "sha512-3D3OB1vSSBXmkGEZR27uiMRNiwN08/RVAcBKwhUYPaiZ8bcvdeEwWPvbnXvvXHY+A/7xluzcN+kaiOFNiOZwWg=="], + + "@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.44.2", "", { "os": "win32", "cpu": "arm64" }, "sha512-VfU0fsMK+rwdK8mwODqYeM2hDrF2WiHaSmCBrS7gColkQft95/8tphyzv2EupVxn3iE0FI78wzffoULH1G+dkw=="], + + "@rollup/rollup-win32-ia32-msvc": ["@rollup/rollup-win32-ia32-msvc@4.44.2", "", { "os": "win32", "cpu": "ia32" }, "sha512-+qMUrkbUurpE6DVRjiJCNGZBGo9xM4Y0FXU5cjgudWqIBWbcLkjE3XprJUsOFgC6xjBClwVa9k6O3A7K3vxb5Q=="], + + "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.44.2", "", { "os": "win32", "cpu": "x64" }, "sha512-3+QZROYfJ25PDcxFF66UEk8jGWigHJeecZILvkPkyQN7oc5BvFo4YEXFkOs154j3FTMp9mn9Ky8RCOwastduEA=="], + + "@socket.io/component-emitter": ["@socket.io/component-emitter@3.1.2", "", {}, "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA=="], + + "@swc/core": ["@swc/core@1.12.11", "", { "dependencies": { "@swc/counter": "^0.1.3", "@swc/types": "^0.1.23" }, "optionalDependencies": { "@swc/core-darwin-arm64": "1.12.11", "@swc/core-darwin-x64": "1.12.11", "@swc/core-linux-arm-gnueabihf": "1.12.11", "@swc/core-linux-arm64-gnu": "1.12.11", "@swc/core-linux-arm64-musl": "1.12.11", "@swc/core-linux-x64-gnu": "1.12.11", "@swc/core-linux-x64-musl": "1.12.11", "@swc/core-win32-arm64-msvc": "1.12.11", "@swc/core-win32-ia32-msvc": "1.12.11", "@swc/core-win32-x64-msvc": "1.12.11" }, "peerDependencies": { "@swc/helpers": ">=0.5.17" }, "optionalPeers": ["@swc/helpers"] }, "sha512-P3GM+0lqjFctcp5HhR9mOcvLSX3SptI9L1aux0Fuvgt8oH4f92rCUrkodAa0U2ktmdjcyIiG37xg2mb/dSCYSA=="], + + "@swc/core-darwin-arm64": ["@swc/core-darwin-arm64@1.12.11", "", { "os": "darwin", "cpu": "arm64" }, "sha512-J19Jj9Y5x/N0loExH7W0OI9OwwoVyxutDdkyq1o/kgXyBqmmzV7Y/Q9QekI2Fm/qc5mNeAdP7aj4boY4AY/JPw=="], + + "@swc/core-darwin-x64": ["@swc/core-darwin-x64@1.12.11", "", { "os": "darwin", "cpu": "x64" }, "sha512-PTuUQrfStQ6cjW+uprGO2lpQHy84/l0v+GqRqq8s/jdK55rFRjMfCeyf6FAR0l6saO5oNOQl+zWR1aNpj8pMQw=="], + + "@swc/core-linux-arm-gnueabihf": ["@swc/core-linux-arm-gnueabihf@1.12.11", "", { "os": "linux", "cpu": "arm" }, "sha512-poxBq152HsupOtnZilenvHmxZ9a8SRj4LtfxUnkMDNOGrZR9oxbQNwEzNKfi3RXEcXz+P8c0Rai1ubBazXv8oQ=="], + + "@swc/core-linux-arm64-gnu": ["@swc/core-linux-arm64-gnu@1.12.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-y1HNamR/D0Hc8xIE910ysyLe269UYiGaQPoLjQS0phzWFfWdMj9bHM++oydVXZ4RSWycO7KyJ3uvw4NilvyMKQ=="], + + "@swc/core-linux-arm64-musl": ["@swc/core-linux-arm64-musl@1.12.11", "", { "os": "linux", "cpu": "arm64" }, "sha512-LlBxPh/32pyQsu2emMEOFRm7poEFLsw12Y1mPY7FWZiZeptomKSOSHRzKDz9EolMiV4qhK1caP1lvW4vminYgQ=="], + + "@swc/core-linux-x64-gnu": ["@swc/core-linux-x64-gnu@1.12.11", "", { "os": "linux", "cpu": "x64" }, "sha512-bOjiZB8O/1AzHkzjge1jqX62HGRIpOHqFUrGPfAln/NC6NR+Z2A78u3ixV7k5KesWZFhCV0YVGJL+qToL27myA=="], + + "@swc/core-linux-x64-musl": ["@swc/core-linux-x64-musl@1.12.11", "", { "os": "linux", "cpu": "x64" }, "sha512-4dzAtbT/m3/UjF045+33gLiHd8aSXJDoqof7gTtu4q0ZyAf7XJ3HHspz+/AvOJLVo4FHHdFcdXhmo/zi1nFn8A=="], + + "@swc/core-win32-arm64-msvc": ["@swc/core-win32-arm64-msvc@1.12.11", "", { "os": "win32", "cpu": "arm64" }, "sha512-h8HiwBZErKvCAmjW92JvQp0iOqm6bncU4ac5jxBGkRApabpUenNJcj3h2g5O6GL5K6T9/WhnXE5gyq/s1fhPQg=="], + + "@swc/core-win32-ia32-msvc": ["@swc/core-win32-ia32-msvc@1.12.11", "", { "os": "win32", "cpu": "ia32" }, "sha512-1pwr325mXRNUhxTtXmx1IokV5SiRL+6iDvnt3FRXj+X5UvXXKtg2zeyftk+03u8v8v8WUr5I32hIypVJPTNxNg=="], + + "@swc/core-win32-x64-msvc": ["@swc/core-win32-x64-msvc@1.12.11", "", { "os": "win32", "cpu": "x64" }, "sha512-5gggWo690Gvs7XiPxAmb5tHwzB9RTVXUV7AWoGb6bmyUd1OXYaebQF0HAOtade5jIoNhfQMQJ7QReRgt/d2jAA=="], + + "@swc/counter": ["@swc/counter@0.1.3", "", {}, "sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ=="], + + "@swc/types": ["@swc/types@0.1.23", "", { "dependencies": { "@swc/counter": "^0.1.3" } }, "sha512-u1iIVZV9Q0jxY+yM2vw/hZGDNudsN85bBpTqzAQ9rzkxW9D+e3aEM4Han+ow518gSewkXgjmEK0BD79ZcNVgPw=="], + + "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], + + "@types/json-schema": ["@types/json-schema@7.0.15", "", {}, "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="], + + "@types/react": ["@types/react@19.1.8", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-AwAfQ2Wa5bCx9WP8nZL2uMZWod7J7/JSplxbTmBQ5ms6QpqNYm672H0Vu9ZVKVngQ+ii4R/byguVEUZQyeg44g=="], + + "@types/react-dom": ["@types/react-dom@19.1.6", "", { "peerDependencies": { "@types/react": "^19.0.0" } }, "sha512-4hOiT/dwO8Ko0gV1m/TJZYk3y0KBnY9vzDh7W+DH17b2HFSOGgdj33dhihPeuy3l0q23+4e+hoXHV6hCC4dCXw=="], + + "@types/uuid": ["@types/uuid@9.0.8", "", {}, "sha512-jg+97EGIcY9AGHJJRaaPVgetKDsrTgbRjQ5Msgjh/DQKEFl0DtyRr/VCOyD1T2R1MNeWPK/u7JoGhlDZnKBAfA=="], + + "@typescript-eslint/eslint-plugin": ["@typescript-eslint/eslint-plugin@8.36.0", "", { "dependencies": { "@eslint-community/regexpp": "^4.10.0", "@typescript-eslint/scope-manager": "8.36.0", "@typescript-eslint/type-utils": "8.36.0", "@typescript-eslint/utils": "8.36.0", "@typescript-eslint/visitor-keys": "8.36.0", "graphemer": "^1.4.0", "ignore": "^7.0.0", "natural-compare": "^1.4.0", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "@typescript-eslint/parser": "^8.36.0", "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-lZNihHUVB6ZZiPBNgOQGSxUASI7UJWhT8nHyUGCnaQ28XFCw98IfrMCG3rUl1uwUWoAvodJQby2KTs79UTcrAg=="], + + "@typescript-eslint/parser": ["@typescript-eslint/parser@8.36.0", "", { "dependencies": { "@typescript-eslint/scope-manager": "8.36.0", "@typescript-eslint/types": "8.36.0", "@typescript-eslint/typescript-estree": "8.36.0", "@typescript-eslint/visitor-keys": "8.36.0", "debug": "^4.3.4" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-FuYgkHwZLuPbZjQHzJXrtXreJdFMKl16BFYyRrLxDhWr6Qr7Kbcu2s1Yhu8tsiMXw1S0W1pjfFfYEt+R604s+Q=="], + + "@typescript-eslint/project-service": ["@typescript-eslint/project-service@8.36.0", "", { "dependencies": { "@typescript-eslint/tsconfig-utils": "^8.36.0", "@typescript-eslint/types": "^8.36.0", "debug": "^4.3.4" }, "peerDependencies": { "typescript": ">=4.8.4 <5.9.0" } }, "sha512-JAhQFIABkWccQYeLMrHadu/fhpzmSQ1F1KXkpzqiVxA/iYI6UnRt2trqXHt1sYEcw1mxLnB9rKMsOxXPxowN/g=="], + + "@typescript-eslint/scope-manager": ["@typescript-eslint/scope-manager@8.36.0", "", { "dependencies": { "@typescript-eslint/types": "8.36.0", "@typescript-eslint/visitor-keys": "8.36.0" } }, "sha512-wCnapIKnDkN62fYtTGv2+RY8FlnBYA3tNm0fm91kc2BjPhV2vIjwwozJ7LToaLAyb1ca8BxrS7vT+Pvvf7RvqA=="], + + "@typescript-eslint/tsconfig-utils": ["@typescript-eslint/tsconfig-utils@8.36.0", "", { "peerDependencies": { "typescript": ">=4.8.4 <5.9.0" } }, "sha512-Nhh3TIEgN18mNbdXpd5Q8mSCBnrZQeY9V7Ca3dqYvNDStNIGRmJA6dmrIPMJ0kow3C7gcQbpsG2rPzy1Ks/AnA=="], + + "@typescript-eslint/type-utils": ["@typescript-eslint/type-utils@8.36.0", "", { "dependencies": { "@typescript-eslint/typescript-estree": "8.36.0", "@typescript-eslint/utils": "8.36.0", "debug": "^4.3.4", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-5aaGYG8cVDd6cxfk/ynpYzxBRZJk7w/ymto6uiyUFtdCozQIsQWh7M28/6r57Fwkbweng8qAzoMCPwSJfWlmsg=="], + + "@typescript-eslint/types": ["@typescript-eslint/types@8.36.0", "", {}, "sha512-xGms6l5cTJKQPZOKM75Dl9yBfNdGeLRsIyufewnxT4vZTrjC0ImQT4fj8QmtJK84F58uSh5HVBSANwcfiXxABQ=="], + + "@typescript-eslint/typescript-estree": ["@typescript-eslint/typescript-estree@8.36.0", "", { "dependencies": { "@typescript-eslint/project-service": "8.36.0", "@typescript-eslint/tsconfig-utils": "8.36.0", "@typescript-eslint/types": "8.36.0", "@typescript-eslint/visitor-keys": "8.36.0", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", "minimatch": "^9.0.4", "semver": "^7.6.0", "ts-api-utils": "^2.1.0" }, "peerDependencies": { "typescript": ">=4.8.4 <5.9.0" } }, "sha512-JaS8bDVrfVJX4av0jLpe4ye0BpAaUW7+tnS4Y4ETa3q7NoZgzYbN9zDQTJ8kPb5fQ4n0hliAt9tA4Pfs2zA2Hg=="], + + "@typescript-eslint/utils": ["@typescript-eslint/utils@8.36.0", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.7.0", "@typescript-eslint/scope-manager": "8.36.0", "@typescript-eslint/types": "8.36.0", "@typescript-eslint/typescript-estree": "8.36.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-VOqmHu42aEMT+P2qYjylw6zP/3E/HvptRwdn/PZxyV27KhZg2IOszXod4NcXisWzPAGSS4trE/g4moNj6XmH2g=="], + + "@typescript-eslint/visitor-keys": ["@typescript-eslint/visitor-keys@8.36.0", "", { "dependencies": { "@typescript-eslint/types": "8.36.0", "eslint-visitor-keys": "^4.2.1" } }, "sha512-vZrhV2lRPWDuGoxcmrzRZyxAggPL+qp3WzUrlZD+slFueDiYHxeBa34dUXPuC0RmGKzl4lS5kFJYvKCq9cnNDA=="], + + "@vitejs/plugin-react-swc": ["@vitejs/plugin-react-swc@3.10.2", "", { "dependencies": { "@rolldown/pluginutils": "1.0.0-beta.11", "@swc/core": "^1.11.31" }, "peerDependencies": { "vite": "^4 || ^5 || ^6 || ^7.0.0-beta.0" } }, "sha512-xD3Rdvrt5LgANug7WekBn1KhcvLn1H3jNBfJRL3reeOIua/WnZOEV5qi5qIBq5T8R0jUDmRtxuvk4bPhzGHDWw=="], + + "acorn": ["acorn@8.15.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg=="], + + "acorn-jsx": ["acorn-jsx@5.3.2", "", { "peerDependencies": { "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ=="], + + "ajv": ["ajv@6.12.6", "", { "dependencies": { "fast-deep-equal": "^3.1.1", "fast-json-stable-stringify": "^2.0.0", "json-schema-traverse": "^0.4.1", "uri-js": "^4.2.2" } }, "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g=="], + + "ansi-regex": ["ansi-regex@6.1.0", "", {}, "sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA=="], + + "ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + + "any-promise": ["any-promise@1.3.0", "", {}, "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A=="], + + "anymatch": ["anymatch@3.1.3", "", { "dependencies": { "normalize-path": "^3.0.0", "picomatch": "^2.0.4" } }, "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw=="], + + "arg": ["arg@5.0.2", "", {}, "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg=="], + + "argparse": ["argparse@2.0.1", "", {}, "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q=="], + + "autoprefixer": ["autoprefixer@10.4.21", "", { "dependencies": { "browserslist": "^4.24.4", "caniuse-lite": "^1.0.30001702", "fraction.js": "^4.3.7", "normalize-range": "^0.1.2", "picocolors": "^1.1.1", "postcss-value-parser": "^4.2.0" }, "peerDependencies": { "postcss": "^8.1.0" }, "bin": { "autoprefixer": "bin/autoprefixer" } }, "sha512-O+A6LWV5LDHSJD3LjHYoNi4VLsj/Whi7k6zG12xTYaU4cQ8oxQGckXNX8cRHK5yOZ/ppVHe0ZBXGzSV9jXdVbQ=="], + + "balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], + + "binary-extensions": ["binary-extensions@2.3.0", "", {}, "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw=="], + + "brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="], + + "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], + + "browserslist": ["browserslist@4.25.1", "", { "dependencies": { "caniuse-lite": "^1.0.30001726", "electron-to-chromium": "^1.5.173", "node-releases": "^2.0.19", "update-browserslist-db": "^1.1.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-KGj0KoOMXLpSNkkEI6Z6mShmQy0bc1I+T7K9N81k4WWMrfz+6fQ6es80B/YLAeRoKvjYE1YSHHOW1qe9xIVzHw=="], + + "callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="], + + "camelcase-css": ["camelcase-css@2.0.1", "", {}, "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA=="], + + "caniuse-lite": ["caniuse-lite@1.0.30001727", "", {}, "sha512-pB68nIHmbN6L/4C6MH1DokyR3bYqFwjaSs/sWDHGj4CTcFtQUQMuJftVwWkXq7mNWOybD3KhUv3oWHoGxgP14Q=="], + + "chalk": ["chalk@4.1.2", "", { "dependencies": { "ansi-styles": "^4.1.0", "supports-color": "^7.1.0" } }, "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA=="], + + "chokidar": ["chokidar@3.6.0", "", { "dependencies": { "anymatch": "~3.1.2", "braces": "~3.0.2", "glob-parent": "~5.1.2", "is-binary-path": "~2.1.0", "is-glob": "~4.0.1", "normalize-path": "~3.0.0", "readdirp": "~3.6.0" }, "optionalDependencies": { "fsevents": "~2.3.2" } }, "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw=="], + + "color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], + + "color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], + + "commander": ["commander@4.1.1", "", {}, "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA=="], + + "concat-map": ["concat-map@0.0.1", "", {}, "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="], + + "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="], + + "cssesc": ["cssesc@3.0.0", "", { "bin": { "cssesc": "bin/cssesc" } }, "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="], + + "csstype": ["csstype@3.1.3", "", {}, "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="], + + "debug": ["debug@4.4.1", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-KcKCqiftBJcZr++7ykoDIEwSa3XWowTfNPo92BYxjXiyYEVrUQh2aLyhxBCwww+heortUFxEJYcRzosstTEBYQ=="], + + "deep-is": ["deep-is@0.1.4", "", {}, "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="], + + "didyoumean": ["didyoumean@1.2.2", "", {}, "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw=="], + + "dlv": ["dlv@1.1.3", "", {}, "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA=="], + + "eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="], + + "electron-to-chromium": ["electron-to-chromium@1.5.180", "", {}, "sha512-ED+GEyEh3kYMwt2faNmgMB0b8O5qtATGgR4RmRsIp4T6p7B8vdMbIedYndnvZfsaXvSzegtpfqRMDNCjjiSduA=="], + + "emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="], + + "engine.io-client": ["engine.io-client@6.6.3", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.3.1", "engine.io-parser": "~5.2.1", "ws": "~8.17.1", "xmlhttprequest-ssl": "~2.1.1" } }, "sha512-T0iLjnyNWahNyv/lcjS2y4oE358tVS/SYQNxYXGAJ9/GLgH4VCvOQ/mhTjqU88mLZCQgiG8RIegFHYCdVC+j5w=="], + + "engine.io-parser": ["engine.io-parser@5.2.3", "", {}, "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q=="], + + "esbuild": ["esbuild@0.25.6", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.6", "@esbuild/android-arm": "0.25.6", "@esbuild/android-arm64": "0.25.6", "@esbuild/android-x64": "0.25.6", "@esbuild/darwin-arm64": "0.25.6", "@esbuild/darwin-x64": "0.25.6", "@esbuild/freebsd-arm64": "0.25.6", "@esbuild/freebsd-x64": "0.25.6", "@esbuild/linux-arm": "0.25.6", "@esbuild/linux-arm64": "0.25.6", "@esbuild/linux-ia32": "0.25.6", "@esbuild/linux-loong64": "0.25.6", "@esbuild/linux-mips64el": "0.25.6", "@esbuild/linux-ppc64": "0.25.6", "@esbuild/linux-riscv64": "0.25.6", "@esbuild/linux-s390x": "0.25.6", "@esbuild/linux-x64": "0.25.6", "@esbuild/netbsd-arm64": "0.25.6", "@esbuild/netbsd-x64": "0.25.6", "@esbuild/openbsd-arm64": "0.25.6", "@esbuild/openbsd-x64": "0.25.6", "@esbuild/openharmony-arm64": "0.25.6", "@esbuild/sunos-x64": "0.25.6", "@esbuild/win32-arm64": "0.25.6", "@esbuild/win32-ia32": "0.25.6", "@esbuild/win32-x64": "0.25.6" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-GVuzuUwtdsghE3ocJ9Bs8PNoF13HNQ5TXbEi2AhvVb8xU1Iwt9Fos9FEamfoee+u/TOsn7GUWc04lz46n2bbTg=="], + + "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], + + "escape-string-regexp": ["escape-string-regexp@4.0.0", "", {}, "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA=="], + + "eslint": ["eslint@9.30.1", "", { "dependencies": { "@eslint-community/eslint-utils": "^4.2.0", "@eslint-community/regexpp": "^4.12.1", "@eslint/config-array": "^0.21.0", "@eslint/config-helpers": "^0.3.0", "@eslint/core": "^0.14.0", "@eslint/eslintrc": "^3.3.1", "@eslint/js": "9.30.1", "@eslint/plugin-kit": "^0.3.1", "@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.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", "file-entry-cache": "^8.0.0", "find-up": "^5.0.0", "glob-parent": "^6.0.2", "ignore": "^5.2.0", "imurmurhash": "^0.1.4", "is-glob": "^4.0.0", "json-stable-stringify-without-jsonify": "^1.0.1", "lodash.merge": "^4.6.2", "minimatch": "^3.1.2", "natural-compare": "^1.4.0", "optionator": "^0.9.3" }, "peerDependencies": { "jiti": "*" }, "optionalPeers": ["jiti"], "bin": { "eslint": "bin/eslint.js" } }, "sha512-zmxXPNMOXmwm9E0yQLi5uqXHs7uq2UIiqEKo3Gq+3fwo1XrJ+hijAZImyF7hclW3E6oHz43Yk3RP8at6OTKflQ=="], + + "eslint-plugin-react-hooks": ["eslint-plugin-react-hooks@5.2.0", "", { "peerDependencies": { "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0" } }, "sha512-+f15FfK64YQwZdJNELETdn5ibXEUQmW1DZL6KXhNnc2heoy/sg9VJJeT7n8TlMWouzWqSWavFkIhHyIbIAEapg=="], + + "eslint-plugin-react-refresh": ["eslint-plugin-react-refresh@0.4.20", "", { "peerDependencies": { "eslint": ">=8.40" } }, "sha512-XpbHQ2q5gUF8BGOX4dHe+71qoirYMhApEPZ7sfhF/dNnOF1UXnCMGZf79SFTBO7Bz5YEIT4TMieSlJBWhP9WBA=="], + + "eslint-scope": ["eslint-scope@8.4.0", "", { "dependencies": { "esrecurse": "^4.3.0", "estraverse": "^5.2.0" } }, "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg=="], + + "eslint-visitor-keys": ["eslint-visitor-keys@4.2.1", "", {}, "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ=="], + + "espree": ["espree@10.4.0", "", { "dependencies": { "acorn": "^8.15.0", "acorn-jsx": "^5.3.2", "eslint-visitor-keys": "^4.2.1" } }, "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ=="], + + "esquery": ["esquery@1.6.0", "", { "dependencies": { "estraverse": "^5.1.0" } }, "sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg=="], + + "esrecurse": ["esrecurse@4.3.0", "", { "dependencies": { "estraverse": "^5.2.0" } }, "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag=="], + + "estraverse": ["estraverse@5.3.0", "", {}, "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA=="], + + "esutils": ["esutils@2.0.3", "", {}, "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g=="], + + "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="], + + "fast-glob": ["fast-glob@3.3.3", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.8" } }, "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg=="], + + "fast-json-stable-stringify": ["fast-json-stable-stringify@2.1.0", "", {}, "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="], + + "fast-levenshtein": ["fast-levenshtein@2.0.6", "", {}, "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw=="], + + "fastq": ["fastq@1.19.1", "", { "dependencies": { "reusify": "^1.0.4" } }, "sha512-GwLTyxkCXjXbxqIhTsMI2Nui8huMPtnxg7krajPJAjnEG/iiOS7i+zCtWGZR9G0NBKbXKh6X9m9UIsYX/N6vvQ=="], + + "fdir": ["fdir@6.4.6", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-hiFoqpyZcfNm1yc4u8oWCf9A2c4D3QjCrks3zmoVKVxpQRzmPNar1hUJcBG2RQHvEVGDN+Jm81ZheVLAQMK6+w=="], + + "file-entry-cache": ["file-entry-cache@8.0.0", "", { "dependencies": { "flat-cache": "^4.0.0" } }, "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ=="], + + "fill-range": ["fill-range@7.1.1", "", { "dependencies": { "to-regex-range": "^5.0.1" } }, "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg=="], + + "find-up": ["find-up@5.0.0", "", { "dependencies": { "locate-path": "^6.0.0", "path-exists": "^4.0.0" } }, "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng=="], + + "flat-cache": ["flat-cache@4.0.1", "", { "dependencies": { "flatted": "^3.2.9", "keyv": "^4.5.4" } }, "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw=="], + + "flatted": ["flatted@3.3.3", "", {}, "sha512-GX+ysw4PBCz0PzosHDepZGANEuFCMLrnRTiEy9McGjmkCQYwRq4A/X786G/fjM/+OjsWSU1ZrY5qyARZmO/uwg=="], + + "foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="], + + "fraction.js": ["fraction.js@4.3.7", "", {}, "sha512-ZsDfxO51wGAXREY55a7la9LScWpwv9RxIrYABrlvOFBlH/ShPnrtsXeuUIfXKKOVicNxQ+o8JTbJvjS4M89yew=="], + + "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], + + "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], + + "glob": ["glob@10.4.5", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg=="], + + "glob-parent": ["glob-parent@6.0.2", "", { "dependencies": { "is-glob": "^4.0.3" } }, "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A=="], + + "globals": ["globals@16.3.0", "", {}, "sha512-bqWEnJ1Nt3neqx2q5SFfGS8r/ahumIakg3HcwtNlrVlwXIeNumWn/c7Pn/wKzGhf6SaW6H6uWXLqC30STCMchQ=="], + + "graphemer": ["graphemer@1.4.0", "", {}, "sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag=="], + + "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="], + + "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], + + "ignore": ["ignore@5.3.2", "", {}, "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g=="], + + "import-fresh": ["import-fresh@3.3.1", "", { "dependencies": { "parent-module": "^1.0.0", "resolve-from": "^4.0.0" } }, "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ=="], + + "imurmurhash": ["imurmurhash@0.1.4", "", {}, "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA=="], + + "is-binary-path": ["is-binary-path@2.1.0", "", { "dependencies": { "binary-extensions": "^2.0.0" } }, "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw=="], + + "is-core-module": ["is-core-module@2.16.1", "", { "dependencies": { "hasown": "^2.0.2" } }, "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w=="], + + "is-extglob": ["is-extglob@2.1.1", "", {}, "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ=="], + + "is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="], + + "is-glob": ["is-glob@4.0.3", "", { "dependencies": { "is-extglob": "^2.1.1" } }, "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg=="], + + "is-number": ["is-number@7.0.0", "", {}, "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng=="], + + "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="], + + "jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="], + + "jiti": ["jiti@1.21.7", "", { "bin": { "jiti": "bin/jiti.js" } }, "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A=="], + + "js-yaml": ["js-yaml@4.1.0", "", { "dependencies": { "argparse": "^2.0.1" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA=="], + + "json-buffer": ["json-buffer@3.0.1", "", {}, "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ=="], + + "json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], + + "json-stable-stringify-without-jsonify": ["json-stable-stringify-without-jsonify@1.0.1", "", {}, "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw=="], + + "keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="], + + "levn": ["levn@0.4.1", "", { "dependencies": { "prelude-ls": "^1.2.1", "type-check": "~0.4.0" } }, "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ=="], + + "lilconfig": ["lilconfig@3.1.3", "", {}, "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw=="], + + "lines-and-columns": ["lines-and-columns@1.2.4", "", {}, "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg=="], + + "locate-path": ["locate-path@6.0.0", "", { "dependencies": { "p-locate": "^5.0.0" } }, "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw=="], + + "lodash.merge": ["lodash.merge@4.6.2", "", {}, "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ=="], + + "lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="], + + "lucide-react": ["lucide-react@0.525.0", "", { "peerDependencies": { "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, "sha512-Tm1txJ2OkymCGkvwoHt33Y2JpN5xucVq1slHcgE6Lk0WjDfjgKWor5CdVER8U6DvcfMwh4M8XxmpTiyzfmfDYQ=="], + + "merge2": ["merge2@1.4.1", "", {}, "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg=="], + + "micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="], + + "minimatch": ["minimatch@3.1.2", "", { "dependencies": { "brace-expansion": "^1.1.7" } }, "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw=="], + + "minipass": ["minipass@7.1.2", "", {}, "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw=="], + + "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], + + "mz": ["mz@2.7.0", "", { "dependencies": { "any-promise": "^1.0.0", "object-assign": "^4.0.1", "thenify-all": "^1.0.0" } }, "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q=="], + + "nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], + + "natural-compare": ["natural-compare@1.4.0", "", {}, "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw=="], + + "node-releases": ["node-releases@2.0.19", "", {}, "sha512-xxOWJsBKtzAq7DY0J+DTzuz58K8e7sJbdgwkbMWQe8UYB6ekmsQ45q0M/tJDsGaZmbC+l7n57UV8Hl5tHxO9uw=="], + + "normalize-path": ["normalize-path@3.0.0", "", {}, "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA=="], + + "normalize-range": ["normalize-range@0.1.2", "", {}, "sha512-bdok/XvKII3nUpklnV6P2hxtMNrCboOjAcyBuQnWEhO665FwrSNRxU+AqpsyvO6LgGYPspN+lu5CLtw4jPRKNA=="], + + "object-assign": ["object-assign@4.1.1", "", {}, "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg=="], + + "object-hash": ["object-hash@3.0.0", "", {}, "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw=="], + + "optionator": ["optionator@0.9.4", "", { "dependencies": { "deep-is": "^0.1.3", "fast-levenshtein": "^2.0.6", "levn": "^0.4.1", "prelude-ls": "^1.2.1", "type-check": "^0.4.0", "word-wrap": "^1.2.5" } }, "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g=="], + + "p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="], + + "p-locate": ["p-locate@5.0.0", "", { "dependencies": { "p-limit": "^3.0.2" } }, "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw=="], + + "package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="], + + "parent-module": ["parent-module@1.0.1", "", { "dependencies": { "callsites": "^3.0.0" } }, "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g=="], + + "path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="], + + "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="], + + "path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="], + + "path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="], + + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], + + "picomatch": ["picomatch@4.0.2", "", {}, "sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg=="], + + "pify": ["pify@2.3.0", "", {}, "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog=="], + + "pirates": ["pirates@4.0.7", "", {}, "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA=="], + + "postcss": ["postcss@8.5.6", "", { "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg=="], + + "postcss-import": ["postcss-import@15.1.0", "", { "dependencies": { "postcss-value-parser": "^4.0.0", "read-cache": "^1.0.0", "resolve": "^1.1.7" }, "peerDependencies": { "postcss": "^8.0.0" } }, "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew=="], + + "postcss-js": ["postcss-js@4.0.1", "", { "dependencies": { "camelcase-css": "^2.0.1" }, "peerDependencies": { "postcss": "^8.4.21" } }, "sha512-dDLF8pEO191hJMtlHFPRa8xsizHaM82MLfNkUHdUtVEV3tgTp5oj+8qbEqYM57SLfc74KSbw//4SeJma2LRVIw=="], + + "postcss-load-config": ["postcss-load-config@4.0.2", "", { "dependencies": { "lilconfig": "^3.0.0", "yaml": "^2.3.4" }, "peerDependencies": { "postcss": ">=8.0.9", "ts-node": ">=9.0.0" }, "optionalPeers": ["postcss", "ts-node"] }, "sha512-bSVhyJGL00wMVoPUzAVAnbEoWyqRxkjv64tUl427SKnPrENtq6hJwUojroMz2VB+Q1edmi4IfrAPpami5VVgMQ=="], + + "postcss-nested": ["postcss-nested@6.2.0", "", { "dependencies": { "postcss-selector-parser": "^6.1.1" }, "peerDependencies": { "postcss": "^8.2.14" } }, "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ=="], + + "postcss-selector-parser": ["postcss-selector-parser@6.1.2", "", { "dependencies": { "cssesc": "^3.0.0", "util-deprecate": "^1.0.2" } }, "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg=="], + + "postcss-value-parser": ["postcss-value-parser@4.2.0", "", {}, "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ=="], + + "prelude-ls": ["prelude-ls@1.2.1", "", {}, "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g=="], + + "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], + + "queue-microtask": ["queue-microtask@1.2.3", "", {}, "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A=="], + + "react": ["react@19.1.0", "", {}, "sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg=="], + + "react-dom": ["react-dom@19.1.0", "", { "dependencies": { "scheduler": "^0.26.0" }, "peerDependencies": { "react": "^19.1.0" } }, "sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g=="], + + "read-cache": ["read-cache@1.0.0", "", { "dependencies": { "pify": "^2.3.0" } }, "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA=="], + + "readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="], + + "resolve": ["resolve@1.22.10", "", { "dependencies": { "is-core-module": "^2.16.0", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-NPRy+/ncIMeDlTAsuqwKIiferiawhefFJtkNSW0qZJEqMEb+qBt/77B/jGeeek+F0uOeN05CDa6HXbbIgtVX4w=="], + + "resolve-from": ["resolve-from@4.0.0", "", {}, "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g=="], + + "reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="], + + "rollup": ["rollup@4.44.2", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.44.2", "@rollup/rollup-android-arm64": "4.44.2", "@rollup/rollup-darwin-arm64": "4.44.2", "@rollup/rollup-darwin-x64": "4.44.2", "@rollup/rollup-freebsd-arm64": "4.44.2", "@rollup/rollup-freebsd-x64": "4.44.2", "@rollup/rollup-linux-arm-gnueabihf": "4.44.2", "@rollup/rollup-linux-arm-musleabihf": "4.44.2", "@rollup/rollup-linux-arm64-gnu": "4.44.2", "@rollup/rollup-linux-arm64-musl": "4.44.2", "@rollup/rollup-linux-loongarch64-gnu": "4.44.2", "@rollup/rollup-linux-powerpc64le-gnu": "4.44.2", "@rollup/rollup-linux-riscv64-gnu": "4.44.2", "@rollup/rollup-linux-riscv64-musl": "4.44.2", "@rollup/rollup-linux-s390x-gnu": "4.44.2", "@rollup/rollup-linux-x64-gnu": "4.44.2", "@rollup/rollup-linux-x64-musl": "4.44.2", "@rollup/rollup-win32-arm64-msvc": "4.44.2", "@rollup/rollup-win32-ia32-msvc": "4.44.2", "@rollup/rollup-win32-x64-msvc": "4.44.2", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-PVoapzTwSEcelaWGth3uR66u7ZRo6qhPHc0f2uRO9fX6XDVNrIiGYS0Pj9+R8yIIYSD/mCx2b16Ws9itljKSPg=="], + + "run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="], + + "scheduler": ["scheduler@0.26.0", "", {}, "sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA=="], + + "semver": ["semver@7.7.2", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-RF0Fw+rO5AMf9MAyaRXI4AV0Ulj5lMHqVxxdSgiVbixSCXoEmmX/jk0CuJw4+3SqroYO9VoUh+HcuJivvtJemA=="], + + "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], + + "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="], + + "signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="], + + "socket.io-client": ["socket.io-client@4.8.1", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.3.2", "engine.io-client": "~6.6.1", "socket.io-parser": "~4.2.4" } }, "sha512-hJVXfu3E28NmzGk8o1sHhN3om52tRvwYeidbj7xKy2eIIse5IoKX3USlS6Tqt3BHAtflLIkCQBkzVrEEfWUyYQ=="], + + "socket.io-parser": ["socket.io-parser@4.2.4", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.3.1" } }, "sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew=="], + + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + + "string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="], + + "string-width-cjs": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + + "strip-ansi": ["strip-ansi@7.1.0", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ=="], + + "strip-ansi-cjs": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + + "strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="], + + "sucrase": ["sucrase@3.35.0", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.2", "commander": "^4.0.0", "glob": "^10.3.10", "lines-and-columns": "^1.1.6", "mz": "^2.7.0", "pirates": "^4.0.1", "ts-interface-checker": "^0.1.9" }, "bin": { "sucrase": "bin/sucrase", "sucrase-node": "bin/sucrase-node" } }, "sha512-8EbVDiu9iN/nESwxeSxDKe0dunta1GOlHufmSSXxMD2z2/tMZpDMpvXQGsc+ajGo8y2uYUmixaSRUc/QPoQ0GA=="], + + "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="], + + "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="], + + "tailwindcss": ["tailwindcss@3.4.17", "", { "dependencies": { "@alloc/quick-lru": "^5.2.0", "arg": "^5.0.2", "chokidar": "^3.6.0", "didyoumean": "^1.2.2", "dlv": "^1.1.3", "fast-glob": "^3.3.2", "glob-parent": "^6.0.2", "is-glob": "^4.0.3", "jiti": "^1.21.6", "lilconfig": "^3.1.3", "micromatch": "^4.0.8", "normalize-path": "^3.0.0", "object-hash": "^3.0.0", "picocolors": "^1.1.1", "postcss": "^8.4.47", "postcss-import": "^15.1.0", "postcss-js": "^4.0.1", "postcss-load-config": "^4.0.2", "postcss-nested": "^6.2.0", "postcss-selector-parser": "^6.1.2", "resolve": "^1.22.8", "sucrase": "^3.35.0" }, "bin": { "tailwind": "lib/cli.js", "tailwindcss": "lib/cli.js" } }, "sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og=="], + + "thenify": ["thenify@3.3.1", "", { "dependencies": { "any-promise": "^1.0.0" } }, "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw=="], + + "thenify-all": ["thenify-all@1.6.0", "", { "dependencies": { "thenify": ">= 3.1.0 < 4" } }, "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA=="], + + "tinyglobby": ["tinyglobby@0.2.14", "", { "dependencies": { "fdir": "^6.4.4", "picomatch": "^4.0.2" } }, "sha512-tX5e7OM1HnYr2+a2C/4V0htOcSQcoSTH9KgJnVvNm5zm/cyEWKJ7j7YutsH9CxMdtOkkLFy2AHrMci9IM8IPZQ=="], + + "to-regex-range": ["to-regex-range@5.0.1", "", { "dependencies": { "is-number": "^7.0.0" } }, "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ=="], + + "ts-api-utils": ["ts-api-utils@2.1.0", "", { "peerDependencies": { "typescript": ">=4.8.4" } }, "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ=="], + + "ts-interface-checker": ["ts-interface-checker@0.1.13", "", {}, "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA=="], + + "type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="], + + "typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="], + + "typescript-eslint": ["typescript-eslint@8.36.0", "", { "dependencies": { "@typescript-eslint/eslint-plugin": "8.36.0", "@typescript-eslint/parser": "8.36.0", "@typescript-eslint/utils": "8.36.0" }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", "typescript": ">=4.8.4 <5.9.0" } }, "sha512-fTCqxthY+h9QbEgSIBfL9iV6CvKDFuoxg6bHPNpJ9HIUzS+jy2lCEyCmGyZRWEBSaykqcDPf1SJ+BfCI8DRopA=="], + + "update-browserslist-db": ["update-browserslist-db@1.1.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-UxhIZQ+QInVdunkDAaiazvvT/+fXL5Osr0JZlJulepYu6Jd7qJtDZjlur0emRlT71EN3ScPoE7gvsuIKKNavKw=="], + + "uri-js": ["uri-js@4.4.1", "", { "dependencies": { "punycode": "^2.1.0" } }, "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg=="], + + "util-deprecate": ["util-deprecate@1.0.2", "", {}, "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="], + + "uuid": ["uuid@9.0.1", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA=="], + + "vite": ["vite@7.0.2", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.6", "picomatch": "^4.0.2", "postcss": "^8.5.6", "rollup": "^4.40.0", "tinyglobby": "^0.2.14" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-hxdyZDY1CM6SNpKI4w4lcUc3Mtkd9ej4ECWVHSMrOdSinVc2zYOAppHeGc/hzmRo3pxM5blMzkuWHOJA/3NiFw=="], + + "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + + "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="], + + "wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="], + + "wrap-ansi-cjs": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="], + + "ws": ["ws@8.17.1", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ=="], + + "xmlhttprequest-ssl": ["xmlhttprequest-ssl@2.1.2", "", {}, "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ=="], + + "yaml": ["yaml@2.8.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-4lLa/EcQCB0cJkyts+FpIRx5G/llPxfP6VQU5KByHEhLxY3IJCH0f0Hy1MHI8sClTvsIb8qwRJ6R/ZdlDJ/leQ=="], + + "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="], + + "@eslint-community/eslint-utils/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], + + "@eslint/eslintrc/globals": ["globals@14.0.0", "", {}, "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ=="], + + "@eslint/plugin-kit/@eslint/core": ["@eslint/core@0.15.1", "", { "dependencies": { "@types/json-schema": "^7.0.15" } }, "sha512-bkOp+iumZCCbt1K1CmWf0R9pM5yKpDv+ZXtvSyQpudrI9kuFLp+bM2WOPXImuD/ceQuaa8f5pj93Y7zyECIGNA=="], + + "@humanfs/node/@humanwhocodes/retry": ["@humanwhocodes/retry@0.3.1", "", {}, "sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA=="], + + "@typescript-eslint/eslint-plugin/ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="], + + "@typescript-eslint/typescript-estree/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], + + "anymatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], + + "chokidar/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], + + "engine.io-client/debug": ["debug@4.3.7", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ=="], + + "fast-glob/glob-parent": ["glob-parent@5.1.2", "", { "dependencies": { "is-glob": "^4.0.1" } }, "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow=="], + + "glob/minimatch": ["minimatch@9.0.5", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow=="], + + "micromatch/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], + + "readdirp/picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], + + "socket.io-client/debug": ["debug@4.3.7", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ=="], + + "socket.io-parser/debug": ["debug@4.3.7", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ=="], + + "string-width-cjs/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + + "string-width-cjs/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + + "strip-ansi-cjs/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + + "wrap-ansi/ansi-styles": ["ansi-styles@6.2.1", "", {}, "sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug=="], + + "wrap-ansi-cjs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="], + + "wrap-ansi-cjs/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], + + "@typescript-eslint/typescript-estree/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], + + "glob/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], + + "string-width-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + + "wrap-ansi-cjs/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="], + + "wrap-ansi-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + } +} diff --git a/webrtc-video-chat/client/eslint.config.js b/webrtc-video-chat/client/eslint.config.js new file mode 100644 index 0000000..d94e7de --- /dev/null +++ b/webrtc-video-chat/client/eslint.config.js @@ -0,0 +1,23 @@ +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import tseslint from 'typescript-eslint' +import { globalIgnores } from 'eslint/config' + +export default tseslint.config([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + js.configs.recommended, + tseslint.configs.recommended, + reactHooks.configs['recommended-latest'], + reactRefresh.configs.vite, + ], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + }, + }, +]) diff --git a/webrtc-video-chat/client/index.html b/webrtc-video-chat/client/index.html new file mode 100644 index 0000000..96b18a3 --- /dev/null +++ b/webrtc-video-chat/client/index.html @@ -0,0 +1,27 @@ + + + + + + + + + + WebRTC Chat + + +
+ + + + + + + + diff --git a/webrtc-video-chat/client/package.json b/webrtc-video-chat/client/package.json new file mode 100644 index 0000000..bf35f4e --- /dev/null +++ b/webrtc-video-chat/client/package.json @@ -0,0 +1,36 @@ +{ + "name": "client", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "eslint .", + "preview": "vite preview" + }, + "dependencies": { + "lucide-react": "^0.525.0", + "react": "^19.1.0", + "react-dom": "^19.1.0", + "socket.io-client": "^4.7.5", + "uuid": "^9.0.1" + }, + "devDependencies": { + "@eslint/js": "^9.29.0", + "@types/react": "^19.1.8", + "@types/react-dom": "^19.1.6", + "@types/uuid": "^9.0.7", + "@vitejs/plugin-react-swc": "^3.10.2", + "autoprefixer": "^10.4.21", + "eslint": "^9.29.0", + "eslint-plugin-react-hooks": "^5.2.0", + "eslint-plugin-react-refresh": "^0.4.20", + "globals": "^16.2.0", + "postcss": "^8.5.6", + "tailwindcss": "3", + "typescript": "~5.8.3", + "typescript-eslint": "^8.34.1", + "vite": "^7.0.0" + } +} diff --git a/webrtc-video-chat/client/postcss.config.js b/webrtc-video-chat/client/postcss.config.js new file mode 100644 index 0000000..2e7af2b --- /dev/null +++ b/webrtc-video-chat/client/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/webrtc-video-chat/client/public/vite.svg b/webrtc-video-chat/client/public/vite.svg new file mode 100644 index 0000000..e7b8dfb --- /dev/null +++ b/webrtc-video-chat/client/public/vite.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/webrtc-video-chat/client/src/App.tsx b/webrtc-video-chat/client/src/App.tsx new file mode 100644 index 0000000..72564e0 --- /dev/null +++ b/webrtc-video-chat/client/src/App.tsx @@ -0,0 +1,27 @@ +import { useState } from 'react'; +import { RoomJoin } from './components/RoomJoin'; +import { VideoCall } from './components/VideoCall'; + +function App() { + const [currentRoom, setCurrentRoom] = useState(null); + + const handleJoinRoom = (roomId: string) => { + setCurrentRoom(roomId); + }; + + const handleLeaveRoom = () => { + setCurrentRoom(null); + }; + + return ( +
+ {currentRoom ? ( + + ) : ( + + )} +
+ ); +} + +export default App; diff --git a/webrtc-video-chat/client/src/components/Chat.tsx b/webrtc-video-chat/client/src/components/Chat.tsx new file mode 100644 index 0000000..2f32b85 --- /dev/null +++ b/webrtc-video-chat/client/src/components/Chat.tsx @@ -0,0 +1,138 @@ +import React, { useState, useRef, useEffect } from 'react'; +import type { ChatMessage } from '../services/webrtc'; + +interface ChatProps { + messages: ChatMessage[]; + currentUserId: string; + onSendMessage: (message: string) => void; + isConnected: boolean; +} + +export default function Chat({ messages, currentUserId, onSendMessage, isConnected }: ChatProps) { + const [inputMessage, setInputMessage] = useState(''); + const messagesEndRef = useRef(null); + const inputRef = useRef(null); + const renderCountRef = useRef(0); + + // Increment render count + renderCountRef.current++; + console.log("💬 Chat component render #", renderCountRef.current, "with", messages.length, "messages"); + + // Debug useEffect to track props changes + useEffect(() => { + console.log("💬 Chat component: messages prop updated:", messages.length, "messages"); + console.log("💬 Chat component: messages array:", messages); + }, [messages]); + + const scrollToBottom = () => { + messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); + }; + + useEffect(() => { + scrollToBottom(); + }, [messages]); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + if (inputMessage.trim() && isConnected) { + onSendMessage(inputMessage); + setInputMessage(''); + } + }; + + const formatTime = (timestamp: Date) => { + return new Date(timestamp).toLocaleTimeString([], { + hour: '2-digit', + minute: '2-digit' + }); + }; + + return ( +
+ {/* Chat Header */} +
+

Чат

+
+
+ + {isConnected ? 'Подключено' : 'Отключено'} + +
+
+ + {/* Messages Container */} +
+ {messages.length === 0 ? ( +
+

Пока нет сообщений

+

Начните общение!

+
+ ) : ( + messages.map((message) => ( +
+
+ {message.type === 'system' ? ( +

{message.content}

+ ) : ( + <> + {message.senderId !== currentUserId && ( +

+ {message.senderName || `User ${message.senderId.slice(0, 8)}`} +

+ )} +

{message.content}

+

+ {formatTime(message.timestamp)} +

+ + )} +
+
+ )) + )} +
+
+ + {/* Input Form */} +
+
+ setInputMessage(e.target.value)} + placeholder={isConnected ? "Введите сообщение..." : "Ожидание подключения..."} + disabled={!isConnected} + className="flex-1 px-3 py-2 text-sm rounded-lg border border-gray-300 sm:text-base focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:bg-gray-100 disabled:cursor-not-allowed" + maxLength={500} + /> + +
+
+ {inputMessage.length}/500 +
+
+
+ ); +} \ No newline at end of file diff --git a/webrtc-video-chat/client/src/components/RoomJoin.tsx b/webrtc-video-chat/client/src/components/RoomJoin.tsx new file mode 100644 index 0000000..f25010f --- /dev/null +++ b/webrtc-video-chat/client/src/components/RoomJoin.tsx @@ -0,0 +1,102 @@ +import React, { useState } from 'react'; + +interface RoomJoinProps { + onJoinRoom: (roomId: string) => void; +} + +export const RoomJoin: React.FC = ({ onJoinRoom }) => { + const [roomId, setRoomId] = useState(''); + const [isJoining, setIsJoining] = useState(false); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + if (roomId.trim()) { + setIsJoining(true); + try { + onJoinRoom(roomId.trim()); + } catch (error) { + console.error('Failed to join room:', error); + setIsJoining(false); + } + } + }; + + const generateRoomId = () => { + const randomId = Math.random().toString(36).substring(2, 8).toUpperCase(); + setRoomId(randomId); + }; + + return ( +
+
+
+

+ WebRTC Видео Чат +

+

+ Присоединитесь к комнате для видео конференции +

+
+ +
+
+ + setRoomId(e.target.value)} + placeholder="Введите ID комнаты" + className="w-full px-4 py-3 text-base border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent outline-none transition-colors" + required + disabled={isJoining} + /> +
+ +
+ + +
+
+ +
+

Как использовать:

+
    +
  • • Введите ID существующей комнаты для присоединения
  • +
  • • Или создайте новую комнату с случайным ID
  • +
  • • Поделитесь ID комнаты с другими участниками
  • +
+
+ +
+

+ Для работы требуется доступ к камере и микрофону +

+
+
+
+ ); +}; \ No newline at end of file diff --git a/webrtc-video-chat/client/src/components/VideoCall.tsx b/webrtc-video-chat/client/src/components/VideoCall.tsx new file mode 100644 index 0000000..3c4b822 --- /dev/null +++ b/webrtc-video-chat/client/src/components/VideoCall.tsx @@ -0,0 +1,497 @@ +import React, { useEffect, useRef, useState } from "react"; +import { + createWebRTCService, + type Participant, + type ChatMessage, +} from "../services/webrtc"; +import Chat from "./Chat"; +import { + MessagesSquare, + Mic, + MicOff, + Phone, + Video, + VideoOff, +} from "lucide-react"; + +interface VideoCallProps { + roomId: string; + onLeave: () => void; +} + +interface RemoteParticipant extends Participant { + stream: MediaStream; +} + +export const VideoCall: React.FC = ({ roomId, onLeave }) => { + const [webrtcService] = useState(() => createWebRTCService()); + const [remoteParticipants, setRemoteParticipants] = useState< + RemoteParticipant[] + >([]); + const [allParticipants, setAllParticipants] = useState>( + new Set() + ); + const [isAudioMuted, setIsAudioMuted] = useState(false); + const [isVideoMuted, setIsVideoMuted] = useState(false); + const [isConnecting, setIsConnecting] = useState(true); + const [error, setError] = useState(null); + const [hasLocalStream, setHasLocalStream] = useState(false); + const [localStream, setLocalStream] = useState(null); + const [chatMessages, setChatMessages] = useState([]); + const [chatConnected, setChatConnected] = useState(false); + const [showChat, setShowChat] = useState(true); + const renderCountRef = useRef(0); + + const localVideoRef = useRef(null); + + // Track renders + renderCountRef.current++; + console.log( + "🎥 VideoCall component render #", + renderCountRef.current, + "with", + chatMessages.length, + "chat messages" + ); + + // Отдельный useEffect для назначения stream к video элементу + useEffect(() => { + if (localVideoRef.current && localStream) { + console.log("Assigning local stream to video element"); + localVideoRef.current.srcObject = localStream; + + localVideoRef.current.play().catch((err) => { + console.error("Error playing video:", err); + }); + } + }, [localStream]); + + // Debug useEffect to track chat messages changes + useEffect(() => { + console.log( + "🔄 Chat messages state updated:", + chatMessages.length, + "messages" + ); + console.log("🔄 Full chatMessages array:", chatMessages); + chatMessages.forEach((msg, index) => { + console.log( + `🔄 Message ${index}:`, + msg.content, + "from", + msg.senderId === webrtcService.getCurrentUserId() ? "me" : "other" + ); + }); + }, [chatMessages]); + + useEffect(() => { + const initializeCall = async () => { + try { + console.log("Initializing video call..."); + + // Update callbacks FIRST, before joining room + console.log("Setting up callbacks..."); + webrtcService.updateCallbacks({ + onLocalStreamReady: (stream) => { + console.log("Local stream ready:", stream); + console.log("Video tracks:", stream.getVideoTracks()); + console.log("Audio tracks:", stream.getAudioTracks()); + + setHasLocalStream(true); + setLocalStream(stream); // Сохраняем stream в state + }, + onRemoteStreamReady: (participantId, stream) => { + console.log("Remote stream ready from:", participantId); + setRemoteParticipants((prev) => { + const existing = prev.find((p) => p.id === participantId); + if (existing) { + return prev.map((p) => + p.id === participantId ? { ...p, stream } : p + ); + } else { + return [...prev, { id: participantId, stream }]; + } + }); + }, + onRoomParticipants: (participantIds) => { + console.log( + "🔥 onRoomParticipants callback called with:", + participantIds + ); + console.log("Setting allParticipants to:", participantIds); + setAllParticipants(new Set(participantIds)); + }, + onParticipantJoined: (participant) => { + console.log( + "🔥 onParticipantJoined callback called:", + participant.id + ); + setAllParticipants((prev) => { + const newSet = new Set([...prev, participant.id]); + console.log( + "Updated allParticipants after join:", + Array.from(newSet) + ); + return newSet; + }); + }, + onParticipantLeft: (participantId) => { + console.log("🔥 onParticipantLeft callback called:", participantId); + setAllParticipants((prev) => { + const newSet = new Set(prev); + newSet.delete(participantId); + console.log( + "Updated allParticipants after leave:", + Array.from(newSet) + ); + return newSet; + }); + setRemoteParticipants((prev) => + prev.filter((p) => p.id !== participantId) + ); + }, + onChatMessage: (message) => { + console.log( + "🔔 VideoCall: Chat message callback received:", + message + ); + + // Use functional update to avoid stale closure issues + setChatMessages((prevMessages) => { + console.log( + "🔔 VideoCall: Current messages count before update:", + prevMessages.length + ); + + // Always sync with the service state to ensure consistency + const updatedMessages = webrtcService.getChatMessages(); + console.log( + "🔔 VideoCall: Messages from service:", + updatedMessages.length + ); + console.log("🔔 VideoCall: Updating state with new messages"); + + // Create a new array to ensure React detects the change + const newArray = [...updatedMessages]; + console.log( + "🔔 VideoCall: Created new array, same reference?", + newArray === updatedMessages + ); + return newArray; + }); + + console.log("🔔 VideoCall: State update triggered"); + }, + onDataChannelOpen: (participantId) => { + console.log("🔗 DataChannel opened with:", participantId); + setChatConnected(true); + + // Force sync messages when DataChannel opens + setChatMessages(() => { + const currentMessages = webrtcService.getChatMessages(); + console.log( + "🔗 Syncing messages on DataChannel open:", + currentMessages.length + ); + return [...currentMessages]; + }); + }, + onDataChannelClose: (participantId) => { + console.log("DataChannel closed with:", participantId); + // Check if any other participants have open channels + const hasOpenChannels = Array.from(allParticipants).some( + (id) => id !== participantId + ); + if (!hasOpenChannels) { + setChatConnected(false); + } + }, + onError: (error) => { + console.error("WebRTC error:", error); + setError(error.message); + setIsConnecting(false); + }, + }); + console.log("Callbacks set up complete"); + + // Initialize local stream + console.log("Initializing local stream..."); + await webrtcService.initializeLocalStream(); + + // Join room AFTER callbacks are set + console.log("Joining room:", roomId); + await webrtcService.joinRoom(roomId); + + // Initialize chat messages from service + setChatMessages(() => { + const initialMessages = webrtcService.getChatMessages(); + console.log("🚀 Initializing chat messages:", initialMessages.length); + return [...initialMessages]; + }); + + setIsConnecting(false); + console.log("Video call initialization complete"); + } catch (error) { + console.error("Failed to initialize call:", error); + setError("Не удалось получить доступ к камере или микрофону"); + setIsConnecting(false); + } + }; + + initializeCall(); + + // Cleanup on unmount + return () => { + webrtcService.cleanup(); + }; + }, [roomId, webrtcService]); + + const handleToggleAudio = () => { + const newState = webrtcService.toggleAudio(); + setIsAudioMuted(!newState); + }; + + const handleToggleVideo = () => { + const newState = webrtcService.toggleVideo(); + setIsVideoMuted(!newState); + }; + + const handleLeave = () => { + webrtcService.cleanup(); + onLeave(); + }; + + const handleSendMessage = (message: string) => { + webrtcService.sendChatMessage(message); + // Immediately update local state to reflect the sent message + setChatMessages(() => { + const updatedMessages = webrtcService.getChatMessages(); + console.log( + "📤 handleSendMessage: Updating state after send, messages:", + updatedMessages.length + ); + return [...updatedMessages]; + }); + }; + + const toggleChat = () => { + setShowChat(!showChat); + }; + + if (isConnecting) { + return ( +
+
+
+

Подключение к видео звонку...

+
+
+ ); + } + + if (error) { + return ( +
+
+
+

{error}

+ +
+
+ ); + } + + return ( +
+ {/* Main Video Area */} +
+ {/* Header */} +
+
+

+ Комната: {roomId} +

+
+ Участников: {allParticipants.size + 1} + {hasLocalStream && " • Камера подключена"} +
+
+
+
+
+ Список: [{Array.from(allParticipants).join(", ")}] +
+
+ Сервер: {import.meta.env.VITE_SERVER_URL} +
+
+
+ {allParticipants.size + 1} участников +
+ +
+
+ + {/* Video Grid */} +
+
+ {/* Local Video */} +
+
+ + {/* Remote Videos */} + {remoteParticipants.map((participant) => ( + + ))} +
+
+ + {/* Controls */} +
+ + + + + +
+
+ + {/* Chat Panel */} + {showChat && ( +
+ +
+ )} +
+ ); +}; + +interface RemoteVideoProps { + participant: RemoteParticipant; +} + +const RemoteVideo: React.FC = ({ participant }) => { + const videoRef = useRef(null); + + useEffect(() => { + if (videoRef.current && participant.stream) { + videoRef.current.srcObject = participant.stream; + videoRef.current.play().catch((err) => { + console.error("Error playing remote video:", err); + }); + } + }, [participant.stream]); + + return ( +
+
+ ); +}; diff --git a/webrtc-video-chat/client/src/index.css b/webrtc-video-chat/client/src/index.css new file mode 100644 index 0000000..0ad0996 --- /dev/null +++ b/webrtc-video-chat/client/src/index.css @@ -0,0 +1,53 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +/* Мобильные улучшения */ +@layer base { + html { + /* Предотвращение масштабирования при фокусе на input на iOS */ + -webkit-text-size-adjust: 100%; + } + + body { + /* Предотвращение отскока при прокрутке на iOS */ + overscroll-behavior: none; + /* Отключение выделения текста на мобильных устройствах */ + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; + user-select: none; + } + + /* Разрешение выделения текста для сообщений чата */ + .chat-message { + -webkit-user-select: text; + -moz-user-select: text; + -ms-user-select: text; + user-select: text; + } + + /* Разрешение выделения текста для полей ввода */ + input, textarea { + -webkit-user-select: text; + -moz-user-select: text; + -ms-user-select: text; + user-select: text; + } + + /* Улучшение тапов на мобильных устройствах */ + button, [role="button"] { + -webkit-tap-highlight-color: transparent; + touch-action: manipulation; + } + + /* Предотвращение zoom при двойном тапе */ + * { + touch-action: pan-x pan-y; + } + + /* Улучшение скроллинга на мобильных устройствах */ + .scroll-smooth { + -webkit-overflow-scrolling: touch; + } +} diff --git a/webrtc-video-chat/client/src/main.tsx b/webrtc-video-chat/client/src/main.tsx new file mode 100644 index 0000000..c983b84 --- /dev/null +++ b/webrtc-video-chat/client/src/main.tsx @@ -0,0 +1,5 @@ +import { createRoot } from "react-dom/client"; +import "./index.css"; +import App from "./App.tsx"; + +createRoot(document.getElementById("root")!).render(); diff --git a/webrtc-video-chat/client/src/services/webrtc.ts b/webrtc-video-chat/client/src/services/webrtc.ts new file mode 100644 index 0000000..e76a39d --- /dev/null +++ b/webrtc-video-chat/client/src/services/webrtc.ts @@ -0,0 +1,556 @@ +import { io, Socket } from "socket.io-client"; +import { v4 as uuidv4 } from "uuid"; + +export interface ChatMessage { + id: string; + senderId: string; + senderName?: string; + content: string; + timestamp: Date; + type: 'text' | 'system'; +} + +export interface Participant { + id: string; + stream?: MediaStream; + peerConnection?: RTCPeerConnection; + dataChannel?: RTCDataChannel; +} + +export interface WebRTCCallbacks { + onParticipantJoined?: (participant: Participant) => void; + onParticipantLeft?: (participantId: string) => void; + onLocalStreamReady?: (stream: MediaStream) => void; + onRemoteStreamReady?: (participantId: string, stream: MediaStream) => void; + onRoomParticipants?: (participantIds: string[]) => void; + onChatMessage?: (message: ChatMessage) => void; + onDataChannelOpen?: (participantId: string) => void; + onDataChannelClose?: (participantId: string) => void; + onError?: (error: Error) => void; +} + +interface WebRTCState { + socket: Socket; + localStream: MediaStream | null; + participants: Map; + roomId: string | null; + userId: string; + isAudioEnabled: boolean; + isVideoEnabled: boolean; + callbacks: WebRTCCallbacks; + chatMessages: ChatMessage[]; +} + +let state: WebRTCState | null = null; + +const ICE_SERVERS = [ + // { urls: "stun:stun.l.google.com:19302" }, + // { urls: "stun:stun1.l.google.com:19302" }, + { + urls: "turn:185.173.176.83:3478", + username: "username1", + credential: "password1", + }, +]; + +export function createWebRTCService(callbacks: WebRTCCallbacks = {}) { + if (state) { + cleanup(); + } + + console.log("Creating WebRTC service..."); + + // Используем HTTP для сервера + const serverUrl = import.meta.env.VITE_SERVER_URL; + console.log("Connecting to server:", serverUrl); + + const socket = io(serverUrl, { + // transports: ['websocket', 'polling'], + // upgrade: true, + // rememberUpgrade: true, + // timeout: 20000, + // forceNew: true + }); + const userId = uuidv4(); + console.log("Generated user ID:", userId); + + state = { + socket, + localStream: null, + participants: new Map(), + roomId: null, + userId, + isAudioEnabled: true, + isVideoEnabled: true, + callbacks, + chatMessages: [], + }; + + setupSocketListeners(); + + return { + initializeLocalStream, + joinRoom, + toggleAudio, + toggleVideo, + leaveRoom, + sendChatMessage, + getChatMessages: () => state?.chatMessages || [], + getCurrentUserId: () => state?.userId || "", + isAudioMuted: () => (state ? !state.isAudioEnabled : true), + isVideoMuted: () => (state ? !state.isVideoEnabled : true), + updateCallbacks: (newCallbacks: WebRTCCallbacks) => { + if (state) { + state.callbacks = { ...state.callbacks, ...newCallbacks }; + console.log("Updated callbacks:", Object.keys(state.callbacks)); + } + }, + cleanup, + }; +} + +function setupSocketListeners() { + if (!state) return; + + const { socket } = state; + console.log("Setting up socket listeners..."); + + socket.on("connect", () => { + console.log("Socket connected with ID:", socket.id); + }); + + socket.on("disconnect", (reason) => { + console.log("Socket disconnected, reason:", reason); + }); + + socket.on("connect_error", (error) => { + console.error("Socket connection error:", error); + }); + + socket.on("reconnect", (attemptNumber) => { + console.log("Socket reconnected after", attemptNumber, "attempts"); + }); + + socket.on("reconnect_error", (error) => { + console.error("Socket reconnection error:", error); + }); + + socket.on("room-participants", (participants: string[]) => { + console.log("Room participants received:", participants); + console.log("Current user ID:", state?.userId); + console.log("Callbacks available:", !!state?.callbacks.onRoomParticipants); + state?.callbacks.onRoomParticipants?.(participants); + participants.forEach((participantId) => { + createPeerConnection(participantId, true); + }); + }); + + socket.on("user-joined", (userId: string) => { + console.log("User joined event received:", userId); + console.log("Current user ID:", state?.userId); + console.log("Callbacks available:", !!state?.callbacks.onParticipantJoined); + if (!state) return; + + const participant: Participant = { id: userId }; + state.participants.set(userId, participant); + state.callbacks.onParticipantJoined?.(participant); + }); + + socket.on("user-left", (userId: string) => { + console.log("User left event received:", userId); + console.log("Callbacks available:", !!state?.callbacks.onParticipantLeft); + if (!state) return; + + const participant = state.participants.get(userId); + if (participant) { + participant.peerConnection?.close(); + state.participants.delete(userId); + state.callbacks.onParticipantLeft?.(userId); + } + }); + + // Добавим общий обработчик для отладки всех событий + socket.onAny((eventName, ...args) => { + console.log(`Socket event received: ${eventName}`, args); + }); + + console.log("Socket listeners set up complete"); + + socket.on( + "offer", + async ({ + offer, + sender, + }: { + offer: RTCSessionDescriptionInit; + sender: string; + }) => { + console.log("Received offer from:", sender); + await handleOffer(sender, offer); + } + ); + + socket.on( + "answer", + async ({ + answer, + sender, + }: { + answer: RTCSessionDescriptionInit; + sender: string; + }) => { + console.log("Received answer from:", sender); + await handleAnswer(sender, answer); + } + ); + + socket.on( + "ice-candidate", + async ({ + candidate, + sender, + }: { + candidate: RTCIceCandidate; + sender: string; + }) => { + console.log("Received ICE candidate from:", sender); + await handleIceCandidate(sender, candidate); + } + ); +} + +async function initializeLocalStream(): Promise { + if (!state) throw new Error("WebRTC service not initialized"); + + try { + // Проверяем доступность WebRTC API + if (!navigator.mediaDevices) { + throw new Error("WebRTC не поддерживается в этом браузере."); + } + + if (!navigator.mediaDevices.getUserMedia) { + throw new Error("getUserMedia не поддерживается в этом браузере."); + } + + console.log("Requesting media access..."); + const stream = await navigator.mediaDevices.getUserMedia({ + video: true, + audio: true, + }); + + console.log("Local stream:", stream); + + stream.getTracks().forEach((track) => { + console.log("Track:", track); + }); + + state.localStream = stream; + state.callbacks.onLocalStreamReady?.(stream); + return stream; + } catch (error) { + console.error("Error accessing media devices:", error); + + // Добавляем более подробную информацию об ошибке + let errorMessage = "Не удалось получить доступ к камере или микрофону"; + + if (error instanceof Error) { + if (error.name === "NotAllowedError") { + errorMessage = + "Доступ к камере и микрофону запрещен. Разрешите доступ в браузере."; + } else if (error.name === "NotFoundError") { + errorMessage = "Камера или микрофон не найдены."; + } else if (error.name === "NotReadableError") { + errorMessage = "Камера или микрофон заняты другим приложением."; + } + } + + state.callbacks.onError?.(new Error(errorMessage)); + throw new Error(errorMessage); + } +} + +async function joinRoom(roomId: string): Promise { + if (!state) throw new Error("WebRTC service not initialized"); + + console.log("Joining room:", roomId, "with user ID:", state.userId); + state.roomId = roomId; + state.socket.emit("join-room", { roomId, userId: state.userId }); +} + +async function createPeerConnection( + participantId: string, + isInitiator: boolean +): Promise { + if (!state) return; + + const peerConnection = new RTCPeerConnection({ iceServers: ICE_SERVERS }); + + let participant = state.participants.get(participantId); + if (!participant) { + participant = { id: participantId }; + state.participants.set(participantId, participant); + } + participant.peerConnection = peerConnection; + + // Create DataChannel for chat (only initiator creates the channel) + if (isInitiator) { + const dataChannel = peerConnection.createDataChannel("chat", { + ordered: true, + }); + participant.dataChannel = dataChannel; + setupDataChannelListeners(dataChannel, participantId); + } + + // Handle incoming DataChannel + peerConnection.ondatachannel = (event) => { + const dataChannel = event.channel; + participant!.dataChannel = dataChannel; + setupDataChannelListeners(dataChannel, participantId); + }; + + // Add local stream tracks + if (state.localStream) { + state.localStream.getTracks().forEach((track) => { + peerConnection.addTrack(track, state!.localStream!); + }); + } + + // Handle remote stream + peerConnection.ontrack = (event) => { + console.log("Received remote track from:", participantId); + const [remoteStream] = event.streams; + participant!.stream = remoteStream; + state!.callbacks.onRemoteStreamReady?.(participantId, remoteStream); + }; + + // Handle ICE candidates + peerConnection.onicecandidate = (event) => { + if (event.candidate && state) { + state.socket.emit("ice-candidate", { + target: participantId, + candidate: event.candidate, + }); + } + }; + + // Create offer if initiator + if (isInitiator) { + try { + const offer = await peerConnection.createOffer(); + await peerConnection.setLocalDescription(offer); + state.socket.emit("offer", { + target: participantId, + offer: offer, + }); + } catch (error) { + console.error("Error creating offer:", error); + state.callbacks.onError?.(error as Error); + } + } +} + +async function handleOffer( + senderId: string, + offer: RTCSessionDescriptionInit +): Promise { + if (!state) return; + + let participant = state.participants.get(senderId); + if (!participant) { + participant = { id: senderId }; + state.participants.set(senderId, participant); + } + + if (!participant.peerConnection) { + await createPeerConnection(senderId, false); + } + + const peerConnection = participant.peerConnection!; + + try { + await peerConnection.setRemoteDescription(offer); + const answer = await peerConnection.createAnswer(); + await peerConnection.setLocalDescription(answer); + + state.socket.emit("answer", { + target: senderId, + answer: answer, + }); + } catch (error) { + console.error("Error handling offer:", error); + state.callbacks.onError?.(error as Error); + } +} + +async function handleAnswer( + senderId: string, + answer: RTCSessionDescriptionInit +): Promise { + if (!state) return; + + const participant = state.participants.get(senderId); + if (participant?.peerConnection) { + try { + await participant.peerConnection.setRemoteDescription(answer); + } catch (error) { + console.error("Error handling answer:", error); + state.callbacks.onError?.(error as Error); + } + } +} + +async function handleIceCandidate( + senderId: string, + candidate: RTCIceCandidate +): Promise { + if (!state) return; + + const participant = state.participants.get(senderId); + if (participant?.peerConnection) { + try { + await participant.peerConnection.addIceCandidate(candidate); + } catch (error) { + console.error("Error handling ICE candidate:", error); + state.callbacks.onError?.(error as Error); + } + } +} + +function toggleAudio(): boolean { + if (!state?.localStream) return false; + + const audioTracks = state.localStream.getAudioTracks(); + audioTracks.forEach((track) => { + track.enabled = !track.enabled; + }); + state.isAudioEnabled = !state.isAudioEnabled; + return state.isAudioEnabled; +} + +function toggleVideo(): boolean { + if (!state?.localStream) return false; + + const videoTracks = state.localStream.getVideoTracks(); + videoTracks.forEach((track) => { + track.enabled = !track.enabled; + }); + state.isVideoEnabled = !state.isVideoEnabled; + return state.isVideoEnabled; +} + +function setupDataChannelListeners(dataChannel: RTCDataChannel, participantId: string): void { + if (!state) return; + + dataChannel.onopen = () => { + console.log("DataChannel opened with participant:", participantId); + state!.callbacks.onDataChannelOpen?.(participantId); + + // Send a test message to verify the channel is working + console.log("DataChannel ready state:", dataChannel.readyState); + }; + + dataChannel.onclose = () => { + console.log("DataChannel closed with participant:", participantId); + state!.callbacks.onDataChannelClose?.(participantId); + }; + + dataChannel.onmessage = (event) => { + try { + const message: ChatMessage = JSON.parse(event.data); + console.log("📨 Received chat message from DataChannel:", message); + console.log("📨 Current user ID:", state!.userId); + console.log("📨 Message sender ID:", message.senderId); + + // Only add messages from other participants (not our own) + if (message.senderId !== state!.userId) { + // Add to local messages + state!.chatMessages.push(message); + console.log("📨 Added message to local state, total messages:", state!.chatMessages.length); + + // Notify callback + console.log("📨 Calling onChatMessage callback"); + console.log("📨 Callback exists:", !!state!.callbacks.onChatMessage); + if (state!.callbacks.onChatMessage) { + state!.callbacks.onChatMessage(message); + console.log("📨 Callback called successfully"); + } else { + console.log("📨 No callback available!"); + } + } else { + console.log("📨 Ignoring own message"); + } + } catch (error) { + console.error("Error parsing chat message:", error); + } + }; + + dataChannel.onerror = (error) => { + console.error("DataChannel error with participant:", participantId, error); + state!.callbacks.onError?.(new Error(`DataChannel error: ${error}`)); + }; +} + +function sendChatMessage(content: string): void { + if (!state || !content.trim()) return; + + const message: ChatMessage = { + id: uuidv4(), + senderId: state.userId, + content: content.trim(), + timestamp: new Date(), + type: 'text', + }; + + // Add to local messages + state.chatMessages.push(message); + console.log("Added own message to local state, total messages:", state.chatMessages.length); + + // Send to all participants via DataChannel + console.log("📤 Sending message to participants, total participants:", state.participants.size); + state.participants.forEach((participant) => { + console.log("📤 Checking participant:", participant.id); + console.log("📤 DataChannel exists:", !!participant.dataChannel); + console.log("📤 DataChannel ready state:", participant.dataChannel?.readyState); + + if (participant.dataChannel && participant.dataChannel.readyState === 'open') { + try { + participant.dataChannel.send(JSON.stringify(message)); + console.log("📤 Successfully sent message to participant:", participant.id); + } catch (error) { + console.error("📤 Error sending chat message to participant:", participant.id, error); + } + } else { + console.log("📤 Cannot send to participant:", participant.id, "- DataChannel not ready"); + } + }); + + // Notify local callback (only for own messages to update UI) + state.callbacks.onChatMessage?.(message); +} + +function leaveRoom(): void { + if (!state) return; + + // Close all peer connections + state.participants.forEach((participant) => { + participant.peerConnection?.close(); + }); + state.participants.clear(); + + // Stop local stream + if (state.localStream) { + state.localStream.getTracks().forEach((track) => track.stop()); + state.localStream = null; + } + + // Disconnect from room + state.socket.disconnect(); + state.roomId = null; +} + +function cleanup(): void { + if (state) { + leaveRoom(); + state = null; + } +} diff --git a/webrtc-video-chat/client/src/vite-env.d.ts b/webrtc-video-chat/client/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/webrtc-video-chat/client/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/webrtc-video-chat/client/tailwind.config.js b/webrtc-video-chat/client/tailwind.config.js new file mode 100644 index 0000000..d37737f --- /dev/null +++ b/webrtc-video-chat/client/tailwind.config.js @@ -0,0 +1,12 @@ +/** @type {import('tailwindcss').Config} */ +export default { + content: [ + "./index.html", + "./src/**/*.{js,ts,jsx,tsx}", + ], + theme: { + extend: {}, + }, + plugins: [], +} + diff --git a/webrtc-video-chat/client/tsconfig.app.json b/webrtc-video-chat/client/tsconfig.app.json new file mode 100644 index 0000000..227a6c6 --- /dev/null +++ b/webrtc-video-chat/client/tsconfig.app.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo", + "target": "ES2022", + "useDefineForClassFields": true, + "lib": ["ES2022", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["src"] +} diff --git a/webrtc-video-chat/client/tsconfig.json b/webrtc-video-chat/client/tsconfig.json new file mode 100644 index 0000000..1ffef60 --- /dev/null +++ b/webrtc-video-chat/client/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/webrtc-video-chat/client/tsconfig.node.json b/webrtc-video-chat/client/tsconfig.node.json new file mode 100644 index 0000000..f85a399 --- /dev/null +++ b/webrtc-video-chat/client/tsconfig.node.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "ES2023", + "lib": ["ES2023"], + "module": "ESNext", + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true, + "noUncheckedSideEffectImports": true + }, + "include": ["vite.config.ts"] +} diff --git a/webrtc-video-chat/client/vite.config.ts b/webrtc-video-chat/client/vite.config.ts new file mode 100644 index 0000000..d204840 --- /dev/null +++ b/webrtc-video-chat/client/vite.config.ts @@ -0,0 +1,13 @@ +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react-swc' + +// https://vitejs.dev/config/ +export default defineConfig({ + plugins: [ + react() + ], + server: { + host: '0.0.0.0', // Разрешить доступ из локальной сети + port: 5173 + } +}) diff --git a/webrtc-video-chat/server b/webrtc-video-chat/server new file mode 160000 index 0000000..a5e7379 --- /dev/null +++ b/webrtc-video-chat/server @@ -0,0 +1 @@ +Subproject commit a5e737960ba86e066b1313058b6c22de92d70d34