diff --git a/apps/react-app/README.md b/apps/react-app/README.md index 8b43a9a1..ff7bfc51 100644 --- a/apps/react-app/README.md +++ b/apps/react-app/README.md @@ -1,11 +1,54 @@ -# React App +# 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 -## Challenges +- 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 -### Session * +### Run postman collection -## How to \ No newline at end of file +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 new file mode 100644 index 00000000..50e237d9 --- /dev/null +++ b/apps/react-app/src/App.tsx @@ -0,0 +1,21 @@ +import { RouterProvider } from "react-router-dom"; + +import Router from "./Router"; +import { AuthProvider, PostProvider, SnackbarProvider } from "./context"; + +function App() { + + return ( + + {/* ACT 7 - Rneder SnackbarProvider component */} + + + + + + + ); +} + +export default App; + diff --git a/apps/react-app/src/Router/PrivateRoute.tsx b/apps/react-app/src/Router/PrivateRoute.tsx new file mode 100644 index 00000000..4dbc5adc --- /dev/null +++ b/apps/react-app/src/Router/PrivateRoute.tsx @@ -0,0 +1,37 @@ +import { useContext, useEffect } from "react"; +import { Navigate } from "react-router-dom"; +import { Grid } from "@mui/material"; + +import { AuthContext } from "../context"; +import Loading from "../components/Loading"; + +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 ( + + + + ); + } + + // ACT 11 - Navigate to /login when the user is not authenticated + 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..caaf2748 --- /dev/null +++ b/apps/react-app/src/Router/Router.tsx @@ -0,0 +1,33 @@ +import { createBrowserRouter } from "react-router-dom"; +import Page, { CategoriesPage, HomePage, LoginPage, PostPage } from "../components/Page"; +import PrivateRoute from "./PrivateRoute"; +import NotFoundPage from "../components/Page/NotFoundPage"; + +const Router = createBrowserRouter([ + { + path: "/", + element: } />} />, + }, + { + path: "/post/:id", + element: } />} /> + }, + { + path: "/login", + element: + }, + { + path: "/categories", + element: } /> } /> + }, + { + path: "*", + element: + } + // ACT 10 - Render PostPage component inside a private route and mark postID as a params + // ACT 10 - Render CategoriesPage component inside a private route + // ACT 10 - Render LoginPage component inside a private route + // ACT 10 - Render NotFoundPage component for undefined routes +]); + +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..0ab1684e --- /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("token"); + +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..8fe82a11 --- /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..e8e51964 --- /dev/null +++ b/apps/react-app/src/api/endpoints/categories.ts @@ -0,0 +1,131 @@ +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 createCategories = async ({ + onSuccess, + onError, + onLoading, + name +}: { + onSuccess?: (data: CategoriesResponse) => void; + onError?: (error: AxiosError) => void; + onLoading?: (isLoading: boolean) => void; + name: string; +}) => { + + onLoading && onLoading(true); + + await axios({ + url: "/categories", + method: "post", + data: { + name: name + } + }) + .then((response: AxiosResponse) => { + const data: CategoriesResponse = response.data; + if((response.status === 200 || response.status === 201) && onSuccess) onSuccess(data); + }) + .catch((error: AxiosError) => { + console.error(`${error}`); + onError && onError(error); + }) + .finally(() => onLoading && onLoading(false)); +}; + +export const updateCategories = async ({ + onSuccess, + onError, + onLoading, + id, + name +}: { + onSuccess?: (data: CategoriesResponse) => void; + onError?: (error: AxiosError) => void; + onLoading?: (isLoading: boolean) => void; + id: string; + name: string; +}) => { + + onLoading && onLoading(true); + + await axios({ + url: "/categories/"+id, + method: "patch", + data: { + name: name + } + }) + .then((response: AxiosResponse) => { + const data: CategoriesResponse = response.data; + if((response.status === 200 || response.status === 201) && onSuccess) onSuccess(data); + }) + .catch((error: AxiosError) => { + console.error(`${error}`); + onError && onError(error); + }) + .finally(() => onLoading && onLoading(false)); +}; + +export const deleteCategories = async ({ + onSuccess, + onError, + onLoading, + id +}: { + onSuccess?: (data: CategoriesResponse) => void; + onError?: (error: AxiosError) => void; + onLoading?: (isLoading: boolean) => void; + id: string; +}) => { + + onLoading && onLoading(true); + + await axios({ + url: "/categories/"+id, + method: "delete", + }) + .then((response: AxiosResponse) => { + const data: CategoriesResponse = response.data; + if((response.status === 200 || response.status === 200) && onSuccess) onSuccess(data); + }) + .catch((error: AxiosError) => { + console.error(`${error}`); + onError && onError(error); + }) + .finally(() => onLoading && onLoading(false)); +}; + +// ACT 9 - Create callbacks fuctions to call create, update and delete APIs 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..218636cd --- /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..574451d8 --- /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/app/app.module.css b/apps/react-app/src/app/app.module.css deleted file mode 100644 index 7b88fbab..00000000 --- a/apps/react-app/src/app/app.module.css +++ /dev/null @@ -1 +0,0 @@ -/* Your styles goes here. */ diff --git a/apps/react-app/src/app/app.spec.tsx b/apps/react-app/src/app/app.spec.tsx deleted file mode 100644 index 9a016c35..00000000 --- a/apps/react-app/src/app/app.spec.tsx +++ /dev/null @@ -1,15 +0,0 @@ -import { render } from '@testing-library/react'; -import { BrowserRouter } from 'react-router-dom'; - -import App from './app'; - -describe('App', () => { - it('should render successfully', () => { - const { baseElement } = render( - - - - ); - expect(baseElement).toBeTruthy(); - }); -}); diff --git a/apps/react-app/src/app/app.tsx b/apps/react-app/src/app/app.tsx deleted file mode 100644 index 139fd9dd..00000000 --- a/apps/react-app/src/app/app.tsx +++ /dev/null @@ -1,8 +0,0 @@ -// eslint-disable-next-line @typescript-eslint/no-unused-vars -import styles from './app.module.css'; - -export function App() { - return
; -} - -export default App; 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..507751d3 --- /dev/null +++ b/apps/react-app/src/common/utils/inputsValidator.tsx @@ -0,0 +1,34 @@ +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/common/utils/shorten.tsx b/apps/react-app/src/common/utils/shorten.tsx new file mode 100644 index 00000000..6c4177d8 --- /dev/null +++ b/apps/react-app/src/common/utils/shorten.tsx @@ -0,0 +1,4 @@ +export function shorten(str: string, maxLen: number) { + if (str.length <= maxLen) return str; + return `${str.substr(0, str.lastIndexOf(" ", maxLen))}...`; + } \ No newline at end of file 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/.gitkeep b/apps/react-app/src/components/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/apps/react-app/src/components/Banner/Banner.styles.tsx b/apps/react-app/src/components/Banner/Banner.styles.tsx new file mode 100644 index 00000000..e85f1f8d --- /dev/null +++ b/apps/react-app/src/components/Banner/Banner.styles.tsx @@ -0,0 +1,30 @@ +import { Box, Typography } from "@mui/material"; +import { styled } from "@mui/system"; + +export const Container = styled(Box)<{ image: string }>` + display: flex; + flex-grow: 1; + height: 100%; + color: white; + background-size: cover; + background-position: center; + background-repeat: no-repeat; + background-image: ${(props) => `url(${props.image})`}; +`; + +export const BannerContent = styled(Box)` + flex-grow: 1; + padding: 16px; + background-color: #4b4b4b3b; +`; + +export const BannerTitle = styled(Typography)` + display: flex; + flex-grow: 1; + text-align: center; + align-items: center; + justify-content: center; + height: calc(100% - 103px); + font-weight: 600; + padding: 30px; +`; diff --git a/apps/react-app/src/components/Banner/Banner.tsx b/apps/react-app/src/components/Banner/Banner.tsx new file mode 100644 index 00000000..55496b5d --- /dev/null +++ b/apps/react-app/src/components/Banner/Banner.tsx @@ -0,0 +1,33 @@ +import Button from "@mui/material/Button"; +import ArrowBackIosIcon from "@mui/icons-material/ArrowBackIos"; + +import { BannerContent, BannerTitle, Container } from "./Banner.styles"; +import { NavLink } from "react-router-dom"; + +const postImage = "https://img-cdn.pixlr.com/image-generator/history/65bb506dcb310754719cf81f/ede935de-1138-4f66-8ed7-44bd16efc709/medium.webp"; // ACT 1 - Put some image URL +const postTitle = "Gatito chino"; //ACT 1 - Write a title + +interface BannerProps { + postImage: string; + postTitle: string; +} + +function Banner({postImage, postTitle}: BannerProps) { + return ( + + {/* ACT 3 - Send postImage as image prop to Container component */} + + + + + {/* Activity 1 - Render post title */} + + + ); +} + +export default Banner; diff --git a/apps/react-app/src/components/Banner/index.ts b/apps/react-app/src/components/Banner/index.ts new file mode 100644 index 00000000..b927b809 --- /dev/null +++ b/apps/react-app/src/components/Banner/index.ts @@ -0,0 +1 @@ +export { default } from './Banner'; diff --git a/apps/react-app/src/components/CategoryButtonGroup/CategoryButtonGroup.styles.tsx b/apps/react-app/src/components/CategoryButtonGroup/CategoryButtonGroup.styles.tsx new file mode 100644 index 00000000..c00d86d1 --- /dev/null +++ b/apps/react-app/src/components/CategoryButtonGroup/CategoryButtonGroup.styles.tsx @@ -0,0 +1,12 @@ +import { Button, Grid } from "@mui/material"; +import { styled } from "@mui/system"; + +export const Container = styled(Grid)` + display: flex; + justify-content: center; + padding-bottom: 16px; +`; + +export const StyledButton = styled(Button)<{ selected: boolean }>` + background-color: ${(props) => (props.selected ? "#DCDCDC" : undefined)}; +`; diff --git a/apps/react-app/src/components/CategoryButtonGroup/CategoryButtonGroup.tsx b/apps/react-app/src/components/CategoryButtonGroup/CategoryButtonGroup.tsx new file mode 100644 index 00000000..45295e55 --- /dev/null +++ b/apps/react-app/src/components/CategoryButtonGroup/CategoryButtonGroup.tsx @@ -0,0 +1,35 @@ +import { ButtonGroup } from "@mui/material"; + +import { Container, StyledButton } from "./CategoryButtonGroup.styles"; +import { Category } from "../../types"; + +interface CategoryButtonGroupProps { + categories: Category[]; + selectedCategory: Category | null; + handleSelectCategory: (category: Category) => void; +} + +function CategoryButtonGroup({ + categories, + selectedCategory, + handleSelectCategory, +}: CategoryButtonGroupProps) { + return ( + + + {categories.map((category) => ( + handleSelectCategory(category)} + > + {category.name} + + ))} + + + ); +} + +export default CategoryButtonGroup; diff --git a/apps/react-app/src/components/CategoryButtonGroup/index.ts b/apps/react-app/src/components/CategoryButtonGroup/index.ts new file mode 100644 index 00000000..680b0832 --- /dev/null +++ b/apps/react-app/src/components/CategoryButtonGroup/index.ts @@ -0,0 +1 @@ +export { default } from './CategoryButtonGroup'; \ No newline at end of file diff --git a/apps/react-app/src/components/CommentCard/CommentCard.styles.tsx b/apps/react-app/src/components/CommentCard/CommentCard.styles.tsx new file mode 100644 index 00000000..0e427e12 --- /dev/null +++ b/apps/react-app/src/components/CommentCard/CommentCard.styles.tsx @@ -0,0 +1,23 @@ +import { Grid, Box, Typography } from "@mui/material"; +import { styled } from "@mui/system"; + +export const Container = styled(Grid)` + display: flex; + gap: 16px; + flex-grow: 1; + padding: 16px; + margin-bottom: 24px; + background-color: white; + border-radius: 8px; + height: fit-content; +`; + +export const Content = styled(Box)` + display: flex; + flex-direction: column; +`; + +export const Author = styled(Typography)` + font-weight: bold; + margin-bottom: 8px; +`; diff --git a/apps/react-app/src/components/CommentCard/CommentCard.tsx b/apps/react-app/src/components/CommentCard/CommentCard.tsx new file mode 100644 index 00000000..75b9d2b4 --- /dev/null +++ b/apps/react-app/src/components/CommentCard/CommentCard.tsx @@ -0,0 +1,36 @@ +import { Typography } from "@mui/material"; +import AccountCircleIcon from "@mui/icons-material/AccountCircle"; + +import { Container, Content, Author } from "./CommentCard.styles"; + +const comment = { + _id: "412fsfa", + author: "José Valenzuela", + content: "Comentario", + createdAt: "2024-04-10 12:00:00", + updatedAt: "2024-04-10 12:00:00", + __v: "-v", +}; // ACT 1 - Fill all the properties with random data + +export interface CommentProp { + _id: string; + author: string; + content: string; + createdAt: string; + updatedAt: string; + __v: string; +} + +function CommentCard({ _id, author, content, createdAt, updatedAt, __v }: CommentProp) { + return ( + + + + {author} + {content} + + + ); +} + +export default CommentCard; diff --git a/apps/react-app/src/components/CommentCard/index.ts b/apps/react-app/src/components/CommentCard/index.ts new file mode 100644 index 00000000..378b8fd1 --- /dev/null +++ b/apps/react-app/src/components/CommentCard/index.ts @@ -0,0 +1 @@ +export { default } from './CommentCard'; diff --git a/apps/react-app/src/components/Comments/Comments.styles.tsx b/apps/react-app/src/components/Comments/Comments.styles.tsx new file mode 100644 index 00000000..93d9a036 --- /dev/null +++ b/apps/react-app/src/components/Comments/Comments.styles.tsx @@ -0,0 +1,15 @@ +import { Grid } from "@mui/material"; +import { styled } from "@mui/system"; + +export const Container = styled(Grid)` + flex-grow: 1; + justify-content: center; +`; + +export const Title = styled(Grid)` + flex-grow: 1; +`; + +export const FormContainer = styled(Grid)` + flex-grow: 1; +`; diff --git a/apps/react-app/src/components/Comments/Comments.tsx b/apps/react-app/src/components/Comments/Comments.tsx new file mode 100644 index 00000000..d3119361 --- /dev/null +++ b/apps/react-app/src/components/Comments/Comments.tsx @@ -0,0 +1,45 @@ +import { Title, Container, FormContainer } from "./Comments.styles"; +import CommentCard from "../CommentCard"; +import { CommentProp } from "../CommentCard/CommentCard"; +import AddCommentForm from "../Form/AddComentForm"; +import { useState } from "react"; + +/*const comments = [ + { + _id: "412fsfa", + author: "José Valenzuela", + content: "Comentario", + createdAt: "2024-04-10 12:00:00", + updatedAt: "2024-04-10 12:00:00", + __v: "-v", + } +]*/ + +interface CommentsProps { + comments: CommentProp[]; + id: string; +} + + +function Comments({comments, id}: CommentsProps) { + + const [commentsState, setCommentsState] = useState(comments); + + return ( + + + <h4>Comments</h4> + + {commentsState.map((comment) => { + return + })} + {/* ACT 5 - Iterate comments to render CommentCard component for each comment */} + + {/* ACT 8 - Create a form to add comments */} + + + + ); +} + +export default Comments; diff --git a/apps/react-app/src/components/Comments/index.ts b/apps/react-app/src/components/Comments/index.ts new file mode 100644 index 00000000..fff63290 --- /dev/null +++ b/apps/react-app/src/components/Comments/index.ts @@ -0,0 +1 @@ +export { default } from './Comments'; diff --git a/apps/react-app/src/components/CreatePostButton/CreatePostButton.styles.tsx b/apps/react-app/src/components/CreatePostButton/CreatePostButton.styles.tsx new file mode 100644 index 00000000..222cef80 --- /dev/null +++ b/apps/react-app/src/components/CreatePostButton/CreatePostButton.styles.tsx @@ -0,0 +1,8 @@ +import { Grid } from "@mui/material"; +import { styled } from "@mui/system"; + +export const Container = styled(Grid)` + display: flex; + justify-content: end; + padding: 8px 16px; +`; diff --git a/apps/react-app/src/components/CreatePostButton/CreatePostButton.tsx b/apps/react-app/src/components/CreatePostButton/CreatePostButton.tsx new file mode 100644 index 00000000..4589f496 --- /dev/null +++ b/apps/react-app/src/components/CreatePostButton/CreatePostButton.tsx @@ -0,0 +1,21 @@ +import EditIcon from "@mui/icons-material/Edit"; +import { IconButton } from "@mui/material"; + +import { Post } from "../../types"; +import { Container } from "./CreatePostButton.styles"; + +interface CreatePostButtonInterface { + handleOpenForm: (defaultValues?: Post) => void; +} + +const CreatePostButton = ({ handleOpenForm }: CreatePostButtonInterface) => { + return ( + + handleOpenForm()}> + + + + ); +}; + +export default CreatePostButton; diff --git a/apps/react-app/src/components/CreatePostButton/index.ts b/apps/react-app/src/components/CreatePostButton/index.ts new file mode 100644 index 00000000..bd1bdc98 --- /dev/null +++ b/apps/react-app/src/components/CreatePostButton/index.ts @@ -0,0 +1 @@ +export { default } from './CreatePostButton'; \ No newline at end of file diff --git a/apps/react-app/src/components/Form/AddComentForm.tsx b/apps/react-app/src/components/Form/AddComentForm.tsx new file mode 100644 index 00000000..c904a6f3 --- /dev/null +++ b/apps/react-app/src/components/Form/AddComentForm.tsx @@ -0,0 +1,70 @@ +import { ChangeEvent, FormEvent, useState, Dispatch, SetStateAction, useRef } from "react"; +import TextField from '@mui/material/TextField'; +import { Button, FormControl } from "@mui/material"; +import { useFormControlContext } from '@mui/base/FormControl'; +import { CommentProp } from "../CommentCard/CommentCard"; +import { CommentResponse } from "../../types"; +import { AxiosError } from "axios"; +import { createComment } from "../../api"; + +interface AddCommentFormProps { + setCommentsState: Dispatch>; + postId: string; +} + +function AddCommentForm({setCommentsState, postId}: AddCommentFormProps){ + + const [inputValue, setInputValue] = useState(null); + const ref = useRef(); + + const createCommentApi = async (newComment: CommentProp) => { + const onSuccess = (data: CommentResponse) => {}; + const onError = (error: AxiosError) => {}; + const onLoading = () => {}; + createComment({postID: postId, newComment: newComment, onSuccess, onError, onLoading}); + } + + function handleSubmit(event: FormEvent):void{ + event.preventDefault(); + if(inputValue?.length <= 15){ + const newComment: CommentProp = { + author: "José Valenzuela", + content: inputValue || "", + } + setCommentsState((oldValue: CommentProp[]) => { + return [...oldValue, newComment]; + }); + setInputValue(null); + if(ref.current){ + ref.current.value = ""; + } + createCommentApi(newComment); + } + } + + function handleChange(event: ChangeEvent):void{ + setInputValue(event.target.value); + console.log(inputValue); + } + + return ( +
+ 15} + onChange={handleChange} + inputRef={ref} + sx={{pb:2}} + /> + + + ); +} + +export default AddCommentForm; \ No newline at end of file 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..f1113f91 --- /dev/null +++ b/apps/react-app/src/components/Form/Form.tsx @@ -0,0 +1,209 @@ +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 hanldeSubmit = 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; \ No newline at end of file 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/Loading/Loading.tsx b/apps/react-app/src/components/Loading/Loading.tsx new file mode 100644 index 00000000..196f107a --- /dev/null +++ b/apps/react-app/src/components/Loading/Loading.tsx @@ -0,0 +1,23 @@ +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 + */ +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 new file mode 100644 index 00000000..e610201e --- /dev/null +++ b/apps/react-app/src/components/Loading/index.ts @@ -0,0 +1 @@ +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..597f44e2 --- /dev/null +++ b/apps/react-app/src/components/NavBar/NavBar.tsx @@ -0,0 +1,84 @@ +import React from "react"; +import Grid from "@mui/material/Grid"; +import { Box, Button, Typography } from "@mui/material"; +import TravelExploreIcon from "@mui/icons-material/TravelExplore"; +import { NavLink } from "react-router-dom"; + +export default function NavBar(): React.JSX.Element { + return ( + + + + ({backgroundColor: isActive ? 'read' : 'blue'})}> + + + ({backgroundColor: isActive ? 'read' : 'blue'})}> + + + + + ); +} 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..974a6c36 --- /dev/null +++ b/apps/react-app/src/components/Page/CategoriesPage/CategoriesPage.tsx @@ -0,0 +1,215 @@ +import { Button, Grid, Modal, TextField } from "@mui/material"; +import { useCallback, useEffect, useState } from "react"; +import { PageContainer } from "./CategoriesPage.styles"; +import { CategoriesResponse, Category } from "../../../types"; +import TableComponent from "../../Table"; +import AddCircleOutlineIcon from '@mui/icons-material/AddCircleOutline'; +import { IconButton, CardContent } from "@mui/material"; +import { getCategories, createCategories, updateCategories, deleteCategories } from "../../../api"; + + const headers: string[] = ["Name", "Actions"]; + + const style = { + position: 'absolute', + top: '50%', + left: '50%', + transform: 'translate(-50%, -50%)', + width: '50%', + bgcolor: 'background.paper', + boxShadow: 24, + p: 4, +}; + +function CategoriesPage() { + // ACT 6 - Create a state called "rows" + const [rows, setRows] = useState(null); + const [isModalOpened, setIsModalOpened] = useState(false); + const [errorInput, setErrorInput] = useState(false); + const [newCategory, setNewCategory] = useState(""); + const [isUpdate, setIsUpdate] = useState(false); + const [updatedCategory, setUpdatedCategory] = useState(); + const [initialValueUpdatedCategory, setInitialValueUpdatedCategory] = useState(); + // ACT 6 - Call setRows when the component is mounted for first time, use "categories" variable as new value. + + const getCategoriesList = useCallback(async () => { + const onSuccess = (data: CategoriesResponse[]) => { + const newRows = data.map((category) => ({ + id: category._id, + name: category.name, + })); + setRows(newRows); + }; + + const onError = () => { + // createAlert({ + // message: "Something went wrong.", + // severity: "error", + // }); + }; + + const onLoading = (isLoading: boolean) => { + }; + + await getCategories({ onSuccess, onError, onLoading }); + }, []); + + const createCategory = useCallback(async (data: string) => { + const onSuccess = (data: CategoriesResponse) => { + getCategoriesList(); + }; + + const onError = () => { + // createAlert({ + // message: "Something went wrong.", + // severity: "error", + // }); + }; + + const onLoading = (isLoading: boolean) => { + }; + + const name = data; + + await createCategories({ onSuccess, onError, onLoading, name }); + }, []); + + useEffect(() => { + getCategoriesList(); + }, []); + + const updateCategoryApi = useCallback(async (id: string, name: string) => { + const onSuccess = (data: CategoriesResponse) => { + getCategoriesList(); + }; + + const onError = () => { + // createAlert({ + // message: "Something went wrong.", + // severity: "error", + // }); + }; + + const onLoading = (isLoading: boolean) => { + }; + + await updateCategories({ onSuccess, onError, onLoading, id, name }); + }, []); + + useEffect(() => { + getCategoriesList(); + }, []); + + const deleteCategoryApi = useCallback(async (id: string) => { + const onSuccess = (data: CategoriesResponse) => { + getCategoriesList(); + }; + + const onError = () => { + // createAlert({ + // message: "Something went wrong.", + // severity: "error", + // }); + }; + + const onLoading = (isLoading: boolean) => { + }; + + await deleteCategories({ onSuccess, onError, onLoading, id }); + }, []); + + useEffect(() => { + getCategoriesList(); + }, []); + //ACT 6 - Create two empty functions called "handleEditItem" and "handleDeleteItem" + function handleDeleteItem(id: string){ + deleteCategoryApi(id); + } + + function handleClose(){ + setIsModalOpened(false); + } + + function openModal(){ + setIsUpdate(false); + setIsModalOpened(true); + } + + function handleCreate(){ + if(newCategory.length === 0){ + setErrorInput(true); + }else { + setErrorInput(false); + //setRows([...(rows || []), {id: Math.random.toString(), name: newCategory}]); + createCategory(newCategory); + setIsModalOpened(false); + + } + } + + function handleChange(value: string){ + setNewCategory(value); + if(value.length > 0){ + setErrorInput(false); + }else{ + setErrorInput(true); + } + } + + function handleChangeUpdate(category: Category){ + setUpdatedCategory(category); + if(category.name.length > 0){ + setErrorInput(false); + }else{ + setErrorInput(true); + } + } + + function updateCategory(){ + updateCategoryApi(initialValueUpdatedCategory!.id, updatedCategory!.name); + } + + function handleEdit(category: Category){ + setIsUpdate(true); + setIsModalOpened(true); + setUpdatedCategory(category); + setInitialValueUpdatedCategory(category); + } + + function handleUpdate(){ + if(updatedCategory?.name.length === 0){ + setErrorInput(true); + }else{ + updateCategory(); + setIsUpdate(false); + setIsModalOpened(false); + } + } + return ( + + Categories Page + + {/* ACT 8 - Use the IconButton component (from MUI) to open the Modal */} + + + + + + {/* ACT 8 - Create a Modal to add new categories and update existing ones */} + + + {!isUpdate ?

Create Category

:

Update Category

} + {!isUpdate ? handleChange(event.target.value)}> : handleChangeUpdate({id: updatedCategory!.id, name: event.target.value})} value={updatedCategory!.name}>} +
+ + {!isUpdate ? : } +
+
+
+
+ ); +} + +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..eba5b28a --- /dev/null +++ b/apps/react-app/src/components/Page/HomePage/HomePage.tsx @@ -0,0 +1,101 @@ +import { useState, useContext, useEffect, useCallback } from "react"; + +import Form from "../../Form"; +import PostList from "../../PostList"; +import CategoryButtonGroup from "../../CategoryButtonGroup"; +import { + PostContext, + // SnackbarContext +} from "../../../context"; +import { CategoriesResponse, Category, Post } from "../../../types"; +import Loading from "../../Loading"; +import CreatePostButton from "../../CreatePostButton"; +import { getCategories } from "../../../api"; + +function HomePage() { + // const createAlert = useContext(SnackbarContext); + const { posts, loadingPosts, getPostList } = useContext(PostContext); + const [open, setOpen] = useState(false); + const [loadingCategories, setLoadingCategories] = useState(false); + const [selectedPost, setSelectedPost] = useState(null); + const [categories, setCategories] = useState(null); + const [selectedCategory, setSelectedCategory] = useState( + null + ); + + const handleOpenForm = (defaultValues?: Post) => { + setOpen(true); + if (defaultValues) setSelectedPost(defaultValues); + }; + + const handleSelectCategory = useCallback( + async (category: Category) => { + const isCategoryAlreadySelected = category.id === selectedCategory?.id; + await getPostList(isCategoryAlreadySelected ? undefined : category.id); + setSelectedCategory(isCategoryAlreadySelected ? null : category); + }, + [selectedCategory, getPostList] + ); + + const getCategoriesList = useCallback(async () => { + const onSuccess = (data: CategoriesResponse[]) => { + const newRows = data.map((category) => ({ + id: category._id, + name: category.name, + })); + setCategories(newRows); + }; + + const onError = () => { + // createAlert({ + // message: "Something went wrong.", + // severity: "error", + // }); + }; + + const onLoading = (isLoading: boolean) => { + setLoadingCategories(isLoading); + }; + + await getCategories({ onSuccess, onError, onLoading }); + }, []); + + useEffect(() => { + getPostList(); + getCategoriesList(); + }, [getPostList, getCategoriesList]); + + if (!categories || loadingCategories) return ; + + return ( + <> + + + + {!posts || loadingPosts ? ( + + ) : ( + + )} + +
+ + ); +} + +export default HomePage; diff --git a/apps/react-app/src/components/Page/HomePage/index.ts b/apps/react-app/src/components/Page/HomePage/index.ts new file mode 100644 index 00000000..bf97a3e7 --- /dev/null +++ b/apps/react-app/src/components/Page/HomePage/index.ts @@ -0,0 +1 @@ +export { default } from './HomePage'; diff --git a/apps/react-app/src/components/Page/LoginPage/LoginPage.styles.tsx b/apps/react-app/src/components/Page/LoginPage/LoginPage.styles.tsx new file mode 100644 index 00000000..ac273bf3 --- /dev/null +++ b/apps/react-app/src/components/Page/LoginPage/LoginPage.styles.tsx @@ -0,0 +1,12 @@ +import { Grid } from "@mui/material"; +import { styled } from "@mui/system"; + +export const PageContainer = styled(Grid)` + display: flex; + padding: 32px; + flex-wrap: nowrap; + align-items: center; + flex-direction: column; + justify-content: center; + 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..8b20cb13 --- /dev/null +++ b/apps/react-app/src/components/Page/LoginPage/LoginPage.tsx @@ -0,0 +1,203 @@ +import { useState, FormEvent, Dispatch, SetStateAction, useCallback } from "react"; +import { PageContainer } from "./LoginPage.styles"; +import { Button, Grid } from "@mui/material"; +import TextField from "../../TextField"; +import { createUser, login } from "../../../api/endpoints/auth"; +import { AuthLoginResponse, AuthResponse } from "../../../types/index"; + +export interface LoginFields{ + inputValueUsername: string | null, + inputValuePassword: string | null +} + +export interface SignUpFields{ + inputValueUsername: string | null, + inputValuePassword: string | null, + inputValueFirstName: string | null, + inputValueLastName: string | null, + inputValueConfirmPassword: string | null +} + +interface TextFields{ + id: string, + label: string, + type: string, + value: string | null, + keyName: string, + error?: boolean + setState: Dispatch>, + comparePasswords?: (password: string | null, confirmPassword: string | null) => void; +} + +const LoginPage = () => { + + //ACT 11 - After the login is successful, use the following to direct the user to the home page: + const { protocol, host } = window.location; + + + const [signUp, setSignUp] = useState(false); + const [loginFields, setLoginFields] = useState({ + inputValueUsername: null, + inputValuePassword: null + }); + const [singUpFields, setSignUpFields] = useState({ + inputValueUsername: null, + inputValuePassword: null, + inputValueFirstName: null, + inputValueLastName: null, + inputValueConfirmPassword: null + }); + const [error, setError] = useState(false); + + function comparePasswords(password: string | null, confirmPassword: string | null) { + if (password !== confirmPassword) { + setError(true); + } else { + setError(false); + } + } + + const textFieldsLogIn: TextFields[] = [ + { + id: "username-id", + label: "User Name", + type: "text", + value: loginFields.inputValueUsername, + keyName: "inputValueUsername", + setState: setLoginFields + }, + { + id: "password-id", + label: "Password", + type: "password", + value: loginFields.inputValuePassword, + keyName: "inputValuePassword", + setState: setLoginFields + } + ]; + + const textFieldsSignUp: TextFields[] = [ + { + id: "username-id-signup", + label: "User Name", + type: "text", + value: singUpFields.inputValueUsername, + keyName: "inputValueUsername", + setState: setSignUpFields + }, + { + id: "password-id-signup", + label: "Password", + type: "password", + value: singUpFields.inputValuePassword, + keyName: "inputValuePassword", + error: error, + comparePasswords: comparePasswords, + setState: setSignUpFields + }, + { + id: "first-name", + label: "First Name", + type: "text", + value: singUpFields.inputValueFirstName, + keyName: "inputValueFirstName", + setState: setSignUpFields + }, + { + id: "last-name", + label: "Last Name", + type: "text", + value: singUpFields.inputValueLastName, + keyName: "inputValueLastName", + setState: setSignUpFields + }, + { + id: "confirm-password", + label: "Confirm Password", + type: "password", + value: singUpFields.inputValueConfirmPassword, + keyName: "inputValueConfirmPassword", + error: error, + comparePasswords: comparePasswords, + setState: setSignUpFields + } + ] + + + const createUserApi = useCallback(async () => { + const onSuccess = (data: AuthResponse) => { + + } + + const onError = () =>{}; + + const onLoading = () => {}; + + createUser({ newUser: {firstname: singUpFields.inputValueFirstName!, lastname: singUpFields.inputValueLastName!, username: singUpFields.inputValueUsername!, password: singUpFields.inputValuePassword!}, onSuccess, onError, onLoading}); + }, [singUpFields]); + + const loginApi = useCallback(async () => { + + const onSuccess = (data: AuthLoginResponse) => { + localStorage.setItem("token", data.accessToken); + const signInUrl = `${protocol}//${host}/`; + if (window.location.href !== signInUrl) { + window.location.assign(signInUrl); + } + } + + const onError = () => {}; + + const onLoading = () => {}; + + login({ user: {username: loginFields.inputValueUsername!, password: loginFields.inputValuePassword!}, onSuccess, onError, onLoading}); + + }, [loginFields]); + + function handleClickSignUp():void{ + if(signUp){ + createUserApi(); + }else{ + setSignUp(true); + } + } + + function handleClickLogIn():void{ + if(signUp){ + setSignUp(false); + }else{ + loginApi(); + } + } + + function handleSubmit(event: FormEvent):void{ + event.preventDefault(); + } + + return ( + + Login Page + + {/* ACT 8 - Create a form to Login and SignUp */} + {!signUp && ( + + {textFieldsLogIn.map(textField => { + return + })} + + )} + {signUp && ( +
+ {textFieldsSignUp.map(textField => { + return + })} + + )} + + +
+
+ ); +}; + +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..2a7ef145 --- /dev/null +++ b/apps/react-app/src/components/Page/Page.tsx @@ -0,0 +1,33 @@ +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/Page/PostPage/PostPage.styles.tsx b/apps/react-app/src/components/Page/PostPage/PostPage.styles.tsx new file mode 100644 index 00000000..a3713f83 --- /dev/null +++ b/apps/react-app/src/components/Page/PostPage/PostPage.styles.tsx @@ -0,0 +1,24 @@ +import { Grid } from "@mui/material"; +import { styled } from "@mui/system"; + +export const Container = styled(Grid)` + flex-grow: 1; + flex-wrap: nowrap; + flex-direction: column; + background-color: #f0f0ff; +`; + +export const BannerContainer = styled(Grid)` + flex-grow: 1; +`; + +export const DescriptionContainer = styled(Grid)` + padding: 16px; +`; + +export const CommentsContainer = styled(Grid)` + display: flex; + flex-grow: 1; + padding: 16px; + justify-content: center; +`; 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..151df6ff --- /dev/null +++ b/apps/react-app/src/components/Page/PostPage/PostPage.tsx @@ -0,0 +1,73 @@ +import { + Container, + BannerContainer, + CommentsContainer, + DescriptionContainer, +} from "./PostPage.styles"; + +import Banner from "../../Banner"; +import Comments from "../../Comments"; +import { useParams } from "react-router-dom"; +import { useEffect, useState } from "react"; +import { Post, PostResponse } from "../../../types"; +import { AxiosError } from "axios"; +import { getPost } from "../../../api"; + +// const postID = "664128a212f505651c18d676" + + +function PostPage() { + // ACT 9 - Use postID variable to fetch the post data + // ACT 10 - Get postID from route params + const { id } = useParams(); + const [post, setPost] = useState(); + + useEffect(() => { + const fetchPost = async () => { + + const onSuccess = (data: PostResponse) => { + setPost({ + id: data._id, + title: data.title, + image: data.image, + description: data.description, + category: data.category, + comments: data.comments + }); + }; + + const onError = (error: AxiosError) => { + console.error("Error al obtener el post:", error); + }; + + const onLoading = () => { + }; + + onLoading(); + getPost({ postID: id!, onSuccess, onError }); + }; + + fetchPost(); + + }, []); + + return ( + <> + {post && ( + + Post page + + + + +

