Compare commits

..

5 Commits

Author SHA1 Message Date
5a61caf3f5 feature: navigation in pokemon page 2026-02-07 17:26:29 -06:00
8d0e4e9115 feature: new pokemon pages 2026-02-07 16:54:28 -06:00
f4dedcd66e feature: more fixes to make it amazing 2026-02-07 16:33:07 -06:00
d55aac6605 fix: redesing and add features 2026-02-07 12:06:35 -06:00
4f103c25b5 feat: Migrate to Tailwind CSS v4 2026-02-06 16:10:57 -06:00
60 changed files with 6017 additions and 2067 deletions

8
agent.md Normal file
View File

@@ -0,0 +1,8 @@
# Agent Guidelines for Vue
These rules must be followed when working on this project:
- **No Code Comments**: Never add comments to the `<script>` logic. Only add comments within the `<template>`.
- **Component Reuse**: Always create unique components and reuse them; never duplicate code.
- **Utils**: Always separate reusable functions into a `utils` folder.
- **Constants**: All constant values that can be removed from a component should be extracted. Maintain a single source of truth for constants. Do not use magic numbers in the code.

4422
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -10,15 +10,17 @@
}, },
"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": "^4.2.5" "vue-router": "^5.0.2"
}, },
"devDependencies": { "devDependencies": {
"@vitejs/plugin-vue": "^4.2.3", "@tailwindcss/vite": "^4.1.18",
"@vitejs/plugin-vue": "^6.0.4",
"autoprefixer": "^10.4.16", "autoprefixer": "^10.4.16",
"postcss": "^8.4.31", "postcss": "^8.4.31",
"tailwindcss": "^3.3.3", "tailwindcss": "^4.1.18",
"vite": "^4.4.5" "vite": "^7.3.1"
} }
} }

View File

@@ -1,6 +0,0 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View File

@@ -1,36 +1,64 @@
<script setup> <script setup>
import PokedexChassis from './components/layout/PokedexChassis.vue'
import { ref } from 'vue'
import Pokedex from './components/Pokedex.vue'
import About from './components/About.vue'
import Page404 from './components/404.vue'
const pokemonId = ref(null)
const isPokemon = ref(false)
const isAbout = ref(false)
const is404 = ref(false)
function getPage() {
const url = window.location.href;
const page = url.split("/").slice(-1)[0];
if (page > 0 && page < 1006) {
isPokemon.value = true
pokemonId.value = parseInt(page)
} else if (page === 'about') {
isAbout.value = true;
} else {
is404.value = true
}
}
getPage()
</script> </script>
<template> <template>
<router-view /> <PokedexChassis />
</template> </template>
<style>
body {
margin: 0;
overflow: hidden;
background-color: #18181b;
}
/*
CRITICAL: Override child components that rely on screen dimensions
to fit within the Pokedex screen container.
*/
.pokedex-screen .h-screen,
.pokedex-screen .min-h-screen {
height: 100% !important;
min-height: 100% !important;
width: 100% !important;
}
.pokedex-screen .w-screen {
width: 100% !important;
padding: 0 !important; /* Reset padding to context */
}
.pokedex-screen .lg\:p-12 {
padding: 1.5rem !important; /* Scale down padding */
}
.pokedex-screen .fixed {
position: absolute !important; /* Contain fixed elements like headers */
}
/* Custom Scrollbar for inside the screen */
.pokedex-screen ::-webkit-scrollbar {
width: 4px;
background: transparent;
}
.pokedex-screen ::-webkit-scrollbar-thumb {
background: #dc0a2d;
border-radius: 2px;
}
/* Reusable custom scrollbar class for content areas */
.custom-scrollbar::-webkit-scrollbar {
width: 4px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: #3f3f46;
border-radius: 4px;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: #52525b;
}
</style>

View File

@@ -1,20 +0,0 @@
<script setup>
import Footer from './Footer.vue'
</script>
<template>
<div class="w-screen h-screen bg-red-500 text-red-100 flex flex-col items-center justify-between p-12">
<div></div>
<div class="space-y-3 text-center w-[50rem]">
<div class="text-7xl font-semibold">
404
</div>
<div class="text-3xl">
Page not found
</div>
</div>
<Footer />
</div>
</template>

View File

@@ -1,50 +0,0 @@
<script setup>
import Footer from './Footer.vue'
</script>
<template>
<div class="w-screen h-screen bg-zinc-900 text-slate-100 flex flex-col items-center justify-between p-4 lg:p-12">
<div></div>
<div class="text-center lg:w-[50rem] flex flex-col gap-8 items-center">
<div class="text-5xl font-semibold">
pokedex
</div>
<div class="text-lg space-y-4 text-center">
A pokedex web app build with Vue 3 and TailwindCSS and using the <a href="https://pokeapi.co/" target="_blank" class="text-primary-500 hover:underline">PokeAPI</a>.
</div>
<div class="space-y-4 px-6">
<p>Here are a couple of popular pokemons:</p>
<ul class="flex flex-wrap justify-center items-center gap-2 text-sm">
<li class="bg-teal-800 hover:bg-teal-700 text-teal-100 rounded px-2 py-1">
<a href="/pokemon/1">Bulbasaur</a>
</li>
<li class="bg-amber-700 hover:bg-amber-600 text-amber-100 rounded px-2 py-1">
<a href="/pokemon/4">Charmander</a>
</li>
<li class="bg-cyan-700 hover:bg-cyan-600 text-cyan-100 rounded px-2 py-1">
<a href="/pokemon/7">Squirtle</a>
</li>
<li class="bg-yellow-600 hover:bg-yellow-500 text-yellow-100 rounded px-2 py-1">
<a href="/pokemon/25">Pikachu</a>
</li>
<li class="bg-yellow-800 hover:bg-yellow-700 text-yellow-100 rounded px-2 py-1">
<a href="/pokemon/133">Eevee</a>
</li>
</ul>
</div>
</div>
<Footer />
</div>
</template>

View File

@@ -1,18 +0,0 @@
<script setup>
import { defineProps } from 'vue'
const props = defineProps({
pokemonData: Object,
});
</script>
<template>
<div class="space-y-2 lg:w-96">
<div class="text-xl lg:text-2xl font-semibold">
Description
</div>
<div class="text-sm lg:text-base font-semibold">
{{ pokemonData?.flavor_text_entries[0].flavor_text }}
</div>
</div>
</template>

View File

@@ -1,22 +0,0 @@
<script setup>
import { defineProps } from 'vue'
const props = defineProps({
pokemonData: Object,
});
</script>
<template>
<div class="space-y-2 text-sm lg:text-base">
<div class="text-xl lg:text-2xl font-semibold">
Information
</div>
<div class="">
<span class="font-semibold">Height:</span> {{ pokemonData.height * 10 }}cm
</div>
<div class="">
<span class="font-semibold">Height:</span> {{ pokemonData.weight / 10 }}kg
</div>
</div>
</template>

View File

@@ -1,65 +0,0 @@
<script setup>
import { ref, defineProps } from 'vue'
import { useRoute } from 'vue-router';
const route = useRoute();
const props = defineProps({
numberPadding: Number,
});
const activeId = ref(null)
const pokedexNumbers = ref(null)
function calculatePageNumbers() {
const createArray = n => Array.from({ length: n }, (_, i) => i + 1);
const pokemonId = parseInt(route.params.id);
const highestPokemon = 1005;
const numberPadding = props.numberPadding;
const numberSize = numberPadding / 2;
let lowerNumbers = numberSize;
let upperNumbers = numberSize;
if (pokemonId < (numberSize + 1)) {
lowerNumbers = pokemonId - 1;
upperNumbers = numberPadding - lowerNumbers;
} else if (pokemonId > (highestPokemon - (numberSize + 1))) {
upperNumbers = highestPokemon - pokemonId;
lowerNumbers = numberPadding - upperNumbers;
}
let lowestNumbers = createArray(lowerNumbers)
lowestNumbers = lowestNumbers.map((x, index) => {
return pokemonId - (lowerNumbers - index)
})
let highestNumbers = createArray(upperNumbers)
highestNumbers = highestNumbers.map((x, index) => {
return pokemonId + (index + 1)
})
pokedexNumbers.value = [
...lowestNumbers,
pokemonId,
...highestNumbers
]
activeId.value = pokemonId
}
calculatePageNumbers()
</script>
<template>
<div v-for="number in pokedexNumbers" class="cursor-pointer">
<a :href="$router.resolve({ name: 'pokemon', params: { id: number } }).href">
<span v-if="number === activeId" class="font-bold text-lg">{{ number }}</span>
<span v-else>{{ number }}</span>
</a>
</div>
</template>

View File

@@ -1,136 +0,0 @@
<script setup>
import { ref, computed } from 'vue'
import { useRoute } from 'vue-router';
const route = useRoute();
import Pages from './Pages.vue'
import Description from './Description.vue'
import Information from './Information.vue'
import Pokemon from './Pokemon.vue'
import PokedexIndex from './PokemonIndex.vue'
import Footer from './Footer.vue'
import ColorThief from 'colorthief'
import tinycolor from 'tinycolor2'
const pokemon = ref(null)
const pokemonImage = ref(null)
const pokemonSpecies = ref(null)
const darkColor = ref(null)
const lightColor = ref(null)
const isDark = ref(true)
function getPokemonColors(image) {
const colorThief = new ColorThief()
const img = new Image();
img.crossOrigin = 'anonymous';
img.src = image
img.onload = () => {
const color = colorThief.getColor(img)
const rgbColor = `rgb (${color[0]}, ${color[1]}, ${color[2]})`
let originalColor = tinycolor(rgbColor);
let light = originalColor.brighten(10).toString();
let dark = originalColor.darken(30).toString();
darkColor.value = dark
lightColor.value = light
}
}
function getPokemonData() {
fetch(`https://pokeapi.co/api/v2/pokemon/${route.params.id}`)
.then((response) => response.json())
.then((json) => pokemon.value = json)
.then(() => {
pokemonImage.value = pokemon.value.sprites.other['official-artwork'].front_default
getPokemonColors(pokemonImage.value)
})
fetch(`https://pokeapi.co/api/v2/pokemon-species/${route.params.id}`)
.then((response) => response.json())
.then((json) => pokemonSpecies.value = json);
}
function changeTheme() {
isDark.value = !isDark.value
}
const pokemonColors = computed(() => {
if (isDark.value) {
return {
backgroundColor: darkColor.value,
color: lightColor.value
}
} else {
return {
backgroundColor: lightColor.value,
color: darkColor.value
}
}
});
getPokemonData()
</script>
<template>
<div v-if="pokemon && pokemonSpecies && pokemonColors">
<div class="w-screen min-h-screen text-white p-8 lg:p-12 font-sans flex flex-col" :style="pokemonColors">
<div class="flex flex-col items-center justify-between flex-grow">
<PokedexIndex :pokemon="pokemon" :pokemonData="pokemonSpecies" />
<div class="flex justify-between items-center w-full lg:gap-24 flex-grow">
<div class="relative text-center hidden lg:block">
<div @click="changeTheme"
class="lg:absolute top-0 left-0 -rotate-90 w-72 -translate-x-36 font-semibold">
Change Theme
</div>
</div>
<div class="w-full space-y-24 flex flex-col items-center justify-center" @click="changeTheme">
<Pokemon :pokemonData="pokemonSpecies" :pokemonImage="pokemonImage" />
<div class="flex flex-col lg:flex-row justify-between gap-6 w-full">
<Information :pokemonData="pokemon" />
<Description :pokemonData="pokemonSpecies" />
</div>
</div>
<div class="hidden lg:block space-y-4 text-center">
<Pages :numberPadding="16" />
</div>
</div>
<div class="space-y-8">
<div class="flex items-center justify-center text-center lg:hidden space-x-4">
<Pages :numberPadding="6" />
</div>
<Footer />
</div>
</div>
</div>
</div>
</template>

View File

@@ -1,25 +0,0 @@
<script setup>
import { defineProps } from 'vue'
const props = defineProps({
pokemonData: Object,
pokemonImage: String,
});
</script>
<template>
<div class="lg-translate-x-24 relative">
<div class="text-7xl lg:text-[15rem] font-semibold tracking-tighter text-center">
{{ pokemonData.names[0].name }}
</div>
<div class="text-4xl lg:text-9xl font-semibold tracking-tighter text-center">
({{ pokemonData.names[1].name }})
</div>
<div class="absolute top-0 left-0 w-full h-full flex items-center justify-center">
<div class="w-64 h-64 lg:w-auto lg:h-auto">
<img :src="pokemonImage" :alt="pokemonData.names[1].name">
</div>
</div>
</div>
</template>

View File

@@ -1,29 +0,0 @@
<script setup>
import { defineProps } from 'vue'
const props = defineProps({
pokemon: Object,
pokemonData: Object,
});
</script>
<template>
<div class="flex items-center justify-between w-full">
<div class="space-y-1">
<div class="text-sm lg:text-lg font-semibold">
#{{ pokemon.id }}
</div>
<div class="text-lg lg:text-3xl font-semibold">
{{ pokemonData.name.charAt(0).toUpperCase() + pokemonData.name.slice(1).toLowerCase() }}
</div>
</div>
<div class=" text-lg lg:text-4xl font-bold">
Pokedex
</div>
</div>
</template>

View File

@@ -4,7 +4,7 @@
<a :href="$router.resolve({ name: 'pokemon', params: { id: 1 } }).href"> <a :href="$router.resolve({ name: 'pokemon', params: { id: 1 } }).href">
Pokedex Pokedex
</a> </a>
<router-link :to="{ name: 'about' }">About</router-link> <router-link :to="{ name: 'home' }">Home</router-link>
<a href="https://dribbble.com/shots/2859891--025-Pikachu" target="_blank">Inspiration</a> <a href="https://dribbble.com/shots/2859891--025-Pikachu" target="_blank">Inspiration</a>
<a href="https://github.com/xyvs/pokedex" target="_blank">Code</a> <a href="https://github.com/xyvs/pokedex" target="_blank">Code</a>
</div> </div>

