From e6f0d2537d374b8c6e706664bbbb6d9c12b3ebdc Mon Sep 17 00:00:00 2001 From: UFObject247 Date: Tue, 28 Apr 2026 15:15:25 +0100 Subject: [PATCH 1/4] feat(collaboration): add websocket transport and server setup --- package-lock.json | 776 +++++++++++++++++- package.json | 1 + .../collaboration/server/webSocketServer.ts | 132 +++ src/features/collaboration/types.ts | 69 ++ src/hooks/useWebSocket.ts | 183 +++++ .../events}/events.test.tsx | 0 6 files changed, 1119 insertions(+), 42 deletions(-) create mode 100644 src/features/collaboration/server/webSocketServer.ts create mode 100644 src/features/collaboration/types.ts create mode 100644 src/hooks/useWebSocket.ts rename src/{pages/events/__tests__ => testing/events}/events.test.tsx (100%) diff --git a/package-lock.json b/package-lock.json index 3a521063..dae770ef 100644 --- a/package-lock.json +++ b/package-lock.json @@ -48,6 +48,7 @@ "react-virtualized-auto-sizer": "^1.0.7", "react-window": "^1.8.9", "recharts": "^2.15.4", + "socket.io": "^4.8.3", "socket.io-client": "^4.8.3", "tailwind-merge": "^2.6.0", "web-vitals": "^4.2.4", @@ -5471,6 +5472,15 @@ "assertion-error": "^2.0.1" } }, + "node_modules/@types/cors": { + "version": "2.8.19", + "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", + "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/d3-array": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", @@ -5558,6 +5568,28 @@ "@types/trusted-types": "*" } }, + "node_modules/@types/eslint": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", + "integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==", + "license": "MIT", + "peer": true, + "dependencies": { + "@types/estree": "*", + "@types/json-schema": "*" + } + }, + "node_modules/@types/eslint-scope": { + "version": "3.7.7", + "resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.7.tgz", + "integrity": "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==", + "license": "MIT", + "peer": true, + "dependencies": { + "@types/eslint": "*", + "@types/estree": "*" + } + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -5568,7 +5600,6 @@ "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", - "dev": true, "license": "MIT" }, "node_modules/@types/json5": { @@ -5604,7 +5635,6 @@ "version": "20.19.30", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.30.tgz", "integrity": "sha512-WJtwWJu7UdlvzEAUm484QNg5eAoq5QR08KDNx7g45Usrs2NtOPiX8ugDqmKdXkyL03rBqU5dYNYVQetEpBHq2g==", - "dev": true, "license": "MIT", "dependencies": { "undici-types": "~6.21.0" @@ -5642,7 +5672,6 @@ "version": "18.3.7", "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-18.3.7.tgz", "integrity": "sha512-MEe3UeoENYVFXzoXEWsvcpg6ZvlrFNlOQ7EOsvhI3CfAXwzPfO8Qwuxd40nepsYKqyyVQnTdEfv68q91yLcKrQ==", - "dev": true, "license": "MIT", "peerDependencies": { "@types/react": "^18.0.0" @@ -5672,6 +5701,15 @@ "integrity": "sha512-CqN8MnISMwQbLJXO3doBAV4Yw9hx9/Pyr2rZ78+NfaCnhyRA/nKrpyk6E7mKw17ZOaQdLpK9GiUjrqLzBlN3sg==", "license": "MIT" }, + "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==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.54.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.54.0.tgz", @@ -6324,52 +6362,165 @@ "url": "https://opencollective.com/vitest" } }, - "node_modules/@wry/caches": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@wry/caches/-/caches-1.0.1.tgz", - "integrity": "sha512-bXuaUNLVVkD20wcGBWRyo7j9N3TxePEWFZj2Y+r9OoUzfqmavM84+mFykRicNsBqatba5JLay1t48wxaXaWnlA==", + "node_modules/@webassemblyjs/ast": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz", + "integrity": "sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==", "license": "MIT", + "peer": true, "dependencies": { - "tslib": "^2.3.0" - }, - "engines": { - "node": ">=8" + "@webassemblyjs/helper-numbers": "1.13.2", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2" } }, - "node_modules/@wry/context": { - "version": "0.7.4", - "resolved": "https://registry.npmjs.org/@wry/context/-/context-0.7.4.tgz", - "integrity": "sha512-jmT7Sb4ZQWI5iyu3lobQxICu2nC/vbUhP0vIdd6tHC9PTfenmRmuIFqktc6GH9cgi+ZHnsLWPvfSvc4DrYmKiQ==", + "node_modules/@webassemblyjs/floating-point-hex-parser": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.13.2.tgz", + "integrity": "sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==", "license": "MIT", + "peer": true + }, + "node_modules/@webassemblyjs/helper-api-error": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.13.2.tgz", + "integrity": "sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==", + "license": "MIT", + "peer": true + }, + "node_modules/@webassemblyjs/helper-buffer": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.14.1.tgz", + "integrity": "sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==", + "license": "MIT", + "peer": true + }, + "node_modules/@webassemblyjs/helper-numbers": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.13.2.tgz", + "integrity": "sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==", + "license": "MIT", + "peer": true, "dependencies": { - "tslib": "^2.3.0" - }, - "engines": { - "node": ">=8" + "@webassemblyjs/floating-point-hex-parser": "1.13.2", + "@webassemblyjs/helper-api-error": "1.13.2", + "@xtuc/long": "4.2.2" } }, - "node_modules/@wry/equality": { - "version": "0.5.7", - "resolved": "https://registry.npmjs.org/@wry/equality/-/equality-0.5.7.tgz", - "integrity": "sha512-BRFORjsTuQv5gxcXsuDXx6oGRhuVsEGwZy6LOzRRfgu+eSfxbhUQ9L9YtSEIuIjY/o7g3iWFjrc5eSY1GXP2Dw==", + "node_modules/@webassemblyjs/helper-wasm-bytecode": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.13.2.tgz", + "integrity": "sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==", + "license": "MIT", + "peer": true + }, + "node_modules/@webassemblyjs/helper-wasm-section": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.14.1.tgz", + "integrity": "sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==", "license": "MIT", + "peer": true, "dependencies": { - "tslib": "^2.3.0" - }, - "engines": { - "node": ">=8" + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/wasm-gen": "1.14.1" } }, - "node_modules/@wry/trie": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/@wry/trie/-/trie-0.5.0.tgz", - "integrity": "sha512-FNoYzHawTMk/6KMQoEG5O4PuioX19UbwdQKF44yw0nLfOypfQdjtfZzo/UIJWAJ23sNIFbD1Ug9lbaDGMwbqQA==", + "node_modules/@webassemblyjs/ieee754": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.13.2.tgz", + "integrity": "sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw==", "license": "MIT", + "peer": true, "dependencies": { - "tslib": "^2.3.0" - }, - "engines": { - "node": ">=8" + "@xtuc/ieee754": "^1.2.0" + } + }, + "node_modules/@webassemblyjs/leb128": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.13.2.tgz", + "integrity": "sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw==", + "license": "Apache-2.0", + "peer": true, + "dependencies": { + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webassemblyjs/utf8": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.13.2.tgz", + "integrity": "sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ==", + "license": "MIT", + "peer": true + }, + "node_modules/@webassemblyjs/wasm-edit": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.14.1.tgz", + "integrity": "sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/helper-wasm-section": "1.14.1", + "@webassemblyjs/wasm-gen": "1.14.1", + "@webassemblyjs/wasm-opt": "1.14.1", + "@webassemblyjs/wasm-parser": "1.14.1", + "@webassemblyjs/wast-printer": "1.14.1" + } + }, + "node_modules/@webassemblyjs/wasm-gen": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.14.1.tgz", + "integrity": "sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg==", + "license": "MIT", + "peer": true, + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/ieee754": "1.13.2", + "@webassemblyjs/leb128": "1.13.2", + "@webassemblyjs/utf8": "1.13.2" + } + }, + "node_modules/@webassemblyjs/wasm-opt": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.14.1.tgz", + "integrity": "sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/wasm-gen": "1.14.1", + "@webassemblyjs/wasm-parser": "1.14.1" + } + }, + "node_modules/@webassemblyjs/wasm-parser": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.14.1.tgz", + "integrity": "sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ==", + "license": "MIT", + "peer": true, + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-api-error": "1.13.2", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/ieee754": "1.13.2", + "@webassemblyjs/leb128": "1.13.2", + "@webassemblyjs/utf8": "1.13.2" + } + }, + "node_modules/@webassemblyjs/wast-printer": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.14.1.tgz", + "integrity": "sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==", + "license": "MIT", + "peer": true, + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@xtuc/long": "4.2.2" } }, "node_modules/@wry/caches": { @@ -6434,6 +6585,19 @@ "license": "Apache-2.0", "peer": true }, + "node_modules/accepts": { + "version": "1.3.8", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", + "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "license": "MIT", + "dependencies": { + "mime-types": "~2.1.34", + "negotiator": "0.6.3" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", @@ -6446,6 +6610,19 @@ "node": ">=0.4.0" } }, + "node_modules/acorn-import-phases": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/acorn-import-phases/-/acorn-import-phases-1.0.4.tgz", + "integrity": "sha512-wKmbr/DDiIXzEOiWrTTUcDm24kQ2vGfZQvM2fwg2vXqR5uW6aapr7ObPtj1th32b9u90/Pf4AItvdTh42fBmVQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10.13.0" + }, + "peerDependencies": { + "acorn": "^8.14.0" + } + }, "node_modules/acorn-jsx": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", @@ -6483,6 +6660,48 @@ "url": "https://github.com/sponsors/epoberezkin" } }, + "node_modules/ajv-formats": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", + "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", + "license": "MIT", + "peer": true, + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ajv-formats/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "peer": true, + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT", + "peer": true + }, "node_modules/ansi-escapes": { "version": "7.3.0", "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz", @@ -6827,6 +7046,15 @@ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "license": "MIT" }, + "node_modules/base64id": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/base64id/-/base64id-2.0.0.tgz", + "integrity": "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==", + "license": "MIT", + "engines": { + "node": "^4.5.0 || >= 5.9" + } + }, "node_modules/baseline-browser-mapping": { "version": "2.9.19", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz", @@ -7041,6 +7269,16 @@ "node": ">= 16" } }, + "node_modules/chrome-trace-event": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz", + "integrity": "sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6.0" + } + }, "node_modules/class-variance-authority": { "version": "0.7.1", "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", @@ -7171,6 +7409,15 @@ "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", "license": "MIT" }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/core-js-compat": { "version": "3.48.0", "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.48.0.tgz", @@ -7184,6 +7431,23 @@ "url": "https://opencollective.com/core-js" } }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/crelt": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.6.tgz", @@ -7679,6 +7943,27 @@ "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", "license": "MIT" }, + "node_modules/engine.io": { + "version": "6.6.7", + "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.7.tgz", + "integrity": "sha512-DgOngfDKM2EviOH3Mr9m7ks1q8roetLy/IMmYthAYzbpInMbYc/GS+fWFA3rl1gvwKVsQrVV61fo5emD1y3OJQ==", + "license": "MIT", + "dependencies": { + "@types/cors": "^2.8.12", + "@types/node": ">=10.0.0", + "@types/ws": "^8.5.12", + "accepts": "~1.3.4", + "base64id": "2.0.0", + "cookie": "~0.7.2", + "cors": "~2.8.5", + "debug": "~4.4.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.18.3" + }, + "engines": { + "node": ">=10.2.0" + } + }, "node_modules/engine.io-client": { "version": "6.6.4", "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.4.tgz", @@ -7722,11 +8007,31 @@ "node": ">=10.0.0" } }, + "node_modules/engine.io/node_modules/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "license": "MIT", + "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/enhanced-resolve": { "version": "5.20.1", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.20.1.tgz", "integrity": "sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==", - "dev": true, "license": "MIT", "dependencies": { "graceful-fs": "^4.2.4", @@ -8441,7 +8746,6 @@ "version": "4.3.0", "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", - "dev": true, "license": "BSD-2-Clause", "dependencies": { "estraverse": "^5.2.0" @@ -8454,7 +8758,6 @@ "version": "5.3.0", "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", - "dev": true, "license": "BSD-2-Clause", "engines": { "node": ">=4.0" @@ -8485,6 +8788,16 @@ "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", "license": "MIT" }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=0.8.x" + } + }, "node_modules/expect-type": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", @@ -8968,6 +9281,13 @@ "node": ">=10.13.0" } }, + "node_modules/glob-to-regexp": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", + "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", + "license": "BSD-2-Clause", + "peer": true + }, "node_modules/glob/node_modules/minimatch": { "version": "10.1.1", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.1.tgz", @@ -9096,7 +9416,6 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -9872,6 +10191,37 @@ "node": ">=10" } }, + "node_modules/jest-worker": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", + "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", + "license": "MIT", + "peer": true, + "dependencies": { + "@types/node": "*", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/jest-worker/node_modules/supports-color": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-8.1.1.tgz", + "integrity": "sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==", + "license": "MIT", + "peer": true, + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, "node_modules/jiti": { "version": "2.6.1", "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz", @@ -10497,6 +10847,20 @@ "url": "https://github.com/chalk/wrap-ansi?sponsor=1" } }, + "node_modules/loader-runner": { + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.2.tgz", + "integrity": "sha512-DFEqQ3ihfS9blba08cLfYf1NRAIEm+dDjic073DRDc3/JspI/8wYmtDsHwd3+4hwvdxSK7PGaElfTmm0awWJ4w==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=6.11.5" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, "node_modules/locate-path": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", @@ -10746,6 +11110,19 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/marked": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz", + "integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==", + "license": "MIT", + "peer": true, + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -10767,6 +11144,13 @@ "integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q==", "license": "MIT" }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "license": "MIT", + "peer": true + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -10791,6 +11175,27 @@ "node": ">=8.6" } }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/mimic-function": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz", @@ -10867,6 +11272,17 @@ "node": "*" } }, + "node_modules/monaco-editor": { + "version": "0.55.1", + "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.55.1.tgz", + "integrity": "sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==", + "license": "MIT", + "peer": true, + "dependencies": { + "dompurify": "3.2.7", + "marked": "14.0.0" + } + }, "node_modules/motion-dom": { "version": "12.29.2", "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.29.2.tgz", @@ -10929,6 +11345,22 @@ "dev": true, "license": "MIT" }, + "node_modules/negotiator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", + "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", + "license": "MIT", + "peer": true + }, "node_modules/next": { "version": "15.3.1", "resolved": "https://registry.npmjs.org/next/-/next-15.3.1.tgz", @@ -12369,7 +12801,7 @@ "version": "4.57.1", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz", "integrity": "sha512-oQL6lgK3e2QZeQ7gcgIkS2YZPg5slw37hYufJ3edKlfQSGGm8ICoxswK15ntSzF/a8+h7ekRy7k7oWc3BQ7y8A==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "@types/estree": "1.0.8" @@ -12548,6 +12980,63 @@ "loose-envify": "^1.1.0" } }, + "node_modules/schema-utils": { + "version": "4.3.3", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.3.3.tgz", + "integrity": "sha512-eflK8wEtyOE6+hsaRVPxvUKYCpRgzLqDTb8krvAsRIwOGlHoSgYLgBXoubGgLd2fT41/OUYdb48v4k4WWHQurA==", + "license": "MIT", + "peer": true, + "dependencies": { + "@types/json-schema": "^7.0.9", + "ajv": "^8.9.0", + "ajv-formats": "^2.1.1", + "ajv-keywords": "^5.1.0" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/schema-utils/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "peer": true, + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/schema-utils/node_modules/ajv-keywords": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", + "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", + "license": "MIT", + "peer": true, + "dependencies": { + "fast-deep-equal": "^3.1.3" + }, + "peerDependencies": { + "ajv": "^8.8.2" + } + }, + "node_modules/schema-utils/node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT", + "peer": true + }, "node_modules/semver": { "version": "7.7.3", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz", @@ -12825,6 +13314,55 @@ "integrity": "sha512-g6T+p7QO8npa+/hNx9ohv1E5pVCmWrVCUzUXJyLdMmftX6ER0oiWY/w9knEonLpnOp6b6FenKnMfR8gqwWdwig==", "license": "MIT" }, + "node_modules/socket.io": { + "version": "4.8.3", + "resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.8.3.tgz", + "integrity": "sha512-2Dd78bqzzjE6KPkD5fHZmDAKRNe3J15q+YHDrIsy9WEkqttc7GY+kT9OBLSMaPbQaEd0x1BjcmtMtXkfpc+T5A==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.4", + "base64id": "~2.0.0", + "cors": "~2.8.5", + "debug": "~4.4.1", + "engine.io": "~6.6.0", + "socket.io-adapter": "~2.5.2", + "socket.io-parser": "~4.2.4" + }, + "engines": { + "node": ">=10.2.0" + } + }, + "node_modules/socket.io-adapter": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.6.tgz", + "integrity": "sha512-DkkO/dz7MGln0dHn5bmN3pPy+JmywNICWrJqVWiVOyvXjWQFIv9c2h24JrQLLFJ2aQVQf/Cvl1vblnd4r2apLQ==", + "license": "MIT", + "dependencies": { + "debug": "~4.4.1", + "ws": "~8.18.3" + } + }, + "node_modules/socket.io-adapter/node_modules/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "license": "MIT", + "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/socket.io-client": { "version": "4.8.3", "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.3.tgz", @@ -13295,7 +13833,6 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz", "integrity": "sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -13350,6 +13887,40 @@ "node": ">=10" } }, + "node_modules/terser-webpack-plugin": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.5.0.tgz", + "integrity": "sha512-UYhptBwhWvfIjKd/UuFo6D8uq9xpGLDK+z8EDsj/zWhrTaH34cKEbrkMKfV5YWqGBvAYA3tlzZbs2R+qYrbQJA==", + "license": "MIT", + "peer": true, + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "jest-worker": "^27.4.5", + "schema-utils": "^4.3.0", + "terser": "^5.31.1" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.1.0" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "uglify-js": { + "optional": true + } + } + }, "node_modules/tiny-invariant": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", @@ -13707,7 +14278,6 @@ "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", - "dev": true, "license": "MIT" }, "node_modules/unicode-canonical-property-names-ecmascript": { @@ -13865,6 +14435,15 @@ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/victory-vendor": { "version": "36.9.2", "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz", @@ -14073,6 +14652,20 @@ "loose-envify": "^1.0.0" } }, + "node_modules/watchpack": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.5.1.tgz", + "integrity": "sha512-Zn5uXdcFNIA1+1Ei5McRd+iRzfhENPCe7LeABkJtNulSxjma+l7ltNx55BWZkRlwRnpOgHqxnjyaDgJnNXnqzg==", + "license": "MIT", + "peer": true, + "dependencies": { + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.1.2" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/web-vitals": { "version": "4.2.4", "resolved": "https://registry.npmjs.org/web-vitals/-/web-vitals-4.2.4.tgz", @@ -14089,6 +14682,105 @@ "node": ">=12" } }, + "node_modules/webpack": { + "version": "5.106.2", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.106.2.tgz", + "integrity": "sha512-wGN3qcrBQIFmQ/c0AiOAQBvrZ5lmY8vbbMv4Mxfgzqd/B6+9pXtLo73WuS1dSGXM5QYY3hZnIbvx+K1xxe6FyA==", + "license": "MIT", + "peer": true, + "dependencies": { + "@types/eslint-scope": "^3.7.7", + "@types/estree": "^1.0.8", + "@types/json-schema": "^7.0.15", + "@webassemblyjs/ast": "^1.14.1", + "@webassemblyjs/wasm-edit": "^1.14.1", + "@webassemblyjs/wasm-parser": "^1.14.1", + "acorn": "^8.16.0", + "acorn-import-phases": "^1.0.3", + "browserslist": "^4.28.1", + "chrome-trace-event": "^1.0.2", + "enhanced-resolve": "^5.20.0", + "es-module-lexer": "^2.0.0", + "eslint-scope": "5.1.1", + "events": "^3.2.0", + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.2.11", + "loader-runner": "^4.3.1", + "mime-db": "^1.54.0", + "neo-async": "^2.6.2", + "schema-utils": "^4.3.3", + "tapable": "^2.3.0", + "terser-webpack-plugin": "^5.3.17", + "watchpack": "^2.5.1", + "webpack-sources": "^3.3.4" + }, + "bin": { + "webpack": "bin/webpack.js" + }, + "engines": { + "node": ">=10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependenciesMeta": { + "webpack-cli": { + "optional": true + } + } + }, + "node_modules/webpack-sources": { + "version": "3.4.0", + "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.4.0.tgz", + "integrity": "sha512-gHwIe1cgBvvfLeu1Yz/dcFpmHfKDVxxyqI+kzqmuxZED81z2ChxpyqPaWcNqigPywhaEke7AjSGga+kxY55gjQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/webpack/node_modules/es-module-lexer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", + "license": "MIT", + "peer": true + }, + "node_modules/webpack/node_modules/eslint-scope": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", + "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", + "license": "BSD-2-Clause", + "peer": true, + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^4.1.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/webpack/node_modules/estraverse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", + "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", + "license": "BSD-2-Clause", + "peer": true, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/webpack/node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "peer": true, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/whatwg-encoding": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", diff --git a/package.json b/package.json index 37fd18f4..d9a5cc27 100644 --- a/package.json +++ b/package.json @@ -65,6 +65,7 @@ "react-virtualized-auto-sizer": "^1.0.7", "react-window": "^1.8.9", "recharts": "^2.15.4", + "socket.io": "^4.8.3", "socket.io-client": "^4.8.3", "tailwind-merge": "^2.6.0", "web-vitals": "^4.2.4", diff --git a/src/features/collaboration/server/webSocketServer.ts b/src/features/collaboration/server/webSocketServer.ts new file mode 100644 index 00000000..3c1e0d90 --- /dev/null +++ b/src/features/collaboration/server/webSocketServer.ts @@ -0,0 +1,132 @@ +import { Server as HttpServer } from 'http'; +import { Server as SocketIOServer } from 'socket.io'; +import type { Socket } from 'socket.io'; +import { applyTextOperation } from '../operations'; +import type { CollaborationMessage, PresenceState, SyncState } from '../types'; + +interface RoomState { + content: string; + version: number; + presence: Map; +} + +const roomStore = new Map(); +const socketMembership = new Map>(); + +const getRoomState = (roomId: string): RoomState => { + const existing = roomStore.get(roomId); + if (existing) { + return existing; + } + + const created: RoomState = { + content: '', + version: 0, + presence: new Map(), + }; + + roomStore.set(roomId, created); + return created; +}; + +const buildSyncState = (roomId: string, roomState: RoomState): SyncState => { + return { + roomId, + content: roomState.content, + version: roomState.version, + presence: [...roomState.presence.values()], + }; +}; + +const emitSync = (socket: Socket, roomId: string, roomState: RoomState): void => { + socket.emit('collaboration:message', { + type: 'sync', + state: buildSyncState(roomId, roomState), + } satisfies CollaborationMessage); +}; + +export const setupCollaborationWebSocketServer = (httpServer: HttpServer): SocketIOServer => { + const io = new SocketIOServer(httpServer, { + cors: { + origin: '*', + methods: ['GET', 'POST'], + }, + path: '/api/collaboration/socket', + }); + + io.on('connection', (socket: Socket) => { + socket.on('collaboration:message', (message: CollaborationMessage) => { + if (message.type === 'join') { + const roomState = getRoomState(message.roomId); + + socket.join(message.roomId); + roomState.presence.set(message.presence.clientId, message.presence); + socketMembership.set(socket.id, [ + ...(socketMembership.get(socket.id) ?? []), + { roomId: message.roomId, clientId: message.presence.clientId }, + ]); + + emitSync(socket, message.roomId, roomState); + socket.to(message.roomId).emit('collaboration:message', { + type: 'presence', + roomId: message.roomId, + presence: message.presence, + } satisfies CollaborationMessage); + + return; + } + + if (message.type === 'presence') { + const roomState = getRoomState(message.roomId); + roomState.presence.set(message.presence.clientId, message.presence); + socket.to(message.roomId).emit('collaboration:message', message); + return; + } + + if (message.type === 'operation') { + const roomState = getRoomState(message.roomId); + + if (message.operation.baseVersion > roomState.version) { + emitSync(socket, message.roomId, roomState); + return; + } + + roomState.content = applyTextOperation(roomState.content, message.operation); + roomState.version += 1; + + socket.emit('collaboration:message', { + type: 'ack', + roomId: message.roomId, + operationId: message.operation.id, + version: roomState.version, + } satisfies CollaborationMessage); + + io.to(message.roomId).emit('collaboration:message', { + ...message, + version: roomState.version, + } satisfies CollaborationMessage); + } + }); + + socket.on('disconnect', () => { + const memberships = socketMembership.get(socket.id) ?? []; + + memberships.forEach(({ roomId, clientId }) => { + const roomState = roomStore.get(roomId); + if (!roomState) { + return; + } + + roomState.presence.delete(clientId); + + if (roomState.presence.size === 0) { + roomStore.delete(roomId); + } + }); + + socketMembership.delete(socket.id); + }); + }); + + return io; +}; diff --git a/src/features/collaboration/types.ts b/src/features/collaboration/types.ts new file mode 100644 index 00000000..ed4a346f --- /dev/null +++ b/src/features/collaboration/types.ts @@ -0,0 +1,69 @@ +export type CollaborationOperationType = 'insert' | 'delete' | 'replace'; + +export interface CursorPosition { + line: number; + column: number; +} + +export interface PresenceState { + clientId: string; + name: string; + color: string; + avatar: string; + roomId: string; + cursor?: CursorPosition; + lastActiveAt: number; +} + +export interface TextOperation { + id: string; + roomId: string; + clientId: string; + baseVersion: number; + type: CollaborationOperationType; + index: number; + length?: number; + text?: string; + timestamp: number; +} + +export interface SyncState { + roomId: string; + content: string; + version: number; + presence: PresenceState[]; +} + +export type CollaborationMessage = + | { + type: 'join'; + roomId: string; + presence: PresenceState; + } + | { + type: 'presence'; + roomId: string; + presence: PresenceState; + } + | { + type: 'operation'; + roomId: string; + operation: TextOperation; + version: number; + presence?: PresenceState; + } + | { + type: 'sync'; + state: SyncState; + } + | { + type: 'ack'; + roomId: string; + operationId: string; + version: number; + } + | { + type: 'error'; + roomId: string; + message: string; + }; diff --git a/src/hooks/useWebSocket.ts b/src/hooks/useWebSocket.ts new file mode 100644 index 00000000..80e0bada --- /dev/null +++ b/src/hooks/useWebSocket.ts @@ -0,0 +1,183 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; + +type ConnectionStatus = 'idle' | 'connecting' | 'connected' | 'disconnected' | 'error'; +type ConnectionMode = 'websocket' | 'broadcast' | 'disabled'; + +interface UseWebSocketOptions { + url?: string; + roomId?: string; + localChannelKey?: string; + enabled?: boolean; + reconnectDelayMs?: number; + parse?: (raw: string) => TMessage; + serialize?: (message: TMessage) => string; +} + +interface UseWebSocketResult { + status: ConnectionStatus; + isConnected: boolean; + mode: ConnectionMode; + lastMessage: TMessage | null; + sendMessage: (message: TMessage) => void; +} + +export const useWebSocket = ({ + url, + roomId, + localChannelKey, + enabled = true, + reconnectDelayMs = 1500, + parse, + serialize, +}: UseWebSocketOptions): UseWebSocketResult => { + const [status, setStatus] = useState(enabled ? 'connecting' : 'idle'); + const [mode, setMode] = useState('disabled'); + const [lastMessage, setLastMessage] = useState(null); + + const socketRef = useRef(null); + const channelRef = useRef(null); + const reconnectTimerRef = useRef | null>(null); + + const canUseWindow = typeof window !== 'undefined'; + + const safeParse = useCallback( + (raw: string): TMessage => { + if (parse) { + return parse(raw); + } + return JSON.parse(raw) as TMessage; + }, + [parse], + ); + + const safeSerialize = useCallback( + (message: TMessage): string => { + if (serialize) { + return serialize(message); + } + return JSON.stringify(message); + }, + [serialize], + ); + + const cleanup = useCallback(() => { + if (reconnectTimerRef.current) { + clearTimeout(reconnectTimerRef.current); + reconnectTimerRef.current = null; + } + + if (socketRef.current) { + socketRef.current.close(); + socketRef.current = null; + } + + if (channelRef.current) { + channelRef.current.close(); + channelRef.current = null; + } + }, []); + + useEffect(() => { + if (!enabled || !canUseWindow) { + cleanup(); + setMode('disabled'); + setStatus(enabled ? 'connecting' : 'idle'); + return; + } + + if (url) { + let cancelled = false; + + const connect = () => { + if (cancelled) { + return; + } + + setMode('websocket'); + setStatus('connecting'); + + const socket = new WebSocket(url); + socketRef.current = socket; + + socket.onopen = () => { + if (!cancelled) { + setStatus('connected'); + } + }; + + socket.onmessage = (event) => { + try { + const message = safeParse(String(event.data)); + setLastMessage(message); + } catch { + setStatus('error'); + } + }; + + socket.onerror = () => { + if (!cancelled) { + setStatus('error'); + } + }; + + socket.onclose = () => { + if (cancelled) { + return; + } + + setStatus('disconnected'); + reconnectTimerRef.current = setTimeout(connect, reconnectDelayMs); + }; + }; + + connect(); + + return () => { + cancelled = true; + cleanup(); + }; + } + + const channelName = localChannelKey ?? `collaboration-room:${roomId ?? 'default'}`; + setMode('broadcast'); + setStatus('connected'); + + const channel = new BroadcastChannel(channelName); + channelRef.current = channel; + + channel.onmessage = (event) => { + setLastMessage(event.data as TMessage); + }; + + return () => { + cleanup(); + setStatus('disconnected'); + }; + }, [canUseWindow, cleanup, enabled, localChannelKey, reconnectDelayMs, roomId, safeParse, url]); + + const sendMessage = useCallback( + (message: TMessage) => { + if (!enabled) { + return; + } + + if (mode === 'websocket' && socketRef.current?.readyState === WebSocket.OPEN) { + socketRef.current.send(safeSerialize(message)); + return; + } + + if (mode === 'broadcast' && channelRef.current) { + channelRef.current.postMessage(message); + } + }, + [enabled, mode, safeSerialize], + ); + + return { + status, + isConnected: status === 'connected', + mode, + lastMessage, + sendMessage, + }; +}; diff --git a/src/pages/events/__tests__/events.test.tsx b/src/testing/events/events.test.tsx similarity index 100% rename from src/pages/events/__tests__/events.test.tsx rename to src/testing/events/events.test.tsx From a9c24c0e8f0742ec8a2115f1c3ec4bc41c49f335 Mon Sep 17 00:00:00 2001 From: UFObject247 Date: Tue, 28 Apr 2026 15:15:26 +0100 Subject: [PATCH 2/4] feat(collaboration): add text operation conflict resolution --- .../collaboration/conflictResolution.ts | 43 +++++++++ src/features/collaboration/index.ts | 3 + src/features/collaboration/operations.ts | 91 +++++++++++++++++++ 3 files changed, 137 insertions(+) create mode 100644 src/features/collaboration/conflictResolution.ts create mode 100644 src/features/collaboration/index.ts create mode 100644 src/features/collaboration/operations.ts diff --git a/src/features/collaboration/conflictResolution.ts b/src/features/collaboration/conflictResolution.ts new file mode 100644 index 00000000..053b1028 --- /dev/null +++ b/src/features/collaboration/conflictResolution.ts @@ -0,0 +1,43 @@ +import { getOperationNetDelta } from './operations'; +import type { TextOperation } from './types'; + +const transformIndexAgainstOperation = (index: number, against: TextOperation): number => { + if (against.type === 'insert') { + const insertLength = against.text?.length ?? 0; + if (against.index <= index) { + return index + insertLength; + } + return index; + } + + const removedLength = against.length ?? 0; + const start = against.index; + const end = start + removedLength; + + if (index <= start) { + return index; + } + + if (index <= end) { + return start; + } + + return index + getOperationNetDelta(against); +}; + +export const transformIncomingOperation = ( + incomingOperation: TextOperation, + pendingLocalOperations: TextOperation[], +): TextOperation => { + const transformed: TextOperation = { ...incomingOperation }; + + for (const pending of pendingLocalOperations) { + if (pending.id === transformed.id) { + continue; + } + + transformed.index = transformIndexAgainstOperation(transformed.index, pending); + } + + return transformed; +}; diff --git a/src/features/collaboration/index.ts b/src/features/collaboration/index.ts new file mode 100644 index 00000000..1b4c1d67 --- /dev/null +++ b/src/features/collaboration/index.ts @@ -0,0 +1,3 @@ +export * from './types'; +export * from './operations'; +export * from './conflictResolution'; diff --git a/src/features/collaboration/operations.ts b/src/features/collaboration/operations.ts new file mode 100644 index 00000000..bf881a81 --- /dev/null +++ b/src/features/collaboration/operations.ts @@ -0,0 +1,91 @@ +import type { TextOperation } from './types'; + +interface CreateOperationMeta { + roomId: string; + clientId: string; + baseVersion: number; + timestamp: number; +} + +const clamp = (value: number, min: number, max: number): number => { + return Math.min(max, Math.max(min, value)); +}; + +const generateOperationId = (clientId: string, timestamp: number): string => { + return `${clientId}-${timestamp}-${Math.random().toString(36).slice(2, 8)}`; +}; + +export const applyTextOperation = (content: string, operation: TextOperation): string => { + const safeIndex = clamp(operation.index, 0, content.length); + + if (operation.type === 'insert') { + const insertion = operation.text ?? ''; + return `${content.slice(0, safeIndex)}${insertion}${content.slice(safeIndex)}`; + } + + const length = clamp(operation.length ?? 0, 0, content.length - safeIndex); + + if (operation.type === 'delete') { + return `${content.slice(0, safeIndex)}${content.slice(safeIndex + length)}`; + } + + const replacement = operation.text ?? ''; + return `${content.slice(0, safeIndex)}${replacement}${content.slice(safeIndex + length)}`; +}; + +export const createTextOperationFromChange = ( + previous: string, + next: string, + meta: CreateOperationMeta, +): TextOperation | null => { + if (previous === next) { + return null; + } + + let prefixLength = 0; + const minLength = Math.min(previous.length, next.length); + + while (prefixLength < minLength && previous[prefixLength] === next[prefixLength]) { + prefixLength += 1; + } + + let previousSuffix = previous.length; + let nextSuffix = next.length; + + while ( + previousSuffix > prefixLength && + nextSuffix > prefixLength && + previous[previousSuffix - 1] === next[nextSuffix - 1] + ) { + previousSuffix -= 1; + nextSuffix -= 1; + } + + const removedLength = previousSuffix - prefixLength; + const insertedText = next.slice(prefixLength, nextSuffix); + + let type: TextOperation['type'] = 'replace'; + if (removedLength === 0) { + type = 'insert'; + } else if (insertedText.length === 0) { + type = 'delete'; + } + + return { + id: generateOperationId(meta.clientId, meta.timestamp), + roomId: meta.roomId, + clientId: meta.clientId, + baseVersion: meta.baseVersion, + type, + index: prefixLength, + length: removedLength > 0 ? removedLength : undefined, + text: insertedText.length > 0 ? insertedText : undefined, + timestamp: meta.timestamp, + }; +}; + +export const getOperationNetDelta = (operation: TextOperation): number => { + const insertedLength = operation.text?.length ?? 0; + const removedLength = operation.length ?? 0; + return insertedLength - removedLength; +}; From d06758e892e03792e3116f3f0d806d0e7c3968a9 Mon Sep 17 00:00:00 2001 From: UFObject247 Date: Tue, 28 Apr 2026 15:15:26 +0100 Subject: [PATCH 3/4] feat(collaboration): wire live presence and cursor indicators --- src/components/code/AdvancedCodeEditor.tsx | 11 +- src/components/code/CollaborativeEditing.tsx | 47 ++- src/hooks/useCodeEditor.tsx | 344 ++++++++++++++++--- 3 files changed, 320 insertions(+), 82 deletions(-) diff --git a/src/components/code/AdvancedCodeEditor.tsx b/src/components/code/AdvancedCodeEditor.tsx index df843d84..a2c755fb 100644 --- a/src/components/code/AdvancedCodeEditor.tsx +++ b/src/components/code/AdvancedCodeEditor.tsx @@ -26,8 +26,8 @@ import type { CompletionSuggestion } from '@/utils/codeUtils'; // Configure Monaco Web Worker to use CDN to prevent main-thread blocking loader.config({ paths: { - vs: 'https://cdn.jsdelivr.net/npm/monaco-editor@0.43.0/min/vs' - } + vs: 'https://cdn.jsdelivr.net/npm/monaco-editor@0.43.0/min/vs', + }, }); // Lazy-load Monaco using Next.js dynamic to avoid SSR issues and improve initial render @@ -99,6 +99,7 @@ export const AdvancedCodeEditor: React.FC = ({ output, validationErrors, collaborators, + isCollaborationConnected, autoCompleteEnabled, currentWord, languages, @@ -259,7 +260,11 @@ export const AdvancedCodeEditor: React.FC = ({ onToggle={toggleAutoComplete} onSelect={handleSuggestionSelect} /> - + diff --git a/src/components/code/CollaborativeEditing.tsx b/src/components/code/CollaborativeEditing.tsx index a917099f..85795492 100644 --- a/src/components/code/CollaborativeEditing.tsx +++ b/src/components/code/CollaborativeEditing.tsx @@ -1,45 +1,25 @@ -import React, { useEffect, useState } from 'react'; +import React from 'react'; import { Users, Wifi } from 'lucide-react'; import type { Collaborator } from '@/hooks/useCodeEditor'; interface CollaborativeEditingProps { collaborators: Collaborator[]; roomId?: string; + isConnected?: boolean; } export const CollaborativeEditing: React.FC = ({ collaborators, roomId, + isConnected = false, }) => { - const [isConnected, setIsConnected] = useState(false); - const [activeCount, setActiveCount] = useState(collaborators.length); - - // Simulate a Socket.IO connection lifecycle - useEffect(() => { - if (!roomId) return; - - // Simulate connection delay - const connectTimer = setTimeout(() => { - setIsConnected(true); - }, 800); - - // Simulate occasional collaborator join/leave - const updateTimer = setInterval(() => { - setActiveCount((prev) => { - const delta = Math.random() > 0.5 ? 0 : Math.random() > 0.5 ? 1 : -1; - return Math.max(0, Math.min(collaborators.length + 2, prev + delta)); - }); - }, 8000); - - return () => { - clearTimeout(connectTimer); - clearInterval(updateTimer); - setIsConnected(false); - }; - }, [roomId, collaborators.length]); + const activeCount = collaborators.length; const visibleCollaborators = collaborators.slice(0, 4); const overflow = Math.max(0, activeCount - 4); + const liveCursorPreview = collaborators + .filter((user) => user.cursorLine && user.cursorColumn) + .slice(0, 2); return (
@@ -107,6 +87,19 @@ export const CollaborativeEditing: React.FC = ({ {activeCount} live
+ + {liveCursorPreview.length > 0 && ( +
+ {liveCursorPreview.map((user) => ( + + {user.name}: L{user.cursorLine}, C{user.cursorColumn} + + ))} +
+ )} ); diff --git a/src/hooks/useCodeEditor.tsx b/src/hooks/useCodeEditor.tsx index cccd44ad..dfd50aa5 100644 --- a/src/hooks/useCodeEditor.tsx +++ b/src/hooks/useCodeEditor.tsx @@ -1,4 +1,4 @@ -import { useState, useCallback, useRef } from 'react'; +import { useState, useCallback, useRef, useEffect, useMemo } from 'react'; import type { editor } from 'monaco-editor'; import { getLanguageConfig, @@ -9,6 +9,15 @@ import { type ExecutionResult, type LanguageConfig, } from '@/utils/codeUtils'; +import { + applyTextOperation, + createTextOperationFromChange, + transformIncomingOperation, + type CollaborationMessage, + type PresenceState, + type TextOperation, +} from '@/features/collaboration'; +import { useWebSocket } from './useWebSocket'; // --------------------------------------------------------------------------- // Types @@ -21,6 +30,8 @@ export interface Collaborator { avatar: string; cursorLine?: number; cursorColumn?: number; + isSelf?: boolean; + lastActiveAt?: number; } export interface UseCodeEditorOptions { @@ -40,6 +51,7 @@ export interface UseCodeEditorReturn { output: ExecutionResult | null; validationErrors: Array<{ line: number; message: string }>; collaborators: Collaborator[]; + isCollaborationConnected: boolean; autoCompleteEnabled: boolean; currentWord: string; @@ -65,36 +77,42 @@ export interface UseCodeEditorReturn { handleEditorMount: (editorInstance: editor.IStandaloneCodeEditor) => void; } -// --------------------------------------------------------------------------- -// Mock collaborator data (simulates Socket.IO presence) -// --------------------------------------------------------------------------- +const PRESENCE_COLORS = ['#6366f1', '#ec4899', '#10b981', '#f59e0b', '#06b6d4']; + +const randomName = (): string => { + const names = ['Alex', 'Jordan', 'Taylor', 'Casey', 'Morgan', 'Reese']; + return names[Math.floor(Math.random() * names.length)] ?? 'Collaborator'; +}; + +const buildSelfPresence = (roomId: string): PresenceState => { + const timestamp = Date.now(); + const clientId = `client-${timestamp}-${Math.random().toString(36).slice(2, 7)}`; + const name = `${randomName()} ${Math.floor(Math.random() * 90) + 10}`; + const color = PRESENCE_COLORS[Math.floor(Math.random() * PRESENCE_COLORS.length)] ?? '#6366f1'; + + return { + clientId, + roomId, + name, + color, + avatar: `https://api.dicebear.com/7.x/avataaars/svg?seed=${encodeURIComponent(name)}`, + cursor: { line: 1, column: 1 }, + lastActiveAt: timestamp, + }; +}; -const MOCK_COLLABORATORS: Collaborator[] = [ - { - id: 'user-1', - name: 'Alice', - color: '#6366f1', - avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=Alice', - cursorLine: 3, - cursorColumn: 10, - }, - { - id: 'user-2', - name: 'Bob', - color: '#ec4899', - avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=Bob', - cursorLine: 7, - cursorColumn: 5, - }, - { - id: 'user-3', - name: 'Charlie', - color: '#10b981', - avatar: 'https://api.dicebear.com/7.x/avataaars/svg?seed=Charlie', - cursorLine: 12, - cursorColumn: 1, - }, -]; +const mapPresenceToCollaborator = (presence: PresenceState, selfId: string): Collaborator => { + return { + id: presence.clientId, + name: presence.name, + color: presence.color, + avatar: presence.avatar, + cursorLine: presence.cursor?.line, + cursorColumn: presence.cursor?.column, + isSelf: presence.clientId === selfId, + lastActiveAt: presence.lastActiveAt, + }; +}; // --------------------------------------------------------------------------- // Hook @@ -103,6 +121,7 @@ const MOCK_COLLABORATORS: Collaborator[] = [ export const useCodeEditor = ({ initialCode, initialLanguage = 'javascript', + roomId, onCodeChange, }: UseCodeEditorOptions = {}): UseCodeEditorReturn => { const langConfig = getLanguageConfig(initialLanguage); @@ -115,12 +134,186 @@ export const useCodeEditor = ({ const [validationErrors, setValidationErrors] = useState< Array<{ line: number; message: string }> >([]); - const [collaborators] = useState(MOCK_COLLABORATORS); + const [collaborators, setCollaborators] = useState([]); const [autoCompleteEnabled, setAutoCompleteEnabled] = useState(true); const [currentWord, setCurrentWord] = useState(''); const [code, setCodeState] = useState(initialCode ?? langConfig.defaultCode); const editorRef = useRef(null); + const codeRef = useRef(initialCode ?? langConfig.defaultCode); + const versionRef = useRef(0); + const pendingOperationsRef = useRef([]); + const cursorRef = useRef<{ line: number; column: number }>({ line: 1, column: 1 }); + const applyingRemoteUpdateRef = useRef(false); + const lastPresenceBroadcastRef = useRef(0); + + const selfPresence = useMemo(() => buildSelfPresence(roomId ?? 'local-room'), [roomId]); + const selfPresenceRef = useRef(selfPresence); + + useEffect(() => { + selfPresenceRef.current = selfPresence; + }, [selfPresence]); + + const { + isConnected: isCollaborationConnected, + lastMessage, + sendMessage, + } = useWebSocket({ + enabled: Boolean(roomId), + roomId, + url: process.env.NEXT_PUBLIC_COLLAB_WS_URL, + localChannelKey: roomId ? `teachlink-collab:${roomId}` : undefined, + }); + + const upsertCollaborator = useCallback((presence: PresenceState) => { + setCollaborators((previous) => { + const nextCollaborator = mapPresenceToCollaborator( + presence, + selfPresenceRef.current.clientId, + ); + const existingIndex = previous.findIndex( + (collaborator) => collaborator.id === nextCollaborator.id, + ); + + if (existingIndex === -1) { + return [...previous, nextCollaborator]; + } + + const cloned = [...previous]; + cloned[existingIndex] = nextCollaborator; + return cloned; + }); + }, []); + + const applyRemoteCode = useCallback( + (nextCode: string) => { + applyingRemoteUpdateRef.current = true; + codeRef.current = nextCode; + setCodeState(nextCode); + onCodeChange?.(nextCode); + + const result = validateCode(language, nextCode); + setValidationErrors(result.errors); + + setTimeout(() => { + applyingRemoteUpdateRef.current = false; + }, 0); + }, + [language, onCodeChange], + ); + + const broadcastPresence = useCallback(() => { + if (!roomId) { + return; + } + + const now = Date.now(); + const nextPresence: PresenceState = { + ...selfPresenceRef.current, + roomId, + cursor: { ...cursorRef.current }, + lastActiveAt: now, + }; + + selfPresenceRef.current = nextPresence; + upsertCollaborator(nextPresence); + + sendMessage({ + type: 'presence', + roomId, + presence: nextPresence, + }); + }, [roomId, sendMessage, upsertCollaborator]); + + useEffect(() => { + if (!roomId) { + setCollaborators([]); + return; + } + + if (!isCollaborationConnected) { + setCollaborators([ + mapPresenceToCollaborator(selfPresenceRef.current, selfPresenceRef.current.clientId), + ]); + return; + } + + sendMessage({ + type: 'join', + roomId, + presence: { + ...selfPresenceRef.current, + roomId, + }, + }); + + broadcastPresence(); + + const heartbeat = setInterval(() => { + broadcastPresence(); + }, 10000); + + return () => { + clearInterval(heartbeat); + }; + }, [broadcastPresence, isCollaborationConnected, roomId, sendMessage]); + + useEffect(() => { + if (!lastMessage || !roomId) { + return; + } + + if (lastMessage.type === 'sync' && lastMessage.state.roomId === roomId) { + versionRef.current = lastMessage.state.version; + pendingOperationsRef.current = []; + applyRemoteCode(lastMessage.state.content); + setCollaborators( + lastMessage.state.presence.map((presence) => + mapPresenceToCollaborator(presence, selfPresenceRef.current.clientId), + ), + ); + return; + } + + if ('roomId' in lastMessage && lastMessage.roomId !== roomId) { + return; + } + + if (lastMessage.type === 'presence') { + upsertCollaborator(lastMessage.presence); + return; + } + + if (lastMessage.type === 'ack') { + pendingOperationsRef.current = pendingOperationsRef.current.filter( + (operation) => operation.id !== lastMessage.operationId, + ); + versionRef.current = Math.max(versionRef.current, lastMessage.version); + return; + } + + if (lastMessage.type === 'operation') { + if (lastMessage.operation.clientId === selfPresenceRef.current.clientId) { + pendingOperationsRef.current = pendingOperationsRef.current.filter( + (operation) => operation.id !== lastMessage.operation.id, + ); + versionRef.current = Math.max(versionRef.current, lastMessage.version); + return; + } + + const transformedOperation = transformIncomingOperation( + lastMessage.operation, + pendingOperationsRef.current, + ); + const nextCode = applyTextOperation(codeRef.current, transformedOperation); + versionRef.current = Math.max(versionRef.current, lastMessage.version); + applyRemoteCode(nextCode); + + if (lastMessage.presence) { + upsertCollaborator(lastMessage.presence); + } + } + }, [applyRemoteCode, lastMessage, roomId, upsertCollaborator]); // ------------------------------------------------------------------------- // Setters @@ -128,12 +321,48 @@ export const useCodeEditor = ({ const setCode = useCallback( (newCode: string) => { + const previousCode = codeRef.current; + if (previousCode === newCode) { + return; + } + + codeRef.current = newCode; setCodeState(newCode); onCodeChange?.(newCode); const result = validateCode(language, newCode); setValidationErrors(result.errors); + + if (!roomId || applyingRemoteUpdateRef.current) { + return; + } + + const timestamp = Date.now(); + const operation = createTextOperationFromChange(previousCode, newCode, { + roomId, + clientId: selfPresenceRef.current.clientId, + baseVersion: versionRef.current, + timestamp, + }); + + if (!operation) { + return; + } + + pendingOperationsRef.current = [...pendingOperationsRef.current, operation]; + versionRef.current += 1; + sendMessage({ + type: 'operation', + roomId, + operation, + version: versionRef.current, + presence: { + ...selfPresenceRef.current, + cursor: { ...cursorRef.current }, + lastActiveAt: timestamp, + }, + }); }, - [language, onCodeChange], + [language, onCodeChange, roomId, sendMessage], ); const setLanguage = useCallback( @@ -141,6 +370,7 @@ export const useCodeEditor = ({ setLanguageState(lang); const config = getLanguageConfig(lang); setCodeState(config.defaultCode); + codeRef.current = config.defaultCode; onCodeChange?.(config.defaultCode); setOutput(null); setValidationErrors([]); @@ -176,24 +406,22 @@ export const useCodeEditor = ({ }, [language, code]); const handleFormat = useCallback(() => { - const formatted = formatCodeUtil(language, code); - setCodeState(formatted); - onCodeChange?.(formatted); + const formatted = formatCodeUtil(language, codeRef.current); + setCode(formatted); if (editorRef.current) { editorRef.current.setValue(formatted); } - }, [language, code, onCodeChange]); + }, [language, setCode]); const resetCode = useCallback(() => { const config = getLanguageConfig(language); - setCodeState(config.defaultCode); - onCodeChange?.(config.defaultCode); + setCode(config.defaultCode); setOutput(null); setValidationErrors([]); if (editorRef.current) { editorRef.current.setValue(config.defaultCode); } - }, [language, onCodeChange]); + }, [language, setCode]); const clearOutput = useCallback(() => setOutput(null), []); @@ -205,19 +433,30 @@ export const useCodeEditor = ({ // Monaco mount handler // ------------------------------------------------------------------------- - const handleEditorMount = useCallback((editorInstance: editor.IStandaloneCodeEditor) => { - editorRef.current = editorInstance; - - // Track cursor word for auto-completion panel - editorInstance.onDidChangeCursorPosition(() => { - const position = editorInstance.getPosition(); - if (!position) return; - const model = editorInstance.getModel(); - if (!model) return; - const word = model.getWordAtPosition(position); - setCurrentWord(word?.word ?? ''); - }); - }, []); + const handleEditorMount = useCallback( + (editorInstance: editor.IStandaloneCodeEditor) => { + editorRef.current = editorInstance; + + // Track cursor word for auto-completion panel + editorInstance.onDidChangeCursorPosition(() => { + const position = editorInstance.getPosition(); + if (!position) return; + + cursorRef.current = { line: position.lineNumber, column: position.column }; + const model = editorInstance.getModel(); + if (!model) return; + const word = model.getWordAtPosition(position); + setCurrentWord(word?.word ?? ''); + + const now = Date.now(); + if (roomId && now - lastPresenceBroadcastRef.current > 120) { + lastPresenceBroadcastRef.current = now; + broadcastPresence(); + } + }); + }, + [broadcastPresence, roomId], + ); return { // State @@ -229,6 +468,7 @@ export const useCodeEditor = ({ output, validationErrors, collaborators, + isCollaborationConnected, autoCompleteEnabled, currentWord, From 16f3bc8a134b0400f7bfd230a168d03c66701a13 Mon Sep 17 00:00:00 2001 From: UFObject247 Date: Tue, 28 Apr 2026 15:15:27 +0100 Subject: [PATCH 4/4] chore(ci): fix existing build blockers discovered during validation --- src/lib/conflict/resolver.ts | 2 ++ src/utils/web3/index.ts | 1 - 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/src/lib/conflict/resolver.ts b/src/lib/conflict/resolver.ts index 25ceed85..4491dc9c 100644 --- a/src/lib/conflict/resolver.ts +++ b/src/lib/conflict/resolver.ts @@ -1,5 +1,7 @@ import { ConflictRecord, ResolutionStrategy, ProgressData } from './types'; +export type { ConflictRecord, ResolutionStrategy } from './types'; + /** * Detects if a conflict exists between local and remote data based on timestamps and versions. */ diff --git a/src/utils/web3/index.ts b/src/utils/web3/index.ts index fd38dd6b..79532125 100644 --- a/src/utils/web3/index.ts +++ b/src/utils/web3/index.ts @@ -8,7 +8,6 @@ export { validateWeb3Env, getExplorerUrl, formatAddress, - isValidStarknetAddress, type EnvValidationResult, type Web3Config, } from './envValidation';