fix: redesing and add features
This commit is contained in:
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.
|
||||||
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>
|
||||||
76
src/components/layout/Header.vue
Normal file
76
src/components/layout/Header.vue
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
title: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
badge: {
|
||||||
|
type: [String, Array],
|
||||||
|
default: null
|
||||||
|
},
|
||||||
|
subtitle: {
|
||||||
|
type: String,
|
||||||
|
default: null
|
||||||
|
},
|
||||||
|
titleColor: {
|
||||||
|
type: String,
|
||||||
|
default: 'text-white'
|
||||||
|
},
|
||||||
|
batteryLevel: {
|
||||||
|
type: Number,
|
||||||
|
default: 80
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const currentTime = ref(new Date().toLocaleTimeString('en-US', { hour12: false, hour: '2-digit', minute: '2-digit' }))
|
||||||
|
|
||||||
|
setInterval(() => {
|
||||||
|
currentTime.value = new Date().toLocaleTimeString('en-US', { hour12: false, hour: '2-digit', minute: '2-digit' })
|
||||||
|
}, 1000)
|
||||||
|
</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" :class="titleColor">{{ 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>
|
||||||
|
|
||||||
|
<!-- Time -->
|
||||||
|
<span class="font-mono text-[10px] text-zinc-500">{{ currentTime }}</span>
|
||||||
|
</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>
|
||||||
42
src/components/layout/PokedexChassis.vue
Normal file
42
src/components/layout/PokedexChassis.vue
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
<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 } = usePokedexNavigation()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="bg-zinc-900 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"
|
||||||
|
/>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
137
src/components/layout/RightControls.vue
Normal file
137
src/components/layout/RightControls.vue
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
<script setup>
|
||||||
|
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 props = defineProps({
|
||||||
|
searchQuery: {
|
||||||
|
type: String,
|
||||||
|
default: ''
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits([
|
||||||
|
'keypad',
|
||||||
|
'main-button',
|
||||||
|
'back-button',
|
||||||
|
'go-home',
|
||||||
|
'next',
|
||||||
|
'prev'
|
||||||
|
])
|
||||||
|
</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>
|
||||||
|
|
||||||
|
<!-- Home Mode -->
|
||||||
|
<div v-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="emit('back-button')" />
|
||||||
|
<NumpadKey label="0" @click="emit('keypad', 0)" />
|
||||||
|
<NumpadKey label="GO" variant="success" @click="emit('main-button')" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- RIGHT: Slim Blue Buttons List -->
|
||||||
|
<div class="w-[140px] flex flex-col gap-2 mr-4">
|
||||||
|
<SideMenuButton
|
||||||
|
v-for="(label, i) in SIDE_MENU_ITEMS"
|
||||||
|
:key="i"
|
||||||
|
:label="label"
|
||||||
|
@click="i === 0 ? emit('go-home') : null"
|
||||||
|
/>
|
||||||
|
</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 / Execute -->
|
||||||
|
<PillButton @click="emit('main-button')">
|
||||||
|
<span class="text-[9px] font-black tracking-wider text-black/60">{{ searchQuery ? 'ENTER' : 'HOME' }}</span>
|
||||||
|
<svg v-if="!searchQuery" 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>
|
||||||
|
<svg v-else xmlns="http://www.w3.org/2000/svg" width="10" height="10" viewBox="0 0 24 24" fill="currentColor" class="text-black/60"><path d="M5 12h14M12 5l7 7-7 7"/></svg>
|
||||||
|
</PillButton>
|
||||||
|
|
||||||
|
<!-- Back / Clear -->
|
||||||
|
<PillButton @click="emit('back-button')">
|
||||||
|
<span class="text-[9px] font-black tracking-wider text-black/60">{{ searchQuery ? 'CLEAR' : 'BACK' }}</span>
|
||||||
|
<svg v-if="!searchQuery" 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>
|
||||||
|
<svg v-else 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="M18 6L6 18M6 6l12 12"/></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>
|
||||||
23
src/components/layout/StatusBar.vue
Normal file
23
src/components/layout/StatusBar.vue
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
<script setup>
|
||||||
|
const props = defineProps({
|
||||||
|
leftText: {
|
||||||
|
type: String,
|
||||||
|
default: 'Global Database'
|
||||||
|
},
|
||||||
|
rightText: {
|
||||||
|
type: String,
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
rightTextColor: {
|
||||||
|
type: String,
|
||||||
|
default: 'text-zinc-600'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</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>{{ leftText }}</span>
|
||||||
|
<span :class="rightTextColor">{{ rightText }}</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>
|
||||||
81
src/composables/usePokedexNavigation.js
Normal file
81
src/composables/usePokedexNavigation.js
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
import { ref } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { appState } from '../store'
|
||||||
|
import { MAX_SEARCH_LENGTH } from '../constants/ui'
|
||||||
|
|
||||||
|
const searchQuery = ref('')
|
||||||
|
|
||||||
|
export function usePokedexNavigation() {
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
function handleKeypad(num) {
|
||||||
|
if (searchQuery.value.length < MAX_SEARCH_LENGTH) {
|
||||||
|
searchQuery.value += String(num)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function goHome() {
|
||||||
|
router.push('/')
|
||||||
|
}
|
||||||
|
|
||||||
|
function goBack() {
|
||||||
|
router.go(-1)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 handleMainButton() {
|
||||||
|
if (searchQuery.value) {
|
||||||
|
executeSearch()
|
||||||
|
} else {
|
||||||
|
goHome()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleBackButton() {
|
||||||
|
if (searchQuery.value) {
|
||||||
|
clearSearch()
|
||||||
|
} else {
|
||||||
|
goBack()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleNext() {
|
||||||
|
if (appState.currentPokemon) {
|
||||||
|
let nextId = appState.currentPokemon.id + 1
|
||||||
|
router.push(`/pokemon/${nextId}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handlePrev() {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
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
|
||||||
|
}
|
||||||
|
}
|
||||||
23
src/constants/ui.js
Normal file
23
src/constants/ui.js
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
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 = ['POKEDEX', 'FAVORITES', 'SETTINGS', 'MAP', 'BAG']
|
||||||
|
|
||||||
|
export const KEYPAD_KEYS = [1, 2, 3, 4, 5, 6, 7, 8, 9, 0]
|
||||||
152
src/pages/Home.vue
Normal file
152
src/pages/Home.vue
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
<script setup>
|
||||||
|
import { ref, onMounted } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import ColorThief from 'colorthief'
|
||||||
|
import { generateThemeColors } from '../utils/theme'
|
||||||
|
import { appState } from '../store'
|
||||||
|
import Header from '../components/layout/Header.vue'
|
||||||
|
import StatusBar from '../components/layout/StatusBar.vue'
|
||||||
|
import BackgroundGrid from '../components/ui/BackgroundGrid.vue'
|
||||||
|
import { useScreenActions } from '../composables/useScreenActions'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
// Clear active pokemon state when entering Dashboard
|
||||||
|
appState.clearCurrentPokemon()
|
||||||
|
|
||||||
|
const { setButtonActions } = useScreenActions()
|
||||||
|
|
||||||
|
// Reusing the featured list logic but integrated into the dashboard
|
||||||
|
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
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function goToRandomPokemon() {
|
||||||
|
const randomId = Math.floor(Math.random() * 1025) + 1
|
||||||
|
router.push(`/pokemon/${randomId}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
extractColors()
|
||||||
|
setButtonActions([
|
||||||
|
{ buttonNumber: 3, label: 'RANDOM', action: goToRandomPokemon },
|
||||||
|
{ buttonNumber: 5, label: 'SETTINGS', action: () => console.log('Settings - TBA') }
|
||||||
|
])
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="absolute inset-0 bg-zinc-950 text-white font-sans flex flex-col overflow-hidden selection:bg-red-500/30">
|
||||||
|
|
||||||
|
<BackgroundGrid :opacity="10" />
|
||||||
|
|
||||||
|
<!-- Top Bar -->
|
||||||
|
<Header
|
||||||
|
title="POKÉDEX"
|
||||||
|
badge="OS v2.0"
|
||||||
|
subtitle="Global Database"
|
||||||
|
:battery-level="80"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Scrollable Dashboard Content -->
|
||||||
|
<div class="flex-1 overflow-y-auto p-4 space-y-5 z-10 custom-scrollbar pb-8">
|
||||||
|
|
||||||
|
<!-- Welcome / Status Card -->
|
||||||
|
<div class="bg-gradient-to-br from-zinc-800 to-zinc-900 rounded-xl p-5 border border-zinc-700 shadow-lg relative overflow-hidden group hover:border-zinc-500 transition-colors">
|
||||||
|
<div class="relative z-10">
|
||||||
|
<h2 class="font-bold text-xl mb-1 text-white">System Ready</h2>
|
||||||
|
<p class="text-xs text-zinc-400 max-w-[75%] leading-relaxed">
|
||||||
|
Database synchronized. All regions accessible.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<!-- Decorative Elements -->
|
||||||
|
<div class="absolute -right-2 -bottom-6 text-zinc-800/50 transform rotate-12 group-hover:rotate-0 transition-transform duration-500">
|
||||||
|
<svg width="100" height="100" viewBox="0 0 24 24" fill="currentColor"><path d="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"/></svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Quick Stats Grid -->
|
||||||
|
<div class="grid grid-cols-2 gap-3">
|
||||||
|
<div class="bg-zinc-900/50 border border-zinc-800 p-3 rounded-lg flex flex-col gap-1 items-center justify-center hover:bg-zinc-800 transition-colors cursor-default">
|
||||||
|
<span class="text-2xl font-mono font-bold text-white">1025+</span>
|
||||||
|
<span class="text-[9px] uppercase tracking-widest text-zinc-500">Total Found</span>
|
||||||
|
</div>
|
||||||
|
<div class="bg-zinc-900/50 border border-zinc-800 p-3 rounded-lg flex flex-col gap-1 items-center justify-center hover:bg-zinc-800 transition-colors cursor-default">
|
||||||
|
<span class="text-2xl font-mono font-bold text-blue-400">98%</span>
|
||||||
|
<span class="text-[9px] uppercase tracking-widest text-zinc-500">Battery</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Featured Section -->
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center justify-between mb-3 px-1">
|
||||||
|
<h3 class="text-[10px] font-bold uppercase text-zinc-500 tracking-[0.2em] flex items-center gap-2">
|
||||||
|
<span class="w-1 h-3 bg-red-600 rounded-full"></span>
|
||||||
|
Priority Targets
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 gap-2">
|
||||||
|
<router-link
|
||||||
|
v-for="poke in featuredPokemon"
|
||||||
|
:key="poke.id"
|
||||||
|
:to="`/pokemon/${poke.id}`"
|
||||||
|
class="bg-zinc-900 border border-zinc-800 rounded-lg p-2.5 flex items-center gap-4 hover:border-zinc-500 hover:bg-zinc-800 transition-all group relative overflow-hidden"
|
||||||
|
>
|
||||||
|
<!-- Row Hover Highlight -->
|
||||||
|
<div class="absolute inset-0 bg-white/5 opacity-0 group-hover:opacity-100 transition-opacity"></div>
|
||||||
|
|
||||||
|
<div class="w-10 h-10 bg-zinc-950 rounded border border-zinc-800 flex items-center justify-center relative shadow-inner">
|
||||||
|
<img :src="getImageUrl(poke.id)" class="w-8 h-8 object-contain z-10 group-hover:scale-125 transition-transform duration-300" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex-1 z-10">
|
||||||
|
<div class="flex justify-between items-baseline">
|
||||||
|
<span class="font-bold text-sm text-zinc-200 group-hover:text-white">{{ poke.name }}</span>
|
||||||
|
<span class="text-[10px] font-mono text-zinc-600 group-hover:text-zinc-400">#{{ String(poke.id).padStart(3, '0') }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-1 mt-0.5">
|
||||||
|
<span class="w-1.5 h-1.5 rounded-full bg-zinc-600 group-hover:bg-green-500 transition-colors"></span>
|
||||||
|
<span class="text-[9px] text-zinc-500 uppercase tracking-wide">{{ poke.type }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="pr-1 opacity-0 group-hover:opacity-100 transition-all -translate-x-4 group-hover:translate-x-0 z-10">
|
||||||
|
<svg class="w-4 h-4 text-zinc-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7"></path></svg>
|
||||||
|
</div>
|
||||||
|
</router-link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Status Bar -->
|
||||||
|
<StatusBar right-text="v2.0.4 - Stable" />
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
59
src/pages/NotFound.vue
Normal file
59
src/pages/NotFound.vue
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
<script setup>
|
||||||
|
import { onMounted } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { useScreenActions } from '../composables/useScreenActions'
|
||||||
|
import Header from '../components/layout/Header.vue'
|
||||||
|
import StatusBar from '../components/layout/StatusBar.vue'
|
||||||
|
import BackgroundGrid from '../components/ui/BackgroundGrid.vue'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const { setButtonActions } = useScreenActions()
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
setButtonActions([
|
||||||
|
{ buttonNumber: 3, label: 'HOME', action: () => router.push('/') }
|
||||||
|
])
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="w-full h-full bg-zinc-950 text-white font-mono flex flex-col overflow-hidden relative selection:bg-red-500/30">
|
||||||
|
|
||||||
|
<BackgroundGrid :opacity="20" />
|
||||||
|
|
||||||
|
<!-- Top Bar -->
|
||||||
|
<Header
|
||||||
|
title="ERROR"
|
||||||
|
badge="404"
|
||||||
|
subtitle="Entry Not Found"
|
||||||
|
title-color="text-red-400"
|
||||||
|
:battery-level="60"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Main Error Content -->
|
||||||
|
<div class="flex-1 min-h-0 flex items-center justify-center relative z-10 p-8">
|
||||||
|
<div class="text-center space-y-6">
|
||||||
|
|
||||||
|
<!-- Simple 404 Display -->
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<!-- Simple Message -->
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<!-- Status Bar -->
|
||||||
|
<StatusBar right-text="Error - 404" right-text-color="text-red-500" />
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
224
src/pages/Pokedex.vue
Normal file
224
src/pages/Pokedex.vue
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
<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 Header from '../components/layout/Header.vue'
|
||||||
|
import StatusBar from '../components/layout/StatusBar.vue'
|
||||||
|
import BackgroundGrid from '../components/ui/BackgroundGrid.vue'
|
||||||
|
import ColorThief from 'colorthief'
|
||||||
|
import { generateThemeColors } from '../utils/theme'
|
||||||
|
import { appState } from '../store'
|
||||||
|
import { useScreenActions } from '../composables/useScreenActions'
|
||||||
|
|
||||||
|
const { setButtonActions } = useScreenActions()
|
||||||
|
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() {
|
||||||
|
// Reset state before fetch to avoid stale data flash
|
||||||
|
pokemon.value = null
|
||||||
|
|
||||||
|
const currentId = parseInt(route.params.id)
|
||||||
|
|
||||||
|
// Fetch current Pokemon
|
||||||
|
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)
|
||||||
|
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
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch previous Pokemon (if exists)
|
||||||
|
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 next Pokemon
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Initial fetch
|
||||||
|
getPokemonData()
|
||||||
|
|
||||||
|
// Watch for route changes to refetch data
|
||||||
|
watch(() => route.params.id, () => {
|
||||||
|
getPokemonData()
|
||||||
|
})
|
||||||
|
onMounted(() => {
|
||||||
|
setButtonActions([
|
||||||
|
{ buttonNumber: 1, label: 'LIST', action: () => router.push('/') },
|
||||||
|
{ buttonNumber: 2, label: 'STATS', action: () => console.log('Stats view - TBA') },
|
||||||
|
{ buttonNumber: 3, label: 'EVOL', action: () => console.log('Evolution chain - TBA') },
|
||||||
|
{ buttonNumber: 4, label: 'MOVES', action: () => console.log('Moves list - TBA') },
|
||||||
|
{ buttonNumber: 5, label: 'THEME', action: changeTheme }
|
||||||
|
])
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div v-if="pokemon && pokemonSpecies && pokemonColors" class="w-full h-full bg-zinc-950 text-white font-mono flex flex-col overflow-hidden relative selection:bg-red-500/30">
|
||||||
|
|
||||||
|
<BackgroundGrid :opacity="20" />
|
||||||
|
|
||||||
|
<!-- Dynamic accent glow based on Pokemon color -->
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<!-- Top Bar (Consistent with Home) -->
|
||||||
|
<Header
|
||||||
|
:title="pokemon.name"
|
||||||
|
:badge="pokemon.types.map(t => t.type.name)"
|
||||||
|
:subtitle="`#${String(pokemon.id).padStart(4, '0')}`"
|
||||||
|
:battery-level="60"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<!-- Main Content -->
|
||||||
|
<div class="flex-1 min-h-0 flex flex-col relative z-10 p-3 gap-2 overflow-hidden">
|
||||||
|
|
||||||
|
<!-- Top Section: Name + Stats + Image -->
|
||||||
|
<div class="flex gap-3 shrink-0">
|
||||||
|
|
||||||
|
<!-- Left: Species + Stats -->
|
||||||
|
<div class="flex-1 flex flex-col gap-2 min-h-0">
|
||||||
|
<!-- Species Info -->
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<!-- Stats/Information -->
|
||||||
|
<div class="overflow-y-auto custom-scrollbar min-h-0">
|
||||||
|
<Information :pokemonData="pokemon" :color="pokemonColors.backgroundColor" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right: Image Viewport -->
|
||||||
|
<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">
|
||||||
|
<!-- Corner brackets -->
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<!-- Bottom Section: Description (Full Width, Scrollable) -->
|
||||||
|
<div class="flex-1 min-h-0 overflow-y-auto custom-scrollbar pt-2">
|
||||||
|
<Description :pokemonData="pokemonSpecies" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Status Bar -->
|
||||||
|
<StatusBar right-text="v2.0.4 - Stable" />
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Loading State -->
|
||||||
|
<div v-else class="w-full h-full bg-zinc-950 text-white font-mono flex flex-col items-center justify-center relative overflow-hidden">
|
||||||
|
|
||||||
|
<BackgroundGrid :opacity="10" />
|
||||||
|
|
||||||
|
<div class="flex flex-col items-center gap-4 z-10">
|
||||||
|
<!-- Animated Loader -->
|
||||||
|
<div class="relative w-16 h-16">
|
||||||
|
<!-- Outer Ring -->
|
||||||
|
<div class="absolute inset-0 border-2 border-zinc-800 rounded-full"></div>
|
||||||
|
<!-- Spinning Segment -->
|
||||||
|
<div class="absolute inset-0 border-t-2 border-red-500 rounded-full animate-spin"></div>
|
||||||
|
<!-- Inner Pulse -->
|
||||||
|
<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>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
@keyframes scan {
|
||||||
|
0%, 100% { top: 10%; opacity: 0; }
|
||||||
|
10% { opacity: 1; }
|
||||||
|
90% { opacity: 1; }
|
||||||
|
100% { top: 90%; opacity: 0; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,13 +1,13 @@
|
|||||||
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 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: "/home", component: Home, name: "home-alias" },
|
||||||
{ path: "/:catchAll(.*)", component: Page404, name: "404" },
|
{ path: "/:catchAll(.*)", component: NotFound, name: "404" },
|
||||||
];
|
];
|
||||||
|
|
||||||
const history = createWebHistory();
|
const history = createWebHistory();
|
||||||
|
|||||||
21
src/store.js
Normal file
21
src/store.js
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
import { reactive } from 'vue'
|
||||||
|
|
||||||
|
export const appState = reactive({
|
||||||
|
currentPokemon: null,
|
||||||
|
previousPokemon: null,
|
||||||
|
nextPokemon: 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
|
||||||
|
}
|
||||||
|
})
|
||||||
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
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user