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

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

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

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

-

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

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

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

-

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

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

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

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

-
- -
- Сервер -

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

-
- -
- Сервер -

-

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

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

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

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

{message.text}

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

Чат

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

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

+

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

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

{message.content}

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

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

+ )} +

{message.content}

+

+ {formatTime(message.timestamp)} +

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

+ WebRTC Видео Чат +

+

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

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

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

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

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

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

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

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

{error}

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

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

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