From 70229678bdf3b5e927481cf4cb68d6387323e929 Mon Sep 17 00:00:00 2001 From: William M Schmidt Date: Thu, 17 Sep 2026 23:11:07 +0200 Subject: [PATCH 1/6] Added a card to display pokemon details In the search page a card displays pokemon details. Ran "npm run format" before commit. - New function and datatype in Api.tsx to get pokemon details. - The card was added directly into App.tsx. Should be its own component maybe. - Updated the global App.css file with card css. --- README.md | 6 +- src/Api.tsx | 55 +++++++++++++++ src/App.css | 149 +++++++++++++++++++++++++++++++++++++++++ src/App.tsx | 124 ++++++++++++++++++++++++++++------ src/PokemonDetails.tsx | 48 +++++++++++++ src/main.tsx | 2 +- 6 files changed, 358 insertions(+), 26 deletions(-) create mode 100644 src/PokemonDetails.tsx 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 d27aa90..7a0fa57 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; @@ -319,3 +330,141 @@ 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 { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; + margin-top: 16px; +} + +.pokemon-info div { + display: flex; + flex-direction: column; + gap: 4px; + padding: 10px 12px; + border-radius: 10px; + background: var(--bg); +} + +.pokemon-info strong, +.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 { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 6px; +} + +.stat { + display: flex; + justify-content: space-between; + gap: 8px; + padding: 7px 9px; + border-radius: 8px; + background: var(--bg); + font-size: 13px; +} + +.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, + .stats-grid { + grid-template-columns: 1fr 1fr; + } +} diff --git a/src/App.tsx b/src/App.tsx index 7bd108f..9885ee3 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,9 +1,10 @@ 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 { getPokemonDetails, listAllPokemon } from './Api'; + import './App.css'; const SEARCH_STORAGE_KEY = 'pokemon-search'; @@ -14,7 +15,10 @@ 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( + null ); // TanStack Query håndterer henting og caching av Pokémon-lista. @@ -29,7 +33,7 @@ function App() { }); const [submittedQuery, setSubmittedQuery] = useState( - () => sessionStorage.getItem(SEARCH_STORAGE_KEY) ?? '', + () => sessionStorage.getItem(SEARCH_STORAGE_KEY) ?? '' ); const [error, setError] = useState(''); @@ -38,12 +42,20 @@ 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) : []; + const { + data: selectedPokemon, + isPending: isDetailsPending, + isError: isDetailsError, + } = useQuery({ + queryKey: ['pokemon-details', selectedPokemonId], + queryFn: () => getPokemonDetails(selectedPokemonId!), + enabled: selectedPokemonId !== null, + }); + function handleSearch(event: FormEvent) { event.preventDefault(); const trimmedQuery = query.trim(); @@ -69,6 +81,76 @@ function App() { Søk på navn, eller en del av et navn – for eksempel «char» eller «pikachu».

