- 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>
231 lines
7.3 KiB
JavaScript
231 lines
7.3 KiB
JavaScript
import { el, clear, onTeardown } from '../lib/dom.js';
|
|
import { loadSnapshot } from '../data/snapshot.js';
|
|
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' },
|
|
];
|
|
|
|
/**
|
|
* 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' });
|
|
|
|
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 input = el('input', {
|
|
class: 'search__input',
|
|
type: 'search',
|
|
value: query,
|
|
oninput: (e) => {
|
|
query = e.target.value;
|
|
ui.set({ searchQuery: query });
|
|
run();
|
|
},
|
|
});
|
|
|
|
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();
|
|
|
|
// ---- renderers -------------------------------------------------
|
|
function renderPokemon(q) {
|
|
const style = settings.get().spriteStyle;
|
|
const matches = snap.species
|
|
.filter(
|
|
(s) =>
|
|
loose(s.name).includes(q) ||
|
|
String(s.id) === q ||
|
|
(s.types || []).some((t) => t === q),
|
|
)
|
|
.slice(0, 60);
|
|
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(
|
|
'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)),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
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, () => {
|
|
clearTimeout(enrichTimer);
|
|
ui.set({ searchScroll: window.scrollY });
|
|
});
|
|
|
|
return view;
|
|
}
|