View File

@@ -0,0 +1,61 @@
<script setup>
const props = defineProps({
title: {
type: String,
required: true
},
badge: {
type: [String, Array],
default: null
},
subtitle: {
type: String,
default: null
},
batteryLevel: {
type: Number,
default: 80
}
})
</script>
<template>
<header class="h-12 border-b border-zinc-800 flex items-center justify-between px-4 bg-zinc-900/90 backdrop-blur z-20 shrink-0 select-none">
<div class="flex items-center gap-2">
<h1 class="font-bold text-sm uppercase tracking-tight text-white">{{ title }}</h1>
<!-- Badge - can be string or array -->
<template v-if="badge">
<!-- Single badge (string) -->
<span v-if="typeof badge === 'string'"
class="text-zinc-500 font-mono text-[10px] border border-zinc-800 px-1.5 py-0.5 rounded bg-zinc-900">
{{ badge }}
</span>
<!-- Multiple badges (array) -->
<div v-else class="flex gap-1">
<span v-for="(item, index) in badge" :key="index"
class="text-[8px] uppercase font-bold px-1.5 py-0.5 rounded border border-zinc-700 bg-zinc-900 text-zinc-400">
{{ item }}
</span>
</div>
</template>
<!-- Separator and Subtitle -->
<template v-if="subtitle">
<div class="h-4 w-px bg-zinc-800"></div>
<span class="text-[10px] text-zinc-500">{{ subtitle }}</span>
</template>
</div>
<div class="flex items-center gap-3">
<!-- Battery Indicator -->
<div class="flex items-center gap-1">
<div class="w-6 h-3 rounded-xs border border-zinc-600 p-px relative flex">
<div class="h-full bg-zinc-400 rounded-[1px]" :style="{ width: `${batteryLevel}%` }"></div>
<div class="absolute -right-0.75 top-1/2 -translate-y-1/2 w-0.5 h-1.5 bg-zinc-600 rounded-r-[1px]"></div>
</div>
</div>
</div>
</header>
</template>

View File

@@ -0,0 +1,68 @@
<script setup>
import { computed } from 'vue'
import BezelButton from '../ui/BezelButton.vue'
import ActionDisplay from '../ui/ActionDisplay.vue'
import { useScreenActions } from '../../composables/useScreenActions'
const { button1, button2, button3, button4, button5, handleButton } = useScreenActions()
const softButtons = computed(() => [
{ id: 1, active: button1.value.active, handler: () => handleButton(1) },
{ id: 2, active: button2.value.active, handler: () => handleButton(2) },
{ id: 3, active: button3.value.active, handler: () => handleButton(3) },
{ id: 4, active: button4.value.active, handler: () => handleButton(4) },
{ id: 5, active: button5.value.active, handler: () => handleButton(5) }
])
</script>
<template>
<!-- Left Panel: The Screen -->
<div class="w-[50%] h-full p-12 pt-36 flex flex-col relative border-r-4 border-[#89061c] bg-[#dc0a2d]">
<!-- Display Bezel -->
<div class="flex-1 bg-zinc-200 rounded-bl-[4rem] rounded-tl-xl rounded-tr-xl rounded-br-xl p-8 flex flex-col gap-4 shadow-[inset_0_0_30px_rgba(0,0,0,0.2)] border-4 border-zinc-300 relative overflow-hidden" style="aspect-ratio: 3/4;">
<!-- Top dots on bezel -->
<div class="flex justify-center gap-4 mb-2 opacity-50">
<div class="w-2 h-2 bg-red-500 rounded-full"></div>
<div class="w-2 h-2 bg-red-500 rounded-full"></div>
</div>
<!-- The Actual Screen -->
<div class="flex-1 bg-zinc-900 rounded-lg overflow-hidden border-4 border-zinc-700 shadow-inner relative flex flex-col pokedex-screen">
<!-- Main Content Layer -->
<div class="relative flex-1 overflow-hidden pointer-events-none">
<router-view v-slot="{ Component }">
<component :is="Component" />
</router-view>
<!-- Screen Reflection/Scanline overlay -->
<div class="absolute inset-0 pointer-events-none bg-[linear-gradient(rgba(18,16,16,0)_50%,rgba(0,0,0,0.25)_50%),linear-gradient(90deg,rgba(255,0,0,0.06),rgba(0,255,0,0.02),rgba(0,0,255,0.06))] z-10 bg-[length:100%_2px,3px_100%] opacity-20"></div>
</div>
<!-- Soft Key Labels -->
<ActionDisplay />
</div>
<!-- Physical Soft Buttons (In Bezel) -->
<div class="grid grid-cols-5 gap-2 px-1 mt-2 z-10 w-full">
<BezelButton
v-for="button in softButtons"
:key="button.id"
:active="button.active"
@click="button.handler"
/>
</div>
<!-- Bottom Bezel Details -->
<div class="flex justify-between items-center px-4 mt-2">
<div class="w-6 h-6 bg-red-600 rounded-full border border-red-800 animate-pulse"></div>
<div class="flex gap-1">
<div class="h-1 w-8 bg-zinc-700 rounded-full"></div>
<div class="h-1 w-8 bg-zinc-700 rounded-full"></div>
<div class="h-1 w-8 bg-zinc-700 rounded-full"></div>
</div>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,56 @@
<script setup>
import Header from './Header.vue'
import StatusBar from './StatusBar.vue'
import BackgroundGrid from '../ui/BackgroundGrid.vue'
const props = defineProps({
title: {
type: String,
required: true
},
subtitle: {
type: String,
default: ''
},
badge: {
type: [String, Array],
default: ''
},
batteryLevel: {
type: Number,
default: 80
},
gridOpacity: {
type: Number,
default: 10
}
})
</script>
<template>
<div class="relative w-full h-full bg-zinc-950 text-white font-sans flex flex-col selection:bg-red-500/30">
<div class="absolute inset-0 z-0">
<BackgroundGrid :opacity="gridOpacity" />
<slot name="background" />
</div>
<div class="relative shrink-0 z-20">
<Header
:title="title"
:badge="badge"
:subtitle="subtitle"
:battery-level="batteryLevel"
/>
</div>
<div class="relative flex-1 min-h-0 z-10 flex flex-col">
<slot />
</div>
<div class="relative shrink-0 z-20">
<StatusBar />
</div>
</div>
</template>

View File

@@ -0,0 +1,53 @@
<script setup>
import { usePokedexNavigation } from '../../composables/usePokedexNavigation'
import LeftScreen from './LeftScreen.vue'
import RightControls from './RightControls.vue'
const { searchQuery, handleKeypad, handleMainButton, handleBackButton, goHome, handleNext, handlePrev, handleSearchGo, handleSearchClear } = usePokedexNavigation()
</script>
<template>
<div class="pokeball-bg h-screen w-screen flex items-center justify-center p-8 overflow-hidden font-sans select-none">
<!-- Main Red Chassis -->
<div class="relative w-[1200px] h-[900px] bg-[#dc0a2d] rounded-3xl shadow-[0_50px_100px_-20px_rgba(0,0,0,0.5)] flex flex-row overflow-hidden border-b-8 border-r-8 border-[#89061c]">
<!-- Structural Inclined Top Bar (Decoration) -->
<div class="absolute top-0 left-0 w-full h-32 pointer-events-none z-20">
<div class="w-[50%] h-full bg-[#dc0a2d] border-b-4 border-r-4 border-[#89061c] pt-6 pl-8 flex gap-4 shadow-lg">
<!-- Big Blue Lens -->
<div class="w-20 h-20 rounded-full bg-blue-400 border-4 border-white shadow-[inset_0_0_20px_rgba(0,0,0,0.5)] relative overflow-hidden group hover:brightness-110 transition-all">
<div class="absolute top-2 left-2 w-6 h-6 bg-white rounded-full opacity-50 blur-[2px]"></div>
<div class="absolute inset-0 bg-blue-500/20 animate-pulse"></div>
</div>
</div>
</div>
<!-- Left Screen Component -->
<LeftScreen />
<!-- Right Controls Component -->
<RightControls
:search-query="searchQuery"
@keypad="handleKeypad"
@main-button="handleMainButton"
@back-button="handleBackButton"
@go-home="goHome"
@next="handleNext"
@prev="handlePrev"
@search-go="handleSearchGo"
@search-clear="handleSearchClear"
/>
</div>
</div>
</template>
<style scoped>
.pokeball-bg {
background-color: #ffffff;
background-image: url("data:image/svg+xml,%3Csvg width='80' height='80' xmlns='http://www.w3.org/2000/svg'%3E%3Cg opacity='0.8'%3E%3Ccircle cx='20' cy='20' r='12' fill='none' stroke='%23e4e4e7' stroke-width='2'/%3E%3Cpath d='M 8 20 L 32 20' stroke='%23e4e4e7' stroke-width='2'/%3E%3Ccircle cx='20' cy='20' r='4' fill='none' stroke='%23d4d4d8' stroke-width='2'/%3E%3Ccircle cx='20' cy='20' r='2' fill='%23d4d4d8'/%3E%3Ccircle cx='60' cy='60' r='8' fill='none' stroke='%23e4e4e7' stroke-width='1.5'/%3E%3Cpath d='M 52 60 L 68 60' stroke='%23e4e4e7' stroke-width='1.5'/%3E%3Ccircle cx='60' cy='60' r='3' fill='none' stroke='%23d4d4d8' stroke-width='1.5'/%3E%3Ccircle cx='60' cy='60' r='1.5' fill='%23d4d4d8'/%3E%3Ccircle cx='50' cy='15' r='6' fill='none' stroke='%23e4e4e7' stroke-width='1.2'/%3E%3Cpath d='M 44 15 L 56 15' stroke='%23e4e4e7' stroke-width='1.2'/%3E%3Ccircle cx='50' cy='15' r='2' fill='%23d4d4d8'/%3E%3Ccircle cx='15' cy='55' r='7' fill='none' stroke='%23e4e4e7' stroke-width='1.2'/%3E%3Cpath d='M 8 55 L 22 55' stroke='%23e4e4e7' stroke-width='1.2'/%3E%3Ccircle cx='15' cy='55' r='2.5' fill='%23d4d4d8'/%3E%3C/g%3E%3C/svg%3E");
background-size: 80px 80px;
background-repeat: repeat;
}
</style>

View File

@@ -0,0 +1,161 @@
<script setup>
import { useRouter } from 'vue-router'
import { appState } from '../../store'
import { SIDE_MENU_ITEMS } from '../../constants/ui'
import PillButton from '../ui/PillButton.vue'
import ArrowButton from '../ui/ArrowButton.vue'
import NumpadKey from '../ui/NumpadKey.vue'
import SideMenuButton from '../ui/SideMenuButton.vue'
import PokemonInfoCard from '../ui/PokemonInfoCard.vue'
import SearchDisplay from '../ui/SearchDisplay.vue'
import StatusButton from '../ui/StatusButton.vue'
const router = useRouter()
const props = defineProps({
searchQuery: {
type: String,
default: ''
}
})
const emit = defineEmits([
'keypad',
'main-button',
'back-button',
'go-home',
'next',
'prev',
'search-go',
'search-clear'
])
function navigateToMenuItem(item) {
if (item.route) {
router.push(item.route)
}
}
</script>
<template>
<!-- Right Panel: Controls -->
<div class="w-[50%] h-full bg-[#dc0a2d] pl-8 pr-4 pt-16 flex flex-col justify-between pb-12 relative shadow-inner z-10">
<!-- Info Screen (Retro Style) -->
<div class="w-full bg-[#c4cfa1] rounded-lg border-8 border-[#757d61] shadow-[inset_0_0_10px_rgba(0,0,0,0.2)] mb-4 flex flex-col relative overflow-hidden p-3 gap-2" style="aspect-ratio: 5/2;">
<!-- Dot Matrix Pattern -->
<div class="absolute inset-0 pointer-events-none z-10 opacity-30"
style="background-image: radial-gradient(#2b2b2b 20%, transparent 20%); background-size: 3px 3px;">
</div>
<!-- LCD Shadow -->
<div class="absolute inset-0 z-20 pointer-events-none shadow-[inset_0_0_20px_rgba(0,0,0,0.1)]"></div>
<!-- Content -->
<div class="flex-1 flex flex-col font-mono text-[#2b2b2b] uppercase z-0 relative justify-between overflow-hidden">
<!-- Header Line with Icon -->
<div class="border-b-2 border-[#2b2b2b]/20 pb-1 flex justify-between items-center shrink-0 mb-1">
<div class="flex items-center gap-1.5">
<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="4" stroke-linecap="round" stroke-linejoin="round" class="text-[#2b2b2b]"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>
<span class="text-[10px] font-bold tracking-wider">INFO</span>
</div>
<span class="text-[9px] font-bold opacity-60">{{ appState.currentPokemon ? 'ACTIVE' : 'READY' }}</span>
</div>
<!-- Page Mode - Show Icon and Title -->
<div v-if="!appState.currentPokemon && appState.pageDisplay.icon" class="flex flex-col items-center justify-center gap-3 flex-1">
<svg
xmlns="http://www.w3.org/2000/svg"
width="48"
height="48"
viewBox="0 0 24 24"
fill="currentColor"
class="text-[#2b2b2b] opacity-70"
>
<path :d="appState.pageDisplay.icon" />
</svg>
<div class="text-[11px] tracking-[0.2em] font-bold opacity-70">{{ appState.pageDisplay.title }}</div>
</div>
<!-- Default Mode - No Page Info -->
<div v-else-if="!appState.currentPokemon" class="flex flex-col items-center justify-center opacity-60 flex-1">
<div class="text-[10px] tracking-widest font-bold">WAITING INPUT</div>
</div>
<!-- Pokemon Mode - Three Pokemon Display -->
<div v-else class="flex items-stretch gap-2 flex-1 min-h-0">
<PokemonInfoCard variant="prev" :pokemon="appState.previousPokemon" />
<PokemonInfoCard variant="active" :pokemon="appState.currentPokemon" />
<PokemonInfoCard variant="next" :pokemon="appState.nextPokemon" />
</div>
</div>
</div>
<!-- Split Control Section -->
<div class="flex flex-1 mb-2 min-h-0 items-center justify-between gap-4">
<!-- LEFT: Search Display + Compact Numpad (Fixed 240px) -->
<div class="w-[240px] flex-none flex flex-col gap-2 mx-auto">
<div class="flex items-center gap-1.5 pl-1 opacity-70 mb-1">
<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="4" stroke-linecap="round" stroke-linejoin="round" class="text-black/60"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/></svg>
<span class="text-[9px] font-black tracking-wider text-black/60">SEARCH</span>
</div>
<SearchDisplay :query="searchQuery" />
<!-- Compact Black Tactile Numpad -->
<div class="grid grid-cols-3 gap-2.5 content-start">
<NumpadKey v-for="n in 9" :key="n" :label="n" @click="emit('keypad', n)" />
<NumpadKey label="DEL" variant="danger" @click="searchQuery ? emit('search-clear') : null" />
<NumpadKey label="0" @click="emit('keypad', 0)" />
<NumpadKey label="GO" variant="success" @click="searchQuery ? emit('search-go') : null" />
</div>
</div>
<!-- RIGHT: Slim Blue Buttons List -->
<div class="w-[140px] flex flex-col gap-2 mr-4">
<SideMenuButton
v-for="(item, i) in SIDE_MENU_ITEMS"
:key="i"
:label="item.label"
@click="navigateToMenuItem(item)"
/>
</div>
</div>
<!-- D-Pad & Controls -->
<div class="flex items-end justify-between w-full px-4">
<!-- Back/Home Buttons -->
<div class="flex gap-4 mb-2">
<!-- Home -->
<PillButton @click="emit('go-home')">
<span class="text-[9px] font-black tracking-wider text-black/60">HOME</span>
<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10" viewBox="0 0 24 24" fill="currentColor" class="text-black/60"><path d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z"/></svg>
</PillButton>
<!-- Back -->
<PillButton @click="emit('back-button')">
<span class="text-[9px] font-black tracking-wider text-black/60">BACK</span>
<svg xmlns="http://www.w3.org/2000/svg" width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="4" stroke-linecap="round" stroke-linejoin="round" class="text-black/60"><path d="M9 14 4 9l5-5"/></svg>
</PillButton>
</div>
<!-- Functional D-Pad (Arrow Buttons + New Green OK Button) -->
<div class="flex flex-col items-center gap-3 mb-2">
<StatusButton label="OK" @click="emit('main-button')" />
<div class="flex items-center gap-4">
<ArrowButton direction="left" @click="emit('prev')" />
<ArrowButton direction="right" @click="emit('next')" />
</div>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,48 @@
<script setup>
defineProps({
// Control the split ratio - defaults to equal split
leftWidth: {
type: String,
default: '50%'
},
rightWidth: {
type: String,
default: '50%'
},
// Optional gap between panels
gap: {
type: String,
default: '0'
},
// Control whether sides have borders
showDivider: {
type: Boolean,
default: false
}
})
</script>
<template>
<div class="flex h-full min-h-0 overflow-hidden" :style="{ gap }">
<!-- Left Side -->
<div
class="flex flex-col min-h-0 overflow-hidden"
:class="{ 'border-r border-zinc-800': showDivider }"
:style="{ width: leftWidth, flexShrink: 0 }"
>
<slot name="left">
<!-- Default left content if none provided -->
</slot>
</div>
<!-- Right Side -->
<div
class="flex flex-col flex-1 min-h-0 overflow-hidden"
:style="{ width: rightWidth }"
>
<slot name="right">
<!-- Default right content if none provided -->
</slot>
</div>
</div>
</template>

