From 8fc5431de8642c3f4ce16e102e227a1813167fee Mon Sep 17 00:00:00 2001 From: joelte Date: Wed, 16 Sep 2026 11:05:11 +0200 Subject: [PATCH 1/2] build: Implement Tanstack Query for fetching Implemented Tanstack Query for fetching from PokeAPI for easier error handling and request control --- PTG/package.json | 1 + PTG/pnpm-lock.yaml | 18 +++++ PTG/pnpm-workspace.yaml | 3 + PTG/src/ChoosePokemon.tsx | 46 ++++------- PTG/src/PokemonGrid.tsx | 157 +++++++++++++------------------------- PTG/src/main.tsx | 7 +- 6 files changed, 94 insertions(+), 138 deletions(-) create mode 100644 PTG/pnpm-workspace.yaml diff --git a/PTG/package.json b/PTG/package.json index 150a52b..a8969f0 100644 --- a/PTG/package.json +++ b/PTG/package.json @@ -10,6 +10,7 @@ "preview": "vite preview" }, "dependencies": { + "@tanstack/react-query": "^5.103.0", "react": "^19.2.8", "react-dom": "^19.2.8" }, diff --git a/PTG/pnpm-lock.yaml b/PTG/pnpm-lock.yaml index 4cc483a..0b445fa 100644 --- a/PTG/pnpm-lock.yaml +++ b/PTG/pnpm-lock.yaml @@ -8,6 +8,9 @@ importers: .: dependencies: + '@tanstack/react-query': + specifier: ^5.103.0 + version: 5.103.0(react@19.2.8) react: specifier: ^19.2.8 version: 19.2.8 @@ -327,6 +330,14 @@ packages: '@rolldown/pluginutils@1.0.1': resolution: {integrity: sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==} + '@tanstack/query-core@5.103.0': + resolution: {integrity: sha512-PfafnHQHEu7mZDsSalxSW/EBxPgF77k3atIJeQbqbb1Z7DmK2mH6YLJyqrvbbDk5M6Ua/egs7LLEs04a8robxA==} + + '@tanstack/react-query@5.103.0': + resolution: {integrity: sha512-Kn/cvTrNwFYpS/q1MYghMsVTSinmAKV1U2AnM3/x07PiczvC+nMoySqNpQK1jgDzVCOIHk5LlF2FhTlJKQmPAQ==} + peerDependencies: + react: ^18 || ^19 + '@types/babel__core@7.20.5': resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} @@ -1180,6 +1191,13 @@ snapshots: '@rolldown/pluginutils@1.0.1': {} + '@tanstack/query-core@5.103.0': {} + + '@tanstack/react-query@5.103.0(react@19.2.8)': + dependencies: + '@tanstack/query-core': 5.103.0 + react: 19.2.8 + '@types/babel__core@7.20.5': dependencies: '@babel/parser': 7.29.8 diff --git a/PTG/pnpm-workspace.yaml b/PTG/pnpm-workspace.yaml new file mode 100644 index 0000000..17a2321 --- /dev/null +++ b/PTG/pnpm-workspace.yaml @@ -0,0 +1,3 @@ +minimumReleaseAgeExclude: + - '@tanstack/query-core@5.103.0' + - '@tanstack/react-query@5.103.0' diff --git a/PTG/src/ChoosePokemon.tsx b/PTG/src/ChoosePokemon.tsx index d2e7c78..bbde5b8 100644 --- a/PTG/src/ChoosePokemon.tsx +++ b/PTG/src/ChoosePokemon.tsx @@ -1,7 +1,7 @@ -import { useEffect, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; import "./ChoosePokemon.css"; -import { fetchGames, type GameOption } from "./games"; -import { fetchTypes, type TypeOption } from "./pokemonTypes"; +import { fetchGames } from "./games"; +import { fetchTypes } from "./pokemonTypes"; type TypeChoice = { label: string, @@ -18,34 +18,14 @@ type ChoosePokemonProps = { } function ChoosePokemon({ selectedGame, selectedType, onGameChange, onTypeChange }: ChoosePokemonProps){ - const [games, setGames] = useState([]); - const [gamesError, setGamesError] = useState(null); - const [types, setTypes] = useState([]); - const [typesError, setTypesError] = useState(null); - - useEffect(() => { - let cancelled = false; - - fetchGames() - .then((data) => { - if (!cancelled) setGames(data); - }) - .catch((err: Error) => { - if (!cancelled) setGamesError(err.message); - }); - - fetchTypes() - .then((data) => { - if (!cancelled) setTypes(data); - }) - .catch((err: Error) => { - if (!cancelled) setTypesError(err.message); - }); - - return () => { - cancelled = true; - }; - }, []); + const { data: games = [], error: gamesError } = useQuery({ + queryKey: ["games"], + queryFn: fetchGames, + }); + const { data: types = [], error: typesError } = useQuery({ + queryKey: ["types"], + queryFn: fetchTypes, + }); function handleGameChange(e: React.ChangeEvent) { onGameChange(e.target.value); } @@ -53,8 +33,8 @@ function ChoosePokemon({ selectedGame, selectedType, onGameChange, onTypeChange const newTypes: TypeChoice[] = [ANY_TYPE, ...types]; - if (gamesError) return

Failed to load games: {gamesError}

; - if (typesError) return

Failed to load types: {typesError}

; + if (gamesError) return

Failed to load games: {gamesError.message}

; + if (typesError) return

Failed to load types: {typesError.message}

; return( <> diff --git a/PTG/src/PokemonGrid.tsx b/PTG/src/PokemonGrid.tsx index 081b8e5..b95cc90 100644 --- a/PTG/src/PokemonGrid.tsx +++ b/PTG/src/PokemonGrid.tsx @@ -1,4 +1,4 @@ -import { useEffect, useState } from "react"; +import { useQuery } from "@tanstack/react-query"; import "./PokemonGrid.css"; import { fetchGameSpeciesIds } from "./games"; import { fetchPokemonTypeMap, TYPE_ICON_URL, type PokemonType } from "./pokemonTypes"; @@ -19,118 +19,67 @@ function capitalize(name: string) { return name.charAt(0).toUpperCase() + name.slice(1); } +async function fetchPokemonList(): Promise { + const res = await fetch(LIST_URL); + if (!res.ok) throw new Error(`PokeAPI request failed: ${res.status}`); + const data: { results: { name: string; url: string }[] } = await res.json(); + return data.results.map(({ name, url }) => { + const id = Number(url.split("/").filter(Boolean).pop()); + return { id, name, image: ARTWORK_URL(id) }; + }); +} + +async function fetchTypeNames(type: string): Promise> { + const res = await fetch(TYPE_URL(type)); + if (!res.ok) throw new Error(`PokeAPI request failed: ${res.status}`); + const data: { pokemon: { pokemon: { name: string } }[] } = await res.json(); + return new Set(data.pokemon.map(({ pokemon }) => pokemon.name)); +} + type PokemonGridProps = { selectedGame: string; selectedType: string; }; export default function PokemonGrid({ selectedGame, selectedType }: PokemonGridProps) { - const [pokemon, setPokemon] = useState([]); - const [error, setError] = useState(null); - const [typeNames, setTypeNames] = useState | null>(null); - const [typeError, setTypeError] = useState(null); - const [gameIds, setGameIds] = useState | null>(null); - const [gameError, setGameError] = useState(null); - const [typeMap, setTypeMap] = useState | null>(null); - - useEffect(() => { - let cancelled = false; - - fetch(LIST_URL) - .then((res) => { - if (!res.ok) throw new Error(`PokeAPI request failed: ${res.status}`); - return res.json(); - }) - .then((data: { results: { name: string; url: string }[] }) => { - if (cancelled) return; - const list = data.results.map(({ name, url }) => { - const id = Number(url.split("/").filter(Boolean).pop()); - return { id, name, image: ARTWORK_URL(id) }; - }); - setPokemon(list); - }) - .catch((err: Error) => { - if (!cancelled) setError(err.message); - }); - - return () => { - cancelled = true; - }; - }, []); - - useEffect(() => { - let cancelled = false; - - // Decorative type icons; a failure here shouldn't block the rest of the grid. - fetchPokemonTypeMap() - .then((map) => { - if (!cancelled) setTypeMap(map); - }) - .catch((err: Error) => { - console.error("Failed to load Pokémon types:", err.message); - }); - - return () => { - cancelled = true; - }; - }, []); - - useEffect(() => { - let cancelled = false; - - if (!selectedType || selectedType === "any") { - setTypeNames(null); - setTypeError(null); - return; - } - - fetch(TYPE_URL(selectedType)) - .then((res) => { - if (!res.ok) throw new Error(`PokeAPI request failed: ${res.status}`); - return res.json(); - }) - .then((data: { pokemon: { pokemon: { name: string } }[] }) => { - if (cancelled) return; - setTypeNames(new Set(data.pokemon.map(({ pokemon }) => pokemon.name))); - setTypeError(null); - }) - .catch((err: Error) => { - if (!cancelled) setTypeError(err.message); - }); - - return () => { - cancelled = true; - }; - }, [selectedType]); - - useEffect(() => { - let cancelled = false; + const { + data: pokemon = [], + error: pokemonError, + } = useQuery({ + queryKey: ["pokemonList"], + queryFn: fetchPokemonList, + }); - if (!selectedGame) { - setGameIds(null); - setGameError(null); - return; - } + // Decorative type icons; a failure here shouldn't block the rest of the grid. + const { data: typeMap } = useQuery({ + queryKey: ["pokemonTypeMap"], + queryFn: fetchPokemonTypeMap, + retry: false, + throwOnError: false, + }); - fetchGameSpeciesIds(selectedGame) - .then((ids) => { - if (!cancelled) { - setGameIds(ids); - setGameError(null); - } - }) - .catch((err: Error) => { - if (!cancelled) setGameError(err.message); - }); + const isAnyType = !selectedType || selectedType === "any"; + const { + data: typeNames, + error: typeError, + } = useQuery({ + queryKey: ["pokemonTypeFilter", selectedType], + queryFn: () => fetchTypeNames(selectedType), + enabled: !isAnyType, + }); - return () => { - cancelled = true; - }; - }, [selectedGame]); + const { + data: gameIds, + error: gameError, + } = useQuery({ + queryKey: ["gameSpeciesIds", selectedGame], + queryFn: () => fetchGameSpeciesIds(selectedGame), + enabled: !!selectedGame, + }); - if (error) return

Failed to load Pokémon: {error}

; - if (typeError) return

Failed to load type: {typeError}

; - if (gameError) return

Failed to load game: {gameError}

; + if (pokemonError) return

Failed to load Pokémon: {pokemonError.message}

; + if (typeError) return

Failed to load type: {typeError.message}

; + if (gameError) return

Failed to load game: {gameError.message}

; // Each filter is resolved to a matching-id/name set independently above; a Pokémon must satisfy all of them. const filtered = pokemon.filter(({ id, name }) => { @@ -149,7 +98,7 @@ export default function PokemonGrid({ selectedGame, selectedType }: PokemonGridP {name} {types && types.length > 0 && (
- {types.map((type) => ( + {types.map((type: PokemonType) => ( {type.name} ))}
diff --git a/PTG/src/main.tsx b/PTG/src/main.tsx index bef5202..fbea0ce 100644 --- a/PTG/src/main.tsx +++ b/PTG/src/main.tsx @@ -1,10 +1,15 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import './index.css' import App from './App.tsx' +const queryClient = new QueryClient() + createRoot(document.getElementById('root')!).render( - + + + , ) From e3535c682780b87da2e010d2516eb67f4afe42b3 Mon Sep 17 00:00:00 2001 From: joelte Date: Wed, 16 Sep 2026 11:47:14 +0200 Subject: [PATCH 2/2] feat: Add pokemon team generation Added A slot for a pokemon team selected randomly from filtered pokemon with favourite functionality, --- PTG/src/App.tsx | 12 +++++ PTG/src/ChoosePokemon.tsx | 19 +------- PTG/src/PokemonGrid.tsx | 78 ++---------------------------- PTG/src/Team.css | 76 ++++++++++++++++++++++++++++++ PTG/src/Team.tsx | 47 ++++++++++++++++++ PTG/src/pokemonFilters.ts | 4 +- PTG/src/pokemonTeam.ts | 40 ++++++++++++++++ PTG/src/useFilteredPokemon.ts | 89 +++++++++++++++++++++++++++++++++++ PTG/src/usePokemonTeam.ts | 52 ++++++++++++++++++++ 9 files changed, 323 insertions(+), 94 deletions(-) create mode 100644 PTG/src/Team.css create mode 100644 PTG/src/Team.tsx create mode 100644 PTG/src/pokemonTeam.ts create mode 100644 PTG/src/useFilteredPokemon.ts create mode 100644 PTG/src/usePokemonTeam.ts diff --git a/PTG/src/App.tsx b/PTG/src/App.tsx index 4a616aa..02abff4 100644 --- a/PTG/src/App.tsx +++ b/PTG/src/App.tsx @@ -1,8 +1,10 @@ import { useEffect, useState } from "react"; import Header from "./Header.tsx" import ChoosePokemon from "./ChoosePokemon.tsx"; +import Team from "./Team.tsx"; import PokemonGrid from "./PokemonGrid.tsx"; import { loadStoredFilters, saveStoredFilters } from "./pokemonFilters.ts"; +import { usePokemonTeam } from "./usePokemonTeam.ts"; export default function App(){ const [selectedGame, setSelectedGame] = useState(() => loadStoredFilters().selectedGame); @@ -12,6 +14,8 @@ export default function App(){ saveStoredFilters({ selectedGame, selectedType }); }, [selectedGame, selectedType]); + const { team, pokemonById, typeMap, allFavourited, generateTeam, toggleFavourite } = usePokemonTeam(selectedGame, selectedType); + return(
@@ -21,6 +25,14 @@ export default function App(){ onGameChange={setSelectedGame} onTypeChange={setSelectedType} /> +
diff --git a/PTG/src/ChoosePokemon.tsx b/PTG/src/ChoosePokemon.tsx index bbde5b8..40b24c8 100644 --- a/PTG/src/ChoosePokemon.tsx +++ b/PTG/src/ChoosePokemon.tsx @@ -17,7 +17,7 @@ type ChoosePokemonProps = { onTypeChange: (value: string) => void; } -function ChoosePokemon({ selectedGame, selectedType, onGameChange, onTypeChange }: ChoosePokemonProps){ +export default function ChoosePokemon({ selectedGame, selectedType, onGameChange, onTypeChange }: ChoosePokemonProps){ const { data: games = [], error: gamesError } = useQuery({ queryKey: ["games"], queryFn: fetchGames, @@ -66,20 +66,3 @@ function ChoosePokemon({ selectedGame, selectedType, onGameChange, onTypeChange ); } - -// Filter controls plus the (not yet wired up) team generation button. -export default function generateTeam({ selectedGame, selectedType, onGameChange, onTypeChange }: ChoosePokemonProps){ - - return( - <> - - - - - ); -} diff --git a/PTG/src/PokemonGrid.tsx b/PTG/src/PokemonGrid.tsx index b95cc90..a109df3 100644 --- a/PTG/src/PokemonGrid.tsx +++ b/PTG/src/PokemonGrid.tsx @@ -1,96 +1,26 @@ -import { useQuery } from "@tanstack/react-query"; import "./PokemonGrid.css"; -import { fetchGameSpeciesIds } from "./games"; -import { fetchPokemonTypeMap, TYPE_ICON_URL, type PokemonType } from "./pokemonTypes"; - -const POKEMON_COUNT = 1025; -const LIST_URL = `https://pokeapi.co/api/v2/pokemon?limit=${POKEMON_COUNT}`; -const ARTWORK_URL = (id: number) => - `https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/other/official-artwork/${id}.png`; -const TYPE_URL = (type: string) => `https://pokeapi.co/api/v2/type/${type}`; - -type Pokemon = { - id: number; - name: string; - image: string; -}; +import { TYPE_ICON_URL, type PokemonType } from "./pokemonTypes"; +import { useFilteredPokemon } from "./useFilteredPokemon"; function capitalize(name: string) { return name.charAt(0).toUpperCase() + name.slice(1); } -async function fetchPokemonList(): Promise { - const res = await fetch(LIST_URL); - if (!res.ok) throw new Error(`PokeAPI request failed: ${res.status}`); - const data: { results: { name: string; url: string }[] } = await res.json(); - return data.results.map(({ name, url }) => { - const id = Number(url.split("/").filter(Boolean).pop()); - return { id, name, image: ARTWORK_URL(id) }; - }); -} - -async function fetchTypeNames(type: string): Promise> { - const res = await fetch(TYPE_URL(type)); - if (!res.ok) throw new Error(`PokeAPI request failed: ${res.status}`); - const data: { pokemon: { pokemon: { name: string } }[] } = await res.json(); - return new Set(data.pokemon.map(({ pokemon }) => pokemon.name)); -} - type PokemonGridProps = { selectedGame: string; selectedType: string; }; export default function PokemonGrid({ selectedGame, selectedType }: PokemonGridProps) { - const { - data: pokemon = [], - error: pokemonError, - } = useQuery({ - queryKey: ["pokemonList"], - queryFn: fetchPokemonList, - }); - - // Decorative type icons; a failure here shouldn't block the rest of the grid. - const { data: typeMap } = useQuery({ - queryKey: ["pokemonTypeMap"], - queryFn: fetchPokemonTypeMap, - retry: false, - throwOnError: false, - }); - - const isAnyType = !selectedType || selectedType === "any"; - const { - data: typeNames, - error: typeError, - } = useQuery({ - queryKey: ["pokemonTypeFilter", selectedType], - queryFn: () => fetchTypeNames(selectedType), - enabled: !isAnyType, - }); - - const { - data: gameIds, - error: gameError, - } = useQuery({ - queryKey: ["gameSpeciesIds", selectedGame], - queryFn: () => fetchGameSpeciesIds(selectedGame), - enabled: !!selectedGame, - }); + const { pokemon, typeMap, pokemonError, typeError, gameError } = useFilteredPokemon(selectedGame, selectedType); if (pokemonError) return

Failed to load Pokémon: {pokemonError.message}

; if (typeError) return

Failed to load type: {typeError.message}

; if (gameError) return

Failed to load game: {gameError.message}

; - // Each filter is resolved to a matching-id/name set independently above; a Pokémon must satisfy all of them. - const filtered = pokemon.filter(({ id, name }) => { - if (gameIds && !gameIds.has(id)) return false; - if (typeNames && !typeNames.has(name)) return false; - return true; - }); - return (
- {filtered.map(({ id, name, image }) => { + {pokemon.map(({ id, name, image }) => { const types = typeMap?.get(name); return (
diff --git a/PTG/src/Team.css b/PTG/src/Team.css new file mode 100644 index 0000000..8183fb4 --- /dev/null +++ b/PTG/src/Team.css @@ -0,0 +1,76 @@ +.generateTeamButton:disabled { + color: gray; +} + +.teamGrid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); + gap: 1rem; + padding: 1rem; +} + +.teamCard { + position: relative; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: 160px; + border: 1px solid #ddd; + border-radius: 8px; + padding: 0.5rem; + text-align: center; +} + +.teamCardImage { + position: relative; + width: 100%; + max-width: 120px; + padding-top: 24px; +} + +.teamCardImage > img { + width: 100%; + height: auto; + display: block; +} + +.teamCardTypes { + position: absolute; + top: 2px; + left: 2px; + display: flex; + gap: 2px; +} + +.teamCardTypes img { + width: 20px; + height: 20px; + filter: drop-shadow(0 0 2px rgba(0, 0, 0, 0.6)); +} + +.teamCardFavourite { + position: absolute; + top: 2px; + right: 2px; + padding: 0; + border: none; + background: none; + font-size: 1.25rem; + line-height: 1; + color: #ccc; + cursor: pointer; +} + +.teamCardFavourite:disabled { + color: #e5e5e5; + cursor: not-allowed; +} + +.teamCardFavourite.isFavourited { + color: gold; +} + +.teamCardEmpty { + color: #999; +} diff --git a/PTG/src/Team.tsx b/PTG/src/Team.tsx new file mode 100644 index 0000000..37726d3 --- /dev/null +++ b/PTG/src/Team.tsx @@ -0,0 +1,47 @@ +import "./Team.css"; +import { TYPE_ICON_URL, type PokemonType } from "./pokemonTypes"; +import type { PokemonTeam } from "./usePokemonTeam"; + +function capitalize(name: string) { + return name.charAt(0).toUpperCase() + name.slice(1); +} + +type TeamProps = Pick; + +export default function Team({ team, pokemonById, typeMap, allFavourited, generateTeam, toggleFavourite }: TeamProps) { + return ( + <> + +
+ {team.map((slot, index) => { + const member = slot.id !== null ? pokemonById.get(slot.id) : undefined; + const types = member ? typeMap?.get(member.name) : undefined; + return ( +
+
+ {member && {member.name}} + + {types && types.length > 0 && ( +
+ {types.map((type: PokemonType) => ( + {type.name} + ))} +
+ )} +
+ {member ? capitalize(member.name) : "Empty"} +
+ ); + })} +
+ + ); +} diff --git a/PTG/src/pokemonFilters.ts b/PTG/src/pokemonFilters.ts index e0cc609..2a9c7fd 100644 --- a/PTG/src/pokemonFilters.ts +++ b/PTG/src/pokemonFilters.ts @@ -9,7 +9,7 @@ const DEFAULT_FILTERS: PokemonFilters = { selectedGame: "", selectedType: "any" export function loadStoredFilters(): PokemonFilters { try { - const raw = localStorage.getItem(STORAGE_KEY); + const raw = sessionStorage.getItem(STORAGE_KEY); if (!raw) return DEFAULT_FILTERS; const parsed = JSON.parse(raw); return { @@ -23,7 +23,7 @@ export function loadStoredFilters(): PokemonFilters { export function saveStoredFilters(filters: PokemonFilters) { try { - localStorage.setItem(STORAGE_KEY, JSON.stringify(filters)); + sessionStorage.setItem(STORAGE_KEY, JSON.stringify(filters)); } catch { // storage unavailable - filter still works for this session } diff --git a/PTG/src/pokemonTeam.ts b/PTG/src/pokemonTeam.ts new file mode 100644 index 0000000..35a89bb --- /dev/null +++ b/PTG/src/pokemonTeam.ts @@ -0,0 +1,40 @@ +const STORAGE_KEY = "pokemonTeam"; + +export const TEAM_SIZE = 6; + +export type TeamSlot = { + id: number | null; + favourite: boolean; +}; + +export function createBlankTeam(): TeamSlot[] { + return Array.from({ length: TEAM_SIZE }, () => ({ id: null, favourite: false })); +} + +function isTeamSlot(value: unknown): value is TeamSlot { + if (typeof value !== "object" || value === null) return false; + const slot = value as Record; + return (slot.id === null || typeof slot.id === "number") && typeof slot.favourite === "boolean"; +} + +export function loadStoredTeam(): TeamSlot[] { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) return createBlankTeam(); + const parsed: unknown = JSON.parse(raw); + if (!Array.isArray(parsed) || parsed.length !== TEAM_SIZE || !parsed.every(isTeamSlot)) { + return createBlankTeam(); + } + return parsed; + } catch { + return createBlankTeam(); + } +} + +export function saveStoredTeam(team: TeamSlot[]) { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(team)); + } catch { + // storage unavailable - team still works for this session + } +} diff --git a/PTG/src/useFilteredPokemon.ts b/PTG/src/useFilteredPokemon.ts new file mode 100644 index 0000000..8ec5c10 --- /dev/null +++ b/PTG/src/useFilteredPokemon.ts @@ -0,0 +1,89 @@ +import { useQuery } from "@tanstack/react-query"; +import { fetchGameSpeciesIds } from "./games"; +import { fetchPokemonTypeMap, type PokemonType } from "./pokemonTypes"; + +const POKEMON_COUNT = 1025; +const LIST_URL = `https://pokeapi.co/api/v2/pokemon?limit=${POKEMON_COUNT}`; +const ARTWORK_URL = (id: number) => + `https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/other/official-artwork/${id}.png`; +const TYPE_URL = (type: string) => `https://pokeapi.co/api/v2/type/${type}`; + +export type Pokemon = { + id: number; + name: string; + image: string; +}; + +async function fetchPokemonList(): Promise { + const res = await fetch(LIST_URL); + if (!res.ok) throw new Error(`PokeAPI request failed: ${res.status}`); + const data: { results: { name: string; url: string }[] } = await res.json(); + return data.results.map(({ name, url }) => { + const id = Number(url.split("/").filter(Boolean).pop()); + return { id, name, image: ARTWORK_URL(id) }; + }); +} + +async function fetchTypeNames(type: string): Promise> { + const res = await fetch(TYPE_URL(type)); + if (!res.ok) throw new Error(`PokeAPI request failed: ${res.status}`); + const data: { pokemon: { pokemon: { name: string } }[] } = await res.json(); + return new Set(data.pokemon.map(({ pokemon }) => pokemon.name)); +} + +// The full Pokémon list, shared (and cached) across every component that needs it. +export function usePokemonList() { + return useQuery({ + queryKey: ["pokemonList"], + queryFn: fetchPokemonList, + }); +} + +export type FilteredPokemon = { + pokemon: Pokemon[]; + typeMap: Map | undefined; + pokemonError: Error | null; + typeError: Error | null; + gameError: Error | null; +}; + +// Resolves the current game/type filters against the full Pokémon list. +export function useFilteredPokemon(selectedGame: string, selectedType: string): FilteredPokemon { + const { data: pokemon = [], error: pokemonError } = usePokemonList(); + + // Decorative type icons. A failure here shouldn't block the rest of the grid. + const { data: typeMap } = useQuery({ + queryKey: ["pokemonTypeMap"], + queryFn: fetchPokemonTypeMap, + retry: false, + throwOnError: false, + }); + + const isAnyType = !selectedType || selectedType === "any"; + const { data: typeNames, error: typeError } = useQuery({ + queryKey: ["pokemonTypeFilter", selectedType], + queryFn: () => fetchTypeNames(selectedType), + enabled: !isAnyType, + }); + + const { data: gameIds, error: gameError } = useQuery({ + queryKey: ["gameSpeciesIds", selectedGame], + queryFn: () => fetchGameSpeciesIds(selectedGame), + enabled: !!selectedGame, + }); + + // Each filter is resolved to a matching-id/name set independently above. A Pokemon must satisfy all of them. + const filtered = pokemon.filter(({ id, name }) => { + if (gameIds && !gameIds.has(id)) return false; + if (typeNames && !typeNames.has(name)) return false; + return true; + }); + + return { + pokemon: filtered, + typeMap, + pokemonError, + typeError, + gameError, + }; +} diff --git a/PTG/src/usePokemonTeam.ts b/PTG/src/usePokemonTeam.ts new file mode 100644 index 0000000..deb003f --- /dev/null +++ b/PTG/src/usePokemonTeam.ts @@ -0,0 +1,52 @@ +import { useEffect, useState } from "react"; +import { loadStoredTeam, saveStoredTeam, type TeamSlot } from "./pokemonTeam"; +import { useFilteredPokemon, usePokemonList, type Pokemon } from "./useFilteredPokemon"; +import type { PokemonType } from "./pokemonTypes"; + +export type PokemonTeam = { + team: TeamSlot[]; + pokemonById: Map; + typeMap: Map | undefined; + allFavourited: boolean; + generateTeam: () => void; + toggleFavourite: (index: number) => void; +}; + +export function usePokemonTeam(selectedGame: string, selectedType: string): PokemonTeam { + const [team, setTeam] = useState(() => loadStoredTeam()); + + // The team can hold Pokémon that fall outside the current filter (picked before the filter changed), + // so members are looked up from the full list, while new picks are drawn from the filtered one. + const { data: allPokemon = [] } = usePokemonList(); + const { pokemon: filteredPokemon, typeMap } = useFilteredPokemon(selectedGame, selectedType); + + useEffect(() => { + saveStoredTeam(team); + }, [team]); + + const pokemonById = new Map(allPokemon.map((entry) => [entry.id, entry])); + const allFavourited = team.every((slot) => slot.favourite); + + function generateTeam() { + const favouriteIds = new Set(team.filter((slot) => slot.favourite).map((slot) => slot.id)); + const available = filteredPokemon.filter(({ id }) => !favouriteIds.has(id)); + + setTeam((current) => + current.map((slot) => { + if (slot.favourite) return slot; + if (available.length === 0) return { ...slot, id: null }; + const index = Math.floor(Math.random() * available.length); + const [picked] = available.splice(index, 1); + return { ...slot, id: picked.id }; + }) + ); + } + + function toggleFavourite(index: number) { + setTeam((current) => + current.map((slot, i) => (i === index && slot.id !== null ? { ...slot, favourite: !slot.favourite } : slot)) + ); + } + + return { team, pokemonById, typeMap, allFavourited, generateTeam, toggleFavourite }; +}