├── .gitignore ├── client ├── src │ ├── pages │ │ ├── OauthGoogle │ │ │ ├── OauthGoogle.scss │ │ │ └── OauthGoogle.jsx │ │ ├── PageNotFound │ │ │ ├── PageNotFound.scss │ │ │ └── PageNotFound.jsx │ │ ├── PostEdit │ │ │ ├── PostEdit.scss │ │ │ └── PostEdit.jsx │ │ ├── PostWrite │ │ │ ├── PostWrite.scss │ │ │ └── PostWrite.jsx │ │ ├── Profile │ │ │ ├── Profile.scss │ │ │ └── Profile.jsx │ │ ├── Home │ │ │ ├── Home.scss │ │ │ └── Home.jsx │ │ ├── PostChanges │ │ │ ├── PostChanges.scss │ │ │ └── PostChanges.jsx │ │ ├── Login │ │ │ └── Login.jsx │ │ ├── Post │ │ │ ├── Post.scss │ │ │ └── Post.jsx │ │ └── Register │ │ │ └── Register.jsx │ ├── categories.js │ ├── App.scss │ ├── components │ │ ├── Loader │ │ │ ├── Loader.jsx │ │ │ └── Loader.scss │ │ ├── Tabs │ │ │ ├── Tabs.jsx │ │ │ └── Tabs.scss │ │ ├── OauthButtonGoogle │ │ │ ├── OauthButtonGoogle.scss │ │ │ └── OauthButtonGoogle.jsx │ │ ├── Comment │ │ │ ├── Comment.scss │ │ │ └── Comment.jsx │ │ ├── PostPreview │ │ │ ├── PostPreview.scss │ │ │ └── PostPreview.jsx │ │ ├── Navbar │ │ │ ├── Navbar.jsx │ │ │ └── Navbar.scss │ │ └── PostForm │ │ │ ├── PostForm.scss │ │ │ └── PostForm.jsx │ ├── index.jsx │ ├── hooks │ │ └── useAxiosGet.jsx │ ├── contexts │ │ └── auth.jsx │ ├── App.jsx │ └── index.scss ├── dummy.env ├── .gitignore ├── scripts │ └── component.sh ├── vite.config.js ├── index.html ├── package.json └── yarn.lock ├── server ├── .gitignore ├── resources │ └── blank-avatar.png ├── src │ ├── types │ │ ├── like │ │ │ └── index.ts │ │ ├── comment │ │ │ └── index.ts │ │ ├── express.d.ts │ │ ├── post-changes │ │ │ └── index.ts │ │ ├── user │ │ │ └── index.ts │ │ ├── post │ │ │ └── index.ts │ │ └── dotenv.d.ts │ ├── routes │ │ ├── oauth │ │ │ ├── index.ts │ │ │ └── google.ts │ │ ├── index.ts │ │ ├── users.ts │ │ ├── likes.ts │ │ ├── comments.ts │ │ ├── auth.ts │ │ ├── post-changes.ts │ │ └── posts.ts │ ├── util │ │ ├── number.ts │ │ ├── upload.ts │ │ ├── database.ts │ │ ├── error.ts │ │ ├── translate.ts │ │ └── process-data.ts │ ├── index.ts │ ├── middlewares │ │ └── checkAuth.ts │ └── models │ │ ├── like.ts │ │ ├── comment.ts │ │ ├── user.ts │ │ ├── post-changes.ts │ │ └── post.ts ├── dummy.env ├── package.json ├── tsconfig.json └── db.sql ├── ss.png ├── dummy.env ├── package.json ├── README.md ├── scripts └── setup.js └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | node_modules -------------------------------------------------------------------------------- /client/src/pages/OauthGoogle/OauthGoogle.scss: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /server/.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | node_modules 3 | uploads 4 | biraj.js 5 | dist -------------------------------------------------------------------------------- /ss.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/biraj21/writers-avenue/HEAD/ss.png -------------------------------------------------------------------------------- /dummy.env: -------------------------------------------------------------------------------- 1 | DB_HOST=127.0.0.1 2 | DB_USER= 3 | DB_PASSWORD= -------------------------------------------------------------------------------- /server/resources/blank-avatar.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/biraj21/writers-avenue/HEAD/server/resources/blank-avatar.png -------------------------------------------------------------------------------- /server/src/types/like/index.ts: -------------------------------------------------------------------------------- 1 | export interface ILike { 2 | liked?: boolean; 3 | postId: number; 4 | userId: number; 5 | } 6 | -------------------------------------------------------------------------------- /client/src/categories.js: -------------------------------------------------------------------------------- 1 | const categories = ["art", "business", "cinema", "food", "science", "technology"]; 2 | export default categories; 3 | -------------------------------------------------------------------------------- /client/dummy.env: -------------------------------------------------------------------------------- 1 | VITE_SERVER_URL=http://127.0.0.1: (same as server's .env) 2 | VITE_GOOGLE_OAUTH_CLIENT_ID=<...> 3 | VITE_GOOGLE_OAUTH_REDIRECT=<...> -------------------------------------------------------------------------------- /server/src/types/comment/index.ts: -------------------------------------------------------------------------------- 1 | export interface IComment { 2 | readonly id?: number; 3 | body: string; 4 | postId: number; 5 | userId: number; 6 | date?: Date; 7 | } 8 | -------------------------------------------------------------------------------- /server/src/types/express.d.ts: -------------------------------------------------------------------------------- 1 | declare global { 2 | namespace Express { 3 | export interface Request { 4 | userId?: number; 5 | } 6 | } 7 | } 8 | 9 | export {}; 10 | -------------------------------------------------------------------------------- /client/src/App.scss: -------------------------------------------------------------------------------- 1 | .app { 2 | height: 100%; 3 | 4 | .content { 5 | height: calc(100% - 56px - 1rem); // 56px navbar's height & its margin-botton is 1rem 6 | overflow: auto; 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /server/src/routes/oauth/index.ts: -------------------------------------------------------------------------------- 1 | import express from "express"; 2 | import googleRouter from "./google.js"; 3 | 4 | // URL: /oauth/... 5 | 6 | const router = express.Router(); 7 | 8 | router.use("/google", googleRouter); 9 | 10 | export default router; 11 | -------------------------------------------------------------------------------- /client/src/pages/PageNotFound/PageNotFound.scss: -------------------------------------------------------------------------------- 1 | #not-found-page { 2 | display: flex; 3 | flex-direction: column; 4 | justify-content: center; 5 | align-items: center; 6 | text-align: center; 7 | 8 | .btn { 9 | margin-top: 1rem; 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /client/src/components/Loader/Loader.jsx: -------------------------------------------------------------------------------- 1 | import "./Loader.scss"; 2 | 3 | export default function Loader() { 4 | return ( 5 |
6 |
7 |
8 |
9 |
10 |
11 | ); 12 | } 13 | -------------------------------------------------------------------------------- /server/src/types/post-changes/index.ts: -------------------------------------------------------------------------------- 1 | import { PostCategory } from "../post/index.js"; 2 | 3 | export interface IPostChanges { 4 | title?: string; 5 | body?: string; 6 | coverPath?: string; 7 | category?: PostCategory; 8 | postId: number; 9 | userId: number; 10 | } 11 | -------------------------------------------------------------------------------- /server/src/types/user/index.ts: -------------------------------------------------------------------------------- 1 | export type UserAuthMethod = "email" | "google"; 2 | 3 | export interface IUser { 4 | readonly id?: number; 5 | name: string; 6 | email: string; 7 | password?: string; 8 | avatarPath: string; 9 | authMethod?: UserAuthMethod; 10 | } 11 | -------------------------------------------------------------------------------- /client/src/pages/PostEdit/PostEdit.scss: -------------------------------------------------------------------------------- 1 | #post-edit-page { 2 | max-width: 956px; // 916px width + 40px left & right padding 3 | 4 | h1 { 5 | margin-bottom: 1rem; 6 | text-align: center; 7 | } 8 | 9 | @media screen and (max-width: 768px) { 10 | max-width: 600px; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /client/src/pages/PostWrite/PostWrite.scss: -------------------------------------------------------------------------------- 1 | #post-write-page { 2 | max-width: 956px; // 916px width + 40px left & right padding 3 | 4 | h1 { 5 | margin-bottom: 1rem; 6 | text-align: center; 7 | } 8 | 9 | @media screen and (max-width: 768px) { 10 | max-width: 600px; 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /server/src/util/number.ts: -------------------------------------------------------------------------------- 1 | export function isInteger(x: unknown) { 2 | if (typeof x === "number" || typeof x === "string") { 3 | for (const c of x.toString()) { 4 | if (c < "0" || c > "9") { 5 | return false; 6 | } 7 | } 8 | 9 | return true; 10 | } 11 | 12 | return false; 13 | } 14 | -------------------------------------------------------------------------------- /client/src/pages/PostWrite/PostWrite.jsx: -------------------------------------------------------------------------------- 1 | import PostForm from "components/PostForm/PostForm"; 2 | import "./PostWrite.scss"; 3 | 4 | export default function PostWrite() { 5 | return ( 6 |
7 |

New Post

8 | 9 |
10 | ); 11 | } 12 | -------------------------------------------------------------------------------- /server/dummy.env: -------------------------------------------------------------------------------- 1 | PORT= 2 | 3 | SERVER_URL=http://127.0.0.1: 4 | 5 | DB_HOST=127.0.0.1 6 | DB_USER= 7 | DB_PASSWORD= 8 | DB_NAME=blogs 9 | 10 | JWT_SECRET= 11 | 12 | GOOGLE_OAUTH_CLIENT_ID=<...> 13 | GOOGLE_OAUTH_CLIENT_SECRET=<...> 14 | GOOGLE_OAUTH_REDIRECT=<...> -------------------------------------------------------------------------------- /client/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /client/src/pages/PageNotFound/PageNotFound.jsx: -------------------------------------------------------------------------------- 1 | import { Link } from "react-router-dom"; 2 | import "./PageNotFound.scss"; 3 | 4 | export default function PageNotFound() { 5 | return ( 6 |
7 |

We couldn't find the page that you are looking for.

