diff --git a/.gitignore b/.gitignore index d030bb1..3c3629e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1 @@ node_modules -default.json -.vscode -TODO.txt diff --git a/client/package.json b/client/package.json index fff941a..97532f2 100644 --- a/client/package.json +++ b/client/package.json @@ -4,15 +4,22 @@ "private": true, "dependencies": { "@reduxjs/toolkit": "^1.9.5", - "@testing-library/jest-dom": "^5.16.5", + "@testing-library/jest-dom": "^5.17.0", "@testing-library/react": "^13.4.0", "@testing-library/user-event": "^13.5.0", + "@types/jest": "^27.5.2", + "@types/node": "^16.18.39", + "@types/react": "^18.2.16", + "@types/react-dom": "^18.2.7", "axios": "^1.4.0", "react": "^18.2.0", "react-dom": "^18.2.0", - "react-redux": "^8.0.5", - "react-router-dom": "^6.11.2", + "react-redux": "^8.1.1", + "react-router-dom": "^6.14.2", "react-scripts": "5.0.1", + "redux": "^4.2.1", + "typescript": "^4.9.5", + "uuid": "^9.0.0", "web-vitals": "^2.1.4" }, "scripts": { @@ -39,5 +46,7 @@ "last 1 safari version" ] }, - "proxy": "http://localhost:5000" + "devDependencies": { + "@types/uuid": "^9.0.2" + } } diff --git a/client/src/App.tsx b/client/src/App.tsx new file mode 100755 index 0000000..76fcd23 --- /dev/null +++ b/client/src/App.tsx @@ -0,0 +1,96 @@ +import React, { useEffect } from 'react'; +import { BrowserRouter as Router, Route, Routes } from 'react-router-dom'; + +import Navbar from './components/layout/Navbar'; +import Landing from './components/layout/Landing'; +import Register from './components/auth/Register'; +import Login from './components/auth/Login'; +import Alert from './components/layout/Alert'; +import Dashboard from './components/dashboard/Dashboard'; +import ProfileForm from './components/profile-forms/ProfileForm'; +import AddExperience from './components/profile-forms/AddExperience'; +import AddEducation from './components/profile-forms/AddEducation'; +import Profiles from './components/profiles/Profiles'; +import Profile from './components/profile/Profile'; +import Posts from './components/posts/Posts'; +import Post from './components/post/Post'; +import NotFound from './components/layout/NotFound'; +import PrivateRoute from './components/routing/PrivateRoute'; + +// Redux +import { Provider } from 'react-redux'; +import store from './store'; +import { loadUser } from './actions/auth'; +import setAuthToken from './utils/setAuthToken'; + +import './App.css'; +import { logOut } from './reducers/auth'; + +const App = () => { + useEffect(() => { + // check for token in LS when app first runs + if (localStorage.token) { + // if there is a token set axios headers for all requests + setAuthToken(localStorage.token); + } + // try to fetch a user, if no token or invalid token we + // will get a 401 response from our API + store.dispatch(loadUser()); + + // log user out from all tabs if they log out in one tab + window.addEventListener("storage", () => { + if (!localStorage.token) store.dispatch(logOut); + }); + }, []); + return ( + + + + + + } /> + } /> + } /> + } /> + } /> + } + /> + } + /> + } + /> + } + /> + } + /> + } /> + } /> + } /> + + + + ); + /* + return ( + + + + } /> + + + + ); + */ +}; + +export default App; diff --git a/client/src/actions/alert.ts b/client/src/actions/alert.ts new file mode 100755 index 0000000..6eb867e --- /dev/null +++ b/client/src/actions/alert.ts @@ -0,0 +1,10 @@ +import { v4 as uuidv4 } from 'uuid'; +import { removeAlert, setAlert } from '../reducers/alert'; +import { AppThunk } from '../types'; + +export const createAlert = (msg: string, alertType: string, timeout = 5000): AppThunk => dispatch => { + const id = uuidv4(); + dispatch(setAlert({ msg, alertType, id })); + + setTimeout(() => dispatch(removeAlert(id)), timeout); +}; diff --git a/client/src/actions/auth.ts b/client/src/actions/auth.ts new file mode 100755 index 0000000..47b2bce --- /dev/null +++ b/client/src/actions/auth.ts @@ -0,0 +1,74 @@ +import api from "../utils/api"; +import { createAlert } from "./alert"; +import { + loginSucces, + authError, + registerSuccess, + logOut, + userLoaded, +} from "../reducers/auth"; +import { AppThunk } from "../types"; +import { AxiosError, isAxiosError } from "axios"; + +export const login = (email: string, password: string): AppThunk => async (dispatch) => { + const body = { email, password }; + try { + const res = await api.post("/auth", body); + + dispatch(loginSucces(res.data)); + + dispatch(loadUser()); + } catch (err: unknown) { + if (err instanceof AxiosError || err instanceof Error) { + if (isAxiosError(err) && err.response !== undefined) { + const errors = err.response.data.errors; + if (errors) { + errors.forEach((error: any) => dispatch(createAlert(error.msg, "danger"))); + } + dispatch({ + type: "auth/loginFail", + }); + } + } + //normal err + } + // not error +} + +export const loadUser = (): AppThunk => async (dispatch) => { + try { + const res = await api.get("/auth"); + + dispatch(userLoaded(res.data)); + } catch (err) { + dispatch(authError()); + } +}; + +// Register User +export const register = (formData: { name: string, email: string, password: string }): AppThunk => async (dispatch) => { + try { + const res = await api.post("/users", formData); + + dispatch(registerSuccess(res.data)); + dispatch(loadUser()); + } catch (err: unknown) { + if (err instanceof AxiosError) { + if (err !== undefined && 'response' in err && err.response !== undefined) { + const errors = err.response.data.errors; + + if (errors) { + errors.forEach((error: any) => dispatch(createAlert(error.msg, "danger"))); + } + + dispatch({ + type: "auth/registerFail", + }); + } + }; + } +} + +export const logout = (): AppThunk => async (dispatch) => { + dispatch(logOut); +}; diff --git a/client/src/actions/post.ts b/client/src/actions/post.ts new file mode 100755 index 0000000..34cee11 --- /dev/null +++ b/client/src/actions/post.ts @@ -0,0 +1,157 @@ +import { + removeComment, + addCommentAction, + updateLikes, + postError, + deletePostAction, + getPostAction, + getPostsAction, + addPostAction, +} from "../reducers/post"; +import { AppThunk } from "../types"; +import api from "../utils/api"; +import { createAlert } from "./alert"; +import { AxiosError } from "axios"; + +// Get posts +export const getPosts = (): AppThunk => async (dispatch) => { + try { + const res = await api.get("/posts"); + + dispatch(getPostsAction(res.data)); + } catch (err: unknown) { + if (err instanceof AxiosError) { + if (err !== undefined && 'response' in err && err.response !== undefined) { + dispatch( + postError({ msg: err.response.statusText, status: err.response.status }) + ); + } + } + } +}; + +// Add like +export const addLike = (id: string): AppThunk => async (dispatch) => { + try { + const res = await api.put(`/posts/like/${id}`); + + dispatch(updateLikes({ id, likes: res.data })); + } catch (err: unknown) { + if (err instanceof AxiosError) { + if (err !== undefined && 'response' in err && err.response !== undefined) { + dispatch( + postError({ msg: err.response.statusText, status: err.response.status }) + ); + } + }; + } +} + +// Remove like +export const removeLike = (id: string): AppThunk => async (dispatch) => { + try { + const res = await api.put(`/posts/unlike/${id}`); + + dispatch(updateLikes({ id, likes: res.data })); + } catch (err: unknown) { + if (err instanceof AxiosError) { + if (err !== undefined && 'response' in err && err.response !== undefined) { + dispatch( + postError({ msg: err.response.statusText, status: err.response.status }) + ); + } + }; + + } +} + +// Delete post +export const deletePost = (id: string): AppThunk => async (dispatch) => { + try { + await api.delete(`/posts/${id}`); + + dispatch(deletePostAction(id)); + + dispatch(createAlert("Post Removed", "success")); + } catch (err: unknown) { + if (err instanceof AxiosError) { + if (err !== undefined && 'response' in err && err.response !== undefined) { + dispatch( + postError({ msg: err.response.statusText, status: err.response.status }) + ); + } + }; + + } +} +// Add post +export const addPost = (formData: { text: string }): AppThunk => async (dispatch) => { + try { + const res = await api.post("/posts", formData); + + dispatch(addPostAction(res.data)); + + dispatch(createAlert("Post Created", "success")); + } catch (err: unknown) { + if (err instanceof AxiosError) { + if (err !== undefined && 'response' in err && err.response !== undefined) { + dispatch( + postError({ msg: err.response.statusText, status: err.response.status }) + ); + } + } + }; +} + +// Get post +export const getPost = (id: string): AppThunk => async (dispatch) => { + try { + const res = await api.get(`/posts/${id}`); + + dispatch(getPostAction(res.data)); + } catch (err: unknown) { + if (err instanceof AxiosError) { + if (err !== undefined && 'response' in err && err.response !== undefined) { + dispatch( + postError({ msg: err.response.statusText, status: err.response.status }) + ); + } + }; + } +} +// Add comment +export const addComment = (postId: string, formData: { text: string }): AppThunk => async (dispatch) => { + try { + const res = await api.post(`/posts/comment/${postId}`, formData); + + dispatch(addCommentAction(res.data)); + + dispatch(createAlert("Comment Added", "success")); + } catch (err: unknown) { + if (err instanceof AxiosError) { + if (err !== undefined && 'response' in err && err.response !== undefined) { + dispatch( + postError({ msg: err.response.statusText, status: err.response.status }) + ); + } + }; + } +} +// Delete comment +export const deleteComment = (postId: string, commentId: string): AppThunk => async (dispatch) => { + try { + await api.delete(`/posts/comment/${postId}/${commentId}`); + + dispatch(removeComment(commentId)); + + dispatch(createAlert("Comment Removed", "success")); + } catch (err: unknown) { + if (err instanceof AxiosError) { + if (err !== undefined && 'response' in err && err.response !== undefined) { + dispatch( + postError({ msg: err.response.statusText, status: err.response.status }) + ); + } + }; + } +} diff --git a/client/src/actions/profile.ts b/client/src/actions/profile.ts new file mode 100755 index 0000000..25cc7aa --- /dev/null +++ b/client/src/actions/profile.ts @@ -0,0 +1,211 @@ +import api from "../utils/api"; +import { createAlert } from "./alert"; +import { + noRepos, + getRepos, + clearProfile, + profileError, + getProfilesType, + updateProfile, + getProfile, +} from "../reducers/profile"; +import { accountDeleted } from "../reducers/auth"; +import { AxiosError } from "axios"; +import { AppThunk, EducationType, ExperienceType } from "../types"; + +const errorHandle = (dispatch: any, err: unknown) => { + if (err instanceof AxiosError) { + if (err !== undefined && 'response' in err && err.response !== undefined) { + dispatch(profileError({ msg: err.response.statusText, status: err.response.status, }), err) + } + } +} +// Get current users profile +export const getCurrentProfile = (): AppThunk> => async (dispatch) => { + try { + const res = await api.get("/profile/me"); + + dispatch(getProfile(res.data)); + } catch (err: unknown) { + errorHandle(dispatch, err) + + } +} +// Get all profiles +export const getProfiles = (): AppThunk> => async (dispatch) => { + dispatch(clearProfile()); + try { + const res = await api.get("/profile"); + + dispatch(getProfilesType(res.data)); + } catch (err: unknown) { + errorHandle(dispatch, err) + } +}; + +// Get profile by ID +export const getProfileById = (userId: string): AppThunk> => async (dispatch) => { + try { + const res = await api.get(`/profile/user/${userId}`); + + dispatch(getProfile(res.data)); + } catch (err: unknown) { + errorHandle(dispatch, err) + } +}; + +// Get Github repos +export const getGithubRepos = (username: string): AppThunk> => async (dispatch) => { + try { + const res = await api.get(`/profile/github/${username}`); + + dispatch(getRepos(res.data)); + } catch (err: unknown) { + dispatch(noRepos()); + } +}; + +// Create or update profile +type FormDataType = { + company: string, + website: string, + location: string, + status: string, + skills: string, + githubusername: string, + bio: string, + twitter: string, + facebook: string, + linkedin: string, + youtube: string, + instagram: string, +} +export const createProfile = + (formData: FormDataType, edit = false): AppThunk> => + async (dispatch) => { + try { + const res = await api.post("/profile", formData); + + dispatch(getProfile(res.data)); + + dispatch( + createAlert(edit ? "Profile Updated" : "Profile Created", "success") + ); + + } catch (err: unknown) { + if (err instanceof AxiosError) { + if (err !== undefined && 'response' in err && err.response !== undefined) { + const errors = err.response.data.errors; + if (errors) { + errors.forEach((error: any) => dispatch(createAlert(error.msg, "danger"))); + } + dispatch( + profileError({ + msg: err.response.statusText, + status: err.response.status, + }) + ); + } + } + } + }; + +// Add Experience +export const addExperience = (formData: Omit): AppThunk> => async (dispatch) => { + try { + const res = await api.put("/profile/experience", formData); + + dispatch(updateProfile(res.data)); + + dispatch(createAlert("Experience Added", "success")); + + } catch (err: unknown) { + if (err instanceof AxiosError) { + if (err !== undefined && 'response' in err && err.response !== undefined) { + const errors = err.response.data.errors; + + if (errors) { + errors.forEach((error: any) => dispatch(createAlert(error.msg, "danger"))); + } + + dispatch( + profileError({ + msg: err.response.statusText, + status: err.response.status, + }) + ); + } + } + } +}; + +// Add Education +export const addEducation = (formData: Omit): AppThunk> => async (dispatch) => { + try { + const res = await api.put("/profile/education", formData); + + dispatch(updateProfile(res.data)); + + dispatch(createAlert("Education Added", "success")); + + } catch (err: unknown) { + if (err instanceof AxiosError) { + if (err !== undefined && 'response' in err && err.response !== undefined) { + const errors = err.response.data.errors; + + if (errors) { + errors.forEach((error: any) => dispatch(createAlert(error.msg, "danger"))); + } + + dispatch( + profileError({ + msg: err.response.statusText, + status: err.response.status, + }) + ); + } + } + } +}; + +// Delete experience +export const deleteExperience = (id: string): AppThunk> => async (dispatch) => { + try { + const res = await api.delete(`/profile/experience/${id}`); + + dispatch(updateProfile(res.data)); + + dispatch(createAlert("Experience Removed", "success")); + } catch (err: unknown) { + errorHandle(dispatch, err) + } +}; + +// Delete education +export const deleteEducation = (id: string): AppThunk> => async (dispatch) => { + try { + const res = await api.delete(`/profile/education/${id}`); + + dispatch(updateProfile(res.data)); + + dispatch(createAlert("Education Removed", "success")); + } catch (err: unknown) { + errorHandle(dispatch, err) + } +}; + +// Delete account & profile +export const deleteAccount = (): AppThunk> => async (dispatch) => { + if (window.confirm("Are you sure? This can NOT be undone!")) { + try { + await api.delete("/profile"); + + dispatch(clearProfile()); + dispatch(accountDeleted()); + + dispatch(createAlert("Your account has been permanently deleted", "danger")); + } catch (err: unknown) { + errorHandle(dispatch, err) + } + } +}; diff --git a/client/src/components/auth/Login.tsx b/client/src/components/auth/Login.tsx new file mode 100755 index 0000000..390afbe --- /dev/null +++ b/client/src/components/auth/Login.tsx @@ -0,0 +1,62 @@ +import React, { useState } from "react"; +import { Link, Navigate } from "react-router-dom"; +import { useAppDispatch, useAppSelector } from "../../utils/hooks"; +import { login } from "../../actions/auth"; + +const Login = () => { + const [email, setEmail] = useState('') + const [password, setPassword] = useState('') + const dispatch = useAppDispatch(); + const isAuthenticated = useAppSelector((state) => state.auth.isAuthenticated); + + const onChangeEmail = () => + setEmail(email) + const onChangePasword = () => + setPassword(password) + + const onSubmit = async (e: React.SyntheticEvent) => { + e.preventDefault(); + await dispatch(login(email, password)); + }; + + if (isAuthenticated) { + return ; + } + + return ( +
+

