diff --git a/PTG/package.json b/PTG/package.json index 150a52b..1c7e73d 100644 --- a/PTG/package.json +++ b/PTG/package.json @@ -11,7 +11,9 @@ }, "dependencies": { "react": "^19.2.8", - "react-dom": "^19.2.8" + "react-dom": "^19.2.8", + "react-router": "^8.3.1", + "react-router-dom": "^7.18.3" }, "devDependencies": { "@babel/core": "^7.29.7", diff --git a/PTG/pnpm-lock.yaml b/PTG/pnpm-lock.yaml index 4cc483a..7f0f7c2 100644 --- a/PTG/pnpm-lock.yaml +++ b/PTG/pnpm-lock.yaml @@ -14,6 +14,12 @@ importers: react-dom: specifier: ^19.2.8 version: 19.2.8(react@19.2.8) + react-router: + specifier: ^8.3.1 + version: 8.3.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + react-router-dom: + specifier: ^7.18.3 + version: 7.18.3(react-dom@19.2.8(react@19.2.8))(react@19.2.8) devDependencies: '@babel/core': specifier: ^7.29.7 @@ -474,6 +480,13 @@ packages: convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + cookie-es@3.1.1: + resolution: {integrity: sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg==} + + cookie@1.1.1: + resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==} + engines: {node: '>=18'} + cross-spawn@7.0.6: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} @@ -813,6 +826,33 @@ packages: peerDependencies: react: ^19.2.8 + react-router-dom@7.18.3: + resolution: {integrity: sha512-ytVbyBBM7vMfRCam25r0WMhSVSom909A8p+8m0/f1w853dz/xfFu6etAT2SEbVoSnI+ZoPRDqIsQXVT89gp7kg==} + engines: {node: '>=20.0.0'} + peerDependencies: + react: '>=18' + react-dom: '>=18' + + react-router@7.18.3: + resolution: {integrity: sha512-gyXgtdr5uACJ5b1Q4udzjVV+tb/rlHIMJKuJ0e89R4Kzgz47z/rgP0dIKxktqIEUhDHluGTPJJH/wRha7CyqsA==} + engines: {node: '>=20.0.0'} + peerDependencies: + react: '>=18' + react-dom: '>=18' + peerDependenciesMeta: + react-dom: + optional: true + + react-router@8.3.1: + resolution: {integrity: sha512-TEOpiO2g0TJHEOJeRVv4amUFun9v1npCKszvcquNvzETUtJ8udV86ah5eFoHT7g26bsBvT6EiIhqulR8eDF++A==} + engines: {node: '>=22.22.0'} + peerDependencies: + react: '>=19.2.7' + react-dom: '>=19.2.7' + peerDependenciesMeta: + react-dom: + optional: true + react@19.2.8: resolution: {integrity: sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw==} engines: {node: '>=0.10.0'} @@ -834,6 +874,9 @@ packages: engines: {node: '>=10'} hasBin: true + set-cookie-parser@2.7.2: + resolution: {integrity: sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==} + shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -1355,6 +1398,10 @@ snapshots: convert-source-map@2.0.0: {} + cookie-es@3.1.1: {} + + cookie@1.1.1: {} + cross-spawn@7.0.6: dependencies: path-key: 3.1.1 @@ -1643,6 +1690,27 @@ snapshots: react: 19.2.8 scheduler: 0.27.0 + react-router-dom@7.18.3(react-dom@19.2.8(react@19.2.8))(react@19.2.8): + dependencies: + react: 19.2.8 + react-dom: 19.2.8(react@19.2.8) + react-router: 7.18.3(react-dom@19.2.8(react@19.2.8))(react@19.2.8) + + react-router@7.18.3(react-dom@19.2.8(react@19.2.8))(react@19.2.8): + dependencies: + cookie: 1.1.1 + react: 19.2.8 + set-cookie-parser: 2.7.2 + optionalDependencies: + react-dom: 19.2.8(react@19.2.8) + + react-router@8.3.1(react-dom@19.2.8(react@19.2.8))(react@19.2.8): + dependencies: + cookie-es: 3.1.1 + react: 19.2.8 + optionalDependencies: + react-dom: 19.2.8(react@19.2.8) + react@19.2.8: {} rolldown@1.2.7: @@ -1672,6 +1740,8 @@ snapshots: semver@7.8.5: {} + set-cookie-parser@2.7.2: {} + shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 diff --git a/PTG/src/About.tsx b/PTG/src/About.tsx new file mode 100644 index 0000000..c4016b3 --- /dev/null +++ b/PTG/src/About.tsx @@ -0,0 +1,22 @@ +import Header from "./Header.tsx" + + +export default function About(){ + return( + <> +
+

About

+

+ This is the result of a group project in IT2810 at NTNU. + Yes, we are aware that each Pokédex takes in National Dex as well, so + it is possible getting, e.g. Charizard when picking HG/SS. + This results in end game Pokémon showing up in the Team Generator. + Please be aware of this when generating a team. +

+

+ This website is made by students, and is made for training reasons, + also for lazy people not wanting to pick a team themselves. +

+ + ); +} \ No newline at end of file diff --git a/PTG/src/App.tsx b/PTG/src/App.tsx index 18749fd..165597b 100644 --- a/PTG/src/App.tsx +++ b/PTG/src/App.tsx @@ -1,13 +1,31 @@ +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 [selectedGeneration, setSelectedGeneration] = useState(() => loadStoredFilters().selectedGeneration); + const [selectedType, setSelectedType] = useState(() => loadStoredFilters().selectedType); + + useEffect(() => { + saveStoredFilters({ selectedGame, selectedGeneration, selectedType }); + }, [selectedGame, selectedGeneration, 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..1ec5248 100644 --- a/PTG/src/ChoosePokemon.tsx +++ b/PTG/src/ChoosePokemon.tsx @@ -1,76 +1,150 @@ -import {useState} from "react" +import { useEffect, useState } from "react"; +import "./ChoosePokemon.css"; +import { fetchGames, type GameOption } from "./games"; +import { fetchGenerations, type GenerationOption } from "./generations"; +import { fetchTypes, type TypeOption } from "./pokemonTypes"; +type TypeChoice = { + label: string, + value: string +} + +const ANY_TYPE: TypeChoice = { label: 'Any Type', value: 'any' }; + +// Earliest generation implied by the selected game and/or generation filter (-1 if neither is set). +function effectiveGenerationIndex( + selectedGame: string, + games: GameOption[], + selectedGeneration: string, + generations: GenerationOption[] +): number { + const indices: number[] = []; + + const game = games.find((g) => g.value === selectedGame); + if (game) { + const gameGenIndex = generations.findIndex((gen) => gen.value === game.generation); + if (gameGenIndex !== -1) indices.push(gameGenIndex); + } + + if (selectedGeneration) { + const generationIndex = generations.findIndex((gen) => gen.value === selectedGeneration); + if (generationIndex !== -1) indices.push(generationIndex); + } + + return indices.length === 0 ? -1 : Math.min(...indices); +} + +// Types introduced after the effective generation aren't offered yet. +function changeTypeArray(effectiveIndex: number, types: TypeOption[], generations: GenerationOption[]) { + const available = effectiveIndex === -1 + ? types + : types.filter((type) => { + const typeGenIndex = generations.findIndex((gen) => gen.value === type.generation); + return typeGenIndex === -1 || typeGenIndex <= effectiveIndex; + }); + return [ANY_TYPE, ...available]; +} + +type ChoosePokemonProps = { + selectedGame: string; + selectedGeneration: string; + selectedType: string; + onGameChange: (value: string) => void; + onGenerationChange: (value: string) => void; + onTypeChange: (value: string) => void; +} + +function ChoosePokemon({ selectedGame, selectedGeneration, selectedType, onGameChange, onGenerationChange, onTypeChange }: ChoosePokemonProps){ + const [games, setGames] = useState([]); + const [gamesError, setGamesError] = useState(null); + const [generations, setGenerations] = useState([]); + const [generationsError, setGenerationsError] = 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); + }); -function ChoosePokemon(){ - const [selectedGen, setSelectedGen] = useState(''); - const [selectedType, setSelectedType] = useState(''); - - type Gen = { - label: string, - value: string + fetchGenerations() + .then((data) => { + if (!cancelled) setGenerations(data); + }) + .catch((err: Error) => { + if (!cancelled) setGenerationsError(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) { + const newGame = e.target.value; + onGameChange(newGame); + // Drop the type filter if it's no longer valid for the new game + const availableTypes = changeTypeArray(effectiveGenerationIndex(newGame, games, selectedGeneration, generations), types, generations); + if (!availableTypes.some((t) => t.value === selectedType)) { + onTypeChange('any'); + } } - 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); + function handleGenerationChange(e: React.ChangeEvent) { + const newGeneration = e.target.value; + onGenerationChange(newGeneration); + // Drop the type filter if it's no longer valid for the new generation + const availableTypes = changeTypeArray(effectiveGenerationIndex(selectedGame, games, newGeneration, generations), types, generations); + if (!availableTypes.some((t) => t.value === selectedType)) { + onTypeChange('any'); } - return types; } - function handleGenChange(e: any) { setSelectedGen(e.target.value); } + function handleTypeChange(e: React.ChangeEvent) { onTypeChange(e.target.value); } - function handleTypeChange(e: any) { setSelectedType(e.target.value); } - - let newTypes : Gen[] = changeTypeArray(); + let newTypes : TypeChoice[] = changeTypeArray(effectiveGenerationIndex(selectedGame, games, selectedGeneration, generations), types, generations); + + if (gamesError) return

Failed to load games: {gamesError}

; + if (generationsError) return

Failed to load generations: {generationsError}

; + if (typesError) return

Failed to load types: {typesError}

; return( <> + @@ -86,18 +160,26 @@ function ChoosePokemon(){ ))} - + ); } -export default function generateTeam(){ - +// Filter controls plus the (not yet wired up) team generation button. +export default function generateTeam({ selectedGame, selectedGeneration, selectedType, onGameChange, onGenerationChange, onTypeChange }: ChoosePokemonProps){ + return( <> - + - + ); -} \ No newline at end of file +} diff --git a/PTG/src/NotFoundPage.tsx b/PTG/src/NotFoundPage.tsx new file mode 100644 index 0000000..6d0c0ac --- /dev/null +++ b/PTG/src/NotFoundPage.tsx @@ -0,0 +1,13 @@ +import { Link } from 'react-router-dom'; + +export default function NotFoundPage(){ + return( +
+

Page Not Found

+ + + +
+ + ); +} \ 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..ba0455b --- /dev/null +++ b/PTG/src/PokemonGrid.tsx @@ -0,0 +1,195 @@ +import { useEffect, useState } from "react"; +import "./PokemonGrid.css"; +import { fetchGameSpeciesIds } from "./games"; +import { fetchGenerationSpeciesIds } from "./generations"; +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; + selectedGeneration: string; + selectedType: string; +}; + +export default function PokemonGrid({ selectedGame, selectedGeneration, 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 [generationIds, setGenerationIds] = useState | null>(null); + const [generationError, setGenerationError] = 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]); + + useEffect(() => { + let cancelled = false; + + if (!selectedGeneration) { + setGenerationIds(null); + setGenerationError(null); + return; + } + + fetchGenerationSpeciesIds(selectedGeneration) + .then((ids) => { + if (!cancelled) { + setGenerationIds(ids); + setGenerationError(null); + } + }) + .catch((err: Error) => { + if (!cancelled) setGenerationError(err.message); + }); + + return () => { + cancelled = true; + }; + }, [selectedGeneration]); + + 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 (generationError) return