8 | 9 | Homepage 10 | 11 |
12 | ); 13 | } 14 | -------------------------------------------------------------------------------- /server/src/types/post/index.ts: -------------------------------------------------------------------------------- 1 | export type PostCategory = "art" | "business" | "cinema" | "food" | "science" | "technology"; 2 | 3 | export type PostStatus = "pub" | "pvt" | "draft"; 4 | 5 | export interface IPost { 6 | readonly id: number; 7 | title: string; 8 | body?: string; 9 | coverPath?: string; 10 | category?: PostCategory; 11 | publishDate: Date; 12 | editDate?: Date; 13 | status?: PostStatus; 14 | userId: number; 15 | } 16 | -------------------------------------------------------------------------------- /client/scripts/component.sh: -------------------------------------------------------------------------------- 1 | if (("$#" < 1)); then 2 | echo "Error: At least 1 component is required" 3 | echo "Usage: component COMPONENT_NAME..." 4 | fi 5 | 6 | for arg in "$@"; do 7 | mkdir -p "./src/$arg" 8 | touch "./src/$arg/$arg.jsx" "./src/$arg/$arg.scss" 9 | 10 | template=$(cat << EOF 11 | import "./src/$arg.scss" 12 | 13 | export default function $arg() { 14 | return <>; 15 | } 16 | EOF 17 | ) 18 | 19 | echo "$template" > "src/$arg/$arg.jsx" 20 | done 21 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "writers-avenue", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "setup": "node scripts/setup.js", 8 | "dev": "(cd server && yarn dev) & (cd client && yarn dev)" 9 | }, 10 | "repository": "https://github.com/biraj21/writers-avenue.git", 11 | "author": "Biraj ", 12 | "license": "MIT", 13 | "dependencies": { 14 | "dotenv": "^16.0.3", 15 | "mariadb": "^3.0.2" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /client/src/pages/Profile/Profile.scss: -------------------------------------------------------------------------------- 1 | #profile-page { 2 | padding-top: 24px; 3 | 4 | .user { 5 | &__info { 6 | display: flex; 7 | flex-direction: column; 8 | align-items: center; 9 | } 10 | 11 | &__avatar { 12 | width: 140px; 13 | height: 140px; 14 | border-radius: 50%; 15 | margin-bottom: 1rem; 16 | } 17 | 18 | &__posts { 19 | margin-top: 10px; 20 | 21 | h3 { 22 | margin-bottom: 10px; 23 | } 24 | } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /server/src/types/dotenv.d.ts: -------------------------------------------------------------------------------- 1 | declare global { 2 | namespace NodeJS { 3 | interface ProcessEnv { 4 | NODE_ENV: string; 5 | PORT: string; 6 | 7 | SERVER_URL: string; 8 | 9 | DB_HOST: string; 10 | DB_USER: string; 11 | DB_PASSWORD: string; 12 | DB_NAME: string; 13 | 14 | JWT_SECRET: string; 15 | 16 | GOOGLE_OAUTH_CLIENT_ID: string; 17 | GOOGLE_OAUTH_CLIENT_SECRET: string; 18 | GOOGLE_OAUTH_REDIRECT: string; 19 | } 20 | } 21 | } 22 | 23 | export {}; 24 | -------------------------------------------------------------------------------- /client/vite.config.js: -------------------------------------------------------------------------------- 1 | import path from "node:path"; 2 | import { defineConfig } from "vite"; 3 | import react from "@vitejs/plugin-react"; 4 | 5 | // https://vitejs.dev/config/ 6 | export default defineConfig({ 7 | resolve: { 8 | alias: { 9 | components: path.resolve(__dirname, "./src/components"), 10 | contexts: path.resolve(__dirname, "./src/contexts"), 11 | hooks: path.resolve(__dirname, "./src/hooks"), 12 | pages: path.resolve(__dirname, "./src/pages"), 13 | }, 14 | }, 15 | plugins: [react()], 16 | }); 17 | -------------------------------------------------------------------------------- /client/src/pages/Home/Home.scss: -------------------------------------------------------------------------------- 1 | #home-page { 2 | .categories { 3 | display: flex; 4 | justify-content: center; 5 | flex-wrap: wrap; 6 | margin-bottom: 1rem; 7 | 8 | a { 9 | margin: 5px; 10 | padding: 4px 8px; 11 | font-size: 14px; 12 | text-decoration: none; 13 | letter-spacing: 1pt; 14 | line-height: 1; 15 | font-weight: 500; 16 | text-transform: lowercase; 17 | 18 | &.active { 19 | background-color: var(--primary-color); 20 | color: #000; 21 | } 22 | } 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /server/src/util/upload.ts: -------------------------------------------------------------------------------- 1 | import multer from "multer"; 2 | 3 | const imageMimetypes = ["image/jpeg", "image/jpg", "image/png"]; 4 | const upload = multer({ 5 | storage: multer.diskStorage({ 6 | destination: "./uploads", 7 | filename: (req, file, cb) => { 8 | cb(null, `${Date.now()}-${file.originalname}`); 9 | }, 10 | }), 11 | fileFilter: (req, file, cb) => { 12 | if (imageMimetypes.includes(file.mimetype)) { 13 | cb(null, true); 14 | } else { 15 | cb(null, false); 16 | } 17 | }, 18 | }); 19 | 20 | export default upload; 21 | -------------------------------------------------------------------------------- /client/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Writer's Avenue 7 | 8 | 9 | 13 | 14 | 15 |
16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /server/src/util/database.ts: -------------------------------------------------------------------------------- 1 | import dotenv from "dotenv"; 2 | import mariadb from "mariadb"; 3 | 4 | if (process.env.NODE_ENV !== "production") { 5 | dotenv.config(); 6 | } 7 | 8 | const pool = mariadb.createPool({ 9 | host: process.env.DB_HOST, 10 | user: process.env.DB_USER, 11 | password: process.env.DB_PASSWORD, 12 | database: process.env.DB_NAME, 13 | }); 14 | 15 | try { 16 | const conn = await pool.getConnection(); 17 | console.log("Connected to database."); 18 | conn.release(); 19 | } catch (err) { 20 | console.error("Error connecting to database:", err instanceof Error ? err.message : err); 21 | await pool.end(); 22 | process.exit(1); 23 | } 24 | 25 | export default pool; 26 | -------------------------------------------------------------------------------- /server/src/routes/index.ts: -------------------------------------------------------------------------------- 1 | import express from "express"; 2 | import authRouter from "./auth.js"; 3 | import commentsRouter from "./comments.js"; 4 | import likesRouter from "./likes.js"; 5 | import oauthRouter from "./oauth/index.js"; 6 | import postsRouter from "./posts.js"; 7 | import postChangesRouter from "./post-changes.js"; 8 | import usersRouter from "./users.js"; 9 | import checkAuth from "../middlewares/checkAuth.js"; 10 | 11 | const router = express.Router(); 12 | 13 | router.use("/auth", authRouter); 14 | router.use("/comments", commentsRouter); 15 | router.use("/likes", likesRouter); 16 | router.use("/oauth", oauthRouter); 17 | router.use("/posts/changes", checkAuth(), postChangesRouter); 18 | router.use("/posts", postsRouter); 19 | router.use("/users", usersRouter); 20 | 21 | export default router; 22 | -------------------------------------------------------------------------------- /client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "writers-avenue-client", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "vite build", 9 | "preview": "vite preview", 10 | "component": "bash scripts/component.sh" 11 | }, 12 | "dependencies": { 13 | "axios": "^1.1.3", 14 | "dompurify": "^2.4.1", 15 | "express": "^4.18.2", 16 | "moment": "^2.29.4", 17 | "react": "^18.2.0", 18 | "react-dom": "^18.2.0", 19 | "react-feather": "^2.0.10", 20 | "react-quill": "^2.0.0", 21 | "react-router-dom": "^6.4.2" 22 | }, 23 | "devDependencies": { 24 | "@types/react": "^18.0.17", 25 | "@types/react-dom": "^18.0.6", 26 | "@vitejs/plugin-react": "^2.1.0", 27 | "sass": "^1.55.0", 28 | "vite": "^3.1.0" 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /server/src/util/error.ts: -------------------------------------------------------------------------------- 1 | export class CustomError extends Error { 2 | isCustom: boolean; 3 | statusCode: number; 4 | 5 | constructor(message: string, statusCode = 500) { 6 | super(message); 7 | this.isCustom = true; 8 | this.statusCode = statusCode; 9 | } 10 | } 11 | 12 | export class ValidationError extends CustomError { 13 | constructor(message: string) { 14 | super(message, 400); 15 | this.name = this.constructor.name; 16 | } 17 | } 18 | 19 | export class ActionForbiddenError extends CustomError { 20 | constructor() { 21 | super("you are not authorized to perform this action", 403); 22 | this.name = this.constructor.name; 23 | } 24 | } 25 | 26 | export class AuthError extends CustomError { 27 | constructor(message: string) { 28 | super(message, 401); 29 | this.name = this.constructor.name; 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /client/src/components/Tabs/Tabs.jsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import "./Tabs.scss"; 3 | 4 | export default function Tabs({ tabs, centered }) { 5 | const [currentTabIndex, setCurrentTabIndex] = useState(0); 6 | 7 | function handleClick(e, tabIndex) { 8 | e.preventDefault(); 9 | setCurrentTabIndex(tabIndex); 10 | } 11 | 12 | return ( 13 |
14 |
15 | {tabs.map((tab, i) => ( 16 | handleClick(e, i)} 21 | > 22 | {tab.name} 23 | 24 | ))} 25 |
26 | 27 |
{tabs[currentTabIndex].content}
28 |
29 | ); 30 | } 31 | -------------------------------------------------------------------------------- /server/src/routes/users.ts: -------------------------------------------------------------------------------- 1 | import express from "express"; 2 | 3 | import checkAuth from "../middlewares/checkAuth.js"; 4 | import Post from "../models/post.js"; 5 | import User from "../models/user.js"; 6 | import { processUser } from "../util/process-data.js"; 7 | 8 | // URL: /users/... 9 | 10 | const router = express.Router(); 11 | 12 | router.get("/:userId", checkAuth(true), async (req, res, next) => { 13 | try { 14 | const userId = Number(req.params.userId); 15 | const user = await User.getById(userId); 16 | if (!user) { 17 | res.status(404).json({ error: "user not found" }); 18 | return; 19 | } 20 | 21 | const posts = await Post.getByUserId(userId, userId === req.userId ? "*" : "pub"); 22 | user.posts = posts; 23 | processUser(user); 24 | 25 | res.json({ data: user }); 26 | } catch (err) { 27 | next(err); 28 | } 29 | }); 30 | 31 | export default router; 32 | -------------------------------------------------------------------------------- /client/src/components/Loader/Loader.scss: -------------------------------------------------------------------------------- 1 | // https://loading.io/css/ 2 | 3 | .loader { 4 | width: 80px; 5 | height: 80px; 6 | display: inline-block; 7 | position: relative; 8 | 9 | div { 10 | box-sizing: border-box; 11 | display: block; 12 | position: absolute; 13 | width: 80%; 14 | height: 80%; 15 | margin: 8px; 16 | border: 4px solid #fff; 17 | border-radius: 50%; 18 | animation: load 1.2s cubic-bezier(0.5, 0, 0.5, 1) infinite; 19 | border-color: #fff transparent transparent transparent; 20 | } 21 | 22 | div:nth-child(1) { 23 | animation-delay: -0.45s; 24 | } 25 | 26 | div:nth-child(2) { 27 | animation-delay: -0.3s; 28 | } 29 | 30 | div:nth-child(3) { 31 | animation-delay: -0.15s; 32 | } 33 | 34 | @keyframes load { 35 | 0% { 36 | transform: rotate(0deg); 37 | } 38 | 39 | 100% { 40 | transform: rotate(360deg); 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /client/src/components/Tabs/Tabs.scss: -------------------------------------------------------------------------------- 1 | .tabs { 2 | &__links { 3 | padding-bottom: 12px; 4 | display: flex; 5 | overflow: auto; 6 | 7 | &--centered { 8 | justify-content: center; 9 | } 10 | 11 | a { 12 | padding: 8px 16px; 13 | border-radius: 6px 6px 0 0; 14 | display: flex; 15 | white-space: nowrap; 16 | align-items: center; 17 | text-decoration: none; 18 | 19 | &:hover { 20 | // background-color: var(--color-bg-hover); 21 | text-decoration: none; 22 | } 23 | 24 | &.tab-active { 25 | font-weight: 600; 26 | // border-bottom: 2px solid var(--color-border-active); 27 | border-bottom: 2px solid var(--primary-color); 28 | } 29 | 30 | svg { 31 | width: 16px; 32 | height: 16px; 33 | margin-right: 6px; 34 | } 35 | } 36 | } 37 | 38 | .tab > * { 39 | width: 100%; 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /client/src/components/OauthButtonGoogle/OauthButtonGoogle.scss: -------------------------------------------------------------------------------- 1 | .oauth-btn-google { 2 | display: flex; 3 | align-items: center; 4 | padding: 5px; 5 | background-color: #202124; 6 | color: #e8eaed; 7 | border: none; 8 | border-radius: 4px; 9 | font-family: var(--font); 10 | font-size: 1rem; 11 | line-height: 1; 12 | text-decoration: none; 13 | transition: background-color 300ms, opacity 300ms; 14 | cursor: pointer; 15 | 16 | &:hover { 17 | background-color: #4b4b4e; 18 | } 19 | 20 | &:disabled { 21 | opacity: 0.7; 22 | pointer-events: none; 23 | } 24 | 25 | &__icon { 26 | background-color: #fff; 27 | border-radius: 4px; 28 | width: 28px; 29 | height: 28px; 30 | display: flex; 31 | align-items: center; 32 | justify-content: center; 33 | 34 | svg { 35 | width: 20px; 36 | height: 20px; 37 | } 38 | } 39 | 40 | &__text { 41 | margin: 0 auto; 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /server/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "writers-avenue-server", 3 | "private": true, 4 | "version": "0.0.0", 5 | "main": "index.js", 6 | "type": "module", 7 | "scripts": { 8 | "start": "yarn build && dist/index.js", 9 | "dev": "tsc --watch & nodemon dist/index.js", 10 | "build": "tsc" 11 | }, 12 | "license": "MIT", 13 | "dependencies": { 14 | "axios": "^1.3.3", 15 | "bcrypt": "^5.1.0", 16 | "cors": "^2.8.5", 17 | "dotenv": "^16.0.3", 18 | "express": "^4.18.2", 19 | "googleapis": "^111.0.0", 20 | "jsonwebtoken": "^8.5.1", 21 | "langchain": "^0.0.110", 22 | "mariadb": "^3.0.2", 23 | "multer": "^1.4.5-lts.1" 24 | }, 25 | "devDependencies": { 26 | "@types/bcrypt": "^5.0.0", 27 | "@types/cors": "^2.8.13", 28 | "@types/express": "^4.17.17", 29 | "@types/jsonwebtoken": "^9.0.2", 30 | "@types/multer": "^1.4.7", 31 | "nodemon": "^2.0.20", 32 | "typescript": "^5.1.6" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /client/src/index.jsx: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | import React from "react"; 3 | import ReactDOM from "react-dom/client"; 4 | import { BrowserRouter } from "react-router-dom"; 5 | import App from "./App"; 6 | import { AuthProvider } from "contexts/auth"; 7 | import "./index.scss"; 8 | 9 | axios.defaults.baseURL = import.meta.env.VITE_SERVER_URL; 10 | axios.interceptors.request.use( 11 | (config) => { 12 | const token = localStorage.getItem("token"); 13 | if (token) { 14 | config.headers["Authorization"] = `Bearer ${token}`; 15 | } 16 | 17 | return config; 18 | }, 19 | (error) => { 20 | console.error(error); 21 | return Promise.reject(error); 22 | } 23 | ); 24 | 25 | const root = ReactDOM.createRoot(document.getElementById("root")); 26 | root.render( 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | ); 35 | -------------------------------------------------------------------------------- /client/src/components/Comment/Comment.scss: -------------------------------------------------------------------------------- 1 | .comment { 2 | padding: 10px; 3 | border: 1px solid var(--border-color); 4 | border-radius: 5px; 5 | position: relative; 6 | 7 | &__delete-btn { 8 | all: unset; 9 | cursor: pointer; 10 | position: absolute; 11 | top: 8px; 12 | right: 5px; 13 | 14 | svg { 15 | width: 16px; 16 | height: 16px; 17 | } 18 | } 19 | 20 | &__author { 21 | display: flex; 22 | align-items: center; 23 | margin-bottom: 10px; 24 | 25 | a { 26 | text-decoration: none; 27 | } 28 | 29 | img { 30 | width: 40px; 31 | height: 40px; 32 | margin-right: 10px; 33 | } 34 | 35 | small { 36 | color: #9f9f9f; 37 | } 38 | } 39 | 40 | &__footer { 41 | text-align: right; 42 | 43 | &__translation-toggle { 44 | color: var(--primary-color); 45 | user-select: none; 46 | 47 | &:hover { 48 | cursor: pointer; 49 | } 50 | } 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /client/src/hooks/useAxiosGet.jsx: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | import { useEffect, useState } from "react"; 3 | 4 | export function useAxiosGet(url) { 5 | const [data, setData] = useState(null); 6 | const [error, setError] = useState(null); 7 | 8 | useEffect(() => { 9 | const abortController = new AbortController(); 10 | axios 11 | .get(url, { signal: abortController.signal }) 12 | .then((res) => { 13 | setData(res.data.data); 14 | setError(null); 15 | }) 16 | .catch((err) => { 17 | if (err.response) { 18 | setError(err.response.data.error); 19 | } else if (err.name !== "CanceledError") { 20 | // request will be aborted once in strict more because I'm using abort controller 21 | // & it will throw CanceledError when it aborts so accounting for that 22 | setError(err.message); 23 | } 24 | }); 25 | 26 | return () => abortController.abort(); 27 | }, [url]); 28 | 29 | return { data, setData, error, setError }; 30 | } 31 | -------------------------------------------------------------------------------- /client/src/components/PostPreview/PostPreview.scss: -------------------------------------------------------------------------------- 1 | .post-preview { 2 | margin-bottom: 1rem; 3 | padding: 10px; 4 | border: 1px solid var(--border-color); 5 | border-radius: 5px; 6 | 7 | &:hover { 8 | box-shadow: 0 -2px 10px rgba(0, 0, 0, 0.7); 9 | } 10 | 11 | a { 12 | text-decoration: none; 13 | } 14 | 15 | &__author { 16 | display: flex; 17 | align-items: center; 18 | margin-bottom: 10px; 19 | 20 | img { 21 | width: 40px; 22 | height: 40px; 23 | margin-right: 10px; 24 | } 25 | 26 | small { 27 | color: #9f9f9f; 28 | } 29 | } 30 | 31 | &__image { 32 | width: 100%; 33 | height: 200px; 34 | margin: 0 0 10px; 35 | object-fit: cover; 36 | } 37 | 38 | &__title { 39 | line-height: 1; 40 | } 41 | 42 | &__body { 43 | margin: 5px 0; 44 | font-size: 0.9rem; 45 | white-space: nowrap; 46 | overflow: hidden; 47 | text-overflow: ellipsis; 48 | } 49 | 50 | @media screen and (max-width: 620px) { 51 | &__image { 52 | height: 150px; 53 | } 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /server/src/index.ts: -------------------------------------------------------------------------------- 1 | import cors from "cors"; 2 | import express from "express"; 3 | import router from "./routes/index.js"; 4 | import "./util/database.js"; 5 | import { CustomError } from "./util/error.js"; 6 | 7 | import { Request, Response, NextFunction } from "express"; 8 | 9 | const app = express(); 10 | 11 | app.use("/uploads", express.static("./uploads")); 12 | app.use("/resources", express.static("./resources")); 13 | app.use(express.json()); 14 | app.use(cors()); // reminder: make sure to set origin to something in production 15 | 16 | app.use(router); 17 | 18 | app.use((req: Request, res: Response, next: NextFunction) => { 19 | res.status(404).json({ error: "not found" }); 20 | }); 21 | 22 | app.use((err: CustomError | unknown, req: Request, res: Response, next: NextFunction) => { 23 | if (err instanceof CustomError) { 24 | res.status(err.statusCode).json({ error: err.message }); 25 | } else { 26 | console.error(err); 27 | res.status(500).json({ error: "internal server error" }); 28 | } 29 | }); 30 | 31 | const PORT = process.env.PORT || 3000; 32 | app.listen(PORT, () => console.log(`server running on port ${PORT}...`)); 33 | -------------------------------------------------------------------------------- /client/src/components/PostPreview/PostPreview.jsx: -------------------------------------------------------------------------------- 1 | import moment from "moment"; 2 | import { Link } from "react-router-dom"; 3 | import "./PostPreview.scss"; 4 | 5 | export default function PostPreview({ post }) { 6 | const doc = new DOMParser().parseFromString(post.body, "text/html"); 7 | return ( 8 |
9 | {post.user && ( 10 |
11 | 12 | Avatar 13 | 14 |
15 | {post.user.name} 16 |
17 | Posted {moment(post.publishDate).fromNow()} 18 |
19 |
20 | )} 21 | 22 | {post.coverUrl && Thumbnail} 23 |

24 | {post.title} 25 |

26 |

{doc.body.textContent}

27 |
28 |
29 | ); 30 | } 31 | -------------------------------------------------------------------------------- /client/src/contexts/auth.jsx: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | import { createContext, useEffect, useState } from "react"; 3 | import { useNavigate } from "react-router-dom"; 4 | 5 | export const authContext = createContext(null); 6 | 7 | export function AuthProvider({ children }) { 8 | const [currentUser, setCurrentUser] = useState(JSON.parse(localStorage.getItem("user"))); 9 | const navigate = useNavigate(); 10 | 11 | useEffect(() => { 12 | localStorage.setItem("user", JSON.stringify(currentUser)); 13 | }, [currentUser]); 14 | 15 | async function login(user) { 16 | const res = await axios.post("/auth/login", user); 17 | setCurrentUser(res.data.data); 18 | localStorage.setItem("token", res.data.token); 19 | navigate("/"); 20 | } 21 | 22 | async function register(user) { 23 | const res = await axios.post("/auth/register", user); 24 | setCurrentUser(res.data.data); 25 | localStorage.setItem("token", res.data.token); 26 | navigate("/"); 27 | } 28 | 29 | function logout() { 30 | setCurrentUser(null); 31 | localStorage.clear(); 32 | navigate("/"); 33 | } 34 | 35 | return ( 36 | 37 | {children} 38 | 39 | ); 40 | } 41 | -------------------------------------------------------------------------------- /server/src/middlewares/checkAuth.ts: -------------------------------------------------------------------------------- 1 | import jwt from "jsonwebtoken"; 2 | import { AuthError } from "../util/error.js"; 3 | 4 | import { Request, Response, NextFunction } from "express"; 5 | 6 | /** 7 | * check if user is authenticated for route protection. adds `userId` property to express's request. 8 | * @param optional false/undefined for protected routes. true when authentication is optional. 9 | * @returns 10 | */ 11 | export default function checkAuth(optional = false) { 12 | return (req: Request, res: Response, next: NextFunction) => { 13 | try { 14 | let authorization = req.headers["authorization"]; 15 | if (!authorization) { 16 | throw new AuthError("token required"); 17 | } 18 | 19 | authorization = authorization.replace(/\s+/, " "); 20 | if (!/^Bearer [a-zA-Z0-9.\-_]+$/.test(authorization)) { 21 | throw new AuthError("invalid value for Authorization header"); 22 | } 23 | 24 | const token = authorization.split(" ")[1]; 25 | try { 26 | req.userId = Number(jwt.verify(token, process.env.JWT_SECRET)); 27 | } catch (err) { 28 | throw new AuthError("invalid token"); 29 | } 30 | 31 | next(); 32 | } catch (err) { 33 | if (optional) { 34 | next(); 35 | } else { 36 | next(err); 37 | } 38 | } 39 | }; 40 | } 41 | -------------------------------------------------------------------------------- /server/src/routes/likes.ts: -------------------------------------------------------------------------------- 1 | import express from "express"; 2 | import checkAuth from "../middlewares/checkAuth.js"; 3 | import Like from "../models/like.js"; 4 | import { isInteger } from "../util/number.js"; 5 | 6 | // URL: /likes/... 7 | 8 | const router = express.Router(); 9 | 10 | router.get("/:postId", checkAuth(true), async (req, res, next) => { 11 | try { 12 | if (!isInteger(req.params.postId)) { 13 | res.status(404).json({ error: "post not found" }); 14 | return; 15 | } 16 | 17 | if (!req.userId) { 18 | res.send({ data: false }); 19 | return; 20 | } 21 | 22 | const { liked } = await Like.getOne(Number(req.params.postId), req.userId); 23 | res.send({ data: Boolean(liked) }); 24 | } catch (err) { 25 | next(err); 26 | } 27 | }); 28 | 29 | router.put("/:postId", checkAuth(), async (req, res, next) => { 30 | try { 31 | if (!isInteger(req.params.postId)) { 32 | res.status(404).json({ error: "post not found" }); 33 | return; 34 | } 35 | 36 | const userId = Number(req.userId); 37 | const postId = Number(req.params.postId); 38 | 39 | const likeData = await Like.getOne(postId, userId); 40 | if (!likeData) { 41 | await Like.create({ postId, userId }); 42 | res.sendStatus(201); 43 | return; 44 | } 45 | 46 | const { liked } = likeData; 47 | await Like.update(!liked, postId, userId); 48 | res.sendStatus(204); 49 | } catch (err) { 50 | next(err); 51 | } 52 | }); 53 | 54 | export default router; 55 | -------------------------------------------------------------------------------- /server/src/util/translate.ts: -------------------------------------------------------------------------------- 1 | import dotenv from "dotenv"; 2 | import { LLMChain } from "langchain/chains"; 3 | import { OpenAI } from "langchain/llms/openai"; 4 | import { PromptTemplate } from "langchain/prompts"; 5 | 6 | dotenv.config(); 7 | 8 | const model = new OpenAI({ temperature: 0.2 }); 9 | 10 | const template = ` 11 | Look at the text enclosed in triple backticks. Identify the language, understand the text & translate it to American English. 12 | You have to give me name of the original language & it's translation as a valid JSON & nothing else. 13 | If for you are not able to translate, then don't make things up & just return translation as an empty string (""). 14 | Look at the examples below for better understanding. 15 | 16 | Example 1:- 17 | - Input: su kare? 18 | - Output: {{ "language": "Gujarati", "translation": "what are you doing?" }} 19 | Example 2:- 20 | - Input: uta aytha? 21 | - Output: {{ "language": "Kannada", "translation": "had food?" }} 22 | Example 3:- 23 | - Input: asdnjsagjff 24 | - Output: {{ "language": "Unknown", "translation": "" }} 25 | 26 | \`\`\` 27 | {text} 28 | \`\`\` 29 | `.trim(); 30 | const prompt = new PromptTemplate({ 31 | template, 32 | inputVariables: ["text"], 33 | }); 34 | 35 | const chain = new LLMChain({ llm: model, prompt: prompt }); 36 | 37 | export default async function translate(text: string): Promise<{ language: string; translation: string }> { 38 | try { 39 | const res = await chain.call({ text }); 40 | return JSON.parse(res.text.trim()); 41 | } catch (err) { 42 | throw err; 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Writer's Avenue 2 | 3 | Writer's Avenue is an open-source blog publishing website with its frontend written in React & Sass, Node.js (Express) for backend and MariaDB for database. 4 | 5 | ![Screenshot](ss.png "Screenshot") 6 | 7 | As of now, the following things are implemented: 8 | 9 | - user can register/login with their email or Google account (OAuth 2.0) 10 | - if a user doesn't upload an avatar while registering, a default avatar will be set 11 | - users can create, read, update & delete posts 12 | - view profile of other users 👀 13 | - users can post comments on posts 14 | - can save drafts instead of directly publishing the posts 15 | - user can edit a published post & save those changes as a draft on the server without affecting the published post 😌 16 | 17 | ## How to run this project locally 18 | 19 | Note: these steps are tested on Linux (Pop OS) with Node v16.16.0 & MariaDB server v10.6.11. 20 | 21 | 1. make sure Node, NPM, Yarn & MariaDB are installed. 22 | 2. there are 3 dotenvs: 23 | 24 | - _dummy.env_ 25 | - _server/dummy.env_ 26 | - _client/dummy.env_ 27 | 28 | read them, populate them with proper values & rename those files from _dummy.env_ to just _.env_. 29 | 30 | 3. install dependencies by executing `yarn` in project's root directory & also in client & server directores. 31 | 4. execute this in project's root directory: 32 | ``` 33 | yarn setup 34 | ``` 35 | 5. execute this in server directory: 36 | ``` 37 | yarn dev 38 | ``` 39 | 6. execute this in client directory: 40 | ``` 41 | yarn dev 42 | ``` 43 | 7. play around with this project & say good things to reward me with some dopamine 😌 44 | -------------------------------------------------------------------------------- /client/src/pages/PostEdit/PostEdit.jsx: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | import { useContext, useState } from "react"; 3 | import { useParams } from "react-router-dom"; 4 | import Loader from "components/Loader/Loader"; 5 | import PostForm from "components/PostForm/PostForm"; 6 | import { authContext } from "contexts/auth"; 7 | import { useAxiosGet } from "hooks/useAxiosGet"; 8 | import "./PostEdit.scss"; 9 | 10 | export default function PostEdit() { 11 | const { id } = useParams(); 12 | const { currentUser } = useContext(authContext); 13 | const [changes, setChanges] = useState(true); 14 | const { data: post, setData, error, setError } = useAxiosGet(`/posts/changes/${id}`); 15 | 16 | async function loadPost() { 17 | try { 18 | const res = await axios.get(`/posts/${id}`); 19 | setData(res.data.data); 20 | setError(null); 21 | } catch (err) { 22 | if (err.response) { 23 | setError(err.response.data.error); 24 | } else { 25 | setError(err.message); 26 | } 27 | } 28 | } 29 | 30 | let content; 31 | if (error) { 32 | if (error === "not found" && changes) { 33 | setError(null); 34 | setChanges(false); 35 | loadPost(); 36 | } else { 37 | content =
{error}
; 38 | } 39 | } else if (post) { 40 | if (post.user.id !== currentUser.id) { 41 | setError("You can only edit your own posts!"); 42 | } else { 43 | content = ; 44 | } 45 | } else { 46 | content = ; 47 | } 48 | 49 | return ( 50 |
51 |

Edit Post

52 | {content} 53 |
54 | ); 55 | } 56 | -------------------------------------------------------------------------------- /client/src/pages/OauthGoogle/OauthGoogle.jsx: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | import { useContext, useEffect, useState } from "react"; 3 | import { useSearchParams } from "react-router-dom"; 4 | import Loader from "components/Loader/Loader"; 5 | import { authContext } from "contexts/auth"; 6 | import "./OauthGoogle.scss"; 7 | 8 | const EXPECTED_SEARCH_PARAMS = ["code"]; 9 | 10 | export default function OauthGoogle() { 11 | const [error, setError] = useState(""); 12 | const [searchParams] = useSearchParams(); 13 | const { setCurrentUser } = useContext(authContext); 14 | 15 | useEffect(() => { 16 | const abortController = new AbortController(); 17 | 18 | for (const param of EXPECTED_SEARCH_PARAMS) { 19 | if (!searchParams.has(param)) { 20 | setError("some error occured"); 21 | return () => abortController.abort(); 22 | } 23 | } 24 | 25 | (async function () { 26 | try { 27 | const res = await axios.post(`/oauth/google/?${searchParams.toString()}`, null, { 28 | signal: abortController.signal, 29 | }); 30 | setCurrentUser(res.data.data); 31 | localStorage.setItem("token", res.data.token); 32 | } catch (err) { 33 | console.error(err); 34 | if (err.response) { 35 | setError(err.response.data.error); 36 | } else if (err.name !== "CanceledError") { 37 | setError(err.message); 38 | } 39 | } 40 | })(); 41 | 42 | return () => abortController.abort(); 43 | }, []); 44 | 45 | let content; 46 | if (error) { 47 | content =
{error}
; 48 | } else { 49 | content = ; 50 | } 51 | 52 | return ( 53 |
54 | {content} 55 |
56 | ); 57 | } 58 | -------------------------------------------------------------------------------- /server/src/models/like.ts: -------------------------------------------------------------------------------- 1 | import dbPool from "../util/database.js"; 2 | 3 | import { ILike } from "../types/like/index.js"; 4 | 5 | export default class Like { 6 | static async create(like: Partial) { 7 | const { postId, userId } = like; 8 | 9 | let conn; 10 | try { 11 | conn = await dbPool.getConnection(); 12 | return conn.query("INSERT INTO `Like` (postId, userId) VALUES (?, ?)", [postId, userId]); 13 | } catch (err) { 14 | throw err; 15 | } finally { 16 | if (conn) { 17 | conn.release(); 18 | } 19 | } 20 | } 21 | 22 | static async getCountByPostId(postId: number) { 23 | let conn; 24 | try { 25 | conn = await dbPool.getConnection(); 26 | return (await conn.query("SELECT COUNT(liked) FROM `Like` WHERE postId = ? AND liked = 1", [postId]))[0][ 27 | "COUNT(liked)" 28 | ]; 29 | } catch (err) { 30 | throw err; 31 | } finally { 32 | if (conn) { 33 | conn.release(); 34 | } 35 | } 36 | } 37 | 38 | static async getOne(postId: number, userId: number) { 39 | let conn; 40 | try { 41 | conn = await dbPool.getConnection(); 42 | return (await conn.query("SELECT liked FROM `Like` WHERE postId = ? AND userId = ?", [postId, userId]))[0]; 43 | } catch (err) { 44 | throw err; 45 | } finally { 46 | if (conn) { 47 | conn.release(); 48 | } 49 | } 50 | } 51 | 52 | static async update(liked: boolean, postId: number, userId: number) { 53 | let conn; 54 | try { 55 | conn = await dbPool.getConnection(); 56 | return ( 57 | await conn.query("UPDATE `Like` SET liked = ? WHERE postId = ? AND userId =?", [liked, postId, userId]) 58 | )[0]; 59 | } catch (err) { 60 | throw err; 61 | } finally { 62 | if (conn) { 63 | conn.release(); 64 | } 65 | } 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /scripts/setup.js: -------------------------------------------------------------------------------- 1 | import fs from "node:fs/promises"; 2 | import path from "node:path"; 3 | import { stdin as input, stdout as output } from "node:process"; 4 | import * as readline from "node:readline"; 5 | import dotenv from "dotenv"; 6 | import mariadb from "mariadb"; 7 | 8 | const DB_KEYS = ["DB_HOST", "DB_USER", "DB_PASSWORD"]; 9 | const DIRS = ["./server/uploads"]; 10 | 11 | dotenv.config(); 12 | 13 | for (const key of DB_KEYS) { 14 | if (!(key in process.env)) { 15 | console.error(`${key} required`); 16 | process.exit(1); 17 | } 18 | } 19 | 20 | const rl = readline.createInterface({ input, output }); 21 | rl.question("This will permanently delete existing `blogs` database.\nContinue? [y/n] ", (answer) => { 22 | rl.close(); 23 | answer = answer.trim(); 24 | if (answer.toLowerCase() === "y") { 25 | setup(); 26 | } else { 27 | console.log("aborted"); 28 | } 29 | }); 30 | 31 | async function setup() { 32 | let conn; 33 | try { 34 | conn = await mariadb.createConnection({ 35 | host: process.env.DB_HOST, 36 | user: process.env.DB_USER, 37 | password: process.env.DB_PASSWORD, 38 | multipleStatements: true, 39 | }); 40 | 41 | console.log("connected with database"); 42 | 43 | const sql = await fs.readFile("./server/db.sql", "utf8"); 44 | console.log("./server/db.sql read"); 45 | console.log("executing SQL statements..."); 46 | conn.query(sql); 47 | console.log("SQL statements executed"); 48 | 49 | const serverDirContents = await fs.readdir("./server"); 50 | for (const dir of DIRS) { 51 | if (serverDirContents.includes(path.basename(dir))) { 52 | console.log(`${dir} alredy exists! skipping...`); 53 | } else { 54 | await fs.mkdir(dir); 55 | console.log(`${dir} created`); 56 | } 57 | } 58 | } catch (err) { 59 | console.error(err); 60 | } finally { 61 | if (conn) { 62 | conn.end(); 63 | } 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /server/src/routes/comments.ts: -------------------------------------------------------------------------------- 1 | import express from "express"; 2 | import checkAuth from "../middlewares/checkAuth.js"; 3 | import Comment from "../models/comment.js"; 4 | import { ActionForbiddenError } from "../util/error.js"; 5 | import { processComment } from "../util/process-data.js"; 6 | import translate from "../util/translate.js"; 7 | 8 | // URL: /comments/... 9 | 10 | const router = express.Router(); 11 | 12 | router.get("/:postId", async (req, res, next) => { 13 | try { 14 | const postId = Number(req.params.postId); 15 | const comments = await Comment.getAllByPostId(postId); 16 | comments.forEach((comment: any) => processComment(comment)); 17 | res.send({ data: comments }); 18 | } catch (err) { 19 | next(err); 20 | } 21 | }); 22 | 23 | router.get("/translation/:commentId", async (req, res, next) => { 24 | try { 25 | const comment = await Comment.getById(parseInt(req.params.commentId)); 26 | if (!comment) { 27 | res.status(404).json({ error: "post not found" }); 28 | return; 29 | } 30 | 31 | const translation = await translate(comment.body); 32 | res.json({ data: translation }); 33 | } catch (err) { 34 | next(err); 35 | } 36 | }); 37 | 38 | router.post("/", checkAuth(), async (req, res, next) => { 39 | try { 40 | let { comment, postId } = req.body; 41 | postId = Number(postId); 42 | const { insertId } = await Comment.create({ body: comment, postId, userId: Number(req.userId) }); 43 | res.status(201).json({ data: Number(insertId) }); 44 | } catch (err) { 45 | next(err); 46 | } 47 | }); 48 | 49 | router.delete("/:commentId", checkAuth(), async (req, res, next) => { 50 | try { 51 | const commentId = Number(req.params.commentId); 52 | const { affectedRows } = await Comment.delete(commentId, Number(req.userId)); 53 | if (affectedRows === 0) { 54 | throw new ActionForbiddenError(); 55 | } 56 | 57 | res.sendStatus(204); 58 | } catch (err) { 59 | next(err); 60 | } 61 | }); 62 | 63 | export default router; 64 | -------------------------------------------------------------------------------- /server/src/util/process-data.ts: -------------------------------------------------------------------------------- 1 | export function processComment(comment: any) { 2 | const user: any = { 3 | id: comment.userId, 4 | name: comment.userName, 5 | }; 6 | 7 | if (comment.userAuthMethod === "email") { 8 | user.avatarUrl = process.env.SERVER_URL + comment.userAvatarPath; 9 | } else if (comment.userAuthMethod === "google") { 10 | user.avatarUrl = comment.userAvatarPath; 11 | } 12 | 13 | comment.user = user; 14 | 15 | delete comment.userId; 16 | delete comment.userName; 17 | delete comment.userAvatarPath; 18 | delete comment.userAuthMethod; 19 | } 20 | 21 | export function processPost(post: any) { 22 | post.coverUrl = post.coverPath ? process.env.SERVER_URL + post.coverPath : null; 23 | const user: any = { 24 | id: post.userId, 25 | name: post.userName, 26 | }; 27 | 28 | if (post.userAuthMethod === "email") { 29 | user.avatarUrl = process.env.SERVER_URL + post.userAvatarPath; 30 | } else if (post.userAuthMethod === "google") { 31 | user.avatarUrl = post.userAvatarPath; 32 | } 33 | 34 | post.user = user; 35 | 36 | delete post.coverPath; 37 | delete post.userId; 38 | delete post.userName; 39 | delete post.userAvatarPath; 40 | delete post.authMethod; 41 | } 42 | 43 | export function processPostChanges(postChanges: any) { 44 | postChanges.id = postChanges.postId; 45 | postChanges.coverUrl = postChanges.coverPath ? process.env.SERVER_URL + postChanges.coverPath : null; 46 | postChanges.user = { 47 | id: postChanges.userId, 48 | }; 49 | 50 | delete postChanges.postId; 51 | delete postChanges.coverPath; 52 | delete postChanges.userId; 53 | } 54 | 55 | export function processUser(user: any) { 56 | if (user.authMethod === "email") { 57 | user.avatarUrl = process.env.SERVER_URL + user.avatarPath; 58 | } else if (user.authMethod === "google") { 59 | user.avatarUrl = user.avatarPath; 60 | } 61 | 62 | user.posts.forEach((post: any) => { 63 | post.coverUrl = post.coverPath ? process.env.SERVER_URL + post.coverPath : null; 64 | delete post.coverPath; 65 | }); 66 | 67 | delete user.avatarPath; 68 | delete user.password; 69 | } 70 | -------------------------------------------------------------------------------- /server/src/models/comment.ts: -------------------------------------------------------------------------------- 1 | import dbPool from "../util/database.js"; 2 | 3 | import { IComment } from "../types/comment/index.js"; 4 | 5 | export default class Comment { 6 | static async create(comment: Partial) { 7 | const { body, postId, userId } = comment; 8 | 9 | let conn; 10 | try { 11 | conn = await dbPool.getConnection(); 12 | const query = "INSERT INTO Comment (body, postId, userId) VALUES (?, ?, ?)"; 13 | return await conn.query(query, [body, postId, userId]); 14 | } catch (err) { 15 | throw err; 16 | } finally { 17 | if (conn) { 18 | conn.release(); 19 | } 20 | } 21 | } 22 | 23 | static async getById(id: number) { 24 | let conn; 25 | try { 26 | conn = await dbPool.getConnection(); 27 | const query = `SELECT * FROM Comment WHERE id = ?`; 28 | return (await conn.query(query, [id]))[0]; 29 | } catch (err) { 30 | throw err; 31 | } finally { 32 | if (conn) { 33 | conn.release(); 34 | } 35 | } 36 | } 37 | 38 | static async getAllByPostId(postId: number) { 39 | let conn; 40 | try { 41 | conn = await dbPool.getConnection(); 42 | const query = `SELECT 43 | c.id, 44 | c.body, 45 | c.postId, 46 | c.date, 47 | u.id userId, 48 | u.name userName, 49 | u.avatarPath userAvatarPath, 50 | u.authMethod userAuthMethod 51 | FROM Comment c 52 | JOIN User u 53 | ON c.userId = u.id 54 | WHERE c.postId = ?`; 55 | return await conn.query(query, [postId]); 56 | } catch (err) { 57 | throw err; 58 | } finally { 59 | if (conn) { 60 | conn.release(); 61 | } 62 | } 63 | } 64 | 65 | static async delete(id: number, userId: number) { 66 | let conn; 67 | try { 68 | conn = await dbPool.getConnection(); 69 | const query = "DELETE FROM Comment WHERE id = ? AND userId = ?"; 70 | return await conn.query(query, [id, userId]); 71 | } catch (err) { 72 | throw err; 73 | } finally { 74 | if (conn) { 75 | conn.release(); 76 | } 77 | } 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /server/src/models/user.ts: -------------------------------------------------------------------------------- 1 | import bcrypt from "bcrypt"; 2 | import dbPool from "../util/database.js"; 3 | 4 | import { IUser } from "../types/user/index.js"; 5 | 6 | export default class User { 7 | static async create(user: Partial) { 8 | const { name, email, password, avatarPath, authMethod } = user; 9 | 10 | let conn; 11 | try { 12 | conn = await dbPool.getConnection(); 13 | const hashedPassword = null; 14 | if (password) { 15 | await bcrypt.hash(password, 12); 16 | } 17 | 18 | const query = "INSERT INTO `User` (name, email, password, avatarPath, authMethod) VALUES (?, ?, ?, ?, ?)"; 19 | return await conn.query(query, [name, email, hashedPassword, avatarPath, authMethod]); 20 | } catch (err) { 21 | throw err; 22 | } finally { 23 | if (conn) { 24 | conn.release(); 25 | } 26 | } 27 | } 28 | 29 | static async getByEmail(email: string) { 30 | let conn; 31 | try { 32 | conn = await dbPool.getConnection(); 33 | const query = "SELECT * FROM `User` WHERE email = ?"; 34 | return (await conn.query(query, [email]))[0]; 35 | } catch (err) { 36 | throw err; 37 | } finally { 38 | if (conn) { 39 | conn.release(); 40 | } 41 | } 42 | } 43 | 44 | static async getById(id: number) { 45 | let conn; 46 | try { 47 | conn = await dbPool.getConnection(); 48 | const query = "SELECT * FROM `User` WHERE id = ?"; 49 | return (await conn.query(query, [id]))[0]; 50 | } catch (err) { 51 | throw err; 52 | } finally { 53 | if (conn) { 54 | conn.release(); 55 | } 56 | } 57 | } 58 | 59 | static async updateById(update: Partial, id: number) { 60 | const { name, avatarPath } = update; 61 | 62 | let conn; 63 | try { 64 | conn = await dbPool.getConnection(); 65 | const query = "UPDATE `User` SET name = ?, avatarPath = ? WHERE id = ?"; 66 | return (await conn.query(query, [name, avatarPath, id]))[0]; 67 | } catch (err) { 68 | throw err; 69 | } finally { 70 | if (conn) { 71 | conn.release(); 72 | } 73 | } 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /client/src/components/OauthButtonGoogle/OauthButtonGoogle.jsx: -------------------------------------------------------------------------------- 1 | import "./OauthButtonGoogle.scss"; 2 | 3 | export default function OauthButtonGoogle({ action }) { 4 | return ( 5 | 6 |
7 | 8 | 9 | 13 | 17 | 21 | 25 | 26 | 27 | 28 |
29 |
Continue with Google
30 |
31 | ); 32 | } 33 | 34 | function getGoogleUrl(from) { 35 | const rootUrl = `https://accounts.google.com/o/oauth2/v2/auth`; 36 | 37 | const options = { 38 | redirect_uri: import.meta.env.VITE_GOOGLE_OAUTH_REDIRECT, 39 | client_id: import.meta.env.VITE_GOOGLE_OAUTH_CLIENT_ID, 40 | access_type: "offline", 41 | response_type: "code", 42 | prompt: "consent", 43 | scope: ["https://www.googleapis.com/auth/userinfo.profile", "https://www.googleapis.com/auth/userinfo.email"].join( 44 | " " 45 | ), 46 | // state: from, 47 | }; 48 | 49 | const qs = new URLSearchParams(options); 50 | 51 | return `${rootUrl}?${qs.toString()}`; 52 | } 53 | -------------------------------------------------------------------------------- /server/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | /* Visit https://aka.ms/tsconfig to read more about this file */ 4 | 5 | /* Language and Environment */ 6 | "target": "es2020", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */ 7 | 8 | /* Modules */ 9 | "module": "NodeNext", /* Specify what module code is generated. */ 10 | "moduleResolution": "NodeNext", /* Specify how TypeScript looks up a file from a given module specifier. */ 11 | 12 | /* Emit */ 13 | "sourceMap": true, /* Create source map files for emitted JavaScript files. */ 14 | "outDir": "./dist", /* Specify an output folder for all emitted files. */ 15 | "removeComments": true, /* Disable emitting comments. */ 16 | 17 | /* Interop Constraints */ 18 | "esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */ 19 | "forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */ 20 | 21 | /* Type Checking */ 22 | "strict": true, /* Enable all strict type-checking options. */ 23 | "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */ 24 | "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */ 25 | "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */ 26 | "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */ 27 | 28 | /* Completeness */ 29 | // "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */ 30 | "skipLibCheck": true /* Skip type checking all .d.ts files. */ 31 | }, 32 | "include": ["src/**/*"] 33 | } 34 | -------------------------------------------------------------------------------- /client/src/pages/Home/Home.jsx: -------------------------------------------------------------------------------- 1 | import { useState } from "react"; 2 | import { Link, useNavigate, useSearchParams } from "react-router-dom"; 3 | import categories from "../../categories"; 4 | import Loader from "components/Loader/Loader"; 5 | import PostPreview from "components/PostPreview/PostPreview"; 6 | import { useAxiosGet } from "hooks/useAxiosGet"; 7 | import "./Home.scss"; 8 | 9 | export default function Home() { 10 | const navigate = useNavigate(); 11 | const [search, setSearch] = useState(""); 12 | 13 | const [searchParams] = useSearchParams(); 14 | const category = searchParams.get("category"); 15 | const { data: posts, error } = useAxiosGet(`/posts${window.location.search}`); 16 | 17 | let content; 18 | if (error) { 19 | content =
{error}
; 20 | } else if (posts) { 21 | content = ( 22 | <> 23 |
24 |
25 | setSearch(e.target.value)} 31 | /> 32 |
33 |
34 | 35 |
36 | {posts.length === 0 &&
no posts found
} 37 | {posts.map((post) => { 38 | return ; 39 | })} 40 |
41 | 42 | ); 43 | } else { 44 | content = ; 45 | } 46 | 47 | function handleSearchSubmit(e) { 48 | e.preventDefault(); 49 | 50 | const url = new URL(window.location.href); 51 | url.searchParams.set("search", search); 52 | navigate(url.pathname + url.search); 53 | } 54 | 55 | return ( 56 |
57 |
58 | 59 | ALL 60 | 61 | 62 | {categories.map((cat, i) => ( 63 | 64 | {cat.toUpperCase()} 65 | 66 | ))} 67 |
68 | {content} 69 |
70 | ); 71 | } 72 | -------------------------------------------------------------------------------- /client/src/components/Comment/Comment.jsx: -------------------------------------------------------------------------------- 1 | import moment from "moment"; 2 | import { useState } from "react"; 3 | import { Trash2 } from "react-feather"; 4 | import { Link } from "react-router-dom"; 5 | import "./Comment.scss"; 6 | 7 | export default function Comment({ comment, handleDelete, handleTranslation }) { 8 | const [isTranslated, setIsTranslated] = useState(false); 9 | const [language, setLanguage] = useState("Unknown"); // language of the original comment 10 | const [translation, setTranslation] = useState(null); 11 | const [isSubmitting, setIsSubmitting] = useState(false); 12 | 13 | async function handleTranslationToggle() { 14 | if (isSubmitting) { 15 | return; 16 | } 17 | 18 | // get translation only if it's not available 19 | if (!translation) { 20 | setIsSubmitting(true); 21 | 22 | const { language, translation: translatedText } = await handleTranslation(comment.id); 23 | setLanguage(language); 24 | setTranslation(translatedText ? translatedText : comment.body); // set original text as 'translation' if translation was unsucessfull 25 | 26 | setIsSubmitting(false); 27 | } 28 | 29 | setIsTranslated(!isTranslated); 30 | } 31 | 32 | function getTranslateButtonText() { 33 | if (isSubmitting) { 34 | return "translating..."; 35 | } 36 | 37 | return isTranslated ? `see original (${language})` : "see tranlsation"; 38 | } 39 | 40 | return ( 41 |
42 | {handleDelete && ( 43 | 46 | )} 47 |
48 | 49 | Avatar 50 | 51 |
52 | {comment.user.name} 53 |
54 | {moment(comment.date).format("MMM Do YYYY, h:mm a")} 55 |
56 |
57 |
{isTranslated && translation ? translation : comment.body}
58 |
59 | 60 | {getTranslateButtonText()} 61 | 62 |
63 |
64 | ); 65 | } 66 | -------------------------------------------------------------------------------- /server/src/routes/auth.ts: -------------------------------------------------------------------------------- 1 | import bcrypt from "bcrypt"; 2 | import express from "express"; 3 | import jwt from "jsonwebtoken"; 4 | import User from "../models/user.js"; 5 | import { ValidationError } from "../util/error.js"; 6 | import upload from "../util/upload.js"; 7 | 8 | // URL: /auth/... 9 | 10 | const router = express.Router(); 11 | 12 | router.post("/login", async (req, res, next) => { 13 | try { 14 | let { email, password } = req.body; 15 | email = email.trim(); 16 | 17 | if (password.includes(" ")) { 18 | throw new ValidationError("password cannot have spaces"); 19 | } else if (password.length < 8) { 20 | throw new ValidationError("password should be at least 8 characters long"); 21 | } 22 | 23 | const user = await User.getByEmail(email); 24 | if (!user || !(await bcrypt.compare(password, user.password))) { 25 | throw new ValidationError("incorrect email or password"); 26 | } 27 | 28 | const token = jwt.sign(user.id, process.env.JWT_SECRET); 29 | res.json({ 30 | token, 31 | data: { 32 | id: user.id, 33 | name: user.name, 34 | email: user.email, 35 | avatarUrl: process.env.SERVER_URL + user.avatarPath, 36 | }, 37 | }); 38 | } catch (err) { 39 | next(err); 40 | } 41 | }); 42 | 43 | router.post("/register", upload.single("avatar"), async (req, res, next) => { 44 | try { 45 | let { name, email, password } = req.body; 46 | name = name.trim(); 47 | email = email.trim(); 48 | 49 | if (await User.getByEmail(email)) { 50 | throw new ValidationError("email already taken"); 51 | } else if (password.includes(" ")) { 52 | throw new ValidationError("passwords cannot have spaces"); 53 | } else if (password.length < 8) { 54 | throw new ValidationError("password should be at least 8 characters long"); 55 | } 56 | 57 | const avatarPath = req.file ? `/${req.file.path}` : "/resources/blank-avatar.png"; 58 | const { insertId } = await User.create({ 59 | name, 60 | email, 61 | avatarPath, 62 | password, 63 | }); 64 | const token = jwt.sign(insertId.toString(), process.env.JWT_SECRET); 65 | res.status(201).json({ 66 | token, 67 | data: { 68 | id: Number(insertId), 69 | name, 70 | email, 71 | avatarUrl: process.env.SERVER_URL + avatarPath, 72 | }, 73 | }); 74 | } catch (err) { 75 | next(err); 76 | } 77 | }); 78 | 79 | export default router; 80 | -------------------------------------------------------------------------------- /client/src/components/Navbar/Navbar.jsx: -------------------------------------------------------------------------------- 1 | import { useContext, useEffect, useState } from "react"; 2 | import { BookOpen } from "react-feather"; 3 | import { Link, NavLink } from "react-router-dom"; 4 | import { authContext } from "contexts/auth"; 5 | import "./Navbar.scss"; 6 | 7 | export default function Navbar() { 8 | const { currentUser, logout } = useContext(authContext); 9 | const [showUserCard, setShowUserCard] = useState(false); 10 | 11 | function closeUserCard(e) { 12 | // console.log(e.target); 13 | const $userCard = document.querySelector(".user"); 14 | if (e.target === $userCard) { 15 | return; 16 | } 17 | 18 | setShowUserCard(false); 19 | } 20 | 21 | useEffect(() => { 22 | document.addEventListener("click", closeUserCard); 23 | return () => document.removeEventListener("click", closeUserCard); 24 | }); 25 | 26 | function handleAvatarClick(e) { 27 | if (!showUserCard) { 28 | setShowUserCard(true); 29 | } 30 | 31 | e.stopPropagation(); 32 | } 33 | 34 | return ( 35 | 79 | ); 80 | } 81 | -------------------------------------------------------------------------------- /server/src/routes/oauth/google.ts: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | import express from "express"; 3 | import jwt from "jsonwebtoken"; 4 | import { google } from "googleapis"; 5 | import User from "../../models/user.js"; 6 | import { ValidationError } from "../../util/error.js"; 7 | 8 | // URL: /oauth/google/... 9 | 10 | const router = express.Router(); 11 | 12 | const oauth2Client = new google.auth.OAuth2( 13 | process.env.GOOGLE_OAUTH_CLIENT_ID, 14 | process.env.GOOGLE_OAUTH_CLIENT_SECRET, 15 | process.env.GOOGLE_OAUTH_REDIRECT 16 | ); 17 | 18 | async function getGoogleUser(code: string) { 19 | try { 20 | const { tokens } = await oauth2Client.getToken(code); 21 | 22 | const res = await axios.get( 23 | `https://www.googleapis.com/oauth2/v3/userinfo?alt=json&access_token=${tokens.access_token}`, 24 | { 25 | headers: { 26 | Authorization: `Bearer ${tokens.id_token}`, 27 | }, 28 | } 29 | ); 30 | 31 | const googleUser = res.data; 32 | return googleUser; 33 | } catch (err) { 34 | throw err; 35 | } 36 | } 37 | 38 | router.post("/", async (req, res, next) => { 39 | try { 40 | if (typeof req.query.code !== "string") { 41 | throw new ValidationError("code is required"); 42 | } 43 | 44 | const googleUser = await getGoogleUser(req.query.code); 45 | const name = `${googleUser.given_name} ${googleUser.family_name || ""}`.trim(); 46 | const user = await User.getByEmail(googleUser.email); 47 | if (!user) { 48 | const { insertId } = await User.create({ 49 | name, 50 | email: googleUser.email, 51 | avatarPath: googleUser.picture, 52 | authMethod: "google", 53 | }); 54 | 55 | const userId = Number(insertId); 56 | const token = jwt.sign(userId.toString(), process.env.JWT_SECRET); 57 | res 58 | .status(201) 59 | .json({ token, data: { id: userId, name, email: googleUser.email, avatarUrl: googleUser.picture } }); 60 | return; 61 | } 62 | 63 | if (user.authMethod === "email") { 64 | throw new ValidationError("this account uses email & password for authentication"); 65 | } 66 | 67 | const updatedUser = { name, avatarPath: googleUser.picture }; 68 | await User.updateById(updatedUser, user.id); 69 | const token = jwt.sign(user.id, process.env.JWT_SECRET); 70 | res.json({ token, data: { id: user.id, name, email: user.email, avatarUrl: googleUser.picture } }); 71 | } catch (err) { 72 | if (axios.isAxiosError(err)) { 73 | const { status, data }: any = err.response; 74 | res.status(status).send({ error: data.error_description }); 75 | return; 76 | } 77 | 78 | next(err); 79 | } 80 | }); 81 | 82 | export default router; 83 | -------------------------------------------------------------------------------- /client/src/pages/Profile/Profile.jsx: -------------------------------------------------------------------------------- 1 | import { useParams } from "react-router-dom"; 2 | import Loader from "components/Loader/Loader"; 3 | import PostPreview from "components/PostPreview/PostPreview"; 4 | import Tabs from "components/Tabs/Tabs"; 5 | import { useAxiosGet } from "hooks/useAxiosGet"; 6 | import "./Profile.scss"; 7 | 8 | export default function Profile() { 9 | const { id } = useParams(); 10 | const { data: user, error } = useAxiosGet(`/users/${id}`); 11 | 12 | let content; 13 | if (error) { 14 | content =
{error}
; 15 | } else if (user) { 16 | // api will send private posts only when the logged in user vists their profile 17 | const published = []; 18 | const unpublished = []; 19 | const drafts = []; 20 | 21 | user.posts.forEach((post) => { 22 | if (post.status === "pub") { 23 | published.push(post); 24 | } else if (post.status === "pvt") { 25 | unpublished.push(post); 26 | } else if (post.status === "draft") { 27 | drafts.push(post); 28 | } 29 | }); 30 | 31 | let tabs = [ 32 | { 33 | name: "Published", 34 | content: ( 35 |
36 | {published.length === 0 &&
nothing here
} 37 | {published.map((post) => ( 38 | 39 | ))} 40 |
41 | ), 42 | }, 43 | ]; 44 | 45 | if (unpublished.length > 0) { 46 | tabs.push({ 47 | name: "Unpublished", 48 | content: ( 49 |
50 | {unpublished.map((post) => ( 51 | 52 | ))} 53 |
54 | ), 55 | }); 56 | } 57 | 58 | if (drafts.length > 0) { 59 | tabs.push({ 60 | name: "Drafts", 61 | content: ( 62 |
63 | {drafts.map((post) => ( 64 | 65 | ))} 66 |
67 | ), 68 | }); 69 | } 70 | 71 | content = ( 72 |
73 |
74 | Avatar 75 |

{user.name}

76 | {user.posts.length} posts 77 |
78 | 79 |
80 | ); 81 | } else { 82 | content = ; 83 | } 84 | 85 | return ( 86 |
87 | {content} 88 |
89 | ); 90 | } 91 | -------------------------------------------------------------------------------- /client/src/pages/PostChanges/PostChanges.scss: -------------------------------------------------------------------------------- 1 | #post-changes-page { 2 | .post { 3 | padding-bottom: 20px; 4 | margin: 0 auto; 5 | 6 | &__other-version { 7 | text-align: center; 8 | display: block; 9 | margin-bottom: 1rem; 10 | } 11 | 12 | &__cover { 13 | margin: 1rem 0; 14 | 15 | img { 16 | width: 100%; 17 | height: 220px; 18 | object-fit: cover; 19 | } 20 | } 21 | 22 | &__author { 23 | margin-bottom: 1rem; 24 | display: flex; 25 | align-items: center; 26 | 27 | img { 28 | width: 40px; 29 | height: 40px; 30 | margin-right: 10px; 31 | } 32 | 33 | a { 34 | text-decoration: none; 35 | } 36 | 37 | small { 38 | color: #9f9f9f; 39 | } 40 | } 41 | 42 | &__actions { 43 | align-self: flex-start; 44 | margin-left: 14px; 45 | 46 | > * { 47 | all: unset; 48 | cursor: pointer; 49 | margin-right: 5px; 50 | } 51 | 52 | svg { 53 | width: 16px; 54 | height: 16px; 55 | } 56 | } 57 | 58 | &__category { 59 | margin-bottom: 1rem; 60 | color: var(--primary-color); 61 | font-size: 13px; 62 | letter-spacing: 0.5pt; 63 | text-align: center; 64 | font-weight: bold; 65 | text-transform: uppercase; 66 | } 67 | 68 | &__body { 69 | white-space: pre-wrap; 70 | 71 | h1, 72 | h2, 73 | h3, 74 | h4, 75 | h5, 76 | h6 { 77 | margin-bottom: 8px; 78 | } 79 | 80 | ul, 81 | ol { 82 | padding-left: 20px; 83 | } 84 | 85 | a { 86 | color: var(--primary-color); 87 | } 88 | 89 | p, 90 | .ql-syntax { 91 | margin-bottom: 14px; 92 | } 93 | 94 | .ql-syntax { 95 | padding: 10px; 96 | border-radius: 5px; 97 | background-color: #1b1b1b; 98 | } 99 | 100 | img { 101 | max-width: 100%; 102 | } 103 | } 104 | 105 | &__comments { 106 | border-top: 1px solid var(--grey); 107 | padding-top: 20px; 108 | 109 | > h2 { 110 | font-size: 19px; 111 | } 112 | 113 | > .form { 114 | margin-top: 16px; 115 | 116 | textarea { 117 | margin-bottom: 6px; 118 | } 119 | 120 | .error-msg { 121 | margin-bottom: 6px; 122 | } 123 | } 124 | 125 | .comment { 126 | margin-top: 16px; 127 | } 128 | 129 | .loader { 130 | display: block; 131 | margin: 0 auto; 132 | } 133 | } 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /client/src/App.jsx: -------------------------------------------------------------------------------- 1 | import { useContext } from "react"; 2 | import { Navigate, Outlet, Route, Routes } from "react-router-dom"; 3 | import Navbar from "components/Navbar/Navbar"; 4 | import { authContext } from "contexts/auth"; 5 | import Home from "pages/Home/Home"; 6 | import Login from "pages/Login/Login"; 7 | import OauthGoogle from "pages/OauthGoogle/OauthGoogle"; 8 | import PageNotFound from "pages/PageNotFound/PageNotFound"; 9 | import Profile from "pages/Profile/Profile"; 10 | import Post from "pages/Post/Post"; 11 | import PostEdit from "pages/PostEdit/PostEdit"; 12 | import PostChanges from "pages/PostChanges/PostChanges"; 13 | import PostWrite from "pages/PostWrite/PostWrite"; 14 | import Register from "pages/Register/Register"; 15 | import "./App.scss"; 16 | 17 | export default function App() { 18 | return ( 19 |
20 | 21 |
22 | 23 | {/* public routes */} 24 | } /> 25 | } /> 26 | } /> 27 | 28 | {/* these routes should only be accessible when the user IS NOT logged in */} 29 | }> 30 | } /> 31 | } /> 32 | } /> 33 | 34 | 35 | {/* these routes should only be accessible when the user IS logged in */} 36 | }> 37 | } /> 38 | } /> 39 | } /> 40 | } /> 41 | 42 | 43 | {/* a catchall route for displaying 404 page */} 44 | } /> 45 | 46 |
47 |
48 | ); 49 | } 50 | 51 | function AuthRoutes({ redirectTo }) { 52 | if (!redirectTo) { 53 | throw new Error(": prop 'redirectTo' is required!"); 54 | } 55 | 56 | const { currentUser } = useContext(authContext); 57 | return currentUser ? : ; 58 | } 59 | 60 | function OnlyUnauthRoutes({ redirectTo }) { 61 | if (!redirectTo) { 62 | throw new Error(": prop 'redirectTo' is required!"); 63 | } 64 | 65 | const { currentUser } = useContext(authContext); 66 | return currentUser ? : ; 67 | } 68 | -------------------------------------------------------------------------------- /server/src/models/post-changes.ts: -------------------------------------------------------------------------------- 1 | import dbPool from "../util/database.js"; 2 | 3 | import { IPostChanges } from "../types/post-changes/index.js"; 4 | 5 | export default class PostChanges { 6 | static async create(changes: Partial) { 7 | const { title, body = null, coverPath = null, category = null, postId, userId } = changes; 8 | 9 | let conn; 10 | try { 11 | conn = await dbPool.getConnection(); 12 | const query = "INSERT INTO PostChanges VALUES (?, ?, ?, ?, ?, ?)"; 13 | return await conn.query(query, [title, body, coverPath, category, postId, userId]); 14 | } catch (err) { 15 | throw err; 16 | } finally { 17 | if (conn) { 18 | conn.release(); 19 | } 20 | } 21 | } 22 | 23 | static async getOneByIds(postId: number, userId: number) { 24 | let conn; 25 | try { 26 | conn = await dbPool.getConnection(); 27 | const query = "SELECT * FROM PostChanges WHERE postId = ? AND userId = ?"; 28 | return (await conn.query(query, [postId, userId]))[0]; 29 | } catch (err) { 30 | throw err; 31 | } finally { 32 | if (conn) { 33 | conn.release(); 34 | } 35 | } 36 | } 37 | 38 | static async getOneXByPostId(columns: string[], postId: number) { 39 | let conn; 40 | try { 41 | conn = await dbPool.getConnection(); 42 | const query = `SELECT ${columns.join(",")} FROM PostChanges WHERE postId = ?`; 43 | return (await conn.query(query, postId))[0]; 44 | } catch (err) { 45 | throw err; 46 | } finally { 47 | if (conn) { 48 | conn.release(); 49 | } 50 | } 51 | } 52 | 53 | static async update(update: Partial) { 54 | const { title, body = null, coverPath = null, category = null, postId, userId } = update; 55 | 56 | let conn; 57 | try { 58 | conn = await dbPool.getConnection(); 59 | const query = ` 60 | UPDATE PostChanges SET 61 | title = ?, 62 | body = ?, 63 | coverPath = ?, 64 | category = ? 65 | WHERE postId = ? AND userId = ?`; 66 | return await conn.query(query, [title, body, coverPath, category, postId, userId]); 67 | } catch (err) { 68 | throw err; 69 | } finally { 70 | if (conn) { 71 | conn.release(); 72 | } 73 | } 74 | } 75 | 76 | static async delete(postId: number, userId: number) { 77 | let conn; 78 | try { 79 | conn = await dbPool.getConnection(); 80 | const query = "DELETE FROM PostChanges WHERE postId = ? AND userId = ?"; 81 | return await conn.query(query, [postId, userId]); 82 | } catch (err) { 83 | throw err; 84 | } finally { 85 | if (conn) { 86 | conn.release(); 87 | } 88 | } 89 | } 90 | } 91 | -------------------------------------------------------------------------------- /client/src/components/Navbar/Navbar.scss: -------------------------------------------------------------------------------- 1 | .navbar { 2 | background-color: var(--bg-color); 3 | box-shadow: 0px 1px 2px #000; 4 | margin-bottom: 20px; 5 | 6 | .wrapper { 7 | max-width: 916px; 8 | margin: 0 auto; 9 | padding: 12px 20px; 10 | display: flex; 11 | align-items: center; 12 | position: relative; // will be used for user options card 13 | } 14 | 15 | h1 a { 16 | display: flex; 17 | align-items: center; 18 | text-decoration: none; 19 | color: var(--primary-color); 20 | 21 | svg { 22 | margin-right: 8px; 23 | } 24 | 25 | span { 26 | display: block; 27 | font-size: 18px; 28 | line-height: 1; 29 | } 30 | 31 | small { 32 | display: block; 33 | font-size: 12px; 34 | line-height: 1; 35 | letter-spacing: 0.5pt; 36 | margin-left: 2px; 37 | } 38 | } 39 | 40 | &__links { 41 | margin-left: auto; 42 | 43 | a { 44 | margin-right: 5px; 45 | padding: 6px 10px; 46 | border-radius: 5px; 47 | text-decoration: none; 48 | display: inline-block; 49 | line-height: 1; 50 | 51 | &:hover { 52 | color: var(--primary-color); 53 | } 54 | 55 | &.active { 56 | background-color: rgba(0, 0, 0, 0.7); 57 | } 58 | } 59 | } 60 | 61 | .user-avatar { 62 | width: 28px; 63 | height: 28px; 64 | margin-left: 8px; 65 | cursor: pointer; 66 | } 67 | 68 | .user { 69 | $card-bg-color: #1b1b1b; 70 | width: 200px; 71 | padding: 16px; 72 | background-color: $card-bg-color; 73 | border-radius: 10px; 74 | box-shadow: 0px 3px 4px 0px hsla(0, 0%, 0%, 0.14), 0px 3px 3px -2px hsla(0, 0%, 0%, 0.12), 75 | 0px 1px 8px 0px hsla(0, 0%, 0%, 0.2); 76 | position: absolute; // relative to .wrapper 77 | top: calc(100% - 6px); 78 | right: 8px; 79 | display: flex; 80 | flex-direction: column; 81 | justify-content: center; 82 | align-items: center; 83 | text-align: center; 84 | display: none; 85 | 86 | &--active { 87 | display: flex; 88 | } 89 | 90 | &::before { 91 | content: ""; 92 | width: 16px; 93 | height: 16px; 94 | background-color: $card-bg-color; 95 | position: absolute; // relative to .user 96 | top: -7px; 97 | right: 17px; 98 | transform: rotateZ(45deg); 99 | } 100 | 101 | &__name { 102 | margin: 12px 0 0; 103 | text-decoration: none; 104 | } 105 | 106 | &__email { 107 | margin: 0 0 12px; 108 | color: #9f9f9f; 109 | } 110 | 111 | &__avatar { 112 | width: 96px; 113 | height: 96px; 114 | border-radius: 50%; 115 | } 116 | 117 | &__logout-btn { 118 | pointer-events: all; 119 | } 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /client/src/pages/Login/Login.jsx: -------------------------------------------------------------------------------- 1 | import { useContext, useState } from "react"; 2 | import { Link } from "react-router-dom"; 3 | import OauthButtonGoogle from "components/OauthButtonGoogle/OauthButtonGoogle"; 4 | import { authContext } from "contexts/auth"; 5 | 6 | // styles for this page are in index.scss 7 | export default function Login() { 8 | const [email, setEmail] = useState(""); 9 | const [password, setPassword] = useState(""); 10 | const [isSubmitting, setIsSubmitting] = useState(false); 11 | const [error, setError] = useState(null); 12 | const { login } = useContext(authContext); 13 | 14 | async function handleSubmit(e) { 15 | try { 16 | e.preventDefault(); 17 | setError(null); 18 | setIsSubmitting(true); 19 | 20 | if (password.includes(" ")) { 21 | throw new Error("passwords cannot have spaces!"); 22 | } else if (password.length < 8) { 23 | throw new Error("password should be at least 8 characters long!"); 24 | } 25 | 26 | const user = { email: email.trim(), password }; 27 | await login(user); 28 | } catch (err) { 29 | console.error(err); 30 | if (err.response) { 31 | setError(err.response.data.error); 32 | } else { 33 | setError(err.message); 34 | } 35 | } finally { 36 | setIsSubmitting(false); 37 | } 38 | } 39 | 40 | return ( 41 |
42 |

Login

43 | 44 |
45 | 46 | 47 |
48 |
49 | Or 50 |
51 | 52 |
53 | 54 | setEmail(e.target.value)} 62 | /> 63 |
64 |
65 | 66 | setPassword(e.target.value)} 74 | /> 75 |
76 | {error &&
{error}
} 77 | 80 | 81 | Don't have an account? 82 |
83 | Create Account 84 |
85 | 86 |
87 | ); 88 | } 89 | -------------------------------------------------------------------------------- /yarn.lock: -------------------------------------------------------------------------------- 1 | # THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. 2 | # yarn lockfile v1 3 | 4 | 5 | "@types/geojson@^7946.0.10": 6 | version "7946.0.10" 7 | resolved "https://registry.yarnpkg.com/@types/geojson/-/geojson-7946.0.10.tgz#6dfbf5ea17142f7f9a043809f1cd4c448cb68249" 8 | integrity sha512-Nmh0K3iWQJzniTuPRcJn5hxXkfB1T1pgB89SBig5PlJQU5yocazeu4jATJlaA0GYFKWMqDdvYemoSnF2pXgLVA== 9 | 10 | "@types/node@^17.0.45": 11 | version "17.0.45" 12 | resolved "https://registry.yarnpkg.com/@types/node/-/node-17.0.45.tgz#2c0fafd78705e7a18b7906b5201a522719dc5190" 13 | integrity sha512-w+tIMs3rq2afQdsPJlODhoUEKzFP1ayaoyl1CcnwtIlsVe7K7bA1NGm4s3PraqTLlXnbIN84zuBlxBWo1u9BLw== 14 | 15 | denque@^2.1.0: 16 | version "2.1.0" 17 | resolved "https://registry.yarnpkg.com/denque/-/denque-2.1.0.tgz#e93e1a6569fb5e66f16a3c2a2964617d349d6ab1" 18 | integrity sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw== 19 | 20 | dotenv@^16.0.3: 21 | version "16.0.3" 22 | resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-16.0.3.tgz#115aec42bac5053db3c456db30cc243a5a836a07" 23 | integrity sha512-7GO6HghkA5fYG9TYnNxi14/7K9f5occMlp3zXAuSxn7CKCxt9xbNWG7yF8hTCSUchlfWSe3uLmlPfigevRItzQ== 24 | 25 | iconv-lite@^0.6.3: 26 | version "0.6.3" 27 | resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.6.3.tgz#a52f80bf38da1952eb5c681790719871a1a72501" 28 | integrity sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw== 29 | dependencies: 30 | safer-buffer ">= 2.1.2 < 3.0.0" 31 | 32 | lru-cache@^7.14.0: 33 | version "7.14.1" 34 | resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-7.14.1.tgz#8da8d2f5f59827edb388e63e459ac23d6d408fea" 35 | integrity sha512-ysxwsnTKdAx96aTRdhDOCQfDgbHnt8SK0KY8SEjO0wHinhWOFTESbjVCMPbU1uGXg/ch4lifqx0wfjOawU2+WA== 36 | 37 | mariadb@^3.0.2: 38 | version "3.0.2" 39 | resolved "https://registry.yarnpkg.com/mariadb/-/mariadb-3.0.2.tgz#427ae286b8fb35f4046b3457f4df729a5019d6c3" 40 | integrity sha512-dVjiQZ6RW0IXFnX+T/ZEmnqs724DgkQsXqfCyInXn0XxVfO2Px6KbS4M3Ny6UiBg0zJ93SHHvfVBgYO4ZnFvvw== 41 | dependencies: 42 | "@types/geojson" "^7946.0.10" 43 | "@types/node" "^17.0.45" 44 | denque "^2.1.0" 45 | iconv-lite "^0.6.3" 46 | lru-cache "^7.14.0" 47 | moment-timezone "^0.5.38" 48 | 49 | moment-timezone@^0.5.38: 50 | version "0.5.40" 51 | resolved "https://registry.yarnpkg.com/moment-timezone/-/moment-timezone-0.5.40.tgz#c148f5149fd91dd3e29bf481abc8830ecba16b89" 52 | integrity sha512-tWfmNkRYmBkPJz5mr9GVDn9vRlVZOTe6yqY92rFxiOdWXbjaR0+9LwQnZGGuNR63X456NqmEkbskte8tWL5ePg== 53 | dependencies: 54 | moment ">= 2.9.0" 55 | 56 | "moment@>= 2.9.0": 57 | version "2.29.4" 58 | resolved "https://registry.yarnpkg.com/moment/-/moment-2.29.4.tgz#3dbe052889fe7c1b2ed966fcb3a77328964ef108" 59 | integrity sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w== 60 | 61 | "safer-buffer@>= 2.1.2 < 3.0.0": 62 | version "2.1.2" 63 | resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" 64 | integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== 65 | -------------------------------------------------------------------------------- /client/src/pages/PostChanges/PostChanges.jsx: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | import DOMPurify from "dompurify"; 3 | import moment from "moment"; 4 | import { useContext } from "react"; 5 | import { Link, useNavigate, useParams } from "react-router-dom"; 6 | import { Edit, Trash2 } from "react-feather"; 7 | import Loader from "components/Loader/Loader"; 8 | import { authContext } from "contexts/auth"; 9 | import { useAxiosGet } from "hooks/useAxiosGet"; 10 | import "./PostChanges.scss"; 11 | 12 | export default function PostChanges() { 13 | const { id } = useParams(); 14 | const { data: postChanges, error } = useAxiosGet(`/posts/changes/${id}`); 15 | const { currentUser } = useContext(authContext); 16 | const navigate = useNavigate(); 17 | 18 | async function handleDelete() { 19 | if (!confirm("This changes in this post will be permanently deleted. Are you sure?")) { 20 | return; 21 | } 22 | 23 | try { 24 | await axios.delete(`/posts/changes/${id}`); 25 | navigate("/"); 26 | } catch (err) { 27 | console.error(err); 28 | if (err.response) { 29 | alert(err.response.data.error); 30 | } else { 31 | alert(err.message); 32 | } 33 | } 34 | } 35 | 36 | let content; 37 | if (error) { 38 | content =
{error}
; 39 | } else if (postChanges) { 40 | content = ( 41 |
42 | 43 | Go to published version 44 | 45 | 46 |
{postChanges.category ? postChanges.category : "---"}
47 | 48 |

{postChanges.title}

49 |
50 | {postChanges.coverUrl ? Cover Image : "---"} 51 |
52 | 53 |
54 | 55 | Avatar 56 | 57 |
58 | 59 | Written by {currentUser.name} 60 | 61 |
62 | 63 | {postChanges.publishDate && `${moment(postChanges.publishDate).format("MMM Do YYYY")}, `}edited{" "} 64 | {moment(postChanges.editData).format("MMM Do YYYY")} 65 | 66 |
67 | 68 |
69 | 70 | 71 | 72 | 73 | 76 |
77 |
78 | 79 |
85 |
86 | ); 87 | } else { 88 | content = ; 89 | } 90 | 91 | return ( 92 |
93 | {content} 94 |
95 | ); 96 | } 97 | -------------------------------------------------------------------------------- /server/db.sql: -------------------------------------------------------------------------------- 1 | DROP DATABASE IF EXISTS `blogs`; 2 | 3 | CREATE DATABASE `blogs`; 4 | 5 | USE `blogs`; 6 | 7 | 8 | -- blogs.`User` definition 9 | 10 | CREATE TABLE `User` ( 11 | `id` int(10) unsigned NOT NULL AUTO_INCREMENT, 12 | `name` varchar(100) NOT NULL, 13 | `email` varchar(255) NOT NULL, 14 | `password` varchar(512) DEFAULT NULL, 15 | `avatarPath` varchar(255) NOT NULL, 16 | `authMethod` enum('email','google') NOT NULL DEFAULT 'email', 17 | PRIMARY KEY (`id`), 18 | UNIQUE KEY `User_UN` (`email`) 19 | ) ENGINE=InnoDB AUTO_INCREMENT=0 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; 20 | 21 | 22 | -- blogs.Post definition 23 | 24 | CREATE TABLE `Post` ( 25 | `id` int(10) unsigned NOT NULL AUTO_INCREMENT, 26 | `title` varchar(255) NOT NULL, 27 | `body` text DEFAULT NULL, 28 | `coverPath` varchar(255) DEFAULT NULL, 29 | `category` enum('art','business','cinema','food','science','technology') DEFAULT NULL, 30 | `publishDate` datetime DEFAULT current_timestamp(), 31 | `editDate` datetime NOT NULL DEFAULT current_timestamp(), 32 | `status` enum('pub','pvt','draft') NOT NULL DEFAULT 'pub', 33 | `userId` int(10) unsigned NOT NULL, 34 | PRIMARY KEY (`id`), 35 | KEY `Post_FK` (`userId`), 36 | CONSTRAINT `Post_FK` FOREIGN KEY (`userId`) REFERENCES `User` (`id`) ON DELETE CASCADE ON UPDATE CASCADE 37 | ) ENGINE=InnoDB AUTO_INCREMENT=0 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; 38 | 39 | 40 | -- blogs.PostChanges definition 41 | 42 | CREATE TABLE `PostChanges` ( 43 | `title` varchar(255) DEFAULT NULL, 44 | `body` text DEFAULT NULL, 45 | `coverPath` varchar(255) DEFAULT NULL, 46 | `category` enum('art','business','cinema','food','science','technology') DEFAULT NULL, 47 | `postId` int(10) unsigned NOT NULL, 48 | `userId` int(10) unsigned NOT NULL, 49 | KEY `PostChange_Post_FK` (`postId`), 50 | KEY `PostChange_User_FK` (`userId`), 51 | CONSTRAINT `PostChange_Post_FK` FOREIGN KEY (`postId`) REFERENCES `Post` (`id`) ON DELETE CASCADE ON UPDATE CASCADE, 52 | CONSTRAINT `PostChange_User_FK` FOREIGN KEY (`userId`) REFERENCES `User` (`id`) ON DELETE CASCADE ON UPDATE CASCADE 53 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; 54 | 55 | 56 | -- blogs.Comment definition 57 | 58 | CREATE TABLE `Comment` ( 59 | `id` int(10) unsigned NOT NULL AUTO_INCREMENT, 60 | `body` text NOT NULL, 61 | `postId` int(10) unsigned NOT NULL, 62 | `userId` int(10) unsigned NOT NULL, 63 | `date` datetime NOT NULL DEFAULT current_timestamp(), 64 | PRIMARY KEY (`id`), 65 | KEY `Comment_Post_FK` (`postId`), 66 | KEY `Comment_User_FK` (`userId`), 67 | CONSTRAINT `Comment_Post_FK` FOREIGN KEY (`postId`) REFERENCES `Post` (`id`) ON DELETE CASCADE ON UPDATE CASCADE, 68 | CONSTRAINT `Comment_User_FK` FOREIGN KEY (`userId`) REFERENCES `User` (`id`) ON DELETE CASCADE ON UPDATE CASCADE 69 | ) ENGINE=InnoDB AUTO_INCREMENT=0 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; 70 | 71 | 72 | -- blogs.`Like` definition 73 | 74 | CREATE TABLE `Like` ( 75 | `liked` tinyint(1) NOT NULL DEFAULT 1, 76 | `postId` int(10) unsigned NOT NULL, 77 | `userId` int(10) unsigned NOT NULL, 78 | KEY `Like_Post_FK` (`postId`), 79 | KEY `Like_FK` (`userId`), 80 | CONSTRAINT `Like_FK` FOREIGN KEY (`userId`) REFERENCES `User` (`id`) ON DELETE CASCADE ON UPDATE CASCADE, 81 | CONSTRAINT `Like_Post_FK` FOREIGN KEY (`postId`) REFERENCES `Post` (`id`) ON DELETE CASCADE ON UPDATE CASCADE 82 | ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci; -------------------------------------------------------------------------------- /client/src/pages/Post/Post.scss: -------------------------------------------------------------------------------- 1 | #post-page { 2 | max-width: 956px; // 916px width + 40px padding 3 | display: flex; 4 | 5 | .post { 6 | padding-bottom: 20px; 7 | flex-grow: 1; 8 | max-width: 600px; 9 | margin: 0 auto; 10 | 11 | &__other-version { 12 | text-align: center; 13 | display: block; 14 | margin-bottom: 1rem; 15 | } 16 | 17 | &__category { 18 | margin-bottom: 1rem; 19 | color: var(--primary-color); 20 | font-size: 13px; 21 | letter-spacing: 0.5pt; 22 | text-align: center; 23 | font-weight: bold; 24 | text-transform: uppercase; 25 | } 26 | 27 | &__cover { 28 | margin: 1rem 0; 29 | 30 | img { 31 | width: 100%; 32 | height: 220px; 33 | object-fit: cover; 34 | } 35 | } 36 | 37 | &__author { 38 | margin-bottom: 1rem; 39 | display: flex; 40 | align-items: center; 41 | 42 | img { 43 | width: 40px; 44 | height: 40px; 45 | margin-right: 10px; 46 | } 47 | 48 | a { 49 | text-decoration: none; 50 | } 51 | 52 | small { 53 | color: #9f9f9f; 54 | } 55 | } 56 | 57 | &__actions { 58 | align-self: flex-start; 59 | margin-left: 14px; 60 | 61 | > * { 62 | all: unset; 63 | cursor: pointer; 64 | margin-right: 5px; 65 | } 66 | 67 | svg { 68 | width: 16px; 69 | height: 16px; 70 | } 71 | } 72 | 73 | &__body { 74 | white-space: pre-wrap; 75 | 76 | h1, 77 | h2, 78 | h3, 79 | h4, 80 | h5, 81 | h6 { 82 | margin-bottom: 8px; 83 | } 84 | 85 | ul, 86 | ol { 87 | padding-left: 20px; 88 | } 89 | 90 | a { 91 | color: var(--primary-color); 92 | } 93 | 94 | p, 95 | .ql-syntax { 96 | margin-bottom: 14px; 97 | } 98 | 99 | .ql-syntax { 100 | padding: 10px; 101 | border-radius: 5px; 102 | background-color: #1b1b1b; 103 | } 104 | 105 | img { 106 | max-width: 100%; 107 | } 108 | } 109 | 110 | &__likes { 111 | border-top: 1px solid var(--grey); 112 | padding: 10px 0; 113 | 114 | display: flex; 115 | align-items: center; 116 | 117 | .btn { 118 | display: flex; 119 | align-items: center; 120 | margin-right: 8px; 121 | 122 | svg { 123 | margin-right: 4px; 124 | } 125 | } 126 | } 127 | 128 | &__comments { 129 | border-top: 1px solid var(--grey); 130 | padding-top: 20px; 131 | 132 | > h2 { 133 | font-size: 19px; 134 | } 135 | 136 | > .form { 137 | margin-top: 16px; 138 | 139 | textarea { 140 | margin-bottom: 6px; 141 | } 142 | 143 | .error-msg { 144 | margin-bottom: 6px; 145 | } 146 | } 147 | 148 | .comment { 149 | margin-top: 16px; 150 | } 151 | 152 | .loader { 153 | display: block; 154 | margin: 0 auto; 155 | } 156 | } 157 | } 158 | 159 | .other-posts { 160 | max-width: 300px; 161 | width: 100%; 162 | flex-shrink: 0; 163 | margin-left: 16px; 164 | 165 | > h2 { 166 | font-size: 19px; 167 | margin-bottom: 16px; 168 | } 169 | 170 | .post-preview__title { 171 | font-size: 20px; 172 | } 173 | 174 | .post-preview__image { 175 | height: 150px; 176 | } 177 | } 178 | 179 | @media screen and (max-width: 768px) { 180 | max-width: 600px; 181 | display: block; 182 | 183 | .other-posts { 184 | max-width: none; 185 | margin-left: 0; 186 | padding-top: 20px; 187 | border-top: 1px solid var(--grey); 188 | } 189 | } 190 | } 191 | -------------------------------------------------------------------------------- /server/src/routes/post-changes.ts: -------------------------------------------------------------------------------- 1 | import fs from "node:fs/promises"; 2 | import path from "node:path"; 3 | import express from "express"; 4 | import { ActionForbiddenError, ValidationError } from "../util/error.js"; 5 | import { isInteger } from "../util/number.js"; 6 | import upload from "../util/upload.js"; 7 | import Post from "../models/post.js"; 8 | import PostChanges from "../models/post-changes.js"; 9 | import { processPostChanges } from "../util/process-data.js"; 10 | 11 | // URL: /post/changes/... 12 | 13 | const router = express.Router(); 14 | 15 | router.get("/:postId", async (req, res, next) => { 16 | try { 17 | const postId = Number(req.params.postId); 18 | const postChanges = await PostChanges.getOneByIds(postId, Number(req.userId)); 19 | if (!postChanges) { 20 | next(); 21 | return; 22 | } 23 | 24 | const { status } = await Post.getOneXById(["status"], postId); 25 | postChanges.status = status; 26 | processPostChanges(postChanges); 27 | res.send({ data: postChanges }); 28 | } catch (err) { 29 | next(err); 30 | } 31 | }); 32 | 33 | router.put("/:postId", upload.single("cover"), async (req, res, next) => { 34 | try { 35 | let { title, body, category } = req.body; 36 | title = title.trim(); 37 | body = body ? body.trim() : null; 38 | category = category ? category.trim() : null; 39 | 40 | if (title.trim().length === 0) { 41 | throw new ValidationError("title is required"); 42 | } 43 | 44 | const postId = Number(req.params.postId); 45 | const postData = await Post.getOneXById(["coverPath", "status", "userId"], postId); 46 | if (!postData) { 47 | if (req.file) { 48 | await fs.unlink(path.join(process.cwd(), req.file.path)); 49 | } 50 | 51 | res.status(404).json({ error: "post not found" }); 52 | return; 53 | } 54 | 55 | let { coverPath, status: postStatus, userId } = postData; 56 | if (req.userId !== userId) { 57 | if (req.file) { 58 | await fs.unlink(path.join(process.cwd(), req.file.path)); 59 | } 60 | 61 | throw new ActionForbiddenError(); 62 | } 63 | 64 | if (postStatus === "draft") { 65 | if (req.file) { 66 | if (coverPath) { 67 | await fs.unlink(path.join(process.cwd(), coverPath)); 68 | } 69 | 70 | coverPath = `/${req.file.path}`; 71 | } 72 | 73 | await Post.update( 74 | { 75 | title, 76 | body: body, 77 | coverPath, 78 | category: category || null, 79 | status: "draft", 80 | }, 81 | postId, 82 | Number(req.userId) 83 | ); 84 | } else { 85 | if (req.file) { 86 | coverPath = `/${req.file.path}`; 87 | } 88 | 89 | const postChangesData = await PostChanges.getOneXByPostId(["userId"], postId); 90 | if (!postChangesData) { 91 | PostChanges.create({ title, body, coverPath, category, postId, userId }); 92 | } else { 93 | PostChanges.update({ title, body, coverPath, category, postId, userId }); 94 | } 95 | } 96 | 97 | res.sendStatus(204); 98 | } catch (err) { 99 | next(err); 100 | } 101 | }); 102 | 103 | router.delete("/:postId", async (req, res, next) => { 104 | try { 105 | const postId = Number(req.params.postId); 106 | 107 | const postChangesData = await PostChanges.getOneXByPostId(["userId"], postId); 108 | if (!processPostChanges) { 109 | res.status(404).json({ error: "post not found" }); 110 | return; 111 | } 112 | 113 | const userId = Number(req.userId); 114 | if (userId !== postChangesData.userId) { 115 | throw new ActionForbiddenError(); 116 | } 117 | 118 | await PostChanges.delete(postId, userId); 119 | res.sendStatus(204); 120 | } catch (err) { 121 | next(err); 122 | } 123 | }); 124 | 125 | export default router; 126 | -------------------------------------------------------------------------------- /client/src/pages/Register/Register.jsx: -------------------------------------------------------------------------------- 1 | import { useState, useContext } from "react"; 2 | import { Link } from "react-router-dom"; 3 | import OauthButtonGoogle from "components/OauthButtonGoogle/OauthButtonGoogle"; 4 | import { authContext } from "contexts/auth"; 5 | 6 | // styles for this page are in index.scss 7 | export default function Register() { 8 | const [name, setName] = useState(""); 9 | const [email, setEmail] = useState(""); 10 | const [password, setPassword] = useState(""); 11 | const [confirmPassword, setConfirmPassword] = useState(""); 12 | const [isSubmitting, setIsSubmitting] = useState(false); 13 | const [error, setError] = useState(null); 14 | const { register } = useContext(authContext); 15 | 16 | async function handleSubmit(e) { 17 | try { 18 | e.preventDefault(); 19 | setError(null); 20 | setIsSubmitting(true); 21 | 22 | if (password.includes(" ") || confirmPassword.includes(" ")) { 23 | throw new Error("passwords cannot have spaces"); 24 | } else if (password.length < 8) { 25 | throw new Error("password should be at least 8 characters long"); 26 | } else if (password !== confirmPassword) { 27 | throw new Error("passwords do not match"); 28 | } 29 | 30 | const user = new FormData(e.target); 31 | await register(user); 32 | } catch (err) { 33 | console.error(err); 34 | if (err.response) { 35 | setError(err.response.data.error); 36 | } else { 37 | setError(err.message); 38 | } 39 | } finally { 40 | setIsSubmitting(false); 41 | } 42 | } 43 | 44 | return ( 45 |
46 |

Create New Account

47 | 48 |
49 | 50 | 51 |
52 |
53 | Or 54 |
55 | 56 |
57 | 58 | setName(e.target.value)} 65 | /> 66 |
67 | 68 |
69 | 70 | 71 |
72 | 73 |
74 | 75 | setEmail(e.target.value)} 83 | /> 84 |
85 | 86 |
87 | 88 | setPassword(e.target.value)} 96 | /> 97 |
98 | 99 |
100 | 101 | setConfirmPassword(e.target.value)} 109 | /> 110 |
111 | 112 | {error &&
{error}
} 113 | 114 | 117 | 118 | 119 | Already have an account? 120 |
121 | Login 122 |
123 | 124 |
125 | ); 126 | } 127 | -------------------------------------------------------------------------------- /client/src/index.scss: -------------------------------------------------------------------------------- 1 | * { 2 | margin: 0; 3 | padding: 0; 4 | box-sizing: border-box; 5 | } 6 | 7 | :root { 8 | --bg-color: #111; 9 | --border-color: #333; 10 | --primary-color: #ffb400; 11 | --grey: #808080; 12 | --red: #ff7272; 13 | --font: "Nunito", sans-serif; 14 | 15 | font-family: var(--font); 16 | -webkit-tap-highlight-color: rgba(0, 0, 0, 0); 17 | -webkit-tap-highlight-color: transparent; 18 | } 19 | 20 | body { 21 | width: 100%; 22 | height: 100vh; 23 | overflow: hidden; 24 | min-width: 360px; 25 | background-color: var(--bg-color); 26 | color: #ddd; 27 | } 28 | 29 | ::-webkit-scrollbar { 30 | width: 5px; // for vertical scroolbar 31 | height: 5px; // for horizontal scrollbar 32 | background-color: #1b1b1b; 33 | } 34 | 35 | ::-webkit-scrollbar-thumb { 36 | background-color: var(--primary-color); 37 | border-radius: 5px; 38 | } 39 | 40 | a { 41 | color: #fff; 42 | 43 | &:hover { 44 | color: var(--primary-color); 45 | } 46 | } 47 | 48 | button, 49 | input, 50 | textarea { 51 | font-family: var(--font); 52 | } 53 | 54 | input, 55 | textarea { 56 | // background-color: #2f2f2f; 57 | background-color: #202124; 58 | 59 | color: #fff; 60 | 61 | &::placeholder { 62 | color: #999; 63 | } 64 | } 65 | 66 | .pill { 67 | border: 1px solid #fff; 68 | border-radius: 10000px; 69 | } 70 | 71 | .error-msg { 72 | color: var(--red); 73 | text-align: center; 74 | } 75 | 76 | #root { 77 | height: 100%; 78 | } 79 | 80 | .page { 81 | max-width: 600px; 82 | min-height: 100%; 83 | margin: 0 auto; 84 | padding: 0 20px 20px; 85 | 86 | // styles for the loader that will be displayed when loading page's contents 87 | > .loader { 88 | display: block; 89 | margin: 0 auto; 90 | } 91 | } 92 | 93 | // CSS components 94 | .avatar { 95 | border-radius: 50%; 96 | border: 1px solid var(--border-color); 97 | } 98 | 99 | .btn { 100 | display: inline-block; 101 | padding: 4px 8px; 102 | background-color: var(--primary-color); 103 | color: #000; 104 | border: none; 105 | border-radius: 4px; 106 | font-family: var(--font); 107 | font-size: 1rem; 108 | line-height: 1; 109 | text-decoration: none; 110 | transition: background-color 300ms, opacity 300ms; 111 | cursor: pointer; 112 | 113 | &:disabled { 114 | opacity: 0.7; 115 | pointer-events: none; 116 | } 117 | 118 | &:hover { 119 | background-color: var(--primary-color); 120 | color: #000; 121 | opacity: 0.8; 122 | } 123 | 124 | &--small { 125 | padding: 3px 6px; 126 | font-size: 0.65rem; 127 | } 128 | 129 | &--stroked { 130 | border: 1px solid var(--primary-color); 131 | padding: 3px 7px; 132 | background: none; 133 | color: #fff; 134 | } 135 | 136 | &--red { 137 | background-color: var(--red); 138 | } 139 | } 140 | 141 | .form { 142 | &__field > * { 143 | width: 100%; 144 | margin-bottom: 1rem; 145 | } 146 | 147 | label { 148 | display: block; 149 | margin-bottom: 5px; 150 | } 151 | 152 | input, 153 | textarea { 154 | border-radius: 5px; 155 | border: 1px solid #000; 156 | padding: 5px; 157 | font-size: 0.9rem; 158 | 159 | &:focus { 160 | outline: 2px solid var(--primary-color); 161 | border-color: transparent; 162 | } 163 | } 164 | } 165 | 166 | #login-page, 167 | #register-page { 168 | max-width: 400px; 169 | 170 | h2 { 171 | margin-bottom: 1rem; 172 | text-align: center; 173 | } 174 | 175 | .error-msg { 176 | margin-bottom: 1rem; 177 | } 178 | 179 | .btn { 180 | display: block; 181 | margin: 0 auto; 182 | } 183 | 184 | .oauth-btn-google { 185 | width: 100%; 186 | } 187 | 188 | .or { 189 | margin: 16px 0; 190 | position: relative; 191 | 192 | hr { 193 | width: 100%; 194 | border: none; 195 | border-bottom: 1px solid var(--border-color); 196 | position: absolute; 197 | top: calc(50% - 0.5px); 198 | z-index: -1; 199 | } 200 | 201 | .text { 202 | width: 40px; 203 | background-color: var(--bg-color); 204 | margin: 0 auto; 205 | } 206 | } 207 | 208 | span { 209 | display: block; 210 | margin-top: 1rem; 211 | text-align: center; 212 | } 213 | } 214 | -------------------------------------------------------------------------------- /server/src/models/post.ts: -------------------------------------------------------------------------------- 1 | import dbPool from "../util/database.js"; 2 | 3 | import { IPost, PostCategory, PostStatus } from "../types/post/index.js"; 4 | 5 | export default class Post { 6 | static async create(post: Partial) { 7 | const { 8 | title, 9 | body = null, 10 | coverPath = null, 11 | category = null, 12 | publishDate = new Date(), 13 | status = "pub", 14 | userId, 15 | } = post; 16 | 17 | let conn; 18 | try { 19 | conn = await dbPool.getConnection(); 20 | const query = 21 | "INSERT INTO Post (title, body, coverPath, category, publishDate, status, userId) VALUES (?, ?, ?, ?, ?, ?, ?)"; 22 | return await conn.query(query, [ 23 | title, 24 | body, 25 | coverPath, 26 | category, 27 | publishDate ?? new Date(), 28 | status ?? "pub", 29 | userId, 30 | ]); 31 | } catch (err) { 32 | throw err; 33 | } finally { 34 | if (conn) { 35 | conn.release(); 36 | } 37 | } 38 | } 39 | 40 | static async delete(id: number, userId: number) { 41 | let conn; 42 | try { 43 | conn = await dbPool.getConnection(); 44 | const query = "DELETE FROM Post WHERE id = ? AND userId = ?"; 45 | return await conn.query(query, [id, userId]); 46 | } catch (err) { 47 | throw err; 48 | } finally { 49 | if (conn) { 50 | conn.release(); 51 | } 52 | } 53 | } 54 | 55 | static async getAll(options: { status?: PostStatus; category?: PostCategory; search?: string }) { 56 | const { status, category, search } = options; 57 | 58 | let conn; 59 | try { 60 | conn = await dbPool.getConnection(); 61 | 62 | let query = `SELECT 63 | p.*, 64 | u.name userName, 65 | u.avatarPath userAvatarPath, 66 | u.authMethod userAuthMethod 67 | FROM Post p JOIN User u 68 | ON p.userId = u.id 69 | WHERE p.status = ?`; 70 | const values: string[] = [status ?? "pub"]; 71 | 72 | if (category) { 73 | query += "\nAND p.category = ?"; 74 | values.push(category); 75 | } 76 | 77 | if (search) { 78 | query += "\nAND p.title LIKE ?"; 79 | values.push(`%${search.replace(/(_|%)/g, "\\$1")}%`); 80 | } 81 | 82 | return await conn.query(query, values); 83 | } catch (err) { 84 | throw err; 85 | } finally { 86 | if (conn) { 87 | conn.release(); 88 | } 89 | } 90 | } 91 | 92 | /** will be used when a user's profile is visited so not joining the user table */ 93 | static async getByUserId(userId: number, status = "pub") { 94 | let conn; 95 | try { 96 | conn = await dbPool.getConnection(); 97 | let query; 98 | if (status !== "*") { 99 | query = "SELECT * FROM Post WHERE userId = ? AND status = ?"; 100 | return await conn.query(query, [userId, status]); 101 | } else { 102 | query = "SELECT * FROM Post WHERE userId = ?"; 103 | return await conn.query(query, [userId]); 104 | } 105 | } catch (err) { 106 | throw err; 107 | } finally { 108 | if (conn) { 109 | conn.release(); 110 | } 111 | } 112 | } 113 | 114 | static async getOneById(id: number) { 115 | let conn; 116 | try { 117 | conn = await dbPool.getConnection(); 118 | const query = ` 119 | SELECT 120 | p.*, 121 | u.name userName, 122 | u.avatarPath userAvatarPath, 123 | u.authMethod userAuthMethod 124 | FROM Post p JOIN User u 125 | ON p.userId = u.id 126 | WHERE p.id = ?`; 127 | return (await conn.query(query, [id]))[0]; 128 | } catch (err) { 129 | throw err; 130 | } finally { 131 | if (conn) { 132 | conn.release(); 133 | } 134 | } 135 | } 136 | 137 | static async getOneXById(columns: string[], id: number) { 138 | let conn; 139 | try { 140 | conn = await dbPool.getConnection(); 141 | const query = `SELECT ${columns.join(",")} FROM Post WHERE id = ?`; 142 | return (await conn.query(query, id))[0]; 143 | } catch (err) { 144 | throw err; 145 | } finally { 146 | if (conn) { 147 | conn.release(); 148 | } 149 | } 150 | } 151 | 152 | /** update will be done based on both post id & logged in user's id */ 153 | static async update(update: Partial, id: number, userId: number) { 154 | const { title, body, coverPath, category, publishDate = null, status = "pub" } = update; 155 | 156 | let conn; 157 | try { 158 | conn = await dbPool.getConnection(); 159 | let query = ` 160 | UPDATE Post SET 161 | title = ?, 162 | body = ?, 163 | coverPath = ?, 164 | category = ?, 165 | ${publishDate ? "publishDate = ?," : ""} 166 | editDate = CURRENT_TIMESTAMP(), 167 | status = ? 168 | WHERE \`id\` = ? AND \`userId\` = ?`; 169 | 170 | let values; 171 | if (publishDate) { 172 | values = [title, body, coverPath, category, publishDate, status, id, userId]; 173 | } else { 174 | values = [title, body, coverPath, category, status, id, userId]; 175 | } 176 | 177 | return await conn.query(query, values); 178 | } catch (err) { 179 | throw err; 180 | } finally { 181 | if (conn) { 182 | conn.release(); 183 | } 184 | } 185 | } 186 | } 187 | -------------------------------------------------------------------------------- /server/src/routes/posts.ts: -------------------------------------------------------------------------------- 1 | import fs from "node:fs/promises"; 2 | import path from "node:path"; 3 | import express from "express"; 4 | import checkAuth from "../middlewares/checkAuth.js"; 5 | import Like from "../models/like.js"; 6 | import Post from "../models/post.js"; 7 | import PostChanges from "../models/post-changes.js"; 8 | import { ActionForbiddenError, ValidationError } from "../util/error.js"; 9 | import { processPost } from "../util/process-data.js"; 10 | import upload from "../util/upload.js"; 11 | 12 | import { IPost, PostCategory } from "../types/post/index.js"; 13 | 14 | // URL: /posts/... 15 | 16 | const router = express.Router(); 17 | 18 | router.get("/", async (req, res, next) => { 19 | try { 20 | const { category, search } = req.query; 21 | const posts = await Post.getAll({ category: category?.toString() as PostCategory, search: search?.toString() }); 22 | posts.forEach(processPost); 23 | res.json({ data: posts }); 24 | } catch (err) { 25 | next(err); 26 | } 27 | }); 28 | 29 | router.get("/:postId", checkAuth(true), async (req, res, next) => { 30 | try { 31 | const postId = Number(req.params.postId); 32 | const post = await Post.getOneById(postId); 33 | if (!post || (post.status !== "pub" && req.userId !== post.userId)) { 34 | res.status(404).json({ error: "post not found" }); 35 | return; 36 | } 37 | 38 | const postChangesData = await PostChanges.getOneXByPostId(["postId"], postId); 39 | if (postChangesData && req.userId === post.userId) { 40 | post.hasChanges = true; 41 | } 42 | 43 | const likeCount = await Like.getCountByPostId(postId); 44 | post.likeCount = Number(likeCount); 45 | 46 | if (req.userId) { 47 | const likeData = await Like.getOne(postId, req.userId); 48 | if (!likeData) { 49 | post.likedByMe = false; 50 | } else { 51 | post.likedByMe = Boolean(likeData.liked); 52 | } 53 | } 54 | 55 | processPost(post); 56 | res.json({ data: post }); 57 | } catch (err) { 58 | next(err); 59 | } 60 | }); 61 | 62 | router.post("/", checkAuth(), upload.single("cover"), async (req, res, next) => { 63 | try { 64 | if (!req.file) { 65 | throw new ValidationError("cover image is required"); 66 | } 67 | 68 | const { title, body, category } = req.body; 69 | const coverPath = `/${req.file.path}`; 70 | const { insertId } = await Post.create({ title, body, coverPath, category, userId: req.userId }); 71 | res.status(201).json({ data: Number(insertId) }); 72 | } catch (err) { 73 | next(err); 74 | } 75 | }); 76 | 77 | router.put("/:postId", checkAuth(), upload.single("cover"), async (req, res, next) => { 78 | try { 79 | const postId = Number(req.params.postId); 80 | 81 | const { title, body, category } = req.body; 82 | const postData = await Post.getOneXById(["coverPath", "status", "userId"], postId); 83 | if (!postData) { 84 | if (req.file) { 85 | await fs.unlink(path.join(process.cwd(), req.file.path)); 86 | } 87 | 88 | res.status(404).json({ error: "post not found" }); 89 | return; 90 | } 91 | 92 | const userId = Number(req.userId); 93 | let { coverPath, status: postStatus, userId: postUserId } = postData; 94 | if (userId !== postUserId) { 95 | if (req.file) { 96 | await fs.unlink(path.join(process.cwd(), req.file.path)); 97 | } 98 | 99 | throw new ActionForbiddenError(); 100 | } 101 | 102 | if (req.file) { 103 | if (coverPath) { 104 | await fs.unlink(path.join(process.cwd(), coverPath)); 105 | } 106 | 107 | coverPath = `/${req.file.path}`; 108 | } else if (!coverPath) { 109 | throw new ValidationError("cover image is required"); 110 | } 111 | 112 | const post: Partial = { title, body, coverPath, category }; 113 | if (postStatus === "draft") { 114 | post.publishDate = new Date(); 115 | } 116 | 117 | const postChangesData = await PostChanges.getOneXByPostId(["postId"], postId); 118 | if (postChangesData) { 119 | await PostChanges.delete(postId, userId); 120 | } 121 | 122 | await Post.update(post, postId, userId); 123 | res.sendStatus(204); 124 | } catch (err) { 125 | next(err); 126 | } 127 | }); 128 | 129 | router.delete("/:postId", checkAuth(), async (req, res, next) => { 130 | try { 131 | const postId = Number(req.params.postId); 132 | 133 | let { coverPath, userId } = await Post.getOneXById(["coverPath", "userId"], postId); 134 | if (req.userId !== userId) { 135 | throw new ActionForbiddenError(); 136 | } 137 | 138 | if (coverPath) { 139 | await fs.unlink(path.join(process.cwd(), coverPath)); 140 | } 141 | 142 | await Post.delete(postId, Number(req.userId)); 143 | res.sendStatus(204); 144 | } catch (err) { 145 | next(err); 146 | } 147 | }); 148 | 149 | router.post("/drafts", checkAuth(), upload.single("cover"), async (req, res, next) => { 150 | try { 151 | let { title, body, category } = req.body; 152 | title = title.trim(); 153 | body = body.trim(); 154 | 155 | if (title.trim().length === 0) { 156 | throw new ValidationError("title is required"); 157 | } 158 | 159 | const { insertId } = await Post.create({ 160 | title, 161 | body: body || null, 162 | ...(req.file && { coverPath: `/${req.file.path}` }), 163 | category: category || null, 164 | status: "draft", 165 | userId: Number(req.userId), 166 | }); 167 | res.status(201).json({ data: Number(insertId) }); 168 | } catch (err) { 169 | next(err); 170 | } 171 | }); 172 | 173 | export default router; 174 | -------------------------------------------------------------------------------- /client/src/components/PostForm/PostForm.scss: -------------------------------------------------------------------------------- 1 | .post-form { 2 | display: flex; 3 | 4 | .left { 5 | flex-grow: 1; 6 | } 7 | 8 | .right { 9 | max-width: 300px; 10 | width: 100%; 11 | flex-shrink: 0; 12 | margin-left: 16px; 13 | } 14 | 15 | .preview { 16 | width: 100%; 17 | margin-bottom: 1rem; 18 | overflow: hidden; 19 | position: relative; 20 | 21 | img { 22 | border-radius: 5px; 23 | width: 100%; 24 | max-height: 200px; 25 | object-fit: contain; 26 | display: block; 27 | margin: 5px auto 0; 28 | } 29 | 30 | button { 31 | all: unset; 32 | cursor: pointer; 33 | position: absolute; 34 | top: 0; 35 | right: 0; 36 | 37 | svg { 38 | width: 16px; 39 | height: 16px; 40 | } 41 | } 42 | } 43 | 44 | .error-msg { 45 | margin-top: 1rem; 46 | } 47 | 48 | .category-card, 49 | .status-card { 50 | margin-top: 1rem; 51 | padding: 1rem; 52 | border: 1px solid #000; 53 | border-radius: 5px; 54 | 55 | h2 { 56 | margin-bottom: 10px; 57 | } 58 | } 59 | 60 | .category-card .category { 61 | display: flex; 62 | align-items: center; 63 | text-transform: capitalize; 64 | margin-right: 1rem; 65 | 66 | label { 67 | margin-bottom: 0; 68 | line-height: 1; 69 | } 70 | } 71 | 72 | .status-card .actions { 73 | margin-top: 1rem; 74 | display: flex; 75 | justify-content: center; 76 | 77 | .btn { 78 | margin-right: 10px; 79 | 80 | &:last-child { 81 | margin-right: 0; 82 | } 83 | } 84 | } 85 | 86 | // react quill's customizations 87 | .ql-toolbar { 88 | border: 1px solid #000; 89 | border-radius: 5px 5px 0 0; 90 | } 91 | 92 | .ql-snow.ql-toolbar button:not(.ql-active), 93 | .ql-snow .ql-toolbar button:not(.ql-active) { 94 | color: #dedede; 95 | } 96 | 97 | .ql-snow.ql-toolbar button:not(.ql-active) .ql-fill, 98 | .ql-snow .ql-toolbar button:not(.ql-active) .ql-fill, 99 | .ql-snow.ql-toolbar button:not(.ql-active) .ql-stroke.ql-fill, 100 | .ql-snow .ql-toolbar button:not(.ql-active) .ql-stroke.ql-fill { 101 | fill: #dedede; 102 | } 103 | 104 | .ql-snow.ql-toolbar button:not(.ql-active) .ql-stroke, 105 | .ql-snow .ql-toolbar button:not(.ql-active) .ql-stroke, 106 | .ql-snow.ql-toolbar button:not(.ql-active) .ql-stroke-miter, 107 | .ql-snow .ql-toolbar button:not(.ql-active) .ql-stroke-miter { 108 | stroke: #dedede; 109 | } 110 | 111 | .ql-snow.ql-toolbar button:hover:not(.ql-active), 112 | .ql-snow .ql-toolbar button:hover:not(.ql-active), 113 | .ql-snow.ql-toolbar button.ql-active, 114 | .ql-snow .ql-toolbar button.ql-active { 115 | stroke: var(--primary-color); 116 | } 117 | 118 | .ql-snow.ql-toolbar button:hover:not(.ql-active) .ql-fill, 119 | .ql-snow .ql-toolbar button:hover:not(.ql-active) .ql-fill, 120 | .ql-snow.ql-toolbar button:hover:not(.ql-active) .ql-stroke.ql-fill, 121 | .ql-snow .ql-toolbar button:hover:not(.ql-active) .ql-stroke.ql-fill, 122 | .ql-snow.ql-toolbar button.ql-active .ql-fill, 123 | .ql-snow .ql-toolbar button.ql-active .ql-fill, 124 | .ql-snow.ql-toolbar button.ql-active .ql-stroke.ql-fill, 125 | .ql-snow .ql-toolbar button.ql-active .ql-stroke.ql-fill { 126 | fill: var(--primary-color); 127 | } 128 | 129 | .ql-snow.ql-toolbar button:hover:not(.ql-active) .ql-stroke, 130 | .ql-snow .ql-toolbar button:hover:not(.ql-active) .ql-stroke, 131 | .ql-snow.ql-toolbar button:hover:not(.ql-active) .ql-stroke-miter, 132 | .ql-snow .ql-toolbar button:hover:not(.ql-active) .ql-stroke-miter, 133 | .ql-snow.ql-toolbar button.ql-active .ql-stroke, 134 | .ql-snow .ql-toolbar button.ql-active .ql-stroke, 135 | .ql-snow.ql-toolbar button.ql-active .ql-stroke-miter, 136 | .ql-snow .ql-toolbar button.ql-active .ql-stroke-miter { 137 | stroke: var(--primary-color); 138 | } 139 | 140 | .ql-snow .ql-picker { 141 | color: #dedede; 142 | 143 | &-label.ql-active { 144 | color: var(--primary-color); 145 | 146 | .ql-stroke { 147 | stroke: var(--primary-color); 148 | } 149 | } 150 | 151 | .ql-stroke { 152 | stroke: #dedede; 153 | } 154 | 155 | &:hover { 156 | .ql-picker-label { 157 | color: var(--primary-color); 158 | } 159 | 160 | .ql-stroke { 161 | stroke: var(--primary-color); 162 | } 163 | } 164 | } 165 | 166 | .ql-picker-options { 167 | background-color: var(--bg-color); 168 | border-radius: 5px; 169 | border: none !important; 170 | color: #fff; 171 | 172 | .ql-picker-item.ql-selected, 173 | .ql-picker-item:hover { 174 | color: var(--primary-color); 175 | } 176 | } 177 | 178 | .ql-snow .ql-tooltip { 179 | background-color: var(--bg-color); 180 | border-radius: 5px; 181 | border: none; 182 | box-shadow: 0 -2px 10px rgba(0, 0, 0, 0.7); 183 | color: #fff; 184 | padding: 5px 12px; 185 | white-space: nowrap; 186 | 187 | input[type="text"] { 188 | border: none; 189 | } 190 | } 191 | 192 | .ql-snow .ql-editor pre.ql-syntax { 193 | background-color: var(--bg-color); 194 | } 195 | 196 | .ql-container { 197 | border-radius: 0 0 5px 5px; 198 | background-color: #2f2f2f; 199 | border: 1px solid #000; 200 | font-size: 1rem; 201 | 202 | a { 203 | color: var(--primary-color); 204 | } 205 | } 206 | 207 | .ql-editor { 208 | font-family: var(--font); 209 | } 210 | 211 | @media screen and (max-width: 768px) { 212 | display: block; 213 | 214 | .right { 215 | max-width: none; 216 | margin-left: 0; 217 | } 218 | } 219 | } 220 | -------------------------------------------------------------------------------- /client/src/components/PostForm/PostForm.jsx: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | import { useState } from "react"; 3 | import { Trash2 } from "react-feather"; 4 | import ReactQuill from "react-quill"; 5 | import { useNavigate } from "react-router-dom"; 6 | import "react-quill/dist/quill.snow.css"; 7 | import categories from "../../categories"; 8 | import "./PostForm.scss"; 9 | 10 | const modules = { 11 | toolbar: [ 12 | [{ header: [1, 2, 3, 4, 5, 6, false] }], 13 | ["bold", "italic", "underline", "strike"], // toggled buttons 14 | ["blockquote", "code-block"], 15 | [{ align: [] }], 16 | ["image"], 17 | [{ list: "ordered" }, { list: "bullet" }], 18 | [{ script: "sub" }, { script: "super" }], // superscript/subscript 19 | ["clean"], // remove formatting button 20 | ], 21 | }; 22 | 23 | // to be used to create & edit posts 24 | export default function PostForm({ defaults, changes }) { 25 | const [title, setTitle] = useState(defaults ? defaults.title : ""); 26 | const [cover, setCover] = useState(null); 27 | const [body, setBody] = useState(defaults?.body || ""); 28 | const [category, setCategory] = useState(defaults?.category || ""); 29 | const [isSubmitting, setIsSubmitting] = useState(false); 30 | const [error, setError] = useState(null); 31 | const navigate = useNavigate(); 32 | 33 | async function handleDraftSubmit() { 34 | try { 35 | if (title.trim().length === 0) { 36 | throw new Error("title is required"); 37 | } 38 | 39 | setError(null); 40 | setIsSubmitting(true); 41 | 42 | const post = new FormData(); 43 | post.set("title", title); 44 | post.set("body", body); 45 | post.set("category", category); 46 | if (cover) { 47 | post.set("cover", cover); 48 | } 49 | 50 | if (defaults) { 51 | await axios.put(`/posts/changes/${defaults.id}`, post); 52 | navigate(`/posts/${defaults.id}`); 53 | } else { 54 | const res = await axios.post("/posts/drafts", post); 55 | const postId = res.data.data; 56 | navigate(`/posts/${postId}`); 57 | } 58 | } catch (err) { 59 | console.error(err); 60 | if (err.response) { 61 | setError(err.response.data.error); 62 | } else { 63 | setError(err.message); 64 | } 65 | } finally { 66 | setIsSubmitting(false); 67 | } 68 | } 69 | 70 | async function handleSubmit(e) { 71 | try { 72 | e.preventDefault(); 73 | 74 | if (body.trim() === "") { 75 | throw new Error("post body cannot be empty"); 76 | } else if (category === null) { 77 | throw new Error("choose a category"); 78 | } 79 | 80 | setError(null); 81 | setIsSubmitting(true); 82 | 83 | const post = new FormData(); 84 | post.set("title", title); 85 | post.set("body", body); 86 | post.set("category", category); 87 | if (cover) { 88 | post.set("cover", cover); 89 | } 90 | 91 | if (defaults) { 92 | await axios.put(`/posts/${defaults.id}`, post); 93 | navigate(`/posts/${defaults.id}`); 94 | } else { 95 | const res = await axios.post("/posts", post); 96 | const postId = res.data.data; 97 | navigate(`/posts/${postId}`); 98 | } 99 | } catch (err) { 100 | console.error(err); 101 | if (err.response) { 102 | setError(err.response.data.error); 103 | } else { 104 | setError(err.message); 105 | } 106 | } finally { 107 | setIsSubmitting(false); 108 | } 109 | } 110 | 111 | return ( 112 |
113 |
114 |
115 | 116 | setTitle(e.target.value)} 123 | /> 124 |
125 | 126 |
127 | 128 | setCover(e.target.files[0])} 134 | /> 135 |
136 | 137 | {(cover || defaults?.coverUrl) && ( 138 |
139 | Preview: 140 | {cover ? : } 141 | {/* user can only remove the new cover not the old one */} 142 | {cover && ( 143 | 146 | )} 147 |
148 | )} 149 | 150 |
151 | 152 | 153 |
154 |
155 | 156 |
157 |
158 |

