├── .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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
--------------------------------------------------------------------------------