├── .DS_Store ├── .babelrc ├── .eslintrc.js ├── .gitignore ├── README.md ├── package.json ├── src ├── app.js ├── assets │ ├── js │ │ ├── addComment.js │ │ ├── main.js │ │ ├── videoPlayer.js │ │ └── videoRecorder.js │ └── scss │ │ ├── config │ │ ├── _variables.scss │ │ ├── reset.scss │ │ └── utils.scss │ │ ├── main.scss │ │ ├── pages │ │ ├── home.scss │ │ ├── userProfile.scss │ │ └── videoDetail.scss │ │ ├── partials │ │ ├── footer.scss │ │ ├── form.scss │ │ ├── header.scss │ │ ├── messages.scss │ │ ├── socialLogin.scss │ │ ├── videoBlock.scss │ │ ├── videoPlayer.scss │ │ └── videoRecorder.scss │ │ └── styles.scss ├── controllers │ ├── userController.js │ └── videoController.js ├── db.js ├── init.js ├── middlewares.js ├── models │ ├── Comment.js │ ├── User.js │ └── Video.js ├── passport.js ├── routers │ ├── apiRouter.js │ ├── globalRouter.js │ ├── userRouter.js │ └── videoRouter.js ├── routes.js ├── views │ ├── changePassword.pug │ ├── deleteVideo.pug │ ├── editProfile.pug │ ├── editVideo.pug │ ├── home.pug │ ├── join.pug │ ├── layouts │ │ └── main.pug │ ├── login.pug │ ├── mixins │ │ ├── message.pug │ │ ├── videoBlock.pug │ │ └── videoPlayer.pug │ ├── partials │ │ ├── footer.pug │ │ ├── header.pug │ │ └── socialLogin.pug │ ├── search.pug │ ├── upload.pug │ ├── userDetail.pug │ └── videoDetail.pug └── webpack.config.js └── yarn.lock /.DS_Store: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/nomadcoders/wetube/6ec78efc27711fe6fa242ab5a4b9ece9bba89da2/.DS_Store -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["@babel/preset-env"] 3 | } 4 | -------------------------------------------------------------------------------- /.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ["airbnb-base", "plugin:prettier/recommended"], 3 | rules: { 4 | "no-console": "off" 5 | }, 6 | env: { 7 | browser: true 8 | } 9 | }; 10 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | 8 | # Runtime data 9 | pids 10 | *.pid 11 | *.seed 12 | *.pid.lock 13 | 14 | # Directory for instrumented libs generated by jscoverage/JSCover 15 | lib-cov 16 | 17 | # Coverage directory used by tools like istanbul 18 | coverage 19 | 20 | # nyc test coverage 21 | .nyc_output 22 | 23 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 24 | .grunt 25 | 26 | # Bower dependency directory (https://bower.io/) 27 | bower_components 28 | 29 | # node-waf configuration 30 | .lock-wscript 31 | 32 | # Compiled binary addons (https://nodejs.org/api/addons.html) 33 | build/Release 34 | 35 | # Dependency directories 36 | node_modules/ 37 | jspm_packages/ 38 | 39 | # TypeScript v1 declaration files 40 | typings/ 41 | 42 | # Optional npm cache directory 43 | .npm 44 | 45 | # Optional eslint cache 46 | .eslintcache 47 | 48 | # Optional REPL history 49 | .node_repl_history 50 | 51 | # Output of 'npm pack' 52 | *.tgz 53 | 54 | # Yarn Integrity file 55 | .yarn-integrity 56 | 57 | # dotenv environment variables file 58 | .env 59 | 60 | # parcel-bundler cache (https://parceljs.org/) 61 | .cache 62 | 63 | # next.js build output 64 | .next 65 | 66 | # nuxt.js build output 67 | .nuxt 68 | 69 | # vuepress build output 70 | .vuepress/dist 71 | 72 | # Serverless directories 73 | .serverless 74 | 75 | # FuseBox cache 76 | .fusebox/ 77 | package-lock.json 78 | uploads 79 | static 80 | build -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # WeTube 2 | 3 | Cloning Youtube with Vanilla and NodeJS 4 | 5 | ## Pages: 6 | 7 | - [ ] Home 8 | - [x] Join 9 | - [x] Login 10 | - [x] Search 11 | - [ ] User Detail 12 | - [x] Edit Profile 13 | - [x] Change Password 14 | - [x] Upload 15 | - [ ] Video Detail 16 | - [x] Edit Video 17 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "wetube", 3 | "version": "1.0.0", 4 | "description": "Cloning Youtube with Vanilla and NodeJS", 5 | "author": "Nicolás Serrano Arévalo", 6 | "dependencies": { 7 | "@babel/cli": "^7.1.5", 8 | "@babel/core": "^7.1.5", 9 | "@babel/node": "^7.0.0", 10 | "@babel/polyfill": "^7.0.0", 11 | "@babel/preset-env": "^7.1.5", 12 | "autoprefixer": "^9.3.1", 13 | "aws-sdk": "^2.361.0", 14 | "axios": "^0.18.0", 15 | "babel-loader": "^8.0.4", 16 | "body-parser": "^1.18.3", 17 | "connect-mongo": "^2.0.1", 18 | "cookie-parser": "^1.4.3", 19 | "css-loader": "^1.0.1", 20 | "dotenv": "^6.1.0", 21 | "express": "^4.16.4", 22 | "express-flash": "0.0.2", 23 | "express-session": "^1.15.6", 24 | "extract-text-webpack-plugin": "^4.0.0-beta.0", 25 | "flash": "^1.1.0", 26 | "get-blob-duration": "^1.0.0", 27 | "helmet": "^3.15.0", 28 | "mongoose": "^5.3.12", 29 | "morgan": "^1.9.1", 30 | "multer": "^1.4.1", 31 | "multer-s3": "^2.9.0", 32 | "node-sass": "^4.10.0", 33 | "passport": "^0.4.0", 34 | "passport-facebook": "^2.1.1", 35 | "passport-github": "^1.1.0", 36 | "passport-local": "^1.0.0", 37 | "passport-local-mongoose": "^5.0.1", 38 | "postcss-loader": "^3.0.0", 39 | "pug": "^2.0.3", 40 | "sass-loader": "^7.1.0", 41 | "webpack": "^4.25.1", 42 | "webpack-cli": "^3.1.2" 43 | }, 44 | "scripts": { 45 | "dev:server": "nodemon --exec babel-node src/init.js --delay 2 --ignore '.scss' --ignore 'static' ", 46 | "dev:assets": "cd src && WEBPACK_ENV=development webpack -w", 47 | "build:assets": "cd src && WEBPACK_ENV=production webpack", 48 | "build:server": "babel src --out-dir build --ignore 'src/assets','src/static','src/webpack.config.js'", 49 | "copyAll": "cp -R src/static build && cp -R src/views build", 50 | "build": "npm run build:server && npm run build:assets && npm run copyAll", 51 | "prebuild": "rm -rf build", 52 | "tunnel": "lt --port 4000", 53 | "start": "PRODUCTION=true node build/init.js", 54 | "prestart": "npm run build" 55 | }, 56 | "devDependencies": { 57 | "eslint": "^5.9.0", 58 | "eslint-config-airbnb-base": "^13.1.0", 59 | "eslint-config-prettier": "^3.3.0", 60 | "eslint-plugin-import": "^2.14.0", 61 | "eslint-plugin-prettier": "^3.0.0", 62 | "nodemon": "^1.18.6", 63 | "prettier": "^1.15.2" 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/app.js: -------------------------------------------------------------------------------- 1 | import express from "express"; 2 | import morgan from "morgan"; 3 | import helmet from "helmet"; 4 | import cookieParser from "cookie-parser"; 5 | import bodyParser from "body-parser"; 6 | import passport from "passport"; 7 | import mongoose from "mongoose"; 8 | import session from "express-session"; 9 | import path from "path"; 10 | import flash from "express-flash"; 11 | import MongoStore from "connect-mongo"; 12 | import { localsMiddleware } from "./middlewares"; 13 | import routes from "./routes"; 14 | import userRouter from "./routers/userRouter"; 15 | import videoRouter from "./routers/videoRouter"; 16 | import globalRouter from "./routers/globalRouter"; 17 | import apiRouter from "./routers/apiRouter"; 18 | 19 | import "./passport"; 20 | 21 | const app = express(); 22 | 23 | const CokieStore = MongoStore(session); 24 | 25 | app.use(helmet()); 26 | app.set("view engine", "pug"); 27 | app.set("views", path.join(__dirname, "views")); 28 | app.use("/static", express.static(path.join(__dirname, "static"))); 29 | app.use(cookieParser()); 30 | app.use(bodyParser.json()); 31 | app.use(bodyParser.urlencoded({ extended: true })); 32 | app.use(morgan("dev")); 33 | app.use( 34 | session({ 35 | secret: process.env.COOKIE_SECRET, 36 | resave: true, 37 | saveUninitialized: false, 38 | store: new CokieStore({ mongooseConnection: mongoose.connection }) 39 | }) 40 | ); 41 | app.use(flash()); 42 | 43 | app.use(passport.initialize()); 44 | app.use(passport.session()); 45 | 46 | app.use(localsMiddleware); 47 | 48 | app.use(routes.home, globalRouter); 49 | app.use(routes.users, userRouter); 50 | app.use(routes.videos, videoRouter); 51 | app.use(routes.api, apiRouter); 52 | 53 | export default app; 54 | -------------------------------------------------------------------------------- /src/assets/js/addComment.js: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | 3 | const addCommentForm = document.getElementById("jsAddComment"); 4 | const commentList = document.getElementById("jsCommentList"); 5 | const commentNumber = document.getElementById("jsCommentNumber"); 6 | 7 | const increaseNumber = () => { 8 | commentNumber.innerHTML = parseInt(commentNumber.innerHTML, 10) + 1; 9 | }; 10 | 11 | const addComment = comment => { 12 | const li = document.createElement("li"); 13 | const span = document.createElement("span"); 14 | span.innerHTML = comment; 15 | li.appendChild(span); 16 | commentList.prepend(li); 17 | increaseNumber(); 18 | }; 19 | 20 | const sendComment = async comment => { 21 | const videoId = window.location.href.split("/videos/")[1]; 22 | const response = await axios({ 23 | url: `/api/${videoId}/comment`, 24 | method: "POST", 25 | data: { 26 | comment 27 | } 28 | }); 29 | if (response.status === 200) { 30 | addComment(comment); 31 | } 32 | }; 33 | 34 | const handleSubmit = event => { 35 | event.preventDefault(); 36 | const commentInput = addCommentForm.querySelector("input"); 37 | const comment = commentInput.value; 38 | sendComment(comment); 39 | commentInput.value = ""; 40 | }; 41 | 42 | function init() { 43 | addCommentForm.addEventListener("submit", handleSubmit); 44 | } 45 | 46 | if (addCommentForm) { 47 | init(); 48 | } 49 | -------------------------------------------------------------------------------- /src/assets/js/main.js: -------------------------------------------------------------------------------- 1 | import "../scss/styles.scss"; 2 | import "./videoPlayer"; 3 | import "./videoRecorder"; 4 | import "./addComment"; 5 | -------------------------------------------------------------------------------- /src/assets/js/videoPlayer.js: -------------------------------------------------------------------------------- 1 | import getBlobDuration from "get-blob-duration"; 2 | 3 | const videoContainer = document.getElementById("jsVideoPlayer"); 4 | const videoPlayer = document.querySelector("#jsVideoPlayer video"); 5 | const playBtn = document.getElementById("jsPlayButton"); 6 | const volumeBtn = document.getElementById("jsVolumeBtn"); 7 | const fullScrnBtn = document.getElementById("jsFullScreen"); 8 | const currentTime = document.getElementById("currentTime"); 9 | const totalTime = document.getElementById("totalTime"); 10 | const volumeRange = document.getElementById("jsVolume"); 11 | 12 | const registerView = () => { 13 | const videoId = window.location.href.split("/videos/")[1]; 14 | fetch(`/api/${videoId}/view`, { 15 | method: "POST" 16 | }); 17 | }; 18 | 19 | function handlePlayClick() { 20 | if (videoPlayer.paused) { 21 | videoPlayer.play(); 22 | playBtn.innerHTML = ''; 23 | } else { 24 | videoPlayer.pause(); 25 | playBtn.innerHTML = ''; 26 | } 27 | } 28 | 29 | function handleVolumeClick() { 30 | if (videoPlayer.muted) { 31 | videoPlayer.muted = false; 32 | volumeBtn.innerHTML = ''; 33 | volumeRange.value = videoPlayer.volume; 34 | } else { 35 | volumeRange.value = 0; 36 | videoPlayer.muted = true; 37 | volumeBtn.innerHTML = ''; 38 | } 39 | } 40 | 41 | function exitFullScreen() { 42 | fullScrnBtn.innerHTML = ''; 43 | fullScrnBtn.addEventListener("click", goFullScreen); 44 | if (document.exitFullscreen) { 45 | document.exitFullscreen(); 46 | } else if (document.mozCancelFullScreen) { 47 | document.mozCancelFullScreen(); 48 | } else if (document.webkitExitFullscreen) { 49 | document.webkitExitFullscreen(); 50 | } else if (document.msExitFullscreen) { 51 | document.msExitFullscreen(); 52 | } 53 | } 54 | 55 | function goFullScreen() { 56 | if (videoContainer.requestFullscreen) { 57 | videoContainer.requestFullscreen(); 58 | } else if (videoContainer.mozRequestFullScreen) { 59 | videoContainer.mozRequestFullScreen(); 60 | } else if (videoContainer.webkitRequestFullscreen) { 61 | videoContainer.webkitRequestFullscreen(); 62 | } else if (videoContainer.msRequestFullscreen) { 63 | videoContainer.msRequestFullscreen(); 64 | } 65 | fullScrnBtn.innerHTML = ''; 66 | fullScrnBtn.removeEventListener("click", goFullScreen); 67 | fullScrnBtn.addEventListener("click", exitFullScreen); 68 | } 69 | 70 | const formatDate = seconds => { 71 | const secondsNumber = parseInt(seconds, 10); 72 | let hours = Math.floor(secondsNumber / 3600); 73 | let minutes = Math.floor((secondsNumber - hours * 3600) / 60); 74 | let totalSeconds = secondsNumber - hours * 3600 - minutes * 60; 75 | 76 | if (hours < 10) { 77 | hours = `0${hours}`; 78 | } 79 | if (minutes < 10) { 80 | minutes = `0${minutes}`; 81 | } 82 | if (seconds < 10) { 83 | totalSeconds = `0${totalSeconds}`; 84 | } 85 | return `${hours}:${minutes}:${totalSeconds}`; 86 | }; 87 | 88 | function getCurrentTime() { 89 | currentTime.innerHTML = formatDate(Math.floor(videoPlayer.currentTime)); 90 | } 91 | 92 | async function setTotalTime() { 93 | let duration; 94 | if (!isFinite(videoPlayer.duration)) { 95 | const blob = await fetch(videoPlayer.src).then(response => response.blob()); 96 | duration = await getBlobDuration(blob); 97 | } else { 98 | duration = videoPlayer.duration; 99 | } 100 | const totalTimeString = formatDate(duration); 101 | totalTime.innerHTML = totalTimeString; 102 | setInterval(getCurrentTime, 1000); 103 | } 104 | 105 | function handleEnded() { 106 | registerView(); 107 | videoPlayer.currentTime = 0; 108 | playBtn.innerHTML = ''; 109 | } 110 | 111 | function handleDrag(event) { 112 | const { 113 | target: { value } 114 | } = event; 115 | videoPlayer.volume = value; 116 | if (value >= 0.6) { 117 | volumeBtn.innerHTML = ''; 118 | } else if (value >= 0.2) { 119 | volumeBtn.innerHTML = ''; 120 | } else { 121 | volumeBtn.innerHTML = ''; 122 | } 123 | } 124 | 125 | function init() { 126 | videoPlayer.volume = 0.5; 127 | playBtn.addEventListener("click", handlePlayClick); 128 | volumeBtn.addEventListener("click", handleVolumeClick); 129 | fullScrnBtn.addEventListener("click", goFullScreen); 130 | videoPlayer.addEventListener("loadedmetadata", setTotalTime); 131 | videoPlayer.addEventListener("ended", handleEnded); 132 | volumeRange.addEventListener("input", handleDrag); 133 | } 134 | 135 | if (videoContainer) { 136 | init(); 137 | } 138 | -------------------------------------------------------------------------------- /src/assets/js/videoRecorder.js: -------------------------------------------------------------------------------- 1 | const recorderContainer = document.getElementById("jsRecordContainer"); 2 | const recordBtn = document.getElementById("jsRecordBtn"); 3 | const videoPreview = document.getElementById("jsVideoPreview"); 4 | 5 | let streamObject; 6 | let videoRecorder; 7 | 8 | const handleVideoData = event => { 9 | const { data: videoFile } = event; 10 | const link = document.createElement("a"); 11 | link.href = URL.createObjectURL(videoFile); 12 | link.download = "recorded.webm"; 13 | document.body.appendChild(link); 14 | link.click(); 15 | }; 16 | 17 | const stopRecording = () => { 18 | videoRecorder.stop(); 19 | recordBtn.removeEventListener("click", stopRecording); 20 | recordBtn.addEventListener("click", getVideo); 21 | recordBtn.innerHTML = "Start recording"; 22 | }; 23 | 24 | const startRecording = () => { 25 | videoRecorder = new MediaRecorder(streamObject); 26 | videoRecorder.start(); 27 | videoRecorder.addEventListener("dataavailable", handleVideoData); 28 | recordBtn.addEventListener("click", stopRecording); 29 | }; 30 | 31 | const getVideo = async () => { 32 | try { 33 | const stream = await navigator.mediaDevices.getUserMedia({ 34 | audio: true, 35 | video: { width: 1280, height: 720 } 36 | }); 37 | videoPreview.srcObject = stream; 38 | videoPreview.muted = true; 39 | videoPreview.play(); 40 | recordBtn.innerHTML = "Stop recording"; 41 | streamObject = stream; 42 | startRecording(); 43 | } catch (error) { 44 | recordBtn.innerHTML = "☹️ Cant record"; 45 | } finally { 46 | recordBtn.removeEventListener("click", getVideo); 47 | } 48 | }; 49 | 50 | function init() { 51 | recordBtn.addEventListener("click", getVideo); 52 | } 53 | 54 | if (recorderContainer) { 55 | init(); 56 | } 57 | -------------------------------------------------------------------------------- /src/assets/scss/config/_variables.scss: -------------------------------------------------------------------------------- 1 | $red: #ea232c; 2 | $dark-red: #bb2f2a; 3 | $grey: #f5f5f5; 4 | $black: #444444; 5 | $dark-grey: #e7e7e7; 6 | -------------------------------------------------------------------------------- /src/assets/scss/config/reset.scss: -------------------------------------------------------------------------------- 1 | html, 2 | body, 3 | div, 4 | span, 5 | applet, 6 | object, 7 | iframe, 8 | h1, 9 | h2, 10 | h3, 11 | h4, 12 | h5, 13 | h6, 14 | p, 15 | blockquote, 16 | pre, 17 | a, 18 | abbr, 19 | acronym, 20 | address, 21 | big, 22 | cite, 23 | code, 24 | del, 25 | dfn, 26 | em, 27 | img, 28 | ins, 29 | kbd, 30 | q, 31 | s, 32 | samp, 33 | small, 34 | strike, 35 | strong, 36 | sub, 37 | sup, 38 | tt, 39 | var, 40 | b, 41 | u, 42 | i, 43 | center, 44 | dl, 45 | dt, 46 | dd, 47 | ol, 48 | ul, 49 | li, 50 | fieldset, 51 | form, 52 | label, 53 | legend, 54 | table, 55 | caption, 56 | tbody, 57 | tfoot, 58 | thead, 59 | tr, 60 | th, 61 | td, 62 | article, 63 | aside, 64 | canvas, 65 | details, 66 | embed, 67 | figure, 68 | figcaption, 69 | footer, 70 | header, 71 | hgroup, 72 | menu, 73 | nav, 74 | output, 75 | ruby, 76 | section, 77 | summary, 78 | time, 79 | mark, 80 | audio, 81 | video { 82 | margin: 0; 83 | padding: 0; 84 | border: 0; 85 | font-size: 100%; 86 | font: inherit; 87 | vertical-align: baseline; 88 | } 89 | /* HTML5 display-role reset for older browsers */ 90 | article, 91 | aside, 92 | details, 93 | figcaption, 94 | figure, 95 | footer, 96 | header, 97 | hgroup, 98 | menu, 99 | nav, 100 | section { 101 | display: block; 102 | } 103 | body { 104 | line-height: 1; 105 | } 106 | ol, 107 | ul { 108 | list-style: none; 109 | } 110 | blockquote, 111 | q { 112 | quotes: none; 113 | } 114 | blockquote:before, 115 | blockquote:after, 116 | q:before, 117 | q:after { 118 | content: ""; 119 | content: none; 120 | } 121 | table { 122 | border-collapse: collapse; 123 | border-spacing: 0; 124 | } 125 | a { 126 | all: unset; 127 | cursor: pointer; 128 | } 129 | 130 | *, 131 | input { 132 | box-sizing: border-box; 133 | } 134 | 135 | input { 136 | border: none; 137 | box-sizing: border-box; 138 | &:focus, 139 | &:active { 140 | outline: none; 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /src/assets/scss/config/utils.scss: -------------------------------------------------------------------------------- 1 | .u-avatar { 2 | height: 80px; 3 | background-color: $red; 4 | border-radius: 50%; 5 | } 6 | -------------------------------------------------------------------------------- /src/assets/scss/main.scss: -------------------------------------------------------------------------------- 1 | html, 2 | body { 3 | height: 100%; 4 | } 5 | body { 6 | background-color: #f5f5f5; 7 | color: $black; 8 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen, 9 | Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif; 10 | font-size: 14px; 11 | } 12 | 13 | main { 14 | width: 100%; 15 | max-width: 1200px; 16 | margin: 0 auto; 17 | min-height: 70vh; 18 | } 19 | 20 | button, 21 | input:not([type="file"]), 22 | textarea, 23 | .fileUpload { 24 | padding: 7px 10px; 25 | width: 100%; 26 | border: none; 27 | border-radius: 5px; 28 | font-size: 14px; 29 | color: $black; 30 | font-weight: 600; 31 | background-color: white; 32 | max-width: 320px; 33 | resize: none; 34 | box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12), 0 1px 2px rgba(0, 0, 0, 0.24); 35 | &::placeholder { 36 | font-weight: 300; 37 | color: rgba(0, 0, 0, 0.7); 38 | } 39 | } 40 | button { 41 | border: none; 42 | background-color: #3498db; 43 | color: white; 44 | } 45 | 46 | button.delete { 47 | background-color: $dark-red; 48 | } 49 | 50 | button, 51 | input[type="submit"] { 52 | cursor: pointer; 53 | } 54 | -------------------------------------------------------------------------------- /src/assets/scss/pages/home.scss: -------------------------------------------------------------------------------- 1 | .home-videos { 2 | display: grid; 3 | grid-template-columns: repeat(6, minmax(150px, 1fr)); 4 | grid-gap: 30px; 5 | .videoBlock:first-child, 6 | .videoBlock:nth-child(2) { 7 | grid-column: span 3; 8 | } 9 | .videoBlock:nth-child(3), 10 | .videoBlock:nth-child(4), 11 | .videoBlock:nth-child(5) { 12 | grid-column: span 2; 13 | } 14 | } 15 | -------------------------------------------------------------------------------- /src/assets/scss/pages/userProfile.scss: -------------------------------------------------------------------------------- 1 | .user-profile { 2 | width: 100%; 3 | display: flex; 4 | align-items: center; 5 | flex-direction: column; 6 | .user-profile__header { 7 | display: flex; 8 | flex-direction: column; 9 | align-items: center; 10 | margin-bottom: 18px; 11 | margin-bottom: 50px; 12 | .profile__username { 13 | font-size: 18px; 14 | margin-top: 15px; 15 | } 16 | } 17 | .user-profile__btns { 18 | display: flex; 19 | flex-direction: column; 20 | align-items: center; 21 | margin-bottom: 50px; 22 | a:not(:last-child) { 23 | margin-bottom: 20px; 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /src/assets/scss/pages/videoDetail.scss: -------------------------------------------------------------------------------- 1 | .video-detail__container { 2 | display: flex; 3 | flex-direction: column; 4 | align-items: center; 5 | .video__info { 6 | width: 100%; 7 | max-width: 850px; 8 | margin-top: 25px; 9 | button { 10 | width: 100px; 11 | margin-bottom: 25px; 12 | } 13 | .video__author { 14 | a { 15 | color: #3498db; 16 | } 17 | } 18 | .video__title, 19 | .video__views, 20 | .video__description { 21 | display: block; 22 | margin-bottom: 15px; 23 | } 24 | .video__title { 25 | font-size: 22px; 26 | font-weight: 300; 27 | } 28 | .video__views { 29 | font-size: 14px; 30 | } 31 | .video__description { 32 | font-size: 16px; 33 | } 34 | } 35 | .video__comments { 36 | margin-top: 25px; 37 | text-align: center; 38 | .video__comment-number { 39 | font-size: 18px; 40 | } 41 | .add__comment { 42 | margin-top: 25px; 43 | width: 100%; 44 | input { 45 | width: 100%; 46 | } 47 | } 48 | .video__comments-list { 49 | margin-top: 50px; 50 | li { 51 | background-color: #3498db; 52 | color: white; 53 | padding: 15px; 54 | border-radius: 20px; 55 | border-bottom-left-radius: 0px; 56 | &:not(:last-child) { 57 | margin-bottom: 15px; 58 | } 59 | } 60 | } 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/assets/scss/partials/footer.scss: -------------------------------------------------------------------------------- 1 | .footer { 2 | margin: 50px 0; 3 | padding-top: 50px; 4 | border-top: 3px solid rgba(0, 0, 0, 0.1); 5 | width: 100%; 6 | display: flex; 7 | flex-direction: column; 8 | align-items: center; 9 | .footer__icon { 10 | color: rgba(0, 0, 0, 0.2); 11 | font-size: 40px; 12 | margin-bottom: 20px; 13 | } 14 | .footer__text { 15 | color: rgba(0, 0, 0, 0.2); 16 | font-weight: 800; 17 | text-transform: uppercase; 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/assets/scss/partials/form.scss: -------------------------------------------------------------------------------- 1 | .form-container { 2 | width: 100%; 3 | display: flex; 4 | flex-direction: column; 5 | align-items: center; 6 | form { 7 | width: 100%; 8 | max-width: 320px; 9 | margin-bottom: 50px; 10 | input:not([type="file"]), 11 | .fileUpload { 12 | display: block; 13 | width: 100%; 14 | padding-top: 10px; 15 | padding-bottom: 10px; 16 | box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12), 0 1px 2px rgba(0, 0, 0, 0.24); 17 | &:not(:last-child) { 18 | margin-bottom: 25px; 19 | } 20 | } 21 | textarea { 22 | box-shadow: 0 1px 3px rgba(0, 0, 0, 0.12), 0 1px 2px rgba(0, 0, 0, 0.24); 23 | margin-bottom: 25px; 24 | } 25 | input[type="submit"] { 26 | background-color: #3498db; 27 | color: white; 28 | } 29 | } 30 | a { 31 | max-width: 320px; 32 | width: 100%; 33 | } 34 | .fileUpload { 35 | label { 36 | font-weight: 300; 37 | margin-right: 10px; 38 | } 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/assets/scss/partials/header.scss: -------------------------------------------------------------------------------- 1 | .header { 2 | background-color: $red; 3 | margin-bottom: 50px; 4 | .header__wrapper { 5 | padding: 5px 0px; 6 | width: 100%; 7 | margin: 0 auto; 8 | max-width: 1200px; 9 | display: grid; 10 | grid-template-columns: repeat(3, 1fr); 11 | align-items: center; 12 | .header__column { 13 | i { 14 | color: white; 15 | font-size: 30px; 16 | } 17 | &:nth-child(2) { 18 | width: 100%; 19 | justify-self: center; 20 | } 21 | &:last-child { 22 | justify-self: end; 23 | } 24 | ul { 25 | display: flex; 26 | color: white; 27 | font-weight: 600; 28 | text-transform: uppercase; 29 | li:not(:last-child) { 30 | margin-right: 15px; 31 | } 32 | } 33 | form { 34 | width: 100%; 35 | input { 36 | padding: 7px 10px; 37 | width: 100%; 38 | border-radius: 5px; 39 | font-size: 14px; 40 | color: $black; 41 | font-weight: 600; 42 | max-width: none; 43 | &::placeholder { 44 | font-weight: 300; 45 | color: rgba(0, 0, 0, 0.7); 46 | } 47 | } 48 | } 49 | } 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/assets/scss/partials/messages.scss: -------------------------------------------------------------------------------- 1 | @keyframes flashAnimation { 2 | 0% { 3 | transform: translateY(-70px); 4 | } 5 | 5% { 6 | transform: translateY(0px); 7 | } 8 | 95% { 9 | transform: translateY(0px); 10 | } 11 | 100% { 12 | transform: translateY(-70px); 13 | } 14 | } 15 | 16 | .flash-message__container { 17 | position: fixed; 18 | top: 0; 19 | left: 0; 20 | width: 100%; 21 | z-index: 9; 22 | padding: 20px; 23 | text-align: center; 24 | animation: flashAnimation 5s ease-in-out forwards; 25 | &.error { 26 | background-color: #e74c3c; 27 | } 28 | &.success { 29 | background-color: #2ecc71; 30 | } 31 | &.info { 32 | background-color: #f1c40f; 33 | } 34 | .flash-message__text { 35 | color: white; 36 | font-size: 14px; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/assets/scss/partials/socialLogin.scss: -------------------------------------------------------------------------------- 1 | .social-login { 2 | display: flex; 3 | flex-direction: column; 4 | align-items: center; 5 | width: 100%; 6 | button { 7 | width: 100%; 8 | max-width: 320px; 9 | display: flex; 10 | justify-content: center; 11 | align-items: center; 12 | &:not(:last-child) { 13 | margin-bottom: 15px; 14 | } 15 | span { 16 | margin-right: 10px; 17 | font-size: 20px; 18 | } 19 | } 20 | .social-login--github { 21 | background-color: $black; 22 | color: white; 23 | } 24 | .social-login--facebook { 25 | background-color: #3a5998; 26 | color: white; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/assets/scss/partials/videoBlock.scss: -------------------------------------------------------------------------------- 1 | .videoBlock { 2 | video { 3 | margin-bottom: 10px; 4 | max-width: 100%; 5 | width: 100%; 6 | overflow: hidden; 7 | } 8 | .videoBlock__title { 9 | font-size: 18px; 10 | font-weight: 300; 11 | margin-bottom: 10px; 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/assets/scss/partials/videoPlayer.scss: -------------------------------------------------------------------------------- 1 | .videoPlayer { 2 | position: relative; 3 | &:hover { 4 | .videoPlayer__controls { 5 | opacity: 1; 6 | } 7 | } 8 | video { 9 | width: 100%; 10 | max-width: 100%; 11 | } 12 | .videoPlayer__controls { 13 | opacity: 0; 14 | transition: opacity 0.4s linear; 15 | color: white; 16 | position: absolute; 17 | z-index: 9; 18 | bottom: 5px; 19 | width: 100%; 20 | background-color: rgba(0, 0, 0, 0.5); 21 | padding: 10px; 22 | display: grid; 23 | grid-template-columns: repeat(3, 1fr); 24 | font-size: 16px; 25 | .videoPlayer__column:first-child { 26 | display: flex; 27 | align-items: center; 28 | #jsVolumeBtn { 29 | margin-right: 10px; 30 | } 31 | } 32 | .videoPlayer__volume { 33 | position: absolute; 34 | padding: 0; 35 | opacity: 1; 36 | top: -60px; 37 | left: -25px; 38 | transform: rotate(-90deg); 39 | z-index: 10; 40 | width: 80px; 41 | 42 | input { 43 | background-color: rgba(0, 0, 0, 0.7); 44 | &::-webkit-slider-runnable-track { 45 | background-color: $grey; 46 | height: 5px; 47 | } 48 | &::-webkit-slider-thumb { 49 | all: unset; 50 | background-color: $red; 51 | height: 15px; 52 | width: 15px; 53 | border-radius: 50%; 54 | position: relative; 55 | top: -5px; 56 | } 57 | } 58 | } 59 | .videoPlayer__column:last-child { 60 | justify-self: end; 61 | } 62 | .videoPlayer__column:nth-child(2) { 63 | justify-self: center; 64 | } 65 | i { 66 | font-size: 25px; 67 | cursor: pointer; 68 | } 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /src/assets/scss/partials/videoRecorder.scss: -------------------------------------------------------------------------------- 1 | .record-container { 2 | width: 100%; 3 | max-width: 320px; 4 | margin-bottom: 50px; 5 | video { 6 | background-color: $black; 7 | width: 100%; 8 | margin-bottom: 20px; 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /src/assets/scss/styles.scss: -------------------------------------------------------------------------------- 1 | @import "config/_variables.scss"; 2 | @import "config/reset.scss"; 3 | @import "config/utils.scss"; 4 | @import "main.scss"; 5 | 6 | @import "partials/header.scss"; 7 | @import "partials/footer.scss"; 8 | @import "partials/form.scss"; 9 | @import "partials/socialLogin.scss"; 10 | @import "partials/videoBlock.scss"; 11 | @import "partials/videoPlayer.scss"; 12 | @import "partials/videoRecorder.scss"; 13 | @import "partials/messages.scss"; 14 | 15 | @import "pages/home.scss"; 16 | @import "pages/videoDetail.scss"; 17 | @import "pages/userProfile.scss"; 18 | -------------------------------------------------------------------------------- /src/controllers/userController.js: -------------------------------------------------------------------------------- 1 | import passport from "passport"; 2 | import routes from "../routes"; 3 | import User from "../models/User"; 4 | 5 | export const getJoin = (req, res) => { 6 | res.render("join", { pageTitle: "Join" }); 7 | }; 8 | 9 | export const postJoin = async (req, res, next) => { 10 | const { 11 | body: { name, email, password, password2 } 12 | } = req; 13 | if (password !== password2) { 14 | req.flash("error", "Passwords don't match"); 15 | res.status(400); 16 | res.render("join", { pageTitle: "Join" }); 17 | } else { 18 | try { 19 | const user = await User({ 20 | name, 21 | email 22 | }); 23 | await User.register(user, password); 24 | next(); 25 | } catch (error) { 26 | console.log(error); 27 | res.redirect(routes.home); 28 | } 29 | } 30 | }; 31 | 32 | export const getLogin = (req, res) => 33 | res.render("login", { pageTitle: "Log In" }); 34 | 35 | export const postLogin = passport.authenticate("local", { 36 | failureRedirect: routes.login, 37 | successRedirect: routes.home, 38 | successFlash: "Welcome", 39 | failureFlash: "Can't log in. Check email and/or password" 40 | }); 41 | 42 | export const githubLogin = passport.authenticate("github", { 43 | successFlash: "Welcome", 44 | failureFlash: "Can't log in at this time" 45 | }); 46 | 47 | export const githubLoginCallback = async (_, __, profile, cb) => { 48 | const { 49 | _json: { id, avatar_url: avatarUrl, name, email } 50 | } = profile; 51 | try { 52 | const user = await User.findOne({ email }); 53 | if (user) { 54 | user.githubId = id; 55 | user.save(); 56 | return cb(null, user); 57 | } 58 | const newUser = await User.create({ 59 | email, 60 | name, 61 | githubId: id, 62 | avatarUrl 63 | }); 64 | return cb(null, newUser); 65 | } catch (error) { 66 | return cb(error); 67 | } 68 | }; 69 | 70 | export const postGithubLogIn = (req, res) => { 71 | res.redirect(routes.home); 72 | }; 73 | 74 | export const facebookLogin = passport.authenticate("facebook", { 75 | successFlash: "Welcome", 76 | failureFlash: "Can't log in at this time" 77 | }); 78 | 79 | export const facebookLoginCallback = async (_, __, profile, cb) => { 80 | const { 81 | _json: { id, name, email } 82 | } = profile; 83 | try { 84 | const user = await User.findOne({ email }); 85 | if (user) { 86 | user.facebookId = id; 87 | user.avatarUrl = `https://graph.facebook.com/${id}/picture?type=large`; 88 | user.save(); 89 | return cb(null, user); 90 | } 91 | const newUser = await User.create({ 92 | email, 93 | name, 94 | facebookId: id, 95 | avatarUrl: `https://graph.facebook.com/${id}/picture?type=large` 96 | }); 97 | return cb(null, newUser); 98 | } catch (error) { 99 | return cb(error); 100 | } 101 | }; 102 | 103 | export const postFacebookLogin = (req, res) => { 104 | res.redirect(routes.home); 105 | }; 106 | 107 | export const logout = (req, res) => { 108 | req.flash("info", "Logged out, see you later"); 109 | req.logout(); 110 | res.redirect(routes.home); 111 | }; 112 | 113 | export const getMe = async (req, res) => { 114 | try { 115 | const user = await User.findById(req.user.id).populate("videos"); 116 | res.render("userDetail", { pageTitle: "User Detail", user }); 117 | } catch (error) { 118 | res.redirect(routes.home); 119 | } 120 | }; 121 | 122 | export const userDetail = async (req, res) => { 123 | const { 124 | params: { id } 125 | } = req; 126 | try { 127 | const user = await User.findById(id).populate("videos"); 128 | res.render("userDetail", { pageTitle: "User Detail", user }); 129 | } catch (error) { 130 | req.flash("error", "User not found"); 131 | res.redirect(routes.home); 132 | } 133 | }; 134 | 135 | export const getEditProfile = (req, res) => 136 | res.render("editProfile", { pageTitle: "Edit Profile" }); 137 | 138 | export const postEditProfile = async (req, res) => { 139 | const { 140 | body: { name, email }, 141 | file 142 | } = req; 143 | try { 144 | await User.findByIdAndUpdate(req.user.id, { 145 | name, 146 | email, 147 | avatarUrl: file ? file.location : req.user.avatarUrl 148 | }); 149 | req.flash("success", "Profile updated"); 150 | res.redirect(routes.me); 151 | } catch (error) { 152 | req.flash("error", "Can't update profile"); 153 | res.redirect(routes.editProfile); 154 | } 155 | }; 156 | 157 | export const getChangePassword = (req, res) => 158 | res.render("changePassword", { pageTitle: "Change Password" }); 159 | 160 | export const postChangePassword = async (req, res) => { 161 | const { 162 | body: { oldPassword, newPassword, newPassword1 } 163 | } = req; 164 | try { 165 | if (newPassword !== newPassword1) { 166 | req.flash("error", "Passwords don't match"); 167 | res.status(400); 168 | res.redirect(`/users/${routes.changePassword}`); 169 | return; 170 | } 171 | await req.user.changePassword(oldPassword, newPassword); 172 | res.redirect(routes.me); 173 | } catch (error) { 174 | req.flash("error", "Can't change password"); 175 | res.status(400); 176 | res.redirect(`/users/${routes.changePassword}`); 177 | } 178 | }; 179 | -------------------------------------------------------------------------------- /src/controllers/videoController.js: -------------------------------------------------------------------------------- 1 | import routes from "../routes"; 2 | import Video from "../models/Video"; 3 | import Comment from "../models/Comment"; 4 | 5 | // Home 6 | 7 | export const home = async (req, res) => { 8 | try { 9 | const videos = await Video.find({}).sort({ _id: -1 }); 10 | res.render("home", { pageTitle: "Home", videos }); 11 | } catch (error) { 12 | console.log(error); 13 | res.render("home", { pageTitle: "Home", videos: [] }); 14 | } 15 | }; 16 | 17 | // Search 18 | 19 | export const search = async (req, res) => { 20 | const { 21 | query: { term: searchingBy } 22 | } = req; 23 | let videos = []; 24 | try { 25 | videos = await Video.find({ 26 | title: { $regex: searchingBy, $options: "i" } 27 | }); 28 | } catch (error) { 29 | console.log(error); 30 | } 31 | res.render("search", { pageTitle: "Search", searchingBy, videos }); 32 | }; 33 | 34 | // Upload 35 | 36 | export const getUpload = (req, res) => 37 | res.render("upload", { pageTitle: "Upload" }); 38 | 39 | export const postUpload = async (req, res) => { 40 | const { 41 | body: { title, description }, 42 | file: { location } 43 | } = req; 44 | const newVideo = await Video.create({ 45 | fileUrl: location, 46 | title, 47 | description, 48 | creator: req.user.id 49 | }); 50 | req.user.videos.push(newVideo.id); 51 | req.user.save(); 52 | res.redirect(routes.videoDetail(newVideo.id)); 53 | }; 54 | 55 | // Video Detail 56 | 57 | export const videoDetail = async (req, res) => { 58 | const { 59 | params: { id } 60 | } = req; 61 | try { 62 | const video = await Video.findById(id) 63 | .populate("creator") 64 | .populate("comments"); 65 | res.render("videoDetail", { pageTitle: video.title, video }); 66 | } catch (error) { 67 | res.redirect(routes.home); 68 | } 69 | }; 70 | 71 | // Edit Video 72 | 73 | export const getEditVideo = async (req, res) => { 74 | const { 75 | params: { id } 76 | } = req; 77 | try { 78 | const video = await Video.findById(id); 79 | if (String(video.creator) !== req.user.id) { 80 | throw Error(); 81 | } else { 82 | res.render("editVideo", { pageTitle: `Edit ${video.title}`, video }); 83 | } 84 | } catch (error) { 85 | res.redirect(routes.home); 86 | } 87 | }; 88 | 89 | export const postEditVideo = async (req, res) => { 90 | const { 91 | params: { id }, 92 | body: { title, description } 93 | } = req; 94 | try { 95 | await Video.findOneAndUpdate({ _id: id }, { title, description }); 96 | res.redirect(routes.videoDetail(id)); 97 | } catch (error) { 98 | res.redirect(routes.home); 99 | } 100 | }; 101 | 102 | // Delete Video 103 | 104 | export const deleteVideo = async (req, res) => { 105 | const { 106 | params: { id } 107 | } = req; 108 | try { 109 | const video = await Video.findById(id); 110 | if (String(video.creator) !== req.user.id) { 111 | throw Error(); 112 | } else { 113 | await Video.findOneAndRemove({ _id: id }); 114 | } 115 | } catch (error) { 116 | console.log(error); 117 | } 118 | res.redirect(routes.home); 119 | }; 120 | 121 | // Register Video View 122 | 123 | export const postRegisterView = async (req, res) => { 124 | const { 125 | params: { id } 126 | } = req; 127 | try { 128 | const video = await Video.findById(id); 129 | video.views += 1; 130 | video.save(); 131 | res.status(200); 132 | } catch (error) { 133 | res.status(400); 134 | } finally { 135 | res.end(); 136 | } 137 | }; 138 | 139 | // Add Comment 140 | 141 | export const postAddComment = async (req, res) => { 142 | const { 143 | params: { id }, 144 | body: { comment }, 145 | user 146 | } = req; 147 | try { 148 | const video = await Video.findById(id); 149 | const newComment = await Comment.create({ 150 | text: comment, 151 | creator: user.id 152 | }); 153 | video.comments.push(newComment.id); 154 | video.save(); 155 | } catch (error) { 156 | console.log(error); 157 | res.status(400); 158 | } finally { 159 | res.end(); 160 | } 161 | }; 162 | -------------------------------------------------------------------------------- /src/db.js: -------------------------------------------------------------------------------- 1 | import mongoose from "mongoose"; 2 | import dotenv from "dotenv"; 3 | 4 | dotenv.config(); 5 | 6 | mongoose.connect( 7 | process.env.PRODUCTION ? process.env.MONGO_URL_PROD : process.env.MONGO_URL, 8 | { 9 | useNewUrlParser: true, 10 | useFindAndModify: false 11 | } 12 | ); 13 | 14 | const db = mongoose.connection; 15 | 16 | const handleOpen = () => console.log("✅ Connected to DB"); 17 | const handleError = error => console.log(`❌ Error on DB Connection:${error}`); 18 | 19 | db.once("open", handleOpen); 20 | db.on("error", handleError); 21 | -------------------------------------------------------------------------------- /src/init.js: -------------------------------------------------------------------------------- 1 | import "@babel/polyfill"; 2 | import dotenv from "dotenv"; 3 | import "./db"; 4 | import app from "./app"; 5 | 6 | dotenv.config(); 7 | 8 | import "./models/Video"; 9 | import "./models/Comment"; 10 | import "./models/User"; 11 | 12 | const PORT = process.env.PORT || 4000; 13 | 14 | const handleListening = () => 15 | console.log(`✅ Listening on: http://localhost:${PORT}`); 16 | 17 | app.listen(PORT, handleListening); 18 | -------------------------------------------------------------------------------- /src/middlewares.js: -------------------------------------------------------------------------------- 1 | import multer from "multer"; 2 | import multerS3 from "multer-s3"; 3 | import aws from "aws-sdk"; 4 | import routes from "./routes"; 5 | 6 | const s3 = new aws.S3({ 7 | accessKeyId: process.env.AWS_KEY, 8 | secretAccessKey: process.env.AWS_PRIVATE_KEY, 9 | region: "ap-northeast-1" 10 | }); 11 | 12 | const multerVideo = multer({ 13 | storage: multerS3({ 14 | s3, 15 | acl: "public-read", 16 | bucket: "wetube/video" 17 | }) 18 | }); 19 | const multerAvatar = multer({ 20 | storage: multerS3({ 21 | s3, 22 | acl: "public-read", 23 | bucket: "wetube/avatar" 24 | }) 25 | }); 26 | 27 | export const uploadVideo = multerVideo.single("videoFile"); 28 | export const uploadAvatar = multerAvatar.single("avatar"); 29 | 30 | export const localsMiddleware = (req, res, next) => { 31 | res.locals.siteName = "WeTube"; 32 | res.locals.routes = routes; 33 | res.locals.loggedUser = req.user || null; 34 | next(); 35 | }; 36 | 37 | export const onlyPublic = (req, res, next) => { 38 | if (req.user) { 39 | res.redirect(routes.home); 40 | } else { 41 | next(); 42 | } 43 | }; 44 | 45 | export const onlyPrivate = (req, res, next) => { 46 | if (req.user) { 47 | next(); 48 | } else { 49 | res.redirect(routes.home); 50 | } 51 | }; 52 | -------------------------------------------------------------------------------- /src/models/Comment.js: -------------------------------------------------------------------------------- 1 | import mongoose from "mongoose"; 2 | 3 | const CommentSchema = new mongoose.Schema({ 4 | text: { 5 | type: String, 6 | required: "Text is required" 7 | }, 8 | createdAt: { 9 | type: Date, 10 | default: Date.now 11 | }, 12 | creator: { 13 | type: mongoose.Schema.Types.ObjectId, 14 | ref: "User" 15 | } 16 | }); 17 | 18 | const model = mongoose.model("Comment", CommentSchema); 19 | export default model; 20 | -------------------------------------------------------------------------------- /src/models/User.js: -------------------------------------------------------------------------------- 1 | import mongoose from "mongoose"; 2 | import passportLocalMongoose from "passport-local-mongoose"; 3 | 4 | const UserSchema = new mongoose.Schema({ 5 | name: String, 6 | email: String, 7 | avatarUrl: String, 8 | facebookId: Number, 9 | githubId: Number, 10 | comments: [ 11 | { 12 | type: mongoose.Schema.Types.ObjectId, 13 | ref: "Comment" 14 | } 15 | ], 16 | videos: [ 17 | { 18 | type: mongoose.Schema.Types.ObjectId, 19 | ref: "Video" 20 | } 21 | ] 22 | }); 23 | 24 | UserSchema.plugin(passportLocalMongoose, { usernameField: "email" }); 25 | 26 | const model = mongoose.model("User", UserSchema); 27 | 28 | export default model; 29 | -------------------------------------------------------------------------------- /src/models/Video.js: -------------------------------------------------------------------------------- 1 | import mongoose from "mongoose"; 2 | 3 | const VideoSchema = new mongoose.Schema({ 4 | fileUrl: { 5 | type: String, 6 | required: "File URL is required" 7 | }, 8 | title: { 9 | type: String, 10 | required: "Tilte is required" 11 | }, 12 | description: String, 13 | views: { 14 | type: Number, 15 | default: 0 16 | }, 17 | createdAt: { 18 | type: Date, 19 | default: Date.now 20 | }, 21 | comments: [ 22 | { 23 | type: mongoose.Schema.Types.ObjectId, 24 | ref: "Comment" 25 | } 26 | ], 27 | creator: { 28 | type: mongoose.Schema.Types.ObjectId, 29 | ref: "User" 30 | } 31 | }); 32 | 33 | const model = mongoose.model("Video", VideoSchema); 34 | export default model; 35 | -------------------------------------------------------------------------------- /src/passport.js: -------------------------------------------------------------------------------- 1 | import passport from "passport"; 2 | import GithubStrategy from "passport-github"; 3 | import FacebookStrategy from "passport-facebook"; 4 | import User from "./models/User"; 5 | import { 6 | githubLoginCallback, 7 | facebookLoginCallback 8 | } from "./controllers/userController"; 9 | import routes from "./routes"; 10 | 11 | passport.use(User.createStrategy()); 12 | 13 | passport.use( 14 | new GithubStrategy( 15 | { 16 | clientID: process.env.GH_ID, 17 | clientSecret: process.env.GH_SECRET, 18 | callbackURL: process.env.PRODUCTION 19 | ? `https://polar-sea-27980.herokuapp.com${routes.githubCallback}` 20 | : `http://localhost:4000${routes.githubCallback}` 21 | }, 22 | githubLoginCallback 23 | ) 24 | ); 25 | 26 | passport.use( 27 | new FacebookStrategy( 28 | { 29 | clientID: process.env.FB_ID, 30 | clientSecret: process.env.FB_SECRET, 31 | callbackURL: `https://polar-sea-27980.herokuapp.com${ 32 | routes.facebookCallback 33 | }`, 34 | profileFields: ["id", "displayName", "photos", "email"], 35 | scope: ["public_profile", "email"] 36 | }, 37 | facebookLoginCallback 38 | ) 39 | ); 40 | 41 | passport.serializeUser(User.serializeUser()); 42 | passport.deserializeUser(User.deserializeUser()); 43 | -------------------------------------------------------------------------------- /src/routers/apiRouter.js: -------------------------------------------------------------------------------- 1 | import express from "express"; 2 | import routes from "../routes"; 3 | import { 4 | postRegisterView, 5 | postAddComment 6 | } from "../controllers/videoController"; 7 | 8 | const apiRouter = express.Router(); 9 | 10 | apiRouter.post(routes.registerView, postRegisterView); 11 | apiRouter.post(routes.addComment, postAddComment); 12 | 13 | export default apiRouter; 14 | -------------------------------------------------------------------------------- /src/routers/globalRouter.js: -------------------------------------------------------------------------------- 1 | import express from "express"; 2 | import passport from "passport"; 3 | import routes from "../routes"; 4 | import { home, search } from "../controllers/videoController"; 5 | import { 6 | getJoin, 7 | getLogin, 8 | logout, 9 | postJoin, 10 | postLogin, 11 | githubLogin, 12 | postGithubLogIn, 13 | getMe, 14 | facebookLogin, 15 | postFacebookLogin 16 | } from "../controllers/userController"; 17 | import { onlyPublic, onlyPrivate } from "../middlewares"; 18 | 19 | const globalRouter = express.Router(); 20 | 21 | globalRouter.get(routes.join, onlyPublic, getJoin); 22 | globalRouter.post(routes.join, onlyPublic, postJoin, postLogin); 23 | 24 | globalRouter.get(routes.login, onlyPublic, getLogin); 25 | globalRouter.post(routes.login, onlyPublic, postLogin); 26 | 27 | globalRouter.get(routes.home, home); 28 | globalRouter.get(routes.search, search); 29 | globalRouter.get(routes.logout, onlyPrivate, logout); 30 | 31 | globalRouter.get(routes.gitHub, githubLogin); 32 | 33 | globalRouter.get( 34 | routes.githubCallback, 35 | passport.authenticate("github", { failureRedirect: "/login" }), 36 | postGithubLogIn 37 | ); 38 | 39 | globalRouter.get(routes.me, getMe); 40 | 41 | globalRouter.get(routes.facebook, facebookLogin); 42 | globalRouter.get( 43 | routes.facebookCallback, 44 | passport.authenticate("facebook", { failureRedirect: "/login" }), 45 | postFacebookLogin 46 | ); 47 | 48 | export default globalRouter; 49 | -------------------------------------------------------------------------------- /src/routers/userRouter.js: -------------------------------------------------------------------------------- 1 | import express from "express"; 2 | import routes from "../routes"; 3 | import { 4 | userDetail, 5 | getEditProfile, 6 | getChangePassword, 7 | postEditProfile, 8 | postChangePassword 9 | } from "../controllers/userController"; 10 | import { onlyPrivate, uploadAvatar } from "../middlewares"; 11 | 12 | const userRouter = express.Router(); 13 | 14 | userRouter.get(routes.editProfile, onlyPrivate, getEditProfile); 15 | userRouter.post(routes.editProfile, onlyPrivate, uploadAvatar, postEditProfile); 16 | 17 | userRouter.get(routes.changePassword, onlyPrivate, getChangePassword); 18 | userRouter.post(routes.changePassword, onlyPrivate, postChangePassword); 19 | userRouter.get(routes.userDetail(), userDetail); 20 | 21 | export default userRouter; 22 | -------------------------------------------------------------------------------- /src/routers/videoRouter.js: -------------------------------------------------------------------------------- 1 | import express from "express"; 2 | import routes from "../routes"; 3 | import { 4 | getUpload, 5 | postUpload, 6 | videoDetail, 7 | deleteVideo, 8 | getEditVideo, 9 | postEditVideo 10 | } from "../controllers/videoController"; 11 | import { uploadVideo, onlyPrivate } from "../middlewares"; 12 | 13 | const videoRouter = express.Router(); 14 | 15 | // Upload 16 | videoRouter.get(routes.upload, onlyPrivate, getUpload); 17 | videoRouter.post(routes.upload, onlyPrivate, uploadVideo, postUpload); 18 | 19 | // Video Detail 20 | videoRouter.get(routes.videoDetail(), videoDetail); 21 | 22 | // Edit Video 23 | videoRouter.get(routes.editVideo(), onlyPrivate, getEditVideo); 24 | videoRouter.post(routes.editVideo(), onlyPrivate, postEditVideo); 25 | 26 | // Delete Video 27 | videoRouter.get(routes.deleteVideo(), onlyPrivate, deleteVideo); 28 | 29 | export default videoRouter; 30 | -------------------------------------------------------------------------------- /src/routes.js: -------------------------------------------------------------------------------- 1 | // Global 2 | const HOME = "/"; 3 | const JOIN = "/join"; 4 | const LOGIN = "/login"; 5 | const LOGOUT = "/logout"; 6 | const SEARCH = "/search"; 7 | 8 | // Users 9 | 10 | const USERS = "/users"; 11 | const USER_DETAIL = "/:id"; 12 | const EDIT_PROFILE = "/edit-profile"; 13 | const CHANGE_PASSWORD = "/change-password"; 14 | const ME = "/me"; 15 | 16 | // Videos 17 | 18 | const VIDEOS = "/videos"; 19 | const UPLOAD = "/upload"; 20 | const VIDEO_DETAIL = "/:id"; 21 | const EDIT_VIDEO = "/:id/edit"; 22 | const DELETE_VIDEO = "/:id/delete"; 23 | 24 | // Github 25 | 26 | const GITHUB = "/auth/github"; 27 | const GITHUB_CALLBACK = "/auth/github/callback"; 28 | 29 | // Facebook 30 | 31 | const FB = "/auth/facebook"; 32 | const FB_CALLBACK = "/auth/facebook/callback"; 33 | 34 | // API 35 | 36 | const API = "/api"; 37 | const REGISTER_VIEW = "/:id/view"; 38 | const ADD_COMMENT = "/:id/comment"; 39 | 40 | const routes = { 41 | home: HOME, 42 | join: JOIN, 43 | login: LOGIN, 44 | logout: LOGOUT, 45 | search: SEARCH, 46 | users: USERS, 47 | userDetail: id => { 48 | if (id) { 49 | return `/users/${id}`; 50 | } else { 51 | return USER_DETAIL; 52 | } 53 | }, 54 | editProfile: EDIT_PROFILE, 55 | changePassword: CHANGE_PASSWORD, 56 | videos: VIDEOS, 57 | upload: UPLOAD, 58 | videoDetail: id => { 59 | if (id) { 60 | return `/videos/${id}`; 61 | } else { 62 | return VIDEO_DETAIL; 63 | } 64 | }, 65 | editVideo: id => { 66 | if (id) { 67 | return `/videos/${id}/edit`; 68 | } else { 69 | return EDIT_VIDEO; 70 | } 71 | }, 72 | deleteVideo: id => { 73 | if (id) { 74 | return `/videos/${id}/delete`; 75 | } else { 76 | return DELETE_VIDEO; 77 | } 78 | }, 79 | gitHub: GITHUB, 80 | githubCallback: GITHUB_CALLBACK, 81 | me: ME, 82 | facebook: FB, 83 | facebookCallback: FB_CALLBACK, 84 | api: API, 85 | registerView: REGISTER_VIEW, 86 | addComment: ADD_COMMENT 87 | }; 88 | 89 | export default routes; 90 | -------------------------------------------------------------------------------- /src/views/changePassword.pug: -------------------------------------------------------------------------------- 1 | extends layouts/main 2 | 3 | block content 4 | .form-container 5 | form(action=`/users${routes.changePassword}`, method="post") 6 | input(type="password", name="oldPassword", placeholder="Current Password") 7 | input(type="password", name="newPassword", placeholder="New Password") 8 | input(type="password", name="newPassword1", placeholder="Verify New Password") 9 | input(type="submit", value="Change Password") -------------------------------------------------------------------------------- /src/views/deleteVideo.pug: -------------------------------------------------------------------------------- 1 | extends layouts/main 2 | 3 | block content 4 | p Delete Video -------------------------------------------------------------------------------- /src/views/editProfile.pug: -------------------------------------------------------------------------------- 1 | extends layouts/main 2 | 3 | block content 4 | .form-container 5 | form(action=`/users${routes.editProfile}`, method="post", enctype="multipart/form-data") 6 | .fileUpload 7 | label(for="avatar") Avatar 8 | input(type="file", id="avatar", name="avatar", accept="image/*") 9 | input(type="text", placeholder="Name", name="name", value=loggedUser.name) 10 | input(type="email", placeholder="Email", name="email", value=loggedUser.email) 11 | input(type="submit", value="Update Profile") 12 | -------------------------------------------------------------------------------- /src/views/editVideo.pug: -------------------------------------------------------------------------------- 1 | extends layouts/main 2 | 3 | block content 4 | .form-container 5 | form(action=routes.editVideo(video.id), method="post") 6 | input(type="text", placeholder="Title", name="title", value=video.title) 7 | textarea(name="description", placeholder="Description")=video.description 8 | input(type="submit", value="Update Video") 9 | a.form-container__link(href=routes.deleteVideo(video.id)) 10 | button.delete Delete Video -------------------------------------------------------------------------------- /src/views/home.pug: -------------------------------------------------------------------------------- 1 | extends layouts/main 2 | include mixins/videoBlock 3 | 4 | block content 5 | .home-videos 6 | each item in videos 7 | +videoBlock({ 8 | id:item.id, 9 | title:item.title, 10 | views:item.views, 11 | videoFile:item.fileUrl 12 | }) -------------------------------------------------------------------------------- /src/views/join.pug: -------------------------------------------------------------------------------- 1 | extends layouts/main 2 | 3 | block content 4 | .form-container 5 | form(action=routes.join, method="post") 6 | input(type="text", name="name", placeholder="Full Name",required=true) 7 | input(type="email", name="email", placeholder="Email",required=true) 8 | input(type="password", name="password", placeholder="Password",required=true) 9 | input(type="password", name="password2", placeholder="Verify Password",required=true) 10 | input(type="submit",value="Join Now") 11 | include partials/socialLogin 12 | -------------------------------------------------------------------------------- /src/views/layouts/main.pug: -------------------------------------------------------------------------------- 1 | include ../mixins/message 2 | 3 | doctype html 4 | html 5 | head 6 | link(rel="stylesheet", href="https://use.fontawesome.com/releases/v5.5.0/css/all.css" integrity="sha384-B4dIYHKNBt8Bc12p+WXckhzcICo0wtJAoU8YZTY5qE0Id1GSseTk6S+L3BlXeVIU", crossorigin="anonymous") 7 | title #{pageTitle} | #{siteName} 8 | link(rel="stylesheet", href="/static/styles.css") 9 | body 10 | if messages.error 11 | +message({ 12 | type:'error', 13 | text:messages.error 14 | }) 15 | else if messages.info 16 | +message({ 17 | type:'info', 18 | text:messages.info 19 | }) 20 | else if messages.success 21 | +message({ 22 | type:'success', 23 | text:messages.success 24 | }) 25 | include ../partials/header 26 | main 27 | block content 28 | include ../partials/footer 29 | script(src="/static/main.js") -------------------------------------------------------------------------------- /src/views/login.pug: -------------------------------------------------------------------------------- 1 | extends layouts/main 2 | 3 | block content 4 | .form-container 5 | form(action=routes.login, method="post") 6 | input(type="email", name="email", placeholder="Email", required=true) 7 | input(type="password", name="password", placeholder="Password", required=true) 8 | input(type="submit",value="Log In") 9 | include partials/socialLogin 10 | -------------------------------------------------------------------------------- /src/views/mixins/message.pug: -------------------------------------------------------------------------------- 1 | mixin message(message={}) 2 | .flash-message__container(class=message.type) 3 | span.flash-message__text=message.text -------------------------------------------------------------------------------- /src/views/mixins/videoBlock.pug: -------------------------------------------------------------------------------- 1 | mixin videoBlock(video = {}) 2 | .videoBlock 3 | a(href=routes.videoDetail(video.id)) 4 | video.videoBlock__thumbnail(src=video.videoFile, controls=false) 5 | h4.videoBlock__title=video.title 6 | if video.views === 1 7 | h6.videoBlock__views 1 view 8 | else 9 | h6.videoBlock__views #{video.views} views -------------------------------------------------------------------------------- /src/views/mixins/videoPlayer.pug: -------------------------------------------------------------------------------- 1 | mixin videoPlayer(video = {}) 2 | .videoPlayer#jsVideoPlayer 3 | video(src=video.src) 4 | .videoPlayer__controls 5 | .videoPlayer__column 6 | span#jsVolumeBtn 7 | i.fas.fa-volume-up 8 | span 9 | span#currentTime 00:00:00 10 | span / 11 | span#totalTime 12 | input.videoPlayer__volume#jsVolume(type="range", min="0" max="1" value="0.5" step="0.1") 13 | .videoPlayer__column 14 | span#jsPlayButton 15 | i.fas.fa-play 16 | .videoPlayer__column 17 | span#jsFullScreen 18 | i.fas.fa-expand 19 | -------------------------------------------------------------------------------- /src/views/partials/footer.pug: -------------------------------------------------------------------------------- 1 | footer.footer 2 | .footer__icon 3 | i.fab.fa-youtube 4 | span.footer__text #{siteName} #{new Date().getFullYear()} © -------------------------------------------------------------------------------- /src/views/partials/header.pug: -------------------------------------------------------------------------------- 1 | header.header 2 | .header__wrapper 3 | .header__column 4 | a(href=routes.home) 5 | i.fab.fa-youtube 6 | .header__column 7 | form(action=routes.search, method="get") 8 | input(type="text", placeholder="Search by term...", name="term") 9 | .header__column 10 | ul 11 | if !loggedUser 12 | li 13 | a(href=routes.join) Join 14 | li 15 | a(href=routes.login) Log In 16 | else 17 | li 18 | a(href=`/videos${routes.upload}`) Upload 19 | li 20 | a(href=routes.me) Profile 21 | li 22 | a(href=routes.logout) Log Out 23 | -------------------------------------------------------------------------------- /src/views/partials/socialLogin.pug: -------------------------------------------------------------------------------- 1 | .social-login 2 | button.social-login--github 3 | a(href=routes.gitHub) 4 | span 5 | i.fab.fa-github 6 | |Continue with Github 7 | button.social-login--facebook 8 | a(href=routes.facebook) 9 | span 10 | i.fab.fa-facebook 11 | |Continue with Facebook -------------------------------------------------------------------------------- /src/views/search.pug: -------------------------------------------------------------------------------- 1 | extends layouts/main 2 | include mixins/videoBlock 3 | 4 | block content 5 | .search__header 6 | h3 Searching for: #{searchingBy} 7 | .search__videos 8 | if videos.length === 0 9 | h5 No Videos Found 10 | each item in videos 11 | +videoBlock({ 12 | title:item.title, 13 | views:item.views, 14 | videoFile:item.videoFile, 15 | id:item.id 16 | }) -------------------------------------------------------------------------------- /src/views/upload.pug: -------------------------------------------------------------------------------- 1 | extends layouts/main 2 | 3 | block content 4 | .form-container 5 | .record-container#jsRecordContainer 6 | video#jsVideoPreview 7 | button#jsRecordBtn Start Recording 8 | form(action=`/videos${routes.upload}`, method="post", enctype="multipart/form-data") 9 | div.fileUpload 10 | label(for="file") Video File 11 | input(type="file", id="file", name="videoFile", required=true, accept="video/*") 12 | input(type="text", placeholder="Title", name="title", required=true) 13 | textarea(name="description", placeholder="Description", required=true) 14 | input(type="submit", value="Upload Video") 15 | -------------------------------------------------------------------------------- /src/views/userDetail.pug: -------------------------------------------------------------------------------- 1 | extends layouts/main 2 | include mixins/videoBlock 3 | 4 | block content 5 | .user-profile 6 | .user-profile__header 7 | img.u-avatar(src=user.avatarUrl) 8 | h4.profile__username=user.name 9 | if loggedUser && loggedUser.id === user.id 10 | .user-profile__btns 11 | a(href=`/users${routes.editProfile}`) 12 | button ✏️ Edit Profile 13 | a(href=`/users${routes.changePassword}`) 14 | button 🔒 Change Password 15 | .home-videos 16 | each item in user.videos 17 | +videoBlock({ 18 | id:item.id, 19 | title:item.title, 20 | views:item.views, 21 | videoFile:item.fileUrl 22 | }) 23 | -------------------------------------------------------------------------------- /src/views/videoDetail.pug: -------------------------------------------------------------------------------- 1 | extends layouts/main 2 | include mixins/videoPlayer 3 | 4 | block content 5 | .video-detail__container 6 | +videoPlayer({ 7 | src:video.fileUrl 8 | }) 9 | .video__info 10 | if loggedUser && video.creator.id === loggedUser.id 11 | a(href=routes.editVideo(video.id)) 12 | button Edit video 13 | h5.video__title=video.title 14 | p.video__description=video.description 15 | if video.views === 1 16 | span.video__views 1 view 17 | else 18 | span.video__views #{video.views} views 19 | .video__author 20 | |Uploaded by 21 | a(href=routes.userDetail(video.creator.id))=video.creator.name 22 | .video__comments 23 | if video.comments.length === 1 24 | span.video__comment-number 25 | span#jsCommentNumber 1 26 | | comment 27 | else 28 | span.video__comment-number 29 | span#jsCommentNumber=video.comments.length 30 | | comments 31 | form.add__comment#jsAddComment 32 | input(type="text", placeholder="Add a comment") 33 | ul.video__comments-list#jsCommentList 34 | each comment in video.comments.reverse() 35 | li 36 | span=comment.text -------------------------------------------------------------------------------- /src/webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | const autoprefixer = require("autoprefixer"); 3 | const ExtractCSS = require("extract-text-webpack-plugin"); 4 | 5 | const MODE = process.env.WEBPACK_ENV; 6 | const ENTRY_FILE = path.resolve(__dirname, "assets", "js", "main.js"); 7 | const OUTPUT_DIR = path.join(__dirname, "static"); 8 | 9 | const config = { 10 | entry: ["@babel/polyfill", ENTRY_FILE], 11 | mode: MODE, 12 | module: { 13 | rules: [ 14 | { 15 | test: /\.(js)$/, 16 | use: [ 17 | { 18 | loader: "babel-loader" 19 | } 20 | ] 21 | }, 22 | { 23 | test: /\.(scss)$/, 24 | use: ExtractCSS.extract([ 25 | { 26 | loader: "css-loader" 27 | }, 28 | { 29 | loader: "postcss-loader", 30 | options: { 31 | plugins() { 32 | return [autoprefixer({ browsers: "cover 99.5%" })]; 33 | } 34 | } 35 | }, 36 | { 37 | loader: "sass-loader" 38 | } 39 | ]) 40 | } 41 | ] 42 | }, 43 | output: { 44 | path: OUTPUT_DIR, 45 | filename: "[name].js" 46 | }, 47 | plugins: [new ExtractCSS("styles.css")] 48 | }; 49 | 50 | module.exports = config; 51 | --------------------------------------------------------------------------------