├── client ├── .env.example ├── .gitignore ├── README.md ├── package-lock.json ├── package.json ├── public │ ├── favicon.ico │ ├── index.html │ ├── logo192.png │ ├── logo512.png │ ├── manifest.json │ └── robots.txt └── src │ ├── App.js │ ├── components │ ├── misc │ │ ├── ErrorPage.jsx │ │ ├── ForceRedirect.jsx │ │ ├── LoadingScreen.jsx │ │ ├── LocalizeEnglish.jsx │ │ ├── LogoutPrompt.jsx │ │ └── ProfileRedirect.jsx │ ├── profiles │ │ ├── FriendList.jsx │ │ ├── Profile.jsx │ │ └── UserSettings.jsx │ ├── signin │ │ ├── Homepage.jsx │ │ ├── LoginForm.jsx │ │ ├── RegisterForm.jsx │ │ └── utils │ │ │ └── validateRegister.js │ └── user │ │ ├── Comment.jsx │ │ ├── CommentForm.jsx │ │ ├── CommentList.jsx │ │ ├── Dashboard.jsx │ │ ├── FriendRequests.jsx │ │ ├── FullPost.jsx │ │ ├── Navbar.jsx │ │ ├── NavbarOptions.jsx │ │ ├── Post.jsx │ │ ├── PostBox.jsx │ │ ├── PostBoxContainer.jsx │ │ ├── PostList.jsx │ │ ├── SearchBar.jsx │ │ └── SearchResults.jsx │ ├── images │ ├── favicon.svg │ ├── logo.svg │ └── no-avatar.png │ ├── index.css │ ├── index.js │ └── localization.js └── server ├── .env.example ├── .gitignore ├── README.md ├── package-lock.json ├── package.json └── src ├── app.js ├── config └── passport.config.js ├── controllers ├── auth.js ├── commentController.js ├── friendRequestController.js ├── postController.js └── userController.js ├── models ├── Comment.js ├── FriendRequest.js ├── Post.js └── User.js ├── routes └── api.js └── tests ├── comments.test.js ├── friendrequests.test.js ├── jest.config.js ├── mongo.config.js ├── posts.test.js └── users.test.js /client/.env.example: -------------------------------------------------------------------------------- 1 | REACT_APP_API_URL='theURLofYourApi' 2 | REACT_APP_MAIN_URL='theURLofThisReactApp' -------------------------------------------------------------------------------- /client/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env 17 | .env.local 18 | .env.development.local 19 | .env.test.local 20 | .env.production.local 21 | 22 | npm-debug.log* 23 | yarn-debug.log* 24 | yarn-error.log* 25 | -------------------------------------------------------------------------------- /client/README.md: -------------------------------------------------------------------------------- 1 | # Project: Facebook clone || Front-End 2 | Made for The Odin Project. 3 | 4 | ## About 5 | This project was made for a course of Node.js from The Odin Project. By working on this project, I could: 6 | * Handle a diverse amount of problems and situations 7 | * Secure a rest-ish API against CSRF using JWT 8 | * Learn more about acessibility 9 | * Work with React hooks such as useState and useEffect 10 | 11 | ## Getting Started 12 | Follow these instructions to set up the project locally. 13 | 14 | ### Prerequisites 15 | * npm 16 | * Node.js 17 | 18 | ### Installation 19 | 1. Get a free Mongo database at [the official website](https://www.mongodb.com/cloud/atlas) 20 | 2. Clone the repo 21 | 3. Install dependencies with NPM 22 | 23 | ``` 24 | npm install 25 | ``` 26 | 27 | 4. Set the environment variables necessary for this project to work. All of them are specified on the .env.example file. 28 | 29 | 5. Run `npm start` to start the React server. -------------------------------------------------------------------------------- /client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "client", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@fortawesome/fontawesome-free": "^5.15.1", 7 | "@testing-library/jest-dom": "^4.2.4", 8 | "@testing-library/react": "^9.5.0", 9 | "@testing-library/user-event": "^7.2.1", 10 | "axios": "^0.21.0", 11 | "fake-email": "0.0.3", 12 | "js-cookie": "^2.2.1", 13 | "react": "^17.0.0", 14 | "react-dom": "^17.0.0", 15 | "react-router-dom": "^5.2.0", 16 | "react-router-redirect": "^1.0.1", 17 | "react-scripts": "3.4.4" 18 | }, 19 | "scripts": { 20 | "start": "react-scripts start", 21 | "build": "react-scripts build", 22 | "test": "react-scripts test", 23 | "eject": "react-scripts eject" 24 | }, 25 | "eslintConfig": { 26 | "extends": "react-app" 27 | }, 28 | "browserslist": { 29 | "production": [ 30 | ">0.2%", 31 | "not dead", 32 | "not op_mini all" 33 | ], 34 | "development": [ 35 | "last 1 chrome version", 36 | "last 1 firefox version", 37 | "last 1 safari version" 38 | ] 39 | }, 40 | "devDependencies": { 41 | "gh-pages": "^3.1.0" 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /client/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lucaskenji/odinclone-app/2868d6dd7f207e5f03ecc86db58503e8ad50c432/client/public/favicon.ico -------------------------------------------------------------------------------- /client/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 17 | 18 | 27 | React App 28 | 29 | 30 | 31 |
32 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /client/public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lucaskenji/odinclone-app/2868d6dd7f207e5f03ecc86db58503e8ad50c432/client/public/logo192.png -------------------------------------------------------------------------------- /client/public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lucaskenji/odinclone-app/2868d6dd7f207e5f03ecc86db58503e8ad50c432/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.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import './index.css'; 3 | import '@fortawesome/fontawesome-free/css/fontawesome.min.css'; 4 | import '@fortawesome/fontawesome-free/css/solid.min.css'; 5 | import '@fortawesome/fontawesome-free/css/regular.min.css'; 6 | import axios from 'axios'; 7 | import { 8 | BrowserRouter as Router, 9 | Route, 10 | Switch 11 | } from 'react-router-dom'; 12 | 13 | import RegisterForm from './components/signin/RegisterForm'; 14 | import Profile from './components/profiles/Profile'; 15 | import FriendList from './components/profiles/FriendList'; 16 | import FriendRequests from './components/user/FriendRequests'; 17 | import LogoutPrompt from './components/misc/LogoutPrompt'; 18 | import Dashboard from './components/user/Dashboard'; 19 | import FullPost from './components/user/FullPost'; 20 | import SearchResults from './components/user/SearchResults'; 21 | import Navbar from './components/user/Navbar'; 22 | import ErrorPage from './components/misc/ErrorPage'; 23 | import ForceRedirect from './components/misc/ForceRedirect'; 24 | import ProfileRedirect from './components/misc/ProfileRedirect'; 25 | import UserSettings from './components/profiles/UserSettings'; 26 | import LoadingScreen from './components/misc/LoadingScreen'; 27 | import LocalizeEnglish from './components/misc/LocalizeEnglish'; 28 | 29 | class App extends React.Component { 30 | state = { 31 | loading: true, 32 | isLogged: false, 33 | loggedUserId: '' 34 | } 35 | 36 | verifyAuth = () => { 37 | return new Promise((resolve, reject) => { 38 | axios.get(`${process.env.REACT_APP_API_URL}/api/islogged`, { withCredentials: true }) 39 | .then((response) => { 40 | this.setState({ 41 | isLogged: response.data.isLogged, 42 | loading: false, 43 | loggedUserId: response.data.isLogged ? response.data.id : '' 44 | }); 45 | 46 | resolve(); 47 | }) 48 | .catch((err) => { 49 | this.setState({ 50 | isLogged: false, 51 | loading: false, 52 | loggedUserId: '' 53 | }); 54 | reject(); 55 | }); 56 | }); 57 | } 58 | 59 | finishLoading = () => { 60 | this.setState({ loading: false }); 61 | } 62 | 63 | render() { 64 | return ( 65 |
66 | 67 | 68 | {this.state.isLogged && } 69 | 70 | 71 | 72 | 73 | } 77 | /> 78 | } 82 | /> 83 | } 87 | /> 88 | } 92 | /> 93 | } 97 | /> 98 | } 102 | /> 103 | } 107 | /> 108 | } 112 | /> 113 | } 117 | /> 118 | } 122 | /> 123 | } 127 | /> 128 | } 132 | /> 133 | } 140 | /> 141 | 142 | 143 |
144 | ); 145 | } 146 | } 147 | 148 | 149 | export default App; 150 | -------------------------------------------------------------------------------- /client/src/components/misc/ErrorPage.jsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from 'react'; 2 | 3 | function ErrorPage(props) { 4 | const { finishLoading } = props; 5 | 6 | useEffect(() => { 7 | finishLoading(); 8 | }, [finishLoading]) 9 | 10 | return ( 11 |
12 |

{ props.errorTitle }

13 | {props.errorMessage} 14 |
15 | ); 16 | } 17 | 18 | export default ErrorPage; -------------------------------------------------------------------------------- /client/src/components/misc/ForceRedirect.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Redirect } from 'react-router-dom'; 3 | import Cookies from 'js-cookie'; 4 | 5 | function ForceRedirect(props) { 6 | // Previously used to set user id and csrf token on localStorage, and now only CSRF token. 7 | 8 | localStorage.setItem('csrfToken', Cookies.get('CSRF')); 9 | return (); 10 | } 11 | 12 | export default ForceRedirect; -------------------------------------------------------------------------------- /client/src/components/misc/LoadingScreen.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import localStrings from '../../localization'; 3 | 4 | function LoadingScreen(props) { 5 | const { loading } = props.state; 6 | const isLoadingHomepage = window.location.href === process.env.REACT_APP_MAIN_URL; 7 | const locale = localStorage.getItem('localizationCode') === 'en-US' ? 'en-US' : 'pt-BR'; 8 | 9 | return ( 10 |
11 |
12 | 13 |
14 | { isLoadingHomepage ? localStrings[locale]['loading']['mainPage'] : localStrings[locale]['loading']['normal'] } 15 |
16 | ); 17 | } 18 | 19 | export default LoadingScreen; -------------------------------------------------------------------------------- /client/src/components/misc/LocalizeEnglish.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Redirect } from 'react-router-dom'; 3 | 4 | function LocalizeEnglish(props) { 5 | localStorage.setItem('localizationCode', 'en-US'); 6 | 7 | return (); 8 | } 9 | 10 | export default LocalizeEnglish; -------------------------------------------------------------------------------- /client/src/components/misc/LogoutPrompt.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import { Redirect } from 'react-router-dom'; 3 | import axios from 'axios'; 4 | 5 | function LogoutPrompt(props) { 6 | const [loggedOut, setLoggedOut] = useState(false); 7 | const [updatedState, setUpdatedState] = useState(false); 8 | const { verifyAuth } = props; 9 | 10 | useEffect(() => { 11 | if (!loggedOut) { 12 | axios.get(`${process.env.REACT_APP_API_URL}/api/logout`, { withCredentials: true }) 13 | .then((response) => { 14 | setLoggedOut(true); 15 | }) 16 | .catch((err) => { 17 | // It's hard for this route to fail, but a connection error would probably logout the user anyway 18 | setLoggedOut(true); 19 | }) 20 | } else { 21 | verifyAuth() 22 | .then(() => { 23 | setUpdatedState(true); 24 | }) 25 | .catch((err) => { 26 | // It's hard for verifyAuth' route to fail, but a connection error would probably logout the user anyway 27 | setUpdatedState(true); 28 | }) 29 | } 30 | }, [loggedOut, verifyAuth]); 31 | 32 | if (loggedOut && updatedState) { 33 | return 34 | } else { 35 | return 'Logging out...'; 36 | } 37 | } 38 | 39 | export default LogoutPrompt; -------------------------------------------------------------------------------- /client/src/components/misc/ProfileRedirect.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useParams, Redirect } from 'react-router-dom'; 3 | 4 | function ProfileRedirect(props) { 5 | const { profileId } = useParams(); 6 | return () 7 | }; 8 | 9 | export default ProfileRedirect; -------------------------------------------------------------------------------- /client/src/components/profiles/FriendList.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import { useParams, Redirect, Link } from 'react-router-dom'; 3 | import axios from 'axios'; 4 | import noAvatar from '../../images/no-avatar.png'; 5 | import localStrings from '../../localization'; 6 | 7 | function FriendList(props) { 8 | const { verifyAuth } = props; 9 | const { userId } = useParams(); 10 | const [friends, setFriends] = useState([]); 11 | const [displayedFriends, setDisplayedFriends] = useState([]); 12 | const [isUnmounted, setIsUnmounted] = useState(false); 13 | const locale = localStorage.getItem('localizationCode') === 'en-US' ? 'en-US' : 'pt-BR'; 14 | 15 | useEffect(() => { 16 | return () => {setIsUnmounted(true)}; 17 | }, [verifyAuth, userId]) 18 | 19 | useEffect(() => { 20 | verifyAuth(); 21 | }, [verifyAuth]); 22 | 23 | useEffect(() => { 24 | axios.get(`${process.env.REACT_APP_API_URL}/api/users/${userId}`) 25 | .then((response) => { 26 | if (!isUnmounted) { 27 | setFriends(response.data.friends); 28 | setDisplayedFriends(response.data.friends); 29 | } 30 | }) 31 | .catch((err) => { 32 | // Routes are tested and a connection error would redirect the user 33 | }) 34 | }, [userId, isUnmounted]); 35 | 36 | const handleSearch = (ev) => { 37 | const query = new RegExp(ev.target.value, 'i'); 38 | const results = [...friends].filter((f) => query.test(f.firstName) || query.test(f.lastName)); 39 | 40 | setDisplayedFriends(results); 41 | } 42 | 43 | if (props.state.loading) { 44 | return 'Loading...'; 45 | } else if (!props.state.isLogged) { 46 | return (); 47 | } else { 48 | return ( 49 |
50 | 51 |
52 |

{localStrings[locale]['friendList']['header']}

53 | 68 |
69 | 70 |
71 | { 72 | displayedFriends.length === 0 && localStrings[locale]['friendList']['noFriends'] 73 | } 74 | 75 | { 76 | displayedFriends.map((friend) => 77 |
78 | 79 | {localStrings[locale]['friendList']['alt']['avatar']} 80 | 81 | 82 | {friend.firstName} {friend.lastName} 83 | 84 |
) 85 | } 86 |
87 |
88 | ); 89 | } 90 | } 91 | 92 | export default FriendList; -------------------------------------------------------------------------------- /client/src/components/profiles/Profile.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import { useParams, Redirect } from 'react-router-dom'; 3 | import axios from 'axios'; 4 | import PostList from '../user/PostList'; 5 | import noAvatar from '../../images/no-avatar.png'; 6 | import ErrorPage from '../misc/ErrorPage'; 7 | import localStrings from '../../localization'; 8 | 9 | function Profile(props) { 10 | const [user, setUser] = useState(null); 11 | const [isSameUser, setIsSameUser] = useState(false); 12 | const [isFriend, setIsFriend] = useState(false); 13 | const [friendRequestId, setFriendRequestId] = useState(''); 14 | const [finishedAsync, setFinishedAsync] = useState(true); 15 | const [requestedUser, setRequestedUser] = useState(false); 16 | const [errorMessage, setErrorMessage] = useState(''); 17 | const [fatalError, setFatalError] = useState(false); 18 | const [isUnmounted, setIsUnmounted] = useState(false); 19 | const { userId } = useParams(); 20 | const { verifyAuth } = props; 21 | const { loggedUserId } = props.state; 22 | const locale = localStorage.getItem('localizationCode') === 'en-US' ? 'en-US' : 'pt-BR'; 23 | 24 | useEffect(() => { 25 | return () => { setIsUnmounted(true) } 26 | }, []); 27 | 28 | useEffect(() => { 29 | verifyAuth(); 30 | }, [verifyAuth]); 31 | 32 | useEffect(() => { 33 | if (!loggedUserId) { 34 | return; 35 | } 36 | 37 | axios.get(`${process.env.REACT_APP_API_URL}/api/users/${userId}`) 38 | .then((response) => { 39 | if (!isUnmounted) { 40 | setUser({ 41 | firstName: response.data.firstName, 42 | lastName: response.data.lastName, 43 | birthDate: new Date(response.data.birthDate), 44 | gender: response.data.gender, 45 | friends: response.data.friends, 46 | photo: response.data.photo 47 | }); 48 | } 49 | 50 | const friendIds = [...response.data.friends].map((friend) => friend._id); 51 | 52 | if (friendIds.indexOf(loggedUserId) !== -1 && !isUnmounted) { 53 | setIsFriend(true); 54 | } 55 | }) 56 | .catch((err) => { 57 | setFatalError(true); 58 | }) 59 | }, [userId, isUnmounted, loggedUserId]); 60 | 61 | useEffect(() => { 62 | if (!loggedUserId) { 63 | return; 64 | } 65 | 66 | axios.get(`${process.env.REACT_APP_API_URL}/api/users/${userId}/friendrequests`) 67 | .then((response) => { 68 | const requestFromUser = response.data.find((request) => request.sender._id === loggedUserId); 69 | 70 | if (!isUnmounted) { 71 | if (requestFromUser == null) { 72 | setFriendRequestId(''); 73 | } else { 74 | setFriendRequestId(requestFromUser._id); 75 | } 76 | } 77 | }) 78 | .catch((err) => { 79 | if (err.response) { 80 | if (err.response.status === 404) { 81 | return; 82 | } 83 | } 84 | 85 | if (!isUnmounted) { 86 | setFatalError(true); 87 | } 88 | }) 89 | }, [userId, isUnmounted, loggedUserId]); 90 | 91 | useEffect(() => { 92 | if (!loggedUserId) { 93 | return; 94 | } 95 | 96 | axios.get(`${process.env.REACT_APP_API_URL}/api/users/${loggedUserId}/friendrequests`) 97 | .then((response) => { 98 | const requestFromUser = response.data.find((request) => request.sender._id === userId); 99 | 100 | if (!isUnmounted) { 101 | if (requestFromUser == null) { 102 | setRequestedUser(false); 103 | } else { 104 | setRequestedUser(true); 105 | } 106 | } 107 | }) 108 | .catch((err) => { 109 | if (err.response) { 110 | if (err.response.status === 404) { 111 | return; 112 | } 113 | } 114 | 115 | if (!isUnmounted) { 116 | setFatalError(true); 117 | } 118 | }) 119 | }, [userId, isUnmounted, loggedUserId]); 120 | 121 | useEffect(() => { 122 | if (loggedUserId) { 123 | if (loggedUserId === userId && !isUnmounted) { 124 | setIsSameUser(true); 125 | } 126 | } 127 | }, [loggedUserId, userId, isUnmounted]); 128 | 129 | const handleSendRequest = () => { 130 | setFinishedAsync(false); 131 | setErrorMessage(''); 132 | 133 | const newRequest = { 134 | sender: loggedUserId, 135 | receiver: userId 136 | }; 137 | 138 | const csrfToken = localStorage.getItem('csrfToken'); 139 | 140 | axios.post(`${process.env.REACT_APP_API_URL}/api/friendrequests`, newRequest, { withCredentials: true, headers: {csrf: csrfToken} }) 141 | .then((response) => { 142 | if (!isUnmounted) { 143 | setFriendRequestId(response.data._id); 144 | setFinishedAsync(true); 145 | } 146 | }) 147 | .catch((err) => { 148 | if (!isUnmounted) { 149 | setErrorMessage(localStrings[locale]['profile']['error']['internal']); 150 | } 151 | }) 152 | } 153 | 154 | const handleCancelRequest = () => { 155 | setFinishedAsync(false); 156 | setErrorMessage(''); 157 | 158 | axios.delete(`${process.env.REACT_APP_API_URL}/api/friendrequests/${friendRequestId}`) 159 | .then((response) => { 160 | if (!isUnmounted) { 161 | setFriendRequestId(''); 162 | setFinishedAsync(true); 163 | } 164 | }) 165 | .catch((err) => { 166 | if (!isUnmounted) { 167 | setErrorMessage(localStrings[locale]['profile']['error']['internal']); 168 | } 169 | }) 170 | } 171 | 172 | const handleUnfriend = async () => { 173 | setFinishedAsync(false); 174 | setErrorMessage(''); 175 | 176 | try { 177 | await axios.put(`${process.env.REACT_APP_API_URL}/api/users/${loggedUserId}/unfriend`, { _id: userId }); 178 | await axios.put(`${process.env.REACT_APP_API_URL}/api/users/${userId}/unfriend`, { _id: loggedUserId }); 179 | 180 | const newUser = {...user}; 181 | newUser.friends = newUser.friends.filter((friend) => friend._id.toString() !== loggedUserId); 182 | 183 | if (!isUnmounted) { 184 | setIsFriend(false); 185 | setUser(newUser); 186 | setFinishedAsync(true); 187 | } 188 | } catch (err) { 189 | if (!isUnmounted) { 190 | setErrorMessage(localStrings[locale]['profile']['error']['internal']); 191 | } 192 | } 193 | } 194 | 195 | const acceptRequest = () => { 196 | window.location.href = '/requests'; 197 | } 198 | 199 | if (fatalError) { 200 | return ( 201 | 205 | ); 206 | } 207 | 208 | if (props.state.loading) { 209 | return 'Loading auth...'; 210 | } else if (!props.state.isLogged) { 211 | return (); 212 | } else if (user) { 213 | return ( 214 |
215 |
216 |
217 | {localStrings[locale]['profile']['alt']['avatar']} 218 | 219 |
220 |

{user.firstName} {user.lastName}

221 | {localStrings[locale]['profile']['bornPrefix']} {user.birthDate.toLocaleDateString(locale)},  222 | { 223 | user.gender === 'undefined' 224 | ? localStrings[locale]['profile']['noGender'] 225 | : user.gender === 'male' 226 | ? localStrings[locale]['profile']['male'] 227 | : user.gender === 'female' 228 | ? localStrings[locale]['profile']['female'] 229 | : localStrings[locale]['profile']['other'] 230 | } 231 |
232 | 233 | 234 | { 235 | isSameUser 236 | ? '' 237 | : ( isFriend 238 | ? 241 | : ( requestedUser 242 | ? 245 | : ( friendRequestId 246 | ? 249 | : 252 | ) 253 | ) 254 | ) 255 | } 256 | 257 | { errorMessage && {errorMessage} } 258 |
259 | 260 |
261 | 262 |
263 |
264 |
265 |

{localStrings[locale]['profile']['headerFriends']}

266 | {localStrings[locale]['profile']['showAllFriends']} 267 |
268 | 269 |
270 | { user.friends.map((friend) => 271 | 272 |
273 | {localStrings[locale]['profile']['alt']['avatarFriend']} 274 | {friend.firstName} {friend.lastName} 275 |
276 |
)} 277 |
278 |
279 | 280 |
281 |
282 | 283 | 284 | 285 |
286 |
287 | ); 288 | } else { 289 | return 'Loading user...'; 290 | } 291 | } 292 | 293 | export default Profile; -------------------------------------------------------------------------------- /client/src/components/profiles/UserSettings.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import axios from 'axios'; 3 | import { Redirect } from 'react-router-dom'; 4 | import { validateName, validateEmail, validatePassword } from '../signin/utils/validateRegister'; 5 | import localStrings from '../../localization'; 6 | 7 | function UserSettings(props) { 8 | const { verifyAuth } = props; 9 | const { loggedUserId } = props.state; 10 | const [finishedAsync, setFinishedAsync] = useState(true); 11 | const [user, setUser] = useState({}); 12 | const [photoUrl, setPhotoUrl] = useState(''); 13 | const [urlIsValid, setUrlIsValid] = useState(true); 14 | const [validatedUrl, setValidatedUrl] = useState(true); 15 | const [errorMessage, setErrorMessage] = useState(''); 16 | const locale = localStorage.getItem('localizationCode') === 'en-US' ? 'en-US' : 'pt-BR'; 17 | 18 | useEffect(() => { 19 | verifyAuth(); 20 | }, [ verifyAuth ]); 21 | 22 | useEffect(() => { 23 | if (loggedUserId) { 24 | axios.get(`${process.env.REACT_APP_API_URL}/api/users/${loggedUserId}`) 25 | .then((response) => { 26 | const userWithStringData = {...response.data}; 27 | userWithStringData.birthDate = new Date(response.data.birthDate); 28 | setUser(userWithStringData); 29 | }) 30 | .catch((err) => { 31 | // Routes are tested and a connection error would redirect the user anyway 32 | }) 33 | } 34 | }, [loggedUserId]) 35 | 36 | 37 | const requestUpdate = (form) => { 38 | form.preventDefault(); 39 | setFinishedAsync(false); 40 | setErrorMessage(''); 41 | 42 | // Checks validation 43 | if (!validatedUrl || !urlIsValid) { 44 | setFinishedAsync(true); 45 | return; 46 | } 47 | 48 | const fields = form.target; 49 | const birthDate = new Date(fields.birthYear.value, fields.birthMonth.value, fields.birthDay.value); 50 | 51 | if (validateName(fields.firstName).valid === false || validateName(fields.lastName).valid === false) { 52 | setFinishedAsync(true); 53 | return; 54 | } 55 | 56 | if (!user.facebookId) { 57 | if (validateEmail(fields.email).valid === false || validatePassword(fields.password).valid === false) { 58 | setFinishedAsync(true); 59 | return; 60 | } 61 | } 62 | 63 | // Updates 64 | const updatedUser = { 65 | firstName: fields.firstName.value, 66 | lastName: fields.lastName.value, 67 | photo: fields.photoUrl.value, 68 | birthDate, 69 | gender: fields.gender.value 70 | }; 71 | 72 | if (!user.facebookId) { 73 | updatedUser.email = fields.email.value; 74 | updatedUser.password = fields.password.value; 75 | } 76 | 77 | axios.put(`${process.env.REACT_APP_API_URL}/api/users/${loggedUserId}`, updatedUser) 78 | .then((response) => { 79 | setFinishedAsync(true); 80 | window.location.href='/profile/' + loggedUserId; 81 | }) 82 | .catch((err) => { 83 | if (err.response) { 84 | if (err.response.status === 409) { 85 | setErrorMessage(localStrings[locale]['settings']['error']['emailConflict']); 86 | } else { 87 | setErrorMessage(localStrings[locale]['settings']['error']['invalidData']); 88 | } 89 | } else { 90 | setErrorMessage(localStrings[locale]['settings']['error']['internal']); 91 | } 92 | setFinishedAsync(true); 93 | }) 94 | } 95 | 96 | const handleInputUrl = (input) => { 97 | setValidatedUrl(false); 98 | setPhotoUrl(input.target.value); 99 | } 100 | 101 | const handleLoadImage = () => { 102 | setUrlIsValid(true); 103 | setValidatedUrl(true); 104 | } 105 | 106 | const handleErrorImage = () => { 107 | setValidatedUrl(true); 108 | 109 | if (photoUrl.length === 0) { 110 | setUrlIsValid(true); 111 | return; 112 | } 113 | 114 | setUrlIsValid(false); 115 | } 116 | 117 | // All years available on the birth year validateName(ev.target)} 150 | /> 151 | 152 | 153 | validateName(ev.target)} 162 | /> 163 | 164 | { 165 | user.facebookId ? '' : 166 | 167 | 168 | validateEmail(ev.target)} 177 | /> 178 | 179 | 180 | validatePassword(ev.target)} 188 | /> 189 | 190 | } 191 | 192 | 200 | 201 | { urlIsValid 202 | || 203 |
204 | {localStrings[locale]['settings']['error']['invalidUrl']} 205 |
} 206 | 207 | 208 | 218 | 219 |
220 | {localStrings[locale]['settings']['birthDate']} 221 | 222 | 232 | 233 | 245 | 246 | 257 |
258 | 259 |
260 | {localStrings[locale]['settings']['gender']} 261 | 271 | 281 | 291 |
292 | 293 | 296 | 297 |
298 | ); 299 | } 300 | } 301 | 302 | export default UserSettings; -------------------------------------------------------------------------------- /client/src/components/signin/Homepage.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import LoginForm from './LoginForm'; 3 | import Logo from '../../images/logo.svg'; 4 | import localStrings from '../../localization'; 5 | 6 | function Homepage(props) { 7 | const locale = localStorage.getItem('localizationCode') === 'en-US' ? 'en-US' : 'pt-BR'; 8 | 9 | const setLocalization = (localizationCode) => { 10 | localStorage.setItem('localizationCode', localizationCode); 11 | window.location.href = "/"; 12 | } 13 | 14 | return ( 15 |
16 |
17 |
18 |
19 | 20 |

21 | {localStrings[locale]['homepage']['homepageDesc']} 22 |

23 |
24 | 25 |
26 |
27 | 50 |
51 | ); 52 | } 53 | 54 | export default Homepage; -------------------------------------------------------------------------------- /client/src/components/signin/LoginForm.jsx: -------------------------------------------------------------------------------- 1 | import React, {useState} from 'react'; 2 | import { Link } from 'react-router-dom'; 3 | import axios from 'axios'; 4 | import localStrings from '../../localization'; 5 | import fakeEmail from 'fake-email'; 6 | 7 | function LoginForm(props) { 8 | const [finishedAsync, setFinishedAsync] = useState(true); 9 | const [errorMessage, setErrorMessage] = useState(''); 10 | const locale = localStorage.getItem('localizationCode') === 'en-US' ? 'en-US' : 'pt-BR'; 11 | 12 | const handleLogin = (form) => { 13 | form.preventDefault(); 14 | setFinishedAsync(false); 15 | setErrorMessage(''); 16 | 17 | const email = form.target.email.value; 18 | const password = form.target.password.value; 19 | 20 | axios.post(`${process.env.REACT_APP_API_URL}/api/login`, { email, password }, { withCredentials: true }) 21 | .then((response) => { 22 | localStorage.setItem('csrfToken', response.headers.csrf); 23 | window.location.href = '/'; 24 | }) 25 | .catch((err) => { 26 | if (err.response) { 27 | setErrorMessage(localStrings[locale]['homepage']['error']['badRequest']); 28 | } else { 29 | setErrorMessage(localStrings[locale]['homepage']['error']['internal']); 30 | } 31 | 32 | setFinishedAsync(true); 33 | }) 34 | } 35 | 36 | const authenticateGuest = () => { 37 | setFinishedAsync(false); 38 | setErrorMessage(''); 39 | 40 | const birthDate = new Date(); 41 | const emailIdentifier = birthDate.toJSON().replace(/[-:.ZT]/g, ''); 42 | const guestEmail = fakeEmail(emailIdentifier) 43 | 44 | const newUser = { 45 | firstName: 'Guest', 46 | lastName: 'account', 47 | email: guestEmail, 48 | password: 'guest', 49 | birthDate, 50 | gender: 'other', 51 | isGuest: true 52 | }; 53 | 54 | axios.post(`${process.env.REACT_APP_API_URL}/api/users`, newUser) 55 | .then((registerResponse) => { 56 | axios.post(`${process.env.REACT_APP_API_URL}/api/login`, { email: guestEmail, password: 'guest' }, { withCredentials: true }) 57 | .then((loginResponse) => { 58 | localStorage.setItem('csrfToken', loginResponse.headers.csrf); 59 | window.location.href = '/'; 60 | }) 61 | .catch((err) => { 62 | setErrorMessage(localStrings[locale]['homepage']['error']['internal']); 63 | setFinishedAsync(true); 64 | }) 65 | }) 66 | .catch((err) => { 67 | setErrorMessage(localStrings[locale]['register']['error']['internal']); 68 | setFinishedAsync(true); 69 | }) 70 | } 71 | 72 | return ( 73 |
74 | { errorMessage &&
{errorMessage}
} 75 | 76 |
77 | 78 | 86 |
87 | 88 | 89 | 97 |
98 |
99 | 100 | 103 |
104 |
105 | 108 |
109 | 110 | 111 | {localStrings[locale]['homepage']['createAccount']} 112 | 113 | 114 | 115 | {localStrings[locale]['homepage']['loginWithFb']} 116 | 117 |
118 | ); 119 | } 120 | 121 | export default LoginForm; -------------------------------------------------------------------------------- /client/src/components/signin/RegisterForm.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import { Redirect, Link } from 'react-router-dom'; 3 | import axios from 'axios'; 4 | import Logo from '../../images/logo.svg'; 5 | import { validateName, validateEmail, validatePassword } from './utils/validateRegister'; 6 | import localStrings from '../../localization'; 7 | 8 | function RegisterForm(props) { 9 | const { verifyAuth } = props; 10 | const [finishedAsync, setFinishedAsync] = useState(true); 11 | const [errorMessage, setErrorMessage] = useState(''); 12 | const locale = localStorage.getItem('localizationCode') === 'en-US' ? 'en-US' : 'pt-BR'; 13 | 14 | useEffect(() => { 15 | verifyAuth(); 16 | }, [ verifyAuth ]); 17 | 18 | const requestRegister = (form) => { 19 | form.preventDefault(); 20 | setFinishedAsync(false); 21 | setErrorMessage(''); 22 | 23 | const fields = form.target; 24 | const birthDate = new Date(fields.birthYear.value, fields.birthMonth.value, fields.birthDay.value); 25 | 26 | if (validateName(fields.firstName).valid === false 27 | || validateName(fields.lastName).valid === false 28 | || validateEmail(fields.email).valid === false 29 | || validatePassword(fields.password).valid === false) { 30 | setFinishedAsync(true); 31 | return; 32 | } 33 | 34 | const newUser = { 35 | firstName: fields.firstName.value, 36 | lastName: fields.lastName.value, 37 | email: fields.email.value, 38 | password: fields.password.value, 39 | birthDate, 40 | gender: fields.gender.value 41 | }; 42 | 43 | axios.post(`${process.env.REACT_APP_API_URL}/api/users`, newUser) 44 | .then((response) => { 45 | window.location.href = '/'; 46 | setFinishedAsync(true); 47 | }) 48 | .catch((err) => { 49 | if (err.response) { 50 | if (err.response.status === 400) { 51 | setErrorMessage(localStrings[locale]['register']['error']['invalidData']); 52 | } else if (err.response.status === 409) { 53 | setErrorMessage(localStrings[locale]['register']['error']['emailConflict']); 54 | } 55 | } else { 56 | setErrorMessage(localStrings[locale]['register']['error']['internal']); 57 | } 58 | 59 | setFinishedAsync(true); 60 | }) 61 | } 62 | 63 | const yearOptions = []; 64 | 65 | for (let i = (new Date().getFullYear() - 1); i > 1910; i--) { 66 | yearOptions.push(i); 67 | } 68 | 69 | if (props.state.loading) { 70 | return 'Loading...'; 71 | } else if (props.state.isLogged) { 72 | return () 73 | } else { 74 | return ( 75 |
76 | 77 | 78 |
79 |

{localStrings[locale]['register']['header']}

80 | 81 | { errorMessage &&
{errorMessage}
} 82 | 83 | 84 | validateName(ev.target)} 92 | /> 93 | 94 | 95 | validateName(ev.target)} 103 | /> 104 | 105 | 106 | validateEmail(ev.target)} 114 | /> 115 | 116 | 117 | validatePassword(ev.target)} 125 | /> 126 | 127 |
128 | {localStrings[locale]['register']['birthDate']} 129 | 130 | 136 | 137 | 144 | 145 | 151 |
152 | 153 |
154 | {localStrings[locale]['register']['gender']} 155 | 164 | 173 | 182 |
183 | 184 | 187 |
188 |
189 | ); 190 | } 191 | } 192 | 193 | export default RegisterForm; -------------------------------------------------------------------------------- /client/src/components/signin/utils/validateRegister.js: -------------------------------------------------------------------------------- 1 | import localStrings from '../../../localization'; 2 | 3 | const locale = localStorage.getItem('localizationCode') === 'en-US' ? 'en-US' : 'pt-BR'; 4 | 5 | const validateName = (input) => { 6 | // Checks if input is empty or blank 7 | if (!input.value || /^\s*$/.test(input.value)) { 8 | input.setCustomValidity(localStrings[locale]['register']['error']['invalidName']); 9 | return {valid: false}; 10 | } 11 | 12 | input.setCustomValidity(''); 13 | return {valid: true}; 14 | } 15 | 16 | const validateEmail = (input) => { 17 | // Checks if email pattern is correct (pattern from W3) 18 | if (!/^[a-zA-Z0-9.!#$%&’*+/=?^_`{|}~-]+@[a-zA-Z0-9-]+(?:\.[a-zA-Z0-9-]+)*$/.test(input.value)) { 19 | input.setCustomValidity(localStrings[locale]['register']['error']['invalidEmail']); 20 | return {valid: false}; 21 | } 22 | 23 | input.setCustomValidity(''); 24 | return {valid: true}; 25 | } 26 | 27 | const validatePassword = (input) => { 28 | // Checks if input is empty 29 | if (!input.value) { 30 | input.setCustomValidity(localStrings[locale]['register']['error']['invalidPassword']); 31 | return {valid: false}; 32 | } 33 | 34 | input.setCustomValidity(''); 35 | return {valid: true}; 36 | } 37 | 38 | export { 39 | validateName, 40 | validateEmail, 41 | validatePassword 42 | }; -------------------------------------------------------------------------------- /client/src/components/user/Comment.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import axios from 'axios'; 3 | import noAvatar from '../../images/no-avatar.png'; 4 | import localStrings from '../../localization'; 5 | 6 | function Comment(props) { 7 | const { comment, loggedUserId } = props; 8 | const [isLiked, setIsLiked] = useState(false); 9 | const [likes, setLikes] = useState(props.comment.likes.length); 10 | const [finishedAsync, setFinishedAsync] = useState(true); 11 | const locale = localStorage.getItem('localizationCode') === 'en-US' ? 'en-US' : 'pt-BR'; 12 | 13 | useEffect(() => { 14 | const foundUser = comment.likes.find(user => user.toString() === loggedUserId); 15 | 16 | foundUser == null ? setIsLiked(false) : setIsLiked(true); 17 | }, [ comment.likes, loggedUserId ]); 18 | 19 | const handleLike = async () => { 20 | setFinishedAsync(false); 21 | 22 | const previousLikes = likes; 23 | 24 | try { 25 | if (!isLiked) { 26 | setLikes(likes + 1); 27 | await axios.put(`${process.env.REACT_APP_API_URL}/api/posts/${comment.post}/comments/${comment._id}/like`, { _id: loggedUserId }); 28 | } else { 29 | setLikes(likes - 1); 30 | await axios.put(`${process.env.REACT_APP_API_URL}/api/posts/${comment.post}/comments/${comment._id}/dislike`, { _id: loggedUserId }); 31 | } 32 | 33 | setIsLiked(!isLiked); 34 | } catch (err) { 35 | setLikes(previousLikes); 36 | } finally { 37 | setFinishedAsync(true); 38 | } 39 | } 40 | 41 | return ( 42 |
43 | 44 | {localStrings[locale]['posts']['alt']['commenterAvatar']} 49 | 50 | 51 |
52 | {comment.author.firstName} {comment.author.lastName} 53 |

{comment.content}

54 | 55 | 58 |
59 |
60 | ); 61 | } 62 | 63 | export default Comment; -------------------------------------------------------------------------------- /client/src/components/user/CommentForm.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import axios from 'axios'; 3 | import localStrings from '../../localization'; 4 | 5 | function CommentForm(props) { 6 | const { postId, triggerRender, loggedUserId } = props; 7 | const [finishedAsync, setFinishedAsync] = useState(true); 8 | const [errorMessage, setErrorMessage] = useState(''); 9 | const locale = localStorage.getItem('localizationCode') === 'en-US' ? 'en-US' : 'pt-BR'; 10 | 11 | const handleSubmit = (form) => { 12 | form.preventDefault(); 13 | setFinishedAsync(false); 14 | setErrorMessage(''); 15 | 16 | const newComment = { 17 | author: loggedUserId, 18 | content: form.target.content.value 19 | } 20 | 21 | const csrfToken = localStorage.getItem('csrfToken'); 22 | 23 | axios.post(`${process.env.REACT_APP_API_URL}/api/posts/${postId}/comments`, newComment, { 24 | withCredentials: true, 25 | headers: { csrf: csrfToken } 26 | }) 27 | .then((response) => { 28 | triggerRender(); 29 | form.target.reset(); 30 | setFinishedAsync(true); 31 | }) 32 | .catch((err) => { 33 | if (!err.response) { 34 | setErrorMessage(localStrings[locale]['posts']['error']['internal']); 35 | } 36 | setFinishedAsync(true); 37 | }) 38 | } 39 | 40 | return ( 41 |
42 | { errorMessage &&
{errorMessage}
} 43 | 44 | 52 |
53 | ); 54 | } 55 | 56 | export default CommentForm; -------------------------------------------------------------------------------- /client/src/components/user/CommentList.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect} from 'react'; 2 | import Comment from './Comment'; 3 | import axios from 'axios'; 4 | import localStrings from '../../localization'; 5 | 6 | function CommentList(props) { 7 | const { postId, renderCount, loggedUserId } = props; 8 | const [comments, setComments] = useState([]); 9 | const locale = localStorage.getItem('localizationCode') === 'en-US' ? 'en-US' : 'pt-BR'; 10 | 11 | useEffect(() => { 12 | axios.get(`${process.env.REACT_APP_API_URL}/api/posts/${postId}/comments`) 13 | .then((response) => { 14 | setComments(response.data); 15 | }) 16 | .catch((err) => { 17 | // Don't load comments if server doesn't return an answer. 18 | }) 19 | }, [postId, renderCount]); 20 | 21 | return ( 22 |
23 |
{comments.length}  24 | { comments.length === 1 25 | ? localStrings[locale]['posts']['singularComments'] 26 | : localStrings[locale]['posts']['pluralComments']} 27 |
28 | { 29 | comments.map((comment) => 30 | 31 | ) 32 | } 33 |
34 | ); 35 | } 36 | 37 | export default CommentList; -------------------------------------------------------------------------------- /client/src/components/user/Dashboard.jsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from 'react'; 2 | import PostBoxContainer from './PostBoxContainer'; 3 | import PostList from './PostList'; 4 | import Homepage from '../signin/Homepage'; 5 | 6 | function Dashboard(props) { 7 | const { verifyAuth } = props; 8 | const { loggedUserId } = props.state; 9 | const [renderCount, setRenderCount] = useState(0); 10 | 11 | useEffect(() => { 12 | verifyAuth(); 13 | }, [ verifyAuth ]); 14 | 15 | const handleRender = () => { 16 | setRenderCount(renderCount + 1); 17 | } 18 | 19 | if (props.state.loading) { 20 | return 'Loading...'; 21 | } else if (!props.state.isLogged) { 22 | return (); 23 | } else { 24 | return ( 25 |
26 | 27 | 28 |
29 | ); 30 | } 31 | } 32 | 33 | export default Dashboard; -------------------------------------------------------------------------------- /client/src/components/user/FriendRequests.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import { Redirect } from 'react-router-dom'; 3 | import axios from 'axios'; 4 | import noAvatar from '../../images/no-avatar.png'; 5 | import localStrings from '../../localization'; 6 | 7 | function FriendRequests(props) { 8 | const { verifyAuth } = props; 9 | const { loggedUserId } = props.state; 10 | const [requestList, setRequestList] = useState([]); 11 | const [disableButton, setDisableButton] = useState(false); 12 | const [errorMessage, setErrorMessage] = useState(''); 13 | const locale = localStorage.getItem('localizationCode') === 'en-US' ? 'en-US' : 'pt-BR'; 14 | 15 | useEffect(() => { 16 | verifyAuth(); 17 | }, [verifyAuth]); 18 | 19 | useEffect(() => { 20 | if (loggedUserId) { 21 | axios.get(`${process.env.REACT_APP_API_URL}/api/users/${loggedUserId}/friendrequests`) 22 | .then((response) => { 23 | setRequestList(response.data); 24 | }) 25 | .catch((err) => { 26 | // Show no requests if no answer from the server. 27 | }) 28 | } 29 | }, [loggedUserId]); 30 | 31 | const handleRequest = async (buttonClicked, requestObject) => { 32 | setDisableButton(true); 33 | setErrorMessage(''); 34 | const friendId = requestObject.sender._id; 35 | const requestId = requestObject._id; 36 | 37 | const addEachOther = () => { 38 | return new Promise(async (resolve, reject) => { 39 | try { 40 | await axios.put(`${process.env.REACT_APP_API_URL}/api/users/${loggedUserId}/friend`, { _id: friendId }); 41 | await axios.put(`${process.env.REACT_APP_API_URL}/api/users/${friendId}/friend`, { _id: loggedUserId }); 42 | return resolve(); 43 | } catch (err) { 44 | return reject(err); 45 | } 46 | }); 47 | } 48 | 49 | try { 50 | if (buttonClicked === 'accept') { 51 | await addEachOther(); 52 | } 53 | 54 | await axios.delete(`${process.env.REACT_APP_API_URL}/api/friendrequests/${requestId}`); 55 | setRequestList( [...requestList].filter((request) => request._id !== requestObject._id) ); 56 | } catch (err) { 57 | setErrorMessage(localStrings[locale]['friendRequests']['error']['internal']); 58 | } 59 | 60 | setDisableButton(false); 61 | } 62 | 63 | const mapEventHandler = (ev, option, request) => { 64 | ev.stopPropagation(); 65 | handleRequest(option, request); 66 | } 67 | 68 | const visitProfile = (id) => { 69 | window.location.href = '/profile/' + id; 70 | } 71 | 72 | if (props.state.loading) { 73 | return 'Loading...'; 74 | } else if (!props.state.isLogged) { 75 | return (); 76 | } else { 77 | return ( 78 |
79 |

{localStrings[locale]['friendRequests']['header']}

80 | 81 | { requestList.length === 0 ? localStrings[locale]['friendRequests']['noRequest'] : '' } 82 | 83 | { requestList.map((request) => 84 |
visitProfile(request.sender._id)}> 85 | {localStrings[locale]['friendRequests']['alt']['avatar']} 90 | 91 |
92 | {request.sender.firstName} {request.sender.lastName}
93 |
94 | 99 | 104 |
105 | { errorMessage &&
{errorMessage}
} 106 |
107 |
) } 108 |
109 | ); 110 | } 111 | } 112 | 113 | export default FriendRequests; -------------------------------------------------------------------------------- /client/src/components/user/FullPost.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import { useParams, Redirect } from 'react-router-dom'; 3 | import axios from 'axios'; 4 | import Post from './Post'; 5 | import CommentForm from './CommentForm'; 6 | import CommentList from './CommentList'; 7 | 8 | function FullPost(props) { 9 | const { verifyAuth } = props; 10 | const { loggedUserId } = props.state; 11 | const { postId } = useParams(); 12 | const [post, setPost] = useState({}); 13 | const [renderCount, setRenderCount] = useState(0); 14 | const [isUnmounted, setIsUnmounted] = useState(false); 15 | 16 | useEffect(() => { 17 | return () => { setIsUnmounted(true) }; 18 | }, [verifyAuth, postId]); 19 | 20 | useEffect(() => { 21 | verifyAuth(); 22 | }, [verifyAuth]); 23 | 24 | useEffect(() => { 25 | axios.get(`${process.env.REACT_APP_API_URL}/api/posts/${postId}`) 26 | .then((response) => { 27 | if (!isUnmounted) { 28 | setPost(response.data); 29 | } 30 | }) 31 | .catch((err) => { 32 | // Show no post if server doesn't return an answer. 33 | }) 34 | }, [postId, isUnmounted]); 35 | 36 | const triggerRender = () => { 37 | setRenderCount(renderCount + 1); 38 | } 39 | 40 | if (props.state.loading) { 41 | return 'Loading auth...'; 42 | } else if (!props.state.isLogged) { 43 | return (); 44 | } else if (post._id) { 45 | return ( 46 |
47 | 48 | 49 | 50 |
51 | ); 52 | } else { 53 | return 'Loading post...'; 54 | } 55 | } 56 | 57 | export default FullPost; -------------------------------------------------------------------------------- /client/src/components/user/Navbar.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Icon from '../../images/favicon.svg'; 3 | import SearchBar from './SearchBar'; 4 | import NavbarOptions from './NavbarOptions'; 5 | import localStrings from '../../localization'; 6 | 7 | function Navbar(props) { 8 | const url = window.location.href; 9 | const locale = localStorage.getItem('localizationCode') === 'en-US' ? 'en-US' : 'pt-BR'; 10 | 11 | return ( 12 | 26 | ) 27 | } 28 | 29 | export default Navbar; -------------------------------------------------------------------------------- /client/src/components/user/NavbarOptions.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import noAvatar from '../../images/no-avatar.png'; 3 | import axios from 'axios'; 4 | import localStrings from '../../localization'; 5 | 6 | function NavbarOptions(props) 7 | { 8 | const [showOptions, setShowOptions] = useState(false); 9 | const [user, setUser] = useState({}); 10 | const { loggedUserId } = props; 11 | const locale = localStorage.getItem('localizationCode') === 'en-US' ? 'en-US' : 'pt-BR'; 12 | 13 | useEffect(() => { 14 | if (loggedUserId) { 15 | axios.get(`${process.env.REACT_APP_API_URL}/api/users/${loggedUserId}`) 16 | .then((response) => { 17 | setUser(response.data); 18 | }) 19 | .catch((err) => { 20 | // 21 | }); 22 | } 23 | }, [loggedUserId]); 24 | 25 | const handleToggle = () => { 26 | setShowOptions(!showOptions); 27 | } 28 | 29 | return ( 30 | 68 | ); 69 | } 70 | 71 | export default NavbarOptions; -------------------------------------------------------------------------------- /client/src/components/user/Post.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import axios from 'axios'; 3 | import noAvatar from '../../images/no-avatar.png'; 4 | import localStrings from '../../localization'; 5 | 6 | function Post(props) { 7 | const { post, loggedUserId } = props; 8 | const [isLiked, setIsLiked] = useState(false); 9 | const [likes, setLikes] = useState(props.post.likes.length); 10 | const [finishedAsync, setFinishedAsync] = useState(true); 11 | const locale = localStorage.getItem('localizationCode') === 'en-US' ? 'en-US' : 'pt-BR'; 12 | 13 | useEffect(() => { 14 | const foundUser = post.likes.find(user => user.toString() === loggedUserId); 15 | 16 | foundUser == null ? setIsLiked(false) : setIsLiked(true); 17 | }, [ post.likes, loggedUserId ]); 18 | 19 | const handleLike = async () => { 20 | setFinishedAsync(false); 21 | 22 | const previousLikes = likes; 23 | 24 | try { 25 | if (!isLiked) { 26 | setLikes(likes + 1); 27 | await axios.put(`${process.env.REACT_APP_API_URL}/api/posts/${post._id}/like`, { _id: loggedUserId }); 28 | } else { 29 | setLikes(likes - 1); 30 | await axios.put(`${process.env.REACT_APP_API_URL}/api/posts/${post._id}/dislike`, { _id: loggedUserId }); 31 | } 32 | 33 | setIsLiked(!isLiked); 34 | } catch (err) { 35 | setLikes(previousLikes); 36 | } finally { 37 | setFinishedAsync(true); 38 | } 39 | } 40 | 41 | if (!post) { 42 | return ''; 43 | } 44 | 45 | return ( 46 |
47 | 59 | 60 |

61 | {post.content} 62 |

63 | 64 | { post.photo 65 | && 66 |
67 | {localStrings[locale]['posts']['alt']['postImagePrefix'] 71 |
} 72 | 73 |
74 | 75 |
76 | 82 | 83 | 84 | {localStrings[locale]['posts']['comments']} 85 | 86 |
87 |
88 | ); 89 | } 90 | 91 | export default Post; -------------------------------------------------------------------------------- /client/src/components/user/PostBox.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import axios from 'axios'; 3 | import localStrings from '../../localization'; 4 | 5 | function PostBox(props) { 6 | const [finishedAsync, setFinishedAsync] = useState(true); 7 | const [inputUrlVisible, setInputUrlVisible] = useState(false); 8 | const { handleRender, handleClose, loggedUserId } = props; 9 | const [errorMessage, setErrorMessage] = useState(''); 10 | 11 | const [photoUrl, setPhotoUrl] = useState(''); 12 | const [urlIsValid, setUrlIsValid] = useState(true); 13 | const [validatedUrl, setValidatedUrl] = useState(true); 14 | 15 | const locale = localStorage.getItem('localizationCode') === 'en-US' ? 'en-US' : 'pt-BR'; 16 | 17 | const closeModal = () => { 18 | if (finishedAsync) { 19 | handleClose(); 20 | } 21 | } 22 | 23 | const handleSubmit = (form) => { 24 | form.preventDefault(); 25 | setFinishedAsync(false); 26 | setErrorMessage(''); 27 | 28 | if (!validatedUrl || !urlIsValid) { 29 | setFinishedAsync(true); 30 | return; 31 | } 32 | 33 | const newPost = { 34 | author: loggedUserId, 35 | content: form.target.content.value, 36 | timestamp: new Date(), 37 | photo: form.target.photoUrl.value || '' 38 | } 39 | 40 | const csrfToken = localStorage.getItem('csrfToken'); 41 | 42 | axios.post(`${process.env.REACT_APP_API_URL}/api/posts`, newPost, { withCredentials: true, headers: { csrf: csrfToken } }) 43 | .then((response) => { 44 | form.target.reset(); 45 | handleRender(); 46 | setFinishedAsync(true); 47 | closeModal(); 48 | }) 49 | .catch((err) => { 50 | if (err.response) { 51 | setErrorMessage(localStrings[locale]['postbox']['error']['badRequest']); 52 | } else { 53 | setErrorMessage(localStrings[locale]['postbox']['error']['internal']); 54 | } 55 | 56 | setFinishedAsync(true); 57 | }) 58 | } 59 | 60 | const handleClickImage = () => { 61 | setInputUrlVisible(!inputUrlVisible); 62 | } 63 | 64 | const handleInputUrl = (input) => { 65 | setValidatedUrl(false); 66 | setPhotoUrl(input.target.value); 67 | } 68 | 69 | const handleLoadImage = () => { 70 | setUrlIsValid(true); 71 | setValidatedUrl(true); 72 | } 73 | 74 | const handleErrorImage = () => { 75 | setValidatedUrl(true); 76 | 77 | if (photoUrl.length === 0) { 78 | setUrlIsValid(true); 79 | return; 80 | } 81 | 82 | setUrlIsValid(false); 83 | } 84 | 85 | return ( 86 |
87 |
88 |
89 |
90 |

{localStrings[locale]['postbox']['header']}

91 | 92 |
93 | 94 |
95 | { errorMessage &&
{errorMessage}
} 96 | 97 | 98 |
99 | 106 | 107 | 115 | 116 | { urlIsValid 117 | || 118 | 119 | {localStrings[locale]['postbox']['error']['badUrl']} 120 | } 121 | 122 |
123 |
124 | 127 | 128 | 131 | 140 |
141 | 142 | 145 |
146 |
147 |
148 |
149 |
150 | ); 151 | } 152 | 153 | export default PostBox; -------------------------------------------------------------------------------- /client/src/components/user/PostBoxContainer.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | import PostBox from './PostBox'; 3 | import localStrings from '../../localization'; 4 | 5 | function PostBoxContainer(props) { 6 | const [openModal, setOpenModal] = useState(false); 7 | const { handleRender, loggedUserId } = props; 8 | const locale = localStorage.getItem('localizationCode') === 'en-US' ? 'en-US' : 'pt-BR'; 9 | 10 | const createModal = () => { 11 | setOpenModal(true); 12 | } 13 | 14 | const handleClose = () => { 15 | setOpenModal(false); 16 | } 17 | 18 | return ( 19 |
20 | 23 | { openModal ? : '' } 24 |
25 | ); 26 | } 27 | 28 | export default PostBoxContainer; -------------------------------------------------------------------------------- /client/src/components/user/PostList.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import axios from 'axios'; 3 | import Post from './Post'; 4 | import localStrings from '../../localization'; 5 | 6 | function PostList(props) { 7 | const { originPath, loggedUserId, renderCount } = props; 8 | const [postList, setPostList] = useState([]); 9 | const [searchedUser, setSearchedUser] = useState(''); 10 | const [previousRender, setPreviousRender] = useState(0); 11 | const locale = localStorage.getItem('localizationCode') === 'en-US' ? 'en-US' : 'pt-BR'; 12 | 13 | useEffect(() => { 14 | if (searchedUser === loggedUserId && previousRender === renderCount) { 15 | return; 16 | } 17 | 18 | axios.get(process.env.REACT_APP_API_URL + originPath) 19 | .then((response) => { 20 | setPostList(response.data); 21 | setSearchedUser(loggedUserId); 22 | setPreviousRender(renderCount); 23 | }) 24 | .catch((err) => { 25 | setPostList([]); 26 | setSearchedUser(loggedUserId); 27 | setPreviousRender(renderCount); 28 | }) 29 | }, [searchedUser, originPath, renderCount, previousRender, loggedUserId]); 30 | 31 | if (postList.length === 0 && searchedUser === loggedUserId) { 32 | return ( 33 |
34 |
:(
35 | {localStrings[locale]['posts']['noPosts']} 36 |
37 | ); 38 | } 39 | 40 | return ( 41 |
42 | { postList.map((post, index) => ) } 43 | 44 | { 45 | postList.length === 0 46 | || 47 |
48 |
;)
49 | {localStrings[locale]['posts']['enoughPosts']} 50 |
51 | } 52 |
53 | ); 54 | } 55 | 56 | export default PostList; -------------------------------------------------------------------------------- /client/src/components/user/SearchBar.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import localStrings from '../../localization'; 3 | 4 | function SearchBar(props) { 5 | const locale = localStorage.getItem('localizationCode') === 'en-US' ? 'en-US' : 'pt-BR'; 6 | 7 | const handleSearch = (form) => { 8 | form.preventDefault(); 9 | 10 | if (form.target.searchbar.value.length !== 0) { 11 | window.location.href = '/search/' + form.target.searchbar.value; 12 | } 13 | } 14 | 15 | return ( 16 |
17 | 18 | 25 |
26 | ); 27 | } 28 | 29 | export default SearchBar; -------------------------------------------------------------------------------- /client/src/components/user/SearchResults.jsx: -------------------------------------------------------------------------------- 1 | import React, { useState, useEffect } from 'react'; 2 | import { useParams, Redirect } from 'react-router-dom'; 3 | import axios from 'axios'; 4 | import noAvatar from '../../images/no-avatar.png'; 5 | import localStrings from '../../localization'; 6 | 7 | function SearchResults(props) { 8 | const { verifyAuth } = props; 9 | const { query } = useParams(); 10 | const [results, setResults] = useState([]); 11 | const locale = localStorage.getItem('localizationCode') === 'en-US' ? 'en-US' : 'pt-BR'; 12 | 13 | useEffect(() => { 14 | verifyAuth(); 15 | }, [verifyAuth]); 16 | 17 | useEffect(() => { 18 | axios.get(`${process.env.REACT_APP_API_URL}/api/users/search/${query}`) 19 | .then((response) => { 20 | setResults(response.data); 21 | }) 22 | .catch((err) => { 23 | // Show no results if server doesn't return a response 24 | }) 25 | }, [query]); 26 | 27 | if (props.state.loading) { 28 | return 'Loading...'; 29 | } else if (!props.state.isLogged) { 30 | return (); 31 | } else { 32 | return ( 33 |
34 |

{localStrings[locale]['search']['header']}

35 | { results.map((result, index) => 36 |
37 | 45 | 46 | { index+1 === results.length ||
} 47 |
) 48 | } 49 |
50 | ); 51 | } 52 | } 53 | 54 | export default SearchResults; -------------------------------------------------------------------------------- /client/src/images/logo.svg: -------------------------------------------------------------------------------- 1 | 2 | 17 | 19 | 25 | 31 | 32 | 51 | 53 | 54 | 56 | image/svg+xml 57 | 59 | 60 | 61 | 62 | 63 | 68 | odinclone 76 | 77 | 78 | -------------------------------------------------------------------------------- /client/src/images/no-avatar.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/lucaskenji/odinclone-app/2868d6dd7f207e5f03ecc86db58503e8ad50c432/client/src/images/no-avatar.png -------------------------------------------------------------------------------- /client/src/index.css: -------------------------------------------------------------------------------- 1 | @import url('https://fonts.googleapis.com/css2?family=Roboto&display=swap'); 2 | @import url('https://fonts.googleapis.com/css2?family=Hind+Madurai&display=swap'); 3 | 4 | /* General */ 5 | 6 | body, html { 7 | background-color: #ebeff0; 8 | } 9 | 10 | body { 11 | overflow-y: scroll; 12 | } 13 | 14 | .uses-font { 15 | font-family: 'Hind Madurai', Arial, sans-serif !important; 16 | } 17 | 18 | .form-input { 19 | width: 84%; 20 | height: 30px; 21 | border-radius: 5px; 22 | border: 1px solid lightgray; 23 | margin-top: 8px; 24 | padding-left: 15px; 25 | font-size: 16px; 26 | } 27 | 28 | .form-input:focus { 29 | outline: 0; 30 | border: 1px solid blue; 31 | box-shadow: 0 0 5px lightblue; 32 | } 33 | 34 | .form-input:invalid { 35 | outline: 0; 36 | border: 1px solid #ff5252; 37 | box-shadow: 0 0 2px #ff5252; 38 | } 39 | 40 | .btn { 41 | background: none; 42 | outline: 0; 43 | border: 0; 44 | border-radius: 5px; 45 | transition: background-color, .2s; 46 | } 47 | 48 | .btn:hover { 49 | cursor: pointer; 50 | } 51 | 52 | .btn-primary { 53 | background-color: #1877f2; 54 | color: white; 55 | } 56 | 57 | .btn-primary:hover { 58 | background-color: #0a4fa9; 59 | } 60 | 61 | .btn-secondary { 62 | background-color: lightgray; 63 | color: black; 64 | } 65 | 66 | .btn-secondary:hover { 67 | background-color: darkgray; 68 | } 69 | 70 | .btn-confirm { 71 | background-color: #26bf4f; 72 | color: white; 73 | } 74 | 75 | .btn-confirm:hover { 76 | background-color: #199c3c; 77 | } 78 | 79 | .btn-link { 80 | display: inline-block; 81 | text-decoration: none; 82 | line-height: 40px; 83 | margin-bottom: 10px; 84 | } 85 | 86 | .btn-large { 87 | font-size: 16px; 88 | } 89 | 90 | .navbar { 91 | background-color: white; 92 | position: fixed; 93 | top: 0; 94 | left: 0; 95 | width: 100%; 96 | height: 56px; 97 | box-shadow: 0 2px 5px lightgray; 98 | display: flex; 99 | -moz-flex-direction: row; 100 | flex-direction: row; 101 | align-items: center; 102 | z-index: 200; 103 | } 104 | 105 | #nav-icon { 106 | width: 42px; 107 | height: 42px; 108 | padding-left: 20px; 109 | } 110 | 111 | #search-bar { 112 | outline: 0; 113 | border: 0; 114 | border-radius: 15px; 115 | margin-left: 10px; 116 | padding-left: 19px; 117 | background-color: #ebeff0; 118 | font-size: 16px; 119 | width: 250px; 120 | height: 30px; 121 | } 122 | 123 | #nav-requests { 124 | width: 64px; 125 | height: 42px; 126 | border-radius: 12px; 127 | display: flex; 128 | justify-content: center; 129 | align-items: center; 130 | } 131 | 132 | #nav-requests:hover { 133 | background-color: #ebeff0; 134 | } 135 | 136 | #nav-requests-link { 137 | margin-left: auto; 138 | color: black; 139 | } 140 | 141 | .nav-requests-select { 142 | box-shadow: 0 4px 0 white, 0 7px 0 #1877f2; 143 | color: #1877f2 !important; 144 | } 145 | 146 | .nav-requests-select #nav-requests:hover { 147 | background-color: white !important; 148 | } 149 | 150 | #nav-options-container { 151 | margin-left: auto; 152 | margin-right: 20px; 153 | margin-top: auto; 154 | padding-top: 6px; 155 | } 156 | 157 | #nav-toggle { 158 | width: 42px; 159 | height: 42px; 160 | margin-left: 250px; 161 | border-radius: 50%; 162 | transition: box-shadow .2s; 163 | } 164 | 165 | #nav-toggle:hover { 166 | cursor: pointer; 167 | box-shadow: 0 0 8px lightblue, inset 0 0 10px lightblue; 168 | } 169 | 170 | #nav-options { 171 | visibility: hidden; 172 | width: 270px; 173 | height: 1px; 174 | padding: 15px 15px 25px 15px; 175 | background-color: white; 176 | box-shadow: 0 5px 10px darkgray; 177 | z-index: 201; 178 | border-radius: 12px; 179 | } 180 | 181 | #nav-options hr { 182 | width: 95%; 183 | border: 1px solid #ddd; 184 | } 185 | 186 | .nav-options-link { 187 | width: 250px; 188 | padding-left: 10px; 189 | margin-bottom: 10px; 190 | border-radius: 12px; 191 | color: black; 192 | font-weight: bold; 193 | display: flex; 194 | -moz-flex-direction: row; 195 | flex-direction: row; 196 | align-items: center; 197 | } 198 | 199 | .nav-options-link:hover { 200 | background-color: #ebeff0; 201 | } 202 | 203 | .nav-options-link span { 204 | display: flex; 205 | flex-direction: column; 206 | moz-flex-direction: column; 207 | } 208 | 209 | .nav-options-guest { 210 | font-size: 14px; 211 | display: none !important; 212 | } 213 | 214 | .nav-options-settings:hover .nav-options-guest { 215 | display: flex !important; 216 | } 217 | 218 | .nav-options-img { 219 | width: 50px; 220 | height: 50px; 221 | margin-right: 10px; 222 | border-radius: 50%; 223 | } 224 | 225 | .nav-options-icon { 226 | width: 30px; 227 | height: 30px; 228 | background-color: lightgray; 229 | margin-right: 10px; 230 | border-radius: 50%; 231 | display: flex; 232 | -moz-flex-direction: row; 233 | flex-direction: row; 234 | justify-content: center; 235 | align-items: center; 236 | } 237 | 238 | .nav-visible { 239 | visibility: visible !important; 240 | height: 140px !important; 241 | } 242 | 243 | .no-underline { 244 | text-decoration: none; 245 | } 246 | 247 | .error-message { 248 | color: #ed1820; 249 | margin-top: 5px; 250 | } 251 | 252 | @media (max-width: 700px) { 253 | #nav-options { 254 | margin-top: 5px; 255 | } 256 | 257 | #search-bar { 258 | width: 150px; 259 | } 260 | 261 | #nav-requests-link { 262 | z-index: 300; 263 | } 264 | 265 | #nav-options-container { 266 | margin-left: -200px; 267 | } 268 | } 269 | 270 | /* Homepage */ 271 | #home-container { 272 | width: 100%; 273 | height: 100vh; 274 | display: flex; 275 | -moz-flex-direction: row; 276 | flex-direction: row; 277 | justify-content: space-around; 278 | margin-top: 120px; 279 | } 280 | 281 | #home-aux-container { 282 | width: 80%; 283 | display: flex; 284 | -moz-flex-direction: row; 285 | flex-direction: row; 286 | justify-content: space-around; 287 | } 288 | 289 | #home-info { 290 | width: 45%; 291 | margin-top: 60px; 292 | } 293 | 294 | #home-logo { 295 | width: 240px; 296 | margin-bottom: -15px; 297 | } 298 | 299 | #home-description { 300 | font-size: 26px; 301 | font-family: Arial, sans-serif; 302 | } 303 | 304 | #home-footer { 305 | position: absolute; 306 | left: 0; 307 | top: 100%; 308 | bottom: 0; 309 | width: 100%; 310 | height: 160px; 311 | background-color: white; 312 | } 313 | 314 | #home-footer div { 315 | padding: 45px 14% 0 14%; 316 | color: gray; 317 | font-size: 14px; 318 | } 319 | 320 | #home-footer hr { 321 | border: 1px solid lightgray; 322 | } 323 | 324 | #home-footer span { 325 | margin-right: 20px; 326 | } 327 | 328 | #home-footer a { 329 | color: gray; 330 | text-decoration: none; 331 | } 332 | 333 | #home-footer a:hover { 334 | text-decoration: underline; 335 | } 336 | 337 | #login-container { 338 | width: 380px; 339 | height: 300px; 340 | border-radius: 12px; 341 | background-color: #fefefe; 342 | box-shadow: 0 5px 10px lightgray; 343 | text-align: center; 344 | padding-top: 20px; 345 | } 346 | 347 | #login-container hr { 348 | width: 90%; 349 | border: 1px solid #ddd; 350 | } 351 | 352 | #register-container { 353 | margin-top: 40px; 354 | text-align: center; 355 | display: flex; 356 | -moz-flex-direction: column; 357 | flex-direction: column; 358 | align-items: center; 359 | } 360 | 361 | #register-logo { 362 | width: 228px; 363 | } 364 | 365 | #register-title { 366 | font-size: 30px; 367 | margin: 4px 0 4px 0; 368 | } 369 | 370 | #register-form { 371 | width: 380px; 372 | margin-top: 30px; 373 | border-radius: 12px; 374 | background-color: #fefefe; 375 | box-shadow: 0 5px 10px lightgray; 376 | padding: 20px 0 20px 0; 377 | } 378 | 379 | .form-select { 380 | width: 28%; 381 | height: 30px; 382 | border-radius: 5px; 383 | border: 1px solid lightgray; 384 | margin-left: 10px; 385 | margin-top: 8px; 386 | padding-left: 15px; 387 | font-size: 16px; 388 | } 389 | 390 | #birthdate-fieldset { 391 | margin-top: 12px; 392 | } 393 | 394 | .btn-home { 395 | width: 90%; 396 | height: 40px; 397 | font-weight: bold; 398 | font-size: 20px; 399 | } 400 | 401 | .btn-login { 402 | margin-bottom: -5px; 403 | } 404 | 405 | .btn-login-fb { 406 | width: 70%; 407 | font-size: 16px; 408 | height: 25px; 409 | line-height: 25px; 410 | } 411 | 412 | .btn-guest { 413 | font-size: 16px; 414 | } 415 | 416 | .btn-guest:hover { 417 | text-decoration: underline; 418 | } 419 | 420 | .home-local:hover { 421 | cursor: pointer; 422 | text-decoration: underline; 423 | } 424 | 425 | @media (max-width: 900px) { 426 | #home-container { 427 | margin-top: 0; 428 | } 429 | 430 | #home-aux-container { 431 | -moz-flex-direction: column; 432 | flex-direction: column; 433 | align-items: center; 434 | } 435 | 436 | #home-info { 437 | width: 100%; 438 | } 439 | } 440 | 441 | @media (max-height: 540px) { 442 | #home-footer { 443 | margin-top: 200px; 444 | } 445 | } 446 | 447 | /* Dashboard */ 448 | .post { 449 | width: 650px; 450 | margin-top: 15px; 451 | padding: 5px 5px 15px 5px; 452 | background-color: white; 453 | box-shadow: -1px 2px 3px lightgray, 1px 2px 3px lightgray; 454 | border-radius: 12px; 455 | } 456 | 457 | .post-avatar { 458 | border-radius: 50%; 459 | width: 42px; 460 | height: 42px; 461 | margin-right: 10px; 462 | transition: filter .2s; 463 | } 464 | 465 | .post-avatar:hover { 466 | filter: brightness(85%); 467 | } 468 | 469 | .post-info { 470 | padding-left: 15px; 471 | padding-top: 5px; 472 | display: flex; 473 | -moz-flex-direction: row; 474 | flex-direction: row; 475 | align-items: center; 476 | } 477 | 478 | .post-info a { 479 | text-decoration: none; 480 | font-weight: bold; 481 | color: black; 482 | } 483 | 484 | .post-info a:hover { 485 | text-decoration: underline; 486 | } 487 | 488 | .post-content { 489 | padding-left: 15px; 490 | padding-right: 15px; 491 | margin-top: 10px; 492 | margin-bottom: 0; 493 | word-wrap: break-word; 494 | } 495 | 496 | .post hr { 497 | width: 95%; 498 | border: 1px solid #ddd; 499 | } 500 | 501 | .post-options { 502 | padding-left: 15px; 503 | padding-right: 15px; 504 | display: flex; 505 | -moz-flex-direction: row; 506 | flex-direction: row; 507 | justify-content: space-around; 508 | } 509 | 510 | .post-image { 511 | margin-top: 10px; 512 | margin-left: -5px; 513 | width: 660px; 514 | height: 420px; 515 | background-color: black; 516 | display: flex; 517 | -moz-flex-direction: row; 518 | flex-direction: row; 519 | justify-content: center; 520 | } 521 | 522 | .post-image img { 523 | max-width: 100%; 524 | max-height: 100%; 525 | -o-object-fit: cover; 526 | object-fit: cover; 527 | } 528 | 529 | #postbox-container-div { 530 | margin-top: 90px; 531 | width: 660px; 532 | height: 40px; 533 | } 534 | 535 | #postbox-container-div .btn-postbox { 536 | width: 100%; 537 | height: 100%; 538 | border-radius: 12px; 539 | padding: 5px 5px 5px 5px; 540 | box-shadow: -1px 2px 3px lightgray, 1px 2px 3px lightgray; 541 | font-size: 16px; 542 | } 543 | 544 | .post-form { 545 | left: 50%; 546 | transform: translateX(-50%); 547 | border-radius: 12px; 548 | padding: 5px 5px 5px 5px; 549 | width: 450px; 550 | background-color: white; 551 | box-shadow: -1px 2px 3px lightgray, 1px 2px 3px lightgray; 552 | text-align: center; 553 | z-index: 10; 554 | position: fixed; 555 | } 556 | 557 | #post-form-header { 558 | display: flex; 559 | -moz-flex-direction: row; 560 | flex-direction: row; 561 | justify-content: center; 562 | align-items: center; 563 | margin-top: 10px; 564 | padding-right: 15px; 565 | padding-left: 30px; 566 | } 567 | 568 | #post-form-header h2 { 569 | margin: 0 auto 0 auto; 570 | } 571 | 572 | #post-form-header i:hover { 573 | cursor: pointer; 574 | } 575 | 576 | #postbox-container { 577 | padding: 15px 15px 15px 15px; 578 | } 579 | 580 | #postbox { 581 | outline: 0; 582 | border: 0; 583 | border-radius: 5px; 584 | padding: 15px 15px 15px 15px; 585 | background-color: #ebeff0; 586 | font-size: 16px; 587 | width: 390px; 588 | height: 90px; 589 | transition: background-color, .2s; 590 | resize: none; 591 | overflow: hidden; 592 | } 593 | 594 | #postbox:hover { 595 | background-color: #dadeef; 596 | } 597 | 598 | #postbox-buttons { 599 | display: flex; 600 | -moz-flex-direction: row; 601 | flex-direction: row; 602 | justify-content: space-between; 603 | } 604 | 605 | #postbox-submit { 606 | border-radius: 5px; 607 | font-size: 16px; 608 | width: 80px; 609 | height: 32px; 610 | } 611 | 612 | .btn-photo { 613 | border-radius: 15px; 614 | font-size: 16px; 615 | padding: 5px 10px 5px 10px; 616 | background-color: #ebeff0; 617 | } 618 | 619 | .btn-photo:hover { 620 | background-color: #dadeef; 621 | } 622 | 623 | #photoUrl { 624 | outline: 0; 625 | border: 0; 626 | border-radius: 15px; 627 | margin-left: 5px; 628 | padding: 5px 5px 5px 15px; 629 | background-color: #ebeff0; 630 | font-size: 16px; 631 | visibility: hidden; 632 | opacity: 0; 633 | width: 0; 634 | height: 20px; 635 | transition: background-color .2s, width .5s, opacity .5s, visibility .3s; 636 | } 637 | 638 | #photoUrl:hover { 639 | background-color: #dadeef; 640 | } 641 | 642 | .turn-visible { 643 | visibility: visible !important; 644 | opacity: 1 !important; 645 | width: 180px !important; 646 | } 647 | 648 | #image-preview-form { 649 | display: none; 650 | } 651 | 652 | .dark-screen { 653 | position: fixed; 654 | width: 100vw; 655 | height: 100vh; 656 | top: 0; 657 | left: 0; 658 | background-color: rgba(0, 0, 0, 0.3); 659 | } 660 | 661 | .full-post { 662 | margin-top: 80px; 663 | width: 650px; 664 | margin-left: auto; 665 | margin-right: auto; 666 | } 667 | 668 | .comment { 669 | display: flex; 670 | -moz-flex-direction: row; 671 | flex-direction: row; 672 | } 673 | 674 | .comment-content { 675 | width: 572px; 676 | margin-bottom: 15px; 677 | margin-left: 15px; 678 | padding: 15px 15px 15px 15px; 679 | background-color: white; 680 | box-shadow: -1px 2px 3px lightgray, 1px 2px 3px lightgray; 681 | border-radius: 12px; 682 | } 683 | 684 | .comment-content a { 685 | color: black; 686 | font-weight: bold; 687 | text-decoration: none; 688 | } 689 | 690 | .comment-content a:hover { 691 | text-decoration: underline; 692 | } 693 | 694 | .comment-link { 695 | /* Used in tags for comment avatars. For some reason they don't behave as usual so you have to resize them to the avatar. */ 696 | width: 42px; 697 | height: 42px; 698 | border-radius: 50%; 699 | } 700 | 701 | .comment-content p { 702 | margin-top: 2px; 703 | margin-bottom: 2px; 704 | } 705 | 706 | #comment-input { 707 | outline: 0; 708 | border: 0; 709 | border-radius: 15px; 710 | margin-top: 18px; 711 | padding-left: 19px; 712 | background-color: white; 713 | box-shadow: 0 2px 3px lightgray; 714 | font-size: 16px; 715 | width: 640px; 716 | height: 30px; 717 | } 718 | 719 | .btn-post { 720 | font-size: 16px; 721 | } 722 | 723 | .btn-post:hover { 724 | background-color: #ebeff0; 725 | } 726 | 727 | .btn-post-comments { 728 | text-decoration: none; 729 | color: black; 730 | padding-left: 3px; 731 | padding-right: 3px; 732 | } 733 | 734 | .btn-liked { 735 | color: #1877f2; 736 | } 737 | 738 | @media (max-width: 660px) { 739 | #dashboard { 740 | width: 100%; 741 | } 742 | 743 | .full-post { 744 | width: 90%; 745 | } 746 | 747 | #comment-input { 748 | width: 95%; 749 | margin-left: auto; 750 | margin-right: auto; 751 | } 752 | 753 | .comm { 754 | width: 90%; 755 | } 756 | 757 | #post-list { 758 | width: 90%; 759 | } 760 | 761 | #post-list-empty { 762 | width: 90% !important; 763 | margin-left: auto; 764 | margin-right: auto; 765 | } 766 | 767 | #postbox-container-div { 768 | width: 90%; 769 | } 770 | 771 | .post { 772 | width: 100%; 773 | margin-left: auto; 774 | margin-right: auto; 775 | padding: 0; 776 | } 777 | 778 | .post-image { 779 | width: 100%; 780 | height: 300px; 781 | background-color: black; 782 | margin-left: 0; 783 | } 784 | 785 | .post-image img { 786 | width: 100% !important; 787 | } 788 | 789 | .post-info { 790 | padding: 15px 15px 5px 15px; 791 | } 792 | 793 | .post-content { 794 | padding: 0 25px 0 25px; 795 | } 796 | 797 | .post-options { 798 | padding: 5px 5px 15px 5px; 799 | } 800 | } 801 | 802 | @media (max-height: 420px) { 803 | .post-form { 804 | top: 70px; 805 | } 806 | } 807 | 808 | /* Search results */ 809 | #search-results { 810 | margin-top: 90px; 811 | margin-left: auto; 812 | margin-right: auto; 813 | border-radius: 12px; 814 | padding: 12px 12px 12px 12px; 815 | width: 640px; 816 | background-color: white; 817 | box-shadow: -1px 2px 3px lightgray, 1px 2px 3px lightgray; 818 | } 819 | 820 | #search-results h1 { 821 | font-size: 18px; 822 | } 823 | 824 | #search-results hr { 825 | border: 1px solid #ddd; 826 | margin-bottom: 20px; 827 | } 828 | 829 | .search-result { 830 | display: flex; 831 | -moz-flex-direction: row; 832 | flex-direction: row; 833 | align-items: center; 834 | margin-bottom: 20px; 835 | } 836 | 837 | .result-avatar { 838 | border-radius: 50%; 839 | width: 60px; 840 | height: 60px; 841 | margin-right: 10px; 842 | transition: filter .2s; 843 | } 844 | 845 | .result-avatar:hover { 846 | filter: brightness(85%); 847 | } 848 | 849 | .result-info a { 850 | text-decoration: none; 851 | color: black; 852 | } 853 | 854 | .result-info a:hover { 855 | text-decoration: underline; 856 | } 857 | 858 | #dashboard { 859 | display: flex; 860 | -moz-flex-direction: column; 861 | flex-direction: column; 862 | align-items: center; 863 | } 864 | 865 | @media (max-width: 700px) { 866 | #search-results { 867 | width: 90%; 868 | } 869 | } 870 | 871 | /* Profile */ 872 | 873 | #profile { 874 | padding-top: 30px; 875 | margin-left: 20%; 876 | margin-right: 20%; 877 | display: flex; 878 | -moz-flex-direction: column; 879 | flex-direction: column; 880 | align-items: center; 881 | } 882 | 883 | #profile-banner { 884 | z-index: 1; 885 | background-color: gray; 886 | width: 100%; 887 | height: 160px; 888 | border-radius: 10px; 889 | } 890 | 891 | #profile-top-bg { 892 | position: absolute; 893 | z-index: 0; 894 | width: 100%; 895 | height: 340px; 896 | background-color: white; 897 | box-shadow: 0 2px 3px lightgray; 898 | } 899 | 900 | #profile-avatar { 901 | z-index: 2; 902 | margin-top: 30px; 903 | position: absolute; 904 | width: 160px; 905 | height: 160px; 906 | border-radius: 50%; 907 | border: 6px solid white; 908 | } 909 | 910 | #profile-info { 911 | margin-top: 25px; 912 | margin-bottom: 30px; 913 | z-index: 1; 914 | text-align: center; 915 | } 916 | 917 | .btn-profile { 918 | display: block; 919 | margin: 0 auto; 920 | } 921 | 922 | #profile-friends-posts { 923 | margin-top: 20px; 924 | display: flex; 925 | -moz-flex-direction: row; 926 | flex-direction: row; 927 | } 928 | 929 | #profile-sidebar { 930 | width: 300px; 931 | margin-top: 15px; 932 | margin-right: 20px; 933 | display: flex; 934 | -moz-flex-direction: column; 935 | flex-direction: column; 936 | } 937 | 938 | #profile-invisible { 939 | /* This div is here so #profile-friends won't automatically assume height of 100% */ 940 | height: 100%; 941 | } 942 | 943 | #profile-friends { 944 | padding: 15px 15px 5px 15px; 945 | background-color: white; 946 | box-shadow: -1px 2px 3px lightgray, 1px 2px 3px lightgray; 947 | border-radius: 12px; 948 | } 949 | 950 | #profile-friends-header { 951 | display: flex; 952 | -moz-flex-direction: row; 953 | flex-direction: row; 954 | align-items: center; 955 | justify-content: space-between; 956 | } 957 | 958 | #profile-friends-header h2 { 959 | display: inline; 960 | margin-top: 0; 961 | margin-bottom: 0; 962 | } 963 | 964 | #profile-friends-header a { 965 | color: black; 966 | } 967 | 968 | #profile-friends-header a:hover { 969 | text-decoration: underline; 970 | } 971 | 972 | #profile-friend-list { 973 | flex-wrap: wrap; 974 | display: flex; 975 | -moz-flex-direction: row; 976 | flex-direction: row; 977 | } 978 | 979 | .profile-friend-container { 980 | width: 84px; 981 | height: 120px; 982 | margin-right: 5px; 983 | margin-bottom: 12px; 984 | font-size: 14px; 985 | font-weight: bold; 986 | color: black; 987 | } 988 | 989 | .profile-friend-container img { 990 | width: 84px; 991 | height: 84px; 992 | display: block; 993 | border-radius: 6px; 994 | transition: filter .2s; 995 | } 996 | 997 | .profile-friend-container img:hover { 998 | filter: brightness(80%); 999 | } 1000 | 1001 | .profile-friend-container span { 1002 | display: block; 1003 | margin-top: 2px; 1004 | line-height: 14px; 1005 | } 1006 | 1007 | .profile-friend-container span:hover { 1008 | text-decoration: underline; 1009 | } 1010 | 1011 | @media (max-width: 1000px) { 1012 | #profile { 1013 | margin-left: auto; 1014 | margin-right: auto; 1015 | width: 100%; 1016 | } 1017 | 1018 | #profile-friends-posts { 1019 | flex-direction: column; 1020 | -moz-flex-direction: column; 1021 | align-items: center; 1022 | width: 100%; 1023 | } 1024 | 1025 | #profile-sidebar { 1026 | width: 60%; 1027 | margin-left: auto; 1028 | margin-right: auto; 1029 | } 1030 | } 1031 | 1032 | @media (max-width: 660px) { 1033 | #profile-sidebar { 1034 | width: 90%; 1035 | } 1036 | } 1037 | 1038 | /* Friend list */ 1039 | 1040 | #friend-list { 1041 | width: 650px; 1042 | margin-left: auto; 1043 | margin-right: auto; 1044 | margin-top: 80px; 1045 | padding: 15px 15px 5px 15px; 1046 | background-color: white; 1047 | box-shadow: -1px 2px 3px lightgray, 1px 2px 3px lightgray; 1048 | border-radius: 12px; 1049 | } 1050 | 1051 | #friend-list h2 { 1052 | display: inline; 1053 | } 1054 | 1055 | #friend-list img { 1056 | width: 84px; 1057 | height: 84px; 1058 | margin-right: 10px; 1059 | border-radius: 5px; 1060 | } 1061 | 1062 | #friend-list img:hover { 1063 | filter: brightness(90%); 1064 | } 1065 | 1066 | #friend-list a { 1067 | color: black; 1068 | font-weight: bold; 1069 | } 1070 | 1071 | #friend-list a:hover { 1072 | text-decoration: underline; 1073 | } 1074 | 1075 | #friend-list-header { 1076 | display: flex; 1077 | -moz-flex-direction: row; 1078 | flex-direction: row; 1079 | align-items: center; 1080 | justify-content: space-between; 1081 | height: 40px; 1082 | } 1083 | 1084 | #friend-list-search { 1085 | display: flex; 1086 | -moz-flex-direction: row; 1087 | flex-direction: row; 1088 | align-items: center; 1089 | } 1090 | 1091 | #friend-list-icon { 1092 | outline: 0; 1093 | border: 0; 1094 | border-radius: 15px 0 0 15px; 1095 | background-color: #ebeff0; 1096 | font-size: 16px; 1097 | width: 30px; 1098 | height: 30px; 1099 | padding-left: 20px; 1100 | display: flex; 1101 | justify-content: center; 1102 | align-items: center; 1103 | } 1104 | 1105 | #friend-list-search input { 1106 | outline: 0; 1107 | border: 0; 1108 | border-radius: 0 15px 15px 0; 1109 | padding-left: 19px; 1110 | background-color: #ebeff0; 1111 | font-size: 16px; 1112 | width: 230px; 1113 | height: 28px; 1114 | } 1115 | 1116 | .friend-list-unit { 1117 | display: flex; 1118 | -moz-flex-direction: row; 1119 | flex-direction: row; 1120 | align-items: center; 1121 | } 1122 | 1123 | #friend-list-border { 1124 | border: 1px solid lightgray; 1125 | border-radius: 5px; 1126 | padding: 8px 5px 5px 10px; 1127 | margin-top: 10px; 1128 | margin-bottom: 10px; 1129 | } 1130 | 1131 | /* Post List */ 1132 | 1133 | #post-list-empty { 1134 | width: 600px; 1135 | margin-top: 15px; 1136 | padding: 35px 15px 30px 15px; 1137 | background-color: white; 1138 | box-shadow: -1px 2px 3px lightgray, 1px 2px 3px lightgray; 1139 | border-radius: 12px; 1140 | color: gray; 1141 | -webkit-user-select: none; 1142 | user-select: none; 1143 | text-align: center; 1144 | } 1145 | 1146 | #post-list-message { 1147 | width: 100%; 1148 | margin-top: 30px; 1149 | margin-bottom: 30px; 1150 | text-align: center; 1151 | color: #555555; 1152 | -webkit-user-select: none; 1153 | user-select: none; 1154 | } 1155 | 1156 | .emote { 1157 | font-size: 56px; 1158 | } 1159 | 1160 | /* Requests */ 1161 | 1162 | #requests-container { 1163 | width: 650px; 1164 | margin-top: 90px; 1165 | margin-left: auto; 1166 | margin-right: auto; 1167 | padding: 15px 15px 15px 15px; 1168 | background-color: white; 1169 | box-shadow: -1px 2px 3px lightgray, 1px 2px 3px lightgray; 1170 | border-radius: 12px; 1171 | } 1172 | 1173 | #requests-container h1 { 1174 | margin: 5px 0 5px 0; 1175 | font-size: 22px; 1176 | } 1177 | 1178 | .request-div { 1179 | display: flex; 1180 | -moz-flex-direction: row; 1181 | flex-direction: row; 1182 | align-items: center; 1183 | border-radius: 12px; 1184 | padding: 10px 10px 10px 10px; 1185 | transition: background-color .2s; 1186 | } 1187 | 1188 | .request-div:hover { 1189 | background-color: #ebeff0; 1190 | cursor: pointer; 1191 | } 1192 | 1193 | .request-avatar { 1194 | border-radius: 50%; 1195 | width: 60px; 1196 | height: 60px; 1197 | margin-right: 10px; 1198 | } 1199 | 1200 | .request-buttons { 1201 | display: flex; 1202 | -moz-flex-direction: row; 1203 | flex-direction: row; 1204 | width: 105%; 1205 | } 1206 | 1207 | .request-buttons button { 1208 | margin-right: 5px; 1209 | } 1210 | 1211 | @media (max-width: 700px) { 1212 | #requests-container { 1213 | width: 90%; 1214 | } 1215 | } 1216 | 1217 | /* Settings page */ 1218 | 1219 | #update-form-container { 1220 | width: 650px; 1221 | margin-top: 90px; 1222 | margin-left: auto; 1223 | margin-right: auto; 1224 | padding: 15px 15px 30px 15px; 1225 | background-color: white; 1226 | box-shadow: -1px 2px 3px lightgray, 1px 2px 3px lightgray; 1227 | border-radius: 12px; 1228 | text-align: center; 1229 | } 1230 | 1231 | @media (max-width: 660px) { 1232 | #update-form-container { 1233 | width: 90%; 1234 | margin-bottom: 20px; 1235 | } 1236 | } 1237 | 1238 | /* Error page */ 1239 | 1240 | #error-container { 1241 | width: 650px; 1242 | margin-top: 90px; 1243 | margin-left: auto; 1244 | margin-right: auto; 1245 | padding: 15px 15px 15px 15px; 1246 | background-color: white; 1247 | box-shadow: -1px 2px 3px lightgray, 1px 2px 3px lightgray; 1248 | border-radius: 12px; 1249 | color: gray; 1250 | text-align: center; 1251 | } 1252 | 1253 | /* Loading screen */ 1254 | 1255 | .loading-screen { 1256 | position: fixed; 1257 | z-index: 190; 1258 | top: 0; 1259 | left: 0; 1260 | width: 100vw; 1261 | height: 100vh; 1262 | background-color: #ebeff0; 1263 | display: flex; 1264 | flex-direction: column; 1265 | -moz-flex-direction: column; 1266 | justify-content: center; 1267 | align-items: center; 1268 | opacity: 1; 1269 | transition: opacity .5s; 1270 | } 1271 | 1272 | .loading-screen-fade { 1273 | opacity: 0; 1274 | pointer-events: none; 1275 | } 1276 | 1277 | .loading-spinner { 1278 | animation-name: spinLoading; 1279 | animation-duration: 1s; 1280 | animation-iteration-count: infinite; 1281 | width: 32px; 1282 | height: 32px; 1283 | font-size: 32px; 1284 | color: #1877f2; 1285 | } 1286 | 1287 | @keyframes spinLoading { 1288 | 0% { 1289 | transform: rotate(0deg); 1290 | } 1291 | 100% { 1292 | transform: rotate(359deg); 1293 | } 1294 | } 1295 | 1296 | /* Accessibility */ 1297 | 1298 | .fieldset-sr-only { 1299 | margin-left: -11px; 1300 | border: none; 1301 | } 1302 | 1303 | .sr-only { 1304 | position: absolute; 1305 | left: -9999px; 1306 | } -------------------------------------------------------------------------------- /client/src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import App from './App'; 4 | 5 | ReactDOM.render( 6 | 7 | 8 | , 9 | document.getElementById('root') 10 | ); -------------------------------------------------------------------------------- /client/src/localization.js: -------------------------------------------------------------------------------- 1 | const localStrings = { 2 | 'en-US': { 3 | 'homepage': { 4 | 'homepageDesc': 'Odinclone is a Facebook clone made for learning purposes.', 5 | 'login': 'Login', 6 | 'createAccount': 'Create account', 7 | 'loginWithFb': 'Login with Facebook', 8 | 'credits': 'App made by Lucas Kenji', 9 | 'githubLink': 'My Github', 10 | 'portfolioLink': 'My portfolio', 11 | 'odinProjectLink': 'The Odin Project prompt', 12 | 'emailField': 'Email', 13 | 'passwordField': 'Password', 14 | 'guest': 'or join as Guest', 15 | 'alt': { 16 | 'logo': 'Logo of the website, named Odinclone' 17 | }, 18 | 'error': { 19 | 'badRequest': 'Incorrect username or password.', 20 | 'internal': 'An error occurred. Please try again later.' 21 | } 22 | }, 23 | 24 | 'register': { 25 | 'header': 'Register', 26 | 'firstName': 'First name', 27 | 'lastName': 'Last name', 28 | 'email': 'Email', 29 | 'password': 'Password', 30 | 'birthDate': 'Birth date', 31 | 'january': 'Jan', 32 | 'monthNames': ['Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'], 33 | 'gender': 'Gender', 34 | 'female': 'Female', 35 | 'male': 'Male', 36 | 'other': 'Other', 37 | 'createAccount': 'Create account', 38 | 'alt': { 39 | 'logo': 'Logo of the website, named Odinclone' 40 | }, 41 | 'error': { 42 | 'invalidName': 'Provide a valid name.', 43 | 'invalidEmail': 'Provide a valid email.', 44 | 'invalidPassword': 'Provide a valid password.', 45 | 'invalidData': 'Invalid data.', 46 | 'emailConflict': 'The email provided is already in use.', 47 | 'internal': 'An error occurred. Please try again later.' 48 | } 49 | }, 50 | 51 | 'dashboard': { 52 | 'newPost': 'New Post' 53 | }, 54 | 55 | 'postbox': { 56 | 'header': 'Create a post', 57 | 'postTip': 'Share your thoughts...', 58 | 'urlTip': 'Photo URL', 59 | 'imageButton': 'Image', 60 | 'send': 'Send', 61 | 'error': { 62 | 'badUrl': ' The image URL provided is invalid.', 63 | 'badRequest': 'You must provide a message.', 64 | 'internal': 'An error occurred. Please try again later.' 65 | } 66 | }, 67 | 68 | 'posts': { 69 | 'noPosts': 'Nothing to see here... yet.', 70 | 'enoughPosts': 'That\'s everything for now.', 71 | 'comments': 'Comments', 72 | 'singularComments': 'comment', 73 | 'pluralComments': 'comments', 74 | 'commentTip': 'Write a comment', 75 | 'alt': { 76 | 'userAvatar': 'Avatar of the user that made the post', 77 | 'postImagePrefix': 'Content posted by ', 78 | 'commenterAvatar': 'Avatar of the user that made the comment' 79 | }, 80 | 'error': { 81 | 'internal': 'An error occurred. Please try again later.' 82 | } 83 | }, 84 | 85 | 'friendRequests': { 86 | 'header': 'Friend Requests', 87 | 'noRequest': 'No new requests.', 88 | 'accept': 'Accept', 89 | 'decline': 'Decline', 90 | 'alt': { 91 | 'avatar': 'Avatar of the sender of the request' 92 | }, 93 | 'error': { 94 | 'internal': 'An error occurred. Please try again later.' 95 | } 96 | }, 97 | 98 | 'navbar': { 99 | 'settings': 'Settings', 100 | 'logout': 'Logout', 101 | 'guest': 'Only for registered users', 102 | 'alt': { 103 | 'logo': 'The website\'s logo', 104 | 'userAvatar': 'The avatar of the currently logged user.' 105 | } 106 | }, 107 | 108 | 'search': { 109 | 'header': 'Showing results', 110 | 'searchTip': 'Search for users', 111 | 'alt': { 112 | 'avatar': 'Avatar of an user from the search results' 113 | } 114 | }, 115 | 116 | 'profile': { 117 | 'male': 'male', 118 | 'female': 'female', 119 | 'other': 'other', 120 | 'bornPrefix': 'Born on', 121 | 'noGender': 'no gender defined', 122 | 'unfriend': 'Unfriend', 123 | 'accept': 'Accept friend request', 124 | 'cancel': 'Cancel friend request', 125 | 'send': 'Send friend request', 126 | 'headerFriends': 'Friends', 127 | 'showAllFriends': 'Show all friends', 128 | 'alt': { 129 | 'avatar': 'The avatar of the user whose profile is being displayed', 130 | 'avatarFriend': 'Avatar of one of the user\'s friends' 131 | }, 132 | 'error': { 133 | 'internal': 'An error occurred. Please try again later.', 134 | 'fatalTitle': 'Uh-oh!', 135 | 'fatalMessage': 'An error occurred on the server. Please try again later.' 136 | } 137 | }, 138 | 139 | 'friendList': { 140 | 'header': 'Friends', 141 | 'noFriends': 'Nothing to see here.', 142 | 'searchTip': 'Search', 143 | 'searchTipAlt': 'Search for users on friend list', 144 | 'alt': { 145 | 'avatar': 'Avatar of one of the user\'s friends' 146 | } 147 | }, 148 | 149 | 'settings': { 150 | 'header': 'Settings', 151 | 'firstName': 'First name', 152 | 'lastName': 'Last name', 153 | 'email': 'Email', 154 | 'password': 'Password', 155 | 'avatarUrl': 'Avatar URL', 156 | 'birthDate': 'Birth date', 157 | 'monthNames': ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'], 158 | 'gender': 'Gender', 159 | 'female': 'Female', 160 | 'male': 'Male', 161 | 'other': 'Other', 162 | 'save': 'Save', 163 | 'error': { 164 | 'emailConflict': 'The email provided is already in use.', 165 | 'invalidData': 'Invalid data.', 166 | 'internal': 'An error occurred. Please try again later.', 167 | 'invalidUrl': ' The image URL provided is invalid.' 168 | } 169 | }, 170 | 171 | 'loading': { 172 | 'normal': 'Loading...', 173 | 'mainPage': 'Waiting for Heroku response...' 174 | } 175 | }, 176 | 'pt-BR': { 177 | 'homepage': { 178 | 'homepageDesc': 'Odinclone é um clone de Facebook feito para propósitos de aprendizado.', 179 | 'login': 'Entrar', 180 | 'createAccount': 'Criar uma conta', 181 | 'loginWithFb': 'Logar pelo Facebook', 182 | 'credits': 'Aplicativo feito por Lucas Kenji', 183 | 'githubLink': 'Meu Github', 184 | 'portfolioLink': 'Meu portfólio', 185 | 'odinProjectLink': 'Proposta do The Odin Project', 186 | 'emailField': 'Email', 187 | 'passwordField': 'Senha', 188 | 'guest': 'ou entre como Convidado', 189 | 'alt': { 190 | 'logo': 'Logo do website, chamado Odinclone' 191 | }, 192 | 'error': { 193 | 'badRequest': 'Nome de usuário ou senha incorretos.', 194 | 'internal': 'Ocorreu um erro. Tente novamente mais tarde.' 195 | } 196 | }, 197 | 198 | 'register': { 199 | 'header': 'Cadastrar', 200 | 'firstName': 'Nome', 201 | 'lastName': 'Sobrenome', 202 | 'email': 'Email', 203 | 'password': 'Senha', 204 | 'birthDate': 'Data de nascimento', 205 | 'january': 'Jan', 206 | 'monthNames': ['Fev', 'Mar', 'Abr', 'Mai', 'Jun', 'Jul', 'Ago', 'Set', 'Out', 'Nov', 'Dez'], 207 | 'gender': 'Gênero', 208 | 'female': 'Feminino', 209 | 'male': 'Masculino', 210 | 'other': 'Outro', 211 | 'createAccount': 'Criar conta', 212 | 'alt': { 213 | 'logo': 'Logo do website, chamado Odinclone' 214 | }, 215 | 'error': { 216 | 'invalidName': 'Forneça um nome válido.', 217 | 'invalidEmail': 'Forneça um email válido.', 218 | 'invalidPassword': 'Forneça uma senha válida.', 219 | 'invalidData': 'Dados inválidos.', 220 | 'emailConflict': 'O email providenciado já possui uma conta.', 221 | 'internal': 'Ocorreu um erro. Tente novamente mais tarde.' 222 | } 223 | }, 224 | 225 | 'dashboard': { 226 | 'newPost': 'Nova postagem' 227 | }, 228 | 229 | 'postbox': { 230 | 'header': 'Criar uma postagem', 231 | 'postTip': 'Compartilhe um pensamento...', 232 | 'urlTip': 'URL da imagem', 233 | 'imageButton': 'Imagem', 234 | 'send': 'Enviar', 235 | 'error': { 236 | 'badUrl': ' A URL de imagem providenciada é inválida.', 237 | 'badRequest': 'Você deve providenciar uma mensagem.', 238 | 'internal': 'Ocorreu um erro. Tente novamente mais tarde.' 239 | } 240 | }, 241 | 242 | 'posts': { 243 | 'noPosts': 'Nada a ver aqui... por enquanto.', 244 | 'enoughPosts': 'Isso é tudo por agora.', 245 | 'comments': 'Comentários', 246 | 'singularComments': 'comentário', 247 | 'pluralComments': 'comentários', 248 | 'commentTip': 'Deixe um comentário', 249 | 'alt': { 250 | 'userAvatar': 'Avatar do usuário que fez a postagem', 251 | 'postImagePrefix': 'Conteúdo postado por ', 252 | 'commenterAvatar': 'Avatar do usuário que fez o comentário' 253 | }, 254 | 'error': { 255 | 'internal': 'Ocorreu um erro. Tente novamente mais tarde.' 256 | } 257 | }, 258 | 259 | 'friendRequests': { 260 | 'header': 'Solicitações de amizade', 261 | 'noRequest': 'Sem novas solicitações.', 262 | 'accept': 'Aceitar', 263 | 'decline': 'Recusar', 264 | 'alt': { 265 | 'avatar': 'Avatar da pessoa que enviou a solicitação' 266 | }, 267 | 'error': { 268 | 'internal': 'Ocorreu um erro. Tente novamente mais tarde.' 269 | } 270 | }, 271 | 272 | 'navbar': { 273 | 'settings': 'Configurações', 274 | 'logout': 'Sair', 275 | 'guest': 'Somente para usuários registrados.', 276 | 'alt': { 277 | 'logo': 'O logo do website', 278 | 'userAvatar': 'O avatar do usuário que está logado atualmente.' 279 | } 280 | }, 281 | 282 | 'search': { 283 | 'header': 'Mostrando resultados', 284 | 'searchTip': 'Buscar usuários', 285 | 'alt': { 286 | 'avatar': 'Avatar de um usuário nos resultados de pesquisa' 287 | } 288 | }, 289 | 290 | 'profile': { 291 | 'male': 'masculino', 292 | 'female': 'feminino', 293 | 'other': 'outro', 294 | 'bornPrefix': 'Nasceu em', 295 | 'noGender': 'sem gênero definido', 296 | 'unfriend': 'Desfazer amizade', 297 | 'accept': 'Aceitar solicitação de amizade', 298 | 'cancel': 'Cancelar solicitação de amizade', 299 | 'send': 'Enviar solicitação de amizade', 300 | 'headerFriends': 'Amigos', 301 | 'showAllFriends': 'Mostrar todos os amigos', 302 | 'alt': { 303 | 'avatar': 'O avatar do usuário cujo perfil está sendo mostrado', 304 | 'avatarFriend': 'Avatar de um dos amigos do usuário' 305 | }, 306 | 'error': { 307 | 'internal': 'Ocorreu um erro. Tente novamente mais tarde.', 308 | 'fatalTitle': 'Uh-oh!', 309 | 'fatalMessage': 'Ocorreu um erro no servidor. Tente novamente mais tarde.' 310 | } 311 | }, 312 | 313 | 'friendList': { 314 | 'header': 'Amigos', 315 | 'noFriends': 'Nada a ver aqui.', 316 | 'searchTip': 'Pesquisar', 317 | 'searchTipAlt': 'Pesquisar por usuários na lista de amigos', 318 | 'alt': { 319 | 'avatar': 'Avatar de um dos amigos do usuário.' 320 | } 321 | }, 322 | 323 | 'settings': { 324 | 'header': 'Configurações', 325 | 'firstName': 'Nome', 326 | 'lastName': 'Sobrenome', 327 | 'email': 'Email', 328 | 'password': 'Senha', 329 | 'avatarUrl': 'URL do avatar', 330 | 'birthDate': 'Data de nascimento', 331 | 'monthNames': ['Jan', 'Fev', 'Mar', 'Abr', 'Mai', 'Jun', 'Jul', 'Ago', 'Set', 'Out', 'Nov', 'Dez'], 332 | 'gender': 'Gênero', 333 | 'female': 'Feminino', 334 | 'male': 'Masculino', 335 | 'other': 'Outro', 336 | 'save': 'Salvar', 337 | 'error': { 338 | 'emailConflict': 'O email providenciado já está sendo usado.', 339 | 'invalidData': 'Dados inválidos.', 340 | 'internal': 'Ocorreu um erro. Tente novamente mais tarde.', 341 | 'invalidUrl': ' A URL providenciada é inválida.' 342 | } 343 | }, 344 | 345 | 'loading': { 346 | 'normal': 'Carregando...', 347 | 'mainPage': 'Aguardando resposta da Heroku...' 348 | } 349 | } 350 | }; 351 | 352 | export default localStrings; -------------------------------------------------------------------------------- /server/.env.example: -------------------------------------------------------------------------------- 1 | CONNECTION_STRING=mongoDbConnectionStringHere 2 | FRONTEND_URL='http://localhost:3000' 3 | SESSION_SECRET='someSecretCombination' 4 | FACEBOOK_APP_ID='idOfYourFacebookApp' 5 | FACEBOOK_APP_SECRET='secretOfYourFacebookApp' 6 | JWT_SECRET='someSecretCombination' 7 | CSRF_SECRET='someSecretCombination' -------------------------------------------------------------------------------- /server/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .env -------------------------------------------------------------------------------- /server/README.md: -------------------------------------------------------------------------------- 1 | # Project: Facebook clone || Back-End 2 | Made for The Odin Project. 3 | 4 | ## About 5 | This project was made for a course of Node.js from The Odin Project. By working on this project, I could: 6 | * Handle a diverse amount of problems and situations 7 | * Secure a rest-ish API against CSRF using JWT 8 | * Learn more about acessibility 9 | * Work with React hooks such as useState and useEffect 10 | 11 | ## Getting Started 12 | Follow these instructions to set up the project locally. 13 | 14 | ### Prerequisites 15 | * npm 16 | * Node.js 17 | 18 | ### Installation 19 | 1. Get a free Mongo database at [the official website](https://www.mongodb.com/cloud/atlas) 20 | 2. Clone the repo 21 | 3. Install dependencies with NPM 22 | 23 | ``` 24 | npm install 25 | ``` 26 | 27 | 4. Set the environment variables necessary for this project to work. All of them are specified on the .env.example file. 28 | 29 | 5. Run `node app` to start the server. -------------------------------------------------------------------------------- /server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "odinbook", 3 | "version": "1.0.0", 4 | "main": "index.js", 5 | "scripts": { 6 | "test": "jest --config src/tests/jest.config.js" 7 | }, 8 | "keywords": [], 9 | "author": "", 10 | "license": "ISC", 11 | "description": "", 12 | "dependencies": { 13 | "bcryptjs": "^2.4.3", 14 | "cookie-parser": "^1.4.5", 15 | "cors": "^2.8.5", 16 | "csrf": "^3.1.0", 17 | "dotenv": "^8.2.0", 18 | "express": "^4.17.1", 19 | "express-session": "^1.17.1", 20 | "express-validator": "^6.6.1", 21 | "jsonwebtoken": "^8.5.1", 22 | "mongoose": "^5.10.10", 23 | "passport": "^0.4.1", 24 | "passport-facebook": "^3.0.0", 25 | "passport-local": "^1.0.0" 26 | }, 27 | "devDependencies": { 28 | "jest": "^26.6.1", 29 | "mongodb-memory-server": "^6.9.2", 30 | "supertest": "^5.0.0" 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /server/src/app.js: -------------------------------------------------------------------------------- 1 | require('dotenv').config({ path: '../.env' }); 2 | const express = require('express'); 3 | const app = express(); 4 | const mongoose = require('mongoose'); 5 | const api = require('./routes/api'); 6 | const cors = require('cors'); 7 | const PORT = process.env.PORT || 3030; 8 | const passport = require('passport'); 9 | const session = require('express-session'); 10 | const cookieParser = require('cookie-parser'); 11 | 12 | // Configuration 13 | const connectionString = process.env.CONNECTION_STRING; 14 | 15 | mongoose.connect(connectionString, { useNewUrlParser: true, useUnifiedTopology: true }); 16 | 17 | const db = mongoose.connection; 18 | db.on('error', console.error.bind(console, 'Mongo connection error: ')); 19 | 20 | app.use(cors({ 21 | origin: process.env.FRONTEND_URL, 22 | credentials: true, 23 | exposedHeaders: 'csrf' 24 | })); 25 | 26 | app.use(cookieParser()); 27 | 28 | require('./config/passport.config.js'); 29 | 30 | app.use(session({ 31 | resave: false, 32 | saveUninitialized: false, 33 | secret: process.env.SESSION_SECRET 34 | })); 35 | app.use(passport.initialize()); 36 | app.use(passport.session()); 37 | 38 | 39 | app.use(express.json()); 40 | app.use('/api/', api); 41 | 42 | 43 | // Start server 44 | app.listen(PORT, () => { 45 | console.log(`Server listening on port ${PORT}`); 46 | }) -------------------------------------------------------------------------------- /server/src/config/passport.config.js: -------------------------------------------------------------------------------- 1 | const passport = require('passport'); 2 | const LocalStrategy = require('passport-local').Strategy; 3 | const FacebookStrategy = require('passport-facebook').Strategy; 4 | const User = require('../models/User'); 5 | const bcrypt = require('bcryptjs'); 6 | 7 | passport.use( 8 | new LocalStrategy( 9 | { 10 | usernameField: 'email', 11 | passwordField: 'password' 12 | }, 13 | (username, password, done) => { 14 | User.findOne({email: username}, (err, user) => { 15 | if (err) { 16 | return done(err); 17 | } 18 | 19 | if (!user) { 20 | return done(null, false, { message: 'Incorrect email.' }); 21 | } 22 | 23 | bcrypt.compare(password, user.password) 24 | .then((result) => { 25 | if (!result) { 26 | return done(null, false, { message: 'Incorrect password. '}); 27 | } 28 | 29 | return done(null, user); 30 | }) 31 | }).select("+password") 32 | } 33 | ) 34 | ); 35 | 36 | passport.use( 37 | new FacebookStrategy( 38 | { 39 | clientID: process.env.FACEBOOK_APP_ID, 40 | clientSecret: process.env.FACEBOOK_APP_SECRET, 41 | callbackURL: '/api/facebook/callback', 42 | profileFields: ['id', 'displayName', 'picture.type(large)'] 43 | }, 44 | (accessToken, refreshToken, profile, done) => { 45 | User.findOne({facebookId: profile.id}, (err, user) => { 46 | if (err) { 47 | return done(err); 48 | } 49 | 50 | if (user) { 51 | return done(null, user); 52 | } 53 | 54 | let names = profile.displayName.split(' '); 55 | 56 | if (names.length !== 2) { 57 | // failed to get a first and last name 58 | names = [profile.displayName, '']; 59 | } 60 | 61 | const newUser = new User({ 62 | firstName: names[0], 63 | lastName: names[1], 64 | email: profile.id, 65 | password: 'logged-by-facebook', 66 | birthDate: new Date(), 67 | gender: 'undefined', 68 | friends: [], 69 | photo: profile.photos ? profile.photos[0].value : '', 70 | facebookId: profile.id 71 | }); 72 | 73 | newUser.save((err, createdUser) => { 74 | if (err) { 75 | return done(err); 76 | } 77 | 78 | return done(err, createdUser); 79 | }) 80 | }) 81 | } 82 | ) 83 | ); 84 | 85 | passport.serializeUser((user, done) => { 86 | done(null, user._id); 87 | }) 88 | 89 | passport.deserializeUser((id, done) => { 90 | User.findById(id).select("+password").lean() 91 | .then((user) => { 92 | done(null, user); 93 | }) 94 | .catch((err) => { 95 | done(err, false); 96 | }); 97 | }); -------------------------------------------------------------------------------- /server/src/controllers/auth.js: -------------------------------------------------------------------------------- 1 | const passport = require('passport'); 2 | const jwt = require('jsonwebtoken'); 3 | const Tokens = require('csrf'); 4 | const tokens = new Tokens(); 5 | 6 | exports.checkReferer = (req, res, next) => { 7 | const referer = req.get('Referer'); 8 | const url = new RegExp(process.env.FRONTEND_URL); 9 | 10 | if (url.test(referer)) { 11 | next(); 12 | } else { 13 | return res.status(403).json({ 14 | message: 'Access denied' 15 | }); 16 | } 17 | } 18 | 19 | exports.authenticate = (req, res) => { 20 | // two csrf values are sent, one as a header and one in the jwt payload 21 | // they are both compared later at verifyToken 22 | const csrfToken = tokens.create(process.env.CSRF_SECRET); 23 | 24 | jwt.sign({ csrf: csrfToken }, process.env.JWT_SECRET, (err, token) => { 25 | if (err) { 26 | console.log('Error on JWT.'); 27 | } 28 | 29 | res.cookie('jwtToken', token, { httpOnly: true }); 30 | res.header('CSRF', csrfToken); 31 | 32 | return res.json({ 33 | message: 'Successfully logged', 34 | id: req.user._id 35 | }); 36 | }); 37 | } 38 | 39 | exports.verifyToken = (req, res, next) => { 40 | jwt.verify(req.cookies.jwtToken, process.env.JWT_SECRET, (err, decoded) => { 41 | if (err) { 42 | return res.status(500).json({ 43 | message: 'Internal server error', 44 | details: ['Failed to decode JWT token'] 45 | }); 46 | } 47 | 48 | if (decoded.csrf !== req.headers.csrf) { 49 | return res.status(401).json({ 50 | message: 'Unauthenticated', 51 | details: ['CSRF value does not match'] 52 | }); 53 | } 54 | 55 | next(); 56 | }); 57 | } 58 | 59 | exports.checkAuth = (req, res) => { 60 | if (req.user) { 61 | return res.json({ 62 | isLogged: true, 63 | id: req.user._id 64 | }); 65 | } 66 | 67 | return res.json({ 68 | isLogged: false, 69 | id: null 70 | }); 71 | } 72 | 73 | exports.logout = (req, res) => { 74 | req.logout(); 75 | return res.json({ 76 | message: 'Successfully logged out' 77 | }); 78 | } 79 | 80 | exports.facebookCallback = (req, res, next) => { 81 | passport.authenticate('facebook', (err, user, info) => { 82 | if (err) { return next(err); } 83 | if (!user) { return res.redirect(process.env.FRONTEND_URL); } 84 | 85 | req.logIn(user, (err) => { 86 | if (err) { return next(err); } 87 | 88 | const csrfToken = tokens.create(process.env.CSRF_SECRET); 89 | 90 | jwt.sign({ csrf: csrfToken }, process.env.JWT_SECRET, (err, token) => { 91 | if (err) { 92 | console.log('Error on JWT.'); 93 | } 94 | 95 | res.cookie('jwtToken', token, { httpOnly: true }); 96 | res.cookie('CSRF', csrfToken); 97 | 98 | return res.redirect(process.env.FRONTEND_URL + '/redirect'); 99 | }); 100 | }) 101 | })(req, res, next); 102 | } -------------------------------------------------------------------------------- /server/src/controllers/commentController.js: -------------------------------------------------------------------------------- 1 | const Comment = require('../models/Comment.js'); 2 | const Post = require('../models/Post.js'); 3 | const User = require('../models/User.js'); 4 | const { body, validationResult } = require('express-validator'); 5 | 6 | exports.getAllComments = (req, res) => { 7 | Comment.find({ post: req.params.postid }).populate('author').populate('post') 8 | .then((comments) => { 9 | if (comments.length === 0) { 10 | return res.status(404).json({ 11 | message: 'Comments not found' 12 | }); 13 | } 14 | 15 | return res.json(comments); 16 | }) 17 | .catch((err) => { 18 | return res.status(500).json({ 19 | message: 'An internal error occurred.', 20 | details: err 21 | }) 22 | }) 23 | } 24 | 25 | 26 | exports.getCommentWithId = (req, res) => { 27 | Comment.findById(req.params.commentid) 28 | .then((comment) => { 29 | if (!comment) { 30 | return res.status(404).json({ 31 | message: 'Comment not found.' 32 | }) 33 | } 34 | 35 | res.json(comment); 36 | }) 37 | .catch((err) => { 38 | return res.status(500).json({ 39 | message: 'An internal error occurred.', 40 | details: err 41 | }) 42 | }) 43 | } 44 | 45 | 46 | exports.commentValidation = [ 47 | body('content').not().isEmpty().withMessage('Must provide some content.').trim(), 48 | body('author').not().isEmpty().withMessage('Must provide an author ID.').trim() 49 | ]; 50 | 51 | 52 | exports.createComment = async (req, res) => { 53 | const errors = validationResult(req); 54 | 55 | if (!errors.isEmpty()) { 56 | return res.status(400).json({ 57 | message: 'Bad request.', 58 | details: errors.array() 59 | }); 60 | } 61 | 62 | const commentAuthor = await User.findById(req.body.author); 63 | 64 | if (!commentAuthor) { 65 | return res.status(400).json({ 66 | message: 'Bad request.', 67 | details: ['Author ID provided does not return any users.'] 68 | }) 69 | } 70 | 71 | const commentPost = await Post.findById(req.params.postid); 72 | 73 | if (!commentPost) { 74 | return res.status(400).json({ 75 | message: 'Bad request.', 76 | details: ['Post ID provided does not return any posts.'] 77 | }); 78 | } 79 | 80 | const newComment = new Comment({ 81 | content: req.body.content, 82 | author: req.body.author, 83 | post: req.params.postid, 84 | likes: [] 85 | }); 86 | 87 | try { 88 | const commentResult = await newComment.save(); 89 | return res.json(commentResult); 90 | } catch (err) { 91 | return res.status(500).json({ 92 | message: 'An internal error occurred.', 93 | details: err 94 | }); 95 | } 96 | } 97 | 98 | 99 | exports.updateValidation = [ 100 | body('content').optional().not().isEmpty().withMessage('Must provide some content.').trim() 101 | ]; 102 | 103 | 104 | exports.updateComment = (req, res) => { 105 | const errors = validationResult(req); 106 | 107 | if (!errors.isEmpty()) { 108 | return res.status(400).json({ 109 | message: 'Bad request.', 110 | details: errors.array() 111 | }); 112 | } 113 | 114 | const updatedData = {...req.body}; 115 | 116 | Comment.updateOne({_id: req.params.commentid}, updatedData) 117 | .then((updateResult) => { 118 | if (updateResult.nModified !== 1) { 119 | throw new Error('Update result did not return nModified as 1'); 120 | } 121 | 122 | return res.json({...updatedData, _id: req.params.commentid}); 123 | }) 124 | .catch((err) => { 125 | return res.status(500).json({ 126 | message: 'An internal error occurred.', 127 | details: err 128 | }); 129 | }) 130 | } 131 | 132 | 133 | exports.deleteComment = async (req, res) => { 134 | const deleteResult = await Comment.deleteOne({ _id: req.params.commentid }); 135 | 136 | if (deleteResult.deletedCount === 1) { 137 | return res.json({ _id: req.params.commentid }); 138 | } else { 139 | res.status(500).json({ 140 | message: 'An internal error occurred.', 141 | details: ['Deleted count did not return 1.'] 142 | }); 143 | } 144 | } 145 | 146 | 147 | exports.likeComment = (req, res) => { 148 | if (!req.body._id) { 149 | return res.status(400).json({ 150 | message: 'Bad request.', 151 | details: ['Missing user ID.'] 152 | }); 153 | } 154 | 155 | Comment.findById(req.params.commentid) 156 | .then((comment) => { 157 | if (!comment) { 158 | return res.status(404).json({ 159 | message: 'Comment not found.' 160 | }); 161 | } 162 | 163 | const likes = [...comment.likes]; 164 | const foundUser = likes.find((user) => user.toString() === req.body._id); 165 | 166 | if (foundUser !== undefined) { 167 | return res.status(403).json({ 168 | message: 'Forbidden', 169 | details: ['User has already liked this comment.'] 170 | }) 171 | } 172 | 173 | likes.push(req.body._id); 174 | 175 | Comment.updateOne({ _id: req.params.commentid }, { likes }) 176 | .then((result) => { 177 | return res.json(result); 178 | }) 179 | .catch((err) => { 180 | throw err; 181 | }) 182 | }) 183 | .catch((err) => { 184 | return res.status(500).json({ 185 | message: 'An internal error occurred.', 186 | details: err 187 | }) 188 | }) 189 | } 190 | 191 | 192 | exports.dislikeComment = (req, res) => { 193 | if (!req.body._id) { 194 | return res.status(400).json({ 195 | message: 'Bad request.', 196 | details: ['Missing user ID.'] 197 | }); 198 | } 199 | 200 | Comment.findById(req.params.commentid) 201 | .then((comment) => { 202 | if (!comment) { 203 | return res.status(404).json({ 204 | message: 'Comment not found.' 205 | }); 206 | } 207 | 208 | if (comment.likes.indexOf(req.body._id) === -1) { 209 | return res.status(403).json({ 210 | message: 'Forbidden', 211 | details: ['User has not liked this comment before.'] 212 | }) 213 | } 214 | 215 | const likes = [...comment.likes].filter((user) => user._id.toString() !== req.body._id); 216 | 217 | Comment.updateOne({ _id: req.params.commentid }, { likes }) 218 | .then((result) => { 219 | return res.json(result); 220 | }) 221 | .catch((err) => { 222 | throw err; 223 | }) 224 | }) 225 | .catch((err) => { 226 | return res.status(500).json({ 227 | message: 'An internal error occurred.', 228 | details: err 229 | }) 230 | }) 231 | } -------------------------------------------------------------------------------- /server/src/controllers/friendRequestController.js: -------------------------------------------------------------------------------- 1 | const FriendRequest = require('../models/FriendRequest'); 2 | const User = require('../models/User'); 3 | 4 | exports.getAllRequests = (req, res) => { 5 | FriendRequest.find().lean().populate('sender').populate('receiver') 6 | .then((results) => { 7 | if (results.length === 0) { 8 | return res.status(404).json({ 9 | message: 'No requests found.' 10 | }); 11 | } 12 | 13 | return res.json(results); 14 | }) 15 | .catch((err) => { 16 | return res.status(500).json({ 17 | message: 'Internal server error.', 18 | details: err 19 | }); 20 | }) 21 | } 22 | 23 | 24 | exports.getRequestWithId = (req, res) => { 25 | FriendRequest.findById(req.params.requestid).lean().populate('sender').populate('receiver') 26 | .then((request) => { 27 | if (!request) { 28 | return res.status(404).json({ 29 | message: 'Request not found.' 30 | }); 31 | } 32 | 33 | return res.json(request); 34 | }) 35 | .catch((err) => { 36 | return res.status(500).json({ 37 | message: 'Internal server error.', 38 | details: err 39 | }); 40 | }) 41 | } 42 | 43 | 44 | exports.requestValidation = async (req, res, next) => { 45 | try { 46 | if (!req.body.sender || !req.body.receiver) { 47 | return res.status(400).json({ 48 | message: 'Bad request.', 49 | details: ['Sender or receiver not provided.'] 50 | }) 51 | } 52 | 53 | if (req.body.sender === req.body.receiver) { 54 | return res.status(400).json({ 55 | message: 'Bad request.', 56 | details: ['Sender or receiver not provided.'] 57 | }) 58 | } 59 | 60 | const senderUser = await User.findById(req.body.sender); 61 | const receiverUser = await User.findById(req.body.receiver); 62 | 63 | if (!senderUser || !receiverUser) { 64 | return res.status(400).json({ 65 | message: 'Bad request.', 66 | details: ['The sender or receiver specified does not exist.'] 67 | }); 68 | } 69 | 70 | return next(); 71 | } catch (err) { 72 | return res.status(500).json({ 73 | message: 'Internal server error.', 74 | details: err 75 | }) 76 | } 77 | } 78 | 79 | 80 | exports.createRequest = async (req, res) => { 81 | try { 82 | const newRequest = new FriendRequest({ 83 | sender: req.body.sender, 84 | receiver: req.body.receiver 85 | }); 86 | const saveResult = await newRequest.save(); 87 | 88 | return res.json(saveResult); 89 | } catch (err) { 90 | return res.status(500).json({ 91 | message: 'Internal server error.', 92 | details: err 93 | }) 94 | } 95 | } 96 | 97 | 98 | exports.updateRequest = (req, res) => { 99 | FriendRequest.updateOne({ _id: req.params.requestid }) 100 | .then((result) => { 101 | return res.json({ _id: req.params.requestid }); 102 | }) 103 | .catch((err) => { 104 | return res.status(500).json({ 105 | message: 'Internal server error.', 106 | details: err 107 | }) 108 | }) 109 | } 110 | 111 | 112 | exports.deleteRequest = (req, res) => { 113 | FriendRequest.deleteOne({ _id: req.params.requestid }) 114 | .then((result) => { 115 | return res.json({ _id: req.params.requestid }); 116 | }) 117 | .catch((err) => { 118 | return res.status(500).json({ 119 | message: 'Internal server error.', 120 | details: err 121 | }) 122 | }) 123 | } -------------------------------------------------------------------------------- /server/src/controllers/postController.js: -------------------------------------------------------------------------------- 1 | const Post = require('../models/Post.js'); 2 | const User = require('../models/User.js'); 3 | const { body, validationResult } = require('express-validator'); 4 | 5 | 6 | exports.getAllPosts = (req, res) => { 7 | Post.find().lean().populate('author') 8 | .then((posts) => { 9 | if (posts.length === 0) { 10 | return res.status(404).json({ 11 | message: 'No posts found.', 12 | }); 13 | } 14 | 15 | return res.json(posts); 16 | }) 17 | .catch((err) => { 18 | return res.status(500).json({ 19 | message: 'An internal error occurred.', 20 | details: err 21 | }) 22 | }) 23 | } 24 | 25 | 26 | exports.getPostWithId = (req, res) => { 27 | Post.findById(req.params.postid).lean().populate('author') 28 | .then((post) => { 29 | if (!post) { 30 | return res.status(404).json({ 31 | message: 'No post with such id found.' 32 | }); 33 | } 34 | 35 | return res.json(post); 36 | }) 37 | .catch((err) => { 38 | return res.status(500).json({ 39 | message: 'An internal error occurred.', 40 | details: err 41 | }) 42 | }) 43 | } 44 | 45 | 46 | exports.postValidation = [ 47 | body('content').not().isEmpty().withMessage('Must provide some content').trim(), 48 | body('author').not().isEmpty().withMessage('Must provide an author ID').trim(), 49 | body('timestamp').not().isEmpty().withMessage('Missing timestamp') 50 | ]; 51 | 52 | 53 | exports.createPost = async (req, res) => { 54 | const errors = validationResult(req); 55 | 56 | if (!errors.isEmpty()) { 57 | return res.status(400).json({ 58 | message: 'Bad request.', 59 | details: errors.array() 60 | }) 61 | } 62 | 63 | let postAuthor; 64 | 65 | try { 66 | postAuthor = await User.findOne({_id: req.body.author}); 67 | } catch(err) { 68 | return res.status(500).json({ 69 | message: 'An internal error occurred', 70 | details: err 71 | }); 72 | } 73 | 74 | if (!postAuthor) { 75 | return res.status(400).json({ 76 | message: 'Bad request.', 77 | details: ['Attempted to create a post with an user that does not exist.'] 78 | }) 79 | } 80 | 81 | const newPost = new Post({ 82 | content: req.body.content, 83 | author: req.body.author, 84 | timestamp: req.body.timestamp, 85 | photo: req.body.photo || '', 86 | likes: [] 87 | }); 88 | 89 | try { 90 | const postResult = await newPost.save(); 91 | return res.json(postResult); 92 | } catch (err) { 93 | return res.status(500).json({ 94 | message: 'An internal error occurred.', 95 | details: err 96 | }); 97 | }; 98 | } 99 | 100 | 101 | exports.updateValidation = [ 102 | body('content').optional().not().isEmpty().withMessage('Must provide some content').trim() 103 | ]; 104 | 105 | 106 | exports.updatePost = (req, res) => { 107 | const errors = validationResult(req); 108 | 109 | if (!errors.isEmpty()) { 110 | return res.status(400).json({ 111 | message: 'Bad request.', 112 | details: errors.array() 113 | }) 114 | } 115 | 116 | const updatedData = {...req.body}; 117 | 118 | Post.updateOne({_id: req.params.postid}, updatedData) 119 | .then((updateResult) => { 120 | if (updateResult.nModified !== 1) { 121 | throw new Error('Update result did not return nModified as 1'); 122 | } 123 | 124 | return res.json({...updatedData, _id: req.params.postid}); 125 | }) 126 | .catch((err) => { 127 | return res.status(500).json({ 128 | message: 'An internal error occurred.', 129 | details: err 130 | }); 131 | }) 132 | } 133 | 134 | 135 | exports.deletePost = async (req, res) => { 136 | const deleteResult = await Post.deleteOne({ _id: req.params.postid }); 137 | 138 | if (deleteResult.deletedCount === 1) { 139 | return res.json({ _id: req.params.postid }); 140 | } else { 141 | res.status(500).json({ 142 | message: 'An internal error occurred.', 143 | details: ['Deleted count did not return 1.'] 144 | }); 145 | } 146 | } 147 | 148 | 149 | exports.getRelevantPosts = (req, res) => { 150 | User.findById(req.params.userid, (userErr, user) => { 151 | if (userErr) { 152 | return res.status(500).json({ 153 | message: 'An internal error occurred.', 154 | details: userErr 155 | }) 156 | } 157 | 158 | const friendList = user.friends; 159 | friendList.push(req.params.userid); 160 | 161 | Post.find({ author: {$in: friendList} }).sort({ timestamp: -1 }).limit(30).lean().populate('author') 162 | .then((posts) => { 163 | if (posts.length === 0) { 164 | return res.status(404).json({ 165 | message: 'No posts found.', 166 | }); 167 | } 168 | 169 | return res.json(posts); 170 | }) 171 | .catch((err) => { 172 | return res.status(500).json({ 173 | message: 'An internal error occurred.', 174 | details: err 175 | }) 176 | }) 177 | }); 178 | } 179 | 180 | 181 | exports.likePost = (req, res) => { 182 | if (!req.body._id) { 183 | return res.status(400).json({ 184 | message: 'Bad request.', 185 | details: ['Missing user ID.'] 186 | }); 187 | } 188 | 189 | Post.findById(req.params.postid) 190 | .then((post) => { 191 | if (!post) { 192 | return res.status(404).json({ 193 | message: 'Post not found.' 194 | }); 195 | } 196 | 197 | const likes = [...post.likes]; 198 | const foundUser = likes.find((user) => user.toString() === req.body._id); 199 | 200 | if (foundUser !== undefined) { 201 | return res.status(403).json({ 202 | message: 'Forbidden', 203 | details: ['User has already liked the post.'] 204 | }) 205 | } 206 | 207 | likes.push(req.body._id); 208 | 209 | Post.updateOne({ _id: req.params.postid }, { likes }) 210 | .then((result) => { 211 | return res.json(result); 212 | }) 213 | .catch((err) => { 214 | throw err; 215 | }) 216 | }) 217 | .catch((err) => { 218 | return res.status(500).json({ 219 | message: 'An internal error occurred.', 220 | details: err 221 | }) 222 | }) 223 | } 224 | 225 | 226 | exports.dislikePost = (req, res) => { 227 | if (!req.body._id) { 228 | return res.status(400).json({ 229 | message: 'Bad request.', 230 | details: ['Missing user ID.'] 231 | }); 232 | } 233 | 234 | Post.findById(req.params.postid) 235 | .then((post) => { 236 | if (!post) { 237 | return res.status(404).json({ 238 | message: 'Post not found.' 239 | }); 240 | } 241 | 242 | if (post.likes.indexOf(req.body._id) === -1) { 243 | return res.status(403).json({ 244 | message: 'Forbidden', 245 | details: ['User has not liked the post before.'] 246 | }) 247 | } 248 | 249 | const likes = [...post.likes].filter((user) => user._id.toString() !== req.body._id); 250 | 251 | Post.updateOne({ _id: req.params.postid }, { likes }) 252 | .then((result) => { 253 | return res.json(result); 254 | }) 255 | .catch((err) => { 256 | throw err; 257 | }) 258 | }) 259 | .catch((err) => { 260 | return res.status(500).json({ 261 | message: 'An internal error occurred.', 262 | details: err 263 | }) 264 | }) 265 | } -------------------------------------------------------------------------------- /server/src/controllers/userController.js: -------------------------------------------------------------------------------- 1 | const User = require('../models/User.js'); 2 | const { body, validationResult } = require('express-validator'); 3 | const bcrypt = require('bcryptjs'); 4 | const FriendRequest = require('../models/FriendRequest'); 5 | const Post = require('../models/Post'); 6 | 7 | 8 | exports.getAllUsers = (req, res) => { 9 | User.find().lean().populate('friends') 10 | .then((users) => { 11 | if (users.length === 0) { 12 | return res.status(404).json({ 13 | message: 'No users found.' 14 | }); 15 | } 16 | 17 | res.json(users); 18 | }) 19 | .catch((err) => { 20 | res.status(500).json({ 21 | message: 'An internal error occurred.', 22 | details: err 23 | }); 24 | }); 25 | } 26 | 27 | 28 | exports.getUserWithId = (req, res) => { 29 | User.findById(req.params.userid).populate('friends') 30 | .then((user) => { 31 | if (!user) { 32 | return res.status(404).json({ 33 | message: 'User not found.' 34 | }) 35 | } 36 | 37 | res.json(user); 38 | }) 39 | .catch((err) => { 40 | res.status(500).json({ 41 | message: 'An internal error occurred.', 42 | details: err 43 | }) 44 | }) 45 | } 46 | 47 | 48 | exports.registerValidation = [ 49 | body('firstName').not().isEmpty().withMessage('You must provide your first name.').trim(), 50 | body('lastName').not().isEmpty().withMessage('You must provide your last name.').trim(), 51 | body('email').not().isEmpty().withMessage('You must provide an email.'), 52 | body('email').isEmail().withMessage('The email provided is invalid.').trim(), 53 | body('password').not().isEmpty().withMessage('You must provide a password.').trim(), 54 | body('birthDate').not().isEmpty().withMessage('You must provide a valid birth date.').trim(), 55 | body('gender').not().isEmpty().withMessage('You must provide your gender.').trim() 56 | ]; 57 | 58 | 59 | exports.userValidation = (req, res, next) => { 60 | const errors = validationResult(req); 61 | 62 | if (!errors.isEmpty()) { 63 | return res.status(400).json({ 64 | message: 'Bad request.', 65 | details: errors.array({ onlyFirstError: true }) 66 | }); 67 | } 68 | 69 | User.findOne({ email: req.body.email }) 70 | .then((user) => { 71 | if (user && typeof req.params.userid === 'undefined') { 72 | return res.status(409).json({ 73 | message: 'Conflict.', 74 | details: ['The email provided is already in use.'] 75 | }); 76 | } 77 | 78 | if (user && user._id.toString() !== req.params.userid) { 79 | return res.status(409).json({ 80 | message: 'Conflict.', 81 | details: ['The email provided is already in use.'] 82 | }); 83 | } 84 | 85 | next(); 86 | }) 87 | .catch((err) => { 88 | res.status(500).json({ 89 | message: 'An internal error occurred.', 90 | details: err 91 | }) 92 | }) 93 | } 94 | 95 | 96 | exports.createUser = async (req, res) => { 97 | try { 98 | const salt = await bcrypt.genSalt(10); 99 | const hashedPassword = await bcrypt.hash(req.body.password, salt); 100 | 101 | const newUser = new User({ 102 | firstName: req.body.firstName, 103 | lastName: req.body.lastName, 104 | email: req.body.email, 105 | password: hashedPassword, 106 | birthDate: req.body.birthDate, 107 | gender: req.body.gender, 108 | friends: [], 109 | isGuest: req.body.isGuest ? true : false 110 | }); 111 | 112 | const newDocument = await newUser.save(); 113 | 114 | return res.json(newDocument); 115 | } catch (err) { 116 | res.status(500).json({ 117 | message: 'An internal error occurred.', 118 | details: err 119 | }); 120 | } 121 | } 122 | 123 | 124 | exports.updateValidation = [ 125 | body('firstName').optional().not().isEmpty().withMessage('First name not provided').trim(), 126 | body('lastName').optional().not().isEmpty().withMessage('Last name not provided').trim(), 127 | body('email').optional().not().isEmpty().withMessage('E-mail not provided').trim(), 128 | body('password').optional().not().isEmpty().withMessage('Password not provided').trim(), 129 | body('birthDate').optional().not().isEmpty().withMessage('Birth date not provided').trim(), 130 | body('gender').optional().not().isEmpty().withMessage('Gender not provided').trim() 131 | ]; 132 | 133 | 134 | exports.updateUser = async (req, res) => { 135 | let hashedPassword = ''; 136 | 137 | if (req.body.password) { 138 | try { 139 | const salt = await bcrypt.genSalt(10); 140 | hashedPassword = await bcrypt.hash(req.body.password, salt); 141 | } catch (err) { 142 | res.status(500).json({ 143 | message: 'An internal error occurred.', 144 | details: err 145 | }); 146 | } 147 | } 148 | 149 | const updatedData = {...req.body}; 150 | 151 | if (req.body.password) { 152 | delete updatedData.password; 153 | updatedData.password = hashedPassword; 154 | } 155 | 156 | const updateResult = await User.updateOne({_id: req.params.userid}, updatedData ); 157 | 158 | if (updateResult.nModified === 1) { 159 | return res.json({ ...updatedData, _id: req.params.userid }); 160 | } else { 161 | res.status(500).json({ 162 | message: 'An internal error occurred.', 163 | details: ['Update result did not return 1.'] 164 | }); 165 | } 166 | } 167 | 168 | 169 | exports.deleteUser = async (req, res) => { 170 | const deleteResult = await User.deleteOne({ _id: req.params.userid }); 171 | 172 | if (deleteResult.deletedCount === 1) { 173 | return res.json({ _id: req.params.userid }); 174 | } else { 175 | res.status(500).json({ 176 | message: 'An internal error occurred.', 177 | details: ['Deleted count did not return 1.'] 178 | }); 179 | } 180 | } 181 | 182 | 183 | exports.getUserFriendRequests = (req, res) => { 184 | FriendRequest.find({ receiver: req.params.userid }).lean().populate('sender').populate('receiver') 185 | .then((results) => { 186 | if (results.length === 0) { 187 | return res.status(404).json({ 188 | message: 'No requests found', 189 | }); 190 | } 191 | 192 | return res.json(results); 193 | }) 194 | .catch((err) => { 195 | return res.status(500).json({ 196 | message: 'An internal error occurred.', 197 | details: err 198 | }); 199 | }) 200 | } 201 | 202 | 203 | exports.addFriend = async (req, res) => { 204 | try { 205 | if (!req.body._id) { 206 | return res.status(400).json({ 207 | message: 'Bad request.', 208 | details: ['User id was not sent on request body'] 209 | }); 210 | } 211 | 212 | const userToAdd = await User.findById(req.body._id); 213 | 214 | if (!userToAdd) { 215 | return res.status(400).json({ 216 | message: 'Bad request.', 217 | details: ['ID sent on request body returns no user'] 218 | }); 219 | } 220 | 221 | const userRequested = await User.findById(req.params.userid); 222 | 223 | if (userRequested.friends.indexOf(req.body._id) !== -1) { 224 | return res.status(400).json({ 225 | message: 'Bad request.', 226 | details: ['User already has the requester ID on friend array.'] 227 | }) 228 | } 229 | 230 | userRequested.friends.push(req.body._id); 231 | 232 | const saveResult = userRequested.save(); 233 | return res.json( saveResult ); 234 | } catch(err) { 235 | return res.status(500).json({ 236 | message: 'Internal server error.', 237 | details: err 238 | }) 239 | } 240 | } 241 | 242 | 243 | exports.removeFriend = async (req, res) => { 244 | try { 245 | if (!req.body._id) { 246 | return res.status(400).json({ 247 | message: 'Bad request.', 248 | details: ['User id was not sent on request body'] 249 | }); 250 | } 251 | 252 | const user = await User.findById(req.params.userid); 253 | 254 | if (!user) { 255 | return res.status(400).json({ 256 | message: 'Bad request.', 257 | details: ['User does not exist.'] 258 | }) 259 | } 260 | 261 | user.friends = [...user.friends].filter((friend) => friend.toString() !== req.body._id); 262 | 263 | const updateResult = await User.updateOne({ _id: req.params.userid }, { friends: user.friends }); 264 | 265 | return res.json( updateResult ); 266 | } catch(err) { 267 | return res.status(500).json({ 268 | message: 'Internal server error.', 269 | details: err 270 | }) 271 | } 272 | } 273 | 274 | 275 | exports.getUserPosts = (req, res) => { 276 | Post.find({ author: req.params.userid }).sort({ timestamp: -1 }).populate('author') 277 | .then((posts) => { 278 | if (posts.length === 0) { 279 | return res.status(404).json({ 280 | message: 'No posts found.' 281 | }); 282 | } 283 | 284 | return res.json(posts); 285 | }) 286 | .catch((err) => { 287 | return res.status(500).json({ 288 | message: 'Internal server error.', 289 | details: err 290 | }); 291 | }) 292 | } 293 | 294 | 295 | exports.searchUser = (req, res) => { 296 | let query = req.params.pattern.split(' '); 297 | 298 | if (query.length === 1) { 299 | query = [req.params.pattern, req.params.pattern]; 300 | } 301 | 302 | User.find({ 303 | $or: [ 304 | {firstName: {$regex: query[0], $options: 'i'}}, 305 | {lastName: {$regex: query[1], $options: 'i'}} 306 | ], 307 | isGuest: { $ne: true } 308 | }) 309 | .then((matches) => { 310 | if (matches.length === 0) { 311 | return res.status(404).json({ 312 | message: 'No matches found' 313 | }); 314 | } 315 | 316 | return res.json(matches); 317 | }) 318 | .catch((err) => { 319 | return res.status(500).json({ 320 | message: 'Internal server error.', 321 | details: err 322 | }) 323 | }) 324 | } 325 | -------------------------------------------------------------------------------- /server/src/models/Comment.js: -------------------------------------------------------------------------------- 1 | const mongoose = require('mongoose'); 2 | const Schema = mongoose.Schema; 3 | 4 | const commentSchema = new Schema({ 5 | content: { 6 | type: String, 7 | required: true 8 | }, 9 | likes: [{ 10 | type: Schema.Types.ObjectId, 11 | ref: 'User', 12 | required: true 13 | }], 14 | post: { 15 | type: Schema.Types.ObjectId, 16 | ref: 'Post', 17 | required: true 18 | }, 19 | author: { 20 | type: Schema.Types.ObjectId, 21 | ref: 'User', 22 | required: true 23 | } 24 | }); 25 | 26 | const Comment = mongoose.model('Comment', commentSchema); 27 | 28 | module.exports = Comment; -------------------------------------------------------------------------------- /server/src/models/FriendRequest.js: -------------------------------------------------------------------------------- 1 | const mongoose = require('mongoose'); 2 | const Schema = mongoose.Schema; 3 | 4 | const friendRequestSchema = new Schema({ 5 | sender: { 6 | type: Schema.Types.ObjectId, 7 | ref: 'User', 8 | required: true 9 | }, 10 | receiver: { 11 | type: Schema.Types.ObjectId, 12 | ref: 'User', 13 | required: true 14 | } 15 | }); 16 | 17 | const FriendRequest = mongoose.model('FriendRequest', friendRequestSchema); 18 | 19 | module.exports = FriendRequest; -------------------------------------------------------------------------------- /server/src/models/Post.js: -------------------------------------------------------------------------------- 1 | const mongoose = require('mongoose'); 2 | const Schema = mongoose.Schema; 3 | 4 | const postSchema = new Schema({ 5 | content: { 6 | type: String, 7 | required: true 8 | }, 9 | likes: [{ 10 | type: Schema.Types.ObjectId, 11 | ref: 'User', 12 | required: true 13 | }], 14 | author: { 15 | type: Schema.Types.ObjectId, 16 | ref: 'User', 17 | required: true 18 | }, 19 | timestamp: { 20 | type: Date, 21 | required: true 22 | }, 23 | photo: { 24 | type: String 25 | } 26 | }); 27 | 28 | const Post = mongoose.model('Post', postSchema); 29 | 30 | module.exports = Post; -------------------------------------------------------------------------------- /server/src/models/User.js: -------------------------------------------------------------------------------- 1 | const mongoose = require('mongoose'); 2 | const Schema = mongoose.Schema; 3 | 4 | 5 | const userSchema = new Schema({ 6 | firstName: { 7 | type: String, 8 | required: true 9 | }, 10 | lastName: { 11 | type: String, 12 | required: true 13 | }, 14 | email: { 15 | type: String, 16 | required: true 17 | }, 18 | password: { 19 | type: String, 20 | required: true, 21 | select: false 22 | }, 23 | birthDate: { 24 | type: Date, 25 | required: true 26 | }, 27 | gender: { 28 | type: String, 29 | required: true 30 | }, 31 | friends: [{ 32 | type: Schema.Types.ObjectId, 33 | ref: 'User' 34 | }], 35 | photo: { 36 | type: String, 37 | default: '' 38 | }, 39 | facebookId: { 40 | type: String 41 | }, 42 | isGuest: { 43 | type: Boolean 44 | } 45 | }); 46 | 47 | const User = mongoose.model('User', userSchema); 48 | 49 | 50 | module.exports = User; -------------------------------------------------------------------------------- /server/src/routes/api.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const router = express.Router(); 3 | const passport = require('passport'); 4 | const user = require('../controllers/userController.js'); 5 | const post = require('../controllers/postController.js'); 6 | const comment = require('../controllers/commentController.js'); 7 | const friendRequest = require('../controllers/friendRequestController.js'); 8 | const auth = require('../controllers/auth.js'); 9 | 10 | router.get('/users', auth.checkReferer, user.getAllUsers); 11 | router.get('/users/search/:pattern', auth.checkReferer, user.searchUser); 12 | router.get('/users/:userid', auth.checkReferer, user.getUserWithId); 13 | router.post('/users', auth.checkReferer, user.registerValidation, user.userValidation, user.createUser); 14 | router.put('/users/:userid', auth.checkReferer, user.userValidation, user.updateUser); 15 | router.delete('/users/:userid', auth.checkReferer, user.deleteUser); 16 | router.get('/users/:userid/friendrequests', auth.checkReferer, user.getUserFriendRequests); 17 | router.get('/users/:userid/posts', auth.checkReferer, user.getUserPosts); 18 | router.put('/users/:userid/friend', auth.checkReferer, user.addFriend); 19 | router.put('/users/:userid/unfriend', auth.checkReferer, user.removeFriend); 20 | 21 | router.get('/posts', auth.checkReferer, post.getAllPosts); 22 | router.get('/posts/relevant/:userid', auth.checkReferer, post.getRelevantPosts); 23 | router.get('/posts/:postid', auth.checkReferer, post.getPostWithId); 24 | router.post('/posts', auth.checkReferer, auth.verifyToken, post.postValidation, post.createPost); 25 | router.put('/posts/:postid', auth.checkReferer, post.updateValidation, post.updatePost); 26 | router.delete('/posts/:postid', auth.checkReferer, post.deletePost); 27 | router.put('/posts/:postid/like', auth.checkReferer, post.likePost); 28 | router.put('/posts/:postid/dislike', auth.checkReferer, post.dislikePost); 29 | 30 | router.get('/posts/:postid/comments', auth.checkReferer, comment.getAllComments); 31 | router.get('/posts/:postid/comments/:commentid', auth.checkReferer, comment.getCommentWithId); 32 | router.post('/posts/:postid/comments', auth.checkReferer, auth.verifyToken, comment.commentValidation, comment.createComment); 33 | router.put('/posts/:postid/comments/:commentid', auth.checkReferer, comment.updateValidation, comment.updateComment); 34 | router.delete('/posts/:postid/comments/:commentid', auth.checkReferer, comment.deleteComment); 35 | router.put('/posts/:postid/comments/:commentid/like', auth.checkReferer, comment.likeComment); 36 | router.put('/posts/:postid/comments/:commentid/dislike', auth.checkReferer, comment.dislikeComment); 37 | 38 | router.get('/friendrequests', auth.checkReferer, friendRequest.getAllRequests); 39 | router.get('/friendrequests/:requestid', auth.checkReferer, friendRequest.getRequestWithId); 40 | router.post('/friendrequests', auth.checkReferer, auth.verifyToken, friendRequest.requestValidation, friendRequest.createRequest); 41 | router.put('/friendrequests/:requestid', auth.checkReferer, friendRequest.updateRequest); 42 | router.delete('/friendrequests/:requestid', auth.checkReferer, friendRequest.deleteRequest); 43 | 44 | router.get('/islogged', auth.checkReferer, auth.checkAuth); 45 | router.get('/logout', auth.logout); 46 | router.post('/login', passport.authenticate('local'), auth.authenticate); 47 | router.get('/facebook', passport.authenticate('facebook')); 48 | router.get('/facebook/callback', auth.facebookCallback); 49 | 50 | module.exports = router; -------------------------------------------------------------------------------- /server/src/tests/comments.test.js: -------------------------------------------------------------------------------- 1 | const request = require('supertest'); 2 | const { startDatabase, destroyDatabase, app } = require('./mongo.config.js'); 3 | 4 | let mainUser; 5 | let mainPost; 6 | 7 | beforeAll(async (done) => { 8 | await startDatabase(); 9 | 10 | const newUser = { 11 | firstName: 'Baz', 12 | lastName: 'Qux', 13 | email: 'bazqux@email.com', 14 | password: '12345', 15 | birthDate: new Date(), 16 | gender: 'male' 17 | }; 18 | 19 | const responseUser = await request(app).post('/users').send(newUser); 20 | mainUser = responseUser.body._id; 21 | 22 | const newPost = { 23 | author: mainUser, 24 | content: 'Hello world', 25 | timestamp: new Date() 26 | }; 27 | 28 | const responsePost = await request(app).post('/posts').send(newPost); 29 | mainPost = responsePost.body._id; 30 | 31 | done(); 32 | }, 20000); 33 | 34 | 35 | afterAll(async (done) => { 36 | await destroyDatabase(); 37 | done(); 38 | }, 20000); 39 | 40 | 41 | describe('Comment API', () => { 42 | test('is able to create a comment', (done) => { 43 | const newComment = { 44 | content: 'First comment', 45 | author: mainUser 46 | } 47 | 48 | request(app) 49 | .post(`/posts/${mainPost}/comments`) 50 | .send(newComment) 51 | .expect(200) 52 | .end((err, comment) => { 53 | if (err) throw err; 54 | 55 | expect(comment.body.content).toBe('First comment'); 56 | done(); 57 | }) 58 | }, 20000); 59 | 60 | test('is not able to create a comment with a non-existent user', (done) => { 61 | const newComment = { 62 | content: 'Hello world', 63 | author: mainUser 64 | } 65 | 66 | request(app) 67 | .post('/posts/000000000000/comments') 68 | .send(newComment) 69 | .expect(400, done); 70 | }, 20000) 71 | 72 | test('is not able to create a comment in a non-existent post', (done) => { 73 | const newComment = { 74 | content: 'Hello world', 75 | author: '000000000000' 76 | } 77 | 78 | request(app) 79 | .post('/posts/' + mainPost + '/comments') 80 | .send(newComment) 81 | .expect(400, done); 82 | }, 20000) 83 | 84 | test('is able to modify a comment', (done) => { 85 | const newComment = { 86 | content: 'To be edited', 87 | author: mainUser 88 | } 89 | 90 | request(app) 91 | .post(`/posts/${mainPost}/comments`) 92 | .send(newComment) 93 | .end((err, response) => { 94 | request(app) 95 | .put(`/posts/${mainPost}/comments/${response.body._id}`) 96 | .send({content: 'Edited'}) 97 | .expect(200) 98 | .end((err, updateResult) => { 99 | expect(updateResult.body.content).toBe('Edited'); 100 | done(); 101 | }); 102 | }); 103 | }, 20000) 104 | 105 | test('is able to delete a comment', (done) => { 106 | const newComment = { 107 | content: 'To be deleted', 108 | author: mainUser 109 | } 110 | 111 | request(app) 112 | .post(`/posts/${mainPost}/comments`) 113 | .send(newComment) 114 | .end((err, response) => { 115 | request(app) 116 | .delete(`/posts/${mainPost}/comments/${response.body._id}`) 117 | .expect(200) 118 | .end((err, deleteResult) => { 119 | request(app) 120 | .get(`/posts/${mainPost}/comments/${response.body._id}`) 121 | .expect(404, done); 122 | }); 123 | }); 124 | }, 20000) 125 | }); -------------------------------------------------------------------------------- /server/src/tests/friendrequests.test.js: -------------------------------------------------------------------------------- 1 | const request = require('supertest'); 2 | const { startDatabase, destroyDatabase, app } = require('./mongo.config.js'); 3 | 4 | let mainUser; 5 | 6 | beforeAll(async (done) => { 7 | await startDatabase(); 8 | 9 | const newUser = { 10 | firstName: 'Spam', 11 | lastName: 'Bacon', 12 | email: 'spam@email.com', 13 | password: '12345', 14 | birthDate: new Date(), 15 | gender: 'male' 16 | }; 17 | 18 | const postResult = await request(app).post('/users').send(newUser); 19 | mainUser = postResult.body._id; 20 | done(); 21 | }, 20000); 22 | 23 | 24 | afterAll(async (done) => { 25 | await destroyDatabase(); 26 | done(); 27 | }, 20000); 28 | 29 | 30 | describe('Friend request API', () => { 31 | test('fails if sender does not exist', (done) => { 32 | const newRequest = { 33 | sender: '000000000000', 34 | receiver: mainUser 35 | } 36 | 37 | request(app) 38 | .post('/friendrequests') 39 | .send(newRequest) 40 | .expect(400, done); 41 | }, 20000); 42 | 43 | test('fails if receiver does not exist', (done) => { 44 | const newRequest = { 45 | sender: mainUser, 46 | receiver: '000000000000' 47 | } 48 | 49 | request(app) 50 | .post('/friendrequests') 51 | .send(newRequest) 52 | .expect(400, done); 53 | }); 54 | }) -------------------------------------------------------------------------------- /server/src/tests/jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | testEnvironment: 'node' 3 | }; -------------------------------------------------------------------------------- /server/src/tests/mongo.config.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const app = express(); 3 | const api = require('../routes/api.js'); 4 | const mongoose = require('mongoose'); 5 | const { MongoMemoryServer } = require('mongodb-memory-server'); 6 | 7 | let mongoServer; 8 | 9 | app.use(express.json()); 10 | app.use('/', api); 11 | 12 | const startDatabase = async () => { 13 | mongoServer = new MongoMemoryServer(); 14 | 15 | const connectionString = await mongoServer.getUri(); 16 | 17 | try { 18 | await mongoose.connect(connectionString, { useNewUrlParser: true, useUnifiedTopology: true }); 19 | } catch (err) { 20 | console.log('Mongo connection error:', err); 21 | } 22 | } 23 | 24 | const destroyDatabase = async () => { 25 | await mongoose.disconnect(); 26 | await mongoServer.stop(); 27 | } 28 | 29 | module.exports = { 30 | startDatabase, 31 | destroyDatabase, 32 | app 33 | } -------------------------------------------------------------------------------- /server/src/tests/posts.test.js: -------------------------------------------------------------------------------- 1 | const request = require('supertest'); 2 | const { startDatabase, destroyDatabase, app } = require('./mongo.config.js'); 3 | 4 | let mainUser; 5 | 6 | beforeAll(async (done) => { 7 | await startDatabase(); 8 | 9 | const newUser = { 10 | firstName: 'Baz', 11 | lastName: 'Qux', 12 | email: 'bazqux@email.com', 13 | password: '12345', 14 | birthDate: new Date(), 15 | gender: 'male' 16 | }; 17 | 18 | const postResult = await request(app).post('/users').send(newUser); 19 | mainUser = postResult.body._id; 20 | done(); 21 | }, 20000); 22 | 23 | 24 | afterAll(async (done) => { 25 | await destroyDatabase(); 26 | done(); 27 | }, 20000); 28 | 29 | 30 | describe('Post API', () => { 31 | test('is able to create a post', (done) => { 32 | const newPost = { 33 | content: 'Hello world', 34 | author: mainUser, 35 | timestamp: new Date() 36 | } 37 | 38 | request(app) 39 | .post('/posts') 40 | .send(newPost) 41 | .expect(200) 42 | .end((err, post) => { 43 | if (err) throw err; 44 | 45 | expect(post.body.content).toBe('Hello world'); 46 | done(); 47 | }) 48 | }, 20000); 49 | 50 | test('is not able to create a post with a non-existent user', (done) => { 51 | const newPost = { 52 | content: 'Hello world', 53 | author: '000000000000', 54 | timestamp: new Date() 55 | } 56 | 57 | request(app) 58 | .post('/posts') 59 | .send(newPost) 60 | .expect(400, done); 61 | }, 20000) 62 | 63 | test('is able to modify a post', (done) => { 64 | const newPost = { 65 | content: 'To be edited', 66 | author: mainUser, 67 | timestamp: new Date() 68 | } 69 | 70 | request(app) 71 | .post('/posts') 72 | .send(newPost) 73 | .end((err, response) => { 74 | request(app) 75 | .put('/posts/' + response.body._id) 76 | .send({content: 'Edited'}) 77 | .expect(200) 78 | .end((err, updateResult) => { 79 | expect(updateResult.body.content).toBe('Edited'); 80 | done(); 81 | }); 82 | }); 83 | }, 20000) 84 | 85 | test('is able to delete a post', (done) => { 86 | const newPost = { 87 | content: 'To be deleted', 88 | author: mainUser, 89 | timestamp: new Date() 90 | } 91 | 92 | request(app) 93 | .post('/posts') 94 | .send(newPost) 95 | .end((err, response) => { 96 | request(app) 97 | .delete('/posts/' + response.body._id) 98 | .expect(200) 99 | .end((err, deleteResult) => { 100 | request(app) 101 | .get('/posts/' + response.body._id) 102 | .expect(404, done); 103 | }); 104 | }); 105 | }, 20000) 106 | }); -------------------------------------------------------------------------------- /server/src/tests/users.test.js: -------------------------------------------------------------------------------- 1 | const request = require('supertest'); 2 | const { startDatabase, destroyDatabase, app } = require('./mongo.config.js'); 3 | 4 | 5 | beforeAll(async (done) => { 6 | await startDatabase(); 7 | done(); 8 | }, 20000); 9 | 10 | afterAll(async (done) => { 11 | await destroyDatabase(); 12 | done(); 13 | }, 20000); 14 | 15 | describe('User API', () => { 16 | test('creates user and access it', (done) => { 17 | const newUser = { 18 | firstName: 'John', 19 | lastName: 'Doe', 20 | email: 'johndoe@email.com', 21 | password: '12345', 22 | birthDate: new Date(), 23 | gender: 'male' 24 | }; 25 | 26 | request(app) 27 | .post('/users') 28 | .send(newUser) 29 | .set('Content-Type', 'application/json') 30 | .expect('Content-Type', /json/) 31 | .expect(200) 32 | .end((err, postResult) => { 33 | if (err) throw err; 34 | 35 | const userId = postResult.body._id; 36 | 37 | request(app) 38 | .get('/users/' + userId) 39 | .expect(200) 40 | .end((err, user) => { 41 | if (err) throw err; 42 | 43 | expect(user.body.firstName).toBe('John'); 44 | expect(user.body.gender).toBe('male'); 45 | done(); 46 | }) 47 | }); 48 | }, 20000); 49 | 50 | test('is able to modify an user', (done) => { 51 | const newUser = { 52 | firstName: 'Jane', 53 | lastName: 'Doe', 54 | email: 'janedoe@email.com', 55 | password: '12345', 56 | birthDate: new Date(), 57 | gender: 'female' 58 | }; 59 | 60 | request(app) 61 | .post('/users') 62 | .send(newUser) 63 | .end((err, postResult) => { 64 | if (err) throw err; 65 | 66 | const modifiedUser = { email: 'janedoe@otherdomain.com' }; 67 | 68 | request(app) 69 | .put('/users/' + postResult.body._id) 70 | .send(modifiedUser) 71 | .expect(200) 72 | .end((err, updateResult) => { 73 | expect(updateResult.body.email).toBe('janedoe@otherdomain.com'); 74 | done(); 75 | }) 76 | }) 77 | }, 20000) 78 | 79 | 80 | test('is not able to use an already existing email', (done) => { 81 | const userOne = { 82 | firstName: 'Foo', 83 | lastName: 'Baz', 84 | email: 'foobaz@email.com', 85 | password: '12345', 86 | birthDate: new Date(), 87 | gender: 'male' 88 | }; 89 | 90 | const userTwo = { 91 | firstName: 'Baz', 92 | lastName: 'Foo', 93 | email: 'foobaz@email.com', 94 | password: '12345', 95 | birthDate: new Date(), 96 | gender: 'male' 97 | } 98 | 99 | request(app) 100 | .post('/users') 101 | .send(userOne) 102 | .end((err, postResult) => { 103 | if (err) throw err; 104 | 105 | request(app) 106 | .post('/users') 107 | .send(userTwo) 108 | .expect(400) 109 | .end((err) => { 110 | if (err) throw err; 111 | done(); 112 | }) 113 | }) 114 | }, 20000) 115 | 116 | test('is able to delete an user', (done) => { 117 | const newUser = { 118 | firstName: 'Foo', 119 | lastName: 'Bar', 120 | email: 'foobar@email.com', 121 | password: '12345', 122 | birthDate: new Date(), 123 | gender: 'male' 124 | }; 125 | 126 | request(app) 127 | .post('/users') 128 | .send(newUser) 129 | .end((err, postResult) => { 130 | if (err) throw err; 131 | 132 | request(app) 133 | .delete('/users/' + postResult.body._id) 134 | .expect(200) 135 | .end((err, deleteResult) => { 136 | if (err) throw err; 137 | 138 | request(app) 139 | .get('/users/' + postResult.body._id) 140 | .expect(404, done) 141 | }) 142 | }); 143 | }, 20000) 144 | 145 | test('fails to friend an user that does not exist', async (done) => { 146 | const friender = { 147 | firstName: 'Friend', 148 | lastName: 'Request', 149 | email: 'friend@email.com', 150 | password: '12345', 151 | birthDate: new Date(), 152 | gender: 'male' 153 | } 154 | 155 | const { body : {_id}} = await request(app).post('/users').send(friender); 156 | await request(app).put(`/users/${_id}/friend`).send({ _id: '000000000000'}).expect(400); 157 | done(); 158 | }, 20000) 159 | 160 | test('fails to friend an already friended user', async (done) => { 161 | const friender = { 162 | firstName: 'Friend', 163 | lastName: 'Request', 164 | email: 'friend1@email.com', 165 | password: '12345', 166 | birthDate: new Date(), 167 | gender: 'male' 168 | } 169 | 170 | const friended = { 171 | firstName: 'Requested', 172 | lastName: 'User', 173 | email: 'friend2@email.com', 174 | password: '12345', 175 | birthDate: new Date(), 176 | gender: 'male' 177 | } 178 | 179 | const postOne = await request(app).post('/users').send(friender); 180 | const postTwo = await request(app).post('/users').send(friended); 181 | const frienderId = postOne.body._id; 182 | const friendedId = postTwo.body._id; 183 | 184 | await request(app).put(`/users/${friendedId}/friend`).send({ _id: frienderId}).expect(200); 185 | await request(app).put(`/users/${friendedId}/friend`).send({ _id: frienderId}).expect(400); 186 | done(); 187 | }, 20000) 188 | }); --------------------------------------------------------------------------------