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(
<>
- Select
- {gens.map((gens) => (
-
- {gens.label}
+ Any Game
+ {games.map((game) => (
+
+ {game.label}
+
+ ))}
+
+
+ Any Gen
+ {generations.map((generation) => (
+
+ {generation.label}
))}
@@ -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(
<>
-
+
Generate Team
>
-
+
);
-}
\ 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
+
+ Go back Home
+
+
+
+ );
+}
\ 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 (
+
+
+
+ {types && types.length > 0 && (
+
+ {types.map((type) => (
+
+ ))}
+
+ )}
+
+
{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;
+}