├── .gitignore ├── README.md ├── package-lock.json ├── package.json ├── postcss.config.js ├── public ├── favicon.ico ├── index.html └── manifest.json ├── server.js ├── server ├── api │ ├── middleware │ │ └── check-auth.js │ └── routes │ │ ├── auth.js │ │ ├── channels.js │ │ ├── messages.js │ │ └── users.js ├── app.js └── models │ ├── channel.js │ ├── message.js │ └── user.js ├── src ├── App.css ├── App.js ├── App.test.js ├── UI │ ├── Backdrop.js │ ├── Form │ │ ├── EmailField.js │ │ ├── FirstNameField.js │ │ ├── PasswordField.js │ │ └── TextField.js │ ├── Modal.js │ └── SpinnerRectangleBounce.js ├── chat-avatar.jpg ├── components │ ├── AuthLayout.js │ ├── ChannelsList.js │ ├── Chat.js │ ├── ChatMain.js │ ├── ChatSidebar.js │ ├── Home.js │ ├── Message.js │ ├── SidebarChannels.js │ ├── SignIn.js │ └── SignUp.js ├── hoc │ ├── withErrorHandler.js │ └── withLoader.js ├── index.css ├── index.js ├── logo.svg ├── registerServiceWorker.js ├── socket-context.js ├── store │ ├── actions │ │ ├── actionTypes.js │ │ ├── auth.js │ │ ├── channel.js │ │ ├── error.js │ │ ├── index.js │ │ ├── loader.js │ │ └── message.js │ └── reducers │ │ ├── auth.js │ │ ├── channel.js │ │ ├── error.js │ │ ├── loader.js │ │ └── message.js ├── styles │ ├── components │ │ ├── channel.css │ │ ├── modal.css │ │ ├── shared.css │ │ └── spinner-rectangle-bounce.css │ └── index.css └── utilities.js ├── tailwind.config.js └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/ignore-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | 6 | # testing 7 | /coverage 8 | 9 | # production 10 | /build 11 | 12 | # misc 13 | .DS_Store 14 | .env.local 15 | .env.development.local 16 | .env.test.local 17 | .env.production.local 18 | 19 | npm-debug.log* 20 | yarn-debug.log* 21 | yarn-error.log* 22 | .env 23 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## Website 2 | 3 | react.chatboard.com.au 4 | 5 | ## About 6 | 7 | A real-time chat application built with `React`, `Redux`, `Node.js`, `MongoDB` and `Socket.IO`. 8 | 9 | ## Running locally for development 10 | 11 | ### Set up environment variables 12 | 13 | Create a .env file in the root folder and set the values for JSON Web Token (JWT) key and MongoDB database. 14 | 15 | ``` 16 | JWT_KEY='your-jwt-key' 17 | DATABASE='your-mongodb-database' 18 | ``` 19 | 20 | ### Start the Express.js development server 21 | 22 | To start the Express.js development server, run the command below and the server will be live on localhost:3001 23 | 24 | ``` 25 | npm run serve 26 | ``` 27 | 28 | ### Start the React app 29 | 30 | To start the React app, run the command below and it will be live on localhost:3000 31 | 32 | ``` 33 | npm start 34 | ``` 35 | 36 | 37 | 38 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "chat-board-react", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "axios": "^0.18.0", 7 | "bcrypt": "^2.0.1", 8 | "body-parser": "^1.18.3", 9 | "dotenv": "^5.0.1", 10 | "express": "^4.16.3", 11 | "font-awesome": "^4.7.0", 12 | "formsy-react": "^1.1.4", 13 | "jsonwebtoken": "^8.2.1", 14 | "moment": "^2.22.2", 15 | "mongoose": "^5.1.2", 16 | "passport": "^0.4.0", 17 | "passport-local": "^1.0.0", 18 | "react": "^16.3.2", 19 | "react-dom": "^16.3.2", 20 | "react-redux": "^5.0.7", 21 | "react-router-dom": "^4.2.2", 22 | "react-scripts": "1.1.4", 23 | "redux": "^4.0.0", 24 | "redux-thunk": "^2.2.0", 25 | "socket.io": "^2.1.1" 26 | }, 27 | "scripts": { 28 | "start": "npm run watch:css & react-scripts start", 29 | "build": "npm run build:css && react-scripts build", 30 | "test": "react-scripts test --env=jsdom", 31 | "eject": "react-scripts eject", 32 | "build:css": "postcss src/styles/index.css -o src/index.css", 33 | "watch:css": "postcss src/styles/index.css -o src/index.css -w", 34 | "serve": "nodemon server.js" 35 | }, 36 | "devDependencies": { 37 | "autoprefixer": "^8.3.0", 38 | "nodemon": "^1.17.5", 39 | "postcss-cli": "^5.0.0", 40 | "redux-devtools": "^3.4.1", 41 | "tailwindcss": "^0.5.2" 42 | }, 43 | "proxy": "http://localhost:3001" 44 | } 45 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | const tailwindcss = require('tailwindcss'); 2 | 3 | module.exports = { 4 | plugins: [ 5 | tailwindcss('./tailwind.config.js'), 6 | require('autoprefixer'), 7 | ] 8 | } -------------------------------------------------------------------------------- /public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spencerfeng/chat-board-react/cf5df9109d4c9da5512723414e1b7e9790f0948b/public/favicon.ico -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 11 | 12 | 13 | 22 | Chat Board React 23 | 24 | 25 | 28 |
29 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | } 10 | ], 11 | "start_url": "./index.html", 12 | "display": "standalone", 13 | "theme_color": "#000000", 14 | "background_color": "#ffffff" 15 | } 16 | -------------------------------------------------------------------------------- /server.js: -------------------------------------------------------------------------------- 1 | const http = require('http'); 2 | const app = require('./server/app'); 3 | 4 | const port = process.env.PORT || 3001; 5 | const server = http.createServer(app); 6 | 7 | const io = require('socket.io')(server, { 8 | path: '/chat/socket.io' 9 | }); 10 | 11 | io.on('connection', (socket) => { 12 | console.log('user connected'); 13 | 14 | socket.on('disconnect', () => { 15 | console.log('user disconnected'); 16 | }); 17 | 18 | socket.on('new-channel-added', channel => { 19 | console.log(channel); 20 | socket.broadcast.emit('new-channel-added-broadcast-from-server', channel); 21 | }); 22 | 23 | socket.on('channel-deleted', channelId => { 24 | socket.broadcast.emit('channel-deleted-broadcast-from-server', channelId); 25 | }); 26 | 27 | socket.on('joined-channel', data => { 28 | socket.broadcast.emit('joined-channel-broadcast-from-server', data); 29 | }); 30 | 31 | socket.on('left-channel', data => { 32 | socket.broadcast.emit('left-channel-broadcast-from-server', data); 33 | }); 34 | 35 | socket.on('new-message-added', message => { 36 | socket.broadcast.emit('new-message-added-broadcast-from-server', message); 37 | }); 38 | 39 | socket.on('message-deleted', messageId => { 40 | socket.broadcast.emit('message-deleted-broadcast-from-server', messageId); 41 | }); 42 | }); 43 | 44 | server.listen(port); -------------------------------------------------------------------------------- /server/api/middleware/check-auth.js: -------------------------------------------------------------------------------- 1 | const jwt = require('jsonwebtoken'); 2 | 3 | module.exports = (req, res, next) => { 4 | try { 5 | const token = req.headers.authorization.split(' ')[1]; 6 | const decoded = jwt.verify(token, process.env.JWT_KEY); 7 | req.userData = decoded; 8 | next(); 9 | } catch (err) { 10 | return res.status(401).json({ 11 | status: 'fail', 12 | code: '401', 13 | data: { 14 | title: 'Please login to see this page.' 15 | } 16 | }); 17 | } 18 | } -------------------------------------------------------------------------------- /server/api/routes/auth.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const router = express.Router(); 3 | const mongoose = require('mongoose'); 4 | const jwt = require('jsonwebtoken'); 5 | const bcrypt = require('bcrypt'); 6 | const passport = require('passport'); 7 | const LocalStrategy = require('passport-local').Strategy; 8 | const checkAuth = require('../middleware/check-auth'); 9 | 10 | // Include models 11 | const User = require('../../models/user'); 12 | 13 | // Use dotnv module to load environment variables from a .env file into process.env 14 | require('dotenv').config() 15 | 16 | // Create the 'local-signup' named strategy using passport-local 17 | passport.use('local-signup', new LocalStrategy({ 18 | usernameField: 'email', 19 | passwordField: 'password', 20 | passReqToCallback: true, 21 | session: false 22 | }, (req, username, password, done) => { 23 | User.findOne({'email': username}).exec() 24 | .then(user => { 25 | if (user) { 26 | return done(null, false); 27 | } 28 | 29 | const newUser = new User(); 30 | 31 | // Generate the hashed password 32 | bcrypt.hash(req.body.password, 10) 33 | .then(hash => { 34 | // User can be created when reaching here 35 | newUser._id = new mongoose.Types.ObjectId(); 36 | newUser.email = username; 37 | newUser.firstName = req.body.firstName; 38 | newUser.lastName = req.body.lastName; 39 | newUser.password = hash; 40 | 41 | // First user is an admin 42 | return User.count().exec(); 43 | }) 44 | .then(count => { 45 | if (count === 0) { 46 | newUser.role = 'admin'; 47 | } else { 48 | newUser.role = 'user'; 49 | } 50 | 51 | return newUser.save(); 52 | }) 53 | .then(result => { 54 | return done(null, { 55 | _id: newUser._id, 56 | email: newUser.email, 57 | firstName: newUser.firstName, 58 | lastName: newUser.lastName, 59 | role: newUser.role 60 | }); 61 | }) 62 | .catch(err => { 63 | return done(err); 64 | }); 65 | }) 66 | .catch(err => { 67 | return done(err); 68 | }); 69 | })); 70 | 71 | // Create the 'local-signin' named strategy using passport-local 72 | passport.use('local-signin', new LocalStrategy({ 73 | usernameField: 'email', 74 | passwordField: 'password', 75 | passReqToCallback: true, 76 | session: false 77 | }, (req, username, password, done) => { 78 | let user = null; 79 | 80 | User.findOne({'email': username}).exec() 81 | .then(response => { 82 | if (!response) { 83 | return done(null, false); 84 | } 85 | 86 | // Assign the user object to a variable in a higher scope 87 | // so that it can be used in chained promises later. 88 | user = response; 89 | 90 | // If user exists, then check if the password is correct 91 | return bcrypt.compare(password, response.password); 92 | }) 93 | .then(res => { 94 | if (res === false) { 95 | return done(null, false); 96 | } else { 97 | return done(null, { 98 | _id: user._id, 99 | email: user.email, 100 | firstName: user.firstName, 101 | lastName: user.lastName, 102 | role: user.role 103 | }); 104 | } 105 | }) 106 | .catch(err => { 107 | return done(err); 108 | }); 109 | })); 110 | 111 | 112 | // Route to handle user signup 113 | router.post('/signup', (req, res, next) => { 114 | passport.authenticate('local-signup', (err, user) => { 115 | if (err) { 116 | return res.status(500).json({ 117 | status: 'error', 118 | code: '500', 119 | message: 'Something is wrong. Please try again later.' 120 | }); 121 | } 122 | 123 | if (!user) { 124 | return res.status(422).json({ 125 | status: 'fail', 126 | code: '422', 127 | data: { 128 | title: 'This email has been used for another account' 129 | } 130 | }); 131 | } 132 | 133 | const token = jwt.sign( 134 | { 135 | email: user.email, 136 | firstName: user.firstName, 137 | lastName: user.lastName, 138 | _id: user._id, 139 | role: user.role 140 | }, 141 | process.env.JWT_KEY, 142 | { 143 | expiresIn: 60 * 60 144 | } 145 | ); 146 | 147 | return res.status(201).json({ 148 | status: 'success', 149 | code: '201', 150 | data: { 151 | token: token, 152 | user: user 153 | } 154 | }) 155 | })(req, res, next); 156 | }); 157 | 158 | // Route to handle user signin 159 | router.post('/signin', (req, res, next) => { 160 | passport.authenticate('local-signin', (err, user) => { 161 | if (err) { 162 | return res.status(500).json({ 163 | status: 'error', 164 | code: '500', 165 | message: 'Something is wrong. Please try again later.' 166 | }); 167 | } 168 | 169 | if (!user) { 170 | return res.status(422).json({ 171 | status: 'failed', 172 | code: '422', 173 | data: { 174 | title: 'User credentials are not correct' 175 | } 176 | }); 177 | } 178 | 179 | const token = jwt.sign( 180 | { 181 | email: user.email, 182 | firstName: user.firstName, 183 | lastName: user.lastName, 184 | _id: user._id, 185 | role: user.role 186 | }, 187 | process.env.JWT_KEY, 188 | { 189 | expiresIn: 60 * 60 190 | } 191 | ); 192 | 193 | return res.status(200).json({ 194 | status: 'success', 195 | code: '200', 196 | data: { 197 | token: token, 198 | user: user 199 | } 200 | }); 201 | 202 | })(req, res, next); 203 | }); 204 | 205 | router.get('/check', checkAuth, (req, res, next) => { 206 | return res.status(200).json({ 207 | status: 'success', 208 | code: '200', 209 | data: { 210 | user: req.userData 211 | } 212 | }); 213 | }); 214 | 215 | module.exports = router; -------------------------------------------------------------------------------- /server/api/routes/channels.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const router = express.Router(); 3 | const mongoose = require('mongoose'); 4 | const jwt = require('jsonwebtoken'); 5 | const checkAuth = require('../middleware/check-auth'); 6 | 7 | // Include models 8 | const Channel = require('../../models/channel'); 9 | const Message = require('../../models/message'); 10 | const User = require('../../models/user'); 11 | 12 | router.get('/', checkAuth, (req, res, next) => { 13 | Channel.find().exec() 14 | .then(channels => { 15 | return res.status(200).json({ 16 | status: 'success', 17 | data: { 18 | channels: channels, 19 | user: { 20 | _id: req.userData._id, 21 | firstName: req.userData.firstName, 22 | lastName: req.userData.lastName, 23 | email: req.userData.email, 24 | role: req.userData.role 25 | } 26 | } 27 | }); 28 | }) 29 | .catch(err => { 30 | return res.status(500).json({ 31 | status: 'error', 32 | code: '500', 33 | message: 'Something is wrong. Please try again later' 34 | }) 35 | }); 36 | }); 37 | 38 | 39 | router.post('/', checkAuth, (req, res, next) => { 40 | const newChannel = new Channel(); 41 | 42 | // Only channel name with a unique name is allowed 43 | Channel.findOne({'name': req.body.name}).exec() 44 | .then(channel => { 45 | if (channel) { 46 | return res.status(422).json({ 47 | status: 'fail', 48 | code: '422', 49 | data: { 50 | title: 'This channel name has been taken' 51 | } 52 | }); 53 | } 54 | 55 | newChannel._id = new mongoose.Types.ObjectId(); 56 | newChannel.name = req.body.name; 57 | newChannel.createdBy = req.userData._id; 58 | newChannel.createdAt = new Date(); 59 | newChannel.members = [req.userData._id]; 60 | 61 | return newChannel.save(); 62 | }) 63 | .then(result => { 64 | const newMessage = new Message(); 65 | newMessage._id = new mongoose.Types.ObjectId(); 66 | newMessage.body = `joined #${newChannel.name}`; 67 | newMessage.createdAt = new Date(); 68 | newMessage.createdBy = req.userData._id; 69 | newMessage.channel = newChannel._id; 70 | 71 | return newMessage.save(); 72 | }) 73 | .then(result => { 74 | return res.status(201).json({ 75 | status: 'success', 76 | code: '201', 77 | data: { 78 | channel: newChannel, 79 | user: { 80 | _id: req.userData._id, 81 | firstName: req.userData.firstName, 82 | lastName: req.userData.lastName, 83 | email: req.userData.email, 84 | role: req.userData.role 85 | } 86 | } 87 | }); 88 | }) 89 | .catch(err => { 90 | return res.status(500).json({ 91 | status: 'error', 92 | code: '500', 93 | message: 'Something is wrong. Please try again later.' 94 | }); 95 | }); 96 | }); 97 | 98 | router.delete('/:channelId', checkAuth, (req, res, next) => { 99 | const channelId = req.params.channelId; 100 | 101 | // Only admin can delete channels 102 | if (req.userData.role !== 'admin') { 103 | return res.status(403).json({ 104 | status: 'fail', 105 | code: '403', 106 | data: { 107 | title: 'Sorry, you do not have the permission to perform this action.' 108 | } 109 | }); 110 | } 111 | 112 | // Check if the channel exists 113 | Channel.findById(channelId).exec() 114 | .then(channel => { 115 | // Delete all messages in this channel 116 | return Message.remove({channel: channelId}).exec(); 117 | }) 118 | .then(result => { 119 | // Delete the channel 120 | return Channel.remove({_id: channelId}).exec(); 121 | }) 122 | .then(result => { 123 | res.status(200).json({ 124 | status: 'success', 125 | code: '200', 126 | data: { 127 | user: { 128 | _id: req.userData._id, 129 | firstName: req.userData.firstName, 130 | lastName: req.userData.lastName, 131 | email: req.userData.email, 132 | role: req.userData.role 133 | } 134 | } 135 | }); 136 | }) 137 | .catch(err => { 138 | return res.status(500).json({ 139 | status: 'error', 140 | code: '500', 141 | message: 'Something is wrong. Please try again later.' 142 | }); 143 | }); 144 | }); 145 | 146 | router.post('/:channelId/members', checkAuth, (req, res, next) => { 147 | let thisChannel = null; 148 | let newMessage = null; 149 | let memberUser = null; 150 | 151 | Channel.findById(req.params.channelId).exec() 152 | .then(channel => { 153 | // TODO: check if user has already been a member of thie channel 154 | 155 | thisChannel = channel; 156 | 157 | channel.members.push(req.body.newMemberUserId); 158 | return channel.save(); 159 | }) 160 | .then(result => { 161 | return User.findById(req.body.newMemberUserId).exec(); 162 | }) 163 | .then(user => { 164 | memberUser = user; 165 | 166 | const message = new Message(); 167 | message._id = new mongoose.Types.ObjectId(); 168 | message.body = `joined #${thisChannel.name}`; 169 | message.createdAt = new Date(); 170 | message.createdBy = req.body.newMemberUserId; 171 | message.channel = req.params.channelId; 172 | 173 | newMessage = message; 174 | 175 | return message.save(); 176 | }) 177 | .then(result => { 178 | return res.status(200).json({ 179 | status: 'success', 180 | code: '200', 181 | data: { 182 | newMessage: { 183 | _id: newMessage._id, 184 | body: newMessage.body, 185 | createdAt: newMessage.createdAt, 186 | channel: newMessage.channel, 187 | createdBy: { 188 | _id: memberUser._id, 189 | firstName: memberUser.firstName, 190 | lastName: memberUser.lastName, 191 | email: memberUser.email, 192 | role: memberUser.role 193 | } 194 | } 195 | } 196 | }); 197 | }) 198 | .catch(err => { 199 | return res.status(500).json({ 200 | status: 'error', 201 | code: '500', 202 | message: 'Something is wrong. Please try again later.' 203 | }); 204 | }); 205 | }); 206 | 207 | router.delete('/:channelId/members/:memberId', checkAuth, (req, res, next) => { 208 | let thisChannel = null; 209 | let newMessage = null; 210 | let memberUser = null; 211 | 212 | Channel.findById(req.params.channelId).exec() 213 | .then(channel => { 214 | // TODO: check if the user is not a member of this channel 215 | 216 | thisChannel = channel; 217 | 218 | const updatedChannelMembers = channel.members.filter(member => { 219 | return member.toString() !== req.params.memberId; 220 | }); 221 | 222 | channel.members = updatedChannelMembers; 223 | return channel.save(); 224 | }) 225 | .then(result => { 226 | return User.findById(req.params.memberId).exec(); 227 | }) 228 | .then(user => { 229 | memberUser = user; 230 | 231 | const message = new Message(); 232 | message._id = new mongoose.Types.ObjectId(); 233 | message.body = `left #${thisChannel.name}`; 234 | message.createdAt = new Date(); 235 | message.channel = req.params.channelId; 236 | message.createdBy = req.params.memberId; 237 | 238 | newMessage = message; 239 | 240 | return newMessage.save(); 241 | }) 242 | .then(result => { 243 | return res.status(200).json({ 244 | status: 'success', 245 | code: '200', 246 | data: { 247 | newMessage: { 248 | _id: newMessage._id, 249 | body: newMessage.body, 250 | createdAt: newMessage.createdAt, 251 | channel: newMessage.channel, 252 | createdBy: { 253 | _id: memberUser._id, 254 | firstName: memberUser.firstName, 255 | lastName: memberUser.lastName, 256 | email: memberUser.email, 257 | role: memberUser.role 258 | } 259 | } 260 | } 261 | }); 262 | }) 263 | .catch(err => { 264 | return res.status(500).json({ 265 | status: 'error', 266 | code: '500', 267 | message: 'Something is wrong. Please try again later.' 268 | }); 269 | }); 270 | }); 271 | 272 | module.exports = router; 273 | -------------------------------------------------------------------------------- /server/api/routes/messages.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const router = express.Router(); 3 | const mongoose = require('mongoose'); 4 | const jwt = require('jsonwebtoken'); 5 | const checkAuth = require('../middleware/check-auth'); 6 | 7 | // Include models 8 | const Message = require('../../models/message'); 9 | 10 | router.get('/channels/:channelId', checkAuth, (req, res, next) => { 11 | Message.find({'channel': req.params.channelId}) 12 | .populate('createdBy', ['_id', 'email', 'firstName', 'lastName', 'role']) 13 | .exec() 14 | .then(messages => { 15 | res.status(200).json({ 16 | status: 'success', 17 | code: '200', 18 | data: { 19 | messages: messages, 20 | user: { 21 | _id: req.userData._id, 22 | firstName: req.userData.firstName, 23 | lastName: req.userData.lastName, 24 | email: req.userData.email, 25 | role: req.userData.role 26 | } 27 | } 28 | }); 29 | }) 30 | .catch(err => { 31 | res.status(500).json({ 32 | status: 'error', 33 | code: '500', 34 | message: 'Something is wrong. Please try again later.' 35 | }); 36 | }); 37 | }); 38 | 39 | router.post('/channels/:channelId', checkAuth, (req, res, next) => { 40 | 41 | // TODO: if the user is not a member of this channel, handling error. 42 | 43 | const newMessage = new Message(); 44 | newMessage._id = new mongoose.Types.ObjectId(); 45 | newMessage.body = req.body.messageBody; 46 | newMessage.createdBy = req.userData._id; 47 | newMessage.createdAt = new Date(); 48 | newMessage.channel = req.params.channelId; 49 | 50 | newMessage.save() 51 | .then(result => { 52 | res.status(201).json({ 53 | status: 'success', 54 | code: '201', 55 | data: { 56 | message: { 57 | _id: newMessage._id, 58 | createdAt: newMessage.createdAt, 59 | body: newMessage.body, 60 | channel: newMessage.channel, 61 | createdBy: { 62 | _id: req.userData._id, 63 | email: req.userData.email, 64 | firstName: req.userData.firstName, 65 | lastName: req.userData.lastName, 66 | role: req.userData.role 67 | } 68 | }, 69 | user: { 70 | _id: req.userData._id, 71 | firstName: req.userData.firstName, 72 | lastName: req.userData.lastName, 73 | email: req.userData.email, 74 | role: req.userData.role 75 | } 76 | } 77 | }); 78 | }) 79 | .catch(err => { 80 | res.status(500).json({ 81 | status: 'error', 82 | code: '500', 83 | message: 'Something is wrong. Please try again later.' 84 | }); 85 | }); 86 | 87 | }); 88 | 89 | router.delete('/:messageId', checkAuth, (req, res, next) => { 90 | const messageId = req.params.messageId; 91 | let deletedMessage = null; 92 | 93 | Message.findById(messageId).exec() 94 | .then(message => { 95 | if (!message) { 96 | return res.status(404).json({ 97 | status: 'fail', 98 | code: '404', 99 | data: { 100 | title: 'Sorry, this message does not exist.' 101 | } 102 | }); 103 | } 104 | 105 | // Only the message owner and admin can delete the message 106 | if (req.userData.role !== 'admin' && req.userData._id !== message.createdBy.toString()) { 107 | return res.status(403).json({ 108 | status: 'fail', 109 | code: '403', 110 | data: { 111 | title: 'Sorry, you are not allow to perform this action.' 112 | } 113 | }); 114 | } 115 | 116 | deletedMessage = message; 117 | return Message.remove({_id: messageId}).exec(); 118 | }) 119 | .then(result => { 120 | return res.status(200).json({ 121 | status: 'success', 122 | code: '200', 123 | data: { 124 | deletedMessage: deletedMessage 125 | } 126 | }); 127 | }) 128 | .catch(err => { 129 | res.status(500).json({ 130 | status: 'error', 131 | code: '500', 132 | message: 'Something is wrong. Please try again later.' 133 | }); 134 | }); 135 | }); 136 | 137 | module.exports = router; -------------------------------------------------------------------------------- /server/api/routes/users.js: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spencerfeng/chat-board-react/cf5df9109d4c9da5512723414e1b7e9790f0948b/server/api/routes/users.js -------------------------------------------------------------------------------- /server/app.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const app = express(); 3 | const bodyParser = require('body-parser'); 4 | const mongoose = require('mongoose'); 5 | 6 | // Use dotnv module to load environment variables from a .env file into process.env 7 | require('dotenv').config() 8 | 9 | // Include routes files 10 | const authRoutes = require('./api/routes/auth'); 11 | const channelsRoutes = require('./api/routes/channels'); 12 | const messagesRoutes = require('./api/routes/messages'); 13 | 14 | // Connect database 15 | mongoose.connect(process.env.DATABASE) 16 | .then(() => { 17 | console.log('Database connected'); 18 | }) 19 | .catch(err => { 20 | console.log(err); 21 | }); 22 | 23 | // Configure body parser 24 | app.use(bodyParser.urlencoded({extended: false})); 25 | app.use(bodyParser.json()); 26 | 27 | // Enable CORS 28 | app.use((req, res, next) => { 29 | res.header('Access-Control-Allow-Origin', '*'); 30 | res.header( 31 | 'Access-Control-Allow-Headers', 32 | 'Origin, X-Requested-With, Content-Type, Accept, Authorization' 33 | ); 34 | if (req.method === 'OPTIONS') { 35 | res.header('Access-Control-Allow-Methods', 'PUT, POST, PATCH, DELETE, GET'); 36 | return res.status(200).json({}); 37 | } 38 | next(); 39 | }); 40 | 41 | // Routes which should handle requests 42 | app.use('/api/auth', authRoutes); 43 | app.use('/api/channels', channelsRoutes); 44 | app.use('/api/messages', messagesRoutes); 45 | 46 | // Catch 404 error 47 | app.use((req, res, next) => { 48 | const error = new Error('Not Found'); 49 | error.status = 404; 50 | next(error); 51 | }); 52 | 53 | // Handle other error 54 | app.use((req, res, next) => { 55 | res.status(error.status || 500); 56 | res.json({ 57 | error: { 58 | message: error.message 59 | } 60 | }); 61 | }); 62 | 63 | module.exports = app; -------------------------------------------------------------------------------- /server/models/channel.js: -------------------------------------------------------------------------------- 1 | const mongoose = require('mongoose'); 2 | const Schema = mongoose.Schema; 3 | 4 | const schema = new Schema({ 5 | _id: { 6 | type: Schema.Types.ObjectId, 7 | required: true 8 | }, 9 | name: { 10 | type: String, 11 | required: true 12 | }, 13 | createdAt: { 14 | type: Date, 15 | required: true 16 | }, 17 | createdBy: { 18 | type: Schema.Types.ObjectId, 19 | ref: 'User', 20 | required: true 21 | }, 22 | members: { 23 | type: [Schema.Types.ObjectId], 24 | ref: 'User', 25 | required: true 26 | } 27 | }); 28 | 29 | module.exports = mongoose.model('Channel', schema); -------------------------------------------------------------------------------- /server/models/message.js: -------------------------------------------------------------------------------- 1 | const mongoose = require('mongoose'); 2 | const Schema = mongoose.Schema; 3 | 4 | const schema = new Schema({ 5 | _id: { 6 | type: Schema.Types.ObjectId, 7 | required: true 8 | }, 9 | body: { 10 | type: String, 11 | required: true 12 | }, 13 | createdAt: { 14 | type: Date, 15 | required: true 16 | }, 17 | createdBy: { 18 | type: Schema.Types.ObjectId, 19 | ref: 'User', 20 | required: true 21 | }, 22 | channel: { 23 | type: Schema.Types.ObjectId, 24 | ref: 'Channel', 25 | required: true 26 | } 27 | }); 28 | 29 | module.exports = mongoose.model('Message', schema); -------------------------------------------------------------------------------- /server/models/user.js: -------------------------------------------------------------------------------- 1 | const mongoose = require('mongoose'); 2 | const Schema = mongoose.Schema; 3 | 4 | const schema = new Schema({ 5 | _id: { 6 | type: Schema.Types.ObjectId, 7 | required: true 8 | }, 9 | firstName: { 10 | type: String, 11 | required: true 12 | }, 13 | lastName: { 14 | type: String, 15 | required: true 16 | }, 17 | email: { 18 | type: String, 19 | required: true 20 | }, 21 | password: { 22 | type: String, 23 | required: true 24 | }, 25 | role: { 26 | type: String, 27 | required: true 28 | } 29 | }); 30 | 31 | module.exports = mongoose.model('User', schema); -------------------------------------------------------------------------------- /src/App.css: -------------------------------------------------------------------------------- 1 | .App { 2 | text-align: center; 3 | } 4 | 5 | .App-logo { 6 | animation: App-logo-spin infinite 20s linear; 7 | height: 80px; 8 | } 9 | 10 | .App-header { 11 | background-color: #222; 12 | height: 150px; 13 | padding: 20px; 14 | color: white; 15 | } 16 | 17 | .App-title { 18 | font-size: 1.5em; 19 | } 20 | 21 | .App-intro { 22 | font-size: large; 23 | } 24 | 25 | @keyframes App-logo-spin { 26 | from { transform: rotate(0deg); } 27 | to { transform: rotate(360deg); } 28 | } 29 | -------------------------------------------------------------------------------- /src/App.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import { Route, Switch, withRouter, Redirect } from 'react-router-dom' 3 | import Chat from './components/Chat' 4 | import Home from './components/Home' 5 | import AuthLayout from './components/AuthLayout' 6 | import { connect } from 'react-redux' 7 | import * as Utilities from './utilities' 8 | import withErrorHandler from './hoc/withErrorHandler' 9 | import withLoader from './hoc/withLoader' 10 | import SocketContext from './socket-context' 11 | import * as io from 'socket.io-client' 12 | 13 | const socket = io(process.env.REACT_APP_API_BASE_URL, { 14 | secure: true, 15 | rejectUnauthorized: false, 16 | path: '/chat/socket.io' 17 | }) 18 | 19 | class App extends Component { 20 | 21 | render() { 22 | return ( 23 | 24 |
25 | 26 | ( 27 | Utilities.isLoggedIn() ? ( 28 | 29 | ) : ( 30 | 31 | ) 32 | )} /> 33 | ( 34 | !Utilities.isLoggedIn() ? ( 35 | 36 | ) : ( 37 | 38 | ) 39 | )} /> 40 | 41 | 42 |
43 |
44 | ); 45 | } 46 | } 47 | 48 | const mapStateToProps = state => { 49 | return { 50 | auth: state.auth 51 | } 52 | } 53 | 54 | export default withRouter(withLoader(withErrorHandler(connect(mapStateToProps)(App)))) 55 | -------------------------------------------------------------------------------- /src/App.test.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import App from './App'; 4 | 5 | it('renders without crashing', () => { 6 | const div = document.createElement('div'); 7 | ReactDOM.render(, div); 8 | ReactDOM.unmountComponentAtNode(div); 9 | }); 10 | -------------------------------------------------------------------------------- /src/UI/Backdrop.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | 3 | class Backdrop extends Component { 4 | 5 | render() { 6 | 7 | return ( 8 | this.props.show ?
: null 9 | ) 10 | 11 | } 12 | 13 | } 14 | 15 | export default Backdrop -------------------------------------------------------------------------------- /src/UI/Form/EmailField.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import { withFormsy } from 'formsy-react' 3 | 4 | class EmailField extends Component { 5 | 6 | changeValue = e => { 7 | this.props.setValue(e.currentTarget.value) 8 | } 9 | 10 | render() { 11 | const errorMessage = this.props.getErrorMessage() 12 | 13 | return ( 14 |
15 | 21 | { !this.props.isPristine() && (
{errorMessage}
) } 22 |
23 | ) 24 | } 25 | 26 | } 27 | 28 | export default withFormsy(EmailField) -------------------------------------------------------------------------------- /src/UI/Form/FirstNameField.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import { withFormsy } from 'formsy-react' 3 | 4 | class FirstNameField extends Component { 5 | 6 | changeValue = e => { 7 | this.props.setValue(e.currentTarget.value) 8 | } 9 | 10 | render() { 11 | const errorMessage = this.props.getErrorMessage() 12 | 13 | return ( 14 |
15 | 21 | { !this.props.isPristine() && (
{errorMessage}
) } 22 |
23 | ) 24 | } 25 | 26 | } 27 | 28 | export default withFormsy(FirstNameField) -------------------------------------------------------------------------------- /src/UI/Form/PasswordField.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import { withFormsy } from 'formsy-react' 3 | 4 | class PasswordField extends Component { 5 | 6 | changeValue = e => { 7 | this.props.setValue(e.currentTarget.value) 8 | } 9 | 10 | render() { 11 | const errorMessage = this.props.getErrorMessage() 12 | 13 | return ( 14 |
15 | 21 | { !this.props.isPristine() && (
{errorMessage}
) } 22 |
23 | ) 24 | 25 | } 26 | 27 | } 28 | 29 | export default withFormsy(PasswordField) -------------------------------------------------------------------------------- /src/UI/Form/TextField.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import { withFormsy } from 'formsy-react' 3 | 4 | class TextField extends Component { 5 | 6 | changeValue = e => { 7 | this.props.setValue(e.currentTarget.value) 8 | } 9 | 10 | render() { 11 | const errorMessage = this.props.getErrorMessage() 12 | 13 | return ( 14 |
15 | 21 | { !this.props.isPristine() && (
{errorMessage}
) } 22 |
23 | ) 24 | } 25 | 26 | } 27 | 28 | export default withFormsy(TextField) -------------------------------------------------------------------------------- /src/UI/Modal.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import { connect } from 'react-redux' 3 | import Backdrop from './Backdrop' 4 | import * as actions from '../store/actions/index' 5 | 6 | class Modal extends Component { 7 | 8 | render() { 9 | 10 | return ( 11 | 12 | 13 |
19 | this.props.onStartDimissError()}>× 20 | {this.props.children} 21 |
22 |
23 | ) 24 | 25 | } 26 | 27 | } 28 | 29 | const mapDispatchToProps = dispatch => { 30 | return { 31 | onStartDimissError: () => dispatch(actions.dismissError()) 32 | } 33 | } 34 | 35 | export default connect(null, mapDispatchToProps)(Modal) 36 | 37 | -------------------------------------------------------------------------------- /src/UI/SpinnerRectangleBounce.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | 3 | class SpinnerRectangleBounce extends Component { 4 | 5 | state = { 6 | size: 'normal', 7 | color: 'white', 8 | class: '' 9 | } 10 | 11 | static getDerivedStateFromProps(nextProps, prevState) { 12 | return { 13 | size: nextProps.size ? nextProps.size : prevState.size, 14 | color: nextProps.color ? nextProps.color : prevState.color, 15 | class: nextProps.class ? nextProps.class : prevState.class 16 | } 17 | } 18 | 19 | render() { 20 | let spinner = null; 21 | if (this.props.show) { 22 | let spinnerClass = ['spinner-rectangle-bounce', `spinner-${this.state.size}`, `spinner-${this.state.color}`, this.state.class]; 23 | spinner = ( 24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 | ) 32 | } 33 | 34 | return spinner 35 | } 36 | 37 | } 38 | 39 | export default SpinnerRectangleBounce -------------------------------------------------------------------------------- /src/chat-avatar.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/spencerfeng/chat-board-react/cf5df9109d4c9da5512723414e1b7e9790f0948b/src/chat-avatar.jpg -------------------------------------------------------------------------------- /src/components/AuthLayout.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Route, Switch, NavLink, withRouter } from 'react-router-dom' 3 | import SignIn from './SignIn' 4 | import SignUp from './SignUp' 5 | import { connect } from 'react-redux' 6 | import logo from '../logo.svg' 7 | 8 | const AuthLayout = props => { 9 | 10 | return ( 11 | 12 |
13 |
14 |

15 | Chat Board logo 16 | Chat Board 17 |

18 |
    19 |
  • 20 | Sign In 21 |
  • 22 |
  • 23 | Sign Up 24 |
  • 25 |
26 |
27 |
28 | 29 | 30 | 31 | 32 |
33 |
34 |
35 | ); 36 | 37 | }; 38 | 39 | const mapStateToProps = state => { 40 | return { 41 | auth: state.auth 42 | } 43 | } 44 | 45 | export default withRouter(connect(mapStateToProps)(AuthLayout)) 46 | -------------------------------------------------------------------------------- /src/components/ChannelsList.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import { connect } from 'react-redux' 3 | import { NavLink, withRouter } from 'react-router-dom' 4 | import * as actions from '../store/actions/index' 5 | import SocketContext from '../socket-context' 6 | 7 | class ChannelsList extends Component { 8 | 9 | joinChannel = channelId => { 10 | this.props.onStartJoinChannel(channelId, this.props.history, this.props.socket) 11 | } 12 | 13 | leaveChannel = channelId => { 14 | this.props.onStartLeaveChannel(channelId, this.props.history, this.props.socket) 15 | } 16 | 17 | deleteChannel(channelId) { 18 | this.props.onStartDeleteChannel(channelId, this.props.history, this.props.socket) 19 | } 20 | 21 | render() { 22 | 23 | let channelsList = ( 24 |
Sorry, there are no channels yet.
25 | ) 26 | 27 | if (this.props.channels !== null && this.props.channels.length) { 28 | channelsList = ( 29 | 69 | ); 70 | } 71 | 72 | return channelsList; 73 | 74 | } 75 | 76 | } 77 | 78 | const ChannelsListWithSocket = props => ( 79 | 80 | {socket => } 81 | 82 | ) 83 | 84 | const mapStateToProps = state => { 85 | return { 86 | auth: state.auth 87 | } 88 | } 89 | 90 | const mapDispatchToProps = dispatch => { 91 | return { 92 | onStartDeleteChannel: (channelId, history, socket) => dispatch(actions.startDeleteChannel(channelId, history, socket)), 93 | onStartJoinChannel: (channelId, history, socket) => dispatch(actions.startJoinChannel(channelId, history, socket)), 94 | onStartLeaveChannel: (channelId, history, socket) => dispatch(actions.startLeaveChannel(channelId, history, socket)) 95 | } 96 | } 97 | 98 | export default withRouter(connect(mapStateToProps, mapDispatchToProps)(ChannelsListWithSocket)) -------------------------------------------------------------------------------- /src/components/Chat.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import { Route, withRouter } from 'react-router-dom' 3 | import { connect } from 'react-redux' 4 | import ChatSidebar from './ChatSidebar' 5 | import ChatMain from './ChatMain' 6 | import SocketContext from '../socket-context' 7 | import * as actions from '../store/actions/index' 8 | 9 | class Chat extends Component { 10 | 11 | componentDidMount() { 12 | // Listen channel added broadcast from the server via socket.io 13 | this.props.socket.on('new-channel-added-broadcast-from-server', channel => { 14 | this.props.onReceiveChannelAddedBroadcast(channel) 15 | }) 16 | 17 | // Listen channel deleted broadcast from the server via socket.io 18 | this.props.socket.on('channel-deleted-broadcast-from-server', channelId => { 19 | this.props.onReceiveChannelDeletedBroadcast(channelId, this.props.history) 20 | }) 21 | 22 | // Listen joined channel broadcast from the server via socket.io 23 | this.props.socket.on('joined-channel-broadcast-from-server', data => { 24 | this.props.onReceiveJoinedChannelBroadcast(data.channelId, data.userId) 25 | }) 26 | 27 | // Listen left channel broadcast from the server via socket.io 28 | this.props.socket.on('left-channel-broadcast-from-server', data => { 29 | this.props.onReceiveLeftChannelBroadcast(data.channelId, data.userId, this.props.history) 30 | }) 31 | 32 | // Listen new message added broadcast from the server via socket.io 33 | this.props.socket.on('new-message-added-broadcast-from-server', message => { 34 | this.props.onReceiveMessageAddedBroadcast(message) 35 | }) 36 | 37 | // Listen message deleted broadcast from the server via socket.io 38 | this.props.socket.on('message-deleted-broadcast-from-server', messageId => { 39 | this.props.onReceiveMessageDeletedBroadcast(messageId) 40 | }) 41 | } 42 | 43 | componentDidUpdate() { 44 | if (this.props.location.pathname === '/messages/' && this.props.selectedChannelId !== null) { 45 | this.props.onSetSelectedChannel(null) 46 | } 47 | } 48 | 49 | render() { 50 | return ( 51 | 52 |
53 | 54 |
55 | 56 |
57 |
58 |
59 | ); 60 | } 61 | } 62 | 63 | const ChatWithSocket = props => ( 64 | 65 | {socket => } 66 | 67 | ) 68 | 69 | const mapStateToProps = state => { 70 | return { 71 | selectedChannelId: state.channel.selectedChannelId 72 | } 73 | } 74 | 75 | const mapDispatchToProps = dispatch => { 76 | return { 77 | onSetSelectedChannel: channelId => dispatch(actions.setSelectedChannel(channelId)), 78 | onReceiveChannelAddedBroadcast: channel => dispatch(actions.addChannelSuccess(channel)), 79 | onReceiveChannelDeletedBroadcast: (channelId, history) => dispatch(actions.receivedChannelDeletedBroadcast(channelId, history)), 80 | onReceiveJoinedChannelBroadcast: (channelId, userId) => dispatch(actions.receivedJoinedChannelBroadcast(channelId, userId)), 81 | onReceiveLeftChannelBroadcast: (channelId, userId, history) => dispatch(actions.receivedLeftChannelBroadcast(channelId, userId, history)), 82 | onReceiveMessageAddedBroadcast: message => dispatch(actions.receivedMessageAddedBroadcast(message)), 83 | onReceiveMessageDeletedBroadcast: messageId => dispatch(actions.receivedMessageDeletedBroadcast(messageId)) 84 | } 85 | } 86 | 87 | export default withRouter(connect(mapStateToProps, mapDispatchToProps)(ChatWithSocket)) -------------------------------------------------------------------------------- /src/components/ChatMain.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import { connect } from 'react-redux' 3 | import * as actions from '../store/actions/index' 4 | import Message from './Message' 5 | import SpinnerRectangleBounce from '../UI/SpinnerRectangleBounce' 6 | import SocketContext from '../socket-context' 7 | 8 | class ChatMain extends Component { 9 | 10 | newMessageFieldRef = React.createRef() 11 | 12 | componentDidMount() { 13 | const channelId = this.props.match.params.channelId 14 | this.props.onSetSelectedChannel(channelId) 15 | 16 | // Only get channel messages if the current user is a member of the channel 17 | if (this.props.channels !== null) { 18 | const channel = this.props.channels.find(c => { 19 | return c._id === channelId 20 | }) 21 | if (channel !== undefined) { 22 | if (channel.members.indexOf(this.props.auth._id) >=0) { 23 | this.props.onStartGetChannelMessages(channelId, this.props.history) 24 | } 25 | } 26 | } 27 | } 28 | 29 | componentDidUpdate() { 30 | const channelId = this.props.match.params.channelId 31 | if (channelId !== this.props.selectedChannelId || (channelId === this.props.selectedChannelId && this.props.error.message === null && this.props.channels && this['props']['messages'][channelId] === undefined)) { 32 | 33 | if (channelId !== this.props.selectedChannelId) { 34 | this.props.onSetSelectedChannel(channelId) 35 | } 36 | 37 | // Only get channel messages if the current user is a member of the channel 38 | if (this.props.channels !== null) { 39 | const channel = this.props.channels.find(c => { 40 | return c._id === channelId 41 | }); 42 | if (channel !== undefined) { 43 | if (channel.members.indexOf(this.props.auth._id) >=0) { 44 | this.props.onStartGetChannelMessages(channelId, this.props.history) 45 | } 46 | } 47 | } 48 | } 49 | } 50 | 51 | sendMessage = e => { 52 | e.preventDefault() 53 | const messageBody = this.newMessageFieldRef.current.value 54 | 55 | this.props.onStartAddMessage(messageBody, this.props.match.params.channelId, this.props.history, this.props.socket) 56 | .then(() => { 57 | const messagesListContainer = document.querySelector('.chat-main-body') 58 | const messagesList = document.querySelector('.messages-container') 59 | 60 | messagesListContainer.scrollTop = messagesList.scrollHeight 61 | }) 62 | 63 | // Clear message field 64 | this.newMessageFieldRef.current.value = '' 65 | } 66 | 67 | render() { 68 | 69 | const channelId = this.props.match.params.channelId 70 | let currentChannel = null 71 | 72 | // Channel Info HTML 73 | let channelInfo = () 74 | 75 | // Chat Section (including messages list and the new message text field) 76 | let chatSection = ( 77 |
78 | 79 |
80 | ); 81 | 82 | // Channels have been loaded from the server 83 | if (this.props.channels !== null) { 84 | currentChannel = this.props.channels.find(channel => { 85 | return channel._id === channelId 86 | }); 87 | 88 | // The selected channel exists 89 | if (currentChannel !== undefined) { 90 | 91 | // Channel Info HTML 92 | channelInfo = (

# {currentChannel.name}

) 93 | 94 | // Check if the current user is a member of the channel 95 | const isMember = currentChannel.members.indexOf(this.props.auth._id) >= 0 96 | 97 | // If the current user is a member 98 | // and messages have been loaded from the server, 99 | // display the messages list 100 | if (isMember) { 101 | 102 | // Check if messages in this channel have been loaded from the server 103 | const messagesLoaded = !!this['props']['messages'][channelId] 104 | 105 | let messagesList = null 106 | 107 | // Messages have been loaded, then display the messages and the new messge field 108 | if (messagesLoaded) { 109 | messagesList = this['props']['messages'][channelId].sort((a, b) => { 110 | return a.createdAt > b.createdAt 111 | }) 112 | .map(message => { 113 | return ( 114 | 115 | ) 116 | }); 117 | 118 | chatSection = ( 119 |
120 |
121 |
122 |
123 | {messagesList} 124 |
125 |
126 |
127 |
128 |
129 | 130 | 131 |
132 |
133 |
134 | ); 135 | } 136 | // Messages have not been loaded, then display the spinner 137 | else { 138 | chatSection = ( 139 |
140 | 141 |
142 | ) 143 | } 144 | } 145 | // If the current user is not a member, display a join button 146 | else { 147 | chatSection = ( 148 |
149 | 150 |
151 | ); 152 | } 153 | } 154 | // The selected channel does not exist 155 | else { 156 | // TODO: what to do if the seleted channel does not exist. 157 | } 158 | } 159 | 160 | return ( 161 | 162 |
163 | {channelInfo} 164 |
165 | {chatSection} 166 |
167 | ) 168 | } 169 | } 170 | 171 | const ChatMainWithSocket = (props) => ( 172 | 173 | {socket => } 174 | 175 | ) 176 | 177 | const mapStateToProps = state => { 178 | return { 179 | selectedChannelId: state.channel.selectedChannelId, 180 | channels: state.channel.channels, 181 | auth: state.auth, 182 | messages: state.message.messages, 183 | error: state.error 184 | } 185 | }; 186 | 187 | const mapDispatchToProps = dispatch => { 188 | return { 189 | onSetSelectedChannel: (channelId) => dispatch(actions.setSelectedChannel(channelId)), 190 | onStartGetChannelMessages: (channelId, history) => dispatch(actions.startGetChannelMessages(channelId, history)), 191 | onStartAddMessage: (messageBody, channelId, history, socket) => dispatch(actions.startAddMessage(messageBody, channelId, history, socket)), 192 | onStartJoinChannel: (channelId, history, socket) => dispatch(actions.startJoinChannel(channelId, history, socket)) 193 | } 194 | 195 | } 196 | 197 | export default connect(mapStateToProps, mapDispatchToProps)(ChatMainWithSocket) -------------------------------------------------------------------------------- /src/components/ChatSidebar.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import { connect } from 'react-redux' 3 | import SidebarChannels from './SidebarChannels' 4 | import SpinnerRectangleBounce from '../UI/SpinnerRectangleBounce' 5 | import * as actions from '../store/actions/index' 6 | import { withRouter } from 'react-router-dom' 7 | 8 | class ChatSidebar extends Component { 9 | 10 | componentDidMount() { 11 | this.props.onStartGetChannels(this.props.history); 12 | } 13 | 14 | render() { 15 | 16 | let accountName = null; 17 | if (this.props.auth._id) { 18 | accountName = ( 19 | 20 |
21 |
{this.props.auth.firstName} {this.props.auth.lastName}
22 |
23 | ) 24 | } 25 | 26 | let chatSidebar = ( 27 |
28 | 29 |
30 | ) 31 | 32 | if (this.props.channels !== null) { 33 | chatSidebar = ( 34 |
35 |
36 |

Chat Board

37 |
38 | {accountName} 39 |
40 |
41 | 42 | 43 | 44 |
45 |
46 | 47 |
48 |
49 |
50 | ) 51 | } 52 | 53 | return chatSidebar 54 | 55 | } 56 | 57 | } 58 | 59 | const mapStateToProps = state => { 60 | return { 61 | channels: state.channel.channels, 62 | auth: state.auth 63 | } 64 | } 65 | 66 | const mapDispatchToProps = dispatch => { 67 | return { 68 | onStartGetChannels: history => dispatch(actions.startGetChannels(history)), 69 | onStartLogout: history => dispatch(actions.startLogout(history)) 70 | } 71 | } 72 | 73 | export default withRouter(connect(mapStateToProps, mapDispatchToProps)(ChatSidebar)) -------------------------------------------------------------------------------- /src/components/Home.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import { NavLink } from 'react-router-dom' 3 | import { connect } from 'react-redux' 4 | import logo from '../logo.svg' 5 | import * as actions from '../store/actions/index' 6 | 7 | class Home extends Component { 8 | 9 | componentDidMount() { 10 | this.props.onStartCheckAuth() 11 | } 12 | 13 | render() { 14 | 15 | let btns = ( 16 |
17 | Sign In 18 | Sign Up 19 |
20 | ) 21 | 22 | if (this.props.auth._id) { 23 | btns = ( 24 |
25 | Go to Chat 26 |
27 | ) 28 | } 29 | 30 | return ( 31 |
32 |
33 |

Chat Board

34 | chat board 35 | {btns} 36 |
37 |
38 | ) 39 | 40 | } 41 | 42 | } 43 | 44 | const mapStateToProps = state => { 45 | return { 46 | auth: state.auth 47 | } 48 | } 49 | 50 | const mapDispatchToProps = dispatch => { 51 | return { 52 | onStartCheckAuth: () => dispatch(actions.startCheckAuth()) 53 | } 54 | } 55 | 56 | export default connect(mapStateToProps, mapDispatchToProps)(Home) -------------------------------------------------------------------------------- /src/components/Message.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import { connect } from 'react-redux' 3 | import { withRouter } from 'react-router-dom' 4 | import * as actions from '../store/actions/index' 5 | import * as Utilities from '../utilities' 6 | import SocketContext from '../socket-context' 7 | import ChatAvatar from '../chat-avatar.jpg' 8 | 9 | class Message extends Component { 10 | 11 | render() { 12 | return ( 13 |
14 |
15 |
16 | avatar 17 |
18 |
19 |
20 |
{this.props.createdBy.firstName} {this.props.createdBy.lastName}
21 |
{Utilities.dateObjToFormattedStr(this.props.createdAt)}
22 |
23 |
24 | {this.props.body} 25 |
26 |
27 |
28 | 29 | {(this.props.auth.role === 'admin' || this.props.auth._id === this.props.createdBy._id) && 30 | ( 31 |
32 | 33 |
34 | ) 35 | } 36 |
37 | ) 38 | } 39 | 40 | } 41 | 42 | const MessageWithSocket = props => ( 43 | 44 | {socket => } 45 | 46 | ) 47 | 48 | const mapStateToProps = state => { 49 | return { 50 | auth: state.auth 51 | } 52 | } 53 | 54 | const mapDispatchToProps = dispatch => { 55 | return { 56 | onStartDeleteMessage: (messageId, history, socket) => dispatch(actions.startDeleteMessage(messageId, history, socket)) 57 | } 58 | } 59 | 60 | export default withRouter(connect(mapStateToProps, mapDispatchToProps)(MessageWithSocket)) -------------------------------------------------------------------------------- /src/components/SidebarChannels.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import ChannelsList from './ChannelsList' 3 | import { connect } from 'react-redux' 4 | import * as actions from '../store/actions/index' 5 | import { withRouter } from 'react-router-dom' 6 | import SocketContext from '../socket-context' 7 | 8 | class SidebarChannels extends Component { 9 | 10 | state = { 11 | isAddingChannel: false 12 | } 13 | 14 | newChannelTextFieldRef = React.createRef(); 15 | 16 | toggleAddChannelForm = () => { 17 | this.setState(prevState => { 18 | return { 19 | isAddingChannel: !prevState.isAddingChannel 20 | } 21 | }) 22 | } 23 | 24 | addChannel = e => { 25 | e.preventDefault(); 26 | 27 | const channelName = this.newChannelTextFieldRef.current.value; 28 | 29 | this.props.onStartAddChannel(channelName, this.props.history, this.props.socket) 30 | .then(() => { 31 | // Hide the add new channel form 32 | this.setState({ 33 | isAddingChannel: false 34 | }) 35 | }) 36 | } 37 | 38 | componentDidUpdate() { 39 | if (this.state.isAddingChannel) { 40 | this.newChannelTextFieldRef.current.focus() 41 | } 42 | } 43 | 44 | render() { 45 | 46 | let addChannelForm = null 47 | 48 | if (this.state.isAddingChannel) { 49 | addChannelForm = ( 50 |
51 |
52 | 53 |
54 |
55 | 56 |
57 |
58 | ); 59 | } 60 | 61 | return ( 62 |
63 |
64 |

Channels

65 | 68 |
69 | 70 | {addChannelForm} 71 | 72 | 73 |
74 | ) 75 | } 76 | 77 | } 78 | 79 | const SidebarChannelsWithSocket = props => ( 80 | 81 | {socket => } 82 | 83 | ) 84 | 85 | const mapDispatchToProps = dispatch => { 86 | return { 87 | onStartAddChannel: (channel, history, socket) => dispatch(actions.startAddChannel(channel, history, socket)) 88 | } 89 | } 90 | 91 | export default withRouter(connect(null, mapDispatchToProps)(SidebarChannelsWithSocket)) -------------------------------------------------------------------------------- /src/components/SignIn.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import * as actions from '../store/actions/index' 3 | import { connect } from 'react-redux' 4 | import Formsy from 'formsy-react' 5 | import EmailField from '../UI/Form/EmailField' 6 | import PasswordField from '../UI/Form/PasswordField' 7 | 8 | class SignIn extends Component { 9 | 10 | mapInputs(inputs) { 11 | return { 12 | 'email': inputs.email, 13 | 'password': inputs.password 14 | } 15 | } 16 | 17 | signIn = model => { 18 | this.props.startSignIn(model.email, model.password, this.props.history) 19 | } 20 | 21 | render() { 22 | return ( 23 |
24 |

Sign In

25 | 26 |
27 | 28 | { 34 | if (!value) { 35 | return false 36 | } else { 37 | return true 38 | } 39 | } 40 | }} 41 | validationError="This is not a valid email" 42 | validationErrors={{ 43 | isEmail: 'You have to use a valid email address', 44 | isEmpty: 'Email can not be empty' 45 | }} 46 | /> 47 |
48 |
49 | 50 | { 55 | if (!value) { 56 | return false 57 | } else { 58 | return true 59 | } 60 | } 61 | }} 62 | validationError="This is not a valid password" 63 | validationErrors={{ 64 | isEmpty: 'Password can not be empty' 65 | }} 66 | /> 67 |
68 |
69 | 70 |
71 |
72 |
73 | ) 74 | } 75 | 76 | } 77 | 78 | const mapDispatchToProps = dispatch => { 79 | return { 80 | startSignIn: (email, password, history) => dispatch(actions.startSignin(email, password, history)) 81 | } 82 | } 83 | 84 | export default connect(null, mapDispatchToProps)(SignIn) -------------------------------------------------------------------------------- /src/components/SignUp.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import * as actions from '../store/actions/index' 3 | import { connect } from 'react-redux' 4 | import TextField from '../UI/Form/TextField' 5 | import EmailField from '../UI/Form/EmailField' 6 | import PasswordField from '../UI/Form/PasswordField' 7 | import Formsy from 'formsy-react' 8 | 9 | class SignUp extends Component { 10 | 11 | mapInputs(inputs) { 12 | return { 13 | 'firstName': inputs.firstName, 14 | 'lastName': inputs.lastName, 15 | 'email': inputs.email, 16 | 'password': inputs.password 17 | } 18 | } 19 | 20 | signUp = model => { 21 | 22 | this.props.startSignUp(model.email, model.password, model.firstName, model.lastName, this.props.history) 23 | } 24 | 25 | render() { 26 | 27 | return ( 28 |
29 |

Sign Up

30 | 31 |
32 | 33 | { 38 | if (!value) { 39 | return false 40 | } else if (value.length <= 0) { 41 | return false 42 | } else { 43 | return true 44 | } 45 | } 46 | }} 47 | validationErrors={{ 48 | isEmpty: 'First name can not be empty' 49 | }} 50 | /> 51 |
52 |
53 | 54 | { 59 | if (!value) { 60 | return false 61 | } else { 62 | return true 63 | } 64 | } 65 | }} 66 | validationErrors={{ 67 | isEmpty: 'Last name can not be empty' 68 | }} 69 | /> 70 |
71 |
72 | 73 | { 79 | if (!value) { 80 | return false 81 | } else { 82 | return true 83 | } 84 | } 85 | }} 86 | validationError="This is not a valid email" 87 | validationErrors={{ 88 | isEmail: 'You have to use a valid email address', 89 | isEmpty: 'Email can not be empty' 90 | }} 91 | /> 92 |
93 |
94 | 95 | { 100 | if (!value) { 101 | return false 102 | } else { 103 | return true 104 | } 105 | } 106 | }} 107 | validationError="This is not a valid password" 108 | validationErrors={{ 109 | isEmpty: 'Password can not be empty' 110 | }} 111 | /> 112 |
113 |
114 | 115 |
116 |
117 |
118 | ) 119 | } 120 | 121 | } 122 | 123 | const mapDispatchToProps = dispatch => { 124 | return { 125 | startSignUp: (email, password, firstName, lastName, history) => dispatch(actions.startSignup(email, password, firstName, lastName, history)) 126 | } 127 | } 128 | 129 | export default connect(null, mapDispatchToProps)(SignUp) -------------------------------------------------------------------------------- /src/hoc/withErrorHandler.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import { connect } from 'react-redux' 3 | import Modal from '../UI/Modal' 4 | import { compose } from 'redux' 5 | 6 | const withErrorHandler = WrappedComponent => { 7 | return class extends Component { 8 | render() { 9 | return ( 10 | 11 | 12 | 13 |
{this.props.error.message ? this.props.error.message : null}
14 |
15 |
16 | ) 17 | } 18 | } 19 | } 20 | 21 | const mapStateToProps = state => { 22 | return { 23 | error: state.error 24 | } 25 | } 26 | 27 | const composedHoc = compose( 28 | connect(mapStateToProps), 29 | withErrorHandler 30 | ) 31 | 32 | export default composedHoc -------------------------------------------------------------------------------- /src/hoc/withLoader.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import Backdrop from '../UI/Backdrop' 3 | import SpinnerRectangleBounce from '../UI/SpinnerRectangleBounce' 4 | import { connect } from 'react-redux' 5 | import { compose } from 'redux' 6 | 7 | const withLoader = WrappedComponent => { 8 | 9 | return class extends Component { 10 | 11 | render() { 12 | 13 | return ( 14 | 15 | 16 | 17 | 23 | 24 | ) 25 | 26 | } 27 | 28 | } 29 | 30 | } 31 | 32 | const mapStateToProps = state => { 33 | return { 34 | loader: state.loader 35 | } 36 | } 37 | 38 | const composedHoc = compose( 39 | connect(mapStateToProps), 40 | withLoader 41 | ) 42 | 43 | export default composedHoc -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom' 3 | import './index.css' 4 | import App from './App' 5 | import { Provider } from 'react-redux' 6 | import { BrowserRouter } from 'react-router-dom' 7 | import { createStore, applyMiddleware, compose, combineReducers } from 'redux' 8 | import thunk from 'redux-thunk' 9 | import authReducer from './store/reducers/auth' 10 | import channelReducer from './store/reducers/channel' 11 | import messageReducer from './store/reducers/message' 12 | import errorReducer from './store/reducers/error' 13 | import loaderReducer from './store/reducers/loader' 14 | import registerServiceWorker from './registerServiceWorker' 15 | 16 | const composeEnhancers = window.__REDUX_DEVTOOLS_EXTENSION_COMPOSE__ || compose 17 | 18 | const rootReducer = combineReducers({ 19 | auth: authReducer, 20 | channel: channelReducer, 21 | message: messageReducer, 22 | error: errorReducer, 23 | loader: loaderReducer 24 | }) 25 | 26 | const store = createStore(rootReducer, composeEnhancers( 27 | applyMiddleware(thunk) 28 | )) 29 | 30 | const app = ( 31 | 32 | 33 | 34 | 35 | 36 | ) 37 | 38 | ReactDOM.render(app, document.getElementById('root')) 39 | registerServiceWorker() 40 | -------------------------------------------------------------------------------- /src/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 16 | 17 | 18 | 19 | 20 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /src/registerServiceWorker.js: -------------------------------------------------------------------------------- 1 | // In production, we register a service worker to serve assets from local cache. 2 | 3 | // This lets the app load faster on subsequent visits in production, and gives 4 | // it offline capabilities. However, it also means that developers (and users) 5 | // will only see deployed updates on the "N+1" visit to a page, since previously 6 | // cached resources are updated in the background. 7 | 8 | // To learn more about the benefits of this model, read https://goo.gl/KwvDNy. 9 | // This link also includes instructions on opting out of this behavior. 10 | 11 | const isLocalhost = Boolean( 12 | window.location.hostname === 'localhost' || 13 | // [::1] is the IPv6 localhost address. 14 | window.location.hostname === '[::1]' || 15 | // 127.0.0.1/8 is considered localhost for IPv4. 16 | window.location.hostname.match( 17 | /^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/ 18 | ) 19 | ); 20 | 21 | export default function register() { 22 | if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) { 23 | // The URL constructor is available in all browsers that support SW. 24 | const publicUrl = new URL(process.env.PUBLIC_URL, window.location); 25 | if (publicUrl.origin !== window.location.origin) { 26 | // Our service worker won't work if PUBLIC_URL is on a different origin 27 | // from what our page is served on. This might happen if a CDN is used to 28 | // serve assets; see https://github.com/facebookincubator/create-react-app/issues/2374 29 | return; 30 | } 31 | 32 | window.addEventListener('load', () => { 33 | const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`; 34 | 35 | if (isLocalhost) { 36 | // This is running on localhost. Lets check if a service worker still exists or not. 37 | checkValidServiceWorker(swUrl); 38 | 39 | // Add some additional logging to localhost, pointing developers to the 40 | // service worker/PWA documentation. 41 | navigator.serviceWorker.ready.then(() => { 42 | console.log( 43 | 'This web app is being served cache-first by a service ' + 44 | 'worker. To learn more, visit https://goo.gl/SC7cgQ' 45 | ); 46 | }); 47 | } else { 48 | // Is not local host. Just register service worker 49 | registerValidSW(swUrl); 50 | } 51 | }); 52 | } 53 | } 54 | 55 | function registerValidSW(swUrl) { 56 | navigator.serviceWorker 57 | .register(swUrl) 58 | .then(registration => { 59 | registration.onupdatefound = () => { 60 | const installingWorker = registration.installing; 61 | installingWorker.onstatechange = () => { 62 | if (installingWorker.state === 'installed') { 63 | if (navigator.serviceWorker.controller) { 64 | // At this point, the old content will have been purged and 65 | // the fresh content will have been added to the cache. 66 | // It's the perfect time to display a "New content is 67 | // available; please refresh." message in your web app. 68 | console.log('New content is available; please refresh.'); 69 | } else { 70 | // At this point, everything has been precached. 71 | // It's the perfect time to display a 72 | // "Content is cached for offline use." message. 73 | console.log('Content is cached for offline use.'); 74 | } 75 | } 76 | }; 77 | }; 78 | }) 79 | .catch(error => { 80 | console.error('Error during service worker registration:', error); 81 | }); 82 | } 83 | 84 | function checkValidServiceWorker(swUrl) { 85 | // Check if the service worker can be found. If it can't reload the page. 86 | fetch(swUrl) 87 | .then(response => { 88 | // Ensure service worker exists, and that we really are getting a JS file. 89 | if ( 90 | response.status === 404 || 91 | response.headers.get('content-type').indexOf('javascript') === -1 92 | ) { 93 | // No service worker found. Probably a different app. Reload the page. 94 | navigator.serviceWorker.ready.then(registration => { 95 | registration.unregister().then(() => { 96 | window.location.reload(); 97 | }); 98 | }); 99 | } else { 100 | // Service worker found. Proceed as normal. 101 | registerValidSW(swUrl); 102 | } 103 | }) 104 | .catch(() => { 105 | console.log( 106 | 'No internet connection found. App is running in offline mode.' 107 | ); 108 | }); 109 | } 110 | 111 | export function unregister() { 112 | if ('serviceWorker' in navigator) { 113 | navigator.serviceWorker.ready.then(registration => { 114 | registration.unregister(); 115 | }); 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /src/socket-context.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | const SocketContext = React.createContext() 4 | 5 | export default SocketContext -------------------------------------------------------------------------------- /src/store/actions/actionTypes.js: -------------------------------------------------------------------------------- 1 | export const START_SIGNUP = 'START_SIGNUP' 2 | export const START_SIGNIN = 'START_SIGNIN' 3 | export const START_LOGOUT = 'START_LOGOUT' 4 | export const LOGOUT_SUCCESS = 'LOGOUT_SUCCESS' 5 | export const AUTH_SUCCESS = 'AUTH_SUCCESS' 6 | 7 | export const START_GET_CHANNELS = 'START_GET_CHANNELS' 8 | export const GET_CHANNELS_SUCCESS = 'GET_CHANNELS_SUCCESS' 9 | export const START_ADD_CHANNEL = 'START_ADD_CHANNEL' 10 | export const ADD_CHANNEL_SUCCESS = 'ADD_CHANNEL_SUCCESS' 11 | export const START_DELETE_CHANNEL = 'START_DELETE_CHANNEL' 12 | export const DELETE_CHANNEL_SUCCESS = 'DELETE_CHANNEL_SUCCESS' 13 | export const SET_SELECTED_CHANNEL = 'SET_SELECTED_CHANNEL' 14 | export const JOIN_CHANNEL_SUCCESS = 'JOIN_CHANNEL_SUCCESS' 15 | export const LEAVE_CHANNEL_SUCCESS = 'LEAVE_CHANNEL_SUCCESS' 16 | export const CLEAR_CHANNELS_AFTER_LOGOUT = 'CLEAR_CHANNELS_AFTER_LOGOUT' 17 | 18 | export const START_ADD_MESSAGE = 'START_ADD_MESSAGE' 19 | export const ADD_MESSAGE_SUCCESS = 'ADD_MESSAGE_SUCCESS' 20 | export const DELETE_MESSAGE_SUCCESS = 'DELETE_MESSAGE_SUCCESS' 21 | export const GET_CHANNEL_MESSAGES_SUCCESS = 'GET_CHANNEL_MESSAGES_SUCCESS' 22 | export const DELETE_CHANNEL_MESSAGES_SUCCESS = 'DELETE_CHANNEL_MESSAGES_SUCCESS' 23 | export const CLEAR_MESSAGES_AFTER_LOGOUT = 'CLEAR_MESSAGES_AFTER_LOGOUT' 24 | 25 | export const DISMISS_ERROR_SUCCESS = 'DISMISS_ERROR_SUCCESS' 26 | export const DISPLAY_ERROR = 'DISPLAY_ERROR' 27 | 28 | export const DISPLAY_LOADER = 'DISPLAY_LOADER' 29 | export const HIDE_LOADER = 'HIDE_LOADER' 30 | 31 | -------------------------------------------------------------------------------- /src/store/actions/auth.js: -------------------------------------------------------------------------------- 1 | import * as actionTypes from './actionTypes' 2 | import axios from 'axios' 3 | import * as actions from './index' 4 | 5 | export const startSignup = (email, password, firstName, lastName, history) => { 6 | return dispatch => { 7 | axios.post('/api/auth/signup', { 8 | email: email, 9 | password: password, 10 | firstName: firstName, 11 | lastName: lastName 12 | }) 13 | .then(response => { 14 | // Signup success 15 | 16 | // Save token to local storage 17 | localStorage.setItem('chat-board-react-token', response.data.data.token) 18 | 19 | // Update redux store 20 | dispatch(authSuccess(response.data.data.user._id, response.data.data.user.email, response.data.data.user.firstName, response.data.data.user.lastName, response.data.data.user.role)) 21 | 22 | // Redirect to messages page 23 | history.push('/messages') 24 | }) 25 | .catch(error => { 26 | if (error.response) { 27 | const errMsg = error.response.data.message ? error.response.data.message : error.response.data.data.title 28 | dispatch(actions.displayError(errMsg)) 29 | } else if (error.resquest) { 30 | dispatch(actions.displayError(error.request)) 31 | } else { 32 | dispatch(actions.displayError(error.message)) 33 | } 34 | }); 35 | } 36 | } 37 | 38 | export const startSignin = (email, password, history) => { 39 | return dispatch => { 40 | axios.post('/api/auth/signin', { 41 | email: email, 42 | password: password 43 | }) 44 | .then(response => { 45 | // Signin success 46 | 47 | // Save token to local storage 48 | localStorage.setItem('chat-board-react-token', response.data.data.token) 49 | 50 | // Update redux store 51 | dispatch(authSuccess(response.data.data.user._id, response.data.data.user.email, response.data.data.user.firstName, response.data.data.user.lastName, response.data.data.user.role)) 52 | 53 | // Redirect to messages page 54 | history.push('/messages') 55 | }) 56 | .catch(error => { 57 | if (error.response) { 58 | const errMsg = error.response.data.message ? error.response.data.message : error.response.data.data.title 59 | dispatch(actions.displayError(errMsg)) 60 | } else if (error.resquest) { 61 | dispatch(actions.displayError(error.request)) 62 | } else { 63 | dispatch(actions.displayError(error.message)) 64 | } 65 | }) 66 | } 67 | } 68 | 69 | export const startCheckAuth = () => { 70 | return dispatch => { 71 | axios.get('/api/auth/check', { 72 | headers: { 73 | Authorization: `Bearer ${localStorage.getItem('chat-board-react-token')}` 74 | } 75 | }) 76 | .then(response => { 77 | const user = response.data.data.user 78 | dispatch(authSuccess(user._id, user.email, user.firstName, user.lastName, user.role)) 79 | }) 80 | } 81 | } 82 | 83 | export const authSuccess = (userId, email, firstName, lastName, role) => { 84 | return { 85 | type: actionTypes.AUTH_SUCCESS, 86 | payload: { 87 | email: email, 88 | _id: userId, 89 | firstName: firstName, 90 | lastName: lastName, 91 | role: role 92 | } 93 | } 94 | } 95 | 96 | export const startLogout = (history) => { 97 | return dispatch => { 98 | localStorage.removeItem('chat-board-react-token') 99 | history.replace('/') 100 | 101 | dispatch(logoutSuccess()) 102 | dispatch(actions.clearChannelsAfterLogout()) 103 | dispatch(actions.clearMessagesAfterLogout()) 104 | } 105 | } 106 | 107 | export const logoutSuccess = () => { 108 | return { 109 | type: actionTypes.LOGOUT_SUCCESS 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /src/store/actions/channel.js: -------------------------------------------------------------------------------- 1 | import * as actionTypes from './actionTypes' 2 | import axios from 'axios' 3 | import * as actions from './index' 4 | 5 | export const startGetChannels = (history) => { 6 | return dispatch => { 7 | axios.get('/api/channels', { 8 | headers: { 9 | Authorization: `Bearer ${localStorage.getItem('chat-board-react-token')}` 10 | } 11 | }) 12 | .then(response => { 13 | dispatch(getChannelsSuccess(response.data.data.channels)) 14 | dispatch(actions.authSuccess(response.data.data.user._id, response.data.data.user.email, response.data.data.user.firstName, response.data.data.user.lastName, response.data.data.user.role)) 15 | }) 16 | .catch(error => { 17 | // Hide loader 18 | dispatch(actions.hideLoader()) 19 | 20 | if (error.response) { 21 | if (error.response.data.code === '401') { 22 | dispatch(actions.startLogout(history)) 23 | } else { 24 | const errMsg = error.response.data.message ? error.response.data.message : error.response.data.data.title 25 | dispatch(actions.displayError(errMsg)) 26 | } 27 | } else if (error.resquest) { 28 | dispatch(actions.displayError(error.request)) 29 | } else { 30 | dispatch(actions.displayError(error.message)) 31 | } 32 | }) 33 | } 34 | } 35 | 36 | export const getChannelsSuccess = (channels) => { 37 | return { 38 | type: actionTypes.GET_CHANNELS_SUCCESS, 39 | payload: { 40 | channels: channels 41 | } 42 | } 43 | } 44 | 45 | export const startAddChannel = (channelName, history, socket) => { 46 | return dispatch => { 47 | 48 | // Show loader 49 | dispatch(actions.displayLoader()) 50 | 51 | return axios.post('/api/channels', { 52 | name: channelName 53 | }, { 54 | headers: { 55 | Authorization: `Bearer ${localStorage.getItem('chat-board-react-token')}` 56 | } 57 | }) 58 | .then(response => { 59 | dispatch(addChannelSuccess(response.data.data.channel)); 60 | history.replace(`/messages/${response.data.data.channel._id}`) 61 | 62 | // Tell the server that a new channel was added via socket.io 63 | socket.emit('new-channel-added', response.data.data.channel) 64 | 65 | // Hide loader 66 | dispatch(actions.hideLoader()) 67 | }) 68 | .catch(error => { 69 | // Hide loader 70 | dispatch(actions.hideLoader()) 71 | 72 | if (error.response) { 73 | if (error.response.data.code === '401') { 74 | dispatch(actions.startLogout(history)) 75 | } else { 76 | const errMsg = error.response.data.message ? error.response.data.message : error.response.data.data.title 77 | dispatch(actions.displayError(errMsg)) 78 | } 79 | } else if (error.resquest) { 80 | dispatch(actions.displayError(error.request)) 81 | } else { 82 | dispatch(actions.displayError(error.message)) 83 | } 84 | }) 85 | } 86 | } 87 | 88 | export const addChannelSuccess = (channel) => { 89 | return { 90 | type: actionTypes.ADD_CHANNEL_SUCCESS, 91 | payload: { 92 | channel: channel 93 | } 94 | } 95 | } 96 | 97 | export const startDeleteChannel = (channelId, history, socket) => { 98 | return (dispatch, getState) => { 99 | // Display loader 100 | dispatch(actions.displayLoader()) 101 | 102 | axios.delete(`/api/channels/${channelId}`, { 103 | headers: { 104 | Authorization: `Bearer ${localStorage.getItem('chat-board-react-token')}` 105 | } 106 | }) 107 | .then(response => { 108 | if (getState().channel.selectedChannelId === channelId) { 109 | history.replace('/messages') 110 | dispatch(setSelectedChannel(null)) 111 | } 112 | dispatch(deleteChannelSuccess(channelId)) 113 | dispatch(deleteChannelMessagesSuccess(channelId)) 114 | 115 | // Tell the server that a channel was deleted via socket.io 116 | socket.emit('channel-deleted', channelId) 117 | 118 | // Hide loader 119 | dispatch(actions.hideLoader()) 120 | }) 121 | .catch(error => { 122 | // Hide loader 123 | dispatch(actions.hideLoader()) 124 | 125 | if (error.response) { 126 | if (error.response.data.code === '401') { 127 | dispatch(actions.startLogout(history)) 128 | } else { 129 | const errMsg = error.response.data.message ? error.response.data.message : error.response.data.data.title 130 | dispatch(actions.displayError(errMsg)) 131 | } 132 | } else if (error.resquest) { 133 | dispatch(actions.displayError(error.request)) 134 | } else { 135 | dispatch(actions.displayError(error.message)) 136 | } 137 | }) 138 | } 139 | } 140 | 141 | export const deleteChannelSuccess = channelId => { 142 | return { 143 | type: actionTypes.DELETE_CHANNEL_SUCCESS, 144 | payload: { 145 | channelId: channelId 146 | } 147 | } 148 | } 149 | 150 | export const deleteChannelMessagesSuccess = channelId => { 151 | return { 152 | type: actionTypes.DELETE_CHANNEL_MESSAGES_SUCCESS, 153 | payload: { 154 | channelId: channelId 155 | } 156 | } 157 | } 158 | 159 | export const receivedChannelDeletedBroadcast = (channelId, history) => { 160 | return (dispatch, getState) => { 161 | dispatch(deleteChannelSuccess(channelId)); 162 | dispatch(deleteChannelMessagesSuccess(channelId)); 163 | if (getState().channel.selectedChannelId === channelId) { 164 | history.replace('/messages') 165 | dispatch(setSelectedChannel(null)) 166 | } 167 | } 168 | } 169 | 170 | export const setSelectedChannel = channelId => { 171 | return { 172 | type: actionTypes.SET_SELECTED_CHANNEL, 173 | payload: { 174 | channelId: channelId 175 | } 176 | } 177 | } 178 | 179 | export const startJoinChannel = (channelId, history, socket) => { 180 | return (dispatch, getState) => { 181 | // Display loader 182 | dispatch(actions.displayLoader()); 183 | 184 | return axios.post(`/api/channels/${channelId}/members`, { 185 | newMemberUserId: getState().auth._id 186 | }, { 187 | headers: { 188 | Authorization: `Bearer ${localStorage.getItem('chat-board-react-token')}` 189 | } 190 | }) 191 | .then(response => { 192 | dispatch(joinChannelSuccess(channelId, getState().auth._id)); 193 | dispatch(actions.addMessageSuccess(response.data.data.newMessage)) 194 | 195 | // Tell the server that a user joined a channel via socket.io 196 | socket.emit('joined-channel', { 197 | channelId: channelId, 198 | userId: getState().auth._id 199 | }); 200 | 201 | // Tell the server that a new message was added via socket.io 202 | socket.emit('new-message-added', response.data.data.newMessage) 203 | 204 | // Redirect to the messages list of this channel 205 | history.push(`/messages/${channelId}`) 206 | 207 | // Hide loader 208 | dispatch(actions.hideLoader()) 209 | }) 210 | .catch(error => { 211 | // Hide loader 212 | dispatch(actions.hideLoader()) 213 | 214 | if (error.response) { 215 | if (error.response.data.code === '401') { 216 | dispatch(actions.startLogout(history)) 217 | } else { 218 | const errMsg = error.response.data.message ? error.response.data.message : error.response.data.data.title 219 | dispatch(actions.displayError(errMsg)) 220 | } 221 | } else if (error.resquest) { 222 | dispatch(actions.displayError(error.request)) 223 | } else { 224 | dispatch(actions.displayError(error.message)) 225 | } 226 | }) 227 | } 228 | } 229 | 230 | export const joinChannelSuccess = (channelId, userId) => { 231 | return { 232 | type: actionTypes.JOIN_CHANNEL_SUCCESS, 233 | payload: { 234 | channelId: channelId, 235 | userId: userId 236 | } 237 | } 238 | } 239 | 240 | export const receivedJoinedChannelBroadcast = (channelId, userId) => { 241 | return (dispatch, getState) => { 242 | if (userId === getState().auth._id) { 243 | dispatch(joinChannelSuccess(channelId, userId)) 244 | } 245 | } 246 | } 247 | 248 | export const startLeaveChannel = (channelId, history, socket) => { 249 | return (dispatch, getState) => { 250 | // Display loader 251 | dispatch(actions.displayLoader()) 252 | 253 | axios.delete(`/api/channels/${channelId}/members/${getState().auth._id}`,{ 254 | headers: { 255 | Authorization: `Bearer ${localStorage.getItem('chat-board-react-token')}` 256 | } 257 | }) 258 | .then(response => { 259 | history.replace('/messages') 260 | 261 | dispatch(setSelectedChannel(null)) 262 | 263 | dispatch(leaveChannelSuccess(channelId, getState().auth._id)) 264 | dispatch(deleteChannelMessagesSuccess(channelId)) 265 | 266 | // Tell the server that a user left a channel via socket.io 267 | socket.emit('left-channel', { 268 | channelId: channelId, 269 | userId: getState().auth._id 270 | }) 271 | 272 | // Tell the server that a new message was added via socket.io 273 | socket.emit('new-message-added', response.data.data.newMessage) 274 | 275 | // Hide loader 276 | dispatch(actions.hideLoader()) 277 | }) 278 | .catch(error => { 279 | // Hide loader 280 | dispatch(actions.hideLoader()) 281 | 282 | if (error.response) { 283 | if (error.response.data.code === '401') { 284 | dispatch(actions.startLogout(history)) 285 | } else { 286 | const errMsg = error.response.data.message ? error.response.data.message : error.response.data.data.title 287 | dispatch(actions.displayError(errMsg)) 288 | } 289 | } else if (error.resquest) { 290 | dispatch(actions.displayError(error.request)) 291 | } else { 292 | dispatch(actions.displayError(error.message)) 293 | } 294 | }) 295 | } 296 | } 297 | 298 | export const leaveChannelSuccess = (channelId, userId) => { 299 | return { 300 | type: actionTypes.LEAVE_CHANNEL_SUCCESS, 301 | payload: { 302 | channelId: channelId, 303 | userId: userId 304 | } 305 | } 306 | } 307 | 308 | export const receivedLeftChannelBroadcast = (channelId, userId, history) => { 309 | return (dispatch, getState) => { 310 | if (userId === getState().auth._id) { 311 | if (getState().channel.selectedChannelId === channelId) { 312 | history.replace('/messages') 313 | dispatch(setSelectedChannel(null)) 314 | } 315 | 316 | dispatch(leaveChannelSuccess(channelId, userId)) 317 | dispatch(deleteChannelMessagesSuccess(channelId)) 318 | } 319 | } 320 | } 321 | 322 | export const clearChannelsAfterLogout = () => { 323 | return { 324 | type: actionTypes.CLEAR_CHANNELS_AFTER_LOGOUT 325 | } 326 | } 327 | 328 | -------------------------------------------------------------------------------- /src/store/actions/error.js: -------------------------------------------------------------------------------- 1 | import * as actionTypes from './actionTypes' 2 | 3 | export const displayError = errMsg => { 4 | return { 5 | type: actionTypes.DISPLAY_ERROR, 6 | payload: { 7 | message: errMsg 8 | } 9 | } 10 | } 11 | 12 | export const dismissError = () => { 13 | return { 14 | type: actionTypes.DISMISS_ERROR_SUCCESS 15 | } 16 | } -------------------------------------------------------------------------------- /src/store/actions/index.js: -------------------------------------------------------------------------------- 1 | export { 2 | startSignin, 3 | startSignup, 4 | authSuccess, 5 | startLogout, 6 | startCheckAuth 7 | } from './auth' 8 | export { 9 | startGetChannels, 10 | startAddChannel, 11 | startDeleteChannel, 12 | channelSelected, 13 | setSelectedChannel, 14 | startJoinChannel, 15 | startLeaveChannel, 16 | clearChannelsAfterLogout, 17 | addChannelSuccess, 18 | receivedChannelDeletedBroadcast, 19 | receivedJoinedChannelBroadcast, 20 | receivedLeftChannelBroadcast 21 | } from './channel' 22 | export { 23 | startAddMessage, 24 | addMessageSuccess, 25 | startDeleteMessage, 26 | startGetChannelMessages, 27 | clearMessagesAfterLogout, 28 | receivedMessageAddedBroadcast, 29 | receivedMessageDeletedBroadcast 30 | } from './message' 31 | export { 32 | dismissError, 33 | displayError 34 | } from './error' 35 | export { 36 | displayLoader, 37 | hideLoader 38 | } from './loader' -------------------------------------------------------------------------------- /src/store/actions/loader.js: -------------------------------------------------------------------------------- 1 | import * as actionTypes from './actionTypes' 2 | 3 | export const displayLoader = () => { 4 | return { 5 | type: actionTypes.DISPLAY_LOADER 6 | } 7 | } 8 | 9 | export const hideLoader = () => { 10 | return { 11 | type: actionTypes.HIDE_LOADER 12 | } 13 | } -------------------------------------------------------------------------------- /src/store/actions/message.js: -------------------------------------------------------------------------------- 1 | import * as actionTypes from './actionTypes' 2 | import axios from 'axios' 3 | import * as actions from './index' 4 | 5 | export const startGetChannelMessages = (channelId, history) => { 6 | return (dispatch, getState) => { 7 | return axios.get(`/api/messages/channels/${channelId}`, { 8 | headers: { 9 | Authorization: `Bearer ${localStorage.getItem('chat-board-react-token')}` 10 | } 11 | }) 12 | .then(response => { 13 | dispatch(getChannelMessagesSuccess(channelId, response.data.data.messages)) 14 | }) 15 | .catch(error => { 16 | if (error.response) { 17 | if (error.response.data.code === '401') { 18 | dispatch(actions.startLogout(history)) 19 | } else { 20 | const errMsg = error.response.data.message ? error.response.data.message : error.response.data.data.title 21 | dispatch(actions.displayError(errMsg)) 22 | } 23 | } else if (error.resquest) { 24 | dispatch(actions.displayError(error.request)) 25 | } else { 26 | dispatch(actions.displayError(error.message)) 27 | } 28 | }) 29 | } 30 | } 31 | 32 | export const getChannelMessagesSuccess = (channelId, messages) => { 33 | return { 34 | type: actionTypes.GET_CHANNEL_MESSAGES_SUCCESS, 35 | payload: { 36 | channelId: channelId, 37 | messages: messages 38 | } 39 | } 40 | } 41 | 42 | export const startAddMessage = (messageBody, channelId, history, socket) => { 43 | return dispatch => { 44 | return axios.post(`/api/messages/channels/${channelId}`, { 45 | messageBody: messageBody 46 | }, { 47 | headers: { 48 | Authorization: `Bearer ${localStorage.getItem('chat-board-react-token')}` 49 | } 50 | }) 51 | .then(response => { 52 | dispatch(addMessageSuccess(response.data.data.message)) 53 | 54 | // Tell the server that a new message was added via socket.io 55 | socket.emit('new-message-added', response.data.data.message) 56 | }) 57 | .catch(error => { 58 | if (error.response) { 59 | if (error.response.data.code === '401') { 60 | dispatch(actions.startLogout(history)) 61 | } else { 62 | const errMsg = error.response.data.message ? error.response.data.message : error.response.data.data.title 63 | dispatch(actions.displayError(errMsg)) 64 | } 65 | } else if (error.resquest) { 66 | dispatch(actions.displayError(error.request)) 67 | } else { 68 | dispatch(actions.displayError(error.message)) 69 | } 70 | }) 71 | } 72 | } 73 | 74 | export const addMessageSuccess = message => { 75 | return { 76 | type: actionTypes.ADD_MESSAGE_SUCCESS, 77 | payload: { 78 | message: message 79 | } 80 | } 81 | } 82 | 83 | export const receivedMessageAddedBroadcast = message => { 84 | return (dispatch, getState) => { 85 | const channel = getState().channel.channels.find(c => { 86 | return c._id === message.channel 87 | }); 88 | 89 | if (channel !== undefined) { 90 | if (channel.members.indexOf(getState().auth._id) >= 0) { 91 | if (getState().channel.selectedChannelId === message.channel) { 92 | dispatch(addMessageSuccess(message)) 93 | } 94 | } 95 | } 96 | } 97 | } 98 | 99 | export const clearMessagesAfterLogout = () => { 100 | return { 101 | type: actionTypes.CLEAR_MESSAGES_AFTER_LOGOUT 102 | } 103 | } 104 | 105 | export const startDeleteMessage = (messageId, history, socket) => { 106 | return dispatch => { 107 | // Display loader 108 | dispatch(actions.displayLoader()) 109 | 110 | axios.delete(`/api/messages/${messageId}`, { 111 | headers: { 112 | Authorization: `Bearer ${localStorage.getItem('chat-board-react-token')}` 113 | } 114 | }) 115 | .then(response => { 116 | dispatch(deleteMessageSuccess(messageId)) 117 | 118 | // Tell the server that a message was deleted via socket.io 119 | socket.emit('message-deleted', messageId) 120 | 121 | // Hide loader 122 | dispatch(actions.hideLoader()) 123 | }) 124 | .catch(error => { 125 | // Hide loader 126 | dispatch(actions.hideLoader()) 127 | 128 | if (error.response) { 129 | if (error.response.data.code === '401') { 130 | dispatch(actions.startLogout(history)) 131 | } else { 132 | const errMsg = error.response.data.message ? error.response.data.message : error.response.data.data.title 133 | dispatch(actions.displayError(errMsg)) 134 | } 135 | } else if (error.resquest) { 136 | dispatch(actions.displayError(error.request)) 137 | } else { 138 | dispatch(actions.displayError(error.message)) 139 | } 140 | }) 141 | } 142 | } 143 | 144 | export const deleteMessageSuccess = messageId => { 145 | return { 146 | type: actionTypes.DELETE_MESSAGE_SUCCESS, 147 | payload: { 148 | messageId: messageId 149 | } 150 | } 151 | } 152 | 153 | export const receivedMessageDeletedBroadcast = messageId => { 154 | return dispatch => { 155 | dispatch(deleteMessageSuccess(messageId)) 156 | } 157 | } 158 | -------------------------------------------------------------------------------- /src/store/reducers/auth.js: -------------------------------------------------------------------------------- 1 | import * as actionTypes from '../actions/actionTypes' 2 | 3 | const initialState = { 4 | _id: null, 5 | firstName: '', 6 | lastName: '', 7 | email: '', 8 | role: '' 9 | }; 10 | 11 | const reducer = (state = initialState, action) => { 12 | switch (action.type) { 13 | case actionTypes.AUTH_SUCCESS: { 14 | return { 15 | ...state, 16 | _id: action.payload._id, 17 | firstName: action.payload.firstName, 18 | lastName: action.payload.lastName, 19 | email: action.payload.email, 20 | role: action.payload.role 21 | } 22 | } 23 | case actionTypes.LOGOUT_SUCCESS: { 24 | return { 25 | ...state, 26 | _id: null, 27 | firstName: '', 28 | lastName: '', 29 | email: '', 30 | role: '' 31 | } 32 | } 33 | default: 34 | return state; 35 | } 36 | } 37 | 38 | export default reducer 39 | -------------------------------------------------------------------------------- /src/store/reducers/channel.js: -------------------------------------------------------------------------------- 1 | import * as actionTypes from '../actions/actionTypes' 2 | 3 | const initialState = { 4 | channels: null, 5 | selectedChannelId: null 6 | } 7 | 8 | const reducer = (state = initialState, action) => { 9 | switch(action.type) { 10 | case actionTypes.GET_CHANNELS_SUCCESS: { 11 | return { 12 | ...state, 13 | channels: action.payload.channels 14 | } 15 | } 16 | case actionTypes.ADD_CHANNEL_SUCCESS: { 17 | const clonedChannels = state.channels.map(channel => { 18 | return { 19 | ...channel, 20 | members: [...channel.members] 21 | } 22 | }) 23 | const newChannel = { 24 | _id: action.payload.channel._id, 25 | name: action.payload.channel.name, 26 | createdBy: action.payload.channel.createdBy, 27 | createdAt: action.payload.channel.createdAt, 28 | members: action.payload.channel.members 29 | } 30 | return { 31 | ...state, 32 | channels: clonedChannels.concat(newChannel) 33 | } 34 | } 35 | case actionTypes.DELETE_CHANNEL_SUCCESS: { 36 | const clonedChannels = state.channels.map(channel => { 37 | return { 38 | ...channel, 39 | members: [...channel.members] 40 | } 41 | }) 42 | return { 43 | ...state, 44 | channels: clonedChannels.filter(channel => { 45 | return channel._id !== action.payload.channelId 46 | }) 47 | } 48 | } 49 | case actionTypes.SET_SELECTED_CHANNEL: { 50 | let clonedChannels = null 51 | if (state.channels !== null) { 52 | clonedChannels = state.channels.map(channel => { 53 | return { 54 | ...channel, 55 | members: [...channel.members] 56 | } 57 | }) 58 | } 59 | 60 | return { 61 | channels: clonedChannels, 62 | selectedChannelId: action.payload.channelId 63 | } 64 | } 65 | case actionTypes.JOIN_CHANNEL_SUCCESS: { 66 | const clonedChannels = state.channels.map(channel => { 67 | return { 68 | ...channel, 69 | members: channel._id === action.payload.channelId ? [...channel.members, action.payload.userId] : [...channel.members] 70 | } 71 | }) 72 | return { 73 | ...state, 74 | channels: clonedChannels 75 | } 76 | } 77 | case actionTypes.LEAVE_CHANNEL_SUCCESS: { 78 | const clonedChannels = state.channels.map(channel => { 79 | return { 80 | ...channel, 81 | members: channel._id !== action.payload.channelId ? [...channel.members] : channel.members.filter(member => { 82 | return member !== action.payload.userId 83 | }) 84 | }; 85 | }); 86 | return { 87 | ...state, 88 | channels: clonedChannels 89 | } 90 | } 91 | case actionTypes.CLEAR_CHANNELS_AFTER_LOGOUT: { 92 | return { 93 | channels: null, 94 | selectedChannelId: null 95 | } 96 | } 97 | default: 98 | return state 99 | } 100 | } 101 | 102 | export default reducer -------------------------------------------------------------------------------- /src/store/reducers/error.js: -------------------------------------------------------------------------------- 1 | import * as actionTypes from '../actions/actionTypes' 2 | 3 | const initialState = { 4 | message: null 5 | }; 6 | 7 | const reducer = (state = initialState, action) => { 8 | switch (action.type) { 9 | case actionTypes.DISPLAY_ERROR: { 10 | return { 11 | message: action.payload.message 12 | } 13 | } 14 | case actionTypes.DISMISS_ERROR_SUCCESS: { 15 | return { 16 | message: null 17 | } 18 | } 19 | default: 20 | return state 21 | } 22 | } 23 | 24 | export default reducer -------------------------------------------------------------------------------- /src/store/reducers/loader.js: -------------------------------------------------------------------------------- 1 | import * as actionTypes from '../actions/actionTypes' 2 | 3 | const initialState = { 4 | show: false 5 | } 6 | 7 | const reducer = (state = initialState, action) => { 8 | switch (action.type) { 9 | case actionTypes.DISPLAY_LOADER: { 10 | return { 11 | show: true 12 | } 13 | } 14 | case actionTypes.HIDE_LOADER: { 15 | return { 16 | show: false 17 | } 18 | } 19 | default: 20 | return state 21 | } 22 | } 23 | 24 | export default reducer -------------------------------------------------------------------------------- /src/store/reducers/message.js: -------------------------------------------------------------------------------- 1 | import * as actionTypes from '../actions/actionTypes' 2 | 3 | const initialState = { 4 | messages: {} 5 | } 6 | 7 | const reducer = (state = initialState, action) => { 8 | switch(action.type) { 9 | case actionTypes.GET_CHANNEL_MESSAGES_SUCCESS: { 10 | const clonedMessages = {}; 11 | for (let key in state.messages) { 12 | const clonedMessagesInAChannel = state['messages'][key].map(message => { 13 | return { 14 | ...message, 15 | createdBy: {...message.createdBy} 16 | } 17 | }) 18 | 19 | clonedMessages[key] = clonedMessagesInAChannel 20 | } 21 | 22 | return { 23 | messages: { 24 | ...clonedMessages, 25 | [action.payload.channelId]: action.payload.messages 26 | } 27 | } 28 | } 29 | case actionTypes.ADD_MESSAGE_SUCCESS: { 30 | const clonedMessages = {} 31 | for (let key in state.messages) { 32 | const clonedMessagesInAChannel = state['messages'][key].map(message => { 33 | return { 34 | ...message, 35 | createdBy: {...message.createdBy} 36 | } 37 | }) 38 | 39 | clonedMessages[key] = clonedMessagesInAChannel; 40 | 41 | if (key === action.payload.message.channel) { 42 | clonedMessages[key].push(action.payload.message); 43 | } 44 | } 45 | 46 | return { 47 | messages: clonedMessages 48 | } 49 | } 50 | case actionTypes.DELETE_CHANNEL_MESSAGES_SUCCESS: { 51 | const clonedMessages = {}; 52 | for (let key in state.messages) { 53 | const clonedMessagesInAChannel = state['messages'][key].map(message => { 54 | return { 55 | ...message, 56 | createdBy: {...message.createdBy} 57 | } 58 | }) 59 | 60 | clonedMessages[key] = clonedMessagesInAChannel; 61 | } 62 | 63 | delete clonedMessages[action.payload.channelId]; 64 | 65 | return { 66 | messages: clonedMessages 67 | } 68 | } 69 | case actionTypes.CLEAR_MESSAGES_AFTER_LOGOUT: { 70 | return { 71 | messages: {} 72 | } 73 | } 74 | case actionTypes.DELETE_MESSAGE_SUCCESS: { 75 | const clonedMessages = {}; 76 | for (let key in state.messages) { 77 | const clonedMessagesInAChannel = state['messages'][key].map(message => { 78 | return { 79 | ...message, 80 | createdBy: {...message.createdBy} 81 | } 82 | }) 83 | 84 | const filterdClonedMessagesInAChannel = clonedMessagesInAChannel.filter(message => { 85 | return message._id.toString() !== action.payload.messageId 86 | }) 87 | 88 | clonedMessages[key] = filterdClonedMessagesInAChannel 89 | } 90 | 91 | return { 92 | messages: clonedMessages 93 | } 94 | } 95 | default: 96 | return state 97 | } 98 | } 99 | 100 | export default reducer -------------------------------------------------------------------------------- /src/styles/components/channel.css: -------------------------------------------------------------------------------- 1 | .active-channel-item { 2 | background-color: config('colors.green-light'); 3 | 4 | &:hover { 5 | background-color: config('colors.green'); 6 | } 7 | } -------------------------------------------------------------------------------- /src/styles/components/modal.css: -------------------------------------------------------------------------------- 1 | .backdrop { 2 | background-color: rgba(255,255,255,0.95); 3 | } 4 | 5 | .modal { 6 | width: 70%; 7 | left: 15%; 8 | top: 30%; 9 | transition: all 0.3s ease-out; 10 | padding: 20px; 11 | } 12 | 13 | .modal-close-btn { 14 | font-size: 2rem; 15 | width: 40px; 16 | height: 40px; 17 | border-radius: 20px; 18 | top: -20px; 19 | right: -20px; 20 | line-height: 35px; 21 | } 22 | 23 | @media only screen and (min-width: 600px) { 24 | .modal { 25 | width: 500px; 26 | left: calc(50% - 250px); 27 | } 28 | } -------------------------------------------------------------------------------- /src/styles/components/shared.css: -------------------------------------------------------------------------------- 1 | body { 2 | font-family: 'Lato', sans-serif; 3 | } -------------------------------------------------------------------------------- /src/styles/components/spinner-rectangle-bounce.css: -------------------------------------------------------------------------------- 1 | .spinner-rectangle-bounce { 2 | text-align: center; 3 | } 4 | 5 | .spinner-rectangle-bounce > div { 6 | height: 100%; 7 | display: inline-block; 8 | 9 | -webkit-animation: sk-stretchdelay 1.2s infinite ease-in-out; 10 | animation: sk-stretchdelay 1.2s infinite ease-in-out; 11 | } 12 | 13 | .spinner-rectangle-bounce .rect2 { 14 | -webkit-animation-delay: -1.1s; 15 | animation-delay: -1.1s; 16 | } 17 | 18 | .spinner-rectangle-bounce .rect3 { 19 | -webkit-animation-delay: -1.0s; 20 | animation-delay: -1.0s; 21 | } 22 | 23 | .spinner-rectangle-bounce .rect4 { 24 | -webkit-animation-delay: -0.9s; 25 | animation-delay: -0.9s; 26 | } 27 | 28 | .spinner-rectangle-bounce .rect5 { 29 | -webkit-animation-delay: -0.8s; 30 | animation-delay: -0.8s; 31 | } 32 | 33 | @-webkit-keyframes sk-stretchdelay { 34 | 0%, 40%, 100% { -webkit-transform: scaleY(0.4) } 35 | 20% { -webkit-transform: scaleY(1.0) } 36 | } 37 | 38 | @keyframes sk-stretchdelay { 39 | 0%, 40%, 100% { 40 | transform: scaleY(0.4); 41 | -webkit-transform: scaleY(0.4); 42 | } 20% { 43 | transform: scaleY(1.0); 44 | -webkit-transform: scaleY(1.0); 45 | } 46 | } 47 | 48 | /* White Spinner */ 49 | .spinner-rectangle-bounce.spinner-white > div { 50 | background-color: #fff; 51 | } 52 | 53 | /* Grey Spinner */ 54 | .spinner-rectangle-bounce.spinner-grey > div { 55 | background-color: #b8c2cc; 56 | } 57 | 58 | /* Small Spinner */ 59 | .spinner-small { 60 | width: 50px; 61 | height: 40px; 62 | } 63 | 64 | .spinner-rectangle-bounce.spinner-small > div { 65 | width: 4px; 66 | margin: 0 1px; 67 | } 68 | 69 | /* Normal Spinner */ 70 | .spinner-normal { 71 | width: 50px; 72 | height: 50px; 73 | } 74 | 75 | .spinner-rectangle-bounce.spinner-normal > div { 76 | width: 6px; 77 | margin: 0 2px; 78 | } 79 | 80 | -------------------------------------------------------------------------------- /src/styles/index.css: -------------------------------------------------------------------------------- 1 | /** Google Fonts */ 2 | @import url(https://fonts.googleapis.com/css?family=Lato:400,700); 3 | 4 | /** Font Awesome */ 5 | @import "../node_modules/font-awesome/css/font-awesome.min.css"; 6 | 7 | /** 8 | * This injects Tailwind's base styles, which is a combination of 9 | * Normalize.css and some additional base styles. 10 | * 11 | * You can see the styles here: 12 | * https://github.com/tailwindcss/tailwindcss/blob/master/css/preflight.css 13 | * 14 | * If using `postcss-import`, you should import this line from it's own file: 15 | * 16 | * @import "./tailwind-preflight.css"; 17 | * 18 | * See: https://github.com/tailwindcss/tailwindcss/issues/53#issuecomment-341413622 19 | */ 20 | @tailwind preflight; 21 | 22 | /** 23 | * Here you would add any of your custom component classes; stuff that you'd 24 | * want loaded *before* the utilities so that the utilities could still 25 | * override them. 26 | * 27 | * Example: 28 | * 29 | * .btn { ... } 30 | * .form-input { ... } 31 | * 32 | * Or if using a preprocessor or `postcss-import`: 33 | * 34 | * @import "components/buttons"; 35 | * @import "components/forms"; 36 | */ 37 | 38 | .btn { 39 | @apply .rounded .px-4 .py-2 .text-white; 40 | } 41 | .btn-green { 42 | @apply .bg-green; 43 | } 44 | 45 | .form-validation-err { 46 | @apply .text-xs .text-red .mt-1; 47 | } 48 | 49 | @import "./styles/components/shared.css"; 50 | @import "./styles/components/modal.css"; 51 | @import "./styles/components/channel.css"; 52 | @import "./styles/components/spinner-rectangle-bounce.css"; 53 | 54 | /** 55 | * This injects all of Tailwind's utility classes, generated based on your 56 | * config file. 57 | * 58 | * If using `postcss-import`, you should import this line from it's own file: 59 | * 60 | * @import "./tailwind-utilities.css"; 61 | * 62 | * See: https://github.com/tailwindcss/tailwindcss/issues/53#issuecomment-341413622 63 | */ 64 | @tailwind utilities; 65 | 66 | /** 67 | * Here you would add any custom utilities you need that don't come out of the 68 | * box with Tailwind. 69 | * 70 | * Example : 71 | * 72 | * .bg-pattern-graph-paper { ... } 73 | * .skew-45 { ... } 74 | * 75 | * Or if using a preprocessor or `postcss-import`: 76 | * 77 | * @import "utilities/background-patterns"; 78 | * @import "utilities/skew-transforms"; 79 | */ 80 | 81 | @responsive { 82 | .border-box { 83 | box-sizing: border-box; 84 | } 85 | } 86 | 87 | -------------------------------------------------------------------------------- /src/utilities.js: -------------------------------------------------------------------------------- 1 | import Moment from 'moment' 2 | 3 | export const isLoggedIn = () => { 4 | return !!localStorage.getItem('chat-board-react-token') 5 | } 6 | 7 | export const dateObjToFormattedStr = (dateObj) => { 8 | return Moment(dateObj).format('DD/MM/YYYY hh:mm:ss') 9 | } -------------------------------------------------------------------------------- /tailwind.config.js: -------------------------------------------------------------------------------- 1 | /* 2 | 3 | Tailwind - The Utility-First CSS Framework 4 | 5 | A project by Adam Wathan (@adamwathan), Jonathan Reinink (@reinink), 6 | David Hemphill (@davidhemphill) and Steve Schoger (@steveschoger). 7 | 8 | Welcome to the Tailwind config file. This is where you can customize 9 | Tailwind specifically for your project. Don't be intimidated by the 10 | length of this file. It's really just a big JavaScript object and 11 | we've done our very best to explain each section. 12 | 13 | View the full documentation at https://tailwindcss.com. 14 | 15 | 16 | |------------------------------------------------------------------------------- 17 | | The default config 18 | |------------------------------------------------------------------------------- 19 | | 20 | | This variable contains the default Tailwind config. You don't have 21 | | to use it, but it can sometimes be helpful to have available. For 22 | | example, you may choose to merge your custom configuration 23 | | values with some of the Tailwind defaults. 24 | | 25 | */ 26 | 27 | // let defaultConfig = require('tailwindcss/defaultConfig')() 28 | 29 | 30 | /* 31 | |------------------------------------------------------------------------------- 32 | | Colors https://tailwindcss.com/docs/colors 33 | |------------------------------------------------------------------------------- 34 | | 35 | | Here you can specify the colors used in your project. To get you started, 36 | | we've provided a generous palette of great looking colors that are perfect 37 | | for prototyping, but don't hesitate to change them for your project. You 38 | | own these colors, nothing will break if you change everything about them. 39 | | 40 | | We've used literal color names ("red", "blue", etc.) for the default 41 | | palette, but if you'd rather use functional names like "primary" and 42 | | "secondary", or even a numeric scale like "100" and "200", go for it. 43 | | 44 | */ 45 | 46 | let colors = { 47 | 'transparent': 'transparent', 48 | 49 | 'black': '#22292f', 50 | 'grey-darkest': '#3d4852', 51 | 'grey-darker': '#606f7b', 52 | 'grey-dark': '#8795a1', 53 | 'grey': '#b8c2cc', 54 | 'grey-light': '#dae1e7', 55 | 'grey-lighter': '#f1f5f8', 56 | 'grey-lightest': '#f8fafc', 57 | 'white': '#ffffff', 58 | 59 | 'red-darkest': '#3b0d0c', 60 | 'red-darker': '#621b18', 61 | 'red-dark': '#cc1f1a', 62 | 'red': '#e3342f', 63 | 'red-light': '#ef5753', 64 | 'red-lighter': '#f9acaa', 65 | 'red-lightest': '#fcebea', 66 | 67 | 'orange-darkest': '#462a16', 68 | 'orange-darker': '#613b1f', 69 | 'orange-dark': '#de751f', 70 | 'orange': '#f6993f', 71 | 'orange-light': '#faad63', 72 | 'orange-lighter': '#fcd9b6', 73 | 'orange-lightest': '#fff5eb', 74 | 75 | 'yellow-darkest': '#453411', 76 | 'yellow-darker': '#684f1d', 77 | 'yellow-dark': '#f2d024', 78 | 'yellow': '#ffed4a', 79 | 'yellow-light': '#fff382', 80 | 'yellow-lighter': '#fff9c2', 81 | 'yellow-lightest': '#fcfbeb', 82 | 83 | 'green-darkest': '#0f2f21', 84 | 'green-darker': '#1a4731', 85 | 'green-dark': '#1f9d55', 86 | 'green': '#38c172', 87 | 'green-light': '#51d88a', 88 | 'green-lighter': '#a2f5bf', 89 | 'green-lightest': '#e3fcec', 90 | 91 | 'teal-darkest': '#0d3331', 92 | 'teal-darker': '#20504f', 93 | 'teal-dark': '#38a89d', 94 | 'teal': '#4dc0b5', 95 | 'teal-light': '#64d5ca', 96 | 'teal-lighter': '#a0f0ed', 97 | 'teal-lightest': '#e8fffe', 98 | 99 | 'blue-darkest': '#12283a', 100 | 'blue-darker': '#1c3d5a', 101 | 'blue-dark': '#2779bd', 102 | 'blue': '#3490dc', 103 | 'blue-light': '#6cb2eb', 104 | 'blue-lighter': '#bcdefa', 105 | 'blue-lightest': '#eff8ff', 106 | 107 | 'indigo-darkest': '#191e38', 108 | 'indigo-darker': '#2f365f', 109 | 'indigo-dark': '#5661b3', 110 | 'indigo': '#6574cd', 111 | 'indigo-light': '#7886d7', 112 | 'indigo-lighter': '#b2b7ff', 113 | 'indigo-lightest': '#e6e8ff', 114 | 115 | 'purple-darkest': '#21183c', 116 | 'purple-darker': '#382b5f', 117 | 'purple-dark': '#794acf', 118 | 'purple': '#9561e2', 119 | 'purple-light': '#a779e9', 120 | 'purple-lighter': '#d6bbfc', 121 | 'purple-lightest': '#f3ebff', 122 | 123 | 'pink-darkest': '#451225', 124 | 'pink-darker': '#6f213f', 125 | 'pink-dark': '#eb5286', 126 | 'pink': '#f66d9b', 127 | 'pink-light': '#fa7ea8', 128 | 'pink-lighter': '#ffbbca', 129 | 'pink-lightest': '#ffebef', 130 | } 131 | 132 | module.exports = { 133 | 134 | /* 135 | |----------------------------------------------------------------------------- 136 | | Colors https://tailwindcss.com/docs/colors 137 | |----------------------------------------------------------------------------- 138 | | 139 | | The color palette defined above is also assigned to the "colors" key of 140 | | your Tailwind config. This makes it easy to access them in your CSS 141 | | using Tailwind's config helper. For example: 142 | | 143 | | .error { color: config('colors.red') } 144 | | 145 | */ 146 | 147 | colors: colors, 148 | 149 | 150 | /* 151 | |----------------------------------------------------------------------------- 152 | | Screens https://tailwindcss.com/docs/responsive-design 153 | |----------------------------------------------------------------------------- 154 | | 155 | | Screens in Tailwind are translated to CSS media queries. They define the 156 | | responsive breakpoints for your project. By default Tailwind takes a 157 | | "mobile first" approach, where each screen size represents a minimum 158 | | viewport width. Feel free to have as few or as many screens as you 159 | | want, naming them in whatever way you'd prefer for your project. 160 | | 161 | | Tailwind also allows for more complex screen definitions, which can be 162 | | useful in certain situations. Be sure to see the full responsive 163 | | documentation for a complete list of options. 164 | | 165 | | Class name: .{screen}:{utility} 166 | | 167 | */ 168 | 169 | screens: { 170 | 'sm': '576px', 171 | 'md': '768px', 172 | 'lg': '992px', 173 | 'xl': '1200px', 174 | }, 175 | 176 | 177 | /* 178 | |----------------------------------------------------------------------------- 179 | | Fonts https://tailwindcss.com/docs/fonts 180 | |----------------------------------------------------------------------------- 181 | | 182 | | Here is where you define your project's font stack, or font families. 183 | | Keep in mind that Tailwind doesn't actually load any fonts for you. 184 | | If you're using custom fonts you'll need to import them prior to 185 | | defining them here. 186 | | 187 | | By default we provide a native font stack that works remarkably well on 188 | | any device or OS you're using, since it just uses the default fonts 189 | | provided by the platform. 190 | | 191 | | Class name: .font-{name} 192 | | 193 | */ 194 | 195 | fonts: { 196 | 'sans': [ 197 | 'system-ui', 198 | 'BlinkMacSystemFont', 199 | '-apple-system', 200 | 'Segoe UI', 201 | 'Roboto', 202 | 'Oxygen', 203 | 'Ubuntu', 204 | 'Cantarell', 205 | 'Fira Sans', 206 | 'Droid Sans', 207 | 'Helvetica Neue', 208 | 'sans-serif', 209 | ], 210 | 'serif': [ 211 | 'Constantia', 212 | 'Lucida Bright', 213 | 'Lucidabright', 214 | 'Lucida Serif', 215 | 'Lucida', 216 | 'DejaVu Serif', 217 | 'Bitstream Vera Serif', 218 | 'Liberation Serif', 219 | 'Georgia', 220 | 'serif', 221 | ], 222 | 'mono': [ 223 | 'Menlo', 224 | 'Monaco', 225 | 'Consolas', 226 | 'Liberation Mono', 227 | 'Courier New', 228 | 'monospace', 229 | ] 230 | }, 231 | 232 | 233 | /* 234 | |----------------------------------------------------------------------------- 235 | | Text sizes https://tailwindcss.com/docs/text-sizing 236 | |----------------------------------------------------------------------------- 237 | | 238 | | Here is where you define your text sizes. Name these in whatever way 239 | | makes the most sense to you. We use size names by default, but 240 | | you're welcome to use a numeric scale or even something else 241 | | entirely. 242 | | 243 | | By default Tailwind uses the "rem" unit type for most measurements. 244 | | This allows you to set a root font size which all other sizes are 245 | | then based on. That said, you are free to use whatever units you 246 | | prefer, be it rems, ems, pixels or other. 247 | | 248 | | Class name: .text-{size} 249 | | 250 | */ 251 | 252 | textSizes: { 253 | 'xxs': '.625rem', // 10px 254 | 'xs': '.75rem', // 12px 255 | 'sm': '.875rem', // 14px 256 | 'base': '1rem', // 16px 257 | 'lg': '1.125rem', // 18px 258 | 'xl': '1.25rem', // 20px 259 | '2xl': '1.5rem', // 24px 260 | '3xl': '1.875rem', // 30px 261 | '4xl': '2.25rem', // 36px 262 | '5xl': '3rem', // 48px 263 | }, 264 | 265 | 266 | /* 267 | |----------------------------------------------------------------------------- 268 | | Font weights https://tailwindcss.com/docs/font-weight 269 | |----------------------------------------------------------------------------- 270 | | 271 | | Here is where you define your font weights. We've provided a list of 272 | | common font weight names with their respective numeric scale values 273 | | to get you started. It's unlikely that your project will require 274 | | all of these, so we recommend removing those you don't need. 275 | | 276 | | Class name: .font-{weight} 277 | | 278 | */ 279 | 280 | fontWeights: { 281 | 'hairline': 100, 282 | 'thin': 200, 283 | 'light': 300, 284 | 'normal': 400, 285 | 'medium': 500, 286 | 'semibold': 600, 287 | 'bold': 700, 288 | 'extrabold': 800, 289 | 'black': 900, 290 | }, 291 | 292 | 293 | /* 294 | |----------------------------------------------------------------------------- 295 | | Leading (line height) https://tailwindcss.com/docs/line-height 296 | |----------------------------------------------------------------------------- 297 | | 298 | | Here is where you define your line height values, or as we call 299 | | them in Tailwind, leadings. 300 | | 301 | | Class name: .leading-{size} 302 | | 303 | */ 304 | 305 | leading: { 306 | 'none': 1, 307 | 'tight': 1.25, 308 | 'normal': 1.5, 309 | 'loose': 2, 310 | }, 311 | 312 | 313 | /* 314 | |----------------------------------------------------------------------------- 315 | | Tracking (letter spacing) https://tailwindcss.com/docs/letter-spacing 316 | |----------------------------------------------------------------------------- 317 | | 318 | | Here is where you define your letter spacing values, or as we call 319 | | them in Tailwind, tracking. 320 | | 321 | | Class name: .tracking-{size} 322 | | 323 | */ 324 | 325 | tracking: { 326 | 'tight': '-0.05em', 327 | 'normal': '0', 328 | 'wide': '0.05em', 329 | }, 330 | 331 | 332 | /* 333 | |----------------------------------------------------------------------------- 334 | | Text colors https://tailwindcss.com/docs/text-color 335 | |----------------------------------------------------------------------------- 336 | | 337 | | Here is where you define your text colors. By default these use the 338 | | color palette we defined above, however you're welcome to set these 339 | | independently if that makes sense for your project. 340 | | 341 | | Class name: .text-{color} 342 | | 343 | */ 344 | 345 | textColors: colors, 346 | 347 | 348 | /* 349 | |----------------------------------------------------------------------------- 350 | | Background colors https://tailwindcss.com/docs/background-color 351 | |----------------------------------------------------------------------------- 352 | | 353 | | Here is where you define your background colors. By default these use 354 | | the color palette we defined above, however you're welcome to set 355 | | these independently if that makes sense for your project. 356 | | 357 | | Class name: .bg-{color} 358 | | 359 | */ 360 | 361 | backgroundColors: colors, 362 | 363 | 364 | /* 365 | |----------------------------------------------------------------------------- 366 | | Background sizes https://tailwindcss.com/docs/background-size 367 | |----------------------------------------------------------------------------- 368 | | 369 | | Here is where you define your background sizes. We provide some common 370 | | values that are useful in most projects, but feel free to add other sizes 371 | | that are specific to your project here as well. 372 | | 373 | | Class name: .bg-{size} 374 | | 375 | */ 376 | 377 | backgroundSize: { 378 | 'auto': 'auto', 379 | 'cover': 'cover', 380 | 'contain': 'contain', 381 | }, 382 | 383 | 384 | /* 385 | |----------------------------------------------------------------------------- 386 | | Border widths https://tailwindcss.com/docs/border-width 387 | |----------------------------------------------------------------------------- 388 | | 389 | | Here is where you define your border widths. Take note that border 390 | | widths require a special "default" value set as well. This is the 391 | | width that will be used when you do not specify a border width. 392 | | 393 | | Class name: .border{-side?}{-width?} 394 | | 395 | */ 396 | 397 | borderWidths: { 398 | default: '1px', 399 | '0': '0', 400 | '2': '2px', 401 | '4': '4px', 402 | '8': '8px', 403 | }, 404 | 405 | 406 | /* 407 | |----------------------------------------------------------------------------- 408 | | Border colors https://tailwindcss.com/docs/border-color 409 | |----------------------------------------------------------------------------- 410 | | 411 | | Here is where you define your border colors. By default these use the 412 | | color palette we defined above, however you're welcome to set these 413 | | independently if that makes sense for your project. 414 | | 415 | | Take note that border colors require a special "default" value set 416 | | as well. This is the color that will be used when you do not 417 | | specify a border color. 418 | | 419 | | Class name: .border-{color} 420 | | 421 | */ 422 | 423 | borderColors: global.Object.assign({ default: colors['grey-light'] }, colors), 424 | 425 | 426 | /* 427 | |----------------------------------------------------------------------------- 428 | | Border radius https://tailwindcss.com/docs/border-radius 429 | |----------------------------------------------------------------------------- 430 | | 431 | | Here is where you define your border radius values. If a `default` radius 432 | | is provided, it will be made available as the non-suffixed `.rounded` 433 | | utility. 434 | | 435 | | If your scale includes a `0` value to reset already rounded corners, it's 436 | | a good idea to put it first so other values are able to override it. 437 | | 438 | | Class name: .rounded{-side?}{-size?} 439 | | 440 | */ 441 | 442 | borderRadius: { 443 | 'none': '0', 444 | 'sm': '.125rem', 445 | default: '.25rem', 446 | 'lg': '.5rem', 447 | 'full': '9999px', 448 | }, 449 | 450 | 451 | /* 452 | |----------------------------------------------------------------------------- 453 | | Width https://tailwindcss.com/docs/width 454 | |----------------------------------------------------------------------------- 455 | | 456 | | Here is where you define your width utility sizes. These can be 457 | | percentage based, pixels, rems, or any other units. By default 458 | | we provide a sensible rem based numeric scale, a percentage 459 | | based fraction scale, plus some other common use-cases. You 460 | | can, of course, modify these values as needed. 461 | | 462 | | 463 | | It's also worth mentioning that Tailwind automatically escapes 464 | | invalid CSS class name characters, which allows you to have 465 | | awesome classes like .w-2/3. 466 | | 467 | | Class name: .w-{size} 468 | | 469 | */ 470 | 471 | width: { 472 | 'auto': 'auto', 473 | 'px': '1px', 474 | '30px': '30px', 475 | '35px': '35px', 476 | '40px': '40px', 477 | '220px': '220px', 478 | '500px': '500px', 479 | '1': '0.25rem', 480 | '2': '0.5rem', 481 | '3': '0.75rem', 482 | '4': '1rem', 483 | '6': '1.5rem', 484 | '8': '2rem', 485 | '10': '2.5rem', 486 | '12': '3rem', 487 | '16': '4rem', 488 | '24': '6rem', 489 | '32': '8rem', 490 | '48': '12rem', 491 | '64': '16rem', 492 | '1/2': '50%', 493 | '1/3': '33.33333%', 494 | '2/3': '66.66667%', 495 | '1/4': '25%', 496 | '3/4': '75%', 497 | '1/5': '20%', 498 | '2/5': '40%', 499 | '3/5': '60%', 500 | '4/5': '80%', 501 | '1/6': '16.66667%', 502 | '5/6': '83.33333%', 503 | 'full': '100%', 504 | 'screen': '100vw' 505 | }, 506 | 507 | 508 | /* 509 | |----------------------------------------------------------------------------- 510 | | Height https://tailwindcss.com/docs/height 511 | |----------------------------------------------------------------------------- 512 | | 513 | | Here is where you define your height utility sizes. These can be 514 | | percentage based, pixels, rems, or any other units. By default 515 | | we provide a sensible rem based numeric scale plus some other 516 | | common use-cases. You can, of course, modify these values as 517 | | needed. 518 | | 519 | | Class name: .h-{size} 520 | | 521 | */ 522 | 523 | height: { 524 | 'auto': 'auto', 525 | 'px': '1px', 526 | '60px': '60px', 527 | '1': '0.25rem', 528 | '2': '0.5rem', 529 | '3': '0.75rem', 530 | '4': '1rem', 531 | '6': '1.5rem', 532 | '8': '2rem', 533 | '10': '2.5rem', 534 | '12': '3rem', 535 | '16': '4rem', 536 | '24': '6rem', 537 | '32': '8rem', 538 | '48': '12rem', 539 | '64': '16rem', 540 | 'full': '100%', 541 | 'screen': '100vh' 542 | }, 543 | 544 | 545 | /* 546 | |----------------------------------------------------------------------------- 547 | | Minimum width https://tailwindcss.com/docs/min-width 548 | |----------------------------------------------------------------------------- 549 | | 550 | | Here is where you define your minimum width utility sizes. These can 551 | | be percentage based, pixels, rems, or any other units. We provide a 552 | | couple common use-cases by default. You can, of course, modify 553 | | these values as needed. 554 | | 555 | | Class name: .min-w-{size} 556 | | 557 | */ 558 | 559 | minWidth: { 560 | '0': '0', 561 | 'full': '100%', 562 | }, 563 | 564 | 565 | /* 566 | |----------------------------------------------------------------------------- 567 | | Minimum height https://tailwindcss.com/docs/min-height 568 | |----------------------------------------------------------------------------- 569 | | 570 | | Here is where you define your minimum height utility sizes. These can 571 | | be percentage based, pixels, rems, or any other units. We provide a 572 | | few common use-cases by default. You can, of course, modify these 573 | | values as needed. 574 | | 575 | | Class name: .min-h-{size} 576 | | 577 | */ 578 | 579 | minHeight: { 580 | '0': '0', 581 | 'full': '100%', 582 | 'screen': '100vh' 583 | }, 584 | 585 | 586 | /* 587 | |----------------------------------------------------------------------------- 588 | | Maximum width https://tailwindcss.com/docs/max-width 589 | |----------------------------------------------------------------------------- 590 | | 591 | | Here is where you define your maximum width utility sizes. These can 592 | | be percentage based, pixels, rems, or any other units. By default 593 | | we provide a sensible rem based scale and a "full width" size, 594 | | which is basically a reset utility. You can, of course, 595 | | modify these values as needed. 596 | | 597 | | Class name: .max-w-{size} 598 | | 599 | */ 600 | 601 | maxWidth: { 602 | 'xs': '20rem', 603 | 'sm': '30rem', 604 | 'md': '40rem', 605 | 'lg': '50rem', 606 | 'xl': '60rem', 607 | '2xl': '70rem', 608 | '3xl': '80rem', 609 | '4xl': '90rem', 610 | '5xl': '100rem', 611 | 'full': '100%', 612 | }, 613 | 614 | 615 | /* 616 | |----------------------------------------------------------------------------- 617 | | Maximum height https://tailwindcss.com/docs/max-height 618 | |----------------------------------------------------------------------------- 619 | | 620 | | Here is where you define your maximum height utility sizes. These can 621 | | be percentage based, pixels, rems, or any other units. We provide a 622 | | couple common use-cases by default. You can, of course, modify 623 | | these values as needed. 624 | | 625 | | Class name: .max-h-{size} 626 | | 627 | */ 628 | 629 | maxHeight: { 630 | 'full': '100%', 631 | 'screen': '100vh', 632 | }, 633 | 634 | 635 | /* 636 | |----------------------------------------------------------------------------- 637 | | Padding https://tailwindcss.com/docs/padding 638 | |----------------------------------------------------------------------------- 639 | | 640 | | Here is where you define your padding utility sizes. These can be 641 | | percentage based, pixels, rems, or any other units. By default we 642 | | provide a sensible rem based numeric scale plus a couple other 643 | | common use-cases like "1px". You can, of course, modify these 644 | | values as needed. 645 | | 646 | | Class name: .p{side?}-{size} 647 | | 648 | */ 649 | 650 | padding: { 651 | 'px': '1px', 652 | '3px': '3px', 653 | '40px': '40px', 654 | '0': '0', 655 | '1': '0.25rem', 656 | '2': '0.5rem', 657 | '3': '0.75rem', 658 | '4': '1rem', 659 | '6': '1.5rem', 660 | '8': '2rem', 661 | }, 662 | 663 | 664 | /* 665 | |----------------------------------------------------------------------------- 666 | | Margin https://tailwindcss.com/docs/margin 667 | |----------------------------------------------------------------------------- 668 | | 669 | | Here is where you define your margin utility sizes. These can be 670 | | percentage based, pixels, rems, or any other units. By default we 671 | | provide a sensible rem based numeric scale plus a couple other 672 | | common use-cases like "1px". You can, of course, modify these 673 | | values as needed. 674 | | 675 | | Class name: .m{side?}-{size} 676 | | 677 | */ 678 | 679 | margin: { 680 | 'auto': 'auto', 681 | 'px': '1px', 682 | '30px': '30px', 683 | '60px': '60px', 684 | '100px': '100px', 685 | '0': '0', 686 | '1': '0.25rem', 687 | '2': '0.5rem', 688 | '3': '0.75rem', 689 | '4': '1rem', 690 | '6': '1.5rem', 691 | '8': '2rem', 692 | }, 693 | 694 | 695 | /* 696 | |----------------------------------------------------------------------------- 697 | | Negative margin https://tailwindcss.com/docs/negative-margin 698 | |----------------------------------------------------------------------------- 699 | | 700 | | Here is where you define your negative margin utility sizes. These can 701 | | be percentage based, pixels, rems, or any other units. By default we 702 | | provide matching values to the padding scale since these utilities 703 | | generally get used together. You can, of course, modify these 704 | | values as needed. 705 | | 706 | | Class name: .-m{side?}-{size} 707 | | 708 | */ 709 | 710 | negativeMargin: { 711 | 'px': '1px', 712 | '0': '0', 713 | '1': '0.25rem', 714 | '2': '0.5rem', 715 | '3': '0.75rem', 716 | '4': '1rem', 717 | '6': '1.5rem', 718 | '8': '2rem', 719 | }, 720 | 721 | 722 | /* 723 | |----------------------------------------------------------------------------- 724 | | Shadows https://tailwindcss.com/docs/shadows 725 | |----------------------------------------------------------------------------- 726 | | 727 | | Here is where you define your shadow utilities. As you can see from 728 | | the defaults we provide, it's possible to apply multiple shadows 729 | | per utility using comma separation. 730 | | 731 | | If a `default` shadow is provided, it will be made available as the non- 732 | | suffixed `.shadow` utility. 733 | | 734 | | Class name: .shadow-{size?} 735 | | 736 | */ 737 | 738 | shadows: { 739 | default: '0 2px 4px 0 rgba(0,0,0,0.10)', 740 | 'md': '0 4px 8px 0 rgba(0,0,0,0.12), 0 2px 4px 0 rgba(0,0,0,0.08)', 741 | 'lg': '0 15px 30px 0 rgba(0,0,0,0.11), 0 5px 15px 0 rgba(0,0,0,0.08)', 742 | 'inner': 'inset 0 2px 4px 0 rgba(0,0,0,0.06)', 743 | 'none': 'none', 744 | }, 745 | 746 | 747 | /* 748 | |----------------------------------------------------------------------------- 749 | | Z-index https://tailwindcss.com/docs/z-index 750 | |----------------------------------------------------------------------------- 751 | | 752 | | Here is where you define your z-index utility values. By default we 753 | | provide a sensible numeric scale. You can, of course, modify these 754 | | values as needed. 755 | | 756 | | Class name: .z-{index} 757 | | 758 | */ 759 | 760 | zIndex: { 761 | 'auto': 'auto', 762 | '0': 0, 763 | '10': 10, 764 | '20': 20, 765 | '30': 30, 766 | '40': 40, 767 | '50': 50, 768 | }, 769 | 770 | 771 | /* 772 | |----------------------------------------------------------------------------- 773 | | Opacity https://tailwindcss.com/docs/opacity 774 | |----------------------------------------------------------------------------- 775 | | 776 | | Here is where you define your opacity utility values. By default we 777 | | provide a sensible numeric scale. You can, of course, modify these 778 | | values as needed. 779 | | 780 | | Class name: .opacity-{name} 781 | | 782 | */ 783 | 784 | opacity: { 785 | '0': '0', 786 | '25': '.25', 787 | '50': '.5', 788 | '75': '.75', 789 | '100': '1', 790 | }, 791 | 792 | 793 | /* 794 | |----------------------------------------------------------------------------- 795 | | SVG fill https://tailwindcss.com/docs/svg 796 | |----------------------------------------------------------------------------- 797 | | 798 | | Here is where you define your SVG fill colors. By default we just provide 799 | | `fill-current` which sets the fill to the current text color. This lets you 800 | | specify a fill color using existing text color utilities and helps keep the 801 | | generated CSS file size down. 802 | | 803 | | Class name: .fill-{name} 804 | | 805 | */ 806 | 807 | svgFill: { 808 | 'current': 'currentColor', 809 | }, 810 | 811 | 812 | /* 813 | |----------------------------------------------------------------------------- 814 | | SVG stroke https://tailwindcss.com/docs/svg 815 | |----------------------------------------------------------------------------- 816 | | 817 | | Here is where you define your SVG stroke colors. By default we just provide 818 | | `stroke-current` which sets the stroke to the current text color. This lets 819 | | you specify a stroke color using existing text color utilities and helps 820 | | keep the generated CSS file size down. 821 | | 822 | | Class name: .stroke-{name} 823 | | 824 | */ 825 | 826 | svgStroke: { 827 | 'current': 'currentColor', 828 | }, 829 | 830 | 831 | /* 832 | |----------------------------------------------------------------------------- 833 | | Modules https://tailwindcss.com/docs/configuration#modules 834 | |----------------------------------------------------------------------------- 835 | | 836 | | Here is where you control which modules are generated and what variants are 837 | | generated for each of those modules. 838 | | 839 | | Currently supported variants: 840 | | - responsive 841 | | - hover 842 | | - focus 843 | | - active 844 | | - group-hover 845 | | 846 | | To disable a module completely, use `false` instead of an array. 847 | | 848 | */ 849 | 850 | modules: { 851 | appearance: ['responsive'], 852 | backgroundAttachment: ['responsive'], 853 | backgroundColors: ['responsive', 'hover'], 854 | backgroundPosition: ['responsive'], 855 | backgroundRepeat: ['responsive'], 856 | backgroundSize: ['responsive'], 857 | borderColors: ['responsive', 'hover', 'focus'], 858 | borderRadius: ['responsive', 'focus'], 859 | borderStyle: ['responsive'], 860 | borderWidths: ['responsive'], 861 | cursor: ['responsive'], 862 | display: ['responsive', 'group-hover'], 863 | flexbox: ['responsive'], 864 | float: ['responsive'], 865 | fonts: ['responsive'], 866 | fontWeights: ['responsive', 'hover'], 867 | height: ['responsive'], 868 | leading: ['responsive'], 869 | lists: ['responsive'], 870 | margin: ['responsive'], 871 | maxHeight: ['responsive'], 872 | maxWidth: ['responsive'], 873 | minHeight: ['responsive'], 874 | minWidth: ['responsive'], 875 | negativeMargin: ['responsive'], 876 | opacity: ['responsive'], 877 | overflow: ['responsive'], 878 | padding: ['responsive'], 879 | pointerEvents: ['responsive'], 880 | position: ['responsive'], 881 | resize: ['responsive'], 882 | shadows: ['responsive'], 883 | svgFill: [], 884 | svgStroke: [], 885 | textAlign: ['responsive'], 886 | textColors: ['responsive', 'hover'], 887 | textSizes: ['responsive'], 888 | textStyle: ['responsive', 'hover'], 889 | tracking: ['responsive'], 890 | userSelect: ['responsive'], 891 | verticalAlign: ['responsive'], 892 | visibility: ['responsive'], 893 | whitespace: ['responsive'], 894 | width: ['responsive'], 895 | zIndex: ['responsive'], 896 | }, 897 | 898 | 899 | /* 900 | |----------------------------------------------------------------------------- 901 | | Plugins https://tailwindcss.com/docs/plugins 902 | |----------------------------------------------------------------------------- 903 | | 904 | | Here is where you can register any plugins you'd like to use in your 905 | | project. Tailwind's built-in `container` plugin is enabled by default to 906 | | give you a Bootstrap-style responsive container component out of the box. 907 | | 908 | | Be sure to view the complete plugin documentation to learn more about how 909 | | the plugin system works. 910 | | 911 | */ 912 | 913 | plugins: [ 914 | require('tailwindcss/plugins/container')({ 915 | // center: true, 916 | // padding: '1rem', 917 | }), 918 | ], 919 | 920 | 921 | /* 922 | |----------------------------------------------------------------------------- 923 | | Advanced Options https://tailwindcss.com/docs/configuration#options 924 | |----------------------------------------------------------------------------- 925 | | 926 | | Here is where you can tweak advanced configuration options. We recommend 927 | | leaving these options alone unless you absolutely need to change them. 928 | | 929 | */ 930 | 931 | options: { 932 | prefix: '', 933 | important: false, 934 | separator: ':', 935 | }, 936 | 937 | } 938 | --------------------------------------------------------------------------------