diff --git a/src/components/FlavorText.js b/src/components/FlavorText.js index 0e27887..b68456f 100644 --- a/src/components/FlavorText.js +++ b/src/components/FlavorText.js @@ -1,36 +1,49 @@ import { el } from '../lib/dom.js'; import { prettify } from '../data/pokedex-resolver.js'; +const clean = (s) => s.replace(/\s+/g, ' ').trim(); + +function label(names) { + const arr = [...names].map(prettify); + return arr.length <= 3 ? arr.join(', ') : `${arr.slice(0, 3).join(', ')} +${arr.length - 3}`; +} + /** - * Pokédex flavour text for the selected game. Most games ship two versions - * (Scarlet/Violet, Sword/Shield…) with different entries — show a pill - * toggle and slide between them. Falls back to the most recent English - * entry when the game itself has none. + * Pokédex flavour text. The primary block shows the selected game's + * entry/entries (with a Sword/Shield-style pill toggle when they differ); + * an "All Pokédex entries" disclosure lists every distinct entry across + * every game, newest first. */ export function FlavorText(species, vg) { - const versions = vg ? vg.versions : []; const english = species.flavor_text_entries.filter((f) => f.language.name === 'en'); + if (!english.length) return null; - const byVersion = new Map(); - for (const f of english) { - if (!byVersion.has(f.version.name)) { - byVersion.set(f.version.name, f.flavor_text.replace(/\s+/g, ' ')); + // distinct text -> set of version names, in newest-first order + const order = []; + const versionsByText = new Map(); + for (let i = english.length - 1; i >= 0; i--) { + const text = clean(english[i].flavor_text); + if (!versionsByText.has(text)) { + versionsByText.set(text, new Set()); + order.push(text); } + versionsByText.get(text).add(english[i].version.name); } - let picks = versions - .filter((v) => byVersion.has(v)) - .map((v) => ({ version: v, text: byVersion.get(v) })); - - // Collapse identical entries (both versions often share the same text). - const seen = new Set(); - picks = picks.filter((p) => (seen.has(p.text) ? false : seen.add(p.text))); - - if (!picks.length && english.length) { - const last = english[english.length - 1]; - picks = [{ version: last.version.name, text: last.flavor_text.replace(/\s+/g, ' '), fallback: true }]; + // The selected game's distinct entries. + const wanted = vg ? vg.versions : []; + let primary = []; + for (const v of wanted) { + const hit = english.find((e) => e.version.name === v); + if (!hit) continue; + const text = clean(hit.flavor_text); + if (!primary.some((p) => p.text === text)) primary.push({ version: v, text }); + } + let fallback = false; + if (!primary.length) { + primary = [{ version: [...versionsByText.get(order[0])][0], text: order[0] }]; + fallback = true; } - if (!picks.length) return null; const quote = el('p', { class: 'ppanel__flavor' }); const caption = el('span', { class: 'flavor__cap' }); @@ -38,20 +51,19 @@ export function FlavorText(species, vg) { let index = 0; function paint() { - const p = picks[index]; + const p = primary[index]; quote.textContent = p.text; quote.style.animation = 'none'; - // eslint-disable-next-line no-unused-expressions - quote.offsetWidth; + quote.offsetWidth; // reflow so the slide-in re-runs quote.style.animation = ''; - caption.textContent = p.fallback + caption.textContent = fallback ? `— ${prettify(p.version)} (most recent)` : `— Pokémon ${prettify(p.version)}`; [...toggle.children].forEach((b, i) => b.classList.toggle('is-active', i === index)); } - if (picks.length > 1) { - picks.forEach((p, i) => + if (primary.length > 1) { + primary.forEach((p, i) => toggle.append( el( 'button', @@ -68,7 +80,35 @@ export function FlavorText(species, vg) { ), ); } - paint(); - return el('div', { class: 'flavor' }, picks.length > 1 ? toggle : null, quote, caption); + + const allEntries = + order.length > 1 + ? el( + 'details', + { class: 'flavor__all' }, + el('summary', {}, `All Pokédex entries (${order.length})`), + el( + 'ul', + { class: 'flavor__list' }, + ...order.map((text) => + el( + 'li', + {}, + el('span', { class: 'flavor__list-ver' }, label(versionsByText.get(text))), + el('span', {}, text), + ), + ), + ), + ) + : null; + + return el( + 'div', + { class: 'flavor' }, + primary.length > 1 ? toggle : null, + quote, + caption, + allEntries, + ); } diff --git a/src/components/GameSheet.js b/src/components/GameSheet.js index eb1f823..902cc12 100644 --- a/src/components/GameSheet.js +++ b/src/components/GameSheet.js @@ -76,6 +76,23 @@ export async function openGameSheet() { function renderGames() { const st = settings.get(); clear(list); + + list.append( + el( + 'button', + { + class: `gamecard gamecard--all${st.versionGroup === 'all' ? ' is-active' : ''}`, + type: 'button', + onclick: () => { + settings.set({ versionGroup: 'all', pokedex: null }); + close(); + }, + }, + el('span', { class: 'gamecard__name' }, 'All games'), + el('span', { class: 'gamecard__meta' }, 'National Dex · newest data, no generation limits'), + ), + ); + for (const { generation, versionGroups } of versionGroupsByGeneration(snap)) { list.append( el('h3', { class: 'gsheet__gen' }, `Gen ${generation.id} · ${generation.name}`), diff --git a/src/store/ui.js b/src/store/ui.js index 4bb4772..0764cad 100644 --- a/src/store/ui.js +++ b/src/store/ui.js @@ -6,4 +6,6 @@ import { createStore } from './createStore.js'; */ export const ui = createStore('pdx.ui', { detailTab: 'about', + searchQuery: '', + searchScroll: 0, }); diff --git a/src/styles/layout.css b/src/styles/layout.css index 06aa03d..3c21681 100644 --- a/src/styles/layout.css +++ b/src/styles/layout.css @@ -904,6 +904,38 @@ color: var(--text-dim); text-transform: capitalize; } +.flavor__all { + margin-top: 12px; + border-top: 1px solid var(--border); + padding-top: 10px; +} +.flavor__all summary { + cursor: pointer; + font-size: 0.82rem; + font-weight: 600; + color: var(--text-dim); +} +.flavor__list { + list-style: none; + margin: 10px 0 0; + padding: 0; + display: flex; + flex-direction: column; + gap: 10px; +} +.flavor__list li { + display: grid; + gap: 2px; + font-size: 0.88rem; + line-height: 1.5; +} +.flavor__list-ver { + font-size: 0.7rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.03em; + color: var(--type-main, var(--accent)); +} @media (prefers-reduced-motion: reduce) { .flavor .ppanel__flavor { animation: none; @@ -1434,3 +1466,11 @@ grid-template-columns: repeat(auto-fill, minmax(10rem, 1fr)); gap: 8px; } +.gamecard--all { + width: 100%; + margin-bottom: 4px; + border-style: dashed; +} +.gamecard--all.is-active { + border-style: solid; +} diff --git a/src/views/DexGrid.js b/src/views/DexGrid.js index 5db9cc6..0b0524c 100644 --- a/src/views/DexGrid.js +++ b/src/views/DexGrid.js @@ -158,7 +158,8 @@ export async function DexGrid() { ids = rows.map((r) => r.species.id); title.textContent = prettify(dex.name || dex.key); - gamePill.firstChild.textContent = prettify(st.versionGroup); + gamePill.firstChild.textContent = + st.versionGroup === 'all' ? 'All games' : prettify(st.versionGroup); refreshMeta(); paintGrid(); } diff --git a/src/views/PokemonDetail.js b/src/views/PokemonDetail.js index fbcd324..c0fa26c 100644 --- a/src/views/PokemonDetail.js +++ b/src/views/PokemonDetail.js @@ -202,20 +202,28 @@ export async function PokemonDetail(nationalId) { const evoPanel = el('div', { class: 'ppanel' }, el('p', { class: 'detail__muted' }, 'Loading…')); loadEvolution(species, st, snap, vgGen).then((node) => evoPanel.replaceChildren(node)); + const allGames = st.versionGroup === 'all'; + // ---- Moves panel ---------------------------------------- const movesPanel = el( 'div', { class: 'ppanel' }, - MovesList(pokemon.moves, { - versionGroupKey: st.versionGroup, - gen: vgGen, - genOfVg: (name) => snap.versionGroupByKey.get(name)?.generation ?? 9, - }), + allGames + ? el('p', { class: 'detail__muted' }, 'Pick a game to see its learnset.') + : MovesList(pokemon.moves, { + versionGroupKey: st.versionGroup, + gen: vgGen, + genOfVg: (name) => snap.versionGroupByKey.get(name)?.generation ?? 9, + }), ); // ---- Locations panel ---------------------------------- const locPanel = el('div', { class: 'ppanel' }, el('p', { class: 'detail__muted' }, 'Loading…')); - if (NO_ENCOUNTER_DATA.has(st.versionGroup)) { + if (allGames) { + locPanel.replaceChildren( + el('p', { class: 'detail__muted' }, 'Pick a game to see where to catch it.'), + ); + } else if (NO_ENCOUNTER_DATA.has(st.versionGroup)) { locPanel.replaceChildren( el('p', { class: 'detail__muted' }, "PokéAPI doesn't have wild-encounter data for this game yet."), ); diff --git a/src/views/SearchView.js b/src/views/SearchView.js index ae05c98..35e749c 100644 --- a/src/views/SearchView.js +++ b/src/views/SearchView.js @@ -1,25 +1,43 @@ 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 { Sprite } from '../components/Sprite.js'; import { TypeChip } from '../components/TypeChip.js'; /** - * Global search across every Pokémon, not just the current dex. Runs - * entirely off the bundled snapshot, so it works offline. + * 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. */ 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)}.`; + const results = el('ul', { class: 'search__results' }); const input = el('input', { class: 'search__input', type: 'search', placeholder: 'Name, number or type…', - autofocus: true, - oninput: (e) => run(e.target.value), + value: ui.get().searchQuery || '', + oninput: (e) => { + ui.set({ searchQuery: e.target.value }); + run(e.target.value); + }, }); view.append( @@ -27,7 +45,7 @@ export async function SearchView() { 'header', { class: 'view__header' }, el('h1', {}, 'Search all Pokémon'), - el('p', {}, 'Look up any Pokémon from any game — not limited to your selected Pokédex.'), + el('p', {}, `Every Pokémon, any game. ${scope}`), ), input, results, @@ -72,6 +90,10 @@ export async function SearchView() { } } - onTeardown(view, () => {}); + // Restore query + scroll on entry; remember scroll on the way out. + run(input.value); + requestAnimationFrame(() => window.scrollTo(0, ui.get().searchScroll || 0)); + onTeardown(view, () => ui.set({ searchScroll: window.scrollY })); + return view; }