├── .env.example ├── .gitignore ├── LICENSE ├── README.md ├── client ├── package-lock.json ├── package.json ├── public │ ├── favicon.ico │ └── index.html └── src │ ├── App.css │ ├── App.js │ ├── App.scss │ ├── actions │ ├── alert.js │ ├── auth.js │ ├── board.js │ └── types.js │ ├── components │ ├── board │ │ ├── ArchivedCards.js │ │ ├── ArchivedLists.js │ │ ├── BoardDrawer.js │ │ ├── BoardTitle.js │ │ ├── CreateList.js │ │ └── Members.js │ ├── card │ │ ├── Card.js │ │ ├── CardMembers.js │ │ ├── CardModal.js │ │ ├── DeleteCard.js │ │ └── MoveCard.js │ ├── checklist │ │ ├── Checklist.js │ │ ├── ChecklistItem.js │ │ └── CreateChecklistItem.js │ ├── list │ │ ├── CreateCardForm.js │ │ ├── List.js │ │ ├── ListMenu.js │ │ ├── ListTitle.js │ │ └── MoveList.js │ ├── other │ │ ├── Alert.js │ │ ├── Copyright.js │ │ ├── CreateBoard.js │ │ └── Navbar.js │ └── pages │ │ ├── Board.js │ │ ├── Dashboard.js │ │ ├── Landing.js │ │ ├── Login.js │ │ └── Register.js │ ├── index.js │ ├── reducers │ ├── alert.js │ ├── auth.js │ ├── board.js │ └── index.js │ ├── store.js │ └── utils │ ├── dialogStyles.js │ ├── drawerStyles.js │ ├── formStyles.js │ ├── getInitials.js │ ├── modalStyles.js │ └── setAuthToken.js ├── middleware ├── auth.js └── member.js ├── models ├── Board.js ├── Card.js ├── List.js └── User.js ├── package-lock.json ├── package.json ├── preview.PNG ├── routes └── api │ ├── auth.js │ ├── boards.js │ ├── cards.js │ ├── checklists.js │ ├── lists.js │ └── users.js └── server.js /.env.example: -------------------------------------------------------------------------------- 1 | MONGO_URI=mongodb+srv://name:password@... 2 | JWT_SECRET=yoursecretkey -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | *.lcov 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Dependency directories 41 | node_modules/ 42 | jspm_packages/ 43 | 44 | # TypeScript v1 declaration files 45 | typings/ 46 | 47 | # TypeScript cache 48 | *.tsbuildinfo 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Microbundle cache 57 | .rpt2_cache/ 58 | .rts2_cache_cjs/ 59 | .rts2_cache_es/ 60 | .rts2_cache_umd/ 61 | 62 | # Optional REPL history 63 | .node_repl_history 64 | 65 | # Output of 'npm pack' 66 | *.tgz 67 | 68 | # Yarn Integrity file 69 | .yarn-integrity 70 | 71 | # dotenv environment variables file 72 | .env 73 | .env.test 74 | 75 | # parcel-bundler cache (https://parceljs.org/) 76 | .cache 77 | 78 | # Next.js build output 79 | .next 80 | 81 | # Nuxt.js build / generate output 82 | .nuxt 83 | dist 84 | 85 | # Gatsby files 86 | .cache/ 87 | # Comment in the public line in if your project uses Gatsby and *not* Next.js 88 | # https://nextjs.org/blog/next-9-1#public-directory-support 89 | # public 90 | 91 | # vuepress build output 92 | .vuepress/dist 93 | 94 | # Serverless directories 95 | .serverless/ 96 | 97 | # FuseBox cache 98 | .fusebox/ 99 | 100 | # DynamoDB Local files 101 | .dynamodb/ 102 | 103 | # TernJS port file 104 | .tern-port 105 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Archawin Wongkittiruk 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 | # TrelloClone 2 | 3 | 4 | 5 | https://aw-trello-clone.herokuapp.com/ 6 | 7 | A Trello clone built using the MERN stack. 8 | 9 | The Trello board I used to organise this project's workflow: 10 | https://trello.com/b/2rP2cJBz/trello-clone 11 | 12 | "I used Trello to clone Trello." 13 | \- Archawin Wongkittiruk (2020) 14 | 15 | ## Quick Start 16 | 17 | You will need Node.js, a browser, and a terminal to run this application. You can use any code editor. I developed this app with Visual Studio Code, and that is what I would recommend. 18 | 19 | ### Add a .env file at the root specifying your own variables 20 | 21 | MONGO_URI - This application uses MongoDB Atlas to host the database in the cloud. You can also use a local database during development. See https://www.mongodb.com/. 22 | 23 | JWT_SECRET - Any random string will do. 24 | 25 | ### Install server dependencies 26 | 27 | ```bash 28 | npm install 29 | ``` 30 | 31 | ### Install client dependencies 32 | 33 | ```bash 34 | cd client 35 | npm install 36 | ``` 37 | 38 | ### Run the server and client at the same time from the root 39 | 40 | ```bash 41 | npm run dev 42 | ``` 43 | 44 | ## Credits 45 | 46 | Major credits to this Udemy course by Brad Traversy for laying the groundwork for my understanding of the MERN stack: https://www.udemy.com/course/mern-stack-front-to-back/, the source code for which can be found at https://github.com/bradtraversy/devconnector_2.0. The quick start for this README was also inspired by that repository's quick start. 47 | -------------------------------------------------------------------------------- /client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "client", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@material-ui/core": "^4.11.3", 7 | "@material-ui/icons": "^4.11.2", 8 | "@material-ui/lab": "^4.0.0-alpha.57", 9 | "@testing-library/jest-dom": "^4.2.4", 10 | "@testing-library/react": "^9.5.0", 11 | "@testing-library/user-event": "^7.2.1", 12 | "axios": "^0.21.1", 13 | "moment": "^2.29.1", 14 | "react": "^16.14.0", 15 | "react-beautiful-dnd": "^13.0.0", 16 | "react-color": "^2.19.3", 17 | "react-dom": "^16.14.0", 18 | "react-moment": "^0.9.7", 19 | "react-redux": "^7.2.2", 20 | "react-router-dom": "^5.2.0", 21 | "react-scripts": "^4.0.2", 22 | "redux": "^4.0.5", 23 | "redux-devtools-extension": "^2.13.8", 24 | "redux-thunk": "^2.3.0", 25 | "uuid": "^8.3.2" 26 | }, 27 | "scripts": { 28 | "start": "react-scripts start", 29 | "build": "react-scripts build", 30 | "test": "react-scripts test", 31 | "eject": "react-scripts eject" 32 | }, 33 | "eslintConfig": { 34 | "extends": "react-app" 35 | }, 36 | "browserslist": { 37 | "production": [ 38 | ">0.2%", 39 | "not dead", 40 | "not op_mini all" 41 | ], 42 | "development": [ 43 | "last 1 chrome version", 44 | "last 1 firefox version", 45 | "last 1 safari version" 46 | ] 47 | }, 48 | "proxy": "http://localhost:5000" 49 | } 50 | -------------------------------------------------------------------------------- /client/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ArchawinWongkittiruk/TrelloClone/e6ed4a02fe375bc79b386b9fd3a4b7729f328a24/client/public/favicon.ico -------------------------------------------------------------------------------- /client/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | TrelloClone 13 | 14 | 15 | 16 |
17 | 18 | 19 | -------------------------------------------------------------------------------- /client/src/App.css: -------------------------------------------------------------------------------- 1 | * { 2 | box-sizing: border-box; 3 | margin: 0; 4 | padding: 0; 5 | font-family: 'Roboto', sans-serif; 6 | } 7 | 8 | body { 9 | font-size: 1rem; 10 | background-color: #eee; 11 | color: #333; 12 | } 13 | 14 | .copyright { 15 | text-align: center; 16 | color: #eee; 17 | } 18 | 19 | .navbar { 20 | display: flex; 21 | flex-direction: row; 22 | justify-content: space-between; 23 | padding: 10px; 24 | } 25 | 26 | .navbar a { 27 | text-decoration: none; 28 | color: white; 29 | font-size: 1rem; 30 | opacity: 0.7; 31 | } 32 | 33 | .navbar a:hover { 34 | opacity: 1; 35 | } 36 | 37 | .landing { 38 | height: 100vh; 39 | color: white; 40 | text-align: center; 41 | background: linear-gradient(135deg, #0079bf, #5067c5); 42 | } 43 | 44 | .landing .top { 45 | display: flex; 46 | flex-direction: row; 47 | justify-content: space-between; 48 | padding: 20px; 49 | } 50 | 51 | .landing .landing-inner { 52 | align-items: center; 53 | display: flex; 54 | flex-direction: column; 55 | padding: 150px 50px; 56 | } 57 | 58 | .landing h1 { 59 | font-size: 5rem; 60 | margin-bottom: 20px; 61 | } 62 | 63 | .landing p { 64 | font-size: 1.5rem; 65 | margin-bottom: 20px; 66 | } 67 | 68 | .landing p a { 69 | text-decoration: none; 70 | color: lightgrey; 71 | transition: 0.3s; 72 | } 73 | 74 | .landing p a:hover { 75 | color: darkgrey; 76 | } 77 | 78 | @media (max-width: 700px) { 79 | .landing h1 { 80 | font-size: 3.5rem; 81 | } 82 | } 83 | 84 | .dashboard-and-navbar .navbar { 85 | background-color: #026aa7; 86 | } 87 | 88 | .dashboard { 89 | display: flex; 90 | flex-direction: column; 91 | align-items: center; 92 | padding: 50px; 93 | } 94 | 95 | .dashboard h1 { 96 | text-align: center; 97 | font-weight: 500; 98 | } 99 | 100 | .dashboard h2 { 101 | margin-top: 40px; 102 | font-weight: 400; 103 | } 104 | 105 | .dashboard .dashboard-loading { 106 | margin: 40px; 107 | } 108 | 109 | .dashboard .boards { 110 | margin: 10px; 111 | display: flex; 112 | flex-direction: row; 113 | flex-wrap: wrap; 114 | align-items: center; 115 | justify-content: center; 116 | } 117 | 118 | .dashboard .board-card { 119 | width: 220px; 120 | height: 120px; 121 | padding: 20px 50px 20px 20px; 122 | margin: 20px; 123 | text-decoration: none; 124 | font-weight: 500; 125 | color: white; 126 | border-radius: 10px; 127 | background-color: #5067c5; 128 | } 129 | 130 | .dashboard .board-card:hover { 131 | background-color: #4057b5; 132 | } 133 | 134 | .dashboard .create-board-card { 135 | padding: 0; 136 | border: none; 137 | color: #333; 138 | font-size: 1rem; 139 | background-color: lightgrey; 140 | cursor: pointer; 141 | } 142 | 143 | .dashboard .create-board-card:hover { 144 | background-color: darkgrey; 145 | } 146 | 147 | .board-and-navbar { 148 | background-size: cover; 149 | height: 100vh; 150 | } 151 | 152 | .board-and-navbar .navbar { 153 | background-color: rgba(50, 50, 50, 0.4); 154 | } 155 | 156 | .board-loading { 157 | text-align: center; 158 | margin-top: 20%; 159 | } 160 | 161 | .board { 162 | padding: 10px; 163 | } 164 | 165 | .board .board-top { 166 | padding: 5px; 167 | display: flex; 168 | flex-wrap: wrap; 169 | flex-direction: row; 170 | justify-content: space-between; 171 | } 172 | 173 | .board .board-top .board-top-left { 174 | display: flex; 175 | flex-wrap: wrap; 176 | flex-direction: row; 177 | } 178 | 179 | @media (max-width: 960px) { 180 | .board .board-top .board-top-left { 181 | flex-direction: column; 182 | } 183 | } 184 | 185 | .board .board-top .board-top-left .board-title { 186 | cursor: pointer; 187 | color: snow; 188 | padding: 5px 0 0 5px; 189 | max-width: 500px; 190 | white-space: nowrap; 191 | overflow: hidden; 192 | } 193 | 194 | .board .board-top .board-top-left .board-title-form { 195 | background-color: snow; 196 | } 197 | 198 | .board .board-top .board-top-left .board-members-wrapper { 199 | display: flex; 200 | flex-wrap: wrap; 201 | margin: 0 20px; 202 | } 203 | 204 | @media (max-width: 960px) { 205 | .board .board-top .board-top-left .board-members-wrapper { 206 | margin: 20px 20px 20px 0; 207 | } 208 | } 209 | 210 | .board .board-top .board-top-left .board-members-wrapper .board-members { 211 | display: flex; 212 | flex-wrap: wrap; 213 | } 214 | 215 | .board .board-top .board-top-left .invite { 216 | margin-left: 10px; 217 | display: flex; 218 | flex-wrap: wrap; 219 | } 220 | 221 | .board .board-top .board-top-left .invite .search-member { 222 | width: 250px; 223 | margin-right: 10px; 224 | height: 2.5rem; 225 | } 226 | 227 | .board .avatar { 228 | margin-right: 2px; 229 | color: darkslategrey; 230 | cursor: default; 231 | background-color: #eee; 232 | } 233 | 234 | .board .avatar:hover { 235 | background-color: #ddd; 236 | } 237 | 238 | .board .create-list-button { 239 | margin-top: 10px; 240 | min-width: 200px; 241 | } 242 | 243 | .board .create-list-form { 244 | min-width: 280px; 245 | padding: 0 10px 10px; 246 | margin-top: 10px; 247 | height: fit-content; 248 | background-color: #eee; 249 | border-radius: 5px; 250 | display: flex; 251 | flex-direction: column; 252 | } 253 | 254 | .board .archived-card { 255 | display: flex; 256 | flex-direction: column; 257 | } 258 | 259 | .board .lists { 260 | display: flex; 261 | flex-direction: row; 262 | overflow-x: auto; 263 | } 264 | 265 | @media (min-height: 600px) and (min-width: 1000px) { 266 | .board .lists { 267 | min-height: 83vh; 268 | } 269 | } 270 | 271 | @media (min-height: 960px) { 272 | .board .lists { 273 | min-height: 88vh; 274 | } 275 | } 276 | 277 | .board .lists .list-wrapper { 278 | background-color: #eee; 279 | border-radius: 5px; 280 | min-width: 280px; 281 | max-width: 280px; 282 | height: fit-content; 283 | margin-top: 10px; 284 | margin-right: 10px; 285 | padding: 10px; 286 | } 287 | 288 | .board .lists .list-wrapper .list-top { 289 | display: flex; 290 | flex-direction: row; 291 | justify-content: space-between; 292 | } 293 | 294 | .board .lists .list-wrapper .list-top .list-title { 295 | cursor: pointer; 296 | padding: 5px 0 0 5px; 297 | white-space: nowrap; 298 | overflow: hidden; 299 | } 300 | 301 | .board .lists .list-wrapper .create-card-button { 302 | margin-top: 5px; 303 | } 304 | 305 | .board .lists .list-wrapper .create-card-form { 306 | margin-top: 5px; 307 | display: flex; 308 | flex-direction: column; 309 | } 310 | 311 | .board .lists .list-wrapper .card-edit-content { 312 | padding-top: 0; 313 | padding-bottom: 5px; 314 | } 315 | 316 | .board .lists .list-wrapper .card-actions { 317 | margin-bottom: 5px; 318 | } 319 | 320 | .board .lists .list-wrapper .not-adding-card { 321 | max-height: 64vh; 322 | } 323 | 324 | @media (min-height: 960px) { 325 | .board .lists .list-wrapper .not-adding-card { 326 | max-height: 75vh; 327 | } 328 | } 329 | 330 | .board .lists .list-wrapper .adding-card { 331 | max-height: 69vh; 332 | } 333 | 334 | @media (min-height: 960px) { 335 | .board .lists .list-wrapper .adding-card { 336 | max-height: 80vh; 337 | } 338 | } 339 | 340 | .board .lists .list-wrapper .list { 341 | min-height: 1px; 342 | overflow-y: auto; 343 | } 344 | 345 | .board .lists .list-wrapper .list .cards { 346 | display: flex; 347 | flex-direction: column; 348 | margin-right: 2px; 349 | } 350 | 351 | .board .lists .list-wrapper .list .cards .card { 352 | margin: 5px 0; 353 | position: relative; 354 | cursor: pointer; 355 | } 356 | 357 | .board .lists .list-wrapper .list .cards .card .card-label { 358 | height: 9px; 359 | width: 45px; 360 | border-radius: 5px; 361 | margin-bottom: 5px; 362 | } 363 | 364 | .board .lists .list-wrapper .list .cards .card .description-indicator { 365 | margin: 3px 5px -5px -3px; 366 | } 367 | 368 | .board .lists .list-wrapper .list .cards .card .checklist-indicator { 369 | display: flex; 370 | align-items: center; 371 | padding: 1px 5px 0 4px; 372 | height: 25px; 373 | margin: auto; 374 | } 375 | 376 | .board .lists .list-wrapper .list .cards .card .checklist-indicator .checklist-indicator-icon { 377 | margin-right: 2px; 378 | } 379 | 380 | .board .lists .list-wrapper .list .cards .card .completed-checklist-indicator { 381 | background-color: #00b800; 382 | border-radius: 5px; 383 | color: snow; 384 | } 385 | 386 | .board .lists .list-wrapper .list .cards .card .card-bottom { 387 | display: flex; 388 | justify-content: space-between; 389 | flex-wrap: wrap; 390 | margin-top: 3px; 391 | margin-bottom: -5px; 392 | } 393 | 394 | .board .lists .list-wrapper .list .cards .card .card-bottom .card-bottom-left { 395 | display: flex; 396 | } 397 | 398 | .board .lists .list-wrapper .list .cards .card .card-bottom .card-member-avatars { 399 | display: flex; 400 | flex-wrap: wrap; 401 | } 402 | 403 | .board .lists .list-wrapper .list .cards .card .card-bottom .card-member-avatars .avatar { 404 | width: 30px; 405 | height: 30px; 406 | font-size: 0.8rem; 407 | background-color: #ddd; 408 | } 409 | 410 | .board .lists .list-wrapper .list .cards .card .card-bottom .card-member-avatars .avatar:hover { 411 | background-color: #ccc; 412 | } 413 | 414 | .board .lists .list-wrapper .list .cards .mouse-over { 415 | background-color: whitesmoke; 416 | } 417 | -------------------------------------------------------------------------------- /client/src/App.js: -------------------------------------------------------------------------------- 1 | import React, { Fragment, useEffect } from 'react'; 2 | import { BrowserRouter as Router, Route, Switch } from 'react-router-dom'; 3 | import Landing from './components/pages/Landing'; 4 | import Register from './components/pages/Register'; 5 | import Login from './components/pages/Login'; 6 | import Dashboard from './components/pages/Dashboard'; 7 | import Board from './components/pages/Board'; 8 | import Alert from './components/other/Alert'; 9 | 10 | // Redux 11 | import { Provider } from 'react-redux'; 12 | import store from './store'; 13 | import { loadUser } from './actions/auth'; 14 | import setAuthToken from './utils/setAuthToken'; 15 | 16 | import './App.css'; 17 | 18 | if (localStorage.token) { 19 | setAuthToken(localStorage.token); 20 | } 21 | 22 | const App = () => { 23 | useEffect(() => { 24 | store.dispatch(loadUser()); 25 | }, []); 26 | 27 | return ( 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | ); 43 | }; 44 | 45 | export default App; 46 | -------------------------------------------------------------------------------- /client/src/App.scss: -------------------------------------------------------------------------------- 1 | // Global 2 | 3 | * { 4 | box-sizing: border-box; 5 | margin: 0; 6 | padding: 0; 7 | font-family: 'Roboto', sans-serif; 8 | } 9 | 10 | body { 11 | font-size: 1rem; 12 | background-color: #eee; 13 | color: #333; 14 | } 15 | 16 | .copyright { 17 | text-align: center; 18 | color: #eee; 19 | } 20 | 21 | // Navbar 22 | 23 | .navbar { 24 | display: flex; 25 | flex-direction: row; 26 | justify-content: space-between; 27 | padding: 10px; 28 | 29 | a { 30 | text-decoration: none; 31 | color: white; 32 | font-size: 1rem; 33 | opacity: 0.7; 34 | &:hover { 35 | opacity: 1; 36 | } 37 | } 38 | } 39 | 40 | // Landing 41 | 42 | .landing { 43 | height: 100vh; 44 | color: white; 45 | text-align: center; 46 | background: linear-gradient(135deg, #0079bf, #5067c5); 47 | 48 | .top { 49 | display: flex; 50 | flex-direction: row; 51 | justify-content: space-between; 52 | padding: 20px; 53 | } 54 | 55 | .landing-inner { 56 | align-items: center; 57 | display: flex; 58 | flex-direction: column; 59 | padding: 150px 50px; 60 | } 61 | 62 | h1 { 63 | font-size: 5rem; 64 | margin-bottom: 20px; 65 | } 66 | 67 | p { 68 | font-size: 1.5rem; 69 | margin-bottom: 20px; 70 | 71 | a { 72 | text-decoration: none; 73 | color: lightgrey; 74 | transition: 0.3s; 75 | &:hover { 76 | color: darkgrey; 77 | } 78 | } 79 | } 80 | 81 | @media (max-width: 700px) { 82 | h1 { 83 | font-size: 3.5rem; 84 | } 85 | } 86 | } 87 | 88 | // Dashboard 89 | 90 | .dashboard-and-navbar { 91 | .navbar { 92 | background-color: #026aa7; 93 | } 94 | } 95 | 96 | .dashboard { 97 | display: flex; 98 | flex-direction: column; 99 | align-items: center; 100 | padding: 50px; 101 | 102 | h1 { 103 | text-align: center; 104 | font-weight: 500; 105 | } 106 | 107 | h2 { 108 | margin-top: 40px; 109 | font-weight: 400; 110 | } 111 | 112 | .dashboard-loading { 113 | margin: 40px; 114 | } 115 | 116 | .boards { 117 | margin: 10px; 118 | display: flex; 119 | flex-direction: row; 120 | flex-wrap: wrap; 121 | align-items: center; 122 | justify-content: center; 123 | } 124 | 125 | .board-card { 126 | width: 220px; 127 | height: 120px; 128 | padding: 20px 50px 20px 20px; 129 | margin: 20px; 130 | text-decoration: none; 131 | font-weight: 500; 132 | color: white; 133 | border-radius: 10px; 134 | background-color: #5067c5; 135 | &:hover { 136 | background-color: #4057b5; 137 | } 138 | } 139 | 140 | .create-board-card { 141 | padding: 0; 142 | border: none; 143 | color: #333; 144 | font-size: 1rem; 145 | background-color: lightgrey; 146 | &:hover { 147 | background-color: darkgrey; 148 | } 149 | cursor: pointer; 150 | } 151 | } 152 | 153 | // Board 154 | 155 | .board-and-navbar { 156 | background-size: cover; 157 | height: 100vh; 158 | 159 | .navbar { 160 | background-color: rgba(50, 50, 50, 0.4); 161 | } 162 | } 163 | 164 | .board-loading { 165 | text-align: center; 166 | margin-top: 20%; 167 | } 168 | 169 | .board { 170 | padding: 10px; 171 | 172 | .board-top { 173 | padding: 5px; 174 | display: flex; 175 | flex-wrap: wrap; 176 | flex-direction: row; 177 | justify-content: space-between; 178 | 179 | .board-top-left { 180 | display: flex; 181 | flex-wrap: wrap; 182 | flex-direction: row; 183 | @media (max-width: 960px) { 184 | flex-direction: column; 185 | } 186 | 187 | .board-title { 188 | cursor: pointer; 189 | color: snow; 190 | padding: 5px 0 0 5px; 191 | max-width: 500px; 192 | white-space: nowrap; 193 | overflow: hidden; 194 | } 195 | 196 | .board-title-form { 197 | background-color: snow; 198 | } 199 | 200 | .board-members-wrapper { 201 | display: flex; 202 | flex-wrap: wrap; 203 | margin: 0 20px; 204 | @media (max-width: 960px) { 205 | margin: 20px 20px 20px 0; 206 | } 207 | 208 | .board-members { 209 | display: flex; 210 | flex-wrap: wrap; 211 | } 212 | } 213 | 214 | .invite { 215 | margin-left: 10px; 216 | display: flex; 217 | flex-wrap: wrap; 218 | 219 | .search-member { 220 | width: 250px; 221 | margin-right: 10px; 222 | height: 2.5rem; 223 | } 224 | } 225 | } 226 | } 227 | 228 | .avatar { 229 | margin-right: 2px; 230 | color: darkslategrey; 231 | cursor: default; 232 | background-color: #eee; 233 | &:hover { 234 | background-color: #ddd; 235 | } 236 | } 237 | 238 | .create-list-button { 239 | margin-top: 10px; 240 | min-width: 200px; 241 | } 242 | 243 | .create-list-form { 244 | min-width: 280px; 245 | padding: 0 10px 10px; 246 | margin-top: 10px; 247 | height: fit-content; 248 | background-color: #eee; 249 | border-radius: 5px; 250 | display: flex; 251 | flex-direction: column; 252 | } 253 | 254 | .archived-card { 255 | display: flex; 256 | flex-direction: column; 257 | } 258 | 259 | .lists { 260 | display: flex; 261 | flex-direction: row; 262 | overflow-x: auto; 263 | @media (min-height: 600px) and (min-width: 1000px) { 264 | min-height: 83vh; 265 | } 266 | @media (min-height: 960px) { 267 | min-height: 88vh; 268 | } 269 | 270 | .list-wrapper { 271 | background-color: #eee; 272 | border-radius: 5px; 273 | min-width: 280px; 274 | max-width: 280px; 275 | height: fit-content; 276 | margin-top: 10px; 277 | margin-right: 10px; 278 | padding: 10px; 279 | 280 | .list-top { 281 | display: flex; 282 | flex-direction: row; 283 | justify-content: space-between; 284 | 285 | .list-title { 286 | cursor: pointer; 287 | padding: 5px 0 0 5px; 288 | white-space: nowrap; 289 | overflow: hidden; 290 | } 291 | } 292 | 293 | .create-card-button { 294 | margin-top: 5px; 295 | } 296 | 297 | .create-card-form { 298 | margin-top: 5px; 299 | display: flex; 300 | flex-direction: column; 301 | } 302 | 303 | .card-edit-content { 304 | padding-top: 0; 305 | padding-bottom: 5px; 306 | } 307 | 308 | .card-actions { 309 | margin-bottom: 5px; 310 | } 311 | 312 | .not-adding-card { 313 | max-height: 64vh; 314 | @media (min-height: 960px) { 315 | max-height: 75vh; 316 | } 317 | } 318 | 319 | .adding-card { 320 | max-height: 69vh; 321 | @media (min-height: 960px) { 322 | max-height: 80vh; 323 | } 324 | } 325 | 326 | .list { 327 | min-height: 1px; 328 | overflow-y: auto; 329 | 330 | .cards { 331 | display: flex; 332 | flex-direction: column; 333 | margin-right: 2px; 334 | 335 | .card { 336 | margin: 5px 0; 337 | position: relative; 338 | cursor: pointer; 339 | 340 | .card-label { 341 | height: 9px; 342 | width: 45px; 343 | border-radius: 5px; 344 | margin-bottom: 5px; 345 | } 346 | 347 | .description-indicator { 348 | margin: 3px 5px -5px -3px; 349 | } 350 | 351 | .checklist-indicator { 352 | display: flex; 353 | align-items: center; 354 | padding: 1px 5px 0 4px; 355 | height: 25px; 356 | margin: auto; 357 | 358 | .checklist-indicator-icon { 359 | margin-right: 2px; 360 | } 361 | } 362 | 363 | .completed-checklist-indicator { 364 | background-color: #00b800; 365 | border-radius: 5px; 366 | color: snow; 367 | } 368 | 369 | .card-bottom { 370 | display: flex; 371 | justify-content: space-between; 372 | flex-wrap: wrap; 373 | margin-top: 3px; 374 | margin-bottom: -5px; 375 | 376 | .card-bottom-left { 377 | display: flex; 378 | } 379 | 380 | .card-member-avatars { 381 | display: flex; 382 | flex-wrap: wrap; 383 | 384 | .avatar { 385 | width: 30px; 386 | height: 30px; 387 | font-size: 0.8rem; 388 | background-color: #ddd; 389 | &:hover { 390 | background-color: #ccc; 391 | } 392 | } 393 | } 394 | } 395 | } 396 | 397 | .mouse-over { 398 | background-color: whitesmoke; 399 | } 400 | } 401 | } 402 | } 403 | } 404 | } 405 | -------------------------------------------------------------------------------- /client/src/actions/alert.js: -------------------------------------------------------------------------------- 1 | import { v4 as uuidv4 } from 'uuid'; 2 | import { SET_ALERT, REMOVE_ALERT } from './types'; 3 | 4 | export const setAlert = (msg, alertType, timeout = 5000) => (dispatch) => { 5 | const id = uuidv4(); 6 | dispatch({ 7 | type: SET_ALERT, 8 | payload: { msg, alertType, id }, 9 | }); 10 | setTimeout(() => dispatch({ type: REMOVE_ALERT, payload: id }), timeout); 11 | }; 12 | -------------------------------------------------------------------------------- /client/src/actions/auth.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import { setAlert } from './alert'; 3 | import { 4 | REGISTER_SUCCESS, 5 | REGISTER_FAIL, 6 | USER_LOADED, 7 | AUTH_ERROR, 8 | LOGIN_SUCCESS, 9 | LOGIN_FAIL, 10 | LOGOUT, 11 | } from './types'; 12 | import setAuthToken from '../utils/setAuthToken'; 13 | 14 | // Load User 15 | export const loadUser = () => async (dispatch) => { 16 | if (localStorage.token) { 17 | setAuthToken(localStorage.token); 18 | } 19 | 20 | try { 21 | const res = await axios.get('/api/auth'); 22 | 23 | dispatch({ 24 | type: USER_LOADED, 25 | payload: res.data, 26 | }); 27 | } catch (err) { 28 | dispatch({ 29 | type: AUTH_ERROR, 30 | }); 31 | } 32 | }; 33 | 34 | // Register User 35 | export const register = ({ name, email, password }) => async (dispatch) => { 36 | const config = { 37 | headers: { 38 | 'Content-Type': 'application/json', 39 | }, 40 | }; 41 | 42 | const body = JSON.stringify({ name, email, password }); 43 | 44 | try { 45 | const res = await axios.post('/api/users', body, config); 46 | 47 | dispatch({ 48 | type: REGISTER_SUCCESS, 49 | payload: res.data, 50 | }); 51 | 52 | dispatch(loadUser()); 53 | } catch (err) { 54 | const errors = err.response.data.errors; 55 | 56 | if (errors) { 57 | errors.forEach((error) => dispatch(setAlert(error.msg, 'error'))); 58 | } 59 | 60 | dispatch({ 61 | type: REGISTER_FAIL, 62 | }); 63 | } 64 | }; 65 | 66 | // Login User 67 | export const login = (email, password) => async (dispatch) => { 68 | const config = { 69 | headers: { 70 | 'Content-Type': 'application/json', 71 | }, 72 | }; 73 | 74 | const body = JSON.stringify({ email, password }); 75 | 76 | try { 77 | const res = await axios.post('/api/auth', body, config); 78 | 79 | dispatch({ 80 | type: LOGIN_SUCCESS, 81 | payload: res.data, 82 | }); 83 | 84 | dispatch(loadUser()); 85 | } catch (err) { 86 | const errors = err.response.data.errors; 87 | 88 | if (errors) { 89 | errors.forEach((error) => dispatch(setAlert(error.msg, 'error'))); 90 | } 91 | 92 | dispatch({ 93 | type: LOGIN_FAIL, 94 | }); 95 | } 96 | }; 97 | 98 | // Logout 99 | export const logout = () => async (dispatch) => { 100 | dispatch({ type: LOGOUT }); 101 | }; 102 | -------------------------------------------------------------------------------- /client/src/actions/board.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | import { setAlert } from './alert'; 3 | import { 4 | CLEAR_BOARD, 5 | GET_BOARDS, 6 | GET_BOARD, 7 | ADD_BOARD, 8 | BOARD_ERROR, 9 | RENAME_BOARD, 10 | GET_LIST, 11 | ADD_LIST, 12 | RENAME_LIST, 13 | ARCHIVE_LIST, 14 | GET_CARD, 15 | ADD_CARD, 16 | EDIT_CARD, 17 | MOVE_CARD, 18 | ARCHIVE_CARD, 19 | DELETE_CARD, 20 | GET_ACTIVITY, 21 | ADD_MEMBER, 22 | MOVE_LIST, 23 | ADD_CARD_MEMBER, 24 | ADD_CHECKLIST_ITEM, 25 | EDIT_CHECKLIST_ITEM, 26 | COMPLETE_CHECKLIST_ITEM, 27 | DELETE_CHECKLIST_ITEM, 28 | } from './types'; 29 | 30 | const config = { 31 | headers: { 32 | 'Content-Type': 'application/json', 33 | }, 34 | }; 35 | 36 | // Get boards 37 | export const getBoards = () => async (dispatch) => { 38 | try { 39 | dispatch({ type: CLEAR_BOARD }); 40 | 41 | const res = await axios.get('/api/boards'); 42 | 43 | dispatch({ 44 | type: GET_BOARDS, 45 | payload: res.data, 46 | }); 47 | } catch (err) { 48 | dispatch({ 49 | type: BOARD_ERROR, 50 | payload: { msg: err.response.statusText, status: err.response.status }, 51 | }); 52 | } 53 | }; 54 | 55 | // Get board 56 | export const getBoard = (id) => async (dispatch) => { 57 | try { 58 | const res = await axios.get(`/api/boards/${id}`); 59 | 60 | if (res) { 61 | axios.defaults.headers.common['boardId'] = id; 62 | } else { 63 | delete axios.defaults.headers.common['boardId']; 64 | } 65 | 66 | dispatch({ 67 | type: GET_BOARD, 68 | payload: { ...res.data, listObjects: [], cardObjects: [] }, 69 | }); 70 | } catch (err) { 71 | dispatch({ 72 | type: BOARD_ERROR, 73 | payload: { msg: err.response.statusText, status: err.response.status }, 74 | }); 75 | } 76 | }; 77 | 78 | // Add board 79 | export const addBoard = (formData, history) => async (dispatch) => { 80 | try { 81 | const body = JSON.stringify(formData); 82 | 83 | const res = await axios.post('/api/boards', body, config); 84 | 85 | dispatch({ 86 | type: ADD_BOARD, 87 | payload: res.data, 88 | }); 89 | 90 | dispatch(setAlert('Board Created', 'success')); 91 | 92 | history.push(`/board/${res.data._id}`); 93 | } catch (err) { 94 | dispatch({ 95 | type: BOARD_ERROR, 96 | payload: { msg: err.response.statusText, status: err.response.status }, 97 | }); 98 | } 99 | }; 100 | 101 | // Rename board 102 | export const renameBoard = (boardId, formData) => async (dispatch) => { 103 | try { 104 | const res = await axios.patch(`/api/boards/rename/${boardId}`, formData, config); 105 | 106 | dispatch({ 107 | type: RENAME_BOARD, 108 | payload: res.data, 109 | }); 110 | 111 | dispatch(getActivity()); 112 | } catch (err) { 113 | dispatch({ 114 | type: BOARD_ERROR, 115 | payload: { msg: err.response.statusText, status: err.response.status }, 116 | }); 117 | } 118 | }; 119 | 120 | // Get list 121 | export const getList = (id) => async (dispatch) => { 122 | try { 123 | const res = await axios.get(`/api/lists/${id}`); 124 | 125 | dispatch({ 126 | type: GET_LIST, 127 | payload: res.data, 128 | }); 129 | } catch (err) { 130 | dispatch({ 131 | type: BOARD_ERROR, 132 | payload: { msg: err.response.statusText, status: err.response.status }, 133 | }); 134 | } 135 | }; 136 | 137 | // Add list 138 | export const addList = (formData) => async (dispatch) => { 139 | try { 140 | const body = JSON.stringify(formData); 141 | 142 | const res = await axios.post('/api/lists', body, config); 143 | 144 | dispatch({ 145 | type: ADD_LIST, 146 | payload: res.data, 147 | }); 148 | 149 | dispatch(getActivity()); 150 | } catch (err) { 151 | dispatch({ 152 | type: BOARD_ERROR, 153 | payload: { msg: err.response.statusText, status: err.response.status }, 154 | }); 155 | } 156 | }; 157 | 158 | // Rename list 159 | export const renameList = (listId, formData) => async (dispatch) => { 160 | try { 161 | const res = await axios.patch(`/api/lists/rename/${listId}`, formData, config); 162 | 163 | dispatch({ 164 | type: RENAME_LIST, 165 | payload: res.data, 166 | }); 167 | } catch (err) { 168 | dispatch({ 169 | type: BOARD_ERROR, 170 | payload: { msg: err.response.statusText, status: err.response.status }, 171 | }); 172 | } 173 | }; 174 | 175 | // Archive/Unarchive list 176 | export const archiveList = (listId, archive) => async (dispatch) => { 177 | try { 178 | const res = await axios.patch(`/api/lists/archive/${archive}/${listId}`); 179 | 180 | dispatch({ 181 | type: ARCHIVE_LIST, 182 | payload: res.data, 183 | }); 184 | 185 | dispatch(getActivity()); 186 | } catch (err) { 187 | dispatch({ 188 | type: BOARD_ERROR, 189 | payload: { msg: err.response.statusText, status: err.response.status }, 190 | }); 191 | } 192 | }; 193 | 194 | // Get card 195 | export const getCard = (id) => async (dispatch) => { 196 | try { 197 | const res = await axios.get(`/api/cards/${id}`); 198 | 199 | dispatch({ 200 | type: GET_CARD, 201 | payload: res.data, 202 | }); 203 | } catch (err) { 204 | dispatch({ 205 | type: BOARD_ERROR, 206 | payload: { msg: err.response.statusText, status: err.response.status }, 207 | }); 208 | } 209 | }; 210 | 211 | // Add card 212 | export const addCard = (formData) => async (dispatch) => { 213 | try { 214 | const body = JSON.stringify(formData); 215 | 216 | const res = await axios.post('/api/cards', body, config); 217 | 218 | dispatch({ 219 | type: ADD_CARD, 220 | payload: res.data, 221 | }); 222 | 223 | dispatch(getActivity()); 224 | } catch (err) { 225 | dispatch({ 226 | type: BOARD_ERROR, 227 | payload: { msg: err.response.statusText, status: err.response.status }, 228 | }); 229 | } 230 | }; 231 | 232 | // Edit card 233 | export const editCard = (cardId, formData) => async (dispatch) => { 234 | try { 235 | const res = await axios.patch(`/api/cards/edit/${cardId}`, formData, config); 236 | 237 | dispatch({ 238 | type: EDIT_CARD, 239 | payload: res.data, 240 | }); 241 | } catch (err) { 242 | dispatch({ 243 | type: BOARD_ERROR, 244 | payload: { msg: err.response.statusText, status: err.response.status }, 245 | }); 246 | } 247 | }; 248 | 249 | // Move card 250 | export const moveCard = (cardId, formData) => async (dispatch) => { 251 | try { 252 | const body = JSON.stringify(formData); 253 | 254 | const res = await axios.patch(`/api/cards/move/${cardId}`, body, config); 255 | 256 | dispatch({ 257 | type: MOVE_CARD, 258 | payload: res.data, 259 | }); 260 | 261 | dispatch(getActivity()); 262 | } catch (err) { 263 | dispatch({ 264 | type: BOARD_ERROR, 265 | payload: { msg: err.response.statusText, status: err.response.status }, 266 | }); 267 | } 268 | }; 269 | 270 | // Archive/Unarchive card 271 | export const archiveCard = (cardId, archive) => async (dispatch) => { 272 | try { 273 | const res = await axios.patch(`/api/cards/archive/${archive}/${cardId}`); 274 | 275 | dispatch({ 276 | type: ARCHIVE_CARD, 277 | payload: res.data, 278 | }); 279 | 280 | dispatch(getActivity()); 281 | } catch (err) { 282 | dispatch({ 283 | type: BOARD_ERROR, 284 | payload: { msg: err.response.statusText, status: err.response.status }, 285 | }); 286 | } 287 | }; 288 | 289 | // Delete card 290 | export const deleteCard = (listId, cardId) => async (dispatch) => { 291 | try { 292 | const res = await axios.delete(`/api/cards/${listId}/${cardId}`); 293 | 294 | dispatch({ 295 | type: DELETE_CARD, 296 | payload: res.data, 297 | }); 298 | 299 | dispatch(getActivity()); 300 | } catch (err) { 301 | dispatch({ 302 | type: BOARD_ERROR, 303 | payload: { msg: err.response.statusText, status: err.response.status }, 304 | }); 305 | } 306 | }; 307 | 308 | // Get activity 309 | export const getActivity = () => async (dispatch) => { 310 | try { 311 | const boardId = axios.defaults.headers.common['boardId']; 312 | 313 | const res = await axios.get(`/api/boards/activity/${boardId}`); 314 | 315 | dispatch({ 316 | type: GET_ACTIVITY, 317 | payload: res.data, 318 | }); 319 | } catch (err) { 320 | dispatch({ 321 | type: BOARD_ERROR, 322 | payload: { msg: err.response.statusText, status: err.response.status }, 323 | }); 324 | } 325 | }; 326 | 327 | // Add member 328 | export const addMember = (userId) => async (dispatch) => { 329 | try { 330 | const res = await axios.put(`/api/boards/addMember/${userId}`); 331 | 332 | dispatch({ 333 | type: ADD_MEMBER, 334 | payload: res.data, 335 | }); 336 | 337 | dispatch(getActivity()); 338 | } catch (err) { 339 | dispatch({ 340 | type: BOARD_ERROR, 341 | payload: { msg: err.response.statusText, status: err.response.status }, 342 | }); 343 | } 344 | }; 345 | 346 | // Move list 347 | export const moveList = (listId, formData) => async (dispatch) => { 348 | try { 349 | const body = JSON.stringify(formData); 350 | 351 | const res = await axios.patch(`/api/lists/move/${listId}`, body, config); 352 | 353 | dispatch({ 354 | type: MOVE_LIST, 355 | payload: res.data, 356 | }); 357 | } catch (err) { 358 | dispatch({ 359 | type: BOARD_ERROR, 360 | payload: { msg: err.response.statusText, status: err.response.status }, 361 | }); 362 | } 363 | }; 364 | 365 | // Add card member 366 | export const addCardMember = (formData) => async (dispatch) => { 367 | try { 368 | const { add, cardId, userId } = formData; 369 | 370 | const res = await axios.put(`/api/cards/addMember/${add}/${cardId}/${userId}`); 371 | 372 | dispatch({ 373 | type: ADD_CARD_MEMBER, 374 | payload: res.data, 375 | }); 376 | 377 | dispatch(getActivity()); 378 | } catch (err) { 379 | dispatch({ 380 | type: BOARD_ERROR, 381 | payload: { msg: err.response.statusText, status: err.response.status }, 382 | }); 383 | } 384 | }; 385 | 386 | // Add checklist item 387 | export const addChecklistItem = (cardId, formData) => async (dispatch) => { 388 | try { 389 | const body = JSON.stringify(formData); 390 | 391 | const res = await axios.post(`/api/checklists/${cardId}`, body, config); 392 | 393 | dispatch({ 394 | type: ADD_CHECKLIST_ITEM, 395 | payload: res.data, 396 | }); 397 | } catch (err) { 398 | dispatch({ 399 | type: BOARD_ERROR, 400 | payload: { msg: err.response.statusText, status: err.response.status }, 401 | }); 402 | } 403 | }; 404 | 405 | // Edit checklist item 406 | export const editChecklistItem = (cardId, itemId, formData) => async (dispatch) => { 407 | try { 408 | const body = JSON.stringify(formData); 409 | 410 | const res = await axios.patch(`/api/checklists/${cardId}/${itemId}`, body, config); 411 | 412 | dispatch({ 413 | type: EDIT_CHECKLIST_ITEM, 414 | payload: res.data, 415 | }); 416 | } catch (err) { 417 | dispatch({ 418 | type: BOARD_ERROR, 419 | payload: { msg: err.response.statusText, status: err.response.status }, 420 | }); 421 | } 422 | }; 423 | 424 | // Complete/Uncomplete checklist item 425 | export const completeChecklistItem = (formData) => async (dispatch) => { 426 | try { 427 | const { cardId, complete, itemId } = formData; 428 | 429 | const res = await axios.patch(`/api/checklists/${cardId}/${complete}/${itemId}`); 430 | 431 | dispatch({ 432 | type: COMPLETE_CHECKLIST_ITEM, 433 | payload: res.data, 434 | }); 435 | } catch (err) { 436 | dispatch({ 437 | type: BOARD_ERROR, 438 | payload: { msg: err.response.statusText, status: err.response.status }, 439 | }); 440 | } 441 | }; 442 | 443 | // Delete checklist item 444 | export const deleteChecklistItem = (cardId, itemId) => async (dispatch) => { 445 | try { 446 | const res = await axios.delete(`/api/checklists/${cardId}/${itemId}`); 447 | 448 | dispatch({ 449 | type: DELETE_CHECKLIST_ITEM, 450 | payload: res.data, 451 | }); 452 | } catch (err) { 453 | dispatch({ 454 | type: BOARD_ERROR, 455 | payload: { msg: err.response.statusText, status: err.response.status }, 456 | }); 457 | } 458 | }; 459 | -------------------------------------------------------------------------------- /client/src/actions/types.js: -------------------------------------------------------------------------------- 1 | export const SET_ALERT = 'SET_ALERT'; 2 | export const REMOVE_ALERT = 'REMOVE_ALERT'; 3 | export const REGISTER_SUCCESS = 'REGISTER_SUCCESS'; 4 | export const REGISTER_FAIL = 'REGISTER_FAIL'; 5 | export const USER_LOADED = 'USER_LOADED'; 6 | export const AUTH_ERROR = 'AUTH_ERROR'; 7 | export const LOGIN_SUCCESS = 'LOGIN_SUCCESS'; 8 | export const LOGIN_FAIL = 'LOGIN_FAIL'; 9 | export const LOGOUT = 'LOGOUT'; 10 | export const CLEAR_BOARD = 'CLEAR_BOARD'; 11 | export const GET_BOARDS = 'GET_BOARDS'; 12 | export const GET_BOARD = 'GET_BOARD'; 13 | export const ADD_BOARD = 'ADD_BOARD'; 14 | export const BOARD_ERROR = 'BOARD_ERROR'; 15 | export const RENAME_BOARD = 'RENAME_BOARD'; 16 | export const GET_LIST = 'GET_LIST'; 17 | export const ADD_LIST = 'ADD_LIST'; 18 | export const RENAME_LIST = 'RENAME_LIST'; 19 | export const ARCHIVE_LIST = 'ARCHIVE_LIST'; 20 | export const GET_CARD = 'GET_CARD'; 21 | export const ADD_CARD = 'ADD_CARD'; 22 | export const EDIT_CARD = 'EDIT_CARD'; 23 | export const MOVE_CARD = 'MOVE_CARD'; 24 | export const ARCHIVE_CARD = 'ARCHIVE_CARD'; 25 | export const DELETE_CARD = 'DELETE_CARD'; 26 | export const GET_ACTIVITY = 'GET_ACTIVITY'; 27 | export const ADD_MEMBER = 'ADD_MEMBER'; 28 | export const MOVE_LIST = 'MOVE_LIST'; 29 | export const ADD_CARD_MEMBER = 'ADD_CARD_MEMBER'; 30 | export const ADD_CHECKLIST_ITEM = 'ADD_CHECKLIST_ITEM'; 31 | export const EDIT_CHECKLIST_ITEM = 'EDIT_CHECKLIST_ITEM'; 32 | export const COMPLETE_CHECKLIST_ITEM = 'COMPLETE_CHECKLIST_ITEM'; 33 | export const DELETE_CHECKLIST_ITEM = 'DELETE_CHECKLIST_ITEM'; 34 | -------------------------------------------------------------------------------- /client/src/components/board/ArchivedCards.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useSelector, useDispatch } from 'react-redux'; 3 | import { archiveCard, deleteCard } from '../../actions/board'; 4 | 5 | import { Card, List, ListItem, CardContent, Button } from '@material-ui/core'; 6 | 7 | const ArchivedCards = () => { 8 | const cards = useSelector((state) => state.board.board.cardObjects); 9 | const lists = useSelector((state) => state.board.board.listObjects); 10 | const dispatch = useDispatch(); 11 | 12 | const onDelete = async (listId, cardId) => { 13 | dispatch(deleteCard(listId, cardId)); 14 | }; 15 | 16 | const onSendBack = async (cardId) => { 17 | dispatch(archiveCard(cardId, false)); 18 | }; 19 | 20 | return ( 21 |
22 | 23 | {cards 24 | .filter((card) => card.archived) 25 | .map((card, index) => ( 26 | 27 | 28 | {card.title} 29 | 30 |
31 | 42 | 43 |
44 |
45 | ))} 46 |
47 |
48 | ); 49 | }; 50 | 51 | export default ArchivedCards; 52 | -------------------------------------------------------------------------------- /client/src/components/board/ArchivedLists.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useSelector, useDispatch } from 'react-redux'; 3 | import { archiveList } from '../../actions/board'; 4 | 5 | import List from '@material-ui/core/List'; 6 | import Button from '@material-ui/core/Button'; 7 | import ListItem from '@material-ui/core/ListItem'; 8 | import ListItemText from '@material-ui/core/ListItemText'; 9 | 10 | const ArchivedLists = () => { 11 | const listObjects = useSelector((state) => state.board.board.listObjects); 12 | const dispatch = useDispatch(); 13 | 14 | const onSubmit = async (listId) => { 15 | dispatch(archiveList(listId, false)); 16 | }; 17 | 18 | return ( 19 |
20 | 21 | {listObjects 22 | .filter((list) => list.archived) 23 | .map((list, index) => ( 24 | 25 | 26 | 27 | 28 | ))} 29 | 30 |
31 | ); 32 | }; 33 | 34 | export default ArchivedLists; 35 | -------------------------------------------------------------------------------- /client/src/components/board/BoardDrawer.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { useSelector } from 'react-redux'; 3 | import Moment from 'react-moment'; 4 | 5 | import Drawer from '@material-ui/core/Drawer'; 6 | import List from '@material-ui/core/List'; 7 | import Divider from '@material-ui/core/Divider'; 8 | import Button from '@material-ui/core/Button'; 9 | import MoreHorizIcon from '@material-ui/icons/MoreHoriz'; 10 | import ChevronLeftIcon from '@material-ui/icons/ChevronLeft'; 11 | import CloseIcon from '@material-ui/icons/Close'; 12 | import ListItem from '@material-ui/core/ListItem'; 13 | import ListItemIcon from '@material-ui/core/ListItemIcon'; 14 | import ListItemText from '@material-ui/core/ListItemText'; 15 | import ArchiveIcon from '@material-ui/icons/Archive'; 16 | 17 | import ArchivedLists from './ArchivedLists'; 18 | import ArchivedCards from './ArchivedCards'; 19 | import useStyles from '../../utils/drawerStyles'; 20 | 21 | const BoardDrawer = () => { 22 | const classes = useStyles(); 23 | const [open, setOpen] = useState(false); 24 | const [viewingArchivedLists, setViewingArchivedLists] = useState(false); 25 | const [viewingArchivedCards, setViewingArchivedCards] = useState(false); 26 | const [activityChunks, setActivityChunks] = useState(1); 27 | const activity = useSelector((state) => state.board.board.activity); 28 | 29 | const handleClose = () => { 30 | setOpen(false); 31 | setActivityChunks(1); 32 | }; 33 | 34 | return ( 35 |
36 | 43 | 52 | {!viewingArchivedLists && !viewingArchivedCards ? ( 53 |
54 |
55 |

Menu

56 | 59 |
60 | 61 | 62 | setViewingArchivedLists(true)}> 63 | 64 | 65 | 66 | 67 | 68 | setViewingArchivedCards(true)}> 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 |
77 |

Activity

78 |
79 | 80 | {activity.slice(0, activityChunks * 10).map((activity) => ( 81 | 82 | {activity.date}} 85 | /> 86 | 87 | ))} 88 | 89 |
90 | 96 |
97 |
98 | ) : viewingArchivedLists ? ( 99 |
100 |
101 | 104 |

Archived Lists

105 | 108 |
109 | 110 | 111 |
112 | ) : ( 113 |
114 |
115 | 118 |

Archived Cards

119 | 122 |
123 | 124 | 125 |
126 | )} 127 | 128 |
129 |
130 | ); 131 | }; 132 | 133 | export default BoardDrawer; 134 | -------------------------------------------------------------------------------- /client/src/components/board/BoardTitle.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import { useDispatch } from 'react-redux'; 3 | import PropTypes from 'prop-types'; 4 | import { renameBoard } from '../../actions/board'; 5 | import { TextField } from '@material-ui/core'; 6 | 7 | const BoardTitle = ({ board }) => { 8 | const [editing, setEditing] = useState(false); 9 | const [title, setTitle] = useState(board.title); 10 | const dispatch = useDispatch(); 11 | 12 | useEffect(() => { 13 | setTitle(board.title); 14 | }, [board.title]); 15 | 16 | const onSubmit = async (e) => { 17 | e.preventDefault(); 18 | dispatch(renameBoard(board._id, { title })); 19 | setEditing(false); 20 | }; 21 | 22 | return !editing ? ( 23 |

setEditing(true)}> 24 | {board.title} 25 |

26 | ) : ( 27 |
onSubmit(e)}> 28 | setTitle(e.target.value)} 34 | /> 35 | 36 | ); 37 | }; 38 | 39 | BoardTitle.propTypes = { 40 | board: PropTypes.object.isRequired, 41 | }; 42 | 43 | export default BoardTitle; 44 | -------------------------------------------------------------------------------- /client/src/components/board/CreateList.js: -------------------------------------------------------------------------------- 1 | import React, { useRef, useState, useEffect } from 'react'; 2 | import { useDispatch } from 'react-redux'; 3 | import { addList } from '../../actions/board'; 4 | import { TextField, Button } from '@material-ui/core'; 5 | import CloseIcon from '@material-ui/icons/Close'; 6 | 7 | const CreateList = () => { 8 | const [adding, setAdding] = useState(false); 9 | const [title, setTitle] = useState(''); 10 | const dispatch = useDispatch(); 11 | 12 | const formRef = useRef(null); 13 | useEffect(() => { 14 | formRef && formRef.current && formRef.current.scrollIntoView(); 15 | }, [title]); 16 | 17 | const onSubmit = async (e) => { 18 | e.preventDefault(); 19 | dispatch(addList({ title })); 20 | setTitle(''); 21 | }; 22 | 23 | return !adding ? ( 24 |
25 | 28 |
29 | ) : ( 30 |
31 |
onSubmit(e)}> 32 | setTitle(e.target.value)} 41 | /> 42 |
43 | 46 | 54 |
55 | 56 |
57 | ); 58 | }; 59 | 60 | export default CreateList; 61 | -------------------------------------------------------------------------------- /client/src/components/board/Members.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import axios from 'axios'; 3 | import { useSelector, useDispatch } from 'react-redux'; 4 | import { addMember } from '../../actions/board'; 5 | import getInitials from '../../utils/getInitials'; 6 | import { TextField, Button } from '@material-ui/core'; 7 | import Avatar from '@material-ui/core/Avatar'; 8 | import Tooltip from '@material-ui/core/Tooltip'; 9 | import Autocomplete from '@material-ui/lab/Autocomplete'; 10 | import CloseIcon from '@material-ui/icons/Close'; 11 | 12 | const Members = () => { 13 | const [inviting, setInviting] = useState(false); 14 | const [user, setUser] = useState(null); 15 | const [inputValue, setInputValue] = useState(''); 16 | const [users, setUsers] = useState([]); 17 | const boardMembers = useSelector((state) => state.board.board.members); 18 | const searchOptions = users.filter((user) => 19 | boardMembers.find((boardMember) => boardMember.user === user._id) ? false : true 20 | ); 21 | const dispatch = useDispatch(); 22 | 23 | const handleInputValue = async (newInputValue) => { 24 | setInputValue(newInputValue); 25 | if (newInputValue && newInputValue !== '') { 26 | const search = (await axios.get(`/api/users/${newInputValue}`)).data.slice(0, 5); 27 | setUsers(search && search.length > 0 ? search : []); 28 | } 29 | }; 30 | 31 | const onSubmit = async () => { 32 | dispatch(addMember(user._id)); 33 | setUser(null); 34 | setInputValue(''); 35 | setInviting(false); 36 | }; 37 | 38 | return ( 39 |
40 |
41 | {boardMembers.map((member) => { 42 | return ( 43 | 44 | {getInitials(member.name)} 45 | 46 | ); 47 | })} 48 |
49 | {!inviting ? ( 50 | 53 | ) : ( 54 |
55 | setUser(newMember)} 58 | inputValue={inputValue} 59 | onInputChange={(e, newInputValue) => handleInputValue(newInputValue)} 60 | options={searchOptions} 61 | getOptionLabel={(member) => member.email} 62 | className='search-member' 63 | renderInput={(params) => ( 64 | 65 | )} 66 | /> 67 |
68 | 76 | 79 |
80 |
81 | )} 82 |
83 | ); 84 | }; 85 | 86 | export default Members; 87 | -------------------------------------------------------------------------------- /client/src/components/card/Card.js: -------------------------------------------------------------------------------- 1 | import React, { Fragment, useRef, useState, useEffect } from 'react'; 2 | import { useSelector, useDispatch } from 'react-redux'; 3 | import PropTypes from 'prop-types'; 4 | import { Draggable } from 'react-beautiful-dnd'; 5 | import { getCard, editCard } from '../../actions/board'; 6 | import getInitials from '../../utils/getInitials'; 7 | 8 | import CardMUI from '@material-ui/core/Card'; 9 | import EditIcon from '@material-ui/icons/Edit'; 10 | import CloseIcon from '@material-ui/icons/Close'; 11 | import SubjectIcon from '@material-ui/icons/Subject'; 12 | import AssignmentTurnedInIcon from '@material-ui/icons/AssignmentTurnedIn'; 13 | import { TextField, CardContent, Button, Avatar, Tooltip } from '@material-ui/core'; 14 | import CardModal from './CardModal'; 15 | 16 | const Card = ({ cardId, list, index }) => { 17 | const [editing, setEditing] = useState(false); 18 | const [openModal, setOpenModal] = useState(false); 19 | const [mouseOver, setMouseOver] = useState(false); 20 | const [title, setTitle] = useState(''); 21 | const [height, setHeight] = useState(0); 22 | const [completeItems, setCompleteItems] = useState(0); 23 | const cardRef = useRef(null); 24 | const card = useSelector((state) => 25 | state.board.board.cardObjects.find((object) => object._id === cardId) 26 | ); 27 | const dispatch = useDispatch(); 28 | 29 | useEffect(() => { 30 | dispatch(getCard(cardId)); 31 | }, [cardId, dispatch]); 32 | 33 | useEffect(() => { 34 | if (card) { 35 | setTitle(card.title); 36 | card.checklist && 37 | setCompleteItems( 38 | card.checklist.reduce( 39 | (completed, item) => (completed += item.complete ? 1 : 0), 40 | 0 41 | ) 42 | ); 43 | } 44 | }, [card]); 45 | 46 | useEffect(() => { 47 | cardRef && cardRef.current && setHeight(cardRef.current.clientHeight); 48 | }, [list, card, cardRef]); 49 | 50 | const onSubmitEdit = async (e) => { 51 | e.preventDefault(); 52 | dispatch(editCard(cardId, { title })); 53 | setEditing(false); 54 | setMouseOver(false); 55 | }; 56 | 57 | return !card || (card && card.archived) ? ( 58 | '' 59 | ) : ( 60 | 61 | 68 | {!editing ? ( 69 | 70 | {(provided) => ( 71 | setMouseOver(true)} 74 | onMouseLeave={() => setMouseOver(false)} 75 | ref={provided.innerRef} 76 | {...provided.draggableProps} 77 | {...provided.dragHandleProps} 78 | > 79 | {mouseOver && !editing && ( 80 | 91 | )} 92 | { 94 | setOpenModal(true); 95 | setMouseOver(false); 96 | }} 97 | ref={cardRef} 98 | > 99 | {card.label && card.label !== 'none' && ( 100 |
101 | )} 102 |

{card.title}

103 |
104 |
105 | {card.description && ( 106 | 107 | )} 108 | {card.checklist && card.checklist.length > 0 && ( 109 |
116 | 120 | {completeItems}/{card.checklist.length} 121 |
122 | )} 123 |
124 |
125 | {card.members.map((member) => { 126 | return ( 127 | 128 | {getInitials(member.name)} 129 | 130 | ); 131 | })} 132 |
133 |
134 | 135 | 136 | )} 137 | 138 | ) : ( 139 |
onSubmitEdit(e)}> 140 | 141 | 142 | setTitle(e.target.value)} 151 | onKeyPress={(e) => e.key === 'Enter' && onSubmitEdit(e)} 152 | /> 153 | 154 | 155 |
156 | 159 | 168 |
169 |
170 | )} 171 | 172 | ); 173 | }; 174 | 175 | Card.propTypes = { 176 | cardId: PropTypes.string.isRequired, 177 | list: PropTypes.object.isRequired, 178 | index: PropTypes.number.isRequired, 179 | }; 180 | 181 | export default Card; 182 | -------------------------------------------------------------------------------- /client/src/components/card/CardMembers.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useSelector, useDispatch } from 'react-redux'; 3 | import PropTypes from 'prop-types'; 4 | import { addCardMember } from '../../actions/board'; 5 | import { Checkbox, FormGroup, FormControlLabel, FormControl } from '@material-ui/core'; 6 | import useStyles from '../../utils/modalStyles'; 7 | 8 | const CardMembers = ({ card }) => { 9 | const classes = useStyles(); 10 | const boardMembers = useSelector((state) => state.board.board.members); 11 | const members = card.members.map((member) => member.user); 12 | const dispatch = useDispatch(); 13 | 14 | return ( 15 |
16 |

Members

17 | 18 | 19 | {boardMembers.map((member) => ( 20 | 26 | dispatch( 27 | addCardMember({ 28 | add: e.target.checked, 29 | cardId: card._id, 30 | userId: e.target.name, 31 | }) 32 | ) 33 | } 34 | name={member.user} 35 | /> 36 | } 37 | label={member.name} 38 | /> 39 | ))} 40 | 41 | 42 |
43 | ); 44 | }; 45 | 46 | CardMembers.propTypes = { 47 | card: PropTypes.object.isRequired, 48 | }; 49 | 50 | export default CardMembers; 51 | -------------------------------------------------------------------------------- /client/src/components/card/CardModal.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import { useDispatch } from 'react-redux'; 3 | import PropTypes from 'prop-types'; 4 | import { GithubPicker } from 'react-color'; 5 | import { editCard, archiveCard } from '../../actions/board'; 6 | import { Modal, TextField, Button } from '@material-ui/core'; 7 | import CloseIcon from '@material-ui/icons/Close'; 8 | import MoveCard from './MoveCard'; 9 | import DeleteCard from './DeleteCard'; 10 | import CardMembers from './CardMembers'; 11 | import Checklist from '../checklist/Checklist'; 12 | import useStyles from '../../utils/modalStyles'; 13 | 14 | const CardModal = ({ cardId, open, setOpen, card, list }) => { 15 | const classes = useStyles(); 16 | const [title, setTitle] = useState(card.title); 17 | const [description, setDescription] = useState(card.description); 18 | const dispatch = useDispatch(); 19 | 20 | useEffect(() => { 21 | setTitle(card.title); 22 | setDescription(card.description); 23 | }, [card]); 24 | 25 | const onTitleDescriptionSubmit = async (e) => { 26 | e.preventDefault(); 27 | dispatch(editCard(cardId, { title, description })); 28 | }; 29 | 30 | const onArchiveCard = async () => { 31 | dispatch(archiveCard(cardId, true)); 32 | setOpen(false); 33 | }; 34 | 35 | return ( 36 | setOpen(false)}> 37 |
38 |
onTitleDescriptionSubmit(e)}> 39 |
40 | setTitle(e.target.value)} 49 | onKeyPress={(e) => e.key === 'Enter' && onTitleDescriptionSubmit(e)} 50 | className={classes.cardTitle} 51 | /> 52 | 55 |
56 | setDescription(e.target.value)} 64 | /> 65 | 78 | 79 |
80 | 81 |
82 |

