diff --git a/src/App.jsx b/src/App.jsx index 9bc0e21..87ecba6 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -42,6 +42,7 @@ import Pets from './pages/Pets.jsx'; import Covid from './pages/Covid.jsx'; import Navbar from './components/Navbar.jsx'; import ContributorsWall from './pages/Contributors.jsx' +import Pokedex from './pages/Pokedex.jsx'; // TODO: Extract theme state into context (see todo 5). import { useState, useEffect } from 'react'; @@ -79,7 +80,8 @@ export default function App() { } /> } /> } /> - } /> + } /> + } /> diff --git a/src/pages/Home.jsx b/src/pages/Home.jsx index a318450..e1be9cc 100644 --- a/src/pages/Home.jsx +++ b/src/pages/Home.jsx @@ -33,7 +33,9 @@ const dashboards = [ { path: '/jokes-quotes', title: 'Jokes & Quotes', desc: 'Random jokes & inspiration' }, { path: '/pets', title: 'Pets Images', desc: 'Random dog & cat images' }, { path: '/covid', title: 'COVID-19 Stats', desc: 'Global & country data' }, + { path: '/pokedex', title: 'Pokédex', desc: 'Explore Pokémon species' }, { path: '/contributors', title: 'Contributor Wall', desc: 'Our Contributors' }, + ]; export default function Home() { diff --git a/src/pages/Pokedex.jsx b/src/pages/Pokedex.jsx new file mode 100644 index 0000000..b4cf85b --- /dev/null +++ b/src/pages/Pokedex.jsx @@ -0,0 +1,387 @@ +// src/components/Pokedex.jsx +import React, { useEffect, useState, useRef, useMemo } from "react"; + +export default function Pokedex() { + const [pokemon, setPokemon] = useState([]); + const [search, setSearch] = useState(""); + const [loadingNames, setLoadingNames] = useState(true); + const [loadingDetails, setLoadingDetails] = useState(true); + const [allNames, setAllNames] = useState([]); + const debounceRef = useRef(null); + + // On mount: fetch all names (lightweight) and then load initial details for first page + useEffect(() => { + let mounted = true; + + async function init() { + try { + // fetch all names (small payload of names/urls) + const res = await fetch("https://pokeapi.co/api/v2/pokemon?limit=100000"); + const list = await res.json(); + if (!mounted) return; + const names = list.results.map((r) => r.name); + setAllNames(names); + setLoadingNames(false); + + // Fetch details for first 60 to populate initial UI + const initial = names.slice(0, 60); + await fetchDetailsForNames(initial); + } catch (e) { + console.error("fetch error", e); + setLoadingNames(false); + setLoadingDetails(false); + } + } + + init(); + return () => { + mounted = false; + }; + }, []); + + // Fetch details helper (accepts array of names) + async function fetchDetailsForNames(names) { + if (!names || names.length === 0) return; + setLoadingDetails(true); + try { + // Only fetch those we don't already have + const existing = new Set(pokemon.map((p) => p.name)); + const toFetch = names.filter((n) => !existing.has(n)); + if (toFetch.length === 0) return; + + const details = await Promise.all( + toFetch.map(async (name) => { + try { + const r = await fetch(`https://pokeapi.co/api/v2/pokemon/${name}`); + if (!r.ok) throw new Error(`not found ${name}`); + return await r.json(); + } catch (err) { + // swallow individual errors + return null; + } + }) + ); + + const valid = details.filter(Boolean); + if (valid.length > 0) { + setPokemon((prev) => { + // merge without duplicates + const map = new Map(prev.map((p) => [p.name, p])); + valid.forEach((d) => map.set(d.name, d)); + return Array.from(map.values()); + }); + } + } catch (e) { + console.error("detail fetch error", e); + } finally { + setLoadingDetails(false); + } + } + + // Build a map for quick lookup + const pokemonByName = useMemo(() => new Map(pokemon.map((p) => [p.name, p])), [pokemon]); + + // Determine the list of names to display based on search; cap results to 60 + const matchedNames = useMemo(() => { + const q = search.trim().toLowerCase(); + if (!q) return allNames.slice(0, 60); + return allNames.filter((n) => n.includes(q)).slice(0, 60); + }, [search, allNames]); + + // Array of pokemon details to render in the matched order + const filtered = matchedNames.map((n) => pokemonByName.get(n)).filter(Boolean); + + // Debounced effect: when `search` changes, fetch details for the top matches + useEffect(() => { + // clear previous debounce + if (debounceRef.current) { + clearTimeout(debounceRef.current); + } + + // If there's no names loaded yet, nothing to do + if (!allNames || allNames.length === 0) return; + + debounceRef.current = setTimeout(() => { + // find matches (top 40) + const q = search.trim().toLowerCase(); + const namesToLoad = q ? allNames.filter((n) => n.includes(q)).slice(0, 40) : allNames.slice(0, 40); + fetchDetailsForNames(namesToLoad); + }, 260); + + return () => { + if (debounceRef.current) clearTimeout(debounceRef.current); + }; + }, [search, allNames]); + + // Inline style objects for a few dynamic bits: + const containerStyle = { + minHeight: "100vh", + padding: "44px 20px", + display: "flex", + flexDirection: "column", + alignItems: "center", + color: "#111", + fontFamily: "'Inter', system-ui, -apple-system, 'Segoe UI', Roboto, 'Helvetica Neue', Arial", + }; + + const titleStyle = { + fontSize: "3rem", + fontWeight: 900, + color: "#fff", + letterSpacing: "3px", + textShadow: "0 6px 14px rgba(0,0,0,0.35)", + margin: "0 0 18px 0", + }; + + const inputStyle = { + width: "280px", + padding: "10px 14px", + borderRadius: "26px", + border: "none", + outline: "none", + textAlign: "center", + fontWeight: 700, + fontSize: "1rem", + marginBottom: "28px", + boxShadow: "0 6px 18px rgba(0,0,0,0.18)", + }; + + const gridStyle = { + display: "grid", + gridTemplateColumns: "repeat(auto-fit, minmax(180px, 1fr))", + gap: "24px", + width: "100%", + maxWidth: "1120px", + }; + + const cardBaseStyle = { + position: "relative", + borderRadius: "18px", + padding: "18px", + textAlign: "center", + overflow: "hidden", + cursor: "pointer", + transition: "transform .28s cubic-bezier(.2,.9,.2,1), box-shadow .28s ease, filter .28s ease", + boxShadow: "0 8px 22px rgba(0,0,0,0.18)", + willChange: "transform", + userSelect: "none", + }; + + return ( +
+ {/* Embedded CSS (keyframes, classes, responsive tweaks). This keeps everything in one file. */} + + +