Scaffold Pokedex PWA (Vite + Workbox)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
commit
cd2655011f
9
.gitignore
vendored
Normal file
9
.gitignore
vendored
Normal 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
67
README.md
Normal 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 1–5 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
24
index.html
Normal 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
6192
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
27
package.json
Normal file
27
package.json
Normal 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
6
public/favicon.svg
Normal 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
7
public/icon.svg
Normal 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
221
scripts/build-snapshot.mjs
Normal 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
47
src/components/Card.js
Normal 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
42
src/components/Nav.js
Normal 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;
|
||||||
|
}
|
||||||
38
src/components/ProgressHeader.js
Normal file
38
src/components/ProgressHeader.js
Normal 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
36
src/components/Sprite.js
Normal 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
30
src/components/StatBar.js
Normal 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}%`,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
5
src/components/TypeChip.js
Normal file
5
src/components/TypeChip.js
Normal 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
26
src/data/api.js
Normal 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}`);
|
||||||
57
src/data/pokedex-resolver.js
Normal file
57
src/data/pokedex-resolver.js
Normal 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
26
src/data/snapshot.js
Normal 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
45
src/lib/dom.js
Normal 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
54
src/main.js
Normal 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
63
src/router.js
Normal 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
55
src/store/createStore.js
Normal 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
61
src/store/selection.js
Normal 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
35
src/store/settings.js
Normal 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
640
src/styles/layout.css
Normal 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
98
src/styles/tokens.css
Normal 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
53
src/sw.js
Normal 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
134
src/views/DexGrid.js
Normal 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
109
src/views/GamePicker.js
Normal 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
189
src/views/PokemonDetail.js
Normal 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
72
src/views/SearchView.js
Normal 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
142
src/views/SettingsView.js
Normal 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
52
vite.config.js
Normal 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',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
});
|
||||||
Loading…
x
Reference in New Issue
Block a user