View File

@@ -0,0 +1,9 @@
<script setup>
</script>
<template>
<div class="h-6 bg-zinc-900 border-t border-zinc-800 flex items-center justify-between px-4 text-[9px] text-zinc-600 uppercase tracking-widest shrink-0">
<span>Global Database</span>
<span>v2.0.4 - Stable</span>
</div>
</template>

View File

@@ -0,0 +1,82 @@
<script setup>
import { ref, onMounted } from 'vue'
import ColorThief from 'colorthief'
import { generateThemeColors } from '../utils/theme'
const featuredPokemon = ref([
{ id: 1, name: 'Bulbasaur', type: 'Grass', customBg: '#2d2d2d', customText: '#ffffff' },
{ id: 4, name: 'Charmander', type: 'Fire', customBg: '#2d2d2d', customText: '#ffffff' },
{ id: 7, name: 'Squirtle', type: 'Water', customBg: '#2d2d2d', customText: '#ffffff' },
{ id: 25, name: 'Pikachu', type: 'Electric', customBg: '#2d2d2d', customText: '#ffffff' },
{ id: 133, name: 'Eevee', type: 'Normal', customBg: '#2d2d2d', customText: '#ffffff' },
{ id: 150, name: 'Mewtwo', type: 'Psychic', customBg: '#2d2d2d', customText: '#ffffff' },
])
function getImageUrl(id) {
return `https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon/other/official-artwork/${id}.png`
}
function extractColors() {
const colorThief = new ColorThief()
featuredPokemon.value.forEach(poke => {
const img = new Image()
img.crossOrigin = 'anonymous'
img.src = getImageUrl(poke.id)
img.onload = () => {
const color = colorThief.getColor(img)
const { background, text } = generateThemeColors(color)
poke.customBg = background
poke.customText = text
}
})
}
onMounted(() => {
extractColors()
})
</script>
<template>
<div class="h-full w-full bg-zinc-950 flex flex-col border-r border-zinc-800">
<div class="p-6 border-b border-zinc-800 bg-zinc-950 sticky top-0 z-10">
<h3 class="text-xs font-mono uppercase tracking-[0.2em] text-zinc-500">System Index // Popular</h3>
</div>
<div class="flex-1 overflow-y-auto">
<div class="grid grid-cols-1">
<router-link
v-for="(poke, index) in featuredPokemon"
:key="poke.id"
:to="`/pokemon/${poke.id}`"
class="group relative h-24 w-full flex items-center border-b border-zinc-800 hover:bg-zinc-900 transition-colors duration-200"
>
<div class="w-24 h-full flex items-center justify-center border-r border-zinc-800 bg-zinc-900/50 group-hover:bg-zinc-800 transition-colors">
<span class="font-mono text-zinc-600 text-xs">#{{ String(poke.id).padStart(3, '0') }}</span>
</div>
<div class="flex-1 px-6 flex flex-col justify-center">
<h2 class="text-lg font-bold uppercase tracking-wider text-zinc-300 group-hover:text-white transition-colors">{{ poke.name }}</h2>
<div class="flex items-center gap-2 mt-1">
<span class="w-1.5 h-1.5 bg-zinc-700 rounded-full group-hover:bg-red-500 transition-colors"></span>
<span class="text-[10px] font-mono uppercase text-zinc-500 tracking-widest">{{ poke.type }}</span>
</div>
</div>
<div class="absolute right-4 top-1/2 -translate-y-1/2 opacity-0 group-hover:opacity-100 transition-opacity duration-200">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2" stroke-linecap="square" stroke-linejoin="round"><path d="M5 12h14"></path><path d="m12 5 7 7-7 7"></path></svg>
</div>
<!-- Subtle image hint on hover -->
<img
:src="getImageUrl(poke.id)"
:alt="poke.name"
class="absolute right-12 h-[120%] object-contain opacity-0 group-hover:opacity-20 pointer-events-none transition-opacity duration-200 grayscale"
>
</router-link>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,18 @@
<script setup>
import { defineProps } from 'vue'
const props = defineProps({
pokemonData: Object,
});
</script>
<template>
<div class="h-full flex flex-col">
<h3 class="text-[10px] uppercase font-bold text-zinc-500 tracking-widest border-b border-zinc-800 pb-1 mb-2">Data Entry</h3>
<div class="flex-1 font-mono text-xs text-zinc-400 leading-relaxed overflow-y-auto">
<span class="text-green-500 mr-2">></span>
{{ pokemonData?.flavor_text_entries.find(e => e.language.name === 'en')?.flavor_text.replace(/\f/g, ' ') || 'No data found.' }}
<span class="inline-block w-1.5 h-3 bg-green-500 ml-1 animate-pulse align-middle"></span>
</div>
</div>
</template>

View File

@@ -0,0 +1,26 @@
<script setup>
import { defineProps } from 'vue'
const props = defineProps({
pokemonData: Object,
});
</script>
<template>
<div class="space-y-4">
<h3 class="text-[10px] uppercase font-bold text-zinc-500 tracking-widest border-b border-zinc-800 pb-1">Biometrics</h3>
<div class="grid grid-cols-2 gap-2">
<div class="bg-zinc-900 border border-zinc-800 p-2 rounded flex flex-col items-center">
<span class="text-[9px] uppercase text-zinc-500 font-bold">Height</span>
<span class="text-lg font-mono text-zinc-200">{{ pokemonData.height / 10 }}m</span>
</div>
<div class="bg-zinc-900 border border-zinc-800 p-2 rounded flex flex-col items-center">
<span class="text-[9px] uppercase text-zinc-500 font-bold">Weight</span>
<span class="text-lg font-mono text-zinc-200">{{ pokemonData.weight / 10 }}kg</span>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,19 @@
<script setup>
import { defineProps } from 'vue'
const props = defineProps({
pokemonData: Object,
pokemonImage: String,
});
</script>
<template>
<div class="w-full h-full flex items-center justify-center p-2 relative z-10">
<img
:src="pokemonImage"
:alt="pokemonData.names[1].name"
class="max-w-full max-h-full object-contain filter drop-shadow-[0_0_10px_rgba(255,255,255,0.2)]"
>
</div>
</template>

View File

@@ -0,0 +1,50 @@
<script setup>
import { computed } from 'vue'
import { useScreenActions } from '../../composables/useScreenActions'
const { button1, button2, button3, button4, button5 } = useScreenActions()
const buttons = computed(() => [
button1.value.label,
button2.value.label,
button3.value.label,
button4.value.label,
button5.value.label
])
</script>
<template>
<div class="h-8 bg-zinc-600 border-t-4 border-zinc-500 grid grid-cols-5 gap-0 z-20 relative shadow-md items-center">
<!-- Button 1 -->
<div class="h-full relative flex items-center justify-center border-r border-zinc-500 bg-gradient-to-b from-transparent to-zinc-700/30">
<span v-if="buttons[0]" class="text-[9px] font-bold text-zinc-100 tracking-wider z-10 drop-shadow-sm">{{ buttons[0] }}</span>
<svg v-if="buttons[0]" class="absolute -bottom-1.5 w-3 h-3 text-zinc-400 fill-current" viewBox="0 0 24 24"><path d="M12 21l-10-16h20z"/></svg>
</div>
<!-- Button 2 -->
<div class="h-full relative flex items-center justify-center border-r border-zinc-500/50">
<span v-if="buttons[1]" class="text-[9px] font-bold text-zinc-100 tracking-wider z-10 drop-shadow-sm">{{ buttons[1] }}</span>
<svg v-if="buttons[1]" class="absolute -bottom-1.5 w-3 h-3 text-zinc-400 fill-current" viewBox="0 0 24 24"><path d="M12 21l-10-16h20z"/></svg>
</div>
<!-- Button 3 -->
<div class="h-full relative flex items-center justify-center border-r border-zinc-500/50">
<span v-if="buttons[2]" class="text-[9px] font-bold text-zinc-100 tracking-wider z-10 drop-shadow-sm">{{ buttons[2] }}</span>
<svg v-if="buttons[2]" class="absolute -bottom-1.5 w-3 h-3 text-zinc-400 fill-current" viewBox="0 0 24 24"><path d="M12 21l-10-16h20z"/></svg>
</div>
<!-- Button 4 -->
<div class="h-full relative flex items-center justify-center border-r border-zinc-500/50">
<span v-if="buttons[3]" class="text-[9px] font-bold text-zinc-100 tracking-wider z-10 drop-shadow-sm">{{ buttons[3] }}</span>
<svg v-if="buttons[3]" class="absolute -bottom-1.5 w-3 h-3 text-zinc-400 fill-current" viewBox="0 0 24 24"><path d="M12 21l-10-16h20z"/></svg>
</div>
<!-- Button 5 -->
<div class="h-full relative flex items-center justify-center bg-gradient-to-b from-transparent to-zinc-700/30">
<span v-if="buttons[4]" class="text-[9px] font-bold text-zinc-100 tracking-wider z-10 drop-shadow-sm">{{ buttons[4] }}</span>
<svg v-if="buttons[4]" class="absolute -bottom-1.5 w-3 h-3 text-zinc-400 fill-current" viewBox="0 0 24 24"><path d="M12 21l-10-16h20z"/></svg>
</div>
</div>
</template>

View File

@@ -0,0 +1,19 @@
<script setup>
defineProps({
direction: {
type: String,
default: 'right',
validator: (val) => ['left', 'right'].includes(val)
}
})
</script>
<template>
<div
class="w-14 h-14 bg-zinc-800 border-2 border-zinc-950 rounded-lg shadow-[0_6px_0_rgba(0,0,0,0.5),inset_0_1px_0_rgba(255,255,255,0.2)] active:translate-y-[6px] active:shadow-[inset_0_1px_0_rgba(255,255,255,0.2)] flex items-center justify-center cursor-pointer group transition-all">
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" class="text-zinc-500 group-hover:text-zinc-200 transition-colors transform group-active:scale-95">
<path v-if="direction === 'left'" d="M15 18l-6-6 6-6" />
<path v-else d="M9 18l6-6-6-6" />
</svg>
</div>
</template>

View File

@@ -0,0 +1,16 @@
<script setup>
defineProps({
opacity: {
type: Number,
default: 10
}
})
</script>
<template>
<div
class="absolute inset-0 pointer-events-none"
:class="`opacity-${opacity}`"
style="background-image: linear-gradient(#333 1px, transparent 1px), linear-gradient(90deg, #333 1px, transparent 1px); background-size: 20px 20px;">
</div>
</template>

