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:
Julien Calixte
2026-06-24 19:11:02 +02:00
parent 8188f263d8
commit fa5ebbfe92
5 changed files with 518 additions and 17 deletions

View File

@@ -1,15 +1,134 @@
<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>
<template>
<main class="min-h-screen bg-base-200 pixel-grid flex items-center justify-center p-6">
<div
class="card bg-base-100 max-w-md w-full rounded-none border-4 border-primary shadow-[6px_6px_0_0_#1a1a1a]"
>
<div class="card-body items-center text-center gap-6">
<div class="min-h-screen bg-base-200 pixel-grid">
<header class="bg-base-100 border-b-4 border-primary">
<div class="max-w-5xl mx-auto px-4 py-4 flex items-center gap-3">
<svg
class="size-20 text-primary"
class="size-9 text-primary shrink-0"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
@@ -23,15 +142,82 @@ const repo = "https://git.apoena.dev/julien/pokedex"
<path d="M3 12h6" />
<path d="M15 12h6" />
</svg>
<h1 class="font-display text-2xl text-primary leading-relaxed">POKEDEX</h1>
<p class="text-2xl opacity-80 leading-snug">An 8-bit Pokédex. Gotta catalog 'em all.</p>
<div class="card-actions">
<a :href="repo" class="btn btn-primary rounded-none font-display text-xs">View source</a>
</div>
<h1 class="font-display text-lg text-primary">POKEDEX</h1>
<span class="ml-auto text-base-content/50 text-lg tabular-nums">
{{ filtered.length }} / {{ list.length || 151 }}
</span>
</div>
</div>
</main>
</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>
<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>
</main>
<PokemonModal
:open="modalOpen"
:loading="detailLoading"
:error="detailError"
:detail="detail"
:favorite="modalFavorite"
@close="modalOpen = false"
@toggle-favorite="toggle"
/>
</div>
</template>

View 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>

View 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>

View 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
View 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),
)
}