diff --git a/package-lock.json b/package-lock.json
index 49b8a8e..60709c3 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -8,9 +8,11 @@
"name": "rs-react-app",
"version": "0.0.0",
"dependencies": {
+ "@reduxjs/toolkit": "^2.8.2",
"@types/react-router-dom": "^5.3.3",
"react": "^19.1.0",
"react-dom": "^19.1.0",
+ "react-redux": "^9.2.0",
"react-router": "^7.7.1",
"react-router-dom": "^7.7.1"
},
@@ -1325,6 +1327,31 @@
"url": "https://opencollective.com/pkgr"
}
},
+ "node_modules/@reduxjs/toolkit": {
+ "version": "2.8.2",
+ "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.8.2.tgz",
+ "integrity": "sha512-MYlOhQ0sLdw4ud48FoC5w0dH9VfWQjtCjreKwYTT3l+r427qYC5Y8PihNutepr8XrNaBUDQo9khWUwQxZaqt5A==",
+ "dependencies": {
+ "@standard-schema/spec": "^1.0.0",
+ "@standard-schema/utils": "^0.3.0",
+ "immer": "^10.0.3",
+ "redux": "^5.0.1",
+ "redux-thunk": "^3.1.0",
+ "reselect": "^5.1.0"
+ },
+ "peerDependencies": {
+ "react": "^16.9.0 || ^17.0.0 || ^18 || ^19",
+ "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0"
+ },
+ "peerDependenciesMeta": {
+ "react": {
+ "optional": true
+ },
+ "react-redux": {
+ "optional": true
+ }
+ }
+ },
"node_modules/@rolldown/pluginutils": {
"version": "1.0.0-beta.19",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.19.tgz",
@@ -1591,6 +1618,16 @@
"win32"
]
},
+ "node_modules/@standard-schema/spec": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.0.0.tgz",
+ "integrity": "sha512-m2bOd0f2RT9k8QJx1JN85cZYyH1RqFBdlwtkSlf4tBDYLCiiZnv1fIIwacK6cqwXavOydf0NPToMQgpKq+dVlA=="
+ },
+ "node_modules/@standard-schema/utils": {
+ "version": "0.3.0",
+ "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz",
+ "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g=="
+ },
"node_modules/@testing-library/dom": {
"version": "10.4.0",
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.0.tgz",
@@ -1816,6 +1853,11 @@
"@testing-library/user-event": "*"
}
},
+ "node_modules/@types/use-sync-external-store": {
+ "version": "0.0.6",
+ "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz",
+ "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg=="
+ },
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.36.0",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.36.0.tgz",
@@ -4039,6 +4081,15 @@
"node": ">= 4"
}
},
+ "node_modules/immer": {
+ "version": "10.1.1",
+ "resolved": "https://registry.npmjs.org/immer/-/immer-10.1.1.tgz",
+ "integrity": "sha512-s2MPrmjovJcoMaHtx6K11Ra7oD05NT97w1IC5zpMkT6Atjr7H8LjaDd81iIxUYpMKSRRNMJE703M1Fhr/TctHw==",
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/immer"
+ }
+ },
"node_modules/import-fresh": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
@@ -5359,6 +5410,28 @@
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"dev": true
},
+ "node_modules/react-redux": {
+ "version": "9.2.0",
+ "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz",
+ "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==",
+ "dependencies": {
+ "@types/use-sync-external-store": "^0.0.6",
+ "use-sync-external-store": "^1.4.0"
+ },
+ "peerDependencies": {
+ "@types/react": "^18.2.25 || ^19",
+ "react": "^18.0 || ^19",
+ "redux": "^5.0.0"
+ },
+ "peerDependenciesMeta": {
+ "@types/react": {
+ "optional": true
+ },
+ "redux": {
+ "optional": true
+ }
+ }
+ },
"node_modules/react-refresh": {
"version": "0.17.0",
"resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz",
@@ -5417,6 +5490,19 @@
"node": ">=8"
}
},
+ "node_modules/redux": {
+ "version": "5.0.1",
+ "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
+ "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w=="
+ },
+ "node_modules/redux-thunk": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz",
+ "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==",
+ "peerDependencies": {
+ "redux": "^5.0.0"
+ }
+ },
"node_modules/reflect.getprototypeof": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz",
@@ -5459,6 +5545,11 @@
"url": "https://github.com/sponsors/ljharb"
}
},
+ "node_modules/reselect": {
+ "version": "5.1.1",
+ "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz",
+ "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w=="
+ },
"node_modules/resolve": {
"version": "2.0.0-next.5",
"resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.5.tgz",
@@ -6476,6 +6567,14 @@
"punycode": "^2.1.0"
}
},
+ "node_modules/use-sync-external-store": {
+ "version": "1.5.0",
+ "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.5.0.tgz",
+ "integrity": "sha512-Rb46I4cGGVBmjamjphe8L/UnvJD+uPPtTkNvX5mZgqdbavhI4EbgIWJiIHXJ8bc/i9EQGPRh4DwEURJ552Do0A==",
+ "peerDependencies": {
+ "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
+ }
+ },
"node_modules/vite": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.0.6.tgz",
diff --git a/package.json b/package.json
index 4f8c9a3..ba9326a 100644
--- a/package.json
+++ b/package.json
@@ -14,9 +14,11 @@
"coverage": "vitest run --coverage"
},
"dependencies": {
+ "@reduxjs/toolkit": "^2.8.2",
"@types/react-router-dom": "^5.3.3",
"react": "^19.1.0",
"react-dom": "^19.1.0",
+ "react-redux": "^9.2.0",
"react-router": "^7.7.1",
"react-router-dom": "^7.7.1"
},
diff --git a/src/About.tsx b/src/About.tsx
index ccc1750..65c3a8d 100644
--- a/src/About.tsx
+++ b/src/About.tsx
@@ -1,9 +1,7 @@
import React from "react";
-import Header from "./Header";
function About(): React.ReactElement {
return (
-
Star Wars Planets App
The author of the application is Pavel Kozin
diff --git a/src/App.css b/src/App.css
index 11ea586..a936122 100644
--- a/src/App.css
+++ b/src/App.css
@@ -1,3 +1,11 @@
+body {
+ background-color: white;
+}
+
+body.dark {
+ background-color: #2a2a2a;
+}
+
.app-container {
max-width: 1200px;
margin: 0 auto;
@@ -6,6 +14,14 @@
padding-right: 100px;
}
+.light {
+ background-color: white;
+}
+
+.dark {
+ background-color: #2a2a2a;
+}
+
.title {
text-align: center;
color: #000000;
@@ -13,6 +29,10 @@
font-size: 2.5rem;
}
+.title.dark {
+ color: #ffffff;
+}
+
.search-container {
display: flex;
align-items: center;
@@ -23,7 +43,7 @@
margin-bottom: 30px;
}
-input {
+.main-input {
flex-grow: 1;
padding: 12px 15px;
border: 2px solid #444;
@@ -32,6 +52,9 @@ input {
color: white;
font-size: 1rem;
}
+.search-container.dark {
+ background-color: #0e0e0e;
+}
.search-container button {
padding: 12px 25px;
@@ -68,11 +91,39 @@ input {
gap: 10px;
}
+.planet-checkbox {
+ width: 20px;
+ height: 20px;
+
+ appearance: none;
+ outline: none;
+ cursor: pointer;
+
+ border: 2px solid #000;
+ background-color: #fff;
+ border-radius: 4px;
+
+ position: relative;
+ transition: all 0.2s ease;
+}
+
+.planet-checkbox:checked {
+ background-color: #ffd700;
+ border-color: #000;
+}
+
+.planet-checkbox:hover {
+ box-shadow: 0 0 0 2px rgba(255, 215, 0, 0.3);
+}
+
.planet-card {
+ display: flex;
+ justify-content: space-between;
background-color: #333;
border-radius: 8px;
padding: 10px 20px 10px 20px;
transition: transform 0.3s;
+ cursor: pointer;
}
.planet-details {
@@ -199,8 +250,8 @@ input {
transform: rotate(360deg);
}
}
+
.error-test-button {
- margin: 20px auto;
padding: 10px 20px;
background-color: #ffe81f;
color: rgb(0, 0, 0);
diff --git a/src/App.tsx b/src/App.tsx
index bef00bd..def1f3d 100644
--- a/src/App.tsx
+++ b/src/App.tsx
@@ -5,21 +5,31 @@ import About from "./About";
import ErrorBoundary from "./ErrorBoundary";
import { BrowserRouter } from "react-router";
import PlanetCard from "./PlanetCard";
+import { ThemeProvider } from "./ThemeProvider";
+import { Provider } from "react-redux";
+import { store } from "./store/store";
+import Wrapper from "./Wrapper";
function App() {
return (
-
-
-
- } />
- }>
- } />
-
- } />
- } />
-
-
-
+
+
+
+
+
+ }>
+ } />
+ } />
+ }>
+ } />
+
+ } />
+
+
+
+
+
+
);
}
diff --git a/src/Header.css b/src/Header.css
deleted file mode 100644
index 37ec9df..0000000
--- a/src/Header.css
+++ /dev/null
@@ -1,24 +0,0 @@
-header {
- background-color: #2a2a2a;
- padding: 20px 30px;
- border-radius: 10px;
- font-weight: bold;
- color: #aaa;
-}
-nav {
- display: flex;
- justify-content: start;
- gap: 30px;
-}
-.nav-link {
- text-decoration: none;
- color: inherit;
-
- color: #aaa;
- font-weight: bold;
-}
-
-.nav-link:hover {
- text-decoration: none;
- color: #f8e329;
-}
diff --git a/src/Header.tsx b/src/Header.tsx
deleted file mode 100644
index 9e2b4aa..0000000
--- a/src/Header.tsx
+++ /dev/null
@@ -1,17 +0,0 @@
-import { NavLink } from "react-router-dom";
-import "./Header.css";
-function Header() {
- return (
-
-
-
- );
-}
-export default Header;
diff --git a/src/Header/Header.css b/src/Header/Header.css
new file mode 100644
index 0000000..eeda275
--- /dev/null
+++ b/src/Header/Header.css
@@ -0,0 +1,53 @@
+header.light {
+ background-color: #2a2a2a;
+ color: #aaa;
+}
+
+header.dark {
+ background-color: #131212;
+ color: #525151;
+}
+
+nav {
+ display: flex;
+ justify-content: start;
+ gap: 30px;
+}
+.nav-link {
+ text-decoration: none;
+ color: inherit;
+
+ color: #aaa;
+ font-weight: bold;
+}
+
+.nav-link:hover {
+ text-decoration: none;
+ color: #f8e329;
+}
+
+header {
+ padding: 20px 30px;
+ border-radius: 10px;
+ font-weight: bold;
+ display: flex;
+ flex-direction: row;
+ justify-content: space-between;
+ align-items: center;
+ gap: 3%;
+}
+
+.switch_theme_button {
+ align-self: flex-end;
+ padding: 12px 25px;
+ background-color: #f8e329;
+ color: #000;
+ border-radius: 6px;
+ font-weight: bold;
+ cursor: pointer;
+ transition: background-color 0.3s;
+}
+
+.switch_theme_button:hover {
+ background-color: #f1cc10;
+}
diff --git a/src/Header/Header.tsx b/src/Header/Header.tsx
new file mode 100644
index 0000000..cadf6c1
--- /dev/null
+++ b/src/Header/Header.tsx
@@ -0,0 +1,26 @@
+import { NavLink } from "react-router-dom";
+import "./Header.css";
+import { ThemeContext } from "../ThemeContext";
+import { useContext } from "react";
+import ErrorButton from "../ErrorButton";
+
+function Header({ handleError }: { handleError: () => void }) {
+ const { theme, toggleTheme } = useContext(ThemeContext) || {};
+ return (
+
+
+
+
+
+ );
+}
+export default Header;
diff --git a/src/Input.tsx b/src/Input.tsx
index 57205cc..5ca588c 100644
--- a/src/Input.tsx
+++ b/src/Input.tsx
@@ -1,11 +1,21 @@
import React from "react";
+import { useContext } from "react";
+import { ThemeContext } from "./ThemeContext";
interface InputProps {
value: string;
onChange: (e: React.ChangeEvent
) => void;
}
function Input({ value, onChange }: InputProps): React.ReactElement {
- return ;
+ const { theme } = useContext(ThemeContext) || {};
+ return (
+
+ );
}
export default Input;
diff --git a/src/MainPage.tsx b/src/MainPage.tsx
index dbc9468..cd398e8 100644
--- a/src/MainPage.tsx
+++ b/src/MainPage.tsx
@@ -1,4 +1,10 @@
-import React, { useRef, useState, useEffect, useCallback } from "react";
+import React, {
+ useRef,
+ useState,
+ useEffect,
+ useCallback,
+ useContext,
+} from "react";
import {
useParams,
useSearchParams,
@@ -6,8 +12,6 @@ import {
useNavigate,
} from "react-router-dom";
import "./App.css";
-import ErrorButton from "./ErrorButton";
-import Header from "./Header";
import { PlanetApi } from "./PlanetFetch";
import Planets from "./Planets";
import Button from "./Button";
@@ -15,7 +19,11 @@ import Input from "./Input";
import type { PlanetsListItem } from "./types";
import Pagination from "./Pagination";
import useLocalStorage from "./hooks/useLocalStorage";
-
+import { ThemeContext } from "./ThemeContext";
+import { useDispatch } from "react-redux";
+import { addItem, removeItem, stateItems } from "./store/selectedItemsSlice";
+import SelectedPlanets from "./SelectedPlanets/SelectedPlanets.tsx";
+import { useAppSelector } from "./hooks/hooks.ts";
const planetsPerPage = 10;
function MainPage(): React.ReactElement {
@@ -33,6 +41,21 @@ function MainPage(): React.ReactElement {
const [searchPlanets, setPlanets] = useState([]);
const [isLoading, setIsLoading] = useState(false);
const [totalPlanets, setTotalPlanets] = useState(0);
+
+ const { theme } = useContext(ThemeContext) || {};
+
+ const dispatch = useDispatch();
+
+ const selectedItems = useAppSelector(stateItems);
+
+ const toggleItemSelection = (item: PlanetsListItem) => {
+ if (selectedItems.some((selected) => selected.uid === item.uid)) {
+ dispatch(removeItem(item.uid));
+ } else {
+ dispatch(addItem(item));
+ }
+ };
+
const getPlanets = useCallback(async () => {
let errorMessage = "Unknown error";
setIsLoading(true);
@@ -73,32 +96,20 @@ function MainPage(): React.ReactElement {
}, [getPlanets, searchQuery]);
const [error, setError] = useState(null);
- const [errorBoolean, setErrorBoolean] = useState(false);
-
- function triggerError() {
- setErrorBoolean(true);
- }
function handleSearch() {
- navigate(`/1?search=${encodeURIComponent(inputValue)}`);
+ navigate(`/list/1?search=${encodeURIComponent(inputValue)}`);
}
function handleInputChange(e: React.ChangeEvent) {
setInputValue(e.target.value);
}
- if (errorBoolean) {
- throw new Error("Test error");
- }
-
return (
-
-
-
Star Wars Planets
-
+ <>
+
Star Wars Planets
{error ?
{error}
: null}
-
-
+
@@ -107,6 +118,8 @@ function MainPage(): React.ReactElement {
searchPlanets={searchPlanets}
isLoading={isLoading}
inputValue={inputValue}
+ onItemSelect={toggleItemSelection}
+ selectedItems={selectedItems}
/>
@@ -118,8 +131,8 @@ function MainPage(): React.ReactElement {
searchQuery={searchQuery}
>
)}
-
-
+ {!isLoading && }
+ >
);
}
diff --git a/src/Pagination.tsx b/src/Pagination.tsx
index ec20f7a..7de7a52 100644
--- a/src/Pagination.tsx
+++ b/src/Pagination.tsx
@@ -24,7 +24,7 @@ export default function Pagination({
{pageNumbers.map((number) => (
{
- navigate(pageNumber ? `/${pageNumber}` : "/");
+ navigate(pageNumber ? `/list/${pageNumber}` : "/");
};
useEffect(() => {
diff --git a/src/PlanetMiniCard.tsx b/src/PlanetMiniCard.tsx
index 422f968..77c4255 100644
--- a/src/PlanetMiniCard.tsx
+++ b/src/PlanetMiniCard.tsx
@@ -2,6 +2,10 @@ import React from "react";
import type { PlanetsListItem } from "./types";
import { useNavigate, useParams } from "react-router-dom";
import { useSearchParams } from "react-router-dom";
+import { useDispatch } from "react-redux";
+import { addItem, removeItem, stateItems } from "./store/selectedItemsSlice";
+import { useAppSelector } from "./hooks/hooks.ts";
+
interface PlanetMiniCardProps {
planet: PlanetsListItem;
}
@@ -10,21 +14,43 @@ function PlanetMiniCard({ planet }: PlanetMiniCardProps): React.ReactElement {
const { pageNumber } = useParams();
const [searchParams] = useSearchParams();
const searchQuery = searchParams.get("search") || "";
+
const navigate = useNavigate();
+
+ const dispatch = useDispatch();
+ const selectedItems = useAppSelector(stateItems);
+
const id = planet.url.split("/").pop();
+
+ const isSelected = selectedItems.some((item) => item.uid === planet.uid);
+
+ const handleCheckboxChange = (e: React.ChangeEvent) => {
+ e.stopPropagation();
+ if (isSelected) {
+ dispatch(removeItem(planet.uid));
+ } else {
+ dispatch(addItem(planet));
+ }
+ };
+
+ const handleCardClick = () => {
+ if (!pageNumber) {
+ navigate(`/list/1/${id}?search=${searchQuery}`);
+ } else {
+ navigate(`/list/${pageNumber}/${id}?search=${searchQuery}`);
+ }
+ };
+
return (
- {
- if (!pageNumber) {
- navigate(`/1/${id}?search=${searchQuery}`);
- } else {
- navigate(`/${pageNumber}/${id}?search=${searchQuery}`);
- }
- }}
- >
+
{planet.name}
+ e.stopPropagation()}
+ className="planet-checkbox"
+ />
);
}
diff --git a/src/Planets.tsx b/src/Planets.tsx
index 6201c7f..742ba99 100644
--- a/src/Planets.tsx
+++ b/src/Planets.tsx
@@ -6,6 +6,8 @@ interface PlanetsProps {
searchPlanets: PlanetsListItem[];
isLoading: boolean;
inputValue: string;
+ onItemSelect: (item: PlanetsListItem) => void;
+ selectedItems: PlanetsListItem[];
}
export default function Planets({
diff --git a/src/SelectedPlanets/SelectedPlanets.css b/src/SelectedPlanets/SelectedPlanets.css
new file mode 100644
index 0000000..bd93acb
--- /dev/null
+++ b/src/SelectedPlanets/SelectedPlanets.css
@@ -0,0 +1,64 @@
+.selected-planets {
+ position: fixed;
+ bottom: -1000px;
+ left: 0;
+ right: 0;
+ background: rgba(0, 0, 0, 0.9);
+ color: white;
+ padding: 1rem;
+ z-index: 1000;
+ max-height: 50vh;
+ transition: bottom 0.3s ease;
+}
+
+.selected-planets.visible {
+ bottom: 0px;
+}
+
+.download_button {
+ align-self: flex-end;
+ background-color: #000000;
+ color: #ffe81f;
+ border: 2px solid #ffe81f;
+ padding: 8px 16px;
+ font-weight: bold;
+ border-radius: 4px;
+ cursor: pointer;
+ width: 30%;
+ text-transform: uppercase;
+ transition: all 0.3s ease;
+}
+
+.download_button:hover {
+ background-color: #ffe81f;
+ color: #000000;
+ box-shadow: 0 0 10px #ffe81f;
+}
+
+.download_button:active {
+ transform: scale(0.95);
+}
+
+.clear_button {
+ align-self: flex-end;
+ background-color: #000000;
+ color: #ffe81f;
+ border: 2px solid #ffe81f;
+ padding: 8px 16px;
+ font-weight: bold;
+ border-radius: 4px;
+ cursor: pointer;
+ width: 30%;
+ text-transform: uppercase;
+ transition: all 0.3s ease;
+}
+
+.clear_button:hover {
+ background-color: #ffe81f;
+ color: #000000;
+ box-shadow: 0 0 10px #ffe81f;
+}
+
+.clear_button:active {
+ transform: scale(0.95);
+}
diff --git a/src/SelectedPlanets/SelectedPlanets.tsx b/src/SelectedPlanets/SelectedPlanets.tsx
new file mode 100644
index 0000000..86e7b25
--- /dev/null
+++ b/src/SelectedPlanets/SelectedPlanets.tsx
@@ -0,0 +1,57 @@
+import { useAppSelector } from "../hooks/hooks.ts";
+import { stateItems, clearAllItems } from "../store/selectedItemsSlice.ts";
+import { useRef, useState } from "react";
+import { useDispatch } from "react-redux";
+import "./SelectedPlanets.css";
+
+export default function SelectedPlanets() {
+ const selectedItems = useAppSelector(stateItems);
+ const downloadRef = useRef
(null);
+ const [downloadUrl, setDownloadUrl] = useState("");
+ const [filename, setFilename] = useState("");
+
+ const dispatch = useDispatch();
+
+ const handleClearAll = () => {
+ dispatch(clearAllItems());
+ };
+
+ function download() {
+ const name_column = "id,name,url \r\n";
+ const download_data = selectedItems
+ .map((items, id) => id + 1 + "," + items.name + "," + items.url)
+ .join("\r\n");
+ const csvdata = name_column + download_data;
+ const csvBlob = new Blob([csvdata], { type: "text/csv" });
+ const url = URL.createObjectURL(csvBlob);
+ setDownloadUrl(url);
+ setFilename(`${selectedItems.length}_items.csv`);
+ setTimeout(() => {
+ if (downloadRef.current) {
+ downloadRef.current.click();
+ URL.revokeObjectURL(url);
+ setDownloadUrl("");
+ }
+ }, 0);
+ }
+
+ return (
+
+
Selected Planets: {selectedItems.length}
+
+
+
+
+ );
+}
diff --git a/src/ThemeContext.ts b/src/ThemeContext.ts
new file mode 100644
index 0000000..173bade
--- /dev/null
+++ b/src/ThemeContext.ts
@@ -0,0 +1,11 @@
+import { createContext } from "react";
+
+type Theme = "light" | "dark";
+type ThemeContextType = {
+ theme: Theme;
+ toggleTheme: () => void;
+};
+
+export const ThemeContext = createContext(
+ {} as ThemeContextType,
+);
diff --git a/src/ThemeProvider.tsx b/src/ThemeProvider.tsx
new file mode 100644
index 0000000..acc785a
--- /dev/null
+++ b/src/ThemeProvider.tsx
@@ -0,0 +1,25 @@
+import React, { useState, useEffect } from "react";
+import { ThemeContext } from "./ThemeContext";
+type Theme = "light" | "dark";
+
+export function ThemeProvider({
+ children,
+}: {
+ children: React.ReactNode;
+}): React.ReactElement {
+ const [theme, setTheme] = useState("light");
+ const toggleTheme = () => {
+ setTheme(theme === "light" ? "dark" : "light");
+ };
+
+ useEffect(() => {
+ document.body.classList.remove("light", "dark");
+ document.body.classList.add(theme);
+ }, [theme]);
+
+ return (
+
+ {children}
+
+ );
+}
diff --git a/src/Wrapper.tsx b/src/Wrapper.tsx
new file mode 100644
index 0000000..b81384b
--- /dev/null
+++ b/src/Wrapper.tsx
@@ -0,0 +1,25 @@
+import { Outlet } from "react-router-dom";
+import Header from "./Header/Header";
+import { useContext, useState } from "react";
+import { ThemeContext } from "./ThemeContext";
+
+function Wrapper() {
+ const { theme } = useContext(ThemeContext) || {};
+ const [errorBoolean, setErrorBoolean] = useState(false);
+
+ function triggerError() {
+ setErrorBoolean(true);
+ }
+ if (errorBoolean) {
+ throw new Error("Test error");
+ }
+
+ return (
+
+
+
+
+ );
+}
+
+export default Wrapper;
diff --git a/src/__tests__/About.test.tsx b/src/__tests__/About.test.tsx
new file mode 100644
index 0000000..ea573c3
--- /dev/null
+++ b/src/__tests__/About.test.tsx
@@ -0,0 +1,22 @@
+import { screen, render } from "@testing-library/react";
+import { BrowserRouter as Router } from "react-router-dom";
+import { Provider } from "react-redux";
+import { store } from "../store/store";
+import About from "../About";
+
+describe("App tests render", () => {
+ test("should render about", () => {
+ render(
+
+
+
+
+ ,
+ );
+
+ expect(screen.getByRole("paragraph")).toHaveTextContent(
+ "The author of the application is Pavel Kozin",
+ );
+ expect(screen.getByText("RS School")).toBeInTheDocument();
+ });
+});
diff --git a/src/__tests__/App.test.tsx b/src/__tests__/App.test.tsx
new file mode 100644
index 0000000..7d3c37d
--- /dev/null
+++ b/src/__tests__/App.test.tsx
@@ -0,0 +1,20 @@
+import { screen, render } from "@testing-library/react";
+import { Provider } from "react-redux";
+import { BrowserRouter } from "react-router-dom";
+import { store } from "../store/store";
+import App from "../App";
+
+describe("App tests render", () => {
+ test("should render app", () => {
+ render(
+
+
+
+
+ ,
+ );
+ expect(screen.getByTestId("app")).toBeInTheDocument();
+ expect(screen.getByRole("navigation")).toBeInTheDocument();
+ expect(screen.getByText("Star Wars Planets")).toBeInTheDocument();
+ });
+});
diff --git a/src/__tests__/Card.test.tsx b/src/__tests__/Card.test.tsx
new file mode 100644
index 0000000..b4699ab
--- /dev/null
+++ b/src/__tests__/Card.test.tsx
@@ -0,0 +1,51 @@
+import { describe, test, expect, vi } from "vitest";
+import { render, screen, waitFor } from "@testing-library/react";
+import PlanetCard from "../PlanetCard";
+import { PlanetApi } from "../PlanetFetch";
+import { BrowserRouter as Router } from "react-router-dom";
+import { Provider } from "react-redux";
+import { store } from "../store/store";
+import type { Params } from "react-router-dom";
+
+vi.mock("../PlanetFetch", () => ({
+ PlanetApi: {
+ fetchPlanetDetail: vi.fn(),
+ },
+}));
+
+vi.mock("react-router-dom", async (importOriginal) => {
+ const original = await importOriginal();
+ return {
+ ...original,
+ useParams: (): Readonly> => ({ planetId: "2" }),
+ };
+});
+
+describe("Planet Cards Rendering", () => {
+ test("should render 1 planet cards", async () => {
+ const mockPlanetData = {
+ name: "Alderaan",
+ diameter: "12500",
+ climate: "temperate",
+ population: "2000000000",
+ rotation_period: "24",
+ orbital_period: "364",
+ gravity: "1 standard",
+ terrain: "grasslands, mountains",
+ };
+
+ vi.mocked(PlanetApi.fetchPlanetDetail).mockResolvedValue(mockPlanetData);
+ render(
+
+
+
+
+ ,
+ );
+ await waitFor(() => {
+ expect(
+ screen.getByRole("heading", { name: /Alderaan/ }),
+ ).toBeInTheDocument();
+ });
+ });
+});
diff --git a/src/__tests__/CardList_Component_Tests.test.tsx b/src/__tests__/CardList.test.tsx
similarity index 83%
rename from src/__tests__/CardList_Component_Tests.test.tsx
rename to src/__tests__/CardList.test.tsx
index c702f61..69fb0a4 100644
--- a/src/__tests__/CardList_Component_Tests.test.tsx
+++ b/src/__tests__/CardList.test.tsx
@@ -5,6 +5,8 @@ import MainPage from "../MainPage";
import { PlanetApi } from "../PlanetFetch";
import { BrowserRouter as Router } from "react-router-dom";
import type { PlanetsListItem } from "../types";
+import { Provider } from "react-redux";
+import { store } from "../store/store";
vi.mock("../PlanetFetch", () => ({
PlanetApi: {
@@ -37,9 +39,11 @@ describe("Planet Cards Rendering", () => {
const user = userEvent.setup();
vi.mocked(PlanetApi.fetchPlanets).mockResolvedValue(mockPlanet);
render(
-
-
- ,
+
+
+
+
+ ,
);
const inputElement = screen.getByRole("textbox");
@@ -57,9 +61,11 @@ describe("Planet Cards Rendering", () => {
test("should render all planet cards", async () => {
vi.mocked(PlanetApi.fetchPlanets).mockResolvedValue(mockPlanet);
render(
-
-
- ,
+
+
+
+
+ ,
);
expect(await screen.findByRole("article")).toBeInTheDocument();
expect(screen.getByText(mockPlanet.planets[0].name)).toBeInTheDocument();
@@ -69,9 +75,11 @@ describe("Planet Cards Rendering", () => {
new Error("Error in request"),
);
render(
-
-
- ,
+
+
+
+
+ ,
);
const error = await screen.findByTestId("error-message");
diff --git a/src/__tests__/Error.test.tsx b/src/__tests__/Error.test.tsx
index 07a2204..c8e6dbc 100644
--- a/src/__tests__/Error.test.tsx
+++ b/src/__tests__/Error.test.tsx
@@ -4,17 +4,23 @@ import ErrorBoundary from "../ErrorBoundary";
import { describe, it, expect, vi } from "vitest";
import userEvent from "@testing-library/user-event";
import { BrowserRouter as Router } from "react-router-dom";
+import { Provider } from "react-redux";
+import { store } from "../store/store";
+import Wrapper from "../Wrapper";
describe("Error component", () => {
it("should throw error", async () => {
const ErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
render(
-
-
-
-
- ,
+
+
+
+
+
+
+
+ ,
);
const errorButton = screen.getByRole("button", { name: /Error/ });
@@ -30,11 +36,14 @@ describe("Error component", () => {
it("should click reboot button", async () => {
render(
-
-
-
-
- ,
+
+
+
+
+
+
+
+ ,
);
const errorButton = screen.getByRole("button", { name: /Error/ });
diff --git a/src/__tests__/App_test1.test.tsx b/src/__tests__/Header.test.tsx
similarity index 64%
rename from src/__tests__/App_test1.test.tsx
rename to src/__tests__/Header.test.tsx
index 6080f06..6a72493 100644
--- a/src/__tests__/App_test1.test.tsx
+++ b/src/__tests__/Header.test.tsx
@@ -1,13 +1,19 @@
import { screen, render } from "@testing-library/react";
import MainPage from "../MainPage";
import { BrowserRouter as Router } from "react-router-dom";
+import { Provider } from "react-redux";
+import { store } from "../store/store";
+import Wrapper from "../Wrapper";
describe("App tests render", () => {
test("should render the title", () => {
render(
-
-
- ,
+
+
+
+
+
+ ,
);
expect(
diff --git a/src/__tests__/Page404.test.tsx b/src/__tests__/Page404.test.tsx
new file mode 100644
index 0000000..1127124
--- /dev/null
+++ b/src/__tests__/Page404.test.tsx
@@ -0,0 +1,24 @@
+import { screen, render } from "@testing-library/react";
+import { BrowserRouter as Router } from "react-router-dom";
+import { Provider } from "react-redux";
+import { store } from "../store/store";
+import Page404 from "../Page404";
+
+describe("App tests render", () => {
+ test("should render about", () => {
+ render(
+
+
+
+
+ ,
+ );
+
+ expect(
+ screen.getByRole("heading", {
+ level: 1,
+ }),
+ ).toHaveTextContent("404");
+ expect(screen.getByRole("link", { name: /Вернуться на главную/ }));
+ });
+});
diff --git "a/src/__tests__/Search_\320\241omponent_Tests.test.tsx" b/src/__tests__/Search.test.tsx
similarity index 77%
rename from "src/__tests__/Search_\320\241omponent_Tests.test.tsx"
rename to src/__tests__/Search.test.tsx
index c069772..e04e9c8 100644
--- "a/src/__tests__/Search_\320\241omponent_Tests.test.tsx"
+++ b/src/__tests__/Search.test.tsx
@@ -3,6 +3,8 @@ import { render, screen } from "@testing-library/react";
import MainPage from "../MainPage";
import userEvent from "@testing-library/user-event";
import { BrowserRouter as Router } from "react-router-dom";
+import { Provider } from "react-redux";
+import { store } from "../store/store";
describe("Search Component Tests", () => {
const localStorageMock = {
@@ -16,9 +18,11 @@ describe("Search Component Tests", () => {
test("should render Input and Button elements", () => {
render(
-
-
- ,
+
+
+
+
+ ,
);
const inputElement = screen.getByRole("textbox");
@@ -38,9 +42,11 @@ describe("Search Component Tests", () => {
}
});
render(
-
-
- ,
+
+
+
+
+ ,
);
expect(screen.getByRole("textbox")).toHaveValue(testQuery);
expect(localStorageMock.getItem).toHaveBeenCalledWith("starWarsQuery");
@@ -51,9 +57,12 @@ describe("Search Component Tests", () => {
return null;
});
render(
-
-
- ,
+
+
+
+
+ ,
+ ,
);
expect(screen.getByRole("textbox")).toHaveValue("");
expect(localStorageMock.getItem).toHaveBeenCalledWith("starWarsQuery");
@@ -62,9 +71,12 @@ describe("Search Component Tests", () => {
test("should update input value when typing", async () => {
const user = userEvent.setup();
render(
-
-
- ,
+
+
+
+
+ ,
+ ,
);
const inputElement = screen.getByRole("textbox");
@@ -77,9 +89,12 @@ describe("Search Component Tests", () => {
const user = userEvent.setup();
render(
-
-
- ,
+
+
+
+
+ ,
+ ,
);
const inputElement = screen.getByRole("textbox");
@@ -98,9 +113,12 @@ describe("Search Component Tests", () => {
const user = userEvent.setup();
render(
-
-
- ,
+
+
+
+
+ ,
+ ,
);
const inputElement = screen.getByRole("textbox");
diff --git a/src/hooks/hooks.ts b/src/hooks/hooks.ts
new file mode 100644
index 0000000..5908179
--- /dev/null
+++ b/src/hooks/hooks.ts
@@ -0,0 +1,4 @@
+import { useSelector } from "react-redux";
+import type { RootState } from "../store/store";
+
+export const useAppSelector = useSelector.withTypes();
diff --git a/src/store/selectedItemsSlice.ts b/src/store/selectedItemsSlice.ts
new file mode 100644
index 0000000..9c2af53
--- /dev/null
+++ b/src/store/selectedItemsSlice.ts
@@ -0,0 +1,36 @@
+import { createSlice } from "@reduxjs/toolkit";
+import type { PayloadAction } from "@reduxjs/toolkit";
+import type { PlanetsListItem } from "../types";
+import type { RootState } from "../store/store";
+
+interface SelectedItemsState {
+ items: PlanetsListItem[];
+}
+
+const initialState: SelectedItemsState = {
+ items: [],
+};
+
+export const selectedItemsSlice = createSlice({
+ name: "selectedItems",
+ initialState,
+ reducers: {
+ addItem: (state, action: PayloadAction) => {
+ if (!state.items.find((item) => item.uid === action.payload.uid)) {
+ state.items.push(action.payload);
+ }
+ },
+ removeItem: (state, action: PayloadAction) => {
+ state.items = state.items.filter((item) => item.uid !== action.payload);
+ },
+ clearAllItems: (state) => {
+ state.items = [];
+ },
+ },
+});
+
+export const stateItems = (state: RootState) => state.selectedItems.items;
+
+export const { addItem, removeItem, clearAllItems } =
+ selectedItemsSlice.actions;
+export default selectedItemsSlice.reducer;
diff --git a/src/store/store.ts b/src/store/store.ts
new file mode 100644
index 0000000..eb02d11
--- /dev/null
+++ b/src/store/store.ts
@@ -0,0 +1,11 @@
+import { configureStore } from "@reduxjs/toolkit";
+import selectedItemsReducer from "./selectedItemsSlice";
+
+export const store = configureStore({
+ reducer: {
+ selectedItems: selectedItemsReducer,
+ },
+});
+export type AppStore = typeof store;
+export type RootState = ReturnType;
+export type AppDispatch = AppStore["dispatch"];