diff --git a/README.md b/README.md index 3c4c4ff..7112c3c 100644 --- a/README.md +++ b/README.md @@ -30,9 +30,9 @@ sudo rsync -av --delete dist/ /var/www/html/project1/ echo "Deploy ferdig!" ``` - ## Prettier use + ``` npm install -npm run format -``` \ No newline at end of file +npm run format +``` diff --git a/src/Api.tsx b/src/Api.tsx index fe0b53f..3088be9 100644 --- a/src/Api.tsx +++ b/src/Api.tsx @@ -51,3 +51,58 @@ function idFromUrl(url: string): number { const match = url.match(/\/pokemon\/(\d+)\//); return match ? Number(match[1]) : 0; } + +/* + Type for displayed Pokemon details. +*/ +export type PokemonDetails = { + id: number; + name: string; + generation: string; + types: string[]; + stats: { + name: string; + value: number; + }[]; + abilities: string[]; + imageUrl: string; +}; + +/* + This function returns the relevant details for a specific Pokemon. + Must return a Promise that resolves as a PokemonDetails object. +*/ +export async function getPokemonDetails(id: number): Promise { + const pokemonResponse = await fetch(pokemonApiUrl(id)); + + if (!pokemonResponse.ok) { + throw new Error(`PokéAPI svarte med ${pokemonResponse.status}`); + } + + const data = await pokemonResponse.json(); + + const speciesResponse = await fetch(data.species.url); + + if (!speciesResponse.ok) { + throw new Error(`Kunne ikke hente species-data`); + } + + const speciesData = await speciesResponse.json(); + + return { + id: data.id, + name: data.name, + generation: speciesData.generation.name, + types: data.types.map((type: { type: { name: string } }) => type.type.name), + stats: data.stats.map( + (stat: { stat: { name: string }; base_stat: number }) => ({ + name: stat.stat.name, + value: stat.base_stat, + }) + ), + abilities: data.abilities.map( + (ability: { ability: { name: string } }) => ability.ability.name + ), + imageUrl: data.sprites.other['official-artwork'].front_default, + }; +} diff --git a/src/App.css b/src/App.css index 5b47a50..7913902 100644 --- a/src/App.css +++ b/src/App.css @@ -1,3 +1,14 @@ +/* + Used for search results in the pokedex search page. +*/ +.search-result { + background: none; + border: none; + padding: 0; + font: inherit; + cursor: pointer; +} + .search-page { display: flex; flex-direction: column; @@ -363,3 +374,160 @@ padding-bottom: 4px; } } + +/* + CSS for pokemon detail card. +*/ +.pokemon-card { + position: relative; + width: min(100%, 620px); + margin: 24px auto; + padding: 20px; + box-sizing: border-box; + + border: 1px solid var(--accent-border); + border-radius: 18px; + background: var(--bg-soft); + text-align: left; + box-shadow: var(--shadow); +} + +.pokemon-card-close { + position: absolute; + top: 10px; + right: 12px; + + border: 0; + background: transparent; + color: var(--text); + font-size: 22px; + cursor: pointer; +} + +.pokemon-card-header { + display: flex; + align-items: center; + gap: 16px; + padding-right: 24px; +} + +.pokemon-card-header img { + width: 100px; + height: 100px; + object-fit: contain; +} + +.pokemon-card h2 { + margin: 0; + font-size: 24px; +} + +.pokemon-card h2 span { + color: var(--text); + font-size: 16px; + font-weight: 400; +} + +.pokemon-generation { + margin-top: 4px; + color: var(--text); + font-size: 14px; +} + + + +.pokemon-info { + width: 100%; + margin-top: 16px; + border-spacing: 6px 0; + table-layout: fixed; +} + +.pokemon-info th, +.pokemon-info td { + padding: 10px 12px; + text-align: left; +} + +.pokemon-info th { + color: var(--text-h); + font-size: 14px; + font-weight: 700; +} + +.pokemon-info td { + padding: 10px 12px; + border-radius: 10px; + background: var(--bg); +} + +.pokemon-stats h3 { + color: var(--text-h); + font-size: 14px; +} + +.pokemon-info span { + color: var(--text); + font-size: 14px; +} + +.pokemon-stats { + margin-top: 16px; +} + +.pokemon-stats h3 { + margin: 0 0 8px; +} + +.stats-grid { + width: 100%; + border-spacing: 6px; + table-layout: fixed; +} + +.stat { + padding: 7px 9px; + border-radius: 8px; + background: var(--bg); + font-size: 13px; +} + +.stat span, +.stat strong { + display: inline-block; +} + +.stat strong { + float: right; +} + +.stat span { + color: var(--text); +} + +.stat strong { + color: var(--text-h); +} + +@media (max-width: 600px) { + .pokemon-card { + padding: 16px; + } + + .pokemon-card-header img { + width: 80px; + height: 80px; + } + + .pokemon-card h2 { + font-size: 20px; + } + + .pokemon-info { + border-spacing: 4px 0; + } + + .stats-grid { + border-spacing: 4px; + } +} diff --git a/src/App.tsx b/src/App.tsx index 00cac48..ad25df3 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,9 +1,11 @@ import { useState } from 'react'; import type { FormEvent } from 'react'; import { useQuery } from '@tanstack/react-query'; -import { listAllPokemon } from './Api'; import { useFavorites } from './useFavorites'; import FavoritesSidebar from './FavoritesSidebar'; +import { listAllPokemon } from './Api'; +import PokemonDetails from './PokemonDetails'; + import './App.css'; const SEARCH_STORAGE_KEY = 'pokemon-search'; @@ -26,8 +28,9 @@ function capitalize(text: string): string { function App() { const [query, setQuery] = useState( - () => sessionStorage.getItem(SEARCH_STORAGE_KEY) ?? '', + () => sessionStorage.getItem(SEARCH_STORAGE_KEY) ?? '' ); + const [selectedPokemonId, setSelectedPokemonId] = useState(1); // TanStack Query håndterer henting og caching av Pokémon-lista. const { @@ -41,7 +44,7 @@ function App() { }); const [submittedQuery, setSubmittedQuery] = useState( - () => sessionStorage.getItem(SEARCH_STORAGE_KEY) ?? '', + () => sessionStorage.getItem(SEARCH_STORAGE_KEY) ?? '' ); const [error, setError] = useState(''); @@ -52,24 +55,22 @@ function App() { const pokemon = submittedQuery ? allPokemon - .filter((entry) => - entry.name.includes(submittedQuery.toLowerCase()), - ) - .slice(0, 24) + .filter((entry) => entry.name.includes(submittedQuery.toLowerCase())) + .slice(0, 24) : []; function addToRecentSearches(trimmedQuery: string) { const withoutDuplicate = recentSearches.filter( - (entry) => entry.toLowerCase() !== trimmedQuery.toLowerCase(), + (entry) => entry.toLowerCase() !== trimmedQuery.toLowerCase() ); const updated = [trimmedQuery, ...withoutDuplicate].slice( 0, - MAX_RECENT_SEARCHES, + MAX_RECENT_SEARCHES ); setRecentSearches(updated); sessionStorage.setItem( RECENT_SEARCHES_STORAGE_KEY, - JSON.stringify(updated), + JSON.stringify(updated) ); } @@ -109,6 +110,14 @@ function App() { Søk på navn, eller en del av et navn – for eksempel «char» eller «pikachu».

+ + + + +
)} - {!isError && error && ( -

{error}

- )} + {!isError && error &&

{error}

} - {!isError && - !error && + {!isError && + !error && !isPending && - submittedQuery && + submittedQuery && pokemon.length === 0 && (

Fant ingen Pokémon som matcher «{submittedQuery}».

)} - {!isError && - !error && - !isPending && - !submittedQuery && ( -

Søkeresultatene dine vises her.

- )} - + {!isError && !error && !isPending && !submittedQuery && ( +

Søkeresultatene dine vises her.

+ )} {pokemon.map((entry) => ( ))}
diff --git a/src/PokemonDetails.tsx b/src/PokemonDetails.tsx new file mode 100644 index 0000000..7cd9d6b --- /dev/null +++ b/src/PokemonDetails.tsx @@ -0,0 +1,125 @@ +import { useQuery } from '@tanstack/react-query'; +import { getPokemonDetails } from './Api'; +import type { Dispatch, SetStateAction } from 'react'; +import './App.css'; + +const capitalize = (str: string) => str.charAt(0).toUpperCase() + str.slice(1); + +// The PokemonDetails type that is used to fetch state from App.tsx to be used and manipulated in the details card. +type PokemonDetailsProps = { + selectedPokemonId: number | null; + setSelectedPokemonId: Dispatch>; +}; + +function PokemonDetails({ + selectedPokemonId, + setSelectedPokemonId, +}: PokemonDetailsProps) { + // TanStack Query is used to fetch the details of the selected Pokémon based on its ID. + const { + data: selectedPokemon, + isPending, + isError, + } = useQuery({ + queryKey: ['pokemon-details', selectedPokemonId], + queryFn: () => getPokemonDetails(selectedPokemonId!), + enabled: selectedPokemonId !== null, // Query only runs if the selectedPokemonId is not null. + }); + + if (selectedPokemonId === 0) { + return ( +
+

No Pokémon selected.

+
+ ); + } + + if (isPending) { + return

Loading Pokémon details...

; + } + + if (isError || selectedPokemon === null) { + return

Could not load Pokémon details.

; + } + + const statRows = Array.from( + { length: Math.ceil(selectedPokemon.stats.length / 3) }, + (_, rowIndex) => selectedPokemon.stats.slice(rowIndex * 3, rowIndex * 3 + 3) + ); + + return ( +
+ + + {selectedPokemon && ( + <> +
+ {capitalize(selectedPokemon.name)} + +
+

+ {capitalize(selectedPokemon.name)}{' '} + #{selectedPokemon.id} +

+

+ {capitalize(selectedPokemon.generation.replace('-', ' '))} +

+
+
+ + + + + + + + + + + + + + +
TypeAbilities
+ {selectedPokemon.types.map(capitalize).join(', ')} + + + {selectedPokemon.abilities.map(capitalize).join(', ')} + +
+ +
+

Base stats

+ + + + {statRows.map((row, rowIndex) => ( + + {row.map((stat) => ( + + ))} + + ))} + +
+ {capitalize(stat.name)} + {stat.value} +
+
+ + )} +
+ ); +} + +export default PokemonDetails; diff --git a/src/main.tsx b/src/main.tsx index eb0dde4..32f177e 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -1,6 +1,6 @@ import { StrictMode } from 'react'; import { createRoot } from 'react-dom/client'; -import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import './index.css'; import App from './App.tsx';