From 5f98e81255457a398777b6ac0bea87b063c4f7f4 Mon Sep 17 00:00:00 2001 From: ExLuZiVe53 Date: Fri, 27 Oct 2023 14:01:03 +0200 Subject: [PATCH] added all files in to lesson 6 --- package.json | 2 +- src/App.jsx | 60 ++++++++++++ src/App.module.css | 18 ++++ src/App.styled.js | 66 +++++++++++++ src/assets/images/heart.svg | 7 ++ src/assets/images/search.svg | 4 + src/components/ErrorMessage.jsx | 7 ++ src/components/HeaderExapmleModules.jsx | 38 ++++++++ src/components/Loader.jsx | 20 ++++ src/components/PostList.jsx | 25 +++++ src/components/PostListItem.jsx | 17 ++++ src/components/SearchPostForm.jsx | 18 ++++ src/index.css | 14 +++ src/index.js | 19 +++- src/pages/HomePage.jsx | 11 +++ src/pages/PostCommentsPage.jsx | 53 +++++++++++ src/pages/PostDetailsPage.jsx | 91 ++++++++++++++++++ src/pages/PostsPage.jsx | 38 ++++++++ src/pages/SearchPage.jsx | 89 ++++++++++++++++++ src/redux/postDetailReducer.js | 120 ++++++++++++++++++++++++ src/redux/store.js | 37 ++++++++ src/services/api.js | 22 +++++ 22 files changed, 771 insertions(+), 5 deletions(-) create mode 100644 src/App.jsx create mode 100644 src/App.module.css create mode 100644 src/App.styled.js create mode 100644 src/assets/images/heart.svg create mode 100644 src/assets/images/search.svg create mode 100644 src/components/ErrorMessage.jsx create mode 100644 src/components/HeaderExapmleModules.jsx create mode 100644 src/components/Loader.jsx create mode 100644 src/components/PostList.jsx create mode 100644 src/components/PostListItem.jsx create mode 100644 src/components/SearchPostForm.jsx create mode 100644 src/pages/HomePage.jsx create mode 100644 src/pages/PostCommentsPage.jsx create mode 100644 src/pages/PostDetailsPage.jsx create mode 100644 src/pages/PostsPage.jsx create mode 100644 src/pages/SearchPage.jsx create mode 100644 src/redux/postDetailReducer.js create mode 100644 src/redux/store.js create mode 100644 src/services/api.js diff --git a/package.json b/package.json index 18768d5..680884b 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "react-homework-template", "version": "0.1.0", "private": true, - "homepage": "https://goitacademy.github.io/react-homework-template/", + "homepage": "https://exluzive53.github.io/react-redux-AsyncThunk/", "dependencies": { "@testing-library/jest-dom": "^5.16.3", "@testing-library/react": "^12.1.4", diff --git a/src/App.jsx b/src/App.jsx new file mode 100644 index 0000000..61bb026 --- /dev/null +++ b/src/App.jsx @@ -0,0 +1,60 @@ +import { Suspense, lazy } from 'react'; +import { Route, Routes } from 'react-router-dom'; + +// import HomePage from 'pages/HomePage'; +// import PostsPage from 'pages/PostsPage'; +// import SearchPage from 'pages/SearchPage'; +// import PostDetailsPage from 'pages/PostDetailsPage'; + +import { StyledAppContainer, StyledNavLink } from 'App.styled'; +import Loader from 'components/Loader'; + +const HomePage = lazy(() => import('pages/HomePage')); +const PostsPage = lazy(() => import('pages/PostsPage')); +const SearchPage = lazy(() => import('pages/SearchPage')); +const PostDetailsPage = lazy(() => import('pages/PostDetailsPage')); + +/* +Маршрутизація: + + Google - будь-які посилання на зовнішні ресурси, + поза нашим додатком + + Some page + Some page - для маршутизації по нашому додатку + + 1. Зміна адресної строки браузера. + 2. Підготувати Route для відображення, тієї чи іншої сторінки + } /> + +*/ + +export const App = () => { + return ( + +
+ +
+ + }> + + } /> + } /> + } /> + {/* /posts/d12dWAF@ */} + } /> + + +
+ ); +}; diff --git a/src/App.module.css b/src/App.module.css new file mode 100644 index 0000000..1aaa40c --- /dev/null +++ b/src/App.module.css @@ -0,0 +1,18 @@ +.header-link { + color: black; + border: 1px solid black; + border-radius: 10px; + display: inline-block; + padding: 20px; + font-size: 22px; + text-decoration: none; + margin-right: 15px; + + transition: all 0.3s; +} + +.header-link.active { + border: 1px solid white; + background-color: black; + color: white; +} diff --git a/src/App.styled.js b/src/App.styled.js new file mode 100644 index 0000000..8d982a2 --- /dev/null +++ b/src/App.styled.js @@ -0,0 +1,66 @@ +import { NavLink } from 'react-router-dom'; +import styled from 'styled-components'; + +export const StyledAppContainer = styled.div` + max-width: 1200px; + width: 100%; + margin: 0 auto; + padding: 0px 15px; + + .title { + text-transform: uppercase; + text-decoration: overline; + color: brown; + transition: all 0.3s; + + &:hover, + &:focus { + background-color: yellow; + color: blue; + } + } + + .postList { + padding: 0; + list-style-type: none; + display: flex; + flex-direction: column; + gap: 25px; + } + + .postListItem { + padding: 20px; + border: 1px solid black; + border-radius: 20px; + } + + .error { + padding: 20px; + font-size: 24px; + border: 1px solid black; + background-color: red; + color: white; + } + + .header-link { + } +`; + +export const StyledNavLink = styled(NavLink)` + color: black; + border: 1px solid black; + border-radius: 10px; + display: inline-block; + padding: 20px; + font-size: 22px; + text-decoration: none; + margin-right: 15px; + + transition: all 0.3s; + + &.active { + border: 1px solid white; + background-color: black; + color: white; + } +`; diff --git a/src/assets/images/heart.svg b/src/assets/images/heart.svg new file mode 100644 index 0000000..bfe45b7 --- /dev/null +++ b/src/assets/images/heart.svg @@ -0,0 +1,7 @@ + + + + \ No newline at end of file diff --git a/src/assets/images/search.svg b/src/assets/images/search.svg new file mode 100644 index 0000000..a37068a --- /dev/null +++ b/src/assets/images/search.svg @@ -0,0 +1,4 @@ + + + \ No newline at end of file diff --git a/src/components/ErrorMessage.jsx b/src/components/ErrorMessage.jsx new file mode 100644 index 0000000..895c0c6 --- /dev/null +++ b/src/components/ErrorMessage.jsx @@ -0,0 +1,7 @@ +import React from 'react'; + +const ErrorMessage = ({ message }) => { + return

{message}

; +}; + +export default ErrorMessage; diff --git a/src/components/HeaderExapmleModules.jsx b/src/components/HeaderExapmleModules.jsx new file mode 100644 index 0000000..255a141 --- /dev/null +++ b/src/components/HeaderExapmleModules.jsx @@ -0,0 +1,38 @@ +import React from 'react'; +import { NavLink } from 'react-router-dom'; +import css from 'App.module.css'; + +const HeaderExapmleModules = () => { + return ( +
+ +
+ ); +}; + +export default HeaderExapmleModules; diff --git a/src/components/Loader.jsx b/src/components/Loader.jsx new file mode 100644 index 0000000..332fcf4 --- /dev/null +++ b/src/components/Loader.jsx @@ -0,0 +1,20 @@ +import React from 'react'; +import { ColorRing } from 'react-loader-spinner'; + +const Loader = () => { + return ( +
+ +
+ ); +}; + +export default Loader; diff --git a/src/components/PostList.jsx b/src/components/PostList.jsx new file mode 100644 index 0000000..79ebd97 --- /dev/null +++ b/src/components/PostList.jsx @@ -0,0 +1,25 @@ +import React from 'react'; +import PostListItem from './PostListItem'; + +const PostList = ({ posts }) => { + const showPosts = Array.isArray(posts) && posts.length; + + return ( + + ); +}; + +export default PostList; diff --git a/src/components/PostListItem.jsx b/src/components/PostListItem.jsx new file mode 100644 index 0000000..eb4a5dc --- /dev/null +++ b/src/components/PostListItem.jsx @@ -0,0 +1,17 @@ +import React from 'react'; +import { Link } from 'react-router-dom'; + +const PostListItem = ({ id, title, userId, body }) => { + return ( +
  • + + Id: {id} +

    Title: {title}

    +

    User Id: {userId}

    +

    Body: {body}

    + +
  • + ); +}; + +export default PostListItem; diff --git a/src/components/SearchPostForm.jsx b/src/components/SearchPostForm.jsx new file mode 100644 index 0000000..e1123d1 --- /dev/null +++ b/src/components/SearchPostForm.jsx @@ -0,0 +1,18 @@ +import React from 'react'; + +const SearchPostForm = ({ handleSearchSubmit, fetchAllPosts }) => { + return ( +
    + +
    + ); +}; + +export default SearchPostForm; diff --git a/src/index.css b/src/index.css index 1aac5f6..a5a7f7e 100644 --- a/src/index.css +++ b/src/index.css @@ -13,3 +13,17 @@ code { font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', monospace; } + +*, +*::before, +*::after { + box-sizing: border-box; +} + +h1, +h2, +h3, +h4, +p { + margin: 0; +} diff --git a/src/index.js b/src/index.js index 2bde91e..3f42b19 100644 --- a/src/index.js +++ b/src/index.js @@ -1,10 +1,21 @@ import React from 'react'; import ReactDOM from 'react-dom/client'; -import { App } from 'components/App'; +import { BrowserRouter } from 'react-router-dom'; +import { Provider } from 'react-redux'; + +import { App } from 'App'; + +import { persistor, store } from 'redux/store'; + import './index.css'; +import { PersistGate } from 'redux-persist/integration/react'; ReactDOM.createRoot(document.getElementById('root')).render( - - - + + + + + + + ); diff --git a/src/pages/HomePage.jsx b/src/pages/HomePage.jsx new file mode 100644 index 0000000..a9efd61 --- /dev/null +++ b/src/pages/HomePage.jsx @@ -0,0 +1,11 @@ +import React from 'react'; + +const HomePage = () => { + return ( +
    + HomePage +
    + ); +} + +export default HomePage; diff --git a/src/pages/PostCommentsPage.jsx b/src/pages/PostCommentsPage.jsx new file mode 100644 index 0000000..8e0e959 --- /dev/null +++ b/src/pages/PostCommentsPage.jsx @@ -0,0 +1,53 @@ +import ErrorMessage from 'components/ErrorMessage'; +import Loader from 'components/Loader'; +import React, { useEffect, useState } from 'react'; +import { useParams } from 'react-router-dom'; +import { findPostCommentsById } from 'services/api'; + +const PostCommentsPage = () => { + const { postId } = useParams(); + const [comments, setComments] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + if (!postId) return; + + const fetchAllPosts = async () => { + try { + setIsLoading(true); + const commentsData = await findPostCommentsById(postId); + + setComments(commentsData); + } catch (error) { + setError(error.message); + } finally { + setIsLoading(false); + } + }; + + fetchAllPosts(); + }, [postId]); + + return ( +
    + {isLoading && } + {error && } + {comments !== null && ( +
      + {comments.map(comment => { + return ( +
    • +

      Name: {comment.name}

      +

      Email: {comment.email}

      +

      Body: {comment.body}

      +
    • + ); + })} +
    + )} +
    + ); +}; + +export default PostCommentsPage; diff --git a/src/pages/PostDetailsPage.jsx b/src/pages/PostDetailsPage.jsx new file mode 100644 index 0000000..687aed4 --- /dev/null +++ b/src/pages/PostDetailsPage.jsx @@ -0,0 +1,91 @@ +import React, { Suspense, lazy, useEffect, useRef } from 'react'; +import { + Link, + NavLink, + Route, + Routes, + useLocation, + useParams, +} from 'react-router-dom'; +// import PostCommentsPage from './PostCommentsPage'; +import Loader from 'components/Loader'; +import ErrorMessage from 'components/ErrorMessage'; + +import { findPostById } from 'services/api'; +import { useDispatch, useSelector } from 'react-redux'; +import { + addPost, + setError, + setIsLoading, + setPostDetails, +} from 'redux/postDetailReducer'; + +const PostCommentsPage = lazy(() => import('pages/PostCommentsPage')); + +const PostDetailsPage = () => { + const { postId } = useParams(); + const location = useLocation(); + const backLinkHref = useRef(location.state?.from ?? '/'); + + const postDetails = useSelector(state => state.postDetails.postDetailsData); + const isLoading = useSelector(state => state.postDetails.isLoading); + const error = useSelector(state => state.postDetails.error); + const dispatch = useDispatch(); + // const [postDetails, setPostDetails] = useState(null); + // const [isLoading, setIsLoading] = useState(false); + // const [error, setError] = useState(null); + + useEffect(() => { + if (!postId) return; + + const fetchAllPosts = async () => { + try { + // setIsLoading(true); + dispatch(setIsLoading(true)); + const postData = await findPostById(postId); + // setPostDetails(postData); + dispatch(setPostDetails(postData)); + } catch (error) { + // setError(error.message); + dispatch(setError(error.message)); + } finally { + // setIsLoading(false); + dispatch(setIsLoading(false)); + } + }; + + fetchAllPosts(); + }, [postId, dispatch]); + + return ( +
    + Go Back + + + {isLoading && } + {error && } + {postDetails !== null && ( +
    +

    Post Title: {postDetails.title}

    +

    Post Body: {postDetails.body}

    +
    + )} + +
    + + Comments + +
    + + }> + + } /> + + +
    + ); +}; + +export default PostDetailsPage; diff --git a/src/pages/PostsPage.jsx b/src/pages/PostsPage.jsx new file mode 100644 index 0000000..7ca3634 --- /dev/null +++ b/src/pages/PostsPage.jsx @@ -0,0 +1,38 @@ +import ErrorMessage from 'components/ErrorMessage'; +import Loader from 'components/Loader'; +import PostList from 'components/PostList'; +import React, { useEffect, useState } from 'react'; +import { fetchPosts } from 'services/api'; + +const PostsPage = () => { + const [posts, setPosts] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + const fetchAllPosts = async () => { + try { + setIsLoading(true); + const postsData = await fetchPosts(); + + setPosts(postsData); + } catch (error) { + setError(error.message); + } finally { + setIsLoading(false); + } + }; + fetchAllPosts(); + }, []); + + return ( +
    + {isLoading && } + {error && } + + +
    + ); +}; + +export default PostsPage; diff --git a/src/pages/SearchPage.jsx b/src/pages/SearchPage.jsx new file mode 100644 index 0000000..b929045 --- /dev/null +++ b/src/pages/SearchPage.jsx @@ -0,0 +1,89 @@ +import { ReactComponent as IconSearch } from 'assets/images/search.svg'; +import ErrorMessage from 'components/ErrorMessage'; +import Loader from 'components/Loader'; +import { useEffect, useState } from 'react'; +import { Link, useLocation, useSearchParams } from 'react-router-dom'; +import { findPostById } from 'services/api'; + +// (async () => { // -- IIFE (Immediately invoked function expression) +// try { +// setIsLoading(true); +// const postData = await findPostById(query); + +// setPosts([postData]); +// } catch (error) { +// setError(error.message); +// } finally { +// setIsLoading(false); +// } +// })() + +const SearchPage = () => { + // /search?query=56 + const [searchParams, setSearchParams] = useSearchParams(); + const location = useLocation(); + const [posts, setPosts] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + + const query = searchParams.get('query'); + + useEffect(() => { + if (!query) return; + + const fetchAllPosts = async () => { + try { + setIsLoading(true); + const postData = await findPostById(query); + + setPosts([postData]); + } catch (error) { + setError(error.message); + } finally { + setIsLoading(false); + } + }; + + fetchAllPosts(); + }, [query]); + + const handleFormSubmit = event => { + event.preventDefault(); + const searchValue = event.currentTarget.elements.searchPostId.value; + + setSearchParams({ query: searchValue }); + }; + + return ( +
    +
    + + +
    + +
    + {isLoading && } + {error && } + {posts !== null && + posts.map(post => ( + + Post Id: {post.id} +

    Post Title: {post.title}

    +

    Post Body: {post.body}

    + + ))} +
    +
    + ); +}; + +export default SearchPage; diff --git a/src/redux/postDetailReducer.js b/src/redux/postDetailReducer.js new file mode 100644 index 0000000..30b5fec --- /dev/null +++ b/src/redux/postDetailReducer.js @@ -0,0 +1,120 @@ +import { createSlice } from '@reduxjs/toolkit'; + +// створюємо початковий стан state +const INITIAL_STATE = { + postDetailsData: null, + isLoading: false, + error: null, + posts: [], +}; + +const postDetailsSlice = createSlice({ + // Ім'я слайсу + name: 'postDetails', + // Початковий стан редюсера слайсу + initialState: INITIAL_STATE, + // Об'єкт редюсерів + reducers: { + setIsLoading(state, action) { + state.isLoading = action.payload; + }, + setPostDetails(state, action) { + state.postDetailsData = action.payload; + }, + setError(state, action) { + state.error = action.payload; + }, + addPost(state, action) { + // state.posts.push(action.payload); + state.posts = [...state.posts, action.payload]; + }, + deletePost(state, action) { + state.posts = state.posts.filter(post => post.id !== action.payload); + // const deletedPostIndex = state.posts.findIndex(post => post.id === action.payload); + // state.posts.splice(deletedPostIndex, 1); + }, + }, +}); + +// Генератори екшенів +export const { setIsLoading, setPostDetails, setError, addPost, deletePost } = + postDetailsSlice.actions; +// Редюсер слайсу +export const postDetailsReducer = postDetailsSlice.reducer; + +// // Ред'юсер це функція яка приймає state, action + +// export const postDetailsReducer = (state = INITIAL_STATE, action) => { + +// // action-> {type: 'postDetails/setIsLoading', payload: true} +// // перевіряємо тип інструкції яка прийшла в наш ред'юсер + +// switch (action.type) { +// case 'postDetails/setIsLoading': { +// return { +// ...state, + +// // єдиний спосіб коли значення прийдуть це тільки action.payload + +// isLoading: action.payload, +// }; +// } + +// case 'postDetails/setPostDetails': { +// return { +// ...state, +// postDetailsData: action.payload, +// }; +// } + +// case 'postDetails/setError': { +// return { +// ...state, +// error: action.payload, +// }; +// } + +// case 'postDetails/addPost': { + +// // action.payload - {id: 1, title: '123', body: "hello"} +// return { +// ...state, +// posts: [...state.posts, action.payload], +// }; +// } +// case 'postDetails/deletePost': { + +// // action.payload -1 +// return { +// ...state, +// posts: state.posts.filter(post => post.id !== action.payload), +// }; +// } + +// default: +// return state; +// } +// }; + +// // Створюємо екшенкріейтори, тобто логіку виносимо в ред'юсер + +// export const setIsLoading = payload => { +// return { +// type: 'postDetails/setIsLoading', +// payload, +// }; +// }; + +// export const setPostDetails = payload => { +// return { +// type: 'postDetails/setPostDetails', +// payload, +// }; +// }; + +// export const setError = payload => { +// return { +// type: 'postDetails/setError', +// payload, +// }; +// }; diff --git a/src/redux/store.js b/src/redux/store.js new file mode 100644 index 0000000..a28dcfd --- /dev/null +++ b/src/redux/store.js @@ -0,0 +1,37 @@ +import { configureStore } from '@reduxjs/toolkit'; +import { + persistStore, + persistReducer, + FLUSH, + REHYDRATE, + PAUSE, + PERSIST, + PURGE, + REGISTER, +} from 'redux-persist'; +import storage from 'redux-persist/lib/storage'; +import { postDetailsReducer } from './postDetailReducer'; + +const postDetailsConfig = { + key: 'postDetails', + storage, + // пишемо для відправлення даних в локальне сховище + whitelist: ['posts'], + // пишемо якщо не хочемо пропускати дані в локльне сховище + blacklist: ['filter'], +}; + +// configureStore приймає об'єкт опцій +export const store = configureStore({ + reducer: { + postDetails: persistReducer(postDetailsConfig, postDetailsReducer), + }, + middleware: getDefaultMiddleware => + getDefaultMiddleware({ + serializableCheck: { + ignoredActions: [FLUSH, REHYDRATE, PAUSE, PERSIST, PURGE, REGISTER], + }, + }), +}); + +export const persistor = persistStore(store); diff --git a/src/services/api.js b/src/services/api.js new file mode 100644 index 0000000..46c4d65 --- /dev/null +++ b/src/services/api.js @@ -0,0 +1,22 @@ +import axios from 'axios'; + +export const fetchPosts = async () => { + const { data } = await axios.get( + 'https://jsonplaceholder.typicode.com/posts/' + ); + return data; +}; + +export const findPostById = async postId => { + const { data } = await axios.get( + `https://jsonplaceholder.typicode.com/posts/${postId}` + ); + return data; +}; + +export const findPostCommentsById = async postId => { + const { data } = await axios.get( + `https://jsonplaceholder.typicode.com/posts/${postId}/comments` + ); + return data; +};