├── .gitattributes ├── .github ├── pull_request_template.md └── workflows │ ├── ci.yml │ ├── docker-ci.yml │ ├── docker-staging.yml │ └── docker.yml ├── .gitignore ├── .prettierrc ├── CODEOWNERS ├── LICENSE ├── README.md ├── auto_server ├── .dockerignore ├── .gitignore ├── Dockerfile ├── README.md ├── auto.py ├── autotimetabler.proto ├── autotimetabler_pb2.py ├── autotimetabler_pb2_grpc.py ├── requirements.txt └── server.py ├── client ├── .dockerignore ├── .eslintrc.js ├── .gitignore ├── .npmrc ├── .prettierrc ├── Caddyfile ├── Dockerfile ├── README.md ├── index.html ├── package.json ├── pnpm-lock.yaml ├── pnpm-workspace.yaml ├── postcss.config.ts ├── public │ ├── favicon.ico │ ├── icon-192x192.png │ ├── icon-256x256.png │ ├── icon-384x384.png │ ├── icon-512x512.png │ ├── manifest.json │ ├── privacy.html │ └── robots.txt ├── src │ ├── App.test.tsx │ ├── App.tsx │ ├── api │ │ ├── config.ts │ │ ├── getAutoTimetable.ts │ │ ├── getCourseInfo.ts │ │ └── getCoursesList.ts │ ├── assets │ │ ├── AutoTimetable.gif │ │ ├── DiscordIcon.tsx │ │ ├── PlanAhead.gif │ │ ├── SelectCourses.gif │ │ ├── blobImage.svg │ │ ├── calendarIcon.png │ │ ├── cat_aaaaa.svg │ │ ├── cat_sit.svg │ │ ├── devsoc.svg │ │ ├── devsoc_white.svg │ │ ├── dragIcon.png │ │ ├── how_to_use.gif │ │ ├── notangles.gif │ │ ├── notangles_1.png │ │ ├── notangles_2.png │ │ ├── peopleIcon.png │ │ ├── sidebar_cat.svg │ │ └── sponsors │ │ │ ├── arista_black.png │ │ │ ├── arista_white.png │ │ │ ├── jane_street_black.svg │ │ │ ├── jane_street_white.svg │ │ │ ├── safetyculture_black.png │ │ │ ├── safetyculture_white.png │ │ │ ├── thetradedesk_black.png │ │ │ └── thetradedesk_white.png │ ├── components │ │ ├── Alerts.tsx │ │ ├── EventShareModal.tsx │ │ ├── Sponsors.tsx │ │ ├── StyledDialog.tsx │ │ ├── SubcomPromotion.tsx │ │ ├── controls │ │ │ ├── Autotimetabler.tsx │ │ │ ├── ColorOptions.tsx │ │ │ ├── ColorPicker.tsx │ │ │ ├── Controls.tsx │ │ │ ├── CourseSelect.tsx │ │ │ ├── CustomEvent.tsx │ │ │ ├── CustomEventGeneral.tsx │ │ │ ├── CustomEventTutoring.tsx │ │ │ ├── History.tsx │ │ │ ├── TermSelect.tsx │ │ │ └── customEventLink.tsx │ │ ├── footer │ │ │ ├── Footer.tsx │ │ │ ├── FooterInfo.tsx │ │ │ └── FooterLinks.tsx │ │ ├── landingPage │ │ │ ├── FeedbackSection.tsx │ │ │ ├── Footer.tsx │ │ │ ├── HeroSection │ │ │ │ └── HeroSection.tsx │ │ │ ├── KeyFeaturesSection │ │ │ │ └── FeaturesSection.tsx │ │ │ ├── LandingPage.tsx │ │ │ ├── ScrollingFeaturesSection.tsx │ │ │ ├── SponsorsSection.tsx │ │ │ └── flip-words.tsx │ │ ├── sidebar │ │ │ ├── About.tsx │ │ │ ├── Changelog.tsx │ │ │ ├── CollapseButton.tsx │ │ │ ├── ColorThemeOptions.tsx │ │ │ ├── ColorThemePreview.tsx │ │ │ ├── CustomModal.tsx │ │ │ ├── DarkModeButton.tsx │ │ │ ├── FriendsButton.tsx │ │ │ ├── Privacy.tsx │ │ │ ├── Settings.tsx │ │ │ ├── Sidebar.tsx │ │ │ ├── Tooltip.tsx │ │ │ ├── UserAccount.tsx │ │ │ └── groupsSidebar │ │ │ │ ├── AddOrEditGroupDialog.tsx │ │ │ │ ├── AddOrEditGroupDialogContent.tsx │ │ │ │ ├── EditImagePopover.tsx │ │ │ │ ├── GroupCircle.tsx │ │ │ │ ├── GroupsSidebar.tsx │ │ │ │ └── friends │ │ │ │ ├── AddAFriendTab.tsx │ │ │ │ ├── FriendsDialog.tsx │ │ │ │ ├── FriendsTablist.tsx │ │ │ │ ├── RequestsTab.tsx │ │ │ │ ├── UserProfile.tsx │ │ │ │ └── YourFriendsTab.tsx │ │ ├── timetable │ │ │ ├── CreateEventPopover.tsx │ │ │ ├── DiscardDialog.tsx │ │ │ ├── DropdownOption.tsx │ │ │ ├── DroppedCards.tsx │ │ │ ├── DroppedClass.tsx │ │ │ ├── DroppedEvent.tsx │ │ │ ├── Dropzone.tsx │ │ │ ├── Dropzones.tsx │ │ │ ├── EventContextMenu.tsx │ │ │ ├── ExpandedClassView.tsx │ │ │ ├── ExpandedEventView.tsx │ │ │ ├── LocationDropdown.tsx │ │ │ ├── PeriodMetadata.tsx │ │ │ ├── Timetable.tsx │ │ │ └── TimetableLayout.tsx │ │ ├── timetableShared.tsx │ │ │ └── TimetableShared.tsx │ │ └── timetableTabs │ │ │ ├── TimetableTabContextMenu.tsx │ │ │ └── TimetableTabs.tsx │ ├── constants │ │ ├── defaults.ts │ │ ├── theme.ts │ │ └── timetable.ts │ ├── context │ │ ├── AppContext.tsx │ │ ├── CourseContext.tsx │ │ └── UserContext.tsx │ ├── hooks │ │ ├── useColorDecoder.ts │ │ ├── useColorMapper.ts │ │ └── useUpdateEffect.ts │ ├── index.css │ ├── index.tsx │ ├── interfaces │ │ ├── Courses.ts │ │ ├── Database.ts │ │ ├── GraphQLCourseInfo.ts │ │ ├── Group.ts │ │ ├── NetworkError.ts │ │ ├── Periods.ts │ │ ├── PropTypes.ts │ │ └── TimeoutError.ts │ ├── lib │ │ └── utils.ts │ ├── logo.svg │ ├── service-worker.ts │ ├── serviceWorkerRegistration.ts │ ├── styles │ │ ├── ControlStyles.tsx │ │ ├── CustomEventStyles.tsx │ │ ├── DroppedCardStyles.tsx │ │ ├── ExpandedViewStyles.tsx │ │ └── TimetableTabStyles.tsx │ └── utils │ │ ├── DbCourse.ts │ │ ├── Drag.ts │ │ ├── areDuplicatePeriods.ts │ │ ├── cardsContextMenu.ts │ │ ├── clashes.ts │ │ ├── convertTo24Hour.ts │ │ ├── createEvent.ts │ │ ├── eventTimes.ts │ │ ├── generateICS.ts │ │ ├── getAllPeriods.ts │ │ ├── getClassCourse.ts │ │ ├── graphQLCourseToDbCourse.ts │ │ ├── oklchCovert.ts │ │ ├── storage.ts │ │ ├── syncTimetables.ts │ │ ├── timeoutPromise.ts │ │ ├── timetableHelpers.ts │ │ └── translateCard.ts ├── tailwind.config.js ├── team.json ├── tsconfig.json ├── vite-env.d.ts └── vite.config.ts ├── docs ├── onboarding.md └── typescript.md ├── renovate.json └── server ├── .dockerignore ├── .env.example ├── .eslintrc.js ├── .gitignore ├── .prettierrc ├── Dockerfile ├── README.md ├── autotimetabler.proto ├── docker-compose.yaml ├── migrate.sh ├── nest-cli.json ├── package.json ├── pnpm-lock.yaml ├── pnpm-workspace.yaml ├── prisma ├── migrations │ ├── 20240713012659_init │ │ └── migration.sql │ ├── 20241003042905_ │ │ └── migration.sql │ ├── 20241101072349_activity │ │ └── migration.sql │ ├── 20250322064905_upgrade_to_v6 │ │ └── migration.sql │ ├── 20250414034722_add_courseid_to_class │ │ └── migration.sql │ ├── 20250424101523_fill_course_id │ │ └── migration.sql │ ├── 20250424102004_require_course_id │ │ └── migration.sql │ ├── 20250605081924_update_color_config │ │ └── migration.sql │ └── migration_lock.toml └── schema.prisma ├── proto-gen.sh ├── src ├── app.controller.ts ├── app.module.ts ├── app.service.ts ├── auth │ ├── auth.controller.spec.ts │ ├── auth.controller.ts │ ├── auth.module.ts │ ├── auth.service.spec.ts │ ├── auth.service.ts │ ├── authenticated.guard.ts │ ├── dtos │ │ ├── auth.dto.ts │ │ └── index.ts │ ├── login.guard.ts │ ├── oidc.strategy.ts │ └── session.serializer.ts ├── auto │ ├── auto.controller.ts │ ├── auto.module.ts │ ├── auto.service.ts │ └── dto │ │ └── auto.dto.ts ├── config.ts ├── friend │ ├── dto │ │ ├── friend.dto.ts │ │ └── index.ts │ ├── friend.controller.spec.ts │ ├── friend.controller.ts │ ├── friend.module.ts │ ├── friend.service.spec.ts │ └── friend.service.ts ├── graphql │ ├── graphql.module.ts │ ├── graphql.response.ts │ ├── graphql.service.spec.ts │ └── graphql.service.ts ├── group │ ├── dto │ │ ├── group.dto.ts │ │ └── index.ts │ ├── entities │ │ └── group.entity.ts │ ├── group.controller.spec.ts │ ├── group.controller.ts │ ├── group.module.ts │ ├── group.service.spec.ts │ └── group.service.ts ├── main.ts ├── prisma │ ├── prisma.module.ts │ └── prisma.service.ts ├── proto │ ├── autotimetabler.proto │ ├── autotimetabler_grpc_pb.d.ts │ ├── autotimetabler_grpc_pb.js │ ├── autotimetabler_pb.d.ts │ └── autotimetabler_pb.js └── user │ ├── dto │ ├── index.ts │ ├── settings.dto.ts │ ├── timetable.dto.ts │ └── user.dto.ts │ ├── user.controller.spec.ts │ ├── user.controller.ts │ ├── user.module.ts │ ├── user.service.spec.ts │ └── user.service.ts ├── test ├── app.e2e-spec.ts ├── db.spec.ts ├── jest-e2e.json └── mockData.ts ├── tsconfig.build.json └── tsconfig.json /.gitattributes: -------------------------------------------------------------------------------- 1 | *.ts text eol=lf 2 | *.tsx text eol=lf 3 | *.js text eol=lf 4 | *.jsx text eol=lf 5 | *.json text eol=lf 6 | *.html text eol=lf 7 | *.css text eol=lf 8 | *.txt text eol=lf 9 | *.sh text eol=lf 10 | *.yaml text eol=lf 11 | .dockerignore text eol=lf 12 | .gitignore text eol=lf 13 | Dockerfile text eol=lf 14 | 15 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | ### Description 🧾 2 | 3 | _Context of PR, summary of changes, as well as any relevant technical decisions/motivations._ 4 | 5 | ### Testing 6 | 7 | _Replace this line with instructions on how to test your changes, as well as any relevant images for UI changes._ 8 | 9 | ### Checklist 10 | 11 | - [ ] 📍 You have assigned yourself to this pull request. 12 | - [ ] 🔗 You have linked an issue to which this pull request closes. 13 | - [ ] 💭 Leave any relevant specific directions to reviewers. 14 | - [ ] 👀 No secrets in clear text in the pull request. 15 | - [ ] 🎟️ To categorise release notes, the pull request should be labelled with at least one of these labels: 16 | - `feature`: for application feature improvement or new features 17 | - `bug`: fixing something that previously wasn't working 18 | - `dev`: development-side related changes 19 | - `docker`: updates to the Docker code 20 | - `setup`: changes to the setup/infrastructure of the codebase 21 | - `test`: updates to internal testing 22 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | push: {} 4 | 5 | jobs: 6 | build: 7 | name: 'Build (${{ matrix.component }})' 8 | runs-on: ubuntu-latest 9 | strategy: 10 | fail-fast: false 11 | matrix: 12 | component: [client, server] 13 | 14 | steps: 15 | - uses: actions/checkout@v4 16 | 17 | - uses: pnpm/action-setup@v4 18 | name: Install pnpm 19 | with: 20 | package_json_file: ${{ matrix.component }}/package.json 21 | run_install: false 22 | 23 | - uses: actions/setup-node@v4 24 | with: 25 | node-version: '22' 26 | cache: 'pnpm' 27 | cache-dependency-path: ${{ matrix.component }}/pnpm-lock.yaml 28 | 29 | - name: Install dependencies 30 | run: pnpm install --frozen-lockfile 31 | working-directory: ${{ matrix.component }} 32 | 33 | - name: Generate Prisma Types from Schema 34 | run: npx prisma generate 35 | if: ${{ matrix.component == 'server' }} 36 | working-directory: server 37 | 38 | - name: Build 39 | run: pnpm run build 40 | working-directory: ${{ matrix.component }} 41 | 42 | check-auto-tt-deps: 43 | name: 'Check Dependencies (auto-timetabler-server)' 44 | runs-on: ubuntu-latest 45 | steps: 46 | - uses: actions/checkout@v4 47 | 48 | - uses: actions/setup-python@v5 49 | with: 50 | python-version: '3.10.4' 51 | cache: 'pip' 52 | 53 | - name: Install dependencies 54 | run: pip install -r requirements.txt 55 | working-directory: auto_server 56 | -------------------------------------------------------------------------------- /.github/workflows/docker-ci.yml: -------------------------------------------------------------------------------- 1 | name: Docker CI 2 | on: 3 | push: 4 | paths: 5 | - '**/Dockerfile' 6 | branches-ignore: 7 | # Pushes to dev will trigger CD anyway 8 | - 'dev' 9 | 10 | jobs: 11 | test-build-client: 12 | name: 'Test Build (client)' 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - uses: actions/checkout@v4 17 | 18 | - uses: docker/setup-qemu-action@v3 19 | with: 20 | platforms: arm64 21 | 22 | - uses: docker/setup-buildx-action@v3 23 | 24 | - uses: docker/build-push-action@v6 25 | with: 26 | context: client 27 | push: false 28 | platforms: linux/amd64 29 | file: client/Dockerfile 30 | cache-from: type=gha 31 | cache-to: type=gha,mode=max 32 | build-args: | 33 | FACEBOOK_APP_ID=${{ secrets.FACEBOOK_APP_ID }} 34 | GOOGLE_ANALYTICS_ID=${{ secrets.GOOGLE_ANALYTICS_ID }} 35 | GOOGLE_API_KEY=${{ secrets.GOOGLE_API_KEY }} 36 | GOOGLE_OAUTH_CLIENT_ID=${{ secrets.GOOGLE_OAUTH_CLIENT_ID }} 37 | SENTRY_INGEST_CLIENT=${{ secrets.SENTRY_INGEST_CLIENT }} 38 | SENTRY_TRACE_RATE_CLIENT=${{ secrets.SENTRY_TRACE_RATE_CLIENT }} 39 | GIT_COMMIT=${{ github.sha }} 40 | 41 | test-build: 42 | name: 'Test Build (${{ matrix.component }})' 43 | runs-on: ubuntu-latest 44 | strategy: 45 | fail-fast: false 46 | matrix: 47 | component: [server, auto_server] 48 | include: 49 | - component: server 50 | name: backend 51 | - component: auto_server 52 | name: auto-timetabler-server 53 | 54 | steps: 55 | - uses: actions/checkout@v4 56 | 57 | - uses: docker/setup-qemu-action@v3 58 | with: 59 | platforms: arm64 60 | 61 | - uses: docker/setup-buildx-action@v3 62 | 63 | - uses: docker/build-push-action@v6 64 | with: 65 | context: ${{ matrix.component }} 66 | push: false 67 | platforms: linux/amd64 68 | file: ${{ matrix.component }}/Dockerfile 69 | cache-from: type=gha 70 | cache-to: type=gha,mode=max 71 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # VSCode 2 | .vscode 3 | .vscode/* 4 | !.vscode/settings.json 5 | !.vscode/tasks.json 6 | !.vscode/launch.json 7 | !.vscode/extensions.json 8 | 9 | .idea/ 10 | 11 | # Logs 12 | logs 13 | *.log 14 | npm-debug.log* 15 | yarn-debug.log* 16 | yarn-error.log* 17 | 18 | # Runtime data 19 | pids 20 | *.pid 21 | *.seed 22 | *.pid.lock 23 | 24 | # Directory for instrumented libs generated by jscoverage/JSCover 25 | lib-cov 26 | 27 | # Coverage directory used by tools like istanbul 28 | coverage 29 | 30 | # nyc test coverage 31 | .nyc_output 32 | 33 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 34 | .grunt 35 | 36 | # Bower dependency directory (https://bower.io/) 37 | bower_components 38 | 39 | # node-waf configuration 40 | .lock-wscript 41 | 42 | # Compiled binary addons (https://nodejs.org/api/addons.html) 43 | build/Release 44 | 45 | # Dependency directories 46 | node_modules/ 47 | jspm_packages/ 48 | 49 | # TypeScript v1 declaration files 50 | typings/ 51 | 52 | # Optional npm cache directory 53 | .npm 54 | 55 | # Optional eslint cache 56 | .eslintcache 57 | 58 | # Optional REPL history 59 | .node_repl_history 60 | 61 | # Output of 'npm pack' 62 | *.tgz 63 | 64 | # Yarn Integrity file 65 | .yarn-integrity 66 | 67 | # dotenv environment variables file 68 | .env 69 | .env.dev 70 | .env.prod 71 | 72 | # next.js build output 73 | .next 74 | 75 | # Mac stuff 76 | .DS_Store 77 | 78 | */venv/* -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "es5", 3 | "tabWidth": 2, 4 | "semi": true, 5 | "singleQuote": true, 6 | "printWidth": 130 7 | } 8 | -------------------------------------------------------------------------------- /CODEOWNERS: -------------------------------------------------------------------------------- 1 | auto_server/ @devsoc-unsw/notangles-leads 2 | client/ @devsoc-unsw/notangles-leads 3 | server/ @devsoc-unsw/notangles-leads 4 | 5 | .github/ @devsoc-unsw/platform 6 | renovate.json @devsoc-unsw/platform 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Notangles 2 | 3 | Copyright (C) 2025 DevSoc UNSW 4 | 5 | This program is free software: you can redistribute it and/or modify it under 6 | the terms of the GNU Affero General Public License as published by the Free 7 | Software Foundation at version 3 of the License. 8 | 9 | This program is distributed in the hope that it will be useful, but WITHOUT ANY 10 | WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A 11 | PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. 12 | 13 | You should have received a copy of the GNU Affero General Public License along 14 | with this program. If not, see . 15 | 16 | # AGPL-v3 License Extension 17 | 18 | 1. You may use, copy, modify, and distribute this software under the terms of 19 | the AGPL-v3 license, provided that you include this license extension in all 20 | copies or substantial portions of the software. 21 | 22 | 2. You may not use this software for commercial purposes, including but not 23 | limited to selling, renting, or licensing the software or any derivative works 24 | thereof. 25 | 26 | 3. If you distribute this software, you must make the source code available 27 | under the AGPL-v3 license and this license extension, and must include a copy 28 | of this license extension with any binary distribution. 29 | 30 | 4. If you modify this software, you must indicate in the source code that 31 | changes have been made and provide the modified source code under the terms of 32 | the AGPL-v3 license and this license extension. 33 | 34 | 5. This license extension applies to the entire software, including any third- 35 | party components included in the software. 36 | 37 | 6. If you use this software in a product that is freely available to the 38 | public, you must give full credit to the original authors of the software by 39 | prominently displaying the following attribution in the product: "This product 40 | includes software developed by DevSoc UNSW." 41 | 42 | 7. This license extension shall not be interpreted to affect the validity or 43 | enforceability of the AGPL-v3 license. 44 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Notangles 2 | 3 | [Notangles](https://notangles.devsoc.app/) is an interactive drag-and-drop timetable planner designed to help UNSW students plan their ideal weekly timetable. 4 | 5 | ## Background and Motivation 6 | 7 | A few weeks before class registration opens, UNSW releases all of their class information at http://timetable.unsw.edu.au. However, the classes and their respective times are formatted in a way that makes it difficult for students trying to plan out their classes before registrations open. Notangles aims to present this information in an easy to visualise and intuitive fashion, allowing students to plan out their timetable by simply dragging and dropping the classes that they are taking. 8 | 9 | Students often find it hard to plan out their classes such that they end up in the same class as their friends. It can also be difficult to plan out times where they can meet up with their friends outside of class. Notangles aims to solve this problem through social timetabling, allowing users to view their friends’ timetables and to also plan out timetables collaboratively. 10 | 11 | ## Running Notangles on your Local Machine 12 | 13 | ### Prerequisites 14 | 15 | Before you start, make sure that you have the following software installed. 16 | 17 | - Git (standard on Linux) or GitHub Desktop 18 | - Node.js and pnpm (install with `npm i -g pnpm`) 19 | - Python 20 | - Docker Desktop 21 | 22 | ### Setup 23 | 24 | Clone the repository: 25 | 26 | `git clone git@github.com:devsoc-unsw/notangles.git` 27 | 28 | > Follow README.md files in `client`, `server`, and `auto_server` subdirectories to setup the notangles application. 29 | 30 | ## Documentation 31 | 32 | For more information, see our [Confluence space](https://devsoc.atlassian.net/wiki/spaces/N/overview?homepageId=1572869). 33 | -------------------------------------------------------------------------------- /auto_server/.dockerignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | env 3 | -------------------------------------------------------------------------------- /auto_server/.gitignore: -------------------------------------------------------------------------------- 1 | __pycache__ 2 | env 3 | .pylintrc 4 | venv -------------------------------------------------------------------------------- /auto_server/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM python:3.10.17-slim-bookworm 2 | 3 | WORKDIR /auto_server 4 | 5 | COPY requirements.txt . 6 | 7 | RUN pip3 install -r requirements.txt 8 | 9 | COPY . . 10 | 11 | EXPOSE 50051 12 | 13 | CMD ["python3", "server.py"] 14 | -------------------------------------------------------------------------------- /auto_server/README.md: -------------------------------------------------------------------------------- 1 | # Notangles Autotimetabler 2 | 3 | The Notangles autotimetabler returns a possible timetable that matches the user's provided requirements. 4 | 5 | ## Installation 6 | 7 | The server has been verified to work with: 8 | 9 | - Python v3.8.10 10 | - pip v21.2.4 11 | 12 | First, in the root server directory `auto_server` create a virtual environment with `python3 -m venv env`. 13 | 14 | Activate the virtual environment by running `source env/bin/activate` 15 | 16 | Finally, in your virtual environment, run `python3 -m pip install -r requirements.txt` to install all the dependencies. 17 | 18 | ## Running 19 | 20 | Run `python3 server.py` to start the autotimetabling server locally. 21 | 22 | The `SENTRY_INGEST_AUTO_SERVER` environment variable is the ingest URL for the Sentry SDK to know where to send the monitored data. 23 | 24 | The `SENTRY_TRACE_RATE_AUTO_SERVER` environment variable is the percentage of transactions monitored and sent. 25 | 26 | The real values of these environment variables are only required when the app is deployed. 27 | 28 | ## Tech Stack 29 | 30 | The Notangles autotimetabler uses: 31 | 32 | - [Google OR-Tools](https://developers.google.com/optimization) 33 | - [gRPC](https://grpc.io/) 34 | 35 | ## Logic 36 | 37 | - The autotimetabler uses Google OR-Tools' set of constraint programming algorithms to generate a possible arrangement of classes based on the user's provided constraints. 38 | - It then returns this result to the Notangles server using gRPC (Remote Procedure Calls) 39 | -------------------------------------------------------------------------------- /auto_server/autotimetabler.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package autotimetabler; 4 | 5 | message TimetableConstraints { 6 | message PeriodInfo { 7 | int32 periodsPerClass = 1; 8 | repeated float periodTimes = 2; 9 | repeated float durations = 3; 10 | } 11 | int32 start = 1; 12 | int32 end = 2; 13 | string days = 3; 14 | int32 gap = 4; 15 | int32 maxdays = 5; 16 | repeated PeriodInfo periodInfo = 6; 17 | } 18 | 19 | message AutoTimetableResponse { 20 | repeated float times = 1; 21 | bool optimal = 2; 22 | } 23 | 24 | service AutoTimetabler { 25 | rpc FindBestTimetable (TimetableConstraints) returns (AutoTimetableResponse); 26 | } -------------------------------------------------------------------------------- /auto_server/autotimetabler_pb2.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # Generated by the protocol buffer compiler. DO NOT EDIT! 3 | # source: autotimetabler.proto 4 | # Protobuf Python Version: 5.26.1 5 | """Generated protocol buffer code.""" 6 | from google.protobuf import descriptor as _descriptor 7 | from google.protobuf import descriptor_pool as _descriptor_pool 8 | from google.protobuf import symbol_database as _symbol_database 9 | from google.protobuf.internal import builder as _builder 10 | # @@protoc_insertion_point(imports) 11 | 12 | _sym_db = _symbol_database.Default() 13 | 14 | 15 | 16 | 17 | DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x14\x61utotimetabler.proto\"\xe3\x01\n\x14TimetableConstraints\x12\r\n\x05start\x18\x01 \x01(\x05\x12\x0b\n\x03\x65nd\x18\x02 \x01(\x05\x12\x0c\n\x04\x64\x61ys\x18\x03 \x01(\t\x12\x0b\n\x03gap\x18\x04 \x01(\x05\x12\x0f\n\x07maxdays\x18\x05 \x01(\x05\x12\x34\n\nperiodInfo\x18\x06 \x03(\x0b\x32 .TimetableConstraints.PeriodInfo\x1aM\n\nPeriodInfo\x12\x17\n\x0fperiodsPerClass\x18\x01 \x01(\x05\x12\x13\n\x0bperiodTimes\x18\x02 \x03(\x02\x12\x11\n\tdurations\x18\x03 \x03(\x02\"7\n\x15\x41utoTimetableResponse\x12\r\n\x05times\x18\x01 \x03(\x02\x12\x0f\n\x07optimal\x18\x02 \x01(\x08\x32T\n\x0e\x41utoTimetabler\x12\x42\n\x11\x46indBestTimetable\x12\x15.TimetableConstraints\x1a\x16.AutoTimetableResponseb\x06proto3') 18 | 19 | _globals = globals() 20 | _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) 21 | _builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'autotimetabler_pb2', _globals) 22 | if not _descriptor._USE_C_DESCRIPTORS: 23 | DESCRIPTOR._loaded_options = None 24 | _globals['_TIMETABLECONSTRAINTS']._serialized_start=25 25 | _globals['_TIMETABLECONSTRAINTS']._serialized_end=252 26 | _globals['_TIMETABLECONSTRAINTS_PERIODINFO']._serialized_start=175 27 | _globals['_TIMETABLECONSTRAINTS_PERIODINFO']._serialized_end=252 28 | _globals['_AUTOTIMETABLERESPONSE']._serialized_start=254 29 | _globals['_AUTOTIMETABLERESPONSE']._serialized_end=309 30 | _globals['_AUTOTIMETABLER']._serialized_start=311 31 | _globals['_AUTOTIMETABLER']._serialized_end=395 32 | # @@protoc_insertion_point(module_scope) 33 | -------------------------------------------------------------------------------- /auto_server/requirements.txt: -------------------------------------------------------------------------------- 1 | absl-py==2.1.0 2 | certifi==2025.1.31 3 | grpcio==1.65.1 4 | immutabledict==4.2.0 5 | numpy==2.1.1 6 | ortools==9.10.4067 7 | pandas==2.2.2 8 | protobuf==5.27.2 9 | python-dateutil==2.9.0.post0 10 | pytz==2025.2 11 | sentry-sdk==2.8.0 12 | six==1.16.0 13 | tzdata==2025.2 14 | urllib3==2.2.2 -------------------------------------------------------------------------------- /auto_server/server.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | from concurrent import futures 4 | 5 | import grpc 6 | import sentry_sdk 7 | 8 | import auto 9 | import autotimetabler_pb2 10 | import autotimetabler_pb2_grpc 11 | 12 | # the command to compile proto file --> python -m grpc_tools.protoc -I./ --python_out=. --grpc_python_out=. ./autotimetabler.proto 13 | 14 | sentry_sdk.init( 15 | os.environ.get("SENTRY_INGEST_AUTO_SERVER"), 16 | traces_sample_rate=float(os.environ.get("SENTRY_TRACE_RATE_AUTO_SERVER", "0")), 17 | ) 18 | 19 | class AutoTimetablerServicer(autotimetabler_pb2_grpc.AutoTimetablerServicer): 20 | def FindBestTimetable(self, request, _context): 21 | """Passes request to auto algorithm. 22 | 23 | Args: 24 | request (request): grpc request message 25 | 26 | Returns: 27 | [int]: times 28 | """ 29 | logging.info("Finding a timetable!") 30 | allocatedTimes, isOptimal = auto.sols(request) 31 | logging.info(allocatedTimes) 32 | return autotimetabler_pb2.AutoTimetableResponse(times=allocatedTimes, optimal=isOptimal) 33 | 34 | def serve(): 35 | server = grpc.server(futures.ThreadPoolExecutor(max_workers=10)) 36 | autotimetabler_pb2_grpc.add_AutoTimetablerServicer_to_server(AutoTimetablerServicer(), server) 37 | server.add_insecure_port('[::]:50051') 38 | server.start() 39 | logging.info("Server started, listening on port 50051") 40 | server.wait_for_termination() 41 | 42 | if __name__ == '__main__': 43 | logging.basicConfig(level=logging.INFO) 44 | serve() 45 | -------------------------------------------------------------------------------- /client/.dockerignore: -------------------------------------------------------------------------------- 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 | -------------------------------------------------------------------------------- /client/.eslintrc.js: -------------------------------------------------------------------------------- 1 | export default { 2 | env: { 3 | browser: true, 4 | es2021: true, 5 | }, 6 | extends: ['eslint:recommended', 'plugin:@typescript-eslint/recommended', 'plugin:react/jsx-runtime', 'prettier'], 7 | overrides: [ 8 | { 9 | env: { 10 | node: true, 11 | }, 12 | files: ['.eslintrc.{js,cjs}'], 13 | parserOptions: { 14 | sourceType: 'script', 15 | }, 16 | }, 17 | ], 18 | parser: '@typescript-eslint/parser', 19 | parserOptions: { 20 | ecmaVersion: 'latest', 21 | sourceType: 'module', 22 | }, 23 | plugins: ['@typescript-eslint', 'react', 'simple-import-sort', 'unused-imports'], 24 | rules: { 25 | 'simple-import-sort/imports': 'error', 26 | 'simple-import-sort/exports': 'error', 27 | '@typescript-eslint/no-unused-vars': 'off', 28 | 'unused-imports/no-unused-imports': 'error', 29 | 'unused-imports/no-unused-vars': [ 30 | 'warn', 31 | { vars: 'all', varsIgnorePattern: '^_', args: 'after-used', argsIgnorePattern: '^_' }, 32 | ], 33 | '@typescript-eslint/no-explicit-any': 'warn', 34 | }, 35 | }; 36 | -------------------------------------------------------------------------------- /client/.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 | /dist 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 | -------------------------------------------------------------------------------- /client/.npmrc: -------------------------------------------------------------------------------- 1 | auto-install-peers=true 2 | save-exact=true -------------------------------------------------------------------------------- /client/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "all", 3 | "tabWidth": 2, 4 | "semi": true, 5 | "singleQuote": true, 6 | "printWidth": 120 7 | } 8 | -------------------------------------------------------------------------------- /client/Caddyfile: -------------------------------------------------------------------------------- 1 | :80 { 2 | log 3 | header -Server 4 | 5 | root * /srv 6 | encode zstd gzip 7 | try_files {path} /index.html 8 | file_server 9 | } 10 | 11 | -------------------------------------------------------------------------------- /client/Dockerfile: -------------------------------------------------------------------------------- 1 | # Grab the latest Node base image 2 | FROM node:22.15.0-alpine AS builder 3 | RUN npm i -g pnpm 4 | 5 | # Set the current working directory inside the container 6 | WORKDIR /app 7 | 8 | COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ 9 | RUN pnpm install --frozen-lockfile 10 | 11 | COPY . . 12 | 13 | ARG FACEBOOK_APP_ID 14 | ARG GOOGLE_ANALYTICS_ID 15 | ARG GOOGLE_API_KEY 16 | ARG GOOGLE_OAUTH_CLIENT_ID 17 | ARG SENTRY_INGEST_CLIENT 18 | ARG SENTRY_TRACE_RATE_CLIENT 19 | 20 | ENV VITE_APP_FACEBOOK_APP_ID=$FACEBOOK_APP_ID 21 | ENV VITE_APP_GOOGLE_ANALYTICS_ID=$GOOGLE_ANALYTICS_ID 22 | ENV VITE_APP_GOOGLE_API_KEY=$GOOGLE_API_KEY 23 | ENV VITE_APP_GOOGLE_OAUTH_CLIENT_ID=$GOOGLE_OAUTH_CLIENT_ID 24 | ENV VITE_APP_SENTRY_INGEST_CLIENT=$SENTRY_INGEST_CLIENT 25 | ENV VITE_APP_SENTRY_TRACE_RATE_CLIENT=$SENTRY_TRACE_RATE_CLIENT 26 | 27 | ENV VITE_APP_ENVIRONMENT=production 28 | ENV NODE_ENV=production 29 | 30 | ARG GIT_COMMIT 31 | RUN if [ -n "$GIT_COMMIT" ]; then export VITE_COMMIT=$GIT_COMMIT; fi && \ 32 | pnpm run build 33 | 34 | FROM caddy:2.10.0-alpine 35 | COPY ./Caddyfile /etc/caddy/Caddyfile 36 | COPY --from=builder /app/dist /srv 37 | -------------------------------------------------------------------------------- /client/README.md: -------------------------------------------------------------------------------- 1 | # Notangles Client 2 | 3 | The Notangles client allows users to interactively plan out their timetables with the latest course information using a simple drag-and-drop system. 4 | 5 | ## Installation & Running 6 | 7 | The client has been verified to work with: 8 | 9 | - pnpm v10.10.0 10 | - node v22.16.0 11 | 12 | ```bash 13 | # prerequisite 14 | $ cd client 15 | 16 | # installation 17 | $ pnpm i 18 | 19 | # running 20 | $ pnpm start # (if you already have the timetable server running locally; connects to that) 21 | 22 | $ pnpm run start:mock #(if you don’t have the timetable server running locally; connects to our real server) 23 | ``` 24 | 25 | > Note: both `pnpm start` and `pnpm run start:mock` connect to the local autotimetabler locally if it is running 26 | > You can then access the client at `http://localhost:5173`. 27 | 28 | ## Tech stack 29 | 30 | The Notangles client uses 31 | 32 | - [React](https://reactjs.org/) 33 | - [TypeScript](https://www.typescriptlang.org/) 34 | - [MUI](https://mui.com/) 35 | 36 | ## Logic 37 | 38 | - The drag and drop feature uses 3 layers. The first and bottommost layer displays the timetable skeleton. The second and middle layer displays all the drop zones for a class. The third and topmost layer displays the class objects that have been dropped into the timetable. 39 | - The client initially fetches details of all courses from the backend and displays them in the dropdown menu. 40 | - When a user selects a course in the dropdown menu, the client fetches more information about the selected course from the backend, which it then uses to generate draggeable class objects for that course. 41 | - When a class object is being dragged, the second layer is used to show all the drop zones for it according to its class times. 42 | -------------------------------------------------------------------------------- /client/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | Notangles - Timetable Planner 9 | 10 | 11 | 15 | 19 | 23 | 24 | 25 | 39 | 40 | 41 | 42 |
43 | 44 | 45 | 46 | -------------------------------------------------------------------------------- /client/pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | onlyBuiltDependencies: 2 | - '@swc/core' 3 | - '@tailwindcss/oxide' 4 | - esbuild 5 | -------------------------------------------------------------------------------- /client/postcss.config.ts: -------------------------------------------------------------------------------- 1 | export default { 2 | plugins: { '@tailwindcss/postcss': {}, autoprefixer: {} }, 3 | }; 4 | -------------------------------------------------------------------------------- /client/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devsoc-unsw/notangles/f667a2f4b48f144205651f1d7af65f6fc3bcf276/client/public/favicon.ico -------------------------------------------------------------------------------- /client/public/icon-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devsoc-unsw/notangles/f667a2f4b48f144205651f1d7af65f6fc3bcf276/client/public/icon-192x192.png -------------------------------------------------------------------------------- /client/public/icon-256x256.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devsoc-unsw/notangles/f667a2f4b48f144205651f1d7af65f6fc3bcf276/client/public/icon-256x256.png -------------------------------------------------------------------------------- /client/public/icon-384x384.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devsoc-unsw/notangles/f667a2f4b48f144205651f1d7af65f6fc3bcf276/client/public/icon-384x384.png -------------------------------------------------------------------------------- /client/public/icon-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devsoc-unsw/notangles/f667a2f4b48f144205651f1d7af65f6fc3bcf276/client/public/icon-512x512.png -------------------------------------------------------------------------------- /client/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "theme_color": "#3a76f8", 3 | "background_color": "#3a76f8", 4 | "display": "standalone", 5 | "scope": "/", 6 | "start_url": "/", 7 | "name": "Notangles Trimester Timetabler", 8 | "short_name": "Notangles", 9 | "description": "Trimester timetabler tool for UNSW students - no more timetable tangles \ud83e\uddf6", 10 | "icons": [ 11 | { 12 | "purpose": "any maskable", 13 | "src": "/icon-192x192.png", 14 | "sizes": "192x192", 15 | "type": "image/png" 16 | }, 17 | { 18 | "purpose": "any maskable", 19 | "src": "/icon-256x256.png", 20 | "sizes": "256x256", 21 | "type": "image/png" 22 | }, 23 | { 24 | "purpose": "any maskable", 25 | "src": "/icon-384x384.png", 26 | "sizes": "384x384", 27 | "type": "image/png" 28 | }, 29 | { 30 | "purpose": "any maskable", 31 | "src": "/icon-512x512.png", 32 | "sizes": "512x512", 33 | "type": "image/png" 34 | } 35 | ] 36 | } 37 | -------------------------------------------------------------------------------- /client/public/robots.txt: -------------------------------------------------------------------------------- 1 | User-agent: * 2 | Allow: / -------------------------------------------------------------------------------- /client/src/App.test.tsx: -------------------------------------------------------------------------------- 1 | import ReactDOM from 'react-dom'; 2 | import { it } from 'vitest'; 3 | 4 | import App from './App'; 5 | 6 | it('renders without crashing', () => { 7 | const div = document.createElement('div'); 8 | ReactDOM.render(, div); 9 | ReactDOM.unmountComponentAtNode(div); 10 | }); 11 | -------------------------------------------------------------------------------- /client/src/api/config.ts: -------------------------------------------------------------------------------- 1 | import { ApolloClient, InMemoryCache } from '@apollo/client'; 2 | 3 | export enum Env { 4 | DEV = 'development', 5 | TEST = 'test', 6 | MOCK = 'mock', 7 | PROD = 'production', 8 | } 9 | 10 | interface Config { 11 | timetable?: string; 12 | auto: string; 13 | server: string; 14 | } 15 | const HASURAGRES_GRAPHQL_API = 'https://graphql.csesoc.app/v1/graphql'; 16 | const LOCAL = 'http://localhost:3001'; 17 | 18 | export const client = new ApolloClient({ 19 | uri: HASURAGRES_GRAPHQL_API, 20 | cache: new InMemoryCache(), 21 | }); 22 | 23 | const API_CONFIG: Record = Object.freeze({ 24 | [Env.DEV]: { timetable: `${LOCAL}/api`, auto: `${LOCAL}/api/auto`, server: `${LOCAL}/api` }, 25 | [Env.TEST]: { timetable: `${LOCAL}/api`, auto: `${LOCAL}/api/auto`, server: `${LOCAL}/api` }, 26 | [Env.MOCK]: { auto: `${LOCAL}/api/auto`, server: `${LOCAL}/api` }, 27 | [Env.PROD]: { auto: `/api/auto`, server: `/api` }, 28 | }); 29 | export const API_URL: Config = API_CONFIG[import.meta.env.VITE_APP_ENVIRONMENT || Env.DEV]; 30 | -------------------------------------------------------------------------------- /client/src/api/getAutoTimetable.ts: -------------------------------------------------------------------------------- 1 | import NetworkError from '../interfaces/NetworkError'; 2 | import { API_URL } from './config'; 3 | 4 | const getAutoTimetable = async (data: any): Promise<[number[], boolean]> => { 5 | try { 6 | const res = await fetch(`${API_URL.auto}`, { 7 | method: 'POST', 8 | headers: { 9 | Accept: 'application/json', 10 | 'Content-Type': 'application/json', 11 | }, 12 | body: JSON.stringify(data), 13 | credentials: 'include', 14 | }); 15 | 16 | if (res.status !== 201) { 17 | throw new NetworkError("Couldn't get response"); 18 | } 19 | 20 | const content = await res.json(); 21 | return [content.given, content.optimal]; 22 | } catch (error) { 23 | throw new NetworkError(`Couldn't get response`); 24 | } 25 | }; 26 | 27 | export default getAutoTimetable; 28 | -------------------------------------------------------------------------------- /client/src/api/getCoursesList.ts: -------------------------------------------------------------------------------- 1 | import { gql } from '@apollo/client'; 2 | 3 | import { client } from '../api/config'; 4 | import { CoursesList, CoursesListWithDate, FetchedCourse } from '../interfaces/Courses'; 5 | import NetworkError from '../interfaces/NetworkError'; 6 | 7 | const toCoursesList = (data: FetchedCourse[]): CoursesList => 8 | data.map((course) => ({ 9 | code: course.course_code, 10 | name: course.course_name, 11 | online: course.online, 12 | inPerson: course.inPerson, 13 | career: course.career, 14 | faculty: course.faculty, 15 | })); 16 | 17 | const GET_COURSE_LIST = gql` 18 | query GetCoursesByTerm($term: String!) { 19 | courses(where: { terms: { _ilike: $term } }) { 20 | campus 21 | career 22 | faculty 23 | modes 24 | school 25 | course_code 26 | course_name 27 | terms 28 | uoc 29 | } 30 | } 31 | `; 32 | 33 | /** 34 | * Fetches a list of course objects, where each course object contains 35 | * the course id, the course code, and course name 36 | * 37 | * Expected response format: {courses: [...]}; 38 | * 39 | * @param term The term that the courses are offered in 40 | * @return A promise containing the list of course objects offered in the specified term 41 | * 42 | * @example 43 | * const coursesList = await getCoursesList('T1') 44 | */ 45 | const getCoursesList = async (term: string): Promise => { 46 | try { 47 | const termWithWildcard = `%${term}%`; 48 | const { data } = await client.query({ query: GET_COURSE_LIST, variables: { term: termWithWildcard } }); 49 | 50 | return { 51 | courses: toCoursesList(data.courses), 52 | }; 53 | } catch (error) { 54 | throw new NetworkError('Could not connect to server'); 55 | } 56 | }; 57 | 58 | export default getCoursesList; 59 | -------------------------------------------------------------------------------- /client/src/assets/AutoTimetable.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devsoc-unsw/notangles/f667a2f4b48f144205651f1d7af65f6fc3bcf276/client/src/assets/AutoTimetable.gif -------------------------------------------------------------------------------- /client/src/assets/DiscordIcon.tsx: -------------------------------------------------------------------------------- 1 | import SvgIcon, { SvgIconProps } from '@mui/material/SvgIcon'; 2 | 3 | export default function DiscordIcon(props: SvgIconProps) { 4 | return ( 5 | 6 | 7 | 8 | ); 9 | } 10 | -------------------------------------------------------------------------------- /client/src/assets/PlanAhead.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devsoc-unsw/notangles/f667a2f4b48f144205651f1d7af65f6fc3bcf276/client/src/assets/PlanAhead.gif -------------------------------------------------------------------------------- /client/src/assets/SelectCourses.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devsoc-unsw/notangles/f667a2f4b48f144205651f1d7af65f6fc3bcf276/client/src/assets/SelectCourses.gif -------------------------------------------------------------------------------- /client/src/assets/blobImage.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /client/src/assets/calendarIcon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devsoc-unsw/notangles/f667a2f4b48f144205651f1d7af65f6fc3bcf276/client/src/assets/calendarIcon.png -------------------------------------------------------------------------------- /client/src/assets/dragIcon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devsoc-unsw/notangles/f667a2f4b48f144205651f1d7af65f6fc3bcf276/client/src/assets/dragIcon.png -------------------------------------------------------------------------------- /client/src/assets/how_to_use.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devsoc-unsw/notangles/f667a2f4b48f144205651f1d7af65f6fc3bcf276/client/src/assets/how_to_use.gif -------------------------------------------------------------------------------- /client/src/assets/notangles.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devsoc-unsw/notangles/f667a2f4b48f144205651f1d7af65f6fc3bcf276/client/src/assets/notangles.gif -------------------------------------------------------------------------------- /client/src/assets/notangles_1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devsoc-unsw/notangles/f667a2f4b48f144205651f1d7af65f6fc3bcf276/client/src/assets/notangles_1.png -------------------------------------------------------------------------------- /client/src/assets/notangles_2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devsoc-unsw/notangles/f667a2f4b48f144205651f1d7af65f6fc3bcf276/client/src/assets/notangles_2.png -------------------------------------------------------------------------------- /client/src/assets/peopleIcon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devsoc-unsw/notangles/f667a2f4b48f144205651f1d7af65f6fc3bcf276/client/src/assets/peopleIcon.png -------------------------------------------------------------------------------- /client/src/assets/sponsors/arista_black.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devsoc-unsw/notangles/f667a2f4b48f144205651f1d7af65f6fc3bcf276/client/src/assets/sponsors/arista_black.png -------------------------------------------------------------------------------- /client/src/assets/sponsors/arista_white.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devsoc-unsw/notangles/f667a2f4b48f144205651f1d7af65f6fc3bcf276/client/src/assets/sponsors/arista_white.png -------------------------------------------------------------------------------- /client/src/assets/sponsors/safetyculture_black.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devsoc-unsw/notangles/f667a2f4b48f144205651f1d7af65f6fc3bcf276/client/src/assets/sponsors/safetyculture_black.png -------------------------------------------------------------------------------- /client/src/assets/sponsors/safetyculture_white.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devsoc-unsw/notangles/f667a2f4b48f144205651f1d7af65f6fc3bcf276/client/src/assets/sponsors/safetyculture_white.png -------------------------------------------------------------------------------- /client/src/assets/sponsors/thetradedesk_black.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devsoc-unsw/notangles/f667a2f4b48f144205651f1d7af65f6fc3bcf276/client/src/assets/sponsors/thetradedesk_black.png -------------------------------------------------------------------------------- /client/src/assets/sponsors/thetradedesk_white.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devsoc-unsw/notangles/f667a2f4b48f144205651f1d7af65f6fc3bcf276/client/src/assets/sponsors/thetradedesk_white.png -------------------------------------------------------------------------------- /client/src/components/Alerts.tsx: -------------------------------------------------------------------------------- 1 | import { Alert, Snackbar } from '@mui/material'; 2 | import React, { useContext } from 'react'; 3 | 4 | import { AppContext } from '../context/AppContext'; 5 | 6 | const Alerts: React.FC = () => { 7 | const { 8 | alertMsg, 9 | errorVisibility, 10 | setErrorVisibility, 11 | infoVisibility, 12 | setInfoVisibility, 13 | autoVisibility, 14 | setAutoVisibility, 15 | alertFunction, 16 | } = useContext(AppContext); 17 | 18 | const handleErrorClose = () => { 19 | setErrorVisibility(false); 20 | }; 21 | 22 | const handleInfoClose = () => { 23 | setInfoVisibility(false); 24 | }; 25 | 26 | const handleAutoClose = () => { 27 | setAutoVisibility(false); 28 | }; 29 | 30 | const getAutoSeverity = () => { 31 | if (alertMsg === 'Success!') return 'success'; 32 | if (alertMsg === 'Copied to clipboard!') return 'success'; // for copying a custom event link 33 | if (alertMsg.startsWith('Could not')) return 'warning'; 34 | if (alertMsg.startsWith('Delete')) return 'info'; // for deleting a timetable 35 | return 'error'; 36 | }; 37 | 38 | // Alerts.ts was not designed to handle onclick events and does not take props, so forgive this code 39 | const deleteAlert = alertMsg.startsWith('Delete'); 40 | return ( 41 | <> 42 | 48 | 49 | {alertMsg} 50 | 51 | 52 | 53 | 54 | Press and hold to drag a card 55 | 56 | 57 | 63 | 64 | {deleteAlert ? ( 65 | { 67 | alertFunction(); 68 | handleAutoClose(); 69 | }} 70 | > 71 | {alertMsg} 72 | 73 | ) : ( 74 | alertMsg 75 | )} 76 | 77 | 78 | 79 | ); 80 | }; 81 | 82 | export default Alerts; 83 | -------------------------------------------------------------------------------- /client/src/components/StyledDialog.tsx: -------------------------------------------------------------------------------- 1 | import { Close as CloseIcon } from '@mui/icons-material'; 2 | import { Button, Dialog, DialogActions, DialogContent, DialogTitle, IconButton } from '@mui/material'; 3 | import { styled } from '@mui/system'; 4 | import React from 'react'; 5 | 6 | const ContentContainer = styled('div')` 7 | display: flex; 8 | flex-direction: column; 9 | justify-content: space-between; 10 | height: 100%; 11 | width: 100%; 12 | padding-top: 10px; 13 | `; 14 | 15 | const StyledDialogTitleFont = styled('div')` 16 | font-size: 18px; 17 | font-weight: 500; 18 | `; 19 | 20 | const StyledDialogContent = styled(DialogContent)` 21 | padding-bottom: 20px; 22 | `; 23 | 24 | const StyledDialogButtons = styled(DialogActions)` 25 | display: flex; 26 | flex-direction: row; 27 | justify-content: flex-end; 28 | align-items: flex-end; 29 | gap: 12px; 30 | padding-bottom: 20px; 31 | padding-right: 24px; 32 | `; 33 | 34 | export const StyledDialogTitle = styled(DialogTitle)` 35 | display: flex; 36 | flex-direction: row; 37 | padding: 8px 12px 8px 24px; 38 | justify-content: space-between; 39 | align-items: center; 40 | `; 41 | 42 | const CustomCloseIconButton = styled(IconButton)` 43 | width: 40px; 44 | height: 40px; 45 | border-radius: 8px; 46 | `; 47 | 48 | // Props definition 49 | interface StyledDialogProps { 50 | open: boolean; 51 | onClose: () => void; 52 | onConfirm: () => void; 53 | title: string; 54 | content: string; 55 | confirmButtonText: string; 56 | cancelButtonText?: string; 57 | disableConfirm?: boolean; 58 | confirmButtonId?: string; 59 | } 60 | 61 | const StyledDialog: React.FC = ({ 62 | open, 63 | onClose, 64 | onConfirm, 65 | title, 66 | content, 67 | confirmButtonText, 68 | cancelButtonText = 'Cancel', 69 | disableConfirm = false, 70 | confirmButtonId, 71 | }) => ( 72 | 73 | 74 | 75 | {title} 76 | 77 | 78 | 79 | 80 | {content} 81 | 82 | 83 | 86 | 89 | 90 | 91 | ); 92 | 93 | export default StyledDialog; 94 | -------------------------------------------------------------------------------- /client/src/components/SubcomPromotion.tsx: -------------------------------------------------------------------------------- 1 | import { Announcement, Close } from '@mui/icons-material'; 2 | import { Alert, Box, IconButton, Link, Slide, Snackbar, Typography } from '@mui/material'; 3 | import { styled } from '@mui/system'; 4 | import { useCallback, useRef, useState } from 'react'; 5 | 6 | import storage from '../utils/storage'; 7 | 8 | const StyledAlertBanner = styled(Alert)(({ theme }) => ({ 9 | backgroundColor: theme.palette.background.default, 10 | color: theme.palette.text.primary, 11 | maxWidth: '30em', 12 | padding: '18px', 13 | })); 14 | 15 | const SUBCOM_PROMOTION_KEY = 'seenSubcom'; 16 | 17 | const SubcomPromotion = () => { 18 | const [seenSubcomPromotional, setSeenSubcomPromotional] = useState( 19 | storage.get(SUBCOM_PROMOTION_KEY) || false, 20 | ); 21 | const activeRecruitment = useRef(new Date().getMonth() === 1); // Subcommittee recruitment peaks in February annually 22 | 23 | const handlePromotionClose = useCallback(() => { 24 | setSeenSubcomPromotional((prev) => !prev); 25 | storage.set(SUBCOM_PROMOTION_KEY, true); 26 | }, []); 27 | 28 | const closingAction = ( 29 | 30 | 31 | 32 | ); 33 | 34 | // Not displaying subcom recruitment banner outside of active recruitment times 35 | if (!activeRecruitment.current) return null; 36 | 37 | return ( 38 | 39 | 44 | 45 | } action={closingAction}> 46 | 47 | Subcommittee Recruitment! 48 | 49 | 50 | Interested in working on Notangles or one of our other flagship projects? DevSoc is currently recruiting 51 | members for our 2024 subcommittee! 52 |
53 |
54 | Find out more at devsoc.app/get-involved 55 |
56 |
57 |
58 |
59 |
60 | ); 61 | }; 62 | 63 | export default SubcomPromotion; 64 | -------------------------------------------------------------------------------- /client/src/components/controls/ColorOptions.tsx: -------------------------------------------------------------------------------- 1 | import styled from '@emotion/styled'; 2 | import AddIcon from '@mui/icons-material/Add'; 3 | import CloseIcon from '@mui/icons-material/Close'; 4 | import { IconButton, List, ListItem } from '@mui/material'; 5 | import { FC, useContext } from 'react'; 6 | 7 | import { AppContext } from '../../context/AppContext'; 8 | import { useColorDecoder } from '../../hooks/useColorDecoder'; 9 | interface ColorOptionsProps { 10 | colors: string[]; 11 | maxDefaultColors?: number; 12 | showCustomColorPicker: boolean; 13 | onSelectColor: (color: string) => void; 14 | onCustomColorSelect: () => void; 15 | } 16 | 17 | const StyledColorIconButton = styled(IconButton, { 18 | shouldForwardProp: (prop) => prop !== 'border' && prop !== 'bgColor', 19 | })<{ border: string; bgColor: string }>(({ border, bgColor }) => ({ 20 | backgroundColor: bgColor, 21 | width: 40, 22 | height: 40, 23 | '&:hover': { 24 | backgroundColor: bgColor, 25 | border: `2px solid ${border}`, 26 | }, 27 | })); 28 | 29 | const ColorOptions: FC = ({ 30 | colors, 31 | maxDefaultColors = 4, // Default to 4 color options 32 | showCustomColorPicker, 33 | onSelectColor, 34 | onCustomColorSelect, 35 | }) => { 36 | // Get the current theme as from AppContext 37 | const { themeObject } = useContext(AppContext); 38 | 39 | const decodedColors = colors.map((color) => { 40 | const decodedColor = useColorDecoder(color); 41 | return decodedColor; 42 | }); 43 | 44 | return ( 45 | 46 | {/* Default Theme Colors (1-4) */} 47 | 48 | {colors.slice(0, maxDefaultColors).map((color, index) => ( 49 | 50 | onSelectColor(color)} 54 | /> 55 | 56 | ))} 57 | 58 | {/* Default Theme Colors (5-7) */} 59 | 60 | {colors.slice(maxDefaultColors, colors.length - 1).map((color, index) => ( 61 | 62 | onSelectColor(color)} 66 | /> 67 | 68 | ))} 69 | 70 | 75 | {showCustomColorPicker ? : } 76 | 77 | 78 | 79 | 80 | ); 81 | }; 82 | 83 | export default ColorOptions; 84 | -------------------------------------------------------------------------------- /client/src/components/controls/Controls.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Grid } from '@mui/material'; 2 | import { styled } from '@mui/system'; 3 | import React from 'react'; 4 | 5 | import { ControlsProps } from '../../interfaces/PropTypes'; 6 | import Autotimetabler from './Autotimetabler'; 7 | import CourseSelect from './CourseSelect'; 8 | import CustomEvents from './CustomEvent'; 9 | import History from './History'; 10 | import TermSelect from './TermSelect'; 11 | 12 | const TermSelectWrapper = styled(Box)` 13 | flex: 0 0 auto; 14 | margin-top: 20px; 15 | margin-right: 10px; 16 | min-width: 140px; 17 | display: flex; 18 | align-items: flex-start; 19 | ` 20 | 21 | const SelectWrapper = styled(Box)` 22 | display: flex; 23 | flex-direction: row; 24 | grid-column: 1 / -1; 25 | grid-row: 1; 26 | padding-top: 20px; 27 | flex-grow: 1; 28 | flex-shrink: 1; 29 | flex-basis: 0; 30 | `; 31 | 32 | const AutotimetablerWrapper = styled(Box)` 33 | flex: 1; 34 | 35 | ${({ theme }) => theme.breakpoints.down('sm')} { 36 | flex: none; 37 | } 38 | `; 39 | 40 | 41 | const CustomEventsWrapper = styled(Box)` 42 | flex: 1; 43 | `; 44 | 45 | const HistoryWrapper = styled(Box)` 46 | margin-top: 20px; 47 | margin-left: 3px; 48 | `; 49 | 50 | const Controls: React.FC = ({ 51 | assignedColors, 52 | handleSelectClass, 53 | handleSelectCourse, 54 | handleRemoveCourse, 55 | }) => { 56 | 57 | return ( 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 | 84 | ); 85 | }; 86 | 87 | export default Controls; 88 | -------------------------------------------------------------------------------- /client/src/components/controls/CustomEventTutoring.tsx: -------------------------------------------------------------------------------- 1 | import { Event } from '@mui/icons-material'; 2 | import ClassIcon from '@mui/icons-material/Class'; 3 | import { Autocomplete, ListItemIcon, TextField } from '@mui/material'; 4 | 5 | import { CustomEventTutoringProp } from '../../interfaces/PropTypes'; 6 | import { StyledListItem } from '../../styles/ControlStyles'; 7 | 8 | const CustomEventTutoring: React.FC = ({ 9 | coursesCodes, 10 | classesCodes, 11 | setCourseCode, 12 | setClassCode, 13 | }) => { 14 | return ( 15 | <> 16 | 17 | 18 | 19 | 20 | } 24 | fullWidth 25 | autoHighlight 26 | noOptionsText="No Results" 27 | onChange={(_, value) => (value ? setCourseCode(value.label) : setCourseCode(''))} 28 | renderOption={(props, option) => { 29 | return ( 30 |
  • 31 | {option.label} 32 |
  • 33 | ); 34 | }} 35 | isOptionEqualToValue={(option, value) => option.id === value.id && option.label === value.label} 36 | ListboxProps={{ 37 | style: { 38 | maxHeight: '120px', 39 | }, 40 | }} 41 | /> 42 |
    43 | 44 | 45 | 46 | 47 | } 51 | fullWidth 52 | autoHighlight 53 | noOptionsText="No Results" 54 | onChange={(_, value) => (value ? setClassCode(value.label) : setClassCode(''))} 55 | renderOption={(props, option) => { 56 | return ( 57 |
  • 58 | {option.label} 59 |
  • 60 | ); 61 | }} 62 | isOptionEqualToValue={(option, value) => option.id === value.id && option.label === value.label} 63 | ListboxProps={{ 64 | style: { 65 | maxHeight: '120px', 66 | }, 67 | }} 68 | /> 69 |
    70 | 71 | ); 72 | }; 73 | 74 | export default CustomEventTutoring; 75 | -------------------------------------------------------------------------------- /client/src/components/controls/customEventLink.tsx: -------------------------------------------------------------------------------- 1 | import { Link, LocationOn } from '@mui/icons-material'; 2 | import { Card, CardProps, ListItemIcon, TextField } from '@mui/material'; 3 | import { styled } from '@mui/system'; 4 | import isBase64 from 'is-base64'; 5 | import { useState } from 'react'; 6 | 7 | import { CustomEventLinkProp } from '../../interfaces/PropTypes'; 8 | import { StyledListItem } from '../../styles/ControlStyles'; 9 | import { StyledListItemText } from '../../styles/CustomEventStyles'; 10 | 11 | const PreviewCard = styled(Card)` 12 | padding: 40px 20px; 13 | margin: 10px; 14 | display: flex; 15 | flex-direction: column; 16 | justify-content: center; 17 | text-align: center; 18 | align-items: center; 19 | background-color: ${(props) => (props.bgColour !== '' ? props.bgColour : '#1f7e8c')}; 20 | border-radius: 10px; 21 | box-shadow: 0 0 10px 0 rgba(0, 0, 0, 0.1); 22 | color: white; 23 | `; 24 | 25 | const StyledLocationOn = styled(LocationOn)` 26 | font-size: 12px; 27 | `; 28 | 29 | const CustomEventLink: React.FC = ({ link, setLink, setAlertMsg, setErrorVisibility }) => { 30 | const [eventPreview, setEventPreview] = useState(false); 31 | const [event, setEvent] = useState({ name: '', location: '', color: '' }); 32 | 33 | const checkRender = (link: string) => { 34 | setLink(link); 35 | if (isBase64(link) && link.length > 0) { 36 | try { 37 | const linkEvent = JSON.parse(atob(link)); 38 | setEventPreview(true); 39 | setEvent({ name: linkEvent.event.name, location: linkEvent.event.location, color: linkEvent.event.color }); 40 | } catch { 41 | setAlertMsg('Invalid event link'); 42 | setErrorVisibility(true); 43 | setEventPreview(false); 44 | setEvent({ name: '', location: '', color: '' }); 45 | } 46 | } 47 | }; 48 | 49 | return ( 50 | <> 51 | 52 | 53 | 54 | 55 | checkRender(e.target.value)} 59 | variant="outlined" 60 | fullWidth 61 | required 62 | defaultValue={link} 63 | /> 64 |
    65 |
    66 | {eventPreview && ( 67 | 68 | 69 | {event.name} 70 |
    71 | 72 | {event.location} 73 |
    74 |
    75 | )} 76 | 77 | ); 78 | }; 79 | 80 | export default CustomEventLink; 81 | -------------------------------------------------------------------------------- /client/src/components/footer/Footer.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Divider, Stack } from '@mui/material'; 2 | import { styled } from '@mui/system'; 3 | import React from 'react'; 4 | 5 | import FooterInfo from './FooterInfo'; 6 | import FooterLinks from './FooterLinks'; 7 | 8 | const FooterContainer = styled(Box)` 9 | text-align: left; 10 | font-size: 12px; 11 | max-width: 95%; 12 | margin: 0 0 25px 60px; 13 | 14 | & div Box { 15 | line-height: 1.5; 16 | } 17 | `; 18 | 19 | const Footer: React.FC = () => { 20 | return ( 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | ); 30 | }; 31 | 32 | export default Footer; 33 | -------------------------------------------------------------------------------- /client/src/components/footer/FooterLinks.tsx: -------------------------------------------------------------------------------- 1 | import EmailIcon from '@mui/icons-material/Email'; 2 | import FacebookRoundedIcon from '@mui/icons-material/FacebookRounded'; 3 | import GitHubIcon from '@mui/icons-material/GitHub'; 4 | import InstagramIcon from '@mui/icons-material/Instagram'; 5 | import { Link, Stack } from '@mui/material'; 6 | import React from 'react'; 7 | 8 | import DiscordIcon from '../../assets/DiscordIcon'; 9 | 10 | const FooterLinks: React.FC = () => { 11 | return ( 12 | 13 |

    © UNSW Software Development Society 2025

    14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 |
    32 | ); 33 | }; 34 | 35 | export default FooterLinks; 36 | -------------------------------------------------------------------------------- /client/src/components/landingPage/FeedbackSection.tsx: -------------------------------------------------------------------------------- 1 | interface ButtonProps { 2 | text: string; 3 | link: string; 4 | } 5 | 6 | const FeedbackButton: React.FC = ({ text, link }) => ( 7 | 11 | {text} 12 | 13 | ); 14 | 15 | const FeedbackSection = () => { 16 | return ( 17 |
    18 |
    19 |

    Interested in Notangles?

    20 |

    21 | If you're a CSE student with a keen interest in Notangles and looking to get involved, keep an eye out for our 22 | recruitment announcements on DevSoc's socials. Otherwise, you can also contribute by suggesting cool new 23 | features, reporting any bugs, or even making a pull request on the Notangles repo. 24 |

    25 |
    26 | 27 | 28 |
    29 |
    30 |
    31 | ); 32 | }; 33 | 34 | export default FeedbackSection; 35 | -------------------------------------------------------------------------------- /client/src/components/landingPage/Footer.tsx: -------------------------------------------------------------------------------- 1 | import DevSocLogo from '../../assets/devsoc_white.svg'; 2 | 3 | const Footer = () => { 4 | return ( 5 |
    6 |
    7 |
    8 | Devsoc Logo 9 |

    © 2025 — UNSW Software Development Society

    10 |
    11 |
    12 |

    13 | DevSoc is the UNSW Software Development Society. We do not represent the School, Faculty, or University. 14 | This website seeks to be a centralised platform for students looking for employment opportunities, but its 15 | information has not been officially endorsed by the University, Faculty, School, or the Computer Science and 16 | Engineering Society. You should confirm with the employer that any information received through this website 17 | is correct.
    18 |
    19 | Notangles was made with by CSE students for CSE students. 20 |

    21 |
    22 |
    23 |
    24 | ); 25 | }; 26 | 27 | export default Footer; 28 | -------------------------------------------------------------------------------- /client/src/components/landingPage/HeroSection/HeroSection.tsx: -------------------------------------------------------------------------------- 1 | import { NavigateNext } from '@mui/icons-material'; 2 | 3 | import notangles from '../../../assets/notangles_1.png'; 4 | import { FlipWords } from '../flip-words'; 5 | 6 | const handleStartClick = () => { 7 | localStorage.setItem('visited', 'true'); 8 | window.location.href = '/'; 9 | }; 10 | 11 | const HeroSection = () => { 12 | const words = ['plan', 'create', 'organise', 'optimise', 'design']; 13 | 14 | return ( 15 |
    16 |
    17 |
    18 |
    25 |
    26 |

    27 | Intuitively 28 |
    29 | the perfect UNSW timetable. 30 |

    31 |

    32 | Drag and drop your university classes
    and events to prepare for a term. 33 |

    34 | 41 |
    42 |
    43 | 47 |
    48 |
    49 |
    50 |
    51 | ); 52 | }; 53 | 54 | export default HeroSection; 55 | -------------------------------------------------------------------------------- /client/src/components/landingPage/KeyFeaturesSection/FeaturesSection.tsx: -------------------------------------------------------------------------------- 1 | import blobImage from '../../../assets/blobImage.svg'; 2 | import calendarIcon from '../../../assets/calendarIcon.png'; 3 | import dragIcon from '../../../assets/dragIcon.png'; 4 | import peopleIcon from '../../../assets/peopleIcon.png'; 5 | 6 | interface FeatureBlockProps { 7 | bgCol: string; 8 | textCol: string; 9 | lineCol: string; 10 | title: string; 11 | desc: string; 12 | icon: string; 13 | } 14 | 15 | const FeatureBlock: React.FC = ({ bgCol, textCol, lineCol, title, desc, icon }) => ( 16 |
    17 |
    18 | 19 |
    20 |

    {title}

    21 |
    22 |

    {desc}

    23 |
    24 | ); 25 | 26 | const FeaturesSection = () => { 27 | const features = [ 28 | { 29 | bgCol: 'bg-[#B0C0DE]', 30 | textCol: 'text-[#5373B8]', 31 | lineCol: 'bg-[#5373B8]', 32 | title: "Drag N' Drop", 33 | desc: 'Drag and drop functionality to make planning an intuitive and easy process', 34 | icon: dragIcon, 35 | }, 36 | { 37 | bgCol: 'bg-[#70C49C]', 38 | textCol: 'text-[#53B887]', 39 | lineCol: 'bg-[#3F916A]', 40 | title: 'Add Friends', 41 | desc: 'Easily coordinate your schedules with friends to plan and attend classes together', 42 | icon: peopleIcon, 43 | }, 44 | { 45 | bgCol: 'bg-[#A96F92]', 46 | textCol: 'text-[#B75391]', 47 | lineCol: 'bg-[#964274]', 48 | title: 'Plan Ahead', 49 | desc: 'Plan in advance to secure your preferred classes and avoid conflicts', 50 | icon: calendarIcon, 51 | }, 52 | ]; 53 | return ( 54 |
    55 | {/* Background blob image */} 56 | 57 | 58 | {/* Feature Content */} 59 |
    60 |

    Introducing our Features

    61 |
    62 | {features.map((feature, index) => ( 63 | 64 | ))} 65 |
    66 |
    67 |
    68 | ); 69 | }; 70 | 71 | export default FeaturesSection; 72 | -------------------------------------------------------------------------------- /client/src/components/landingPage/LandingPage.tsx: -------------------------------------------------------------------------------- 1 | import notangles from '../../assets/notangles_1.png'; 2 | import FeedbackSection from './FeedbackSection'; 3 | import Footer from './Footer'; 4 | import HeroSection from './HeroSection/HeroSection'; 5 | import FeaturesSection from './KeyFeaturesSection/FeaturesSection'; 6 | import ScrollingFeaturesSection from './ScrollingFeaturesSection'; 7 | import SponsorsSection from './SponsorsSection'; 8 | 9 | const LandingPage = () => { 10 | return ( 11 |
    12 |
    13 |
    14 | 15 |

    Notangles

    16 |
    17 |
    18 |
    19 | 20 |
    21 |
    22 |
    23 | 24 | 25 |
    26 |
    27 | 28 |
    29 | 30 | {/* Sticky Footer */} 31 |
    32 |
    33 |
    34 | ); 35 | }; 36 | 37 | export default LandingPage; 38 | -------------------------------------------------------------------------------- /client/src/components/landingPage/ScrollingFeaturesSection.tsx: -------------------------------------------------------------------------------- 1 | import AutoTimetable from '../../assets/AutoTimetable.gif'; 2 | import PlanAhead from '../../assets/PlanAhead.gif'; 3 | import SelectCourse from '../../assets/SelectCourses.gif'; 4 | 5 | interface FeatureItemProps { 6 | number: string; 7 | title: string; 8 | description: string; 9 | gif: string; 10 | } 11 | 12 | const FeatureItem: React.FC = ({ number, title, description, gif }) => ( 13 |
    14 |
    15 |
    16 |

    {number}

    17 |
    18 |
    19 |

    {title}

    20 |
    21 |
    22 |

    {description}

    23 |
    24 |
    25 |
    26 |
    27 | 28 |
    29 |
    30 |
    31 | ); 32 | 33 | const ScrollingFeaturesSection = () => { 34 | const features = [ 35 | { 36 | number: '01', 37 | title: 'Course Selector', 38 | description: 39 | 'Search and select your desired course and get a general overview of each class, their times and locations, all before the registrations start!', 40 | gif: SelectCourse, 41 | }, 42 | { 43 | number: '02', 44 | title: 'Plan Ahead', 45 | description: 46 | 'Add any course classes or custom events to your calendar, effectively scheduling all your uni commitments!', 47 | gif: PlanAhead, 48 | }, 49 | { 50 | number: '03', 51 | title: 'Auto Timetabling', 52 | description: 'Struggling to make your ideal timetable? Put in your preferences and we’ll generate one for you!', 53 | gif: AutoTimetable, 54 | }, 55 | ]; 56 | 57 | return ( 58 |
    59 |
    60 | {features.map((feature, index) => ( 61 | 62 | ))} 63 |
    64 |
    65 | ); 66 | }; 67 | 68 | export default ScrollingFeaturesSection; 69 | -------------------------------------------------------------------------------- /client/src/components/landingPage/SponsorsSection.tsx: -------------------------------------------------------------------------------- 1 | import arista from '../../assets/sponsors/arista_black.png'; 2 | import janeStreet from '../../assets/sponsors/jane_street_black.svg'; 3 | import safetyCulture from '../../assets/sponsors/safetyculture_black.png'; 4 | import theTradeDesk from '../../assets/sponsors/thetradedesk_black.png'; 5 | 6 | const SponsorsSection = () => { 7 | return ( 8 |
    9 |

    Brought to you by

    10 |
    11 |
      12 |
    • 13 | Arista 14 |
    • 15 |
    • 16 | theTradeDesk 17 |
    • 18 |
    • 19 | Safety Culture 20 |
    • 21 |
    • 22 | Jane Street 23 |
    • 24 |
    25 |
    26 |
    27 | ); 28 | }; 29 | 30 | export default SponsorsSection; 31 | -------------------------------------------------------------------------------- /client/src/components/landingPage/flip-words.tsx: -------------------------------------------------------------------------------- 1 | 'use client'; 2 | import { AnimatePresence,motion } from 'framer-motion'; 3 | import { useCallback, useEffect, useState } from 'react'; 4 | 5 | import { cn } from '../../lib/utils'; 6 | 7 | export const FlipWords = ({ 8 | words, 9 | duration = 3000, 10 | className, 11 | }: { 12 | words: string[]; 13 | duration?: number; 14 | className?: string; 15 | }) => { 16 | const [currentWord, setCurrentWord] = useState(words[0]); 17 | const [isAnimating, setIsAnimating] = useState(false); 18 | 19 | // thanks for the fix Julian - https://github.com/Julian-AT 20 | const startAnimation = useCallback(() => { 21 | const word = words[words.indexOf(currentWord) + 1] || words[0]; 22 | setCurrentWord(word); 23 | setIsAnimating(true); 24 | }, [currentWord, words]); 25 | 26 | useEffect(() => { 27 | if (!isAnimating) 28 | setTimeout(() => { 29 | startAnimation(); 30 | }, duration); 31 | }, [isAnimating, duration, startAnimation]); 32 | 33 | return ( 34 | { 36 | setIsAnimating(false); 37 | }} 38 | > 39 | 64 | {currentWord.split('').map((letter, index) => ( 65 | 75 | {letter} 76 | 77 | ))} 78 | 79 | 80 | ); 81 | }; 82 | -------------------------------------------------------------------------------- /client/src/components/sidebar/CollapseButton.tsx: -------------------------------------------------------------------------------- 1 | import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; 2 | import { IconButton, Tooltip } from '@mui/material'; 3 | import { styled } from '@mui/system'; 4 | 5 | interface CollapseButtonProps { 6 | collapsed: boolean; 7 | onClick: () => void; 8 | toolTipTitle: string; 9 | } 10 | 11 | const StyledCollapseButton = styled(IconButton)` 12 | border-radius: 8px; 13 | color: ${({ theme }) => theme.palette.text.primary}; 14 | `; 15 | 16 | const StyledExpandMoreIcon = styled(ExpandMoreIcon)<{ collapsed: boolean }>` 17 | transform: ${({ collapsed }) => (collapsed ? 'rotate(270deg)' : 'rotate(90deg)')}; 18 | `; 19 | 20 | const CollapseButton: React.FC = ({ collapsed, onClick, toolTipTitle }) => { 21 | return ( 22 | <> 23 | 24 | 25 | 26 | 27 | 28 | 29 | ); 30 | }; 31 | 32 | export default CollapseButton; 33 | -------------------------------------------------------------------------------- /client/src/components/sidebar/ColorThemeOptions.tsx: -------------------------------------------------------------------------------- 1 | import { FormControl, FormControlLabel, Radio, RadioGroup } from '@mui/material'; 2 | import { styled } from '@mui/system'; 3 | import { useMemo } from 'react'; 4 | 5 | import { themes } from '../../constants/theme'; 6 | import { ColorThemePreview } from './ColorThemePreview'; 7 | 8 | const SettingsItem = styled('div')` 9 | margin: 0 10px; 10 | border-radius: 1rem; 11 | &:hover { 12 | background-color: ${({ theme }) => (theme.palette.mode === 'dark' ? '#333' : '#f0f0f0')}; 13 | } 14 | `; 15 | 16 | const StyledFormControlLabel = styled(FormControlLabel)` 17 | margin: 0; 18 | gap: 1rem; 19 | padding: 1vh 0; 20 | `; 21 | 22 | const StyledRadioGroup = styled(RadioGroup)` 23 | display: flex; 24 | flex-direction: column; 25 | gap: 1; 26 | `; 27 | 28 | const ControlLabelContent: React.FC<{ theme: string }> = ({ theme }) => { 29 | return ( 30 |
    38 |
    {theme}
    39 | 40 |
    41 | ); 42 | }; 43 | 44 | interface ColorThemeOptionsProps { 45 | currentTheme: string; 46 | setCurrentTheme: (theme: string) => void; 47 | } 48 | 49 | export const ColorThemeOptions: React.FC = ({ currentTheme, setCurrentTheme }) => { 50 | const themeOptions = useMemo(() => { 51 | return Object.keys(themes).map((theme) => ( 52 | 53 | } label={} /> 54 | 55 | )); 56 | }, [themes]); 57 | 58 | const handleChange = (event: React.ChangeEvent) => { 59 | setCurrentTheme((event.target as HTMLInputElement).value); 60 | }; 61 | 62 | return ( 63 | 64 | 70 | {themeOptions} 71 | 72 | 73 | ); 74 | }; 75 | -------------------------------------------------------------------------------- /client/src/components/sidebar/ColorThemePreview.tsx: -------------------------------------------------------------------------------- 1 | import { List, ListItem } from '@mui/material'; 2 | import { styled } from '@mui/system'; 3 | import { useMemo } from 'react'; 4 | 5 | import { colors } from '../../constants/timetable'; 6 | import { useColorDecoder } from '../../hooks/useColorDecoder'; 7 | 8 | const StyledPreviewContainer = styled(List)` 9 | display: flex; 10 | flex-direction: row; 11 | gap: 0.5rem; 12 | padding: 0 0.7rem; 13 | `; 14 | 15 | const StyledListItem = styled(ListItem, { 16 | shouldForwardProp: (prop) => prop !== 'backgroundColor' && prop !== 'preveiewTheme', 17 | })<{ 18 | backgroundColor: string; 19 | preveiewTheme?: string; 20 | }>` 21 | background-color: ${({ backgroundColor }) => backgroundColor}; 22 | width: 10px; 23 | height: 10px; 24 | border-radius: 1rem; 25 | box-shadow: 2px 2px 2px 0px rgba(0, 0, 0, 0.1); 26 | `; 27 | 28 | interface ColorThemePreviewProps { 29 | previewTheme?: string; 30 | } 31 | 32 | export const ColorThemePreview = ({ previewTheme }: ColorThemePreviewProps) => { 33 | const decodedColors = Object.fromEntries( 34 | Object.entries(colors).map(([key, color]) => { 35 | const decodedColor = useColorDecoder(color, previewTheme); 36 | return [key, decodedColor]; 37 | }), 38 | ); 39 | const colorPreview = useMemo(() => { 40 | return Object.values(decodedColors).map((color) => ( 41 | 42 | )); 43 | }, [colors, previewTheme]); 44 | 45 | return {colorPreview}; 46 | }; 47 | -------------------------------------------------------------------------------- /client/src/components/sidebar/CustomModal.tsx: -------------------------------------------------------------------------------- 1 | import { Close } from '@mui/icons-material'; 2 | import { Dialog, DialogContent, DialogTitle, Divider, IconButton, Tooltip, Typography } from '@mui/material'; 3 | import { styled } from '@mui/system'; 4 | import React from 'react'; 5 | 6 | import { CustomModalProps } from '../../interfaces/PropTypes'; 7 | 8 | const StyledDialogTitle = styled(DialogTitle)` 9 | background-color: ${({ theme }) => theme.palette.background.paper}; 10 | margin: 0; 11 | padding: 20px; 12 | `; 13 | 14 | const CloseButton = styled(IconButton)` 15 | position: absolute; 16 | right: 10px; 17 | top: 10px; 18 | `; 19 | 20 | const StyledTypography = styled(Typography)` 21 | margin-top: 10px; 22 | margin-bottom: 10px; 23 | `; 24 | 25 | const ShowModalButton = styled(IconButton)<{ isSelected: boolean }>` 26 | display: flex; 27 | flex-direction: row; 28 | gap: 16px; 29 | border-radius: 8px; 30 | justify-content: flex-start; 31 | padding: 12px 12px 12px 12px; 32 | background-color: ${({ isSelected }) => (isSelected ? 'rgb(157, 157, 157, 0.15)' : 'transparent')}; 33 | `; 34 | 35 | const StyledDialogContent = styled(DialogContent)` 36 | background-color: ${({ theme }) => theme.palette.background.paper}; 37 | padding: 20px; 38 | `; 39 | 40 | const IndividualComponentTypography = styled(Typography)` 41 | margin: 0px; 42 | fontsize: 16px; 43 | `; 44 | 45 | const CustomModal: React.FC = ({ 46 | title, 47 | toolTipTitle, 48 | showIcon, 49 | description, 50 | content, 51 | collapsed, 52 | isClickable, 53 | isSelected = false, 54 | }) => { 55 | const [isOpen, setIsOpen] = React.useState(false); 56 | 57 | const toggleIsOpen = () => { 58 | if (isClickable) { 59 | setIsOpen(!isOpen); 60 | } 61 | }; 62 | 63 | return ( 64 | <> 65 | 66 | 67 | {showIcon} 68 | {collapsed ? '' : title} 69 | 70 | 71 | 79 | 80 | {description} 81 | 82 | 83 | 84 | 85 | 86 | {content} 87 | 88 | 89 | ); 90 | }; 91 | 92 | export default CustomModal; 93 | -------------------------------------------------------------------------------- /client/src/components/sidebar/DarkModeButton.tsx: -------------------------------------------------------------------------------- 1 | import { LightMode as LightModeIcon, NightsStay as DarkModeIcon } from '@mui/icons-material'; 2 | import { IconButton, Tooltip, Typography } from '@mui/material'; 3 | import { styled } from '@mui/system'; 4 | import React, { useContext } from 'react'; 5 | 6 | import { AppContext } from '../../context/AppContext'; 7 | import { DarkModeButtonProps } from '../../interfaces/PropTypes'; 8 | 9 | const ToggleDarkModeButton = styled(IconButton)` 10 | display: flex; 11 | border-radius: 8px; 12 | gap: 16px; 13 | justify-content: flex-start; 14 | padding: 12px 12px 12px 12px; 15 | `; 16 | 17 | const IndividualComponentTypography = styled(Typography)<{ collapsed: boolean }>` 18 | font-size: 16px; 19 | `; 20 | 21 | const DarkModeButton: React.FC = ({ collapsed }) => { 22 | const { isDarkMode, setIsDarkMode } = useContext(AppContext); 23 | 24 | const toggleDarkMode = () => { 25 | setIsDarkMode(!isDarkMode); 26 | }; 27 | 28 | return ( 29 | <> 30 | 31 | 32 | {isDarkMode ? : } 33 | 34 | {collapsed ? '' : isDarkMode ? 'Change to Light Mode' : 'Change to Dark Mode'} 35 | 36 | 37 | 38 | 39 | ); 40 | }; 41 | 42 | export default DarkModeButton; 43 | -------------------------------------------------------------------------------- /client/src/components/sidebar/FriendsButton.tsx: -------------------------------------------------------------------------------- 1 | import { SwitchAccount } from '@mui/icons-material'; 2 | import { IconButton, Tooltip, Typography } from '@mui/material'; 3 | import { styled } from '@mui/system'; 4 | import React, { useContext } from 'react'; 5 | 6 | import { UserContext } from '../../context/UserContext'; 7 | 8 | interface FriendsButtonProps { 9 | collapsed: boolean; 10 | } 11 | 12 | const StyledFriendsButton = styled(IconButton)<{ isSelected: boolean }>` 13 | display: flex; 14 | border-radius: 8px; 15 | gap: 16px; 16 | justify-content: flex-start; 17 | padding: 12px 12px 12px 12px; 18 | background-color: ${({ isSelected }) => (isSelected ? 'rgb(157, 157, 157, 0.15)' : 'transparent')}; 19 | `; 20 | 21 | const IndividualComponentTypography = styled(Typography)<{ collapsed: boolean }>` 22 | font-size: 16px; 23 | `; 24 | 25 | const FriendsButton: React.FC = ({ collapsed }) => { 26 | const { groupsSidebarCollapsed, setGroupsSidebarCollapsed } = useContext(UserContext); 27 | 28 | return ( 29 | <> 30 | 31 | setGroupsSidebarCollapsed(!groupsSidebarCollapsed)} UNCOMMENT to see shared timetables 34 | isSelected={!groupsSidebarCollapsed} 35 | > 36 | 37 | 38 | {collapsed ? '' : 'Shared Timetables'} 39 | 40 | 41 | 42 | 43 | ); 44 | }; 45 | 46 | export default FriendsButton; 47 | -------------------------------------------------------------------------------- /client/src/components/sidebar/Tooltip.tsx: -------------------------------------------------------------------------------- 1 | import { ReactNode } from 'react'; 2 | 3 | type TooltipProps = { 4 | children: ReactNode; 5 | tooltip?: string; 6 | }; 7 | 8 | export default function Tooltip({ children, tooltip }: TooltipProps) { 9 | return ( 10 |
    11 | {children} 12 | {tooltip && ( 13 |
    19 |
    20 |
    25 | 26 | {tooltip} 27 | 28 |
    29 |
    30 | )} 31 |
    32 | ); 33 | } 34 | -------------------------------------------------------------------------------- /client/src/components/sidebar/groupsSidebar/EditImagePopover.tsx: -------------------------------------------------------------------------------- 1 | import { Edit as EditIcon } from '@mui/icons-material'; 2 | import { IconButton, Popover, TextField } from '@mui/material'; 3 | import { styled } from '@mui/system'; 4 | import React from 'react'; 5 | 6 | import { Group } from '../../../interfaces/Group'; 7 | 8 | const EditIconCircle = styled('div')` 9 | background-color: ${({ theme }) => theme.palette.background.paper}; 10 | border: 1px solid gray; 11 | border-radius: 999px; 12 | width: 35px; 13 | height: 35px; 14 | display: flex; 15 | justify-content: center; 16 | align-items: center; 17 | position: relative; 18 | top: -35px; 19 | cursor: pointer; 20 | &:hover { 21 | border: ${({ theme }) => (theme.palette.mode === 'light' ? '1px solid black' : '1px solid white;')}; 22 | } 23 | `; 24 | 25 | const StyledPopoverContent = styled('div')` 26 | width: 400px; 27 | `; 28 | 29 | interface EditImagePopOverProps { 30 | group: Group; 31 | setGroup: (group: Group) => void; 32 | } 33 | 34 | const EditImagePopOver: React.FC = ({ group, setGroup }) => { 35 | const [anchorEl, setAnchorEl] = React.useState(null); 36 | 37 | return ( 38 | 39 |
    40 | setAnchorEl(e.currentTarget)}> 41 | 42 | 43 | 44 | setAnchorEl(null)} 48 | anchorOrigin={{ 49 | vertical: 'bottom', 50 | horizontal: 'left', 51 | }} 52 | transformOrigin={{ 53 | vertical: 'top', 54 | horizontal: 'center', 55 | }} 56 | > 57 | 58 | setGroup({ ...group, imageURL: e.target.value })} 64 | /> 65 | 66 | 67 |
    68 |
    69 | ); 70 | }; 71 | 72 | export default EditImagePopOver; 73 | -------------------------------------------------------------------------------- /client/src/components/sidebar/groupsSidebar/GroupsSidebar.tsx: -------------------------------------------------------------------------------- 1 | import React, { useContext } from 'react'; 2 | 3 | import { UserContext } from '../../../context/UserContext'; 4 | import AddOrEditGroupDialog from './AddOrEditGroupDialog'; 5 | import FriendsDialog from './friends/FriendsDialog'; 6 | import GroupCircle from './GroupCircle'; 7 | 8 | const GroupsSidebar: React.FC = () => { 9 | const { user, groups, fetchUserInfo, selectedGroupIndex, setSelectedGroupIndex } = useContext(UserContext); 10 | 11 | const handleChangeSelectedGroup = (newIndex: number) => { 12 | setSelectedGroupIndex(newIndex); 13 | }; 14 | 15 | return ( 16 | <> 17 | 18 | 19 | {groups.map((group, i) => { 20 | return ( 21 | handleChangeSelectedGroup(i)} 28 | /> 29 | ); 30 | })} 31 | 32 | ); 33 | }; 34 | 35 | export default GroupsSidebar; 36 | -------------------------------------------------------------------------------- /client/src/components/sidebar/groupsSidebar/friends/FriendsDialog.tsx: -------------------------------------------------------------------------------- 1 | import { Group } from '@mui/icons-material'; 2 | import { Close as CloseIcon } from '@mui/icons-material'; 3 | import { Badge, Dialog, DialogTitle, IconButton, Paper, styled, Typography } from '@mui/material'; 4 | import React, { useState } from 'react'; 5 | 6 | import { User } from '../../UserAccount'; 7 | import FriendsTablist from './FriendsTablist'; 8 | 9 | const StyledDialogTitle = styled(DialogTitle)` 10 | background-color: ${({ theme }) => theme.palette.background.paper}; 11 | padding: 30px 30px 0px 30px; 12 | display: flex; 13 | flex-direction: row; 14 | justify-content: space-between; 15 | `; 16 | 17 | const StyledPaper = styled(Paper)` 18 | height: 80vh; 19 | overflow: hidden; 20 | display: flex; 21 | flex-direction: column; 22 | `; 23 | 24 | const FriendsDialog: React.FC<{ user: User; fetchUserInfo: (userID: string) => void }> = ({ user, fetchUserInfo }) => { 25 | const [isOpen, setIsOpen] = useState(false); 26 | 27 | const handleClose = () => setIsOpen(false); 28 | 29 | return ( 30 | <> 31 | setIsOpen(true)}> 32 | 33 | 34 | 35 | 36 | 37 | 45 | <> 46 | 47 | Friends 48 |
    49 | 50 | 51 | 52 |
    53 |
    54 | 55 | 56 |
    57 | 58 | ); 59 | }; 60 | 61 | export default FriendsDialog; 62 | -------------------------------------------------------------------------------- /client/src/components/sidebar/groupsSidebar/friends/FriendsTablist.tsx: -------------------------------------------------------------------------------- 1 | import styled from '@emotion/styled'; 2 | import { Badge } from '@mui/material'; 3 | import Box from '@mui/material/Box'; 4 | import Tab from '@mui/material/Tab'; 5 | import Tabs from '@mui/material/Tabs'; 6 | import * as React from 'react'; 7 | 8 | import { User } from '../../UserAccount'; 9 | import AddAFriendTab from './AddAFriendTab'; 10 | import RequestsTab from './RequestsTab'; 11 | import YourFriendsTab from './YourFriendsTab'; 12 | 13 | interface TabPanelProps { 14 | children?: React.ReactNode; 15 | index: number; 16 | value: number; 17 | } 18 | 19 | const MoveTextFromUnderBadge = styled('div')` 20 | margin-right: 12px; 21 | `; 22 | 23 | const CustomTabPanel = (props: TabPanelProps) => { 24 | const { children, value, index, ...other } = props; 25 | 26 | return ( 27 | 36 | ); 37 | }; 38 | 39 | const a11yProps = (index: number) => { 40 | return { 41 | id: `simple-tab-${index}`, 42 | 'aria-controls': `simple-tabpanel-${index}`, 43 | }; 44 | }; 45 | 46 | const FriendsTablist: React.FC<{ user: User; fetchUserInfo: (userID: string) => void }> = ({ user, fetchUserInfo }) => { 47 | const [value, setValue] = React.useState(0); 48 | 49 | const handleChange = (event: React.SyntheticEvent, newValue: number) => { 50 | setValue(newValue); 51 | }; 52 | 53 | return ( 54 | 55 | 56 | 57 | 58 | 61 | Requests 62 | 63 | } 64 | {...a11yProps(1)} 65 | /> 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | ); 80 | }; 81 | 82 | export default FriendsTablist; 83 | -------------------------------------------------------------------------------- /client/src/components/sidebar/groupsSidebar/friends/UserProfile.tsx: -------------------------------------------------------------------------------- 1 | import styled from '@emotion/styled'; 2 | import React from 'react'; 3 | 4 | export const emptyProfile = 'https://cdn.pixabay.com/photo/2015/10/05/22/37/blank-profile-picture-973460_960_720.png'; 5 | 6 | const StyledContainer = styled('div')` 7 | display: flex; 8 | gap: 14px; 9 | align-items: center; 10 | `; 11 | 12 | const StyledFullname = styled('div')` 13 | word-break: break-all; 14 | `; 15 | 16 | const StyledEmail = styled('div')` 17 | color: #949494; 18 | word-break: break-all; 19 | `; 20 | 21 | const UserProfile: React.FC<{ firstname: string; lastname: string; email: string; profileURL: string }> = ({ 22 | firstname, 23 | lastname, 24 | email, 25 | profileURL, 26 | }) => { 27 | const getFullName = () => { 28 | let fullname = firstname + ' ' + lastname; 29 | if (fullname.length >= 32) { 30 | fullname = fullname.slice(0, 32); 31 | return fullname + '...'; 32 | } 33 | return fullname; 34 | }; 35 | 36 | const getEmail = () => { 37 | if (email.length >= 15) { 38 | email = email.slice(0, 15); 39 | return email + '...'; 40 | } 41 | return email; 42 | }; 43 | return ( 44 | 45 | 51 |
    52 | {getFullName()} 53 | {getEmail()} 54 |
    55 |
    56 | ); 57 | }; 58 | 59 | export default UserProfile; 60 | -------------------------------------------------------------------------------- /client/src/components/sidebar/groupsSidebar/friends/YourFriendsTab.tsx: -------------------------------------------------------------------------------- 1 | import styled from '@emotion/styled'; 2 | import DeleteOutlineIcon from '@mui/icons-material/DeleteOutline'; 3 | import { IconButton, Tooltip } from '@mui/material'; 4 | import React from 'react'; 5 | 6 | import { API_URL } from '../../../../api/config'; 7 | import NetworkError from '../../../../interfaces/NetworkError'; 8 | import { User } from '../../UserAccount'; 9 | import UserProfile from './UserProfile'; 10 | 11 | const StyledContainer = styled('div')` 12 | display: flex; 13 | flex-direction: column; 14 | gap: 12px; 15 | `; 16 | 17 | const StyledItem = styled('div')` 18 | display: flex; 19 | justify-content: space-between; 20 | `; 21 | 22 | const YourFriendsTab: React.FC<{ user: User; fetchUserInfo: (userID: string) => void }> = ({ user, fetchUserInfo }) => { 23 | const handleRemoveFriend = async (friendID: string) => { 24 | try { 25 | const res = await fetch(`${API_URL.server}/friend`, { 26 | method: 'DELETE', 27 | headers: { 28 | Accept: 'application/json', 29 | 'Content-Type': 'application/json', 30 | }, 31 | body: JSON.stringify({ 32 | senderId: user.userID, 33 | sendeeId: friendID, 34 | }), 35 | credentials: 'include', 36 | }); 37 | if (res.status !== 200) throw new NetworkError("Couldn't get response"); 38 | const acceptRequestStatus = await res.json(); 39 | console.log('unfriend status', acceptRequestStatus); 40 | fetchUserInfo(user.userID); 41 | } catch (error) { 42 | throw new NetworkError(`Couldn't get response cause encountered error: ${error}`); 43 | } 44 | }; 45 | 46 | return ( 47 | 48 | {user.friends.map((friend: User, i) => ( 49 | 50 | 57 | 58 | handleRemoveFriend(friend.userID)}> 59 | 60 | 61 | 62 | 63 | ))} 64 | {user.friends.length === 0 &&
    You currently have no friends.
    } 65 |
    66 | ); 67 | }; 68 | 69 | export default YourFriendsTab; 70 | -------------------------------------------------------------------------------- /client/src/components/timetable/DiscardDialog.tsx: -------------------------------------------------------------------------------- 1 | import { Box, Button, Dialog } from '@mui/material'; 2 | import { styled } from '@mui/system'; 3 | import React from 'react'; 4 | 5 | import { DiscardDialogProps } from '../../interfaces/PropTypes'; 6 | import { StyledDialogContent, StyledTitleContainer } from '../../styles/ControlStyles'; 7 | 8 | const StyledDialogButtons = styled(Box)` 9 | display: flex; 10 | flex-direction: row; 11 | justify-content: flex-end; 12 | align-items: flex-end; 13 | padding-bottom: 5px; 14 | padding-right: 5px; 15 | `; 16 | 17 | const DiscardDialog: React.FC = ({ 18 | openSaveDialog, 19 | handleDiscardChanges, 20 | setIsEditing, 21 | setOpenSaveDialog, 22 | }) => { 23 | return ( 24 | setOpenSaveDialog(false)}> 25 | {/* This dialog pops up when user tries to exit with unsaved editing changes */} 26 | 27 | Discard unsaved changes? 28 | 29 | 30 | 38 | 39 | 40 | 41 | ); 42 | }; 43 | 44 | export default DiscardDialog; 45 | -------------------------------------------------------------------------------- /client/src/components/timetable/DropdownOption.tsx: -------------------------------------------------------------------------------- 1 | import { Grid, ListItem, ListItemText, ToggleButton, ToggleButtonGroup } from '@mui/material'; 2 | import { styled } from '@mui/system'; 3 | import React from 'react'; 4 | 5 | import { DropdownOptionProps } from '../../interfaces/PropTypes'; 6 | 7 | const StyledOptionToggle = styled(ToggleButtonGroup)` 8 | margin-top: 10px; 9 | width: 100%; 10 | `; 11 | 12 | const StyledOptionButtonToggle = styled(ToggleButton)` 13 | width: 100%; 14 | height: 32px; 15 | margin-bottom: 10px; 16 | `; 17 | 18 | const DropdownOption: React.FC = ({ 19 | optionName, 20 | optionState, 21 | setOptionState, 22 | optionChoices, 23 | multiple, 24 | noOff, 25 | }) => { 26 | const handleOptionChange = (event: React.MouseEvent, newOption: string | null) => { 27 | if (newOption !== null) { 28 | setOptionState(newOption); 29 | } 30 | }; 31 | 32 | return ( 33 | 34 | 35 | 36 | 37 | 38 | 39 | 46 | {!noOff && ( 47 | 48 | off 49 | 50 | )} 51 | {optionChoices.map((option) => ( 52 | 53 | {option} 54 | 55 | ))} 56 | 57 | 58 | 59 | 60 | ); 61 | }; 62 | 63 | export default DropdownOption; 64 | -------------------------------------------------------------------------------- /client/src/components/timetable/Dropzone.tsx: -------------------------------------------------------------------------------- 1 | import { PersonOutline, VideocamOutlined } from '@mui/icons-material'; 2 | import { styled } from '@mui/system'; 3 | import React, { useEffect, useRef } from 'react'; 4 | 5 | import { borderRadius } from '../../constants/theme'; 6 | import { ClassPeriod, InInventory } from '../../interfaces/Periods'; 7 | import { DropzoneProps } from '../../interfaces/PropTypes'; 8 | import { defaultTransition, registerDropzone, unregisterDropzone } from '../../utils/Drag'; 9 | import { classTranslateY, getClassHeight } from '../../utils/translateCard'; 10 | 11 | const StyledDropzone = styled('div', { 12 | shouldForwardProp: (prop) => 13 | !['classPeriod', 'x', 'color', 'isInventory', 'earliestStartTime'].includes(prop.toString()), 14 | })<{ 15 | classPeriod: ClassPeriod | InInventory; 16 | x: number; 17 | color: string; 18 | isInventory?: boolean; 19 | earliestStartTime: number; 20 | }>` 21 | display: inline-flex; 22 | align-items: center; 23 | justify-content: center; 24 | z-index: 50; 25 | pointer-events: none; 26 | grid-column: ${({ x }) => x}; 27 | grid-row: 2 / -1; 28 | transform: translateY( 29 | ${({ classPeriod, earliestStartTime }) => (classPeriod ? classTranslateY(classPeriod, earliestStartTime) : 0)} 30 | ); 31 | height: ${({ classPeriod, isInventory }) => (isInventory ? '100%' : getClassHeight(classPeriod))}; 32 | margin-bottom: ${1 / devicePixelRatio}px; 33 | background-color: ${({ color }) => color}; 34 | opacity: 0; 35 | transition: 36 | ${defaultTransition}, 37 | z-index 0s; 38 | border-bottom-right-radius: ${({ isInventory }) => (isInventory ? borderRadius : 0)}px; 39 | `; 40 | 41 | const Dropzone: React.FC = ({ classPeriod, x, earliestStartTime, color, isInventory }) => { 42 | const element = useRef(null); 43 | useEffect(() => { 44 | const elementCurrent = element.current; 45 | if (elementCurrent) registerDropzone(classPeriod, elementCurrent, isInventory); 46 | return () => { 47 | if (elementCurrent) unregisterDropzone(classPeriod, isInventory); 48 | }; 49 | }, []); 50 | 51 | return ( 52 | 61 | {classPeriod !== null && ( 62 | <> 63 | {classPeriod.locations.includes('Online') && } 64 | {classPeriod.locations.some((location) => location !== 'Online') && ( 65 | 66 | )} 67 | 68 | )} 69 | 70 | ); 71 | }; 72 | 73 | export default Dropzone; 74 | -------------------------------------------------------------------------------- /client/src/components/timetable/LocationDropdown.tsx: -------------------------------------------------------------------------------- 1 | import { FormControl, MenuItem, Select, Typography } from '@mui/material'; 2 | import React from 'react'; 3 | 4 | import { LocationDropdownProps } from '../../interfaces/PropTypes'; 5 | 6 | const LocationDropdown: React.FC = ({ selectedIndex, sectionsAndLocations, handleChange }) => { 7 | return ( 8 | 9 | 23 | 24 | ); 25 | }; 26 | 27 | export default LocationDropdown; 28 | -------------------------------------------------------------------------------- /client/src/components/timetable/PeriodMetadata.tsx: -------------------------------------------------------------------------------- 1 | import { LocationOn, PeopleAlt, Warning } from '@mui/icons-material'; 2 | import { yellow } from '@mui/material/colors'; 3 | import { styled } from '@mui/system'; 4 | import React, { useContext } from 'react'; 5 | 6 | import { unknownErrorMessage } from '../../constants/timetable'; 7 | import { AppContext } from '../../context/AppContext'; 8 | import { CourseContext } from '../../context/CourseContext'; 9 | import { ClassData, Status } from '../../interfaces/Periods'; 10 | import { PeriodMetadataProps } from '../../interfaces/PropTypes'; 11 | import { getClassDataFromPeriod } from '../../utils/getClassCourse'; 12 | 13 | const StyledLocationIcon = styled(LocationOn)` 14 | vertical-align: top; 15 | font-size: inherit; 16 | `; 17 | 18 | const StyledPeopleIcon = styled(PeopleAlt)` 19 | vertical-align: top; 20 | font-size: inherit; 21 | margin-right: 0.2rem; 22 | `; 23 | 24 | const StyledWarningIcon = styled(Warning)` 25 | vertical-align: top; 26 | font-size: inherit; 27 | margin-right: 0.2rem; 28 | color: ${yellow[400]}; 29 | `; 30 | 31 | const StyledCapacityIndicator = styled('span', { 32 | shouldForwardProp: (prop) => prop !== 'classStatus', 33 | })<{ 34 | classStatus: string; 35 | }>` 36 | text-overflow: ellipsis; 37 | margin: 0; 38 | font-weight: ${({ classStatus }) => (classStatus !== 'Open' ? 'bolder' : undefined)}; 39 | `; 40 | 41 | const PeriodMetadata: React.FC = ({ period }) => { 42 | const { setAlertMsg, setErrorVisibility } = useContext(AppContext); 43 | const { selectedCourses } = useContext(CourseContext); 44 | 45 | let currClass: ClassData | null = null; 46 | let classStatus: Status | null = null; 47 | 48 | try { 49 | currClass = getClassDataFromPeriod(selectedCourses, period); 50 | classStatus = currClass.status; 51 | } catch (err) { 52 | setAlertMsg(unknownErrorMessage); 53 | setErrorVisibility(true); 54 | } 55 | 56 | if (!currClass || !classStatus) return <>; 57 | 58 | const currLocation = period.locations[0]; 59 | const possibleLocations = period.locations.length; 60 | 61 | return ( 62 | <> 63 | 64 | {classStatus !== 'Open' ? : } 65 | {classStatus === 'On Hold' ? 'On Hold ' : `${currClass.enrolments}/${currClass.capacity} `} 66 | 67 | ({period.time.weeks.length > 0 ? 'Weeks' : 'Week'} {period.time.weeksString})
    68 | {currLocation ? ( 69 | <> 70 | 71 | {currLocation + (possibleLocations > 1 ? ` + ${possibleLocations - 1}` : '')} 72 | 73 | ) : ( 74 | <> 75 | )} 76 | 77 | ); 78 | }; 79 | 80 | export default PeriodMetadata; 81 | -------------------------------------------------------------------------------- /client/src/components/timetable/Timetable.tsx: -------------------------------------------------------------------------------- 1 | import { Box } from '@mui/material'; 2 | import { styled } from '@mui/system'; 3 | import React, { useContext, useState } from 'react'; 4 | 5 | import { contentPadding, inventoryMargin } from '../../constants/theme'; 6 | import { timetableWidth } from '../../constants/timetable'; 7 | import { AppContext } from '../../context/AppContext'; 8 | import { EventPeriod } from '../../interfaces/Periods'; 9 | import { TimetableProps } from '../../interfaces/PropTypes'; 10 | import DroppedCards from './DroppedCards'; 11 | import Dropzones from './Dropzones'; 12 | import { TimetableLayout } from './TimetableLayout'; 13 | 14 | const StyledTimetable = styled(Box, { 15 | shouldForwardProp: (prop) => !['rows', 'cols'].includes(prop.toString()), 16 | })<{ 17 | rows: number; 18 | cols: number; 19 | }>` 20 | display: grid; 21 | min-width: ${timetableWidth}px; 22 | padding: 0px ${contentPadding}px ${contentPadding}px ${contentPadding}px; 23 | box-sizing: content-box; 24 | user-select: none; 25 | grid-gap: 1px; 26 | grid-template: 27 | auto repeat(${({ rows }) => rows}, 1fr) 28 | / auto repeat(${({ cols }) => cols}, minmax(0, 1fr)) ${inventoryMargin}px minmax(0, 1fr); 29 | `; 30 | 31 | const StyledTimetableScroll = styled(Box)` 32 | padding: ${1 / devicePixelRatio}px; 33 | position: relative; 34 | left: -${contentPadding}px; 35 | width: calc(100% + ${contentPadding * 2 - (1 / devicePixelRatio) * 2}px); 36 | overflow-x: none; 37 | overflow-y: hidden; 38 | 39 | ${({ theme }) => theme.breakpoints.down('sm')} { 40 | overflow-x: scroll; 41 | } 42 | `; 43 | 44 | const Timetable: React.FC = ({ assignedColors, handleSelectClass }) => { 45 | const { days, earliestStartTime, latestEndTime } = useContext(AppContext); 46 | const [copiedEvent, setCopiedEvent] = useState(null); 47 | 48 | // Calculate the correct number of rows, accounting for when the earliest start time is later than latest end time. 49 | // E.g. starting at 7pm and ending at 4am. 50 | const numRows = 51 | latestEndTime > earliestStartTime ? latestEndTime - earliestStartTime : 24 - earliestStartTime + latestEndTime; 52 | 53 | return ( 54 | 55 | 56 | 57 | 58 | 64 | 65 | 66 | ); 67 | }; 68 | 69 | export default Timetable; 70 | -------------------------------------------------------------------------------- /client/src/components/timetableShared.tsx/TimetableShared.tsx: -------------------------------------------------------------------------------- 1 | import styled from '@emotion/styled'; 2 | import { Tooltip } from '@mui/material'; 3 | import React, { useContext } from 'react'; 4 | 5 | import { UserContext } from '../../context/UserContext'; 6 | import { TimetableProps } from '../../interfaces/PropTypes'; 7 | import { emptyProfile } from '../sidebar/groupsSidebar/friends/UserProfile'; 8 | import Timetable from '../timetable/Timetable'; 9 | 10 | const Container = styled('div')` 11 | display: flex; 12 | flex-direction: column; 13 | gap: 12px; 14 | `; 15 | 16 | const Legend = styled('div')` 17 | display: flex; 18 | gap: 24px; 19 | align-items: center; 20 | padding: 12px 32px; 21 | border-radius: 12px; 22 | border: 1px dotted grey; 23 | width: fit-content; 24 | margin: 12px; 25 | align-self: center; 26 | `; 27 | 28 | const Members = styled('div')` 29 | display: flex; 30 | gap: 2px; 31 | `; 32 | 33 | const MemberText = styled('div')` 34 | display: flex; 35 | gap: 2px; 36 | flex-direction: column; 37 | align-items: start; 38 | `; 39 | const GroupName = styled('div')` 40 | font-size: 18px; 41 | font-weight: 600; 42 | `; 43 | const GroupDescription = styled('div')` 44 | font-size: 14px; 45 | `; 46 | const TimetableShared: React.FC = ({ assignedColors, handleSelectClass }) => { 47 | const { groups, selectedGroupIndex } = useContext(UserContext); 48 | if (groups.length === 0) return <>; 49 | const group = groups[selectedGroupIndex]; 50 | 51 | return ( 52 | 53 | 54 | 55 | {group.name} 56 | {group.description} 57 | 58 | 59 | {group.members.map((member, i) => ( 60 | 61 | 67 | 68 | ))} 69 | 70 | 71 | 72 | 73 | ); 74 | }; 75 | 76 | export default TimetableShared; 77 | -------------------------------------------------------------------------------- /client/src/constants/defaults.ts: -------------------------------------------------------------------------------- 1 | import { createDefaultTimetable } from '../utils/timetableHelpers'; 2 | import { lightTheme, themes } from './theme'; 3 | 4 | const defaults: Record = { 5 | themeObject: lightTheme(Object.keys(themes)[0]), 6 | currentTheme: Object.keys(themes)[0], 7 | is12HourMode: true, 8 | isDarkMode: window.matchMedia('(prefers-color-scheme: dark)').matches, 9 | isSquareEdges: false, 10 | isShowOnlyOpenClasses: false, 11 | isDefaultUnscheduled: true, 12 | isHideClassInfo: false, 13 | isHideExamClasses: false, 14 | isConvertToLocalTimezone: false, 15 | courseData: { map: [] }, 16 | timetables: { T0: createDefaultTimetable('') }, 17 | }; 18 | 19 | export default defaults; 20 | -------------------------------------------------------------------------------- /client/src/context/CourseContext.tsx: -------------------------------------------------------------------------------- 1 | import { createContext, useMemo, useState } from 'react'; 2 | 3 | import { CourseData, CreatedEvents, SelectedClasses } from '../interfaces/Periods'; 4 | import { CourseContextProviderProps } from '../interfaces/PropTypes'; 5 | 6 | export interface ICourseContext { 7 | selectedCourses: CourseData[]; 8 | setSelectedCourses: (newSelectedCourses: CourseData[]) => void; 9 | 10 | selectedClasses: SelectedClasses; 11 | setSelectedClasses(newSelectedClasses: SelectedClasses): void; 12 | setSelectedClasses(callback: (oldSelectedClasses: SelectedClasses) => SelectedClasses): void; 13 | 14 | createdEvents: CreatedEvents; 15 | setCreatedEvents: (newCreatedEvents: CreatedEvents) => void; 16 | 17 | assignedColors: Record; 18 | setAssignedColors(newAssignedColours: Record): void; 19 | setAssignedColors(callback: (newAssignedColours: Record) => Record): void; 20 | } 21 | 22 | export const CourseContext = createContext({ 23 | selectedCourses: [], 24 | setSelectedCourses: () => {}, 25 | 26 | selectedClasses: {}, 27 | setSelectedClasses: () => {}, 28 | 29 | createdEvents: {}, 30 | setCreatedEvents: () => {}, 31 | 32 | assignedColors: {}, 33 | setAssignedColors: () => {}, 34 | }); 35 | 36 | const CourseContextProvider = ({ children }: CourseContextProviderProps) => { 37 | const [selectedCourses, setSelectedCourses] = useState([]); 38 | const [selectedClasses, setSelectedClasses] = useState({}); 39 | const [createdEvents, setCreatedEvents] = useState({}); 40 | const [assignedColors, setAssignedColors] = useState>({}); 41 | const initialContext = useMemo( 42 | () => ({ 43 | selectedCourses, 44 | setSelectedCourses, 45 | selectedClasses, 46 | setSelectedClasses, 47 | createdEvents, 48 | setCreatedEvents, 49 | assignedColors, 50 | setAssignedColors, 51 | }), 52 | [selectedCourses, selectedClasses, createdEvents, assignedColors], 53 | ); 54 | 55 | return {children}; 56 | }; 57 | 58 | export default CourseContextProvider; 59 | -------------------------------------------------------------------------------- /client/src/hooks/useColorDecoder.ts: -------------------------------------------------------------------------------- 1 | import { useContext } from 'react'; 2 | 3 | import { themes } from '../constants/theme'; 4 | import { AppContext } from '../context/AppContext'; 5 | 6 | /** 7 | * Decodes all assigned colours using the `useColorDecoder` function. 8 | * Converts each assigned colour key to its corresponding color value in the current or preview theme. 9 | * 10 | * @param {Record} assignedColors A record of assigned colour keys (e.g., { event1: 'default-1' }). 11 | * @param {string} [previewTheme] An optional theme to use for decoding instead of the current theme. 12 | * @returns {Record} A record of decoded colour values (e.g., { event1: 'oklch(0.8 0.1 200)' }). 13 | */ 14 | export const useColorsDecoder = (assignedColors: Record, previewTheme?: string) => { 15 | const decodedColors = Object.fromEntries( 16 | Object.entries(assignedColors).map(([key, color]) => { 17 | const decodedColor = useColorDecoder(color, previewTheme); 18 | return [key, decodedColor]; 19 | }), 20 | ); 21 | 22 | return decodedColors; 23 | }; 24 | 25 | /** 26 | * Converts an assigned colour to the corresponding colour value in the current or preview theme. 27 | * If the assigned colour is not found in the theme, it returns the original assigned colour. 28 | * 29 | * @param {string} assignedColor The assigned colour key (e.g., 'default-1'). 30 | * @param {string} [previewTheme] An optional theme to use for decoding instead of the current theme. 31 | * @returns {string} The decoded colour value (e.g., an OKLCH colour string or the original assigned colour). 32 | */ 33 | export const useColorDecoder = (assignedColor: string, previewTheme?: string) => { 34 | const { currentTheme } = useContext(AppContext); 35 | 36 | const theme = previewTheme ?? currentTheme; 37 | 38 | const themeObject = themes[theme as keyof typeof themes]; 39 | 40 | if (assignedColor.startsWith('default-')) { 41 | // extract the number from the assigned colour key 42 | const colorNumber = parseInt(assignedColor.split('-')[1], 10) - 1; 43 | return themeObject.colors[colorNumber] || assignedColor; 44 | } 45 | 46 | return assignedColor; 47 | }; 48 | -------------------------------------------------------------------------------- /client/src/hooks/useColorMapper.ts: -------------------------------------------------------------------------------- 1 | import { colors } from '../constants/timetable'; 2 | 3 | const defaultColor = colors[colors.length - 1]; 4 | 5 | /** 6 | * Assigns a unique color, chosen from a set of fixed colors, to each course code 7 | * 8 | * @param courseCodes An array containing course codes 9 | * @return An object that maps a color to each course code 10 | * 11 | * @example 12 | * const assignedColors = useColorMapper(selectedCourses.map(course => course.code)) 13 | */ 14 | const useColorMapper = (courseCodes: string[], assignedColors: Record): Record => { 15 | const takenColors = new Set(); 16 | const newAssignedColors: Record = {}; 17 | 18 | courseCodes.forEach((course) => { 19 | let color; 20 | if (course in assignedColors) { 21 | color = assignedColors[course]; 22 | newAssignedColors[course] = color || defaultColor; 23 | } 24 | 25 | if (!(course in newAssignedColors)) { 26 | color = colors.find((c) => !takenColors.has(c)); 27 | newAssignedColors[course] = color || defaultColor; 28 | } 29 | 30 | if (color) { 31 | takenColors.add(color); 32 | } 33 | }); 34 | 35 | return newAssignedColors; 36 | }; 37 | 38 | export default useColorMapper; 39 | -------------------------------------------------------------------------------- /client/src/hooks/useUpdateEffect.ts: -------------------------------------------------------------------------------- 1 | import { useEffect, useRef } from 'react'; 2 | 3 | // https://stackoverflow.com/a/55075818/1526448 4 | const useUpdateEffect = (effect: () => void, dependencies: unknown[] = []) => { 5 | const isInitialMount = useRef(true); 6 | 7 | useEffect(() => { 8 | if (isInitialMount.current) { 9 | isInitialMount.current = false; 10 | } else { 11 | effect(); 12 | } 13 | }, dependencies); 14 | }; 15 | 16 | export default useUpdateEffect; 17 | -------------------------------------------------------------------------------- /client/src/index.css: -------------------------------------------------------------------------------- 1 | @import 'tailwindcss'; 2 | 3 | @config '../tailwind.config.js'; 4 | 5 | /* 6 | The default border color has changed to `currentcolor` in Tailwind CSS v4, 7 | so we've added these compatibility styles to make sure everything still 8 | looks the same as it did with Tailwind CSS v3. 9 | 10 | If we ever want to remove these styles, we need to add an explicit border 11 | color utility to any element that depends on these defaults. 12 | */ 13 | @layer base { 14 | *, 15 | ::after, 16 | ::before, 17 | ::backdrop, 18 | ::file-selector-button { 19 | border-color: var(--color-gray-200, currentcolor); 20 | } 21 | } 22 | 23 | html, 24 | body { 25 | height: 100%; 26 | } 27 | 28 | body { 29 | margin: 0; 30 | font-family: 'Roboto Flex Variable', sans-serif; 31 | -webkit-font-smoothing: antialiased; 32 | -moz-osx-font-smoothing: grayscale; 33 | } 34 | 35 | code { 36 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace; 37 | } 38 | -------------------------------------------------------------------------------- /client/src/index.tsx: -------------------------------------------------------------------------------- 1 | import '@fontsource-variable/roboto-flex'; 2 | import './index.css'; 3 | 4 | import { ApolloProvider } from '@apollo/client'; 5 | import { browserTracingIntegration } from '@sentry/browser'; 6 | import * as Sentry from '@sentry/react'; 7 | import React from 'react'; 8 | import { createRoot } from 'react-dom/client'; 9 | import { BrowserRouter, Route, Routes } from 'react-router-dom'; 10 | 11 | import { client } from './api/config'; 12 | import App from './App'; 13 | import EventShareModal from './components/EventShareModal'; 14 | import LandingPage from './components/landingPage/LandingPage'; 15 | import AppContextProvider from './context/AppContext'; 16 | import CourseContextProvider from './context/CourseContext'; 17 | import UserContextProvider from './context/UserContext'; 18 | import * as swRegistration from './serviceWorkerRegistration'; 19 | 20 | Sentry.init({ 21 | dsn: import.meta.env.VITE_APP_SENTRY_INGEST_CLIENT, 22 | integrations: [browserTracingIntegration()], 23 | tracesSampleRate: Number(import.meta.env.VITE_APP_SENTRY_TRACE_RATE_CLIENT), 24 | }); 25 | 26 | const Root: React.FC = () => { 27 | const hasVisited = localStorage.getItem('visited'); 28 | 29 | return ( 30 | 31 | 32 | 33 | 34 | 35 | 36 | {hasVisited ? ( 37 | } path="/"> 38 | } /> 39 | 40 | ) : ( 41 | } path="/" /> 42 | )} 43 | 44 | 45 | 46 | 47 | 48 | 49 | ); 50 | }; 51 | 52 | const root = createRoot(document.getElementById('root')!); 53 | root.render(); 54 | 55 | // If you want your app to work offline and load faster, you can change 56 | // unregister() to register() below. Note this comes with some pitfalls. 57 | // Learn more about service workers: https://bit.ly/CRA-PWA 58 | swRegistration.unregister(); 59 | -------------------------------------------------------------------------------- /client/src/interfaces/Courses.ts: -------------------------------------------------------------------------------- 1 | import { CourseCode } from './Periods'; 2 | 3 | export type CoursesList = CourseOverview[]; 4 | export interface CourseOverview { 5 | code: string; 6 | name: string; 7 | online: boolean; 8 | inPerson: boolean; 9 | career: string; 10 | faculty: string; 11 | } 12 | 13 | export interface CoursesListWithDate { 14 | courses: CoursesList; 15 | } 16 | 17 | export interface FetchedCourse { 18 | course_code: CourseCode; 19 | course_name: string; 20 | online: boolean; 21 | inPerson: boolean; 22 | career: string; 23 | faculty: string; 24 | } 25 | -------------------------------------------------------------------------------- /client/src/interfaces/Database.ts: -------------------------------------------------------------------------------- 1 | import { Activity, CourseCode, Section, Status } from './Periods'; 2 | 3 | export interface DbCourse { 4 | courseCode: CourseCode; 5 | name: string; 6 | classes: DbClass[]; 7 | } 8 | 9 | export interface DbClass { 10 | activity: Activity; 11 | times: DbTimes[]; 12 | classID: string; 13 | status: Status; 14 | courseEnrolment: DbCourseEnrolment; 15 | section: Section; 16 | term: string; 17 | } 18 | 19 | export interface DbCourseEnrolment { 20 | enrolments: number; 21 | capacity: number; 22 | } 23 | 24 | export interface DbTimes { 25 | time: DbTime; 26 | day: string; 27 | location: string; 28 | weeks: string; 29 | } 30 | 31 | export interface DbTime { 32 | start: string; 33 | end: string; 34 | } 35 | -------------------------------------------------------------------------------- /client/src/interfaces/GraphQLCourseInfo.ts: -------------------------------------------------------------------------------- 1 | interface Time { 2 | __typename: 'times'; 3 | day: string; 4 | time: string; 5 | weeks: string; 6 | location: string; 7 | } 8 | 9 | interface Class { 10 | __typename: 'classes'; 11 | activity: string; 12 | status: string; 13 | course_enrolment: string; 14 | section: string; 15 | times: Time[]; 16 | term: string; 17 | class_id: string; 18 | } 19 | 20 | interface Course { 21 | __typename: 'courses'; 22 | course_code: string; 23 | course_name: string; 24 | classes: Class[]; 25 | } 26 | 27 | interface CoursesData { 28 | courses: Course[]; 29 | } 30 | 31 | export interface GraphQLCourse { 32 | data: CoursesData; 33 | loading: boolean; 34 | networkStatus: number; 35 | } 36 | -------------------------------------------------------------------------------- /client/src/interfaces/Group.ts: -------------------------------------------------------------------------------- 1 | import { User } from '../components/sidebar/UserAccount'; 2 | import { TimetableData } from './Periods'; 3 | 4 | export enum Privacy { 5 | PRIVATE = 'PRIVATE', 6 | PUBLIC = 'PUBLIC', 7 | } 8 | 9 | export interface Group { 10 | id: string; 11 | name: string; 12 | description: string; 13 | visibility: Privacy; 14 | timetables: TimetableData[]; 15 | members: User[]; 16 | groupAdmins: User[]; 17 | imageURL: string; 18 | } 19 | -------------------------------------------------------------------------------- /client/src/interfaces/NetworkError.ts: -------------------------------------------------------------------------------- 1 | export default class NetworkError extends Error { 2 | constructor(message = '') { 3 | super(message); 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /client/src/interfaces/TimeoutError.ts: -------------------------------------------------------------------------------- 1 | export default class TimeoutError extends Error { 2 | constructor(message = '') { 3 | super(message); 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /client/src/lib/utils.ts: -------------------------------------------------------------------------------- 1 | import { ClassValue, clsx } from 'clsx'; 2 | import { twMerge } from 'tailwind-merge'; 3 | 4 | export function cn(...inputs: ClassValue[]) { 5 | return twMerge(clsx(inputs)); 6 | } 7 | -------------------------------------------------------------------------------- /client/src/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /client/src/styles/ControlStyles.tsx: -------------------------------------------------------------------------------- 1 | import { Close } from '@mui/icons-material'; 2 | import { Box, DialogContent, DialogTitle, ListItem, Typography } from '@mui/material'; 3 | import { styled } from '@mui/system'; 4 | 5 | export const StyledButtonText = styled(Box)` 6 | margin-top: 3px; 7 | margin-left: 1px; 8 | flex-grow: 1; 9 | `; 10 | 11 | export const StyledControlsButton = styled('div')` 12 | display: flex; 13 | `; 14 | 15 | export const StyledCloseIcon = styled(Close)` 16 | font-size: 20px; 17 | &:hover { 18 | cursor: pointer; 19 | } 20 | `; 21 | 22 | export const ColorIndicatorBox = styled(Box, { 23 | shouldForwardProp: (prop) => prop !== 'backgroundColor', 24 | })<{ 25 | backgroundColor: string; 26 | }>` 27 | width: 35px; 28 | height: 35px; 29 | border-radius: 5px; 30 | background-color: ${({ backgroundColor }) => backgroundColor}; 31 | &:hover { 32 | cursor: pointer; 33 | } 34 | `; 35 | 36 | export const StyledButtonContainer = styled(Box)` 37 | padding-left: 16px; 38 | `; 39 | 40 | export const StyledTopIcons = styled(Box)` 41 | display: flex; 42 | flex-direction: row; 43 | justify-content: flex-end; 44 | padding: 10px 10px 0px 10px; 45 | `; 46 | 47 | export const StyledDialogButtons = styled(Box)` 48 | display: flex; 49 | flex-direction: row; 50 | justify-content: flex-end; 51 | align-items: flex-end; 52 | gap: 12px; 53 | padding-bottom: 20px; 54 | padding-right: 24px; 55 | `; 56 | 57 | export const StyledDialogTitle = styled(DialogTitle)` 58 | display: flex; 59 | flex-direction: row; 60 | padding: 8px 20px 8px 24px; 61 | justify-content: space-between; 62 | align-items: center; 63 | `; 64 | 65 | export const StyledDialogContent = styled(DialogContent)` 66 | padding-bottom: 20px; 67 | `; 68 | 69 | export const StyledTitleContainer = styled(Box)` 70 | display: flex; 71 | flex-direction: column; 72 | justify-content: space-between; 73 | height: 100%; 74 | width: 100%; 75 | padding-bottom: 10px; 76 | padding-top: 10px; 77 | `; 78 | 79 | export const StyledDialogTitleFont = styled(Typography)` 80 | font-size: 18px; 81 | font-weight: 500; 82 | `; 83 | 84 | export const StyledListItem = styled(ListItem)` 85 | padding-top: 8px; 86 | `; 87 | -------------------------------------------------------------------------------- /client/src/styles/CustomEventStyles.tsx: -------------------------------------------------------------------------------- 1 | import { Delete, LocationOn } from '@mui/icons-material'; 2 | import { TabPanel } from '@mui/lab'; 3 | import { Button, ListItemText, Menu, MenuProps } from '@mui/material'; 4 | import { alpha, styled } from '@mui/system'; 5 | 6 | export const DropdownButton = styled(Button)` 7 | && { 8 | width: 100%; 9 | height: 55px; 10 | margin-top: 20px; 11 | margin-right: 10px; 12 | text-align: left; 13 | &:hover { 14 | background-color: #598dff; 15 | } 16 | } 17 | `; 18 | 19 | export const StyledTabPanel = styled(TabPanel)` 20 | padding-bottom: 0; 21 | `; 22 | 23 | export const StyledListItemText = styled(ListItemText)` 24 | align-self: center; 25 | padding-right: 8px; 26 | `; 27 | 28 | export const ColourButton = styled(Button)` 29 | text-transform: none; 30 | `; 31 | 32 | export const ExecuteButton = styled(Button)` 33 | margin-top: 4px; 34 | height: 40px; 35 | width: 100%; 36 | border-radius: 0px 0px 5px 5px; 37 | `; 38 | 39 | export const StyledLocationIcon = styled(LocationOn)` 40 | vertical-align: text-bottom; 41 | font-size: inherit; 42 | padding-bottom: 0.1em; 43 | `; 44 | 45 | export const RedDeleteIcon = styled(Delete)` 46 | color: red; 47 | `; 48 | 49 | export const RedListItemText = styled(ListItemText)` 50 | color: red; 51 | `; 52 | 53 | export const StyledMenu = styled((props: MenuProps) => ( 54 | 66 | ))(({ theme }) => ({ 67 | '& .MuiPaper-root': { 68 | borderRadius: 10, 69 | borderWidth: 'thin', 70 | boxShadow: '0 0 2px 1px rgb(0, 0, 0, 0.2)', 71 | minWidth: 130, 72 | opacity: '0.9 !important', 73 | '& .MuiList-root': { 74 | '& .MuiMenuItem-root': { 75 | listStyle: 'none', 76 | height: '25px', 77 | marginLeft: theme.spacing(0.5), 78 | marginRight: theme.spacing(0.5), 79 | borderRadius: 5, 80 | marginBottom: '2px', 81 | '& .MuiSvgIcon-root': { 82 | fontSize: 15, 83 | marginLeft: theme.spacing(-0.5), 84 | }, 85 | '& .MuiTypography-root': { 86 | fontSize: 14, 87 | marginLeft: theme.spacing(-2), 88 | }, 89 | '&:hover': { 90 | backgroundColor: 'rgb(157, 157, 157, 0.35) !important', 91 | }, 92 | '&:active': { 93 | backgroundColor: alpha(theme.palette.grey[300], 0.5), 94 | }, 95 | }, 96 | }, 97 | }, 98 | })); 99 | -------------------------------------------------------------------------------- /client/src/styles/ExpandedViewStyles.tsx: -------------------------------------------------------------------------------- 1 | import { Divider, styled } from '@mui/material'; 2 | 3 | import { StyledListItem } from './ControlStyles'; 4 | 5 | export const ColorDivider = styled(Divider)({ 6 | padding: '10px', 7 | }); 8 | 9 | export const ColorListItem = styled(StyledListItem)({ 10 | justifyContent: 'center', 11 | }); 12 | -------------------------------------------------------------------------------- /client/src/utils/areDuplicatePeriods.ts: -------------------------------------------------------------------------------- 1 | import { ClassPeriod } from '../interfaces/Periods'; 2 | 3 | /** 4 | * @param a A class period 5 | * @param b Another class period 6 | * @returns Whether the two periods are duplicates i.e. occur at the same time and day but at different locations 7 | */ 8 | export const areDuplicatePeriods = (a: ClassPeriod, b: ClassPeriod) => 9 | a.subActivity === b.subActivity && 10 | a.time.day === b.time.day && 11 | a.time.start === b.time.start && 12 | a.time.end === b.time.end; 13 | -------------------------------------------------------------------------------- /client/src/utils/cardsContextMenu.ts: -------------------------------------------------------------------------------- 1 | import { CreatedEvents } from '../interfaces/Periods'; 2 | import { EventPeriod } from '../interfaces/Periods'; 3 | import { createEventObj } from './createEvent'; 4 | 5 | /** 6 | * Updates copiedEvent to match the cell double clicked on 7 | * @param e 8 | * @param copiedEvent 9 | * @param setCopiedEvent 10 | * @param day 11 | * @param startTime 12 | * @param setContextMenu 13 | */ 14 | export const handleContextMenu = ( 15 | e: React.MouseEvent, 16 | copiedEvent: EventPeriod | null, 17 | setCopiedEvent: (copiedEvent: EventPeriod | null) => void, 18 | day: number, 19 | startTime: number, 20 | setContextMenu: (contextMenu: null | { x: number; y: number }) => void, 21 | ) => { 22 | e.preventDefault(); 23 | setContextMenu({ x: e.clientX, y: e.clientY }); 24 | 25 | if (copiedEvent) { 26 | const copyCopiedEvent: EventPeriod = copiedEvent; 27 | const eventTimeLength: number = copiedEvent.time.end - copiedEvent.time.start; 28 | const startOffset: number = copyCopiedEvent.time.start % 1; // Get the floating point value, ie. the minutes 29 | const [start, end]: [number, number] = [ 30 | Math.floor(startTime) + startOffset, 31 | Math.floor(startTime) + startOffset + eventTimeLength, 32 | ]; 33 | 34 | copyCopiedEvent.time = { 35 | day, 36 | start, 37 | end, 38 | }; 39 | 40 | setCopiedEvent(copyCopiedEvent); 41 | } 42 | }; 43 | 44 | /** 45 | * Creates a new event that is a copy of the event details stored in copiedEvent 46 | * Updates copiedEvent to match the cell double clicked on 47 | * @param copiedEvent 48 | * @param setContextMenu 49 | * @param createdEvents 50 | * @param setCreatedEvents 51 | */ 52 | export const handlePasteEvent = ( 53 | copiedEvent: EventPeriod | null, 54 | setContextMenu: (contextMenu: null | { x: number; y: number }) => void, 55 | createdEvents: CreatedEvents, 56 | setCreatedEvents: (createdEvents: CreatedEvents) => void, 57 | ) => { 58 | if (!copiedEvent) return; 59 | const { name, location, description, color, day, start, end } = { ...copiedEvent.event, ...copiedEvent.time }; 60 | const newEvent = createEventObj(name, location, description, color, day + 1, start, end, copiedEvent.subtype); 61 | setCreatedEvents({ ...createdEvents, [newEvent.event.id]: newEvent }); 62 | setContextMenu(null); 63 | }; 64 | 65 | export const handleDeleteEvent = ( 66 | createdEvents: CreatedEvents, 67 | setCreatedEvents: (createdEvents: CreatedEvents) => void, 68 | eventPeriod: EventPeriod, 69 | ) => { 70 | const updatedEventData = { ...createdEvents }; 71 | delete updatedEventData[eventPeriod.event.id]; 72 | setCreatedEvents(updatedEventData); 73 | }; 74 | -------------------------------------------------------------------------------- /client/src/utils/convertTo24Hour.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @param n The hour to convert 3 | * @returns The hour represented in 24 hour format 4 | */ 5 | export const to24Hour = (n: number) => { 6 | // If the event time happens from 00:00 to 00:59, change the hour to 00 instead of 24 7 | const isMidnight = n == 24 || n == 0; 8 | let result = ''; 9 | 10 | if (isMidnight) { 11 | result += '00:'; 12 | } else { 13 | if ((n / 1) >> 0 < 10) { 14 | result += '0'; 15 | } 16 | result += `${String((n / 1) >> 0)}:`; 17 | } 18 | 19 | if ((n % 1) * 60) { 20 | if ((n % 1) * 60 < 10) { 21 | result += '0'; 22 | } 23 | result += `${String(((n % 1) * 60) >> 0)}`; 24 | } else { 25 | result += '00'; 26 | } 27 | 28 | return result; 29 | }; 30 | -------------------------------------------------------------------------------- /client/src/utils/createEvent.ts: -------------------------------------------------------------------------------- 1 | import { v4 as uuidv4 } from 'uuid'; 2 | 3 | import { daysShort } from '../constants/timetable'; 4 | import { EventPeriod, EventSubtype } from '../interfaces/Periods'; 5 | 6 | /** 7 | * Returns an event object with all the event info 8 | * This was made to directly pass day, startTime, endTime for creating events via link 9 | * @param name 10 | * @param location 11 | * @param description 12 | * @param color 13 | * @param day 14 | * @param startTime 15 | * @param endTime 16 | * @returns EventPeriod object 17 | */ 18 | export const createEventObj = ( 19 | name: string, 20 | location: string, 21 | description: string, 22 | color: string, 23 | day: number, 24 | startTime: number, 25 | endTime: number, 26 | subtype: EventSubtype, 27 | ): EventPeriod => { 28 | const newEvent: EventPeriod = { 29 | type: 'event', 30 | subtype: subtype, 31 | event: { 32 | id: uuidv4(), 33 | name: name, 34 | location: location, 35 | description: description, 36 | color: color, 37 | }, 38 | time: { 39 | day: day, 40 | start: startTime, 41 | end: endTime, 42 | }, 43 | }; 44 | 45 | return newEvent; 46 | }; 47 | 48 | /** 49 | * Similar to the createEventObj function 50 | * except that it converts the type of day, startTime and endTime 51 | * @param name 52 | * @param location 53 | * @param description 54 | * @param color 55 | * @param day 56 | * @param startTime 57 | * @param endTime 58 | * @returns EventPeriod object 59 | */ 60 | export const parseAndCreateEventObj = ( 61 | name: string, 62 | location: string, 63 | description: string, 64 | color: string, 65 | day: string, 66 | startTime: Date, 67 | endTime: Date, 68 | subtype: EventSubtype, 69 | ): EventPeriod => { 70 | const isMidnight = endTime.getHours() + endTime.getMinutes() / 60 === 0; 71 | const eventDay = daysShort.indexOf(day) + 1; 72 | const eventStart = startTime.getHours() + startTime.getMinutes() / 60; 73 | const eventEnd = isMidnight ? 24.0 : endTime.getHours() + endTime.getMinutes() / 60; 74 | 75 | return createEventObj(name, location, description, color, eventDay, eventStart, eventEnd, subtype); 76 | }; 77 | -------------------------------------------------------------------------------- /client/src/utils/eventTimes.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * @param start The starting time of the event 3 | * @param end The ending time of the event 4 | * @returns Whether the start and end times represent a valid event 5 | */ 6 | export const areValidEventTimes = (start: Date, end: Date) => { 7 | // Return true if the event ends at midnight 8 | if (end.getHours() + end.getMinutes() / 60 === 0) { 9 | return true; 10 | } else { 11 | return start.getHours() + start.getMinutes() / 60 < end.getHours() + end.getMinutes() / 60; 12 | } 13 | }; 14 | 15 | /** 16 | * @param time The start or end time of the event 17 | * @returns Whether the start and end times represent a valid event 18 | */ 19 | export const createDateWithTime = (time: number) => { 20 | return new Date(2022, 0, 0, time, (time - Math.floor(time)) * 60); 21 | }; 22 | 23 | /** 24 | * @param day The day of the week the event starts on 25 | * @returns An array of the days of the week starting with the given day 26 | */ 27 | export const resizeWeekArray = (day: number) => { 28 | const MondayToSunday: string[] = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday']; 29 | return MondayToSunday.slice(day); 30 | }; 31 | -------------------------------------------------------------------------------- /client/src/utils/getAllPeriods.ts: -------------------------------------------------------------------------------- 1 | import { Activity, ClassData, ClassPeriod } from '../interfaces/Periods'; 2 | 3 | /** 4 | * @param activities All activities of the course 5 | * @param activity An activity of the course 6 | * @returns A list of all periods for the activity of the course 7 | */ 8 | export const getAllPeriods = (activities: Record, activity: string) => 9 | activities[activity].reduce((prev, currClass) => [...prev, ...currClass.periods], [] as ClassPeriod[]); 10 | -------------------------------------------------------------------------------- /client/src/utils/getClassCourse.ts: -------------------------------------------------------------------------------- 1 | import { ClassData, ClassPeriod, CourseData, InventoryData, InventoryPeriod } from '../interfaces/Periods'; 2 | 3 | /** 4 | * @param selectedCourses The currently selected courses 5 | * @param data The current class's data 6 | * @returns The course data for the course associated with a particular class 7 | */ 8 | export const getCourseFromClassData = (selectedCourses: CourseData[], data: ClassData | InventoryData) => { 9 | const course = selectedCourses.find((course) => course.code === data.courseCode); 10 | if (course) { 11 | return course; 12 | } else { 13 | throw new Error(); 14 | } 15 | }; 16 | 17 | /** 18 | * @param selectedCourses The currently selected courses 19 | * @param period The current period's data 20 | * @returns The class data for the class associated with a particular period 21 | */ 22 | export const getClassDataFromPeriod = (selectedCourses: CourseData[], period: ClassPeriod | InventoryPeriod) => { 23 | const course = selectedCourses.find((course) => course.code === period.courseCode); 24 | if (!course) throw new Error(); 25 | 26 | const classData = course.activities[period.activity].find((classData) => classData.id === period.classId); 27 | if (!classData) throw new Error(); 28 | 29 | return classData; 30 | }; 31 | -------------------------------------------------------------------------------- /client/src/utils/graphQLCourseToDbCourse.ts: -------------------------------------------------------------------------------- 1 | import { DbCourse } from '../interfaces/Database'; 2 | import { GraphQLCourse } from '../interfaces/GraphQLCourseInfo'; 3 | import { Status } from '../interfaces/Periods'; 4 | 5 | const statusMapping: Record = { 6 | open: 'Open', 7 | full: 'Full', 8 | 'on hold': 'On Hold', 9 | }; 10 | 11 | /** 12 | * An adapter that formats a GraphQLCourse object to a DBCourse object 13 | * 14 | * @param graphQLCourse A GraphQLCourse object 15 | * @return A DBCourse object 16 | * 17 | * @example 18 | * const data = await client.query({query: GET_COURSE_INFO, variables: { courseCode, term }}); 19 | * const json: DbCourse = graphQLCourseToDbCourse(data); 20 | */ 21 | export const graphQLCourseToDbCourse = (graphQLCourse: GraphQLCourse): DbCourse => { 22 | const course = graphQLCourse.data.courses[0]; 23 | 24 | return { 25 | courseCode: course.course_code, 26 | name: course.course_name, 27 | classes: course.classes.map((classItem) => ({ 28 | section: classItem.section, 29 | activity: classItem.activity, 30 | status: statusMapping[classItem.status.toLowerCase()] || 'Open', 31 | courseEnrolment: { 32 | enrolments: parseInt(classItem.course_enrolment.split('/')[0].trim()), 33 | capacity: parseInt(classItem.course_enrolment.split('/')[1].trim()), 34 | }, 35 | times: classItem.times.map((time) => ({ 36 | day: time.day, 37 | time: { 38 | start: time.time.split('-')[0].trim(), 39 | end: time.time.split('-')[1]?.trim() || '', 40 | }, 41 | weeks: time.weeks, 42 | location: time.location, 43 | })), 44 | term: classItem.term, 45 | classID: classItem.class_id, 46 | })), 47 | }; 48 | }; 49 | -------------------------------------------------------------------------------- /client/src/utils/oklchCovert.ts: -------------------------------------------------------------------------------- 1 | import { oklch2hex } from 'colorizr'; 2 | 3 | export const oklchToHex = (oklch: string): string => { 4 | if (!oklch.startsWith('oklch(')) { 5 | return oklch; 6 | } 7 | 8 | const [l, c, h] = oklch 9 | .replace('oklch(', '') 10 | .replace(')', '') 11 | .split(' ') 12 | .map((v) => parseFloat(v.trim())); 13 | return oklch2hex([l, c, h]); 14 | }; 15 | -------------------------------------------------------------------------------- /client/src/utils/timeoutPromise.ts: -------------------------------------------------------------------------------- 1 | import TimeoutError from '../interfaces/TimeoutError'; 2 | 3 | /** 4 | * @param ms How many milliseconds to reject the promise after 5 | * @param promise The promise to execute 6 | * @returns A promise that is rejected after some time has elapsed 7 | */ 8 | const timeoutPromise = (ms: number, promise: Promise) => { 9 | return new Promise((resolve, reject) => { 10 | const timeoutId = setTimeout(() => { 11 | reject(new TimeoutError('timeout')); 12 | }, ms); 13 | 14 | promise.then( 15 | (res) => { 16 | clearTimeout(timeoutId); 17 | resolve(res); 18 | }, 19 | (err) => { 20 | clearTimeout(timeoutId); 21 | reject(err); 22 | }, 23 | ); 24 | }); 25 | }; 26 | 27 | export default timeoutPromise; 28 | -------------------------------------------------------------------------------- /client/tailwind.config.js: -------------------------------------------------------------------------------- 1 | import flattenColorPalette from 'tailwindcss/lib/util/flattenColorPalette'; 2 | 3 | /** @type {import('tailwindcss').Config} */ 4 | export default { 5 | content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'], 6 | theme: { 7 | extend: { 8 | keyframes: { 9 | updown: { 10 | '0%, 100%': { transform: 'translateY(0)' }, 11 | '50%': { transform: 'translateY(-10px)' }, 12 | }, 13 | 'infinite-scroll': { 14 | from: { transform: 'translateX(0)' }, 15 | to: { transform: 'translateX(-100%)' }, 16 | }, 17 | aurora: { 18 | from: { 19 | backgroundPosition: '50% 50%, 50% 50%', 20 | }, 21 | to: { 22 | backgroundPosition: '350% 50%, 350% 50%', 23 | }, 24 | }, 25 | }, 26 | animation: { 27 | updown: 'updown 1.3s ease-in-out infinite', 28 | aurora: 'aurora 60s linear infinite', 29 | 'infinite-scroll': 'infinite-scroll 25s linear infinite', 30 | }, 31 | }, 32 | }, 33 | variants: {}, 34 | plugins: [addVariablesForColors], 35 | }; 36 | 37 | // This plugin adds each Tailwind color as a global CSS variable, e.g. var(--gray-200). 38 | function addVariablesForColors({ addBase, theme }) { 39 | let allColors = flattenColorPalette(theme('colors')); 40 | let newVars = Object.fromEntries(Object.entries(allColors).map(([key, val]) => [`--${key}`, val])); 41 | 42 | addBase({ 43 | ':root': newVars, 44 | }); 45 | } 46 | -------------------------------------------------------------------------------- /client/team.json: -------------------------------------------------------------------------------- 1 | { 2 | "directors": ["Lucas Harvey", "Mark Tran"], 3 | "subcommittee": ["Ana Kiperas, Ben Quin, Fritz Rehde, Jason Poon, Kailash Siva, Meredith Zhang, Sunny Chen"] 4 | } 5 | -------------------------------------------------------------------------------- /client/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2015", 4 | "lib": ["dom", "dom.iterable", "esnext"], 5 | "allowJs": true, 6 | "skipLibCheck": true, 7 | "esModuleInterop": true, 8 | "allowSyntheticDefaultImports": true, 9 | "strict": true, 10 | "forceConsistentCasingInFileNames": true, 11 | "module": "esnext", 12 | "moduleResolution": "bundler", 13 | "resolveJsonModule": true, 14 | "isolatedModules": true, 15 | "noEmit": true, 16 | "jsx": "react-jsx", 17 | "types": ["vite/client"] 18 | }, 19 | "include": ["src"] 20 | } 21 | -------------------------------------------------------------------------------- /client/vite-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /client/vite.config.ts: -------------------------------------------------------------------------------- 1 | import react from '@vitejs/plugin-react-swc'; 2 | import tsconfigPaths from 'vite-tsconfig-paths'; 3 | import { defineConfig } from 'vitest/config'; 4 | 5 | // https://vitejs.dev/config/ 6 | export default defineConfig({ 7 | base: '/', 8 | plugins: [react(), tsconfigPaths()], 9 | build: { 10 | sourcemap: true, 11 | }, 12 | test: { 13 | globals: true, 14 | environment: 'jsdom', 15 | css: true, 16 | reporters: 'verbose', 17 | }, 18 | }); 19 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": ["config:base"], 3 | "lockFileMaintenance": { "enabled": true, "automerge": true }, 4 | "prHourlyLimit": 5, 5 | "labels": ["dependencies"], 6 | "reviewersFromCodeOwners": true, 7 | "packageRules": [ 8 | { 9 | "matchUpdateTypes": ["minor", "patch", "pin", "digest"], 10 | "automerge": true, 11 | "automergeType": "branch" 12 | }, 13 | { 14 | "groupName": "ci-actions", 15 | "managers": ["github-actions", "dockerfile"], 16 | "automerge": true, 17 | "automergeType": "branch", 18 | "addLabels": ["deps: ci-actions"] 19 | }, 20 | { 21 | "matchPackageNames": "python", 22 | "allowedVersions": "<3.11" 23 | }, 24 | { 25 | "matchUpdateTypes": ["major"], 26 | "automerge": false, 27 | "matchPackageNames": ["react", "react-dom", "@types/react", "@types/react-dom"], 28 | "groupName": "react monorepo with types", 29 | "addLabels": ["deps: react"] 30 | }, 31 | { 32 | "matchUpdateTypes": ["minor", "patch"], 33 | "groupName": "weekly minor & patch updates", 34 | "schedule": ["before 5am every monday"] 35 | }, 36 | { 37 | "matchUpdateTypes": ["minor"], 38 | "addLabels": ["deps: minor"] 39 | }, 40 | { 41 | "matchUpdateTypes": ["patch"], 42 | "addLabels": ["deps: patches"] 43 | }, 44 | { 45 | "managers": ["npm"], 46 | "addLabels": ["deps: javascript"] 47 | } 48 | ] 49 | } 50 | -------------------------------------------------------------------------------- /server/.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | npm-debug.log 3 | test 4 | .gitignore 5 | .prettierrc 6 | .git 7 | Dockerfile 8 | README.md 9 | dist -------------------------------------------------------------------------------- /server/.env.example: -------------------------------------------------------------------------------- 1 | POSTGRES_USER="postgres" 2 | POSTGRES_DB="postgresdb" 3 | POSTGRES_PASSWORD="verysneakypassword2022" 4 | POSTGRES_PORT="5432" 5 | DATABASE_URL="postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:${POSTGRES_PORT}/${POSTGRES_DB}?schema=public" 6 | OAUTH2_CLIENT_PROVIDER_OIDC_ISSUER="https://id.notangles.com.au" 7 | OAUTH2_CLIENT_REGISTRATION_LOGIN_CLIENT_ID="abcd1234" 8 | OAUTH2_CLIENT_REGISTRATION_LOGIN_CLIENT_SECRET="abcd1234" 9 | OAUTH2_CLIENT_REGISTRATION_LOGIN_SCOPE="openid" 10 | OAUTH2_CLIENT_REGISTRATION_LOGIN_REDIRECT_URI="http://somehost:3001/api/auth/callback/your_org" 11 | OAUTH2_CLIENT_REGISTRATION_LOGIN_POST_LOGOUT_REDIRECT_URI="http://somehost:3001/api/auth/callback/your_org" 12 | SESSION_SECRET="abcd1234" -------------------------------------------------------------------------------- /server/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | parser: '@typescript-eslint/parser', 3 | parserOptions: { 4 | project: 'tsconfig.json', 5 | tsconfigRootDir: __dirname, 6 | sourceType: 'module', 7 | }, 8 | plugins: ['@typescript-eslint/eslint-plugin'], 9 | extends: [ 10 | 'plugin:@typescript-eslint/recommended', 11 | 'plugin:prettier/recommended', 12 | ], 13 | root: true, 14 | env: { 15 | node: true, 16 | jest: true, 17 | }, 18 | ignorePatterns: ['.eslintrc.js'], 19 | rules: { 20 | '@typescript-eslint/interface-name-prefix': 'off', 21 | '@typescript-eslint/explicit-function-return-type': 'off', 22 | '@typescript-eslint/explicit-module-boundary-types': 'off', 23 | '@typescript-eslint/no-explicit-any': 'off', 24 | }, 25 | }; 26 | -------------------------------------------------------------------------------- /server/.gitignore: -------------------------------------------------------------------------------- 1 | # compiled output 2 | /dist 3 | /node_modules 4 | /build 5 | 6 | # Logs 7 | logs 8 | *.log 9 | npm-debug.log* 10 | pnpm-debug.log* 11 | yarn-debug.log* 12 | yarn-error.log* 13 | lerna-debug.log* 14 | 15 | # OS 16 | .DS_Store 17 | 18 | # Tests 19 | /coverage 20 | /.nyc_output 21 | 22 | # IDEs and editors 23 | /.idea 24 | .project 25 | .classpath 26 | .c9/ 27 | *.launch 28 | .settings/ 29 | *.sublime-workspace 30 | 31 | # IDE - VSCode 32 | .vscode/* 33 | !.vscode/settings.json 34 | !.vscode/tasks.json 35 | !.vscode/launch.json 36 | !.vscode/extensions.json 37 | 38 | # dotenv environment variable files 39 | .env 40 | .env.development.local 41 | .env.test.local 42 | .env.production.local 43 | .env.local 44 | 45 | # temp directory 46 | .temp 47 | .tmp 48 | 49 | # Runtime data 50 | pids 51 | *.pid 52 | *.seed 53 | *.pid.lock 54 | 55 | # Diagnostic reports (https://nodejs.org/api/report.html) 56 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 57 | -------------------------------------------------------------------------------- /server/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "all" 4 | } -------------------------------------------------------------------------------- /server/Dockerfile: -------------------------------------------------------------------------------- 1 | # Dependency versions should be pinned 2 | FROM node:22.15.0-alpine AS builder 3 | RUN apk add --no-cache openssl 4 | 5 | RUN npm i -g pnpm 6 | RUN pnpm install @nestjs/cli 7 | WORKDIR /server 8 | 9 | COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ 10 | COPY prisma ./prisma/ 11 | 12 | RUN pnpm i --frozen-lockfile 13 | 14 | COPY . . 15 | RUN pnpm run build 16 | 17 | FROM node:22.15.0-alpine 18 | WORKDIR /server 19 | RUN npm i -g pnpm 20 | 21 | ENV NODE_ENV=production 22 | COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ 23 | RUN pnpm install --production --frozen-lockfile 24 | 25 | COPY --from=builder /server . 26 | RUN npx prisma generate 27 | 28 | EXPOSE 3001 29 | 30 | CMD ["pnpm", "run", "start:migrate:prod"] 31 | -------------------------------------------------------------------------------- /server/autotimetabler.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | 4 | 5 | message TimetableConstraints { 6 | message PeriodInfo { 7 | int32 periodsPerClass = 1; 8 | repeated float periodTimes = 2; 9 | repeated float durations = 3; 10 | } 11 | int32 start = 1; 12 | int32 end = 2; 13 | string days = 3; 14 | int32 gap = 4; 15 | int32 maxdays = 5; 16 | repeated PeriodInfo periodInfo = 6; 17 | } 18 | 19 | message AutoTimetableResponse { 20 | repeated float times = 1; 21 | bool optimal = 2; 22 | } 23 | 24 | service AutoTimetabler { 25 | rpc FindBestTimetable (TimetableConstraints) returns (AutoTimetableResponse); 26 | } 27 | -------------------------------------------------------------------------------- /server/docker-compose.yaml: -------------------------------------------------------------------------------- 1 | version: '3.7' 2 | services: 3 | server: 4 | container_name: notangles-server 5 | image: notangles-server 6 | restart: always 7 | build: 8 | context: . 9 | dockerfile: Dockerfile 10 | depends_on: 11 | - database 12 | ports: 13 | - '3001:3001' 14 | networks: 15 | - notangles_network 16 | environment: 17 | - DATABASE_URL=postgresql://${POSTGRES_USER}:${POSTGRES_PASSWORD}@database:5432/${POSTGRES_DB}?schema=public 18 | 19 | database: 20 | container_name: notangles-database 21 | hostname: notangles_database 22 | restart: always 23 | image: postgres:17-alpine 24 | ports: 25 | - '5432:5432' 26 | environment: 27 | - POSTGRES_PASSWORD=${POSTGRES_PASSWORD} 28 | - POSTGRES_USER=${POSTGRES_USER} 29 | - POSTGRES_DB=${POSTGRES_DB} 30 | volumes: 31 | - postgres:/var/lib/postgresql/data 32 | networks: 33 | - notangles_network 34 | 35 | volumes: 36 | postgres: 37 | name: server 38 | networks: 39 | notangles_network: 40 | driver: bridge 41 | -------------------------------------------------------------------------------- /server/migrate.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -e 4 | 5 | cd /app/prisma 6 | 7 | # Check if there are any migrations 8 | if [ -z "$(ls -A migrations)" ]; then 9 | echo "No existing migrations found. Creating initial migration..." 10 | else 11 | echo "Existing migrations found. Deleting and reapplying..." 12 | rm -rf migrations/* 13 | fi 14 | 15 | npx prisma migrate dev --name init 16 | npx prisma generate 17 | -------------------------------------------------------------------------------- /server/nest-cli.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/nest-cli", 3 | "collection": "@nestjs/schematics", 4 | "sourceRoot": "src", 5 | "compilerOptions": { 6 | "deleteOutDir": true, 7 | "assets": [ 8 | "../prisma/*", 9 | "./proto/*" 10 | ] 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /server/pnpm-workspace.yaml: -------------------------------------------------------------------------------- 1 | onlyBuiltDependencies: 2 | - '@nestjs/core' 3 | - '@prisma/client' 4 | - '@prisma/engines' 5 | - esbuild 6 | - grpc-tools 7 | - prisma 8 | - protobufjs 9 | -------------------------------------------------------------------------------- /server/prisma/migrations/20241003042905_/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - You are about to drop the column `classType` on the `classes` table. All the data in the column will be lost. 5 | - You are about to drop the column `courseName` on the `classes` table. All the data in the column will be lost. 6 | - You are about to drop the column `userID` on the `timetables` table. All the data in the column will be lost. 7 | - Added the required column `classNo` to the `classes` table without a default value. This is not possible if the table is not empty. 8 | - Added the required column `courseCode` to the `classes` table without a default value. This is not possible if the table is not empty. 9 | - Added the required column `term` to the `classes` table without a default value. This is not possible if the table is not empty. 10 | - Added the required column `year` to the `classes` table without a default value. This is not possible if the table is not empty. 11 | - Added the required column `subtype` to the `events` table without a default value. This is not possible if the table is not empty. 12 | - Changed the type of `day` on the `events` table. No cast exists, the column would be dropped and recreated, which cannot be done if there is data, since the column is required. 13 | - Changed the type of `start` on the `events` table. No cast exists, the column would be dropped and recreated, which cannot be done if there is data, since the column is required. 14 | - Changed the type of `end` on the `events` table. No cast exists, the column would be dropped and recreated, which cannot be done if there is data, since the column is required. 15 | - Added the required column `mapKey` to the `timetables` table without a default value. This is not possible if the table is not empty. 16 | 17 | */ 18 | -- AlterTable 19 | ALTER TABLE "classes" DROP COLUMN "classType", 20 | DROP COLUMN "courseName", 21 | ADD COLUMN "classNo" TEXT NOT NULL, 22 | ADD COLUMN "courseCode" TEXT NOT NULL, 23 | ADD COLUMN "term" TEXT NOT NULL, 24 | ADD COLUMN "year" TEXT NOT NULL; 25 | 26 | -- AlterTable 27 | ALTER TABLE "events" ADD COLUMN "subtype" TEXT NOT NULL, 28 | DROP COLUMN "day", 29 | ADD COLUMN "day" INTEGER NOT NULL, 30 | DROP COLUMN "start", 31 | ADD COLUMN "start" INTEGER NOT NULL, 32 | DROP COLUMN "end", 33 | ADD COLUMN "end" INTEGER NOT NULL; 34 | 35 | -- AlterTable 36 | ALTER TABLE "timetables" DROP COLUMN "userID", 37 | ADD COLUMN "mapKey" TEXT NOT NULL; 38 | 39 | -- DropEnum 40 | DROP TYPE "ClassType"; 41 | -------------------------------------------------------------------------------- /server/prisma/migrations/20241101072349_activity/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - Added the required column `activity` to the `classes` table without a default value. This is not possible if the table is not empty. 5 | 6 | */ 7 | -- AlterTable 8 | ALTER TABLE "classes" ADD COLUMN "activity" TEXT NOT NULL; 9 | -------------------------------------------------------------------------------- /server/prisma/migrations/20250322064905_upgrade_to_v6/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "_TimetableToUser" ADD CONSTRAINT "_TimetableToUser_AB_pkey" PRIMARY KEY ("A", "B"); 3 | 4 | -- DropIndex 5 | DROP INDEX "_TimetableToUser_AB_unique"; 6 | 7 | -- AlterTable 8 | ALTER TABLE "_admins" ADD CONSTRAINT "_admins_AB_pkey" PRIMARY KEY ("A", "B"); 9 | 10 | -- DropIndex 11 | DROP INDEX "_admins_AB_unique"; 12 | 13 | -- AlterTable 14 | ALTER TABLE "_friend_requests" ADD CONSTRAINT "_friend_requests_AB_pkey" PRIMARY KEY ("A", "B"); 15 | 16 | -- DropIndex 17 | DROP INDEX "_friend_requests_AB_unique"; 18 | 19 | -- AlterTable 20 | ALTER TABLE "_group_members" ADD CONSTRAINT "_group_members_AB_pkey" PRIMARY KEY ("A", "B"); 21 | 22 | -- DropIndex 23 | DROP INDEX "_group_members_AB_unique"; 24 | 25 | -- AlterTable 26 | ALTER TABLE "_group_timetables" ADD CONSTRAINT "_group_timetables_AB_pkey" PRIMARY KEY ("A", "B"); 27 | 28 | -- DropIndex 29 | DROP INDEX "_group_timetables_AB_unique"; 30 | 31 | -- AlterTable 32 | ALTER TABLE "_user_friends" ADD CONSTRAINT "_user_friends_AB_pkey" PRIMARY KEY ("A", "B"); 33 | 34 | -- DropIndex 35 | DROP INDEX "_user_friends_AB_unique"; 36 | -------------------------------------------------------------------------------- /server/prisma/migrations/20250414034722_add_courseid_to_class/migration.sql: -------------------------------------------------------------------------------- 1 | -- AlterTable 2 | ALTER TABLE "classes" ADD COLUMN "courseId" TEXT; 3 | -------------------------------------------------------------------------------- /server/prisma/migrations/20250424101523_fill_course_id/migration.sql: -------------------------------------------------------------------------------- 1 | -- classNo is currently a string such as COMP6420Undergraduate-11965-T1-2025 2 | -- courseId should be a string such as COMP6420Undergraduate 3 | UPDATE "classes" 4 | SET "courseId" = split_part("classNo", '-', 1); 5 | -------------------------------------------------------------------------------- /server/prisma/migrations/20250424102004_require_course_id/migration.sql: -------------------------------------------------------------------------------- 1 | /* 2 | Warnings: 3 | 4 | - Made the column `courseId` on table `classes` required. This step will fail if there are existing NULL values in that column. 5 | 6 | */ 7 | -- AlterTable 8 | ALTER TABLE "classes" ALTER COLUMN "courseId" SET NOT NULL; 9 | -------------------------------------------------------------------------------- /server/prisma/migrations/20250605081924_update_color_config/migration.sql: -------------------------------------------------------------------------------- 1 | -- Update the colour column in the events table based on the MIGRATE_COLOR_MAP 2 | 3 | UPDATE "events" 4 | SET "colour" = CASE 5 | WHEN "colour" = '#137786' THEN 'default-1' 6 | WHEN "colour" = '#a843a4' THEN 'default-2' 7 | WHEN "colour" = '#134e86' THEN 'default-3' 8 | WHEN "colour" = '#138652' THEN 'default-4' 9 | WHEN "colour" = '#861313' THEN 'default-5' 10 | WHEN "colour" = '#868413' THEN 'default-6' 11 | WHEN "colour" = '#2e89ff' THEN 'default-7' 12 | WHEN "colour" = '#3323ad' THEN 'default-8' 13 | ELSE "colour" 14 | END 15 | WHERE "colour" IN ('#137786', '#a843a4', '#134e86', '#138652', '#861313', '#868413', '#2e89ff', '#3323ad'); 16 | -------------------------------------------------------------------------------- /server/prisma/migrations/migration_lock.toml: -------------------------------------------------------------------------------- 1 | # Please do not edit this file manually 2 | # It should be added in your version-control system (e.g., Git) 3 | provider = "postgresql" 4 | -------------------------------------------------------------------------------- /server/proto-gen.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | PROTO_DEST=./src/proto 4 | 5 | FINAL_TS=$PROTO_DEST/autotimetabler_grpc_pb.d.ts 6 | FINAL_JS=$PROTO_DEST/autotimetabler_grpc_pb.js 7 | 8 | npx grpc_tools_node_protoc --js_out=import_style=commonjs,binary:./src/proto --grpc_out=./src/proto -I ./ ./autotimetabler.proto 9 | npx grpc_tools_node_protoc --plugin=protoc-gen-ts=./node_modules/.bin/protoc-gen-ts --ts_out=./src/proto -I ./ ./autotimetabler.proto 10 | 11 | sed -i -r 's/(import.*")grpc/\1@grpc\/grpc-js/' $FINAL_TS # replaces grpc import with @grpc/grpc-js 12 | sed -i -r 's/(require.*'')grpc/\1@grpc\/grpc-js/' $FINAL_JS # replaces grpc import with @grpc/grpc-js 13 | 14 | python3 -m grpc_tools.protoc -I./ --python_out=../auto_server --grpc_python_out=../auto_server ./autotimetabler.proto -------------------------------------------------------------------------------- /server/src/app.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller } from '@nestjs/common'; 2 | import { AppService } from './app.service'; 3 | 4 | @Controller() 5 | export class AppController { 6 | constructor(private readonly appService: AppService) {} 7 | } 8 | -------------------------------------------------------------------------------- /server/src/app.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { ConfigModule } from '@nestjs/config'; 3 | import { AppController } from './app.controller'; 4 | import { AppService } from './app.service'; 5 | import { AuthModule } from './auth/auth.module'; 6 | import { AutoModule } from './auto/auto.module'; 7 | import config from './config'; 8 | // import { FriendModule } from './friend/friend.module'; 9 | // import { GroupModule } from './group/group.module'; 10 | import { PrismaModule } from './prisma/prisma.module'; 11 | import { UserModule } from './user/user.module'; 12 | import { GraphqlService } from './graphql/graphql.service'; 13 | import { GraphqlModule } from './graphql/graphql.module'; 14 | 15 | // TOOD: Re-enable FriendModule and GroupModule when ready 16 | // Need to be locked down better, and FE supported 17 | @Module({ 18 | imports: [ 19 | ConfigModule.forRoot({ 20 | load: [config], 21 | isGlobal: true, 22 | envFilePath: '../.env', 23 | }), 24 | AuthModule, 25 | AutoModule, 26 | UserModule, 27 | // FriendModule, 28 | PrismaModule, 29 | GraphqlModule, 30 | // GroupModule, 31 | ], 32 | controllers: [AppController], 33 | providers: [AppService, GraphqlService], 34 | }) 35 | export class AppModule {} 36 | -------------------------------------------------------------------------------- /server/src/app.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { ConfigService } from '@nestjs/config'; 3 | 4 | @Injectable() 5 | export class AppService { 6 | constructor(private configService: ConfigService) {} 7 | 8 | getServerConfig() { 9 | const env = this.configService.get('NODE_ENV'); 10 | const port = this.configService.get('PORT'); 11 | 12 | const auto = `${this.configService.get('AUTO_SERVER_HOST_NAME')}:${this.configService.get('AUTO_SERVER_HOST_PORT')}`; 13 | const client = `${this.configService.get('CLIENT_HOST_NAME')}:${this.configService.get('CLIENT_HOST_PORT')}`; 14 | const redirectLink = `https://${this.configService.get('CLIENT_HOST_NAME')}:${this.configService.get('CLIENT_HOST_PORT')}`; 15 | 16 | return { 17 | env, 18 | port, 19 | auto, 20 | client, 21 | redirectLink, 22 | }; 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /server/src/auth/auth.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { AuthController } from './auth.controller'; 3 | 4 | describe('AuthController', () => { 5 | let controller: AuthController; 6 | 7 | beforeEach(async () => { 8 | const module: TestingModule = await Test.createTestingModule({ 9 | controllers: [AuthController], 10 | }).compile(); 11 | 12 | controller = module.get(AuthController); 13 | }); 14 | 15 | it('should be defined', () => { 16 | expect(controller).toBeDefined(); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /server/src/auth/auth.controller.ts: -------------------------------------------------------------------------------- 1 | import { Controller, Get, Request, Res, UseGuards } from '@nestjs/common'; 2 | import { Response } from 'express'; 3 | 4 | import { AuthService } from './auth.service'; 5 | import { LoginGuard } from './login.guard'; 6 | import { UserService } from 'src/user/user.service'; 7 | import { ConfigService } from '@nestjs/config'; 8 | import { InitUserDTO, UserDTO } from 'src/user/dto'; 9 | 10 | @Controller('auth') 11 | export class AuthController { 12 | constructor( 13 | private authService: AuthService, 14 | private userService: UserService, 15 | private configService: ConfigService, 16 | ) {} 17 | 18 | @UseGuards(LoginGuard) 19 | @Get('/login') 20 | login() {} 21 | 22 | checkUserDataUpdatedBeforeLogin = ( 23 | userData: UserDTO, 24 | updatedUserData: InitUserDTO, 25 | ) => { 26 | if ( 27 | userData.firstname !== updatedUserData.firstname || 28 | userData.lastname !== updatedUserData.lastname || 29 | userData.email !== updatedUserData.email 30 | ) { 31 | return true; 32 | } 33 | 34 | return false; 35 | }; 36 | @Get('/user') 37 | async user(@Request() req, @Res() res: Response) { 38 | if (req.user) { 39 | const userID = req.user.userinfo.sub; 40 | const updateUserData = async () => { 41 | const userData = req.user.userinfo.userData ?? { 42 | firstName: `No First (${userID})`, 43 | lastName: 'No Last', 44 | }; 45 | await this.userService.setUserProfile({ 46 | userID: userID, 47 | email: '', 48 | firstname: userData.firstName, 49 | lastname: userData.lastName, 50 | }); 51 | }; 52 | try { 53 | const userData = await this.userService.getUserInfo(userID); 54 | const reqUserData = req.user.userinfo.userData ?? { 55 | firstName: `No First (${userID})`, 56 | lastName: 'No Last', 57 | email: '', 58 | userID: userID, 59 | }; 60 | if (this.checkUserDataUpdatedBeforeLogin(userData, reqUserData)) { 61 | console.debug( 62 | 'The user ' + 63 | userID + 64 | ' has their profiles updated! Updating now :)', 65 | ); 66 | updateUserData(); 67 | } 68 | } catch (e) { 69 | console.debug(`User ${userID} does not exist in db, adding them now!`); 70 | updateUserData(); 71 | } 72 | return res.json(req.user.userinfo.sub); 73 | } 74 | 75 | return res.json(req.user); 76 | } 77 | 78 | @UseGuards(LoginGuard) 79 | @Get('/callback/csesoc') 80 | loginCallback(@Res() res: Response) { 81 | res.redirect( 82 | this.configService.get( 83 | 'app.redirectLink', 84 | 'https://notangles.devsoc.app/api/auth/callback/csesoc', 85 | ), 86 | ); 87 | } 88 | 89 | @Get('/logout') 90 | async logout(@Request() req, @Res() res: Response) { 91 | await this.authService.logout(req, res); 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /server/src/auth/auth.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { PassportModule } from '@nestjs/passport'; 3 | import { OidcStrategy, buildOpenIdClient } from './oidc.strategy'; 4 | import { SessionSerializer } from './session.serializer'; 5 | import { AuthService } from './auth.service'; 6 | import { AuthController } from './auth.controller'; 7 | import { UserService } from 'src/user/user.service'; 8 | import { PrismaService } from 'src/prisma/prisma.service'; 9 | import { GraphqlService } from 'src/graphql/graphql.service'; 10 | 11 | const OidcStrategyFactory = { 12 | provide: 'OidcStrategy', 13 | useFactory: async (authService: AuthService) => { 14 | const client = await buildOpenIdClient(); 15 | return new OidcStrategy(authService, client); 16 | }, 17 | inject: [AuthService], 18 | }; 19 | 20 | @Module({ 21 | imports: [ 22 | PassportModule.register({ session: true, defaultStrategy: 'oidc' }), 23 | ], 24 | controllers: [AuthController], 25 | providers: [ 26 | OidcStrategyFactory, 27 | SessionSerializer, 28 | AuthService, 29 | UserService, 30 | PrismaService, 31 | GraphqlService, 32 | ], 33 | }) 34 | export class AuthModule {} 35 | -------------------------------------------------------------------------------- /server/src/auth/auth.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { AuthService } from './auth.service'; 3 | 4 | describe('AuthService', () => { 5 | let service: AuthService; 6 | 7 | beforeEach(async () => { 8 | const module: TestingModule = await Test.createTestingModule({ 9 | providers: [AuthService], 10 | }).compile(); 11 | 12 | service = module.get(AuthService); 13 | }); 14 | 15 | it('should be defined', () => { 16 | expect(service).toBeDefined(); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /server/src/auth/auth.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable, Request, Res } from '@nestjs/common'; 2 | import { Response } from 'express'; 3 | import { Issuer } from 'openid-client'; 4 | import { ConfigService } from '@nestjs/config'; 5 | 6 | @Injectable() 7 | export class AuthService { 8 | constructor(private configService: ConfigService) {} 9 | async logout(@Request() req, @Res() res: Response): Promise { 10 | const id_token = req.user ? req.user.id_token : undefined; 11 | 12 | const TrustIssuer = await Issuer.discover( 13 | `${process.env.OAUTH2_CLIENT_PROVIDER_OIDC_ISSUER}/.well-known/openid-configuration`, 14 | ); 15 | 16 | const postLogoutRedirect = this.configService.get( 17 | 'app.redirectLink', 18 | process.env.OAUTH2_CLIENT_REGISTRATION_LOGIN_POST_LOGOUT_REDIRECT_URI, 19 | ); 20 | 21 | if (!id_token || !TrustIssuer) { 22 | return res.redirect(postLogoutRedirect); 23 | } 24 | 25 | const endSessionEndpoint = TrustIssuer.metadata.end_session_endpoint; 26 | 27 | return new Promise((resolve, reject) => { 28 | req.logout((err) => { 29 | if (err) return reject(err); 30 | 31 | req.session.destroy((error) => { 32 | if (error) return reject(error); 33 | 34 | if (endSessionEndpoint && id_token) { 35 | res.redirect( 36 | `${endSessionEndpoint}?post_logout_redirect_uri=${postLogoutRedirect}&id_token_hint=${id_token}`, 37 | ); 38 | } else { 39 | res.redirect(postLogoutRedirect); 40 | } 41 | 42 | resolve(); 43 | }); 44 | }); 45 | }); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /server/src/auth/authenticated.guard.ts: -------------------------------------------------------------------------------- 1 | import { ExecutionContext, Injectable, CanActivate } from '@nestjs/common'; 2 | 3 | @Injectable() 4 | export class AuthenticatedGuard implements CanActivate { 5 | async canActivate(context: ExecutionContext) { 6 | const request = context.switchToHttp().getRequest(); 7 | return request.isAuthenticated(); 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /server/src/auth/dtos/auth.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsNotEmpty, IsString } from 'class-validator'; 2 | 3 | export class AuthDto { 4 | @IsString() @IsNotEmpty() code: string; 5 | 6 | @IsString() @IsNotEmpty() state: string; 7 | } 8 | -------------------------------------------------------------------------------- /server/src/auth/dtos/index.ts: -------------------------------------------------------------------------------- 1 | export * from './auth.dto'; 2 | -------------------------------------------------------------------------------- /server/src/auth/login.guard.ts: -------------------------------------------------------------------------------- 1 | import { ExecutionContext, Injectable } from '@nestjs/common'; 2 | import { AuthGuard } from '@nestjs/passport'; 3 | 4 | @Injectable() 5 | export class LoginGuard extends AuthGuard('oidc') { 6 | async canActivate(context: ExecutionContext) { 7 | const result = (await super.canActivate(context)) as boolean; 8 | const request = context.switchToHttp().getRequest(); 9 | await super.logIn(request); 10 | return result; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /server/src/auth/oidc.strategy.ts: -------------------------------------------------------------------------------- 1 | import { Request } from 'express'; 2 | import { UnauthorizedException } from '@nestjs/common'; 3 | import { PassportStrategy } from '@nestjs/passport'; 4 | import { 5 | Strategy, 6 | Client, 7 | UserinfoResponse, 8 | TokenSet, 9 | Issuer, 10 | } from 'openid-client'; 11 | import { AuthService } from './auth.service'; 12 | 13 | export const buildOpenIdClient = async () => { 14 | const TrustIssuer = await Issuer.discover( 15 | `${process.env.OAUTH2_CLIENT_PROVIDER_OIDC_ISSUER}/.well-known/openid-configuration`, 16 | ); 17 | const client = new TrustIssuer.Client({ 18 | client_id: process.env.OAUTH2_CLIENT_REGISTRATION_LOGIN_CLIENT_ID, 19 | client_secret: process.env.OAUTH2_CLIENT_REGISTRATION_LOGIN_CLIENT_SECRET, 20 | }); 21 | return client; 22 | }; 23 | 24 | export class OidcStrategy extends PassportStrategy(Strategy, 'oidc') { 25 | client: Client; 26 | 27 | constructor( 28 | private readonly authService: AuthService, 29 | client: Client, 30 | ) { 31 | super({ 32 | client: client, 33 | params: { 34 | redirect_uri: process.env.OAUTH2_CLIENT_REGISTRATION_LOGIN_REDIRECT_URI, 35 | scope: process.env.OAUTH2_CLIENT_REGISTRATION_LOGIN_SCOPE, 36 | }, 37 | passReqToCallback: true, 38 | usePKCE: false, 39 | }); 40 | 41 | this.client = client; 42 | } 43 | 44 | async validate( 45 | req: Request & { login: any }, 46 | tokenset: TokenSet, 47 | ): Promise { 48 | const userinfo: UserinfoResponse = await this.client.userinfo(tokenset); 49 | try { 50 | const id_token = tokenset.id_token; 51 | const access_token = tokenset.access_token; 52 | const refresh_token = tokenset.refresh_token; 53 | const user = { 54 | id_token, 55 | access_token, 56 | refresh_token, 57 | userinfo, 58 | }; 59 | 60 | return new Promise((resolve, reject) => { 61 | req.login(user, (err) => { 62 | if (err) { 63 | return reject(new UnauthorizedException('Login failed')); 64 | } 65 | resolve(user); 66 | }); 67 | }); 68 | } catch (err) { 69 | throw new UnauthorizedException(); 70 | } 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /server/src/auth/session.serializer.ts: -------------------------------------------------------------------------------- 1 | import { PassportSerializer } from '@nestjs/passport'; 2 | import { Injectable } from '@nestjs/common'; 3 | @Injectable() 4 | export class SessionSerializer extends PassportSerializer { 5 | serializeUser(user: any, done: (err: Error, user: any) => void): any { 6 | done(null, user); 7 | } 8 | deserializeUser( 9 | payload: any, 10 | done: (err: Error, payload: string) => void, 11 | ): any { 12 | done(null, payload); 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /server/src/auto/auto.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Controller, 3 | Get, 4 | Post, 5 | Body, 6 | Patch, 7 | Param, 8 | Delete, 9 | } from '@nestjs/common'; 10 | import { AutoService } from './auto.service'; 11 | import { autoDTO } from './dto/auto.dto'; 12 | 13 | @Controller('auto') 14 | export class AutoController { 15 | constructor(private readonly autoService: AutoService) {} 16 | 17 | @Post() 18 | async create(@Body() userRequestConstraints: autoDTO) { 19 | return await this.autoService.getAutoTimetable(userRequestConstraints); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /server/src/auto/auto.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { AutoService } from './auto.service'; 3 | import { AutoController } from './auto.controller'; 4 | import { ClientsModule, Transport } from '@nestjs/microservices'; 5 | import { join } from 'path'; 6 | 7 | @Module({ 8 | imports: [ 9 | ClientsModule.register([ 10 | { 11 | name: 'autotimetabler', 12 | transport: Transport.GRPC, 13 | options: { 14 | package: 'autotimetabler', 15 | protoPath: join(__dirname, '../proto/autotimetabler.proto'), 16 | url: `${process.env.AUTO_SERVER_HOST_NAME}:${process.env.AUTO_SERVER_HOST_PORT}`, 17 | }, 18 | }, 19 | ]), 20 | ], 21 | controllers: [AutoController], 22 | providers: [AutoService], 23 | }) 24 | export class AutoModule {} 25 | -------------------------------------------------------------------------------- /server/src/auto/auto.service.ts: -------------------------------------------------------------------------------- 1 | import { Body, HttpException, HttpStatus, Injectable } from '@nestjs/common'; 2 | 3 | import * as grpc from '@grpc/grpc-js'; 4 | import { TimetableConstraints } from '../proto/autotimetabler_pb'; 5 | import { AutoTimetablerClient } from '../proto/autotimetabler_grpc_pb'; 6 | import { autoDTO } from './dto/auto.dto'; 7 | import { ConfigService } from '@nestjs/config'; 8 | 9 | @Injectable() 10 | export class AutoService { 11 | constructor(private configService: ConfigService) {} 12 | async getAutoTimetable(@Body() autoService: autoDTO): Promise { 13 | const AUTO_SERVER_HOST = `${this.configService.get( 14 | 'AUTO_SERVER_HOST_NAME', 15 | )}:${this.configService.get('AUTO_SERVER_HOST_PORT')}`; 16 | return await getAuto(autoService, AUTO_SERVER_HOST); 17 | } 18 | } 19 | 20 | interface getAutoParameter { 21 | (data: autoDTO, grpc_client_conn: string): Promise; 22 | } 23 | 24 | export const getAuto: getAutoParameter = async ( 25 | data: autoDTO, 26 | grpc_client_conn: string, 27 | ) => { 28 | const client = new AutoTimetablerClient( 29 | grpc_client_conn, 30 | grpc.credentials.createInsecure(), 31 | ); 32 | const constraints = new TimetableConstraints(); 33 | 34 | constraints.setStart(data.start); 35 | constraints.setEnd(data.end); 36 | constraints.setDays(data.days); 37 | constraints.setGap(data.gap); 38 | constraints.setMaxdays(data.maxdays); 39 | data.periodInfoList.forEach((thisPeriod) => { 40 | const thisPeriodInfo = new TimetableConstraints.PeriodInfo(); 41 | 42 | thisPeriodInfo.setPeriodsperclass(thisPeriod.periodsPerClass); 43 | thisPeriodInfo.setPeriodtimesList(thisPeriod.periodTimes); 44 | thisPeriodInfo.setDurationsList(thisPeriod.durations); 45 | 46 | constraints.addPeriodinfo(thisPeriodInfo); 47 | }); 48 | return new Promise((resolve, reject) => { 49 | client.findBestTimetable(constraints, (err, response) => { 50 | if (err) { 51 | console.error('error was found: ' + err); 52 | reject( 53 | new HttpException( 54 | 'An error occurred when handling the request.', 55 | HttpStatus.BAD_GATEWAY, 56 | ), 57 | ); 58 | } else { 59 | resolve( 60 | JSON.stringify({ 61 | given: response.getTimesList(), 62 | optimal: response.getOptimal(), 63 | }), 64 | ); 65 | } 66 | }); 67 | }); 68 | }; 69 | -------------------------------------------------------------------------------- /server/src/auto/dto/auto.dto.ts: -------------------------------------------------------------------------------- 1 | export class autoDTO { 2 | start: number; 3 | end: number; 4 | days: string; 5 | gap: number; 6 | maxdays: number; 7 | periodInfoList: { 8 | periodsPerClass: number; 9 | periodTimes: Array; 10 | durations: Array; 11 | }[]; 12 | } 13 | -------------------------------------------------------------------------------- /server/src/config.ts: -------------------------------------------------------------------------------- 1 | import { registerAs } from '@nestjs/config'; 2 | 3 | export default registerAs('app', () => ({ 4 | env: process.env.NODE_ENV || 'dev', 5 | port: parseInt(process.env.PORT, 10) || 3000, 6 | auto: `${process.env.AUTO_SERVER_HOST_NAME}:${process.env.AUTO_SERVER_HOST_PORT}`, 7 | client: `${process.env.CLIENT_HOST_NAME}:${process.env.CLIENT_HOST_PORT}`, 8 | redirectLink: 9 | (process.env.NODE_ENV === 'dev' ? `http://` : `https://`) + 10 | `${process.env.CLIENT_HOST_NAME}:${process.env.CLIENT_HOST_PORT}`, 11 | })); 12 | -------------------------------------------------------------------------------- /server/src/friend/dto/friend.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsNotEmpty, IsString } from 'class-validator'; 2 | 3 | export class friendDto { 4 | @IsString() 5 | @IsNotEmpty() 6 | senderId: string; 7 | 8 | @IsString() 9 | @IsNotEmpty() 10 | sendeeId: string; 11 | } 12 | 13 | // export class friendRequestDto { 14 | // @IsString() 15 | // @IsNotEmpty() 16 | // requestId: string; 17 | // } 18 | -------------------------------------------------------------------------------- /server/src/friend/dto/index.ts: -------------------------------------------------------------------------------- 1 | export * from './friend.dto'; 2 | -------------------------------------------------------------------------------- /server/src/friend/friend.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { FriendController } from './friend.controller'; 3 | 4 | describe('FriendController', () => { 5 | let controller: FriendController; 6 | 7 | beforeEach(async () => { 8 | const module: TestingModule = await Test.createTestingModule({ 9 | controllers: [FriendController], 10 | }).compile(); 11 | 12 | controller = module.get(FriendController); 13 | }); 14 | 15 | it('should be defined', () => { 16 | expect(controller).toBeDefined(); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /server/src/friend/friend.controller.ts: -------------------------------------------------------------------------------- 1 | import { Body, Controller, Delete, Get, Param, Post } from '@nestjs/common'; 2 | import { friendDto } from './dto'; 3 | import { FriendService } from './friend.service'; 4 | @Controller('friend') 5 | export class FriendController { 6 | constructor(private friendService: FriendService) {} 7 | @Get(':userId') 8 | findAllFriends(@Param('userId') userId: string) { 9 | return this.friendService.findAllFriends(userId).then((data) => { 10 | return { 11 | status: "Found user's friends", 12 | data, 13 | }; 14 | }); 15 | } 16 | 17 | @Post() 18 | friendUsers(@Body() friendDTO: friendDto) { 19 | return this.friendService 20 | .friendUsers(friendDTO.senderId, friendDTO.sendeeId) 21 | .then((id) => { 22 | return { 23 | status: 'Successfully added users as friends!', 24 | data: { 25 | id, 26 | }, 27 | }; 28 | }); 29 | } 30 | 31 | @Delete() 32 | unfriendUsers(@Body() friendDTO: friendDto) { 33 | return this.friendService 34 | .unfriendUsers(friendDTO.senderId, friendDTO.sendeeId) 35 | .then((id) => { 36 | return { 37 | status: 'Successfully removed users as friends!', 38 | data: { 39 | id, 40 | }, 41 | }; 42 | }); 43 | } 44 | 45 | @Post('request') 46 | sendFriendRequest(@Body() friendDTO: friendDto) { 47 | return this.friendService.sendFriendRequest( 48 | friendDTO.senderId, 49 | friendDTO.sendeeId, 50 | ); 51 | } 52 | 53 | @Delete('request') 54 | deleteFriendRequest(@Body() friendDTO: friendDto) { 55 | return this.friendService 56 | .deleteFriendRequest(friendDTO.senderId, friendDTO.sendeeId) 57 | .then((id) => { 58 | return { 59 | status: 'Successfully rejected friend request!', 60 | data: { 61 | senderId: id, 62 | }, 63 | }; 64 | }); 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /server/src/friend/friend.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { FriendService } from './friend.service'; 3 | import { FriendController } from './friend.controller'; 4 | import { PrismaService } from 'src/prisma/prisma.service'; 5 | 6 | @Module({ 7 | providers: [FriendService, PrismaService], 8 | controllers: [FriendController], 9 | }) 10 | export class FriendModule {} 11 | -------------------------------------------------------------------------------- /server/src/friend/friend.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { FriendService } from './friend.service'; 3 | 4 | describe('FriendService', () => { 5 | let service: FriendService; 6 | 7 | beforeEach(async () => { 8 | const module: TestingModule = await Test.createTestingModule({ 9 | providers: [FriendService], 10 | }).compile(); 11 | 12 | service = module.get(FriendService); 13 | }); 14 | 15 | it('should be defined', () => { 16 | expect(service).toBeDefined(); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /server/src/graphql/graphql.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { GraphqlService } from './graphql.service'; 3 | 4 | @Module({ 5 | providers: [GraphqlService], 6 | }) 7 | export class GraphqlModule {} 8 | -------------------------------------------------------------------------------- /server/src/graphql/graphql.response.ts: -------------------------------------------------------------------------------- 1 | export interface GQLCourseInfo { 2 | course_code: string; 3 | course_name: string; 4 | classes: { 5 | activity: string; 6 | status: string; 7 | course_enrolment: string; 8 | class_id: string; 9 | term: string; 10 | section: string; 11 | times: { 12 | day: string; 13 | time: string; 14 | weeks: string; 15 | location: string; 16 | }[]; 17 | }[]; 18 | } 19 | 20 | export type GQLCourseData = { 21 | data: { 22 | courses: GQLCourseInfo[]; 23 | }; 24 | }; 25 | -------------------------------------------------------------------------------- /server/src/graphql/graphql.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { GraphqlService } from './graphql.service'; 3 | 4 | describe('GraphqlService', () => { 5 | let service: GraphqlService; 6 | 7 | beforeEach(async () => { 8 | const module: TestingModule = await Test.createTestingModule({ 9 | providers: [GraphqlService], 10 | }).compile(); 11 | 12 | service = module.get(GraphqlService); 13 | }); 14 | 15 | it('should be defined', () => { 16 | expect(service).toBeDefined(); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /server/src/graphql/graphql.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | import { GQLCourseData } from './graphql.response'; 3 | const HASURAGRES_GRAPHQL_API = 'https://graphql.csesoc.app/v1/graphql'; 4 | 5 | export const GET_COURSE_INFO = ` 6 | query GetCourseInfo($courseCode: String!, $term: String!, $year: String!) { 7 | courses(where: { course_code: { _eq: $courseCode } }) { 8 | course_code 9 | course_name 10 | classes( 11 | where: { 12 | term: { _eq: $term } 13 | year: { _eq: $year } 14 | activity: { _neq: "Course Enrolment" } 15 | } 16 | ) { 17 | activity 18 | status 19 | course_enrolment 20 | class_id 21 | term 22 | section 23 | times { 24 | day 25 | time 26 | weeks 27 | location 28 | } 29 | consent 30 | mode 31 | class_notes 32 | } 33 | } 34 | } 35 | `; 36 | 37 | @Injectable() 38 | export class GraphqlService { 39 | async fetchData( 40 | query: string, 41 | variables?: Record, 42 | ): Promise { 43 | try { 44 | const data = await fetch(HASURAGRES_GRAPHQL_API, { 45 | method: 'POST', 46 | headers: { 47 | 'Content-Type': 'application/json', 48 | }, 49 | body: JSON.stringify({ 50 | query, 51 | variables, 52 | }), 53 | }); 54 | return data.json(); 55 | } catch (error) { 56 | console.error('GraphQL Request Error:', error); 57 | throw error; 58 | } 59 | } 60 | async fetchCourseData( 61 | courseCode: string, 62 | term: string, 63 | year: string, 64 | ): Promise { 65 | return this.fetchData(GET_COURSE_INFO, { courseCode, term, year }); 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /server/src/group/dto/group.dto.ts: -------------------------------------------------------------------------------- 1 | export class GroupDto { 2 | name: string; 3 | description: string; 4 | imageURL: string; 5 | visibility: string; 6 | timetableIDs: string[]; 7 | memberIDs: string[]; 8 | groupAdminIDs: string[]; 9 | } 10 | -------------------------------------------------------------------------------- /server/src/group/dto/index.ts: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devsoc-unsw/notangles/f667a2f4b48f144205651f1d7af65f6fc3bcf276/server/src/group/dto/index.ts -------------------------------------------------------------------------------- /server/src/group/entities/group.entity.ts: -------------------------------------------------------------------------------- 1 | export class Group {} 2 | -------------------------------------------------------------------------------- /server/src/group/group.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { GroupController } from './group.controller'; 3 | import { GroupService } from './group.service'; 4 | 5 | describe('GroupController', () => { 6 | let controller: GroupController; 7 | 8 | beforeEach(async () => { 9 | const module: TestingModule = await Test.createTestingModule({ 10 | controllers: [GroupController], 11 | providers: [GroupService], 12 | }).compile(); 13 | 14 | controller = module.get(GroupController); 15 | }); 16 | 17 | it('should be defined', () => { 18 | expect(controller).toBeDefined(); 19 | }); 20 | }); 21 | -------------------------------------------------------------------------------- /server/src/group/group.controller.ts: -------------------------------------------------------------------------------- 1 | import { 2 | Controller, 3 | Get, 4 | Post, 5 | Put, 6 | Delete, 7 | Body, 8 | Param, 9 | NotFoundException, 10 | ConflictException, 11 | BadRequestException, 12 | } from '@nestjs/common'; 13 | import { GroupService } from './group.service'; 14 | import { GroupDto } from './dto/group.dto'; 15 | 16 | @Controller('group') 17 | export class GroupController { 18 | constructor(private readonly groupService: GroupService) {} 19 | 20 | @Get(':id') 21 | async findOne(@Param('id') id: string) { 22 | const group = await this.groupService.findOne(id); 23 | if (!group) { 24 | throw new NotFoundException({ 25 | timestamp: new Date().toISOString(), 26 | path: `/api/group/${id}`, 27 | data: "Can't find group!", 28 | }); 29 | } 30 | return { 31 | status: 'Success message for fetching group.', 32 | data: group, 33 | }; 34 | } 35 | 36 | @Post() 37 | async create(@Body() createGroupDto: GroupDto) { 38 | try { 39 | const group = await this.groupService.create(createGroupDto); 40 | return { 41 | status: 'Success message for creation of group.', 42 | data: group, 43 | }; 44 | } catch (error) { 45 | throw new BadRequestException({ 46 | timestamp: new Date().toISOString(), 47 | path: `/api/group`, 48 | data: `There was an error creating the group, the error: ${error}`, 49 | }); 50 | } 51 | } 52 | 53 | @Put(':id') 54 | async update(@Param('id') id: string, @Body() updateGroupDto: GroupDto) { 55 | try { 56 | const group = await this.groupService.update(id, updateGroupDto); 57 | return { 58 | status: 'Success message for updating of group.', 59 | data: group, 60 | }; 61 | } catch (error) { 62 | if (error.message === 'Group not found') { 63 | throw new NotFoundException({ 64 | timestamp: new Date().toISOString(), 65 | path: `/api/group/${id}`, 66 | data: "Can't find group!", 67 | }); 68 | } else if (error.message === 'Group already exists') { 69 | throw new ConflictException({ 70 | timestamp: new Date().toISOString(), 71 | path: `/api/group/${id}`, 72 | data: 'Message detailing what went wrong. ie already exists', 73 | }); 74 | } 75 | throw new BadRequestException({ 76 | timestamp: new Date().toISOString(), 77 | path: `/api/group/${id}`, 78 | data: 'Message detailing what went wrong.', 79 | }); 80 | } 81 | } 82 | 83 | @Delete(':id') 84 | async remove(@Param('id') id: string) { 85 | try { 86 | await this.groupService.remove(id); 87 | return { 88 | status: 'Success message for deletion of group.', 89 | data: {}, 90 | }; 91 | } catch (error) { 92 | throw new NotFoundException({ 93 | timestamp: new Date().toISOString(), 94 | path: `/api/group/${id}`, 95 | data: error.message || 'Message detailing what went wrong.', 96 | }); 97 | } 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /server/src/group/group.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { GroupService } from './group.service'; 3 | import { GroupController } from './group.controller'; 4 | import { PrismaService } from 'src/prisma/prisma.service'; 5 | import { UserService } from 'src/user/user.service'; 6 | import { GraphqlService } from 'src/graphql/graphql.service'; 7 | 8 | @Module({ 9 | controllers: [GroupController], 10 | providers: [GroupService, PrismaService, UserService, GraphqlService], 11 | }) 12 | export class GroupModule {} 13 | -------------------------------------------------------------------------------- /server/src/group/group.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { GroupService } from './group.service'; 3 | 4 | describe('GroupService', () => { 5 | let service: GroupService; 6 | 7 | beforeEach(async () => { 8 | const module: TestingModule = await Test.createTestingModule({ 9 | providers: [GroupService], 10 | }).compile(); 11 | 12 | service = module.get(GroupService); 13 | }); 14 | 15 | it('should be defined', () => { 16 | expect(service).toBeDefined(); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /server/src/main.ts: -------------------------------------------------------------------------------- 1 | import { ValidationPipe } from '@nestjs/common'; 2 | import { NestFactory } from '@nestjs/core'; 3 | import { MicroserviceOptions, Transport } from '@nestjs/microservices'; 4 | import { PrismaSessionStore } from '@quixo3/prisma-session-store'; 5 | import { SwaggerModule, DocumentBuilder } from '@nestjs/swagger'; 6 | import * as dotenv from 'dotenv'; 7 | import * as session from 'express-session'; 8 | import * as passport from 'passport'; 9 | import * as path from 'path'; 10 | import { AppModule } from './app.module'; 11 | const { PrismaClient } = require('@prisma/client'); // pnpm breaks in production if require is not used. 12 | 13 | async function bootstrap() { 14 | const app = await NestFactory.create(AppModule); 15 | app.setGlobalPrefix('api'); 16 | const config = new DocumentBuilder() 17 | .setTitle('Notangles Backend') 18 | .setDescription('Notangles Backend configuration file') 19 | .setVersion('1.0') 20 | // .addTag('Notangle Server') 21 | .build(); 22 | 23 | const document = SwaggerModule.createDocument(app, config); 24 | SwaggerModule.setup('swagger', app, document); 25 | dotenv.config({ 26 | path: path.resolve(__dirname, '../.env'), 27 | }); 28 | 29 | app.enableCors({ 30 | origin: [ 31 | 'http://localhost:5173', 32 | 'https://notanglesstaging.devsoc.app/', 33 | 'https://notangles.devsoc.app/', 34 | ], 35 | credentials: true, // Allow credentials (e.g., cookies) to be sent with the request 36 | }); 37 | app.useGlobalPipes(new ValidationPipe()); 38 | app.use( 39 | session({ 40 | store: new PrismaSessionStore(new PrismaClient(), { 41 | checkPeriod: 2 * 60 * 1000, //ms 42 | dbRecordIdIsSessionId: true, 43 | dbRecordIdFunction: undefined, 44 | }), // where session will be stored 45 | secret: process.env.SESSION_SECRET, // to sign session id 46 | resave: false, 47 | saveUninitialized: false, 48 | 49 | rolling: true, // keep session alive 50 | cookie: { 51 | secure: false, 52 | maxAge: 30 * 60 * 1000, // session expires in 1hr, refreshed by `rolling: true` option. 53 | httpOnly: true, // so that cookie can't be accessed via client-side script 54 | }, 55 | }), 56 | ); 57 | app.connectMicroservice({ 58 | transport: Transport.GRPC, // Use Transport.GRPC for gRPC 59 | options: { 60 | url: `${process.env.AUTO_SERVER_HOST_NAME}:${process.env.AUTO_SERVER_HOST_PORT}`, 61 | protoPath: path.join(__dirname, '../proto/autotimetabler.proto'), 62 | package: 'autotimetabler', 63 | }, 64 | }); 65 | app.use(passport.initialize()); 66 | app.use(passport.session()); 67 | await app.listen(3001); 68 | } 69 | 70 | bootstrap(); 71 | -------------------------------------------------------------------------------- /server/src/prisma/prisma.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { PrismaService } from './prisma.service'; 3 | 4 | @Module({ 5 | providers: [PrismaService], 6 | }) 7 | export class PrismaModule {} 8 | -------------------------------------------------------------------------------- /server/src/prisma/prisma.service.ts: -------------------------------------------------------------------------------- 1 | import { Injectable } from '@nestjs/common'; 2 | // import { PrismaClient } from '@prisma/client'; 3 | const { PrismaClient } = require('@prisma/client'); 4 | import 'dotenv/config'; 5 | 6 | @Injectable() 7 | export class PrismaService extends PrismaClient { 8 | constructor() { 9 | super({ 10 | datasources: { 11 | db: { 12 | url: process.env.DATABASE_URL, 13 | }, 14 | }, 15 | }); 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /server/src/proto/autotimetabler.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | 3 | package autotimetabler; 4 | 5 | message TimetableConstraints { 6 | message PeriodInfo { 7 | int32 periodsPerClass = 1; 8 | repeated float periodTimes = 2; 9 | repeated float durations = 3; 10 | } 11 | int32 start = 1; 12 | int32 end = 2; 13 | string days = 3; 14 | int32 gap = 4; 15 | int32 maxdays = 5; 16 | repeated PeriodInfo periodInfo = 6; 17 | } 18 | 19 | message AutoTimetableResponse { 20 | repeated float times = 1; 21 | bool optimal = 2; 22 | } 23 | 24 | service AutoTimetabler { 25 | rpc FindBestTimetable (TimetableConstraints) returns (AutoTimetableResponse); 26 | } -------------------------------------------------------------------------------- /server/src/proto/autotimetabler_grpc_pb.js: -------------------------------------------------------------------------------- 1 | // GENERATED CODE -- DO NOT EDIT! 2 | 3 | 'use strict'; 4 | var grpc = require('@grpc/grpc-js'); 5 | var autotimetabler_pb = require('./autotimetabler_pb.js'); 6 | 7 | function serialize_AutoTimetableResponse(arg) { 8 | if (!(arg instanceof autotimetabler_pb.AutoTimetableResponse)) { 9 | throw new Error('Expected argument of type AutoTimetableResponse'); 10 | } 11 | return Buffer.from(arg.serializeBinary()); 12 | } 13 | 14 | function deserialize_AutoTimetableResponse(buffer_arg) { 15 | return autotimetabler_pb.AutoTimetableResponse.deserializeBinary(new Uint8Array(buffer_arg)); 16 | } 17 | 18 | function serialize_TimetableConstraints(arg) { 19 | if (!(arg instanceof autotimetabler_pb.TimetableConstraints)) { 20 | throw new Error('Expected argument of type TimetableConstraints'); 21 | } 22 | return Buffer.from(arg.serializeBinary()); 23 | } 24 | 25 | function deserialize_TimetableConstraints(buffer_arg) { 26 | return autotimetabler_pb.TimetableConstraints.deserializeBinary(new Uint8Array(buffer_arg)); 27 | } 28 | 29 | 30 | var AutoTimetablerService = exports.AutoTimetablerService = { 31 | findBestTimetable: { 32 | path: '/AutoTimetabler/FindBestTimetable', 33 | requestStream: false, 34 | responseStream: false, 35 | requestType: autotimetabler_pb.TimetableConstraints, 36 | responseType: autotimetabler_pb.AutoTimetableResponse, 37 | requestSerialize: serialize_TimetableConstraints, 38 | requestDeserialize: deserialize_TimetableConstraints, 39 | responseSerialize: serialize_AutoTimetableResponse, 40 | responseDeserialize: deserialize_AutoTimetableResponse, 41 | }, 42 | }; 43 | 44 | exports.AutoTimetablerClient = grpc.makeGenericClientConstructor(AutoTimetablerService); 45 | -------------------------------------------------------------------------------- /server/src/user/dto/index.ts: -------------------------------------------------------------------------------- 1 | export * from './settings.dto'; 2 | export * from './user.dto'; 3 | export * from './timetable.dto'; 4 | -------------------------------------------------------------------------------- /server/src/user/dto/settings.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsBoolean } from 'class-validator'; 2 | 3 | export class SettingsDto { 4 | @IsBoolean() is12HourMode: boolean; 5 | @IsBoolean() isDarkMode: boolean; 6 | @IsBoolean() isSquareEdges: boolean; 7 | @IsBoolean() isHideFullClasses: boolean; 8 | @IsBoolean() isDefaultUnscheduled: boolean; 9 | @IsBoolean() isHideClassInfo: boolean; 10 | @IsBoolean() isSortAlphabetic: boolean; 11 | @IsBoolean() isShowOnlyOpenClasses: boolean; 12 | @IsBoolean() isHideExamClasses: boolean; 13 | @IsBoolean() isConvertToLocalTimezone: boolean; 14 | } 15 | -------------------------------------------------------------------------------- /server/src/user/dto/timetable.dto.ts: -------------------------------------------------------------------------------- 1 | import { IsArray, IsString } from 'class-validator'; 2 | 3 | export class TimetableDto { 4 | @IsString() 5 | id: string; // Randomly generated on the backend 6 | 7 | @IsArray() 8 | @IsString({ each: true }) 9 | selectedCourses: string[]; 10 | selectedClasses: ClassDto[]; 11 | createdEvents: EventDto[]; 12 | name?: string; 13 | mapKey: string; 14 | } 15 | 16 | export class ReconstructedTimetableDto { 17 | @IsString() 18 | id: string; // Randomly generated on the backend 19 | 20 | @IsArray() 21 | @IsString({ each: true }) 22 | selectedCourses: string[]; 23 | selectedClasses: ScrapedClassDto[]; 24 | createdEvents: EventDto[]; 25 | name?: string; 26 | mapKey: string; 27 | } 28 | 29 | export class ClassDto { 30 | id: string; 31 | classNo: string; // From scraper 32 | year: string; 33 | term: string; 34 | courseCode: string; 35 | timetableId?: string; 36 | activity: string; 37 | } 38 | 39 | export class ClassTimeDto { 40 | day: string; 41 | time: { 42 | start: string; 43 | end: string; 44 | }; 45 | weeks: string; 46 | location: string; 47 | } 48 | 49 | // Get class from scraper 50 | export class ScrapedClassDto { 51 | classID: string; 52 | section: string; 53 | term: string; 54 | activity: string; 55 | status: string; 56 | courseEnrolment: { 57 | enrolments: number; 58 | capacity: number; 59 | }; 60 | termDates: { 61 | start: string; 62 | end: string; 63 | }; 64 | needsConsent: boolean; 65 | mode: string; 66 | times: ClassTimeDto[]; 67 | courseCode: string; 68 | notes: []; 69 | } 70 | 71 | export class EventDto { 72 | id: string; // Frontend generated event id 73 | name: string; 74 | location: string; 75 | description: string; 76 | colour: string; 77 | day: string; 78 | start: Date; 79 | end: Date; 80 | } 81 | -------------------------------------------------------------------------------- /server/src/user/dto/user.dto.ts: -------------------------------------------------------------------------------- 1 | import { 2 | IsString, 3 | IsEmail, 4 | IsBoolean, 5 | IsArray, 6 | IsISO8601, 7 | IsOptional, 8 | } from 'class-validator'; 9 | import { SettingsDto } from './settings.dto'; 10 | import { TimetableDto } from './timetable.dto'; 11 | 12 | export class InitUserDTO { 13 | @IsString() 14 | userID: string; 15 | 16 | @IsString() 17 | @IsOptional() 18 | firstname?: string; 19 | 20 | @IsString() 21 | @IsOptional() 22 | lastname?: string; 23 | 24 | @IsEmail() 25 | email: string; 26 | 27 | @IsString() 28 | @IsOptional() 29 | profileURL?: string; 30 | } 31 | export class UserDTO extends InitUserDTO { 32 | @IsISO8601() 33 | @IsOptional() 34 | createdAt?: string; 35 | 36 | @IsISO8601() 37 | @IsOptional() 38 | lastLogin?: string; 39 | 40 | @IsBoolean() 41 | loggedIn: boolean; 42 | 43 | @IsArray() 44 | friends: string[]; 45 | 46 | @IsOptional() 47 | settings?: SettingsDto; 48 | 49 | @IsArray() 50 | timetables: TimetableDto[]; 51 | } 52 | -------------------------------------------------------------------------------- /server/src/user/user.controller.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { UserController } from './user.controller'; 3 | 4 | describe('UserController', () => { 5 | let controller: UserController; 6 | 7 | beforeEach(async () => { 8 | const module: TestingModule = await Test.createTestingModule({ 9 | controllers: [UserController], 10 | }).compile(); 11 | 12 | controller = module.get(UserController); 13 | }); 14 | 15 | it('should be defined', () => { 16 | expect(controller).toBeDefined(); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /server/src/user/user.module.ts: -------------------------------------------------------------------------------- 1 | import { Module } from '@nestjs/common'; 2 | import { UserService } from './user.service'; 3 | import { UserController } from './user.controller'; 4 | import { PrismaService } from 'src/prisma/prisma.service'; 5 | import { GraphqlService } from 'src/graphql/graphql.service'; 6 | 7 | @Module({ 8 | providers: [UserService, PrismaService, GraphqlService], 9 | controllers: [UserController], 10 | }) 11 | export class UserModule {} 12 | -------------------------------------------------------------------------------- /server/src/user/user.service.spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { UserService } from './user.service'; 3 | 4 | describe('UserService', () => { 5 | let service: UserService; 6 | 7 | beforeEach(async () => { 8 | const module: TestingModule = await Test.createTestingModule({ 9 | providers: [UserService], 10 | }).compile(); 11 | 12 | service = module.get(UserService); 13 | }); 14 | 15 | it('should be defined', () => { 16 | expect(service).toBeDefined(); 17 | }); 18 | }); 19 | -------------------------------------------------------------------------------- /server/test/app.e2e-spec.ts: -------------------------------------------------------------------------------- 1 | import { Test, TestingModule } from '@nestjs/testing'; 2 | import { INestApplication } from '@nestjs/common'; 3 | import * as request from 'supertest'; 4 | import { AppModule } from './../src/app.module'; 5 | 6 | describe('AppController (e2e)', () => { 7 | let app: INestApplication; 8 | 9 | beforeEach(async () => { 10 | const moduleFixture: TestingModule = await Test.createTestingModule({ 11 | imports: [AppModule], 12 | }).compile(); 13 | 14 | app = moduleFixture.createNestApplication(); 15 | await app.init(); 16 | }); 17 | 18 | it('/ (GET)', () => { 19 | return request(app.getHttpServer()) 20 | .get('/') 21 | .expect(200) 22 | .expect('Hello World!'); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /server/test/jest-e2e.json: -------------------------------------------------------------------------------- 1 | { 2 | "moduleFileExtensions": ["js", "json", "ts"], 3 | "rootDir": ".", 4 | "testEnvironment": "node", 5 | "testRegex": ".e2e-spec.ts$", 6 | "transform": { 7 | "^.+\\.(t|j)s$": "ts-jest" 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /server/tsconfig.build.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] 4 | } 5 | -------------------------------------------------------------------------------- /server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "module": "commonjs", 4 | "declaration": true, 5 | "removeComments": true, 6 | "emitDecoratorMetadata": true, 7 | "experimentalDecorators": true, 8 | "allowSyntheticDefaultImports": true, 9 | "target": "ES2021", 10 | "sourceMap": true, 11 | "outDir": "./dist", 12 | "baseUrl": "./", 13 | "incremental": true, 14 | "skipLibCheck": true, 15 | "strictNullChecks": false, 16 | "noImplicitAny": false, 17 | "strictBindCallApply": false, 18 | "forceConsistentCasingInFileNames": false, 19 | "noFallthroughCasesInSwitch": false 20 | } 21 | } 22 | --------------------------------------------------------------------------------