first commit

This commit is contained in:
belasriiimad 2024-02-01 12:51:06 +00:00
commit c4fb5b57e6
38 changed files with 9848 additions and 0 deletions

20
.eslintrc.cjs Normal file
View File

@ -0,0 +1,20 @@
module.exports = {
root: true,
env: { browser: true, es2020: true },
extends: [
'eslint:recommended',
'plugin:react/recommended',
'plugin:react/jsx-runtime',
'plugin:react-hooks/recommended',
],
ignorePatterns: ['dist', '.eslintrc.cjs'],
parserOptions: { ecmaVersion: 'latest', sourceType: 'module' },
settings: { react: { version: '18.2' } },
plugins: ['react-refresh'],
rules: {
'react-refresh/only-export-components': [
'warn',
{ allowConstantExport: true },
],
},
}

24
.gitignore vendored Normal file
View File

@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

8
README.md Normal file
View File

@ -0,0 +1,8 @@
# React + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh

13
index.html Normal file
View File

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>React Medium Clone</title>
</head>
<body class="bg-light">
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>

7606
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

36
package.json Normal file
View File

@ -0,0 +1,36 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"lint": "eslint . --ext js,jsx --report-unused-disable-directives --max-warnings 0",
"preview": "vite preview"
},
"dependencies": {
"@reduxjs/toolkit": "^2.1.0",
"axios": "^1.6.6",
"bootstrap": "^5.3.2",
"bootstrap-icons": "^1.11.3",
"html-to-react": "^1.7.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-quill": "^2.0.0",
"react-redux": "^9.1.0",
"react-router-dom": "^6.21.3",
"react-toastify": "^10.0.4",
"redux-persist": "^6.0.0"
},
"devDependencies": {
"@types/react": "^18.2.43",
"@types/react-dom": "^18.2.17",
"@vitejs/plugin-react": "^4.2.1",
"eslint": "^8.55.0",
"eslint-plugin-react": "^7.33.2",
"eslint-plugin-react-hooks": "^4.6.0",
"eslint-plugin-react-refresh": "^0.4.5",
"vite": "^5.0.8"
}
}

1
public/vite.svg Normal file
View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

35
src/App.jsx Normal file
View File

@ -0,0 +1,35 @@
import React from 'react'
import Home from './components/Home'
import Login from './components/user/Login'
import Register from './components/user/Register'
import Profile from './components/user/Profile'
import Header from './components/layouts/Header'
import { BrowserRouter, Routes, Route} from 'react-router-dom'
import Write from './components/articles/Write'
import Article from './components/articles/Article'
import Bookmarked from './components/user/articles/Bookmarked'
import UpdateArticle from './components/user/articles/UpdateArticle'
import UpdateProfile from './components/user/UpdateProfile'
import UpdatePassword from './components/user/UpdatePassword'
import PageNotFound from './components/404/PageNotFound'
export default function App() {
return (
<BrowserRouter>
<Header />
<Routes>
<Route path="/" element={<Home />} />
<Route path="/login" element={<Login />} />
<Route path="/register" element={<Register />} />
<Route path="/profile" element={<Profile />} />
<Route path="/write" element={<Write />} />
<Route path="/articles/:slug" element={<Article />} />
<Route path="/update/profile" element={<UpdateProfile />} />
<Route path="/update/password" element={<UpdatePassword />} />
<Route path="/update/article/:slug" element={<UpdateArticle />} />
<Route path="/bookmarked" element={<Bookmarked />} />
<Route path="*" element={<PageNotFound />} />
</Routes>
</BrowserRouter>
)
}

View File

@ -0,0 +1,28 @@
import React from 'react'
import { Link } from 'react-router-dom'
import useTitle from '../custom/useTitle'
export default function PageNotFound() {
//change page title
useTitle('404 Page Not Found')
return (
<div className='container'>
<div className="my-5 pb-5">
<div className="col-md-6 mx-auto">
<div className="card text-center">
<div className="card-body">
<h3 className="my-3">
404 Page Not Found
</h3>
<Link to="/" className='btn btn-outline-secondary my-2'>
Back Home
</Link>
</div>
</div>
</div>
</div>
</div>
)
}

105
src/components/Home.jsx Normal file
View File

@ -0,0 +1,105 @@
import React, { useEffect, useState } from 'react'
import { BASE_URL, getConfig } from '../helpers/config'
import axios from 'axios'
import ArticleList from './articles/ArticleList'
import Tags from './tags/Tags'
import Spinner from './layouts/Spinner'
import SwitchNav from './layouts/SwitchNav'
import { useSelector } from 'react-redux'
import useTitle from './custom/useTitle'
export default function Home() {
const { token, isLoggedIn } = useSelector(state => state.user)
const [articles, setArticles] = useState([])
const [loading, setLoading] = useState(false)
const [articleByTag, setArticleByTag] = useState('')
const [articleByFollowing, setArticleByFollowing] = useState(false)
const [meta, setMeta] = useState({
to: 0,
total: 0
})
//change page title
useTitle('Home')
useEffect(() => {
const fetchArticles = async () => {
setLoading(true)
try {
if (articleByTag) {
const response = await axios.get(`${BASE_URL}/tag/${articleByTag}/articles`)
setArticles(response.data.data)
setMeta(response.data.meta)
setLoading(false)
} else if (articleByFollowing) {
const response = await axios.get(`${BASE_URL}/followings/articles`,
getConfig(token))
setArticles(response.data.data)
setMeta(response.data.meta)
setLoading(false)
} else {
const response = await axios.get(`${BASE_URL}/articles`)
setArticles(response.data.data)
setMeta(response.data.meta)
setLoading(false)
}
} catch (error) {
setLoading(false)
console.log(error)
}
}
fetchArticles()
}, [articleByTag, articleByFollowing])
const fetchNextArticles = async () => {
try {
if (articleByTag) {
const response = await axios.get(`${BASE_URL}/tag/${articleByTag}/articles?page=${meta.current_page += 1}`)
setArticles(prevArticles => [...prevArticles, ...response.data.data])
setMeta(response.data.meta)
} else if (articleByFollowing) {
const response = await axios.get(`${BASE_URL}/followings/articles?page=${meta.current_page += 1}`,
getConfig(token))
setArticles(prevArticles => [...prevArticles, ...response.data.data])
setMeta(response.data.meta)
} else {
const response = await axios.get(`${BASE_URL}/articles?page=${meta.current_page += 1}`)
setArticles(prevArticles => [...prevArticles, ...response.data.data])
setMeta(response.data.meta)
}
} catch (error) {
setLoading(false)
console.log(error)
}
}
return (
<div className="container">
{
loading ?
<div className="d-flex justify-content-center mt-5">
<Spinner />
</div>
:
<div className='row my-5'>
{/* switching between all the articles and the articles
of the users we follow */}
{
isLoggedIn && <SwitchNav articleByFollowing={articleByFollowing}
setArticleByFollowing={setArticleByFollowing}
setArticleByTag={setArticleByTag} />
}
{/* display all the published articles */}
<ArticleList articles={articles}
fetchNextArticles={fetchNextArticles}
meta={meta}
/>
{/* display all the tags */}
<Tags setArticleByTag={setArticleByTag}
articleByTag={articleByTag}
setArticleByFollowing={setArticleByFollowing} />
</div>
}
</div>
)
}

