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:
220
src/App.vue
220
src/App.vue
@@ -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>
|
||||
|
||||
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