├── src
├── helpers
│ ├── Confetti
│ │ ├── index.js
│ │ └── Confetti.jsx
│ ├── EasterEgg
│ │ ├── index.js
│ │ ├── image.png
│ │ ├── EasterEgg.jsx
│ │ └── EasterEgg.module.css
│ ├── login.js
│ ├── cut-string.js
│ └── time.js
├── assets
│ ├── cities.json
│ ├── images.json
│ └── users.json
├── redux
│ ├── counter
│ │ ├── counter.init-state.js
│ │ ├── counter.selector.js
│ │ ├── counter.action.js
│ │ └── counter.reducer.js
│ ├── auth
│ │ ├── auth.selector.js
│ │ ├── auth.init-state.js
│ │ ├── auth.thunk.js
│ │ └── auth.slice.js
│ ├── posts
│ │ ├── posts.init-state.js
│ │ ├── posts.thunk.js
│ │ └── posts.slice.js
│ ├── profile
│ │ ├── profile.init-state.js
│ │ ├── profile.thunk.js
│ │ └── profile.slice.js
│ ├── users
│ │ ├── users.init-state.js
│ │ ├── users.selector.js
│ │ └── users.slice.js
│ ├── root.reducer.js
│ ├── root.init-state.js
│ ├── store.js
│ └── rtk-posts
│ │ └── rtk-posts.api.js
├── components
│ ├── NotFound
│ │ ├── 713761_big_8857bc5285.jpeg
│ │ └── NotFound.jsx
│ ├── Layout
│ │ ├── Header
│ │ │ └── Header.jsx
│ │ ├── Sidebar
│ │ │ ├── Sidebar.jsx
│ │ │ └── Navigation
│ │ │ │ └── Navigation.jsx
│ │ └── Layout.jsx
│ ├── Skeleton
│ │ ├── Skeleton.jsx
│ │ └── Skeleton.module.css
│ ├── AuthRouts
│ │ ├── PrivateRoute.jsx
│ │ └── PublicRoute.jsx
│ ├── Loader
│ │ └── Loader.jsx
│ ├── Button
│ │ └── Button.jsx
│ ├── Posts
│ │ ├── SearchPosts.jsx
│ │ ├── PostsLoader.jsx
│ │ └── PostsItem.jsx
│ ├── Modal
│ │ └── Modal.jsx
│ └── Confetti
│ │ └── Confetti.jsx
├── constants
│ └── status.constants.js
├── pages
│ ├── NotFoundPage
│ │ ├── pulp-fiction-john-travolta.gif
│ │ └── NotFoundPage.jsx
│ ├── ExercisesPage
│ │ ├── CounterPageTwo
│ │ │ ├── reducer.js
│ │ │ └── CounterPageTwo.jsx
│ │ ├── RerenderPage
│ │ │ └── RerenderPage.jsx
│ │ ├── LongRequestPage
│ │ │ └── LongRequestPage.jsx
│ │ ├── UsersPage
│ │ │ ├── UsersItem.jsx
│ │ │ └── UsersPage.jsx
│ │ ├── TimerPage
│ │ │ └── TimerPage.jsx
│ │ ├── ExercisesPage.jsx
│ │ └── CounterPage
│ │ │ └── CounterPage.jsx
│ ├── SinglePostPage
│ │ ├── CommentsPage
│ │ │ ├── CommentsPage.jsx
│ │ │ ├── CommentForm
│ │ │ │ └── CommentForm.jsx
│ │ │ └── CommentList
│ │ │ │ └── CommentList.jsx
│ │ └── SinglePostPage.jsx
│ ├── HomePage
│ │ └── HomePage.jsx
│ ├── LoginPage
│ │ └── LoginPage.jsx
│ ├── RTKPostsListPage
│ │ └── RTKPostsListPage.jsx
│ ├── PostsListPage
│ │ └── PostsListPage.jsx
│ ├── JoinPage
│ │ └── JoinPage.jsx
│ └── NewPostPage
│ │ └── NewPostPage.jsx
├── http
│ └── http.js
├── index.jsx
├── index.css
└── App.jsx
├── public
├── img.jpg
├── user.png
├── favicon.ico
├── logo192.png
├── logo512.png
├── robots.txt
├── manifest.json
├── 404.html
└── index.html
├── .prettierrc
├── .gitignore
├── .github
└── workflows
│ └── deploy.yml
├── package.json
├── .eslintrc
└── README.md
/src/helpers/Confetti/index.js:
--------------------------------------------------------------------------------
1 | export * from './Confetti';
2 |
--------------------------------------------------------------------------------
/src/assets/cities.json:
--------------------------------------------------------------------------------
1 | ["Київ", "Варшава", "Лондон", "Берлін"]
2 |
--------------------------------------------------------------------------------
/src/helpers/EasterEgg/index.js:
--------------------------------------------------------------------------------
1 | export { EasterEgg } from './EasterEgg';
2 |
--------------------------------------------------------------------------------
/src/redux/counter/counter.init-state.js:
--------------------------------------------------------------------------------
1 | export const counterInitState = 0;
2 |
--------------------------------------------------------------------------------
/public/img.jpg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/iMykhailychenko/goit-59-fs/HEAD/public/img.jpg
--------------------------------------------------------------------------------
/public/user.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/iMykhailychenko/goit-59-fs/HEAD/public/user.png
--------------------------------------------------------------------------------
/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/iMykhailychenko/goit-59-fs/HEAD/public/favicon.ico
--------------------------------------------------------------------------------
/public/logo192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/iMykhailychenko/goit-59-fs/HEAD/public/logo192.png
--------------------------------------------------------------------------------
/public/logo512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/iMykhailychenko/goit-59-fs/HEAD/public/logo512.png
--------------------------------------------------------------------------------
/public/robots.txt:
--------------------------------------------------------------------------------
1 | # https://www.robotstxt.org/robotstxt.html
2 | User-agent: *
3 | Disallow:
4 |
--------------------------------------------------------------------------------
/src/redux/counter/counter.selector.js:
--------------------------------------------------------------------------------
1 | export const selectCounter = state => state.counter;
2 |
--------------------------------------------------------------------------------
/src/helpers/EasterEgg/image.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/iMykhailychenko/goit-59-fs/HEAD/src/helpers/EasterEgg/image.png
--------------------------------------------------------------------------------
/src/helpers/login.js:
--------------------------------------------------------------------------------
1 | import { confetti } from 'components/Service/Confetti';
2 |
3 | export const login = confetti.run;
4 |
--------------------------------------------------------------------------------
/src/redux/counter/counter.action.js:
--------------------------------------------------------------------------------
1 | import { createAction } from '@reduxjs/toolkit';
2 |
3 | export const counterAction = createAction('COUNTER');
4 |
--------------------------------------------------------------------------------
/src/components/NotFound/713761_big_8857bc5285.jpeg:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/iMykhailychenko/goit-59-fs/HEAD/src/components/NotFound/713761_big_8857bc5285.jpeg
--------------------------------------------------------------------------------
/src/constants/status.constants.js:
--------------------------------------------------------------------------------
1 | export const STATUS = {
2 | idle: 'idle',
3 | loading: 'loading',
4 | success: 'success',
5 | error: 'error',
6 | };
7 |
--------------------------------------------------------------------------------
/src/redux/auth/auth.selector.js:
--------------------------------------------------------------------------------
1 | export const selectAuthStatus = (state) => state.auth.status;
2 | export const selectAuthToken = (state) => state.auth.data;
3 |
--------------------------------------------------------------------------------
/src/pages/NotFoundPage/pulp-fiction-john-travolta.gif:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/iMykhailychenko/goit-59-fs/HEAD/src/pages/NotFoundPage/pulp-fiction-john-travolta.gif
--------------------------------------------------------------------------------
/src/helpers/cut-string.js:
--------------------------------------------------------------------------------
1 | export const cutString = (string, maxLength) => {
2 | return string.length > maxLength ? string.slice(0, maxLength) + '...' : string;
3 | };
4 |
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "singleQuote": true,
3 | "trailingComma": "all",
4 | "tabWidth": 2,
5 | "printWidth": 80,
6 | "arrowParens": "avoid",
7 | "endOfLine": "auto"
8 | }
9 |
--------------------------------------------------------------------------------
/src/redux/auth/auth.init-state.js:
--------------------------------------------------------------------------------
1 | import { STATUS } from '../../constants/status.constants';
2 |
3 | export const authInitState = {
4 | status: STATUS.idle,
5 | data: null,
6 | };
7 |
--------------------------------------------------------------------------------
/src/components/Layout/Header/Header.jsx:
--------------------------------------------------------------------------------
1 | export const Header = ({ title }) => {
2 | return (
3 |
6 | );
7 | };
8 |
--------------------------------------------------------------------------------
/src/redux/posts/posts.init-state.js:
--------------------------------------------------------------------------------
1 | import { STATUS } from '../../constants/status.constants';
2 |
3 | export const postsInitState = {
4 | posts: null,
5 | status: STATUS.idle,
6 | };
7 |
--------------------------------------------------------------------------------
/src/redux/profile/profile.init-state.js:
--------------------------------------------------------------------------------
1 | import { STATUS } from '../../constants/status.constants';
2 |
3 | export const profileInitState = {
4 | status: STATUS.idle,
5 | data: null,
6 | };
7 |
--------------------------------------------------------------------------------
/src/redux/users/users.init-state.js:
--------------------------------------------------------------------------------
1 | import usersJson from '../../assets/users.json';
2 |
3 | export const userInitState = {
4 | search: '',
5 | data: usersJson,
6 | isModalOpen: false,
7 | };
8 |
--------------------------------------------------------------------------------
/src/components/Skeleton/Skeleton.jsx:
--------------------------------------------------------------------------------
1 | import classNames from 'classnames';
2 |
3 | import styles from './Skeleton.module.css';
4 |
5 | export const Skeleton = ({ className, ...props }) => {
6 | return
;
7 | };
8 |
--------------------------------------------------------------------------------
/src/pages/ExercisesPage/CounterPageTwo/reducer.js:
--------------------------------------------------------------------------------
1 | export const counterReducer = (state, { type }) => {
2 | switch (type) {
3 | case 'MINUS':
4 | return state - 1;
5 |
6 | case 'PLUS':
7 | return state + 1;
8 |
9 | default:
10 | return state;
11 | }
12 | };
13 |
--------------------------------------------------------------------------------
/src/redux/posts/posts.thunk.js:
--------------------------------------------------------------------------------
1 | import { createAsyncThunk } from '@reduxjs/toolkit';
2 |
3 | import { publicApi } from '../../http/http';
4 |
5 | export const getPostsThunk = createAsyncThunk('posts', async params => {
6 | const { data } = await publicApi.get('/posts', {
7 | params,
8 | });
9 |
10 | return data;
11 | });
12 |
--------------------------------------------------------------------------------
/src/redux/auth/auth.thunk.js:
--------------------------------------------------------------------------------
1 | import { createAsyncThunk } from '@reduxjs/toolkit';
2 |
3 | import { publicApi, token } from '../../http/http';
4 |
5 | export const authLoginThunk = createAsyncThunk('login', async (values) => {
6 | const { data } = await publicApi.post('/users/login', values);
7 | token.set(data);
8 | return data;
9 | });
10 |
--------------------------------------------------------------------------------
/src/components/AuthRouts/PrivateRoute.jsx:
--------------------------------------------------------------------------------
1 | import { useSelector } from 'react-redux';
2 | import { Navigate, Outlet } from 'react-router-dom';
3 |
4 | import { selectAuthToken } from '../../redux/auth/auth.selector';
5 |
6 | export const PrivateRoute = () => {
7 | const token = useSelector(selectAuthToken);
8 |
9 | return token ? : ;
10 | };
11 |
--------------------------------------------------------------------------------
/src/redux/counter/counter.reducer.js:
--------------------------------------------------------------------------------
1 | import { createReducer } from '@reduxjs/toolkit';
2 |
3 | import { counterAction } from './counter.action';
4 | import { counterInitState } from './counter.init-state';
5 |
6 | export const counterReducer = createReducer(counterInitState, builder => {
7 | builder.addCase(counterAction, (state, { payload }) => {
8 | return state + payload;
9 | });
10 | });
11 |
--------------------------------------------------------------------------------
/src/components/NotFound/NotFound.jsx:
--------------------------------------------------------------------------------
1 | import image from './713761_big_8857bc5285.jpeg';
2 |
3 | export const NotFound = () => {
4 | return (
5 |
6 |

7 |
Произошол отрицательний поиск. Потерь нет!
8 |
9 | );
10 | };
11 |
--------------------------------------------------------------------------------
/src/components/Skeleton/Skeleton.module.css:
--------------------------------------------------------------------------------
1 | @keyframes skeleton {
2 | 0% {
3 | background-position: 0 50%;
4 | }
5 | 50% {
6 | background-position: 100% 50%;
7 | }
8 | }
9 |
10 | .skeleton {
11 | width: 100%;
12 | min-height: 8px;
13 | border-radius: 5px;
14 | background: linear-gradient(90deg, #eee, #a9a7a7, #eee);
15 | background-size: 400% 400%;
16 | animation: skeleton 2s ease infinite;
17 | }
18 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 |
8 | # testing
9 | /coverage
10 |
11 | # production
12 | /build
13 |
14 | # misc
15 | .DS_Store
16 | .env
17 | .env.local
18 | .env.development.local
19 | .env.test.local
20 | .env.production.local
21 |
22 | npm-debug.log*
23 | yarn-debug.log*
24 | yarn-error.log*
25 |
26 | .vscode
27 | .idea
--------------------------------------------------------------------------------
/src/components/AuthRouts/PublicRoute.jsx:
--------------------------------------------------------------------------------
1 | import { useSelector } from 'react-redux';
2 | import { Navigate, Outlet, useLocation } from 'react-router-dom';
3 |
4 | import { selectAuthToken } from '../../redux/auth/auth.selector';
5 |
6 | export const PublicRoute = () => {
7 | const token = useSelector(selectAuthToken);
8 | const location = useLocation();
9 |
10 | return token ? : ;
11 | };
12 |
--------------------------------------------------------------------------------
/src/components/Layout/Sidebar/Sidebar.jsx:
--------------------------------------------------------------------------------
1 | import { Navigation } from './Navigation/Navigation';
2 |
3 | export const Sidebar = () => {
4 | return (
5 |
16 | );
17 | };
18 |
--------------------------------------------------------------------------------
/src/components/Loader/Loader.jsx:
--------------------------------------------------------------------------------
1 | export const Loader = () => {
2 | return (
3 | <>
4 |
5 |
9 |
10 | Loading...
11 |
12 |
13 | >
14 | );
15 | };
16 |
--------------------------------------------------------------------------------
/src/pages/SinglePostPage/CommentsPage/CommentsPage.jsx:
--------------------------------------------------------------------------------
1 | import { useState } from 'react';
2 |
3 | import { CommentForm } from './CommentForm/CommentForm';
4 | import { CommentList } from './CommentList/CommentList';
5 |
6 | const CommentsPage = () => {
7 | const [comments, setComments] = useState(null);
8 |
9 | return (
10 | <>
11 |
12 |
13 | >
14 | );
15 | };
16 |
17 | export default CommentsPage;
18 |
--------------------------------------------------------------------------------
/src/helpers/time.js:
--------------------------------------------------------------------------------
1 | const concat = (...args) => {
2 | return args.join(':');
3 | };
4 |
5 | export const formatTime = seconds => {
6 | const milliseconds = seconds % 1000;
7 |
8 | const secondsOfset = seconds / 1000;
9 | const secondsPassed = Math.floor(secondsOfset) % 60;
10 | const minutesPassed = Math.floor(secondsOfset / 60) % 60;
11 |
12 | return concat(
13 | String(minutesPassed).padStart(2, '0'),
14 | String(secondsPassed).padStart(2, '0'),
15 | String(milliseconds).padStart(3, '0'),
16 | );
17 | };
18 |
--------------------------------------------------------------------------------
/src/redux/root.reducer.js:
--------------------------------------------------------------------------------
1 | import { authInitState } from './auth/auth.init-state';
2 | import { counterInitState } from './counter/counter.init-state';
3 | import { postsInitState } from './posts/posts.init-state';
4 | import { profileInitState } from './profile/profile.init-state';
5 | import { userInitState } from './users/users.init-state';
6 |
7 | export const initState = {
8 | counter: counterInitState,
9 | users: userInitState,
10 | posts: postsInitState,
11 | auth: authInitState,
12 | profile: profileInitState,
13 | };
14 |
--------------------------------------------------------------------------------
/src/http/http.js:
--------------------------------------------------------------------------------
1 | import axios from 'axios';
2 |
3 | export const publicApi = axios.create({
4 | baseURL: 'https://taupe-croissant-c4162a.netlify.app/api',
5 | });
6 |
7 | export const privateApi = axios.create({
8 | baseURL: 'https://taupe-croissant-c4162a.netlify.app/api',
9 | });
10 |
11 | export const token = {
12 | set: (data) => {
13 | privateApi.defaults.headers.Authorization = `${data.token_type} ${data.access_token}`;
14 | },
15 |
16 | remove: () => {
17 | privateApi.defaults.headers.Authorization = null;
18 | },
19 | };
20 |
--------------------------------------------------------------------------------
/src/helpers/EasterEgg/EasterEgg.jsx:
--------------------------------------------------------------------------------
1 | import { useState } from 'react';
2 |
3 | import classNames from 'classnames';
4 |
5 | import css from './EasterEgg.module.css';
6 | import image from './image.png';
7 |
8 | export const EasterEgg = () => {
9 | const [isOpen, setIsOpen] = useState(true);
10 |
11 | const handleOpen = () => setIsOpen(false);
12 |
13 | return (
14 |
17 | );
18 | };
19 |
--------------------------------------------------------------------------------
/src/redux/profile/profile.thunk.js:
--------------------------------------------------------------------------------
1 | import { createAsyncThunk } from '@reduxjs/toolkit';
2 |
3 | import { privateApi, token } from '../../http/http';
4 | import { selectAuthToken } from '../auth/auth.selector';
5 |
6 | export const getProfileThunk = createAsyncThunk('profile', async (_, { getState, rejectWithValue }) => {
7 | const stateToken = selectAuthToken(getState());
8 |
9 | if (!stateToken) {
10 | return rejectWithValue();
11 | }
12 |
13 | token.set(stateToken);
14 | const { data } = await privateApi.get('/users/profile');
15 | return data;
16 | });
17 |
--------------------------------------------------------------------------------
/.github/workflows/deploy.yml:
--------------------------------------------------------------------------------
1 | name: Build and deploy to GitHub Pages
2 |
3 | on:
4 | push:
5 | branches: [ main ]
6 |
7 | jobs:
8 | build-and-deploy:
9 | runs-on: ubuntu-latest
10 | steps:
11 | - name: Checkout 🛎️
12 | uses: actions/checkout@v2.3.1
13 |
14 | - name: Install, lint, build 🔧
15 | run: |
16 | npm install
17 | npm run lint:js
18 | npm run build
19 | - name: Deploy 🚀
20 | uses: JamesIves/github-pages-deploy-action@4.1.0
21 | with:
22 | branch: gh-pages
23 | folder: build
--------------------------------------------------------------------------------
/src/pages/NotFoundPage/NotFoundPage.jsx:
--------------------------------------------------------------------------------
1 | import { Link } from 'react-router-dom';
2 |
3 | import image from './pulp-fiction-john-travolta.gif';
4 |
5 | const NotFoundPage = () => {
6 | return (
7 |
8 |

9 |
Opsss! This page doesn't exist
10 |
11 |
12 | Open home page
13 |
14 |
15 | );
16 | };
17 |
18 | export default NotFoundPage;
19 |
--------------------------------------------------------------------------------
/src/redux/root.init-state.js:
--------------------------------------------------------------------------------
1 | import { authReducer } from './auth/auth.slice';
2 | import { counterReducer } from './counter/counter.reducer';
3 | import { postsReducer } from './posts/posts.slice';
4 | import { profileReducer } from './profile/profile.slice';
5 | import { postsApi } from './rtk-posts/rtk-posts.api';
6 | import { usersReducer } from './users/users.slice';
7 |
8 | export const rootReducer = {
9 | counter: counterReducer,
10 | users: usersReducer,
11 | posts: postsReducer,
12 | auth: authReducer,
13 | profile: profileReducer,
14 |
15 | [postsApi.reducerPath]: postsApi.reducer,
16 | };
17 |
--------------------------------------------------------------------------------
/public/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "short_name": "React App",
3 | "name": "Create React App Sample",
4 | "icons": [
5 | {
6 | "src": "favicon.ico",
7 | "sizes": "64x64 32x32 24x24 16x16",
8 | "type": "image/x-icon"
9 | },
10 | {
11 | "src": "logo192.png",
12 | "type": "image/png",
13 | "sizes": "192x192"
14 | },
15 | {
16 | "src": "logo512.png",
17 | "type": "image/png",
18 | "sizes": "512x512"
19 | }
20 | ],
21 | "start_url": ".",
22 | "display": "standalone",
23 | "theme_color": "#000000",
24 | "background_color": "#ffffff"
25 | }
26 |
--------------------------------------------------------------------------------
/src/helpers/EasterEgg/EasterEgg.module.css:
--------------------------------------------------------------------------------
1 | .wrp {
2 | position: fixed;
3 | right: 0;
4 | bottom: 0;
5 | height: 20vh;
6 | width: 10vw;
7 | overflow: hidden;
8 | }
9 |
10 | @keyframes slide {
11 | 0% {
12 | transform: translateX(100%);
13 | }
14 | 100% {
15 | transform: translateX(30%) rotate(-10deg);
16 | }
17 | }
18 |
19 | .img {
20 | width: 100%;
21 | height: auto;
22 | transform: translateX(30%) rotate(-10deg);
23 | }
24 |
25 | .open .img {
26 | animation: slide 8s ease-in-out;
27 | }
28 |
29 | .close .img {
30 | transform: translateX(100%);
31 | transition: 1s ease-in-out;
32 | }
33 |
--------------------------------------------------------------------------------
/src/index.jsx:
--------------------------------------------------------------------------------
1 | import React from 'react';
2 |
3 | import ReactDOM from 'react-dom/client';
4 | import { Provider } from 'react-redux';
5 | import { PersistGate } from 'redux-persist/integration/react';
6 |
7 | import { App } from './App';
8 | import { store, persistor } from './redux/store';
9 |
10 | import 'react-toastify/dist/ReactToastify.css';
11 | import './index.css';
12 |
13 | const root = ReactDOM.createRoot(document.getElementById('root'));
14 | root.render(
15 |
16 |
17 |
18 |
19 | ,
20 | );
21 |
--------------------------------------------------------------------------------
/src/components/Button/Button.jsx:
--------------------------------------------------------------------------------
1 | import classNames from 'classnames';
2 |
3 | export const Button = ({ type = 'button', className = 'btn-primary', isLoading, children, disabled, ...props }) => {
4 | return (
5 | // eslint-disable-next-line react/button-has-type
6 |
16 | );
17 | };
18 |
--------------------------------------------------------------------------------
/src/assets/images.json:
--------------------------------------------------------------------------------
1 | [
2 | "https://images.unsplash.com/photo-1659368076528-399473cf1b48?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=4140&q=80",
3 | "https://images.unsplash.com/photo-1659574087501-92ef4aa7b2d8?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=2574&q=80",
4 | "https://images.unsplash.com/photo-1660579232151-f12f71c76cd2?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=2940&q=80",
5 | "https://images.unsplash.com/photo-1656904889109-e59d094ab030?ixlib=rb-1.2.1&ixid=MnwxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8&auto=format&fit=crop&w=2942&q=80"
6 | ]
7 |
--------------------------------------------------------------------------------
/src/redux/users/users.selector.js:
--------------------------------------------------------------------------------
1 | import { createSelector } from '@reduxjs/toolkit';
2 |
3 | export const selectIsModalOpen = state => state.users.isModalOpen;
4 | export const selectUsers = state => state.users.data;
5 | export const selectUsersSearch = state => state.users.search;
6 |
7 | export const selectFilteredUsers = createSelector(
8 | [selectUsersSearch, selectUsers],
9 |
10 | (search, users) =>
11 | users.filter(user =>
12 | user.name.toLowerCase().includes(search.toLowerCase()),
13 | ),
14 | );
15 |
16 | export const selectTotalOpenToWork = createSelector(
17 | [selectUsers],
18 | users => users.filter(user => user.isOpenToWork).length,
19 | );
20 |
--------------------------------------------------------------------------------
/src/components/Layout/Layout.jsx:
--------------------------------------------------------------------------------
1 | import { ToastContainer } from 'react-toastify';
2 |
3 | import { ConfettiContainer } from '../../helpers/Confetti';
4 |
5 | import { Sidebar } from './Sidebar/Sidebar';
6 |
7 | export const Layout = ({ children }) => {
8 | return (
9 | <>
10 |
11 |
12 |
13 |
17 | {children}
18 |
19 |
20 |
21 |
22 |
23 | >
24 | );
25 | };
26 |
--------------------------------------------------------------------------------
/src/redux/store.js:
--------------------------------------------------------------------------------
1 | import { configureStore } from '@reduxjs/toolkit';
2 | import {
3 | persistStore,
4 | FLUSH,
5 | REHYDRATE,
6 | PAUSE,
7 | PERSIST,
8 | PURGE,
9 | REGISTER,
10 | } from 'redux-persist';
11 |
12 | import { rootReducer } from './root.init-state';
13 | import { initState } from './root.reducer';
14 | import { postsApi } from './rtk-posts/rtk-posts.api';
15 |
16 | export const store = configureStore({
17 | preloadedState: initState,
18 | devTools: true,
19 | reducer: rootReducer,
20 |
21 | middleware: getDefaultMiddleware =>
22 | getDefaultMiddleware({
23 | serializableCheck: {
24 | ignoredActions: [FLUSH, REHYDRATE, PAUSE, PERSIST, PURGE, REGISTER],
25 | },
26 | }).concat([postsApi.middleware]),
27 | });
28 |
29 | export const persistor = persistStore(store);
30 |
--------------------------------------------------------------------------------
/src/redux/profile/profile.slice.js:
--------------------------------------------------------------------------------
1 | import { createSlice } from '@reduxjs/toolkit';
2 |
3 | import { STATUS } from '../../constants/status.constants';
4 |
5 | import { profileInitState } from './profile.init-state';
6 | import { getProfileThunk } from './profile.thunk';
7 |
8 | const profileSlice = createSlice({
9 | name: 'profile',
10 | initialState: profileInitState,
11 | extraReducers: builder => {
12 | builder.addCase(getProfileThunk.pending, (state) => {
13 | state.status = STATUS.loading;
14 | }).addCase(getProfileThunk.fulfilled, (state, { payload }) => {
15 | state.status = STATUS.success;
16 | state.data = payload;
17 | }).addCase(getProfileThunk.rejected, (state) => {
18 | state.status = STATUS.error;
19 | });
20 | },
21 | });
22 |
23 | export const profileReducer = profileSlice.reducer;
24 |
--------------------------------------------------------------------------------
/src/redux/posts/posts.slice.js:
--------------------------------------------------------------------------------
1 | import { createSlice } from '@reduxjs/toolkit';
2 |
3 | import { STATUS } from '../../constants/status.constants';
4 |
5 | import { postsInitState } from './posts.init-state';
6 | import { getPostsThunk } from './posts.thunk';
7 |
8 | const postsSlice = createSlice({
9 | name: 'posts',
10 | initialState: postsInitState,
11 | extraReducers: builder => {
12 | builder
13 | .addCase(getPostsThunk.pending, state => {
14 | state.status = STATUS.loading;
15 | })
16 | .addCase(getPostsThunk.fulfilled, (state, { payload }) => {
17 | state.status = STATUS.success;
18 | state.posts = payload;
19 | })
20 | .addCase(getPostsThunk.rejected, state => {
21 | state.status = STATUS.error;
22 | });
23 | },
24 | });
25 |
26 | export const postsReducer = postsSlice.reducer;
27 |
--------------------------------------------------------------------------------
/src/pages/ExercisesPage/RerenderPage/RerenderPage.jsx:
--------------------------------------------------------------------------------
1 | import { useState, useCallback, memo } from 'react';
2 |
3 | const Button = memo(({ label, onClick }) => {
4 | console.log('Button');
5 | return (
6 |
9 | );
10 | });
11 |
12 | Button.displayName = 'Button';
13 |
14 | const RerenderPage = () => {
15 | const [counter, setCounter] = useState(0);
16 |
17 | const handleCount = useCallback(() => {
18 | setCounter(prev => prev + 1);
19 | }, []);
20 |
21 | console.log('Rerender');
22 |
23 | return (
24 |
25 |
{counter}
26 |
27 |
28 |
29 | );
30 | };
31 |
32 | export default RerenderPage;
33 |
--------------------------------------------------------------------------------
/src/components/Posts/SearchPosts.jsx:
--------------------------------------------------------------------------------
1 | import { Component } from 'react';
2 |
3 | export class SearchPosts extends Component {
4 | state = {
5 | value: '',
6 | };
7 |
8 | handleChange = event => {
9 | const { value } = event.target;
10 | this.setState({ value });
11 | };
12 |
13 | handleSubmit = event => {
14 | event.preventDefault();
15 |
16 | this.props.onSearch(this.state.value);
17 | };
18 |
19 | render() {
20 | const { value } = this.state;
21 |
22 | return (
23 |
40 | );
41 | }
42 | }
43 |
--------------------------------------------------------------------------------
/src/redux/users/users.slice.js:
--------------------------------------------------------------------------------
1 | import { createSlice } from '@reduxjs/toolkit';
2 | import { persistReducer } from 'redux-persist';
3 | import storage from 'redux-persist/lib/storage';
4 |
5 | import { userInitState } from './users.init-state';
6 |
7 | const userSlice = createSlice({
8 | name: 'users',
9 | initialState: userInitState,
10 | reducers: {
11 | usersSearchAction: (state, { payload }) => {
12 | state.search = payload;
13 | },
14 |
15 | deleteUserAction: (state, { payload }) => {
16 | state.data = state.data.filter(user => user.id !== payload);
17 | },
18 |
19 | toggleModalAction: state => {
20 | state.isModalOpen = !state.isModalOpen;
21 | },
22 | },
23 | });
24 |
25 | export const { usersSearchAction, deleteUserAction, toggleModalAction } =
26 | userSlice.actions;
27 |
28 | const persistConfig = {
29 | key: 'goit',
30 | storage,
31 | whitelist: ['data'],
32 | };
33 |
34 | export const usersReducer = persistReducer(persistConfig, userSlice.reducer);
35 |
--------------------------------------------------------------------------------
/src/pages/ExercisesPage/LongRequestPage/LongRequestPage.jsx:
--------------------------------------------------------------------------------
1 | import { Component } from 'react';
2 |
3 | class LongRequestPage extends Component {
4 | state = {
5 | isError: false,
6 | isLoading: false,
7 | isDone: false,
8 | };
9 |
10 | async componentDidMount() {
11 | this.setState({ isLoading: true, isError: false });
12 |
13 | // TODO fetch http://70.34.201.18:4444/long
14 | // if ok -> this.setState({ isDone: true, isLoading: false });
15 | // if error -> this.setState({ isError: true, isLoading: false });
16 | }
17 |
18 | componentWillUnmount() {
19 | // TODO
20 | }
21 |
22 | render() {
23 | const { isError, isLoading, isDone } = this.state;
24 |
25 | return (
26 |
27 |
28 | {isDone && 'Success - request ended'}
29 | {isError && 'Error - no data'}
30 | {isLoading && 'Loading ...'}
31 |
32 |
33 |
36 |
37 | );
38 | }
39 | }
40 |
41 | export default LongRequestPage;
42 |
--------------------------------------------------------------------------------
/src/index.css:
--------------------------------------------------------------------------------
1 | @import-normalize;
2 |
3 | * {
4 | margin: 0;
5 | padding: 0;
6 | }
7 |
8 | body {
9 | min-height: 100vh;
10 | font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
11 | 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
12 | sans-serif;
13 | }
14 |
15 | a,
16 | button {
17 | display: block;
18 | background: none;
19 | border: none;
20 | }
21 |
22 | svg {
23 | display: block;
24 | height: 1em;
25 | width: 1em;
26 | }
27 |
28 | @keyframes sale {
29 | 0% {
30 | color: red;
31 | }
32 | 30% {
33 | color: green;
34 | }
35 | 70% {
36 | color: blue;
37 | }
38 | }
39 |
40 | .content {
41 | min-height: 100vh;
42 | }
43 |
44 | .card-title {
45 | overflow: hidden;
46 | white-space: nowrap;
47 | text-overflow: ellipsis;
48 | }
49 |
50 | .sidebar {
51 | position: sticky;
52 | top: 0;
53 | left: 0;
54 | }
55 |
56 | .pagination {
57 | position: fixed;
58 | bottom: 0;
59 | left: 0;
60 | width: 100%;
61 | background: #fff;
62 | border-top: 1px solid #000;
63 | }
64 |
65 | .nav-btn {
66 | margin-left: -10px;
67 | text-align: left;
68 | }
69 |
--------------------------------------------------------------------------------
/src/redux/rtk-posts/rtk-posts.api.js:
--------------------------------------------------------------------------------
1 | import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';
2 |
3 | export const postsApi = createApi({
4 | reducerPath: 'rtk-posts',
5 | tagTypes: ['Posts'],
6 | baseQuery: fetchBaseQuery({ baseUrl: 'http://70.34.201.18:4444' }),
7 |
8 | endpoints: builder => ({
9 | getPosts: builder.query({
10 | query: params => ({
11 | url: '/posts',
12 | params,
13 | }),
14 |
15 | providesTags: ({ data }) => {
16 | if (data) {
17 | return [
18 | ...data.map(({ id }) => ({ type: 'Posts', id })),
19 | { type: 'Posts', id: 'LIST' },
20 | ];
21 | }
22 |
23 | return [{ type: 'Posts', id: 'LIST' }];
24 | },
25 | }),
26 |
27 | deletePosts: builder.mutation({
28 | query: id => ({
29 | url: '/posts/' + id,
30 | method: 'DELETE',
31 | }),
32 |
33 | invalidatesTags: (result, error, id) => [{ type: 'Posts', id }],
34 | }),
35 | }),
36 | });
37 |
38 | export const {
39 | useGetPostsQuery,
40 | useLazyGetPostsQuery,
41 | useDeletePostsMutation,
42 | } = postsApi;
43 |
--------------------------------------------------------------------------------
/src/pages/ExercisesPage/CounterPageTwo/CounterPageTwo.jsx:
--------------------------------------------------------------------------------
1 | import { useReducer } from 'react';
2 |
3 | import { counterReducer } from './reducer';
4 |
5 | const CounterPageTwo = () => {
6 | const [state, dispatch] = useReducer(counterReducer, 0);
7 | return (
8 | <>
9 |
10 |
Counter
11 |
12 | {state}
13 |
14 |
15 |
16 |
23 |
24 |
31 |
32 |
33 | >
34 | );
35 | };
36 |
37 | export default CounterPageTwo;
38 |
--------------------------------------------------------------------------------
/src/redux/auth/auth.slice.js:
--------------------------------------------------------------------------------
1 | import { createSlice } from '@reduxjs/toolkit';
2 | import { persistReducer } from 'redux-persist';
3 | import storage from 'redux-persist/lib/storage';
4 |
5 | import { STATUS } from '../../constants/status.constants';
6 | import { getProfileThunk } from '../profile/profile.thunk';
7 |
8 | import { authInitState } from './auth.init-state';
9 | import { authLoginThunk } from './auth.thunk';
10 |
11 | const authSlice = createSlice({
12 | name: 'auth',
13 | initialState: authInitState,
14 | reducers: {
15 | logoutAction: () => authInitState,
16 | },
17 | extraReducers: builder => {
18 | builder.addCase(authLoginThunk.pending, state => {
19 | state.status = STATUS.loading;
20 | }).addCase(authLoginThunk.fulfilled, (state, { payload }) => {
21 | state.status = STATUS.success;
22 | state.data = payload;
23 | }).addCase(authLoginThunk.rejected, state => {
24 | state.status = STATUS.error;
25 | }).addCase(getProfileThunk.rejected, () => authInitState);
26 | },
27 | });
28 |
29 | export const { logoutAction } = authSlice.actions;
30 |
31 | export const authReducer = persistReducer({
32 | key: 'auth',
33 | storage,
34 | }, authSlice.reducer);
35 |
--------------------------------------------------------------------------------
/src/components/Posts/PostsLoader.jsx:
--------------------------------------------------------------------------------
1 | import { Skeleton } from '../Skeleton/Skeleton';
2 |
3 | export const PostsLoader = ({ amount = 9 }) => {
4 | return (
5 |
6 |
7 | {[...Array(amount)].map((_, index) => (
8 |
9 |
10 |
11 |
12 |
13 |
14 |
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 |
Delete post
24 |
Read post
25 |
26 |
27 |
28 |
29 | ))}
30 |
31 |
32 | );
33 | };
34 |
--------------------------------------------------------------------------------
/src/pages/ExercisesPage/UsersPage/UsersItem.jsx:
--------------------------------------------------------------------------------
1 | export const UsersItem = ({ user, onDelete }) => {
2 | const { id, name, email, bio, skills, isOpenToWork } = user;
3 |
4 | const handleDelete = () => onDelete(id);
5 |
6 | return (
7 |
8 |
9 |
10 | {name}
11 | {isOpenToWork && (
12 |
Open to work
13 | )}
14 |
15 |
16 |
{email}
17 |
{bio}
18 |
19 |
20 | {skills.map(skil => (
21 |
22 | {skil}
23 |
24 | ))}
25 |
26 |
27 |
28 |
35 |
36 |
37 |
38 | );
39 | };
40 |
--------------------------------------------------------------------------------
/src/pages/ExercisesPage/TimerPage/TimerPage.jsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useState, useRef } from 'react';
2 |
3 | import { Button } from '../../../components/Button/Button';
4 | import { formatTime } from '../../../helpers/time';
5 |
6 | const TimerPage = () => {
7 | const ref = useRef(null);
8 | const [time, setTime] = useState(0);
9 |
10 | const handleStartTimer = () => {
11 | if (!ref.current) {
12 | ref.current = setInterval(() => {
13 | setTime(prev => prev + 1);
14 | console.log('setInterval');
15 | }, 0);
16 | }
17 | };
18 |
19 | const handleStopTimer = () => {
20 | if (ref.current) {
21 | clearInterval(ref.current);
22 | ref.current = null;
23 | }
24 | };
25 |
26 | useEffect(() => {
27 | return () => {
28 | handleStopTimer();
29 | };
30 | }, []);
31 |
32 | return (
33 | <>
34 | {formatTime(time)}
35 |
36 |
37 |
40 |
41 |
44 |
45 | >
46 | );
47 | };
48 |
49 | export default TimerPage;
50 |
--------------------------------------------------------------------------------
/src/components/Modal/Modal.jsx:
--------------------------------------------------------------------------------
1 | import { useEffect } from 'react';
2 |
3 | export const Modal = ({ children, onClose }) => {
4 | useEffect(() => {
5 | const handleKeyClose = event => {
6 | if (event.code === 'Escape') {
7 | onClose();
8 | }
9 | };
10 |
11 | window.addEventListener('keydown', handleKeyClose);
12 |
13 | return () => {
14 | window.removeEventListener('keydown', handleKeyClose);
15 | };
16 | }, [onClose]);
17 |
18 | const handleBackdropClick = event => {
19 | if (event.target === event.currentTarget) {
20 | onClose();
21 | }
22 | };
23 |
24 | return (
25 | <>
26 |
27 |
32 |
33 |
34 |
35 |
Modal title
36 |
42 |
43 |
{children}
44 |
45 |
46 |
47 | >
48 | );
49 | };
50 |
--------------------------------------------------------------------------------
/src/pages/ExercisesPage/ExercisesPage.jsx:
--------------------------------------------------------------------------------
1 | import { Suspense } from 'react';
2 |
3 | import { NavLink, Outlet } from 'react-router-dom';
4 |
5 | const ExercisesPage = () => {
6 | return (
7 | <>
8 |
9 | -
10 |
11 | Timer
12 |
13 |
14 |
15 | -
16 |
17 | Long Request
18 |
19 |
20 |
21 | -
22 |
23 | Counter
24 |
25 |
26 |
27 | -
28 |
29 | Counter Two
30 |
31 |
32 |
33 | -
34 |
35 | Re-render
36 |
37 |
38 |
39 | -
40 |
41 | Users list
42 |
43 |
44 |
45 |
46 | Loading inside ExercisesPage...}>
47 |
48 |
49 | >
50 | );
51 | };
52 |
53 | export default ExercisesPage;
54 |
--------------------------------------------------------------------------------
/src/pages/ExercisesPage/CounterPage/CounterPage.jsx:
--------------------------------------------------------------------------------
1 | import { useState } from 'react';
2 |
3 | import { useSelector, useDispatch } from 'react-redux';
4 |
5 | import { counterAction } from '../../../redux/counter/counter.action';
6 | import { selectCounter } from '../../../redux/counter/counter.selector';
7 |
8 | const CounterPage = () => {
9 | const dispatch = useDispatch();
10 | const counter = useSelector(selectCounter);
11 |
12 | const handleMinus = () => {
13 | dispatch(counterAction(-1));
14 | };
15 |
16 | const handlePlus = () => {
17 | dispatch(counterAction(1));
18 | };
19 |
20 | const [isOpen, setIsOpen] = useState(false);
21 |
22 | return (
23 | <>
24 |
31 |
32 |
33 |
Counter
34 |
35 | {counter}
36 |
37 |
38 |
39 |
46 |
47 |
54 |
55 |
56 | >
57 | );
58 | };
59 |
60 | export default CounterPage;
61 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "goit-59-fs",
3 | "version": "0.1.0",
4 | "private": true,
5 | "homepage": "https://iMykhailychenko.github.io/goit-59-fs/",
6 | "dependencies": {
7 | "@reduxjs/toolkit": "^1.9.2",
8 | "@testing-library/jest-dom": "^5.16.5",
9 | "@testing-library/react": "^13.4.0",
10 | "@testing-library/user-event": "^13.5.0",
11 | "axios": "^1.2.2",
12 | "classnames": "^2.3.2",
13 | "date-fns": "^2.29.3",
14 | "lodash": "^4.17.21",
15 | "prop-types": "^15.8.1",
16 | "react": "^18.2.0",
17 | "react-confetti": "^6.1.0",
18 | "react-dom": "^18.2.0",
19 | "react-redux": "^8.0.5",
20 | "react-router-dom": "^6.6.2",
21 | "react-scripts": "5.0.1",
22 | "react-toastify": "^9.1.1",
23 | "redux-persist": "^6.0.0",
24 | "web-vitals": "^2.1.4"
25 | },
26 | "scripts": {
27 | "start": "react-scripts start",
28 | "build": "react-scripts build",
29 | "test": "react-scripts test",
30 | "eject": "react-scripts eject",
31 | "lint:js": "eslint src/**/*.{js,jsx}"
32 | },
33 | "eslintConfig": {
34 | "extends": [
35 | "react-app",
36 | "react-app/jest"
37 | ]
38 | },
39 | "browserslist": {
40 | "production": [
41 | ">0.2%",
42 | "not dead",
43 | "not op_mini all"
44 | ],
45 | "development": [
46 | "last 1 chrome version",
47 | "last 1 firefox version",
48 | "last 1 safari version"
49 | ]
50 | },
51 | "devDependencies": {
52 | "eslint": "^8.20.0",
53 | "eslint-config-airbnb": "^19.0.4",
54 | "eslint-plugin-import": "^2.26.0",
55 | "eslint-plugin-jsx-a11y": "^6.6.0",
56 | "eslint-plugin-react": "^7.30.1",
57 | "eslint-plugin-react-hooks": "^4.6.0",
58 | "prettier": "^2.7.1"
59 | }
60 | }
61 |
--------------------------------------------------------------------------------
/src/components/Posts/PostsItem.jsx:
--------------------------------------------------------------------------------
1 | import formatDistanceToNow from 'date-fns/formatDistanceToNow';
2 | import { Link, useLocation } from 'react-router-dom';
3 |
4 | import { cutString } from '../../helpers/cut-string';
5 |
6 | export const PostsItem = ({ post, onDelete }) => {
7 | const location = useLocation();
8 |
9 | const deletePost = () => {
10 | onDelete?.(post.id);
11 | };
12 |
13 | return (
14 |
15 |
16 |

23 |
24 |
25 |
{post.title}
26 |
27 |
{cutString(post.content, 60)}
28 |
29 |
30 | - Views: {post.views}
31 | -
32 | Created: {formatDistanceToNow(new Date(post.created_at))}
33 |
34 |
35 |
36 |
37 |
44 |
45 |
50 | Read post
51 |
52 |
53 |
54 |
55 |
56 | );
57 | };
58 |
--------------------------------------------------------------------------------
/src/pages/SinglePostPage/SinglePostPage.jsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from 'react';
2 |
3 | import axios from 'axios';
4 | import { useParams, Link, Outlet, useLocation } from 'react-router-dom';
5 | import { toast } from 'react-toastify';
6 |
7 | import { Loader } from '../../components/Loader/Loader';
8 |
9 | const SinglePostPage = () => {
10 | const { postId } = useParams();
11 | const location = useLocation();
12 | console.log(location);
13 |
14 | const [post, setPost] = useState(null);
15 | const [isLoading, setIsLoading] = useState(true);
16 |
17 | useEffect(() => {
18 | setIsLoading(true);
19 |
20 | axios
21 | .get('http://70.34.201.18:4444/posts/' + postId)
22 | .then(({ data }) => setPost(data))
23 | .catch(() => {
24 | toast.error('Something went wrong!');
25 | })
26 | .finally(() => setIsLoading(false));
27 | }, [postId]);
28 |
29 | if (isLoading) {
30 | return ;
31 | }
32 |
33 | return (
34 | post && (
35 | <>
36 |
37 | Back
38 |
39 |
40 |
46 | {post.title}
47 |
48 | '),
51 | }}
52 | />
53 |
54 |
59 | Vew post comments
60 |
61 |
62 |
63 | >
64 | )
65 | );
66 | };
67 |
68 | export default SinglePostPage;
69 |
--------------------------------------------------------------------------------
/src/pages/SinglePostPage/CommentsPage/CommentForm/CommentForm.jsx:
--------------------------------------------------------------------------------
1 | import { useState } from 'react';
2 |
3 | import axios from 'axios';
4 | import classNames from 'classnames';
5 | import { toast } from 'react-toastify';
6 |
7 | export const CommentForm = ({ setComments }) => {
8 | // TODO change to dynamic value
9 | const postId = 10;
10 |
11 | const [isLoading, setIsLoading] = useState(false);
12 | const [content, setContent] = useState('');
13 |
14 | const handleChange = event => setContent(event.target.value);
15 | const handleReset = () => setContent('');
16 |
17 | const handleSubmit = event => {
18 | event.preventDefault();
19 |
20 | if (!content.trim()) {
21 | toast.error('Fill all required fields!');
22 | return;
23 | }
24 |
25 | setIsLoading(true);
26 | axios
27 | .post(`http://70.34.201.18:4444/posts/${postId}/comments`, {
28 | content,
29 | })
30 | .then(data => {
31 | toast.success('You have successfully created a new comment!');
32 | setComments(prev => ({ ...prev, data: [data, ...prev.data] }));
33 | handleReset();
34 | })
35 | .catch(() => {
36 | toast.error('Something went wrong!');
37 | })
38 | .finally(() => setIsLoading(false));
39 | };
40 |
41 | return (
42 |
65 | );
66 | };
67 |
--------------------------------------------------------------------------------
/public/404.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Single Page Apps for GitHub Pages
6 |
43 |
44 |
45 |
46 |
--------------------------------------------------------------------------------
/src/pages/HomePage/HomePage.jsx:
--------------------------------------------------------------------------------
1 | import { useLazyGetPostsQuery } from '../../redux/rtk-posts/rtk-posts.api';
2 |
3 | const HomePage = () => {
4 | const [trigger, { isLoading, data }] = useLazyGetPostsQuery();
5 | console.log(isLoading, data);
6 |
7 | return (
8 | <>
9 |
10 |
11 |
Custom jumbotron
12 |
13 | Using a series of utilities, you can create this jumbotron, just
14 | like the one in previous versions of Bootstrap.
15 |
16 |
23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
Change the background
31 |
32 | Swap the background-color utility and add a `.text-*` color
33 | utility to mix up the jumbotron look.
34 |
35 |
38 |
39 |
40 |
41 |
42 |
43 |
Add borders
44 |
45 | Or, keep it light and add a border for some added definition to
46 | the boundaries of your content.
47 |
48 |
51 |
52 |
53 |
54 |
55 | >
56 | );
57 | };
58 |
59 | export default HomePage;
60 |
--------------------------------------------------------------------------------
/src/pages/LoginPage/LoginPage.jsx:
--------------------------------------------------------------------------------
1 | import { useState } from 'react';
2 |
3 | import { useDispatch, useSelector } from 'react-redux';
4 | import { toast } from 'react-toastify';
5 |
6 | import { STATUS } from '../../constants/status.constants';
7 | import { selectAuthStatus } from '../../redux/auth/auth.selector';
8 | import { authLoginThunk } from '../../redux/auth/auth.thunk';
9 |
10 | const initialState = {
11 | email: '',
12 | password: '',
13 | };
14 |
15 | const LoginPage = () => {
16 | const dispatch = useDispatch();
17 | const status = useSelector(selectAuthStatus);
18 |
19 | const [values, setValues] = useState(initialState);
20 |
21 | const handleChange = event => {
22 | const { value, name } = event.target;
23 | setValues(prev => ({ ...prev, [name]: value }));
24 | };
25 |
26 | const handleSubmit = async event => {
27 | event.preventDefault();
28 |
29 | try {
30 | await dispatch(authLoginThunk(values)).unwrap();
31 | toast.success('Success');
32 | } catch {
33 | toast.error('Error');
34 | }
35 | };
36 |
37 | return (
38 | <>
39 | {status === STATUS.loading && Loading ...
}
40 |
41 |
72 | >
73 | );
74 | };
75 |
76 | export default LoginPage;
77 |
--------------------------------------------------------------------------------
/src/components/Confetti/Confetti.jsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useState, useRef } from 'react';
2 |
3 | import ConfettiComponent from 'react-confetti';
4 | import ReactDOM from 'react-dom';
5 |
6 | import EventEmitter from 'events';
7 |
8 | const TIMEOUT = 4_000;
9 | const ANIMATION_DURATION = 2_000;
10 | const SCROLL_BAR_WIDTH = 20;
11 |
12 | class ConfettiEmmiter extends EventEmitter {
13 | run = () => {
14 | this.emit('confetti', true);
15 | };
16 |
17 | close = () => {
18 | this.emit('confetti', false);
19 | };
20 | }
21 |
22 | export const confetti = new ConfettiEmmiter();
23 |
24 | export const Confetti = () => {
25 | const [party, setParty] = useState(true);
26 | const [size, setSize] = useState({ y: window.innerHeight, x: window.innerWidth - SCROLL_BAR_WIDTH });
27 |
28 | useEffect(() => {
29 | const resize = () => setSize({ y: window?.innerHeight, x: window.innerWidth - SCROLL_BAR_WIDTH });
30 | window.addEventListener('resize', resize);
31 | return () => window.removeEventListener('resize', resize);
32 | }, []);
33 |
34 | useEffect(() => {
35 | const id = setTimeout(() => {
36 | setParty(false);
37 | }, ANIMATION_DURATION);
38 |
39 | return () => {
40 | clearTimeout(id);
41 | };
42 | }, []);
43 |
44 | return ReactDOM.createPortal(
45 | {
50 | setParty(false);
51 | c?.reset();
52 | }}
53 | width={size.x}
54 | height={size.y}
55 | />,
56 | document.body,
57 | );
58 | };
59 |
60 | export const ConfettiContainer = () => {
61 | const timeoutId = useRef(null);
62 | const [isOpen, setIsOpen] = useState(false);
63 |
64 | useEffect(() => {
65 | confetti.on('confetti', setIsOpen);
66 | return () => {
67 | confetti.off('confetti', setIsOpen);
68 | };
69 | }, []);
70 |
71 | useEffect(() => {
72 | if (isOpen) {
73 | timeoutId.current = setTimeout(() => {
74 | confetti.close();
75 | }, TIMEOUT);
76 | }
77 |
78 | return () => {
79 | if (timeoutId.current) {
80 | clearTimeout(timeoutId.current);
81 | }
82 | };
83 | }, [isOpen]);
84 |
85 | return isOpen && ;
86 | };
87 |
--------------------------------------------------------------------------------
/src/pages/ExercisesPage/UsersPage/UsersPage.jsx:
--------------------------------------------------------------------------------
1 | import { useDispatch, useSelector } from 'react-redux';
2 |
3 | import { NotFound } from '../../../components/NotFound/NotFound';
4 | import { confetti } from '../../../helpers/Confetti/Confetti';
5 | import {
6 | selectFilteredUsers,
7 | selectIsModalOpen,
8 | selectTotalOpenToWork,
9 | selectUsersSearch,
10 | } from '../../../redux/users/users.selector';
11 | import {
12 | deleteUserAction,
13 | toggleModalAction,
14 | usersSearchAction,
15 | } from '../../../redux/users/users.slice';
16 |
17 | import { UsersItem } from './UsersItem';
18 |
19 | const UsersPage = () => {
20 | const dispatch = useDispatch();
21 |
22 | const search = useSelector(selectUsersSearch); // '' === ''
23 | const users = useSelector(selectFilteredUsers); // [10] === [10] -> true
24 | const totalOpenToWork = useSelector(selectTotalOpenToWork); // 5 -> 5
25 | // const { users, search } = useSelector(state => state.users); // stop
26 |
27 | const handleSearch = event => {
28 | dispatch(
29 | usersSearchAction(
30 | event.target.value,
31 | ) /* -> { type: SEARCH, payload: event.target.value } */,
32 | );
33 | };
34 |
35 | const handleDelete = id => {
36 | dispatch(deleteUserAction(id));
37 | confetti.run();
38 | };
39 |
40 | const isModalOpen = useSelector(selectIsModalOpen); // true -> false
41 | const toggleModal = () => {
42 | dispatch(toggleModalAction());
43 | };
44 |
45 | return (
46 | <>
47 |
50 | {isModalOpen && My modal
}
51 |
52 |
53 |
60 |
61 |
62 | Total users: {users.length}
63 | Total Open to Work: {totalOpenToWork}
64 |
65 |
66 | {users.length ? (
67 | users.map(user => (
68 |
69 | ))
70 | ) : (
71 |
72 | )}
73 |
74 | >
75 | );
76 | };
77 |
78 | export default UsersPage;
79 |
--------------------------------------------------------------------------------
/src/components/Layout/Sidebar/Navigation/Navigation.jsx:
--------------------------------------------------------------------------------
1 | import { useDispatch, useSelector } from 'react-redux';
2 | import { NavLink, useLocation } from 'react-router-dom';
3 |
4 | import { selectAuthToken } from '../../../../redux/auth/auth.selector';
5 | import { logoutAction } from '../../../../redux/auth/auth.slice';
6 | import { Button } from '../../../Button/Button';
7 |
8 | const getActiveClassName = ({ isActive }) => {
9 | return isActive ? 'btn nav-btn btn-light active' : 'btn nav-btn btn-light';
10 | };
11 |
12 | export const Navigation = () => {
13 | const dispatch = useDispatch();
14 | const location = useLocation();
15 |
16 | const token = useSelector(selectAuthToken);
17 | const profile = useSelector(state => state.profile.data);
18 |
19 | return (
20 |
21 |
22 | {!token &&
Please log in!
}
23 |
24 | {token && profile && (
25 | <>
26 | Welcome back!
27 | {profile.first_name} {profile.last_name}
28 | {profile.email}
29 |
30 |
31 | >
32 | )}
33 |
34 |
35 | Home page
36 |
37 |
38 | Posts list
39 |
40 |
41 | {token ? (
42 | <>
43 |
44 | RTK Posts list
45 |
46 |
47 |
48 | Create new post
49 |
50 |
51 |
52 | React exercises
53 |
54 |
55 |
56 | >
57 | ) : (
58 | <>
59 |
60 | Login
61 |
62 |
63 |
64 | Join
65 |
66 | >
67 | )}
68 |
69 |
70 | );
71 | };
72 |
--------------------------------------------------------------------------------
/src/helpers/Confetti/Confetti.jsx:
--------------------------------------------------------------------------------
1 | import { useEffect, useState, useRef } from 'react';
2 |
3 | import ConfettiComponent from 'react-confetti';
4 | import ReactDOM from 'react-dom';
5 |
6 | import EventEmitter from 'events';
7 |
8 | const TIMEOUT = 4_000;
9 | const ANIMATION_DURATION = 1_000;
10 | const SCROLL_BAR_WIDTH = 20;
11 |
12 | class ConfettiEmmiter extends EventEmitter {
13 | run = () => {
14 | this.emit('confetti', true);
15 | };
16 |
17 | close = () => {
18 | this.emit('confetti', false);
19 | };
20 | }
21 |
22 | export const confetti = new ConfettiEmmiter();
23 |
24 | export const Confetti = () => {
25 | const [party, setParty] = useState(true);
26 | const [size, setSize] = useState({
27 | y: window.innerHeight,
28 | x: window.innerWidth - SCROLL_BAR_WIDTH,
29 | });
30 |
31 | useEffect(() => {
32 | const resize = () =>
33 | setSize({
34 | y: window?.innerHeight,
35 | x: window.innerWidth - SCROLL_BAR_WIDTH,
36 | });
37 | window.addEventListener('resize', resize);
38 | return () => window.removeEventListener('resize', resize);
39 | }, []);
40 |
41 | useEffect(() => {
42 | const id = setTimeout(() => {
43 | setParty(false);
44 | }, ANIMATION_DURATION);
45 |
46 | return () => {
47 | clearTimeout(id);
48 | };
49 | }, []);
50 |
51 | return ReactDOM.createPortal(
52 | {
64 | setParty(false);
65 | c?.reset();
66 | }}
67 | width={size.x}
68 | height={size.y}
69 | />,
70 | document.body,
71 | );
72 | };
73 |
74 | export const ConfettiContainer = () => {
75 | const timeoutId = useRef(null);
76 | const [isOpen, setIsOpen] = useState(false);
77 |
78 | useEffect(() => {
79 | confetti.on('confetti', setIsOpen);
80 | return () => {
81 | confetti.off('confetti', setIsOpen);
82 | };
83 | }, []);
84 |
85 | useEffect(() => {
86 | if (isOpen) {
87 | timeoutId.current = setTimeout(() => {
88 | confetti.close();
89 | }, TIMEOUT);
90 | }
91 |
92 | return () => {
93 | if (timeoutId.current) {
94 | clearTimeout(timeoutId.current);
95 | }
96 | };
97 | }, [isOpen]);
98 |
99 | return isOpen && ;
100 | };
101 |
--------------------------------------------------------------------------------
/src/assets/users.json:
--------------------------------------------------------------------------------
1 | [
2 | {
3 | "id": 1,
4 | "name": "Leanne Graham",
5 | "email": "Sincere@april.biz",
6 | "bio": "Assumenda harum mollitia neque, officiis veniam repellat sapiente delectus aspernatur",
7 | "skills": ["react", "vue"],
8 | "isOpenToWork": true
9 | },
10 | {
11 | "id": 2,
12 | "name": "Ervin Howell",
13 | "email": "Shanna@melissa.tv",
14 | "bio": "Assumenda harum mollitia neque, officiis veniam repellat sapiente delectus aspernatur",
15 | "skills": ["angular"],
16 | "isOpenToWork": true
17 | },
18 | {
19 | "id": 3,
20 | "name": "Clementine Bauch",
21 | "email": "Nathan@yesenia.net",
22 | "bio": "Assumenda harum mollitia neque, officiis veniam repellat sapiente delectus aspernatur",
23 | "skills": ["react"],
24 | "isOpenToWork": false
25 | },
26 | {
27 | "id": 4,
28 | "name": "Patricia Lebsack",
29 | "email": "Julianne.OConner@kory.org",
30 | "bio": "Assumenda harum mollitia neque, officiis veniam repellat sapiente delectus aspernatur",
31 | "skills": ["react"],
32 | "isOpenToWork": false
33 | },
34 | {
35 | "id": 5,
36 | "name": "Chelsey Dietrich",
37 | "email": "Lucio_Hettinger@annie.ca",
38 | "bio": "Assumenda harum mollitia neque, officiis veniam repellat sapiente delectus aspernatur",
39 | "skills": ["react", "angular"],
40 | "isOpenToWork": false
41 | },
42 | {
43 | "id": 6,
44 | "name": "Mrs. Dennis Schulist",
45 | "email": "Karley_Dach@jasper.info",
46 | "bio": "Assumenda harum mollitia neque, officiis veniam repellat sapiente delectus aspernatur",
47 | "skills": ["vue"],
48 | "isOpenToWork": true
49 | },
50 | {
51 | "id": 7,
52 | "name": "Kurtis Weissnat",
53 | "email": "Telly.Hoeger@billy.biz",
54 | "bio": "Assumenda harum mollitia neque, officiis veniam repellat sapiente delectus aspernatur",
55 | "skills": ["react"],
56 | "isOpenToWork": false
57 | },
58 | {
59 | "id": 8,
60 | "name": "Nicholas Runolfsdottir V",
61 | "email": "Sherwood@rosamond.me",
62 | "bio": "Assumenda harum mollitia neque, officiis veniam repellat sapiente delectus aspernatur",
63 | "skills": ["react"],
64 | "isOpenToWork": true
65 | },
66 | {
67 | "id": 9,
68 | "name": "Glenna Reichert",
69 | "email": "Chaim_McDermott@dana.io",
70 | "bio": "Assumenda harum mollitia neque, officiis veniam repellat sapiente delectus aspernatur",
71 | "skills": ["react"],
72 | "isOpenToWork": false
73 | },
74 | {
75 | "id": 10,
76 | "name": "Clementina DuBuque",
77 | "email": "Rey.Padberg@karina.biz",
78 | "bio": "Assumenda harum mollitia neque, officiis veniam repellat sapiente delectus aspernatur",
79 | "skills": ["angular"],
80 | "isOpenToWork": true
81 | }
82 | ]
83 |
--------------------------------------------------------------------------------
/src/pages/RTKPostsListPage/RTKPostsListPage.jsx:
--------------------------------------------------------------------------------
1 | import { useMemo, useState } from 'react';
2 |
3 | import debounce from 'lodash/debounce';
4 | import { useSearchParams } from 'react-router-dom';
5 |
6 | import { NotFound } from '../../components/NotFound/NotFound';
7 | import { PostsItem } from '../../components/Posts/PostsItem';
8 | import { PostsLoader } from '../../components/Posts/PostsLoader';
9 | import { useGetPostsQuery, useDeletePostsMutation } from '../../redux/rtk-posts/rtk-posts.api';
10 |
11 | const RTKPostsListPage = () => {
12 | const [searchParams, setSearchParams] = useSearchParams();
13 | const page = searchParams.get('page') ?? 1;
14 | const searchQuery = searchParams.get('search') ?? '';
15 |
16 | const { data, isLoading, isError, isSuccess } = useGetPostsQuery({
17 | page,
18 | search: searchQuery,
19 | });
20 | const [trigger] = useDeletePostsMutation();
21 |
22 | const [search, setSearch] = useState(searchQuery);
23 |
24 | const searchPosts = useMemo(() => {
25 | return debounce(search => {
26 | setSearchParams({ page: 1, search });
27 | }, 500);
28 | }, [setSearchParams]);
29 |
30 | const handleSearch = event => {
31 | setSearch(event.target.value);
32 | searchPosts(event.target.value);
33 | };
34 |
35 | return (
36 | <>
37 |
44 |
45 | {isLoading && }
46 |
47 | {isError && }
48 |
49 | {isSuccess && (
50 |
51 |
52 | {data?.data.map(post => (
53 |
54 | ))}
55 |
56 |
57 | )}
58 |
59 | {data?.total_pages && (
60 |
61 |
62 | {[...Array(data.total_pages)].map((_, index) => {
63 | const innerPage = index + 1;
64 |
65 | return (
66 |
77 | );
78 | })}
79 |
80 |
81 | )}
82 | >
83 | );
84 | };
85 |
86 | export default RTKPostsListPage;
87 |
--------------------------------------------------------------------------------
/src/pages/SinglePostPage/CommentsPage/CommentList/CommentList.jsx:
--------------------------------------------------------------------------------
1 | import { useCallback, useEffect, useState } from 'react';
2 |
3 | import axios from 'axios';
4 | import { formatDistance } from 'date-fns';
5 | import { useParams } from 'react-router-dom';
6 | import { toast } from 'react-toastify';
7 |
8 | export const CommentList = ({ comments, setComments }) => {
9 | // TODO change to dynamic value
10 | const { postId } = useParams();
11 |
12 | const [isLoading, setIsLoading] = useState(true);
13 |
14 | const fetchComments = useCallback(() => {
15 | return axios
16 | .get(`http://70.34.201.18:4444/posts/${postId}/comments`)
17 | .then(setComments)
18 | .catch(() => {
19 | toast.error('Something went wrong!');
20 | });
21 | }, [postId, setComments]);
22 |
23 | useEffect(() => {
24 | setIsLoading(true);
25 | fetchComments().finally(() => setIsLoading(false));
26 | }, [fetchComments]);
27 |
28 | const handleDeleteComment = commentId => {
29 | axios
30 | .delete(`http://70.34.201.18:8000/comments/${commentId}`)
31 | .then(() => {
32 | setComments(prev => ({
33 | ...prev,
34 | data: prev.data.filter(item => item.id !== commentId),
35 | }));
36 | toast.success('You have successfully deleted your comment!');
37 | })
38 | .catch(() => {
39 | toast.error('Something went wrong!');
40 | });
41 | };
42 |
43 | if (isLoading) {
44 | return (
45 |
46 | Loading...
47 |
48 | );
49 | }
50 |
51 | if (!comments?.data?.length) {
52 | return No comments yet!
;
53 | }
54 |
55 | return (
56 | <>
57 |
58 | {comments.data.map(comment => (
59 | -
63 |
64 |
65 | {formatDistance(new Date(comment.created_at), new Date(), {
66 | addSuffix: true,
67 | })}
68 |
69 |
70 |
71 | '),
75 | }}
76 | />
77 |
78 |
79 |
86 |
89 |
90 |
91 | ))}
92 |
93 | >
94 | );
95 | };
96 |
--------------------------------------------------------------------------------
/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "env": {
3 | "browser": true,
4 | "es2021": true
5 | },
6 | "extends": [
7 | "airbnb",
8 | "react-app",
9 | "plugin:react/recommended",
10 | "plugin:import/recommended"
11 | ],
12 | "parserOptions": {
13 | "ecmaFeatures": {
14 | "jsx": true
15 | },
16 | "ecmaVersion": "latest",
17 | "sourceType": "module"
18 | },
19 | "plugins": ["react", "import"],
20 | "rules": {
21 | "linebreak-style": "off",
22 | "max-len": "off",
23 | "react/no-unused-state": "off",
24 | "prefer-template": "off",
25 | "no-alert": "off",
26 | "no-console": "off",
27 | "no-shadow": "off",
28 | "react/jsx-fragments": "off",
29 | "function-paren-newline": "off",
30 | "implicit-arrow-linebreak": "off",
31 | "jsx-a11y/control-has-associated-label": "off",
32 | "react/destructuring-assignment": "off",
33 | "class-methods-use-this": "off",
34 | "react/static-property-placement": "off",
35 | "jsx-a11y/anchor-is-valid": "off",
36 | "react/require-default-props": "off",
37 | "arrow-parens": "off",
38 | "arrow-body-style": "off",
39 | "react/jsx-props-no-spreading": "off",
40 | "react/prop-types": "off",
41 | "react/no-array-index-key": "off",
42 | "react/jsx-wrap-multilines": "off",
43 | "no-param-reassign": "off",
44 | "max-classes-per-file": "off",
45 | "operator-linebreak": "off",
46 | "no-restricted-exports": "off",
47 | "object-curly-newline": "off",
48 | "react/jsx-no-useless-fragment": "off",
49 | "react/function-component-definition": "off",
50 | "react/state-in-constructor": "off",
51 | "react/prefer-stateless-function": "off",
52 | "import/prefer-default-export": "off",
53 | "react/react-in-jsx-scope": "off",
54 | "react/jsx-no-constructed-context-values": "off",
55 | "react/jsx-one-expression-per-line": "off",
56 | "jsx-a11y/label-has-associated-control": "off",
57 | "import/extensions": "off",
58 | "no-nested-ternary": "off",
59 | "default-param-last": "off",
60 | "react/no-danger": "off",
61 | "consistent-return": "off",
62 | "import/no-unresolved": "off",
63 | "no-return-await": "off",
64 | "no-confusing-arrow": "off",
65 | "jsx-a11y/no-static-element-interactions": "off",
66 | "jsx-a11y/click-events-have-key-events": "off",
67 | "react/no-unknown-property": "off",
68 | "camelcase": "off",
69 | "jsx-a11y/no-distracting-elements": "off",
70 | "react/no-children-prop": "off",
71 | "import/first": "warn",
72 | "import/order": [
73 | "warn",
74 | {
75 | "alphabetize": { "order": "asc", "caseInsensitive": false },
76 | "groups": ["external", "builtin", "parent", "sibling", "index"],
77 | "pathGroups": [
78 | {
79 | "pattern": "react",
80 | "group": "external",
81 | "position": "before",
82 | "newlines-between": "never"
83 | }
84 | ],
85 | "pathGroupsExcludedImportTypes": ["builtin"],
86 | "newlines-between": "always"
87 | }
88 | ]
89 | }
90 | }
91 |
--------------------------------------------------------------------------------
/src/pages/PostsListPage/PostsListPage.jsx:
--------------------------------------------------------------------------------
1 | import { useMemo, useEffect, useState } from 'react';
2 |
3 | import debounce from 'lodash/debounce';
4 | import { useDispatch, useSelector } from 'react-redux';
5 | import { useSearchParams } from 'react-router-dom';
6 |
7 | import { NotFound } from '../../components/NotFound/NotFound';
8 | import { PostsItem } from '../../components/Posts/PostsItem';
9 | import { PostsLoader } from '../../components/Posts/PostsLoader';
10 | import { STATUS } from '../../constants/status.constants';
11 | import { getPostsThunk } from '../../redux/posts/posts.thunk';
12 |
13 | const PostsListPage = () => {
14 | const dispatch = useDispatch();
15 | const status = useSelector(state => state.posts.status);
16 | const posts = useSelector(state => state.posts.posts);
17 |
18 | const [searchParams, setSearchParams] = useSearchParams();
19 | const page = searchParams.get('page') ?? 1;
20 | const searchQuery = searchParams.get('search') ?? '';
21 |
22 | const [search, setSearch] = useState(searchQuery);
23 |
24 | const searchPosts = useMemo(() => {
25 | return debounce(search => {
26 | setSearchParams({ page: 1, search }); // localhost.../?page=1&search=javascript
27 | }, 500);
28 | }, [setSearchParams]);
29 |
30 | const handleSearch = event => {
31 | setSearch(event.target.value);
32 | searchPosts(event.target.value);
33 | };
34 |
35 | useEffect(() => {
36 | dispatch(getPostsThunk({ page, search: searchQuery }));
37 | }, [dispatch, page, searchQuery]);
38 |
39 | return (
40 | <>
41 |
48 |
49 | {(status === STATUS.loading || status === STATUS.idle) && }
50 |
51 | {status === STATUS.error && }
52 |
53 |
54 |
55 | {posts?.data.map(post => (
56 |
57 | ))}
58 |
59 |
60 |
61 | {posts?.total_pages && (
62 |
63 |
64 | {[...Array(posts.total_pages)].map((_, index) => {
65 | const innerPage = index + 1;
66 |
67 | return (
68 |
80 | );
81 | })}
82 |
83 |
84 | )}
85 | >
86 | );
87 | };
88 |
89 | export default PostsListPage;
90 |
--------------------------------------------------------------------------------
/src/pages/JoinPage/JoinPage.jsx:
--------------------------------------------------------------------------------
1 | import { useState } from 'react';
2 |
3 | import { useDispatch } from 'react-redux';
4 | import { Link } from 'react-router-dom';
5 | import { toast } from 'react-toastify';
6 |
7 | import { publicApi } from '../../http/http';
8 | import { authLoginThunk } from '../../redux/auth/auth.thunk';
9 |
10 | const year = new Date().getFullYear();
11 | const initialState = {
12 | email: '',
13 | first_name: '',
14 | last_name: '',
15 | password: '',
16 | };
17 |
18 | const JoinPage = () => {
19 | const dispatch = useDispatch();
20 | const [isLoading, setIsLoading] = useState(false);
21 | const [values, setValues] = useState(initialState);
22 |
23 | const [isPass, setIsPass] = useState(true);
24 |
25 | const handleChange = event => {
26 | const { value, name } = event.target;
27 | setValues(prev => ({ ...prev, [name]: value }));
28 | };
29 |
30 | const handleSubmit = async (event) => {
31 | event.preventDefault();
32 |
33 | try {
34 | setIsLoading(true);
35 | await publicApi.post('/users/create', values);
36 | await dispatch(authLoginThunk({ email: values.email, password: values.password })).unwrap();
37 |
38 | setIsLoading(false);
39 | toast.success('Success!');
40 | } catch (e) {
41 | console.log(e);
42 | toast.error('Some error');
43 | }
44 | };
45 |
46 | return (
47 | <>
48 | {isLoading && Loading ...
}
49 |
50 |
118 | >
119 | );
120 | };
121 |
122 | export default JoinPage;
123 |
--------------------------------------------------------------------------------
/src/App.jsx:
--------------------------------------------------------------------------------
1 | import { lazy, Suspense, useEffect } from 'react';
2 |
3 | import { useDispatch, useSelector } from 'react-redux';
4 | import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
5 |
6 | import { PrivateRoute } from './components/AuthRouts/PrivateRoute';
7 | import { PublicRoute } from './components/AuthRouts/PublicRoute';
8 | import { Layout } from './components/Layout/Layout';
9 | import { selectAuthToken } from './redux/auth/auth.selector';
10 | import { getProfileThunk } from './redux/profile/profile.thunk';
11 |
12 | const NewPostPage = lazy(() => import('./pages/NewPostPage/NewPostPage'));
13 | const NotFoundPage = lazy(() => import('./pages/NotFoundPage/NotFoundPage'));
14 | const CommentsPage = lazy(() =>
15 | import('./pages/SinglePostPage/CommentsPage/CommentsPage'),
16 | );
17 | const SinglePostPage = lazy(() =>
18 | import('./pages/SinglePostPage/SinglePostPage'),
19 | );
20 | const HomePage = lazy(() => import('./pages/HomePage/HomePage'));
21 | const ExercisesPage = lazy(() => import('./pages/ExercisesPage/ExercisesPage'));
22 | const LongRequestPage = lazy(() =>
23 | import('./pages/ExercisesPage/LongRequestPage/LongRequestPage'),
24 | );
25 | const RerenderPage = lazy(() =>
26 | import('./pages/ExercisesPage/RerenderPage/RerenderPage'),
27 | );
28 | const TimerPage = lazy(() =>
29 | import('./pages/ExercisesPage/TimerPage/TimerPage'),
30 | );
31 | const CounterPage = lazy(() =>
32 | import('./pages/ExercisesPage/CounterPage/CounterPage'),
33 | );
34 | const CounterPageTwo = lazy(() =>
35 | import('./pages/ExercisesPage/CounterPageTwo/CounterPageTwo'),
36 | );
37 | const UsersPage = lazy(() =>
38 | import('./pages/ExercisesPage/UsersPage/UsersPage'),
39 | );
40 | const PostsListPage = lazy(() => import('./pages/PostsListPage/PostsListPage'));
41 | const RTKPostsListPage = lazy(() =>
42 | import('./pages/RTKPostsListPage/RTKPostsListPage'),
43 | );
44 | const LoginPage = lazy(() => import('./pages/LoginPage/LoginPage'));
45 | const JoinPage = lazy(() => import('./pages/JoinPage/JoinPage'));
46 |
47 | export const App = () => {
48 | const dispatch = useDispatch();
49 | const token = useSelector(selectAuthToken);
50 |
51 | useEffect(() => {
52 | dispatch(getProfileThunk());
53 | }, [token, dispatch]);
54 |
55 | return (
56 |
57 |
58 | Loading...}>
59 |
60 | } />
61 |
62 | }>
63 | } />
64 | } />
65 |
66 |
67 | } />
68 |
69 | }>
70 | } />
71 |
72 | }>
73 | } />
74 |
75 |
76 | } />
77 | } />
78 |
79 | }>
80 | } />
81 | } />
82 | } />
83 | } />
84 | } />
85 | } />
86 | } />
87 |
88 |
89 |
90 | } />
91 |
92 |
93 |
94 |
95 | );
96 | };
97 |
--------------------------------------------------------------------------------
/src/pages/NewPostPage/NewPostPage.jsx:
--------------------------------------------------------------------------------
1 | import { useState } from 'react';
2 |
3 | import axios from 'axios';
4 | import { toast } from 'react-toastify';
5 |
6 | import { Loader } from '../../components/Loader/Loader';
7 |
8 | const initialState = {
9 | title: '',
10 | content: '',
11 | image: '',
12 | preview_image: '',
13 | };
14 |
15 | const NewPostPage = () => {
16 | const [isLoading, setIsLoading] = useState(false);
17 | const [form, setForm] = useState(initialState);
18 |
19 | const handleChange = event => {
20 | const { name, value } = event.target;
21 | setForm(prev => ({ ...prev, [name]: value }));
22 | };
23 |
24 | const handleReset = () => setForm(initialState);
25 |
26 | const handleSubmit = event => {
27 | event.preventDefault();
28 |
29 | const isEmpty = Object.values(form).some(item => !item);
30 | if (isEmpty) {
31 | toast.error('Fill all required fields!');
32 | return;
33 | }
34 |
35 | setIsLoading(true);
36 |
37 | axios
38 | .post('http://70.34.201.18:4444/posts', form)
39 | .then(() => {
40 | toast.success('You have successfully created a new post!');
41 | })
42 | .catch(() => {
43 | toast.error('Something went wrong!');
44 | })
45 | .finally(() => setIsLoading(false));
46 | };
47 |
48 | return (
49 | <>
50 | {isLoading && }
51 |
52 |
141 | >
142 | );
143 | };
144 |
145 | export default NewPostPage;
146 |
--------------------------------------------------------------------------------
/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
12 |
13 |
17 |
18 |
19 |
25 |
26 |
35 | React App
36 |
37 |
38 |
66 |
67 |
68 |
69 |
70 |
71 |
72 |
118 |
119 |
120 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # GOIT 59-FS REACT
2 |
3 | ## Lesson 1 - 13/12/2022
4 |
5 | - Навіщо потрібні JS-фреймворки?
6 | - Веб-додатки, порівняння MPA та SPA
7 | - Концепція Virtual DOM
8 | - create-react-app
9 | - Пакети react та react-dom
10 | - React-елементи
11 | - JSX як шаблонізатор. Вирази та рендер за умовою.
12 | - Компоненти-функції
13 | - Передача даних через Props
14 | - Дефолтні значення пропсів у компонентах-функціях
15 | - Інструменти розробника - React DevTools
16 | - Основи композиції компонентів
17 | - Пакет prop-types, властивість propTypes
18 | - Робота з колекціями, ключі
19 | - React strict mode
20 | - Аліаси та абсолютні імпорти
21 |
22 | ## Lesson 2 - 15/12/2022
23 |
24 | - Ванільний CSS
25 | - Інлайн стилі
26 | - CSS-модулі
27 | - Нормалізація стилів
28 | - CSS in JS із бібліотекою styled-components
29 | - Бібліотеки clsx / classNames
30 | - Бібліотека react-icons
31 |
32 | ## Lesson 3 - 20/12/2022
33 |
34 | - Компоненти-класи
35 | - Події: SyntheticEvent Object
36 | - Внутрішній стан компонента. Початковий стан від props
37 | - Зміна стану
38 | - Асинхронність оновлення стану
39 | - Зміна стану від попереднього
40 | - Підйом стану
41 | - Обчислювані властивості
42 |
43 | ## Lesson 4 - 22/12/2022
44 |
45 | - Неконтрольовані елементи
46 | - Паттерн controlled element
47 | - Створюємо форму реєстрації
48 | - Форми та робота з input, checkbox, radio, select
49 | - Генерація id для елементів форми
50 | - Робота зі списком юзерів
51 | - Рендер списку
52 | - Видалення юзера
53 | - фільтрація за вмістом (текст)
54 |
55 | ## Lesson 5 - 27/12/2022
56 |
57 | Графік:
58 | https://projects.wojtekmaj.pl/react-lifecycle-methods-diagram/
59 |
60 | - Життєвий цикл компонента.
61 | - Методи життєвого циклу компонент-класів.
62 | - Збереження колекції юзерів в localStorage (componentDidMount та componentDidUpdate)
63 | - Модальне вікно (componentDidMount та componentWillUnmount)
64 | - Таймер і memory leak з setState() - розібрати componentWillUnmount
65 | - shouldComponentUpdate / PureComponent
66 | - Портали (опціонально)
67 |
68 | ## Lesson 6 - 29/12/2022
69 |
70 | https://github.com/iMykhailychenko/goit-blog-backend
71 |
72 | - Методи життєвого циклу та HTTP-запити
73 | - Стан та компонент для індикатора завантаження
74 | - Стан та компонент для обробки помилки
75 | - Паттерн "State machine" для зберігання статусу запиту
76 | - Витік пам'яті при розмонтуванні компонента з активним запитом HTTP
77 |
78 | ## Lesson 7 - 10/01/2023
79 |
80 | - useState
81 | - useEffect (модалка)
82 | - Рефи і useRef
83 | - Контекст і useContext
84 |
85 | ## Lesson 8 - 12/01/2023
86 |
87 | - список юзерів + localStorage
88 | - useMemo і React.memo
89 | - useCallback
90 | - debounce пошукового запиту
91 | - Кастомні хуки
92 | - useReducer
93 | - Бібліотека react-use
94 |
95 | ## Lesson 9 - 17/01/2023
96 |
97 | https://miro.com/app/board/uXjVPxI6iaM=/?share_link_id=481867053248
98 |
99 | - Концепція SPA (Single Page Application) та CSR (Client Side Rendering)
100 | - Структура url-адреси та HTML5 History API https://textbook.edu.goit.global/react-zr7b4k/v1/uk/img/lesson-09/url-string.jpg
101 | - Бібліотека react-router-dom https://reactrouter.com/en/main
102 | - Компоненти BrowserRouter, Routes, Route
103 | - Компоненти Link та NavLink
104 | - Динамічні URL-параметри / useParams
105 | - Вкладені маршрути та навігація
106 |
107 | ## Lesson 10 - 19/01/2023
108 |
109 | - Програмна навігація з використанням useNavigate
110 | - Компонент Navigate
111 | - query params / useSearchParams
112 | - Об'єкт місцезнаходження / useLocation / state
113 | - Розділення коду / React.lazy / React.Suspense та fallback
114 |
115 | ## Lesson 11 - 31/01/2023
116 |
117 | - Основні концепції: store, state, actions, reducers
118 | - Створюємо та налаштовуємо сховище
119 | - Пакет react-redux
120 | - Компонент Provider
121 | - Пишемо редюсер
122 | - Готуємо екшени
123 | - Хуки useDispatch і useSelector
124 | - Redux DevTools
125 | - Композиція редюсерів з combineReducers
126 | - Feature based структура файлів та папок
127 |
128 | ## Lesson 12 - 4/11/2023
129 |
130 | - Розбираємо Redux Toolkit та рефакторимо код попереднього заняття
131 | - configureStore()
132 | - createReducer()
133 | - createAction()
134 | - createSlice()
135 | - Бібліотека redux-persist
136 |
137 | ## Lesson 13 7/02/2023
138 |
139 | - Middleware
140 | - Асинхронні дії (операції)
141 | - Бібліотека redux-thunk
142 | - HTTP-запити
143 | - createAsyncThunk
144 |
145 | ## Lesson 14 09/02/2023
146 |
147 | - Селектори
148 | - Функція createSelector()
149 | - RTK Query
150 | - query
151 | - mutations
152 | - кешування
153 |
154 |
155 | ## Lesson 15 - 14/02/2023
156 |
157 | https://github.com/iMykhailychenko/goit-blog-backend-auth
158 |
159 | - Знайомство з JWT
160 | - Реєстрація, логін та логаут користувача
161 | - Персист токена з redux-persist
162 | - Рефреш користувача за токеном
163 | - Робота з приватними ресурсами користувача
164 |
165 |
166 | ## Lesson 16 - 16/02/2023
167 |
168 | - Приватні та публічні маршрути
169 | - Пишемо компоненти PrivateRoute, PublicRoute
170 | - Просунуті редіректи з використанням location.state
171 |
--------------------------------------------------------------------------------