View File

@@ -0,0 +1,11 @@
<script setup>
defineProps({
active: Boolean
})
</script>
<template>
<div class="h-8 bg-[#2b2b2b] rounded-md border-b-4 border-zinc-950 shadow-[0_3px_0_rgba(0,0,0,0.8),inset_0_1px_0_rgba(255,255,255,0.1)] active:translate-y-[2px] active:shadow-none active:border-b-2 cursor-pointer transition-all hover:bg-[#3f3f3f] flex items-center justify-center">
<div class="w-8 h-1 bg-zinc-800 rounded-full opacity-30" :class="{ 'opacity-100 bg-green-500': active }"></div>
</div>
</template>

View File

@@ -0,0 +1,22 @@
<script setup>
defineProps({
label: {
type: [String, Number],
required: true
},
variant: {
type: String,
default: 'default',
validator: (val) => ['default', 'danger', 'success'].includes(val)
}
})
</script>
<template>
<div
class="h-12 bg-zinc-800 border-b-4 border-zinc-950 rounded shadow-[0_2px_0_rgba(0,0,0,0.3),inset_0_1px_0_rgba(255,255,255,0.2)] active:translate-y-[2px] active:border-b-2 active:shadow-none flex items-center justify-center cursor-pointer group transition-all">
<span v-if="variant === 'default'" class="text-zinc-400 font-bold text-lg font-mono group-hover:text-zinc-200">{{ label }}</span>
<span v-else-if="variant === 'danger'" class="text-xs font-black text-red-500/70 group-hover:text-red-400 tracking-wider">{{ label }}</span>
<span v-else-if="variant === 'success'" class="text-xs font-black text-green-500/70 group-hover:text-green-400 tracking-wider">{{ label }}</span>
</div>
</template>

View File

@@ -0,0 +1,11 @@
<template>
<div class="flex flex-col items-center gap-1.5 cursor-pointer group">
<!-- Tactile Pill Button -->
<div class="w-12 h-4 rounded-full bg-zinc-800 border border-zinc-950 shadow-[0_3px_0_rgba(0,0,0,0.6)] group-active:translate-y-[3px] group-active:shadow-none transition-transform"></div>
<!-- Label + Icon -->
<div class="flex items-center gap-1 opacity-70 group-hover:opacity-100 transition-opacity">
<slot />
</div>
</div>
</template>

View File

@@ -0,0 +1,59 @@
<script setup>
import { computed } from 'vue'
const props = defineProps({
pokemon: Object,
variant: {
type: String,
required: true,
validator: (val) => ['prev', 'active', 'next'].includes(val)
}
})
const isActive = computed(() => props.variant === 'active')
const containerClass = computed(() => isActive.value
? 'border-4 border-[#2b2b2b]/40 rounded bg-[#2b2b2b]/10 p-2 relative'
: 'border-2 border-[#2b2b2b]/30 rounded p-2 opacity-70 relative bg-[#2b2b2b]/5'
)
const labelText = computed(() => {
if (props.variant === 'active') return '● ACTIVE'
if (props.variant === 'prev') return '◄ PREV'
return 'NEXT ►'
})
const labelClass = computed(() => isActive.value
? 'text-sm leading-none font-bold bg-[#2b2b2b] text-[#c4cfa1] px-2 py-0.5 rounded-sm'
: 'text-sm leading-none font-bold opacity-70 bg-[#c4cfa1] px-2 py-0.5 rounded-sm'
)
const typeClass = computed(() => isActive.value
? 'text-[8px] uppercase font-bold px-1.5 py-0.5 rounded-sm border-2 border-[#2b2b2b]/40 bg-[#2b2b2b]/10'
: 'text-[7px] uppercase font-bold px-1 py-0.5 rounded-sm border border-[#2b2b2b]/30'
)
</script>
<template>
<div class="flex-1 flex flex-col items-center justify-center" :class="containerClass">
<div class="absolute top-1 left-0 right-0 flex justify-center">
<div :class="labelClass">{{ labelText }}</div>
</div>
<div v-if="pokemon" class="flex flex-col items-center gap-1 mt-2" :class="{ 'mt-3 gap-1.5': isActive }">
<div class="leading-tight font-black text-center px-1" :class="isActive ? 'text-sm' : 'text-xs'">
{{ pokemon.name.toUpperCase() }}
</div>
<div v-if="isActive" class="h-px w-full bg-[#2b2b2b]/30"></div>
<div class="leading-none opacity-80 font-bold" :class="isActive ? 'text-[11px]' : 'text-[9px] opacity-70'">
NO: {{ String(pokemon.id).padStart(4, '0') }}
</div>
<div class="flex gap-1 flex-wrap justify-center" :class="{ 'mt-1': !isActive }">
<div v-for="type in pokemon.types" :key="type.type.name" :class="typeClass">
{{ type.type.name }}
</div>
</div>
</div>
<div v-else class="text-[9px] opacity-40 mt-2">-- NONE --</div>
</div>
</template>

View File

@@ -0,0 +1,24 @@
<script setup>
defineProps({
query: String
})
</script>
<template>
<div
class="w-full bg-zinc-800 rounded-t-lg rounded-b-md p-3 pt-4 border-b-4 border-zinc-950 shadow-md relative overflow-hidden group shrink-0">
<div class="absolute top-0 inset-x-0 h-1 bg-zinc-700"></div> <!-- Lip -->
<div
class="bg-zinc-900 h-10 rounded border border-zinc-950 flex items-center px-4 relative shadow-[inset_0_2px_5px_black]">
<!-- Read-only display -->
<div class="flex items-center w-full justify-start">
<span v-if="!query" class="text-zinc-600 font-bold font-sans text-sm uppercase opacity-50"># _ _ _
_</span>
<span v-else
class="text-zinc-300 font-bold font-sans text-lg uppercase tracking-widest leading-none">{{
query }}</span>
<span class="w-1.5 h-3 bg-zinc-500/50 animate-pulse ml-1"></span>
</div>
</div>
</div>
</template>

View File

@@ -0,0 +1,15 @@
<script setup>
defineProps({
label: {
type: String,
required: true
}
})
</script>
<template>
<div class="h-7 w-full bg-blue-500 border-b-2 border-blue-800 rounded-sm shadow-[0_2px_0_#1e3a8a,inset_0_1px_0_rgba(255,255,255,0.4)] active:translate-y-[2px] active:border-b-0 active:shadow-none cursor-pointer flex items-center px-2 group transition-all overflow-hidden">
<div class="w-1.5 h-1.5 bg-blue-900/40 rounded-full mr-2"></div>
<span class="text-[9px] font-bold text-blue-900 opacity-70 group-hover:opacity-100 drop-shadow-sm tracking-tight truncate">{{ label }}</span>
</div>
</template>

View File

@@ -0,0 +1,15 @@
<script setup>
defineProps({
label: {
type: String,
default: 'OK'
}
})
</script>
<template>
<div
class="w-32 h-10 bg-green-500 border-2 border-green-700 rounded-lg shadow-[0_4px_0_#15803d,inset_0_1px_0_rgba(255,255,255,0.4)] active:translate-y-[4px] active:shadow-[inset_0_1px_0_rgba(255,255,255,0.4)] flex items-center justify-center cursor-pointer group transition-all z-10">
<span class="text-sm font-black text-green-900/80 tracking-widest drop-shadow-sm">{{ label }}</span>
</div>
</template>

View File

@@ -0,0 +1,127 @@
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('')
export const homeMenuState = ref({
selectedIndex: 0,
itemCount: 0,
onSelect: null,
onMoveUp: null,
onMoveDown: null
})
export function usePokedexNavigation() {
const router = useRouter()
const route = useRoute()
const pokemonListStore = usePokemonListStore()
function handleKeypad(num) {
if (searchQuery.value.length < MAX_SEARCH_LENGTH) {
searchQuery.value += String(num)
}
}
function goHome() {
router.push('/')
}
function goBack() {
const path = route.path
if (path === '/') {
return
}
const segments = path.split('/').filter(segment => segment !== '')
if (segments.length === 0) {
return
}
if (segments.length === 1) {
router.push('/')
} else {
segments.pop()
router.push('/' + segments.join('/'))
}
}
function executeSearch() {
if (searchQuery.value) {
const pokemonId = parseInt(searchQuery.value)
router.push(`/pokemon/${pokemonId}`)
searchQuery.value = ''
}
}
function clearSearch() {
if (searchQuery.value.length > 0) {
searchQuery.value = searchQuery.value.slice(0, -1)
}
}
function handleSearchGo() {
executeSearch()
}
function handleSearchClear() {
clearSearch()
}
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()
}
}
function handleBackButton() {
if (route.path !== '/') {
goBack()
}
}
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}`)
}
}
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}`)
}
}
return {
searchQuery,
handleKeypad,
goHome,
goBack,
executeSearch,
clearSearch,
handleMainButton,
handleBackButton,
handleNext,
handlePrev,
handleSearchGo,
handleSearchClear
}
}

View File

@@ -0,0 +1,60 @@
import { reactive, toRefs } from 'vue'
const state = reactive({
button1: { label: '', action: null, active: false },
button2: { label: '', action: null, active: false },
button3: { label: '', action: null, active: false },
button4: { label: '', action: null, active: false },
button5: { label: '', action: null, active: false }
})
export function useScreenActions() {
function setButtonAction(buttonNumber, label, action) {
if (buttonNumber >= 1 && buttonNumber <= 5) {
const buttonKey = `button${buttonNumber}`
state[buttonKey].label = label
state[buttonKey].action = action
}
}
function setButtonActive(buttonNumber, active) {
if (buttonNumber >= 1 && buttonNumber <= 5) {
const buttonKey = `button${buttonNumber}`
state[buttonKey].active = active
}
}
function clearActions() {
for (let i = 1; i <= 5; i++) {
const buttonKey = `button${i}`
state[buttonKey].label = ''
state[buttonKey].action = null
state[buttonKey].active = false
}
}
function setButtonActions(actions) {
clearActions()
actions.forEach(({ buttonNumber, label, action }) => {
setButtonAction(buttonNumber, label, action)
})
}
function handleButton(buttonNumber) {
if (buttonNumber >= 1 && buttonNumber <= 5) {
const buttonKey = `button${buttonNumber}`
if (state[buttonKey].action) {
state[buttonKey].action()
}
}
}
return {
...toRefs(state),
setButtonAction,
setButtonActive,
clearActions,
setButtonActions,
handleButton
}
}

2
src/constants/layout.js Normal file
View File

@@ -0,0 +1,2 @@
export const SCREEN_MAX_WIDTH = 380
export const SCREEN_MAX_HEIGHT = 680

29
src/constants/ui.js Normal file
View File

@@ -0,0 +1,29 @@
export const POKEDEX_COLORS = {
CHASSIS_MAIN: '#dc0a2d',
CHASSIS_DARK: '#89061c',
SCREEN_BG: '#18181b',
SCREEN_BEZEL: '#e4e4e7', // zinc-200
SCREEN_BORDER: '#89061c',
HINT_GREEN: '#22c55e',
HINT_RED: '#ef4444',
TEXT_MUTE: '#a1a1aa' // zinc-400
}
export const DIMENSIONS = {
WIDTH: '1200px',
HEIGHT: '900px',
SCREEN_ASPECT_RATIO: '3/4',
INFO_ASPECT_RATIO: '5/2'
}
export const MAX_SEARCH_LENGTH = 4
export const SIDE_MENU_ITEMS = [
{ label: 'POKÉMON', route: '/pokemon' },
{ label: 'MAPS', route: '/maps' },
{ label: 'GAMES', route: '/games' },
{ label: 'MOVES', route: '/moves' },
{ label: 'SETTINGS', route: '/settings' }
]
export const KEYPAD_KEYS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 0]

View File

