├── .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 |
49 |
50 |
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 |
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 |
27 |
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 |
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 |
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 |
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 |

47 |
48 | :
})
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 |
73 | )
74 | }
75 |
76 | export default CreateForm
77 |
--------------------------------------------------------------------------------
/client/src/components/comments/AvatarComment.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { Link } from 'react-router-dom'
3 |
4 | import { IUser } from '../../utils/TypeScript'
5 |
6 | interface IProps {
7 | user: IUser
8 | }
9 |
10 | const AvatarComment: React.FC = ({ user }) => {
11 | return (
12 |
13 |

14 |
15 |
16 |
17 | {user.name}
18 |
19 |
20 |
21 | )
22 | }
23 |
24 | export default AvatarComment
25 |
--------------------------------------------------------------------------------
/client/src/components/comments/AvatarReply.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { Link } from 'react-router-dom'
3 |
4 | import { IUser } from '../../utils/TypeScript'
5 |
6 | interface IProps {
7 | user: IUser
8 | reply_user?: IUser
9 | }
10 | const AvatarReply: React.FC = ({ user, reply_user }) => {
11 | return (
12 |
13 |

14 |
15 |
16 |
17 |
19 | { user.name }
20 |
21 |
22 |
23 |
24 | Reply to
25 | { reply_user?.name }
26 |
27 |
28 |
29 |
30 | )
31 | }
32 |
33 | export default AvatarReply
34 |
--------------------------------------------------------------------------------
/client/src/components/comments/CommentList.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState } from 'react'
2 | import { useDispatch, useSelector } from 'react-redux'
3 |
4 | import { IComment, RootStore } from '../../utils/TypeScript'
5 |
6 | import {
7 | replyComment,
8 | updateComment,
9 | deleteComment
10 | } from '../../redux/actions/commentAction'
11 |
12 | import Input from './Input'
13 |
14 | interface IProps {
15 | comment: IComment
16 | showReply: IComment[]
17 | setShowReply: (showReply: IComment[]) => void
18 | }
19 |
20 | const CommentList: React.FC = ({
21 | children, comment, showReply, setShowReply
22 | }) => {
23 | const [onReply, setOnReply] = useState(false)
24 | const { auth } = useSelector((state: RootStore) => state)
25 | const dispatch = useDispatch()
26 |
27 | const [edit, setEdit] = useState()
28 |
29 | const handleReply = (body: string) => {
30 | if(!auth.user || !auth.access_token) return;
31 |
32 | const data = {
33 | user: auth.user,
34 | blog_id: comment.blog_id,
35 | blog_user_id: comment.blog_user_id,
36 | content: body,
37 | replyCM: [],
38 | reply_user: comment.user,
39 | comment_root: comment.comment_root || comment._id,
40 | createdAt: new Date().toISOString()
41 | }
42 |
43 |
44 | setShowReply([data, ...showReply])
45 | dispatch(replyComment(data, auth.access_token))
46 | setOnReply(false)
47 | }
48 |
49 |
50 | const handleUpdate = (body: string) => {
51 | if(!auth.user || !auth.access_token || !edit) return;
52 |
53 | if(body === edit.content)
54 | return setEdit(undefined)
55 |
56 | const newComment = {...edit, content: body}
57 | dispatch(updateComment(newComment, auth.access_token))
58 | setEdit(undefined)
59 | }
60 |
61 | const handleDelete = (comment: IComment) => {
62 | if(!auth.user || !auth.access_token) return;
63 | dispatch(deleteComment(comment, auth.access_token))
64 | }
65 |
66 |
67 | const Nav = (comment: IComment) => {
68 | return(
69 |
70 | handleDelete(comment)} />
72 |
73 | setEdit(comment)} />
75 |
76 | )
77 | }
78 |
79 | return (
80 |
81 | {
82 | edit
83 | ?
88 |
89 | :
90 |
93 |
94 |
95 |
setOnReply(!onReply)}>
97 | {onReply ? '- Cancel -' :'- Reply -'}
98 |
99 |
100 |
101 |
102 | {
103 | comment.blog_user_id === auth.user?._id
104 | ? comment.user._id === auth.user._id
105 | ? Nav(comment)
106 | : handleDelete(comment)} />
108 | : comment.user._id === auth.user?._id && Nav(comment)
109 | }
110 |
111 |
112 |
113 | { new Date(comment.createdAt).toLocaleString() }
114 |
115 |
116 |
117 |
118 |
119 | }
120 |
121 | {
122 | onReply &&
123 | }
124 |
125 | { children }
126 |
127 | )
128 | }
129 |
130 | export default CommentList
131 |
--------------------------------------------------------------------------------
/client/src/components/comments/Comments.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState, useEffect } from 'react'
2 |
3 | import { IComment } from '../../utils/TypeScript'
4 |
5 | import AvatarComment from './AvatarComment'
6 | import AvatarReply from './AvatarReply'
7 | import CommentList from './CommentList'
8 |
9 |
10 | interface IProps {
11 | comment: IComment
12 | }
13 |
14 | const Comments: React.FC = ({ comment }) => {
15 | const [showReply, setShowReply] = useState([])
16 | const [next, setNext] = useState(2)
17 |
18 | useEffect(() => {
19 | if(!comment.replyCM) return;
20 | setShowReply(comment.replyCM)
21 | },[comment.replyCM])
22 |
23 | return (
24 |
28 |
29 |
30 |
35 | {
36 | showReply.slice(0, next).map((comment, index) => (
37 |
52 | ))
53 | }
54 |
55 |
56 | {
57 | showReply.length -next > 0
58 | ? setNext(next + 5)}>
60 | See more comments...
61 |
62 | : showReply.length > 2 &&
63 | setNext(2)}>
65 | Hide comments...
66 |
67 | }
68 |
69 |
70 |
71 |
72 | )
73 | }
74 |
75 | export default Comments
76 |
--------------------------------------------------------------------------------
/client/src/components/comments/Input.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState, useRef, useEffect } from 'react'
2 | import { IComment } from '../../utils/TypeScript'
3 | import LiteQuill from '../editor/LiteQuill'
4 |
5 |
6 | interface IProps {
7 | callback: (body: string) => void
8 | edit?: IComment
9 | setEdit?:(edit?: IComment) => void
10 | }
11 |
12 | const Input: React.FC = ({ callback, edit, setEdit }) => {
13 |
14 | const [body, setBody] = useState('')
15 | const divRef = useRef(null)
16 |
17 | useEffect(() => {
18 | if(edit) setBody(edit.content)
19 | },[edit])
20 |
21 |
22 | const handleSubmit = () => {
23 | const div = divRef.current;
24 | const text = (div?.innerText as string)
25 | if(!text.trim()) {
26 | if(setEdit) return setEdit(undefined);
27 | return;
28 | };
29 |
30 | callback(body)
31 |
32 | setBody('')
33 | }
34 |
35 | return (
36 |
37 |
38 |
39 |
42 |
43 |
47 |
48 | )
49 | }
50 |
51 | export default Input
52 |
--------------------------------------------------------------------------------
/client/src/components/editor/LiteQuill.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import ReactQuill from 'react-quill';
3 | import 'react-quill/dist/quill.snow.css';
4 |
5 |
6 |
7 | interface IProps {
8 | body: string
9 | setBody: (value: string) => void
10 | }
11 |
12 | const LiteQuill: React.FC = ({body, setBody}) => {
13 |
14 | const modules = { toolbar: { container }}
15 |
16 |
17 | return (
18 |
19 | setBody(e)}
23 | value={body}
24 | />
25 |
26 | )
27 | }
28 |
29 | let container = [
30 | [{ 'font': [] }],
31 | ['bold', 'italic', 'underline', 'strike'],
32 | ['blockquote', 'code-block', 'link'],
33 | [{ 'color': [] }, { 'background': [] }],
34 | [{ 'script': 'sub'}, { 'script': 'super' }]
35 | ]
36 |
37 | export default LiteQuill
38 |
--------------------------------------------------------------------------------
/client/src/components/editor/ReactQuill.tsx:
--------------------------------------------------------------------------------
1 | import React, { useEffect, useRef, useCallback } from 'react'
2 | import ReactQuill from 'react-quill';
3 | import 'react-quill/dist/quill.snow.css';
4 |
5 | import { useDispatch } from 'react-redux'
6 |
7 | import { checkImage, imageUpload } from '../../utils/ImageUpload'
8 | import { ALERT } from '../../redux/types/alertType'
9 |
10 | interface IProps {
11 | setBody: (value: string) => void
12 | body: string
13 | }
14 |
15 | const Quill: React.FC = ({setBody, body}) => {
16 | const dispatch = useDispatch()
17 | const quillRef = useRef(null)
18 |
19 | const modules = { toolbar: { container }}
20 |
21 | // Custom image
22 | const handleChangeImage = useCallback(() => {
23 | const input = document.createElement('input')
24 | input.type = "file"
25 | input.accept = "image/*"
26 | input.click()
27 |
28 | input.onchange = async () => {
29 | const files = input.files
30 | if(!files) return dispatch({
31 | type: ALERT, payload: { errors: 'File does not exist.'}
32 | });
33 |
34 | const file = files[0]
35 | const check = checkImage(file)
36 | if(check) return dispatch({ type: ALERT, payload: { errors: check } });
37 |
38 | dispatch({ type: ALERT, payload: { loading: true } })
39 | const photo = await imageUpload(file)
40 |
41 | const quill = quillRef.current;
42 | const range = quill?.getEditor().getSelection()?.index
43 | if(range !== undefined){
44 | quill?.getEditor().insertEmbed(range, 'image', `${photo.url}`)
45 | }
46 |
47 | dispatch({ type: ALERT, payload: { loading: false } })
48 | }
49 | },[dispatch])
50 |
51 |
52 | useEffect(() => {
53 | const quill = quillRef.current;
54 | if(!quill) return;
55 |
56 | let toolbar = quill.getEditor().getModule('toolbar')
57 | toolbar.addHandler('image', handleChangeImage)
58 | },[handleChangeImage])
59 |
60 | return (
61 |
62 | setBody(e)}
66 | value={body}
67 | ref={quillRef} />
68 |
69 | )
70 | }
71 |
72 | let container = [
73 | [{ 'font': [] }],
74 | [{ 'header': [1, 2, 3, 4, 5, 6, false] }],
75 | [{ 'size': ['small', false, 'large', 'huge'] }], // custom dropdown
76 |
77 | ['bold', 'italic', 'underline', 'strike'], // toggled buttons
78 | ['blockquote', 'code-block'],
79 | [{ 'color': [] }, { 'background': [] }], // dropdown with defaults from theme
80 | [{ 'script': 'sub'}, { 'script': 'super' }], // superscript/subscript
81 |
82 | [{ 'list': 'ordered'}, { 'list': 'bullet' }],
83 | [{ 'indent': '-1'}, { 'indent': '+1' }], // outdent/indent
84 | [{ 'direction': 'rtl' }], // text direction
85 | [{ 'align': [] }],
86 |
87 | ['clean', 'link', 'image','video']
88 | ]
89 |
90 | export default Quill
91 |
--------------------------------------------------------------------------------
/client/src/components/global/Footer.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 |
3 | const Footer = () => {
4 | return (
5 |
14 | )
15 | }
16 |
17 | export default Footer
18 |
--------------------------------------------------------------------------------
/client/src/components/global/Header.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { Link } from 'react-router-dom'
3 | import Search from './Search'
4 | import Menu from './Menu'
5 |
6 | const Header = () => {
7 | return (
8 |
22 | )
23 | }
24 |
25 | export default Header
26 |
--------------------------------------------------------------------------------
/client/src/components/global/Loading.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 |
3 | const Loading = () => {
4 | return (
5 |
6 |
7 | Loading...
8 |
9 |
10 | )
11 | }
12 |
13 | export default Loading
14 |
--------------------------------------------------------------------------------
/client/src/components/global/Menu.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { Link, useLocation } from 'react-router-dom'
3 | import { useSelector, useDispatch } from 'react-redux'
4 | import { RootStore } from '../../utils/TypeScript'
5 | import { logout } from '../../redux/actions/authAction'
6 |
7 | const Menu = () => {
8 | const { auth } = useSelector((state: RootStore) => state)
9 | const dispatch = useDispatch()
10 |
11 | const { pathname } = useLocation()
12 |
13 | const bfLoginLinks = [
14 | { label: 'Login', path: '/login' },
15 | { label: 'Register', path: '/register' }
16 | ]
17 |
18 | const afLoginLinks = [
19 | { label: 'Home', path: '/' },
20 | { label: 'CreateBlog', path: '/create_blog' }
21 | ]
22 |
23 | const navLinks = auth.access_token ? afLoginLinks : bfLoginLinks
24 |
25 | const isActive = (pn: string) => {
26 | if(pn === pathname) return 'active';
27 | }
28 |
29 | const handleLogout = () => {
30 | if(!auth.access_token) return;
31 | dispatch(logout(auth.access_token))
32 | }
33 |
34 |
35 | return (
36 |
37 | {
38 | navLinks.map((link, index) => (
39 | -
40 | {link.label}
41 |
42 | ))
43 | }
44 |
45 | {
46 | auth.user?.role === 'admin' &&
47 | -
48 | Category
49 |
50 | }
51 |
52 | {
53 | auth.user &&
54 | -
55 |
56 |
57 |
58 |
59 |
60 | -
61 |
64 | Profile
65 |
66 |
67 |
68 |
69 |
70 | -
71 |
73 | Logout
74 |
75 |
76 |
77 |
78 |
79 | }
80 |
81 |
82 | )
83 | }
84 |
85 | export default Menu
86 |
--------------------------------------------------------------------------------
/client/src/components/global/NotFound.tsx:
--------------------------------------------------------------------------------
1 | import React from "react";
2 |
3 | const NotFound = () => {
4 | return (
5 |
9 |
13 | 404 | NotFound
14 |
15 |
16 | );
17 | };
18 |
19 | export default NotFound;
20 |
--------------------------------------------------------------------------------
/client/src/components/global/Pagination.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState, useEffect } from 'react'
2 | import { useHistory } from 'react-router-dom'
3 |
4 | interface IProps {
5 | total: number
6 | callback: (num: number) => void
7 | }
8 |
9 | const Pagination: React.FC = ({total, callback}) => {
10 | const [page, setPage] = useState(1)
11 |
12 | const newArr = [...Array(total)].map((_,i) => i + 1)
13 | const history = useHistory()
14 |
15 | const isActive = (index: number) => {
16 | if(index === page) return "active";
17 | return ""
18 | }
19 |
20 | const handlePagination = (num: number) => {
21 | history.push(`?page=${num}`)
22 | callback(num)
23 | }
24 |
25 | useEffect(() => {
26 | const num = history.location.search.slice(6) || 1
27 | setPage(Number(num))
28 | },[history.location.search])
29 |
30 |
31 | return (
32 |
65 | )
66 | }
67 |
68 | export default Pagination
69 |
--------------------------------------------------------------------------------
/client/src/components/global/Search.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState, useEffect } from 'react'
2 | import { useLocation } from 'react-router-dom'
3 |
4 | import { getAPI } from '../../utils/FetchData'
5 | import { IBlog } from '../../utils/TypeScript'
6 |
7 | import CardHoriz from '../cards/CardHoriz'
8 |
9 | const Search = () => {
10 | const [search, setSearch] = useState('')
11 | const [blogs, setBlogs] = useState([])
12 |
13 | const { pathname } = useLocation()
14 |
15 | useEffect(() => {
16 | const delayDebounce = setTimeout(async () => {
17 | if(search.length < 2) return setBlogs([]);
18 |
19 | try {
20 | const res = await getAPI(`search/blogs?title=${search}`)
21 | setBlogs(res.data)
22 | } catch (err) {
23 | console.log(err)
24 | }
25 | }, 400)
26 |
27 | return () => clearTimeout(delayDebounce)
28 | },[search])
29 |
30 |
31 | useEffect(() => {
32 | setSearch('')
33 | setBlogs([])
34 | },[pathname])
35 |
36 | return (
37 |
38 |
setSearch(e.target.value)} />
41 |
42 | {
43 | search.length >= 2 &&
44 |
50 | {
51 | blogs.length
52 | ? blogs.map(blog => (
53 |
54 | ))
55 | :
No Blogs
56 | }
57 |
58 | }
59 |
60 | )
61 | }
62 |
63 | export default Search
64 |
--------------------------------------------------------------------------------
/client/src/components/profile/OtherInfo.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState, useEffect } from 'react'
2 | import { useSelector, useDispatch } from 'react-redux'
3 |
4 | import { getOtherInfo } from '../../redux/actions/userAction'
5 | import { RootStore, IUser } from '../../utils/TypeScript'
6 |
7 | import Loading from '../global/Loading'
8 |
9 |
10 | interface IProps {
11 | id: string
12 | }
13 |
14 | const OtherInfo: React.FC = ({id}) => {
15 | const [other, setOther] = useState()
16 |
17 | const { otherInfo } = useSelector((state: RootStore) => state)
18 | const dispatch = useDispatch()
19 |
20 | useEffect(() => {
21 | if(!id) return;
22 |
23 | if(otherInfo.every(user => user._id !== id)){
24 | dispatch(getOtherInfo(id))
25 | }else{
26 | const newUser = otherInfo.find(user => user._id === id)
27 | if(newUser) setOther(newUser)
28 | }
29 | },[id, otherInfo, dispatch])
30 |
31 |
32 | if(!other) return ;
33 | return (
34 |
35 |
36 |

37 |
38 |
39 |
40 | {other.role}
41 |
42 |
43 |
44 | Name:
45 | {other.name}
46 |
47 |
48 |
49 |
Email / Phone number
50 |
51 | {other.account}
52 |
53 |
54 |
55 | Join Date:
56 | { new Date(other.createdAt).toLocaleString() }
57 |
58 |
59 |
60 | )
61 | }
62 |
63 | export default OtherInfo
64 |
--------------------------------------------------------------------------------
/client/src/components/profile/UserBlogs.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState, useEffect } from 'react'
2 | import { useDispatch, useSelector } from 'react-redux'
3 | import { useParams, useHistory } from 'react-router-dom'
4 |
5 | import { IParams, RootStore, IBlog } from '../../utils/TypeScript'
6 |
7 | import { getBlogsByUserId } from '../../redux/actions/blogAction'
8 |
9 | import CardHoriz from '../cards/CardHoriz'
10 | import Loading from '../global/Loading'
11 | import Pagination from '../global/Pagination'
12 |
13 | const UserBlogs = () => {
14 | const { blogsUser } = useSelector((state: RootStore) => state)
15 | const dispatch = useDispatch()
16 | const user_id = useParams().slug
17 |
18 | const [blogs, setBlogs] = useState()
19 | const [total, setTotal] = useState(0)
20 |
21 | const history = useHistory()
22 | const { search } = history.location
23 |
24 | useEffect(() => {
25 | if(!user_id) return;
26 |
27 | if(blogsUser.every(item => item.id !== user_id)){
28 | dispatch(getBlogsByUserId(user_id, search))
29 | }else{
30 | const data = blogsUser.find(item => item.id === user_id)
31 | if(!data) return;
32 |
33 | setBlogs(data.blogs)
34 | setTotal(data.total)
35 | if(data.search) history.push(data.search)
36 | }
37 | },[user_id, blogsUser, dispatch, search, history])
38 |
39 | const handlePagination = (num: number) => {
40 | const search = `?page=${num}`
41 | dispatch(getBlogsByUserId(user_id, search))
42 | }
43 |
44 |
45 | if(!blogs) return ;
46 |
47 | if(blogs.length === 0 && total < 1) return(
48 | No Blogs
49 | )
50 |
51 | return (
52 |
53 |
54 | {
55 | blogs.map(blog => (
56 |
57 | ))
58 | }
59 |
60 |
61 |
67 |
68 | )
69 | }
70 |
71 | export default UserBlogs
72 |
--------------------------------------------------------------------------------
/client/src/components/profile/UserInfo.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState } from 'react'
2 | import { useSelector, useDispatch } from 'react-redux'
3 |
4 | import { RootStore, InputChange, IUserProfile, FormSubmit } from '../../utils/TypeScript'
5 |
6 | import NotFound from '../global/NotFound'
7 |
8 | import { updateUser, resetPassword } from '../../redux/actions/userAction'
9 |
10 | const UserInfo = () => {
11 | const initState = {
12 | name: '', account: '', avatar: '', password: '', cf_password: ''
13 | }
14 |
15 | const { auth } = useSelector((state: RootStore) => state)
16 | const dispatch = useDispatch()
17 |
18 | const [user, setUser] = useState(initState)
19 | const [typePass, setTypePass] = useState(false)
20 | const [typeCfPass, setTypeCfPass] = useState(false)
21 |
22 |
23 | const handleChangeInput = (e: InputChange) => {
24 | const { name, value } = e.target
25 | setUser({ ...user, [name]:value })
26 | }
27 |
28 |
29 | const handleChangeFile = (e: InputChange) => {
30 | const target = e.target as HTMLInputElement
31 | const files = target.files
32 |
33 | if(files){
34 | const file = files[0]
35 | setUser({...user, avatar: file})
36 | }
37 | }
38 |
39 |
40 | const handleSubmit = (e: FormSubmit) => {
41 | e.preventDefault()
42 | if(avatar || name)
43 | dispatch(updateUser((avatar as File), name, auth))
44 |
45 | if(password && auth.access_token)
46 | dispatch(resetPassword(password, cf_password, auth.access_token))
47 | }
48 |
49 |
50 | const { name, avatar, password, cf_password } = user
51 |
52 | if(!auth.user) return
53 | return (
54 |
126 | )
127 | }
128 |
129 | export default UserInfo
130 |
--------------------------------------------------------------------------------
/client/src/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import ReactDOM from 'react-dom';
3 | import './styles/index.css';
4 | import App from './App';
5 | import { Provider } from 'react-redux'
6 | import store from './redux/store'
7 |
8 | ReactDOM.render(
9 |
10 |
11 |
12 |
13 | ,
14 | document.getElementById('root')
15 | );
16 |
17 |
18 |
--------------------------------------------------------------------------------
/client/src/pages/active/[slug].tsx:
--------------------------------------------------------------------------------
1 | import React, { useState, useEffect } from 'react'
2 | import { useParams } from 'react-router-dom'
3 |
4 | import { IParams } from '../../utils/TypeScript'
5 | import { postAPI } from '../../utils/FetchData'
6 | import { showErrMsg, showSuccessMsg } from '../../components/alert/Alert'
7 |
8 | const Active = () => {
9 | const { slug }: IParams = useParams()
10 | const [err, setErr] = useState('')
11 | const [success, setSuccess] = useState('')
12 |
13 | useEffect(() => {
14 | if(slug){
15 | postAPI('active', { active_token: slug })
16 | .then(res => setSuccess(res.data.msg))
17 | .catch(err => setErr(err.response.data.msg))
18 | }
19 | },[slug])
20 |
21 | return (
22 |
23 | { err && showErrMsg(err) }
24 | { success && showSuccessMsg(success) }
25 |
26 | )
27 | }
28 |
29 | export default Active
30 |
--------------------------------------------------------------------------------
/client/src/pages/blog/[slug].tsx:
--------------------------------------------------------------------------------
1 | import React, { useState, useEffect } from 'react'
2 | import { useParams } from 'react-router-dom'
3 | import { useSelector } from 'react-redux'
4 |
5 | import { IParams, IBlog, RootStore } from '../../utils/TypeScript'
6 | import { getAPI } from '../../utils/FetchData'
7 |
8 | import Loading from '../../components/global/Loading'
9 | import { showErrMsg } from '../../components/alert/Alert'
10 | import DisplayBlog from '../../components/blog/DisplayBlog'
11 |
12 | const DetailBlog = () => {
13 | const id = useParams().slug
14 | const { socket } = useSelector((state: RootStore) => state)
15 |
16 | const [blog, setBlog] = useState()
17 | const [loading, setLoading] = useState(false)
18 | const [error, setError] = useState('')
19 |
20 | useEffect(() => {
21 | if(!id) return;
22 |
23 | setLoading(true)
24 |
25 | getAPI(`blog/${id}`)
26 | .then(res => {
27 | setBlog(res.data)
28 | setLoading(false)
29 | })
30 | .catch(err => {
31 | setError(err.response.data.msg)
32 | setLoading(false)
33 | })
34 |
35 | return () => setBlog(undefined)
36 | },[id])
37 |
38 | // Join Room
39 | useEffect(() => {
40 | if(!id || !socket) return;
41 | socket.emit('joinRoom', id)
42 |
43 | return () => {
44 | socket.emit('outRoom', id)
45 | }
46 | },[socket, id])
47 |
48 |
49 | if(loading) return ;
50 | return (
51 |
52 | { error && showErrMsg(error) }
53 |
54 | { blog && }
55 |
56 |
57 | )
58 | }
59 |
60 | export default DetailBlog
61 |
--------------------------------------------------------------------------------
/client/src/pages/blogs/[slug].tsx:
--------------------------------------------------------------------------------
1 | import React, { useState, useEffect } from 'react'
2 | import { useParams, useHistory } from 'react-router-dom'
3 | import { useSelector, useDispatch } from 'react-redux'
4 |
5 | import { getBlogsByCategoryId } from '../../redux/actions/blogAction'
6 |
7 | import { RootStore, IParams, IBlog } from '../../utils/TypeScript'
8 |
9 | import Loading from '../../components/global/Loading'
10 | import Pagination from '../../components/global/Pagination'
11 | import CardVert from '../../components/cards/CardVert'
12 |
13 |
14 | const BlogsByCategory = () => {
15 | const { categories, blogsCategory } = useSelector((state: RootStore) => state)
16 | const dispatch = useDispatch()
17 | const { slug } = useParams()
18 |
19 | const [categoryId, setCategoryId] = useState('')
20 | const [blogs, setBlogs] = useState()
21 | const [total, setTotal] = useState(0)
22 |
23 | const history = useHistory()
24 | const { search } = history.location;
25 |
26 | useEffect(() => {
27 | const category = categories.find(item => item.name === slug)
28 | if(category) setCategoryId(category._id)
29 | },[slug, categories])
30 |
31 |
32 | useEffect(() => {
33 | if(!categoryId) return;
34 |
35 | if(blogsCategory.every(item => item.id !== categoryId)){
36 | dispatch(getBlogsByCategoryId(categoryId, search))
37 | }else{
38 | const data = blogsCategory.find(item => item.id === categoryId)
39 | if(!data) return;
40 | setBlogs(data.blogs)
41 | setTotal(data.total)
42 |
43 | if(data.search) history.push(data.search)
44 | }
45 | },[categoryId, blogsCategory, dispatch, search, history])
46 |
47 |
48 | const handlePagination = (num: number) => {
49 | const search = `?page=${num}`
50 | dispatch(getBlogsByCategoryId(categoryId, search))
51 | }
52 |
53 |
54 | if(!blogs) return ;
55 | return (
56 |
57 |
58 | {
59 | blogs.map(blog => (
60 |
61 | ))
62 | }
63 |
64 |
65 | {
66 | total > 1 &&
67 |
71 | }
72 |
73 |
74 | )
75 | }
76 |
77 | export default BlogsByCategory
78 |
--------------------------------------------------------------------------------
/client/src/pages/category.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState, useEffect } from 'react'
2 | import { useSelector, useDispatch } from 'react-redux'
3 |
4 | import { FormSubmit, RootStore, ICategory } from '../utils/TypeScript'
5 |
6 | import { createCategory, updateCategory, deleteCategory } from '../redux/actions/categoryAction'
7 |
8 | import NotFound from '../components/global/NotFound'
9 |
10 | const Category = () => {
11 | const [name, setName] = useState('')
12 | const [edit, setEdit] = useState(null)
13 |
14 | const { auth, categories } = useSelector((state: RootStore) => state)
15 | const dispatch = useDispatch()
16 |
17 | useEffect(() => {
18 | if(edit) setName(edit.name)
19 | },[edit])
20 |
21 | const handleSubmit = (e: FormSubmit) => {
22 | e.preventDefault()
23 | if(!auth.access_token || !name) return;
24 |
25 | if(edit){
26 | if(edit.name === name) return;
27 | const data = {...edit, name}
28 | dispatch(updateCategory(data, auth.access_token))
29 | }else{
30 | dispatch(createCategory(name, auth.access_token))
31 | }
32 | setName('')
33 | setEdit(null)
34 | }
35 |
36 |
37 | const handleDelete = (id: string) => {
38 | if(!auth.access_token) return;
39 | if(window.confirm('Are you sure to delete this category?')){
40 | dispatch(deleteCategory(id, auth.access_token))
41 | }
42 | }
43 |
44 |
45 | if(auth.user?.role !== 'admin') return
46 | return (
47 |
48 |
65 |
66 |
67 | {
68 | categories.map(category => (
69 |
70 |
{category.name}
71 |
72 |
73 | setEdit(category)} />
75 | handleDelete(category._id)} />
77 |
78 |
79 | ))
80 | }
81 |
82 |
83 | )
84 | }
85 |
86 | export default Category
87 |
--------------------------------------------------------------------------------
/client/src/pages/create_blog.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState, useRef, useEffect } from 'react'
2 | import { useSelector, useDispatch } from 'react-redux'
3 |
4 | import { RootStore, IBlog, IUser } from '../utils/TypeScript'
5 | import { validCreateBlog, shallowEqual } from '../utils/Valid'
6 | import { getAPI } from '../utils/FetchData'
7 |
8 | import NotFound from '../components/global/NotFound'
9 | import CreateForm from '../components/cards/CreateForm'
10 | import CardHoriz from '../components/cards/CardHoriz'
11 |
12 | import ReactQuill from '../components/editor/ReactQuill'
13 |
14 | import { ALERT } from '../redux/types/alertType'
15 |
16 | import { createBlog, updateBlog } from '../redux/actions/blogAction'
17 |
18 | interface IProps {
19 | id?: string
20 | }
21 | const CreateBlog: React.FC = ({id}) => {
22 | const initState = {
23 | user: '',
24 | title: '',
25 | content: '',
26 | description: '',
27 | thumbnail: '',
28 | category: '',
29 | createdAt: new Date().toISOString()
30 | }
31 |
32 | const [blog, setBlog] = useState(initState)
33 | const [body, setBody] = useState('')
34 |
35 | const divRef = useRef(null)
36 | const [text, setText] = useState('')
37 |
38 | const { auth } = useSelector((state: RootStore) => state)
39 | const dispatch = useDispatch()
40 |
41 | const [oldData, setOldData] = useState(initState)
42 |
43 | useEffect(() => {
44 | if(!id) return;
45 |
46 | getAPI(`blog/${id}`)
47 | .then(res => {
48 | setBlog(res.data)
49 | setBody(res.data.content)
50 | setOldData(res.data)
51 | })
52 | .catch(err => console.log(err))
53 |
54 | const initData = {
55 | user: '',
56 | title: '',
57 | content: '',
58 | description: '',
59 | thumbnail: '',
60 | category: '',
61 | createdAt: new Date().toISOString()
62 | }
63 |
64 | return () => {
65 | setBlog(initData)
66 | setBody('')
67 | setOldData(initData)
68 | }
69 | },[id])
70 |
71 | useEffect(() => {
72 | const div = divRef.current;
73 | if(!div) return;
74 |
75 | const text = (div?.innerText as string)
76 | setText(text)
77 | },[body])
78 |
79 | const handleSubmit = async() => {
80 | if(!auth.access_token) return;
81 |
82 | const check = validCreateBlog({...blog, content: text})
83 | if(check.errLength !== 0){
84 | return dispatch({ type: ALERT, payload: { errors: check.errMsg } })
85 | }
86 |
87 | let newData = {...blog, content: body}
88 |
89 | if(id){
90 | if((blog.user as IUser)._id !== auth.user?._id)
91 | return dispatch({
92 | type: ALERT,
93 | payload: { errors: 'Invalid Authentication.' }
94 | })
95 |
96 | const result = shallowEqual(oldData, newData)
97 | if(result) return dispatch({
98 | type: ALERT,
99 | payload: { errors: 'The data does not change.' }
100 | })
101 |
102 | dispatch(updateBlog(newData, auth.access_token))
103 | }else{
104 | dispatch(createBlog(newData, auth.access_token))
105 | }
106 | }
107 |
108 |
109 | if(!auth.access_token) return ;
110 | return (
111 |
112 |
113 |
114 |
115 |
Create
116 |
117 |
118 |
119 |
120 |
Preview
121 |
122 |
123 |
124 |
125 |
126 |
127 |
130 |
131 |
{text.length}
132 |
133 |
137 |
138 | )
139 | }
140 |
141 | export default CreateBlog
142 |
--------------------------------------------------------------------------------
/client/src/pages/forgot_password.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState } from 'react'
2 | import { useDispatch } from 'react-redux'
3 |
4 | import { forgotPassword} from '../redux/actions/authAction'
5 |
6 | import { FormSubmit} from '../utils/TypeScript'
7 |
8 | const ForgotPassword = () => {
9 | const [account, setAccount] = useState('')
10 | const dispatch = useDispatch()
11 |
12 | const handleSubmit = (e: FormSubmit) => {
13 | e.preventDefault()
14 | dispatch(forgotPassword(account))
15 | }
16 |
17 | return (
18 |
19 |
Forgot Password?
20 |
21 |
34 |
35 | )
36 | }
37 |
38 | export default ForgotPassword
39 |
--------------------------------------------------------------------------------
/client/src/pages/index.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { useSelector } from 'react-redux'
3 | import { Link } from 'react-router-dom'
4 |
5 | import { RootStore } from '../utils/TypeScript'
6 |
7 | import CardVert from '../components/cards/CardVert'
8 | import Loading from '../components/global/Loading'
9 |
10 | const Home = () => {
11 | const { homeBlogs } = useSelector((state: RootStore) => state)
12 |
13 |
14 | if(homeBlogs.length === 0) return ;
15 | return (
16 |
17 | {
18 | homeBlogs.map(homeBlog => (
19 |
20 | {
21 | homeBlog.count > 0 &&
22 | <>
23 |
24 |
25 | { homeBlog.name } ({ homeBlog.count })
26 |
27 |
28 |
29 |
30 |
31 | {
32 | homeBlog.blogs.map(blog => (
33 |
34 | ))
35 | }
36 |
37 | >
38 | }
39 |
40 | {
41 | homeBlog.count > 4 &&
42 |
45 | Read more >>
46 |
47 | }
48 |
49 | ))
50 | }
51 |
52 | )
53 | }
54 |
55 | export default Home
56 |
--------------------------------------------------------------------------------
/client/src/pages/login.tsx:
--------------------------------------------------------------------------------
1 | import React, { useState, useEffect } from 'react'
2 | import { Link, useHistory } from 'react-router-dom'
3 | import { useSelector } from 'react-redux'
4 |
5 | import LoginPass from '../components/auth/LoginPass'
6 | import LoginSMS from '../components/auth/LoginSMS'
7 | import SocialLogin from '../components/auth/SocialLogin'
8 |
9 | import { RootStore } from '../utils/TypeScript'
10 |
11 | const Login = () => {
12 | const [sms, setSms] = useState(false)
13 | const history = useHistory()
14 |
15 | const { auth } = useSelector((state: RootStore) => state)
16 |
17 | useEffect(() => {
18 | if(auth.access_token) {
19 | let url = history.location.search.replace('?', '/')
20 | return history.push(url)
21 | }
22 | },[auth.access_token, history])
23 |
24 | return (
25 |
26 |
27 |
Login
28 |
29 |
30 |
31 | { sms ?
:
}
32 |
33 |
34 |
35 |
36 | Forgot password?
37 |
38 |
39 |
40 | setSms(!sms)}>
41 | { sms ? 'Sign in with password' : 'Sign in with SMS' }
42 |
43 |
44 |
45 |
46 | {`You don't have an account? `}
47 |
48 | Register Now
49 |
50 |
51 |
52 |
53 |
54 | )
55 | }
56 |
57 | export default Login
58 |
--------------------------------------------------------------------------------
/client/src/pages/profile/[slug].tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { useParams } from 'react-router-dom'
3 | import { useSelector } from 'react-redux'
4 |
5 | import { IParams, RootStore } from '../../utils/TypeScript'
6 |
7 | import UserInfo from '../../components/profile/UserInfo'
8 | import OtherInfo from '../../components/profile/OtherInfo'
9 | import UserBlogs from '../../components/profile/UserBlogs'
10 |
11 | const Profile = () => {
12 | const { slug }: IParams = useParams()
13 | const { auth } = useSelector((state: RootStore) => state)
14 |
15 | return (
16 |
17 |
18 | {
19 | auth.user?._id === slug
20 | ?
21 | :
22 | }
23 |
24 |
25 |
26 |
27 |
28 |
29 | )
30 | }
31 |
32 | export default Profile
33 |
--------------------------------------------------------------------------------
/client/src/pages/register.tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { Link, useHistory } from 'react-router-dom'
3 |
4 | import RegisterForm from '../components/auth/RegisterForm'
5 |
6 | const Register = () => {
7 | const history = useHistory()
8 |
9 | return (
10 |
11 |
12 |
Register
13 |
14 |
15 |
16 |
17 | {`Already have an account? `}
18 |
19 | Login Now
20 |
21 |
22 |
23 |
24 |
25 | )
26 | }
27 |
28 | export default Register
29 |
--------------------------------------------------------------------------------
/client/src/pages/reset_password/[slug].tsx:
--------------------------------------------------------------------------------
1 | import React, { useState } from 'react'
2 | import { useParams } from 'react-router-dom'
3 | import { useDispatch } from 'react-redux'
4 |
5 | import { IParams, FormSubmit } from '../../utils/TypeScript'
6 |
7 | import { resetPassword } from '../../redux/actions/userAction'
8 |
9 | const ResetPassword = () => {
10 | const token = useParams().slug
11 | const dispatch = useDispatch()
12 |
13 | const [password, setPassword] = useState('')
14 | const [cf_password, setCfPassword] = useState('')
15 | const [typePass, setTypePass] = useState(false)
16 | const [typeCfPass, setTypeCfPass] = useState(false)
17 |
18 | const handleSubmit = (e: FormSubmit) => {
19 | e.preventDefault()
20 | dispatch(resetPassword(password, cf_password, token))
21 | }
22 |
23 | return (
24 |
25 |
64 |
65 | )
66 | }
67 |
68 | export default ResetPassword
69 |
--------------------------------------------------------------------------------
/client/src/pages/update_blog/[slug].tsx:
--------------------------------------------------------------------------------
1 | import React from 'react'
2 | import { useParams } from 'react-router-dom'
3 |
4 | import { IParams } from '../../utils/TypeScript'
5 |
6 | import CreateBlog from '../create_blog'
7 |
8 | const UpdateBlog = () => {
9 | const { slug } = useParams()
10 |
11 | return
12 | }
13 |
14 | export default UpdateBlog
15 |
--------------------------------------------------------------------------------
/client/src/react-app-env.d.ts:
--------------------------------------------------------------------------------
1 | ///
2 |
--------------------------------------------------------------------------------
/client/src/redux/actions/authAction.ts:
--------------------------------------------------------------------------------
1 | import { Dispatch } from 'redux'
2 | import { AUTH, IAuthType } from '../types/authType'
3 | import { ALERT, IAlertType } from '../types/alertType'
4 |
5 | import { IUserLogin, IUserRegister } from '../../utils/TypeScript'
6 | import { postAPI, getAPI } from '../../utils/FetchData'
7 | import { validRegister, validPhone } from '../../utils/Valid'
8 | import { checkTokenExp } from '../../utils/checkTokenExp'
9 |
10 |
11 | export const login = (userLogin: IUserLogin) =>
12 | async (dispatch: Dispatch) => {
13 | try {
14 | dispatch({ type: ALERT, payload: { loading: true } })
15 |
16 | const res = await postAPI('login', userLogin)
17 |
18 | dispatch({ type: AUTH,payload: res.data })
19 |
20 | dispatch({ type: ALERT, payload: { success: res.data.msg } })
21 | localStorage.setItem('logged', 'devat-channel')
22 |
23 | } catch (err: any) {
24 | dispatch({ type: ALERT, payload: { errors: err.response.data.msg } })
25 | }
26 | }
27 |
28 |
29 | export const register = (userRegister: IUserRegister) =>
30 | async (dispatch: Dispatch) => {
31 | const check = validRegister(userRegister)
32 |
33 | if(check.errLength > 0)
34 | return dispatch({ type: ALERT, payload: { errors: check.errMsg } })
35 |
36 | try {
37 | dispatch({ type: ALERT, payload: { loading: true } })
38 |
39 | const res = await postAPI('register', userRegister)
40 |
41 | dispatch({ type: ALERT, payload: { success: res.data.msg } })
42 | } catch (err: any) {
43 | dispatch({ type: ALERT, payload: { errors: err.response.data.msg } })
44 | }
45 | }
46 |
47 |
48 | export const refreshToken = () =>
49 | async (dispatch: Dispatch) => {
50 | const logged = localStorage.getItem('logged')
51 | if(logged !== 'devat-channel') return;
52 |
53 | try {
54 | dispatch({ type: ALERT, payload: { loading: true } })
55 |
56 | const res = await getAPI('refresh_token')
57 |
58 | dispatch({ type: AUTH,payload: res.data })
59 |
60 | dispatch({ type: ALERT, payload: { } })
61 | } catch (err: any) {
62 | dispatch({ type: ALERT, payload: { errors: err.response.data.msg } })
63 | localStorage.removeItem('logged')
64 | }
65 | }
66 |
67 |
68 | export const logout = (token: string) =>
69 | async (dispatch: Dispatch) => {
70 | const result = await checkTokenExp(token, dispatch)
71 | const access_token = result ? result : token
72 |
73 | try {
74 | localStorage.removeItem('logged')
75 | dispatch({ type: AUTH, payload: { } })
76 | await getAPI('logout', access_token)
77 | } catch (err: any) {
78 | dispatch({ type: ALERT, payload: { errors: err.response.data.msg } })
79 | }
80 | }
81 |
82 | export const googleLogin = (id_token: string) =>
83 | async (dispatch: Dispatch) => {
84 | try {
85 | dispatch({ type: ALERT, payload: { loading: true } })
86 |
87 | const res = await postAPI('google_login', { id_token })
88 |
89 | dispatch({ type: AUTH,payload: res.data })
90 |
91 | dispatch({ type: ALERT, payload: { success: res.data.msg } })
92 | localStorage.setItem('logged', 'devat-channel')
93 |
94 | } catch (err: any) {
95 | dispatch({ type: ALERT, payload: { errors: err.response.data.msg } })
96 | }
97 | }
98 |
99 | export const facebookLogin = (accessToken: string, userID: string) =>
100 | async (dispatch: Dispatch) => {
101 | try {
102 | dispatch({ type: ALERT, payload: { loading: true } })
103 |
104 | const res = await postAPI('facebook_login', { accessToken, userID })
105 |
106 | dispatch({ type: AUTH,payload: res.data })
107 |
108 | dispatch({ type: ALERT, payload: { success: res.data.msg } })
109 | localStorage.setItem('logged', 'devat-channel')
110 |
111 | } catch (err: any) {
112 | dispatch({ type: ALERT, payload: { errors: err.response.data.msg } })
113 | }
114 | }
115 |
116 |
117 | export const loginSMS = (phone: string) =>
118 | async (dispatch: Dispatch) => {
119 | const check = validPhone(phone)
120 | if(!check)
121 | return dispatch({
122 | type: ALERT,
123 | payload: { errors: 'Phone number format is incorrect.' }
124 | });
125 |
126 | try {
127 | dispatch({ type: ALERT, payload: { loading: true } })
128 |
129 | const res = await postAPI('login_sms', { phone })
130 |
131 | if(!res.data.valid)
132 | verifySMS(phone, dispatch)
133 |
134 | } catch (err: any) {
135 | dispatch({ type: ALERT, payload: { errors: err.response.data.msg } })
136 | }
137 | }
138 |
139 | export const verifySMS = async (
140 | phone: string, dispatch: Dispatch
141 | ) => {
142 | const code = prompt('Enter your code')
143 | if(!code) return;
144 |
145 | try {
146 | dispatch({ type: ALERT, payload: { loading: true } })
147 |
148 | const res = await postAPI('sms_verify', { phone, code })
149 |
150 | dispatch({ type: AUTH,payload: res.data })
151 |
152 | dispatch({ type: ALERT, payload: { success: res.data.msg } })
153 | localStorage.setItem('logged', 'devat-channel')
154 | } catch (err: any) {
155 | dispatch({ type: ALERT, payload: { errors: err.response.data.msg } })
156 | setTimeout(() => {
157 | verifySMS(phone, dispatch)
158 | }, 100);
159 | }
160 |
161 | }
162 |
163 |
164 | export const forgotPassword = (account: string) =>
165 | async (dispatch: Dispatch) => {
166 | try {
167 | dispatch({ type: ALERT, payload: { loading: true } })
168 |
169 | const res = await postAPI('forgot_password', { account })
170 |
171 | dispatch({ type: ALERT, payload: { success: res.data.msg } })
172 | } catch (err: any) {
173 | dispatch({ type: ALERT, payload: { errors: err.response.data.msg } })
174 | }
175 | }
--------------------------------------------------------------------------------
/client/src/redux/actions/blogAction.ts:
--------------------------------------------------------------------------------
1 | import { Dispatch } from 'redux'
2 | import { IBlog } from '../../utils/TypeScript'
3 | import { imageUpload } from '../../utils/ImageUpload'
4 | import { postAPI, getAPI, putAPI, deleteAPI } from '../../utils/FetchData'
5 |
6 | import { ALERT, IAlertType } from '../types/alertType'
7 |
8 | import {
9 | GET_HOME_BLOGS,
10 | IGetHomeBlogsType,
11 | GET_BLOGS_CATEGORY_ID,
12 | IGetBlogsCategoryType,
13 | GET_BLOGS_USER_ID,
14 | IGetBlogsUserType,
15 | CREATE_BLOGS_USER_ID,
16 | ICreateBlogsUserType,
17 | DELETE_BLOGS_USER_ID,
18 | IDeleteBlogsUserType
19 | } from '../types/blogType'
20 |
21 | import { checkTokenExp } from '../../utils/checkTokenExp'
22 |
23 | export const createBlog = (blog: IBlog, token: string) =>
24 | async (dispatch: Dispatch) => {
25 | const result = await checkTokenExp(token, dispatch)
26 | const access_token = result ? result : token
27 |
28 | let url;
29 | try {
30 | dispatch({ type: ALERT, payload: { loading: true } })
31 |
32 | if(typeof(blog.thumbnail) !== 'string'){
33 | const photo = await imageUpload(blog.thumbnail)
34 | url = photo.url
35 | }else{
36 | url = blog.thumbnail
37 | }
38 |
39 | const newBlog = {...blog, thumbnail: url}
40 |
41 | const res = await postAPI('blog', newBlog, access_token)
42 |
43 | dispatch({
44 | type: CREATE_BLOGS_USER_ID,
45 | payload: res.data
46 | })
47 |
48 | dispatch({ type: ALERT, payload: { loading: false } })
49 | } catch (err: any) {
50 | dispatch({ type: ALERT, payload: {errors: err.response.data.msg} })
51 | }
52 | }
53 |
54 |
55 | export const getHomeBlogs = () =>
56 | async (dispatch: Dispatch) => {
57 | try {
58 | dispatch({ type: ALERT, payload: { loading: true } })
59 |
60 | const res = await getAPI('home/blogs')
61 |
62 | dispatch({
63 | type: GET_HOME_BLOGS,
64 | payload: res.data
65 | })
66 |
67 | dispatch({ type: ALERT, payload: { loading: false } })
68 | } catch (err: any) {
69 | dispatch({ type: ALERT, payload: {errors: err.response.data.msg} })
70 | }
71 | }
72 |
73 |
74 | export const getBlogsByCategoryId = (id: string, search: string) =>
75 | async (dispatch: Dispatch) => {
76 | try {
77 | let limit = 8;
78 | let value = search ? search : `?page=${1}`;
79 |
80 | dispatch({ type: ALERT, payload: { loading: true } })
81 |
82 | const res = await getAPI(`blogs/category/${id}${value}&limit=${limit}`)
83 |
84 | dispatch({
85 | type: GET_BLOGS_CATEGORY_ID,
86 | payload: {...res.data, id, search }
87 | })
88 |
89 | dispatch({ type: ALERT, payload: { loading: false } })
90 | } catch (err: any) {
91 | dispatch({ type: ALERT, payload: {errors: err.response.data.msg} })
92 | }
93 | }
94 |
95 |
96 | export const getBlogsByUserId = (id: string, search: string) =>
97 | async (dispatch: Dispatch) => {
98 | try {
99 | let limit = 3;
100 | let value = search ? search : `?page=${1}`;
101 |
102 | dispatch({ type: ALERT, payload: { loading: true } })
103 |
104 | const res = await getAPI(`blogs/user/${id}${value}&limit=${limit}`)
105 |
106 | dispatch({
107 | type: GET_BLOGS_USER_ID,
108 | payload: {...res.data, id, search }
109 | })
110 |
111 | dispatch({ type: ALERT, payload: { loading: false } })
112 | } catch (err: any) {
113 | dispatch({ type: ALERT, payload: {errors: err.response.data.msg} })
114 | }
115 | }
116 |
117 |
118 | export const updateBlog = (blog: IBlog, token: string) =>
119 | async (dispatch: Dispatch) => {
120 | const result = await checkTokenExp(token, dispatch)
121 | const access_token = result ? result : token
122 | let url;
123 | try {
124 | dispatch({ type: ALERT, payload: { loading: true } })
125 |
126 | if(typeof(blog.thumbnail) !== 'string'){
127 | const photo = await imageUpload(blog.thumbnail)
128 | url = photo.url
129 | }else{
130 | url = blog.thumbnail
131 | }
132 |
133 | const newBlog = {...blog, thumbnail: url}
134 |
135 | const res = await putAPI(`blog/${newBlog._id}`, newBlog, access_token)
136 |
137 | dispatch({ type: ALERT, payload: { success: res.data.msg } })
138 | } catch (err: any) {
139 | dispatch({ type: ALERT, payload: {errors: err.response.data.msg} })
140 | }
141 | }
142 |
143 |
144 | export const deleteBlog = (blog: IBlog, token: string) =>
145 | async (dispatch: Dispatch) => {
146 | const result = await checkTokenExp(token, dispatch)
147 | const access_token = result ? result : token
148 | try {
149 | dispatch({
150 | type: DELETE_BLOGS_USER_ID,
151 | payload: blog
152 | })
153 |
154 | await deleteAPI(`blog/${blog._id}`, access_token)
155 |
156 | } catch (err: any) {
157 | dispatch({ type: ALERT, payload: {errors: err.response.data.msg} })
158 | }
159 | }
--------------------------------------------------------------------------------
/client/src/redux/actions/categoryAction.ts:
--------------------------------------------------------------------------------
1 | import { Dispatch } from 'redux'
2 | import { ALERT, IAlertType } from '../types/alertType'
3 |
4 | import { postAPI, getAPI, patchAPI, deleteAPI } from '../../utils/FetchData'
5 | import { ICategory } from '../../utils/TypeScript'
6 |
7 | import {
8 | CREATE_CATEGORY,
9 | ICategoryType,
10 | GET_CATEGORIES,
11 | UPDATE_CATEGORY,
12 | DELETE_CATEGORY
13 | } from '../types/categoryType'
14 |
15 | import { checkTokenExp } from '../../utils/checkTokenExp'
16 |
17 | export const createCategory = (name: string, token: string) =>
18 | async(dispatch: Dispatch) => {
19 | const result = await checkTokenExp(token, dispatch)
20 | const access_token = result ? result : token
21 | try {
22 | dispatch({ type: ALERT, payload: { loading: true }})
23 |
24 | const res = await postAPI('category', { name }, access_token)
25 |
26 | dispatch({
27 | type: CREATE_CATEGORY,
28 | payload: res.data.newCategory
29 | })
30 |
31 | dispatch({ type: ALERT, payload: { loading: false }})
32 | } catch (err: any) {
33 | dispatch({ type: ALERT, payload: { errors: err.response.data.msg }})
34 | }
35 | }
36 |
37 | export const getCategories = () =>
38 | async(dispatch: Dispatch) => {
39 | try {
40 | dispatch({ type: ALERT, payload: { loading: true }})
41 |
42 | const res = await getAPI('category')
43 |
44 | dispatch({
45 | type: GET_CATEGORIES,
46 | payload: res.data.categories
47 | })
48 |
49 | dispatch({ type: ALERT, payload: { loading: false }})
50 | } catch (err: any) {
51 | dispatch({ type: ALERT, payload: { errors: err.response.data.msg }})
52 | }
53 | }
54 |
55 | export const updateCategory = (data: ICategory, token: string) =>
56 | async(dispatch: Dispatch) => {
57 | const result = await checkTokenExp(token, dispatch)
58 | const access_token = result ? result : token
59 | try {
60 |
61 | dispatch({ type: UPDATE_CATEGORY, payload: data })
62 |
63 | await patchAPI(`category/${data._id}`, {
64 | name: data.name
65 | }, access_token)
66 |
67 | } catch (err: any) {
68 | dispatch({ type: ALERT, payload: { errors: err.response.data.msg }})
69 | }
70 | }
71 |
72 | export const deleteCategory = (id: string, token: string) =>
73 | async(dispatch: Dispatch) => {
74 | const result = await checkTokenExp(token, dispatch)
75 | const access_token = result ? result : token
76 | try {
77 |
78 | dispatch({ type: DELETE_CATEGORY, payload: id })
79 | await deleteAPI(`category/${id}`, access_token)
80 |
81 | } catch (err: any) {
82 | dispatch({ type: ALERT, payload: { errors: err.response.data.msg }})
83 | }
84 | }
--------------------------------------------------------------------------------
/client/src/redux/actions/commentAction.ts:
--------------------------------------------------------------------------------
1 | import { Dispatch } from 'redux'
2 |
3 | import { ALERT, IAlertType } from '../types/alertType'
4 | import {
5 | ICreateCommentType,
6 | GET_COMMENTS,
7 | IGetCommentsType,
8 | IReplyCommentType,
9 | UPDATE_COMMENT,
10 | UPDATE_REPLY,
11 | IUpdateType,
12 | DELETE_COMMENT,
13 | DELETE_REPLY,
14 | IDeleteType
15 | } from '../types/commentType'
16 |
17 | import { IComment } from '../../utils/TypeScript'
18 | import { postAPI, getAPI, patchAPI, deleteAPI } from '../../utils/FetchData'
19 | import { checkTokenExp } from '../../utils/checkTokenExp'
20 |
21 |
22 |
23 | export const createComment = (
24 | data: IComment, token: string
25 | ) => async(dispatch: Dispatch) => {
26 | const result = await checkTokenExp(token, dispatch)
27 | const access_token = result ? result : token
28 | try {
29 | await postAPI('comment', data, access_token)
30 |
31 | // dispatch({
32 | // type: CREATE_COMMENT,
33 | // payload: { ...res.data, user: data.user }
34 | // })
35 |
36 | } catch (err: any) {
37 | dispatch({ type: ALERT, payload: { errors: err.response.data.msg } })
38 | }
39 | }
40 |
41 |
42 | export const getComments = (
43 | id: string, num: number
44 | ) => async(dispatch: Dispatch) => {
45 | try {
46 | let limit = 4;
47 |
48 | const res = await getAPI(`comments/blog/${id}?page=${num}&limit=${limit}`)
49 |
50 | dispatch({
51 | type: GET_COMMENTS,
52 | payload: {
53 | data: res.data.comments,
54 | total: res.data.total
55 | }
56 | })
57 |
58 | } catch (err: any) {
59 | dispatch({ type: ALERT, payload: { errors: err.response.data.msg } })
60 | }
61 | }
62 |
63 |
64 | export const replyComment = (
65 | data: IComment, token: string
66 | ) => async(dispatch: Dispatch) => {
67 | const result = await checkTokenExp(token, dispatch)
68 | const access_token = result ? result : token
69 | try {
70 | await postAPI('reply_comment', data, access_token)
71 |
72 | // dispatch({
73 | // type: REPLY_COMMENT,
74 | // payload: {
75 | // ...res.data,
76 | // user: data.user,
77 | // reply_user: data.reply_user
78 | // }
79 | // })
80 |
81 | } catch (err: any) {
82 | dispatch({ type: ALERT, payload: { errors: err.response.data.msg } })
83 | }
84 | }
85 |
86 |
87 | export const updateComment = (
88 | data: IComment, token: string
89 | ) => async(dispatch: Dispatch) => {
90 | const result = await checkTokenExp(token, dispatch)
91 | const access_token = result ? result : token
92 | try {
93 | dispatch({
94 | type: data.comment_root ? UPDATE_REPLY : UPDATE_COMMENT,
95 | payload: data
96 | })
97 |
98 | await patchAPI(`comment/${data._id}`, { data }, access_token)
99 |
100 | } catch (err: any) {
101 | dispatch({ type: ALERT, payload: { errors: err.response.data.msg } })
102 | }
103 | }
104 |
105 |
106 | export const deleteComment = (
107 | data: IComment, token: string
108 | ) => async(dispatch: Dispatch) => {
109 | const result = await checkTokenExp(token, dispatch)
110 | const access_token = result ? result : token
111 | try {
112 | dispatch({
113 | type: data.comment_root ? DELETE_REPLY : DELETE_COMMENT,
114 | payload: data
115 | })
116 |
117 | await deleteAPI(`comment/${data._id}`, access_token)
118 |
119 | } catch (err: any) {
120 | dispatch({ type: ALERT, payload: { errors: err.response.data.msg } })
121 | }
122 | }
--------------------------------------------------------------------------------
/client/src/redux/actions/userAction.ts:
--------------------------------------------------------------------------------
1 | import { Dispatch } from 'redux'
2 | import { IAuth, IAuthType, AUTH } from '../types/authType'
3 | import { IAlertType, ALERT } from '../types/alertType'
4 |
5 | import { checkImage, imageUpload } from '../../utils/ImageUpload'
6 | import { patchAPI, getAPI } from '../../utils/FetchData'
7 | import { checkPassword } from '../../utils/Valid'
8 |
9 | import {
10 | GET_OTHER_INFO,
11 | IGetOtherInfoType
12 | } from '../types/profileType'
13 |
14 | import { checkTokenExp } from '../../utils/checkTokenExp'
15 |
16 |
17 | export const updateUser = (
18 | avatar: File, name: string, auth: IAuth
19 | ) => async (dispatch: Dispatch) => {
20 | if(!auth.access_token || !auth.user) return;
21 |
22 | const result = await checkTokenExp(auth.access_token, dispatch)
23 | const access_token = result ? result : auth.access_token
24 |
25 | let url = '';
26 | try {
27 | dispatch({ type: ALERT, payload: {loading: true}})
28 | if(avatar){
29 | const check = checkImage(avatar)
30 | if(check)
31 | return dispatch({ type: ALERT,payload: { errors: check } })
32 |
33 | const photo = await imageUpload(avatar)
34 | url = photo.url
35 | }
36 |
37 | dispatch({
38 | type: AUTH,
39 | payload: {
40 | access_token: auth.access_token,
41 | user: {
42 | ...auth.user,
43 | avatar: url ? url : auth.user.avatar,
44 | name: name ? name : auth.user.name
45 | }
46 | }
47 | })
48 |
49 | const res = await patchAPI('user', {
50 | avatar: url ? url : auth.user.avatar,
51 | name: name ? name : auth.user.name
52 | }, access_token)
53 |
54 | dispatch({ type: ALERT, payload: {success: res.data.msg}})
55 |
56 | } catch (err: any) {
57 | dispatch({ type: ALERT, payload: {errors: err.response.data.msg}})
58 | }
59 | }
60 |
61 |
62 | export const resetPassword = (
63 | password: string, cf_password: string, token: string
64 | ) => async (dispatch: Dispatch) => {
65 | const result = await checkTokenExp(token, dispatch)
66 | const access_token = result ? result : token
67 |
68 | const msg = checkPassword(password, cf_password)
69 | if(msg) return dispatch({ type: ALERT, payload: {errors: msg}})
70 |
71 | try {
72 | dispatch({ type: ALERT, payload: {loading: true}})
73 |
74 | const res = await patchAPI('reset_password', { password }, access_token)
75 |
76 | dispatch({ type: ALERT, payload: {success: res.data.msg}})
77 |
78 | } catch (err: any) {
79 | dispatch({ type: ALERT, payload: {errors: err.response.data.msg}})
80 | }
81 | }
82 |
83 |
84 | export const getOtherInfo = (id: string) =>
85 | async (dispatch: Dispatch) => {
86 | try {
87 | dispatch({ type: ALERT, payload: {loading: true}})
88 |
89 | const res = await getAPI(`user/${id}`)
90 |
91 | dispatch({
92 | type: GET_OTHER_INFO,
93 | payload: res.data
94 | })
95 |
96 | dispatch({ type: ALERT, payload: { }})
97 |
98 | } catch (err: any) {
99 | dispatch({ type: ALERT, payload: {errors: err.response.data.msg}})
100 | }
101 | }
--------------------------------------------------------------------------------
/client/src/redux/reducers/alertReducer.ts:
--------------------------------------------------------------------------------
1 | import { ALERT, IAlertType } from '../types/alertType'
2 | import { IAlert } from '../../utils/TypeScript'
3 |
4 |
5 | const alertReducer = (state: IAlert = {}, action: IAlertType): IAlert => {
6 | switch (action.type){
7 | case ALERT:
8 | return action.payload
9 | default:
10 | return state
11 | }
12 | }
13 |
14 | export default alertReducer;
--------------------------------------------------------------------------------
/client/src/redux/reducers/authReducer.ts:
--------------------------------------------------------------------------------
1 | import { AUTH, IAuth, IAuthType } from '../types/authType'
2 |
3 |
4 | const authReducer = (state: IAuth = {}, action: IAuthType): IAuth => {
5 | switch (action.type){
6 | case AUTH:
7 | return action.payload
8 | default:
9 | return state
10 | }
11 | }
12 |
13 | export default authReducer;
--------------------------------------------------------------------------------
/client/src/redux/reducers/blogsCategoryReducer.ts:
--------------------------------------------------------------------------------
1 | import {
2 | GET_BLOGS_CATEGORY_ID,
3 | IBlogsCategory,
4 | IGetBlogsCategoryType
5 | } from '../types/blogType'
6 |
7 |
8 | const blogsCategoryReducer = (
9 | state: IBlogsCategory[] = [],
10 | action: IGetBlogsCategoryType
11 | ): IBlogsCategory[] => {
12 | switch(action.type){
13 | case GET_BLOGS_CATEGORY_ID:
14 | if(state.every(item => item.id !== action.payload.id)){
15 | return [...state, action.payload]
16 |
17 | }else{
18 | return state.map(blog => (
19 | blog.id === action.payload.id
20 | ? action.payload
21 | : blog
22 | ))
23 | }
24 |
25 | default:
26 | return state;
27 | }
28 | }
29 |
30 |
31 | export default blogsCategoryReducer;
--------------------------------------------------------------------------------
/client/src/redux/reducers/blogsUserReducer.ts:
--------------------------------------------------------------------------------
1 | import { IUser } from '../../utils/TypeScript'
2 |
3 | import {
4 | IBlogsUser,
5 | GET_BLOGS_USER_ID,
6 | CREATE_BLOGS_USER_ID,
7 | DELETE_BLOGS_USER_ID,
8 | IBlogUserType
9 | } from '../types/blogType'
10 |
11 |
12 | const blogsUserReducer = (
13 | state: IBlogsUser[] = [],
14 | action: IBlogUserType
15 | ): IBlogsUser[] => {
16 | switch(action.type){
17 | case GET_BLOGS_USER_ID:
18 | if(state.every(item => item.id !== action.payload.id)){
19 | return [...state, action.payload]
20 |
21 | }else{
22 | return state.map(item => (
23 | item.id === action.payload.id
24 | ? action.payload
25 | : item
26 | ))
27 | }
28 |
29 | case CREATE_BLOGS_USER_ID:
30 | return state.map(item => (
31 | item.id === (action.payload.user as IUser)._id
32 | ? {
33 | ...item,
34 | blogs: [action.payload, ...item.blogs]
35 | }
36 | : item
37 | ))
38 |
39 | case DELETE_BLOGS_USER_ID:
40 | return state.map(item => (
41 | item.id === (action.payload.user as IUser)._id
42 | ? {
43 | ...item,
44 | blogs: item.blogs.filter(blog => (
45 | blog._id !== action.payload._id
46 | ))
47 | }
48 | : item
49 | ))
50 | default:
51 | return state;
52 | }
53 | }
54 |
55 |
56 | export default blogsUserReducer;
--------------------------------------------------------------------------------
/client/src/redux/reducers/categoryReducer.ts:
--------------------------------------------------------------------------------
1 | import * as types from '../types/categoryType'
2 | import { ICategory } from '../../utils/TypeScript'
3 |
4 | const categoryReducer = (
5 | state: ICategory[] = [], action: types.ICategoryType
6 | ): ICategory[] => {
7 | switch (action.type) {
8 | case types.CREATE_CATEGORY:
9 | return [action.payload, ...state]
10 |
11 | case types.GET_CATEGORIES:
12 | return action.payload
13 |
14 | case types.UPDATE_CATEGORY:
15 | return state.map(item => (
16 | item._id === action.payload._id
17 | ? { ...item, name: action.payload.name}
18 | : item
19 | ))
20 |
21 | case types.DELETE_CATEGORY:
22 | return state.filter(item => item._id !== action.payload)
23 |
24 | default:
25 | return state;
26 | }
27 | }
28 |
29 | export default categoryReducer;
--------------------------------------------------------------------------------
/client/src/redux/reducers/commentReducer.ts:
--------------------------------------------------------------------------------
1 | import {
2 | ICommentState,
3 | CREATE_COMMENT,
4 | GET_COMMENTS,
5 | REPLY_COMMENT,
6 | UPDATE_COMMENT,
7 | UPDATE_REPLY,
8 | DELETE_COMMENT,
9 | DELETE_REPLY,
10 | ICommentType
11 | } from '../types/commentType'
12 |
13 | const initialState = {
14 | data: [],
15 | total: 1
16 | }
17 |
18 |
19 | const commentReducer = (
20 | state: ICommentState = initialState,
21 | action: ICommentType
22 | ): ICommentState => {
23 | switch(action.type){
24 | case CREATE_COMMENT:
25 | return{
26 | ...state,
27 | data: [action.payload, ...state.data]
28 | }
29 |
30 | case GET_COMMENTS:
31 | return action.payload
32 |
33 | case REPLY_COMMENT:
34 | return {
35 | ...state,
36 | data: state.data.map(item => (
37 | item._id === action.payload.comment_root
38 | ? {
39 | ...item,
40 | replyCM: [
41 | action.payload,
42 | ...item.replyCM
43 | ]
44 | }
45 | : item
46 | ))
47 | }
48 |
49 | case UPDATE_COMMENT:
50 | return{
51 | ...state,
52 | data: state.data.map(item => (
53 | item._id === action.payload._id
54 | ? action.payload
55 | : item
56 | ))
57 | }
58 |
59 | case UPDATE_REPLY:
60 | return{
61 | ...state,
62 | data: state.data.map(item => (
63 | item._id === action.payload.comment_root
64 | ? {
65 | ...item,
66 | replyCM: item.replyCM?.map(rp => (
67 | rp._id === action.payload._id
68 | ? action.payload
69 | : rp
70 | ))
71 | }
72 | : item
73 | ))
74 | }
75 |
76 | case DELETE_COMMENT:
77 | return{
78 | ...state,
79 | data: state.data.filter(item =>
80 | item._id !== action.payload._id
81 | )
82 | }
83 |
84 | case DELETE_REPLY:
85 | return{
86 | ...state,
87 | data: state.data.map(item => (
88 | item._id === action.payload.comment_root
89 | ? {
90 | ...item,
91 | replyCM: item.replyCM?.filter(rp => (
92 | rp._id !== action.payload._id
93 | ))
94 | }
95 | : item
96 | ))
97 | }
98 |
99 | default:
100 | return state
101 | }
102 | }
103 |
104 |
105 | export default commentReducer
--------------------------------------------------------------------------------
/client/src/redux/reducers/homeBlogsReducer.ts:
--------------------------------------------------------------------------------
1 | import {
2 | GET_HOME_BLOGS,
3 | IGetHomeBlogsType,
4 | IHomeBlogs
5 | } from '../types/blogType'
6 |
7 |
8 | const homeBlogsReducer = (
9 | state: IHomeBlogs[] = [],
10 | action: IGetHomeBlogsType
11 | ): IHomeBlogs[] => {
12 | switch (action.type){
13 | case GET_HOME_BLOGS:
14 | return action.payload
15 |
16 | default:
17 | return state
18 | }
19 | }
20 |
21 |
22 | export default homeBlogsReducer
--------------------------------------------------------------------------------
/client/src/redux/reducers/index.ts:
--------------------------------------------------------------------------------
1 | import { combineReducers } from 'redux'
2 | import auth from './authReducer'
3 | import alert from './alertReducer'
4 | import categories from './categoryReducer'
5 | import homeBlogs from './homeBlogsReducer'
6 | import blogsCategory from './blogsCategoryReducer'
7 | import otherInfo from './otherInfoReducer'
8 | import blogsUser from './blogsUserReducer'
9 | import comments from './commentReducer'
10 | import socket from './socketReducer'
11 |
12 | export default combineReducers({
13 | auth,
14 | alert,
15 | categories,
16 | homeBlogs,
17 | blogsCategory,
18 | otherInfo,
19 | blogsUser,
20 | comments,
21 | socket
22 | })
--------------------------------------------------------------------------------
/client/src/redux/reducers/otherInfoReducer.ts:
--------------------------------------------------------------------------------
1 | import { IUser } from '../../utils/TypeScript'
2 | import {
3 | GET_OTHER_INFO,
4 | IGetOtherInfoType
5 | } from '../types/profileType'
6 |
7 |
8 | const otherInfoReducer = (
9 | state: IUser[] = [],
10 | action: IGetOtherInfoType
11 | ): IUser[] => {
12 | switch(action.type){
13 | case GET_OTHER_INFO:
14 | return [...state, action.payload]
15 |
16 | default:
17 | return state
18 | }
19 | }
20 |
21 | export default otherInfoReducer
--------------------------------------------------------------------------------
/client/src/redux/reducers/socketReducer.ts:
--------------------------------------------------------------------------------
1 | import { SOCKET, ISocketType } from '../types/socketType'
2 |
3 | const socketReducer = (state: any = null, action: ISocketType): any => {
4 | switch(action.type) {
5 | case SOCKET:
6 | return action.payload
7 | default:
8 | return state
9 | }
10 | }
11 |
12 | export default socketReducer
13 |
--------------------------------------------------------------------------------
/client/src/redux/store.ts:
--------------------------------------------------------------------------------
1 | import { createStore, applyMiddleware } from 'redux'
2 | import thunk from 'redux-thunk'
3 |
4 | import rootReducer from './reducers/index'
5 |
6 | import { composeWithDevTools } from 'redux-devtools-extension'
7 |
8 | const store = createStore(
9 | rootReducer,
10 | composeWithDevTools(applyMiddleware(thunk))
11 | )
12 |
13 | export default store;
--------------------------------------------------------------------------------
/client/src/redux/types/alertType.ts:
--------------------------------------------------------------------------------
1 | import { IAlert } from '../../utils/TypeScript'
2 |
3 | export const ALERT = 'ALERT'
4 |
5 | export interface IAlertType {
6 | type: typeof ALERT
7 | payload: IAlert
8 | }
--------------------------------------------------------------------------------
/client/src/redux/types/authType.ts:
--------------------------------------------------------------------------------
1 | import { IUser } from '../../utils/TypeScript'
2 |
3 | export const AUTH = 'AUTH'
4 |
5 | export interface IAuth {
6 | msg?: string
7 | access_token?: string
8 | user?: IUser
9 | }
10 |
11 | export interface IAuthType{
12 | type: typeof AUTH
13 | payload: IAuth
14 | }
--------------------------------------------------------------------------------
/client/src/redux/types/blogType.ts:
--------------------------------------------------------------------------------
1 | import { IBlog } from '../../utils/TypeScript'
2 |
3 | export const GET_HOME_BLOGS = "GET_HOME_BLOGS"
4 | export const GET_BLOGS_CATEGORY_ID = "GET_BLOGS_CATEGORY_ID"
5 | export const GET_BLOGS_USER_ID = "GET_BLOGS_USER_ID"
6 | export const CREATE_BLOGS_USER_ID = "CREATE_BLOGS_USER_ID"
7 | export const DELETE_BLOGS_USER_ID = "DELETE_BLOGS_USER_ID"
8 |
9 |
10 | export interface IHomeBlogs {
11 | _id: string
12 | name: string
13 | count: number
14 | blogs: IBlog[]
15 | }
16 |
17 | export interface IGetHomeBlogsType {
18 | type: typeof GET_HOME_BLOGS,
19 | payload: IHomeBlogs[]
20 | }
21 |
22 | export interface IBlogsCategory {
23 | id: string
24 | blogs: IBlog[]
25 | total: number
26 | search: string
27 | }
28 |
29 | export interface IGetBlogsCategoryType {
30 | type: typeof GET_BLOGS_CATEGORY_ID,
31 | payload: IBlogsCategory
32 | }
33 |
34 | export interface IBlogsUser {
35 | id: string
36 | blogs: IBlog[]
37 | total: number
38 | search: string
39 | }
40 |
41 | export interface IGetBlogsUserType {
42 | type: typeof GET_BLOGS_USER_ID,
43 | payload: IBlogsUser
44 | }
45 |
46 | export interface ICreateBlogsUserType {
47 | type: typeof CREATE_BLOGS_USER_ID,
48 | payload: IBlog
49 | }
50 |
51 | export interface IDeleteBlogsUserType {
52 | type: typeof DELETE_BLOGS_USER_ID,
53 | payload: IBlog
54 | }
55 |
56 | export type IBlogUserType =
57 | | IGetBlogsUserType
58 | | ICreateBlogsUserType
59 | | IDeleteBlogsUserType
--------------------------------------------------------------------------------
/client/src/redux/types/categoryType.ts:
--------------------------------------------------------------------------------
1 | import { ICategory } from '../../utils/TypeScript'
2 |
3 | export const CREATE_CATEGORY = 'CREATE_CATEGORY'
4 | export const GET_CATEGORIES = 'GET_CATEGORIES'
5 | export const UPDATE_CATEGORY = 'UPDATE_CATEGORY'
6 | export const DELETE_CATEGORY = 'DELETE_CATEGORY'
7 |
8 |
9 | export interface ICreateCatery{
10 | type: typeof CREATE_CATEGORY
11 | payload: ICategory
12 | }
13 |
14 | export interface IGetCategories{
15 | type: typeof GET_CATEGORIES
16 | payload: ICategory[]
17 | }
18 |
19 | export interface IUpdateCategory{
20 | type: typeof UPDATE_CATEGORY
21 | payload: ICategory
22 | }
23 |
24 | export interface IDeleteCategory{
25 | type: typeof DELETE_CATEGORY
26 | payload: string
27 | }
28 |
29 | export type ICategoryType =
30 | | ICreateCatery
31 | | IGetCategories
32 | | IUpdateCategory
33 | | IDeleteCategory
34 |
--------------------------------------------------------------------------------
/client/src/redux/types/commentType.ts:
--------------------------------------------------------------------------------
1 | import { IComment } from '../../utils/TypeScript'
2 |
3 | export const CREATE_COMMENT = "CREATE_COMMENT"
4 | export const GET_COMMENTS = "GET_COMMENTS"
5 | export const REPLY_COMMENT = "REPLY_COMMENT"
6 | export const UPDATE_COMMENT = "UPDATE_COMMENT"
7 | export const UPDATE_REPLY = "UPDATE_REPLY"
8 | export const DELETE_COMMENT = "DELETE_COMMENT"
9 | export const DELETE_REPLY = "DELETE_REPLY"
10 |
11 | export interface ICommentState {
12 | data: IComment[],
13 | total: number
14 | }
15 |
16 | export interface ICreateCommentType {
17 | type: typeof CREATE_COMMENT,
18 | payload: IComment
19 | }
20 |
21 | export interface IGetCommentsType {
22 | type: typeof GET_COMMENTS,
23 | payload: ICommentState
24 | }
25 |
26 | export interface IReplyCommentType {
27 | type: typeof REPLY_COMMENT,
28 | payload: IComment
29 | }
30 |
31 | export interface IUpdateType {
32 | type: typeof UPDATE_COMMENT | typeof UPDATE_REPLY,
33 | payload: IComment
34 | }
35 |
36 | export interface IDeleteType {
37 | type: typeof DELETE_COMMENT | typeof DELETE_REPLY,
38 | payload: IComment
39 | }
40 |
41 |
42 |
43 | export type ICommentType =
44 | | ICreateCommentType
45 | | IGetCommentsType
46 | | IReplyCommentType
47 | | IUpdateType
48 | | IDeleteType
--------------------------------------------------------------------------------
/client/src/redux/types/profileType.ts:
--------------------------------------------------------------------------------
1 | import { IUser } from '../../utils/TypeScript'
2 |
3 | export const GET_OTHER_INFO = "GET_OTHER_INFO"
4 |
5 |
6 | export interface IGetOtherInfoType {
7 | type: typeof GET_OTHER_INFO,
8 | payload: IUser
9 | }
--------------------------------------------------------------------------------
/client/src/redux/types/socketType.ts:
--------------------------------------------------------------------------------
1 | import { Socket } from 'socket.io-client'
2 |
3 | export const SOCKET = "SOCKET"
4 |
5 | export interface ISocketType {
6 | type: typeof SOCKET,
7 | payload: Socket
8 | }
--------------------------------------------------------------------------------
/client/src/styles/alert.css:
--------------------------------------------------------------------------------
1 | .errMsg{
2 | background: rgb(214, 10, 10);
3 | color: #fff9;
4 | text-align: center;
5 | padding: 10px 0;
6 | letter-spacing: 1.3px;
7 | }
8 |
9 | .successMsg{
10 | background: rgb(9, 158, 54);
11 | color: #fff9;
12 | text-align: center;
13 | padding: 10px 0;
14 | letter-spacing: 1.3px;
15 | }
--------------------------------------------------------------------------------
/client/src/styles/auth.css:
--------------------------------------------------------------------------------
1 | .auth_page{
2 | width: 100%;
3 | padding: 2.5rem 0;
4 | background: #fdfdfd;
5 | display: flex;
6 | justify-content: center;
7 | }
8 |
9 | .auth_page .auth_box{
10 | background: #fff;
11 | max-width: 400px;
12 | width: 100%;
13 | border: 1px solid #ddd;
14 | padding: 2.7rem 1.7rem;
15 | }
16 |
17 | .auth_page .pass{
18 | position: relative;
19 | }
20 | .auth_page .pass small{
21 | position: absolute;
22 | top: 50%;
23 | right: 5px;
24 | transform: translateY(-50%);
25 | cursor: pointer;
26 | opacity: 0.5;
27 | }
28 |
29 | .auth_page input::placeholder{
30 | opacity: 0.5;
31 | font-size: 80%;
32 | }
33 |
34 | .auth_page a{
35 | text-decoration: none;
36 | }
37 | .auth_page a:hover{
38 | text-decoration: underline;
39 | }
--------------------------------------------------------------------------------
/client/src/styles/blogs_category.css:
--------------------------------------------------------------------------------
1 | .blogs_category {
2 | width: 100%;
3 | margin: 1rem 0;
4 | }
5 |
6 | .blogs_category .show_blogs{
7 | width: 100%;
8 | display: grid;
9 | grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
10 | grid-gap: 10px;
11 | margin-bottom: 15px;
12 | }
13 |
14 |
--------------------------------------------------------------------------------
/client/src/styles/category.css:
--------------------------------------------------------------------------------
1 | .category{
2 | max-width: 500px;
3 | margin: 2em auto;
4 | }
5 |
6 | .category form{
7 | width: 100%;
8 | margin-bottom: 2rem;
9 | }
10 | .category form label{
11 | display: block;
12 | font-weight: 700;
13 | letter-spacing: 2px;
14 | text-transform: uppercase;
15 | margin-bottom: 1rem;
16 | }
17 |
18 | .category form input,
19 | .category form button{
20 | height: 35px;
21 | border: none;
22 | outline: none;
23 | border-bottom: 1px solid #555;
24 | }
25 |
26 | .category form input{
27 | flex: 1;
28 | }
29 |
30 | .category form button{
31 | width: 80px;
32 | background: #555;
33 | color: white;
34 | margin-left: 10px;
35 | }
36 |
37 | .category .category_row{
38 | width: 100%;
39 | display: flex;
40 | justify-content: space-between;
41 | align-items: center;
42 | padding: 10px;
43 | margin-bottom: 1rem;
44 | border: 1px solid #ccc;
45 | cursor: pointer;
46 | }
--------------------------------------------------------------------------------
/client/src/styles/comments.css:
--------------------------------------------------------------------------------
1 | .avatar_comment{
2 | width: 70px;
3 | min-width: 70px;
4 | text-align: center;
5 | padding: 5px;
6 | }
7 |
8 | .avatar_comment img{
9 | width: 40px;
10 | height: 40px;
11 | border-radius: 50%;
12 | object-fit: cover;
13 | }
14 |
15 | .avatar_comment small a{
16 | color: #444;
17 | text-decoration: none;
18 | font-weight: 500;
19 | }
20 |
21 | .avatar_comment small a:hover{
22 | color: crimson;
23 | }
24 |
25 | .comment_box {
26 | width: 100%;
27 | border: 1px solid #ddd;
28 | margin-bottom: 0.75rem;
29 | }
30 |
31 | .avatar_reply{
32 | display: flex;
33 | align-items: center;
34 | margin-bottom: 0.25rem;
35 | }
36 |
37 | .avatar_reply img{
38 | width: 40px;
39 | height: 40px;
40 | border-radius: 50%;
41 | object-fit: cover;
42 | }
43 | .avatar_reply .reply-text{
44 | display: block;
45 | opacity: 0.5;
46 | font-size: 11px;
47 | }
48 |
49 | .comment_box .comment_nav {
50 | cursor: pointer;
51 | display: none;
52 | }
53 |
54 | .comment_box:hover .comment_nav {
55 | display: block;
56 | }
57 | .comment_box .comment_nav i:hover {
58 | color: crimson;
59 | }
--------------------------------------------------------------------------------
/client/src/styles/home.css:
--------------------------------------------------------------------------------
1 | .home_page {
2 | width: 100%;
3 | margin: 1rem 0;
4 | }
5 |
6 | .home_page h3 a {
7 | text-transform: uppercase;
8 | text-decoration: none;
9 | color: darkblue;
10 | letter-spacing: 3;
11 | margin-top: 1rem;
12 | cursor: pointer;
13 | }
14 |
15 | .home_page h3:hover a {
16 | color: crimson;
17 | }
18 |
19 | .home_page h3 a small{
20 | font-size: 15px;
21 | }
22 |
23 | .home_blogs{
24 | width: 100%;
25 | display: grid;
26 | grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
27 | grid-gap: 10px;
28 | margin-bottom: 15px;
29 | }
30 |
31 |
--------------------------------------------------------------------------------
/client/src/styles/index.css:
--------------------------------------------------------------------------------
1 | * {
2 | padding: 0;
3 | box-sizing: border-box;
4 | margin: 0;
5 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
6 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
7 | sans-serif;
8 | -webkit-font-smoothing: antialiased;
9 | -moz-osx-font-smoothing: grayscale;
10 | }
11 |
12 | code {
13 | font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
14 | monospace;
15 | }
16 |
17 | .avatar{
18 | width: 30px;
19 | height: 30px;
20 | object-fit: cover;
21 | transform: translateY(-3px);
22 | border-radius: 50%;
23 | }
24 |
25 | /* ---------- Auth Page------------ */
26 | @import url('./auth.css');
27 |
28 | /* ---------- Loading Page------------ */
29 | @import url('./loading.css');
30 |
31 | /* ---------- Alert Page------------ */
32 | @import url('./alert.css');
33 |
34 | /* ---------- Profile Page------------ */
35 | @import url('./profile.css');
36 |
37 | /* ---------- Category Page------------ */
38 | @import url('./category.css');
39 |
40 | /* ---------- Home Page------------ */
41 | @import url('./home.css');
42 |
43 | /* ---------- Blogs Category Page------------ */
44 | @import url('./blogs_category.css');
45 |
46 | /* ---------- Comments ------------ */
47 | @import url('./comments.css');
48 |
--------------------------------------------------------------------------------
/client/src/styles/loading.css:
--------------------------------------------------------------------------------
1 | .loading{
2 | display: flex;
3 | justify-content: center;
4 | align-items: center;
5 | }
6 |
7 | .loading svg{
8 | font-size: 5px;
9 | font-weight: 600;
10 | text-transform: uppercase;
11 | letter-spacing: 1.2px;
12 | animation: text 1s ease-in-out infinite;
13 | }
14 | @keyframes text{
15 | 50% { opacity: 0.1 }
16 | }
17 |
18 | .loading polygon{
19 | stroke-dasharray: 22;
20 | stroke-dashoffset: 1;
21 | animation: dash 4s cubic-bezier(0.445, 0.05, 0.55, 0.95)
22 | infinite alternate-reverse;
23 | }
24 | @keyframes dash{
25 | to { stroke-dashoffset: 234 }
26 | }
--------------------------------------------------------------------------------
/client/src/styles/profile.css:
--------------------------------------------------------------------------------
1 | .profile_info{
2 | background: #fff;
3 | width: 100%;
4 | border: 1px solid #ddd;
5 | padding: 2.5rem 1.7rem;
6 | }
7 |
8 | .profile_info .pass{
9 | position: relative;
10 | }
11 |
12 | .profile_info .pass small{
13 | position: absolute;
14 | top: 50%;
15 | right: 5px;
16 | transform: translateY(-50%);
17 | cursor: pointer;
18 | opacity: 0.5;
19 | }
20 |
21 | .profile_info .info_avatar{
22 | width: 180px;
23 | height: 180px;
24 | overflow: hidden;
25 | border-radius: 50%;
26 | position: relative;
27 | margin: 0 auto;
28 | margin-bottom: 15px;
29 | border: 1px solid #ddd;
30 | cursor: pointer;
31 | }
32 | .profile_info .info_avatar img{
33 | width: 100%;
34 | height: 100%;
35 | display: block;
36 | object-fit: cover;
37 | }
38 | .profile_info .info_avatar span{
39 | position: absolute;
40 | bottom: -100%;
41 | left: 0;
42 | width: 100%;
43 | height: 50%;
44 | text-align: center;
45 | color: crimson;
46 | transition: 0.3s ease-in-out;
47 | background: #fff5;
48 | }
49 | .profile_info .info_avatar:hover span{
50 | bottom: -15%;
51 | }
52 |
53 | .profile_info .info_avatar #file_up{
54 | position: absolute;
55 | top:0;
56 | left: 0;
57 | width: 100%;
58 | height: 100%;
59 | cursor: pointer;
60 | opacity: 0;
61 | }
62 | ::-webkit-file-upload-button{
63 | cursor: pointer;
64 | }
--------------------------------------------------------------------------------
/client/src/utils/FetchData.ts:
--------------------------------------------------------------------------------
1 | import axios from 'axios'
2 |
3 |
4 | export const postAPI = async (url: string, post: object, token?:string) => {
5 | const res = await axios.post(`/api/${url}`, post, {
6 | headers: { Authorization: token }
7 | })
8 |
9 | return res;
10 | }
11 |
12 |
13 | export const getAPI = async (url: string, token?:string) => {
14 | const res = await axios.get(`/api/${url}`, {
15 | headers: { Authorization: token }
16 | })
17 |
18 | return res;
19 | }
20 |
21 | export const patchAPI = async (url: string, post: object, token?:string) => {
22 | const res = await axios.patch(`/api/${url}`, post, {
23 | headers: { Authorization: token }
24 | })
25 |
26 | return res;
27 | }
28 |
29 |
30 | export const putAPI = async (url: string, post: object, token?:string) => {
31 | const res = await axios.put(`/api/${url}`, post, {
32 | headers: { Authorization: token }
33 | })
34 |
35 | return res;
36 | }
37 |
38 |
39 | export const deleteAPI = async (url: string, token?:string) => {
40 | const res = await axios.delete(`/api/${url}`, {
41 | headers: { Authorization: token }
42 | })
43 |
44 | return res;
45 | }
--------------------------------------------------------------------------------
/client/src/utils/ImageUpload.ts:
--------------------------------------------------------------------------------
1 | export const checkImage = (file: File) => {
2 | const types = ['image/png', 'image/jpeg']
3 | let err = ''
4 | if(!file) return err = "File does not exist."
5 |
6 | if(file.size > 1024 * 1024) // 1mb
7 | err = "The largest image size is 1mb"
8 |
9 | if(!types.includes(file.type))
10 | err = "The image type is png / jpeg"
11 |
12 | return err;
13 | }
14 |
15 | export const imageUpload = async (file: File) => {
16 | const formData = new FormData()
17 | formData.append("file", file)
18 | formData.append("upload_preset", "xwqohnif")
19 | formData.append("cloud_name", "devat-channel")
20 |
21 | const res = await fetch("https://api.cloudinary.com/v1_1/devat-channel/upload", {
22 | method: "POST",
23 | body: formData
24 | })
25 |
26 | const data = await res.json()
27 | return { public_id: data.public_id, url: data.secure_url };
28 | }
--------------------------------------------------------------------------------
/client/src/utils/TypeScript.ts:
--------------------------------------------------------------------------------
1 | import { ChangeEvent, FormEvent } from 'react'
2 | import rootReducer from '../redux/reducers/index'
3 |
4 | export type InputChange = ChangeEvent<
5 | | HTMLInputElement
6 | | HTMLTextAreaElement
7 | | HTMLSelectElement
8 | >
9 |
10 | export type FormSubmit = FormEvent
11 |
12 | export type RootStore = ReturnType
13 |
14 |
15 | export interface IParams {
16 | page: string
17 | slug: string
18 | }
19 |
20 | export interface IUserLogin {
21 | account: string
22 | password: string
23 | }
24 |
25 | export interface IUserRegister extends IUserLogin {
26 | name: string
27 | cf_password: string
28 | }
29 |
30 | export interface IUser extends IUserLogin {
31 | avatar: string
32 | createdAt: string
33 | name: string
34 | role: string
35 | type: string
36 | updatedAt: string
37 | _id: string
38 | }
39 |
40 | export interface IUserProfile extends IUserRegister {
41 | avatar: string | File
42 | }
43 |
44 |
45 |
46 | export interface IAlert {
47 | loading?: boolean
48 | success?: string | string[]
49 | errors?: string | string[]
50 | }
51 |
52 |
53 | export interface ICategory {
54 | _id: string
55 | name: string
56 | createdAt: string
57 | updatedAt: string
58 | }
59 |
60 | export interface IBlog {
61 | _id?: string
62 | user: string | IUser
63 | title: string
64 | content: string
65 | description: string
66 | thumbnail: string | File
67 | category: string
68 | createdAt: string
69 | }
70 |
71 | export interface IComment {
72 | _id?: string
73 | user: IUser
74 | blog_id: string
75 | blog_user_id: string
76 | content: string
77 | replyCM: IComment[]
78 | reply_user?: IUser
79 | comment_root?: string
80 | createdAt: string
81 | }
--------------------------------------------------------------------------------
/client/src/utils/Valid.ts:
--------------------------------------------------------------------------------
1 | import { IUserRegister, IBlog } from './TypeScript'
2 |
3 | export const validRegister = (userRegister: IUserRegister) => {
4 | const { name, account, password, cf_password } = userRegister;
5 | const errors: string[] = [];
6 |
7 | if(!name){
8 | errors.push("Please add your name.")
9 | }else if(name.length > 20){
10 | errors.push("Your name is up to 20 chars long.")
11 | }
12 |
13 | if(!account){
14 | errors.push("Please add your email or phone number.")
15 | }else if(!validPhone(account) && !validateEmail(account)){
16 | errors.push("Email or phone number format is incorrect.")
17 | }
18 |
19 | const msg = checkPassword(password, cf_password)
20 | if(msg) errors.push(msg)
21 |
22 | return {
23 | errMsg: errors,
24 | errLength: errors.length
25 | }
26 | }
27 |
28 |
29 | export const checkPassword = (password: string, cf_password: string) => {
30 | if(password.length < 6){
31 | return ("Password must be at least 6 chars.")
32 | }else if(password !== cf_password){
33 | return ("Confirm password did not match.")
34 | }
35 | }
36 |
37 | export function validPhone(phone: string) {
38 | const re = /^[+]/g
39 | return re.test(phone)
40 | }
41 |
42 | export function validateEmail(email: string) {
43 | const re = /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
44 | return re.test(String(email).toLowerCase());
45 | }
46 |
47 |
48 | // Valid Blog
49 | export const validCreateBlog = ({
50 | title, content, description, thumbnail, category
51 | }: IBlog) => {
52 | const err: string[] = []
53 |
54 | if(title.trim().length < 10){
55 | err.push("Title has at least 10 characters.")
56 | }else if(title.trim().length > 50){
57 | err.push("Title is up to 50 characters long.")
58 | }
59 |
60 | if(content.trim().length < 2000){
61 | err.push("Content has at least 2000 characters.")
62 | }
63 |
64 | if(description.trim().length < 50){
65 | err.push("Description has at least 50 characters.")
66 | }else if(description.trim().length > 200){
67 | err.push("Description is up to 200 characters long.")
68 | }
69 |
70 | if(!thumbnail){
71 | err.push("Thumbnail cannot be left blank.")
72 | }
73 |
74 | if(!category){
75 | err.push("Category cannot be left blank.")
76 | }
77 |
78 | return {
79 | errMsg: err,
80 | errLength: err.length
81 | }
82 |
83 | }
84 |
85 | // Shallow equality
86 | export const shallowEqual = (object1: any, object2: any) => {
87 | const keys1 = Object.keys(object1)
88 | const keys2 = Object.keys(object2)
89 |
90 | if(keys1.length !== keys2.length) {
91 | return false;
92 | }
93 |
94 | for(let key of keys1) {
95 | if(object1[key] !== object2[key]){
96 | return false;
97 | }
98 | }
99 |
100 | return true;
101 |
102 | }
--------------------------------------------------------------------------------
/client/src/utils/checkTokenExp.ts:
--------------------------------------------------------------------------------
1 | import jwt_decode from "jwt-decode";
2 | import { AUTH } from '../redux/types/authType'
3 | import { getAPI } from './FetchData'
4 |
5 |
6 | interface IToken {
7 | exp: number
8 | iat: number
9 | id: string
10 | }
11 |
12 | export const checkTokenExp = async (token: string, dispatch: any) => {
13 | const decoded: IToken = jwt_decode(token)
14 |
15 | if(decoded.exp >= Date.now() / 1000) return;
16 |
17 | const res = await getAPI('refresh_token')
18 | dispatch({ type: AUTH, payload: res.data })
19 | return res.data.access_token;
20 | }
--------------------------------------------------------------------------------
/client/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "target": "es5",
4 | "lib": [
5 | "dom",
6 | "dom.iterable",
7 | "esnext"
8 | ],
9 | "allowJs": true,
10 | "skipLibCheck": true,
11 | "esModuleInterop": true,
12 | "allowSyntheticDefaultImports": true,
13 | "strict": true,
14 | "forceConsistentCasingInFileNames": true,
15 | "noFallthroughCasesInSwitch": true,
16 | "module": "esnext",
17 | "moduleResolution": "node",
18 | "resolveJsonModule": true,
19 | "isolatedModules": true,
20 | "noEmit": true,
21 | "jsx": "react-jsx"
22 | },
23 | "include": [
24 | "src"
25 | ]
26 | }
27 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "blogdev",
3 | "version": "1.0.0",
4 | "description": "",
5 | "main": "dist/index.js",
6 | "scripts": {
7 | "start": "node dist/index.js",
8 | "dev": "ts-node-dev server/index.ts",
9 | "build": "tsc",
10 | "heroku-postbuild": "tsc && cd client && npm i --only=dev && npm i && npm run build"
11 | },
12 | "keywords": [],
13 | "author": "",
14 | "license": "ISC",
15 | "dependencies": {
16 | "bcrypt": "^5.0.1",
17 | "cookie-parser": "^1.4.5",
18 | "cors": "^2.8.5",
19 | "dotenv": "^9.0.2",
20 | "express": "^4.17.1",
21 | "google-auth-library": "^7.1.0",
22 | "jsonwebtoken": "^8.5.1",
23 | "mongoose": "^5.12.10",
24 | "morgan": "^1.10.0",
25 | "node-fetch": "^2.6.1",
26 | "nodemailer": "^6.6.1",
27 | "socket.io": "^4.2.0",
28 | "twilio": "^3.63.0"
29 | },
30 | "devDependencies": {
31 | "@types/bcrypt": "^5.0.0",
32 | "@types/cookie-parser": "^1.4.2",
33 | "@types/cors": "^2.8.10",
34 | "@types/dotenv": "^8.2.0",
35 | "@types/express": "^4.17.11",
36 | "@types/jsonwebtoken": "^8.5.1",
37 | "@types/mongoose": "^5.10.5",
38 | "@types/morgan": "^1.9.2",
39 | "@types/node": "^15.3.1",
40 | "@types/node-fetch": "^2.5.10",
41 | "@types/nodemailer": "^6.4.2",
42 | "ts-node-dev": "^1.1.6",
43 | "typescript": "^4.2.4"
44 | }
45 | }
46 |
--------------------------------------------------------------------------------
/server/config/database.ts:
--------------------------------------------------------------------------------
1 | import mongoose from 'mongoose'
2 |
3 | const URI = process.env.MONGODB_URL
4 |
5 | mongoose.connect(`${URI}`, {
6 | useCreateIndex: true,
7 | useFindAndModify: false,
8 | useNewUrlParser: true,
9 | useUnifiedTopology: true
10 | }, (err) => {
11 | if(err) throw err;
12 | console.log('Mongodb connection')
13 | })
--------------------------------------------------------------------------------
/server/config/generateToken.ts:
--------------------------------------------------------------------------------
1 | import jwt from 'jsonwebtoken'
2 | import { Response } from 'express'
3 |
4 |
5 | const {
6 | ACTIVE_TOKEN_SECRET,
7 | ACCESS_TOKEN_SECRET,
8 | REFRESH_TOKEN_SECRET
9 | } = process.env
10 |
11 | export const generateActiveToken = (payload: object) => {
12 | return jwt.sign(payload, `${ACTIVE_TOKEN_SECRET}`, {expiresIn: '5m'})
13 | }
14 |
15 | export const generateAccessToken = (payload: object) => {
16 | return jwt.sign(payload, `${ACCESS_TOKEN_SECRET}`, {expiresIn: '15m'})
17 | }
18 |
19 | export const generateRefreshToken = (payload: object, res: Response) => {
20 | const refresh_token = jwt.sign(payload, `${REFRESH_TOKEN_SECRET}`, {expiresIn: '30d'})
21 |
22 | res.cookie('refreshtoken', refresh_token, {
23 | httpOnly: true,
24 | path: `/api/refresh_token`,
25 | maxAge: 30*24*60*60*1000 // 30days
26 | })
27 |
28 | return refresh_token;
29 | }
--------------------------------------------------------------------------------
/server/config/interface.ts:
--------------------------------------------------------------------------------
1 | import { Document } from 'mongoose'
2 | import { Request } from 'express'
3 |
4 | export interface IUser extends Document{
5 | name: string
6 | account: string
7 | password: string
8 | avatar: string
9 | role: string
10 | type: string
11 | rf_token?: string
12 | _doc: object
13 | }
14 |
15 |
16 | export interface INewUser {
17 | name: string
18 | account: string
19 | password: string
20 | }
21 |
22 | export interface IDecodedToken {
23 | id?: string
24 | newUser?: INewUser
25 | iat: number
26 | exp: number
27 | }
28 |
29 | export interface IGgPayload {
30 | email: string
31 | email_verified: boolean
32 | name: string
33 | picture: string
34 | }
35 |
36 | export interface IUserParams {
37 | name: string
38 | account: string
39 | password: string
40 | avatar?: string
41 | type: string
42 | }
43 |
44 | export interface IReqAuth extends Request {
45 | user?: IUser
46 | }
47 |
48 |
49 | export interface IComment extends Document{
50 | user: string
51 | blog_id: string
52 | blog_user_id: string
53 | content: string
54 | replyCM: string[]
55 | reply_user: string
56 | comment_root: string
57 | _doc: object
58 | }
59 |
60 |
61 | export interface IBlog extends Document{
62 | user: string
63 | title: string
64 | content: string
65 | description: string
66 | thumbnail: string
67 | category: string
68 | _doc: object
69 | }
--------------------------------------------------------------------------------
/server/config/sendMail.ts:
--------------------------------------------------------------------------------
1 | const nodemailer = require("nodemailer");
2 | import { OAuth2Client } from "google-auth-library";
3 |
4 | const OAUTH_PLAYGROUND = "https://developers.google.com/oauthplayground";
5 |
6 | const CLIENT_ID = `${process.env.MAIL_CLIENT_ID}`;
7 | const CLIENT_SECRET = `${process.env.MAIL_CLIENT_SECRET}`;
8 | const REFRESH_TOKEN = `${process.env.MAIL_REFRESH_TOKEN}`;
9 | const SENDER_MAIL = `${process.env.SENDER_EMAIL_ADDRESS}`;
10 |
11 | // send mail
12 | const sendEmail = async (to: string, url: string, txt: string) => {
13 | const oAuth2Client = new OAuth2Client(
14 | CLIENT_ID,
15 | CLIENT_SECRET,
16 | OAUTH_PLAYGROUND
17 | );
18 |
19 | oAuth2Client.setCredentials({ refresh_token: REFRESH_TOKEN });
20 |
21 | try {
22 | const access_token = await oAuth2Client.getAccessToken();
23 |
24 | const transport = nodemailer.createTransport({
25 | service: "gmail",
26 | auth: {
27 | type: "OAuth2",
28 | user: SENDER_MAIL,
29 | clientId: CLIENT_ID,
30 | clientSecret: CLIENT_SECRET,
31 | refreshToken: REFRESH_TOKEN,
32 | access_token,
33 | },
34 | });
35 |
36 | const mailOptions = {
37 | from: SENDER_MAIL,
38 | to: to,
39 | subject: "BlogDev",
40 | html: `
41 |
42 |
Welcome to the DevAT channel.
43 |
Congratulations! You're almost set to start using BlogDEV.
44 | Just click the button below to validate your email address.
45 |
46 |
47 |
${txt}
48 |
49 |
If the button doesn't work for any reason, you can also click on the link below:
50 |
51 |
${url}
52 |
53 | `,
54 | };
55 |
56 | const result = await transport.sendMail(mailOptions);
57 | return result;
58 | } catch (err) {
59 | console.log(err);
60 | }
61 | };
62 |
63 | export default sendEmail;
64 |
--------------------------------------------------------------------------------
/server/config/sendSMS.ts:
--------------------------------------------------------------------------------
1 | import { Twilio } from 'twilio'
2 |
3 | const accountSid = `${process.env.TWILIO_ACCOUNT_SID}`;
4 | const authToken = `${process.env.TWILIO_AUTH_TOKEN}`;
5 | const from = `${process.env.TWILIO_PHONE_NUMBER}`;
6 | const serviceID = `${process.env.TWILIO_SERVICE_ID}`;
7 |
8 | const client = new Twilio(accountSid, authToken)
9 |
10 |
11 | export const sendSms = (to: string, body: string, txt: string) => {
12 | try {
13 | client.messages
14 | .create({
15 | body: `BlogDev ${txt} - ${body}`,
16 | from,
17 | to
18 | })
19 | .then(message => console.log(message.sid));
20 |
21 | } catch (err) {
22 | console.log(err)
23 | }
24 | }
25 |
26 |
27 | export const smsOTP = async(to: string, channel: string) => {
28 | try {
29 | const data = await client
30 | .verify
31 | .services(serviceID)
32 | .verifications
33 | .create({
34 | to,
35 | channel
36 | })
37 |
38 | return data;
39 | } catch (err) {
40 | console.log(err)
41 | }
42 | }
43 |
44 | export const smsVerify = async(to: string, code: string) => {
45 | try {
46 | const data = await client
47 | .verify
48 | .services(serviceID)
49 | .verificationChecks
50 | .create({
51 | to,
52 | code
53 | })
54 |
55 | return data;
56 | } catch (err) {
57 | console.log(err)
58 | }
59 | }
--------------------------------------------------------------------------------
/server/config/socket.ts:
--------------------------------------------------------------------------------
1 | import { Socket } from 'socket.io'
2 |
3 |
4 | export const SocketServer = (socket: Socket) => {
5 | socket.on('joinRoom', (id: string) => {
6 | socket.join(id)
7 | // console.log({ joinRoom: (socket as any).adapter.rooms })
8 | })
9 |
10 | socket.on('outRoom', (id: string) => {
11 | socket.leave(id)
12 | // console.log({ outRoom: (socket as any).adapter.rooms })
13 | })
14 |
15 | socket.on('disconnect', () =>{
16 | console.log(socket.id + ' disconnected')
17 | })
18 | }
--------------------------------------------------------------------------------
/server/controllers/authCtrl.ts:
--------------------------------------------------------------------------------
1 | import { Request, Response } from 'express'
2 | import Users from '../models/userModel'
3 | import bcrypt from 'bcrypt'
4 | import jwt from 'jsonwebtoken'
5 | import { generateActiveToken, generateAccessToken, generateRefreshToken } from '../config/generateToken'
6 | import sendMail from '../config/sendMail'
7 | import { validateEmail, validPhone } from '../middleware/vaild'
8 | import { sendSms, smsOTP, smsVerify } from '../config/sendSMS'
9 | import { IDecodedToken, IUser, IGgPayload, IUserParams, IReqAuth } from '../config/interface'
10 |
11 | import { OAuth2Client } from 'google-auth-library'
12 | import fetch from 'node-fetch'
13 |
14 |
15 | const client = new OAuth2Client(`${process.env.MAIL_CLIENT_ID}`)
16 | const CLIENT_URL = `${process.env.BASE_URL}`
17 |
18 | const authCtrl = {
19 | register: async(req: Request, res: Response) => {
20 | try {
21 | const { name, account, password } = req.body
22 |
23 | const user = await Users.findOne({account})
24 | if(user) return res.status(400).json({msg: 'Email or Phone number already exists.'})
25 |
26 | const passwordHash = await bcrypt.hash(password, 12)
27 |
28 | const newUser = { name, account, password: passwordHash }
29 |
30 | const active_token = generateActiveToken({newUser})
31 |
32 | const url = `${CLIENT_URL}/active/${active_token}`
33 |
34 | if(validateEmail(account)){
35 | sendMail(account, url, "Verify your email address")
36 | return res.json({ msg: "Success! Please check your email." })
37 |
38 | }else if(validPhone(account)){
39 | sendSms(account, url, "Verify your phone number")
40 | return res.json({ msg: "Success! Please check phone." })
41 | }
42 |
43 | } catch (err: any) {
44 | return res.status(500).json({msg: err.message})
45 | }
46 | },
47 | activeAccount: async(req: Request, res: Response) => {
48 | try {
49 | const { active_token } = req.body
50 |
51 | const decoded = jwt.verify(active_token, `${process.env.ACTIVE_TOKEN_SECRET}`)
52 |
53 | const { newUser } = decoded
54 |
55 | if(!newUser) return res.status(400).json({msg: "Invalid authentication."})
56 |
57 | const user = await Users.findOne({account: newUser.account})
58 | if(user) return res.status(400).json({msg: "Account already exists."})
59 |
60 | const new_user = new Users(newUser)
61 |
62 | await new_user.save()
63 |
64 | res.json({msg: "Account has been activated!"})
65 |
66 | } catch (err: any) {
67 | return res.status(500).json({msg: err.message})
68 | }
69 | },
70 | login: async(req: Request, res: Response) => {
71 | try {
72 | const { account, password } = req.body
73 |
74 | const user = await Users.findOne({account})
75 | if(!user) return res.status(400).json({msg: 'This account does not exits.'})
76 |
77 | // if user exists
78 | loginUser(user, password, res)
79 |
80 | } catch (err: any) {
81 | return res.status(500).json({msg: err.message})
82 | }
83 | },
84 | logout: async(req: IReqAuth, res: Response) => {
85 | if(!req.user)
86 | return res.status(400).json({msg: "Invalid Authentication."})
87 |
88 | try {
89 | res.clearCookie('refreshtoken', { path: `/api/refresh_token` })
90 |
91 | await Users.findOneAndUpdate({_id: req.user._id}, {
92 | rf_token: ''
93 | })
94 |
95 | return res.json({msg: "Logged out!"})
96 |
97 | } catch (err: any) {
98 | return res.status(500).json({msg: err.message})
99 | }
100 | },
101 | refreshToken: async(req: Request, res: Response) => {
102 | try {
103 | const rf_token = req.cookies.refreshtoken
104 | if(!rf_token) return res.status(400).json({msg: "Please login now!"})
105 |
106 | const decoded = jwt.verify(rf_token, `${process.env.REFRESH_TOKEN_SECRET}`)
107 | if(!decoded.id) return res.status(400).json({msg: "Please login now!"})
108 |
109 | const user = await Users.findById(decoded.id).select("-password +rf_token")
110 | if(!user) return res.status(400).json({msg: "This account does not exist."})
111 |
112 | if(rf_token !== user.rf_token)
113 | return res.status(400).json({msg: "Please login now!"})
114 |
115 | const access_token = generateAccessToken({id: user._id})
116 | const refresh_token = generateRefreshToken({id: user._id}, res)
117 |
118 | await Users.findOneAndUpdate({_id: user._id}, {
119 | rf_token: refresh_token
120 | })
121 |
122 | res.json({ access_token, user })
123 |
124 | } catch (err: any) {
125 | return res.status(500).json({msg: err.message})
126 | }
127 | },
128 | googleLogin: async(req: Request, res: Response) => {
129 | try {
130 | const { id_token } = req.body
131 | const verify = await client.verifyIdToken({
132 | idToken: id_token, audience: `${process.env.MAIL_CLIENT_ID}`
133 | })
134 |
135 | const {
136 | email, email_verified, name, picture
137 | } = verify.getPayload()
138 |
139 | if(!email_verified)
140 | return res.status(500).json({msg: "Email verification failed."})
141 |
142 | const password = email + 'your google secrect password'
143 | const passwordHash = await bcrypt.hash(password, 12)
144 |
145 | const user = await Users.findOne({account: email})
146 |
147 | if(user){
148 | loginUser(user, password, res)
149 | }else{
150 | const user = {
151 | name,
152 | account: email,
153 | password: passwordHash,
154 | avatar: picture,
155 | type: 'google'
156 | }
157 | registerUser(user, res)
158 | }
159 |
160 | } catch (err: any) {
161 | return res.status(500).json({msg: err.message})
162 | }
163 | },
164 | facebookLogin: async(req: Request, res: Response) => {
165 | try {
166 | const { accessToken, userID } = req.body
167 |
168 | const URL = `
169 | https://graph.facebook.com/v3.0/${userID}/?fields=id,name,email,picture&access_token=${accessToken}
170 | `
171 |
172 | const data = await fetch(URL)
173 | .then(res => res.json())
174 | .then(res => { return res })
175 |
176 | const { email, name, picture } = data
177 |
178 | const password = email + 'your facebook secrect password'
179 | const passwordHash = await bcrypt.hash(password, 12)
180 |
181 | const user = await Users.findOne({account: email})
182 |
183 | if(user){
184 | loginUser(user, password, res)
185 | }else{
186 | const user = {
187 | name,
188 | account: email,
189 | password: passwordHash,
190 | avatar: picture.data.url,
191 | type: 'facebook'
192 | }
193 | registerUser(user, res)
194 | }
195 |
196 | } catch (err: any) {
197 | return res.status(500).json({msg: err.message})
198 | }
199 | },
200 | loginSMS: async(req: Request, res: Response) => {
201 | try {
202 | const { phone } = req.body
203 | const data = await smsOTP(phone, 'sms')
204 | res.json(data)
205 | } catch (err: any) {
206 | return res.status(500).json({msg: err.message})
207 | }
208 | },
209 | smsVerify: async(req: Request, res: Response) => {
210 | try {
211 | const { phone, code } = req.body
212 |
213 | const data = await smsVerify(phone, code)
214 | if(!data?.valid) return res.status(400).json({msg: "Invalid Authentication."})
215 |
216 | const password = phone + 'your phone secrect password'
217 | const passwordHash = await bcrypt.hash(password, 12)
218 |
219 | const user = await Users.findOne({account: phone})
220 |
221 | if(user){
222 | loginUser(user, password, res)
223 | }else{
224 | const user = {
225 | name: phone,
226 | account: phone,
227 | password: passwordHash,
228 | type: 'sms'
229 | }
230 | registerUser(user, res)
231 | }
232 |
233 | } catch (err: any) {
234 | return res.status(500).json({msg: err.message})
235 | }
236 | },
237 | forgotPassword: async(req: Request, res: Response) => {
238 | try {
239 | const { account } = req.body
240 |
241 | const user = await Users.findOne({account})
242 | if(!user)
243 | return res.status(400).json({msg: 'This account does not exist.'})
244 |
245 | if(user.type !== 'register')
246 | return res.status(400).json({
247 | msg: `Quick login account with ${user.type} can't use this function.`
248 | })
249 |
250 | const access_token = generateAccessToken({id: user._id})
251 |
252 | const url = `${CLIENT_URL}/reset_password/${access_token}`
253 |
254 | if(validPhone(account)){
255 | sendSms(account, url, "Forgot password?")
256 | return res.json({msg: "Success! Please check your phone."})
257 |
258 | }else if(validateEmail(account)){
259 | sendMail(account, url, "Forgot password?")
260 | return res.json({msg: "Success! Please check your email."})
261 | }
262 |
263 | } catch (err: any) {
264 | return res.status(500).json({msg: err.message})
265 | }
266 | },
267 | }
268 |
269 |
270 | const loginUser = async (user: IUser, password: string, res: Response) => {
271 | const isMatch = await bcrypt.compare(password, user.password)
272 |
273 | if(!isMatch) {
274 | let msgError = user.type === 'register'
275 | ? 'Password is incorrect.'
276 | : `Password is incorrect. This account login with ${user.type}`
277 |
278 | return res.status(400).json({ msg: msgError })
279 | }
280 |
281 | const access_token = generateAccessToken({id: user._id})
282 | const refresh_token = generateRefreshToken({id: user._id}, res)
283 |
284 | await Users.findOneAndUpdate({_id: user._id}, {
285 | rf_token:refresh_token
286 | })
287 |
288 | res.json({
289 | msg: 'Login Success!',
290 | access_token,
291 | user: { ...user._doc, password: '' }
292 | })
293 |
294 | }
295 |
296 | const registerUser = async (user: IUserParams, res: Response) => {
297 | const newUser = new Users(user)
298 |
299 | const access_token = generateAccessToken({id: newUser._id})
300 | const refresh_token = generateRefreshToken({id: newUser._id}, res)
301 |
302 | newUser.rf_token = refresh_token
303 | await newUser.save()
304 |
305 | res.json({
306 | msg: 'Login Success!',
307 | access_token,
308 | user: { ...newUser._doc, password: '' }
309 | })
310 |
311 | }
312 |
313 | export default authCtrl;
--------------------------------------------------------------------------------
/server/controllers/blogCtrl.ts:
--------------------------------------------------------------------------------
1 | import { Request, Response } from 'express'
2 | import Blogs from '../models/blogModel'
3 | import Comments from '../models/commentModel'
4 | import { IReqAuth } from '../config/interface'
5 | import mongoose from 'mongoose'
6 |
7 |
8 | const Pagination = (req: IReqAuth) => {
9 | let page = Number(req.query.page) * 1 || 1;
10 | let limit = Number(req.query.limit) * 1 || 4;
11 | let skip = (page - 1) * limit;
12 |
13 | return { page, limit, skip };
14 | }
15 |
16 | const blogCtrl = {
17 | createBlog: async (req: IReqAuth, res: Response) => {
18 | if(!req.user) return res.status(400).json({msg: "Invalid Authentication."})
19 |
20 | try {
21 | const { title, content, description, thumbnail, category } = req.body
22 |
23 | const newBlog = new Blogs({
24 | user: req.user._id,
25 | title: title.toLowerCase(),
26 | content,
27 | description,
28 | thumbnail,
29 | category
30 | })
31 |
32 | await newBlog.save()
33 | res.json({
34 | ...newBlog._doc,
35 | user: req.user
36 | })
37 |
38 | } catch (err: any) {
39 | return res.status(500).json({msg: err.message})
40 | }
41 | },
42 | getHomeBlogs: async (req: Request, res: Response) => {
43 | try {
44 | const blogs = await Blogs.aggregate([
45 | // User
46 | {
47 | $lookup:{
48 | from: "users",
49 | let: { user_id: "$user" },
50 | pipeline: [
51 | { $match: { $expr: { $eq: ["$_id", "$$user_id"] } } },
52 | { $project: { password: 0 }}
53 | ],
54 | as: "user"
55 | }
56 | },
57 | // array -> object
58 | { $unwind: "$user" },
59 | // Category
60 | {
61 | $lookup: {
62 | "from": "categories",
63 | "localField": "category",
64 | "foreignField": "_id",
65 | "as": "category"
66 | }
67 | },
68 | // array -> object
69 | { $unwind: "$category" },
70 | // Sorting
71 | { $sort: { "createdAt": -1 } },
72 | // Group by category
73 | {
74 | $group: {
75 | _id: "$category._id",
76 | name: { $first: "$category.name" },
77 | blogs: { $push: "$$ROOT" },
78 | count: { $sum: 1 }
79 | }
80 | },
81 | // Pagination for blogs
82 | {
83 | $project: {
84 | blogs: {
85 | $slice: ['$blogs', 0, 4]
86 | },
87 | count: 1,
88 | name: 1
89 | }
90 | }
91 | ])
92 |
93 | res.json(blogs)
94 |
95 | } catch (err: any) {
96 | return res.status(500).json({msg: err.message})
97 | }
98 | },
99 | getBlogsByCategory: async (req: Request, res: Response) => {
100 | const { limit, skip } = Pagination(req)
101 |
102 | try {
103 | const Data = await Blogs.aggregate([
104 | {
105 | $facet: {
106 | totalData: [
107 | {
108 | $match:{
109 | category: mongoose.Types.ObjectId(req.params.id)
110 | }
111 | },
112 | // User
113 | {
114 | $lookup:{
115 | from: "users",
116 | let: { user_id: "$user" },
117 | pipeline: [
118 | { $match: { $expr: { $eq: ["$_id", "$$user_id"] } } },
119 | { $project: { password: 0 }}
120 | ],
121 | as: "user"
122 | }
123 | },
124 | // array -> object
125 | { $unwind: "$user" },
126 | // Sorting
127 | { $sort: { createdAt: -1 } },
128 | { $skip: skip },
129 | { $limit: limit }
130 | ],
131 | totalCount: [
132 | {
133 | $match: {
134 | category: mongoose.Types.ObjectId(req.params.id)
135 | }
136 | },
137 | { $count: 'count' }
138 | ]
139 | }
140 | },
141 | {
142 | $project: {
143 | count: { $arrayElemAt: ["$totalCount.count", 0] },
144 | totalData: 1
145 | }
146 | }
147 | ])
148 |
149 | const blogs = Data[0].totalData;
150 | const count = Data[0].count;
151 |
152 | // Pagination
153 | let total = 0;
154 |
155 | if(count % limit === 0){
156 | total = count / limit;
157 | }else {
158 | total = Math.floor(count / limit) + 1;
159 | }
160 |
161 | res.json({ blogs, total })
162 | } catch (err: any) {
163 | return res.status(500).json({msg: err.message})
164 | }
165 | },
166 | getBlogsByUser: async (req: Request, res: Response) => {
167 | const { limit, skip } = Pagination(req)
168 |
169 | try {
170 | const Data = await Blogs.aggregate([
171 | {
172 | $facet: {
173 | totalData: [
174 | {
175 | $match:{
176 | user: mongoose.Types.ObjectId(req.params.id)
177 | }
178 | },
179 | // User
180 | {
181 | $lookup:{
182 | from: "users",
183 | let: { user_id: "$user" },
184 | pipeline: [
185 | { $match: { $expr: { $eq: ["$_id", "$$user_id"] } } },
186 | { $project: { password: 0 }}
187 | ],
188 | as: "user"
189 | }
190 | },
191 | // array -> object
192 | { $unwind: "$user" },
193 | // Sorting
194 | { $sort: { createdAt: -1 } },
195 | { $skip: skip },
196 | { $limit: limit }
197 | ],
198 | totalCount: [
199 | {
200 | $match: {
201 | user: mongoose.Types.ObjectId(req.params.id)
202 | }
203 | },
204 | { $count: 'count' }
205 | ]
206 | }
207 | },
208 | {
209 | $project: {
210 | count: { $arrayElemAt: ["$totalCount.count", 0] },
211 | totalData: 1
212 | }
213 | }
214 | ])
215 |
216 | const blogs = Data[0].totalData;
217 | const count = Data[0].count;
218 |
219 | // Pagination
220 | let total = 0;
221 |
222 | if(count % limit === 0){
223 | total = count / limit;
224 | }else {
225 | total = Math.floor(count / limit) + 1;
226 | }
227 |
228 | res.json({ blogs, total })
229 | } catch (err: any) {
230 | return res.status(500).json({msg: err.message})
231 | }
232 | },
233 | getBlog: async (req: Request, res: Response) => {
234 | try {
235 | const blog = await Blogs.findOne({_id: req.params.id})
236 | .populate("user", "-password")
237 |
238 | if(!blog) return res.status(400).json({ msg: "Blog does not exist." })
239 |
240 | return res.json(blog)
241 | } catch (err: any) {
242 | return res.status(500).json({ msg: err.message })
243 | }
244 | },
245 | updateBlog: async (req: IReqAuth, res: Response) => {
246 | if(!req.user)
247 | return res.status(400).json({msg: "Invalid Authentication."})
248 |
249 | try {
250 | const blog = await Blogs.findOneAndUpdate({
251 | _id: req.params.id, user: req.user._id
252 | }, req.body)
253 |
254 | if(!blog) return res.status(400).json({msg: "Invalid Authentication."})
255 |
256 | res.json({ msg: 'Update Success!', blog })
257 |
258 | } catch (err: any) {
259 | return res.status(500).json({msg: err.message})
260 | }
261 | },
262 | deleteBlog: async (req: IReqAuth, res: Response) => {
263 | if(!req.user)
264 | return res.status(400).json({msg: "Invalid Authentication."})
265 |
266 | try {
267 | // Delete Blog
268 | const blog = await Blogs.findOneAndDelete({
269 | _id: req.params.id, user: req.user._id
270 | })
271 |
272 | if(!blog)
273 | return res.status(400).json({msg: "Invalid Authentication."})
274 |
275 | // Delete Comments
276 | await Comments.deleteMany({ blog_id: blog._id })
277 |
278 | res.json({ msg: 'Delete Success!' })
279 |
280 | } catch (err: any) {
281 | return res.status(500).json({msg: err.message})
282 | }
283 | },
284 | searchBlogs: async (req: Request, res: Response) => {
285 | try {
286 | const blogs = await Blogs.aggregate([
287 | {
288 | $search: {
289 | index: "searchTitle",
290 | autocomplete: {
291 | "query": `${req.query.title}`,
292 | "path": "title"
293 | }
294 | }
295 | },
296 | { $sort: { createdAt: -1 } },
297 | { $limit: 5},
298 | {
299 | $project: {
300 | title: 1,
301 | description: 1,
302 | thumbnail: 1,
303 | createdAt: 1
304 | }
305 | }
306 | ])
307 |
308 | if(!blogs.length)
309 | return res.status(400).json({msg: 'No Blogs.'})
310 |
311 | res.json(blogs)
312 |
313 | } catch (err: any) {
314 | return res.status(500).json({msg: err.message})
315 | }
316 | },
317 | }
318 |
319 |
320 | export default blogCtrl;
--------------------------------------------------------------------------------
/server/controllers/categoryCtrl.ts:
--------------------------------------------------------------------------------
1 | import { Request, Response } from 'express'
2 | import Categories from '../models/categoryModel'
3 | import Blogs from '../models/blogModel'
4 | import { IReqAuth } from '../config/interface'
5 |
6 | const categoryCtrl = {
7 | createCategory: async (req: IReqAuth, res: Response) => {
8 | if(!req.user) return res.status(400).json({msg: "Invalid Authentication."})
9 |
10 | if(req.user.role !== 'admin')
11 | return res.status(400).json({msg: "Invalid Authentication."})
12 |
13 | try {
14 | const name = req.body.name.toLowerCase()
15 |
16 | const newCategory = new Categories({ name })
17 | await newCategory.save()
18 |
19 | res.json({ newCategory })
20 | } catch (err: any) {
21 | let errMsg;
22 |
23 | if(err.code === 11000){
24 | errMsg = Object.values(err.keyValue)[0] + " already exists."
25 | }else{
26 | let name = Object.keys(err.errors)[0]
27 | errMsg = err.errors[`${name}`].message
28 | }
29 |
30 | return res.status(500).json({ msg: errMsg })
31 | }
32 | },
33 | getCategories: async (req: Request, res: Response) => {
34 | try {
35 | const categories = await Categories.find().sort("-createdAt")
36 | res.json({ categories })
37 | } catch (err: any) {
38 | return res.status(500).json({ msg: err.message })
39 | }
40 | },
41 | updateCategory: async (req: IReqAuth, res: Response) => {
42 | if(!req.user) return res.status(400).json({msg: "Invalid Authentication."})
43 |
44 | if(req.user.role !== 'admin')
45 | return res.status(400).json({msg: "Invalid Authentication."})
46 |
47 | try {
48 | const category = await Categories.findOneAndUpdate({
49 | _id: req.params.id
50 | }, { name: (req.body.name).toLowerCase() })
51 |
52 | res.json({ msg: "Update Success!" })
53 | } catch (err: any) {
54 | return res.status(500).json({ msg: err.message })
55 | }
56 | },
57 | deleteCategory: async (req: IReqAuth, res: Response) => {
58 | if(!req.user) return res.status(400).json({msg: "Invalid Authentication."})
59 |
60 | if(req.user.role !== 'admin')
61 | return res.status(400).json({msg: "Invalid Authentication."})
62 |
63 | try {
64 | const blog = await Blogs.findOne({category: req.params.id})
65 | if(blog)
66 | return res.status(400).json({
67 | msg: "Can not delete! In this category also exist blogs."
68 | })
69 |
70 | const category = await Categories.findByIdAndDelete(req.params.id)
71 | if(!category)
72 | return res.status(400).json({msg: "Category does not exists."})
73 |
74 | res.json({ msg: "Delete Success!" })
75 | } catch (err: any) {
76 | return res.status(500).json({ msg: err.message })
77 | }
78 | }
79 | }
80 |
81 |
82 | export default categoryCtrl;
--------------------------------------------------------------------------------
/server/controllers/commentCtrl.ts:
--------------------------------------------------------------------------------
1 | import { Request, Response } from 'express'
2 | import Comments from '../models/commentModel'
3 | import { IReqAuth } from '../config/interface'
4 | import mongoose from 'mongoose'
5 | import { io } from '../index'
6 |
7 |
8 | const Pagination = (req: IReqAuth) => {
9 | let page = Number(req.query.page) * 1 || 1;
10 | let limit = Number(req.query.limit) * 1 || 4;
11 | let skip = (page - 1) * limit;
12 |
13 | return { page, limit, skip };
14 | }
15 |
16 | const commentCtrl = {
17 | createComment: async (req: IReqAuth, res: Response) => {
18 | if(!req.user)
19 | return res.status(400).json({msg: "invalid Authentication."})
20 |
21 | try {
22 | const {
23 | content,
24 | blog_id,
25 | blog_user_id
26 | } = req.body
27 |
28 | const newComment = new Comments({
29 | user: req.user._id,
30 | content,
31 | blog_id,
32 | blog_user_id
33 | })
34 |
35 | const data = {
36 | ...newComment._doc,
37 | user: req.user,
38 | createdAt: new Date().toISOString()
39 | }
40 |
41 | io.to(`${blog_id}`).emit('createComment', data)
42 |
43 | await newComment.save()
44 |
45 | return res.json(newComment)
46 |
47 | } catch (err: any) {
48 | return res.status(500).json({msg: err.message})
49 | }
50 | },
51 | getComments: async (req: Request, res: Response) => {
52 | const { limit, skip } = Pagination(req)
53 |
54 | try {
55 | const data = await Comments.aggregate([
56 | {
57 | $facet: {
58 | totalData:[
59 | { $match: {
60 | blog_id: mongoose.Types.ObjectId(req.params.id),
61 | comment_root: { $exists: false },
62 | reply_user: { $exists: false }
63 | }},
64 | {
65 | $lookup: {
66 | "from": "users",
67 | "let": { user_id: "$user" },
68 | "pipeline": [
69 | { $match: { $expr: { $eq: ["$_id", "$$user_id"] } } },
70 | { $project: { name: 1, avatar: 1 } }
71 | ],
72 | "as": "user"
73 | }
74 | },
75 | { $unwind: "$user" },
76 | {
77 | $lookup: {
78 | "from": "comments",
79 | "let": { cm_id: "$replyCM" },
80 | "pipeline": [
81 | { $match: { $expr: { $in: ["$_id", "$$cm_id"] } } },
82 | {
83 | $lookup: {
84 | "from": "users",
85 | "let": { user_id: "$user" },
86 | "pipeline": [
87 | { $match: { $expr: { $eq: ["$_id", "$$user_id"] } } },
88 | { $project: { name: 1, avatar: 1 } }
89 | ],
90 | "as": "user"
91 | }
92 | },
93 | { $unwind: "$user" },
94 | {
95 | $lookup: {
96 | "from": "users",
97 | "let": { user_id: "$reply_user" },
98 | "pipeline": [
99 | { $match: { $expr: { $eq: ["$_id", "$$user_id"] } } },
100 | { $project: { name: 1, avatar: 1 } }
101 | ],
102 | "as": "reply_user"
103 | }
104 | },
105 | { $unwind: "$reply_user" }
106 | ],
107 | "as": "replyCM"
108 | }
109 | },
110 | { $sort: { createdAt: -1 } },
111 | { $skip: skip },
112 | { $limit: limit }
113 | ],
114 | totalCount: [
115 | { $match: {
116 | blog_id: mongoose.Types.ObjectId(req.params.id),
117 | comment_root: { $exists: false },
118 | reply_user: { $exists: false }
119 | }},
120 | { $count: 'count' }
121 | ]
122 | }
123 | },
124 | {
125 | $project: {
126 | count: { $arrayElemAt: ["$totalCount.count", 0] },
127 | totalData: 1
128 | }
129 | }
130 | ])
131 |
132 | const comments = data[0].totalData;
133 | const count = data[0].count;
134 |
135 | let total = 0;
136 |
137 | if(count % limit === 0){
138 | total = count / limit;
139 | }else{
140 | total = Math.floor(count / limit) + 1;
141 | }
142 |
143 | return res.json({ comments, total })
144 |
145 | } catch (err: any) {
146 | return res.status(500).json({msg: err.message})
147 | }
148 | },
149 | replyComment: async (req: IReqAuth, res: Response) => {
150 | if(!req.user)
151 | return res.status(400).json({msg: "invalid Authentication."})
152 |
153 | try {
154 | const {
155 | content,
156 | blog_id,
157 | blog_user_id,
158 | comment_root,
159 | reply_user
160 | } = req.body
161 |
162 |
163 | const newComment = new Comments({
164 | user: req.user._id,
165 | content,
166 | blog_id,
167 | blog_user_id,
168 | comment_root,
169 | reply_user: reply_user._id
170 | })
171 |
172 | await Comments.findOneAndUpdate({_id: comment_root}, {
173 | $push: { replyCM: newComment._id }
174 | })
175 |
176 | const data = {
177 | ...newComment._doc,
178 | user: req.user,
179 | reply_user: reply_user,
180 | createdAt: new Date().toISOString()
181 | }
182 |
183 | io.to(`${blog_id}`).emit('replyComment', data)
184 |
185 | await newComment.save()
186 |
187 | return res.json(newComment)
188 |
189 | } catch (err: any) {
190 | return res.status(500).json({msg: err.message})
191 | }
192 | },
193 | updateComment: async (req: IReqAuth, res: Response) => {
194 | if(!req.user)
195 | return res.status(400).json({msg: "invalid Authentication."})
196 |
197 | try {
198 | const { data } = req.body
199 |
200 | const comment = await Comments.findOneAndUpdate({
201 | _id: req.params.id, user: req.user.id
202 | }, { content: data.content })
203 |
204 | if(!comment)
205 | return res.status(400).json({msg: "Comment does not exits."})
206 |
207 | io.to(`${data.blog_id}`).emit('updateComment', data)
208 |
209 | return res.json({msg: "Update Success!"})
210 |
211 | } catch (err: any) {
212 | return res.status(500).json({msg: err.message})
213 | }
214 | },
215 | deleteComment: async (req: IReqAuth, res: Response) => {
216 | if(!req.user)
217 | return res.status(400).json({msg: "invalid Authentication."})
218 |
219 | try {
220 |
221 | const comment = await Comments.findOneAndDelete({
222 | _id: req.params.id,
223 | $or: [
224 | { user: req.user._id },
225 | { blog_user_id: req.user._id}
226 | ]
227 | })
228 |
229 | if(!comment)
230 | return res.status(400).json({msg: "Comment does not exits."})
231 |
232 | if(comment.comment_root){
233 | // update replyCM
234 | await Comments.findOneAndUpdate({_id: comment.comment_root}, {
235 | $pull: { replyCM: comment._id }
236 | })
237 | }else{
238 | // delete all comments in replyCM
239 | await Comments.deleteMany({_id: {$in: comment.replyCM}})
240 | }
241 |
242 | io.to(`${comment.blog_id}`).emit('deleteComment', comment)
243 |
244 | return res.json({msg: "Delete Success!"})
245 |
246 | } catch (err: any) {
247 | return res.status(500).json({msg: err.message})
248 | }
249 | }
250 | }
251 |
252 | export default commentCtrl;
--------------------------------------------------------------------------------
/server/controllers/userCtrl.ts:
--------------------------------------------------------------------------------
1 | import { Request, Response } from 'express'
2 | import { IReqAuth } from '../config/interface'
3 | import Users from '../models/userModel'
4 | import bcrypt from 'bcrypt'
5 |
6 |
7 | const userCtrl = {
8 | updateUser: async (req: IReqAuth, res: Response) => {
9 | if(!req.user) return res.status(400).json({msg: "Invalid Authentication."})
10 |
11 | try {
12 | const { avatar, name } = req.body
13 |
14 | await Users.findOneAndUpdate({_id: req.user._id}, {
15 | avatar, name
16 | })
17 |
18 | res.json({ msg: "Update Success!" })
19 | } catch (err: any) {
20 | return res.status(500).json({msg: err.message})
21 | }
22 | },
23 | resetPassword: async (req: IReqAuth, res: Response) => {
24 | if(!req.user) return res.status(400).json({msg: "Invalid Authentication."})
25 |
26 | if(req.user.type !== 'register')
27 | return res.status(400).json({
28 | msg: `Quick login account with ${req.user.type} can't use this function.`
29 | })
30 |
31 | try {
32 | const { password } = req.body
33 | const passwordHash = await bcrypt.hash(password, 12)
34 |
35 | await Users.findOneAndUpdate({_id: req.user._id}, {
36 | password: passwordHash
37 | })
38 |
39 | res.json({ msg: "Reset Password Success!" })
40 | } catch (err: any) {
41 | return res.status(500).json({msg: err.message})
42 | }
43 | },
44 | getUser: async (req: Request, res: Response) => {
45 | try {
46 | const user = await Users.findById(req.params.id).select('-password')
47 | res.json(user)
48 | } catch (err: any) {
49 | return res.status(500).json({msg: err.message})
50 | }
51 | }
52 | }
53 |
54 |
55 | export default userCtrl;
--------------------------------------------------------------------------------
/server/index.ts:
--------------------------------------------------------------------------------
1 | import dotenv from 'dotenv'
2 | dotenv.config()
3 |
4 | import express from 'express'
5 | import cors from 'cors'
6 | import cookieParser from 'cookie-parser'
7 | import morgan from 'morgan'
8 | import routes from './routes/index'
9 | import { createServer } from 'http'
10 | import { Server, Socket } from 'socket.io'
11 | import path from 'path'
12 |
13 |
14 | // Middleware
15 | const app = express()
16 | app.use(express.json())
17 | app.use(express.urlencoded({ extended: false }))
18 | app.use(cors())
19 | app.use(morgan('dev'))
20 | app.use(cookieParser())
21 |
22 | // Socket.io
23 | const http = createServer(app)
24 | export const io = new Server(http)
25 | import { SocketServer } from './config/socket'
26 |
27 |
28 | io.on("connection", (socket: Socket) => {
29 | SocketServer(socket)
30 | })
31 |
32 |
33 | // Routes
34 | app.use('/api', routes)
35 |
36 |
37 |
38 | // Database
39 | import './config/database'
40 |
41 |
42 | // Production Deploy
43 | if(process.env.NODE_ENV === 'production'){
44 | app.use(express.static('client/build'))
45 | app.get('*', (req, res) => {
46 | res.sendFile(path.join(__dirname, '../client', 'build', 'index.html'))
47 | })
48 | }
49 |
50 |
51 | // server listenning
52 | const PORT = process.env.PORT || 5000
53 | http.listen(PORT, () => {
54 | console.log('Server is running on port', PORT)
55 | })
--------------------------------------------------------------------------------
/server/middleware/auth.ts:
--------------------------------------------------------------------------------
1 | import { Response, NextFunction} from 'express'
2 | import Users from '../models/userModel'
3 | import jwt from 'jsonwebtoken'
4 | import { IDecodedToken, IReqAuth } from '../config/interface'
5 |
6 | const auth = async (req: IReqAuth, res: Response, next: NextFunction) => {
7 | try {
8 | const token = req.header("Authorization")
9 | if(!token) return res.status(400).json({msg: "Invalid Authentication."})
10 |
11 | const decoded = jwt.verify(token, `${process.env.ACCESS_TOKEN_SECRET}`)
12 | if(!decoded) return res.status(400).json({msg: "Invalid Authentication."})
13 |
14 | const user = await Users.findOne({_id: decoded.id}).select("-password")
15 | if(!user) return res.status(400).json({msg: "User does not exist."})
16 |
17 | req.user = user;
18 |
19 | next()
20 | } catch (err: any) {
21 | return res.status(500).json({msg: err.message})
22 | }
23 | }
24 |
25 | export default auth;
--------------------------------------------------------------------------------
/server/middleware/vaild.ts:
--------------------------------------------------------------------------------
1 | import { Request, Response, NextFunction } from 'express'
2 |
3 | export const validRegister = async (req: Request, res: Response, next: NextFunction) => {
4 | const { name, account, password } = req.body
5 |
6 | const errors = [];
7 |
8 | if(!name){
9 | errors.push("Please add your name.")
10 | }else if(name.length > 20){
11 | errors.push("Your name is up to 20 chars long.")
12 | }
13 |
14 | if(!account){
15 | errors.push("Please add your email or phone number.")
16 | }else if(!validPhone(account) && !validateEmail(account)){
17 | errors.push("Email or phone number format is incorrect.")
18 | }
19 |
20 | if(password.length < 6){
21 | errors.push("Password must be at least 6 chars.")
22 | }
23 |
24 | if(errors.length > 0) return res.status(400).json({msg: errors})
25 |
26 | next();
27 | }
28 |
29 |
30 |
31 | export function validPhone(phone: string) {
32 | const re = /^[+]/g
33 | return re.test(phone)
34 | }
35 |
36 | export function validateEmail(email: string) {
37 | const re = /^(([^<>()[\]\\.,;:\s@"]+(\.[^<>()[\]\\.,;:\s@"]+)*)|(".+"))@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\])|(([a-zA-Z\-0-9]+\.)+[a-zA-Z]{2,}))$/;
38 | return re.test(String(email).toLowerCase());
39 | }
--------------------------------------------------------------------------------
/server/models/blogModel.ts:
--------------------------------------------------------------------------------
1 | import mongoose from 'mongoose'
2 | import { IBlog } from '../config/interface'
3 |
4 | const blogSchema = new mongoose.Schema({
5 | user: { type: mongoose.Types.ObjectId, ref: 'user' },
6 | title: {
7 | type: String,
8 | require: true,
9 | trim: true,
10 | minLength: 10,
11 | maxLength: 50
12 | },
13 | content: {
14 | type: String,
15 | require: true,
16 | minLength: 2000
17 | },
18 | description: {
19 | type: String,
20 | require: true,
21 | trim: true,
22 | minLength: 50,
23 | maxLength: 200
24 | },
25 | thumbnail:{
26 | type: String,
27 | require: true
28 | },
29 | category: { type: mongoose.Types.ObjectId, ref: 'category' }
30 | }, {
31 | timestamps: true
32 | })
33 |
34 |
35 | export default mongoose.model('blog', blogSchema)
--------------------------------------------------------------------------------
/server/models/categoryModel.ts:
--------------------------------------------------------------------------------
1 | import mongoose from 'mongoose'
2 |
3 | const categorySchema = new mongoose.Schema({
4 | name: {
5 | type: String,
6 | required: [true, "Please add your category"],
7 | trim: true,
8 | unique: true,
9 | maxLength: [50, "Name is up to 50 chars long."]
10 | }
11 | }, {
12 | timestamps: true
13 | })
14 |
15 | export default mongoose.model('category', categorySchema)
--------------------------------------------------------------------------------
/server/models/commentModel.ts:
--------------------------------------------------------------------------------
1 | import mongoose from 'mongoose'
2 | import { IComment } from '../config/interface'
3 |
4 |
5 | const commentSchema = new mongoose.Schema({
6 | user: { type: mongoose.Types.ObjectId, ref: 'user' },
7 | blog_id: mongoose.Types.ObjectId,
8 | blog_user_id: mongoose.Types.ObjectId,
9 | content: { type: String, required: true },
10 | replyCM: [{ type: mongoose.Types.ObjectId, ref: 'comment' }],
11 | reply_user: { type: mongoose.Types.ObjectId, ref: 'user' },
12 | comment_root: { type: mongoose.Types.ObjectId, ref: 'comment' }
13 | }, {
14 | timestamps: true
15 | })
16 |
17 |
18 | export default mongoose.model('comment', commentSchema)
--------------------------------------------------------------------------------
/server/models/userModel.ts:
--------------------------------------------------------------------------------
1 | import mongoose from 'mongoose'
2 | import { IUser } from '../config/interface'
3 |
4 | const userSchema = new mongoose.Schema({
5 | name: {
6 | type: String,
7 | required: [true, "Please add your name"],
8 | trim: true,
9 | maxLength: [20, "Your name is up to 20 chars long."]
10 | },
11 | account: {
12 | type: String,
13 | required: [true, "Please add your email or phone"],
14 | trim: true,
15 | unique: true
16 | },
17 | password: {
18 | type: String,
19 | required: [true, "Please add your password"]
20 | },
21 | avatar: {
22 | type: String,
23 | default: 'https://res.cloudinary.com/devatchannel/image/upload/v1602752402/avatar/avatar_cugq40.png'
24 | },
25 | role: {
26 | type: String,
27 | default: 'user' // admin
28 | },
29 | type: {
30 | type: String,
31 | default: 'register' // login
32 | },
33 | rf_token: { type: String, select: false }
34 | }, {
35 | timestamps: true
36 | })
37 |
38 | export default mongoose.model('user', userSchema)
--------------------------------------------------------------------------------
/server/routes/authRouter.ts:
--------------------------------------------------------------------------------
1 | import express from 'express'
2 | import authCtrl from '../controllers/authCtrl'
3 | import { validRegister } from '../middleware/vaild'
4 | import auth from '../middleware/auth'
5 |
6 | const router = express.Router()
7 |
8 | router.post('/register', validRegister, authCtrl.register)
9 |
10 | router.post('/active', authCtrl.activeAccount)
11 |
12 | router.post('/login', authCtrl.login)
13 |
14 | router.get('/logout', auth, authCtrl.logout)
15 |
16 | router.get('/refresh_token', authCtrl.refreshToken)
17 |
18 | router.post('/google_login', authCtrl.googleLogin)
19 |
20 | router.post('/facebook_login', authCtrl.facebookLogin)
21 |
22 | router.post('/login_sms', authCtrl.loginSMS)
23 |
24 | router.post('/sms_verify', authCtrl.smsVerify)
25 |
26 | router.post('/forgot_password', authCtrl.forgotPassword)
27 |
28 |
29 | export default router;
--------------------------------------------------------------------------------
/server/routes/blogRouter.ts:
--------------------------------------------------------------------------------
1 | import express from 'express'
2 | import blogCtrl from '../controllers/blogCtrl'
3 | import auth from '../middleware/auth'
4 |
5 | const router = express.Router()
6 |
7 |
8 | router.post('/blog', auth, blogCtrl.createBlog)
9 |
10 | router.get('/home/blogs', blogCtrl.getHomeBlogs)
11 |
12 | router.get('/blogs/category/:id', blogCtrl.getBlogsByCategory)
13 |
14 | router.get('/blogs/user/:id', blogCtrl.getBlogsByUser)
15 |
16 | router.route('/blog/:id')
17 | .get(blogCtrl.getBlog)
18 | .put(auth, blogCtrl.updateBlog)
19 | .delete(auth, blogCtrl.deleteBlog)
20 |
21 | router.get('/search/blogs', blogCtrl.searchBlogs)
22 |
23 |
24 | export default router;
--------------------------------------------------------------------------------
/server/routes/categoryRouter.ts:
--------------------------------------------------------------------------------
1 | import express from 'express'
2 | import categoryCtrl from '../controllers/categoryCtrl'
3 | import auth from '../middleware/auth'
4 |
5 | const router = express.Router()
6 |
7 | router.route('/category')
8 | .get(categoryCtrl.getCategories)
9 | .post(auth, categoryCtrl.createCategory)
10 |
11 | router.route('/category/:id')
12 | .patch(auth, categoryCtrl.updateCategory)
13 | .delete(auth, categoryCtrl.deleteCategory)
14 |
15 | export default router;
--------------------------------------------------------------------------------
/server/routes/commentRouter.ts:
--------------------------------------------------------------------------------
1 | import express from 'express'
2 | import commentCtrl from '../controllers/commentCtrl'
3 | import auth from '../middleware/auth'
4 |
5 | const router = express.Router()
6 |
7 | router.post('/comment', auth, commentCtrl.createComment)
8 |
9 | router.get('/comments/blog/:id', commentCtrl.getComments)
10 |
11 | router.post('/reply_comment', auth, commentCtrl.replyComment)
12 |
13 | router.patch('/comment/:id', auth, commentCtrl.updateComment)
14 |
15 | router.delete('/comment/:id', auth, commentCtrl.deleteComment)
16 |
17 |
18 | export default router;
--------------------------------------------------------------------------------
/server/routes/index.ts:
--------------------------------------------------------------------------------
1 | import authRouter from './authRouter'
2 | import userRouter from './userRouter'
3 | import categoryRouter from './categoryRouter'
4 | import blogRouter from './blogRouter'
5 | import commentRouter from './commentRouter'
6 |
7 | const routes = [
8 | authRouter,
9 | userRouter,
10 | categoryRouter,
11 | blogRouter,
12 | commentRouter
13 | ]
14 |
15 | export default routes;
--------------------------------------------------------------------------------
/server/routes/userRouter.ts:
--------------------------------------------------------------------------------
1 | import express from 'express'
2 | import auth from '../middleware/auth'
3 | import userCtrl from '../controllers/userCtrl'
4 |
5 | const router = express.Router()
6 |
7 | router.patch('/user', auth, userCtrl.updateUser)
8 |
9 | router.patch('/reset_password', auth, userCtrl.resetPassword)
10 |
11 | router.get('/user/:id', userCtrl.getUser)
12 |
13 |
14 | export default router;
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | /* Visit https://aka.ms/tsconfig.json to read more about this file */
4 |
5 | /* Basic Options */
6 | // "incremental": true, /* Enable incremental compilation */
7 | "target": "es6", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */
8 | "module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */
9 | // "lib": [], /* Specify library files to be included in the compilation. */
10 | // "allowJs": true, /* Allow javascript files to be compiled. */
11 | // "checkJs": true, /* Report errors in .js files. */
12 | // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', 'react', 'react-jsx' or 'react-jsxdev'. */
13 | // "declaration": true, /* Generates corresponding '.d.ts' file. */
14 | // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */
15 | // "sourceMap": true, /* Generates corresponding '.map' file. */
16 | // "outFile": "./", /* Concatenate and emit output to single file. */
17 | "outDir": "./dist", /* Redirect output structure to the directory. */
18 | "rootDir": "./server", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */
19 | // "composite": true, /* Enable project compilation */
20 | // "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */
21 | // "removeComments": true, /* Do not emit comments to output. */
22 | // "noEmit": true, /* Do not emit outputs. */
23 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */
24 | // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */
25 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */
26 |
27 | /* Strict Type-Checking Options */
28 | "strict": true, /* Enable all strict type-checking options. */
29 | // "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */
30 | // "strictNullChecks": true, /* Enable strict null checks. */
31 | // "strictFunctionTypes": true, /* Enable strict checking of function types. */
32 | // "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */
33 | // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */
34 | // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */
35 | // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */
36 |
37 | /* Additional Checks */
38 | // "noUnusedLocals": true, /* Report errors on unused locals. */
39 | // "noUnusedParameters": true, /* Report errors on unused parameters. */
40 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */
41 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */
42 | // "noUncheckedIndexedAccess": true, /* Include 'undefined' in index signature results */
43 | // "noPropertyAccessFromIndexSignature": true, /* Require undeclared properties from index signatures to use element accesses. */
44 |
45 | /* Module Resolution Options */
46 | "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */
47 | // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */
48 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */
49 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */
50 | // "typeRoots": [], /* List of folders to include type definitions from. */
51 | // "types": [], /* Type declaration files to be included in compilation. */
52 | // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */
53 | "esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */
54 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */
55 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
56 |
57 | /* Source Map Options */
58 | // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */
59 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
60 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */
61 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */
62 |
63 | /* Experimental Options */
64 | // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */
65 | // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */
66 |
67 | /* Advanced Options */
68 | "skipLibCheck": true, /* Skip type checking of declaration files. */
69 | "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */
70 | },
71 | "include": [
72 | "server/**/*"
73 | ]
74 | }
75 |
--------------------------------------------------------------------------------