Skip to content
27 changes: 24 additions & 3 deletions src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { getPokemonByID } from "./api/pokemon";
import { PokemonCard } from "./components/PokemonCard";
import { PokemonCard } from "./components/PokemonCard/PokemonCard";
import { Favorite } from "./components/Favorite/Favorite";

export default function App() {
const [pokemonId, setPokemonId] = useState(Math.ceil(Math.random() * 493));
// Start at a default ID
const [pokemonId, setPokemonId] = useState(1);

const query = useQuery({
queryKey: ["pokemon", pokemonId],
Expand All @@ -19,6 +21,14 @@ export default function App() {
setPokemonId((prev) => prev + 1);
};

const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = parseInt(e.target.value, 10);
// Ensure the user cannot enter negative numbers or invalid text
if (!isNaN(value) && value > 0) {
setPokemonId(value);
}
};

return (
<div
style={{
Expand All @@ -28,16 +38,27 @@ export default function App() {
gap: "16px",
}}
>
<div style={{ display: "flex", gap: "10px" }}>
<div style={{ display: "flex", gap: "10px", alignItems: "center" }}>
<button onClick={handlePrevious} disabled={pokemonId <= 1}>
&larr; Prev
</button>

<input
type="number"
value={pokemonId}
onChange={handleInputChange}
min="1"
style={{ width: "70px", textAlign: "center", padding: "4px" }}
aria-label="Pokemon ID"
/>

<button onClick={handleNext}>Next &rarr;</button>
</div>

{query.isPending && <h1>Loading...</h1>}
{query.isError && <h1>Failed to load Pokemon</h1>}
{query.data && <PokemonCard pokemon={query.data} />}
<Favorite pokemonId={pokemonId} />
</div>
);
}
25 changes: 25 additions & 0 deletions src/components/Favorite/Favorite.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
.favorite-button {
background: transparent;
border: none;
font-size: 1.5rem;
cursor: pointer;
padding: 0.5rem;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
transition: transform 0.2s ease-in-out;
}

.favorite-button:hover {
transform: scale(1.2);
}

.favorite-button:focus-visible {
outline: 2px solid #0056b3;
outline-offset: 2px;
}

.favorite-button:active {
transform: scale(0.9);
}
46 changes: 46 additions & 0 deletions src/components/Favorite/Favorite.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { useState } from "react";
import "./Favorite.css";

type FavoriteProps = {
pokemonId: number;
};

export function Favorite({ pokemonId }: FavoriteProps) {
// 1. Lazy initialization from localStorage
const [favorites, setFavorites] = useState<number[]>(() => {
try {
const storedFavorites = window.localStorage.getItem("favorites");
return storedFavorites ? JSON.parse(storedFavorites) : [];
} catch (error) {
console.error("Error reading localStorage", error);
return [];
}
});

const isFavorite = favorites.includes(pokemonId);

// 2. The click handler syncing State and Storage
const toggleFavorite = () => {
let updatedFavorites: number[];

if (isFavorite) {
updatedFavorites = favorites.filter((id) => id !== pokemonId);
} else {
updatedFavorites = [...favorites, pokemonId];
}

setFavorites(updatedFavorites);
window.localStorage.setItem("favorites", JSON.stringify(updatedFavorites));
};

// 3. Accessible UI rendering
return (
<button
onClick={toggleFavorite}
aria-label={isFavorite ? "Remove from favorites" : "Add to favorites"}
className="favorite-button"
>
{isFavorite ? "❤️" : "🤍"}
</button>
);
}
46 changes: 46 additions & 0 deletions src/components/FavoritePokemon/FavoritePokemon.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
.favorite-container {
width: 100%;
max-width: 400px;
margin: 0 auto;
}

.favorite-list {
list-style-type: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 12px;
}

.favorite-item {
display: flex;
align-items: center;
background-color: #f8f9fa;
border: 1px solid #e0e0e0;
border-radius: 8px;
padding: 8px 16px;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
}

.favorite-sprite {
width: 64px;
height: 64px;
margin-right: 16px;
}

.favorite-info {
display: flex;
flex-direction: column;
}

.dex-number {
font-size: 0.85rem;
color: #6c757d;
}

.pokemon-name {
font-size: 1.1rem;
font-weight: bold;
text-transform: capitalize;
}
68 changes: 68 additions & 0 deletions src/components/FavoritePokemon/FavoritePokemon.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import "./FavoritePokemon.css";

type PokemonData = {
id: number;
name: string;
sprites: {
front_default: string;
};
};

type FavoritePokemonProps = {
pokemonList?: PokemonData[];
};

const DUMMY_DATA: PokemonData[] = [
{
id: 1,
name: "bulbasaur",
sprites: {
front_default:
"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/1.png",
},
},
{
id: 4,
name: "charmander",
sprites: {
front_default:
"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/4.png",
},
},
{
id: 25,
name: "pikachu",
sprites: {
front_default:
"https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/25.png",
},
},
];

export function FavoritePokemon({ pokemonList }: FavoritePokemonProps) {
const listToRender =
pokemonList && pokemonList.length > 0 ? pokemonList : DUMMY_DATA;

return (
<div className="favorite-container">
<h2>My Favorites</h2>
<ul className="favorite-list">
{listToRender.map((pokemon) => (
<li key={pokemon.id} className="favorite-item">
<img
src={pokemon.sprites.front_default}
alt={pokemon.name}
className="favorite-sprite"
/>
<div className="favorite-info">
<span className="dex-number">
#{pokemon.id.toString().padStart(3, "0")}
</span>
<span className="pokemon-name">{pokemon.name}</span>
</div>
</li>
))}
</ul>
</div>
);
}
68 changes: 51 additions & 17 deletions src/components/PokemonCard/PokemonCard.css
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
div {
.pokemon-card {
display: flex;
flex-direction: column;
align-items: center;
Expand All @@ -9,38 +9,72 @@ div {
background: #fff;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
margin: 0 auto;
max-width: 500px;
}

h1 {
.pokemon-card h1 {
text-align: center;
margin-bottom: 10px;
}

img {
.pokemon-card img {
display: block;
margin: 0 auto 20px auto;
width: 120px;
height: 120px;
}

table {
.general-info {
width: 100%;
background: #f8f9fa;
padding: 16px;
border-radius: 8px;
margin-bottom: 24px;
display: flex;
flex-direction: column;
gap: 8px;
}

.info-row {
display: flex;
align-items: center;
gap: 8px;
font-size: 14px;
}

.type-badges {
display: flex;
gap: 6px;
}

.type-badge {
background: #333;
color: white;
padding: 2px 8px;
border-radius: 12px;
font-size: 12px;
text-transform: capitalize;
}

.stats-table {
display: grid;
grid-template-columns: auto auto 1fr auto auto;
width: 100%;
border-collapse: collapse;
}

tbody,
tfoot,
tr {
.stats-table tbody,
.stats-table tfoot,
.stats-table tr {
display: contents;
}

tr {
.stats-table tr {
border-bottom: 1px solid #eee;
}

th {
.stats-table th {
padding: 8px 4px;
font-weight: normal;
color: #666;
Expand All @@ -52,13 +86,13 @@ th {
white-space: nowrap;
}

th.cell-num:nth-child(1) {
.stats-table th.cell-num:nth-child(1) {
text-align: left;
color: #666;
font-weight: 500;
}

th.cell-num:nth-child(2) {
.stats-table th.cell-num:nth-child(2) {
font-weight: bold;
color: #333;
}
Expand All @@ -79,31 +113,31 @@ th.cell-num:nth-child(2) {
padding: 6px;
}

tfoot tr {
.stats-table tfoot tr {
border-bottom: none;
}

tfoot th {
.stats-table tfoot th {
color: #333;
font-weight: bold !important;
}

tfoot th:nth-child(1) {
.stats-table tfoot th:nth-child(1) {
grid-column: 1;
text-align: left;
}

tfoot th:nth-child(2) {
.stats-table tfoot th:nth-child(2) {
grid-column: 2;
text-align: right;
}

tfoot th:nth-child(3) {
.stats-table tfoot th:nth-child(3) {
grid-column: 4;
text-align: right;
}

tfoot th:nth-child(4) {
.stats-table tfoot th:nth-child(4) {
grid-column: 5;
text-align: right;
}
Loading