@@ -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(() => {

24
src/pages/Games.vue Normal file
View File

@@ -0,0 +1,24 @@
<script setup>
import { onMounted } from 'vue'
import { appState } from '../store'
import PageLayout from '../components/layout/PageLayout.vue'
onMounted(() => {
appState.setPageDisplay(
'M21 6H3c-1.1 0-2 .9-2 2v8c0 1.1.9 2 2 2h18c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2zm-10 7H8v3H6v-3H3v-2h3V8h2v3h3v2zm4.5 2c-.83 0-1.5-.67-1.5-1.5s.67-1.5 1.5-1.5 1.5.67 1.5 1.5-.67 1.5-1.5 1.5zm4-3c-.83 0-1.5-.67-1.5-1.5S18.67 9 19.5 9s1.5.67 1.5 1.5-.67 1.5-1.5 1.5z',
'GAMES'
)
})
</script>
<template>
<PageLayout
title="GAMES"
badge=""
subtitle=""
>
<div class="flex items-center justify-center h-full">
<p class="text-zinc-500 text-sm">Coming Soon</p>
</div>
</PageLayout>
</template>

249
src/pages/Home.vue Normal file
View File

@@ -0,0 +1,249 @@
<script setup>
import { ref, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
import { appState } from '../store'
import PageLayout from '../components/layout/PageLayout.vue'
import { homeMenuState } from '../composables/usePokedexNavigation'
import { useScreenActions } from '../composables/useScreenActions'
const router = useRouter()
const { clearActions } = useScreenActions()
appState.clearCurrentPokemon()
const menuItems = ref([
{
id: 'pokemons',
label: 'POKÉMON',
subtitle: 'Browse Global Database',
icon: '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',
route: '/pokemon',
color: 'red'
},
{
id: 'maps',
label: 'MAPS',
subtitle: 'Explore Regions',
icon: 'M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7zm0 9.5c-1.38 0-2.5-1.12-2.5-2.5s1.12-2.5 2.5-2.5 2.5 1.12 2.5 2.5-1.12 2.5-2.5 2.5z',
route: '/maps',
color: 'blue'
},
{
id: 'games',
label: 'GAMES',
subtitle: 'Mini Games & Challenges',
icon: 'M21 6H3c-1.1 0-2 .9-2 2v8c0 1.1.9 2 2 2h18c1.1 0 2-.9 2-2V8c0-1.1-.9-2-2-2zm-10 7H8v3H6v-3H3v-2h3V8h2v3h3v2zm4.5 2c-.83 0-1.5-.67-1.5-1.5s.67-1.5 1.5-1.5 1.5.67 1.5 1.5-.67 1.5-1.5 1.5zm4-3c-.83 0-1.5-.67-1.5-1.5S18.67 9 19.5 9s1.5.67 1.5 1.5-.67 1.5-1.5 1.5z',
route: '/games',
color: 'green'
},
{
id: 'moves',
label: 'MOVES',
subtitle: 'Attack & Ability Database',
icon: 'M9 2L7.17 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2h-3.17L15 2H9zm3 15c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5z M12 14l-1.25-2.75L8 10l2.75-1.25L12 6l1.25 2.75L16 10l-2.75 1.25z',
route: '/moves',
color: 'purple'
},
{
id: 'settings',
label: 'SETTINGS',
subtitle: 'System Configuration',
icon: 'M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.09.63-.09.94s.02.64.07.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z',
route: '/settings',
color: 'zinc'
}
])
const selectedIndex = ref(0)
function movePrev() {
if (selectedIndex.value > 0) {
selectedIndex.value--
} else {
selectedIndex.value = menuItems.value.length - 1
}
}
function moveNext() {
if (selectedIndex.value < menuItems.value.length - 1) {
selectedIndex.value++
} else {
selectedIndex.value = 0
}
}
function selectItem() {
const item = menuItems.value[selectedIndex.value]
if (item.route) {
router.push(item.route)
} else {
console.log(`${item.label} - Coming Soon`)
}
}
function getIconColorClass(isSelected) {
return isSelected ? 'text-zinc-400' : 'text-zinc-600'
}
onMounted(() => {
clearActions()
appState.setPageDisplay(
'M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z',
'MAIN MENU'
)
homeMenuState.value = {
selectedIndex: selectedIndex.value,
itemCount: menuItems.value.length,
onSelect: selectItem,
onMoveUp: movePrev,
onMoveDown: moveNext
}
window.addEventListener('keydown', handleKeyboard)
})
onUnmounted(() => {
homeMenuState.value = {
selectedIndex: 0,
itemCount: 0,
onSelect: null,
onMoveUp: null,
onMoveDown: null
}
window.removeEventListener('keydown', handleKeyboard)
})
function handleKeyboard(e) {
switch(e.key) {
case 'ArrowLeft':
case 'a':
case 'A':
e.preventDefault()
movePrev()
break
case 'ArrowRight':
case 'd':
case 'D':
e.preventDefault()
moveNext()
break
case 'Enter':
case ' ':
e.preventDefault()
selectItem()
break
}
}
</script>
<template>
<PageLayout
title="POKEDEX"
badge=""
subtitle=""
>
<div class="flex flex-col items-center justify-center px-4 py-4 flex-1 min-h-0">
<div class="mb-4 text-center">
<p class="text-xs text-zinc-400 uppercase tracking-wide font-bold">Main Menu</p>
</div>
<div class="relative w-full max-w-sm h-48 mb-4 flex items-center justify-center overflow-hidden">
<div
v-for="(item, index) in menuItems"
:key="item.id"
:class="[
'absolute transition-all duration-300 ease-out',
'border-2 rounded-lg flex flex-col items-center justify-center gap-3',
'bg-zinc-900',
index === selectedIndex
? 'z-20 scale-100 opacity-100 border-zinc-600 w-40 h-44'
: index === selectedIndex - 1 || (selectedIndex === 0 && index === menuItems.length - 1)
? 'z-10 scale-75 opacity-40 border-zinc-800 w-40 h-44 -translate-x-36'
: index === selectedIndex + 1 || (selectedIndex === menuItems.length - 1 && index === 0)
? 'z-10 scale-75 opacity-40 border-zinc-800 w-40 h-44 translate-x-36'
: 'scale-50 opacity-0 pointer-events-none w-40 h-44'
]"
>
<div
:class="[
'rounded-lg flex items-center justify-center bg-zinc-950 border-2 transition-all duration-300',
index === selectedIndex ? 'w-14 h-14 border-zinc-700' : 'w-12 h-12 border-zinc-800'
]"
>
<svg
:class="[
'transition-all duration-300',
index === selectedIndex ? 'w-8 h-8' : 'w-7 h-7',
getIconColorClass(index === selectedIndex)
]"
viewBox="0 0 24 24"
fill="currentColor"
>
<path :d="item.icon" />
</svg>
</div>
<div class="text-center px-2">
<div
:class="[
'font-bold mb-1 transition-all duration-300',
index === selectedIndex ? 'text-base text-white' : 'text-sm text-zinc-500'
]"
>
{{ item.label }}
</div>
<div
:class="[
'text-xs transition-all duration-300',
index === selectedIndex ? 'text-zinc-400' : 'text-zinc-600'
]"
>
{{ item.subtitle }}
</div>
</div>
</div>
</div>
<div class="flex items-center gap-1 mb-4">
<div
v-for="(item, index) in menuItems"
:key="item.id"
:class="[
'rounded-full transition-all duration-300',
index === selectedIndex ? 'bg-zinc-400 w-3 h-1.5' : 'bg-zinc-700 w-1.5 h-1.5'
]"
></div>
</div>
<div class="flex items-center gap-3 text-[10px] text-zinc-500">
<div class="flex items-center gap-1.5">
<div class="flex gap-1">
<div class="w-5 h-5 rounded border border-zinc-700 bg-zinc-900 flex items-center justify-center">
<svg class="w-2.5 h-2.5" fill="currentColor" viewBox="0 0 24 24">
<path d="M15 19l-7-7 7-7"/>
</svg>
</div>
<div class="w-5 h-5 rounded border border-zinc-700 bg-zinc-900 flex items-center justify-center">
<svg class="w-2.5 h-2.5" fill="currentColor" viewBox="0 0 24 24">
<path d="M9 5l7 7-7 7"/>
</svg>
</div>
</div>
<span class="uppercase tracking-wide">Navigate</span>
</div>
<div class="flex items-center gap-1.5">
<div class="px-2 py-1 rounded border border-green-700 bg-green-900 text-green-400 font-bold text-[9px]">
OK
</div>
<span class="uppercase tracking-wide">Select</span>
</div>
</div>
</div>
</PageLayout>
</template>

24
src/pages/Maps.vue Normal file
View File

@@ -0,0 +1,24 @@
<script setup>
import { onMounted } from 'vue'
import { appState } from '../store'
import PageLayout from '../components/layout/PageLayout.vue'
onMounted(() => {
appState.setPageDisplay(
'M12 2C8.13 2 5 5.13 5 9c0 5.25 7 13 7 13s7-7.75 7-13c0-3.87-3.13-7-7-7zm0 9.5c-1.38 0-2.5-1.12-2.5-2.5s1.12-2.5 2.5-2.5 2.5 1.12 2.5 2.5-1.12 2.5-2.5 2.5z',
'MAPS'
)
})
</script>
<template>
<PageLayout
title="MAPS"
badge=""
subtitle=""
>
<div class="flex items-center justify-center h-full">
<p class="text-zinc-500 text-sm">Coming Soon</p>
</div>
</PageLayout>
</template>

24
src/pages/Moves.vue Normal file
View File

@@ -0,0 +1,24 @@
<script setup>
import { onMounted } from 'vue'
import { appState } from '../store'
import PageLayout from '../components/layout/PageLayout.vue'
onMounted(() => {
appState.setPageDisplay(
'M9 2L7.17 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2h-3.17L15 2H9zm3 15c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5z M12 14l-1.25-2.75L8 10l2.75-1.25L12 6l1.25 2.75L16 10l-2.75 1.25z',
'MOVES'
)
})
</script>
<template>
<PageLayout
title="MOVES"
badge=""
subtitle=""
>
<div class="flex items-center justify-center h-full">
<p class="text-zinc-500 text-sm">Coming Soon</p>
</div>
</PageLayout>
</template>

50
src/pages/NotFound.vue Normal file
View File

@@ -0,0 +1,50 @@
<script setup>
import { onMounted } from 'vue'
import { useRouter } from 'vue-router'
import { appState } from '../store'
import { useScreenActions } from '../composables/useScreenActions'
import PageLayout from '../components/layout/PageLayout.vue'
const router = useRouter()
const { setButtonActions } = useScreenActions()
onMounted(() => {
appState.setPageDisplay(
'M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-2h2v2zm0-4h-2V7h2v6z',
'ERROR 404'
)
setButtonActions([
{ buttonNumber: 3, label: 'HOME', action: () => router.push('/') }
])
})
</script>
<template>
<PageLayout
title="ERROR"
badge="404"
subtitle=""
:battery-level="60"
:grid-opacity="20"
>
<div class="flex items-center justify-center h-full p-8">
<div class="text-center space-y-6">
<div>
<div class="text-8xl font-black text-red-500 tracking-tighter leading-none mb-4">
404
</div>
<div class="text-sm text-zinc-400 uppercase tracking-[0.2em]">
Entry Not Found
</div>
</div>
<p class="text-[10px] text-zinc-500 max-w-xs leading-relaxed">
The requested Pokémon does not exist in the database.
</p>
</div>
</div>
</PageLayout>
</template>

203
src/pages/Pokedex.vue Normal file
View File

