diff --git a/PTG/src/App.tsx b/PTG/src/App.tsx index 18749fd..4a616aa 100644 --- a/PTG/src/App.tsx +++ b/PTG/src/App.tsx @@ -1,13 +1,28 @@ +import { useEffect, useState } from "react"; import Header from "./Header.tsx" import ChoosePokemon from "./ChoosePokemon.tsx"; +import PokemonGrid from "./PokemonGrid.tsx"; +import { loadStoredFilters, saveStoredFilters } from "./pokemonFilters.ts"; export default function App(){ - + const [selectedGame, setSelectedGame] = useState(() => loadStoredFilters().selectedGame); + const [selectedType, setSelectedType] = useState(() => loadStoredFilters().selectedType); + + useEffect(() => { + saveStoredFilters({ selectedGame, selectedType }); + }, [selectedGame, selectedType]); + return(
- + +
- ); -} \ No newline at end of file + ); +} diff --git a/PTG/src/ChoosePokemon.css b/PTG/src/ChoosePokemon.css new file mode 100644 index 0000000..d3edf93 --- /dev/null +++ b/PTG/src/ChoosePokemon.css @@ -0,0 +1,5 @@ +.chooseType { + field-sizing: content; + width: fit-content; + max-width: 100%; +} diff --git a/PTG/src/ChoosePokemon.tsx b/PTG/src/ChoosePokemon.tsx index 92e014b..d2e7c78 100644 --- a/PTG/src/ChoosePokemon.tsx +++ b/PTG/src/ChoosePokemon.tsx @@ -1,76 +1,72 @@ -import {useState} from "react" - - - -function ChoosePokemon(){ - const [selectedGen, setSelectedGen] = useState(''); - const [selectedType, setSelectedType] = useState(''); - - type Gen = { - label: string, - value: string - } - - let gens: Gen[] = [ - {label: 'Gen I', value: 'gen_i'}, - {label: 'Gen II', value: 'gen_ii'}, - {label: 'Gen III', value: 'gen_iii'}, - {label: 'Gen IV', value: 'gen_iv'}, - {label: 'Gen V', value: 'gen_v'}, - {label: 'Gen VI', value: 'gen_vi'}, - {label: 'Gen VII', value: 'gen_vii'}, - {label: 'Gen VIII', value: 'gen_viii'}, - {label: 'Gen IX', value: 'gen_ix'}, - ] - - let types: Gen[] = [ - {label: 'Any', value: 'any'}, - {label: 'Fire', value: 'fire'}, - {label: 'Water', value: 'water'}, - {label: 'Grass', value: 'grass'}, - {label: 'Electric', value: 'electric'}, - {label: 'Rock', value: 'rock'}, - {label: 'Ground', value: 'ground'}, - {label: 'Flying', value: 'flying'}, - {label: 'Dragon', value: 'dragon'}, - {label: 'Bug', value: 'bug'}, - {label: 'Poison', value: 'poison'}, - {label: 'Psychic', value: 'psychic'}, - {label: 'Normal', value: 'normal'}, - {label: 'Fighting', value: 'fighting'}, - {label: 'Ghost', value: 'ghost'}, - {label: 'Ice', value: 'ice'}, - {label: 'Steel', value: 'steel'}, - {label: 'Dark', value: 'dark'}, - {label: 'Fairy', value: 'fairy'} - ] - - function changeTypeArray(){ - if (selectedGen === 'gen_i') return types.slice(0, 16); - else if (selectedGen === 'gen_ii' || selectedGen === 'gen_iii' || - selectedGen === 'gen_iv' || selectedGen === 'gen_v'){ - return types.slice(0, 18); - } - return types; - } - - function handleGenChange(e: any) { setSelectedGen(e.target.value); } - - function handleTypeChange(e: any) { setSelectedType(e.target.value); } - - let newTypes : Gen[] = changeTypeArray(); +import { useEffect, useState } from "react"; +import "./ChoosePokemon.css"; +import { fetchGames, type GameOption } from "./games"; +import { fetchTypes, type TypeOption } from "./pokemonTypes"; + +type TypeChoice = { + label: string, + value: string +} + +const ANY_TYPE: TypeChoice = { label: 'Any Type', value: 'any' }; + +type ChoosePokemonProps = { + selectedGame: string; + selectedType: string; + onGameChange: (value: string) => void; + onTypeChange: (value: string) => void; +} + +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; + }; + }, []); + + function handleGameChange(e: React.ChangeEvent) { onGameChange(e.target.value); } + + function handleTypeChange(e: React.ChangeEvent) { onTypeChange(e.target.value); } + + const newTypes: TypeChoice[] = [ANY_TYPE, ...types]; + + if (gamesError) return

Failed to load games: {gamesError}

; + if (typesError) return

