feature: navigation in pokemon page
This commit is contained in:
61
package-lock.json
generated
61
package-lock.json
generated
@@ -9,6 +9,7 @@
|
|||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"colorthief": "^2.4.0",
|
"colorthief": "^2.4.0",
|
||||||
|
"pinia": "^3.0.4",
|
||||||
"tinycolor2": "^1.6.0",
|
"tinycolor2": "^1.6.0",
|
||||||
"vue": "^3.3.4",
|
"vue": "^3.3.4",
|
||||||
"vue-router": "^5.0.2"
|
"vue-router": "^5.0.2"
|
||||||
@@ -2797,6 +2798,66 @@
|
|||||||
"url": "https://github.com/sponsors/jonschlinkert"
|
"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": {
|
"node_modules/pkg-types": {
|
||||||
"version": "2.3.0",
|
"version": "2.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/pkg-types/-/pkg-types-2.3.0.tgz",
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"colorthief": "^2.4.0",
|
"colorthief": "^2.4.0",
|
||||||
|
"pinia": "^3.0.4",
|
||||||
"tinycolor2": "^1.6.0",
|
"tinycolor2": "^1.6.0",
|
||||||
"vue": "^3.3.4",
|
"vue": "^3.3.4",
|
||||||
"vue-router": "^5.0.2"
|
"vue-router": "^5.0.2"
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { ref } from 'vue'
|
import { ref } from 'vue'
|
||||||
import { useRouter, useRoute } from 'vue-router'
|
import { useRouter, useRoute } from 'vue-router'
|
||||||
import { appState } from '../store'
|
import { appState } from '../store'
|
||||||
|
import { usePokemonListStore } from '../stores/pokemonListStore'
|
||||||
import { MAX_SEARCH_LENGTH } from '../constants/ui'
|
import { MAX_SEARCH_LENGTH } from '../constants/ui'
|
||||||
|
|
||||||
const searchQuery = ref('')
|
const searchQuery = ref('')
|
||||||
@@ -16,6 +17,7 @@ export const homeMenuState = ref({
|
|||||||
export function usePokedexNavigation() {
|
export function usePokedexNavigation() {
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
|
const pokemonListStore = usePokemonListStore()
|
||||||
|
|
||||||
function handleKeypad(num) {
|
function handleKeypad(num) {
|
||||||
if (searchQuery.value.length < MAX_SEARCH_LENGTH) {
|
if (searchQuery.value.length < MAX_SEARCH_LENGTH) {
|
||||||
@@ -73,10 +75,10 @@ export function usePokedexNavigation() {
|
|||||||
function handleMainButton() {
|
function handleMainButton() {
|
||||||
if (route.path === '/' && homeMenuState.value.onSelect) {
|
if (route.path === '/' && homeMenuState.value.onSelect) {
|
||||||
homeMenuState.value.onSelect()
|
homeMenuState.value.onSelect()
|
||||||
|
} else if (route.path === '/pokemon' && pokemonListStore.onSelect) {
|
||||||
|
pokemonListStore.onSelect()
|
||||||
} else if (searchQuery.value) {
|
} else if (searchQuery.value) {
|
||||||
executeSearch()
|
executeSearch()
|
||||||
} else {
|
|
||||||
goHome()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,6 +91,8 @@ export function usePokedexNavigation() {
|
|||||||
function handleNext() {
|
function handleNext() {
|
||||||
if (route.path === '/' && homeMenuState.value.onMoveDown) {
|
if (route.path === '/' && homeMenuState.value.onMoveDown) {
|
||||||
homeMenuState.value.onMoveDown()
|
homeMenuState.value.onMoveDown()
|
||||||
|
} else if (route.path === '/pokemon' && pokemonListStore.onMoveDown) {
|
||||||
|
pokemonListStore.onMoveDown()
|
||||||
} else if (appState.currentPokemon) {
|
} else if (appState.currentPokemon) {
|
||||||
let nextId = appState.currentPokemon.id + 1
|
let nextId = appState.currentPokemon.id + 1
|
||||||
router.push(`/pokemon/${nextId}`)
|
router.push(`/pokemon/${nextId}`)
|
||||||
@@ -98,6 +102,8 @@ export function usePokedexNavigation() {
|
|||||||
function handlePrev() {
|
function handlePrev() {
|
||||||
if (route.path === '/' && homeMenuState.value.onMoveUp) {
|
if (route.path === '/' && homeMenuState.value.onMoveUp) {
|
||||||
homeMenuState.value.onMoveUp()
|
homeMenuState.value.onMoveUp()
|
||||||
|
} else if (route.path === '/pokemon' && pokemonListStore.onMoveUp) {
|
||||||
|
pokemonListStore.onMoveUp()
|
||||||
} else if (appState.currentPokemon && appState.currentPokemon.id > 1) {
|
} else if (appState.currentPokemon && appState.currentPokemon.id > 1) {
|
||||||
let prevId = appState.currentPokemon.id - 1
|
let prevId = appState.currentPokemon.id - 1
|
||||||
router.push(`/pokemon/${prevId}`)
|
router.push(`/pokemon/${prevId}`)
|
||||||
|
|||||||
@@ -1,10 +1,13 @@
|
|||||||
import { createApp } from "vue";
|
import { createApp } from "vue";
|
||||||
|
import { createPinia } from "pinia";
|
||||||
import "./style.css";
|
import "./style.css";
|
||||||
import App from "./App.vue";
|
import App from "./App.vue";
|
||||||
import router from "./router";
|
import router from "./router";
|
||||||
|
|
||||||
const app = createApp(App);
|
const app = createApp(App);
|
||||||
|
const pinia = createPinia();
|
||||||
|
|
||||||
|
app.use(pinia);
|
||||||
app.use(router);
|
app.use(router);
|
||||||
|
|
||||||
router.isReady().then(() => {
|
router.isReady().then(() => {
|
||||||
|
|||||||
@@ -8,9 +8,11 @@ import PageLayout from '../components/layout/PageLayout.vue'
|
|||||||
import ColorThief from 'colorthief'
|
import ColorThief from 'colorthief'
|
||||||
import { generateThemeColors } from '../utils/theme'
|
import { generateThemeColors } from '../utils/theme'
|
||||||
import { appState } from '../store'
|
import { appState } from '../store'
|
||||||
|
import { usePokemonListStore } from '../stores/pokemonListStore'
|
||||||
import { useScreenActions } from '../composables/useScreenActions'
|
import { useScreenActions } from '../composables/useScreenActions'
|
||||||
|
|
||||||
const { setButtonActions } = useScreenActions()
|
const { setButtonActions } = useScreenActions()
|
||||||
|
const pokemonListStore = usePokemonListStore()
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
@@ -53,6 +55,7 @@ function getPokemonData() {
|
|||||||
pokemon.value = json
|
pokemon.value = json
|
||||||
pokemonImage.value = pokemon.value.sprites.other['official-artwork'].front_default
|
pokemonImage.value = pokemon.value.sprites.other['official-artwork'].front_default
|
||||||
appState.setCurrentPokemon(json)
|
appState.setCurrentPokemon(json)
|
||||||
|
pokemonListStore.setLastSelectedPokemon(currentId)
|
||||||
getPokemonColors(pokemonImage.value)
|
getPokemonColors(pokemonImage.value)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -1,24 +1,306 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { onMounted } from 'vue'
|
import { ref, onMounted, onUnmounted, watch, nextTick } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
import { appState } from '../store'
|
import { appState } from '../store'
|
||||||
import PageLayout from '../components/layout/PageLayout.vue'
|
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(() => {
|
onMounted(() => {
|
||||||
appState.setPageDisplay(
|
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',
|
'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'
|
'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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<PageLayout
|
<PageLayout
|
||||||
title="POKÉMON"
|
title="POKÉMON"
|
||||||
badge=""
|
subtitle="Global Database"
|
||||||
subtitle=""
|
statusText="Browse all Pokémon species"
|
||||||
>
|
>
|
||||||
<div class="flex items-center justify-center h-full">
|
<div v-if="loading" class="flex items-center justify-center h-full">
|
||||||
<p class="text-zinc-500 text-sm">Coming Soon</p>
|
<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>
|
</div>
|
||||||
</PageLayout>
|
</PageLayout>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
42
src/stores/pokemonListStore.js
Normal file
42
src/stores/pokemonListStore.js
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user