View File

@ -0,0 +1,231 @@
import React, { useEffect, useState } from 'react'
import { Link, useParams } from 'react-router-dom'
import axios from 'axios'
import { BASE_URL, getConfig } from '../../helpers/config'
import Spinner from '../layouts/Spinner'
import { useDispatch, useSelector } from 'react-redux'
import { Parser } from 'html-to-react'
import { setCurrentUser } from '../../redux/slices/userSlice'
import { bookmark } from '../../redux/slices/bookmarkSlice'
import useTitle from '../custom/useTitle'
export default function Article() {
const { isLoggedIn, token, user} = useSelector(state => state.user)
const { bookmarked } = useSelector(state => state.bookmark)
const [article, setArticle] = useState(null)
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const { slug } = useParams()
const dispatch = useDispatch()
const exists = bookmarked.find(item => item.id === article?.id)
//change page title
useTitle(`${article?.title ? article?.title : ''}`)
useEffect(() => {
const fetchArticleBySlug = async () => {
setLoading(true)
try {
const response = await axios.get(`${BASE_URL}/articles/${slug}`)
setArticle(response.data.data)
setLoading(false)
} catch (error) {
if (error?.response?.status === 404) {
setError('The article you are looking for does not exist.')
}
setLoading(false)
console.log(error)
}
}
fetchArticleBySlug()
}, [slug])
const followUser = async (follower_id, following_id) => {
try {
const response = await axios.post(`${BASE_URL}/user/follow`, {
follower_id, following_id
}, getConfig(token))
article.user = response.data.following
dispatch(setCurrentUser(response.data.follower))
} catch (error) {
console.log(error)
}
}
const unfollowUser = async (follower_id, following_id) => {
try {
const response = await axios.post(`${BASE_URL}/user/unfollow`, {
follower_id, following_id
}, getConfig(token))
article.user = response.data.following
dispatch(setCurrentUser(response.data.follower))
} catch (error) {
console.log(error)
}
}
const checkIfFollowingUser = () => (
article?.user?.followers?.findIndex(item => item.pivot.follower_id === user?.id) !== -1
?
<button className="border-0 bg-light text-success ms-1"
onClick={() => unfollowUser(user?.id, article?.user?.id)}>
Unfollow
</button>
:
<button className="border-0 bg-light text-success ms-1"
onClick={() => followUser(user?.id, article?.user?.id)}>
Follow
</button>
)
const showClapButton = () => (
isLoggedIn ?
<button className="border-0 bg-light text-danger ms-1"
onClick={() => addClap()}>
<i className="bi bi-balloon-heart-fill"></i>
</button>
:
<Link className="border-0 bg-light text-danger ms-1"
to="/login">
<i className="bi bi-balloon-heart-fill"></i>
</Link>
)
const addClap = async () => {
try {
const response = await axios.get(`${BASE_URL}/clap/${slug}/article`, getConfig(token))
setArticle(response.data.data)
} catch (error) {
console.log(error)
}
}
return (
<div className='container'>
{
loading ? <div className="d-flex justify-content-center mt-3">
<Spinner />
</div>
:
error ? <div className="row my-5">
<div className="col-md-6 mx-auto">
<div className="card">
<div className="card-body">
<div className="alert alert-danger my-3">
{ error }
</div>
</div>
</div>
</div>
</div>
:
<div className="my-5">
<div className="col-md-10 mx-auto mb-2">
<div className="card">
<div className="card-header bg-white d-flex flex-column justify-content-center align-items-center">
<h3 className="mt-2">
{ article?.title }
</h3>
<div className="p-3 d-flex align-items-center">
<img src={article?.user?.image_path}
width={40}
height={40}
className='rounded-circle me-2'
alt={article?.user?.name}
/>
<span className="text-primary fw-bold">
{ article?.user?.name }
</span>
{
isLoggedIn ?
article?.user?.id !== user?.id && checkIfFollowingUser()
:
<Link className="text-decoration-none btn border-0 bg-light text-success ms-1"
to="/login">
Follow
</Link>
}
<span className="mx-2">
|
</span>
<span className="text-muted">
{ article?.created_at }
</span>
</div>
<div>
{
showClapButton()
}
<span className="mx-2 fw-bold">
{ article?.clapsCount }
</span>
</div>
</div>
<div className="card-body">
<div className="col-md-8 mx-auto my-5">
<img src={article?.image_path}
className='img-fluid rounded'
alt={article?.title}
/>
</div>
<div>
{ Parser().parse(article?.body) }
</div>
<div className="p-3 d-flex flex-column
justify-content-start bg-light rounded">
<img src={article?.user?.image_path}
width={80}
height={80}
className='rounded-circle me-2'
alt={article?.user?.name}
/>
<h4 className="text-primary fw-bold">
{ article?.user?.name }
</h4>
{
article?.user?.bio && <p>
{ article?.user?.bio }
</p>
}
{
article?.user?.followers.length > 0 && <div className="text-muted">
<i>
{ article?.user?.followers.length } {" "}
{ article?.user?.followers.length > 1 ? 'Followers' : 'Follower' }
</i>
</div>
}
</div>
</div>
<div className="card-footer bg-white d-flex justify-content-between align-items-center">
<div className="d-flex flex-wrap">
{
article?.tags.map(tag => (
<span key={tag.id} className="badge bg-secondary me-1">
{ tag.name }
</span>
))
}
</div>
<div>
{
isLoggedIn ?
<button className={`btn btn-sm btn-${exists ? 'warning' : 'light'}`}
onClick={() => dispatch(bookmark(article))}>
<i className="bi bi-bookmark-plus"></i>
</button>
:
<Link className='btn btn-sm btn-light' to="/login">
<i className="bi bi-bookmark-plus"></i>
</Link>
}
</div>
</div>
</div>
</div>
</div>
}
</div>
)
}

View File

@ -0,0 +1,39 @@
import React, { useEffect } from 'react'
import ArticleListItem from './ArticleListItem'
export default function ArticleList({articles, fetchNextArticles, meta}) {
useEffect(() => {
//define the scroll
const scroll = () => {
const bottom = Math.ceil(window.innerHeight + window.scrollY) >= document.documentElement.scrollHeight
if (bottom) {
if (meta.to < meta.total) {
fetchNextArticles()
}
}
}
//add the scroll event once the component is mounted
window.addEventListener('scroll', scroll)
//remove the event once the component unmount
return () => {
window.removeEventListener('scroll', scroll)
}
}, [meta.to, meta.total])
return (
<div className='col-md-8'>
{
articles?.length ? articles?.map(article => (
<ArticleListItem article={article}
key={article.id} />
))
:
<div className="alert alert-info">
No article found.
</div>
}
</div>
)
}

View File

