feature: navigation in pokemon page

This commit is contained in:
2026-02-07 17:26:29 -06:00
parent 8d0e4e9115
commit 5a61caf3f5
7 changed files with 405 additions and 7 deletions

61
package-lock.json generated
View File

@@ -9,6 +9,7 @@
"version": "0.0.0",
"dependencies": {
"colorthief": "^2.4.0",
"pinia": "^3.0.4",
"tinycolor2": "^1.6.0",
"vue": "^3.3.4",
"vue-router": "^5.0.2"
@@ -2797,6 +2798,66 @@
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/pinia": {
"version": "3.0.4",
"resolved": "https://registry.npmjs.org/pinia/-/pinia-3.0.4.tgz",
"integrity": "sha512-l7pqLUFTI/+ESXn6k3nu30ZIzW5E2WZF/LaHJEpoq6ElcLD+wduZoB2kBN19du6K/4FDpPMazY2wJr+IndBtQw==",
"license": "MIT",
"dependencies": {
"@vue/devtools-api": "^7.7.7"
},
"funding": {
"url": "https://github.com/sponsors/posva"
},
"peerDependencies": {
"typescript": ">=4.5.0",
"vue": "^3.5.11"
},
"peerDependenciesMeta": {
"typescript": {
"optional": true
}
}
},
"node_modules/pinia/node_modules/@vue/devtools-api": {
"version": "7.7.9",
"resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-7.7.9.tgz",
"integrity": "sha512-kIE8wvwlcZ6TJTbNeU2HQNtaxLx3a84aotTITUuL/4bzfPxzajGBOoqjMhwZJ8L9qFYDU/lAYMEEm11dnZOD6g==",
"license": "MIT",
"dependencies": {
"@vue/devtools-kit": "^7.7.9"
}
},
"node_modules/pinia/node_modules/@vue/devtools-kit": {
"version": "7.7.9",
"resolved": "https://registry.npmjs.org/@vue/devtools-kit/-/devtools-kit-7.7.9.tgz",
"integrity": "sha512-PyQ6odHSgiDVd4hnTP+aDk2X4gl2HmLDfiyEnn3/oV+ckFDuswRs4IbBT7vacMuGdwY/XemxBoh302ctbsptuA==",
"license": "MIT",
"dependencies": {
"@vue/devtools-shared": "^7.7.9",
"birpc": "^2.3.0",
"hookable": "^5.5.3",
"mitt": "^3.0.1",
"perfect-debounce": "^1.0.0",
"speakingurl": "^14.0.1",
"superjson": "^2.2.2"
}
},
"node_modules/pinia/node_modules/@vue/devtools-shared": {
"version": "7.7.9",
"resolved": "https://registry.npmjs.org/@vue/devtools-shared/-/devtools-shared-7.7.9.tgz",
"integrity": "sha512-iWAb0v2WYf0QWmxCGy0seZNDPdO3Sp5+u78ORnyeonS6MT4PC7VPrryX2BpMJrwlDeaZ6BD4vP4XKjK0SZqaeA==",
"license": "MIT",
"dependencies": {
"rfdc": "^1.4.1"
}
},
"node_modules/pinia/node_modules/perfect-debounce": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/perfect-debounce/-/perfect-debounce-1.0.0.tgz",
"integrity": "sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==",
"license": "MIT"
},
"node_modules/pkg-types": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.0.tgz",

View File