{post!.description}

+
+ + + +
)} + + ); +} + +export default PostPage; diff --git a/apps/react-app/src/components/Page/PostPage/index.ts b/apps/react-app/src/components/Page/PostPage/index.ts new file mode 100644 index 00000000..9374e837 --- /dev/null +++ b/apps/react-app/src/components/Page/PostPage/index.ts @@ -0,0 +1 @@ +export { default } from './PostPage'; 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..641bb5c1 --- /dev/null +++ b/apps/react-app/src/components/Page/index.ts @@ -0,0 +1,8 @@ +export { default as HomePage } from "./HomePage"; +export { default as PostPage } from "./PostPage"; +export { default as CategoriesPage } from "./CategoriesPage"; +export { default as LoginPage } from "./LoginPage"; +// ACT 10 - export NotFoundPage componenet +export { default as NotFoundPage } from "./NotFoundPage"; + +export { default } from "./Page"; diff --git a/apps/react-app/src/components/PostList/PostList.styles.tsx b/apps/react-app/src/components/PostList/PostList.styles.tsx new file mode 100644 index 00000000..5fb3fb94 --- /dev/null +++ b/apps/react-app/src/components/PostList/PostList.styles.tsx @@ -0,0 +1,40 @@ +import { Grid, Box } from "@mui/material"; +import { styled } from "@mui/system"; + +export const PostCard = styled(Grid)<{ image: string }>` + display: flex; + flex-grow: 1; + color: white; + cursor: pointer; + background-size: cover; + background-position: center; + background-repeat: no-repeat; + background-image: ${(props) => `url(${props.image})`}; + + :hover .card-actions { + visibility: visible; + } +`; + +export const CardContainer = styled(Box)` + display: flex; + flex-grow: 1; + flex-direction: column; + justify-content: space-between; + background-color: #4b4b4b3b; +`; + +export const CardContent = styled(Box)` + display: flex; + padding: 24px; + padding-top: 160px; + flex-direction: column; +`; + +export const CardActions = styled(Box)` + gap: 16px; + padding: 16px; + display: flex; + visibility: hidden; + justify-content: end; +`; \ No newline at end of file diff --git a/apps/react-app/src/components/PostList/PostList.tsx b/apps/react-app/src/components/PostList/PostList.tsx new file mode 100644 index 00000000..b8096860 --- /dev/null +++ b/apps/react-app/src/components/PostList/PostList.tsx @@ -0,0 +1,83 @@ +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 { shorten } from "../../common/utils"; +import { Category, Post } from "../../types"; +import { PostContext } from "../../context"; +import { + CardActions, + CardContainer, + CardContent, + PostCard, +} from "./PostList.styles"; +import { useNavigate } from "react-router-dom"; + +interface PostListProps { + posts: Post[]; + selectedCategory: Category | null; + handleOpenForm: (defaultValues?: Post) => void; +} + +function PostList({ posts, selectedCategory, handleOpenForm }: PostListProps) { + const { removePost } = useContext(PostContext); + const navigate = useNavigate(); + + function handleClick(id: string){ + navigate("/post/"+id); + } + + return ( + + {posts.map((post) => ( + handleClick(post.id)} + // ACT 10 - Navigate to PostPage component and send postID as route params + > + + +

