diff --git a/apps/react-app/README.md b/apps/react-app/README.md new file mode 100644 index 00000000..ff7bfc51 --- /dev/null +++ b/apps/react-app/README.md @@ -0,0 +1,54 @@ +# React - Discovering the World App + +## Instructions + +1. Fork [mfee-project repository](https://github.com/gus-code/mfee-project). +2. Make sure to fork all the branches (You need to unselect the checkbox of fork only main branch). +3. You can clone the repository to your local or you can create a codespace in github. +4. Checkout to branch `` this is the starter boilerplate + - `git checkout ` +5. We will be working with some examples during the sessions in this same repository, once we finish with the session you can get the example from the branch `react/session-*` and merge it to your branch (We will provide the branch after each session) + - `git merge react/session-*` +6. Each session branch will have the challenges to accomplish and the expected results. +7. After finish the challenges you need to create a pull request to the base repository, you will have a branch with your EID (``). If you don't know how to do it you can check this [quick guide](#create-pull-request) + +## Recommendations + +- Before make your commit format your files with prettier + +### Recommended extensions + +- Name: Error Lens + Id: usernamehw.errorlens + Description: Improve highlighting of errors, warnings and other language diagnostics. + Version: 3.17.0 + Publisher: Alexander + VS Marketplace Link: https://marketplace.visualstudio.com/items?itemName=usernamehw.errorlens + +- Name: Pretty TypeScript Errors + Id: YoavBls.pretty-ts-errors + Description: Make TypeScript errors prettier and more human-readable in VSCode + Version: 0.5.4 + Publisher: yoavbls + VS Marketplace Link: https://marketplace.visualstudio.com/items?itemName=yoavbls.pretty-ts-errors + +- Name: ES7+ React/Redux/React-Native snippets + Id: dsznajder.es7-react-js-snippets + Description: Extensions for React, React-Native and Redux in JS/TS with ES7+ syntax. Customizable. Built-in integration with prettier. + Version: 4.4.3 + Publisher: dsznajder + VS Marketplace Link: https://marketplace.visualstudio.com/items?itemName=dsznajder.es7-react-js-snippets + + +## How to + +### Run postman collection + +1. Download postman collection from `apps/react/assets/Capstone-Project.postman_collection` +2. Import collection to postman +3. Register a new user using the "register" API, inside the "Auth" folder + ![Register User](assets/register-user.png) +4. Use the credentials to generate a token using the "login" API + ![Generate token](assets/generate-token.png) +5. Configure accestoken variable. After updating the value you need to save it with `Ctrl + S` + ![Postman Variables](/assets/postman-variables.png) diff --git a/apps/react-app/assets/Capstone-Project.postman_collection.json b/apps/react-app/assets/Capstone-Project.postman_collection.json new file mode 100644 index 00000000..a188c5e8 --- /dev/null +++ b/apps/react-app/assets/Capstone-Project.postman_collection.json @@ -0,0 +1,579 @@ +{ + "info": { + "_postman_id": "973fac89-18c9-4051-a1a1-a481d1a292f1", + "name": "Capstone Project", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json", + "_exporter_id": "30322992" + }, + "item": [ + { + "name": "Auth", + "item": [ + { + "name": "Register", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"username\": \"david@outlook.com\",\n \"password\": \"4h%K.1\"\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://{{dev}}/api/auth/register", + "protocol": "https", + "host": [ + "{{dev}}" + ], + "path": [ + "api", + "auth", + "register" + ] + } + }, + "response": [] + }, + { + "name": "Login", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"username\": \"david@outlook.com\",\n \"password\": \"4h%K.1\"\n}\n", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://{{dev}}/api/auth/login", + "protocol": "https", + "host": [ + "{{dev}}" + ], + "path": [ + "api", + "auth", + "login" + ] + } + }, + "response": [] + }, + { + "name": "Refresh", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "url": { + "raw": "https://{{dev}}/api/auth/refresh", + "protocol": "https", + "host": [ + "{{dev}}" + ], + "path": [ + "api", + "auth", + "refresh" + ] + } + }, + "response": [] + }, + { + "name": "Logout", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "url": { + "raw": "https://{{dev}}/api/auth/logout", + "protocol": "https", + "host": [ + "{{dev}}" + ], + "path": [ + "api", + "auth", + "logout" + ] + } + }, + "response": [] + } + ] + }, + { + "name": "Categories", + "item": [ + { + "name": "AllCategories", + "request": { + "method": "GET", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "url": { + "raw": "https://{{dev}}/api/categories", + "protocol": "https", + "host": [ + "{{dev}}" + ], + "path": [ + "api", + "categories" + ] + } + }, + "response": [] + }, + { + "name": "Category", + "request": { + "method": "GET", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "apitoken", + "value": "{{token}}" + } + ], + "url": { + "raw": "https://{{dev}}/api/categories/663e7932d513515319551c1d", + "protocol": "https", + "host": [ + "{{dev}}" + ], + "path": [ + "api", + "categories", + "663e7932d513515319551c1d" + ] + } + }, + "response": [] + }, + { + "name": "CreateCategory", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "apitoken", + "value": "{{token}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"name\":\"Travel\"\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://{{dev}}/api/categories", + "protocol": "https", + "host": [ + "{{dev}}" + ], + "path": [ + "api", + "categories" + ] + } + }, + "response": [] + }, + { + "name": "UpdateCategory", + "request": { + "method": "PATCH", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "apitoken", + "value": "{{token}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"name\":\"Sports\"\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://{{dev}}/api/categories/663e7932d513515319551c1d", + "protocol": "https", + "host": [ + "{{dev}}" + ], + "path": [ + "api", + "categories", + "663e7932d513515319551c1d" + ] + } + }, + "response": [] + }, + { + "name": "DeleteCategory", + "request": { + "method": "DELETE", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "apitoken", + "value": "{{token}}" + } + ], + "url": { + "raw": "https://{{dev}}/api/categories/663e796bd513515319551c24", + "protocol": "https", + "host": [ + "{{dev}}" + ], + "path": [ + "api", + "categories", + "663e796bd513515319551c24" + ] + } + }, + "response": [] + } + ] + }, + { + "name": "Posts", + "item": [ + { + "name": "AllPosts", + "request": { + "method": "GET", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "apitoken", + "value": "{{token}}" + } + ], + "url": { + "raw": "https://{{dev}}/api/posts", + "protocol": "https", + "host": [ + "{{dev}}" + ], + "path": [ + "api", + "posts" + ] + } + }, + "response": [] + }, + { + "name": "PostByCategory", + "request": { + "method": "GET", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "apitoken", + "value": "{{token}}" + } + ], + "url": { + "raw": "https://{{dev}}/api/posts/category/663e7932d513515319551c1d", + "protocol": "https", + "host": [ + "{{dev}}" + ], + "path": [ + "api", + "posts", + "category", + "663e7932d513515319551c1d" + ] + } + }, + "response": [] + }, + { + "name": "Post", + "request": { + "method": "GET", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "apitoken", + "value": "{{token}}" + } + ], + "url": { + "raw": "https://{{dev}}/api/posts/663e7af3d513515319551c2b", + "protocol": "https", + "host": [ + "{{dev}}" + ], + "path": [ + "api", + "posts", + "663e7af3d513515319551c2b" + ] + } + }, + "response": [] + }, + { + "name": "CreatePost", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "apitoken", + "value": "{{token}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"title\": \"Other post\",\r\n \"image\": \"Image url\",\r\n \"description\": \"Post description\",\r\n \"category\": \"663e7932d513515319551c1d\"\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://{{dev}}/api/posts", + "protocol": "https", + "host": [ + "{{dev}}" + ], + "path": [ + "api", + "posts" + ] + } + }, + "response": [] + }, + { + "name": "UpdatePost", + "request": { + "method": "PATCH", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "apitoken", + "value": "{{token}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"title\": \"New Title\",\r\n \"image\": \"New image URL\",\r\n \"description\": \"New Description\",\r\n \"category\": \"663e7932d513515319551c1d\"\r\n}\r\n", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://{{dev}}/api/posts/663e7b50d513515319551c31", + "protocol": "https", + "host": [ + "{{dev}}" + ], + "path": [ + "api", + "posts", + "663e7b50d513515319551c31" + ] + } + }, + "response": [] + }, + { + "name": "DeletePost", + "request": { + "method": "DELETE", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "apitoken", + "value": "{{token}}" + } + ], + "url": { + "raw": "https://{{dev}}/api/posts/663e7b96d513515319551c36", + "protocol": "https", + "host": [ + "{{dev}}" + ], + "path": [ + "api", + "posts", + "663e7b96d513515319551c36" + ] + } + }, + "response": [] + } + ] + }, + { + "name": "Comments", + "item": [ + { + "name": "CreateComment", + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + }, + { + "key": "apitoken", + "value": "{{token}}" + } + ], + "body": { + "mode": "raw", + "raw": "{\r\n \"author\": \"David\",\r\n \"content\": \"Hi\"\r\n}", + "options": { + "raw": { + "language": "json" + } + } + }, + "url": { + "raw": "https://{{dev}}/api/posts/663e7af3d513515319551c2b/comments", + "protocol": "https", + "host": [ + "{{dev}}" + ], + "path": [ + "api", + "posts", + "663e7af3d513515319551c2b", + "comments" + ] + } + }, + "response": [] + } + ] + } + ], + "auth": { + "type": "bearer", + "bearer": [ + { + "key": "token", + "value": "{{accessToken}}", + "type": "string" + } + ] + }, + "event": [ + { + "listen": "prerequest", + "script": { + "type": "text/javascript", + "packages": {}, + "exec": [ + "" + ] + } + }, + { + "listen": "test", + "script": { + "type": "text/javascript", + "packages": {}, + "exec": [ + "" + ] + } + } + ], + "variable": [ + { + "key": "accessToken", + "value": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6IjRoJUsuMSIsImlhdCI6MTcxNTczODE5NiwiZXhwIjoxNzE1NzQxNzk2fQ.5Eb8YtIVfJI1lvFMIsTQsUUDxc9-TmOkpCrmrhmuN-I", + "type": "string" + }, + { + "key": "dev", + "value": "test.neuraac.com", + "type": "string" + } + ] +} \ No newline at end of file diff --git a/apps/react-app/assets/generate-token.png b/apps/react-app/assets/generate-token.png new file mode 100644 index 00000000..75f31db6 Binary files /dev/null and b/apps/react-app/assets/generate-token.png differ diff --git a/apps/react-app/assets/postman-variables.png b/apps/react-app/assets/postman-variables.png new file mode 100644 index 00000000..ef3532b1 Binary files /dev/null and b/apps/react-app/assets/postman-variables.png differ diff --git a/apps/react-app/assets/register-user.png b/apps/react-app/assets/register-user.png new file mode 100644 index 00000000..6f5cc034 Binary files /dev/null and b/apps/react-app/assets/register-user.png differ diff --git a/apps/react-app/src/App.tsx b/apps/react-app/src/App.tsx index 3fe215a7..1787a03d 100644 --- a/apps/react-app/src/App.tsx +++ b/apps/react-app/src/App.tsx @@ -1,30 +1,17 @@ -import { Grid, Typography } from '@mui/material'; +import { RouterProvider } from 'react-router-dom'; -import { Container } from './components/Header/Header.styles'; +import { AuthProvider, PostProvider, SnackbarProvider } from './context'; +import Router from './Router'; function App() { return ( - - {/* Activity 2 - Move all Container content to Header component */} - - - - [ - Making your Life Easier - ] - - - - - Discovering the World - - - - - - {/* Activity 1 - Render HomePage and PostPage */} - - + + + + + + + ); } diff --git a/apps/react-app/src/Router/PrivateRoute.tsx b/apps/react-app/src/Router/PrivateRoute.tsx new file mode 100644 index 00000000..e727872c --- /dev/null +++ b/apps/react-app/src/Router/PrivateRoute.tsx @@ -0,0 +1,33 @@ +import { useContext, useEffect } from 'react'; +import { Navigate } from 'react-router-dom'; +import { Grid } from '@mui/material'; + +import Loading from '../components/Loading'; +import { AuthContext } from '../context'; + +interface PrivateRouteProps { + route: React.JSX.Element; +} + +const PrivateRoute = ({ route }: PrivateRouteProps): JSX.Element => { + const { authLoading, isAuthenticated, validateToken } = useContext(AuthContext); + + useEffect(() => { + const initialize = async () => { + await validateToken(); + }; + initialize(); + }, [validateToken]); + + if (isAuthenticated === null || authLoading === null || authLoading) { + return ( + + + + ); + } + + return
{isAuthenticated ? { ...route } : }
; +}; + +export default PrivateRoute; diff --git a/apps/react-app/src/Router/Router.tsx b/apps/react-app/src/Router/Router.tsx new file mode 100644 index 00000000..183003fb --- /dev/null +++ b/apps/react-app/src/Router/Router.tsx @@ -0,0 +1,28 @@ +import { createBrowserRouter } from 'react-router-dom'; +import Page, { CategoriesPage, HomePage, LoginPage, NotFoundPage, PostPage } from '../components/Page'; +import PrivateRoute from './PrivateRoute'; + +const Router = createBrowserRouter([ + { + path: '/', + element: } />} /> + }, + { + path: '/post/:postId', + element: } />} /> + }, + { + path: '/categories', + element: } />} /> + }, + { + path: '/login', + element: } /> + }, + { + path: '*', + element: } />} /> + } +]); + +export default Router; diff --git a/apps/react-app/src/Router/index.ts b/apps/react-app/src/Router/index.ts new file mode 100644 index 00000000..1c664800 --- /dev/null +++ b/apps/react-app/src/Router/index.ts @@ -0,0 +1 @@ +export { default } from './Router'; diff --git a/apps/react-app/src/api/axios.ts b/apps/react-app/src/api/axios.ts new file mode 100644 index 00000000..08558c78 --- /dev/null +++ b/apps/react-app/src/api/axios.ts @@ -0,0 +1,13 @@ +import axios from 'axios'; + +const axiosInstance = axios.create(); +const token = localStorage.getItem('apiToken'); + +axiosInstance.interceptors.request.use((config) => { + config.baseURL = 'https://test.neuraac.com/api'; + config.headers.Authorization = `Bearer ${token}`; + config.signal = AbortSignal.timeout(5000); + return config; +}); + +export default axiosInstance; diff --git a/apps/react-app/src/api/endpoints/auth.ts b/apps/react-app/src/api/endpoints/auth.ts new file mode 100644 index 00000000..bbad2482 --- /dev/null +++ b/apps/react-app/src/api/endpoints/auth.ts @@ -0,0 +1,121 @@ +import { AxiosError, AxiosResponse } from 'axios'; + +import axios from '../axios'; +import { User, AuthResponse, AuthLoginResponse, NewUser } from '../../types'; + +export const createUser = async ({ + newUser, + onSuccess, + onError, + onLoading, +}: { + newUser: NewUser; + onSuccess?: (data: AuthResponse) => void; + onError?: (error: AxiosError) => void; + onLoading?: (isLoading: boolean) => void; +}) => { + onLoading && onLoading(true); + + await axios({ + method: 'post', + url: '/auth/register', + data: newUser, + }) + .then((response: AxiosResponse) => { + const data: AuthResponse = response.data; + if (response.status === 201 && onSuccess) onSuccess(data); + }) + .catch((error: AxiosError) => { + console.error(`${error}`); + onError && onError(error); + }) + .finally(() => onLoading && onLoading(false)); +}; + +export const login = async ({ + user, + onSuccess, + onError, + onLoading, +}: { + user: User; + onSuccess?: (data: AuthLoginResponse) => void; + onError?: (error: AxiosError) => void; + onLoading?: (isLoading: boolean) => void; +}) => { + onLoading && onLoading(true); + + await axios({ + method: 'post', + url: '/auth/login', + data: user, + }) + .then((response: AxiosResponse) => { + const data: AuthLoginResponse = response.data; + if (response.status === 200 && onSuccess) { + const token = data.accessToken; + localStorage.setItem('apiToken', token); + onSuccess(data); + } + }) + .catch((error: AxiosError) => { + console.error(`${error}`); + onError && onError(error); + }) + .finally(() => onLoading && onLoading(false)); +}; + +export const logout = async ({ + onSuccess, + onError, + onLoading, +}: { + onSuccess?: (data: AuthResponse) => void; + onError?: (error: AxiosError) => void; + onLoading?: (isLoading: boolean) => void; +}) => { + onLoading && onLoading(true); + + await axios({ + url: '/auth/logout', + method: 'post', + }) + .then((response: AxiosResponse) => { + const data: AuthResponse = response.data; + if (response.status === 200 && onSuccess) { + localStorage.removeItem('apiToken'); + onSuccess(data); + } + }) + .catch((error: AxiosError) => { + console.error(`${error}`); + onError && onError(error); + }) + .finally(() => onLoading && onLoading(false)); +}; + +export const refreshToken = async ({ + onSuccess, + onError, + onLoading, +}: { + onSuccess?: (data: AuthResponse) => void; + onError?: (error: AxiosError) => void; + onLoading?: (isLoading: boolean) => void; +}) => { + onLoading && onLoading(true); + + await axios({ + url: '/auth/refresh', + method: 'post', + }) + .then((response: AxiosResponse) => { + const data: AuthResponse = response.data; + if (response.status === 200 && onSuccess) onSuccess(data); + }) + .catch((error: AxiosError) => { + console.error(`${error}`); + onError && onError(error); + }) + .finally(() => onLoading && onLoading(false)); +}; diff --git a/apps/react-app/src/api/endpoints/categories.ts b/apps/react-app/src/api/endpoints/categories.ts new file mode 100644 index 00000000..6796cf37 --- /dev/null +++ b/apps/react-app/src/api/endpoints/categories.ts @@ -0,0 +1,118 @@ +import { AxiosError, AxiosResponse } from "axios"; + +import axios from "../axios"; +import { /* Category, */ CategoriesResponse, NewCategory } from "../../types"; + +export const getCategories = async ({ + onSuccess, + onError, + onLoading, +}: { + onSuccess?: (data: CategoriesResponse[]) => void; + onError?: (error: AxiosError) => void; + onLoading?: (isLoading: boolean) => void; +}) => { + onLoading && onLoading(true); + + await axios({ + url: "/categories", + method: "get", + }) + .then((response: AxiosResponse) => { + const data: CategoriesResponse[] = response.data; + if (response.status === 200 && onSuccess) onSuccess(data); + }) + .catch((error: AxiosError) => { + console.error(`${error}`); + onError && onError(error); + }) + .finally(() => onLoading && onLoading(false)); +}; + +export const createCategory = async ({ + newCategory, + onSuccess, + onError, + onLoading, +}: { + newCategory: NewCategory; + onSuccess?: (data: CategoriesResponse[]) => void; + onError?: (error: AxiosError) => void; + onLoading?: (isLoading: boolean) => void; +}) => { + onLoading && onLoading(true); + + await axios({ + url: "/categories", + method: "post", + data: newCategory, + }) + .then((response: AxiosResponse) => { + const data: CategoriesResponse[] = response.data; + if (response.status === 200 && onSuccess) onSuccess(data); + }) + .catch((error: AxiosError) => { + console.error(`${error}`); + onError && onError(error); + }) + .finally(() => onLoading && onLoading(false)); +}; + +export const updateCategory = async ({ + categoryId, + updatedCategory, + onSuccess, + onError, + onLoading, +}: { + categoryId: string; + updatedCategory: NewCategory; + onSuccess?: (data: CategoriesResponse[]) => void; + onError?: (error: AxiosError) => void; + onLoading?: (isLoading: boolean) => void; +}) => { + onLoading && onLoading(true); + + await axios({ + url: `/categories/${categoryId}`, + method: "patch", + data: updatedCategory, + }) + .then((response: AxiosResponse) => { + const data: CategoriesResponse[] = response.data; + if (response.status === 200 && onSuccess) onSuccess(data); + }) + .catch((error: AxiosError) => { + console.error(`${error}`); + onError && onError(error); + }) + .finally(() => onLoading && onLoading(false)); +}; + +export const deleteCategory = async ({ + categoryId, + onSuccess, + onError, + onLoading, +}: { + categoryId: string; + onSuccess?: (data: CategoriesResponse[]) => void; + onError?: (error: AxiosError) => void; + onLoading?: (isLoading: boolean) => void; +}) => { + onLoading && onLoading(true); + + await axios({ + url: `/categories/${categoryId}`, + method: "delete", + }) + .then((response: AxiosResponse) => { + const data: CategoriesResponse[] = response.data; + if (response.status === 200 && onSuccess) onSuccess(data); + }) + .catch((error: AxiosError) => { + console.error(`${error}`); + onError && onError(error); + }) + .finally(() => onLoading && onLoading(false)); +}; diff --git a/apps/react-app/src/api/endpoints/comments.ts b/apps/react-app/src/api/endpoints/comments.ts new file mode 100644 index 00000000..f722c995 --- /dev/null +++ b/apps/react-app/src/api/endpoints/comments.ts @@ -0,0 +1,35 @@ +import { AxiosError, AxiosResponse } from "axios"; + +import axios from "../axios"; +import { NewComment, CommentResponse } from "../../types"; + +export const createComment = async ({ + postId, + newComment, + onSuccess, + onError, + onLoading, +}: { + postId: string; + newComment: NewComment; + onSuccess?: (data: CommentResponse) => void; + onError?: (error: AxiosError) => void; + onLoading?: (isLoading: boolean) => void; +}) => { + onLoading && onLoading(true); + + await axios({ + method: "post", + url: `/posts/${postId}/comments`, + data: newComment, + }) + .then((response: AxiosResponse) => { + const data: CommentResponse = response.data; + if (response.status === 201 && onSuccess) onSuccess(data); + }) + .catch((error: AxiosError) => { + console.error(`${error}`); + onError && onError(error); + }) + .finally(() => onLoading && onLoading(false)); +}; diff --git a/apps/react-app/src/api/endpoints/index.ts b/apps/react-app/src/api/endpoints/index.ts new file mode 100644 index 00000000..efbe5817 --- /dev/null +++ b/apps/react-app/src/api/endpoints/index.ts @@ -0,0 +1,4 @@ +export * from "./categories"; +export * from "./comments"; +export * from "./posts"; +export * from "./auth"; diff --git a/apps/react-app/src/api/endpoints/posts.ts b/apps/react-app/src/api/endpoints/posts.ts new file mode 100644 index 00000000..fd174586 --- /dev/null +++ b/apps/react-app/src/api/endpoints/posts.ts @@ -0,0 +1,173 @@ +import { AxiosError, AxiosResponse } from "axios"; + +import axios from "../axios"; +import { PostsResponse, PostResponse, NewPost } from "../../types"; + +export const getPosts = async ({ + onSuccess, + onError, + onLoading, +}: { + onSuccess?: (data: PostsResponse[]) => void; + onError?: (error: AxiosError) => void; + onLoading?: (isLoading: boolean) => void; +}) => { + onLoading && onLoading(true); + + await axios({ + url: "/posts", + method: "get", + }) + .then((response: AxiosResponse) => { + const data: PostsResponse[] = response.data; + if (response.status === 200 && onSuccess) onSuccess(data); + }) + .catch((error: AxiosError) => { + console.error(`${error}`); + onError && onError(error); + }) + .finally(() => onLoading && onLoading(false)); +}; + +export const getPostsByCategory = async ({ + selectedCategoryID, + onSuccess, + onError, + onLoading, +}: { + selectedCategoryID: string; + onSuccess?: (data: PostsResponse[]) => void; + onError?: (error: AxiosError) => void; + onLoading?: (isLoading: boolean) => void; +}) => { + onLoading && onLoading(true); + + await axios({ + url: `/posts/category/${selectedCategoryID}`, + method: "get", + }) + .then((response: AxiosResponse) => { + const data: PostsResponse[] = response.data; + if (response.status === 200 && onSuccess) onSuccess(data); + }) + .catch((error: AxiosError) => { + console.error(`${error}`); + onError && onError(error); + }) + .finally(() => onLoading && onLoading(false)); +}; + +export const getPost = async ({ + postId, + onSuccess, + onError, + onLoading, +}: { + postId: string; + onSuccess?: (data: PostResponse) => void; + onError?: (error: AxiosError) => void; + onLoading?: (isLoading: boolean) => void; +}) => { + onLoading && onLoading(true); + + await axios({ + url: `/posts/${postId}`, + method: "get", + }) + .then((response: AxiosResponse) => { + const data: PostResponse = response.data; + if (response.status === 200 && onSuccess) onSuccess(data); + }) + .catch((error: AxiosError) => { + console.error(`${error}`); + onError && onError(error); + }) + .finally(() => onLoading && onLoading(false)); +}; + +export const createPost = async ({ + newPost, + onSuccess, + onError, + onLoading, +}: { + newPost: NewPost; + onSuccess?: (data: PostsResponse) => void; + onError?: (error: AxiosError) => void; + onLoading?: (isLoading: boolean) => void; +}) => { + onLoading && onLoading(true); + + await axios({ + method: "post", + url: `/posts`, + data: newPost, + }) + .then((response: AxiosResponse) => { + const data: PostsResponse = response.data; + if (response.status === 201 && onSuccess) onSuccess(data); + }) + .catch((error: AxiosError) => { + console.error(`${error}`); + onError && onError(error); + }) + .finally(() => onLoading && onLoading(false)); +}; + +export const updatePost = async ({ + postId, + updatedPost, + onSuccess, + onError, + onLoading, +}: { + postId: string; + updatedPost: NewPost; + onSuccess?: (data: PostsResponse) => void; + onError?: (error: AxiosError) => void; + onLoading?: (isLoading: boolean) => void; +}) => { + onLoading && onLoading(true); + + await axios({ + url: `/posts/${postId}`, + method: "patch", + data: updatedPost, + }) + .then((response: AxiosResponse) => { + const data: PostsResponse = response.data; + if (response.status === 200 && onSuccess) onSuccess(data); + }) + .catch((error: AxiosError) => { + console.error(`${error}`); + onError && onError(error); + }) + .finally(() => onLoading && onLoading(false)); +}; + +export const deletePost = async ({ + postId, + onSuccess, + onError, + onLoading, +}: { + postId: string; + onSuccess?: () => void; + onError?: (error: AxiosError) => void; + onLoading?: (isLoading: boolean) => void; +}) => { + onLoading && onLoading(true); + + await axios({ + url: `/posts/${postId}`, + method: "delete", + }) + .then((response: AxiosResponse) => { + if (response.status === 204 && onSuccess) onSuccess(); + }) + .catch((error: AxiosError) => { + console.error(`${error}`); + onError && onError(error); + }) + .finally(() => onLoading && onLoading(false)); +}; diff --git a/apps/react-app/src/api/index.ts b/apps/react-app/src/api/index.ts new file mode 100644 index 00000000..c9cd5154 --- /dev/null +++ b/apps/react-app/src/api/index.ts @@ -0,0 +1 @@ +export * from "./endpoints" \ No newline at end of file diff --git a/apps/react-app/src/common/utils/getErrorMessage.tsx b/apps/react-app/src/common/utils/getErrorMessage.tsx new file mode 100644 index 00000000..d2b53bb6 --- /dev/null +++ b/apps/react-app/src/common/utils/getErrorMessage.tsx @@ -0,0 +1,8 @@ +export const errorMessage = (type: string | undefined) => { + let error: string = ""; + if (type === "required") error = "This field is required"; + if (type === "minLength") error = "Please, write more than 10 characters"; + if (type === "maxLength") error = "Comment cannot exceed 20 characters"; + return

{error}

; +}; + diff --git a/apps/react-app/src/common/utils/index.ts b/apps/react-app/src/common/utils/index.ts new file mode 100644 index 00000000..ed85ce09 --- /dev/null +++ b/apps/react-app/src/common/utils/index.ts @@ -0,0 +1,4 @@ +export { validator } from "./inputsValidator"; +export { errorMessage } from "./getErrorMessage"; +export { shorten } from "./shorten"; +export { stableSort } from "./sortTable"; diff --git a/apps/react-app/src/common/utils/inputsValidator.tsx b/apps/react-app/src/common/utils/inputsValidator.tsx new file mode 100644 index 00000000..d6131285 --- /dev/null +++ b/apps/react-app/src/common/utils/inputsValidator.tsx @@ -0,0 +1,28 @@ +function validateUrl(url: string) { + const re = /https?:\/\/(www\.)?[-a-zA-Z0-9@:%._+~#=]{1,256}\.[a-zA-Z0-9()]{1,6}\b([-a-zA-Z0-9()@:%_+.~#?&//=]*)/; + return re.test(url); +} + +export const validator = ({ name, value }: { name: string; value: string }) => { + let error = ''; + if (!value) return (error = 'Please fill out this field.'); + + switch (name) { + case 'title': + if (value.length < 5 || value.length > 25) error = 'The title must contain more than 5 and less than 25 characters.'; + break; + + case 'description': + if (value.length < 20) error = 'The description must contain more than 20 characters.'; + break; + + case 'image': + if (!validateUrl(value)) error = 'Please enter a valid URL.'; + break; + + default: + break; + } + + return error; +}; diff --git a/apps/react-app/src/utils/index.tsx b/apps/react-app/src/common/utils/shorten.tsx similarity index 61% rename from apps/react-app/src/utils/index.tsx rename to apps/react-app/src/common/utils/shorten.tsx index 30d39a3d..8a56fadd 100644 --- a/apps/react-app/src/utils/index.tsx +++ b/apps/react-app/src/common/utils/shorten.tsx @@ -1,4 +1,4 @@ export function shorten(str: string, maxLen: number) { if (str.length <= maxLen) return str; - return `${str.substr(0, str.lastIndexOf(" ", maxLen))}...`; + return `${str.substr(0, str.lastIndexOf(' ', maxLen))}...`; } diff --git a/apps/react-app/src/common/utils/sortTable.tsx b/apps/react-app/src/common/utils/sortTable.tsx new file mode 100644 index 00000000..a2f15c5c --- /dev/null +++ b/apps/react-app/src/common/utils/sortTable.tsx @@ -0,0 +1,52 @@ +import { Order, TableData } from "../../types"; + +function descendingComparator( + a: TableData, + b: TableData, + orderBy: string +) { + if (b[orderBy] < a[orderBy]) return -1; + if (b[orderBy] > a[orderBy]) return 1; + return 0; +} + +function comparator({ + a, + b, + order, + orderBy, +}: { + a: TableData; + b: TableData; + order: Order; + orderBy: string; +}) { + return order === "desc" + ? descendingComparator(a, b, orderBy) + : -descendingComparator(a, b, orderBy); +} + +export function stableSort({ + rows, + order: selectedOrder, + orderBy, +}: { + rows: TableData[]; + order: Order; + orderBy: string; +}) { + const stabilizedThis = rows.map( + (el, index) => [el, index] as [TableData, number] + ); + stabilizedThis.sort((a, b) => { + const order = comparator({ + a: a[0], + b: b[0], + order: selectedOrder, + orderBy, + }); + if (order !== 0) return order; + return a[1] - b[1]; + }); + return stabilizedThis.map((el) => el[0]); +} diff --git a/apps/react-app/src/components/Banner/Banner.tsx b/apps/react-app/src/components/Banner/Banner.tsx index 4033c6d9..ed815da3 100644 --- a/apps/react-app/src/components/Banner/Banner.tsx +++ b/apps/react-app/src/components/Banner/Banner.tsx @@ -1,19 +1,28 @@ -import Button from "@mui/material/Button"; -import ArrowBackIosIcon from "@mui/icons-material/ArrowBackIos"; -import { BannerContent, BannerTitle, Container } from "./Banner.styles"; +import Button from '@mui/material/Button'; +import ArrowBackIosIcon from '@mui/icons-material/ArrowBackIos'; +import { useNavigate } from 'react-router-dom'; -// const postTitle = "A good place to camp"; -const postImage = - "https://th.bing.com/th/id/R.e0bad63364a867fea652212c254bf869?rik=avtecz5aXVdevA&riu=http%3a%2f%2fwww.viajejet.com%2fwp-content%2fviajes%2fLago-Moraine-Parque-Nacional-Banff-Alberta-Canada.jpg&ehk=6qRhWDqqQAEkSFs%2bHP8p2Bl6XfPbjznSoORh%2bsEJ%2bQE%3d&risl=&pid=ImgRaw&r=0"; +import { BannerContent, BannerTitle, Container } from './Banner.styles'; + +interface BannerProps { + postImage: string; + postTitle: string; +} + +function Banner({ postImage, postTitle }: BannerProps) { + const navigate = useNavigate(); + + const goToHome = () => { + navigate('/'); + }; -function Banner() { return ( - - {/* Activity 1 - Render post title */} + {postTitle} ); diff --git a/apps/react-app/src/components/CategoryButtonGroup/CategoryButtonGroup.styles.tsx b/apps/react-app/src/components/CategoryButtonGroup/CategoryButtonGroup.styles.tsx index ceee193f..c00d86d1 100644 --- a/apps/react-app/src/components/CategoryButtonGroup/CategoryButtonGroup.styles.tsx +++ b/apps/react-app/src/components/CategoryButtonGroup/CategoryButtonGroup.styles.tsx @@ -3,7 +3,6 @@ import { styled } from "@mui/system"; export const Container = styled(Grid)` display: flex; - flex-grow: 1; justify-content: center; padding-bottom: 16px; `; diff --git a/apps/react-app/src/components/CategoryButtonGroup/CategoryButtonGroup.tsx b/apps/react-app/src/components/CategoryButtonGroup/CategoryButtonGroup.tsx index 2f94d5ec..42ed0660 100644 --- a/apps/react-app/src/components/CategoryButtonGroup/CategoryButtonGroup.tsx +++ b/apps/react-app/src/components/CategoryButtonGroup/CategoryButtonGroup.tsx @@ -1,42 +1,28 @@ -import { ButtonGroup } from "@mui/material"; +import { ButtonGroup } from '@mui/material'; -import { Container, StyledButton } from "./CategoryButtonGroup.styles"; +import { Container, StyledButton } from './CategoryButtonGroup.styles'; +import { Category } from '../../types'; -// const categoryOptions = [ -// { -// key: "all", -// name: "All", -// }, -// { -// key: "healt", -// name: "Health", -// }, -// { -// key: "travel", -// name: "Travel", -// }, -// { -// key: "sports", -// name: "Sports", -// }, -// ]; +interface CategoryButtonGroupProps { + categories: Category[]; + selectedCategory: Category | null; + handleSelectCategory: (category: Category) => void; +} -function CategoryButtonGroup() { +function CategoryButtonGroup({ categories, selectedCategory, handleSelectCategory }: CategoryButtonGroupProps) { return ( - - {/* Activity 1 - Render category name */} - - - {/* Activity 1 - Render category name */} - - - {/* Activity 1 - Render category name */} - - - {/* Activity 1 - Render category name */} - + {categories.map((category) => ( + handleSelectCategory(category)} + > + {category.name} + + ))} ); diff --git a/apps/react-app/src/components/CommentCard/CommentCard.tsx b/apps/react-app/src/components/CommentCard/CommentCard.tsx index 2f6c2187..4531c886 100644 --- a/apps/react-app/src/components/CommentCard/CommentCard.tsx +++ b/apps/react-app/src/components/CommentCard/CommentCard.tsx @@ -1,22 +1,20 @@ -import { Typography } from "@mui/material"; -import AccountCircleIcon from "@mui/icons-material/AccountCircle"; +import { Typography } from '@mui/material'; +import AccountCircleIcon from '@mui/icons-material/AccountCircle'; -import { Container, Content, Author } from "./CommentCard.styles"; +import { Container, Content, Author } from './CommentCard.styles'; -// const comment = { -// id: "2.1", -// author: "Anonymus", -// content: -// "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.", -// }; +interface CommentCardProps { + author: string; + content: string; +} -function CommentCard() { +function CommentCard({ author, content }: CommentCardProps) { return ( - {/* Activity 1 - Render author */} - {/* Activity 1 - Render comment content */} + {author} + {content} ); diff --git a/apps/react-app/src/components/Comments/Comments.tsx b/apps/react-app/src/components/Comments/Comments.tsx index e7e711db..2a2d0689 100644 --- a/apps/react-app/src/components/Comments/Comments.tsx +++ b/apps/react-app/src/components/Comments/Comments.tsx @@ -1,12 +1,58 @@ -import { Title, Container } from "./Comments.styles"; +import { useState } from 'react'; +import { Button, TextField, Typography } from '@mui/material'; + +import { Title, Container, FormContainer } from './Comments.styles'; +import CommentCard from '../CommentCard/CommentCard'; +import { Comment } from '../../types/index'; + +interface CommentsProps { + comments: Comment[]; + handleCommentSubmition: (comment: Comment) => void; +} + +function Comments({ comments, handleCommentSubmition }: CommentsProps) { + // const [commentsArray, setCommentsArray] = useState(comments); + const [comment, setComment] = useState(''); + + function handleComment(str: string) { + setComment(str); + } -function Comments() { return ( <h4>Comments</h4> - {/* Activity 1 - Render CommentCard */} + {comments.map((comment: Comment) => { + return ; + })} + + + Add a comment + + handleComment(e.target.value)} + /> + + ); } diff --git a/apps/react-app/src/components/CreatePostButton/CreatePostButton.styles.tsx b/apps/react-app/src/components/CreatePostButton/CreatePostButton.styles.tsx index c09e2ab1..222cef80 100644 --- a/apps/react-app/src/components/CreatePostButton/CreatePostButton.styles.tsx +++ b/apps/react-app/src/components/CreatePostButton/CreatePostButton.styles.tsx @@ -3,7 +3,6 @@ import { styled } from "@mui/system"; export const Container = styled(Grid)` display: flex; - flex-grow: 1; justify-content: end; - padding-right: 16px; + padding: 8px 16px; `; diff --git a/apps/react-app/src/components/CreatePostButton/CreatePostButton.tsx b/apps/react-app/src/components/CreatePostButton/CreatePostButton.tsx index a3bd3ea1..4589f496 100644 --- a/apps/react-app/src/components/CreatePostButton/CreatePostButton.tsx +++ b/apps/react-app/src/components/CreatePostButton/CreatePostButton.tsx @@ -1,12 +1,17 @@ import EditIcon from "@mui/icons-material/Edit"; import { IconButton } from "@mui/material"; +import { Post } from "../../types"; import { Container } from "./CreatePostButton.styles"; -const CreatePostButton = () => { +interface CreatePostButtonInterface { + handleOpenForm: (defaultValues?: Post) => void; +} + +const CreatePostButton = ({ handleOpenForm }: CreatePostButtonInterface) => { return ( - + handleOpenForm()}> diff --git a/apps/react-app/src/components/Form/Form.tsx b/apps/react-app/src/components/Form/Form.tsx new file mode 100644 index 00000000..2df060db --- /dev/null +++ b/apps/react-app/src/components/Form/Form.tsx @@ -0,0 +1,185 @@ +import * as React from 'react'; +import { Button, TextField, Dialog, DialogActions, DialogContent, DialogTitle, MenuItem, SelectChangeEvent } from '@mui/material'; + +import { Category, NewPost, Post } from '../../types'; +import { validator } from '../../common/utils'; +import { PostContext } from '../../context'; +import { FormInputs, Inputs } from '../../types'; + +const inputs: Inputs = [ + { + id: 'title-id', + name: 'title', + label: 'Title', + type: 'text' + }, + { + id: 'description-id', + name: 'description', + label: 'Description', + type: 'text' + }, + { + id: 'category-label', + name: 'category', + label: 'Category', + type: 'menu', + options: [ + { id: '663fef70d513515319551d1f', name: 'Travel' }, + { id: '663fef70d513515319546d1f', name: 'Food' } + ] + }, + { + id: 'url-id', + name: 'image', + label: 'URL of the image', + type: 'url' + } +]; + +const emptyInputs: FormInputs = { + title: { value: '', error: '' }, + description: { value: '', error: '' }, + category: { value: '', error: '' }, + image: { value: '', error: '' } +}; + +interface FormProps { + open: boolean; + post: Post | null; + categories: Category[] | null; + selectedCategory: Category | null; + setOpen: React.Dispatch>; + setSelectedPost: (value: React.SetStateAction) => void; +} + +const Form = ({ open, post, categories, selectedCategory, setOpen, setSelectedPost }: FormProps) => { + const [formData, setFormData] = React.useState(emptyInputs); + const { addPost, updatePostData } = React.useContext(PostContext); + + React.useEffect(() => { + if (!post) return; + const existingPost = { + title: { value: post.title, error: '' }, + description: { value: post.description, error: '' }, + category: { value: post.category?._id ?? '', error: '' }, + image: { value: post.image, error: '' } + }; + setFormData(existingPost); + }, [post]); + + const handleClose = () => { + setFormData(emptyInputs); + setOpen(false); + setSelectedPost(null); + }; + + const handleSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + + const inputs = Object.values(formData); + const containError = inputs.map((input) => input.error).some((v) => !!v); + if (containError) return; + + const newPost: NewPost = { + title: formData.title.value, + image: formData.image.value, + description: formData.description.value, + category: formData.category.value + }; + + handleClose(); + + post + ? await updatePostData({ + postId: post.id, + updatedPost: newPost, + selectedCategoryID: selectedCategory?.id + }) + : await addPost(newPost); + }; + + const handleChange = (e: React.ChangeEvent | SelectChangeEvent) => { + const { name, value } = e.target; + setFormData((prevFormData) => ({ + ...prevFormData, + [name]: { value, error: '' } + })); + }; + + const handleBlur = (e: React.ChangeEvent | SelectChangeEvent) => { + const { name, value } = e.target; + const error = validator({ name, value }); + setFormData((prevFormData) => ({ + ...prevFormData, + [name]: { ...prevFormData[name as keyof FormInputs], error } + })); + }; + + return ( + + + Create Post + + + {inputs.map((input, idx) => ( + + {(input.type === 'text' || input.type === 'url') && ( + + )} + {input.type === 'menu' && ( + + {categories?.map((option, idx) => ( + + {option.name} + + ))} + + )} + + ))} + + + + + + + ); +}; + +export default Form; diff --git a/apps/react-app/src/components/Form/index.ts b/apps/react-app/src/components/Form/index.ts new file mode 100644 index 00000000..8bc7b770 --- /dev/null +++ b/apps/react-app/src/components/Form/index.ts @@ -0,0 +1 @@ +export { default } from './Form'; diff --git a/apps/react-app/src/components/Header/Header.tsx b/apps/react-app/src/components/Header/Header.tsx deleted file mode 100644 index 8b137891..00000000 --- a/apps/react-app/src/components/Header/Header.tsx +++ /dev/null @@ -1 +0,0 @@ - diff --git a/apps/react-app/src/components/Header/index.ts b/apps/react-app/src/components/Header/index.ts deleted file mode 100644 index 6df4c0d6..00000000 --- a/apps/react-app/src/components/Header/index.ts +++ /dev/null @@ -1 +0,0 @@ -// export { default } from './Header'; diff --git a/apps/react-app/src/components/HomePage/HomePage.tsx b/apps/react-app/src/components/HomePage/HomePage.tsx deleted file mode 100644 index 8c747833..00000000 --- a/apps/react-app/src/components/HomePage/HomePage.tsx +++ /dev/null @@ -1,5 +0,0 @@ -function HomePage() { - return <>{/* Activity 1 - Render CreatePostButton, CategoryButtonGroup and PostList */}; -} - -export default HomePage; diff --git a/apps/react-app/src/components/Loading/Loading.tsx b/apps/react-app/src/components/Loading/Loading.tsx index 5ea16a52..196f107a 100644 --- a/apps/react-app/src/components/Loading/Loading.tsx +++ b/apps/react-app/src/components/Loading/Loading.tsx @@ -1,6 +1,6 @@ -import React from 'react'; -import CircularProgress from '@mui/material/CircularProgress'; -import Grid from '@mui/material/Grid'; +import React from "react"; +import CircularProgress from "@mui/material/CircularProgress"; +import Grid from "@mui/material/Grid"; /** * This shows a horizontally and vertically centred loading spinner to use when a component is loading content to display @@ -9,11 +9,15 @@ export default function Loading(): React.JSX.Element { return ( - + ); } diff --git a/apps/react-app/src/components/Loading/index.ts b/apps/react-app/src/components/Loading/index.ts index 62141369..e610201e 100644 --- a/apps/react-app/src/components/Loading/index.ts +++ b/apps/react-app/src/components/Loading/index.ts @@ -1 +1 @@ -export { default } from './Loading'; +export { default } from './Loading'; \ No newline at end of file diff --git a/apps/react-app/src/components/NavBar/NavBar.tsx b/apps/react-app/src/components/NavBar/NavBar.tsx new file mode 100644 index 00000000..3a7becb4 --- /dev/null +++ b/apps/react-app/src/components/NavBar/NavBar.tsx @@ -0,0 +1,124 @@ +import React, { useContext } from 'react'; +import { NavLink } from 'react-router-dom'; +import TravelExploreIcon from '@mui/icons-material/TravelExplore'; +import { Box, Button, Typography } from '@mui/material'; +import Grid from '@mui/material/Grid'; + +import { AuthContext } from '../../context'; +import { SnackbarContext } from '../../context/SnackbarProvider'; +import { logout } from '../../api/endpoints/auth'; + +export default function NavBar(): React.JSX.Element { + const { createAlert } = useContext(SnackbarContext); + const { isAuthenticated } = useContext(AuthContext); + + const loginLink = ( + ({ + gap: '16px', + display: 'flex', + alignItems: 'center', + borderRadius: '10px', + backgroundColor: isActive ? '#307ecc' : 'transparent', + padding: '0.5em', + textDecoration: 'none', + color: 'white' + })} + > + Login + + ); + + const logoutLink = ( + + ); + + return ( + + + + + + Discovering the World + + + Making your Life Easier + + + + + + ({ + gap: '16px', + display: 'flex', + alignItems: 'center', + borderRadius: '10px', + padding: '0.5em', + backgroundColor: isActive ? '#307ecc' : 'transparent', + textDecoration: 'none', + color: 'white' + })} + > + Categories + + {isAuthenticated ? logoutLink : loginLink} + + + ); +} diff --git a/apps/react-app/src/components/NavBar/index.ts b/apps/react-app/src/components/NavBar/index.ts new file mode 100644 index 00000000..7d65d872 --- /dev/null +++ b/apps/react-app/src/components/NavBar/index.ts @@ -0,0 +1 @@ +export {default} from "./NavBar" \ No newline at end of file diff --git a/apps/react-app/src/components/Page/CategoriesPage/CategoriesPage.styles.tsx b/apps/react-app/src/components/Page/CategoriesPage/CategoriesPage.styles.tsx new file mode 100644 index 00000000..13b701e6 --- /dev/null +++ b/apps/react-app/src/components/Page/CategoriesPage/CategoriesPage.styles.tsx @@ -0,0 +1,11 @@ +import { Grid } from "@mui/material"; +import { styled } from "@mui/system"; + +export const PageContainer = styled(Grid)` + gap: 16px; + display: flex; + padding: 32px; + flex-wrap: nowrap; + flex-direction: column; + flex-grow: 1; +`; diff --git a/apps/react-app/src/components/Page/CategoriesPage/CategoriesPage.tsx b/apps/react-app/src/components/Page/CategoriesPage/CategoriesPage.tsx new file mode 100644 index 00000000..1bbff9ce --- /dev/null +++ b/apps/react-app/src/components/Page/CategoriesPage/CategoriesPage.tsx @@ -0,0 +1,253 @@ +import { useCallback, useContext, useEffect, useState } from 'react'; +import { + Button, + Dialog, + DialogTitle, + Grid, + IconButton, + Paper, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + TextField +} from '@mui/material'; +import AddIcon from '@mui/icons-material/Add'; +import DeleteIcon from '@mui/icons-material/Delete'; +import EditIcon from '@mui/icons-material/Edit'; +import { AxiosError, AxiosResponse } from 'axios'; + +import { PageContainer } from './CategoriesPage.styles'; +import { createCategory, deleteCategory, getCategories /* updateCategory */, updateCategory } from '../../../api'; +import axiosInstance from '../../../api/axios'; +import { /* CreateAlert, */ SnackbarContext } from '../../../context/SnackbarProvider'; +import { CategoriesResponse, Category, NewCategory } from '../../../types'; + +const emptyCategory = { id: '', name: '' }; +const emptyInput = { + name: { value: '', error: '' } +}; + +const style = { + position: 'absolute' as const, + top: '50%', + left: '50%', + transform: 'translate(-50%, -50%)', + width: 400, + padding: '1em', + bgcolor: 'background.paper', + // boxShadow: 24, + p: 4 +}; + +function CategoriesPage() { + const [category, setCategory] = useState(emptyCategory); // The category to be edited. + const [formData, setFormData] = useState(emptyInput); + const [open, setOpen] = useState(false); + const [rows, setRows] = useState(null); + const { createAlert } = useContext(SnackbarContext); + + // const handleAdd = (categoryName: string) => { + // categories.push({ + // id: categories[categories.length - 1].id, + // name: categoryName + // }); + // setRows(categories); + // handleClose(); + // }; + + const onError = useCallback(() => { + createAlert('Something went wrong.', 'error'); + }, [createAlert]); + + const getCategoryList = useCallback(async () => { + const onSuccess = async (data: CategoriesResponse[]) => { + const newList = data.map((category) => ({ + id: category._id, + name: category.name, + createdAt: category.createdAt, + updatedAt: category.updatedAt, + __v: category.__v + })); + setRows(newList); + }; + + const params = { onSuccess, onError }; + await getCategories(params); + }, [onError]); + + const addCategory = useCallback( + async (newCategory: NewCategory) => { + const onSuccess = async () => { + await getCategoryList(); + createAlert('Category successfully created.', 'success'); + }; + + await createCategory({ newCategory, onSuccess, onError }); + }, + [createAlert, getCategoryList, onError] + ); + + const updateCategoryData = useCallback( + async ({ categoryId, updatedCategory }: { categoryId: string; updatedCategory: NewCategory }) => { + const onSuccess = async () => { + await getCategoryList(); + createAlert('Category successfully updated.', 'success'); + }; + + await updateCategory({ categoryId, updatedCategory, onSuccess, onError }); + }, + [createAlert, onError, getCategoryList] + ); + + const removeCategory = useCallback( + async ({ categoryId }: { categoryId: string }) => { + const onSuccess = async () => { + await getCategoryList(); + createAlert('Category successfully deleted.', 'success'); + }; + await deleteCategory({ categoryId, onSuccess, onError }); + }, + [createAlert, onError, getCategoryList] + ); + + const handleClose = () => { + setFormData(emptyInput); + setOpen(false); + }; + + const handleSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + + const inputs = Object.values(formData); + const containError = inputs.map((input) => input.error).some((v) => !!v); + if (containError) return; + + const newCategory: NewCategory = { + name: formData.name.value + }; + + handleClose(); + + category !== emptyCategory + ? await updateCategoryData({ + categoryId: category.id, + updatedCategory: newCategory + }) + : await addCategory(newCategory); + + setCategory(emptyCategory); + }; + + const handleOpen = () => setOpen(true); + + const handleNew = () => { + handleOpen(); + setCategory(emptyCategory); + }; + + function handleEditItem(category: Category) { + handleOpen(); + setCategory(category); + } + + useEffect(() => { + axiosInstance({ + url: '/categories', + method: 'get' + }) + .then((res: AxiosResponse) => { + getCategoryList(); + }) + .catch((res: AxiosError) => { + console.log(res); + }); + }); + + return ( + + + + + + + + + + + + + Name + Actions + + + + + {rows?.map((row) => ( + + + {row.name} + + + + { + e.stopPropagation(); + removeCategory({ categoryId: row.id }); + }} + > + + + + { + handleEditItem(row); + }} + > + + + + + ))} + +
+
+
+ + + {category !== emptyCategory ? `Edit category ${category.id}` : 'Add a category'} + + setFormData({ name: { value: e.target.value, error: '' } })} + /> + + +
+ ); +} + +export default CategoriesPage; diff --git a/apps/react-app/src/components/Page/CategoriesPage/index.ts b/apps/react-app/src/components/Page/CategoriesPage/index.ts new file mode 100644 index 00000000..b90633e1 --- /dev/null +++ b/apps/react-app/src/components/Page/CategoriesPage/index.ts @@ -0,0 +1 @@ +export { default } from './CategoriesPage'; diff --git a/apps/react-app/src/components/Page/HomePage/HomePage.tsx b/apps/react-app/src/components/Page/HomePage/HomePage.tsx new file mode 100644 index 00000000..b5a89ae5 --- /dev/null +++ b/apps/react-app/src/components/Page/HomePage/HomePage.tsx @@ -0,0 +1,57 @@ +import { useCallback, useContext, useEffect, useState } from 'react'; + +import PostList from '../../PostList'; +import CategoryButtonGroup from '../../CategoryButtonGroup'; +import CreatePostButton from '../../CreatePostButton'; +import { Category, Post } from '../../../types'; +import { PostContext } from '../../../context'; +import Loading from '../../Loading'; +import Form from '../../Form'; + +const categories: Category[] = [ + { id: '663fef70d513515319551d1f', name: 'Travel' }, + { id: '663fef70d513515319546d1f', name: 'Food' } +]; + +function HomePage() { + const [openForm, setOpenForm] = useState(false); + const { posts, getPostList } = useContext(PostContext); + const [selectedPost, setSelectedPost] = useState(null); + const [selectedCategory, setSelectedCategory] = useState(null); + + const handleOpenForm = (defaultValues?: Post) => { + setOpenForm(true); + if (defaultValues) setSelectedPost(defaultValues); + }; + + const handleSelectCategory = useCallback( + (category: Category) => { + const isCategoryAlreadySelected = category.id === selectedCategory?.id; + getPostList(isCategoryAlreadySelected ? undefined : category.id); + setSelectedCategory(isCategoryAlreadySelected ? null : category); + }, + [selectedCategory, getPostList] + ); + + useEffect(getPostList, [getPostList]); + + if (!posts) return ; + + return ( + <> + + + +
+ + ); +} + +export default HomePage; diff --git a/apps/react-app/src/components/HomePage/index.ts b/apps/react-app/src/components/Page/HomePage/index.ts similarity index 100% rename from apps/react-app/src/components/HomePage/index.ts rename to apps/react-app/src/components/Page/HomePage/index.ts diff --git a/apps/react-app/src/components/Header/Header.styles.tsx b/apps/react-app/src/components/Page/LoginPage/LoginPage.styles.tsx similarity index 57% rename from apps/react-app/src/components/Header/Header.styles.tsx rename to apps/react-app/src/components/Page/LoginPage/LoginPage.styles.tsx index a96059ce..ac273bf3 100644 --- a/apps/react-app/src/components/Header/Header.styles.tsx +++ b/apps/react-app/src/components/Page/LoginPage/LoginPage.styles.tsx @@ -1,9 +1,12 @@ import { Grid } from "@mui/material"; import { styled } from "@mui/system"; -export const Container = styled(Grid)` +export const PageContainer = styled(Grid)` + display: flex; + padding: 32px; + flex-wrap: nowrap; + align-items: center; flex-direction: column; justify-content: center; - align-items: center; - padding: 2; + flex-grow: 1; `; diff --git a/apps/react-app/src/components/Page/LoginPage/LoginPage.tsx b/apps/react-app/src/components/Page/LoginPage/LoginPage.tsx new file mode 100644 index 00000000..dc6db443 --- /dev/null +++ b/apps/react-app/src/components/Page/LoginPage/LoginPage.tsx @@ -0,0 +1,113 @@ +import { useContext, useState } from 'react'; +import { useForm } from 'react-hook-form'; +import { Button, Card, CardActions, CardContent, Grid, TextField, Typography } from '@mui/material'; + +import { PageContainer } from './LoginPage.styles'; +import { createUser, login } from '../../../api/endpoints/auth'; +import { SnackbarContext } from '../../../context/SnackbarProvider'; +import { NewUser } from '../../../types'; + +const LoginPage = () => { + const [username, setUsername] = useState(''); + const [password, setPassword] = useState(''); + const { createAlert } = useContext(SnackbarContext); + const { register, handleSubmit } = useForm(); + + const onSubmit = () => { + login({ + user: { username, password }, + onSuccess: () => { + const { protocol, host } = window.location; + const signInUrl = `${protocol}//${host}/`; + if (window.location.href !== signInUrl) { + window.location.assign(signInUrl); + } + createAlert('You are successfully logged in!', 'success'); + }, + onError: () => { + createAlert('Email or password is not matching with our record.', 'error'); + } + }); + }; + + const handleSignUp = ({ username, password }: NewUser) => { + createUser({ + newUser: { + username, + password, + firstName: 'John', + lastName: 'Smith' + } + }); + createAlert('User successfully signed up!', 'success'); + }; + + function handleUsername(str: string) { + setUsername(str); + console.log(username); + } + + function handlePassword(str: string) { + setPassword(str); + console.log(password); + } + + return ( + + + + + + Login + + + handleUsername(e.target.value)} + /> + + handlePassword(e.target.value)} + /> + + + + + + + + + + + ); +}; + +export default LoginPage; diff --git a/apps/react-app/src/components/Page/LoginPage/index.ts b/apps/react-app/src/components/Page/LoginPage/index.ts new file mode 100644 index 00000000..bcf79128 --- /dev/null +++ b/apps/react-app/src/components/Page/LoginPage/index.ts @@ -0,0 +1 @@ +export { default } from './LoginPage'; diff --git a/apps/react-app/src/components/Page/NotFoundPage/NotFoundPage.tsx b/apps/react-app/src/components/Page/NotFoundPage/NotFoundPage.tsx new file mode 100644 index 00000000..81c92cc2 --- /dev/null +++ b/apps/react-app/src/components/Page/NotFoundPage/NotFoundPage.tsx @@ -0,0 +1,23 @@ +import React from "react"; +import Typography from "@mui/material/Typography"; +import { Grid } from "@mui/material"; + +export default function NotFoundPage(): React.JSX.Element { + return ( + + Page Not Found. +
+ + You have ended up on a URL for a page that does not exist. You can use + the back button in your browser to return to where you were. + +
+ ); +} diff --git a/apps/react-app/src/components/Page/NotFoundPage/index.ts b/apps/react-app/src/components/Page/NotFoundPage/index.ts new file mode 100644 index 00000000..35f02dc1 --- /dev/null +++ b/apps/react-app/src/components/Page/NotFoundPage/index.ts @@ -0,0 +1 @@ +export { default } from './NotFoundPage'; diff --git a/apps/react-app/src/components/Page/Page.tsx b/apps/react-app/src/components/Page/Page.tsx new file mode 100644 index 00000000..1f6e0ddc --- /dev/null +++ b/apps/react-app/src/components/Page/Page.tsx @@ -0,0 +1,35 @@ +import React from 'react'; +import Grid from '@mui/material/Grid'; +import NavBar from '../NavBar'; + +interface PageProps { + /** + * The page component that will be rendered inside the global Page component + * template + */ + page: React.JSX.Element; +} + +/** + * The global page template which wraps the page component (provided as a prop) in + * the global site template/header/footer + */ +export default function Page({ page }: PageProps): React.JSX.Element { + return ( + + + + {page} + + + ); +} diff --git a/apps/react-app/src/components/PostPage/PostPage.styles.tsx b/apps/react-app/src/components/Page/PostPage/PostPage.styles.tsx similarity index 92% rename from apps/react-app/src/components/PostPage/PostPage.styles.tsx rename to apps/react-app/src/components/Page/PostPage/PostPage.styles.tsx index 5e0a4d5f..a3713f83 100644 --- a/apps/react-app/src/components/PostPage/PostPage.styles.tsx +++ b/apps/react-app/src/components/Page/PostPage/PostPage.styles.tsx @@ -2,7 +2,8 @@ import { Grid } from "@mui/material"; import { styled } from "@mui/system"; export const Container = styled(Grid)` - height: 100%; + flex-grow: 1; + flex-wrap: nowrap; flex-direction: column; background-color: #f0f0ff; `; diff --git a/apps/react-app/src/components/Page/PostPage/PostPage.tsx b/apps/react-app/src/components/Page/PostPage/PostPage.tsx new file mode 100644 index 00000000..5f4bff15 --- /dev/null +++ b/apps/react-app/src/components/Page/PostPage/PostPage.tsx @@ -0,0 +1,68 @@ +import { useCallback, useEffect, useState } from 'react'; + +import { Container, BannerContainer, CommentsContainer, DescriptionContainer } from './PostPage.styles'; +import Banner from '../../Banner'; +import Comments from '../../Comments'; +import { getPost } from '../../../api'; +import { Comment, Post } from '../../../types/index'; +import { useParams } from 'react-router-dom'; + +function PostPage() { + const [post, setPost] = useState(); + const [comments, setComment] = useState([]); + + const { postId } = useParams(); + useEffect(() => { + getPost({ + postId: postId ?? '', + onSuccess: async (res) => { + setPost({ + id: res._id, + title: res.title, + image: res.image, + description: res.description, + category: res.category, + comments: res.comments.map((comment) => comment.content) + }); + setComment( + res.comments.map((comment) => { + return { + id: comment._id, + author: comment.author, + content: comment.content + }; + }) + ); + console.log('Got post!'); + }, + onError: async () => { + console.log('Post not gotten!'); + }, + onLoading: async () => {} + }); + }, [postId]); + + const handleCommentSubmition = useCallback( + (comment: Comment) => { + console.log(comments); + setComment([...comments, comment]); + }, + [comments] + ); + + return ( + + + + + +

{post?.description}

+
+ + + +
+ ); +} + +export default PostPage; diff --git a/apps/react-app/src/components/PostPage/index.ts b/apps/react-app/src/components/Page/PostPage/index.ts similarity index 100% rename from apps/react-app/src/components/PostPage/index.ts rename to apps/react-app/src/components/Page/PostPage/index.ts diff --git a/apps/react-app/src/components/Page/index.ts b/apps/react-app/src/components/Page/index.ts new file mode 100644 index 00000000..99b073b6 --- /dev/null +++ b/apps/react-app/src/components/Page/index.ts @@ -0,0 +1,7 @@ +export { default as HomePage } from './HomePage'; +export { default as PostPage } from './PostPage'; +export { default as CategoriesPage } from './CategoriesPage'; +export { default as LoginPage } from './LoginPage'; +export { default as NotFoundPage } from './NotFoundPage'; + +export { default } from './Page'; diff --git a/apps/react-app/src/components/PostList/PostList.tsx b/apps/react-app/src/components/PostList/PostList.tsx index 24c61238..9c892913 100644 --- a/apps/react-app/src/components/PostList/PostList.tsx +++ b/apps/react-app/src/components/PostList/PostList.tsx @@ -1,107 +1,68 @@ -import EditIcon from "@mui/icons-material/Edit"; -import DeleteIcon from "@mui/icons-material/Delete"; -import { Grid, IconButton, Typography } from "@mui/material"; +import { useContext } from 'react'; +import EditIcon from '@mui/icons-material/Edit'; +import DeleteIcon from '@mui/icons-material/Delete'; +import { Grid, IconButton, Typography } from '@mui/material'; +import { useNavigate } from 'react-router-dom'; -import { shorten } from "../../utils/index"; -import { - CardActions, - CardContainer, - CardContent, - PostCard, -} from "./PostList.styles"; +import { CardActions, CardContainer, CardContent, PostCard } from './PostList.styles'; +import { shorten } from '../../common/utils'; +import { Category, Post } from '../../types'; +import { PostContext } from '../../context'; -const posts = [ - { - id: "1.23", - title: "A good place to camp", - image: - "https://th.bing.com/th/id/R.e0bad63364a867fea652212c254bf869?rik=avtecz5aXVdevA&riu=http%3a%2f%2fwww.viajejet.com%2fwp-content%2fviajes%2fLago-Moraine-Parque-Nacional-Banff-Alberta-Canada.jpg&ehk=6qRhWDqqQAEkSFs%2bHP8p2Bl6XfPbjznSoORh%2bsEJ%2bQE%3d&risl=&pid=ImgRaw&r=0", - description: - "Beautiful water, incredible landscapes and huge bears everywhere. Everything your soul needs.", - category: "Travel", - comments: [ - { - id: "2.1", - author: "Anonymus", - content: - "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.", - }, - { - id: "2.2", - author: "Anonymus", - content: - "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.", - }, - ], - }, - { - id: "1.24", - title: "The average path a grandparent took to get to school", - image: - "https://th.bing.com/th/id/R.df8ba69a16ad146c6e8cc769fa900ab0?rik=qYqjcnEnWzdXug&pid=ImgRaw&r=0", - description: - "Don't forget to bring your machete in case you encounter the devil or some stones in case witches appear. ", - category: "Travel", - comments: [ - { - id: "2.1", - author: "Anonymus", - content: - "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.", - }, - ], - }, -]; +interface PostListProps { + posts: Post[]; + selectedCategory: Category | null; + handleOpenForm: (defaultValues?: Post) => void; +} + +function PostList({ posts, selectedCategory, handleOpenForm }: PostListProps) { + const { removePost } = useContext(PostContext); + + const navigate = useNavigate(); + const goToPost = (postId: string) => { + navigate(`post/${postId}`); + }; -function PostList() { return ( - - - -

{/* Activity 1 - Render post title */}

-

- {/* Activity 1 - Render comments length */} - {" Comment"} -

-

{shorten(posts[0].description, 70)}

- - {/* Activity 1 - Render post category */} - -
- - - - - - - - -
-
- - - -

{/* Activity 1 - Render post title */}

-

- {/* Activity 1 - Render comments length */} - {" Comment"} -

-

{shorten(posts[1].description, 70)}

- - {/* Activity 1 - Render post category */} - -
- - - - - - - - -
-
+ {posts.map((post) => ( + goToPost(post.id)}> + + +

{post.title}

+

+ {post.comments.length} + {post.comments.length > 1 ? ' Comments' : ' Comment'} +

+

{shorten(post.description, 70)}

+ {post.category?.name} +
+ + { + e.stopPropagation(); + handleOpenForm(post); + }} + > + + + { + e.stopPropagation(); + removePost({ + postId: post.id, + selectedCategoryID: selectedCategory?.id + }); + }} + > + + + +
+
+ ))}
); } diff --git a/apps/react-app/src/components/PostPage/PostPage.tsx b/apps/react-app/src/components/PostPage/PostPage.tsx deleted file mode 100644 index d0cd4fb7..00000000 --- a/apps/react-app/src/components/PostPage/PostPage.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import { - Container, - BannerContainer, - CommentsContainer, - DescriptionContainer, -} from "./PostPage.styles"; - -// const post = { -// id: "1.23", -// title: "A good place to camp", -// image: -// "https://th.bing.com/th/id/R.e0bad63364a867fea652212c254bf869?rik=avtecz5aXVdevA&riu=http%3a%2f%2fwww.viajejet.com%2fwp-content%2fviajes%2fLago-Moraine-Parque-Nacional-Banff-Alberta-Canada.jpg&ehk=6qRhWDqqQAEkSFs%2bHP8p2Bl6XfPbjznSoORh%2bsEJ%2bQE%3d&risl=&pid=ImgRaw&r=0", -// description: -// "Beautiful water, incredible landscapes and huge bears everywhere. Everything your soul needs.", -// category: "Travel", -// comments: [ -// { -// id: "2.1", -// author: "Anonymus", -// content: -// "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.", -// }, -// { -// id: "2.2", -// author: "Anonymus", -// content: -// "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.", -// }, -// ], -// }; - -function PostPage() { - return ( - - {/* Activity 1 - Render Banner */} - -

