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; }