Sign In

+

+ Sign Into Your Account +

+
+
+ +
+
+ +
+ +
+

+ Don't have an account? Sign Up +

+
+ ); +}; + +export default Login; diff --git a/client/src/components/auth/Register.tsx b/client/src/components/auth/Register.tsx new file mode 100755 index 0000000..f6c0766 --- /dev/null +++ b/client/src/components/auth/Register.tsx @@ -0,0 +1,86 @@ +import React, { useState } from 'react'; +import { useAppDispatch, useAppSelector } from '../../utils/hooks'; +import { Link, Navigate } from 'react-router-dom'; +import { createAlert } from '../../actions/alert'; +import { register } from '../../actions/auth'; + + +const Register = () => { + const dispatch = useAppDispatch(); + const [name, setName] = useState('') + const [email, setEmail] = useState('') + const [password, setPassword] = useState('') + const [password2, setPassword2] = useState('') + + const isAuthenticated = useAppSelector(state => state.auth.isAuthenticated) + + const onSubmit = async (e: React.SyntheticEvent) => { + e.preventDefault(); + if (password !== password2) { + dispatch(createAlert('Passwords do not match', 'danger')); + } else { + dispatch(register({ name, email, password })); + } + }; + + if (isAuthenticated) { + return ; + } + + return ( +
+

Sign Up

+

+ Create Your Account +

+
+
+ setName(name)} + /> +
+
+ setEmail(email)} + /> + + This site uses Gravatar so if you want a profile image, use a + Gravatar email + +
+
+ setPassword(password)} + /> +
+
+ setPassword2(password2)} + /> +
+ +
+

