├── .expo ├── README.md └── settings.json ├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md ├── auto_assign.yml ├── pull_request_template.md └── workflows │ ├── api.yaml │ ├── auto-merge.yaml │ └── client.yaml ├── .gitignore ├── LICENSE ├── README.md ├── api ├── .eslintrc.json ├── .gitignore ├── .prettierrc ├── Procfile ├── README.md ├── bin │ └── www ├── gulpfile.js ├── package.json ├── src │ ├── .DS_Store │ ├── api │ │ ├── index.js │ │ ├── language │ │ │ ├── audio.js │ │ │ ├── course.js │ │ │ ├── image.js │ │ │ ├── index.js │ │ │ ├── lesson.js │ │ │ ├── unit.js │ │ │ └── vocab.js │ │ ├── learner │ │ │ ├── complete.js │ │ │ ├── index.js │ │ │ ├── join.js │ │ │ └── search.js │ │ └── user.js │ ├── app.js │ ├── middleware │ │ ├── authentication.js │ │ ├── authorization.js │ │ ├── errorHandler.js │ │ ├── errorWrap.js │ │ └── index.js │ ├── models │ │ ├── index.js │ │ ├── language.js │ │ └── user.js │ ├── templates │ │ ├── course-approved.html │ │ ├── course-confirmation.html │ │ └── course-rejected.html │ └── utils │ │ ├── aws │ │ ├── exports.js │ │ └── s3.js │ │ ├── constants.js │ │ ├── example-data.js │ │ ├── languageHelper.js │ │ ├── learnerHelper.js │ │ ├── mongo-setup.js │ │ ├── response.js │ │ └── userHelper.js ├── test.env ├── test │ ├── .DS_Store │ ├── db-data │ │ ├── courses.json │ │ ├── generate-db-data.js │ │ ├── lessons.json │ │ ├── units.json │ │ └── users.json │ ├── mock-data │ │ ├── auth-mock-data.js │ │ ├── complete-mock-data.js │ │ ├── course-mock-data.js │ │ ├── join-mock-data.js │ │ ├── lesson-mock-data.js │ │ ├── non-latin-mock-data.js │ │ ├── search-mock-data.js │ │ ├── unit-mock-data.js │ │ ├── user-mock-data.js │ │ └── vocab-mock-data.js │ ├── routes │ │ ├── complete.test.js │ │ ├── course.test.js │ │ ├── join.test.js │ │ ├── lesson.test.js │ │ ├── search.test.js │ │ ├── unit.test.js │ │ ├── user.test.js │ │ └── vocab.test.js │ └── utils │ │ ├── auth.js │ │ ├── constants.js │ │ └── db.js ├── webpack.config.js └── yarn.lock ├── client ├── .eslintrc.js ├── .expo │ ├── packager-info.json │ └── settings.json ├── .gitignore ├── .prettierignore ├── .prettierrc ├── App.js ├── LICENSE ├── README.md ├── app.config.js ├── app.json ├── assets │ ├── fonts │ │ ├── GT-Haptik-Bold-2.ttf │ │ ├── GT-Haptik-Bold-Oblique-2.ttf │ │ ├── GT-Haptik-Bold-Oblique.ttf │ │ ├── GT-Haptik-Bold-Rotalic-2.ttf │ │ ├── GT-Haptik-Bold-Rotalic.ttf │ │ ├── GT-Haptik-Bold.ttf │ │ ├── GT-Haptik-Regular-2.ttf │ │ ├── GT-Haptik-Regular-Oblique-2.ttf │ │ ├── GT-Haptik-Regular-Oblique.ttf │ │ ├── GT-Haptik-Regular-Rotalic-2.ttf │ │ ├── GT-Haptik-Regular-Rotalic.ttf │ │ └── GT-Haptik-Regular.ttf │ └── images │ │ ├── 7000-logos │ │ ├── 7000LanguagesDoorLogo.png │ │ ├── 7000Languages_Logotype copy.png │ │ └── 7000Languages_Logotype.png │ │ ├── app-icon.png │ │ ├── congrats.svg │ │ ├── default-icon.png │ │ ├── landing-background.png │ │ ├── landing-logo.svg │ │ ├── logo-lg-black.png │ │ ├── logo-lg-white.png │ │ ├── logo-sm-black.png │ │ ├── logo-sm-gray.svg │ │ ├── logo-sm-white.png │ │ └── splash.png ├── babel.config.js ├── index.js ├── metro.config.js ├── package.json ├── src │ ├── App.js │ ├── AppContent.js │ ├── api │ │ ├── api.js │ │ └── axios-config.js │ ├── components │ │ ├── Drawer │ │ │ ├── Drawer.js │ │ │ └── index.js │ │ ├── DrawerLogoutButton │ │ │ ├── DrawerLogoutButton.js │ │ │ └── index.js │ │ ├── HomeBaseCase │ │ │ ├── HomeBaseCase.js │ │ │ └── index.js │ │ ├── Indicator │ │ │ ├── Indicator.js │ │ │ └── index.js │ │ ├── LanguageHome │ │ │ ├── LanguageHome.js │ │ │ └── index.js │ │ ├── LearnerHome │ │ │ ├── LearnerHome.js │ │ │ └── index.js │ │ ├── LoadingSpinner │ │ │ ├── LoadingSpinner.js │ │ │ └── index.js │ │ ├── ManageView │ │ │ ├── ManageView.js │ │ │ └── index.js │ │ ├── NumberBox │ │ │ ├── NumberBox.js │ │ │ └── index.js │ │ ├── RecordAudioView │ │ │ ├── RecordAudioView.js │ │ │ └── index.js │ │ ├── RequiredField │ │ │ ├── RequiredField.js │ │ │ └── index.js │ │ ├── SearchResultCard │ │ │ ├── SearchResultCard.js │ │ │ └── index.js │ │ ├── StyledButton │ │ │ ├── StyledButton.js │ │ │ └── index.js │ │ ├── StyledCard │ │ │ ├── StyledCard.js │ │ │ └── index.js │ │ └── VocabBox │ │ │ ├── VocabBox.js │ │ │ └── index.js │ ├── hooks │ │ ├── index.js │ │ ├── useErrorWrap.js │ │ └── useTrackPromise.js │ ├── index.js │ ├── navigator │ │ ├── Drawer │ │ │ ├── Drawer.js │ │ │ ├── DrawerMenu.js │ │ │ ├── SplitDrawerItemList.js │ │ │ └── index.js │ │ ├── Navigator.js │ │ ├── Stacks │ │ │ ├── BackButton.js │ │ │ ├── DrawerButton.js │ │ │ ├── Stacks.js │ │ │ └── index.js │ │ ├── Tabs │ │ │ ├── Tabs.js │ │ │ └── index.js │ │ └── index.js │ ├── pages │ │ ├── AccountInfo │ │ │ ├── AccountInfo.js │ │ │ └── index.js │ │ ├── Activity1 │ │ │ ├── Activity1.js │ │ │ └── index.js │ │ ├── Activity2 │ │ │ ├── Activity2.js │ │ │ └── index.js │ │ ├── AppLanguage │ │ │ ├── AppLanguage.js │ │ │ └── index.js │ │ ├── Apply │ │ │ ├── Apply.js │ │ │ └── index.js │ │ ├── Congrats │ │ │ ├── Congrats.js │ │ │ └── index.js │ │ ├── CourseHome │ │ │ ├── CourseHome.js │ │ │ └── index.js │ │ ├── CourseSettings │ │ │ ├── CourseSettings.js │ │ │ └── index.js │ │ ├── CreateLesson │ │ │ ├── CreateLesson.js │ │ │ └── index.js │ │ ├── CreateUnit │ │ │ ├── CreateUnit.js │ │ │ └── index.js │ │ ├── Details │ │ │ └── Details.js │ │ ├── Home │ │ │ ├── Home.js │ │ │ └── index.js │ │ ├── Intro │ │ │ ├── Intro.js │ │ │ └── index.js │ │ ├── Landing │ │ │ ├── Landing.js │ │ │ └── index.js │ │ ├── LearnerCourseHome │ │ │ ├── LearnerCourseHome.js │ │ │ └── index.js │ │ ├── LearnerCourseSettings │ │ │ ├── LearnerCourseSettings.js │ │ │ └── index.js │ │ ├── LearnerLessonHome │ │ │ ├── LearnerLessonHome.js │ │ │ └── index.js │ │ ├── LearnerSearch │ │ │ ├── LearnerSearch.js │ │ │ └── index.js │ │ ├── LearnerUnitHome │ │ │ ├── LearnerUnitHome.js │ │ │ └── index.js │ │ ├── LessonHome │ │ │ ├── LessonHome.js │ │ │ └── index.js │ │ ├── Login │ │ │ └── Login.js │ │ ├── ManageLessons │ │ │ ├── ManageLessons.js │ │ │ └── index.js │ │ ├── ManageUnits │ │ │ ├── ManageUnits.js │ │ │ └── index.js │ │ ├── ManageVocab │ │ │ ├── ManageVocab.js │ │ │ └── index.js │ │ ├── Profile │ │ │ └── Profile.js │ │ ├── SelectLanguage │ │ │ ├── SelectLanguage.js │ │ │ └── index.js │ │ ├── StartActivity │ │ │ ├── StartActivity.js │ │ │ └── index.js │ │ ├── UnitHome │ │ │ ├── UnitHome.js │ │ │ └── index.js │ │ ├── UpdateCourse │ │ │ ├── UpdateCourse.js │ │ │ └── index.js │ │ ├── UpdateLesson │ │ │ ├── UpdateLesson.js │ │ │ └── index.js │ │ ├── UpdateUnit │ │ │ ├── UpdateUnit.js │ │ │ └── index.js │ │ └── VocabDrawer │ │ │ ├── VocabDrawer.js │ │ │ └── index.js │ ├── redux │ │ ├── slices │ │ │ ├── app.slice.js │ │ │ ├── auth.slice.js │ │ │ └── language.slice.js │ │ └── store.js │ ├── theme │ │ ├── colors.js │ │ ├── fonts.js │ │ ├── images.js │ │ ├── index.js │ │ └── nativebase.js │ └── utils │ │ ├── auth.js │ │ ├── cache │ │ └── index.js │ │ ├── constants.js │ │ ├── i18n │ │ ├── index.js │ │ ├── translations.js │ │ └── utils.js │ │ ├── ignore.js │ │ ├── languageHelper.js │ │ ├── learnerHelper.js │ │ └── manageHelper.js └── yarn.lock ├── package-lock.json ├── package.json └── yarn.lock /.expo/README.md: -------------------------------------------------------------------------------- 1 | > Why do I have a folder named ".expo" in my project? 2 | 3 | The ".expo" folder is created when an Expo project is started using "expo start" command. 4 | 5 | > What do the files contain? 6 | 7 | - "devices.json": contains information about devices that have recently opened this project. This is used to populate the "Development sessions" list in your development builds. 8 | - "packager-info.json": contains port numbers and process PIDs that are used to serve the application to the mobile device/simulator. 9 | - "settings.json": contains the server configuration that is used to serve the application manifest. 10 | 11 | > Should I commit the ".expo" folder? 12 | 13 | No, you should not share the ".expo" folder. It does not contain any information that is relevant for other developers working on the project, it is specific to your machine. 14 | 15 | Upon project creation, the ".expo" folder is already added to your ".gitignore" file. 16 | -------------------------------------------------------------------------------- /.expo/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "hostType": "lan", 3 | "lanType": "ip", 4 | "dev": true, 5 | "minify": false, 6 | "urlRandomness": null, 7 | "https": false 8 | } 9 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help us improve 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Go to '...' 16 | 2. Click on '....' 17 | 3. Scroll down to '....' 18 | 4. See error 19 | 20 | **Expected behavior** 21 | A clear and concise description of what you expected to happen. 22 | 23 | **Screenshots** 24 | If applicable, add screenshots to help explain your problem. 25 | 26 | **Environment (please complete the following information):** 27 | - Device: [e.g. iPhone6] 28 | - OS: [e.g. iOS8.1] 29 | - Version: [e.g. 22] 30 | 31 | **Additional context** 32 | Add any other context about the problem here. -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for this project 4 | title: '' 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. -------------------------------------------------------------------------------- /.github/auto_assign.yml: -------------------------------------------------------------------------------- 1 | # Commenting this out as of Jan 1, 2023 2 | 3 | # # Set to true to add reviewers to pull requests 4 | # addReviewers: true 5 | 6 | # # Set to true to add assignees to pull requests 7 | # addAssignees: true 8 | 9 | # # A list of reviewers to be added to pull requests (GitHub user name) 10 | # reviewers: 11 | # - ashayp22 12 | 13 | # # A list of keywords to be skipped the process that add reviewers if pull requests include it 14 | # skipKeywords: 15 | # - wip 16 | 17 | # # A number of reviewers added to the pull request 18 | # # Set 0 to add all the reviewers (default: 0) 19 | # numberOfReviewers: 1 20 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | 5 | 6 | ## Status: 7 | 8 | 13 | 14 | ## Description 15 | 16 | 19 | 20 | Fixes # 21 | 22 | ## Todos 23 | 24 | 28 | 29 | ## Screenshots 30 | 31 | -------------------------------------------------------------------------------- /.github/workflows/api.yaml: -------------------------------------------------------------------------------- 1 | name: api 2 | 3 | on: push 4 | 5 | defaults: 6 | run: 7 | working-directory: ./api 8 | 9 | jobs: 10 | api-format: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Check out repository 14 | uses: actions/checkout@v2 15 | 16 | - name: Set up Node 17 | uses: actions/setup-node@v2 18 | with: 19 | node-version: 16 20 | 21 | - name: Cache dependencies 22 | uses: actions/cache@v2 23 | with: 24 | path: api/node_modules 25 | key: ${{ runner.os }}-cache 26 | 27 | - name: Install dependencies 28 | run: yarn 29 | 30 | - name: Check formatting 31 | run: yarn format:check 32 | 33 | api-lint: 34 | runs-on: ubuntu-latest 35 | steps: 36 | - name: Check out repository 37 | uses: actions/checkout@v2 38 | 39 | - name: Set up Node 40 | uses: actions/setup-node@v2 41 | with: 42 | node-version: 16 43 | 44 | - name: Cache dependencies 45 | uses: actions/cache@v2 46 | with: 47 | path: api/node_modules 48 | key: ${{ runner.os }}-cache 49 | 50 | - name: Install dependencies 51 | run: yarn 52 | 53 | - name: Run linter 54 | run: yarn lint 55 | 56 | api-test: 57 | runs-on: ubuntu-latest 58 | steps: 59 | - name: Check out repository 60 | uses: actions/checkout@v2 61 | 62 | - name: Set up Node 63 | uses: actions/setup-node@v2 64 | with: 65 | node-version: 16 66 | 67 | - name: Cache dependencies 68 | uses: actions/cache@v2 69 | with: 70 | path: api/node_modules 71 | key: ${{ runner.os }}-cache 72 | 73 | - name: Install dependencies 74 | run: yarn 75 | 76 | - name: Build application 77 | run: yarn test -------------------------------------------------------------------------------- /.github/workflows/auto-merge.yaml: -------------------------------------------------------------------------------- 1 | name: 'Merge Dependencies' 2 | 3 | on: [pull_request_target] 4 | 5 | jobs: 6 | auto-merge: 7 | runs-on: ubuntu-latest 8 | if: github.actor == 'dependabot[bot]' 9 | steps: 10 | - name: 'Checkout repository' 11 | uses: actions/checkout@v2.3.4 12 | - name: 'Automerge dependency updates from Dependabot' 13 | uses: ahmadnassri/action-dependabot-auto-merge@v2.6 14 | with: 15 | github-token: ${{ secrets.DEPENDABOT_AUTOMERGE }} 16 | -------------------------------------------------------------------------------- /.github/workflows/client.yaml: -------------------------------------------------------------------------------- 1 | name: client 2 | 3 | on: push 4 | 5 | jobs: 6 | client-format: 7 | runs-on: ubuntu-latest 8 | steps: 9 | - name: Check out repository 10 | uses: actions/checkout@v2 11 | 12 | - name: Set up Node 13 | uses: actions/setup-node@v2.4.0 14 | with: 15 | node-version: 16 16 | 17 | - name: Cache dependencies 18 | uses: actions/cache@v2 19 | with: 20 | path: client/node_modules 21 | key: ${{ runner.os }}-cache 22 | 23 | - name: Install dependencies 24 | working-directory: ./client 25 | run: yarn 26 | 27 | - name: Check formatting 28 | working-directory: ./client 29 | run: yarn format:check 30 | 31 | client-lint: 32 | runs-on: ubuntu-latest 33 | steps: 34 | - name: Check out repository 35 | uses: actions/checkout@v2 36 | 37 | - name: Set up Node 38 | uses: actions/setup-node@v2.4.0 39 | with: 40 | node-version: 16 41 | 42 | - name: Cache dependencies 43 | uses: actions/cache@v2 44 | with: 45 | path: client/node_modules 46 | key: ${{ runner.os }}-cache 47 | 48 | - name: Install dependencies 49 | working-directory: ./client 50 | run: yarn 51 | 52 | - name: Install root dependencies 53 | working-directory: ./ 54 | run: yarn 55 | 56 | - name: Run linter 57 | working-directory: ./client 58 | run: yarn lint -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store -------------------------------------------------------------------------------- /api/.eslintrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "plugins": ["@hack4impact-uiuc", "jest"], 3 | "extends": ["plugin:@hack4impact-uiuc/base"], 4 | "env": { 5 | "jest/globals": true 6 | }, 7 | "rules": { 8 | "require-await": "off", 9 | "no-magic-numbers": "off", 10 | "@hack4impact-uiuc/no-redundant-functions": "off" 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /api/.gitignore: -------------------------------------------------------------------------------- 1 | build/ 2 | production.env 3 | development.env 4 | test.env 5 | 6 | # Created by https://www.toptal.com/developers/gitignore/api/node 7 | # Edit at https://www.toptal.com/developers/gitignore?templates=node 8 | 9 | ### Node ### 10 | # Logs 11 | logs 12 | *.log 13 | npm-debug.log* 14 | yarn-debug.log* 15 | yarn-error.log* 16 | lerna-debug.log* 17 | 18 | # Diagnostic reports (https://nodejs.org/api/report.html) 19 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 20 | 21 | # Runtime data 22 | pids 23 | *.pid 24 | *.seed 25 | *.pid.lock 26 | 27 | # Directory for instrumented libs generated by jscoverage/JSCover 28 | lib-cov 29 | 30 | # Coverage directory used by tools like istanbul 31 | coverage 32 | *.lcov 33 | 34 | # nyc test coverage 35 | .nyc_output 36 | 37 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 38 | .grunt 39 | 40 | # Bower dependency directory (https://bower.io/) 41 | bower_components 42 | 43 | # node-waf configuration 44 | .lock-wscript 45 | 46 | # Compiled binary addons (https://nodejs.org/api/addons.html) 47 | build/Release 48 | 49 | # Dependency directories 50 | node_modules/ 51 | jspm_packages/ 52 | 53 | # TypeScript v1 declaration files 54 | typings/ 55 | 56 | # TypeScript cache 57 | *.tsbuildinfo 58 | 59 | # Optional npm cache directory 60 | .npm 61 | 62 | # Optional eslint cache 63 | .eslintcache 64 | 65 | # Microbundle cache 66 | .rpt2_cache/ 67 | .rts2_cache_cjs/ 68 | .rts2_cache_es/ 69 | .rts2_cache_umd/ 70 | 71 | # Optional REPL history 72 | .node_repl_history 73 | 74 | # Output of 'npm pack' 75 | *.tgz 76 | 77 | # Yarn Integrity file 78 | .yarn-integrity 79 | 80 | # dotenv environment TEXT, 81 | .env 82 | .env.test 83 | 84 | # parcel-bundler cache (https://parceljs.org/) 85 | .cache 86 | 87 | # Next.js build output 88 | .next 89 | 90 | # Nuxt.js build / generate output 91 | .nuxt 92 | dist 93 | 94 | # Gatsby files 95 | .cache/ 96 | # Comment in the public line in if your project uses Gatsby and not Next.js 97 | # https://nextjs.org/blog/next-9-1#public-directory-support 98 | # public 99 | 100 | # vuepress build output 101 | .vuepress/dist 102 | 103 | # Serverless directories 104 | .serverless/ 105 | 106 | # FuseBox cache 107 | .fusebox/ 108 | 109 | # DynamoDB Local files 110 | .dynamodb/ 111 | 112 | # TernJS port file 113 | .tern-port 114 | 115 | # Stores VSCode versions used for testing VSCode extensions 116 | .vscode-test 117 | 118 | # End of https://www.toptal.com/developers/gitignore/api/node 119 | 120 | # Elastic Beanstalk Files 121 | .elasticbeanstalk/* 122 | !.elasticbeanstalk/*.cfg.yml 123 | !.elasticbeanstalk/*.global.yml 124 | 125 | .DS_Store 126 | .expo 127 | .elasticbeanstalk 128 | .platform -------------------------------------------------------------------------------- /api/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "tabWidth": 2, 4 | "trailingComma": "all" 5 | } -------------------------------------------------------------------------------- /api/Procfile: -------------------------------------------------------------------------------- 1 | web: npm run start:prod -------------------------------------------------------------------------------- /api/README.md: -------------------------------------------------------------------------------- 1 | # 7000 Languages API 2 | 3 | This folder contains the backend api of the application. 4 | 5 | ## Environments 6 | 7 | There are three environments that the backend runs in: `production`, `dev`, and `test`, each with their own database. `production` is set on deployment, `dev` is set whenever running locally, and `test` is set when tests are running. These environments are automatically set based on the task. 8 | 9 | _As of 02/04/2022, only the dev environment is available._ 10 | 11 | ## Getting Started 12 | 13 | First, make sure you have [Node.js and npm](https://docs.npmjs.com/downloading-and-installing-node-js-and-npm) installed. 14 | 15 | Also, make sure you have [yarn](https://classic.yarnpkg.com/lang/en/docs/install/#mac-stable) installed. 16 | 17 | Next, run `cd api` to move to this directory. 18 | 19 | To install all of the required node packages, run: 20 | 21 | ```bash 22 | yarn install 23 | ``` 24 | 25 | Then, set the necessary environment variables by creating a `development.env` file in the `api` folder. Populate the file with the following: 26 | 27 | ``` 28 | NODE_ENV=development 29 | PORT=3000 30 | MONGO_URL= 31 | EXPO_CLIENT_ID= 32 | ``` 33 | 34 | You can obtain `MONGO_URL` by [creating a MongoDB Atlas Database](https://www.mongodb.com/atlas) and the `EXPO_CLIENT_ID` by creating [Google OAuth credentials](https://console.cloud.google.com/apis/credentials?pli=1). 35 | 36 | Finally, run: 37 | 38 | ```bash 39 | yarn start 40 | ``` 41 | 42 | This will create a server on [http://localhost:3000](http://localhost:3000). 43 | 44 | ## Run 45 | 46 | To run the API at any future time, use the following command: 47 | 48 | ```bash 49 | yarn start 50 | ``` 51 | 52 | Before commiting and pushing code to the remote repository, run the command below for linting and formatting: 53 | 54 | ```bash 55 | yarn style 56 | ``` 57 | 58 | ## Technologies 59 | 60 | Built with [Express](https://expressjs.com/) and [MongoDB](https://www.mongodb.com/). 61 | 62 | ### Code Style 63 | 64 | Use [ESLint](https://eslint.org) with [Prettier](https://prettier.io/). 65 | 66 | ## Testing 67 | 68 | The unit tests are written with [Jest](https://jestjs.io/) and [SuperTest](https://github.com/visionmedia/supertest). 69 | 70 | To test, 71 | 72 | ```bash 73 | yarn test 74 | ``` 75 | -------------------------------------------------------------------------------- /api/bin/www: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | const path = require('path'); 3 | // Configure additional environment variables 4 | require('dotenv').config({ 5 | path: path.resolve(`${process.env.NODE_ENV}.env`), 6 | }) 7 | const app = require('../src/app'); 8 | const debug = require('debug')('api:server'); 9 | const http = require('http'); 10 | 11 | const port = normalizePort(process.env.PORT || '3000'); 12 | app.set('port', port); 13 | 14 | const server = http.createServer(app); 15 | 16 | // Listen on provided port 17 | server.listen(port); 18 | server.on('error', onError); 19 | server.on('listening', onListening); 20 | 21 | // Normalize port into number, string, or false 22 | function normalizePort(val) { 23 | const port = parseInt(val, 10); 24 | 25 | if (isNaN(port)) return val; 26 | if (port >= 0) return port; 27 | 28 | return false; 29 | } 30 | 31 | // Event listener for HTTP server "error" event. 32 | function onError(error) { 33 | if (error.syscall !== 'listen') { 34 | throw error; 35 | } 36 | 37 | const bind = typeof port === 'string' ? 'Pipe ' + port : 'Port ' + port; 38 | 39 | switch (error.code) { 40 | case 'EACCES': 41 | console.error(bind + ' requires elevated privileges'); 42 | process.exit(1); 43 | break; 44 | case 'EADDRINUSE': 45 | console.error(bind + ' is already in use'); 46 | process.exit(1); 47 | break; 48 | default: 49 | throw error; 50 | } 51 | } 52 | 53 | // Event listener for HTTP server "listening" event. 54 | function onListening() { 55 | const addr = server.address(); 56 | const bind = typeof addr === 'string' ? 'pipe ' + addr : 'port ' + addr.port; 57 | } 58 | -------------------------------------------------------------------------------- /api/gulpfile.js: -------------------------------------------------------------------------------- 1 | /** 2 | * This is one method for generating a zipped verion of the application, which can then be uploaded 3 | * to AWS Elastic Beanstalk. However, this is currently not being used. Instead, the API is deployed 4 | * through the AWS and EB CLI. 5 | */ 6 | const fs = require('fs'); 7 | const { exec } = require('child_process'); 8 | 9 | const { src, dest, series } = require('gulp'); 10 | const del = require('del'); 11 | const zip = require('gulp-zip'); 12 | const rename = require('gulp-rename'); 13 | const log = require('fancy-log'); 14 | 15 | const NODE_ENV = 'production'; 16 | const paths = { 17 | prod_build: 'prod-build', 18 | zipped_file_name: 'full-application.zip', 19 | }; 20 | 21 | paths.server_source_dest = `${paths.prod_build}/dist/src`; 22 | 23 | const makeDirectory = (dir) => { 24 | log(`Creating the folder if not exist ${dir}`); 25 | if (!fs.existsSync(dir)) { 26 | fs.mkdirSync(dir, { recursive: true }); 27 | log('📁 folder created:', dir); 28 | } 29 | }; 30 | 31 | const clean = () => { 32 | log('Removing the old files in the directory'); 33 | del('build/**', { force: true }); 34 | del('prod-build/**', { force: true }); 35 | return Promise.resolve(); 36 | }; 37 | 38 | const createProdBuildFolder = () => { 39 | makeDirectory(paths.prod_build); 40 | makeDirectory(paths.server_source_dest); 41 | return Promise.resolve(); 42 | }; 43 | 44 | const buildServerCodeTask = (cb) => { 45 | log('Building server code into the directory'); 46 | return exec('yarn build', (err, stdout, stderr) => { 47 | log(stdout); 48 | log(stderr); 49 | cb(err); 50 | }); 51 | }; 52 | 53 | const copyNodeJSCodeTask = () => { 54 | log('Building and copying server code into the directory'); 55 | src('build/index.js').pipe(dest(`${paths.server_source_dest}`)); 56 | 57 | return src(['package.json', `${NODE_ENV}.env`, `Procfile`]).pipe( 58 | dest(`${paths.prod_build}`), 59 | ); 60 | }; 61 | 62 | const addEngineToPackage = () => 63 | exec( 64 | `yarn json -I -f ${paths.prod_build}/package.json -e "this.engines = { 'node': '16.0.0' }"`, 65 | ); 66 | const updatePackage = () => 67 | exec( 68 | `yarn json -I -f ${paths.prod_build}/package.json -e "this.scripts.start = 'NODE_ENV=production node -r dotenv/config ./dist/src/index.js'"`, 69 | ); 70 | 71 | const zippingTask = () => { 72 | log('Zipping the code '); 73 | return src(`${paths.prod_build}/**`, { dot: true }) 74 | .pipe(zip(`${paths.zipped_file_name}`)) 75 | .pipe(dest(`${paths.prod_build}`)); 76 | }; 77 | 78 | exports.default = series( 79 | clean, 80 | createProdBuildFolder, 81 | buildServerCodeTask, 82 | copyNodeJSCodeTask, 83 | addEngineToPackage, 84 | updatePackage, 85 | zippingTask, 86 | ); 87 | -------------------------------------------------------------------------------- /api/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "7000-languages-api", 3 | "version": "0.1.0", 4 | "license": "MIT", 5 | "scripts": { 6 | "start": "if-env NODE_ENV=production ?? npm run start:prod || npm run start:dev", 7 | "start:prod": "NODE_ENV=production node -r dotenv/config ./bin/www", 8 | "start:dev": "NODE_ENV=development nodemon -r dotenv/config ./bin/www", 9 | "build": "NODE_ENV=production webpack ./bin/www", 10 | "lint": "eslint --fix src", 11 | "format": "prettier --write \"./**/*.{js,jsx,json,md}\"", 12 | "format:check": "prettier -- \"./**/*.{js,jsx,json,md}\"", 13 | "test": "jest --setupFiles dotenv/config --runInBand --forceExit --coverage", 14 | "style": "yarn format && yarn lint" 15 | }, 16 | "dependencies": { 17 | "@hack4impact-uiuc/eslint-plugin": "^2.0.10", 18 | "aws-sdk": "^2.1116.0", 19 | "cors": "^2.8.5", 20 | "debug": "~4.3.3", 21 | "dotenv": "^16.0.0", 22 | "eslint-plugin-jest": "^26.1.1", 23 | "express": "~4.17.1", 24 | "express-busboy": "^8.0.2", 25 | "express-fileupload": "^1.3.1", 26 | "fs": "^0.0.1-security", 27 | "google-auth-library": "^7.14.0", 28 | "helmet": "^5.0.2", 29 | "http-errors": "~1.8.0", 30 | "if-env": "^1.0.4", 31 | "isomorphic-unfetch": "^3.0.0", 32 | "lodash": "^4.17.21", 33 | "mongodb": "^4.4.0", 34 | "mongodb-memory-server": "^8.4.0", 35 | "mongoose": "^6.2.3", 36 | "morgan": "~1.10.0", 37 | "mpath": "^0.8.4", 38 | "multer": "^1.4.4", 39 | "nodemailer": "^6.8.0", 40 | "nodemailer-express-handlebars": "^6.0.0", 41 | "omit-deep-lodash": "^1.1.6" 42 | }, 43 | "devDependencies": { 44 | "@faker-js/faker": "^6.0.0-alpha.7", 45 | "@hack4impact-uiuc/eslint-plugin": "^2.0.10", 46 | "eslint": "^7.32.0", 47 | "faker": "5.5.3", 48 | "jest": "^27.5.1", 49 | "nodemon": "^2.0.7", 50 | "prettier": "^2.2.1", 51 | "supertest": "^6.2.2" 52 | }, 53 | "jest": { 54 | "testEnvironment": "node", 55 | "verbose": true, 56 | "testTimeout": 60000 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /api/src/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hack4impact-uiuc/7000-languages/8153ca142d90f7bbec01d009d09f62ac2a317b8f/api/src/.DS_Store -------------------------------------------------------------------------------- /api/src/api/index.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const router = express.Router(); 3 | 4 | router.get('/', (req, res) => 5 | res.json(`API Working, running ${process.env.NODE_ENV}`), 6 | ); 7 | // Put all routes here 8 | router.use('/user', require('./user')); 9 | router.use('/language', require('./language')); 10 | router.use('/learner', require('./learner')); 11 | 12 | module.exports = router; 13 | -------------------------------------------------------------------------------- /api/src/api/language/index.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const router = express.Router(); 3 | 4 | // Put all routes here 5 | router.use('/course', require('./course')); 6 | router.use('/unit', require('./unit')); 7 | router.use('/lesson', require('./lesson')); 8 | router.use('/vocab', require('./vocab')); 9 | router.use('/audio', require('./audio')); 10 | router.use('/image', require('./image')); 11 | 12 | module.exports = router; 13 | -------------------------------------------------------------------------------- /api/src/api/learner/complete.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const router = express.Router(); 3 | const { errorWrap } = require('../../middleware'); 4 | const { sendResponse } = require('../../utils/response'); 5 | const { requireAuthentication } = require('../../middleware/authentication'); 6 | const { 7 | requireLearnerAuthorization, 8 | } = require('../../middleware/authorization'); 9 | const { models } = require('../../models/index.js'); 10 | const { ERR_MISSING_OR_INVALID_DATA } = require('../../utils/constants'); 11 | const { checkIds } = require('../../utils/languageHelper'); 12 | const { hasCompletedLesson } = require('../../utils/learnerHelper'); 13 | 14 | /** 15 | * Marks the user as having completed a specific lesson 16 | */ 17 | router.post( 18 | '/', 19 | requireAuthentication, 20 | requireLearnerAuthorization, 21 | errorWrap(async (req, res) => { 22 | const { course_id, unit_id, lesson_id } = req.body; 23 | 24 | if ( 25 | course_id === undefined || 26 | unit_id === undefined || 27 | lesson_id === undefined 28 | ) { 29 | return sendResponse(res, 400, ERR_MISSING_OR_INVALID_DATA); 30 | } 31 | 32 | // Checks if the ids are valid 33 | const isValid = await checkIds({ course_id, unit_id, lesson_id }); 34 | 35 | if (!isValid) { 36 | return sendResponse(res, 400, ERR_MISSING_OR_INVALID_DATA); 37 | } 38 | 39 | const hasCompletedLessonAlready = await hasCompletedLesson( 40 | req.user._id, 41 | lesson_id, 42 | ); 43 | 44 | // Check if this document has already been created 45 | if (hasCompletedLessonAlready) { 46 | return sendResponse( 47 | res, 48 | 400, 49 | 'Lesson has already been marked as complete', 50 | ); 51 | } 52 | 53 | // Go on to marking the lesson as complete 54 | const lessonComplete = new models.Complete({ 55 | user_id: req.user._id, 56 | _course_id: course_id, 57 | _unit_id: unit_id, 58 | _lesson_id: lesson_id, 59 | }); 60 | 61 | await lessonComplete.save(); 62 | 63 | return sendResponse( 64 | res, 65 | 200, 66 | 'Marked the user as having completed the lesson.', 67 | ); 68 | }), 69 | ); 70 | 71 | module.exports = router; 72 | -------------------------------------------------------------------------------- /api/src/api/learner/index.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const router = express.Router(); 3 | 4 | // Put all routes here 5 | router.use('/complete', require('./complete')); 6 | router.use('/join', require('./join')); 7 | router.use('/search', require('./search')); 8 | 9 | module.exports = router; 10 | -------------------------------------------------------------------------------- /api/src/api/learner/join.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const router = express.Router(); 3 | const { errorWrap } = require('../../middleware'); 4 | const { sendResponse } = require('../../utils/response'); 5 | const { requireAuthentication } = require('../../middleware/authentication'); 6 | const { models } = require('../../models/index.js'); 7 | const { ERR_MISSING_OR_INVALID_DATA } = require('../../utils/constants'); 8 | const { checkIds } = require('../../utils/languageHelper'); 9 | 10 | /** 11 | * Joins user in course with course_id 12 | */ 13 | router.post( 14 | '/', 15 | requireAuthentication, 16 | errorWrap(async (req, res) => { 17 | const { course_id, code } = req.body; 18 | 19 | if (course_id === undefined) { 20 | return sendResponse(res, 400, ERR_MISSING_OR_INVALID_DATA); 21 | } 22 | 23 | const isValid = await checkIds({ course_id }); 24 | 25 | if (!isValid) { 26 | return sendResponse(res, 400, ERR_MISSING_OR_INVALID_DATA); 27 | } 28 | 29 | const learnerLanguages = req.user.learnerLanguages; 30 | 31 | if (learnerLanguages.includes(String(course_id))) { 32 | return sendResponse(res, 400, 'User has already joined course'); 33 | } 34 | 35 | const course = await models.Course.findById(course_id); 36 | const isPrivate = course.details.is_private; 37 | const courseCode = course.details.code; 38 | 39 | if (isPrivate && courseCode !== code) { 40 | return sendResponse(res, 400, 'Invalid code provided for private course'); 41 | } 42 | 43 | const user = await models.User.findById(req.user._id); 44 | 45 | user.learnerLanguages.push(course_id); 46 | user.save(); 47 | 48 | return sendResponse(res, 200, "Added course to user's learner languages"); 49 | }), 50 | ); 51 | 52 | /** 53 | * Removes user from course 54 | */ 55 | router.delete( 56 | '/', 57 | requireAuthentication, 58 | errorWrap(async (req, res) => { 59 | const { id: course_id } = req.query; 60 | 61 | if (course_id === undefined) { 62 | return sendResponse(res, 400, ERR_MISSING_OR_INVALID_DATA); 63 | } 64 | 65 | const isValid = await checkIds({ course_id }); 66 | 67 | if (!isValid) { 68 | return sendResponse(res, 400, ERR_MISSING_OR_INVALID_DATA); 69 | } 70 | 71 | const learnerLanguages = req.user.learnerLanguages; 72 | 73 | if (!learnerLanguages.includes(String(course_id))) { 74 | return sendResponse(res, 400, 'User not in course'); 75 | } 76 | 77 | const user = await models.User.findById(req.user._id); 78 | 79 | user.learnerLanguages = user.learnerLanguages.filter( 80 | (id) => id !== course_id, 81 | ); 82 | 83 | await user.save(); 84 | 85 | // Delete course progress 86 | await models.Complete.deleteMany({ user_id: req.user._id }); 87 | 88 | return sendResponse( 89 | res, 90 | 200, 91 | "Removed course from user's learner languages", 92 | ); 93 | }), 94 | ); 95 | 96 | module.exports = router; 97 | -------------------------------------------------------------------------------- /api/src/api/learner/search.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const router = express.Router(); 3 | const { errorWrap } = require('../../middleware'); 4 | const { sendResponse } = require('../../utils/response'); 5 | const { models } = require('../../models/index.js'); 6 | const { requireAuthentication } = require('../../middleware/authentication'); 7 | 8 | router.get( 9 | '/', 10 | requireAuthentication, 11 | errorWrap(async (req, res) => { 12 | let allCourses = await models.Course.find({}, { admin_id: 0, approved: 0 }); 13 | 14 | const unitPromise = []; 15 | 16 | const getNumUnits = async (course_id) => { 17 | const numUnits = await models.Unit.countDocuments({ 18 | _course_id: { $eq: course_id }, 19 | selected: { $eq: true }, 20 | }); 21 | 22 | return numUnits; 23 | }; 24 | 25 | for (let course of allCourses) { 26 | unitPromise.push(getNumUnits(course._id)); 27 | } 28 | 29 | const totalNumUnits = await Promise.all(unitPromise); 30 | 31 | for (let i = 0; i < allCourses.length; i++) { 32 | allCourses[i] = allCourses[i].toJSON(); 33 | allCourses[i].numUnits = totalNumUnits[i]; 34 | } 35 | 36 | // Return all of the courses 37 | return sendResponse(res, 200, 'Searched courses', allCourses); 38 | }), 39 | ); 40 | 41 | module.exports = router; 42 | -------------------------------------------------------------------------------- /api/src/api/user.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const router = express.Router(); 3 | const { errorWrap } = require('../middleware'); 4 | const { sendResponse } = require('../utils/response'); 5 | const { models } = require('../models/index.js'); 6 | const { ROLE_ENUM } = require('../utils/constants.js'); 7 | const { 8 | requireAuthentication, 9 | getUserByIDToken, 10 | } = require('../middleware/authentication'); 11 | const _ = require('lodash'); 12 | const { 13 | ERR_IMPROPER_ID_TOKEN, 14 | SUCCESS_GETTING_USER_DATA, 15 | ERR_GETTING_USER_DATA, 16 | } = require('../utils/constants'); 17 | const { getCoursesByUser } = require('../utils/userHelper'); 18 | 19 | /** 20 | * Creates a new user in the database 21 | * 22 | * @param {newUser} New user 23 | * @returns a new user with their role set as user by default 24 | * authID, and admin, learner, and collaborator languages set to 25 | * an empty array 26 | */ 27 | router.post( 28 | '/', 29 | errorWrap(async (req, res) => { 30 | const userInfo = req.body; 31 | const userData = await getUserByIDToken(userInfo.idToken); 32 | 33 | if (!userData || !userData.sub) { 34 | return sendResponse(res, 400, ERR_IMPROPER_ID_TOKEN); 35 | } 36 | const userAuthID = userData.sub; 37 | 38 | const userExists = await models.User.findOne({ authID: userAuthID }); 39 | if (userExists) { 40 | let result = userExists; 41 | result = _.omit(result, ['authID']); 42 | return sendResponse( 43 | res, 44 | 202, 45 | 'User with this authID already exists', 46 | result, 47 | ); 48 | } 49 | const newUser = new models.User({ 50 | role: ROLE_ENUM.USER, 51 | authID: userAuthID, 52 | adminLanguages: [], 53 | learnerLanguages: [], 54 | collaboratorLanguages: [], 55 | }); 56 | await newUser.save(); 57 | let newResult = newUser.toJSON(); 58 | newResult = _.omit(newResult, ['authID']); 59 | return sendResponse(res, 200, 'Successfully created a new user', newResult); 60 | }), 61 | ); 62 | 63 | router.get( 64 | '/', 65 | requireAuthentication, 66 | errorWrap(async (req, res) => { 67 | const userData = req.user; 68 | 69 | // if for some reason, there is no user data to work with 70 | if (!userData) { 71 | return sendResponse(res, 400, ERR_GETTING_USER_DATA); 72 | } 73 | 74 | const dataToReturn = _.omit(userData, ['authID']); 75 | 76 | // reformats the data related to the courses that the user belongs to 77 | 78 | dataToReturn.adminLanguages = await getCoursesByUser( 79 | dataToReturn.adminLanguages, 80 | ); 81 | dataToReturn.learnerLanguages = await getCoursesByUser( 82 | dataToReturn.learnerLanguages, 83 | ); 84 | dataToReturn.collaboratorLanguages = await getCoursesByUser( 85 | dataToReturn.collaboratorLanguages, 86 | ); 87 | 88 | return sendResponse(res, 200, SUCCESS_GETTING_USER_DATA, dataToReturn); 89 | }), 90 | ); 91 | 92 | module.exports = router; 93 | -------------------------------------------------------------------------------- /api/src/app.js: -------------------------------------------------------------------------------- 1 | const createError = require('http-errors'); 2 | const cors = require('cors'); 3 | const express = require('express'); 4 | const helmet = require('helmet'); 5 | const logger = require('morgan'); 6 | const bodyParser = require('body-parser'); 7 | const apiRoutes = require('./api'); 8 | const { errorHandler, errorWrap } = require('./middleware'); 9 | const { initDB } = require('./utils/mongo-setup'); 10 | const { ENV_TEST } = require('./utils/constants'); 11 | const bb = require('express-busboy'); 12 | 13 | const app = express(); 14 | 15 | app.use(helmet()); 16 | app.use(cors()); 17 | 18 | app.use(logger('dev')); 19 | 20 | app.use(bodyParser.json()); 21 | app.use(bodyParser.urlencoded({ extended: false })); 22 | 23 | bb.extend(app, { 24 | upload: true, 25 | }); 26 | 27 | // Mongo setup 28 | if (process.env.NODE_ENV !== ENV_TEST) { 29 | initDB(); 30 | } 31 | 32 | // Routes 33 | app.use('/', apiRoutes); 34 | app.get('/favicon.ico', (req, res) => res.status(204)); 35 | 36 | app.use(function (req, res, next) { 37 | next(createError(404)); 38 | }); 39 | 40 | app.use(errorHandler, errorWrap); 41 | 42 | module.exports = app; 43 | -------------------------------------------------------------------------------- /api/src/middleware/authentication.js: -------------------------------------------------------------------------------- 1 | const { 2 | ERR_AUTH_FAILED, 3 | ERR_IMPROPER_ID_TOKEN, 4 | ERR_NO_MONGO_DOCUMENT, 5 | ENV_PROD, 6 | } = require('../utils/constants'); 7 | const { sendResponse } = require('../utils/response'); 8 | const { OAuth2Client } = require('google-auth-library'); 9 | const client = new OAuth2Client(); 10 | const { models } = require('../models/index.js'); 11 | 12 | /** 13 | * Middleware requires the incoming request to be authenticated, meaning that they have previously 14 | * logged in with their Google Account. Note, we are not checking if a user has access to a specific course. 15 | * 16 | * If not authenticated, a response 17 | * is sent back to the client, and the middleware chain is stopped. Authentication is done through 18 | * the 'authentication' HTTP header, which should be of the format 'Bearer '. If 19 | * successful, the user's data is attachted to req.user before calling the next function. 20 | */ 21 | 22 | const requireAuthentication = async (req, res, next) => { 23 | try { 24 | // Validate user using Google Auth ID Token 25 | const user = await getUserFromRequest(req); 26 | if (!user) { 27 | sendResponse(res, 401, ERR_IMPROPER_ID_TOKEN); 28 | } else { 29 | // Checks if user exists in MongoDB 30 | const userInMongo = await models.User.findOne({ authID: user.sub }); // user.sub returns the user's Google Account unique ID 31 | if (userInMongo) { 32 | const userData = { 33 | name: user.name, 34 | locale: user.locale, 35 | email: user.email, 36 | picture: user.picture, 37 | given_name: user.given_name, 38 | family_name: user.family_name, 39 | }; 40 | const mergedUserData = Object.assign(userData, userInMongo.toJSON()); 41 | req.user = mergedUserData; 42 | 43 | return next(); 44 | } 45 | return sendResponse(res, 401, ERR_NO_MONGO_DOCUMENT); 46 | } 47 | } catch (error) { 48 | console.error('Error during authentication middleware:', error); 49 | sendResponse(res, 401, ERR_AUTH_FAILED); 50 | } 51 | }; 52 | 53 | /** 54 | * Valides a user with their Google Auth ID Token 55 | * @param {String} idToken Google Auth ID Token (JWT) 56 | * @returns Google User Data 57 | */ 58 | const getUserByIDToken = async (idToken) => { 59 | try { 60 | let audience = process.env.EXPO_CLIENT_ID; 61 | 62 | if (process.env.NODE_ENV === ENV_PROD) { 63 | audience = [process.env.IOS_CLIENT_ID, process.env.ANDROID_CLIENT_ID]; 64 | } 65 | 66 | if (idToken) { 67 | const ticket = await client.verifyIdToken({ 68 | idToken, 69 | audience: audience, 70 | }); 71 | const data = ticket.getPayload(); 72 | return data; 73 | } 74 | return null; 75 | } catch (error) { 76 | console.error('Error during Google Auth ID Token Verification: ', error); 77 | return null; 78 | } 79 | }; 80 | 81 | /** 82 | * Gets user data using a JWT stored in the request header 83 | * @param {*} req request data 84 | * @returns user data 85 | */ 86 | const getUserFromRequest = async (req) => { 87 | const authHeader = req?.headers?.authorization?.split(' '); 88 | 89 | if (authHeader?.length !== 2) { 90 | return null; 91 | } 92 | const idToken = authHeader[1]; 93 | 94 | const user = await getUserByIDToken(idToken); 95 | return user; 96 | }; 97 | 98 | module.exports = { 99 | getUserByIDToken, 100 | requireAuthentication, 101 | }; 102 | -------------------------------------------------------------------------------- /api/src/middleware/authorization.js: -------------------------------------------------------------------------------- 1 | const { ERR_AUTH_FAILED, ERR_NOT_AUTHORIZED } = require('../utils/constants'); 2 | const { sendResponse } = require('../utils/response'); 3 | const { models } = require('../models/index.js'); 4 | 5 | const requireLanguageAuthorization = async (req, res, next) => { 6 | var current_language = 7 | req.body._course_id || 8 | req.body.course_id || 9 | req.query.course_id || 10 | req.params.id || 11 | req.params.course_id; 12 | 13 | if (!current_language) { 14 | return sendResponse(res, 403, ERR_AUTH_FAILED); 15 | } 16 | 17 | var ObjectId = require('mongoose').Types.ObjectId; 18 | if (!ObjectId.isValid(current_language)) { 19 | return sendResponse(res, 400, 'Invalid ObjectID'); 20 | } 21 | try { 22 | /* Check if the course exists */ 23 | const courseExists = await models.Course.findById(current_language); 24 | if (!courseExists) { 25 | return sendResponse(res, 404, 'Course does not exist'); 26 | } 27 | 28 | /* Check if the user is an admin for this course */ 29 | var authorized_languages = req.user.adminLanguages; 30 | 31 | let isAuthorized = false; 32 | 33 | for (let i = 0; i < authorized_languages.length; i++) { 34 | if (authorized_languages[i] === current_language) { 35 | isAuthorized = true; 36 | break; 37 | } 38 | } 39 | 40 | if (isAuthorized) { 41 | const course = await models.Course.findById(current_language); 42 | if (course.admin_id !== req.user.authID) { 43 | // Course doesn't contain the admin id, so we discredit the user as an admin of this course 44 | isAuthorized = false; 45 | } else { 46 | req.user.isLearner = false; 47 | // Authorized admin 48 | return next(); 49 | } 50 | } 51 | 52 | /* 53 | Check if the user is a learner for this course if the user isn't an admin 54 | and it is a GET request 55 | */ 56 | if (!isAuthorized && 'GET' === req.method) { 57 | var authorized_learner_languages = req.user.learnerLanguages; 58 | for (let i = 0; i < authorized_learner_languages.length; i++) { 59 | if (authorized_learner_languages[i] === current_language) { 60 | req.user.isLearner = true; 61 | isAuthorized = true; 62 | break; 63 | } 64 | } 65 | } 66 | 67 | if (!isAuthorized) { 68 | return sendResponse(res, 403, ERR_NOT_AUTHORIZED); 69 | } 70 | 71 | return next(); 72 | } catch (error) { 73 | console.error( 74 | 'requireLanguageAuthorization(): error caught: ', 75 | error.message, 76 | ); 77 | return sendResponse(res, 403, ERR_AUTH_FAILED); 78 | } 79 | }; 80 | 81 | const requireLearnerAuthorization = async (req, res, next) => { 82 | var current_language = 83 | req.body._course_id || 84 | req.body.course_id || 85 | req.query.course_id || 86 | req.params.id || 87 | req.params.course_id; 88 | 89 | if (!current_language) { 90 | return sendResponse(res, 403, ERR_AUTH_FAILED); 91 | } 92 | 93 | var ObjectId = require('mongoose').Types.ObjectId; 94 | if (!ObjectId.isValid(current_language)) { 95 | return sendResponse(res, 400, 'Invalid ObjectID'); 96 | } 97 | try { 98 | /* Check if the course exists */ 99 | const courseExists = await models.Course.findById(current_language); 100 | if (!courseExists) { 101 | return sendResponse(res, 404, 'Course does not exist'); 102 | } 103 | 104 | let isAuthorized = false; 105 | 106 | /* 107 | Check if the user is a learner for this course 108 | */ 109 | var authorized_learner_languages = req.user.learnerLanguages; 110 | for (let i = 0; i < authorized_learner_languages.length; i++) { 111 | if (authorized_learner_languages[i] === current_language) { 112 | req.user.isLearner = true; 113 | isAuthorized = true; 114 | break; 115 | } 116 | } 117 | 118 | if (!isAuthorized) { 119 | return sendResponse(res, 403, ERR_NOT_AUTHORIZED); 120 | } 121 | 122 | return next(); 123 | } catch (error) { 124 | console.error( 125 | 'requireLanguageAuthorization(): error caught: ', 126 | error.message, 127 | ); 128 | return sendResponse(res, 403, ERR_AUTH_FAILED); 129 | } 130 | }; 131 | 132 | module.exports = { 133 | requireLanguageAuthorization, 134 | requireLearnerAuthorization, 135 | }; 136 | -------------------------------------------------------------------------------- /api/src/middleware/errorHandler.js: -------------------------------------------------------------------------------- 1 | const errorHandler = (err, req, res) => { 2 | console.error(err); 3 | console.error(err.stack); 4 | res.status(500).json({ 5 | code: 500, 6 | message: err.message, 7 | result: {}, 8 | success: false, 9 | }); 10 | }; 11 | 12 | module.exports = errorHandler; 13 | -------------------------------------------------------------------------------- /api/src/middleware/errorWrap.js: -------------------------------------------------------------------------------- 1 | /* 2 | * Middleware to allow us to safely handle errors in async/await code 3 | * without wrapping each route in try...catch blocks 4 | */ 5 | const errorWrap = (fn) => (req, res, next) => 6 | Promise.resolve(fn(req, res, next)).catch(next); 7 | 8 | module.exports = errorWrap; 9 | -------------------------------------------------------------------------------- /api/src/middleware/index.js: -------------------------------------------------------------------------------- 1 | const errorHandler = require('./errorHandler'); 2 | const errorWrap = require('./errorWrap'); 3 | 4 | module.exports = { 5 | errorHandler, 6 | errorWrap, 7 | }; 8 | -------------------------------------------------------------------------------- /api/src/models/index.js: -------------------------------------------------------------------------------- 1 | const { User, Complete } = require('./user'); 2 | const { 3 | Course, 4 | CourseDetails, 5 | Unit, 6 | Lesson, 7 | Vocab, 8 | isUniqueOrder, 9 | } = require('./language'); 10 | 11 | const models = { 12 | User, 13 | Course, 14 | CourseDetails, 15 | Unit, 16 | Lesson, 17 | Vocab, 18 | Complete, 19 | }; 20 | 21 | module.exports = { 22 | models, 23 | isUniqueOrder, 24 | }; 25 | -------------------------------------------------------------------------------- /api/src/models/language.js: -------------------------------------------------------------------------------- 1 | const mongoose = require('mongoose'); 2 | 3 | // function to get a random code for a new course 4 | const getRandomCode = () => Math.random().toString(36).substring(2, 7); 5 | 6 | /* Schemas */ 7 | 8 | const CourseDetails = new mongoose.Schema({ 9 | admin_name: { type: String, required: true }, 10 | admin_email: { type: String, required: true }, 11 | name: { type: String, required: true }, 12 | alternative_name: { type: String, required: false, default: '' }, 13 | description: { type: String, required: false, default: '' }, 14 | iso: { type: String, required: false, default: '' }, 15 | glotto: { type: String, required: false, default: '' }, 16 | translated_language: { type: String, required: true, default: 'English' }, 17 | population: { type: String, required: false, default: '' }, 18 | location: { type: String, required: false, default: '' }, 19 | link: { type: String, required: false, default: '' }, 20 | is_private: { type: Boolean, required: false, default: true }, 21 | code: { type: String, required: false, default: getRandomCode }, 22 | }); 23 | 24 | const Course = new mongoose.Schema({ 25 | approved: { type: Boolean, required: true }, 26 | admin_id: { type: String, required: true }, 27 | details: { type: CourseDetails, required: true }, 28 | }); 29 | 30 | const Unit = new mongoose.Schema({ 31 | _course_id: { type: String, required: true, index: true }, 32 | name: { type: String, required: true }, 33 | _order: { type: Number, required: true, index: true }, 34 | selected: { type: Boolean, required: true }, 35 | description: { type: String, required: false, default: '' }, 36 | }); 37 | 38 | Unit.index({ _course_id: 1, _order: 1 }); 39 | 40 | const Vocab = new mongoose.Schema({ 41 | _order: { type: Number, required: true, index: true }, 42 | original: { type: String, required: true }, 43 | translation: { type: String, required: true }, 44 | image: { type: String, required: false, default: '' }, 45 | audio: { type: String, required: false, default: '' }, 46 | selected: { type: Boolean, required: true, default: true }, 47 | notes: { type: String, required: false, default: '' }, 48 | }); 49 | 50 | Vocab.index({ _order: 1 }); 51 | 52 | const Lesson = new mongoose.Schema({ 53 | _course_id: { type: String, required: true, index: true }, 54 | _unit_id: { type: String, required: true, index: true }, 55 | name: { type: String, required: true }, 56 | _order: { type: Number, required: true, index: true }, 57 | selected: { type: Boolean, required: true }, 58 | vocab: { type: [Vocab], required: true, default: [] }, 59 | description: { type: String, required: false, default: '' }, 60 | }); 61 | 62 | Lesson.index({ _course_id: 1, _unit_id: 1, _order: 1 }); 63 | 64 | /* Validation Methods */ 65 | 66 | /** 67 | * Determines whether a document with a specific _id has a specific order 68 | * @param {JSON} params Parameters considered when searching for all documents in model 69 | * @param {ObjectId} _id document that we are checking 70 | * @param {Mongoose Model} model Model that the document belongs to 71 | * @param {Object} session Mongoose Transaction Session object 72 | * @returns true if the document with _id has a unique order 73 | */ 74 | const isUniqueOrder = async (params, _id, model, session = null) => { 75 | let documents; 76 | 77 | if (session) { 78 | documents = await model.find(params).session(session).lean(); 79 | } else { 80 | documents = await model.find(params).lean(); 81 | } 82 | 83 | if (documents.length > 2) { 84 | // If there's more than two, it's not unique. 85 | return false; 86 | } else if (documents.length <= 1) { 87 | // If there is one, it is unique 88 | return true; 89 | } 90 | 91 | const string_id = _id.toString(); 92 | 93 | // If there's exactly two, the _id better match 94 | if ( 95 | documents[0]._id.toString() !== string_id && 96 | documents[1]._id.toString() !== string_id 97 | ) { 98 | return false; 99 | } 100 | 101 | // If there is exactly two, only one document should be selected 102 | return documents[0].selected !== documents[1].selected; 103 | }; 104 | 105 | /* Exports */ 106 | module.exports.Course = mongoose.model('Course', Course); 107 | module.exports.CourseDetails = mongoose.model('CourseDetails', CourseDetails); 108 | module.exports.Unit = mongoose.model('Unit', Unit); 109 | module.exports.Lesson = mongoose.model('Lesson', Lesson); 110 | module.exports.Vocab = mongoose.model('Vocab', Vocab); 111 | module.exports.isUniqueOrder = isUniqueOrder; 112 | -------------------------------------------------------------------------------- /api/src/models/user.js: -------------------------------------------------------------------------------- 1 | const mongoose = require('mongoose'); 2 | 3 | const User = new mongoose.Schema({ 4 | role: { type: Number, required: true }, 5 | authID: { type: String, required: true, unique: true }, 6 | adminLanguages: { type: [String], required: false, default: [] }, 7 | learnerLanguages: { type: [String], required: false, default: [] }, 8 | collaboratorLanguages: { type: [String], required: false, default: [] }, 9 | }); 10 | 11 | const Complete = new mongoose.Schema({ 12 | user_id: { type: String, required: true }, 13 | _course_id: { type: String, required: true, index: true }, 14 | _unit_id: { type: String, required: true, index: true }, 15 | _lesson_id: { type: String, required: true, index: true }, 16 | }); 17 | 18 | Complete.index({ _course_id: 1, _unit_id: 1, _lesson_id: 1 }); 19 | 20 | module.exports.Complete = mongoose.model('Complete', Complete); 21 | module.exports.User = mongoose.model('User', User); 22 | -------------------------------------------------------------------------------- /api/src/utils/aws/exports.js: -------------------------------------------------------------------------------- 1 | exports.S3_BUCKET_NAME = process.env.S3_BUCKET_NAME; 2 | exports.S3_REGION = process.env.S3_REGION; 3 | exports.ACCESS_KEY_ID = process.env.S3_ACCESS_KEY_ID; 4 | exports.SECRET_ACCESS_KEY = process.env.S3_SECRET_ACCESS_KEY; 5 | -------------------------------------------------------------------------------- /api/src/utils/aws/s3.js: -------------------------------------------------------------------------------- 1 | const AWS = require('aws-sdk'); 2 | 3 | const { 4 | S3_BUCKET_NAME, 5 | S3_REGION, 6 | ACCESS_KEY_ID, 7 | SECRET_ACCESS_KEY, 8 | } = require('./exports'); 9 | 10 | const getS3 = () => { 11 | const s3 = new AWS.S3({ 12 | accessKeyId: ACCESS_KEY_ID, 13 | secretAccessKey: SECRET_ACCESS_KEY, 14 | region: S3_REGION, 15 | }); 16 | 17 | return s3; 18 | }; 19 | 20 | /** 21 | * Downloads file from the S3 bucket 22 | * @param objectKey The key as defined in S3 console. Usually is just the full path of the file. 23 | * @param credentials The temporary credentials of the end user. Frontend should provide this. 24 | * @param onDownloaded Callback after finished downloading. Params are (err, data). 25 | */ 26 | module.exports.downloadFile = (objectKey) => { 27 | const params = { 28 | Bucket: S3_BUCKET_NAME, 29 | Key: objectKey, 30 | }; 31 | 32 | const s3 = getS3(); 33 | const object = s3.getObject(params); 34 | 35 | return object; 36 | }; 37 | 38 | module.exports.uploadFile = async (content, remoteFileName) => { 39 | const params = { 40 | Body: content, 41 | Bucket: S3_BUCKET_NAME, 42 | Key: remoteFileName, 43 | }; 44 | 45 | const s3 = getS3(); 46 | await s3.putObject(params).promise(); 47 | }; 48 | 49 | module.exports.deleteFile = async (objectKey) => { 50 | const params = { 51 | Bucket: S3_BUCKET_NAME, 52 | Key: objectKey, 53 | }; 54 | 55 | const s3 = getS3(); 56 | await s3.deleteObject(params).promise(); 57 | }; 58 | 59 | /** 60 | * Delete's all of the files in a folder 61 | * @param {String} folderName The id of the patient 62 | * Source: https://stackoverflow.com/questions/20207063/how-can-i-delete-folder-on-s3-with-node-js 63 | */ 64 | const deleteFolder = async (folderName) => { 65 | const params = { 66 | Bucket: S3_BUCKET_NAME, 67 | Prefix: `${folderName}/`, 68 | }; 69 | 70 | const s3 = getS3(); 71 | 72 | // Gets up to 1000 files that need to be deleted 73 | const listedObjects = await s3.listObjectsV2(params).promise(); 74 | if (listedObjects.Contents.length === 0) { 75 | return; 76 | } 77 | 78 | const deleteParams = { 79 | Bucket: S3_BUCKET_NAME, 80 | Delete: { Objects: [] }, 81 | }; 82 | 83 | // Builds a list of the files to delete 84 | listedObjects.Contents.forEach(({ Key }) => { 85 | deleteParams.Delete.Objects.push({ Key }); 86 | }); 87 | 88 | // Deletes the files from S3 89 | await s3.deleteObjects(deleteParams).promise(); 90 | 91 | // If there are more than 1000 objects that need to be deleted from the folder 92 | if (listedObjects.IsTruncated) { 93 | await deleteFolder(folderName); 94 | } 95 | }; 96 | 97 | module.exports.deleteFolder = deleteFolder; 98 | -------------------------------------------------------------------------------- /api/src/utils/constants.js: -------------------------------------------------------------------------------- 1 | module.exports.ENV_TEST = 'test'; 2 | module.exports.ENV_PROD = 'production'; 3 | module.exports.ROLE_ENUM = { USER: 0, ADMIN: 1 }; 4 | module.exports.ERR_IMPROPER_ID_TOKEN = 5 | 'Missing or improper ID Token. Please log out and try again.'; 6 | module.exports.ERR_AUTH_FAILED = 7 | 'Authentication failed. Please log out and try again.'; 8 | module.exports.ERR_NO_MONGO_DOCUMENT = 9 | 'You do not have a record in our database. Please log out and try again.'; 10 | module.exports.ERR_NO_COURSE_DETAILS = 11 | 'Missing Course details. Please log out and try again.'; 12 | module.exports.ERR_GETTING_USER_DATA = 13 | 'Error fetching user data, please try again.'; 14 | module.exports.ERR_NOT_AUTHORIZED = 15 | 'You are not authorized to access this course.'; 16 | module.exports.SUCCESS_GETTING_USER_DATA = 'Successfully fetched user data'; 17 | module.exports.ERR_GETTING_LESSON_DATA = 18 | 'Error fetching vocab data, please try again.'; 19 | module.exports.SUCCESS_GETTING_LESSON_DATA = 'Successfully fetched lesson data'; 20 | module.exports.ERR_MISSING_OR_INVALID_DATA = 21 | 'Missing or invalid data in the request. Please try again.'; 22 | module.exports.SUCCESS_POSTING_VOCAB_DATA = 23 | 'Successfully created a new vocab item'; 24 | module.exports.NOT_FOUND_INDEX = -1; 25 | module.exports.SUCCESS_PATCHING_LESSON_DATA = 'Success patching lesson data'; 26 | module.exports.SUCCESS_POSTING_LESSON_DATA = 27 | 'Successfully created a new lesson'; 28 | -------------------------------------------------------------------------------- /api/src/utils/example-data.js: -------------------------------------------------------------------------------- 1 | module.exports.exampleData = [ 2 | { 3 | unitData: { 4 | name: 'Language Learning Phrases', 5 | selected: true, 6 | description: 7 | 'Useful phrases that are a good introduction to the language.', 8 | }, 9 | lessons: [ 10 | { 11 | lessonData: { 12 | name: 'Helpful Phrases', 13 | selected: true, 14 | description: 'Helpful phrases description.', 15 | }, 16 | vocab: [ 17 | { 18 | original: 'How do you say hello?', 19 | translation: 'Translation of hello', 20 | }, 21 | { 22 | original: 'How do you say goodnight?', 23 | translation: 'Translation of goodnight', 24 | }, 25 | ], 26 | }, 27 | { 28 | lessonData: { 29 | name: 'The Basics - Colors', 30 | selected: true, 31 | description: 'Learn how to say a variety of colors.', 32 | }, 33 | vocab: [], 34 | }, 35 | { 36 | lessonData: { 37 | name: 'The Basics - Numbers', 38 | selected: true, 39 | description: 'Learn the basic numbers of the language.', 40 | }, 41 | vocab: [], 42 | }, 43 | ], 44 | }, 45 | ]; 46 | -------------------------------------------------------------------------------- /api/src/utils/learnerHelper.js: -------------------------------------------------------------------------------- 1 | const { models } = require('../models'); 2 | 3 | /** 4 | * Determines if a user has completed a unit 5 | * @param {String} user_id User MongoDB _id 6 | * @param {String} unit_id MongoDB _id of the unit to check 7 | * @returns True if the unit is complete 8 | */ 9 | const hasCompletedUnit = async (user_id, unit_id) => { 10 | if (!user_id || !unit_id) { 11 | return false; 12 | } 13 | 14 | const selectedLessons = await models.Lesson.find({ 15 | _unit_id: { $eq: unit_id }, 16 | selected: { $eq: true }, 17 | }); 18 | 19 | const checkCompletedList = []; 20 | 21 | for (let lesson of selectedLessons) { 22 | checkCompletedList.push(hasCompletedLesson(user_id, lesson._id)); 23 | } 24 | 25 | const results = await Promise.all(checkCompletedList); 26 | 27 | // See if there is any uncompleted lesson 28 | for (let val of results) { 29 | if (!val) { 30 | return false; 31 | } 32 | } 33 | 34 | return true; 35 | }; 36 | 37 | /** 38 | * Determines if a user has completed a specific lesson 39 | * @param {String} user_id User MongoDB _id 40 | * @param {String} lesson_id MongoDB _id of the lesson to check 41 | * @returns True if the lesson is marked as complete 42 | */ 43 | const hasCompletedLesson = async (user_id, lesson_id) => { 44 | if (!lesson_id || !user_id) { 45 | return false; 46 | } 47 | const docExists = await models.Complete.exists({ 48 | user_id: user_id, 49 | _lesson_id: lesson_id, 50 | }); 51 | return docExists; 52 | }; 53 | 54 | module.exports.hasCompletedLesson = hasCompletedLesson; 55 | 56 | /** 57 | * Gets all lessons a user has completed within a given unit 58 | * @param {String} user_id User MongoDB _id 59 | * @param {String} unit_id MongoDB _id of the unit to check 60 | * @returns List of lessons completed in the unit 61 | */ 62 | const getAllCompletedLessons = async (user_id, unit_id) => { 63 | if (!unit_id || !user_id) { 64 | return false; 65 | } 66 | const completedLessons = await models.Complete.find({ 67 | user_id: user_id, 68 | _unit_id: unit_id, 69 | }); 70 | const filteredLessons = completedLessons.map((val) => val._lesson_id); 71 | return filteredLessons; 72 | }; 73 | 74 | module.exports.getAllCompletedLessons = getAllCompletedLessons; 75 | 76 | const getAllCompletedUnits = async (user_id, course_id) => { 77 | if (!course_id || !user_id) { 78 | return false; 79 | } 80 | 81 | let units = await models.Unit.find({ _course_id: course_id, selected: true }); 82 | 83 | const checkCompletedList = []; 84 | 85 | for (let unit of units) { 86 | checkCompletedList.push(hasCompletedUnit(user_id, unit._id)); 87 | } 88 | 89 | const results = await Promise.all(checkCompletedList); 90 | 91 | const completedUnits = []; 92 | 93 | // See if there is any uncompleted lesson 94 | for (let i = 0; i < results.length; i++) { 95 | if (results[i]) { 96 | completedUnits.push(String(units[i]._id)); 97 | } 98 | } 99 | 100 | return completedUnits; 101 | }; 102 | 103 | module.exports.getAllCompletedUnits = getAllCompletedUnits; 104 | -------------------------------------------------------------------------------- /api/src/utils/mongo-setup.js: -------------------------------------------------------------------------------- 1 | const mongoose = require('mongoose'); 2 | 3 | // CONNECTION TO MONGO 4 | 5 | /** 6 | * Initalizes and connects to the DB. Should be called at app startup. 7 | */ 8 | module.exports.initDB = () => { 9 | mongoose.connect(process.env.MONGO_URL, { 10 | useNewUrlParser: true, 11 | useUnifiedTopology: true, 12 | }); 13 | 14 | mongoose.Promise = global.Promise; 15 | 16 | mongoose.connection 17 | .once('open', () => { 18 | console.log('Connected to the DB'); 19 | }) 20 | .on('error', (error) => 21 | console.error('Error connecting to the database: ', error), 22 | ); 23 | }; 24 | -------------------------------------------------------------------------------- /api/src/utils/response.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Convienience function for sending responses. 3 | * @param {Object} res The response object 4 | * @param {Number} code The HTTP response code to send 5 | * @param {String} message The message to send. 6 | * @param {Object} data The optional data to send back. 7 | */ 8 | module.exports.sendResponse = async (res, code, message, data = {}) => { 9 | await res.status(code).json({ 10 | success: isCodeSuccessful(code), 11 | message, 12 | result: data, 13 | }); 14 | }; 15 | 16 | const isCodeSuccessful = (code) => code >= 200 && code < 300; 17 | -------------------------------------------------------------------------------- /api/src/utils/userHelper.js: -------------------------------------------------------------------------------- 1 | const { models } = require('../models/index.js'); 2 | const { getNumUnitsInCourse } = require('./languageHelper'); 3 | 4 | module.exports.getCoursesByUser = async (courseList) => { 5 | const getCourseList = courseList.map(async (courseId) => { 6 | const singleCourse = await getCourseById(courseId); 7 | return singleCourse; 8 | }); 9 | 10 | const courseData = await Promise.all(getCourseList); 11 | return courseData; 12 | }; 13 | 14 | const getCourseById = async (courseId) => { 15 | const courseData = await models.Course.findById(courseId); 16 | if (courseData) { 17 | const num_units = await getNumUnitsInCourse(courseId); 18 | return { 19 | name: courseData.details.name, 20 | _id: courseId, 21 | num_units, 22 | }; 23 | } 24 | return null; 25 | }; 26 | -------------------------------------------------------------------------------- /api/test.env: -------------------------------------------------------------------------------- 1 | NODE_ENV='test' 2 | PORT=3000 3 | DB_URI='fakeurl' 4 | IOS_CLIENT_ID='fakeid' 5 | ANDROID_CLIENT_ID='fakeid' 6 | S3_ACCESS_KEY_ID='access_key_id' 7 | S3_SECRET_ACCESS_KEY='secret_access_key' 8 | S3_BUCKET_NAME='bucket_name' 9 | S3_REGION='us-east-1' -------------------------------------------------------------------------------- /api/test/.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hack4impact-uiuc/7000-languages/8153ca142d90f7bbec01d009d09f62ac2a317b8f/api/test/.DS_Store -------------------------------------------------------------------------------- /api/test/db-data/courses.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "_id": "62391a30487d5ae343c82311", 4 | "approved": true, 5 | "admin_id": "ba32cb26-2020-4fbc-b77d-34ea6b0790a6", 6 | "details": { 7 | "_id": "62391a30487d5ae343c82312", 8 | "is_private": false, 9 | "name": "vero", 10 | "alternative_name": "architecto", 11 | "admin_name": "Miss Priscilla Nienow", 12 | "admin_email": "Rodolfo64@gmail.com", 13 | "description": "Minus illo maiores ut laborum vitae soluta eaque est. Numquam atque nostrum rem in aspernatur debitis. Quis aut omnis optio nisi non consequatur autem quod quisquam. Sunt laboriosam quae sapiente ea doloremque.", 14 | "translated_language": "English", 15 | "location": "North America", 16 | "iso": "en", 17 | "glotto": "stan1293", 18 | "population": "8750", 19 | "link": "https://tepid-toll.com" 20 | } 21 | }, 22 | { 23 | "_id": "62391a30487d5ae343c82315", 24 | "approved": true, 25 | "admin_id": "ba32cb26-2020-4fbc-b77d-34ea6b0790a6", 26 | "details": { 27 | "name": "Course with no units", 28 | "alternative_name": "what is this?", 29 | "admin_name": "Mr Yogi Koppol", 30 | "admin_email": "yeet@gmail.com", 31 | "description": "Minus illo maiores ut laborum vitae soluta eaque est. Numquam atque nostrum rem in aspernatur debitis. Quis aut omnis optio nisi non consequatur autem quod quisquam. Sunt laboriosam quae sapiente ea doloremque.", 32 | "translated_language": "English", 33 | "location": "North America", 34 | "iso": "en", 35 | "glotto": "stan1293", 36 | "population": 8750, 37 | "link": "https://tepid-toll.com" 38 | } 39 | }, 40 | { 41 | "_id": "62391a30487d5ae343c82319", 42 | "approved": true, 43 | "admin_id": "ba32cb26-2020-4fbc-b77d-34ea6b0790a6", 44 | "details": { 45 | "name": "Private course", 46 | "alternative_name": "cool course with code!", 47 | "admin_name": "Mr Alex Masgras", 48 | "admin_email": "email@gmail.com", 49 | "description": "Minus illo maiores ut laborum vitae soluta eaque est. Numquam atque nostrum rem in aspernatur debitis. Quis aut omnis optio nisi non consequatur autem quod quisquam. Sunt laboriosam quae sapiente ea doloremque.", 50 | "translated_language": "English", 51 | "location": "North America", 52 | "iso": "en", 53 | "glotto": "stan1293", 54 | "population": "8750", 55 | "link": "https://tepid-toll.com", 56 | "is_private": "true", 57 | "code": "1234" 58 | } 59 | } 60 | ] 61 | -------------------------------------------------------------------------------- /api/test/db-data/generate-db-data.js: -------------------------------------------------------------------------------- 1 | const fs = require('fs'); 2 | const { faker } = require('@faker-js/faker'); 3 | const mongoose = require('mongoose'); 4 | 5 | const { 6 | DEFAULT_AUTH_ID, 7 | NUM_FAKE_USER_ENTRIES, 8 | NUM_FAKE_COURSE_ENTRIES, 9 | NUM_FAKE_LESSONS_PER_UNIT, 10 | NUM_FAKE_UNITS_PER_COURSE, 11 | NUM_FAKE_WORDS_PER_LESSON, 12 | } = require('../utils/constants'); 13 | 14 | const saveDataToFile = (data, filename) => { 15 | const serializedData = JSON.stringify(data, null, 2); 16 | fs.writeFileSync(`${__dirname}/../${filename}`, serializedData); 17 | }; 18 | 19 | /** 20 | * Generates n number of users 21 | * 22 | * @param {number} n the number of users to generate 23 | * @returns the generated users 24 | */ 25 | const generateUsers = (n) => { 26 | const generateUser = () => ({ 27 | _id: mongoose.Types.ObjectId(), 28 | role: 0, 29 | authID: faker.datatype.uuid(), 30 | adminLanguages: [], 31 | learnerLanguages: [], 32 | collaboratorLanguages: [], 33 | }); 34 | 35 | const users = Array(n).fill(null).map(generateUser); 36 | return users; 37 | }; 38 | 39 | /** 40 | * Generates n number of courses 41 | * 42 | * @param {number} n the number of users to generate 43 | * @returns the generated users 44 | */ 45 | const generateLanguages = ( 46 | num_courses, 47 | num_units_per_course, 48 | num_lessons_per_unit, 49 | num_words_per_lesson, 50 | ) => { 51 | const generateCourse = () => ({ 52 | _id: mongoose.Types.ObjectId(), 53 | approved: true, 54 | admin_id: DEFAULT_AUTH_ID, 55 | details: { 56 | name: faker.lorem.word(), 57 | alternative_name: faker.lorem.word(), 58 | admin_name: faker.name.findName(), 59 | admin_email: faker.internet.email(), 60 | description: faker.lorem.paragraph(), 61 | translated_language: 'English', 62 | location: 'North America', 63 | iso: 'en', 64 | glotto: 'stan1293', 65 | population: faker.datatype.number(), 66 | link: faker.internet.url(), 67 | }, 68 | }); 69 | 70 | const generateUnit = (course_id, order) => ({ 71 | _id: mongoose.Types.ObjectId(), 72 | _course_id: course_id, 73 | name: faker.lorem.word(), 74 | _order: order, 75 | selected: true, 76 | description: faker.lorem.paragraph(), 77 | }); 78 | 79 | const generateLesson = (course_id, unit_id, order) => ({ 80 | _id: mongoose.Types.ObjectId(), 81 | _course_id: course_id, 82 | _unit_id: unit_id, 83 | name: faker.lorem.word(), 84 | _order: order, 85 | selected: true, 86 | description: faker.lorem.paragraph(), 87 | vocab: Array(num_words_per_lesson) 88 | .fill(null) 89 | .map((_, index) => generateWord(index)), 90 | }); 91 | 92 | const generateWord = (order) => ({ 93 | _id: mongoose.Types.ObjectId(), 94 | _order: order, 95 | original: faker.lorem.word(), 96 | translation: faker.lorem.word(), 97 | image: faker.datatype.string(), 98 | audio: faker.datatype.string(), 99 | notes: faker.lorem.paragraph(), 100 | }); 101 | 102 | const courseData = Array(num_courses).fill(null).map(generateCourse); 103 | const unitData = []; 104 | const lessonData = []; 105 | 106 | courseData.forEach((element) => { 107 | const course_id = element._id; 108 | for (let i = 0; i < num_units_per_course; i++) { 109 | unitData.push(generateUnit(course_id, i)); 110 | } 111 | }); 112 | 113 | unitData.forEach((element) => { 114 | const course_id = element._course_id; 115 | const unit_id = element._id; 116 | for (let i = 0; i < num_lessons_per_unit; i++) { 117 | lessonData.push(generateLesson(course_id, unit_id, i)); 118 | } 119 | }); 120 | 121 | return { 122 | courseData, 123 | unitData, 124 | lessonData, 125 | }; 126 | }; 127 | 128 | // Change this to change what data you want generated 129 | const generate = () => { 130 | // Generates users 131 | // const userData = generateUsers(NUM_FAKE_USER_ENTRIES); 132 | // saveDataToFile(userData, './db-data/users.json'); 133 | 134 | // Generates course 135 | const { courseData, unitData, lessonData } = generateLanguages( 136 | NUM_FAKE_COURSE_ENTRIES, 137 | NUM_FAKE_UNITS_PER_COURSE, 138 | NUM_FAKE_LESSONS_PER_UNIT, 139 | NUM_FAKE_WORDS_PER_LESSON, 140 | ); 141 | saveDataToFile(courseData, './db-data/courses.json'); 142 | saveDataToFile(unitData, './db-data/units.json'); 143 | saveDataToFile(lessonData, './db-data/lessons.json'); 144 | }; 145 | 146 | generate(); 147 | -------------------------------------------------------------------------------- /api/test/db-data/units.json: -------------------------------------------------------------------------------- 1 | [ 2 | { 3 | "_id": "62391a30487d5ae343c82312", 4 | "_course_id": "62391a30487d5ae343c82311", 5 | "name": "ratione", 6 | "_order": 0, 7 | "selected": true, 8 | "description": "Omnis quam pariatur quasi id aperiam reprehenderit. Accusantium sint sunt nihil id eveniet alias aut fuga ut. Quae amet qui vel doloremque doloremque. Est doloribus qui ea enim voluptas nemo voluptatibus qui. Labore sed explicabo tenetur repellendus odio odit quas. Aperiam accusantium et." 9 | }, 10 | { 11 | "_id": "62391a30487d5ae343c82313", 12 | "_course_id": "62391a30487d5ae343c82311", 13 | "name": "est", 14 | "_order": 1, 15 | "selected": true, 16 | "description": "Vel aperiam minima magnam hic pariatur. Rem neque culpa. Quae sit in. Magni enim illum architecto et labore quo ut accusamus." 17 | }, 18 | { 19 | "_id": "62391a30487d5ae343c82314", 20 | "_course_id": "62391a30487d5ae343c82311", 21 | "name": "sed", 22 | "_order": 2, 23 | "selected": true, 24 | "description": "Ut molestiae perferendis dolores vero sed. Enim rerum quod quis hic neque at aperiam in consequuntur. Voluptatum qui repudiandae voluptate fugit excepturi et dolorem aspernatur. Aut veritatis quia repudiandae libero expedita. Aut quia minima quia voluptas sed. Sint numquam tenetur." 25 | }, 26 | { 27 | "_id": "62391a30487d5ae343c82315", 28 | "_course_id": "62391a30487d5ae343c82311", 29 | "name": "dolore", 30 | "_order": 3, 31 | "selected": true, 32 | "description": "Qui sapiente dolorum quaerat rerum eaque. Ea quo id animi eligendi ab enim aperiam omnis pariatur. Doloremque illum id nulla sunt mollitia saepe natus amet. Dolores deleniti sunt reiciendis." 33 | }, 34 | { 35 | "_id": "62391a30487d5ae343c82316", 36 | "_course_id": "62391a30487d5ae343c82311", 37 | "name": "occaecati", 38 | "_order": 4, 39 | "selected": true, 40 | "description": "Et quis voluptas amet. Ut aut ut occaecati saepe et assumenda dolorem. Ipsam architecto aliquam. Aut iure sed vero dolorum nobis." 41 | }, 42 | { 43 | "_id": "62391a30487d5ae343c82317", 44 | "_course_id": "62391a30487d5ae343c82311", 45 | "name": "placeat", 46 | "_order": 5, 47 | "selected": true, 48 | "description": "Corporis iure consequatur sit. Eligendi qui ex voluptate repellat eos quia. Aut facere odio ipsum in modi quas non. Ullam voluptatibus est ipsam vel maiores." 49 | }, 50 | { 51 | "_id": "62391a30487d5ae343c82318", 52 | "_course_id": "62391a30487d5ae343c82311", 53 | "name": "sapiente", 54 | "_order": 6, 55 | "selected": true, 56 | "description": "Qui blanditiis et. Deserunt ad molestias veniam libero minus quo doloribus. Quia itaque voluptatem amet inventore nam enim et. Omnis ratione nihil adipisci enim est ut laborum. Ex odit natus aspernatur." 57 | }, 58 | { 59 | "_id": "62391a30487d5ae343c82319", 60 | "_course_id": "62391a30487d5ae343c82311", 61 | "name": "ea", 62 | "_order": 7, 63 | "selected": true, 64 | "description": "Et labore molestiae nam voluptatibus id ea nihil nulla. Voluptatibus eos molestiae. Est velit et dignissimos voluptatem voluptatibus sit voluptas. Quod quisquam est. Aut quo tenetur ullam dicta fuga voluptas." 65 | }, 66 | { 67 | "_id": "62391a30487d5ae343c8231a", 68 | "_course_id": "62391a30487d5ae343c82311", 69 | "name": "architecto", 70 | "_order": 8, 71 | "selected": true, 72 | "description": "Minima omnis ex. Non velit molestias amet eius labore temporibus quo cupiditate. Necessitatibus fugiat est tempore sequi ut." 73 | }, 74 | { 75 | "_id": "62391a30487d5ae343c8231b", 76 | "_course_id": "62391a30487d5ae343c82311", 77 | "name": "rem", 78 | "_order": 9, 79 | "selected": true, 80 | "description": "Et minus sed commodi molestiae doloremque hic dolore dignissimos. Pariatur est in labore nostrum nobis in eveniet error. Maiores libero accusamus iusto officia doloremque. Asperiores porro provident quia deserunt culpa. Delectus sint omnis id laboriosam. Et sed incidunt." 81 | } 82 | ] 83 | -------------------------------------------------------------------------------- /api/test/mock-data/auth-mock-data.js: -------------------------------------------------------------------------------- 1 | const { DEFAULT_ID_TOKEN, DEFAULT_AUTH_ID } = require('../utils/constants'); 2 | 3 | /* 4 | This function mocks the return value of the verifyIdToken() method of 5 | the OAuth2Client class, which is part of google-auth-library. 6 | for more details on the function, visit: https://github.com/googleapis/google-auth-library-nodejs/blob/main/src/auth/oauth2client.ts 7 | */ 8 | module.exports.verifyIdTokenMockReturnValue = (data) => { 9 | const userData = GOOGLE_AUTH_USER_DATA; 10 | if (data.idToken !== DEFAULT_ID_TOKEN) { 11 | userData.sub = data.idToken; 12 | } else { 13 | userData.sub = DEFAULT_AUTH_ID; 14 | } 15 | 16 | return { 17 | getPayload() { 18 | return userData; 19 | }, 20 | }; 21 | }; 22 | 23 | // This is sample data returned about a Google User 24 | const GOOGLE_AUTH_USER_DATA = { 25 | iss: 'https://accounts.google.com', 26 | azp: '', 27 | aud: '', 28 | sub: DEFAULT_AUTH_ID, // matches the authID of the first user in api/test/db-data/users.json 29 | email: 'test@gmail.com', 30 | email_verified: true, 31 | at_hash: '', 32 | nonce: '', 33 | name: 'Test Person', 34 | picture: '', 35 | given_name: 'Test', 36 | family_name: 'Person', 37 | locale: 'en', 38 | iat: 0, 39 | exp: 0, 40 | }; 41 | -------------------------------------------------------------------------------- /api/test/mock-data/complete-mock-data.js: -------------------------------------------------------------------------------- 1 | module.exports.POST_COMPLETE_LESSON = { 2 | lesson_id: '62391a30487d5ae343c8231c', 3 | course_id: '62391a30487d5ae343c82311', 4 | unit_id: '62391a30487d5ae343c82312', 5 | }; 6 | 7 | module.exports.POST_INVALID_LESSON = { 8 | lesson_id: '72391a30487d5ae343c8231c', 9 | course_id: '62391a30487d5ae343c82311', 10 | unit_id: '62391a30487d5ae343c82312', 11 | }; 12 | 13 | module.exports.POST_MISSING_LESSON = { 14 | course_id: '62391a30487d5ae343c82311', 15 | unit_id: '62391a30487d5ae343c82312', 16 | }; 17 | 18 | module.exports.POST_LESSON_INVALID_COURSE = { 19 | lesson_id: '62391a30487d5ae343c8231c', 20 | course_id: 'abc', 21 | unit_id: '62391a30487d5ae343c82312', 22 | }; 23 | 24 | module.exports.POST_LESSON_NONEXISTING_COURSE = { 25 | lesson_id: '62391a30487d5ae343c8231c', 26 | course_id: '72391a30487d5ae343c82311', 27 | unit_id: '62391a30487d5ae343c82312', 28 | }; 29 | -------------------------------------------------------------------------------- /api/test/mock-data/join-mock-data.js: -------------------------------------------------------------------------------- 1 | module.exports.POST_JOIN_COURSE = { 2 | course_id: '62391a30487d5ae343c82311', 3 | }; 4 | 5 | module.exports.POST_JOIN_PRIVATE = { 6 | course_id: '62391a30487d5ae343c82319', 7 | code: '1234', 8 | }; 9 | 10 | module.exports.POST_WRONG_CODE = { 11 | course_id: '62391a30487d5ae343c82319', 12 | code: '4321', 13 | }; 14 | 15 | module.exports.POST_NO_CODE = { 16 | course_id: '62391a30487d5ae343c82319', 17 | }; 18 | 19 | module.exports.POST_INVALID_COURSE = { 20 | course_id: "this isn't a course idiot", 21 | }; 22 | 23 | module.exports.POST_NONEXISTANT_COURSE = { 24 | course_id: '72391a30487d5ae343c82311', 25 | }; 26 | 27 | module.exports.DELETE_LEAVE_COURSE = { 28 | course_id: '62391a30487d5ae343c82311', 29 | }; 30 | -------------------------------------------------------------------------------- /api/test/mock-data/search-mock-data.js: -------------------------------------------------------------------------------- 1 | module.exports.SEARCH_COURSE_EXPECTED = [ 2 | { 3 | numUnits: 10, 4 | details: { 5 | name: 'vero', 6 | is_private: false, 7 | alternative_name: 'architecto', 8 | admin_name: 'Miss Priscilla Nienow', 9 | admin_email: 'Rodolfo64@gmail.com', 10 | description: 11 | 'Minus illo maiores ut laborum vitae soluta eaque est. Numquam atque nostrum rem in aspernatur debitis. Quis aut omnis optio nisi non consequatur autem quod quisquam. Sunt laboriosam quae sapiente ea doloremque.', 12 | translated_language: 'English', 13 | location: 'North America', 14 | iso: 'en', 15 | glotto: 'stan1293', 16 | population: '8750', 17 | link: 'https://tepid-toll.com', 18 | }, 19 | }, 20 | { 21 | numUnits: 0, 22 | details: { 23 | name: 'Course with no units', 24 | alternative_name: 'what is this?', 25 | is_private: true, 26 | admin_name: 'Mr Yogi Koppol', 27 | admin_email: 'yeet@gmail.com', 28 | description: 29 | 'Minus illo maiores ut laborum vitae soluta eaque est. Numquam atque nostrum rem in aspernatur debitis. Quis aut omnis optio nisi non consequatur autem quod quisquam. Sunt laboriosam quae sapiente ea doloremque.', 30 | translated_language: 'English', 31 | location: 'North America', 32 | iso: 'en', 33 | glotto: 'stan1293', 34 | population: '8750', 35 | link: 'https://tepid-toll.com', 36 | }, 37 | }, 38 | { 39 | numUnits: 0, 40 | details: { 41 | name: 'Private course', 42 | alternative_name: 'cool course with code!', 43 | admin_name: 'Mr Alex Masgras', 44 | admin_email: 'email@gmail.com', 45 | description: 46 | 'Minus illo maiores ut laborum vitae soluta eaque est. Numquam atque nostrum rem in aspernatur debitis. Quis aut omnis optio nisi non consequatur autem quod quisquam. Sunt laboriosam quae sapiente ea doloremque.', 47 | translated_language: 'English', 48 | location: 'North America', 49 | iso: 'en', 50 | glotto: 'stan1293', 51 | population: '8750', 52 | link: 'https://tepid-toll.com', 53 | is_private: true, 54 | }, 55 | }, 56 | ]; 57 | -------------------------------------------------------------------------------- /api/test/mock-data/user-mock-data.js: -------------------------------------------------------------------------------- 1 | const { ROLE_ENUM } = require('../../src/utils/constants.js'); 2 | 3 | module.exports.POST_SIMPLE_USER = { 4 | role: ROLE_ENUM.USER, 5 | idToken: 'ba32cb26-2020-4fbc-b77d-34ea6b0790a7', 6 | }; 7 | 8 | module.exports.POST_SIMPLE_USER_EXPECTED = { 9 | role: ROLE_ENUM.USER, 10 | adminLanguages: [], 11 | learnerLanguages: [], 12 | collaboratorLanguages: [], 13 | }; 14 | 15 | module.exports.POST_WRONG_USER_NO_ID_TOKEN = { 16 | role: ROLE_ENUM.USER, 17 | adminLanguages: [], 18 | learnerLanguages: [], 19 | collaboratorLanguages: [], 20 | }; 21 | 22 | module.exports.POST_USER_ADMIN = { 23 | role: ROLE_ENUM.ADMIN, 24 | idToken: 'ba32cb26-2020-4fbc-b77d-34ea6b0790a7', 25 | adminLanguages: [], 26 | learnerLanguages: [], 27 | collaboratorLanguages: [], 28 | }; 29 | 30 | module.exports.POST_USER_ADDITIONAL_FIELDS = { 31 | role: ROLE_ENUM.USER, 32 | idToken: 'ba32cb26-2020-4fbc-b77d-34ea6b0790a7', 33 | adminLanguages: [], 34 | learnerLanguages: [], 35 | collaboratorLanguages: [], 36 | randomLanguages: [], 37 | }; 38 | 39 | module.exports.POST_WRONG_USER_NO_ROLE = { 40 | idToken: 'ba32cb26-2020-4fbc-b77d-34ea6b0790a7', 41 | adminLanguages: [], 42 | learnerLanguages: [], 43 | collaboratorLanguages: [], 44 | }; 45 | 46 | module.exports.POST_USER_ONE_LESS_FIELD = { 47 | role: ROLE_ENUM.USER, 48 | idToken: 'ba32cb26-2020-4fbc-b77d-34ea6b0790a7', 49 | adminLanguages: [], 50 | learnerLanguages: [], 51 | }; 52 | 53 | module.exports.EXPECTED_GET_USER_DATA = { 54 | name: 'Test Person', 55 | locale: 'en', 56 | email: 'test@gmail.com', 57 | picture: '', 58 | given_name: 'Test', 59 | family_name: 'Person', 60 | role: 0, 61 | adminLanguages: [ 62 | { name: 'vero', _id: '62391a30487d5ae343c82311', num_units: 10 }, 63 | { 64 | name: 'Course with no units', 65 | _id: '62391a30487d5ae343c82315', 66 | num_units: 0, 67 | }, 68 | ], 69 | learnerLanguages: [], 70 | collaboratorLanguages: [], 71 | }; 72 | -------------------------------------------------------------------------------- /api/test/routes/search.test.js: -------------------------------------------------------------------------------- 1 | let app = require('../../src/app'); 2 | const request = require('supertest'); 3 | const db = require('../utils/db'); 4 | const { SEARCH_COURSE_EXPECTED } = require('../mock-data/search-mock-data'); 5 | 6 | const { withAuthentication } = require('../utils/auth'); 7 | const omitDeep = require('omit-deep-lodash'); 8 | const _ = require('lodash'); 9 | 10 | jest.mock('google-auth-library'); 11 | const { OAuth2Client } = require('google-auth-library'); 12 | const { verifyIdTokenMockReturnValue } = require('../mock-data/auth-mock-data'); 13 | 14 | const verifyIdTokenMock = OAuth2Client.prototype.verifyIdToken; 15 | verifyIdTokenMock.mockImplementation(verifyIdTokenMockReturnValue); 16 | 17 | // This block tests the GET /learner/search endpoint. 18 | describe('GET /learner/search ', () => { 19 | /* 20 | We have to make sure we connect to a MongoDB mock db before the test 21 | and close the connection at the end. 22 | */ 23 | afterAll(async () => await db.closeDatabase()); 24 | afterEach(async () => await db.resetDatabase()); 25 | 26 | beforeAll(async () => { 27 | await db.connect(); 28 | }); 29 | 30 | test('Success searching for courses', async () => { 31 | const response = await withAuthentication( 32 | request(app).get(`/learner/search`), 33 | '69023be1-368c-4a86-8eb0-9771bffa0186', 34 | ); 35 | 36 | const result = omitDeep(response.body.result, '__v', '_id', 'code'); 37 | expect(response.status).toBe(200); 38 | expect(result).toEqual(SEARCH_COURSE_EXPECTED); 39 | }); 40 | }); 41 | -------------------------------------------------------------------------------- /api/test/utils/auth.js: -------------------------------------------------------------------------------- 1 | const { DEFAULT_ID_TOKEN } = require('./constants'); 2 | 3 | module.exports.withAuthentication = (request, idToken = DEFAULT_ID_TOKEN) => 4 | request.set({ 5 | /* Can be set to a random auth ID Token since auth is mocked, 6 | which makes the authidtoken irrelevant. */ 7 | authorization: `Bearer ${idToken}`, 8 | }); 9 | -------------------------------------------------------------------------------- /api/test/utils/constants.js: -------------------------------------------------------------------------------- 1 | module.exports.NUM_FAKE_USER_ENTRIES = 50; 2 | module.exports.NUM_FAKE_COURSE_ENTRIES = 1; 3 | module.exports.NUM_FAKE_UNITS_PER_COURSE = 10; 4 | module.exports.NUM_FAKE_LESSONS_PER_UNIT = 5; 5 | module.exports.NUM_FAKE_WORDS_PER_LESSON = 15; 6 | 7 | module.exports.DEFAULT_ID_TOKEN = 'ba32cb26-2020-4fbc-b77d-34ea6b0790a6'; // matches the authID of the first user in api/test/db-data/users.json 8 | module.exports.DEFAULT_AUTH_ID = this.DEFAULT_ID_TOKEN; // matches the authID of the first user in api/test/db-data/users.json 9 | -------------------------------------------------------------------------------- /api/test/utils/db.js: -------------------------------------------------------------------------------- 1 | const mongoose = require('mongoose'); 2 | const { MongoMemoryReplSet } = require('mongodb-memory-server'); 3 | const { models } = require('../../src/models'); 4 | const userData = require('../db-data/users.json'); 5 | const courseData = require('../db-data/courses.json'); 6 | const unitData = require('../db-data/units.json'); 7 | const lessonData = require('../db-data/lessons.json'); 8 | 9 | let users = null; 10 | let courses = null; 11 | let units = null; 12 | let lessons = null; 13 | 14 | const replSet = new MongoMemoryReplSet({ 15 | replSet: { storageEngine: 'wiredTiger' }, 16 | }); 17 | 18 | /** 19 | * Connect to the in-memory database. 20 | * Should only be called once per suite 21 | */ 22 | module.exports.connect = async () => { 23 | if (replSet._state !== 'running') { 24 | await replSet.start(); 25 | } 26 | await replSet.waitUntilRunning(); 27 | const uri = replSet.getUri(); 28 | 29 | const mongooseOpts = { 30 | useNewUrlParser: true, 31 | useUnifiedTopology: true, 32 | }; 33 | 34 | await mongoose.connect(uri, mongooseOpts); 35 | await this.resetDatabase(); 36 | }; 37 | 38 | /** 39 | * Drop database, close the connection and stop mongod. 40 | */ 41 | module.exports.closeDatabase = async () => { 42 | await this.clearDatabase(); 43 | await mongoose.connection.dropDatabase(); 44 | await mongoose.connection.close(); 45 | }; 46 | 47 | /** 48 | * Resets the database to it's original state with mock data. 49 | */ 50 | module.exports.resetDatabase = async () => { 51 | await this.clearDatabase(); 52 | constructStaticData(); 53 | await saveMany(users); 54 | await saveMany(courses); 55 | await saveMany(units); 56 | await saveMany(lessons); 57 | }; 58 | 59 | const saveMany = async (modelList) => { 60 | for (let i = 0; i < modelList.length; i++) { 61 | const model = modelList[i]; 62 | model.isNew = true; 63 | await model.save(); 64 | } 65 | }; 66 | 67 | const constructStaticData = () => { 68 | if (!users) { 69 | users = constructAll(userData, models.User); 70 | } 71 | if (!courses) { 72 | courses = constructAll(courseData, models.Course); 73 | } 74 | 75 | users = constructAll(userData, models.User); 76 | courses = constructAll(courseData, models.Course); 77 | units = constructAll(unitData, models.Unit); 78 | lessons = constructAll(lessonData, models.Lesson); 79 | }; 80 | 81 | const constructAll = (data, constructor) => 82 | data.map((item) => new constructor(item)); 83 | 84 | /** 85 | * Remove all the data for all db collections. 86 | */ 87 | module.exports.clearDatabase = async () => { 88 | const { collections } = mongoose.connection; 89 | for (const key in collections) { 90 | const collection = collections[key]; 91 | await collection.deleteMany(); 92 | } 93 | }; 94 | -------------------------------------------------------------------------------- /api/webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | const nodeExternals = require('webpack-node-externals'); 4 | 5 | module.exports = { 6 | mode: 'production', 7 | devtool: 'source-map', 8 | entry: { 9 | server: './index.js', 10 | }, 11 | output: { 12 | path: path.join(__dirname, 'build'), 13 | filename: 'index.js', 14 | }, 15 | target: 'node', 16 | node: { 17 | __dirname: false, 18 | __filename: false, 19 | }, 20 | externals: [nodeExternals()], 21 | module: { 22 | rules: [ 23 | { 24 | exclude: /node_modules/, 25 | }, 26 | ], 27 | }, 28 | }; 29 | -------------------------------------------------------------------------------- /client/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | browser: true, 4 | es6: true, 5 | jest: true, 6 | }, 7 | parser: 'babel-eslint', 8 | extends: ['plugin:react/recommended', 'airbnb'], 9 | globals: { 10 | Atomics: 'readonly', 11 | SharedArrayBuffer: 'readonly', 12 | }, 13 | parserOptions: { 14 | ecmaFeatures: { 15 | jsx: true, 16 | }, 17 | ecmaVersion: 2018, 18 | sourceType: 'module', 19 | }, 20 | plugins: ['react'], 21 | rules: { 22 | semi: [2, 'never'], 23 | 'react/jsx-filename-extension': 'off', 24 | 'react/jsx-one-expression-per-line': 'off', 25 | 'max-len': 'off', 26 | 'react/prop-types': [1], 27 | 'global-require': 'off', 28 | 'no-console': 'off', 29 | 'no-underscore-dangle': 'off', 30 | 'react/jsx-props-no-spreading': 'off', 31 | 'react/destructuring-assignment': 'off', 32 | 'react/no-children-prop': 'off', 33 | 'import/no-extraneous-dependencies': [ 34 | 'error', 35 | { 36 | devDependencies: true, 37 | }, 38 | ], 39 | }, 40 | settings: { 41 | 'import/resolver': { 42 | 'babel-module': {}, 43 | }, 44 | }, 45 | } 46 | -------------------------------------------------------------------------------- /client/.expo/packager-info.json: -------------------------------------------------------------------------------- 1 | { 2 | "expoServerPort": null, 3 | "packagerPort": 19000, 4 | "expoServerNgrokUrl": "https://pm-pr6.ashayp22.client.exp.direct", 5 | "packagerNgrokUrl": "https://pm-pr6.ashayp22.client.exp.direct", 6 | "ngrokPid": 10360, 7 | "packagerPid": null, 8 | "webpackServerPort": null 9 | } 10 | -------------------------------------------------------------------------------- /client/.expo/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "hostType": "tunnel", 3 | "lanType": "ip", 4 | "dev": true, 5 | "minify": false, 6 | "urlRandomness": "pm-pr6", 7 | "https": false, 8 | "scheme": null, 9 | "devClient": false 10 | } 11 | -------------------------------------------------------------------------------- /client/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | .env 3 | 4 | # dependencies 5 | /node_modules 6 | /.pnp 7 | .pnp.js 8 | 9 | # testing 10 | /coverage 11 | .expo/ 12 | 13 | # production 14 | /build 15 | 16 | # misc 17 | .DS_Store 18 | .env.local 19 | .env.development.local 20 | .env.test.local 21 | .env.production.local 22 | 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | 27 | 28 | # @generated expo-cli sync-b25d41054229aa64f1468014f243adfae8268af2 29 | # The following patterns were generated by expo-cli 30 | 31 | # OSX 32 | # 33 | .DS_Store 34 | 35 | # Xcode 36 | # 37 | build/ 38 | *.pbxuser 39 | !default.pbxuser 40 | *.mode1v3 41 | !default.mode1v3 42 | *.mode2v3 43 | !default.mode2v3 44 | *.perspectivev3 45 | !default.perspectivev3 46 | xcuserdata 47 | *.xccheckout 48 | *.moved-aside 49 | DerivedData 50 | *.hmap 51 | *.ipa 52 | *.xcuserstate 53 | project.xcworkspace 54 | 55 | # Android/IntelliJ 56 | # 57 | build/ 58 | .idea 59 | .gradle 60 | local.properties 61 | *.iml 62 | *.hprof 63 | .cxx/ 64 | 65 | # node.js 66 | # 67 | node_modules/ 68 | npm-debug.log 69 | yarn-error.log 70 | 71 | # BUCK 72 | buck-out/ 73 | \.buckd/ 74 | *.keystore 75 | !debug.keystore 76 | 77 | # Bundle artifacts 78 | *.jsbundle 79 | 80 | # CocoaPods 81 | /ios/Pods/ 82 | 83 | # Expo 84 | .expo/ 85 | web-build/ 86 | dist/ 87 | eas.json 88 | 89 | # @end expo-cli -------------------------------------------------------------------------------- /client/.prettierignore: -------------------------------------------------------------------------------- 1 | package.json 2 | package-lock.json 3 | babel.config.js 4 | README.md -------------------------------------------------------------------------------- /client/.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "tabWidth": 4, 4 | "trailingComma": "all" 5 | } -------------------------------------------------------------------------------- /client/App.js: -------------------------------------------------------------------------------- 1 | import App from './src/index' 2 | 3 | export default App 4 | -------------------------------------------------------------------------------- /client/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Wataru Maeda 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /client/README.md: -------------------------------------------------------------------------------- 1 | # 7000 Languages Client 2 | 3 | This folder contains the frontend client of the application. 4 | 5 | ## Getting Started 6 | 7 | First, make sure you have [Node.js and npm](https://docs.npmjs.com/downloading-and-installing-node-js-and-npm) installed. 8 | 9 | Also, make sure you have [yarn](https://classic.yarnpkg.com/lang/en/docs/install/#mac-stable) installed. 10 | 11 | Next, install the [expo-cli](https://docs.expo.dev) with the following command: 12 | ```bash 13 | yarn global add expo-cli 14 | ``` 15 | 16 | To install all of the required node packages, run: 17 | 18 | ```bash 19 | yarn install 20 | ``` 21 | 22 | Then, set the necessary environment variables by creating a `.env` file in the `client` folder. Populate the file with the following: 23 | ``` 24 | API_PORT=3000 25 | IOS_CLIENT_ID= 26 | ANDROID_CLIENT_ID= 27 | ``` 28 | 29 | You can obtain `IOS_CLIENT_ID` and `ANDROID_CLIENT_ID` by creating [Google OAuth credentials](https://console.cloud.google.com/apis/credentials?pli=1). 30 | 31 | Finally, run: 32 | 33 | ```bash 34 | expo start 35 | ``` 36 | 37 | This will start Metro Bundler, which is an HTTP server that compiles the JavaScript code of our app using Babel and serves it to the Expo app. It also pops up Expo Dev Tools, a graphical interface for Expo CLI. 38 | 39 | You will have the open to run the app on a connected device via USB, [iOS simulator](https://docs.expo.dev/workflow/ios-simulator/), [Android emulator](https://docs.expo.dev/workflow/android-studio-emulator/), or on your own phone through the [Expo Go](https://expo.dev/client) app. I recommend using the Expo Go for day-to-day dev work, and your own device or simulator for checking features. 40 | 41 | ## Run 42 | 43 | To set up, first `cd` into this directory. Then, 44 | 45 | ```bash 46 | yarn start 47 | ``` 48 | 49 | Before commiting and pushing code to the remote repository, run the command below for linting and formatting: 50 | 51 | ```bash 52 | yarn style 53 | ``` 54 | ## Technologies 55 | 56 | Built with [React Native](https://reactnative.dev/). 57 | 58 | ### Code Style 59 | 60 | Use [ESLint](https://eslint.org) with [Prettier](https://prettier.io/) and the [Airbnb Javascript Style Guide](https://github.com/airbnb/javascript). 61 | 62 | To run, `yarn lint` and `yarn format` in the directory. 63 | 64 | ### Type Checking 65 | 66 | Use [Flow](https://flow.org/) for type checking. 67 | 68 | To run, `yarn flow` in the directory. 69 | -------------------------------------------------------------------------------- /client/app.config.js: -------------------------------------------------------------------------------- 1 | let extraConfig = { 2 | eas: { 3 | projectId: 'a584bef5-9bb9-4c77-91dc-679921f7a6bf', 4 | }, 5 | apiDevelopmentPort: 3000, 6 | } 7 | 8 | if (process.env.EAS_BUILD_PLATFORM === 'ios') { 9 | extraConfig.apiURL = process.env.API_URL 10 | extraConfig.iosClientId = process.env.IOS_CLIENT_ID 11 | extraConfig.platform = process.env.EAS_BUILD_PLATFORM 12 | } else if (process.env.EAS_BUILD_PLATFORM === 'android') { 13 | extraConfig.apiURL = process.env.API_URL 14 | extraConfig.androidClientId = process.env.ANDROID_CLIENT_ID 15 | extraConfig.platform = process.env.EAS_BUILD_PLATFORM 16 | } else { 17 | extraConfig.expoClientId = 18 | '1534417123-rirmc8ql9i0jqrqchojsl2plf5c102j6.apps.googleusercontent.com' 19 | extraConfig.expoClientSecret = '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' 20 | extraConfig.platform = 'expo' 21 | } 22 | 23 | export default ({ config }) => { 24 | return { 25 | ...config, 26 | extra: extraConfig, 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /client/app.json: -------------------------------------------------------------------------------- 1 | { 2 | "expo": { 3 | "name": "7000 Languages", 4 | "owner": "7000languages", 5 | "slug": "7000-languages", 6 | "scheme": "com.h4i-7000-languages.7000-languages", 7 | "privacy": "public", 8 | "platforms": ["ios", "android"], 9 | "version": "1.0.1", 10 | "orientation": "portrait", 11 | "icon": "./assets/images/app-icon.png", 12 | "splash": { 13 | "image": "./assets/images/splash.png", 14 | "resizeMode": "cover", 15 | "backgroundColor": "#fff" 16 | }, 17 | "updates": { 18 | "fallbackToCacheTimeout": 0 19 | }, 20 | "assetBundlePatterns": ["**/*"], 21 | "ios": { 22 | "supportsTablet": true, 23 | "bundleIdentifier": "com.7000languages.app", 24 | "buildNumber": "0.0.2" 25 | }, 26 | "android": { 27 | "adaptiveIcon": { 28 | "foregroundImage": "./assets/images/app-icon.png", 29 | "backgroundColor": "#DF4E47" 30 | }, 31 | "package": "com.languages7000.app", 32 | "versionCode": 2 33 | }, 34 | "packagerOpts": { 35 | "config": "metro.config.js", 36 | "sourceExts": ["js", "jsx", "svg", "ts", "tsx"] 37 | }, 38 | "androidStatusBar": { 39 | "barStyle": "dark-content" 40 | }, 41 | "plugins": [ 42 | [ 43 | "expo-image-picker", 44 | { 45 | "photosPermission": "The app accesses your photos to let you capture images related to your course." 46 | } 47 | ], 48 | [ 49 | "expo-build-properties", 50 | { 51 | "android": {}, 52 | "ios": { 53 | "deploymentTarget": "13.0" 54 | } 55 | } 56 | ] 57 | ] 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /client/assets/fonts/GT-Haptik-Bold-2.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hack4impact-uiuc/7000-languages/8153ca142d90f7bbec01d009d09f62ac2a317b8f/client/assets/fonts/GT-Haptik-Bold-2.ttf -------------------------------------------------------------------------------- /client/assets/fonts/GT-Haptik-Bold-Oblique-2.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hack4impact-uiuc/7000-languages/8153ca142d90f7bbec01d009d09f62ac2a317b8f/client/assets/fonts/GT-Haptik-Bold-Oblique-2.ttf -------------------------------------------------------------------------------- /client/assets/fonts/GT-Haptik-Bold-Oblique.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hack4impact-uiuc/7000-languages/8153ca142d90f7bbec01d009d09f62ac2a317b8f/client/assets/fonts/GT-Haptik-Bold-Oblique.ttf -------------------------------------------------------------------------------- /client/assets/fonts/GT-Haptik-Bold-Rotalic-2.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hack4impact-uiuc/7000-languages/8153ca142d90f7bbec01d009d09f62ac2a317b8f/client/assets/fonts/GT-Haptik-Bold-Rotalic-2.ttf -------------------------------------------------------------------------------- /client/assets/fonts/GT-Haptik-Bold-Rotalic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hack4impact-uiuc/7000-languages/8153ca142d90f7bbec01d009d09f62ac2a317b8f/client/assets/fonts/GT-Haptik-Bold-Rotalic.ttf -------------------------------------------------------------------------------- /client/assets/fonts/GT-Haptik-Bold.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hack4impact-uiuc/7000-languages/8153ca142d90f7bbec01d009d09f62ac2a317b8f/client/assets/fonts/GT-Haptik-Bold.ttf -------------------------------------------------------------------------------- /client/assets/fonts/GT-Haptik-Regular-2.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hack4impact-uiuc/7000-languages/8153ca142d90f7bbec01d009d09f62ac2a317b8f/client/assets/fonts/GT-Haptik-Regular-2.ttf -------------------------------------------------------------------------------- /client/assets/fonts/GT-Haptik-Regular-Oblique-2.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hack4impact-uiuc/7000-languages/8153ca142d90f7bbec01d009d09f62ac2a317b8f/client/assets/fonts/GT-Haptik-Regular-Oblique-2.ttf -------------------------------------------------------------------------------- /client/assets/fonts/GT-Haptik-Regular-Oblique.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hack4impact-uiuc/7000-languages/8153ca142d90f7bbec01d009d09f62ac2a317b8f/client/assets/fonts/GT-Haptik-Regular-Oblique.ttf -------------------------------------------------------------------------------- /client/assets/fonts/GT-Haptik-Regular-Rotalic-2.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hack4impact-uiuc/7000-languages/8153ca142d90f7bbec01d009d09f62ac2a317b8f/client/assets/fonts/GT-Haptik-Regular-Rotalic-2.ttf -------------------------------------------------------------------------------- /client/assets/fonts/GT-Haptik-Regular-Rotalic.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hack4impact-uiuc/7000-languages/8153ca142d90f7bbec01d009d09f62ac2a317b8f/client/assets/fonts/GT-Haptik-Regular-Rotalic.ttf -------------------------------------------------------------------------------- /client/assets/fonts/GT-Haptik-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hack4impact-uiuc/7000-languages/8153ca142d90f7bbec01d009d09f62ac2a317b8f/client/assets/fonts/GT-Haptik-Regular.ttf -------------------------------------------------------------------------------- /client/assets/images/7000-logos/7000LanguagesDoorLogo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hack4impact-uiuc/7000-languages/8153ca142d90f7bbec01d009d09f62ac2a317b8f/client/assets/images/7000-logos/7000LanguagesDoorLogo.png -------------------------------------------------------------------------------- /client/assets/images/7000-logos/7000Languages_Logotype copy.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hack4impact-uiuc/7000-languages/8153ca142d90f7bbec01d009d09f62ac2a317b8f/client/assets/images/7000-logos/7000Languages_Logotype copy.png -------------------------------------------------------------------------------- /client/assets/images/7000-logos/7000Languages_Logotype.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hack4impact-uiuc/7000-languages/8153ca142d90f7bbec01d009d09f62ac2a317b8f/client/assets/images/7000-logos/7000Languages_Logotype.png -------------------------------------------------------------------------------- /client/assets/images/app-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hack4impact-uiuc/7000-languages/8153ca142d90f7bbec01d009d09f62ac2a317b8f/client/assets/images/app-icon.png -------------------------------------------------------------------------------- /client/assets/images/default-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hack4impact-uiuc/7000-languages/8153ca142d90f7bbec01d009d09f62ac2a317b8f/client/assets/images/default-icon.png -------------------------------------------------------------------------------- /client/assets/images/landing-background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hack4impact-uiuc/7000-languages/8153ca142d90f7bbec01d009d09f62ac2a317b8f/client/assets/images/landing-background.png -------------------------------------------------------------------------------- /client/assets/images/landing-logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /client/assets/images/logo-lg-black.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hack4impact-uiuc/7000-languages/8153ca142d90f7bbec01d009d09f62ac2a317b8f/client/assets/images/logo-lg-black.png -------------------------------------------------------------------------------- /client/assets/images/logo-lg-white.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hack4impact-uiuc/7000-languages/8153ca142d90f7bbec01d009d09f62ac2a317b8f/client/assets/images/logo-lg-white.png -------------------------------------------------------------------------------- /client/assets/images/logo-sm-black.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hack4impact-uiuc/7000-languages/8153ca142d90f7bbec01d009d09f62ac2a317b8f/client/assets/images/logo-sm-black.png -------------------------------------------------------------------------------- /client/assets/images/logo-sm-gray.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /client/assets/images/logo-sm-white.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hack4impact-uiuc/7000-languages/8153ca142d90f7bbec01d009d09f62ac2a317b8f/client/assets/images/logo-sm-white.png -------------------------------------------------------------------------------- /client/assets/images/splash.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/hack4impact-uiuc/7000-languages/8153ca142d90f7bbec01d009d09f62ac2a317b8f/client/assets/images/splash.png -------------------------------------------------------------------------------- /client/babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = (api) => { 2 | api.cache(true) 3 | return { 4 | presets: ['babel-preset-expo'], 5 | plugins: [ 6 | [ 7 | 'module-resolver', 8 | { 9 | alias: { 10 | components: './src/components', 11 | pages: './src/pages', 12 | theme: './src/theme', 13 | utils: './src/utils', 14 | slices: './src/redux/slices', 15 | api: './src/api/api', 16 | hooks: './src/hooks', 17 | utils: './src/utils' 18 | }, 19 | }, 20 | ], 21 | 'react-native-reanimated/plugin' 22 | ], 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /client/index.js: -------------------------------------------------------------------------------- 1 | import { registerRootComponent } from 'expo' 2 | 3 | import App from './App' 4 | 5 | // registerRootComponent calls AppRegistry.registerComponent('main', () => App); 6 | // It also ensures that whether you load the app in Expo Go or in a native build, 7 | // the environment is set up appropriately 8 | registerRootComponent(App) 9 | -------------------------------------------------------------------------------- /client/metro.config.js: -------------------------------------------------------------------------------- 1 | // Learn more https://docs.expo.io/guides/customizing-metro 2 | const { getDefaultConfig } = require('expo/metro-config') 3 | 4 | // extra config is needed to enable `react-native-svg-transformer` 5 | module.exports = (async () => { 6 | const { 7 | resolver: { sourceExts, assetExts }, 8 | } = await getDefaultConfig(__dirname) 9 | return { 10 | transformer: { 11 | babelTransformerPath: require.resolve('react-native-svg-transformer'), 12 | assetPlugins: ['expo-asset/tools/hashAssetFiles'], 13 | }, 14 | resolver: { 15 | assetExts: assetExts.filter((ext) => ext !== 'svg'), 16 | sourceExts: [...sourceExts, 'svg'], 17 | }, 18 | } 19 | })() 20 | -------------------------------------------------------------------------------- /client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "7000-languages-client", 3 | "private": true, 4 | "scripts": { 5 | "start": "expo start --dev-client", 6 | "android": "npx expo run:android", 7 | "ios": "npx expo run:ios", 8 | "eject": "expo eject", 9 | "format": "prettier --write \"./**/*.{js,jsx,json,md}\"", 10 | "format:check": "prettier -- \"./**/*.{js,jsx,json,md}\"", 11 | "lint": "node_modules/.bin/eslint src/ --fix src/ --fix", 12 | "test": "node_modules/.bin/jest --passWithNoTests", 13 | "style": "yarn format && yarn lint" 14 | }, 15 | "dependencies": { 16 | "@expo/vector-icons": "^13.0.0", 17 | "@react-native-async-storage/async-storage": "~1.17.3", 18 | "@react-native-community/masked-view": "0.1.10", 19 | "@react-navigation/bottom-tabs": "^6.4.0", 20 | "@react-navigation/drawer": "^6.5.0", 21 | "@react-navigation/native": "^6.0.13", 22 | "@react-navigation/stack": "^6.3.4", 23 | "@reduxjs/toolkit": "^1.7.2", 24 | "axios": "^0.26.0", 25 | "dotenv": "^16.0.0", 26 | "eslint-import-resolver-babel-module": "^5.3.1", 27 | "expo": "^47.0.3", 28 | "expo-asset": "~8.7.0", 29 | "expo-auth-session": "~3.8.0", 30 | "expo-av": "~13.0.1", 31 | "expo-build-properties": "^0.4.1", 32 | "expo-cli": "^6.0.8", 33 | "expo-constants": "~14.0.2", 34 | "expo-file-system": "~15.1.1", 35 | "expo-font": "~11.0.1", 36 | "expo-image-picker": "~14.0.1", 37 | "expo-localization": "~14.0.0", 38 | "expo-modules-core": "^1.1.0", 39 | "expo-random": "~13.0.0", 40 | "expo-secure-store": "~12.0.0", 41 | "expo-splash-screen": "~0.17.5", 42 | "expo-status-bar": "~1.4.2", 43 | "expo-updates": "^0.15.6", 44 | "expo-web-browser": "~12.0.0", 45 | "global": "^4.4.0", 46 | "i18n-js": "^4.1.1", 47 | "lodash": "^4.17.21", 48 | "moment": "^2.24.0", 49 | "native-base": "^3.3.7", 50 | "prop-types": "^15.7.2", 51 | "react": "18.1.0", 52 | "react-dom": "18.1.0", 53 | "react-native": "0.70.5", 54 | "react-native-dismiss-keyboard": "^1.0.0", 55 | "react-native-drag-sort": "^2.4.4", 56 | "react-native-gesture-handler": "~2.8.0", 57 | "react-native-modal": "^13.0.1", 58 | "react-native-reanimated": "~2.12.0", 59 | "react-native-safe-area-context": "4.4.1", 60 | "react-native-screens": "~3.18.0", 61 | "react-native-svg": "13.4.0", 62 | "react-native-svg-transformer": "^1.0.0", 63 | "react-native-vector-icons": "^6.6.0", 64 | "react-native-web": "~0.18.9", 65 | "react-redux": "^8.0.5", 66 | "redux": "^4.0.4", 67 | "redux-logger": "^3.0.6" 68 | }, 69 | "devDependencies": { 70 | "@babel/core": "^7.12.9", 71 | "babel-eslint": "^10.1.0", 72 | "babel-plugin-module-resolver": "^4.0.0", 73 | "babel-preset-expo": "9.0.2", 74 | "braces": ">=2.3.1", 75 | "eslint": "^6.8.0", 76 | "eslint-config-airbnb": "^18.1.0", 77 | "eslint-config-prettier": "^6.11.0", 78 | "eslint-plugin-import": "^2.20.2", 79 | "eslint-plugin-jsx-a11y": "^6.2.3", 80 | "eslint-plugin-prettier": "^3.1.3", 81 | "eslint-plugin-react": "^7.19.0", 82 | "eslint-plugin-react-hooks": "^2.5.1", 83 | "jest-expo": "^44.0.0", 84 | "lint-staged": "^10.2.0", 85 | "prettier": "^2.0.5", 86 | "pretty-quick": "^3.1.3", 87 | "react-test-renderer": "^16.13.1" 88 | }, 89 | "prettier": { 90 | "trailingComma": "all", 91 | "tabWidth": 2, 92 | "semi": false, 93 | "singleQuote": true, 94 | "bracketSpacing": true 95 | }, 96 | "jest": { 97 | "preset": "jest-expo", 98 | "transformIgnorePatterns": [ 99 | "node_modules/(?!(jest-)?react-native|react-clone-referenced-element|@react-native-community|expo(nent)?|@expo(nent)?/.*|react-navigation|@react-navigation/.*|@unimodules/.*|unimodules|sentry-expo|native-base|@sentry/.*)" 100 | ] 101 | }, 102 | "lint-staged": { 103 | "*.{js,jsx}": [ 104 | "pretty-quick --staged", 105 | "yarn lint", 106 | "yarn test" 107 | ] 108 | }, 109 | "version": "1.0.0" 110 | } 111 | -------------------------------------------------------------------------------- /client/src/App.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react' 2 | import { View } from 'react-native' 3 | import { Provider } from 'react-redux' 4 | import 'utils/ignore' 5 | 6 | // assets 7 | import { imageAssets } from 'theme/images' 8 | import { fontAssets } from 'theme/fonts' 9 | import { NativeBaseProvider } from 'native-base' 10 | import { nativebase } from 'theme' 11 | import store from './redux/store' 12 | import AppContent from './AppContent' 13 | 14 | const App = () => { 15 | const [didLoad, setDidLoad] = useState(false) 16 | 17 | // assets preloading 18 | const handleLoadAssets = async () => { 19 | await Promise.all([...imageAssets, ...fontAssets]) 20 | setDidLoad(true) 21 | } 22 | 23 | useEffect(() => { 24 | handleLoadAssets() 25 | }, []) 26 | 27 | return didLoad ? ( 28 | 29 | 30 | 31 | 32 | 33 | ) : ( 34 | 35 | 36 | 37 | ) 38 | } 39 | 40 | export default App 41 | -------------------------------------------------------------------------------- /client/src/AppContent.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import 'utils/ignore' 3 | import LoadingSpinner from 'components/LoadingSpinner' 4 | 5 | // assets 6 | import { useSelector } from 'react-redux' 7 | import Navigator from './navigator' 8 | 9 | /** 10 | * Handles the logic when the app needs to display loading indicator 11 | */ 12 | const AppContent = () => { 13 | const { isLoading } = useSelector((state) => state.app) 14 | 15 | if (isLoading) { 16 | return ( 17 | <> 18 | 19 | 20 | 21 | ) 22 | } 23 | 24 | return 25 | } 26 | 27 | export default AppContent 28 | -------------------------------------------------------------------------------- /client/src/api/axios-config.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios' 2 | import Constants from 'expo-constants' 3 | import { logout } from 'slices/auth.slice' 4 | import store from '../redux/store' 5 | import { 6 | loadUserIDToken, 7 | refreshIDToken, 8 | removeUserIDToken, 9 | removeUserRefreshToken, 10 | } from '../utils/auth' 11 | 12 | const API_URL = Constants.manifest.extra.apiURL 13 | const API_PORT = Constants.manifest.extra.apiDevelopmentPort 14 | 15 | // Source: https://stackoverflow.com/questions/47417766/calling-locally-hosted-server-from-expo-app/70964774 16 | export const BASE_URL = API_URL 17 | || `http://${Constants.manifest.debuggerHost.split(':').shift()}:${API_PORT}` 18 | const LOGOUT_MESSAGE = 'Oops, it looks like your login has expired, please try again!' 19 | 20 | // The configured axios instance to be exported 21 | const instance = axios.create({ 22 | baseURL: BASE_URL, 23 | validateStatus: () => true, 24 | }) 25 | 26 | /** 27 | * Appends an authorization header to the request 28 | * @param {AxiosRequestConfig} config 29 | * @returns {Promise>} updated config 30 | */ 31 | const addAuthHeader = async (config) => loadUserIDToken().then((idToken) => { 32 | const updateConfig = config 33 | if (idToken) { 34 | updateConfig.headers.Authorization = `Bearer ${idToken}` 35 | } 36 | return Promise.resolve(updateConfig) 37 | }) 38 | /** 39 | * 40 | * @param {AxiosResponse} response 41 | * @returns {Promise>} retried response if auth was expired or original response otherwise 42 | */ 43 | const authRefresh = async (response) => { 44 | const status = response ? response.status : null 45 | 46 | if (status === 401) { 47 | return refreshIDToken().then((newToken) => { 48 | if (newToken && !response.config.__isRetryRequest) { 49 | response.config.headers.Authorization = `Bearer ${newToken}` 50 | response.config.baseURL = undefined 51 | response.config.__isRetryRequest = true 52 | return instance.request(response.config) 53 | } 54 | // Unable to retrieve new idToken -> Prompt log in again 55 | removeUserIDToken() 56 | removeUserRefreshToken() 57 | store.dispatch(logout()) 58 | if (typeof response.data === 'object') { 59 | response.data.message = LOGOUT_MESSAGE 60 | } 61 | return response 62 | }) 63 | } 64 | 65 | return response 66 | } 67 | 68 | instance.interceptors.request.use(addAuthHeader) 69 | instance.interceptors.response.use(authRefresh) 70 | 71 | export default instance 72 | -------------------------------------------------------------------------------- /client/src/components/Drawer/Drawer.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react' 2 | import PropTypes from 'prop-types' 3 | import { Text } from 'native-base' 4 | import { 5 | KeyboardAvoidingView, 6 | ScrollView, 7 | StyleSheet, 8 | View, 9 | Platform, 10 | } from 'react-native' 11 | import StyledButton from 'components/StyledButton' 12 | import { colors } from 'theme' 13 | import FontIcon from 'react-native-vector-icons/Feather' 14 | 15 | const styles = StyleSheet.create({ 16 | root: { 17 | flex: 1, 18 | alignItems: 'center', 19 | justifyContent: 'center', 20 | backgroundColor: colors.white.dark, 21 | }, 22 | form: { 23 | marginTop: 10, 24 | width: '95%', 25 | height: '5%', 26 | }, 27 | header: { 28 | flexDirection: 'row', 29 | flexWrap: 'wrap', 30 | justifyContent: 'space-between', 31 | marginBottom: 20, 32 | }, 33 | body: { 34 | marginTop: 15, 35 | width: '95%', 36 | flex: 1, 37 | height: '80%', 38 | }, 39 | }) 40 | 41 | const Drawer = ({ 42 | titleText, 43 | closeCallback, 44 | successCallback, 45 | areAllFieldsFilled, 46 | successText, 47 | body, 48 | }) => { 49 | const [isDisabled, setDisabled] = useState(!areAllFieldsFilled) // used to disable success button 50 | // sets the initial state of areAllFieldsFilled state to the areAllFieldsFilled param 51 | useEffect(() => setDisabled(!areAllFieldsFilled), [areAllFieldsFilled]) 52 | // always listening to when isDisabled is changed 53 | 54 | const onPress = () => { 55 | if (!isDisabled) { 56 | setDisabled(true) 57 | successCallback() 58 | } 59 | } 60 | return ( 61 | 66 | 67 | 68 | 69 | {titleText} 70 | 71 | 72 | 73 | 74 | 78 | {body} 79 | 80 | 87 | 88 | ) 89 | } 90 | // Button object fields 91 | Drawer.propTypes = { 92 | titleText: PropTypes.string, 93 | successText: PropTypes.string, 94 | closeCallback: PropTypes.func, 95 | successCallback: PropTypes.func, 96 | areAllFieldsFilled: PropTypes.bool, 97 | body: PropTypes.element, 98 | } 99 | 100 | Drawer.defaultProps = { 101 | titleText: '', 102 | successText: '', 103 | closeCallback: () => null, 104 | successCallback: () => null, 105 | areAllFieldsFilled: false, 106 | body: null, 107 | } 108 | 109 | export default Drawer 110 | -------------------------------------------------------------------------------- /client/src/components/Drawer/index.js: -------------------------------------------------------------------------------- 1 | import Drawer from './Drawer' 2 | 3 | export default Drawer 4 | -------------------------------------------------------------------------------- /client/src/components/DrawerLogoutButton/DrawerLogoutButton.js: -------------------------------------------------------------------------------- 1 | import { Entypo } from '@expo/vector-icons' 2 | import React from 'react' 3 | import { Pressable, View } from 'react-native' 4 | import { colors } from 'theme' 5 | import { Text } from 'native-base' 6 | import { logout } from 'slices/auth.slice' 7 | import { clear } from 'slices/language.slice' 8 | import { useDispatch } from 'react-redux' 9 | import i18n from 'utils/i18n' 10 | import { removeUserIDToken, removeUserRefreshToken } from 'utils/auth' 11 | 12 | const DrawerLogoutButton = () => { 13 | const dispatch = useDispatch() 14 | const logoutUser = async () => { 15 | removeUserIDToken() 16 | removeUserRefreshToken() 17 | dispatch(logout()) 18 | dispatch(clear()) 19 | } 20 | 21 | return ( 22 | 29 | 30 | 36 | 37 | 47 | {i18n.t('actions.logOut')} 48 | 49 | 50 | 51 | 52 | ) 53 | } 54 | 55 | export default DrawerLogoutButton 56 | -------------------------------------------------------------------------------- /client/src/components/DrawerLogoutButton/index.js: -------------------------------------------------------------------------------- 1 | import DrawerLogoutButton from './DrawerLogoutButton' 2 | 3 | export default DrawerLogoutButton 4 | -------------------------------------------------------------------------------- /client/src/components/HomeBaseCase/HomeBaseCase.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import StyledButton from 'components/StyledButton' 3 | import { colors } from 'theme' 4 | import { Text } from 'native-base' 5 | import { StyleSheet, View, StatusBar } from 'react-native' 6 | import PropTypes from 'prop-types' 7 | import { AntDesign } from '@expo/vector-icons' 8 | import i18n from 'utils/i18n' 9 | 10 | const styles = StyleSheet.create({ 11 | root: { 12 | flex: 1, 13 | flexDirection: 'column', 14 | alignItems: 'center', 15 | justifyContent: 'center', 16 | backgroundColor: colors.white.dark, 17 | }, 18 | }) 19 | const HomeBaseCase = ({ navigation }) => ( 20 | 21 | 22 | 29 | {i18n.t('dict.welcome')} 30 | 31 | 37 | {i18n.t('dialogue.notLearnerPrompt')} 38 | 39 | 43 | } 44 | variant="primary_short" 45 | onPress={() => navigation.navigate('Search', { screen: 'LearnerSearch' })} 46 | /> 47 | 53 | 62 | 63 | 70 | {i18n.t('dialogue.ourMission')} 71 | 72 | {i18n.t('dialogue.supportRevitalization')} 73 | 74 | 75 | { 84 | navigation.navigate('Apply', { from: 'HomeBaseCase' }) 85 | }} 86 | > 87 | {i18n.t('actions.becomeContributor')} 88 | 89 | 90 | ) 91 | 92 | // Home Base Case Object Fields 93 | HomeBaseCase.propTypes = { 94 | navigation: PropTypes.shape({ 95 | navigate: PropTypes.func, 96 | }), 97 | } 98 | 99 | HomeBaseCase.defaultProps = { 100 | navigation: { navigate: () => null }, 101 | } 102 | 103 | export default HomeBaseCase 104 | -------------------------------------------------------------------------------- /client/src/components/HomeBaseCase/index.js: -------------------------------------------------------------------------------- 1 | import HomeBaseCase from './HomeBaseCase' 2 | 3 | export default HomeBaseCase 4 | -------------------------------------------------------------------------------- /client/src/components/Indicator/Indicator.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import { Text, View } from 'native-base' 4 | import { FontAwesome } from '@expo/vector-icons' 5 | import { colors } from 'theme' 6 | import { INDICATOR_TYPES } from 'utils/constants' 7 | import i18n from 'utils/i18n' 8 | 9 | const Indicator = ({ indicatorType }) => { 10 | let indicatorBody = null 11 | 12 | switch (indicatorType) { 13 | case INDICATOR_TYPES.COMPLETE: 14 | indicatorBody = ( 15 | <> 16 | 21 | 28 | {i18n.t('dict.completed')} 29 | 30 | 31 | ) 32 | break 33 | case INDICATOR_TYPES.INCOMPLETE: 34 | indicatorBody = ( 35 | <> 36 | 37 | 44 | {i18n.t('dict.inProgress')} 45 | 46 | 47 | ) 48 | break 49 | case INDICATOR_TYPES.NONE: 50 | return null 51 | default: 52 | return null 53 | } 54 | 55 | return ( 56 | 64 | {indicatorBody} 65 | 66 | ) 67 | } 68 | 69 | // Button object fields 70 | Indicator.propTypes = { 71 | indicatorType: PropTypes.number, 72 | } 73 | 74 | Indicator.defaultProps = { 75 | indicatorType: INDICATOR_TYPES.COMPLETE, 76 | } 77 | 78 | export default Indicator 79 | -------------------------------------------------------------------------------- /client/src/components/Indicator/index.js: -------------------------------------------------------------------------------- 1 | import Indicator from './Indicator' 2 | 3 | export default Indicator 4 | -------------------------------------------------------------------------------- /client/src/components/LanguageHome/index.js: -------------------------------------------------------------------------------- 1 | import LanguageHome from './LanguageHome' 2 | 3 | export default LanguageHome 4 | -------------------------------------------------------------------------------- /client/src/components/LearnerHome/index.js: -------------------------------------------------------------------------------- 1 | import LearnerHome from './LearnerHome' 2 | 3 | export default LearnerHome 4 | -------------------------------------------------------------------------------- /client/src/components/LoadingSpinner/LoadingSpinner.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { View, ActivityIndicator, StyleSheet } from 'react-native' 3 | import { useSelector } from 'react-redux' 4 | 5 | const styles = StyleSheet.create({ 6 | container: { 7 | position: 'absolute', 8 | top: 0, 9 | left: 0, 10 | right: 0, 11 | bottom: 0, 12 | justifyContent: 'center', 13 | alignItems: 'center', 14 | }, 15 | }) 16 | 17 | const LoadingSpinner = () => { 18 | const { isLoading } = useSelector((state) => state.app) 19 | 20 | if (isLoading) { 21 | return ( 22 | 23 | 24 | 25 | ) 26 | } 27 | 28 | return null 29 | } 30 | 31 | export default LoadingSpinner 32 | -------------------------------------------------------------------------------- /client/src/components/LoadingSpinner/index.js: -------------------------------------------------------------------------------- 1 | import LoadingSpinner from './LoadingSpinner' 2 | 3 | export default LoadingSpinner 4 | -------------------------------------------------------------------------------- /client/src/components/ManageView/index.js: -------------------------------------------------------------------------------- 1 | import ManageView from './ManageView' 2 | 3 | export default ManageView 4 | -------------------------------------------------------------------------------- /client/src/components/NumberBox/NumberBox.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Box } from 'native-base' 3 | import { colors } from 'theme' 4 | import PropTypes from 'prop-types' 5 | 6 | const NumberBox = ({ number, learner, noMargin }) => ( 7 | 20 | {number} 21 | 22 | ) 23 | 24 | NumberBox.propTypes = { 25 | number: PropTypes.number, 26 | learner: PropTypes.bool, 27 | noMargin: PropTypes.bool, 28 | } 29 | 30 | NumberBox.defaultProps = { 31 | number: 1, 32 | learner: false, 33 | noMargin: false, 34 | } 35 | 36 | export default NumberBox 37 | -------------------------------------------------------------------------------- /client/src/components/NumberBox/index.js: -------------------------------------------------------------------------------- 1 | import NumberBox from './NumberBox' 2 | 3 | export default NumberBox 4 | -------------------------------------------------------------------------------- /client/src/components/RecordAudioView/index.js: -------------------------------------------------------------------------------- 1 | import RecordAudioView from './RecordAudioView' 2 | 3 | export default RecordAudioView 4 | -------------------------------------------------------------------------------- /client/src/components/RequiredField/RequiredField.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import { Text } from 'native-base' 4 | import { colors } from 'theme' 5 | import i18n from 'utils/i18n' 6 | 7 | const RequiredField = ({ title, fontSize }) => ( 8 | 9 | {title} 10 | 11 | {' *'} 12 | 13 | 14 | ) 15 | RequiredField.propTypes = { 16 | title: PropTypes.string, 17 | fontSize: PropTypes.string, 18 | } 19 | RequiredField.defaultProps = { 20 | title: `${i18n.t('dialogue.requiredField')}`, 21 | fontSize: 'xl', 22 | } 23 | 24 | export default RequiredField 25 | -------------------------------------------------------------------------------- /client/src/components/RequiredField/index.js: -------------------------------------------------------------------------------- 1 | import RequiredField from './RequiredField' 2 | 3 | export default RequiredField 4 | -------------------------------------------------------------------------------- /client/src/components/SearchResultCard/index.js: -------------------------------------------------------------------------------- 1 | import SearchResultCard from './SearchResultCard' 2 | 3 | export default SearchResultCard 4 | -------------------------------------------------------------------------------- /client/src/components/StyledButton/StyledButton.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import { Button } from 'native-base' 4 | 5 | const StyledButton = ({ 6 | title, 7 | variant, 8 | onPress, 9 | leftIcon, 10 | rightIcon, 11 | fontSize, 12 | shadow, 13 | style, 14 | isDisabled, 15 | }) => ( 16 | 28 | ) 29 | // Button object fields 30 | StyledButton.propTypes = { 31 | title: PropTypes.string, 32 | variant: PropTypes.string, 33 | onPress: PropTypes.func, 34 | leftIcon: PropTypes.element, 35 | rightIcon: PropTypes.element, 36 | fontSize: PropTypes.oneOfType([PropTypes.string, PropTypes.number]), 37 | shadow: PropTypes.bool, 38 | style: PropTypes.objectOf( 39 | PropTypes.oneOfType([PropTypes.string, PropTypes.number]), 40 | ), 41 | isDisabled: PropTypes.bool, 42 | } 43 | 44 | StyledButton.defaultProps = { 45 | title: 'Button', 46 | variant: 'primary', 47 | leftIcon: null, 48 | rightIcon: null, 49 | onPress: () => {}, 50 | fontSize: 'lg', 51 | shadow: false, 52 | style: {}, 53 | isDisabled: false, 54 | } 55 | 56 | export default StyledButton 57 | -------------------------------------------------------------------------------- /client/src/components/StyledButton/index.js: -------------------------------------------------------------------------------- 1 | import StyledButton from './StyledButton' 2 | 3 | export default StyledButton 4 | -------------------------------------------------------------------------------- /client/src/components/StyledCard/index.js: -------------------------------------------------------------------------------- 1 | import StyledCard from './StyledCard' 2 | 3 | export default StyledCard 4 | -------------------------------------------------------------------------------- /client/src/components/VocabBox/VocabBox.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import { Box, Text, Image } from 'native-base' 4 | import { StyleSheet, View } from 'react-native' 5 | import { FontAwesome } from '@expo/vector-icons' 6 | import { colors } from 'theme' 7 | import StyledButton from 'components/StyledButton' 8 | import i18n from 'utils/i18n' 9 | 10 | const styles = StyleSheet.create({ 11 | root: { 12 | display: 'flex', 13 | flexDirection: 'column', 14 | justifyContent: 'space-between', 15 | maxWidth: '100%', 16 | backgroundColor: colors.gray.light, 17 | borderWidth: 2, 18 | borderColor: colors.gray.semi_light, 19 | borderRadius: 12, 20 | margin: 4, 21 | textAlign: 'left', 22 | }, 23 | top: { 24 | display: 'flex', 25 | flexDirection: 'column', 26 | justifyContent: 'space-between', 27 | alignItems: 'flex-start', 28 | flexShrink: 2, 29 | }, 30 | middle: { 31 | flexDirection: 'row', 32 | left: 0, 33 | padding: 5, 34 | }, 35 | bottom: { 36 | display: 'flex', 37 | alignItems: 'center', 38 | }, 39 | leftIcon: { 40 | alignItems: 'center', 41 | justifyContent: 'center', 42 | }, 43 | rightIcon: { 44 | alignItems: 'center', 45 | justifyContent: 'center', 46 | paddingLeft: 20, 47 | paddingRight: 20, 48 | }, 49 | soundIcon: { 50 | alignItems: 'center', 51 | justifyContent: 'center', 52 | backgroundColor: colors.gray.semi_light, 53 | height: 40, 54 | width: 40, 55 | borderRadius: 20, 56 | }, 57 | indicator: {}, 58 | }) 59 | 60 | const VocabBox = ({ 61 | titleText, 62 | bodyText, 63 | imageURI, 64 | showVolumeIcon, 65 | volumeIconCallback, 66 | width, 67 | height, 68 | notes, 69 | }) => { 70 | const generateImage = imageURI === '' ? null : ( 71 | 72 | Alternate Text 81 | 82 | ) 83 | const generateAudio = showVolumeIcon ? ( 84 | 90 | } 91 | onPress={volumeIconCallback} 92 | /> 93 | ) : ( 94 | 95 | ) 96 | 97 | const generateNotes = notes === '' ? null : ( 98 | 99 | 105 | {notes} 106 | 107 | 108 | ) 109 | 110 | return ( 111 | 118 | 119 | 126 | {titleText} 127 | 128 | 129 | {bodyText} 130 | 131 | 132 | 139 | {generateNotes} 140 | {generateImage} 141 | 142 | {generateAudio} 143 | 144 | ) 145 | } 146 | 147 | VocabBox.propTypes = { 148 | titleText: PropTypes.string, 149 | bodyText: PropTypes.string, 150 | imageURI: PropTypes.string, 151 | showVolumeIcon: PropTypes.bool, 152 | volumeIconCallback: PropTypes.func, 153 | width: PropTypes.number, 154 | height: PropTypes.number, 155 | notes: PropTypes.string, 156 | } 157 | 158 | VocabBox.defaultProps = { 159 | titleText: '', 160 | bodyText: '', 161 | imageURI: '', 162 | showVolumeIcon: false, 163 | volumeIconCallback: () => {}, 164 | width: 100, 165 | height: 300, 166 | notes: '', 167 | } 168 | 169 | export default VocabBox 170 | -------------------------------------------------------------------------------- /client/src/components/VocabBox/index.js: -------------------------------------------------------------------------------- 1 | import VocabBox from './VocabBox' 2 | 3 | export default VocabBox 4 | -------------------------------------------------------------------------------- /client/src/hooks/index.js: -------------------------------------------------------------------------------- 1 | import useErrorWrap from './useErrorWrap' 2 | import useTrackPromise from './useTrackPromise' 3 | 4 | export { useErrorWrap, useTrackPromise } 5 | -------------------------------------------------------------------------------- /client/src/hooks/useErrorWrap.js: -------------------------------------------------------------------------------- 1 | import { Alert } from 'react-native' 2 | import { useDispatch } from 'react-redux' 3 | import { setLoading } from 'slices/app.slice' 4 | import i18n from 'utils/i18n' 5 | import { ERROR_ALERT_TITLE } from '../utils/constants' 6 | 7 | /** 8 | * Custom hook that creates an error wrapper. If an error is thrown in the 9 | * error wrapper, then the global error is set and the error modal automatically 10 | * appears over the screen. There is a callback for when a request is successfully completed, 11 | * and a callback when a request errors out. 12 | */ 13 | 14 | const defaultSuccessCallback = () => {} 15 | const defaultErrorCallback = () => {} 16 | 17 | const useErrorWrap = () => { 18 | const dispatch = useDispatch() 19 | 20 | const errorWrapper = async ( 21 | func, 22 | successCallback = defaultSuccessCallback, 23 | errorCallback = defaultErrorCallback, 24 | ) => { 25 | try { 26 | if (func) await func() 27 | successCallback() 28 | } catch (error) { 29 | dispatch(setLoading({ isLoading: false })) 30 | console.error(`${i18n.t('dialogue.errorCaught')} `, error.message) 31 | Alert.alert(ERROR_ALERT_TITLE, error.message, [ 32 | { 33 | text: `${i18n.t('dict.ok')} `, 34 | onPress: () => errorCallback(), 35 | }, 36 | ]) 37 | } 38 | } 39 | 40 | return errorWrapper 41 | } 42 | 43 | export default useErrorWrap 44 | -------------------------------------------------------------------------------- /client/src/hooks/useTrackPromise.js: -------------------------------------------------------------------------------- 1 | import { useDispatch } from 'react-redux' 2 | import { setLoading } from 'slices/app.slice' 3 | 4 | const useTrackPromise = () => { 5 | const dispatch = useDispatch() 6 | 7 | const trackPromise = async (promise) => { 8 | if (promise) { 9 | dispatch(setLoading({ isLoading: true })) 10 | const value = await promise 11 | dispatch(setLoading({ isLoading: false })) 12 | return value 13 | } 14 | return null 15 | } 16 | 17 | return trackPromise 18 | } 19 | 20 | export default useTrackPromise 21 | -------------------------------------------------------------------------------- /client/src/index.js: -------------------------------------------------------------------------------- 1 | import App from './App' 2 | 3 | export default App 4 | -------------------------------------------------------------------------------- /client/src/navigator/Drawer/DrawerMenu.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import { View, SafeAreaView } from 'react-native' 4 | import { Text } from 'native-base' 5 | import { DrawerActions } from '@react-navigation/native' 6 | import FontIcon from 'react-native-vector-icons/FontAwesome5' 7 | import { colors } from 'theme' 8 | import i18n from 'utils/i18n' 9 | 10 | const styles = { 11 | root: { 12 | flex: 1, 13 | flexDirection: 'column', 14 | paddingHorizontal: 20, 15 | }, 16 | head: { 17 | flexDirection: 'row', 18 | justifyContent: 'space-around', 19 | }, 20 | main: { 21 | flex: 1, 22 | flexDirection: 'column', 23 | justifyContent: 'center', 24 | alignItems: 'center', 25 | }, 26 | line: { 27 | marginTop: '8%', 28 | marginBottom: '4%', 29 | height: 1, 30 | backgroundColor: '#C0C0C0', 31 | width: '65%', 32 | }, 33 | } 34 | 35 | const DrawerMenu = ({ navigation }) => ( 36 | 37 | 38 | 48 | {i18n.t('actions.myCourses')} 49 | 50 | 51 | 52 | { 59 | navigation.dispatch(DrawerActions.closeDrawer()) 60 | }} 61 | /> 62 | 63 | 64 | ) 65 | 66 | DrawerMenu.propTypes = { 67 | navigation: PropTypes.shape({ 68 | dispatch: PropTypes.func, 69 | }), 70 | } 71 | 72 | DrawerMenu.defaultProps = { 73 | navigation: { 74 | dispatch: () => null, 75 | }, 76 | } 77 | 78 | export default DrawerMenu 79 | -------------------------------------------------------------------------------- /client/src/navigator/Drawer/index.js: -------------------------------------------------------------------------------- 1 | import Drawer from './Drawer' 2 | 3 | export default Drawer 4 | -------------------------------------------------------------------------------- /client/src/navigator/Navigator.js: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from 'react' 2 | import { NavigationContainer } from '@react-navigation/native' 3 | import { useSelector, useDispatch } from 'react-redux' 4 | import { authenticate } from 'slices/auth.slice' 5 | import { createStackNavigator } from '@react-navigation/stack' 6 | import { getUser } from 'api' 7 | import { loadUserIDToken } from 'utils/auth' 8 | import DrawerNavigator from './Drawer' 9 | import { 10 | AppSettingsNavigator, 11 | AuthNavigator, 12 | ModalNavigator, 13 | ActivityNavigator, 14 | SearchNavigator, 15 | } from './Stacks' 16 | 17 | const RootStack = createStackNavigator() 18 | 19 | const Navigator = () => { 20 | /* 21 | Here is an example of useSelector, a hook that allows you to extract data from the Redux store state. 22 | The selector will be run whenever the function component renders. 23 | useSelector() will also subscribe to the Redux store, and run your selector whenever an action is dispatched. 24 | */ 25 | const dispatch = useDispatch() 26 | 27 | useEffect(() => { 28 | const loadUserAuth = async () => { 29 | const idToken = await loadUserIDToken() 30 | if (idToken != null) { 31 | dispatch(authenticate({ loggedIn: false })) 32 | } 33 | getUser() 34 | .then(({ success }) => { 35 | dispatch(authenticate({ loggedIn: success })) 36 | }) 37 | .catch(() => { 38 | dispatch(authenticate({ loggedIn: false })) 39 | }) 40 | } 41 | 42 | loadUserAuth() 43 | }, []) 44 | 45 | const { loggedIn } = useSelector((state) => state.auth) 46 | 47 | /* 48 | Based on whether the user is logged in or not, we will present the appropriate navigators. 49 | */ 50 | 51 | return ( 52 | 59 | {loggedIn ? ( 60 | <> 61 | 62 | 63 | 64 | 65 | 69 | 70 | 71 | 72 | ) : ( 73 | 74 | )} 75 | 76 | ) 77 | } 78 | 79 | export default () => ( 80 | 81 | 82 | 83 | ) 84 | -------------------------------------------------------------------------------- /client/src/navigator/Stacks/BackButton.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import { StyleSheet } from 'react-native' 4 | import FontIcon from 'react-native-vector-icons/FontAwesome5' 5 | import { colors } from 'theme' 6 | 7 | const styles = StyleSheet.create({ 8 | button: { 9 | paddingLeft: 15, 10 | }, 11 | }) 12 | 13 | const BackButton = ({ navigation, color, onPress }) => { 14 | const goBack = () => { 15 | onPress() 16 | navigation.goBack() 17 | } 18 | 19 | return ( 20 | 29 | ) 30 | } 31 | 32 | BackButton.propTypes = { 33 | navigation: PropTypes.shape({ 34 | goBack: PropTypes.func, 35 | }), 36 | color: PropTypes.string, 37 | onPress: PropTypes.func, 38 | } 39 | 40 | BackButton.defaultProps = { 41 | navigation: { goBack: () => null }, 42 | onPress: () => null, 43 | color: 'black', 44 | } 45 | 46 | export default BackButton 47 | 48 | /* 49 | 50 | 2. Create Selected Units and Unselected Units header 51 | 3. Add add units button 52 | 4. Add necessary props so far 53 | 5. Create simple card 54 | 6. Test out card functionality 55 | 7. Link up to state 56 | 8. Set up proper callbacks 57 | 58 | */ 59 | -------------------------------------------------------------------------------- /client/src/navigator/Stacks/DrawerButton.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import PropTypes from 'prop-types' 3 | import { StyleSheet } from 'react-native' 4 | import FontIcon from 'react-native-vector-icons/FontAwesome5' 5 | import { colors } from 'theme' 6 | 7 | const styles = StyleSheet.create({ 8 | button: { 9 | paddingLeft: 15, 10 | }, 11 | }) 12 | 13 | const DrawerButton = ({ navigation }) => ( 14 | { 21 | navigation.openDrawer() 22 | }} 23 | style={styles.button} 24 | /> 25 | ) 26 | 27 | DrawerButton.propTypes = { 28 | navigation: PropTypes.shape({ 29 | openDrawer: PropTypes.func, 30 | }), 31 | } 32 | 33 | DrawerButton.defaultProps = { 34 | navigation: { openDrawer: () => null }, 35 | } 36 | 37 | export default DrawerButton 38 | -------------------------------------------------------------------------------- /client/src/navigator/Stacks/index.js: -------------------------------------------------------------------------------- 1 | import { 2 | HomeNavigator, 3 | AuthNavigator, 4 | ModalNavigator, 5 | SettingsNavigator, 6 | SearchNavigator, 7 | ActivityNavigator, 8 | AppSettingsNavigator, 9 | } from './Stacks' 10 | 11 | export { 12 | HomeNavigator, 13 | AuthNavigator, 14 | ModalNavigator, 15 | SettingsNavigator, 16 | SearchNavigator, 17 | ActivityNavigator, 18 | AppSettingsNavigator, 19 | } 20 | -------------------------------------------------------------------------------- /client/src/navigator/Tabs/index.js: -------------------------------------------------------------------------------- 1 | import TabNavigator from './Tabs' 2 | 3 | export default TabNavigator 4 | -------------------------------------------------------------------------------- /client/src/navigator/index.js: -------------------------------------------------------------------------------- 1 | import Navigator from './Navigator' 2 | 3 | export default Navigator 4 | -------------------------------------------------------------------------------- /client/src/pages/AccountInfo/AccountInfo.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Text, Image } from 'native-base' 3 | import { StyleSheet, View, TouchableOpacity } from 'react-native' 4 | import { useSelector } from 'react-redux' 5 | import PropTypes from 'prop-types' 6 | import i18n from 'utils/i18n' 7 | import DrawerLogoutButton from 'components/DrawerLogoutButton' 8 | import { images } from 'theme' 9 | import { MaterialCommunityIcons } from '@expo/vector-icons' 10 | 11 | const styles = StyleSheet.create({ 12 | root: { 13 | flexDirection: 'column', 14 | justifyContent: 'space-between', 15 | alignItems: 'center', 16 | }, 17 | description: { 18 | marginTop: 10, 19 | marginBottom: 20, 20 | marginHorizontal: '3%', 21 | width: '92%', 22 | height: 'auto', 23 | }, 24 | body: { 25 | marginHorizontal: '5%', 26 | width: '90%', 27 | }, 28 | delete: { 29 | position: 'absolute', 30 | bottom: '0%', 31 | width: '90%', 32 | }, 33 | bottomContiner: { 34 | position: 'absolute', 35 | bottom: 10, 36 | }, 37 | profileContainer: { 38 | display: 'flex', 39 | flexDirection: 'row', 40 | justifyContent: 'flex-start', 41 | alignItems: 'center', 42 | marginLeft: 10, 43 | }, 44 | userInfoContainer: { 45 | display: 'flex', 46 | flexDirection: 'column', 47 | marginRight: 20, 48 | justifyContent: 'flex-end', 49 | }, 50 | userName: { 51 | paddingLeft: 10, 52 | fontSize: 20, 53 | }, 54 | userEmail: { 55 | fontSize: 15, 56 | paddingLeft: 10, 57 | }, 58 | bottomDivider: { 59 | marginTop: '3%', 60 | height: 1, 61 | backgroundColor: '#EFEFEF', 62 | width: '90%', 63 | }, 64 | }) 65 | 66 | const AccountInfo = ({ navigation }) => { 67 | const { userEmail, profileUrl, userName } = useSelector((state) => state.auth) 68 | 69 | const goToAppLanguage = () => { 70 | navigation.navigate('AppLanguage') 71 | } 72 | 73 | return ( 74 | <> 75 | 76 | 77 | 84 | {i18n.t('dict.userInfo')} 85 | 86 | 92 | {i18n.t('dialogue.appSettingsDescription')} 93 | 94 | 95 | 96 | 106 | 107 | {i18n.t('dict.language')} 108 | 109 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | Profile Icon 130 | 131 | 137 | {userName} 138 | 139 | {userEmail} 140 | 141 | 142 | 143 | 144 | 145 | 146 | ) 147 | } 148 | 149 | AccountInfo.propTypes = { 150 | navigation: PropTypes.shape({ 151 | navigate: PropTypes.func, 152 | goBack: PropTypes.func, 153 | }), 154 | } 155 | 156 | AccountInfo.defaultProps = { 157 | navigation: { navigate: () => null, goBack: () => null }, 158 | } 159 | 160 | export default AccountInfo 161 | -------------------------------------------------------------------------------- /client/src/pages/AccountInfo/index.js: -------------------------------------------------------------------------------- 1 | import AccountInfo from './AccountInfo' 2 | 3 | export default AccountInfo 4 | -------------------------------------------------------------------------------- /client/src/pages/Activity1/index.js: -------------------------------------------------------------------------------- 1 | import Activity1 from './Activity1' 2 | 3 | export default Activity1 4 | -------------------------------------------------------------------------------- /client/src/pages/Activity2/index.js: -------------------------------------------------------------------------------- 1 | import Activity2 from './Activity2' 2 | 3 | export default Activity2 4 | -------------------------------------------------------------------------------- /client/src/pages/AppLanguage/index.js: -------------------------------------------------------------------------------- 1 | import AppLanguage from './AppLanguage' 2 | 3 | export default AppLanguage 4 | -------------------------------------------------------------------------------- /client/src/pages/Apply/index.js: -------------------------------------------------------------------------------- 1 | import Apply from './Apply' 2 | 3 | export default Apply 4 | -------------------------------------------------------------------------------- /client/src/pages/Congrats/Congrats.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import StyledButton from 'components/StyledButton' 3 | import i18n from 'utils/i18n' 4 | import PropTypes from 'prop-types' 5 | import { View } from 'native-base' 6 | import Congratulations from '../../../assets/images/congrats.svg' 7 | 8 | const Congrats = ({ navigation }) => ( 9 | 10 | 18 | navigation.navigate('Drawer', { screen: 'LearnerHome' })} 23 | /> 24 | 25 | ) 26 | 27 | Congrats.propTypes = { 28 | navigation: PropTypes.shape({ 29 | navigate: PropTypes.func, 30 | }), 31 | } 32 | 33 | Congrats.defaultProps = { 34 | navigation: { 35 | navigate: () => null, 36 | }, 37 | } 38 | 39 | export default Congrats 40 | -------------------------------------------------------------------------------- /client/src/pages/Congrats/index.js: -------------------------------------------------------------------------------- 1 | import Congrats from './Congrats' 2 | 3 | export default Congrats 4 | -------------------------------------------------------------------------------- /client/src/pages/CourseHome/CourseHome.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react' 2 | import PropTypes from 'prop-types' 3 | import LanguageHome from 'components/LanguageHome' 4 | import { useSelector, useDispatch } from 'react-redux' 5 | import { setField } from 'slices/language.slice' 6 | import i18n from 'utils/i18n' 7 | import { INDICATOR_TYPES } from '../../utils/constants' 8 | 9 | const CourseHome = ({ navigation, courseDescription, courseName }) => { 10 | const { allUnits, courseDetails } = useSelector((state) => state.language) 11 | 12 | const dispatch = useDispatch() 13 | 14 | const [data, setData] = useState([]) 15 | const [name, setName] = useState(courseName) 16 | const [description, setDescription] = useState(courseDescription) 17 | 18 | /** 19 | * Updates the units presented in a list on this page 20 | */ 21 | useEffect(() => { 22 | let formattedUnitData = [] 23 | 24 | for (let i = 0; i < allUnits.length; i += 1) { 25 | const item = allUnits[i] 26 | 27 | // filters out unselected items 28 | if (item.selected) { 29 | const formattedItem = { 30 | _id: item._id, 31 | name: item.name, 32 | body: `${item.num_lessons} ${ 33 | item.num_lessons === 1 34 | ? `${i18n.t('dict.lessonSingle')}` 35 | : `${i18n.t('dict.lessonPlural')}` 36 | }`, 37 | indicatorType: INDICATOR_TYPES.NONE, 38 | _order: item._order, 39 | } 40 | formattedUnitData.push(formattedItem) 41 | } 42 | } 43 | 44 | // Units have order, so we must sort them before presenting to the user 45 | formattedUnitData = formattedUnitData.sort((a, b) => a._order - b._order) 46 | 47 | setData(formattedUnitData) 48 | }, [allUnits]) 49 | 50 | useEffect(() => { 51 | setName(courseDetails.name) 52 | setDescription(courseDetails.description) 53 | }, [courseDetails]) 54 | 55 | // Sets the title of the page 56 | useEffect(() => { 57 | navigation.setOptions({ 58 | title: `${i18n.t('dict.courseHome')}`, 59 | }) 60 | }, [navigation]) 61 | 62 | /** 63 | * Navigates to the Manage Units Page 64 | */ 65 | const navigateToManage = () => { 66 | navigation.navigate('ManageUnits') 67 | } 68 | 69 | /** 70 | * Navigates to the Unit Page 71 | * @param {Object} element Unit that was selected on this page 72 | */ 73 | const goToNextPage = (element) => { 74 | const currentUnitId = element._id 75 | dispatch(setField({ key: 'currentUnitId', value: currentUnitId })) // make sure to save the selected unit in state 76 | navigation.navigate('UnitHome') 77 | } 78 | 79 | const navigateToUpdate = () => { 80 | navigation.navigate('Modal', { screen: 'UpdateCourse' }) 81 | } 82 | /** 83 | * Navigates to the update unit modal 84 | */ 85 | const navigateToAdd = () => { 86 | navigation.navigate('Modal', { screen: 'CreateUnit' }) 87 | } 88 | 89 | return ( 90 | 105 | ) 106 | } 107 | 108 | CourseHome.propTypes = { 109 | navigation: PropTypes.shape({ 110 | navigate: PropTypes.func, 111 | goBack: PropTypes.func, 112 | setOptions: PropTypes.func, 113 | }), 114 | courseDescription: PropTypes.string, 115 | courseName: PropTypes.string, 116 | } 117 | 118 | CourseHome.defaultProps = { 119 | navigation: { 120 | navigate: () => null, 121 | goBack: () => null, 122 | setOptions: () => null, 123 | }, 124 | courseDescription: '', 125 | courseName: '', 126 | } 127 | 128 | export default CourseHome 129 | -------------------------------------------------------------------------------- /client/src/pages/CourseHome/index.js: -------------------------------------------------------------------------------- 1 | import CourseHome from './CourseHome' 2 | 3 | export default CourseHome 4 | -------------------------------------------------------------------------------- /client/src/pages/CourseSettings/index.js: -------------------------------------------------------------------------------- 1 | import CourseSettings from './CourseSettings' 2 | 3 | export default CourseSettings 4 | -------------------------------------------------------------------------------- /client/src/pages/CreateLesson/CreateLesson.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react' 2 | import { StyleSheet, View } from 'react-native' 3 | import PropTypes from 'prop-types' 4 | import Drawer from 'components/Drawer' 5 | import { Input, TextArea, Text } from 'native-base' 6 | import { Foundation } from '@expo/vector-icons' 7 | import { colors } from 'theme' 8 | import { useSelector, useDispatch } from 'react-redux' 9 | import { addLesson } from 'slices/language.slice' 10 | import { createLesson } from 'api' 11 | import { useErrorWrap } from 'hooks' 12 | import RequiredField from 'components/RequiredField' 13 | import i18n from 'utils/i18n' 14 | 15 | const styles = StyleSheet.create({ 16 | container: { 17 | borderRadius: 2, 18 | borderWidth: 0.5, 19 | padding: 8, 20 | marginBottom: 10, 21 | backgroundColor: colors.blue.light, 22 | borderColor: colors.blue.light, 23 | }, 24 | textRow: { 25 | flexDirection: 'row', 26 | }, 27 | }) 28 | const CreateLesson = ({ navigation }) => { 29 | const errorWrap = useErrorWrap() 30 | const dispatch = useDispatch() 31 | const { currentCourseId, currentUnitId } = useSelector( 32 | (state) => state.language, 33 | ) 34 | const [name, setName] = useState('') // the name of the lesson 35 | const [purpose, setPurpose] = useState('') // the purpose/description of the lesson 36 | 37 | // checks if all fields are filled 38 | // otherwise, the submit button is disabled 39 | const areRequiredFieldsFilled = name !== '' && purpose !== '' 40 | 41 | // Closes the modal 42 | const close = () => { 43 | navigation.goBack() 44 | } 45 | 46 | // Posts the new lesson to the API and updates the state 47 | const success = async () => { 48 | errorWrap( 49 | async () => { 50 | const newLesson = { 51 | name, 52 | description: purpose, 53 | selected: true, 54 | } 55 | 56 | const { result } = await createLesson( 57 | currentCourseId, 58 | currentUnitId, 59 | newLesson, 60 | ) 61 | 62 | // All new lessons have 0 vocab items, and we must set this since this information 63 | // will be presented on the app 64 | result.num_vocab = 0 65 | 66 | dispatch(addLesson({ lesson: result })) 67 | }, 68 | () => { 69 | // on success, close the modal 70 | close() 71 | }, 72 | ) 73 | } 74 | 75 | const body = ( 76 | <> 77 | 78 | 79 | 80 | 88 | {i18n.t('dict.suggestion')} 89 | 90 | 91 | 92 | {i18n.t('dialogue.createLessonDescription')} 93 | 94 | 95 | 96 | setName(text)} 101 | /> 102 | 103 | 104 |