diff --git a/package-lock.json b/package-lock.json index 82f957d..8973a9e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,8 @@ "name": "@acmucsd/acm-ai-site", "version": "2.0.0", "dependencies": { + "@dicebear/collection": "^7.0.1", + "@dicebear/core": "^7.0.1", "@types/aos": "^3.0.3", "@types/chart.js": "^2.9.27", "aos": "^3.0.0-beta.6", @@ -15,6 +17,7 @@ "chart.js": "^2.9.4", "cors": "^2.8.5", "express": "^4.17.1", + "moment": "^2.30.1", "path-browserify": "^1.0.1", "postcss-loader": "^7.3.3", "prettier": "^2.1.2", @@ -51,7 +54,8 @@ "react-intersection-observer": "^9.5.2", "serve": "^14.2.0", "style-loader": "^3.3.3", - "typescript": "^4.9.5" + "typescript": "^4.9.5", + "webpack": "^5.89.0" }, "engines": { "npm": ">=9.8.1 <10.0.0" @@ -2568,6 +2572,395 @@ "node": ">=10" } }, + "node_modules/@dicebear/adventurer": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@dicebear/adventurer/-/adventurer-7.0.1.tgz", + "integrity": "sha512-eqbHHAQO8HjG8YNMl8xgklxphC7HvfDtqVr1rkJWP98e7r2AdQpu0cPYIOZPV4uv9gxl1ncaErQjdjvIvFRGiA==", + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^7.0.0" + } + }, + "node_modules/@dicebear/adventurer-neutral": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@dicebear/adventurer-neutral/-/adventurer-neutral-7.0.1.tgz", + "integrity": "sha512-dZfyaUFS8qQv7Lv+OXNTHVkercDCh+VqGSJU8jIf3FFbtFbFF79FXZJwJ8V3+pr0xKcZWa8i+8hXLtU3gqZ18g==", + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^7.0.0" + } + }, + "node_modules/@dicebear/avataaars": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@dicebear/avataaars/-/avataaars-7.0.1.tgz", + "integrity": "sha512-U7JJLDFJsbVyQl3j1SqtTxi5h+I5JXL8CGfwAOPtQTnk/tKQFXM9WF/zdHegtxbxYAxQaYJtyprdwTJHx5ELnw==", + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^7.0.0" + } + }, + "node_modules/@dicebear/avataaars-neutral": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@dicebear/avataaars-neutral/-/avataaars-neutral-7.0.1.tgz", + "integrity": "sha512-e3XwK3xup4ifJ/BUNjR5rcrw9982SC75UTJlPsKuuOM/Lwx3MtUe3+dqeDSyYbrC7KoWespX70oDZK1+2dBQFw==", + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^7.0.0" + } + }, + "node_modules/@dicebear/big-ears": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@dicebear/big-ears/-/big-ears-7.0.1.tgz", + "integrity": "sha512-ITI0IQCwdn5s5/kUrNdO488TQvZdiCljnzKpqbQ1hqfsxZ0C+eZs+cudZ0bqLftYxM+WBvmaJwrh3pXNAz1h+w==", + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^7.0.0" + } + }, + "node_modules/@dicebear/big-ears-neutral": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@dicebear/big-ears-neutral/-/big-ears-neutral-7.0.1.tgz", + "integrity": "sha512-2QK9HVmApoGFLi3ONW9mh0Tk/PPyHx9rvzUvcT5H/mb80ooBqIVMPYYq4rVlGVP6wAtsNHdoxzzlKja0DG+vvQ==", + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^7.0.0" + } + }, + "node_modules/@dicebear/big-smile": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@dicebear/big-smile/-/big-smile-7.0.1.tgz", + "integrity": "sha512-hVAhUMZ0LUhMFvtmUDR8GU7v2ufl5pOcVPiVSC3oV8nyywFp7s1ZqYGhi6rBCEG3qsMR54JfMFWkjV88j4Yrmg==", + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^7.0.0" + } + }, + "node_modules/@dicebear/bottts": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@dicebear/bottts/-/bottts-7.0.1.tgz", + "integrity": "sha512-k0adSvnT9+gFDO7/Cmts9TM3CSWYrZrxZe1WpELjTvwe4QOqdn3LgrYR9JXU/2hRz3GaXtP02SHNd85CkadYVw==", + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^7.0.0" + } + }, + "node_modules/@dicebear/bottts-neutral": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@dicebear/bottts-neutral/-/bottts-neutral-7.0.1.tgz", + "integrity": "sha512-1T1NEKAEvqyGlUprkO1Q1btITZnMBiCP5YeCy6wYyM7qJsPVDSySsjASJ1j/+IZFi8ePgWReFIbigFiHdo7iLA==", + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^7.0.0" + } + }, + "node_modules/@dicebear/collection": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@dicebear/collection/-/collection-7.0.1.tgz", + "integrity": "sha512-Y5xzjU2hiklYUkqsSr5VBpVPG++iUUTm1UDJLPM+iXg3lMF3PQuifXoaAxcuoBvvnKfJKNHf5wP1Bq6nRUl4NA==", + "dependencies": { + "@dicebear/adventurer": "7.0.1", + "@dicebear/adventurer-neutral": "7.0.1", + "@dicebear/avataaars": "7.0.1", + "@dicebear/avataaars-neutral": "7.0.1", + "@dicebear/big-ears": "7.0.1", + "@dicebear/big-ears-neutral": "7.0.1", + "@dicebear/big-smile": "7.0.1", + "@dicebear/bottts": "7.0.1", + "@dicebear/bottts-neutral": "7.0.1", + "@dicebear/croodles": "7.0.1", + "@dicebear/croodles-neutral": "7.0.1", + "@dicebear/fun-emoji": "7.0.1", + "@dicebear/icons": "7.0.1", + "@dicebear/identicon": "7.0.1", + "@dicebear/initials": "7.0.1", + "@dicebear/lorelei": "7.0.1", + "@dicebear/lorelei-neutral": "7.0.1", + "@dicebear/micah": "7.0.1", + "@dicebear/miniavs": "7.0.1", + "@dicebear/notionists": "7.0.1", + "@dicebear/notionists-neutral": "7.0.1", + "@dicebear/open-peeps": "7.0.1", + "@dicebear/personas": "7.0.1", + "@dicebear/pixel-art": "7.0.1", + "@dicebear/pixel-art-neutral": "7.0.1", + "@dicebear/rings": "7.0.1", + "@dicebear/shapes": "7.0.1", + "@dicebear/thumbs": "7.0.1" + }, + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^7.0.0" + } + }, + "node_modules/@dicebear/converter": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@dicebear/converter/-/converter-7.0.1.tgz", + "integrity": "sha512-CEIF6ZKi1FAE9kW10FvuPUjA6HLi+LcuB/GRFct/Bv28llzTel9xwbmfOEa1aIM8Nnp8BuT4U7tBIytksf+ptw==", + "dependencies": { + "@types/json-schema": "^7.0.11", + "tmp-promise": "^3.0.3" + }, + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "@resvg/resvg-js": "^2.4.1", + "exiftool-vendored": "^22.0.0", + "sharp": "^0.32.1" + }, + "peerDependenciesMeta": { + "@resvg/resvg-js": { + "optional": true + }, + "exiftool-vendored": { + "optional": true + }, + "sharp": { + "optional": true + } + } + }, + "node_modules/@dicebear/core": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@dicebear/core/-/core-7.0.1.tgz", + "integrity": "sha512-jaJG693c+myLocgG3kKXdHa+WJ+S6OcD31SEr9Oby7hhOzALQYD+LcJ15oBWwI7SLHJcGPYTOLyx2eDr8YhXCQ==", + "dependencies": { + "@dicebear/converter": "7.0.1", + "@types/json-schema": "^7.0.11" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@dicebear/croodles": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@dicebear/croodles/-/croodles-7.0.1.tgz", + "integrity": "sha512-uauBTUvKFvsiaT+LWYKCEboEeOJy2Pk055nsdczi13UgHHfj+Qvy0/ky/uzYn+WC/1gewqQ6w/yS1WfpgPtIpg==", + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^7.0.0" + } + }, + "node_modules/@dicebear/croodles-neutral": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@dicebear/croodles-neutral/-/croodles-neutral-7.0.1.tgz", + "integrity": "sha512-u09YylowZcbSAVyKJ4I8BCo1ehluqg3onYCclx++8mOWcEo+XGsGKIeN7osayaflNY/qtA9Jt2JsPgiS8KpQ5A==", + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^7.0.0" + } + }, + "node_modules/@dicebear/fun-emoji": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@dicebear/fun-emoji/-/fun-emoji-7.0.1.tgz", + "integrity": "sha512-oJj5sb4rakro4e0lZTCkcKkiClHxDWr6+NWTwoU5L1HYRkXV6ngk4s7xSdOrYBQpYjLhdu+Lpx1VHYNpLUu2vg==", + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^7.0.0" + } + }, + "node_modules/@dicebear/icons": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@dicebear/icons/-/icons-7.0.1.tgz", + "integrity": "sha512-juHS4feScGCz4YdiwjxR60RJ2G7Z6W+tdUqNHN9ufMvY/FpJTfrQvzvrJfJfc84QZwIrqI/96WV3JIBEIO2AwQ==", + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^7.0.0" + } + }, + "node_modules/@dicebear/identicon": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@dicebear/identicon/-/identicon-7.0.1.tgz", + "integrity": "sha512-9W9pqqhvpMsZmOkjuLwlw0iift56A3VFq7eNpJPB1mm6gytfqgxozgOVLDFgug9VXgUVI2Jrk/XnXGIFVIeVQA==", + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^7.0.0" + } + }, + "node_modules/@dicebear/initials": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@dicebear/initials/-/initials-7.0.1.tgz", + "integrity": "sha512-zCI6fky4odM5ezl/GlhcSdnu+oNfmBbIghFB5NzgB/wV5nHmw2okONRC+Mgmxv8P8EpFb9z5hEOnh8xwW8htow==", + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^7.0.0" + } + }, + "node_modules/@dicebear/lorelei": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@dicebear/lorelei/-/lorelei-7.0.1.tgz", + "integrity": "sha512-3pyI2JF70PlqZUZEs5pVxmQWDJ2/bWmGG/iFtwsEh9HivtF8Zon4Er0NrsEoiKDvScyY4VGwl4LyUBc8JvNb9w==", + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^7.0.0" + } + }, + "node_modules/@dicebear/lorelei-neutral": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@dicebear/lorelei-neutral/-/lorelei-neutral-7.0.1.tgz", + "integrity": "sha512-4XaqE5v1dhE4TYrKSGG/VNUFqA31ADlqOnr6bd27E5MnaJLlY8ZAm3sue7EI9kEJ/i5KYov+Q4uS7JNDA5+cag==", + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^7.0.0" + } + }, + "node_modules/@dicebear/micah": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@dicebear/micah/-/micah-7.0.1.tgz", + "integrity": "sha512-zHnEewRaREZGNTqnlZiSoha/wNFxEsVQ3E5QYpe9KB3rcLW4CVUgFAHjb449vniG6NfsAWzyAkOfhy4N6Zzw0g==", + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^7.0.0" + } + }, + "node_modules/@dicebear/miniavs": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@dicebear/miniavs/-/miniavs-7.0.1.tgz", + "integrity": "sha512-v0n2JT0N1I7vAGoi4NQ98IKtn4JgjwD2Gkqq7l5QAy0jzl1v289FfTng0cOrthroMGBQ5jPS0wUyI0TluoFZRw==", + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^7.0.0" + } + }, + "node_modules/@dicebear/notionists": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@dicebear/notionists/-/notionists-7.0.1.tgz", + "integrity": "sha512-uEYBywouoUmvWtWARyeqAoQWX1DpvKL33dVxZ5K/ulYd/nXu9WHeFCPaP4tqE5II1XPS4khwneimFN6F1HA5NQ==", + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^7.0.0" + } + }, + "node_modules/@dicebear/notionists-neutral": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@dicebear/notionists-neutral/-/notionists-neutral-7.0.1.tgz", + "integrity": "sha512-jRA7u2UU1I9EXzqBZL3vwI/V7pdDT60yB3bBjyD5J4TznT7bMwt7qEm1eV31U37mn3H+LTFiPD9/4G6whiU3nQ==", + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^7.0.0" + } + }, + "node_modules/@dicebear/open-peeps": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@dicebear/open-peeps/-/open-peeps-7.0.1.tgz", + "integrity": "sha512-z1gXzd7XXLzSZpOrDPZmnJDXySCUEKmunRdRuWBSRrfIcVkgStZM0y8uuSrs3LpR8U2xcNJN9yO2wNRRWKmFEw==", + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^7.0.0" + } + }, + "node_modules/@dicebear/personas": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@dicebear/personas/-/personas-7.0.1.tgz", + "integrity": "sha512-6/nsrN7JIlMqdH7UwhrACVoCEM3IVHkpMq2I0A1JbhmYp240TI8kM5xYSF0KRdOyAPbyDH/TEB8Uld4LKE+3wQ==", + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^7.0.0" + } + }, + "node_modules/@dicebear/pixel-art": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@dicebear/pixel-art/-/pixel-art-7.0.1.tgz", + "integrity": "sha512-9f17Ze4533CbHp23E+gRSSZdCUAB5/PieRq6/ZtVOnPI/PfglhhKMKSxQIm/H267gE2Y+VVhHpUTwGlbAgh1Lg==", + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^7.0.0" + } + }, + "node_modules/@dicebear/pixel-art-neutral": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@dicebear/pixel-art-neutral/-/pixel-art-neutral-7.0.1.tgz", + "integrity": "sha512-+9RS0ohGDbPu+W2eGGk3LyzvFbM5qsuhCQR4qO7YIcvmODyNFPJ7eW9g/MHFVPLQXq60SCEUF5CEKY0xs4baUQ==", + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^7.0.0" + } + }, + "node_modules/@dicebear/rings": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@dicebear/rings/-/rings-7.0.1.tgz", + "integrity": "sha512-6wsLE4kbkBGeaaEA/afIV0eNYYfIVXo60XgApJA7JdcwyvdTa9LE5Wcp2VBEsZYXdsT9Ml7BC4er/QyMqCayUw==", + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^7.0.0" + } + }, + "node_modules/@dicebear/shapes": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@dicebear/shapes/-/shapes-7.0.1.tgz", + "integrity": "sha512-/ol+SazDlJYYe5pYaqKcnYDBjux+2Ny57hIrkHhonV0z4ny3Pq6c4Lq+hN3MnTBpKJszCXLrSP3uCbSQpjnkOg==", + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^7.0.0" + } + }, + "node_modules/@dicebear/thumbs": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/@dicebear/thumbs/-/thumbs-7.0.1.tgz", + "integrity": "sha512-eQYVJ8NN9buPfbd2Va0fY8sHRq9n1d7FJt/dL9xwimRGlpWh9lqS6gcHazuSHhSgnRHsHLANEiyboIcyhWh2Hg==", + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "@dicebear/core": "^7.0.0" + } + }, "node_modules/@emotion/hash": { "version": "0.8.0", "resolved": "https://registry.npmjs.org/@emotion/hash/-/hash-0.8.0.tgz", @@ -12819,9 +13212,9 @@ } }, "node_modules/moment": { - "version": "2.29.4", - "resolved": "https://registry.npmjs.org/moment/-/moment-2.29.4.tgz", - "integrity": "sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w==", + "version": "2.30.1", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", + "integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==", "engines": { "node": "*" } @@ -18390,6 +18783,25 @@ "resolved": "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.3.tgz", "integrity": "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==" }, + "node_modules/tmp": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.2.1.tgz", + "integrity": "sha512-76SUhtfqR2Ijn+xllcI5P1oyannHNHByD80W1q447gU3mp9G9PSpGdWmjUOHRDPiHYacIk66W7ubDTuPF3BEtQ==", + "dependencies": { + "rimraf": "^3.0.0" + }, + "engines": { + "node": ">=8.17.0" + } + }, + "node_modules/tmp-promise": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/tmp-promise/-/tmp-promise-3.0.3.tgz", + "integrity": "sha512-RwM7MoPojPxsOBYnyd2hy0bxtIlVrihNs9pj5SUvY8Zz1sQcQG2tG1hSr8PDxfgEB8RNKDhqbIlroIarSNDNsQ==", + "dependencies": { + "tmp": "^0.2.0" + } + }, "node_modules/tmpl": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/tmpl/-/tmpl-1.0.5.tgz", @@ -19079,9 +19491,9 @@ } }, "node_modules/webpack": { - "version": "5.88.2", - "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.88.2.tgz", - "integrity": "sha512-JmcgNZ1iKj+aiR0OvTYtWQqJwq37Pf683dY9bVORwVbUrDhLhdn/PlO2sHsFHPkj7sHNQF3JwaAkp49V+Sq1tQ==", + "version": "5.89.0", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.89.0.tgz", + "integrity": "sha512-qyfIC10pOr70V+jkmud8tMfajraGCZMBWJtrmuBymQKCrLTRejBI8STDp1MCyZu/QTdZSeacCQYpYNQVOzX5kw==", "dependencies": { "@types/eslint-scope": "^3.7.3", "@types/estree": "^1.0.0", diff --git a/package.json b/package.json index fd9931a..3070284 100644 --- a/package.json +++ b/package.json @@ -2,6 +2,8 @@ "name": "@acmucsd/acm-ai-site", "version": "2.0.0", "dependencies": { + "@dicebear/collection": "^7.0.1", + "@dicebear/core": "^7.0.1", "@types/aos": "^3.0.3", "@types/chart.js": "^2.9.27", "aos": "^3.0.0-beta.6", @@ -9,6 +11,7 @@ "chart.js": "^2.9.4", "cors": "^2.8.5", "express": "^4.17.1", + "moment": "^2.30.1", "path-browserify": "^1.0.1", "postcss-loader": "^7.3.3", "prettier": "^2.1.2", @@ -70,6 +73,7 @@ "react-intersection-observer": "^9.5.2", "serve": "^14.2.0", "style-loader": "^3.3.3", - "typescript": "^4.9.5" + "typescript": "^4.9.5", + "webpack": "^5.89.0" } } diff --git a/src/App.tsx b/src/App.tsx index 1dcda7c..8b3f518 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -49,6 +49,8 @@ import CompetitionPortalPage from './pages/Competitions/CompetitionPortalPage'; import ProjectPage from './pages/ProjectsPage/index'; import JoinTeamsPage from './pages/Competitions/CompetitionTeamPages/JoinTeamsPage'; +import SubmissionLogPage from './pages/Competitions/CompetitionPortalPage/SubmissionLogPage'; +import MatchesPage from './pages/Competitions/CompetitionPortalPage/MatchesPage'; let cookie = getCookie(COOKIE_NAME); @@ -136,6 +138,18 @@ function App() { component={CompetitionPortalPage} /> + + + + ; +} + export const uploadSubmission = async ( file: File | undefined, tagsSelected: string[], diff --git a/src/actions/teams/utils.ts b/src/actions/teams/utils.ts index 5e2f4a3..1420045 100644 --- a/src/actions/teams/utils.ts +++ b/src/actions/teams/utils.ts @@ -142,7 +142,8 @@ export const addToTeam = async ( let body = { username, teamName, - code, + competitionName, + code }; return new Promise((resolve, reject) => { @@ -155,7 +156,7 @@ export const addToTeam = async ( headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json', - }, + } } ) .then((res: AxiosResponse) => { @@ -168,3 +169,46 @@ export const addToTeam = async ( }); }); }; + + + +export const leaveTeam = async( + competitionName: string, + username: string, + teamName: string +): Promise => { + let token = getToken(COOKIE_NAME); + + return new Promise((resolve, reject) => { + axios + .delete( + process.env.REACT_APP_API + + `/v1/competitions/teams/${competitionName}/remove-member`, + { + headers: { + Authorization: `Bearer ${token}`, + }, + data: { + username: username, + teamName: teamName, + } + + + } + + + ) + .then((res: AxiosResponse) => { + resolve(res); + }) + .catch((error) => { + message.error(error.response.data); + reject(error); + }); + }); + + + +}; + + diff --git a/src/components/EventCard/index.tsx b/src/components/EventCard/index.tsx index 98fccc4..9ada8ec 100644 --- a/src/components/EventCard/index.tsx +++ b/src/components/EventCard/index.tsx @@ -1,3 +1,10 @@ +/** + * Modular component that displays event data in the form of a card. This is used in the + * home page and events page. Provides ability to expand details via a modal. + * + * @param {ACMEvent} event custom type that contains information about the event + * + */ import React, { useState } from 'react'; import { Row, Col, Button, Modal } from 'antd'; import { AiFillCalendar, AiOutlineLink } from 'react-icons/ai'; @@ -6,19 +13,23 @@ import { ACMEvent } from '../../actions/events'; import { HiLocationMarker } from 'react-icons/hi'; const EventCard = ({ event }: { event: ACMEvent }) => { + const [isModalOpen, setIsModalOpen] = useState(false); - // Modal props + const showModal = () => { setIsModalOpen(true); console.log(isModalOpen); }; + const handleCancel = () => { setIsModalOpen(false); }; + const formatCalendarTime = (dateTime: string) => { return new Date(dateTime).toISOString().replace(/-|:|\.\d+/g, ''); }; + // Helper function to format external links for shceduling a new event const googleCalendarLink = `https://www.google.com/calendar/render?action=TEMPLATE&text=${encodeURIComponent( event.title )}&details=${encodeURIComponent( @@ -83,10 +94,12 @@ const EventCard = ({ event }: { event: ACMEvent }) => { onCancel={handleCancel} title={

{event.title}

} footer={ + // If this is an old event, do not give user ability to schedule the event new Date() > new Date(event.end) ? null : ( + /* Antd Button has a bug where using href directly will mess up the - * alignment of the button text so we use onClick instead + * alignment of the button text so we use onClick() instead */ + )} + + + {!team.teamMembers.includes(user.username) && ( + <> + setCode(e.target.value)} + placeholder="Code" + /> + + + )} + + } + > + +
+ + {/* Display team member names */} + {team.teamMembers.length !== 0 ? + team.teamMembers.map((member: string, index: number) => ( +

{member}

+ )) + :

no members

} +
+ + + + + + {/* If user is in not in team, make card white otherwise make it grey*/} +
showModal()} + > + +
+
+ +
+