@@ -10,6 +10,7 @@
},
"dependencies": {
"colorthief": "^2.4.0",
"pinia": "^3.0.4",
"tinycolor2": "^1.6.0",
"vue": "^3.3.4",
"vue-router": "^5.0.2"

View File

@@ -1,6 +1,7 @@
import { ref } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { appState } from '../store'
import { usePokemonListStore } from '../stores/pokemonListStore'
import { MAX_SEARCH_LENGTH } from '../constants/ui'
const searchQuery = ref('')
@@ -16,6 +17,7 @@ export const homeMenuState = ref({
export function usePokedexNavigation() {
const router = useRouter()
const route = useRoute()
const pokemonListStore = usePokemonListStore()
function handleKeypad(num) {
if (searchQuery.value.length < MAX_SEARCH_LENGTH) {
@@ -73,10 +75,10 @@ export function usePokedexNavigation() {
function handleMainButton() {
if (route.path === '/' && homeMenuState.value.onSelect) {
homeMenuState.value.onSelect()
} else if (route.path === '/pokemon' && pokemonListStore.onSelect) {
pokemonListStore.onSelect()
} else if (searchQuery.value) {
executeSearch()
} else {
goHome()
}
}
@@ -89,6 +91,8 @@ export function usePokedexNavigation() {
function handleNext() {
if (route.path === '/' && homeMenuState.value.onMoveDown) {
homeMenuState.value.onMoveDown()
} else if (route.path === '/pokemon' && pokemonListStore.onMoveDown) {
pokemonListStore.onMoveDown()
} else if (appState.currentPokemon) {
let nextId = appState.currentPokemon.id + 1
router.push(`/pokemon/${nextId}`)
@@ -98,6 +102,8 @@ export function usePokedexNavigation() {
function handlePrev() {
if (route.path === '/' && homeMenuState.value.onMoveUp) {
homeMenuState.value.onMoveUp()
} else if (route.path === '/pokemon' && pokemonListStore.onMoveUp) {
pokemonListStore.onMoveUp()
} else if (appState.currentPokemon && appState.currentPokemon.id > 1) {
let prevId = appState.currentPokemon.id - 1
router.push(`/pokemon/${prevId}`)

View File

@@ -1,10 +1,13 @@
import { createApp } from "vue";
import { createPinia } from "pinia";
import "./style.css";
import App from "./App.vue";
import router from "./router";
const app = createApp(App);
const pinia = createPinia();
app.use(pinia);
app.use(router);
router.isReady().then(() => {

View File

@@ -8,9 +8,11 @@ import PageLayout from '../components/layout/PageLayout.vue'
import ColorThief from 'colorthief'
import { generateThemeColors } from '../utils/theme'
import { appState } from '../store'
import { usePokemonListStore } from '../stores/pokemonListStore'
import { useScreenActions } from '../composables/useScreenActions'
const { setButtonActions } = useScreenActions()
const pokemonListStore = usePokemonListStore()
const route = useRoute();
const router = useRouter();
@@ -53,6 +55,7 @@ function getPokemonData() {
pokemon.value = json
pokemonImage.value = pokemon.value.sprites.other['official-artwork'].front_default
appState.setCurrentPokemon(json)
pokemonListStore.setLastSelectedPokemon(currentId)
getPokemonColors(pokemonImage.value)
})

View File

@@ -1,24 +1,306 @@
<script setup>
import { onMounted } from 'vue'
import { ref, onMounted, onUnmounted, watch, nextTick } from 'vue'
import { useRouter } from 'vue-router'
import { appState } from '../store'
import PageLayout from '../components/layout/PageLayout.vue'
import { usePokemonListStore } from '../stores/pokemonListStore'
import { useScreenActions } from '../composables/useScreenActions'
const router = useRouter()
const { setButtonActions } = useScreenActions()
const pokemonListStore = usePokemonListStore()
const pokemonList = ref([])
const selectedIndex = ref(0)
const scrollContainer = ref(null)
const loading = ref(true)
const loadingMore = ref(false)
const TOTAL_POKEMON = 1025
const ITEM_HEIGHT = 92
const INITIAL_LOAD = 20
const LOAD_AHEAD_BUFFER = 10
const BATCH_SIZE = 10
async function loadPokemonBatch(startId, count) {
const promises = []
const endId = Math.min(startId + count, TOTAL_POKEMON + 1)
for (let i = startId; i < endId; i++) {
promises.push(
fetch(`https://pokeapi.co/api/v2/pokemon/${i}`)
.then(res => res.ok ? res.json() : null)
.catch(() => null)
)
}
const results = await Promise.all(promises)
return results.filter(p => p !== null)
}
async function loadInitialPokemon() {
loading.value = true
const lastId = pokemonListStore.lastSelectedPokemonId
if (lastId && lastId > INITIAL_LOAD) {
const batchesToLoad = Math.ceil(lastId / BATCH_SIZE)
const pokemonToLoad = Math.min(batchesToLoad * BATCH_SIZE, TOTAL_POKEMON)
const initial = await loadPokemonBatch(1, pokemonToLoad)
pokemonList.value = initial
} else {
const initial = await loadPokemonBatch(1, INITIAL_LOAD)
pokemonList.value = initial
}
loading.value = false
if (lastId) {
const index = pokemonList.value.findIndex(p => p.id === lastId)
if (index !== -1) {
selectedIndex.value = index
nextTick(() => scrollToSelected())
}
}
}
async function loadMorePokemon() {
if (loadingMore.value || pokemonList.value.length >= TOTAL_POKEMON) return
loadingMore.value = true
const nextId = pokemonList.value.length + 1
const newPokemon = await loadPokemonBatch(nextId, BATCH_SIZE)
pokemonList.value = [...pokemonList.value, ...newPokemon]
loadingMore.value = false
}
function checkAndLoadMore() {
const remainingPokemon = pokemonList.value.length - selectedIndex.value - 1
if (remainingPokemon < LOAD_AHEAD_BUFFER && pokemonList.value.length < TOTAL_POKEMON) {
loadMorePokemon()
}
}
function scrollToSelected() {
if (!scrollContainer.value) return
nextTick(() => {
const container = scrollContainer.value
const itemElement = container.children[0]?.children[selectedIndex.value]
if (itemElement) {
const containerHeight = container.clientHeight
const itemTop = itemElement.offsetTop
const itemHeight = ITEM_HEIGHT
const scrollTop = itemTop - (containerHeight / 2) + (itemHeight / 2)
container.scrollTo({
top: scrollTop,
behavior: 'smooth'
})
}
})
}
function moveUp() {
if (selectedIndex.value > 0) {
selectedIndex.value--
scrollToSelected()
}
}
function moveDown() {
if (selectedIndex.value < pokemonList.value.length - 1) {
selectedIndex.value++
scrollToSelected()
checkAndLoadMore()
}
}
function selectPokemon() {
const pokemon = pokemonList.value[selectedIndex.value]
if (pokemon) {
pokemonListStore.setLastSelectedPokemon(pokemon.id)
router.push(`/pokemon/${pokemon.id}`)
}
}
watch(selectedIndex, () => {
checkAndLoadMore()
})
onMounted(() => {
appState.setPageDisplay(
'M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8zm-5.5-2.5l7.51-3.22-7.52-3.22 3.22 7.52 3.21-7.52-6.42 6.44z',
'POKÉMON'
)
setButtonActions([
{ buttonNumber: 1, label: 'BACK', action: () => router.push('/') }
])
pokemonListStore.setNavigationHandlers({
selectedIndex: selectedIndex.value,
onSelect: selectPokemon,
onMoveUp: moveUp,
onMoveDown: moveDown
})
loadInitialPokemon()
})
onUnmounted(() => {
pokemonListStore.clearNavigationHandlers()
})
</script>
<template>
<PageLayout
title="POKÉMON"
badge=""
subtitle=""
subtitle="Global Database"
statusText="Browse all Pokémon species"
>
<div class="flex items-center justify-center h-full">
<p class="text-zinc-500 text-sm">Coming Soon</p>
<div v-if="loading" class="flex items-center justify-center h-full">
<div class="flex flex-col items-center gap-4">
<div class="relative w-20 h-20">
<div class="absolute inset-0 border-2 border-zinc-800 rounded-full"></div>
<div class="absolute inset-0 border-t-2 border-red-500 rounded-full animate-spin"></div>
<div class="absolute inset-2 border-2 border-zinc-700 rounded-full"></div>
<div class="absolute inset-6 bg-zinc-900 rounded-full flex items-center justify-center shadow-[inset_0_0_15px_rgba(0,0,0,0.7)]">
<div class="w-3 h-3 bg-red-500 rounded-full animate-ping"></div>
</div>
</div>
<div class="text-center space-y-1">
<div class="text-sm font-black tracking-[0.25em] text-zinc-400 mb-2">LOADING DATABASE</div>
<div class="flex items-center justify-center gap-1">
<div class="w-1 h-1 bg-zinc-600 rounded-full animate-pulse"></div>
<div class="w-1 h-1 bg-zinc-600 rounded-full animate-pulse" style="animation-delay: 0.2s"></div>
<div class="w-1 h-1 bg-zinc-600 rounded-full animate-pulse" style="animation-delay: 0.4s"></div>
</div>
<div class="text-[10px] text-zinc-600 font-mono">Initializing Pokédex...</div>
</div>
</div>
</div>
<div v-else class="flex flex-col h-full overflow-hidden">
<div class="px-5 py-3 border-b-2 border-zinc-800 bg-zinc-900 shrink-0">
<div class="flex items-center justify-between">
<div class="flex items-center gap-2.5">
<div class="relative">
<div class="w-2 h-2 bg-green-500 rounded-full animate-pulse"></div>
<div class="absolute inset-0 w-2 h-2 bg-green-500 rounded-full animate-ping"></div>
</div>
<div class="text-[10px] text-zinc-400 uppercase tracking-[0.15em] font-bold">
{{ pokemonList.length }} <span class="text-zinc-600">/</span> {{ TOTAL_POKEMON }}
</div>
</div>
<div class="flex items-center gap-2">
<div class="text-[8px] text-zinc-600 uppercase tracking-wider">Selected</div>
<div class="text-xs text-zinc-300 font-mono font-black px-2 py-0.5 bg-zinc-950 border border-zinc-700 rounded">
#{{ String(pokemonList[selectedIndex]?.id || 0).padStart(4, '0') }}
</div>
</div>
</div>
</div>
<div
ref="scrollContainer"
class="flex-1 overflow-y-auto custom-scrollbar p-5"
style="scroll-behavior: smooth;"
>
<div class="space-y-3">
<div
v-for="(pokemon, index) in pokemonList"
:key="pokemon.id"
@click="selectedIndex = index; selectPokemon()"
:class="[
'flex items-center gap-4 p-3 rounded-lg border-2 transition-all duration-200 cursor-pointer relative',
index === selectedIndex
? 'bg-zinc-900 border-zinc-600 shadow-[0_0_20px_rgba(255,255,255,0.1)]'
: 'bg-zinc-950 border-zinc-800 hover:border-zinc-700 hover:bg-zinc-900/60'
]"
:style="{ height: ITEM_HEIGHT + 'px' }"
>
<div
:class="[
'shrink-0 rounded-lg border flex items-center justify-center overflow-hidden transition-all duration-200',
index === selectedIndex
? 'w-18 h-18 border-zinc-700 bg-zinc-950 shadow-inner'
: 'w-16 h-16 border-zinc-800 bg-black'
]"
>
<img
v-if="pokemon.sprites?.other?.['official-artwork']?.front_default"
:src="pokemon.sprites.other['official-artwork'].front_default"
:alt="pokemon.name"
class="w-full h-full object-contain p-1"
loading="lazy"
/>
<div v-else class="text-zinc-700 text-xs font-bold">?</div>
</div>
<div class="flex-1 flex flex-col justify-center min-w-0">
<div class="flex items-baseline gap-2 mb-1">
<div
:class="[
'font-black uppercase tracking-tight truncate transition-all duration-200',
index === selectedIndex
? 'text-base text-white'
: 'text-sm text-zinc-400'
]"
>
{{ pokemon.name }}
</div>
<div
:class="[
'font-mono font-bold shrink-0 transition-all duration-200',
index === selectedIndex
? 'text-[10px] text-zinc-500'
: 'text-[9px] text-zinc-600'
]"
>
#{{ String(pokemon.id).padStart(4, '0') }}
</div>
</div>
<div class="flex items-center gap-1.5">
<span
v-for="type in pokemon.types"
:key="type.slot"
:class="[
'uppercase font-bold rounded border transition-all duration-200',
index === selectedIndex
? 'text-[9px] px-2 py-0.5 border-zinc-700 bg-zinc-800 text-zinc-300'
: 'text-[8px] px-1.5 py-0.5 border-zinc-800 bg-zinc-950 text-zinc-500'
]"
>
{{ type.type.name }}
</span>
</div>
</div>
<div
v-if="index === selectedIndex"
:class="[
'shrink-0 w-1 h-12 rounded-full bg-white shadow-[0_0_10px_rgba(255,255,255,0.5)]'
]"
></div>
</div>
<div v-if="loadingMore" class="flex items-center justify-center py-8">
<div class="flex flex-col items-center gap-3">
<div class="relative w-10 h-10">
<div class="absolute inset-0 border-2 border-zinc-800 rounded-full"></div>
<div class="absolute inset-0 border-t-2 border-zinc-500 rounded-full animate-spin"></div>
<div class="absolute inset-2 border border-zinc-700 rounded-full"></div>
</div>
<span class="text-[10px] uppercase tracking-[0.2em] text-zinc-500 font-bold">Loading more...</span>
</div>
</div>
</div>
</div>
</div>
</PageLayout>
</template>

View File

@@ -0,0 +1,42 @@
import { defineStore } from 'pinia'
export const usePokemonListStore = defineStore('pokemonList', {
state: () => ({
lastSelectedPokemonId: parseInt(localStorage.getItem('lastSelectedPokemonId')) || null,
selectedIndex: 0,
onSelect: null,
onMoveUp: null,
onMoveDown: null
}),
actions: {
setLastSelectedPokemon(id) {
this.lastSelectedPokemonId = id
if (id) {
localStorage.setItem('lastSelectedPokemonId', id.toString())
} else {
localStorage.removeItem('lastSelectedPokemonId')
}
},
setNavigationHandlers(handlers) {
this.selectedIndex = handlers.selectedIndex || 0
this.onSelect = handlers.onSelect || null
this.onMoveUp = handlers.onMoveUp || null
this.onMoveDown = handlers.onMoveDown || null
},
clearNavigationHandlers() {
this.selectedIndex = 0
this.onSelect = null
this.onMoveUp = null
this.onMoveDown = null
}
},
getters: {
hasNavigationHandlers: (state) => {
return state.onSelect !== null || state.onMoveUp !== null || state.onMoveDown !== null
}
}
})