+ + +
+ + + {isDetailsPending &&

Laster detaljer...

} + + {isDetailsError && ( +

Kunne ikke hente detaljer.

+ )} + + {selectedPokemon && ( + <> +
+ {capitalize(selectedPokemon.name)} + +
+

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

+

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

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

Base stats

+ +
+ {selectedPokemon.stats.map((stat) => ( +
+ {capitalize(stat.name)} + {stat.value} +
+ ))} +
+
+ + )} +
+ +
)} - {!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..c063ac8 --- /dev/null +++ b/src/PokemonDetails.tsx @@ -0,0 +1,48 @@ +import { useQuery } from '@tanstack/react-query'; +import type { Pokemon } from './Api'; +import { getPokemonDetails } from './Api'; +import './App.css'; + +const capitalize = (str: string) => str.charAt(0).toUpperCase() + str.slice(1); + +function PokemonDetails({ pokemon }: { pokemon: Pokemon }) { + const { data, isPending, isError } = useQuery({ + queryKey: ['pokemon-details', pokemon.id], + queryFn: () => getPokemonDetails(pokemon.id), + }); + + if (isPending) { + return

Loading Pokémon details...

; + } + + if (isError || !data) { + return

Could not load Pokémon details.

; + } + + return ( +
+

+ {capitalize(data.name)} (#{data.id}) +

+ + {capitalize(data.name)} + +

Types

+

{data.types.map(capitalize).join(', ')}

+ +

Abilities

+

{data.abilities.map(capitalize).join(', ')}

+ +

Stats

+
    + {data.stats.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'; From 018322b8b567ef1caccde3af0d93db79e0b50ad2 Mon Sep 17 00:00:00 2001 From: William M Schmidt Date: Thu, 17 Sep 2026 23:15:25 +0200 Subject: [PATCH 2/6] Changed default pokemon to #1 so it always loads something. --- src/App.tsx | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 9885ee3..8cf0f6a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -17,9 +17,7 @@ function App() { const [query, setQuery] = useState( () => sessionStorage.getItem(SEARCH_STORAGE_KEY) ?? '' ); - const [selectedPokemonId, setSelectedPokemonId] = useState( - null - ); + const [selectedPokemonId, setSelectedPokemonId] = useState(1); // TanStack Query håndterer henting og caching av Pokémon-lista. const { From 89a4e945ef4d69b50571cf39e873112a923533e7 Mon Sep 17 00:00:00 2001 From: William M Schmidt Date: Fri, 18 Sep 2026 13:02:58 +0200 Subject: [PATCH 3/6] App.tsx renders component from PokemonDetails.tsx No more UI code directly in App.tsx. Simply a call to the component. Passing state props. --- src/App.css | 7 +++ src/App.tsx | 92 ++++------------------------------- src/PokemonDetails.tsx | 106 ++++++++++++++++++++++++++++++----------- 3 files changed, 96 insertions(+), 109 deletions(-) diff --git a/src/App.css b/src/App.css index 31cb7b8..367b0ff 100644 --- a/src/App.css +++ b/src/App.css @@ -375,6 +375,13 @@ } } + + + + + + + /* CSS for pokemon detail card. */ diff --git a/src/App.tsx b/src/App.tsx index 247661e..d0f592f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -3,7 +3,8 @@ import type { FormEvent } from 'react'; import { useQuery } from '@tanstack/react-query'; import { useFavorites } from './useFavorites'; import FavoritesSidebar from './FavoritesSidebar'; -import { getPokemonDetails, listAllPokemon } from './Api'; +import { listAllPokemon } from './Api'; +import PokemonDetails from './PokemonDetails'; import './App.css'; @@ -58,37 +59,24 @@ function App() { .slice(0, 24) : []; - const { - data: selectedPokemon, - isPending: isDetailsPending, - isError: isDetailsError, - } = useQuery({ - queryKey: ['pokemon-details', selectedPokemonId], - queryFn: () => getPokemonDetails(selectedPokemonId!), - enabled: selectedPokemonId !== null, - }); - - - 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) ); } function runSearch(rawQuery: string) { const trimmedQuery = rawQuery.trim(); - if (!trimmedQuery) { setSubmittedQuery(''); setError('Skriv inn et navn (eller en del av et navn) først.'); @@ -124,71 +112,11 @@ function App() {

-
- - - {isDetailsPending &&

Laster detaljer...

} - - {isDetailsError && ( -

Kunne ikke hente detaljer.

- )} - - {selectedPokemon && ( - <> -
- {capitalize(selectedPokemon.name)} - -
-

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

-

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

-
-
- -
-
- Type - - {selectedPokemon.types.map(capitalize).join(', ')} - -
- -
- Abilities - - {selectedPokemon.abilities.map(capitalize).join(', ')} - -
-
- -
-

Base stats

- -
- {selectedPokemon.stats.map((stat) => ( -
- {capitalize(stat.name)} - {stat.value} -
- ))} -
-
- - )} +
+
diff --git a/src/PokemonDetails.tsx b/src/PokemonDetails.tsx index c063ac8..c59912b 100644 --- a/src/PokemonDetails.tsx +++ b/src/PokemonDetails.tsx @@ -1,46 +1,98 @@ import { useQuery } from '@tanstack/react-query'; -import type { Pokemon } from './Api'; import { getPokemonDetails } from './Api'; +import type { Dispatch, SetStateAction } from 'react'; import './App.css'; const capitalize = (str: string) => str.charAt(0).toUpperCase() + str.slice(1); -function PokemonDetails({ pokemon }: { pokemon: Pokemon }) { - const { data, isPending, isError } = useQuery({ - queryKey: ['pokemon-details', pokemon.id], - queryFn: () => getPokemonDetails(pokemon.id), +// 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 (isPending) { return

Loading Pokémon details...

; } - if (isError || !data) { + if (isError || !selectedPokemon) { return

Could not load Pokémon details.

; } return ( -
-

- {capitalize(data.name)} (#{data.id}) -

- - {capitalize(data.name)} - -

Types

-

{data.types.map(capitalize).join(', ')}

- -

Abilities

-

{data.abilities.map(capitalize).join(', ')}

- -

Stats

-
    - {data.stats.map((stat) => ( -
  • - {capitalize(stat.name)}: {stat.value} -
  • - ))} -
+
+ + + {selectedPokemon && ( + <> +
+ {capitalize(selectedPokemon.name)} + +
+

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

+

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

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

Base stats

+ +
+ {selectedPokemon.stats.map((stat) => ( +
+ {capitalize(stat.name)} + {stat.value} +
+ ))} +
+
+ + )}
); } From fd129c1ff2462e44f060a2b97ec4a81d9c41e92f Mon Sep 17 00:00:00 2001 From: William M Schmidt Date: Fri, 18 Sep 2026 13:05:49 +0200 Subject: [PATCH 4/6] Removed unnecessary section wrapper --- src/App.tsx | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index d0f592f..0a3ba26 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -112,12 +112,10 @@ function App() {

-
-
From 4c5773e2b52eb9eff4e8855094bb6d299efb9f12 Mon Sep 17 00:00:00 2001 From: William M Schmidt Date: Fri, 18 Sep 2026 13:08:06 +0200 Subject: [PATCH 5/6] Added "no pokemon selected" state --- src/PokemonDetails.tsx | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/PokemonDetails.tsx b/src/PokemonDetails.tsx index c59912b..7c8d9b2 100644 --- a/src/PokemonDetails.tsx +++ b/src/PokemonDetails.tsx @@ -27,11 +27,17 @@ function PokemonDetails({ 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) { + if (isError || selectedPokemon === null) { return

Could not load Pokémon details.

; } @@ -40,7 +46,7 @@ function PokemonDetails({
- +
diff --git a/src/PokemonDetails.tsx b/src/PokemonDetails.tsx index 7c8d9b2..7cd9d6b 100644 --- a/src/PokemonDetails.tsx +++ b/src/PokemonDetails.tsx @@ -15,8 +15,7 @@ function PokemonDetails({ selectedPokemonId, setSelectedPokemonId, }: PokemonDetailsProps) { - - // TanStack Query is used to fetch the details of the selected Pokémon based on its ID. + // TanStack Query is used to fetch the details of the selected Pokémon based on its ID. const { data: selectedPokemon, isPending, @@ -27,10 +26,12 @@ function PokemonDetails({ enabled: selectedPokemonId !== null, // Query only runs if the selectedPokemonId is not null. }); -if(selectedPokemonId === 0) { - return
-

No Pokémon selected.

-
; + if (selectedPokemonId === 0) { + return ( +
+

No Pokémon selected.

+
+ ); } if (isPending) { @@ -41,6 +42,11 @@ if(selectedPokemonId === 0) { 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 (