├── social-media-client ├── src │ ├── App.scss │ ├── react-app-env.d.ts │ ├── setupTests.ts │ ├── graphql │ │ ├── models │ │ │ ├── like.model.ts │ │ │ ├── comment.model.ts │ │ │ ├── user.model.ts │ │ │ └── post.model.ts │ │ ├── queries.ts │ │ └── mutations.ts │ ├── data │ │ ├── auth-context.ts │ │ └── AuthContextPrivider.tsx │ ├── reportWebVitals.ts │ ├── index.tsx │ ├── pages │ │ ├── Home │ │ │ ├── Home.scss │ │ │ └── Home.tsx │ │ ├── Login │ │ │ └── Login.tsx │ │ ├── SinglePost │ │ │ └── SinglePost.tsx │ │ └── Register │ │ │ └── Register.tsx │ ├── index.scss │ ├── common │ │ └── auth │ │ │ └── AuthRoute.tsx │ ├── components │ │ ├── CommentForm │ │ │ └── CommentForm.tsx │ │ ├── MenuBar │ │ │ └── MenuBar.tsx │ │ ├── LikeButton │ │ │ └── LikeButton.tsx │ │ ├── DeleteButton │ │ │ └── DeleteButton.tsx │ │ ├── PostForm │ │ │ └── PostForm.tsx │ │ └── PostCard │ │ │ └── PostCard.tsx │ ├── apollo │ │ └── apollo-config.ts │ └── App.tsx ├── public │ ├── robots.txt │ ├── favicon.ico │ ├── logo192.png │ ├── logo512.png │ ├── manifest.json │ └── index.html ├── .gitignore ├── tsconfig.json ├── package.json └── .eslintcache ├── social-media-backend ├── .gitignore ├── models │ ├── User.js │ └── Post.js ├── util │ ├── utils.js │ ├── check-auth.js │ └── validators.js ├── package.json ├── graphql │ ├── resolvers │ │ ├── index.js │ │ ├── comments.js │ │ ├── users.js │ │ └── posts.js │ └── typeDefs.js ├── index.js └── package-lock.json ├── .gitignore ├── package.json └── README.md /social-media-client/src/App.scss: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /social-media-client/src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /social-media-client/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /social-media-client/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BetoGlez/social-media-app/HEAD/social-media-client/public/favicon.ico -------------------------------------------------------------------------------- /social-media-client/public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BetoGlez/social-media-app/HEAD/social-media-client/public/logo192.png -------------------------------------------------------------------------------- /social-media-client/public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/BetoGlez/social-media-app/HEAD/social-media-client/public/logo512.png -------------------------------------------------------------------------------- /social-media-backend/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | 3 | # misc 4 | .DS_Store 5 | .env.local 6 | .env.development.local 7 | .env.test.local 8 | .env.production.local -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | config.js 3 | /.pnp 4 | .pnp.js 5 | 6 | # misc 7 | .DS_Store 8 | .env.local 9 | .env.development.local 10 | .env.test.local 11 | .env.production.local 12 | 13 | npm-debug.log* -------------------------------------------------------------------------------- /social-media-client/src/setupTests.ts: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import '@testing-library/jest-dom'; 6 | -------------------------------------------------------------------------------- /social-media-backend/models/User.js: -------------------------------------------------------------------------------- 1 | const { model, Schema } = require("mongoose"); 2 | 3 | // Define the schema to be used to store data in MongoDB 4 | const userSchema = new Schema({ 5 | username: String, 6 | password: String, 7 | email: String, 8 | createdAt: String 9 | }); 10 | 11 | module.exports = model("User", userSchema); -------------------------------------------------------------------------------- /social-media-backend/util/utils.js: -------------------------------------------------------------------------------- 1 | const jwt = require("jsonwebtoken"); 2 | 3 | const { SECRET_KEY } = require("../config"); 4 | 5 | module.exports.generateToken = (user) => { 6 | return jwt.sign({ 7 | id: user.id, 8 | email: user.email, 9 | username: user.username 10 | }, SECRET_KEY, { expiresIn: "1h" }); 11 | }; -------------------------------------------------------------------------------- /social-media-client/src/graphql/models/like.model.ts: -------------------------------------------------------------------------------- 1 | import { IPost } from "./post.model"; 2 | 3 | export interface ILike { 4 | id: string; 5 | createdAt: string; 6 | username: string; 7 | } 8 | 9 | export interface ILikePostPayload { 10 | postId: string; 11 | } 12 | export interface ILikePostData { 13 | likePost: IPost; 14 | } -------------------------------------------------------------------------------- /social-media-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.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | -------------------------------------------------------------------------------- /social-media-client/src/data/auth-context.ts: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { IUser } from "../graphql/models/user.model"; 3 | 4 | export interface AuthContextModel { 5 | user: IUser | null; 6 | login: (userData: IUser) => void; 7 | logout: () => void; 8 | } 9 | 10 | const AuthContext = React.createContext({ 11 | user: null, 12 | login: () => {}, 13 | logout: () => {} 14 | }); 15 | 16 | export default AuthContext; -------------------------------------------------------------------------------- /social-media-client/src/reportWebVitals.ts: -------------------------------------------------------------------------------- 1 | import { ReportHandler } from 'web-vitals'; 2 | 3 | const reportWebVitals = (onPerfEntry?: ReportHandler) => { 4 | if (onPerfEntry && onPerfEntry instanceof Function) { 5 | import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => { 6 | getCLS(onPerfEntry); 7 | getFID(onPerfEntry); 8 | getFCP(onPerfEntry); 9 | getLCP(onPerfEntry); 10 | getTTFB(onPerfEntry); 11 | }); 12 | } 13 | }; 14 | 15 | export default reportWebVitals; 16 | -------------------------------------------------------------------------------- /social-media-client/src/graphql/models/comment.model.ts: -------------------------------------------------------------------------------- 1 | import { IPost } from "./post.model"; 2 | 3 | export interface IComment { 4 | id: string; 5 | body: string; 6 | username: string; 7 | createdAt: string; 8 | } 9 | 10 | export interface IDeleteCommentPayload { 11 | postId: string; 12 | commentId: string; 13 | } 14 | export interface IDeleteCommentData { 15 | deleteComment: IPost; 16 | } 17 | 18 | export interface ICreateCommentPayload { 19 | postId: string; 20 | body: string; 21 | } 22 | export interface ICreateCommentData { 23 | createComment: IPost; 24 | } -------------------------------------------------------------------------------- /social-media-client/src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import ReactDOM from 'react-dom'; 3 | import './index.scss'; 4 | import App from './App'; 5 | import reportWebVitals from './reportWebVitals'; 6 | 7 | ReactDOM.render( 8 | 9 | 10 | , 11 | document.getElementById('root') 12 | ); 13 | 14 | // If you want to start measuring performance in your app, pass a function 15 | // to log results (for example: reportWebVitals(console.log)) 16 | // or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals 17 | reportWebVitals(); 18 | -------------------------------------------------------------------------------- /social-media-client/src/graphql/models/user.model.ts: -------------------------------------------------------------------------------- 1 | export interface IUser { 2 | id: string; 3 | email: string; 4 | token: string; 5 | username: string; 6 | createdAt: string; 7 | } 8 | 9 | export interface IRegisterUserPayload { 10 | username: string; 11 | email: string; 12 | password: string; 13 | confirmPassword: string; 14 | } 15 | export interface IRegisterUserData { 16 | register: IUser; 17 | } 18 | 19 | export interface ILoginUserPayload { 20 | username: string; 21 | password: string; 22 | } 23 | export interface ILoginUserData { 24 | login: IUser; 25 | } -------------------------------------------------------------------------------- /social-media-client/src/pages/Home/Home.scss: -------------------------------------------------------------------------------- 1 | .home-page { 2 | .post-form-container { 3 | width: 100%; 4 | .post-form { 5 | width: 60%; 6 | } 7 | } 8 | .custom-scrolltop { 9 | width: 2rem; 10 | height: 2rem; 11 | border-radius: 4px; 12 | background-color: var(--primary-color) !important; 13 | } 14 | .custom-scrolltop:hover { 15 | background-color: var(--primary-color) !important; 16 | } 17 | .custom-scrolltop .p-scrolltop-icon { 18 | font-size: 1rem; 19 | color: var(--primary-color-text); 20 | } 21 | } -------------------------------------------------------------------------------- /social-media-backend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "social-media-backend", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "start": "node index", 8 | "serve": "nodemon index" 9 | }, 10 | "keywords": [], 11 | "author": "", 12 | "license": "ISC", 13 | "dependencies": { 14 | "apollo-server": "^2.19.2", 15 | "bcryptjs": "^2.4.3", 16 | "graphql": "^15.5.0", 17 | "jsonwebtoken": "^8.5.1", 18 | "mongoose": "^5.11.14" 19 | }, 20 | "devDependencies": { 21 | "nodemon": "^2.0.7" 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /social-media-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 | -------------------------------------------------------------------------------- /social-media-client/src/index.scss: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", 4 | "Oxygen", "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", 5 | "Helvetica Neue", sans-serif; 6 | -webkit-font-smoothing: antialiased; 7 | -moz-osx-font-smoothing: grayscale; 8 | background-color: #20272d; 9 | h1, h2, h3, h4, h5, h6, p { 10 | color: white; 11 | font-weight: 200; 12 | } 13 | h1 { 14 | font-size: 60px; 15 | } 16 | } 17 | 18 | code { 19 | font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New", 20 | monospace; 21 | } 22 | -------------------------------------------------------------------------------- /social-media-client/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "esModuleInterop": true, 12 | "allowSyntheticDefaultImports": true, 13 | "strict": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "noFallthroughCasesInSwitch": true, 16 | "module": "esnext", 17 | "moduleResolution": "node", 18 | "resolveJsonModule": true, 19 | "isolatedModules": true, 20 | "noEmit": true, 21 | "jsx": "react-jsx" 22 | }, 23 | "include": [ 24 | "src" 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /social-media-backend/models/Post.js: -------------------------------------------------------------------------------- 1 | const { model, Schema } = require("mongoose"); 2 | 3 | const postSchema = new Schema({ 4 | body: String, 5 | username: String, 6 | createdAt: String, 7 | comments: [ 8 | { 9 | body: String, 10 | username: String, 11 | createdAt: String 12 | } 13 | ], 14 | likes: [ 15 | { 16 | username: String, 17 | createdAt: String 18 | } 19 | ], 20 | user: { 21 | type: Schema.Types.ObjectId, 22 | ref: "users" 23 | } 24 | }); 25 | 26 | // Here we create a reference to automatically populate to the user, like a reference 27 | 28 | module.exports = model("Post", postSchema); -------------------------------------------------------------------------------- /social-media-backend/graphql/resolvers/index.js: -------------------------------------------------------------------------------- 1 | const postsResolvers = require("./posts"); 2 | const usersResolvers = require("./users"); 3 | const commentsResolvers = require("./comments"); 4 | 5 | // Graphql modifier fo Post, each time a mutation modifier a Post will go through the modifier 6 | module.exports = { 7 | Post: { 8 | likeCount: (parent) => parent.likes.length, 9 | commentCount: (parent) => parent.comments.length 10 | }, 11 | Query: { 12 | ...postsResolvers.Query 13 | }, 14 | Mutation: { 15 | ...usersResolvers.Mutation, 16 | ...postsResolvers.Mutation, 17 | ...commentsResolvers.Mutation 18 | }, 19 | Subscription: { 20 | ...postsResolvers.Subscription 21 | } 22 | }; -------------------------------------------------------------------------------- /social-media-client/src/graphql/models/post.model.ts: -------------------------------------------------------------------------------- 1 | import { IComment } from "./comment.model"; 2 | import { ILike } from "./like.model"; 3 | 4 | export interface IPost { 5 | id: string; 6 | body: string; 7 | username: string; 8 | createdAt: string; 9 | comments: Array; 10 | likes: Array; 11 | commentCount: number; 12 | likeCount: number; 13 | } 14 | export interface IGetPostsData { 15 | getPosts: Array; 16 | } 17 | 18 | export interface IGetPostPayload { 19 | postId: string; 20 | } 21 | export interface IGetPostData { 22 | getPost: IPost; 23 | } 24 | 25 | export interface ICreatePostPayload{ 26 | body: string; 27 | } 28 | export interface ICreatePostData { 29 | createPost: IPost; 30 | } 31 | 32 | export interface IDeletePostPayload { 33 | postId: string; 34 | } -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "social-media-app", 3 | "version": "1.0.0", 4 | "description": "social-media-app", 5 | "main": "index.js", 6 | "scripts": { 7 | "start": "concurrently -k \"npm run start:backend\" \"npm run start:frontend\"", 8 | "start:backend": "cd social-media-backend/ && npm start", 9 | "start:frontend": "cd social-media-client/ && npm start" 10 | }, 11 | "repository": { 12 | "type": "git", 13 | "url": "git+https://github.com/BetoGlez/social-media-app.git" 14 | }, 15 | "keywords": [], 16 | "author": "", 17 | "license": "ISC", 18 | "bugs": { 19 | "url": "https://github.com/BetoGlez/social-media-app/issues" 20 | }, 21 | "homepage": "https://github.com/BetoGlez/social-media-app#readme", 22 | "dependencies": { 23 | "concurrently": "^5.3.0" 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /social-media-backend/util/check-auth.js: -------------------------------------------------------------------------------- 1 | const jwt = require("jsonwebtoken"); 2 | const { AuthenticationError } = require("apollo-server"); 3 | 4 | const { SECRET_KEY } = require("../config"); 5 | 6 | module.exports = (context) => { 7 | // context = { ...headers } 8 | const authHeader = context.req.headers.authorization; 9 | if (authHeader) { 10 | // Bearer ......Vaue of token as a convention 11 | const token = authHeader.split("Bearer ")[1]; 12 | if (token) { 13 | try { 14 | const user = jwt.verify(token, SECRET_KEY); 15 | return user; 16 | } catch(err) { 17 | throw new AuthenticationError("Invalid or expired token"); 18 | } 19 | } 20 | throw new Error("Authentication token must be \"Bearer [token]"); 21 | } 22 | throw new Error("Authorization header must be provided"); 23 | } -------------------------------------------------------------------------------- /social-media-client/src/common/auth/AuthRoute.tsx: -------------------------------------------------------------------------------- 1 | import React, { useContext } from "react"; 2 | import { Redirect, Route, RouteProps } from "react-router-dom"; 3 | 4 | import AuthContext from "../../data/auth-context"; 5 | 6 | // Route that only allows authenticated users to certain routes 7 | interface AuthRouteProps extends RouteProps { 8 | component: any; 9 | } 10 | const AuthRoute: React.FC = (props) => { 11 | const { user } = useContext(AuthContext); 12 | const { component: Component, ...rest } = props; 13 | 14 | return ( 15 | 18 | !!user ? ( 19 | 20 | ) : ( 21 | 22 | ) 23 | } 24 | /> 25 | ); 26 | }; 27 | 28 | export default AuthRoute; 29 | -------------------------------------------------------------------------------- /social-media-backend/index.js: -------------------------------------------------------------------------------- 1 | const { ApolloServer, PubSub } = require("apollo-server"); 2 | const mongoose = require("mongoose"); 3 | 4 | const { MONGO_DB } = require("./config.js"); 5 | const typeDefs = require("./graphql/typeDefs"); 6 | const resolvers = require("./graphql/resolvers"); 7 | 8 | const pubsub = new PubSub(); 9 | 10 | const PORT = process.env.PORT || 5000; 11 | 12 | // We take the request req from express and forward it to apollo 13 | const server = new ApolloServer({ typeDefs, resolvers, context: ({ req }) => ({ req, pubsub })}); 14 | 15 | // First we connect to mongo DB and after that chain in a promise the server listen 16 | mongoose.connect(MONGO_DB, { useNewUrlParser: true, useUnifiedTopology: true }) 17 | .then(() => { 18 | console.log("Connected to mongo DB"); 19 | return server.listen({ port: PORT }) 20 | }) 21 | .then(res => { 22 | console.log(`Server running at ${res.url}`) 23 | }) 24 | .catch(err => { 25 | console.error("There was an error running the server or connectiong to DB: ", err); 26 | }); -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Social Media App 2 | 3 | A full stack project for a small social media app, using MERNG stack (MongoDB, Express, React JS, Node and GraphQL) 4 | 5 | ## Project demo 6 | 7 | https://youtu.be/pHzLxIJZBCU 8 | 9 | ### Previous configuration 10 | 11 | In order to test it by yourself you should provide a config file under `social-media-backend/config.js` with your custom Mongo DB credentials and a secret key to encode your auth token: 12 | 13 | ```js 14 | module.exports = { 15 | MONGO_DB: "mongodb+srv...YOUR_MONGODB_CREDENTIALS_AND_DATABASE_HERE", 16 | SECRET_KEY: "YOUR_AUTH_TOKEN_KEY_GENERATOR_HERE" 17 | } 18 | ``` 19 | 20 | ### Technolgies used 21 | 22 | * Node JS 23 | * Express 24 | * Apollo Server 25 | * GraphQL 26 | * Mongoose 27 | * Mongo DB 28 | * React JS 29 | * Prime React 30 | * Apollo Client 31 | * Formik 32 | 33 | ### Project features 34 | 35 | * Register user 36 | * Login user 37 | * View all posts 38 | * View a single post 39 | * Create a post 40 | * Delete owned posts 41 | * Comment on a post 42 | * Delete owned comments 43 | * Like and unlike posts 44 | -------------------------------------------------------------------------------- /social-media-client/src/graphql/queries.ts: -------------------------------------------------------------------------------- 1 | import { gql } from "@apollo/client"; 2 | 3 | export abstract class gqlQueries { 4 | public static readonly GET_POSTS = gql` 5 | { 6 | getPosts { 7 | id 8 | body 9 | createdAt 10 | username 11 | commentCount 12 | likeCount 13 | likes { 14 | id 15 | username 16 | } 17 | comments { 18 | id 19 | username 20 | createdAt 21 | body 22 | } 23 | } 24 | } 25 | `; 26 | public static readonly GET_POST = gql` 27 | query getPost($postId: ID!) { 28 | getPost(postId: $postId) { 29 | id 30 | body 31 | username 32 | comments { 33 | id 34 | body 35 | username 36 | } 37 | likes { 38 | id 39 | username 40 | } 41 | likeCount 42 | commentCount 43 | createdAt 44 | } 45 | } 46 | `; 47 | } 48 | -------------------------------------------------------------------------------- /social-media-client/src/components/CommentForm/CommentForm.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import { useMutation } from "@apollo/client"; 3 | import { InputTextarea } from "primereact/inputtextarea"; 4 | import { Button } from "primereact/button"; 5 | import { gqlMutations } from "../../graphql/mutations"; 6 | import { ICreateCommentData, ICreateCommentPayload } from "../../graphql/models/comment.model"; 7 | 8 | interface CommentFormProps { 9 | postId: string; 10 | } 11 | const CommentForm: React.FC = (props) => { 12 | 13 | const [newCommentBody, setNewCommentBody] = useState(""); 14 | 15 | const [createComment] = useMutation(gqlMutations.CREATE_COMMENT, { 16 | variables: { body: newCommentBody, postId: props.postId }, 17 | update: () => {setNewCommentBody("");} 18 | }); 19 | 20 | const submitComment = () => { 21 | if (newCommentBody) { 22 | createComment(); 23 | } 24 | }; 25 | 26 | return ( 27 |
28 | setNewCommentBody((e.target as HTMLTextAreaElement).value)}/> 30 |
32 | ); 33 | }; 34 | 35 | export default CommentForm; -------------------------------------------------------------------------------- /social-media-client/src/apollo/apollo-config.ts: -------------------------------------------------------------------------------- 1 | import { ApolloClient, InMemoryCache, createHttpLink } from "@apollo/client"; 2 | import { setContext } from "@apollo/client/link/context"; 3 | 4 | // Configure our graphql server 5 | const httpLink = createHttpLink({ 6 | uri: "http://localhost:5000" 7 | }); 8 | // Authorization header with our token 9 | const authLink = setContext((_, { headers }) => { 10 | const token = localStorage.getItem("jwtToken"); 11 | return { 12 | headers: { 13 | ...headers, 14 | authorization: token ? `Bearer ${token}` : "" 15 | } 16 | }; 17 | }); 18 | // Configure client set type policy to prevent merge warning in likes 19 | const apolloClient = new ApolloClient({ 20 | link: authLink.concat(httpLink), 21 | cache: new InMemoryCache({ 22 | typePolicies: { 23 | Post: { 24 | fields: { 25 | likes: { 26 | merge: false 27 | }, 28 | comments: { 29 | merge: false 30 | } 31 | } 32 | }, 33 | Query: { 34 | fields: { 35 | getPosts: { 36 | merge(_, incoming) { 37 | return incoming 38 | } 39 | } 40 | } 41 | } 42 | } 43 | }) 44 | }); 45 | 46 | 47 | export default apolloClient; -------------------------------------------------------------------------------- /social-media-backend/graphql/typeDefs.js: -------------------------------------------------------------------------------- 1 | const { gql } = require("apollo-server"); 2 | 3 | module.exports = gql` 4 | type Post { 5 | id: ID! 6 | body: String! 7 | username: String! 8 | createdAt: String! 9 | comments: [Comment]! 10 | likes: [Like]! 11 | commentCount: Int! 12 | likeCount: Int! 13 | } 14 | type Comment { 15 | id: ID! 16 | body: String! 17 | username: String! 18 | createdAt: String! 19 | } 20 | type Like { 21 | id: ID! 22 | createdAt: String! 23 | username: String! 24 | } 25 | type User { 26 | id: ID! 27 | email: String! 28 | token: String! 29 | username: String! 30 | createdAt: String! 31 | } 32 | input RegisterInput { 33 | username: String! 34 | password: String! 35 | confirmPassword: String! 36 | email: String! 37 | } 38 | input LoginInput { 39 | username: String! 40 | password: String! 41 | } 42 | type Query { 43 | getPosts: [Post] 44 | getPost(postId: ID!): Post 45 | } 46 | type Mutation { 47 | register(registerInput: RegisterInput): User! 48 | login(loginInput: LoginInput): User! 49 | createPost(body: String!): Post! 50 | deletePost(postId: ID!): String! 51 | createComment(postId: ID!, body: String!): Post! 52 | deleteComment(postId: ID!, commentId: ID!): Post! 53 | likePost(postId: ID!): Post! 54 | } 55 | type Subscription { 56 | newPost: Post! 57 | } 58 | `; -------------------------------------------------------------------------------- /social-media-client/src/App.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { BrowserRouter as Router, Route } from "react-router-dom"; 3 | import { ApolloProvider } from "@apollo/client"; 4 | import PrimeReact from "primereact/api"; 5 | import "./App.scss"; 6 | 7 | // Prime react css files 8 | import 'primereact/resources/themes/bootstrap4-dark-blue/theme.css'; 9 | import 'primereact/resources/primereact.min.css'; 10 | import 'primeicons/primeicons.css'; 11 | import 'primeflex/primeflex.css'; 12 | 13 | import apolloClient from "./apollo/apollo-config"; 14 | import HomePage from "./pages/Home/Home"; 15 | import LoginPage from "./pages/Login/Login"; 16 | import RegisterPage from "./pages/Register/Register"; 17 | import SinglePostPage from "./pages/SinglePost/SinglePost"; 18 | import MenuBar from "./components/MenuBar/MenuBar"; 19 | import AuthContextProvider from "./data/AuthContextPrivider"; 20 | import AuthRoute from "./common/auth/AuthRoute"; 21 | 22 | const App = () => { 23 | PrimeReact.ripple = true; 24 | 25 | return ( 26 | 27 | 28 | 29 |
30 | 31 | 32 | 33 | 34 | 35 |
36 |
37 |
38 |
39 | ); 40 | } 41 | 42 | export default App; 43 | -------------------------------------------------------------------------------- /social-media-client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "social-media-client", 3 | "version": "1.0.0", 4 | "private": true, 5 | "dependencies": { 6 | "@apollo/client": "^3.3.7", 7 | "@testing-library/jest-dom": "^5.11.4", 8 | "@testing-library/react": "^11.1.0", 9 | "@testing-library/user-event": "^12.1.10", 10 | "@types/jest": "^26.0.15", 11 | "@types/node": "^12.0.0", 12 | "@types/react": "^16.9.53", 13 | "@types/react-dom": "^16.9.8", 14 | "formik": "^2.2.6", 15 | "graphql": "^15.5.0", 16 | "jwt-decode": "^3.1.2", 17 | "moment": "^2.29.1", 18 | "node-sass": "4.14.1", 19 | "primeflex": "^2.0.0", 20 | "primeicons": "^4.1.0", 21 | "primereact": "^6.0.1", 22 | "react": "^17.0.1", 23 | "react-dom": "^17.0.1", 24 | "react-router-dom": "^5.2.0", 25 | "react-scripts": "4.0.1", 26 | "react-transition-group": "^4.4.1", 27 | "typescript": "^4.0.3", 28 | "web-vitals": "^0.2.4" 29 | }, 30 | "scripts": { 31 | "start": "react-scripts start", 32 | "build": "react-scripts build", 33 | "test": "react-scripts test", 34 | "eject": "react-scripts eject" 35 | }, 36 | "eslintConfig": { 37 | "extends": [ 38 | "react-app", 39 | "react-app/jest" 40 | ] 41 | }, 42 | "browserslist": { 43 | "production": [ 44 | ">0.2%", 45 | "not dead", 46 | "not op_mini all" 47 | ], 48 | "development": [ 49 | "last 1 chrome version", 50 | "last 1 firefox version", 51 | "last 1 safari version" 52 | ] 53 | }, 54 | "devDependencies": { 55 | "@types/react-router-dom": "^5.1.7" 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /social-media-client/src/pages/Home/Home.tsx: -------------------------------------------------------------------------------- 1 | import React, { useContext } from "react"; 2 | import "./Home.scss"; 3 | import { useQuery } from "@apollo/client"; 4 | import { ScrollTop } from "primereact/scrolltop"; 5 | import { ScrollPanel } from 'primereact/scrollpanel'; 6 | 7 | import AuthContext from "../../data/auth-context"; 8 | import { gqlQueries } from "../../graphql/queries"; 9 | import { IGetPostsData } from "../../graphql/models/post.model"; 10 | import PostCard from "../../components/PostCard/PostCard"; 11 | import PostForm from "../../components/PostForm/PostForm"; 12 | 13 | 14 | const HomePage: React.FC = () => { 15 | 16 | const { user } = useContext(AuthContext); 17 | const { loading, data } = useQuery(gqlQueries.GET_POSTS); 18 | 19 | return ( 20 |
21 |

Recent posts

22 | { 23 | !!user && 24 |
25 | 26 |
27 | } 28 | 29 |
30 | { 31 | !loading && data && data.getPosts.map(post => ( 32 |
33 | 34 |
35 | )) 36 | } 37 |
38 | 39 |
40 |
41 | ); 42 | }; 43 | 44 | export default HomePage; -------------------------------------------------------------------------------- /social-media-client/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 17 | 18 | 27 | Social Media Beto 28 | 29 | 30 | 31 |
32 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /social-media-client/src/components/MenuBar/MenuBar.tsx: -------------------------------------------------------------------------------- 1 | import React, { useContext, useState } from "react"; 2 | import { TabMenu } from "primereact/tabmenu"; 3 | import { useHistory } from "react-router-dom"; 4 | 5 | import AuthContext from "../../data/auth-context"; 6 | 7 | export interface MenuOption { 8 | route: string; 9 | label: string; 10 | icon: string; 11 | isOnlyForAuth: boolean; 12 | } 13 | 14 | const MenuBar: React.FC = () => { 15 | 16 | const MENU_OPTIONS: Array = [ 17 | {route: "/", label: "Home", icon: "pi pi-fw pi-home", isOnlyForAuth: false}, 18 | {route: "/", label: "Welcome", icon: "pi pi-fw pi-lock-open", isOnlyForAuth: true}, 19 | {route: "/login",label: "Login", icon: "pi pi-fw pi-user", isOnlyForAuth: false}, 20 | {route: "/register",label: "Register", icon: "pi pi-fw pi-user-plus", isOnlyForAuth: false}, 21 | {route: "",label: "Logout", icon: "pi pi-fw pi-sign-out", isOnlyForAuth: true} 22 | ]; 23 | 24 | const history = useHistory(); 25 | const { user, logout } = useContext(AuthContext); 26 | const [activeTab, setActiveTab] = useState(MENU_OPTIONS[0]); 27 | 28 | const handleTabChange = (e: { originalEvent: Event, value: any }) => { 29 | const selectedOption: MenuOption = e.value; 30 | if (selectedOption.route) { 31 | setActiveTab(selectedOption); 32 | history.replace(selectedOption.route); 33 | } else if(selectedOption.label === "Logout") { 34 | logout(); 35 | } 36 | }; 37 | 38 | const composeMenuOptions = (): Array => ( 39 | MENU_OPTIONS.filter(menuOption => menuOption.isOnlyForAuth === !!user ) 40 | ); 41 | 42 | return ( 43 |
44 | 45 |
46 | ); 47 | }; 48 | 49 | export default MenuBar; -------------------------------------------------------------------------------- /social-media-backend/util/validators.js: -------------------------------------------------------------------------------- 1 | module.exports.validateRegisterInput = ( 2 | username, 3 | email, 4 | password, 5 | confirmPassword 6 | ) => { 7 | const errors = {}; 8 | if (username.trim() === "") { 9 | errors.username = "Username must not be empty"; 10 | } 11 | if (email.trim() === "") { 12 | errors.email = "Email must not be empty"; 13 | } else { 14 | // Validate it is an email 15 | const regEx = /^([0-9a-zA-Z]([-.\w]*[0-9a-zA-Z])*@([0-9a-zA-Z][-\w]*[0-9a-zA-Z]\.)+[a-zA-Z]{2,9})$/; 16 | if (!email.match(regEx)) { 17 | errors.email = "Email must be a valid email address"; 18 | } 19 | } 20 | if (password === "") { 21 | errors.password = "Password must not be empty"; 22 | } else if (password !== confirmPassword) { 23 | errors.confirmPassword = "Passwords must match" 24 | } 25 | 26 | return { 27 | errors, 28 | valid: Object.keys(errors).length < 1 29 | }; 30 | }; 31 | 32 | module.exports.validateLoginInput = (username, password) => { 33 | const errors = {}; 34 | if (username.trim() === "") { 35 | errors.username = "Username must not be empty"; 36 | } 37 | if (password.trim() === "") { 38 | errors.password = "Password must not be empty"; 39 | } 40 | 41 | return { 42 | errors, 43 | valid: Object.keys(errors).length < 1 44 | }; 45 | }; 46 | 47 | module.exports.validatePostInput = (body) => { 48 | const errors = {}; 49 | 50 | if (body.trim() === "") { 51 | errors.postBody = "The post body cannot be empty"; 52 | } 53 | 54 | return { 55 | errors, 56 | valid: Object.keys(errors).length < 1 57 | }; 58 | }; 59 | 60 | module.exports.validateCommentInput = (body) => { 61 | const errors = {}; 62 | 63 | if (body.trim() === "") { 64 | errors.postBody = "The comment body cannot be empty"; 65 | } 66 | 67 | return { 68 | errors, 69 | valid: Object.keys(errors).length < 1 70 | }; 71 | }; -------------------------------------------------------------------------------- /social-media-backend/graphql/resolvers/comments.js: -------------------------------------------------------------------------------- 1 | const { UserInputError, AuthenticationError } = require("apollo-server"); 2 | 3 | const Post = require("../../models/Post"); 4 | const checkAuth = require("../../util/check-auth"); 5 | const { validateCommentInput } = require("../../util/validators"); 6 | 7 | module.exports = { 8 | Mutation: { 9 | createComment: async (_, { postId, body }, context) => { 10 | const { username } = checkAuth(context); 11 | const { errors, valid } = validateCommentInput(body); 12 | if (valid) { 13 | const post = await Post.findById(postId); 14 | if (post) { 15 | post.comments.unshift({ 16 | body, 17 | username, 18 | createdAt: new Date().toISOString() 19 | }); 20 | await post.save(); 21 | return post; 22 | } else { 23 | throw new UserInputError("Post not found"); 24 | } 25 | } else { 26 | throw new UserInputError("The comment body cannot be empty", { errors }); 27 | } 28 | }, 29 | deleteComment: async (_, { postId, commentId }, context) => { 30 | const { username } = checkAuth(context); 31 | 32 | const post = await Post.findById(postId); 33 | 34 | if (post) { 35 | const commentIndex = post.comments.findIndex(comment => comment.id === commentId); 36 | if (commentIndex > -1) { 37 | if (post.comments[commentIndex].username === username) { 38 | post.comments.splice(commentIndex, 1); 39 | await post.save(); 40 | return post; 41 | } else { 42 | throw new AuthenticationError("Action not allowed"); 43 | } 44 | } else { 45 | throw new UserInputError("Comment not found"); 46 | } 47 | } else { 48 | throw new UserInputError("Post not found"); 49 | } 50 | } 51 | } 52 | }; -------------------------------------------------------------------------------- /social-media-client/src/data/AuthContextPrivider.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from "react"; 2 | import jwtDecode, { JwtPayload } from "jwt-decode"; 3 | 4 | import AuthContext, { AuthContextModel } from "./auth-context"; 5 | import { IUser } from "../graphql/models/user.model"; 6 | import apolloClient from "../apollo/apollo-config"; 7 | 8 | const AuthContextProvider: React.FC = (props) => { 9 | 10 | const [user, setUser] = useState(null); 11 | 12 | useEffect(() => { 13 | // Persist data from local storage 14 | const token = localStorage.getItem("jwtToken"); 15 | if(token) { 16 | console.log("Token found in local storage"); 17 | const decodedToken = jwtDecode(token); 18 | if (decodedToken.exp && (decodedToken.exp * 1000 < Date.now())) { 19 | console.log("Token expired"); 20 | localStorage.removeItem("jwtToken"); 21 | } else { 22 | const localStorageUser = decodedToken as IUser; 23 | const composedUser: IUser = { 24 | id: localStorageUser.id, 25 | email: localStorageUser.email, 26 | username: localStorageUser.username, 27 | createdAt: "", 28 | token 29 | }; 30 | console.log("Set current user to local storage user: ", composedUser); 31 | setUser(composedUser); 32 | } 33 | } else { 34 | console.log("No token found in local storage"); 35 | } 36 | }, []); 37 | 38 | const login = (user: IUser) => { 39 | setUser(user); 40 | localStorage.setItem("jwtToken", user.token); 41 | }; 42 | 43 | const logout = () => { 44 | setUser(null); 45 | localStorage.removeItem("jwtToken"); 46 | apolloClient.resetStore(); 47 | }; 48 | 49 | const authContext: AuthContextModel = { 50 | user, 51 | login, 52 | logout 53 | }; 54 | 55 | return ( 56 | 57 | {props.children} 58 | 59 | ); 60 | }; 61 | 62 | export default AuthContextProvider; -------------------------------------------------------------------------------- /social-media-client/src/components/LikeButton/LikeButton.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useState } from "react"; 2 | import { useHistory } from "react-router-dom"; 3 | import { Button } from "primereact/button"; 4 | import { Tooltip } from "primereact/tooltip"; 5 | import { useMutation } from "@apollo/client"; 6 | 7 | import { ILike, ILikePostData, ILikePostPayload } from "../../graphql/models/like.model"; 8 | import { IUser } from "../../graphql/models/user.model"; 9 | import { gqlMutations } from "../../graphql/mutations"; 10 | 11 | interface LikeButtonProps { 12 | id: string; 13 | likes: Array; 14 | likeCount: number; 15 | className?: string; 16 | user: IUser | null; 17 | } 18 | const LikeButton: React.FC = (props) => { 19 | 20 | const history = useHistory(); 21 | const [liked, setLiked] = useState(false); 22 | 23 | useEffect(() => { 24 | if (props.user && props.likes.find(like => like.username === props.user?.username)) { 25 | // The current user have liked this post before 26 | setLiked(true); 27 | } else { 28 | setLiked(false); 29 | } 30 | }, [props.user, props.likes]); 31 | 32 | const [likePost] = useMutation(gqlMutations.LIKE_POST); 33 | 34 | const clikLikeButton = () => { 35 | if (props.user) { 36 | likePost({ variables: { postId: props.id } }); 37 | } else { 38 | history.replace("/login"); 39 | } 40 | }; 41 | 42 | const composeUserLikes = (likes: Array): string => { 43 | let userLikesMsg = "No likes yet"; 44 | if (likes.length > 0) { 45 | userLikesMsg = likes.map(like => { 46 | return like.username; 47 | }) 48 | .reduce((prev, curr) => prev + ", " + curr); 49 | } 50 | return userLikesMsg; 51 | }; 52 | 53 | return ( 54 |
55 | 56 |
60 | ); 61 | }; 62 | 63 | export default LikeButton; -------------------------------------------------------------------------------- /social-media-client/src/components/DeleteButton/DeleteButton.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from "react"; 2 | import { useHistory } from "react-router-dom"; 3 | import { ApolloCache, FetchResult, useMutation } from "@apollo/client"; 4 | import { Button } from "primereact/button"; 5 | import { ConfirmDialog } from 'primereact/confirmdialog'; 6 | 7 | import { gqlMutations } from "../../graphql/mutations"; 8 | import { IDeletePostPayload, IGetPostsData } from "../../graphql/models/post.model"; 9 | import { IDeleteCommentData, IDeleteCommentPayload } from "../../graphql/models/comment.model"; 10 | import { gqlQueries } from "../../graphql/queries"; 11 | 12 | interface DeleteButtonProps { 13 | className?: string; 14 | postId: string; 15 | commentId?: string; 16 | } 17 | const DeleteButton: React.FC = (props) => { 18 | 19 | const history = useHistory(); 20 | const [isConfirmOpen, setIsConfirmOpen] = useState(false); 21 | 22 | const mutationQuery = props.commentId ? gqlMutations.DELETE_COMMENT : gqlMutations.DELETE_POST; 23 | const [deletePostOrCommentMut] = useMutation<{} | IDeleteCommentData, IDeletePostPayload | IDeleteCommentPayload>(mutationQuery, { 24 | update: (proxy, result) => handleDelete(proxy, result) 25 | }); 26 | 27 | const handleDelete = (proxy: ApolloCache<{}>, result: FetchResult<{}>) => { 28 | const data = proxy.readQuery({ query: gqlQueries.GET_POSTS }); 29 | if (data && !props.commentId) { 30 | proxy.writeQuery({ query: gqlQueries.GET_POSTS, data: { 31 | ...data, 32 | getPosts: data.getPosts.filter(post => post.id !== props.postId) 33 | }}) 34 | } 35 | history.replace("/"); 36 | }; 37 | 38 | const deletePostOrComment = (postId: string, commentId?: string) => { 39 | if (commentId) { 40 | console.log("Deleting comment: ", commentId); 41 | } else { 42 | console.log("Deleting post: ", postId); 43 | } 44 | deletePostOrCommentMut({ variables: { postId, commentId }}); 45 | }; 46 | 47 | return ( 48 | <> 49 | setIsConfirmOpen(false)} acceptClassName="p-button-danger" 50 | message={`Do you want to delete this ${props.commentId ? "comment" : "post"}?`} icon="pi pi-exclamation-triangle" 51 | accept={() => deletePostOrComment(props.postId, props.commentId)} reject={() => setIsConfirmOpen(false)} /> 52 |