Compare commits
No commits in common. "0f00b50198fe91db2b7bc3e89384239c2bbad683" and "61ceb6abf33ba9f5460d1bf2e66544a0f0a479bd" have entirely different histories.
0f00b50198
...
61ceb6abf3
@ -78,13 +78,8 @@ Working app with a type-themed UI:
|
|||||||
incl. pre-Gen-4 physical/special-by-type); wild encounter locations.
|
incl. pre-Gen-4 physical/special-by-type); wild encounter locations.
|
||||||
- **Game-aware** — abilities gated to Gen 3+ (hidden to Gen 5+); type chart
|
- **Game-aware** — abilities gated to Gen 3+ (hidden to Gen 5+); type chart
|
||||||
applies Gen 1 / pre-Gen 6 rules.
|
applies Gen 1 / pre-Gen 6 rules.
|
||||||
- **Games** — overlay picker with stylised version-colour cover tiles,
|
- **Games / Search / Settings** — game picker with sub-dex switch, offline
|
||||||
sub-dex switch, and an "All games" (National, no gen limits) option.
|
global search, theme + sprite-style + JSON export/import.
|
||||||
- **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,
|
- **PWA** — Workbox SW: precache shell + snapshot, SWR for API JSON,
|
||||||
cache-first LRU for sprites, in-app update toast.
|
cache-first LRU for sprites, in-app update toast.
|
||||||
|
|
||||||
|
|||||||
@ -6,7 +6,6 @@ import {
|
|||||||
prettify,
|
prettify,
|
||||||
} from '../data/pokedex-resolver.js';
|
} from '../data/pokedex-resolver.js';
|
||||||
import { settings } from '../store/settings.js';
|
import { settings } from '../store/settings.js';
|
||||||
import { gameColors } from '../data/game-colors.js';
|
|
||||||
|
|
||||||
let openInstance = null;
|
let openInstance = null;
|
||||||
|
|
||||||
@ -102,14 +101,12 @@ export async function openGameSheet() {
|
|||||||
for (const vg of versionGroups) {
|
for (const vg of versionGroups) {
|
||||||
const active = vg.key === st.versionGroup;
|
const active = vg.key === st.versionGroup;
|
||||||
const dexCount = dexesForVersionGroup(snap, vg.key).length;
|
const dexCount = dexesForVersionGroup(snap, vg.key).length;
|
||||||
const [c1, c2] = gameColors(vg.key, generation.id);
|
|
||||||
grid.append(
|
grid.append(
|
||||||
el(
|
el(
|
||||||
'button',
|
'button',
|
||||||
{
|
{
|
||||||
class: `gamecard gamecard--cover${active ? ' is-active' : ''}`,
|
class: `gamecard${active ? ' is-active' : ''}`,
|
||||||
type: 'button',
|
type: 'button',
|
||||||
style: `--g1:${c1};--g2:${c2}`,
|
|
||||||
onclick: () => {
|
onclick: () => {
|
||||||
settings.set({ versionGroup: vg.key, pokedex: null });
|
settings.set({ versionGroup: vg.key, pokedex: null });
|
||||||
if (dexesForVersionGroup(snap, vg.key).length <= 1) {
|
if (dexesForVersionGroup(snap, vg.key).length <= 1) {
|
||||||
@ -121,12 +118,8 @@ export async function openGameSheet() {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
el('span', { class: 'gamecard__name' }, prettify(vg.name || vg.key)),
|
el('span', { class: 'gamecard__name' }, prettify(vg.name || vg.key)),
|
||||||
el(
|
el('span', { class: 'gamecard__meta' }, vg.versions.map(prettify).join(' / ')),
|
||||||
'span',
|
el('span', { class: 'gamecard__meta' }, `${dexCount} ${dexCount === 1 ? 'dex' : 'dexes'}`),
|
||||||
{ class: 'gamecard__foot' },
|
|
||||||
el('span', { class: 'gamecard__meta' }, vg.versions.map(prettify).join(' / ')),
|
|
||||||
el('span', { class: 'gamecard__dex' }, `${dexCount} ${dexCount === 1 ? 'dex' : 'dexes'}`),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -25,14 +25,8 @@ export const getPokemon = (idOrName) => getJSON(`pokemon/${idOrName}`);
|
|||||||
export const getSpecies = (idOrName) => getJSON(`pokemon-species/${idOrName}`);
|
export const getSpecies = (idOrName) => getJSON(`pokemon-species/${idOrName}`);
|
||||||
export const getEvolutionChain = (id) => getJSON(`evolution-chain/${id}`);
|
export const getEvolutionChain = (id) => getJSON(`evolution-chain/${id}`);
|
||||||
export const getMove = (idOrName) => getJSON(`move/${idOrName}`);
|
export const getMove = (idOrName) => getJSON(`move/${idOrName}`);
|
||||||
export const getItem = (idOrName) => getJSON(`item/${idOrName}`);
|
|
||||||
export const getEncounters = (id) => getJSON(`pokemon/${id}/encounters`);
|
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. */
|
/** Run async `fn` over `items` with a bounded number of parallel workers. */
|
||||||
export async function mapLimit(items, limit, fn) {
|
export async function mapLimit(items, limit, fn) {
|
||||||
const out = new Array(items.length);
|
const out = new Array(items.length);
|
||||||
|
|||||||
@ -1,51 +0,0 @@
|
|||||||
/**
|
|
||||||
* Signature colours for each game, used to render a stylised "cover" tile in
|
|
||||||
* the game picker (real box art is Nintendo's and not in the API). Two
|
|
||||||
* colours = the paired versions; games without a listed entry fall back to
|
|
||||||
* a per-generation hue.
|
|
||||||
*/
|
|
||||||
const GAME = {
|
|
||||||
'red-blue': ['#e0362f', '#2f6cd8'],
|
|
||||||
'red-green-japan': ['#e0362f', '#39ab57'],
|
|
||||||
'blue-japan': ['#2f6cd8', '#3f8ad0'],
|
|
||||||
yellow: ['#f4c21a', '#f0a81f'],
|
|
||||||
'gold-silver': ['#d7a521', '#b9bcc6'],
|
|
||||||
crystal: ['#41a3c4', '#6fd0d8'],
|
|
||||||
'ruby-sapphire': ['#b4302e', '#2e5cb4'],
|
|
||||||
emerald: ['#12904a', '#26b56b'],
|
|
||||||
'firered-leafgreen': ['#e4552b', '#57b04b'],
|
|
||||||
'diamond-pearl': ['#7fb0e2', '#e0a3c8'],
|
|
||||||
platinum: ['#9aa0ab', '#c9ccd4'],
|
|
||||||
'heartgold-soulsilver': ['#d7a521', '#b9bcc6'],
|
|
||||||
'black-white': ['#26262b', '#b9b9c2'],
|
|
||||||
'black-2-white-2': ['#2b2b31', '#aeaeb8'],
|
|
||||||
'x-y': ['#4c7bc4', '#c43b3b'],
|
|
||||||
'omega-ruby-alpha-sapphire': ['#b4302e', '#2e5cb4'],
|
|
||||||
'sun-moon': ['#f0952b', '#4062b4'],
|
|
||||||
'ultra-sun-ultra-moon': ['#e3781e', '#34489e'],
|
|
||||||
'lets-go-pikachu-lets-go-eevee': ['#f4c21a', '#c08a52'],
|
|
||||||
'sword-shield': ['#00a0c6', '#c33b84'],
|
|
||||||
'brilliant-diamond-and-shining-pearl': ['#6f5ac4', '#d06fa6'],
|
|
||||||
'legends-arceus': ['#3e7c6a', '#b08d4a'],
|
|
||||||
'scarlet-violet': ['#c9352e', '#7c4da0'],
|
|
||||||
'the-teal-mask': ['#2e8b7a', '#39a892'],
|
|
||||||
'the-indigo-disk': ['#3b3ba0', '#5a5ac4'],
|
|
||||||
colosseum: ['#3a3a44', '#6e5aa0'],
|
|
||||||
xd: ['#4a2e6e', '#6a45a0'],
|
|
||||||
};
|
|
||||||
|
|
||||||
const GEN = {
|
|
||||||
1: ['#e0362f', '#e8564f'],
|
|
||||||
2: ['#d7a521', '#e0b94a'],
|
|
||||||
3: ['#12904a', '#2eb56b'],
|
|
||||||
4: ['#6e86c4', '#9ab0dc'],
|
|
||||||
5: ['#44444c', '#666670'],
|
|
||||||
6: ['#5b7bc4', '#7c9adc'],
|
|
||||||
7: ['#f0952b', '#f5b45a'],
|
|
||||||
8: ['#3aa6c6', '#5ec6dc'],
|
|
||||||
9: ['#c9352e', '#e0564f'],
|
|
||||||
};
|
|
||||||
|
|
||||||
export function gameColors(versionGroupKey, generation = 9) {
|
|
||||||
return GAME[versionGroupKey] || GEN[generation] || GEN[9];
|
|
||||||
}
|
|
||||||
@ -3,14 +3,10 @@ import { DexGrid } from './views/DexGrid.js';
|
|||||||
import { PokemonDetail } from './views/PokemonDetail.js';
|
import { PokemonDetail } from './views/PokemonDetail.js';
|
||||||
import { SearchView } from './views/SearchView.js';
|
import { SearchView } from './views/SearchView.js';
|
||||||
import { SettingsView } from './views/SettingsView.js';
|
import { SettingsView } from './views/SettingsView.js';
|
||||||
import { MoveDetail } from './views/MoveDetail.js';
|
|
||||||
import { ItemDetail } from './views/ItemDetail.js';
|
|
||||||
|
|
||||||
const routes = [
|
const routes = [
|
||||||
{ pattern: /^#?\/?$/, view: () => DexGrid() },
|
{ pattern: /^#?\/?$/, view: () => DexGrid() },
|
||||||
{ pattern: /^#\/pokemon\/(\d+)$/, view: (m) => PokemonDetail(Number(m[1])) },
|
{ 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: /^#\/search$/, view: () => SearchView() },
|
||||||
{ pattern: /^#\/settings$/, view: () => SettingsView() },
|
{ pattern: /^#\/settings$/, view: () => SettingsView() },
|
||||||
];
|
];
|
||||||
|
|||||||
@ -8,7 +8,6 @@ export const ui = createStore('pdx.ui', {
|
|||||||
detailTab: 'about',
|
detailTab: 'about',
|
||||||
searchQuery: '',
|
searchQuery: '',
|
||||||
searchScroll: 0,
|
searchScroll: 0,
|
||||||
searchTab: 'pokemon',
|
|
||||||
sort: 'dex',
|
sort: 'dex',
|
||||||
sortDesc: false,
|
sortDesc: false,
|
||||||
filter: 'all',
|
filter: 'all',
|
||||||
|
|||||||
@ -621,59 +621,6 @@
|
|||||||
text-transform: capitalize;
|
text-transform: capitalize;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Cover tiles: a clean split of the two version colours, no gloss. */
|
|
||||||
.gamecard--cover {
|
|
||||||
position: relative;
|
|
||||||
overflow: hidden;
|
|
||||||
min-height: 88px;
|
|
||||||
justify-content: flex-end;
|
|
||||||
gap: 6px;
|
|
||||||
padding: 14px;
|
|
||||||
border: none;
|
|
||||||
color: #fff;
|
|
||||||
background: linear-gradient(105deg, var(--g1) 0%, var(--g1) 42%, var(--g2) 58%, var(--g2) 100%);
|
|
||||||
box-shadow: var(--shadow);
|
|
||||||
}
|
|
||||||
.gamecard--cover::after {
|
|
||||||
content: "";
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
background: linear-gradient(to top, rgba(0, 0, 0, 0.28), transparent 55%);
|
|
||||||
}
|
|
||||||
.gamecard--cover > * {
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
.gamecard--cover:hover {
|
|
||||||
border: none;
|
|
||||||
transform: translateY(-3px);
|
|
||||||
}
|
|
||||||
.gamecard--cover.is-active {
|
|
||||||
background: linear-gradient(105deg, var(--g1) 0%, var(--g1) 42%, var(--g2) 58%, var(--g2) 100%);
|
|
||||||
box-shadow: 0 0 0 2px var(--surface), 0 0 0 4px var(--accent);
|
|
||||||
}
|
|
||||||
.gamecard--cover .gamecard__name {
|
|
||||||
font-size: 1rem;
|
|
||||||
line-height: 1.15;
|
|
||||||
}
|
|
||||||
.gamecard__foot {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
.gamecard--cover .gamecard__meta {
|
|
||||||
color: rgba(255, 255, 255, 0.9);
|
|
||||||
}
|
|
||||||
.gamecard__dex {
|
|
||||||
font-size: 0.68rem;
|
|
||||||
font-weight: 700;
|
|
||||||
padding: 2px 8px;
|
|
||||||
border-radius: 6px;
|
|
||||||
background: rgba(255, 255, 255, 0.2);
|
|
||||||
color: #fff;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ================= Detail: type-themed hero + tabbed sheet ========= */
|
/* ================= Detail: type-themed hero + tabbed sheet ========= */
|
||||||
.pdetail {
|
.pdetail {
|
||||||
--ink: #fff;
|
--ink: #fff;
|
||||||
@ -1331,37 +1278,7 @@
|
|||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- Search / lookup ----------------------------------- */
|
/* ---------- Search --------------------------------------------- */
|
||||||
.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 {
|
.search__results {
|
||||||
list-style: none;
|
list-style: none;
|
||||||
margin: 16px 0 0;
|
margin: 16px 0 0;
|
||||||
@ -1409,88 +1326,12 @@
|
|||||||
.search__types {
|
.search__types {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 4px;
|
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,
|
.search__empty,
|
||||||
.settings__note {
|
.settings__note {
|
||||||
color: var(--text-dim);
|
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 ------------------------------------------ */
|
||||||
.settings__group,
|
.settings__group,
|
||||||
.settings__actions {
|
.settings__actions {
|
||||||
|
|||||||
@ -1,71 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@ -1,97 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@ -4,227 +4,96 @@ import { settings } from '../store/settings.js';
|
|||||||
import { ui } from '../store/ui.js';
|
import { ui } from '../store/ui.js';
|
||||||
import { entry } from '../store/selection.js';
|
import { entry } from '../store/selection.js';
|
||||||
import { prettify } from '../data/pokedex-resolver.js';
|
import { prettify } from '../data/pokedex-resolver.js';
|
||||||
import { getMoveIndex, getItemIndex, getMove, mapLimit } from '../data/api.js';
|
|
||||||
import { Sprite } from '../components/Sprite.js';
|
import { Sprite } from '../components/Sprite.js';
|
||||||
import { TypeChip } from '../components/TypeChip.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
|
* Global search across every Pokémon, independent of the selected dex. Runs
|
||||||
* snapshot (offline); moves and items lazy-load a name index from PokéAPI
|
* off the bundled snapshot (offline-capable). The query and scroll position
|
||||||
* (then cached by the service worker) and fetch per-entry detail on demand.
|
* 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.
|
||||||
*/
|
*/
|
||||||
export async function SearchView() {
|
export async function SearchView() {
|
||||||
const snap = await loadSnapshot();
|
const snap = await loadSnapshot();
|
||||||
const view = el('section', { class: 'view search' });
|
const view = el('section', { class: 'view search' });
|
||||||
|
|
||||||
let tab = TABS.some((t) => t.id === ui.get().searchTab) ? ui.get().searchTab : 'pokemon';
|
const st = settings.get();
|
||||||
let query = ui.get().searchQuery || '';
|
const scope =
|
||||||
let enrichTimer = null;
|
st.versionGroup === 'all'
|
||||||
let runToken = 0;
|
? 'Opening one shows it across all games.'
|
||||||
|
: `Opening one shows it for ${prettify(st.versionGroup)}.`;
|
||||||
|
|
||||||
|
const results = el('ul', { class: 'search__results' });
|
||||||
const input = el('input', {
|
const input = el('input', {
|
||||||
class: 'search__input',
|
class: 'search__input',
|
||||||
type: 'search',
|
type: 'search',
|
||||||
value: query,
|
placeholder: 'Name, number or type…',
|
||||||
|
value: ui.get().searchQuery || '',
|
||||||
oninput: (e) => {
|
oninput: (e) => {
|
||||||
query = e.target.value;
|
ui.set({ searchQuery: e.target.value });
|
||||||
ui.set({ searchQuery: query });
|
run(e.target.value);
|
||||||
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(
|
view.append(
|
||||||
el('header', { class: 'view__header' }, el('h1', {}, 'Search'), note),
|
el(
|
||||||
seg,
|
'header',
|
||||||
|
{ class: 'view__header' },
|
||||||
|
el('h1', {}, 'Search all Pokémon'),
|
||||||
|
el('p', {}, `Every Pokémon, any game. ${scope}`),
|
||||||
|
),
|
||||||
input,
|
input,
|
||||||
results,
|
results,
|
||||||
);
|
);
|
||||||
syncPlaceholder();
|
|
||||||
|
|
||||||
// ---- renderers -------------------------------------------------
|
function run(raw) {
|
||||||
function renderPokemon(q) {
|
const q = raw.trim().toLowerCase();
|
||||||
|
clear(results);
|
||||||
|
if (!q) return;
|
||||||
|
|
||||||
const style = settings.get().spriteStyle;
|
const style = settings.get().spriteStyle;
|
||||||
const matches = snap.species
|
const matches = snap.species
|
||||||
.filter(
|
.filter(
|
||||||
(s) =>
|
(s) =>
|
||||||
loose(s.name).includes(q) ||
|
s.name.includes(q) ||
|
||||||
String(s.id) === q ||
|
String(s.id) === q ||
|
||||||
(s.types || []).some((t) => t === q),
|
(s.types || []).some((t) => t === q),
|
||||||
)
|
)
|
||||||
.slice(0, 60);
|
.slice(0, 60);
|
||||||
if (!matches.length) return void results.append(el('p', { class: 'search__empty' }, 'No matches.'));
|
|
||||||
|
if (!matches.length) {
|
||||||
|
results.append(el('li', { class: 'search__empty' }, 'No matches.'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
for (const s of matches) {
|
for (const s of matches) {
|
||||||
const e = entry(s.id);
|
const e = entry(s.id);
|
||||||
results.append(
|
results.append(
|
||||||
el(
|
el(
|
||||||
'a',
|
'li',
|
||||||
{ class: `search__row${e.caught ? ' is-caught' : ''}`, href: `#/pokemon/${s.id}` },
|
{},
|
||||||
Sprite(s.id, { style, alt: s.name, size: 52 }),
|
el(
|
||||||
el('span', { class: 'search__num' }, `#${String(s.id).padStart(4, '0')}`),
|
'a',
|
||||||
el('span', { class: 'search__name' }, s.name.replace(/-/g, ' ')),
|
{ class: `search__row${e.caught ? ' is-caught' : ''}`, href: `#/pokemon/${s.id}` },
|
||||||
el('span', { class: 'search__types' }, ...(s.types || []).map(TypeChip)),
|
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)),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function renderMoves(q, token) {
|
// Restore query + scroll on entry; remember scroll on the way out.
|
||||||
if (!moveIndex) {
|
run(input.value);
|
||||||
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));
|
requestAnimationFrame(() => window.scrollTo(0, ui.get().searchScroll || 0));
|
||||||
onTeardown(view, () => {
|
onTeardown(view, () => ui.set({ searchScroll: window.scrollY }));
|
||||||
clearTimeout(enrichTimer);
|
|
||||||
ui.set({ searchScroll: window.scrollY });
|
|
||||||
});
|
|
||||||
|
|
||||||
return view;
|
return view;
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user