Move & item lookup: tabbed Search + detail pages

- Search becomes a tabbed lookup: Pokémon (offline snapshot) / Moves /
  Items. Moves + Items lazy-load a name index from PokéAPI (SW-cached) and
  filter client-side; short move result sets get type/class/power chips
- MoveDetail (#/move/:id): type-themed header, power/accuracy/PP/priority,
  effect text with %-substitution, target, ailment/crit/drain/stat-change
  summary, learned-by count
- ItemDetail (#/item/:id): item sprite, category, cost, fling power,
  attributes, held-by count, effect text
- Loose name matching (hyphens vs spaces); run-token guards stale async
  renders when the query or tab changes mid-fetch

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
chris 2026-08-27 13:51:51 -04:00
parent e61bf7ac6c
commit 0f00b50198
8 changed files with 474 additions and 53 deletions

View File

@ -78,8 +78,13 @@ Working app with a type-themed UI:
incl. pre-Gen-4 physical/special-by-type); wild encounter locations.
- **Game-aware** — abilities gated to Gen 3+ (hidden to Gen 5+); type chart
applies Gen 1 / pre-Gen 6 rules.
- **Games / Search / Settings** — game picker with sub-dex switch, offline
global search, theme + sprite-style + JSON export/import.
- **Games** — overlay picker with stylised version-colour cover tiles,
sub-dex switch, and an "All games" (National, no gen limits) option.
- **Search** — tabbed lookup: Pokémon (offline, from the snapshot), Moves
and Items (lazy name index + on-demand detail, SW-cached), each with its
own detail page. Query, scroll and tab persist.
- **Settings** — theme (System / Light / Dark / Black / Sepia) + accent
colour, sprite style, JSON export/import.
- **PWA** — Workbox SW: precache shell + snapshot, SWR for API JSON,
cache-first LRU for sprites, in-app update toast.

View File

@ -25,8 +25,14 @@ export const getPokemon = (idOrName) => getJSON(`pokemon/${idOrName}`);
export const getSpecies = (idOrName) => getJSON(`pokemon-species/${idOrName}`);
export const getEvolutionChain = (id) => getJSON(`evolution-chain/${id}`);
export const getMove = (idOrName) => getJSON(`move/${idOrName}`);
export const getItem = (idOrName) => getJSON(`item/${idOrName}`);
export const getEncounters = (id) => getJSON(`pokemon/${id}/encounters`);
// Full name indexes for the lookup tabs — one request each, then the
// service worker keeps them (stale-while-revalidate).
export const getMoveIndex = () => getJSON('move?limit=2000');
export const getItemIndex = () => getJSON('item?limit=3000');
/** Run async `fn` over `items` with a bounded number of parallel workers. */
export async function mapLimit(items, limit, fn) {
const out = new Array(items.length);

View File

@ -3,10 +3,14 @@ import { DexGrid } from './views/DexGrid.js';
import { PokemonDetail } from './views/PokemonDetail.js';
import { SearchView } from './views/SearchView.js';
import { SettingsView } from './views/SettingsView.js';
import { MoveDetail } from './views/MoveDetail.js';
import { ItemDetail } from './views/ItemDetail.js';
const routes = [
{ pattern: /^#?\/?$/, view: () => DexGrid() },
{ pattern: /^#\/pokemon\/(\d+)$/, view: (m) => PokemonDetail(Number(m[1])) },
{ pattern: /^#\/move\/(\d+)$/, view: (m) => MoveDetail(Number(m[1])) },
{ pattern: /^#\/item\/(\d+)$/, view: (m) => ItemDetail(Number(m[1])) },
{ pattern: /^#\/search$/, view: () => SearchView() },
{ pattern: /^#\/settings$/, view: () => SettingsView() },
];

View File

@ -8,6 +8,7 @@ export const ui = createStore('pdx.ui', {
detailTab: 'about',
searchQuery: '',
searchScroll: 0,
searchTab: 'pokemon',
sort: 'dex',
sortDesc: false,
filter: 'all',

View File

@ -1331,7 +1331,37 @@
font-size: 0.8rem;
}
/* ---------- Search --------------------------------------------- */
/* ---------- Search / lookup ----------------------------------- */
.search__note {
color: var(--text-dim);
font-size: 0.9rem;
margin: 0 0 14px;
}
.seg {
display: inline-flex;
gap: 2px;
padding: 3px;
border-radius: 999px;
background: var(--surface-2);
margin-bottom: 12px;
}
.seg__btn {
appearance: none;
border: none;
background: none;
font: inherit;
font-size: 0.85rem;
font-weight: 600;
color: var(--text-dim);
padding: 7px 16px;
border-radius: 999px;
cursor: pointer;
}
.seg__btn.is-active {
background: var(--surface);
color: var(--text);
box-shadow: var(--shadow);
}
.search__results {
list-style: none;
margin: 16px 0 0;
@ -1379,12 +1409,88 @@
.search__types {
display: flex;
gap: 4px;
align-items: center;
flex-wrap: wrap;
}
.search__row--slim {
padding: 11px 14px;
}
.search__item-icon {
width: 34px;
height: 34px;
flex: none;
object-fit: contain;
image-rendering: pixelated;
}
.search__empty,
.settings__note {
color: var(--text-dim);
}
/* ---- Move / item detail ------------------------------------- */
.lookup {
max-width: 720px;
margin: 0 auto;
}
.lookup__nav {
margin-bottom: 10px;
font-size: 0.9rem;
}
.lookup__head {
padding: 20px;
border-radius: var(--radius);
color: #fff;
background: linear-gradient(
140deg,
var(--type-main, var(--accent)),
color-mix(in srgb, var(--type-main, var(--accent)) 55%, #000)
);
}
.lookup__head h1 {
margin: 0;
font-size: 1.8rem;
text-transform: capitalize;
}
.lookup__chips {
display: flex;
align-items: center;
gap: 8px;
margin-top: 8px;
}
.lookup__class {
font-size: 0.7rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
padding: 3px 10px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.24);
}
.lookup__head--item {
display: flex;
align-items: center;
gap: 16px;
background: var(--surface);
border: 1px solid var(--border);
color: inherit;
}
.lookup__head--item .lookup__class {
display: inline-block;
margin-top: 6px;
background: var(--surface-2);
color: var(--text-dim);
}
.lookup__item-img {
width: 64px;
height: 64px;
flex: none;
object-fit: contain;
image-rendering: pixelated;
}
.lookup .pfacts {
margin: 20px 0;
}
/* ---------- Settings ------------------------------------------ */
.settings__group,
.settings__actions {

71
src/views/ItemDetail.js Normal file
View File

@ -0,0 +1,71 @@
import { el, clear } from '../lib/dom.js';
import { getItem } from '../data/api.js';
import { prettify } from '../data/pokedex-resolver.js';
const ITEM_SPRITE = (name) =>
`https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/items/${name}.png`;
const english = (entries, key) => {
const hit = (entries || []).find((e) => e.language.name === 'en');
return hit ? hit[key].replace(/\s+/g, ' ').trim() : '';
};
function fact(k, v) {
return el('div', { class: 'fact' }, el('dt', {}, k), el('dd', {}, String(v)));
}
export async function ItemDetail(id) {
const view = el('section', { class: 'view lookup' });
view.append(el('div', { class: 'view--loading' }, 'Loading item…'));
let d;
try {
d = await getItem(id);
} catch (err) {
clear(view).append(
el(
'div',
{ class: 'view--error' },
el('h1', {}, 'Could not load this item'),
el('p', {}, navigator.onLine ? err.message : 'You appear to be offline.'),
el('a', { class: 'button', href: '#/search' }, 'Back to lookup'),
),
);
return view;
}
const effect =
english(d.effect_entries, 'short_effect') ||
english(d.flavor_text_entries, 'text') ||
'No description.';
const img = el('img', { class: 'lookup__item-img', alt: d.name, src: ITEM_SPRITE(d.name) });
img.addEventListener('error', () => img.remove(), { once: true });
clear(view).append(
el('nav', { class: 'lookup__nav' }, el('a', { class: 'link', href: '#/search' }, ' Lookup')),
el(
'header',
{ class: 'lookup__head lookup__head--item' },
img,
el(
'div',
{},
el('h1', {}, d.name.replace(/-/g, ' ')),
el('span', { class: 'lookup__class' }, prettify(d.category?.name || 'item')),
),
),
el(
'dl',
{ class: 'pfacts' },
d.cost ? fact('Buy price', `${d.cost.toLocaleString()}`) : null,
d.fling_power != null ? fact('Fling power', d.fling_power) : null,
d.attributes?.length
? fact('Attributes', d.attributes.map((a) => prettify(a.name)).join(', '))
: null,
fact('Held by', `${d.held_by_pokemon.length} Pokémon`),
),
el('section', { class: 'detail__section' }, el('h2', {}, 'Effect'), el('p', {}, effect)),
);
return view;
}

97
src/views/MoveDetail.js Normal file
View File

@ -0,0 +1,97 @@
import { el, clear } from '../lib/dom.js';
import { getMove } from '../data/api.js';
import { prettify } from '../data/pokedex-resolver.js';
import { TypeChip } from '../components/TypeChip.js';
import { typeHex } from '../lib/type-color.js';
const DMG = { physical: 'Physical', special: 'Special', status: 'Status' };
const english = (entries, key) => {
const hit = (entries || []).find((e) => e.language.name === 'en');
return hit ? hit[key].replace(/\s+/g, ' ').trim() : '';
};
function fact(k, v) {
return el('div', { class: 'fact' }, el('dt', {}, k), el('dd', {}, String(v)));
}
export async function MoveDetail(id) {
const view = el('section', { class: 'view lookup' });
view.append(el('div', { class: 'view--loading' }, 'Loading move…'));
let d;
try {
d = await getMove(id);
} catch (err) {
clear(view).append(
el(
'div',
{ class: 'view--error' },
el('h1', {}, 'Could not load this move'),
el('p', {}, navigator.onLine ? err.message : 'You appear to be offline.'),
el('a', { class: 'button', href: '#/search' }, 'Back to lookup'),
),
);
return view;
}
const type = d.type.name;
view.style.setProperty('--type-main', typeHex(type));
const chance = d.effect_chance;
let effect =
english(d.effect_entries, 'short_effect') ||
english(d.flavor_text_entries, 'flavor_text') ||
'No description.';
if (chance != null) effect = effect.replace(/\$effect_chance%?/g, `${chance}%`);
const bits = [];
if (d.priority) bits.push(`Priority ${d.priority > 0 ? '+' : ''}${d.priority}`);
if (d.target?.name) bits.push(`Target: ${prettify(d.target.name)}`);
if (d.meta?.ailment?.name && d.meta.ailment.name !== 'none') {
bits.push(
`May ${prettify(d.meta.ailment.name)}${d.meta.ailment_chance ? ` (${d.meta.ailment_chance}%)` : ''}`,
);
}
if (d.meta?.drain) bits.push(`${d.meta.drain > 0 ? 'Drains' : 'Recoil'} ${Math.abs(d.meta.drain)}%`);
if (d.meta?.healing) bits.push(`Heals ${d.meta.healing}%`);
if (d.meta?.crit_rate) bits.push(`+${d.meta.crit_rate} crit rate`);
if (d.meta?.flinch_chance) bits.push(`Flinch ${d.meta.flinch_chance}%`);
const statChanges = (d.stat_changes || [])
.map((s) => `${s.change > 0 ? '+' : ''}${s.change} ${prettify(s.stat.name)}`)
.join(', ');
if (statChanges) bits.push(statChanges);
clear(view).append(
el('nav', { class: 'lookup__nav' }, el('a', { class: 'link', href: '#/search' }, ' Lookup')),
el(
'header',
{ class: 'lookup__head', dataset: { type } },
el('h1', {}, d.name.replace(/-/g, ' ')),
el(
'div',
{ class: 'lookup__chips' },
TypeChip(type),
el('span', { class: 'lookup__class' }, DMG[d.damage_class?.name] || '—'),
),
),
el(
'dl',
{ class: 'pfacts' },
fact('Power', d.power ?? '—'),
fact('Accuracy', d.accuracy != null ? `${d.accuracy}%` : '—'),
fact('PP', d.pp ?? '—'),
fact('Priority', d.priority ?? 0),
fact('Introduced', prettify(d.generation.name)),
fact('Learned by', `${d.learned_by_pokemon.length} Pokémon`),
),
el(
'section',
{ class: 'detail__section' },
el('h2', {}, 'Effect'),
el('p', {}, effect),
bits.length ? el('p', { class: 'movebody__bits' }, bits.join(' · ')) : null,
),
);
return view;
}

View File

@ -4,96 +4,227 @@ import { settings } from '../store/settings.js';
import { ui } from '../store/ui.js';
import { entry } from '../store/selection.js';
import { prettify } from '../data/pokedex-resolver.js';
import { getMoveIndex, getItemIndex, getMove, mapLimit } from '../data/api.js';
import { Sprite } from '../components/Sprite.js';
import { TypeChip } from '../components/TypeChip.js';
const idFromUrl = (u) => Number(u.replace(/\/$/, '').split('/').pop());
// Names in the API use hyphens; people type spaces. Compare both loosely.
const loose = (s) => s.toLowerCase().replace(/[-\s]+/g, ' ').trim();
const itemSprite = (name) =>
`https://raw.githubusercontent.com/PokeAPI/sprites/master/sprites/items/${name}.png`;
const DMG = { physical: 'Phys', special: 'Spec', status: 'Stat' };
let moveIndex = null;
let itemIndex = null;
const TABS = [
{ id: 'pokemon', label: 'Pokémon' },
{ id: 'moves', label: 'Moves' },
{ id: 'items', label: 'Items' },
];
/**
* Global search across every Pokémon, independent of the selected dex. Runs
* off the bundled snapshot (offline-capable). The query and scroll position
* are kept in the `ui` store so going back or re-opening the tab lands you
* exactly where you left off.
*
* Results are game-agnostic matching is on name / national number / type.
* Opening a result shows that Pokémon for whichever game is currently
* selected (or all games), so its stats, learnset and dex text follow that
* choice, not the search.
* Unified lookup for Pokémon, moves and items. Pokémon come from the bundled
* snapshot (offline); moves and items lazy-load a name index from PokéAPI
* (then cached by the service worker) and fetch per-entry detail on demand.
*/
export async function SearchView() {
const snap = await loadSnapshot();
const view = el('section', { class: 'view search' });
const st = settings.get();
const scope =
st.versionGroup === 'all'
? 'Opening one shows it across all games.'
: `Opening one shows it for ${prettify(st.versionGroup)}.`;
let tab = TABS.some((t) => t.id === ui.get().searchTab) ? ui.get().searchTab : 'pokemon';
let query = ui.get().searchQuery || '';
let enrichTimer = null;
let runToken = 0;
const results = el('ul', { class: 'search__results' });
const input = el('input', {
class: 'search__input',
type: 'search',
placeholder: 'Name, number or type…',
value: ui.get().searchQuery || '',
value: query,
oninput: (e) => {
ui.set({ searchQuery: e.target.value });
run(e.target.value);
query = e.target.value;
ui.set({ searchQuery: query });
run();
},
});
view.append(
el(
'header',
{ class: 'view__header' },
el('h1', {}, 'Search all Pokémon'),
el('p', {}, `Every Pokémon, any game. ${scope}`),
const seg = el(
'div',
{ class: 'seg' },
...TABS.map((t) =>
el(
'button',
{
class: `seg__btn${t.id === tab ? ' is-active' : ''}`,
type: 'button',
onclick: () => {
tab = t.id;
ui.set({ searchTab: t.id });
[...seg.children].forEach((b, i) => b.classList.toggle('is-active', TABS[i].id === tab));
syncPlaceholder();
run();
},
},
t.label,
),
),
);
const note = el('p', { class: 'search__note' });
const results = el('div', { class: 'search__results' });
function syncPlaceholder() {
input.placeholder =
tab === 'pokemon' ? 'Name, number or type…' : tab === 'moves' ? 'Move name…' : 'Item name…';
const st = settings.get();
note.textContent =
tab === 'pokemon'
? `Every Pokémon, any game. Opening one shows it for ${
st.versionGroup === 'all' ? 'all games' : prettify(st.versionGroup)
}.`
: tab === 'moves'
? 'Every move in the series.'
: 'Every item in the series.';
}
view.append(
el('header', { class: 'view__header' }, el('h1', {}, 'Search'), note),
seg,
input,
results,
);
syncPlaceholder();
function run(raw) {
const q = raw.trim().toLowerCase();
clear(results);
if (!q) return;
// ---- renderers -------------------------------------------------
function renderPokemon(q) {
const style = settings.get().spriteStyle;
const matches = snap.species
.filter(
(s) =>
s.name.includes(q) ||
loose(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;
}
if (!matches.length) return void results.append(el('p', { class: 'search__empty' }, 'No matches.'));
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)),
),
'a',
{ class: `search__row${e.caught ? ' is-caught' : ''}`, href: `#/pokemon/${s.id}` },
Sprite(s.id, { style, alt: s.name, size: 52 }),
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)),
),
);
}
}
// Restore query + scroll on entry; remember scroll on the way out.
run(input.value);
async function renderMoves(q, token) {
if (!moveIndex) {
results.append(el('p', { class: 'search__empty' }, 'Loading moves…'));
try {
moveIndex = (await getMoveIndex()).results;
} catch {
if (token === runToken) results.textContent = 'Move list unavailable offline.';
return;
}
if (token !== runToken) return; // superseded by a newer query/tab
clear(results);
}
const matches = moveIndex.filter((m) => loose(m.name).includes(q)).slice(0, 80);
if (!matches.length) return void results.append(el('p', { class: 'search__empty' }, 'No matches.'));
const metas = new Map();
for (const m of matches) {
const meta = el('span', { class: 'search__types' });
metas.set(m.name, meta);
results.append(
el(
'a',
{ class: 'search__row search__row--slim', href: `#/move/${idFromUrl(m.url)}` },
el('span', { class: 'search__name' }, m.name.replace(/-/g, ' ')),
meta,
),
);
}
// Enrich a short result set with type / class / power after a pause.
clearTimeout(enrichTimer);
if (matches.length <= 24) {
enrichTimer = setTimeout(() => {
mapLimit(matches, 6, async (m) => {
try {
const d = await getMove(idFromUrl(m.url));
const meta = metas.get(m.name);
if (!meta.isConnected) return;
meta.append(
TypeChip(d.type.name),
el('span', { class: 'moverow__cat', dataset: { cat: d.damage_class?.name || '' } },
DMG[d.damage_class?.name] || '—'),
el('span', { class: 'search__num' }, d.power != null ? `${d.power} pw` : ''),
);
} catch {
/* leave bare */
}
});
}, 220);
}
}
async function renderItems(q, token) {
if (!itemIndex) {
results.append(el('p', { class: 'search__empty' }, 'Loading items…'));
try {
itemIndex = (await getItemIndex()).results;
} catch {
if (token === runToken) results.textContent = 'Item list unavailable offline.';
return;
}
if (token !== runToken) return;
clear(results);
}
const matches = itemIndex.filter((it) => loose(it.name).includes(q)).slice(0, 80);
if (!matches.length) return void results.append(el('p', { class: 'search__empty' }, 'No matches.'));
for (const it of matches) {
const icon = el('img', {
class: 'search__item-icon',
loading: 'lazy',
alt: '',
src: itemSprite(it.name),
});
icon.addEventListener('error', () => icon.remove(), { once: true });
results.append(
el(
'a',
{ class: 'search__row', href: `#/item/${idFromUrl(it.url)}` },
icon,
el('span', { class: 'search__name' }, it.name.replace(/-/g, ' ')),
),
);
}
}
function run() {
const token = ++runToken;
const q = loose(query);
clear(results);
if (tab === 'pokemon') {
if (q) renderPokemon(q);
return;
}
if (!q) return;
(tab === 'moves' ? renderMoves : renderItems)(q, token);
}
run();
requestAnimationFrame(() => window.scrollTo(0, ui.get().searchScroll || 0));
onTeardown(view, () => ui.set({ searchScroll: window.scrollY }));
onTeardown(view, () => {
clearTimeout(enrichTimer);
ui.set({ searchScroll: window.scrollY });
});
return view;
}