├── 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 You need to enable JavaScript to run this 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 | {props.buttonLabel}
23 |
24 |
25 | {props.children}
26 | cancel
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 |
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) => ;
17 |
18 | export const Primary = Template.bind({});
19 | // More on args: https://storybook.js.org/docs/react/writing-stories/args
20 | Primary.args = {
21 | primary: true,
22 | label: 'Button',
23 | };
24 |
25 | export const Secondary = Template.bind({});
26 | Secondary.args = {
27 | label: 'Button',
28 | };
29 |
30 | export const Large = Template.bind({});
31 | Large.args = {
32 | size: 'large',
33 | label: 'Button',
34 | };
35 |
36 | export const Small = Template.bind({});
37 | Small.args = {
38 | size: 'small',
39 | label: 'Button',
40 | };
41 |
--------------------------------------------------------------------------------
/front/src/components/ErrorPage.js:
--------------------------------------------------------------------------------
1 | const ErrorPage = () => {
2 | return (
3 |
4 |
5 |
6 |
9 |
10 | Something's missing.
11 |
12 |
13 | Sorry, we can't find that page. You'll find lots to explore on the
14 | home page.{" "}
15 |
16 |
20 | Back to Homepage
21 |
22 |
23 |
24 |
25 | );
26 | };
27 |
28 | export default ErrorPage;
29 |
--------------------------------------------------------------------------------
/front/src/components/UserView.js:
--------------------------------------------------------------------------------
1 | import { Spinner, Footer } from "flowbite-react";
2 | import blogReducer from "../reducers/blogReducer";
3 | import users from "../services/users";
4 | import Blog from "./Blog";
5 |
6 | const UserView = ({ userInView }) => {
7 | if (userInView === undefined) {
8 | return null;
9 | }
10 | const totalVotes = userInView.blogs
11 | .map((blog) => blog.likes)
12 | .reduce((accumulator, currentValue) => accumulator + currentValue, 0);
13 |
14 | return (
15 |
16 |
17 |
20 |
21 | {totalVotes} likes
22 |
23 |
24 | Posts added by {userInView.name}
25 |
26 | {userInView.blogs.map((blog) => (
27 |
28 | ))}
29 |
30 |
31 | );
32 | };
33 |
34 | export default UserView;
35 |
--------------------------------------------------------------------------------
/front/src/stories/assets/direction.svg:
--------------------------------------------------------------------------------
1 | illustration/direction
--------------------------------------------------------------------------------
/back/app.js:
--------------------------------------------------------------------------------
1 | const config = require("./utils/config");
2 | const express = require("express");
3 | const app = express();
4 | const cors = require("cors");
5 | require("express-async-errors");
6 | const blogRouter = require("./controllers/blogs");
7 | const usersRouter = require("./controllers/users");
8 | const loginRouter = require("./controllers/login");
9 | const path = require("path");
10 | const middleware = require("./utils/middleware");
11 | const logger = require("./utils/logger");
12 | const mongoose = require("mongoose");
13 |
14 | logger.info("connecting to", config.MONGODB_URI);
15 |
16 | mongoose
17 | .connect(config.MONGODB_URI)
18 | .then(() => {
19 | logger.info("connected to MongoDB");
20 | })
21 | .catch((error) => {
22 | logger.error("error connecting to MongoDB:", error.message);
23 | });
24 |
25 | app.use(cors());
26 | app.use(express.static("build"));
27 | app.use(express.json());
28 | app.use(middleware.requestLogger);
29 |
30 | app.use("/api/blogs", blogRouter);
31 | app.use("/api/users", usersRouter);
32 | app.use("/api/login", loginRouter);
33 |
34 | app.get("/*", function (req, res) {
35 | res.sendFile(path.join(__dirname, "build", "index.html"));
36 | });
37 |
38 | app.use(middleware.unknownEndpoint);
39 | app.use(middleware.errorHandler);
40 |
41 | module.exports = app;
42 |
--------------------------------------------------------------------------------
/front/src/stories/Button.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 | import PropTypes from 'prop-types';
3 | import './button.css';
4 |
5 | /**
6 | * Primary UI component for user interaction
7 | */
8 | export const Button = ({ primary, backgroundColor, size, label, ...props }) => {
9 | const mode = primary ? 'storybook-button--primary' : 'storybook-button--secondary';
10 | return (
11 |
17 | {label}
18 |
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 |
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 |
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 | You need to enable JavaScript to run this app.
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 |
17 |
18 | ### Backend
19 |
20 |
21 |
22 | ### Testing
23 |
24 |
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 |
90 |
91 |
92 |
93 |
94 |
95 |
96 | >
97 | );
98 | };
99 |
100 | export default NewBlog;
101 |
--------------------------------------------------------------------------------
/back/tests/helper.test.js:
--------------------------------------------------------------------------------
1 | const listHelper = require('../utils/list_helper')
2 |
3 | const listWithOneBlog = [
4 | {
5 | _id: '5a422aa71b54a676234d17f8',
6 | title: 'Go To Statement Considered Harmful',
7 | author: 'Edsger W. Dijkstra',
8 | url: 'http://www.u.arizona.edu/~rubinson/copyright_violations/Go_To_Considered_Harmful.html',
9 | likes: 5,
10 | __v: 0
11 | }
12 | ]
13 | const blogs = [
14 | {
15 | _id: "5a422a851b54a676234d17f7",
16 | title: "React patterns",
17 | author: "Michael Chan",
18 | url: "https://reactpatterns.com/",
19 | likes: 7,
20 | __v: 0
21 | },
22 | {
23 | _id: "5a422aa71b54a676234d17f8",
24 | title: "Go To Statement Considered Harmful",
25 | author: "Edsger W. Dijkstra",
26 | url: "http://www.u.arizona.edu/~rubinson/copyright_violations/Go_To_Considered_Harmful.html",
27 | likes: 5,
28 | __v: 0
29 | },
30 | {
31 | _id: "5a422b3a1b54a676234d17f9",
32 | title: "Canonical string reduction",
33 | author: "Edsger W. Dijkstra",
34 | url: "http://www.cs.utexas.edu/~EWD/transcriptions/EWD08xx/EWD808.html",
35 | likes: 12,
36 | __v: 0
37 | },
38 | {
39 | _id: "5a422b891b54a676234d17fa",
40 | title: "First class tests",
41 | author: "Robert C. Martin",
42 | url: "http://blog.cleancoder.com/uncle-bob/2017/05/05/TestDefinitions.htmll",
43 | likes: 10,
44 | __v: 0
45 | },
46 | {
47 | _id: "5a422ba71b54a676234d17fb",
48 | title: "TDD harms architecture",
49 | author: "Robert C. Martin",
50 | url: "http://blog.cleancoder.com/uncle-bob/2017/03/03/TDD-Harms-Architecture.html",
51 | likes: 0,
52 | __v: 0
53 | },
54 | {
55 | _id: "5a422bc61b54a676234d17fc",
56 | title: "Type wars",
57 | author: "Robert C. Martin",
58 | url: "http://blog.cleancoder.com/uncle-bob/2016/05/01/TypeWars.html",
59 | likes: 2,
60 | __v: 0
61 | }
62 | ]
63 | const blogWithZeroItems = []
64 |
65 |
66 | test('dummy returns one', () => {
67 |
68 | const result = listHelper.dummy(blogs)
69 | expect(result).toBe(1)
70 | })
71 |
72 |
73 | describe('total likes', () => {
74 |
75 |
76 | test ('of empty list is zero', () => {
77 | const result = listHelper.totalLikes(blogWithZeroItems)
78 | expect(result).toBe(0)
79 | })
80 | test('when list has only one blog, equals the likes of that', () => {
81 | const result = listHelper.totalLikes(listWithOneBlog)
82 | expect(result).toBe(5)
83 | })
84 |
85 | test('of a bigger list that is calculated right', () => {
86 | const result = listHelper.totalLikes(blogs)
87 | expect(result).toBe(36)
88 |
89 | })
90 |
91 | })
92 |
93 | describe('favourite blog', () => {
94 | test ('of empty list is zero', () => {
95 | const result = listHelper.favouriteBlog(blogWithZeroItems)
96 | expect(result).toEqual({})
97 | })
98 | test('when list has only one blog, equals the likes of that', () => {
99 | const result = listHelper.favouriteBlog(listWithOneBlog)
100 | expect(result).toEqual({
101 | title: "Go To Statement Considered Harmful",
102 | author: "Edsger W. Dijkstra",
103 | likes: 5
104 | })
105 | })
106 | test ('of a bigger list', () => {
107 | const result = listHelper.favouriteBlog(blogs)
108 | expect(result).toEqual({
109 | title:"Canonical string reduction",
110 | author:"Edsger W. Dijkstra",
111 | likes:12
112 | })
113 | })
114 | })
--------------------------------------------------------------------------------
/front/src/App.js:
--------------------------------------------------------------------------------
1 | import { useState, useEffect } from "react";
2 | import Notif from "./components/Notif";
3 | import SignIn from "./components/LoginForm";
4 | import { useSelector, useDispatch } from "react-redux";
5 | import { initializeBlogs } from "./reducers/blogReducer";
6 | import { initializeUsers, setUser } from "./reducers/userReducer";
7 | import BlogList from "./components/BlogList";
8 | import {
9 | BrowserRouter as Router,
10 | Routes,
11 | Route,
12 | useMatch,
13 | } from "react-router-dom";
14 | import NewBlog from "./components/NewBlog";
15 | import NavigationBar from "./components/NavigationBar";
16 | import { Navigate } from "react-router-dom";
17 | import { initializeAllUsers } from "./reducers/allUsersReducer";
18 | import BlogView from "./components/BlogView";
19 | import UserView from "./components/UserView";
20 | import ExampleBlog from "./components/ExampleBlog";
21 | import RegisterUser from "./components/RegisterUser";
22 | import About from "./components/About";
23 | import ErrorPage from "./components/ErrorPage";
24 | import BlogEdit from "./components/BlogEdit";
25 |
26 | const App = () => {
27 | const dispatch = useDispatch();
28 |
29 | const user = useSelector((state) => state.users);
30 | const blogs = useSelector((state) => state.blogs);
31 | const allUsers = useSelector((state) => state.allUsers);
32 | const [theme, setTheme] = useState(
33 | localStorage.getItem("color-theme")
34 | ? JSON.parse(localStorage.getItem("color-theme"))
35 | : true
36 | );
37 |
38 | useEffect(() => {
39 | dispatch(initializeBlogs());
40 | }, [dispatch]);
41 |
42 | useEffect(() => {
43 | dispatch(initializeUsers());
44 | }, [dispatch]);
45 |
46 | useEffect(() => {
47 | dispatch(initializeAllUsers());
48 | }, [dispatch]);
49 |
50 | const match = useMatch("/posts/:id");
51 | const blog = match ? blogs.find((blog) => blog.id === match.params.id) : null;
52 | const match2 = useMatch("/posts/edit/:id");
53 | const blog1 = match2
54 | ? blogs.find((blog) => blog.id === match2.params.id)
55 | : null;
56 |
57 | const match1 = useMatch("/users/:id");
58 | const userInView = match1
59 | ? allUsers.find((user) => user.username === match1.params.id)
60 | : null;
61 |
62 | const handleThemeSwitch = (event) => {
63 | event.preventDefault();
64 | setTheme(!theme);
65 | localStorage.setItem("color-theme", JSON.stringify(!theme));
66 | };
67 |
68 | return (
69 |
70 |
71 |
72 |
77 |
78 |
79 | } />
80 | }
83 | />
84 | : }
87 | />
88 | } />
89 | }
92 | />
93 | } />
94 | } />
95 | } />
96 | } />
97 | } />
98 |
99 |
100 |
101 |
102 |
103 | );
104 | };
105 |
106 | export default App;
107 |
--------------------------------------------------------------------------------
/back/tests/blog_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 Blog = require('../models/blog')
6 |
7 |
8 | const api = supertest(app)
9 |
10 |
11 |
12 | const newBlogObject2 = [
13 | {
14 | title: "TDD harms architecture",
15 | author: "Robert C. Martin",
16 | url: "http://blog.cleancoder.com/uncle-bob/2017/03/03/TDD-Harms-Architecture.html",
17 | }
18 | ]
19 | const newBlogObject3 = [
20 | {
21 | author: "Robert C. Martin",
22 | likes: 100
23 | }
24 | ]
25 |
26 |
27 |
28 | beforeEach(async () => {
29 | await Blog.deleteMany({})
30 | let blogObject = new Blog(helper.initialBlogs[0])
31 | await blogObject.save()
32 | blogObject = new Blog(helper.initialBlogs[1])
33 | await blogObject.save()
34 | blogObject = new Blog(helper.initialBlogs[2])
35 | await blogObject.save()
36 | })
37 |
38 | // GET Test, check for 3 blogs
39 | test('test1', async () => {
40 | const response = await api.get('/api/blogs')
41 | expect(response.body).toHaveLength(3)
42 | })
43 |
44 | // Check if each blog has ID property
45 | test ('test2', async () => {
46 | const blogs = await Blog.find({})
47 | expect(blogs[0].id).toBeDefined
48 | })
49 |
50 |
51 | // Check if POST works
52 | test ('test3', async () => {
53 | const test3Obj = [
54 | {
55 | title: "TDD harms architecture",
56 | author: "Robert C. Martin",
57 | url: "http://blog.cleancoder.com/uncle-bob/2017/03/03/TDD-Harms-Architecture.html",
58 | likes:7
59 | }
60 | ]
61 |
62 | await api
63 | .post('/api/blogs')
64 | .send(test3Obj)
65 | .expect(201)
66 | .expect('Content-Type', /application\/json/)
67 |
68 | const response = await api.get('/api/blogs')
69 | expect(response.body).toHaveLength(helper.initialBlogs.length + 1)
70 | })
71 |
72 | // Check if likes defaults to 0 if likes isn't defined
73 | test ('test4', async () => {
74 |
75 | await api
76 | .post('/api/blogs')
77 | .send(newBlogObject2)
78 | .expect(201)
79 | .expect('Content-Type', /application\/json/)
80 |
81 | const blogs = await Blog.find({})
82 | expect(blogs[3].likes).toBe(0)
83 | })
84 |
85 | // Checks if there's 400 error when url or title aren't defined
86 | test ('test5', async () => {
87 |
88 |
89 | await api
90 | .post('/api/blogs')
91 | .send(newBlogObject3)
92 | .expect(400)
93 |
94 | const response = await api.get('/api/blogs')
95 | expect(response.body).toHaveLength(helper.initialBlogs.length)
96 | })
97 |
98 | // Check DELETE
99 | test('test6', async () => {
100 | const blogsAtStart = await Blog.find({})
101 | const blogToDelete = blogsAtStart[0]
102 |
103 | await api
104 | .delete(`/api/blogs/${blogToDelete.id}`)
105 | .expect(204)
106 |
107 | const blogsAtEnd = await Blog.find({})
108 |
109 | expect(blogsAtEnd).toHaveLength(
110 | helper.initialBlogs.length - 1
111 | )
112 |
113 |
114 | const contents = blogsAtEnd.map(r => r.title)
115 |
116 | expect(contents).not.toContain(blogToDelete.title)
117 | })
118 |
119 |
120 | // Check PUT
121 | test('test7', async () => {
122 | const newBlogObject4 = [{
123 | title: "LOL",
124 | author: "Michael Chan",
125 | url: "https://reactpatterns.com/",
126 | likes:100,
127 |
128 | }]
129 | const blogsAtStart = await Blog.find({})
130 | const blogToUpdate = blogsAtStart[0]
131 |
132 | await api
133 | .put(`/api/blogs/${blogToUpdate.id}`)
134 | .send(newBlogObject4)
135 | .expect(200)
136 | .expect('Content-Type', /application\/json/)
137 |
138 | const blogsAtEnd = await Blog.find({})
139 |
140 | expect(blogsAtEnd[0].title).toBe('LOL')
141 | expect(blogsAtEnd[0].likes).toBe(100)
142 |
143 | })
144 |
145 | afterAll(() => {
146 | mongoose.connection.close()
147 | })
148 |
--------------------------------------------------------------------------------
/front/src/components/BlogEdit.js:
--------------------------------------------------------------------------------
1 | import { useState } from "react";
2 | import { updateBlog } 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, Spinner } from "flowbite-react";
7 | import BlogFooter from "./BlogFooter";
8 |
9 | const BlogEdit = ({ blog }) => {
10 | const dispatch = useDispatch();
11 | const [newTitle, setNewTitle] = useState("");
12 | const [newContent, setNewContent] = useState("");
13 |
14 | const navigate = useNavigate();
15 | if (blog === undefined) {
16 | return ;
17 | }
18 | if (blog && newTitle === "") {
19 | setNewTitle(blog.title);
20 | setNewContent(blog.content);
21 | }
22 |
23 | const editBlog = (event) => {
24 | event.preventDefault();
25 | const blogObject = {
26 | ...blog,
27 | title: newTitle,
28 | content: newContent,
29 | dateCreated: new Date(),
30 | };
31 | editNewBlog(blogObject);
32 | setNewContent("");
33 | setNewTitle("");
34 | };
35 |
36 | const editNewBlog = async (blogObject) => {
37 | try {
38 | const notif1 = {
39 | message: `Post was successfully edited`,
40 | type: "success",
41 | };
42 | await dispatch(updateBlog(blogObject));
43 | navigate(`/posts/${blog.id}`);
44 |
45 | dispatch(setNotification(notif1, 2500));
46 | } catch (exception) {
47 | const notif2 = {
48 | message: `Cannot edit post`,
49 | type: "failure",
50 | };
51 | dispatch(setNotification(notif2, 2500));
52 | }
53 | };
54 |
55 | return (
56 | <>
57 |
58 |
59 |
60 |
61 |
62 |
63 | Edit Post
64 |
65 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
setNewTitle(target.value)}
79 | />
80 |
81 |
82 |
83 |
84 |
85 |
setNewContent(target.value)}
90 | rows={10}
91 | />
92 |
93 |
94 |
95 | Submit
96 |
97 |
98 |
99 |
100 |
101 |
102 |
103 |
104 | >
105 | );
106 | };
107 |
108 | export default BlogEdit;
109 |
--------------------------------------------------------------------------------
/front/src/components/NavigationBar.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import { useState } from "react";
3 | import {
4 | Navbar,
5 | Dropdown,
6 | Avatar,
7 | Button,
8 | Modal,
9 | Label,
10 | Textarea,
11 | TextInput,
12 | Checkbox,
13 | } from "flowbite-react";
14 | import { useDispatch } from "react-redux";
15 | import { setUser } from "../reducers/userReducer";
16 | import { Add } from "@mui/icons-material";
17 | import { useNavigate } from "react-router-dom";
18 | import ForumIcon from "@mui/icons-material/Forum";
19 | import { DarkThemeToggle } from "flowbite-react";
20 | import { DarkMode } from "@mui/icons-material";
21 | import { LightMode } from "@mui/icons-material";
22 |
23 | const NavigationBar = ({ user, handleThemeSwitch, theme }) => {
24 | const dispatch = useDispatch();
25 | const navigate = useNavigate();
26 | const logout = (event) => {
27 | event.preventDefault();
28 | window.localStorage.removeItem("AKAppSessionID");
29 | dispatch(setUser(null));
30 | navigate("/");
31 | };
32 |
33 | return (
34 |
38 |
39 |
40 |
41 | Forum App
42 |
43 |
44 |
45 |
46 | About this App
47 |
48 | {user === null && Create Post }
49 | {user && Create Post }
50 |
51 | {user === null && (
52 |
53 | Log In
54 |
55 | )}
56 | {user && (
57 |
71 | )}
72 |
73 |
74 |
81 | {theme ? (
82 |
90 |
95 |
96 | ) : (
97 |
105 |
110 |
111 | )}
112 |
113 |
114 |
115 |
116 |
117 | );
118 | };
119 |
120 | export default NavigationBar;
121 |
--------------------------------------------------------------------------------
/back/controllers/blogs.js:
--------------------------------------------------------------------------------
1 | const blogRouter = require("express").Router();
2 | const Blog = require("../models/blog");
3 | const User = require("../models/user");
4 | const jwt = require("jsonwebtoken");
5 | const blog = require("../models/blog");
6 |
7 | blogRouter.get("/", async (request, response) => {
8 | const blogs = await Blog.find({}).populate("user", { username: 1, name: 1 });
9 | response.json(blogs);
10 | });
11 |
12 | blogRouter.get("/:id", async (request, response) => {
13 | const blog = await Blog.findById(request.params.id);
14 | if (blog) {
15 | response.json(blog.toJSON());
16 | } else {
17 | response.status(404).end();
18 | }
19 | });
20 | const getTokenFrom = (request) => {
21 | const authorization = request.get("authorization");
22 | if (authorization && authorization.toLowerCase().startsWith("bearer ")) {
23 | return authorization.substring(7);
24 | }
25 | return null;
26 | };
27 | blogRouter.post("/", async (request, response, next) => {
28 | const body = request.body;
29 | const token = getTokenFrom(request);
30 | const decodedToken = jwt.verify(token, process.env.SECRET);
31 | if (!decodedToken.id) {
32 | return response.status(401).json({ error: "token missing or invalid" });
33 | }
34 | if (!body.likes) {
35 | body.likes = 0;
36 | }
37 | if (!body.comments) {
38 | body.comments = [];
39 | }
40 | if (!body.title) {
41 | return response.status(400).json({
42 | error: "title is required",
43 | });
44 | }
45 |
46 | const user = await User.findById(decodedToken.id);
47 |
48 | const blog = new Blog({
49 | title: body.title,
50 | content: body.content,
51 | dateCreated: body.dateCreated,
52 | likes: body.likes,
53 | comments: body.comments,
54 | user: user._id,
55 | });
56 | try {
57 | const savedBlog = await blog.save();
58 | user.blogs = user.blogs.concat(savedBlog._id);
59 | await user.save();
60 | response.status(201).json(savedBlog);
61 | } catch (exception) {
62 | next(exception);
63 | }
64 | });
65 |
66 | blogRouter.delete("/:id", async (request, response, next) => {
67 | const token = getTokenFrom(request);
68 | const decodedToken = jwt.verify(token, process.env.SECRET);
69 | if (!decodedToken.id) {
70 | return response.status(401).json({ error: "token missing or invalid" });
71 | }
72 |
73 | const user = await User.findById(decodedToken.id);
74 | const blogToDelete = await Blog.findById(request.params.id);
75 |
76 | if (user._id.toString() != blogToDelete.user._id.toString()) {
77 | return response.status(401).json({ error: `Unauthorized` });
78 | }
79 |
80 | try {
81 | await Blog.findByIdAndDelete(request.params.id);
82 | response.status(204).end();
83 | } catch (error) {
84 | next(error);
85 | }
86 | });
87 |
88 | blogRouter.put("/:id", async (request, response, next) => {
89 | const body = request.body;
90 | const token = getTokenFrom(request);
91 | const decodedToken = jwt.verify(token, process.env.SECRET);
92 | if (!decodedToken.id) {
93 | return response.status(401).json({ error: "token missing or invalid" });
94 | }
95 | if (!body.likes) {
96 | body.likes = 0;
97 | }
98 | if (!body.title) {
99 | return response.status(400).json({
100 | error: "title is required",
101 | });
102 | }
103 |
104 | const blog = {
105 | title: body.title,
106 | content: body.content,
107 | dateCreated: body.dateCreated,
108 | likes: body.likes,
109 | comments: body.comments,
110 | };
111 | try {
112 | const updatedBlog = await Blog.findByIdAndUpdate(request.params.id, blog, {
113 | new: true,
114 | });
115 | response.json(updatedBlog.toJSON());
116 | } catch (exception) {
117 | next(exception);
118 | }
119 | });
120 |
121 | blogRouter.post("/:id/comments", async (request, response) => {
122 | const body = request.body;
123 | const blog = await Blog.findById(request.params.id).populate("user", {
124 | username: 1,
125 | name: 1,
126 | });
127 | const token = getTokenFrom(request);
128 | const decodedToken = jwt.verify(token, process.env.SECRET);
129 | if (!decodedToken.id) {
130 | return response.status(401).json({ error: "token missing or invalid" });
131 | }
132 |
133 | const user = await User.findById(decodedToken.id);
134 | const comment = {
135 | content: body.content,
136 | user: user._id,
137 | };
138 |
139 | blog.comments = blog.comments.concat(comment);
140 |
141 | const updatedBlog = await blog.save();
142 |
143 | updatedBlog
144 | ? response.status(200).json(updatedBlog.toJSON())
145 | : response.status(404).end();
146 | });
147 |
148 | module.exports = blogRouter;
149 |
--------------------------------------------------------------------------------
/front/src/components/RegisterUser.js:
--------------------------------------------------------------------------------
1 | import { useState } from "react";
2 | import { useDispatch } from "react-redux";
3 | import { useSelector } from "react-redux";
4 | import { registerUser } from "../reducers/allUsersReducer";
5 | import { useNavigate } from "react-router-dom";
6 | import { setNotification } from "../reducers/notificationReducer";
7 | import BlogFooter from "./BlogFooter";
8 | import Forum from "@mui/icons-material/Forum";
9 |
10 | const RegisterUser = () => {
11 | const dispatch = useDispatch();
12 | const [username, setUsername] = useState("");
13 | const [password, setPassword] = useState("");
14 |
15 | const navigate = useNavigate();
16 |
17 | const allUsers = useSelector((state) => state.allUsers);
18 | const allUsernames = allUsers.map((user) => user.username);
19 |
20 | const addUser = async (event) => {
21 | event.preventDefault();
22 | const newUser = {
23 | username: username,
24 | password: password,
25 | };
26 | try {
27 | await dispatch(registerUser(newUser));
28 | if (allUsernames.find((user1) => user1 === username)) {
29 | setUsername("");
30 | setPassword("");
31 | const notif = {
32 | message: `Username already taken. Try another.`,
33 | type: "success",
34 | };
35 | dispatch(setNotification(notif, 4000));
36 | }
37 | const notif1 = {
38 | message: `Account registered. You may login now.`,
39 | type: "success",
40 | };
41 | setUsername("");
42 | setPassword("");
43 | dispatch(setNotification(notif1, 4000));
44 | navigate("/login");
45 | } catch (error) {
46 | const notif2 = {
47 | message: `Unable to add account due to server issues. Try again later!`,
48 | type: "failure",
49 | };
50 | dispatch(setNotification(notif2, 4000));
51 | }
52 | };
53 |
54 | return (
55 | <>
56 |
57 |
58 |
62 |
63 | Forum App
64 |
65 |
66 |
67 |
68 | Create an account
69 |
70 |
71 |
72 |
76 | Your username
77 |
78 | setUsername(target.value)}
83 | id="username"
84 | class="bg-gray-50 border border-gray-300 text-gray-900 sm:text-sm rounded-lg focus:ring-primary-600 focus:border-primary-600 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
85 | placeholder=""
86 | required={true}
87 | />
88 |
89 |
90 |
94 | Password
95 |
96 | setPassword(target.value)}
101 | id="password"
102 | placeholder="••••••••"
103 | class="bg-gray-50 border border-gray-300 text-gray-900 sm:text-sm rounded-lg focus:ring-primary-600 focus:border-primary-600 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
104 | required={true}
105 | />
106 |
107 |
111 | Create an account
112 |
113 |
114 | Already have an account?{" "}
115 |
119 | Login here
120 |
121 |
122 |
123 |
124 |
125 |
126 |
127 |
128 | >
129 | );
130 | };
131 |
132 | export default RegisterUser;
133 |
--------------------------------------------------------------------------------
/front/src/stories/Introduction.stories.mdx:
--------------------------------------------------------------------------------
1 | import { Meta } from '@storybook/addon-docs';
2 | import Code from './assets/code-brackets.svg';
3 | import Colors from './assets/colors.svg';
4 | import Comments from './assets/comments.svg';
5 | import Direction from './assets/direction.svg';
6 | import Flow from './assets/flow.svg';
7 | import Plugin from './assets/plugin.svg';
8 | import Repo from './assets/repo.svg';
9 | import StackAlt from './assets/stackalt.svg';
10 |
11 |
12 |
13 |
116 |
117 | # Welcome to Storybook
118 |
119 | Storybook helps you build UI components in isolation from your app's business logic, data, and context.
120 | That makes it easy to develop hard-to-reach states. Save these UI states as **stories** to revisit during development, testing, or QA.
121 |
122 | Browse example stories now by navigating to them in the sidebar.
123 | View their code in the `stories` directory to learn how they work.
124 | We recommend building UIs with a [**component-driven**](https://componentdriven.org) process starting with atomic components and ending with pages.
125 |
126 | Configure
127 |
128 |
174 |
175 | Learn
176 |
177 |
207 |
208 |
209 | Tip Edit the Markdown in{' '}
210 | stories/Introduction.stories.mdx
211 |
212 |
--------------------------------------------------------------------------------
/front/src/components/LoginForm.js:
--------------------------------------------------------------------------------
1 | import * as React from "react";
2 | import loginService from "../services/login";
3 | import blogService from "../services/blogs";
4 | import { useDispatch } from "react-redux";
5 | import { setUser } from "../reducers/userReducer";
6 | import { useState } from "react";
7 | import { setNotification } from "../reducers/notificationReducer";
8 | import BlogFooter from "./BlogFooter";
9 | import { useNavigate } from "react-router-dom";
10 | import Forum from "@mui/icons-material/Forum";
11 |
12 | const SignIn = () => {
13 | const [username, setUsername] = useState();
14 | const [password, setPassword] = useState();
15 | const dispatch = useDispatch();
16 | const navigate = useNavigate();
17 |
18 | const handleLogin = async (event) => {
19 | event.preventDefault();
20 | try {
21 | const user = await loginService.login({ username, password });
22 | window.localStorage.setItem("AKAppSessionID", JSON.stringify(user));
23 | blogService.setToken(user.token);
24 | dispatch(setUser(user));
25 | navigate("/posts");
26 | } catch (exception) {
27 | const notif = {
28 | message: "Wrong credentials",
29 | type: "error",
30 | };
31 | dispatch(setNotification(notif, 2500));
32 | }
33 | };
34 |
35 | return (
36 | <>
37 |
38 |
39 |
43 |
44 | Forum App
45 |
46 |
47 |
48 |
49 | Sign in to your account
50 |
51 |
56 |
57 |
61 | Your username
62 |
63 | setUsername(target.value)}
68 | className="bg-gray-50 border border-gray-300 text-gray-900 sm:text-sm rounded-lg focus:ring-primary-600 focus:border-primary-600 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
69 | required=""
70 | >
71 |
72 |
73 |
77 | Password
78 |
79 | setPassword(target.value)}
84 | placeholder="••••••••"
85 | className="bg-gray-50 border border-gray-300 text-gray-900 sm:text-sm rounded-lg focus:ring-primary-600 focus:border-primary-600 block w-full p-2.5 dark:bg-gray-700 dark:border-gray-600 dark:placeholder-gray-400 dark:text-white dark:focus:ring-blue-500 dark:focus:border-blue-500"
86 | required=""
87 | >
88 |
89 |
116 |
120 | Sign in
121 |
122 |
123 | Don’t have an account yet?{" "}
124 |
128 | Sign up
129 |
130 |
131 |
132 |
133 |
134 |
135 |
136 |
137 | >
138 | );
139 | };
140 |
141 | export default SignIn;
142 |
--------------------------------------------------------------------------------
/front/src/stories/assets/colors.svg:
--------------------------------------------------------------------------------
1 | illustration/colors
--------------------------------------------------------------------------------
/front/src/components/About.js:
--------------------------------------------------------------------------------
1 | import BlogFooter from "./BlogFooter";
2 | const About = () => {
3 | return (
4 |
5 |
6 |
7 |
8 |
41 |
42 |
43 |
44 |
45 | About this Website
46 |
47 |
48 | Hi! I'm Arul and I made this site in December 2022, as part
49 | of my project for a full stack development course - Full
50 | Stack Open 2022. This is actually my first foray in
51 | JavaScript based full-stack development. Through building
52 | this website, I have learnt several things, from using
53 | Tailwind CSS to build a proper user experience to developing
54 | proper REST APIs using MongoDB, Express and Mongoose.
55 |
56 |
57 |
62 |
68 |
69 | {" "}
70 | View my Github
71 |
77 |
82 |
83 |
84 |
85 |
86 |
87 |
88 |
89 |
90 |
91 | Technologies Used
92 |
93 |
94 |
{" "}
100 |
{" "}
106 |
{" "}
112 |
118 |
124 |
{" "}
130 |
136 |
{" "}
142 |
148 |
149 |
150 |
151 |
152 |
153 |
154 |
155 |
156 |
157 |
158 | );
159 | };
160 |
161 | export default About;
162 |
--------------------------------------------------------------------------------
/front/src/components/BlogView.js:
--------------------------------------------------------------------------------
1 | import { Spinner, Footer, Button } from "flowbite-react";
2 | import { useSelector, useDispatch } from "react-redux";
3 | import FavoriteIcon from "@mui/icons-material/Favorite";
4 | import EditIcon from "@mui/icons-material/Edit";
5 | import DeleteIcon from "@mui/icons-material/Delete";
6 | import { setNotification } from "../reducers/notificationReducer";
7 | import { updateBlog, deleteBlog, commentBlog } from "../reducers/blogReducer";
8 | import { useState } from "react";
9 | import { useNavigate } from "react-router-dom";
10 | import BlogFooter from "./BlogFooter";
11 | import Comment from "./Comment";
12 |
13 | const BlogView = ({ blog }) => {
14 | console.log(blog);
15 | const user = useSelector((state) => state.users);
16 | const allUsers = useSelector((state) => state.allUsers);
17 | const [newComment, setNewComment] = useState("");
18 | const dispatch = useDispatch();
19 | const navigate = useNavigate();
20 | const blogs = useSelector((state) => state.blogs);
21 | if (blog === undefined) {
22 | return ;
23 | }
24 |
25 | const comments = blog.comments ? blog.comments : [];
26 | const user1 = allUsers.find((user) => user.id === blog.user);
27 |
28 | const handleUpdateBlog = async (blogObject) => {
29 | if (!user) {
30 | navigate("/login");
31 | }
32 | try {
33 | const updatedBlog = {
34 | ...blogObject,
35 | likes: blog.likes + 1,
36 | };
37 | await dispatch(updateBlog(updatedBlog));
38 | } catch (error) {
39 | const notif = {
40 | message: error.response.data.error,
41 | type: "error",
42 | };
43 | dispatch(setNotification(notif, 2500));
44 | }
45 | };
46 | const handleDeleteBlog = async (id) => {
47 | const blog1 = blogs.filter((b) => b.id === id);
48 | if (!user) {
49 | navigate("/login");
50 | }
51 | if (window.confirm(`Do you want to delete this post?`)) {
52 | try {
53 | await dispatch(deleteBlog(id));
54 | const notif = {
55 | message: "Successfully deleted post",
56 | type: "success",
57 | };
58 | dispatch(setNotification(notif, 2500));
59 | navigate("/");
60 | } catch (error) {
61 | const notif = {
62 | message: error.message,
63 | type: "error",
64 | };
65 | dispatch(setNotification(notif, 2500));
66 | }
67 | }
68 | };
69 |
70 | const commentFormSubmit = (event) => {
71 | event.preventDefault();
72 | setNewComment("");
73 | handleComment(newComment, blog.id);
74 | };
75 |
76 | const handleComment = async (comment, id) => {
77 | if (!user) {
78 | navigate("/login");
79 | }
80 | try {
81 | await dispatch(commentBlog(comment, id));
82 | const notif1 = {
83 | message: "Comment added successfully",
84 | type: "success",
85 | };
86 | dispatch(setNotification(notif1, 2500));
87 | } catch (error) {
88 | const notif2 = {
89 | message: error.message,
90 | type: "failure",
91 | };
92 | dispatch(setNotification(notif2, 2500));
93 | }
94 | };
95 |
96 | return (
97 |
98 |
99 |
100 |
101 |
102 |
103 | {blog.title}
104 |
105 |
106 |
107 |
108 |
116 | u/
117 | {blog.user.username || user1.username}
118 |
119 |
120 | Posted on{" "}
121 | {new Date(blog.dateCreated).toLocaleDateString("en-GB")}
122 |
123 |
124 | {blog.likes} {blog.likes === 1 ? "like" : "likes"}
125 |
{" "}
126 |
127 | {comments.length}{" "}
128 | {comments.length === 1 ? "comment" : "comments"}
129 |
130 |
131 | handleUpdateBlog(blog)}>
132 |
133 |
134 | {user &&
135 | (user.id === blog.user.id || user.id === blog.user) ? (
136 | <>
137 |
141 |
142 |
143 | handleDeleteBlog(blog.id)}
145 | color="failure"
146 | >
147 |
148 |
149 | >
150 | ) : null}
151 |
152 |
153 |
154 |
155 |
156 |
160 | {blog.content}
161 |
162 |
163 |
164 |
165 |
166 | Discussion
167 |
168 |
169 |
170 |
171 |
172 | Your comment
173 |
174 |
183 |
184 |
188 | Post comment
189 |
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":""}
--------------------------------------------------------------------------------