diff --git a/compose.dev.yml b/compose.dev.yml index d0215809ac..9d70909157 100644 --- a/compose.dev.yml +++ b/compose.dev.yml @@ -47,6 +47,10 @@ services: volumes: - /app/node_modules - ./services/collaboration:/app + + collaboration-db: + ports: + - 27020:27017 broker: ports: diff --git a/frontend/angular.json b/frontend/angular.json index 59d981aec0..eeacf89c31 100644 --- a/frontend/angular.json +++ b/frontend/angular.json @@ -37,8 +37,8 @@ "budgets": [ { "type": "initial", - "maximumWarning": "2MB", - "maximumError": "3MB" + "maximumWarning": "7MB", + "maximumError": "10MB" }, { "type": "anyComponentStyle", diff --git a/frontend/package-lock.json b/frontend/package-lock.json index becb841779..9a4f4f3d1b 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -16,7 +16,15 @@ "@angular/platform-browser": "^18.2.0", "@angular/platform-browser-dynamic": "^18.2.0", "@angular/router": "^18.2.0", + "@codemirror/commands": "^6.7.1", + "@codemirror/lang-cpp": "^6.0.2", + "@codemirror/lang-go": "^6.0.1", "@codemirror/lang-java": "^6.0.1", + "@codemirror/lang-javascript": "^6.2.2", + "@codemirror/lang-php": "^6.0.1", + "@codemirror/lang-python": "^6.1.6", + "@codemirror/lang-rust": "^6.0.1", + "@codemirror/lang-sql": "^6.8.0", "@codemirror/theme-one-dark": "^6.1.0", "codemirror": "^6.0.1", "primeflex": "^3.3.1", @@ -34,6 +42,8 @@ "@angular-devkit/build-angular": "^18.2.2", "@angular/cli": "^18.2.2", "@angular/compiler-cli": "^18.2.0", + "@prettier/plugin-php": "^0.22.2", + "@prettier/plugin-xml": "^3.4.1", "@types/jasmine": "~5.1.0", "angular-eslint": "18.3.1", "eslint": "^9.9.1", @@ -48,6 +58,8 @@ "prettier": "3.3.3", "prettier-eslint": "^16.3.0", "prettier-plugin-java": "^2.6.0", + "prettier-plugin-rust": "^0.1.9", + "prettier-plugin-sql": "^0.18.1", "typescript": "~5.5.2", "typescript-eslint": "8.2.0" } @@ -2667,9 +2679,9 @@ } }, "node_modules/@codemirror/commands": { - "version": "6.6.2", - "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.6.2.tgz", - "integrity": "sha512-Fq7eWOl1Rcbrfn6jD8FPCj9Auaxdm5nIK5RYOeW7ughnd/rY5AmPg6b+CfsG39ZHdwiwe8lde3q8uR7CF5S0yQ==", + "version": "6.7.1", + "resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.7.1.tgz", + "integrity": "sha512-llTrboQYw5H4THfhN4U3qCnSZ1SOJ60ohhz+SzU0ADGtwlc533DtklQP0vSFaQuCPDn3BPpOd1GbbnUtwNjsrw==", "license": "MIT", "dependencies": { "@codemirror/language": "^6.0.0", @@ -2678,6 +2690,58 @@ "@lezer/common": "^1.1.0" } }, + "node_modules/@codemirror/lang-cpp": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@codemirror/lang-cpp/-/lang-cpp-6.0.2.tgz", + "integrity": "sha512-6oYEYUKHvrnacXxWxYa6t4puTlbN3dgV662BDfSH8+MfjQjVmP697/KYTDOqpxgerkvoNm7q5wlFMBeX8ZMocg==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@lezer/cpp": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-css": { + "version": "6.3.0", + "resolved": "git+ssh://git@github.com/codemirror/lang-css.git#8bad51270d44d030f6ca45700ad827839358f518", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@lezer/common": "^1.0.2", + "@lezer/css": "^1.1.7" + } + }, + "node_modules/@codemirror/lang-go": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@codemirror/lang-go/-/lang-go-6.0.1.tgz", + "integrity": "sha512-7fNvbyNylvqCphW9HD6WFnRpcDjr+KXX/FgqXy5H5ZS0eC5edDljukm/yNgYkwTsgp2busdod50AOTIy6Jikfg==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/language": "^6.6.0", + "@codemirror/state": "^6.0.0", + "@lezer/common": "^1.0.0", + "@lezer/go": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-html": { + "version": "6.4.9", + "resolved": "https://registry.npmjs.org/@codemirror/lang-html/-/lang-html-6.4.9.tgz", + "integrity": "sha512-aQv37pIMSlueybId/2PVSP6NPnmurFDVmZwzc7jszd2KAF8qd4VBbvNYPXWQq90WIARjsdVkPbw29pszmHws3Q==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/lang-css": "^6.0.0", + "@codemirror/lang-javascript": "^6.0.0", + "@codemirror/language": "^6.4.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0", + "@lezer/css": "^1.1.0", + "@lezer/html": "^1.3.0" + } + }, "node_modules/@codemirror/lang-java": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/@codemirror/lang-java/-/lang-java-6.0.1.tgz", @@ -2688,6 +2752,71 @@ "@lezer/java": "^1.0.0" } }, + "node_modules/@codemirror/lang-javascript": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.2.tgz", + "integrity": "sha512-VGQfY+FCc285AhWuwjYxQyUQcYurWlxdKYT4bqwr3Twnd5wP5WSeu52t4tvvuWmljT4EmgEgZCqSieokhtY8hg==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/language": "^6.6.0", + "@codemirror/lint": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@codemirror/view": "^6.17.0", + "@lezer/common": "^1.0.0", + "@lezer/javascript": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-php": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@codemirror/lang-php/-/lang-php-6.0.1.tgz", + "integrity": "sha512-ublojMdw/PNWa7qdN5TMsjmqkNuTBD3k6ndZ4Z0S25SBAiweFGyY68AS3xNcIOlb6DDFDvKlinLQ40vSLqf8xA==", + "license": "MIT", + "dependencies": { + "@codemirror/lang-html": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@lezer/common": "^1.0.0", + "@lezer/php": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-python": { + "version": "6.1.6", + "resolved": "https://registry.npmjs.org/@codemirror/lang-python/-/lang-python-6.1.6.tgz", + "integrity": "sha512-ai+01WfZhWqM92UqjnvorkxosZ2aq2u28kHvr+N3gu012XqY2CThD67JPMHnGceRfXPDBmn1HnyqowdpF57bNg==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.3.2", + "@codemirror/language": "^6.8.0", + "@codemirror/state": "^6.0.0", + "@lezer/common": "^1.2.1", + "@lezer/python": "^1.1.4" + } + }, + "node_modules/@codemirror/lang-rust": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/@codemirror/lang-rust/-/lang-rust-6.0.1.tgz", + "integrity": "sha512-344EMWFBzWArHWdZn/NcgkwMvZIWUR1GEBdwG8FEp++6o6vT6KL9V7vGs2ONsKxxFUPXKI0SPcWhyYyl2zPYxQ==", + "license": "MIT", + "dependencies": { + "@codemirror/language": "^6.0.0", + "@lezer/rust": "^1.0.0" + } + }, + "node_modules/@codemirror/lang-sql": { + "version": "6.8.0", + "resolved": "https://registry.npmjs.org/@codemirror/lang-sql/-/lang-sql-6.8.0.tgz", + "integrity": "sha512-aGLmY4OwGqN3TdSx3h6QeA1NrvaYtF7kkoWR/+W7/JzB0gQtJ+VJxewlnE3+VImhA4WVlhmkJr109PefOOhjLg==", + "license": "MIT", + "dependencies": { + "@codemirror/autocomplete": "^6.0.0", + "@codemirror/language": "^6.0.0", + "@codemirror/state": "^6.0.0", + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, "node_modules/@codemirror/language": { "version": "6.10.3", "resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.10.3.tgz", @@ -3944,6 +4073,39 @@ "integrity": "sha512-Z+R3hN6kXbgBWAuejUNPihylAL1Z5CaFqnIe0nTX8Ej+XlIy3EGtXxn6WtLMO+os2hRkQvm2yvaGMYliUzlJaw==", "license": "MIT" }, + "node_modules/@lezer/cpp": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@lezer/cpp/-/cpp-1.1.2.tgz", + "integrity": "sha512-macwKtyeUO0EW86r3xWQCzOV9/CF8imJLpJlPv3sDY57cPGeUZ8gXWOWNlJr52TVByMV3PayFQCA5SHEERDmVQ==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@lezer/css": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@lezer/css/-/css-1.1.9.tgz", + "integrity": "sha512-TYwgljcDv+YrV0MZFFvYFQHCfGgbPMR6nuqLabBdmZoFH3EP1gvw8t0vae326Ne3PszQkbXfVBjCnf3ZVCr0bA==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@lezer/go": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/@lezer/go/-/go-1.0.0.tgz", + "integrity": "sha512-co9JfT3QqX1YkrMmourYw2Z8meGC50Ko4d54QEcQbEYpvdUvN4yb0NBZdn/9ertgvjsySxHsKzH3lbm3vqJ4Jw==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, "node_modules/@lezer/highlight": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.1.tgz", @@ -3953,6 +4115,17 @@ "@lezer/common": "^1.0.0" } }, + "node_modules/@lezer/html": { + "version": "1.3.10", + "resolved": "https://registry.npmjs.org/@lezer/html/-/html-1.3.10.tgz", + "integrity": "sha512-dqpT8nISx/p9Do3AchvYGV3qYc4/rKr3IBZxlHmpIKam56P47RSHkSF5f13Vu9hebS1jM0HmtJIwLbWz1VIY6w==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, "node_modules/@lezer/java": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/@lezer/java/-/java-1.1.3.tgz", @@ -3964,6 +4137,17 @@ "@lezer/lr": "^1.0.0" } }, + "node_modules/@lezer/javascript": { + "version": "1.4.19", + "resolved": "https://registry.npmjs.org/@lezer/javascript/-/javascript-1.4.19.tgz", + "integrity": "sha512-j44kbR1QL26l6dMunZ1uhKBFteVGLVCBGNUD2sUaMnic+rbTviVuoK0CD1l9FTW31EueWvFFswCKMH7Z+M3JRA==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.1.3", + "@lezer/lr": "^1.3.0" + } + }, "node_modules/@lezer/lr": { "version": "1.4.2", "resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.2.tgz", @@ -3973,6 +4157,39 @@ "@lezer/common": "^1.0.0" } }, + "node_modules/@lezer/php": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@lezer/php/-/php-1.0.2.tgz", + "integrity": "sha512-GN7BnqtGRpFyeoKSEqxvGvhJQiI4zkgmYnDk/JIyc7H7Ifc1tkPnUn/R2R8meH3h/aBf5rzjvU8ZQoyiNDtDrA==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.1.0" + } + }, + "node_modules/@lezer/python": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/@lezer/python/-/python-1.1.14.tgz", + "integrity": "sha512-ykDOb2Ti24n76PJsSa4ZoDF0zH12BSw1LGfQXCYJhJyOGiFTfGaX0Du66Ze72R+u/P35U+O6I9m8TFXov1JzsA==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, + "node_modules/@lezer/rust": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@lezer/rust/-/rust-1.0.2.tgz", + "integrity": "sha512-Lz5sIPBdF2FUXcWeCu1//ojFAZqzTQNRga0aYv6dYXqJqPfMdCAI0NzajWUd4Xijj1IKJLtjoXRPMvTKWBcqKg==", + "license": "MIT", + "dependencies": { + "@lezer/common": "^1.2.0", + "@lezer/highlight": "^1.0.0", + "@lezer/lr": "^1.0.0" + } + }, "node_modules/@listr2/prompt-adapter-inquirer": { "version": "2.0.15", "resolved": "https://registry.npmjs.org/@listr2/prompt-adapter-inquirer/-/prompt-adapter-inquirer-2.0.15.tgz", @@ -4487,6 +4704,33 @@ "url": "https://opencollective.com/unts" } }, + "node_modules/@prettier/plugin-php": { + "version": "0.22.2", + "resolved": "https://registry.npmjs.org/@prettier/plugin-php/-/plugin-php-0.22.2.tgz", + "integrity": "sha512-md0+7tNbsP0oy+wIP3KZZc6fzx1k1jtWaMjOy/gM8yU9f2BDYEi+iHOc/UNPihYvPI28zFTbjvlhH4QXQjQwNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "linguist-languages": "^7.27.0", + "php-parser": "^3.1.5" + }, + "peerDependencies": { + "prettier": "^3.0.0" + } + }, + "node_modules/@prettier/plugin-xml": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/@prettier/plugin-xml/-/plugin-xml-3.4.1.tgz", + "integrity": "sha512-Uf/6/+9ez6z/IvZErgobZ2G9n1ybxF5BhCd7eMcKqfoWuOzzNUxBipNo3QAP8kRC1VD18TIo84no7LhqtyDcTg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@xml-tools/parser": "^1.0.11" + }, + "peerDependencies": { + "prettier": "^3.0.0" + } + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.22.4", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.22.4.tgz", @@ -5912,6 +6156,26 @@ "@xtuc/long": "4.2.2" } }, + "node_modules/@xml-tools/parser": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@xml-tools/parser/-/parser-1.0.11.tgz", + "integrity": "sha512-aKqQ077XnR+oQtHJlrAflaZaL7qZsulWc/i/ZEooar5JiWj1eLt0+Wg28cpa+XLney107wXqneC+oG1IZvxkTA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "chevrotain": "7.1.1" + } + }, + "node_modules/@xml-tools/parser/node_modules/chevrotain": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/chevrotain/-/chevrotain-7.1.1.tgz", + "integrity": "sha512-wy3mC1x4ye+O+QkEinVJkPf5u2vsrDIYW9G7ZuwFl6v/Yu0LwUuT2POsb+NUWApebyxfkQq6+yDfRExbnI5rcw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "regexp-to-ast": "0.5.0" + } + }, "node_modules/@xtuc/ieee754": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", @@ -6423,6 +6687,16 @@ "dev": true, "license": "MIT" }, + "node_modules/big-integer": { + "version": "1.6.52", + "resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.52.tgz", + "integrity": "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==", + "dev": true, + "license": "Unlicense", + "engines": { + "node": ">=0.6" + } + }, "node_modules/big.js": { "version": "5.2.2", "resolved": "https://registry.npmjs.org/big.js/-/big.js-5.2.2.tgz", @@ -7774,6 +8048,13 @@ "node": ">=8" } }, + "node_modules/discontinuous-range": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/discontinuous-range/-/discontinuous-range-1.0.0.tgz", + "integrity": "sha512-c68LpLbO+7kP/b1Hr1qs8/BJ09F5khZGTxqxZuhzxpmwJKOgRFHJWIb9/KmqnqHhLdO55aOxFH/EGBvUQbL/RQ==", + "dev": true, + "license": "MIT" + }, "node_modules/dlv": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", @@ -9192,6 +9473,19 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/get-stdin": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/get-stdin/-/get-stdin-8.0.0.tgz", + "integrity": "sha512-sY22aA6xchAzprjyqmSEQv4UbAAzRN0L2dQB0NlN5acTTK9Don6nhoc3eAbUnpZiCANAMfd/+40kVdKfFygohg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/get-stream": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", @@ -10307,6 +10601,13 @@ "url": "https://github.com/chalk/supports-color?sponsor=1" } }, + "node_modules/jinx-rust": { + "version": "0.1.6", + "resolved": "https://registry.npmjs.org/jinx-rust/-/jinx-rust-0.1.6.tgz", + "integrity": "sha512-qP+wtQL1PrDDFwtPKhNGtjWOmijCrKdfUHWTV2G/ikxfjrh+cjdvkQTmny9RAsVF0jiui9m+F0INWu4cuRcZeQ==", + "dev": true, + "license": "MIT" + }, "node_modules/jiti": { "version": "1.21.6", "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.6.tgz", @@ -10428,6 +10729,16 @@ ], "license": "MIT" }, + "node_modules/jsox": { + "version": "1.2.121", + "resolved": "https://registry.npmjs.org/jsox/-/jsox-1.2.121.tgz", + "integrity": "sha512-9Ag50tKhpTwS6r5wh3MJSAvpSof0UBr39Pto8OnzFT32Z/pAbxAsKHzyvsyMEHVslELvHyO/4/jaQELHk8wDcw==", + "dev": true, + "license": "MIT", + "bin": { + "jsox": "lib/cli.js" + } + }, "node_modules/karma": { "version": "6.4.4", "resolved": "https://registry.npmjs.org/karma/-/karma-6.4.4.tgz", @@ -11126,6 +11437,13 @@ "dev": true, "license": "MIT" }, + "node_modules/linguist-languages": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/linguist-languages/-/linguist-languages-7.27.0.tgz", + "integrity": "sha512-Wzx/22c5Jsv2ag+uKy+ITanGA5hzvBZngrNGDXLTC7ZjGM6FLCYGgomauTkxNJeP9of353OM0pWqngYA180xgw==", + "dev": true, + "license": "MIT" + }, "node_modules/listr2": { "version": "8.2.4", "resolved": "https://registry.npmjs.org/listr2/-/listr2-8.2.4.tgz", @@ -12085,6 +12403,13 @@ "mkdirp": "bin/cmd.js" } }, + "node_modules/moo": { + "version": "0.5.2", + "resolved": "https://registry.npmjs.org/moo/-/moo-0.5.2.tgz", + "integrity": "sha512-iSAJLHYKnX41mKcJKjqvnAN9sf0LMDTXDEvFv+ffuRR9a1MIuXLjMNL6EsnDHSkKLTWNqQQ5uo61P4EbU4NU+Q==", + "dev": true, + "license": "BSD-3-Clause" + }, "node_modules/mrmime": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/mrmime/-/mrmime-2.0.0.tgz", @@ -12192,6 +12517,29 @@ "dev": true, "license": "MIT" }, + "node_modules/nearley": { + "version": "2.20.1", + "resolved": "https://registry.npmjs.org/nearley/-/nearley-2.20.1.tgz", + "integrity": "sha512-+Mc8UaAebFzgV+KpI5n7DasuuQCHA89dmwm7JXw3TV43ukfNQ9DnBH3Mdb2g/I4Fdxc26pwimBWvjIw0UAILSQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "commander": "^2.19.0", + "moo": "^0.5.0", + "railroad-diagrams": "^1.0.0", + "randexp": "0.4.6" + }, + "bin": { + "nearley-railroad": "bin/nearley-railroad.js", + "nearley-test": "bin/nearley-test.js", + "nearley-unparse": "bin/nearley-unparse.js", + "nearleyc": "bin/nearleyc.js" + }, + "funding": { + "type": "individual", + "url": "https://nearley.js.org/#give-to-nearley" + } + }, "node_modules/needle": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/needle/-/needle-3.3.1.tgz", @@ -12389,6 +12737,19 @@ "dev": true, "license": "MIT" }, + "node_modules/node-sql-parser": { + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/node-sql-parser/-/node-sql-parser-4.18.0.tgz", + "integrity": "sha512-2YEOR5qlI1zUFbGMLKNfsrR5JUvFg9LxIRVE+xJe962pfVLH0rnItqLzv96XVs1Y1UIR8FxsXAuvX/lYAWZ2BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "big-integer": "^1.6.48" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/nopt": { "version": "7.2.1", "resolved": "https://registry.npmjs.org/nopt/-/nopt-7.2.1.tgz", @@ -13146,6 +13507,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/php-parser": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/php-parser/-/php-parser-3.1.5.tgz", + "integrity": "sha512-jEY2DcbgCm5aclzBdfW86GM6VEIWcSlhTBSHN1qhJguVePlYe28GhwS0yoeLYXpM2K8y6wzLwrbq814n2PHSoQ==", + "dev": true, + "license": "BSD-3-Clause" + }, "node_modules/picocolors": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.0.tgz", @@ -14057,6 +14425,55 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, + "node_modules/prettier-plugin-rust": { + "version": "0.1.9", + "resolved": "https://registry.npmjs.org/prettier-plugin-rust/-/prettier-plugin-rust-0.1.9.tgz", + "integrity": "sha512-n1DTTJQaHMdnoG/+nKUvBm3EKsMVWsYES2UPCiOPiZdBrmuAO/pX++m7L3+Hz3uuhtddpH0HRKHB2F3jbtJBOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "jinx-rust": "0.1.6", + "prettier": "^2.7.1" + } + }, + "node_modules/prettier-plugin-rust/node_modules/prettier": { + "version": "2.8.8", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.8.8.tgz", + "integrity": "sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin-prettier.js" + }, + "engines": { + "node": ">=10.13.0" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/prettier-plugin-sql": { + "version": "0.18.1", + "resolved": "https://registry.npmjs.org/prettier-plugin-sql/-/prettier-plugin-sql-0.18.1.tgz", + "integrity": "sha512-2+Nob2sg7hzLAKJoE6sfgtkhBZCqOzrWHZPvE4Kee/e80oOyI4qwy9vypeltqNBJwTtq3uiKPrCxlT03bBpOaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "jsox": "^1.2.119", + "node-sql-parser": "^4.12.0", + "sql-formatter": "^15.0.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/unts" + }, + "peerDependencies": { + "prettier": "^3.0.3" + } + }, "node_modules/pretty-format": { "version": "29.7.0", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-29.7.0.tgz", @@ -14236,6 +14653,27 @@ ], "license": "MIT" }, + "node_modules/railroad-diagrams": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/railroad-diagrams/-/railroad-diagrams-1.0.0.tgz", + "integrity": "sha512-cz93DjNeLY0idrCNOH6PviZGRN9GJhsdm9hpn1YCS879fj4W+x5IFJhhkRZcwVgMmFF7R82UA/7Oh+R8lLZg6A==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/randexp": { + "version": "0.4.6", + "resolved": "https://registry.npmjs.org/randexp/-/randexp-0.4.6.tgz", + "integrity": "sha512-80WNmd9DA0tmZrw9qQa62GPPWfuXJknrmVmLcxvq4uZBdYqb1wYoKTmnlGUchvVWe0XiLupYkBoXVOxz3C8DYQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "discontinuous-range": "1.0.0", + "ret": "~0.1.10" + }, + "engines": { + "node": ">=0.12" + } + }, "node_modules/randombytes": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", @@ -14371,6 +14809,13 @@ "dev": true, "license": "MIT" }, + "node_modules/regexp-to-ast": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/regexp-to-ast/-/regexp-to-ast-0.5.0.tgz", + "integrity": "sha512-tlbJqcMHnPKI9zSrystikWKwHkBqu2a/Sgw01h3zFjvYrMxEDYHzzoMZnUrbIfpTFEsoRnnviOXNCzFiSc54Qw==", + "dev": true, + "license": "MIT" + }, "node_modules/regexpu-core": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-5.3.2.tgz", @@ -14532,6 +14977,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/ret": { + "version": "0.1.15", + "resolved": "https://registry.npmjs.org/ret/-/ret-0.1.15.tgz", + "integrity": "sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12" + } + }, "node_modules/retry": { "version": "0.12.0", "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", @@ -15421,6 +15876,21 @@ "dev": true, "license": "BSD-3-Clause" }, + "node_modules/sql-formatter": { + "version": "15.4.5", + "resolved": "https://registry.npmjs.org/sql-formatter/-/sql-formatter-15.4.5.tgz", + "integrity": "sha512-dxYn0OzEmB19/9Y+yh8bqD8kJx2S/4pOTM4QLKxQDh7K6lp1Sx9MhmiF9RUJHSVjfV72KihW5R1h6Kecy6O5qA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1", + "get-stdin": "=8.0.0", + "nearley": "^2.20.1" + }, + "bin": { + "sql-formatter": "bin/sql-formatter-cli.cjs" + } + }, "node_modules/ssri": { "version": "10.0.6", "resolved": "https://registry.npmjs.org/ssri/-/ssri-10.0.6.tgz", diff --git a/frontend/package.json b/frontend/package.json index 5d310e53ca..a940aa91c3 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -20,7 +20,15 @@ "@angular/platform-browser": "^18.2.0", "@angular/platform-browser-dynamic": "^18.2.0", "@angular/router": "^18.2.0", + "@codemirror/commands": "^6.7.1", + "@codemirror/lang-cpp": "^6.0.2", + "@codemirror/lang-go": "^6.0.1", "@codemirror/lang-java": "^6.0.1", + "@codemirror/lang-javascript": "^6.2.2", + "@codemirror/lang-php": "^6.0.1", + "@codemirror/lang-python": "^6.1.6", + "@codemirror/lang-rust": "^6.0.1", + "@codemirror/lang-sql": "^6.8.0", "@codemirror/theme-one-dark": "^6.1.0", "codemirror": "^6.0.1", "primeflex": "^3.3.1", @@ -38,6 +46,8 @@ "@angular-devkit/build-angular": "^18.2.2", "@angular/cli": "^18.2.2", "@angular/compiler-cli": "^18.2.0", + "@prettier/plugin-php": "^0.22.2", + "@prettier/plugin-xml": "^3.4.1", "@types/jasmine": "~5.1.0", "angular-eslint": "18.3.1", "eslint": "^9.9.1", @@ -52,6 +62,8 @@ "prettier": "3.3.3", "prettier-eslint": "^16.3.0", "prettier-plugin-java": "^2.6.0", + "prettier-plugin-rust": "^0.1.9", + "prettier-plugin-sql": "^0.18.1", "typescript": "~5.5.2", "typescript-eslint": "8.2.0" } diff --git a/frontend/public/pair-programming.png b/frontend/public/pair-programming.png new file mode 100644 index 0000000000..940dbe915b Binary files /dev/null and b/frontend/public/pair-programming.png differ diff --git a/frontend/src/_services/collab.guard.service.ts b/frontend/src/_services/collab.guard.service.ts index a4a9b0690b..84d135c47b 100644 --- a/frontend/src/_services/collab.guard.service.ts +++ b/frontend/src/_services/collab.guard.service.ts @@ -4,6 +4,7 @@ import { Observable, of, combineLatest } from 'rxjs'; import { catchError, map, switchMap } from 'rxjs/operators'; import { CollabService } from './collab.service'; import { AuthenticationService } from './authentication.service'; +import { ToastService } from './toast.service'; @Injectable({ providedIn: 'root', @@ -13,6 +14,7 @@ export class CollabGuardService implements CanActivate { private collabService: CollabService, private router: Router, private authService: AuthenticationService, + private toastService: ToastService, ) {} canActivate(route: ActivatedRouteSnapshot): Observable { @@ -22,7 +24,7 @@ export class CollabGuardService implements CanActivate { return combineLatest([roomId$, user$]).pipe( switchMap(([roomId, user]) => { if (!roomId || !user) { - this.router.navigate(['/matching']); + this.router.navigate(['/home']); return of(false); } @@ -32,14 +34,26 @@ export class CollabGuardService implements CanActivate { const isOpen = response.data.room_status; const isForfeit = response.data.users.find(roomUser => roomUser?.id === user.id)?.isForfeit; - if (!isFound || !isOpen || isForfeit) { - this.router.navigate(['/matching']); + if (!isOpen) { + this.toastService.showToast('You cannot enter this session as it already had ended.'); + this.router.navigate(['/home']); return false; } + if (isForfeit) { + this.toastService.showToast('You have already forfeited in this session.'); + this.router.navigate(['/home']); + return false; + } + if (!isFound) { + this.toastService.showToast('Are you sure you are in the right session room?'); + this.router.navigate(['/home']); + return false; + } + return true; }), catchError(() => { - this.router.navigate(['/matching']); + this.router.navigate(['/home']); return of(false); }), ); diff --git a/frontend/src/_services/collab.service.ts b/frontend/src/_services/collab.service.ts index ec8c961cd5..fde85853f5 100644 --- a/frontend/src/_services/collab.service.ts +++ b/frontend/src/_services/collab.service.ts @@ -19,13 +19,22 @@ export class CollabService extends ApiService { super(); } + getRoomsWithQuery(isActive: boolean, isForfeit: boolean) { + const params = new URLSearchParams({ + roomStatus: isActive.toString(), + isForfeit: isForfeit.toString(), + }).toString(); + + return this.http.get(`${this.apiUrl}/?${params}`); + } + /** * Retrieves all room IDs for a given user, but only if the room is still * active (room_status is true). One user can have multiple rooms, * and each room is identified by a unique room_id. */ getRooms() { - return this.http.get(this.apiUrl + 'user/rooms'); + return this.http.get(this.apiUrl + '/user/rooms'); } /** diff --git a/frontend/src/_services/toast.service.ts b/frontend/src/_services/toast.service.ts new file mode 100644 index 0000000000..cbc23d8e02 --- /dev/null +++ b/frontend/src/_services/toast.service.ts @@ -0,0 +1,14 @@ +import { Injectable } from '@angular/core'; +import { Subject } from 'rxjs'; + +@Injectable({ + providedIn: 'root', +}) +export class ToastService { + private toastSubject = new Subject(); + toast$ = this.toastSubject.asObservable(); + + showToast(message: string) { + this.toastSubject.next(message); + } +} diff --git a/frontend/src/app/account/login.component.ts b/frontend/src/app/account/login.component.ts index 8733b14b6c..45faf09541 100644 --- a/frontend/src/app/account/login.component.ts +++ b/frontend/src/app/account/login.component.ts @@ -26,7 +26,7 @@ export class LoginComponent { ) { //redirect to home if already logged in if (this.authenticationService.userValue) { - this.router.navigate(['/matching']); + this.router.navigate(['/home']); } } @@ -44,7 +44,7 @@ export class LoginComponent { // authenticationService returns an observable that we can subscribe to this.authenticationService.login(this.userForm.username, this.userForm.password).subscribe({ next: () => { - this.router.navigate(['/matching']); + this.router.navigate(['/home']); }, error: error => { this.isProcessingLogin = false; diff --git a/frontend/src/app/account/register.component.ts b/frontend/src/app/account/register.component.ts index 3324e4957e..01d73883f6 100644 --- a/frontend/src/app/account/register.component.ts +++ b/frontend/src/app/account/register.component.ts @@ -48,7 +48,7 @@ export class RegisterComponent { ) { // redirect to home if already logged in if (this.authenticationService.userValue) { - this.router.navigate(['/matching']); + this.router.navigate(['/home']); } } @@ -151,7 +151,7 @@ export class RegisterComponent { .pipe() .subscribe({ next: () => { - this.router.navigate(['/matching']); + this.router.navigate(['/home']); }, // error handling for registration because we assume there will be no errors with auto login error: error => { diff --git a/frontend/src/app/app.routes.ts b/frontend/src/app/app.routes.ts index 9b50f97036..59b77f39aa 100644 --- a/frontend/src/app/app.routes.ts +++ b/frontend/src/app/app.routes.ts @@ -2,6 +2,7 @@ import { Routes } from '@angular/router'; import { QuestionsComponent } from './questions/questions.component'; import { CollaborationComponent } from './collaboration/collaboration.component'; import { MatchingComponent } from './matching/matching.component'; +import { HomeComponent } from './home/home.component'; import { AuthGuardService } from '../_services/auth.guard.service'; import { CollabGuardService } from '../_services/collab.guard.service'; @@ -27,4 +28,18 @@ export const routes: Routes = [ component: MatchingComponent, canActivate: [AuthGuardService], }, + { + path: 'home', + component: HomeComponent, + canActivate: [AuthGuardService], + }, + { + path: '**', + redirectTo: '/home', + }, + { + path: '', + redirectTo: '/home', + pathMatch: 'full', + }, ]; diff --git a/frontend/src/app/collaboration/chat-box/chat-box.component.css b/frontend/src/app/collaboration/chat-box/chat-box.component.css new file mode 100644 index 0000000000..f98f01caf9 --- /dev/null +++ b/frontend/src/app/collaboration/chat-box/chat-box.component.css @@ -0,0 +1,143 @@ +.card { + display: flex; + flex-direction: column; + height: 100%; +} + +.container { + display: flex; + flex-direction: column; + flex: 1; + overflow: hidden; +} + +:host ::ng-deep .messages { + flex: 1; + overflow-y: auto; + padding: 10px; + background-color: var(--surface-section); + color: #ffffff; + border-radius: 5px; + margin-top: 5px; + width: 100%; + scrollbar-width: none; +} + +.input-container { + display: flex; + align-items: center; + padding: 10px 0; + background-color: var(--surface-section); + width: 100%; +} + +input[type='text'] { + flex: 1; + padding: 8px; + margin-right: 10px; + background-color: #333; + color: #fff; + border-radius: 5px; + border: none; + outline: none; +} + +button { + padding: 8px 15px; + background-color: #3d3d3d; + color: #fff; + border: none; + cursor: pointer; + border-radius: 5px; +} + +button:hover { + background-color: #555; +} + +.username-left { + font-weight: bold; + color: #e0e0e0; + font-size: 0.9em; + text-align: left; + margin-bottom: 2px; + align-self: flex-start; +} + +.username-right { + font-weight: bold; + color: #e0e0e0; + font-size: 0.9em; + text-align: right; + margin-bottom: 2px; + align-self: flex-end; +} + +.username-system-left { + color: #ff4c4c; + text-align: left; + font-style: italic; + width: 100%; +} + +.username-system-right { + color: #ff4c4c; + text-align: left; + font-style: italic; + width: 100%; +} + +.message-container { + margin-bottom: 10px; +} + +.message-left, .message-right { + display: flex; + flex-direction: column; +} + +.message-left { + align-items: flex-start; +} + +.message-right { + align-items: flex-end; +} + +.message-system { + text-align: left; + color: #ff4c4c; + font-style: italic; +} + +.message-box { + min-width: 100px; + max-width: 300px; + padding: 8px 12px; + background-color: #222; + border-radius: 4px; + color: #ffffff; + word-wrap: break-word; + white-space: pre-wrap; +} + +.timestamp { + font-size: 0.75em; + color: rgba(255, 255, 255, 0.6); + margin-top: 3px; + align-self: flex-start; +} + +.message-right .timestamp { + align-self: flex-end; +} + +button + button { + margin-left: 8px; +} + +input[disabled], button[disabled] { + background-color: #555; + cursor: not-allowed; + opacity: 0.6; +} diff --git a/frontend/src/app/collaboration/chat-box/chat-box.component.html b/frontend/src/app/collaboration/chat-box/chat-box.component.html new file mode 100644 index 0000000000..0182804dc3 --- /dev/null +++ b/frontend/src/app/collaboration/chat-box/chat-box.component.html @@ -0,0 +1,41 @@ +
+
+
+
+
+
+
+
+ {{ message.sender }} +
+
+
{{ message.text }}
+
{{ message.timestamp }}
+
+
+
+
+ + + +
+
+
+
+
+
diff --git a/frontend/src/app/collaboration/chat-box/chat-box.component.spec.ts b/frontend/src/app/collaboration/chat-box/chat-box.component.spec.ts new file mode 100644 index 0000000000..a7763cc079 --- /dev/null +++ b/frontend/src/app/collaboration/chat-box/chat-box.component.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { ChatBoxComponent } from './chat-box.component'; + +describe('ChatBoxComponent', () => { + let component: ChatBoxComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ChatBoxComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(ChatBoxComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/frontend/src/app/collaboration/chat-box/chat-box.component.ts b/frontend/src/app/collaboration/chat-box/chat-box.component.ts new file mode 100644 index 0000000000..0acd62ec40 --- /dev/null +++ b/frontend/src/app/collaboration/chat-box/chat-box.component.ts @@ -0,0 +1,135 @@ +import { AfterViewInit, Component, ElementRef, Input, ViewChild } from '@angular/core'; +import * as Y from 'yjs'; +import { AuthenticationService } from '../../../_services/authentication.service'; +import { WebsocketProvider } from 'y-websocket'; +import { NgClass, NgForOf } from '@angular/common'; +import { ScrollPanelModule } from 'primeng/scrollpanel'; + +@Component({ + selector: 'app-chat-box', + standalone: true, + imports: [NgForOf, NgClass, ScrollPanelModule], + templateUrl: './chat-box.component.html', + styleUrls: ['./chat-box.component.css'], +}) +export class ChatBoxComponent implements AfterViewInit { + @Input() ydoc!: Y.Doc; + @Input() wsProvider!: WebsocketProvider; + @Input() roomId!: string; + + @ViewChild('chatInput') chatInput!: ElementRef; + @ViewChild('messagesContainer') messagesContainer!: ElementRef; + + messages: { text: string; sender: string; timestamp: string; visibleTo: boolean }[] = []; + yChatArray!: Y.Array<{ text: string; sender: string; timestamp: string; visibleTo: boolean }>; + yMute!: Y.Map; + username!: string; + isMuted = false; + + constructor(private authService: AuthenticationService) {} + + ngAfterViewInit() { + this.getUsername(); + this.initYdoc(); + this.initYMap(); + this.initArrayListener(); + } + + getUsername() { + this.username = this.authService.userValue?.username || 'Guest'; + } + + initYdoc() { + this.yChatArray = this.ydoc.getArray('chatMessages'); + } + + initYMap() { + this.yMute = this.ydoc.getMap('muteStatus'); + + if (!this.yMute.has(this.username)) { + this.yMute.set(this.username, false); + } + + this.yMute.observe(() => { + this.isMuted = this.yMute.get(this.username) ?? false; + }); + } + + initArrayListener() { + this.yChatArray.observe(() => { + const allMessages = this.yChatArray.toArray(); + this.messages = allMessages.filter(message => { + return message.sender === this.username || message.visibleTo || message.sender === 'System'; + }); + this.scrollToBottom(); + }); + } + + sendMessage() { + if (this.isMuted) return; + const message = this.chatInput.nativeElement.value.trim(); + if (message) { + const timestamp = new Date().toLocaleString('en-SG', { + day: 'numeric', + month: 'numeric', + year: '2-digit', + hour: 'numeric', + minute: '2-digit', + hour12: true, + }); + + const otherUserMuted = this.yMute.get(this.getOtherUsername()) ?? false; + + const newMessage = { + text: message, + sender: this.username, + timestamp, + visibleTo: !otherUserMuted, + }; + + this.yChatArray.push([newMessage]); + this.chatInput.nativeElement.value = ''; + this.scrollToBottom(); + } + } + + toggleMute() { + const timestamp = new Date().toLocaleString('en-SG', { + day: 'numeric', + month: 'numeric', + year: '2-digit', + hour: 'numeric', + minute: '2-digit', + hour12: true, + }); + this.isMuted = !this.isMuted; + this.yMute.set(this.username, this.isMuted); + + if (this.isMuted) { + this.sendSystemMessage(`${this.username} has muted the conversation.`, timestamp); + } else { + this.sendSystemMessage(`${this.username} has un-muted the conversation.`, timestamp); + } + } + + sendSystemMessage(text: string, timestamp: string) { + const systemMessage = { + text, + sender: 'System', + timestamp, + visibleTo: true, + }; + this.yChatArray.push([systemMessage]); + } + + getOtherUsername(): string { + const allUsers = Array.from(this.yMute.keys()); + return allUsers.find(user => user !== this.username) || 'Guest'; + } + + scrollToBottom() { + setTimeout(() => { + this.messagesContainer.nativeElement.scrollTop = this.messagesContainer.nativeElement.scrollHeight; + }, 100); + } +} diff --git a/frontend/src/app/collaboration/collab.model.ts b/frontend/src/app/collaboration/collab.model.ts index 72f41328db..6b1fb3a9aa 100644 --- a/frontend/src/app/collaboration/collab.model.ts +++ b/frontend/src/app/collaboration/collab.model.ts @@ -12,7 +12,7 @@ export interface CloseRoomResponse { export interface RoomsResponse { status: string; - data: string[]; + data: RoomData[]; } export interface CollabUser { @@ -22,7 +22,7 @@ export interface CollabUser { isForfeit: boolean; } -interface RoomData { +export interface RoomData { room_id: string; users: CollabUser[]; question: Question; @@ -38,3 +38,20 @@ export interface awarenessData { colorLight: string; }; } + +// export interface Question { +// _id: string; +// id: number; +// description: string; +// difficulty: string; +// title: string; +// topics: string[]; +// } + +// export interface Question { +// id: number; +// description: string; +// difficulty: string; +// title: string; +// topics?: string[]; +// } diff --git a/frontend/src/app/collaboration/collaboration.component.css b/frontend/src/app/collaboration/collaboration.component.css index 035bef9897..5bb7706768 100644 --- a/frontend/src/app/collaboration/collaboration.component.css +++ b/frontend/src/app/collaboration/collaboration.component.css @@ -17,9 +17,36 @@ display: flex; flex-direction: column; align-items: center; - margin-top: 1rem; +} + +:host ::ng-deep .p-splitter { + border: 0px; } ::ng-deep .b1 { height: calc(100% - 80px); } + +:host .grid { + margin-right: 0rem; + margin-left: 0rem; + margin-top: 0rem; +} + +:host ::ng-deep .p-splitter-gutter:hover { + background-color: #bbbbbb; + cursor: pointer; +} + +:host ::ng-deep .p-splitter-gutter { + transition: background-color 0.3s ease; +} + +:host ::ng-deep .p-splitter-gutter-handle { + background-color: #bbbbbb; + cursor: pointer; +} + +:host ::ng-deep .p-splitter-gutter-handle { + transition: background-color 0.3s ease; +} diff --git a/frontend/src/app/collaboration/collaboration.component.html b/frontend/src/app/collaboration/collaboration.component.html index bcfb99efb4..d11092dca3 100644 --- a/frontend/src/app/collaboration/collaboration.component.html +++ b/frontend/src/app/collaboration/collaboration.component.html @@ -1,8 +1,13 @@ - + - +
+ + +
- +
+ +
diff --git a/frontend/src/app/collaboration/collaboration.component.ts b/frontend/src/app/collaboration/collaboration.component.ts index f2ba127e52..a718cfc0dc 100644 --- a/frontend/src/app/collaboration/collaboration.component.ts +++ b/frontend/src/app/collaboration/collaboration.component.ts @@ -1,13 +1,65 @@ -import { Component } from '@angular/core'; +import { Component, OnInit, OnDestroy } from '@angular/core'; import { QuestionBoxComponent } from './question-box/question-box.component'; import { EditorComponent } from './editor/editor.component'; import { SplitterModule } from 'primeng/splitter'; +import { ChatBoxComponent } from './chat-box/chat-box.component'; +import * as Y from 'yjs'; +import { WebsocketProvider } from 'y-websocket'; +import { RoomService } from './room.service'; +import { AuthenticationService } from '../../_services/authentication.service'; +import { WebSocketCode } from './websocket-code.enum'; +import { Router } from '@angular/router'; +import { environment } from '../../environments/environment'; @Component({ selector: 'app-collaboration', standalone: true, - imports: [QuestionBoxComponent, EditorComponent, SplitterModule], + imports: [QuestionBoxComponent, EditorComponent, SplitterModule, ChatBoxComponent], templateUrl: './collaboration.component.html', styleUrl: './collaboration.component.css', }) -export class CollaborationComponent {} +export class CollaborationComponent implements OnInit, OnDestroy { + ydoc!: Y.Doc; + roomId!: string; + wsProvider!: WebsocketProvider; + + constructor( + private roomService: RoomService, + private authService: AuthenticationService, + private router: Router, + ) {} + + ngOnDestroy() { + // This lets the client to disconnect from the websocket on re-route to another page. + this.wsProvider.destroy(); + } + + ngOnInit() { + this.initRoomId(); + this.initConnection(); + } + + initRoomId() { + this.roomService.getRoomId().subscribe(id => { + this.roomId = id!; + }); + } + + initConnection() { + this.ydoc = new Y.Doc(); + + const websocketUrl = environment.wsUrl + 'collaboration/'; + this.wsProvider = new WebsocketProvider(websocketUrl, this.roomId, this.ydoc, { + params: { + accessToken: this.authService.userValue?.accessToken || '', + }, + }); + + this.wsProvider.ws!.onclose = (event: { code: number; reason: string }) => { + if (event.code === WebSocketCode.AUTH_FAILED || event.code === WebSocketCode.ROOM_CLOSED) { + console.error('WebSocket authorization failed:', event.reason); + this.router.navigate(['/home']); + } + }; + } +} diff --git a/frontend/src/app/collaboration/editor/editor.component.html b/frontend/src/app/collaboration/editor/editor.component.html index 278d5b49e2..16c6f00a89 100644 --- a/frontend/src/app/collaboration/editor/editor.component.html +++ b/frontend/src/app/collaboration/editor/editor.component.html @@ -1,18 +1,45 @@ -
-
-
+
+
+

Editor

- +
+ +
+
- +
- - + +
diff --git a/frontend/src/app/collaboration/editor/editor.component.ts b/frontend/src/app/collaboration/editor/editor.component.ts index d4de6f8786..e459d3944c 100644 --- a/frontend/src/app/collaboration/editor/editor.component.ts +++ b/frontend/src/app/collaboration/editor/editor.component.ts @@ -1,38 +1,50 @@ -import { AfterViewInit, Component, ElementRef, ViewChild, OnInit, OnDestroy, ChangeDetectorRef } from '@angular/core'; -import { oneDark } from '@codemirror/theme-one-dark'; -import { EditorState, Extension } from '@codemirror/state'; -import { basicSetup } from 'codemirror'; -import { EditorView } from 'codemirror'; -import { java } from '@codemirror/lang-java'; +import { + AfterViewInit, + Component, + ElementRef, + Inject, + ViewChild, + OnInit, + ChangeDetectorRef, + Input, +} from '@angular/core'; +import { DOCUMENT } from '@angular/common'; +import { FormsModule } from '@angular/forms'; +import { DropdownModule } from 'primeng/dropdown'; import { ScrollPanelModule } from 'primeng/scrollpanel'; import { ButtonModule } from 'primeng/button'; -import { ConfirmationService, MessageService } from 'primeng/api'; import { ConfirmDialogModule } from 'primeng/confirmdialog'; import { ToastModule } from 'primeng/toast'; -import * as Y from 'yjs'; -import { WebsocketProvider } from 'y-websocket'; -import { yCollab } from 'y-codemirror.next'; -import * as prettier from 'prettier'; -import * as prettierPluginEstree from 'prettier/plugins/estree'; -import { usercolors } from './user-colors'; +import { MessageService } from 'primeng/api'; import { AuthenticationService } from '../../../_services/authentication.service'; -import { RoomService } from '../room.service'; // The 'prettier-plugin-java' package does not provide TypeScript declaration files. // We are using '@ts-ignore' to bypass TypeScript's missing type declaration error. // eslint-disable-next-line @typescript-eslint/ban-ts-comment // @ts-ignore import prettierPluginJava from 'prettier-plugin-java'; +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore +import prettierPluginPhp from '@prettier/plugin-php'; +import prettierPluginXml from '@prettier/plugin-xml'; +import * as prettierPluginRust from 'prettier-plugin-rust'; +import prettierPluginSql from 'prettier-plugin-sql'; +import parserBabel from 'prettier/plugins/babel'; +import * as prettier from 'prettier'; +import * as prettierPluginEstree from 'prettier/plugins/estree'; +import { oneDark } from '@codemirror/theme-one-dark'; +import { EditorState, Extension, StateEffect } from '@codemirror/state'; +import { EditorView, basicSetup } from 'codemirror'; +import { keymap } from '@codemirror/view'; +import { indentWithTab } from '@codemirror/commands'; +import { yCollab } from 'y-codemirror.next'; +import * as Y from 'yjs'; +import { WebsocketProvider } from 'y-websocket'; import { SubmitDialogComponent } from '../submit-dialog/submit-dialog.component'; import { ForfeitDialogComponent } from '../forfeit-dialog/forfeit-dialog.component'; -import { Router } from '@angular/router'; +import { languageMap, parserMap, LanguageOption } from './languages'; import { awarenessData } from '../collab.model'; -import { environment } from '../../../environments/environment'; - -enum WebSocketCode { - AUTH_FAILED = 4000, - ROOM_CLOSED = 4001, -} +import { usercolors } from './user-colors'; @Component({ selector: 'app-editor', @@ -44,128 +56,113 @@ enum WebSocketCode { ToastModule, SubmitDialogComponent, ForfeitDialogComponent, + DropdownModule, + FormsModule, ], - providers: [ConfirmationService, MessageService], + providers: [MessageService], templateUrl: './editor.component.html', styleUrl: './editor.component.css', }) -export class EditorComponent implements AfterViewInit, OnInit, OnDestroy { +export class EditorComponent implements AfterViewInit, OnInit { @ViewChild('editor') editor!: ElementRef; @ViewChild(ForfeitDialogComponent) forfeitChild!: ForfeitDialogComponent; + @Input() ydoc!: Y.Doc; + @Input() wsProvider!: WebsocketProvider; + @Input() roomId!: string; + state!: EditorState; view!: EditorView; - ydoc!: Y.Doc; yeditorText = new Y.Text(''); ysubmit = new Y.Map(); yforfeit = new Y.Map(); + ylanguage = new Y.Map(); undoManager!: Y.UndoManager; customTheme!: Extension; - wsProvider!: WebsocketProvider; isSubmit = false; isInitiator = false; isForfeitClick = false; - roomId!: string; numUniqueUsers = 0; + selectedLanguage!: string; + languages: LanguageOption[] = []; constructor( + @Inject(DOCUMENT) private document: Document, private messageService: MessageService, private authService: AuthenticationService, - private roomService: RoomService, - private router: Router, private changeDetector: ChangeDetectorRef, ) {} - ngOnDestroy() { - // This lets the client to disconnect from the websocket on re-route to another page. - this.wsProvider.destroy(); - } - ngOnInit() { - this.initRoomId(); - this.initConnection(); + this.initYdoc(); + this.initDoctListener(); this.getNumOfConnectedUsers(); + this.populateLanguages(); } ngAfterViewInit() { this.setTheme(); this.setProvider(); - this.setEditorState(); + this.setEditorState(this.selectedLanguage); this.setEditorView(); this.setCursorPosition(); } - initConnection() { - this.ydoc = new Y.Doc(); - const websocketUrl = environment.wsUrl + 'collaboration/'; - this.wsProvider = new WebsocketProvider(websocketUrl, this.roomId, this.ydoc, { - params: { - accessToken: this.authService.userValue?.accessToken || '', - }, - }); - - this.wsProvider.ws!.onclose = (event: { code: number; reason: string }) => { - if (event.code === WebSocketCode.AUTH_FAILED || event.code === WebSocketCode.ROOM_CLOSED) { - console.error('WebSocket authorization failed:', event.reason); - this.router.navigate(['/matching']); - } - }; - - this.yeditorText = this.ydoc.getText('editorText'); - this.ysubmit = this.ydoc.getMap('submit'); - this.yforfeit = this.ydoc.getMap('forfeit'); - this.undoManager = new Y.UndoManager(this.yeditorText); + populateLanguages() { + this.languages = Object.keys(languageMap).map(lang => ({ + label: lang.charAt(0).toUpperCase() + lang.slice(1), + value: lang, + })); } - getNumOfConnectedUsers() { - this.wsProvider.awareness.on('change', () => { - const data = Array.from(this.wsProvider.awareness.getStates().values()); - const uniqueIds = new Set( - data - .map(x => (x as awarenessData).user?.userId) - .filter((userId): userId is string => userId !== undefined), - ); - - this.numUniqueUsers = uniqueIds.size; - - this.changeDetector.detectChanges(); - }); + changeLanguage(language: string) { + this.selectedLanguage = language.toLowerCase(); + this.ylanguage.set('selected', language); } - showSubmitDialog() { - this.isSubmit = true; - this.isInitiator = true; + updateEditor(language: string) { + this.setEditorState(language); + this.view.setState(this.state); } - initRoomId() { - this.roomService.getRoomId().subscribe(id => { - this.roomId = id!; - }); + updateLanguageExtension(language: string) { + if (languageMap[language]) { + this.view.dispatch({ + effects: StateEffect.reconfigure.of([this.getEditorExtensions(language)]), + }); + } } - async format() { - try { - const currentCode = this.view.state.doc.toString(); + initYdoc() { + this.yeditorText = this.ydoc.getText('editorText'); + this.ysubmit = this.ydoc.getMap('submit'); + this.yforfeit = this.ydoc.getMap('forfeit'); + this.ylanguage = this.ydoc.getMap('language'); + this.undoManager = new Y.UndoManager(this.yeditorText); - const formattedCode = prettier.format(currentCode, { - parser: 'java', - plugins: [prettierPluginJava, prettierPluginEstree], // Add necessary plugins - }); + const language = this.ylanguage.get('selection'); + if (language == undefined) { + this.ylanguage.set('selected', 'java'); + this.selectedLanguage = 'java'; + } else { + this.selectedLanguage = language!; + } + } - this.view.dispatch({ - changes: { - from: 0, - to: this.view.state.doc.length, - insert: await formattedCode, - }, + initDoctListener() { + this.ylanguage.observe(ymapEvent => { + ymapEvent.changes.keys.forEach((change, key) => { + if (change.action === 'update') { + this.selectedLanguage = this.ylanguage.get(key)!; + const languageExtension = languageMap[this.selectedLanguage]; + + if (languageExtension) { + this.updateLanguageExtension(this.selectedLanguage); + } + } }); - - this.view.focus(); - } catch (e) { - console.error('Error formatting code:', e); - this.messageService.add({ severity: 'error', summary: 'Formatting Error' }); - } + }); } setProvider() { @@ -179,20 +176,23 @@ export class EditorComponent implements AfterViewInit, OnInit, OnDestroy { }); } - setEditorState() { - const undoManager = this.undoManager; - const myExt: Extension = [ + setEditorState(language: string) { + this.state = EditorState.create({ + doc: this.yeditorText.toString(), + extensions: this.getEditorExtensions(language), + }); + } + + getEditorExtensions(language: string): Extension[] { + return [ + EditorView.lineWrapping, basicSetup, - java(), + keymap.of([indentWithTab]), + languageMap[language], this.customTheme, oneDark, - yCollab(this.yeditorText, this.wsProvider.awareness, { undoManager }), + yCollab(this.yeditorText, this.wsProvider.awareness, { undoManager: this.undoManager }), ]; - - this.state = EditorState.create({ - doc: this.yeditorText.toString(), - extensions: myExt, - }); } setEditorView() { @@ -232,6 +232,89 @@ export class EditorComponent implements AfterViewInit, OnInit, OnDestroy { this.view.focus(); } + getNumOfConnectedUsers() { + this.wsProvider.awareness.on('change', () => { + const data = Array.from(this.wsProvider.awareness.getStates().values()); + const uniqueIds = new Set( + data + .map(x => (x as awarenessData).user?.userId) + .filter((userId): userId is string => userId !== undefined), + ); + + this.numUniqueUsers = uniqueIds.size; + + this.changeDetector.detectChanges(); + }); + } + + async format() { + try { + const selectedParser = parserMap[this.selectedLanguage.toLowerCase()]; + + if (selectedParser === undefined) { + this.notifyUnsupported(); + return; + } + + const currentCode = this.view.state.doc.toString(); + const formattedCode = this.prettierFormat(currentCode, selectedParser); + + this.view.dispatch({ + changes: { + from: 0, + to: this.view.state.doc.length, + insert: await formattedCode, + }, + }); + + this.view.focus(); + } catch (error) { + if (error instanceof SyntaxError || error instanceof Error) { + this.notifyFormattingErr( + "There's a syntax error in your code. Please fix it and try formatting again.", + ); + } else { + this.notifyFormattingErr('An error occurred while formatting. Please check your code and try again.'); + } + } + } + + prettierFormat(currentCode: string, selectedParser: string): Promise { + return prettier.format(currentCode, { + parser: selectedParser, + plugins: [ + parserBabel, + prettierPluginJava, + prettierPluginEstree, + prettierPluginPhp, + prettierPluginXml, + prettierPluginRust, + prettierPluginSql, + ], + }); + } + + notifyFormattingErr(message: string) { + this.messageService.add({ + severity: 'warn', + summary: 'Formatting Error', + detail: message, + }); + } + + notifyUnsupported() { + this.messageService.add({ + severity: 'info', + summary: 'Info Message', + detail: `The selected language ${this.selectedLanguage.toLowerCase()} is currently not supported for auto formatting.`, + }); + } + + showSubmitDialog() { + this.isSubmit = true; + this.isInitiator = true; + } + onSubmitDialogClose(numForfeit: number) { if (numForfeit == 0 && this.ysubmit.size > 0) { this.messageService.add({ diff --git a/frontend/src/app/collaboration/editor/languages.ts b/frontend/src/app/collaboration/editor/languages.ts new file mode 100644 index 0000000000..d6d4d971e9 --- /dev/null +++ b/frontend/src/app/collaboration/editor/languages.ts @@ -0,0 +1,34 @@ +import { java } from '@codemirror/lang-java'; +import { javascript } from '@codemirror/lang-javascript'; +import { Extension } from '@codemirror/state'; +import { php } from '@codemirror/lang-php'; +import { rust } from '@codemirror/lang-rust'; +import { sql } from '@codemirror/lang-sql'; +import { python } from '@codemirror/lang-python'; +import { cpp } from '@codemirror/lang-cpp'; +import { go } from '@codemirror/lang-go'; + +export const languageMap: Record = { + java: java(), + javascript: javascript(), + PHP: php(), + rust: rust(), + SQL: sql(), + python: python(), + 'C++': cpp(), + go: go(), +}; + +export const parserMap: Record = { + java: 'java', + javascript: 'babel', + php: 'php', + xml: 'xml', + rust: 'rust', + sql: 'sql', +}; + +export interface LanguageOption { + label: string; + value: string; +} diff --git a/frontend/src/app/collaboration/forfeit-dialog/forfeit-dialog.component.ts b/frontend/src/app/collaboration/forfeit-dialog/forfeit-dialog.component.ts index 74bf3d014b..ab7d8bf645 100644 --- a/frontend/src/app/collaboration/forfeit-dialog/forfeit-dialog.component.ts +++ b/frontend/src/app/collaboration/forfeit-dialog/forfeit-dialog.component.ts @@ -69,7 +69,7 @@ export class ForfeitDialogComponent implements OnInit { this.isForfeit = true; this.hideButtons = true; setTimeout(() => { - this.router.navigate(['/matching']); + this.router.navigate(['/home']); }, 1500); }, }); diff --git a/frontend/src/app/collaboration/question-box/question-box.component.html b/frontend/src/app/collaboration/question-box/question-box.component.html index 1fae6f816a..5d857f17c4 100644 --- a/frontend/src/app/collaboration/question-box/question-box.component.html +++ b/frontend/src/app/collaboration/question-box/question-box.component.html @@ -1,7 +1,7 @@ -
-
-
- +
+
+
+

{{ question.title }}

@switch (question.difficulty) { @@ -9,7 +9,7 @@

{{ question.title }}

{{ question.difficulty }} } @case (difficultyLevels.MEDIUM) { - {{ question.difficulty }} + {{ question.difficulty }} } @case (difficultyLevels.HARD) { {{ question.difficulty }} @@ -23,8 +23,10 @@

{{ question.title }}

{{ topic }} }
-

{{ question.description }}

-
+

+ {{ question.description }} +

diff --git a/frontend/src/app/collaboration/submit-dialog/submit-dialog.component.ts b/frontend/src/app/collaboration/submit-dialog/submit-dialog.component.ts index 324799f1b1..fe572e8222 100644 --- a/frontend/src/app/collaboration/submit-dialog/submit-dialog.component.ts +++ b/frontend/src/app/collaboration/submit-dialog/submit-dialog.component.ts @@ -125,14 +125,14 @@ export class SubmitDialogComponent implements AfterViewInit { next: () => { this.message = 'Successfully submitted. \n\n Redirecting you to homepage...'; setTimeout(() => { - this.router.navigate(['/matching']); + this.router.navigate(['/home']); }, 1500); }, }); } setTimeout(() => { - this.router.navigate(['/matching']); + this.router.navigate(['/home']); }, 1500); } diff --git a/frontend/src/app/collaboration/websocket-code.enum.ts b/frontend/src/app/collaboration/websocket-code.enum.ts new file mode 100644 index 0000000000..9424a6b9bd --- /dev/null +++ b/frontend/src/app/collaboration/websocket-code.enum.ts @@ -0,0 +1,4 @@ +export enum WebSocketCode { + AUTH_FAILED = 4000, + ROOM_CLOSED = 4001, +} diff --git a/frontend/src/app/home/active-sessions.model.ts b/frontend/src/app/home/active-sessions.model.ts new file mode 100644 index 0000000000..f3f7cb9f29 --- /dev/null +++ b/frontend/src/app/home/active-sessions.model.ts @@ -0,0 +1,7 @@ +import { DifficultyLevels } from '../questions/difficulty-levels.enum'; + +export interface ActiveSession { + questionTitle: string; + difficulty: DifficultyLevels; // Assuming DifficultyLevel is an enum + peer: string; +} diff --git a/frontend/src/app/home/home.component.css b/frontend/src/app/home/home.component.css new file mode 100644 index 0000000000..159f9e9d24 --- /dev/null +++ b/frontend/src/app/home/home.component.css @@ -0,0 +1,51 @@ +@import '../collaboration/question-box/question-box.component.css'; + +:host .grid { + margin-right: 0rem; + margin-left: 0rem; + margin-top: 0rem; +} + +:host ::ng-deep .container { + background-color: var(--surface-section); + border-radius: 0.75rem; + display: flex; + flex-direction: column; + align-items: center; +} + +:host ::ng-deep .round { + background-color: var(--surface-section); + border-radius: 0.75rem; +} + +:host ::ng-deep .full-width-button { + width: 100%; + display: flex; + justify-content: center; +} + +:host ::ng-deep h1.title { + font-family: "Poppins", sans-serif; + font-weight: 700; + font-style: normal; + font-size: 3rem; + color: white; +} + +:host ::ng-deep .start-matching-button { + width: 100%; + padding: 13px; + border-radius: 19px; + /* color: white; */ + font-size: 1.1rem; + font-family: "Poppins", sans-serif; + font-weight: 700; + font-style: normal; +} + +@media (min-width: 992px) { + .custom-height-lg { + height: calc(100% - 34px); + } +} \ No newline at end of file diff --git a/frontend/src/app/home/home.component.html b/frontend/src/app/home/home.component.html new file mode 100644 index 0000000000..63cb90b26f --- /dev/null +++ b/frontend/src/app/home/home.component.html @@ -0,0 +1,104 @@ +
+ @if (loading) { + + } @else { +
+
+ Web illustrations by Storyset + +
+
+
+
+
+

Welcome to PeerPrep!

+
+
+ + +
+

Active Sessions

+
+
+ @if (activeSessions.length > 0) { + + + Question + Difficulty + Peer + + + + + + {{ session.question.title }} + + @switch (session.question.difficulty) { + @case (difficultyLevels.EASY) { + {{ session.question.difficulty }} + } + @case (difficultyLevels.MEDIUM) { + {{ session.question.difficulty }} + } + @case (difficultyLevels.HARD) { + {{ session.question.difficulty }} + } + @default { + {{ session.question.difficulty }} + } + } + + {{ getPeer(session.users) }} + + + + + + } @else { + +
+
+

You currently have no active sessions.

+
+
+
+ } +
+
+
+ +
+
+
+ } + + +
diff --git a/frontend/src/app/home/home.component.spec.ts b/frontend/src/app/home/home.component.spec.ts new file mode 100644 index 0000000000..55b08fbefc --- /dev/null +++ b/frontend/src/app/home/home.component.spec.ts @@ -0,0 +1,22 @@ +import { ComponentFixture, TestBed } from '@angular/core/testing'; + +import { HomeComponent } from './home.component'; + +describe('HomeComponent', () => { + let component: HomeComponent; + let fixture: ComponentFixture; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [HomeComponent], + }).compileComponents(); + + fixture = TestBed.createComponent(HomeComponent); + component = fixture.componentInstance; + fixture.detectChanges(); + }); + + it('should create', () => { + expect(component).toBeTruthy(); + }); +}); diff --git a/frontend/src/app/home/home.component.ts b/frontend/src/app/home/home.component.ts new file mode 100644 index 0000000000..46708e29ff --- /dev/null +++ b/frontend/src/app/home/home.component.ts @@ -0,0 +1,87 @@ +import { Component, OnInit, OnDestroy } from '@angular/core'; +import { TableModule } from 'primeng/table'; +import { ButtonModule } from 'primeng/button'; +import { DifficultyLevels } from '../questions/difficulty-levels.enum'; +import { ChipModule } from 'primeng/chip'; +import { Router } from '@angular/router'; +import { MessageService } from 'primeng/api'; +import { CollabService } from '../../_services/collab.service'; +import { RoomData, CollabUser } from '../collaboration/collab.model'; +import { ToastModule } from 'primeng/toast'; +import { AuthenticationService } from '../../_services/authentication.service'; +import { ProgressSpinnerModule } from 'primeng/progressspinner'; +import { ToastService } from '../../_services/toast.service'; + +@Component({ + selector: 'app-home', + standalone: true, + imports: [TableModule, ButtonModule, ChipModule, ToastModule, ProgressSpinnerModule], + providers: [CollabService, MessageService], + templateUrl: './home.component.html', + styleUrl: './home.component.css', +}) +export class HomeComponent implements OnInit, OnDestroy { + loading = true; + activeSessions: RoomData[] = []; + difficultyLevels = DifficultyLevels; + userId!: string; + + constructor( + private collabService: CollabService, + private messageService: MessageService, + private authService: AuthenticationService, + private router: Router, + private toastService: ToastService, + ) {} + + ngOnDestroy() { + this.activeSessions = []; + } + + ngOnInit() { + this.getActiveSessions(); + this.getUserId(); + this.initToastService(); + } + + initToastService() { + this.toastService.toast$.subscribe(message => { + this.messageService.add({ severity: 'warn', summary: 'Warning', detail: message }); + }); + } + + getUserId() { + this.userId = this.authService.userValue!.id; + } + + goToCollab(roomId: string) { + this.router.navigate(['/collab'], { queryParams: { roomId } }); + } + + goToMatch() { + this.router.navigate(['/matching']); + } + + getPeer(users: CollabUser[]) { + return users.find(user => user.id !== this.userId)?.username; + } + + getActiveSessions() { + this.collabService.getRoomsWithQuery(true, false).subscribe({ + next: response => { + this.activeSessions = Array.isArray(response.data) ? response.data : []; + }, + error: () => { + this.messageService.add({ + severity: 'error', + summary: 'Error', + detail: 'Failed to retrieve room data', + life: 3000, + }); + }, + complete: () => { + this.loading = false; + }, + }); + } +} diff --git a/frontend/src/app/navigation-bar/navigation-bar.component.html b/frontend/src/app/navigation-bar/navigation-bar.component.html index c4ccb399d1..feeeaacca8 100644 --- a/frontend/src/app/navigation-bar/navigation-bar.component.html +++ b/frontend/src/app/navigation-bar/navigation-bar.component.html @@ -1,6 +1,6 @@ -
+
logo

PeerPrep

diff --git a/frontend/src/app/navigation-bar/navigation-bar.component.ts b/frontend/src/app/navigation-bar/navigation-bar.component.ts index ba9a58a137..1955769aed 100644 --- a/frontend/src/app/navigation-bar/navigation-bar.component.ts +++ b/frontend/src/app/navigation-bar/navigation-bar.component.ts @@ -1,6 +1,6 @@ import { Component, OnInit, ViewChild, HostListener, Renderer2 } from '@angular/core'; import { MenuItem } from 'primeng/api'; -import { CommonModule, NgFor } from '@angular/common'; +import { CommonModule } from '@angular/common'; import { MenubarModule } from 'primeng/menubar'; import { AuthenticationService } from '../../_services/authentication.service'; import { User } from '../../_models/user.model'; @@ -10,7 +10,7 @@ import { ButtonModule } from 'primeng/button'; @Component({ selector: 'app-navigation-bar', standalone: true, - imports: [MenubarModule, CommonModule, NgFor, MenuModule, ButtonModule], + imports: [MenubarModule, CommonModule, MenuModule, ButtonModule], templateUrl: './navigation-bar.component.html', styleUrl: './navigation-bar.component.css', }) @@ -44,6 +44,12 @@ export class NavigationBarComponent implements OnInit { setMenuItems() { if (this.authService.isLoggedIn) { this.items = [ + { + label: 'Home', + icon: 'pi pi-home', + routerLink: '/home', + class: 'p-submenu-list', + }, { label: 'Find Match', icon: 'pi pi-users', diff --git a/package-lock.json b/package-lock.json index e7f5318afc..e97f7809da 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,5 +1,5 @@ { - "name": "PeerPrep", + "name": "cs3219-ay2425s1-project-g03", "lockfileVersion": 3, "requires": true, "packages": {} diff --git a/services/collaboration/README.md b/services/collaboration/README.md index 477a2749cb..75a5c911e4 100644 --- a/services/collaboration/README.md +++ b/services/collaboration/README.md @@ -26,9 +26,9 @@ docker compose down -v ## Overview The `Collaboration Service` manages the lifecycle of collaboration sessions, including room creation, retrieval, and -closure. When -a room is created, it is assigned to two users, a Yjs document is initialized for real-time collaboration, and the -room’s status is set to `open`. Rooms are used to group users working together on a shared task, such as collaborative +closure. When a room is created, it is assigned to two users, a Yjs document is initialized for real-time collaboration, +and the room’s status is set to `open`. Rooms are used to group users working together on a shared task, such as +collaborative coding, and are identified by a unique `room_id`. The room’s status can be updated to `closed` when users leave or forfeit the session, which also removes the Yjs document and its data from MongoDB to free resources. @@ -105,7 +105,8 @@ not provided directly. ```bash curl -X GET http://localhost:8080/api/collaboration/room/user/rooms \ - -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjY3MjFhNWZiZWFlNjBjOGViMWU1ZWYzNCIsInVzZXJuYW1lIjoiVGVzdGluZyIsInJvbGUiOiJ1c2VyIiwiaWF0IjoxNzMwMjU4NDI4LCJleHAiOjE3MzAzNDQ4Mjh9.DF9CaChoG3-UmeZgZG9SlpjtTknVzeVSBAJDJRdqGk0 + -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjY3MjRlOTZlNDNjMmNjNWQ5ODA5NmM2OSIsInVzZXJuYW1lIjoiVGVzdGluZzEiLCJyb2xlIjoidXNlciIsImlhdCI6MTczMDQ3MjMwMywiZXhwIjoxNzMwNTU4NzAzfQ.x92l-NIgWj_dpM-EC-xOKAGB8zrgGAdKbDpAu3UD5vE" \ + -H "Content-Type: application/json" ``` ### Example of Response Body for Success: @@ -114,19 +115,20 @@ curl -X GET http://localhost:8080/api/collaboration/room/user/rooms \ { "status": "Success", "data": [ - "6721a64b0c4d990bc0feee4c" + "6724e9d892fb3e9f04c2e280" ] } ``` --- -## Get Room by Room ID +## Get Rooms by User ID, Room Status, and User's isForfeit status -This endpoint retrieves the details of a room by its room ID. +This endpoint retrieves the details of rooms associated with the authenticated user, filtered by the specified room +status and isForfeit status using query parameters. - **HTTP Method**: `GET` -- **Endpoint**: `/api/collaboration/room/{roomId}` +- **Endpoint**: `/api/collaboration/room/` ### Authorization @@ -134,21 +136,23 @@ This endpoint requires a valid JWT token in the Authorization header. ### Parameters: -- `roomId` (Required) - The ID of the room to retrieve. +- `roomStatus` (Required) - The status of the room to filter by (`true` for open rooms, `false` for closed rooms). +- `isForfeit` (Required) - The status of the user in a room to filter by (`true` for rooms forfeited by the + user, `false` for rooms not forfeited by the user). ### Responses: -| Response Code | Explanation | -|-----------------------------|---------------------------------------------| -| 200 (OK) | Success, room details returned. | -| 404 (Not Found) | Room not found. | -| 500 (Internal Server Error) | Unexpected error in the server or database. | +| Response Code | Explanation | +|-----------------------------|-------------------------------------------------------------------------------------------| +| 200 (OK) | Success, room details returned. If no rooms are found, success message is still returned. | +| 500 (Internal Server Error) | Unexpected error in the server or database. | ### Command Line Example: ```bash -curl -X GET http://localhost:8080/api/collaboration/room/6721a64b0c4d990bc0feee4c \ - -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjY3MjFhNWZiZWFlNjBjOGViMWU1ZWYzNCIsInVzZXJuYW1lIjoiVGVzdGluZyIsInJvbGUiOiJ1c2VyIiwiaWF0IjoxNzMwMjU4NDI4LCJleHAiOjE3MzAzNDQ4Mjh9.DF9CaChoG3-UmeZgZG9SlpjtTknVzeVSBAJDJRdqGk0 +curl -X GET "http://localhost:8080/api/collaboration/room/?roomStatus=true&isForfeit=false" \ + -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjY3MjFhNWZiZWFlNjBjOGViMWU1ZWYzNCIsInVzZXJuYW1lIjoiVGVzdGluZzEiLCJyb2xlIjoidXNlciIsImlhdCI6MTczMDQ3MjY2NCwiZXhwIjoxNzMwNTYxMDY0fQ.DF9CaChoG3-UmeZgZG9SlpjtTknVzeVSBAJDJRdqGk0" \ + -H "Content-Type: application/json" ``` ### Example of Response Body for Success: @@ -156,26 +160,38 @@ curl -X GET http://localhost:8080/api/collaboration/room/6721a64b0c4d990bc0feee4 ```json { "status": "Success", - "data": { - "room_id": "6721a64b0c4d990bc0feee4c", - "users": [ - { - "id": "6718b0050e24954ac125e5dd", - "username": "Testing", - "requestId": "6718b027a8144e99bbee17ce", - "isForfeit": false + "data": [ + { + "room_id": "6724e9d892fb3e9f04c2e280", + "users": [ + { + "id": "6724e96e43c2cc5d98096c69", + "username": "Testing1", + "requestId": "6724e9d7a752183798494a85", + "isForfeit": false + }, + { + "id": "6724e94843c2cc5d98096c63", + "username": "Testing", + "requestId": "6724e9d6a752183798494a80", + "isForfeit": false + } + ], + "question": { + "_id": "6724e8b47cdb78e50482a119", + "id": 4, + "description": "Given two binary strings a and b, return their sum as a binary string.", + "difficulty": "Easy", + "title": "Add Binary", + "topics": [ + "Bit Manipulation", + "Algorithms" + ] }, - { - "id": "6718b0070e24954ac125e5e1", - "username": "Testing1", - "requestId": "6718b026a8144e99bbee17c8", - "isForfeit": false - } - ], - "question_id": 2, - "createdAt": "2024-10-23T08:13:27.886Z", - "room_status": true - } + "createdAt": "2024-11-01T14:46:48.085Z", + "room_status": true + } + ] } ``` @@ -210,8 +226,8 @@ This endpoint requires a valid JWT token in the Authorization header. The userId ### Command Line Example: ```bash -curl -X PATCH http://localhost:8080/api/collaboration/room/6721a64b0c4d990bc0feee4c/user/isForfeit \ - -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjY3MjFhNWZiZWFlNjBjOGViMWU1ZWYzNCIsInVzZXJuYW1lIjoiVGVzdGluZyIsInJvbGUiOiJ1c2VyIiwiaWF0IjoxNzMwMjU4NDI4LCJleHAiOjE3MzAzNDQ4Mjh9.DF9CaChoG3-UmeZgZG9SlpjtTknVzeVSBAJDJRdqGk0" \ + curl -X PATCH http://localhost:8080/api/collaboration/room/6724e9d892fb3e9f04c2e280/user/isForfeit \ + -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjY3MjRlOTZlNDNjMmNjNWQ5ODA5NmM2OSIsInVzZXJuYW1lIjoiVGVzdGluZzEiLCJyb2xlIjoidXNlciIsImlhdCI6MTczMDQ3MjMwMywiZXhwIjoxNzMwNTU4NzAzfQ.x92l-NIgWj_dpM-EC-xOKAGB8zrgGAdKbDpAu3UD5vE" \ -H "Content-Type: application/json" \ -d '{"isForfeit": true}' ``` @@ -222,25 +238,35 @@ curl -X PATCH http://localhost:8080/api/collaboration/room/6721a64b0c4d990bc0fee { "status": "Success", "data": { - "message": "User status updated successfully", + "message": "User isForfeit status updated successfully", "room": { - "room_id": "6721a64b0c4d990bc0feee4c", + "_id": "6724e9d892fb3e9f04c2e280", "users": [ { - "id": "6718b0050e24954ac125e5dd", - "username": "Testing", - "requestId": "6718b027a8144e99bbee17ce", - "isForfeit": false - }, - { - "id": "6718b0070e24954ac125e5e1", + "id": "6724e96e43c2cc5d98096c69", "username": "Testing1", - "requestId": "6718b026a8144e99bbee17c8", + "requestId": "6724e9d7a752183798494a85", "isForfeit": true + }, + { + "id": "6724e94843c2cc5d98096c63", + "username": "Testing", + "requestId": "6724e9d6a752183798494a80", + "isForfeit": false } ], - "question_id": 2, - "createdAt": "2024-10-23T08:13:27.886Z", + "question": { + "_id": "6724e8b47cdb78e50482a119", + "id": 4, + "description": "Given two binary strings a and b, return their sum as a binary string.", + "difficulty": "Easy", + "title": "Add Binary", + "topics": [ + "Bit Manipulation", + "Algorithms" + ] + }, + "createdAt": "2024-11-01T14:46:48.085Z", "room_status": true } } @@ -275,8 +301,9 @@ This endpoint requires a valid JWT token in the Authorization header. ### Command Line Example: ```bash -curl -X PATCH http://localhost:8080/api/collaboration/room/6721a64b0c4d990bc0feee4c/close \ - -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjY3MjFhNWZiZWFlNjBjOGViMWU1ZWYzNCIsInVzZXJuYW1lIjoiVGVzdGluZyIsInJvbGUiOiJ1c2VyIiwiaWF0IjoxNzMwMjU4NDI4LCJleHAiOjE3MzAzNDQ4Mjh9.DF9CaChoG3-UmeZgZG9SlpjtTknVzeVSBAJDJRdqGk0" +curl -X PATCH http://localhost:8080/api/collaboration/room/6724e9d892fb3e9f04c2e280/close \ + -H "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjY3MjRlOTZlNDNjMmNjNWQ5ODA5NmM2OSIsInVzZXJuYW1lIjoiVGVzdGluZzEiLCJyb2xlIjoidXNlciIsImlhdCI6MTczMDQ3MjMwMywiZXhwIjoxNzMwNTU4NzAzfQ.x92l-NIgWj_dpM-EC-xOKAGB8zrgGAdKbDpAu3UD5vE" \ + -H "Content-Type: application/json" ``` ### Example of Response Body for Success: @@ -284,7 +311,7 @@ curl -X PATCH http://localhost:8080/api/collaboration/room/6721a64b0c4d990bc0fee ```json { "status": "Success", - "data": "Room 6721a64b0c4d990bc0feee4c successfully closed" + "data": "Room 6724e9d892fb3e9f04c2e280 successfully closed" } ``` @@ -326,23 +353,24 @@ The producer will send a message to the `MATCH_FAILED` queue when a collaboratio - **Queue**: `MATCH_FAILED` - **Data Produced** - - `requestId1` (Required) - The first request ID associated with the match failure. - - `requestId2` (Required) - The second request ID associated with the match failure. - - `reason` (Required) - The error encountered. + - `requestId1` (Required) - The first request ID associated with the match failure. + - `requestId2` (Required) - The second request ID associated with the match failure. + - `reason` (Required) - The error encountered. - ```json - { - "requestId1": "6714d1806da8e6d033ac2be1", - "requestId2": "67144180cda8e610333e4b12", - "reason": "Failed to create room", - } - ``` +```json +{ + "requestId1": "6714d1806da8e6d033ac2be1", + "requestId2": "67144180cda8e610333e4b12", + "reason": "Failed to create room" +} + ``` --- ## Consumer -The consumer will listen for messages on the `QUESTION_FOUND` queue and create a collaboration room when two users are matched. +The consumer will listen for messages on the `QUESTION_FOUND` queue and create a collaboration room when two users are +matched. - **Queue**: `QUESTION_FOUND` - **Data in the Message**: @@ -367,7 +395,9 @@ The consumer will listen for messages on the `QUESTION_FOUND` queue and create a "id": 21, "title": "Reverse Integer", "description": "Given a signed 32-bit integer x, return x with its digits reversed.", - "topics": ["Math"], + "topics": [ + "Math" + ], "difficulty": "Medium" } } diff --git a/services/collaboration/src/controllers/roomController.ts b/services/collaboration/src/controllers/roomController.ts index c80f134327..9e1a7a0a55 100644 --- a/services/collaboration/src/controllers/roomController.ts +++ b/services/collaboration/src/controllers/roomController.ts @@ -43,24 +43,6 @@ export const createRoomWithQuestion = async (user1: any, user2: any, question: Q } }; -export const getRoomIdsByUserIdController = async (req: Request, res: Response) => { - const userId = req.user.id; - - console.log('Received request for user ID:', userId); - try { - const rooms = await findRoomsByUserId(userId); - if (!rooms || rooms.length === 0) { - return handleHttpNotFound(res, 'No rooms found for the given user'); - } - - const roomIds = rooms.map(room => (room as Room)._id); - return handleHttpSuccess(res, roomIds); - } catch (error) { - console.error('Error fetching rooms by user ID:', error); - return handleHttpServerError(res, 'Failed to retrieve room IDs by user ID'); - } -}; - /** * Controller function to get room details by room ID * @param req @@ -157,3 +139,53 @@ export const updateUserStatusInRoomController = async (req: Request, res: Respon return handleHttpServerError(res, 'Failed to update user isForfeit status in room'); } }; + +/** + * Controller function to get room details (including question details) by authenticated user, filtered by room status + * @param req + * @param res + */ +export const getRoomsByUserIdAndStatusController = async (req: Request, res: Response) => { + const userId = req.user.id; + const roomStatusParam = req.query.roomStatus as string; + const isForfeitParam = req.query.isForfeit as string; + + if (roomStatusParam !== 'true' && roomStatusParam !== 'false') { + return handleHttpBadRequest(res, 'Invalid roomStatus value. Must be "true" or "false".'); + } + + if (isForfeitParam !== 'true' && isForfeitParam !== 'false') { + return handleHttpBadRequest(res, 'Invalid isForfeit value. Must be "true" or "false".'); + } + + const roomStatus = roomStatusParam === 'true'; + const isForfeit = isForfeitParam === 'true'; + + console.log( + 'Received request for user ID:', + userId, + 'with room status:', + roomStatus, + 'with forfeit status:', + isForfeit, + ); + + try { + const rooms = await findRoomsByUserId(userId, roomStatus, isForfeit); + if (!rooms || rooms.length === 0) { + return handleHttpSuccess(res, 'No rooms found for the given user and status'); + } + + const roomDetails = rooms.map(room => ({ + room_id: room._id, + users: room.users, + question: room.question, + createdAt: room.createdAt, + room_status: room.room_status, + })); + return handleHttpSuccess(res, roomDetails); + } catch (error) { + console.error('Error fetching rooms by user ID and status:', error); + return handleHttpServerError(res, 'Failed to retrieve rooms by user ID and status'); + } +}; diff --git a/services/collaboration/src/events/broker.ts b/services/collaboration/src/events/broker.ts index 095201dc5d..01ccc4fc1f 100644 --- a/services/collaboration/src/events/broker.ts +++ b/services/collaboration/src/events/broker.ts @@ -2,7 +2,7 @@ import client, { Channel, Connection } from 'amqplib'; import config from '../config'; /** - * Adapated from + * Adapted from * https://hassanfouad.medium.com/using-rabbitmq-with-nodejs-and-typescript-8b33d56a62cc */ class MessageBroker { diff --git a/services/collaboration/src/middleware/request.ts b/services/collaboration/src/middleware/request.ts index d60cad964e..175c1cc4e4 100644 --- a/services/collaboration/src/middleware/request.ts +++ b/services/collaboration/src/middleware/request.ts @@ -1,4 +1,3 @@ -import { Types } from 'mongoose'; import { z } from 'zod'; export enum Role { diff --git a/services/collaboration/src/routes/roomRoutes.ts b/services/collaboration/src/routes/roomRoutes.ts index e5239c45ec..3e927ca559 100644 --- a/services/collaboration/src/routes/roomRoutes.ts +++ b/services/collaboration/src/routes/roomRoutes.ts @@ -1,9 +1,9 @@ import { Router } from 'express'; import { - getRoomIdsByUserIdController, getRoomByRoomIdController, closeRoomController, updateUserStatusInRoomController, + getRoomsByUserIdAndStatusController, } from '../controllers/roomController'; /** @@ -11,11 +11,6 @@ import { */ const router = Router(); -/** - * Get room IDs by user ID (userId is now obtained from the JWT token) - */ -router.get('/user/rooms', getRoomIdsByUserIdController); - /** * Get room by room ID */ @@ -31,4 +26,9 @@ router.patch('/:roomId/close', closeRoomController); */ router.patch('/:roomId/user/isForfeit', updateUserStatusInRoomController); +/** + * Get rooms by room status and isForfeit status for the authenticated user + */ +router.get('/', getRoomsByUserIdAndStatusController); + export default router; diff --git a/services/collaboration/src/services/mongodbService.ts b/services/collaboration/src/services/mongodbService.ts index 41da9a08f1..480c02806d 100644 --- a/services/collaboration/src/services/mongodbService.ts +++ b/services/collaboration/src/services/mongodbService.ts @@ -67,8 +67,10 @@ export const startMongoDB = async (): Promise => { /** * Save room data in the MongoDB rooms database and create a Yjs document - * @param roomData * @returns roomId + * @param user1 + * @param user2 + * @param question */ export const createRoomInDB = async (user1: any, user2: any, question: Question): Promise => { try { @@ -140,24 +142,37 @@ export const deleteYjsDocument = async (roomId: string) => { }; /** - * Find rooms by user ID where room_status is true + * Find rooms by user ID and room status * @param userId + * @param roomStatus + * @returns */ -export const findRoomsByUserId = async (userId: string): Promise[]> => { +export const findRoomsByUserId = async ( + userId: string, + roomStatus: boolean, + isForfeit: boolean, +): Promise[]> => { try { const db = await connectToRoomDB(); - console.log(`Querying for rooms with user ID: ${userId}`); + console.log( + `Querying for rooms with user ID: ${userId}, room status: ${roomStatus} and isForfeit status; ${isForfeit}`, + ); + const rooms = await db .collection('rooms') .find({ - users: { $elemMatch: { id: userId } }, - room_status: true, + users: { $elemMatch: { id: userId, isForfeit: isForfeit } }, + room_status: roomStatus, }) .toArray(); + console.log('Rooms found:', rooms); return rooms; } catch (error) { - console.error(`Error querying rooms for user ID ${userId}:`, error); + console.error( + `Error querying rooms for user ID ${userId} with room status ${roomStatus} and isForfeit status; ${isForfeit}:`, + error, + ); throw error; } }; diff --git a/services/collaboration/src/services/webSocketService.ts b/services/collaboration/src/services/webSocketService.ts index 547b5e9084..b79ce42777 100644 --- a/services/collaboration/src/services/webSocketService.ts +++ b/services/collaboration/src/services/webSocketService.ts @@ -12,7 +12,7 @@ const { setPersistence, setupWSConnection } = require('../utils/utility.js'); const URL_REGEX = /^.*\/([0-9a-f]{24})\?accessToken=([a-zA-Z0-9\-._~%]{1,})$/; const authorize = async (ws: WebSocket, request: IncomingMessage): Promise => { - const url = request.url; + const url = request.url ?? ''; const match = url?.match(URL_REGEX); if (!match) { handleAuthFailed(ws, 'Authorization failed: Invalid format'); diff --git a/services/match/src/events/broker.ts b/services/match/src/events/broker.ts index a1078e2a6c..9a85daa78a 100644 --- a/services/match/src/events/broker.ts +++ b/services/match/src/events/broker.ts @@ -2,7 +2,7 @@ import client, { Channel, Connection } from 'amqplib'; import config from '../config'; /** - * Adapated from + * Adapted from * https://hassanfouad.medium.com/using-rabbitmq-with-nodejs-and-typescript-8b33d56a62cc */ class MessageBroker { diff --git a/services/question/src/events/broker.ts b/services/question/src/events/broker.ts index a1078e2a6c..9a85daa78a 100644 --- a/services/question/src/events/broker.ts +++ b/services/question/src/events/broker.ts @@ -2,7 +2,7 @@ import client, { Channel, Connection } from 'amqplib'; import config from '../config'; /** - * Adapated from + * Adapted from * https://hassanfouad.medium.com/using-rabbitmq-with-nodejs-and-typescript-8b33d56a62cc */ class MessageBroker {