├── .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 | You need to enable JavaScript to run this app.
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 |
84 | {cancelButtonText}
85 |
86 |
87 | {confirmButtonText}
88 |
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 |
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 |
38 | Start
39 |
40 |
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 |
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 |
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 |
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 |
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 |
34 | {value === index && {children} }
35 |
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 | {
32 | setOpenSaveDialog(false);
33 | setIsEditing(true);
34 | }}
35 | >
36 | Cancel
37 |
38 | handleDiscardChanges()}>Discard
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 |
17 | {sectionsAndLocations.map(([, location], index) => (
18 |
19 | {location}
20 |
21 | ))}
22 |
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 |
--------------------------------------------------------------------------------