Update environment configuration for local development, add new dependencies for WebRTC functionality, and refactor user session management components. Remove deprecated SessionUsersPanel and enhance SessionUsersPanel2 with improved user handling and controls. Integrate socket.io for real-time communication in the server.
This commit is contained in:
+3
-1
@@ -1,2 +1,4 @@
|
||||
# VITE_API_URL=http://192.168.1.23:3000
|
||||
VITE_API_URL=http://192.168.1.224:3000
|
||||
# VITE_API_URL=http://192.168.1.224:3000
|
||||
VITE_API_URL=http://localhost:3000
|
||||
VITE_WEBRTC_URL=http://localhost:3001
|
||||
Generated
+343
-5
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<HTMLDivElement>(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 (
|
||||
<div
|
||||
ref={containerRef}
|
||||
onMouseDown={handleStart}
|
||||
onTouchStart={handleStart}
|
||||
className="flex absolute gap-4 active:cursor-grabbing cursor-grab"
|
||||
style={style}
|
||||
>
|
||||
<div
|
||||
className={clsx(
|
||||
"flex gap-4 w-max",
|
||||
corner.left ? "flex-row-reverse" : "flex-row",
|
||||
corner.top ? "items-start" : "items-end"
|
||||
)}
|
||||
>
|
||||
{users.map((user) => (
|
||||
<UserCamera
|
||||
key={user.id}
|
||||
onMute={() => console.log(`Mute user ${user.id}`)}
|
||||
onVideoOff={() => console.log(`Video off user ${user.id}`)}
|
||||
onCanControl={() => console.log(`Can control user ${user.id}`)}
|
||||
{...user}
|
||||
/>
|
||||
))}
|
||||
<UserDevicesControls />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<DraggableContainer
|
||||
enableSnapping={true}
|
||||
@@ -39,17 +31,45 @@ function SessionUsersPanel2() {
|
||||
padding="1.111vw"
|
||||
className="flex gap-4 z-[999]"
|
||||
>
|
||||
{users.map((user) => (
|
||||
{/* Локальная камера пользователя */}
|
||||
<UserCamera
|
||||
name="Вы"
|
||||
isSpeaking={false}
|
||||
isMuted={isLocalAudioMuted}
|
||||
isVideoOff={isLocalVideoMuted}
|
||||
isControlDisabled={false}
|
||||
isAdmin={true}
|
||||
isLocal={true}
|
||||
mediaStream={localStream}
|
||||
onMute={toggleAudio}
|
||||
onVideoOff={toggleVideo}
|
||||
onCanControl={() => console.log("Toggle control")}
|
||||
/>
|
||||
|
||||
{/* Камеры удаленных участников */}
|
||||
{participants.map((participant) => (
|
||||
<UserCamera
|
||||
key={user.id}
|
||||
onMute={() => 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}`)}
|
||||
/>
|
||||
))}
|
||||
|
||||
<UserDevicesControls />
|
||||
<UserDevicesControls
|
||||
toggleAudio={toggleAudio}
|
||||
toggleVideo={toggleVideo}
|
||||
isAudioMuted={isLocalAudioMuted}
|
||||
isVideoMuted={isLocalVideoMuted}
|
||||
hasLocalStream={hasLocalStream}
|
||||
/>
|
||||
</DraggableContainer>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
function VolumeIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M9.583 3.75a.833.833 0 0 0-.916.183l-3.5 3.5a.833.833 0 0 0-.584.244H3.333a.833.833 0 0 0-.833.833v3.333a.833.833 0 0 0 .833.834h1.25a.833.833 0 0 0 .584.243l3.5 3.5a.833.833 0 0 0 1.416-.583V4.167a.833.833 0 0 0-.5-.417"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M13.096 6.905a.625.625 0 1 0-.884.884A3.333 3.333 0 0 1 13.333 10a3.333 3.333 0 0 1-1.121 2.212.625.625 0 1 0 .884.883A4.583 4.583 0 0 0 14.583 10a4.583 4.583 0 0 0-1.487-3.095"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M14.762 5.238a.625.625 0 0 0-.884.884A5.833 5.833 0 0 1 15.833 10a5.833 5.833 0 0 1-1.955 4.378.625.625 0 0 0 .884.884A7.083 7.083 0 0 0 17.083 10a7.083 7.083 0 0 0-2.321-5.262"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default VolumeIcon;
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
function VolumeOffIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path
|
||||
d="M9.583 3.75a.833.833 0 0 0-.916.183l-3.5 3.5a.833.833 0 0 0-.584.244H3.333a.833.833 0 0 0-.833.833v3.333a.833.833 0 0 0 .833.834h1.25a.833.833 0 0 0 .584.243l3.5 3.5a.833.833 0 0 0 1.416-.583V4.167a.833.833 0 0 0-.5-.417"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M13.232 7.768a.625.625 0 1 0-.884.884L14.116 10l-1.768 1.768a.625.625 0 1 0 .884.884L15 11.116l1.768 1.768a.625.625 0 1 0 .884-.884L15.884 10l1.768-1.768a.625.625 0 1 0-.884-.884L15 9.116z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export default VolumeOffIcon;
|
||||
|
||||
@@ -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<HTMLDivElement>(null);
|
||||
const [messages, setMessages] = useState<MessageItemProps[]>([
|
||||
{
|
||||
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() {
|
||||
>
|
||||
<PopupWrapper title="Чат" className="sm:overflow-hidden" headerRef={headerRef}>
|
||||
<div className="flex flex-col 2xl:h-[19.444vw] max-sm:h-[87.5dvh] 2xl:-m-[1.389vw] -m-5">
|
||||
<MessageFeed messages={messages} />
|
||||
<MessageFeed messages={chatMessages} currentUserId={currentUserId} />
|
||||
<MessageInput onMessageSend={onMessageSend} />
|
||||
</div>
|
||||
</PopupWrapper>
|
||||
@@ -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<HTMLDivElement>(null);
|
||||
|
||||
// Скролл к концу при получении нового сообщения
|
||||
@@ -76,8 +68,16 @@ function MessageFeed({ messages }: { messages: MessageItemProps[] }) {
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col 2xl:gap-[1.111vw] gap-4 items-end mt-auto">
|
||||
{messages.map((message, index) => (
|
||||
<MessageItem key={index} {...message} />
|
||||
{messages.map((message) => (
|
||||
<MessageItem
|
||||
key={message.id}
|
||||
content={message.content}
|
||||
timestamp={new Date(message.timestamp).toLocaleTimeString('ru-RU', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})}
|
||||
isFromMe={message.senderId === currentUserId}
|
||||
/>
|
||||
))}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
@@ -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 (
|
||||
<div
|
||||
className={clsx(
|
||||
|
||||
@@ -7,14 +7,25 @@ import XMarkFilledIcon from "../icons/XMarkFilledIcon";
|
||||
import Avatar from "../ui/Avatar";
|
||||
import Button from "../ui/Button";
|
||||
import ShareFilledIcon from "../icons/ShareFilledIcon";
|
||||
import HandRaisedOffFilledIcon from "../icons/HandRaisedOffFilledIcon";
|
||||
import MicrophoneOffFilledIcon from "../icons/MicrophoneOffFilledIcon";
|
||||
import { Fragment, useRef } from "react";
|
||||
import DraggableContainer from "../DraggableContainer";
|
||||
import { useWebRTC } from "../../hooks/useWebRTC";
|
||||
import type { Participant } from "../../lib/webrtc";
|
||||
|
||||
export default function ParticipantsPopup() {
|
||||
const participants = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
|
||||
const { participants, currentUserId, localStream } = useWebRTC();
|
||||
const headerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Добавляем локального пользователя в начало списка
|
||||
const allParticipants: (Participant & { isLocal?: boolean })[] = [
|
||||
{
|
||||
id: currentUserId,
|
||||
stream: localStream || undefined,
|
||||
isLocal: true,
|
||||
},
|
||||
...participants,
|
||||
];
|
||||
|
||||
return (
|
||||
<DraggableContainer
|
||||
@@ -27,12 +38,21 @@ export default function ParticipantsPopup() {
|
||||
<PopupWrapper title="Участники" headerRef={headerRef}>
|
||||
<div className="flex flex-col 2xl:gap-[1.667vw] gap-6 2xl:-mt-[1.389vw] -mt-5">
|
||||
<div className="flex flex-col gap-4 2xl:gap-[1.111vw] 2xl:max-h-[calc(11.944vw+1.389vw)] max-h-[73.75dvh] overflow-y-auto 2xl:pt-[1.389vw] pt-5">
|
||||
{participants.map((participant, index) => (
|
||||
<Fragment key={index}>
|
||||
<ParticipantItem id={participant.toString()} />
|
||||
<hr className="w-full 2xl:h-[0.69vw] h-px border-[#F6F6F6] last:hidden" />
|
||||
</Fragment>
|
||||
))}
|
||||
{allParticipants.length === 0 ? (
|
||||
<div className="text-center text-gray-500 py-8">
|
||||
Нет участников
|
||||
</div>
|
||||
) : (
|
||||
allParticipants.map((participant) => (
|
||||
<Fragment key={participant.id}>
|
||||
<ParticipantItem
|
||||
participant={participant}
|
||||
isLocal={participant.isLocal || false}
|
||||
/>
|
||||
<hr className="w-full 2xl:h-[0.69vw] h-px border-[#F6F6F6] last:hidden" />
|
||||
</Fragment>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="primary"
|
||||
@@ -59,59 +79,85 @@ export default function ParticipantsPopup() {
|
||||
);
|
||||
}
|
||||
|
||||
function ParticipantItem({ id }: { id: string }) {
|
||||
const isMuted = true;
|
||||
const isNotControlling = true;
|
||||
|
||||
function ParticipantItem({
|
||||
participant,
|
||||
isLocal
|
||||
}: {
|
||||
participant: Participant & { isLocal?: boolean };
|
||||
isLocal: boolean;
|
||||
}) {
|
||||
const parentRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Проверяем наличие аудио/видео треков
|
||||
const hasAudio = participant.stream?.getAudioTracks().some(track => track.enabled) ?? false;
|
||||
const hasVideo = participant.stream?.getVideoTracks().some(track => track.enabled) ?? false;
|
||||
const isMuted = !hasAudio;
|
||||
const isVideoOff = !hasVideo;
|
||||
|
||||
// Определяем статус участника
|
||||
const status: "admin" | "caution" | undefined = participant.stream ? "admin" : "caution";
|
||||
|
||||
return (
|
||||
<div ref={parentRef} className="flex items-center justify-between w-full">
|
||||
<div ref={parentRef} className="flex justify-between items-center w-full">
|
||||
<div className="flex items-center 2xl:gap-[0.833vw] gap-3">
|
||||
<Avatar size="medium" status="caution" />
|
||||
<Avatar size="medium" status={status} />
|
||||
<div className="flex flex-col 2xl:gap-[0.278vw] gap-1">
|
||||
<span className="button-m">Иван Иванович {id}</span>
|
||||
<span className="caption-s text-[#CCCCCC]">Роль</span>
|
||||
<span className="button-m">
|
||||
{isLocal ? "Вы" : participant.name || `Участник ${participant.id.slice(0, 8)}`}
|
||||
</span>
|
||||
<span className="caption-s text-[#CCCCCC]">
|
||||
{isLocal ? "Организатор" : "Участник"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex 2xl:gap-[0.556vw] gap-2 items-center">
|
||||
{isNotControlling && (
|
||||
<div className="2xl:size-[1.111vw] size-4 text-[#FF4517]">
|
||||
<HandRaisedOffFilledIcon />
|
||||
</div>
|
||||
)}
|
||||
{isMuted && (
|
||||
<div className="2xl:size-[1.111vw] size-4 text-[#FF4517]">
|
||||
<MicrophoneOffFilledIcon />
|
||||
</div>
|
||||
)}
|
||||
{isVideoOff && (
|
||||
<div className="2xl:size-[1.111vw] size-4 text-[#FF4517]">
|
||||
<VideoOffFilledIcon />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ActionsPopover
|
||||
options={[
|
||||
{
|
||||
icon: <MicrophoneFilledIcon />,
|
||||
label: "Выключить микрофон",
|
||||
onClick: () => {},
|
||||
},
|
||||
{
|
||||
icon: <VideoOffFilledIcon />,
|
||||
label: "Выключить камеру",
|
||||
onClick: () => {},
|
||||
disabled: true,
|
||||
},
|
||||
{
|
||||
icon: <HandRaisedFilledIcon />,
|
||||
label: "Передать управление",
|
||||
onClick: () => {},
|
||||
},
|
||||
{
|
||||
icon: <XMarkFilledIcon />,
|
||||
label: "Удалить со встречи",
|
||||
onClick: () => {},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
{/* Действия только для удаленных участников и только для администратора */}
|
||||
{!isLocal && (
|
||||
<ActionsPopover
|
||||
options={[
|
||||
{
|
||||
icon: <MicrophoneFilledIcon />,
|
||||
label: "Выключить микрофон",
|
||||
onClick: () => {
|
||||
console.log("Mute participant:", participant.id);
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: <VideoOffFilledIcon />,
|
||||
label: "Выключить камеру",
|
||||
onClick: () => {
|
||||
console.log("Turn off video:", participant.id);
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: <HandRaisedFilledIcon />,
|
||||
label: "Передать управление",
|
||||
onClick: () => {
|
||||
console.log("Grant control:", participant.id);
|
||||
},
|
||||
},
|
||||
{
|
||||
icon: <XMarkFilledIcon />,
|
||||
label: "Удалить со встречи",
|
||||
onClick: () => {
|
||||
console.log("Remove participant:", participant.id);
|
||||
},
|
||||
},
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import HandRaisedOffFilledIcon from "../icons/HandRaisedOffFilledIcon";
|
||||
import HandRaisedFilledIcon from "../icons/HandRaisedFilledIcon";
|
||||
import MicrophoneFilledIcon from "../icons/MicrophoneFilledIcon";
|
||||
@@ -8,21 +8,31 @@ import VideoFilledIcon from "../icons/VideoFilledIcon";
|
||||
import ControlButton from "./ControlButton";
|
||||
import Admin from "../indicators/Admin";
|
||||
import clsx from "clsx";
|
||||
import VolumeIcon from "../icons/VolumeIcon";
|
||||
import VolumeOffIcon from "../icons/VolumeOffIcon";
|
||||
|
||||
interface UserCameraControlsProps {
|
||||
isMuted: boolean;
|
||||
isVideoOff: boolean;
|
||||
isControlDisabled: boolean;
|
||||
isAdmin: boolean;
|
||||
onMute: () => void;
|
||||
onVideoOff: () => void;
|
||||
onCanControl: () => void;
|
||||
}
|
||||
|
||||
interface UserCameraProps {
|
||||
isMuted: boolean;
|
||||
isVideoOff: boolean;
|
||||
isControlDisabled: boolean;
|
||||
onMute: () => void;
|
||||
onVideoOff: () => void;
|
||||
onCanControl: () => void;
|
||||
}
|
||||
|
||||
interface UserCameraProps extends UserCameraControlsProps {
|
||||
isAdmin?: boolean;
|
||||
name?: string;
|
||||
mediaStream?: MediaStream | null;
|
||||
isSpeaking?: boolean;
|
||||
isLocal?: boolean;
|
||||
}
|
||||
|
||||
export default function UserCamera({
|
||||
@@ -36,27 +46,185 @@ export default function UserCamera({
|
||||
isAdmin = false,
|
||||
name = "Гость",
|
||||
mediaStream = null,
|
||||
isLocal = false,
|
||||
}: UserCameraProps) {
|
||||
const ref = useRef<HTMLVideoElement>(null);
|
||||
const [isAudioMuted, setIsAudioMuted] = useState(true); // Для удаленных участников - начинаем с muted
|
||||
|
||||
useEffect(() => {
|
||||
if (ref.current) {
|
||||
if (ref.current && mediaStream) {
|
||||
console.log(
|
||||
`[UserCamera] Setting srcObject for ${name}, isLocal: ${isLocal}, stream:`,
|
||||
mediaStream
|
||||
);
|
||||
ref.current.srcObject = mediaStream;
|
||||
|
||||
// Убеждаемся что видео muted для autoplay
|
||||
if (!isLocal) {
|
||||
ref.current.muted = true;
|
||||
console.log(`[UserCamera] Set muted=true for remote video ${name}`);
|
||||
}
|
||||
|
||||
// Принудительно запускаем воспроизведение
|
||||
ref.current.play().catch((error) => {
|
||||
console.error(`[UserCamera] Failed to play video for ${name}:`, error);
|
||||
});
|
||||
|
||||
// Дополнительная попытка воспроизведения с задержкой для Firefox
|
||||
if (!isLocal) {
|
||||
// Попытка через 500ms
|
||||
setTimeout(() => {
|
||||
if (ref.current) {
|
||||
console.log(`[UserCamera] First retry for ${name}, paused: ${ref.current.paused}, readyState: ${ref.current.readyState}`);
|
||||
if (ref.current.paused) {
|
||||
ref.current.play().catch((error) => {
|
||||
console.error(`[UserCamera] First retry play failed for ${name}:`, error);
|
||||
});
|
||||
}
|
||||
}
|
||||
}, 500);
|
||||
|
||||
// Попытка через 1 секунду
|
||||
setTimeout(() => {
|
||||
if (ref.current && ref.current.paused) {
|
||||
console.log(`[UserCamera] Second retry for ${name} after timeout`);
|
||||
ref.current.play().catch((error) => {
|
||||
console.error(`[UserCamera] Second retry play failed for ${name}:`, error);
|
||||
});
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
// Еще одна попытка через 3 секунды
|
||||
setTimeout(() => {
|
||||
if (ref.current) {
|
||||
console.log(`[UserCamera] Final retry for ${name}, paused: ${ref.current.paused}, readyState: ${ref.current.readyState}`);
|
||||
if (ref.current.paused) {
|
||||
ref.current.play().catch((error) => {
|
||||
console.error(`[UserCamera] Final retry play failed for ${name}:`, error);
|
||||
});
|
||||
}
|
||||
}
|
||||
}, 3000);
|
||||
}
|
||||
} else if (ref.current && !mediaStream) {
|
||||
console.log(`[UserCamera] Clearing srcObject for ${name}`);
|
||||
ref.current.srcObject = null;
|
||||
}
|
||||
}, [mediaStream]);
|
||||
}, [mediaStream, name, isLocal]);
|
||||
|
||||
// Добавляем обработчики событий для отладки
|
||||
useEffect(() => {
|
||||
const videoElement = ref.current;
|
||||
if (!videoElement) return;
|
||||
|
||||
const handleLoadStart = () => {
|
||||
console.log(`[UserCamera] ${name} video loadstart`);
|
||||
};
|
||||
|
||||
const handleLoadedData = () => {
|
||||
console.log(`[UserCamera] ${name} video loadeddata`);
|
||||
};
|
||||
|
||||
const handleCanPlay = () => {
|
||||
console.log(`[UserCamera] ${name} video canplay`);
|
||||
};
|
||||
|
||||
const handleLoadedMetadata = () => {
|
||||
console.log(`[UserCamera] ${name} video loadedmetadata`);
|
||||
};
|
||||
|
||||
const handleCanPlayThrough = () => {
|
||||
console.log(`[UserCamera] ${name} video canplaythrough`);
|
||||
};
|
||||
|
||||
const handlePlay = () => {
|
||||
console.log(`[UserCamera] ${name} video play event`);
|
||||
};
|
||||
|
||||
const handlePlaying = () => {
|
||||
console.log(`[UserCamera] ${name} video playing event`);
|
||||
};
|
||||
|
||||
const handleWaiting = () => {
|
||||
console.log(`[UserCamera] ${name} video waiting event, paused: ${videoElement.paused}, readyState: ${videoElement.readyState}`);
|
||||
};
|
||||
|
||||
const handleStalled = () => {
|
||||
console.log(`[UserCamera] ${name} video stalled event, paused: ${videoElement.paused}, readyState: ${videoElement.readyState}`);
|
||||
};
|
||||
|
||||
const handlePause = () => {
|
||||
console.log(`[UserCamera] ${name} video pause event`);
|
||||
};
|
||||
|
||||
const handleError = (e: Event) => {
|
||||
console.error(`[UserCamera] ${name} video error:`, e);
|
||||
};
|
||||
|
||||
videoElement.addEventListener('loadstart', handleLoadStart);
|
||||
videoElement.addEventListener('loadeddata', handleLoadedData);
|
||||
videoElement.addEventListener('loadedmetadata', handleLoadedMetadata);
|
||||
videoElement.addEventListener('canplay', handleCanPlay);
|
||||
videoElement.addEventListener('canplaythrough', handleCanPlayThrough);
|
||||
videoElement.addEventListener('play', handlePlay);
|
||||
videoElement.addEventListener('playing', handlePlaying);
|
||||
videoElement.addEventListener('waiting', handleWaiting);
|
||||
videoElement.addEventListener('stalled', handleStalled);
|
||||
videoElement.addEventListener('pause', handlePause);
|
||||
videoElement.addEventListener('error', handleError);
|
||||
|
||||
return () => {
|
||||
videoElement.removeEventListener('loadstart', handleLoadStart);
|
||||
videoElement.removeEventListener('loadeddata', handleLoadedData);
|
||||
videoElement.removeEventListener('loadedmetadata', handleLoadedMetadata);
|
||||
videoElement.removeEventListener('canplay', handleCanPlay);
|
||||
videoElement.removeEventListener('canplaythrough', handleCanPlayThrough);
|
||||
videoElement.removeEventListener('play', handlePlay);
|
||||
videoElement.removeEventListener('playing', handlePlaying);
|
||||
videoElement.removeEventListener('waiting', handleWaiting);
|
||||
videoElement.removeEventListener('stalled', handleStalled);
|
||||
videoElement.removeEventListener('pause', handlePause);
|
||||
videoElement.removeEventListener('error', handleError);
|
||||
};
|
||||
}, [name]);
|
||||
|
||||
const toggleRemoteAudio = () => {
|
||||
if (!isLocal && ref.current) {
|
||||
const newMutedState = !isAudioMuted;
|
||||
ref.current.muted = newMutedState;
|
||||
setIsAudioMuted(newMutedState);
|
||||
console.log(
|
||||
`[UserCamera] ${name} audio ${newMutedState ? "muted" : "unmuted"}, video element muted: ${ref.current.muted}`
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handleVideoClick = () => {
|
||||
if (!isLocal && ref.current) {
|
||||
console.log(`[UserCamera] User clicked on ${name} video, paused: ${ref.current.paused}, readyState: ${ref.current.readyState}, muted: ${ref.current.muted}`);
|
||||
if (ref.current.paused) {
|
||||
ref.current.play().catch((error) => {
|
||||
console.error(`[UserCamera] Click play failed for ${name}:`, error);
|
||||
});
|
||||
} else {
|
||||
console.log(`[UserCamera] Video ${name} is already playing`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
"aspect-square h-fit group 2xl:rounded-[1.667vw] rounded-2xl relative flex-shrink-0 transition-[width,box-shadow,background-color] duration-300 pointer-events-auto hover:w-[10.833vw] w-[6.944vw] shadow-[0_4px_40px_0_rgba(15,16,17,0.1),0_2px_2px_0_rgba(0,0,0,0.06)]",
|
||||
isAdmin && "order-last",
|
||||
"aspect-square h-fit group 2xl:rounded-[1.667vw] rounded-2xl relative flex-shrink-0 transition-[width,box-shadow,background-color] duration-300 pointer-events-auto hover:w-[10.833vw] w-[6.944vw] shadow-[0_4px_40px_0_rgba(15,16,17,0.1),0_2px_2px_0_rgba(0,0,0,0.06)] overflow-hidden",
|
||||
isLocal && "order-last",
|
||||
isSpeaking
|
||||
? "ring-[0.139vw] ring-[#7B60F3]"
|
||||
: "ring-[0.069vw] ring-[#FFFFFF4D]",
|
||||
isVideoOff ? "bg-green-500" : "bg-yellow-500"
|
||||
isVideoOff ? "bg-green-500" : "bg-yellow-500/10"
|
||||
)}
|
||||
onClick={handleVideoClick}
|
||||
>
|
||||
{isAdmin && <Admin className="absolute top-0 right-0" />}
|
||||
{isLocal && <Admin className="absolute top-0 right-0" />}
|
||||
|
||||
<div
|
||||
key="name"
|
||||
@@ -65,22 +233,104 @@ export default function UserCamera({
|
||||
{name}
|
||||
</div>
|
||||
|
||||
{/* Заглушка когда нет видео */}
|
||||
{!mediaStream && (
|
||||
<div className="flex absolute inset-0 justify-center items-center bg-gradient-to-br from-gray-700 to-gray-900">
|
||||
<div className="flex flex-col gap-2 items-center text-white/60">
|
||||
<div className="2xl:size-[2.778vw] size-10">
|
||||
<VideoOffFilledIcon />
|
||||
</div>
|
||||
<span className="text-xs">Нет видео</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Подсказка для запуска видео */}
|
||||
{!isLocal && mediaStream && (
|
||||
<div className="flex absolute inset-0 justify-center items-center opacity-0 transition-opacity duration-300 pointer-events-none bg-black/50 group-hover:opacity-100">
|
||||
<div className="px-2 py-1 text-xs text-white rounded bg-black/70">
|
||||
Кликните для запуска видео
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<video
|
||||
ref={ref}
|
||||
className="object-cover size-full"
|
||||
className={clsx(
|
||||
"object-cover size-full",
|
||||
isLocal && "scale-x-[-1]",
|
||||
!mediaStream && "hidden"
|
||||
)}
|
||||
autoPlay
|
||||
muted={isMuted}
|
||||
muted={isLocal ? isMuted : isAudioMuted}
|
||||
playsInline
|
||||
webkit-playsinline="true"
|
||||
controls={false}
|
||||
preload="metadata"
|
||||
loop={false}
|
||||
onLoadedData={() => {
|
||||
if (!isLocal && ref.current) {
|
||||
console.log(`[UserCamera] onLoadedData for ${name}, attempting play, readyState: ${ref.current.readyState}`);
|
||||
ref.current.play().catch((error) => {
|
||||
console.error(`[UserCamera] onLoadedData play failed for ${name}:`, error);
|
||||
});
|
||||
}
|
||||
}}
|
||||
onLoadedMetadata={() => {
|
||||
if (!isLocal && ref.current) {
|
||||
console.log(`[UserCamera] onLoadedMetadata for ${name}, attempting play, readyState: ${ref.current.readyState}`);
|
||||
ref.current.play().catch((error) => {
|
||||
console.error(`[UserCamera] onLoadedMetadata play failed for ${name}:`, error);
|
||||
});
|
||||
}
|
||||
}}
|
||||
onCanPlay={() => {
|
||||
if (!isLocal && ref.current) {
|
||||
console.log(`[UserCamera] onCanPlay for ${name}, attempting play`);
|
||||
ref.current.play().catch((error) => {
|
||||
console.error(`[UserCamera] onCanPlay play failed for ${name}:`, error);
|
||||
});
|
||||
}
|
||||
}}
|
||||
onPlaying={() => {
|
||||
console.log(`[UserCamera] onPlaying for ${name} - video is actually playing!`);
|
||||
}}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleVideoClick();
|
||||
}}
|
||||
/>
|
||||
|
||||
<UserCameraControls
|
||||
isMuted={isMuted}
|
||||
isVideoOff={isVideoOff}
|
||||
isControlDisabled={isControlDisabled}
|
||||
onMute={onMute}
|
||||
onVideoOff={onVideoOff}
|
||||
onCanControl={onCanControl}
|
||||
/>
|
||||
{/* Кнопка управления звуком для удаленных участников */}
|
||||
{!isLocal && mediaStream && (
|
||||
<div
|
||||
className="absolute top-[0.556vw] right-[0.556vw] opacity-0 group-hover:opacity-100 transition-opacity duration-300"
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<button
|
||||
onClick={toggleRemoteAudio}
|
||||
className="2xl:size-[1.667vw] size-6 bg-[#14141426] backdrop-blur-[4px] hover:bg-[#14141440] rounded-full flex items-center justify-center transition-colors"
|
||||
title={isAudioMuted ? "Включить звук" : "Выключить звук"}
|
||||
>
|
||||
<div className="2xl:size-[0.972vw] size-3.5 text-white">
|
||||
{isAudioMuted ? <VolumeOffIcon /> : <VolumeIcon />}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Элементы управления только для удаленных участников */}
|
||||
{!isLocal && (
|
||||
<UserCameraControls
|
||||
isMuted={isMuted}
|
||||
isVideoOff={isVideoOff}
|
||||
isControlDisabled={isControlDisabled}
|
||||
isAdmin={isAdmin || false}
|
||||
onMute={onMute}
|
||||
onVideoOff={onVideoOff}
|
||||
onCanControl={onCanControl}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -89,12 +339,14 @@ function UserCameraControls({
|
||||
isMuted,
|
||||
isVideoOff,
|
||||
isControlDisabled,
|
||||
isAdmin,
|
||||
onMute,
|
||||
onVideoOff,
|
||||
onCanControl,
|
||||
}: UserCameraControlsProps) {
|
||||
return (
|
||||
<div className="absolute transition-[bottom] duration-300 2xl:bottom-[0.278vw] 2xl:group-hover:bottom-[0.556vw] group-hover:bottom-2 bottom-1 left-1/2 -translate-x-1/2">
|
||||
{/* Индикатор muted - показывается всегда */}
|
||||
<div
|
||||
className={clsx(
|
||||
"2xl:size-[1.667vw] size-6 bg-[#14141426] backdrop-blur-[4px] transition-opacity duration-300 rounded-full flex items-center justify-center z-10a absolute left-1/2 -translate-x-1/2 2xl:bottom-0 [0.278vw] group-hover:opacity-0",
|
||||
@@ -105,35 +357,39 @@ function UserCameraControls({
|
||||
<MicrophoneOffIcon />
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="flex gap-[0.278vw] mb-[0.278vw] group-hover:opacity-100 opacity-0 transition-opacity duration-300"
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<ControlButton
|
||||
icon={isMuted ? <MicrophoneOffIcon /> : <MicrophoneFilledIcon />}
|
||||
size={"small"}
|
||||
disabled={isMuted}
|
||||
onClick={onMute}
|
||||
/>
|
||||
<ControlButton
|
||||
icon={isVideoOff ? <VideoOffFilledIcon /> : <VideoFilledIcon />}
|
||||
size={"small"}
|
||||
disabled={isVideoOff}
|
||||
onClick={onVideoOff}
|
||||
/>
|
||||
<ControlButton
|
||||
icon={
|
||||
isControlDisabled ? (
|
||||
<HandRaisedOffFilledIcon />
|
||||
) : (
|
||||
<HandRaisedFilledIcon />
|
||||
)
|
||||
}
|
||||
size={"small"}
|
||||
disabled={isControlDisabled}
|
||||
onClick={onCanControl}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Кнопки управления - только для администраторов */}
|
||||
{isAdmin && (
|
||||
<div
|
||||
className="flex gap-[0.278vw] mb-[0.278vw] group-hover:opacity-100 opacity-0 transition-opacity duration-300"
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
<ControlButton
|
||||
icon={isMuted ? <MicrophoneOffIcon /> : <MicrophoneFilledIcon />}
|
||||
size={"small"}
|
||||
disabled={false}
|
||||
onClick={onMute}
|
||||
/>
|
||||
<ControlButton
|
||||
icon={isVideoOff ? <VideoOffFilledIcon /> : <VideoFilledIcon />}
|
||||
size={"small"}
|
||||
disabled={false}
|
||||
onClick={onVideoOff}
|
||||
/>
|
||||
<ControlButton
|
||||
icon={
|
||||
isControlDisabled ? (
|
||||
<HandRaisedOffFilledIcon />
|
||||
) : (
|
||||
<HandRaisedFilledIcon />
|
||||
)
|
||||
}
|
||||
size={"small"}
|
||||
disabled={isControlDisabled}
|
||||
onClick={onCanControl}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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(<SettingsModal />);
|
||||
}
|
||||
@@ -27,20 +34,22 @@ export default function UserDevicesControls() {
|
||||
<ControlButton
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
size="large"
|
||||
icon={<MicrophoneFilledIcon />}
|
||||
onClick={ToggleAudioDevice}
|
||||
icon={isAudioMuted ? <MicrophoneOffIcon /> : <MicrophoneFilledIcon />}
|
||||
disabled={!hasLocalStream}
|
||||
onClick={toggleAudio}
|
||||
/>
|
||||
<ControlButton
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
size="large"
|
||||
icon={<VideoFilledIcon />}
|
||||
onClick={ToggleVideoDevice}
|
||||
icon={isVideoMuted ? <VideoOffFilledIcon /> : <VideoFilledIcon />}
|
||||
disabled={!hasLocalStream}
|
||||
onClick={toggleVideo}
|
||||
/>
|
||||
<ControlButton
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
size="large"
|
||||
icon={<HandRaisedFilledIcon />}
|
||||
onClick={ToggleCanControl}
|
||||
onClick={() => console.log("Toggle can control")}
|
||||
/>
|
||||
<ControlButton
|
||||
onMouseDown={(e) => e.stopPropagation()}
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
import { useEffect, useState, useRef } from "react";
|
||||
import {
|
||||
createWebRTCService,
|
||||
type Participant,
|
||||
type ChatMessage,
|
||||
} from "../lib/webrtc";
|
||||
|
||||
let webrtcServiceInstance: ReturnType<typeof createWebRTCService> | null = null;
|
||||
let isInitializing = false;
|
||||
|
||||
export const useWebRTC = (roomId?: string, autoJoin = false) => {
|
||||
const callbacksRegisteredRef = useRef(false);
|
||||
const hasJoinedRoomRef = useRef(false);
|
||||
const [localStream, setLocalStream] = useState<MediaStream | null>(null);
|
||||
const [participants, setParticipants] = useState<Participant[]>([]);
|
||||
const [isAudioMuted, setIsAudioMuted] = useState(false);
|
||||
const [isVideoMuted, setIsVideoMuted] = useState(false);
|
||||
const [chatMessages, setChatMessages] = useState<ChatMessage[]>([]);
|
||||
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,
|
||||
};
|
||||
};
|
||||
@@ -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<string, Participant>;
|
||||
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<K extends keyof WebRTCCallbacks>(
|
||||
eventName: K,
|
||||
...args: Parameters<NonNullable<WebRTCCallbacks[K]>>
|
||||
) {
|
||||
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<MediaStream | null> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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(<SharePopup link={`${window.location.origin}/sessions/${id}`} />);
|
||||
}
|
||||
|
||||
// Перенаправление на тестовую страницу при завершении сессии
|
||||
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 (
|
||||
<div className="flex overflow-hidden relative order-3 w-screen h-screen bg-black justify-center_items-center">
|
||||
{/* 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() {
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Сообщение когда Pixel Streaming завершён, но WebRTC чат работает */}
|
||||
{session.status === "ended" && (
|
||||
<div className="flex flex-col gap-6 justify-center items-center w-full h-full">
|
||||
<div className="flex flex-col gap-2 items-center">
|
||||
<div className="text-2xl font-semibold text-white">
|
||||
Сессия завершена
|
||||
</div>
|
||||
<div className="text-base text-gray-400">
|
||||
Вы можете продолжать общаться через видеочат
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="primary" onClick={() => navigate("/test")}>
|
||||
Покинуть сессию
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ActionsSidebarWrapper className="z-[99]">
|
||||
<FloatingActionButton
|
||||
className="max-2xl:hidden"
|
||||
@@ -210,7 +230,9 @@ function NewSessionPage() {
|
||||
</FloatingActionButton>
|
||||
<ControlsPopover />
|
||||
</ActionsSidebarWrapper>
|
||||
<SessionUsersPanel />
|
||||
|
||||
{/* WebRTC видеочат - работает всегда, пока пользователь на странице */}
|
||||
<SessionUsersPanel2 roomId={session.id} autoJoin={true} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<div className="flex justify-center items-center min-h-screen bg-gray-50">
|
||||
<div className="flex flex-col gap-4 items-center">
|
||||
<div className="size-12 text-[#7B60F3] animate-spin">
|
||||
<LoaderIcon />
|
||||
</div>
|
||||
<p className="text-gray-600 text-m">
|
||||
Загрузка информации о сессии...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !session) {
|
||||
return (
|
||||
<div className="flex justify-center items-center min-h-screen bg-gray-50">
|
||||
<div className="p-8 w-full max-w-2xl bg-white rounded-lg shadow-md">
|
||||
<div className="flex gap-4 items-start">
|
||||
<div className="text-red-500 size-6">
|
||||
<WarningIcon />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h1 className="mb-2 text-red-900 title-l">Сессия не найдена</h1>
|
||||
<p className="mb-6 text-gray-600 text-m">
|
||||
{error instanceof Error
|
||||
? error.message
|
||||
: "Не удалось загрузить информацию о сессии"}
|
||||
</p>
|
||||
<Button variant="primary" onClick={() => navigate("/test")}>
|
||||
Вернуться назад
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="px-4 py-8 min-h-screen bg-gray-50">
|
||||
<div className="mx-auto max-w-4xl">
|
||||
{/* Header с названием приложения */}
|
||||
<div className="flex gap-4 justify-between items-center mb-6">
|
||||
<div>
|
||||
<h1 className="font-bold text-gray-900 title-l">
|
||||
{session.app?.title || "Приложение"}
|
||||
</h1>
|
||||
<p className="text-gray-500 text-s">
|
||||
ID сессии: {session.id.slice(0, 8)}...
|
||||
</p>
|
||||
</div>
|
||||
<StatusBadge status={session.status} />
|
||||
</div>
|
||||
|
||||
{/* Pixel Streaming Player - показывается когда сессия запущена */}
|
||||
{session.status === "started" &&
|
||||
session.mode === "stream" &&
|
||||
session.server?.localIp &&
|
||||
session.playerPort && (
|
||||
<div className="mb-6 aspect-video">
|
||||
<PixelStreamingWrapper
|
||||
initialSettings={{
|
||||
ss: `ws://${session.server.localIp}:${session.playerPort}`,
|
||||
AutoPlayVideo: true,
|
||||
AutoConnect: true,
|
||||
StartVideoMuted: true,
|
||||
HoveringMouse: true,
|
||||
WaitForStreamer: true,
|
||||
}}
|
||||
onVideoInitialized={() => {
|
||||
console.log("Video initialized");
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Основная карточка с информацией */}
|
||||
<div className="grid gap-6 mb-6 md:grid-cols-2">
|
||||
{/* Информация о сессии */}
|
||||
<div className="p-6 bg-white rounded-2xl shadow-sm">
|
||||
<h2 className="flex gap-2 items-center mb-4 font-semibold text-gray-900 title-m">
|
||||
<div className="size-5 text-[#7B60F3]">
|
||||
<StartIcon />
|
||||
</div>
|
||||
Информация о сессии
|
||||
</h2>
|
||||
<div className="space-y-3">
|
||||
<InfoRow label="Режим" value={getModeLabel(session.mode)} />
|
||||
{session.tier && (
|
||||
<InfoRow label="Уровень" value={getTierLabel(session.tier)} />
|
||||
)}
|
||||
<InfoRow label="Начало" value={formatDateTime(session.startAt)} />
|
||||
{session.endAt && (
|
||||
<InfoRow
|
||||
label="Завершение"
|
||||
value={formatDateTime(session.endAt)}
|
||||
/>
|
||||
)}
|
||||
{session.appPid && (
|
||||
<InfoRow label="PID приложения" value={session.appPid} />
|
||||
)}
|
||||
{session.cirrusPid && (
|
||||
<InfoRow label="PID Cirrus" value={session.cirrusPid} />
|
||||
)}
|
||||
{session.streamerPort && (
|
||||
<InfoRow label="Streamer Port" value={session.streamerPort} />
|
||||
)}
|
||||
{session.playerPort && (
|
||||
<InfoRow label="Player Port" value={session.playerPort} />
|
||||
)}
|
||||
{session.sfuPort && (
|
||||
<InfoRow label="SFU Port" value={session.sfuPort} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Информация о сервере */}
|
||||
{session.server && (
|
||||
<div className="p-6 bg-white rounded-2xl shadow-sm">
|
||||
<h2 className="flex gap-2 items-center mb-4 font-semibold text-gray-900 title-m">
|
||||
<div className="size-5 text-[#7B60F3]">
|
||||
<CheckIcon />
|
||||
</div>
|
||||
Сервер
|
||||
</h2>
|
||||
<div className="space-y-3">
|
||||
<InfoRow label="Hostname" value={session.server.hostname} />
|
||||
<InfoRow label="IP адрес" value={session.server.localIp} />
|
||||
<InfoRow
|
||||
label="Тип"
|
||||
value={
|
||||
session.server.type === "stream" ? "Стрим" : "Локальный"
|
||||
}
|
||||
/>
|
||||
{session.server.location && (
|
||||
<InfoRow
|
||||
label="Локация"
|
||||
value={getLocationLabel(session.server.location)}
|
||||
/>
|
||||
)}
|
||||
{session.server.tier && (
|
||||
<InfoRow
|
||||
label="Уровень"
|
||||
value={getTierLabel(session.server.tier)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Если сервер еще не назначен */}
|
||||
{!session.server && session.status === "starting" && (
|
||||
<div className="p-6 bg-white rounded-2xl shadow-sm">
|
||||
<h2 className="flex gap-2 items-center mb-4 font-semibold text-gray-900 title-m">
|
||||
<div className="text-yellow-500 animate-spin size-5">
|
||||
<LoaderIcon />
|
||||
</div>
|
||||
Сервер
|
||||
</h2>
|
||||
<p className="text-gray-600 text-s">
|
||||
Подбирается подходящий сервер...
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Информация о приложении */}
|
||||
{session.app && (
|
||||
<div className="p-6 bg-white rounded-2xl shadow-sm md:col-span-2">
|
||||
<h2 className="flex gap-2 items-center mb-4 font-semibold text-gray-900 title-m">
|
||||
<div className="size-5 text-[#7B60F3]">
|
||||
<ClockIcon />
|
||||
</div>
|
||||
Приложение
|
||||
</h2>
|
||||
<div className="grid gap-3 md:grid-cols-2">
|
||||
<InfoRow label="Название" value={session.app.title} />
|
||||
<InfoRow label="Имя системное" value={session.app.name} />
|
||||
{session.app.gpuLimitMb && (
|
||||
<InfoRow
|
||||
label="Лимит GPU"
|
||||
value={`${session.app.gpuLimitMb} МБ`}
|
||||
/>
|
||||
)}
|
||||
{session.app.psVersion && (
|
||||
<InfoRow label="Версия PS" value={session.app.psVersion} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Статусное сообщение */}
|
||||
<StatusMessage status={session.status} />
|
||||
|
||||
{/* Кнопки управления */}
|
||||
<div className="flex gap-4 justify-center">
|
||||
<Button variant="secondary" onClick={() => navigate("/test")}>
|
||||
Вернуться назад
|
||||
</Button>
|
||||
{(session.status === "starting" || session.status === "ending") && (
|
||||
<Button variant="primary" onClick={() => refetch()}>
|
||||
Обновить
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Компоненты помощники
|
||||
function StatusBadge({ status }: { status: Session["status"] }) {
|
||||
const config = {
|
||||
starting: {
|
||||
label: "Запускается",
|
||||
color: "bg-yellow-100 text-yellow-800",
|
||||
icon: <LoaderIcon />,
|
||||
animate: true,
|
||||
},
|
||||
started: {
|
||||
label: "Запущена",
|
||||
color: "bg-green-100 text-green-800",
|
||||
icon: <CheckIcon />,
|
||||
animate: false,
|
||||
},
|
||||
ending: {
|
||||
label: "Завершается",
|
||||
color: "bg-orange-100 text-orange-800",
|
||||
icon: <LoaderIcon />,
|
||||
animate: true,
|
||||
},
|
||||
ended: {
|
||||
label: "Завершена",
|
||||
color: "bg-gray-100 text-gray-800",
|
||||
icon: <ClockIcon />,
|
||||
animate: false,
|
||||
},
|
||||
};
|
||||
|
||||
const statusConfig = config[status];
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
"flex gap-2 items-center px-4 py-2 rounded-xl button-m font-medium",
|
||||
statusConfig.color
|
||||
)}
|
||||
>
|
||||
<div className={clsx("size-4", statusConfig.animate && "animate-spin")}>
|
||||
{statusConfig.icon}
|
||||
</div>
|
||||
{statusConfig.label}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function InfoRow({ label, value }: { label: string; value: string | number }) {
|
||||
return (
|
||||
<div className="flex justify-between items-start">
|
||||
<span className="text-gray-500 text-s">{label}:</span>
|
||||
<span className="font-medium text-right text-gray-900 text-s">
|
||||
{value}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusMessage({ status }: { status: Session["status"] }) {
|
||||
const messages = {
|
||||
starting: {
|
||||
text: "Сессия запускается. Пожалуйста, подождите...",
|
||||
color: "bg-yellow-50 border-yellow-200 text-yellow-800",
|
||||
icon: <LoaderIcon />,
|
||||
},
|
||||
started: {
|
||||
text: "Сессия успешно запущена и готова к работе!",
|
||||
color: "bg-green-50 border-green-200 text-green-800",
|
||||
icon: <CheckIcon />,
|
||||
},
|
||||
ending: {
|
||||
text: "Сессия завершается...",
|
||||
color: "bg-orange-50 border-orange-200 text-orange-800",
|
||||
icon: <LoaderIcon />,
|
||||
},
|
||||
ended: {
|
||||
text: "Сессия завершена. Через 5 секунд вы будете перенаправлены на главную страницу.",
|
||||
color: "bg-gray-50 border-gray-200 text-gray-800",
|
||||
icon: <ClockIcon />,
|
||||
},
|
||||
};
|
||||
|
||||
const message = messages[status];
|
||||
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
"flex gap-4 items-start p-4 mb-6 rounded-xl border",
|
||||
message.color
|
||||
)}
|
||||
>
|
||||
<div className="size-5 flex-shrink-0 mt-0.5">{message.icon}</div>
|
||||
<p className="text-m">{message.text}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Утилиты форматирования
|
||||
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;
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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<string>;
|
||||
}
|
||||
|
||||
interface User {
|
||||
id: string;
|
||||
roomId?: string;
|
||||
socketId: string;
|
||||
}
|
||||
|
||||
const rooms = new Map<string, Room>();
|
||||
const users = new Map<string, User>();
|
||||
|
||||
// Вспомогательные функции
|
||||
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",
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
VITE_SERVER_URL=https://webrtc.graff.tech
|
||||
# VITE_SERVER_URL=http://localhost:3000
|
||||
@@ -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?
|
||||
@@ -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...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
@@ -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=="],
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
])
|
||||
@@ -0,0 +1,27 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="theme-color" content="#1f2937" />
|
||||
<title>WebRTC Chat</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
<!-- Мобильная консоль для отладки -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/eruda@3.0.1/eruda.min.js"></script>
|
||||
<script>
|
||||
// Показывать консоль только на мобильных устройствах или при добавлении ?debug=1
|
||||
if (/Android|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent) ||
|
||||
window.location.search.includes('debug=1')) {
|
||||
eruda.init();
|
||||
}
|
||||
</script>
|
||||
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,27 @@
|
||||
import { useState } from 'react';
|
||||
import { RoomJoin } from './components/RoomJoin';
|
||||
import { VideoCall } from './components/VideoCall';
|
||||
|
||||
function App() {
|
||||
const [currentRoom, setCurrentRoom] = useState<string | null>(null);
|
||||
|
||||
const handleJoinRoom = (roomId: string) => {
|
||||
setCurrentRoom(roomId);
|
||||
};
|
||||
|
||||
const handleLeaveRoom = () => {
|
||||
setCurrentRoom(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="App">
|
||||
{currentRoom ? (
|
||||
<VideoCall roomId={currentRoom} onLeave={handleLeaveRoom} />
|
||||
) : (
|
||||
<RoomJoin onJoinRoom={handleJoinRoom} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default App;
|
||||
@@ -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<HTMLDivElement>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(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 (
|
||||
<div className="flex flex-col h-full bg-white border-l-0 md:border-l border-gray-200">
|
||||
{/* Chat Header */}
|
||||
<div className="px-3 py-2 bg-gray-50 border-b border-gray-200 sm:px-4 sm:py-3">
|
||||
<h3 className="text-base font-semibold text-gray-800 sm:text-lg">Чат</h3>
|
||||
<div className="flex items-center mt-1">
|
||||
<div className={`w-2 h-2 rounded-full mr-2 ${isConnected ? 'bg-green-500' : 'bg-red-500'}`}></div>
|
||||
<span className="text-xs text-gray-600 sm:text-sm">
|
||||
{isConnected ? 'Подключено' : 'Отключено'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Messages Container */}
|
||||
<div className="overflow-y-auto flex-1 p-2 space-y-2 sm:p-4 sm:space-y-3 scroll-smooth">
|
||||
{messages.length === 0 ? (
|
||||
<div className="mt-4 text-center text-gray-500 sm:mt-8">
|
||||
<p className="text-sm sm:text-base">Пока нет сообщений</p>
|
||||
<p className="mt-1 text-xs sm:text-sm">Начните общение!</p>
|
||||
</div>
|
||||
) : (
|
||||
messages.map((message) => (
|
||||
<div
|
||||
key={message.id}
|
||||
className={`flex ${message.senderId === currentUserId ? 'justify-end' : 'justify-start'}`}
|
||||
>
|
||||
<div
|
||||
className={`max-w-[280px] sm:max-w-xs lg:max-w-md px-3 sm:px-4 py-2 rounded-lg chat-message ${
|
||||
message.senderId === currentUserId
|
||||
? 'bg-blue-500 text-white'
|
||||
: message.type === 'system'
|
||||
? 'bg-gray-200 text-gray-700 text-center'
|
||||
: 'bg-gray-200 text-gray-800'
|
||||
}`}
|
||||
>
|
||||
{message.type === 'system' ? (
|
||||
<p className="text-xs sm:text-sm">{message.content}</p>
|
||||
) : (
|
||||
<>
|
||||
{message.senderId !== currentUserId && (
|
||||
<p className="mb-1 text-xs opacity-75">
|
||||
{message.senderName || `User ${message.senderId.slice(0, 8)}`}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-sm break-words sm:text-base">{message.content}</p>
|
||||
<p className={`text-xs mt-1 ${
|
||||
message.senderId === currentUserId ? 'text-blue-100' : 'text-gray-500'
|
||||
}`}>
|
||||
{formatTime(message.timestamp)}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
|
||||
{/* Input Form */}
|
||||
<div className="p-2 border-t border-gray-200 sm:p-4">
|
||||
<form onSubmit={handleSubmit} className="flex space-x-2">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={inputMessage}
|
||||
onChange={(e) => 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}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!inputMessage.trim() || !isConnected}
|
||||
className="px-3 py-2 text-white bg-blue-500 rounded-lg transition-colors sm:px-4 hover:bg-blue-600 active:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:bg-gray-300 disabled:cursor-not-allowed"
|
||||
>
|
||||
<svg className="w-4 h-4 sm:w-5 sm:h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 19l9 2-9-18-9 18 9-2zm0 0v-8" />
|
||||
</svg>
|
||||
</button>
|
||||
</form>
|
||||
<div className="mt-1 text-xs text-gray-500">
|
||||
{inputMessage.length}/500
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import React, { useState } from 'react';
|
||||
|
||||
interface RoomJoinProps {
|
||||
onJoinRoom: (roomId: string) => void;
|
||||
}
|
||||
|
||||
export const RoomJoin: React.FC<RoomJoinProps> = ({ 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 (
|
||||
<div className="min-h-screen bg-gradient-to-br from-blue-900 to-purple-900 flex items-center justify-center p-4 sm:p-6">
|
||||
<div className="bg-white rounded-lg shadow-2xl p-6 sm:p-8 w-full max-w-md mx-auto">
|
||||
<div className="text-center mb-6 sm:mb-8">
|
||||
<h1 className="text-2xl sm:text-3xl font-bold text-gray-800 mb-2">
|
||||
WebRTC Видео Чат
|
||||
</h1>
|
||||
<p className="text-sm sm:text-base text-gray-600">
|
||||
Присоединитесь к комнате для видео конференции
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4 sm:space-y-6">
|
||||
<div>
|
||||
<label htmlFor="roomId" className="block text-sm font-medium text-gray-700 mb-2">
|
||||
ID Комнаты
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="roomId"
|
||||
value={roomId}
|
||||
onChange={(e) => 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}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col sm:flex-row space-y-3 sm:space-y-0 sm:space-x-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={generateRoomId}
|
||||
className="flex-1 bg-gray-500 hover:bg-gray-600 active:bg-gray-700 text-white py-3 px-4 rounded-lg font-medium transition-colors disabled:opacity-50 text-sm sm:text-base"
|
||||
disabled={isJoining}
|
||||
>
|
||||
Создать комнату
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="flex-1 bg-blue-500 hover:bg-blue-600 active:bg-blue-700 text-white py-3 px-4 rounded-lg font-medium transition-colors disabled:opacity-50 flex items-center justify-center text-sm sm:text-base"
|
||||
disabled={isJoining || !roomId.trim()}
|
||||
>
|
||||
{isJoining ? (
|
||||
<>
|
||||
<div className="animate-spin rounded-full h-5 w-5 border-b-2 border-white mr-2"></div>
|
||||
<span className="hidden sm:inline">Подключение...</span>
|
||||
<span className="sm:hidden">Подключение</span>
|
||||
</>
|
||||
) : (
|
||||
'Присоединиться'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div className="mt-6 sm:mt-8 p-4 bg-gray-50 rounded-lg">
|
||||
<h3 className="font-medium text-gray-800 mb-2 text-sm sm:text-base">Как использовать:</h3>
|
||||
<ul className="text-xs sm:text-sm text-gray-600 space-y-1">
|
||||
<li>• Введите ID существующей комнаты для присоединения</li>
|
||||
<li>• Или создайте новую комнату с случайным ID</li>
|
||||
<li>• Поделитесь ID комнаты с другими участниками</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 sm:mt-6 text-center">
|
||||
<p className="text-xs text-gray-500">
|
||||
Для работы требуется доступ к камере и микрофону
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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<VideoCallProps> = ({ roomId, onLeave }) => {
|
||||
const [webrtcService] = useState(() => createWebRTCService());
|
||||
const [remoteParticipants, setRemoteParticipants] = useState<
|
||||
RemoteParticipant[]
|
||||
>([]);
|
||||
const [allParticipants, setAllParticipants] = useState<Set<string>>(
|
||||
new Set()
|
||||
);
|
||||
const [isAudioMuted, setIsAudioMuted] = useState(false);
|
||||
const [isVideoMuted, setIsVideoMuted] = useState(false);
|
||||
const [isConnecting, setIsConnecting] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [hasLocalStream, setHasLocalStream] = useState(false);
|
||||
const [localStream, setLocalStream] = useState<MediaStream | null>(null);
|
||||
const [chatMessages, setChatMessages] = useState<ChatMessage[]>([]);
|
||||
const [chatConnected, setChatConnected] = useState(false);
|
||||
const [showChat, setShowChat] = useState(true);
|
||||
const renderCountRef = useRef(0);
|
||||
|
||||
const localVideoRef = useRef<HTMLVideoElement>(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 (
|
||||
<div className="flex justify-center items-center h-screen bg-gray-900">
|
||||
<div className="text-center">
|
||||
<div className="mx-auto mb-4 w-12 h-12 rounded-full border-b-2 border-blue-500 animate-spin"></div>
|
||||
<p className="text-white">Подключение к видео звонку...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex justify-center items-center h-screen bg-gray-900">
|
||||
<div className="text-center">
|
||||
<div className="mb-4 text-xl text-red-500">❌</div>
|
||||
<p className="mb-4 text-white">{error}</p>
|
||||
<button
|
||||
onClick={onLeave}
|
||||
className="px-4 py-2 text-white bg-blue-500 rounded hover:bg-blue-600"
|
||||
>
|
||||
Вернуться
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-screen bg-gray-900 md:flex-row">
|
||||
{/* Main Video Area */}
|
||||
<div
|
||||
className={`flex flex-col ${
|
||||
showChat ? "flex-1" : "w-full"
|
||||
} transition-all duration-300`}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="flex justify-between items-center p-2 bg-gray-800 sm:p-4">
|
||||
<div className="flex-1 min-w-0">
|
||||
<h1 className="text-sm text-white truncate sm:text-xl">
|
||||
Комната: {roomId}
|
||||
</h1>
|
||||
<div className="hidden mt-1 text-xs text-white sm:block sm:text-sm">
|
||||
Участников: {allParticipants.size + 1}
|
||||
{hasLocalStream && " • Камера подключена"}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2 sm:space-x-4">
|
||||
<div className="hidden text-xs text-white md:block sm:text-sm">
|
||||
<div className="text-gray-300">
|
||||
Список: [{Array.from(allParticipants).join(", ")}]
|
||||
</div>
|
||||
<div className="text-gray-400">
|
||||
Сервер: {import.meta.env.VITE_SERVER_URL}
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-xs text-white sm:hidden">
|
||||
{allParticipants.size + 1} участников
|
||||
</div>
|
||||
<button
|
||||
onClick={toggleChat}
|
||||
className="p-2 text-white bg-blue-500 rounded-lg transition-colors hover:bg-blue-600 active:bg-blue-700"
|
||||
title={showChat ? "Скрыть чат" : "Показать чат"}
|
||||
>
|
||||
<MessagesSquare />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Video Grid */}
|
||||
<div className="flex-1 p-2 sm:p-4">
|
||||
<div
|
||||
className={`grid gap-2 sm:gap-4 h-full ${
|
||||
remoteParticipants.length === 0
|
||||
? "grid-cols-1"
|
||||
: remoteParticipants.length === 1
|
||||
? "grid-cols-1 md:grid-cols-2"
|
||||
: remoteParticipants.length <= 4
|
||||
? "grid-cols-1 md:grid-cols-2"
|
||||
: "grid-cols-1 md:grid-cols-2 lg:grid-cols-3"
|
||||
}`}
|
||||
>
|
||||
{/* Local Video */}
|
||||
<div className="relative bg-gray-800 rounded-lg overflow-hidden min-h-[150px] sm:min-h-[200px]">
|
||||
<video
|
||||
ref={localVideoRef}
|
||||
autoPlay
|
||||
muted
|
||||
playsInline
|
||||
className="object-cover w-full h-full"
|
||||
style={{
|
||||
transform: "scaleX(-1)", // Зеркальное отображение для селфи-эффекта
|
||||
minHeight: "150px",
|
||||
}}
|
||||
onLoadedMetadata={() => {
|
||||
console.log("Video metadata loaded");
|
||||
if (localVideoRef.current) {
|
||||
console.log("Video dimensions:", {
|
||||
videoWidth: localVideoRef.current.videoWidth,
|
||||
videoHeight: localVideoRef.current.videoHeight,
|
||||
});
|
||||
}
|
||||
}}
|
||||
onCanPlay={() => {
|
||||
console.log("Video can play");
|
||||
}}
|
||||
onError={(e) => {
|
||||
console.error("Video error:", e);
|
||||
}}
|
||||
/>
|
||||
<div className="absolute bottom-2 left-2 px-2 py-1 text-xs text-white bg-black bg-opacity-50 rounded sm:text-sm">
|
||||
Вы {isAudioMuted && <MicOff size={20} />}{" "}
|
||||
{isVideoMuted && <VideoOff size={20} />}
|
||||
</div>
|
||||
{!hasLocalStream && (
|
||||
<div className="flex absolute inset-0 justify-center items-center">
|
||||
<div className="text-center text-white">
|
||||
<div className="mb-2 text-2xl sm:text-4xl">
|
||||
<Video size={20} />
|
||||
</div>
|
||||
<div className="text-sm sm:text-base">
|
||||
Загрузка камеры...
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Remote Videos */}
|
||||
{remoteParticipants.map((participant) => (
|
||||
<RemoteVideo key={participant.id} participant={participant} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
<div className="flex justify-center p-2 space-x-3 bg-gray-800 sm:p-4 sm:space-x-4">
|
||||
<button
|
||||
onClick={handleToggleAudio}
|
||||
className={`p-2 sm:p-3 rounded-full ${
|
||||
isAudioMuted
|
||||
? "bg-red-500 hover:bg-red-600 active:bg-red-700"
|
||||
: "bg-gray-600 hover:bg-gray-700 active:bg-gray-800"
|
||||
} text-white transition-colors text-lg sm:text-xl`}
|
||||
title={isAudioMuted ? "Включить микрофон" : "Выключить микрофон"}
|
||||
>
|
||||
{isAudioMuted ? <MicOff size={20} /> : <Mic size={20} />}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleToggleVideo}
|
||||
className={`p-2 sm:p-3 rounded-full ${
|
||||
isVideoMuted
|
||||
? "bg-red-500 hover:bg-red-600 active:bg-red-700"
|
||||
: "bg-gray-600 hover:bg-gray-700 active:bg-gray-800"
|
||||
} text-white transition-colors text-lg sm:text-xl`}
|
||||
title={isVideoMuted ? "Включить камеру" : "Выключить камеру"}
|
||||
>
|
||||
{isVideoMuted ? <VideoOff size={20} /> : <Video size={20} />}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleLeave}
|
||||
className="p-2 text-lg text-white bg-red-500 rounded-full transition-colors sm:p-3 hover:bg-red-600 active:bg-red-700 sm:text-xl"
|
||||
title="Покинуть звонок"
|
||||
>
|
||||
<Phone size={20} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Chat Panel */}
|
||||
{showChat && (
|
||||
<div className="w-full h-64 border-t border-gray-600 md:w-80 md:h-full md:border-t-0 md:border-l">
|
||||
<Chat
|
||||
messages={chatMessages}
|
||||
currentUserId={webrtcService.getCurrentUserId()}
|
||||
onSendMessage={handleSendMessage}
|
||||
isConnected={chatConnected}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface RemoteVideoProps {
|
||||
participant: RemoteParticipant;
|
||||
}
|
||||
|
||||
const RemoteVideo: React.FC<RemoteVideoProps> = ({ participant }) => {
|
||||
const videoRef = useRef<HTMLVideoElement>(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 (
|
||||
<div className="relative bg-gray-800 rounded-lg overflow-hidden min-h-[150px] sm:min-h-[200px]">
|
||||
<video
|
||||
ref={videoRef}
|
||||
autoPlay
|
||||
playsInline
|
||||
className="object-cover w-full h-full"
|
||||
style={{ minHeight: "150px" }}
|
||||
/>
|
||||
<div className="absolute bottom-2 left-2 px-2 py-1 text-xs text-white bg-black bg-opacity-50 rounded sm:text-sm">
|
||||
Участник {participant.id.slice(0, 8)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { createRoot } from "react-dom/client";
|
||||
import "./index.css";
|
||||
import App from "./App.tsx";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(<App />);
|
||||
@@ -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<string, Participant>;
|
||||
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<MediaStream> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
@@ -0,0 +1,12 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
"./src/**/*.{js,ts,jsx,tsx}",
|
||||
],
|
||||
theme: {
|
||||
extend: {},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./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"]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
})
|
||||
Submodule
+1
Submodule webrtc-video-chat/server added at a5e737960b
Reference in New Issue
Block a user