├── backend ├── .babelrc ├── .env.example ├── .gitignore ├── controllers │ ├── auth.js │ ├── comments.js │ └── posts.js ├── data │ └── reddit-db.js ├── models │ ├── comment.js │ ├── post.js │ └── user.js ├── package-lock.json ├── package.json ├── routes │ ├── index.js │ ├── posts.js │ └── users.js ├── server.js ├── tests │ ├── auth.test.js │ ├── index.test.js │ └── posts.test.js └── util │ └── autopopulate.js └── frontend ├── .env.example ├── .gitignore ├── components ├── Comments │ ├── CommentList.js │ ├── CommentReply.js │ └── NewComment.js ├── Layout.js ├── Navigation.js ├── PanelLeft.js └── PanelRight.js ├── helpers └── cookies.js ├── next.config.js ├── package-lock.json ├── package.json ├── pages ├── _app.js ├── api │ └── hello.js ├── index.js ├── login.js ├── posts │ └── new.js ├── r │ └── [sub] │ │ ├── [slug].js │ │ └── index.js └── signup.js ├── public ├── favicon.ico └── vercel.svg └── styles ├── Auth.module.css ├── Comments.module.css ├── Home.module.css ├── Layout.module.css └── globals.css /backend/.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "@babel/preset-env" 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /backend/.env.example: -------------------------------------------------------------------------------- 1 | PORT= 2 | MONGODB_URI= 3 | SECRET= 4 | -------------------------------------------------------------------------------- /backend/.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 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | 27 | # local env files 28 | .env 29 | .env.local 30 | .env.development.local 31 | .env.test.local 32 | .env.production.local 33 | 34 | # vercel 35 | .vercel 36 | -------------------------------------------------------------------------------- /backend/controllers/auth.js: -------------------------------------------------------------------------------- 1 | const mongoose = require("mongoose") 2 | const jwt = require('jsonwebtoken') 3 | const User = require('../models/user') 4 | 5 | const UserController = {} 6 | 7 | UserController.newUser = async (req, res) => { 8 | try { 9 | const user = new User(req.body) 10 | await user.save() 11 | res 12 | return res.cookie('nToken', jwt.sign({ _id: user._id }, process.env.SECRET, { expiresIn: "60 days" }), { maxAge: 900000, httpOnly: false }).json({ message: "User created successfully"}) 13 | } catch (err) { 14 | return res.status(500).json({ message: err.message }) 15 | } 16 | } 17 | 18 | UserController.login = async (req, res) => { 19 | try { 20 | const username = req.body.username; 21 | const password = req.body.password; 22 | const user = await User.findOne({ username }, "username password") 23 | if (!user) return res.status(401).send({ message: "Wrong Username or Password" }); 24 | user.comparePassword(password, (err, isMatch) => { 25 | if (!isMatch) return res.status(401).send({ message: "Wrong Username or password" }); 26 | if (err) return res.status(500).send({ message: err.message}); 27 | return res.cookie('nToken', jwt.sign({ _id: user._id, username: user.username }, process.env.SECRET, { expiresIn: "60 days" }), { maxAge: 900000, httpOnly: false }).json({ message: "User login successful"}) 28 | }); 29 | } catch (err) { 30 | return res.status(500).json({ message: err.message }) 31 | } 32 | } 33 | 34 | UserController.logout = (req, res) => { 35 | res.clearCookie("nToken") 36 | return res.status(200).json({message: "User logged out"}) 37 | } 38 | 39 | module.exports = UserController 40 | -------------------------------------------------------------------------------- /backend/controllers/comments.js: -------------------------------------------------------------------------------- 1 | const mongoose = require("mongoose") 2 | const Comment = require('../models/comment') 3 | const Post = require('../models/post') 4 | const User = require('../models/user'); 5 | 6 | const CommentController = {} 7 | 8 | CommentController.newComment = async (req, res) => { 9 | if (req.user) { 10 | try { 11 | const comment = new Comment(req.body) 12 | comment.author = req.user._id 13 | const savedComment = await comment.save() 14 | const post = await Post.findById(req.params.postId) 15 | post.comments.unshift(savedComment) 16 | await post.save() 17 | const user = await User.findById(req.user._id) 18 | user.comments.unshift(savedComment) 19 | user.save() 20 | return res.json({ message: "Comment created successfully"}) 21 | } catch (err) { 22 | return res.status(500).json({ message: err.message }) 23 | } 24 | } else return res.status(401).json({message: "Unauthorized"}) 25 | } 26 | 27 | CommentController.newCommentReply = async (req, res) => { 28 | if (req.user) { 29 | try { 30 | const reply = new Comment(req.body); 31 | reply.author = req.user._id 32 | const savedReply = await reply.save() 33 | console.log(req.params) 34 | const comment = await Comment.findById(req.params.commentId) 35 | if (!comment) return res.status(500).json({ message: "Comment not found" }) 36 | comment.comments.unshift(savedReply) 37 | comment.save() 38 | return res.json({ message: "Reply created successfully"}) 39 | } catch (err) { 40 | return res.status(500).json({ message: err.message }) 41 | } 42 | } else return res.status(401).json({message: "Unauthorized"}) 43 | } 44 | module.exports = CommentController 45 | -------------------------------------------------------------------------------- /backend/controllers/posts.js: -------------------------------------------------------------------------------- 1 | const mongoose = require("mongoose") 2 | const Post = require('../models/post') 3 | const User = require('../models/user'); 4 | const PostController = {} 5 | 6 | PostController.newPost = (req, res) => { 7 | if (req.user) { 8 | const post = new Post(req.body) 9 | post.author = req.user._id 10 | post.upVotes = [] 11 | post.downVotes = [] 12 | post.voteScore = 0 13 | post.save().then(post => { 14 | return User.findById(req.user._id); 15 | }).then(user => { 16 | user.posts.unshift(post); 17 | user.save(); 18 | // SHOULD REDIRECT TO THE NEW POST 19 | return res.json({ message: "Post created successfully"}) 20 | }).catch(err => { 21 | return res.status(500).json({ message: err.message }) 22 | }); 23 | } else return res.status(401).json({message: "Unauthorized"}) 24 | } 25 | 26 | PostController.allPosts = (req, res) => { 27 | Post.find({}).populate('comments').lean().then(posts => { 28 | res.status(200).json({ posts }); 29 | }).catch(err => { 30 | console.log(err.message); 31 | }) 32 | } 33 | 34 | PostController.postBySlug = (req, res) => { 35 | Post.findOne({slug: req.params.slug}).populate('comments').lean().then(post => { 36 | res.status(200).json({ post }); 37 | }).catch(err => { 38 | console.log(err.message); 39 | }) 40 | } 41 | 42 | PostController.postsBySub = (req, res) => { 43 | Post.find({subreddit: req.params.sub, slug: req.params.slug}).populate('comments').lean().then(posts => { 44 | res.status(200).json({ posts }); 45 | }).catch(err => { 46 | console.log(err.message); 47 | }) 48 | } 49 | 50 | PostController.upvote = (req, res) => { 51 | if (req.user) { 52 | Post.findById(req.params.id).exec(function(err, post) { 53 | post.upVotes.push(req.user._id); 54 | post.voteScore = post.voteScore + 1; 55 | post.save(); 56 | return res.status(200).json({ voteScore: post.voteScore }); 57 | }); 58 | } else return res.status(401).json({message: "Unauthorized"}) 59 | } 60 | 61 | PostController.downvote = (req, res) => { 62 | if (req.user) { 63 | Post.findById(req.params.id).exec(function(err, post) { 64 | post.downVotes.push(req.user._id); 65 | post.voteScore = post.voteScore - 1; 66 | post.save(); 67 | return res.status(200).json({ voteScore: post.voteScore }); 68 | }); 69 | } else return res.status(401).json({message: "Unauthorized"}) 70 | } 71 | 72 | module.exports = PostController 73 | -------------------------------------------------------------------------------- /backend/data/reddit-db.js: -------------------------------------------------------------------------------- 1 | const mongoose = require("mongoose") 2 | const assert = require("assert") 3 | 4 | mongoose.Promise = global.Promise 5 | mongoose.connect( 6 | process.env.MONGODB_URI, 7 | { 8 | useNewUrlParser: true, 9 | useUnifiedTopology: true, 10 | useCreateIndex: true, 11 | useFindAndModify: false 12 | }, 13 | function(err, db) { 14 | assert.equal(null, err) 15 | console.log("Connected successfully to database") 16 | 17 | // db.close(); turn on for testing 18 | } 19 | ); 20 | mongoose.connection.on("error", console.error.bind(console, "MongoDB connection Error:")) 21 | mongoose.set("debug", true) 22 | 23 | module.exports = mongoose.connection 24 | -------------------------------------------------------------------------------- /backend/models/comment.js: -------------------------------------------------------------------------------- 1 | const mongoose = require("mongoose") 2 | const Schema = mongoose.Schema 3 | const Populate = require("../util/autopopulate") 4 | 5 | const CommentSchema = new Schema({ 6 | author : { type: Schema.Types.ObjectId, ref: "User", required: true }, 7 | content: { type: String, required: true }, 8 | comments: [{type: Schema.Types.ObjectId, ref: "Comment"}] 9 | }, {timestamps: {createdAt: 'created_at'}}) 10 | 11 | CommentSchema 12 | .pre('findOne', Populate('author')) 13 | .pre('find', Populate('author')) 14 | .pre('findOne', Populate('comments')) 15 | .pre('find', Populate('comments')) 16 | 17 | module.exports = mongoose.model("Comment", CommentSchema) 18 | -------------------------------------------------------------------------------- /backend/models/post.js: -------------------------------------------------------------------------------- 1 | const mongoose = require("mongoose") 2 | const slugify = require("slugify") 3 | const Schema = mongoose.Schema 4 | const Populate = require("../util/autopopulate") 5 | 6 | const PostSchema = new Schema({ 7 | author : { type: Schema.Types.ObjectId, ref: "User", required: true }, 8 | title: { type: String, required: true }, 9 | slug: { type: String, required: true, unique: true }, 10 | summary: { type: String, required: true }, 11 | subreddit: { type: String, required: true }, 12 | comments: [{ type: Schema.Types.ObjectId, ref: 'Comment' }], 13 | upVotes : [{ type: Schema.Types.ObjectId, ref: "User"}], 14 | downVotes : [{ type: Schema.Types.ObjectId, ref: "User"}], 15 | voteScore : {type: Number} 16 | }, {timestamps: {createdAt: 'created_at'}}) 17 | 18 | // Create a unique slug based on title 19 | PostSchema.pre('findOne', Populate('author')) 20 | .pre('find', Populate('author')) 21 | .pre("validate", function(next) { 22 | const post = this; 23 | if (post.title) { 24 | post.slug = slugify(post.title, { lower: true, strict: true }); 25 | } 26 | next(); 27 | }) 28 | 29 | module.exports = mongoose.model("Post", PostSchema) 30 | -------------------------------------------------------------------------------- /backend/models/user.js: -------------------------------------------------------------------------------- 1 | const mongoose = require("mongoose") 2 | const bcrypt = require("bcryptjs") 3 | const Schema = mongoose.Schema 4 | 5 | const UserSchema = new Schema({ 6 | password: { type: String, select: false }, 7 | username: { type: String, required: true, unique: true }, 8 | posts: [{ type: Schema.Types.ObjectId, ref: "Post" }], 9 | comments: [{ type: Schema.Types.ObjectId, ref: "Comment" }] 10 | }, 11 | {timestamps: true} 12 | ); 13 | 14 | UserSchema.pre("save", function (next) { 15 | const user = this 16 | if (!user.isModified("password")) { 17 | return next() 18 | } 19 | bcrypt.genSalt(10, (err, salt) => { 20 | bcrypt.hash(user.password, salt, (err, hash) => { 21 | user.password = hash 22 | next(); 23 | }); 24 | }); 25 | }); 26 | 27 | UserSchema.methods.comparePassword = function (password, done) { 28 | bcrypt.compare(password, this.password, (err, isMatch) => { 29 | done(err, isMatch) 30 | }); 31 | }; 32 | 33 | module.exports = mongoose.model("User", UserSchema) 34 | -------------------------------------------------------------------------------- /backend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "reddit-clone-backend", 3 | "version": "1.0.0", 4 | "description": "Clone of Reddit website", 5 | "main": "server.js", 6 | "scripts": { 7 | "start": "nodemon --exec babel-node server.js", 8 | "test": "mocha tests/*.test.js", 9 | "test-watch": "nodemon --exec 'npm test'" 10 | }, 11 | "author": "Philippos Tsamantanis", 12 | "license": "ISC", 13 | "devDependencies": { 14 | "@babel/core": "^7.12.10", 15 | "@babel/node": "^7.12.10", 16 | "@babel/preset-env": "^7.12.11", 17 | "chai": "^4.2.0", 18 | "chai-http": "^4.3.0", 19 | "mocha": "^8.2.1", 20 | "nodemon": "^2.0.7" 21 | }, 22 | "dependencies": { 23 | "assert": "^2.0.0", 24 | "bcryptjs": "^2.4.3", 25 | "cookie-parser": "^1.4.5", 26 | "cors": "^2.8.5", 27 | "dotenv": "^8.2.0", 28 | "express": "^4.17.1", 29 | "jsonwebtoken": "^8.5.1", 30 | "mongoose": "^5.11.14", 31 | "slugify": "^1.4.6" 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /backend/routes/index.js: -------------------------------------------------------------------------------- 1 | const router = require('express').Router() 2 | 3 | router.use('/posts', require('./posts')) 4 | router.use('/users', require('./users')) 5 | router.get('/', function(req, res) { 6 | return res.send("success") 7 | }) 8 | module.exports = router; 9 | -------------------------------------------------------------------------------- /backend/routes/posts.js: -------------------------------------------------------------------------------- 1 | const router = require('express').Router() 2 | const { 3 | newPost, 4 | allPosts, 5 | postBySlug, 6 | postsBySub, 7 | upvote, 8 | downvote, 9 | } = require('../controllers/posts') 10 | 11 | const { 12 | newComment, 13 | newCommentReply 14 | } = require('../controllers/comments') 15 | 16 | router.get("/", allPosts) 17 | router.get("/r/:sub", postsBySub) 18 | router.get("/r/:sub/:slug", postBySlug) 19 | router.post("/new", newPost) 20 | router.post("/:postId/comments/new", newComment) 21 | router.post("/:postId/comments/:commentId/replies/new", newCommentReply) 22 | router.put("/:id/upvote", upvote) 23 | router.put("/:id/downvote", downvote) 24 | 25 | 26 | module.exports = router 27 | -------------------------------------------------------------------------------- /backend/routes/users.js: -------------------------------------------------------------------------------- 1 | const router = require('express').Router() 2 | const { 3 | newUser, 4 | login, 5 | logout 6 | } = require('../controllers/auth') 7 | 8 | router.post("/sign-up", newUser) 9 | router.post("/login", login) 10 | router.get("/logout", logout) 11 | 12 | module.exports = router 13 | -------------------------------------------------------------------------------- /backend/server.js: -------------------------------------------------------------------------------- 1 | require('dotenv/config') 2 | const cors = require('cors') 3 | const express = require('express') 4 | const cookieParser = require('cookie-parser') 5 | const jwt = require('jsonwebtoken') 6 | 7 | // db 8 | require('./data/reddit-db') 9 | 10 | const app = express() 11 | 12 | const checkAuth = (req, res, next) => { 13 | if (typeof req.cookies.nToken === "undefined" || req.cookies.nToken === null) { 14 | req.user = null; 15 | } else { 16 | const token = req.cookies.nToken; 17 | const decodedToken = jwt.decode(token, { complete: true }) || {}; 18 | req.user = decodedToken.payload; 19 | } 20 | next(); 21 | } 22 | 23 | app.use(cors({ 24 | origin: ['http://localhost:3000'], 25 | methods: ['GET', 'PUT', 'POST', 'DELETE'], 26 | credentials: true, 27 | })) 28 | app.use(express.json()) 29 | app.use(express.urlencoded({ extended: true })) 30 | app.use(cookieParser()) 31 | app.use(checkAuth) 32 | // routes 33 | app.use(require('./routes')) 34 | 35 | app.listen(process.env.PORT, () => 36 | console.log(`App listening on port ${process.env.PORT}!`), 37 | ) 38 | 39 | module.exports = app 40 | -------------------------------------------------------------------------------- /backend/tests/auth.test.js: -------------------------------------------------------------------------------- 1 | const chai = require("chai"); 2 | const chaiHttp = require("chai-http"); 3 | const server = require("../server"); 4 | const should = chai.should(); 5 | chai.use(chaiHttp); 6 | 7 | // Agent that will keep track of our cookies 8 | const agent = chai.request.agent(server); 9 | 10 | const User = require("../models/user"); 11 | 12 | describe("User", function() { 13 | it("should not be able to login if they have not registered", function(done) { 14 | agent.post("/users/login", { email: "wrong@wrong.com", password: "nope" }).end(function(err, res) { 15 | res.status.should.be.equal(401); 16 | done(); 17 | }); 18 | }); 19 | it("should be able to signup", function(done) { 20 | User.findOneAndRemove({ username: "testone" }, function() { 21 | agent 22 | .post("/users/sign-up") 23 | .send({ username: "testone", password: "password" }) 24 | .end(function(err, res) { 25 | res.should.have.status(200); 26 | agent.should.have.cookie("nToken"); 27 | done(); 28 | }); 29 | }); 30 | }); 31 | it("should be able to login", function(done) { 32 | agent.post("/users/login") 33 | .send({ username: "testone", password: "password" }) 34 | .end(function(err, res) { 35 | res.should.have.status(200); 36 | agent.should.have.cookie("nToken"); 37 | done(); 38 | }); 39 | }); 40 | it("should be able to logout", function(done) { 41 | agent.get("/users/logout").end(function(err, res) { 42 | console.log(res) 43 | res.should.have.status(200); 44 | agent.should.not.have.cookie("nToken"); 45 | done(); 46 | }); 47 | }); 48 | after(function () { 49 | agent.close(); 50 | }); 51 | }); 52 | -------------------------------------------------------------------------------- /backend/tests/index.test.js: -------------------------------------------------------------------------------- 1 | const app = require("../server"); 2 | const chai = require("chai"); 3 | const chaiHttp = require("chai-http"); 4 | const should = chai.should(); 5 | 6 | chai.use(chaiHttp); 7 | 8 | describe("site", function() { 9 | // Describe what you are testing 10 | it("Should have home page", function(done) { 11 | // Describe what should happen 12 | // In this case we test that the home page loads 13 | chai 14 | .request(app) 15 | .get("/") 16 | .end(function(err, res) { 17 | if (err) { 18 | return done(err); 19 | } 20 | res.status.should.be.equal(200); 21 | return done(); // Call done if the test completed successfully. 22 | }); 23 | }); 24 | }); 25 | -------------------------------------------------------------------------------- /backend/tests/posts.test.js: -------------------------------------------------------------------------------- 1 | // test/posts.js 2 | const app = require("./../server"); 3 | const chai = require("chai"); 4 | const chaiHttp = require("chai-http"); 5 | const expect = chai.expect; 6 | 7 | // Import the Post model from our models folder so we 8 | // we can use it in our tests. 9 | const Post = require('../models/post'); 10 | const User = require("../models/user"); 11 | const server = require('../server'); 12 | 13 | chai.should(); 14 | chai.use(chaiHttp); 15 | 16 | describe('Posts', function() { 17 | const agent = chai.request.agent(server); 18 | // Post that we'll use for testing purposes 19 | const newPost = { 20 | title: 'post title', 21 | summary: 'post summary', 22 | subreddit: 'subreddit' 23 | }; 24 | const user = { 25 | username: 'poststest', 26 | password: 'testposts' 27 | }; 28 | before(function (done) { 29 | agent.post('/users/sign-up') 30 | .set("content-type", "application/x-www-form-urlencoded") 31 | .send(user).then(function (res) { 32 | done(); 33 | }).catch(function (err) { 34 | done(err); 35 | }); 36 | }); 37 | 38 | it("should create with valid attributes at POST /posts/new", function (done) { 39 | Post.estimatedDocumentCount().then(function (initialDocCount) { 40 | agent.post("/posts/new") 41 | .set("content-type", "application/x-www-form-urlencoded") 42 | .send(newPost).then(function (res) { 43 | Post.estimatedDocumentCount().then(function (newDocCount) { 44 | // Check that the database has one more post in it 45 | expect(res).to.have.status(200); 46 | // Check that the database has one more post in it 47 | expect(newDocCount).to.be.equal(initialDocCount + 1) 48 | done(); 49 | }).catch(function (err) { 50 | done(err); 51 | }); 52 | }).catch(function (err) { 53 | done(err); 54 | }); 55 | }).catch(function (err) { 56 | done(err); 57 | }); 58 | }); 59 | after(async function () { 60 | try { 61 | await Post.findOneAndDelete({"title": newPost.title, "summary": newPost.summary}); 62 | await User.findOneAndDelete({ username: user.username }); 63 | console.log("Post & test user deleted successfully"); 64 | } catch (error) { 65 | res.status(500).send(error); 66 | } 67 | }); 68 | }); 69 | -------------------------------------------------------------------------------- /backend/util/autopopulate.js: -------------------------------------------------------------------------------- 1 | module.exports = field => { 2 | return function(next) { 3 | this.populate(field); 4 | next(); 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /frontend/.env.example: -------------------------------------------------------------------------------- 1 | NEXT_APP_URI= 2 | -------------------------------------------------------------------------------- /frontend/.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 | # next.js 12 | /.next/ 13 | /out/ 14 | 15 | # production 16 | build 17 | 18 | # misc 19 | .DS_Store 20 | *.pem 21 | 22 | # debug 23 | npm-debug.log* 24 | yarn-debug.log* 25 | yarn-error.log* 26 | 27 | # local env files 28 | .env.local 29 | .env.development.local 30 | .env.test.local 31 | .env.production.local 32 | 33 | # vercel 34 | .vercel 35 | -------------------------------------------------------------------------------- /frontend/components/Comments/CommentList.js: -------------------------------------------------------------------------------- 1 | import { useState } from 'react' 2 | import CommentReply from './CommentReply' 3 | import styles from '../../styles/Comments.module.css' 4 | const BetterDate = require('@tsamantanis/date-lib') 5 | 6 | function CommentList({ postId, comments, author, loadPost }) { 7 | const [reply, setReply] = useState(null) 8 | 9 | return comments && comments.length > 0 && comments.map((comment, index) => { 10 | return ( 11 |
12 | { comment.author.username } 13 | {new BetterDate(comment.created_at).when()} 14 |

