All-games mode, full flavour list, persistent search
- FlavorText: 'All Pokédex entries' disclosure listing every distinct English entry across all games (newest first, labelled by game) — not just the selected game's one line - Search remembers its query and scroll position in the ui store, so Back or re-opening the tab restores the results; header explains the scope (matches all species; opens for the selected game / all games) - GameSheet 'All games' option: National Dex, newest data, no generation limits — abilities/shiny ungated, types untrimmed, evolution unpruned; Moves/Locations prompt to pick a game Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
parent
0665d0f2ef
commit
01269fd35a
@ -1,36 +1,49 @@
|
|||||||
import { el } from '../lib/dom.js';
|
import { el } from '../lib/dom.js';
|
||||||
import { prettify } from '../data/pokedex-resolver.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
|
* Pokédex flavour text. The primary block shows the selected game's
|
||||||
* (Scarlet/Violet, Sword/Shield…) with different entries — show a pill
|
* entry/entries (with a Sword/Shield-style pill toggle when they differ);
|
||||||
* toggle and slide between them. Falls back to the most recent English
|
* an "All Pokédex entries" disclosure lists every distinct entry across
|
||||||
* entry when the game itself has none.
|
* every game, newest first.
|
||||||
*/
|
*/
|
||||||
export function FlavorText(species, vg) {
|
export function FlavorText(species, vg) {
|
||||||
const versions = vg ? vg.versions : [];
|
|
||||||
const english = species.flavor_text_entries.filter((f) => f.language.name === 'en');
|
const english = species.flavor_text_entries.filter((f) => f.language.name === 'en');
|
||||||
|
if (!english.length) return null;
|
||||||
|
|
||||||
const byVersion = new Map();
|
// distinct text -> set of version names, in newest-first order
|
||||||
for (const f of english) {
|
const order = [];
|
||||||
if (!byVersion.has(f.version.name)) {
|
const versionsByText = new Map();
|
||||||
byVersion.set(f.version.name, f.flavor_text.replace(/\s+/g, ' '));
|
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
|
// The selected game's distinct entries.
|
||||||
.filter((v) => byVersion.has(v))
|
const wanted = vg ? vg.versions : [];
|
||||||
.map((v) => ({ version: v, text: byVersion.get(v) }));
|
let primary = [];
|
||||||
|
for (const v of wanted) {
|
||||||
// Collapse identical entries (both versions often share the same text).
|
const hit = english.find((e) => e.version.name === v);
|
||||||
const seen = new Set();
|
if (!hit) continue;
|
||||||
picks = picks.filter((p) => (seen.has(p.text) ? false : seen.add(p.text)));
|
const text = clean(hit.flavor_text);
|
||||||
|
if (!primary.some((p) => p.text === text)) primary.push({ version: v, text });
|
||||||
if (!picks.length && english.length) {
|
}
|
||||||
const last = english[english.length - 1];
|
let fallback = false;
|
||||||
picks = [{ version: last.version.name, text: last.flavor_text.replace(/\s+/g, ' '), fallback: true }];
|
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 quote = el('p', { class: 'ppanel__flavor' });
|
||||||
const caption = el('span', { class: 'flavor__cap' });
|
const caption = el('span', { class: 'flavor__cap' });
|
||||||
@ -38,20 +51,19 @@ export function FlavorText(species, vg) {
|
|||||||
let index = 0;
|
let index = 0;
|
||||||
|
|
||||||
function paint() {
|
function paint() {
|
||||||
const p = picks[index];
|
const p = primary[index];
|
||||||
quote.textContent = p.text;
|
quote.textContent = p.text;
|
||||||
quote.style.animation = 'none';
|
quote.style.animation = 'none';
|
||||||
// eslint-disable-next-line no-unused-expressions
|
quote.offsetWidth; // reflow so the slide-in re-runs
|
||||||
quote.offsetWidth;
|
|
||||||
quote.style.animation = '';
|
quote.style.animation = '';
|
||||||
caption.textContent = p.fallback
|
caption.textContent = fallback
|
||||||
? `— ${prettify(p.version)} (most recent)`
|
? `— ${prettify(p.version)} (most recent)`
|
||||||
: `— Pokémon ${prettify(p.version)}`;
|
: `— Pokémon ${prettify(p.version)}`;
|
||||||
[...toggle.children].forEach((b, i) => b.classList.toggle('is-active', i === index));
|
[...toggle.children].forEach((b, i) => b.classList.toggle('is-active', i === index));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (picks.length > 1) {
|
if (primary.length > 1) {
|
||||||
picks.forEach((p, i) =>
|
primary.forEach((p, i) =>
|
||||||
toggle.append(
|
toggle.append(
|
||||||
el(
|
el(
|
||||||
'button',
|
'button',
|
||||||
@ -68,7 +80,35 @@ export function FlavorText(species, vg) {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
paint();
|
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,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -76,6 +76,23 @@ export async function openGameSheet() {
|
|||||||
function renderGames() {
|
function renderGames() {
|
||||||
const st = settings.get();
|
const st = settings.get();
|
||||||
clear(list);
|
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)) {
|
for (const { generation, versionGroups } of versionGroupsByGeneration(snap)) {
|
||||||
list.append(
|
list.append(
|
||||||
el('h3', { class: 'gsheet__gen' }, `Gen ${generation.id} · ${generation.name}`),
|
el('h3', { class: 'gsheet__gen' }, `Gen ${generation.id} · ${generation.name}`),
|
||||||
|
|||||||
@ -6,4 +6,6 @@ import { createStore } from './createStore.js';
|
|||||||
*/
|
*/
|
||||||
export const ui = createStore('pdx.ui', {
|
export const ui = createStore('pdx.ui', {
|
||||||
detailTab: 'about',
|
detailTab: 'about',
|
||||||
|
searchQuery: '',
|
||||||
|
searchScroll: 0,
|
||||||
});
|
});
|
||||||
|
|||||||
@ -904,6 +904,38 @@
|
|||||||
color: var(--text-dim);
|
color: var(--text-dim);
|
||||||
text-transform: capitalize;
|
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) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.flavor .ppanel__flavor {
|
.flavor .ppanel__flavor {
|
||||||
animation: none;
|
animation: none;
|
||||||
@ -1434,3 +1466,11 @@
|
|||||||
grid-template-columns: repeat(auto-fill, minmax(10rem, 1fr));
|
grid-template-columns: repeat(auto-fill, minmax(10rem, 1fr));
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
|
.gamecard--all {
|
||||||
|
width: 100%;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
border-style: dashed;
|
||||||
|
}
|
||||||
|
.gamecard--all.is-active {
|
||||||
|
border-style: solid;
|
||||||
|
}
|
||||||
|
|||||||
@ -158,7 +158,8 @@ export async function DexGrid() {
|
|||||||
ids = rows.map((r) => r.species.id);
|
ids = rows.map((r) => r.species.id);
|
||||||
|
|
||||||
title.textContent = prettify(dex.name || dex.key);
|
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();
|
refreshMeta();
|
||||||
paintGrid();
|
paintGrid();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -202,20 +202,28 @@ export async function PokemonDetail(nationalId) {
|
|||||||
const evoPanel = el('div', { class: 'ppanel' }, el('p', { class: 'detail__muted' }, 'Loading…'));
|
const evoPanel = el('div', { class: 'ppanel' }, el('p', { class: 'detail__muted' }, 'Loading…'));
|
||||||
loadEvolution(species, st, snap, vgGen).then((node) => evoPanel.replaceChildren(node));
|
loadEvolution(species, st, snap, vgGen).then((node) => evoPanel.replaceChildren(node));
|
||||||
|
|
||||||
|
const allGames = st.versionGroup === 'all';
|
||||||
|
|
||||||
// ---- Moves panel ----------------------------------------
|
// ---- Moves panel ----------------------------------------
|
||||||
const movesPanel = el(
|
const movesPanel = el(
|
||||||
'div',
|
'div',
|
||||||
{ class: 'ppanel' },
|
{ class: 'ppanel' },
|
||||||
MovesList(pokemon.moves, {
|
allGames
|
||||||
versionGroupKey: st.versionGroup,
|
? el('p', { class: 'detail__muted' }, 'Pick a game to see its learnset.')
|
||||||
gen: vgGen,
|
: MovesList(pokemon.moves, {
|
||||||
genOfVg: (name) => snap.versionGroupByKey.get(name)?.generation ?? 9,
|
versionGroupKey: st.versionGroup,
|
||||||
}),
|
gen: vgGen,
|
||||||
|
genOfVg: (name) => snap.versionGroupByKey.get(name)?.generation ?? 9,
|
||||||
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
// ---- Locations panel ----------------------------------
|
// ---- Locations panel ----------------------------------
|
||||||
const locPanel = el('div', { class: 'ppanel' }, el('p', { class: 'detail__muted' }, 'Loading…'));
|
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(
|
locPanel.replaceChildren(
|
||||||
el('p', { class: 'detail__muted' }, "PokéAPI doesn't have wild-encounter data for this game yet."),
|
el('p', { class: 'detail__muted' }, "PokéAPI doesn't have wild-encounter data for this game yet."),
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,25 +1,43 @@
|
|||||||
import { el, clear, onTeardown } from '../lib/dom.js';
|
import { el, clear, onTeardown } from '../lib/dom.js';
|
||||||
import { loadSnapshot } from '../data/snapshot.js';
|
import { loadSnapshot } from '../data/snapshot.js';
|
||||||
import { settings } from '../store/settings.js';
|
import { settings } from '../store/settings.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 { Sprite } from '../components/Sprite.js';
|
import { Sprite } from '../components/Sprite.js';
|
||||||
import { TypeChip } from '../components/TypeChip.js';
|
import { TypeChip } from '../components/TypeChip.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Global search across every Pokémon, not just the current dex. Runs
|
* Global search across every Pokémon, independent of the selected dex. Runs
|
||||||
* entirely off the bundled snapshot, so it works offline.
|
* 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() {
|
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' });
|
||||||
|
|
||||||
|
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 results = el('ul', { class: 'search__results' });
|
||||||
const input = el('input', {
|
const input = el('input', {
|
||||||
class: 'search__input',
|
class: 'search__input',
|
||||||
type: 'search',
|
type: 'search',
|
||||||
placeholder: 'Name, number or type…',
|
placeholder: 'Name, number or type…',
|
||||||
autofocus: true,
|
value: ui.get().searchQuery || '',
|
||||||
oninput: (e) => run(e.target.value),
|
oninput: (e) => {
|
||||||
|
ui.set({ searchQuery: e.target.value });
|
||||||
|
run(e.target.value);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
view.append(
|
view.append(
|
||||||
@ -27,7 +45,7 @@ export async function SearchView() {
|
|||||||
'header',
|
'header',
|
||||||
{ class: 'view__header' },
|
{ class: 'view__header' },
|
||||||
el('h1', {}, 'Search all Pokémon'),
|
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,
|
input,
|
||||||
results,
|
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;
|
return view;
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user