Failed to load types: {typesError}

; return( <> @@ -86,18 +82,24 @@ function ChoosePokemon(){ ))} - + ); } -export default function generateTeam(){ - +// Filter controls plus the (not yet wired up) team generation button. +export default function generateTeam({ selectedGame, selectedType, onGameChange, onTypeChange }: ChoosePokemonProps){ + return( <> - + - + ); -} \ No newline at end of file +} diff --git a/PTG/src/PokemonGrid.css b/PTG/src/PokemonGrid.css new file mode 100644 index 0000000..8d25505 --- /dev/null +++ b/PTG/src/PokemonGrid.css @@ -0,0 +1,47 @@ +.pokemonGrid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); + gap: 1rem; + padding: 1rem; +} + +.pokemonCard { + display: flex; + flex-direction: column; + align-items: center; + border: 1px solid #ddd; + border-radius: 8px; + padding: 0.5rem; + text-align: center; +} + +.pokemonCardImage { + position: relative; + width: 100%; + max-width: 120px; +} + +.pokemonCardImage > img { + width: 100%; + height: auto; + display: block; +} + +.pokemonCardTypes { + position: absolute; + top: 2px; + left: 2px; + display: flex; + gap: 2px; +} + +.pokemonCardTypes img { + width: 20px; + height: 20px; + filter: drop-shadow(0 0 2px rgba(0, 0, 0, 0.6)); +} + +.pokemonGridError { + color: red; + text-align: center; +} diff --git a/PTG/src/PokemonGrid.tsx b/PTG/src/PokemonGrid.tsx new file mode 100644 index 0000000..081b8e5 --- /dev/null +++ b/PTG/src/PokemonGrid.tsx @@ -0,0 +1,164 @@ +import { useEffect, useState } from "react"; +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; +}; + +function capitalize(name: string) { + return name.charAt(0).toUpperCase() + name.slice(1); +} + +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; + + if (!selectedGame) { + setGameIds(null); + setGameError(null); + return; + } + + fetchGameSpeciesIds(selectedGame) + .then((ids) => { + if (!cancelled) { + setGameIds(ids); + setGameError(null); + } + }) + .catch((err: Error) => { + if (!cancelled) setGameError(err.message); + }); + + return () => { + cancelled = true; + }; + }, [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}

; + + // 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 }) => { + const types = typeMap?.get(name); + return ( +
+
+ {name} + {types && types.length > 0 && ( +
+ {types.map((type) => ( + {type.name} + ))} +
+ )} +
+ {capitalize(name)} +
+ ); + })} +
+ ); +} diff --git a/PTG/src/games.ts b/PTG/src/games.ts new file mode 100644 index 0000000..1e375a8 --- /dev/null +++ b/PTG/src/games.ts @@ -0,0 +1,67 @@ +const VERSION_GROUP_LIST_URL = "https://pokeapi.co/api/v2/version-group?limit=100"; +const VERSION_GROUP_URL = (name: string) => `https://pokeapi.co/api/v2/version-group/${name}`; + +export type GameOption = { + label: string; + value: string; +}; + +function idFromUrl(url: string): number { + return Number(url.split("/").filter(Boolean).pop()); +} + +function toLabel(name: string): string { + return name + .split("-") + .map((word) => word.charAt(0).toUpperCase() + word.slice(1)) + .join(" "); +} + +type VersionGroup = { + name: string; + url: string; +}; + +type VersionGroupDetail = { + pokedexes: { name: string; url: string }[]; +}; + +export async function fetchGames(): Promise { + const res = await fetch(VERSION_GROUP_LIST_URL); + if (!res.ok) throw new Error(`PokeAPI request failed: ${res.status}`); + const data: { results: VersionGroup[] } = await res.json(); + + const details = await Promise.all( + data.results.map(async (versionGroup) => { + const detailRes = await fetch(versionGroup.url); + if (!detailRes.ok) throw new Error(`PokeAPI request failed: ${detailRes.status}`); + const detail: VersionGroupDetail = await detailRes.json(); + return { versionGroup, detail }; + }) + ); + + return details + .filter(({ detail }) => detail.pokedexes.length > 0) // exclude spin-off version groups with no pokedex + .sort((a, b) => idFromUrl(a.versionGroup.url) - idFromUrl(b.versionGroup.url)) + .map(({ versionGroup }) => ({ + label: toLabel(versionGroup.name), + value: versionGroup.name, + })); +} + +export async function fetchGameSpeciesIds(name: string): Promise> { + const res = await fetch(VERSION_GROUP_URL(name)); + if (!res.ok) throw new Error(`PokeAPI request failed: ${res.status}`); + const data: VersionGroupDetail = await res.json(); + + const idLists = await Promise.all( + data.pokedexes.map(async (pokedex) => { + const pokedexRes = await fetch(pokedex.url); + if (!pokedexRes.ok) throw new Error(`PokeAPI request failed: ${pokedexRes.status}`); + const pokedexData: { pokemon_entries: { pokemon_species: { url: string } }[] } = await pokedexRes.json(); + return pokedexData.pokemon_entries.map(({ pokemon_species }) => idFromUrl(pokemon_species.url)); + }) + ); + + return new Set(idLists.flat()); +} diff --git a/PTG/src/pokemonFilters.ts b/PTG/src/pokemonFilters.ts new file mode 100644 index 0000000..e0cc609 --- /dev/null +++ b/PTG/src/pokemonFilters.ts @@ -0,0 +1,30 @@ +const STORAGE_KEY = "pokemonFilters"; + +export type PokemonFilters = { + selectedGame: string; + selectedType: string; +}; + +const DEFAULT_FILTERS: PokemonFilters = { selectedGame: "", selectedType: "any" }; + +export function loadStoredFilters(): PokemonFilters { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) return DEFAULT_FILTERS; + const parsed = JSON.parse(raw); + return { + selectedGame: typeof parsed.selectedGame === "string" ? parsed.selectedGame : DEFAULT_FILTERS.selectedGame, + selectedType: typeof parsed.selectedType === "string" ? parsed.selectedType : DEFAULT_FILTERS.selectedType, + }; + } catch { + return DEFAULT_FILTERS; + } +} + +export function saveStoredFilters(filters: PokemonFilters) { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(filters)); + } catch { + // storage unavailable - filter still works for this session + } +} diff --git a/PTG/src/pokemonTypes.ts b/PTG/src/pokemonTypes.ts new file mode 100644 index 0000000..6f20f19 --- /dev/null +++ b/PTG/src/pokemonTypes.ts @@ -0,0 +1,82 @@ +// Type data pulled live from PokeAPI, so new types (and the generation +// that introduces them) show up automatically. +const TYPE_LIST_URL = "https://pokeapi.co/api/v2/type?limit=100"; +const TYPE_URL = (name: string) => `https://pokeapi.co/api/v2/type/${name}`; + +// "unknown" (???) and "shadow" are not real battle types you can filter by. +const NON_BATTLE_TYPE_ID = 10000; + +// Official Scarlet/Violet type icon, indexed by the same numeric type id PokeAPI uses. +export const TYPE_ICON_URL = (id: number) => + `https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/types/generation-ix/scarlet-violet/small/${id}.png`; + +export type TypeOption = { + label: string; + value: string; +}; + +export type PokemonType = { + id: number; + name: string; +}; + +function idFromUrl(url: string): number { + return Number(url.split("/").filter(Boolean).pop()); +} + +function toLabel(name: string): string { + return name.charAt(0).toUpperCase() + name.slice(1); +} + +export async function fetchTypes(): Promise { + const listRes = await fetch(TYPE_LIST_URL); + if (!listRes.ok) throw new Error(`PokeAPI request failed: ${listRes.status}`); + const listData: { results: { name: string; url: string }[] } = await listRes.json(); + + return listData.results + .filter(({ url }) => idFromUrl(url) < NON_BATTLE_TYPE_ID) + .sort((a, b) => idFromUrl(a.url) - idFromUrl(b.url)) + .map(({ name }) => ({ label: toLabel(name), value: name })); +} + +type TypeDetail = { + id: number; + name: string; + pokemon: { slot: number; pokemon: { name: string } }[]; +}; + +// Maps each Pokemon name to its types, ordered by slot (slot 1 = primary type). +export async function fetchPokemonTypeMap(): Promise> { + const listRes = await fetch(TYPE_LIST_URL); + if (!listRes.ok) throw new Error(`PokeAPI request failed: ${listRes.status}`); + const listData: { results: { name: string; url: string }[] } = await listRes.json(); + const battleTypes = listData.results.filter(({ url }) => idFromUrl(url) < NON_BATTLE_TYPE_ID); + + const details = await Promise.all( + battleTypes.map(async ({ name }) => { + const res = await fetch(TYPE_URL(name)); + if (!res.ok) throw new Error(`PokeAPI request failed: ${res.status}`); + const data: TypeDetail = await res.json(); + return data; + }) + ); + + const bySlot = new Map(); + for (const type of details) { + for (const { slot, pokemon } of type.pokemon) { + const entries = bySlot.get(pokemon.name) ?? []; + entries.push({ slot, type: { id: type.id, name: type.name } }); + bySlot.set(pokemon.name, entries); + } + } + + const typeMap = new Map(); + for (const [name, entries] of bySlot) { + typeMap.set( + name, + entries.sort((a, b) => a.slot - b.slot).map((entry) => entry.type) + ); + } + + return typeMap; +}