├── frontend
├── .env.example
├── public
│ ├── favicon.ico
│ └── vercel.svg
├── next.config.js
├── helpers
│ └── cookies.js
├── pages
│ ├── api
│ │ └── hello.js
│ ├── _app.js
│ ├── index.js
│ ├── r
│ │ └── [sub]
│ │ │ ├── index.js
│ │ │ └── [slug].js
│ ├── signup.js
│ ├── login.js
│ └── posts
│ │ └── new.js
├── .gitignore
├── package.json
├── components
│ ├── PanelRight.js
│ ├── Layout.js
│ ├── Navigation.js
│ ├── Comments
│ │ ├── CommentList.js
│ │ ├── NewComment.js
│ │ └── CommentReply.js
│ └── PanelLeft.js
└── styles
│ ├── Comments.module.css
│ ├── Auth.module.css
│ ├── globals.css
│ ├── Layout.module.css
│ └── Home.module.css
└── backend
├── .env.example
├── .babelrc
├── util
└── autopopulate.js
├── routes
├── index.js
├── users.js
└── posts.js
├── .gitignore
├── models
├── comment.js
├── user.js
└── post.js
├── data
└── reddit-db.js
├── tests
├── index.test.js
├── auth.test.js
└── posts.test.js
├── package.json
├── server.js
└── controllers
├── auth.js
├── comments.js
└── posts.js
/frontend/.env.example:
--------------------------------------------------------------------------------
1 | NEXT_APP_URI=
2 |
--------------------------------------------------------------------------------
/backend/.env.example:
--------------------------------------------------------------------------------
1 | PORT=
2 | MONGODB_URI=
3 | SECRET=
4 |
--------------------------------------------------------------------------------
/backend/.babelrc:
--------------------------------------------------------------------------------
1 | {
2 | "presets": [
3 | "@babel/preset-env"
4 | ]
5 | }
6 |
--------------------------------------------------------------------------------
/frontend/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/jfloresy/node-nextjs-reddit-clone/HEAD/frontend/public/favicon.ico
--------------------------------------------------------------------------------
/frontend/next.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | env: {
3 | NEXT_APP_URI: process.env.NEXT_APP_URI,
4 | },
5 | };
6 |
--------------------------------------------------------------------------------
/backend/util/autopopulate.js:
--------------------------------------------------------------------------------
1 | module.exports = field => {
2 | return function(next) {
3 | this.populate(field);
4 | next();
5 | }
6 | }
7 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/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/.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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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/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 |
--------------------------------------------------------------------------------
/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/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/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/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 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/frontend/public/vercel.svg:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/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 |
55 | )
56 | }
57 |
58 | export default NewComment
59 |
--------------------------------------------------------------------------------
/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 |
57 | )
58 | }
59 |
60 | export default CommentReply
61 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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 |
76 | )
77 | }
78 |
79 | export default SignUp
80 |
--------------------------------------------------------------------------------
/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 |
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 |
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 |
--------------------------------------------------------------------------------