├── .eslintignore ├── .eslintrc.json ├── .github └── FUNDING.yml ├── .gitignore ├── .gitmodules ├── .husky ├── .gitignore └── pre-commit ├── .prettierrc ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── amplify.yml ├── backend ├── .gitignore ├── README.md ├── lambda │ ├── cron-remove-timetable │ │ ├── index.js │ │ ├── package.json │ │ └── yarn.lock │ ├── cron-update-ranking │ │ ├── index.js │ │ ├── package.json │ │ └── yarn.lock │ ├── emailer │ │ ├── index.js │ │ ├── package.json │ │ ├── resend.js │ │ ├── setup.js │ │ ├── template.js │ │ └── yarn.lock │ ├── graphql │ │ ├── codegen.yml │ │ ├── package.json │ │ ├── src │ │ │ ├── context │ │ │ │ └── index.ts │ │ │ ├── directives │ │ │ │ ├── auth.ts │ │ │ │ ├── index.ts │ │ │ │ └── rateLimit.ts │ │ │ ├── graphql.ts │ │ │ ├── jwt │ │ │ │ └── index.ts │ │ │ ├── local.ts │ │ │ ├── plugins │ │ │ │ └── logging.ts │ │ │ ├── resolvers │ │ │ │ ├── course.ts │ │ │ │ ├── discussion.ts │ │ │ │ ├── index.ts │ │ │ │ ├── ranking.ts │ │ │ │ ├── report.ts │ │ │ │ ├── review.ts │ │ │ │ ├── timetable.ts │ │ │ │ └── user.ts │ │ │ ├── scalars │ │ │ │ ├── courseID.ts │ │ │ │ └── index.ts │ │ │ ├── schemas │ │ │ │ ├── build.ts │ │ │ │ ├── courses.graphql │ │ │ │ ├── discussion.graphql │ │ │ │ ├── index.ts │ │ │ │ ├── ranking.graphql │ │ │ │ ├── report.graphql │ │ │ │ ├── reviews.graphql │ │ │ │ ├── scalars.graphql │ │ │ │ ├── timetable.graphql │ │ │ │ └── user.graphql │ │ │ └── utils │ │ │ │ ├── getCourse.ts │ │ │ │ └── withCache.ts │ │ ├── tsconfig.json │ │ └── yarn.lock │ └── lambda.yaml ├── mongodb │ ├── package.json │ ├── src │ │ ├── constants │ │ │ ├── config.ts │ │ │ └── schema.ts │ │ ├── controllers │ │ │ ├── course.ts │ │ │ ├── discussion.ts │ │ │ ├── email.ts │ │ │ ├── index.ts │ │ │ ├── ranking.ts │ │ │ ├── report.ts │ │ │ ├── review.ts │ │ │ ├── timetable.ts │ │ │ └── user.ts │ │ ├── index.ts │ │ ├── jest │ │ │ ├── discussion.test.ts │ │ │ ├── env.ts │ │ │ ├── ranking.test.ts │ │ │ ├── report.test.ts │ │ │ ├── review.test.ts │ │ │ ├── timetable.test.ts │ │ │ └── user.test.ts │ │ └── models │ │ │ ├── course.ts │ │ │ ├── discussion.ts │ │ │ ├── email.ts │ │ │ ├── ranking.ts │ │ │ ├── report.ts │ │ │ ├── review.ts │ │ │ ├── timetable.ts │ │ │ └── user.ts │ ├── tsconfig.json │ └── yarn.lock ├── package.json ├── root-stack.yaml └── tools │ ├── copy-data.sh │ ├── copy-env.sh │ ├── copy-file.sh │ ├── deploy.sh │ ├── init.sh │ ├── install-package.sh │ ├── load-test │ ├── index.js │ ├── package.json │ └── yarn.lock │ ├── run-server.sh │ └── watch-files.sh ├── frontend ├── .eslintrc.json ├── .gitignore ├── README.md ├── next-env.d.ts ├── next-sitemap.config.js ├── next.config.js ├── package.json ├── public │ ├── cutopia-logo.png │ ├── images │ │ ├── error.svg │ │ └── null.svg │ └── robots.txt ├── sentry.client.config.js ├── sentry.properties ├── sentry.server.config.js ├── src │ ├── components │ │ ├── about │ │ │ ├── AboutSection.tsx │ │ │ └── tabs │ │ │ │ ├── AboutTab.tsx │ │ │ │ ├── PrivacyTab.tsx │ │ │ │ ├── TermsOfUseTab.tsx │ │ │ │ └── index.ts │ │ ├── atoms │ │ │ ├── Badge.tsx │ │ │ ├── CaptionDivider.tsx │ │ │ ├── Card.tsx │ │ │ ├── CardHeader.tsx │ │ │ ├── GradeIndicator.tsx │ │ │ ├── HeadSeo.tsx │ │ │ ├── If.tsx │ │ │ ├── Loading.tsx │ │ │ ├── LoadingButton.tsx │ │ │ ├── LoadingView.tsx │ │ │ ├── Logo.tsx │ │ │ ├── Page.tsx │ │ │ ├── TextField.tsx │ │ │ └── TextIcon.tsx │ │ ├── home │ │ │ ├── HomePageTabs.tsx │ │ │ └── UserCard.tsx │ │ ├── molecules │ │ │ ├── ChipsRow.tsx │ │ │ ├── ErrorCard.tsx │ │ │ ├── FeedCard.tsx │ │ │ ├── Footer.tsx │ │ │ ├── Link.tsx │ │ │ ├── ListItem.tsx │ │ │ ├── SearchInput.tsx │ │ │ ├── Section.tsx │ │ │ ├── SectionGroup.tsx │ │ │ ├── SectionText.tsx │ │ │ ├── ShowMoreOverlay.tsx │ │ │ ├── SnackBar.tsx │ │ │ ├── TabsContainer.tsx │ │ │ └── authenticatedRoute.tsx │ │ ├── organisms │ │ │ ├── Header.tsx │ │ │ ├── SearchDropdown.tsx │ │ │ └── SearchPanel.tsx │ │ ├── planner │ │ │ ├── CourseCard.tsx │ │ │ ├── CourseSectionCard.tsx │ │ │ ├── PlannerCart.tsx │ │ │ ├── PlannerTimetable.tsx │ │ │ ├── Timetable.tsx │ │ │ └── TimetableOverview.tsx │ │ ├── review │ │ │ ├── CourseCard.tsx │ │ │ ├── CourseComments.tsx │ │ │ ├── CourseReviews.tsx │ │ │ ├── CourseSections.tsx │ │ │ ├── GradeRow.tsx │ │ │ ├── LikeButtonRow.tsx │ │ │ ├── ReviewCard.tsx │ │ │ ├── ReviewEditPanel.tsx │ │ │ └── ReviewFilterBar.tsx │ │ └── templates │ │ │ ├── Dialog.tsx │ │ │ ├── DialogContentTemplate.tsx │ │ │ ├── LoginPanel.tsx │ │ │ └── TimetablePanel.tsx │ ├── config.ts │ ├── constants │ │ ├── colors.ts │ │ ├── courseCodes.ts │ │ ├── errors.ts │ │ ├── index.ts │ │ ├── messages.ts │ │ ├── mutations.ts │ │ └── queries.ts │ ├── helpers │ │ ├── apollo-client.ts │ │ ├── colorMixing.ts │ │ ├── data.ts │ │ ├── dynamicQueries.ts │ │ ├── getTime.ts │ │ ├── handleCompleted.ts │ │ ├── handleError.ts │ │ ├── ics.ts │ │ ├── index.ts │ │ ├── store.ts │ │ ├── timetable.ts │ │ ├── updateOpacity.ts │ │ └── withUndo.ts │ ├── hooks │ │ ├── useClickObserver.ts │ │ ├── useDebounce.ts │ │ ├── useMobileQuery.ts │ │ └── useOuterClick.ts │ ├── pages │ │ ├── _app.tsx │ │ ├── _document.tsx │ │ ├── about.tsx │ │ ├── index.tsx │ │ ├── login.tsx │ │ ├── planner.tsx │ │ └── review │ │ │ ├── [courseId].tsx │ │ │ └── index.tsx │ ├── store │ │ ├── DataStore.ts │ │ ├── PlannerStore.ts │ │ ├── StorePrototype.ts │ │ ├── UserStore.ts │ │ ├── ViewStore.ts │ │ └── index.tsx │ ├── styles │ │ ├── components │ │ │ ├── about │ │ │ │ └── About.module.scss │ │ │ ├── atoms │ │ │ │ ├── Badge.module.scss │ │ │ │ ├── CaptionDivider.module.scss │ │ │ │ ├── CardHeader.module.scss │ │ │ │ ├── GradeIndicator.module.scss │ │ │ │ ├── Loading.module.scss │ │ │ │ ├── Logo.module.scss │ │ │ │ ├── TextField.module.scss │ │ │ │ └── TextIcon.module.scss │ │ │ ├── home │ │ │ │ ├── HomePageTabs.module.scss │ │ │ │ └── UserCard.module.scss │ │ │ ├── molecules │ │ │ │ ├── ChipsRow.module.scss │ │ │ │ ├── ErrorCard.module.scss │ │ │ │ ├── FeedCard.module.scss │ │ │ │ ├── Footer.module.scss │ │ │ │ ├── Link.module.scss │ │ │ │ ├── SearchInput.module.scss │ │ │ │ ├── Section.module.scss │ │ │ │ ├── SectionGroup.module.scss │ │ │ │ ├── ShowMoreOverlay.module.scss │ │ │ │ └── SnackBar.module.scss │ │ │ ├── organisms │ │ │ │ ├── Header.module.scss │ │ │ │ ├── SearchDropdown.module.scss │ │ │ │ └── SearchPanel.module.scss │ │ │ ├── planner │ │ │ │ ├── CourseCard.module.scss │ │ │ │ ├── PlannerCart.module.scss │ │ │ │ ├── PlannerTimetable.module.scss │ │ │ │ ├── Timetable.module.scss │ │ │ │ └── TimetableOverview.module.scss │ │ │ ├── review │ │ │ │ ├── CourseCard.module.scss │ │ │ │ ├── CourseComments.module.scss │ │ │ │ ├── CoursePanel.module.scss │ │ │ │ ├── CourseReviews.module.scss │ │ │ │ ├── CourseSections.module.scss │ │ │ │ ├── GradeRow.module.scss │ │ │ │ ├── LikeButtonRow.module.scss │ │ │ │ ├── ReviewCard.module.scss │ │ │ │ ├── ReviewEditPanel.module.scss │ │ │ │ ├── ReviewFilterBar.module.scss │ │ │ │ └── ReviewPage.module.scss │ │ │ └── templates │ │ │ │ ├── Dialog.module.scss │ │ │ │ └── TimetablePanel.module.scss │ │ ├── globals.scss │ │ └── pages │ │ │ ├── AboutPage.module.scss │ │ │ ├── HomePage.module.scss │ │ │ ├── PlannerPage.module.scss │ │ │ └── login.module.scss │ └── types │ │ ├── courses.ts │ │ ├── discussions.ts │ │ ├── enums.ts │ │ ├── events.ts │ │ ├── general.ts │ │ ├── index.ts │ │ ├── reviews.ts │ │ ├── user.ts │ │ └── views.ts ├── tools │ └── coursesLoader.ts └── tsconfig.json ├── jest.config.js ├── lerna.json ├── package.json ├── tools ├── data.ipynb ├── git_sync.sh └── move_data.sh ├── tsconfig.json ├── types ├── .gitignore ├── .npmignore ├── package.json ├── src │ ├── index.ts │ ├── rules.test.ts │ ├── rules.ts │ └── types │ │ ├── codes.ts │ │ ├── courses.ts │ │ ├── discussions.ts │ │ ├── email.ts │ │ ├── events.ts │ │ ├── index.ts │ │ ├── ranking.ts │ │ ├── reports.ts │ │ ├── reviews.ts │ │ └── user.ts └── tsconfig.json └── yarn.lock /.eslintignore: -------------------------------------------------------------------------------- 1 | __tests__ 2 | node_modules 3 | backend/lambda/graphql/build 4 | backend/lambda/graphql/src/schemas/types.ts 5 | backend/mongodb/lib 6 | types/lib 7 | -------------------------------------------------------------------------------- /.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "es2021": true, 4 | "node": true 5 | }, 6 | "extends": [ 7 | "eslint:recommended", 8 | "plugin:@typescript-eslint/recommended", 9 | "plugin:prettier/recommended", 10 | "plugin:import/recommended", 11 | "plugin:import/typescript" 12 | ], 13 | "parser": "@typescript-eslint/parser", 14 | "parserOptions": { 15 | "ecmaVersion": 12, 16 | "sourceType": "module" 17 | }, 18 | "plugins": ["@typescript-eslint"], 19 | "rules": { 20 | "no-console": 0, 21 | "no-unused-vars": 0, 22 | "no-var": 0, 23 | "space-infix-ops": ["error", { "int32Hint": false }], 24 | "comma-spacing": 2, 25 | "keyword-spacing": ["error", { "before": true }], 26 | "semi": ["error", "always"], 27 | "quote-props": ["error", "as-needed"], 28 | "prefer-const": ["error", { "destructuring": "all" }], 29 | "import/order": [ 30 | "error", 31 | { 32 | "newlines-between": "always", 33 | "alphabetize": { 34 | "order": "asc", 35 | "caseInsensitive": true 36 | } 37 | } 38 | ], 39 | "@typescript-eslint/no-unused-vars": 0 /* For express hanlder, DO NOT delete unused params */, 40 | "@typescript-eslint/no-explicit-any": 0, 41 | "@typescript-eslint/explicit-module-boundary-types": 0, 42 | "@typescript-eslint/no-var-requires": 0 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | ko_fi: cutopia 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | 25 | .eslintcache 26 | data/ 27 | logs/ 28 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "data"] 2 | path = data 3 | url = https://github.com/cutopia-labs/cuhk-course-data 4 | [submodule "tools/scraper"] 5 | path = tools/scraper 6 | url = https://github.com/mikezzb/cuhk-course-scraper.git 7 | -------------------------------------------------------------------------------- /.husky/.gitignore: -------------------------------------------------------------------------------- 1 | _ 2 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | . "$(dirname "$0")/_/husky.sh" 3 | 4 | npx lint-staged 5 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "quoteProps": "consistent", 3 | "singleQuote": true, 4 | "tabWidth": 2, 5 | "trailingComma": "es5", 6 | "useTabs": false, 7 | "arrowParens": "avoid" 8 | } -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to CUtopia 2 | 3 | Thank you for your interesting in helping us to make this project useful for CUHK students. Here are some guidelines for your contributions. 4 | 5 | ## Pull Request 6 | 7 | Before you open you pull request, you are encouraged to open an issue first to discuss it with us. Also, please make sure that one feature / bug fix per PR. 8 | 9 | Steps: 10 | 11 | 1. Fork and clone this repo 12 | 2. Set upstream: 13 | 14 | ```sh 15 | git remote add upstream https://github.com/cutopia-labs/CUtopia.git 16 | ``` 17 | 18 | 3. Checkout to your feature / bug fix branch: 19 | 20 | ```sh 21 | git checkout -b your-branch 22 | ``` 23 | 24 | 4. Install the dependencies and start the dev servers according to [readme](https://github.com/cutopia-labs/CUtopia#scripts) 25 | 5. Commit your changes and push them to your fork repo 26 | 27 | ```sh 28 | git push -u origin HEAD 29 | ``` 30 | 31 | 6. Go to [this repo](https://github.com/cutopia-labs/CUtopia) and make a Pull Request to **dev branch** 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 CUtopia 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /amplify.yml: -------------------------------------------------------------------------------- 1 | version: 1 2 | applications: 3 | - frontend: 4 | phases: 5 | preBuild: 6 | commands: 7 | - eval $(ssh-agent -s) 8 | - ssh-add <(echo "$SUBMODULE_KEY" | base64 --decode) 9 | - mkdir -p /root/.ssh/ 10 | - touch /root/.ssh/config 11 | - 'echo -e "Host *\n\tStrictHostKeyChecking no\n\n" > ~/.ssh/config' 12 | - yarn --cwd ../ bootstrap:fe 13 | build: 14 | commands: 15 | - echo "REACT_APP_ENV_MODE=$REACT_APP_ENV_MODE" >> .env 16 | - echo "REACT_APP_CURRENT_TERM=$REACT_APP_CURRENT_TERM" >> .env 17 | - echo "REACT_APP_LAST_DATA_UPDATE=$REACT_APP_LAST_DATA_UPDATE" >> .env 18 | - yarn --cwd ../ run build:fe 19 | artifacts: 20 | baseDirectory: build 21 | files: 22 | - '**/*' 23 | cache: 24 | paths: 25 | - node_modules/**/* 26 | appRoot: frontend 27 | -------------------------------------------------------------------------------- /backend/.gitignore: -------------------------------------------------------------------------------- 1 | samconfig.toml 2 | node_modules/ 3 | *.env 4 | lambda/graphql/build 5 | lambda/graphql/src/data 6 | lambda/graphql/src/schemas/bundle.graphql 7 | lambda/graphql/src/schemas/types.ts 8 | mongodb/build 9 | mongodb/lib 10 | *.key 11 | *.key.pub 12 | -------------------------------------------------------------------------------- /backend/lambda/cron-remove-timetable/index.js: -------------------------------------------------------------------------------- 1 | const { connect, cleanExpiredTimetable } = require('mongodb'); 2 | require('dotenv').config(); 3 | 4 | exports.handler = async () => { 5 | await connect(process.env.ATLAS_URI); 6 | 7 | console.time('Remove expired timetables'); 8 | const result = await cleanExpiredTimetable({ 9 | expireDate: Date.now(), 10 | }); 11 | console.timeEnd('Remove expired timetables'); 12 | console.log(`Removed ${result.modifiedCount} expired timetables`); 13 | }; 14 | -------------------------------------------------------------------------------- /backend/lambda/cron-remove-timetable/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cron-remove-timetable", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "license": "MIT", 6 | "dependencies": { 7 | "dotenv": "^16.0.1", 8 | "mongodb": "file:../../mongodb/lib" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /backend/lambda/cron-remove-timetable/yarn.lock: -------------------------------------------------------------------------------- 1 | # THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. 2 | # yarn lockfile v1 3 | 4 | 5 | dotenv@^16.0.1: 6 | version "16.0.1" 7 | resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-16.0.1.tgz#8f8f9d94876c35dac989876a5d3a82a267fdce1d" 8 | integrity sha512-1K6hR6wtk2FviQ4kEiSjFiH5rpzEVi8WW0x96aztHVMhEspNpc4DVOUTEHtEva5VThQ8IaBX1Pe4gSzpVVUsKQ== 9 | 10 | "mongodb@file:../../mongodb/lib": 11 | version "0.0.0" 12 | -------------------------------------------------------------------------------- /backend/lambda/cron-update-ranking/index.js: -------------------------------------------------------------------------------- 1 | const { connect, rankCourses } = require('mongodb'); 2 | require('dotenv').config(); 3 | 4 | exports.handler = async () => { 5 | await connect(process.env.ATLAS_URI); 6 | 7 | console.time('Rank courses'); 8 | await rankCourses(); 9 | console.timeEnd('Rank courses'); 10 | }; 11 | -------------------------------------------------------------------------------- /backend/lambda/cron-update-ranking/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cron-update-ranking", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "license": "MIT", 6 | "dependencies": { 7 | "dotenv": "^16.0.1", 8 | "mongodb": "file:../../mongodb/lib" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /backend/lambda/cron-update-ranking/yarn.lock: -------------------------------------------------------------------------------- 1 | # THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. 2 | # yarn lockfile v1 3 | 4 | 5 | dotenv@^16.0.1: 6 | version "16.0.1" 7 | resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-16.0.1.tgz#8f8f9d94876c35dac989876a5d3a82a267fdce1d" 8 | integrity sha512-1K6hR6wtk2FviQ4kEiSjFiH5rpzEVi8WW0x96aztHVMhEspNpc4DVOUTEHtEva5VThQ8IaBX1Pe4gSzpVVUsKQ== 9 | 10 | "mongodb@file:../../mongodb/lib": 11 | version "0.0.0" 12 | -------------------------------------------------------------------------------- /backend/lambda/emailer/index.js: -------------------------------------------------------------------------------- 1 | require('dotenv').config(); 2 | const { connect, disconnect, addToResendList } = require('mongodb'); 3 | 4 | const { setupEmailer } = require('./setup'); 5 | const { getTemplateByAction } = require('./template'); 6 | 7 | const transporters = setupEmailer(); 8 | 9 | exports.handler = async event => { 10 | const message = JSON.parse(event.Records[0].Sns.Message); 11 | const mail = getTemplateByAction(message); 12 | 13 | for (const transporter of transporters) { 14 | // temporary workaround to resend emails when failed 15 | try { 16 | await transporter.sendMail(mail); 17 | console.log('sent!', transporter.transporter.auth.user, message); 18 | return; 19 | } catch (e) { 20 | console.log('failed:', transporter.transporter.auth.user); 21 | console.log(e); 22 | continue; 23 | } 24 | } 25 | // still failed, then add that message to database for resending later 26 | console.log('still failed :(', message); 27 | await connect(process.env.ATLAS_URI); 28 | await addToResendList(message); 29 | await disconnect(); 30 | }; 31 | -------------------------------------------------------------------------------- /backend/lambda/emailer/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "emailer", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1" 8 | }, 9 | "author": "", 10 | "license": "ISC", 11 | "dependencies": { 12 | "dotenv": "^16.0.1", 13 | "googleapis": "^100.0.0", 14 | "mongodb": "file:../../mongodb/lib", 15 | "nodemailer": "^6.7.5" 16 | }, 17 | "engines": { 18 | "node": ">=v14.0.0", 19 | "npm": ">=6.14.8", 20 | "yarn": ">=1.22.5" 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /backend/lambda/emailer/resend.js: -------------------------------------------------------------------------------- 1 | require('dotenv').config(); 2 | const { 3 | connect, 4 | disconnect, 5 | getResendList, 6 | removeFromResendList, 7 | } = require('mongodb'); 8 | 9 | const { setupEmailer } = require('./setup'); 10 | const { getTemplateByAction } = require('./template'); 11 | 12 | const transporters = setupEmailer(); 13 | 14 | exports.handler = async event => { 15 | await connect(process.env.ATLAS_URI); 16 | const resendList = await getResendList(); 17 | 18 | try { 19 | await Promise.all( 20 | resendList.map(async message => { 21 | for (const transporter of transporters) { 22 | // temporary workaround to resend emails when failed 23 | try { 24 | await transporter.sendMail(getTemplateByAction(message)); 25 | await removeFromResendList(message); 26 | console.log('resent!', message); 27 | break; 28 | } catch (e) { 29 | console.log('resend failed', message); 30 | continue; 31 | } 32 | } 33 | }) 34 | ); 35 | } finally { 36 | await disconnect(); 37 | } 38 | }; 39 | -------------------------------------------------------------------------------- /backend/lambda/emailer/setup.js: -------------------------------------------------------------------------------- 1 | require('dotenv').config({ path: '.emailer.env' }); 2 | const { google } = require('googleapis'); 3 | const nodemailer = require('nodemailer'); 4 | 5 | exports.setupEmailer = () => { 6 | const setup = id => { 7 | const OAuth2Client = new google.auth.OAuth2( 8 | process.env[`GMAIL_CLIENT_ID_${id}`], 9 | process.env[`GMAIL_CLIENT_SECRET_${id}`], 10 | 'https://developers.google.com/oauthplayground' 11 | ); 12 | OAuth2Client.setCredentials({ 13 | refresh_token: process.env[`GMAIL_REFRESH_TOKEN_${id}`], 14 | }); 15 | const transporter = nodemailer.createTransport({ 16 | service: 'gmail', 17 | auth: { 18 | type: 'OAuth2', 19 | user: process.env[`GMAIL_ADDRESS_${id}`], 20 | clientId: process.env[`GMAIL_CLIENT_ID_${id}`], 21 | clientSecret: process.env[`GMAIL_CLIENT_SECRET_${id}`], 22 | refreshToken: process.env[`GMAIL_REFRESH_TOKEN_${id}`], 23 | accessToken: process.env[`GMAIL_ACCESS_TOKEN_${id}`], 24 | }, 25 | }); 26 | transporter.set('oauth2_provision_cb', (user, renew, callback) => { 27 | if (renew) { 28 | const accessToken = OAuth2Client.getAccessToken(); 29 | return callback(null, accessToken); 30 | } 31 | return callback(null, process.env[`GMAIL_ACCESS_TOKEN_${id}`]); 32 | }); 33 | return transporter; 34 | }; 35 | // temporary workaround to resend emails when failed 36 | // only 3 gmails are set up to send emails 37 | return Array.from({ length: 3 }, (_, i) => i).map(setup); 38 | }; 39 | -------------------------------------------------------------------------------- /backend/lambda/emailer/template.js: -------------------------------------------------------------------------------- 1 | exports.getTemplateByAction = input => { 2 | const { action, ...templateInput } = input; 3 | switch (action) { 4 | case 'create': 5 | return this.createUserTemplate(templateInput); 6 | case 'resetPwd': 7 | return this.resetPwdTemplate(templateInput); 8 | } 9 | }; 10 | 11 | exports.createUserTemplate = ({ SID, username, code }) => ({ 12 | from: 'CUtopia ', 13 | to: `${SID}@link.cuhk.edu.hk`, 14 | subject: 'CUtopia confirmation email', 15 | html: `Thanks for using CUtopia!

To verify your account, please click 16 | here OR 17 | manually enter the verification code: ${code}

Regards,
CUtopia Team`, 18 | }); 19 | 20 | exports.resetPwdTemplate = ({ SID, userId, code }) => ({ 21 | from: 'CUtopia ', 22 | to: `${SID}@link.cuhk.edu.hk`, 23 | subject: 'CUtopia reset password', 24 | html: `To reset your password, please click 25 | here OR 26 | manually enter the code: ${code}

