diff --git a/README.md b/README.md index f254fde..76d3413 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/src/data/api.js b/src/data/api.js index 12e4653..017a866 100644 --- a/src/data/api.js +++ b/src/data/api.js @@ -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); diff --git a/src/router.js b/src/router.js index edd37cb..f5e2522 100644 --- a/src/router.js +++ b/src/router.js @@ -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() }, ]; diff --git a/src/store/ui.js b/src/store/ui.js index 1e38d85..2398b64 100644 --- a/src/store/ui.js +++ b/src/store/ui.js @@ -8,6 +8,7 @@ export const ui = createStore('pdx.ui', { detailTab: 'about', searchQuery: '', searchScroll: 0, + searchTab: 'pokemon', sort: 'dex', sortDesc: false, filter: 'all', diff --git a/src/styles/layout.css b/src/styles/layout.css index f336815..ab8a443 100644 --- a/src/styles/layout.css +++ b/src/styles/layout.css @@ -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 { diff --git a/src/views/ItemDetail.js b/src/views/ItemDetail.js new file mode 100644 index 0000000..36694e7 --- /dev/null +++ b/src/views/ItemDetail.js @@ -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; +} diff --git a/src/views/MoveDetail.js b/src/views/MoveDetail.js new file mode 100644 index 0000000..4174abb --- /dev/null +++ b/src/views/MoveDetail.js @@ -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; +} diff --git a/src/views/SearchView.js b/src/views/SearchView.js index 35e749c..643da3d 100644 --- a/src/views/SearchView.js +++ b/src/views/SearchView.js @@ -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; }