Category

159 | 160 | {categories.map((cat, i) => ( 161 |
162 | setCategory(cat)} 167 | checked={cat === category} 168 | /> 169 |   170 | 171 |
172 | ))} 173 |
174 | 175 |
176 |

Publish

177 | Status: {defaults ? `${defaults.status}${changes ? " (draft version)" : ""}` : "new"} 178 |
179 | Visibility: Public 180 |
181 | 184 | 185 | 188 |
189 | {error &&
{error}
} 190 |
191 |
192 |
193 | ); 194 | } 195 | -------------------------------------------------------------------------------- /client/src/pages/Post/Post.jsx: -------------------------------------------------------------------------------- 1 | import axios from "axios"; 2 | import DOMPurify from "dompurify"; 3 | import moment from "moment"; 4 | import { useContext, useState } from "react"; 5 | import { Link, useNavigate, useParams } from "react-router-dom"; 6 | import { Edit, Heart, Trash2 } from "react-feather"; 7 | import Comment from "components/Comment/Comment"; 8 | import Loader from "components/Loader/Loader"; 9 | import PostPreview from "components/PostPreview/PostPreview"; 10 | import { authContext } from "contexts/auth"; 11 | import { useAxiosGet } from "hooks/useAxiosGet"; 12 | import "./Post.scss"; 13 | 14 | export default function Post() { 15 | const { id } = useParams(); 16 | const { data: post, error } = useAxiosGet(`/posts/${id}`); 17 | const { currentUser } = useContext(authContext); 18 | const navigate = useNavigate(); 19 | 20 | async function handleDelete() { 21 | if (!confirm("This post will be permanently deleted. Are you sure?")) { 22 | return; 23 | } 24 | 25 | try { 26 | await axios.delete(`/posts/${id}`); 27 | navigate("/"); 28 | } catch (err) { 29 | console.error(err); 30 | if (err.response) { 31 | alert(err.response.data.error); 32 | } else { 33 | alert(err.message); 34 | } 35 | } 36 | } 37 | 38 | let content; 39 | if (error) { 40 | content =
{error}
; 41 | } else if (post) { 42 | content = ( 43 | <> 44 |
45 | {post.hasChanges && ( 46 | 47 | Go to draft version 48 | 49 | )} 50 | 51 |
52 | {post.category ? post.category : "---"} 53 | {post.status === "draft" && " (draft)"} 54 |
55 | 56 |

{post.title}

57 |
{post.coverUrl ? Cover Image : "---"}
58 | 59 |
60 | 61 | Avatar 62 | 63 |
64 | 65 | Written by {post.user.name} 66 | 67 |
68 | 69 | {post.publishDate && `${moment(post.publishDate).format("MMM Do YYYY")}, `}edited on{" "} 70 | {moment(post.editData).format("MMM Do YYYY")} 71 | 72 |
73 | 74 | {currentUser?.id === post.user.id && ( 75 |
76 | 77 | 78 | 79 | 80 | 83 |
84 | )} 85 |
86 | 87 |
93 | 94 | 95 | 96 | 97 |
98 | 99 | {post.status === "pub" && } 100 | 101 | ); 102 | } else { 103 | content = ; 104 | } 105 | 106 | return ( 107 |
108 | {content} 109 |
110 | ); 111 | } 112 | 113 | function PostLike({ postId, liked: likedByMe, likeCount }) { 114 | const { currentUser } = useContext(authContext); 115 | const [liked, setLiked] = useState(likedByMe); 116 | if (likedByMe && !liked) { 117 | likeCount -= 1; 118 | } else if (!likedByMe && liked) { 119 | likeCount += 1; 120 | } 121 | 122 | async function handeClick() { 123 | try { 124 | if (!currentUser) { 125 | alert("you need to be logged in!"); 126 | return; 127 | } 128 | 129 | setLiked(!liked); 130 | await axios.put(`/likes/${postId}`); 131 | } catch (err) { 132 | console.error(err); 133 | } 134 | } 135 | 136 | return ( 137 |
138 | 142 | {likeCount} likes 143 |
144 | ); 145 | } 146 | 147 | function PostComments({ postId }) { 148 | const [comment, setComment] = useState(""); 149 | const [isSubmitting, setIsSubmitting] = useState(false); 150 | const [error, setError] = useState(null); 151 | const { currentUser } = useContext(authContext); 152 | const { data: comments, setData: setComments, error: getCommentsError } = useAxiosGet(`/comments/${postId}`); 153 | 154 | async function handleDelete(commentId) { 155 | try { 156 | if (!confirm("This comment will be deleted. Are you sure?")) { 157 | return; 158 | } 159 | 160 | await axios.delete(`/comments/${commentId}`); 161 | setComments(comments.filter((comment) => comment.id !== commentId)); 162 | } catch (err) { 163 | console.error(err); 164 | if (err.response) { 165 | setError(err.response.data.error); 166 | } else { 167 | setError(err.message); 168 | } 169 | } 170 | } 171 | 172 | async function handleTranslation(commentId) { 173 | try { 174 | const res = await axios.get(`/comments/translation/${commentId}`); 175 | return res.data.data; 176 | } catch (err) { 177 | console.error(err); 178 | if (err.response) { 179 | setError(err.response.data.error); 180 | } else { 181 | setError(err.message); 182 | } 183 | } 184 | } 185 | 186 | let content; 187 | if (getCommentsError) { 188 | content =
{getCommentsError}
; 189 | } else if (comments) { 190 | content = ( 191 | <> 192 | {currentUser ? ( 193 |
194 |
195 | 196 |
197 | {error &&
{error}
} 198 | 201 |
202 | ) : ( 203 |
204 | Login to comment. 205 |
206 | )} 207 | {comments.map((comment) => ( 208 | 214 | ))} 215 | 216 | ); 217 | } else { 218 | content = ; 219 | } 220 | 221 | async function handleSubmit(e) { 222 | try { 223 | e.preventDefault(); 224 | setIsSubmitting(true); 225 | 226 | if (comment.trim().length === 0) { 227 | throw new Error("comment cannot be empty"); 228 | } 229 | 230 | setError(null); 231 | 232 | const res = await axios.post("/comments", { comment: comment.trim(), postId }); 233 | const newComment = { 234 | id: res.data.data, 235 | body: comment.trim(), 236 | postId, 237 | user: { 238 | id: currentUser.id, 239 | name: currentUser.name, 240 | avatarUrl: currentUser.avatarUrl, 241 | }, 242 | }; 243 | 244 | setComment(""); 245 | setComments([...comments, newComment]); 246 | } catch (err) { 247 | console.error(err); 248 | if (err.response) { 249 | setError(err.response.data.error); 250 | } else { 251 | setError(err.message); 252 | } 253 | } finally { 254 | setIsSubmitting(false); 255 | } 256 | } 257 | 258 | return ( 259 |
260 |

Comments {comments && `(${comments.length})`}

261 | {content} 262 |
263 | ); 264 | } 265 | 266 | function OtherPosts({ category, mainPostId }) { 267 | const { data: posts, error } = useAxiosGet(`/posts?category=${category}`); 268 | 269 | let content; 270 | if (error) { 271 | content =
{error}
; 272 | } else if (posts) { 273 | content = ( 274 | <> 275 | {posts.length === 1 && "no other posts found in this category"} 276 | {posts 277 | .filter((post) => post.id != mainPostId) 278 | .map((post) => { 279 | return ; 280 | })} 281 | 282 | ); 283 | } else { 284 | content = ; 285 | } 286 | 287 | return ( 288 |
289 |

Other Posts You May Like

290 | {content} 291 |
292 | ); 293 | } 294 | -------------------------------------------------------------------------------- /client/yarn.lock: -------------------------------------------------------------------------------- 1 | # THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. 2 | # yarn lockfile v1 3 | 4 | 5 | "@ampproject/remapping@^2.1.0": 6 | version "2.2.0" 7 | resolved "https://registry.yarnpkg.com/@ampproject/remapping/-/remapping-2.2.0.tgz#56c133824780de3174aed5ab6834f3026790154d" 8 | integrity sha512-qRmjj8nj9qmLTQXXmaR1cck3UXSRMPrbsLJAasZpF+t3riI71BXed5ebIOYwQntykeZuhjsdweEc9BxH5Jc26w== 9 | dependencies: 10 | "@jridgewell/gen-mapping" "^0.1.0" 11 | "@jridgewell/trace-mapping" "^0.3.9" 12 | 13 | "@babel/code-frame@^7.18.6": 14 | version "7.18.6" 15 | resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.18.6.tgz#3b25d38c89600baa2dcc219edfa88a74eb2c427a" 16 | integrity sha512-TDCmlK5eOvH+eH7cdAFlNXeVJqWIQ7gW9tY1GJIpUtFb6CmjVyq2VM3u71bOyR8CRihcCgMUYoDNyLXao3+70Q== 17 | dependencies: 18 | "@babel/highlight" "^7.18.6" 19 | 20 | "@babel/compat-data@^7.19.3": 21 | version "7.19.4" 22 | resolved "https://registry.yarnpkg.com/@babel/compat-data/-/compat-data-7.19.4.tgz#95c86de137bf0317f3a570e1b6e996b427299747" 23 | integrity sha512-CHIGpJcUQ5lU9KrPHTjBMhVwQG6CQjxfg36fGXl3qk/Gik1WwWachaXFuo0uCWJT/mStOKtcbFJCaVLihC1CMw== 24 | 25 | "@babel/core@^7.18.13": 26 | version "7.19.3" 27 | resolved "https://registry.yarnpkg.com/@babel/core/-/core-7.19.3.tgz#2519f62a51458f43b682d61583c3810e7dcee64c" 28 | integrity sha512-WneDJxdsjEvyKtXKsaBGbDeiyOjR5vYq4HcShxnIbG0qixpoHjI3MqeZM9NDvsojNCEBItQE4juOo/bU6e72gQ== 29 | dependencies: 30 | "@ampproject/remapping" "^2.1.0" 31 | "@babel/code-frame" "^7.18.6" 32 | "@babel/generator" "^7.19.3" 33 | "@babel/helper-compilation-targets" "^7.19.3" 34 | "@babel/helper-module-transforms" "^7.19.0" 35 | "@babel/helpers" "^7.19.0" 36 | "@babel/parser" "^7.19.3" 37 | "@babel/template" "^7.18.10" 38 | "@babel/traverse" "^7.19.3" 39 | "@babel/types" "^7.19.3" 40 | convert-source-map "^1.7.0" 41 | debug "^4.1.0" 42 | gensync "^1.0.0-beta.2" 43 | json5 "^2.2.1" 44 | semver "^6.3.0" 45 | 46 | "@babel/generator@^7.19.3", "@babel/generator@^7.19.4": 47 | version "7.19.5" 48 | resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.19.5.tgz#da3f4b301c8086717eee9cab14da91b1fa5dcca7" 49 | integrity sha512-DxbNz9Lz4aMZ99qPpO1raTbcrI1ZeYh+9NR9qhfkQIbFtVEqotHojEBxHzmxhVONkGt6VyrqVQcgpefMy9pqcg== 50 | dependencies: 51 | "@babel/types" "^7.19.4" 52 | "@jridgewell/gen-mapping" "^0.3.2" 53 | jsesc "^2.5.1" 54 | 55 | "@babel/helper-annotate-as-pure@^7.18.6": 56 | version "7.18.6" 57 | resolved "https://registry.yarnpkg.com/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.18.6.tgz#eaa49f6f80d5a33f9a5dd2276e6d6e451be0a6bb" 58 | integrity sha512-duORpUiYrEpzKIop6iNbjnwKLAKnJ47csTyRACyEmWj0QdUrm5aqNJGHSSEQSUAvNW0ojX0dOmK9dZduvkfeXA== 59 | dependencies: 60 | "@babel/types" "^7.18.6" 61 | 62 | "@babel/helper-compilation-targets@^7.19.3": 63 | version "7.19.3" 64 | resolved "https://registry.yarnpkg.com/@babel/helper-compilation-targets/-/helper-compilation-targets-7.19.3.tgz#a10a04588125675d7c7ae299af86fa1b2ee038ca" 65 | integrity sha512-65ESqLGyGmLvgR0mst5AdW1FkNlj9rQsCKduzEoEPhBCDFGXvz2jW6bXFG6i0/MrV2s7hhXjjb2yAzcPuQlLwg== 66 | dependencies: 67 | "@babel/compat-data" "^7.19.3" 68 | "@babel/helper-validator-option" "^7.18.6" 69 | browserslist "^4.21.3" 70 | semver "^6.3.0" 71 | 72 | "@babel/helper-environment-visitor@^7.18.9": 73 | version "7.18.9" 74 | resolved "https://registry.yarnpkg.com/@babel/helper-environment-visitor/-/helper-environment-visitor-7.18.9.tgz#0c0cee9b35d2ca190478756865bb3528422f51be" 75 | integrity sha512-3r/aACDJ3fhQ/EVgFy0hpj8oHyHpQc+LPtJoY9SzTThAsStm4Ptegq92vqKoE3vD706ZVFWITnMnxucw+S9Ipg== 76 | 77 | "@babel/helper-function-name@^7.19.0": 78 | version "7.19.0" 79 | resolved "https://registry.yarnpkg.com/@babel/helper-function-name/-/helper-function-name-7.19.0.tgz#941574ed5390682e872e52d3f38ce9d1bef4648c" 80 | integrity sha512-WAwHBINyrpqywkUH0nTnNgI5ina5TFn85HKS0pbPDfxFfhyR/aNQEn4hGi1P1JyT//I0t4OgXUlofzWILRvS5w== 81 | dependencies: 82 | "@babel/template" "^7.18.10" 83 | "@babel/types" "^7.19.0" 84 | 85 | "@babel/helper-hoist-variables@^7.18.6": 86 | version "7.18.6" 87 | resolved "https://registry.yarnpkg.com/@babel/helper-hoist-variables/-/helper-hoist-variables-7.18.6.tgz#d4d2c8fb4baeaa5c68b99cc8245c56554f926678" 88 | integrity sha512-UlJQPkFqFULIcyW5sbzgbkxn2FKRgwWiRexcuaR8RNJRy8+LLveqPjwZV/bwrLZCN0eUHD/x8D0heK1ozuoo6Q== 89 | dependencies: 90 | "@babel/types" "^7.18.6" 91 | 92 | "@babel/helper-module-imports@^7.18.6": 93 | version "7.18.6" 94 | resolved "https://registry.yarnpkg.com/@babel/helper-module-imports/-/helper-module-imports-7.18.6.tgz#1e3ebdbbd08aad1437b428c50204db13c5a3ca6e" 95 | integrity sha512-0NFvs3VkuSYbFi1x2Vd6tKrywq+z/cLeYC/RJNFrIX/30Bf5aiGYbtvGXolEktzJH8o5E5KJ3tT+nkxuuZFVlA== 96 | dependencies: 97 | "@babel/types" "^7.18.6" 98 | 99 | "@babel/helper-module-transforms@^7.19.0": 100 | version "7.19.0" 101 | resolved "https://registry.yarnpkg.com/@babel/helper-module-transforms/-/helper-module-transforms-7.19.0.tgz#309b230f04e22c58c6a2c0c0c7e50b216d350c30" 102 | integrity sha512-3HBZ377Fe14RbLIA+ac3sY4PTgpxHVkFrESaWhoI5PuyXPBBX8+C34qblV9G89ZtycGJCmCI/Ut+VUDK4bltNQ== 103 | dependencies: 104 | "@babel/helper-environment-visitor" "^7.18.9" 105 | "@babel/helper-module-imports" "^7.18.6" 106 | "@babel/helper-simple-access" "^7.18.6" 107 | "@babel/helper-split-export-declaration" "^7.18.6" 108 | "@babel/helper-validator-identifier" "^7.18.6" 109 | "@babel/template" "^7.18.10" 110 | "@babel/traverse" "^7.19.0" 111 | "@babel/types" "^7.19.0" 112 | 113 | "@babel/helper-plugin-utils@^7.18.6", "@babel/helper-plugin-utils@^7.19.0": 114 | version "7.19.0" 115 | resolved "https://registry.yarnpkg.com/@babel/helper-plugin-utils/-/helper-plugin-utils-7.19.0.tgz#4796bb14961521f0f8715990bee2fb6e51ce21bf" 116 | integrity sha512-40Ryx7I8mT+0gaNxm8JGTZFUITNqdLAgdg0hXzeVZxVD6nFsdhQvip6v8dqkRHzsz1VFpFAaOCHNn0vKBL7Czw== 117 | 118 | "@babel/helper-simple-access@^7.18.6": 119 | version "7.19.4" 120 | resolved "https://registry.yarnpkg.com/@babel/helper-simple-access/-/helper-simple-access-7.19.4.tgz#be553f4951ac6352df2567f7daa19a0ee15668e7" 121 | integrity sha512-f9Xq6WqBFqaDfbCzn2w85hwklswz5qsKlh7f08w4Y9yhJHpnNC0QemtSkK5YyOY8kPGvyiwdzZksGUhnGdaUIg== 122 | dependencies: 123 | "@babel/types" "^7.19.4" 124 | 125 | "@babel/helper-split-export-declaration@^7.18.6": 126 | version "7.18.6" 127 | resolved "https://registry.yarnpkg.com/@babel/helper-split-export-declaration/-/helper-split-export-declaration-7.18.6.tgz#7367949bc75b20c6d5a5d4a97bba2824ae8ef075" 128 | integrity sha512-bde1etTx6ZyTmobl9LLMMQsaizFVZrquTEHOqKeQESMKo4PlObf+8+JA25ZsIpZhT/WEd39+vOdLXAFG/nELpA== 129 | dependencies: 130 | "@babel/types" "^7.18.6" 131 | 132 | "@babel/helper-string-parser@^7.19.4": 133 | version "7.19.4" 134 | resolved "https://registry.yarnpkg.com/@babel/helper-string-parser/-/helper-string-parser-7.19.4.tgz#38d3acb654b4701a9b77fb0615a96f775c3a9e63" 135 | integrity sha512-nHtDoQcuqFmwYNYPz3Rah5ph2p8PFeFCsZk9A/48dPc/rGocJ5J3hAAZ7pb76VWX3fZKu+uEr/FhH5jLx7umrw== 136 | 137 | "@babel/helper-validator-identifier@^7.18.6", "@babel/helper-validator-identifier@^7.19.1": 138 | version "7.19.1" 139 | resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.19.1.tgz#7eea834cf32901ffdc1a7ee555e2f9c27e249ca2" 140 | integrity sha512-awrNfaMtnHUr653GgGEs++LlAvW6w+DcPrOliSMXWCKo597CwL5Acf/wWdNkf/tfEQE3mjkeD1YOVZOUV/od1w== 141 | 142 | "@babel/helper-validator-option@^7.18.6": 143 | version "7.18.6" 144 | resolved "https://registry.yarnpkg.com/@babel/helper-validator-option/-/helper-validator-option-7.18.6.tgz#bf0d2b5a509b1f336099e4ff36e1a63aa5db4db8" 145 | integrity sha512-XO7gESt5ouv/LRJdrVjkShckw6STTaB7l9BrpBaAHDeF5YZT+01PCwmR0SJHnkW6i8OwW/EVWRShfi4j2x+KQw== 146 | 147 | "@babel/helpers@^7.19.0": 148 | version "7.19.4" 149 | resolved "https://registry.yarnpkg.com/@babel/helpers/-/helpers-7.19.4.tgz#42154945f87b8148df7203a25c31ba9a73be46c5" 150 | integrity sha512-G+z3aOx2nfDHwX/kyVii5fJq+bgscg89/dJNWpYeKeBv3v9xX8EIabmx1k6u9LS04H7nROFVRVK+e3k0VHp+sw== 151 | dependencies: 152 | "@babel/template" "^7.18.10" 153 | "@babel/traverse" "^7.19.4" 154 | "@babel/types" "^7.19.4" 155 | 156 | "@babel/highlight@^7.18.6": 157 | version "7.18.6" 158 | resolved "https://registry.yarnpkg.com/@babel/highlight/-/highlight-7.18.6.tgz#81158601e93e2563795adcbfbdf5d64be3f2ecdf" 159 | integrity sha512-u7stbOuYjaPezCuLj29hNW1v64M2Md2qupEKP1fHc7WdOA3DgLh37suiSrZYY7haUB7iBeQZ9P1uiRF359do3g== 160 | dependencies: 161 | "@babel/helper-validator-identifier" "^7.18.6" 162 | chalk "^2.0.0" 163 | js-tokens "^4.0.0" 164 | 165 | "@babel/parser@^7.18.10", "@babel/parser@^7.19.3", "@babel/parser@^7.19.4": 166 | version "7.19.4" 167 | resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.19.4.tgz#03c4339d2b8971eb3beca5252bafd9b9f79db3dc" 168 | integrity sha512-qpVT7gtuOLjWeDTKLkJ6sryqLliBaFpAtGeqw5cs5giLldvh+Ch0plqnUMKoVAUS6ZEueQQiZV+p5pxtPitEsA== 169 | 170 | "@babel/plugin-syntax-jsx@^7.18.6": 171 | version "7.18.6" 172 | resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.18.6.tgz#a8feef63b010150abd97f1649ec296e849943ca0" 173 | integrity sha512-6mmljtAedFGTWu2p/8WIORGwy+61PLgOMPOdazc7YoJ9ZCWUyFy3A6CpPkRKLKD1ToAesxX8KGEViAiLo9N+7Q== 174 | dependencies: 175 | "@babel/helper-plugin-utils" "^7.18.6" 176 | 177 | "@babel/plugin-transform-react-jsx-development@^7.18.6": 178 | version "7.18.6" 179 | resolved "https://registry.yarnpkg.com/@babel/plugin-transform-react-jsx-development/-/plugin-transform-react-jsx-development-7.18.6.tgz#dbe5c972811e49c7405b630e4d0d2e1380c0ddc5" 180 | integrity sha512-SA6HEjwYFKF7WDjWcMcMGUimmw/nhNRDWxr+KaLSCrkD/LMDBvWRmHAYgE1HDeF8KUuI8OAu+RT6EOtKxSW2qA== 181 | dependencies: 182 | "@babel/plugin-transform-react-jsx" "^7.18.6" 183 | 184 | "@babel/plugin-transform-react-jsx-self@^7.18.6": 185 | version "7.18.6" 186 | resolved "https://registry.yarnpkg.com/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.18.6.tgz#3849401bab7ae8ffa1e3e5687c94a753fc75bda7" 187 | integrity sha512-A0LQGx4+4Jv7u/tWzoJF7alZwnBDQd6cGLh9P+Ttk4dpiL+J5p7NSNv/9tlEFFJDq3kjxOavWmbm6t0Gk+A3Ig== 188 | dependencies: 189 | "@babel/helper-plugin-utils" "^7.18.6" 190 | 191 | "@babel/plugin-transform-react-jsx-source@^7.18.6": 192 | version "7.18.6" 193 | resolved "https://registry.yarnpkg.com/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.18.6.tgz#06e9ae8a14d2bc19ce6e3c447d842032a50598fc" 194 | integrity sha512-utZmlASneDfdaMh0m/WausbjUjEdGrQJz0vFK93d7wD3xf5wBtX219+q6IlCNZeguIcxS2f/CvLZrlLSvSHQXw== 195 | dependencies: 196 | "@babel/helper-plugin-utils" "^7.18.6" 197 | 198 | "@babel/plugin-transform-react-jsx@^7.18.10", "@babel/plugin-transform-react-jsx@^7.18.6": 199 | version "7.19.0" 200 | resolved "https://registry.yarnpkg.com/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.19.0.tgz#b3cbb7c3a00b92ec8ae1027910e331ba5c500eb9" 201 | integrity sha512-UVEvX3tXie3Szm3emi1+G63jyw1w5IcMY0FSKM+CRnKRI5Mr1YbCNgsSTwoTwKphQEG9P+QqmuRFneJPZuHNhg== 202 | dependencies: 203 | "@babel/helper-annotate-as-pure" "^7.18.6" 204 | "@babel/helper-module-imports" "^7.18.6" 205 | "@babel/helper-plugin-utils" "^7.19.0" 206 | "@babel/plugin-syntax-jsx" "^7.18.6" 207 | "@babel/types" "^7.19.0" 208 | 209 | "@babel/template@^7.18.10": 210 | version "7.18.10" 211 | resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.18.10.tgz#6f9134835970d1dbf0835c0d100c9f38de0c5e71" 212 | integrity sha512-TI+rCtooWHr3QJ27kJxfjutghu44DLnasDMwpDqCXVTal9RLp3RSYNh4NdBrRP2cQAoG9A8juOQl6P6oZG4JxA== 213 | dependencies: 214 | "@babel/code-frame" "^7.18.6" 215 | "@babel/parser" "^7.18.10" 216 | "@babel/types" "^7.18.10" 217 | 218 | "@babel/traverse@^7.19.0", "@babel/traverse@^7.19.3", "@babel/traverse@^7.19.4": 219 | version "7.19.4" 220 | resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.19.4.tgz#f117820e18b1e59448a6c1fa9d0ff08f7ac459a8" 221 | integrity sha512-w3K1i+V5u2aJUOXBFFC5pveFLmtq1s3qcdDNC2qRI6WPBQIDaKFqXxDEqDO/h1dQ3HjsZoZMyIy6jGLq0xtw+g== 222 | dependencies: 223 | "@babel/code-frame" "^7.18.6" 224 | "@babel/generator" "^7.19.4" 225 | "@babel/helper-environment-visitor" "^7.18.9" 226 | "@babel/helper-function-name" "^7.19.0" 227 | "@babel/helper-hoist-variables" "^7.18.6" 228 | "@babel/helper-split-export-declaration" "^7.18.6" 229 | "@babel/parser" "^7.19.4" 230 | "@babel/types" "^7.19.4" 231 | debug "^4.1.0" 232 | globals "^11.1.0" 233 | 234 | "@babel/types@^7.18.10", "@babel/types@^7.18.6", "@babel/types@^7.19.0", "@babel/types@^7.19.3", "@babel/types@^7.19.4": 235 | version "7.19.4" 236 | resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.19.4.tgz#0dd5c91c573a202d600490a35b33246fed8a41c7" 237 | integrity sha512-M5LK7nAeS6+9j7hAq+b3fQs+pNfUtTGq+yFFfHnauFA8zQtLRfmuipmsKDKKLuyG+wC8ABW43A153YNawNTEtw== 238 | dependencies: 239 | "@babel/helper-string-parser" "^7.19.4" 240 | "@babel/helper-validator-identifier" "^7.19.1" 241 | to-fast-properties "^2.0.0" 242 | 243 | "@esbuild/android-arm@0.15.10": 244 | version "0.15.10" 245 | resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.15.10.tgz#a5f9432eb221afc243c321058ef25fe899886892" 246 | integrity sha512-FNONeQPy/ox+5NBkcSbYJxoXj9GWu8gVGJTVmUyoOCKQFDTrHVKgNSzChdNt0I8Aj/iKcsDf2r9BFwv+FSNUXg== 247 | 248 | "@esbuild/linux-loong64@0.15.10": 249 | version "0.15.10" 250 | resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.15.10.tgz#78a42897c2cf8db9fd5f1811f7590393b77774c7" 251 | integrity sha512-w0Ou3Z83LOYEkwaui2M8VwIp+nLi/NA60lBLMvaJ+vXVMcsARYdEzLNE7RSm4+lSg4zq4d7fAVuzk7PNQ5JFgg== 252 | 253 | "@jridgewell/gen-mapping@^0.1.0": 254 | version "0.1.1" 255 | resolved "https://registry.yarnpkg.com/@jridgewell/gen-mapping/-/gen-mapping-0.1.1.tgz#e5d2e450306a9491e3bd77e323e38d7aff315996" 256 | integrity sha512-sQXCasFk+U8lWYEe66WxRDOE9PjVz4vSM51fTu3Hw+ClTpUSQb718772vH3pyS5pShp6lvQM7SxgIDXXXmOX7w== 257 | dependencies: 258 | "@jridgewell/set-array" "^1.0.0" 259 | "@jridgewell/sourcemap-codec" "^1.4.10" 260 | 261 | "@jridgewell/gen-mapping@^0.3.2": 262 | version "0.3.2" 263 | resolved "https://registry.yarnpkg.com/@jridgewell/gen-mapping/-/gen-mapping-0.3.2.tgz#c1aedc61e853f2bb9f5dfe6d4442d3b565b253b9" 264 | integrity sha512-mh65xKQAzI6iBcFzwv28KVWSmCkdRBWoOh+bYQGW3+6OZvbbN3TqMGo5hqYxQniRcH9F2VZIoJCm4pa3BPDK/A== 265 | dependencies: 266 | "@jridgewell/set-array" "^1.0.1" 267 | "@jridgewell/sourcemap-codec" "^1.4.10" 268 | "@jridgewell/trace-mapping" "^0.3.9" 269 | 270 | "@jridgewell/resolve-uri@3.1.0": 271 | version "3.1.0" 272 | resolved "https://registry.yarnpkg.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.0.tgz#2203b118c157721addfe69d47b70465463066d78" 273 | integrity sha512-F2msla3tad+Mfht5cJq7LSXcdudKTWCVYUgw6pLFOOHSTtZlj6SWNYAp+AhuqLmWdBO2X5hPrLcu8cVP8fy28w== 274 | 275 | "@jridgewell/set-array@^1.0.0", "@jridgewell/set-array@^1.0.1": 276 | version "1.1.2" 277 | resolved "https://registry.yarnpkg.com/@jridgewell/set-array/-/set-array-1.1.2.tgz#7c6cf998d6d20b914c0a55a91ae928ff25965e72" 278 | integrity sha512-xnkseuNADM0gt2bs+BvhO0p78Mk762YnZdsuzFV018NoG1Sj1SCQvpSqa7XUaTam5vAGasABV9qXASMKnFMwMw== 279 | 280 | "@jridgewell/sourcemap-codec@1.4.14", "@jridgewell/sourcemap-codec@^1.4.10": 281 | version "1.4.14" 282 | resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.14.tgz#add4c98d341472a289190b424efbdb096991bb24" 283 | integrity sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw== 284 | 285 | "@jridgewell/trace-mapping@^0.3.9": 286 | version "0.3.16" 287 | resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.16.tgz#a7982f16c18cae02be36274365433e5b49d7b23f" 288 | integrity sha512-LCQ+NeThyJ4k1W2d+vIKdxuSt9R3pQSZ4P92m7EakaYuXcVWbHuT5bjNcqLd4Rdgi6xYWYDvBJZJLZSLanjDcA== 289 | dependencies: 290 | "@jridgewell/resolve-uri" "3.1.0" 291 | "@jridgewell/sourcemap-codec" "1.4.14" 292 | 293 | "@remix-run/router@1.0.2": 294 | version "1.0.2" 295 | resolved "https://registry.yarnpkg.com/@remix-run/router/-/router-1.0.2.tgz#1c17eadb2fa77f80a796ad5ea9bf108e6993ef06" 296 | integrity sha512-GRSOFhJzjGN+d4sKHTMSvNeUPoZiDHWmRnXfzaxrqe7dE/Nzlc8BiMSJdLDESZlndM7jIUrZ/F4yWqVYlI0rwQ== 297 | 298 | "@types/prop-types@*": 299 | version "15.7.5" 300 | resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.5.tgz#5f19d2b85a98e9558036f6a3cacc8819420f05cf" 301 | integrity sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w== 302 | 303 | "@types/quill@^1.3.10": 304 | version "1.3.10" 305 | resolved "https://registry.yarnpkg.com/@types/quill/-/quill-1.3.10.tgz#dc1f7b6587f7ee94bdf5291bc92289f6f0497613" 306 | integrity sha512-IhW3fPW+bkt9MLNlycw8u8fWb7oO7W5URC9MfZYHBlA24rex9rs23D5DETChu1zvgVdc5ka64ICjJOgQMr6Shw== 307 | dependencies: 308 | parchment "^1.1.2" 309 | 310 | "@types/react-dom@^18.0.6": 311 | version "18.0.6" 312 | resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-18.0.6.tgz#36652900024842b74607a17786b6662dd1e103a1" 313 | integrity sha512-/5OFZgfIPSwy+YuIBP/FgJnQnsxhZhjjrnxudMddeblOouIodEQ75X14Rr4wGSG/bknL+Omy9iWlLo1u/9GzAA== 314 | dependencies: 315 | "@types/react" "*" 316 | 317 | "@types/react@*", "@types/react@^18.0.17": 318 | version "18.0.21" 319 | resolved "https://registry.yarnpkg.com/@types/react/-/react-18.0.21.tgz#b8209e9626bb00a34c76f55482697edd2b43cc67" 320 | integrity sha512-7QUCOxvFgnD5Jk8ZKlUAhVcRj7GuJRjnjjiY/IUBWKgOlnvDvTMLD4RTF7NPyVmbRhNrbomZiOepg7M/2Kj1mA== 321 | dependencies: 322 | "@types/prop-types" "*" 323 | "@types/scheduler" "*" 324 | csstype "^3.0.2" 325 | 326 | "@types/scheduler@*": 327 | version "0.16.2" 328 | resolved "https://registry.yarnpkg.com/@types/scheduler/-/scheduler-0.16.2.tgz#1a62f89525723dde24ba1b01b092bf5df8ad4d39" 329 | integrity sha512-hppQEBDmlwhFAXKJX2KnWLYu5yMfi91yazPb2l+lbJiwW+wdo1gNeRA+3RgNSO39WYX2euey41KEwnqesU2Jew== 330 | 331 | "@vitejs/plugin-react@^2.1.0": 332 | version "2.1.0" 333 | resolved "https://registry.yarnpkg.com/@vitejs/plugin-react/-/plugin-react-2.1.0.tgz#4c99df15e71d2630601bd3018093bdc787d40e55" 334 | integrity sha512-am6rPyyU3LzUYne3Gd9oj9c4Rzbq5hQnuGXSMT6Gujq45Il/+bunwq3lrB7wghLkiF45ygMwft37vgJ/NE8IAA== 335 | dependencies: 336 | "@babel/core" "^7.18.13" 337 | "@babel/plugin-transform-react-jsx" "^7.18.10" 338 | "@babel/plugin-transform-react-jsx-development" "^7.18.6" 339 | "@babel/plugin-transform-react-jsx-self" "^7.18.6" 340 | "@babel/plugin-transform-react-jsx-source" "^7.18.6" 341 | magic-string "^0.26.2" 342 | react-refresh "^0.14.0" 343 | 344 | accepts@~1.3.8: 345 | version "1.3.8" 346 | resolved "https://registry.yarnpkg.com/accepts/-/accepts-1.3.8.tgz#0bf0be125b67014adcb0b0921e62db7bffe16b2e" 347 | integrity sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw== 348 | dependencies: 349 | mime-types "~2.1.34" 350 | negotiator "0.6.3" 351 | 352 | ansi-styles@^3.2.1: 353 | version "3.2.1" 354 | resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-3.2.1.tgz#41fbb20243e50b12be0f04b8dedbf07520ce841d" 355 | integrity sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA== 356 | dependencies: 357 | color-convert "^1.9.0" 358 | 359 | anymatch@~3.1.2: 360 | version "3.1.2" 361 | resolved "https://registry.yarnpkg.com/anymatch/-/anymatch-3.1.2.tgz#c0557c096af32f106198f4f4e2a383537e378716" 362 | integrity sha512-P43ePfOAIupkguHUycrc4qJ9kz8ZiuOUijaETwX7THt0Y/GNK7v0aa8rY816xWjZ7rJdA5XdMcpVFTKMq+RvWg== 363 | dependencies: 364 | normalize-path "^3.0.0" 365 | picomatch "^2.0.4" 366 | 367 | array-flatten@1.1.1: 368 | version "1.1.1" 369 | resolved "https://registry.yarnpkg.com/array-flatten/-/array-flatten-1.1.1.tgz#9a5f699051b1e7073328f2a008968b64ea2955d2" 370 | integrity sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg== 371 | 372 | asynckit@^0.4.0: 373 | version "0.4.0" 374 | resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79" 375 | integrity sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q== 376 | 377 | axios@^1.1.3: 378 | version "1.1.3" 379 | resolved "https://registry.yarnpkg.com/axios/-/axios-1.1.3.tgz#8274250dada2edf53814ed7db644b9c2866c1e35" 380 | integrity sha512-00tXVRwKx/FZr/IDVFt4C+f9FYairX517WoGCL6dpOntqLkZofjhu43F/Xl44UOpqa+9sLFDrG/XAnFsUYgkDA== 381 | dependencies: 382 | follow-redirects "^1.15.0" 383 | form-data "^4.0.0" 384 | proxy-from-env "^1.1.0" 385 | 386 | binary-extensions@^2.0.0: 387 | version "2.2.0" 388 | resolved "https://registry.yarnpkg.com/binary-extensions/-/binary-extensions-2.2.0.tgz#75f502eeaf9ffde42fc98829645be4ea76bd9e2d" 389 | integrity sha512-jDctJ/IVQbZoJykoeHbhXpOlNBqGNcwXJKJog42E5HDPUwQTSdjCHdihjj0DlnheQ7blbT6dHOafNAiS8ooQKA== 390 | 391 | body-parser@1.20.1: 392 | version "1.20.1" 393 | resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.20.1.tgz#b1812a8912c195cd371a3ee5e66faa2338a5c668" 394 | integrity sha512-jWi7abTbYwajOytWCQc37VulmWiRae5RyTpaCyDcS5/lMdtwSz5lOpDE67srw/HYe35f1z3fDQw+3txg7gNtWw== 395 | dependencies: 396 | bytes "3.1.2" 397 | content-type "~1.0.4" 398 | debug "2.6.9" 399 | depd "2.0.0" 400 | destroy "1.2.0" 401 | http-errors "2.0.0" 402 | iconv-lite "0.4.24" 403 | on-finished "2.4.1" 404 | qs "6.11.0" 405 | raw-body "2.5.1" 406 | type-is "~1.6.18" 407 | unpipe "1.0.0" 408 | 409 | braces@~3.0.2: 410 | version "3.0.2" 411 | resolved "https://registry.yarnpkg.com/braces/-/braces-3.0.2.tgz#3454e1a462ee8d599e236df336cd9ea4f8afe107" 412 | integrity sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A== 413 | dependencies: 414 | fill-range "^7.0.1" 415 | 416 | browserslist@^4.21.3: 417 | version "4.21.4" 418 | resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.21.4.tgz#e7496bbc67b9e39dd0f98565feccdcb0d4ff6987" 419 | integrity sha512-CBHJJdDmgjl3daYjN5Cp5kbTf1mUhZoS+beLklHIvkOWscs83YAhLlF3Wsh/lciQYAcbBJgTOD44VtG31ZM4Hw== 420 | dependencies: 421 | caniuse-lite "^1.0.30001400" 422 | electron-to-chromium "^1.4.251" 423 | node-releases "^2.0.6" 424 | update-browserslist-db "^1.0.9" 425 | 426 | bytes@3.1.2: 427 | version "3.1.2" 428 | resolved "https://registry.yarnpkg.com/bytes/-/bytes-3.1.2.tgz#8b0beeb98605adf1b128fa4386403c009e0221a5" 429 | integrity sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg== 430 | 431 | call-bind@^1.0.0, call-bind@^1.0.2: 432 | version "1.0.2" 433 | resolved "https://registry.yarnpkg.com/call-bind/-/call-bind-1.0.2.tgz#b1d4e89e688119c3c9a903ad30abb2f6a919be3c" 434 | integrity sha512-7O+FbCihrB5WGbFYesctwmTKae6rOiIzmz1icreWJ+0aA7LJfuqhEso2T9ncpcFtzMQtzXf2QGGueWJGTYsqrA== 435 | dependencies: 436 | function-bind "^1.1.1" 437 | get-intrinsic "^1.0.2" 438 | 439 | caniuse-lite@^1.0.30001400: 440 | version "1.0.30001418" 441 | resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001418.tgz#5f459215192a024c99e3e3a53aac310fc7cf24e6" 442 | integrity sha512-oIs7+JL3K9JRQ3jPZjlH6qyYDp+nBTCais7hjh0s+fuBwufc7uZ7hPYMXrDOJhV360KGMTcczMRObk0/iMqZRg== 443 | 444 | chalk@^2.0.0: 445 | version "2.4.2" 446 | resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.4.2.tgz#cd42541677a54333cf541a49108c1432b44c9424" 447 | integrity sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ== 448 | dependencies: 449 | ansi-styles "^3.2.1" 450 | escape-string-regexp "^1.0.5" 451 | supports-color "^5.3.0" 452 | 453 | "chokidar@>=3.0.0 <4.0.0": 454 | version "3.5.3" 455 | resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.5.3.tgz#1cf37c8707b932bd1af1ae22c0432e2acd1903bd" 456 | integrity sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw== 457 | dependencies: 458 | anymatch "~3.1.2" 459 | braces "~3.0.2" 460 | glob-parent "~5.1.2" 461 | is-binary-path "~2.1.0" 462 | is-glob "~4.0.1" 463 | normalize-path "~3.0.0" 464 | readdirp "~3.6.0" 465 | optionalDependencies: 466 | fsevents "~2.3.2" 467 | 468 | clone@^2.1.1: 469 | version "2.1.2" 470 | resolved "https://registry.yarnpkg.com/clone/-/clone-2.1.2.tgz#1b7f4b9f591f1e8f83670401600345a02887435f" 471 | integrity sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w== 472 | 473 | color-convert@^1.9.0: 474 | version "1.9.3" 475 | resolved "https://registry.yarnpkg.com/color-convert/-/color-convert-1.9.3.tgz#bb71850690e1f136567de629d2d5471deda4c1e8" 476 | integrity sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg== 477 | dependencies: 478 | color-name "1.1.3" 479 | 480 | color-name@1.1.3: 481 | version "1.1.3" 482 | resolved "https://registry.yarnpkg.com/color-name/-/color-name-1.1.3.tgz#a7d0558bd89c42f795dd42328f740831ca53bc25" 483 | integrity sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw== 484 | 485 | combined-stream@^1.0.8: 486 | version "1.0.8" 487 | resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.8.tgz#c3d45a8b34fd730631a110a8a2520682b31d5a7f" 488 | integrity sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg== 489 | dependencies: 490 | delayed-stream "~1.0.0" 491 | 492 | content-disposition@0.5.4: 493 | version "0.5.4" 494 | resolved "https://registry.yarnpkg.com/content-disposition/-/content-disposition-0.5.4.tgz#8b82b4efac82512a02bb0b1dcec9d2c5e8eb5bfe" 495 | integrity sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ== 496 | dependencies: 497 | safe-buffer "5.2.1" 498 | 499 | content-type@~1.0.4: 500 | version "1.0.4" 501 | resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.4.tgz#e138cc75e040c727b1966fe5e5f8c9aee256fe3b" 502 | integrity sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA== 503 | 504 | convert-source-map@^1.7.0: 505 | version "1.9.0" 506 | resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-1.9.0.tgz#7faae62353fb4213366d0ca98358d22e8368b05f" 507 | integrity sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A== 508 | 509 | cookie-signature@1.0.6: 510 | version "1.0.6" 511 | resolved "https://registry.yarnpkg.com/cookie-signature/-/cookie-signature-1.0.6.tgz#e303a882b342cc3ee8ca513a79999734dab3ae2c" 512 | integrity sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ== 513 | 514 | cookie@0.5.0: 515 | version "0.5.0" 516 | resolved "https://registry.yarnpkg.com/cookie/-/cookie-0.5.0.tgz#d1f5d71adec6558c58f389987c366aa47e994f8b" 517 | integrity sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw== 518 | 519 | csstype@^3.0.2: 520 | version "3.1.1" 521 | resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.1.1.tgz#841b532c45c758ee546a11d5bd7b7b473c8c30b9" 522 | integrity sha512-DJR/VvkAvSZW9bTouZue2sSxDwdTN92uHjqeKVm+0dAqdfNykRzQ95tay8aXMBAAPpUiq4Qcug2L7neoRh2Egw== 523 | 524 | debug@2.6.9: 525 | version "2.6.9" 526 | resolved "https://registry.yarnpkg.com/debug/-/debug-2.6.9.tgz#5d128515df134ff327e90a4c93f4e077a536341f" 527 | integrity sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA== 528 | dependencies: 529 | ms "2.0.0" 530 | 531 | debug@^4.1.0: 532 | version "4.3.4" 533 | resolved "https://registry.yarnpkg.com/debug/-/debug-4.3.4.tgz#1319f6579357f2338d3337d2cdd4914bb5dcc865" 534 | integrity sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ== 535 | dependencies: 536 | ms "2.1.2" 537 | 538 | deep-equal@^1.0.1: 539 | version "1.1.1" 540 | resolved "https://registry.yarnpkg.com/deep-equal/-/deep-equal-1.1.1.tgz#b5c98c942ceffaf7cb051e24e1434a25a2e6076a" 541 | integrity sha512-yd9c5AdiqVcR+JjcwUQb9DkhJc8ngNr0MahEBGvDiJw8puWab2yZlh+nkasOnZP+EGTAP6rRp2JzJhJZzvNF8g== 542 | dependencies: 543 | is-arguments "^1.0.4" 544 | is-date-object "^1.0.1" 545 | is-regex "^1.0.4" 546 | object-is "^1.0.1" 547 | object-keys "^1.1.1" 548 | regexp.prototype.flags "^1.2.0" 549 | 550 | define-properties@^1.1.3: 551 | version "1.1.4" 552 | resolved "https://registry.yarnpkg.com/define-properties/-/define-properties-1.1.4.tgz#0b14d7bd7fbeb2f3572c3a7eda80ea5d57fb05b1" 553 | integrity sha512-uckOqKcfaVvtBdsVkdPv3XjveQJsNQqmhXgRi8uhvWWuPYZCNlzT8qAyblUgNoXdHdjMTzAqeGjAoli8f+bzPA== 554 | dependencies: 555 | has-property-descriptors "^1.0.0" 556 | object-keys "^1.1.1" 557 | 558 | delayed-stream@~1.0.0: 559 | version "1.0.0" 560 | resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619" 561 | integrity sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ== 562 | 563 | depd@2.0.0: 564 | version "2.0.0" 565 | resolved "https://registry.yarnpkg.com/depd/-/depd-2.0.0.tgz#b696163cc757560d09cf22cc8fad1571b79e76df" 566 | integrity sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw== 567 | 568 | destroy@1.2.0: 569 | version "1.2.0" 570 | resolved "https://registry.yarnpkg.com/destroy/-/destroy-1.2.0.tgz#4803735509ad8be552934c67df614f94e66fa015" 571 | integrity sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg== 572 | 573 | dompurify@^2.4.1: 574 | version "2.4.1" 575 | resolved "https://registry.yarnpkg.com/dompurify/-/dompurify-2.4.1.tgz#f9cb1a275fde9af6f2d0a2644ef648dd6847b631" 576 | integrity sha512-ewwFzHzrrneRjxzmK6oVz/rZn9VWspGFRDb4/rRtIsM1n36t9AKma/ye8syCpcw+XJ25kOK/hOG7t1j2I2yBqA== 577 | 578 | ee-first@1.1.1: 579 | version "1.1.1" 580 | resolved "https://registry.yarnpkg.com/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d" 581 | integrity sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow== 582 | 583 | electron-to-chromium@^1.4.251: 584 | version "1.4.279" 585 | resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.4.279.tgz#84267fec806a8b1c5a1daebf726c4e296e5bcdf9" 586 | integrity sha512-xs7vEuSZ84+JsHSTFqqG0TE3i8EAivHomRQZhhcRvsmnjsh5C2KdhwNKf4ZRYtzq75wojpFyqb62m32Oam57wA== 587 | 588 | encodeurl@~1.0.2: 589 | version "1.0.2" 590 | resolved "https://registry.yarnpkg.com/encodeurl/-/encodeurl-1.0.2.tgz#ad3ff4c86ec2d029322f5a02c3a9a606c95b3f59" 591 | integrity sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w== 592 | 593 | esbuild-android-64@0.15.10: 594 | version "0.15.10" 595 | resolved "https://registry.yarnpkg.com/esbuild-android-64/-/esbuild-android-64-0.15.10.tgz#8a59a84acbf2eca96996cadc35642cf055c494f0" 596 | integrity sha512-UI7krF8OYO1N7JYTgLT9ML5j4+45ra3amLZKx7LO3lmLt1Ibn8t3aZbX5Pu4BjWiqDuJ3m/hsvhPhK/5Y/YpnA== 597 | 598 | esbuild-android-arm64@0.15.10: 599 | version "0.15.10" 600 | resolved "https://registry.yarnpkg.com/esbuild-android-arm64/-/esbuild-android-arm64-0.15.10.tgz#f453851dc1d8c5409a38cf7613a33852faf4915d" 601 | integrity sha512-EOt55D6xBk5O05AK8brXUbZmoFj4chM8u3riGflLa6ziEoVvNjRdD7Cnp82NHQGfSHgYR06XsPI8/sMuA/cUwg== 602 | 603 | esbuild-darwin-64@0.15.10: 604 | version "0.15.10" 605 | resolved "https://registry.yarnpkg.com/esbuild-darwin-64/-/esbuild-darwin-64-0.15.10.tgz#778bd29c8186ff47b176c8af58c08cf0fb8e6b86" 606 | integrity sha512-hbDJugTicqIm+WKZgp208d7FcXcaK8j2c0l+fqSJ3d2AzQAfjEYDRM3Z2oMeqSJ9uFxyj/muSACLdix7oTstRA== 607 | 608 | esbuild-darwin-arm64@0.15.10: 609 | version "0.15.10" 610 | resolved "https://registry.yarnpkg.com/esbuild-darwin-arm64/-/esbuild-darwin-arm64-0.15.10.tgz#b30bbefb46dc3c5d4708b0435e52f6456578d6df" 611 | integrity sha512-M1t5+Kj4IgSbYmunf2BB6EKLkWUq+XlqaFRiGOk8bmBapu9bCDrxjf4kUnWn59Dka3I27EiuHBKd1rSO4osLFQ== 612 | 613 | esbuild-freebsd-64@0.15.10: 614 | version "0.15.10" 615 | resolved "https://registry.yarnpkg.com/esbuild-freebsd-64/-/esbuild-freebsd-64-0.15.10.tgz#ab301c5f6ded5110dbdd611140bef1a7c2e99236" 616 | integrity sha512-KMBFMa7C8oc97nqDdoZwtDBX7gfpolkk6Bcmj6YFMrtCMVgoU/x2DI1p74DmYl7CSS6Ppa3xgemrLrr5IjIn0w== 617 | 618 | esbuild-freebsd-arm64@0.15.10: 619 | version "0.15.10" 620 | resolved "https://registry.yarnpkg.com/esbuild-freebsd-arm64/-/esbuild-freebsd-arm64-0.15.10.tgz#a5b09b867a6ff49110f52343b6f12265db63d43f" 621 | integrity sha512-m2KNbuCX13yQqLlbSojFMHpewbn8wW5uDS6DxRpmaZKzyq8Dbsku6hHvh2U+BcLwWY4mpgXzFUoENEf7IcioGg== 622 | 623 | esbuild-linux-32@0.15.10: 624 | version "0.15.10" 625 | resolved "https://registry.yarnpkg.com/esbuild-linux-32/-/esbuild-linux-32-0.15.10.tgz#5282fe9915641caf9c8070e4ba2c3e16d358f837" 626 | integrity sha512-guXrwSYFAvNkuQ39FNeV4sNkNms1bLlA5vF1H0cazZBOLdLFIny6BhT+TUbK/hdByMQhtWQ5jI9VAmPKbVPu1w== 627 | 628 | esbuild-linux-64@0.15.10: 629 | version "0.15.10" 630 | resolved "https://registry.yarnpkg.com/esbuild-linux-64/-/esbuild-linux-64-0.15.10.tgz#f3726e85a00149580cb19f8abfabcbb96f5d52bb" 631 | integrity sha512-jd8XfaSJeucMpD63YNMO1JCrdJhckHWcMv6O233bL4l6ogQKQOxBYSRP/XLWP+6kVTu0obXovuckJDcA0DKtQA== 632 | 633 | esbuild-linux-arm64@0.15.10: 634 | version "0.15.10" 635 | resolved "https://registry.yarnpkg.com/esbuild-linux-arm64/-/esbuild-linux-arm64-0.15.10.tgz#2f0056e9d5286edb0185b56655caa8c574d8dbe7" 636 | integrity sha512-GByBi4fgkvZFTHFDYNftu1DQ1GzR23jws0oWyCfhnI7eMOe+wgwWrc78dbNk709Ivdr/evefm2PJiUBMiusS1A== 637 | 638 | esbuild-linux-arm@0.15.10: 639 | version "0.15.10" 640 | resolved "https://registry.yarnpkg.com/esbuild-linux-arm/-/esbuild-linux-arm-0.15.10.tgz#40a9270da3c8ffa32cf72e24a79883e323dff08d" 641 | integrity sha512-6N8vThLL/Lysy9y4Ex8XoLQAlbZKUyExCWyayGi2KgTBelKpPgj6RZnUaKri0dHNPGgReJriKVU6+KDGQwn10A== 642 | 643 | esbuild-linux-mips64le@0.15.10: 644 | version "0.15.10" 645 | resolved "https://registry.yarnpkg.com/esbuild-linux-mips64le/-/esbuild-linux-mips64le-0.15.10.tgz#90ce1c4ee0202edb4ac69807dea77f7e5804abc4" 646 | integrity sha512-BxP+LbaGVGIdQNJUNF7qpYjEGWb0YyHVSKqYKrn+pTwH/SiHUxFyJYSP3pqkku61olQiSBnSmWZ+YUpj78Tw7Q== 647 | 648 | esbuild-linux-ppc64le@0.15.10: 649 | version "0.15.10" 650 | resolved "https://registry.yarnpkg.com/esbuild-linux-ppc64le/-/esbuild-linux-ppc64le-0.15.10.tgz#782837ae7bd5b279178106c9dd801755a21fabdf" 651 | integrity sha512-LoSQCd6498PmninNgqd/BR7z3Bsk/mabImBWuQ4wQgmQEeanzWd5BQU2aNi9mBURCLgyheuZS6Xhrw5luw3OkQ== 652 | 653 | esbuild-linux-riscv64@0.15.10: 654 | version "0.15.10" 655 | resolved "https://registry.yarnpkg.com/esbuild-linux-riscv64/-/esbuild-linux-riscv64-0.15.10.tgz#d7420d806ece5174f24f4634303146f915ab4207" 656 | integrity sha512-Lrl9Cr2YROvPV4wmZ1/g48httE8z/5SCiXIyebiB5N8VT7pX3t6meI7TQVHw/wQpqP/AF4SksDuFImPTM7Z32Q== 657 | 658 | esbuild-linux-s390x@0.15.10: 659 | version "0.15.10" 660 | resolved "https://registry.yarnpkg.com/esbuild-linux-s390x/-/esbuild-linux-s390x-0.15.10.tgz#21fdf0cb3494a7fb520a71934e4dffce67fe47be" 661 | integrity sha512-ReP+6q3eLVVP2lpRrvl5EodKX7EZ1bS1/z5j6hsluAlZP5aHhk6ghT6Cq3IANvvDdscMMCB4QEbI+AjtvoOFpA== 662 | 663 | esbuild-netbsd-64@0.15.10: 664 | version "0.15.10" 665 | resolved "https://registry.yarnpkg.com/esbuild-netbsd-64/-/esbuild-netbsd-64-0.15.10.tgz#6c06b3107e3df53de381e6299184d4597db0440f" 666 | integrity sha512-iGDYtJCMCqldMskQ4eIV+QSS/CuT7xyy9i2/FjpKvxAuCzrESZXiA1L64YNj6/afuzfBe9i8m/uDkFHy257hTw== 667 | 668 | esbuild-openbsd-64@0.15.10: 669 | version "0.15.10" 670 | resolved "https://registry.yarnpkg.com/esbuild-openbsd-64/-/esbuild-openbsd-64-0.15.10.tgz#4daef5f5d8e74bbda53b65160029445d582570cf" 671 | integrity sha512-ftMMIwHWrnrYnvuJQRJs/Smlcb28F9ICGde/P3FUTCgDDM0N7WA0o9uOR38f5Xe2/OhNCgkjNeb7QeaE3cyWkQ== 672 | 673 | esbuild-sunos-64@0.15.10: 674 | version "0.15.10" 675 | resolved "https://registry.yarnpkg.com/esbuild-sunos-64/-/esbuild-sunos-64-0.15.10.tgz#5fe7bef267a02f322fd249a8214d0274937388a7" 676 | integrity sha512-mf7hBL9Uo2gcy2r3rUFMjVpTaGpFJJE5QTDDqUFf1632FxteYANffDZmKbqX0PfeQ2XjUDE604IcE7OJeoHiyg== 677 | 678 | esbuild-windows-32@0.15.10: 679 | version "0.15.10" 680 | resolved "https://registry.yarnpkg.com/esbuild-windows-32/-/esbuild-windows-32-0.15.10.tgz#48e3dde25ab0135579a288b30ab6ddef6d1f0b28" 681 | integrity sha512-ttFVo+Cg8b5+qHmZHbEc8Vl17kCleHhLzgT8X04y8zudEApo0PxPg9Mz8Z2cKH1bCYlve1XL8LkyXGFjtUYeGg== 682 | 683 | esbuild-windows-64@0.15.10: 684 | version "0.15.10" 685 | resolved "https://registry.yarnpkg.com/esbuild-windows-64/-/esbuild-windows-64-0.15.10.tgz#387a9515bef3fee502d277a5d0a2db49a4ecda05" 686 | integrity sha512-2H0gdsyHi5x+8lbng3hLbxDWR7mKHWh5BXZGKVG830KUmXOOWFE2YKJ4tHRkejRduOGDrBvHBriYsGtmTv3ntA== 687 | 688 | esbuild-windows-arm64@0.15.10: 689 | version "0.15.10" 690 | resolved "https://registry.yarnpkg.com/esbuild-windows-arm64/-/esbuild-windows-arm64-0.15.10.tgz#5a6fcf2fa49e895949bf5495cf088ab1b43ae879" 691 | integrity sha512-S+th4F+F8VLsHLR0zrUcG+Et4hx0RKgK1eyHc08kztmLOES8BWwMiaGdoW9hiXuzznXQ0I/Fg904MNbr11Nktw== 692 | 693 | esbuild@^0.15.9: 694 | version "0.15.10" 695 | resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.15.10.tgz#85c2f8446e9b1fe04fae68daceacba033eedbd42" 696 | integrity sha512-N7wBhfJ/E5fzn/SpNgX+oW2RLRjwaL8Y0ezqNqhjD6w0H2p0rDuEz2FKZqpqLnO8DCaWumKe8dsC/ljvVSSxng== 697 | optionalDependencies: 698 | "@esbuild/android-arm" "0.15.10" 699 | "@esbuild/linux-loong64" "0.15.10" 700 | esbuild-android-64 "0.15.10" 701 | esbuild-android-arm64 "0.15.10" 702 | esbuild-darwin-64 "0.15.10" 703 | esbuild-darwin-arm64 "0.15.10" 704 | esbuild-freebsd-64 "0.15.10" 705 | esbuild-freebsd-arm64 "0.15.10" 706 | esbuild-linux-32 "0.15.10" 707 | esbuild-linux-64 "0.15.10" 708 | esbuild-linux-arm "0.15.10" 709 | esbuild-linux-arm64 "0.15.10" 710 | esbuild-linux-mips64le "0.15.10" 711 | esbuild-linux-ppc64le "0.15.10" 712 | esbuild-linux-riscv64 "0.15.10" 713 | esbuild-linux-s390x "0.15.10" 714 | esbuild-netbsd-64 "0.15.10" 715 | esbuild-openbsd-64 "0.15.10" 716 | esbuild-sunos-64 "0.15.10" 717 | esbuild-windows-32 "0.15.10" 718 | esbuild-windows-64 "0.15.10" 719 | esbuild-windows-arm64 "0.15.10" 720 | 721 | escalade@^3.1.1: 722 | version "3.1.1" 723 | resolved "https://registry.yarnpkg.com/escalade/-/escalade-3.1.1.tgz#d8cfdc7000965c5a0174b4a82eaa5c0552742e40" 724 | integrity sha512-k0er2gUkLf8O0zKJiAhmkTnJlTvINGv7ygDNPbeIsX/TJjGJZHuh9B2UxbsaEkmlEo9MfhrSzmhIlhRlI2GXnw== 725 | 726 | escape-html@~1.0.3: 727 | version "1.0.3" 728 | resolved "https://registry.yarnpkg.com/escape-html/-/escape-html-1.0.3.tgz#0258eae4d3d0c0974de1c169188ef0051d1d1988" 729 | integrity sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow== 730 | 731 | escape-string-regexp@^1.0.5: 732 | version "1.0.5" 733 | resolved "https://registry.yarnpkg.com/escape-string-regexp/-/escape-string-regexp-1.0.5.tgz#1b61c0562190a8dff6ae3bb2cf0200ca130b86d4" 734 | integrity sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg== 735 | 736 | etag@~1.8.1: 737 | version "1.8.1" 738 | resolved "https://registry.yarnpkg.com/etag/-/etag-1.8.1.tgz#41ae2eeb65efa62268aebfea83ac7d79299b0887" 739 | integrity sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg== 740 | 741 | eventemitter3@^2.0.3: 742 | version "2.0.3" 743 | resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-2.0.3.tgz#b5e1079b59fb5e1ba2771c0a993be060a58c99ba" 744 | integrity sha512-jLN68Dx5kyFHaePoXWPsCGW5qdyZQtLYHkxkg02/Mz6g0kYpDx4FyP6XfArhQdlOC4b8Mv+EMxPo/8La7Tzghg== 745 | 746 | express@^4.18.2: 747 | version "4.18.2" 748 | resolved "https://registry.yarnpkg.com/express/-/express-4.18.2.tgz#3fabe08296e930c796c19e3c516979386ba9fd59" 749 | integrity sha512-5/PsL6iGPdfQ/lKM1UuielYgv3BUoJfz1aUwU9vHZ+J7gyvwdQXFEBIEIaxeGf0GIcreATNyBExtalisDbuMqQ== 750 | dependencies: 751 | accepts "~1.3.8" 752 | array-flatten "1.1.1" 753 | body-parser "1.20.1" 754 | content-disposition "0.5.4" 755 | content-type "~1.0.4" 756 | cookie "0.5.0" 757 | cookie-signature "1.0.6" 758 | debug "2.6.9" 759 | depd "2.0.0" 760 | encodeurl "~1.0.2" 761 | escape-html "~1.0.3" 762 | etag "~1.8.1" 763 | finalhandler "1.2.0" 764 | fresh "0.5.2" 765 | http-errors "2.0.0" 766 | merge-descriptors "1.0.1" 767 | methods "~1.1.2" 768 | on-finished "2.4.1" 769 | parseurl "~1.3.3" 770 | path-to-regexp "0.1.7" 771 | proxy-addr "~2.0.7" 772 | qs "6.11.0" 773 | range-parser "~1.2.1" 774 | safe-buffer "5.2.1" 775 | send "0.18.0" 776 | serve-static "1.15.0" 777 | setprototypeof "1.2.0" 778 | statuses "2.0.1" 779 | type-is "~1.6.18" 780 | utils-merge "1.0.1" 781 | vary "~1.1.2" 782 | 783 | extend@^3.0.2: 784 | version "3.0.2" 785 | resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.2.tgz#f8b1136b4071fbd8eb140aff858b1019ec2915fa" 786 | integrity sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g== 787 | 788 | fast-diff@1.1.2: 789 | version "1.1.2" 790 | resolved "https://registry.yarnpkg.com/fast-diff/-/fast-diff-1.1.2.tgz#4b62c42b8e03de3f848460b639079920695d0154" 791 | integrity sha512-KaJUt+M9t1qaIteSvjc6P3RbMdXsNhK61GRftR6SNxqmhthcd9MGIi4T+o0jD8LUSpSnSKXE20nLtJ3fOHxQig== 792 | 793 | fill-range@^7.0.1: 794 | version "7.0.1" 795 | resolved "https://registry.yarnpkg.com/fill-range/-/fill-range-7.0.1.tgz#1919a6a7c75fe38b2c7c77e5198535da9acdda40" 796 | integrity sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ== 797 | dependencies: 798 | to-regex-range "^5.0.1" 799 | 800 | finalhandler@1.2.0: 801 | version "1.2.0" 802 | resolved "https://registry.yarnpkg.com/finalhandler/-/finalhandler-1.2.0.tgz#7d23fe5731b207b4640e4fcd00aec1f9207a7b32" 803 | integrity sha512-5uXcUVftlQMFnWC9qu/svkWv3GTd2PfUhK/3PLkYNAe7FbqJMt3515HaxE6eRL74GdsriiwujiawdaB1BpEISg== 804 | dependencies: 805 | debug "2.6.9" 806 | encodeurl "~1.0.2" 807 | escape-html "~1.0.3" 808 | on-finished "2.4.1" 809 | parseurl "~1.3.3" 810 | statuses "2.0.1" 811 | unpipe "~1.0.0" 812 | 813 | follow-redirects@^1.15.0: 814 | version "1.15.2" 815 | resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.2.tgz#b460864144ba63f2681096f274c4e57026da2c13" 816 | integrity sha512-VQLG33o04KaQ8uYi2tVNbdrWp1QWxNNea+nmIB4EVM28v0hmP17z7aG1+wAkNzVq4KeXTq3221ye5qTJP91JwA== 817 | 818 | form-data@^4.0.0: 819 | version "4.0.0" 820 | resolved "https://registry.yarnpkg.com/form-data/-/form-data-4.0.0.tgz#93919daeaf361ee529584b9b31664dc12c9fa452" 821 | integrity sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww== 822 | dependencies: 823 | asynckit "^0.4.0" 824 | combined-stream "^1.0.8" 825 | mime-types "^2.1.12" 826 | 827 | forwarded@0.2.0: 828 | version "0.2.0" 829 | resolved "https://registry.yarnpkg.com/forwarded/-/forwarded-0.2.0.tgz#2269936428aad4c15c7ebe9779a84bf0b2a81811" 830 | integrity sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow== 831 | 832 | fresh@0.5.2: 833 | version "0.5.2" 834 | resolved "https://registry.yarnpkg.com/fresh/-/fresh-0.5.2.tgz#3d8cadd90d976569fa835ab1f8e4b23a105605a7" 835 | integrity sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q== 836 | 837 | fsevents@~2.3.2: 838 | version "2.3.2" 839 | resolved "https://registry.yarnpkg.com/fsevents/-/fsevents-2.3.2.tgz#8a526f78b8fdf4623b709e0b975c52c24c02fd1a" 840 | integrity sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA== 841 | 842 | function-bind@^1.1.1: 843 | version "1.1.1" 844 | resolved "https://registry.yarnpkg.com/function-bind/-/function-bind-1.1.1.tgz#a56899d3ea3c9bab874bb9773b7c5ede92f4895d" 845 | integrity sha512-yIovAzMX49sF8Yl58fSCWJ5svSLuaibPxXQJFLmBObTuCr0Mf1KiPopGM9NiFjiYBCbfaa2Fh6breQ6ANVTI0A== 846 | 847 | functions-have-names@^1.2.2: 848 | version "1.2.3" 849 | resolved "https://registry.yarnpkg.com/functions-have-names/-/functions-have-names-1.2.3.tgz#0404fe4ee2ba2f607f0e0ec3c80bae994133b834" 850 | integrity sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ== 851 | 852 | gensync@^1.0.0-beta.2: 853 | version "1.0.0-beta.2" 854 | resolved "https://registry.yarnpkg.com/gensync/-/gensync-1.0.0-beta.2.tgz#32a6ee76c3d7f52d46b2b1ae5d93fea8580a25e0" 855 | integrity sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg== 856 | 857 | get-intrinsic@^1.0.2, get-intrinsic@^1.1.1: 858 | version "1.1.3" 859 | resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.1.3.tgz#063c84329ad93e83893c7f4f243ef63ffa351385" 860 | integrity sha512-QJVz1Tj7MS099PevUG5jvnt9tSkXN8K14dxQlikJuPt4uD9hHAHjLyLBiLR5zELelBdD9QNRAXZzsJx0WaDL9A== 861 | dependencies: 862 | function-bind "^1.1.1" 863 | has "^1.0.3" 864 | has-symbols "^1.0.3" 865 | 866 | glob-parent@~5.1.2: 867 | version "5.1.2" 868 | resolved "https://registry.yarnpkg.com/glob-parent/-/glob-parent-5.1.2.tgz#869832c58034fe68a4093c17dc15e8340d8401c4" 869 | integrity sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow== 870 | dependencies: 871 | is-glob "^4.0.1" 872 | 873 | globals@^11.1.0: 874 | version "11.12.0" 875 | resolved "https://registry.yarnpkg.com/globals/-/globals-11.12.0.tgz#ab8795338868a0babd8525758018c2a7eb95c42e" 876 | integrity sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA== 877 | 878 | has-flag@^3.0.0: 879 | version "3.0.0" 880 | resolved "https://registry.yarnpkg.com/has-flag/-/has-flag-3.0.0.tgz#b5d454dc2199ae225699f3467e5a07f3b955bafd" 881 | integrity sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw== 882 | 883 | has-property-descriptors@^1.0.0: 884 | version "1.0.0" 885 | resolved "https://registry.yarnpkg.com/has-property-descriptors/-/has-property-descriptors-1.0.0.tgz#610708600606d36961ed04c196193b6a607fa861" 886 | integrity sha512-62DVLZGoiEBDHQyqG4w9xCuZ7eJEwNmJRWw2VY84Oedb7WFcA27fiEVe8oUQx9hAUJ4ekurquucTGwsyO1XGdQ== 887 | dependencies: 888 | get-intrinsic "^1.1.1" 889 | 890 | has-symbols@^1.0.2, has-symbols@^1.0.3: 891 | version "1.0.3" 892 | resolved "https://registry.yarnpkg.com/has-symbols/-/has-symbols-1.0.3.tgz#bb7b2c4349251dce87b125f7bdf874aa7c8b39f8" 893 | integrity sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A== 894 | 895 | has-tostringtag@^1.0.0: 896 | version "1.0.0" 897 | resolved "https://registry.yarnpkg.com/has-tostringtag/-/has-tostringtag-1.0.0.tgz#7e133818a7d394734f941e73c3d3f9291e658b25" 898 | integrity sha512-kFjcSNhnlGV1kyoGk7OXKSawH5JOb/LzUc5w9B02hOTO0dfFRjbHQKvg1d6cf3HbeUmtU9VbbV3qzZ2Teh97WQ== 899 | dependencies: 900 | has-symbols "^1.0.2" 901 | 902 | has@^1.0.3: 903 | version "1.0.3" 904 | resolved "https://registry.yarnpkg.com/has/-/has-1.0.3.tgz#722d7cbfc1f6aa8241f16dd814e011e1f41e8796" 905 | integrity sha512-f2dvO0VU6Oej7RkWJGrehjbzMAjFp5/VKPp5tTpWIV4JHHZK1/BxbFRtf/siA2SWTe09caDmVtYYzWEIbBS4zw== 906 | dependencies: 907 | function-bind "^1.1.1" 908 | 909 | http-errors@2.0.0: 910 | version "2.0.0" 911 | resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-2.0.0.tgz#b7774a1486ef73cf7667ac9ae0858c012c57b9d3" 912 | integrity sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ== 913 | dependencies: 914 | depd "2.0.0" 915 | inherits "2.0.4" 916 | setprototypeof "1.2.0" 917 | statuses "2.0.1" 918 | toidentifier "1.0.1" 919 | 920 | iconv-lite@0.4.24: 921 | version "0.4.24" 922 | resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.24.tgz#2022b4b25fbddc21d2f524974a474aafe733908b" 923 | integrity sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA== 924 | dependencies: 925 | safer-buffer ">= 2.1.2 < 3" 926 | 927 | immutable@^4.0.0: 928 | version "4.1.0" 929 | resolved "https://registry.yarnpkg.com/immutable/-/immutable-4.1.0.tgz#f795787f0db780183307b9eb2091fcac1f6fafef" 930 | integrity sha512-oNkuqVTA8jqG1Q6c+UglTOD1xhC1BtjKI7XkCXRkZHrN5m18/XsnUp8Q89GkQO/z+0WjonSvl0FLhDYftp46nQ== 931 | 932 | inherits@2.0.4: 933 | version "2.0.4" 934 | resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" 935 | integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== 936 | 937 | ipaddr.js@1.9.1: 938 | version "1.9.1" 939 | resolved "https://registry.yarnpkg.com/ipaddr.js/-/ipaddr.js-1.9.1.tgz#bff38543eeb8984825079ff3a2a8e6cbd46781b3" 940 | integrity sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g== 941 | 942 | is-arguments@^1.0.4: 943 | version "1.1.1" 944 | resolved "https://registry.yarnpkg.com/is-arguments/-/is-arguments-1.1.1.tgz#15b3f88fda01f2a97fec84ca761a560f123efa9b" 945 | integrity sha512-8Q7EARjzEnKpt/PCD7e1cgUS0a6X8u5tdSiMqXhojOdoV9TsMsiO+9VLC5vAmO8N7/GmXn7yjR8qnA6bVAEzfA== 946 | dependencies: 947 | call-bind "^1.0.2" 948 | has-tostringtag "^1.0.0" 949 | 950 | is-binary-path@~2.1.0: 951 | version "2.1.0" 952 | resolved "https://registry.yarnpkg.com/is-binary-path/-/is-binary-path-2.1.0.tgz#ea1f7f3b80f064236e83470f86c09c254fb45b09" 953 | integrity sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw== 954 | dependencies: 955 | binary-extensions "^2.0.0" 956 | 957 | is-core-module@^2.9.0: 958 | version "2.10.0" 959 | resolved "https://registry.yarnpkg.com/is-core-module/-/is-core-module-2.10.0.tgz#9012ede0a91c69587e647514e1d5277019e728ed" 960 | integrity sha512-Erxj2n/LDAZ7H8WNJXd9tw38GYM3dv8rk8Zcs+jJuxYTW7sozH+SS8NtrSjVL1/vpLvWi1hxy96IzjJ3EHTJJg== 961 | dependencies: 962 | has "^1.0.3" 963 | 964 | is-date-object@^1.0.1: 965 | version "1.0.5" 966 | resolved "https://registry.yarnpkg.com/is-date-object/-/is-date-object-1.0.5.tgz#0841d5536e724c25597bf6ea62e1bd38298df31f" 967 | integrity sha512-9YQaSxsAiSwcvS33MBk3wTCVnWK+HhF8VZR2jRxehM16QcVOdHqPn4VPHmRK4lSr38n9JriurInLcP90xsYNfQ== 968 | dependencies: 969 | has-tostringtag "^1.0.0" 970 | 971 | is-extglob@^2.1.1: 972 | version "2.1.1" 973 | resolved "https://registry.yarnpkg.com/is-extglob/-/is-extglob-2.1.1.tgz#a88c02535791f02ed37c76a1b9ea9773c833f8c2" 974 | integrity sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ== 975 | 976 | is-glob@^4.0.1, is-glob@~4.0.1: 977 | version "4.0.3" 978 | resolved "https://registry.yarnpkg.com/is-glob/-/is-glob-4.0.3.tgz#64f61e42cbbb2eec2071a9dac0b28ba1e65d5084" 979 | integrity sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg== 980 | dependencies: 981 | is-extglob "^2.1.1" 982 | 983 | is-number@^7.0.0: 984 | version "7.0.0" 985 | resolved "https://registry.yarnpkg.com/is-number/-/is-number-7.0.0.tgz#7535345b896734d5f80c4d06c50955527a14f12b" 986 | integrity sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng== 987 | 988 | is-regex@^1.0.4: 989 | version "1.1.4" 990 | resolved "https://registry.yarnpkg.com/is-regex/-/is-regex-1.1.4.tgz#eef5663cd59fa4c0ae339505323df6854bb15958" 991 | integrity sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg== 992 | dependencies: 993 | call-bind "^1.0.2" 994 | has-tostringtag "^1.0.0" 995 | 996 | "js-tokens@^3.0.0 || ^4.0.0", js-tokens@^4.0.0: 997 | version "4.0.0" 998 | resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" 999 | integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ== 1000 | 1001 | jsesc@^2.5.1: 1002 | version "2.5.2" 1003 | resolved "https://registry.yarnpkg.com/jsesc/-/jsesc-2.5.2.tgz#80564d2e483dacf6e8ef209650a67df3f0c283a4" 1004 | integrity sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA== 1005 | 1006 | json5@^2.2.1: 1007 | version "2.2.1" 1008 | resolved "https://registry.yarnpkg.com/json5/-/json5-2.2.1.tgz#655d50ed1e6f95ad1a3caababd2b0efda10b395c" 1009 | integrity sha512-1hqLFMSrGHRHxav9q9gNjJ5EXznIxGVO09xQRrwplcS8qs28pZ8s8hupZAmqDwZUmVZ2Qb2jnyPOWcDH8m8dlA== 1010 | 1011 | lodash@^4.17.4: 1012 | version "4.17.21" 1013 | resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c" 1014 | integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg== 1015 | 1016 | loose-envify@^1.1.0, loose-envify@^1.4.0: 1017 | version "1.4.0" 1018 | resolved "https://registry.yarnpkg.com/loose-envify/-/loose-envify-1.4.0.tgz#71ee51fa7be4caec1a63839f7e682d8132d30caf" 1019 | integrity sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q== 1020 | dependencies: 1021 | js-tokens "^3.0.0 || ^4.0.0" 1022 | 1023 | magic-string@^0.26.2: 1024 | version "0.26.7" 1025 | resolved "https://registry.yarnpkg.com/magic-string/-/magic-string-0.26.7.tgz#caf7daf61b34e9982f8228c4527474dac8981d6f" 1026 | integrity sha512-hX9XH3ziStPoPhJxLq1syWuZMxbDvGNbVchfrdCtanC7D13888bMFow61x8axrx+GfHLtVeAx2kxL7tTGRl+Ow== 1027 | dependencies: 1028 | sourcemap-codec "^1.4.8" 1029 | 1030 | media-typer@0.3.0: 1031 | version "0.3.0" 1032 | resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748" 1033 | integrity sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ== 1034 | 1035 | merge-descriptors@1.0.1: 1036 | version "1.0.1" 1037 | resolved "https://registry.yarnpkg.com/merge-descriptors/-/merge-descriptors-1.0.1.tgz#b00aaa556dd8b44568150ec9d1b953f3f90cbb61" 1038 | integrity sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w== 1039 | 1040 | methods@~1.1.2: 1041 | version "1.1.2" 1042 | resolved "https://registry.yarnpkg.com/methods/-/methods-1.1.2.tgz#5529a4d67654134edcc5266656835b0f851afcee" 1043 | integrity sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w== 1044 | 1045 | mime-db@1.52.0: 1046 | version "1.52.0" 1047 | resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.52.0.tgz#bbabcdc02859f4987301c856e3387ce5ec43bf70" 1048 | integrity sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg== 1049 | 1050 | mime-types@^2.1.12, mime-types@~2.1.24, mime-types@~2.1.34: 1051 | version "2.1.35" 1052 | resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.35.tgz#381a871b62a734450660ae3deee44813f70d959a" 1053 | integrity sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw== 1054 | dependencies: 1055 | mime-db "1.52.0" 1056 | 1057 | mime@1.6.0: 1058 | version "1.6.0" 1059 | resolved "https://registry.yarnpkg.com/mime/-/mime-1.6.0.tgz#32cd9e5c64553bd58d19a568af452acff04981b1" 1060 | integrity sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg== 1061 | 1062 | moment@^2.29.4: 1063 | version "2.29.4" 1064 | resolved "https://registry.yarnpkg.com/moment/-/moment-2.29.4.tgz#3dbe052889fe7c1b2ed966fcb3a77328964ef108" 1065 | integrity sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w== 1066 | 1067 | ms@2.0.0: 1068 | version "2.0.0" 1069 | resolved "https://registry.yarnpkg.com/ms/-/ms-2.0.0.tgz#5608aeadfc00be6c2901df5f9861788de0d597c8" 1070 | integrity sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A== 1071 | 1072 | ms@2.1.2: 1073 | version "2.1.2" 1074 | resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009" 1075 | integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w== 1076 | 1077 | ms@2.1.3: 1078 | version "2.1.3" 1079 | resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" 1080 | integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== 1081 | 1082 | nanoid@^3.3.4: 1083 | version "3.3.4" 1084 | resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.4.tgz#730b67e3cd09e2deacf03c027c81c9d9dbc5e8ab" 1085 | integrity sha512-MqBkQh/OHTS2egovRtLk45wEyNXwF+cokD+1YPf9u5VfJiRdAiRwB2froX5Co9Rh20xs4siNPm8naNotSD6RBw== 1086 | 1087 | negotiator@0.6.3: 1088 | version "0.6.3" 1089 | resolved "https://registry.yarnpkg.com/negotiator/-/negotiator-0.6.3.tgz#58e323a72fedc0d6f9cd4d31fe49f51479590ccd" 1090 | integrity sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg== 1091 | 1092 | node-releases@^2.0.6: 1093 | version "2.0.6" 1094 | resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.6.tgz#8a7088c63a55e493845683ebf3c828d8c51c5503" 1095 | integrity sha512-PiVXnNuFm5+iYkLBNeq5211hvO38y63T0i2KKh2KnUs3RpzJ+JtODFjkD8yjLwnDkTYF1eKXheUwdssR+NRZdg== 1096 | 1097 | normalize-path@^3.0.0, normalize-path@~3.0.0: 1098 | version "3.0.0" 1099 | resolved "https://registry.yarnpkg.com/normalize-path/-/normalize-path-3.0.0.tgz#0dcd69ff23a1c9b11fd0978316644a0388216a65" 1100 | integrity sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA== 1101 | 1102 | object-assign@^4.1.1: 1103 | version "4.1.1" 1104 | resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863" 1105 | integrity sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg== 1106 | 1107 | object-inspect@^1.9.0: 1108 | version "1.12.2" 1109 | resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.12.2.tgz#c0641f26394532f28ab8d796ab954e43c009a8ea" 1110 | integrity sha512-z+cPxW0QGUp0mcqcsgQyLVRDoXFQbXOwBaqyF7VIgI4TWNQsDHrBpUQslRmIfAoYWdYzs6UlKJtB2XJpTaNSpQ== 1111 | 1112 | object-is@^1.0.1: 1113 | version "1.1.5" 1114 | resolved "https://registry.yarnpkg.com/object-is/-/object-is-1.1.5.tgz#b9deeaa5fc7f1846a0faecdceec138e5778f53ac" 1115 | integrity sha512-3cyDsyHgtmi7I7DfSSI2LDp6SK2lwvtbg0p0R1e0RvTqF5ceGx+K2dfSjm1bKDMVCFEDAQvy+o8c6a7VujOddw== 1116 | dependencies: 1117 | call-bind "^1.0.2" 1118 | define-properties "^1.1.3" 1119 | 1120 | object-keys@^1.1.1: 1121 | version "1.1.1" 1122 | resolved "https://registry.yarnpkg.com/object-keys/-/object-keys-1.1.1.tgz#1c47f272df277f3b1daf061677d9c82e2322c60e" 1123 | integrity sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA== 1124 | 1125 | on-finished@2.4.1: 1126 | version "2.4.1" 1127 | resolved "https://registry.yarnpkg.com/on-finished/-/on-finished-2.4.1.tgz#58c8c44116e54845ad57f14ab10b03533184ac3f" 1128 | integrity sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg== 1129 | dependencies: 1130 | ee-first "1.1.1" 1131 | 1132 | parchment@^1.1.2, parchment@^1.1.4: 1133 | version "1.1.4" 1134 | resolved "https://registry.yarnpkg.com/parchment/-/parchment-1.1.4.tgz#aeded7ab938fe921d4c34bc339ce1168bc2ffde5" 1135 | integrity sha512-J5FBQt/pM2inLzg4hEWmzQx/8h8D0CiDxaG3vyp9rKrQRSDgBlhjdP5jQGgosEajXPSQouXGHOmVdgo7QmJuOg== 1136 | 1137 | parseurl@~1.3.3: 1138 | version "1.3.3" 1139 | resolved "https://registry.yarnpkg.com/parseurl/-/parseurl-1.3.3.tgz#9da19e7bee8d12dff0513ed5b76957793bc2e8d4" 1140 | integrity sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ== 1141 | 1142 | path-parse@^1.0.7: 1143 | version "1.0.7" 1144 | resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.7.tgz#fbc114b60ca42b30d9daf5858e4bd68bbedb6735" 1145 | integrity sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw== 1146 | 1147 | path-to-regexp@0.1.7: 1148 | version "0.1.7" 1149 | resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-0.1.7.tgz#df604178005f522f15eb4490e7247a1bfaa67f8c" 1150 | integrity sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ== 1151 | 1152 | picocolors@^1.0.0: 1153 | version "1.0.0" 1154 | resolved "https://registry.yarnpkg.com/picocolors/-/picocolors-1.0.0.tgz#cb5bdc74ff3f51892236eaf79d68bc44564ab81c" 1155 | integrity sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ== 1156 | 1157 | picomatch@^2.0.4, picomatch@^2.2.1: 1158 | version "2.3.1" 1159 | resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.3.1.tgz#3ba3833733646d9d3e4995946c1365a67fb07a42" 1160 | integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA== 1161 | 1162 | postcss@^8.4.16: 1163 | version "8.4.17" 1164 | resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.4.17.tgz#f87863ec7cd353f81f7ab2dec5d67d861bbb1be5" 1165 | integrity sha512-UNxNOLQydcOFi41yHNMcKRZ39NeXlr8AxGuZJsdub8vIb12fHzcq37DTU/QtbI6WLxNg2gF9Z+8qtRwTj1UI1Q== 1166 | dependencies: 1167 | nanoid "^3.3.4" 1168 | picocolors "^1.0.0" 1169 | source-map-js "^1.0.2" 1170 | 1171 | prop-types@^15.7.2: 1172 | version "15.8.1" 1173 | resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.8.1.tgz#67d87bf1a694f48435cf332c24af10214a3140b5" 1174 | integrity sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg== 1175 | dependencies: 1176 | loose-envify "^1.4.0" 1177 | object-assign "^4.1.1" 1178 | react-is "^16.13.1" 1179 | 1180 | proxy-addr@~2.0.7: 1181 | version "2.0.7" 1182 | resolved "https://registry.yarnpkg.com/proxy-addr/-/proxy-addr-2.0.7.tgz#f19fe69ceab311eeb94b42e70e8c2070f9ba1025" 1183 | integrity sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg== 1184 | dependencies: 1185 | forwarded "0.2.0" 1186 | ipaddr.js "1.9.1" 1187 | 1188 | proxy-from-env@^1.1.0: 1189 | version "1.1.0" 1190 | resolved "https://registry.yarnpkg.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz#e102f16ca355424865755d2c9e8ea4f24d58c3e2" 1191 | integrity sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg== 1192 | 1193 | qs@6.11.0: 1194 | version "6.11.0" 1195 | resolved "https://registry.yarnpkg.com/qs/-/qs-6.11.0.tgz#fd0d963446f7a65e1367e01abd85429453f0c37a" 1196 | integrity sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q== 1197 | dependencies: 1198 | side-channel "^1.0.4" 1199 | 1200 | quill-delta@^3.6.2: 1201 | version "3.6.3" 1202 | resolved "https://registry.yarnpkg.com/quill-delta/-/quill-delta-3.6.3.tgz#b19fd2b89412301c60e1ff213d8d860eac0f1032" 1203 | integrity sha512-wdIGBlcX13tCHOXGMVnnTVFtGRLoP0imqxM696fIPwIf5ODIYUHIvHbZcyvGlZFiFhK5XzDC2lpjbxRhnM05Tg== 1204 | dependencies: 1205 | deep-equal "^1.0.1" 1206 | extend "^3.0.2" 1207 | fast-diff "1.1.2" 1208 | 1209 | quill@^1.3.7: 1210 | version "1.3.7" 1211 | resolved "https://registry.yarnpkg.com/quill/-/quill-1.3.7.tgz#da5b2f3a2c470e932340cdbf3668c9f21f9286e8" 1212 | integrity sha512-hG/DVzh/TiknWtE6QmWAF/pxoZKYxfe3J/d/+ShUWkDvvkZQVTPeVmUJVu1uE6DDooC4fWTiCLh84ul89oNz5g== 1213 | dependencies: 1214 | clone "^2.1.1" 1215 | deep-equal "^1.0.1" 1216 | eventemitter3 "^2.0.3" 1217 | extend "^3.0.2" 1218 | parchment "^1.1.4" 1219 | quill-delta "^3.6.2" 1220 | 1221 | range-parser@~1.2.1: 1222 | version "1.2.1" 1223 | resolved "https://registry.yarnpkg.com/range-parser/-/range-parser-1.2.1.tgz#3cf37023d199e1c24d1a55b84800c2f3e6468031" 1224 | integrity sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg== 1225 | 1226 | raw-body@2.5.1: 1227 | version "2.5.1" 1228 | resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.5.1.tgz#fe1b1628b181b700215e5fd42389f98b71392857" 1229 | integrity sha512-qqJBtEyVgS0ZmPGdCFPWJ3FreoqvG4MVQln/kCgF7Olq95IbOp0/BWyMwbdtn4VTvkM8Y7khCQ2Xgk/tcrCXig== 1230 | dependencies: 1231 | bytes "3.1.2" 1232 | http-errors "2.0.0" 1233 | iconv-lite "0.4.24" 1234 | unpipe "1.0.0" 1235 | 1236 | react-dom@^18.2.0: 1237 | version "18.2.0" 1238 | resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-18.2.0.tgz#22aaf38708db2674ed9ada224ca4aa708d821e3d" 1239 | integrity sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g== 1240 | dependencies: 1241 | loose-envify "^1.1.0" 1242 | scheduler "^0.23.0" 1243 | 1244 | react-feather@^2.0.10: 1245 | version "2.0.10" 1246 | resolved "https://registry.yarnpkg.com/react-feather/-/react-feather-2.0.10.tgz#0e9abf05a66754f7b7bb71757ac4da7fb6be3b68" 1247 | integrity sha512-BLhukwJ+Z92Nmdcs+EMw6dy1Z/VLiJTzEQACDUEnWMClhYnFykJCGWQx+NmwP/qQHGX/5CzQ+TGi8ofg2+HzVQ== 1248 | dependencies: 1249 | prop-types "^15.7.2" 1250 | 1251 | react-is@^16.13.1: 1252 | version "16.13.1" 1253 | resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4" 1254 | integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ== 1255 | 1256 | react-quill@^2.0.0: 1257 | version "2.0.0" 1258 | resolved "https://registry.yarnpkg.com/react-quill/-/react-quill-2.0.0.tgz#67a0100f58f96a246af240c9fa6841b363b3e017" 1259 | integrity sha512-4qQtv1FtCfLgoD3PXAur5RyxuUbPXQGOHgTlFie3jtxp43mXDtzCKaOgQ3mLyZfi1PUlyjycfivKelFhy13QUg== 1260 | dependencies: 1261 | "@types/quill" "^1.3.10" 1262 | lodash "^4.17.4" 1263 | quill "^1.3.7" 1264 | 1265 | react-refresh@^0.14.0: 1266 | version "0.14.0" 1267 | resolved "https://registry.yarnpkg.com/react-refresh/-/react-refresh-0.14.0.tgz#4e02825378a5f227079554d4284889354e5f553e" 1268 | integrity sha512-wViHqhAd8OHeLS/IRMJjTSDHF3U9eWi62F/MledQGPdJGDhodXJ9PBLNGr6WWL7qlH12Mt3TyTpbS+hGXMjCzQ== 1269 | 1270 | react-router-dom@^6.4.2: 1271 | version "6.4.2" 1272 | resolved "https://registry.yarnpkg.com/react-router-dom/-/react-router-dom-6.4.2.tgz#115b37d501d6d8ac870683694978c51c43e6c0d2" 1273 | integrity sha512-yM1kjoTkpfjgczPrcyWrp+OuQMyB1WleICiiGfstnQYo/S8hPEEnVjr/RdmlH6yKK4Tnj1UGXFSa7uwAtmDoLQ== 1274 | dependencies: 1275 | "@remix-run/router" "1.0.2" 1276 | react-router "6.4.2" 1277 | 1278 | react-router@6.4.2: 1279 | version "6.4.2" 1280 | resolved "https://registry.yarnpkg.com/react-router/-/react-router-6.4.2.tgz#300628ee9ed81b8ef1597b5cb98b474efe9779b8" 1281 | integrity sha512-Rb0BAX9KHhVzT1OKhMvCDMw776aTYM0DtkxqUBP8dNBom3mPXlfNs76JNGK8wKJ1IZEY1+WGj+cvZxHVk/GiKw== 1282 | dependencies: 1283 | "@remix-run/router" "1.0.2" 1284 | 1285 | react@^18.2.0: 1286 | version "18.2.0" 1287 | resolved "https://registry.yarnpkg.com/react/-/react-18.2.0.tgz#555bd98592883255fa00de14f1151a917b5d77d5" 1288 | integrity sha512-/3IjMdb2L9QbBdWiW5e3P2/npwMBaU9mHCSCUzNln0ZCYbcfTsGbTJrU/kGemdH2IWmB2ioZ+zkxtmq6g09fGQ== 1289 | dependencies: 1290 | loose-envify "^1.1.0" 1291 | 1292 | readdirp@~3.6.0: 1293 | version "3.6.0" 1294 | resolved "https://registry.yarnpkg.com/readdirp/-/readdirp-3.6.0.tgz#74a370bd857116e245b29cc97340cd431a02a6c7" 1295 | integrity sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA== 1296 | dependencies: 1297 | picomatch "^2.2.1" 1298 | 1299 | regexp.prototype.flags@^1.2.0: 1300 | version "1.4.3" 1301 | resolved "https://registry.yarnpkg.com/regexp.prototype.flags/-/regexp.prototype.flags-1.4.3.tgz#87cab30f80f66660181a3bb7bf5981a872b367ac" 1302 | integrity sha512-fjggEOO3slI6Wvgjwflkc4NFRCTZAu5CnNfBd5qOMYhWdn67nJBBu34/TkD++eeFmd8C9r9jfXJ27+nSiRkSUA== 1303 | dependencies: 1304 | call-bind "^1.0.2" 1305 | define-properties "^1.1.3" 1306 | functions-have-names "^1.2.2" 1307 | 1308 | resolve@^1.22.1: 1309 | version "1.22.1" 1310 | resolved "https://registry.yarnpkg.com/resolve/-/resolve-1.22.1.tgz#27cb2ebb53f91abb49470a928bba7558066ac177" 1311 | integrity sha512-nBpuuYuY5jFsli/JIs1oldw6fOQCBioohqWZg/2hiaOybXOft4lonv85uDOKXdf8rhyK159cxU5cDcK/NKk8zw== 1312 | dependencies: 1313 | is-core-module "^2.9.0" 1314 | path-parse "^1.0.7" 1315 | supports-preserve-symlinks-flag "^1.0.0" 1316 | 1317 | rollup@~2.78.0: 1318 | version "2.78.1" 1319 | resolved "https://registry.yarnpkg.com/rollup/-/rollup-2.78.1.tgz#52fe3934d9c83cb4f7c4cb5fb75d88591be8648f" 1320 | integrity sha512-VeeCgtGi4P+o9hIg+xz4qQpRl6R401LWEXBmxYKOV4zlF82lyhgh2hTZnheFUbANE8l2A41F458iwj2vEYaXJg== 1321 | optionalDependencies: 1322 | fsevents "~2.3.2" 1323 | 1324 | safe-buffer@5.2.1: 1325 | version "5.2.1" 1326 | resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.2.1.tgz#1eaf9fa9bdb1fdd4ec75f58f9cdb4e6b7827eec6" 1327 | integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== 1328 | 1329 | "safer-buffer@>= 2.1.2 < 3": 1330 | version "2.1.2" 1331 | resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" 1332 | integrity sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg== 1333 | 1334 | sass@^1.55.0: 1335 | version "1.55.0" 1336 | resolved "https://registry.yarnpkg.com/sass/-/sass-1.55.0.tgz#0c4d3c293cfe8f8a2e8d3b666e1cf1bff8065d1c" 1337 | integrity sha512-Pk+PMy7OGLs9WaxZGJMn7S96dvlyVBwwtToX895WmCpAOr5YiJYEUJfiJidMuKb613z2xNWcXCHEuOvjZbqC6A== 1338 | dependencies: 1339 | chokidar ">=3.0.0 <4.0.0" 1340 | immutable "^4.0.0" 1341 | source-map-js ">=0.6.2 <2.0.0" 1342 | 1343 | scheduler@^0.23.0: 1344 | version "0.23.0" 1345 | resolved "https://registry.yarnpkg.com/scheduler/-/scheduler-0.23.0.tgz#ba8041afc3d30eb206a487b6b384002e4e61fdfe" 1346 | integrity sha512-CtuThmgHNg7zIZWAXi3AsyIzA3n4xx7aNyjwC2VJldO2LMVDhFK+63xGqq6CsJH4rTAt6/M+N4GhZiDYPx9eUw== 1347 | dependencies: 1348 | loose-envify "^1.1.0" 1349 | 1350 | semver@^6.3.0: 1351 | version "6.3.0" 1352 | resolved "https://registry.yarnpkg.com/semver/-/semver-6.3.0.tgz#ee0a64c8af5e8ceea67687b133761e1becbd1d3d" 1353 | integrity sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw== 1354 | 1355 | send@0.18.0: 1356 | version "0.18.0" 1357 | resolved "https://registry.yarnpkg.com/send/-/send-0.18.0.tgz#670167cc654b05f5aa4a767f9113bb371bc706be" 1358 | integrity sha512-qqWzuOjSFOuqPjFe4NOsMLafToQQwBSOEpS+FwEt3A2V3vKubTquT3vmLTQpFgMXp8AlFWFuP1qKaJZOtPpVXg== 1359 | dependencies: 1360 | debug "2.6.9" 1361 | depd "2.0.0" 1362 | destroy "1.2.0" 1363 | encodeurl "~1.0.2" 1364 | escape-html "~1.0.3" 1365 | etag "~1.8.1" 1366 | fresh "0.5.2" 1367 | http-errors "2.0.0" 1368 | mime "1.6.0" 1369 | ms "2.1.3" 1370 | on-finished "2.4.1" 1371 | range-parser "~1.2.1" 1372 | statuses "2.0.1" 1373 | 1374 | serve-static@1.15.0: 1375 | version "1.15.0" 1376 | resolved "https://registry.yarnpkg.com/serve-static/-/serve-static-1.15.0.tgz#faaef08cffe0a1a62f60cad0c4e513cff0ac9540" 1377 | integrity sha512-XGuRDNjXUijsUL0vl6nSD7cwURuzEgglbOaFuZM9g3kwDXOWVTck0jLzjPzGD+TazWbboZYu52/9/XPdUgne9g== 1378 | dependencies: 1379 | encodeurl "~1.0.2" 1380 | escape-html "~1.0.3" 1381 | parseurl "~1.3.3" 1382 | send "0.18.0" 1383 | 1384 | setprototypeof@1.2.0: 1385 | version "1.2.0" 1386 | resolved "https://registry.yarnpkg.com/setprototypeof/-/setprototypeof-1.2.0.tgz#66c9a24a73f9fc28cbe66b09fed3d33dcaf1b424" 1387 | integrity sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw== 1388 | 1389 | side-channel@^1.0.4: 1390 | version "1.0.4" 1391 | resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.0.4.tgz#efce5c8fdc104ee751b25c58d4290011fa5ea2cf" 1392 | integrity sha512-q5XPytqFEIKHkGdiMIrY10mvLRvnQh42/+GoBlFW3b2LXLE2xxJpZFdm94we0BaoV3RwJyGqg5wS7epxTv0Zvw== 1393 | dependencies: 1394 | call-bind "^1.0.0" 1395 | get-intrinsic "^1.0.2" 1396 | object-inspect "^1.9.0" 1397 | 1398 | "source-map-js@>=0.6.2 <2.0.0", source-map-js@^1.0.2: 1399 | version "1.0.2" 1400 | resolved "https://registry.yarnpkg.com/source-map-js/-/source-map-js-1.0.2.tgz#adbc361d9c62df380125e7f161f71c826f1e490c" 1401 | integrity sha512-R0XvVJ9WusLiqTCEiGCmICCMplcCkIwwR11mOSD9CR5u+IXYdiseeEuXCVAjS54zqwkLcPNnmU4OeJ6tUrWhDw== 1402 | 1403 | sourcemap-codec@^1.4.8: 1404 | version "1.4.8" 1405 | resolved "https://registry.yarnpkg.com/sourcemap-codec/-/sourcemap-codec-1.4.8.tgz#ea804bd94857402e6992d05a38ef1ae35a9ab4c4" 1406 | integrity sha512-9NykojV5Uih4lgo5So5dtw+f0JgJX30KCNI8gwhz2J9A15wD0Ml6tjHKwf6fTSa6fAdVBdZeNOs9eJ71qCk8vA== 1407 | 1408 | statuses@2.0.1: 1409 | version "2.0.1" 1410 | resolved "https://registry.yarnpkg.com/statuses/-/statuses-2.0.1.tgz#55cb000ccf1d48728bd23c685a063998cf1a1b63" 1411 | integrity sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ== 1412 | 1413 | supports-color@^5.3.0: 1414 | version "5.5.0" 1415 | resolved "https://registry.yarnpkg.com/supports-color/-/supports-color-5.5.0.tgz#e2e69a44ac8772f78a1ec0b35b689df6530efc8f" 1416 | integrity sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow== 1417 | dependencies: 1418 | has-flag "^3.0.0" 1419 | 1420 | supports-preserve-symlinks-flag@^1.0.0: 1421 | version "1.0.0" 1422 | resolved "https://registry.yarnpkg.com/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz#6eda4bd344a3c94aea376d4cc31bc77311039e09" 1423 | integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w== 1424 | 1425 | to-fast-properties@^2.0.0: 1426 | version "2.0.0" 1427 | resolved "https://registry.yarnpkg.com/to-fast-properties/-/to-fast-properties-2.0.0.tgz#dc5e698cbd079265bc73e0377681a4e4e83f616e" 1428 | integrity sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog== 1429 | 1430 | to-regex-range@^5.0.1: 1431 | version "5.0.1" 1432 | resolved "https://registry.yarnpkg.com/to-regex-range/-/to-regex-range-5.0.1.tgz#1648c44aae7c8d988a326018ed72f5b4dd0392e4" 1433 | integrity sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ== 1434 | dependencies: 1435 | is-number "^7.0.0" 1436 | 1437 | toidentifier@1.0.1: 1438 | version "1.0.1" 1439 | resolved "https://registry.yarnpkg.com/toidentifier/-/toidentifier-1.0.1.tgz#3be34321a88a820ed1bd80dfaa33e479fbb8dd35" 1440 | integrity sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA== 1441 | 1442 | type-is@~1.6.18: 1443 | version "1.6.18" 1444 | resolved "https://registry.yarnpkg.com/type-is/-/type-is-1.6.18.tgz#4e552cd05df09467dcbc4ef739de89f2cf37c131" 1445 | integrity sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g== 1446 | dependencies: 1447 | media-typer "0.3.0" 1448 | mime-types "~2.1.24" 1449 | 1450 | unpipe@1.0.0, unpipe@~1.0.0: 1451 | version "1.0.0" 1452 | resolved "https://registry.yarnpkg.com/unpipe/-/unpipe-1.0.0.tgz#b2bf4ee8514aae6165b4817829d21b2ef49904ec" 1453 | integrity sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ== 1454 | 1455 | update-browserslist-db@^1.0.9: 1456 | version "1.0.10" 1457 | resolved "https://registry.yarnpkg.com/update-browserslist-db/-/update-browserslist-db-1.0.10.tgz#0f54b876545726f17d00cd9a2561e6dade943ff3" 1458 | integrity sha512-OztqDenkfFkbSG+tRxBeAnCVPckDBcvibKd35yDONx6OU8N7sqgwc7rCbkJ/WcYtVRZ4ba68d6byhC21GFh7sQ== 1459 | dependencies: 1460 | escalade "^3.1.1" 1461 | picocolors "^1.0.0" 1462 | 1463 | utils-merge@1.0.1: 1464 | version "1.0.1" 1465 | resolved "https://registry.yarnpkg.com/utils-merge/-/utils-merge-1.0.1.tgz#9f95710f50a267947b2ccc124741c1028427e713" 1466 | integrity sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA== 1467 | 1468 | vary@~1.1.2: 1469 | version "1.1.2" 1470 | resolved "https://registry.yarnpkg.com/vary/-/vary-1.1.2.tgz#2299f02c6ded30d4a5961b0b9f74524a18f634fc" 1471 | integrity sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg== 1472 | 1473 | vite@^3.1.0: 1474 | version "3.1.7" 1475 | resolved "https://registry.yarnpkg.com/vite/-/vite-3.1.7.tgz#9fc2b57a395f79175d38fa3cffd15080b0d9cbfc" 1476 | integrity sha512-5vCAmU4S8lyVdFCInu9M54f/g8qbOMakVw5xJ4pjoaDy5wgy9sLLZkGdSLN52dlsBqh0tBqxjaqqa8LgPqwRAA== 1477 | dependencies: 1478 | esbuild "^0.15.9" 1479 | postcss "^8.4.16" 1480 | resolve "^1.22.1" 1481 | rollup "~2.78.0" 1482 | optionalDependencies: 1483 | fsevents "~2.3.2" 1484 | --------------------------------------------------------------------------------