@@ -0,0 +1,203 @@
<script setup>
import { ref, computed, watch, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router';
import Description from '../components/pokemon/Description.vue'
import Information from '../components/pokemon/Information.vue'
import Pokemon from '../components/pokemon/Pokemon.vue'
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();
const pokemon = ref(null)
const pokemonImage = ref(null)
const pokemonSpecies = ref(null)
const darkColor = ref(null)
const lightColor = ref(null)
const isDark = ref(true)
function getPokemonColors(image) {
const colorThief = new ColorThief()
const img = new Image();
img.crossOrigin = 'anonymous';
img.src = image
img.onload = () => {
const color = colorThief.getColor(img)
const { background, text } = generateThemeColors(color)
darkColor.value = background
lightColor.value = text
}
}
function getPokemonData() {
pokemon.value = null
const currentId = parseInt(route.params.id)
fetch(`https://pokeapi.co/api/v2/pokemon/${currentId}`)
.then((response) => {
if (!response.ok) {
router.push({ name: '404', params: { catchAll: 'not-found' } })
return null
}
return response.json()
})
.then((json) => {
if (!json) return
pokemon.value = json
pokemonImage.value = pokemon.value.sprites.other['official-artwork'].front_default
appState.setCurrentPokemon(json)
pokemonListStore.setLastSelectedPokemon(currentId)
getPokemonColors(pokemonImage.value)
})
fetch(`https://pokeapi.co/api/v2/pokemon-species/${currentId}`)
.then((response) => {
if (!response.ok) return null
return response.json()
})
.then((json) => {
if (json) pokemonSpecies.value = json
});
if (currentId > 1) {
fetch(`https://pokeapi.co/api/v2/pokemon/${currentId - 1}`)
.then((response) => response.ok ? response.json() : null)
.then((json) => {
if (json) appState.setPreviousPokemon(json)
})
} else {
appState.setPreviousPokemon(null)
}
fetch(`https://pokeapi.co/api/v2/pokemon/${currentId + 1}`)
.then((response) => response.ok ? response.json() : null)
.then((json) => {
if (json) appState.setNextPokemon(json)
else appState.setNextPokemon(null)
})
}
const pokemonColors = computed(() => {
if (isDark.value) {
return {
backgroundColor: darkColor.value,
color: lightColor.value
}
} else {
return {
backgroundColor: lightColor.value,
color: darkColor.value
}
}
});
getPokemonData()
watch(() => route.params.id, () => {
getPokemonData()
})
onMounted(() => {
appState.clearPageDisplay()
const pokemonId = route.params.id
setButtonActions([
{ buttonNumber: 1, label: 'LOCA', action: () => router.push(`/pokemon/${pokemonId}/locations`) },
{ buttonNumber: 2, label: 'COLOR', action: () => router.push(`/pokemon/${pokemonId}/colors`) },
{ buttonNumber: 3, label: 'FORMS', action: () => router.push(`/pokemon/${pokemonId}/forms`) },
{ buttonNumber: 4, label: 'SHAPE', action: () => router.push(`/pokemon/${pokemonId}/shapes`) },
{ buttonNumber: 5, label: 'SPEC', action: () => router.push(`/pokemon/${pokemonId}/species`) }
])
})
</script>
<template>
<PageLayout
v-if="pokemon && pokemonSpecies && pokemonColors"
:title="pokemon.name"
:badge="pokemon.types.map(t => t.type.name)"
:subtitle="`#${String(pokemon.id).padStart(4, '0')}`"
:battery-level="60"
:grid-opacity="20"
>
<template #background>
<div class="absolute inset-0 opacity-20 pointer-events-none transition-colors duration-1000"
:style="{ background: `radial-gradient(circle at 70% 50%, ${pokemonColors.backgroundColor}, transparent 70%)` }">
</div>
</template>
<div class="flex flex-col p-3 gap-2 h-full overflow-hidden">
<div class="flex gap-3 shrink-0">
<div class="flex-1 flex flex-col gap-2 min-h-0">
<div class="flex items-baseline gap-2 pb-2 border-b border-zinc-800">
<span class="text-[9px] text-zinc-500 uppercase leading-none">Species:</span>
<span class="text-[10px] font-bold text-zinc-300 leading-none">{{ pokemonSpecies.genera.find(g => g.language.name === 'en')?.genus || 'Unknown' }}</span>
</div>
<div class="overflow-y-auto custom-scrollbar min-h-0">
<Information :pokemonData="pokemon" :color="pokemonColors.backgroundColor" />
</div>
</div>
<div class="w-40 h-40 relative border border-zinc-700 bg-zinc-900/50 rounded overflow-hidden flex items-center justify-center shrink-0">
<div class="absolute top-0 left-0 w-3 h-3 border-t-2 border-l-2 border-zinc-500"></div>
<div class="absolute top-0 right-0 w-3 h-3 border-t-2 border-r-2 border-zinc-500"></div>
<div class="absolute bottom-0 left-0 w-3 h-3 border-b-2 border-l-2 border-zinc-500"></div>
<div class="absolute bottom-0 right-0 w-3 h-3 border-b-2 border-r-2 border-zinc-500"></div>
<Pokemon :pokemonData="pokemonSpecies" :pokemonImage="pokemonImage" />
</div>
</div>
<div class="flex-1 min-h-0 overflow-y-auto custom-scrollbar pt-2">
<Description :pokemonData="pokemonSpecies" />
</div>
</div>
</PageLayout>
<PageLayout
v-else
title="LOADING"
subtitle="Please wait..."
:battery-level="60"
>
<div class="flex items-center justify-center h-full">
<div class="flex flex-col items-center gap-4">
<div class="relative w-16 h-16">
<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-4 bg-zinc-900 rounded-full flex items-center justify-center shadow-[inset_0_0_10px_rgba(0,0,0,0.5)]">
<div class="w-2 h-2 bg-red-500 rounded-full animate-ping"></div>
</div>
</div>
<div class="text-center">
<div class="text-xs font-bold tracking-[0.2em] text-zinc-500 mb-1">ACCESSING DATABASE</div>
<div class="text-[10px] text-zinc-700 animate-pulse">Establishing secure connection...</div>
</div>
</div>
</div>
</PageLayout>
</template>
<style scoped>
@keyframes scan {
0%, 100% { top: 10%; opacity: 0; }
10% { opacity: 1; }
90% { opacity: 1; }
100% { top: 90%; opacity: 0; }
}
</style>

306
src/pages/Pokemon.vue Normal file
View File

@@ -0,0 +1,306 @@
<script setup>
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"
subtitle="Global Database"
statusText="Browse all Pokémon species"
>
<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>

200
src/pages/PokemonColors.vue Normal file
View File

@@ -0,0 +1,200 @@
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { appState } from '../store'
import PageLayout from '../components/layout/PageLayout.vue'
import { useScreenActions } from '../composables/useScreenActions'
const { setButtonActions } = useScreenActions()
const route = useRoute()
const router = useRouter()
const pokemon = ref(null)
const pokemonSpecies = ref(null)
const colorData = ref(null)
const loading = ref(true)
const colorMap = {
'black': '#1a1a1a',
'blue': '#3b82f6',
'brown': '#92400e',
'gray': '#6b7280',
'green': '#22c55e',
'pink': '#ec4899',
'purple': '#a855f7',
'red': '#ef4444',
'white': '#f5f5f5',
'yellow': '#eab308'
}
const colorHex = computed(() => {
if (!colorData.value) return '#6b7280'
return colorMap[colorData.value.name] || '#6b7280'
})
const sortedNames = computed(() => {
if (!colorData.value?.names) return []
const priorityLanguages = ['en', 'ja-Hrkt', 'roomaji', 'ko', 'zh-Hans', 'fr', 'de', 'es', 'it']
return [...colorData.value.names].sort((a, b) => {
const aIndex = priorityLanguages.indexOf(a.language.name)
const bIndex = priorityLanguages.indexOf(b.language.name)
if (aIndex === -1 && bIndex === -1) return 0
if (aIndex === -1) return 1
if (bIndex === -1) return -1
return aIndex - bIndex
})
})
function getPokemonData() {
const currentId = parseInt(route.params.id)
loading.value = true
fetch(`https://pokeapi.co/api/v2/pokemon/${currentId}`)
.then((response) => {
if (!response.ok) {
router.push({ name: '404', params: { catchAll: 'not-found' } })
return null
}
return response.json()
})
.then((json) => {
if (json) pokemon.value = json
})
fetch(`https://pokeapi.co/api/v2/pokemon-species/${currentId}`)
.then((response) => {
if (!response.ok) return null
return response.json()
})
.then((json) => {
if (json) {
pokemonSpecies.value = json
const colorUrl = json.color.url
return fetch(colorUrl)
}
return null
})
.then((response) => {
if (!response || !response.ok) return null
return response.json()
})
.then((json) => {
if (json) colorData.value = json
loading.value = false
})
}
getPokemonData()
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-9h10v2H7z',
'COLORS'
)
setButtonActions([
{ buttonNumber: 1, label: 'BACK', action: () => router.push(`/pokemon/${route.params.id}`) }
])
})
</script>
<template>
<PageLayout
v-if="pokemon && !loading"
:title="pokemon.name"
:badge="pokemon.types.map(t => t.type.name)"
subtitle="Color Data"
statusText="Color Information"
>
<div v-if="!colorData" class="flex items-center justify-center h-full">
<div class="text-center space-y-2">
<div class="text-zinc-600 text-xs"></div>
<p class="text-zinc-500 text-xs">No color data available</p>
</div>
</div>
<div v-else class="flex flex-col h-full overflow-hidden p-3 gap-3">
<div class="border border-zinc-800 bg-zinc-950/50 rounded overflow-hidden">
<div class="bg-zinc-900 px-2 py-1.5 border-b border-zinc-800">
<h3 class="text-[10px] font-bold text-zinc-300 uppercase tracking-wide">
Primary Color
</h3>
</div>
<div class="p-3 flex items-center gap-3">
<div
class="w-20 h-20 rounded border-2 border-zinc-700 shadow-lg shrink-0"
:style="{ backgroundColor: colorHex }"
>
<div v-if="colorData.name === 'white'" class="w-full h-full border border-zinc-300"></div>
</div>
<div class="flex-1 space-y-1">
<div class="text-xl font-bold text-zinc-200 capitalize">
{{ colorData.name }}
</div>
<div class="text-[10px] text-zinc-500 font-mono">
{{ colorHex }}
</div>
<div class="text-[9px] text-zinc-600 mt-2">
Pokédex classification color
</div>
</div>
</div>
</div>
<div class="border border-zinc-800 bg-zinc-950/50 rounded overflow-hidden flex-1 min-h-0 flex flex-col">
<div class="bg-zinc-900 px-2 py-1.5 border-b border-zinc-800">
<h3 class="text-[10px] font-bold text-zinc-300 uppercase tracking-wide">
Translations
</h3>
</div>
<div class="flex-1 overflow-y-auto custom-scrollbar p-2 space-y-1.5">
<div
v-for="nameEntry in sortedNames"
:key="nameEntry.language.name"
class="flex items-baseline justify-between py-1 px-2 hover:bg-zinc-900/50 rounded"
>
<span class="text-[9px] text-zinc-500 uppercase tracking-wider min-w-15">
{{ nameEntry.language.name }}
</span>
<span class="text-[11px] text-zinc-300 font-medium">
{{ nameEntry.name }}
</span>
</div>
</div>
</div>
</div>
</PageLayout>
<PageLayout
v-else
title="LOADING"
subtitle="Please wait..."
>
<div class="flex items-center justify-center h-full">
<div class="flex flex-col items-center gap-4">
<div class="relative w-16 h-16">
<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-4 bg-zinc-900 rounded-full flex items-center justify-center shadow-[inset_0_0_10px_rgba(0,0,0,0.5)]">
<div class="w-2 h-2 bg-red-500 rounded-full animate-ping"></div>
</div>
</div>
<div class="text-center">
<div class="text-xs font-bold tracking-[0.2em] text-zinc-500 mb-1">LOADING DATA</div>
<div class="text-[10px] text-zinc-700 animate-pulse">Fetching color information...</div>
</div>
</div>
</div>
</PageLayout>
</template>

175
src/pages/PokemonForms.vue Normal file
View File

