feat: build Gen-I Pokédex with browse, search, type filter, favorites
- 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
This commit is contained in:
214
src/App.vue
214
src/App.vue
@@ -1,15 +1,134 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
const repo = "https://git.apoena.dev/julien/pokedex"
|
import { computed, onMounted, onUnmounted, ref } from "vue"
|
||||||
|
import {
|
||||||
|
fetchPokedex,
|
||||||
|
fetchPokemon,
|
||||||
|
fetchTypeIds,
|
||||||
|
type PokemonDetail,
|
||||||
|
type PokemonListItem,
|
||||||
|
TYPE_COLORS,
|
||||||
|
} from "@/lib/pokeapi"
|
||||||
|
import { useFavorites } from "@/composables/useFavorites"
|
||||||
|
import PokemonCard from "@/components/PokemonCard.vue"
|
||||||
|
import PokemonModal from "@/components/PokemonModal.vue"
|
||||||
|
|
||||||
|
const { toggle, isFavorite } = useFavorites()
|
||||||
|
|
||||||
|
const TYPES = Object.keys(TYPE_COLORS)
|
||||||
|
|
||||||
|
const list = ref<PokemonListItem[]>([])
|
||||||
|
const loading = ref(true)
|
||||||
|
const loadError = ref<string | null>(null)
|
||||||
|
|
||||||
|
const search = ref("")
|
||||||
|
const selectedType = ref<string | null>(null)
|
||||||
|
const typeIds = ref<Set<number> | null>(null)
|
||||||
|
const typeLoading = ref(false)
|
||||||
|
const showFavoritesOnly = ref(false)
|
||||||
|
|
||||||
|
const modalOpen = ref(false)
|
||||||
|
const detail = ref<PokemonDetail | null>(null)
|
||||||
|
const detailLoading = ref(false)
|
||||||
|
const detailError = ref<string | null>(null)
|
||||||
|
|
||||||
|
const detailCache = new Map<number, PokemonDetail>()
|
||||||
|
const typeCache = new Map<string, Set<number>>()
|
||||||
|
|
||||||
|
const filtered = computed(() => {
|
||||||
|
let items = list.value
|
||||||
|
if (showFavoritesOnly.value) items = items.filter((p) => isFavorite(p.id))
|
||||||
|
if (typeIds.value) {
|
||||||
|
const ids = typeIds.value
|
||||||
|
items = items.filter((p) => ids.has(p.id))
|
||||||
|
}
|
||||||
|
const q = search.value.trim().toLowerCase().replace(/^#/, "")
|
||||||
|
if (q) {
|
||||||
|
items = items.filter(
|
||||||
|
(p) => p.name.includes(q) || String(p.id) === q || String(p.id).padStart(3, "0") === q,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
})
|
||||||
|
|
||||||
|
const modalFavorite = computed(() => (detail.value ? isFavorite(detail.value.id) : false))
|
||||||
|
|
||||||
|
async function load(): Promise<void> {
|
||||||
|
loading.value = true
|
||||||
|
loadError.value = null
|
||||||
|
try {
|
||||||
|
list.value = await fetchPokedex()
|
||||||
|
} catch (e) {
|
||||||
|
loadError.value = e instanceof Error ? e.message : "Failed to load Pokédex"
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function selectType(type: string): Promise<void> {
|
||||||
|
const next = type || null
|
||||||
|
selectedType.value = next
|
||||||
|
if (!next) {
|
||||||
|
typeIds.value = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const cached = typeCache.get(next)
|
||||||
|
if (cached) {
|
||||||
|
typeIds.value = cached
|
||||||
|
return
|
||||||
|
}
|
||||||
|
typeLoading.value = true
|
||||||
|
try {
|
||||||
|
const ids = await fetchTypeIds(next)
|
||||||
|
typeCache.set(next, ids)
|
||||||
|
typeIds.value = ids
|
||||||
|
} catch {
|
||||||
|
typeIds.value = null
|
||||||
|
selectedType.value = null
|
||||||
|
} finally {
|
||||||
|
typeLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openDetail(id: number): Promise<void> {
|
||||||
|
modalOpen.value = true
|
||||||
|
detailError.value = null
|
||||||
|
const cached = detailCache.get(id)
|
||||||
|
if (cached) {
|
||||||
|
detail.value = cached
|
||||||
|
detailLoading.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
|
detail.value = null
|
||||||
|
detailLoading.value = true
|
||||||
|
try {
|
||||||
|
const d = await fetchPokemon(id)
|
||||||
|
detailCache.set(id, d)
|
||||||
|
detail.value = d
|
||||||
|
} catch (e) {
|
||||||
|
detailError.value = e instanceof Error ? e.message : "Failed to load this Pokémon"
|
||||||
|
} finally {
|
||||||
|
detailLoading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKeydown(e: KeyboardEvent): void {
|
||||||
|
if (e.key === "Escape") modalOpen.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
window.addEventListener("keydown", onKeydown)
|
||||||
|
void load()
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => window.removeEventListener("keydown", onKeydown))
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<main class="min-h-screen bg-base-200 pixel-grid flex items-center justify-center p-6">
|
<div class="min-h-screen bg-base-200 pixel-grid">
|
||||||
<div
|
<header class="bg-base-100 border-b-4 border-primary">
|
||||||
class="card bg-base-100 max-w-md w-full rounded-none border-4 border-primary shadow-[6px_6px_0_0_#1a1a1a]"
|
<div class="max-w-5xl mx-auto px-4 py-4 flex items-center gap-3">
|
||||||
>
|
|
||||||
<div class="card-body items-center text-center gap-6">
|
|
||||||
<svg
|
<svg
|
||||||
class="size-20 text-primary"
|
class="size-9 text-primary shrink-0"
|
||||||
viewBox="0 0 24 24"
|
viewBox="0 0 24 24"
|
||||||
fill="none"
|
fill="none"
|
||||||
stroke="currentColor"
|
stroke="currentColor"
|
||||||
@@ -23,15 +142,82 @@ const repo = "https://git.apoena.dev/julien/pokedex"
|
|||||||
<path d="M3 12h6" />
|
<path d="M3 12h6" />
|
||||||
<path d="M15 12h6" />
|
<path d="M15 12h6" />
|
||||||
</svg>
|
</svg>
|
||||||
|
<h1 class="font-display text-lg text-primary">POKEDEX</h1>
|
||||||
<h1 class="font-display text-2xl text-primary leading-relaxed">POKEDEX</h1>
|
<span class="ml-auto text-base-content/50 text-lg tabular-nums">
|
||||||
|
{{ filtered.length }} / {{ list.length || 151 }}
|
||||||
<p class="text-2xl opacity-80 leading-snug">An 8-bit Pokédex. Gotta catalog 'em all.</p>
|
</span>
|
||||||
|
|
||||||
<div class="card-actions">
|
|
||||||
<a :href="repo" class="btn btn-primary rounded-none font-display text-xs">View source</a>
|
|
||||||
</div>
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="max-w-5xl mx-auto px-4 py-6">
|
||||||
|
<div class="flex flex-wrap items-center gap-2 mb-6">
|
||||||
|
<input
|
||||||
|
v-model="search"
|
||||||
|
type="search"
|
||||||
|
placeholder="Search name or #001…"
|
||||||
|
class="input input-bordered rounded-none border-2 border-base-300 focus:border-primary grow text-lg"
|
||||||
|
aria-label="Search Pokémon"
|
||||||
|
/>
|
||||||
|
<select
|
||||||
|
:value="selectedType ?? ''"
|
||||||
|
class="select select-bordered rounded-none border-2 border-base-300 text-lg"
|
||||||
|
aria-label="Filter by type"
|
||||||
|
@change="selectType(($event.target as HTMLSelectElement).value)"
|
||||||
|
>
|
||||||
|
<option value="">All types</option>
|
||||||
|
<option v-for="t in TYPES" :key="t" :value="t" class="capitalize">{{ t }}</option>
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
class="btn rounded-none border-2 text-lg"
|
||||||
|
:class="showFavoritesOnly ? 'btn-primary' : 'btn-outline border-base-300'"
|
||||||
|
:aria-pressed="showFavoritesOnly"
|
||||||
|
@click="showFavoritesOnly = !showFavoritesOnly"
|
||||||
|
>
|
||||||
|
★ Favorites
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<p v-if="typeLoading" class="text-center text-lg text-base-content/50 py-2">Filtering…</p>
|
||||||
|
|
||||||
|
<div v-if="loading" class="text-center py-20 text-2xl text-base-content/60">
|
||||||
|
Booting Pokédex…
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="loadError" class="text-center py-20 space-y-4">
|
||||||
|
<p class="text-xl text-error">{{ loadError }}</p>
|
||||||
|
<button class="btn btn-primary rounded-none font-display text-xs" @click="load">
|
||||||
|
Retry
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-else-if="filtered.length === 0"
|
||||||
|
class="text-center py-20 text-2xl text-base-content/60"
|
||||||
|
>
|
||||||
|
No Pokémon found.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-3">
|
||||||
|
<PokemonCard
|
||||||
|
v-for="p in filtered"
|
||||||
|
:key="p.id"
|
||||||
|
:id="p.id"
|
||||||
|
:name="p.name"
|
||||||
|
:favorite="isFavorite(p.id)"
|
||||||
|
@select="openDetail"
|
||||||
|
@toggle-favorite="toggle"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
<PokemonModal
|
||||||
|
:open="modalOpen"
|
||||||
|
:loading="detailLoading"
|
||||||
|
:error="detailError"
|
||||||
|
:detail="detail"
|
||||||
|
:favorite="modalFavorite"
|
||||||
|
@close="modalOpen = false"
|
||||||
|
@toggle-favorite="toggle"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
45
src/components/PokemonCard.vue
Normal file
45
src/components/PokemonCard.vue
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { spriteUrl } from "@/lib/pokeapi"
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
favorite: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
select: [id: number]
|
||||||
|
"toggle-favorite": [id: number]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const dexNo = `#${String(props.id).padStart(3, "0")}`
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="group relative bg-base-100 border-4 border-base-300 rounded-none p-3 flex flex-col items-center cursor-pointer transition-all hover:border-primary hover:shadow-[4px_4px_0_0_#1a1a1a] hover:-translate-y-0.5"
|
||||||
|
@click="emit('select', id)"
|
||||||
|
>
|
||||||
|
<button
|
||||||
|
class="absolute top-1 right-1 z-10 leading-none text-xl p-1"
|
||||||
|
:class="favorite ? 'text-primary' : 'text-base-300 hover:text-primary'"
|
||||||
|
:aria-label="favorite ? `Remove ${name} from favorites` : `Add ${name} to favorites`"
|
||||||
|
:aria-pressed="favorite"
|
||||||
|
@click.stop="emit('toggle-favorite', id)"
|
||||||
|
>
|
||||||
|
{{ favorite ? "★" : "☆" }}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<img
|
||||||
|
:src="spriteUrl(id)"
|
||||||
|
:alt="name"
|
||||||
|
width="96"
|
||||||
|
height="96"
|
||||||
|
loading="lazy"
|
||||||
|
class="pixelated size-24 drop-shadow"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<span class="text-base-content/50 text-lg leading-none">{{ dexNo }}</span>
|
||||||
|
<span class="capitalize text-xl leading-tight text-center">{{ name }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
121
src/components/PokemonModal.vue
Normal file
121
src/components/PokemonModal.vue
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { type PokemonDetail, TYPE_COLORS } from "@/lib/pokeapi"
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
open: boolean
|
||||||
|
loading: boolean
|
||||||
|
error: string | null
|
||||||
|
detail: PokemonDetail | null
|
||||||
|
favorite: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
close: []
|
||||||
|
"toggle-favorite": [id: number]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const STAT_LABELS: Record<string, string> = {
|
||||||
|
hp: "HP",
|
||||||
|
attack: "ATK",
|
||||||
|
defense: "DEF",
|
||||||
|
"special-attack": "SP.ATK",
|
||||||
|
"special-defense": "SP.DEF",
|
||||||
|
speed: "SPD",
|
||||||
|
}
|
||||||
|
|
||||||
|
// Highest base stat in Gen I (Chansey's HP, 250) — round up for the bar scale.
|
||||||
|
const STAT_MAX = 255
|
||||||
|
|
||||||
|
function dexNo(id: number): string {
|
||||||
|
return `#${String(id).padStart(3, "0")}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function barWidth(value: number): string {
|
||||||
|
return `${Math.min(100, (value / STAT_MAX) * 100)}%`
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="modal" :class="{ 'modal-open': open }" @click.self="emit('close')">
|
||||||
|
<div
|
||||||
|
class="modal-box rounded-none border-4 border-primary shadow-[8px_8px_0_0_#1a1a1a] max-w-lg"
|
||||||
|
>
|
||||||
|
<div v-if="loading" class="py-16 text-center text-xl">Loading…</div>
|
||||||
|
|
||||||
|
<div v-else-if="error" class="py-16 text-center space-y-4">
|
||||||
|
<p class="text-xl text-error">{{ error }}</p>
|
||||||
|
<button class="btn btn-sm rounded-none" @click="emit('close')">Close</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="detail" class="space-y-5">
|
||||||
|
<header class="flex items-center justify-between gap-3">
|
||||||
|
<h2 class="font-display text-base text-primary capitalize">{{ detail.name }}</h2>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-base-content/50 text-xl">{{ dexNo(detail.id) }}</span>
|
||||||
|
<button
|
||||||
|
class="text-2xl leading-none"
|
||||||
|
:class="favorite ? 'text-primary' : 'text-base-300 hover:text-primary'"
|
||||||
|
:aria-pressed="favorite"
|
||||||
|
:aria-label="favorite ? 'Remove from favorites' : 'Add to favorites'"
|
||||||
|
@click="emit('toggle-favorite', detail.id)"
|
||||||
|
>
|
||||||
|
{{ favorite ? "★" : "☆" }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-5">
|
||||||
|
<img
|
||||||
|
:src="detail.sprite"
|
||||||
|
:alt="detail.name"
|
||||||
|
width="96"
|
||||||
|
height="96"
|
||||||
|
class="pixelated size-28 shrink-0"
|
||||||
|
/>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<span
|
||||||
|
v-for="t in detail.types"
|
||||||
|
:key="t"
|
||||||
|
class="px-2 py-1 text-sm uppercase text-white rounded-none"
|
||||||
|
:style="{ backgroundColor: TYPE_COLORS[t] ?? '#777' }"
|
||||||
|
>
|
||||||
|
{{ t }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<dl class="text-lg grid grid-cols-2 gap-x-4">
|
||||||
|
<dt class="text-base-content/50">Height</dt>
|
||||||
|
<dd>{{ (detail.height / 10).toFixed(1) }} m</dd>
|
||||||
|
<dt class="text-base-content/50">Weight</dt>
|
||||||
|
<dd>{{ (detail.weight / 10).toFixed(1) }} kg</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h3 class="text-base-content/50 text-lg mb-1">Abilities</h3>
|
||||||
|
<p class="capitalize text-xl">{{ detail.abilities.join(", ").replace(/-/g, " ") }}</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="space-y-1.5">
|
||||||
|
<h3 class="text-base-content/50 text-lg">Base stats</h3>
|
||||||
|
<div v-for="s in detail.stats" :key="s.name" class="flex items-center gap-2 text-lg">
|
||||||
|
<span class="w-16 shrink-0 text-base-content/70">{{
|
||||||
|
STAT_LABELS[s.name] ?? s.name
|
||||||
|
}}</span>
|
||||||
|
<span class="w-8 shrink-0 text-right tabular-nums">{{ s.value }}</span>
|
||||||
|
<div class="flex-1 h-3 bg-base-300 rounded-none">
|
||||||
|
<div class="h-full bg-primary" :style="{ width: barWidth(s.value) }"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="modal-action">
|
||||||
|
<button class="btn rounded-none font-display text-xs" @click="emit('close')">
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
38
src/composables/useFavorites.ts
Normal file
38
src/composables/useFavorites.ts
Normal file
@@ -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<Set<number>>(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 }
|
||||||
|
}
|
||||||
111
src/lib/pokeapi.ts
Normal file
111
src/lib/pokeapi.ts
Normal file
@@ -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<string, string> = {
|
||||||
|
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<PokemonListItem[]> {
|
||||||
|
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<PokemonDetail> {
|
||||||
|
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<Set<number>> {
|
||||||
|
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),
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user