├── .env ├── .gitignore ├── README.md ├── client ├── package-lock.json ├── package.json ├── public │ ├── favicon.ico │ ├── index.html │ ├── logo192.png │ ├── logo512.png │ ├── manifest.json │ └── robots.txt ├── src │ ├── App.tsx │ ├── PageRender.tsx │ ├── SocketClient.tsx │ ├── components │ │ ├── alert │ │ │ ├── Alert.tsx │ │ │ ├── Loading.tsx │ │ │ └── Toast.tsx │ │ ├── auth │ │ │ ├── LoginPass.tsx │ │ │ ├── LoginSMS.tsx │ │ │ ├── RegisterForm.tsx │ │ │ └── SocialLogin.tsx │ │ ├── blog │ │ │ └── DisplayBlog.tsx │ │ ├── cards │ │ │ ├── CardHoriz.tsx │ │ │ ├── CardVert.tsx │ │ │ └── CreateForm.tsx │ │ ├── comments │ │ │ ├── AvatarComment.tsx │ │ │ ├── AvatarReply.tsx │ │ │ ├── CommentList.tsx │ │ │ ├── Comments.tsx │ │ │ └── Input.tsx │ │ ├── editor │ │ │ ├── LiteQuill.tsx │ │ │ └── ReactQuill.tsx │ │ ├── global │ │ │ ├── Footer.tsx │ │ │ ├── Header.tsx │ │ │ ├── Loading.tsx │ │ │ ├── Menu.tsx │ │ │ ├── NotFound.tsx │ │ │ ├── Pagination.tsx │ │ │ └── Search.tsx │ │ └── profile │ │ │ ├── OtherInfo.tsx │ │ │ ├── UserBlogs.tsx │ │ │ └── UserInfo.tsx │ ├── index.tsx │ ├── pages │ │ ├── active │ │ │ └── [slug].tsx │ │ ├── blog │ │ │ └── [slug].tsx │ │ ├── blogs │ │ │ └── [slug].tsx │ │ ├── category.tsx │ │ ├── create_blog.tsx │ │ ├── forgot_password.tsx │ │ ├── index.tsx │ │ ├── login.tsx │ │ ├── profile │ │ │ └── [slug].tsx │ │ ├── register.tsx │ │ ├── reset_password │ │ │ └── [slug].tsx │ │ └── update_blog │ │ │ └── [slug].tsx │ ├── react-app-env.d.ts │ ├── redux │ │ ├── actions │ │ │ ├── authAction.ts │ │ │ ├── blogAction.ts │ │ │ ├── categoryAction.ts │ │ │ ├── commentAction.ts │ │ │ └── userAction.ts │ │ ├── reducers │ │ │ ├── alertReducer.ts │ │ │ ├── authReducer.ts │ │ │ ├── blogsCategoryReducer.ts │ │ │ ├── blogsUserReducer.ts │ │ │ ├── categoryReducer.ts │ │ │ ├── commentReducer.ts │ │ │ ├── homeBlogsReducer.ts │ │ │ ├── index.ts │ │ │ ├── otherInfoReducer.ts │ │ │ └── socketReducer.ts │ │ ├── store.ts │ │ └── types │ │ │ ├── alertType.ts │ │ │ ├── authType.ts │ │ │ ├── blogType.ts │ │ │ ├── categoryType.ts │ │ │ ├── commentType.ts │ │ │ ├── profileType.ts │ │ │ └── socketType.ts │ ├── styles │ │ ├── alert.css │ │ ├── auth.css │ │ ├── blogs_category.css │ │ ├── category.css │ │ ├── comments.css │ │ ├── home.css │ │ ├── index.css │ │ ├── loading.css │ │ └── profile.css │ └── utils │ │ ├── FetchData.ts │ │ ├── ImageUpload.ts │ │ ├── TypeScript.ts │ │ ├── Valid.ts │ │ └── checkTokenExp.ts └── tsconfig.json ├── package-lock.json ├── package.json ├── server ├── config │ ├── database.ts │ ├── generateToken.ts │ ├── interface.ts │ ├── sendMail.ts │ ├── sendSMS.ts │ └── socket.ts ├── controllers │ ├── authCtrl.ts │ ├── blogCtrl.ts │ ├── categoryCtrl.ts │ ├── commentCtrl.ts │ └── userCtrl.ts ├── index.ts ├── middleware │ ├── auth.ts │ └── vaild.ts ├── models │ ├── blogModel.ts │ ├── categoryModel.ts │ ├── commentModel.ts │ └── userModel.ts └── routes │ ├── authRouter.ts │ ├── blogRouter.ts │ ├── categoryRouter.ts │ ├── commentRouter.ts │ ├── index.ts │ └── userRouter.ts └── tsconfig.json /.env: -------------------------------------------------------------------------------- 1 | MONGODB_URL = 2 | 3 | ACTIVE_TOKEN_SECRET = your_active_token_secret 4 | ACCESS_TOKEN_SECRET = your_access_token_secret 5 | REFRESH_TOKEN_SECRET = your_refresh_token_secret 6 | 7 | BASE_URL = http://localhost:3000 8 | 9 | MAIL_CLIENT_ID = 10 | MAIL_CLIENT_SECRET = 11 | MAIL_REFRESH_TOKEN = 12 | SENDER_EMAIL_ADDRESS = 13 | 14 | 15 | TWILIO_ACCOUNT_SID = 16 | TWILIO_AUTH_TOKEN = 17 | TWILIO_PHONE_NUMBER = 18 | TWILIO_SERVICE_ID = 19 | 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # MERN STACK + TYPESCRIPT + REDUX | BLOG TUTORIAL 2 | ## MERN Stack Build a blog app using MERN + Typescript + Redux + Bootstrap 5 + ReactQuill + Socket.io + Twilio 3 | > + Register, login with Email or Phone number. 4 | > + Quick login with Google, Facebook, SMS. 5 | > + Forgot password, reset password and register a new account by Email or SMS verification. 6 | > + Update personal information (name, password and avatar) 7 | > + Create new blog with React quill. 8 | > + Comment realtime with Socket.io 9 | > + Pagination, search with autocomplete Mongodb 10 | 11 | ## Author: Dev A.T Viet Nam 12 | 13 | ## Youtube tutorials: https://youtube.com/playlist?list=PLs4co9a6NhMw7xB4xPSkSQRM8uQVAZak6 14 | 15 | ## Install dependencies for server 16 | ### `npm install` 17 | 18 | ## Install dependencies for client 19 | ### cd client ---> `npm install` 20 | 21 | ## Connect to your mongodb and add info in .env 22 | 23 | ## Run the Express server only 24 | ### `npm run dev` 25 | 26 | ## Run the React client only 27 | ### cd client ---> `npm start` 28 | 29 | ## Server runs on http://localhost:5000 and client on http://localhost:3000 30 | 31 | ## 🔥 Donate 32 | > + 👉 Buy Me a Coffee . Thank You ! 💗 : 33 | > + 👉 https://www.buymeacoffee.com/QK1DkYS 34 | > + 👉 Paypal : https://paypal.me/tuananh251192 35 | 36 | ### 👻👻VietNam: 37 | > + 👉Vietcombank: 0061001044348 (LE TUAN ANH) 38 | > + 👉Momo : 0374481936 39 | -------------------------------------------------------------------------------- /client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "client", 3 | "proxy": "http://localhost:5000", 4 | "version": "0.1.0", 5 | "private": true, 6 | "dependencies": { 7 | "@testing-library/jest-dom": "^5.12.0", 8 | "@testing-library/react": "^11.2.7", 9 | "@testing-library/user-event": "^12.8.3", 10 | "@types/jest": "^26.0.23", 11 | "@types/node": "^12.20.13", 12 | "@types/react": "^17.0.6", 13 | "@types/react-dom": "^17.0.5", 14 | "axios": "^0.21.1", 15 | "jwt-decode": "^3.1.2", 16 | "react": "^17.0.2", 17 | "react-dom": "^17.0.2", 18 | "react-facebook-login-lite": "^1.0.0", 19 | "react-google-login-lite": "^1.0.0", 20 | "react-quill": "^1.3.5", 21 | "react-redux": "^7.2.4", 22 | "react-router-dom": "^5.2.0", 23 | "react-scripts": "4.0.3", 24 | "redux": "^4.1.0", 25 | "redux-devtools-extension": "^2.13.9", 26 | "redux-thunk": "^2.3.0", 27 | "socket.io-client": "^4.2.0", 28 | "typescript": "^4.2.4", 29 | "web-vitals": "^1.1.2" 30 | }, 31 | "scripts": { 32 | "start": "react-scripts start", 33 | "build": "react-scripts build", 34 | "test": "react-scripts test", 35 | "eject": "react-scripts eject" 36 | }, 37 | "eslintConfig": { 38 | "extends": [ 39 | "react-app", 40 | "react-app/jest" 41 | ] 42 | }, 43 | "browserslist": { 44 | "production": [ 45 | ">0.2%", 46 | "not dead", 47 | "not op_mini all" 48 | ], 49 | "development": [ 50 | "last 1 chrome version", 51 | "last 1 firefox version", 52 | "last 1 safari version" 53 | ] 54 | }, 55 | "devDependencies": { 56 | "@types/react-router-dom": "^5.1.7" 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /client/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devat-youtuber/MERN-Typescript-Blogdev/939657f24a945f642c9680a989a9a440f027004a/client/public/favicon.ico -------------------------------------------------------------------------------- /client/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 17 | 18 | 27 | 28 | 29 | React App 30 | 31 | 32 | 33 |
34 | 44 | 45 | 46 | 47 | 48 | -------------------------------------------------------------------------------- /client/public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devat-youtuber/MERN-Typescript-Blogdev/939657f24a945f642c9680a989a9a440f027004a/client/public/logo192.png -------------------------------------------------------------------------------- /client/public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/devat-youtuber/MERN-Typescript-Blogdev/939657f24a945f642c9680a989a9a440f027004a/client/public/logo512.png -------------------------------------------------------------------------------- /client/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /client/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /client/src/App.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from 'react' 2 | import { BrowserRouter as Router, Route, Switch } from 'react-router-dom' 3 | import { useDispatch } from 'react-redux' 4 | 5 | import PageRender from './PageRender' 6 | import Header from './components/global/Header' 7 | import Footer from './components/global/Footer' 8 | 9 | import { Alert } from './components/alert/Alert' 10 | 11 | import { refreshToken } from './redux/actions/authAction' 12 | import { getCategories } from './redux/actions/categoryAction' 13 | import { getHomeBlogs } from './redux/actions/blogAction' 14 | 15 | import io from 'socket.io-client' 16 | 17 | import SocketClient from './SocketClient' 18 | 19 | 20 | const App = () => { 21 | const dispatch = useDispatch() 22 | 23 | useEffect(() => { 24 | dispatch(getCategories()) 25 | dispatch(refreshToken()) 26 | dispatch(getHomeBlogs()) 27 | },[dispatch]) 28 | 29 | useEffect(() => { 30 | const socket = io() 31 | dispatch({ type: 'SOCKET', payload: socket }) 32 | return () => { socket.close() } 33 | },[dispatch]) 34 | 35 | return ( 36 |
37 | 38 | 39 | 40 |
41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 |
51 | ) 52 | } 53 | 54 | export default App 55 | 56 | -------------------------------------------------------------------------------- /client/src/PageRender.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { useParams } from 'react-router-dom' 3 | import { IParams } from './utils/TypeScript' 4 | import NotFound from './components/global/NotFound' 5 | 6 | 7 | const generatePage = (name: string) => { 8 | const component = () => require(`./pages/${name}`).default 9 | 10 | try { 11 | return React.createElement(component()) 12 | } catch (err) { 13 | return ; 14 | } 15 | } 16 | 17 | const PageRender = () => { 18 | const { page, slug }: IParams = useParams() 19 | 20 | let name = ''; 21 | 22 | if(page){ 23 | name = slug ? `${page}/[slug]` : `${page}` 24 | } 25 | 26 | return generatePage(name) 27 | } 28 | 29 | export default PageRender 30 | -------------------------------------------------------------------------------- /client/src/SocketClient.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from 'react' 2 | import { useDispatch, useSelector } from 'react-redux' 3 | 4 | import { RootStore, IComment } from './utils/TypeScript' 5 | 6 | import { 7 | CREATE_COMMENT, 8 | REPLY_COMMENT, 9 | UPDATE_COMMENT, 10 | UPDATE_REPLY, 11 | DELETE_COMMENT, 12 | DELETE_REPLY 13 | } from './redux/types/commentType' 14 | 15 | const SocketClient = () => { 16 | const { socket } = useSelector((state: RootStore) => state) 17 | const dispatch = useDispatch() 18 | 19 | // Create Comment 20 | useEffect(() => { 21 | if(!socket) return; 22 | 23 | socket.on('createComment', (data: IComment) => { 24 | dispatch({ type: CREATE_COMMENT, payload: data }) 25 | }) 26 | 27 | return () => { socket.off('createComment') } 28 | 29 | },[socket, dispatch]) 30 | 31 | // Reply Comment 32 | useEffect(() => { 33 | if(!socket) return; 34 | 35 | socket.on('replyComment', (data: IComment) => { 36 | dispatch({ type: REPLY_COMMENT, payload: data }) 37 | }) 38 | 39 | return () => { socket.off('replyComment') } 40 | 41 | },[socket, dispatch]) 42 | 43 | // Update Comment 44 | useEffect(() => { 45 | if(!socket) return; 46 | 47 | socket.on('updateComment', (data: IComment) => { 48 | dispatch({ 49 | type: data.comment_root ? UPDATE_REPLY : UPDATE_COMMENT, 50 | payload: data 51 | }) 52 | }) 53 | 54 | return () => { socket.off('updateComment') } 55 | },[socket, dispatch]) 56 | 57 | 58 | // Delete Comment 59 | useEffect(() => { 60 | if(!socket) return; 61 | 62 | socket.on('deleteComment', (data: IComment) => { 63 | dispatch({ 64 | type: data.comment_root ? DELETE_REPLY : DELETE_COMMENT, 65 | payload: data 66 | }) 67 | }) 68 | 69 | return () => { socket.off('deleteComment') } 70 | },[socket, dispatch]) 71 | 72 | 73 | return ( 74 |
75 | 76 |
77 | ) 78 | } 79 | 80 | export default SocketClient 81 | -------------------------------------------------------------------------------- /client/src/components/alert/Alert.tsx: -------------------------------------------------------------------------------- 1 | import { useSelector } from 'react-redux' 2 | import { RootStore } from '../../utils/TypeScript' 3 | 4 | import Loading from './Loading' 5 | import Toast from './Toast' 6 | 7 | export const Alert = () => { 8 | const { alert } = useSelector((state: RootStore) => state) 9 | 10 | return ( 11 |
12 | { alert.loading && } 13 | 14 | { 15 | alert.errors && 16 | 21 | } 22 | 23 | { 24 | alert.success && 25 | 30 | } 31 |
32 | ) 33 | } 34 | 35 | export const showErrMsg = (msg: string) => { 36 | return
{msg}
37 | } 38 | 39 | export const showSuccessMsg = (msg: string) => { 40 | return
{msg}
41 | } -------------------------------------------------------------------------------- /client/src/components/alert/Loading.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | const Loading = () => { 4 | return ( 5 |
7 | 8 | 10 | Loading 11 | 12 |
13 | ) 14 | } 15 | 16 | export default Loading 17 | -------------------------------------------------------------------------------- /client/src/components/alert/Toast.tsx: -------------------------------------------------------------------------------- 1 | import { useDispatch } from 'react-redux' 2 | import { ALERT } from '../../redux/types/alertType' 3 | 4 | interface IProps { 5 | title: string 6 | body: string | string[] 7 | bgColor: string 8 | } 9 | 10 | const Toast = ({title, body, bgColor}: IProps) => { 11 | 12 | const dispatch = useDispatch() 13 | 14 | const handleClose = () => { 15 | dispatch({ type: ALERT, payload: {} }) 16 | } 17 | 18 | return ( 19 |
21 | 22 |
23 | {title} 24 |
28 | 29 |
30 | { 31 | typeof(body) === 'string' 32 | ? body 33 | :
    34 | { 35 | body.map((text, index) => ( 36 |
  • {text}
  • 37 | )) 38 | } 39 |
40 | } 41 |
42 | 43 |
44 | ) 45 | } 46 | 47 | export default Toast 48 | -------------------------------------------------------------------------------- /client/src/components/auth/LoginPass.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react' 2 | import { useDispatch } from 'react-redux' 3 | 4 | import { InputChange, FormSubmit } from '../../utils/TypeScript' 5 | import { login } from '../../redux/actions/authAction' 6 | 7 | 8 | const LoginPass = () => { 9 | const initialState = { account: '', password: '' } 10 | const [userLogin, setUserLogin] = useState(initialState) 11 | const { account, password } = userLogin 12 | 13 | const [typePass, setTypePass] = useState(false) 14 | 15 | const dispatch = useDispatch() 16 | 17 | const handleChangeInput = (e: InputChange) => { 18 | const {value, name} = e.target 19 | setUserLogin({...userLogin, [name]:value}) 20 | } 21 | 22 | const handleSubmit = (e: FormSubmit) => { 23 | e.preventDefault() 24 | dispatch(login(userLogin)) 25 | } 26 | 27 | return ( 28 |
29 |
30 | 33 | 34 | 36 |
37 | 38 |
39 | 40 | 41 |
42 | 48 | 49 | setTypePass(!typePass)}> 50 | {typePass ? 'Hide' : 'Show'} 51 | 52 |
53 |
54 | 55 | 59 |
60 | ) 61 | } 62 | 63 | export default LoginPass 64 | -------------------------------------------------------------------------------- /client/src/components/auth/LoginSMS.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react' 2 | import { useDispatch } from 'react-redux' 3 | 4 | import { FormSubmit } from '../../utils/TypeScript' 5 | import { loginSMS } from '../../redux/actions/authAction' 6 | 7 | const LoginSMS = () => { 8 | const [phone, setPhone] = useState('') 9 | const dispatch = useDispatch() 10 | 11 | const handleSubmit = (e: FormSubmit) => { 12 | e.preventDefault() 13 | dispatch(loginSMS(phone)) 14 | } 15 | 16 | return ( 17 |
18 |
19 | 20 | 21 | setPhone(e.target.value)} 23 | placeholder="+84374481936" /> 24 |
25 | 26 | 30 |
31 | ) 32 | } 33 | 34 | export default LoginSMS 35 | -------------------------------------------------------------------------------- /client/src/components/auth/RegisterForm.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react' 2 | import { useDispatch } from 'react-redux' 3 | 4 | import { InputChange, FormSubmit } from '../../utils/TypeScript' 5 | import { register } from '../../redux/actions/authAction' 6 | 7 | 8 | const RegisterForm = () => { 9 | 10 | const initialState = { 11 | name: '', account: '', password: '', cf_password: '' 12 | } 13 | const [userRegister, setUserRegister] = useState(initialState) 14 | const { name, account, password, cf_password } = userRegister 15 | 16 | const [typePass, setTypePass] = useState(false) 17 | const [typeCfPass, setTypeCfPass] = useState(false) 18 | 19 | const dispatch = useDispatch() 20 | 21 | const handleChangeInput = (e: InputChange) => { 22 | const {value, name} = e.target 23 | setUserRegister({...userRegister, [name]:value}) 24 | } 25 | 26 | const handleSubmit = (e: FormSubmit) => { 27 | e.preventDefault() 28 | dispatch(register(userRegister)) 29 | } 30 | 31 | return ( 32 |
33 |
34 | 35 | 36 | 39 |
40 | 41 |
42 | 45 | 46 | 49 |
50 | 51 |
52 | 53 | 54 |
55 | 62 | 63 | setTypePass(!typePass)}> 64 | {typePass ? 'Hide' : 'Show'} 65 | 66 |
67 |
68 | 69 |
70 | 73 | 74 |
75 | 82 | 83 | setTypeCfPass(!typeCfPass)}> 84 | {typeCfPass ? 'Hide' : 'Show'} 85 | 86 |
87 |
88 | 89 | 92 |
93 | ) 94 | } 95 | 96 | export default RegisterForm 97 | -------------------------------------------------------------------------------- /client/src/components/auth/SocialLogin.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { useDispatch } from 'react-redux' 3 | 4 | import { GoogleLogin, GoogleLoginResponse } from 'react-google-login-lite'; 5 | import { FacebookLogin, FacebookLoginAuthResponse } from 'react-facebook-login-lite'; 6 | 7 | import { googleLogin, facebookLogin } from '../../redux/actions/authAction' 8 | 9 | const SocialLogin = () => { 10 | const dispatch = useDispatch() 11 | 12 | const onSuccess = (googleUser: GoogleLoginResponse) => { 13 | const id_token = googleUser.getAuthResponse().id_token 14 | dispatch(googleLogin(id_token)) 15 | } 16 | 17 | const onFBSuccess = (response: FacebookLoginAuthResponse) => { 18 | const { accessToken, userID } = response.authResponse 19 | dispatch(facebookLogin(accessToken, userID)) 20 | } 21 | 22 | 23 | return ( 24 | <> 25 |
26 | 31 |
32 | 33 |
34 | 38 |
39 | 40 | ) 41 | } 42 | 43 | export default SocialLogin 44 | -------------------------------------------------------------------------------- /client/src/components/blog/DisplayBlog.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect, useCallback } from 'react' 2 | import { useDispatch, useSelector } from 'react-redux' 3 | import { Link, useHistory } from 'react-router-dom' 4 | 5 | import { IBlog, RootStore, IUser, IComment } from '../../utils/TypeScript' 6 | 7 | import Input from '../comments/Input' 8 | import Comments from '../comments/Comments' 9 | import Loading from '../global/Loading' 10 | import Pagination from '../global/Pagination' 11 | 12 | import { 13 | createComment, 14 | getComments 15 | } from '../../redux/actions/commentAction' 16 | 17 | 18 | interface IProps { 19 | blog: IBlog 20 | } 21 | 22 | const DisplayBlog: React.FC = ({blog}) => { 23 | const { auth, comments } = useSelector((state: RootStore) => state) 24 | const dispatch = useDispatch() 25 | 26 | const [showComments, setShowComments] = useState([]) 27 | const [loading, setLoading] = useState(false) 28 | 29 | const history = useHistory() 30 | 31 | 32 | const handleComment = (body: string) => { 33 | if(!auth.user || !auth.access_token) return; 34 | 35 | const data = { 36 | content: body, 37 | user: auth.user, 38 | blog_id: (blog._id as string), 39 | blog_user_id: (blog.user as IUser)._id, 40 | replyCM: [], 41 | createdAt: new Date().toISOString() 42 | } 43 | 44 | setShowComments([data, ...showComments]) 45 | dispatch(createComment(data, auth.access_token)) 46 | } 47 | 48 | 49 | useEffect(() => { 50 | setShowComments(comments.data) 51 | },[comments.data]) 52 | 53 | 54 | const fetchComments = useCallback(async(id: string, num = 1) => { 55 | setLoading(true) 56 | await dispatch(getComments(id, num)) 57 | setLoading(false) 58 | },[dispatch]) 59 | 60 | 61 | useEffect(() => { 62 | if(!blog._id) return; 63 | const num = history.location.search.slice(6) || 1; 64 | fetchComments(blog._id, num) 65 | },[blog._id, fetchComments, history]) 66 | 67 | const handlePagination = (num: number) => { 68 | if(!blog._id) return; 69 | fetchComments(blog._id, num) 70 | } 71 | 72 | 73 | return ( 74 |
75 |

77 | {blog.title} 78 |

79 | 80 |
81 | 82 | { 83 | typeof(blog.user) !== 'string' && 84 | `By: ${blog.user.name}` 85 | } 86 | 87 | 88 | 89 | { new Date(blog.createdAt).toLocaleString() } 90 | 91 |
92 | 93 |
96 | 97 |
98 |

✩ Comments ✩

99 | 100 | { 101 | auth.user 102 | ? 103 | :
104 | Please login to comment. 105 |
106 | } 107 | 108 | { 109 | loading 110 | ? 111 | : showComments?.map((comment, index) => ( 112 | 113 | )) 114 | } 115 | 116 | { 117 | comments.total > 1 && 118 | 122 | } 123 |
124 | ) 125 | } 126 | 127 | export default DisplayBlog 128 | -------------------------------------------------------------------------------- /client/src/components/cards/CardHoriz.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Link, useParams } from 'react-router-dom' 3 | import { useSelector, useDispatch } from 'react-redux' 4 | 5 | import { IBlog, IParams, RootStore } from '../../utils/TypeScript' 6 | 7 | import { deleteBlog } from '../../redux/actions/blogAction' 8 | 9 | 10 | interface IProps { 11 | blog: IBlog 12 | } 13 | 14 | const CardHoriz: React.FC = ({blog}) => { 15 | const { slug } = useParams() 16 | const { auth } = useSelector((state: RootStore) => state) 17 | const dispatch = useDispatch() 18 | 19 | const handleDelete = () => { 20 | if(!auth.user || !auth.access_token) return; 21 | 22 | if(slug !== auth.user._id) return dispatch({ 23 | type: 'ALERT', 24 | payload: { errors: 'Invalid Authentication.' } 25 | }) 26 | 27 | if(window.confirm("Do you want to delete this post?")){ 28 | dispatch(deleteBlog(blog, auth.access_token)) 29 | } 30 | } 31 | 32 | return ( 33 |
34 |
35 |
38 | { 39 | blog.thumbnail && 40 | <> 41 | { 42 | typeof(blog.thumbnail) === 'string' 43 | ? 44 | thumbnail 47 | 48 | :thumbnail 51 | } 52 | 53 | } 54 | 55 |
56 | 57 |
58 |
59 |
60 | 62 | {blog.title} 63 | 64 |
65 |

{blog.description}

66 | 67 | { 68 | blog.title && 69 |
72 | { 73 | (auth.user && slug === auth.user._id) && 74 |
75 | 76 | 77 | 78 | 79 | 81 |
82 | } 83 | 84 | {new Date(blog.createdAt).toLocaleString()} 85 | 86 |
87 | } 88 |
89 |
90 |
91 |
92 | ) 93 | } 94 | 95 | export default CardHoriz 96 | -------------------------------------------------------------------------------- /client/src/components/cards/CardVert.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { Link} from 'react-router-dom' 3 | 4 | import { IBlog } from '../../utils/TypeScript' 5 | 6 | interface IProps { 7 | blog: IBlog 8 | } 9 | 10 | const CardVert: React.FC = ({blog}) => { 11 | return ( 12 |
13 | { 14 | typeof(blog.thumbnail) === 'string' && 15 | ... 17 | } 18 | 19 |
20 |
21 | 24 | {blog.title.slice(0,50) + '...'} 25 | 26 |
27 |

28 | { blog.description.slice(0,100) + '...' } 29 |

30 | 31 |

32 | 33 | { 34 | typeof(blog.user) !== 'string' && 35 | 38 | By: {blog.user.name} 39 | 40 | } 41 | 42 | 43 | 44 | { new Date(blog.createdAt).toLocaleString() } 45 | 46 |

47 |
48 |
49 | ) 50 | } 51 | 52 | export default CardVert 53 | -------------------------------------------------------------------------------- /client/src/components/cards/CreateForm.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { useSelector } from 'react-redux' 3 | 4 | import { RootStore, IBlog, InputChange } from '../../utils/TypeScript' 5 | 6 | 7 | interface IProps { 8 | blog: IBlog, 9 | setBlog: (blog: IBlog) => void 10 | } 11 | 12 | const CreateForm: React.FC = ({blog, setBlog}) => { 13 | const { categories } = useSelector((state: RootStore) => state) 14 | 15 | const handleChangeInput = (e: InputChange) => { 16 | const { value, name } = e.target 17 | setBlog({...blog, [name]:value}) 18 | } 19 | 20 | const handleChangeThumbnail = (e: InputChange) => { 21 | const target = e.target as HTMLInputElement 22 | const files = target.files 23 | if(files){ 24 | const file = files[0] 25 | setBlog({...blog, thumbnail: file}) 26 | } 27 | } 28 | 29 | return ( 30 |
31 |
32 | 35 | 36 | 38 | {blog.title.length}/50 39 | 40 |
41 | 42 |
43 | 45 |
46 | 47 |
48 |