{comment.content}

15 | { reply && reply === comment._id ? 16 | setReply(null)} 21 | /> 22 | : 23 | 29 | } 30 | { comment.comments 31 | && 37 | } 38 |
39 | ) 40 | }) 41 | } 42 | 43 | export default CommentList 44 | -------------------------------------------------------------------------------- /frontend/components/Comments/CommentReply.js: -------------------------------------------------------------------------------- 1 | import { useState } from 'react' 2 | import axios from 'axios' 3 | import { useCookies } from "react-cookie" 4 | 5 | function CommentReply({ toggleReply, postId, commentId, loadPost}) { 6 | const [cookies, setCookie, removeCookie] = useCookies(['nToken']) 7 | const [comment, setComment] = useState('') 8 | const [error, setError] = useState('') 9 | 10 | async function handleSubmit(event) { 11 | event.preventDefault() 12 | if (!comment || comment.length === 0) { 13 | setError('Please fill out all fields') 14 | return 15 | } 16 | try { 17 | const res = await axios.post(process.env.NEXT_APP_URI + '/posts/' + postId + '/comments/' + commentId + '/replies/new', { 18 | content: comment 19 | }, { withCredentials: true }) 20 | setComment('') 21 | setError('') 22 | toggleReply() 23 | loadPost() 24 | } catch (error) { 25 | setError(error.message) 26 | } 27 | } 28 | 29 | return ( 30 |
31 |
32 | { 40 | setComment(event.target.value); 41 | setError('') 42 | }} 43 | /> 44 |
45 |
46 | 47 | 48 |
49 | { error && error.length > 0 && 50 |
51 | 52 | {error} 53 | 54 |
55 | } 56 |
57 | ) 58 | } 59 | 60 | export default CommentReply 61 | -------------------------------------------------------------------------------- /frontend/components/Comments/NewComment.js: -------------------------------------------------------------------------------- 1 | import { useState } from 'react' 2 | import axios from 'axios' 3 | import { useCookies } from "react-cookie" 4 | 5 | function NewComment({ postId, loadPost }) { 6 | const [cookies, setCookie, removeCookie] = useCookies(['nToken']) 7 | const [comment, setComment] = useState('') 8 | const [error, setError] = useState('') 9 | 10 | async function handleSubmit(event) { 11 | event.preventDefault() 12 | if (!comment || comment.length === 0) { 13 | setError('Please fill out all fields') 14 | return 15 | } 16 | try { 17 | const res = await axios.post(process.env.NEXT_APP_URI + '/posts/' + postId + '/comments/new', { 18 | content: comment 19 | }, { withCredentials: true }) 20 | setComment('') 21 | setError('') 22 | loadPost() 23 | } catch (error) { 24 | setError(error.message) 25 | } 26 | } 27 | 28 | return ( 29 |
30 |
31 | { 39 | setComment(event.target.value); 40 | setError('') 41 | }} 42 | /> 43 |
44 | { error && error.length > 0 && 45 |
46 | 47 | {error} 48 | 49 |
50 | } 51 |
52 | 53 |
54 |
55 | ) 56 | } 57 | 58 | export default NewComment 59 | -------------------------------------------------------------------------------- /frontend/components/Layout.js: -------------------------------------------------------------------------------- 1 | import Navigation from './Navigation' 2 | import PanelLeft from './PanelLeft' 3 | import PanelRight from './PanelRight' 4 | 5 | const Layout = ({ children }) => { 6 | return ( 7 | <> 8 | 9 | 10 |
11 |
12 | 14 |
15 | { children } 16 |
17 | 18 |
19 |
20 | 21 | ) 22 | } 23 | 24 | export default Layout; 25 | -------------------------------------------------------------------------------- /frontend/components/Navigation.js: -------------------------------------------------------------------------------- 1 | import { useCookies } from "react-cookie" 2 | import styles from '../styles/Layout.module.css' 3 | 4 | function Navigation() { 5 | const [cookie, setCookie, removeCookie] = useCookies(["user"]); 6 | 7 | function logout() { 8 | removeCookie("nToken"); 9 | } 10 | return ( 11 | 19 | ) 20 | } 21 | 22 | export default Navigation; 23 | -------------------------------------------------------------------------------- /frontend/components/PanelLeft.js: -------------------------------------------------------------------------------- 1 | import { Nav } from "react-bootstrap" 2 | import Link from 'next/link' 3 | import styles from '../styles/Layout.module.css' 4 | 5 | function PanelLeft() { 6 | return ( 7 | 70 | ) 71 | } 72 | 73 | export default PanelLeft 74 | -------------------------------------------------------------------------------- /frontend/components/PanelRight.js: -------------------------------------------------------------------------------- 1 | import Link from 'next/link' 2 | import { useCookies } from "react-cookie" 3 | import styles from '../styles/Layout.module.css' 4 | 5 | function PanelRight() { 6 | const [cookie, setCookie] = useCookies(["nToken"]) 7 | if (cookie && cookie.nToken) 8 | return ( 9 |
10 | 11 |
12 | New Post 13 |
14 | 15 |
16 | ) 17 | return null 18 | } 19 | 20 | export default PanelRight 21 | -------------------------------------------------------------------------------- /frontend/helpers/cookies.js: -------------------------------------------------------------------------------- 1 | import cookie from "cookie" 2 | 3 | export function parseCookies(req) { 4 | return cookie.parse(req ? req.headers.cookie || "" : document.cookie) 5 | } 6 | -------------------------------------------------------------------------------- /frontend/next.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | env: { 3 | NEXT_APP_URI: process.env.NEXT_APP_URI, 4 | }, 5 | }; 6 | -------------------------------------------------------------------------------- /frontend/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "reddit-clone-frontend", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "dev": "next dev", 7 | "build": "next build", 8 | "start": "next start" 9 | }, 10 | "dependencies": { 11 | "@tsamantanis/date-lib": "^1.0.3", 12 | "axios": "^0.21.1", 13 | "bootstrap": "^4.6.0", 14 | "cookie": "^0.4.1", 15 | "next": "10.0.6", 16 | "react": "17.0.1", 17 | "react-bootstrap": "^1.4.3", 18 | "react-cookie": "^4.0.3", 19 | "react-dom": "17.0.1" 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /frontend/pages/_app.js: -------------------------------------------------------------------------------- 1 | import { CookiesProvider } from "react-cookie" 2 | import '../styles/globals.css' 3 | import 'bootstrap/dist/css/bootstrap.css' 4 | 5 | function MyApp({ Component, pageProps }) { 6 | return ( 7 | 8 | 9 | 10 | ) 11 | } 12 | 13 | export default MyApp 14 | -------------------------------------------------------------------------------- /frontend/pages/api/hello.js: -------------------------------------------------------------------------------- 1 | // Next.js API route support: https://nextjs.org/docs/api-routes/introduction 2 | 3 | export default (req, res) => { 4 | res.status(200).json({ name: 'John Doe' }) 5 | } 6 | -------------------------------------------------------------------------------- /frontend/pages/index.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios' 2 | import { useState, useEffect } from 'react' 3 | import Link from 'next/link' 4 | import Layout from '../components/Layout' 5 | import styles from '../styles/Home.module.css' 6 | 7 | export default function Home() { 8 | const [posts, setPosts] = useState([]) 9 | useEffect(() => loadPosts(), []) 10 | 11 | async function loadPosts() { 12 | const res = await axios.get(process.env.NEXT_APP_URI + '/posts', { withCredentials: true }) 13 | setPosts(res.data.posts) 14 | } 15 | return ( 16 | 17 | {posts.map((post) => { 18 | return ( 19 | 20 |
21 |
22 | { `/r/${post.subreddit} - ${post.author ? post.author.username : "anonymous"}` } 23 |

{ post.title }

24 |

{ post.summary.length > 100 ? post.summary.substring(0, 97) + '...' : post.summary }

25 |
26 |
27 | 28 | )} 29 | )} 30 |
31 | ) 32 | } 33 | -------------------------------------------------------------------------------- /frontend/pages/login.js: -------------------------------------------------------------------------------- 1 | import { useState } from 'react' 2 | import { useRouter } from 'next/router' 3 | import axios from 'axios' 4 | import { useCookies } from "react-cookie" 5 | import styles from '../styles/Auth.module.css' 6 | function SignIn() { 7 | const router = useRouter() 8 | const [cookies, setCookie, getCookie] = useCookies(['nToken']); 9 | const [username, setUsername] = useState('') 10 | const [password, setPassword] = useState('') 11 | const [error, setError] = useState('') 12 | async function handleSubmit(event) { 13 | event.preventDefault() 14 | if (!username || username.length === 0 || !password || password.length === 0) { 15 | setError('Please fill out all fields') 16 | return 17 | } 18 | try { 19 | const res = await axios.post(process.env.NEXT_APP_URI + '/users/login', { 20 | username: username, 21 | password: password, 22 | }, { withCredentials: true }) 23 | ; 24 | setCookie("nToken", unescape(new RegExp("nToken" + "=([^;]+)").exec(document.cookie)[1])) 25 | router.push("/") 26 | } catch (error) { 27 | setError(error.message) 28 | } 29 | } 30 | return ( 31 |
32 |
33 |
34 | 35 |

Sign In

36 |
37 |
38 | 39 | { 47 | setUsername(event.target.value); 48 | setError('') 49 | }} 50 | /> 51 |
52 |
53 | 54 | { 62 | setPassword(event.target.value); 63 | setError('') 64 | }} 65 | /> 66 |
67 | { error && error.length > 0 && 68 |
69 | 70 | {error} 71 | 72 |
73 | } 74 |
75 | 76 |
77 |
78 |
79 |
80 | ) 81 | } 82 | 83 | export default SignIn 84 | -------------------------------------------------------------------------------- /frontend/pages/posts/new.js: -------------------------------------------------------------------------------- 1 | import Layout from '../../components/Layout' 2 | import { useState } from 'react' 3 | import { useRouter } from 'next/router' 4 | import axios from 'axios' 5 | 6 | function NewPost() { 7 | const router = useRouter() 8 | const [title, setTitle] = useState('') 9 | const [subreddit, setSubreddit] = useState('') 10 | const [summary, setSummary] = useState('') 11 | const [error, setError] = useState('') 12 | async function handleSubmit(event) { 13 | event.preventDefault() 14 | if (!title || title.length === 0 || !summary || summary.length === 0) { 15 | setError('Please fill out all fields') 16 | return 17 | } 18 | try { 19 | const res = await axios.post(process.env.NEXT_APP_URI + '/posts/new', { 20 | title: title, 21 | subreddit: subreddit, 22 | summary: summary, 23 | }, { withCredentials: true }) 24 | router.push("/") 25 | } catch (error) { 26 | setError(error.message) 27 | } 28 | } 29 | return ( 30 | 31 |
32 |
33 |
34 |
35 | New Post 36 |
37 | 38 | { 46 | setTitle(event.target.value); 47 | setError('') 48 | }} 49 | /> 50 |
51 |
52 | 53 | { 61 | setSubreddit(event.target.value); 62 | setError('') 63 | }} 64 | /> 65 |
66 |
67 | 68 | 79 |
80 | { error && error.length > 0 && 81 |
82 | 83 | {error} 84 | 85 |
86 | } 87 |
88 | 89 |
90 |
91 |
92 |
93 |
94 |
95 | ) 96 | } 97 | 98 | export default NewPost; 99 | -------------------------------------------------------------------------------- /frontend/pages/r/[sub]/[slug].js: -------------------------------------------------------------------------------- 1 | import { useState } from 'react' 2 | import axios from 'axios' 3 | import Head from 'next/head' 4 | import { useRouter } from 'next/router' 5 | import { useCookies } from "react-cookie" 6 | import Layout from '../../../components/Layout' 7 | import NewComment from '../../../components/Comments/NewComment' 8 | import CommentList from '../../../components/Comments/CommentList' 9 | import styles from '../../../styles/Home.module.css' 10 | 11 | function Post({ post }) { 12 | const router = useRouter() 13 | const [cookies, setCookie, removeCookie] = useCookies(['nToken']) 14 | const [voteScore, setVoteScore] = useState(post.voteScore ? post.voteScore : 0) 15 | 16 | async function handleUpvote() { 17 | try { 18 | const res = await axios.put(process.env.NEXT_APP_URI + '/posts/' + post._id + '/upvote', {}, { withCredentials: true }) 19 | setVoteScore(res.data.voteScore) 20 | } catch (error) { 21 | console.log(error) 22 | } 23 | } 24 | async function handleDownvote() { 25 | try { 26 | const res = await axios.put(process.env.NEXT_APP_URI + '/posts/' + post._id + '/downvote', {}, { withCredentials: true }) 27 | setVoteScore(res.data.voteScore) 28 | } catch (error) { 29 | console.log(error) 30 | } 31 | } 32 | console.log(cookies) 33 | return ( 34 | 35 | 36 | {`Reddit.js | ${post.subreddit} | ${post.title}`} 37 | 38 | 39 |
40 |
41 |
42 |
43 | 50 |

{ voteScore }

51 | 58 |
59 |
60 | { `/r/${post.subreddit} - ${post.author ? post.author.username : "anonymous"}` } 61 |

{ post.title }

62 |

{ post.summary }

63 |
64 |
65 |
66 |
67 | router.replace(router.asPath) } 72 | /> 73 |
74 | { post.comments && post.comments.length > 0 &&
} 75 | router.replace(router.asPath) } 78 | /> 79 |
80 | 81 |
82 |
83 | ) 84 | } 85 | 86 | export async function getStaticPaths() { 87 | try { 88 | const res = await axios.get(process.env.NEXT_APP_URI + '/posts/') 89 | const posts = res.data.posts 90 | const paths = posts.map((post) => `/r/${post.subreddit}/${post.slug}`) 91 | return { paths, fallback: false } 92 | } catch (err) { 93 | console.log(err.message) 94 | } 95 | 96 | } 97 | 98 | export async function getStaticProps({ params }) { 99 | const res = await axios.get(process.env.NEXT_APP_URI + '/posts/r/' + params.sub + '/' + params.slug, { withCredentials: true}) 100 | const post = res.data.post 101 | return { props: { post }, revalidate: 1, } 102 | } 103 | 104 | export default Post 105 | -------------------------------------------------------------------------------- /frontend/pages/r/[sub]/index.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios' 2 | import { useRouter } from "next/router"; 3 | import { useState, useEffect } from 'react' 4 | import Link from 'next/link' 5 | import Layout from '../../../components/Layout' 6 | import styles from '../../../styles/Home.module.css' 7 | 8 | function Subreddit() { 9 | const router = useRouter(); 10 | const { sub } = router.query; 11 | const [posts, setPosts] = useState([]) 12 | useEffect(() => loadPosts(), []) 13 | 14 | async function loadPosts() { 15 | const res = await axios.get(process.env.NEXT_APP_URI + '/r/' + sub) 16 | setPosts(res.data.posts) 17 | } 18 | 19 | return ( 20 | 21 | {posts && posts.map((post) => { 22 | return ( 23 | 24 |
25 |
26 | { `/r/${post.subreddit} - ${post.author ? post.author.username : "anonymous"}` } 27 |

