├── .gitignore ├── client ├── assets │ └── images │ │ ├── default.png │ │ └── unicornbike.jpg ├── main.js ├── auth │ ├── PrivateRoute.js │ ├── api-auth.js │ ├── auth-helper.js │ └── Signin.js ├── theme.js ├── App.js ├── enrollment │ ├── Enroll.js │ ├── Enrollments.js │ ├── api-enrollment.js │ └── Enrollment.js ├── MainRouter.js ├── course │ ├── DeleteCourse.js │ ├── Courses.js │ ├── MyCourses.js │ ├── NewLesson.js │ ├── api-course.js │ ├── NewCourse.js │ ├── Course.js │ └── EditCourse.js ├── user │ ├── api-user.js │ ├── DeleteUser.js │ ├── Users.js │ ├── Profile.js │ ├── Signup.js │ └── EditProfile.js └── core │ ├── Menu.js │ └── Home.js ├── nodemon.json ├── .babelrc ├── server ├── routes │ ├── auth.routes.js │ ├── user.routes.js │ ├── enrollment.routes.js │ └── course.routes.js ├── models │ ├── enrollment.model.js │ ├── course.model.js │ └── user.model.js ├── server.js ├── devBundle.js ├── helpers │ └── dbErrorHandler.js ├── controllers │ ├── auth.controller.js │ ├── user.controller.js │ ├── enrollment.controller.js │ └── course.controller.js └── express.js ├── config └── config.js ├── .github └── stale.yml ├── webpack.config.client.production.js ├── webpack.config.server.js ├── template.js ├── LICENSE.md ├── webpack.config.client.js ├── package.json └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | /node_modules/ 2 | /dist/ 3 | /data/ 4 | npm-debug.log 5 | -------------------------------------------------------------------------------- /client/assets/images/default.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shamahoque/mern-classroom/HEAD/client/assets/images/default.png -------------------------------------------------------------------------------- /client/assets/images/unicornbike.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shamahoque/mern-classroom/HEAD/client/assets/images/unicornbike.jpg -------------------------------------------------------------------------------- /client/main.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { hydrate } from 'react-dom' 3 | import App from './App' 4 | 5 | hydrate(, document.getElementById('root')) 6 | -------------------------------------------------------------------------------- /nodemon.json: -------------------------------------------------------------------------------- 1 | { 2 | "verbose": false, 3 | "watch": [ 4 | "./server" 5 | ], 6 | "exec": "webpack --mode=development --config webpack.config.server.js && node ./dist/server.generated.js" 7 | } 8 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | ["@babel/preset-env", 4 | { 5 | "targets": { 6 | "node": "current" 7 | } 8 | } 9 | ], 10 | "@babel/preset-react" 11 | ], 12 | "plugins": [ 13 | "react-hot-loader/babel" 14 | ] 15 | } 16 | -------------------------------------------------------------------------------- /server/routes/auth.routes.js: -------------------------------------------------------------------------------- 1 | import express from 'express' 2 | import authCtrl from '../controllers/auth.controller' 3 | 4 | const router = express.Router() 5 | 6 | router.route('/auth/signin') 7 | .post(authCtrl.signin) 8 | router.route('/auth/signout') 9 | .get(authCtrl.signout) 10 | 11 | export default router 12 | -------------------------------------------------------------------------------- /config/config.js: -------------------------------------------------------------------------------- 1 | const config = { 2 | env: process.env.NODE_ENV || 'development', 3 | port: process.env.PORT || 3000, 4 | jwtSecret: process.env.JWT_SECRET || "YOUR_secret_key", 5 | mongoUri: process.env.MONGODB_URI || 6 | process.env.MONGO_HOST || 7 | 'mongodb://' + (process.env.IP || 'localhost') + ':' + 8 | (process.env.MONGO_PORT || '27017') + 9 | '/mernproject' 10 | } 11 | 12 | export default config 13 | -------------------------------------------------------------------------------- /client/auth/PrivateRoute.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import { Route, Redirect } from 'react-router-dom' 3 | import auth from './auth-helper' 4 | 5 | const PrivateRoute = ({ component: Component, ...rest }) => ( 6 | ( 7 | auth.isAuthenticated() ? ( 8 | 9 | ) : ( 10 | 14 | ) 15 | )}/> 16 | ) 17 | 18 | export default PrivateRoute 19 | -------------------------------------------------------------------------------- /server/models/enrollment.model.js: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose' 2 | 3 | const EnrollmentSchema = new mongoose.Schema({ 4 | course: {type: mongoose.Schema.ObjectId, ref: 'Course'}, 5 | updated: Date, 6 | enrolled: { 7 | type: Date, 8 | default: Date.now 9 | }, 10 | student: {type: mongoose.Schema.ObjectId, ref: 'User'}, 11 | lessonStatus: [{ 12 | lesson: {type: mongoose.Schema.ObjectId, ref: 'Lesson'}, 13 | complete: Boolean}], 14 | completed: Date 15 | }) 16 | 17 | export default mongoose.model('Enrollment', EnrollmentSchema) 18 | -------------------------------------------------------------------------------- /server/server.js: -------------------------------------------------------------------------------- 1 | import config from './../config/config' 2 | import app from './express' 3 | import mongoose from 'mongoose' 4 | 5 | // Connection URL 6 | mongoose.Promise = global.Promise 7 | mongoose.connect(config.mongoUri, { useNewUrlParser: true, useCreateIndex: true, useUnifiedTopology: true }) 8 | mongoose.connection.on('error', () => { 9 | throw new Error(`unable to connect to database: ${config.mongoUri}`) 10 | }) 11 | 12 | app.listen(config.port, (err) => { 13 | if (err) { 14 | console.log(err) 15 | } 16 | console.info('Server started on port %s.', config.port) 17 | }) 18 | -------------------------------------------------------------------------------- /client/theme.js: -------------------------------------------------------------------------------- 1 | import { createMuiTheme } from '@material-ui/core/styles' 2 | 3 | const theme = createMuiTheme({ 4 | typography: { 5 | useNextVariants: true, 6 | }, 7 | palette: { 8 | primary: { 9 | light: '#8e8e8e', 10 | main: '#616161', 11 | dark: '#373737', 12 | contrastText: '#fffde7', 13 | }, 14 | secondary: { 15 | light: '#ffad42', 16 | main: '#f57c00', 17 | dark: '#bb4d00', 18 | contrastText: '#fffde7', 19 | }, 20 | openTitle: '#455a64', 21 | protectedTitle: '#f57c00', 22 | type: 'light' 23 | } 24 | }) 25 | 26 | export default theme -------------------------------------------------------------------------------- /server/routes/user.routes.js: -------------------------------------------------------------------------------- 1 | import express from 'express' 2 | import userCtrl from '../controllers/user.controller' 3 | import authCtrl from '../controllers/auth.controller' 4 | 5 | const router = express.Router() 6 | 7 | router.route('/api/users') 8 | .get(userCtrl.list) 9 | .post(userCtrl.create) 10 | 11 | router.route('/api/users/:userId') 12 | .get(authCtrl.requireSignin, userCtrl.read) 13 | .put(authCtrl.requireSignin, authCtrl.hasAuthorization, userCtrl.update) 14 | .delete(authCtrl.requireSignin, authCtrl.hasAuthorization, userCtrl.remove) 15 | 16 | router.param('userId', userCtrl.userByID) 17 | 18 | export default router 19 | -------------------------------------------------------------------------------- /server/devBundle.js: -------------------------------------------------------------------------------- 1 | import config from './../config/config' 2 | import webpack from 'webpack' 3 | import webpackMiddleware from 'webpack-dev-middleware' 4 | import webpackHotMiddleware from 'webpack-hot-middleware' 5 | import webpackConfig from './../webpack.config.client.js' 6 | 7 | const compile = (app) => { 8 | if(config.env === "development"){ 9 | const compiler = webpack(webpackConfig) 10 | const middleware = webpackMiddleware(compiler, { 11 | publicPath: webpackConfig.output.publicPath 12 | }) 13 | app.use(middleware) 14 | app.use(webpackHotMiddleware(compiler)) 15 | } 16 | } 17 | 18 | export default { 19 | compile 20 | } 21 | -------------------------------------------------------------------------------- /client/App.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import MainRouter from './MainRouter' 3 | import {BrowserRouter} from 'react-router-dom' 4 | import { ThemeProvider } from '@material-ui/styles' 5 | import theme from './theme' 6 | import { hot } from 'react-hot-loader' 7 | 8 | const App = () => { 9 | React.useEffect(() => { 10 | const jssStyles = document.querySelector('#jss-server-side') 11 | if (jssStyles) { 12 | jssStyles.parentNode.removeChild(jssStyles) 13 | } 14 | }, []) 15 | return ( 16 | 17 | 18 | 19 | 20 | 21 | )} 22 | 23 | export default hot(module)(App) 24 | -------------------------------------------------------------------------------- /client/auth/api-auth.js: -------------------------------------------------------------------------------- 1 | const signin = async (user) => { 2 | try { 3 | let response = await fetch('/auth/signin/', { 4 | method: 'POST', 5 | headers: { 6 | 'Accept': 'application/json', 7 | 'Content-Type': 'application/json' 8 | }, 9 | credentials: 'include', 10 | body: JSON.stringify(user) 11 | }) 12 | return await response.json() 13 | } catch(err) { 14 | console.log(err) 15 | } 16 | } 17 | 18 | const signout = async () => { 19 | try { 20 | let response = await fetch('/auth/signout/', { method: 'GET' }) 21 | return await response.json() 22 | } catch(err) { 23 | console.log(err) 24 | } 25 | } 26 | 27 | export { 28 | signin, 29 | signout 30 | } -------------------------------------------------------------------------------- /.github/stale.yml: -------------------------------------------------------------------------------- 1 | # Number of days of inactivity before an issue becomes stale 2 | daysUntilStale: 60 3 | # Number of days of inactivity before a stale issue is closed 4 | daysUntilClose: 7 5 | # Issues with these labels will never be considered stale 6 | exemptLabels: 7 | - pinned 8 | - security 9 | # Label to use when marking an issue as stale 10 | staleLabel: inactive 11 | # Comment to post when marking an issue as stale. Set to `false` to disable 12 | markComment: > 13 | This issue has been automatically marked as stale because it has not had 14 | recent activity. It will be closed if no further activity occurs. Thank you 15 | for your contributions. 16 | # Comment to post when closing a stale issue. Set to `false` to disable 17 | closeComment: false 18 | -------------------------------------------------------------------------------- /webpack.config.client.production.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const CURRENT_WORKING_DIR = process.cwd() 3 | 4 | const config = { 5 | mode: "production", 6 | entry: [ 7 | path.join(CURRENT_WORKING_DIR, 'client/main.js') 8 | ], 9 | output: { 10 | path: path.join(CURRENT_WORKING_DIR , '/dist'), 11 | filename: 'bundle.js', 12 | publicPath: "/dist/" 13 | }, 14 | module: { 15 | rules: [ 16 | { 17 | test: /\.jsx?$/, 18 | exclude: /node_modules/, 19 | use: [ 20 | 'babel-loader' 21 | ] 22 | }, 23 | { 24 | test: /\.(ttf|eot|svg|gif|jpg|png)(\?[\s\S]+)?$/, 25 | use: 'file-loader' 26 | } 27 | ] 28 | } 29 | } 30 | 31 | module.exports = config 32 | -------------------------------------------------------------------------------- /webpack.config.server.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const nodeExternals = require('webpack-node-externals') 3 | const CURRENT_WORKING_DIR = process.cwd() 4 | 5 | const config = { 6 | name: "server", 7 | entry: [ path.join(CURRENT_WORKING_DIR , './server/server.js') ], 8 | target: "node", 9 | output: { 10 | path: path.join(CURRENT_WORKING_DIR , '/dist/'), 11 | filename: "server.generated.js", 12 | publicPath: '/dist/', 13 | libraryTarget: "commonjs2" 14 | }, 15 | externals: [nodeExternals()], 16 | module: { 17 | rules: [ 18 | { 19 | test: /\.js$/, 20 | exclude: /node_modules/, 21 | use: [ 'babel-loader' ] 22 | }, 23 | { 24 | test: /\.(ttf|eot|svg|gif|jpg|png)(\?[\s\S]+)?$/, 25 | use: 'file-loader' 26 | } 27 | ] 28 | } 29 | } 30 | 31 | module.exports = config 32 | -------------------------------------------------------------------------------- /server/models/course.model.js: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose' 2 | 3 | const LessonSchema = new mongoose.Schema({ 4 | title: String, 5 | content: String, 6 | resource_url: String 7 | }) 8 | const Lesson = mongoose.model('Lesson', LessonSchema) 9 | const CourseSchema = new mongoose.Schema({ 10 | name: { 11 | type: String, 12 | trim: true, 13 | required: 'Name is required' 14 | }, 15 | image: { 16 | data: Buffer, 17 | contentType: String 18 | }, 19 | description: { 20 | type: String, 21 | trim: true 22 | }, 23 | category: { 24 | type: String, 25 | required: 'Category is required' 26 | }, 27 | updated: Date, 28 | created: { 29 | type: Date, 30 | default: Date.now 31 | }, 32 | instructor: {type: mongoose.Schema.ObjectId, ref: 'User'}, 33 | published: { 34 | type: Boolean, 35 | default: false 36 | }, 37 | lessons: [LessonSchema] 38 | }) 39 | 40 | export default mongoose.model('Course', CourseSchema) 41 | -------------------------------------------------------------------------------- /template.js: -------------------------------------------------------------------------------- 1 | export default ({markup, css}) => { 2 | return ` 3 | 4 | 5 | 6 | 10 | MERN Classroom 11 | 12 | 13 | 19 | 20 | 21 |
${markup}
22 | 23 | 24 | 25 | ` 26 | } 27 | -------------------------------------------------------------------------------- /client/auth/auth-helper.js: -------------------------------------------------------------------------------- 1 | import { signout } from './api-auth.js' 2 | 3 | const auth = { 4 | isAuthenticated() { 5 | if (typeof window == "undefined") 6 | return false 7 | 8 | if (sessionStorage.getItem('jwt')) 9 | return JSON.parse(sessionStorage.getItem('jwt')) 10 | else 11 | return false 12 | }, 13 | authenticate(jwt, cb) { 14 | if (typeof window !== "undefined") 15 | sessionStorage.setItem('jwt', JSON.stringify(jwt)) 16 | cb() 17 | }, 18 | clearJWT(cb) { 19 | if (typeof window !== "undefined") 20 | sessionStorage.removeItem('jwt') 21 | cb() 22 | //optional 23 | signout().then((data) => { 24 | document.cookie = "t=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/;" 25 | }) 26 | }, 27 | updateUser(user, cb) { 28 | if(typeof window !== "undefined"){ 29 | if(sessionStorage.getItem('jwt')){ 30 | let auth = JSON.parse(sessionStorage.getItem('jwt')) 31 | auth.user = user 32 | sessionStorage.setItem('jwt', JSON.stringify(auth)) 33 | cb() 34 | } 35 | } 36 | } 37 | } 38 | 39 | export default auth 40 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Shama Hoque 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /server/routes/enrollment.routes.js: -------------------------------------------------------------------------------- 1 | import express from 'express' 2 | import enrollmentCtrl from '../controllers/enrollment.controller' 3 | import courseCtrl from '../controllers/course.controller' 4 | import authCtrl from '../controllers/auth.controller' 5 | 6 | const router = express.Router() 7 | 8 | router.route('/api/enrollment/enrolled') 9 | .get(authCtrl.requireSignin, enrollmentCtrl.listEnrolled) 10 | 11 | router.route('/api/enrollment/new/:courseId') 12 | .post(authCtrl.requireSignin, enrollmentCtrl.findEnrollment, enrollmentCtrl.create) 13 | 14 | router.route('/api/enrollment/stats/:courseId') 15 | .get(enrollmentCtrl.enrollmentStats) 16 | 17 | router.route('/api/enrollment/complete/:enrollmentId') 18 | .put(authCtrl.requireSignin, enrollmentCtrl.isStudent, enrollmentCtrl.complete) 19 | 20 | router.route('/api/enrollment/:enrollmentId') 21 | .get(authCtrl.requireSignin, enrollmentCtrl.isStudent, enrollmentCtrl.read) 22 | .delete(authCtrl.requireSignin, enrollmentCtrl.isStudent, enrollmentCtrl.remove) 23 | 24 | router.param('courseId', courseCtrl.courseByID) 25 | router.param('enrollmentId', enrollmentCtrl.enrollmentByID) 26 | 27 | export default router 28 | -------------------------------------------------------------------------------- /server/helpers/dbErrorHandler.js: -------------------------------------------------------------------------------- 1 | 'use strict' 2 | 3 | /** 4 | * Get unique error field name 5 | */ 6 | const getUniqueErrorMessage = (err) => { 7 | let output 8 | try { 9 | let fieldName = err.message.substring(err.message.lastIndexOf('.$') + 2, err.message.lastIndexOf('_1')) 10 | output = fieldName.charAt(0).toUpperCase() + fieldName.slice(1) + ' already exists' 11 | } catch (ex) { 12 | output = 'Unique field already exists' 13 | } 14 | 15 | return output 16 | } 17 | 18 | /** 19 | * Get the error message from error object 20 | */ 21 | const getErrorMessage = (err) => { 22 | let message = '' 23 | 24 | if (err.code) { 25 | switch (err.code) { 26 | case 11000: 27 | case 11001: 28 | message = getUniqueErrorMessage(err) 29 | break 30 | default: 31 | message = 'Something went wrong' 32 | } 33 | } else { 34 | for (let errName in err.errors) { 35 | if (err.errors[errName].message) message = err.errors[errName].message 36 | } 37 | } 38 | 39 | return message 40 | } 41 | 42 | export default {getErrorMessage} 43 | -------------------------------------------------------------------------------- /webpack.config.client.js: -------------------------------------------------------------------------------- 1 | const path = require('path') 2 | const webpack = require('webpack') 3 | const CURRENT_WORKING_DIR = process.cwd() 4 | 5 | const config = { 6 | name: "browser", 7 | mode: "development", 8 | devtool: 'eval-source-map', 9 | entry: [ 10 | 'webpack-hot-middleware/client?reload=true', 11 | path.join(CURRENT_WORKING_DIR, 'client/main.js') 12 | ], 13 | output: { 14 | path: path.join(CURRENT_WORKING_DIR , '/dist'), 15 | filename: 'bundle.js', 16 | publicPath: '/dist/' 17 | }, 18 | module: { 19 | rules: [ 20 | { 21 | test: /\.jsx?$/, 22 | exclude: /node_modules/, 23 | use: [ 24 | 'babel-loader' 25 | ] 26 | }, 27 | { 28 | test: /\.(ttf|eot|svg|gif|jpg|png)(\?[\s\S]+)?$/, 29 | use: 'file-loader' 30 | } 31 | ] 32 | }, 33 | plugins: [ 34 | new webpack.HotModuleReplacementPlugin(), 35 | new webpack.NoEmitOnErrorsPlugin() 36 | ], 37 | resolve: { 38 | alias: { 39 | 'react-dom': '@hot-loader/react-dom' 40 | } 41 | } 42 | } 43 | 44 | module.exports = config 45 | -------------------------------------------------------------------------------- /server/routes/course.routes.js: -------------------------------------------------------------------------------- 1 | import express from 'express' 2 | import courseCtrl from '../controllers/course.controller' 3 | import userCtrl from '../controllers/user.controller' 4 | import authCtrl from '../controllers/auth.controller' 5 | 6 | const router = express.Router() 7 | 8 | router.route('/api/courses/published') 9 | .get(courseCtrl.listPublished) 10 | 11 | router.route('/api/courses/by/:userId') 12 | .post(authCtrl.requireSignin, authCtrl.hasAuthorization, userCtrl.isEducator, courseCtrl.create) 13 | .get(authCtrl.requireSignin, authCtrl.hasAuthorization, courseCtrl.listByInstructor) 14 | 15 | router.route('/api/courses/photo/:courseId') 16 | .get(courseCtrl.photo, courseCtrl.defaultPhoto) 17 | 18 | router.route('/api/courses/defaultphoto') 19 | .get(courseCtrl.defaultPhoto) 20 | 21 | router.route('/api/courses/:courseId/lesson/new') 22 | .put(authCtrl.requireSignin, courseCtrl.isInstructor, courseCtrl.newLesson) 23 | 24 | router.route('/api/courses/:courseId') 25 | .get(courseCtrl.read) 26 | .put(authCtrl.requireSignin, courseCtrl.isInstructor, courseCtrl.update) 27 | .delete(authCtrl.requireSignin, courseCtrl.isInstructor, courseCtrl.remove) 28 | 29 | router.param('courseId', courseCtrl.courseByID) 30 | router.param('userId', userCtrl.userByID) 31 | 32 | export default router 33 | -------------------------------------------------------------------------------- /client/enrollment/Enroll.js: -------------------------------------------------------------------------------- 1 | import React, {useState} from 'react' 2 | import PropTypes from 'prop-types' 3 | import Button from '@material-ui/core/Button' 4 | import {makeStyles} from '@material-ui/core/styles' 5 | import {create} from './api-enrollment' 6 | import auth from './../auth/auth-helper' 7 | import {Redirect} from 'react-router-dom' 8 | 9 | const useStyles = makeStyles(theme => ({ 10 | form: { 11 | minWidth: 500 12 | } 13 | })) 14 | 15 | export default function Enroll(props) { 16 | const classes = useStyles() 17 | const [values, setValues] = useState({ 18 | enrollmentId: '', 19 | error: '', 20 | redirect: false 21 | }) 22 | const jwt = auth.isAuthenticated() 23 | const clickEnroll = () => { 24 | create({ 25 | courseId: props.courseId 26 | }, { 27 | t: jwt.token 28 | }).then((data) => { 29 | if (data && data.error) { 30 | setValues({...values, error: data.error}) 31 | } else { 32 | setValues({...values, enrollmentId: data._id, redirect: true}) 33 | } 34 | }) 35 | } 36 | 37 | if(values.redirect){ 38 | return () 39 | } 40 | 41 | return ( 42 | 43 | ) 44 | } 45 | 46 | Enroll.propTypes = { 47 | courseId: PropTypes.string.isRequired 48 | } 49 | -------------------------------------------------------------------------------- /client/MainRouter.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import {Route, Switch} from 'react-router-dom' 3 | import Home from './core/Home' 4 | import Users from './user/Users' 5 | import Signup from './user/Signup' 6 | import Signin from './auth/Signin' 7 | import EditProfile from './user/EditProfile' 8 | import Profile from './user/Profile' 9 | import PrivateRoute from './auth/PrivateRoute' 10 | import Menu from './core/Menu' 11 | import NewCourse from './course/NewCourse' 12 | //import Courses from './course/Courses' 13 | import Course from './course/Course' 14 | import EditCourse from './course/EditCourse' 15 | import MyCourses from './course/MyCourses' 16 | import Enrollment from './enrollment/Enrollment' 17 | 18 | const MainRouter = () => { 19 | return (
20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 |
) 38 | } 39 | 40 | export default MainRouter 41 | -------------------------------------------------------------------------------- /server/controllers/auth.controller.js: -------------------------------------------------------------------------------- 1 | import User from '../models/user.model' 2 | import jwt from 'jsonwebtoken' 3 | import expressJwt from 'express-jwt' 4 | import config from './../../config/config' 5 | 6 | const signin = async (req, res) => { 7 | try { 8 | let user = await User.findOne({ 9 | "email": req.body.email 10 | }) 11 | if (!user) 12 | return res.status('401').json({ 13 | error: "User not found" 14 | }) 15 | 16 | if (!user.authenticate(req.body.password)) { 17 | return res.status('401').send({ 18 | error: "Email and password don't match." 19 | }) 20 | } 21 | 22 | const token = jwt.sign({ 23 | _id: user._id 24 | }, config.jwtSecret) 25 | 26 | res.cookie("t", token, { 27 | expire: new Date() + 9999 28 | }) 29 | 30 | return res.json({ 31 | token, 32 | user: { 33 | _id: user._id, 34 | name: user.name, 35 | email: user.email, 36 | educator: user.educator 37 | } 38 | }) 39 | 40 | } catch (err) { 41 | 42 | return res.status('401').json({ 43 | error: "Could not sign in" 44 | }) 45 | 46 | } 47 | } 48 | 49 | const signout = (req, res) => { 50 | res.clearCookie("t") 51 | return res.status('200').json({ 52 | message: "signed out" 53 | }) 54 | } 55 | 56 | const requireSignin = expressJwt({ 57 | secret: config.jwtSecret, 58 | userProperty: 'auth' 59 | }) 60 | 61 | const hasAuthorization = (req, res, next) => { 62 | const authorized = req.profile && req.auth && req.profile._id == req.auth._id 63 | if (!(authorized)) { 64 | return res.status('403').json({ 65 | error: "User is not authorized" 66 | }) 67 | } 68 | next() 69 | } 70 | 71 | export default { 72 | signin, 73 | signout, 74 | requireSignin, 75 | hasAuthorization 76 | } 77 | -------------------------------------------------------------------------------- /server/models/user.model.js: -------------------------------------------------------------------------------- 1 | import mongoose from 'mongoose' 2 | import crypto from 'crypto' 3 | const UserSchema = new mongoose.Schema({ 4 | name: { 5 | type: String, 6 | trim: true, 7 | required: 'Name is required' 8 | }, 9 | email: { 10 | type: String, 11 | trim: true, 12 | unique: 'Email already exists', 13 | match: [/.+\@.+\..+/, 'Please fill a valid email address'], 14 | required: 'Email is required' 15 | }, 16 | hashed_password: { 17 | type: String, 18 | required: "Password is required" 19 | }, 20 | salt: String, 21 | updated: Date, 22 | created: { 23 | type: Date, 24 | default: Date.now 25 | }, 26 | educator: { 27 | type: Boolean, 28 | default: false 29 | }, 30 | }) 31 | 32 | UserSchema 33 | .virtual('password') 34 | .set(function(password) { 35 | this._password = password 36 | this.salt = this.makeSalt() 37 | this.hashed_password = this.encryptPassword(password) 38 | }) 39 | .get(function() { 40 | return this._password 41 | }) 42 | 43 | UserSchema.path('hashed_password').validate(function(v) { 44 | if (this._password && this._password.length < 6) { 45 | this.invalidate('password', 'Password must be at least 6 characters.') 46 | } 47 | if (this.isNew && !this._password) { 48 | this.invalidate('password', 'Password is required') 49 | } 50 | }, null) 51 | 52 | UserSchema.methods = { 53 | authenticate: function(plainText) { 54 | return this.encryptPassword(plainText) === this.hashed_password 55 | }, 56 | encryptPassword: function(password) { 57 | if (!password) return '' 58 | try { 59 | return crypto 60 | .createHmac('sha1', this.salt) 61 | .update(password) 62 | .digest('hex') 63 | } catch (err) { 64 | return '' 65 | } 66 | }, 67 | makeSalt: function() { 68 | return Math.round((new Date().valueOf() * Math.random())) + '' 69 | } 70 | } 71 | 72 | export default mongoose.model('User', UserSchema) 73 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "mern-classroom", 3 | "version": "2.0.0", 4 | "description": "A MERN stack based online classroom application", 5 | "author": "Shama Hoque", 6 | "license": "MIT", 7 | "keywords": [ 8 | "react", 9 | "express", 10 | "mongodb", 11 | "node", 12 | "mern" 13 | ], 14 | "repository": { 15 | "type": "git", 16 | "url": "https://github.com/shamahoque/mern-classroom.git" 17 | }, 18 | "homepage": "https://github.com/shamahoque/mern-classroom", 19 | "main": "./dist/server.generated.js", 20 | "scripts": { 21 | "development": "nodemon", 22 | "build": "webpack --config webpack.config.client.production.js && webpack --mode=production --config webpack.config.server.js", 23 | "start": "NODE_ENV=production node ./dist/server.generated.js" 24 | }, 25 | "engines": { 26 | "node": "13.12.0", 27 | "npm": "6.14.4" 28 | }, 29 | "devDependencies": { 30 | "@babel/core": "7.9.0", 31 | "@babel/preset-env": "7.9.0", 32 | "@babel/preset-react": "7.9.4", 33 | "babel-loader": "8.1.0", 34 | "file-loader": "6.0.0", 35 | "nodemon": "2.0.2", 36 | "webpack": "4.42.1", 37 | "webpack-cli": "3.3.11", 38 | "webpack-dev-middleware": "3.7.2", 39 | "webpack-hot-middleware": "2.25.0", 40 | "webpack-node-externals": "1.7.2" 41 | }, 42 | "dependencies": { 43 | "@hot-loader/react-dom": "16.13.0", 44 | "@material-ui/core": "4.9.8", 45 | "@material-ui/icons": "4.9.1", 46 | "body-parser": "1.19.0", 47 | "compression": "1.7.4", 48 | "cookie-parser": "1.4.5", 49 | "cors": "2.8.5", 50 | "express": "4.17.1", 51 | "express-jwt": "5.3.1", 52 | "formidable": "1.2.2", 53 | "helmet": "3.22.0", 54 | "jsonwebtoken": "8.5.1", 55 | "lodash": "4.17.15", 56 | "mongoose": "5.9.7", 57 | "react": "16.13.1", 58 | "react-dom": "16.13.1", 59 | "react-hot-loader": "4.12.20", 60 | "react-router": "5.1.2", 61 | "react-router-dom": "5.1.2" 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /client/course/DeleteCourse.js: -------------------------------------------------------------------------------- 1 | import React, {useState} from 'react' 2 | import PropTypes from 'prop-types' 3 | import IconButton from '@material-ui/core/IconButton' 4 | import Button from '@material-ui/core/Button' 5 | import DeleteIcon from '@material-ui/icons/Delete' 6 | import Dialog from '@material-ui/core/Dialog' 7 | import DialogActions from '@material-ui/core/DialogActions' 8 | import DialogContent from '@material-ui/core/DialogContent' 9 | import DialogContentText from '@material-ui/core/DialogContentText' 10 | import DialogTitle from '@material-ui/core/DialogTitle' 11 | import auth from './../auth/auth-helper' 12 | import {remove} from './api-course.js' 13 | 14 | export default function DeleteCourse(props) { 15 | const [open, setOpen] = useState(false) 16 | 17 | const jwt = auth.isAuthenticated() 18 | const clickButton = () => { 19 | setOpen(true) 20 | } 21 | const deleteCourse = () => { 22 | remove({ 23 | courseId: props.course._id 24 | }, {t: jwt.token}).then((data) => { 25 | if (data.error) { 26 | console.log(data.error) 27 | } else { 28 | setOpen(false) 29 | props.onRemove(props.course) 30 | } 31 | }) 32 | } 33 | const handleRequestClose = () => { 34 | setOpen(false) 35 | } 36 | return ( 37 | 38 | 39 | 40 | 41 | 42 | {"Delete "+props.course.name} 43 | 44 | 45 | Confirm to delete your course {props.course.name}. 46 | 47 | 48 | 49 | 52 | 55 | 56 | 57 | ) 58 | } 59 | DeleteCourse.propTypes = { 60 | course: PropTypes.object.isRequired, 61 | onRemove: PropTypes.func.isRequired 62 | } -------------------------------------------------------------------------------- /client/user/api-user.js: -------------------------------------------------------------------------------- 1 | const create = async (user) => { 2 | try { 3 | let response = await fetch('/api/users/', { 4 | method: 'POST', 5 | headers: { 6 | 'Accept': 'application/json', 7 | 'Content-Type': 'application/json' 8 | }, 9 | body: JSON.stringify(user) 10 | }) 11 | return await response.json() 12 | } catch(err) { 13 | console.log(err) 14 | } 15 | } 16 | 17 | const list = async (signal) => { 18 | try { 19 | let response = await fetch('/api/users/', { 20 | method: 'GET', 21 | signal: signal, 22 | }) 23 | return await response.json() 24 | } catch(err) { 25 | console.log(err) 26 | } 27 | } 28 | 29 | const read = async (params, credentials, signal) => { 30 | try { 31 | let response = await fetch('/api/users/' + params.userId, { 32 | method: 'GET', 33 | signal: signal, 34 | headers: { 35 | 'Accept': 'application/json', 36 | 'Content-Type': 'application/json', 37 | 'Authorization': 'Bearer ' + credentials.t 38 | } 39 | }) 40 | return await response.json() 41 | } catch(err) { 42 | console.log(err) 43 | } 44 | } 45 | 46 | const update = async (params, credentials, user) => { 47 | try { 48 | let response = await fetch('/api/users/' + params.userId, { 49 | method: 'PUT', 50 | headers: { 51 | 'Accept': 'application/json', 52 | 'Content-Type': 'application/json', 53 | 'Authorization': 'Bearer ' + credentials.t 54 | }, 55 | body: JSON.stringify(user) 56 | }) 57 | return await response.json() 58 | } catch(err) { 59 | console.log(err) 60 | } 61 | } 62 | 63 | const remove = async (params, credentials) => { 64 | try { 65 | let response = await fetch('/api/users/' + params.userId, { 66 | method: 'DELETE', 67 | headers: { 68 | 'Accept': 'application/json', 69 | 'Content-Type': 'application/json', 70 | 'Authorization': 'Bearer ' + credentials.t 71 | } 72 | }) 73 | return await response.json() 74 | } catch(err) { 75 | console.log(err) 76 | } 77 | } 78 | 79 | export { 80 | create, 81 | list, 82 | read, 83 | update, 84 | remove 85 | } -------------------------------------------------------------------------------- /client/user/DeleteUser.js: -------------------------------------------------------------------------------- 1 | import React, {useState} from 'react' 2 | import PropTypes from 'prop-types' 3 | import IconButton from '@material-ui/core/IconButton' 4 | import Button from '@material-ui/core/Button' 5 | import DeleteIcon from '@material-ui/icons/Delete' 6 | import Dialog from '@material-ui/core/Dialog' 7 | import DialogActions from '@material-ui/core/DialogActions' 8 | import DialogContent from '@material-ui/core/DialogContent' 9 | import DialogContentText from '@material-ui/core/DialogContentText' 10 | import DialogTitle from '@material-ui/core/DialogTitle' 11 | import auth from './../auth/auth-helper' 12 | import {remove} from './api-user.js' 13 | import {Redirect} from 'react-router-dom' 14 | 15 | export default function DeleteUser(props) { 16 | const [open, setOpen] = useState(false) 17 | const [redirect, setRedirect] = useState(false) 18 | 19 | const jwt = auth.isAuthenticated() 20 | const clickButton = () => { 21 | setOpen(true) 22 | } 23 | const deleteAccount = () => { 24 | remove({ 25 | userId: props.userId 26 | }, {t: jwt.token}).then((data) => { 27 | if (data && data.error) { 28 | console.log(data.error) 29 | } else { 30 | auth.clearJWT(() => console.log('deleted')) 31 | setRedirect(true) 32 | } 33 | }) 34 | } 35 | const handleRequestClose = () => { 36 | setOpen(false) 37 | } 38 | 39 | if (redirect) { 40 | return 41 | } 42 | return ( 43 | 44 | 45 | 46 | 47 | 48 | {"Delete Account"} 49 | 50 | 51 | Confirm to delete your account. 52 | 53 | 54 | 55 | 58 | 61 | 62 | 63 | ) 64 | 65 | } 66 | DeleteUser.propTypes = { 67 | userId: PropTypes.string.isRequired 68 | } 69 | 70 | -------------------------------------------------------------------------------- /client/enrollment/Enrollments.js: -------------------------------------------------------------------------------- 1 | import React, {useState, useEffect} from 'react' 2 | import { makeStyles } from '@material-ui/core/styles' 3 | import GridList from '@material-ui/core/GridList' 4 | import GridListTile from '@material-ui/core/GridListTile' 5 | import GridListTileBar from '@material-ui/core/GridListTileBar' 6 | import CompletedIcon from '@material-ui/icons/VerifiedUser' 7 | import InProgressIcon from '@material-ui/icons/DonutLarge' 8 | import {Link} from 'react-router-dom' 9 | 10 | const useStyles = makeStyles(theme => ({ 11 | title: { 12 | padding:`${theme.spacing(3)}px ${theme.spacing(2.5)}px ${theme.spacing(2)}px`, 13 | color: theme.palette.openTitle 14 | }, 15 | media: { 16 | minHeight: 400 17 | }, 18 | container: { 19 | minWidth: '100%', 20 | paddingBottom: '14px' 21 | }, 22 | gridList: { 23 | width: '100%', 24 | minHeight: 100, 25 | padding: '12px 0 10px' 26 | }, 27 | tile: { 28 | textAlign: 'center' 29 | }, 30 | image: { 31 | height: '100%' 32 | }, 33 | tileBar: { 34 | backgroundColor: 'rgba(0, 0, 0, 0.85)', 35 | textAlign: 'left' 36 | }, 37 | tileTitle: { 38 | fontSize:'1.1em', 39 | marginBottom:'5px', 40 | color:'#fffde7', 41 | display:'block' 42 | }, 43 | action:{ 44 | margin: '0 10px' 45 | }, 46 | progress:{ 47 | color: '#b4f8b4' 48 | } 49 | })) 50 | 51 | export default function Enrollments(props){ 52 | const classes = useStyles() 53 | return ( 54 |
55 | 56 | {props.enrollments.map((course, i) => ( 57 | 58 | {course.course.name} 59 | {course.course.name}} 61 | actionIcon={
62 | {course.completed ? () 63 | : () 64 | }
} 65 | /> 66 |
67 | ))} 68 |
69 |
70 | ) 71 | } 72 | 73 | -------------------------------------------------------------------------------- /client/core/Menu.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import AppBar from '@material-ui/core/AppBar' 3 | import Toolbar from '@material-ui/core/Toolbar' 4 | import Typography from '@material-ui/core/Typography' 5 | import IconButton from '@material-ui/core/IconButton' 6 | import HomeIcon from '@material-ui/icons/Home' 7 | import Library from '@material-ui/icons/LocalLibrary' 8 | import Button from '@material-ui/core/Button' 9 | import auth from './../auth/auth-helper' 10 | import {Link, withRouter} from 'react-router-dom' 11 | 12 | const isActive = (history, path) => { 13 | if (history.location.pathname == path) 14 | return {color: '#f57c00'} 15 | else 16 | return {color: '#fffde7'} 17 | } 18 | const isPartActive = (history, path) => { 19 | if (history.location.pathname.includes(path)) 20 | return {color: '#fffde7', backgroundColor: '#f57c00', marginRight:10} 21 | else 22 | return {color: '#616161', backgroundColor: '#fffde7', border:'1px solid #f57c00', marginRight:10} 23 | } 24 | const Menu = withRouter(({history}) => ( 25 | 26 | 27 | 28 | MERN Classroom 29 | 30 |
31 | 32 | 33 | 34 | 35 | 36 |
37 |
38 | { 39 | !auth.isAuthenticated() && ( 40 | 41 | 43 | 44 | 45 | 47 | 48 | ) 49 | } 50 | { 51 | auth.isAuthenticated() && ( 52 | {auth.isAuthenticated().user.educator && ()} 53 | 54 | 55 | 56 | 59 | ) 60 | } 61 |
62 |
63 |
64 | )) 65 | 66 | export default Menu 67 | -------------------------------------------------------------------------------- /client/course/Courses.js: -------------------------------------------------------------------------------- 1 | import React, {useState, useEffect} from 'react' 2 | import PropTypes from 'prop-types' 3 | import { makeStyles } from '@material-ui/core/styles' 4 | import GridList from '@material-ui/core/GridList' 5 | import GridListTile from '@material-ui/core/GridListTile' 6 | import GridListTileBar from '@material-ui/core/GridListTileBar' 7 | import {Link} from 'react-router-dom' 8 | import auth from './../auth/auth-helper' 9 | import Enroll from './../enrollment/Enroll' 10 | 11 | const useStyles = makeStyles(theme => ({ 12 | title: { 13 | padding:`${theme.spacing(3)}px ${theme.spacing(2.5)}px ${theme.spacing(2)}px`, 14 | color: theme.palette.openTitle 15 | }, 16 | media: { 17 | minHeight: 400 18 | }, 19 | gridList: { 20 | width: '100%', 21 | minHeight: 200, 22 | padding: '16px 0 0px' 23 | }, 24 | tile: { 25 | textAlign: 'center', 26 | border: '1px solid #cecece', 27 | backgroundColor:'#04040c' 28 | }, 29 | image: { 30 | height: '100%' 31 | }, 32 | tileBar: { 33 | backgroundColor: 'rgba(0, 0, 0, 0.85)', 34 | textAlign: 'left' 35 | }, 36 | tileTitle: { 37 | fontSize:'1.1em', 38 | marginBottom:'5px', 39 | color:'#fffde7', 40 | display:'block' 41 | }, 42 | action:{ 43 | margin: '0 10px' 44 | } 45 | })) 46 | 47 | export default function Courses(props){ 48 | const classes = useStyles() 49 | const findCommon = (course) => { 50 | return !props.common.find((enrolled)=>{return enrolled.course._id == course._id}) 51 | } 52 | return ( 53 | 54 | {props.courses.map((course, i) => { 55 | return ( 56 | findCommon(course) && 57 | 58 | {course.name} 59 | {course.name}} 61 | subtitle={{course.category}} 62 | actionIcon={ 63 |
64 | {auth.isAuthenticated() ? : Sign in to Enroll} 65 |
66 | } 67 | /> 68 |
) 69 | } 70 | )} 71 |
72 | ) 73 | } 74 | 75 | Courses.propTypes = { 76 | courses: PropTypes.array.isRequired 77 | } -------------------------------------------------------------------------------- /server/express.js: -------------------------------------------------------------------------------- 1 | import express from 'express' 2 | import path from 'path' 3 | import bodyParser from 'body-parser' 4 | import cookieParser from 'cookie-parser' 5 | import compress from 'compression' 6 | import cors from 'cors' 7 | import helmet from 'helmet' 8 | import Template from './../template' 9 | import userRoutes from './routes/user.routes' 10 | import authRoutes from './routes/auth.routes' 11 | import courseRoutes from './routes/course.routes' 12 | import enrollmentRoutes from './routes/enrollment.routes' 13 | 14 | // modules for server side rendering 15 | import React from 'react' 16 | import ReactDOMServer from 'react-dom/server' 17 | import MainRouter from './../client/MainRouter' 18 | import { StaticRouter } from 'react-router-dom' 19 | 20 | import { ServerStyleSheets, ThemeProvider } from '@material-ui/styles' 21 | import theme from './../client/theme' 22 | //end 23 | 24 | //comment out before building for production 25 | import devBundle from './devBundle' 26 | 27 | const CURRENT_WORKING_DIR = process.cwd() 28 | const app = express() 29 | 30 | //comment out before building for production 31 | devBundle.compile(app) 32 | 33 | // parse body params and attache them to req.body 34 | app.use(bodyParser.json()) 35 | app.use(bodyParser.urlencoded({ extended: true })) 36 | app.use(cookieParser()) 37 | app.use(compress()) 38 | // secure apps by setting various HTTP headers 39 | app.use(helmet()) 40 | // enable CORS - Cross Origin Resource Sharing 41 | app.use(cors()) 42 | 43 | app.use('/dist', express.static(path.join(CURRENT_WORKING_DIR, 'dist'))) 44 | 45 | // mount routes 46 | app.use('/', userRoutes) 47 | app.use('/', authRoutes) 48 | app.use('/', courseRoutes) 49 | app.use('/', enrollmentRoutes) 50 | 51 | app.get('*', (req, res) => { 52 | const sheets = new ServerStyleSheets() 53 | const context = {} 54 | const markup = ReactDOMServer.renderToString( 55 | sheets.collect( 56 | 57 | 58 | 59 | 60 | 61 | ) 62 | ) 63 | if (context.url) { 64 | return res.redirect(303, context.url) 65 | } 66 | const css = sheets.toString() 67 | res.status(200).send(Template({ 68 | markup: markup, 69 | css: css 70 | })) 71 | }) 72 | 73 | // Catch unauthorised errors 74 | app.use((err, req, res, next) => { 75 | if (err.name === 'UnauthorizedError') { 76 | res.status(401).json({"error" : err.name + ": " + err.message}) 77 | }else if (err) { 78 | res.status(400).json({"error" : err.name + ": " + err.message}) 79 | console.log(err) 80 | } 81 | }) 82 | 83 | export default app 84 | -------------------------------------------------------------------------------- /client/user/Users.js: -------------------------------------------------------------------------------- 1 | import React, {useState, useEffect} from 'react' 2 | import { makeStyles } from '@material-ui/core/styles' 3 | import Paper from '@material-ui/core/Paper' 4 | import List from '@material-ui/core/List' 5 | import ListItem from '@material-ui/core/ListItem' 6 | import ListItemAvatar from '@material-ui/core/ListItemAvatar' 7 | import ListItemSecondaryAction from '@material-ui/core/ListItemSecondaryAction' 8 | import ListItemText from '@material-ui/core/ListItemText' 9 | import Avatar from '@material-ui/core/Avatar' 10 | import IconButton from '@material-ui/core/IconButton' 11 | import Typography from '@material-ui/core/Typography' 12 | import ArrowForward from '@material-ui/icons/ArrowForward' 13 | import Person from '@material-ui/icons/Person' 14 | import {Link} from 'react-router-dom' 15 | import {list} from './api-user.js' 16 | 17 | const useStyles = makeStyles(theme => ({ 18 | root: theme.mixins.gutters({ 19 | padding: theme.spacing(1), 20 | margin: theme.spacing(5), 21 | marginTop: theme.spacing(12), 22 | }), 23 | title: { 24 | margin: `${theme.spacing(4)}px 0 ${theme.spacing(2)}px`, 25 | color: theme.palette.openTitle 26 | } 27 | })) 28 | 29 | export default function Users() { 30 | const classes = useStyles() 31 | const [users, setUsers] = useState([]) 32 | 33 | useEffect(() => { 34 | const abortController = new AbortController() 35 | const signal = abortController.signal 36 | 37 | list(signal).then((data) => { 38 | if (data && data.error) { 39 | console.log(data.error) 40 | } else { 41 | setUsers(data) 42 | } 43 | }) 44 | 45 | return function cleanup(){ 46 | abortController.abort() 47 | } 48 | }, []) 49 | 50 | 51 | return ( 52 | 53 | 54 | All Users 55 | 56 | 57 | {users.map((item, i) => { 58 | return 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | }) 74 | } 75 | 76 | 77 | ) 78 | } 79 | -------------------------------------------------------------------------------- /server/controllers/user.controller.js: -------------------------------------------------------------------------------- 1 | import User from '../models/user.model' 2 | import extend from 'lodash/extend' 3 | import errorHandler from './../helpers/dbErrorHandler' 4 | 5 | const create = async (req, res) => { 6 | const user = new User(req.body) 7 | try { 8 | await user.save() 9 | return res.status(200).json({ 10 | message: "Successfully signed up!" 11 | }) 12 | } catch (err) { 13 | return res.status(400).json({ 14 | error: errorHandler.getErrorMessage(err) 15 | }) 16 | } 17 | } 18 | 19 | /** 20 | * Load user and append to req. 21 | */ 22 | const userByID = async (req, res, next, id) => { 23 | try { 24 | let user = await User.findById(id) 25 | if (!user) 26 | return res.status('400').json({ 27 | error: "User not found" 28 | }) 29 | req.profile = user 30 | next() 31 | } catch (err) { 32 | return res.status('400').json({ 33 | error: "Could not retrieve user" 34 | }) 35 | } 36 | } 37 | 38 | const read = (req, res) => { 39 | req.profile.hashed_password = undefined 40 | req.profile.salt = undefined 41 | return res.json(req.profile) 42 | } 43 | 44 | const list = async (req, res) => { 45 | try { 46 | let users = await User.find().select('name email updated created') 47 | res.json(users) 48 | } catch (err) { 49 | return res.status(400).json({ 50 | error: errorHandler.getErrorMessage(err) 51 | }) 52 | } 53 | } 54 | 55 | const update = async (req, res) => { 56 | try { 57 | let user = req.profile 58 | user = extend(user, req.body) 59 | user.updated = Date.now() 60 | await user.save() 61 | user.hashed_password = undefined 62 | user.salt = undefined 63 | res.json(user) 64 | } catch (err) { 65 | return res.status(400).json({ 66 | error: errorHandler.getErrorMessage(err) 67 | }) 68 | } 69 | } 70 | 71 | const remove = async (req, res) => { 72 | try { 73 | let user = req.profile 74 | let deletedUser = await user.remove() 75 | deletedUser.hashed_password = undefined 76 | deletedUser.salt = undefined 77 | res.json(deletedUser) 78 | } catch (err) { 79 | return res.status(400).json({ 80 | error: errorHandler.getErrorMessage(err) 81 | }) 82 | } 83 | } 84 | 85 | const isEducator = (req, res, next) => { 86 | const isEducator = req.profile && req.profile.educator 87 | if (!isEducator) { 88 | return res.status('403').json({ 89 | error: "User is not an educator" 90 | }) 91 | } 92 | next() 93 | } 94 | 95 | export default { 96 | create, 97 | userByID, 98 | read, 99 | list, 100 | remove, 101 | update, 102 | isEducator 103 | } 104 | -------------------------------------------------------------------------------- /client/enrollment/api-enrollment.js: -------------------------------------------------------------------------------- 1 | const create = async (params, credentials) => { 2 | try { 3 | let response = await fetch('/api/enrollment/new/'+params.courseId, { 4 | method: 'POST', 5 | headers: { 6 | 'Accept': 'application/json', 7 | 'Content-Type': 'application/json', 8 | 'Authorization': 'Bearer ' + credentials.t 9 | } 10 | }) 11 | return await response.json() 12 | } catch(err) { 13 | console.log(err) 14 | } 15 | } 16 | 17 | const listEnrolled = async (credentials, signal) => { 18 | try { 19 | let response = await fetch('/api/enrollment/enrolled', { 20 | method: 'GET', 21 | headers: { 22 | 'Accept': 'application/json', 23 | 'Authorization': 'Bearer ' + credentials.t 24 | }, 25 | signal: signal, 26 | }) 27 | return await response.json() 28 | } catch(err) { 29 | console.log(err) 30 | } 31 | } 32 | 33 | const enrollmentStats = async (params, credentials, signal) => { 34 | try { 35 | let response = await fetch('/api/enrollment/stats/'+params.courseId, { 36 | method: 'GET', 37 | headers: { 38 | 'Accept': 'application/json', 39 | 'Authorization': 'Bearer ' + credentials.t 40 | }, 41 | signal: signal, 42 | }) 43 | return await response.json() 44 | } catch(err) { 45 | console.log(err) 46 | } 47 | } 48 | 49 | const read = async (params, credentials, signal) => { 50 | try { 51 | let response = await fetch('/api/enrollment/' + params.enrollmentId, { 52 | method: 'GET', 53 | signal: signal, 54 | headers: { 55 | 'Accept': 'application/json', 56 | 'Content-Type': 'application/json', 57 | 'Authorization': 'Bearer ' + credentials.t 58 | } 59 | }) 60 | return await response.json() 61 | } catch(err) { 62 | console.log(err) 63 | } 64 | } 65 | 66 | const complete = async (params, credentials, enrollment) => { 67 | try { 68 | let response = await fetch('/api/enrollment/complete/' + params.enrollmentId, { 69 | method: 'PUT', 70 | headers: { 71 | 'Accept': 'application/json', 72 | 'Content-Type': 'application/json', 73 | 'Authorization': 'Bearer ' + credentials.t 74 | }, 75 | body: JSON.stringify(enrollment) 76 | }) 77 | return await response.json() 78 | } catch(err) { 79 | console.log(err) 80 | } 81 | } 82 | 83 | const remove = async (params, credentials) => { 84 | try { 85 | let response = await fetch('/api/enrollment/' + params.enrollmentId, { 86 | method: 'DELETE', 87 | headers: { 88 | 'Accept': 'application/json', 89 | 'Content-Type': 'application/json', 90 | 'Authorization': 'Bearer ' + credentials.t 91 | } 92 | }) 93 | return await response.json() 94 | } catch(err) { 95 | console.log(err) 96 | } 97 | } 98 | 99 | export { 100 | create, 101 | read, 102 | complete, 103 | remove, 104 | listEnrolled, 105 | enrollmentStats 106 | } -------------------------------------------------------------------------------- /client/user/Profile.js: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react' 2 | import { makeStyles } from '@material-ui/core/styles' 3 | import Paper from '@material-ui/core/Paper' 4 | import List from '@material-ui/core/List' 5 | import ListItem from '@material-ui/core/ListItem' 6 | import ListItemAvatar from '@material-ui/core/ListItemAvatar' 7 | import ListItemSecondaryAction from '@material-ui/core/ListItemSecondaryAction' 8 | import ListItemText from '@material-ui/core/ListItemText' 9 | import Avatar from '@material-ui/core/Avatar' 10 | import IconButton from '@material-ui/core/IconButton' 11 | import Typography from '@material-ui/core/Typography' 12 | import Edit from '@material-ui/icons/Edit' 13 | import Person from '@material-ui/icons/Person' 14 | import Divider from '@material-ui/core/Divider' 15 | import DeleteUser from './DeleteUser' 16 | import auth from './../auth/auth-helper' 17 | import {read} from './api-user.js' 18 | import {Redirect, Link} from 'react-router-dom' 19 | 20 | const useStyles = makeStyles(theme => ({ 21 | root: theme.mixins.gutters({ 22 | maxWidth: 600, 23 | margin: 'auto', 24 | padding: theme.spacing(3), 25 | marginTop: theme.spacing(12) 26 | }), 27 | title: { 28 | marginTop: theme.spacing(3), 29 | color: theme.palette.protectedTitle 30 | } 31 | })) 32 | 33 | export default function Profile({ match }) { 34 | const classes = useStyles() 35 | const [user, setUser] = useState({}) 36 | const [redirectToSignin, setRedirectToSignin] = useState(false) 37 | const jwt = auth.isAuthenticated() 38 | 39 | useEffect(() => { 40 | const abortController = new AbortController() 41 | const signal = abortController.signal 42 | 43 | read({ 44 | userId: match.params.userId 45 | }, {t: jwt.token}, signal).then((data) => { 46 | if (data && data.error) { 47 | setRedirectToSignin(true) 48 | } else { 49 | setUser(data) 50 | } 51 | }) 52 | 53 | return function cleanup(){ 54 | abortController.abort() 55 | } 56 | 57 | }, [match.params.userId]) 58 | 59 | if (redirectToSignin) { 60 | return 61 | } 62 | return ( 63 | 64 | 65 | Profile 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | { 75 | auth.isAuthenticated().user && auth.isAuthenticated().user._id == user._id && 76 | ( 77 | 78 | 79 | 80 | 81 | 82 | 83 | ) 84 | } 85 | 86 | 87 | 88 | 90 | 91 | 92 | 93 | ) 94 | } -------------------------------------------------------------------------------- /client/auth/Signin.js: -------------------------------------------------------------------------------- 1 | import React, {useState} from 'react' 2 | import Card from '@material-ui/core/Card' 3 | import CardActions from '@material-ui/core/CardActions' 4 | import CardContent from '@material-ui/core/CardContent' 5 | import Button from '@material-ui/core/Button' 6 | import TextField from '@material-ui/core/TextField' 7 | import Typography from '@material-ui/core/Typography' 8 | import Icon from '@material-ui/core/Icon' 9 | import { makeStyles } from '@material-ui/core/styles' 10 | import auth from './../auth/auth-helper' 11 | import {Redirect} from 'react-router-dom' 12 | import {signin} from './api-auth.js' 13 | 14 | const useStyles = makeStyles(theme => ({ 15 | card: { 16 | maxWidth: 600, 17 | margin: 'auto', 18 | textAlign: 'center', 19 | marginTop: theme.spacing(12), 20 | paddingBottom: theme.spacing(2) 21 | }, 22 | error: { 23 | verticalAlign: 'middle' 24 | }, 25 | title: { 26 | marginTop: theme.spacing(2), 27 | color: theme.palette.openTitle 28 | }, 29 | textField: { 30 | marginLeft: theme.spacing(1), 31 | marginRight: theme.spacing(1), 32 | width: 300 33 | }, 34 | submit: { 35 | margin: 'auto', 36 | marginBottom: theme.spacing(2) 37 | } 38 | })) 39 | 40 | export default function Signin(props) { 41 | const classes = useStyles() 42 | const [values, setValues] = useState({ 43 | email: '', 44 | password: '', 45 | error: '', 46 | redirectToReferrer: false 47 | }) 48 | 49 | const clickSubmit = () => { 50 | const user = { 51 | email: values.email || undefined, 52 | password: values.password || undefined 53 | } 54 | 55 | signin(user).then((data) => { 56 | if (data.error) { 57 | setValues({ ...values, error: data.error}) 58 | } else { 59 | auth.authenticate(data, () => { 60 | setValues({ ...values, error: '',redirectToReferrer: true}) 61 | }) 62 | } 63 | }) 64 | } 65 | 66 | const handleChange = name => event => { 67 | setValues({ ...values, [name]: event.target.value }) 68 | } 69 | 70 | const {from} = props.location.state || { 71 | from: { 72 | pathname: '/' 73 | } 74 | } 75 | const {redirectToReferrer} = values 76 | if (redirectToReferrer) { 77 | return () 78 | } 79 | 80 | return ( 81 | 82 | 83 | 84 | Sign In 85 | 86 |
87 | 88 |
{ 89 | values.error && ( 90 | error 91 | {values.error} 92 | ) 93 | } 94 |
95 | 96 | 97 | 98 |
99 | ) 100 | } 101 | -------------------------------------------------------------------------------- /client/course/MyCourses.js: -------------------------------------------------------------------------------- 1 | import React, {useState, useEffect} from 'react' 2 | import { makeStyles } from '@material-ui/core/styles' 3 | import Paper from '@material-ui/core/Paper' 4 | import List from '@material-ui/core/List' 5 | import ListItem from '@material-ui/core/ListItem' 6 | import ListItemAvatar from '@material-ui/core/ListItemAvatar' 7 | import ListItemSecondaryAction from '@material-ui/core/ListItemSecondaryAction' 8 | import ListItemText from '@material-ui/core/ListItemText' 9 | import Avatar from '@material-ui/core/Avatar' 10 | import Icon from '@material-ui/core/Icon' 11 | import Button from '@material-ui/core/Button' 12 | import Typography from '@material-ui/core/Typography' 13 | import Divider from '@material-ui/core/Divider' 14 | import auth from './../auth/auth-helper' 15 | import {listByInstructor} from './api-course.js' 16 | import {Redirect, Link} from 'react-router-dom' 17 | 18 | const useStyles = makeStyles(theme => ({ 19 | root: theme.mixins.gutters({ 20 | maxWidth: 600, 21 | margin: 'auto', 22 | padding: theme.spacing(3), 23 | marginTop: theme.spacing(12) 24 | }), 25 | title: { 26 | margin: `${theme.spacing(3)}px 0 ${theme.spacing(3)}px ${theme.spacing(1)}px` , 27 | color: theme.palette.protectedTitle, 28 | fontSize: '1.2em' 29 | }, 30 | addButton:{ 31 | float:'right' 32 | }, 33 | leftIcon: { 34 | marginRight: "8px" 35 | }, 36 | avatar: { 37 | borderRadius: 0, 38 | width:65, 39 | height: 40 40 | }, 41 | listText: { 42 | marginLeft: 16 43 | } 44 | })) 45 | 46 | export default function MyCourses(){ 47 | const classes = useStyles() 48 | const [courses, setCourses] = useState([]) 49 | const [redirectToSignin, setRedirectToSignin] = useState(false) 50 | const jwt = auth.isAuthenticated() 51 | 52 | useEffect(() => { 53 | const abortController = new AbortController() 54 | const signal = abortController.signal 55 | listByInstructor({ 56 | userId: jwt.user._id 57 | }, {t: jwt.token}, signal).then((data) => { 58 | if (data.error) { 59 | setRedirectToSignin(true) 60 | } else { 61 | setCourses(data) 62 | } 63 | }) 64 | return function cleanup(){ 65 | abortController.abort() 66 | } 67 | }, []) 68 | 69 | if (redirectToSignin) { 70 | return 71 | } 72 | return ( 73 |
74 | 75 | 76 | Your Courses 77 | 78 | 79 | 82 | 83 | 84 | 85 | 86 | {courses.map((course, i) => { 87 | return 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | })} 96 | 97 | 98 |
) 99 | } -------------------------------------------------------------------------------- /client/course/NewLesson.js: -------------------------------------------------------------------------------- 1 | import React, {useState} from 'react' 2 | import PropTypes from 'prop-types' 3 | import Button from '@material-ui/core/Button' 4 | import TextField from '@material-ui/core/TextField' 5 | import Dialog from '@material-ui/core/Dialog' 6 | import DialogActions from '@material-ui/core/DialogActions' 7 | import DialogContent from '@material-ui/core/DialogContent' 8 | import DialogTitle from '@material-ui/core/DialogTitle' 9 | import Add from '@material-ui/icons/AddBox' 10 | import {makeStyles} from '@material-ui/core/styles' 11 | import {newLesson} from './api-course' 12 | import auth from './../auth/auth-helper' 13 | 14 | const useStyles = makeStyles(theme => ({ 15 | form: { 16 | minWidth: 500 17 | } 18 | })) 19 | 20 | export default function NewLesson(props) { 21 | const classes = useStyles() 22 | const [open, setOpen] = useState(false) 23 | const [values, setValues] = useState({ 24 | title: '', 25 | content: '', 26 | resource_url: '' 27 | }) 28 | 29 | const handleChange = name => event => { 30 | setValues({ ...values, [name]: event.target.value }) 31 | } 32 | const clickSubmit = () => { 33 | const jwt = auth.isAuthenticated() 34 | const lesson = { 35 | title: values.title || undefined, 36 | content: values.content || undefined, 37 | resource_url: values.resource_url || undefined 38 | } 39 | newLesson({ 40 | courseId: props.courseId 41 | }, { 42 | t: jwt.token 43 | }, lesson).then((data) => { 44 | if (data && data.error) { 45 | setValues({...values, error: data.error}) 46 | } else { 47 | props.addLesson(data) 48 | setValues({...values, title: '', 49 | content: '', 50 | resource_url: ''}) 51 | setOpen(false) 52 | } 53 | }) 54 | } 55 | const handleClickOpen = () => { 56 | setOpen(true) 57 | } 58 | 59 | const handleClose = () => { 60 | setOpen(false) 61 | } 62 | 63 | return ( 64 |
65 | 68 | 69 |
70 | Add New Lesson 71 | 72 | 73 |
80 |
89 |
96 | 97 |
98 | 99 | 100 | 103 | 106 | 107 |
108 |
109 |
110 | ) 111 | } 112 | NewLesson.propTypes = { 113 | courseId: PropTypes.string.isRequired, 114 | addLesson: PropTypes.func.isRequired 115 | } -------------------------------------------------------------------------------- /client/core/Home.js: -------------------------------------------------------------------------------- 1 | import React, {useState, useEffect} from 'react' 2 | import { makeStyles } from '@material-ui/core/styles' 3 | import Card from '@material-ui/core/Card' 4 | import Divider from '@material-ui/core/Divider' 5 | import {listPublished} from './../course/api-course' 6 | import {listEnrolled, listCompleted} from './../enrollment/api-enrollment' 7 | import Typography from '@material-ui/core/Typography' 8 | import auth from './../auth/auth-helper' 9 | import Courses from './../course/Courses' 10 | import Enrollments from '../enrollment/Enrollments' 11 | 12 | 13 | const useStyles = makeStyles(theme => ({ 14 | card: { 15 | width:'90%', 16 | margin: 'auto', 17 | marginTop: 20, 18 | marginBottom: theme.spacing(2), 19 | padding: 20, 20 | backgroundColor: '#ffffff' 21 | }, 22 | extraTop: { 23 | marginTop: theme.spacing(12) 24 | }, 25 | title: { 26 | padding:`${theme.spacing(3)}px ${theme.spacing(2.5)}px ${theme.spacing(2)}px`, 27 | color: theme.palette.openTitle 28 | }, 29 | media: { 30 | minHeight: 400 31 | }, 32 | gridList: { 33 | width: '100%', 34 | minHeight: 200, 35 | padding: '16px 0 10px' 36 | }, 37 | tile: { 38 | textAlign: 'center' 39 | }, 40 | image: { 41 | height: '100%' 42 | }, 43 | tileBar: { 44 | backgroundColor: 'rgba(0, 0, 0, 0.72)', 45 | textAlign: 'left' 46 | }, 47 | enrolledTitle: { 48 | color:'#efefef', 49 | marginBottom: 5 50 | }, 51 | action:{ 52 | margin: '0 10px' 53 | }, 54 | enrolledCard: { 55 | backgroundColor: '#616161', 56 | }, 57 | divider: { 58 | marginBottom: 16, 59 | backgroundColor: 'rgb(157, 157, 157)' 60 | }, 61 | noTitle: { 62 | color: 'lightgrey', 63 | marginBottom: 12, 64 | marginLeft: 8 65 | } 66 | })) 67 | 68 | export default function Home(){ 69 | const classes = useStyles() 70 | const jwt = auth.isAuthenticated() 71 | const [courses, setCourses] = useState([]) 72 | const [enrolled, setEnrolled] = useState([]) 73 | useEffect(() => { 74 | const abortController = new AbortController() 75 | const signal = abortController.signal 76 | listEnrolled({t: jwt.token}, signal).then((data) => { 77 | if (data.error) { 78 | console.log(data.error) 79 | } else { 80 | setEnrolled(data) 81 | } 82 | }) 83 | return function cleanup(){ 84 | abortController.abort() 85 | } 86 | }, []) 87 | useEffect(() => { 88 | const abortController = new AbortController() 89 | const signal = abortController.signal 90 | listPublished(signal).then((data) => { 91 | if (data.error) { 92 | console.log(data.error) 93 | } else { 94 | setCourses(data) 95 | } 96 | }) 97 | return function cleanup(){ 98 | abortController.abort() 99 | } 100 | }, []) 101 | return (
102 | {auth.isAuthenticated().user && ( 103 | 104 | 105 | Courses you are enrolled in 106 | 107 | {enrolled.length != 0 ? () 108 | : (No courses.) 109 | } 110 | 111 | )} 112 | 113 | 114 | All Courses 115 | 116 | {(courses.length != 0 && courses.length != enrolled.length) ? () 117 | : (No new courses.) 118 | } 119 | 120 |
121 | ) 122 | } 123 | 124 | -------------------------------------------------------------------------------- /client/course/api-course.js: -------------------------------------------------------------------------------- 1 | const create = async (params, credentials, course) => { 2 | try { 3 | let response = await fetch('/api/courses/by/'+ params.userId, { 4 | method: 'POST', 5 | headers: { 6 | 'Accept': 'application/json', 7 | 'Authorization': 'Bearer ' + credentials.t 8 | }, 9 | body: course 10 | }) 11 | return response.json() 12 | } catch(err) { 13 | console.log(err) 14 | } 15 | } 16 | 17 | const list = async (signal) => { 18 | try { 19 | let response = await fetch('/api/courses/', { 20 | method: 'GET', 21 | signal: signal, 22 | }) 23 | return await response.json() 24 | } catch(err) { 25 | console.log(err) 26 | } 27 | } 28 | 29 | const read = async (params, signal) => { 30 | try { 31 | let response = await fetch('/api/courses/' + params.courseId, { 32 | method: 'GET', 33 | signal: signal, 34 | headers: { 35 | 'Accept': 'application/json', 36 | 'Content-Type': 'application/json', 37 | } 38 | }) 39 | return await response.json() 40 | } catch(err) { 41 | console.log(err) 42 | } 43 | } 44 | 45 | const update = async (params, credentials, course) => { 46 | try { 47 | let response = await fetch('/api/courses/' + params.courseId, { 48 | method: 'PUT', 49 | headers: { 50 | 'Accept': 'application/json', 51 | 'Authorization': 'Bearer ' + credentials.t 52 | }, 53 | body: course 54 | }) 55 | return await response.json() 56 | } catch(err) { 57 | console.log(err) 58 | } 59 | } 60 | 61 | const remove = async (params, credentials) => { 62 | try { 63 | let response = await fetch('/api/courses/' + params.courseId, { 64 | method: 'DELETE', 65 | headers: { 66 | 'Accept': 'application/json', 67 | 'Content-Type': 'application/json', 68 | 'Authorization': 'Bearer ' + credentials.t 69 | } 70 | }) 71 | return await response.json() 72 | } catch(err) { 73 | console.log(err) 74 | } 75 | } 76 | 77 | const listByInstructor = async (params, credentials, signal) => { 78 | try { 79 | let response = await fetch('/api/courses/by/'+params.userId, { 80 | method: 'GET', 81 | signal: signal, 82 | headers: { 83 | 'Accept': 'application/json', 84 | 'Authorization': 'Bearer ' + credentials.t 85 | } 86 | }) 87 | return response.json() 88 | } catch(err){ 89 | console.log(err) 90 | } 91 | } 92 | 93 | const newLesson = async (params, credentials, lesson) => { 94 | try { 95 | let response = await fetch('/api/courses/'+params.courseId+'/lesson/new', { 96 | method: 'PUT', 97 | headers: { 98 | 'Accept': 'application/json', 99 | 'Content-Type': 'application/json', 100 | 'Authorization': 'Bearer ' + credentials.t 101 | }, 102 | body: JSON.stringify({lesson:lesson}) 103 | }) 104 | return response.json() 105 | } catch(err){ 106 | console.log(err) 107 | } 108 | } 109 | const listPublished = async (signal) => { 110 | try { 111 | let response = await fetch('/api/courses/published', { 112 | method: 'GET', 113 | signal: signal, 114 | headers: { 115 | 'Accept': 'application/json', 116 | 'Content-Type': 'application/json', 117 | } 118 | }) 119 | return await response.json() 120 | } catch(err) { 121 | console.log(err) 122 | } 123 | } 124 | export { 125 | create, 126 | list, 127 | read, 128 | update, 129 | remove, 130 | listByInstructor, 131 | newLesson, 132 | listPublished 133 | } -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # MERN Classroom 2 | 3 | A simple web-based classroom application that allows instructors to add courses with lessons, while students can enroll in these courses and track their progress. - developed using React, Node, Express and MongoDB. 4 | 5 | ![MERN Skeleton](https://mernbook.s3.amazonaws.com/git+/classroom.png "MERN Skeleton") 6 | 7 | ### [Live Demo](http://classroom.mernbook.com/ "MERN Classroom") 8 | 9 | #### What you need to run this code 10 | 1. Node (13.12.0) 11 | 2. NPM (6.14.4) or Yarn (1.22.4) 12 | 3. MongoDB (4.2.0) 13 | 14 | #### How to run this code 15 | 1. Make sure MongoDB is running on your system 16 | 2. Clone this repository 17 | 3. Open command line in the cloned folder, 18 | - To install dependencies, run ``` npm install ``` or ``` yarn ``` 19 | - To run the application for development, run ``` npm run development ``` or ``` yarn development ``` 20 | 4. Open [localhost:3000](http://localhost:3000/) in the browser 21 | ---- 22 | ### More applications built using this stack 23 | 24 | * [MERN Skeleton](https://github.com/shamahoque/mern-social/tree/second-edition) 25 | * [MERN Social](https://github.com/shamahoque/mern-social/tree/second-edition) 26 | * [MERN Marketplace](https://github.com/shamahoque/mern-marketplace/tree/second-edition) 27 | * [MERN Expense Tracker](https://github.com/shamahoque/mern-expense-tracker) 28 | * [MERN Mediastream](https://github.com/shamahoque/mern-mediastream/tree/second-edition) 29 | * [MERN VR Game](https://github.com/shamahoque/mern-vrgame/tree/second-edition) 30 | 31 | Learn more at [mernbook.com](http://www.mernbook.com/) 32 | 33 | ---- 34 | ## Get the book 35 | #### [Full-Stack React Projects - Second Edition](https://www.packtpub.com/web-development/full-stack-react-projects-second-edition) 36 | *Learn MERN stack development by building modern web apps using MongoDB, Express, React, and Node.js* 37 | 38 | Full-Stack React Projects 39 | 40 | React combined with industry-tested, server-side technologies, such as Node, Express, and MongoDB, enables you to develop and deploy robust real-world full-stack web apps. This updated second edition focuses on the latest versions and conventions of the technologies in this stack, along with their new features such as Hooks in React and async/await in JavaScript. The book also explores advanced topics such as implementing real-time bidding, a web-based classroom app, and data visualization in an expense tracking app. 41 | 42 | Full-Stack React Projects will take you through the process of preparing the development environment for MERN stack-based web development, creating a basic skeleton app, and extending it to build six different web apps. You'll build apps for social media, classrooms, media streaming, online marketplaces with real-time bidding, and web-based games with virtual reality features. Throughout the book, you'll learn how MERN stack web development works, extend its capabilities for complex features, and gain actionable insights into creating MERN-based apps, along with exploring industry best practices to meet the ever-increasing demands of the real world. 43 | 44 | Things you'll learn in this book: 45 | 46 | - Extend a MERN-based application to build a variety of applications 47 | - Add real-time communication capabilities with Socket.IO 48 | - Implement data visualization features for React applications using Victory 49 | - Develop media streaming applications using MongoDB GridFS 50 | - Improve SEO for your MERN apps by implementing server-side rendering with data 51 | - Implement user authentication and authorization using JSON web tokens 52 | - Set up and use React 360 to develop user interfaces with VR capabilities 53 | - Make your MERN stack applications reliable and scalable with industry best practices 54 | 55 | If you feel this book is for you, get your [copy](https://www.amazon.com/dp/1839215410) today! 56 | 57 | --- 58 | -------------------------------------------------------------------------------- /client/user/Signup.js: -------------------------------------------------------------------------------- 1 | import React, {useState} from 'react' 2 | import Card from '@material-ui/core/Card' 3 | import CardActions from '@material-ui/core/CardActions' 4 | import CardContent from '@material-ui/core/CardContent' 5 | import Button from '@material-ui/core/Button' 6 | import TextField from '@material-ui/core/TextField' 7 | import Typography from '@material-ui/core/Typography' 8 | import Icon from '@material-ui/core/Icon' 9 | import { makeStyles } from '@material-ui/core/styles' 10 | import {create} from './api-user.js' 11 | import Dialog from '@material-ui/core/Dialog' 12 | import DialogActions from '@material-ui/core/DialogActions' 13 | import DialogContent from '@material-ui/core/DialogContent' 14 | import DialogContentText from '@material-ui/core/DialogContentText' 15 | import DialogTitle from '@material-ui/core/DialogTitle' 16 | import {Link} from 'react-router-dom' 17 | 18 | const useStyles = makeStyles(theme => ({ 19 | card: { 20 | maxWidth: 600, 21 | margin: 'auto', 22 | textAlign: 'center', 23 | marginTop: theme.spacing(12), 24 | paddingBottom: theme.spacing(2) 25 | }, 26 | error: { 27 | verticalAlign: 'middle' 28 | }, 29 | title: { 30 | marginTop: theme.spacing(2), 31 | color: theme.palette.openTitle 32 | }, 33 | textField: { 34 | marginLeft: theme.spacing(1), 35 | marginRight: theme.spacing(1), 36 | width: 300 37 | }, 38 | submit: { 39 | margin: 'auto', 40 | marginBottom: theme.spacing(2) 41 | } 42 | })) 43 | 44 | export default function Signup() { 45 | const classes = useStyles() 46 | const [values, setValues] = useState({ 47 | name: '', 48 | password: '', 49 | email: '', 50 | open: false, 51 | error: '' 52 | }) 53 | 54 | const handleChange = name => event => { 55 | setValues({ ...values, [name]: event.target.value }) 56 | } 57 | 58 | const clickSubmit = () => { 59 | const user = { 60 | name: values.name || undefined, 61 | email: values.email || undefined, 62 | password: values.password || undefined 63 | } 64 | create(user).then((data) => { 65 | if (data.error) { 66 | setValues({ ...values, error: data.error}) 67 | } else { 68 | setValues({ ...values, error: '', open: true}) 69 | } 70 | }) 71 | } 72 | 73 | return (
74 | 75 | 76 | 77 | Sign Up 78 | 79 |
80 |
81 | 82 |
{ 83 | values.error && ( 84 | error 85 | {values.error}) 86 | } 87 |
88 | 89 | 90 | 91 |
92 | 93 | New Account 94 | 95 | 96 | New account successfully created. 97 | 98 | 99 | 100 | 101 | 104 | 105 | 106 | 107 |
108 | ) 109 | } -------------------------------------------------------------------------------- /server/controllers/enrollment.controller.js: -------------------------------------------------------------------------------- 1 | import Enrollment from '../models/enrollment.model' 2 | import errorHandler from './../helpers/dbErrorHandler' 3 | 4 | const create = async (req, res) => { 5 | let newEnrollment = { 6 | course: req.course, 7 | student: req.auth, 8 | } 9 | newEnrollment.lessonStatus = req.course.lessons.map((lesson)=>{ 10 | return {lesson: lesson, complete:false} 11 | }) 12 | const enrollment = new Enrollment(newEnrollment) 13 | try { 14 | let result = await enrollment.save() 15 | return res.status(200).json(result) 16 | } catch (err) { 17 | return res.status(400).json({ 18 | error: errorHandler.getErrorMessage(err) 19 | }) 20 | } 21 | } 22 | 23 | /** 24 | * Load enrollment and append to req. 25 | */ 26 | const enrollmentByID = async (req, res, next, id) => { 27 | try { 28 | let enrollment = await Enrollment.findById(id) 29 | .populate({path: 'course', populate:{ path: 'instructor'}}) 30 | .populate('student', '_id name') 31 | if (!enrollment) 32 | return res.status('400').json({ 33 | error: "Enrollment not found" 34 | }) 35 | req.enrollment = enrollment 36 | next() 37 | } catch (err) { 38 | return res.status('400').json({ 39 | error: "Could not retrieve enrollment" 40 | }) 41 | } 42 | } 43 | 44 | const read = (req, res) => { 45 | return res.json(req.enrollment) 46 | } 47 | 48 | const complete = async (req, res) => { 49 | let updatedData = {} 50 | updatedData['lessonStatus.$.complete']= req.body.complete 51 | updatedData.updated = Date.now() 52 | if(req.body.courseCompleted) 53 | updatedData.completed = req.body.courseCompleted 54 | 55 | try { 56 | let enrollment = await Enrollment.updateOne({'lessonStatus._id':req.body.lessonStatusId}, {'$set': updatedData}) 57 | res.json(enrollment) 58 | } catch (err) { 59 | return res.status(400).json({ 60 | error: errorHandler.getErrorMessage(err) 61 | }) 62 | } 63 | } 64 | 65 | const remove = async (req, res) => { 66 | try { 67 | let enrollment = req.enrollment 68 | let deletedEnrollment = await enrollment.remove() 69 | res.json(deletedEnrollment) 70 | } catch (err) { 71 | return res.status(400).json({ 72 | error: errorHandler.getErrorMessage(err) 73 | }) 74 | } 75 | } 76 | 77 | const isStudent = (req, res, next) => { 78 | const isStudent = req.auth && req.auth._id == req.enrollment.student._id 79 | if (!isStudent) { 80 | return res.status('403').json({ 81 | error: "User is not enrolled" 82 | }) 83 | } 84 | next() 85 | } 86 | 87 | const listEnrolled = async (req, res) => { 88 | try { 89 | let enrollments = await Enrollment.find({student: req.auth._id}).sort({'completed': 1}).populate('course', '_id name category') 90 | res.json(enrollments) 91 | } catch (err) { 92 | console.log(err) 93 | return res.status(400).json({ 94 | error: errorHandler.getErrorMessage(err) 95 | }) 96 | } 97 | } 98 | 99 | const findEnrollment = async (req, res, next) => { 100 | try { 101 | let enrollments = await Enrollment.find({course:req.course._id, student: req.auth._id}) 102 | if(enrollments.length == 0){ 103 | next() 104 | }else{ 105 | res.json(enrollments[0]) 106 | } 107 | } catch (err) { 108 | return res.status(400).json({ 109 | error: errorHandler.getErrorMessage(err) 110 | }) 111 | } 112 | } 113 | 114 | const enrollmentStats = async (req, res) => { 115 | try { 116 | let stats = {} 117 | stats.totalEnrolled = await Enrollment.find({course:req.course._id}).countDocuments() 118 | stats.totalCompleted = await Enrollment.find({course:req.course._id}).exists('completed', true).countDocuments() 119 | res.json(stats) 120 | } catch (err) { 121 | return res.status(400).json({ 122 | error: errorHandler.getErrorMessage(err) 123 | }) 124 | } 125 | } 126 | 127 | export default { 128 | create, 129 | enrollmentByID, 130 | read, 131 | remove, 132 | complete, 133 | isStudent, 134 | listEnrolled, 135 | findEnrollment, 136 | enrollmentStats 137 | } 138 | -------------------------------------------------------------------------------- /client/course/NewCourse.js: -------------------------------------------------------------------------------- 1 | import React, {useState} from 'react' 2 | import Card from '@material-ui/core/Card' 3 | import CardActions from '@material-ui/core/CardActions' 4 | import CardContent from '@material-ui/core/CardContent' 5 | import Button from '@material-ui/core/Button' 6 | import FileUpload from '@material-ui/icons/AddPhotoAlternate' 7 | import auth from './../auth/auth-helper' 8 | import TextField from '@material-ui/core/TextField' 9 | import Typography from '@material-ui/core/Typography' 10 | import Icon from '@material-ui/core/Icon' 11 | import { makeStyles } from '@material-ui/core/styles' 12 | import {create} from './api-course.js' 13 | import {Link, Redirect} from 'react-router-dom' 14 | 15 | const useStyles = makeStyles(theme => ({ 16 | card: { 17 | maxWidth: 600, 18 | margin: 'auto', 19 | textAlign: 'center', 20 | marginTop: theme.spacing(12), 21 | paddingBottom: theme.spacing(2) 22 | }, 23 | error: { 24 | verticalAlign: 'middle' 25 | }, 26 | title: { 27 | marginTop: theme.spacing(2), 28 | color: theme.palette.openTitle 29 | }, 30 | textField: { 31 | marginLeft: theme.spacing(1), 32 | marginRight: theme.spacing(1), 33 | width: 300 34 | }, 35 | submit: { 36 | margin: 'auto', 37 | marginBottom: theme.spacing(2) 38 | }, 39 | input: { 40 | display: 'none' 41 | }, 42 | filename:{ 43 | marginLeft:'10px' 44 | } 45 | })) 46 | 47 | export default function NewCourse() { 48 | const classes = useStyles() 49 | const [values, setValues] = useState({ 50 | name: '', 51 | description: '', 52 | image: '', 53 | category: '', 54 | redirect: false, 55 | error: '' 56 | }) 57 | const jwt = auth.isAuthenticated() 58 | 59 | const handleChange = name => event => { 60 | const value = name === 'image' 61 | ? event.target.files[0] 62 | : event.target.value 63 | setValues({...values, [name]: value }) 64 | } 65 | const clickSubmit = () => { 66 | let courseData = new FormData() 67 | values.name && courseData.append('name', values.name) 68 | values.description && courseData.append('description', values.description) 69 | values.image && courseData.append('image', values.image) 70 | values.category && courseData.append('category', values.category) 71 | create({ 72 | userId: jwt.user._id 73 | }, { 74 | t: jwt.token 75 | }, courseData).then((data) => { 76 | if (data.error) { 77 | setValues({...values, error: data.error}) 78 | } else { 79 | setValues({...values, error: '', redirect: true}) 80 | } 81 | }) 82 | } 83 | 84 | if (values.redirect) { 85 | return () 86 | } 87 | return (
88 | 89 | 90 | 91 | New Course 92 | 93 |
94 | 95 | {values.image ? values.image.name : ''}
101 |
102 |
112 |
113 | { 114 | values.error && ( 115 | error 116 | {values.error}) 117 | } 118 |
119 | 120 | 121 | 122 | 123 |
124 |
) 125 | } 126 | -------------------------------------------------------------------------------- /client/user/EditProfile.js: -------------------------------------------------------------------------------- 1 | import React, {useState, useEffect} from 'react' 2 | import Card from '@material-ui/core/Card' 3 | import CardActions from '@material-ui/core/CardActions' 4 | import CardContent from '@material-ui/core/CardContent' 5 | import Button from '@material-ui/core/Button' 6 | import TextField from '@material-ui/core/TextField' 7 | import Typography from '@material-ui/core/Typography' 8 | import Icon from '@material-ui/core/Icon' 9 | import FormControlLabel from '@material-ui/core/FormControlLabel' 10 | import Switch from '@material-ui/core/Switch' 11 | import { makeStyles } from '@material-ui/core/styles' 12 | import auth from './../auth/auth-helper' 13 | import {read, update} from './api-user.js' 14 | import {Redirect} from 'react-router-dom' 15 | 16 | const useStyles = makeStyles(theme => ({ 17 | card: { 18 | maxWidth: 600, 19 | margin: 'auto', 20 | textAlign: 'center', 21 | marginTop: theme.spacing(12), 22 | paddingBottom: theme.spacing(2) 23 | }, 24 | title: { 25 | margin: theme.spacing(2), 26 | color: theme.palette.protectedTitle 27 | }, 28 | error: { 29 | verticalAlign: 'middle' 30 | }, 31 | textField: { 32 | marginLeft: theme.spacing(1), 33 | marginRight: theme.spacing(1), 34 | width: 300 35 | }, 36 | submit: { 37 | margin: 'auto', 38 | marginBottom: theme.spacing(2) 39 | } 40 | })) 41 | 42 | export default function EditProfile({ match }) { 43 | const classes = useStyles() 44 | const [values, setValues] = useState({ 45 | name: '', 46 | password: '', 47 | email: '', 48 | open: false, 49 | error: '', 50 | redirectToProfile: false, 51 | educator: false 52 | }) 53 | const jwt = auth.isAuthenticated() 54 | 55 | useEffect(() => { 56 | const abortController = new AbortController() 57 | const signal = abortController.signal 58 | 59 | read({ 60 | userId: match.params.userId 61 | }, {t: jwt.token}, signal).then((data) => { 62 | if (data && data.error) { 63 | setValues({...values, error: data.error}) 64 | } else { 65 | setValues({...values, name: data.name, email: data.email, educator: data.educator}) 66 | } 67 | }) 68 | return function cleanup(){ 69 | abortController.abort() 70 | } 71 | 72 | }, [match.params.userId]) 73 | 74 | const clickSubmit = () => { 75 | const user = { 76 | name: values.name || undefined, 77 | email: values.email || undefined, 78 | password: values.password || undefined, 79 | educator: values.educator 80 | } 81 | update({ 82 | userId: match.params.userId 83 | }, { 84 | t: jwt.token 85 | }, user).then((data) => { 86 | if (data && data.error) { 87 | setValues({...values, error: data.error}) 88 | } else { 89 | auth.updateUser(data, ()=>{ 90 | setValues({...values, userId: data._id, redirectToProfile: true}) 91 | }) 92 | } 93 | }) 94 | } 95 | const handleChange = name => event => { 96 | setValues({...values, [name]: event.target.value}) 97 | } 98 | const handleCheck = (event, checked) => { 99 | setValues({...values, educator: checked}) 100 | } 101 | 102 | if (values.redirectToProfile) { 103 | return () 104 | } 105 | return ( 106 | 107 | 108 | 109 | Edit Profile 110 | 111 |
112 |
113 |
114 |
115 | 116 | I am an Educator 117 | 118 | } 127 | label={values.educator? 'Yes' : 'No'} 128 | /> 129 |
{ 130 | values.error && ( 131 | error 132 | {values.error} 133 | ) 134 | } 135 |
136 | 137 | 138 | 139 |
140 | ) 141 | } 142 | 143 | -------------------------------------------------------------------------------- /server/controllers/course.controller.js: -------------------------------------------------------------------------------- 1 | import Course from '../models/course.model' 2 | import extend from 'lodash/extend' 3 | import fs from 'fs' 4 | import errorHandler from './../helpers/dbErrorHandler' 5 | import formidable from 'formidable' 6 | import defaultImage from './../../client/assets/images/default.png' 7 | 8 | const create = (req, res) => { 9 | let form = new formidable.IncomingForm() 10 | form.keepExtensions = true 11 | form.parse(req, async (err, fields, files) => { 12 | if (err) { 13 | return res.status(400).json({ 14 | error: "Image could not be uploaded" 15 | }) 16 | } 17 | let course = new Course(fields) 18 | course.instructor= req.profile 19 | if(files.image){ 20 | course.image.data = fs.readFileSync(files.image.path) 21 | course.image.contentType = files.image.type 22 | } 23 | try { 24 | let result = await course.save() 25 | res.json(result) 26 | }catch (err){ 27 | return res.status(400).json({ 28 | error: errorHandler.getErrorMessage(err) 29 | }) 30 | } 31 | }) 32 | } 33 | 34 | /** 35 | * Load course and append to req. 36 | */ 37 | const courseByID = async (req, res, next, id) => { 38 | try { 39 | let course = await Course.findById(id).populate('instructor', '_id name') 40 | if (!course) 41 | return res.status('400').json({ 42 | error: "Course not found" 43 | }) 44 | req.course = course 45 | next() 46 | } catch (err) { 47 | return res.status('400').json({ 48 | error: "Could not retrieve course" 49 | }) 50 | } 51 | } 52 | 53 | const read = (req, res) => { 54 | req.course.image = undefined 55 | return res.json(req.course) 56 | } 57 | 58 | const list = async (req, res) => { 59 | try { 60 | let courses = await Course.find().select('name email updated created') 61 | res.json(courses) 62 | } catch (err) { 63 | return res.status(400).json({ 64 | error: errorHandler.getErrorMessage(err) 65 | }) 66 | } 67 | } 68 | 69 | const update = async (req, res) => { 70 | let form = new formidable.IncomingForm() 71 | form.keepExtensions = true 72 | form.parse(req, async (err, fields, files) => { 73 | if (err) { 74 | return res.status(400).json({ 75 | error: "Photo could not be uploaded" 76 | }) 77 | } 78 | let course = req.course 79 | course = extend(course, fields) 80 | if(fields.lessons){ 81 | course.lessons = JSON.parse(fields.lessons) 82 | } 83 | course.updated = Date.now() 84 | if(files.image){ 85 | course.image.data = fs.readFileSync(files.image.path) 86 | course.image.contentType = files.image.type 87 | } 88 | try { 89 | await course.save() 90 | res.json(course) 91 | } catch (err) { 92 | return res.status(400).json({ 93 | error: errorHandler.getErrorMessage(err) 94 | }) 95 | } 96 | }) 97 | } 98 | 99 | const newLesson = async (req, res) => { 100 | try { 101 | let lesson = req.body.lesson 102 | let result = await Course.findByIdAndUpdate(req.course._id, {$push: {lessons: lesson}, updated: Date.now()}, {new: true}) 103 | .populate('instructor', '_id name') 104 | .exec() 105 | res.json(result) 106 | } catch (err) { 107 | return res.status(400).json({ 108 | error: errorHandler.getErrorMessage(err) 109 | }) 110 | } 111 | } 112 | 113 | const remove = async (req, res) => { 114 | try { 115 | let course = req.course 116 | let deleteCourse = await course.remove() 117 | res.json(deleteCourse) 118 | } catch (err) { 119 | return res.status(400).json({ 120 | error: errorHandler.getErrorMessage(err) 121 | }) 122 | } 123 | } 124 | 125 | const isInstructor = (req, res, next) => { 126 | const isInstructor = req.course && req.auth && req.course.instructor._id == req.auth._id 127 | if(!isInstructor){ 128 | return res.status('403').json({ 129 | error: "User is not authorized" 130 | }) 131 | } 132 | next() 133 | } 134 | 135 | const listByInstructor = (req, res) => { 136 | Course.find({instructor: req.profile._id}, (err, courses) => { 137 | if (err) { 138 | return res.status(400).json({ 139 | error: errorHandler.getErrorMessage(err) 140 | }) 141 | } 142 | res.json(courses) 143 | }).populate('instructor', '_id name') 144 | } 145 | 146 | const listPublished = (req, res) => { 147 | Course.find({published: true}, (err, courses) => { 148 | if (err) { 149 | return res.status(400).json({ 150 | error: errorHandler.getErrorMessage(err) 151 | }) 152 | } 153 | res.json(courses) 154 | }).populate('instructor', '_id name') 155 | } 156 | 157 | const photo = (req, res, next) => { 158 | if(req.course.image.data){ 159 | res.set("Content-Type", req.course.image.contentType) 160 | return res.send(req.course.image.data) 161 | } 162 | next() 163 | } 164 | const defaultPhoto = (req, res) => { 165 | return res.sendFile(process.cwd()+defaultImage) 166 | } 167 | 168 | 169 | export default { 170 | create, 171 | courseByID, 172 | read, 173 | list, 174 | remove, 175 | update, 176 | isInstructor, 177 | listByInstructor, 178 | photo, 179 | defaultPhoto, 180 | newLesson, 181 | listPublished 182 | } 183 | -------------------------------------------------------------------------------- /client/course/Course.js: -------------------------------------------------------------------------------- 1 | import React, {useState, useEffect} from 'react' 2 | import Card from '@material-ui/core/Card' 3 | import CardHeader from '@material-ui/core/CardHeader' 4 | import CardMedia from '@material-ui/core/CardMedia' 5 | import Typography from '@material-ui/core/Typography' 6 | import IconButton from '@material-ui/core/IconButton' 7 | import Edit from '@material-ui/icons/Edit' 8 | import PeopleIcon from '@material-ui/icons/Group' 9 | import CompletedIcon from '@material-ui/icons/VerifiedUser' 10 | import Button from '@material-ui/core/Button' 11 | import {makeStyles} from '@material-ui/core/styles' 12 | import List from '@material-ui/core/List' 13 | import ListItem from '@material-ui/core/ListItem' 14 | import ListItemAvatar from '@material-ui/core/ListItemAvatar' 15 | import Avatar from '@material-ui/core/Avatar' 16 | import ListItemText from '@material-ui/core/ListItemText' 17 | import {read, update} from './api-course.js' 18 | import {enrollmentStats} from './../enrollment/api-enrollment' 19 | import {Link, Redirect} from 'react-router-dom' 20 | import auth from './../auth/auth-helper' 21 | import DeleteCourse from './DeleteCourse' 22 | import Divider from '@material-ui/core/Divider' 23 | import NewLesson from './NewLesson' 24 | import Dialog from '@material-ui/core/Dialog' 25 | import DialogActions from '@material-ui/core/DialogActions' 26 | import DialogContent from '@material-ui/core/DialogContent' 27 | import DialogTitle from '@material-ui/core/DialogTitle' 28 | import Enroll from './../enrollment/Enroll' 29 | 30 | const useStyles = makeStyles(theme => ({ 31 | root: theme.mixins.gutters({ 32 | maxWidth: 800, 33 | margin: 'auto', 34 | padding: theme.spacing(3), 35 | marginTop: theme.spacing(12) 36 | }), 37 | flex:{ 38 | display:'flex', 39 | marginBottom: 20 40 | }, 41 | card: { 42 | padding:'24px 40px 40px' 43 | }, 44 | subheading: { 45 | margin: '10px', 46 | color: theme.palette.openTitle 47 | }, 48 | details: { 49 | margin: '16px', 50 | }, 51 | sub: { 52 | display: 'block', 53 | margin: '3px 0px 5px 0px', 54 | fontSize: '0.9em' 55 | }, 56 | media: { 57 | height: 190, 58 | display: 'inline-block', 59 | width: '100%', 60 | marginLeft: '16px' 61 | }, 62 | icon: { 63 | verticalAlign: 'sub' 64 | }, 65 | category:{ 66 | color: '#5c5c5c', 67 | fontSize: '0.9em', 68 | padding: '3px 5px', 69 | backgroundColor: '#dbdbdb', 70 | borderRadius: '0.2em', 71 | marginTop: 5 72 | }, 73 | action: { 74 | margin: '10px 0px', 75 | display: 'flex', 76 | justifyContent: 'flex-end' 77 | }, 78 | statSpan: { 79 | margin: '7px 10px 0 10px', 80 | alignItems: 'center', 81 | color: '#616161', 82 | display: 'inline-flex', 83 | '& svg': { 84 | marginRight: 10, 85 | color: '#b6ab9a' 86 | } 87 | }, 88 | enroll:{ 89 | float: 'right' 90 | } 91 | })) 92 | 93 | export default function Course ({match}) { 94 | const classes = useStyles() 95 | const [stats, setStats] = useState({}) 96 | const [course, setCourse] = useState({instructor:{}}) 97 | const [values, setValues] = useState({ 98 | redirect: false, 99 | error: '' 100 | }) 101 | const [open, setOpen] = useState(false) 102 | const jwt = auth.isAuthenticated() 103 | useEffect(() => { 104 | const abortController = new AbortController() 105 | const signal = abortController.signal 106 | 107 | read({courseId: match.params.courseId}, signal).then((data) => { 108 | if (data.error) { 109 | setValues({...values, error: data.error}) 110 | } else { 111 | setCourse(data) 112 | } 113 | }) 114 | return function cleanup(){ 115 | abortController.abort() 116 | } 117 | }, [match.params.courseId]) 118 | useEffect(() => { 119 | const abortController = new AbortController() 120 | const signal = abortController.signal 121 | 122 | enrollmentStats({courseId: match.params.courseId}, {t:jwt.token}, signal).then((data) => { 123 | if (data.error) { 124 | setValues({...values, error: data.error}) 125 | } else { 126 | setStats(data) 127 | } 128 | }) 129 | return function cleanup(){ 130 | abortController.abort() 131 | } 132 | }, [match.params.courseId]) 133 | const removeCourse = (course) => { 134 | setValues({...values, redirect:true}) 135 | } 136 | const addLesson = (course) => { 137 | setCourse(course) 138 | } 139 | const clickPublish = () => { 140 | if(course.lessons.length > 0){ 141 | setOpen(true) 142 | } 143 | } 144 | const publish = () => { 145 | let courseData = new FormData() 146 | courseData.append('published', true) 147 | update({ 148 | courseId: match.params.courseId 149 | }, { 150 | t: jwt.token 151 | }, courseData).then((data) => { 152 | if (data && data.error) { 153 | setValues({...values, error: data.error}) 154 | } else { 155 | setCourse({...course, published: true}) 156 | setOpen(false) 157 | } 158 | }) 159 | } 160 | const handleClose = () => { 161 | setOpen(false) 162 | } 163 | if (values.redirect) { 164 | return () 165 | } 166 | const imageUrl = course._id 167 | ? `/api/courses/photo/${course._id}?${new Date().getTime()}` 168 | : '/api/courses/defaultphoto' 169 | return ( 170 |
171 | 172 | 175 | By {course.instructor.name} 176 | {course.category} 177 |
178 | } 179 | action={<> 180 | {auth.isAuthenticated().user && auth.isAuthenticated().user._id == course.instructor._id && 181 | ( 182 | 183 | 184 | 185 | 186 | 187 | {!course.published ? (<> 188 | 189 | 190 | ) : ( 191 | 192 | )} 193 | ) 194 | } 195 | {course.published && (
196 | {stats.totalEnrolled} enrolled 197 | {stats.totalCompleted} completed 198 |
199 | )} 200 | 201 | 202 | } 203 | /> 204 |
205 | 210 |
211 | 212 | {course.description}
213 |
214 | 215 | {course.published &&
} 216 | 217 | 218 |
219 |
220 | 221 |
222 | Lessons 224 | } 225 | subheader={{course.lessons && course.lessons.length} lessons} 226 | action={ 227 | auth.isAuthenticated().user && auth.isAuthenticated().user._id == course.instructor._id && !course.published && 228 | ( 229 | 230 | ) 231 | } 232 | /> 233 | 234 | {course.lessons && course.lessons.map((lesson, index) => { 235 | return( 236 | 237 | 238 | 239 | {index+1} 240 | 241 | 242 | 245 | 246 | 247 | ) 248 | } 249 | )} 250 | 251 |
252 | 253 | 254 | Publish Course 255 | 256 | Publishing your course will make it live to students for enrollment. Make sure all lessons are added and ready for publishing. 257 | 258 | 261 | 264 | 265 | 266 | ) 267 | } 268 | -------------------------------------------------------------------------------- /client/course/EditCourse.js: -------------------------------------------------------------------------------- 1 | import React, {useState, useEffect} from 'react' 2 | import Card from '@material-ui/core/Card' 3 | import CardHeader from '@material-ui/core/CardHeader' 4 | import CardMedia from '@material-ui/core/CardMedia' 5 | import Typography from '@material-ui/core/Typography' 6 | import IconButton from '@material-ui/core/IconButton' 7 | import DeleteIcon from '@material-ui/icons/Delete' 8 | import FileUpload from '@material-ui/icons/AddPhotoAlternate' 9 | import ArrowUp from '@material-ui/icons/ArrowUpward' 10 | import Button from '@material-ui/core/Button' 11 | import {makeStyles} from '@material-ui/core/styles' 12 | import List from '@material-ui/core/List' 13 | import ListItem from '@material-ui/core/ListItem' 14 | import TextField from '@material-ui/core/TextField' 15 | import ListItemAvatar from '@material-ui/core/ListItemAvatar' 16 | import Avatar from '@material-ui/core/Avatar' 17 | import ListItemSecondaryAction from '@material-ui/core/ListItemSecondaryAction' 18 | import ListItemText from '@material-ui/core/ListItemText' 19 | import {read, update} from './api-course.js' 20 | import {Link, Redirect} from 'react-router-dom' 21 | import auth from './../auth/auth-helper' 22 | import Divider from '@material-ui/core/Divider' 23 | 24 | const useStyles = makeStyles(theme => ({ 25 | root: theme.mixins.gutters({ 26 | maxWidth: 800, 27 | margin: 'auto', 28 | padding: theme.spacing(3), 29 | marginTop: theme.spacing(12) 30 | }), 31 | flex:{ 32 | display:'flex', 33 | marginBottom: 20 34 | }, 35 | card: { 36 | padding:'24px 40px 40px' 37 | }, 38 | subheading: { 39 | margin: '10px', 40 | color: theme.palette.openTitle 41 | }, 42 | details: { 43 | margin: '16px', 44 | }, 45 | upArrow: { 46 | border: '2px solid #f57c00', 47 | marginLeft: 3, 48 | marginTop: 10, 49 | padding:4 50 | }, 51 | sub: { 52 | display: 'block', 53 | margin: '3px 0px 5px 0px', 54 | fontSize: '0.9em' 55 | }, 56 | media: { 57 | height: 250, 58 | display: 'inline-block', 59 | width: '50%', 60 | marginLeft: '16px' 61 | }, 62 | icon: { 63 | verticalAlign: 'sub' 64 | }, 65 | textfield:{ 66 | width: 350 67 | }, 68 | action: { 69 | margin: '8px 24px', 70 | display: 'inline-block' 71 | }, input: { 72 | display: 'none' 73 | }, 74 | filename:{ 75 | marginLeft:'10px' 76 | }, 77 | list: { 78 | backgroundColor: '#f3f3f3' 79 | } 80 | })) 81 | 82 | export default function EditCourse ({match}) { 83 | const classes = useStyles() 84 | const [course, setCourse] = useState({ 85 | name: '', 86 | description: '', 87 | image:'', 88 | category:'', 89 | instructor:{}, 90 | lessons: [] 91 | }) 92 | const [values, setValues] = useState({ 93 | redirect: false, 94 | error: '' 95 | }) 96 | useEffect(() => { 97 | const abortController = new AbortController() 98 | const signal = abortController.signal 99 | 100 | read({courseId: match.params.courseId}, signal).then((data) => { 101 | if (data.error) { 102 | setValues({...values, error: data.error}) 103 | } else { 104 | data.image = '' 105 | setCourse(data) 106 | } 107 | }) 108 | return function cleanup(){ 109 | abortController.abort() 110 | } 111 | }, [match.params.courseId]) 112 | const jwt = auth.isAuthenticated() 113 | const handleChange = name => event => { 114 | const value = name === 'image' 115 | ? event.target.files[0] 116 | : event.target.value 117 | setCourse({ ...course, [name]: value }) 118 | } 119 | const handleLessonChange = (name, index) => event => { 120 | const lessons = course.lessons 121 | lessons[index][name] = event.target.value 122 | setCourse({ ...course, lessons: lessons }) 123 | } 124 | const deleteLesson = index => event => { 125 | const lessons = course.lessons 126 | lessons.splice(index, 1) 127 | setCourse({...course, lessons:lessons}) 128 | } 129 | const moveUp = index => event => { 130 | const lessons = course.lessons 131 | const moveUp = lessons[index] 132 | lessons[index] = lessons[index-1] 133 | lessons[index-1] = moveUp 134 | setCourse({ ...course, lessons: lessons }) 135 | } 136 | const clickSubmit = () => { 137 | let courseData = new FormData() 138 | course.name && courseData.append('name', course.name) 139 | course.description && courseData.append('description', course.description) 140 | course.image && courseData.append('image', course.image) 141 | course.category && courseData.append('category', course.category) 142 | courseData.append('lessons', JSON.stringify(course.lessons)) 143 | update({ 144 | courseId: match.params.courseId 145 | }, { 146 | t: jwt.token 147 | }, courseData).then((data) => { 148 | if (data && data.error) { 149 | console.log(data.error) 150 | setValues({...values, error: data.error}) 151 | } else { 152 | setValues({...values, redirect: true}) 153 | } 154 | }) 155 | } 156 | if (values.redirect) { 157 | return () 158 | } 159 | const imageUrl = course._id 160 | ? `/api/courses/photo/${course._id}?${new Date().getTime()}` 161 | : '/api/courses/defaultphoto' 162 | return ( 163 |
164 | 165 | } 173 | subheader={
174 | By {course.instructor.name} 175 | {} 182 |
183 | } 184 | action={ 185 | auth.isAuthenticated().user && auth.isAuthenticated().user._id == course.instructor._id && 186 | ( 187 | ) 188 | } 189 | /> 190 |
191 | 196 |
197 |

206 | 207 | {course.image ? course.image.name : ''}
213 |
214 | 215 | 216 |
217 | 218 |
219 | Lessons - Edit and Rearrange 221 | } 222 | subheader={{course.lessons && course.lessons.length} lessons} 223 | /> 224 | 225 | {course.lessons && course.lessons.map((lesson, index) => { 226 | return( 227 | 228 | 229 | <> 230 | 231 | {index+1} 232 | 233 | { index != 0 && 234 | 235 | 236 | 237 | } 238 | 239 | 240 |
248 |
257 |
} 264 | /> 265 | {!course.published && 266 | 267 | 268 | 269 | } 270 |
271 | 272 |
) 273 | } 274 | )} 275 |
276 |
277 |
278 |
) 279 | } 280 | -------------------------------------------------------------------------------- /client/enrollment/Enrollment.js: -------------------------------------------------------------------------------- 1 | import React, {useState, useEffect} from 'react' 2 | import Card from '@material-ui/core/Card' 3 | import CardHeader from '@material-ui/core/CardHeader' 4 | import CardMedia from '@material-ui/core/CardMedia' 5 | import CardActions from '@material-ui/core/CardActions' 6 | import Typography from '@material-ui/core/Typography' 7 | import Button from '@material-ui/core/Button' 8 | import {makeStyles} from '@material-ui/core/styles' 9 | import List from '@material-ui/core/List' 10 | import ListItem from '@material-ui/core/ListItem' 11 | import ListItemAvatar from '@material-ui/core/ListItemAvatar' 12 | import ListItemSecondaryAction from '@material-ui/core/ListItemSecondaryAction' 13 | import ListSubheader from '@material-ui/core/ListSubheader' 14 | import Avatar from '@material-ui/core/Avatar' 15 | import ListItemIcon from '@material-ui/core/ListItemIcon' 16 | import ListItemText from '@material-ui/core/ListItemText' 17 | import {read, complete} from './api-enrollment.js' 18 | import {Link} from 'react-router-dom' 19 | import auth from './../auth/auth-helper' 20 | import Divider from '@material-ui/core/Divider' 21 | import Drawer from '@material-ui/core/Drawer' 22 | import Info from '@material-ui/icons/Info' 23 | import CheckCircle from '@material-ui/icons/CheckCircle' 24 | import RadioButtonUncheckedIcon from '@material-ui/icons/RadioButtonUnchecked' 25 | import { CardContent } from '@material-ui/core' 26 | 27 | 28 | const useStyles = makeStyles(theme => ({ 29 | root: theme.mixins.gutters({ 30 | maxWidth: 800, 31 | margin: 'auto', 32 | marginTop: theme.spacing(12), 33 | marginLeft: 250 34 | }), 35 | heading: { 36 | marginBottom: theme.spacing(3), 37 | fontWeight: 200 38 | }, 39 | flex:{ 40 | display:'flex', 41 | marginBottom: 20 42 | }, 43 | card: { 44 | padding:'24px 40px 20px' 45 | }, 46 | subheading: { 47 | margin: '10px', 48 | color: theme.palette.openTitle 49 | }, 50 | details: { 51 | margin: '16px', 52 | }, 53 | sub: { 54 | display: 'block', 55 | margin: '3px 0px 5px 0px', 56 | fontSize: '0.9em' 57 | }, 58 | avatar: { 59 | color: '#9b9b9b', 60 | border: '1px solid #bdbdbd', 61 | background: 'none' 62 | }, 63 | media: { 64 | height: 180, 65 | display: 'inline-block', 66 | width: '100%', 67 | marginLeft: '16px' 68 | }, 69 | icon: { 70 | verticalAlign: 'sub' 71 | }, 72 | category:{ 73 | color: '#5c5c5c', 74 | fontSize: '0.9em', 75 | padding: '3px 5px', 76 | backgroundColor: '#dbdbdb', 77 | borderRadius: '0.2em', 78 | marginTop: 5 79 | }, 80 | action: { 81 | margin: '8px 24px', 82 | display: 'inline-block' 83 | }, 84 | drawer: { 85 | width: 240, 86 | flexShrink: 0, 87 | }, 88 | drawerPaper: { 89 | width: 240, 90 | backgroundColor: '#616161' 91 | }, 92 | content: { 93 | flexGrow: 1, 94 | padding: theme.spacing(3), 95 | }, 96 | toolbar: theme.mixins.toolbar, 97 | selectedDrawer: { 98 | backgroundColor: '#e9e3df' 99 | }, 100 | unselected: { 101 | backgroundColor: '#ffffff' 102 | }, 103 | check: { 104 | color:'#38cc38' 105 | }, 106 | subhead: { 107 | fontSize: '1.2em' 108 | }, 109 | progress: { 110 | textAlign: 'center', 111 | color: '#dfdfdf', 112 | '& span':{ 113 | color: '#fffde7', 114 | fontSize: '1.15em' 115 | } 116 | }, 117 | para: { 118 | whiteSpace: 'pre-wrap' 119 | } 120 | })) 121 | 122 | export default function Enrollment ({match}) { 123 | const classes = useStyles() 124 | const [enrollment, setEnrollment] = useState({course:{instructor:[]}, lessonStatus: []}) 125 | const [values, setValues] = useState({ 126 | error: '', 127 | drawer: -1 128 | }) 129 | const [totalComplete, setTotalComplete] = useState(0) 130 | const jwt = auth.isAuthenticated() 131 | useEffect(() => { 132 | const abortController = new AbortController() 133 | const signal = abortController.signal 134 | 135 | read({enrollmentId: match.params.enrollmentId}, {t: jwt.token}, signal).then((data) => { 136 | if (data.error) { 137 | setValues({...values, error: data.error}) 138 | } else { 139 | totalCompleted(data.lessonStatus) 140 | setEnrollment(data) 141 | } 142 | }) 143 | return function cleanup(){ 144 | abortController.abort() 145 | } 146 | }, [match.params.enrollmentId]) 147 | const totalCompleted = (lessons) => { 148 | let count = lessons.reduce((total, lessonStatus) => {return total + (lessonStatus.complete ? 1 : 0)}, 0) 149 | setTotalComplete(count) 150 | return count 151 | } 152 | const selectDrawer = (index) => event => { 153 | setValues({...values, drawer:index}) 154 | } 155 | const markComplete = () => { 156 | if(!enrollment.lessonStatus[values.drawer].complete){ 157 | const lessonStatus = enrollment.lessonStatus 158 | lessonStatus[values.drawer].complete = true 159 | let count = totalCompleted(lessonStatus) 160 | 161 | let updatedData = {} 162 | updatedData.lessonStatusId = lessonStatus[values.drawer]._id 163 | updatedData.complete = true 164 | 165 | if(count == lessonStatus.length){ 166 | updatedData.courseCompleted = Date.now() 167 | } 168 | 169 | complete({ 170 | enrollmentId: match.params.enrollmentId 171 | }, { 172 | t: jwt.token 173 | }, updatedData).then((data) => { 174 | if (data && data.error) { 175 | setValues({...values, error: data.error}) 176 | } else { 177 | setEnrollment({...enrollment, lessonStatus: lessonStatus}) 178 | } 179 | }) 180 | } 181 | } 182 | const imageUrl = enrollment.course._id 183 | ? `/api/courses/photo/${enrollment.course._id}?${new Date().getTime()}` 184 | : '/api/courses/defaultphoto' 185 | return ( 186 |
187 |
194 | 195 | 196 | 197 | 198 | 199 | 200 | 201 | 202 | 203 | Lessons 204 | 205 | {enrollment.lessonStatus.map((lesson, index) => ( 206 | 207 | 208 | 209 | {index+1} 210 | 211 | 212 | 213 | 214 | { lesson.complete ? : } 215 | 216 | 217 | ))} 218 | 219 | 220 | 221 | 222 | {totalComplete} out of {enrollment.lessonStatus.length} completed
} /> 223 | 224 | 225 |
226 | {values.drawer == - 1 && 227 | 228 | 231 | By {enrollment.course.instructor.name} 232 | {enrollment.course.category} 233 |
234 | } 235 | action={ 236 | totalComplete == enrollment.lessonStatus.length && 237 | ( 238 | 241 | ) 242 | } 243 | /> 244 |
245 | 250 |
251 | 252 | {enrollment.course.description}
253 |
254 |
255 |
256 | 257 |
258 | Lessons 260 | } 261 | subheader={{enrollment.course.lessons && enrollment.course.lessons.length} lessons} 262 | action={ 263 | auth.isAuthenticated().user && auth.isAuthenticated().user._id == enrollment.course.instructor._id && 264 | ( 265 | 266 | ) 267 | } 268 | /> 269 | 270 | {enrollment.course.lessons && enrollment.course.lessons.map((lesson, i) => { 271 | return( 272 | 273 | 274 | 275 | {i+1} 276 | 277 | 278 | 281 | 282 | 283 | ) 284 | } 285 | )} 286 | 287 |
288 | } 289 | {values.drawer != -1 && (<> 290 | {enrollment.course.name} 291 | 292 | {enrollment.lessonStatus[values.drawer].complete? "Completed" : "Mark as complete"}} /> 295 | 296 | {enrollment.course.lessons[values.drawer].content} 297 | 298 | 299 | 300 | 301 | )} 302 | ) 303 | } --------------------------------------------------------------------------------