Failed to load generation: {generationError}

; + + // 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 (generationIds && !generationIds.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..789b62e --- /dev/null +++ b/PTG/src/games.ts @@ -0,0 +1,70 @@ +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; + generation: 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 = { + generation: { name: string }; + 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, detail }) => ({ + label: toLabel(versionGroup.name), + value: versionGroup.name, + generation: detail.generation.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/generations.ts b/PTG/src/generations.ts new file mode 100644 index 0000000..8a19384 --- /dev/null +++ b/PTG/src/generations.ts @@ -0,0 +1,31 @@ +const GENERATION_LIST_URL = "https://pokeapi.co/api/v2/generation?limit=100"; +const GENERATION_URL = (name: string) => `https://pokeapi.co/api/v2/generation/${name}`; + +export type GenerationOption = { + label: string; + value: string; +}; + +function idFromUrl(url: string): number { + return Number(url.split("/").filter(Boolean).pop()); +} + +function toLabel(name: string): string { + return `Gen ${name.replace("generation-", "").toUpperCase()}`; +} + +export async function fetchGenerations(): Promise { + const res = await fetch(GENERATION_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 + .sort((a, b) => idFromUrl(a.url) - idFromUrl(b.url)) + .map(({ name }) => ({ label: toLabel(name), value: name })); +} + +export async function fetchGenerationSpeciesIds(name: string): Promise> { + const res = await fetch(GENERATION_URL(name)); + if (!res.ok) throw new Error(`PokeAPI request failed: ${res.status}`); + const data: { pokemon_species: { url: string }[] } = await res.json(); + return new Set(data.pokemon_species.map((species) => idFromUrl(species.url))); +} diff --git a/PTG/src/main.tsx b/PTG/src/main.tsx index bef5202..50d8564 100644 --- a/PTG/src/main.tsx +++ b/PTG/src/main.tsx @@ -1,10 +1,28 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' +import { createBrowserRouter, RouterProvider } from 'react-router-dom' import './index.css' import App from './App.tsx' +import About from './About.tsx' +import NotFoundPage from './NotFoundPage.tsx' -createRoot(document.getElementById('root')!).render( +const router = createBrowserRouter([ + { + path: "/", + element: + }, + { + path: "/about", + element: + }, + { + path: "*", + element: + } +]); + +createRoot(document.getElementById('root')).render( - + , -) +); diff --git a/PTG/src/pokemonFilters.ts b/PTG/src/pokemonFilters.ts new file mode 100644 index 0000000..8fd555d --- /dev/null +++ b/PTG/src/pokemonFilters.ts @@ -0,0 +1,32 @@ +const STORAGE_KEY = "pokemonFilters"; + +export type PokemonFilters = { + selectedGame: string; + selectedGeneration: string; + selectedType: string; +}; + +const DEFAULT_FILTERS: PokemonFilters = { selectedGame: "", selectedGeneration: "", 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, + selectedGeneration: typeof parsed.selectedGeneration === "string" ? parsed.selectedGeneration : DEFAULT_FILTERS.selectedGeneration, + 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..a862291 --- /dev/null +++ b/PTG/src/pokemonTypes.ts @@ -0,0 +1,92 @@ +// 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; + generation: 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(); + 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: { id: number; name: string; generation: { name: string } } = await res.json(); + return data; + }) + ); + + return details + .sort((a, b) => a.id - b.id) + .map((type) => ({ label: toLabel(type.name), value: type.name, generation: type.generation.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; +}