diff --git a/README.md b/README.md index 76d3413..200dbe1 100644 --- a/README.md +++ b/README.md @@ -80,9 +80,13 @@ Working app with a type-themed UI: applies Gen 1 / pre-Gen 6 rules. - **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. +- **Search** — tabbed lookup: Pokémon, Moves and Items, all browsable + offline from the snapshot. Moves filter by type / damage class / "TMs in + this game" and sort by power / accuracy / recency; Items filter by + category. Each has its own detail page. Query, scroll, tab and filters + persist. +- **Team** — a lineup of up to 6 with a Coverage table (weaknesses, STAB + gaps) and a Compare table; toggleable in the nav from Settings. - **Settings** — theme (System / Light / Dark / Black / Sepia) + accent colour, sprite style, JSON export/import. - **PWA** — Workbox SW: precache shell + snapshot, SWR for API JSON, diff --git a/scripts/build-snapshot.mjs b/scripts/build-snapshot.mjs index 82781d6..c22ff68 100644 --- a/scripts/build-snapshot.mjs +++ b/scripts/build-snapshot.mjs @@ -196,6 +196,41 @@ async function main() { }; }); + // ---- Moves index ------------------------------------------- + const moveIndex = await api('move?limit=100000'); + console.log(`Fetching ${moveIndex.results.length} moves …`); + const moves = await mapLimit(moveIndex.results, CONCURRENCY, async (r) => { + const m = await api(r.url); + return { + id: m.id, + name: m.name, + type: m.type?.name || 'normal', + damageClass: m.damage_class?.name || null, + power: m.power, + accuracy: m.accuracy, + pp: m.pp, + priority: m.priority, + generation: idFromUrl(m.generation.url), + // version-group -> machine id (resolve to TM/HM number on demand) + machines: (m.machines || []).map((x) => ({ + vg: x.version_group.name, + id: idFromUrl(x.machine.url), + })), + }; + }); + moves.sort((a, b) => a.id - b.id); + + // ---- Item categories (gives every item a category cheaply) ---- + const catIndex = await api('item-category?limit=100'); + const items = []; + await mapLimit(catIndex.results, CONCURRENCY, async (c) => { + const cat = await api(c.name.startsWith('http') ? c.name : `item-category/${c.name}`); + for (const it of cat.items) { + items.push({ id: idFromUrl(it.url), name: it.name, category: cat.name }); + } + }); + items.sort((a, b) => a.id - b.id); + // ---- Write ---------------------------------------------------- const snapshot = { meta: { @@ -203,6 +238,8 @@ async function main() { source: BASE, counts: { species: species.length, + moves: moves.length, + items: items.length, pokedexes: pokedexes.length, versionGroups: versionGroups.length, }, @@ -210,6 +247,8 @@ async function main() { types, generations, species, + moves, + items, pokedexes, versionGroups, }; diff --git a/src/data/snapshot.js b/src/data/snapshot.js index e3bbf0b..3aef9b2 100644 --- a/src/data/snapshot.js +++ b/src/data/snapshot.js @@ -20,6 +20,11 @@ export async function loadSnapshot() { 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])); + data.moves = data.moves || []; + data.items = data.items || []; + data.moveById = new Map(data.moves.map((m) => [m.id, m])); + data.itemById = new Map(data.items.map((it) => [it.id, it])); + data.itemCategories = [...new Set(data.items.map((it) => it.category))].sort(); cached = data; return cached; diff --git a/src/store/ui.js b/src/store/ui.js index 7d2b6e4..46e889f 100644 --- a/src/store/ui.js +++ b/src/store/ui.js @@ -18,4 +18,10 @@ export const ui = createStore('pdx.ui', { recent: [], toolsOpen: false, teamMode: 'coverage', + mvType: '', + mvClass: '', + mvSort: 'name', + mvTm: false, + itCat: '', + itSort: 'name', }); diff --git a/src/styles/layout.css b/src/styles/layout.css index 6436cac..aedbe7f 100644 --- a/src/styles/layout.css +++ b/src/styles/layout.css @@ -2274,3 +2274,34 @@ opacity: 0.5; cursor: not-allowed; } + +/* ---- Lookup filter bar ------------------------------------- */ +.lookup__filters { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 12px; +} +.lookup__filters .feed-sort__select { + flex: 1 1 8rem; + max-width: none; +} +.lookup__check { + display: flex; + align-items: center; + gap: 6px; + font-size: 0.82rem; + font-weight: 600; + color: var(--text-dim); + white-space: nowrap; +} +.search__cat { + flex: none; + font-size: 0.7rem; + font-weight: 600; + color: var(--text-dim); + background: var(--surface-2); + padding: 2px 8px; + border-radius: 6px; + text-transform: capitalize; +} diff --git a/src/views/SearchView.js b/src/views/SearchView.js index 643da3d..4aa5c50 100644 --- a/src/views/SearchView.js +++ b/src/views/SearchView.js @@ -4,19 +4,19 @@ 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 { getMachine, 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 DMG = { physical: 'Phys', special: 'Spec', status: 'Stat' }; +const TYPES = [ + 'normal', 'fire', 'water', 'electric', 'grass', 'ice', 'fighting', 'poison', + 'ground', 'flying', 'psychic', 'bug', 'rock', 'ghost', 'dragon', 'dark', 'steel', 'fairy', +]; 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' }, @@ -24,11 +24,11 @@ const TABS = [ { 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. - */ +const tmLabel = (raw) => { + const m = raw.match(/^([a-z]+)0*(\d+)$/i); + return m ? `${m[1].toUpperCase()}${String(Number(m[2])).padStart(2, '0')}` : raw.toUpperCase(); +}; + export async function SearchView() { const snap = await loadSnapshot(); const view = el('section', { class: 'view search' }); @@ -62,7 +62,7 @@ export async function SearchView() { tab = t.id; ui.set({ searchTab: t.id }); [...seg.children].forEach((b, i) => b.classList.toggle('is-active', TABS[i].id === tab)); - syncPlaceholder(); + syncUI(); run(); }, }, @@ -71,32 +71,94 @@ export async function SearchView() { ), ); + const filters = el('div', { class: 'lookup__filters' }); const note = el('p', { class: 'search__note' }); const results = el('div', { class: 'search__results' }); - function syncPlaceholder() { + // ---- filter controls (rebuilt per tab) --------------------------- + function pill(label, options, value, onChange) { + return el( + 'select', + { class: 'feed-sort__select', onchange: (e) => onChange(e.target.value) }, + ...options.map(([v, t]) => el('option', { value: v, selected: v === value }, t)), + ); + } + + function buildFilters() { + clear(filters); + if (tab === 'moves') { + filters.append( + pill('mvType', [['', 'Any type'], ...TYPES.map((t) => [t, prettify(t)])], ui.get().mvType, (v) => { + ui.set({ mvType: v }); + run(); + }), + pill( + 'mvClass', + [['', 'Any category'], ['physical', 'Physical'], ['special', 'Special'], ['status', 'Status']], + ui.get().mvClass, + (v) => { + ui.set({ mvClass: v }); + run(); + }, + ), + pill( + 'mvSort', + [['name', 'A–Z'], ['power', 'Power'], ['accuracy', 'Accuracy'], ['gen', 'Newest']], + ui.get().mvSort, + (v) => { + ui.set({ mvSort: v }); + run(); + }, + ), + el( + 'label', + { class: 'lookup__check' }, + el('input', { + type: 'checkbox', + checked: !!ui.get().mvTm, + onchange: (e) => { + ui.set({ mvTm: e.target.checked }); + run(); + }, + }), + 'TMs only', + ), + ); + } else if (tab === 'items') { + filters.append( + pill( + 'itCat', + [['', 'Any category'], ...snap.itemCategories.map((c) => [c, prettify(c)])], + ui.get().itCat, + (v) => { + ui.set({ itCat: v }); + run(); + }, + ), + pill('itSort', [['name', 'A–Z'], ['id', 'Dex order']], ui.get().itSort, (v) => { + ui.set({ itSort: v }); + run(); + }), + ); + } + } + + function syncUI() { 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.'; + buildFilters(); } view.append( el('header', { class: 'view__header' }, el('h1', {}, 'Search'), note), seg, + filters, input, results, ); - syncPlaceholder(); + syncUI(); - // ---- renderers ------------------------------------------------- + // ---- Pokémon ------------------------------------------------- function renderPokemon(q) { const style = settings.get().spriteStyle; const matches = snap.species @@ -123,100 +185,121 @@ export async function SearchView() { } } - 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.')); + // ---- Moves (from snapshot, offline) ----------------------------- + function renderMoves(q, token) { + const st = settings.get(); + const vg = st.versionGroup; + const type = ui.get().mvType; + const cls = ui.get().mvClass; + const sort = ui.get().mvSort; + const tmOnly = !!ui.get().mvTm; - const metas = new Map(); - for (const m of matches) { - const meta = el('span', { class: 'search__types' }); - metas.set(m.name, meta); + let list = snap.moves.filter((m) => { + if (q && !loose(m.name).includes(q)) return false; + if (type && m.type !== type) return false; + if (cls && m.damageClass !== cls) return false; + if (tmOnly && !m.machines.some((x) => vg === 'all' || x.vg === vg)) return false; + return true; + }); + + list.sort((a, b) => { + if (sort === 'power') return (b.power ?? -1) - (a.power ?? -1) || a.name.localeCompare(b.name); + if (sort === 'accuracy') return (b.accuracy ?? -1) - (a.accuracy ?? -1) || a.name.localeCompare(b.name); + if (sort === 'gen') return b.generation - a.generation || a.name.localeCompare(b.name); + return a.name.localeCompare(b.name); + }); + + note.textContent = `${list.length} move${list.length === 1 ? '' : 's'}`; + const shown = list.slice(0, 400); + const machineCells = new Map(); + + for (const m of shown) { + const tmCell = tmOnly ? el('span', { class: 'moverow__tm' }) : null; + if (tmCell) machineCells.set(m.id, { cell: tmCell, m }); results.append( el( 'a', - { class: 'search__row search__row--slim', href: `#/move/${idFromUrl(m.url)}` }, + { class: 'search__row search__row--slim', href: `#/move/${m.id}` }, + tmCell, el('span', { class: 'search__name' }, m.name.replace(/-/g, ' ')), - meta, + el( + 'span', + { class: 'search__types' }, + TypeChip(m.type), + el('span', { class: 'moverow__cat', dataset: { cat: m.damageClass || '' } }, DMG[m.damageClass] || '—'), + el('span', { class: 'search__num' }, m.power != null ? `${m.power} pw` : ''), + ), ), ); } - // Enrich a short result set with type / class / power after a pause. - clearTimeout(enrichTimer); - if (matches.length <= 24) { + if (list.length > shown.length) { + results.append(el('p', { class: 'search__empty' }, `Showing ${shown.length} of ${list.length} — refine to see more.`)); + } + + if (tmOnly && machineCells.size) { + clearTimeout(enrichTimer); enrichTimer = setTimeout(() => { - mapLimit(matches, 6, async (m) => { + mapLimit([...machineCells.values()], 6, async ({ cell, m }) => { + if (token !== runToken || !cell.isConnected) return; + const hit = m.machines.find((x) => x.vg === vg) || m.machines[m.machines.length - 1]; + if (!hit) return; 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` : ''), - ); + const mc = await getMachine(hit.id); + cell.textContent = tmLabel(mc.item.name); } catch { - /* leave bare */ + /* leave blank */ } }); - }, 220); + }, 200); } } - 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), - }); + // ---- Items (from snapshot, offline) --------------------------- + function renderItems(q) { + const cat = ui.get().itCat; + const sort = ui.get().itSort; + let list = snap.items.filter((it) => { + if (q && !loose(it.name).includes(q)) return false; + if (cat && it.category !== cat) return false; + return true; + }); + list.sort((a, b) => (sort === 'id' ? a.id - b.id : a.name.localeCompare(b.name))); + + note.textContent = `${list.length} item${list.length === 1 ? '' : 's'}`; + const shown = list.slice(0, 400); + for (const it of shown) { + 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)}` }, + { class: 'search__row', href: `#/item/${it.id}` }, icon, el('span', { class: 'search__name' }, it.name.replace(/-/g, ' ')), + el('span', { class: 'search__cat' }, prettify(it.category)), ), ); } + if (list.length > shown.length) { + results.append(el('p', { class: 'search__empty' }, `Showing ${shown.length} of ${list.length} — refine to see more.`)); + } } function run() { const token = ++runToken; const q = loose(query); clear(results); + note.textContent = ''; if (tab === 'pokemon') { + const st = settings.get(); + note.textContent = `Every Pokémon, any game. Opens for ${ + st.versionGroup === 'all' ? 'all games' : prettify(st.versionGroup) + }.`; if (q) renderPokemon(q); return; } - if (!q) return; - (tab === 'moves' ? renderMoves : renderItems)(q, token); + if (tab === 'moves') renderMoves(q, token); + else renderItems(q); } run();