{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, + }); + }} + > + + + +
+
+ ))} +
+ ); +} + +export default PostList; diff --git a/apps/react-app/src/components/PostList/index.ts b/apps/react-app/src/components/PostList/index.ts new file mode 100644 index 00000000..d9b38bc8 --- /dev/null +++ b/apps/react-app/src/components/PostList/index.ts @@ -0,0 +1 @@ +export { default } from './PostList'; \ No newline at end of file diff --git a/apps/react-app/src/assets/.gitkeep b/apps/react-app/src/components/Table/Table.styles.tsx similarity index 100% rename from apps/react-app/src/assets/.gitkeep rename to apps/react-app/src/components/Table/Table.styles.tsx diff --git a/apps/react-app/src/components/Table/Table.tsx b/apps/react-app/src/components/Table/Table.tsx new file mode 100644 index 00000000..8b21fdc4 --- /dev/null +++ b/apps/react-app/src/components/Table/Table.tsx @@ -0,0 +1,45 @@ +import { Category } from "../../types"; +import { Table, TableBody, TableCell, TableContainer, TableHead, TableRow } from '@mui/material'; +import DeleteIcon from '@mui/icons-material/Delete'; +import EditIcon from '@mui/icons-material/Edit'; +import IconButton from "@mui/material/IconButton"; +import {Paper} from "@mui/material"; + +interface TableProps{ + headers: string[], + categories: Category[] | null, + handleDeleteItem: (id: string) => void, + handleEdit: (category: Category) => void; +} + +function TableComponent({headers, categories, handleDeleteItem, handleEdit}:TableProps){ + + return ( + + + + + {headers.map(header => { + return {header} + })} + + + + {categories?.map(category => { + return ( + + {category.name} + + handleDeleteItem(category.id)}> + handleEdit(category)}> + + + ) + })} + +
+
+ ); +} + +export default TableComponent; \ No newline at end of file diff --git a/apps/react-app/src/components/Table/index.ts b/apps/react-app/src/components/Table/index.ts new file mode 100644 index 00000000..1da37b78 --- /dev/null +++ b/apps/react-app/src/components/Table/index.ts @@ -0,0 +1 @@ +export { default } from './Table'; \ No newline at end of file diff --git a/apps/react-app/src/components/TextField/TextField.tsx b/apps/react-app/src/components/TextField/TextField.tsx new file mode 100644 index 00000000..8219515b --- /dev/null +++ b/apps/react-app/src/components/TextField/TextField.tsx @@ -0,0 +1,65 @@ +import { TextField as TextFieldMui } from "@mui/material"; +import { Dispatch, SetStateAction } from "react"; +interface TextFieldProps{ + id: string, + label: string, + type: string, + value: string | null, + keyName: string, + error?: boolean, + comparePasswords?: (password: string | null, confirmPassword: string | null) => void; + setState: Dispatch> +} + +function TextField({id, label, type, value, keyName, error, comparePasswords, setState}: TextFieldProps){ + + function handleChange(keyName: string, value: string):void{ + setState((oldValue) => { + console.log({...oldValue, [keyName]: value}); + return {...oldValue, [keyName]: value} + }); + } + + function handleChangePasswords(keyName: string, value: string): void { + setState((oldValue) => { + const updatedFields = { ...oldValue, [keyName]: value }; + + // Aquí llamamos a comparePasswords con los valores actualizados + if (comparePasswords && (keyName === "inputValuePassword" || keyName === "inputValueConfirmPassword")) { + comparePasswords(updatedFields.inputValuePassword, updatedFields.inputValueConfirmPassword); + } + + return updatedFields; + }); + } + + return ( + <> + {!id.includes("password") ? ( handleChange(keyName, event.target.value)} + sx={{pb:2}} + />) : ( handleChangePasswords(keyName, event.target.value)} + error={error} + sx={{pb:2}} + />)} + + + ) +} + +export default TextField; \ No newline at end of file diff --git a/apps/react-app/src/components/TextField/index.ts b/apps/react-app/src/components/TextField/index.ts new file mode 100644 index 00000000..4b6daa14 --- /dev/null +++ b/apps/react-app/src/components/TextField/index.ts @@ -0,0 +1 @@ +export { default } from './TextField'; \ No newline at end of file diff --git a/apps/react-app/src/containers/.gitkeep b/apps/react-app/src/containers/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/apps/react-app/src/context/AuthProvider.tsx b/apps/react-app/src/context/AuthProvider.tsx new file mode 100644 index 00000000..adfaa44c --- /dev/null +++ b/apps/react-app/src/context/AuthProvider.tsx @@ -0,0 +1,53 @@ +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("token"); + // ACT 11 - Get the token from localStorage + 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..f4066df7 --- /dev/null +++ b/apps/react-app/src/context/PostProvider.tsx @@ -0,0 +1,186 @@ +import React, { + createContext, + useState, + useContext, + useCallback, +} 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; + 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: () => {}, + 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("error", "Something went wrong.") + }, []); + + const getPostList = useCallback( + async (selectedCategoryID?: string) => { + 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); + }, + [onError] + ); + + const addPost = useCallback( + async (newPost: NewPost) => { + const onSuccess = async () => { + await getPostList(); + createAlert("success", "Post successfully created.") + }; + + 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("success", "Post successfully updated.") + }; + + 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("success", "Posts deleted successfuly"); + }; + setLoadingPosts(true); + await deletePost({ postID, onSuccess, onError }); + }, + [onError, getPostList] + [onError, getPostList] + ); + + return ( + + {children} + + ); +} \ No newline at end of file diff --git a/apps/react-app/src/context/SnackbarProvider.tsx b/apps/react-app/src/context/SnackbarProvider.tsx new file mode 100644 index 00000000..8ff47a39 --- /dev/null +++ b/apps/react-app/src/context/SnackbarProvider.tsx @@ -0,0 +1,69 @@ +// ACT 7 - Create SnackbarProvider + +import { createContext, useState, useContext } from "react"; +import Snackbar, { SnackbarCloseReason } from '@mui/material/Snackbar'; +import { Alert } from "@mui/material"; + +interface SnackbarProviderProps { + children: React.ReactNode; +} + +interface SnackbarContextProps { + createAlert: (severity: Severity, message: string) => void; + +} + +interface SnackbarState { + open: boolean, + severity: Severity, + message: string +} + +type Severity = "success" | "info" | "warning" | "error"; + +export const SnackbarContext = createContext({ + createAlert: () => {} +}); + +export function SnackbarProvider({ + children +}: SnackbarProviderProps): React.JSX.Element { + const initialSnackbarState: SnackbarState = { + open: false, + severity: "success", + message: "" + } + const [snackbar, setSnackbar] = useState(initialSnackbarState); + + const createAlert = (severity: Severity, message: string) => { + setSnackbar({open: true, severity, message}); + } + + const handleClose = (event: React.SyntheticEvent | Event, reason?: SnackbarCloseReason) => { + if(reason === "clickaway"){ + return; + } + + setSnackbar({...snackbar, open: false}); + } + + return ( + + + + {snackbar.message} + + + {children} + + ); +} \ No newline at end of file diff --git a/apps/react-app/src/context/index.ts b/apps/react-app/src/context/index.ts new file mode 100644 index 00000000..dccb84f2 --- /dev/null +++ b/apps/react-app/src/context/index.ts @@ -0,0 +1,5 @@ +export { PostProvider, PostContext } from "./PostProvider"; +// ACT 7 - Export SnackbarProvider component +export { SnackbarProvider, SnackbarContext } from "./SnackbarProvider"; +export { AuthProvider, AuthContext } from "./AuthProvider"; +// ACT 7 - Export SnackbarProvider component diff --git a/apps/react-app/src/main.tsx b/apps/react-app/src/main.tsx index 0d4ef43d..8b92f083 100644 --- a/apps/react-app/src/main.tsx +++ b/apps/react-app/src/main.tsx @@ -1,15 +1,12 @@ import { StrictMode } from 'react'; import * as ReactDOM from 'react-dom/client'; -import { BrowserRouter } from 'react-router-dom'; -import App from './app/app'; +import App from './App'; const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement); root.render( - - - + ); diff --git a/apps/react-app/src/pages/.gitkeep b/apps/react-app/src/pages/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/apps/react-app/src/services/.gitkeep b/apps/react-app/src/services/.gitkeep deleted file mode 100644 index e69de29b..00000000 diff --git a/apps/react-app/src/styles.css b/apps/react-app/src/styles.css index 90d4ee00..a7f2c8b1 100644 --- a/apps/react-app/src/styles.css +++ b/apps/react-app/src/styles.css @@ -1 +1,14 @@ -/* You can add global styles to this file, and also import other style files */ +body { + margin: 0; + } + :root { + font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif; + line-height: 1.5; + font-weight: 400; + + font-synthesis: none; + text-rendering: optimizeLegibility; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + } + \ No newline at end of file diff --git a/apps/react-app/src/types/index.ts b/apps/react-app/src/types/index.ts new file mode 100644 index 00000000..eb0cfe02 --- /dev/null +++ b/apps/react-app/src/types/index.ts @@ -0,0 +1,143 @@ +import { CommentProp } from "../components/CommentCard/CommentCard"; + +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: CommentProp[]; +}; + +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: CommentProp[]; + 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/apps/react-app/src/utils/getErrorMessage.tsx b/apps/react-app/src/utils/getErrorMessage.tsx new file mode 100644 index 00000000..d2b53bb6 --- /dev/null +++ b/apps/react-app/src/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/utils/index.ts b/apps/react-app/src/utils/index.ts new file mode 100644 index 00000000..ed85ce09 --- /dev/null +++ b/apps/react-app/src/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/utils/inputsValidator.tsx b/apps/react-app/src/utils/inputsValidator.tsx new file mode 100644 index 00000000..507751d3 --- /dev/null +++ b/apps/react-app/src/utils/inputsValidator.tsx @@ -0,0 +1,34 @@ +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/shorten.tsx b/apps/react-app/src/utils/shorten.tsx new file mode 100644 index 00000000..6c4177d8 --- /dev/null +++ b/apps/react-app/src/utils/shorten.tsx @@ -0,0 +1,4 @@ +export function shorten(str: string, maxLen: number) { + if (str.length <= maxLen) return str; + return `${str.substr(0, str.lastIndexOf(" ", maxLen))}...`; + } \ No newline at end of file diff --git a/apps/react-app/src/utils/sortTable.tsx b/apps/react-app/src/utils/sortTable.tsx new file mode 100644 index 00000000..a2f15c5c --- /dev/null +++ b/apps/react-app/src/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/db.json b/db.json new file mode 100644 index 00000000..2840848c --- /dev/null +++ b/db.json @@ -0,0 +1,62 @@ +{ + "posts": [ + { + "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." + } + ] + }, + { + "id": "3", + "title": "Peaceful activities to do on a Monday morning", + "image": "https://www.cairnsskydivers.com.au/wp-content/uploads/2021/07/skydivingoverthegreatbarrierreef.jpg", + "description": "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. ", + "category": "Sports", + "comments": [ + { + "id": "3.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": "4", + "title": "A good choice to eatt", + "image": "https://th.bing.com/th/id/R.2d66d3ce21d052726c2c527a03da4f4c?rik=3FedcY2H7LDtBw&riu=http%3a%2f%2ftheartofplating.com%2fwp-content%2fuploads%2f2015%2f06%2fEvan_Feature.jpg&ehk=KCxZkONbpjuAYhfpKxoeHgIizR%2fy1U0LM6olKn1d8go%3d&risl=&pid=ImgRaw&r=0", + "description": "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. ", + "category": "Health", + "comments": [ + { + "id": "4.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": "0.34674599859102395", + "title": "New", + "image": "https://th.bing.com/th/id/R.385e7dbec0e6c313cfd6dc3b6fff1c95?rik=Ps5ZHpTWtX4y3A&pid=ImgRaw&r=0", + "description": "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. ", + "category": "Travel", + "comments": [] + }, + { + "id": "0.18206228009545122", + "title": "Latest", + "image": "https://media.geeksforgeeks.org/wp-content/uploads/Screen-Shot-2017-11-13-at-10.23.39-AM.png", + "description": "Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. ", + "category": "Sports", + "comments": [] + } + ] +} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index b27ba381..4e7d20dd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -41244,4 +41244,4 @@ } } } -} +} \ No newline at end of file