{ post.title }

28 |

{ post.summary.length > 100 ? post.summary.substring(0, 97) + '...' : post.summary }

29 |
30 |
31 | 32 | )} 33 | )} 34 |
35 | ) 36 | } 37 | 38 | export default Subreddit 39 | -------------------------------------------------------------------------------- /frontend/pages/signup.js: -------------------------------------------------------------------------------- 1 | import { useState } from 'react' 2 | import { useRouter } from 'next/router' 3 | import axios from 'axios' 4 | import styles from '../styles/Auth.module.css' 5 | function SignUp() { 6 | const router = useRouter() 7 | const [username, setUsername] = useState('') 8 | const [password, setPassword] = useState('') 9 | const [error, setError] = useState('') 10 | async function handleSubmit(event) { 11 | event.preventDefault() 12 | if (!username || username.length === 0 || !password || password.length === 0) { 13 | setError('Please fill out all fields') 14 | return 15 | } 16 | try { 17 | const res = await axios.post(process.env.NEXT_APP_URI + '/users/sign-up', { 18 | username: username, 19 | password: password, 20 | }, { withCredentials: true }) 21 | router.push("/") 22 | } catch (error) { 23 | setError(error.message) 24 | } 25 | } 26 | return ( 27 |
28 |
29 |
30 | 31 |

Sign Up

32 |
33 |
34 | 35 | { 43 | setUsername(event.target.value); 44 | setError('') 45 | }} 46 | /> 47 |
48 |
49 | 50 | { 58 | setPassword(event.target.value); 59 | setError('') 60 | }} 61 | /> 62 |
63 | { error && error.length > 0 && 64 |
65 | 66 | {error} 67 | 68 |
69 | } 70 |
71 | 72 |
73 |
74 |
75 |
76 | ) 77 | } 78 | 79 | export default SignUp 80 | -------------------------------------------------------------------------------- /frontend/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jfloresy/node-nextjs-reddit-clone/ae3056f864174dd1f6b94455020b9a840fdb8495/frontend/public/favicon.ico -------------------------------------------------------------------------------- /frontend/public/vercel.svg: -------------------------------------------------------------------------------- 1 | 3 | 4 | -------------------------------------------------------------------------------- /frontend/styles/Auth.module.css: -------------------------------------------------------------------------------- 1 | .container { 2 | height: 100vh; 3 | width: 100%; 4 | padding: 1rem; 5 | display: flex; 6 | align-items: center; 7 | justify-content: center; 8 | } 9 | 10 | .container input { 11 | height: 50px; 12 | } 13 | 14 | .container legend { 15 | text-align: center; 16 | margin-bottom: 2rem; 17 | } 18 | 19 | .container button { 20 | background: var(--primary-desat); 21 | color: var(--primary); 22 | border: 1px solid var(--primary-desat); 23 | margin-top: 1rem; 24 | min-width: 300px; 25 | width: 100%; 26 | height: 50px; 27 | border-radius: 5px; 28 | transition: 0.5s ease; 29 | font-size: 1.2rem; 30 | } 31 | 32 | .container button:hover { 33 | background: var(--primary); 34 | color: var(--white); 35 | font-weight: bold; 36 | } 37 | -------------------------------------------------------------------------------- /frontend/styles/Comments.module.css: -------------------------------------------------------------------------------- 1 | .commentContainer { 2 | background: var(--light-gray); 3 | border-radius: 5px; 4 | padding: 0.75rem 0.75rem 2rem 0.75rem; 5 | } 6 | 7 | .timestamp { 8 | font-size: 0.85rem; 9 | color: var(--primary); 10 | margin-bottom: 0.5rem; 11 | } 12 | 13 | .comment { 14 | margin: 1rem 0 0 0 !important; 15 | } 16 | 17 | .username { 18 | font-size: 0.85rem; 19 | font-weight: bold; 20 | color: var(--secondary) 21 | } 22 | 23 | .replyButton { 24 | outline: none; 25 | border: none; 26 | padding: 0; 27 | margin-top: -2rem; 28 | margin-right: 1rem; 29 | float: right; 30 | background: transparent; 31 | color: var(--dark); 32 | } 33 | -------------------------------------------------------------------------------- /frontend/styles/Home.module.css: -------------------------------------------------------------------------------- 1 | .container { 2 | min-height: 100vh; 3 | padding: 0 0.5rem; 4 | display: flex; 5 | flex-direction: column; 6 | justify-content: center; 7 | align-items: center; 8 | } 9 | 10 | .main { 11 | padding: 5rem 0; 12 | flex: 1; 13 | display: flex; 14 | flex-direction: column; 15 | justify-content: center; 16 | align-items: center; 17 | } 18 | 19 | .footer { 20 | width: 100%; 21 | height: 100px; 22 | border-top: 1px solid #eaeaea; 23 | display: flex; 24 | justify-content: center; 25 | align-items: center; 26 | } 27 | 28 | .footer img { 29 | margin-left: 0.5rem; 30 | } 31 | 32 | .footer a { 33 | display: flex; 34 | justify-content: center; 35 | align-items: center; 36 | } 37 | 38 | .title a { 39 | color: var(--secondary); 40 | text-decoration: none; 41 | } 42 | 43 | .title a:hover, 44 | .title a:focus, 45 | .title a:active { 46 | text-decoration: underline; 47 | } 48 | 49 | .title { 50 | margin: 0; 51 | line-height: 1.15; 52 | font-size: 4rem; 53 | } 54 | 55 | .title, 56 | .description { 57 | text-align: center; 58 | } 59 | 60 | .description { 61 | line-height: 1.5; 62 | font-size: 1.5rem; 63 | } 64 | 65 | .code { 66 | background: #fafafa; 67 | border-radius: 5px; 68 | padding: 0.75rem; 69 | font-size: 1.1rem; 70 | font-family: Menlo, Monaco, Lucida Console, Liberation Mono, DejaVu Sans Mono, 71 | Bitstream Vera Sans Mono, Courier New, monospace; 72 | } 73 | 74 | .grid { 75 | display: flex; 76 | align-items: center; 77 | justify-content: center; 78 | flex-wrap: wrap; 79 | max-width: 800px; 80 | margin-top: 3rem; 81 | } 82 | 83 | .card { 84 | margin: 1rem; 85 | flex-basis: 45%; 86 | padding: 1.5rem; 87 | text-align: left; 88 | color: inherit; 89 | text-decoration: none; 90 | border: 1px solid var(--gray); 91 | border-radius: 10px; 92 | transition: color 0.15s ease, border-color 0.15s ease; 93 | } 94 | 95 | .posts .card:hover, 96 | .posts .card:focus, 97 | .posts .card:active { 98 | color: var(--secondary); 99 | border-color: var(--secondary); 100 | cursor: pointer; 101 | } 102 | 103 | .card h3 { 104 | margin: 0.5rem 0 1rem 0; 105 | font-size: 1.5rem; 106 | } 107 | 108 | .card p { 109 | margin: 0; 110 | font-size: 1.25rem; 111 | line-height: 1.5; 112 | } 113 | 114 | .card span.subredditText { 115 | color: var(--secondary); 116 | font-size: 0.75rem; 117 | } 118 | 119 | .logo { 120 | height: 1em; 121 | } 122 | 123 | @media (max-width: 600px) { 124 | .grid { 125 | width: 100%; 126 | flex-direction: column; 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /frontend/styles/Layout.module.css: -------------------------------------------------------------------------------- 1 | div.menuItem { 2 | margin: 1rem; 3 | padding: 1rem; 4 | border-radius: 5px; 5 | min-width: 100%; 6 | border-left: 3px solid var(--bg-light); 7 | } 8 | 9 | div.menuItem:hover { 10 | background: var(--secondary-desat); 11 | cursor: pointer; 12 | border-left: 3px solid var(--secondary); 13 | } 14 | 15 | a.item:hover { 16 | text-decoration: none; 17 | } 18 | 19 | div.newPost { 20 | min-width: 100%; 21 | text-align: center; 22 | color: var(--primary); 23 | font-weight: bold; 24 | background: var(--primary-desat); 25 | padding: 0.5rem; 26 | border-radius: 5px; 27 | transition: 0.3s ease; 28 | } 29 | 30 | div.newPost:hover { 31 | text-decoration: none; 32 | cursor: pointer; 33 | transform: translateY(-2px); 34 | } 35 | 36 | .navbar { 37 | z-index: 101; 38 | box-shadow: 0px 6px 24px rgba(60, 60, 60, 0.20); 39 | } 40 | 41 | .sidebar { 42 | position: fixed; 43 | top: 55px; 44 | bottom: 0; 45 | left: 0; 46 | min-height: 100vh !important; 47 | min-width: 250px; 48 | max-width: 350px; 49 | z-index: 100; 50 | padding: 48px 0 0; 51 | } 52 | 53 | .sidebar h5 { 54 | margin: 1rem; 55 | padding: 1rem; 56 | text-align: center; 57 | } 58 | 59 | #sidebar-wrapper { 60 | min-height: 100vh !important; 61 | width: 100vw; 62 | margin-left: -1rem; 63 | -webkit-transition: margin .25s ease-out; 64 | -moz-transition: margin .25s ease-out; 65 | -o-transition: margin .25s ease-out; 66 | transition: margin .25s ease-out; 67 | } 68 | #sidebar-wrapper .sidebar-heading { 69 | padding: 0.875rem 1.25rem; 70 | font-size: 1.2rem; 71 | } 72 | 73 | #page-content-wrapper { 74 | min-width: 0; 75 | width: 100%; 76 | } 77 | -------------------------------------------------------------------------------- /frontend/styles/globals.css: -------------------------------------------------------------------------------- 1 | :root { 2 | --primary: rgba(255, 99, 20, 1) !important; 3 | --primary-desat: rgba(255, 99, 20, 0.25) !important; 4 | --secondary: rgba(82, 150, 221, 1) !important; 5 | --secondary-desat: rgba(82, 150, 221, 0.3) !important; 6 | --tertiary: rgba(146, 189, 223, 1) !important; 7 | --gray: rgba(215, 215, 215, 1) !important; 8 | --light-gray: rgba(215, 215, 215, 0.25) !important; 9 | --bg-light: rgba(252, 252, 252, 1) !important; 10 | } 11 | 12 | html, 13 | body { 14 | padding: 0; 15 | margin: 0; 16 | font-family: -apple-system, BlinkMacSystemFont, Segoe UI, Roboto, Oxygen, 17 | Ubuntu, Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif; 18 | background-color: var(--bg-light) !important; 19 | } 20 | 21 | a { 22 | color: inherit; 23 | text-decoration: none; 24 | } 25 | 26 | * { 27 | box-sizing: border-box; 28 | } 29 | --------------------------------------------------------------------------------