├── .gitignore ├── LICENSE ├── README.md ├── frontend ├── .eslintcache ├── .gitignore ├── README.md ├── craco.config.js ├── package.json ├── public │ ├── favicon.ico │ ├── index.html │ ├── logo192.png │ ├── logo512.png │ ├── manifest.json │ ├── preview.jpg │ └── robots.txt ├── src │ ├── App.css │ ├── App.test.tsx │ ├── App.tsx │ ├── components │ │ ├── hoc │ │ │ ├── index.ts │ │ │ ├── withAuth.tsx │ │ │ └── withTheme.tsx │ │ ├── main │ │ │ ├── BookmarkButton │ │ │ │ └── index.tsx │ │ │ ├── Chats │ │ │ │ ├── ChatBox.tsx │ │ │ │ ├── Chats.tsx │ │ │ │ ├── MinimizedChats.tsx │ │ │ │ └── index.tsx │ │ │ ├── Comments │ │ │ │ ├── CommentInput.tsx │ │ │ │ ├── CommentItem.tsx │ │ │ │ ├── CommentList.tsx │ │ │ │ └── index.tsx │ │ │ ├── FollowButton │ │ │ │ └── index.tsx │ │ │ ├── LikeButton │ │ │ │ └── index.tsx │ │ │ ├── Messages │ │ │ │ ├── MessagesList.tsx │ │ │ │ └── index.tsx │ │ │ ├── Modals │ │ │ │ ├── ComposeMessageModal.tsx │ │ │ │ ├── CreatePostModal.tsx │ │ │ │ ├── CropProfileModal.tsx │ │ │ │ ├── DeleteCommentModal.tsx │ │ │ │ ├── DeletePostModal.tsx │ │ │ │ ├── EditPostModal.tsx │ │ │ │ ├── ImageLightbox.tsx │ │ │ │ ├── LogoutModal.tsx │ │ │ │ ├── PostLikesModal.tsx │ │ │ │ ├── PostModals.tsx │ │ │ │ └── index.ts │ │ │ ├── Notification │ │ │ │ ├── NotificationList.tsx │ │ │ │ └── index.tsx │ │ │ ├── Options │ │ │ │ ├── CommentOptions.tsx │ │ │ │ ├── PostOptions.tsx │ │ │ │ └── index.ts │ │ │ ├── PostItem │ │ │ │ └── index.tsx │ │ │ ├── SuggestedPeople │ │ │ │ └── index.tsx │ │ │ ├── UserCard │ │ │ │ └── index.tsx │ │ │ └── index.ts │ │ └── shared │ │ │ ├── Avatar.tsx │ │ │ ├── Badge.tsx │ │ │ ├── Boundary.tsx │ │ │ ├── ImageGrid.tsx │ │ │ ├── Loader.tsx │ │ │ ├── Loaders.tsx │ │ │ ├── NavBar.tsx │ │ │ ├── NavBarMobile.tsx │ │ │ ├── Preloader.tsx │ │ │ ├── SearchInput.tsx │ │ │ ├── SocialLogin.tsx │ │ │ ├── ThemeToggler.tsx │ │ │ └── index.ts │ ├── constants │ │ ├── actionType.ts │ │ └── routes.ts │ ├── helpers │ │ ├── cropImage.tsx │ │ └── utils.tsx │ ├── hooks │ │ ├── index.ts │ │ ├── useDidMount.tsx │ │ ├── useDocumentTitle.tsx │ │ ├── useFileHandler.tsx │ │ └── useModal.tsx │ ├── images │ │ ├── SVG │ │ │ └── logoAsset 1.svg │ │ ├── avatar_placeholder.png │ │ ├── friends_meal.jpg │ │ ├── friends_meal_2.webp │ │ ├── logo-white.svg │ │ ├── logo.svg │ │ ├── screen1.png │ │ ├── screen2.png │ │ ├── screen3.png │ │ ├── screen4.png │ │ └── thumbnail.jpg │ ├── index.css │ ├── index.tsx │ ├── pages │ │ ├── chat │ │ │ └── index.tsx │ │ ├── error │ │ │ ├── PageNotFound.tsx │ │ │ └── index.ts │ │ ├── home │ │ │ ├── SideMenu.tsx │ │ │ └── index.tsx │ │ ├── index.ts │ │ ├── login │ │ │ └── index.tsx │ │ ├── post │ │ │ └── index.tsx │ │ ├── profile │ │ │ ├── Header │ │ │ │ ├── CoverPhotoOverlay.tsx │ │ │ │ ├── Tabs.tsx │ │ │ │ └── index.tsx │ │ │ ├── Tabs │ │ │ │ ├── Bio.tsx │ │ │ │ ├── Bookmarks.tsx │ │ │ │ ├── EditInfo.tsx │ │ │ │ ├── Followers.tsx │ │ │ │ ├── Following.tsx │ │ │ │ ├── Info.tsx │ │ │ │ ├── Posts.tsx │ │ │ │ └── index.ts │ │ │ └── index.tsx │ │ ├── redirects │ │ │ ├── SocialAuthFailed.tsx │ │ │ └── index.ts │ │ ├── register │ │ │ └── index.tsx │ │ ├── search │ │ │ ├── Posts.tsx │ │ │ ├── Users.tsx │ │ │ └── index.tsx │ │ └── suggested_people │ │ │ └── index.tsx │ ├── react-app-env.d.ts │ ├── redux │ │ ├── action │ │ │ ├── authActions.ts │ │ │ ├── chatActions.ts │ │ │ ├── errorActions.ts │ │ │ ├── feedActions.ts │ │ │ ├── helperActions.ts │ │ │ ├── loadingActions.ts │ │ │ ├── modalActions.ts │ │ │ ├── profileActions.ts │ │ │ └── settingsActions.ts │ │ ├── reducer │ │ │ ├── authReducer.ts │ │ │ ├── chatReducer.ts │ │ │ ├── errorReducer.ts │ │ │ ├── helperReducer.ts │ │ │ ├── loadingReducer.ts │ │ │ ├── modalReducer.ts │ │ │ ├── newsFeedReducer.ts │ │ │ ├── profileReducer.ts │ │ │ ├── rootReducer.ts │ │ │ └── settingsReducer.ts │ │ ├── sagas │ │ │ ├── authSaga.ts │ │ │ ├── index.ts │ │ │ ├── newsFeedSaga.ts │ │ │ └── profileSaga.ts │ │ └── store │ │ │ └── store.ts │ ├── reportWebVitals.ts │ ├── routers │ │ ├── ProtectedRoute.tsx │ │ ├── PublicRoute.tsx │ │ └── index.ts │ ├── services │ │ ├── api.ts │ │ └── fetcher.ts │ ├── setupTests.ts │ ├── socket │ │ └── socket.ts │ ├── styles │ │ ├── app.css │ │ ├── base │ │ │ └── base.css │ │ ├── components │ │ │ ├── container.css │ │ │ ├── grid.css │ │ │ └── modal.css │ │ ├── elements │ │ │ ├── button.css │ │ │ ├── input.css │ │ │ └── link.css │ │ └── utils │ │ │ └── utils.css │ └── types │ │ └── types.ts ├── tailwind.config.js ├── tsconfig.json ├── tsconfig.paths.json └── yarn.lock ├── package-lock.json ├── package.json └── server ├── .gitignore ├── Procfile ├── nodemon.json ├── package-lock.json ├── package.json ├── src ├── app.ts ├── config │ ├── config.ts │ ├── passport.ts │ └── socket.ts ├── constants │ ├── constants.ts │ └── error-types.ts ├── db │ └── db.ts ├── helpers │ └── utils.ts ├── middlewares │ ├── error.middleware.ts │ ├── index.ts │ └── middlewares.ts ├── routes │ ├── api │ │ └── v1 │ │ │ ├── auth.ts │ │ │ ├── bookmark.ts │ │ │ ├── comment.ts │ │ │ ├── feed.ts │ │ │ ├── follow.ts │ │ │ ├── message.ts │ │ │ ├── notification.ts │ │ │ ├── post.ts │ │ │ ├── search.ts │ │ │ └── user.ts │ └── createRouter.ts ├── schemas │ ├── BookmarkSchema.ts │ ├── ChatSchema.ts │ ├── CommentSchema.ts │ ├── FollowSchema.ts │ ├── LikeSchema.ts │ ├── MessageSchema.ts │ ├── NewsFeedSchema.ts │ ├── NotificationSchema.ts │ ├── PostSchema.ts │ ├── UserSchema.ts │ └── index.ts ├── server.ts ├── services │ ├── follow.service.ts │ ├── index.ts │ ├── newsfeed.service.ts │ └── post.service.ts ├── storage │ ├── cloudinary.ts │ └── filestorage.ts ├── types │ └── express │ │ └── index.d.ts ├── utils │ └── storage.utils.ts └── validations │ └── validations.ts └── tsconfig.json /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | frontend/.env-dev 3 | frontend/.env-prod 4 | frontend/.env.development 5 | frontend/.env.production 6 | frontend/.env 7 | frontend/.eslintcache 8 | server/.env-dev 9 | server/.env-prod 10 | server/.env 11 | server/build 12 | server/uploads 13 | yarn-error.log -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Julius Guevarra 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 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Foodie 2 | A social media for food lovers and for people looking for new ideas for their next menu. A facebook/instagram-like inspired social media. 3 | 4 | ![Heroku](https://heroku-badge.herokuapp.com/?app=foodie-social) ![Vercel](https://vercelbadge.vercel.app/api/jgudo/foodie) 5 | 6 | 7 | 8 | ## Table of contents 9 | * [Features](#features) 10 | * [Technologies](#technologies) 11 | * [Installation](#installation) 12 | * [Run Locally](#run_local) 13 | * [Deployment](#deployment) 14 | * [Screenshots](#screenshots) 15 | 16 | ## Features 17 | This web app consists of a basic features/functionalities of a socia media 18 | * Login and Registration 19 | * Notification 20 | * Private Messaging 21 | * Post CRUD functionality 22 | * Comment feature 23 | * Profile Customization 24 | * Followers/Following feature 25 | * Search Feature 26 | 27 | ## Technologies 28 | | Front End | Back End | 29 | | ----------- | ------------| 30 | | React 17.0.1| Node 12.18.1| 31 | | TypeScript | MongoDB | 32 | | Redux | Mongoose | 33 | | Redux-Saga | SocketIO | 34 | | React Router| Express JS | 35 | | TailwindCSS | Passport JS | 36 | | PostCSS | Google Cloud Storage| 37 | | Axios | | 38 | 39 | ## Installation 40 | To install both ends (frontend/server). 41 | ``` 42 | $ npm run init-project 43 | ``` 44 | 45 | Or to install them individually 46 | ``` 47 | $ cd frontend // or cd server 48 | $ npm install 49 | ``` 50 | 51 | ## Run locally 52 | Before running the project, make sure to have the following done: 53 | * Download and install [MongoDB](https://www.mongodb.com/) 54 | * Create [Firebase Project](https://console.firebase.google.com/u/0/) for storage bucket 55 | * Create Google Service Account json key and configure ENV variable to your machine 56 | 57 | Create ```.env-dev``` or ```.end-prod``` env variables and set the following: 58 | ``` 59 | MONGODB_URI= 60 | DB_NAME= 61 | PORT= 62 | CLIENT_URL= 63 | SESSION_SECRET= 64 | SESSION_NAME= 65 | FIREBASE_PROJECT_ID= 66 | FIREBASE_STORAGE_BUCKET_URL= 67 | GOOGLE_APPLICATION_CREDENTIALS= 68 | FACEBOOK_CLIENT_ID= 69 | FACEBOOK_CLIENT_SECRET= 70 | GITHUB_CLIENT_ID= 71 | GITHUB_CLIENT_SECRET= 72 | ``` 73 | 74 | You can get your Facebook client id/secret here [Facebook for developers](http://developers.facebook.com/) and for GitHub here [Register Github OAuth App](https://github.com/settings/applications/new) and set the necessary env vars above. 75 | 76 | After doing the steps above, you have to run your ```Mongo Server``` and finally you can now run both ends simultaneously by running: 77 | ``` 78 | $ npm start 79 | ``` 80 | 81 | Or you can run them individually 82 | ``` 83 | $ npm run start-client // frontend 84 | $ npm run start-server // backend 85 | 86 | // Or you can change to individual directory then run 87 | $ cd frontend // or cd server 88 | $ npm start 89 | ``` 90 | 91 | ## Deployment 92 | You can deploy your react app in [Vercel](http://vercel.app/) or whatever your preferred deployment platform. 93 | And for the backend, you can deploy your server in [Heroku](https://heroku.com) 94 | 95 | ## Screenshots 96 | 97 | ![Foodie screenshot](https://raw.githubusercontent.com/jgudo/foodie/master/frontend/src/images/screen1.png) 98 | ![Foodie screenshot](https://raw.githubusercontent.com/jgudo/foodie/master/frontend/src/images/screen2.png) 99 | ![Foodie screenshot](https://raw.githubusercontent.com/jgudo/foodie/master/frontend/src/images/screen3.png) 100 | ![Foodie screenshot](https://raw.githubusercontent.com/jgudo/foodie/master/frontend/src/images/screen4.png) 101 | -------------------------------------------------------------------------------- /frontend/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | 25 | .eslintcache -------------------------------------------------------------------------------- /frontend/README.md: -------------------------------------------------------------------------------- 1 | # Getting Started with Create React App 2 | 3 | This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app). 4 | 5 | ## Available Scripts 6 | 7 | In the project directory, you can run: 8 | 9 | ### `yarn start` 10 | 11 | Runs the app in the development mode.\ 12 | Open [http://localhost:3000](http://localhost:3000) to view it in the browser. 13 | 14 | The page will reload if you make edits.\ 15 | You will also see any lint errors in the console. 16 | 17 | ### `yarn test` 18 | 19 | Launches the test runner in the interactive watch mode.\ 20 | See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information. 21 | 22 | ### `yarn build` 23 | 24 | Builds the app for production to the `build` folder.\ 25 | It correctly bundles React in production mode and optimizes the build for the best performance. 26 | 27 | The build is minified and the filenames include the hashes.\ 28 | Your app is ready to be deployed! 29 | 30 | See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information. 31 | 32 | ### `yarn eject` 33 | 34 | **Note: this is a one-way operation. Once you `eject`, you can’t go back!** 35 | 36 | If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project. 37 | 38 | Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own. 39 | 40 | You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it. 41 | 42 | ## Learn More 43 | 44 | You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started). 45 | 46 | To learn React, check out the [React documentation](https://reactjs.org/). 47 | -------------------------------------------------------------------------------- /frontend/craco.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const { CracoAliasPlugin, configPaths } = require('react-app-rewire-alias') 3 | 4 | module.exports = { 5 | style: { 6 | postcss: { 7 | plugins: [ 8 | require('postcss-import'), 9 | require('postcss-nested'), 10 | require('postcss-extend'), 11 | require('tailwindcss'), 12 | require('autoprefixer'), 13 | ], 14 | }, 15 | }, 16 | webpack: { 17 | alias: { 18 | // Add the aliases for all the top-level folders in the `src/` folder. 19 | '~/*': path.join(path.resolve(__dirname, './src/*')) 20 | } 21 | }, 22 | jest: { 23 | configure: { 24 | moduleNameMapper: { 25 | // Jest module mapper which will detect our absolute imports. 26 | '^assets(.*)$': '/src/assets$1', 27 | '^components(.*)$': '/src/components$1', 28 | '^routers(.*)$': '/src/routers$1', 29 | '^pages(.*)$': '/src/pages$1', 30 | '^redux(.*)$': '/src/redux$1', 31 | '^utils(.*)$': '/src/utils$1', 32 | '^types(.*)$': '/src/types$1', 33 | '^constants(.*)$': '/src/constants$1', 34 | 35 | // Another example for using a wildcard character 36 | '^~(.*)$': '/src$1' 37 | } 38 | } 39 | }, 40 | plugins: [ 41 | { 42 | plugin: CracoAliasPlugin, 43 | options: { alias: configPaths('./tsconfig.paths.json') } 44 | } 45 | ] 46 | } -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "frontend", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@ant-design/icons": "^4.3.0", 7 | "@tailwindcss/aspect-ratio": "^0.2.0", 8 | "@tailwindcss/forms": "^0.2.1", 9 | "@tailwindcss/postcss7-compat": "^2.0.2", 10 | "@tailwindcss/typography": "^0.3.1", 11 | "@testing-library/jest-dom": "^5.11.4", 12 | "@testing-library/react": "^11.1.0", 13 | "@testing-library/user-event": "^12.1.10", 14 | "@types/jest": "^26.0.15", 15 | "@types/node": "^12.0.0", 16 | "@types/react": "^16.9.53", 17 | "@types/react-dom": "^16.9.8", 18 | "@types/react-redux": "^7.1.12", 19 | "@types/react-router-dom": "^5.1.6", 20 | "@types/redux": "^3.6.0", 21 | "@types/redux-logger": "^3.0.8", 22 | "autoprefixer": "^9", 23 | "axios": "^0.21.0", 24 | "dayjs": "^1.9.8", 25 | "history": "4.10.1", 26 | "lodash.debounce": "^4.0.8", 27 | "postcss": "^7", 28 | "react": "^17.0.1", 29 | "react-content-loader": "^6.0.1", 30 | "react-dom": "^17.0.1", 31 | "react-easy-crop": "^3.3.1", 32 | "react-image-lightbox": "^5.1.1", 33 | "react-infinite-scroll-hook": "^3.0.0", 34 | "react-modal": "^3.12.1", 35 | "react-redux": "^7.2.2", 36 | "react-router-dom": "^5.2.0", 37 | "react-scripts": "4.0.1", 38 | "react-toastify": "^6.2.0", 39 | "react-transition-group": "^4.4.1", 40 | "redux": "^4.0.5", 41 | "redux-logger": "^3.0.6", 42 | "redux-saga": "^1.1.3", 43 | "socket.io": "^3.0.4", 44 | "socket.io-client": "^3.0.4", 45 | "tailwindcss": "npm:@tailwindcss/postcss7-compat", 46 | "typescript": "^4.0.3", 47 | "web-vitals": "^0.2.4" 48 | }, 49 | "scripts": { 50 | "start": "craco start", 51 | "build": "craco build", 52 | "test": "craco test", 53 | "eject": "react-scripts eject" 54 | }, 55 | "eslintConfig": { 56 | "extends": [ 57 | "react-app", 58 | "react-app/jest" 59 | ] 60 | }, 61 | "browserslist": { 62 | "production": [ 63 | ">0.2%", 64 | "not dead", 65 | "not op_mini all" 66 | ], 67 | "development": [ 68 | "last 1 chrome version", 69 | "last 1 firefox version", 70 | "last 1 safari version" 71 | ] 72 | }, 73 | "proxy": "http://localhost:9000", 74 | "devDependencies": { 75 | "@craco/craco": "^6.0.0", 76 | "@neojp/tailwindcss-important-variant": "^1.0.1", 77 | "@tailwindcss/custom-forms": "^0.2.1", 78 | "@tailwindcss/jit": "^0.1.1", 79 | "@types/lodash.debounce": "^4.0.6", 80 | "@types/react-modal": "^3.10.6", 81 | "@types/react-transition-group": "^4.4.0", 82 | "@types/socket.io": "^2.1.12", 83 | "node-sass": "4.14.1", 84 | "postcss-extend": "^1.0.5", 85 | "postcss-import": "12.0.1", 86 | "postcss-loader": "^4.1.0", 87 | "postcss-nested": "4.2.3", 88 | "react-app-rewire-alias": "^0.1.9", 89 | "tailwindcss-children": "^2.1.0" 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /frontend/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jgudo/foodie/84f5e6d81f48e9cd0149ec07a2611049a852746a/frontend/public/favicon.ico -------------------------------------------------------------------------------- /frontend/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 31 | 32 | 41 | Foodie | Social Network 42 | 43 | 44 | 45 | 46 |
47 | 57 | 58 | 59 | -------------------------------------------------------------------------------- /frontend/public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jgudo/foodie/84f5e6d81f48e9cd0149ec07a2611049a852746a/frontend/public/logo192.png -------------------------------------------------------------------------------- /frontend/public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jgudo/foodie/84f5e6d81f48e9cd0149ec07a2611049a852746a/frontend/public/logo512.png -------------------------------------------------------------------------------- /frontend/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /frontend/public/preview.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jgudo/foodie/84f5e6d81f48e9cd0149ec07a2611049a852746a/frontend/public/preview.jpg -------------------------------------------------------------------------------- /frontend/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /frontend/src/App.css: -------------------------------------------------------------------------------- 1 | .App { 2 | text-align: center; 3 | } 4 | 5 | .App-logo { 6 | height: 40vmin; 7 | pointer-events: none; 8 | } 9 | 10 | @media (prefers-reduced-motion: no-preference) { 11 | .App-logo { 12 | animation: App-logo-spin infinite 20s linear; 13 | } 14 | } 15 | 16 | .App-header { 17 | background-color: #282c34; 18 | min-height: 100vh; 19 | display: flex; 20 | flex-direction: column; 21 | align-items: center; 22 | justify-content: center; 23 | font-size: calc(10px + 2vmin); 24 | color: white; 25 | } 26 | 27 | .App-link { 28 | color: #61dafb; 29 | } 30 | 31 | @keyframes App-logo-spin { 32 | from { 33 | transform: rotate(0deg); 34 | } 35 | to { 36 | transform: rotate(360deg); 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /frontend/src/App.test.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render, screen } from '@testing-library/react'; 3 | import App from './App'; 4 | 5 | test('renders learn react link', () => { 6 | render(); 7 | const linkElement = screen.getByText(/learn react/i); 8 | expect(linkElement).toBeInTheDocument(); 9 | }); 10 | -------------------------------------------------------------------------------- /frontend/src/App.tsx: -------------------------------------------------------------------------------- 1 | import { createBrowserHistory } from 'history'; 2 | import { useEffect, useState } from "react"; 3 | import { useDispatch } from "react-redux"; 4 | import { Route, Router, Switch } from "react-router-dom"; 5 | import { Slide, ToastContainer } from 'react-toastify'; 6 | import 'react-toastify/dist/ReactToastify.css'; 7 | import { Chats } from '~/components/main'; 8 | import { NavBar, Preloader } from "~/components/shared"; 9 | import * as ROUTE from "~/constants/routes"; 10 | import * as pages from '~/pages'; 11 | import { ProtectedRoute, PublicRoute } from "~/routers"; 12 | import { loginSuccess } from "./redux/action/authActions"; 13 | import { checkAuthSession } from "./services/api"; 14 | import socket from './socket/socket'; 15 | 16 | export const history = createBrowserHistory(); 17 | 18 | function App() { 19 | const [isCheckingSession, setCheckingSession] = useState(true); 20 | const dispatch = useDispatch(); 21 | const isNotMobile = window.screen.width >= 800; 22 | 23 | useEffect(() => { 24 | (async () => { 25 | try { 26 | const { auth } = await checkAuthSession(); 27 | 28 | dispatch(loginSuccess(auth)); 29 | 30 | socket.on('connect', () => { 31 | socket.emit('userConnect', auth.id); 32 | console.log('Client connected to socket.'); 33 | }); 34 | 35 | // Try to reconnect again 36 | socket.on('error', function () { 37 | socket.emit('userConnect', auth.id); 38 | }); 39 | 40 | setCheckingSession(false); 41 | } catch (e) { 42 | console.log('ERROR', e); 43 | setCheckingSession(false); 44 | } 45 | })(); 46 | // eslint-disable-next-line react-hooks/exhaustive-deps 47 | }, []); 48 | 49 | return isCheckingSession ? ( 50 | 51 | ) : ( 52 | 53 |
54 | 62 | 63 | 64 | 65 | 66 | 67 | } /> 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | {isNotMobile && } 76 |
77 |
78 | ); 79 | } 80 | 81 | export default App; 82 | -------------------------------------------------------------------------------- /frontend/src/components/hoc/index.ts: -------------------------------------------------------------------------------- 1 | export { default as withAuth } from './withAuth'; 2 | export { default as withTheme } from './withTheme'; 3 | -------------------------------------------------------------------------------- /frontend/src/components/hoc/withAuth.tsx: -------------------------------------------------------------------------------- 1 | import { useSelector } from "react-redux" 2 | import { IRootReducer } from "~/types/types" 3 | 4 | interface IInjectedProps { 5 | isAuth: boolean; 6 | } 7 | 8 | const withAuth =