{/* Activity 1 - Render post description */}

-
- {/* Activity 1 - Render Comments */} -
- ); -} - -export default PostPage; diff --git a/apps/react-app/src/context/AuthProvider.tsx b/apps/react-app/src/context/AuthProvider.tsx new file mode 100644 index 00000000..48464efa --- /dev/null +++ b/apps/react-app/src/context/AuthProvider.tsx @@ -0,0 +1,44 @@ +import axios, { AxiosResponse } from 'axios'; +import React, { createContext, useCallback, useState } from 'react'; + +interface AuthContextProps { + authLoading: boolean; + isAuthenticated: boolean | null; + validateToken: () => void; +} + +export const AuthContext = createContext({ + authLoading: false, + isAuthenticated: null, + validateToken: () => {} +}); + +interface AuthProviderProps { + children: React.JSX.Element; +} + +export function AuthProvider({ children }: AuthProviderProps): React.JSX.Element { + const [authLoading, setAuthLoading] = useState(false); + const [isAuthenticated, setIsAuthenticated] = useState(null); + + const validateToken = useCallback(async () => { + const token = localStorage.getItem('apiToken'); + const onLoading = (isLoading: boolean) => setAuthLoading(isLoading); + + onLoading(true); + await axios({ + url: 'https://test.neuraac.com/api/posts', + method: 'get', + headers: { Authorization: `Bearer ${token}` } + }) + .then((response: AxiosResponse) => { + if (response.status === 200) { + setIsAuthenticated(true); + } + }) + .catch(() => setIsAuthenticated(false)) + .finally(() => onLoading(false)); + }, []); + + return {children}; +} diff --git a/apps/react-app/src/context/PostProvider.tsx b/apps/react-app/src/context/PostProvider.tsx new file mode 100644 index 00000000..3af477f5 --- /dev/null +++ b/apps/react-app/src/context/PostProvider.tsx @@ -0,0 +1,140 @@ +import React, { + createContext, + useCallback, + // useContext, useEffect, + useState +} from 'react'; + +import { NewPost, Post, PostsResponse } from '../types'; +// import { SnackbarContext } from './SnackbarProvider'; +import { createPost, deletePost, getPosts, getPostsByCategory, updatePost } from '../api'; + +interface PostContextProps { + posts: Post[] | null; + loadingPosts: boolean; + addPost: (newPost: NewPost) => void; + removePost: ({ postId, selectedCategoryID }: { postId: string; selectedCategoryID?: string }) => void; + getPostList: (selectedCategoryID?: string) => void; + updatePostData: ({ + postId, + updatedPost, + selectedCategoryID + }: { + postId: string; + updatedPost: NewPost; + selectedCategoryID?: string; + }) => void; +} + +interface PostProviderProps { + children: React.JSX.Element; +} + +export const PostContext = createContext({ + posts: null, + loadingPosts: false, + addPost: () => {}, + removePost: () => {}, + getPostList: () => {}, + updatePostData: () => {} +}); + +export function PostProvider({ children }: PostProviderProps): React.JSX.Element { + // const createAlert = useContext(SnackbarContext); + const [posts, setPosts] = useState(null); + const [loadingPosts, setLoadingPosts] = useState(false); + + const onLoading = (isLoading: boolean) => { + setLoadingPosts(isLoading); + }; + + const onError = useCallback(() => { + // createAlert( + // message: "Something went wrong.", + // severity: "error", + // ); + }, []); + + const getPostList = useCallback( + (selectedCategoryID?: string) => { + async function process() { + const onSuccess = async (data: PostsResponse[]) => { + const newList = data.map((post) => ({ + id: post._id, + title: post.title, + image: post.image, + description: post.description, + category: post.category, + comments: post.comments + })); + setPosts(newList); + }; + + const params = { onSuccess, onError, onLoading }; + selectedCategoryID ? await getPostsByCategory({ selectedCategoryID, ...params }) : await getPosts(params); + } + process(); + }, + [onError] + ); + + const addPost = useCallback( + async (newPost: NewPost) => { + const onSuccess = async () => { + await getPostList(); + // createAlert({ + // message: "Post successfully created.", + // severity: "success", + // }); + }; + + await createPost({ newPost, onSuccess, onError, onLoading }); + }, + [onError, getPostList] + ); + + const updatePostData = useCallback( + async ({ postId, updatedPost, selectedCategoryID }: { postId: string; updatedPost: NewPost; selectedCategoryID?: string }) => { + const onSuccess = async () => { + await getPostList(selectedCategoryID); + // createAlert({ + // message: "Post successfully updated.", + // severity: "success", + // }); + }; + + await updatePost({ postId, updatedPost, onSuccess, onError, onLoading }); + }, + [onError, getPostList] + ); + + const removePost = useCallback( + async ({ postId, selectedCategoryID }: { postId: string; selectedCategoryID?: string }) => { + const onSuccess = async () => { + await getPostList(selectedCategoryID); + // createAlert({ + // message: "Post successfully deleted.", + // severity: "success", + // }); + }; + setLoadingPosts(true); + await deletePost({ postId, onSuccess, onError }); + }, + [onError, getPostList] + ); + + return ( + + {children} + + ); +} diff --git a/apps/react-app/src/context/SnackbarProvider.tsx b/apps/react-app/src/context/SnackbarProvider.tsx new file mode 100644 index 00000000..ffa9946d --- /dev/null +++ b/apps/react-app/src/context/SnackbarProvider.tsx @@ -0,0 +1,52 @@ +import React, { createContext, SyntheticEvent, useState, useCallback } from 'react'; +import AlertComponent from '@mui/material/Alert'; +import Snackbar, { SnackbarCloseReason } from '@mui/material/Snackbar'; + +import { Alert } from '../types'; + +type Severity = 'error' | 'warning' | 'info' | 'success' | undefined; +type CreateAlert = (message: string, severity?: Severity) => void; + +interface SnackbarContextProps { + createAlert: CreateAlert; +} + +interface SnackbarProviderProps { + children: React.JSX.Element; +} + +export const SnackbarContext = createContext({ + createAlert: () => {} +}); + +export function SnackbarProvider({ children }: SnackbarProviderProps): React.JSX.Element { + const [alert, setAlert] = useState(); + const [open, setOpen] = useState(false); + + const handleClose = (event: SyntheticEvent | Event, reason?: SnackbarCloseReason) => { + if (reason === 'clickaway') { + return; + } + + setOpen(false); + }; + + const createAlert = useCallback((message: string, severity?: Severity) => { + setAlert({ + severity, + message + }); + setOpen(true); + }, []); + + return ( + + {children} + + + {alert?.message} + + + + ); +} diff --git a/apps/react-app/src/context/index.ts b/apps/react-app/src/context/index.ts new file mode 100644 index 00000000..96342f10 --- /dev/null +++ b/apps/react-app/src/context/index.ts @@ -0,0 +1,3 @@ +export { AuthProvider, AuthContext } from "./AuthProvider"; +export { PostProvider, PostContext } from "./PostProvider"; +export { SnackbarProvider, SnackbarContext } from "./SnackbarProvider"; diff --git a/apps/react-app/src/types/index.ts b/apps/react-app/src/types/index.ts new file mode 100644 index 00000000..ddd3715c --- /dev/null +++ b/apps/react-app/src/types/index.ts @@ -0,0 +1,140 @@ +export type Input = { + value: string; + error: string; +}; + +export type FormInputs = { + title: Input; + description: Input; + category: Input; + image: Input; +}; + +export type NewPost = { + title: string; + image: string; + description: string; + category: string; +}; + +export type Comment = { + id: string; + author: string; + content: string; +}; + +export interface Alert { + severity?: "error" | "warning" | "info" | "success"; + message: string; +} + +export type Order = "asc" | "desc"; + +export interface TableData { + [key: string]: string; +} + +export interface HeadCell { + id: string; + label: string; +} + +export type FormData = { [key: string]: string }; + +export type Inputs = { + id: string; + name: keyof FormInputs; + label: string; + type: string; + options?: { id?: string; name: string }[]; +}[]; + +export interface Category { + id: string; + name: string; +} + +export interface NewCategory { + name: string; +} + +export interface CategoriesResponse { + _id: string; + name: string; + createdAt: string; + updatedAt: string; + __v: number; +} + +export type Post = { + id: string; + title: string; + image: string; + description: string; + category: CategoriesResponse | null; + comments: string[]; +}; + +export type SelectedPost = { + id: string; + title: string; + image: string; + description: string; + category: CategoriesResponse | null; + comments: CommentResponse[]; +}; + +export interface PostsResponse { + _id: string; + title: string; + image: string; + description: string; + category: CategoriesResponse | null; + comments: string[]; + createdAt: string; + updatedAt: string; + __v: number; +} + +export interface CommentResponse { + _id: string; + author: string; + content: string; + createdAt: string; + updatedAt: string; + __v: number; +} +export interface PostResponse { + _id: string; + title: string; + image: string; + description: string; + category: CategoriesResponse | null; + comments: CommentResponse[]; + createdAt: string; + updatedAt: string; + __v: number; +} + +export interface NewComment { + author: string; + content: string; +} + +export interface User { + username: string; + password: string; +} + +export interface NewUser extends User { + firstName: string; + lastName: string; +} + +export interface AuthResponse { + message: string; +} + +export interface AuthLoginResponse { + accessToken: string; +} diff --git a/package.json b/package.json index 02839adc..1213ba42 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,7 @@ "normalize.css": "^8.0.1", "react": "18.2.0", "react-dom": "18.2.0", - "react-hook-form": "^7.51.0", + "react-hook-form": "^7.52.2", "react-router-dom": "6.11.2", "rxjs": "~7.8.0", "tslib": "^2.3.0",