@@ -0,0 +1,175 @@
<script setup>
import { ref, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { appState } from '../store'
import PageLayout from '../components/layout/PageLayout.vue'
import { useScreenActions } from '../composables/useScreenActions'
const { setButtonActions } = useScreenActions()
const route = useRoute()
const router = useRouter()
const pokemon = ref(null)
const forms = ref([])
const loading = ref(true)
function formatFormName(name) {
return name
.split('-')
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ')
}
async function getPokemonData() {
const currentId = parseInt(route.params.id)
loading.value = true
try {
const pokemonResponse = await fetch(`https://pokeapi.co/api/v2/pokemon/${currentId}`)
if (!pokemonResponse.ok) {
router.push({ name: '404', params: { catchAll: 'not-found' } })
return
}
const pokemonData = await pokemonResponse.json()
pokemon.value = pokemonData
const formPromises = pokemonData.forms.map(form =>
fetch(form.url).then(res => res.ok ? res.json() : null)
)
const formResults = await Promise.all(formPromises)
forms.value = formResults.filter(form => form !== null)
} catch (error) {
console.error('Error fetching forms:', error)
} finally {
loading.value = false
}
}
getPokemonData()
onMounted(() => {
appState.setPageDisplay(
'M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10 10-4.5 10-10S17.5 2 12 2zm0 18c-4.4 0-8-3.6-8-8s3.6-8 8-8 8 3.6 8 8-3.6 8-8 8zm-4-8l1.4 1.4L11 11.8V16h2v-4.2l1.6 1.6L16 12l-4-4-4 4z',
'FORMS'
)
setButtonActions([
{ buttonNumber: 1, label: 'BACK', action: () => router.push(`/pokemon/${route.params.id}`) }
])
})
</script>
<template>
<PageLayout
v-if="pokemon && !loading"
:title="pokemon.name"
:badge="pokemon.types.map(t => t.type.name)"
subtitle="Form Variations"
statusText="Form Information"
>
<div v-if="forms.length === 0" class="flex items-center justify-center h-full">
<div class="text-center space-y-2">
<div class="text-zinc-600 text-xs"></div>
<p class="text-zinc-500 text-xs">No form data available</p>
</div>
</div>
<div v-else class="flex flex-col h-full overflow-hidden p-3 gap-2">
<div class="flex-1 overflow-y-auto custom-scrollbar space-y-3">
<div
v-for="form in forms"
:key="form.id"
class="border border-zinc-800 bg-zinc-950/50 rounded overflow-hidden"
:class="{ 'border-zinc-600': form.is_default }"
>
<div class="bg-zinc-900 px-2 py-1.5 border-b border-zinc-800 flex items-center justify-between">
<h3 class="text-[10px] font-bold text-zinc-300 uppercase tracking-wide">
{{ formatFormName(form.name) }}
</h3>
<div class="flex gap-1">
<span v-if="form.is_default" class="text-[8px] bg-zinc-700 text-zinc-300 px-1.5 py-0.5 rounded uppercase">
Default
</span>
<span v-if="form.is_mega" class="text-[8px] bg-purple-900/50 text-purple-300 px-1.5 py-0.5 rounded uppercase">
Mega
</span>
<span v-if="form.is_battle_only" class="text-[8px] bg-red-900/50 text-red-300 px-1.5 py-0.5 rounded uppercase">
Battle
</span>
</div>
</div>
<div class="p-3 flex gap-3">
<div class="grid grid-cols-2 gap-2 shrink-0">
<div v-if="form.sprites.front_default" class="w-16 h-16 bg-zinc-900/50 border border-zinc-800 rounded flex items-center justify-center">
<img :src="form.sprites.front_default" :alt="form.name" class="w-full h-full object-contain" />
</div>
<div v-if="form.sprites.front_shiny" class="w-16 h-16 bg-zinc-900/50 border border-zinc-800 rounded flex items-center justify-center relative">
<img :src="form.sprites.front_shiny" :alt="form.name + ' shiny'" class="w-full h-full object-contain" />
<div class="absolute top-0.5 right-0.5 w-1.5 h-1.5 bg-yellow-400 rounded-full"></div>
</div>
<div v-if="form.sprites.back_default" class="w-16 h-16 bg-zinc-900/50 border border-zinc-800 rounded flex items-center justify-center">
<img :src="form.sprites.back_default" :alt="form.name + ' back'" class="w-full h-full object-contain opacity-60" />
</div>
<div v-if="form.sprites.back_shiny" class="w-16 h-16 bg-zinc-900/50 border border-zinc-800 rounded flex items-center justify-center relative">
<img :src="form.sprites.back_shiny" :alt="form.name + ' back shiny'" class="w-full h-full object-contain opacity-60" />
<div class="absolute top-0.5 right-0.5 w-1.5 h-1.5 bg-yellow-400 rounded-full"></div>
</div>
</div>
<div class="flex-1 space-y-2 min-w-0">
<div v-if="form.form_name" class="space-y-0.5">
<div class="text-[9px] text-zinc-500 uppercase">Form Name</div>
<div class="text-[10px] text-zinc-300 capitalize">{{ form.form_name }}</div>
</div>
<div v-if="form.types && form.types.length > 0" class="space-y-0.5">
<div class="text-[9px] text-zinc-500 uppercase">Types</div>
<div class="flex gap-1">
<span
v-for="type in form.types"
:key="type.slot"
class="text-[8px] bg-zinc-800 text-zinc-400 px-1.5 py-0.5 rounded uppercase"
>
{{ type.type.name }}
</span>
</div>
</div>
<div class="space-y-0.5">
<div class="text-[9px] text-zinc-500 uppercase">Order</div>
<div class="text-[10px] text-zinc-400 font-mono">
Form: {{ form.form_order }} | Overall: {{ form.order }}
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</PageLayout>
<PageLayout
v-else
title="LOADING"
subtitle="Please wait..."
>
<div class="flex items-center justify-center h-full">
<div class="flex flex-col items-center gap-4">
<div class="relative w-16 h-16">
<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-4 bg-zinc-900 rounded-full flex items-center justify-center shadow-[inset_0_0_10px_rgba(0,0,0,0.5)]">
<div class="w-2 h-2 bg-red-500 rounded-full animate-ping"></div>
</div>
</div>
<div class="text-center">
<div class="text-xs font-bold tracking-[0.2em] text-zinc-500 mb-1">LOADING DATA</div>
<div class="text-[10px] text-zinc-700 animate-pulse">Fetching form information...</div>
</div>
</div>
</div>
</PageLayout>
</template>

View File

@@ -0,0 +1,185 @@
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { appState } from '../store'
import PageLayout from '../components/layout/PageLayout.vue'
import { useScreenActions } from '../composables/useScreenActions'
const { setButtonActions } = useScreenActions()
const route = useRoute()
const router = useRouter()
const pokemon = ref(null)
const encounters = ref(null)
const loading = ref(true)
function formatLocationName(name) {
return name
.split('-')
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ')
}
function formatMethodName(name) {
return name.charAt(0).toUpperCase() + name.slice(1)
}
const groupedLocations = computed(() => {
if (!encounters.value || encounters.value.length === 0) return []
const grouped = {}
encounters.value.forEach(encounter => {
const locationName = encounter.location_area.name
if (!grouped[locationName]) {
grouped[locationName] = {
name: locationName,
versions: []
}
}
encounter.version_details.forEach(versionDetail => {
const versionName = versionDetail.version.name
const methods = versionDetail.encounter_details.map(detail => ({
method: detail.method.name,
minLevel: detail.min_level,
maxLevel: detail.max_level,
chance: detail.chance
}))
grouped[locationName].versions.push({
version: versionName,
maxChance: versionDetail.max_chance,
methods
})
})
})
return Object.values(grouped)
})
function getPokemonData() {
const currentId = parseInt(route.params.id)
loading.value = true
fetch(`https://pokeapi.co/api/v2/pokemon/${currentId}`)
.then((response) => {
if (!response.ok) {
router.push({ name: '404', params: { catchAll: 'not-found' } })
return null
}
return response.json()
})
.then((json) => {
if (json) pokemon.value = json
})
fetch(`https://pokeapi.co/api/v2/pokemon/${currentId}/encounters`)
.then((response) => {
if (!response.ok) return null
return response.json()
})
.then((json) => {
encounters.value = json
loading.value = false
})
}
getPokemonData()
onMounted(() => {
appState.setPageDisplay(
'M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10 10-4.5 10-10S17.5 2 12 2zm0 18c-4.4 0-8-3.6-8-8s3.6-8 8-8 8 3.6 8 8-3.6 8-8 8zm-1-13h2v6h-2zm0 8h2v2h-2z',
'LOCATIONS'
)
setButtonActions([
{ buttonNumber: 1, label: 'BACK', action: () => router.push(`/pokemon/${route.params.id}`) }
])
})
</script>
<template>
<PageLayout
v-if="pokemon && !loading"
:title="pokemon.name"
:badge="pokemon.types.map(t => t.type.name)"
subtitle="Location Areas"
statusText="Location Information"
>
<div v-if="groupedLocations.length === 0" class="flex items-center justify-center h-full">
<div class="text-center space-y-2">
<div class="text-zinc-600 text-xs"></div>
<p class="text-zinc-500 text-xs">No location data available</p>
<p class="text-zinc-700 text-[10px]">This Pokémon cannot be found in the wild</p>
</div>
</div>
<div v-else class="flex flex-col h-full overflow-hidden p-3 gap-2">
<div class="flex-1 overflow-y-auto custom-scrollbar space-y-3">
<div
v-for="location in groupedLocations"
:key="location.name"
class="border border-zinc-800 bg-zinc-950/50 rounded overflow-hidden"
>
<div class="bg-zinc-900 px-2 py-1.5 border-b border-zinc-800">
<h3 class="text-[10px] font-bold text-zinc-300 uppercase tracking-wide">
{{ formatLocationName(location.name) }}
</h3>
</div>
<div class="p-2 space-y-2">
<div
v-for="(version, idx) in location.versions"
:key="idx"
class="space-y-1"
>
<div class="flex items-center justify-between">
<span class="text-[9px] text-zinc-500 uppercase">{{ version.version }}</span>
<span class="text-[9px] text-zinc-600">Max: {{ version.maxChance }}%</span>
</div>
<div class="space-y-1">
<div
v-for="(method, mIdx) in version.methods"
:key="mIdx"
class="flex items-center justify-between text-[9px] pl-2 py-0.5"
>
<div class="flex items-center gap-2">
<span class="text-zinc-400">{{ formatMethodName(method.method) }}</span>
<span class="text-zinc-600">Lv {{ method.minLevel }}-{{ method.maxLevel }}</span>
</div>
<span class="text-zinc-500 font-mono">{{ method.chance }}%</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</PageLayout>
<PageLayout
v-else
title="LOADING"
subtitle="Please wait..."
>
<div class="flex items-center justify-center h-full">
<div class="flex flex-col items-center gap-4">
<div class="relative w-16 h-16">
<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-4 bg-zinc-900 rounded-full flex items-center justify-center shadow-[inset_0_0_10px_rgba(0,0,0,0.5)]">
<div class="w-2 h-2 bg-red-500 rounded-full animate-ping"></div>
</div>
</div>
<div class="text-center">
<div class="text-xs font-bold tracking-[0.2em] text-zinc-500 mb-1">LOADING DATA</div>
<div class="text-[10px] text-zinc-700 animate-pulse">Fetching location information...</div>
</div>
</div>
</div>
</PageLayout>
</template>

243
src/pages/PokemonShapes.vue Normal file
View File

@@ -0,0 +1,243 @@
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { appState } from '../store'
import PageLayout from '../components/layout/PageLayout.vue'
import { useScreenActions } from '../composables/useScreenActions'
const { setButtonActions } = useScreenActions()
const route = useRoute()
const router = useRouter()
const pokemon = ref(null)
const pokemonSpecies = ref(null)
const shapeData = ref(null)
const loading = ref(true)
const shapeIcons = {
'ball': '●',
'squiggle': '~',
'fish': '><>',
'arms': '⚔',
'blob': '◉',
'upright': '▲',
'legs': '⚊',
'quadruped': '◆',
'wings': '✦',
'tentacles': '※',
'heads': '◭',
'humanoid': '☗',
'bug-wings': '✧',
'armor': '⬢'
}
const shapeIcon = computed(() => {
if (!shapeData.value) return '◆'
return shapeIcons[shapeData.value.name] || '◆'
})
const sortedNames = computed(() => {
if (!shapeData.value?.names) return []
const priorityLanguages = ['en', 'ja-Hrkt', 'roomaji', 'ko', 'zh-Hans', 'fr', 'de', 'es', 'it']
return [...shapeData.value.names].sort((a, b) => {
const aIndex = priorityLanguages.indexOf(a.language.name)
const bIndex = priorityLanguages.indexOf(b.language.name)
if (aIndex === -1 && bIndex === -1) return 0
if (aIndex === -1) return 1
if (bIndex === -1) return -1
return aIndex - bIndex
})
})
const sortedAwesomeNames = computed(() => {
if (!shapeData.value?.awesome_names) return []
const priorityLanguages = ['en', 'ja-Hrkt', 'roomaji', 'ko', 'zh-Hans', 'fr', 'de', 'es', 'it']
return [...shapeData.value.awesome_names].sort((a, b) => {
const aIndex = priorityLanguages.indexOf(a.language.name)
const bIndex = priorityLanguages.indexOf(b.language.name)
if (aIndex === -1 && bIndex === -1) return 0
if (aIndex === -1) return 1
if (bIndex === -1) return -1
return aIndex - bIndex
})
})
function getPokemonData() {
const currentId = parseInt(route.params.id)
loading.value = true
fetch(`https://pokeapi.co/api/v2/pokemon/${currentId}`)
.then((response) => {
if (!response.ok) {
router.push({ name: '404', params: { catchAll: 'not-found' } })
return null
}
return response.json()
})
.then((json) => {
if (json) pokemon.value = json
})
fetch(`https://pokeapi.co/api/v2/pokemon-species/${currentId}`)
.then((response) => {
if (!response.ok) return null
return response.json()
})
.then((json) => {
if (json) {
pokemonSpecies.value = json
if (json.shape) {
const shapeUrl = json.shape.url
return fetch(shapeUrl)
}
}
return null
})
.then((response) => {
if (!response || !response.ok) return null
return response.json()
})
.then((json) => {
if (json) shapeData.value = json
loading.value = false
})
}
getPokemonData()
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 18l-8-8 8-8 8 8-8 8z',
'SHAPES'
)
setButtonActions([
{ buttonNumber: 1, label: 'BACK', action: () => router.push(`/pokemon/${route.params.id}`) }
])
})
</script>
<template>
<PageLayout
v-if="pokemon && !loading"
:title="pokemon.name"
:badge="pokemon.types.map(t => t.type.name)"
subtitle="Body Shape"
statusText="Shape Information"
>
<div v-if="!shapeData" class="flex items-center justify-center h-full">
<div class="text-center space-y-2">
<div class="text-zinc-600 text-xs"></div>
<p class="text-zinc-500 text-xs">No shape data available</p>
</div>
</div>
<div v-else class="flex flex-col h-full overflow-hidden p-3 gap-3">
<div class="border border-zinc-800 bg-zinc-950/50 rounded overflow-hidden">
<div class="bg-zinc-900 px-2 py-1.5 border-b border-zinc-800">
<h3 class="text-[10px] font-bold text-zinc-300 uppercase tracking-wide">
Body Shape
</h3>
</div>
<div class="p-3 flex items-center gap-3">
<div class="w-20 h-20 rounded border-2 border-zinc-700 bg-zinc-900 flex items-center justify-center shrink-0">
<div class="text-5xl text-zinc-400">{{ shapeIcon }}</div>
</div>
<div class="flex-1 space-y-1">
<div class="text-xl font-bold text-zinc-200 capitalize">
{{ shapeData.name }}
</div>
<div v-if="sortedAwesomeNames.length > 0" class="text-[11px] text-zinc-400 italic">
{{ sortedAwesomeNames[0].awesome_name }}
</div>
<div class="text-[9px] text-zinc-600 mt-2">
Pokédex body shape classification
</div>
</div>
</div>
</div>
<div v-if="sortedAwesomeNames.length > 0" class="border border-zinc-800 bg-zinc-950/50 rounded overflow-hidden">
<div class="bg-zinc-900 px-2 py-1.5 border-b border-zinc-800">
<h3 class="text-[10px] font-bold text-zinc-300 uppercase tracking-wide">
Scientific Names
</h3>
</div>
<div class="p-2 space-y-1.5">
<div
v-for="nameEntry in sortedAwesomeNames"
:key="nameEntry.language.name"
class="flex items-baseline justify-between py-1 px-2 hover:bg-zinc-900/50 rounded"
>
<span class="text-[9px] text-zinc-500 uppercase tracking-wider min-w-15">
{{ nameEntry.language.name }}
</span>
<span class="text-[11px] text-zinc-300 font-medium italic">
{{ nameEntry.awesome_name }}
</span>
</div>
</div>
</div>
<div class="border border-zinc-800 bg-zinc-950/50 rounded overflow-hidden flex-1 min-h-0 flex flex-col">
<div class="bg-zinc-900 px-2 py-1.5 border-b border-zinc-800">
<h3 class="text-[10px] font-bold text-zinc-300 uppercase tracking-wide">
Translations
</h3>
</div>
<div class="flex-1 overflow-y-auto custom-scrollbar p-2 space-y-1.5">
<div
v-for="nameEntry in sortedNames"
:key="nameEntry.language.name"
class="flex items-baseline justify-between py-1 px-2 hover:bg-zinc-900/50 rounded"
>
<span class="text-[9px] text-zinc-500 uppercase tracking-wider min-w-15">
{{ nameEntry.language.name }}
</span>
<span class="text-[11px] text-zinc-300 font-medium">
{{ nameEntry.name }}
</span>
</div>
</div>
</div>
</div>
</PageLayout>
<PageLayout
v-else
title="LOADING"
subtitle="Please wait..."
>
<div class="flex items-center justify-center h-full">
<div class="flex flex-col items-center gap-4">
<div class="relative w-16 h-16">
<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-4 bg-zinc-900 rounded-full flex items-center justify-center shadow-[inset_0_0_10px_rgba(0,0,0,0.5)]">
<div class="w-2 h-2 bg-red-500 rounded-full animate-ping"></div>
</div>
</div>
<div class="text-center">
<div class="text-xs font-bold tracking-[0.2em] text-zinc-500 mb-1">LOADING DATA</div>
<div class="text-[10px] text-zinc-700 animate-pulse">Fetching shape information...</div>
</div>
</div>
</div>
</PageLayout>
</template>

View File

