From fa5ebbfe928a1057352b7cfa648b7b2f82704e4d Mon Sep 17 00:00:00 2001 From: Julien Calixte Date: Wed, 24 Jun 2026 19:11:02 +0200 Subject: [PATCH] =?UTF-8?q?feat:=20build=20Gen-I=20Pok=C3=A9dex=20with=20b?= =?UTF-8?q?rowse,=20search,=20type=20filter,=20favorites?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - lib/pokeapi.ts: typed PokeAPI client (list, detail, type filter) scoped to #001-151 - composables/useFavorites.ts: localStorage-backed favorites - PokemonCard / PokemonModal: pixel-art cards and detail view with stat bars - App.vue: search, type filter, favorites toggle, detail modal --- src/App.vue | 220 +++++++++++++++++++++++++++++--- src/components/PokemonCard.vue | 45 +++++++ src/components/PokemonModal.vue | 121 ++++++++++++++++++ src/composables/useFavorites.ts | 38 ++++++ src/lib/pokeapi.ts | 111 ++++++++++++++++ 5 files changed, 518 insertions(+), 17 deletions(-) create mode 100644 src/components/PokemonCard.vue create mode 100644 src/components/PokemonModal.vue create mode 100644 src/composables/useFavorites.ts create mode 100644 src/lib/pokeapi.ts diff --git a/src/App.vue b/src/App.vue index d2eaaa4..9c00208 100644 --- a/src/App.vue +++ b/src/App.vue @@ -1,15 +1,134 @@ diff --git a/src/components/PokemonCard.vue b/src/components/PokemonCard.vue new file mode 100644 index 0000000..71e8474 --- /dev/null +++ b/src/components/PokemonCard.vue @@ -0,0 +1,45 @@ + + + diff --git a/src/components/PokemonModal.vue b/src/components/PokemonModal.vue new file mode 100644 index 0000000..e0e650d --- /dev/null +++ b/src/components/PokemonModal.vue @@ -0,0 +1,121 @@ + + + diff --git a/src/composables/useFavorites.ts b/src/composables/useFavorites.ts new file mode 100644 index 0000000..89a28a2 --- /dev/null +++ b/src/composables/useFavorites.ts @@ -0,0 +1,38 @@ +import { ref, watch } from "vue" + +// Favorited dex ids, persisted to localStorage. Module-level singleton so every +// component shares one reactive source of truth. + +const KEY = "pokedex:favorites" + +function load(): number[] { + try { + const raw = localStorage.getItem(KEY) + return raw ? (JSON.parse(raw) as number[]) : [] + } catch { + return [] + } +} + +const favorites = ref>(new Set(load())) + +watch( + favorites, + (val) => { + localStorage.setItem(KEY, JSON.stringify([...val])) + }, + { deep: true }, +) + +export function useFavorites() { + function toggle(id: number): void { + if (favorites.value.has(id)) favorites.value.delete(id) + else favorites.value.add(id) + } + + function isFavorite(id: number): boolean { + return favorites.value.has(id) + } + + return { favorites, toggle, isFavorite } +} diff --git a/src/lib/pokeapi.ts b/src/lib/pokeapi.ts new file mode 100644 index 0000000..b1e72f6 --- /dev/null +++ b/src/lib/pokeapi.ts @@ -0,0 +1,111 @@ +// Thin typed client over the public PokeAPI (https://pokeapi.co). +// Scoped to Generation I (#001–#151) — the classic 8-bit Red/Blue dex. + +const API = "https://pokeapi.co/api/v2" +const GEN1_LIMIT = 151 + +export interface PokemonListItem { + id: number + name: string +} + +export interface PokemonStat { + name: string + value: number +} + +export interface PokemonDetail { + id: number + name: string + height: number // decimetres + weight: number // hectograms + types: string[] + stats: PokemonStat[] + abilities: string[] + sprite: string +} + +interface RawList { + results: { name: string; url: string }[] +} + +interface RawDetail { + id: number + name: string + height: number + weight: number + types: { type: { name: string } }[] + stats: { base_stat: number; stat: { name: string } }[] + abilities: { ability: { name: string } }[] + sprites: { front_default: string | null } +} + +interface RawType { + pokemon: { pokemon: { url: string } }[] +} + +// Classic per-type colours, used for the type badges. +export const TYPE_COLORS: Record = { + normal: "#A8A77A", + fire: "#EE8130", + water: "#6390F0", + electric: "#F7D02C", + grass: "#7AC74C", + ice: "#96D9D6", + fighting: "#C22E28", + poison: "#A33EA1", + ground: "#E2BF65", + flying: "#A98FF3", + psychic: "#F95587", + bug: "#A6B91A", + rock: "#B6A136", + ghost: "#735797", + dragon: "#6F35FC", + dark: "#705746", + steel: "#B7B7CE", + fairy: "#D685AD", +} + +// Pixel-art front sprite for a given dex id (PokeAPI's sprite CDN). +export function spriteUrl(id: number): string { + return `https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/${id}.png` +} + +function idFromUrl(url: string): number { + const match = url.match(/\/pokemon\/(\d+)\/?$/) + return match ? Number(match[1]) : 0 +} + +// The full Gen-I roster (one request — sprites are derived from the id). +export async function fetchPokedex(): Promise { + const res = await fetch(`${API}/pokemon?limit=${GEN1_LIMIT}`) + if (!res.ok) throw new Error(`PokeAPI list failed (${res.status})`) + const data = (await res.json()) as RawList + return data.results.map((r) => ({ id: idFromUrl(r.url), name: r.name })) +} + +export async function fetchPokemon(id: number): Promise { + const res = await fetch(`${API}/pokemon/${id}`) + if (!res.ok) throw new Error(`PokeAPI detail failed (${res.status})`) + const d = (await res.json()) as RawDetail + return { + id: d.id, + name: d.name, + height: d.height, + weight: d.weight, + types: d.types.map((t) => t.type.name), + stats: d.stats.map((s) => ({ name: s.stat.name, value: s.base_stat })), + abilities: d.abilities.map((a) => a.ability.name), + sprite: d.sprites.front_default ?? spriteUrl(d.id), + } +} + +// Gen-I dex ids belonging to a given type (one request, intersected with #1–#151). +export async function fetchTypeIds(type: string): Promise> { + const res = await fetch(`${API}/type/${type}`) + if (!res.ok) throw new Error(`PokeAPI type failed (${res.status})`) + const data = (await res.json()) as RawType + return new Set( + data.pokemon.map((p) => idFromUrl(p.pokemon.url)).filter((id) => id >= 1 && id <= GEN1_LIMIT), + ) +}