Label

83 | dispatch(editCard(cardId, { label: color.hex }))} 86 | /> 87 | 94 |
95 |
96 | 97 |
98 | 99 |
100 | 107 | 108 |
109 |
110 |
111 |
112 | ); 113 | }; 114 | 115 | CardModal.propTypes = { 116 | cardId: PropTypes.string.isRequired, 117 | open: PropTypes.bool.isRequired, 118 | setOpen: PropTypes.func.isRequired, 119 | card: PropTypes.object.isRequired, 120 | list: PropTypes.object.isRequired, 121 | }; 122 | 123 | export default CardModal; 124 | -------------------------------------------------------------------------------- /client/src/components/card/DeleteCard.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { useDispatch } from 'react-redux'; 3 | import { deleteCard } from '../../actions/board'; 4 | import PropTypes from 'prop-types'; 5 | import Button from '@material-ui/core/Button'; 6 | import Dialog from '@material-ui/core/Dialog'; 7 | import DialogActions from '@material-ui/core/DialogActions'; 8 | import DialogTitle from '@material-ui/core/DialogTitle'; 9 | import CloseIcon from '@material-ui/icons/Close'; 10 | 11 | const DeleteCard = ({ cardId, setOpen, list }) => { 12 | const [openDialog, setOpenDialog] = useState(false); 13 | const dispatch = useDispatch(); 14 | 15 | const handleClickOpen = () => { 16 | setOpenDialog(true); 17 | }; 18 | 19 | const handleClose = () => { 20 | setOpenDialog(false); 21 | }; 22 | 23 | const onDeleteCard = async () => { 24 | dispatch(deleteCard(list._id, cardId)); 25 | setOpenDialog(false); 26 | setOpen(false); 27 | }; 28 | 29 | return ( 30 |
31 | 34 | 35 | {'Delete card?'} 36 | 37 | 40 | 43 | 44 | 45 |
46 | ); 47 | }; 48 | 49 | DeleteCard.propTypes = { 50 | cardId: PropTypes.string.isRequired, 51 | setOpen: PropTypes.func.isRequired, 52 | list: PropTypes.object.isRequired, 53 | }; 54 | 55 | export default DeleteCard; 56 | -------------------------------------------------------------------------------- /client/src/components/card/MoveCard.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import { useSelector, useDispatch } from 'react-redux'; 3 | import PropTypes from 'prop-types'; 4 | import { moveCard } from '../../actions/board'; 5 | 6 | import Button from '@material-ui/core/Button'; 7 | import InputLabel from '@material-ui/core/InputLabel'; 8 | import MenuItem from '@material-ui/core/MenuItem'; 9 | import FormControl from '@material-ui/core/FormControl'; 10 | import Select from '@material-ui/core/Select'; 11 | import useStyles from '../../utils/modalStyles'; 12 | 13 | const MoveCard = ({ cardId, setOpen, thisList }) => { 14 | const classes = useStyles(); 15 | const [listObject, setListObject] = useState(null); 16 | const [listTitle, setListTitle] = useState(''); 17 | const [position, setPosition] = useState(0); 18 | const [positions, setPositions] = useState([0]); 19 | const lists = useSelector((state) => state.board.board.lists); 20 | const listObjects = useSelector((state) => 21 | state.board.board.listObjects 22 | .sort( 23 | (a, b) => 24 | lists.findIndex((id) => id === a._id) - lists.findIndex((id) => id === b._id) 25 | ) 26 | .filter((list) => !list.archived) 27 | ); 28 | const cardObjects = useSelector((state) => state.board.board.cardObjects); 29 | const dispatch = useDispatch(); 30 | 31 | useEffect(() => { 32 | setListObject(thisList); 33 | setListTitle(thisList.title); 34 | }, [thisList, cardId]); 35 | 36 | useEffect(() => { 37 | if (listObject) { 38 | const unarchivedListCards = listObject.cards 39 | .map((id, index) => { 40 | const card = cardObjects.find((object) => object._id === id); 41 | const position = index; 42 | return { card, position }; 43 | }) 44 | .filter((card) => !card.card.archived); 45 | let cardPositions = unarchivedListCards.map((card) => card.position); 46 | if (listObject !== thisList) { 47 | cardPositions = cardPositions.concat(listObject.cards.length); 48 | } 49 | if (listObject.cards.length > 0) { 50 | setPositions(cardPositions); 51 | setPosition(thisList.cards.findIndex((id) => id === cardId)); 52 | } else { 53 | setPositions([0]); 54 | setPosition(0); 55 | } 56 | } 57 | }, [thisList, cardId, listObject, cardObjects]); 58 | 59 | const onSubmit = async () => { 60 | dispatch( 61 | moveCard(cardId, { fromId: thisList._id, toId: listObject._id, toIndex: position }) 62 | ); 63 | setOpen(false); 64 | }; 65 | 66 | return ( 67 |
68 |

Move this card

69 |
70 | 71 | List 72 | 87 | 88 | 89 | Position 90 | 102 | 103 |
104 | 112 |
113 | ); 114 | }; 115 | 116 | MoveCard.propTypes = { 117 | cardId: PropTypes.string.isRequired, 118 | setOpen: PropTypes.func.isRequired, 119 | thisList: PropTypes.object.isRequired, 120 | }; 121 | 122 | export default MoveCard; 123 | -------------------------------------------------------------------------------- /client/src/components/checklist/Checklist.js: -------------------------------------------------------------------------------- 1 | import React, { Fragment } from 'react'; 2 | import PropTypes from 'prop-types'; 3 | import CreateChecklistItem from './CreateChecklistItem'; 4 | import ChecklistItem from './ChecklistItem'; 5 | import { FormGroup, FormControl } from '@material-ui/core'; 6 | import useStyles from '../../utils/modalStyles'; 7 | 8 | const Checklist = ({ card }) => { 9 | const classes = useStyles(); 10 | 11 | return ( 12 | 13 |

Checklist

14 | 15 | 16 | {card.checklist.map((item) => ( 17 | 18 | ))} 19 | 20 | 21 | 22 |
23 | ); 24 | }; 25 | 26 | Checklist.propTypes = { 27 | card: PropTypes.object.isRequired, 28 | }; 29 | 30 | export default Checklist; 31 | -------------------------------------------------------------------------------- /client/src/components/checklist/ChecklistItem.js: -------------------------------------------------------------------------------- 1 | import React, { Fragment, useState, useEffect } from 'react'; 2 | import { useDispatch } from 'react-redux'; 3 | import PropTypes from 'prop-types'; 4 | import { 5 | completeChecklistItem, 6 | editChecklistItem, 7 | deleteChecklistItem, 8 | } from '../../actions/board'; 9 | import { TextField, Button } from '@material-ui/core'; 10 | import { Checkbox, FormControlLabel } from '@material-ui/core'; 11 | import EditIcon from '@material-ui/icons/Edit'; 12 | import HighlightOffIcon from '@material-ui/icons/HighlightOff'; 13 | import CloseIcon from '@material-ui/icons/Close'; 14 | import useStyles from '../../utils/modalStyles'; 15 | 16 | const ChecklistItem = ({ item, card }) => { 17 | const classes = useStyles(); 18 | const [text, setText] = useState(item.text); 19 | const [editing, setEditing] = useState(false); 20 | const dispatch = useDispatch(); 21 | 22 | useEffect(() => { 23 | setText(item.text); 24 | }, [item.text]); 25 | 26 | const onEdit = async (e) => { 27 | e.preventDefault(); 28 | dispatch(editChecklistItem(card._id, item._id, { text })); 29 | setEditing(false); 30 | }; 31 | 32 | const onComplete = async (e) => { 33 | dispatch( 34 | completeChecklistItem({ 35 | cardId: card._id, 36 | complete: e.target.checked, 37 | itemId: item._id, 38 | }) 39 | ); 40 | }; 41 | 42 | const onDelete = async (e) => { 43 | dispatch(deleteChecklistItem(card._id, item._id)); 44 | }; 45 | 46 | return ( 47 |
48 | {editing ? ( 49 |
onEdit(e)} className={classes.checklistFormLabel}> 50 | setText(e.target.value)} 58 | onKeyPress={(e) => e.key === 'Enter' && onEdit(e)} 59 | /> 60 |
61 | 64 | 72 |
73 | 74 | ) : ( 75 | 76 | cardItem._id === item._id).complete 81 | } 82 | onChange={onComplete} 83 | name={item._id} 84 | /> 85 | } 86 | label={item.text} 87 | className={classes.checklistFormLabel} 88 | /> 89 |
90 | 93 | 96 |
97 |
98 | )} 99 |
100 | ); 101 | }; 102 | 103 | ChecklistItem.propTypes = { 104 | item: PropTypes.object.isRequired, 105 | card: PropTypes.object.isRequired, 106 | }; 107 | 108 | export default ChecklistItem; 109 | -------------------------------------------------------------------------------- /client/src/components/checklist/CreateChecklistItem.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { useDispatch } from 'react-redux'; 3 | import PropTypes from 'prop-types'; 4 | import { addChecklistItem } from '../../actions/board'; 5 | import { TextField, Button } from '@material-ui/core'; 6 | import CloseIcon from '@material-ui/icons/Close'; 7 | import useStyles from '../../utils/modalStyles'; 8 | 9 | const CreateChecklistItem = ({ cardId }) => { 10 | const classes = useStyles(); 11 | const [adding, setAdding] = useState(false); 12 | const [text, setText] = useState(''); 13 | const dispatch = useDispatch(); 14 | 15 | const onSubmit = async (e) => { 16 | e.preventDefault(); 17 | dispatch(addChecklistItem(cardId, { text })); 18 | setText(''); 19 | }; 20 | 21 | return !adding ? ( 22 |
23 | 26 |
27 | ) : ( 28 |
29 |
onSubmit(e)}> 30 | setText(e.target.value)} 39 | onKeyPress={(e) => e.key === 'Enter' && onSubmit(e)} 40 | /> 41 |
42 | 45 | 53 |
54 | 55 |
56 | ); 57 | }; 58 | 59 | CreateChecklistItem.propTypes = { 60 | cardId: PropTypes.string.isRequired, 61 | }; 62 | 63 | export default CreateChecklistItem; 64 | -------------------------------------------------------------------------------- /client/src/components/list/CreateCardForm.js: -------------------------------------------------------------------------------- 1 | import React, { useRef, useState, useEffect } from 'react'; 2 | import { useDispatch } from 'react-redux'; 3 | import PropTypes from 'prop-types'; 4 | import { addCard } from '../../actions/board'; 5 | import { Card, CardContent, TextField, Button } from '@material-ui/core'; 6 | import CloseIcon from '@material-ui/icons/Close'; 7 | 8 | const CreateCardForm = ({ listId, setAdding }) => { 9 | const [title, setTitle] = useState(''); 10 | const dispatch = useDispatch(); 11 | 12 | const formRef = useRef(null); 13 | useEffect(() => { 14 | formRef && formRef.current && formRef.current.scrollIntoView(); 15 | }, [title]); 16 | 17 | const onSubmit = async (e) => { 18 | e.preventDefault(); 19 | dispatch(addCard({ title, listId })); 20 | setTitle(''); 21 | }; 22 | 23 | return ( 24 |
onSubmit(e)}> 25 | 26 | 27 | setTitle(e.target.value)} 36 | onKeyPress={(e) => e.key === 'Enter' && onSubmit(e)} 37 | /> 38 | 39 | 40 |
41 | 44 | 52 |
53 |
54 | ); 55 | }; 56 | 57 | CreateCardForm.propTypes = { 58 | listId: PropTypes.string.isRequired, 59 | setAdding: PropTypes.func.isRequired, 60 | }; 61 | 62 | export default CreateCardForm; 63 | -------------------------------------------------------------------------------- /client/src/components/list/List.js: -------------------------------------------------------------------------------- 1 | import React, { useRef, useState, useEffect } from 'react'; 2 | import { useSelector, useDispatch } from 'react-redux'; 3 | import PropTypes from 'prop-types'; 4 | import { Draggable, Droppable } from 'react-beautiful-dnd'; 5 | import { getList } from '../../actions/board'; 6 | import ListTitle from './ListTitle'; 7 | import ListMenu from './ListMenu'; 8 | import Card from '../card/Card'; 9 | import CreateCardForm from './CreateCardForm'; 10 | import Button from '@material-ui/core/Button'; 11 | 12 | const List = ({ listId, index }) => { 13 | const [addingCard, setAddingCard] = useState(false); 14 | const list = useSelector((state) => 15 | state.board.board.listObjects.find((object) => object._id === listId) 16 | ); 17 | const dispatch = useDispatch(); 18 | 19 | useEffect(() => { 20 | dispatch(getList(listId)); 21 | }, [dispatch, listId]); 22 | 23 | const createCardFormRef = useRef(null); 24 | useEffect(() => { 25 | addingCard && createCardFormRef.current.scrollIntoView(); 26 | }, [addingCard]); 27 | 28 | return !list || (list && list.archived) ? ( 29 | '' 30 | ) : ( 31 | 32 | {(provided) => ( 33 |
39 |
40 | 41 | 42 |
43 | 44 | {(provided) => ( 45 |
50 |
51 | {list.cards.map((cardId, index) => ( 52 | 53 | ))} 54 |
55 | {provided.placeholder} 56 | {addingCard && ( 57 |
58 | 59 |
60 | )} 61 |
62 | )} 63 |
64 | {!addingCard && ( 65 |
66 | 69 |
70 | )} 71 |
72 | )} 73 |
74 | ); 75 | }; 76 | 77 | List.propTypes = { 78 | listId: PropTypes.string.isRequired, 79 | index: PropTypes.number.isRequired, 80 | }; 81 | 82 | export default List; 83 | -------------------------------------------------------------------------------- /client/src/components/list/ListMenu.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { useDispatch } from 'react-redux'; 3 | import PropTypes from 'prop-types'; 4 | import { archiveList } from '../../actions/board'; 5 | import { Button, Menu, MenuItem } from '@material-ui/core'; 6 | import MoreHorizIcon from '@material-ui/icons/MoreHoriz'; 7 | import MoveList from './MoveList'; 8 | 9 | const ListMenu = ({ listId }) => { 10 | const [anchorEl, setAnchorEl] = useState(null); 11 | const dispatch = useDispatch(); 12 | 13 | const handleClick = (event) => { 14 | setAnchorEl(event.currentTarget); 15 | }; 16 | 17 | const handleClose = () => { 18 | setAnchorEl(null); 19 | }; 20 | 21 | const archive = async () => { 22 | dispatch(archiveList(listId, true)); 23 | }; 24 | 25 | return ( 26 |
27 | 30 | 36 | 37 | 38 | 39 | { 41 | archive(); 42 | handleClose(); 43 | }} 44 | > 45 | Archive This List 46 | 47 | 48 | 49 | 50 | 51 |
52 | ); 53 | }; 54 | 55 | ListMenu.propTypes = { 56 | listId: PropTypes.string.isRequired, 57 | }; 58 | 59 | export default ListMenu; 60 | -------------------------------------------------------------------------------- /client/src/components/list/ListTitle.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import { useDispatch } from 'react-redux'; 3 | import PropTypes from 'prop-types'; 4 | import { renameList } from '../../actions/board'; 5 | import { TextField } from '@material-ui/core'; 6 | 7 | const ListTitle = ({ list }) => { 8 | const [editing, setEditing] = useState(false); 9 | const [title, setTitle] = useState(list.title); 10 | const dispatch = useDispatch(); 11 | 12 | useEffect(() => { 13 | setTitle(list.title); 14 | }, [list.title]); 15 | 16 | const onSubmit = async (e) => { 17 | e.preventDefault(); 18 | dispatch(renameList(list._id, { title })); 19 | setEditing(false); 20 | }; 21 | 22 | return !editing ? ( 23 |

setEditing(true)}> 24 | {list.title} 25 |

26 | ) : ( 27 |
onSubmit(e)}> 28 | setTitle(e.target.value)} /> 29 | 30 | ); 31 | }; 32 | 33 | ListTitle.propTypes = { 34 | list: PropTypes.object.isRequired, 35 | }; 36 | 37 | export default ListTitle; 38 | -------------------------------------------------------------------------------- /client/src/components/list/MoveList.js: -------------------------------------------------------------------------------- 1 | import React, { Fragment, useState, useEffect } from 'react'; 2 | import { useSelector, useDispatch } from 'react-redux'; 3 | import PropTypes from 'prop-types'; 4 | import { moveList } from '../../actions/board'; 5 | 6 | import Button from '@material-ui/core/Button'; 7 | import Dialog from '@material-ui/core/Dialog'; 8 | import DialogActions from '@material-ui/core/DialogActions'; 9 | import DialogTitle from '@material-ui/core/DialogTitle'; 10 | import CloseIcon from '@material-ui/icons/Close'; 11 | import InputLabel from '@material-ui/core/InputLabel'; 12 | import MenuItem from '@material-ui/core/MenuItem'; 13 | import FormControl from '@material-ui/core/FormControl'; 14 | import Select from '@material-ui/core/Select'; 15 | import useStyles from '../../utils/dialogStyles'; 16 | 17 | const MoveList = ({ listId, closeMenu }) => { 18 | const classes = useStyles(); 19 | const [openDialog, setOpenDialog] = useState(false); 20 | const [position, setPosition] = useState(0); 21 | const [positions, setPositions] = useState([0]); 22 | const lists = useSelector((state) => state.board.board.lists); 23 | const listObjects = useSelector((state) => state.board.board.listObjects); 24 | const dispatch = useDispatch(); 25 | 26 | useEffect(() => { 27 | const mappedListObjects = listObjects 28 | .sort( 29 | (a, b) => 30 | lists.findIndex((id) => id === a._id) - lists.findIndex((id) => id === b._id) 31 | ) 32 | .map((list, index) => ({ list, index })); 33 | setPositions( 34 | mappedListObjects.filter((list) => !list.list.archived).map((list) => list.index) 35 | ); 36 | setPosition(mappedListObjects.findIndex((list) => list.list._id === listId)); 37 | }, [lists, listId, listObjects]); 38 | 39 | const onSubmit = async () => { 40 | dispatch(moveList(listId, { toIndex: position })); 41 | setOpenDialog(false); 42 | closeMenu(); 43 | }; 44 | 45 | return ( 46 | 47 |
setOpenDialog(true)}>Move This List
48 | setOpenDialog(false)}> 49 |
50 | {'Move List'} 51 | 54 |
55 | 56 | 57 | Position 58 | 70 | 79 | 80 | 81 |
82 |
83 | ); 84 | }; 85 | 86 | MoveList.propTypes = { 87 | listId: PropTypes.string.isRequired, 88 | closeMenu: PropTypes.func.isRequired, 89 | }; 90 | 91 | export default MoveList; 92 | -------------------------------------------------------------------------------- /client/src/components/other/Alert.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useSelector } from 'react-redux'; 3 | import AlertMUI from '@material-ui/lab/Alert'; 4 | 5 | const Alert = () => { 6 | const alerts = useSelector((state) => state.alert); 7 | 8 | return ( 9 | alerts !== null && 10 | alerts.length > 0 && 11 | alerts.map((alert) => ( 12 | 13 | {alert.msg} 14 | 15 | )) 16 | ); 17 | }; 18 | 19 | export default Alert; 20 | -------------------------------------------------------------------------------- /client/src/components/other/Copyright.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const Copyright = () => { 4 | return

Copyright © TrelloClone {new Date().getFullYear()}.

; 5 | }; 6 | 7 | export default Copyright; 8 | -------------------------------------------------------------------------------- /client/src/components/other/CreateBoard.js: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import { useDispatch } from 'react-redux'; 3 | import { withRouter } from 'react-router-dom'; 4 | import { addBoard } from '../../actions/board'; 5 | import { Modal, TextField, Button } from '@material-ui/core'; 6 | import CloseIcon from '@material-ui/icons/Close'; 7 | import useStyles from '../../utils/modalStyles'; 8 | 9 | const CreateBoard = ({ history }) => { 10 | const classes = useStyles(); 11 | const [open, setOpen] = useState(false); 12 | const [title, setTitle] = useState(''); 13 | const dispatch = useDispatch(); 14 | 15 | const onSubmit = async (e) => { 16 | e.preventDefault(); 17 | dispatch(addBoard({ title }, history)); 18 | }; 19 | 20 | const body = ( 21 |
22 |
23 |

Create new board

24 | 27 |
28 |
onSubmit(e)}> 29 | setTitle(e.target.value)} 38 | /> 39 | 42 | 43 |
44 | ); 45 | 46 | return ( 47 |
48 | 51 | setOpen(false)}> 52 | {body} 53 | 54 |
55 | ); 56 | }; 57 | 58 | export default withRouter(CreateBoard); 59 | -------------------------------------------------------------------------------- /client/src/components/other/Navbar.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Link } from 'react-router-dom'; 3 | import { useSelector, useDispatch } from 'react-redux'; 4 | import { logout } from '../../actions/auth'; 5 | 6 | const Navbar = () => { 7 | const isAuthenticated = useSelector((state) => state.auth.isAuthenticated); 8 | const dispatch = useDispatch(); 9 | 10 | if (!isAuthenticated) { 11 | return ''; 12 | } 13 | 14 | return ( 15 | 22 | ); 23 | }; 24 | 25 | export default Navbar; 26 | -------------------------------------------------------------------------------- /client/src/components/pages/Board.js: -------------------------------------------------------------------------------- 1 | import React, { Fragment, useEffect } from 'react'; 2 | import { useSelector, useDispatch } from 'react-redux'; 3 | import { Redirect } from 'react-router-dom'; 4 | import { DragDropContext, Droppable } from 'react-beautiful-dnd'; 5 | import { getBoard, moveCard, moveList } from '../../actions/board'; 6 | import { CircularProgress, Box } from '@material-ui/core'; 7 | import BoardTitle from '../board/BoardTitle'; 8 | import BoardDrawer from '../board/BoardDrawer'; 9 | import List from '../list/List'; 10 | import CreateList from '../board/CreateList'; 11 | import Members from '../board/Members'; 12 | import Navbar from '../other/Navbar'; 13 | 14 | const Board = ({ match }) => { 15 | const board = useSelector((state) => state.board.board); 16 | const isAuthenticated = useSelector((state) => state.auth.isAuthenticated); 17 | const dispatch = useDispatch(); 18 | 19 | useEffect(() => { 20 | dispatch(getBoard(match.params.id)); 21 | }, [dispatch, match.params.id]); 22 | 23 | useEffect(() => { 24 | if (board?.title) document.title = board.title + ' | TrelloClone'; 25 | }, [board?.title]); 26 | 27 | if (!isAuthenticated) { 28 | return ; 29 | } 30 | 31 | const onDragEnd = (result) => { 32 | const { source, destination, draggableId, type } = result; 33 | if (!destination) { 34 | return; 35 | } 36 | if (type === 'card') { 37 | dispatch( 38 | moveCard(draggableId, { 39 | fromId: source.droppableId, 40 | toId: destination.droppableId, 41 | toIndex: destination.index, 42 | }) 43 | ); 44 | } else { 45 | dispatch(moveList(draggableId, { toIndex: destination.index })); 46 | } 47 | }; 48 | 49 | return !board ? ( 50 | 51 | 52 | 53 | 54 | 55 | 56 | ) : ( 57 |
68 | 69 |
70 |
71 |
72 | 73 | 74 |
75 | 76 |
77 | 78 | 79 | {(provided) => ( 80 |
81 | {board.lists.map((listId, index) => ( 82 | 83 | ))} 84 | {provided.placeholder} 85 | 86 |
87 | )} 88 |
89 |
90 |
91 |
92 | ); 93 | }; 94 | 95 | export default Board; 96 | -------------------------------------------------------------------------------- /client/src/components/pages/Dashboard.js: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from 'react'; 2 | import { useSelector, useDispatch } from 'react-redux'; 3 | import { Redirect, Link } from 'react-router-dom'; 4 | import { getBoards } from '../../actions/board'; 5 | import CreateBoard from '../other/CreateBoard'; 6 | import Navbar from '../other/Navbar'; 7 | import CircularProgress from '@material-ui/core/CircularProgress'; 8 | 9 | const Dashboard = () => { 10 | const { user, isAuthenticated } = useSelector((state) => state.auth); 11 | const boards = useSelector((state) => state.board.boards); 12 | const loading = useSelector((state) => state.board.dashboardLoading); 13 | const dispatch = useDispatch(); 14 | 15 | useEffect(() => { 16 | dispatch(getBoards()); 17 | }, [dispatch]); 18 | 19 | useEffect(() => { 20 | document.title = 'Your Boards | TrelloClone'; 21 | }, []); 22 | 23 | if (!isAuthenticated) { 24 | return ; 25 | } 26 | 27 | return ( 28 |
29 | 30 |
31 |

Welcome {user && user.name}

32 |

Your Boards

33 | {loading && } 34 |
35 | {boards.map((board) => ( 36 | 37 | {board.title} 38 | 39 | ))} 40 | 41 |
42 |
43 |
44 | ); 45 | }; 46 | 47 | export default Dashboard; 48 | -------------------------------------------------------------------------------- /client/src/components/pages/Landing.js: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from 'react'; 2 | import { Button } from '@material-ui/core'; 3 | import { Redirect } from 'react-router-dom'; 4 | import { useSelector } from 'react-redux'; 5 | 6 | const Landing = () => { 7 | const isAuthenticated = useSelector((state) => state.auth.isAuthenticated); 8 | 9 | useEffect(() => { 10 | document.title = 'TrelloClone'; 11 | }, []); 12 | 13 | if (isAuthenticated) { 14 | return ; 15 | } 16 | 17 | return ( 18 |
19 | 30 |
31 |

TrelloClone

32 |

33 | Just like Trello, but made by just one guy! 34 |

35 |
36 | 39 |
40 |
41 |
42 | ); 43 | }; 44 | 45 | export default Landing; 46 | -------------------------------------------------------------------------------- /client/src/components/pages/Login.js: -------------------------------------------------------------------------------- 1 | // https://github.com/mui-org/material-ui/tree/master/docs/src/pages/getting-started/templates/sign-in 2 | 3 | import React, { useState, useEffect } from 'react'; 4 | import { Redirect } from 'react-router-dom'; 5 | import { useSelector, useDispatch } from 'react-redux'; 6 | import { login } from '../../actions/auth'; 7 | 8 | import Button from '@material-ui/core/Button'; 9 | import CssBaseline from '@material-ui/core/CssBaseline'; 10 | import TextField from '@material-ui/core/TextField'; 11 | import Link from '@material-ui/core/Link'; 12 | import Grid from '@material-ui/core/Grid'; 13 | import Box from '@material-ui/core/Box'; 14 | import Typography from '@material-ui/core/Typography'; 15 | import Container from '@material-ui/core/Container'; 16 | 17 | import Copyright from '../other/Copyright'; 18 | import useStyles from '../../utils/formStyles'; 19 | 20 | const Login = () => { 21 | const classes = useStyles(); 22 | 23 | const [formData, setFormData] = useState({ 24 | email: '', 25 | password: '', 26 | }); 27 | const isAuthenticated = useSelector((state) => state.auth.isAuthenticated); 28 | const dispatch = useDispatch(); 29 | 30 | const { email, password } = formData; 31 | 32 | useEffect(() => { 33 | document.title = 'TrelloClone | Sign In'; 34 | }, []); 35 | 36 | const onChange = (e) => setFormData({ ...formData, [e.target.name]: e.target.value }); 37 | 38 | const onSubmit = async (e) => { 39 | e.preventDefault(); 40 | dispatch(login(email, password)); 41 | }; 42 | 43 | if (isAuthenticated) { 44 | return ; 45 | } 46 | 47 | return ( 48 | 49 | 50 |
51 | 52 | TrelloClone 53 | 54 | 55 | Sign in 56 | 57 |
onSubmit(e)}> 58 | onChange(e)} 69 | /> 70 | onChange(e)} 81 | /> 82 | 91 | 92 | 93 | 94 | Don't have an account? Sign Up 95 | 96 | 97 | 98 | 99 |
100 | 101 | 102 | 103 |
104 | ); 105 | }; 106 | 107 | export default Login; 108 | -------------------------------------------------------------------------------- /client/src/components/pages/Register.js: -------------------------------------------------------------------------------- 1 | // https://github.com/mui-org/material-ui/tree/master/docs/src/pages/getting-started/templates/sign-up 2 | 3 | import React, { useState, useEffect } from 'react'; 4 | import { Redirect } from 'react-router-dom'; 5 | import { useSelector, useDispatch } from 'react-redux'; 6 | import { setAlert } from '../../actions/alert'; 7 | import { register } from '../../actions/auth'; 8 | 9 | import Button from '@material-ui/core/Button'; 10 | import CssBaseline from '@material-ui/core/CssBaseline'; 11 | import TextField from '@material-ui/core/TextField'; 12 | import Link from '@material-ui/core/Link'; 13 | import Grid from '@material-ui/core/Grid'; 14 | import Box from '@material-ui/core/Box'; 15 | import Typography from '@material-ui/core/Typography'; 16 | import Container from '@material-ui/core/Container'; 17 | 18 | import Copyright from '../other/Copyright'; 19 | import useStyles from '../../utils/formStyles'; 20 | 21 | const Register = () => { 22 | const classes = useStyles(); 23 | 24 | const [formData, setFormData] = useState({ 25 | name: '', 26 | email: '', 27 | password: '', 28 | password2: '', 29 | }); 30 | const isAuthenticated = useSelector((state) => state.auth.isAuthenticated); 31 | const dispatch = useDispatch(); 32 | 33 | useEffect(() => { 34 | document.title = 'TrelloClone | Sign Up'; 35 | }, []); 36 | 37 | const { name, email, password, password2 } = formData; 38 | 39 | const onChange = (e) => setFormData({ ...formData, [e.target.name]: e.target.value }); 40 | 41 | const onSubmit = async (e) => { 42 | e.preventDefault(); 43 | if (password !== password2) { 44 | dispatch(setAlert('Passwords do not match', 'error')); 45 | } else { 46 | dispatch(register({ name, email, password })); 47 | } 48 | }; 49 | 50 | if (isAuthenticated) { 51 | return ; 52 | } 53 | 54 | return ( 55 | 56 | 57 |
58 | 59 | TrelloClone 60 | 61 | 62 | Sign up 63 | 64 |
onSubmit(e)}> 65 | 66 | 67 | onChange(e)} 77 | /> 78 | 79 | 80 | onChange(e)} 89 | /> 90 | 91 | 92 | onChange(e)} 101 | /> 102 | 103 | 104 | onChange(e)} 113 | /> 114 | 115 | 116 | 125 | 126 | 127 | 128 | Already have an account? Sign in 129 | 130 | 131 | 132 |
133 |
134 | 135 | 136 | 137 |
138 | ); 139 | }; 140 | 141 | export default Register; 142 | -------------------------------------------------------------------------------- /client/src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import App from './App'; 4 | 5 | ReactDOM.render( 6 | 7 | 8 | , 9 | document.getElementById('root') 10 | ); 11 | -------------------------------------------------------------------------------- /client/src/reducers/alert.js: -------------------------------------------------------------------------------- 1 | import { SET_ALERT, REMOVE_ALERT } from '../actions/types'; 2 | 3 | const initialState = []; 4 | 5 | export default function (state = initialState, action) { 6 | const { type, payload } = action; 7 | 8 | switch (type) { 9 | case SET_ALERT: 10 | return [...state, payload]; 11 | case REMOVE_ALERT: 12 | return state.filter((alert) => alert.id !== payload); 13 | default: 14 | return state; 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /client/src/reducers/auth.js: -------------------------------------------------------------------------------- 1 | import { 2 | REGISTER_SUCCESS, 3 | REGISTER_FAIL, 4 | USER_LOADED, 5 | AUTH_ERROR, 6 | LOGIN_SUCCESS, 7 | LOGIN_FAIL, 8 | LOGOUT, 9 | } from '../actions/types'; 10 | 11 | const initialState = { 12 | token: localStorage.getItem('token'), 13 | isAuthenticated: null, 14 | loading: true, 15 | user: null, 16 | }; 17 | 18 | export default function (state = initialState, action) { 19 | const { type, payload } = action; 20 | 21 | switch (type) { 22 | case USER_LOADED: 23 | return { 24 | ...state, 25 | isAuthenticated: true, 26 | loading: false, 27 | user: payload, 28 | }; 29 | case REGISTER_SUCCESS: 30 | case LOGIN_SUCCESS: 31 | localStorage.setItem('token', payload.token); 32 | return { 33 | ...state, 34 | ...payload, 35 | isAuthenticated: true, 36 | loading: false, 37 | }; 38 | case REGISTER_FAIL: 39 | case AUTH_ERROR: 40 | case LOGIN_FAIL: 41 | case LOGOUT: 42 | localStorage.removeItem('token'); 43 | return { 44 | ...state, 45 | token: null, 46 | isAuthenticated: false, 47 | loading: false, 48 | }; 49 | default: 50 | return state; 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /client/src/reducers/board.js: -------------------------------------------------------------------------------- 1 | import { 2 | CLEAR_BOARD, 3 | GET_BOARDS, 4 | GET_BOARD, 5 | ADD_BOARD, 6 | BOARD_ERROR, 7 | RENAME_BOARD, 8 | GET_LIST, 9 | ADD_LIST, 10 | RENAME_LIST, 11 | ARCHIVE_LIST, 12 | GET_CARD, 13 | ADD_CARD, 14 | EDIT_CARD, 15 | MOVE_CARD, 16 | ARCHIVE_CARD, 17 | DELETE_CARD, 18 | GET_ACTIVITY, 19 | ADD_MEMBER, 20 | MOVE_LIST, 21 | ADD_CARD_MEMBER, 22 | ADD_CHECKLIST_ITEM, 23 | EDIT_CHECKLIST_ITEM, 24 | COMPLETE_CHECKLIST_ITEM, 25 | DELETE_CHECKLIST_ITEM, 26 | } from '../actions/types'; 27 | 28 | const initialState = { 29 | boards: [], 30 | board: null, 31 | dashboardLoading: true, 32 | error: {}, 33 | }; 34 | 35 | export default function (state = initialState, action) { 36 | const { type, payload } = action; 37 | 38 | switch (type) { 39 | case CLEAR_BOARD: 40 | return { 41 | ...state, 42 | board: null, 43 | }; 44 | case GET_BOARDS: 45 | return { 46 | ...state, 47 | boards: payload, 48 | dashboardLoading: false, 49 | }; 50 | case RENAME_BOARD: 51 | case GET_BOARD: 52 | return { 53 | ...state, 54 | board: { ...state.board, ...payload }, 55 | }; 56 | case ADD_BOARD: 57 | return { 58 | ...state, 59 | boards: [payload, ...state.boards], 60 | }; 61 | case BOARD_ERROR: 62 | return { 63 | ...state, 64 | error: payload, 65 | }; 66 | case GET_LIST: 67 | return { 68 | ...state, 69 | board: { 70 | ...state.board, 71 | listObjects: [...state.board.listObjects, payload], 72 | }, 73 | }; 74 | case ADD_LIST: 75 | return { 76 | ...state, 77 | board: { 78 | ...state.board, 79 | lists: [...state.board.lists, payload._id], 80 | }, 81 | }; 82 | case ARCHIVE_LIST: 83 | case RENAME_LIST: 84 | return { 85 | ...state, 86 | board: { 87 | ...state.board, 88 | listObjects: state.board.listObjects.map((list) => 89 | list._id === payload._id ? payload : list 90 | ), 91 | }, 92 | }; 93 | case GET_CARD: 94 | return { 95 | ...state, 96 | board: { 97 | ...state.board, 98 | cardObjects: [...state.board.cardObjects, payload], 99 | }, 100 | }; 101 | case ADD_CARD: 102 | return { 103 | ...state, 104 | board: { 105 | ...state.board, 106 | listObjects: state.board.listObjects.map((list) => 107 | list._id === payload.listId 108 | ? { ...list, cards: [...list.cards, payload.cardId] } 109 | : list 110 | ), 111 | }, 112 | }; 113 | case ADD_CHECKLIST_ITEM: 114 | case EDIT_CHECKLIST_ITEM: 115 | case COMPLETE_CHECKLIST_ITEM: 116 | case DELETE_CHECKLIST_ITEM: 117 | case ARCHIVE_CARD: 118 | case ADD_CARD_MEMBER: 119 | case EDIT_CARD: 120 | return { 121 | ...state, 122 | board: { 123 | ...state.board, 124 | cardObjects: state.board.cardObjects.map((card) => 125 | card._id === payload._id ? payload : card 126 | ), 127 | }, 128 | }; 129 | case MOVE_CARD: 130 | return { 131 | ...state, 132 | board: { 133 | ...state.board, 134 | listObjects: state.board.listObjects.map((list) => 135 | list._id === payload.from._id 136 | ? payload.from 137 | : list._id === payload.to._id 138 | ? payload.to 139 | : list 140 | ), 141 | cardObjects: state.board.cardObjects.filter( 142 | (card) => card._id !== payload.cardId || payload.to._id === payload.from._id 143 | ), 144 | }, 145 | }; 146 | case DELETE_CARD: 147 | return { 148 | ...state, 149 | board: { 150 | ...state.board, 151 | cardObjects: state.board.cardObjects.filter((card) => card._id !== payload), 152 | listObjects: state.board.listObjects.map((list) => 153 | list.cards.includes(payload) 154 | ? { ...list, cards: list.cards.filter((card) => card !== payload) } 155 | : list 156 | ), 157 | }, 158 | }; 159 | case GET_ACTIVITY: 160 | return { 161 | ...state, 162 | board: { 163 | ...state.board, 164 | activity: payload, 165 | }, 166 | }; 167 | case ADD_MEMBER: 168 | return { 169 | ...state, 170 | board: { 171 | ...state.board, 172 | members: payload, 173 | }, 174 | }; 175 | case MOVE_LIST: 176 | return { 177 | ...state, 178 | board: { 179 | ...state.board, 180 | lists: payload, 181 | }, 182 | }; 183 | default: 184 | return state; 185 | } 186 | } 187 | -------------------------------------------------------------------------------- /client/src/reducers/index.js: -------------------------------------------------------------------------------- 1 | import { combineReducers } from 'redux'; 2 | import alert from './alert'; 3 | import auth from './auth'; 4 | import board from './board'; 5 | 6 | export default combineReducers({ alert, auth, board }); 7 | -------------------------------------------------------------------------------- /client/src/store.js: -------------------------------------------------------------------------------- 1 | import { createStore, applyMiddleware } from 'redux'; 2 | import { composeWithDevTools } from 'redux-devtools-extension'; 3 | import thunk from 'redux-thunk'; 4 | import rootReducer from './reducers'; 5 | 6 | const initialState = {}; 7 | 8 | const middleWare = [thunk]; 9 | 10 | const store = createStore( 11 | rootReducer, 12 | initialState, 13 | composeWithDevTools(applyMiddleware(...middleWare)) 14 | ); 15 | 16 | export default store; 17 | -------------------------------------------------------------------------------- /client/src/utils/dialogStyles.js: -------------------------------------------------------------------------------- 1 | import { makeStyles } from '@material-ui/core/styles'; 2 | 3 | const useStyles = makeStyles((theme) => ({ 4 | dialog: { 5 | padding: 20, 6 | }, 7 | moveListTop: { 8 | display: 'flex', 9 | }, 10 | moveListBottom: { 11 | display: 'flex', 12 | flexDirection: 'column', 13 | }, 14 | moveListButton: { 15 | marginTop: 20, 16 | }, 17 | })); 18 | 19 | export default useStyles; 20 | -------------------------------------------------------------------------------- /client/src/utils/drawerStyles.js: -------------------------------------------------------------------------------- 1 | import { makeStyles } from '@material-ui/core/styles'; 2 | 3 | const drawerWidth = 330; 4 | 5 | const useStyles = makeStyles((theme) => ({ 6 | hide: { 7 | display: 'none', 8 | }, 9 | showMenuButton: { 10 | display: 'flex', 11 | justifyContent: 'space-between', 12 | width: 150, 13 | }, 14 | drawer: { 15 | width: drawerWidth, 16 | flexShrink: 0, 17 | }, 18 | drawerPaper: { 19 | width: drawerWidth, 20 | }, 21 | drawerHeader: { 22 | display: 'flex', 23 | alignItems: 'center', 24 | padding: '10px 20px', 25 | justifyContent: 'space-between', 26 | }, 27 | activityTitle: { 28 | textAlign: 'center', 29 | padding: '20px 20px 0', 30 | }, 31 | viewMoreActivityButton: { 32 | textAlign: 'center', 33 | margin: '0 auto 20px', 34 | }, 35 | })); 36 | 37 | export default useStyles; 38 | -------------------------------------------------------------------------------- /client/src/utils/formStyles.js: -------------------------------------------------------------------------------- 1 | import { makeStyles } from '@material-ui/core/styles'; 2 | 3 | const useStyles = makeStyles((theme) => ({ 4 | container: { 5 | display: 'flex', 6 | flexDirection: 'column', 7 | alignItems: 'center', 8 | height: '100vh', 9 | maxWidth: '100vw', 10 | padding: '20px', 11 | background: 'linear-gradient(135deg, #0079bf, #5067c5)', 12 | }, 13 | paper: { 14 | marginTop: theme.spacing(8), 15 | display: 'flex', 16 | flexDirection: 'column', 17 | alignItems: 'center', 18 | padding: '20px', 19 | background: 'white', 20 | maxWidth: '500px', 21 | }, 22 | form: { 23 | width: '100%', // Fix IE 11 issue. 24 | marginTop: theme.spacing(1), 25 | }, 26 | submit: { 27 | margin: theme.spacing(3, 0, 2), 28 | }, 29 | })); 30 | 31 | export default useStyles; 32 | -------------------------------------------------------------------------------- /client/src/utils/getInitials.js: -------------------------------------------------------------------------------- 1 | const getInitials = (name) => { 2 | let initials = name.match(/\b\w/g) || []; 3 | return ((initials.shift() || '') + (initials.pop() || '')).toUpperCase(); 4 | }; 5 | 6 | export default getInitials; 7 | -------------------------------------------------------------------------------- /client/src/utils/modalStyles.js: -------------------------------------------------------------------------------- 1 | import { makeStyles } from '@material-ui/core/styles'; 2 | 3 | const useStyles = makeStyles((theme) => ({ 4 | createBoardModal: { 5 | width: 400, 6 | }, 7 | cardModal: { 8 | width: 800, 9 | [theme.breakpoints.down('sm')]: { 10 | maxWidth: 400, 11 | }, 12 | }, 13 | cardTitle: { 14 | width: '100%', 15 | }, 16 | button: { 17 | width: 180, 18 | marginTop: 10, 19 | }, 20 | membersTitle: { 21 | margin: '20px 0 10px', 22 | }, 23 | labelTitle: { 24 | margin: '20px 0 10px', 25 | }, 26 | colorPicker: { 27 | minWidth: 212, 28 | }, 29 | noLabel: { 30 | width: 100, 31 | }, 32 | moveCardTitle: { 33 | marginTop: 20, 34 | }, 35 | moveCard: { 36 | display: 'flex', 37 | flexDirection: 'column', 38 | }, 39 | moveCardSelect: { 40 | marginTop: 10, 41 | marginRight: 20, 42 | width: 200, 43 | }, 44 | header: { 45 | marginTop: 10, 46 | marginBottom: 10, 47 | }, 48 | checklistItem: { 49 | display: 'flex', 50 | width: '100%', 51 | justifyContent: 'space-between', 52 | margin: '2px 0 5px', 53 | }, 54 | checklistFormLabel: { 55 | width: '100%', 56 | }, 57 | itemButtons: { 58 | display: 'flex', 59 | margin: 'auto', 60 | [theme.breakpoints.down('sm')]: { 61 | flexDirection: 'column', 62 | }, 63 | }, 64 | itemButton: { 65 | height: 40, 66 | }, 67 | checklistBottom: { 68 | marginTop: 5, 69 | }, 70 | paper: { 71 | display: 'flex', 72 | flexDirection: 'column', 73 | position: 'absolute', 74 | left: '50%', 75 | transform: 'translateX(-50%)', 76 | [theme.breakpoints.up('md')]: { 77 | top: '5%', 78 | maxHeight: '90vh', 79 | }, 80 | [theme.breakpoints.down('sm')]: { 81 | height: '100%', 82 | }, 83 | overflowY: 'auto', 84 | backgroundColor: theme.palette.background.paper, 85 | border: '2px solid #000', 86 | boxShadow: theme.shadows[5], 87 | padding: theme.spacing(2, 4, 3), 88 | }, 89 | modalTop: { 90 | display: 'flex', 91 | }, 92 | modalSection: { 93 | display: 'flex', 94 | justifyContent: 'space-between', 95 | flexWrap: 'wrap', 96 | height: 'auto', 97 | }, 98 | modalBottomRight: { 99 | display: 'flex', 100 | flexDirection: 'column', 101 | justifyContent: 'flex-end', 102 | marginTop: 20, 103 | }, 104 | archiveButton: { 105 | marginBottom: 5, 106 | }, 107 | })); 108 | 109 | export default useStyles; 110 | -------------------------------------------------------------------------------- /client/src/utils/setAuthToken.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios'; 2 | 3 | const setAuthToken = (token) => { 4 | if (token) { 5 | axios.defaults.headers.common['x-auth-token'] = token; 6 | } else { 7 | delete axios.defaults.headers.common['x-auth-token']; 8 | } 9 | }; 10 | 11 | export default setAuthToken; 12 | -------------------------------------------------------------------------------- /middleware/auth.js: -------------------------------------------------------------------------------- 1 | const jwt = require('jsonwebtoken'); 2 | require('dotenv').config(); 3 | 4 | module.exports = function (req, res, next) { 5 | // Get token from header 6 | const token = req.header('x-auth-token'); 7 | 8 | // Check if no token 9 | if (!token) { 10 | return res.status(401).json({ msg: 'No token, authorization denied' }); 11 | } 12 | 13 | // Verify token 14 | try { 15 | const decoded = jwt.verify(token, process.env.JWT_SECRET); 16 | req.user = decoded.user; 17 | next(); 18 | } catch (err) { 19 | res.status(401).json({ msg: 'Token is not valid' }); 20 | } 21 | }; 22 | -------------------------------------------------------------------------------- /middleware/member.js: -------------------------------------------------------------------------------- 1 | const Board = require('../models/Board'); 2 | 3 | module.exports = async function (req, res, next) { 4 | const board = await Board.findById(req.header('boardId')); 5 | if (!board) { 6 | return res.status(404).json({ msg: 'Board not found' }); 7 | } 8 | 9 | const members = board.members.map((member) => member.user); 10 | if (members.includes(req.user.id)) { 11 | next(); 12 | } else { 13 | res.status(401).json({ msg: 'You must be a member of this board to make changes' }); 14 | } 15 | }; 16 | -------------------------------------------------------------------------------- /models/Board.js: -------------------------------------------------------------------------------- 1 | const { Schema, model } = require('mongoose'); 2 | 3 | const BoardSchema = new Schema( 4 | { 5 | title: { 6 | type: String, 7 | required: true, 8 | }, 9 | lists: [ 10 | { 11 | type: Schema.Types.ObjectId, 12 | ref: 'lists', 13 | }, 14 | ], 15 | activity: [ 16 | { 17 | text: { 18 | type: String, 19 | }, 20 | date: { 21 | type: Date, 22 | default: Date.now, 23 | }, 24 | }, 25 | ], 26 | backgroundURL: { 27 | type: String, 28 | }, 29 | members: [ 30 | { 31 | _id: false, 32 | user: { 33 | type: Schema.Types.ObjectId, 34 | ref: 'users', 35 | }, 36 | name: { 37 | type: String, 38 | required: true, 39 | }, 40 | role: { 41 | type: String, 42 | default: 'admin', 43 | }, 44 | }, 45 | ], 46 | }, 47 | { 48 | timestamps: true, 49 | } 50 | ); 51 | 52 | module.exports = Board = model('board', BoardSchema); 53 | -------------------------------------------------------------------------------- /models/Card.js: -------------------------------------------------------------------------------- 1 | const { Schema, model } = require('mongoose'); 2 | 3 | const CardSchema = new Schema({ 4 | title: { 5 | type: String, 6 | required: true, 7 | }, 8 | description: { 9 | type: String, 10 | }, 11 | label: { 12 | type: String, 13 | }, 14 | members: [ 15 | { 16 | _id: false, 17 | user: { 18 | type: Schema.Types.ObjectId, 19 | ref: 'users', 20 | }, 21 | name: { 22 | type: String, 23 | required: true, 24 | }, 25 | }, 26 | ], 27 | checklist: [ 28 | { 29 | text: { 30 | type: String, 31 | }, 32 | complete: { 33 | type: Boolean, 34 | }, 35 | }, 36 | ], 37 | archived: { 38 | type: Boolean, 39 | required: true, 40 | default: false, 41 | }, 42 | }); 43 | 44 | module.exports = Card = model('card', CardSchema); 45 | -------------------------------------------------------------------------------- /models/List.js: -------------------------------------------------------------------------------- 1 | const { Schema, model } = require('mongoose'); 2 | 3 | const ListSchema = new Schema({ 4 | title: { 5 | type: String, 6 | required: true, 7 | }, 8 | cards: [ 9 | { 10 | type: Schema.Types.ObjectId, 11 | ref: 'cards', 12 | }, 13 | ], 14 | archived: { 15 | type: Boolean, 16 | required: true, 17 | default: false, 18 | }, 19 | }); 20 | 21 | module.exports = List = model('list', ListSchema); 22 | -------------------------------------------------------------------------------- /models/User.js: -------------------------------------------------------------------------------- 1 | const { Schema, model } = require('mongoose'); 2 | 3 | const UserSchema = new Schema({ 4 | name: { 5 | type: String, 6 | required: true, 7 | }, 8 | email: { 9 | type: String, 10 | required: true, 11 | unique: true, 12 | }, 13 | password: { 14 | type: String, 15 | required: true, 16 | }, 17 | avatar: { 18 | type: String, 19 | }, 20 | boards: [ 21 | { 22 | type: Schema.Types.ObjectId, 23 | ref: 'boards', 24 | }, 25 | ], 26 | }); 27 | 28 | module.exports = User = model('user', UserSchema); 29 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "trelloclone", 3 | "version": "1.0.0", 4 | "description": "A Trello clone built using the MERN stack.", 5 | "main": "server.js", 6 | "scripts": { 7 | "start": "node server", 8 | "server": "nodemon server", 9 | "client": "npm start --prefix client", 10 | "dev": "concurrently \"npm run server\" \"npm run client\"", 11 | "heroku-postbuild": "NPM_CONFIG_PRODUCTION=false npm install --prefix client && npm run build --prefix client" 12 | }, 13 | "repository": { 14 | "type": "git", 15 | "url": "git+https://github.com/ArchawinWongkittiruk/TrelloClone.git" 16 | }, 17 | "author": "Archawin Wongkittiruk", 18 | "license": "MIT", 19 | "bugs": { 20 | "url": "https://github.com/ArchawinWongkittiruk/TrelloClone/issues" 21 | }, 22 | "homepage": "https://github.com/ArchawinWongkittiruk/TrelloClone#readme", 23 | "dependencies": { 24 | "bcryptjs": "^2.4.3", 25 | "dotenv": "^8.2.0", 26 | "express": "^4.17.1", 27 | "express-validator": "^6.9.2", 28 | "gravatar": "^1.8.1", 29 | "jsonwebtoken": "^8.5.1", 30 | "mongoose": "^5.11.16" 31 | }, 32 | "devDependencies": { 33 | "concurrently": "^5.3.0", 34 | "nodemon": "^2.0.7" 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /preview.PNG: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ArchawinWongkittiruk/TrelloClone/e6ed4a02fe375bc79b386b9fd3a4b7729f328a24/preview.PNG -------------------------------------------------------------------------------- /routes/api/auth.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const router = express.Router(); 3 | const bcrypt = require('bcryptjs'); 4 | const auth = require('../../middleware/auth'); 5 | const jwt = require('jsonwebtoken'); 6 | const { check, validationResult } = require('express-validator'); 7 | require('dotenv').config(); 8 | 9 | const User = require('../../models/User'); 10 | 11 | // Get authorized user 12 | router.get('/', auth, async (req, res) => { 13 | try { 14 | const user = await User.findById(req.user.id).select('-password'); 15 | res.json(user); 16 | } catch (err) { 17 | console.error(err.message); 18 | res.status(500).send('Server error'); 19 | } 20 | }); 21 | 22 | // Authenticate user & get token 23 | router.post( 24 | '/', 25 | [ 26 | check('email', 'Email is required').isEmail(), 27 | check('password', 'Password is required').exists(), 28 | ], 29 | async (req, res) => { 30 | const errors = validationResult(req); 31 | if (!errors.isEmpty()) { 32 | return res.status(400).json({ errors: errors.array() }); 33 | } 34 | 35 | const { email, password } = req.body; 36 | 37 | try { 38 | // See if user exists 39 | let user = await User.findOne({ email }); 40 | if (!user) { 41 | return res.status(400).json({ 42 | errors: [{ msg: 'Invalid credentials' }], 43 | }); 44 | } 45 | 46 | // Check for email and password match 47 | const isMatch = await bcrypt.compare(password, user.password); 48 | if (!isMatch) { 49 | return res.status(400).json({ 50 | errors: [{ msg: 'Invalid credentials' }], 51 | }); 52 | } 53 | 54 | // Return jsonwebtoken 55 | jwt.sign( 56 | { 57 | user: { 58 | id: user.id, 59 | }, 60 | }, 61 | process.env.JWT_SECRET, 62 | { expiresIn: 360000 }, 63 | (err, token) => { 64 | if (err) throw err; 65 | res.json({ token }); 66 | } 67 | ); 68 | } catch (err) { 69 | console.error(err.message); 70 | res.status(500).send('Server error'); 71 | } 72 | } 73 | ); 74 | 75 | module.exports = router; 76 | -------------------------------------------------------------------------------- /routes/api/boards.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const router = express.Router(); 3 | const auth = require('../../middleware/auth'); 4 | const member = require('../../middleware/member'); 5 | const { check, validationResult } = require('express-validator'); 6 | 7 | const User = require('../../models/User'); 8 | const Board = require('../../models/Board'); 9 | 10 | // Add a board 11 | router.post( 12 | '/', 13 | [auth, [check('title', 'Title is required').not().isEmpty()]], 14 | async (req, res) => { 15 | const errors = validationResult(req); 16 | if (!errors.isEmpty()) { 17 | return res.status(400).json({ errors: errors.array() }); 18 | } 19 | 20 | try { 21 | const { title, backgroundURL } = req.body; 22 | 23 | // Create and save the board 24 | const newBoard = new Board({ title, backgroundURL }); 25 | const board = await newBoard.save(); 26 | 27 | // Add board to user's boards 28 | const user = await User.findById(req.user.id); 29 | user.boards.unshift(board.id); 30 | await user.save(); 31 | 32 | // Add user to board's members as admin 33 | board.members.push({ user: user.id, name: user.name }); 34 | 35 | // Log activity 36 | board.activity.unshift({ 37 | text: `${user.name} created this board`, 38 | }); 39 | await board.save(); 40 | 41 | res.json(board); 42 | } catch (err) { 43 | console.error(err.message); 44 | res.status(500).send('Server Error'); 45 | } 46 | } 47 | ); 48 | 49 | // Get user's boards 50 | router.get('/', auth, async (req, res) => { 51 | try { 52 | const user = await User.findById(req.user.id); 53 | 54 | const boards = []; 55 | for (const boardId of user.boards) { 56 | boards.push(await Board.findById(boardId)); 57 | } 58 | 59 | res.json(boards); 60 | } catch (err) { 61 | console.error(err.message); 62 | res.status(500).send('Server Error'); 63 | } 64 | }); 65 | 66 | // Get a board by id 67 | router.get('/:id', auth, async (req, res) => { 68 | try { 69 | const board = await Board.findById(req.params.id); 70 | if (!board) { 71 | return res.status(404).json({ msg: 'Board not found' }); 72 | } 73 | 74 | res.json(board); 75 | } catch (err) { 76 | console.error(err.message); 77 | res.status(500).send('Server Error'); 78 | } 79 | }); 80 | 81 | // Get a board's activity 82 | router.get('/activity/:boardId', auth, async (req, res) => { 83 | try { 84 | const board = await Board.findById(req.params.boardId); 85 | if (!board) { 86 | return res.status(404).json({ msg: 'Board not found' }); 87 | } 88 | 89 | res.json(board.activity); 90 | } catch (err) { 91 | console.error(err.message); 92 | res.status(500).send('Server Error'); 93 | } 94 | }); 95 | 96 | // Change a board's title 97 | router.patch( 98 | '/rename/:id', 99 | [auth, member, [check('title', 'Title is required').not().isEmpty()]], 100 | async (req, res) => { 101 | const errors = validationResult(req); 102 | if (!errors.isEmpty()) { 103 | return res.status(400).json({ errors: errors.array() }); 104 | } 105 | 106 | try { 107 | const board = await Board.findById(req.params.id); 108 | if (!board) { 109 | return res.status(404).json({ msg: 'Board not found' }); 110 | } 111 | 112 | // Log activity 113 | if (req.body.title !== board.title) { 114 | const user = await User.findById(req.user.id); 115 | board.activity.unshift({ 116 | text: `${user.name} renamed this board (from '${board.title}')`, 117 | }); 118 | } 119 | 120 | board.title = req.body.title; 121 | await board.save(); 122 | 123 | res.json(board); 124 | } catch (err) { 125 | console.error(err.message); 126 | res.status(500).send('Server Error'); 127 | } 128 | } 129 | ); 130 | 131 | // Add a board member 132 | router.put('/addMember/:userId', [auth, member], async (req, res) => { 133 | try { 134 | const board = await Board.findById(req.header('boardId')); 135 | const user = await User.findById(req.params.userId); 136 | if (!user) { 137 | return res.status(404).json({ msg: 'User not found' }); 138 | } 139 | 140 | // See if already member of board 141 | if (board.members.map((member) => member.user).includes(req.params.userId)) { 142 | return res.status(400).json({ msg: 'Already member of board' }); 143 | } 144 | 145 | // Add board to user's boards 146 | user.boards.unshift(board.id); 147 | await user.save(); 148 | 149 | // Add user to board's members with 'normal' role 150 | board.members.push({ user: user.id, name: user.name, role: 'normal' }); 151 | 152 | // Log activity 153 | board.activity.unshift({ 154 | text: `${user.name} joined this board`, 155 | }); 156 | await board.save(); 157 | 158 | res.json(board.members); 159 | } catch (err) { 160 | console.error(err.message); 161 | res.status(500).send('Server Error'); 162 | } 163 | }); 164 | 165 | module.exports = router; 166 | -------------------------------------------------------------------------------- /routes/api/cards.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const router = express.Router(); 3 | const auth = require('../../middleware/auth'); 4 | const member = require('../../middleware/member'); 5 | const { check, validationResult } = require('express-validator'); 6 | 7 | const User = require('../../models/User'); 8 | const Board = require('../../models/Board'); 9 | const List = require('../../models/List'); 10 | const Card = require('../../models/Card'); 11 | 12 | // Add a card 13 | router.post( 14 | '/', 15 | [auth, member, [check('title', 'Title is required').not().isEmpty()]], 16 | async (req, res) => { 17 | const errors = validationResult(req); 18 | if (!errors.isEmpty()) { 19 | return res.status(400).json({ errors: errors.array() }); 20 | } 21 | 22 | try { 23 | const { title, listId } = req.body; 24 | const boardId = req.header('boardId'); 25 | 26 | // Create and save the card 27 | const newCard = new Card({ title }); 28 | const card = await newCard.save(); 29 | 30 | // Assign the card to the list 31 | const list = await List.findById(listId); 32 | list.cards.push(card.id); 33 | await list.save(); 34 | 35 | // Log activity 36 | const user = await User.findById(req.user.id); 37 | const board = await Board.findById(boardId); 38 | board.activity.unshift({ 39 | text: `${user.name} added '${title}' to '${list.title}'`, 40 | }); 41 | await board.save(); 42 | 43 | res.json({ cardId: card.id, listId }); 44 | } catch (err) { 45 | console.error(err.message); 46 | res.status(500).send('Server Error'); 47 | } 48 | } 49 | ); 50 | 51 | // Get all of a list's cards 52 | router.get('/listCards/:listId', auth, async (req, res) => { 53 | try { 54 | const list = await List.findById(req.params.listId); 55 | if (!list) { 56 | return res.status(404).json({ msg: 'List not found' }); 57 | } 58 | 59 | const cards = []; 60 | for (const cardId of list.cards) { 61 | cards.push(await List.findById(cardId)); 62 | } 63 | 64 | res.json(cards); 65 | } catch (err) { 66 | console.error(err.message); 67 | res.status(500).send('Server Error'); 68 | } 69 | }); 70 | 71 | // Get a card by id 72 | router.get('/:id', auth, async (req, res) => { 73 | try { 74 | const card = await Card.findById(req.params.id); 75 | if (!card) { 76 | return res.status(404).json({ msg: 'Card not found' }); 77 | } 78 | 79 | res.json(card); 80 | } catch (err) { 81 | console.error(err.message); 82 | res.status(500).send('Server Error'); 83 | } 84 | }); 85 | 86 | // Edit a card's title, description, and/or label 87 | router.patch('/edit/:id', [auth, member], async (req, res) => { 88 | try { 89 | const { title, description, label } = req.body; 90 | if (title === '') { 91 | return res.status(400).json({ msg: 'Title is required' }); 92 | } 93 | 94 | const card = await Card.findById(req.params.id); 95 | if (!card) { 96 | return res.status(404).json({ msg: 'Card not found' }); 97 | } 98 | 99 | card.title = title ? title : card.title; 100 | if (description || description === '') { 101 | card.description = description; 102 | } 103 | if (label || label === 'none') { 104 | card.label = label; 105 | } 106 | await card.save(); 107 | 108 | res.json(card); 109 | } catch (err) { 110 | console.error(err.message); 111 | res.status(500).send('Server Error'); 112 | } 113 | }); 114 | 115 | // Archive/Unarchive a card 116 | router.patch('/archive/:archive/:id', [auth, member], async (req, res) => { 117 | try { 118 | const card = await Card.findById(req.params.id); 119 | if (!card) { 120 | return res.status(404).json({ msg: 'Card not found' }); 121 | } 122 | 123 | card.archived = req.params.archive === 'true'; 124 | await card.save(); 125 | 126 | // Log activity 127 | const user = await User.findById(req.user.id); 128 | const board = await Board.findById(req.header('boardId')); 129 | board.activity.unshift({ 130 | text: card.archived 131 | ? `${user.name} archived card '${card.title}'` 132 | : `${user.name} sent card '${card.title}' to the board`, 133 | }); 134 | await board.save(); 135 | 136 | res.json(card); 137 | } catch (err) { 138 | console.error(err.message); 139 | res.status(500).send('Server Error'); 140 | } 141 | }); 142 | 143 | // Move a card 144 | router.patch('/move/:id', [auth, member], async (req, res) => { 145 | try { 146 | const { fromId, toId, toIndex } = req.body; 147 | const boardId = req.header('boardId'); 148 | 149 | const cardId = req.params.id; 150 | const from = await List.findById(fromId); 151 | let to = await List.findById(toId); 152 | if (!cardId || !from || !to) { 153 | return res.status(404).json({ msg: 'List/card not found' }); 154 | } else if (fromId === toId) { 155 | to = from; 156 | } 157 | 158 | const fromIndex = from.cards.indexOf(cardId); 159 | if (fromIndex !== -1) { 160 | from.cards.splice(fromIndex, 1); 161 | await from.save(); 162 | } 163 | 164 | if (!to.cards.includes(cardId)) { 165 | if (toIndex === 0 || toIndex) { 166 | to.cards.splice(toIndex, 0, cardId); 167 | } else { 168 | to.cards.push(cardId); 169 | } 170 | await to.save(); 171 | } 172 | 173 | // Log activity 174 | if (fromId !== toId) { 175 | const user = await User.findById(req.user.id); 176 | const board = await Board.findById(boardId); 177 | const card = await Card.findById(cardId); 178 | board.activity.unshift({ 179 | text: `${user.name} moved '${card.title}' from '${from.title}' to '${to.title}'`, 180 | }); 181 | await board.save(); 182 | } 183 | 184 | res.send({ cardId, from, to }); 185 | } catch (err) { 186 | console.error(err.message); 187 | res.status(500).send('Server Error'); 188 | } 189 | }); 190 | 191 | // Add/Remove a member 192 | router.put('/addMember/:add/:cardId/:userId', [auth, member], async (req, res) => { 193 | try { 194 | const { cardId, userId } = req.params; 195 | const card = await Card.findById(cardId); 196 | const user = await User.findById(userId); 197 | if (!card || !user) { 198 | return res.status(404).json({ msg: 'Card/user not found' }); 199 | } 200 | 201 | const add = req.params.add === 'true'; 202 | const members = card.members.map((member) => member.user); 203 | const index = members.indexOf(userId); 204 | if ((add && members.includes(userId)) || (!add && index === -1)) { 205 | return res.json(card); 206 | } 207 | 208 | if (add) { 209 | card.members.push({ user: user.id, name: user.name }); 210 | } else { 211 | card.members.splice(index, 1); 212 | } 213 | await card.save(); 214 | 215 | // Log activity 216 | const board = await Board.findById(req.header('boardId')); 217 | board.activity.unshift({ 218 | text: `${user.name} ${add ? 'joined' : 'left'} '${card.title}'`, 219 | }); 220 | await board.save(); 221 | 222 | res.json(card); 223 | } catch (err) { 224 | console.error(err.message); 225 | res.status(500).send('Server Error'); 226 | } 227 | }); 228 | 229 | // Delete a card 230 | router.delete('/:listId/:id', [auth, member], async (req, res) => { 231 | try { 232 | const card = await Card.findById(req.params.id); 233 | const list = await List.findById(req.params.listId); 234 | if (!card || !list) { 235 | return res.status(404).json({ msg: 'List/card not found' }); 236 | } 237 | 238 | list.cards.splice(list.cards.indexOf(req.params.id), 1); 239 | await list.save(); 240 | await card.remove(); 241 | 242 | // Log activity 243 | const user = await User.findById(req.user.id); 244 | const board = await Board.findById(req.header('boardId')); 245 | board.activity.unshift({ 246 | text: `${user.name} deleted '${card.title}' from '${list.title}'`, 247 | }); 248 | await board.save(); 249 | 250 | res.json(req.params.id); 251 | } catch (err) { 252 | console.error(err.message); 253 | res.status(500).send('Server Error'); 254 | } 255 | }); 256 | 257 | module.exports = router; 258 | -------------------------------------------------------------------------------- /routes/api/checklists.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const router = express.Router(); 3 | const auth = require('../../middleware/auth'); 4 | const member = require('../../middleware/member'); 5 | const { check, validationResult } = require('express-validator'); 6 | 7 | const Card = require('../../models/Card'); 8 | 9 | // Add a checklist item 10 | router.post( 11 | '/:cardId', 12 | [auth, member, [check('text', 'Text is required').not().isEmpty()]], 13 | async (req, res) => { 14 | const errors = validationResult(req); 15 | if (!errors.isEmpty()) { 16 | return res.status(400).json({ errors: errors.array() }); 17 | } 18 | 19 | try { 20 | const card = await Card.findById(req.params.cardId); 21 | if (!card) { 22 | return res.status(404).json({ msg: 'Card not found' }); 23 | } 24 | 25 | card.checklist.push({ text: req.body.text, complete: false }); 26 | await card.save(); 27 | 28 | res.json(card); 29 | } catch (err) { 30 | console.error(err.message); 31 | res.status(500).send('Server Error'); 32 | } 33 | } 34 | ); 35 | 36 | // Edit a checklist's item's text 37 | router.patch( 38 | '/:cardId/:itemId', 39 | [auth, member, [check('text', 'Text is required').not().isEmpty()]], 40 | async (req, res) => { 41 | const errors = validationResult(req); 42 | if (!errors.isEmpty()) { 43 | return res.status(400).json({ errors: errors.array() }); 44 | } 45 | 46 | try { 47 | const card = await Card.findById(req.params.cardId); 48 | if (!card) { 49 | return res.status(404).json({ msg: 'Card not found' }); 50 | } 51 | 52 | card.checklist.find((item) => item.id === req.params.itemId).text = req.body.text; 53 | await card.save(); 54 | 55 | res.json(card); 56 | } catch (err) { 57 | console.error(err.message); 58 | res.status(500).send('Server Error'); 59 | } 60 | } 61 | ); 62 | 63 | // Complete/Uncomplete a checklist item 64 | router.patch('/:cardId/:complete/:itemId', [auth, member], async (req, res) => { 65 | try { 66 | const card = await Card.findById(req.params.cardId); 67 | if (!card) { 68 | return res.status(404).json({ msg: 'Card not found' }); 69 | } 70 | 71 | card.checklist.find((item) => item.id === req.params.itemId).complete = 72 | req.params.complete === 'true'; 73 | await card.save(); 74 | 75 | res.json(card); 76 | } catch (err) { 77 | console.error(err.message); 78 | res.status(500).send('Server Error'); 79 | } 80 | }); 81 | 82 | // Delete a checklist item 83 | router.delete('/:cardId/:itemId', [auth, member], async (req, res) => { 84 | try { 85 | const card = await Card.findById(req.params.cardId); 86 | if (!card) { 87 | return res.status(404).json({ msg: 'Card not found' }); 88 | } 89 | 90 | const index = card.checklist.findIndex((item) => item.id === req.params.itemId); 91 | if (index !== -1) { 92 | card.checklist.splice(index, 1); 93 | await card.save(); 94 | } 95 | 96 | res.json(card); 97 | } catch (err) { 98 | console.error(err.message); 99 | res.status(500).send('Server Error'); 100 | } 101 | }); 102 | 103 | module.exports = router; 104 | -------------------------------------------------------------------------------- /routes/api/lists.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const router = express.Router(); 3 | const auth = require('../../middleware/auth'); 4 | const member = require('../../middleware/member'); 5 | const { check, validationResult } = require('express-validator'); 6 | 7 | const User = require('../../models/User'); 8 | const Board = require('../../models/Board'); 9 | const List = require('../../models/List'); 10 | 11 | // Add a list 12 | router.post( 13 | '/', 14 | [auth, member, [check('title', 'Title is required').not().isEmpty()]], 15 | async (req, res) => { 16 | const errors = validationResult(req); 17 | if (!errors.isEmpty()) { 18 | return res.status(400).json({ errors: errors.array() }); 19 | } 20 | 21 | try { 22 | const title = req.body.title; 23 | const boardId = req.header('boardId'); 24 | 25 | // Create and save the list 26 | const newList = new List({ title }); 27 | const list = await newList.save(); 28 | 29 | // Assign the list to the board 30 | const board = await Board.findById(boardId); 31 | board.lists.push(list.id); 32 | 33 | // Log activity 34 | const user = await User.findById(req.user.id); 35 | board.activity.unshift({ 36 | text: `${user.name} added '${title}' to this board`, 37 | }); 38 | await board.save(); 39 | 40 | res.json(list); 41 | } catch (err) { 42 | console.error(err.message); 43 | res.status(500).send('Server Error'); 44 | } 45 | } 46 | ); 47 | 48 | // Get all of a board's lists 49 | router.get('/boardLists/:boardId', auth, async (req, res) => { 50 | try { 51 | const board = await Board.findById(req.params.boardId); 52 | if (!board) { 53 | return res.status(404).json({ msg: 'Board not found' }); 54 | } 55 | 56 | const lists = []; 57 | for (const listId of board.lists) { 58 | lists.push(await List.findById(listId)); 59 | } 60 | 61 | res.json(lists); 62 | } catch (err) { 63 | console.error(err.message); 64 | res.status(500).send('Server Error'); 65 | } 66 | }); 67 | 68 | // Get a list by id 69 | router.get('/:id', auth, async (req, res) => { 70 | try { 71 | const list = await List.findById(req.params.id); 72 | if (!list) { 73 | return res.status(404).json({ msg: 'List not found' }); 74 | } 75 | 76 | res.json(list); 77 | } catch (err) { 78 | console.error(err.message); 79 | res.status(500).send('Server Error'); 80 | } 81 | }); 82 | 83 | // Edit a list's title 84 | router.patch( 85 | '/rename/:id', 86 | [auth, member, [check('title', 'Title is required').not().isEmpty()]], 87 | async (req, res) => { 88 | const errors = validationResult(req); 89 | if (!errors.isEmpty()) { 90 | return res.status(400).json({ errors: errors.array() }); 91 | } 92 | 93 | try { 94 | const list = await List.findById(req.params.id); 95 | if (!list) { 96 | return res.status(404).json({ msg: 'List not found' }); 97 | } 98 | 99 | list.title = req.body.title; 100 | await list.save(); 101 | 102 | res.json(list); 103 | } catch (err) { 104 | console.error(err.message); 105 | res.status(500).send('Server Error'); 106 | } 107 | } 108 | ); 109 | 110 | // Archive/Unarchive a list 111 | router.patch('/archive/:archive/:id', [auth, member], async (req, res) => { 112 | try { 113 | const list = await List.findById(req.params.id); 114 | if (!list) { 115 | return res.status(404).json({ msg: 'List not found' }); 116 | } 117 | 118 | list.archived = req.params.archive === 'true'; 119 | await list.save(); 120 | 121 | // Log activity 122 | const user = await User.findById(req.user.id); 123 | const board = await Board.findById(req.header('boardId')); 124 | board.activity.unshift({ 125 | text: list.archived 126 | ? `${user.name} archived list '${list.title}'` 127 | : `${user.name} sent list '${list.title}' to the board`, 128 | }); 129 | await board.save(); 130 | 131 | res.json(list); 132 | } catch (err) { 133 | console.error(err.message); 134 | res.status(500).send('Server Error'); 135 | } 136 | }); 137 | 138 | // Move a list 139 | router.patch('/move/:id', [auth, member], async (req, res) => { 140 | try { 141 | const toIndex = req.body.toIndex ? req.body.toIndex : 0; 142 | const boardId = req.header('boardId'); 143 | const board = await Board.findById(boardId); 144 | const listId = req.params.id; 145 | if (!listId) { 146 | return res.status(404).json({ msg: 'List not found' }); 147 | } 148 | 149 | board.lists.splice(board.lists.indexOf(listId), 1); 150 | board.lists.splice(toIndex, 0, listId); 151 | await board.save(); 152 | 153 | res.send(board.lists); 154 | } catch (err) { 155 | console.error(err.message); 156 | res.status(500).send('Server Error'); 157 | } 158 | }); 159 | 160 | module.exports = router; 161 | -------------------------------------------------------------------------------- /routes/api/users.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const router = express.Router(); 3 | const auth = require('../../middleware/auth'); 4 | const gravatar = require('gravatar'); 5 | const bcrypt = require('bcryptjs'); 6 | const jwt = require('jsonwebtoken'); 7 | const { check, validationResult } = require('express-validator'); 8 | require('dotenv').config(); 9 | 10 | const User = require('../../models/User'); 11 | 12 | // Register user 13 | router.post( 14 | '/', 15 | [ 16 | check('name', 'Name is required').not().isEmpty(), 17 | check('email', 'Please include a valid email').isEmail(), 18 | check('password', 'Please enter a password with 6 or more characters').isLength({ 19 | min: 6, 20 | }), 21 | ], 22 | async (req, res) => { 23 | const errors = validationResult(req); 24 | if (!errors.isEmpty()) { 25 | return res.status(400).json({ errors: errors.array() }); 26 | } 27 | 28 | const { name, email, password } = req.body; 29 | 30 | try { 31 | // See if user exists 32 | if (await User.findOne({ email })) { 33 | return res.status(400).json({ errors: [{ msg: 'User already exists' }] }); 34 | } 35 | 36 | // Register new user 37 | const user = new User({ 38 | name, 39 | email, 40 | avatar: gravatar.url(email, { s: '200', r: 'pg', d: 'mm' }), 41 | password: await bcrypt.hash(password, await bcrypt.genSalt(10)), 42 | }); 43 | 44 | await user.save(); 45 | 46 | // Return jsonwebtoken 47 | jwt.sign( 48 | { 49 | user: { 50 | id: user.id, 51 | }, 52 | }, 53 | process.env.JWT_SECRET, 54 | { expiresIn: 360000 }, 55 | (err, token) => { 56 | if (err) throw err; 57 | res.json({ token }); 58 | } 59 | ); 60 | } catch (err) { 61 | console.error(err.message); 62 | res.status(500).send('Server error'); 63 | } 64 | } 65 | ); 66 | 67 | // Get users with email regex 68 | router.get('/:input', auth, async (req, res) => { 69 | try { 70 | const regex = new RegExp(req.params.input, 'i'); 71 | const users = await User.find({ 72 | email: regex, 73 | }).select('-password'); 74 | 75 | res.json(users.filter((user) => user.id !== req.user.id)); 76 | } catch (err) { 77 | console.error(err.message); 78 | res.status(500).send('Server error'); 79 | } 80 | }); 81 | 82 | module.exports = router; 83 | -------------------------------------------------------------------------------- /server.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const path = require('path'); 3 | const mongoose = require('mongoose'); 4 | require('dotenv').config(); 5 | 6 | const app = express(); 7 | 8 | // Connect database 9 | (async function connectDB() { 10 | try { 11 | await mongoose.connect(process.env.MONGO_URI, { 12 | useNewUrlParser: true, 13 | useUnifiedTopology: true, 14 | useCreateIndex: true, 15 | useFindAndModify: false, 16 | }); 17 | console.log('MongoDB Connected...'); 18 | } catch (err) { 19 | console.error(err.message); 20 | // Exit process with failure 21 | process.exit(1); 22 | } 23 | })(); 24 | 25 | // Init middleware 26 | app.use(express.json({ extended: false })); 27 | 28 | // Define routes 29 | app.use('/api/users', require('./routes/api/users')); 30 | app.use('/api/auth', require('./routes/api/auth')); 31 | app.use('/api/boards', require('./routes/api/boards')); 32 | app.use('/api/lists', require('./routes/api/lists')); 33 | app.use('/api/cards', require('./routes/api/cards')); 34 | app.use('/api/checklists', require('./routes/api/checklists')); 35 | 36 | // Serve static assets in production 37 | if (process.env.NODE_ENV === 'production') { 38 | // Set static folder 39 | app.use(express.static('client/build')); 40 | 41 | app.get('*', (req, res) => { 42 | res.sendFile(path.resolve(__dirname, 'client', 'build', 'index.html')); 43 | }); 44 | } 45 | 46 | const PORT = process.env.PORT || 5000; 47 | 48 | app.listen(PORT, () => console.log('Server started on port ' + PORT)); 49 | --------------------------------------------------------------------------------