@ -0,0 +1,80 @@
import React from 'react'
import { Link } from 'react-router-dom'
import { useDispatch, useSelector } from 'react-redux'
import { bookmark } from '../../redux/slices/bookmarkSlice'
export default function ArticleListItem({article}) {
const { isLoggedIn } = useSelector(state => state.user)
const { bookmarked } = useSelector(state => state.bookmark)
const exists = bookmarked.find(item => item.id === article?.id)
const dispatch = useDispatch()
return (
<div className='card mb-2'>
<div className="card-header bg-white d-flex justify-content-start">
<div className="p-3 d-flex align-items-center">
<img src={article.user.image_path}
width={40}
height={40}
className='rounded-circle me-2'
alt={article.user.name}
/>
<span className="text-primary fw-bold">
{ article.user.name }
</span>
<span className="mx-2">
|
</span>
<span className="text-muted">
{ article.created_at }
</span>
</div>
</div>
<div className="card-body d-flex justify-content-between">
<div className="d-flex flex-column p-2">
<Link to={`/articles/${article.slug}`}
className='text-decoration-none text-dark'>
<h4>
{ article.title }
</h4>
</Link>
<p>
{ article.excerpt }
</p>
</div>
<div>
<img src={article.image_path}
width={150}
height={150}
className='rounded'
alt={article.title}
/>
</div>
</div>
<div className="card-footer bg-white d-flex justify-content-between align-items-center">
<div className="d-flex flex-wrap">
{
article?.tags.map(tag => (
<span key={tag.id} className="badge bg-secondary me-1">
{ tag.name }
</span>
))
}
</div>
<div>
{
isLoggedIn ?
<button className={`btn btn-sm btn-${exists ? 'warning' : 'light'}`}
onClick={() => dispatch(bookmark(article))}>
<i className="bi bi-bookmark-plus"></i>
</button>
:
<Link className='btn btn-sm btn-light' to="/login">
<i className="bi bi-bookmark-plus"></i>
</Link>
}
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,168 @@
import React, { useEffect, useState } from 'react'
import useValidation from '../custom/useValidation'
import ReactQuill from 'react-quill'
import Spinner from '../layouts/Spinner'
import { useNavigate } from 'react-router-dom'
import { useSelector } from 'react-redux'
import { BASE_URL, getConfig, modules } from '../../helpers/config'
import axios from 'axios'
import useTag from '../custom/useTag'
import useTitle from '../custom/useTitle'
import { toast } from 'react-toastify'
export default function Write() {
const { isLoggedIn, token } = useSelector(state => state.user)
const [article, setArticle] = useState({
title: '',
body: '',
excerpt: '',
image: ''
})
const [loading, setLoading] = useState(false)
const [errors, setErrors] = useState([])
const navigate = useNavigate()
const fetchedTags = useTag()
const [choosenTags, setChoosenTags] = useState([])
//change page title
useTitle('Write')
useEffect(() => {
if (!isLoggedIn) navigate('/login')
}, [isLoggedIn])
const handleTagsInputChange = (e) => {
let exists = choosenTags.find(tag => tag === e.target.value)
if (exists) {
const updatedTags = choosenTags.filter(tag => tag !== e.target.value)
setChoosenTags(updatedTags)
}else {
setChoosenTags([...choosenTags, e.target.value])
}
}
const storeArticle = async (e) => {
e.preventDefault()
setLoading(true)
setErrors([])
const formData = new FormData()
formData.append('image', article.image)
formData.append('title', article.title)
formData.append('body', article.body)
formData.append('excerpt', article.excerpt)
formData.append('tags', choosenTags)
try {
const response = await axios.post(`${BASE_URL}/add/article`, formData,
getConfig(token, 'multipart/form-data'))
setLoading(false)
toast.success(response.data.message)
navigate('/')
} catch (error) {
setLoading(false)
if (error?.response?.status === 422) {
setErrors(error.response.data.errors)
}
console.log(error)
}
}
return (
<div className='row my-5'>
<div className="col-md-6 mx-auto">
<div className="card shadow-sm">
<div className="card-header bg-white">
<h5 className="text-center mt-2">
Add new article
</h5>
</div>
<div className="card-body">
<form className="mt-5" onSubmit={(e) => storeArticle(e)}>
<div className="mb-3">
<label htmlFor="title" className='form-label'>Title*</label>
<input
type="text" name='title' id='title'
value={article.title}
onChange={(e) => setArticle({
...article, title: e.target.value
})}
className="form-control" />
{ useValidation(errors, 'title')}
</div>
<div className="mb-3">
<label htmlFor="excerpt" className='form-label'>Excerpt*</label>
<textarea rows={5}
cols={30}
name='excerpt'
value={article.excerpt}
onChange={(e) => setArticle({
...article, excerpt: e.target.value
})}
id='excerpt'
className="form-control"></textarea>
{ useValidation(errors, 'excerpt')}
</div>
<div className="mb-3">
<label htmlFor="body" className='form-label'>Body*</label>
<ReactQuill theme="snow"
value={article.body}
modules={modules}
onChange={(value) => setArticle({
...article, body: value
})} />
{ useValidation(errors, 'body')}
</div>
<div className="mb-3">
<label htmlFor="image" className="form-label">Image*</label>
<input
type="file" name="image" id='image'
accept="image/*"
onChange={(e) => setArticle({
...article, image: e.target.files[0]
})}
className="form-control" />
{ useValidation(errors, 'image')}
{
article.image &&
<img src={URL.createObjectURL(article.image)}
alt="image"
width={150}
height={150}
className='rounded my-2'/>
}
</div>
<div className="mb-3 d-flex flex-wrap">
{
fetchedTags?.map(tag => (
<div key={tag.id} className="form-check">
<input type="checkbox"
id={tag.id}
value={tag.id}
checked={choosenTags.some(item => item == tag.id)}
onChange={(e) => handleTagsInputChange(e)}
className='form-check-input mx-1'
/>
<label htmlFor={tag.id} className='form-check-label'>
{ tag.name }
</label>
</div>
))
}
</div>
<div className="mb-3">
{
loading ?
<Spinner />
:
<button type="submit" className='btn btn-sm btn-dark'>
Submit
</button>
}
</div>
</form>
</div>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,21 @@
import { useEffect, useState } from 'react'
import { BASE_URL } from '../../helpers/config'
import axios from 'axios'
export default function useTag() {
const [tags, setTags] = useState()
useEffect(() => {
const fetchTags = async () => {
try {
const response = await axios.get(`${BASE_URL}/tags`)
setTags(response.data.data)
} catch (error) {
console.log(error)
}
}
fetchTags()
}, [])
return tags
}

View File

@ -0,0 +1,9 @@
import { useEffect } from 'react'
export default function useTitle(title) {
useEffect(() => {
document.title = `React Medium Clone ${title}`
}, [title])
return null
}

View File

@ -0,0 +1,11 @@
export default function useValidation(errors, field) {
const renderErrors = (field) => (
errors?.[field]?.map((error, index) => (
<div key={index} className="text-white my-2 rounded p-2 bg-danger">
{ error }
</div>
))
)
return renderErrors(field)
}

View File

@ -0,0 +1,121 @@
import React, { useEffect } from 'react'
import { Link, useLocation, useNavigate } from 'react-router-dom'
import { useDispatch, useSelector } from 'react-redux'
import { BASE_URL, getConfig } from '../../helpers/config'
import axios from 'axios'
import { setLoggedInOut, setCurrentUser, setToken } from '../../redux/slices/userSlice'
import { toast } from 'react-toastify'
import SearchBox from '../search/SearchBox'
export default function Header() {
const { isLoggedIn, token, user } = useSelector(state => state.user)
const { bookmarked } = useSelector(state => state.bookmark)
const navigate = useNavigate()
const dispatch = useDispatch()
const location = useLocation()
useEffect(() => {
const getLoggedInUser = async () => {
try {
const response = await axios.get(`${BASE_URL}/user`,
getConfig(token))
dispatch(setCurrentUser(response.data.user))
} catch (error) {
if (error?.response?.status === 401) {
dispatch(setLoggedInOut(false))
dispatch(setCurrentUser(null))
dispatch(setToken(''))
}
console.log(error)
}
}
if (token) getLoggedInUser()
}, [token])
const logoutUser = async () => {
try {
const response = await axios.post(`${BASE_URL}/user/logout`, null,
getConfig(token))
dispatch(setLoggedInOut(false))
dispatch(setCurrentUser(null))
dispatch(setToken(''))
toast.success(response.data.message)
navigate('/login')
} catch (error) {
console.log(error)
}
}
return (
<>
<nav className="navbar navbar-expand-lg bg-body-tertiary">
<div className="container-fluid">
<Link className="navbar-brand" to="/">React Medium Clone</Link>
<button className="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation">
<span className="navbar-toggler-icon"></span>
</button>
<div className="collapse navbar-collapse" id="navbarSupportedContent">
<ul className="navbar-nav me-auto mb-2 mb-lg-0">
<li className="nav-item">
<Link className={`nav-link ${location.pathname === '/' ? 'active' : ''}`} aria-current="page" to="/">
<i className="bi bi-house"></i> Home
</Link>
</li>
{
isLoggedIn ?
<>
<li className="nav-item">
<Link className={`nav-link ${location.pathname === '/write' ? 'active' : ''}`} aria-current="page"
to="/write">
<i className="bi bi-pencil"></i> Write
</Link>
</li>
<li className="nav-item">
<Link className={`nav-link ${location.pathname === '/profile' ? 'active' : ''}`} aria-current="page"
to="/profile">
<i className="bi bi-person"></i> { user?.name }
</Link>
</li>
<li className="nav-item">
<button className="nav-link border-0 bg-light"
onClick={() => logoutUser()}
aria-current="page">
<i className="bi bi-person-fill-down"></i> Logout
</button>
</li>
<li className="nav-item">
<Link className={`nav-link ${location.pathname === '/bookmarked' ? 'active' : ''}`} aria-current="page"
to="/bookmarked">
<i className="bi bi-bookmark-plus"></i> ({ bookmarked.length })
</Link>
</li>
</>
:
<>
<li className="nav-item">
<Link className={`nav-link ${location.pathname === '/login' ? 'active' : ''}`} aria-current="page" to="/login">
<i className="bi bi-person-fill-up"></i> Login
</Link>
</li>
<li className="nav-item">
<Link className={`nav-link ${location.pathname === '/register' ? 'active' : ''}`} aria-current="page" to="/register">
<i className="bi bi-person-add"></i> Register
</Link>
</li>
</>
}
</ul>
<ul className="navbar-nav ml-auto mb-2 ml-lg-0">
<li className="nav-item">
<a className="nav-link" data-bs-toggle="offcanvas" href="#offcanvasExample" role="button" aria-controls="offcanvasExample">
<i className="bi bi-search"></i>
</a>
</li>
</ul>
</div>
</div>
</nav>
<SearchBox />
</>
)
}

View File

@ -0,0 +1,9 @@
import React from 'react'
export default function Spinner() {
return (
<div className="spinner-border" role="status">
<span className="visually-hidden">Loading...</span>
</div>
)
}

View File

@ -0,0 +1,28 @@
import React from 'react'
export default function SwitchNav({ articleByFollowing, setArticleByFollowing, setArticleByTag }) {
return (
<div className='row mt-3 mb-5'>
<div className="col-md-6 mx-auto">
<div className="d-flex justify-content-start">
<button className={`btn btn-link text-decoration-none text-dark
${!articleByFollowing ? 'border-bottom' : ''}`}
onClick={() => {
setArticleByTag('')
setArticleByFollowing(false)
}}>
For you
</button>
<button className={`btn btn-link text-decoration-none text-dark
${articleByFollowing ? 'border-bottom' : ''}`}
onClick={() => {
setArticleByTag('')
setArticleByFollowing(true)
}}>
Following
</button>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,99 @@
import axios from 'axios'
import React, { useState } from 'react'
import { BASE_URL } from '../../helpers/config'
import { Link } from 'react-router-dom'
export default function SearchBox() {
const [searchTerm, setSearTerm] = useState('')
const [message, setMessage] = useState('')
const [articles, setArticles] = useState([])
const [loading, setLaoding] = useState(false)
const searchArticles = async (e) => {
e.preventDefault()
setArticles([])
setMessage('')
setLaoding(true)
const data = { searchTerm }
try {
const response = await axios.post(`${BASE_URL}/find/articles`,
data)
if (response.data.data.length) {
setArticles(response.data.data)
}else {
setMessage('No results found.')
}
setLaoding(false)
setSearTerm('')
} catch (error) {
setLaoding(false)
setSearTerm('')
console.log(error)
}
}
return (
<div className="offcanvas offcanvas-start" tabIndex="-1" id="offcanvasExample" aria-labelledby="offcanvasExampleLabel">
<div className="offcanvas-header">
<h5 className="offcanvas-title" id="offcanvasExampleLabel">Search</h5>
<button type="button" className="btn-close" data-bs-dismiss="offcanvas" aria-label="Close"></button>
</div>
<div className="offcanvas-body">
<form onSubmit={(e) => searchArticles(e)}>
<div className="row align-items-center">
<div className="col-md-10">
<input type="search" name="searchTerm"
value={searchTerm}
onChange={(e) => setSearTerm(e.target.value)}
className="form-control"
placeholder='Search...'
/>
</div>
<div className="col-md-2">
<button type='submit' className="btn btn-sm btn-dark">
<i className="bi bi-search"></i>
</button>
</div>
</div>
</form>
{
message && <div className="alert alert-info my-3">
{ message }
</div>
}
<ul className="list-group my-3">
{
loading ?
<div className="my-3">
<span className="text-muted">
<i>Searching...</i>
</span>
</div>
:
articles?.map(article => (
<li key={article.id} className="list-group-item">
<div className="d-flex justify-content-between align-items-center">
<img src={article.image_path}
className='rounded me-3'
width={60}
height={60}
alt={article.title}
/>
<span>
{ article.title }
</span>
<Link to={`/articles/${article.slug}`}
className='text-decoration-none text-primary'>
View
</Link>
</div>
</li>
))
}
</ul>
</div>
</div>
)
}

View File

@ -0,0 +1,35 @@
import React from 'react'
import useTags from '../custom/useTag'
export default function Tags({articleByTag, setArticleByTag, setArticleByFollowing}) {
const fetchedTags = useTags()
return (
<div className='col-md-4'>
<div className="card">
<div className="card-body">
{
articleByTag && <button className="btn btn-sm btn-danger rounded-0 mb-1"
onClick={() => {
setArticleByFollowing(false)
setArticleByTag('')
}}>
All
</button>
}
{
fetchedTags?.map(tag => (
<button key={tag.id} className={`btn btn-sm btn-${articleByTag === tag.slug ? 'primary' : 'light'} mb-1 rounded-0`}
onClick={() => {
setArticleByFollowing(false)
setArticleByTag(tag.slug)
}}>
{ tag.name }
</button>
))
}
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,102 @@
import React, { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { useDispatch, useSelector } from 'react-redux'
import axios from 'axios'
import { toast } from 'react-toastify'
import useValidation from '../custom/useValidation'
import Spinner from '../layouts/Spinner'
import { BASE_URL } from '../../helpers/config'
import { setCurrentUser, setToken, setLoggedInOut } from '../../redux/slices/userSlice'
import useTitle from '../custom/useTitle'
export default function Login() {
const { isLoggedIn } = useSelector(state => state.user)
const [user, setUser] = useState({
email: '',
password: ''
})
const [errors, setErrors] = useState([])
const [loading, setLoading] = useState(false)
const dispatch = useDispatch()
const navigate = useNavigate()
//change page title
useTitle('Login')
useEffect(() => {
if (isLoggedIn) navigate('/')
}, [isLoggedIn])
const loginUser = async(e) => {
e.preventDefault()
setLoading(true)
setErrors([])
try {
const response = await axios.post(`${BASE_URL}/user/login`, user)
setLoading(false)
if (response.data.error) {
toast.error(response.data.error)
}else {
dispatch(setLoggedInOut(true))
dispatch(setCurrentUser(response.data.user))
dispatch(setToken(response.data.access_token))
toast.success(response.data.message)
navigate('/')
}
} catch (error) {
setLoading(false)
if (error?.response?.status === 422) {
setErrors(error.response.data.errors)
}
console.log(error)
}
}
return (
<div className='container'>
<div className="row my-5">
<div className="col-md-6 mx-auto">
<div className="card shadow-sm">
<div className="card-header bg-white">
<h5 className="text-center mt-2">
Login
</h5>
</div>
<div className="card-body">
<form className="mt-5" onSubmit={(e) => loginUser(e)}>
<div className="mb-3">
<label htmlFor="email" className='form-label'>Email*</label>
<input type="email" name='email'
onChange={(e) => setUser({
...user, email: e.target.value
})}
id='email' className="form-control" />
{ useValidation(errors, 'email')}
</div>
<div className="mb-3">
<label htmlFor="password" className='form-label'>Password*</label>
<input type="password" name='password'
onChange={(e) => setUser({
...user, password: e.target.value
})}
id='password' className="form-control" />
{ useValidation(errors, 'password')}
</div>
<div className="mb-3">
{
loading ?
<Spinner />
:
<button type="submit" className='btn btn-sm btn-dark'>
Submit
</button>
}
</div>
</form>
</div>
</div>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,27 @@
import React, { useEffect } from 'react'
import { useSelector } from 'react-redux'
import { useNavigate } from 'react-router-dom'
import Sidebar from './partials/Sidebar'
import UserArticles from './articles/UserArticles'
import useTitle from '../custom/useTitle'
export default function Profile() {
const { isLoggedIn } = useSelector(state => state.user)
const navigate = useNavigate()
//change page title
useTitle('Profile')
useEffect(() => {
if (!isLoggedIn) navigate('/login')
}, [isLoggedIn])
return (
<div className='container'>
<div className="row my-5">
<Sidebar />
<UserArticles />
</div>
</div>
)
}

View File

@ -0,0 +1,104 @@
import React, { useEffect, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import axios from 'axios'
import { toast } from 'react-toastify'
import useValidation from '../custom/useValidation'
import Spinner from '../layouts/Spinner'
import { BASE_URL } from '../../helpers/config'
import { useSelector } from 'react-redux'
import useTitle from '../custom/useTitle'
export default function Register() {
const { isLoggedIn } = useSelector(state => state.user)
const [user, setUser] = useState({
name: '',
email: '',
password: ''
})
const [errors, setErrors] = useState([])
const [loading, setLoading] = useState(false)
const navigate = useNavigate()
//change page title
useTitle('Register')
useEffect(() => {
if (isLoggedIn) navigate('/')
}, [isLoggedIn])
const registerUser = async(e) => {
e.preventDefault()
setLoading(true)
setErrors([])
try {
const response = await axios.post(`${BASE_URL}/user/register`, user)
setLoading(false)
toast.success(response.data.message)
navigate('/login')
} catch (error) {
setLoading(false)
if (error?.response?.status === 422) {
setErrors(error.response.data.errors)
}
console.log(error)
}
}
return (
<div className='container'>
<div className="row my-5">
<div className="col-md-6 mx-auto">
<div className="card shadow-sm">
<div className="card-header bg-white">
<h5 className="text-center mt-2">
Register
</h5>
</div>
<div className="card-body">
<form className="mt-5" onSubmit={(e) => registerUser(e)}>
<div className="mb-3">
<label htmlFor="name" className='form-label'>Name*</label>
<input
type="text" name='name' id='name'
onChange={(e) => setUser({
...user, name: e.target.value
})}
className="form-control" />
{ useValidation(errors, 'name')}
</div>
<div className="mb-3">
<label htmlFor="email" className='form-label'>Email*</label>
<input type="email" name='email'
onChange={(e) => setUser({
...user, email: e.target.value
})}
id='email' className="form-control" />
{ useValidation(errors, 'email')}
</div>
<div className="mb-3">
<label htmlFor="password" className='form-label'>Password*</label>
<input type="password" name='password'
onChange={(e) => setUser({
...user, password: e.target.value
})}
id='password' className="form-control" />
{ useValidation(errors, 'password')}
</div>
<div className="mb-3">
{
loading ?
<Spinner />
:
<button type="submit" className='btn btn-sm btn-dark'>
Submit
</button>
}
</div>
</form>
</div>
</div>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,104 @@
import React, { useEffect, useState } from 'react'
import { useSelector } from 'react-redux'
import { useNavigate } from 'react-router-dom'
import useValidation from '../custom/useValidation'
import Spinner from '../layouts/Spinner'
import axios from 'axios'
import { BASE_URL, getConfig } from '../../helpers/config'
import { toast } from 'react-toastify'
import useTitle from '../custom/useTitle'
export default function UpdatePassword() {
const { token, isLoggedIn } = useSelector(state => state.user)
const navigate = useNavigate()
const [newPassword, setNewPassword] = useState('')
const [currentPassword, setCurrentPassword] = useState('')
const [errors, setErrors] = useState([])
const [loading, setLoading] = useState(false)
//change page title
useTitle('Update Password')
useEffect(() => {
if (!isLoggedIn) navigate('/login')
}, [isLoggedIn])
const updatePassword = async (e) => {
e.preventDefault()
setErrors([])
setLoading(true)
const data = { currentPassword, newPassword}
try {
const response = await axios.put(`${BASE_URL}/update/password`, data,
getConfig(token))
if (response.data.error) {
setLoading(false)
toast.error(response.data.error)
}else {
setLoading(false)
setCurrentPassword('')
setNewPassword('')
toast.success(response.data.message)
navigate('/profile')
}
} catch (error) {
setLoading(false)
if (error?.response?.status === 422) {
setErrors(error.response.data.errors)
}
console.log(error)
}
}
return (
<div className='container'>
<div className="row my-5">
<div className="col-md-6 mx-auto">
<div className="card">
<div className="card-header bg-white text-center">
<h4 className="mt-2">
Update your password
</h4>
</div>
<div className="card-body">
<form className="mt-5" onSubmit={(e) => updatePassword(e)}>
<div className="mb-3">
<label htmlFor="currentPassword" className='form-label'>Current Password*</label>
<input
type="password" name='currentPassword' id='currentPassword'
value={currentPassword}
onChange={(e) => setCurrentPassword(e.target.value)}
className="form-control" />
{ useValidation(errors, 'currentPassword')}
</div>
<div className="mb-3">
<label htmlFor="newPassword" className='form-label'>New Password*</label>
<input
type="password" name='newPassword' id='newPassword'
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
className="form-control" />
{ useValidation(errors, 'newPassword')}
</div>
<div className="mb-3">
{
loading ?
<Spinner />
:
<button type="submit" className='btn btn-sm btn-dark'>
Submit
</button>
}
</div>
</form>
</div>
</div>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,147 @@
import React, { useEffect, useState } from 'react'
import { useDispatch, useSelector } from 'react-redux'
import { useNavigate } from 'react-router-dom'
import useValidation from '../custom/useValidation'
import Spinner from '../layouts/Spinner'
import { setCurrentUser } from '../../redux/slices/userSlice'
import axios from 'axios'
import { BASE_URL, getConfig } from '../../helpers/config'
import { toast } from 'react-toastify'
import useTitle from '../custom/useTitle'
export default function UpdateProfile() {
const { user, token, isLoggedIn } = useSelector(state => state.user)
const navigate = useNavigate()
const [data, setData] = useState({
name: user?.name,
email: user?.email,
bio: user?.bio || ''
})
const [errors, setErrors] = useState([])
const dispatch = useDispatch()
const [loading, setLoading] = useState(false)
//change page title
useTitle('Update Profile')
useEffect(() => {
if (!isLoggedIn) navigate('/login')
}, [isLoggedIn])
const updateProfile = async (e) => {
e.preventDefault()
setErrors([])
setLoading(true)
const formData = new FormData()
if (data.image !== undefined) {
formData.append('image', data.image)
}
formData.append('name', data.name)
formData.append('email', data.email)
formData.append('bio', data.bio)
formData.append('_method', 'put')
try {
const response = await axios.post(`${BASE_URL}/update/profile`, formData,
getConfig(token, 'multipart/form-data'))
dispatch(setCurrentUser(response.data.user))
setLoading(false)
toast.success(response.data.message)
navigate('/profile')
} catch (error) {
setLoading(false)
if (error?.response?.status === 422) {
setErrors(error.response.data.errors)
}
console.log(error)
}
}
return (
<div className='container'>
<div className="row my-5">
<div className="col-md-6 mx-auto">
<div className="card">
<div className="card-header bg-white text-center">
<h4 className="mt-2">
Update your profile
</h4>
</div>
<div className="card-body">
<form className="mt-5" onSubmit={(e) => updateProfile(e)}>
<div className="mb-3">
<label htmlFor="name" className='form-label'>Name*</label>
<input
type="text" name='name' id='name'
value={data.name}
onChange={(e) => setData({
...data, name: e.target.value
})}
className="form-control" />
{ useValidation(errors, 'name')}
</div>
<div className="mb-3">
<label htmlFor="email" className='form-label'>Email*</label>
<input
type="email" name='email' id='email'
value={data.email}
onChange={(e) => setData({
...data, email: e.target.value
})}
className="form-control" />
{ useValidation(errors, 'email')}
</div>
<div className="mb-3">
<label htmlFor="bio" className='form-label'>Bio</label>
<textarea rows={5}
cols={30}
name='bio'
value={data.bio}
onChange={(e) => setData({
...data, bio: e.target.value
})}
id='bio'
className="form-control"></textarea>
</div>
<div className="mb-3">
<label htmlFor="image" className="form-label">Image</label>
<input
type="file" name="image" id='image'
accept="image/*"
onChange={(e) =>
setData({
...data, image: e.target.files[0]
})
}
className="form-control" />
{ useValidation(errors, 'image')}
{
data?.image &&
<img src={URL.createObjectURL(data.image)}
alt="image"
width={150}
height={150}
className='rounded my-2'/>
}
</div>
<div className="mb-3">
{
loading ?
<Spinner />
:
<button type="submit" className='btn btn-sm btn-dark'>
Submit
</button>
}
</div>
</form>
</div>
</div>
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,63 @@
import React, { useEffect } from 'react'
import { useSelector } from 'react-redux'
import { Link, useNavigate } from 'react-router-dom'
import useTitle from '../../custom/useTitle'
export default function Bookmarked() {
const { isLoggedIn } = useSelector(state => state.user)
const { bookmarked } = useSelector(state => state.bookmark)
const navigate = useNavigate()
//change page title
useTitle('Saved Articles')
useEffect(() => {
if (!isLoggedIn) navigate('/login')
}, [isLoggedIn])
return (
<div className='container'>
<div className="row my-5">
<div className="col-md-10 mx-auto">
{
bookmarked?.length ?
<table className='table table-responsive'>
<caption>Saved articles</caption>
<thead>
<tr>
<th>ID</th>
<th>Title</th>
<th></th>
</tr>
</thead>
<tbody>
{
bookmarked.map((article, index) => (
<tr key={index}>
<td>
{ index += 1}
</td>
<td>
{ article.title }
</td>
<td>
<Link className='btn btn-sm btn-primary'
to={`/articles/${article.slug}`}>
<i className="bi bi-eye"></i>
</Link>
</td>
</tr>
))
}
</tbody>
</table>
:
<div className="alert alert-info">
No saved articles found.
</div>
}
</div>
</div>
</div>
)
}

View File

@ -0,0 +1,227 @@
import React, { useEffect, useState } from 'react'
import useValidation from '../../custom/useValidation'
import ReactQuill from 'react-quill'
import Spinner from '../../layouts/Spinner'
import { useNavigate, useParams } from 'react-router-dom'
import { useDispatch, useSelector } from 'react-redux'
import { BASE_URL, getConfig, modules } from '../../../helpers/config'
import axios from 'axios'
import useTag from '../../custom/useTag'
import { toast } from 'react-toastify'
import { setCurrentUser } from '../../../redux/slices/userSlice'
import useTitle from '../../custom/useTitle'
export default function UpdateArticle() {
const { isLoggedIn, token } = useSelector(state => state.user)
const [article, setArticle] = useState({
title: '',
body: '',
excerpt: '',
image: ''
})
const [loading, setLoading] = useState(false)
const [loadingData, setLoadingData] = useState(false)
const [imageChanged, setImageChanged] = useState(false)
const [error, setError] = useState('')
const [errors, setErrors] = useState([])
const navigate = useNavigate()
const dispatch = useDispatch()
const fetchedTags = useTag()
const [choosenTags, setChoosenTags] = useState([])
const { slug } = useParams()
//change page title
useTitle('Update Article')
useEffect(() => {
if (!isLoggedIn) navigate('/login')
const fetchArticleBySlug = async () => {
setLoadingData(true)
try {
const response = await axios.get(`${BASE_URL}/articles/${slug}`)
setArticle(response.data.data)
response.data.data.tags.forEach(tag => setChoosenTags(
prevTags => [...prevTags, tag.id]
))
setLoadingData(false)
} catch (error) {
if (error?.response?.status === 404) {
setError('The article you are looking for does not exist.')
}
setLoadingData(false)
console.log(error)
}
}
fetchArticleBySlug()
}, [isLoggedIn, slug])
const handleTagsInputChange = (e) => {
let exists = choosenTags.find(tag => tag === parseInt(e.target.value))
if (exists) {
const updatedTags = choosenTags.filter(tag => tag !== parseInt(e.target.value))
setChoosenTags(updatedTags)
}else {
setChoosenTags([...choosenTags, parseInt(e.target.value)])
}
}
const updateArticle = async (e) => {
e.preventDefault()
setLoading(true)
setErrors([])
const formData = new FormData()
if (article.image !== undefined) {
formData.append('image', article.image)
}
formData.append('title', article.title)
formData.append('body', article.body)
formData.append('excerpt', article.excerpt)
formData.append('tags', choosenTags)
formData.append('_method', 'put')
try {
const response = await axios.post(`${BASE_URL}/update/${slug}/article`, formData,
getConfig(token, 'multipart/form-data'))
dispatch(setCurrentUser(response.data.user))
setLoading(false)
toast.success(response.data.message)
navigate('/profile')
} catch (error) {
setLoading(false)
if (error?.response?.status === 422) {
setErrors(error.response.data.errors)
}
console.log(error)
}
}
return (
<div className="container">
{
loadingData ? <div className="d-flex justify-content-center mt-3">
<Spinner />
</div>
:
error ? <div className="row my-5">
<div className="col-md-6 mx-auto">
<div className="card">
<div className="card-body">
<div className="alert alert-danger my-3">
{ error }
</div>
</div>
</div>
</div>
</div>
:
<div className='row my-5'>
<div className="col-md-6 mx-auto">
<div className="card shadow-sm">
<div className="card-header bg-white">
<h5 className="text-center mt-2">
Update article
</h5>
</div>
<div className="card-body">
<form className="mt-5" onSubmit={(e) => updateArticle(e)}>
<div className="mb-3">
<label htmlFor="title" className='form-label'>Title*</label>
<input
type="text" name='title' id='title'
value={article.title}
onChange={(e) => setArticle({
...article, title: e.target.value
})}
className="form-control" />
{ useValidation(errors, 'title')}
</div>
<div className="mb-3">
<label htmlFor="excerpt" className='form-label'>Excerpt*</label>
<textarea rows={5}
cols={30}
name='excerpt'
value={article.excerpt}
onChange={(e) => setArticle({
...article, excerpt: e.target.value
})}
id='excerpt'
className="form-control"></textarea>
{ useValidation(errors, 'excerpt')}
</div>
<div className="mb-3">
<label htmlFor="body" className='form-label'>Body*</label>
<ReactQuill theme="snow"
value={article.body}
modules={modules}
onChange={(value) => setArticle({
...article, body: value
})} />
{ useValidation(errors, 'body')}
</div>
<div className="mb-3">
<label htmlFor="image" className="form-label">Image*</label>
<input
type="file" name="image" id='image'
accept="image/*"
onChange={(e) => {
setImageChanged(true)
setArticle({
...article, image: e.target.files[0]
})
}}
className="form-control" />
{ useValidation(errors, 'image')}
{
article?.image_path && imageChanged ?
<img src={URL.createObjectURL(article.image)}
alt="image"
width={150}
height={150}
className='rounded my-2'/>
:
<img src={article.image_path}
alt="image"
width={150}
height={150}
className='rounded my-2'/>
}
</div>
<div className="mb-3 d-flex flex-wrap">
{
fetchedTags?.map(tag => (
<div key={tag.id} className="form-check">
<input type="checkbox"
id={tag.id}
value={tag.id}
checked={choosenTags.some(item => item == tag.id)}
onChange={(e) => handleTagsInputChange(e)}
className='form-check-input mx-1'
/>
<label htmlFor={tag.id} className='form-check-label'>
{ tag.name }
</label>
</div>
))
}
</div>
<div className="mb-3">
{
loading ?
<Spinner />
:
<button type="submit" className='btn btn-sm btn-dark'>
Submit
</button>
}
</div>
</form>
</div>
</div>
</div>
</div>
}
</div>
)
}

View File

@ -0,0 +1,118 @@
import React, { useEffect, useState } from 'react'
import { useDispatch, useSelector } from 'react-redux'
import { Link, useNavigate } from 'react-router-dom'
import { setCurrentUser } from '../../../redux/slices/userSlice'
import { toast } from 'react-toastify'
import axios from 'axios'
import { BASE_URL, getConfig } from '../../../helpers/config'
import Spinner from '../../layouts/Spinner'
export default function UserArticles() {
const { token } = useSelector(state => state.user)
const dispatch = useDispatch()
const navigate = useNavigate()
const [articles, setArticles] = useState([])
const [loading, setLoading] = useState(false)
useEffect(() => {
const getLoggedInUser = async () => {
setLoading(true)
try {
const response = await axios.get(`${BASE_URL}/user/articles`, getConfig(token))
setArticles(response.data.data)
setLoading(false)
} catch (error) {
setLoading(false)
console.log(error)
}
}
getLoggedInUser()
}, [])
const deleteArticle = async (slug) => {
try {
const response = await axios.delete(`${BASE_URL}/delete/${slug}/article`,
getConfig(token))
if (response.data.error) {
toast.error(response.data.error)
}else {
dispatch(setCurrentUser(response.data.user))
toast.success(response.data.message)
navigate('/profile')
}
} catch (error) {
console.log(error)
}
}
return (
<div className="col-md-9">
{
loading ? <div className="d-flex justify-content-center">
<Spinner />
</div>
:
articles?.length ?
<table className='table table-responsive'>
<caption>List of published articles</caption>
<thead>
<tr>
<th>ID</th>
<th>Image</th>
<th>Title</th>
<th>Claps</th>
<th>Published</th>
<th></th>
</tr>
</thead>
<tbody>
{
articles.map((article, index) => (
<tr key={index}>
<td>
{ index += 1}
</td>
<td>
<img src={article.image_path}
width={60}
height={60}
className='rounded'
alt={article.title}
/>
</td>
<td>
{ article.title }
</td>
<td>
{ article.clapsCount }
</td>
<td>
{ article.created_at }
</td>
<td>
<Link className='btn btn-sm btn-warning'
to={`/update/article/${article.slug}`}>
<i className="bi bi-pen"></i>
</Link>
<button className="btn btn-sm btn-danger ms-1"
onClick={() => {
if (confirm("Are you sure that you want to delete this article ?")) {
deleteArticle(article.slug)
}
}}>
<i className="bi bi-trash"></i>
</button>
</td>
</tr>
))
}
</tbody>
</table>
:
<div className="alert alert-info">
No articles found.
</div>
}
</div>
)
}

View File

@ -0,0 +1,46 @@
import React from 'react'
import { useSelector } from 'react-redux'
import { Link } from 'react-router-dom'
export default function Sidebar() {
const { user } = useSelector(state => state.user)
return (
<div className='col-md-3'>
<div className="card">
<div className="card-body d-flex flex-column justify-content-center align-items-center">
<img src={user?.image_path}
width={100}
height={100}
className='rounded-circle'
alt={user?.name}
/>
<span className="fw-bold my-2">
{user?.name}
</span>
{
user?.bio && <p>
{ user?.bio }
</p>
}
<span className="text-muted my-2">
<i>
{ user?.followers.length } {" "}
{ user?.followers.length > 1 ? 'Followers' : 'Follower' }
</i>
</span>
<span className="text-muted my-2">
<i>
{ user?.followings.length } {" "}
Following
</i>
</span>
<Link className='btn btn-link' to="/update/profile"
>Update your profile</Link>
<Link className='btn btn-link' to="/update/password"
>Update your password</Link>
</div>
</div>
</div>
)
}

33
src/helpers/config.js Normal file
View File

@ -0,0 +1,33 @@
export const BASE_URL = 'http://127.0.0.1:8000/api'
export const getConfig = (token, contentType) => {
const config = {
headers: {
"Content-type": contentType || "application/json",
"Authorization": `Bearer ${token}`
}
}
return config
}
export const modules = {
toolbar: [
['bold', 'italic', 'underline', 'strike'], // toggled buttons
['blockquote', 'code-block'],
[{ 'header': 1 }, { 'header': 2 }], // custom button values
[{ 'list': 'ordered'}, { 'list': 'bullet' }],
[{ 'script': 'sub'}, { 'script': 'super' }], // superscript/subscript
[{ 'indent': '-1'}, { 'indent': '+1' }], // outdent/indent
[{ 'direction': 'rtl' }], // text direction
[{ 'size': ['small', false, 'large', 'huge'] }], // custom dropdown
[{ 'header': [1, 2, 3, 4, 5, 6, false] }],
[{ 'color': [] }, { 'background': [] }], // dropdown with defaults from theme
[{ 'font': [] }],
[{ 'align': [] }],
['clean'] // remove formatting button
]
}

12
src/index.css Normal file
View File

@ -0,0 +1,12 @@
.ql-editor {
overflow-y: scroll;
resize: vertical;
}
pre.ql-syntax {
background-color: #23241f;
color: #f8f8f2;
overflow: scroll;
border-radius: 5px;
padding: 35px;
}

23
src/main.jsx Normal file
View File

@ -0,0 +1,23 @@
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.jsx'
import './index.css'
import { store, persistor } from './redux/store/index.js'
import { PersistGate } from 'redux-persist/integration/react'
import { Provider } from 'react-redux'
import 'bootstrap/dist/css/bootstrap.min.css'
import 'bootstrap/dist/js/bootstrap.min.js'
import 'bootstrap-icons/font/bootstrap-icons.css'
import 'react-toastify/dist/ReactToastify.css'
import 'react-quill/dist/quill.snow.css'
import { ToastContainer } from 'react-toastify'
ReactDOM.createRoot(document.getElementById('root')).render(
<Provider store={store}>
<PersistGate persistor={persistor}>
<ToastContainer position="top-right"/>
<App />
</PersistGate>
</Provider>,
)

View File

@ -0,0 +1,28 @@
import { createSlice } from "@reduxjs/toolkit"
const initialState = {
bookmarked: []
}
export const bookmarkSlice = createSlice({
name: 'bookmarked',
initialState,
reducers: {
bookmark (state, action) {
const item = action.payload
const exists = state.bookmarked.find(article => article.id === item.id)
if (exists) {
state.bookmarked = state.bookmarked.filter(article => article.id !== item.id)
}else {
state.bookmarked = [item, ...state.bookmarked]
}
}
}
})
const bookmarkReducer = bookmarkSlice.reducer
export const { bookmark } = bookmarkSlice.actions
export default bookmarkReducer

View File

@ -0,0 +1,31 @@
import { createSlice } from "@reduxjs/toolkit"
const initialState = {
isLoggedIn: false,
token: '',
user: null
}
export const userSlice = createSlice({
name: 'user',
initialState,
reducers: {
setCurrentUser(state, action) {
state.user = action.payload
},
setLoggedInOut(state, action) {
state.isLoggedIn = action.payload
},
setToken(state, action) {
state.token = action.payload
}
}
})
const userReducer = userSlice.reducer
export const { setCurrentUser, setLoggedInOut, setToken } = userSlice.actions
export default userReducer

49
src/redux/store/index.js Normal file
View File

@ -0,0 +1,49 @@
import { combineReducers, configureStore } from '@reduxjs/toolkit'
import {
persistStore,
persistReducer,
FLUSH,
REHYDRATE,
PAUSE,
PERSIST,
PURGE,
REGISTER,
} from 'redux-persist'
import storage from 'redux-persist/lib/storage'
import userReducer from '../slices/userSlice'
import bookmarkReducer from '../slices/bookmarkSlice'
//combine all reducers
const rootReducer = combineReducers({
user: userReducer,
bookmark: bookmarkReducer
})
//config persist
const persistConfig = {
key: 'root',
storage
}
//create the persisted reducer
const persistedReducer = persistReducer(persistConfig, rootReducer)
//create the store
const store = configureStore({
reducer: persistedReducer,
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware({
serializableCheck: {
ignoredActions: [FLUSH, REHYDRATE, PAUSE, PERSIST, PURGE, REGISTER],
},
}),
})
//create persistor
const persistor = persistStore(store)
export {
store, persistor
}

7
vite.config.js Normal file
View File

@ -0,0 +1,7 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [react()],
})