├── back ├── requests │ └── req1.rest ├── .gitignore ├── .dockerignore ├── build │ ├── favicon.ico │ ├── logo192.png │ ├── logo512.png │ ├── robots.txt │ ├── asset-manifest.json │ ├── manifest.json │ ├── index.html │ └── static │ │ ├── js │ │ └── main.b9f121bb.js.LICENSE.txt │ │ └── css │ │ └── main.52dae25a.css.map ├── utils │ ├── logger.js │ ├── config.js │ └── middleware.js ├── index.js ├── models │ ├── user.js │ └── blog.js ├── package.json ├── fly.toml ├── controllers │ ├── login.js │ ├── users.js │ └── blogs.js ├── Dockerfile ├── tests │ ├── test_helper.js │ ├── user_api.test.js │ ├── helper.test.js │ └── blog_api.test.js └── app.js ├── front ├── README.md ├── public │ ├── favicon.ico │ ├── logo192.png │ ├── logo512.png │ ├── robots.txt │ ├── manifest.json │ └── index.html ├── postcss.config.js ├── src │ ├── services │ │ ├── login.js │ │ ├── users.js │ │ └── blogs.js │ ├── index.css │ ├── index.js │ ├── utils │ │ └── store.js │ ├── stories │ │ ├── Header.stories.jsx │ │ ├── header.css │ │ ├── button.css │ │ ├── Page.stories.jsx │ │ ├── Button.stories.jsx │ │ ├── assets │ │ │ ├── direction.svg │ │ │ ├── flow.svg │ │ │ ├── code-brackets.svg │ │ │ ├── comments.svg │ │ │ ├── repo.svg │ │ │ ├── plugin.svg │ │ │ ├── stackalt.svg │ │ │ └── colors.svg │ │ ├── Button.jsx │ │ ├── page.css │ │ ├── Header.jsx │ │ ├── Page.jsx │ │ └── Introduction.stories.mdx │ ├── reducers │ │ ├── userReducer.js │ │ ├── notificationReducer.js │ │ ├── allUsersReducer.js │ │ └── blogReducer.js │ ├── components │ │ ├── Togglable.js │ │ ├── Comment.js │ │ ├── ErrorPage.js │ │ ├── UserView.js │ │ ├── Notif.js │ │ ├── BlogFooter.js │ │ ├── BlogList.js │ │ ├── Blog.js │ │ ├── NewBlog.js │ │ ├── BlogEdit.js │ │ ├── NavigationBar.js │ │ ├── RegisterUser.js │ │ ├── LoginForm.js │ │ ├── About.js │ │ └── BlogView.js │ └── App.js ├── .gitignore ├── tailwind.config.js └── package.json ├── .gitattributes ├── images ├── blogList.png ├── blogView.png ├── commenting.png └── createPost.png ├── LICENSE └── README.md /back/requests/req1.rest: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /back/.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .env -------------------------------------------------------------------------------- /front/README.md: -------------------------------------------------------------------------------- 1 | Front-end/Client-side app is powered by ReactJS! 2 | -------------------------------------------------------------------------------- /back/.dockerignore: -------------------------------------------------------------------------------- 1 | Dockerfile 2 | .dockerignore 3 | node_modules 4 | .git 5 | -------------------------------------------------------------------------------- /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /images/blogList.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xxdydx/Forum-App/HEAD/images/blogList.png -------------------------------------------------------------------------------- /images/blogView.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xxdydx/Forum-App/HEAD/images/blogView.png -------------------------------------------------------------------------------- /back/build/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xxdydx/Forum-App/HEAD/back/build/favicon.ico -------------------------------------------------------------------------------- /back/build/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xxdydx/Forum-App/HEAD/back/build/logo192.png -------------------------------------------------------------------------------- /back/build/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xxdydx/Forum-App/HEAD/back/build/logo512.png -------------------------------------------------------------------------------- /images/commenting.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xxdydx/Forum-App/HEAD/images/commenting.png -------------------------------------------------------------------------------- /images/createPost.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xxdydx/Forum-App/HEAD/images/createPost.png -------------------------------------------------------------------------------- /back/build/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /front/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xxdydx/Forum-App/HEAD/front/public/favicon.ico -------------------------------------------------------------------------------- /front/public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xxdydx/Forum-App/HEAD/front/public/logo192.png -------------------------------------------------------------------------------- /front/public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/xxdydx/Forum-App/HEAD/front/public/logo512.png -------------------------------------------------------------------------------- /front/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /front/postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | tailwindcss: {}, 4 | autoprefixer: {}, 5 | }, 6 | } 7 | -------------------------------------------------------------------------------- /back/utils/logger.js: -------------------------------------------------------------------------------- 1 | const info = (...params) => { 2 | console.log(...params) 3 | } 4 | 5 | const error = (...params) => { 6 | console.error(...params) 7 | } 8 | 9 | module.exports = { 10 | info, error 11 | } -------------------------------------------------------------------------------- /front/src/services/login.js: -------------------------------------------------------------------------------- 1 | import axios from 'axios' 2 | const baseUrl = '/api/login' 3 | 4 | const login = async credentials => { 5 | const response = await axios.post(baseUrl, credentials) 6 | return response.data 7 | } 8 | 9 | export default {login} -------------------------------------------------------------------------------- /back/utils/config.js: -------------------------------------------------------------------------------- 1 | require('dotenv').config() 2 | 3 | const PORT = process.env.PORT 4 | 5 | const MONGODB_URI = process.env.NODE_ENV === 'test' 6 | ? process.env.TEST_MONGODB_URI 7 | : process.env.MONGODB_URI 8 | 9 | module.exports = { 10 | MONGODB_URI, 11 | PORT 12 | } 13 | -------------------------------------------------------------------------------- /front/src/index.css: -------------------------------------------------------------------------------- 1 | @tailwind base; 2 | @tailwind components; 3 | @tailwind utilities; 4 | @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&display=swap'); 5 | 6 | html { font-family: 'Inter', sans-serif; 7 | } 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | -------------------------------------------------------------------------------- /back/index.js: -------------------------------------------------------------------------------- 1 | const app = require('./app') 2 | const http = require('http') 3 | const config = require('./utils/config') 4 | const logger = require('./utils/logger') 5 | 6 | const server = http.createServer(app) 7 | 8 | server.listen(config.PORT, () => { 9 | logger.info(`Server running on port ${config.PORT}`) 10 | }) -------------------------------------------------------------------------------- /front/src/services/users.js: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | const baseUrl = "/api/users"; 3 | 4 | const getAll = async () => { 5 | const response = await axios.get(baseUrl); 6 | return response.data; 7 | }; 8 | 9 | const addUser = async (user) => { 10 | const response = await axios.post(baseUrl, user); 11 | return response.data; 12 | }; 13 | 14 | export default { getAll, addUser }; 15 | -------------------------------------------------------------------------------- /back/build/asset-manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": { 3 | "main.css": "/static/css/main.52dae25a.css", 4 | "main.js": "/static/js/main.b9f121bb.js", 5 | "index.html": "/index.html", 6 | "main.52dae25a.css.map": "/static/css/main.52dae25a.css.map", 7 | "main.b9f121bb.js.map": "/static/js/main.b9f121bb.js.map" 8 | }, 9 | "entrypoints": [ 10 | "static/css/main.52dae25a.css", 11 | "static/js/main.b9f121bb.js" 12 | ] 13 | } -------------------------------------------------------------------------------- /front/.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 | .storybook 8 | 9 | # testing 10 | /coverage 11 | 12 | # production 13 | /build 14 | 15 | # misc 16 | .DS_Store 17 | .env.local 18 | .env.development.local 19 | .env.test.local 20 | .env.production.local 21 | 22 | npm-debug.log* 23 | yarn-debug.log* 24 | yarn-error.log* 25 | .storybook 26 | -------------------------------------------------------------------------------- /front/src/index.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom/client"; 3 | import App from "./App"; 4 | import store from "./utils/store"; 5 | import { Provider } from "react-redux"; 6 | import { BrowserRouter as Router } from "react-router-dom"; 7 | 8 | import "./index.css"; 9 | 10 | ReactDOM.createRoot(document.getElementById("root")).render( 11 | 12 | 13 | 14 | 15 | 16 | ); 17 | -------------------------------------------------------------------------------- /front/src/utils/store.js: -------------------------------------------------------------------------------- 1 | import { configureStore } from "@reduxjs/toolkit"; 2 | import notificationReducer from "../reducers/notificationReducer"; 3 | import blogReducer from "../reducers/blogReducer"; 4 | import userReducer from "../reducers/userReducer"; 5 | import allUsersReducer from "../reducers/allUsersReducer"; 6 | 7 | const store = configureStore({ 8 | reducer: { 9 | notifications: notificationReducer, 10 | blogs: blogReducer, 11 | users: userReducer, 12 | allUsers: allUsersReducer, 13 | }, 14 | }); 15 | 16 | export default store; 17 | -------------------------------------------------------------------------------- /back/build/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /front/src/stories/Header.stories.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { Header } from './Header'; 4 | 5 | export default { 6 | title: 'Example/Header', 7 | component: Header, 8 | parameters: { 9 | // More on Story layout: https://storybook.js.org/docs/react/configure/story-layout 10 | layout: 'fullscreen', 11 | }, 12 | }; 13 | 14 | const Template = (args) =>
; 15 | 16 | export const LoggedIn = Template.bind({}); 17 | LoggedIn.args = { 18 | user: { 19 | name: 'Jane Doe', 20 | }, 21 | }; 22 | 23 | export const LoggedOut = Template.bind({}); 24 | LoggedOut.args = {}; 25 | -------------------------------------------------------------------------------- /front/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /back/build/index.html: -------------------------------------------------------------------------------- 1 | React App
-------------------------------------------------------------------------------- /back/models/user.js: -------------------------------------------------------------------------------- 1 | const mongoose = require('mongoose') 2 | 3 | const userSchema = new mongoose.Schema({ 4 | username: String, 5 | name: String, 6 | passwordHash: String, 7 | blogs: [ 8 | { 9 | type: mongoose.Schema.Types.ObjectId, 10 | ref: 'Blog' 11 | } 12 | ], 13 | }) 14 | 15 | userSchema.set('toJSON', { 16 | transform: (document, returnedObject) => { 17 | returnedObject.id = returnedObject._id.toString() 18 | delete returnedObject._id 19 | delete returnedObject.__v 20 | delete returnedObject.passwordHash 21 | } 22 | }) 23 | 24 | const User = mongoose.model('User', userSchema) 25 | 26 | module.exports = User 27 | -------------------------------------------------------------------------------- /front/src/stories/header.css: -------------------------------------------------------------------------------- 1 | .wrapper { 2 | font-family: 'Nunito Sans', 'Helvetica Neue', Helvetica, Arial, sans-serif; 3 | border-bottom: 1px solid rgba(0, 0, 0, 0.1); 4 | padding: 15px 20px; 5 | display: flex; 6 | align-items: center; 7 | justify-content: space-between; 8 | } 9 | 10 | svg { 11 | display: inline-block; 12 | vertical-align: top; 13 | } 14 | 15 | h1 { 16 | font-weight: 900; 17 | font-size: 20px; 18 | line-height: 1; 19 | margin: 6px 0 6px 10px; 20 | display: inline-block; 21 | vertical-align: top; 22 | } 23 | 24 | button + button { 25 | margin-left: 10px; 26 | } 27 | 28 | .welcome { 29 | color: #333; 30 | font-size: 14px; 31 | margin-right: 10px; 32 | } 33 | -------------------------------------------------------------------------------- /front/src/reducers/userReducer.js: -------------------------------------------------------------------------------- 1 | import { createSlice } from "@reduxjs/toolkit"; 2 | import blogService from "../services/blogs"; 3 | 4 | const userSlice = createSlice({ 5 | name: "users", 6 | initialState: null, 7 | reducers: { 8 | setUser(state, action) { 9 | return action.payload; 10 | }, 11 | }, 12 | }); 13 | 14 | export const { setUser } = userSlice.actions; 15 | 16 | export const initializeUsers = () => { 17 | return (dispatch) => { 18 | const loggedUserJSON = window.localStorage.getItem("AKAppSessionID"); 19 | if (loggedUserJSON) { 20 | const user = JSON.parse(loggedUserJSON); 21 | dispatch(setUser(user)); 22 | blogService.setToken(user.token); 23 | } 24 | }; 25 | }; 26 | 27 | export default userSlice.reducer; 28 | -------------------------------------------------------------------------------- /front/src/stories/button.css: -------------------------------------------------------------------------------- 1 | .storybook-button { 2 | font-family: 'Nunito Sans', 'Helvetica Neue', Helvetica, Arial, sans-serif; 3 | font-weight: 700; 4 | border: 0; 5 | border-radius: 3em; 6 | cursor: pointer; 7 | display: inline-block; 8 | line-height: 1; 9 | } 10 | .storybook-button--primary { 11 | color: white; 12 | background-color: #1ea7fd; 13 | } 14 | .storybook-button--secondary { 15 | color: #333; 16 | background-color: transparent; 17 | box-shadow: rgba(0, 0, 0, 0.15) 0px 0px 0px 1px inset; 18 | } 19 | .storybook-button--small { 20 | font-size: 12px; 21 | padding: 10px 16px; 22 | } 23 | .storybook-button--medium { 24 | font-size: 14px; 25 | padding: 11px 20px; 26 | } 27 | .storybook-button--large { 28 | font-size: 16px; 29 | padding: 12px 24px; 30 | } 31 | -------------------------------------------------------------------------------- /front/src/reducers/notificationReducer.js: -------------------------------------------------------------------------------- 1 | import { createSlice } from "@reduxjs/toolkit"; 2 | 3 | const notifSlice = createSlice({ 4 | name: "notifications", 5 | initialState: null, 6 | reducers: { 7 | createNotification(state, action) { 8 | const notification = action.payload; 9 | return notification; 10 | }, 11 | }, 12 | }); 13 | 14 | export const { createNotification } = notifSlice.actions; 15 | 16 | let timeoutId = null; 17 | 18 | export const setNotification = (message, time) => { 19 | return async (dispatch) => { 20 | dispatch(createNotification(message)); 21 | 22 | if (timeoutId) { 23 | clearTimeout(timeoutId); 24 | } 25 | 26 | timeoutId = setTimeout(() => dispatch(createNotification(null)), time); 27 | }; 28 | }; 29 | 30 | export default notifSlice.reducer; 31 | -------------------------------------------------------------------------------- /back/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "part4", 3 | "version": "0.0.1", 4 | "description": "", 5 | "main": "index.js", 6 | "scripts": { 7 | "start": "SET NODE_ENV=production & node index.js", 8 | "dev": "SET NODE_ENV=development & nodemon index.js", 9 | "test": "cross-env NODE_ENV=test jest --verbose --runInBand --forceExit" 10 | }, 11 | "author": "", 12 | "license": "ISC", 13 | "devDependencies": { 14 | "cross-env": "^7.0.3", 15 | "jest": "^29.2.2", 16 | "supertest": "^6.3.1" 17 | }, 18 | "jest": { 19 | "testEnvironment": "node" 20 | }, 21 | "dependencies": { 22 | "bcryptjs": "^2.4.3", 23 | "cors": "^2.8.5", 24 | "dotenv": "^16.0.3", 25 | "express-async-errors": "^3.1.1", 26 | "jsonwebtoken": "^8.5.1", 27 | "mongoose": "^6.8.0", 28 | "nodemon": "^2.0.20", 29 | "path": "^0.12.7" 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /back/fly.toml: -------------------------------------------------------------------------------- 1 | # fly.toml file generated for forum-app on 2022-12-08T18:32:29+08:00 2 | 3 | app = "forum-app" 4 | kill_signal = "SIGINT" 5 | kill_timeout = 5 6 | processes = [] 7 | 8 | [env] 9 | PORT = "8080" 10 | 11 | [experimental] 12 | allowed_public_ports = [] 13 | auto_rollback = true 14 | 15 | [[services]] 16 | http_checks = [] 17 | internal_port = 8080 18 | processes = ["app"] 19 | protocol = "tcp" 20 | script_checks = [] 21 | [services.concurrency] 22 | hard_limit = 25 23 | soft_limit = 20 24 | type = "connections" 25 | 26 | [[services.ports]] 27 | force_https = true 28 | handlers = ["http"] 29 | port = 80 30 | 31 | [[services.ports]] 32 | handlers = ["tls", "http"] 33 | port = 443 34 | 35 | [[services.tcp_checks]] 36 | grace_period = "1s" 37 | interval = "15s" 38 | restart_limit = 0 39 | timeout = "2s" 40 | -------------------------------------------------------------------------------- /front/src/stories/Page.stories.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { within, userEvent } from '@storybook/testing-library'; 3 | 4 | import { Page } from './Page'; 5 | 6 | export default { 7 | title: 'Example/Page', 8 | component: Page, 9 | parameters: { 10 | // More on Story layout: https://storybook.js.org/docs/react/configure/story-layout 11 | layout: 'fullscreen', 12 | }, 13 | }; 14 | 15 | const Template = (args) => ; 16 | 17 | // More on interaction testing: https://storybook.js.org/docs/react/writing-tests/interaction-testing 18 | export const LoggedOut = Template.bind({}); 19 | 20 | export const LoggedIn = Template.bind({}); 21 | LoggedIn.play = async ({ canvasElement }) => { 22 | const canvas = within(canvasElement); 23 | const loginButton = await canvas.getByRole('button', { name: /Log in/i }); 24 | await userEvent.click(loginButton); 25 | }; 26 | -------------------------------------------------------------------------------- /front/src/components/Togglable.js: -------------------------------------------------------------------------------- 1 | import { useState, forwardRef, useImperativeHandle } from "react"; 2 | 3 | const Togglable = forwardRef((props, refs) => { 4 | const [visible, setVisible] = useState(false); 5 | 6 | const hideWhenVisible = { display: visible ? "none" : "" }; 7 | const showWhenVisible = { display: visible ? "" : "none" }; 8 | 9 | const toggleVisibility = () => { 10 | setVisible(!visible); 11 | }; 12 | 13 | useImperativeHandle(refs, () => { 14 | return { 15 | toggleVisibility, 16 | }; 17 | }); 18 | 19 | return ( 20 |
21 |
22 | 23 |
24 |
25 | {props.children} 26 | 27 |
28 |
29 | ); 30 | }); 31 | 32 | export default Togglable; 33 | -------------------------------------------------------------------------------- /back/controllers/login.js: -------------------------------------------------------------------------------- 1 | const jwt = require("jsonwebtoken"); 2 | const bcrypt = require("bcryptjs"); 3 | const loginRouter = require("express").Router(); 4 | const User = require("../models/user"); 5 | 6 | loginRouter.post("/", async (request, response) => { 7 | const { username, password } = request.body; 8 | 9 | const user = await User.findOne({ username }); 10 | const passwordCorrect = 11 | user === null ? false : await bcrypt.compare(password, user.passwordHash); 12 | 13 | if (!(user && passwordCorrect)) { 14 | return response.status(401).json({ 15 | error: "invalid username or password", 16 | }); 17 | } 18 | 19 | const userForToken = { 20 | username: user.username, 21 | id: user._id, 22 | }; 23 | 24 | const token = jwt.sign(userForToken, process.env.SECRET); 25 | 26 | response 27 | .status(200) 28 | .send({ token, username: user.username, name: user.name, id: user._id }); 29 | }); 30 | 31 | module.exports = loginRouter; 32 | -------------------------------------------------------------------------------- /front/src/components/Comment.js: -------------------------------------------------------------------------------- 1 | import { useSelector } from "react-redux"; 2 | import { Spinner } from "flowbite-react"; 3 | 4 | const Comment = ({ comment }) => { 5 | const allUsers = useSelector((state) => state.allUsers); 6 | const user = allUsers.find((user) => user.id === comment.user); 7 | if (user === undefined) { 8 | return ; 9 | } 10 | 11 | return ( 12 |
13 |
14 |
15 |

16 | u/{user.username} 17 |

18 |
19 |
20 |

{comment.content}

21 |
22 | ); 23 | }; 24 | 25 | export default Comment; 26 | -------------------------------------------------------------------------------- /front/src/reducers/allUsersReducer.js: -------------------------------------------------------------------------------- 1 | import { createSlice } from "@reduxjs/toolkit"; 2 | import userService from "../services/users"; 3 | import { initializeUsers } from "./userReducer"; 4 | 5 | const usersSlice = createSlice({ 6 | name: "allUsers", 7 | initialState: [], 8 | reducers: { 9 | setUsers(state, action) { 10 | return action.payload; 11 | }, 12 | create(state, action) { 13 | const user = action.payload; 14 | state.push(user); 15 | }, 16 | }, 17 | }); 18 | 19 | export const { setUsers, create } = usersSlice.actions; 20 | 21 | export const initializeAllUsers = () => { 22 | return async (dispatch) => { 23 | const users = await userService.getAll(); 24 | dispatch(setUsers(users)); 25 | }; 26 | }; 27 | 28 | export const registerUser = (user) => { 29 | return async (dispatch) => { 30 | const user1 = await userService.addUser(user); 31 | dispatch(create(user1)); 32 | }; 33 | }; 34 | 35 | export default usersSlice.reducer; 36 | -------------------------------------------------------------------------------- /back/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM debian:bullseye as builder 2 | 3 | ARG NODE_VERSION=16.17.0 4 | 5 | RUN apt-get update; apt install -y curl 6 | RUN curl https://get.volta.sh | bash 7 | ENV VOLTA_HOME /root/.volta 8 | ENV PATH /root/.volta/bin:$PATH 9 | RUN volta install node@${NODE_VERSION} 10 | 11 | ####################################################################### 12 | 13 | RUN mkdir /app 14 | WORKDIR /app 15 | 16 | # NPM will not install any package listed in "devDependencies" when NODE_ENV is set to "production", 17 | # to install all modules: "npm install --production=false". 18 | # Ref: https://docs.npmjs.com/cli/v9/commands/npm-install#description 19 | 20 | ENV NODE_ENV production 21 | 22 | COPY . . 23 | 24 | RUN npm install 25 | FROM debian:bullseye 26 | 27 | LABEL fly_launch_runtime="nodejs" 28 | 29 | COPY --from=builder /root/.volta /root/.volta 30 | COPY --from=builder /app /app 31 | 32 | WORKDIR /app 33 | ENV NODE_ENV production 34 | ENV PATH /root/.volta/bin:$PATH 35 | 36 | CMD [ "npm", "run", "start" ] 37 | -------------------------------------------------------------------------------- /back/models/blog.js: -------------------------------------------------------------------------------- 1 | const mongoose = require("mongoose"); 2 | 3 | const commentSchema = new mongoose.Schema({ 4 | content: String, 5 | user: { 6 | type: mongoose.Schema.Types.ObjectId, 7 | ref: "User", 8 | }, 9 | }); 10 | commentSchema.set("toJSON", { 11 | transform: (document, returnedObject) => { 12 | returnedObject.id = returnedObject._id.toString(); 13 | delete returnedObject._id; 14 | delete returnedObject.__v; 15 | }, 16 | }); 17 | const blogSchema = new mongoose.Schema({ 18 | title: String, 19 | content: String, 20 | dateCreated: Date, 21 | likes: Number, 22 | comments: [commentSchema], 23 | user: { 24 | type: mongoose.Schema.Types.ObjectId, 25 | ref: "User", 26 | }, 27 | }); 28 | blogSchema.set("toJSON", { 29 | transform: (document, returnedObject) => { 30 | returnedObject.id = returnedObject._id.toString(); 31 | delete returnedObject._id; 32 | delete returnedObject.__v; 33 | }, 34 | }); 35 | 36 | module.exports = mongoose.model("Blog", blogSchema); 37 | -------------------------------------------------------------------------------- /back/utils/middleware.js: -------------------------------------------------------------------------------- 1 | const logger = require('./logger') 2 | 3 | const requestLogger = (request, response, next) => { 4 | logger.info('Method:', request.method) 5 | logger.info('Path: ', request.path) 6 | logger.info('Body: ', request.body) 7 | logger.info('---') 8 | next() 9 | } 10 | 11 | const unknownEndpoint = (request, response) => { 12 | response.status(404).send({ error: 'unknown endpoint' }) 13 | } 14 | 15 | const errorHandler = (error, request, response, next) => { 16 | logger.error(error.message) 17 | 18 | if (error.name === 'CastError') { 19 | return response.status(400).send({ error: 'malformatted id' }) 20 | } else if (error.name === 'ValidationError') { 21 | return response.status(400).json({ error: error.message }) 22 | } else if (error.name === 'JsonWebTokenError') { 23 | return response.status(401).json({ 24 | error: 'invalid token' 25 | }) 26 | } 27 | 28 | next(error) 29 | } 30 | 31 | module.exports = { 32 | requestLogger, 33 | unknownEndpoint, 34 | errorHandler 35 | } 36 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 xxdydx 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /back/tests/test_helper.js: -------------------------------------------------------------------------------- 1 | const Blog = require('../models/blog') 2 | const User = require('../models/user') 3 | 4 | 5 | const initialBlogs = [ 6 | { 7 | 8 | title: "React patterns", 9 | author: "Michael Chan", 10 | url: "https://reactpatterns.com/", 11 | likes: 7, 12 | 13 | }, 14 | { 15 | 16 | title: "Go To Statement Considered Harmful", 17 | author: "Edsger W. Dijkstra", 18 | url: "http://www.u.arizona.edu/~rubinson/copyright_violations/Go_To_Considered_Harmful.html", 19 | likes: 5, 20 | 21 | }, 22 | { 23 | 24 | title: "Canonical string reduction", 25 | author: "Edsger W. Dijkstra", 26 | url: "http://www.cs.utexas.edu/~EWD/transcriptions/EWD08xx/EWD808.html", 27 | likes: 12, 28 | 29 | } 30 | ] 31 | 32 | 33 | const blogsInDb = async () => { 34 | const blogs = await Blog.find({}) 35 | return blogs.map(blog => blog.toJSON()) 36 | } 37 | const usersInDb = async () => { 38 | const users = await User.find({}) 39 | return users.map(u => u.toJSON()) 40 | } 41 | 42 | module.exports = { 43 | initialBlogs, blogsInDb, usersInDb 44 | } -------------------------------------------------------------------------------- /front/src/stories/Button.stories.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { Button } from './Button'; 4 | 5 | // More on default export: https://storybook.js.org/docs/react/writing-stories/introduction#default-export 6 | export default { 7 | title: 'Example/Button', 8 | component: Button, 9 | // More on argTypes: https://storybook.js.org/docs/react/api/argtypes 10 | argTypes: { 11 | backgroundColor: { control: 'color' }, 12 | }, 13 | }; 14 | 15 | // More on component templates: https://storybook.js.org/docs/react/writing-stories/introduction#using-args 16 | const Template = (args) => 19 | ); 20 | }; 21 | 22 | Button.propTypes = { 23 | /** 24 | * Is this the principal call to action on the page? 25 | */ 26 | primary: PropTypes.bool, 27 | /** 28 | * What background color to use 29 | */ 30 | backgroundColor: PropTypes.string, 31 | /** 32 | * How large should the button be? 33 | */ 34 | size: PropTypes.oneOf(['small', 'medium', 'large']), 35 | /** 36 | * Button contents 37 | */ 38 | label: PropTypes.string.isRequired, 39 | /** 40 | * Optional click handler 41 | */ 42 | onClick: PropTypes.func, 43 | }; 44 | 45 | Button.defaultProps = { 46 | backgroundColor: null, 47 | primary: false, 48 | size: 'medium', 49 | onClick: undefined, 50 | }; 51 | -------------------------------------------------------------------------------- /front/src/components/Notif.js: -------------------------------------------------------------------------------- 1 | import * as React from "react"; 2 | import { Toast } from "flowbite-react"; 3 | import DoneIcon from "@mui/icons-material/Done"; 4 | import ClearIcon from "@mui/icons-material/Clear"; 5 | import { useSelector } from "react-redux"; 6 | 7 | const Notif = () => { 8 | const notification = useSelector((state) => state.notifications); 9 | if (notification === null) { 10 | return null; 11 | } else if (notification.type === "success") { 12 | return ( 13 | 14 |
19 | 20 |
21 |
{notification.message}
22 | 23 |
24 | ); 25 | } 26 | 27 | return ( 28 | 29 |
30 | 31 |
32 |
{notification.message}
33 | 34 |
35 | ); 36 | }; 37 | 38 | export default Notif; 39 | -------------------------------------------------------------------------------- /front/src/stories/assets/flow.svg: -------------------------------------------------------------------------------- 1 | illustration/flow -------------------------------------------------------------------------------- /back/controllers/users.js: -------------------------------------------------------------------------------- 1 | const bcrypt = require("bcryptjs"); 2 | const usersRouter = require("express").Router(); 3 | const User = require("../models/user"); 4 | 5 | usersRouter.get("/", async (request, response) => { 6 | const users = await User.find({}).populate("blogs", { 7 | title: 1, 8 | content: 1, 9 | dateCreated: 1, 10 | likes: 1, 11 | }); 12 | response.json(users); 13 | }); 14 | 15 | usersRouter.post("/", async (request, response) => { 16 | const { username, name, password } = request.body; 17 | const checkUsername = (obj) => obj.username === username; 18 | 19 | if (!username) { 20 | return response.status(400).json({ 21 | error: "username is required", 22 | }); 23 | } 24 | if (!password) { 25 | return response.status(400).json({ 26 | error: "password is required", 27 | }); 28 | } 29 | const condt = await User.find({}); 30 | if (condt.some(checkUsername)) { 31 | return response.status(400).json({ 32 | error: "username must be unique", 33 | }); 34 | } 35 | 36 | const saltRounds = 10; 37 | const passwordHash = await bcrypt.hash(password, saltRounds); 38 | 39 | const user = new User({ 40 | username, 41 | name, 42 | passwordHash, 43 | }); 44 | 45 | const savedUser = await user.save(); 46 | 47 | response.status(201).json(savedUser); 48 | }); 49 | 50 | module.exports = usersRouter; 51 | -------------------------------------------------------------------------------- /front/src/stories/page.css: -------------------------------------------------------------------------------- 1 | section { 2 | font-family: 'Nunito Sans', 'Helvetica Neue', Helvetica, Arial, sans-serif; 3 | font-size: 14px; 4 | line-height: 24px; 5 | padding: 48px 20px; 6 | margin: 0 auto; 7 | max-width: 600px; 8 | color: #333; 9 | } 10 | 11 | section h2 { 12 | font-weight: 900; 13 | font-size: 32px; 14 | line-height: 1; 15 | margin: 0 0 4px; 16 | display: inline-block; 17 | vertical-align: top; 18 | } 19 | 20 | section p { 21 | margin: 1em 0; 22 | } 23 | 24 | section a { 25 | text-decoration: none; 26 | color: #1ea7fd; 27 | } 28 | 29 | section ul { 30 | padding-left: 30px; 31 | margin: 1em 0; 32 | } 33 | 34 | section li { 35 | margin-bottom: 8px; 36 | } 37 | 38 | section .tip { 39 | display: inline-block; 40 | border-radius: 1em; 41 | font-size: 11px; 42 | line-height: 12px; 43 | font-weight: 700; 44 | background: #e7fdd8; 45 | color: #66bf3c; 46 | padding: 4px 12px; 47 | margin-right: 10px; 48 | vertical-align: top; 49 | } 50 | 51 | section .tip-wrapper { 52 | font-size: 13px; 53 | line-height: 20px; 54 | margin-top: 40px; 55 | margin-bottom: 40px; 56 | } 57 | 58 | section .tip-wrapper svg { 59 | display: inline-block; 60 | height: 12px; 61 | width: 12px; 62 | margin-right: 4px; 63 | vertical-align: top; 64 | margin-top: 3px; 65 | } 66 | 67 | section .tip-wrapper svg path { 68 | fill: #1ea7fd; 69 | } 70 | -------------------------------------------------------------------------------- /front/src/services/blogs.js: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | const baseUrl = "/api/blogs"; 3 | 4 | let token = null; 5 | 6 | const setToken = (newToken) => { 7 | token = `bearer ${newToken}`; 8 | }; 9 | 10 | const getAll = () => { 11 | const request = axios.get(baseUrl); 12 | return request.then((response) => response.data); 13 | }; 14 | 15 | const create = async (newObject) => { 16 | const config = { 17 | headers: { Authorization: token }, 18 | }; 19 | const response = await axios.post(baseUrl, newObject, config); 20 | return response.data; 21 | }; 22 | 23 | const update = async (newObject) => { 24 | const config = { 25 | headers: { Authorization: token }, 26 | }; 27 | const response = await axios.put( 28 | `${baseUrl}/${newObject.id}`, 29 | newObject, 30 | config 31 | ); 32 | return response.data; 33 | }; 34 | const remove = async (id) => { 35 | const config = { 36 | headers: { Authorization: token }, 37 | }; 38 | const response = await axios.delete(`${baseUrl}/${id}`, config); 39 | return response.data; 40 | }; 41 | 42 | const postComment = async (comment, id) => { 43 | const config = { 44 | headers: { Authorization: token }, 45 | }; 46 | const response = await axios.post( 47 | `${baseUrl}/${id}/comments`, 48 | comment, 49 | config 50 | ); 51 | return response.data; 52 | }; 53 | 54 | export default { getAll, create, update, remove, setToken, postComment }; 55 | -------------------------------------------------------------------------------- /front/src/components/BlogFooter.js: -------------------------------------------------------------------------------- 1 | import { Footer } from "flowbite-react"; 2 | const BlogFooter = () => { 3 | return ( 4 |
5 | 36 |
37 | ); 38 | }; 39 | 40 | export default BlogFooter; 41 | -------------------------------------------------------------------------------- /front/src/stories/assets/code-brackets.svg: -------------------------------------------------------------------------------- 1 | illustration/code-brackets -------------------------------------------------------------------------------- /front/src/stories/assets/comments.svg: -------------------------------------------------------------------------------- 1 | illustration/comments -------------------------------------------------------------------------------- /front/src/stories/assets/repo.svg: -------------------------------------------------------------------------------- 1 | illustration/repo -------------------------------------------------------------------------------- /front/tailwind.config.js: -------------------------------------------------------------------------------- 1 | /** @type {import('tailwindcss').Config} */ 2 | module.exports = { 3 | content: [ 4 | "./src/**/*.{js,jsx,ts,tsx}", 5 | "node_modules/flowbite-react/**/*.{js,jsx,ts,tsx}", 6 | ], 7 | darkMode: "class", 8 | theme: { 9 | extend: { 10 | colors: { 11 | primary: { 12 | 50: "#eff6ff", 13 | 100: "#dbeafe", 14 | 200: "#bfdbfe", 15 | 300: "#93c5fd", 16 | 400: "#60a5fa", 17 | 500: "#3b82f6", 18 | 600: "#2563eb", 19 | 700: "#1d4ed8", 20 | 800: "#1e40af", 21 | 900: "#1e3a8a", 22 | }, 23 | }, 24 | }, 25 | fontFamily: { 26 | body: [ 27 | "Inter", 28 | "ui-sans-serif", 29 | "system-ui", 30 | "-apple-system", 31 | "system-ui", 32 | "Segoe UI", 33 | "Roboto", 34 | "Helvetica Neue", 35 | "Arial", 36 | "Noto Sans", 37 | "sans-serif", 38 | "Apple Color Emoji", 39 | "Segoe UI Emoji", 40 | "Segoe UI Symbol", 41 | "Noto Color Emoji", 42 | ], 43 | sans: [ 44 | "Inter", 45 | "ui-sans-serif", 46 | "system-ui", 47 | "-apple-system", 48 | "system-ui", 49 | "Segoe UI", 50 | "Roboto", 51 | "Helvetica Neue", 52 | "Arial", 53 | "Noto Sans", 54 | "sans-serif", 55 | "Apple Color Emoji", 56 | "Segoe UI Emoji", 57 | "Segoe UI Symbol", 58 | "Noto Color Emoji", 59 | ], 60 | }, 61 | }, 62 | plugins: [require("flowbite/plugin")], 63 | }; 64 | -------------------------------------------------------------------------------- /front/src/stories/Header.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import PropTypes from 'prop-types'; 3 | 4 | import { Button } from './Button'; 5 | import './header.css'; 6 | 7 | export const Header = ({ user, onLogin, onLogout, onCreateAccount }) => ( 8 |
9 |
10 |
11 | 12 | 13 | 17 | 21 | 25 | 26 | 27 |

Acme

28 |
29 |
30 | {user ? ( 31 | <> 32 | 33 | Welcome, {user.name}! 34 | 35 |
44 |
45 |
46 | ); 47 | 48 | Header.propTypes = { 49 | user: PropTypes.shape({}), 50 | onLogin: PropTypes.func.isRequired, 51 | onLogout: PropTypes.func.isRequired, 52 | onCreateAccount: PropTypes.func.isRequired, 53 | }; 54 | 55 | Header.defaultProps = { 56 | user: null, 57 | }; 58 | -------------------------------------------------------------------------------- /front/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 17 | 18 | 27 | React App 28 | 29 | 30 | 31 |
32 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /front/src/components/BlogList.js: -------------------------------------------------------------------------------- 1 | import Blog from "../components/Blog"; 2 | import { useRef } from "react"; 3 | import { setUser } from "../reducers/userReducer"; 4 | import { useSelector, useDispatch } from "react-redux"; 5 | import Togglable from "./Togglable"; 6 | import NewBlog from "./NewBlog"; 7 | import BlogFooter from "./BlogFooter"; 8 | import { Card } from "flowbite-react"; 9 | 10 | const BlogList = () => { 11 | const blogs = useSelector((state) => state.blogs); 12 | const blogs1 = [...blogs]; 13 | 14 | return ( 15 |
16 |
17 |
18 |
19 |
20 |

21 | Posts 22 |

23 |
24 |
25 | {blogs1.length > 0 ? ( 26 | blogs1 27 | .sort((a, b) => (a.likes > b.likes ? -1 : 1)) 28 | .map((blog) => ) 29 | ) : ( 30 |
31 |
32 |

33 | No posts yet... Create one! 34 |

35 |
36 | )} 37 |
38 |
39 |
40 | 41 | 42 |
43 | ); 44 | }; 45 | 46 | export default BlogList; 47 | -------------------------------------------------------------------------------- /front/src/stories/assets/plugin.svg: -------------------------------------------------------------------------------- 1 | illustration/plugin -------------------------------------------------------------------------------- /front/src/reducers/blogReducer.js: -------------------------------------------------------------------------------- 1 | import { createSlice } from "@reduxjs/toolkit"; 2 | import blogService from "../services/blogs"; 3 | 4 | const blogSlice = createSlice({ 5 | name: "blogs", 6 | initialState: [], 7 | reducers: { 8 | create(state, action) { 9 | const blog = action.payload; 10 | state.push(blog); 11 | }, 12 | setBlogs(state, action) { 13 | return action.payload; 14 | }, 15 | edit(state, action) { 16 | const updatedBlog = action.payload; 17 | return state.map((item) => 18 | item.id === updatedBlog.id ? updatedBlog : item 19 | ); 20 | }, 21 | 22 | remove(state, action) { 23 | const id = action.payload; 24 | return state.filter((blogs) => blogs.id !== id); 25 | }, 26 | comment(state, action) { 27 | const updatedBlog = action.payload; 28 | return state.map((item) => 29 | item.id === updatedBlog.id ? updatedBlog : item 30 | ); 31 | }, 32 | }, 33 | }); 34 | 35 | export const { create, setBlogs, edit, remove } = blogSlice.actions; 36 | 37 | export const initializeBlogs = () => { 38 | return async (dispatch) => { 39 | const blogs = await blogService.getAll(); 40 | dispatch(setBlogs(blogs)); 41 | }; 42 | }; 43 | export const createBlog = (blog) => { 44 | return async (dispatch) => { 45 | const newBlog = await blogService.create(blog); 46 | dispatch(create(newBlog)); 47 | }; 48 | }; 49 | 50 | export const updateBlog = (updatedBlog) => { 51 | return async (dispatch) => { 52 | const updatedBlog1 = await blogService.update(updatedBlog); 53 | dispatch(edit(updatedBlog1)); 54 | }; 55 | }; 56 | 57 | export const deleteBlog = (id) => { 58 | return async (dispatch) => { 59 | const response = await blogService.remove(id); 60 | dispatch(remove(id)); 61 | }; 62 | }; 63 | 64 | export const commentBlog = (comment, id) => { 65 | const formattedComment = { 66 | content: comment, 67 | }; 68 | return async (dispatch) => { 69 | const response = await blogService.postComment(formattedComment, id); 70 | dispatch(edit(response)); 71 | }; 72 | }; 73 | 74 | export default blogSlice.reducer; 75 | -------------------------------------------------------------------------------- /back/tests/user_api.test.js: -------------------------------------------------------------------------------- 1 | const mongoose = require('mongoose') 2 | const supertest = require('supertest') 3 | const app = require('../app') 4 | const helper = require('./test_helper') 5 | const bcrypt = require('bcryptjs') 6 | const User = require('../models/user') 7 | const api = supertest(app) 8 | 9 | 10 | describe('btest', () => { 11 | beforeEach(async () => { 12 | await User.deleteMany({}) 13 | const passwordHash = await bcrypt.hash('lmao', 10) 14 | const user = new User({ username: 'root', passwordHash }) 15 | 16 | await user.save() 17 | }) 18 | 19 | test('creation succeeds with a fresh username', async () => { 20 | const usersAtStart = await helper.usersInDb() 21 | 22 | const newUser = { 23 | username: 'arul00', 24 | name: 'AK', 25 | password: 'hello1234', 26 | } 27 | 28 | await api 29 | .post('/api/users') 30 | .send(newUser) 31 | .expect(201) 32 | .expect('Content-Type', /application\/json/) 33 | 34 | const usersAtEnd = await helper.usersInDb() 35 | expect(usersAtEnd).toHaveLength(usersAtStart.length + 1) 36 | 37 | const usernames = usersAtEnd.map(u => u.username) 38 | expect(usernames).toContain(newUser.username) 39 | }) 40 | 41 | test ('invalid username test', async () => { 42 | 43 | const invalidUser = { 44 | name: 'John', 45 | password:'hello' 46 | } 47 | await api 48 | .post('/api/users') 49 | .send(invalidUser) 50 | .expect(400) 51 | 52 | const response = await api.get('/api/users') 53 | expect(response.body).toHaveLength(1) 54 | }) 55 | 56 | test ('same username test', async () => { 57 | const sameUser = { 58 | username: 'root', 59 | name: 'LOL', 60 | password: '803840344' 61 | } 62 | await api 63 | .post('/api/users') 64 | .send(sameUser) 65 | .expect(400) 66 | 67 | const response = await api.get('/api/users') 68 | expect(response.body).toHaveLength(1) 69 | }) 70 | 71 | }) 72 | 73 | 74 | 75 | 76 | 77 | 78 | afterAll(() => { 79 | mongoose.connection.close() 80 | }) 81 | -------------------------------------------------------------------------------- /front/src/stories/assets/stackalt.svg: -------------------------------------------------------------------------------- 1 | illustration/stackalt -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Forum App 2 | 3 | A simple forum app. This is my first foray into full-stack web development using the MERN stack. 4 | 5 | ## Features 6 | 7 | - CRUD functionality of posts (both frontend and backend) 8 | - Like and comment functionality 9 | - User authentication using JSON Web Token 10 | - Clean and usable UI 11 | 12 | ## Stack and Frameworks used 13 | 14 | ### Frontend 15 | 16 | ReactJS Redux React-Router Tailwind CSS 17 | 18 | ### Backend 19 | 20 | NodeJS MongoDB 21 | 22 | ### Testing 23 | 24 | Jest Cypress 25 | 26 | ## Screenshots 27 | 28 | 29 | 30 | 31 | 32 | 33 | ## Installing this project locally 34 | It's an easy process. 35 | 1. Install NodeJS and the NPM package manager. 36 | 2. Get your own MongoDB database (you can get one for free at MongoDB Atlas or you can set up one locally) 37 | 3. Clone this git repository 38 | 4. `cd back` 39 | 5. Set your MongoDB database link and port (3003 by default) variables under the .env file 40 | 6. `npm install` 41 | 7. `npm start` 42 | 43 | There you go! Hopefully I find time to make a Dockerfile for this lol. 44 | 45 | 46 | ## Upcoming Features 47 | 48 | - Fix up some user interfaces (I have trouble with CSS :/) 49 | - Dockerfile and automated CI/CD 50 | - Markdown support for posts 51 | - Abilty to like and unlike posts (user-unique likes, it's currently anonymous now) 52 | - Nested commenting features (quite lazy to do tbh) 53 | - Better and more secure user authentication 54 | -------------------------------------------------------------------------------- /front/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "frontend", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@emotion/react": "^11.10.5", 7 | "@emotion/styled": "^11.10.5", 8 | "@mui/icons-material": "^5.10.9", 9 | "@mui/material": "^5.10.12", 10 | "@reduxjs/toolkit": "^1.9.1", 11 | "@testing-library/jest-dom": "^5.16.4", 12 | "@testing-library/react": "^13.1.1", 13 | "@testing-library/user-event": "^14.1.1", 14 | "axios": "^0.27.2", 15 | "flowbite": "^1.5.4", 16 | "flowbite-react": "^0.3.6", 17 | "inter-ui": "^3.19.3", 18 | "react": "^18.1.0", 19 | "react-dom": "^18.1.0", 20 | "react-icons": "^4.7.1", 21 | "react-redux": "^8.0.5", 22 | "react-router-dom": "^6.4.4", 23 | "react-scripts": "5.0.1", 24 | "redux": "^4.2.0", 25 | "redux-thunk": "^2.4.2", 26 | "web-vitals": "^2.1.4" 27 | }, 28 | "scripts": { 29 | "start": "react-scripts start", 30 | "build": "react-scripts build", 31 | "test": "react-scripts test", 32 | "eject": "react-scripts eject", 33 | "storybook": "start-storybook -p 6006 -s public", 34 | "build-storybook": "build-storybook -s public" 35 | }, 36 | "eslintConfig": { 37 | "extends": [ 38 | "react-app", 39 | "react-app/jest" 40 | ], 41 | "overrides": [ 42 | { 43 | "files": [ 44 | "**/*.stories.*" 45 | ], 46 | "rules": { 47 | "import/no-anonymous-default-export": "off" 48 | } 49 | } 50 | ] 51 | }, 52 | "browserslist": { 53 | "production": [ 54 | ">0.2%", 55 | "not dead", 56 | "not op_mini all" 57 | ], 58 | "development": [ 59 | "last 1 chrome version", 60 | "last 1 firefox version", 61 | "last 1 safari version" 62 | ] 63 | }, 64 | "proxy": "http://localhost:3003", 65 | "devDependencies": { 66 | "@storybook/addon-actions": "^6.5.13", 67 | "@storybook/addon-essentials": "^6.5.13", 68 | "@storybook/addon-interactions": "^6.5.13", 69 | "@storybook/addon-links": "^6.5.13", 70 | "@storybook/builder-webpack5": "^6.5.13", 71 | "@storybook/manager-webpack5": "^6.5.13", 72 | "@storybook/node-logger": "^6.5.13", 73 | "@storybook/preset-create-react-app": "^4.1.2", 74 | "@storybook/react": "^6.5.13", 75 | "@storybook/testing-library": "^0.0.13", 76 | "autoprefixer": "^10.4.13", 77 | "babel-plugin-named-exports-order": "^0.0.2", 78 | "postcss": "^8.4.19", 79 | "prop-types": "^15.8.1", 80 | "tailwindcss": "^3.2.4", 81 | "webpack": "^5.74.0" 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /front/src/components/Blog.js: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import { useSelector, useDispatch } from "react-redux"; 3 | import { setNotification } from "../reducers/notificationReducer"; 4 | import { updateBlog, deleteBlog } from "../reducers/blogReducer"; 5 | import { Link } from "react-router-dom"; 6 | import { Card } from "flowbite-react"; 7 | import FavoriteIcon from "@mui/icons-material/Favorite"; 8 | import CommentIcon from "@mui/icons-material/Comment"; 9 | 10 | const Blog = ({ blog }) => { 11 | const user = useSelector((state) => state.users); 12 | const dispatch = useDispatch(); 13 | const blogs = useSelector((state) => state.blogs); 14 | console.log(blog); 15 | if (blog === undefined) { 16 | return null; 17 | } 18 | const comments = blog.comments ? blog.comments : []; 19 | 20 | const handleUpdateBlog = async (blogObject) => { 21 | try { 22 | await dispatch(updateBlog(blogObject)); 23 | } catch (error) { 24 | const notif = { 25 | message: error.response.data.error, 26 | type: "error", 27 | }; 28 | dispatch(setNotification(notif, 2500)); 29 | } 30 | }; 31 | const handleDeleteBlog = async (id) => { 32 | const blog1 = blogs.filter((b) => b.id === id); 33 | const title = blog1[0].title; 34 | if (window.confirm(`Do you want to delete ${title}?`)) { 35 | try { 36 | await dispatch(deleteBlog(id)); 37 | const notif = { 38 | message: "Successfully deleted", 39 | type: "success", 40 | }; 41 | dispatch(setNotification(notif, 2500)); 42 | } catch (error) { 43 | const notif = { 44 | message: error.message, 45 | type: "error", 46 | }; 47 | dispatch(setNotification(notif, 2500)); 48 | } 49 | } 50 | }; 51 | var summary = blog.content.substring(0, 130); 52 | summary = 53 | summary.length === 130 54 | ? summary.substr(0, Math.min(summary.length, summary.lastIndexOf(" "))) 55 | : summary; 56 | 57 | return ( 58 | 59 |
60 | {blog.title} 61 |
62 |

{summary}

63 |
64 |
65 | 66 | {blog.likes} 67 |
68 |
69 | 70 | {comments.length} 71 |
72 |
73 |
74 | ); 75 | }; 76 | export default Blog; 77 | -------------------------------------------------------------------------------- /front/src/stories/Page.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { Header } from './Header'; 4 | import './page.css'; 5 | 6 | export const Page = () => { 7 | const [user, setUser] = React.useState(); 8 | 9 | return ( 10 |
11 |
setUser({ name: 'Jane Doe' })} 14 | onLogout={() => setUser(undefined)} 15 | onCreateAccount={() => setUser({ name: 'Jane Doe' })} 16 | /> 17 | 18 |
19 |

Pages in Storybook

20 |

21 | We recommend building UIs with a{' '} 22 | 23 | component-driven 24 | {' '} 25 | process starting with atomic components and ending with pages. 26 |

27 |

28 | Render pages with mock data. This makes it easy to build and review page states without 29 | needing to navigate to them in your app. Here are some handy patterns for managing page 30 | data in Storybook: 31 |

32 |
    33 |
  • 34 | Use a higher-level connected component. Storybook helps you compose such data from the 35 | "args" of child component stories 36 |
  • 37 |
  • 38 | Assemble data in the page component from your services. You can mock these services out 39 | using Storybook. 40 |
  • 41 |
42 |

43 | Get a guided tutorial on component-driven development at{' '} 44 | 45 | Storybook tutorials 46 | 47 | . Read more in the{' '} 48 | 49 | docs 50 | 51 | . 52 |

53 |
54 | Tip Adjust the width of the canvas with the{' '} 55 | 56 | 57 | 62 | 63 | 64 | Viewports addon in the toolbar 65 |
66 |
67 |
68 | ); 69 | }; 70 | -------------------------------------------------------------------------------- /back/build/static/js/main.b9f121bb.js.LICENSE.txt: -------------------------------------------------------------------------------- 1 | /*! 2 | Copyright (c) 2018 Jed Watson. 3 | Licensed under the MIT License (MIT), see 4 | http://jedwatson.github.io/classnames 5 | */ 6 | 7 | /*! 8 | Copyright (c) 2017 Jed Watson. 9 | Licensed under the MIT License (MIT), see 10 | http://jedwatson.github.io/classnames 11 | */ 12 | 13 | /*! regenerator-runtime -- Copyright (c) 2014-present, Facebook, Inc. -- license (MIT): https://github.com/facebook/regenerator/blob/main/LICENSE */ 14 | 15 | /** 16 | * @license React 17 | * react-dom.production.min.js 18 | * 19 | * Copyright (c) Facebook, Inc. and its affiliates. 20 | * 21 | * This source code is licensed under the MIT license found in the 22 | * LICENSE file in the root directory of this source tree. 23 | */ 24 | 25 | /** 26 | * @license React 27 | * react-is.production.min.js 28 | * 29 | * Copyright (c) Facebook, Inc. and its affiliates. 30 | * 31 | * This source code is licensed under the MIT license found in the 32 | * LICENSE file in the root directory of this source tree. 33 | */ 34 | 35 | /** 36 | * @license React 37 | * react-jsx-runtime.production.min.js 38 | * 39 | * Copyright (c) Facebook, Inc. and its affiliates. 40 | * 41 | * This source code is licensed under the MIT license found in the 42 | * LICENSE file in the root directory of this source tree. 43 | */ 44 | 45 | /** 46 | * @license React 47 | * react.production.min.js 48 | * 49 | * Copyright (c) Facebook, Inc. and its affiliates. 50 | * 51 | * This source code is licensed under the MIT license found in the 52 | * LICENSE file in the root directory of this source tree. 53 | */ 54 | 55 | /** 56 | * @license React 57 | * scheduler.production.min.js 58 | * 59 | * Copyright (c) Facebook, Inc. and its affiliates. 60 | * 61 | * This source code is licensed under the MIT license found in the 62 | * LICENSE file in the root directory of this source tree. 63 | */ 64 | 65 | /** 66 | * @license React 67 | * use-sync-external-store-shim.production.min.js 68 | * 69 | * Copyright (c) Facebook, Inc. and its affiliates. 70 | * 71 | * This source code is licensed under the MIT license found in the 72 | * LICENSE file in the root directory of this source tree. 73 | */ 74 | 75 | /** 76 | * @license React 77 | * use-sync-external-store-shim/with-selector.production.min.js 78 | * 79 | * Copyright (c) Facebook, Inc. and its affiliates. 80 | * 81 | * This source code is licensed under the MIT license found in the 82 | * LICENSE file in the root directory of this source tree. 83 | */ 84 | 85 | /** 86 | * @remix-run/router v1.0.4 87 | * 88 | * Copyright (c) Remix Software Inc. 89 | * 90 | * This source code is licensed under the MIT license found in the 91 | * LICENSE.md file in the root directory of this source tree. 92 | * 93 | * @license MIT 94 | */ 95 | 96 | /** 97 | * React Router v6.4.4 98 | * 99 | * Copyright (c) Remix Software Inc. 100 | * 101 | * This source code is licensed under the MIT license found in the 102 | * LICENSE.md file in the root directory of this source tree. 103 | * 104 | * @license MIT 105 | */ 106 | 107 | /** @license MUI v5.10.8 108 | * 109 | * This source code is licensed under the MIT license found in the 110 | * LICENSE file in the root directory of this source tree. 111 | */ 112 | 113 | /** @license React v16.13.1 114 | * react-is.production.min.js 115 | * 116 | * Copyright (c) Facebook, Inc. and its affiliates. 117 | * 118 | * This source code is licensed under the MIT license found in the 119 | * LICENSE file in the root directory of this source tree. 120 | */ 121 | -------------------------------------------------------------------------------- /front/src/components/NewBlog.js: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import { createBlog } from "../reducers/blogReducer"; 3 | import { useDispatch } from "react-redux"; 4 | import { useNavigate } from "react-router-dom"; 5 | import { setNotification } from "../reducers/notificationReducer"; 6 | import { TextInput, Label, Button, Textarea } from "flowbite-react"; 7 | import BlogFooter from "./BlogFooter"; 8 | 9 | const NewBlog = () => { 10 | const dispatch = useDispatch(); 11 | const [newTitle, setNewTitle] = useState(""); 12 | const [newContent, setNewContent] = useState(""); 13 | 14 | const navigate = useNavigate(); 15 | 16 | const addBlog = (event) => { 17 | event.preventDefault(); 18 | const blogObject = { 19 | title: newTitle, 20 | content: newContent, 21 | dateCreated: new Date(), 22 | }; 23 | addNewBlog(blogObject); 24 | setNewContent(""); 25 | setNewTitle(""); 26 | }; 27 | 28 | const addNewBlog = async (blogObject) => { 29 | try { 30 | const notif1 = { 31 | message: `Post was successfully added`, 32 | type: "success", 33 | }; 34 | await dispatch(createBlog(blogObject)); 35 | navigate("/"); 36 | 37 | dispatch(setNotification(notif1, 2500)); 38 | } catch (exception) { 39 | const notif2 = { 40 | message: `Cannot add post`, 41 | type: "failure", 42 | }; 43 | dispatch(setNotification(notif2, 2500)); 44 | } 45 | }; 46 | 47 | return ( 48 | <> 49 |
50 |
51 |
52 |
53 |
54 |

55 | Create a Post 56 |

57 |
58 |
59 |
60 |
61 |
62 |
64 | setNewTitle(target.value)} 71 | /> 72 |
73 |
74 |
75 |
77 | 183 |
184 | 190 |
191 | 192 | {comments.length > 0 ? ( 193 | comments.map((comment) => ( 194 | 195 | )) 196 | ) : ( 197 |
198 |
199 |

200 | No Comments Yet 201 |

202 |
203 | )} 204 | 205 |
206 |
207 |
208 | 209 | 210 |
211 | ); 212 | }; 213 | 214 | export default BlogView; 215 | -------------------------------------------------------------------------------- /back/build/static/css/main.52dae25a.css.map: -------------------------------------------------------------------------------- 1 | {"version":3,"file":"static/css/main.52dae25a.css","mappings":"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,CAAA,sG,CAAA,qG,CAAA,uG,CAAA,yG,CAAA,wG,CAAA,wG,CAAA,uG,CAAA,uG,CAAA,uG,CAAA,uG,CAAA,wG,CAAA,uG,CAAA,yG,CAAA,sG,CAAA,uG,CAAA,sG,CAAA,oG,CAAA,uG,CAAA,uG,CAAA,yD,CAAA,mG,CAAA,yC,CAAA,kH,CAAA,kH,CAAA,kH,CAAA,mH,CAAA,sH,CAAA,mH,CAAA,qH,CAAA,oD,CAAA,+G,CAAA,+G,CAAA,8G,CAAA,gH,CAAA,iH,CAAA,iH,CAAA,iH,CAAA,+G,CAAA,2E,CAAA,yD,CAAA,qD,CAAA,uG,CAAA,wF,CAAA,sZ,CAAA,iH,CAAA,iF,CAAA,oI,CAAA,oI,CAAA,oC,CAAA,0C,CAAA,sG,CAAA,sG,CAAA,sG,CAAA,sG,CAAA,yG,CAAA,wG,CAAA,yG,CAAA,yG,CAAA,wG,CAAA,sG,CAAA,8F,CAAA,8F,CAAA,8F,CAAA,kG,CAAA,8F,CAAA,8F,CAAA,gE,CAAA,iG,CAAA,gG,CAAA,kG,CAAA,mG,CAAA,8F,CAAA,mG,CAAA,mG,CAAA,iG,CAAA,8F,CAAA,oD,CAAA,gG,CAAA,kG,CAAA,+F,CAAA,+D,CAAA,+D,CAAA,iG,CAAA,gG,CAAA,mG,CAAA,kG,CAAA,+F,CAAA,8F,CAAA,8F,CAAA,iG,CAAA,gG,CAAA,iG,CAAA,8F,CAAA,iG,CAAA,mG,CAAA,kG,CAAA,8C,CAAA,iG,CAAA,iC,CAAA,uC,CAAA,yF,CAAA,4F,CAAA,8F,CAAA,4F,CAAA,4F,CAAA,0F,CAAA,6F,CAAA,2F,CAAA,2F,CAAA,yF,CAAA,0F,CAAA,4F,CAAA,0F,CAAA,yF,CAAA,yF,CAAA,4F,CAAA,yF,CAAA,yF,CAAA,2F,CAAA,4F,CAAA,4F,CAAA,0F,CAAA,yF,CAAA,4F,CAAA,yF,CAAA,yF,CAAA,4F,CAAA,2F,CAAA,4F,CAAA,4F,CAAA,4I,CAAA,8H,CAAA,sR,CAAA,mG,CAAA,mG,CAAA,sG,CAAA,oG,CAAA,sG,CAAA,uG,CAAA,sG,CAAA,sG,CAAA,qG,CAAA,mG,CAAA,gE,CAAA,kH,CAAA,kH,CAAA,mH,CAAA,2G,CAAA,2G,CAAA,+G,CAAA,2G,CAAA,8G,CAAA,6G,CAAA,+G,CAAA,+G,CAAA,2G,CAAA,4G,CAAA,+G,CAAA,4G,CAAA,0G,CAAA,sG,CAAA,yG,CAAA,wH,CAAA,qH,CAAA,mH,CAAA,sH,CAAA,sH,CAAA,6G,CAAA,sG,CAAA,oH,CAAA,gH,CAAA,qH,CAAA,oH,CAAA,kH,CAAA,oH,CAAA,gH,CAAA,gH,CAAA,iH,CAAA,gH,CAAA,mH,CAAA,gH,CAAA,kH,CAAA,iH,CAAA,gH,CAAA,gH,CAAA,iH,CAAA,mH,CAAA,+G,CAAA,kH,CAAA,iH,CAAA,kH,CAAA,gH,CAAA,mH,CAAA,mH,CAAA,mH,CAAA,+G,CAAA,8H,CAAA,8H,CAAA,gI,CAAA,kI,CAAA,+H,CAAA,iI,CAAA,2H,CAAA,wH,CAAA,0H,CAAA,4H,CAAA,4H,CAAA,6H,CAAA,6H,CAAA,2H,CAAA,yF,CAAA,mH,CAAA,0F,CAAA,wE,CAAA,sB,CAAA,yB,CAAA,sB,CAAA,uB,CAAA,uB,CAAA,sB,CAAA,uB,CAAA,sB,CAAA,qB,CAAA,6B,CAAA,8D,CAAA,8D,CAAA,0K,CAAA,4K,CAAA,iC,CAAA,mC,CAAA,8E,CAAA,gF,CAAA,qB,CAAA,8C,CAAA,4B,CAAA,kC,CAAA,mD,CAAA,kD,CAAA,kD,CAAA,kD,CAAA,8C,EAAA,mE,CAAA,8C,CAAA,6B,CAAA,6B,CAAA,sB,CAAA,wB,CAAA,sB,CAAA,wB,CAAA,uB,CAAA,uB,CAAA,qB,CAAA,sB,CAAA,6B,CAAA,8D,CAAA,gC,CAAA,oC,CAAA,kD,CAAA,gL,CAAA,4K,CAAA,iC,CAAA,8E,CAAA,4B,CAAA,4C,CAAA,uB,CAAA,qB,CAAA,kB,CAAA,0C,CAAA,mD,CAAA,kD,CAAA,+C,CAAA,kD,CAAA,gC,CAAA,kF,CAAA,yD,CAAA,+F,CAAA,qE,CAAA,0G,EAAA,mE,CAAA,yB,CAAA,sC,CAAA,8B,CAAA,2B,CAAA,gE,CAAA,8D,CAAA,8D,CAAA,mB,CAAA,wB,CAAA,+C,CAAA,kD,CAAA,+C,CAAA,+C,CAAA,2B,CAAA,8B,CAAA,8B,CAAA,kD,CAAA,kD,CAAA,+C,CAAA,0C,EAAA,0C,CAAA,kB,CAAA,6C","sources":["index.css"],"sourcesContent":["@tailwind base;\r\n@tailwind components;\r\n@tailwind utilities;\r\n@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&display=swap');\r\n\r\nhtml { font-family: 'Inter', sans-serif; \r\n}\r\n\r\n \r\n\r\n\r\n\r\n\r\n\r\n\r\n\r\n"],"names":[],"sourceRoot":""} --------------------------------------------------------------------------------