fix: redesing and add features

This commit is contained in:
2026-02-07 12:06:35 -06:00
parent 4f103c25b5
commit d55aac6605
40 changed files with 1486 additions and 403 deletions

8
agent.md Normal file
View File

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

View File

@@ -1,36 +1,64 @@
<script setup>
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()
import PokedexChassis from './components/layout/PokedexChassis.vue'
</script>
<template>
<router-view />
<PokedexChassis />
</template>
<style>
body {
margin: 0;
overflow: hidden;
background-color: #18181b;
}
/*
CRITICAL: Override child components that rely on screen dimensions
to fit within the Pokedex screen container.
*/
.pokedex-screen .h-screen,
.pokedex-screen .min-h-screen {
height: 100% !important;
min-height: 100% !important;
width: 100% !important;
}
.pokedex-screen .w-screen {
width: 100% !important;
padding: 0 !important; /* Reset padding to context */
}
.pokedex-screen .lg\:p-12 {
padding: 1.5rem !important; /* Scale down padding */
}
.pokedex-screen .fixed {
position: absolute !important; /* Contain fixed elements like headers */
}
/* Custom Scrollbar for inside the screen */
.pokedex-screen ::-webkit-scrollbar {
width: 4px;
background: transparent;
}
.pokedex-screen ::-webkit-scrollbar-thumb {
background: #dc0a2d;
border-radius: 2px;
}
/* Reusable custom scrollbar class for content areas */
.custom-scrollbar::-webkit-scrollbar {
width: 4px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: #3f3f46;
border-radius: 4px;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: #52525b;
}
</style>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -4,7 +4,7 @@
<a :href="$router.resolve({ name: 'pokemon', params: { id: 1 } }).href">
Pokedex
</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://github.com/xyvs/pokedex" target="_blank">Code</a>
</div>

View 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>

View File

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

View File

@@ -0,0 +1,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>

View 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>

View File

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

View File

@@ -0,0 +1,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>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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
}
}

View File

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

23
src/constants/ui.js Normal file
View 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
View 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
View 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
View 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>

View File

@@ -1,13 +1,13 @@
import { createRouter, createWebHistory } from "vue-router";
import Pokedex from "./components/Pokedex.vue";
import Page404 from "./components/404.vue";
import About from "./components/About.vue";
import Home from "./pages/Home.vue";
import Pokedex from "./pages/Pokedex.vue";
import NotFound from "./pages/NotFound.vue";
const routes = [
{ path: "/", component: About, name: "home" },
{ path: "/", component: Home, name: "home" },
{ path: "/pokemon/:id", component: Pokedex, name: "pokemon" },
{ path: "/about", component: About, name: "about" },
{ path: "/:catchAll(.*)", component: Page404, name: "404" },
{ path: "/home", component: Home, name: "home-alias" },
{ path: "/:catchAll(.*)", component: NotFound, name: "404" },
];
const history = createWebHistory();

21
src/store.js Normal file
View 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
View File

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