+ Already have an account? Sign In +

+
+ ); +}; + +export default Register; diff --git a/client/src/components/dashboard/Dashboard.tsx b/client/src/components/dashboard/Dashboard.tsx new file mode 100755 index 0000000..733fb0e --- /dev/null +++ b/client/src/components/dashboard/Dashboard.tsx @@ -0,0 +1,53 @@ +import React, { useEffect } from "react"; +import { Link } from "react-router-dom"; +import { useAppDispatch, useAppSelector } from '../../utils/hooks'; +import DashboardActions from "./DashboardActions"; +import Experience from "./Experience"; +import Education from "./Education"; +import { getCurrentProfile, deleteAccount } from "../../actions/profile"; + +const Dashboard = () => { + const dispatch = useAppDispatch(); + useEffect(() => { + function fetchData() { + dispatch(getCurrentProfile()); + } + fetchData(); + }, [dispatch]); + const user = useAppSelector((state) => state.auth.user); + const profile = useAppSelector((state) => state.profile.profile); + return ( +
+

Dashboard

+

+ Welcome {user && user.name} +

+ {profile !== null ? ( + <> + + + + +
+ +
+ + ) : ( + <> +

You have not yet setup a profile, please add some info

+ + Create Profile + + + )} +
+ ); +}; + +export default Dashboard; diff --git a/client/src/components/dashboard/DashboardActions.tsx b/client/src/components/dashboard/DashboardActions.tsx new file mode 100755 index 0000000..3a63e5d --- /dev/null +++ b/client/src/components/dashboard/DashboardActions.tsx @@ -0,0 +1,20 @@ +import React from 'react'; +import { Link } from 'react-router-dom'; + +const DashboardActions = () => { + return ( +
+ + Edit Profile + + + Add Experience + + + Add Education + +
+ ); +}; + +export default DashboardActions; diff --git a/client/src/components/dashboard/Education.tsx b/client/src/components/dashboard/Education.tsx new file mode 100755 index 0000000..3c58a45 --- /dev/null +++ b/client/src/components/dashboard/Education.tsx @@ -0,0 +1,45 @@ +import React, { Fragment } from "react"; +import { deleteEducation } from "../../actions/profile"; +import formatDate from "../../utils/formatDate"; +import { EducationType } from "../../types"; +import { useAppDispatch } from "../../utils/hooks"; + +const Education = ({ education }: { education: EducationType[] }) => { + const dispatch = useAppDispatch(); + const educations = education.map((edu) => ( + + {edu.school} + {edu.degree} + + {formatDate(edu.from)} - {edu.to ? formatDate(edu.to) : "Now"} + + + + + + )); + + return ( + +

Education Credentials

+ + + + + + + + + {educations} +
SchoolDegreeYears +
+
+ ); +}; + +export default Education; diff --git a/client/src/components/dashboard/Experience.tsx b/client/src/components/dashboard/Experience.tsx new file mode 100755 index 0000000..055d57a --- /dev/null +++ b/client/src/components/dashboard/Experience.tsx @@ -0,0 +1,47 @@ +import React, { Fragment } from 'react'; +import { deleteExperience } from '../../actions/profile'; +import { ExperienceType } from '../../types'; +import formatDate from '../../utils/formatDate'; +import { useAppDispatch } from '../../utils/hooks'; + +const Experience = ({experience}:{experience:ExperienceType[]}) => { + const dispatch = useAppDispatch(); + const experiences = experience.map((exp) => ( + + {exp.company} + {exp.title} + + {formatDate(exp.from)} - {exp.to ? formatDate(exp.to) : 'Now'} + + + + + + )); + + return ( + +

Experience Credentials

+ + + + + + + + + {experiences} +
CompanyTitleYears +
+
+ ); +}; + + + +export default Experience; diff --git a/client/src/components/layout/Alert.tsx b/client/src/components/layout/Alert.tsx new file mode 100755 index 0000000..0de1fc6 --- /dev/null +++ b/client/src/components/layout/Alert.tsx @@ -0,0 +1,16 @@ +import React from "react"; +import { useAppSelector } from "../../utils/hooks"; +const Alert = () => { + const alerts = useAppSelector((state) => state.alert); + return ( +
+ {alerts.map((alert) => ( +
+ {alert.msg} +
+ ))} +
+ ); +}; + +export default Alert; diff --git a/client/src/components/layout/Landing.tsx b/client/src/components/layout/Landing.tsx new file mode 100755 index 0000000..a211499 --- /dev/null +++ b/client/src/components/layout/Landing.tsx @@ -0,0 +1,34 @@ +import React from "react"; +import { Link, Navigate } from "react-router-dom"; +import { useAppSelector } from "../../utils/hooks"; + +const Landing = () => { + const isAuthenticated = useAppSelector((state) => state.auth.isAuthenticated); + if (isAuthenticated) { + return ; + } + + return ( +
+
+
+

Developer Connector

+

+ Create a developer profile/portfolio, share posts and get help from + other developers +

+
+ + Sign Up + + + Login + +
+
+
+
+ ); +}; + +export default Landing; diff --git a/client/src/components/layout/Navbar.tsx b/client/src/components/layout/Navbar.tsx new file mode 100755 index 0000000..701c8f3 --- /dev/null +++ b/client/src/components/layout/Navbar.tsx @@ -0,0 +1,57 @@ +import React, { Fragment } from "react"; +import { Link } from "react-router-dom"; +import { logOut } from "../../reducers/auth"; +import { useAppDispatch, useAppSelector } from "../../utils/hooks"; + +const Navbar = () => { + const isAuthenticated = useAppSelector((state) => state.auth.isAuthenticated); + const dispatch = useAppDispatch(); + const authLinks = ( + + ); + + const guestLinks = ( +
    +
  • + Developers +
  • +
  • + Register +
  • +
  • + Login +
  • +
+ ); + + return ( + + ); +}; + +export default Navbar; diff --git a/client/src/components/layout/NotFound.tsx b/client/src/components/layout/NotFound.tsx new file mode 100755 index 0000000..7a243da --- /dev/null +++ b/client/src/components/layout/NotFound.tsx @@ -0,0 +1,14 @@ +import React from 'react'; + +const NotFound = () => { + return ( +
+

+ Page Not Found +

+

Sorry, this page does not exist

+
+ ); +}; + +export default NotFound; diff --git a/client/src/components/layout/Spinner.tsx b/client/src/components/layout/Spinner.tsx new file mode 100755 index 0000000..c551308 --- /dev/null +++ b/client/src/components/layout/Spinner.tsx @@ -0,0 +1,14 @@ +import React, { Fragment } from 'react'; +import spinner from './spinner.gif'; + +const Spinner = () => ( + + Loading... + +); + +export default Spinner; diff --git a/client/src/components/post/CommentForm.tsx b/client/src/components/post/CommentForm.tsx new file mode 100755 index 0000000..a0f47b0 --- /dev/null +++ b/client/src/components/post/CommentForm.tsx @@ -0,0 +1,39 @@ +import React, { useState } from 'react'; +import { addComment } from '../../actions/post'; +import { useAppDispatch } from '../../utils/hooks'; + +const CommentForm = ({ postId }: { postId: string }) => { + const [text, setText] = useState(''); + const dispatch = useAppDispatch(); + + return ( +
+
+

Leave a Comment

+
+
{ + e.preventDefault(); + await dispatch(addComment(postId, { text })); + setText(''); + }} + > +