@@ -0,0 +1,246 @@
<script setup>
import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { appState } from '../store'
import PageLayout from '../components/layout/PageLayout.vue'
import { useScreenActions } from '../composables/useScreenActions'
const { setButtonActions } = useScreenActions()
const route = useRoute()
const router = useRouter()
const pokemon = ref(null)
const speciesData = ref(null)
const loading = ref(true)
const genderRateText = computed(() => {
if (!speciesData.value) return 'Unknown'
const rate = speciesData.value.gender_rate
if (rate === -1) return 'Genderless'
const femalePercent = (rate / 8) * 100
const malePercent = 100 - femalePercent
return `${femalePercent}% / ♂ ${malePercent}%`
})
const captureRateBars = computed(() => {
if (!speciesData.value) return 0
return Math.ceil((speciesData.value.capture_rate / 255) * 20)
})
function formatName(name) {
return name
.split('-')
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
.join(' ')
}
function getPokemonData() {
const currentId = parseInt(route.params.id)
loading.value = true
fetch(`https://pokeapi.co/api/v2/pokemon/${currentId}`)
.then((response) => {
if (!response.ok) {
router.push({ name: '404', params: { catchAll: 'not-found' } })
return null
}
return response.json()
})
.then((json) => {
if (json) pokemon.value = json
})
fetch(`https://pokeapi.co/api/v2/pokemon-species/${currentId}`)
.then((response) => {
if (!response.ok) return null
return response.json()
})
.then((json) => {
if (json) speciesData.value = json
loading.value = false
})
}
getPokemonData()
onMounted(() => {
appState.setPageDisplay(
'M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10 10-4.5 10-10S17.5 2 12 2zm0 18c-4.4 0-8-3.6-8-8s3.6-8 8-8 8 3.6 8 8-3.6 8-8 8zm4-8c0-2.2-1.8-4-4-4s-4 1.8-4 4 1.8 4 4 4 4-1.8 4-4z',
'SPECIES'
)
setButtonActions([
{ buttonNumber: 1, label: 'BACK', action: () => router.push(`/pokemon/${route.params.id}`) }
])
})
</script>
<template>
<PageLayout
v-if="pokemon && !loading"
:title="pokemon.name"
:badge="pokemon.types.map(t => t.type.name)"
subtitle="Species Data"
statusText="Species Information"
>
<div v-if="!speciesData" class="flex items-center justify-center h-full">
<div class="text-center space-y-2">
<div class="text-zinc-600 text-xs"></div>
<p class="text-zinc-500 text-xs">No species data available</p>
</div>
</div>
<div v-else class="flex flex-col h-full overflow-hidden p-3 gap-2">
<div class="flex-1 overflow-y-auto custom-scrollbar space-y-2">
<div v-if="speciesData.is_baby || speciesData.is_legendary || speciesData.is_mythical" class="flex gap-1.5">
<span v-if="speciesData.is_baby" class="text-[8px] bg-pink-900/30 text-pink-300 px-2 py-1 rounded uppercase border border-pink-800">
Baby
</span>
<span v-if="speciesData.is_legendary" class="text-[8px] bg-yellow-900/30 text-yellow-300 px-2 py-1 rounded uppercase border border-yellow-800">
Legendary
</span>
<span v-if="speciesData.is_mythical" class="text-[8px] bg-purple-900/30 text-purple-300 px-2 py-1 rounded uppercase border border-purple-800">
Mythical
</span>
</div>
<div class="border border-zinc-800 bg-zinc-950/50 rounded overflow-hidden">
<div class="bg-zinc-900 px-2 py-1 border-b border-zinc-800">
<h3 class="text-[9px] font-bold text-zinc-300 uppercase tracking-wide">Base Stats</h3>
</div>
<div class="p-2 space-y-2">
<div class="space-y-1">
<div class="flex items-center justify-between">
<span class="text-[9px] text-zinc-500 uppercase">Capture Rate</span>
<span class="text-[9px] text-zinc-400 font-mono">{{ speciesData.capture_rate }}/255</span>
</div>
<div class="flex gap-0.5 h-1">
<div v-for="i in 20" :key="i"
class="flex-1 rounded-sm"
:class="i <= captureRateBars ? 'bg-green-500' : 'bg-zinc-800'">
</div>
</div>
</div>
<div class="flex items-baseline justify-between py-1">
<span class="text-[9px] text-zinc-500 uppercase">Base Happiness</span>
<span class="text-[10px] text-zinc-300 font-mono">{{ speciesData.base_happiness }}</span>
</div>
<div class="flex items-baseline justify-between py-1">
<span class="text-[9px] text-zinc-500 uppercase">Gender Ratio</span>
<span class="text-[9px] text-zinc-300 font-mono">{{ genderRateText }}</span>
</div>
<div class="flex items-baseline justify-between py-1">
<span class="text-[9px] text-zinc-500 uppercase">Hatch Counter</span>
<span class="text-[10px] text-zinc-300 font-mono">{{ speciesData.hatch_counter }}</span>
</div>
</div>
</div>
<div class="border border-zinc-800 bg-zinc-950/50 rounded overflow-hidden">
<div class="bg-zinc-900 px-2 py-1 border-b border-zinc-800">
<h3 class="text-[9px] font-bold text-zinc-300 uppercase tracking-wide">Classification</h3>
</div>
<div class="p-2 space-y-1.5">
<div class="flex items-baseline justify-between py-0.5">
<span class="text-[9px] text-zinc-500 uppercase">Generation</span>
<span class="text-[9px] text-zinc-300">{{ formatName(speciesData.generation.name) }}</span>
</div>
<div class="flex items-baseline justify-between py-0.5">
<span class="text-[9px] text-zinc-500 uppercase">Growth Rate</span>
<span class="text-[9px] text-zinc-300 capitalize">{{ speciesData.growth_rate.name }}</span>
</div>
<div v-if="speciesData.habitat" class="flex items-baseline justify-between py-0.5">
<span class="text-[9px] text-zinc-500 uppercase">Habitat</span>
<span class="text-[9px] text-zinc-300 capitalize">{{ speciesData.habitat.name }}</span>
</div>
<div v-if="speciesData.egg_groups && speciesData.egg_groups.length > 0" class="space-y-1 pt-1">
<span class="text-[9px] text-zinc-500 uppercase">Egg Groups</span>
<div class="flex gap-1 flex-wrap">
<span
v-for="group in speciesData.egg_groups"
:key="group.name"
class="text-[8px] bg-zinc-800 text-zinc-400 px-1.5 py-0.5 rounded capitalize"
>
{{ group.name }}
</span>
</div>
</div>
</div>
</div>
<div v-if="speciesData.evolves_from_species" class="border border-zinc-800 bg-zinc-950/50 rounded overflow-hidden">
<div class="bg-zinc-900 px-2 py-1 border-b border-zinc-800">
<h3 class="text-[9px] font-bold text-zinc-300 uppercase tracking-wide">Evolution</h3>
</div>
<div class="p-2">
<div class="flex items-center gap-2">
<span class="text-[9px] text-zinc-500 uppercase">Evolves From</span>
<span class="text-[10px] text-zinc-300 capitalize">{{ formatName(speciesData.evolves_from_species.name) }}</span>
</div>
</div>
</div>
<div v-if="speciesData.pokedex_numbers && speciesData.pokedex_numbers.length > 0" class="border border-zinc-800 bg-zinc-950/50 rounded overflow-hidden">
<div class="bg-zinc-900 px-2 py-1 border-b border-zinc-800">
<h3 class="text-[9px] font-bold text-zinc-300 uppercase tracking-wide">Pokedex Entries</h3>
</div>
<div class="p-2 space-y-1">
<div
v-for="entry in speciesData.pokedex_numbers"
:key="entry.pokedex.name"
class="flex items-baseline justify-between py-0.5"
>
<span class="text-[9px] text-zinc-500 capitalize">{{ formatName(entry.pokedex.name) }}</span>
<span class="text-[9px] text-zinc-400 font-mono">#{{ String(entry.entry_number).padStart(3, '0') }}</span>
</div>
</div>
</div>
<div v-if="speciesData.has_gender_differences || speciesData.forms_switchable" class="border border-zinc-800 bg-zinc-950/50 rounded overflow-hidden">
<div class="bg-zinc-900 px-2 py-1 border-b border-zinc-800">
<h3 class="text-[9px] font-bold text-zinc-300 uppercase tracking-wide">Additional Info</h3>
</div>
<div class="p-2 space-y-1">
<div v-if="speciesData.has_gender_differences" class="text-[9px] text-zinc-400">
Has gender differences
</div>
<div v-if="speciesData.forms_switchable" class="text-[9px] text-zinc-400">
Forms are switchable
</div>
</div>
</div>
</div>
</div>
</PageLayout>
<PageLayout
v-else
title="LOADING"
subtitle="Please wait..."
>
<div class="flex items-center justify-center h-full">
<div class="flex flex-col items-center gap-4">
<div class="relative w-16 h-16">
<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-4 bg-zinc-900 rounded-full flex items-center justify-center shadow-[inset_0_0_10px_rgba(0,0,0,0.5)]">
<div class="w-2 h-2 bg-red-500 rounded-full animate-ping"></div>
</div>
</div>
<div class="text-center">
<div class="text-xs font-bold tracking-[0.2em] text-zinc-500 mb-1">LOADING DATA</div>
<div class="text-[10px] text-zinc-700 animate-pulse">Fetching species information...</div>
</div>
</div>
</div>
</PageLayout>
</template>

24
src/pages/Settings.vue Normal file
View File

@@ -0,0 +1,24 @@
<script setup>
import { onMounted } from 'vue'
import { appState } from '../store'
import PageLayout from '../components/layout/PageLayout.vue'
onMounted(() => {
appState.setPageDisplay(
'M19.14 12.94c.04-.3.06-.61.06-.94 0-.32-.02-.64-.07-.94l2.03-1.58c.18-.14.23-.41.12-.61l-1.92-3.32c-.12-.22-.37-.29-.59-.22l-2.39.96c-.5-.38-1.03-.7-1.62-.94l-.36-2.54c-.04-.24-.24-.41-.48-.41h-3.84c-.24 0-.43.17-.47.41l-.36 2.54c-.59.24-1.13.57-1.62.94l-2.39-.96c-.22-.08-.47 0-.59.22L2.74 8.87c-.12.21-.08.47.12.61l2.03 1.58c-.05.3-.09.63-.09.94s.02.64.07.94l-2.03 1.58c-.18.14-.23.41-.12.61l1.92 3.32c.12.22.37.29.59.22l2.39-.96c.5.38 1.03.7 1.62.94l.36 2.54c.05.24.24.41.48.41h3.84c.24 0 .44-.17.47-.41l.36-2.54c.59-.24 1.13-.56 1.62-.94l2.39.96c.22.08.47 0 .59-.22l1.92-3.32c.12-.22.07-.47-.12-.61l-2.01-1.58zM12 15.6c-1.98 0-3.6-1.62-3.6-3.6s1.62-3.6 3.6-3.6 3.6 1.62 3.6 3.6-1.62 3.6-3.6 3.6z',
'SETTINGS'
)
})
</script>
<template>
<PageLayout
title="SETTINGS"
badge=""
subtitle=""
>
<div class="flex items-center justify-center h-full">
<p class="text-zinc-500 text-sm">Coming Soon</p>
</div>
</PageLayout>
</template>

View File

@@ -1,13 +1,33 @@
import { createRouter, createWebHistory } from "vue-router"; import { createRouter, createWebHistory } from "vue-router";
import Pokedex from "./components/Pokedex.vue"; import Home from "./pages/Home.vue";
import Page404 from "./components/404.vue"; import Pokedex from "./pages/Pokedex.vue";
import About from "./components/About.vue"; import Pokemon from "./pages/Pokemon.vue";
import PokemonLocations from "./pages/PokemonLocations.vue";
import PokemonColors from "./pages/PokemonColors.vue";
import PokemonForms from "./pages/PokemonForms.vue";
import PokemonShapes from "./pages/PokemonShapes.vue";
import PokemonSpecies from "./pages/PokemonSpecies.vue";
import Settings from "./pages/Settings.vue";
import Maps from "./pages/Maps.vue";
import Games from "./pages/Games.vue";
import Moves from "./pages/Moves.vue";
import NotFound from "./pages/NotFound.vue";
const routes = [ const routes = [
{ path: "/", component: About, name: "home" }, { path: "/", component: Home, name: "home" },
{ path: "/pokemon/:id", component: Pokedex, name: "pokemon" }, { path: "/pokemon/:id", component: Pokedex, name: "pokemon" },
{ path: "/about", component: About, name: "about" }, { path: "/pokemon/:id/locations", component: PokemonLocations, name: "pokemon-locations" },
{ path: "/:catchAll(.*)", component: Page404, name: "404" }, { path: "/pokemon/:id/colors", component: PokemonColors, name: "pokemon-colors" },
{ path: "/pokemon/:id/forms", component: PokemonForms, name: "pokemon-forms" },
{ path: "/pokemon/:id/shapes", component: PokemonShapes, name: "pokemon-shapes" },
{ path: "/pokemon/:id/species", component: PokemonSpecies, name: "pokemon-species" },
{ path: "/pokemon", component: Pokemon, name: "pokemon-list" },
{ path: "/settings", component: Settings, name: "settings" },
{ path: "/maps", component: Maps, name: "maps" },
{ path: "/games", component: Games, name: "games" },
{ path: "/moves", component: Moves, name: "moves" },
{ path: "/home", component: Home, name: "home-alias" },
{ path: "/:catchAll(.*)", component: NotFound, name: "404" },
]; ];
const history = createWebHistory(); const history = createWebHistory();

33
src/store.js Normal file
View File

@@ -0,0 +1,33 @@
import { reactive } from 'vue'
export const appState = reactive({
currentPokemon: null,
previousPokemon: null,
nextPokemon: null,
pageDisplay: {
icon: null,
title: null
},
setCurrentPokemon(pokemon) {
this.currentPokemon = pokemon
},
setPreviousPokemon(pokemon) {
this.previousPokemon = pokemon
},
setNextPokemon(pokemon) {
this.nextPokemon = pokemon
},
clearCurrentPokemon() {
this.currentPokemon = null
this.previousPokemon = null
this.nextPokemon = null
},
setPageDisplay(icon, title) {
this.pageDisplay.icon = icon
this.pageDisplay.title = title
},
clearPageDisplay() {
this.pageDisplay.icon = null
this.pageDisplay.title = null
}
})

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

View File

@@ -1,3 +1 @@
@tailwind base; @import "tailwindcss";
@tailwind components;
@tailwind utilities;

11
src/utils/theme.js Normal file
View File

@@ -0,0 +1,11 @@
import tinycolor from 'tinycolor2'
export function generateThemeColors(colorArray) {
const rgbColor = `rgb(${colorArray[0]}, ${colorArray[1]}, ${colorArray[2]})`
const originalColor = tinycolor(rgbColor)
return {
background: originalColor.darken(30).toString(),
text: originalColor.brighten(40).toString() // slightly brighter for contrast
}
}

View File

@@ -1,12 +0,0 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{vue,js,ts,jsx,tsx}",
],
theme: {
extend: {},
},
plugins: [],
}

View File

@@ -1,9 +1,13 @@
import { defineConfig } from 'vite' import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue' import vue from '@vitejs/plugin-vue'
import tailwindcss from '@tailwindcss/vite'
// https://vitejs.dev/config/ // https://vitejs.dev/config/
export default defineConfig({ export default defineConfig({
plugins: [vue()], plugins: [
vue(),
tailwindcss(),
],
server: { server: {
watch: { watch: {
usePolling: true, usePolling: true,