Scaffold Pokedex PWA (Vite + Workbox)

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
chris 2026-08-27 10:54:36 -04:00
commit cd2655011f
32 changed files with 8662 additions and 0 deletions

9
.gitignore vendored Normal file
View File

@ -0,0 +1,9 @@
node_modules
dist
dev-dist
.DS_Store
*.local
.vite
# Generated at build time from PokéAPI. Run `npm run snapshot` to (re)create.
src/data/snapshot.json

67
README.md Normal file
View File

@ -0,0 +1,67 @@
# Pokédex PWA
A responsive, offline-capable Pokédex reference built on
[PokéAPI](https://pokeapi.co/). Vanilla JS + Vite + Workbox — no UI
framework.
## Two headline features
| Feature | How it works |
| --- | --- |
| **Game series selection** | Pick a *game* (PokéAPI version group, e.g. Scarlet & Violet). If it has more than one regional dex (Paldea / Kitakami / Blueberry) a sub-dex switcher appears. The choice drives which species show, their regional numbering, and which version's flavor text the detail page uses. Stored in `localStorage`. |
| **Unified Pokémon selection** | One tracking record per Pokémon, keyed by **National Dex id**. Mark Pikachu once and every game's view reflects it — each dex computes its own "Seen / Caught" totals by intersecting its species list with that single map. The active Pokémon is also a single deep-linkable route (`#/pokemon/25`). |
## Data & storage split
- **Preferences + tracking state**`localStorage` (`pdx.settings`, `pdx.selection`). Tiny, synchronous, restored on reload.
- **Build-time snapshot** (`src/data/snapshot.json`, ~150 KB) → generated by `npm run snapshot` from PokéAPI: every species (id, name, generation, types), all regional Pokédex lists, and all version groups. Precached by the service worker, so the list / search / game switching work on the first offline launch.
- **Detail data** (stats, abilities, flavor text, evolution) → fetched lazily from `pokeapi.co` per Pokémon, then cached by the service worker (stale-while-revalidate, 30-day TTL).
- **Sprites** → cache-first with a capped LRU.
Export / import of `settings` + `selection` as JSON lives in **Settings**.
## Scripts
```bash
npm install
npm run snapshot # fetch data from PokéAPI -> src/data/snapshot.json (run once; refreshes if >30 days old)
npm run dev # vite dev server
npm run build # runs snapshot (prebuild) then vite build
npm run preview # serve the production build (needed to exercise the service worker)
```
Deploying under a sub-path (e.g. GitHub Pages project site): `BASE_PATH=/repo-name/ npm run build`.
## Project layout
```
scripts/build-snapshot.mjs PokéAPI -> snapshot.json
src/
main.js boot: theme, nav, router, SW registration
router.js hash router (#/, #/pokemon/:id, #/games, #/search, #/settings)
sw.js Workbox service worker (injectManifest)
store/ createStore + settings + selection (localStorage)
data/ snapshot loader, pokedex resolver, lazy API client
components/ Card, Sprite, TypeChip, StatBar, ProgressHeader, Nav
views/ DexGrid, PokemonDetail, GamePicker, SearchView, SettingsView
styles/ tokens.css (palette, type colors, light/dark), layout.css
```
## Status
Scaffold — build steps 15 of the design are in place: responsive shell,
snapshot pipeline, dex grid with per-dex progress, game picker, lazy detail
page, offline search, settings with export/import, and the service worker.
Not yet done: evolution chain, version-exclusive badges, richer filters
(type/generation/legendary — the snapshot would need legendary flags),
skeleton loaders, install-prompt handling, PNG/maskable raster icons
(currently an SVG icon only), and a Lighthouse PWA pass.
## Notes
- `npm audit` reports the known esbuild dev-server advisory via Vite 5. It
affects the local dev server only, not the production build. Vite 8 (which
fixes it) requires a newer Node than this environment has.
- Data and images © Nintendo / Game Freak / The Pokémon Company, served via
PokéAPI. This project is a non-commercial reference tool.

24
index.html Normal file
View File

@ -0,0 +1,24 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1, viewport-fit=cover"
/>
<meta name="theme-color" content="#b3161a" />
<meta
name="description"
content="A responsive, offline-capable Pokédex reference powered by PokéAPI."
/>
<link rel="icon" href="/favicon.svg" type="image/svg+xml" />
<link rel="apple-touch-icon" href="/icon.svg" />
<title>Pokédex</title>
</head>
<body>
<div id="app" class="app">
<noscript>This Pokédex needs JavaScript enabled.</noscript>
</div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

6192
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

27
package.json Normal file
View File

@ -0,0 +1,27 @@
{
"name": "pokedex-pwa",
"version": "0.1.0",
"private": true,
"type": "module",
"description": "A responsive, offline-capable Pokédex reference PWA powered by PokéAPI.",
"scripts": {
"snapshot": "node scripts/build-snapshot.mjs",
"prebuild": "node scripts/build-snapshot.mjs",
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"license": "MIT",
"devDependencies": {
"vite": "^5.4.21",
"vite-plugin-pwa": "^0.21.2"
},
"dependencies": {
"workbox-cacheable-response": "^7.4.1",
"workbox-expiration": "^7.4.1",
"workbox-precaching": "^7.4.1",
"workbox-routing": "^7.4.1",
"workbox-strategies": "^7.4.1",
"workbox-window": "^7.4.1"
}
}

6
public/favicon.svg Normal file
View File

@ -0,0 +1,6 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="Pokédex">
<circle cx="32" cy="32" r="30" fill="#fff"/>
<path d="M2 32a30 30 0 0 1 60 0Z" fill="#b3161a"/>
<rect x="2" y="28" width="60" height="8" fill="#111"/>
<circle cx="32" cy="32" r="11" fill="#fff" stroke="#111" stroke-width="5"/>
</svg>

After

Width:  |  Height:  |  Size: 336 B

7
public/icon.svg Normal file
View File

@ -0,0 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" role="img" aria-label="Pokédex">
<rect width="512" height="512" rx="96" fill="#b3161a"/>
<circle cx="256" cy="256" r="150" fill="#fff"/>
<path d="M106 256a150 150 0 0 1 300 0Z" fill="#111"/>
<rect x="106" y="240" width="300" height="32" fill="#111"/>
<circle cx="256" cy="256" r="54" fill="#fff" stroke="#111" stroke-width="22"/>
</svg>

After

Width:  |  Height:  |  Size: 410 B

221
scripts/build-snapshot.mjs Normal file
View File

@ -0,0 +1,221 @@
#!/usr/bin/env node
/**
* Build a compact, offline-first data snapshot from PokéAPI.
*
* The app needs three things to work fully offline before any lazy detail
* fetch: the species list (id, name, generation, types), the regional
* Pokédex lists (which species, in what order, with regional numbers), and
* the version groups (games) with their associated Pokédexes.
*
* Everything richer than that stats, abilities, flavor text, evolution
* is fetched on demand by the app and cached by the service worker.
*
* Output: src/data/snapshot.json (imported by src/data/snapshot.js)
* Usage: node scripts/build-snapshot.mjs [--force]
*/
import { writeFile, mkdir, stat } from 'node:fs/promises';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const OUT = resolve(__dirname, '../src/data/snapshot.json');
const BASE = 'https://pokeapi.co/api/v2';
const MAX_AGE_DAYS = 30;
const FORCE = process.argv.includes('--force');
const CONCURRENCY = 8;
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const idFromUrl = (u) => Number(u.replace(/\/$/, '').split('/').pop());
const pretty = (k) =>
k.replace(/-/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
const _cache = new Map();
async function api(path) {
if (_cache.has(path)) return _cache.get(path);
const url = path.startsWith('http') ? path : `${BASE}/${path}`;
let lastErr;
for (let attempt = 0; attempt < 4; attempt++) {
try {
const res = await fetch(url);
if (res.ok) {
const json = await res.json();
_cache.set(path, json);
return json;
}
if (res.status === 404) throw new Error(`404 ${url}`);
lastErr = new Error(`${res.status} ${url}`);
} catch (err) {
lastErr = err;
}
await sleep(400 * (attempt + 1));
}
throw lastErr;
}
async function mapLimit(items, limit, fn) {
const out = new Array(items.length);
let i = 0;
const workers = Array.from({ length: Math.min(limit, items.length) }, async () => {
while (i < items.length) {
const idx = i++;
out[idx] = await fn(items[idx], idx);
}
});
await Promise.all(workers);
return out;
}
async function isFresh() {
try {
const s = await stat(OUT);
return (Date.now() - s.mtimeMs) / 86_400_000 < MAX_AGE_DAYS;
} catch {
return false;
}
}
async function main() {
if (!FORCE && (await isFresh())) {
console.log(
`snapshot.json is fresh (< ${MAX_AGE_DAYS} days old). Use --force to rebuild.`,
);
return;
}
console.log('Building snapshot from PokéAPI …');
const t0 = Date.now();
// ---- Types: build pokemonName -> [type, ...] -----------------------------
const typeIndex = await api('type?limit=100');
const usableTypes = typeIndex.results
.map((r) => r.name)
.filter((n) => !['unknown', 'shadow', 'stellar'].includes(n));
const typesByPokemon = new Map();
await mapLimit(usableTypes, CONCURRENCY, async (t) => {
const data = await api(`type/${t}`);
for (const { slot, pokemon } of data.pokemon) {
const arr = typesByPokemon.get(pokemon.name) || [];
arr.push({ slot, type: t });
typesByPokemon.set(pokemon.name, arr);
}
});
const typesFor = (name) =>
(typesByPokemon.get(name) || [])
.sort((a, b) => a.slot - b.slot)
.map((x) => x.type);
const typesForSpecies = (name) => {
const direct = typesFor(name);
if (direct.length) return direct;
// Fall back to a default form variant, e.g. "deoxys" -> "deoxys-normal".
for (const key of typesByPokemon.keys()) {
if (key === name || key.startsWith(`${name}-`)) {
const t = typesFor(key);
if (t.length) return t;
}
}
return [];
};
// ---- Generations: species -> { name, generation } ----------------------
const genIndex = await api('generation?limit=50');
const generations = [];
const speciesMeta = new Map();
await mapLimit(genIndex.results, CONCURRENCY, async (g) => {
const data = await api(`generation/${g.name}`);
generations.push({
id: data.id,
name: pretty(data.main_region?.name || g.name),
region: data.main_region?.name || null,
});
for (const sp of data.pokemon_species) {
const id = idFromUrl(sp.url);
speciesMeta.set(id, { id, name: sp.name, generation: data.id });
}
});
generations.sort((a, b) => a.id - b.id);
const speciesIds = [...speciesMeta.keys()].sort((a, b) => a - b);
const species = speciesIds.map((id) => {
const m = speciesMeta.get(id);
return {
id,
name: m.name,
generation: m.generation,
types: typesForSpecies(m.name),
};
});
// ---- Pokédexes --------------------------------------------------------
const dexIndex = await api('pokedex?limit=100');
const pokedexes = [];
await mapLimit(dexIndex.results, CONCURRENCY, async (d) => {
const data = await api(`pokedex/${d.name}`);
pokedexes.push({
key: data.name,
name: pretty(data.name),
region: data.region?.name || null,
isMainSeries: data.is_main_series,
versionGroups: data.version_groups.map((v) => v.name),
entries: data.pokemon_entries
.map((e) => [idFromUrl(e.pokemon_species.url), e.entry_number])
.sort((a, b) => a[1] - b[1]),
});
});
pokedexes.sort((a, b) => a.key.localeCompare(b.key));
// ---- Version groups (the "games") -----------------------------------
const vgIndex = await api('version-group?limit=100');
const versionGroups = [];
await mapLimit(vgIndex.results, CONCURRENCY, async (v) => {
const data = await api(`version-group/${v.name}`);
versionGroups.push({
key: data.name,
name: pretty(data.name),
generation: idFromUrl(data.generation.url),
versions: data.versions.map((x) => x.name),
pokedexKeys: data.pokedexes.map((x) => x.name),
});
});
versionGroups.sort(
(a, b) => a.generation - b.generation || a.key.localeCompare(b.key),
);
// ---- Write ----------------------------------------------------------
const snapshot = {
meta: {
generatedAt: new Date().toISOString(),
source: BASE,
counts: {
species: species.length,
pokedexes: pokedexes.length,
versionGroups: versionGroups.length,
},
},
types: usableTypes,
generations,
species,
pokedexes,
versionGroups,
};
await mkdir(dirname(OUT), { recursive: true });
await writeFile(OUT, JSON.stringify(snapshot));
const kb = (JSON.stringify(snapshot).length / 1024).toFixed(0);
console.log(
`Wrote ${OUT}${species.length} species, ${pokedexes.length} pokédexes, ` +
`${versionGroups.length} version groups (${kb} KB) in ${(
(Date.now() - t0) /
1000
).toFixed(1)}s`,
);
}
main().catch((err) => {
console.error('\nSnapshot build failed:', err.message);
console.error(
'If you are offline, commit a previously generated src/data/snapshot.json ' +
'or retry when you have a connection.',
);
process.exit(1);
});

47
src/components/Card.js Normal file
View File

@ -0,0 +1,47 @@
import { el } from '../lib/dom.js';
import { Sprite } from './Sprite.js';
import { TypeChip } from './TypeChip.js';
import { entry, toggle } from '../store/selection.js';
/**
* One Pokémon in the dex grid. The card is a link to the detail route; the
* caught toggle is a real button layered on top and updates itself in place
* so toggling never repaints the whole grid.
*/
export function Card(species, number, { spriteStyle = 'default' } = {}) {
const state = entry(species.id);
const caughtBtn = el('button', {
class: 'card__caught',
type: 'button',
title: 'Toggle caught',
'aria-pressed': String(state.caught),
onclick: (event) => {
event.preventDefault();
event.stopPropagation();
toggle(species.id, 'caught');
const next = entry(species.id);
caughtBtn.setAttribute('aria-pressed', String(next.caught));
card.classList.toggle('is-caught', next.caught);
},
});
const card = el(
'a',
{
class: `card${state.caught ? ' is-caught' : ''}`,
href: `#/pokemon/${species.id}`,
},
el('span', { class: 'card__num' }, `#${String(number ?? species.id).padStart(4, '0')}`),
Sprite(species.id, { style: spriteStyle, alt: species.name, size: 96 }),
el('span', { class: 'card__name' }, species.name.replace(/-/g, ' ')),
el(
'span',
{ class: 'card__types' },
...(species.types || []).map(TypeChip),
),
caughtBtn,
);
return card;
}

42
src/components/Nav.js Normal file
View File

@ -0,0 +1,42 @@
import { el } from '../lib/dom.js';
const ITEMS = [
{ href: '#/', label: 'Dex', icon: '▦', match: (h) => h === '#/' || h === '' || h === '#' },
{ href: '#/games', label: 'Games', icon: '◉', match: (h) => h.startsWith('#/games') },
{ href: '#/search', label: 'Search', icon: '⌕', match: (h) => h.startsWith('#/search') },
{ href: '#/settings', label: 'Settings', icon: '⚙', match: (h) => h.startsWith('#/settings') },
];
/**
* One nav element, styled by CSS into a bottom bar on phones and a sidebar
* on wide screens. The active item is derived from the hash.
*/
export function Nav() {
const links = ITEMS.map((item) =>
el(
'a',
{ class: 'nav__link', href: item.href },
el('span', { class: 'nav__icon', 'aria-hidden': 'true' }, item.icon),
el('span', { class: 'nav__label' }, item.label),
),
);
const nav = el(
'nav',
{ class: 'nav', 'aria-label': 'Primary' },
el('span', { class: 'nav__brand' }, 'Pokédex'),
...links,
);
function sync() {
const hash = location.hash || '#/';
ITEMS.forEach((item, i) => {
links[i].classList.toggle('is-active', item.match(hash));
links[i].setAttribute('aria-current', item.match(hash) ? 'page' : 'false');
});
}
window.addEventListener('hashchange', sync);
sync();
return nav;
}

View File

@ -0,0 +1,38 @@
import { el, clear } from '../lib/dom.js';
/**
* Sticky dex header: title, game context, seen/caught counts, progress bar.
* Returns { node, update } so the counts can refresh without a full
* re-render when the unified selection changes.
*/
export function ProgressHeader({ title, subtitle }) {
const heading = el('h1', {}, title);
const sub = el('p', { class: 'progress-header__sub' }, subtitle);
const counts = el('p', { class: 'progress-header__counts' });
const bar = el('div', { class: 'progress-header__bar' });
const track = el('div', { class: 'progress-header__track' }, bar);
const node = el(
'header',
{ class: 'progress-header' },
el('div', { class: 'progress-header__titles' }, heading, sub, counts),
track,
);
function update({ seen, caught, total }) {
counts.replaceChildren(
`Seen ${seen} / ${total}`,
el('span', { class: 'progress-header__dot' }, '·'),
`Caught ${caught} / ${total}`,
);
const pct = total ? (caught / total) * 100 : 0;
bar.style.width = `${pct}%`;
}
function setTitles(t, s) {
clear(heading).append(t);
clear(sub).append(s);
}
return { node, update, setTitles };
}

36
src/components/Sprite.js Normal file
View File

@ -0,0 +1,36 @@
import { el } from '../lib/dom.js';
const SPRITES =
'https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/pokemon';
export function spriteUrl(id, style = 'default', shiny = false) {
const s = shiny ? '/shiny' : '';
if (style === 'official') {
return `${SPRITES}/other/official-artwork${shiny ? '/shiny' : ''}/${id}.png`;
}
if (style === 'home') {
return `${SPRITES}/other/home${shiny ? '/shiny' : ''}/${id}.png`;
}
return `${SPRITES}${s}/${id}.png`;
}
export function Sprite(id, { style = 'default', shiny = false, alt = '', size = 96 } = {}) {
const img = el('img', {
class: 'sprite',
loading: 'lazy',
decoding: 'async',
width: size,
height: size,
alt,
src: spriteUrl(id, style, shiny),
});
// Not every form has official/home art — fall back to the pixel sprite.
img.addEventListener(
'error',
() => {
img.src = spriteUrl(id, 'default', false);
},
{ once: true },
);
return img;
}

30
src/components/StatBar.js Normal file
View File

@ -0,0 +1,30 @@
import { el } from '../lib/dom.js';
const LABELS = {
hp: 'HP',
attack: 'Atk',
defense: 'Def',
'special-attack': 'SpA',
'special-defense': 'SpD',
speed: 'Spe',
};
// 255 is the max any base stat reaches (Blissey HP).
export function StatBar(name, value) {
const pct = Math.min(100, (value / 255) * 100);
return el(
'div',
{ class: 'statbar' },
el('span', { class: 'statbar__label' }, LABELS[name] || name),
el('span', { class: 'statbar__value' }, String(value)),
el(
'div',
{ class: 'statbar__track' },
el('div', {
class: 'statbar__fill',
dataset: { stat: name },
style: `width:${pct}%`,
}),
),
);
}

View File

@ -0,0 +1,5 @@
import { el } from '../lib/dom.js';
export function TypeChip(type) {
return el('span', { class: 'type-chip', dataset: { type }, title: type }, type);
}

26
src/data/api.js Normal file
View File

@ -0,0 +1,26 @@
/**
* Lazy detail fetches. Only detail pages call these; the list, search and
* game switching all run off the bundled snapshot.
*
* In-flight requests are de-duplicated in memory. Durable caching (across
* reloads and offline) is the service worker's job see src/sw.js, which
* runs stale-while-revalidate over this origin.
*/
const BASE = 'https://pokeapi.co/api/v2';
const inflight = new Map();
export function getJSON(path) {
if (inflight.has(path)) return inflight.get(path);
const promise = fetch(`${BASE}/${path}`)
.then((res) => {
if (!res.ok) throw new Error(`${path}${res.status}`);
return res.json();
})
.finally(() => inflight.delete(path));
inflight.set(path, promise);
return promise;
}
export const getPokemon = (idOrName) => getJSON(`pokemon/${idOrName}`);
export const getSpecies = (idOrName) => getJSON(`pokemon-species/${idOrName}`);
export const getEvolutionChain = (id) => getJSON(`evolution-chain/${id}`);

View File

@ -0,0 +1,57 @@
/**
* Resolves "which game am I playing" (a version group) into "which list of
* Pokémon do I show" (a regional Pokédex).
*/
export function prettify(key) {
return key
.replace(/-/g, ' ')
.replace(/\b\w/g, (c) => c.toUpperCase());
}
/** Regional dexes belonging to a version group, in the API's order. */
export function dexesForVersionGroup(snap, versionGroupKey) {
const vg = snap.versionGroupByKey.get(versionGroupKey);
const keys = vg && vg.pokedexKeys.length ? vg.pokedexKeys : ['national'];
return keys
.map((k) => snap.pokedexByKey.get(k))
.filter(Boolean);
}
/**
* The dex to display right now: the one named in settings if it belongs to
* the current game, otherwise that game's first dex, otherwise National.
*/
export function resolvePokedex(snap, settingsState) {
const dexes = dexesForVersionGroup(snap, settingsState.versionGroup);
return (
dexes.find((d) => d.key === settingsState.pokedex) ||
dexes[0] ||
snap.pokedexByKey.get('national')
);
}
/** Expand a dex's entries into { species, number } rows, dropping unknowns. */
export function dexRows(snap, dex) {
return dex.entries
.map(([speciesId, number]) => ({
species: snap.speciesById.get(speciesId),
number,
}))
.filter((row) => row.species);
}
/** Version groups grouped by generation, for the game picker. */
export function versionGroupsByGeneration(snap) {
const groups = new Map();
for (const vg of snap.versionGroups) {
if (!groups.has(vg.generation)) groups.set(vg.generation, []);
groups.get(vg.generation).push(vg);
}
return [...groups.entries()]
.sort((a, b) => a[0] - b[0])
.map(([genId, vgs]) => ({
generation: snap.generationById.get(genId) || { id: genId, name: `Gen ${genId}` },
versionGroups: vgs,
}));
}

26
src/data/snapshot.js Normal file
View File

@ -0,0 +1,26 @@
/**
* Loads the build-time PokéAPI snapshot and builds lookup indexes.
*
* The JSON is referenced as an asset URL (not inlined) so Vite emits it as
* a hashed file that the service worker precaches the species list, dex
* lists and game list are therefore available on the very first offline
* launch, before any detail fetch happens.
*/
const snapshotUrl = new URL('./snapshot.json', import.meta.url);
let cached;
export async function loadSnapshot() {
if (cached) return cached;
const res = await fetch(snapshotUrl);
if (!res.ok) throw new Error(`Failed to load data snapshot (${res.status})`);
const data = await res.json();
data.speciesById = new Map(data.species.map((s) => [s.id, s]));
data.pokedexByKey = new Map(data.pokedexes.map((d) => [d.key, d]));
data.versionGroupByKey = new Map(data.versionGroups.map((v) => [v.key, v]));
data.generationById = new Map(data.generations.map((g) => [g.id, g]));
cached = data;
return cached;
}

45
src/lib/dom.js Normal file
View File

@ -0,0 +1,45 @@
/**
* Tiny DOM helpers enough structure to avoid a framework without the
* ergonomics falling apart.
*
* el('div', { class: 'card', onclick: fn }, 'text', childNode)
*/
export function el(tag, props = {}, ...children) {
const node = document.createElement(tag);
for (const [key, value] of Object.entries(props || {})) {
if (value == null || value === false) continue;
if (key === 'class') node.className = value;
else if (key === 'dataset') Object.assign(node.dataset, value);
else if (key === 'html') node.innerHTML = value;
else if (key.startsWith('on') && typeof value === 'function') {
node.addEventListener(key.slice(2).toLowerCase(), value);
} else if (key in node) {
try {
node[key] = value;
} catch {
node.setAttribute(key, value);
}
} else {
node.setAttribute(key, value);
}
}
append(node, children);
return node;
}
export function append(node, children) {
for (const child of children.flat(Infinity)) {
if (child == null || child === false) continue;
node.append(child.nodeType ? child : document.createTextNode(String(child)));
}
}
export function clear(node) {
node.replaceChildren();
return node;
}
/** Run a cleanup fn when the view node is torn down by the router. */
export function onTeardown(viewNode, fn) {
viewNode.addEventListener('view:teardown', fn, { once: true });
}

54
src/main.js Normal file
View File

@ -0,0 +1,54 @@
import './styles/tokens.css';
import './styles/layout.css';
import { registerSW } from 'virtual:pwa-register';
import { el } from './lib/dom.js';
import { initRouter } from './router.js';
import { Nav } from './components/Nav.js';
import { settings, applyTheme } from './store/settings.js';
applyTheme();
settings.subscribe(() => applyTheme());
window
.matchMedia('(prefers-color-scheme: dark)')
.addEventListener('change', () => applyTheme());
const app = document.getElementById('app');
app.replaceChildren();
const viewHost = el('main', { id: 'view', class: 'view-host' });
app.append(Nav(), viewHost);
initRouter(viewHost);
// --- Service worker: prompt to refresh rather than silently swapping ------
const updateSW = registerSW({
onNeedRefresh() {
showToast('A new version is available.', 'Reload', () => updateSW(true));
},
onOfflineReady() {
showToast('Ready to use offline.', 'Dismiss');
},
});
function showToast(message, actionLabel, onAction) {
const toast = el(
'div',
{ class: 'toast', role: 'status' },
el('span', {}, message),
el(
'button',
{
class: 'toast__action',
type: 'button',
onclick: () => {
toast.remove();
onAction?.();
},
},
actionLabel,
),
);
document.body.append(toast);
if (!onAction) setTimeout(() => toast.remove(), 5000);
}

63
src/router.js Normal file
View File

@ -0,0 +1,63 @@
import { el } from './lib/dom.js';
import { DexGrid } from './views/DexGrid.js';
import { PokemonDetail } from './views/PokemonDetail.js';
import { GamePicker } from './views/GamePicker.js';
import { SearchView } from './views/SearchView.js';
import { SettingsView } from './views/SettingsView.js';
const routes = [
{ pattern: /^#?\/?$/, view: () => DexGrid() },
{ pattern: /^#\/pokemon\/(\d+)$/, view: (m) => PokemonDetail(Number(m[1])) },
{ pattern: /^#\/games$/, view: () => GamePicker() },
{ pattern: /^#\/search$/, view: () => SearchView() },
{ pattern: /^#\/settings$/, view: () => SettingsView() },
];
function resolve(hash) {
for (const route of routes) {
const match = hash.match(route.pattern);
if (match) return { route, match };
}
return { route: routes[0], match: [] };
}
export function initRouter(host) {
let current = null;
async function render() {
const hash = location.hash || '#/';
const { route, match } = resolve(hash);
if (current) {
current.dispatchEvent(new CustomEvent('view:teardown'));
current.remove();
current = null;
}
const pending = el('div', { class: 'view view--loading' }, 'Loading…');
host.replaceChildren(pending);
try {
const node = await route.view(match);
// Guard against a fast second navigation while awaiting.
if ((location.hash || '#/') !== hash) return;
current = node;
host.replaceChildren(node);
} catch (err) {
console.error(err);
host.replaceChildren(
el(
'div',
{ class: 'view view--error' },
el('h1', {}, 'Something went wrong'),
el('p', {}, err.message || String(err)),
el('a', { href: '#/', class: 'button' }, 'Back to the dex'),
),
);
}
window.scrollTo(0, 0);
}
window.addEventListener('hashchange', render);
render();
}

55
src/store/createStore.js Normal file
View File

@ -0,0 +1,55 @@
/**
* Minimal localStorage-backed reactive store.
*
* - `get()` returns the current state object (treat as read-only)
* - `set(patch)` shallow-merges an object, or accepts `(state) => newState`
* - `subscribe(fn)` registers a listener, returns an unsubscribe function
*
* State is persisted to localStorage synchronously on every `set`, so a
* reload restores exactly what the user last saw. Reads are synchronous,
* which is why grid badges can be painted without awaiting anything.
*/
export function createStore(key, initial) {
const subscribers = new Set();
function load() {
try {
const raw = localStorage.getItem(key);
return raw ? { ...initial, ...JSON.parse(raw) } : { ...initial };
} catch {
return { ...initial };
}
}
let state = load();
function save() {
try {
localStorage.setItem(key, JSON.stringify(state));
} catch {
/* private mode / quota — keep working in-memory */
}
}
return {
get: () => state,
set(patch) {
state =
typeof patch === 'function'
? patch(state)
: { ...state, ...patch };
save();
for (const fn of subscribers) fn(state);
},
subscribe(fn) {
subscribers.add(fn);
return () => subscribers.delete(fn);
},
/** Replace the whole state (used by data import). */
replace(next) {
state = { ...initial, ...next };
save();
for (const fn of subscribers) fn(state);
},
};
}

61
src/store/selection.js Normal file
View File

@ -0,0 +1,61 @@
import { createStore } from './createStore.js';
/**
* The "unified Pokémon selection".
*
* One record per Pokémon, keyed by NATIONAL dex id. That key is what makes
* it unified: mark Pikachu once and every game's view Kanto, Paldea,
* National, search results reflects it, because each view computes its
* own progress by intersecting its species list with this single map.
*/
export const selection = createStore('pdx.selection', {
version: 2,
pokemon: {},
});
const BLANK = { seen: false, caught: false, favorite: false, note: '' };
export function entry(id) {
return selection.get().pokemon[id] || BLANK;
}
export function toggle(id, field) {
selection.set((s) => {
const current = s.pokemon[id] || BLANK;
const next = {
...current,
[field]: !current[field],
updatedAt: new Date().toISOString(),
};
// Catching something implies you've seen it.
if (field === 'caught' && next.caught) next.seen = true;
return { ...s, pokemon: { ...s.pokemon, [id]: next } };
});
}
export function setNote(id, note) {
selection.set((s) => {
const current = s.pokemon[id] || BLANK;
return {
...s,
pokemon: {
...s.pokemon,
[id]: { ...current, note, updatedAt: new Date().toISOString() },
},
};
});
}
/** Aggregate seen/caught counts over an arbitrary list of national ids. */
export function stats(speciesIds) {
const p = selection.get().pokemon;
let seen = 0;
let caught = 0;
for (const id of speciesIds) {
const e = p[id];
if (!e) continue;
if (e.seen || e.caught) seen++;
if (e.caught) caught++;
}
return { seen, caught, total: speciesIds.length };
}

35
src/store/settings.js Normal file
View File

@ -0,0 +1,35 @@
import { createStore } from './createStore.js';
/**
* User preferences. Small, synchronous, read on boot.
*
* - versionGroup: the selected "game" (PokéAPI version-group key)
* - pokedex: the active regional dex within that game (null = first one)
* - theme: 'system' | 'light' | 'dark'
* - spriteStyle: 'default' (pixel) | 'official' | 'home'
* - showShiny: default the detail view to shiny sprites
* - locale: reserved for localized names (PokéAPI supports many)
*/
export const settings = createStore('pdx.settings', {
versionGroup: 'scarlet-violet',
pokedex: null,
theme: 'system',
spriteStyle: 'default',
showShiny: false,
locale: 'en',
});
export function applyTheme(theme = settings.get().theme) {
const root = document.documentElement;
if (theme === 'system') root.removeAttribute('data-theme');
else root.setAttribute('data-theme', theme);
const meta = document.querySelector('meta[name="theme-color"]');
if (meta) {
const dark =
theme === 'dark' ||
(theme === 'system' &&
window.matchMedia('(prefers-color-scheme: dark)').matches);
meta.setAttribute('content', dark ? '#0b0b0c' : '#b3161a');
}
}

640
src/styles/layout.css Normal file
View File

@ -0,0 +1,640 @@
/* ---------- App shell ---------------------------------------------------- */
.app {
min-height: 100dvh;
}
.view-host {
max-width: var(--maxw);
margin: 0 auto;
padding: var(--gap);
padding-bottom: calc(var(--nav-size) + env(safe-area-inset-bottom) + var(--gap));
}
/* ---------- Nav: bottom bar on phones, sidebar on wide screens ---------- */
.nav {
position: fixed;
inset: auto 0 0 0;
height: calc(var(--nav-size) + env(safe-area-inset-bottom));
padding-bottom: env(safe-area-inset-bottom);
display: flex;
align-items: stretch;
background: var(--surface);
border-top: 1px solid var(--border);
z-index: 20;
}
.nav__brand {
display: none;
}
.nav__link {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2px;
text-decoration: none;
color: var(--text-dim);
font-size: 0.72rem;
border: none;
background: none;
}
.nav__link.is-active {
color: var(--accent);
}
.nav__icon {
font-size: 1.25rem;
line-height: 1;
}
@media (min-width: 1024px) {
.app {
display: grid;
grid-template-columns: 220px 1fr;
}
.nav {
position: sticky;
top: 0;
inset: 0 auto 0 0;
height: 100dvh;
flex-direction: column;
align-items: stretch;
padding: var(--gap) 12px;
gap: 4px;
border-top: none;
border-right: 1px solid var(--border);
}
.nav__brand {
display: block;
font-weight: 800;
font-size: 1.15rem;
color: var(--accent);
padding: 8px 12px 16px;
}
.nav__link {
flex: 0 0 auto;
flex-direction: row;
justify-content: flex-start;
gap: 12px;
padding: 10px 12px;
border-radius: var(--radius-sm);
font-size: 0.95rem;
}
.nav__link.is-active {
background: var(--surface-2);
}
.view-host {
padding: calc(var(--gap) * 1.5);
padding-bottom: calc(var(--gap) * 2);
}
}
/* ---------- Generic bits ---------------------------------------------- */
.view--loading,
.view--error {
padding: 48px 8px;
text-align: center;
color: var(--text-dim);
}
.view__header h1 {
margin: 0 0 4px;
font-size: 1.4rem;
}
.view__header p {
margin: 0 0 16px;
color: var(--text-dim);
}
.link {
color: var(--accent);
text-decoration: none;
font-weight: 600;
}
.link:hover {
text-decoration: underline;
}
.button {
appearance: none;
border: 1px solid var(--border);
background: var(--surface);
color: var(--text);
padding: 9px 14px;
border-radius: var(--radius-sm);
font: inherit;
font-weight: 600;
cursor: pointer;
}
.button:hover {
border-color: var(--accent);
}
.button--ghost {
background: transparent;
}
.button--danger {
color: var(--danger);
border-color: color-mix(in srgb, var(--danger) 40%, var(--border));
}
.chips {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.chip {
appearance: none;
border: 1px solid var(--border);
background: var(--surface);
color: var(--text-dim);
padding: 6px 12px;
border-radius: 999px;
font: inherit;
font-size: 0.85rem;
cursor: pointer;
}
.chip.is-active {
background: var(--accent);
color: var(--accent-text);
border-color: var(--accent);
}
/* ---------- Type chips ---------------------------------------------- */
.type-chip {
display: inline-block;
padding: 2px 8px;
border-radius: 999px;
font-size: 0.68rem;
font-weight: 700;
text-transform: capitalize;
color: #fff;
background: var(--type-normal);
letter-spacing: 0.02em;
}
.type-chip[data-type="normal"] { background: var(--type-normal); }
.type-chip[data-type="fire"] { background: var(--type-fire); }
.type-chip[data-type="water"] { background: var(--type-water); }
.type-chip[data-type="electric"] { background: var(--type-electric); color: #3a3a1a; }
.type-chip[data-type="grass"] { background: var(--type-grass); }
.type-chip[data-type="ice"] { background: var(--type-ice); color: #1c3b38; }
.type-chip[data-type="fighting"] { background: var(--type-fighting); }
.type-chip[data-type="poison"] { background: var(--type-poison); }
.type-chip[data-type="ground"] { background: var(--type-ground); }
.type-chip[data-type="flying"] { background: var(--type-flying); }
.type-chip[data-type="psychic"] { background: var(--type-psychic); }
.type-chip[data-type="bug"] { background: var(--type-bug); }
.type-chip[data-type="rock"] { background: var(--type-rock); color: #2e2a1c; }
.type-chip[data-type="ghost"] { background: var(--type-ghost); }
.type-chip[data-type="dragon"] { background: var(--type-dragon); }
.type-chip[data-type="dark"] { background: var(--type-dark); }
.type-chip[data-type="steel"] { background: var(--type-steel); }
.type-chip[data-type="fairy"] { background: var(--type-fairy); color: #3d1f3b; }
/* ---------- Dex grid ---------------------------------------------- */
.progress-header {
position: sticky;
top: 0;
z-index: 10;
background: var(--bg);
padding: 8px 0 12px;
}
.progress-header__titles h1 {
margin: 0;
font-size: 1.5rem;
text-transform: capitalize;
}
.progress-header__sub,
.progress-header__counts {
margin: 2px 0 0;
color: var(--text-dim);
font-size: 0.9rem;
}
.progress-header__dot {
margin: 0 6px;
}
.progress-header__track {
margin-top: 10px;
height: 6px;
border-radius: 999px;
background: var(--surface-2);
overflow: hidden;
}
.progress-header__bar {
height: 100%;
width: 0;
background: var(--good);
transition: width 0.3s ease;
}
.dexgrid__controls {
display: flex;
flex-direction: column;
gap: 10px;
margin: 8px 0 16px;
}
.dexgrid__search,
.search__input {
width: 100%;
padding: 10px 14px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--surface);
color: var(--text);
font: inherit;
}
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(9rem, 1fr));
gap: var(--gap);
}
@media (min-width: 640px) {
.grid {
grid-template-columns: repeat(auto-fill, minmax(10.5rem, 1fr));
}
}
.grid__empty {
grid-column: 1 / -1;
text-align: center;
color: var(--text-dim);
padding: 32px 0;
}
.card {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
padding: 14px 10px 12px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
text-decoration: none;
color: inherit;
box-shadow: var(--shadow);
content-visibility: auto;
contain-intrinsic-size: 210px;
transition: transform 0.12s ease, border-color 0.12s ease;
}
.card:hover {
transform: translateY(-2px);
border-color: var(--accent);
}
.card.is-caught {
border-color: color-mix(in srgb, var(--good) 55%, var(--border));
}
.card.is-caught::after {
content: "";
position: absolute;
inset: 0;
border-radius: var(--radius);
background: color-mix(in srgb, var(--good) 8%, transparent);
pointer-events: none;
}
.card__num {
font-size: 0.72rem;
color: var(--text-dim);
font-variant-numeric: tabular-nums;
}
.card .sprite {
width: 96px;
height: 96px;
object-fit: contain;
image-rendering: pixelated;
}
.card__name {
font-weight: 700;
font-size: 0.9rem;
text-transform: capitalize;
text-align: center;
}
.card__types {
display: flex;
gap: 4px;
flex-wrap: wrap;
justify-content: center;
}
.card__caught {
position: absolute;
top: 8px;
right: 8px;
width: 26px;
height: 26px;
border-radius: 50%;
border: 1px solid var(--border);
background: var(--surface);
color: var(--text-dim);
font-size: 0.7rem;
cursor: pointer;
display: grid;
place-items: center;
}
.card__caught[aria-pressed="true"] {
background: var(--good);
border-color: var(--good);
color: #fff;
}
.card__caught[aria-pressed="true"]::before {
content: "✓";
font-size: 0.85rem;
}
.card__caught[aria-pressed="false"]::before {
content: "";
}
/* ---------- Game picker ------------------------------------------- */
.gamepicker__subdex {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
margin-bottom: 18px;
}
.gamepicker__subdex-label {
color: var(--text-dim);
font-size: 0.85rem;
}
.gamepicker__gen {
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-dim);
margin: 22px 0 10px;
}
.gamepicker__grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(11rem, 1fr));
gap: 12px;
}
.gamecard {
display: flex;
flex-direction: column;
gap: 3px;
text-align: left;
padding: 12px 14px;
border: 1px solid var(--border);
border-radius: var(--radius);
background: var(--surface);
color: inherit;
cursor: pointer;
font: inherit;
}
.gamecard:hover {
border-color: var(--accent);
}
.gamecard.is-active {
border-color: var(--accent);
box-shadow: inset 0 0 0 1px var(--accent);
}
.gamecard__name {
font-weight: 700;
}
.gamecard__meta {
font-size: 0.78rem;
color: var(--text-dim);
text-transform: capitalize;
}
/* ---------- Detail ---------------------------------------------- */
.detail {
max-width: 640px;
margin: 0 auto;
}
.detail__nav {
display: flex;
justify-content: space-between;
gap: 8px;
margin-bottom: 8px;
font-size: 0.9rem;
text-transform: capitalize;
}
.detail__num {
margin: 0;
color: var(--text-dim);
font-size: 0.85rem;
text-transform: capitalize;
}
.detail__name {
margin: 2px 0 8px;
font-size: 2rem;
text-transform: capitalize;
}
.detail__types {
display: flex;
gap: 6px;
}
.detail__media {
display: flex;
flex-direction: column;
align-items: center;
gap: 10px;
padding: 16px 0;
}
.detail__media .sprite {
width: min(240px, 70vw);
height: min(240px, 70vw);
object-fit: contain;
}
.detail__track {
display: flex;
gap: 8px;
justify-content: center;
flex-wrap: wrap;
margin-bottom: 16px;
}
.toggle {
appearance: none;
border: 1px solid var(--border);
background: var(--surface);
color: var(--text-dim);
padding: 8px 16px;
border-radius: 999px;
font: inherit;
font-weight: 600;
cursor: pointer;
}
.toggle.is-on {
background: var(--good);
border-color: var(--good);
color: #fff;
}
.detail__flavor {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 14px 16px;
font-style: italic;
color: var(--text-dim);
}
.detail__stats {
margin-top: 20px;
}
.detail__stats h2 {
font-size: 1rem;
}
.statbar {
display: grid;
grid-template-columns: 44px 44px 1fr;
align-items: center;
gap: 10px;
margin: 6px 0;
font-size: 0.85rem;
}
.statbar__label {
color: var(--text-dim);
}
.statbar__value {
font-variant-numeric: tabular-nums;
text-align: right;
}
.statbar__track {
height: 8px;
background: var(--surface-2);
border-radius: 999px;
overflow: hidden;
}
.statbar__fill {
height: 100%;
background: var(--accent);
border-radius: 999px;
}
.statbar--total {
font-weight: 700;
border-top: 1px solid var(--border);
padding-top: 8px;
margin-top: 8px;
}
.detail__facts {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px 16px;
margin-top: 20px;
}
.fact dt {
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--text-dim);
}
.fact dd {
margin: 2px 0 0;
text-transform: capitalize;
}
/* ---------- Search --------------------------------------------- */
.search__results {
list-style: none;
margin: 16px 0 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 6px;
}
.search__row {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 12px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--surface);
text-decoration: none;
color: inherit;
}
.search__row:hover {
border-color: var(--accent);
}
.search__row.is-caught {
border-color: color-mix(in srgb, var(--good) 55%, var(--border));
}
.search__row .sprite {
width: 48px;
height: 48px;
image-rendering: pixelated;
}
.search__num {
color: var(--text-dim);
font-size: 0.8rem;
font-variant-numeric: tabular-nums;
}
.search__name {
font-weight: 700;
text-transform: capitalize;
flex: 1;
}
.search__types {
display: flex;
gap: 4px;
}
.search__empty,
.settings__note {
color: var(--text-dim);
}
/* ---------- Settings ------------------------------------------ */
.settings__group,
.settings__actions {
display: flex;
flex-direction: column;
gap: 12px;
margin: 12px 0 24px;
}
.settings__actions {
flex-flow: row wrap;
}
.field {
display: flex;
flex-direction: column;
gap: 4px;
max-width: 320px;
}
.field--check {
flex-direction: row;
align-items: center;
gap: 10px;
max-width: none;
}
.field select {
padding: 9px 12px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--surface);
color: var(--text);
font: inherit;
}
/* ---------- Toast ------------------------------------------- */
.toast {
position: fixed;
left: 50%;
bottom: calc(var(--nav-size) + env(safe-area-inset-bottom) + 12px);
transform: translateX(-50%);
display: flex;
align-items: center;
gap: 14px;
background: var(--text);
color: var(--bg);
padding: 10px 16px;
border-radius: 999px;
box-shadow: var(--shadow);
z-index: 40;
font-size: 0.9rem;
}
.toast__action {
background: none;
border: none;
color: var(--accent);
font: inherit;
font-weight: 700;
cursor: pointer;
}
@media (min-width: 1024px) {
.toast {
bottom: 20px;
left: calc(50% + 110px);
}
}

98
src/styles/tokens.css Normal file
View File

@ -0,0 +1,98 @@
/* Design tokens. Light palette on :root; dark overrides via media query and
an explicit [data-theme="dark"] so the in-app toggle wins both ways. */
:root {
--bg: #f4f4f6;
--surface: #ffffff;
--surface-2: #ececed;
--text: #16161a;
--text-dim: #5b5b66;
--border: #dcdce1;
--accent: #b3161a;
--accent-text: #ffffff;
--good: #2f9e44;
--danger: #c92a2a;
--radius: 14px;
--radius-sm: 9px;
--gap: 16px;
--nav-size: 64px;
--maxw: 1200px;
--shadow: 0 1px 2px rgba(0, 0, 0, 0.06), 0 6px 20px rgba(0, 0, 0, 0.06);
--font: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
/* Pokémon type colors */
--type-normal: #9099a1;
--type-fire: #ff9c54;
--type-water: #4d90d5;
--type-electric: #f3d23b;
--type-grass: #63bc5a;
--type-ice: #74cec0;
--type-fighting: #ce4069;
--type-poison: #ab6ac8;
--type-ground: #d97845;
--type-flying: #8fa8dd;
--type-psychic: #f97176;
--type-bug: #90c12c;
--type-rock: #c7b78b;
--type-ghost: #5269ac;
--type-dragon: #0a6dc4;
--type-dark: #5a5366;
--type-steel: #5a8ea1;
--type-fairy: #ec8fe6;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--bg: #0b0b0c;
--surface: #17171a;
--surface-2: #202024;
--text: #f2f2f4;
--text-dim: #a0a0ab;
--border: #2c2c31;
--accent: #ff5a5f;
--accent-text: #16161a;
--shadow: 0 1px 2px rgba(0, 0, 0, 0.4), 0 8px 24px rgba(0, 0, 0, 0.35);
}
}
:root[data-theme="dark"] {
--bg: #0b0b0c;
--surface: #17171a;
--surface-2: #202024;
--text: #f2f2f4;
--text-dim: #a0a0ab;
--border: #2c2c31;
--accent: #ff5a5f;
--accent-text: #16161a;
--shadow: 0 1px 2px rgba(0, 0, 0, 0.4), 0 8px 24px rgba(0, 0, 0, 0.35);
}
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
padding: 0;
}
body {
background: var(--bg);
color: var(--text);
font-family: var(--font);
line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
a {
color: inherit;
}
@media (prefers-reduced-motion: reduce) {
* {
animation-duration: 0.001ms !important;
transition-duration: 0.001ms !important;
}
}

53
src/sw.js Normal file
View File

@ -0,0 +1,53 @@
/**
* Service worker (Workbox, injectManifest mode).
*
* - App shell + the PokéAPI snapshot are precached (offline launch, instant
* list / search / game switching).
* - Lazy detail JSON from pokeapi.co: stale-while-revalidate, 30-day TTL.
* - Sprites from the PokeAPI sprites repo: cache-first, capped LRU.
*/
import { precacheAndRoute } from 'workbox-precaching';
import { registerRoute } from 'workbox-routing';
import { StaleWhileRevalidate, CacheFirst } from 'workbox-strategies';
import { ExpirationPlugin } from 'workbox-expiration';
import { CacheableResponsePlugin } from 'workbox-cacheable-response';
precacheAndRoute(self.__WB_MANIFEST || []);
const MONTH = 30 * 24 * 60 * 60;
registerRoute(
({ url }) => url.origin === 'https://pokeapi.co',
new StaleWhileRevalidate({
cacheName: 'pokeapi-json',
plugins: [
new CacheableResponsePlugin({ statuses: [0, 200] }),
new ExpirationPlugin({
maxEntries: 1500,
maxAgeSeconds: MONTH,
purgeOnQuotaError: true,
}),
],
}),
);
registerRoute(
({ url }) =>
url.origin === 'https://raw.githubusercontent.com' &&
url.pathname.includes('/PokeAPI/sprites/'),
new CacheFirst({
cacheName: 'pokeapi-img',
plugins: [
new CacheableResponsePlugin({ statuses: [0, 200] }),
new ExpirationPlugin({
maxEntries: 600,
maxAgeSeconds: MONTH,
purgeOnQuotaError: true,
}),
],
}),
);
self.addEventListener('message', (event) => {
if (event.data && event.data.type === 'SKIP_WAITING') self.skipWaiting();
});

134
src/views/DexGrid.js Normal file
View File

@ -0,0 +1,134 @@
import { el, clear, onTeardown } from '../lib/dom.js';
import { loadSnapshot } from '../data/snapshot.js';
import { resolvePokedex, dexRows, prettify } from '../data/pokedex-resolver.js';
import { settings } from '../store/settings.js';
import { selection, stats } from '../store/selection.js';
import { Card } from '../components/Card.js';
import { ProgressHeader } from '../components/ProgressHeader.js';
const FILTERS = [
{ key: 'all', label: 'All', test: () => true },
{ key: 'caught', label: 'Caught', test: (e) => e.caught },
{ key: 'uncaught', label: 'Not caught', test: (e) => !e.caught },
{ key: 'favorite', label: 'Favorites', test: (e) => e.favorite },
];
export async function DexGrid() {
const view = el('section', { class: 'view dexgrid' });
const snap = await loadSnapshot();
const grid = el('div', { class: 'grid' });
let query = '';
let filterKey = 'all';
const search = el('input', {
class: 'dexgrid__search',
type: 'search',
placeholder: 'Filter by name or number…',
oninput: (e) => {
query = e.target.value.trim().toLowerCase();
paintGrid();
},
});
const filterBar = el(
'div',
{ class: 'chips' },
...FILTERS.map((f) =>
el(
'button',
{
class: `chip${f.key === filterKey ? ' is-active' : ''}`,
type: 'button',
onclick: () => {
filterKey = f.key;
[...filterBar.children].forEach((c, i) =>
c.classList.toggle('is-active', FILTERS[i].key === filterKey),
);
paintGrid();
},
},
f.label,
),
),
);
const header = ProgressHeader({ title: '', subtitle: '' });
view.append(
header.node,
el('div', { class: 'dexgrid__controls' }, search, filterBar),
grid,
);
let rows = [];
let ids = [];
function paintGrid() {
const st = settings.get();
const pokemonState = selection.get().pokemon;
const filterTest = FILTERS.find((f) => f.key === filterKey).test;
const frag = document.createDocumentFragment();
let shown = 0;
for (const { species, number } of rows) {
if (
query &&
!species.name.includes(query) &&
String(number) !== query &&
String(species.id) !== query
) {
continue;
}
const e = pokemonState[species.id] || {
seen: false,
caught: false,
favorite: false,
};
if (!filterTest(e)) continue;
frag.append(Card(species, number, { spriteStyle: st.spriteStyle }));
shown++;
}
clear(grid);
if (shown === 0) {
grid.append(el('p', { class: 'grid__empty' }, 'Nothing matches those filters.'));
} else {
grid.append(frag);
}
}
function rebuild() {
const st = settings.get();
const dex = resolvePokedex(snap, st);
rows = dexRows(snap, dex);
ids = rows.map((r) => r.species.id);
header.setTitles(
prettify(dex.name || dex.key),
el(
'span',
{},
`${prettify(st.versionGroup)} · `,
el('a', { href: '#/games', class: 'link' }, 'change game'),
),
);
header.update(stats(ids));
paintGrid();
}
rebuild();
const offSettings = settings.subscribe(rebuild);
// Selection changes only affect counts + which cards pass the filter.
const offSelection = selection.subscribe(() => {
header.update(stats(ids));
if (filterKey !== 'all') paintGrid();
});
onTeardown(view, () => {
offSettings();
offSelection();
});
return view;
}

109
src/views/GamePicker.js Normal file
View File

@ -0,0 +1,109 @@
import { el, onTeardown } from '../lib/dom.js';
import { loadSnapshot } from '../data/snapshot.js';
import {
versionGroupsByGeneration,
dexesForVersionGroup,
prettify,
} from '../data/pokedex-resolver.js';
import { settings } from '../store/settings.js';
/**
* Two-tier selection: pick a game (version group), then if it has more
* than one regional dex pick which dex is active. Both persist to
* settings, so the dex grid resumes here on next launch.
*/
export async function GamePicker() {
const snap = await loadSnapshot();
const view = el('section', { class: 'view gamepicker' });
const subdexBar = el('div', { class: 'gamepicker__subdex' });
function renderSubdex() {
const st = settings.get();
const dexes = dexesForVersionGroup(snap, st.versionGroup);
subdexBar.replaceChildren();
if (dexes.length <= 1) return;
subdexBar.append(el('span', { class: 'gamepicker__subdex-label' }, 'Dex:'));
for (const dex of dexes) {
const active = (st.pokedex || dexes[0].key) === dex.key;
subdexBar.append(
el(
'button',
{
class: `chip${active ? ' is-active' : ''}`,
type: 'button',
onclick: () => {
settings.set({ pokedex: dex.key });
renderSubdex();
},
},
prettify(dex.name || dex.key),
),
);
}
}
function renderGames() {
const st = settings.get();
list.replaceChildren();
for (const { generation, versionGroups } of versionGroupsByGeneration(snap)) {
list.append(el('h2', { class: 'gamepicker__gen' }, `Gen ${generation.id}${generation.name}`));
const groupWrap = el('div', { class: 'gamepicker__grid' });
for (const vg of versionGroups) {
const active = vg.key === st.versionGroup;
const dexCount = dexesForVersionGroup(snap, vg.key).length;
groupWrap.append(
el(
'button',
{
class: `gamecard${active ? ' is-active' : ''}`,
type: 'button',
onclick: () => {
settings.set({ versionGroup: vg.key, pokedex: null });
renderGames();
renderSubdex();
},
},
el('span', { class: 'gamecard__name' }, prettify(vg.name || vg.key)),
el(
'span',
{ class: 'gamecard__meta' },
vg.versions.map((v) => prettify(v)).join(' / '),
),
el(
'span',
{ class: 'gamecard__meta' },
`${dexCount} ${dexCount === 1 ? 'dex' : 'dexes'}`,
),
),
);
}
list.append(groupWrap);
}
}
const list = el('div', { class: 'gamepicker__list' });
view.append(
el(
'header',
{ class: 'view__header' },
el('h1', {}, 'Choose your game'),
el('p', {}, 'Sets which Pokédex, numbering and flavor text the app shows.'),
),
subdexBar,
list,
);
renderGames();
renderSubdex();
const off = settings.subscribe(() => {
renderGames();
renderSubdex();
});
onTeardown(view, off);
return view;
}

189
src/views/PokemonDetail.js Normal file
View File

@ -0,0 +1,189 @@
import { el, clear, onTeardown } from '../lib/dom.js';
import { loadSnapshot } from '../data/snapshot.js';
import { resolvePokedex, dexRows, prettify } from '../data/pokedex-resolver.js';
import { getPokemon, getSpecies } from '../data/api.js';
import { settings } from '../store/settings.js';
import { entry, toggle } from '../store/selection.js';
import { Sprite } from '../components/Sprite.js';
import { TypeChip } from '../components/TypeChip.js';
import { StatBar } from '../components/StatBar.js';
export async function PokemonDetail(nationalId) {
const view = el('section', { class: 'view detail' });
const snap = await loadSnapshot();
const st = settings.get();
// Prev / next within the currently selected dex.
const dex = resolvePokedex(snap, st);
const rows = dexRows(snap, dex);
const pos = rows.findIndex((r) => r.species.id === nationalId);
const prev = pos > 0 ? rows[pos - 1] : null;
const next = pos >= 0 && pos < rows.length - 1 ? rows[pos + 1] : null;
const regionalNumber = pos >= 0 ? rows[pos].number : null;
view.append(
el('div', { class: 'detail__skeleton' }, 'Loading Pokémon…'),
);
let pokemon;
let species;
try {
[pokemon, species] = await Promise.all([
getPokemon(nationalId),
getSpecies(nationalId),
]);
} catch (err) {
clear(view).append(
el('div', { class: 'view--error' },
el('h1', {}, 'Could not load this Pokémon'),
el('p', {}, navigator.onLine ? err.message : 'You appear to be offline.'),
el('a', { class: 'button', href: '#/' }, 'Back to the dex'),
),
);
return view;
}
let shiny = st.showShiny;
const artwork = Sprite(nationalId, {
style: st.spriteStyle === 'default' ? 'official' : st.spriteStyle,
shiny,
alt: species.name,
size: 240,
});
const shinyToggle = el(
'button',
{
class: 'button button--ghost',
type: 'button',
onclick: () => {
shiny = !shiny;
const fresh = Sprite(nationalId, {
style: st.spriteStyle === 'default' ? 'official' : st.spriteStyle,
shiny,
alt: species.name,
size: 240,
});
artwork.replaceWith(fresh);
artworkRef.node = fresh;
shinyToggle.textContent = shiny ? 'Show normal' : 'Show shiny';
},
},
shiny ? 'Show normal' : 'Show shiny',
);
const artworkRef = { node: artwork };
// Caught / seen / favorite controls — write to the unified selection.
const trackBar = el('div', { class: 'detail__track' });
function renderTrack() {
const e = entry(nationalId);
trackBar.replaceChildren(
toggleButton('seen', 'Seen', e.seen),
toggleButton('caught', 'Caught', e.caught),
toggleButton('favorite', '★ Favorite', e.favorite),
);
}
function toggleButton(field, label, on) {
return el(
'button',
{
class: `toggle${on ? ' is-on' : ''}`,
type: 'button',
'aria-pressed': String(on),
onclick: () => {
toggle(nationalId, field);
renderTrack();
},
},
label,
);
}
renderTrack();
// Flavor text for the currently selected game, newest fallback otherwise.
const vg = snap.versionGroupByKey.get(st.versionGroup);
const wantedVersions = new Set(vg ? vg.versions : []);
const englishEntries = species.flavor_text_entries.filter(
(f) => f.language.name === 'en',
);
const flavor =
englishEntries.find((f) => wantedVersions.has(f.version.name)) ||
englishEntries[englishEntries.length - 1];
const stat = (name) =>
pokemon.stats.find((s) => s.stat.name === name)?.base_stat ?? 0;
const statNames = [
'hp',
'attack',
'defense',
'special-attack',
'special-defense',
'speed',
];
const total = statNames.reduce((sum, n) => sum + stat(n), 0);
clear(view).append(
el(
'nav',
{ class: 'detail__nav' },
prev
? el('a', { class: 'link', href: `#/pokemon/${prev.species.id}` }, ` ${prev.species.name}`)
: el('span'),
el('a', { class: 'link', href: '#/' }, 'All'),
next
? el('a', { class: 'link', href: `#/pokemon/${next.species.id}` }, `${next.species.name} `)
: el('span'),
),
el(
'header',
{ class: 'detail__header' },
el('p', { class: 'detail__num' },
regionalNumber != null
? `#${String(regionalNumber).padStart(3, '0')} · ${prettify(dex.name || dex.key)}`
: `#${String(nationalId).padStart(4, '0')} · National`),
el('h1', { class: 'detail__name' }, species.name.replace(/-/g, ' ')),
el('div', { class: 'detail__types' }, ...pokemon.types.map((t) => TypeChip(t.type.name))),
),
el(
'div',
{ class: 'detail__media' },
artwork,
shinyToggle,
),
trackBar,
flavor
? el('p', { class: 'detail__flavor' }, flavor.flavor_text.replace(/\s+/g, ' '))
: null,
el(
'section',
{ class: 'detail__stats' },
el('h2', {}, 'Base stats'),
...statNames.map((n) => StatBar(n, stat(n))),
el('div', { class: 'statbar statbar--total' },
el('span', { class: 'statbar__label' }, 'Total'),
el('span', { class: 'statbar__value' }, String(total)),
el('div', { class: 'statbar__track' }),
),
),
el(
'dl',
{ class: 'detail__facts' },
fact('Height', `${pokemon.height / 10} m`),
fact('Weight', `${pokemon.weight / 10} kg`),
fact('Abilities', pokemon.abilities.map((a) => prettify(a.ability.name) + (a.is_hidden ? ' (hidden)' : '')).join(', ')),
fact('Generation', prettify(species.generation.name)),
),
);
const off = settings.subscribe(() => renderTrack());
onTeardown(view, off);
return view;
}
function fact(label, value) {
return el('div', { class: 'fact' },
el('dt', {}, label),
el('dd', {}, value),
);
}

72
src/views/SearchView.js Normal file
View File

@ -0,0 +1,72 @@
import { el, clear, onTeardown } from '../lib/dom.js';
import { loadSnapshot } from '../data/snapshot.js';
import { settings } from '../store/settings.js';
import { entry } from '../store/selection.js';
import { Sprite } from '../components/Sprite.js';
import { TypeChip } from '../components/TypeChip.js';
/**
* Global search across every Pokémon, not just the current dex. Runs
* entirely off the bundled snapshot, so it works offline.
*/
export async function SearchView() {
const snap = await loadSnapshot();
const view = el('section', { class: 'view search' });
const results = el('ul', { class: 'search__results' });
const input = el('input', {
class: 'search__input',
type: 'search',
placeholder: 'Name, number or type…',
autofocus: true,
oninput: (e) => run(e.target.value),
});
view.append(
el('header', { class: 'view__header' }, el('h1', {}, 'Search')),
input,
results,
);
function run(raw) {
const q = raw.trim().toLowerCase();
clear(results);
if (!q) return;
const style = settings.get().spriteStyle;
const matches = snap.species
.filter(
(s) =>
s.name.includes(q) ||
String(s.id) === q ||
(s.types || []).some((t) => t === q),
)
.slice(0, 60);
if (!matches.length) {
results.append(el('li', { class: 'search__empty' }, 'No matches.'));
return;
}
for (const s of matches) {
const e = entry(s.id);
results.append(
el(
'li',
{},
el(
'a',
{ class: `search__row${e.caught ? ' is-caught' : ''}`, href: `#/pokemon/${s.id}` },
Sprite(s.id, { style, alt: s.name, size: 56 }),
el('span', { class: 'search__num' }, `#${String(s.id).padStart(4, '0')}`),
el('span', { class: 'search__name' }, s.name.replace(/-/g, ' ')),
el('span', { class: 'search__types' }, ...(s.types || []).map(TypeChip)),
),
),
);
}
}
onTeardown(view, () => {});
return view;
}

142
src/views/SettingsView.js Normal file
View File

@ -0,0 +1,142 @@
import { el, onTeardown } from '../lib/dom.js';
import { settings, applyTheme } from '../store/settings.js';
import { selection } from '../store/selection.js';
export async function SettingsView() {
const view = el('section', { class: 'view settings' });
const st = settings.get();
const themeField = selectField('Theme', st.theme, [
['system', 'System'],
['light', 'Light'],
['dark', 'Dark'],
], (v) => {
settings.set({ theme: v });
applyTheme(v);
});
const spriteField = selectField('Sprite style', st.spriteStyle, [
['default', 'Pixel'],
['official', 'Official artwork'],
['home', 'Pokémon HOME'],
], (v) => settings.set({ spriteStyle: v }));
const shinyField = el('label', { class: 'field field--check' },
el('input', {
type: 'checkbox',
checked: st.showShiny,
onchange: (e) => settings.set({ showShiny: e.target.checked }),
}),
'Default detail view to shiny sprites',
);
const storageNote = el('p', { class: 'settings__note' }, 'Calculating storage…');
if (navigator.storage?.estimate) {
navigator.storage.estimate().then(({ usage = 0, quota = 0 }) => {
const mb = (n) => (n / 1024 / 1024).toFixed(1);
storageNote.textContent = `Using ${mb(usage)} MB of ~${mb(quota)} MB available (caches + tracking data).`;
});
} else {
storageNote.textContent = 'Storage estimate not available in this browser.';
}
const importInput = el('input', {
type: 'file',
accept: 'application/json',
style: 'display:none',
onchange: async (e) => {
const file = e.target.files[0];
if (!file) return;
try {
const data = JSON.parse(await file.text());
if (data.settings) settings.replace(data.settings);
if (data.selection) selection.replace(data.selection);
alert('Import complete.');
} catch {
alert('That file could not be read as a Pokédex backup.');
}
},
});
view.append(
el('header', { class: 'view__header' }, el('h1', {}, 'Settings')),
el('div', { class: 'settings__group' }, themeField, spriteField, shinyField),
el('h2', {}, 'Your data'),
storageNote,
el('div', { class: 'settings__actions' },
el('button', {
class: 'button',
type: 'button',
onclick: () => exportData(),
}, 'Export backup'),
el('button', {
class: 'button',
type: 'button',
onclick: () => importInput.click(),
}, 'Import backup'),
importInput,
el('button', {
class: 'button button--danger',
type: 'button',
onclick: () => {
if (confirm('Clear all seen/caught/favorite tracking? This cannot be undone.')) {
selection.replace({ version: 2, pokemon: {} });
}
},
}, 'Clear tracking data'),
el('button', {
class: 'button button--danger',
type: 'button',
onclick: async () => {
if (!('caches' in window)) return;
if (confirm('Clear cached PokéAPI data and sprites? They will re-download when online.')) {
for (const key of await caches.keys()) await caches.delete(key);
alert('Caches cleared.');
}
},
}, 'Clear cached API data'),
),
el('h2', {}, 'About'),
el('p', { class: 'settings__note' },
'Data from ',
el('a', { href: 'https://pokeapi.co/', class: 'link', target: '_blank', rel: 'noreferrer' }, 'PokéAPI'),
'. Tracking data and preferences are stored locally in your browser.',
),
);
onTeardown(view, () => {});
return view;
}
function selectField(label, value, options, onChange) {
const select = el(
'select',
{ onchange: (e) => onChange(e.target.value) },
...options.map(([v, text]) =>
el('option', { value: v, selected: v === value }, text),
),
);
return el('label', { class: 'field' }, el('span', {}, label), select);
}
function exportData() {
const payload = {
exportedAt: new Date().toISOString(),
settings: settings.get(),
selection: selection.get(),
};
const blob = new Blob([JSON.stringify(payload, null, 2)], {
type: 'application/json',
});
const url = URL.createObjectURL(blob);
const a = el('a', {
href: url,
download: `pokedex-backup-${new Date().toISOString().slice(0, 10)}.json`,
});
document.body.append(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(url), 1000);
}

52
vite.config.js Normal file
View File

@ -0,0 +1,52 @@
import { defineConfig } from 'vite';
import { VitePWA } from 'vite-plugin-pwa';
// GitHub Pages / sub-path friendly: set BASE_PATH env at build time if needed.
const base = process.env.BASE_PATH || '/';
export default defineConfig({
base,
build: {
target: 'es2020',
sourcemap: true,
},
plugins: [
VitePWA({
strategies: 'injectManifest',
srcDir: 'src',
filename: 'sw.js',
registerType: 'prompt',
injectManifest: {
globPatterns: ['**/*.{js,css,html,svg,png,ico,json,webmanifest}'],
// The bundled PokéAPI snapshot can be a few hundred KB.
maximumFileSizeToCacheInBytes: 5 * 1024 * 1024,
},
includeAssets: ['icon.svg', 'favicon.svg'],
manifest: {
name: 'Pokédex',
short_name: 'Pokédex',
description:
'A responsive, offline-capable Pokédex reference powered by PokéAPI.',
start_url: './',
scope: './',
display: 'standalone',
orientation: 'portrait',
background_color: '#0b0b0c',
theme_color: '#b3161a',
categories: ['games', 'reference', 'utilities'],
icons: [
{
src: 'icon.svg',
sizes: 'any',
type: 'image/svg+xml',
purpose: 'any maskable',
},
],
},
devOptions: {
enabled: false,
type: 'module',
},
}),
],
});