(Component: React.ComponentType

) => { 9 | return (props: Pick>) => { 10 | const isAuth = useSelector((state: IRootReducer) => !!state.auth.id && !!state.auth.username); 11 | 12 | return 13 | } 14 | }; 15 | 16 | export default withAuth; 17 | 18 | -------------------------------------------------------------------------------- /frontend/src/components/hoc/withTheme.tsx: -------------------------------------------------------------------------------- 1 | import { useSelector } from "react-redux"; 2 | import { IRootReducer } from "~/types/types"; 3 | 4 | interface IInjectedProps { 5 | theme: string; 6 | [prop: string]: any; 7 | } 8 | 9 | const withTheme =

(Component: React.ComponentType

) => { 10 | return (props: Pick>) => { 11 | const theme = useSelector((state: IRootReducer) => state.settings.theme); 12 | 13 | return 14 | } 15 | }; 16 | 17 | export default withTheme; 18 | 19 | -------------------------------------------------------------------------------- /frontend/src/components/main/BookmarkButton/index.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | import { toast } from 'react-toastify'; 3 | import { useDidMount } from '~/hooks'; 4 | import { bookmarkPost } from '~/services/api'; 5 | 6 | interface IProps { 7 | postID: string; 8 | initBookmarkState: boolean; 9 | children: (props: any) => React.ReactNode; 10 | } 11 | 12 | const BookmarkButton: React.FC = (props) => { 13 | const [isBookmarked, setIsBookmarked] = useState(props.initBookmarkState || false); 14 | const [isLoading, setLoading] = useState(false); 15 | const didMount = useDidMount(true); 16 | 17 | useEffect(() => { 18 | setIsBookmarked(props.initBookmarkState); 19 | }, [props.initBookmarkState]); 20 | 21 | const dispatchBookmark = async () => { 22 | if (isLoading) return; 23 | 24 | try { 25 | // state = TRUE | FALSE 26 | setLoading(true); 27 | const { state } = await bookmarkPost(props.postID); 28 | 29 | if (didMount) { 30 | setIsBookmarked(state); 31 | setLoading(false); 32 | } 33 | 34 | if (state) { 35 | toast.dark('Post successfully bookmarked.', { 36 | hideProgressBar: true, 37 | autoClose: 2000 38 | }); 39 | } else { 40 | toast.info('Post removed from bookmarks.', { 41 | hideProgressBar: true, 42 | autoClose: 2000 43 | }); 44 | } 45 | } catch (e) { 46 | didMount && setLoading(false); 47 | console.log(e); 48 | } 49 | } 50 | 51 | return ( 52 |

53 | { props.children({ dispatchBookmark, isBookmarked, isLoading })} 54 |
55 | ); 56 | }; 57 | 58 | export default BookmarkButton; 59 | -------------------------------------------------------------------------------- /frontend/src/components/main/Chats/Chats.tsx: -------------------------------------------------------------------------------- 1 | import { useSelector } from "react-redux"; 2 | import { IRootReducer } from "~/types/types"; 3 | import ChatBox from "./ChatBox"; 4 | import MinimizedChats from "./MinimizedChats"; 5 | 6 | const Chats = () => { 7 | const { chats, user } = useSelector((state: IRootReducer) => ({ 8 | chats: state.chats, 9 | user: state.auth 10 | })); 11 | 12 | return ( 13 |
14 | {chats.items.map(chat => chats.active === chat.id && ( 15 | 16 | ))} 17 | 18 |
19 | ) 20 | }; 21 | 22 | export default Chats; 23 | -------------------------------------------------------------------------------- /frontend/src/components/main/Chats/MinimizedChats.tsx: -------------------------------------------------------------------------------- 1 | import { useDispatch } from "react-redux"; 2 | import { CSSTransition, TransitionGroup } from "react-transition-group"; 3 | import Avatar from "~/components/shared/Avatar"; 4 | import { initiateChat } from "~/redux/action/chatActions"; 5 | import { IChatItemsState } from "~/types/types"; 6 | 7 | interface IProps { 8 | users: IChatItemsState[] 9 | } 10 | 11 | const MinimizedChats: React.FC = ({ users }) => { 12 | const dispatch = useDispatch(); 13 | 14 | return ( 15 |
16 | 17 | {users.map(chat => chat.minimized && ( 18 | 23 |
dispatch(initiateChat(chat))} 26 | title={chat.username} 27 | > 28 | 33 |
34 |
35 | ))} 36 |
37 |
38 | ); 39 | }; 40 | 41 | export default MinimizedChats; 42 | 43 | -------------------------------------------------------------------------------- /frontend/src/components/main/Chats/index.tsx: -------------------------------------------------------------------------------- 1 | export { default as ChatBox } from './ChatBox'; 2 | export { default as Chats } from './Chats'; 3 | export { default as MinimizedChats } from './MinimizedChats'; 4 | -------------------------------------------------------------------------------- /frontend/src/components/main/Comments/CommentInput.tsx: -------------------------------------------------------------------------------- 1 | import React, { forwardRef, MutableRefObject, useEffect } from "react"; 2 | import { useSelector } from "react-redux"; 3 | import { Avatar } from "~/components/shared"; 4 | import { IRootReducer } from "~/types/types"; 5 | 6 | interface IProps { 7 | isLoading: boolean; 8 | isSubmitting: boolean; 9 | isUpdateMode: boolean; 10 | [prop: string]: any; 11 | } 12 | 13 | const CommentInput = forwardRef((props, ref) => { 14 | const { isUpdateMode, isSubmitting, isLoading, ...rest } = props; 15 | const userPicture = useSelector((state: IRootReducer) => state.auth.profilePicture); 16 | 17 | useEffect(() => { 18 | ref && (ref as MutableRefObject).current.focus(); 19 | }, [ref]) 20 | 21 | return ( 22 |
23 | {!isUpdateMode && } 24 |
25 | 32 | {isUpdateMode && Press Esc to Cancel} 33 |
34 |
35 | ); 36 | }); 37 | 38 | export default CommentInput; 39 | -------------------------------------------------------------------------------- /frontend/src/components/main/Comments/CommentList.tsx: -------------------------------------------------------------------------------- 1 | import { LoadingOutlined } from "@ant-design/icons"; 2 | import React, { lazy, Suspense, useEffect, useState } from "react"; 3 | import { useDispatch } from "react-redux"; 4 | import { CSSTransition, TransitionGroup } from "react-transition-group"; 5 | import { useDidMount, useModal } from "~/hooks"; 6 | import { setTargetComment } from "~/redux/action/helperActions"; 7 | import { IComment } from "~/types/types"; 8 | import CommentItem from "./CommentItem"; 9 | 10 | const DeleteCommentModal = lazy(() => import('~/components/main/Modals/DeleteCommentModal')) 11 | 12 | interface IProps { 13 | comments: IComment[]; 14 | updateCommentCallback?: (comment: IComment) => void; 15 | } 16 | 17 | const CommentList: React.FC = ({ comments, updateCommentCallback }) => { 18 | const didMount = useDidMount(); 19 | const dispatch = useDispatch(); 20 | const [replies, setReplies] = useState(comments); 21 | const { isOpen, closeModal, openModal } = useModal(); 22 | 23 | useEffect(() => { 24 | didMount && setReplies(comments); 25 | // eslint-disable-next-line react-hooks/exhaustive-deps 26 | }, [comments]); 27 | 28 | const deleteSuccessCallback = (comment: IComment) => { 29 | if (didMount) { 30 | (updateCommentCallback) && updateCommentCallback(comment); // For updating the base/parent comment 31 | dispatch(setTargetComment(null)); 32 | setReplies(oldComments => oldComments.filter((cmt) => cmt.id !== comment.id)); 33 | } 34 | } 35 | 36 | return ( 37 | 38 | {replies.map(comment => ( 39 | 44 | 45 | 46 | ))} 47 | {/* ---- DELETE MODAL ---- */} 48 | }> 49 | {isOpen && ( 50 | 55 | )} 56 | 57 | 58 | ); 59 | }; 60 | 61 | export default CommentList; 62 | -------------------------------------------------------------------------------- /frontend/src/components/main/FollowButton/index.tsx: -------------------------------------------------------------------------------- 1 | import { CheckOutlined, UserAddOutlined } from "@ant-design/icons"; 2 | import { useEffect, useState } from "react"; 3 | import { useDidMount } from "~/hooks"; 4 | import { followUser, unfollowUser } from "~/services/api"; 5 | 6 | interface IProps { 7 | isFollowing: boolean; 8 | userID: string; 9 | size?: string; 10 | } 11 | 12 | const FollowButton: React.FC = (props) => { 13 | const [isFollowing, setIsFollowing] = useState(props.isFollowing); 14 | const [isLoading, setLoading] = useState(false); 15 | const didMount = useDidMount(); 16 | 17 | useEffect(() => { 18 | setIsFollowing(props.isFollowing); 19 | }, [props.isFollowing]) 20 | 21 | const dispatchFollow = async () => { 22 | try { 23 | setLoading(true); 24 | if (isFollowing) { 25 | const result = await unfollowUser(props.userID); 26 | didMount && setIsFollowing(result.state); 27 | } else { 28 | const result = await followUser(props.userID); 29 | didMount && setIsFollowing(result.state); 30 | } 31 | 32 | didMount && setLoading(false); 33 | } catch (e) { 34 | didMount && setLoading(false); 35 | console.log(e); 36 | } 37 | }; 38 | 39 | return ( 40 | 55 | ); 56 | }; 57 | 58 | export default FollowButton; 59 | -------------------------------------------------------------------------------- /frontend/src/components/main/LikeButton/index.tsx: -------------------------------------------------------------------------------- 1 | import { LikeOutlined } from '@ant-design/icons'; 2 | import { useEffect, useState } from 'react'; 3 | import { useDidMount } from '~/hooks'; 4 | import { likePost } from '~/services/api'; 5 | 6 | interface IProps { 7 | postID: string; 8 | isLiked: boolean; 9 | likeCallback: (postID: string, state: boolean, newLikeCount: number) => void; 10 | } 11 | 12 | const LikeButton: React.FC = (props) => { 13 | const [isLiked, setIsLiked] = useState(props.isLiked); 14 | const [isLoading, setLoading] = useState(false); 15 | const didMount = useDidMount(); 16 | 17 | useEffect(() => { 18 | setIsLiked(props.isLiked); 19 | }, [props.isLiked]); 20 | 21 | const dispatchLike = async () => { 22 | if (isLoading) return; 23 | 24 | try { 25 | setLoading(true); 26 | 27 | const { state, likesCount } = await likePost(props.postID); 28 | if (didMount) { 29 | setLoading(false); 30 | setIsLiked(state); 31 | } 32 | 33 | props.likeCallback(props.postID, state, likesCount); 34 | } catch (e) { 35 | didMount && setLoading(false); 36 | console.log(e); 37 | } 38 | } 39 | 40 | return ( 41 | 45 | 46 | 47 |   48 | {isLiked ? 'Unlike' : 'Like'} 49 | 50 | ); 51 | }; 52 | 53 | export default LikeButton; 54 | -------------------------------------------------------------------------------- /frontend/src/components/main/Modals/ComposeMessageModal.tsx: -------------------------------------------------------------------------------- 1 | import { CloseOutlined, FormOutlined } from '@ant-design/icons'; 2 | import Modal from 'react-modal'; 3 | import { useDispatch } from 'react-redux'; 4 | import { useHistory } from 'react-router-dom'; 5 | import { SearchInput } from '~/components/shared'; 6 | import { initiateChat } from '~/redux/action/chatActions'; 7 | import { IUser } from '~/types/types'; 8 | 9 | interface IProps { 10 | isOpen: boolean; 11 | onAfterOpen?: () => void; 12 | closeModal: () => void; 13 | openModal: () => void; 14 | userID: string; 15 | } 16 | 17 | Modal.setAppElement('#root'); 18 | 19 | const ComposeMessageModal: React.FC = (props) => { 20 | const dispatch = useDispatch(); 21 | const history = useHistory(); 22 | 23 | const clickSearchResultCallback = (user: IUser) => { 24 | if (props.userID === user.id) return; 25 | dispatch(initiateChat(user)); 26 | props.closeModal(); 27 | 28 | if (window.screen.width < 800) { 29 | history.push(`/chat/${user.username}`); 30 | } 31 | } 32 | 33 | return ( 34 | 43 |
44 |
48 | 49 |
50 |

51 | 52 | Compose Message 53 |

54 |
55 |

To:

56 | 62 |
63 |
64 | 65 |
66 | ); 67 | }; 68 | 69 | export default ComposeMessageModal; 70 | -------------------------------------------------------------------------------- /frontend/src/components/main/Modals/ImageLightbox.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | import Lightbox from 'react-image-lightbox'; 3 | 4 | interface IProps { 5 | images: string[]; 6 | isOpen: boolean; 7 | activeIndex: number; 8 | closeLightbox: () => void; 9 | } 10 | 11 | const ImageLightbox: React.FC = (props): React.ReactElement | null => { 12 | const { images, isOpen, closeLightbox, activeIndex } = props; 13 | const [imgIndex, setImgIndex] = useState(activeIndex); 14 | 15 | useEffect(() => { 16 | setImgIndex(activeIndex); 17 | }, [activeIndex]); 18 | 19 | const onNext = () => { 20 | setImgIndex((imgIndex + 1) % images.length); 21 | } 22 | 23 | const onPrev = () => { 24 | setImgIndex((imgIndex + images.length - 1) % images.length); 25 | } 26 | 27 | return isOpen ? ( 28 |
29 | 45 |
46 | ) : null; 47 | }; 48 | 49 | export default ImageLightbox; 50 | -------------------------------------------------------------------------------- /frontend/src/components/main/Modals/LogoutModal.tsx: -------------------------------------------------------------------------------- 1 | import { CloseOutlined } from '@ant-design/icons'; 2 | import Modal from 'react-modal'; 3 | import { IError } from '~/types/types'; 4 | 5 | interface IProps { 6 | isOpen: boolean; 7 | onAfterOpen?: () => void; 8 | closeModal: () => void; 9 | openModal: () => void; 10 | dispatchLogout: () => void; 11 | isLoggingOut: boolean; 12 | error: IError; 13 | } 14 | 15 | Modal.setAppElement('#root'); 16 | 17 | const LogoutModal: React.FC = (props) => { 18 | const onCloseModal = () => { 19 | if (!props.isLoggingOut) { 20 | props.closeModal(); 21 | } 22 | } 23 | 24 | return ( 25 | 34 |
35 |
39 | 40 |
41 | {props.error && ( 42 | 43 | {props.error?.error?.message || 'Unable to process your request.'} 44 | 45 | )} 46 |
47 |

Confirm Logout

48 |

Are you sure you want to logout?

49 |
50 | 57 | 64 |
65 |
66 |
67 | 68 |
69 | ); 70 | }; 71 | 72 | export default LogoutModal; 73 | -------------------------------------------------------------------------------- /frontend/src/components/main/Modals/PostModals.tsx: -------------------------------------------------------------------------------- 1 | import { LoadingOutlined } from "@ant-design/icons"; 2 | import { lazy, Suspense } from "react"; 3 | import { IPost } from "~/types/types"; 4 | 5 | const EditPostModal = lazy(() => import('./EditPostModal')); 6 | const PostLikesModal = lazy(() => import('./PostLikesModal')); 7 | const DeletePostModal = lazy(() => import('./DeletePostModal')); 8 | 9 | interface IProps { 10 | deleteSuccessCallback: (postID: string) => void; 11 | updateSuccessCallback: (post: IPost) => void; 12 | } 13 | 14 | const PostModals: React.FC = (props) => { 15 | return ( 16 | }> 17 | 18 | 19 | 20 | 21 | ); 22 | } 23 | 24 | export default PostModals; 25 | -------------------------------------------------------------------------------- /frontend/src/components/main/Modals/index.ts: -------------------------------------------------------------------------------- 1 | export { default as ComposeMessageModal } from './ComposeMessageModal'; 2 | export { default as CreatePostModal } from './CreatePostModal'; 3 | export { default as CropProfileModal } from './CropProfileModal'; 4 | export { default as DeleteCommentModal } from './DeleteCommentModal'; 5 | export { default as DeletePostModal } from './DeletePostModal'; 6 | export { default as EditPostModal } from './EditPostModal'; 7 | export { default as ImageLightbox } from './ImageLightbox'; 8 | export { default as LogoutModal } from './LogoutModal'; 9 | export { default as PostLikesModal } from './PostLikesModal'; 10 | export { default as PostModals } from './PostModals'; 11 | 12 | -------------------------------------------------------------------------------- /frontend/src/components/main/Options/CommentOptions.tsx: -------------------------------------------------------------------------------- 1 | import { DeleteOutlined, EditOutlined, EllipsisOutlined } from '@ant-design/icons'; 2 | import { useEffect, useRef, useState } from 'react'; 3 | import { useDispatch } from 'react-redux'; 4 | import { setTargetComment } from '~/redux/action/helperActions'; 5 | import { IComment } from '~/types/types'; 6 | 7 | interface IProps { 8 | comment: IComment; 9 | onClickEdit: () => void; 10 | openDeleteModal: () => void; 11 | } 12 | 13 | const CommentOptions: React.FC = (props) => { 14 | const [isOpen, setIsOpen] = useState(false); 15 | const isOpenRef = useRef(isOpen); 16 | const dispatch = useDispatch(); 17 | 18 | useEffect(() => { 19 | document.addEventListener('click', handleClickOutside); 20 | 21 | return () => { 22 | document.removeEventListener('click', handleClickOutside); 23 | } 24 | // eslint-disable-next-line react-hooks/exhaustive-deps 25 | }, []); 26 | 27 | useEffect(() => { 28 | isOpenRef.current = isOpen; 29 | }, [isOpen]); 30 | 31 | const handleClickOutside = (e: Event) => { 32 | const option = (e.target as HTMLDivElement).closest(`#comment_${props.comment.id}`); 33 | 34 | if (!option && isOpenRef.current) { 35 | setIsOpen(false); 36 | } 37 | } 38 | 39 | const toggleOpen = () => { 40 | setIsOpen(!isOpen); 41 | } 42 | 43 | const onClickDelete = () => { 44 | dispatch(setTargetComment(props.comment)); 45 | props.openDeleteModal(); 46 | } 47 | 48 | const onClickEdit = () => { 49 | setIsOpen(false); 50 | 51 | props.onClickEdit(); 52 | dispatch(setTargetComment(props.comment)); 53 | } 54 | 55 | return ( 56 |
57 |
61 | 62 |
63 | {isOpen && ( 64 |
65 | {props.comment.isOwnComment && ( 66 |

70 | 71 | Edit Comment 72 |

73 | )} 74 |

78 | 79 | Delete Comment 80 |

81 |
82 | )} 83 |
84 | ); 85 | }; 86 | 87 | export default CommentOptions; 88 | -------------------------------------------------------------------------------- /frontend/src/components/main/Options/index.ts: -------------------------------------------------------------------------------- 1 | export { default as CommentOptions } from './CommentOptions'; 2 | export { default as PostOptions } from './PostOptions'; 3 | -------------------------------------------------------------------------------- /frontend/src/components/main/SuggestedPeople/index.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | import { Link } from "react-router-dom"; 3 | import { FollowButton } from "~/components/main"; 4 | import { Avatar, UserLoader } from "~/components/shared"; 5 | import { SUGGESTED_PEOPLE } from "~/constants/routes"; 6 | import { getSuggestedPeople } from "~/services/api"; 7 | import { IError, IProfile } from "~/types/types"; 8 | 9 | const SuggestedPeople: React.FC = () => { 10 | const [people, setPeople] = useState([]); 11 | const [isLoading, setIsLoading] = useState(false); 12 | const [error, setError] = useState(null); 13 | 14 | useEffect(() => { 15 | (async function () { 16 | try { 17 | setIsLoading(true); 18 | const users = await getSuggestedPeople({ offset: 0, limit: 6 }); 19 | 20 | setPeople(users); 21 | setIsLoading(false); 22 | } catch (e) { 23 | setIsLoading(false); 24 | setError(e); 25 | } 26 | })(); 27 | }, []); 28 | 29 | return ( 30 |
31 |
32 |

Suggested People

33 | See all 34 |
35 | {isLoading && ( 36 |
37 | 38 | 39 | 40 | 41 |
42 | )} 43 | {(!isLoading && error) && ( 44 |
45 | 46 | {(error as IError)?.error?.message || 'Something went wrong :('} 47 | 48 |
49 | )} 50 | {!error && people.map((user) => ( 51 |
52 |
53 | 54 |
55 | 56 |
{user.username}
57 |
58 | 59 |
60 | 65 |
66 |
67 |
68 | ))} 69 |
70 | ); 71 | }; 72 | 73 | export default SuggestedPeople; 74 | -------------------------------------------------------------------------------- /frontend/src/components/main/UserCard/index.tsx: -------------------------------------------------------------------------------- 1 | import { useSelector } from "react-redux"; 2 | import { Link } from "react-router-dom"; 3 | import { FollowButton } from '~/components/main'; 4 | import { Avatar } from "~/components/shared"; 5 | import { IProfile, IRootReducer, IUser } from "~/types/types"; 6 | 7 | interface IProps { 8 | profile: IProfile | IUser; 9 | } 10 | 11 | const UserCard: React.FC = ({ profile }) => { 12 | const myUsername = useSelector((state: IRootReducer) => state.auth.username); 13 | 14 | return ( 15 |
16 | 17 |
18 | 19 |
@{profile.username}
20 |
21 | 22 |
23 | {profile.username === myUsername ? ( 24 |

Me

25 | ) : ( 26 | 27 | )} 28 |
29 |
30 | ); 31 | }; 32 | 33 | export default UserCard; 34 | -------------------------------------------------------------------------------- /frontend/src/components/main/index.ts: -------------------------------------------------------------------------------- 1 | export { default as BookmarkButton } from './BookmarkButton'; 2 | export * from './Chats'; 3 | export { default as Comments } from './Comments'; 4 | export { default as FollowButton } from './FollowButton'; 5 | export { default as LikeButton } from './LikeButton'; 6 | export { default as Messages } from './Messages'; 7 | export * from './Modals'; 8 | export { default as Notification } from './Notification'; 9 | export * from './Options'; 10 | export { default as PostItem } from './PostItem'; 11 | export { default as SuggestedPeople } from './SuggestedPeople'; 12 | export { default as UserCard } from './UserCard'; 13 | 14 | -------------------------------------------------------------------------------- /frontend/src/components/shared/Avatar.tsx: -------------------------------------------------------------------------------- 1 | import placeholder from '~/images/avatar_placeholder.png'; 2 | 3 | interface IProps { 4 | url?: string; 5 | size?: string; 6 | className?: string; 7 | } 8 | 9 | const Avatar: React.FC = ({ url, size, className }) => { 10 | return ( 11 |
22 | ) 23 | }; 24 | 25 | Avatar.defaultProps = { 26 | size: 'md' 27 | } 28 | 29 | export default Avatar; 30 | -------------------------------------------------------------------------------- /frontend/src/components/shared/Badge.tsx: -------------------------------------------------------------------------------- 1 | interface IProps { 2 | children?: React.ReactNode, 3 | count: number; 4 | } 5 | 6 | const Badge: React.FC = ({ children, count = 0 }) => { 7 | return ( 8 |
9 | {count > 0 && ( 10 |
11 | {count} 12 |
13 | )} 14 | {children && children} 15 |
16 | ); 17 | }; 18 | 19 | export default Badge; 20 | -------------------------------------------------------------------------------- /frontend/src/components/shared/Boundary.tsx: -------------------------------------------------------------------------------- 1 | import { Component } from 'react'; 2 | 3 | class Boundary extends Component { 4 | static getDerivedStateFromError(error: any) { 5 | return { hasError: true }; 6 | } 7 | 8 | state = { 9 | hasError: false 10 | }; 11 | 12 | 13 | componentDidCatch(error: any, errorInfo: any) { 14 | console.log(error); 15 | } 16 | 17 | render() { 18 | if (this.state.hasError) { 19 | return ( 20 |
21 |

:( Something went wrong.

22 |
23 | ); 24 | } 25 | 26 | return this.props.children; 27 | } 28 | } 29 | 30 | export default Boundary; -------------------------------------------------------------------------------- /frontend/src/components/shared/Loader.tsx: -------------------------------------------------------------------------------- 1 | 2 | const Loader: React.FC<{ mode?: string; size?: string }> = ({ mode, size }) => { 3 | return ( 4 |
5 |
12 |
19 |
26 |
27 | ); 28 | }; 29 | 30 | Loader.defaultProps = { 31 | mode: 'dark', 32 | size: 'sm' 33 | } 34 | 35 | export default Loader; 36 | -------------------------------------------------------------------------------- /frontend/src/components/shared/Preloader.tsx: -------------------------------------------------------------------------------- 1 | import { LoadingOutlined } from '@ant-design/icons'; 2 | import React from 'react'; 3 | import logo from '~/images/logo.svg'; 4 | 5 | const Preloader = () => ( 6 |
7 | Foodie Logo 8 |

Nothing brings people together like good food.

9 | 10 |
11 | ); 12 | 13 | export default Preloader; 14 | -------------------------------------------------------------------------------- /frontend/src/components/shared/SocialLogin.tsx: -------------------------------------------------------------------------------- 1 | import { FacebookFilled, GithubFilled, GoogleOutlined } from "@ant-design/icons"; 2 | 3 | const SocialLogin: React.FC<{ isLoading: boolean; }> = ({ isLoading }) => { 4 | const onClickSocialLogin = (e: React.MouseEvent) => { 5 | if (isLoading) e.preventDefault(); 6 | } 7 | 8 | return ( 9 | 35 | ) 36 | }; 37 | 38 | export default SocialLogin; 39 | -------------------------------------------------------------------------------- /frontend/src/components/shared/ThemeToggler.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | import { useDispatch, useSelector } from 'react-redux'; 3 | import { setTheme } from '~/redux/action/settingsActions'; 4 | import { IRootReducer } from '~/types/types'; 5 | 6 | const ThemeToggler = () => { 7 | const { theme } = useSelector((state: IRootReducer) => ({ theme: state.settings.theme })); 8 | const dispatch = useDispatch(); 9 | 10 | useEffect(() => { 11 | const root = document.documentElement; 12 | 13 | if (theme === 'dark') { 14 | root.classList.add('dark'); 15 | } else { 16 | root.classList.remove('dark'); 17 | } 18 | }, [theme]); 19 | 20 | const onThemeChange = (e: React.ChangeEvent) => { 21 | if (e.target.checked) { 22 | dispatch(setTheme('dark')); 23 | } else { 24 | dispatch(setTheme('light')); 25 | } 26 | } 27 | 28 | return ( 29 | 57 | ); 58 | }; 59 | 60 | export default ThemeToggler; -------------------------------------------------------------------------------- /frontend/src/components/shared/index.ts: -------------------------------------------------------------------------------- 1 | export { default as Avatar } from './Avatar'; 2 | export { default as Badge } from './Badge'; 3 | export { default as Boundary } from './Boundary'; 4 | export { default as ImageGrid } from './ImageGrid'; 5 | export { default as Loader } from './Loader'; 6 | export * from './Loaders'; 7 | export { default as NavBar } from './NavBar'; 8 | export { default as NavBarMobile } from './NavBarMobile'; 9 | export { default as Preloader } from './Preloader'; 10 | export { default as SearchInput } from './SearchInput'; 11 | export { default as SocialLogin } from './SocialLogin'; 12 | export { default as ThemeToggler } from './ThemeToggler'; 13 | -------------------------------------------------------------------------------- /frontend/src/constants/actionType.ts: -------------------------------------------------------------------------------- 1 | // ---- AUTH CONSTANTS 2 | export const LOGIN_START = 'LOGIN_START'; 3 | export const LOGIN_SUCCESS = 'LOGIN_SUCCESS'; 4 | export const CHECK_SESSION = 'CHECK_SESSION'; 5 | export const LOGOUT_START = 'LOGOUT_START'; 6 | export const LOGOUT_SUCCESS = 'LOGOUT_SUCCESS'; 7 | export const REGISTER_START = 'REGISTER_START'; 8 | export const REGISTER_SUCCESS = 'REGISTER_SUCCESS'; 9 | 10 | // ---- ERROR CONSTANTS 11 | export const SET_AUTH_ERR_MSG = 'SET_AUTH_ERR_MSG'; 12 | export const CLEAR_AUTH_ERR_MSG = 'CLEAR_AUTH_ERR_MSG'; 13 | export const SET_PROFILE_ERR_MSG = 'SET_PROFILE_ERR_MSG'; 14 | export const SET_NEWSFEED_ERR_MSG = 'SET_NEWSFEED_ERR_MSG'; 15 | 16 | // ---- LOADING CONSTANTS 17 | export const SET_AUTH_LOADING = 'SET_AUTH_LOADING'; 18 | export const SET_CREATE_POST_LOADING = 'SET_CREATE_POST_LOADING'; 19 | export const SET_GET_USER_LOADING = 'SET_GET_USER_LOADING'; 20 | export const SET_GET_FEED_LOADING = 'SET_GET_FEED_LOADING'; 21 | 22 | // ---- FEED CONSTANTS 23 | export const GET_FEED_START = 'GET_FEED_START'; 24 | export const GET_FEED_SUCCESS = 'GET_FEED_SUCCESS'; 25 | export const CREATE_POST_START = 'CREATE_POST_START'; 26 | export const CREATE_POST_SUCCESS = 'CREATE_POST_SUCCESS'; 27 | export const UPDATE_FEED_POST = 'UPDATE_FEED_POST'; 28 | export const DELETE_FEED_POST = 'DELETE_FEED_POST'; 29 | export const UPDATE_USER_POST = 'UPDATE_USER_POST'; 30 | export const CLEAR_FEED = 'CLEAR_FEED'; 31 | export const HAS_NEW_FEED = 'HAS_NEW_FEED'; 32 | export const UPDATE_POST_LIKES = 'UPDATE_POST_LIKES'; 33 | 34 | // ---- PROFILE CONSTANTS 35 | export const UPDATE_PROFILE_INFO = 'UPDATE_PROFILE_INFO'; 36 | export const UPDATE_PROFILE_PICTURE = 'UPDATE_PROFILE_PICTURE'; 37 | export const UPDATE_COVER_PHOTO = 'UPDATE_COVER_PHOTO'; 38 | export const UPDATE_AUTH_PICTURE = 'UPDATE_AUTH_PICTURE'; 39 | export const GET_USER_START = 'GET_USER_START'; 40 | export const GET_USER_SUCCESS = 'GET_USER_SUCCESS'; 41 | 42 | // ---- CHAT CONSTANTS 43 | export const INITIATE_CHAT = 'INITIATE_CHAT'; 44 | export const MINIMIZE_CHAT = 'MINIMIZE_CHAT'; 45 | export const CLOSE_CHAT = 'CLOSE_CHAT'; 46 | export const CLEAR_CHAT = 'CLEAR_CHAT'; 47 | export const GET_MESSAGES_SUCCESS = 'GET_MESSAGES_SUCCESS'; 48 | export const NEW_MESSAGE_ARRIVED = 'NEW_MESSAGE_ARRIVED'; 49 | 50 | // ---- SETTINGS CONSTANTS 51 | export const SET_THEME = 'SET_THEME'; 52 | 53 | // ---- HELPERS CONSTANTS 54 | export const SET_TARGET_COMMENT = 'SET_TARGET_COMMENT_ID'; 55 | export const SET_TARGET_POST = 'SET_TARGET_POST'; 56 | 57 | // ---- MODAL CONSTANTS 58 | export const SHOW_MODAL = 'SHOW_MODAL'; 59 | export const HIDE_MODAL = 'HIDE_MODAL'; -------------------------------------------------------------------------------- /frontend/src/constants/routes.ts: -------------------------------------------------------------------------------- 1 | export const LOGIN = '/login'; 2 | export const REGISTER = '/register'; 3 | export const HOME = '/'; 4 | export const POST = '/post/:post_id'; 5 | export const PROFILE = '/user/:username'; 6 | export const PROFILE_INFO = '/user/:username/info'; 7 | export const PROFILE_EDIT_INFO = '/user/:username/edit'; 8 | export const PROFILE_FOLLOWERS = '/user/:username/followers'; 9 | export const PROFILE_FOLLOWING = '/user/:username/following'; 10 | export const PROFILE_BOOKMARKS = '/user/:username/bookmarks'; 11 | export const SEARCH = '/search'; 12 | export const CHAT = '/chat/:username'; 13 | export const SUGGESTED_PEOPLE = '/suggested'; 14 | 15 | export const SOCIAL_AUTH_FAILED = '/auth/:provider/failed'; -------------------------------------------------------------------------------- /frontend/src/helpers/cropImage.tsx: -------------------------------------------------------------------------------- 1 | const createImage = (url: string): Promise => 2 | new Promise((resolve, reject) => { 3 | const image = new Image() 4 | image.addEventListener('load', () => resolve(image)) 5 | image.addEventListener('error', error => reject(error)) 6 | image.setAttribute('crossOrigin', 'anonymous') // needed to avoid cross-origin issues on CodeSandbox 7 | image.src = url 8 | }) 9 | 10 | function getRadianAngle(degreeValue: number): number { 11 | return (degreeValue * Math.PI) / 180 12 | } 13 | 14 | interface IPixelCrop { 15 | width: number; 16 | height: number; 17 | x: number; 18 | y: number; 19 | } 20 | 21 | export default async function getCroppedImg(imageSrc: string, pixelCrop: IPixelCrop | null, rotation = 0): Promise { 22 | const image: HTMLImageElement = await createImage(imageSrc) 23 | const canvas = document.createElement('canvas') 24 | const ctx: CanvasRenderingContext2D | null = canvas.getContext('2d') 25 | 26 | const maxSize = Math.max(image.width, image.height) 27 | const safeArea = 2 * ((maxSize / 2) * Math.sqrt(2)) 28 | 29 | if (!ctx || !pixelCrop) return; 30 | // set each dimensions to double largest dimension to allow for a safe area for the 31 | // image to rotate in without being clipped by canvas context 32 | canvas.width = safeArea 33 | canvas.height = safeArea 34 | 35 | // translate canvas context to a central location on image to allow rotating around the center. 36 | ctx.translate(safeArea / 2, safeArea / 2) 37 | ctx.rotate(getRadianAngle(rotation)) 38 | ctx.translate(-safeArea / 2, -safeArea / 2) 39 | 40 | // draw rotated image and store data. 41 | ctx.drawImage( 42 | image, 43 | safeArea / 2 - image.width * 0.5, 44 | safeArea / 2 - image.height * 0.5 45 | ) 46 | const data = ctx.getImageData(0, 0, safeArea, safeArea) 47 | 48 | // set canvas width to final desired crop size - this will clear existing context 49 | canvas.width = pixelCrop.width 50 | canvas.height = pixelCrop.height 51 | 52 | // paste generated rotate image with correct offsets for x,y crop values. 53 | ctx.putImageData( 54 | data, 55 | Math.round(0 - safeArea / 2 + image.width * 0.5 - pixelCrop.x), 56 | Math.round(0 - safeArea / 2 + image.height * 0.5 - pixelCrop.y) 57 | ) 58 | 59 | // As Base64 string 60 | // return canvas.toDataURL('image/jpeg'); 61 | 62 | // As a blob 63 | return new Promise(resolve => { 64 | canvas.toBlob(file => { 65 | resolve({ url: URL.createObjectURL(file), blob: file }); 66 | }, 'image/jpeg') 67 | }) 68 | } 69 | -------------------------------------------------------------------------------- /frontend/src/helpers/utils.tsx: -------------------------------------------------------------------------------- 1 | import dayjs from 'dayjs'; 2 | import relativeTime from 'dayjs/plugin/relativeTime'; 3 | 4 | dayjs.extend(relativeTime); 5 | 6 | export const displayTime = (createdAt: string | Date, showTime = false) => { 7 | const now = dayjs(); 8 | const created = dayjs(createdAt); 9 | const oneDay = 24 * 60 * 60 * 1000; 10 | const twelveHours = 12 * 60 * 60 * 1000; 11 | const timeDisplay = !showTime ? '' : ` | ${dayjs(createdAt).format('hh:mm a')}`; 12 | 13 | if (now.diff(created) < twelveHours) { 14 | return dayjs(createdAt).fromNow(); 15 | } else if (now.diff(created) < oneDay) { 16 | return `${dayjs(createdAt).fromNow()} ${timeDisplay}`; 17 | } else if (now.diff(createdAt, 'year') >= 1) { 18 | return `${dayjs(createdAt).format('MMM. DD, YYYY')} ${timeDisplay}`; 19 | } else { 20 | return `${dayjs(createdAt).format('MMM. DD')} ${timeDisplay}`; 21 | } 22 | } -------------------------------------------------------------------------------- /frontend/src/hooks/index.ts: -------------------------------------------------------------------------------- 1 | export { default as useDidMount } from './useDidMount'; 2 | export { default as useDocumentTitle } from './useDocumentTitle'; 3 | export { default as useFileHandler } from './useFileHandler'; 4 | export { default as useModal } from './useModal'; 5 | -------------------------------------------------------------------------------- /frontend/src/hooks/useDidMount.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | 3 | const useDidMount = (initState = false) => { 4 | const [didMount, setDidMount] = useState(initState); 5 | useEffect(() => { 6 | setDidMount(true); 7 | 8 | return () => { 9 | setDidMount(false); 10 | } 11 | }, []); 12 | 13 | return didMount; 14 | }; 15 | 16 | export default useDidMount; -------------------------------------------------------------------------------- /frontend/src/hooks/useDocumentTitle.tsx: -------------------------------------------------------------------------------- 1 | import { useLayoutEffect } from 'react'; 2 | 3 | const useDocumentTitle = (title: string) => { 4 | useLayoutEffect(() => { 5 | if (title) { 6 | document.title = title; 7 | } else { 8 | document.title = 'Foodie | Social Network'; 9 | } 10 | }, [title]); 11 | }; 12 | 13 | export default useDocumentTitle; 14 | -------------------------------------------------------------------------------- /frontend/src/hooks/useFileHandler.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from 'react'; 2 | import { toast } from 'react-toastify'; 3 | import { IFileHandler, IImage } from '~/types/types'; 4 | 5 | const useFileHandler = (type = "multiple", initState: T): IFileHandler => { 6 | const [imageFile, setImageFile] = useState(initState); 7 | const [isFileLoading, setFileLoading] = useState(false); 8 | 9 | const removeImage = (id: string) => { 10 | if (!Array.isArray(imageFile)) return; 11 | 12 | const items = imageFile.filter(item => item.id !== id); 13 | 14 | setImageFile(items as T); 15 | }; 16 | 17 | const clearFiles = () => { 18 | setImageFile(initState as T); 19 | } 20 | 21 | const onFileChange = (event: React.ChangeEvent, callback?: (file?: IImage) => void) => { 22 | if (!event.target.files) return; 23 | if ((event.target.files.length + (imageFile as IImage[]).length) > 5) { 24 | return toast.error('Maximum of 5 photos per post allowed.', { hideProgressBar: true }); 25 | } 26 | 27 | // TODO === FILTER OUT DUPLICATE IMAGES 28 | 29 | const val = event.target.value; 30 | const img = event.target.files[0] as File; 31 | 32 | if (!img) return; 33 | 34 | const size = img.size / 1024 / 1024; 35 | const regex = /(\.jpg|\.jpeg|\.png)$/i; 36 | 37 | setFileLoading(true); 38 | if (!regex.exec(val)) { 39 | toast.error('File type must be JPEG or PNG', { hideProgressBar: true }); 40 | setFileLoading(false); 41 | } else if (size > 2) { 42 | toast.error('File size exceeded 2mb', { hideProgressBar: true }); 43 | setFileLoading(false); 44 | } else if (type === 'single') { 45 | const file = event.target.files[0] as File; 46 | const url = URL.createObjectURL(file); 47 | setImageFile({ 48 | file, 49 | url, 50 | id: file.name 51 | } as T); 52 | if (callback) callback(imageFile as IImage); 53 | } else { 54 | Array.from(event.target.files).forEach((file) => { 55 | const url = URL.createObjectURL(file); 56 | setImageFile((oldFiles) => ([...oldFiles as any, { 57 | file, 58 | url, 59 | id: file.name 60 | }] as T)); 61 | }); 62 | if (callback) callback(imageFile as IImage); 63 | setFileLoading(false); 64 | } 65 | }; 66 | 67 | return { 68 | imageFile, 69 | setImageFile, 70 | isFileLoading, 71 | onFileChange, 72 | removeImage, 73 | clearFiles 74 | }; 75 | }; 76 | 77 | export default useFileHandler; -------------------------------------------------------------------------------- /frontend/src/hooks/useModal.tsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | 3 | const useModal = () => { 4 | const [isOpen, setOpen] = useState(false); 5 | 6 | const openModal = () => { 7 | setOpen(true); 8 | } 9 | 10 | const closeModal = () => { 11 | setOpen(false); 12 | }; 13 | 14 | return { isOpen, openModal, closeModal }; 15 | }; 16 | 17 | export default useModal; -------------------------------------------------------------------------------- /frontend/src/images/SVG/logoAsset 1.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /frontend/src/images/avatar_placeholder.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jgudo/foodie/84f5e6d81f48e9cd0149ec07a2611049a852746a/frontend/src/images/avatar_placeholder.png -------------------------------------------------------------------------------- /frontend/src/images/friends_meal.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jgudo/foodie/84f5e6d81f48e9cd0149ec07a2611049a852746a/frontend/src/images/friends_meal.jpg -------------------------------------------------------------------------------- /frontend/src/images/friends_meal_2.webp: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jgudo/foodie/84f5e6d81f48e9cd0149ec07a2611049a852746a/frontend/src/images/friends_meal_2.webp -------------------------------------------------------------------------------- /frontend/src/images/screen1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jgudo/foodie/84f5e6d81f48e9cd0149ec07a2611049a852746a/frontend/src/images/screen1.png -------------------------------------------------------------------------------- /frontend/src/images/screen2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jgudo/foodie/84f5e6d81f48e9cd0149ec07a2611049a852746a/frontend/src/images/screen2.png -------------------------------------------------------------------------------- /frontend/src/images/screen3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jgudo/foodie/84f5e6d81f48e9cd0149ec07a2611049a852746a/frontend/src/images/screen3.png -------------------------------------------------------------------------------- /frontend/src/images/screen4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jgudo/foodie/84f5e6d81f48e9cd0149ec07a2611049a852746a/frontend/src/images/screen4.png -------------------------------------------------------------------------------- /frontend/src/images/thumbnail.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jgudo/foodie/84f5e6d81f48e9cd0149ec07a2611049a852746a/frontend/src/images/thumbnail.jpg -------------------------------------------------------------------------------- /frontend/src/index.css: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jgudo/foodie/84f5e6d81f48e9cd0149ec07a2611049a852746a/frontend/src/index.css -------------------------------------------------------------------------------- /frontend/src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import 'react-image-lightbox/style.css'; 4 | import { Provider } from 'react-redux'; 5 | import store from '~/redux/store/store'; 6 | import '~/styles/app.css'; 7 | import App from './App'; 8 | import reportWebVitals from './reportWebVitals'; 9 | 10 | ReactDOM.render( 11 | 12 | 13 | 14 | 15 | , 16 | document.getElementById('root') 17 | ); 18 | 19 | // If you want to start measuring performance in your app, pass a function 20 | // to log results (for example: reportWebVitals(console.log)) 21 | // or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals 22 | reportWebVitals(); 23 | -------------------------------------------------------------------------------- /frontend/src/pages/chat/index.tsx: -------------------------------------------------------------------------------- 1 | import { useSelector } from "react-redux"; 2 | import { RouteComponentProps } from "react-router-dom"; 3 | import { ChatBox } from "~/components/main"; 4 | import { PageNotFound } from "~/pages"; 5 | import { IRootReducer } from "~/types/types"; 6 | 7 | const Chat: React.FC> = ({ match }) => { 8 | const { username } = match.params; 9 | const { target, user } = useSelector((state: IRootReducer) => ({ 10 | target: state.chats.items.find(chat => chat.username === username), 11 | user: state.auth 12 | })); 13 | 14 | return !target ? : ( 15 |
16 | 20 |
21 | ); 22 | }; 23 | 24 | export default Chat; 25 | -------------------------------------------------------------------------------- /frontend/src/pages/error/PageNotFound.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Link } from 'react-router-dom'; 3 | import { useDocumentTitle } from '~/hooks'; 4 | 5 | const PageNotFound: React.FC = () => { 6 | useDocumentTitle('Page Not Found'); 7 | 8 | return ( 9 |
10 |

Uh oh, you seemed lost.

11 |

The page you're trying to visit doesn't exist.

12 |
13 | 17 | Go to News Feed 18 | 19 |
20 | ); 21 | }; 22 | 23 | export default PageNotFound; 24 | -------------------------------------------------------------------------------- /frontend/src/pages/error/index.ts: -------------------------------------------------------------------------------- 1 | export { default as PageNotFound } from './PageNotFound'; 2 | -------------------------------------------------------------------------------- /frontend/src/pages/home/SideMenu.tsx: -------------------------------------------------------------------------------- 1 | import { StarOutlined, TeamOutlined } from "@ant-design/icons"; 2 | import { Link } from "react-router-dom"; 3 | import { Avatar } from "~/components/shared"; 4 | 5 | interface IProps { 6 | username: string; 7 | profilePicture?: string; 8 | } 9 | 10 | const SideMenu: React.FC = ({ username, profilePicture }) => { 11 | return ( 12 |
    13 |
  • 14 | 15 | 16 |
    My Profile
    17 | 18 |
  • 19 |
  • 20 | 21 | 22 |
    Following
    23 | 24 |
  • 25 |
  • 26 | 27 | 28 |
    Followers
    29 | 30 |
  • 31 |
  • 32 | 33 | 34 |
    Bookmarks
    35 | 36 |
  • 37 |
38 | ) 39 | }; 40 | 41 | export default SideMenu; 42 | -------------------------------------------------------------------------------- /frontend/src/pages/index.ts: -------------------------------------------------------------------------------- 1 | export { default as Chat } from './chat'; 2 | export * from './error'; 3 | export { default as Home } from './home'; 4 | export { default as Login } from './login'; 5 | export { default as Post } from './post'; 6 | export { default as Profile } from './profile'; 7 | export * from './redirects'; 8 | export { default as Register } from './register'; 9 | export { default as Search } from './search'; 10 | export { default as SuggestedPeople } from './suggested_people'; 11 | -------------------------------------------------------------------------------- /frontend/src/pages/post/index.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from 'react'; 2 | import { RouteComponentProps } from 'react-router-dom'; 3 | import { PostItem, PostModals } from '~/components/main'; 4 | import { Loader } from '~/components/shared'; 5 | import { useDocumentTitle } from '~/hooks'; 6 | import { PageNotFound } from '~/pages'; 7 | import { getSinglePost } from '~/services/api'; 8 | import { IError, IPost } from '~/types/types'; 9 | 10 | const Post: React.FC> = ({ history, match }) => { 11 | const [post, setPost] = useState(null); 12 | const [isLoading, setIsLoading] = useState(false); 13 | const [error, setError] = useState(null); 14 | const { post_id } = match.params; 15 | 16 | useDocumentTitle(`${post?.description} - Foodie` || 'View Post'); 17 | useEffect(() => { 18 | fetchPost(); 19 | // eslint-disable-next-line react-hooks/exhaustive-deps 20 | }, []); 21 | 22 | const likeCallback = (postID: string, state: boolean, newLikesCount: number) => { 23 | setPost({ 24 | ...post, 25 | isLiked: state, 26 | likesCount: newLikesCount 27 | } as IPost); 28 | } 29 | 30 | const updateSuccessCallback = (updatedPost: IPost) => { 31 | setPost({ ...post, ...updatedPost }); 32 | } 33 | 34 | const deleteSuccessCallback = () => { 35 | history.push('/'); 36 | } 37 | 38 | const fetchPost = async () => { 39 | try { 40 | setIsLoading(true); 41 | 42 | const fetchedPost = await getSinglePost(post_id); 43 | console.log(fetchedPost); 44 | setIsLoading(false); 45 | setPost(fetchedPost); 46 | } catch (e) { 47 | console.log(e); 48 | setIsLoading(false); 49 | setError(e); 50 | } 51 | }; 52 | 53 | return ( 54 | <> 55 | {(isLoading && !error) && ( 56 |
57 | 58 |
59 | )} 60 | {(!isLoading && !error && post) && ( 61 |
62 | 63 |
64 | )} 65 | {(!isLoading && error) && ( 66 | <> 67 | {error.status_code === 404 ? ( 68 | 69 | ) : ( 70 |
71 |

72 | {error?.error?.message || 'Something went wrong :('} 73 |

74 |
75 | )} 76 | 77 | )} 78 | {/* ----- ALL PSOST MODALS ----- */} 79 | 83 | 84 | ) 85 | }; 86 | 87 | export default Post; 88 | -------------------------------------------------------------------------------- /frontend/src/pages/profile/Header/CoverPhotoOverlay.tsx: -------------------------------------------------------------------------------- 1 | import { CameraOutlined, CloseOutlined } from "@ant-design/icons"; 2 | import Loader from "~/components/shared/Loader"; 3 | import { IFileHandler, IImage } from "~/types/types"; 4 | 5 | interface IProps { 6 | coverPhotoOverlayRef: React.RefObject; 7 | coverPhoto: IFileHandler; 8 | isUploadingCoverPhoto: boolean; 9 | isOwnProfile: boolean; 10 | handleSaveCoverPhoto: () => void; 11 | } 12 | 13 | const CoverPhotoOverlay: React.FC = (props) => { 14 | return ( 15 |
19 | 27 | {props.isOwnProfile && ( 28 | <> 29 | {props.isUploadingCoverPhoto ? : ( 30 | <> 31 | {props.coverPhoto.imageFile.file ? ( 32 |
33 | 36 |   37 | 43 |   44 | 45 |
46 | ) : ( 47 | 55 | 56 | )} 57 | 58 | )} 59 | 60 | )} 61 | 62 |
63 | ); 64 | }; 65 | 66 | export default CoverPhotoOverlay; 67 | -------------------------------------------------------------------------------- /frontend/src/pages/profile/Tabs/Bio.tsx: -------------------------------------------------------------------------------- 1 | import dayjs from 'dayjs'; 2 | 3 | interface IProps { 4 | bio: string; 5 | dateJoined: string | Date; 6 | } 7 | 8 | const Bio: React.FC = ({ bio, dateJoined }) => { 9 | return ( 10 | 26 | ); 27 | }; 28 | 29 | export default Bio; 30 | -------------------------------------------------------------------------------- /frontend/src/pages/profile/Tabs/Followers.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from "react"; 2 | import useInfiniteScroll from "react-infinite-scroll-hook"; 3 | import { CSSTransition, TransitionGroup } from "react-transition-group"; 4 | import { UserCard } from "~/components/main"; 5 | import { Loader, UserLoader } from "~/components/shared"; 6 | import { useDidMount, useDocumentTitle } from "~/hooks"; 7 | import { getFollowers } from "~/services/api"; 8 | import { IError, IProfile } from "~/types/types"; 9 | 10 | interface IProps { 11 | username: string; 12 | } 13 | 14 | const Followers: React.FC = ({ username }) => { 15 | const [followers, setFollowers] = useState([]); 16 | const [isLoading, setIsLoading] = useState(false); 17 | const [offset, setOffset] = useState(0); // Pagination 18 | const didMount = useDidMount(true); 19 | const [error, setError] = useState(null); 20 | 21 | useDocumentTitle(`Followers - ${username} | Foodie`); 22 | useEffect(() => { 23 | fetchFollowers(); 24 | // eslint-disable-next-line react-hooks/exhaustive-deps 25 | }, []); 26 | 27 | const fetchFollowers = async () => { 28 | try { 29 | setIsLoading(true); 30 | const fetchedFollowers = await getFollowers(username, { offset }); 31 | 32 | if (didMount) { 33 | setFollowers([...followers, ...fetchedFollowers]); 34 | setIsLoading(false); 35 | setOffset(offset + 1); 36 | 37 | setError(null); 38 | } 39 | } catch (e) { 40 | if (didMount) { 41 | setIsLoading(false); 42 | setError(e) 43 | } 44 | console.log(e); 45 | } 46 | }; 47 | 48 | const infiniteRef = useInfiniteScroll({ 49 | loading: isLoading, 50 | hasNextPage: !error && followers.length >= 10, 51 | onLoadMore: fetchFollowers, 52 | scrollContainer: 'window', 53 | threshold: 200 54 | }); 55 | 56 | return ( 57 |
}> 58 | {(isLoading && followers.length === 0) && ( 59 |
60 | 61 | 62 | 63 | 64 |
65 | )} 66 | {(!isLoading && followers.length === 0 && error) && ( 67 |
68 |
{error?.error?.message || 'Something went wrong.'}
69 |
70 | )} 71 | {followers.length !== 0 && ( 72 |
73 |

Followers

74 | 75 | {followers.map(user => ( 76 | 81 |
82 | 83 |
84 |
85 | ))} 86 |
87 | {(followers.length !== 0 && !error && isLoading) && ( 88 |
89 | 90 |
91 | )} 92 |
93 | )} 94 |
95 | ); 96 | }; 97 | 98 | export default Followers; 99 | -------------------------------------------------------------------------------- /frontend/src/pages/profile/Tabs/Info.tsx: -------------------------------------------------------------------------------- 1 | import dayjs from 'dayjs'; 2 | import { useSelector } from "react-redux"; 3 | import { useHistory } from "react-router-dom"; 4 | import { useDocumentTitle } from '~/hooks'; 5 | import { IRootReducer } from "~/types/types"; 6 | 7 | const Info = () => { 8 | const { profile, isOwnProfile } = useSelector((state: IRootReducer) => ({ 9 | profile: state.profile, 10 | isOwnProfile: state.auth.username === state.profile.username 11 | })); 12 | const history = useHistory(); 13 | useDocumentTitle(`Info - ${profile.username} | Foodie`); 14 | 15 | return ( 16 |
17 |
18 |

Info

19 | {isOwnProfile && ( 20 | history.push(`/user/${profile.username}/edit`)} 23 | > 24 | Edit 25 | 26 | )} 27 |
28 |
29 |
30 |
Full Name
31 | {profile.fullname ? ( 32 | {profile.fullname} 33 | ) : ( 34 | Name not set. 35 | )} 36 |
37 |
38 |
Gender
39 | {profile.info.gender ? ( 40 | {profile.info.gender} 41 | ) : ( 42 | Gender not set. 43 | )} 44 |
45 |
46 |
Birthday
47 | {profile.info.birthday ? ( 48 | {dayjs(profile.info.birthday).format('MMM.DD, YYYY')} 49 | ) : ( 50 | Birthday not set. 51 | )} 52 |
53 |
54 |
Bio
55 | {profile.info.bio ? ( 56 | {profile.info.bio} 57 | ) : ( 58 | Bio not set. 59 | )} 60 |
61 |
62 |
Date Joined
63 | {dayjs(profile.dateJoined).format('MMM.DD, YYYY')} 64 |
65 |
66 |
67 | ); 68 | }; 69 | 70 | export default Info; 71 | -------------------------------------------------------------------------------- /frontend/src/pages/profile/Tabs/index.ts: -------------------------------------------------------------------------------- 1 | export { default as Bio } from './Bio'; 2 | export { default as Bookmarks } from './Bookmarks'; 3 | export { default as EditInfo } from './EditInfo'; 4 | export { default as Followers } from './Followers'; 5 | export { default as Following } from './Following'; 6 | export { default as Info } from './Info'; 7 | export { default as Posts } from './Posts'; 8 | 9 | -------------------------------------------------------------------------------- /frontend/src/pages/profile/index.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect } from "react"; 2 | import { useDispatch, useSelector } from "react-redux"; 3 | import { Route, RouteComponentProps, Switch } from "react-router-dom"; 4 | import { Boundary, ProfileLoader } from "~/components/shared"; 5 | import * as ROUTE from "~/constants/routes"; 6 | import { PageNotFound } from "~/pages"; 7 | import { getUserStart } from "~/redux/action/profileActions"; 8 | import { IRootReducer } from "~/types/types"; 9 | import Header from './Header'; 10 | import * as Tab from './Tabs'; 11 | 12 | interface MatchParams { 13 | username: string; 14 | } 15 | 16 | interface IProps extends RouteComponentProps { 17 | children: React.ReactNode; 18 | } 19 | 20 | const Profile: React.FC = (props) => { 21 | const dispatch = useDispatch(); 22 | const { username } = props.match.params; 23 | const state = useSelector((state: IRootReducer) => ({ 24 | profile: state.profile, 25 | auth: state.auth, 26 | error: state.error.profileError, 27 | isLoadingGetUser: state.loading.isLoadingGetUser 28 | })); 29 | 30 | useEffect(() => { 31 | if (state.profile.username !== username) { 32 | dispatch(getUserStart(username)); 33 | } 34 | // eslint-disable-next-line react-hooks/exhaustive-deps 35 | }, []); 36 | 37 | return ( 38 | 39 | {(state.error && !state.isLoadingGetUser) && ( 40 | 41 | )} 42 | {(state.isLoadingGetUser) && ( 43 |
44 | )} 45 | {(!state.error && !state.isLoadingGetUser && state.profile.id) && ( 46 |
47 |
51 |
52 |
53 | 57 |
58 |
59 | 60 | 61 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 81 | 82 | 83 |
84 |
85 |
86 | )} 87 |
88 | ); 89 | }; 90 | 91 | export default Profile; 92 | -------------------------------------------------------------------------------- /frontend/src/pages/redirects/SocialAuthFailed.tsx: -------------------------------------------------------------------------------- 1 | import { Link } from "react-router-dom"; 2 | import { useDocumentTitle } from "~/hooks"; 3 | 4 | const SocialAuthFailed = () => { 5 | useDocumentTitle('Authentication Failed'); 6 | 7 | return ( 8 |
9 |

Failed to authenticate

10 |
11 |

Possible cause(s):

12 |
    13 |
  • Same email/username has been already linked to other social login eg: Google
  • 14 |
15 | 16 | Back to Login 17 |
18 | ); 19 | }; 20 | 21 | export default SocialAuthFailed; 22 | -------------------------------------------------------------------------------- /frontend/src/pages/redirects/index.ts: -------------------------------------------------------------------------------- 1 | export { default as SocialAuthFailed } from './SocialAuthFailed'; 2 | -------------------------------------------------------------------------------- /frontend/src/pages/search/Users.tsx: -------------------------------------------------------------------------------- 1 | import UserCard from "~/components/main/UserCard"; 2 | import useDocumentTitle from "~/hooks/useDocumentTitle"; 3 | import { IProfile } from "~/types/types"; 4 | 5 | interface IProps { 6 | users: IProfile[]; 7 | } 8 | 9 | const Users: React.FC = ({ users }) => { 10 | useDocumentTitle(`Search Users | Foodie`); 11 | 12 | return ( 13 |
14 | {users.map((user) => ( 15 |
19 | 20 |
21 | ))} 22 |
23 | ); 24 | }; 25 | 26 | export default Users; 27 | -------------------------------------------------------------------------------- /frontend/src/pages/suggested_people/index.tsx: -------------------------------------------------------------------------------- 1 | import { useEffect, useState } from "react"; 2 | import useInfiniteScroll from "react-infinite-scroll-hook"; 3 | import { CSSTransition, TransitionGroup } from "react-transition-group"; 4 | import { UserCard } from "~/components/main"; 5 | import { Loader, UserLoader } from "~/components/shared"; 6 | import { useDidMount } from "~/hooks"; 7 | import { getSuggestedPeople } from "~/services/api"; 8 | import { IError, IProfile } from "~/types/types"; 9 | 10 | const SuggestedPeople = () => { 11 | const [people, setPeople] = useState([]); 12 | const [isLoading, setIsLoading] = useState(false); 13 | const [error, setError] = useState(null); 14 | const [offset, setOffset] = useState(0); 15 | const didMount = useDidMount(true); 16 | 17 | useEffect(() => { 18 | fetchSuggested(); 19 | // eslint-disable-next-line react-hooks/exhaustive-deps 20 | }, []); 21 | 22 | const fetchSuggested = async () => { 23 | try { 24 | setIsLoading(true); 25 | const users = await getSuggestedPeople({ offset }); 26 | 27 | if (didMount) { 28 | setPeople([...people, ...users]); 29 | setOffset(offset + 1); 30 | setIsLoading(false); 31 | } 32 | } catch (e) { 33 | if (didMount) { 34 | setIsLoading(false); 35 | setError(e); 36 | } 37 | } 38 | } 39 | 40 | const infiniteRef = useInfiniteScroll({ 41 | loading: isLoading, 42 | hasNextPage: !error && people.length >= 10, 43 | onLoadMore: fetchSuggested, 44 | scrollContainer: 'window', 45 | }); 46 | 47 | return ( 48 |
49 |
50 |

Suggested People

51 |

Follow people to see their updates

52 |
53 | {(isLoading && people.length === 0) && ( 54 |
55 | 56 | 57 | 58 | 59 |
60 | )} 61 | {(!isLoading && error && people.length === 0) && ( 62 |
63 | 64 | {(error as IError)?.error?.message || 'Something went wrong :('} 65 | 66 |
67 | )} 68 | 69 |
} 72 | > 73 | {people.map(user => ( 74 | 79 |
80 | 81 |
82 |
83 | ))} 84 |
85 |
86 | {(isLoading && people.length >= 10 && !error) && ( 87 |
88 | 89 |
90 | )} 91 |
92 | ); 93 | }; 94 | 95 | export default SuggestedPeople; 96 | -------------------------------------------------------------------------------- /frontend/src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /frontend/src/redux/action/authActions.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CHECK_SESSION, 3 | LOGIN_START, 4 | LOGIN_SUCCESS, 5 | LOGOUT_START, 6 | LOGOUT_SUCCESS, 7 | REGISTER_START, 8 | REGISTER_SUCCESS, 9 | UPDATE_AUTH_PICTURE 10 | } from "~/constants/actionType"; 11 | import { IRegister, IUser } from "~/types/types"; 12 | 13 | export const loginStart = (email: string, password: string) => ({ 14 | type: LOGIN_START, 15 | payload: { 16 | email, 17 | password 18 | } 19 | }); 20 | 21 | export const loginSuccess = (auth: IUser) => ({ 22 | type: LOGIN_SUCCESS, 23 | payload: auth 24 | }); 25 | 26 | export const logoutStart = (callback?: () => void) => ({ 27 | type: LOGOUT_START, 28 | payload: { callback } 29 | }); 30 | 31 | export const logoutSuccess = () => ({ 32 | type: LOGOUT_SUCCESS 33 | }); 34 | 35 | export const registerStart = ({ email, password, username }: IRegister) => ({ 36 | type: REGISTER_START, 37 | payload: { 38 | email, 39 | password, 40 | username 41 | } 42 | }); 43 | 44 | export const registerSuccess = (userAuth: IUser) => ({ 45 | type: REGISTER_SUCCESS, 46 | payload: userAuth 47 | }); 48 | 49 | 50 | export const checkSession = () => ({ 51 | type: CHECK_SESSION 52 | }); 53 | 54 | export const updateAuthPicture = (image: Object) => ({ 55 | type: UPDATE_AUTH_PICTURE, 56 | payload: image 57 | }); 58 | 59 | export type TAuthActionType = 60 | | ReturnType 61 | | ReturnType 62 | | ReturnType 63 | | ReturnType 64 | | ReturnType 65 | | ReturnType 66 | | ReturnType 67 | | ReturnType; -------------------------------------------------------------------------------- /frontend/src/redux/action/chatActions.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CLEAR_CHAT, 3 | CLOSE_CHAT, 4 | GET_MESSAGES_SUCCESS, 5 | INITIATE_CHAT, 6 | MINIMIZE_CHAT, 7 | NEW_MESSAGE_ARRIVED 8 | } from "~/constants/actionType"; 9 | import { IMessage, IUser, PartialBy } from "~/types/types"; 10 | 11 | export const initiateChat = (user: PartialBy) => ({ 12 | type: INITIATE_CHAT, 13 | payload: user 14 | }); 15 | 16 | export const minimizeChat = (target: string) => ({ 17 | type: MINIMIZE_CHAT, 18 | payload: target 19 | }); 20 | 21 | export const closeChat = (target: string) => ({ 22 | type: CLOSE_CHAT, 23 | payload: target 24 | }); 25 | 26 | export const getMessagesSuccess = (target: string, messages: IMessage[]) => ({ 27 | type: GET_MESSAGES_SUCCESS, 28 | payload: { 29 | username: target, 30 | messages 31 | } 32 | }); 33 | 34 | export const newMessageArrived = (target: string, message: IMessage) => ({ 35 | type: NEW_MESSAGE_ARRIVED, 36 | payload: { 37 | username: target, 38 | message 39 | } 40 | }); 41 | 42 | export const clearChat = () => ({ 43 | type: CLEAR_CHAT 44 | }); 45 | 46 | export type TChatActionType = 47 | | ReturnType 48 | | ReturnType 49 | | ReturnType 50 | | ReturnType 51 | | ReturnType 52 | | ReturnType; -------------------------------------------------------------------------------- /frontend/src/redux/action/errorActions.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CLEAR_AUTH_ERR_MSG, 3 | SET_AUTH_ERR_MSG, 4 | SET_NEWSFEED_ERR_MSG, 5 | SET_PROFILE_ERR_MSG 6 | } from "~/constants/actionType"; 7 | import { IError } from "~/types/types"; 8 | 9 | export const setAuthErrorMessage = (error: IError | null) => ({ 10 | type: SET_AUTH_ERR_MSG, 11 | payload: error 12 | }); 13 | 14 | export const setProfileErrorMessage = (error: IError | null) => ({ 15 | type: SET_PROFILE_ERR_MSG, 16 | payload: error 17 | }); 18 | 19 | export const setNewsFeedErrorMessage = (error: IError | null) => ({ 20 | type: SET_NEWSFEED_ERR_MSG, 21 | payload: error 22 | }); 23 | 24 | export const clearAuthErrorMessage = () => ({ 25 | type: CLEAR_AUTH_ERR_MSG 26 | }); 27 | 28 | export type ErrorActionType = 29 | | ReturnType 30 | | ReturnType 31 | | ReturnType 32 | | ReturnType; 33 | -------------------------------------------------------------------------------- /frontend/src/redux/action/feedActions.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CLEAR_FEED, 3 | CREATE_POST_START, 4 | CREATE_POST_SUCCESS, 5 | DELETE_FEED_POST, 6 | GET_FEED_START, 7 | GET_FEED_SUCCESS, 8 | HAS_NEW_FEED, 9 | UPDATE_FEED_POST, 10 | UPDATE_POST_LIKES 11 | } from "~/constants/actionType"; 12 | import { IFetchParams, IPost } from "~/types/types"; 13 | 14 | export const getNewsFeedStart = (options?: IFetchParams) => ({ 15 | type: GET_FEED_START, 16 | payload: options 17 | }); 18 | 19 | export const getNewsFeedSuccess = (posts: IPost[]) => ({ 20 | type: GET_FEED_SUCCESS, 21 | payload: posts 22 | }); 23 | 24 | export const createPostStart = (post: FormData) => ({ 25 | type: CREATE_POST_START, 26 | payload: post 27 | }); 28 | 29 | export const createPostSuccess = (post: IPost) => ({ 30 | type: CREATE_POST_SUCCESS, 31 | payload: post 32 | }); 33 | 34 | export const updateFeedPost = (post: IPost) => ({ 35 | type: UPDATE_FEED_POST, 36 | payload: post 37 | }); 38 | 39 | export const updatePostLikes = (postID: string, state: boolean, likesCount: number) => ({ 40 | type: UPDATE_POST_LIKES, 41 | payload: { postID, state, likesCount } 42 | }); 43 | 44 | export const deleteFeedPost = (postID: string) => ({ 45 | type: DELETE_FEED_POST, 46 | payload: postID 47 | }); 48 | 49 | export const clearNewsFeed = () => ({ 50 | type: CLEAR_FEED 51 | }); 52 | 53 | export const hasNewFeed = (bool = true) => ({ 54 | type: HAS_NEW_FEED, 55 | payload: bool 56 | }); 57 | 58 | export type TNewsFeedActionType = 59 | | ReturnType 60 | | ReturnType 61 | | ReturnType 62 | | ReturnType 63 | | ReturnType 64 | | ReturnType 65 | | ReturnType 66 | | ReturnType 67 | | ReturnType; -------------------------------------------------------------------------------- /frontend/src/redux/action/helperActions.ts: -------------------------------------------------------------------------------- 1 | import { SET_TARGET_COMMENT, SET_TARGET_POST } from "~/constants/actionType"; 2 | import { IComment, IPost } from "~/types/types"; 3 | 4 | export const setTargetComment = (comment: IComment | null) => ({ 5 | type: SET_TARGET_COMMENT, 6 | payload: comment 7 | }); 8 | 9 | export const setTargetPost = (post: IPost | null) => ({ 10 | type: SET_TARGET_POST, 11 | payload: post 12 | }); 13 | 14 | export type helperActionType = 15 | | ReturnType 16 | | ReturnType -------------------------------------------------------------------------------- /frontend/src/redux/action/loadingActions.ts: -------------------------------------------------------------------------------- 1 | import { 2 | SET_AUTH_LOADING, 3 | SET_CREATE_POST_LOADING, 4 | SET_GET_FEED_LOADING, 5 | SET_GET_USER_LOADING 6 | } from "~/constants/actionType"; 7 | 8 | export const isAuthenticating = (bool: boolean = true) => ({ 9 | type: SET_AUTH_LOADING, 10 | payload: bool 11 | }); 12 | 13 | 14 | export const isCreatingPost = (bool: boolean = true) => ({ 15 | type: SET_CREATE_POST_LOADING, 16 | payload: bool 17 | }); 18 | 19 | export const isGettingUser = (bool: boolean = true) => ({ 20 | type: SET_GET_USER_LOADING, 21 | payload: bool 22 | }); 23 | 24 | export const isGettingFeed = (bool: boolean = true) => ({ 25 | type: SET_GET_FEED_LOADING, 26 | payload: bool 27 | }); 28 | 29 | export type TLoadingActionType = 30 | | ReturnType 31 | | ReturnType 32 | | ReturnType 33 | | ReturnType; -------------------------------------------------------------------------------- /frontend/src/redux/action/modalActions.ts: -------------------------------------------------------------------------------- 1 | import { HIDE_MODAL, SHOW_MODAL } from "~/constants/actionType"; 2 | import { EModalType } from "~/types/types"; 3 | 4 | export const showModal = (modalType: EModalType) => ({ 5 | type: SHOW_MODAL, 6 | payload: modalType 7 | }); 8 | 9 | export const hideModal = (modalType: EModalType) => ({ 10 | type: HIDE_MODAL, 11 | payload: modalType 12 | }); 13 | 14 | 15 | export type modalActionType = 16 | | ReturnType 17 | | ReturnType -------------------------------------------------------------------------------- /frontend/src/redux/action/profileActions.ts: -------------------------------------------------------------------------------- 1 | import { GET_USER_START, GET_USER_SUCCESS, UPDATE_COVER_PHOTO, UPDATE_PROFILE_INFO, UPDATE_PROFILE_PICTURE } from "~/constants/actionType"; 2 | import { IProfile } from "~/types/types"; 3 | 4 | export const getUserStart = (username: string) => ({ 5 | type: GET_USER_START, 6 | payload: username 7 | }); 8 | 9 | export const getUserSuccess = (user: IProfile) => ({ 10 | type: GET_USER_SUCCESS, 11 | payload: user 12 | }); 13 | 14 | export const updateProfileInfo = (user: IProfile) => ({ 15 | type: UPDATE_PROFILE_INFO, 16 | payload: user 17 | }); 18 | 19 | export const updateProfilePicture = (image: Object) => ({ 20 | type: UPDATE_PROFILE_PICTURE, 21 | payload: image 22 | }); 23 | 24 | export const updateCoverPhoto = (image: Object) => ({ 25 | type: UPDATE_COVER_PHOTO, 26 | payload: image 27 | }); 28 | 29 | export type TProfileActionTypes = 30 | | ReturnType 31 | | ReturnType 32 | | ReturnType 33 | | ReturnType 34 | | ReturnType; -------------------------------------------------------------------------------- /frontend/src/redux/action/settingsActions.ts: -------------------------------------------------------------------------------- 1 | import { SET_THEME } from "~/constants/actionType"; 2 | 3 | export const setTheme = (theme: 'light' | 'dark') => ({ 4 | type: SET_THEME, 5 | payload: theme 6 | }); 7 | 8 | export type TSettingsActionType = 9 | | ReturnType; 10 | -------------------------------------------------------------------------------- /frontend/src/redux/reducer/authReducer.ts: -------------------------------------------------------------------------------- 1 | import { LOGIN_SUCCESS, LOGOUT_SUCCESS, REGISTER_SUCCESS, UPDATE_AUTH_PICTURE } from '~/constants/actionType'; 2 | import { TAuthActionType } from '~/redux/action/authActions'; 3 | import { IUser } from '~/types/types'; 4 | 5 | const initState: IUser = { 6 | id: '', 7 | username: '', 8 | fullname: '', 9 | profilePicture: {} 10 | } 11 | 12 | const authReducer = (state = initState, action: TAuthActionType) => { 13 | switch (action.type) { 14 | case LOGIN_SUCCESS: 15 | return action.payload; 16 | case LOGOUT_SUCCESS: 17 | return initState; 18 | case REGISTER_SUCCESS: 19 | return action.payload; 20 | case UPDATE_AUTH_PICTURE: 21 | return { 22 | ...state, 23 | profilePicture: action.payload 24 | } 25 | default: 26 | return state; 27 | } 28 | }; 29 | 30 | export default authReducer; 31 | -------------------------------------------------------------------------------- /frontend/src/redux/reducer/chatReducer.ts: -------------------------------------------------------------------------------- 1 | import { CLEAR_CHAT, CLOSE_CHAT, GET_MESSAGES_SUCCESS, INITIATE_CHAT, MINIMIZE_CHAT, NEW_MESSAGE_ARRIVED } from "~/constants/actionType"; 2 | import { IChatState } from "~/types/types"; 3 | import { TChatActionType } from "../action/chatActions"; 4 | 5 | const initState: IChatState = { 6 | active: '', 7 | items: [] 8 | }; 9 | 10 | const chatReducer = (state = initState, action: TChatActionType) => { 11 | switch (action.type) { 12 | case INITIATE_CHAT: 13 | const exists = state.items.some(chat => (chat.id as unknown) === action.payload.id); 14 | const initChat = { 15 | username: action.payload.username, 16 | id: action.payload.id, 17 | profilePicture: action.payload.profilePicture, 18 | minimized: false, 19 | chats: [] 20 | }; 21 | 22 | const maxItems = 4; 23 | const hasReachedLimit = state.items.length === maxItems; 24 | 25 | 26 | if (!exists) { 27 | // Delete first and set minimized to true 28 | const mapped = state.items.map(chat => ({ 29 | ...chat, 30 | minimized: true 31 | })); 32 | const deletedFirstItem = mapped.splice(1); 33 | 34 | return { 35 | active: action.payload.id, 36 | items: hasReachedLimit 37 | ? [...deletedFirstItem, initChat] 38 | : [...(state.items.map(chat => ({ 39 | ...chat, 40 | minimized: true 41 | }))), initChat] 42 | } 43 | } else { 44 | return { 45 | active: action.payload.id, 46 | items: state.items.map((chat) => { 47 | if ((chat.id as unknown) === action.payload.id) { 48 | return { 49 | ...chat, 50 | minimized: false 51 | } 52 | } 53 | 54 | return { 55 | ...chat, 56 | minimized: true 57 | }; 58 | }) 59 | }; 60 | } 61 | case MINIMIZE_CHAT: 62 | return { 63 | active: '', 64 | items: state.items.map((chat) => { 65 | if (chat.username === action.payload) { 66 | return { 67 | ...chat, 68 | minimized: true 69 | } 70 | } 71 | return chat; 72 | }) 73 | } 74 | case CLOSE_CHAT: 75 | return { 76 | active: '', 77 | items: state.items.filter(chat => chat.username !== action.payload) 78 | } 79 | case GET_MESSAGES_SUCCESS: 80 | return { 81 | ...state, 82 | items: state.items.map(chat => chat.username !== action.payload.username ? chat : { 83 | ...chat, 84 | offset: (chat.offset || 0) + 1, 85 | chats: [...action.payload.messages, ...chat.chats] 86 | }) 87 | } 88 | case NEW_MESSAGE_ARRIVED: 89 | return { 90 | ...state, 91 | items: state.items.map(chat => chat.username !== action.payload.username ? chat : { 92 | ...chat, 93 | chats: [...chat.chats, action.payload.message] 94 | }) 95 | } 96 | case CLEAR_CHAT: 97 | return initState; 98 | default: 99 | return state; 100 | } 101 | }; 102 | 103 | export default chatReducer; 104 | -------------------------------------------------------------------------------- /frontend/src/redux/reducer/errorReducer.ts: -------------------------------------------------------------------------------- 1 | import { 2 | CLEAR_AUTH_ERR_MSG, 3 | SET_AUTH_ERR_MSG, 4 | SET_NEWSFEED_ERR_MSG, 5 | SET_PROFILE_ERR_MSG 6 | } from "~/constants/actionType"; 7 | import { IErrorState } from "~/types/types"; 8 | import { ErrorActionType } from "../action/errorActions"; 9 | 10 | const initState: IErrorState = { 11 | authError: null, 12 | profileError: null, 13 | newsFeedError: null 14 | } 15 | 16 | const errorReducer = (state = initState, action: ErrorActionType) => { 17 | switch (action.type) { 18 | case SET_AUTH_ERR_MSG: 19 | return { 20 | ...state, 21 | authError: action.payload 22 | } 23 | case SET_PROFILE_ERR_MSG: 24 | return { 25 | ...state, 26 | profileError: action.payload 27 | } 28 | case SET_NEWSFEED_ERR_MSG: 29 | return { 30 | ...state, 31 | newsFeedError: action.payload 32 | } 33 | case CLEAR_AUTH_ERR_MSG: 34 | return { 35 | ...state, 36 | authError: null 37 | } 38 | default: 39 | return state; 40 | } 41 | }; 42 | 43 | export default errorReducer; 44 | -------------------------------------------------------------------------------- /frontend/src/redux/reducer/helperReducer.ts: -------------------------------------------------------------------------------- 1 | import { SET_TARGET_COMMENT, SET_TARGET_POST } from "~/constants/actionType"; 2 | import { IHelperState } from "~/types/types"; 3 | import { helperActionType } from "../action/helperActions"; 4 | 5 | const initState: IHelperState = { 6 | targetComment: null, 7 | targetPost: null 8 | } 9 | 10 | const helperReducer = (state = initState, action: helperActionType) => { 11 | switch (action.type) { 12 | case SET_TARGET_COMMENT: 13 | return { 14 | ...state, 15 | targetComment: action.payload 16 | } 17 | case SET_TARGET_POST: 18 | return { 19 | ...state, 20 | targetPost: action.payload 21 | } 22 | default: 23 | return state; 24 | } 25 | } 26 | 27 | export default helperReducer; -------------------------------------------------------------------------------- /frontend/src/redux/reducer/loadingReducer.ts: -------------------------------------------------------------------------------- 1 | import { 2 | SET_AUTH_LOADING, 3 | SET_CREATE_POST_LOADING, 4 | SET_GET_FEED_LOADING, 5 | SET_GET_USER_LOADING 6 | } from "~/constants/actionType"; 7 | import { TLoadingActionType } from "../action/loadingActions"; 8 | 9 | const initState = { 10 | isLoadingAuth: false, 11 | isLoadingCreatePost: false, 12 | isLoadingGetUser: false, 13 | isLoadingProfile: false, 14 | isLoadingFeed: false 15 | } 16 | 17 | const loadingReducer = (state = initState, action: TLoadingActionType) => { 18 | switch (action.type) { 19 | case SET_AUTH_LOADING: 20 | return { 21 | ...state, 22 | isLoadingAuth: action.payload 23 | } 24 | case SET_CREATE_POST_LOADING: 25 | return { 26 | ...state, 27 | isLoadingCreatePost: action.payload 28 | } 29 | case SET_GET_USER_LOADING: 30 | return { 31 | ...state, 32 | isLoadingGetUser: action.payload 33 | } 34 | case SET_GET_FEED_LOADING: 35 | return { 36 | ...state, 37 | isLoadingFeed: action.payload 38 | } 39 | default: 40 | return state; 41 | } 42 | }; 43 | 44 | export default loadingReducer; 45 | -------------------------------------------------------------------------------- /frontend/src/redux/reducer/modalReducer.ts: -------------------------------------------------------------------------------- 1 | import { HIDE_MODAL, SHOW_MODAL } from "~/constants/actionType"; 2 | import { EModalType, IModalState } from "~/types/types"; 3 | import { modalActionType } from "../action/modalActions"; 4 | 5 | const initState: IModalState = { 6 | isOpenDeleteComment: false, 7 | isOpenDeletePost: false, 8 | isOpenEditPost: false, 9 | isOpenPostLikes: false 10 | } 11 | 12 | const modalMapType = { 13 | [EModalType.DELETE_COMMENT]: 'isOpenDeleteComment', 14 | [EModalType.DELETE_POST]: 'isOpenDeletePost', 15 | [EModalType.EDIT_POST]: 'isOpenEditPost', 16 | [EModalType.POST_LIKES]: 'isOpenPostLikes', 17 | } 18 | 19 | const modalReducer = (state = initState, action: modalActionType) => { 20 | switch (action.type) { 21 | case SHOW_MODAL: 22 | return { 23 | ...state, 24 | [modalMapType[action.payload]]: true 25 | } 26 | case HIDE_MODAL: 27 | return { 28 | ...state, 29 | [modalMapType[action.payload]]: false 30 | } 31 | default: 32 | return state; 33 | } 34 | } 35 | 36 | export default modalReducer; -------------------------------------------------------------------------------- /frontend/src/redux/reducer/newsFeedReducer.ts: -------------------------------------------------------------------------------- 1 | import { CLEAR_FEED, CREATE_POST_SUCCESS, DELETE_FEED_POST, GET_FEED_SUCCESS, HAS_NEW_FEED, UPDATE_FEED_POST, UPDATE_POST_LIKES } from "~/constants/actionType"; 2 | import { INewsFeed, IPost } from "~/types/types"; 3 | import { TNewsFeedActionType } from "../action/feedActions"; 4 | 5 | const initState: INewsFeed = { 6 | items: [], 7 | offset: 0, 8 | hasNewFeed: false 9 | }; 10 | 11 | const newsFeedReducer = (state = initState, action: TNewsFeedActionType) => { 12 | switch (action.type) { 13 | case GET_FEED_SUCCESS: 14 | return { 15 | ...state, 16 | items: [...state.items, ...action.payload], 17 | offset: state.offset + 1 18 | }; 19 | case CREATE_POST_SUCCESS: 20 | return { 21 | ...state, 22 | items: [action.payload, ...state.items] 23 | }; 24 | case CLEAR_FEED: 25 | return initState; 26 | case UPDATE_FEED_POST: 27 | return { 28 | ...state, 29 | items: state.items.map((post: IPost) => { 30 | if (post.id === action.payload.id) { 31 | return { 32 | ...post, 33 | ...action.payload 34 | }; 35 | } 36 | return post; 37 | }) 38 | } 39 | case UPDATE_POST_LIKES: 40 | return { 41 | ...state, 42 | items: state.items.map((post: IPost) => { 43 | if (post.id === action.payload.postID) { 44 | return { 45 | ...post, 46 | isLiked: action.payload.state, 47 | likesCount: action.payload.likesCount 48 | }; 49 | } 50 | return post; 51 | }) 52 | } 53 | case DELETE_FEED_POST: 54 | return { 55 | ...state, 56 | // eslint-disable-next-line array-callback-return 57 | items: state.items.filter((post: IPost) => { 58 | if (post.id !== action.payload) { 59 | return post; 60 | } 61 | }) 62 | } 63 | case HAS_NEW_FEED: 64 | return { 65 | ...state, 66 | hasNewFeed: action.payload 67 | } 68 | default: 69 | return state; 70 | } 71 | }; 72 | 73 | export default newsFeedReducer; 74 | -------------------------------------------------------------------------------- /frontend/src/redux/reducer/profileReducer.ts: -------------------------------------------------------------------------------- 1 | import { GET_USER_SUCCESS, UPDATE_COVER_PHOTO, UPDATE_PROFILE_INFO, UPDATE_PROFILE_PICTURE } from "~/constants/actionType"; 2 | import { IProfile } from "~/types/types"; 3 | import { TProfileActionTypes } from "../action/profileActions"; 4 | 5 | const initState: IProfile = { 6 | _id: '', 7 | id: '', 8 | username: '', 9 | email: '', 10 | fullname: '', 11 | firstname: '', 12 | lastname: '', 13 | info: { 14 | bio: '', 15 | birthday: '', 16 | gender: 'unspecified', 17 | }, 18 | isEmailValidated: false, 19 | profilePicture: {}, 20 | coverPhoto: {}, 21 | followersCount: 0, 22 | followingCount: 0, 23 | dateJoined: '' 24 | }; 25 | 26 | const profileReducer = (state = initState, action: TProfileActionTypes) => { 27 | switch (action.type) { 28 | case GET_USER_SUCCESS: 29 | return action.payload; 30 | case UPDATE_PROFILE_PICTURE: 31 | return { 32 | ...state, 33 | profilePicture: action.payload 34 | } 35 | case UPDATE_PROFILE_INFO: 36 | const { payload: user } = action; 37 | return { 38 | ...state, 39 | fullname: user.fullname, 40 | firstname: user.firstname, 41 | lastname: user.lastname, 42 | info: user.info 43 | } 44 | case UPDATE_COVER_PHOTO: 45 | return { 46 | ...state, 47 | coverPhoto: action.payload 48 | } 49 | default: 50 | return state; 51 | } 52 | }; 53 | 54 | export default profileReducer; 55 | -------------------------------------------------------------------------------- /frontend/src/redux/reducer/rootReducer.ts: -------------------------------------------------------------------------------- 1 | import { combineReducers } from 'redux'; 2 | import authReducer from './authReducer'; 3 | import chatReducer from './chatReducer'; 4 | import errorReducer from './errorReducer'; 5 | import helperReducer from './helperReducer'; 6 | import loadingReducer from './loadingReducer'; 7 | import modalReducer from './modalReducer'; 8 | import newsFeedReducer from './newsFeedReducer'; 9 | import profileReducer from './profileReducer'; 10 | import settingsReducer from './settingsReducer'; 11 | 12 | const rootReducer = combineReducers({ 13 | auth: authReducer, 14 | error: errorReducer, 15 | loading: loadingReducer, 16 | newsFeed: newsFeedReducer, 17 | profile: profileReducer, 18 | chats: chatReducer, 19 | helper: helperReducer, 20 | modal: modalReducer, 21 | settings: settingsReducer, 22 | }); 23 | 24 | export default rootReducer; 25 | -------------------------------------------------------------------------------- /frontend/src/redux/reducer/settingsReducer.ts: -------------------------------------------------------------------------------- 1 | import { SET_THEME } from "~/constants/actionType"; 2 | import { ISettingsState } from "~/types/types"; 3 | import { TSettingsActionType } from "../action/settingsActions"; 4 | 5 | const initState: ISettingsState = { 6 | theme: 'light', 7 | // ... more settings 8 | } 9 | 10 | const settingsReducer = (state = initState, action: TSettingsActionType) => { 11 | switch (action.type) { 12 | case SET_THEME: 13 | return { 14 | ...state, 15 | theme: action.payload 16 | } 17 | default: 18 | return state; 19 | } 20 | } 21 | 22 | export default settingsReducer; 23 | -------------------------------------------------------------------------------- /frontend/src/redux/sagas/authSaga.ts: -------------------------------------------------------------------------------- 1 | import { call, put, select } from "redux-saga/effects"; 2 | import { history } from '~/App'; 3 | import { CHECK_SESSION, LOGIN_START, LOGOUT_START, REGISTER_START } from "~/constants/actionType"; 4 | import { LOGIN } from "~/constants/routes"; 5 | import { checkAuthSession, login, logout, register } from "~/services/api"; 6 | import socket from "~/socket/socket"; 7 | import { IError, IUser } from "~/types/types"; 8 | import { loginSuccess, logoutSuccess, registerSuccess } from "../action/authActions"; 9 | import { clearChat } from "../action/chatActions"; 10 | import { setAuthErrorMessage } from "../action/errorActions"; 11 | import { clearNewsFeed } from "../action/feedActions"; 12 | import { isAuthenticating } from "../action/loadingActions"; 13 | 14 | interface IAuthSaga { 15 | type: string; 16 | payload: any; 17 | } 18 | 19 | function* handleError(e: IError) { 20 | yield put(isAuthenticating(false)); 21 | 22 | yield put(setAuthErrorMessage(e)); 23 | } 24 | 25 | function* authSaga({ type, payload }: IAuthSaga) { 26 | switch (type) { 27 | case LOGIN_START: 28 | try { 29 | yield put(isAuthenticating(true)); 30 | const { auth } = yield call(login, payload.email, payload.password); 31 | socket.emit('userConnect', auth.id); 32 | yield put(clearNewsFeed()); 33 | yield put(loginSuccess(auth)); 34 | yield put(isAuthenticating(false)); 35 | } catch (e) { 36 | console.log(e); 37 | 38 | yield handleError(e); 39 | } 40 | break; 41 | case CHECK_SESSION: 42 | try { 43 | yield put(isAuthenticating(true)); 44 | const { auth } = yield call(checkAuthSession); 45 | 46 | console.log('SUCCESS ', auth); 47 | yield put(loginSuccess(auth)); 48 | yield put(isAuthenticating(false)); 49 | } catch (e) { 50 | yield handleError(e); 51 | } 52 | break; 53 | case LOGOUT_START: 54 | try { 55 | const { auth } = yield select(); 56 | yield put(isAuthenticating(true)); 57 | yield call(logout); 58 | 59 | payload.callback && payload.callback(); 60 | 61 | yield put(logoutSuccess()); 62 | yield put(isAuthenticating(false)); 63 | yield put(clearNewsFeed()); 64 | yield put(clearChat()); 65 | history.push(LOGIN); 66 | socket.emit('userDisconnect', auth.id); 67 | } catch (e) { 68 | yield handleError(e); 69 | } 70 | break; 71 | case REGISTER_START: 72 | try { 73 | yield put(isAuthenticating(true)); 74 | 75 | const user: IUser = yield call(register, payload); 76 | 77 | socket.emit('userConnect', user.id); 78 | yield put(registerSuccess(user)); 79 | yield put(isAuthenticating(false)); 80 | } 81 | catch (e) { 82 | console.log('ERR', e); 83 | yield handleError(e); 84 | } 85 | break; 86 | default: 87 | return; 88 | } 89 | } 90 | 91 | export default authSaga; 92 | -------------------------------------------------------------------------------- /frontend/src/redux/sagas/index.ts: -------------------------------------------------------------------------------- 1 | import { takeLatest } from 'redux-saga/effects'; 2 | import { 3 | CHECK_SESSION, 4 | CREATE_POST_START, 5 | GET_FEED_START, 6 | GET_USER_START, 7 | LOGIN_START, 8 | LOGOUT_START, 9 | REGISTER_START 10 | } from '~/constants/actionType'; 11 | import authSaga from './authSaga'; 12 | import newsFeedSaga from './newsFeedSaga'; 13 | import profileSaga from './profileSaga'; 14 | 15 | function* rootSaga() { 16 | yield takeLatest([ 17 | LOGIN_START, 18 | LOGOUT_START, 19 | REGISTER_START, 20 | CHECK_SESSION, 21 | ], authSaga); 22 | 23 | yield takeLatest([ 24 | GET_FEED_START, 25 | CREATE_POST_START 26 | ], newsFeedSaga); 27 | 28 | yield takeLatest([ 29 | GET_USER_START 30 | ], profileSaga) 31 | } 32 | 33 | export default rootSaga; -------------------------------------------------------------------------------- /frontend/src/redux/sagas/newsFeedSaga.ts: -------------------------------------------------------------------------------- 1 | import { toast } from "react-toastify"; 2 | import { call, put } from "redux-saga/effects"; 3 | import { CREATE_POST_START, GET_FEED_START } from "~/constants/actionType"; 4 | import { createPost, getNewsFeed } from "~/services/api"; 5 | import { IPost } from "~/types/types"; 6 | import { setNewsFeedErrorMessage } from "../action/errorActions"; 7 | import { createPostSuccess, getNewsFeedSuccess } from "../action/feedActions"; 8 | import { isCreatingPost, isGettingFeed } from "../action/loadingActions"; 9 | 10 | interface INewsFeedSaga { 11 | type: string; 12 | payload: any; 13 | } 14 | 15 | function* newsFeedSaga({ type, payload }: INewsFeedSaga) { 16 | switch (type) { 17 | case GET_FEED_START: 18 | try { 19 | yield put(isGettingFeed(true)); 20 | yield put(setNewsFeedErrorMessage(null)); 21 | 22 | const posts: IPost[] = yield call(getNewsFeed, payload); 23 | 24 | yield put(isGettingFeed(false)); 25 | yield put(getNewsFeedSuccess(posts)); 26 | } catch (e) { 27 | console.log(e); 28 | yield put(isGettingFeed(false)); 29 | yield put(setNewsFeedErrorMessage(e)) 30 | } 31 | 32 | break; 33 | case CREATE_POST_START: 34 | try { 35 | yield put(isCreatingPost(true)); 36 | 37 | const post: IPost = yield call(createPost, payload); 38 | 39 | yield put(createPostSuccess(post)); 40 | yield put(isCreatingPost(false)); 41 | toast.dismiss(); 42 | toast.dark('Post succesfully created.'); 43 | } catch (e) { 44 | yield put(isCreatingPost(false)); 45 | console.log(e); 46 | } 47 | break; 48 | default: 49 | throw new Error('Unexpected action type.') 50 | } 51 | } 52 | 53 | export default newsFeedSaga; 54 | -------------------------------------------------------------------------------- /frontend/src/redux/sagas/profileSaga.ts: -------------------------------------------------------------------------------- 1 | import { call, put } from "redux-saga/effects"; 2 | import { GET_USER_START } from "~/constants/actionType"; 3 | import { getUser } from "~/services/api"; 4 | import { IProfile } from "~/types/types"; 5 | import { setProfileErrorMessage } from "../action/errorActions"; 6 | import { isGettingUser } from "../action/loadingActions"; 7 | import { getUserSuccess } from "../action/profileActions"; 8 | 9 | interface IProfileSaga { 10 | type: string; 11 | payload: any; 12 | } 13 | 14 | function* profileSaga({ type, payload }: IProfileSaga) { 15 | switch (type) { 16 | case GET_USER_START: 17 | try { 18 | yield put(isGettingUser(true)); 19 | const user: IProfile = yield call(getUser, payload); 20 | 21 | yield put(isGettingUser(false)); 22 | yield put(setProfileErrorMessage(null)); 23 | yield put(getUserSuccess(user)); 24 | } catch (e) { 25 | yield put(setProfileErrorMessage(e)); 26 | yield put(isGettingUser(false)); 27 | console.log(e); 28 | } 29 | break; 30 | default: 31 | throw new Error(`Unexpected action type ${type}.`); 32 | } 33 | } 34 | 35 | export default profileSaga; 36 | -------------------------------------------------------------------------------- /frontend/src/redux/store/store.ts: -------------------------------------------------------------------------------- 1 | import { Dispatch } from 'react'; 2 | import { applyMiddleware, compose, createStore, Store } from 'redux'; 3 | import createSagaMiddleware from 'redux-saga'; 4 | import rootReducer from '../reducer/rootReducer'; 5 | import rootSaga from '../sagas'; 6 | 7 | declare global { 8 | interface Window { 9 | __REDUX_DEVTOOLS_EXTENSION_COMPOSE__?: typeof compose; 10 | } 11 | } 12 | 13 | const localStorageMiddleware = (store: Store) => { 14 | return (next: Dispatch) => (action: any) => { 15 | const result = next(action); 16 | try { 17 | const { settings } = store.getState(); 18 | localStorage.setItem('foodie_theme', JSON.stringify(settings.theme)); 19 | } catch (e) { 20 | console.log('Error while saving in localStorage', e); 21 | } 22 | return result; 23 | }; 24 | }; 25 | 26 | const reHydrateStore = () => { 27 | const storage = localStorage.getItem('foodie_theme'); 28 | if (storage && storage !== null) { 29 | return { 30 | settings: { 31 | theme: JSON.parse(storage) 32 | } 33 | }; 34 | } 35 | return undefined; 36 | }; 37 | 38 | const sagaMiddleware = createSagaMiddleware(); 39 | const middlewares: any = [sagaMiddleware, localStorageMiddleware]; 40 | const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose; 41 | 42 | if (process.env.NODE_ENV === `development`) { 43 | const { logger } = require(`redux-logger`); 44 | 45 | middlewares.push(logger); 46 | } 47 | 48 | const configureStore = () => { 49 | const store = createStore( 50 | rootReducer, 51 | reHydrateStore(), 52 | composeEnhancers(applyMiddleware(...middlewares)), 53 | ); 54 | 55 | sagaMiddleware.run(rootSaga); 56 | return store; 57 | }; 58 | 59 | const store = configureStore(); 60 | 61 | export default store; 62 | -------------------------------------------------------------------------------- /frontend/src/reportWebVitals.ts: -------------------------------------------------------------------------------- 1 | import { ReportHandler } from 'web-vitals'; 2 | 3 | const reportWebVitals = (onPerfEntry?: ReportHandler) => { 4 | if (onPerfEntry && onPerfEntry instanceof Function) { 5 | import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => { 6 | getCLS(onPerfEntry); 7 | getFID(onPerfEntry); 8 | getFCP(onPerfEntry); 9 | getLCP(onPerfEntry); 10 | getTTFB(onPerfEntry); 11 | }); 12 | } 13 | }; 14 | 15 | export default reportWebVitals; 16 | -------------------------------------------------------------------------------- /frontend/src/routers/ProtectedRoute.tsx: -------------------------------------------------------------------------------- 1 | import { Redirect, Route } from "react-router-dom"; 2 | import withAuth from "~/components/hoc/withAuth"; 3 | import { LOGIN } from "~/constants/routes"; 4 | 5 | interface IProps { 6 | component: React.ComponentType; 7 | path: string; 8 | isAuth: boolean; 9 | [propName: string]: any; 10 | } 11 | 12 | const ProtectedRoute: React.FC = ({ isAuth, component: Component, path, ...rest }) => { 13 | return ( 14 | { 17 | return isAuth ? : 18 | }} 19 | /> 20 | ); 21 | } 22 | 23 | export default withAuth(ProtectedRoute); 24 | -------------------------------------------------------------------------------- /frontend/src/routers/PublicRoute.tsx: -------------------------------------------------------------------------------- 1 | import { Redirect, Route } from "react-router-dom"; 2 | import withAuth from "~/components/hoc/withAuth"; 3 | import { HOME } from "~/constants/routes"; 4 | 5 | interface IProps { 6 | component: React.ComponentType; 7 | path: string; 8 | isAuth: boolean; 9 | [propName: string]: any; 10 | } 11 | 12 | const PublicRoute: React.FC = ({ isAuth, component: Component, path, ...rest }) => { 13 | return ( 14 | { 17 | return isAuth ? : 18 | }} 19 | /> 20 | ); 21 | }; 22 | 23 | export default withAuth(PublicRoute); 24 | -------------------------------------------------------------------------------- /frontend/src/routers/index.ts: -------------------------------------------------------------------------------- 1 | export { default as ProtectedRoute } from './ProtectedRoute'; 2 | export { default as PublicRoute } from './PublicRoute'; 3 | -------------------------------------------------------------------------------- /frontend/src/services/fetcher.ts: -------------------------------------------------------------------------------- 1 | import axios, { AxiosRequestConfig } from "axios"; 2 | import { logoutStart } from '~/redux/action/authActions'; 3 | import store from '~/redux/store/store'; 4 | 5 | const foodieUrl = process.env.REACT_APP_FOODIE_URL || 'http://localhost:9000'; 6 | const foodieApiVersion = process.env.REACT_APP_FOODIE_API_VERSION || 'v1'; 7 | axios.defaults.baseURL = `${foodieUrl}/api/${foodieApiVersion}`; 8 | axios.defaults.withCredentials = true; 9 | 10 | let isLogoutTriggered = false; 11 | 12 | function resetIsLogoutTriggered() { 13 | isLogoutTriggered = false; 14 | } 15 | 16 | axios.interceptors.response.use( 17 | response => response, 18 | error => { 19 | const { data, status } = error.response; 20 | if (status === 401 21 | && (data?.error?.type || '') !== 'INCORRECT_CREDENTIALS' 22 | && error.config 23 | && !error.config.__isRetryRequest 24 | ) { 25 | if (!isLogoutTriggered) { 26 | isLogoutTriggered = true; 27 | store.dispatch(logoutStart(resetIsLogoutTriggered)); 28 | } 29 | } 30 | return Promise.reject(error); 31 | } 32 | ); 33 | 34 | const httpRequest = (req: AxiosRequestConfig): Promise => { 35 | return new Promise(async (resolve, reject) => { 36 | try { 37 | const request = await axios(req); 38 | 39 | resolve(request.data.data) 40 | } catch (e) { 41 | reject(e?.response?.data || {}); 42 | } 43 | }); 44 | } 45 | 46 | export default httpRequest; -------------------------------------------------------------------------------- /frontend/src/setupTests.ts: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import '@testing-library/jest-dom'; 6 | -------------------------------------------------------------------------------- /frontend/src/socket/socket.ts: -------------------------------------------------------------------------------- 1 | import { io } from 'socket.io-client'; 2 | 3 | const socket = io(process.env.REACT_APP_FOODIE_URL || 'http://localhost:9000', { 4 | reconnection: true, 5 | reconnectionDelay: 1000, 6 | reconnectionDelayMax: 5000, 7 | reconnectionAttempts: 50 8 | }); 9 | 10 | export default socket; -------------------------------------------------------------------------------- /frontend/src/styles/app.css: -------------------------------------------------------------------------------- 1 | @import './base/base.css'; 2 | 3 | @import './elements/link.css'; 4 | @import './elements/button.css'; 5 | @import './elements/input.css'; 6 | 7 | @import './utils/utils.css'; 8 | 9 | @import './components/container.css'; 10 | @import './components/modal.css'; 11 | @import './components/grid.css'; 12 | -------------------------------------------------------------------------------- /frontend/src/styles/base/base.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | 5 | :root { 6 | --background: #f9f9f9; 7 | --modalBackground: #fff; 8 | --modalOverlayBackground: rgba(0,0,0, .3); 9 | --scrollBarTrackBg: #cacaca; 10 | } 11 | 12 | :root.dark { 13 | --background: #08070f; 14 | --modalBackground: #100f17; 15 | --modalOverlayBackground: rgba(0,0,0, .7); 16 | --scrollBarTrackBg: #1e1c2a; 17 | } 18 | 19 | @layer base { 20 | h1, h2 { 21 | @apply font-bold; 22 | } 23 | 24 | h3, h4, h5, h6 { 25 | @apply font-medium; 26 | } 27 | 28 | h1 { 29 | @apply text-4xl leading-loose; 30 | } 31 | 32 | h2 { 33 | @apply text-2xl; 34 | } 35 | 36 | h3 { 37 | @apply text-xl; 38 | } 39 | 40 | a { 41 | @apply text-indigo-600; 42 | } 43 | 44 | button { 45 | @apply 46 | group 47 | relative 48 | disabled:opacity-50 49 | disabled:cursor-not-allowed 50 | flex 51 | justify-center 52 | py-3 53 | px-4 54 | border 55 | outline-none 56 | border-transparent 57 | text-sm 58 | font-medium 59 | rounded-full 60 | text-white 61 | bg-indigo-600; 62 | 63 | &:hover { 64 | @apply bg-indigo-700; 65 | } 66 | 67 | &:focus { 68 | @apply bg-indigo-900; 69 | @apply outline-none; 70 | } 71 | } 72 | 73 | input[type=text], 74 | input[type=email], 75 | input[type=password], 76 | input[type=date], 77 | textarea { 78 | @apply 79 | appearance-none 80 | relative 81 | block 82 | w-full 83 | px-6 84 | py-3 85 | border 86 | border-gray-300 87 | placeholder-gray-500 88 | text-gray-900 89 | rounded-full 90 | readonly:opacity-50 91 | focus:border-indigo-100 92 | hover:cursor-not-allowed; 93 | 94 | &:focus { 95 | @apply outline-none; 96 | @apply z-10; 97 | } 98 | 99 | @screen mobile { 100 | @apply text-sm; 101 | } 102 | } 103 | 104 | input[type=checkbox] { 105 | @apply 106 | h-4 107 | w-4 108 | text-indigo-600 109 | border-indigo-500 110 | rounded 111 | hover:cursor-not-allowed 112 | focus:ring-0 113 | readonly:opacity-50; 114 | 115 | &:focus { 116 | @apply ring-0 outline-none; 117 | } 118 | } 119 | 120 | textarea { 121 | @apply rounded-md; 122 | @apply resize-none; 123 | } 124 | 125 | label { 126 | @apply text-gray-500; 127 | @apply text-sm; 128 | } 129 | 130 | select { 131 | @apply border-gray-300; 132 | @apply rounded-full; 133 | @apply px-4 py-3; 134 | } 135 | } 136 | 137 | @layer utilities { 138 | .scrollbar { 139 | scrollbar-color: white; 140 | scrollbar-width: thin; 141 | 142 | &::-webkit-scrollbar { 143 | width: 10px; 144 | } 145 | 146 | &::-webkit-scrollbar-track { 147 | background: var(--scrollBarTrackBg); 148 | } 149 | 150 | &::-webkit-scrollbar-thumb { 151 | @apply bg-gray-500; 152 | border-radius: 10px; 153 | 154 | &:hover { 155 | @apply bg-indigo-500; 156 | } 157 | } 158 | } 159 | } 160 | 161 | body { 162 | margin: 0; 163 | font-family: 'SF Pro Display', sans-serif; 164 | -webkit-font-smoothing: antialiased; 165 | -moz-osx-font-smoothing: grayscale; 166 | min-height: 100vh; 167 | line-height: 1.6; 168 | background: var(--background); 169 | 170 | } 171 | 172 | code { 173 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', 174 | monospace; 175 | } 176 | 177 | .App { 178 | min-height: 100vh; 179 | } 180 | 181 | .anticon { 182 | @apply inline-flex items-center justify-center; 183 | } -------------------------------------------------------------------------------- /frontend/src/styles/components/container.css: -------------------------------------------------------------------------------- 1 | .contain { 2 | width: 100%; 3 | @apply px-4; 4 | @apply laptop:px-6%; 5 | } -------------------------------------------------------------------------------- /frontend/src/styles/components/grid.css: -------------------------------------------------------------------------------- 1 | .grid-img { 2 | width: 100%; 3 | height: 100%; 4 | object-fit: cover; 5 | 6 | &:hover { 7 | cursor: pointer; 8 | } 9 | } 10 | 11 | .custom-grid { 12 | display: grid; 13 | width: 100%; 14 | height: 100%; 15 | grid-gap: 2px; 16 | overflow:hidden; 17 | } 18 | 19 | .custom-grid-cols-2 { 20 | grid-template-columns: repeat(2, 1fr); 21 | } 22 | .custom-grid-cols-3 { 23 | grid-template-columns: repeat(3, 1fr); 24 | } 25 | 26 | .custom-grid-rows-2 { 27 | grid-template-columns: 1fr; 28 | grid-template-rows: repeat(2, 50%); 29 | } -------------------------------------------------------------------------------- /frontend/src/styles/components/modal.css: -------------------------------------------------------------------------------- 1 | .modal { 2 | position: absolute; 3 | background: var(--modalBackground); 4 | top: 50%; 5 | left: 50%; 6 | transform: translate(-50%, -50%); 7 | border: none; 8 | outline: none; 9 | overflow: hidden; 10 | @apply rounded-lg shadow-xl; 11 | @apply w-11/12; 12 | @apply laptop:w-auto; 13 | } 14 | 15 | .modal-overlay { 16 | position: fixed; 17 | top: 0; 18 | left: 0; 19 | right: 0; 20 | bottom: 0; 21 | z-index: 999; 22 | background-color: var(--modalOverlayBackground); 23 | } 24 | 25 | 26 | .ril-next-button, 27 | .ril-prev-button { 28 | border-radius: 15px; 29 | } 30 | .ril-next-button { 31 | border-top-right-radius: 0; 32 | border-bottom-right-radius: 0 33 | } 34 | 35 | .ril-prev-button { 36 | border-top-left-radius: 0; 37 | border-bottom-left-radius: 0 38 | } 39 | 40 | .ril-toolbar { 41 | background: transparent; 42 | } -------------------------------------------------------------------------------- /frontend/src/styles/elements/button.css: -------------------------------------------------------------------------------- 1 | .button { 2 | @extend button; 3 | } 4 | 5 | .button--stretch { 6 | @extend button; 7 | width: 100%; 8 | } 9 | .button--muted { 10 | @extend button; 11 | @apply bg-gray-100; 12 | @apply text-gray-600; 13 | @apply rounded-md; 14 | 15 | &:hover { 16 | @apply bg-gray-300; 17 | @apply text-gray-700; 18 | } 19 | 20 | &:active { 21 | @apply bg-gray-300; 22 | } 23 | 24 | &:focus { 25 | @apply bg-gray-100; 26 | } 27 | } 28 | 29 | .button--danger { 30 | @extend button; 31 | @apply bg-red-500; 32 | 33 | &:hover { 34 | @apply bg-red-700; 35 | } 36 | 37 | &:active, 38 | &:focus { 39 | @apply bg-red-700; 40 | } 41 | } -------------------------------------------------------------------------------- /frontend/src/styles/elements/input.css: -------------------------------------------------------------------------------- 1 | .input--error { 2 | @extend input[type=text]; 3 | @apply border-red-300 !important; 4 | 5 | &:focus { 6 | @apply ring-0 border-red-600 !important; 7 | } 8 | } 9 | 10 | .input-search { 11 | @extend input[type=text]; 12 | @apply py-0; 13 | } -------------------------------------------------------------------------------- /frontend/src/styles/elements/link.css: -------------------------------------------------------------------------------- 1 | .nav-active { 2 | @apply text-indigo-500; 3 | @apply font-bold; 4 | } -------------------------------------------------------------------------------- /frontend/src/styles/utils/utils.css: -------------------------------------------------------------------------------- 1 | .excerpt-text { 2 | display: -webkit-box; 3 | overflow : hidden; 4 | text-overflow: ellipsis; 5 | -webkit-line-clamp: number_of_lines_you_want; 6 | -webkit-box-orient: vertical; 7 | } 8 | 9 | .animate-loader { 10 | width: 10px; 11 | height: 10px; 12 | border-radius: 50%; 13 | animation-duration: .5s; 14 | animation-iteration-count: infinite; 15 | animation-timing-function: ease; 16 | animation-direction: alternate; 17 | 18 | &:nth-of-type(1) { 19 | animation-delay: .2s; 20 | } 21 | 22 | &:nth-of-type(2) { 23 | animation-delay: .5s; 24 | } 25 | 26 | &:nth-of-type(3) { 27 | animation-delay: .8s; 28 | } 29 | } 30 | 31 | .animate-loader-dark { 32 | @apply bg-indigo-700; 33 | animation-name: blink-dark; 34 | 35 | } 36 | 37 | .animate-loader-light { 38 | @apply bg-white; 39 | animation-name: blink-light; 40 | } 41 | 42 | @keyframes blink-dark { 43 | to { 44 | background: #a29afc; 45 | } 46 | } 47 | 48 | @keyframes blink-light { 49 | to { 50 | opacity: .3; 51 | } 52 | } 53 | 54 | .Toastify { 55 | z-index: 9999; 56 | } 57 | 58 | .animate-fade { 59 | animation: animate-fade .5s ease; 60 | } 61 | 62 | @keyframes animate-fade { 63 | 0% { 64 | opacity: 0; 65 | } 66 | 100% { 67 | opacity: 1; 68 | } 69 | } 70 | 71 | .fade-enter { 72 | max-height: 0; 73 | opacity: 0; 74 | } 75 | 76 | .fade-enter-active { 77 | max-height: 500px; 78 | opacity: 1; 79 | transition: all .5s; 80 | } 81 | 82 | .fade-exit { 83 | max-height: 500px; 84 | opacity: 1; 85 | } 86 | 87 | .fade-exit-active { 88 | max-height: 0; 89 | opacity: 0; 90 | transition: all .5s; 91 | } 92 | 93 | .social-login-divider { 94 | position: relative; 95 | display: block; 96 | width: 100%; 97 | text-align: center; 98 | @apply text-sm; 99 | @apply text-gray-400; 100 | 101 | &:after, 102 | &:before { 103 | content: ''; 104 | position: absolute; 105 | top: 0; 106 | bottom: 0; 107 | margin: auto 0; 108 | width: 45%; 109 | height: 1px; 110 | @apply bg-gray-200; 111 | } 112 | 113 | &:after { 114 | right: 0; 115 | } 116 | 117 | &:before { 118 | left: 0; 119 | } 120 | } -------------------------------------------------------------------------------- /frontend/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.paths.json", 3 | "compilerOptions": { 4 | "target": "es5", 5 | "lib": [ 6 | "dom", 7 | "dom.iterable", 8 | "esnext" 9 | ], 10 | "baseUrl": "./", 11 | "allowJs": true, 12 | "skipLibCheck": true, 13 | "esModuleInterop": true, 14 | "allowSyntheticDefaultImports": true, 15 | "strict": true, 16 | "forceConsistentCasingInFileNames": true, 17 | "noFallthroughCasesInSwitch": true, 18 | "module": "esnext", 19 | "moduleResolution": "node", 20 | "resolveJsonModule": true, 21 | "isolatedModules": true, 22 | "noEmit": true, 23 | "jsx": "react-jsx" 24 | }, 25 | "include": [ 26 | "src" 27 | ] 28 | } 29 | -------------------------------------------------------------------------------- /frontend/tsconfig.paths.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "baseUrl": "./src", 4 | "paths": { 5 | "~/*": [ 6 | "./src/*" 7 | ] 8 | } 9 | } 10 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "foodie", 3 | "version": "1.0.0", 4 | "description": "A social media website for food lovers and cooks.", 5 | "main": "index.js", 6 | "repository": { 7 | "type": "git", 8 | "url": "" 9 | }, 10 | "engines": { 11 | "node": "12.18.1" 12 | }, 13 | "scripts": { 14 | "start-client": "cd frontend && npm start", 15 | "start-server": "cd server && npm start", 16 | "start": "concurrently \"npm run start-server\" \"npm run start-client\"", 17 | "init-project": "concurrently \"cd frontend && npm install\" \"cd server && npm install\"" 18 | }, 19 | "keywords": [], 20 | "author": "Julius Guevarra ", 21 | "license": "MIT", 22 | "devDependencies": { 23 | "concurrently": "^5.3.0" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /server/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .env 3 | .env.dev 4 | .env.prod -------------------------------------------------------------------------------- /server/Procfile: -------------------------------------------------------------------------------- 1 | web: node ./build/server.js -------------------------------------------------------------------------------- /server/nodemon.json: -------------------------------------------------------------------------------- 1 | { 2 | "watch": [ 3 | "src", 4 | ".env" 5 | ], 6 | "ext": "js,ts,json", 7 | "ignore": [ 8 | "src/**/*.spec.ts", 9 | "src/**/*.test.ts" 10 | ], 11 | "exec": "ts-node --transpile-only src/server.ts" 12 | } -------------------------------------------------------------------------------- /server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "server", 3 | "version": "0.0.0", 4 | "private": true, 5 | "scripts": { 6 | "serve": "node ./build/server.js", 7 | "start": "nodemon --exec ts-node --files -r tsconfig-paths/register src/server.ts", 8 | "build": "rimraf ./build && tsc && ef-tspm", 9 | "postinstall": "rimraf ./build && tsc && ef-tspm" 10 | }, 11 | "engines": { 12 | "node": "12.18.1" 13 | }, 14 | "dependencies": { 15 | "@google-cloud/storage": "^5.7.2", 16 | "bad-words": "^3.0.4", 17 | "bcrypt": "^5.0.0", 18 | "cloudinary": "^1.25.0", 19 | "connect-mongo": "^3.2.0", 20 | "cookie-parser": "~1.4.4", 21 | "cors": "^2.8.5", 22 | "cross-env": "^7.0.3", 23 | "csurf": "^1.11.0", 24 | "debug": "~2.6.9", 25 | "express": "^4.16.4", 26 | "express-rate-limit": "^5.2.3", 27 | "express-session": "^1.17.1", 28 | "helmet": "^4.2.0", 29 | "hpp": "^0.2.3", 30 | "http-errors": "~1.6.3", 31 | "jade": "~1.11.0", 32 | "joi": "^17.3.0", 33 | "jsonwebtoken": "^8.5.1", 34 | "lodash.omit": "^4.5.0", 35 | "mongoose": "^5.11.5", 36 | "morgan": "~1.9.1", 37 | "multer": "^1.4.2", 38 | "node-fetch": "^2.6.1", 39 | "passport": "^0.4.1", 40 | "passport-facebook": "^3.0.0", 41 | "passport-github": "^1.1.0", 42 | "passport-google-oauth2": "^0.2.0", 43 | "passport-local": "^1.0.0", 44 | "socket.io": "^3.0.4" 45 | }, 46 | "devDependencies": { 47 | "@ef-carbon/tspm": "^2.2.5", 48 | "@types/bad-words": "^3.0.0", 49 | "@types/bcrypt": "^3.0.0", 50 | "@types/connect-mongo": "^3.1.3", 51 | "@types/csurf": "^1.11.0", 52 | "@types/express": "^4.17.11", 53 | "@types/express-session": "^1.17.0", 54 | "@types/hpp": "^0.2.1", 55 | "@types/http-errors": "^1.8.0", 56 | "@types/morgan": "^1.9.2", 57 | "@types/multer": "^1.4.5", 58 | "@types/node": "^14.14.25", 59 | "@types/passport": "^1.0.5", 60 | "@types/passport-facebook": "^2.1.10", 61 | "@types/passport-github": "^1.1.5", 62 | "@types/passport-google-oauth2": "^0.1.3", 63 | "@types/passport-local": "^1.0.33", 64 | "@types/socket.io": "^2.1.13", 65 | "clean-css": "^4.2.3", 66 | "dotenv": "^8.2.0", 67 | "mquery": "^3.2.3", 68 | "nodemon": "^2.0.6", 69 | "rimraf": "^3.0.2", 70 | "ts-node": "^9.1.1", 71 | "tsconfig-paths": "^3.9.0", 72 | "typescript": "^4.1.3" 73 | } 74 | } 75 | -------------------------------------------------------------------------------- /server/src/app.ts: -------------------------------------------------------------------------------- 1 | import cors from 'cors'; 2 | import csurf from 'csurf'; 3 | import createDebug from 'debug'; 4 | import express from 'express'; 5 | import session, { SessionOptions } from 'express-session'; 6 | import helmet from 'helmet'; 7 | import hpp from 'hpp'; 8 | import http, { Server } from 'http'; 9 | import createError from 'http-errors'; 10 | import logger from 'morgan'; 11 | import passport from 'passport'; 12 | import config from './config/config'; 13 | import initializePassport from './config/passport'; 14 | import initializeSocket from './config/socket'; 15 | import initializeDB from './db/db'; 16 | import errorHandler from './middlewares/error.middleware'; 17 | import routers from './routes/createRouter'; 18 | 19 | const debug = createDebug('server:server'); 20 | 21 | console.log(config); 22 | 23 | class Express { 24 | public app: express.Application; 25 | public server: Server; 26 | 27 | constructor() { 28 | this.app = express(); 29 | this.server = http.createServer(this.app); 30 | initializeDB(); 31 | this.initializeMiddlewares(); 32 | initializeSocket(this.app, this.server); 33 | initializePassport(passport); 34 | } 35 | 36 | private initializeMiddlewares() { 37 | this.app.disable('x-powered-by'); 38 | this.app.use(express.json()); 39 | this.app.use(express.urlencoded({ extended: true })); 40 | this.app.use(cors(config.cors)); 41 | this.app.set('trust proxy', 1); 42 | this.app.use(logger('dev')); 43 | this.app.use(helmet()); 44 | this.app.use(hpp()); 45 | 46 | this.app.use(session(config.session as SessionOptions)); 47 | this.app.use(passport.initialize()); 48 | this.app.use(passport.session()); 49 | this.app.use('/api', routers); 50 | 51 | // catch 404 and forward to error handler 52 | this.app.use(function (req, res, next) { 53 | next(createError(404)); 54 | }); 55 | 56 | // error handler 57 | this.app.use(csurf()); 58 | this.app.use(errorHandler); 59 | } 60 | 61 | public onError() { 62 | this.server.on('error', (error: NodeJS.ErrnoException) => { 63 | if (error.syscall !== 'listen') { 64 | throw error; 65 | } 66 | 67 | const bind = typeof config.server.port === 'string' 68 | ? 'Pipe ' + config.server.port 69 | : 'Port ' + config.server.port; 70 | 71 | // handle specific listen errors with friendly messages 72 | switch (error.code) { 73 | case 'EACCES': 74 | console.error(bind + ' requires elevated privileges'); 75 | process.exit(1); 76 | case 'EADDRINUSE': 77 | console.error(bind + ' is already in use'); 78 | process.exit(1); 79 | default: 80 | throw error; 81 | } 82 | }) 83 | } 84 | 85 | public onListening() { 86 | this.server.on('listening', () => { 87 | const addr = this.server.address(); 88 | const bind = typeof addr === 'string' 89 | ? 'pipe ' + addr 90 | : 'port ' + addr.port; 91 | 92 | debug('Listening on ' + bind); 93 | }) 94 | } 95 | 96 | public listen() { 97 | this.server.listen(config.server.port, () => { 98 | console.log(`# Application is listening on port ${config.server.port} #`) 99 | }) 100 | } 101 | } 102 | 103 | export default Express; -------------------------------------------------------------------------------- /server/src/config/config.ts: -------------------------------------------------------------------------------- 1 | import connectMongo from 'connect-mongo'; 2 | import session from 'express-session'; 3 | import mongoose from 'mongoose'; 4 | import path from 'path'; 5 | 6 | const MongoStore = connectMongo(session); 7 | const env = process.env.NODE_ENV || 'dev'; 8 | 9 | if (env === 'dev') { 10 | require('dotenv').config({ 11 | path: path.join(__dirname, '../../.env-dev') 12 | }) 13 | } 14 | 15 | export default { 16 | server: { 17 | env, 18 | port: process.env.PORT || 9000, 19 | }, 20 | mongodb: { 21 | uri: process.env.MONGODB_URI, 22 | dbName: process.env.MONGODB_DB_NAME 23 | }, 24 | session: { 25 | key: process.env.SESSION_NAME, 26 | secret: process.env.SESSION_SECRET, 27 | resave: false, 28 | saveUninitialized: true, 29 | cookie: { 30 | expires: new Date(new Date().getTime() + 7 * 24 * 60 * 60 * 1000), 31 | secure: env !== 'dev', 32 | sameSite: env === 'dev' ? 'strict' : 'none', 33 | httpOnly: env !== 'dev' 34 | }, //14 days expiration 35 | store: new MongoStore({ 36 | mongooseConnection: mongoose.connection, 37 | collection: 'session' 38 | }) 39 | }, 40 | cors: { 41 | origin: process.env.CLIENT_URL, 42 | credentials: true, 43 | preflightContinue: true 44 | }, 45 | gCloudStorage: { 46 | projectId: process.env.FIREBASE_PROJECT_ID, 47 | keyFilename: process.env.GOOGLE_APPLICATION_CREDENTIALS 48 | }, 49 | cloudinary: { 50 | cloud_name: process.env.CLOUDINARY_NAME, 51 | api_key: process.env.CLOUDINARY_API_KEY, 52 | api_secret: process.env.CLOUDINARY_API_SECRET 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /server/src/config/socket.ts: -------------------------------------------------------------------------------- 1 | import config from '@/config/config'; 2 | import User from '@/schemas/UserSchema'; 3 | import { Application } from "express"; 4 | import { Server } from "http"; 5 | 6 | export default function (app: Application, server: Server) { 7 | const io = require('socket.io')(server, { 8 | cors: { 9 | origin: config.cors.origin || 'http://localhost:3000', 10 | methods: ["GET", "POST", "PATCH"], 11 | credentials: true 12 | } 13 | }); 14 | 15 | app.set('io', io); 16 | 17 | io.on("connection", (socket: SocketIO.Socket) => { 18 | socket.on("userConnect", (id) => { 19 | User 20 | .findById(id) 21 | .then((user) => { 22 | if (user) { 23 | socket.join(user._id.toString()); 24 | console.log('Client connected.'); 25 | } 26 | }) 27 | .catch((e) => { 28 | console.log('Invalid user ID, cannot join Socket.'); 29 | }); 30 | }); 31 | 32 | socket.on("userDisconnect", (userID) => { 33 | socket.leave(userID); 34 | console.log('Client Disconnected.'); 35 | }); 36 | 37 | socket.on("onFollowUser", (data) => { 38 | console.log(data); 39 | }); 40 | 41 | socket.on("user-typing", ({ user, state }) => { 42 | io.to(user.id).emit("typing", state) 43 | }) 44 | 45 | socket.on("disconnect", () => { 46 | console.log('Client disconnected'); 47 | }); 48 | }); 49 | } -------------------------------------------------------------------------------- /server/src/constants/constants.ts: -------------------------------------------------------------------------------- 1 | // Limits count for querying documents 2 | export const NOTIFICATIONS_LIMIT = 10; 3 | export const MESSAGES_LIMIT = 10; 4 | export const LIKES_LIMIT = 10; 5 | export const FEED_LIMIT = 10; 6 | export const COMMENTS_LIMIT = 10; 7 | export const USERS_LIMIT = 10; 8 | export const POST_LIMIT = 10; 9 | export const BOOKMARKS_LIMIT = 10; -------------------------------------------------------------------------------- /server/src/constants/error-types.ts: -------------------------------------------------------------------------------- 1 | export const INVALID_INPUT = 'INVALID_INPUT'; 2 | export const INCORRECT_CREDENTIALS = 'INCORRECT_CREDENTIALS'; 3 | export const EMAIL_TAKEN = 'EMAIL_TAKEN'; 4 | export const VALIDATION_ERROR = 'VALIDATION_ERROR'; 5 | export const DUPLICATE_FIELDS = 'DUPLICATE_FIELDS'; 6 | export const REQUEST_ERROR = 'REQUEST_ERROR'; -------------------------------------------------------------------------------- /server/src/db/db.ts: -------------------------------------------------------------------------------- 1 | const mongoose = require('mongoose'); 2 | import config from '@/config/config'; 3 | const mongoUri = config.mongodb.uri || 'mongodb://localhost:27017'; 4 | const dbName = config.mongodb.dbName || 'foodie'; 5 | 6 | if (config.server.env === 'dev') { 7 | mongoose.set("debug", true); 8 | } 9 | 10 | const options = { 11 | useNewUrlParser: true, 12 | useUnifiedTopology: true, 13 | useCreateIndex: true, 14 | useFindAndModify: false, 15 | serverSelectionTimeoutMS: 5000, 16 | dbName 17 | }; 18 | 19 | export default async function () { 20 | try { 21 | await mongoose.connect(mongoUri, options); 22 | console.log(`MongoDB connected as ${mongoUri}`); 23 | } catch (e) { 24 | console.log('Error connecting to mongoose: ', e); 25 | } 26 | }; 27 | -------------------------------------------------------------------------------- /server/src/helpers/utils.ts: -------------------------------------------------------------------------------- 1 | import { IUser } from "@/schemas/UserSchema"; 2 | import Filter from 'bad-words'; 3 | 4 | interface IResponseStatus { 5 | status_code: number; 6 | success: Boolean; 7 | data: any; 8 | error: any; 9 | timestamp: string | Date; 10 | } 11 | 12 | const initStatus: IResponseStatus = { 13 | status_code: 404, 14 | success: false, 15 | data: null, 16 | error: null, 17 | timestamp: null 18 | }; 19 | 20 | const sessionizeUser = (user: Partial) => ({ 21 | id: user._id, 22 | username: user.username, 23 | fullname: user.fullname, 24 | profilePicture: user.profilePicture 25 | }) 26 | 27 | const makeResponseJson = (data: any, success = true) => { 28 | return { 29 | ...initStatus, 30 | status_code: 200, 31 | success, 32 | data, 33 | timestamp: new Date() 34 | }; 35 | } 36 | 37 | const newBadWords = [ 38 | 'gago', 'puta', 'animal', 'porn', 'amputa', 'tangina', 'pota', 'puta', 'putangina', 39 | 'libog', 'eut', 'iyot', 'iyutan', 'eutan', 'umiyot', 'karat', 'pornhub', 'ptngina', 'tngina' 40 | ]; 41 | 42 | const filterWords = new Filter(); 43 | filterWords.addWords(...newBadWords); 44 | 45 | export { sessionizeUser, makeResponseJson, filterWords }; 46 | 47 | -------------------------------------------------------------------------------- /server/src/middlewares/error.middleware.ts: -------------------------------------------------------------------------------- 1 | import { NextFunction, Request, Response } from "express"; 2 | 3 | class ErrorHandler extends Error { 4 | statusCode: number | undefined; 5 | message: string | undefined; 6 | constructor(statusCode?: number, message?: string) { 7 | super() 8 | this.statusCode = statusCode; 9 | this.message = message; 10 | } 11 | } 12 | 13 | interface IErrorResponseJSON { 14 | statusCode: number; 15 | title?: string; 16 | type?: string; 17 | message?: string; 18 | errors?: any[]; 19 | } 20 | 21 | const errorResponseJSON = ({ statusCode, title, type, message, errors = null }: IErrorResponseJSON) => ({ 22 | status_code: statusCode, 23 | success: false, 24 | data: null, 25 | error: { type, title, message, errors }, 26 | timestamp: new Date().getTime() 27 | }) 28 | 29 | const errorMiddleware = (err: any, req: Request, res: Response, next: NextFunction) => { 30 | const { statusCode = 500, message = 'Internal Server Error' } = err; 31 | 32 | if (err.name === 'MongoError' && err.code === 11000) { // Mongo error 33 | const field = Object.keys(err.keyValue); 34 | 35 | return res.status(409).json(errorResponseJSON({ 36 | statusCode: 409, 37 | title: 'Conflict', 38 | type: 'CONFLICT_ERROR', 39 | message: `An account with that ${field} already exists.` 40 | })); 41 | } 42 | 43 | if (err.name === 'ValidationError') { // For mongoose validation error handler 44 | const errors = Object.values(err.errors).map((el: any) => ({ message: el.message, path: el.path })); 45 | 46 | return res.status(400).json(errorResponseJSON({ 47 | statusCode: 400, 48 | title: 'Invalid Input', 49 | type: 'INVALID_INPUT_ERROR', 50 | message: err?.message || 'Invalid input.', 51 | errors 52 | })); 53 | } 54 | 55 | if (err.statusCode === 400) { // BadRequestError 56 | return res.status(400).json(errorResponseJSON({ 57 | statusCode: 400, 58 | title: 'Bad Request.', 59 | type: 'BAD_REQUEST_ERROR', 60 | message: err?.message || 'Bad request.' 61 | })); 62 | } 63 | 64 | if (err.statusCode === 401) { // UnathorizeError 65 | return res.status(401).json(errorResponseJSON({ 66 | statusCode: 401, 67 | title: 'Unauthorize Error', 68 | type: 'UNAUTHORIZE_ERROR', 69 | message: err?.message || "You're not authorized to perform your request." 70 | })); 71 | } 72 | 73 | if (err.statusCode === 403) { // Forbidden 74 | return res.status(403).json(errorResponseJSON({ 75 | statusCode: 403, 76 | title: 'Forbidden Error', 77 | type: 'FORBIDDEN_ERROR', 78 | message: err?.message || 'Forbidden request.' 79 | })); 80 | } 81 | 82 | if (err.statusCode === 404) { // NotFoundError 83 | return res.status(404).json(errorResponseJSON({ 84 | statusCode: 404, 85 | title: 'Resource Not Found', 86 | type: 'NOT_FOUND_ERROR', 87 | message: err?.message || 'Requested resource not found.' 88 | })); 89 | } 90 | 91 | if (err.statusCode === 422) { // UnprocessableEntity 92 | // return res.status(422).json(errorResponseJSON(422, err?.message || 'Unable to process your request.')); 93 | return res.status(422).json(errorResponseJSON({ 94 | statusCode: 422, 95 | title: 'Unprocessable Entity', 96 | type: 'UNPROCESSABLE_ENTITY_ERROR', 97 | message: err?.message || 'Unable to process your request.' 98 | })); 99 | } 100 | 101 | console.log('FROM MIDDLEWARE ------------------------', err) 102 | res.status(statusCode).json(errorResponseJSON({ 103 | statusCode, 104 | title: 'Server Error', 105 | type: 'SERVER_ERROR', 106 | message 107 | })); 108 | } 109 | 110 | export { errorMiddleware as default, ErrorHandler }; 111 | 112 | -------------------------------------------------------------------------------- /server/src/middlewares/index.ts: -------------------------------------------------------------------------------- 1 | export * from './error.middleware'; 2 | export * from './middlewares'; 3 | -------------------------------------------------------------------------------- /server/src/middlewares/middlewares.ts: -------------------------------------------------------------------------------- 1 | import { NextFunction } from 'express'; 2 | import { isValidObjectId } from 'mongoose'; 3 | import { ErrorHandler } from './error.middleware'; 4 | 5 | function isAuthenticated(req: any, res: any, next: NextFunction) { 6 | if (req.isAuthenticated()) { 7 | console.log('CHECK MIDDLEWARE: IS AUTH: ', req.isAuthenticated()); 8 | return next(); 9 | } 10 | 11 | return next(new ErrorHandler(401)); 12 | } 13 | 14 | function validateObjectID(...ObjectIDs) { 15 | return function (req: any, res: any, next: NextFunction) { 16 | ObjectIDs.forEach((id) => { 17 | if (!isValidObjectId(req.params[id])) { 18 | return next(new ErrorHandler(400, `ObjectID ${id} supplied is not valid`)); 19 | } else { 20 | next(); 21 | } 22 | }); 23 | } 24 | } 25 | 26 | export { isAuthenticated, validateObjectID }; 27 | 28 | -------------------------------------------------------------------------------- /server/src/routes/api/v1/bookmark.ts: -------------------------------------------------------------------------------- 1 | import { BOOKMARKS_LIMIT } from '@/constants/constants'; 2 | import { makeResponseJson } from '@/helpers/utils'; 3 | import { ErrorHandler } from '@/middlewares/error.middleware'; 4 | import { isAuthenticated, validateObjectID } from '@/middlewares/middlewares'; 5 | import { Bookmark, Post } from '@/schemas'; 6 | import { NextFunction, Request, Response, Router } from 'express'; 7 | import { Types } from 'mongoose'; 8 | 9 | const router = Router({ mergeParams: true }); 10 | 11 | router.post( 12 | '/v1/bookmark/post/:post_id', 13 | isAuthenticated, 14 | validateObjectID('post_id'), 15 | async (req: Request, res: Response, next: NextFunction) => { 16 | try { 17 | const { post_id } = req.params; 18 | const userID = req.user._id; 19 | 20 | const post = await Post.findById(post_id); 21 | if (!post) return res.sendStatus(404); 22 | 23 | if (userID.toString() === post._author_id.toString()) { 24 | return next(new ErrorHandler(400, 'You can\'t bookmark your own post.')); 25 | } 26 | 27 | const isPostBookmarked = await Bookmark 28 | .findOne({ 29 | _author_id: userID, 30 | _post_id: Types.ObjectId(post_id) 31 | }); 32 | 33 | if (isPostBookmarked) { 34 | await Bookmark.findOneAndDelete({ _author_id: userID, _post_id: Types.ObjectId(post_id) }); 35 | 36 | res.status(200).send(makeResponseJson({ state: false })); 37 | } else { 38 | const bookmark = new Bookmark({ 39 | _post_id: post_id, 40 | _author_id: userID, 41 | createdAt: Date.now() 42 | }); 43 | await bookmark.save(); 44 | 45 | res.status(200).send(makeResponseJson({ state: true })); 46 | } 47 | } catch (e) { 48 | console.log('CANT BOOKMARK POST ', e); 49 | next(e) 50 | } 51 | } 52 | ); 53 | 54 | router.get( 55 | '/v1/bookmarks', 56 | isAuthenticated, 57 | async (req: Request, res: Response, next: NextFunction) => { 58 | try { 59 | const userID = req.user._id; 60 | const offset = parseInt((req.query.offset as string), 10) || 0; 61 | const limit = BOOKMARKS_LIMIT; 62 | const skip = offset * limit; 63 | 64 | const bookmarks = await Bookmark 65 | .find({ _author_id: userID }) 66 | .populate({ 67 | path: 'post', 68 | select: 'photos description', 69 | populate: { 70 | path: 'likesCount commentsCount' 71 | } 72 | }) 73 | .limit(limit) 74 | .skip(skip) 75 | .sort({ createdAt: -1 }); 76 | 77 | if (bookmarks.length === 0) { 78 | return next(new ErrorHandler(404, "You don't have any bookmarks.")) 79 | } 80 | 81 | const result = bookmarks.map((item) => { 82 | return { 83 | ...item.toObject(), 84 | isBookmarked: true, 85 | } 86 | }); 87 | 88 | res.status(200).send(makeResponseJson(result)); 89 | } catch (e) { 90 | console.log('CANT GET BOOKMARKS ', e); 91 | next(e); 92 | } 93 | } 94 | ); 95 | 96 | export default router; 97 | -------------------------------------------------------------------------------- /server/src/routes/api/v1/feed.ts: -------------------------------------------------------------------------------- 1 | import { FEED_LIMIT } from '@/constants/constants'; 2 | import { makeResponseJson } from '@/helpers/utils'; 3 | import { ErrorHandler } from '@/middlewares'; 4 | import { EPrivacy } from '@/schemas/PostSchema'; 5 | import { NewsFeedService, PostService } from '@/services'; 6 | import { NextFunction, Request, Response, Router } from 'express'; 7 | 8 | const router = Router({ mergeParams: true }); 9 | 10 | router.get( 11 | '/v1/feed', 12 | async (req: Request, res: Response, next: NextFunction) => { 13 | 14 | try { 15 | const offset = parseInt((req.query.offset as string), 10) || 0; 16 | const limit = FEED_LIMIT; 17 | const skip = offset * limit; 18 | 19 | let result = []; 20 | 21 | if (req.isAuthenticated()) { 22 | result = await NewsFeedService.getNewsFeed( 23 | req.user, 24 | { follower: req.user._id }, 25 | skip, 26 | limit 27 | ); 28 | } else { 29 | result = await PostService.getPosts(null, { privacy: EPrivacy.public }, { skip, limit, sort: { createdAt: -1 } }); 30 | } 31 | 32 | if (result.length === 0) { 33 | return next(new ErrorHandler(404, 'No more feed.')); 34 | } 35 | 36 | res.status(200).send(makeResponseJson(result)); 37 | } catch (e) { 38 | console.log('CANT GET FEED', e); 39 | next(e); 40 | } 41 | } 42 | ); 43 | 44 | export default router; 45 | -------------------------------------------------------------------------------- /server/src/routes/api/v1/notification.ts: -------------------------------------------------------------------------------- 1 | import { NOTIFICATIONS_LIMIT } from '@/constants/constants'; 2 | import { makeResponseJson } from '@/helpers/utils'; 3 | import { ErrorHandler, isAuthenticated } from '@/middlewares'; 4 | import { Notification } from '@/schemas'; 5 | import { NextFunction, Request, Response, Router } from 'express'; 6 | 7 | const router = Router({ mergeParams: true }); 8 | 9 | router.get( 10 | '/v1/notifications', 11 | isAuthenticated, 12 | async (req: Request, res: Response, next: NextFunction) => { 13 | try { 14 | let offset = parseInt(req.query.offset as string) || 0; 15 | 16 | const limit = NOTIFICATIONS_LIMIT; 17 | const skip = offset * limit; 18 | 19 | const notifications = await Notification 20 | .find({ target: req.user._id }) 21 | .populate('target initiator', 'profilePicture username fullname') 22 | .sort({ createdAt: -1 }) 23 | .limit(limit) 24 | .skip(skip); 25 | const unreadCount = await Notification.find({ target: req.user._id, unread: true }); 26 | const count = await Notification.find({ target: req.user._id }); 27 | const result = { notifications, unreadCount: unreadCount.length, count: count.length }; 28 | 29 | if (notifications.length === 0 && offset === 0) { 30 | return next(new ErrorHandler(404, 'You have no notifications.')); 31 | } else if (notifications.length === 0 && offset >= 1) { 32 | return next(new ErrorHandler(404, 'No more notifications.')); 33 | } 34 | 35 | res.status(200).send(makeResponseJson(result)); 36 | } catch (e) { 37 | console.log(e); 38 | next(e); 39 | } 40 | } 41 | ); 42 | 43 | router.get( 44 | '/v1/notifications/unread', 45 | isAuthenticated, 46 | async (req: Request, res: Response, next: NextFunction) => { 47 | try { 48 | const notif = await Notification.find({ target: req.user._id, unread: true }); 49 | 50 | res.status(200).send(makeResponseJson({ count: notif.length })); 51 | } catch (e) { 52 | console.log('CANT GET UNREAD NOTIFICATIONS', e); 53 | next(e); 54 | } 55 | } 56 | ); 57 | 58 | router.patch( 59 | '/v1/notifications/mark', 60 | isAuthenticated, 61 | async (req: Request, res: Response, next: NextFunction) => { 62 | try { 63 | await Notification 64 | .updateMany( 65 | { target: req.user._id }, 66 | { 67 | $set: { 68 | unread: false 69 | } 70 | }); 71 | res.status(200).send(makeResponseJson({ state: false })); 72 | } catch (e) { 73 | console.log('CANT MARK ALL AS UNREAD', e); 74 | next(e); 75 | } 76 | } 77 | ); 78 | 79 | router.patch( 80 | '/v1/read/notification/:id', 81 | isAuthenticated, 82 | async (req: Request, res: Response, next: NextFunction) => { 83 | try { 84 | const { id } = req.params; 85 | const notif = await Notification.findById(id); 86 | if (!notif) return res.sendStatus(400); 87 | 88 | await Notification 89 | .findByIdAndUpdate(id, { 90 | $set: { 91 | unread: false 92 | } 93 | }); 94 | 95 | res.status(200).send(makeResponseJson({ state: false })) // state = false EQ unread = false 96 | } catch (e) { 97 | next(e); 98 | } 99 | } 100 | ); 101 | 102 | export default router; 103 | -------------------------------------------------------------------------------- /server/src/routes/api/v1/search.ts: -------------------------------------------------------------------------------- 1 | import { makeResponseJson } from '@/helpers/utils'; 2 | import { ErrorHandler } from '@/middlewares'; 3 | import { Follow, User } from '@/schemas'; 4 | import { EPrivacy } from '@/schemas/PostSchema'; 5 | import { PostService } from '@/services'; 6 | import { NextFunction, Request, Response, Router } from 'express'; 7 | 8 | const router = Router({ mergeParams: true }); 9 | 10 | router.get( 11 | '/v1/search', 12 | async (req: Request, res: Response, next: NextFunction) => { 13 | try { 14 | const { q, type } = req.query; 15 | const offset = parseInt(req.query.offset as string) || 0; 16 | const limit = parseInt(req.query.limit as string) || 10; 17 | const skip = offset * limit; 18 | 19 | if (!q) return next(new ErrorHandler(400, 'Search query is required.')); 20 | 21 | let result = []; 22 | 23 | if (type === 'posts') { 24 | const posts = await PostService 25 | .getPosts( 26 | req.user, 27 | { 28 | description: { 29 | $regex: q, 30 | $options: 'i' 31 | }, 32 | privacy: EPrivacy.public 33 | }, 34 | { 35 | sort: { createdAt: -1 }, 36 | skip, 37 | limit 38 | } 39 | ); 40 | 41 | if (posts.length === 0) { 42 | return next(new ErrorHandler(404, 'No posts found.')); 43 | } 44 | 45 | result = posts; 46 | // console.log(posts); 47 | } else { 48 | const users = await User 49 | .find({ 50 | $or: [ 51 | { firstname: { $regex: q, $options: 'i' } }, 52 | { lastname: { $regex: q, $options: 'i' } }, 53 | { username: { $regex: q, $options: 'i' } } 54 | ] 55 | }) 56 | .limit(limit) 57 | .skip(skip); 58 | 59 | if (users.length === 0) { 60 | return next(new ErrorHandler(404, 'No users found.')); 61 | } 62 | 63 | const myFollowingDoc = await Follow.find({ user: req.user?._id }); 64 | const myFollowing = myFollowingDoc.map(user => user.target); 65 | 66 | const usersResult = users.map((user) => { 67 | return { 68 | ...user.toProfileJSON(), 69 | isFollowing: myFollowing.includes(user.id) 70 | } 71 | }); 72 | 73 | result = usersResult; 74 | } 75 | 76 | res.status(200).send(makeResponseJson(result)); 77 | } catch (e) { 78 | console.log('CANT PERFORM SEARCH: ', e); 79 | next(e); 80 | } 81 | 82 | } 83 | ); 84 | 85 | export default router; 86 | -------------------------------------------------------------------------------- /server/src/routes/createRouter.ts: -------------------------------------------------------------------------------- 1 | import config from '@/config/config'; 2 | import { Router } from 'express'; 3 | import glob from 'glob'; 4 | 5 | const routers = glob 6 | .sync(`**/*.${config.server.env === 'dev' ? 'ts' : 'js'}`, { cwd: `${__dirname}/` }) 7 | .map((filename: string) => require(`./${filename}`)) 8 | .filter((router: any) => { 9 | return router.default && Object.getPrototypeOf(router.default) === Router 10 | }) 11 | .reduce((rootRouter: Router, router: any) => { 12 | return rootRouter.use(router.default) 13 | }, Router({ mergeParams: true })); 14 | 15 | export default routers; -------------------------------------------------------------------------------- /server/src/schemas/BookmarkSchema.ts: -------------------------------------------------------------------------------- 1 | import { Document, model, Schema } from "mongoose"; 2 | import { IPost } from "./PostSchema"; 3 | import { IUser } from "./UserSchema"; 4 | 5 | export interface IBookmark extends Document { 6 | _post_id: IPost['_id']; 7 | _author_id: IUser['_id']; 8 | createdAt: string | Date; 9 | } 10 | 11 | const BookmarkSchema = new Schema({ 12 | _post_id: { 13 | type: Schema.Types.ObjectId, 14 | ref: 'Post', 15 | required: true 16 | }, 17 | _author_id: { 18 | type: Schema.Types.ObjectId, 19 | ref: 'User', 20 | required: true 21 | }, 22 | createdAt: { 23 | type: Date, 24 | required: true 25 | } 26 | 27 | }, { timestamps: true, toJSON: { virtuals: true }, toObject: { getters: true, virtuals: true } }); 28 | 29 | BookmarkSchema.virtual('post', { 30 | ref: 'Post', 31 | localField: '_post_id', 32 | foreignField: '_id', 33 | justOne: true 34 | }); 35 | 36 | export default model('Bookmark', BookmarkSchema); 37 | -------------------------------------------------------------------------------- /server/src/schemas/ChatSchema.ts: -------------------------------------------------------------------------------- 1 | import { Document, model, Schema } from "mongoose"; 2 | import { IMessage } from "./MessageSchema"; 3 | import { IUser } from "./UserSchema"; 4 | 5 | export interface IChat extends Document { 6 | participants: Array; 7 | lastmessage: IMessage['_id']; 8 | } 9 | 10 | const ChatSchema = new Schema({ 11 | participants: [{ 12 | type: Schema.Types.ObjectId, 13 | ref: 'User' 14 | }], 15 | lastmessage: { 16 | type: Schema.Types.ObjectId, 17 | ref: 'Message' 18 | } 19 | 20 | }, { timestamps: true, toJSON: { virtuals: true }, toObject: { getters: true, virtuals: true } }); 21 | 22 | export default model('Chat', ChatSchema); 23 | -------------------------------------------------------------------------------- /server/src/schemas/CommentSchema.ts: -------------------------------------------------------------------------------- 1 | import { Document, model, Schema } from "mongoose"; 2 | import { IPost } from "./PostSchema"; 3 | import { IUser } from "./UserSchema"; 4 | 5 | export interface IComment extends Document { 6 | _post_id: IPost['_id']; 7 | body: string; 8 | _author_id: IUser['_id']; 9 | depth: number; 10 | parent: IComment['id']; 11 | parents: IComment['id'][]; 12 | isEdited: boolean; 13 | createdAt: number | Date; 14 | updatedAt: number | Date; 15 | } 16 | 17 | const options = { 18 | timestamps: true, 19 | toJSON: { 20 | virtuals: true, 21 | transform: function (doc, ret, opt) { 22 | delete ret.parents; 23 | return ret; 24 | } 25 | }, 26 | toObject: { 27 | getters: true, 28 | virtuals: true, 29 | transform: function (doc, ret, opt) { 30 | delete ret.parents; 31 | return ret; 32 | } 33 | } 34 | } 35 | 36 | const CommentSchema = new Schema({ 37 | _post_id: { 38 | type: Schema.Types.ObjectId, 39 | ref: 'Post', 40 | required: true 41 | }, 42 | parent: { 43 | type: Schema.Types.ObjectId, 44 | ref: 'Comment', 45 | default: null 46 | }, 47 | parents: [{ 48 | type: Schema.Types.ObjectId, 49 | ref: 'Comment' 50 | }], 51 | depth: { 52 | type: Number, 53 | default: 1 54 | }, 55 | body: String, 56 | _author_id: { 57 | type: Schema.Types.ObjectId, 58 | ref: 'User' 59 | }, 60 | isEdited: { 61 | type: Boolean, 62 | default: false 63 | }, 64 | createdAt: Date, 65 | updatedAt: Date 66 | }, options); 67 | 68 | CommentSchema.virtual('author', { 69 | ref: 'User', 70 | localField: '_author_id', 71 | foreignField: '_id', 72 | justOne: true 73 | }); 74 | 75 | export default model('Comment', CommentSchema); 76 | -------------------------------------------------------------------------------- /server/src/schemas/FollowSchema.ts: -------------------------------------------------------------------------------- 1 | import { Document, model, Schema } from "mongoose"; 2 | import { IUser } from "./UserSchema"; 3 | 4 | export interface IFollow extends Document { 5 | user: IUser['_id']; 6 | target: IUser['_id']; 7 | } 8 | 9 | const FollowSchema = new Schema({ 10 | user: { 11 | type: Schema.Types.ObjectId, 12 | ref: 'User', 13 | required: true 14 | }, 15 | target: { 16 | type: Schema.Types.ObjectId, 17 | ref: 'User', 18 | default: [] 19 | }, 20 | }, { timestamps: true, toJSON: { virtuals: true }, toObject: { getters: true, virtuals: true } }); 21 | 22 | export default model('Follow', FollowSchema); 23 | -------------------------------------------------------------------------------- /server/src/schemas/LikeSchema.ts: -------------------------------------------------------------------------------- 1 | import { Document, model, Schema } from "mongoose"; 2 | import { IPost } from "./PostSchema"; 3 | import { IUser } from "./UserSchema"; 4 | 5 | export interface ILike extends Document { 6 | target: IPost['_id']; 7 | user: IUser['_id']; 8 | } 9 | 10 | const LikeSchema = new Schema({ 11 | type: { 12 | type: String, 13 | required: true, 14 | enum: ['Post', 'Comment'] 15 | }, 16 | target: { 17 | type: Schema.Types.ObjectId, 18 | refPath: 'type', 19 | required: true 20 | }, 21 | user: { 22 | type: Schema.Types.ObjectId, 23 | ref: 'User', 24 | required: true 25 | } 26 | }, { timestamps: true, toJSON: { virtuals: true }, toObject: { getters: true, virtuals: true } }); 27 | 28 | export default model('Like', LikeSchema); 29 | -------------------------------------------------------------------------------- /server/src/schemas/MessageSchema.ts: -------------------------------------------------------------------------------- 1 | import { Document, model, Schema } from "mongoose"; 2 | import { IUser } from "./UserSchema"; 3 | 4 | export interface IMessage extends Document { 5 | from: IUser['_id']; 6 | to: IUser['_id']; 7 | text: string; 8 | seen: boolean; 9 | createdAt: string | number; 10 | } 11 | 12 | const MessageSchema = new Schema({ 13 | from: { 14 | type: Schema.Types.ObjectId, 15 | ref: 'User', 16 | required: true 17 | }, 18 | to: { 19 | type: Schema.Types.ObjectId, 20 | ref: 'User', 21 | required: true 22 | }, 23 | text: { 24 | type: String, 25 | required: true 26 | }, 27 | seen: { 28 | type: Boolean, 29 | default: false 30 | }, 31 | createdAt: { 32 | type: Date, 33 | required: true 34 | } 35 | 36 | }, { timestamps: true, toJSON: { virtuals: true }, toObject: { getters: true, virtuals: true } }); 37 | 38 | export default model('Message', MessageSchema); 39 | -------------------------------------------------------------------------------- /server/src/schemas/NewsFeedSchema.ts: -------------------------------------------------------------------------------- 1 | import { Document, model, Schema } from "mongoose"; 2 | import { IPost } from "./PostSchema"; 3 | import { IUser } from "./UserSchema"; 4 | 5 | export interface INewsFeed extends Document { 6 | follower: IUser['_id']; 7 | post: IPost['_id']; 8 | post_owner: IUser['_id']; 9 | } 10 | 11 | const NewsFeedSchema = new Schema({ 12 | follower: { 13 | type: Schema.Types.ObjectId, 14 | required: true, 15 | ref: 'User' 16 | }, 17 | post: { 18 | type: Schema.Types.ObjectId, 19 | required: true, 20 | ref: 'Post' 21 | }, 22 | post_owner: { 23 | type: Schema.Types.ObjectId, 24 | required: true, 25 | ref: 'User' 26 | }, 27 | createdAt: Date 28 | }); 29 | 30 | export default model('NewsFeed', NewsFeedSchema); 31 | -------------------------------------------------------------------------------- /server/src/schemas/NotificationSchema.ts: -------------------------------------------------------------------------------- 1 | import { Document, model, Schema } from "mongoose"; 2 | import { IUser } from "./UserSchema"; 3 | 4 | export enum ENotificationType { 5 | follow = 'follow', 6 | like = 'like', 7 | commentLike = 'comment-like', 8 | comment = 'comment', 9 | reply = 'reply' 10 | } 11 | 12 | interface INotificationDocument extends Document { 13 | type: ENotificationType; 14 | initiator: IUser['_id']; 15 | target: IUser['_id']; 16 | unread: boolean; 17 | link: string; 18 | createdAt: string | number; 19 | } 20 | 21 | const NotificationSchema = new Schema({ 22 | type: { 23 | type: String, 24 | required: true, 25 | enum: ['follow', 'like', 'comment-like', 'comment', 'reply'], 26 | }, 27 | initiator: { 28 | type: Schema.Types.ObjectId, 29 | ref: 'User', 30 | required: true 31 | }, 32 | target: { 33 | type: Schema.Types.ObjectId, 34 | ref: 'User', 35 | required: true 36 | }, 37 | unread: { 38 | type: Boolean, 39 | default: true 40 | }, 41 | link: { 42 | type: String, 43 | required: true 44 | }, 45 | createdAt: { 46 | type: Date, 47 | required: true 48 | } 49 | }, { 50 | timestamps: true, 51 | toJSON: { 52 | virtuals: true 53 | }, 54 | toObject: { 55 | virtuals: true, 56 | getters: true 57 | } 58 | }); 59 | 60 | const Notification = model('Notification', NotificationSchema); 61 | 62 | export default Notification; 63 | -------------------------------------------------------------------------------- /server/src/schemas/PostSchema.ts: -------------------------------------------------------------------------------- 1 | import { Document, isValidObjectId, model, Schema } from "mongoose"; 2 | import { IComment } from "./CommentSchema"; 3 | import { IUser } from "./UserSchema"; 4 | 5 | export enum EPrivacy { 6 | private = 'private', 7 | public = 'public', 8 | follower = 'follower' 9 | } 10 | 11 | export interface IPost extends Document { 12 | _author_id: IUser['_id']; 13 | privacy: EPrivacy; 14 | photos?: Record[]; 15 | description: string; 16 | likes: Array; 17 | comments: Array; 18 | isEdited: boolean; 19 | createdAt: string | number; 20 | updatedAt: string | number; 21 | 22 | author: IUser; 23 | 24 | isPostLiked(id: string): boolean; 25 | } 26 | 27 | const PostSchema = new Schema({ 28 | _author_id: { 29 | // author: { 30 | type: Schema.Types.ObjectId, 31 | ref: 'User', 32 | required: true 33 | }, 34 | privacy: { 35 | type: String, 36 | default: 'public', 37 | enum: ['private', 'public', 'follower'] 38 | }, 39 | photos: [Object], 40 | description: { 41 | type: String, 42 | default: '' 43 | }, 44 | isEdited: { 45 | type: Boolean, 46 | default: false 47 | }, 48 | createdAt: Date, 49 | updatedAt: Date, 50 | }, { timestamps: true, toJSON: { virtuals: true }, toObject: { getters: true, virtuals: true } }); 51 | 52 | PostSchema.virtual('author', { 53 | ref: 'User', 54 | localField: '_author_id', 55 | foreignField: '_id', 56 | justOne: true 57 | }); 58 | 59 | PostSchema.methods.isPostLiked = function (this: IPost, userID) { 60 | if (!isValidObjectId(userID)) return; 61 | 62 | return this.likes.some(user => { 63 | return user._id.toString() === userID.toString(); 64 | }); 65 | } 66 | 67 | export default model('Post', PostSchema); 68 | -------------------------------------------------------------------------------- /server/src/schemas/index.ts: -------------------------------------------------------------------------------- 1 | export { default as Bookmark } from './BookmarkSchema'; 2 | export { default as Chat } from './ChatSchema'; 3 | export { default as Comment } from './CommentSchema'; 4 | export { default as Follow } from './FollowSchema'; 5 | export { default as Like } from './LikeSchema'; 6 | export { default as Message } from './MessageSchema'; 7 | export { default as NewsFeed } from './NewsFeedSchema'; 8 | export { default as Notification } from './NotificationSchema'; 9 | export { default as Post } from './PostSchema'; 10 | export { default as User } from './UserSchema'; 11 | 12 | -------------------------------------------------------------------------------- /server/src/server.ts: -------------------------------------------------------------------------------- 1 | import Express from './app'; 2 | 3 | const express = new Express(); 4 | express.listen(); 5 | express.onError(); 6 | express.onListening(); -------------------------------------------------------------------------------- /server/src/services/follow.service.ts: -------------------------------------------------------------------------------- 1 | import { Follow } from "@/schemas"; 2 | import { IUser } from "@/schemas/UserSchema"; 3 | 4 | export const getFollow = ( 5 | query: Object, 6 | type = 'followers', 7 | user: IUser, 8 | skip?: number, 9 | limit?: number 10 | ): Promise => { 11 | return new Promise(async (resolve, reject) => { 12 | try { 13 | const myFollowingDoc = await Follow.find({ user: user._id }); 14 | const myFollowing = myFollowingDoc.map(user => user.target); // map to array of user IDs 15 | 16 | const agg = await Follow.aggregate([ 17 | { 18 | $match: query 19 | }, 20 | { $skip: skip }, 21 | { $limit: limit }, 22 | { 23 | $lookup: { 24 | from: 'users', 25 | localField: type === 'following' ? 'target' : 'user', 26 | foreignField: '_id', 27 | as: 'user' 28 | } 29 | }, 30 | { 31 | $unwind: '$user' 32 | }, 33 | { 34 | $addFields: { 35 | isFollowing: { $in: ['$user._id', myFollowing] } 36 | } 37 | }, 38 | { 39 | $project: { 40 | _id: 0, 41 | id: '$user._id', 42 | username: '$user.username', 43 | email: '$user.email', 44 | profilePicture: '$user.profilePicture', 45 | isFollowing: 1 46 | } 47 | } 48 | ]); 49 | 50 | resolve(agg); 51 | } catch (err) { 52 | reject(err); 53 | } 54 | }); 55 | } -------------------------------------------------------------------------------- /server/src/services/index.ts: -------------------------------------------------------------------------------- 1 | export * as FollowService from './follow.service'; 2 | export * as NewsFeedService from './newsfeed.service'; 3 | export * as PostService from './post.service'; 4 | 5 | -------------------------------------------------------------------------------- /server/src/storage/cloudinary.ts: -------------------------------------------------------------------------------- 1 | import config from '@/config/config'; 2 | import { AdminApiOptions, v2 as cloudinaryV2 } from 'cloudinary'; 3 | import Multer from 'multer'; 4 | 5 | cloudinaryV2.config(config.cloudinary); 6 | 7 | export const multer = Multer({ 8 | dest: 'uploads/', 9 | limits: { 10 | fileSize: 2 * 1024 * 1024 // no larger than 2mb 11 | } 12 | }); 13 | 14 | export const uploadImageToStorage = (file: File | File[], folder: string) => { 15 | if (file) { 16 | return new Promise(async (resolve, reject) => { 17 | const opts: AdminApiOptions = { 18 | folder, 19 | resource_type: 'auto', 20 | overwrite: true, 21 | quality: 'auto' 22 | }; 23 | 24 | if (Array.isArray(file)) { 25 | const req = file.map((img: any) => { 26 | return cloudinaryV2.uploader.upload(img.path, opts); 27 | }); 28 | 29 | try { 30 | const result = await Promise.all(req); 31 | resolve(result); 32 | } catch (err) { 33 | reject(err); 34 | } 35 | } else { 36 | try { 37 | const result = await cloudinaryV2.uploader.upload((file as any).path, opts); 38 | resolve(result); 39 | } catch (err) { 40 | reject(err); 41 | } 42 | } 43 | }); 44 | } 45 | } 46 | 47 | export const deleteImageFromStorage = (publicID: string | string[]) => { 48 | if (publicID) { 49 | return new Promise(async (resolve, reject) => { 50 | if (Array.isArray(publicID)) { 51 | try { 52 | await cloudinaryV2.api.delete_resources(publicID); 53 | resolve({ state: true }); 54 | } catch (err) { 55 | reject(err); 56 | } 57 | } else { 58 | try { 59 | await cloudinaryV2.uploader.destroy(publicID, { invalidate: true }); 60 | resolve({ state: true }); 61 | } catch (err) { 62 | reject(err); 63 | } 64 | } 65 | }); 66 | } 67 | } -------------------------------------------------------------------------------- /server/src/storage/filestorage.ts: -------------------------------------------------------------------------------- 1 | import config from '@/config/config'; 2 | import { Storage } from '@google-cloud/storage'; 3 | import { format } from 'util'; 4 | 5 | const storage = new Storage(config.gCloudStorage); 6 | 7 | const bucket = storage.bucket(process.env.FIREBASE_STORAGE_BUCKET_URL); 8 | 9 | const uploadImageToStorage = (file) => { 10 | return new Promise((resolve, reject) => { 11 | if (!file) { 12 | reject('No image file'); 13 | } 14 | let newFileName = `${file.originalname}`; 15 | 16 | let fileUpload = bucket.file(newFileName); 17 | 18 | const blobStream = fileUpload.createWriteStream({ 19 | metadata: { 20 | contentType: file.mimetype 21 | } 22 | }); 23 | 24 | blobStream.on('error', (err) => { 25 | console.log(err); 26 | reject('Something is wrong! Unable to upload at the moment.'); 27 | }); 28 | 29 | blobStream.on('finish', () => { 30 | // The public URL can be used to directly access the file via HTTP. 31 | const url = format(`https://storage.googleapis.com/${bucket.name}/${fileUpload.name}`); 32 | resolve(url); 33 | }); 34 | 35 | blobStream.end(file.buffer); 36 | }); 37 | } 38 | 39 | const deleteImageFromStorage = (...images) => { 40 | return new Promise(async (resolve, reject) => { 41 | if (images.length === 0) { 42 | return reject('Images to delete not provided.'); 43 | } 44 | 45 | try { 46 | images.map(async (image) => { 47 | const spl = image.split('/'); 48 | const filename = spl[spl.length - 1]; 49 | 50 | await bucket.file(filename).delete(); 51 | }); 52 | 53 | resolve('Successfully deleted.'); 54 | } catch (e) { 55 | console.log(e); 56 | reject('Cannot delete images.'); 57 | } 58 | }); 59 | } 60 | 61 | export { uploadImageToStorage, deleteImageFromStorage }; 62 | 63 | -------------------------------------------------------------------------------- /server/src/types/express/index.d.ts: -------------------------------------------------------------------------------- 1 | import { IUser } from "../../schemas/UserSchema"; 2 | 3 | declare global { 4 | namespace Express { 5 | interface SessionData { 6 | cookie: any 7 | } 8 | } 9 | } 10 | 11 | declare module 'express' { 12 | export interface Request { 13 | user: IUser; 14 | file: any; 15 | files: any; 16 | query: any; 17 | } 18 | } -------------------------------------------------------------------------------- /server/src/utils/storage.utils.ts: -------------------------------------------------------------------------------- 1 | const { Storage } = require('@google-cloud/storage'); 2 | const Multer = require('multer'); 3 | const config = require('../config/config'); 4 | 5 | export default class CloudStorage { 6 | public bucket; 7 | 8 | public initialize() { 9 | const storage = new Storage(config.gCloudStorage); 10 | 11 | this.bucket = storage.bucket(process.env.FIREBASE_STORAGE_BUCKET_URL); 12 | } 13 | } 14 | 15 | export const multer = Multer({ 16 | storage: Multer.memoryStorage(), 17 | limits: { 18 | fileSize: 2 * 1024 * 1024 // no larger than 2mb 19 | } 20 | }); -------------------------------------------------------------------------------- /server/src/validations/validations.ts: -------------------------------------------------------------------------------- 1 | import { ErrorHandler } from '@/middlewares'; 2 | import { NextFunction, Request, Response } from 'express'; 3 | import Joi, { Schema } from 'joi'; 4 | 5 | const email = Joi 6 | .string() 7 | .email({ minDomainSegments: 2, tlds: { allow: ['com', 'net'] } }) 8 | .required() 9 | .messages({ 10 | 'string.base': `Email should be a type of 'text'`, 11 | 'string.empty': `Email cannot be an empty field`, 12 | 'string.min': `Email should have a minimum length of {#limit}`, 13 | 'any.required': `Email is a required field.` 14 | }); 15 | 16 | const password = Joi 17 | .string() 18 | .min(8) 19 | .max(50) 20 | .required() 21 | .messages({ 22 | 'string.base': `Password should be a type of 'text'`, 23 | 'string.empty': `Password cannot be an empty field`, 24 | 'string.min': `Password should have a minimum length of {#limit}`, 25 | 'any.required': `Password is a required field` 26 | }); 27 | const username = Joi 28 | .string() 29 | .required() 30 | .messages({ 31 | 'string.base': 'Username should be of type "text"', 32 | 'string.empty': `Username cannot be an empty field`, 33 | 'string.min': `Username should have a minimum length of {#limit}`, 34 | 'any.required': 'Username field is required' 35 | }); 36 | 37 | export const schemas = { 38 | loginSchema: Joi.object().keys({ 39 | username, 40 | password 41 | }).options({ abortEarly: false }), 42 | registerSchema: Joi.object().keys({ 43 | email, 44 | password, 45 | username 46 | }).options({ abortEarly: false }), 47 | createPostSchema: Joi.object().keys({ 48 | description: Joi.string(), 49 | photos: Joi.array(), 50 | privacy: Joi.string() 51 | }), 52 | commentSchema: Joi.object().keys({ 53 | body: Joi 54 | .string() 55 | .required() 56 | .messages({ 57 | 'string.base': 'Comment body should be of type "string"', 58 | 'string.empty': `Comment body cannot be an empty field`, 59 | 'any.required': 'Comment body field is required' 60 | }), 61 | post_id: Joi.string().empty(''), 62 | comment_id: Joi.string().empty('') 63 | }), 64 | editProfileSchema: Joi.object().keys({ 65 | firstname: Joi.string().empty(''), 66 | lastname: Joi.string().empty(''), 67 | bio: Joi.string().empty(''), 68 | gender: Joi.string().empty(''), 69 | birthday: Joi.date().empty('') 70 | }) 71 | }; 72 | 73 | export const validateBody = (schema: Schema) => { 74 | return (req: Request & { value: any }, res: Response, next: NextFunction) => { 75 | const result = schema.validate(req.body); 76 | 77 | if (result.error) { 78 | console.log(result.error); 79 | return next(new ErrorHandler(400, result.error.details[0].message)) 80 | } else { 81 | if (!req.value) { 82 | req.value = {} 83 | } 84 | req.value['body'] = result.value; 85 | next(); 86 | } 87 | } 88 | } 89 | -------------------------------------------------------------------------------- /server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es2017", 4 | "lib": [ 5 | "es2017", 6 | "esnext.asynciterable" 7 | ], 8 | "baseUrl": ".", 9 | "paths": { 10 | "@/*": [ 11 | "./src/*" 12 | ] 13 | }, 14 | "typeRoots": [ 15 | "./node_modules/@types", 16 | "./src/types" 17 | ], 18 | "allowSyntheticDefaultImports": true, 19 | "experimentalDecorators": true, 20 | "emitDecoratorMetadata": true, 21 | "forceConsistentCasingInFileNames": true, 22 | "moduleResolution": "node", 23 | "module": "commonjs", 24 | "pretty": true, 25 | "sourceMap": true, 26 | "outDir": "./build", 27 | "allowJs": true, 28 | "noEmit": false, 29 | "esModuleInterop": true, 30 | "resolveJsonModule": true 31 | }, 32 | "include": [ 33 | "./src/**/*", 34 | ".env" 35 | ], 36 | "exclude": [ 37 | "node_modules", 38 | ] 39 | } --------------------------------------------------------------------------------