Regards,
CUtopia Team`, 27 | }); 28 | -------------------------------------------------------------------------------- /backend/lambda/graphql/codegen.yml: -------------------------------------------------------------------------------- 1 | overwrite: true 2 | schema: src/schemas/bundle.graphql 3 | documents: null 4 | generates: 5 | src/schemas/types.ts: 6 | plugins: 7 | - "typescript" 8 | - "typescript-resolvers" 9 | - "typescript-document-nodes" 10 | config: 11 | # path is relative to src/schemas 12 | contextType: ../context/index#Context 13 | # map GraphQL types to MongoDB model types 14 | # for accessing fields that exist in MongoDB model only 15 | # e.g. upvoteUserIds in Review 16 | mappers: 17 | Review: cutopia-types#Review 18 | mapperTypeSuffix: MongoDB 19 | -------------------------------------------------------------------------------- /backend/lambda/graphql/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "graphql-endpoint", 3 | "version": "1.0.0", 4 | "main": "graphql.js", 5 | "scripts": { 6 | "build-schema": "ts-node src/schemas/build.ts", 7 | "build-gql-types": "graphql-codegen --config codegen.yml", 8 | "build": "rm -rf build && yarn run build-schema && tsc && yarn --modules-folder build/node_modules --prod install" 9 | }, 10 | "license": "MIT", 11 | "dependencies": { 12 | "@graphql-tools/load-files": "^6.5.3", 13 | "@graphql-tools/schema": "^9.0.2", 14 | "@graphql-tools/utils": "^8.6.12", 15 | "apollo-server-lambda": "^3.8.1", 16 | "aws-sdk": "^2.1144.0", 17 | "cutopia-types": "file:../../../types/lib", 18 | "dotenv": "^16.0.1", 19 | "graphql": "^16.5.0", 20 | "graphql-constraint-directive": "^3.1.1", 21 | "graphql-fields": "^2.0.3", 22 | "graphql-middleware": "^6.1.28", 23 | "graphql-rate-limit-directive": "^2.0.2", 24 | "graphql-scalars": "^1.17.0", 25 | "graphql-tools": "^8.2.11", 26 | "jsonwebtoken": "^8.5.1", 27 | "mongodb": "file:../../mongodb/lib", 28 | "node-cache": "^5.1.2", 29 | "rate-limiter-flexible": "^2.3.7" 30 | }, 31 | "engines": { 32 | "node": ">=v14.0.0", 33 | "npm": ">=6.14.8", 34 | "yarn": ">=1.22.5" 35 | }, 36 | "devDependencies": { 37 | "@graphql-codegen/cli": "2.6.2", 38 | "@graphql-codegen/typescript": "2.4.11", 39 | "@graphql-codegen/typescript-document-nodes": "2.2.11", 40 | "@graphql-codegen/typescript-resolvers": "^2.6.4", 41 | "@types/jsonwebtoken": "^9.0.1", 42 | "apollo-server-express": "^3.8.1", 43 | "express": "^4.18.1", 44 | "nodemon": "^2.0.19", 45 | "ts-node": "^10.8.0" 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /backend/lambda/graphql/src/context/index.ts: -------------------------------------------------------------------------------- 1 | import { Token, verify } from '../jwt'; 2 | 3 | export interface Context { 4 | user: Token; 5 | ip: string; 6 | } 7 | 8 | const context = async ({ event: lambdaEvent }) => { 9 | const ip = lambdaEvent.requestContext.identity.sourceIp; 10 | const split = (lambdaEvent.headers.Authorization || '').split('Bearer '); 11 | 12 | const defaultContext = { 13 | user: null, 14 | ip, 15 | }; 16 | 17 | if (split.length !== 2) { 18 | return defaultContext; 19 | } 20 | const token = split[1]; 21 | const userContext = await verify(token); 22 | return { 23 | ...defaultContext, 24 | user: userContext, 25 | }; 26 | }; 27 | 28 | export default context; 29 | -------------------------------------------------------------------------------- /backend/lambda/graphql/src/directives/auth.ts: -------------------------------------------------------------------------------- 1 | import { mapSchema, getDirective, MapperKind } from '@graphql-tools/utils'; 2 | import { ErrorCode } from 'cutopia-types'; 3 | import { GraphQLSchema, defaultFieldResolver } from 'graphql'; 4 | 5 | // reference: https://www.graphql-tools.com/docs/schema-directives#enforcing-access-permissions 6 | const authDirectiveTypeDefs = `directive @auth on OBJECT | FIELD_DEFINITION`; 7 | const authDirectiveTransformer = (schema: GraphQLSchema) => 8 | mapSchema(schema, { 9 | [MapperKind.OBJECT_FIELD]: (fieldConfig, _fieldName, typeName) => { 10 | const authDirective = getDirective(schema, fieldConfig, 'auth')?.[0]; 11 | if (authDirective) { 12 | const { resolve = defaultFieldResolver } = fieldConfig; 13 | fieldConfig.resolve = (parent, args, context, info) => { 14 | if (!context.user) { 15 | throw Error(ErrorCode.AUTHORIZATION_INVALID_TOKEN.toString()); 16 | } 17 | return resolve(parent, args, context, info); 18 | }; 19 | return fieldConfig; 20 | } 21 | }, 22 | }); 23 | 24 | export { authDirectiveTypeDefs, authDirectiveTransformer }; 25 | -------------------------------------------------------------------------------- /backend/lambda/graphql/src/directives/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | constraintDirective, 3 | constraintDirectiveTypeDefs, 4 | } from 'graphql-constraint-directive'; 5 | 6 | import { authDirectiveTypeDefs, authDirectiveTransformer } from './auth'; 7 | import { 8 | rateLimitDirectiveTypeDefs, 9 | rateLimitDirectiveTransformer, 10 | } from './rateLimit'; 11 | 12 | const directivesTypeDefs = [ 13 | authDirectiveTypeDefs, 14 | rateLimitDirectiveTypeDefs, 15 | constraintDirectiveTypeDefs, 16 | ]; 17 | const addDirectivesToSchema = schema => { 18 | let newSchema = authDirectiveTransformer(schema); 19 | newSchema = rateLimitDirectiveTransformer(newSchema); 20 | newSchema = constraintDirective()(newSchema); 21 | return newSchema; 22 | }; 23 | 24 | export { directivesTypeDefs, addDirectivesToSchema }; 25 | -------------------------------------------------------------------------------- /backend/lambda/graphql/src/directives/rateLimit.ts: -------------------------------------------------------------------------------- 1 | import { ErrorCode } from 'cutopia-types'; 2 | import { 3 | defaultKeyGenerator, 4 | rateLimitDirective, 5 | } from 'graphql-rate-limit-directive'; 6 | 7 | import { Context } from '../context'; 8 | 9 | // reference: https://github.com/ravangen/graphql-rate-limit/tree/master/examples/context 10 | const { rateLimitDirectiveTypeDefs, rateLimitDirectiveTransformer } = 11 | rateLimitDirective({ 12 | keyGenerator: (...params) => 13 | `${(params[3] as Context).ip}:${defaultKeyGenerator(...params)}`, 14 | onLimit: () => { 15 | throw new Error(ErrorCode.EXCEED_RATE_LIMIT.toString()); 16 | }, 17 | }); 18 | 19 | export { rateLimitDirectiveTypeDefs, rateLimitDirectiveTransformer }; 20 | -------------------------------------------------------------------------------- /backend/lambda/graphql/src/graphql.ts: -------------------------------------------------------------------------------- 1 | import { makeExecutableSchema } from '@graphql-tools/schema'; 2 | import { ApolloServer } from 'apollo-server-lambda'; 3 | import dotenv from 'dotenv'; 4 | import { connect } from 'mongodb'; 5 | 6 | import createContext from './context'; 7 | import { directivesTypeDefs, addDirectivesToSchema } from './directives'; 8 | import loggingPlugin from './plugins/logging'; 9 | import resolvers from './resolvers'; 10 | import scalarResolvers from './scalars'; 11 | import typeDefs from './schemas'; 12 | 13 | dotenv.config(); 14 | 15 | const isProduction = process.env.NODE_ENV === 'production'; 16 | 17 | // no need to await, mongoose buffers function calls internally 18 | connect(process.env.ATLAS_URI); 19 | 20 | let schema = makeExecutableSchema({ 21 | typeDefs: [...directivesTypeDefs, ...typeDefs], 22 | resolvers: { 23 | ...scalarResolvers, 24 | ...resolvers, 25 | }, 26 | }); 27 | schema = addDirectivesToSchema(schema); 28 | 29 | const server = new ApolloServer({ 30 | schema, 31 | context: createContext, 32 | introspection: !isProduction, 33 | plugins: [loggingPlugin], 34 | }); 35 | 36 | export const graphqlHandler = server.createHandler({ 37 | expressGetMiddlewareOptions: { 38 | cors: { 39 | origin: isProduction 40 | ? ['https://cutopia.app', 'https://dev.cutopia.app'] 41 | : '*', 42 | methods: ['get', 'post'], 43 | maxAge: 3600, 44 | }, 45 | }, 46 | }); 47 | -------------------------------------------------------------------------------- /backend/lambda/graphql/src/jwt/index.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import { join } from 'path'; 3 | 4 | import { ApolloError } from 'apollo-server-errors'; 5 | import { ErrorCode } from 'cutopia-types'; 6 | import jwt, { JwtPayload } from 'jsonwebtoken'; 7 | import { getUser } from 'mongodb'; 8 | 9 | const privateKey = fs.readFileSync(join(__dirname, './jwtRS256.key')); 10 | const publicKey = fs.readFileSync(join(__dirname, './jwtRS256.key.pub')); 11 | 12 | // i.e. if last 6 digits of hashed pwd is same, then user didn't change pwd 13 | const SAME_PASSWORD_THRESHOLD = 6; 14 | export interface Token extends JwtPayload { 15 | username: string; 16 | password: string; 17 | } 18 | 19 | export const sign = (payload: Token) => { 20 | payload.password = payload.password.slice(-SAME_PASSWORD_THRESHOLD); 21 | const token = jwt.sign(payload, privateKey, { 22 | algorithm: 'RS256', 23 | expiresIn: '7d', 24 | }); 25 | return token; 26 | }; 27 | 28 | export const verify = async token => { 29 | let decoded: Token; 30 | try { 31 | decoded = jwt.verify(token, publicKey, { 32 | ignoreExpiration: true, 33 | }) as Token; 34 | } catch (e) { 35 | return null; 36 | } 37 | 38 | if (Date.now() >= (decoded as JwtPayload).exp * 1000) { 39 | const { username, password } = decoded; 40 | const user = await getUser(username, 'password'); 41 | // If pwd not changed, then return refreshed token 42 | if (user?.password.endsWith(password)) { 43 | throw new ApolloError( 44 | 'Expired token', 45 | ErrorCode.AUTHORIZATION_REFRESH_TOKEN.toString(), 46 | { 47 | refreshedToken: sign({ username, password }), 48 | } 49 | ); 50 | } else { 51 | throw new ApolloError( 52 | 'Invalid token', 53 | ErrorCode.AUTHORIZATION_INVALID_TOKEN.toString() 54 | ); 55 | } 56 | } 57 | return decoded; 58 | }; 59 | -------------------------------------------------------------------------------- /backend/lambda/graphql/src/local.ts: -------------------------------------------------------------------------------- 1 | import { makeExecutableSchema } from '@graphql-tools/schema'; 2 | import { ApolloServer } from 'apollo-server-express'; 3 | import dotenv from 'dotenv'; 4 | import express from 'express'; 5 | import { connect } from 'mongodb'; 6 | 7 | import createContext from './context'; 8 | import { directivesTypeDefs, addDirectivesToSchema } from './directives'; 9 | import loggingPlugin from './plugins/logging'; 10 | import resolvers from './resolvers'; 11 | import scalarResolvers from './scalars'; 12 | import typeDefs from './schemas'; 13 | 14 | dotenv.config(); 15 | 16 | let schema = makeExecutableSchema({ 17 | typeDefs: [...directivesTypeDefs, ...typeDefs], 18 | resolvers: { 19 | ...scalarResolvers, 20 | ...resolvers, 21 | }, 22 | }); 23 | schema = addDirectivesToSchema(schema); 24 | 25 | const server = new ApolloServer({ 26 | schema, 27 | context: req => 28 | // To fake a lambda request context 29 | createContext({ 30 | event: { 31 | headers: { 32 | Authorization: req.req.headers.authorization, 33 | }, 34 | requestContext: { 35 | identity: { 36 | sourceIp: 'localhost', 37 | }, 38 | }, 39 | }, 40 | }), 41 | introspection: true, 42 | plugins: [loggingPlugin], 43 | }); 44 | 45 | const startApolloServer = async () => { 46 | await server.start(); 47 | 48 | await connect(process.env.ATLAS_URI); 49 | const app = express(); 50 | server.applyMiddleware({ app }); 51 | app.listen({ port: 4000 }); 52 | console.log(`🚀 Server ready at http://localhost:4000${server.graphqlPath}`); 53 | }; 54 | 55 | startApolloServer(); 56 | -------------------------------------------------------------------------------- /backend/lambda/graphql/src/plugins/logging.ts: -------------------------------------------------------------------------------- 1 | // Reference: https://www.apollographql.com/docs/apollo-server/integrations/plugins/#end-hooks 2 | 3 | const excludedMutations = ['switchTimetable', 'uploadTimetable']; 4 | const excludedVariables = ['password', 'newPassword']; 5 | 6 | const getCommonLog = requestContext => { 7 | const query = requestContext.request.query; 8 | const variables = JSON.stringify( 9 | Object.fromEntries( 10 | Object.entries(requestContext.request.variables).filter( 11 | ([key, value]) => !excludedVariables.includes(key) 12 | ) 13 | ), 14 | null, 15 | 2 16 | ); 17 | const { user, ip } = requestContext.context; 18 | return `${user ? user.username : ip}\n${query}\n${variables}`; 19 | }; 20 | 21 | const logMutation = requestContext => { 22 | if ( 23 | !excludedMutations.includes( 24 | requestContext.operation.selectionSet.selections[0].name.value 25 | ) 26 | ) { 27 | console.log(getCommonLog(requestContext)); 28 | } 29 | }; 30 | 31 | const logError = (requestContext, type, err) => { 32 | console.error(`${getCommonLog(requestContext)}\n${type}\n${err}`); 33 | }; 34 | 35 | const loggingPlugin = { 36 | async requestDidStart(requestContext) { 37 | return { 38 | async parsingDidStart() { 39 | return async err => { 40 | if (err) { 41 | logError(requestContext, 'parsing', err); 42 | } 43 | }; 44 | }, 45 | async validationDidStart() { 46 | return async errs => { 47 | if (errs) { 48 | logError(requestContext, 'val', errs); 49 | } 50 | }; 51 | }, 52 | async executionDidStart() { 53 | if (requestContext.operation.operation === 'mutation') { 54 | logMutation(requestContext); 55 | } 56 | 57 | return { 58 | async executionDidEnd(err) { 59 | if (err) { 60 | logError(requestContext, 'exec', err); 61 | } 62 | }, 63 | }; 64 | }, 65 | }; 66 | }, 67 | }; 68 | 69 | export default loggingPlugin; 70 | -------------------------------------------------------------------------------- /backend/lambda/graphql/src/resolvers/course.ts: -------------------------------------------------------------------------------- 1 | import { getCourse as getCourseDataFromDB } from 'mongodb'; 2 | import NodeCache from 'node-cache'; 3 | 4 | import { Resolvers } from '../schemas/types'; 5 | import { getCourse as getCourseDataFromJSON } from '../utils/getCourse'; 6 | import withCache from '../utils/withCache'; 7 | 8 | const courseCache = new NodeCache({ stdTTL: 600 }); 9 | 10 | const coursesResolver: Resolvers = { 11 | Query: { 12 | course: async (parent, { filter }) => { 13 | const { requiredCourse, requiredTerm } = filter; 14 | return withCache( 15 | courseCache, 16 | `${requiredCourse}#${requiredTerm}`, 17 | async () => { 18 | const { lecturers, terms, rating } = 19 | (await getCourseDataFromDB(requiredCourse)) || {}; 20 | const courseData = getCourseDataFromJSON(requiredCourse); 21 | return { 22 | ...courseData, 23 | sections: requiredTerm ? courseData['terms'][requiredTerm] : null, 24 | reviewLecturers: lecturers, 25 | reviewTerms: terms, 26 | rating, 27 | }; 28 | } 29 | ); 30 | }, 31 | }, 32 | Course: { 33 | rating: async ({ rating }) => { 34 | if (!rating) { 35 | return null; 36 | } 37 | return { 38 | numReviews: rating.numReviews, 39 | overall: rating.overall / rating.numReviews, 40 | grading: rating.grading / rating.numReviews, 41 | content: rating.content / rating.numReviews, 42 | teaching: rating.teaching / rating.numReviews, 43 | difficulty: rating.difficulty / rating.numReviews, 44 | }; 45 | }, 46 | sections: ({ sections }) => { 47 | if (!sections) { 48 | return null; 49 | } 50 | const sectionsNames = Object.keys(sections); 51 | return sectionsNames.map(name => ({ 52 | name, 53 | ...sections[name], 54 | })); 55 | }, 56 | assessments: ({ assessments }) => { 57 | if (!assessments) { 58 | return null; 59 | } 60 | const assessmentsNames = Object.keys(assessments); 61 | return assessmentsNames.map(name => ({ 62 | name, 63 | percentage: assessments[name], 64 | })); 65 | }, 66 | }, 67 | }; 68 | 69 | export default coursesResolver; 70 | -------------------------------------------------------------------------------- /backend/lambda/graphql/src/resolvers/discussion.ts: -------------------------------------------------------------------------------- 1 | import { getDiscussion, sendDiscussionMessage } from 'mongodb'; 2 | 3 | import { Resolvers } from '../schemas/types'; 4 | 5 | const discussionResolver: Resolvers = { 6 | Mutation: { 7 | sendMessage: async (parent, { input }, { user }) => { 8 | const { username } = user; 9 | const id = await sendDiscussionMessage({ 10 | ...input, 11 | user: username, 12 | }); 13 | return { id }; 14 | }, 15 | }, 16 | Query: { 17 | discussion: async (parent, { input }) => getDiscussion(input), 18 | }, 19 | }; 20 | 21 | export default discussionResolver; 22 | -------------------------------------------------------------------------------- /backend/lambda/graphql/src/resolvers/index.ts: -------------------------------------------------------------------------------- 1 | import { mergeResolvers } from '@graphql-tools/merge'; 2 | import { resolvers as scalarResolvers } from 'graphql-scalars'; 3 | 4 | import coursesResolver from './course'; 5 | import discussionResolver from './discussion'; 6 | import rankingResolver from './ranking'; 7 | import reportResolver from './report'; 8 | import reviewsResolver from './review'; 9 | import timetableResolver from './timetable'; 10 | import userResolver from './user'; 11 | 12 | const resolvers = [ 13 | scalarResolvers, 14 | reviewsResolver, 15 | coursesResolver, 16 | userResolver, 17 | rankingResolver, 18 | timetableResolver, 19 | reportResolver, 20 | discussionResolver, 21 | ]; 22 | 23 | export default mergeResolvers(resolvers); 24 | -------------------------------------------------------------------------------- /backend/lambda/graphql/src/resolvers/ranking.ts: -------------------------------------------------------------------------------- 1 | import { getRanking } from 'mongodb'; 2 | import NodeCache from 'node-cache'; 3 | 4 | import { Resolvers } from '../schemas/types'; 5 | import { getCourse } from '../utils/getCourse'; 6 | import withCache from '../utils/withCache'; 7 | 8 | const rankingCache = new NodeCache({ stdTTL: 600 }); 9 | 10 | const rankingResolver: Resolvers = { 11 | Query: { 12 | ranking: () => ({}), 13 | }, 14 | RankTable: { 15 | rankedCourses: async (parent, { filter: { rankBy } }) => 16 | withCache(rankingCache, rankBy, async () => { 17 | const { ranks } = await getRanking(rankBy); 18 | return ranks?.map(({ _id, val }) => ({ 19 | courseId: _id, 20 | course: getCourse(_id), 21 | [rankBy]: val, 22 | })); 23 | }), 24 | }, 25 | }; 26 | 27 | export default rankingResolver; 28 | -------------------------------------------------------------------------------- /backend/lambda/graphql/src/resolvers/report.ts: -------------------------------------------------------------------------------- 1 | import { report } from 'mongodb'; 2 | 3 | import { Resolvers } from '../schemas/types'; 4 | 5 | const reportResolver: Resolvers = { 6 | Mutation: { 7 | report: async (parent, { input }, context) => { 8 | const reportId = await report({ 9 | ...input, 10 | username: context.user?.username, 11 | }); 12 | return reportId; 13 | }, 14 | }, 15 | }; 16 | 17 | export default reportResolver; 18 | -------------------------------------------------------------------------------- /backend/lambda/graphql/src/resolvers/review.ts: -------------------------------------------------------------------------------- 1 | import { VoteAction } from 'cutopia-types'; 2 | import { 3 | createReview, 4 | getReviews, 5 | getReview, 6 | editReview, 7 | voteReview, 8 | formatReviewId, 9 | } from 'mongodb'; 10 | import NodeCache from 'node-cache'; 11 | 12 | import { Resolvers } from '../schemas/types'; 13 | import withCache from '../utils/withCache'; 14 | 15 | const reviewCache = new NodeCache({ stdTTL: 600 }); 16 | 17 | const reviewsResolver: Resolvers = { 18 | Mutation: { 19 | createReview: async (parent, { input }, { user }) => { 20 | const { username } = user; 21 | const { createdAt } = await createReview({ 22 | ...input, 23 | username, 24 | }); 25 | return { createdAt }; 26 | }, 27 | voteReview: async (parent, { input }, { user }) => { 28 | const { username } = user; 29 | await voteReview({ 30 | ...input, 31 | username, 32 | }); 33 | }, 34 | editReview: async (parent, { input }, { user }) => { 35 | const { username } = user; 36 | await editReview({ 37 | ...input, 38 | username, 39 | }); 40 | }, 41 | }, 42 | Review: { 43 | username: ({ username, anonymous }) => (anonymous ? 'Anonymous' : username), 44 | myVote: ({ upvoteUserIds, downvoteUserIds }, args, { user }) => { 45 | if (user) { 46 | const { username } = user; 47 | if (upvoteUserIds.includes(username)) { 48 | return VoteAction.UPVOTE; 49 | } 50 | if (downvoteUserIds.includes(username)) { 51 | return VoteAction.DOWNVOTE; 52 | } 53 | } 54 | return null; 55 | }, 56 | }, 57 | Query: { 58 | reviews: async (parent, { input }) => { 59 | const { courseId, page } = input; 60 | return withCache( 61 | reviewCache, 62 | courseId ? JSON.stringify(input) : `latest#${page || 0}`, 63 | async () => getReviews(input) 64 | ); 65 | }, 66 | review: async (parent, { input }) => { 67 | const { courseId, createdAt } = input; 68 | return withCache( 69 | reviewCache, 70 | formatReviewId(courseId, createdAt), 71 | async () => getReview(input) 72 | ); 73 | }, 74 | }, 75 | ReviewDetails: {}, 76 | }; 77 | 78 | export default reviewsResolver; 79 | -------------------------------------------------------------------------------- /backend/lambda/graphql/src/resolvers/timetable.ts: -------------------------------------------------------------------------------- 1 | import { 2 | uploadTimetable, 3 | removeTimetable, 4 | getTimetable, 5 | getTimetablesOverview, 6 | switchTimetable, 7 | cloneTimetable, 8 | } from 'mongodb'; 9 | 10 | import { Resolvers } from '../schemas/types'; 11 | 12 | const timetableResolver: Resolvers = { 13 | User: { 14 | timetables: async (parent, args, { user }) => { 15 | const { username } = user; 16 | return await getTimetablesOverview({ username }); 17 | }, 18 | }, 19 | Query: { 20 | timetable: async (parent, { _id }, { user }) => { 21 | const { username } = user; 22 | return await getTimetable({ _id, username }); 23 | }, 24 | }, 25 | Mutation: { 26 | uploadTimetable: async (parent, { input }, { user }) => { 27 | const { username } = user; 28 | return await uploadTimetable({ ...input, username }); 29 | }, 30 | removeTimetable: async (parent, { input }, { user }) => { 31 | const { username } = user; 32 | return await removeTimetable({ ...input, username }); 33 | }, 34 | switchTimetable: async (parent, { input }, { user }) => { 35 | const { username } = user; 36 | return await switchTimetable({ ...input, username }); 37 | }, 38 | cloneTimetable: async (parent, { input }, { user }) => { 39 | const { username } = user; 40 | return await cloneTimetable({ ...input, username }); 41 | }, 42 | }, 43 | }; 44 | 45 | export default timetableResolver; 46 | -------------------------------------------------------------------------------- /backend/lambda/graphql/src/resolvers/user.ts: -------------------------------------------------------------------------------- 1 | import AWS from 'aws-sdk'; 2 | import { 3 | createUser, 4 | verifyUser, 5 | getUser, 6 | getResetPasswordCodeAndEmail, 7 | resetPassword, 8 | login, 9 | } from 'mongodb'; 10 | 11 | import { sign } from '../jwt'; 12 | import { Resolvers } from '../schemas/types'; 13 | 14 | const SNS = new AWS.SNS({ apiVersion: '2010-03-31' }); 15 | 16 | const sendEmail = async message => { 17 | if (process.env.AWS_EXECUTION_ENV) { 18 | const params = { 19 | TopicArn: process.env.UserSNSTopic, 20 | Message: JSON.stringify(message), 21 | }; 22 | await SNS.publish(params).promise(); 23 | } else { 24 | console.log('Email content:', message); 25 | } 26 | }; 27 | 28 | const userResolver: Resolvers = { 29 | Query: { 30 | me: async (parent, args, { user }) => { 31 | const { username } = user; 32 | return await getUser(username); 33 | }, 34 | }, 35 | Mutation: { 36 | createUser: async (parent, { input }) => { 37 | const { username, SID } = input; 38 | const veriCode = await createUser(input); 39 | await sendEmail({ 40 | action: 'create', 41 | code: veriCode, 42 | username, 43 | SID, 44 | }); 45 | }, 46 | verifyUser: async (parent, { input }) => verifyUser(input), 47 | login: async (parent, { input }) => { 48 | const user = await login(input); 49 | const token = sign({ username: user.username, password: user.password }); 50 | return { 51 | token, 52 | me: user, 53 | }; 54 | }, 55 | sendResetPasswordCode: async (parent, { input }) => { 56 | const { resetPwdCode, SID } = await getResetPasswordCodeAndEmail(input); 57 | await sendEmail({ 58 | action: 'resetPwd', 59 | code: resetPwdCode, 60 | userId: input.userId, 61 | SID, 62 | }); 63 | }, 64 | resetPassword: async (parent, { input }) => resetPassword(input), 65 | }, 66 | }; 67 | 68 | export default userResolver; 69 | -------------------------------------------------------------------------------- /backend/lambda/graphql/src/scalars/courseID.ts: -------------------------------------------------------------------------------- 1 | import { ErrorCode } from 'cutopia-types'; 2 | import { GraphQLScalarType } from 'graphql'; 3 | 4 | import courseIds from '../data/derived/subject_course_names.json'; 5 | 6 | const validateCourseId = (courseId: string) => { 7 | const subject = courseId.slice(0, 4); 8 | const course = courseId.slice(4, 8); 9 | return courseIds[subject]?.includes(course); 10 | }; 11 | 12 | export default new GraphQLScalarType({ 13 | name: 'CourseID', 14 | description: 'Validate course ID', 15 | parseValue: (value: string) => { 16 | if (!validateCourseId(value)) { 17 | throw Error(ErrorCode.INVALID_COURSE_ID.toString()); 18 | } 19 | return value.slice(0, 8); 20 | }, 21 | }); 22 | -------------------------------------------------------------------------------- /backend/lambda/graphql/src/scalars/index.ts: -------------------------------------------------------------------------------- 1 | import courseIDResolver from './courseID'; 2 | 3 | const scalarResolvers = { 4 | CourseID: courseIDResolver, 5 | }; 6 | 7 | export default scalarResolvers; 8 | -------------------------------------------------------------------------------- /backend/lambda/graphql/src/schemas/build.ts: -------------------------------------------------------------------------------- 1 | import { writeFileSync } from 'fs'; 2 | import { join } from 'path'; 3 | 4 | import { loadFilesSync } from '@graphql-tools/load-files'; 5 | import { mergeTypeDefs } from '@graphql-tools/merge'; 6 | import { print } from 'graphql'; 7 | import { constraintDirectiveTypeDefs } from 'graphql-constraint-directive'; 8 | import { typeDefs as scalarTypeDefs } from 'graphql-scalars'; 9 | 10 | const graphqlFiles = [ 11 | 'courses.graphql', 12 | 'discussion.graphql', 13 | 'ranking.graphql', 14 | 'report.graphql', 15 | 'reviews.graphql', 16 | 'scalars.graphql', 17 | 'timetable.graphql', 18 | 'user.graphql', 19 | ].map(relativePath => join(__dirname, relativePath)); 20 | 21 | const typesArray = loadFilesSync(graphqlFiles); 22 | 23 | const types = mergeTypeDefs([ 24 | scalarTypeDefs, 25 | ...typesArray, 26 | [constraintDirectiveTypeDefs], 27 | ]); 28 | 29 | writeFileSync(join(__dirname, 'bundle.graphql'), print(types)); 30 | -------------------------------------------------------------------------------- /backend/lambda/graphql/src/schemas/courses.graphql: -------------------------------------------------------------------------------- 1 | type Query { 2 | course(filter: CourseFilter): Course! @auth @rateLimit(duration: 60, limit: 100) 3 | } 4 | 5 | input CourseFilter { 6 | requiredCourse: CourseID! 7 | requiredTerm: String 8 | } 9 | 10 | type Course { 11 | courseId: CourseID! 12 | title: String 13 | reviewLecturers: [String] 14 | reviewTerms: [String] 15 | career: String 16 | units: String 17 | grading: String 18 | components: String 19 | campus: String 20 | academic_group: String 21 | requirements: String 22 | description: String 23 | outcome: String 24 | syllabus: String 25 | required_readings: String 26 | recommended_readings: String 27 | sections: [CourseSection] 28 | assessments: [AssessementComponent] 29 | rating: CourseRating 30 | } 31 | 32 | type CourseRating { 33 | numReviews: Int! 34 | overall: Float! 35 | grading: Float! 36 | content: Float! 37 | difficulty: Float! 38 | teaching: Float! 39 | } 40 | 41 | type CourseSection { 42 | name: String! 43 | startTimes: [String]! 44 | endTimes: [String]! 45 | days: [String]! 46 | locations: [String]! 47 | instructors: [String]! 48 | hide: Boolean 49 | meetingDates: [String] 50 | } 51 | 52 | type AssessementComponent { 53 | name: String! 54 | percentage: String! 55 | } 56 | -------------------------------------------------------------------------------- /backend/lambda/graphql/src/schemas/discussion.graphql: -------------------------------------------------------------------------------- 1 | type Mutation { 2 | sendMessage(input: SendMessageInput!): SendMessageResult @auth @rateLimit(duration: 1, limit: 5) 3 | } 4 | 5 | type Query { 6 | discussion(input: DiscussionFilter!): DiscussionResult! @auth 7 | } 8 | 9 | input SendMessageInput { 10 | courseId: CourseID! 11 | text: String! 12 | } 13 | 14 | type SendMessageResult { 15 | id: Long! 16 | } 17 | 18 | input DiscussionFilter { 19 | courseId: CourseID! 20 | page: Int 21 | } 22 | 23 | type DiscussionMessage { 24 | id: Long! 25 | text: String! 26 | user: String! 27 | } 28 | 29 | type DiscussionResult { 30 | messages: [DiscussionMessage] 31 | nextPage: Int 32 | } 33 | 34 | type Discussion { 35 | id: String! 36 | messages: [DiscussionMessage] 37 | } 38 | -------------------------------------------------------------------------------- /backend/lambda/graphql/src/schemas/index.ts: -------------------------------------------------------------------------------- 1 | import { join } from 'path'; 2 | 3 | import { loadFilesSync } from '@graphql-tools/load-files'; 4 | 5 | const types = loadFilesSync(join(__dirname, 'bundle.graphql')); 6 | 7 | export default types; 8 | -------------------------------------------------------------------------------- /backend/lambda/graphql/src/schemas/ranking.graphql: -------------------------------------------------------------------------------- 1 | type Query { 2 | ranking: RankTable 3 | } 4 | 5 | type RankTable { 6 | rankedCourses(filter: RankingFilter!): [RankedCourse] @auth @rateLimit(duration: 60, limit: 100) 7 | } 8 | 9 | input RankingFilter { 10 | rankBy: String! # numReviews, grading, content, difficulty, teaching, overall 11 | } 12 | 13 | type RankedCourse { 14 | courseId: CourseID! 15 | course: Course! 16 | numReviews: Int 17 | overall: Float 18 | grading: Float 19 | content: Float 20 | difficulty: Float 21 | teaching: Float 22 | } 23 | -------------------------------------------------------------------------------- /backend/lambda/graphql/src/schemas/report.graphql: -------------------------------------------------------------------------------- 1 | type Mutation { 2 | report(input: ReportInput!): String! @rateLimit(duration: 60, limit: 10) 3 | } 4 | 5 | input ReportInput { 6 | cat: Int! 7 | "review id or course id" 8 | identifier: String 9 | types: [Int]! 10 | description: String! @constraint(maxLength: 10000) 11 | } 12 | -------------------------------------------------------------------------------- /backend/lambda/graphql/src/schemas/scalars.graphql: -------------------------------------------------------------------------------- 1 | scalar CourseID 2 | -------------------------------------------------------------------------------- /backend/lambda/graphql/src/schemas/timetable.graphql: -------------------------------------------------------------------------------- 1 | type Mutation { 2 | uploadTimetable(input: UploadTimetableInput!): UploadTimetableResult @auth @rateLimit(duration: 60, limit: 30) 3 | removeTimetable(input: RemoveTimetableInput!): Timetable @auth @rateLimit(duration: 1, limit: 3) 4 | switchTimetable(input: SwitchTimetableInput!): Timetable @auth @rateLimit(duration: 1, limit: 3) 5 | cloneTimetable(input: CloneTimetableInput!): Timetable @auth @rateLimit(duration: 1, limit: 3) 6 | } 7 | 8 | type Query { 9 | timetable(_id: String!): Timetable @auth @rateLimit(duration: 1, limit: 5) 10 | } 11 | 12 | type User { 13 | timetableId: String 14 | timetables: [TimetableOverview] 15 | } 16 | 17 | type CourseTableEntry { 18 | courseId: CourseID! 19 | title: String! 20 | credits: Int! 21 | sections: [CourseSection] 22 | } 23 | 24 | input CourseTableEntryInput { 25 | courseId: CourseID! 26 | title: String! 27 | credits: Int! 28 | sections: [CourseSectionInput] 29 | } 30 | 31 | input CourseSectionInput { 32 | name: String! 33 | startTimes: [String]! 34 | endTimes: [String]! 35 | days: [String]! 36 | locations: [String]! 37 | instructors: [String]! 38 | hide: Boolean 39 | meetingDates: [String] 40 | } 41 | 42 | input UploadTimetableInput { 43 | _id: String 44 | entries: [CourseTableEntryInput] 45 | tableName: String 46 | expire: Int 47 | } 48 | 49 | type UploadTimetableResult { 50 | _id: String 51 | createdAt: Timestamp 52 | } 53 | 54 | input RemoveTimetableInput { 55 | _id: String! 56 | switchTo: String 57 | } 58 | 59 | type Timetable { 60 | _id: String! 61 | entries: [CourseTableEntry]! 62 | tableName: String 63 | createdAt: Timestamp! 64 | expireAt: Timestamp! 65 | expire: Int! 66 | } 67 | 68 | type TimetableOverview { 69 | _id: String! 70 | tableName: String 71 | createdAt: Timestamp! 72 | expireAt: Timestamp! 73 | expire: Int! 74 | } 75 | 76 | input SwitchTimetableInput { 77 | _id: String! 78 | } 79 | 80 | input CloneTimetableInput { 81 | _id: String! 82 | } 83 | -------------------------------------------------------------------------------- /backend/lambda/graphql/src/schemas/user.graphql: -------------------------------------------------------------------------------- 1 | type Query { 2 | me: User! @auth 3 | } 4 | 5 | type User { 6 | username: String! 7 | verified: Boolean! 8 | reviewIds: [String]! 9 | discussions: [String] 10 | upvotes: Int! 11 | exp: Int! 12 | fullAccess: Boolean! 13 | } 14 | 15 | type Mutation { 16 | createUser(input: CreateUserInput!): Void 17 | verifyUser(input: VerifyUserInput!): Void @rateLimit(duration: 60, limit: 10) 18 | login(input: LoginInput!): LoginResult! @rateLimit(duration: 60, limit: 20) 19 | sendResetPasswordCode(input: SendResetPasswordCodeInput!): Void @rateLimit(duration: 60, limit: 10) 20 | resetPassword(input: ResetPasswordInput!): Void @rateLimit(duration: 60, limit: 10) 21 | } 22 | 23 | input CreateUserInput { 24 | username: String! 25 | SID: String! 26 | password: String! 27 | } 28 | 29 | input VerifyUserInput { 30 | username: String! 31 | code: String! 32 | } 33 | 34 | input LoginInput { 35 | userId: String! 36 | password: String! 37 | } 38 | 39 | type LoginResult { 40 | token: String 41 | me: User 42 | } 43 | 44 | input SendResetPasswordCodeInput { 45 | userId: String! 46 | } 47 | 48 | input ResetPasswordInput { 49 | userId: String! 50 | newPassword: String! 51 | resetCode: String! 52 | } 53 | -------------------------------------------------------------------------------- /backend/lambda/graphql/src/utils/getCourse.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | 3 | import { Course } from 'cutopia-types'; 4 | 5 | const courses: Record = {}; 6 | const courseFolder = `${__dirname}/../data/courses`; 7 | 8 | const getCourse = (courseId: string): Course => { 9 | if (!(courseId in courses)) { 10 | const subjectName = courseId.slice(0, 4); 11 | const courseList: Course[] = JSON.parse( 12 | fs.readFileSync(`${courseFolder}/${subjectName}.json`).toString() 13 | ); 14 | if (courseList.length !== 0) { 15 | courseList.forEach(course => { 16 | const courseId = `${subjectName}${course.code}`; 17 | courses[courseId] = { 18 | ...course, 19 | courseId, 20 | }; 21 | }); 22 | } 23 | } 24 | return courses[courseId]; 25 | }; 26 | 27 | export { getCourse }; 28 | -------------------------------------------------------------------------------- /backend/lambda/graphql/src/utils/withCache.ts: -------------------------------------------------------------------------------- 1 | import NodeCache from 'node-cache'; 2 | 3 | const withCache = async ( 4 | cache: NodeCache, 5 | cacheKey: string, 6 | callback: () => any 7 | ) => { 8 | if (!cacheKey) { 9 | return; 10 | } 11 | const cachedData = JSON.parse(cache.get(cacheKey) || 'null'); 12 | if (cachedData) { 13 | // console.log(`Cached: ${JSON.stringify(cachedData)}`); 14 | return cachedData; 15 | } 16 | const fetchedData = await callback(); 17 | // console.log(`Fetched: ${JSON.stringify(fetchedData)}`); 18 | if (fetchedData) { 19 | cache.set(cacheKey, JSON.stringify(fetchedData)); 20 | } 21 | return fetchedData; 22 | }; 23 | 24 | export default withCache; 25 | -------------------------------------------------------------------------------- /backend/lambda/graphql/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "ts-node": { 3 | // It is faster to skip typechecking. 4 | // Remove if you want ts-node to do typechecking. 5 | "transpileOnly": true, 6 | 7 | "files": true, 8 | 9 | "compilerOptions": { 10 | // compilerOptions specified here will override those declared below, 11 | // but *only* in ts-node. Useful if you want ts-node and tsc to use 12 | // different options with a single tsconfig.json. 13 | } 14 | }, 15 | "compilerOptions": { 16 | "target": "ES6", 17 | "module": "commonjs", 18 | "esModuleInterop": true, 19 | "moduleResolution": "node", 20 | "lib": ["ES2020"], 21 | "sourceMap": true, 22 | "outDir": "build", 23 | "baseUrl": ".", 24 | "skipDefaultLibCheck": true, 25 | "noEmitOnError": true, 26 | "strict": false, 27 | "paths": { 28 | "*": [ 29 | "node_modules/*" 30 | ] 31 | }, 32 | "skipLibCheck": true, 33 | "resolveJsonModule": true 34 | }, 35 | "include": ["src/**/*"], 36 | "exclude": [ 37 | "node_modules", 38 | "src/**/*.test.ts", 39 | "src/schemas/build.ts", 40 | "src/schemas/types.ts", 41 | ] 42 | } -------------------------------------------------------------------------------- /backend/mongodb/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mongodb", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "scripts": { 6 | "build": "rm -rf lib && tsc && yarn --modules-folder lib/node_modules --prod install" 7 | }, 8 | "exports": { 9 | ".": "./lib" 10 | }, 11 | "license": "MIT", 12 | "dependencies": { 13 | "bcryptjs": "^2.4.3", 14 | "cutopia-types": "file:../../types/lib", 15 | "dotenv": "^16.0.1", 16 | "mongodb": "^4.6.0", 17 | "mongoose": "^6.3.4", 18 | "nanoid": "^3.3.4" 19 | }, 20 | "devDependencies": { 21 | "@types/bcryptjs": "^2.4.2", 22 | "@types/dotenv": "^8.2.0", 23 | "@types/jest": "^27.5.1", 24 | "jest": "^28.1.0", 25 | "ts-jest": "^28.0.3", 26 | "typescript": "^4.7.2" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /backend/mongodb/src/constants/config.ts: -------------------------------------------------------------------------------- 1 | // User configs 2 | export const SALT_ROUNDS = 10; 3 | export const VERIFY_EXPIRATION_TIME = 24 * 60 * 60 * 1000; 4 | 5 | // Discussion configs 6 | export const MESSAGES_PER_PAGE = 10; 7 | 8 | // Review configs 9 | export const REVIEWS_PER_PAGE = 10; 10 | 11 | // Ranking configs 12 | export const RANK_LIMIT = 10; 13 | 14 | // Timetable configs 15 | export const UPLOAD_TIMETABLE_ENTRY_LIMIT = 20; 16 | export const UPLOAD_TIMETABLE_TOTAL_LIMIT = 20; 17 | -------------------------------------------------------------------------------- /backend/mongodb/src/constants/schema.ts: -------------------------------------------------------------------------------- 1 | export const requiredNumber = { 2 | type: Number, 3 | required: true, 4 | }; 5 | 6 | export const requiredString = { 7 | type: String, 8 | required: true, 9 | }; 10 | 11 | export const createdAt = { 12 | type: Number, 13 | default: () => +new Date(), 14 | }; 15 | -------------------------------------------------------------------------------- /backend/mongodb/src/controllers/course.ts: -------------------------------------------------------------------------------- 1 | import { Review } from 'cutopia-types'; 2 | 3 | import Course from '../models/course'; 4 | 5 | export const getCourse = async courseId => Course.findById(courseId); 6 | 7 | export const updateCourseData = async ( 8 | courseId: string, 9 | review: Review, 10 | oldReview?: Review // update course data from existing review if any 11 | ) => { 12 | const ratingInc = { 13 | 'rating.numReviews': 1, 14 | 'rating.overall': review.overall, 15 | 'rating.grading': review.grading.grade, 16 | 'rating.content': review.content.grade, 17 | 'rating.difficulty': review.difficulty.grade, 18 | 'rating.teaching': review.teaching.grade, 19 | }; 20 | if (oldReview) { 21 | const critierions = ['grading', 'content', 'difficulty', 'teaching']; 22 | critierions.forEach( 23 | critierion => 24 | (ratingInc[`rating.${critierion}`] -= oldReview[critierion].grade) 25 | ); 26 | ratingInc['rating.numReviews'] = 0; 27 | ratingInc['rating.overall'] -= oldReview.overall; 28 | } 29 | 30 | return await Course.findByIdAndUpdate( 31 | courseId, 32 | { 33 | $addToSet: { 34 | lecturers: review.lecturer, 35 | terms: review.term, 36 | }, 37 | $inc: ratingInc, 38 | }, 39 | { 40 | new: true, 41 | upsert: true, 42 | } 43 | ); 44 | }; 45 | -------------------------------------------------------------------------------- /backend/mongodb/src/controllers/discussion.ts: -------------------------------------------------------------------------------- 1 | import { MESSAGES_PER_PAGE } from '../constants/config'; 2 | import DiscussionModel from '../models/discussion'; 3 | 4 | type SendDiscussionMessageProps = { 5 | courseId: string; 6 | text: string; 7 | user: string; 8 | }; 9 | 10 | export const sendDiscussionMessage = async ( 11 | input: SendDiscussionMessageProps 12 | ) => { 13 | const { courseId, ...messageBody } = input; 14 | const messageId = +new Date(); 15 | await DiscussionModel.findByIdAndUpdate( 16 | courseId, 17 | { 18 | $push: { 19 | messages: { 20 | ...messageBody, 21 | _id: messageId, 22 | }, 23 | }, 24 | $inc: { 25 | numMessages: 1, 26 | }, 27 | }, 28 | { 29 | new: true, 30 | upsert: true, 31 | } 32 | ); 33 | return messageId; 34 | }; 35 | 36 | type GetDiscussionProps = { 37 | courseId: string; 38 | page?: number; 39 | }; 40 | 41 | export const getDiscussion = async ({ courseId, page }: GetDiscussionProps) => { 42 | page = page || 0; 43 | const discussion = await DiscussionModel.findById(courseId, { 44 | messages: { 45 | $slice: ['$messages', -(++page * MESSAGES_PER_PAGE), MESSAGES_PER_PAGE], 46 | }, 47 | numMessages: '$numMessages', 48 | }).lean(); 49 | return { 50 | messages: discussion?.messages?.map(message => ({ 51 | ...message, 52 | id: message._id, 53 | })), 54 | nextPage: 55 | page * MESSAGES_PER_PAGE >= (discussion?.numMessages || 0) ? null : page, 56 | }; 57 | }; 58 | -------------------------------------------------------------------------------- /backend/mongodb/src/controllers/email.ts: -------------------------------------------------------------------------------- 1 | import { Email as EmailType } from 'cutopia-types'; 2 | 3 | import Email from '../models/email'; 4 | 5 | export const addToResendList = async (input: EmailType) => { 6 | const { action, SID } = input; 7 | const exists = await Email.exists({ action, SID }); 8 | if (!exists) { 9 | const email = new Email(input); 10 | await email.save(); 11 | } 12 | }; 13 | 14 | export const getResendList = async filter => Email.find(filter).exec(); 15 | 16 | export const removeFromResendList = async filter => 17 | Email.findOneAndDelete(filter).exec(); 18 | -------------------------------------------------------------------------------- /backend/mongodb/src/controllers/index.ts: -------------------------------------------------------------------------------- 1 | export * from './course'; 2 | export * from './discussion'; 3 | export * from './email'; 4 | export * from './ranking'; 5 | export * from './report'; 6 | export * from './review'; 7 | export * from './timetable'; 8 | export * from './user'; 9 | -------------------------------------------------------------------------------- /backend/mongodb/src/controllers/ranking.ts: -------------------------------------------------------------------------------- 1 | import { RANK_LIMIT } from '../constants/config'; 2 | import Course from '../models/course'; 3 | import Ranking from '../models/ranking'; 4 | 5 | const rankField = (field: string) => [ 6 | { 7 | $project: { 8 | val: 9 | field === 'numReviews' 10 | ? '$rating.numReviews' 11 | : { 12 | $divide: [`$rating.${field}`, '$rating.numReviews'], 13 | }, 14 | }, 15 | }, 16 | { 17 | $sort: { 18 | val: -1, 19 | }, 20 | }, 21 | { 22 | $limit: RANK_LIMIT, 23 | }, 24 | ]; 25 | 26 | export const rankCourses = async () => { 27 | const result = await Course.aggregate([ 28 | { 29 | $facet: { 30 | numReviews: rankField('numReviews'), 31 | grading: rankField('grading'), 32 | content: rankField('content'), 33 | difficulty: rankField('difficulty'), 34 | teaching: rankField('teaching'), 35 | overall: rankField('overall'), 36 | } as any, 37 | }, 38 | // $facet does not support $merge in nested pipeline and seems 39 | // splitting one document into multiples with different fields is not feasible ($unwind does not help) 40 | // so we have to separate the write operations from aggregate 41 | ]).exec(); 42 | const bulkOperations = Object.keys(result[0]).map(field => ({ 43 | updateOne: { 44 | filter: { _id: field }, 45 | update: { 46 | _id: field, 47 | ranks: result[0][field], 48 | }, 49 | upsert: true, 50 | }, 51 | })); 52 | await Ranking.bulkWrite(bulkOperations); 53 | }; 54 | 55 | export const getRanking = async (field: string) => Ranking.findById(field); 56 | -------------------------------------------------------------------------------- /backend/mongodb/src/controllers/report.ts: -------------------------------------------------------------------------------- 1 | import Report from '../models/report'; 2 | 3 | export const report = async input => { 4 | const newReport = new Report(input); 5 | await newReport.save(); 6 | return newReport._id; 7 | }; 8 | -------------------------------------------------------------------------------- /backend/mongodb/src/index.ts: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose'; 2 | 3 | export * from './controllers'; 4 | 5 | require('dotenv').config(); 6 | 7 | export const connect = async uri => { 8 | if (mongoose.connection.readyState === mongoose.STATES.disconnected) { 9 | console.log(`Try connecting to ${uri}`); 10 | await mongoose.connect(uri); 11 | console.log('Connected to MongoDB successfully'); 12 | } 13 | }; 14 | 15 | export const disconnect = async () => { 16 | await mongoose.disconnect(); 17 | console.log('Disconnected from MongoDB'); 18 | }; 19 | -------------------------------------------------------------------------------- /backend/mongodb/src/jest/discussion.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, beforeAll, afterAll, it } from '@jest/globals'; 2 | 3 | import { 4 | getDiscussion, 5 | sendDiscussionMessage, 6 | } from '../controllers/discussion'; 7 | import DiscussionModel from '../models/discussion'; 8 | 9 | import { setup, teardown } from './env'; 10 | 11 | describe('Discussion', () => { 12 | beforeAll(async () => { 13 | await setup(); 14 | // Empty the discussion documents 15 | await DiscussionModel.deleteMany({}); 16 | }); 17 | 18 | afterAll(teardown); 19 | 20 | it('Send and Get Discussion', async () => { 21 | const id = await sendDiscussionMessage({ 22 | courseId: 'ABCD1234', 23 | text: 'Leng grade course', 24 | user: 'Someone', 25 | }); 26 | 27 | const discussion = await getDiscussion({ 28 | courseId: 'ABCD1234', 29 | page: 0, 30 | }); 31 | expect(discussion).toMatchObject({ 32 | messages: [ 33 | { 34 | id, 35 | text: 'Leng grade course', 36 | user: 'Someone', 37 | }, 38 | ], 39 | nextPage: null, 40 | }); 41 | }); 42 | }); 43 | -------------------------------------------------------------------------------- /backend/mongodb/src/jest/env.ts: -------------------------------------------------------------------------------- 1 | import bcrypt from 'bcryptjs'; 2 | import dotenv from 'dotenv'; 3 | import mongoose from 'mongoose'; 4 | import { nanoid } from 'nanoid'; 5 | 6 | import { connect } from '../'; 7 | import { SALT_ROUNDS } from '../constants/config'; 8 | import User from '../models/user'; 9 | 10 | dotenv.config({ path: 'backend/.env' }); 11 | 12 | export const createTestUser = async () => { 13 | const now = +new Date(); 14 | const hash = await bcrypt.hash(nanoid(10), SALT_ROUNDS); 15 | const user = new User({ 16 | username: nanoid(5), 17 | SID: Math.floor(1000000000 + Math.random() * 9000000000).toString(), 18 | password: hash, 19 | createdAt: now, 20 | verified: true, 21 | }); 22 | await user.save(); 23 | return user; 24 | }; 25 | 26 | export const deleteTestUser = async ({ username, SID }) => 27 | User.findOneAndDelete({ 28 | username, 29 | SID, 30 | }); 31 | 32 | export const setup = async () => { 33 | if (process.env.ATLAS_JEST_URI.includes('production')) { 34 | throw Error( 35 | "Please make sure that ATLAS_JEST_URI does not contain 'production'." 36 | ); 37 | } 38 | await connect(process.env.ATLAS_JEST_URI); 39 | }; 40 | 41 | export const teardown = async () => await mongoose.connection.close(); 42 | -------------------------------------------------------------------------------- /backend/mongodb/src/jest/ranking.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, expect, beforeAll, afterAll, it } from '@jest/globals'; 2 | 3 | import { getRanking, rankCourses } from '../controllers/ranking'; 4 | import { createReview } from '../controllers/review'; 5 | import CourseModel from '../models/course'; 6 | import RankingModel from '../models/ranking'; 7 | 8 | import { createTestUser, deleteTestUser, setup, teardown } from './env'; 9 | 10 | describe('Ranking', () => { 11 | let testUser, testUser2; 12 | 13 | beforeAll(async () => { 14 | await setup(); 15 | // Empty the ranking and course documents 16 | await RankingModel.deleteMany({}); 17 | await CourseModel.deleteMany({}); 18 | testUser = await createTestUser(); 19 | testUser2 = await createTestUser(); 20 | }); 21 | 22 | afterAll(async () => { 23 | await deleteTestUser(testUser); 24 | await deleteTestUser(testUser2); 25 | await teardown(); 26 | }); 27 | 28 | it('Create review and update ranking', async () => { 29 | const review = { 30 | username: testUser.username, 31 | courseId: 'ABCD1234', 32 | anonymous: false, 33 | lecturer: 'Someone', 34 | term: '1', 35 | overall: 4, 36 | grading: { 37 | grade: 3, 38 | text: 'Some text', 39 | }, 40 | teaching: { 41 | grade: 2, 42 | text: 'Some text', 43 | }, 44 | difficulty: { 45 | grade: 1, 46 | text: 'Some text', 47 | }, 48 | content: { 49 | grade: 0, 50 | text: 'Some text', 51 | }, 52 | }; 53 | const review2 = { 54 | ...review, 55 | username: testUser2.username, 56 | grading: { 57 | grade: 4, 58 | text: 'Some text', 59 | }, 60 | }; 61 | await createReview(review); 62 | await createReview(review2); 63 | 64 | await rankCourses(); 65 | expect(getRanking('numReviews')).resolves.toMatchObject({ 66 | _id: 'numReviews', 67 | ranks: [{ _id: 'ABCD1234', val: 2 }], 68 | }); 69 | expect(getRanking('grading')).resolves.toMatchObject({ 70 | _id: 'grading', 71 | ranks: [{ _id: 'ABCD1234', val: 3.5 }], 72 | }); 73 | }); 74 | }); 75 | -------------------------------------------------------------------------------- /backend/mongodb/src/jest/report.test.ts: -------------------------------------------------------------------------------- 1 | import { describe, beforeAll, afterAll, it } from '@jest/globals'; 2 | import { CourseReportType, ReportCategory } from 'cutopia-types'; 3 | 4 | import { report } from '../controllers/report'; 5 | import ReportModel from '../models/report'; 6 | 7 | import { setup, teardown } from './env'; 8 | 9 | describe('Report', () => { 10 | beforeAll(async () => { 11 | await setup(); 12 | // Empty the report documents 13 | await ReportModel.deleteMany({}); 14 | }); 15 | 16 | afterAll(teardown); 17 | 18 | it('Create report', async () => { 19 | const reportInput = { 20 | description: 'The course title and assessments are incorrect', 21 | identifier: 'ABCD1234', 22 | cat: ReportCategory.COURSE, 23 | types: [CourseReportType.COURSE_TITLE, CourseReportType.ASSESSMENTS], 24 | }; 25 | const reportId = await report(reportInput); 26 | expect(ReportModel.findById(reportId)).resolves.toMatchObject(reportInput); 27 | }); 28 | }); 29 | -------------------------------------------------------------------------------- /backend/mongodb/src/models/course.ts: -------------------------------------------------------------------------------- 1 | import { CourseDocument } from 'cutopia-types'; 2 | import { Schema, model } from 'mongoose'; 3 | 4 | import { requiredNumber, requiredString } from '../constants/schema'; 5 | 6 | const courseRating = { 7 | type: Number, 8 | required: true, 9 | default: 0, 10 | min: 0, 11 | }; 12 | 13 | const courseSchema = new Schema( 14 | { 15 | _id: requiredString, 16 | lecturers: { 17 | type: [String], 18 | required: true, 19 | }, 20 | terms: { 21 | type: [String], 22 | required: true, 23 | }, 24 | rating: { 25 | numReviews: requiredNumber, 26 | overall: courseRating, 27 | grading: courseRating, 28 | content: courseRating, 29 | difficulty: courseRating, 30 | teaching: courseRating, 31 | }, 32 | }, 33 | { 34 | timestamps: false, 35 | versionKey: false, 36 | _id: false, 37 | } 38 | ); 39 | 40 | const CourseModal = model('Course', courseSchema); 41 | 42 | export default CourseModal; 43 | -------------------------------------------------------------------------------- /backend/mongodb/src/models/discussion.ts: -------------------------------------------------------------------------------- 1 | import { Discussion } from 'cutopia-types'; 2 | import { model, Schema } from 'mongoose'; 3 | 4 | import { createdAt, requiredString } from '../constants/schema'; 5 | 6 | const discussionMessage = { 7 | _id: createdAt, 8 | text: requiredString, 9 | user: requiredString, 10 | }; 11 | 12 | const discussionSchema = new Schema( 13 | { 14 | _id: requiredString, 15 | messages: { 16 | type: [discussionMessage], 17 | required: true, 18 | }, 19 | numMessages: { type: Number, default: 0 }, 20 | }, 21 | { 22 | _id: false, 23 | versionKey: false, 24 | } 25 | ); 26 | 27 | const DiscussionModel = model('Discussion', discussionSchema); 28 | 29 | export default DiscussionModel; 30 | -------------------------------------------------------------------------------- /backend/mongodb/src/models/email.ts: -------------------------------------------------------------------------------- 1 | import { Email } from 'cutopia-types'; 2 | import { Schema, model } from 'mongoose'; 3 | 4 | import { requiredString } from '../constants/schema'; 5 | 6 | const emailSchema = new Schema( 7 | { 8 | action: requiredString, 9 | username: requiredString, 10 | SID: requiredString, 11 | code: String, 12 | }, 13 | { 14 | timestamps: false, 15 | versionKey: false, 16 | } 17 | ); 18 | 19 | const EmailModel = model('Email', emailSchema); 20 | 21 | export default EmailModel; 22 | -------------------------------------------------------------------------------- /backend/mongodb/src/models/ranking.ts: -------------------------------------------------------------------------------- 1 | import { Ranking } from 'cutopia-types'; 2 | import { Schema, model } from 'mongoose'; 3 | 4 | import { requiredNumber, requiredString } from '../constants/schema'; 5 | 6 | const rankEntry = { 7 | _id: requiredString, 8 | val: { 9 | type: Schema.Types.Mixed, 10 | required: true, 11 | }, 12 | }; 13 | 14 | const rankingSchema = new Schema( 15 | { 16 | _id: requiredString, 17 | ranks: { 18 | type: [rankEntry], 19 | required: true, 20 | }, 21 | updatedAt: requiredNumber, 22 | }, 23 | { 24 | _id: false, 25 | timestamps: false, 26 | versionKey: false, 27 | } 28 | ); 29 | 30 | const RankingModel = model('Ranking', rankingSchema); 31 | 32 | export default RankingModel; 33 | -------------------------------------------------------------------------------- /backend/mongodb/src/models/report.ts: -------------------------------------------------------------------------------- 1 | import { ReportDocument } from 'cutopia-types'; 2 | import { Schema, model } from 'mongoose'; 3 | import { nanoid } from 'nanoid'; 4 | 5 | import { createdAt, requiredNumber, requiredString } from '../constants/schema'; 6 | 7 | const reportSchema = new Schema( 8 | { 9 | _id: { 10 | type: String, 11 | default: () => nanoid(5), 12 | }, 13 | createdAt: createdAt, 14 | cat: requiredNumber, 15 | username: String, 16 | description: requiredString, 17 | types: [requiredNumber], 18 | identifier: String, 19 | }, 20 | { 21 | timestamps: false, 22 | versionKey: false, 23 | _id: false, 24 | } 25 | ); 26 | 27 | const Report = model('Report', reportSchema); 28 | 29 | export default Report; 30 | -------------------------------------------------------------------------------- /backend/mongodb/src/models/review.ts: -------------------------------------------------------------------------------- 1 | import { Review } from 'cutopia-types'; 2 | import { Schema, model } from 'mongoose'; 3 | 4 | import { requiredNumber, requiredString } from '../constants/schema'; 5 | 6 | const rating = { 7 | type: Number, 8 | required: true, 9 | min: 0, 10 | max: 4, 11 | }; 12 | 13 | const reviewDetail = { 14 | grade: rating, 15 | text: { 16 | type: String, 17 | required: true, 18 | maxlength: 10000, 19 | }, 20 | }; 21 | 22 | const reviewSchema = new Schema( 23 | { 24 | _id: requiredString, 25 | courseId: requiredString, // dummy for filtering 26 | username: requiredString, 27 | title: String, 28 | term: requiredString, 29 | lecturer: requiredString, 30 | anonymous: { type: Boolean, required: true }, 31 | upvotes: { 32 | type: Number, 33 | default: 0, 34 | }, 35 | downvotes: { 36 | type: Number, 37 | default: 0, 38 | }, 39 | upvoteUserIds: [String], 40 | downvoteUserIds: [String], 41 | overall: rating, 42 | grading: reviewDetail, 43 | teaching: reviewDetail, 44 | difficulty: reviewDetail, 45 | content: reviewDetail, 46 | createdAt: requiredNumber, // cannot in second, as this shall be the same as the one in id 47 | updatedAt: requiredNumber, 48 | }, 49 | { 50 | // not sure updateAt gonna trigger when updates changed, better manually change when editReview 51 | timestamps: false, 52 | _id: false, 53 | toJSON: { virtuals: true, getters: true }, // to store virtuals in cache 54 | } 55 | ); 56 | // By default, MongoDB creates a unique index on the _id field during the creation of a collection. 57 | reviewSchema.index({ createdAt: -1 }); 58 | 59 | const ReviewModel = model('Review', reviewSchema); 60 | 61 | export default ReviewModel; 62 | -------------------------------------------------------------------------------- /backend/mongodb/src/models/timetable.ts: -------------------------------------------------------------------------------- 1 | import { Timetable } from 'cutopia-types'; 2 | import { Schema, model } from 'mongoose'; 3 | import { nanoid } from 'nanoid'; 4 | 5 | import { requiredNumber, requiredString, createdAt } from '../constants/schema'; 6 | 7 | const timetableSection = { 8 | days: [requiredString], 9 | endTimes: [requiredString], 10 | startTimes: [requiredString], 11 | instructors: [requiredString], 12 | locations: [requiredString], 13 | meetingDates: [String], 14 | hide: { 15 | type: Boolean, 16 | default: false, 17 | }, 18 | name: String, 19 | }; 20 | 21 | const timetableEntry = { 22 | courseId: requiredString, 23 | title: requiredString, 24 | credits: requiredNumber, 25 | sections: [timetableSection], 26 | }; 27 | 28 | const timetableSchema = new Schema( 29 | { 30 | _id: { 31 | type: String, 32 | default: () => nanoid(10), 33 | }, 34 | createdAt: createdAt, 35 | entries: [timetableEntry], 36 | expire: requiredNumber, 37 | expireAt: requiredNumber, 38 | username: requiredString, 39 | tableName: String, 40 | }, 41 | { 42 | timestamps: false, 43 | versionKey: false, 44 | _id: false, 45 | } 46 | ); 47 | 48 | const Timetable = model('Timetable', timetableSchema); 49 | 50 | export default Timetable; 51 | -------------------------------------------------------------------------------- /backend/mongodb/src/models/user.ts: -------------------------------------------------------------------------------- 1 | import { User } from 'cutopia-types'; 2 | import { Schema, model } from 'mongoose'; 3 | 4 | import { requiredString, createdAt } from '../constants/schema'; 5 | 6 | const userSchema = new Schema( 7 | { 8 | username: { 9 | type: String, 10 | required: true, 11 | index: true, 12 | unique: true, 13 | }, 14 | SID: { 15 | type: String, 16 | required: true, 17 | index: true, 18 | unique: true, 19 | }, 20 | password: requiredString, 21 | createdAt: createdAt, 22 | reviewIds: [String], // format: courseId#createdAt 23 | upvotes: { 24 | type: Number, 25 | default: 0, 26 | }, 27 | downvotes: { 28 | type: Number, 29 | default: 0, 30 | }, 31 | resetPwdCode: String, 32 | exp: { 33 | type: Number, 34 | default: 0, 35 | }, 36 | veriCode: String, 37 | verified: { 38 | type: Boolean, 39 | default: false, 40 | }, 41 | timetableId: { 42 | type: String, 43 | default: null, 44 | }, 45 | timetables: [ 46 | { 47 | type: String, 48 | ref: 'Timetable', 49 | }, 50 | ], 51 | }, 52 | { 53 | toJSON: { virtuals: true, getters: true }, 54 | } 55 | ); 56 | 57 | // to be removed when viewsCount is implemented 58 | userSchema.virtual('fullAccess').get(function () { 59 | return this.reviewIds.length > 0; 60 | }); 61 | 62 | const UserModal = model('User', userSchema); 63 | 64 | export default UserModal; 65 | -------------------------------------------------------------------------------- /backend/mongodb/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "ts-node": { 3 | // It is faster to skip typechecking. 4 | // Remove if you want ts-node to do typechecking. 5 | "transpileOnly": true, 6 | 7 | "files": true, 8 | 9 | "compilerOptions": { 10 | // compilerOptions specified here will override those declared below, 11 | // but *only* in ts-node. Useful if you want ts-node and tsc to use 12 | // different options with a single tsconfig.json. 13 | } 14 | }, 15 | "compilerOptions": { 16 | "target": "ES6", 17 | "types": ["node"], 18 | "module": "commonjs", 19 | "esModuleInterop": true, 20 | "moduleResolution": "node", 21 | "lib": ["ES2020"], 22 | "sourceMap": true, 23 | "outDir": "lib", 24 | "baseUrl": ".", 25 | "skipDefaultLibCheck": true, 26 | "noEmitOnError": true, 27 | "strict": false, 28 | "paths": { 29 | "*": [ 30 | "node_modules/*" 31 | ] 32 | }, 33 | "skipLibCheck": true, 34 | "declaration": true, 35 | "resolveJsonModule": true 36 | }, 37 | "include": ["src/**/*"], 38 | "exclude": [ 39 | "node_modules", 40 | "src/**/*.test.ts", 41 | ] 42 | } -------------------------------------------------------------------------------- /backend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cutopia-backend", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "devDependencies": { 6 | "nodemon": "^2.0.19" 7 | }, 8 | "scripts": { 9 | "bootstrap": "bash tools/init.sh", 10 | "move-data": "bash tools/copy-data.sh", 11 | "watch": "bash tools/watch-files.sh", 12 | "dev": "bash tools/run-server.sh", 13 | "lint": "eslint --ext .js,.ts ." 14 | }, 15 | "license": "MIT", 16 | "engines": { 17 | "node": ">=v12.21.0", 18 | "yarn": ">=1.22.0" 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /backend/root-stack.yaml: -------------------------------------------------------------------------------- 1 | AWSTemplateFormatVersion: "2010-09-09" 2 | Transform: "AWS::Serverless-2016-10-31" 3 | Resources: 4 | Lambda: 5 | Type: AWS::CloudFormation::Stack 6 | Properties: 7 | TemplateURL: ./lambda/lambda.yaml 8 | Parameters: 9 | UserSNSTopic: !Ref UserSNSTopic 10 | UserSNSTopic: 11 | Type: AWS::SNS::Topic 12 | Properties: 13 | TopicName: !Sub ${AWS::StackName}-User 14 | DisplayName: "User" 15 | -------------------------------------------------------------------------------- /backend/tools/copy-data.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | data_path=lambda/graphql/src/data 4 | 5 | [ -e $data_path ] && rm -r $data_path 6 | 7 | mkdir -p $data_path/courses 8 | mkdir -p $data_path/derived 9 | 10 | cp -r ../data/courses $data_path 11 | cp ../data/derived/subject_course_names.json $data_path/derived 12 | -------------------------------------------------------------------------------- /backend/tools/copy-env.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -a 4 | source ./.env 5 | set +a 6 | 7 | NODE_ENV=${NODE_ENV:-development} 8 | if [[ "$NODE_ENV" == "production" ]]; then 9 | URI=${ATLAS_PROD_URI:-$ATLAS_URI} 10 | else 11 | URI=${ATLAS_DEV_URI:-$ATLAS_URI} 12 | fi 13 | 14 | env_content=$(grep -vE '^(NODE_ENV|ATLAS_URI)=' ./.env) 15 | env_content="${env_content} 16 | NODE_ENV=\"${NODE_ENV}\" 17 | ATLAS_URI=\"${URI}\"" 18 | 19 | declare -a modules=( 20 | "./lambda/emailer" 21 | "./lambda/graphql" 22 | "./lambda/cron-remove-timetable" 23 | "./lambda/cron-update-ranking" 24 | ) 25 | 26 | for d in "${modules[@]}"; do 27 | printf "%s\n" "$env_content" > "$d/.env" 28 | done 29 | -------------------------------------------------------------------------------- /backend/tools/copy-file.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | cd lambda/graphql 4 | cp src/jwt/jwtRS256.key build/jwt 5 | cp src/jwt/jwtRS256.key.pub build/jwt 6 | cp src/schemas/bundle.graphql build/schemas 7 | cp .env build/.env 8 | cp -R src/data build 9 | -------------------------------------------------------------------------------- /backend/tools/deploy.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | yarn --cwd mongodb/ run build 4 | yarn --cwd lambda/graphql/ run build 5 | 6 | bash tools/copy-env.sh 7 | bash tools/copy-file.sh 8 | 9 | if [ -f "samconfig.toml" ]; then 10 | sam deploy --stack-name $1 -t root-stack.yaml 11 | else 12 | sam deploy --stack-name $1 -t root-stack.yaml --guided --capabilities CAPABILITY_IAM CAPABILITY_AUTO_EXPAND 13 | fi 14 | -------------------------------------------------------------------------------- /backend/tools/install-package.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | modules=( 4 | "./" 5 | "./mongodb" 6 | "./lambda/emailer" 7 | "./lambda/graphql" 8 | "./lambda/cron-remove-timetable" 9 | "./lambda/cron-update-ranking" 10 | "./tools/load-test" 11 | ) 12 | 13 | for d in "${modules[@]}"; do 14 | if [ -d "$d/node_modules" ]; then 15 | echo "Removing node_modules in $d" 16 | rm -rf $d/node_modules 17 | fi 18 | 19 | echo "Installing node_modules in $d" 20 | yarn --cwd $d install --production=false 21 | 22 | # Build the mongodb for lambda to install it 23 | if [ $d = "./mongodb" ]; then 24 | echo "Building mongodb" 25 | yarn --cwd $d build 26 | fi 27 | done 28 | -------------------------------------------------------------------------------- /backend/tools/load-test/index.js: -------------------------------------------------------------------------------- 1 | // this script is intended for server load test and lambda instances warmup 2 | 3 | require('dotenv').config(); 4 | const { GraphQLClient, gql } = require('graphql-request'); 5 | 6 | const login = gql` 7 | mutation LoginMutation($loginInput: LoginInput!) { 8 | login(input: $loginInput) { 9 | token 10 | } 11 | } 12 | `; 13 | 14 | const getReviews = gql` 15 | query Query($reviewsInput: ReviewsFilter!) { 16 | reviews(input: $reviewsInput) { 17 | _id 18 | courseId 19 | username 20 | anonymous 21 | title 22 | createdAt 23 | term 24 | lecturer 25 | overall 26 | grading { 27 | grade 28 | text 29 | } 30 | teaching { 31 | grade 32 | text 33 | } 34 | difficulty { 35 | grade 36 | text 37 | } 38 | content { 39 | grade 40 | text 41 | } 42 | upvotes 43 | downvotes 44 | myVote 45 | } 46 | } 47 | `; 48 | 49 | const endpoint = process.env.GRAPHQL_ENDPOINT; 50 | 51 | const testOne = async label => { 52 | const client = new GraphQLClient(endpoint); 53 | 54 | console.time(`boom ${label}`); 55 | const loginRes = await client.request(login, { 56 | loginInput: { 57 | username: process.env.AUTH_USERNAME, 58 | password: process.env.AUTH_PASSWORD, 59 | }, 60 | }); 61 | client.setHeader('Authorization', `Bearer ${loginRes.login.token}`); 62 | await client.request(getReviews, { 63 | reviewsInput: { 64 | courseId: null, 65 | }, 66 | }); 67 | await client.request(getReviews, { 68 | reviewsInput: { 69 | courseId: 'CSCI3230', 70 | sortBy: 'upvotes', 71 | }, 72 | }); 73 | console.timeEnd(`boom ${label}`); 74 | }; 75 | 76 | const testAll = async () => { 77 | console.time('all boom'); 78 | await Promise.all(Array.from({ length: 50 }, (_, i) => i).map(testOne)); 79 | console.timeEnd('all boom'); 80 | }; 81 | 82 | testAll().then(() => console.log('done')); 83 | -------------------------------------------------------------------------------- /backend/tools/load-test/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "load-test", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "license": "MIT", 6 | "dependencies": { 7 | "dotenv": "^10.0.0", 8 | "graphql": "^15.5.2", 9 | "graphql-request": "^3.5.0" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /backend/tools/run-server.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | NODE_ENV=dev; bash tools/copy-env.sh 4 | yarn --cwd lambda/graphql nodemon src/local.ts 5 | -------------------------------------------------------------------------------- /backend/tools/watch-files.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | trap "trap - SIGTERM && kill -- -$$" SIGINT SIGTERM EXIT 4 | 5 | yarn nodemon --watch "mongodb/src" \ 6 | -e ts \ 7 | --exec "tsc --project mongodb && yarn --cwd lambda/graphql upgrade mongodb" & \ 8 | yarn --cwd lambda/graphql nodemon --watch "src/schemas/*.graphql" \ 9 | -e graphql \ 10 | --ignore "src/schemas/bundle.graphql" \ 11 | --exec "yarn run build-schema && yarn run build-gql-types" 12 | -------------------------------------------------------------------------------- /frontend/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | 25 | .eslintcache 26 | 27 | # Next 28 | .next 29 | /out 30 | 31 | # Others 32 | .env 33 | # Sentry 34 | .sentryclirc 35 | 36 | # Site map 37 | public/**/*.xml 38 | public/resources/robots.txt 39 | 40 | # data 41 | public/resources 42 | src/constants/faculty_subjects.json 43 | -------------------------------------------------------------------------------- /frontend/README.md: -------------------------------------------------------------------------------- 1 | # CUtopia Frontend 2 | The source code for [cutopia.app](cutopia.app) frontend. 3 | 4 | ## Development 5 | 6 | Framework: [Next.js](https://nextjs.org/) 7 | 8 | State Management: [MobX](https://github.com/mobxjs/mobx) 9 | 10 | UI Components: [MUI](https://github.com/mui/material-ui) 11 | 12 | API: [Apollo Client](https://github.com/apollographql/apollo-client) 13 | -------------------------------------------------------------------------------- /frontend/next-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | /// 3 | 4 | // NOTE: This file should not be edited 5 | // see https://nextjs.org/docs/basic-features/typescript for more information. 6 | -------------------------------------------------------------------------------- /frontend/next-sitemap.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('next-sitemap').IConfig} */ 2 | const config = { 3 | siteUrl: process.env.SITE_URL || 'https://cutopia.app', 4 | generateRobotsTxt: true, // (optional) 5 | outDir: 'build', 6 | // ...other options 7 | }; 8 | 9 | module.exports = config; 10 | -------------------------------------------------------------------------------- /frontend/next.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const loaderUtils = require('loader-utils'); 3 | const { withSentryConfig } = require('@sentry/nextjs'); 4 | 5 | /** @type {import('next').NextConfig} */ 6 | 7 | // based on https://github.com/vercel/next.js/blob/0af3b526408bae26d6b3f8cab75c4229998bf7cb/packages/next/build/webpack/config/blocks/css/loaders/getCssModuleLocalIdent.ts 8 | const hashOnlyIdent = (context, _, exportName) => 9 | loaderUtils 10 | .getHashDigest( 11 | Buffer.from( 12 | `filePath:${path 13 | .relative(context.rootContext, context.resourcePath) 14 | .replace(/\\+/g, '/')}#className:${exportName}` 15 | ), 16 | 'md4', 17 | 'base64', 18 | 7 19 | ) 20 | .replace(/^(-?\d|--)/, '_$1') 21 | .split('+') 22 | .join('_') 23 | .split('/') 24 | .join('_'); 25 | 26 | const moduleExports = { 27 | env: { 28 | REACT_APP_ENV_MODE: process.env.REACT_APP_ENV_MODE, 29 | REACT_APP_LAST_DATA_UPDATE: process.env.REACT_APP_LAST_DATA_UPDATE, 30 | REACT_APP_CURRENT_TERM: process.env.REACT_APP_CURRENT_TERM, 31 | }, 32 | trailingSlash: false, 33 | sassOptions: { 34 | includePaths: [path.join(__dirname, 'styles')], 35 | }, 36 | webpack(config, { dev }) { 37 | if (!dev) { 38 | const rules = config.module.rules 39 | .find(rule => typeof rule.oneOf === 'object') 40 | .oneOf.filter(rule => Array.isArray(rule.use)); 41 | 42 | rules.forEach(rule => { 43 | rule.use.forEach(moduleLoader => { 44 | if ( 45 | moduleLoader.loader?.includes('css-loader') && 46 | !moduleLoader.loader?.includes('postcss-loader') 47 | ) 48 | moduleLoader.options.modules.getLocalIdent = hashOnlyIdent; 49 | }); 50 | }); 51 | } 52 | 53 | return config; 54 | }, 55 | }; 56 | 57 | const sentryWebpackPluginOptions = { 58 | silent: true, 59 | }; 60 | 61 | module.exports = withSentryConfig(moduleExports, sentryWebpackPluginOptions); 62 | -------------------------------------------------------------------------------- /frontend/public/cutopia-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/cutopia-labs/CUtopia/0934704c2d74f625d68627c96d9f6a0298e67e8e/frontend/public/cutopia-logo.png -------------------------------------------------------------------------------- /frontend/public/robots.txt: -------------------------------------------------------------------------------- 1 | # * 2 | User-agent: * 3 | Allow: / 4 | 5 | # Host 6 | Host: cutopia.app 7 | 8 | # Sitemaps 9 | Sitemap: cutopia.app/sitemap.xml 10 | -------------------------------------------------------------------------------- /frontend/sentry.client.config.js: -------------------------------------------------------------------------------- 1 | // This file configures the initialization of Sentry on the browser. 2 | // The config you add here will be used whenever a page is visited. 3 | // https://docs.sentry.io/platforms/javascript/guides/nextjs/ 4 | 5 | import * as Sentry from '@sentry/nextjs'; 6 | 7 | const SENTRY_DSN = 8 | process.env.SENTRY_DSN || 9 | process.env.NEXT_PUBLIC_SENTRY_DSN || 10 | 'https://c38359448a5448a58971eeb211568473@o861810.ingest.sentry.io/5821571'; 11 | 12 | Sentry.init({ 13 | dsn: process.env.NODE_ENV === 'production' ? SENTRY_DSN : '', 14 | // Adjust this value in production, or use tracesSampler for greater control 15 | tracesSampleRate: 0.5, 16 | // ... 17 | // Note: if you want to override the automatic release value, do not set a 18 | // `release` value here - use the environment variable `SENTRY_RELEASE`, so 19 | // that it will also get attached to your source maps 20 | }); 21 | -------------------------------------------------------------------------------- /frontend/sentry.properties: -------------------------------------------------------------------------------- 1 | defaults.url=https://sentry.io/ 2 | defaults.org=cutopia 3 | defaults.project=cutopia-web 4 | cli.executable=../../../.npm/_npx/a8388072043b4cbc/node_modules/@sentry/cli/bin/sentry-cli 5 | -------------------------------------------------------------------------------- /frontend/sentry.server.config.js: -------------------------------------------------------------------------------- 1 | // This file configures the initialization of Sentry on the server. 2 | // The config you add here will be used whenever the server handles a request. 3 | // https://docs.sentry.io/platforms/javascript/guides/nextjs/ 4 | 5 | import * as Sentry from '@sentry/nextjs'; 6 | 7 | const SENTRY_DSN = 8 | process.env.SENTRY_DSN || 9 | process.env.NEXT_PUBLIC_SENTRY_DSN || 10 | 'https://c38359448a5448a58971eeb211568473@o861810.ingest.sentry.io/5821571'; 11 | 12 | Sentry.init({ 13 | dsn: process.env.NODE_ENV === 'production' ? SENTRY_DSN : '', 14 | // Adjust this value in production, or use tracesSampler for greater control 15 | tracesSampleRate: 0.5, 16 | // ... 17 | // Note: if you want to override the automatic release value, do not set a 18 | // `release` value here - use the environment variable `SENTRY_RELEASE`, so 19 | // that it will also get attached to your source maps 20 | }); 21 | -------------------------------------------------------------------------------- /frontend/src/components/about/AboutSection.tsx: -------------------------------------------------------------------------------- 1 | import { FC, PropsWithChildren } from 'react'; 2 | 3 | import Section, { SectionProps } from '../molecules/Section'; 4 | import styles from '../../styles/components/about/About.module.scss'; 5 | 6 | const AboutSection: FC> = props => ( 7 |
8 | ); 9 | 10 | export default AboutSection; 11 | -------------------------------------------------------------------------------- /frontend/src/components/about/tabs/PrivacyTab.tsx: -------------------------------------------------------------------------------- 1 | import { Link } from '@mui/material'; 2 | import clsx from 'clsx'; 3 | import { FC } from 'react'; 4 | 5 | import styles from '../../../styles/components/about/About.module.scss'; 6 | import Card from '../../atoms/Card'; 7 | import AboutSection from '../AboutSection'; 8 | 9 | const PrivacyTab: FC = () => ( 10 | 11 | 15 |

16 | {' '} 17 | We need your student id for verification purposes only. Reviews are 18 | being posted anonymously or with CUtopia username, but not student id. 19 | Thus, your student id is only used in sign-up and password recovery 20 | services. 21 |

22 |

23 | We will never share your personal data, such as your student id, with 24 | any third parties. However, your username might be collected through 25 | third party sites or services. Please read the details below. 26 |

27 |
28 | 29 |

30 | We use services provided by{' '} 31 | Sentry for crash reporting. Your 32 | crash log together with your identifier (username) will be automatically 33 | send to us for troubleshooting. 34 |

35 |
36 | 37 |

38 | We use{' '} 39 | Google Analytics to 40 | analyze the usage. Activities such as page views, session duration will 41 | be collected anonymously. 42 |

43 |
44 |
45 | ); 46 | 47 | export default PrivacyTab; 48 | -------------------------------------------------------------------------------- /frontend/src/components/about/tabs/TermsOfUseTab.tsx: -------------------------------------------------------------------------------- 1 | import clsx from 'clsx'; 2 | import { FC } from 'react'; 3 | 4 | import styles from '../../../styles/components/about/About.module.scss'; 5 | import Card from '../../atoms/Card'; 6 | import AboutSection from '../AboutSection'; 7 | 8 | const TermsOfUseTab: FC = () => { 9 | return ( 10 | 11 | 15 |

Drafting...

16 |
17 |
18 | ); 19 | }; 20 | 21 | export default TermsOfUseTab; 22 | -------------------------------------------------------------------------------- /frontend/src/components/about/tabs/index.ts: -------------------------------------------------------------------------------- 1 | export { default as AboutTab } from './AboutTab'; 2 | export { default as PrivacyTab } from './PrivacyTab'; 3 | export { default as TermsOfUseTab } from './TermsOfUseTab'; 4 | -------------------------------------------------------------------------------- /frontend/src/components/atoms/Badge.tsx: -------------------------------------------------------------------------------- 1 | import clsx from 'clsx'; 2 | import { FC } from 'react'; 3 | import styles from '../../styles/components/atoms/Badge.module.scss'; 4 | import updateOpacity from '../../helpers/updateOpacity'; 5 | import colors from '../../constants/colors'; 6 | 7 | type BadgeProps = { 8 | index?: number; 9 | text: string; 10 | value?: string; 11 | isGrade?: boolean; 12 | color?: string; 13 | }; 14 | 15 | const Badge: FC> = ({ 16 | index, 17 | text, 18 | value, 19 | isGrade, 20 | color, 21 | className, 22 | }) => { 23 | const badgeColor = isGrade 24 | ? colors.gradeColors[value] 25 | : colors.randomColors[ 26 | index >= colors.randomColors.length 27 | ? index % colors.randomColors.length 28 | : index 29 | ]; 30 | return ( 31 | 37 | {text} 38 | {Boolean(value) &&

{`${value}`}

} 39 |
40 | ); 41 | }; 42 | 43 | export default Badge; 44 | -------------------------------------------------------------------------------- /frontend/src/components/atoms/CaptionDivider.tsx: -------------------------------------------------------------------------------- 1 | import { Divider } from '@mui/material'; 2 | import clsx from 'clsx'; 3 | import { FCC } from '../../types/general'; 4 | import styles from '../../styles/components/atoms/CaptionDivider.module.scss'; 5 | 6 | type Props = { 7 | className?: string; 8 | }; 9 | 10 | const CaptionDivider: FCC = ({ children, className }) => ( 11 | 14 | {children} 15 | 16 | 17 | ); 18 | 19 | export default CaptionDivider; 20 | -------------------------------------------------------------------------------- /frontend/src/components/atoms/Card.tsx: -------------------------------------------------------------------------------- 1 | import clsx from 'clsx'; 2 | import { FC } from 'react'; 3 | import CardHeader from './CardHeader'; 4 | 5 | type CardOwnProps = { 6 | inPlace?: boolean; 7 | title?: string; 8 | titleContent?: JSX.Element; 9 | }; 10 | 11 | export type CardProps = CardOwnProps & React.HTMLProps; 12 | 13 | const Card: FC = ({ 14 | className, 15 | children, 16 | title, 17 | titleContent, 18 | inPlace, 19 | ...props 20 | }) => ( 21 |
22 | {Boolean(title) && {titleContent}} 23 | {children} 24 |
25 | ); 26 | 27 | export default Card; 28 | -------------------------------------------------------------------------------- /frontend/src/components/atoms/CardHeader.tsx: -------------------------------------------------------------------------------- 1 | import clsx from 'clsx'; 2 | import styles from '../../styles/components/atoms/CardHeader.module.scss'; 3 | import { FCC } from '../../types/general'; 4 | 5 | type CardHeaderProps = { 6 | left?: JSX.Element; 7 | className?: string; 8 | title?: string; 9 | }; 10 | 11 | const CardHeader: FCC = ({ 12 | className, 13 | children, 14 | left, 15 | title, 16 | ...props 17 | }) => ( 18 |
22 | {left} 23 |

{title}

24 | {children} 25 |
26 | ); 27 | 28 | export default CardHeader; 29 | -------------------------------------------------------------------------------- /frontend/src/components/atoms/GradeIndicator.tsx: -------------------------------------------------------------------------------- 1 | import clsx from 'clsx'; 2 | import { FC } from 'react'; 3 | import colors from '../../constants/colors'; 4 | import updateOpacity from '../../helpers/updateOpacity'; 5 | import { getLabel } from '../../helpers'; 6 | import styles from '../../styles/components/atoms/GradeIndicator.module.scss'; 7 | 8 | type GradeIndicatorProps = { 9 | grade: string | number; 10 | style?: string; 11 | }; 12 | 13 | const GradeIndicator: FC = ({ grade, style }) => { 14 | const label = getLabel(grade); 15 | const color = colors.gradeColors[label.charAt(0)]; 16 | return ( 17 |
24 | {label} 25 |
26 | ); 27 | }; 28 | 29 | export default GradeIndicator; 30 | -------------------------------------------------------------------------------- /frontend/src/components/atoms/If.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from 'react'; 2 | import { FCC } from '../../types/general'; 3 | 4 | export type IfProps = { 5 | visible: boolean | any; 6 | elseNode?: ReactNode; 7 | }; 8 | 9 | // @ts-ignore 10 | const If: FCC = ({ visible, children, elseNode = null }) => { 11 | return visible ? children : elseNode; 12 | }; 13 | 14 | export default If; 15 | -------------------------------------------------------------------------------- /frontend/src/components/atoms/Loading.tsx: -------------------------------------------------------------------------------- 1 | import { CircularProgress, CircularProgressProps } from '@mui/material'; 2 | import clsx from 'clsx'; 3 | import { FC } from 'react'; 4 | 5 | import styles from '../../styles/components/atoms/Loading.module.scss'; 6 | import Logo from './Logo'; 7 | 8 | export type LoadingProps = { 9 | fixed?: boolean; 10 | padding?: boolean; 11 | logo?: boolean; 12 | style?: string; 13 | }; 14 | 15 | const Loading: FC = ({ 16 | fixed, 17 | padding = true, 18 | logo, 19 | style, 20 | ...props 21 | }) => { 22 | return ( 23 |
32 | {logo ? ( 33 | 34 | ) : ( 35 | 36 | )} 37 |
38 | ); 39 | }; 40 | 41 | export default Loading; 42 | -------------------------------------------------------------------------------- /frontend/src/components/atoms/LoadingButton.tsx: -------------------------------------------------------------------------------- 1 | import { Button, ButtonProps } from '@mui/material'; 2 | import { FC } from 'react'; 3 | 4 | import Loading from './Loading'; 5 | 6 | type LoadingbuttonProps = { 7 | loading: boolean; 8 | }; 9 | 10 | const LoadingButton: FC = ({ 11 | loading, 12 | children, 13 | ...props 14 | }) => ( 15 | 18 | ); 19 | 20 | export default LoadingButton; 21 | -------------------------------------------------------------------------------- /frontend/src/components/atoms/LoadingView.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from 'react'; 2 | import { FCC } from '../../types/general'; 3 | import If from './If'; 4 | import Loading, { LoadingProps } from './Loading'; 5 | 6 | type Props = { 7 | loading: boolean; 8 | elseNode?: ReactNode; 9 | }; 10 | 11 | const LoadingView: FCC = ({ 12 | loading, 13 | children, 14 | ...props 15 | }) => ( 16 | }> 17 | {children || null} 18 | 19 | ); 20 | 21 | export default LoadingView; 22 | -------------------------------------------------------------------------------- /frontend/src/components/atoms/Logo.tsx: -------------------------------------------------------------------------------- 1 | import clsx from 'clsx'; 2 | import { FC } from 'react'; 3 | import styles from '../../styles/components/atoms/Logo.module.scss'; 4 | 5 | type LogoProps = { 6 | shine?: boolean; 7 | style?: string; 8 | }; 9 | 10 | const Logo: FC = ({ shine }) => ( 11 |
12 | cutopia 13 |
14 | ); 15 | 16 | export default Logo; 17 | -------------------------------------------------------------------------------- /frontend/src/components/atoms/Page.tsx: -------------------------------------------------------------------------------- 1 | import clsx from 'clsx'; 2 | import { FCC } from '../../types/general'; 3 | 4 | type PageProps = { 5 | className?: string; 6 | center?: boolean; 7 | padding?: boolean; 8 | column?: boolean; 9 | }; 10 | 11 | const Page: FCC = ({ 12 | children, 13 | className, 14 | center, 15 | padding, 16 | column, 17 | }) => { 18 | return ( 19 |
28 | {children} 29 |
30 | ); 31 | }; 32 | 33 | export default Page; 34 | -------------------------------------------------------------------------------- /frontend/src/components/atoms/TextField.tsx: -------------------------------------------------------------------------------- 1 | import clsx from 'clsx'; 2 | import { FC } from 'react'; 3 | import styles from '../../styles/components/atoms/TextField.module.scss'; 4 | 5 | type Tag = 'input' | 'textarea'; 6 | 7 | type TextFieldProps = { 8 | onChangeText: (text: string) => any; 9 | error?: string; 10 | Tag?: Tag; 11 | value: string; 12 | type?: string; 13 | inputRef?: React.RefObject; 14 | label?: string; 15 | disabled?: boolean; 16 | }; 17 | 18 | const TextField: FC< 19 | React.HTMLAttributes & TextFieldProps 20 | > = ({ 21 | value, 22 | onChangeText, 23 | placeholder, 24 | error, 25 | defaultValue, 26 | type, 27 | Tag, 28 | inputRef, 29 | label, 30 | onBlur, 31 | onFocus, 32 | disabled, 33 | className, 34 | }) => { 35 | const TagName = Tag || 'input'; 36 | return ( 37 | <> 38 | {Boolean(label) && ( 39 | {label} 40 | )} 41 | onChangeText(e.target.value)} 49 | onFocus={onFocus} 50 | onBlur={onBlur} 51 | disabled={disabled} 52 | /> 53 | {Boolean(error) &&
{error}
} 54 | 55 | ); 56 | }; 57 | 58 | export default TextField; 59 | -------------------------------------------------------------------------------- /frontend/src/components/atoms/TextIcon.tsx: -------------------------------------------------------------------------------- 1 | import { Avatar } from '@mui/material'; 2 | import clsx from 'clsx'; 3 | 4 | import { FC } from 'react'; 5 | import styles from '../../styles/components/atoms/TextIcon.module.scss'; 6 | import colors from '../../constants/colors'; 7 | import { hashing } from '../../helpers'; 8 | 9 | type TextIconProps = { 10 | text?: string; 11 | char?: string; 12 | fontSize?: number; 13 | className?: string; 14 | backgroundColor?: string; 15 | size?: number; 16 | }; 17 | 18 | const TextIcon: FC = ({ 19 | text, 20 | char, 21 | className, 22 | backgroundColor, 23 | size, 24 | fontSize, 25 | }) => ( 26 | 39 | {char || text.charAt(0)} 40 | 41 | ); 42 | 43 | export default TextIcon; 44 | -------------------------------------------------------------------------------- /frontend/src/components/home/HomePageTabs.tsx: -------------------------------------------------------------------------------- 1 | import Link from 'next/link'; 2 | import clsx from 'clsx'; 3 | import { FC } from 'react'; 4 | import styles from '../../styles/components/home/HomePageTabs.module.scss'; 5 | import ListItem from '../molecules/ListItem'; 6 | import Loading from '../atoms/Loading'; 7 | import { getMMMDDYY } from '../../helpers/getTime'; 8 | import { CourseConcise, ErrorCardMode } from '../../types'; 9 | import ErrorCard from '../molecules/ErrorCard'; 10 | 11 | type CoursesListProps = { 12 | loading: boolean; 13 | courses: CourseConcise[]; 14 | }; 15 | 16 | export const CoursesList: FC = ({ loading, courses }) => { 17 | if (!courses.length) 18 | return ; 19 | if (loading) return ; 20 | return ( 21 |
22 | {courses?.map(course => ( 23 | 24 | 30 | 31 | ))} 32 |
33 | ); 34 | }; 35 | 36 | type ReviewsListProps = { 37 | reviewIds: string[]; 38 | }; 39 | 40 | export const ReviewsList: FC = ({ reviewIds }) => { 41 | if (!reviewIds?.length) 42 | return ; 43 | return ( 44 |
45 | {reviewIds?.map(id => { 46 | const [courseId, createdAt] = id.split('#'); 47 | return ( 48 | 49 | 55 | 56 | ); 57 | })} 58 |
59 | ); 60 | }; 61 | -------------------------------------------------------------------------------- /frontend/src/components/molecules/ChipsRow.tsx: -------------------------------------------------------------------------------- 1 | import { Chip } from '@mui/material'; 2 | import clsx from 'clsx'; 3 | import { FC, MouseEvent } from 'react'; 4 | import { ErrorCardMode } from '../../types'; 5 | 6 | import styles from '../../styles/components/molecules/ChipsRow.module.scss'; 7 | import ErrorCard from './ErrorCard'; 8 | 9 | type ChipsRowProps = { 10 | items: string[]; 11 | select?: string | string[]; 12 | setSelect?: (item: string, selected: boolean) => any; 13 | onItemClick?: (item: string, e: MouseEvent) => any; 14 | chipClassName?: string; 15 | }; 16 | 17 | const ChipsRow: FC> = ({ 18 | items, 19 | select, 20 | setSelect, 21 | className, 22 | chipClassName, 23 | onItemClick, 24 | ...props 25 | }) => { 26 | if (!items.length) return ; 27 | const multipleSelection = typeof select !== 'string'; 28 | return ( 29 |
30 | {items.map(item => { 31 | const selected = multipleSelection 32 | ? select?.includes(item) 33 | : select === item; 34 | return ( 35 | { 39 | if (onItemClick) { 40 | onItemClick(item, e); 41 | } else { 42 | setSelect(item, selected); 43 | } 44 | }} 45 | label={item} 46 | variant={selected ? 'filled' : 'outlined'} 47 | /> 48 | ); 49 | })} 50 |
51 | ); 52 | }; 53 | 54 | export default ChipsRow; 55 | -------------------------------------------------------------------------------- /frontend/src/components/molecules/ErrorCard.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from 'react'; 2 | import clsx from 'clsx'; 3 | import Card from '../atoms/Card'; 4 | import styles from '../../styles/components/molecules/ErrorCard.module.scss'; 5 | import { ErrorCardMode } from '../../types'; 6 | 7 | const CARD_ITEMS = { 8 | [ErrorCardMode.NULL]: { 9 | image: '/images/null.svg', 10 | caption: 'Nothing here...', 11 | }, 12 | [ErrorCardMode.ERROR]: { 13 | image: '/images/error.svg', 14 | caption: 'Something is wrong here...', 15 | }, 16 | }; 17 | 18 | type ErrorCardProps = { 19 | mode: ErrorCardMode; 20 | inPlace?: boolean; 21 | caption?: string; 22 | }; 23 | 24 | const ErrorCard: FC = ({ mode, inPlace = true, caption }) => ( 25 | 29 | Empty! 36 | {caption || CARD_ITEMS[mode]?.caption} 37 | 38 | ); 39 | 40 | export default ErrorCard; 41 | -------------------------------------------------------------------------------- /frontend/src/components/molecules/FeedCard.tsx: -------------------------------------------------------------------------------- 1 | import clsx from 'clsx'; 2 | import { FC } from 'react'; 3 | 4 | import styles from '../../styles/components/molecules/FeedCard.module.scss'; 5 | import { CourseConcise } from '../../types'; 6 | import Card, { CardProps } from '../atoms/Card'; 7 | import ListItem from './ListItem'; 8 | 9 | type FeedCardProps = { 10 | courses: CourseConcise[]; 11 | onItemClick: (course: CourseConcise) => any; 12 | }; 13 | 14 | const FeedCard: FC = ({ 15 | title, 16 | className, 17 | courses, 18 | onItemClick, 19 | ...props 20 | }) => ( 21 | 22 | {courses.map(course => ( 23 | onItemClick(course)} 28 | noBorder 29 | /> 30 | ))} 31 | 32 | ); 33 | 34 | export default FeedCard; 35 | -------------------------------------------------------------------------------- /frontend/src/components/molecules/Link.tsx: -------------------------------------------------------------------------------- 1 | import clsx from 'clsx'; 2 | import { FC } from 'react'; 3 | import { FiExternalLink } from 'react-icons/fi'; 4 | 5 | import styles from '../../styles/components/molecules/Link.module.scss'; 6 | 7 | type LinkProps = { 8 | url: string; 9 | label: string; 10 | truncate?: number; 11 | icon?: boolean; 12 | style?: string; 13 | }; 14 | 15 | const Link: FC = ({ url, label, truncate, icon = true, style }) => ( 16 |
17 | {icon && } 18 | 25 | {label} 26 | 27 |
28 | ); 29 | 30 | export default Link; 31 | -------------------------------------------------------------------------------- /frontend/src/components/molecules/ListItem.tsx: -------------------------------------------------------------------------------- 1 | import clsx from 'clsx'; 2 | import colors from '../../constants/colors'; 3 | import { FCC } from '../../types/general'; 4 | 5 | export type ListItemProps = { 6 | title?: string; 7 | caption?: string | JSX.Element; 8 | onClick?: (...args: any[]) => any; 9 | chevron?: boolean; 10 | noBorder?: boolean; 11 | ribbonIndex?: number; 12 | left?: JSX.Element; 13 | right?: JSX.Element; 14 | className?: string; 15 | noHover?: boolean; 16 | onMouseDown?: (...args: any[]) => any; 17 | }; 18 | 19 | const ListItem: FCC = ({ 20 | title, 21 | caption, 22 | onClick, 23 | chevron, 24 | noBorder, 25 | ribbonIndex, 26 | left, 27 | right, 28 | className, 29 | onMouseDown, 30 | noHover, 31 | children, 32 | }) => { 33 | const listContent = ( 34 | <> 35 | {ribbonIndex !== undefined && ( 36 | 43 | )} 44 | {left} 45 | 46 | {title && {title}} 47 | {Boolean(caption) && ( 48 | {caption} 49 | )} 50 | 51 | {right} 52 | {chevron && {'\u203A'}} 53 | 54 | ); 55 | const renderContent = () => { 56 | if (children) 57 | return ( 58 | <> 59 |
{listContent}
60 | {children} 61 | 62 | ); 63 | return listContent; 64 | }; 65 | return ( 66 |
77 | {renderContent()} 78 |
79 | ); 80 | }; 81 | 82 | export default ListItem; 83 | -------------------------------------------------------------------------------- /frontend/src/components/molecules/Section.tsx: -------------------------------------------------------------------------------- 1 | import clsx from 'clsx'; 2 | import styles from '../../styles/components/molecules/Section.module.scss'; 3 | import { FCC } from '../../types/general'; 4 | 5 | export type SectionProps = { 6 | title: string; 7 | className?: string; 8 | labelClassName?: string; 9 | subheading?: boolean; 10 | }; 11 | 12 | const Section: FCC = ({ 13 | title, 14 | children, 15 | className, 16 | labelClassName, 17 | subheading, 18 | }) => ( 19 |
20 |
21 | {title} 22 |
23 | {children} 24 |
25 | ); 26 | 27 | export default Section; 28 | -------------------------------------------------------------------------------- /frontend/src/components/molecules/SectionGroup.tsx: -------------------------------------------------------------------------------- 1 | import clsx from 'clsx'; 2 | import { FC } from 'react'; 3 | import colors from '../../constants/colors'; 4 | import { Grade } from '../../types'; 5 | import styles from '../../styles/components/molecules/SectionGroup.module.scss'; 6 | import { FCC } from '../../types/general'; 7 | 8 | type FormSectionProps = { 9 | title: string; 10 | className?: string; 11 | }; 12 | 13 | export const FormSection: FCC = ({ 14 | title, 15 | className, 16 | children, 17 | }) => ( 18 | <> 19 | {title} 20 | {children} 21 | 22 | ); 23 | 24 | type SelectionGroupProps = { 25 | selections: Grade[]; 26 | selectedIndex: number; 27 | onSelect: (index: number) => any; 28 | }; 29 | 30 | const SelectionGroup: FC = ({ 31 | selections, 32 | selectedIndex, 33 | onSelect, 34 | }) => ( 35 |
36 | {selections.map((selection, i) => ( 37 | onSelect(i)} 44 | style={ 45 | selectedIndex === i 46 | ? { 47 | backgroundColor: colors.gradeColors[selection], 48 | } 49 | : null 50 | } 51 | > 52 | {selection} 53 | 54 | ))} 55 |
56 | ); 57 | 58 | export default SelectionGroup; 59 | -------------------------------------------------------------------------------- /frontend/src/components/molecules/SectionText.tsx: -------------------------------------------------------------------------------- 1 | import { FC } from 'react'; 2 | import Section, { SectionProps } from './Section'; 3 | 4 | type SectionTextProps = { 5 | caption: string; 6 | }; 7 | 8 | const SectionText: FC = props => ( 9 |
10 |

{props.caption}

11 |
12 | ); 13 | 14 | export default SectionText; 15 | -------------------------------------------------------------------------------- /frontend/src/components/molecules/ShowMoreOverlay.tsx: -------------------------------------------------------------------------------- 1 | import { ExpandMore } from '@mui/icons-material'; 2 | import clsx from 'clsx'; 3 | import { FC } from 'react'; 4 | 5 | import styles from '../../styles/components/molecules/ShowMoreOverlay.module.scss'; 6 | import If from '../atoms/If'; 7 | 8 | type ShowMoreOverlayProps = { 9 | visible: boolean; 10 | onShowMore: (...args: any[]) => any; 11 | style?: string; 12 | }; 13 | const ShowMoreOverlay: FC = ({ 14 | visible, 15 | onShowMore, 16 | style, 17 | }) => ( 18 | 19 |
20 |
21 | Show More 22 | 23 |
24 |
25 |
26 | ); 27 | 28 | export default ShowMoreOverlay; 29 | -------------------------------------------------------------------------------- /frontend/src/components/molecules/SnackBar.tsx: -------------------------------------------------------------------------------- 1 | import { FC, useEffect, useState } from 'react'; 2 | import { Button, Portal, Alert } from '@mui/material'; 3 | import { Check } from '@mui/icons-material'; 4 | import { observer } from 'mobx-react-lite'; 5 | 6 | import styles from '../../styles/components/molecules/SnackBar.module.scss'; 7 | import { useView } from '../../store'; 8 | 9 | const SnackBar: FC = () => { 10 | const view = useView(); 11 | const [buttonClicked, setButtonClicked] = useState(false); 12 | 13 | useEffect(() => { 14 | setButtonClicked(false); 15 | }, [view.snackbar.snackbarId]); 16 | 17 | return ( 18 | 19 | {Boolean(view.snackbar.message) && ( 20 | { 29 | view.snackbar.onClick(); 30 | setButtonClicked(true); 31 | }} 32 | disabled={buttonClicked} 33 | > 34 | {buttonClicked ? ( 35 | 36 | ) : ( 37 | view.snackbar.label 38 | )} 39 | 40 | ) 41 | } 42 | > 43 | {view.snackbar.message} 44 | 45 | )} 46 | 47 | ); 48 | }; 49 | 50 | export default observer(SnackBar); 51 | -------------------------------------------------------------------------------- /frontend/src/components/molecules/TabsContainer.tsx: -------------------------------------------------------------------------------- 1 | import clsx from 'clsx'; 2 | import { FC } from 'react'; 3 | import { MenuItem } from '../../types'; 4 | 5 | type TabsContainerProps = { 6 | items: MenuItem[]; 7 | selected: string; 8 | onSelect: (label: any) => void; 9 | mb?: boolean; 10 | }; 11 | 12 | const TabsContainer: FC = ({ 13 | items, 14 | selected, 15 | onSelect, 16 | mb, 17 | }) => ( 18 |
19 | {items.map(item => ( 20 |
onSelect(item.label)} 28 | > 29 | {item.icon} 30 | {item.label} 31 |
32 | ))} 33 |
34 | ); 35 | 36 | export default TabsContainer; 37 | -------------------------------------------------------------------------------- /frontend/src/components/organisms/SearchDropdown.tsx: -------------------------------------------------------------------------------- 1 | import { useState, useRef, FC } from 'react'; 2 | import clsx from 'clsx'; 3 | import styles from '../../styles/components/organisms/SearchDropdown.module.scss'; 4 | import SearchInput from '../molecules/SearchInput'; 5 | import useMobileQuery from '../../hooks/useMobileQuery'; 6 | import useOuterClick from '../../hooks/useOuterClick'; 7 | import { SearchPayload } from '../../types'; 8 | import If from '../atoms/If'; 9 | import SearchPanel, { SearchPanelProps } from './SearchPanel'; 10 | 11 | const SearchDropdown: FC = props => { 12 | const { onCoursePress, style, ...searchPanelProps } = props || {}; 13 | const [searchPayload, setSearchPayload] = useState( 14 | null 15 | ); 16 | const [visible, setVisible] = useState(false); 17 | const isMobile = useMobileQuery(); 18 | 19 | const inputRef = useRef(null); 20 | const searchDropDownRef = useOuterClick(e => { 21 | setVisible(false); 22 | setSearchPayload(null); 23 | }, !visible); 24 | 25 | const onSubmitSearch = e => { 26 | e.preventDefault(); 27 | }; 28 | 29 | return ( 30 |
39 | 48 | 49 | { 53 | setSearchPayload(null); 54 | setVisible(false); 55 | }} 56 | setSearchPayloadProp={setSearchPayload} 57 | {...searchPanelProps} 58 | /> 59 | 60 |
61 | ); 62 | }; 63 | 64 | export default SearchDropdown; 65 | -------------------------------------------------------------------------------- /frontend/src/components/planner/CourseSectionCard.tsx: -------------------------------------------------------------------------------- 1 | import { useQuery } from '@apollo/client'; 2 | import { FC } from 'react'; 3 | import { CURRENT_TERM } from '../../config'; 4 | import { COURSE_SECTIONS_QUERY } from '../../constants/queries'; 5 | import { validCourse } from '../../helpers'; 6 | import { useView } from '../../store'; 7 | import Loading from '../atoms/Loading'; 8 | import CourseCard from '../review/CourseCard'; 9 | 10 | type Props = { 11 | courseId: string; 12 | }; 13 | 14 | const CourseSectionCard: FC = ({ courseId }) => { 15 | const view = useView(); 16 | // Fetch course info 17 | const { data: courseInfo, loading: courseInfoLoading } = useQuery( 18 | COURSE_SECTIONS_QUERY, 19 | { 20 | skip: !courseId || !validCourse(courseId), 21 | variables: { 22 | courseId, 23 | term: CURRENT_TERM, 24 | }, 25 | onError: view.handleError, 26 | } 27 | ); 28 | if (!courseId) return null; 29 | if (courseInfoLoading) return ; 30 | return ( 31 | 38 | ); 39 | }; 40 | 41 | export default CourseSectionCard; 42 | -------------------------------------------------------------------------------- /frontend/src/components/review/GradeRow.tsx: -------------------------------------------------------------------------------- 1 | import clsx from 'clsx'; 2 | 3 | import { RATING_FIELDS } from '../../constants'; 4 | import { CourseRating, Review } from '../../types'; 5 | import GradeIndicator from '../atoms/GradeIndicator'; 6 | import styles from '../../styles/components/review/GradeRow.module.scss'; 7 | import { FCC } from '../../types/general'; 8 | 9 | type GradeRowProps = { 10 | rating: Review | CourseRating; 11 | isReview?: boolean; 12 | selected?: string; 13 | setSelected?: (label: string) => void; 14 | style?: string; 15 | gradeIndicatorStyle?: string; 16 | additionalChildClassName?: string; 17 | isMobile?: boolean; 18 | concise?: boolean; 19 | }; 20 | 21 | const GradeRow: FCC = ({ 22 | rating, 23 | children, 24 | isReview, 25 | selected, 26 | setSelected, 27 | concise, 28 | style, 29 | additionalChildClassName, 30 | gradeIndicatorStyle, 31 | isMobile, 32 | }) => ( 33 |
34 | {['overall', ...RATING_FIELDS].map(field => ( 35 |
{ 44 | if (setSelected && !isMobile) { 45 | setSelected(field); 46 | } 47 | }} 48 | > 49 | {field !== 'overall' && ( 50 |
54 | {`${field}${isMobile ? '' : ':'}`} 55 |
56 | )} 57 | 65 |
66 | ))} 67 | {children} 68 |
69 | ); 70 | 71 | export default GradeRow; 72 | -------------------------------------------------------------------------------- /frontend/src/components/review/LikeButtonRow.tsx: -------------------------------------------------------------------------------- 1 | import { Divider } from '@mui/material'; 2 | import Button from '@mui/material/Button'; 3 | import clsx from 'clsx'; 4 | import { FC, Fragment } from 'react'; 5 | import { BiDownvote, BiUpvote } from 'react-icons/bi'; 6 | 7 | import styles from '../../styles/components/review/LikeButtonRow.module.scss'; 8 | 9 | const LikeButton: FC = ({ 10 | isLike, 11 | selected, 12 | disabled, 13 | caption, 14 | onClick, 15 | }) => ( 16 | 27 | ); 28 | 29 | const LikeButtonsRow: FC = ({ 30 | liked, 31 | myVote, 32 | updateVote, 33 | likeCaption, 34 | dislikeCaption, 35 | }) => { 36 | return ( 37 |
38 | {[1, 0].map(label => ( 39 | 40 | updateVote(label)} 46 | /> 47 | {Boolean(label) && } 48 | 49 | ))} 50 |
51 | ); 52 | }; 53 | 54 | export default LikeButtonsRow; 55 | -------------------------------------------------------------------------------- /frontend/src/components/templates/DialogContentTemplate.tsx: -------------------------------------------------------------------------------- 1 | import clsx from 'clsx'; 2 | import { FCC } from '../../types/general'; 3 | 4 | type DialogContentTemplateProps = { 5 | className?: string; 6 | title?: string; 7 | caption?: string; 8 | }; 9 | 10 | const DialogContentTemplate: FCC = ({ 11 | className, 12 | children, 13 | title, 14 | caption, 15 | }) => ( 16 |
17 |
{title}
18 |
{caption}
19 | {children} 20 |
21 | ); 22 | 23 | export default DialogContentTemplate; 24 | -------------------------------------------------------------------------------- /frontend/src/constants/courseCodes.ts: -------------------------------------------------------------------------------- 1 | import COURSE_CODES from './faculty_subjects.json'; 2 | 3 | export default COURSE_CODES; 4 | -------------------------------------------------------------------------------- /frontend/src/constants/errors.ts: -------------------------------------------------------------------------------- 1 | import { ErrorCode } from 'cutopia-types'; 2 | 3 | export const ERROR_MESSAGES = { 4 | [ErrorCode.AUTHORIZATION_INVALID_TOKEN]: 'Invalid token, please login again', 5 | [ErrorCode.INVALID_COURSE_ID]: 'Invalid courseId', 6 | [ErrorCode.CREATE_USER_INVALID_EMAIL]: 'Invalid SID', 7 | [ErrorCode.CREATE_USER_USERNAME_EXISTS]: 'Username already exist!', 8 | [ErrorCode.CREATE_USER_EMAIL_EXISTS]: 'SID already exist!', 9 | [ErrorCode.VERIFICATION_FAILED]: 'Failed to verify', 10 | [ErrorCode.VERIFICATION_ALREADY_VERIFIED]: 'CUHK SID already verified!', 11 | [ErrorCode.VERIFICATION_USER_DNE]: "User doesn't exist!", 12 | [ErrorCode.VERIFICATION_EXPIRED]: 13 | 'Verification expired, please sign up again!', 14 | [ErrorCode.LOGIN_FAILED]: 'Wrong password!', 15 | [ErrorCode.LOGIN_USER_DNE]: "Username doesn't exist!", 16 | [ErrorCode.LOGIN_NOT_VERIFIED]: 'Account not verified!', 17 | [ErrorCode.GET_PASSWORD_USER_DNE]: "Username doesn't exist!", 18 | [ErrorCode.GET_PASSWORD_NOT_VERIFIED]: 'Not verified!', 19 | [ErrorCode.RESET_PASSWORD_FAILED]: 'Failed to reset password', 20 | [ErrorCode.RESET_PASSWORD_USER_DNE]: "Username doesn't exist!", 21 | [ErrorCode.RESET_PASSWORD_NOT_VERIFIED]: 'Not verified!', 22 | [ErrorCode.CREATE_REVIEW_ALREADY_CREATED]: 'Already created review!', 23 | [ErrorCode.VOTE_REVIEW_INVALID_VALUE]: 'Invalid voting', 24 | [ErrorCode.VOTE_REVIEW_VOTED_ALREADY]: 'Voted already!', 25 | [ErrorCode.UPLOAD_TIMETABLE_EXCEED_ENTRY_LIMIT]: 26 | 'Timetable exceed 10 courses! Please remove some and retry.', 27 | [ErrorCode.UPLOAD_TIMETABLE_EXCEED_TOTAL_LIMIT]: 28 | 'Exceed upload limit, please delete remote timetable before upload!', 29 | [ErrorCode.GET_REVIEW_INVALID_SORTING]: 'Invalid sorting!', 30 | [ErrorCode.GET_TIMETABLE_INVALID_ID]: 'Invalid timetable id!', 31 | [ErrorCode.GET_TIMETABLE_UNAUTHORIZED]: 'Private timetable, failed to load!', 32 | [ErrorCode.GET_TIMETABLE_EXPIRED]: 'Link expired!', 33 | [ErrorCode.DEL_TIMETABLE_INVALID_ID]: 'Failed to delete (invalid id)!', 34 | [ErrorCode.INPUT_INVALID_LENGTH]: 'Invalid input length!', 35 | [ErrorCode.INPUT_INVALID_VALUE]: 'Invalid input value!', 36 | [ErrorCode.EXCEED_RATE_LIMIT]: 'Too fast, please try again later!', 37 | }; 38 | -------------------------------------------------------------------------------- /frontend/src/constants/messages.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CourseReportType, 3 | IssueReportType, 4 | ReportCategory, 5 | ReviewReportType, 6 | } from 'cutopia-types'; 7 | 8 | export const REPORT_ISSUES_MESSAGES = { 9 | [ReportCategory.ISSUE]: 'Describe your issue', 10 | [ReportCategory.COURSE]: `Inaccurate course info for `, 11 | [ReportCategory.REVIEW]: `Inappropriate review`, 12 | }; 13 | 14 | export const REPORT_MODES = { 15 | [ReportCategory.ISSUE]: { 16 | [IssueReportType.UI]: 'UI', 17 | [IssueReportType.BUGS]: 'bugs', 18 | [IssueReportType.FEATURES]: 'features', 19 | [IssueReportType.EXPERIENCE]: 'experiences', 20 | [IssueReportType.COURSE_INFO]: 'courses', 21 | [IssueReportType.OTHER]: 'other', 22 | }, 23 | [ReportCategory.COURSE]: { 24 | [CourseReportType.COURSE_TITLE]: 'title', 25 | [CourseReportType.CREDITS]: 'credits', 26 | [CourseReportType.ASSESSMENTS]: 'assessments', 27 | [CourseReportType.REQUIREMENTS]: 'requirements', 28 | [CourseReportType.DESCRIPTION]: 'description', 29 | [CourseReportType.OTHER]: 'other', 30 | }, 31 | [ReportCategory.REVIEW]: { 32 | [ReviewReportType.HATE_SPEECH]: 'hate speech', 33 | [ReviewReportType.PERSONAL_ATTACK]: 'personal attack', 34 | [ReviewReportType.SPAM]: 'spam', 35 | [ReviewReportType.MISLEADING]: 'misleading', 36 | [ReviewReportType.OTHER]: 'other', 37 | }, 38 | }; 39 | 40 | export const WINDOW_LEAVE_MESSAGES = { 41 | REVIEW_EDIT: 42 | 'Are you sure you want to leave? (Review will be saved as draft)', 43 | }; 44 | -------------------------------------------------------------------------------- /frontend/src/helpers/apollo-client.ts: -------------------------------------------------------------------------------- 1 | import { ApolloClient, createHttpLink, InMemoryCache } from '@apollo/client'; 2 | import { setContext } from '@apollo/client/link/context'; 3 | import { SERVER_CONFIG } from '../config'; 4 | import { getToken } from '.'; 5 | 6 | const httpLink = createHttpLink({ 7 | uri: SERVER_CONFIG.URI, 8 | }); 9 | const authLink = setContext((_, { headers }) => { 10 | const token = getToken(); 11 | return { 12 | headers: { 13 | ...headers, 14 | authorization: token ? `Bearer ${token}` : '', 15 | }, 16 | }; 17 | }); 18 | const client = new ApolloClient({ 19 | link: authLink.concat(httpLink), 20 | cache: new InMemoryCache({ 21 | addTypename: false, 22 | }), 23 | }); 24 | 25 | export default client; 26 | -------------------------------------------------------------------------------- /frontend/src/helpers/colorMixing.ts: -------------------------------------------------------------------------------- 1 | const hexTorgba = (hex: string): string => { 2 | const red = parseInt(hex.slice(1, 3), 16); 3 | const green = parseInt(hex.slice(3, 5), 16); 4 | const blue = parseInt(hex.slice(5, 7), 16); 5 | const alpha = parseInt(hex.slice(7, 9), 16) / 255; 6 | return `rgba(${red}, ${green}, ${blue}, ${alpha})`; 7 | }; 8 | 9 | const rgbaToArray = (rgba: string) => { 10 | const arr = rgba.substr(5).split(')')[0].split(','); 11 | if (arr.indexOf('/') > -1) arr.splice(3, 1); 12 | return arr.map(x => parseFloat(x)); 13 | }; 14 | 15 | const colorMixing = (color: string, base: string) => { 16 | if (color.charAt(0) === '#') { 17 | color = hexTorgba(color); 18 | } 19 | const baseColor = base; 20 | const mix1 = rgbaToArray(baseColor); 21 | const mix2 = rgbaToArray(color); 22 | const mix = []; 23 | mix[3] = 1 - (1 - mix2[3]) * (1 - mix1[3]); // alpha 24 | mix[0] = Math.round( 25 | (mix2[0] * mix2[3]) / mix[3] + (mix1[0] * mix1[3] * (1 - mix2[3])) / mix[3] 26 | ); // red 27 | mix[1] = Math.round( 28 | (mix2[1] * mix2[3]) / mix[3] + (mix1[1] * mix1[3] * (1 - mix2[3])) / mix[3] 29 | ); // green 30 | mix[2] = Math.round( 31 | (mix2[2] * mix2[3]) / mix[3] + (mix1[2] * mix1[3] * (1 - mix2[3])) / mix[3] 32 | ); // blue 33 | return `rgba(${mix})`; 34 | }; 35 | 36 | export default colorMixing; 37 | -------------------------------------------------------------------------------- /frontend/src/helpers/dynamicQueries.ts: -------------------------------------------------------------------------------- 1 | import { 2 | RECENT_REVIEWS_CONTENT_QUERY, 3 | RECENT_REVIEWS_DIFFICULTY_QUERY, 4 | RECENT_REVIEWS_GRADING_QUERY, 5 | RECENT_REVIEWS_TEACHING_QUERY, 6 | } from '../constants/queries'; 7 | import { RatingField } from '../types'; 8 | 9 | const RECENT_REVIEWS_MAPPING = { 10 | content: RECENT_REVIEWS_CONTENT_QUERY, 11 | grading: RECENT_REVIEWS_GRADING_QUERY, 12 | teaching: RECENT_REVIEWS_TEACHING_QUERY, 13 | difficulty: RECENT_REVIEWS_DIFFICULTY_QUERY, 14 | }; 15 | 16 | export const getRecentReviewQuery = (category: RatingField) => 17 | RECENT_REVIEWS_MAPPING[category]; 18 | -------------------------------------------------------------------------------- /frontend/src/helpers/handleCompleted.ts: -------------------------------------------------------------------------------- 1 | import { viewStore } from '../store'; 2 | import ViewStore from '../store/ViewStore'; 3 | 4 | type HandleCompletedConfigs = { 5 | message?: string; 6 | view?: ViewStore; 7 | mute?: boolean; 8 | }; 9 | 10 | const handleCompleted = 11 | (callback?: (data?: any) => any, options?: HandleCompletedConfigs) => 12 | (data: any) => { 13 | callback(data); 14 | if (options?.mute) return; 15 | const msg = options?.message || 'Success'; 16 | (options?.view || viewStore).setSnackBar(msg); 17 | }; 18 | 19 | export default handleCompleted; 20 | -------------------------------------------------------------------------------- /frontend/src/helpers/handleError.ts: -------------------------------------------------------------------------------- 1 | import { ApolloError } from '@apollo/client'; 2 | import { ErrorCode } from 'cutopia-types'; 3 | import { ERROR_MESSAGES } from '../constants/errors'; 4 | import { userStore } from '../store'; 5 | import ViewStore from '../store/ViewStore'; 6 | 7 | const handleError = (e: ApolloError, view: ViewStore): boolean | null => { 8 | const err_code = parseInt(e.message, 10); 9 | // @ts-ignore 10 | const customErrors = isNaN(err_code) ? e.networkError?.result?.errors : null; 11 | const alt_code = customErrors?.length 12 | ? parseInt(customErrors[0]?.extensions?.code, 10) 13 | : null; 14 | // Clear the invalid token and log out 15 | if ( 16 | err_code === ErrorCode.AUTHORIZATION_INVALID_TOKEN || 17 | alt_code === ErrorCode.AUTHORIZATION_REFRESH_TOKEN || 18 | alt_code === ErrorCode.AUTHORIZATION_INVALID_TOKEN 19 | ) { 20 | if (alt_code === ErrorCode.AUTHORIZATION_REFRESH_TOKEN) { 21 | userStore.saveToken(customErrors[0]?.extensions?.refreshedToken); 22 | // Return true for index.tsx not to logout 23 | return true; 24 | } else { 25 | userStore.logout(); 26 | return; // Do not show error, cuz it's confusing, just let them login again 27 | } 28 | } 29 | view.setSnackBar({ 30 | message: ERROR_MESSAGES[err_code] || e.message, 31 | severity: 'error', 32 | }); 33 | }; 34 | 35 | export default handleError; 36 | -------------------------------------------------------------------------------- /frontend/src/helpers/ics.ts: -------------------------------------------------------------------------------- 1 | import { addZero } from './getTime'; 2 | 3 | const PRODID = 'cutopia-labs'; 4 | const SEP = '\n'; 5 | const ICS_FOOTER = 'END:VCALENDAR'; 6 | 7 | export const formatIcsDate = (date: Date) => { 8 | return [ 9 | date.getFullYear(), 10 | addZero(date.getMonth() + 1), 11 | addZero(date.getDate()), 12 | 'T', 13 | addZero(date.getHours()), 14 | addZero(date.getMinutes()), 15 | addZero(date.getSeconds()), 16 | ].join(''); 17 | }; 18 | 19 | export type IcsEvent = { 20 | startTime: string; 21 | endTime: string; 22 | location?: string; 23 | title?: string; 24 | uid?: string; 25 | }; 26 | 27 | /** 28 | * Used to create a iCal object with one or multiple events 29 | */ 30 | export default class ICS { 31 | private prodid: string; 32 | private events: string[]; 33 | private tzone: string; 34 | private numEvents: number; 35 | constructor(prodid = PRODID, tzone = 'Asia/Hong_Kong') { 36 | this.prodid = prodid; 37 | this.events = []; 38 | this.tzone = tzone; 39 | this.numEvents = 0; 40 | } 41 | get content() { 42 | const evsStr = this.events.map(ev => ev).join(SEP); 43 | return [this.header, evsStr, ICS_FOOTER].join(SEP); 44 | } 45 | get length() { 46 | return this.numEvents; 47 | } 48 | private get header() { 49 | return ['BEGIN:VCALENDAR', `PRODID:${this.prodid}`, 'VERSION:2.0'].join( 50 | SEP 51 | ); 52 | } 53 | private get timestamp() { 54 | return formatIcsDate(new Date()); 55 | } 56 | private _addEvent(ev: string) { 57 | this.events.push(ev); 58 | this.numEvents++; 59 | } 60 | getIcsFile(filename: string) { 61 | return new File([this.content], filename, { type: 'plain/text' }); 62 | } 63 | addEvent(ev: IcsEvent) { 64 | const uid = ev.uid || `${ev.title}_${ev.startTime}`; 65 | const evStr = [ 66 | 'BEGIN:VEVENT', 67 | `UID:${uid}`, 68 | `SUMMARY:${ev.title}`, 69 | `DTSTAMP:${this.timestamp}`, 70 | `DTSTART:${ev.startTime}`, 71 | `DTEND:${ev.endTime}`, 72 | `LOCATION:${ev.location}`, 73 | 'END:VEVENT', 74 | ].join(SEP); 75 | this._addEvent(evStr); 76 | } 77 | addEvents(events: IcsEvent[]) { 78 | events.forEach(ev => this.addEvent(ev)); 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /frontend/src/helpers/store.ts: -------------------------------------------------------------------------------- 1 | export function removeStoreItem(key: string) { 2 | try { 3 | localStorage.removeItem(key); 4 | return true; 5 | } catch (e) { 6 | return false; 7 | } 8 | } 9 | 10 | export function getStoreData(key: string, parse = true) { 11 | try { 12 | const value = localStorage.getItem(key); 13 | return parse ? JSON.parse(value) : value; 14 | } catch (e) { 15 | return localStorage.getItem(key); 16 | } 17 | } 18 | 19 | export function storeData(key: string, value: any, stringify = true) { 20 | try { 21 | return localStorage.setItem(key, stringify ? JSON.stringify(value) : value); 22 | } catch (e) { 23 | return ''; 24 | } 25 | } 26 | 27 | export function clearStore() { 28 | localStorage.clear(); 29 | } 30 | -------------------------------------------------------------------------------- /frontend/src/helpers/updateOpacity.ts: -------------------------------------------------------------------------------- 1 | const RGB_REPLACE_RULE = new RegExp('[\\d\\.]+\\)$', 'g'); 2 | 3 | const addHexZero = (str: string) => (str.length === 1 ? `0${str}` : str); 4 | const addOpacity = (rgbString: string, opacity: number) => { 5 | if (rgbString.startsWith('#')) { 6 | const hexOpacity = Math.round(Math.min(Math.max(opacity || 1, 0), 1) * 255); 7 | return rgbString + addHexZero(hexOpacity.toString(16).toUpperCase()); 8 | } 9 | if (rgbString.startsWith('rgba')) { 10 | return rgbString.replace(RGB_REPLACE_RULE, `${opacity})` as any); 11 | } 12 | return rgbString.replace(')', `, ${opacity})`).replace('rgb', 'rgba'); 13 | }; 14 | export default addOpacity; 15 | -------------------------------------------------------------------------------- /frontend/src/helpers/withUndo.ts: -------------------------------------------------------------------------------- 1 | import ViewStore from '../store/ViewStore'; 2 | 3 | type UndoConfigs = { 4 | prevData: any; 5 | setData: (prevData: any) => void; 6 | viewStore: ViewStore; 7 | stringify?: boolean; 8 | message?: string; 9 | }; 10 | 11 | const withUndo = async ( 12 | { prevData, setData, viewStore, stringify, message }: UndoConfigs, 13 | fn?: (...args: any[]) => void 14 | ) => { 15 | prevData = stringify ? JSON.stringify(prevData) : prevData; 16 | fn && fn(); 17 | await viewStore.setSnackBar({ 18 | message: message || 'Success', 19 | label: 'UNDO', 20 | onClick: () => { 21 | setData(stringify ? JSON.parse(prevData) : prevData); 22 | }, 23 | }); 24 | }; 25 | 26 | export default withUndo; 27 | -------------------------------------------------------------------------------- /frontend/src/hooks/useClickObserver.ts: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | 3 | type Point = { 4 | x: number; 5 | y: number; 6 | }; 7 | 8 | const l2dist = (p1: Point, p2: Point) => { 9 | const dx = p2.x - p1.x; 10 | const dy = p2.y - p1.y; 11 | return Math.sqrt(dx * dx + dy * dy); 12 | }; 13 | 14 | const useClickObserver = clickCallback => { 15 | const [dragStartPos, setDragStartPos] = useState(null); 16 | const onStart = (_, data) => { 17 | setDragStartPos({ x: data.x, y: data.y }); 18 | }; 19 | const onStop = (_, data) => { 20 | const dragStopPoint = { x: data.x, y: data.y }; 21 | if (l2dist(dragStartPos, dragStopPoint) < 5) { 22 | clickCallback(); 23 | } 24 | }; 25 | return { onStart, onStop }; 26 | }; 27 | 28 | export default useClickObserver; 29 | -------------------------------------------------------------------------------- /frontend/src/hooks/useDebounce.ts: -------------------------------------------------------------------------------- 1 | import { useRef, useEffect, useCallback } from 'react'; 2 | 3 | const useDebounce = (fn: (...args: any[]) => any, delay: number) => { 4 | const { current } = useRef({ fn, timer: null }); 5 | useEffect(() => { 6 | current.fn = fn; 7 | }, [current, fn]); 8 | return useCallback(() => { 9 | if (current.timer) { 10 | clearTimeout(current.timer); 11 | } 12 | current.timer = setTimeout(function () { 13 | current.fn(...arguments); 14 | }, delay); 15 | }, [current, delay]); 16 | }; 17 | 18 | export default useDebounce; 19 | -------------------------------------------------------------------------------- /frontend/src/hooks/useMobileQuery.ts: -------------------------------------------------------------------------------- 1 | import { useMediaQuery } from '@mui/material'; 2 | import { MIN_DESKTOP_WIDTH } from '../config'; 3 | 4 | const useMobileQuery = () => 5 | useMediaQuery(`(max-width:${MIN_DESKTOP_WIDTH}px)`); 6 | 7 | export default useMobileQuery; 8 | -------------------------------------------------------------------------------- /frontend/src/hooks/useOuterClick.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from 'react'; 2 | 3 | const useOuterClick = ( 4 | callback: (e: MouseEvent) => any, 5 | skip?: boolean, 6 | ignoredClassNames?: Set 7 | ) => { 8 | const callbackRef = useRef(null); 9 | const innerRef = useRef(null); 10 | 11 | useEffect(() => { 12 | callbackRef.current = callback; 13 | }); 14 | 15 | useEffect(() => { 16 | const handleClick = e => { 17 | if (skip) { 18 | return; 19 | } 20 | if (ignoredClassNames && typeof e.target?.className === 'string') { 21 | for (const name of (e.target.className as string).split(' ')) { 22 | if (ignoredClassNames.has(name)) { 23 | return; 24 | } 25 | } 26 | } 27 | if ( 28 | innerRef.current && 29 | callbackRef.current && 30 | !innerRef.current.contains(e.target) 31 | ) 32 | callbackRef.current(e); 33 | }; 34 | document.addEventListener('click', handleClick); 35 | return () => document.removeEventListener('click', handleClick); 36 | }, [skip]); 37 | 38 | return innerRef; 39 | }; 40 | 41 | export default useOuterClick; 42 | -------------------------------------------------------------------------------- /frontend/src/pages/_document.tsx: -------------------------------------------------------------------------------- 1 | import { Html, Head, Main, NextScript } from 'next/document'; 2 | import { FC } from 'react'; 3 | 4 | const Document: FC = () => ( 5 | 6 | 7 | 8 | 9 | 10 | 11 | 15 | 16 | 17 |
18 | 19 | 20 | 21 | ); 22 | 23 | export default Document; 24 | -------------------------------------------------------------------------------- /frontend/src/pages/about.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | InfoOutlined, 3 | LockOutlined, 4 | DescriptionOutlined, 5 | } from '@mui/icons-material'; 6 | import { FC, useState } from 'react'; 7 | import clsx from 'clsx'; 8 | 9 | import styles from '../styles/pages/AboutPage.module.scss'; 10 | import { AboutTab, PrivacyTab, TermsOfUseTab } from '../components/about/tabs'; 11 | import Page from '../components/atoms/Page'; 12 | import TabsContainer from '../components/molecules/TabsContainer'; 13 | 14 | export const ABOUT_PAGE_ROUTES = [ 15 | { 16 | label: 'about', 17 | icon: , 18 | }, 19 | { 20 | label: 'privacy', 21 | icon: , 22 | }, 23 | { 24 | label: 'terms', 25 | icon: , 26 | }, 27 | ]; 28 | 29 | const AboutPage: FC = () => { 30 | const [tab, setTab] = useState('about'); 31 | const renderTab = () => { 32 | switch (tab) { 33 | case 'about': 34 | return ; 35 | case 'privacy': 36 | return ; 37 | case 'terms': 38 | return ; 39 | } 40 | }; 41 | 42 | return ( 43 | 44 |
45 | setTab(label)} 49 | /> 50 | {renderTab()} 51 |
52 |
53 | ); 54 | }; 55 | 56 | export default AboutPage; 57 | -------------------------------------------------------------------------------- /frontend/src/pages/login.tsx: -------------------------------------------------------------------------------- 1 | import LoginPanel from '../components/templates/LoginPanel'; 2 | 3 | export default LoginPanel; 4 | -------------------------------------------------------------------------------- /frontend/src/store/ViewStore.ts: -------------------------------------------------------------------------------- 1 | import { ApolloError } from '@apollo/client'; 2 | import { makeAutoObservable } from 'mobx'; 3 | import { SNACKBAR_TIMEOUT } from '../config'; 4 | import { wait } from '../helpers'; 5 | import { default as handleErrorFn } from '../helpers/handleError'; 6 | import { Dialog, SnackBar, SnackBarProps } from '../types'; 7 | 8 | class ViewStore { 9 | snackbar: SnackBar; 10 | dialog: Dialog | null; 11 | 12 | init() { 13 | this.snackbar = { 14 | message: '', 15 | label: '', 16 | onClick: null, 17 | snackbarId: undefined, 18 | }; 19 | this.dialog = null; 20 | } 21 | 22 | constructor() { 23 | this.init(); 24 | this.handleError = this.handleError.bind(this); 25 | makeAutoObservable(this); 26 | } 27 | 28 | handleError(e: ApolloError) { 29 | handleErrorFn(e, this); 30 | } 31 | 32 | withErrorHandle(fn: (...args: any[]) => any) { 33 | return function (...args: any[]) { 34 | try { 35 | return fn(...args); 36 | } catch (e) { 37 | this.handleError(e); 38 | } 39 | }; 40 | } 41 | 42 | async setSnackBar(prop: string | SnackBarProps) { 43 | const snackbar = typeof prop === 'string' ? { message: prop } : prop; 44 | const snackbarId = snackbar?.message ? +new Date() : undefined; 45 | this.snackbar = prop ? { ...snackbar, snackbarId } : null; 46 | await wait(SNACKBAR_TIMEOUT); 47 | if (this.needsClear(snackbarId)) { 48 | this.snackbar = { message: '', snackbarId: undefined }; 49 | } 50 | } 51 | 52 | warn(message: string) { 53 | this.setSnackBar({ 54 | message, 55 | severity: 'warning', 56 | }); 57 | } 58 | 59 | setDialog(dialog: Dialog | null) { 60 | this.dialog = dialog; 61 | } 62 | 63 | needsClear(snackbarId: number) { 64 | return this.snackbar.snackbarId === snackbarId; 65 | } 66 | } 67 | 68 | export default ViewStore; 69 | -------------------------------------------------------------------------------- /frontend/src/styles/components/about/About.module.scss: -------------------------------------------------------------------------------- 1 | .aboutCard { 2 | padding: var(--card-padding); 3 | p { 4 | margin-top: 0; 5 | margin-bottom: var(--card-padding); 6 | line-height: var(--line-height); 7 | opacity: 0.9; 8 | } 9 | } 10 | 11 | .aboutHeader { 12 | font-size: 16px; 13 | font-weight: 500; 14 | margin-bottom: var(--card-padding); 15 | } 16 | 17 | .aboutTitle { 18 | border-bottom: 0; 19 | font-size: 20px; 20 | } 21 | -------------------------------------------------------------------------------- /frontend/src/styles/components/atoms/Badge.module.scss: -------------------------------------------------------------------------------- 1 | .badge { 2 | display: flex; 3 | flex-direction: row; 4 | align-items: center; 5 | justify-content: center; 6 | border-radius: 4px; 7 | height: 30px; 8 | color: white; 9 | font-size: 12px; 10 | font-weight: bold; 11 | } 12 | .badgeText { 13 | text-transform: capitalize; 14 | padding: 6px; 15 | } 16 | .badgeTextValue { 17 | padding: 0 4px; 18 | border-color: "white"; 19 | border-left: 1px solid white; 20 | color: white; 21 | } 22 | -------------------------------------------------------------------------------- /frontend/src/styles/components/atoms/CaptionDivider.module.scss: -------------------------------------------------------------------------------- 1 | .captionDivider { 2 | font-size: 14px; 3 | font-weight: 500; 4 | margin-left: 2px; 5 | :global(.MuiDivider-root) { 6 | flex: 1; 7 | margin-left: var(--card-padding); 8 | } 9 | :global(.caption) { 10 | color: var(--caption); 11 | font-size: 12px; 12 | margin-left: 6px; 13 | } 14 | } 15 | 16 | @media only screen and (max-width: 1260px) { 17 | .captionDivider { 18 | margin: 0 6px; 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /frontend/src/styles/components/atoms/CardHeader.module.scss: -------------------------------------------------------------------------------- 1 | .cardHeader { 2 | padding: 12px 16px; 3 | column-gap: 6px; 4 | svg { 5 | color: var(--text); 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /frontend/src/styles/components/atoms/GradeIndicator.module.scss: -------------------------------------------------------------------------------- 1 | .gradeIndicator { 2 | display: flex; 3 | align-items: center; 4 | justify-content: center; 5 | width: 32px; 6 | height: 32px; 7 | border-radius: 4px; 8 | font-weight: 800; 9 | } 10 | -------------------------------------------------------------------------------- /frontend/src/styles/components/atoms/Loading.module.scss: -------------------------------------------------------------------------------- 1 | .loadingView { 2 | display: flex; 3 | flex: 1; 4 | justify-content: center; 5 | align-items: center; 6 | z-index: 10; 7 | &:global(.fixed) { 8 | position: fixed; 9 | --loading-content-width: 20px; 10 | --loading-margin: calc(50% - var(--loading-content-width)); 11 | &:global(.logo) { 12 | --loading-content-width: 46px; 13 | } 14 | &:global(.padding) { 15 | --loading-margin: calc(50% - var(--loading-content-width) - var(--page-margin) / 2); 16 | } 17 | left: var(--loading-margin); 18 | top: var(--loading-margin); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /frontend/src/styles/components/atoms/Logo.module.scss: -------------------------------------------------------------------------------- 1 | .logoContainer { 2 | display: flex; 3 | align-items: center; 4 | font-family: "Pacifico", cursive; 5 | font-size: 22px; 6 | letter-spacing: 2px; 7 | cursor: pointer; 8 | color: var(--text); 9 | &.shine { 10 | font-size: 30px; 11 | animation: shine 2s infinite; 12 | } 13 | } 14 | 15 | @keyframes shine { 16 | 0% { 17 | opacity: 0.9; 18 | } 19 | 50% { 20 | opacity: 0.725; 21 | } 22 | 100% { 23 | opacity: 0.9; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /frontend/src/styles/components/atoms/TextField.module.scss: -------------------------------------------------------------------------------- 1 | .errorLabel { 2 | color: #f3284d; 3 | font-size: 14px; 4 | font-weight: 500; 5 | margin-left: 3px; 6 | } 7 | -------------------------------------------------------------------------------- /frontend/src/styles/components/atoms/TextIcon.module.scss: -------------------------------------------------------------------------------- 1 | .textIcon { 2 | font-weight: 500; 3 | text-transform: uppercase; 4 | } 5 | -------------------------------------------------------------------------------- /frontend/src/styles/components/home/HomePageTabs.module.scss: -------------------------------------------------------------------------------- 1 | .homeCourseContainer { 2 | height: fit-content; 3 | overflow: visible; 4 | .homeCourseListItem { 5 | height: auto; 6 | border-bottom: 1px solid var(--border); 7 | align-items: flex-start; 8 | } 9 | & > a { 10 | &:last-child { 11 | .homeCourseListItem { 12 | border-bottom: none; 13 | } 14 | } 15 | } 16 | } 17 | .homeCourseListItem { 18 | & > span { 19 | margin: 0 16px; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /frontend/src/styles/components/home/UserCard.module.scss: -------------------------------------------------------------------------------- 1 | .userCard { 2 | height: fit-content; 3 | } 4 | .userCardHeader { 5 | padding: var(--card-padding); 6 | .charIcon { 7 | background-color: var(--primary); 8 | color: white; 9 | text-transform: uppercase; 10 | } 11 | :global(.title) { 12 | font-size: 16px; 13 | margin-bottom: auto; 14 | span { 15 | margin-left: 8px; 16 | cursor: pointer; 17 | } 18 | } 19 | :global(.caption) { 20 | font-size: 12px; 21 | } 22 | button { 23 | margin-left: auto; 24 | color: var(--text); 25 | svg { 26 | font-size: 20px; 27 | } 28 | } 29 | } 30 | 31 | .userCardHeaderDetails { 32 | height: 36px; 33 | margin-left: 8px; 34 | } 35 | 36 | .userAboutCard { 37 | margin-top: var(--card-padding); 38 | padding: var(--card-padding); 39 | padding-top: 0; 40 | flex-direction: row; 41 | & > div { 42 | flex: 1; 43 | :global(.sub-title) { 44 | margin-bottom: 6px; 45 | } 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /frontend/src/styles/components/molecules/ChipsRow.module.scss: -------------------------------------------------------------------------------- 1 | .chipsRow { 2 | display: flex; 3 | flex-direction: row; 4 | overflow-x: scroll; 5 | width: 100%; 6 | flex-wrap: wrap; 7 | row-gap: 10px; 8 | column-gap: 10px; 9 | &::-webkit-scrollbar { 10 | display: none; 11 | } 12 | div { 13 | text-transform: capitalize; 14 | border: 1px solid var(--underlay); 15 | :global(.MuiChip-label) { 16 | padding-left: 10px; 17 | padding-right: 10px; 18 | } 19 | &:global(.active) { 20 | background-color: var(--default-button-background); 21 | color: white; 22 | border: none; 23 | } 24 | } 25 | } 26 | 27 | @media only screen and (max-width: 1260px) { 28 | .chipsRow { 29 | flex-wrap: nowrap; 30 | overflow-x: scroll; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /frontend/src/styles/components/molecules/ErrorCard.module.scss: -------------------------------------------------------------------------------- 1 | .errorCardContainer { 2 | padding: 5% 0; 3 | img { 4 | width: 35%; 5 | } 6 | :global(.caption) { 7 | padding-top: 4%; 8 | color: var(--caption-light); 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /frontend/src/styles/components/molecules/FeedCard.module.scss: -------------------------------------------------------------------------------- 1 | .feedCard { 2 | :global(.list-item-caption) { 3 | overflow: hidden; 4 | text-overflow: ellipsis; 5 | display: -webkit-box; 6 | -webkit-line-clamp: 1; 7 | -webkit-box-orient: vertical; 8 | margin-top: 5px; 9 | } 10 | :global(.list-item-title-container) { 11 | margin: 0 16px; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /frontend/src/styles/components/molecules/Footer.module.scss: -------------------------------------------------------------------------------- 1 | .footer { 2 | display: flex; 3 | flex-wrap: wrap; 4 | align-items: center; 5 | justify-content: center; 6 | } 7 | 8 | .footerLink { 9 | color: var(--caption); 10 | font-size: 12px; 11 | margin: 6px 12px; 12 | opacity: 0.6; 13 | transition: ease-in-out 0.1s opacity; 14 | &:hover { 15 | opacity: 1; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /frontend/src/styles/components/molecules/Link.module.scss: -------------------------------------------------------------------------------- 1 | .truncate { 2 | white-space: nowrap; 3 | overflow: hidden; 4 | text-overflow: ellipsis; 5 | } 6 | 7 | .linkContainer { 8 | svg { 9 | margin-right: 8px; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /frontend/src/styles/components/molecules/SearchInput.module.scss: -------------------------------------------------------------------------------- 1 | .searchInputContainer { 2 | display: flex; 3 | align-items: center; 4 | border-radius: 6px; 5 | background-color: var(--background-search); 6 | height: calc(var(--header-height) - 20px); 7 | padding: 0 10px; 8 | width: 220px; 9 | transition: width 300ms cubic-bezier(0.7, 0, 0.3, 1) 0ms; 10 | } 11 | 12 | .active { 13 | width: calc(var(--searchDropdown-width) - 20px); 14 | border-bottom-left-radius: 0; 15 | border-bottom-right-radius: 0; 16 | input { 17 | width: 100%; 18 | } 19 | } 20 | 21 | @media only screen and (max-width: 1260px) { 22 | .searchInputContainer { 23 | border: none; 24 | margin-right: 0; 25 | background-color: transparent; 26 | width: 34px; 27 | } 28 | .active { 29 | width: calc(100vw - 40px); 30 | margin: 0 10px 10px 10px; 31 | height: 42px; 32 | background-color: var(--background-search); 33 | border-bottom-left-radius: var(--border-radius); 34 | border-bottom-right-radius: var(--border-radius); 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /frontend/src/styles/components/molecules/Section.module.scss: -------------------------------------------------------------------------------- 1 | .sectionContainer { 2 | display: flex; 3 | flex-direction: column; 4 | width: 100%; 5 | :global(.label) { 6 | font-weight: 500; 7 | color: var(--text); 8 | font-size: 14px; 9 | margin-bottom: 8px; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /frontend/src/styles/components/molecules/SectionGroup.module.scss: -------------------------------------------------------------------------------- 1 | .selectionGroup { 2 | background-color: var(--background-secondary); 3 | border-radius: 4px; 4 | margin-right: 4px; 5 | overflow: hidden; 6 | } 7 | 8 | .selectionItem { 9 | display: flex; 10 | align-items: center; 11 | justify-content: center; 12 | cursor: pointer; 13 | height: 26px; 14 | width: 26px; 15 | opacity: 0.4; 16 | &:global(.selected) { 17 | color: white; 18 | font-weight: 500; 19 | opacity: 1; 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /frontend/src/styles/components/molecules/ShowMoreOverlay.module.scss: -------------------------------------------------------------------------------- 1 | .showMoreOverlay { 2 | display: flex; 3 | align-items: center; 4 | justify-content: center; 5 | width: 100%; 6 | height: 102px; 7 | background-image: linear-gradient( 8 | to bottom, 9 | rgba(255, 255, 255, 0.18) 0, 10 | rgba(255, 255, 255, 1) 100%, 11 | rgba(255, 255, 255, 1) 100% 12 | ); 13 | z-index: 3; 14 | cursor: pointer; 15 | position: absolute; 16 | bottom: 0; 17 | } 18 | 19 | .showMoreLabel { 20 | margin-top: 80px; 21 | cursor: pointer; 22 | color: var(--primary); 23 | justify-content: center; 24 | font-size: 14px; 25 | } 26 | 27 | @media (prefers-color-scheme: dark) { 28 | .showMoreOverlay { 29 | background-image: linear-gradient(to bottom, rgba(31, 31, 31, 0.2) 0, var(--surface) 100%); 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /frontend/src/styles/components/molecules/SnackBar.module.scss: -------------------------------------------------------------------------------- 1 | .snackbarContainer { 2 | position: fixed; 3 | z-index: 2000; 4 | bottom: 30px; 5 | margin-left: auto; 6 | margin-right: auto; 7 | width: fit-content; 8 | right: 2.5%; 9 | left: 2.5%; 10 | align-items: center; 11 | :global(.MuiAlert-action) { 12 | padding: 0 0 0 16px; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /frontend/src/styles/components/organisms/Header.module.scss: -------------------------------------------------------------------------------- 1 | .headerBgContainer { 2 | background-color: var(--surface); 3 | position: sticky; 4 | top: 0; 5 | z-index: 10; 6 | max-height: var(--header-height); 7 | } 8 | 9 | .headerContainer { 10 | --searchDropdown-width: 370px; 11 | height: var(--header-height); 12 | min-height: var(--header-content-height); 13 | justify-content: space-between; 14 | max-width: calc(var(--max-width) - 2 * var(--page-margin)); 15 | margin-left: auto; 16 | margin-right: auto; 17 | & > .headerLogo { 18 | display: flex; 19 | align-items: center; 20 | color: var(--reversed-text); 21 | opacity: 1; 22 | } 23 | } 24 | 25 | .headerNav { 26 | margin-left: auto; 27 | } 28 | 29 | .navLabelContainer { 30 | &:first-of-type { 31 | margin-left: var(--card-padding); 32 | } 33 | svg { 34 | font-size: 24px; 35 | margin-bottom: 3px; 36 | } 37 | align-items: center; 38 | width: 86px; 39 | font-weight: 500; 40 | font-size: 12px; 41 | transition: all 0.1s ease-in-out; 42 | text-transform: capitalize; 43 | opacity: 0.6; 44 | &:hover { 45 | opacity: 1; 46 | } 47 | &:global(.active) { 48 | opacity: 1; 49 | border-bottom: 2px solid var(--text); 50 | } 51 | } 52 | 53 | @media only screen and (max-width: 1260px) { 54 | .headerContainer { 55 | padding: 0 var(--card-padding); 56 | max-width: 100%; 57 | svg { 58 | color: var(--text); 59 | } 60 | } 61 | .headerSearchDropdown { 62 | position: absolute; 63 | left: 0; 64 | right: 0; 65 | top: 0; 66 | width: fit-content; 67 | margin-left: auto; 68 | .search-input { 69 | visibility: hidden; 70 | } 71 | } 72 | .headerLogo { 73 | margin-left: 12px; 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /frontend/src/styles/components/organisms/SearchDropdown.module.scss: -------------------------------------------------------------------------------- 1 | .searchDropdown { 2 | --searchDropdown-width: 370px; 3 | height: fit-content; 4 | flex-direction: column; 5 | margin: 10px 0; 6 | align-items: flex-end; 7 | z-index: 2; 8 | :global(.searchPanel) { 9 | --searchPanel-width: calc(var(--searchDropdown-width) - 2px); // 2px for border 10 | border-radius: 0; 11 | border-top: 0; 12 | :global(.searchPanelInputContainer) { 13 | display: none; 14 | } 15 | :global(.MuiDivider-root) { 16 | &:first-of-type { 17 | display: none; 18 | } 19 | } 20 | } 21 | } 22 | 23 | @media only screen and (max-width: 1260px) { 24 | .searchDropdown { 25 | --searchDropdown-width: 100vw; 26 | margin-left: auto; 27 | :global(.searchPanel) { 28 | display: initial; 29 | top: 0; 30 | } 31 | } 32 | .active { 33 | width: 100vw; 34 | overflow-x: hidden; 35 | height: fit-content; 36 | margin-left: initial; 37 | background-color: var(--surface); 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /frontend/src/styles/components/organisms/SearchPanel.module.scss: -------------------------------------------------------------------------------- 1 | .searchLoading { 2 | svg { 3 | color: var(--accent); 4 | } 5 | } 6 | 7 | .searchPanel { 8 | --searchPanel-width: 326px; 9 | width: var(--searchPanel-width); 10 | min-width: var(--searchPanel-width); 11 | border-right: 1px solid var(--border); 12 | position: sticky; 13 | top: calc(var(--header-height) + var(--page-margin)); 14 | left: 0; 15 | height: auto; 16 | align-self: flex-start; 17 | max-height: calc(100vh - var(--header-height) - 2 * var(--page-margin) - 2px); 18 | overflow-y: scroll; 19 | overflow-x: hidden; 20 | svg { 21 | color: var(--text); 22 | } 23 | &::-webkit-scrollbar { 24 | display: none; 25 | } 26 | .searchPanelInputContainer { 27 | background-color: var(--surface); 28 | padding: 6px 16px; 29 | position: sticky; 30 | top: 0; 31 | z-index: 2; 32 | align-items: center; 33 | } 34 | .recentChips { 35 | margin: 0 var(--card-padding) 6px var(--card-padding); 36 | width: fit-content; 37 | } 38 | } 39 | 40 | .searchPanelInputContainer { 41 | .goBackBtn { 42 | margin-left: -4px; 43 | height: 30px; 44 | width: 30px; 45 | } 46 | } 47 | 48 | .goBackBtn, 49 | .searchIcon { 50 | margin-right: 6px; 51 | } 52 | 53 | .searchForm { 54 | display: flex; 55 | flex: 1; 56 | } 57 | 58 | .search-input { 59 | flex: 1; 60 | } 61 | 62 | .codeList { 63 | display: grid; 64 | grid-template-columns: repeat(2, 1fr); 65 | overflow-x: hidden; 66 | } 67 | 68 | .searchListItem { 69 | height: auto; 70 | justify-content: space-between; 71 | position: relative; 72 | flex: 1; 73 | :global(.list-item-title-container) { 74 | margin-left: 24px; 75 | } 76 | :global(.list-item-color-bar) { 77 | position: absolute; 78 | } 79 | } 80 | 81 | @media only screen and (max-width: 1260px) { 82 | .searchPanel { 83 | display: none; 84 | border-right: 0; 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /frontend/src/styles/components/planner/CourseCard.module.scss: -------------------------------------------------------------------------------- 1 | .timetableCourseCard { 2 | display: flex; 3 | flex-direction: column; 4 | position: absolute; 5 | overflow: hidden; 6 | border-radius: 4px; 7 | z-index: 1; 8 | background-color: var(--background); 9 | transform: scale(1); 10 | transition: transform 0.3s; 11 | user-select: none; 12 | padding: 4px 6px; 13 | .delete { 14 | display: none; 15 | } 16 | &:hover { 17 | height: fit-content !important; 18 | transform: scale(1.2); 19 | z-index: 10; 20 | .delete { 21 | display: inline-flex; 22 | position: absolute; 23 | z-index: 12; 24 | bottom: 2px; 25 | right: 2px; 26 | svg { 27 | width: 18px; 28 | height: 18px; 29 | } 30 | } 31 | } 32 | } 33 | 34 | .timetableCourseCardTitle { 35 | display: flex; 36 | font-size: 12px; 37 | font-weight: bold; 38 | text-align: initial; 39 | } 40 | 41 | .timetableCourseCardLocation { 42 | margin-top: 2px; 43 | font-size: 10px; 44 | text-align: initial; 45 | } 46 | -------------------------------------------------------------------------------- /frontend/src/styles/components/planner/PlannerCart.module.scss: -------------------------------------------------------------------------------- 1 | .plannerCart { 2 | width: 280px; 3 | height: fit-content; 4 | max-height: calc(100vh - var(--header-height) - 2 * var(--page-margin)); 5 | overflow-y: scroll; 6 | &::-webkit-scrollbar { 7 | display: none; 8 | } 9 | header { 10 | border-top-left-radius: var(--border-radius); 11 | border-top-right-radius: var(--border-radius); 12 | padding: 8px var(--card-padding) 0 var(--card-padding); 13 | position: sticky; 14 | top: 0; 15 | background-color: var(--surface); 16 | z-index: 1; 17 | & > div:first-child { 18 | height: 30px; 19 | } 20 | button { 21 | margin-left: auto; 22 | color: var(--text); 23 | } 24 | } 25 | :global(.list-item-container) { 26 | height: fit-content; 27 | justify-content: space-between; 28 | position: relative; 29 | --list-item-inner-padding: var(--card-padding); 30 | .plannerCartListIcon { 31 | margin-right: 8px; 32 | margin-left: 16px; 33 | font-size: 1.2rem; 34 | color: var(--error); 35 | &.alert { 36 | color: rgb(255, 196, 0); 37 | } 38 | } 39 | .plannerCartCheckbox { 40 | margin-left: 6px; 41 | } 42 | :global(.list-item-title-container) { 43 | margin-left: 6px; 44 | } 45 | &:last-child { 46 | border-bottom: none; 47 | } 48 | } 49 | } 50 | 51 | .timetableInfoRow { 52 | margin: 10px 0 14px 0; 53 | span { 54 | flex: 1; 55 | } 56 | :global(.sub-title) { 57 | margin-bottom: 4px; 58 | } 59 | } 60 | 61 | @media only screen and (max-width: 1260px) { 62 | .plannerCart { 63 | height: auto; 64 | width: 100%; 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /frontend/src/styles/components/planner/PlannerTimetable.module.scss: -------------------------------------------------------------------------------- 1 | .plannerFooter { 2 | margin-top: 4px; 3 | } 4 | 5 | .plannerShareDialog { 6 | .shareLinkRow { 7 | height: 46px; 8 | :global(.copy) { 9 | height: 100%; 10 | margin-left: 6px; 11 | width: 72px; 12 | } 13 | } 14 | } 15 | 16 | .plannerInputContainer { 17 | flex: 1; 18 | } 19 | 20 | @media only screen and (max-width: 1260px) { 21 | .plannerTimetableContainer { 22 | max-width: 100vw; 23 | width: 100%; 24 | } 25 | .plannerFooter { 26 | display: none; 27 | } 28 | .plannerInputContainer { 29 | width: 100%; 30 | } 31 | .plannerShareDialog { 32 | :global(.MuiPaper-root) { 33 | margin: 0; 34 | } 35 | .contentContainer { 36 | width: calc(90vw - 2 * var(--card-padding)); 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /frontend/src/styles/components/planner/Timetable.module.scss: -------------------------------------------------------------------------------- 1 | .timetableContainer { 2 | display: flex; 3 | flex: 1; 4 | flex-direction: column; 5 | --left-bar-width: 42px; 6 | margin: 16px 0; 7 | } 8 | .weekdayRow { 9 | display: flex; 10 | margin-left: var(--left-bar-width); 11 | margin-bottom: 10px; 12 | font-size: 14px; 13 | div { 14 | display: flex; 15 | align-items: center; 16 | justify-content: center; 17 | flex: 1; 18 | } 19 | .weekdayText { 20 | color: var(--text); 21 | opacity: 1; 22 | :global(.caption) { 23 | opacity: 1; 24 | font-size: 11px; 25 | margin-top: 4px; 26 | opacity: 0.7; 27 | } 28 | } 29 | } 30 | .timetableCanvas { 31 | display: flex; 32 | flex: 1; 33 | } 34 | .timetableTicks { 35 | display: flex; 36 | flex-direction: column; 37 | width: calc(var(--left-bar-width) - 10px); 38 | font-size: 12px; 39 | padding-right: 10px; 40 | text-align: right; 41 | opacity: 0.7; 42 | .timeLineBox { 43 | flex: 1; 44 | line-height: 14px; 45 | margin-top: -7px; 46 | } 47 | } 48 | .timetableCoursesContainer { 49 | flex: 1; 50 | position: relative; 51 | display: flex; 52 | border-right: 1px solid var(--border); 53 | border-bottom: 1px solid var(--border); 54 | } 55 | 56 | @media only screen and (max-width: 560px) { 57 | .timetableContainer { 58 | --left-bar-width: 42px; 59 | width: 126vw; 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /frontend/src/styles/components/planner/TimetableOverview.module.scss: -------------------------------------------------------------------------------- 1 | .ttOverviewListItem { 2 | flex-direction: row; 3 | :global(.list-item-title-container) { 4 | margin-left: 0; 5 | :global(.caption) { 6 | display: flex; 7 | align-items: center; 8 | svg { 9 | margin-left: 4px; 10 | margin-right: 4px; 11 | font-size: 16px; 12 | } 13 | } 14 | } 15 | .btnContainer { 16 | svg { 17 | padding: 4px; 18 | } 19 | } 20 | } 21 | 22 | .menuItem { 23 | &:global(.MuiMenuItem-root) { 24 | padding-top: 0; 25 | padding-bottom: 0; 26 | } 27 | } 28 | 29 | .timetableSelectionMenu { 30 | --menu-padding-horizontal: 16px; 31 | --menu-padding-vertical: 6px; 32 | & :global(.MuiPaper-root) { 33 | // Menu contianer 34 | :global(.MuiSvgIcon-root) { 35 | // may not working for nested global? 36 | font-size: 22px; 37 | } 38 | :global(.MuiList-root) { 39 | padding: 0; 40 | } 41 | h4 { 42 | margin: 12px var(--menu-padding-horizontal); 43 | color: var(--primary); 44 | } 45 | } 46 | & .timetableLabelInputContainer { 47 | margin: 0 var(--menu-padding-horizontal) var(--menu-padding-vertical) var(--menu-padding-horizontal); 48 | > :global(.MuiInputBase-root) { 49 | flex: 1; 50 | } 51 | } 52 | & :global(.MuiButtonBase-root):last-child { 53 | color: var(--primary); 54 | font-weight: bold; 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /frontend/src/styles/components/review/CourseComments.module.scss: -------------------------------------------------------------------------------- 1 | .forumTextIcon { 2 | margin-right: var(--message-spacing); 3 | } 4 | 5 | .message { 6 | --message-spacing: 10px; 7 | } 8 | 9 | .messageUsername { 10 | margin: 0px var(--message-spacing) 6px 0; 11 | font-weight: 500; 12 | font-size: 14px; 13 | :global(.caption) { 14 | margin-left: 4px; 15 | font-size: 13px; 16 | } 17 | } 18 | 19 | .messageText { 20 | font-size: 14px; 21 | font-weight: normal; 22 | } 23 | 24 | .messagesContainer { 25 | row-gap: 20px; 26 | .loadMoreBtn { 27 | margin-right: auto; 28 | margin-left: auto; 29 | &:global(.MuiButton-text) { 30 | color: var(--primary); 31 | font-size: 12px; 32 | } 33 | border: 2px solid var(--primary); 34 | } 35 | } 36 | 37 | .messageInputContainer { 38 | position: sticky; 39 | bottom: 8px; 40 | margin-top: 10px; 41 | width: inherit; 42 | padding: 0 var(--card-padding); 43 | input { 44 | flex: 1; 45 | background-color: transparent; 46 | padding-left: 0; 47 | height: 58px; 48 | } 49 | :global(.MuiIconButton-root) { 50 | margin-right: 4px; 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /frontend/src/styles/components/review/CoursePanel.module.scss: -------------------------------------------------------------------------------- 1 | .coursePanelContainer { 2 | height: fit-content; 3 | position: relative; 4 | } 5 | 6 | .reviewPage { 7 | grid-template-columns: minmax(0, 1fr) min-content; 8 | } 9 | 10 | .courseCard { 11 | margin: var(--card-padding); 12 | } 13 | 14 | .coursePanel { 15 | position: relative; 16 | padding: 0; 17 | :global(.tabs-row) { 18 | border: 0px; 19 | border-radius: 0; 20 | } 21 | > .courseCard, 22 | .reviewEdit { 23 | margin: var(--card-padding); 24 | } 25 | p { 26 | line-height: var(--line-height); 27 | } 28 | } 29 | 30 | .coursePanelFab { 31 | position: fixed; 32 | right: calc((100vw - var(--max-width)) / 2 + 32px); 33 | bottom: 20px; 34 | } 35 | 36 | .gradeRow.standalone { 37 | width: var(--second-column-width); 38 | height: 120px; 39 | background-color: var(--surface); 40 | } 41 | 42 | @media only screen and (max-width: 1260px) { 43 | .coursePanel { 44 | max-height: none; 45 | } 46 | .coursePanelFab { 47 | right: 20px; 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /frontend/src/styles/components/review/CourseReviews.module.scss: -------------------------------------------------------------------------------- 1 | .floatReviewFilter { 2 | position: fixed; 3 | z-index: 20; 4 | bottom: 0; 5 | right: 0; 6 | width: inherit; 7 | box-shadow: 0 4px 10px rgba(46, 43, 43, 0.1); 8 | } 9 | -------------------------------------------------------------------------------- /frontend/src/styles/components/review/CourseSections.module.scss: -------------------------------------------------------------------------------- 1 | .courseSections { 2 | display: flex; 3 | flex-direction: column; 4 | width: 100%; 5 | } 6 | .courseSectionWrapper { 7 | display: flex; 8 | flex-direction: column; 9 | width: 100%; 10 | } 11 | .courseTermLabel { 12 | display: flex; 13 | font-size: 14px; 14 | font-weight: bold; 15 | width: 100%; 16 | margin-bottom: 4px; 17 | } 18 | .courseSectionCard { 19 | padding: 12px; 20 | background-color: var(--background-secondary); 21 | border-radius: 12px; 22 | margin-top: 12px; 23 | } 24 | .sectionHeader { 25 | display: flex; 26 | justify-content: space-between; 27 | align-items: center; 28 | margin-left: 4px; 29 | & > button :global(.MuiSvgIcon-root) { 30 | font-size: 20px; 31 | color: var(--text); 32 | } 33 | } 34 | .sectionDetail { 35 | display: flex; 36 | flex-wrap: wrap; 37 | justify-content: space-between; 38 | } 39 | .sectionDetailItem { 40 | min-width: 50%; 41 | display: flex; 42 | align-items: center; 43 | margin-top: 10px; 44 | font-size: 12px; 45 | svg { 46 | margin-right: 6px; 47 | color: var(--text); 48 | opacity: 0.7; 49 | font-size: 16px; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /frontend/src/styles/components/review/GradeRow.module.scss: -------------------------------------------------------------------------------- 1 | .gradeRow { 2 | display: flex; 3 | align-items: center; 4 | overflow: hidden; 5 | background-color: var(--background-secondary); 6 | border-radius: var(--border-radius); 7 | flex-wrap: wrap; 8 | svg { 9 | color: #3b3b3bbf; 10 | } 11 | } 12 | 13 | .reviewsFilterGradeIndicator { 14 | border-radius: 8px; 15 | height: auto; 16 | width: auto; 17 | padding: 2px 6px; 18 | min-width: 28px; 19 | font-size: 14px; 20 | } 21 | 22 | .ratingContainer { 23 | cursor: default; 24 | padding: 0 4px; 25 | height: 30px; 26 | } 27 | 28 | .reviewsFilterLabel { 29 | margin: 0 10px 0 0; 30 | } 31 | 32 | .concise { 33 | margin-bottom: var(--card-padding); 34 | height: auto; 35 | flex-wrap: wrap; 36 | justify-content: flex-start; 37 | > div { 38 | flex: 1; 39 | padding: 0 10px; 40 | justify-content: space-between; 41 | margin: 6px; 42 | } 43 | > div:first-child { 44 | display: none; 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /frontend/src/styles/components/review/LikeButtonRow.module.scss: -------------------------------------------------------------------------------- 1 | .likeBtn { 2 | height: 30px; 3 | align-items: center; 4 | justify-content: center; 5 | flex-direction: row; 6 | opacity: 0.8; 7 | } 8 | .likeBtnRow { 9 | background-color: var(--background); 10 | border-radius: 50px; 11 | overflow: hidden; 12 | margin-left: auto; 13 | & > .likeBtn { 14 | border-radius: 0; 15 | &:global(.active.Mui-disabled) { 16 | svg { 17 | color: var(--primary); 18 | } 19 | color: var(--primary); 20 | opacity: 1; 21 | border: none; 22 | } 23 | } 24 | :global(.MuiDivider-root) { 25 | height: 20px; 26 | } 27 | & > :global(.MuiButton-root) { 28 | &:global(.Mui-disabled) { 29 | color: var(--text); 30 | opacity: 0.8; 31 | } 32 | } 33 | } 34 | 35 | @media only screen and (max-width: 1260px) { 36 | .likeBtnRow { 37 | margin-left: 6px; 38 | } 39 | } -------------------------------------------------------------------------------- /frontend/src/styles/components/review/ReviewFilterBar.module.scss: -------------------------------------------------------------------------------- 1 | 2 | .reviewsFilter { 3 | display: flex; 4 | padding: 12px 8px; 5 | align-items: center; 6 | flex-wrap: wrap; 7 | justify-content: space-between; 8 | svg { 9 | color: var(--text); 10 | } 11 | flex-direction: row; 12 | .filterRow { 13 | .reviewsFilterLabel { 14 | margin: 0; 15 | font-weight: 500; 16 | } 17 | } 18 | button { 19 | &.edit { 20 | font-size: 18px; 21 | width: 32px; 22 | height: 32px; 23 | } 24 | } 25 | } 26 | .reviewsFilter-ratings { 27 | align-items: center; 28 | } 29 | 30 | @media only screen and (max-width: 1260px) { 31 | .reviewsFilter { 32 | padding: 12px var(--card-padding); 33 | } 34 | .filterRow { 35 | button { 36 | :global(.MuiButton-endIcon) { 37 | margin-left: 0; 38 | } 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /frontend/src/styles/components/review/ReviewPage.module.scss: -------------------------------------------------------------------------------- 1 | .reviewPage { 2 | grid-template-columns: minmax(0, 1fr) min-content; 3 | } 4 | .reviewHomePanel { 5 | padding: 0; 6 | grid-auto-rows: min-content; 7 | } 8 | .rankingCard { 9 | overflow: hidden; 10 | border-bottom: 0; 11 | width: 100%; 12 | & > * { 13 | border-bottom: 1px solid var(--border); 14 | } 15 | &:first-child { 16 | margin-left: 0; 17 | } 18 | :global(.list-item-container) { 19 | height: auto; 20 | border-bottom: none; 21 | border-right: none; 22 | :global(.list-item-title-container) { 23 | margin-left: 0; 24 | } 25 | & > :last-child { 26 | margin-right: 8px; 27 | margin-bottom: 0; 28 | } 29 | } 30 | .gradeIndicator { 31 | width: 56px; 32 | height: 28px; 33 | } 34 | } 35 | 36 | .rankingLabel { 37 | width: 40px; 38 | opacity: 0.5; 39 | } 40 | 41 | .sortMenu { 42 | text-transform: capitalize; 43 | } 44 | 45 | .recentReview { 46 | --recentReview-width: calc(100% - 2 * var(--card-padding)); 47 | width: var(--recentReview-width); 48 | padding: 0 var(--card-padding) 12px var(--card-padding); 49 | :global(.list-item-title-container) { 50 | margin-left: 0; 51 | :global(.title) { 52 | color: var(--primary); 53 | } 54 | } 55 | .recentReviewText { 56 | line-height: var(--line-height); 57 | width: 100%; 58 | white-space: pre-line; 59 | -webkit-line-clamp: 3; 60 | } 61 | .gradeIndicator { 62 | width: 36px; 63 | height: 36px; 64 | } 65 | } 66 | 67 | @media only screen and (max-width: 1260px) { 68 | .homeChipsRow { 69 | padding: 0 8px; 70 | width: calc(100% - 2 * 8px); 71 | } 72 | .rankingCard { 73 | width: 100%; 74 | &:last-child { 75 | margin-left: 0; 76 | margin-top: var(--card-padding); 77 | } 78 | } 79 | .recentReviewText { 80 | max-width: calc(100vw - 2 * var(--card-padding)); 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /frontend/src/styles/components/templates/Dialog.module.scss: -------------------------------------------------------------------------------- 1 | .logOutRow { 2 | width: calc(100% - 24px); 3 | padding: 12px 0 12px 14px; 4 | height: 32px; 5 | :global(.title) { 6 | font-weight: normal; 7 | font-size: 16px; 8 | } 9 | } 10 | 11 | .dialogDescription { 12 | height: 36px; 13 | font-size: 14px; 14 | } 15 | 16 | .globalModalContainer { 17 | :global(.MuiDialog-paper) { 18 | min-width: 280px; 19 | overflow-x: hidden; 20 | } 21 | } 22 | 23 | @media only screen and (max-width: 1260px) { 24 | .issueChipsRow { 25 | flex-wrap: wrap; 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /frontend/src/styles/components/templates/TimetablePanel.module.scss: -------------------------------------------------------------------------------- 1 | .timetablePanel { 2 | & > header { 3 | justify-content: space-between; 4 | :global(.title) { 5 | font-size: 18px; 6 | } 7 | .btnRow { 8 | & > button { 9 | margin-left: 24px; 10 | color: var(--text); 11 | :global(.MuiButton-label) { 12 | text-transform: capitalize; 13 | } 14 | :global(.MuiSvgIcon-root) { 15 | font-size: 22px; 16 | } 17 | } 18 | } 19 | } 20 | } 21 | 22 | // Animation 23 | 24 | .iconSpin { 25 | -webkit-animation: iconSpin 1s infinite linear; 26 | animation: iconSpin 1s infinite linear; 27 | } 28 | 29 | @-webkit-keyframes iconSpin { 30 | 0% { 31 | -webkit-transform: rotate(0deg); 32 | transform: rotate(0deg); 33 | } 34 | 100% { 35 | -webkit-transform: rotate(359deg); 36 | transform: rotate(359deg); 37 | } 38 | } 39 | 40 | @keyframes iconSpin { 41 | 0% { 42 | -webkit-transform: rotate(0deg); 43 | transform: rotate(0deg); 44 | } 45 | 100% { 46 | -webkit-transform: rotate(359deg); 47 | transform: rotate(359deg); 48 | } 49 | } 50 | 51 | @media only screen and (max-width: 1260px) { 52 | .timetablePanel { 53 | padding: var(--card-padding); 54 | overflow-x: scroll; 55 | & > header { 56 | position: sticky; 57 | top: 0; 58 | left: 0; 59 | } 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /frontend/src/styles/pages/AboutPage.module.scss: -------------------------------------------------------------------------------- 1 | .aboutPage { 2 | background-color: var(--background); 3 | height: 10%; 4 | grid-template-columns: none; 5 | } 6 | 7 | .content { 8 | grid-auto-rows: min-content; 9 | } 10 | -------------------------------------------------------------------------------- /frontend/src/styles/pages/HomePage.module.scss: -------------------------------------------------------------------------------- 1 | .homePage { 2 | background-color: var(--background); 3 | height: 100%; 4 | } 5 | 6 | .homePageLeft { 7 | height: 0; 8 | width: 300px; 9 | position: sticky; 10 | top: calc(var(--header-height) + var(--page-margin)); 11 | } 12 | 13 | .homePageRight { 14 | flex: 1; 15 | height: fit-content; 16 | } 17 | 18 | .homePageTimetable { 19 | min-height: calc(100vh - 202px); 20 | margin-left: 0; 21 | } 22 | 23 | .linksCard { 24 | padding: var(--card-padding); 25 | row-gap: 8px; 26 | :global(.sub-title) { 27 | margin-bottom: 8px; 28 | } 29 | } 30 | 31 | .homeLinkContainer { 32 | font-size: 14px; 33 | &:hover { 34 | color: var(--primary); 35 | } 36 | svg { 37 | margin-right: 8px; 38 | } 39 | } 40 | 41 | @media only screen and (max-width: 1260px) { 42 | .homePage { 43 | :global(.center-page) { 44 | flex-direction: column; 45 | overflow-y: visible; 46 | overflow-x: hidden; 47 | } 48 | :global(.tabs-row) { 49 | height: auto; 50 | flex-shrink: 0; 51 | width: 100%; 52 | flex-wrap: nowrap; 53 | overflow-x: scroll; 54 | } 55 | } 56 | .homePageLeft { 57 | position: relative; 58 | height: fit-content; 59 | width: 100%; 60 | top: 0; 61 | } 62 | .homePageRight { 63 | width: 100%; 64 | margin-top: var(--card-padding); 65 | margin-left: 0; 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /frontend/src/styles/pages/PlannerPage.module.scss: -------------------------------------------------------------------------------- 1 | .plannerPage { 2 | min-height: calc(100vh - var(--header-height) - 2 * var(--page-margin)); 3 | grid-template-columns: min-content 1fr min-content; 4 | } 5 | 6 | @media only screen and (max-width: 1260px) { 7 | .plannerPage { 8 | display: flex; 9 | min-height: calc(100vh - var(--header-height) - 2px); 10 | height: fit-content; 11 | width: 100vw; 12 | } 13 | .plannerDraggableFabContainer:global(.MuiButtonBase-root) { 14 | position: fixed; 15 | z-index: 12; 16 | bottom: 5vh; 17 | right: 5vw; 18 | width: 48px; 19 | height: 48px; 20 | svg { 21 | font-size: 22px; 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /frontend/src/styles/pages/login.module.scss: -------------------------------------------------------------------------------- 1 | .loginPage { 2 | flex: 1; 3 | } 4 | 5 | .loginPanel { 6 | margin: auto; 7 | background-color: var(--surface); 8 | border-radius: var(--border-radius); 9 | padding: 36px; 10 | height: auto; 11 | width: 50%; 12 | max-width: 400px; 13 | font-size: 16px; 14 | h2 { 15 | margin-top: 0; 16 | margin-bottom: 0; 17 | } 18 | :global(.title) { 19 | margin-bottom: 10px; 20 | } 21 | :global(.caption) { 22 | opacity: 0.6; 23 | line-height: 24px; 24 | } 25 | .goBackIcon { 26 | color: var(--text); 27 | margin: 0 10px 0 -16px; 28 | } 29 | :global(.textFieldLabel) { 30 | margin-left: 0; 31 | color: var(--text); 32 | margin-bottom: -6px; 33 | cursor: initial; 34 | } 35 | } 36 | .label { 37 | font-size: 14px; 38 | cursor: pointer; 39 | margin-left: 12px; 40 | font-weight: bold; 41 | } 42 | .loginInputContainer { 43 | margin-top: 0; 44 | } 45 | .titleRow { 46 | margin-bottom: 8px; 47 | } 48 | .forgotPwdRow { 49 | margin-top: 4px; 50 | align-items: center; 51 | flex-direction: row; 52 | justify-content: space-between; 53 | > .forgotPwdLabel { 54 | margin-left: auto; 55 | } 56 | } 57 | .loginBtn { 58 | height: 50px; 59 | margin-top: 10px !important; 60 | border-radius: var(--border-radius) !important; 61 | background: var(--default-button-background) !important; 62 | box-shadow: none !important; 63 | & > :global(.MuiButton-label) { 64 | font-weight: bold; 65 | text-transform: capitalize; 66 | font-size: 18px; 67 | } 68 | :global(.MuiCircularProgress-svg) { 69 | color: white; 70 | } 71 | } 72 | .switchContainer { 73 | margin-top: 10px; 74 | font-size: 14px; 75 | } 76 | 77 | @media only screen and (max-width: 1260px) { 78 | .loginPage { 79 | background-color: var(--surface); 80 | } 81 | .loginPanel { 82 | width: calc(100% - 72px); 83 | padding: 0; 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /frontend/src/types/courses.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CourseDataRaw, 3 | CourseRating, 4 | Term, 5 | PlannerRaw, 6 | CourseSection, 7 | } from 'cutopia-types'; 8 | import { TimetableOverviewMode } from './enums'; 9 | import { TbaSections } from './events'; 10 | 11 | export type Subject = { 12 | name: string; 13 | courses: CourseWithRating[]; 14 | }; 15 | 16 | export interface CourseWithRating extends CourseDataRaw { 17 | subject: Subject; 18 | terms?: Term[]; 19 | rating?: CourseRating; 20 | reviewLecturers?: string[]; 21 | reviewTerms?: string[]; 22 | } 23 | 24 | export interface CourseInfo extends CourseWithRating { 25 | courseId: string; 26 | sections?: CourseSection[]; // For planner use 27 | } 28 | 29 | export interface Planner extends PlannerRaw { 30 | type?: TimetableOverviewMode; 31 | } 32 | 33 | export type TimetableInfo = { 34 | totalCredits: number; 35 | averageHour: number; 36 | weekdayAverageHour: Record; 37 | tbaSections: TbaSections; 38 | }; 39 | 40 | export type DepartmentCourses = { 41 | [department: string]: CourseSearchItem[]; 42 | }; 43 | 44 | export type CourseSearchItem = { 45 | c: string; 46 | t: string; 47 | o?: number; 48 | }; 49 | 50 | export type CourseConcise = { 51 | courseId: string; 52 | title: string; 53 | }; 54 | -------------------------------------------------------------------------------- /frontend/src/types/discussions.ts: -------------------------------------------------------------------------------- 1 | import { DiscussionMessageBase } from 'cutopia-types'; 2 | 3 | export interface DiscussionMessage extends DiscussionMessageBase { 4 | id?: number; 5 | _id?: number; 6 | } 7 | -------------------------------------------------------------------------------- /frontend/src/types/enums.ts: -------------------------------------------------------------------------------- 1 | export enum LoginPageMode { 2 | CUSIS, 3 | CUTOPIA_LOGIN, 4 | CUTOPIA_SIGNUP, 5 | VERIFY, 6 | RESET_PASSWORD, 7 | RESET_PASSWORD_VERIFY, 8 | } 9 | 10 | export enum AuthState { 11 | INIT, 12 | LOGGED_OUT, 13 | LOADING, 14 | LOGGED_IN, 15 | } 16 | 17 | export enum ErrorCardMode { 18 | NULL, 19 | ERROR, 20 | } 21 | 22 | export enum TimetableOverviewMode { 23 | UPLOAD, 24 | SHARE, 25 | UPLOAD_SHARABLE, 26 | } 27 | 28 | export enum ShareTimetableMode { 29 | UPLOAD, // user persist timetable / persist sharing ttb 30 | SHARE, 31 | } 32 | 33 | export enum PlannerSyncState { 34 | DIRTY, 35 | SYNCING, 36 | SYNCED, 37 | } 38 | -------------------------------------------------------------------------------- /frontend/src/types/events.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CourseTableEntry, 3 | PlannerCourse, 4 | TimetableOverview, 5 | } from 'cutopia-types'; 6 | import { TimetableOverviewMode } from './enums'; 7 | 8 | export type UploadTimetableResponse = { 9 | id: string; 10 | }; 11 | 12 | export type UploadTimetable = { 13 | entries: CourseTableEntry[]; 14 | tableName?: string; 15 | createdAt: number; 16 | expireAt: number; 17 | expire?: number; 18 | }; 19 | 20 | export type OverlapSection = { 21 | name: string; 22 | courseIndex: number; 23 | sectionKey: string; 24 | }; 25 | 26 | export type OverlapSections = { 27 | [key: string]: OverlapSection; 28 | }; 29 | 30 | export type TbaSection = { 31 | name: string; 32 | courseIndex: number; 33 | }; 34 | 35 | export type TbaSections = { 36 | [key: string]: TbaSection; 37 | }; 38 | 39 | export interface TimetableOverviewWithMode extends TimetableOverview { 40 | mode: TimetableOverviewMode; 41 | } 42 | 43 | export type PlannerDelta = { 44 | tableName?: string; 45 | courses?: PlannerCourse[]; 46 | }; 47 | -------------------------------------------------------------------------------- /frontend/src/types/general.ts: -------------------------------------------------------------------------------- 1 | import { FC, ReactNode } from 'react'; 2 | 3 | /** FC with children */ 4 | export type FCC = FC< 5 | { 6 | children?: ReactNode; 7 | } & T 8 | >; 9 | -------------------------------------------------------------------------------- /frontend/src/types/index.ts: -------------------------------------------------------------------------------- 1 | /* eslint-disable import/export */ 2 | export * from 'cutopia-types'; 3 | export * from './enums'; 4 | export * from './courses'; 5 | export * from './discussions'; 6 | export * from './reviews'; 7 | export * from './events'; 8 | export * from './user'; 9 | export * from './views'; 10 | import { DiscussionMessage } from './discussions'; 11 | import { CourseInfo } from './courses'; 12 | import { Review } from './reviews'; 13 | import { User } from './user'; 14 | export type { DiscussionMessage, CourseInfo, Review, User }; 15 | -------------------------------------------------------------------------------- /frontend/src/types/reviews.ts: -------------------------------------------------------------------------------- 1 | import { Review as ReviewRaw } from 'cutopia-types'; 2 | 3 | export type ReviewTerm = 'Term 1' | 'Term 2' | 'Summer'; 4 | 5 | export type Review = ReviewRaw & { 6 | myVote?: number; 7 | }; 8 | -------------------------------------------------------------------------------- /frontend/src/types/user.ts: -------------------------------------------------------------------------------- 1 | import { UserVisible, TimetableOverview } from 'cutopia-types'; 2 | 3 | export type User = UserVisible & { 4 | timetables?: TimetableOverview[]; 5 | }; 6 | 7 | export type UserData = { 8 | me: User; 9 | }; 10 | -------------------------------------------------------------------------------- /frontend/src/types/views.ts: -------------------------------------------------------------------------------- 1 | import { DialogProps, AlertProps } from '@mui/material'; 2 | import UserStore from '../store/UserStore'; 3 | import { CourseSearchItem } from './courses'; 4 | 5 | export type DatedData = { 6 | value: T; 7 | time: number; 8 | }; 9 | 10 | export type SearchMode = 'subject' | 'query' | 'Pins' | 'My Courses'; 11 | 12 | export type SearchPayload = { 13 | mode: SearchMode | null; 14 | text?: string; 15 | showAvalibility?: boolean; 16 | offerredOnly?: boolean; 17 | }; 18 | 19 | export interface MenuItem { 20 | icon?: string | JSX.Element; 21 | label: string; 22 | } 23 | 24 | export interface PlannerItem extends MenuItem { 25 | id: string; 26 | } 27 | 28 | export interface SnackBarProps extends AlertProps { 29 | message: string; 30 | label?: string; 31 | onClick?: (...args: any[]) => any; 32 | } 33 | 34 | export interface SnackBar extends SnackBarProps { 35 | snackbarId: number | undefined; 36 | } 37 | 38 | export type DialogKeys = 'userSettings' | 'reportIssues'; 39 | 40 | export type Dialog = { 41 | key: DialogKeys; 42 | props?: Partial; 43 | contentProps?: Record; 44 | }; 45 | 46 | export type CourseSearchList = Record; 47 | 48 | export type DataWithETag = { 49 | data: T; 50 | etag: number; 51 | }; 52 | 53 | export type CourseQuery = { 54 | payload: SearchPayload; 55 | user?: UserStore; 56 | limit?: number; 57 | offerredOnly?: boolean; 58 | }; 59 | 60 | export type LecturerQuery = { 61 | payload: string; 62 | limit: number; 63 | }; 64 | 65 | export type DataConfig = { 66 | expire: number; 67 | fetchKey?: string; // default same as store key 68 | }; 69 | -------------------------------------------------------------------------------- /frontend/tools/coursesLoader.ts: -------------------------------------------------------------------------------- 1 | import fs from 'fs'; 2 | import { Course } from 'cutopia-types'; 3 | 4 | export interface CourseExtended extends Course { 5 | courseId: string; 6 | } 7 | 8 | const ignoredFiles = new Set(['.DS_Store']); 9 | 10 | const courses: Record = {}; 11 | 12 | const dirname = `${process.cwd()}/../data/courses`; 13 | 14 | const subjectFilenames = fs.readdirSync(dirname); 15 | 16 | console.log(`Loading ${subjectFilenames.length} subjects in ${dirname}`); 17 | 18 | subjectFilenames.forEach(subjectFileName => { 19 | if (ignoredFiles.has(subjectFileName)) return; 20 | const filepath = fs.readFileSync(`${dirname}/${subjectFileName}`).toString(); 21 | const courseList: Course[] = JSON.parse(filepath); 22 | if (courseList.length !== 0) { 23 | const subjectName = subjectFileName.split('.')[0]; 24 | courseList 25 | .filter(course => course) 26 | .forEach(course => { 27 | const courseId = `${subjectName}${course.code}`; 28 | const assessmentsNames = Object.keys(course?.assessments || {}); 29 | courses[courseId] = { 30 | ...course, 31 | assessments: assessmentsNames.map(name => ({ 32 | name, 33 | percentage: course.assessments[name], 34 | })), 35 | courseId, 36 | }; 37 | }); 38 | } 39 | }); 40 | 41 | export { courses }; 42 | -------------------------------------------------------------------------------- /frontend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext", 8 | "es5", 9 | "es6", 10 | "es7" 11 | ], 12 | "allowJs": true, 13 | "skipLibCheck": true, 14 | "esModuleInterop": true, 15 | "allowSyntheticDefaultImports": true, 16 | "strict": false, 17 | "forceConsistentCasingInFileNames": true, 18 | "noFallthroughCasesInSwitch": true, 19 | "module": "esnext", 20 | "moduleResolution": "node", 21 | "resolveJsonModule": true, 22 | "isolatedModules": true, 23 | "noEmit": true, 24 | "noEmitOnError": true, 25 | "downlevelIteration": true, 26 | "jsx": "preserve", 27 | "experimentalDecorators": true, 28 | "incremental": true 29 | }, 30 | "overrides": [ 31 | { 32 | "files": [ 33 | "*.ts", 34 | "*.tsx", 35 | "*.js" 36 | ] 37 | } 38 | ], 39 | "include": [ 40 | "next-env.d.ts", 41 | "**/*.ts", 42 | "**/*.tsx" 43 | ], 44 | "exclude": [ 45 | "node_modules" 46 | ] 47 | } 48 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('ts-jest/dist/types').InitialOptionsTsJest} */ 2 | 3 | module.exports = { 4 | preset: 'ts-jest', 5 | testEnvironment: 'node', 6 | modulePathIgnorePatterns: ['types/lib/', 'backend/mongodb/lib/'], 7 | moduleDirectories: ['backend/mongodb/node_modules/'], 8 | globals: { 9 | 'ts-jest': { 10 | diagnostics: false, // set to true to enable type checking 11 | }, 12 | }, 13 | }; 14 | -------------------------------------------------------------------------------- /lerna.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "node_modules/lerna/schemas/lerna-schema.json", 3 | "useNx": true, 4 | "useWorkspaces": true, 5 | "version": "0.0.0" 6 | } 7 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cutopia", 3 | "version": "1.0.0", 4 | "workspaces": [ 5 | "types", 6 | "frontend", 7 | "backend" 8 | ], 9 | "private": true, 10 | "description": "CUtopia is a course review and timetable planning website for CUHK students.", 11 | "repository": "https://github.com/cutopia-labs/CUtopia.git", 12 | "license": "MIT", 13 | "scripts": { 14 | "fe": "yarn workspace cutopia-frontend", 15 | "be": "yarn workspace cutopia-backend", 16 | "types": "yarn workspace cutopia-types", 17 | "prebootstrap": "yarn && yarn types build && yarn load-data", 18 | "bootstrap": "yarn prebootstrap && yarn bootstrap:all", 19 | "bootstrap:fe": "yarn prebootstrap && yarn fe bootstrap", 20 | "bootstrap:all": "yarn fe bootstrap && yarn be bootstrap", 21 | "prepare": "husky install", 22 | "load-data": "git submodule update --init --remote", 23 | "mount-data": "yarn fe move-data && yarn be move-data", 24 | "loadnmount": "yarn load-data && yarn mount-data", 25 | "build:fe": "yarn fe build", 26 | "test": "jest", 27 | "postinstall": "yarn types build", 28 | "reinstall": "lerna clean --yes && yarn" 29 | }, 30 | "devDependencies": { 31 | "@types/nprogress": "^0.2.0", 32 | "@types/pluralize": "^0.0.29", 33 | "@types/jest": "^28.1.6", 34 | "@types/loader-utils": "^2.0.3", 35 | "@types/lodash": "^4.14.182", 36 | "@types/node": "^18.7.13", 37 | "@typescript-eslint/eslint-plugin": "^5.35.1", 38 | "@typescript-eslint/parser": "^5.35.1", 39 | "eslint": "^8.22.0", 40 | "eslint-config-prettier": "^8.5.0", 41 | "eslint-plugin-import": "^2.26.0", 42 | "eslint-plugin-prettier": "^4.2.1", 43 | "husky": "^8.0.1", 44 | "jest": "^28.0.0", 45 | "lerna": "^5.4.3", 46 | "lint-staged": ">=11.1.2", 47 | "prettier": "^2.3.2", 48 | "ts-jest": "^28.0.7", 49 | "ts-node": "^10.9.1", 50 | "typescript": "^4.8.2" 51 | }, 52 | "lint-staged": { 53 | "*.{js,jsx,ts,tsx}": "eslint --cache --fix" 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /tools/git_sync.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | git checkout master 4 | git rebase dev 5 | git push origin master 6 | -------------------------------------------------------------------------------- /tools/move_data.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # Move data 4 | readonly DATA=$(dirname `pwd`)/data 5 | readonly TOOL=$(dirname `pwd`)/tools 6 | echo moving $1 to $DATA 7 | cp -r $TOOL/data/$1/* $DATA 8 | cd ../data 9 | # Commit data 10 | set -x 11 | git add . 12 | git commit -am "Update data @$1" 13 | git push origin master 14 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es6", 4 | "module": "esnext", 5 | "moduleResolution": "node", 6 | "lib": ["es6"], 7 | "downlevelIteration": true, 8 | "alwaysStrict": true, 9 | "sourceMap": true, 10 | "declaration": true, 11 | "removeComments": false, 12 | "strict": true, 13 | "noImplicitReturns": true, 14 | "noUnusedLocals": true, 15 | "noImplicitAny": false, 16 | "noImplicitThis": false, 17 | "noEmit": true, 18 | "experimentalDecorators": true, 19 | "useDefineForClassFields": true, 20 | "jsx": "react", 21 | "esModuleInterop": true 22 | }, 23 | "exclude": ["node_modules", "**/__tests__/*"] 24 | } 25 | -------------------------------------------------------------------------------- /types/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | /lib 3 | -------------------------------------------------------------------------------- /types/.npmignore: -------------------------------------------------------------------------------- 1 | src 2 | tsconfig.json 3 | -------------------------------------------------------------------------------- /types/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "cutopia-types", 3 | "version": "1.0.29", 4 | "main": "./lib/index.js", 5 | "types": "./lib/index.d.ts", 6 | "scripts": { 7 | "build": "tsc", 8 | "prepare": "yarn build", 9 | "test": "jest" 10 | }, 11 | "exports": { 12 | ".": "./lib", 13 | "./types": "./lib/types" 14 | }, 15 | "keywords": [], 16 | "license": "MIT" 17 | } 18 | -------------------------------------------------------------------------------- /types/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './types'; 2 | export * from './rules'; 3 | -------------------------------------------------------------------------------- /types/src/rules.test.ts: -------------------------------------------------------------------------------- 1 | import { PASSWORD_RULE, SID_RULE, USERNAME_RULE } from '.'; 2 | 3 | describe('Rule: password', () => { 4 | it('should reject short length', () => { 5 | const pwd = '1234567'; 6 | expect(PASSWORD_RULE.test(pwd)).toBe(false); 7 | }); 8 | it('should reject white space', () => { 9 | const pwd = '1234567 '; 10 | expect(PASSWORD_RULE.test(pwd)).toBe(false); 11 | }); 12 | it('should NOT reject long length', () => { 13 | const pwd = '123456790123456'; 14 | 15 | expect(PASSWORD_RULE.test(pwd)).toBe(true); 16 | }); 17 | it('should NOT reject special char', () => { 18 | const pwd = ')*+,-./:;<=>?@['; 19 | 20 | expect(PASSWORD_RULE.test(pwd)).toBe(true); 21 | }); 22 | }); 23 | 24 | describe('Rule: username', () => { 25 | it('should reject long length', () => { 26 | const str = '12345678901'; 27 | expect(USERNAME_RULE.test(str)).toBe(false); 28 | }); 29 | }); 30 | 31 | describe('Rule: SID', () => { 32 | it('should reject length > 10', () => { 33 | const str = '11345678901'; 34 | expect(SID_RULE.test(str)).toBe(false); 35 | }); 36 | it('should reject length < 10', () => { 37 | const str = '11345678'; 38 | expect(SID_RULE.test(str)).toBe(false); 39 | }); 40 | it('should reject non 11 start', () => { 41 | const str = '1780230430'; 42 | expect(SID_RULE.test(str)).toBe(false); 43 | }); 44 | it('should NOT reject length of 10 and 11 start', () => { 45 | const str = '1134567891'; 46 | expect(SID_RULE.test(str)).toBe(true); 47 | }); 48 | }); 49 | -------------------------------------------------------------------------------- /types/src/rules.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * start w/ 11 + digits only + length of 10 3 | */ 4 | export const SID_RULE = new RegExp(`^11[0-9]{8}$`); 5 | 6 | /** 7 | * Alphanumeric + Chinese Char 8 | */ 9 | export const USERNAME_RULE = new RegExp( 10 | `^[A-Za-z0-9\u3000\u3400-\u4DBF\u4E00-\u9FFF]{2,10}$` 11 | ); 12 | 13 | /** 14 | * Contains no space 15 | * Allow only alphas + digits + !"#$%&'()*+,-./:;<=>?@[\]^_`{|}~ 16 | * 8 length up 17 | */ 18 | export const PASSWORD_RULE = new RegExp( 19 | '^[A-Za-z0-9!"#$%&\'()*+,-./:;<=>?@[\\]^_`{|}~]{8,}$' 20 | ); 21 | 22 | /** 23 | * 4 alpha + 4 digit code 24 | */ 25 | export const VALID_COURSE_RULE = new RegExp('^[a-z]{4}\\d{4}$', 'i'); 26 | -------------------------------------------------------------------------------- /types/src/types/codes.ts: -------------------------------------------------------------------------------- 1 | export enum ErrorCode { 2 | AUTHORIZATION_INVALID_TOKEN, 3 | AUTHORIZATION_REFRESH_TOKEN, 4 | INVALID_COURSE_ID, 5 | CREATE_USER_INVALID_USERNAME, 6 | CREATE_USER_INVALID_EMAIL, 7 | CREATE_USER_USERNAME_EXISTS, 8 | CREATE_USER_EMAIL_EXISTS, 9 | VERIFICATION_FAILED, 10 | VERIFICATION_ALREADY_VERIFIED, 11 | VERIFICATION_USER_DNE, 12 | VERIFICATION_EXPIRED, 13 | LOGIN_FAILED, 14 | LOGIN_USER_DNE, 15 | LOGIN_NOT_VERIFIED, 16 | GET_PASSWORD_USER_DNE, 17 | GET_PASSWORD_NOT_VERIFIED, 18 | RESET_PASSWORD_FAILED, 19 | RESET_PASSWORD_USER_DNE, 20 | RESET_PASSWORD_NOT_VERIFIED, 21 | CREATE_REVIEW_ALREADY_CREATED, 22 | VOTE_REVIEW_INVALID_VALUE, 23 | VOTE_REVIEW_VOTED_ALREADY, 24 | VOTE_REVIEW_DNE, 25 | UPLOAD_TIMETABLE_EXCEED_ENTRY_LIMIT, 26 | UPLOAD_TIMETABLE_EXCEED_TOTAL_LIMIT, 27 | GET_REVIEW_INVALID_SORTING, 28 | GET_TIMETABLE_INVALID_ID, 29 | GET_TIMETABLE_UNAUTHORIZED, 30 | GET_TIMETABLE_EXPIRED, 31 | DEL_TIMETABLE_INVALID_ID, 32 | INPUT_INVALID_LENGTH, 33 | INPUT_INVALID_VALUE, 34 | EXCEED_RATE_LIMIT, 35 | } 36 | 37 | export enum ReportCategory { 38 | ISSUE, 39 | FEEDBACK, 40 | COURSE, 41 | REVIEW, 42 | } 43 | 44 | export enum IssueReportType { 45 | OTHER, 46 | UI, 47 | BUGS, 48 | FEATURES, 49 | EXPERIENCE, 50 | COURSE_INFO, 51 | } 52 | 53 | export enum ReviewReportType { 54 | OTHER, 55 | HATE_SPEECH, 56 | PERSONAL_ATTACK, 57 | SPAM, 58 | MISLEADING, 59 | } 60 | 61 | export enum CourseReportType { 62 | OTHER, 63 | COURSE_TITLE, 64 | CREDITS, 65 | ASSESSMENTS, 66 | REQUIREMENTS, 67 | DESCRIPTION, 68 | } 69 | 70 | export enum VoteAction { 71 | DOWNVOTE, 72 | UPVOTE, 73 | } 74 | -------------------------------------------------------------------------------- /types/src/types/courses.ts: -------------------------------------------------------------------------------- 1 | import { Events } from './events'; 2 | 3 | export interface CourseDataRaw { 4 | code: string; 5 | title: string; 6 | career: string; 7 | units: string; 8 | grading: string; 9 | components: string; 10 | campus: string; 11 | academic_group: string; 12 | requirements: string; 13 | description: string; 14 | outcome: string; 15 | syllabus: string; 16 | required_readings: string; 17 | recommended_readings: string; 18 | assessments?: AssessementComponent[]; 19 | } 20 | 21 | export interface Course extends CourseDataRaw { 22 | courseId: string; 23 | rating?: CourseRating; 24 | reviewLecturers?: string[]; 25 | reviewTerms?: string[]; 26 | } 27 | 28 | export interface CourseInfo extends Course { 29 | courseId: string; 30 | } 31 | 32 | export type CourseRating = { 33 | numReviews: number; 34 | overall: number; 35 | grading: number; 36 | content: number; 37 | difficulty: number; 38 | teaching: number; 39 | }; 40 | 41 | export type Term = { 42 | name: string; 43 | course_sections?: [CourseSection]; 44 | }; 45 | 46 | export type PlannerCourse = { 47 | courseId: string; 48 | title: string; 49 | credits: number; 50 | sections: { 51 | [sectionName: string]: CourseSection; 52 | }; 53 | }; 54 | 55 | export type PlannerRaw = { 56 | tableName?: string; 57 | createdAt?: number; 58 | courses: PlannerCourse[]; 59 | expire?: number; 60 | expireAt?: number; 61 | id: string; 62 | }; 63 | 64 | export interface CourseSection extends Events { 65 | name: string; 66 | instructors: string[]; 67 | hide?: boolean; 68 | } 69 | 70 | export type AssessementComponent = { 71 | name: string; 72 | percentage: string; 73 | }; 74 | 75 | export type CourseDocument = { 76 | _id: string; 77 | lecturers: string[]; 78 | terms: string[]; 79 | rating: { 80 | numReviews: number; 81 | overall: number; 82 | grading: number; 83 | content: number; 84 | difficulty: number; 85 | teaching: number; 86 | }; 87 | }; 88 | -------------------------------------------------------------------------------- /types/src/types/discussions.ts: -------------------------------------------------------------------------------- 1 | export interface DiscussionMessageBase { 2 | text: string; 3 | user: string; 4 | } 5 | 6 | export interface DiscussionMessage extends DiscussionMessageBase { 7 | _id: number; 8 | } 9 | 10 | export type Discussion = { 11 | _id: string; 12 | messages: [DiscussionMessage]; 13 | numMessages: number; 14 | }; 15 | 16 | export type DiscussionRecent = { 17 | courseId: string; 18 | text: string; 19 | time?: number; 20 | user: string; 21 | }; 22 | -------------------------------------------------------------------------------- /types/src/types/email.ts: -------------------------------------------------------------------------------- 1 | export type Email = { 2 | _id: string; 3 | username: string; 4 | action: string; 5 | SID: string; 6 | code: string; 7 | }; 8 | -------------------------------------------------------------------------------- /types/src/types/events.ts: -------------------------------------------------------------------------------- 1 | import { CourseSection } from './courses'; 2 | 3 | export interface Event { 4 | courseId: string; 5 | day: number; 6 | startTime: string; 7 | endTime: string; 8 | color?: string; 9 | title?: string; 10 | location?: string; 11 | section?: string; 12 | } 13 | 14 | export interface Events { 15 | startTimes: string[]; 16 | endTimes: string[]; 17 | days: number[]; 18 | locations: string[]; 19 | meetingDates: string[]; 20 | } 21 | 22 | export type EventConfig = { 23 | startHour: number; 24 | endHour: number; 25 | numOfDays: number; 26 | numOfHours: number; 27 | }; 28 | 29 | export type CourseTableEntry = { 30 | courseId: string; 31 | title: string; 32 | credits: number; 33 | sections?: CourseSection[]; 34 | }; 35 | 36 | export interface TimetableOverview { 37 | _id: string; 38 | createdAt: number; 39 | tableName: string | null; 40 | expireAt: number; 41 | expire: number; 42 | } 43 | 44 | export interface Timetable extends TimetableOverview { 45 | entries: any[]; 46 | username: string; 47 | } 48 | -------------------------------------------------------------------------------- /types/src/types/index.ts: -------------------------------------------------------------------------------- 1 | export * from './courses'; 2 | export * from './discussions'; 3 | export * from './email'; 4 | export * from './events'; 5 | export * from './ranking'; 6 | export * from './reports'; 7 | export * from './reviews'; 8 | export * from './user'; 9 | export * from './codes'; 10 | -------------------------------------------------------------------------------- /types/src/types/ranking.ts: -------------------------------------------------------------------------------- 1 | import { Course } from './courses'; 2 | 3 | export type RankEntry = { 4 | _id: string; // courseId 5 | val: any; 6 | }; 7 | 8 | export type Ranking = { 9 | _id: string; // ranking field, e.g. numReviews, grading 10 | ranks: RankEntry[]; 11 | updatedAt: number; 12 | }; 13 | 14 | export type RankTable = { 15 | popularCourses: PopularCourse[]; 16 | topRatedCourses: TopRatedCourse[]; 17 | topRatedAcademicGroups: TopRatedAcademicGroups[]; 18 | }; 19 | 20 | export type PopularCourse = { 21 | courseId: string; 22 | course: Course; 23 | numReviews: number; 24 | }; 25 | 26 | export type TopRatedCourse = { 27 | courseId: string; 28 | course: Course; 29 | numReviews: number; 30 | overall: number; 31 | grading: number; 32 | content: number; 33 | difficulty: number; 34 | teaching: number; 35 | }; 36 | 37 | export type TopRatedAcademicGroups = { 38 | name: string; 39 | numReviews: number; 40 | overall: number; 41 | grading: number; 42 | content: number; 43 | difficulty: number; 44 | teaching: number; 45 | }; 46 | -------------------------------------------------------------------------------- /types/src/types/reports.ts: -------------------------------------------------------------------------------- 1 | import { CourseReportType, ReviewReportType } from './codes'; 2 | 3 | type Report = { 4 | type: T; 5 | description: string; 6 | }; 7 | 8 | export type ReviewReport = Report; 9 | 10 | export type CourseInfoReport = Report; 11 | 12 | export type ReportDocument = { 13 | _id: string; 14 | createdAt: number; 15 | cat: number; 16 | username: string; 17 | description: string; 18 | types?: number[]; 19 | identifier?: string; 20 | }; 21 | -------------------------------------------------------------------------------- /types/src/types/reviews.ts: -------------------------------------------------------------------------------- 1 | export type Grade = 'F' | 'D' | 'C' | 'B' | 'A'; 2 | 3 | export type RatingField = 'grading' | 'content' | 'difficulty' | 'teaching'; 4 | 5 | export type RatingFieldWithOverall = 6 | | 'overall' 7 | | 'grading' 8 | | 'content' 9 | | 'difficulty' 10 | | 'teaching'; 11 | 12 | export type RecentReview = { 13 | id: string; 14 | courseId: string; 15 | username: string; 16 | title?: string; 17 | createdAt: string; 18 | overall: number; 19 | grading: ReviewDetails; 20 | }; 21 | 22 | export type Review = { 23 | _id: string; 24 | username: string; 25 | reviewId: string; 26 | title: string; 27 | courseId: string; 28 | term: string; 29 | lecturer: string; 30 | anonymous: boolean; 31 | upvotes: number; 32 | downvotes: number; 33 | upvoteUserIds: string[]; 34 | downvoteUserIds: string[]; 35 | overall: number; 36 | grading: ReviewDetails; 37 | teaching: ReviewDetails; 38 | difficulty: ReviewDetails; 39 | content: ReviewDetails; 40 | createdAt: number; 41 | updatedAt: number; 42 | }; 43 | 44 | export type ReviewDetails = { 45 | grade: number; 46 | text: string; 47 | }; 48 | 49 | export type ReviewsResult = { 50 | reviews: Review[]; 51 | nextPage?: number; 52 | }; 53 | 54 | export type LastEvaluatedKey = { 55 | courseId: string; 56 | createdAt: string; 57 | upvotes?: number; 58 | }; 59 | 60 | export type CreateReviewResult = { 61 | createdAt?: string; 62 | }; 63 | 64 | export type ReviewsFilter = { 65 | courseId: string; 66 | sortBy?: string; 67 | page?: number; 68 | lecturer?: string; 69 | term?: string; 70 | ascending?: boolean; 71 | }; 72 | -------------------------------------------------------------------------------- /types/src/types/user.ts: -------------------------------------------------------------------------------- 1 | import { Timetable } from './events'; 2 | 3 | export type UserVisible = { 4 | username: string; 5 | verified: boolean; 6 | reviewIds: string[]; 7 | upvotes: number; 8 | exp: number; 9 | level: number; 10 | timetableId: string; 11 | timetables: Timetable[]; 12 | fullAccess: boolean; 13 | }; 14 | 15 | export type User = UserVisible & { 16 | SID: string; 17 | password: string; 18 | resetPwdCode: string; 19 | createdAt: number; 20 | downvotes: number; 21 | veriCode: string; 22 | sharedTimetables: Timetable[]; 23 | }; 24 | 25 | export type LoginResult = { 26 | token?: string; 27 | me?: User; 28 | }; 29 | -------------------------------------------------------------------------------- /types/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es6", 4 | "module": "commonjs", 5 | "declaration": true, 6 | "outDir": "./lib", 7 | "strict": true, 8 | "baseUrl": ".", 9 | "typeRoots": [ 10 | "./node_modules/@types", 11 | "../node_modules/@types", 12 | ], 13 | "skipLibCheck": true 14 | }, 15 | "include": ["src"], 16 | "exclude": ["node_modules", "**/__tests__/*"], 17 | } 18 | --------------------------------------------------------------------------------