Compare commits
7 Commits
3d5b5f5262
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 5a61caf3f5 | |||
| 8d0e4e9115 | |||
| f4dedcd66e | |||
| d55aac6605 | |||
| 4f103c25b5 | |||
| c24e98ff8a | |||
| 0f77a1e81b |
21
.github/workflows/deploy.yml
vendored
21
.github/workflows/deploy.yml
vendored
@@ -1,21 +0,0 @@
|
|||||||
name: Vercel Production Deployment
|
|
||||||
env:
|
|
||||||
VERCEL_ORG_ID: ${{ secrets.VERCEL_ORG_ID }}
|
|
||||||
VERCEL_PROJECT_ID: ${{ secrets.VERCEL_PROJECT_ID }}
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
jobs:
|
|
||||||
Deploy-Production:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- uses: actions/checkout@v2
|
|
||||||
- name: Install Vercel CLI
|
|
||||||
run: npm install --global vercel@latest
|
|
||||||
- name: Pull Vercel Environment Information
|
|
||||||
run: vercel pull --yes --environment=production --token=${{ secrets.VERCEL_TOKEN }}
|
|
||||||
- name: Build Project Artifacts
|
|
||||||
run: vercel build --prod --token=${{ secrets.VERCEL_TOKEN }}
|
|
||||||
- name: Deploy Project Artifacts to Vercel
|
|
||||||
run: vercel deploy --prebuilt --prod --token=${{ secrets.VERCEL_TOKEN }}
|
|
||||||
8
agent.md
Normal file
8
agent.md
Normal 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.
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Pokedex by xyvs</title>
|
<title>Pokedex by xyvs</title>
|
||||||
|
<script defer src="https://umami.fran.jp.net/script.js" data-website-id="af4ff079-dfc8-405a-8e10-4677fa9e39c6" data-domains="pokedex.xyvs.io"></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|||||||
4420
package-lock.json
generated
4420
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
10
package.json
10
package.json
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
export default {
|
|
||||||
plugins: {
|
|
||||||
tailwindcss: {},
|
|
||||||
autoprefixer: {},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
90
src/App.vue
90
src/App.vue
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
|
||||||
|
|
||||||
|
|
||||||
@@ -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>
|
|
||||||
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
|
|
||||||
@@ -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>
|
|
||||||
@@ -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>
|
|
||||||
|
|
||||||
@@ -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>
|
|
||||||
|
|
||||||
@@ -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>
|
|
||||||
|
|
||||||
@@ -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>
|
||||||
61
src/components/layout/Header.vue
Normal file
61
src/components/layout/Header.vue
Normal 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>
|
||||||
68
src/components/layout/LeftScreen.vue
Normal file
68
src/components/layout/LeftScreen.vue
Normal 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>
|
||||||
56
src/components/layout/PageLayout.vue
Normal file
56
src/components/layout/PageLayout.vue
Normal 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>
|
||||||
53
src/components/layout/PokedexChassis.vue
Normal file
53
src/components/layout/PokedexChassis.vue
Normal 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>
|
||||||
161
src/components/layout/RightControls.vue
Normal file
161
src/components/layout/RightControls.vue
Normal 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>
|
||||||
48
src/components/layout/ScreenSplit.vue
Normal file
48
src/components/layout/ScreenSplit.vue
Normal 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>
|
||||||
9
src/components/layout/StatusBar.vue
Normal file
9
src/components/layout/StatusBar.vue
Normal 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>
|
||||||
82
src/components/lists/PopularPokemonList.vue
Normal file
82
src/components/lists/PopularPokemonList.vue
Normal 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>
|
||||||
18
src/components/pokemon/Description.vue
Normal file
18
src/components/pokemon/Description.vue
Normal 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>
|
||||||
26
src/components/pokemon/Information.vue
Normal file
26
src/components/pokemon/Information.vue
Normal 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>
|
||||||
|
|
||||||
19
src/components/pokemon/Pokemon.vue
Normal file
19
src/components/pokemon/Pokemon.vue
Normal 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>
|
||||||
|
|
||||||
50
src/components/ui/ActionDisplay.vue
Normal file
50
src/components/ui/ActionDisplay.vue
Normal 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>
|
||||||
19
src/components/ui/ArrowButton.vue
Normal file
19
src/components/ui/ArrowButton.vue
Normal 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>
|
||||||
16
src/components/ui/BackgroundGrid.vue
Normal file
16
src/components/ui/BackgroundGrid.vue
Normal 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>
|
||||||
11
src/components/ui/BezelButton.vue
Normal file
11
src/components/ui/BezelButton.vue
Normal 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>
|
||||||
22
src/components/ui/NumpadKey.vue
Normal file
22
src/components/ui/NumpadKey.vue
Normal 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>
|
||||||
11
src/components/ui/PillButton.vue
Normal file
11
src/components/ui/PillButton.vue
Normal 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>
|
||||||
59
src/components/ui/PokemonInfoCard.vue
Normal file
59
src/components/ui/PokemonInfoCard.vue
Normal 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>
|
||||||
24
src/components/ui/SearchDisplay.vue
Normal file
24
src/components/ui/SearchDisplay.vue
Normal 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>
|
||||||
15
src/components/ui/SideMenuButton.vue
Normal file
15
src/components/ui/SideMenuButton.vue
Normal 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>
|
||||||
15
src/components/ui/StatusButton.vue
Normal file
15
src/components/ui/StatusButton.vue
Normal 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>
|
||||||
127
src/composables/usePokedexNavigation.js
Normal file
127
src/composables/usePokedexNavigation.js
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
60
src/composables/useScreenActions.js
Normal file
60
src/composables/useScreenActions.js
Normal 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
2
src/constants/layout.js
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export const SCREEN_MAX_WIDTH = 380
|
||||||
|
export const SCREEN_MAX_HEIGHT = 680
|
||||||
29
src/constants/ui.js
Normal file
29
src/constants/ui.js
Normal 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]
|
||||||
@@ -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
24
src/pages/Games.vue
Normal 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
249
src/pages/Home.vue
Normal 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
24
src/pages/Maps.vue
Normal 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
24
src/pages/Moves.vue
Normal 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
50
src/pages/NotFound.vue
Normal 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
203
src/pages/Pokedex.vue
Normal 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
306
src/pages/Pokemon.vue
Normal 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
200
src/pages/PokemonColors.vue
Normal 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
175
src/pages/PokemonForms.vue
Normal 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>
|
||||||
185
src/pages/PokemonLocations.vue
Normal file
185
src/pages/PokemonLocations.vue
Normal 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
243
src/pages/PokemonShapes.vue
Normal 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>
|
||||||
246
src/pages/PokemonSpecies.vue
Normal file
246
src/pages/PokemonSpecies.vue
Normal 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
24
src/pages/Settings.vue
Normal 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>
|
||||||
@@ -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
33
src/store.js
Normal 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
|
||||||
|
}
|
||||||
|
})
|
||||||
42
src/stores/pokemonListStore.js
Normal file
42
src/stores/pokemonListStore.js
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
|
||||||
|
export const usePokemonListStore = defineStore('pokemonList', {
|
||||||
|
state: () => ({
|
||||||
|
lastSelectedPokemonId: parseInt(localStorage.getItem('lastSelectedPokemonId')) || null,
|
||||||
|
selectedIndex: 0,
|
||||||
|
onSelect: null,
|
||||||
|
onMoveUp: null,
|
||||||
|
onMoveDown: null
|
||||||
|
}),
|
||||||
|
|
||||||
|
actions: {
|
||||||
|
setLastSelectedPokemon(id) {
|
||||||
|
this.lastSelectedPokemonId = id
|
||||||
|
if (id) {
|
||||||
|
localStorage.setItem('lastSelectedPokemonId', id.toString())
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem('lastSelectedPokemonId')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
setNavigationHandlers(handlers) {
|
||||||
|
this.selectedIndex = handlers.selectedIndex || 0
|
||||||
|
this.onSelect = handlers.onSelect || null
|
||||||
|
this.onMoveUp = handlers.onMoveUp || null
|
||||||
|
this.onMoveDown = handlers.onMoveDown || null
|
||||||
|
},
|
||||||
|
|
||||||
|
clearNavigationHandlers() {
|
||||||
|
this.selectedIndex = 0
|
||||||
|
this.onSelect = null
|
||||||
|
this.onMoveUp = null
|
||||||
|
this.onMoveDown = null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
getters: {
|
||||||
|
hasNavigationHandlers: (state) => {
|
||||||
|
return state.onSelect !== null || state.onMoveUp !== null || state.onMoveDown !== null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -1,3 +1 @@
|
|||||||
@tailwind base;
|
@import "tailwindcss";
|
||||||
@tailwind components;
|
|
||||||
@tailwind utilities;
|
|
||||||
11
src/utils/theme.js
Normal file
11
src/utils/theme.js
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
/** @type {import('tailwindcss').Config} */
|
|
||||||
export default {
|
|
||||||
content: [
|
|
||||||
"./index.html",
|
|
||||||
"./src/**/*.{vue,js,ts,jsx,tsx}",
|
|
||||||
],
|
|
||||||
theme: {
|
|
||||||
extend: {},
|
|
||||||
},
|
|
||||||
plugins: [],
|
|
||||||
}
|
|
||||||
|
|
||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user