{team.teamName}

+

{team.teamMembers.length} members

+
+ +
+ + {/* Clicking the button should open a modal to display team details + * and the option to join if user isn't part of team yet */} + + + +
+ + ); + }; + + export default TeamCard; diff --git a/src/components/layouts/container.less b/src/components/layouts/container.less index 8b6feb1..0e8efdc 100644 --- a/src/components/layouts/container.less +++ b/src/components/layouts/container.less @@ -2,5 +2,9 @@ .container { margin-left: 0; margin-right: 0; - + position: fixed; + overflow-y:scroll; + top: 80px; + bottom: 0; + width: 100%; } diff --git a/src/components/layouts/default.tsx b/src/components/layouts/default.tsx index 98f1262..8f72a6c 100644 --- a/src/components/layouts/default.tsx +++ b/src/components/layouts/default.tsx @@ -7,7 +7,7 @@ function DefaultLayout(props: any) { return (
- {props.children} + {props.children}
); } diff --git a/src/newStyles/components.less b/src/newStyles/components.less index 7a44630..50c2d73 100644 --- a/src/newStyles/components.less +++ b/src/newStyles/components.less @@ -47,13 +47,16 @@ @media screen and (max-width: @sm){ width: 100%; } + + @media screen and (min-width: 769px){ + width: 80%; + } } // A wrapper for most sections with content .generic-section { padding: 3rem @padding-base2; - margin: 0 auto; /* This will center the container horizontally */ box-sizing: border-box; /* Include padding and borders within the width */ } diff --git a/src/newStyles/layout.less b/src/newStyles/layout.less index 7dd09a8..b7cd887 100644 --- a/src/newStyles/layout.less +++ b/src/newStyles/layout.less @@ -15,7 +15,6 @@ margin: 0; padding: 0; overflow-x: hidden; // prevents user from scrolling horizontally - padding-top: 88px; // shifts page content down to make room for nav bar } diff --git a/src/newStyles/variables.less b/src/newStyles/variables.less index 706672a..e712099 100644 --- a/src/newStyles/variables.less +++ b/src/newStyles/variables.less @@ -31,7 +31,7 @@ @black: #282828; @white2: #f9fbff; // email background white @gray: #E9E9E9; -@light-gray: #EfEfEf; +@light-gray: #f0f0f0; @grayhover: #C9C9C9; @transparent-white-1: rgba(255,255,255,0.15); @@ -51,7 +51,7 @@ @blue: #42b0ff; @p1: #ff6f6f; @p2: #FF8D8B; -@p3: #1260ba; +@p3: rgb(18, 113, 255); @primary-color: #fe8019; @bg1: #001529; @@ -99,7 +99,7 @@ @xxs: 480px; @xs: 600px; @sm: 768px; -@md: 992px; +@md: 1000px; @lg: 1200px; diff --git a/src/pages/AboutPage/index.less b/src/pages/AboutPage/index.less index e693004..4f6de47 100644 --- a/src/pages/AboutPage/index.less +++ b/src/pages/AboutPage/index.less @@ -141,6 +141,14 @@ justify-content: center; } + .boardMemberPhoto { + margin-right: 1rem; + height: 100px; + width: 100px; + object-fit: cover; + object-position: center top; + border-radius: 100%; + } .titleBox { margin-top: 1rem; width: 70%; diff --git a/src/pages/AboutPage/index.tsx b/src/pages/AboutPage/index.tsx index 5605de2..cde9a04 100644 --- a/src/pages/AboutPage/index.tsx +++ b/src/pages/AboutPage/index.tsx @@ -266,16 +266,8 @@ function AboutPage() {
- {`profile diff --git a/src/pages/Competitions/CompetitionLeaderboardPage/index.tsx b/src/pages/Competitions/CompetitionLeaderboardPage/index.tsx index da9e81f..e29ab08 100644 --- a/src/pages/Competitions/CompetitionLeaderboardPage/index.tsx +++ b/src/pages/Competitions/CompetitionLeaderboardPage/index.tsx @@ -2,34 +2,15 @@ import React, { useEffect, useRef, useState } from 'react'; import './index.less'; import { useHistory, useParams } from 'react-router-dom'; import DefaultLayout from '../../../components/layouts/default'; -import { getMetaData, getLeaderboard } from '../../../actions/competition'; +import { getMetaData, getLeaderboard, CompetitionData } from '../../../actions/competition'; import { Table, Button, Modal } from 'antd'; import path from 'path'; import ChartJS from 'chart.js'; import { ColumnsType } from 'antd/lib/table'; import { genColor } from '../../../utils/colors'; -interface CompetitionData { - rank: number; - team: string; - // users: string[]; - score: number; - submitHistory: Array; - // last: Date; -} -const stringHash = (str: string) => { - let hash = 0, - i, - chr; - if (str.length === 0) return hash; - for (i = 0; i < str.length; i++) { - chr = str.charCodeAt(i) * 2; - hash = (hash << 5) - hash + chr; - hash |= 0; // Convert to 32bit integer - } - return hash; -}; + const columns: ColumnsType = [ { @@ -43,8 +24,8 @@ const columns: ColumnsType = [ dataIndex: 'team', sorter: (a, b) => a.team.length - b.team.length, render(value, record, index) { - const color1 = genColor(value); - const color2 = genColor(`${value}_abcs`); + const color1 = genColor(record.team); + const color2 = genColor(`${record.team}_abcs`); return (
= [ { title: 'Score', dataIndex: 'score', - // defaultSortOrder: 'descend', sorter: (a, b) => a.score - b.score, }, { title: 'Submissions', dataIndex: 'submitHistory', - // defaultSortOrder: 'descend', render: (v) => v.length, sorter: (a, b) => a.submitHistory.length - b.submitHistory.length, }, @@ -95,7 +74,6 @@ const CompetitionLeaderboardPage = () => { submissionsEnabled: boolean; } | null>(null); const [visible, setVisible] = useState(false); - const [chart, setChart] = useState(null); const chartContainer = useRef(null); const [scoreHistTitle, setScoreHistTitle] = useState(''); const params = useParams() as { id: string }; @@ -119,7 +97,6 @@ const CompetitionLeaderboardPage = () => { setData(newData); }); getMetaData(competitionID).then((res) => { - // console.log("METADATA", res.data); setMeta(res.data); }); }; diff --git a/src/pages/Competitions/CompetitionPortalPage/CountDownTimer.tsx b/src/pages/Competitions/CompetitionPortalPage/CountDownTimer.tsx new file mode 100644 index 0000000..c75bc2a --- /dev/null +++ b/src/pages/Competitions/CompetitionPortalPage/CountDownTimer.tsx @@ -0,0 +1,86 @@ +import React, { useState, useEffect } from 'react'; +import moment from 'moment'; + + +/** + * Component that displays the remaining time until + * a competition ends at a provided data + * + * @param {any} endDate represents the terminating date for a competition + * + * NOTE: this component has a bug where the remaining time is incorrect + * + */ +const CountdownTimer = ({ endDate }: { endDate: any }) => { + + /** + * Calculates the difference between current time + * and end time using moment js + */ + const calculateTimeRemaining = () => { + const now = moment(); + const endDateTime = moment(endDate); + const duration = moment.duration(endDateTime.diff(now)); + + return { + days: duration.days(), + hours: duration.hours(), + minutes: duration.minutes(), + seconds: duration.seconds(), + }; + }; + + const [timeRemaining, setTimeRemaining] = useState(calculateTimeRemaining()); + + /** + * Formats the remaining time into a semantic text + * like '5 days 4 hours left' + */ + const formatTimeRemaining = () => { + const { days, hours, minutes, seconds } = timeRemaining; + + if (days > 0) { + return `Closes in ${days} day${days !== 1 ? 's' : ''}${hours > 0 ? ` ${hours} hours` : ''}${minutes > 0 ? ` ${minutes} minutes` : '' + }`; + } else if (hours > 0) { + return `Closes in ${hours} hour${hours !== 1 ? 's' : ''}${minutes > 0 ? ` ${minutes} minutes` : ''}`; + } else { + return `Closes in ${minutes} minute${minutes !== 1 ? 's' : ''}${seconds > 0 ? ` ${seconds} seconds` : ''}`; + } + }; + + // Update the remaining time on a scheduled interval + useEffect(() => { + let intervalId: string | number | NodeJS.Timeout | undefined; + + const updateInterval = () => { + const { minutes } = timeRemaining; + + if (minutes > 1) { + // Use a longer interval when there's more than 1 minute remaining + return 60 * 1000; // 1 minute + } + else { + // Switch to a shorter interval when there's 1 minute or less remaining + return 1000; // 1 second + } + }; + + const updateTimer = () => { + setTimeRemaining(calculateTimeRemaining()); + }; + intervalId = setInterval(updateTimer, updateInterval()); + + return () => { + clearInterval(intervalId); + }; + + }, [endDate]); + + + return ( +

{formatTimeRemaining()}

+ ); +}; + +export default CountdownTimer; diff --git a/src/pages/Competitions/CompetitionPortalPage/LineChart.tsx b/src/pages/Competitions/CompetitionPortalPage/LineChart.tsx new file mode 100644 index 0000000..9de8c3f --- /dev/null +++ b/src/pages/Competitions/CompetitionPortalPage/LineChart.tsx @@ -0,0 +1,167 @@ +import React, { useEffect, useRef, useState } from 'react'; +import ChartJS from 'chart.js'; +import { Empty, Statistic } from 'antd'; +import { ArrowDownOutlined, ArrowUpOutlined } from '@ant-design/icons'; + +/** Configuration for chart graph that removes legend, + * horizontal lines and ticks. Customizes line and dot color + * + */ +const chartConfig = { + type: 'line', + data: { + labels: [0], + datasets: [ + { + data: [0], + pointBackgroundColor: 'rgb(18, 113, 255)', + backgroundColor: (ctx: any) => { + const canvas = ctx.chart.ctx; + const containerHeight = canvas.canvas.clientHeight; + + const gradient = canvas.createLinearGradient(0, -160, 0, containerHeight); + + // Adds a gradient under the curve + gradient.addColorStop(0, 'rgb(18, 113, 255)'); + gradient.addColorStop(1, 'rgba(255, 255, 255, 0.6)'); + + return gradient; + }, + borderColor: 'rgb(18, 113, 255)', + }, + ], + }, + options: { + responsive: true, + title: { + display: false, + }, + legend: { + display: false + }, + elements: { + rectangle: { + backgroundColor: '', // '' means no color + }, + }, + scales: { + yAxes: [{ + ticks: { + display: true, + fontColor: "grey" + }, + gridLines: { + display: false + }, + + angleLines: { + display: false + } + }], + xAxes: [{ + ticks: { + display: true, + length: 0, + fontColor: "grey" + }, + + angleLines: { + display: false + } + }], + }, + }, +}; + + +/** + * Used as an interactive line chart to view a competition team's + * submission score history from the competition portal dashboard. + * Utilizes a chart.js graph to plot data. + * + * @param {Array} scoreHistory the team's score history + * + */ +const LineChart = ({ scoreHistory }: {scoreHistory: Array}) => { + + // initialize the chart + const chartContainer = useRef(null); + const [chart, setChart] = useState(null); + const [chartTrigger, setTrigger] = useState(false); + + // Metric used to show relative change from previous submission score + const [scoreHistoryPercentage, setScoreHistoryPercentage] = useState(0); + + console.log(scoreHistory) + + useEffect(() => { + if(scoreHistory.length != 0) { + + // Find relative growth of scores + let lastTwo = scoreHistory.slice(-2); + + const diff = lastTwo[1] - lastTwo[0]; + const percent = diff / lastTwo[0]; + setScoreHistoryPercentage(percent); + } + + if (chartContainer.current && scoreHistory.length !== 0) { + const myChartRef = chartContainer.current.getContext('2d'); + + if (!chart) { + // Create the chart instance only once during the component mount + const newChart = new ChartJS(myChartRef!, chartConfig); + setChart(newChart); + } + + // Update chart data and labels + chartConfig.data.labels = Array.from({ length: scoreHistory.length }, (_, i) => i + 1); + chartConfig.data.datasets[0].data = scoreHistory; + + // Update the chart + chart?.update(); + } + }, [chartTrigger]); + + + + // Use a second hook to address bug where chartContainer ref does not update in time nor triggers callback + useEffect(() => { + setTrigger(true); + + }, [scoreHistory]); + + return ( + <> + {scoreHistory.length == 0 ? + // No data available + + : + // Otherwise display the percenage metric and the chart +
+ {scoreHistoryPercentage ? + : } + suffix="%" + /> + : + <> + } + {/* DO NOT remove the max height. ChartJS has a bug where changing the scorehistory + causes the line graph to grow down infinitely. This can happen whenever the user leaves and + joins another team. */} + +
+ + } + + + ) + +}; + + +export default LineChart; diff --git a/src/pages/Competitions/CompetitionPortalPage/MatchesPage/index.tsx b/src/pages/Competitions/CompetitionPortalPage/MatchesPage/index.tsx new file mode 100644 index 0000000..f8828d3 --- /dev/null +++ b/src/pages/Competitions/CompetitionPortalPage/MatchesPage/index.tsx @@ -0,0 +1,17 @@ +import React, { useContext, useEffect, useState } from "react"; +import DefaultLayout from "../../../../components/layouts/default"; + + +function MatchesPage() { + + return ( + + + + ) + +} + + + +export default MatchesPage; \ No newline at end of file diff --git a/src/pages/Competitions/CompetitionPortalPage/SubmissionEntryCard/index.less b/src/pages/Competitions/CompetitionPortalPage/SubmissionEntryCard/index.less new file mode 100644 index 0000000..f20442b --- /dev/null +++ b/src/pages/Competitions/CompetitionPortalPage/SubmissionEntryCard/index.less @@ -0,0 +1,125 @@ +@import '../../../../newStyles/text.less'; +@import '../../../../newStyles/components.less'; +@import '../../../../newStyles/index.less'; + +.submissionPreviewCard { + background: white; + width: 100%; + border-radius: @radius-base4; + padding: @padding-base2; + + span.submissionCardHeader { + display: inline-flex; + justify-content: space-between; + width: 100%; + + } + + h3.submissionUserName { + font-weight: @semi-bold; + } + + p.submissionDescription { + margin-top: 1rem; + margin-bottom: 1rem; + color: @text-gray2; + } + + span.submissionFileRow { + display: inline-flex; + align-items: center; + } +} + + +section.submissionDetailsSection { + margin-top: 2rem; + + div.submissionDetailsRow { + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; + max-width: 400px; + margin-bottom: 12px; + + span { + display: inline-flex; + align-items: center; + + } + + span > div { + + display: flex; + justify-content: center; + align-items: center; + background-color: @gray; + border-radius: @radius-base5; + width: 32px; + height: 32px; + margin-right: 1rem; + + .submissionDetailsIcon { + color: @text-gray2; + } + + p { + color: @text-gray2; + } + + } + + span .teamMemberImage { + height: 2rem !important; + width: 2rem !important; + } + + } +} + +section.submissionDescriptionSection, section.submissionErrorLogsSection { + margin-top: 2.5rem; + h4 { + font-weight: @semi-bold; + } + + p { + margin-top: 1rem; + color: @text-gray2; + } +} + + +section.submissionErrorLogsSection { + + margin-bottom: 2rem; + + p { + background: @gray; + border-radius: @radius-base4; + padding: @padding-base2; + } +} + + +p.submissionStatus { + padding: 4px 12px; + background: @p3; + align-self: center; + width: fit-content; + border-radius: @radius-base5; + color: white; +} + + +span.submissionTimeStamp { + display: inline-flex; + align-items: center; + margin-left: 1rem; + + p { + padding-left: 4px; + } + +} \ No newline at end of file diff --git a/src/pages/Competitions/CompetitionPortalPage/SubmissionEntryCard/index.tsx b/src/pages/Competitions/CompetitionPortalPage/SubmissionEntryCard/index.tsx new file mode 100644 index 0000000..c80dd1b --- /dev/null +++ b/src/pages/Competitions/CompetitionPortalPage/SubmissionEntryCard/index.tsx @@ -0,0 +1,196 @@ +import React, { useEffect, useState } from "react"; +import './index.less'; +import { IoEllipse, IoEllipsisVertical, IoPerson, IoTime } from "react-icons/io5"; +import moment from "moment"; +import { FaEllipsisH, FaLink, FaPaperclip } from "react-icons/fa"; +import { Button, Modal } from "antd"; +import { TeamMemberAvatar } from "../../CompetitionPortalPage"; +import { BsQuestion, BsQuestionLg } from "react-icons/bs"; +import { BiStats } from "react-icons/bi"; + +/** + * Modular component that renders a competition submission details. + * This is used as part of a submission log preview in the + * Competitions Portal page in the 'My Team' tab as well as the + * Submissions Log page. + * + * @param {any} data The submission entry, dereferenced using data.entry + * + * An entry inherits the competitionEntry interface: + * + * interface competitionEntry { + * _id: mongoose.Types.ObjectId, + * competitionTeam: mongoose.Types.ObjectId; + * competitionName: string; + * status: submitStates; + * description: string; + * tags: string[]; + * fileLocation: string; + * score: number; + * trueScore: number; + * rank: Object; + * submissionDate: Date; + * error: string; + * } + * + */ +const SubmissionEntryCard = (data: any) => { + + const [isModalOpen, setIsModalOpen] = useState(false); + + // the upload status of the entry + let status = '?'; + switch (data.entry.status) { + case 0: + status = 'uploading'; + break; + case 1: + status = 'unverified'; + break; + case 2: + status = 'verified'; + break; + case 3: + status = 'failed'; + break; + } + + function openModal() { + setIsModalOpen(true); + } + + return ( + <> + setIsModalOpen(false)} + onCancel={() => setIsModalOpen(false)} + open={isModalOpen} + title={

submission

} + centered + className="submissionModal" + > +
+ + +

{data.entry.dateString}

+ + +

+ {moment(data.entry.date).fromNow()} +

+
+
+ +
+ +
+ +
+ +
+ +
+

File

+
+

+
+ +
+ +
+ +
+

Uploader

+
+ + + {/* Current competitionEntry schema doesn't have way to add username to identify uploader. + * A simple solution is to insert the username into the tag list as the first element*/} + +

{data.entry.tags.split(',')[0]}

+
+ +
+ +
+ +
+ +
+

Status

+
+

{status}

+
+ +
+ +
+ +
+

Score

+
+

submission score

+
+
+ +
+

Description

+

{data.entry.description}

+
+ +
+

Error Logs

+
+

{data.entry.description}

+
+
+ +
+ + + +

{status}

+ + +

+ {moment(data.entry.date).fromNow()} +

+
+
+
+ + ); +} + +export default SubmissionEntryCard; \ No newline at end of file diff --git a/src/pages/Competitions/CompetitionPortalPage/SubmissionLogPage/index.less b/src/pages/Competitions/CompetitionPortalPage/SubmissionLogPage/index.less new file mode 100644 index 0000000..afe3314 --- /dev/null +++ b/src/pages/Competitions/CompetitionPortalPage/SubmissionLogPage/index.less @@ -0,0 +1,51 @@ +@import '../../../../newStyles/text.less'; +@import '../../../../newStyles/components.less'; +@import '../../../../newStyles/index.less'; + + + +#SubmissionLogPage { + .noContainer(); + + #submissionLogHeader { + .generic-section(); + .constrained-bounds(); + max-width: 1200px !important; + margin-top: 4rem; + margin-bottom: -2rem !important; + + span { + margin-top: 2rem; + width: 100%; + display: inline-flex; + align-items: center; + + #submissionLogSearch { + background-color: @light-gray; + border: none; + border-radius: @radius-base3; + max-width: @xxs; + margin-right: 1rem; + } + + #refreshButton { + .button-black-square(); + } + + } + + } + + #submissionLogColumn { + max-width: 1200px !important; + .generic-section(); + .constrained-bounds(); + + + .submissionPreviewCard { + box-shadow: @bx2 !important; + border-radius: @radius-base5; + } + } + +} \ No newline at end of file diff --git a/src/pages/Competitions/CompetitionPortalPage/SubmissionLogPage/index.tsx b/src/pages/Competitions/CompetitionPortalPage/SubmissionLogPage/index.tsx new file mode 100644 index 0000000..50f44db --- /dev/null +++ b/src/pages/Competitions/CompetitionPortalPage/SubmissionLogPage/index.tsx @@ -0,0 +1,144 @@ +import React, { useEffect, useState } from "react"; +import DefaultLayout from "../../../../components/layouts/default"; +import { Content } from "antd/es/layout/layout"; +import "./index.less"; +import { generateTeamPicture } from ".."; +import { useParams } from "react-router-dom"; +import { Button, Input, List, Skeleton } from "antd"; +import { PaginationAlign, PaginationPosition } from "antd/es/pagination/Pagination"; +import { getSubmissionDetails, getTeamInfo } from "../../../../actions/teams/utils"; +import SubmissionEntryCard from "../SubmissionEntryCard"; + +/** + * Renders the list of competition submission details for the user's team. + */ +function SubmissionLogPage() { + + // extract competition & team details from the url parameter to fetch corresponding submission data + const {competitionName, id} = useParams<{competitionName: string; id: string}>(); + const [position] = useState('bottom'); + const [align] = useState('center'); + + const [submissions, setSubmissions] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [teamInfo, setTeamInfo] = useState({}); + + // First grab the competition data and update team info + useEffect(() => { + getTeamInfo(competitionName, id).then((res) => { + console.log(res.data); + setTeamInfo(res.data); + console.log("set team info"); + }) + + }, []); + + // Once we verify team, get recent submissions + useEffect(() => { + if(teamInfo !== null) { + console.log(teamInfo) + fetchRecents(); + } + }, [teamInfo]); + + + /** + * Retrieves the submissions associated with the team. + */ + const fetchRecents = () => { + setIsLoading(true); + + // initialize the submissions + setSubmissions([]); + + if (teamInfo && teamInfo.submitHistory) { + console.log(teamInfo); + + {/* Currently slices data, but need to remove this line in the future */} + teamInfo.submitHistory.slice(0, 3).map((id: any) => { + getSubmissionDetails(competitionName, id).then((res) => { + let submission = res.data[0]; + if (!submission) return; + let date = new Date(submission.submissionDate); + + let submissionDetails = { + date: date, + status: submission.status, + dateString: + date.toLocaleDateString() + ' at ' + date.toLocaleTimeString(), + description: submission.description, + tags: submission.tags.join(', '), + score: submission.score, + key: id, + }; + + // Append the new submissions to the current state + setSubmissions((submissionData: any) => [ + ...submissionData, + submissionDetails, + ]); + + // Set a delayed loading status + setTimeout(() => { + setIsLoading(false); + }, 500); + + }); + }); + } + else { + setTimeout(() => { + setIsLoading(false); + }, 500); + } + } + + return( + + + + + + + {generateTeamPicture(id)} +

{id}

+
+ +

Submission Log

+

View your previous submissions to gauge the performance of your bots!

+ + + + + +
+ + + + + + {/* Render list of submission entry cards */} + {isLoading ? + + : + ( + + + + )} + /> + } + + +
+
+ ) +} + + +export default SubmissionLogPage; \ No newline at end of file diff --git a/src/pages/Competitions/CompetitionPortalPage/index.less b/src/pages/Competitions/CompetitionPortalPage/index.less index a292be7..b05c1b8 100644 --- a/src/pages/Competitions/CompetitionPortalPage/index.less +++ b/src/pages/Competitions/CompetitionPortalPage/index.less @@ -2,15 +2,403 @@ @import '../../../newStyles/components.less'; @import '../../../newStyles/index.less'; + .CompetitionPortalPage { .noContainer(); - #portalHeader{ + #portalHeader { .generic-section(); .constrained-bounds(); + max-width: 1200px !important; margin-top: 4rem; + + span { + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: center; + + Button { + .button-black-square(); + display: flex; + justify-content: center; + align-items: center; + } + } + + #portalBanner { + .gradient(); + border-radius: @radius-base4; + padding: @padding-base2; + margin-top: 1rem; + box-sizing: border-box; + + p { + color: white; + } + } + } + + + + + #portalStatsContent { + + width: 100%; + min-height: 120px; + margin-top: 2rem; + + section#noTeamMessage { + height: 108px; + text-align: left; + align-items: start; + padding: @padding-base2; + max-width: @sm; + color: @black; + } + + + #portalStatsRow { + display: grid; + grid-template-columns: repeat(4, minmax(124px, 1fr)); + gap: 1rem 2rem; // Adjust the gap as needed + width: 100%; + + @media screen and (max-width: @lg) { + grid-template-columns: repeat(auto-fill, minmax(45%, 1fr)); + } + + .portalStatsBox { + background-color: @light-gray; + min-height: 140px; + padding: @padding-base2; + border-radius: @radius-base4; + display: flex; + flex-direction: column; + justify-content: space-between; + margin-bottom: 1rem; + transition: background-color 0.2s ease-in; + + span { + display: flex; + justify-content: start; + flex-direction: row; + align-items: center; + flex-wrap: wrap; + + .statsIcon { + padding: 6px; + border-radius: 8px; + color: white; + background: black; + margin-right: 1rem; + } + + p { + color: @text-gray3; + } + + } + + p.stat { + font-size: 2.8rem; + font-weight: @semi-bold; + margin-top: 1rem; + } + } + + } + } + + + + #portalTabContent { + .generic-section(); + .constrained-bounds(); + overflow-y: hidden; // prevents layout shifts when user switches tabs + max-width: 1200px !important; + + .ant-tabs-ink-bar { + display: block; + background-color: @mainred; + } + + } + + + #findTeamsContainer { + margin-top: 1rem; + margin-bottom: 5rem; + + Input { + line-height: 2.5rem; + font-size: 16px; + } + } + +} + + +#leaderBoardContainer { + margin-top: 1rem; + + section { + display: inline-flex; + width: 100%; + justify-content: space-between; + margin-bottom: 1rem; + + p#lastRefreshedText { + background: @light-gray; + border-radius: @radius-base4; + padding: @padding-base; + margin-right: 2rem; + } + + @media screen and (max-width: @sm) { + p#lastRefreshedText { + display: none; + } + } + + Button { + .button-black-square(); + } + } +} + + + + +#myTeamContainer { + margin-top: 1rem; + + section:first-of-type { + display: grid; + grid-template-columns: minmax(400px, 3fr) 2fr; } + div#teamMainContent { + #submitFileButton { + margin-top: 2rem; + .button-black-square(); + } + #makeTeamButton { + margin-top: 1rem; + width: fit-content; + } + + #leaveTeamButton { + border: none; + } + + #teamNameWrapper { + display: flex; + justify-content: space-between; + align-items: center; + width: 100%; + + #rankingTag { + border-radius: @radius-base5; + background-color: rgb(18, 113, 255); + padding: 4px 12px; + width: fit-content; + color: white; + transform: translateY(4px); + font-weight: @semi-bold; + } + } + + #teamNameInput { + max-width: 300px; + } + + + + #teamHeader { + display: flex; + margin-bottom: 2rem; + align-items: center; + + h3 { + font-weight: @semi-bold; + } + } + + #uploadFileSection, + #submissionsPreviewSection, + #teamScoreHistorySection { + display: block; + margin-top: 5rem; + } + + #uploadFileSection { + background-color: @light-gray; + padding: @padding-base2; + border-radius: @radius-base4; + + #uploadFileHeader { + display: flex; + justify-content: space-between; + flex-direction: row; + margin-bottom: 2rem; + + h3 { + font-weight: @semi-bold; + } + } + + #uploadDescription { + color: @black; + border-radius: @radius-base3; + margin-bottom: 2rem; + height: 100px; + border: none; + resize: none; + } + + #antUploadText { + color: @text-gray; + } + } + + #teamScoreHistorySection { + + h3 { + font-weight: @semi-bold; + } + } + + #submissionsPreviewSection { + background-color: @light-gray; + padding: @padding-base2; + border-radius: @radius-base4; + margin-bottom: 10rem; + + + span#submissionsPreviewHeader { + width: 100%; + display: inline-flex; + justify-content: space-between; + align-items: center; + h3 { + font-weight: @semi-bold; + } + + span { + display: inline-flex; + } + + #viewSubmissionsButton { + color: black; + } + } + + Button#viewAllSubmissionsButton { + display: flex; + align-items: center; + } + + section#submissionsPreviewColumn { + width: 100%; + display: block; + margin-top: @margin-base2; + } + + } + + + #submissionCountDown { + background-color: @light-gray; + border-radius: @radius-base3; + padding: 10px; + display: inline-block; + margin-left: 1rem; + } + + + } + + div#sideContent { + margin-top: 1rem; + margin-left: 2rem; + height: fit-content; + + + div#membersBox { + height: fit-content; + box-sizing: border-box; + padding: @padding-base3; + background-color: @light-gray; + border-radius: @radius-base4; + + + #teamMembersHeader { + display: flex; + justify-content: space-between; + align-items: center; + + h3 { + margin-right: 1rem; + font-weight: @semi-bold; + } + + #inviteButton { + .button-black-square(); + } + } + + div.teamMember { + display: flex; + padding: 8px; + background: none; + border-radius: @radius-base4; + margin: 1rem 0rem 1rem 0; + transition: 0.2s ease-in; + + .teamMemberName { + font-weight: bold; + } + } + + div.teamMember:hover { + background-color: white !important; + cursor: pointer; + } + } + + div#matchesBox { + .gradient(); + height: fit-content; + box-sizing: border-box; + padding: @padding-base3; + border-radius: @radius-base4; + margin-top: 3rem; + + h3 { + margin-right: 1rem; + font-weight: @semi-bold; + color: white; + margin-bottom: 1rem; + } + + p { + color: white; + } + } + } +} + + + +@media screen and (max-width: @lg) { + #myTeamContainer section:first-of-type { + display: block; + } + + div#sideContent { + margin-left: 0 !important; + } } \ No newline at end of file diff --git a/src/pages/Competitions/CompetitionPortalPage/index.tsx b/src/pages/Competitions/CompetitionPortalPage/index.tsx index da78e21..3579d46 100644 --- a/src/pages/Competitions/CompetitionPortalPage/index.tsx +++ b/src/pages/Competitions/CompetitionPortalPage/index.tsx @@ -1,42 +1,1100 @@ -import React, { useContext, useEffect } from "react"; -import { message } from "antd"; -import { Layout, Space, Button } from 'antd'; -import UserContext from "../../../UserContext"; -import { useHistory } from 'react-router-dom'; +import React, { useContext, useEffect, useState } from "react"; +import { AutoComplete, Drawer, List, Skeleton, Tabs, message, Empty, Tooltip } from "antd"; +import { InboxOutlined } from '@ant-design/icons'; +import { Layout, Button, Input, Modal, Upload } from 'antd'; +import type { UploadProps } from 'antd'; +import UserContext, { User } from "../../../UserContext"; +import { Link, useHistory } from 'react-router-dom'; +import { + getTeamInfo, + createTeam, + getCompetitionUser, + getTeams, + leaveTeam, + getSubmissionDetails +} from '../../../actions/teams/utils'; +import TeamCard from '../../../components/TeamCard/index'; import './index.less'; +import path from 'path'; import DefaultLayout from "../../../components/layouts/default"; +import { PaginationPosition, PaginationAlign } from "antd/es/pagination/Pagination"; +import { CompetitionData, getLeaderboard, getMetaData, getRanks, registerCompetitionUser, uploadSubmission } from "../../../actions/competition"; +import { genColor } from "../../../utils/colors"; +import { IoHelp, IoRefresh, IoSearch, IoTime } from "react-icons/io5"; +import { IoEllipsisVertical , IoPersonAdd} from "react-icons/io5"; +import { FaCheck, FaClock, FaStar } from "react-icons/fa"; +import Table, { ColumnsType } from "antd/es/table"; +import { BiStats } from "react-icons/bi"; +import { createAvatar } from '@dicebear/core'; +import { botttsNeutral, identicon } from '@dicebear/collection'; +import CountdownTimer from "./CountDownTimer"; +import TextArea from "antd/es/input/TextArea"; +import LineChart from "./LineChart"; +import SubmissionEntryCard from "./SubmissionEntryCard"; const { Content } = Layout; +/** + * Renders the tab to view all available teams to join or leave + * + * @param {any} data Holds the array of all teams + * @param {User} user + * @param {any} compUser The competition user + * @param fetchTeamsCallback Function that refetches all team data + * @param updateRankings Function that retreives the new rankings of teams + * + */ +const FindTeamsTab = ( + { data, user, compUser, registered, fetchTeamsCallback, updateRankings }: + { data: Object[], user: User, compUser: any, registered: Boolean, fetchTeamsCallback: () => void, updateRankings: () => void } +) => { -function CompetitionPortalPage () { + // Constants to align the pagination options for the teams list + const [position] = useState('bottom'); + const [align] = useState('center'); + + // Dropdown options for search bar + const [options, setOptions] = useState>(data); + + // Initialize the teams data once that data defined + useEffect(() => { + if (data) { + setOptions(data); + } + }, [data, registered]) + + const handleSearch = (value: string) => { + // Resets search options back to the original data if the value is an empty string + if (value === "") { + setOptions(data); + } + } + + const handleSelect = (value: string) => { + + // Filter the list items + const filteredOptions = data.filter((item: any) => + item.teamName.toUpperCase().includes(value.toUpperCase()) + ); + setOptions(filteredOptions) + } + + return ( + + handleSearch(text)} + onSelect={handleSelect} + + // list of all possible options for dropdown + options={options.map((item: any) => ({ value: item.teamName }))} + + // filterOption to handle filtered dropdown items + filterOption={(inputValue, option) => + option!.value.toUpperCase().indexOf(inputValue.toUpperCase()) !== -1 + } + size="large" + style={{ width: "100%" }} + > + } size="large" placeholder="Look up a team name" /> + + + {/** List to preview all the teams based on the user's query */} + ( + + {} + + )} + /> + + + ); +}; + + + + +/** + * Renders the leaderboard of all teams based on their ranking + * + * @param {any} rankData The ranking data of all teams + * @param {Date} lastRefresh The last time when the leaderboard was refreshed + * @param updateRankingsCallback Function that refetches the rankings for all teams + * @param {boolean} isLoading Indicates if all the competitions teams info is being fetched + * + */ +const LeaderBoardTab = ( + {rankData, lastRefresh, updateRankingsCallback, isLoading}: + { rankData: any, + lastRefresh: Date | null, + updateRankingsCallback: () => void, + isLoading: boolean + } +) => { + + // Formats how the columns should be arranged and styled + const columns: ColumnsType = [ + { + title: 'Rank', + dataIndex: 'rank', + sorter: (a, b) => b.score - a.score, + defaultSortOrder: 'ascend', + }, + { + title: 'Team', + dataIndex: 'team', + sorter: (a, b) => a.team.length - b.team.length, + render(value, record, index) { + const color1 = genColor(record.team); + const color2 = genColor(`${record.team}_additional_seed`); + + return ( + +
+ {value.length > 28 ? ( + {value.substring(0, 28)}... + ) : ( + {value.substring(0, 28)} + )} +
+ ); + }, + }, + { + title: 'Score', + dataIndex: 'score', + sorter: (a, b) => a.score - b.score, + }, + ]; + + + return ( + +
+ +

+ Last refreshed{': '} + {lastRefresh ? lastRefresh.toLocaleString() : ''} +

+ +
+ + + ); +}; + + +/** + * Renders the submission preview list for the user's team + * + * @param {any} teamInfo The team's general information + * @param {string} competitionName The name of the current competition + * + */ +const SubmissionsPreview = ({teamInfo, competitionName}: {teamInfo: any, competitionName: string}) => { + + const [submissions, setSubmissions] = useState([]); + const [isLoading, setIsLoading] = useState(false); + + /* The actual data inside the teamInfo.submitHistory will be a list of mongoose object ids + that point to each competition entry object. This component will query the + recent entries and display them in a list view */ + + const dummyData = [ + "63c396f9671b14068b17f681" + ]; + + const fetchRecents = async() => { + setSubmissions([]); + setIsLoading(true); + + if (teamInfo) { + teamInfo.submitHistory.slice(0, 3).map((id: any) => { + getSubmissionDetails(competitionName, id).then((res) => { + let submission = res.data[0]; + if (!submission) return; + let date = new Date(submission.submissionDate); + let submissionDetails = { + date: date, + status: submission.status, + dateString: + date.toLocaleDateString() + ' at ' + date.toLocaleTimeString(), + description: submission.description, + tags: submission.tags.join(', '), + score: submission.score, + key: id, + }; + setSubmissions((submissionData: any) => [ + ...submissionData, + submissionDetails, + ]); + }); + }) + + setTimeout(() => { + // Your code to be executed after the delay + console.log("Delayed code executed!"); + setIsLoading(false); + }, 500); + + } + + } + + + useEffect(() => { + fetchRecents(); + }, []); + + return ( +
+ +

Submission Log

+ + + + +
+ +
+ {isLoading ? : + ( + + + + )} + /> + } +
+
+ ); +} + + + + +/** + * Generates a unique avatar for each team member + * using a third party avatar library + * + * @param {string} username A team member's name + * + */ +export const TeamMemberAvatar = ( {username}:{username: string}) => { + const [avatarUrl, setAvatarUrl] = useState(''); + const [loadingImage, setLoadingImage] = useState(false); + + useEffect(() => { + setLoadingImage(true); + // Generate avatar based on username using DiceBear + const svg = createAvatar(botttsNeutral, { + seed: username, + radius: 50, + backgroundType: ["gradientLinear"] + }); + + // Convert the SVG string to a data URL + const dataUrl = `data:image/svg+xml;base64,${btoa(svg.toString())}`; + + // Set the avatar URL + setAvatarUrl(dataUrl); + + setLoadingImage(false); + }, [username]); + + return ( + <> + {loadingImage ? ( + + ) : ( + {`Avatar + )} + + ) +} + +export const generateTeamPicture = (teamName: any) => { + + const color1 = genColor(teamName); + const color2 = genColor(`${teamName}_additional_seed`); + + + return ( +
+
+ ) +} + + + + +/** + * Component that displays a team's data. Contains several components + * to create a team, view team members, upload submissions, view submission previews, + * and navigate to the matches page. + * + * @param {boolean} isLoadingTeamInfo the current team the user is viewing + * @param {any} compUser schema that holds user's competition profile data + * @param {any} rankData schema that holds the team's ranking data + * @param {any} teamInfo schema that holds the team's general information + * @param fetchTeamCallback callback function to update the team data and the competition user + * + */ +const MyTeamTab = ( { isLoadingTeamInfo, compUser, rankData, teamInfo, metaData , fetchTeamsCallback}: + { isLoadingTeamInfo: boolean, compUser: any, rankData: any, teamInfo: any, metaData: any, fetchTeamsCallback: () => void } +) => { + + const [isLoading, setIsLoading] = useState(false); + + // Form field to create a new etam with a name + const [newTeamName, setNewTeamName] = useState(""); + + // Modal states + const [isInviteModalVisible, setIsInviteModalVisible] = useState(false); + const [isLeaveModalVisible, setIsLeaveModalVisible] = useState(false); + + // Submission description input + const [desc, setDesc] = useState(''); + + // Submission tags input (not being used for now as there isn't UI to add tags yet) + // const [tags, setTags] = useState>([]); + + const [submissionFile, setFile] = useState(); + const [uploading, setUploading] = useState(false); + + const showLeaveModal = () => { + setIsLeaveModalVisible(true); + }; + + const handleLeaveModalClose = () => { + setIsLeaveModalVisible(false); + }; + + const showInviteModal = () => { + setIsInviteModalVisible(true); + }; + + const handleInviteModalClose = () => { + setIsInviteModalVisible(false); + }; + + // Upload submission + const { Dragger } = Upload; + const uploadProps: UploadProps = { + name: 'file', + multiple: false, + // TODO: replace placeholder link with actual file uploading logic + // action: 'https://run.mocky.io/v3/435e224c-44fb-4773-9faf-380c5e6a2188', + onChange(info) { + const { status } = info.file; + if (status !== 'uploading') { + console.log(info.file, info.fileList); + } + if (status === 'done') { + message.success(`${info.file.name} file uploaded successfully.`); + setFile(info.file) + } else if (status === 'error') { + message.error(`${info.file.name} file upload failed.`); + } + }, + onDrop(e) { + console.log('Dropped files', e.dataTransfer.files); + }, + }; + + /** + * Helper function to refresh the submission history or log + * when the user successfully uploads a submission. Also + * performs a refresh of the team data in case the eval server + * updates the team's ranking, score, etc. + * + * @param event A react form event + */ + const handleSubmit = (event: React.FormEvent) => { + + event.preventDefault(); + fetchTeamsCallback(); + + /* TODO: When eval servers are up, uncomment this portion + event.preventDefault(); + setUploading(true); + uploadSubmission( + submissionFile, + // use the username as first tag value + [compUser.username], + desc, + compUser.competitionName, + compUser.username as string + ) + .then((res) => { + message.success('Submission Uploaded Succesfully'); + fetchTeamsCallback(); + }) + .catch((err) => { + message.error(`${err}`); + }) + .finally(() => { + setUploading(false); + }); */ + }; + + /** + * Converts a numeric rankinginto a ordinal label + * e.g. 1 = 1st, 2 = 2nd, 3 = 3rd, 4 = 4th... + * + * @param number Represents the team's ranking + * @returns {string} + */ + function getOrdinal(number: any) { + const suffixes = ['th', 'st', 'nd', 'rd']; + const v = number % 100; + return number + (suffixes[(v - 20) % 10] || suffixes[v] || suffixes[0]); + } + + /** + * Helper function that removes the user from the team. + * Invokes callback to update the dashboard data and competition user info + */ + const handleLeaveTeam = () => { + leaveTeam(compUser.competitionName, compUser.username, compUser.competitionTeam.teamName).then((res) => { + message.success('Successfully left team.'); + handleLeaveModalClose(); + fetchTeamsCallback(); + }) + .catch((error) => ( + message.error(error) + )); + } + + /** + * Helper function that calls API to create a new team + * for the user upon submission of a new name + * + */ + const handleClick = () => { + + if (newTeamName.length == 0) { + message.info('Name cannot be empty'); + return; + } + setIsLoading(true); + + createTeam(compUser.competitionName, compUser.username, newTeamName).then((res) => { + message.success('Successfully made a new team!'); + fetchTeamsCallback(); + console.log(compUser) + + }) + .catch((error) => { + message.error(error.message); + }); + setIsLoading(false); + } + + + return ( + <> + {isLoadingTeamInfo ? + + : + + + {teamInfo !== null && teamInfo.teamName && ( +
+
+ +
+ {generateTeamPicture(teamInfo.teamName)} +
+
+

{teamInfo.teamName}

+

{getOrdinal(rankData.rank)} place

+
+ + + + +
+
+ +
+

Score History

+ +
+ +
+ +

Upload Submission

+ {metaData.submissionsEnabled ? : "Submissions have closed" }

}> + +
+ +
+ +