Compare commits
No commits in common. "4b9bc5c0abe3c4788a56ff11e3915ba03b6cc826" and "fecafe139c4e58706300af00bdeb9c3356e40d30" have entirely different histories.
4b9bc5c0ab
...
fecafe139c
15
README.md
15
README.md
@ -70,15 +70,7 @@ src/
|
|||||||
Working app with a type-themed UI:
|
Working app with a type-themed UI:
|
||||||
|
|
||||||
- **Dex grid** — type-tinted cards (official artwork, spotlight, ghost number),
|
- **Dex grid** — type-tinted cards (official artwork, spotlight, ghost number),
|
||||||
per-dex progress, name/number filter, caught/favorite filters, plus a
|
per-dex progress, name/number filter, caught/favorite filters.
|
||||||
filter drawer: type, generation, ability, egg group, minimum BST, and
|
|
||||||
"fully evolved only" (derived client-side from each species' evolves-from
|
|
||||||
link — no extra fetches). A "Notes" chip filters to Pokémon with a note;
|
|
||||||
cards with one get a small 📝 mark.
|
|
||||||
- **Notes** — a free-text note per Pokémon on its detail page (trade plans,
|
|
||||||
where you caught it, anything), autosaving as you type and flushing
|
|
||||||
immediately on blur or navigation so a quick tab-away never drops a
|
|
||||||
keystroke. Rides along in the existing JSON export/import.
|
|
||||||
- **Forms** — Megas, Gigantamax, regional forms and alternate formes in the
|
- **Forms** — Megas, Gigantamax, regional forms and alternate formes in the
|
||||||
snapshot; a pill switcher on the detail page rebuilds types / stats /
|
snapshot; a pill switcher on the detail page rebuilds types / stats /
|
||||||
matchups / abilities / learnset / artwork for the chosen form. Purely
|
matchups / abilities / learnset / artwork for the chosen form. Purely
|
||||||
@ -114,10 +106,7 @@ Working app with a type-themed UI:
|
|||||||
Clefairy is Normal, etc.), and era move data. Toggleable in the nav
|
Clefairy is Normal, etc.), and era move data. Toggleable in the nav
|
||||||
from Settings.
|
from Settings.
|
||||||
- **Settings** — theme (System / Light / Dark / Black / Sepia) + accent
|
- **Settings** — theme (System / Light / Dark / Black / Sepia) + accent
|
||||||
colour, text size, an in-app reduce-motion override (on top of the OS
|
colour, sprite style, JSON export/import.
|
||||||
preference, which is always respected too), sprite style, haptic
|
|
||||||
feedback on catch (Vibration API), which screen to land on at launch,
|
|
||||||
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.
|
||||||
|
|
||||||
|
|||||||
@ -312,11 +312,6 @@ async function main() {
|
|||||||
color: sp.color?.name || null,
|
color: sp.color?.name || null,
|
||||||
eggGroups: sp.egg_groups.map((g) => g.name),
|
eggGroups: sp.egg_groups.map((g) => g.name),
|
||||||
genderRate: sp.gender_rate,
|
genderRate: sp.gender_rate,
|
||||||
// Species this evolves FROM (null for base forms) — lets the app
|
|
||||||
// derive "fully evolved" client-side without fetching every
|
|
||||||
// evolution chain: a species nothing points to via this field has no
|
|
||||||
// further evolution.
|
|
||||||
evolvesFromId: sp.evolves_from_species ? idFromUrl(sp.evolves_from_species.url) : null,
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -4,8 +4,6 @@ import { TypeChip } from './TypeChip.js';
|
|||||||
import { entry, toggle } from '../store/selection.js';
|
import { entry, toggle } from '../store/selection.js';
|
||||||
import { typeHex } from '../lib/type-color.js';
|
import { typeHex } from '../lib/type-color.js';
|
||||||
import { typesForGen } from '../lib/type-resolve.js';
|
import { typesForGen } from '../lib/type-resolve.js';
|
||||||
import { prefersReducedMotion } from '../store/settings.js';
|
|
||||||
import { buzz } from '../lib/haptics.js';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* One Pokémon in the dex feed. Tinted by its primary type: a soft top-down
|
* One Pokémon in the dex feed. Tinted by its primary type: a soft top-down
|
||||||
@ -32,15 +30,12 @@ export function Card(species, number, { spriteStyle = 'official', versionGroup,
|
|||||||
const next = entry(species.id);
|
const next = entry(species.id);
|
||||||
caughtBtn.setAttribute('aria-pressed', String(next.caught));
|
caughtBtn.setAttribute('aria-pressed', String(next.caught));
|
||||||
card.classList.toggle('is-caught', next.caught);
|
card.classList.toggle('is-caught', next.caught);
|
||||||
if (next.caught) {
|
if (next.caught && !window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
||||||
buzz();
|
|
||||||
if (!prefersReducedMotion()) {
|
|
||||||
caughtBtn.animate(
|
caughtBtn.animate(
|
||||||
[{ transform: 'scale(1)' }, { transform: 'scale(1.4)' }, { transform: 'scale(1)' }],
|
[{ transform: 'scale(1)' }, { transform: 'scale(1.4)' }, { transform: 'scale(1)' }],
|
||||||
{ duration: 260, easing: 'cubic-bezier(.34,1.4,.64,1)' },
|
{ duration: 260, easing: 'cubic-bezier(.34,1.4,.64,1)' },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -54,9 +49,6 @@ export function Card(species, number, { spriteStyle = 'official', versionGroup,
|
|||||||
},
|
},
|
||||||
el('span', { class: 'card__ghost', 'aria-hidden': 'true' }, num),
|
el('span', { class: 'card__ghost', 'aria-hidden': 'true' }, num),
|
||||||
el('span', { class: 'card__spot', 'aria-hidden': 'true' }),
|
el('span', { class: 'card__spot', 'aria-hidden': 'true' }),
|
||||||
state.note && state.note.trim()
|
|
||||||
? el('span', { class: 'card__notemark', title: 'Has a note', 'aria-hidden': 'true' }, '📝')
|
|
||||||
: null,
|
|
||||||
el(
|
el(
|
||||||
'div',
|
'div',
|
||||||
{ class: 'card__art' },
|
{ class: 'card__art' },
|
||||||
|
|||||||
@ -1,15 +0,0 @@
|
|||||||
import { settings } from '../store/settings.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A short haptic pulse for a positive action (catch, favorite…). Gated on
|
|
||||||
* the user's haptics setting and on Vibration API support — most notably
|
|
||||||
* absent on iOS Safari, where this silently does nothing.
|
|
||||||
*/
|
|
||||||
export function buzz(pattern = 15) {
|
|
||||||
if (!settings.get().haptics || !navigator.vibrate) return;
|
|
||||||
try {
|
|
||||||
navigator.vibrate(pattern);
|
|
||||||
} catch {
|
|
||||||
/* unsupported in this context (e.g. no user-activation) — ignore */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -15,15 +15,6 @@ if ('scrollRestoration' in history) history.scrollRestoration = 'manual';
|
|||||||
// Navigation scroll memory is per-session — start a cold launch at the top.
|
// Navigation scroll memory is per-session — start a cold launch at the top.
|
||||||
ui.set({ feedScroll: 0, searchScroll: 0 });
|
ui.set({ feedScroll: 0, searchScroll: 0 });
|
||||||
|
|
||||||
// Land on the configured default screen for a fresh launch (no hash yet at
|
|
||||||
// all — a bookmark, the home-screen icon, or typing the bare URL). Explicit
|
|
||||||
// navigation, like tapping "Dex" in the nav, is never redirected — that
|
|
||||||
// only ever touches location.hash after this point.
|
|
||||||
if (!location.hash) {
|
|
||||||
const DEFAULT_ROUTES = { dex: '#/', team: '#/team', search: '#/search' };
|
|
||||||
location.hash = DEFAULT_ROUTES[settings.get().defaultRoute] || '#/';
|
|
||||||
}
|
|
||||||
|
|
||||||
// React to preference changes. The dex feed updates itself in place, but the
|
// React to preference changes. The dex feed updates itself in place, but the
|
||||||
// Pokémon detail page is built once per visit — rebuild it when the selected
|
// Pokémon detail page is built once per visit — rebuild it when the selected
|
||||||
// game changes so its data (flavour text, learnset, matchups, evolution,
|
// game changes so its data (flavour text, learnset, matchups, evolution,
|
||||||
|
|||||||
@ -9,7 +9,6 @@ import { AbilityDetail } from './views/AbilityDetail.js';
|
|||||||
import { TeamView } from './views/TeamView.js';
|
import { TeamView } from './views/TeamView.js';
|
||||||
import { NaturesView } from './views/NaturesView.js';
|
import { NaturesView } from './views/NaturesView.js';
|
||||||
import { detailSkeleton, lookupSkeleton } from './components/skeletons.js';
|
import { detailSkeleton, lookupSkeleton } from './components/skeletons.js';
|
||||||
import { prefersReducedMotion } from './store/settings.js';
|
|
||||||
|
|
||||||
const routes = [
|
const routes = [
|
||||||
{ pattern: /^#?\/?$/, view: () => DexGrid() },
|
{ pattern: /^#?\/?$/, view: () => DexGrid() },
|
||||||
@ -32,7 +31,8 @@ function resolve(hash) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const canAnimate = () =>
|
const canAnimate = () =>
|
||||||
typeof document.startViewTransition === 'function' && !prefersReducedMotion();
|
typeof document.startViewTransition === 'function' &&
|
||||||
|
!window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||||
|
|
||||||
let rerenderCurrent = null;
|
let rerenderCurrent = null;
|
||||||
|
|
||||||
|
|||||||
@ -9,10 +9,6 @@ import { createStore } from './createStore.js';
|
|||||||
* - spriteStyle: 'default' (pixel) | 'official' | 'home'
|
* - spriteStyle: 'default' (pixel) | 'official' | 'home'
|
||||||
* - showShiny: default the detail view to shiny sprites
|
* - showShiny: default the detail view to shiny sprites
|
||||||
* - locale: reserved for localized names (PokéAPI supports many)
|
* - locale: reserved for localized names (PokéAPI supports many)
|
||||||
* - fontScale: 'small' | 'default' | 'large' text size
|
|
||||||
* - reduceMotion: in-app override — off even if the OS doesn't ask for it
|
|
||||||
* - defaultRoute: 'dex' | 'team' | 'search' — landing screen on cold launch
|
|
||||||
* - haptics: short vibration on catch (Vibration API, Android only)
|
|
||||||
*/
|
*/
|
||||||
export const settings = createStore('pdx.settings', {
|
export const settings = createStore('pdx.settings', {
|
||||||
versionGroup: 'all', // 'all' = National Dex, newest data, no generation limits
|
versionGroup: 'all', // 'all' = National Dex, newest data, no generation limits
|
||||||
@ -23,18 +19,13 @@ export const settings = createStore('pdx.settings', {
|
|||||||
showShiny: false,
|
showShiny: false,
|
||||||
showTeamNav: true,
|
showTeamNav: true,
|
||||||
locale: 'en',
|
locale: 'en',
|
||||||
fontScale: 'default',
|
|
||||||
reduceMotion: false,
|
|
||||||
defaultRoute: 'dex',
|
|
||||||
haptics: true,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const DARKISH = new Set(['dark', 'black']);
|
const DARKISH = new Set(['dark', 'black']);
|
||||||
const FONT_SCALE = { small: 0.9, default: 1, large: 1.15 };
|
|
||||||
|
|
||||||
export function applyTheme(state = settings.get()) {
|
export function applyTheme(state = settings.get()) {
|
||||||
const root = document.documentElement;
|
const root = document.documentElement;
|
||||||
const { theme, accent, fontScale, reduceMotion } = state;
|
const { theme, accent } = state;
|
||||||
|
|
||||||
if (!theme || theme === 'system') root.removeAttribute('data-theme');
|
if (!theme || theme === 'system') root.removeAttribute('data-theme');
|
||||||
else root.setAttribute('data-theme', theme);
|
else root.setAttribute('data-theme', theme);
|
||||||
@ -42,11 +33,6 @@ export function applyTheme(state = settings.get()) {
|
|||||||
if (!accent || accent === 'red') root.removeAttribute('data-accent');
|
if (!accent || accent === 'red') root.removeAttribute('data-accent');
|
||||||
else root.setAttribute('data-accent', accent);
|
else root.setAttribute('data-accent', accent);
|
||||||
|
|
||||||
root.style.fontSize = `${(FONT_SCALE[fontScale] ?? 1) * 100}%`;
|
|
||||||
|
|
||||||
if (reduceMotion) root.setAttribute('data-reduce-motion', 'true');
|
|
||||||
else root.removeAttribute('data-reduce-motion');
|
|
||||||
|
|
||||||
const meta = document.querySelector('meta[name="theme-color"]');
|
const meta = document.querySelector('meta[name="theme-color"]');
|
||||||
if (meta) {
|
if (meta) {
|
||||||
const dark =
|
const dark =
|
||||||
@ -61,11 +47,3 @@ export function applyTheme(state = settings.get()) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** True if animations should be skipped — OS preference or the in-app override. */
|
|
||||||
export function prefersReducedMotion() {
|
|
||||||
return (
|
|
||||||
!!settings.get().reduceMotion ||
|
|
||||||
window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@ -15,10 +15,6 @@ export const ui = createStore('pdx.ui', {
|
|||||||
filter: 'all',
|
filter: 'all',
|
||||||
filterType: '',
|
filterType: '',
|
||||||
filterGen: 0,
|
filterGen: 0,
|
||||||
filterAbility: 0,
|
|
||||||
filterEggGroup: '',
|
|
||||||
filterMinBst: 0,
|
|
||||||
filterFullyEvolved: false,
|
|
||||||
recent: [],
|
recent: [],
|
||||||
toolsOpen: false,
|
toolsOpen: false,
|
||||||
teamMode: 'coverage',
|
teamMode: 'coverage',
|
||||||
|
|||||||
@ -559,20 +559,6 @@
|
|||||||
.card.is-caught {
|
.card.is-caught {
|
||||||
border-color: color-mix(in srgb, var(--good) 45%, var(--border));
|
border-color: color-mix(in srgb, var(--good) 45%, var(--border));
|
||||||
}
|
}
|
||||||
.card__notemark {
|
|
||||||
position: absolute;
|
|
||||||
top: 10px;
|
|
||||||
right: 10px;
|
|
||||||
z-index: 3;
|
|
||||||
width: 22px;
|
|
||||||
height: 22px;
|
|
||||||
border-radius: 50%;
|
|
||||||
display: grid;
|
|
||||||
place-items: center;
|
|
||||||
font-size: 0.7rem;
|
|
||||||
background: rgba(255, 255, 255, 0.75);
|
|
||||||
backdrop-filter: blur(3px);
|
|
||||||
}
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.card {
|
.card {
|
||||||
animation: none;
|
animation: none;
|
||||||
@ -950,55 +936,6 @@
|
|||||||
border-color: #fff;
|
border-color: #fff;
|
||||||
color: #ff4d6d;
|
color: #ff4d6d;
|
||||||
}
|
}
|
||||||
.pnote {
|
|
||||||
margin: 10px 0 0;
|
|
||||||
}
|
|
||||||
.pnote__label {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
font-size: 0.7rem;
|
|
||||||
font-weight: 700;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.04em;
|
|
||||||
color: var(--text-dim);
|
|
||||||
margin-bottom: 4px;
|
|
||||||
}
|
|
||||||
.pnote__status {
|
|
||||||
font-size: 0.68rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--good);
|
|
||||||
text-transform: none;
|
|
||||||
letter-spacing: normal;
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
.pnote__status.is-visible {
|
|
||||||
animation: pnote-fade 1.6s ease forwards;
|
|
||||||
}
|
|
||||||
@keyframes pnote-fade {
|
|
||||||
0% { opacity: 1; }
|
|
||||||
70% { opacity: 1; }
|
|
||||||
100% { opacity: 0; }
|
|
||||||
}
|
|
||||||
.pnote__input {
|
|
||||||
width: 100%;
|
|
||||||
min-height: 2.2em;
|
|
||||||
max-height: 12em;
|
|
||||||
padding: 8px 12px;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 12px;
|
|
||||||
background: var(--surface-2);
|
|
||||||
color: var(--text);
|
|
||||||
font: inherit;
|
|
||||||
font-size: 0.85rem;
|
|
||||||
line-height: 1.4;
|
|
||||||
resize: none;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
.pnote__input:focus {
|
|
||||||
outline: none;
|
|
||||||
border-color: var(--type-main, var(--accent));
|
|
||||||
}
|
|
||||||
.psheet__tabs {
|
.psheet__tabs {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
@ -1900,29 +1837,6 @@
|
|||||||
max-width: none;
|
max-width: none;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
.feed-minbst {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 6px;
|
|
||||||
flex: 1;
|
|
||||||
padding: 7px 12px;
|
|
||||||
border: 1.5px solid var(--border);
|
|
||||||
border-radius: 999px;
|
|
||||||
font-size: 0.84rem;
|
|
||||||
font-weight: 600;
|
|
||||||
color: var(--text-dim);
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
.feed-minbst input {
|
|
||||||
width: 4.5em;
|
|
||||||
min-width: 0;
|
|
||||||
border: none;
|
|
||||||
background: transparent;
|
|
||||||
color: var(--text);
|
|
||||||
font: inherit;
|
|
||||||
font-weight: 700;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
.recent {
|
.recent {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@ -140,10 +140,3 @@ a {
|
|||||||
transition-duration: 0.001ms !important;
|
transition-duration: 0.001ms !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
/* Settings > Reduce motion — an explicit in-app override alongside the OS
|
|
||||||
preference above, for anyone who wants it off without changing OS-level
|
|
||||||
settings (or whose OS setting this app can't see, e.g. some WebViews). */
|
|
||||||
:root[data-reduce-motion='true'] * {
|
|
||||||
animation-duration: 0.001ms !important;
|
|
||||||
transition-duration: 0.001ms !important;
|
|
||||||
}
|
|
||||||
|
|||||||
@ -20,7 +20,6 @@ const FILTERS = [
|
|||||||
{ key: 'missing', label: 'Missing', test: (e) => !e.caught },
|
{ key: 'missing', label: 'Missing', test: (e) => !e.caught },
|
||||||
{ key: 'favorite', label: 'Favorites', test: (e) => e.favorite },
|
{ key: 'favorite', label: 'Favorites', test: (e) => e.favorite },
|
||||||
{ key: 'legendary', label: 'Legendary', test: (e, sp) => sp.isLegendary || sp.isMythical },
|
{ key: 'legendary', label: 'Legendary', test: (e, sp) => sp.isLegendary || sp.isMythical },
|
||||||
{ key: 'notes', label: 'Notes', test: (e) => !!(e.note && e.note.trim()) },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const SORTS = [
|
const SORTS = [
|
||||||
@ -83,10 +82,6 @@ export async function DexGrid() {
|
|||||||
let filterKey = ui.get().filter || 'all';
|
let filterKey = ui.get().filter || 'all';
|
||||||
let filterType = ui.get().filterType || '';
|
let filterType = ui.get().filterType || '';
|
||||||
let filterGen = Number(ui.get().filterGen) || 0;
|
let filterGen = Number(ui.get().filterGen) || 0;
|
||||||
let filterAbility = Number(ui.get().filterAbility) || 0;
|
|
||||||
let filterEggGroup = ui.get().filterEggGroup || '';
|
|
||||||
let filterMinBst = Number(ui.get().filterMinBst) || 0;
|
|
||||||
let filterFullyEvolved = !!ui.get().filterFullyEvolved;
|
|
||||||
let sortKey = ui.get().sort || 'dex';
|
let sortKey = ui.get().sort || 'dex';
|
||||||
let sortDesc = !!ui.get().sortDesc;
|
let sortDesc = !!ui.get().sortDesc;
|
||||||
let rows = [];
|
let rows = [];
|
||||||
@ -215,87 +210,6 @@ export async function DexGrid() {
|
|||||||
'🎲',
|
'🎲',
|
||||||
);
|
);
|
||||||
|
|
||||||
// "Fully evolved" = no other species lists this one as evolvesFromId —
|
|
||||||
// derived client-side rather than fetching every evolution chain.
|
|
||||||
const hasNextEvo = new Set(snap.species.map((s) => s.evolvesFromId).filter(Boolean));
|
|
||||||
const isFullyEvolved = (sp) => !hasNextEvo.has(sp.id);
|
|
||||||
|
|
||||||
const eggGroupList = [...new Set(snap.species.flatMap((s) => s.eggGroups || []))].sort();
|
|
||||||
const abilityList = snap.abilities.slice().sort((a, b) => a.name.localeCompare(b.name));
|
|
||||||
|
|
||||||
const abilitySelect = el(
|
|
||||||
'select',
|
|
||||||
{
|
|
||||||
class: 'feed-sort__select',
|
|
||||||
onchange: (e) => {
|
|
||||||
filterAbility = Number(e.target.value) || 0;
|
|
||||||
ui.set({ filterAbility });
|
|
||||||
paintGrid();
|
|
||||||
resetScroll();
|
|
||||||
syncToolsBadge();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
el('option', { value: '0' }, 'Any ability'),
|
|
||||||
...abilityList.map((a) =>
|
|
||||||
el('option', { value: String(a.id), selected: a.id === filterAbility }, prettify(a.name)),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
const eggGroupSelect = el(
|
|
||||||
'select',
|
|
||||||
{
|
|
||||||
class: 'feed-sort__select',
|
|
||||||
onchange: (e) => {
|
|
||||||
filterEggGroup = e.target.value;
|
|
||||||
ui.set({ filterEggGroup });
|
|
||||||
paintGrid();
|
|
||||||
resetScroll();
|
|
||||||
syncToolsBadge();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
el('option', { value: '' }, 'Any egg group'),
|
|
||||||
...eggGroupList.map((g) => el('option', { value: g, selected: g === filterEggGroup }, prettify(g))),
|
|
||||||
);
|
|
||||||
|
|
||||||
const minBstField = el(
|
|
||||||
'label',
|
|
||||||
{ class: 'feed-minbst' },
|
|
||||||
el('span', {}, 'Min BST'),
|
|
||||||
el('input', {
|
|
||||||
type: 'number',
|
|
||||||
min: '0',
|
|
||||||
max: '1000',
|
|
||||||
step: '10',
|
|
||||||
placeholder: '0',
|
|
||||||
value: filterMinBst || '',
|
|
||||||
onchange: (e) => {
|
|
||||||
filterMinBst = Math.max(0, Number(e.target.value) || 0);
|
|
||||||
e.target.value = filterMinBst || '';
|
|
||||||
ui.set({ filterMinBst });
|
|
||||||
paintGrid();
|
|
||||||
resetScroll();
|
|
||||||
syncToolsBadge();
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
const fullyEvolvedCheck = el(
|
|
||||||
'label',
|
|
||||||
{ class: 'lookup__check' },
|
|
||||||
el('input', {
|
|
||||||
type: 'checkbox',
|
|
||||||
checked: filterFullyEvolved,
|
|
||||||
onchange: (e) => {
|
|
||||||
filterFullyEvolved = e.target.checked;
|
|
||||||
ui.set({ filterFullyEvolved });
|
|
||||||
paintGrid();
|
|
||||||
resetScroll();
|
|
||||||
syncToolsBadge();
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
'Fully evolved only',
|
|
||||||
);
|
|
||||||
|
|
||||||
// Changing the list order/contents makes the old scroll offset meaningless.
|
// Changing the list order/contents makes the old scroll offset meaningless.
|
||||||
function resetScroll() {
|
function resetScroll() {
|
||||||
ui.set({ feedScroll: 0 });
|
ui.set({ feedScroll: 0 });
|
||||||
@ -349,8 +263,6 @@ export async function DexGrid() {
|
|||||||
filterBar,
|
filterBar,
|
||||||
el('div', { class: 'feed-sort' }, el('span', { class: 'feed-sort__label' }, 'Sort'), sortSelect, dirBtn),
|
el('div', { class: 'feed-sort' }, el('span', { class: 'feed-sort__label' }, 'Sort'), sortSelect, dirBtn),
|
||||||
el('div', { class: 'feed-sort feed-filters' }, typeSelect, genSelect, randomBtn),
|
el('div', { class: 'feed-sort feed-filters' }, typeSelect, genSelect, randomBtn),
|
||||||
el('div', { class: 'feed-sort feed-filters' }, abilitySelect, eggGroupSelect, minBstField),
|
|
||||||
el('div', { class: 'feed-sort feed-filters' }, fullyEvolvedCheck),
|
|
||||||
);
|
);
|
||||||
const toolsToggle = el(
|
const toolsToggle = el(
|
||||||
'button',
|
'button',
|
||||||
@ -369,13 +281,7 @@ export async function DexGrid() {
|
|||||||
);
|
);
|
||||||
function syncToolsBadge() {
|
function syncToolsBadge() {
|
||||||
const n =
|
const n =
|
||||||
(filterKey !== 'all' ? 1 : 0) +
|
(filterKey !== 'all' ? 1 : 0) + (filterType ? 1 : 0) + (filterGen ? 1 : 0);
|
||||||
(filterType ? 1 : 0) +
|
|
||||||
(filterGen ? 1 : 0) +
|
|
||||||
(filterAbility ? 1 : 0) +
|
|
||||||
(filterEggGroup ? 1 : 0) +
|
|
||||||
(filterMinBst ? 1 : 0) +
|
|
||||||
(filterFullyEvolved ? 1 : 0);
|
|
||||||
toolsToggle.dataset.active = n ? String(n) : '';
|
toolsToggle.dataset.active = n ? String(n) : '';
|
||||||
}
|
}
|
||||||
syncToolsBadge();
|
syncToolsBadge();
|
||||||
@ -408,15 +314,13 @@ export async function DexGrid() {
|
|||||||
let caught = 0;
|
let caught = 0;
|
||||||
let favorite = 0;
|
let favorite = 0;
|
||||||
let legendary = 0;
|
let legendary = 0;
|
||||||
let notes = 0;
|
|
||||||
for (const { species } of rows) {
|
for (const { species } of rows) {
|
||||||
const e = p[species.id];
|
const e = p[species.id];
|
||||||
if (e?.caught) caught++;
|
if (e?.caught) caught++;
|
||||||
if (e?.favorite) favorite++;
|
if (e?.favorite) favorite++;
|
||||||
if (species.isLegendary || species.isMythical) legendary++;
|
if (species.isLegendary || species.isMythical) legendary++;
|
||||||
if (e?.note && e.note.trim()) notes++;
|
|
||||||
}
|
}
|
||||||
return { all: rows.length, caught, missing: rows.length - caught, favorite, legendary, notes };
|
return { all: rows.length, caught, missing: rows.length - caught, favorite, legendary };
|
||||||
}
|
}
|
||||||
|
|
||||||
function refreshMeta() {
|
function refreshMeta() {
|
||||||
@ -433,7 +337,6 @@ export async function DexGrid() {
|
|||||||
const gen = snap.versionGroupByKey.get(st.versionGroup)?.generation ?? 9;
|
const gen = snap.versionGroupByKey.get(st.versionGroup)?.generation ?? 9;
|
||||||
const boxed = st.spriteStyle === 'game' && gen <= 2;
|
const boxed = st.spriteStyle === 'game' && gen <= 2;
|
||||||
const filterTest = FILTERS.find((f) => f.key === filterKey).test;
|
const filterTest = FILTERS.find((f) => f.key === filterKey).test;
|
||||||
const abilityMon = filterAbility ? new Set(snap.abilityById.get(filterAbility)?.pokemon || []) : null;
|
|
||||||
|
|
||||||
let list = rows.filter(({ species, number }) => {
|
let list = rows.filter(({ species, number }) => {
|
||||||
if (
|
if (
|
||||||
@ -446,10 +349,6 @@ export async function DexGrid() {
|
|||||||
}
|
}
|
||||||
if (filterGen && species.generation !== filterGen) return false;
|
if (filterGen && species.generation !== filterGen) return false;
|
||||||
if (filterType && !typesForGen(species, gen).includes(filterType)) return false;
|
if (filterType && !typesForGen(species, gen).includes(filterType)) return false;
|
||||||
if (abilityMon && !abilityMon.has(species.id)) return false;
|
|
||||||
if (filterEggGroup && !(species.eggGroups || []).includes(filterEggGroup)) return false;
|
|
||||||
if (filterMinBst && (species.bst || 0) < filterMinBst) return false;
|
|
||||||
if (filterFullyEvolved && !isFullyEvolved(species)) return false;
|
|
||||||
const e = pokemonState[species.id] || { seen: false, caught: false, favorite: false };
|
const e = pokemonState[species.id] || { seen: false, caught: false, favorite: false };
|
||||||
return filterTest(e, species);
|
return filterTest(e, species);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -3,9 +3,9 @@ import { onSwipe } from '../lib/swipe.js';
|
|||||||
import { loadSnapshot } from '../data/snapshot.js';
|
import { loadSnapshot } from '../data/snapshot.js';
|
||||||
import { resolvePokedex, dexRows, prettify } from '../data/pokedex-resolver.js';
|
import { resolvePokedex, dexRows, prettify } from '../data/pokedex-resolver.js';
|
||||||
import { getPokemon, getSpecies, getEvolutionChain, getEncounters } from '../data/api.js';
|
import { getPokemon, getSpecies, getEvolutionChain, getEncounters } from '../data/api.js';
|
||||||
import { settings, prefersReducedMotion } from '../store/settings.js';
|
import { settings } from '../store/settings.js';
|
||||||
import { ui } from '../store/ui.js';
|
import { ui } from '../store/ui.js';
|
||||||
import { entry, toggle, setNote } from '../store/selection.js';
|
import { entry, toggle } from '../store/selection.js';
|
||||||
import { team, addToTeam, removeFromTeam, inTeam, MAX_TEAM } from '../store/team.js';
|
import { team, addToTeam, removeFromTeam, inTeam, MAX_TEAM } from '../store/team.js';
|
||||||
import { isFormCaught, toggleFormCaught } from '../store/formTracking.js';
|
import { isFormCaught, toggleFormCaught } from '../store/formTracking.js';
|
||||||
import { Sprite, spriteUrl } from '../components/Sprite.js';
|
import { Sprite, spriteUrl } from '../components/Sprite.js';
|
||||||
@ -17,7 +17,6 @@ import { TypeMatchups } from '../components/TypeMatchups.js';
|
|||||||
import { Locations } from '../components/Locations.js';
|
import { Locations } from '../components/Locations.js';
|
||||||
import { FlavorText } from '../components/FlavorText.js';
|
import { FlavorText } from '../components/FlavorText.js';
|
||||||
import { typeHex } from '../lib/type-color.js';
|
import { typeHex } from '../lib/type-color.js';
|
||||||
import { buzz } from '../lib/haptics.js';
|
|
||||||
import { offensiveSummary } from '../data/type-chart.js';
|
import { offensiveSummary } from '../data/type-chart.js';
|
||||||
|
|
||||||
const idFromUrl = (u) => Number(u.replace(/\/$/, '').split('/').pop());
|
const idFromUrl = (u) => Number(u.replace(/\/$/, '').split('/').pop());
|
||||||
@ -239,15 +238,12 @@ export async function PokemonDetail(nationalId) {
|
|||||||
onclick: () => {
|
onclick: () => {
|
||||||
toggle(nationalId, field);
|
toggle(nationalId, field);
|
||||||
syncTrack();
|
syncTrack();
|
||||||
if (entry(nationalId)[field]) {
|
if (entry(nationalId)[field] && !window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
||||||
buzz();
|
|
||||||
if (!prefersReducedMotion()) {
|
|
||||||
b.animate(
|
b.animate(
|
||||||
[{ transform: 'scale(1)' }, { transform: 'scale(1.12)' }, { transform: 'scale(1)' }],
|
[{ transform: 'scale(1)' }, { transform: 'scale(1.12)' }, { transform: 'scale(1)' }],
|
||||||
{ duration: 240, easing: 'cubic-bezier(.34,1.4,.64,1)' },
|
{ duration: 240, easing: 'cubic-bezier(.34,1.4,.64,1)' },
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
b.append(label);
|
b.append(label);
|
||||||
@ -284,57 +280,6 @@ export async function PokemonDetail(nationalId) {
|
|||||||
}
|
}
|
||||||
syncTrack();
|
syncTrack();
|
||||||
|
|
||||||
// ---- Personal note ------------------------------------------------
|
|
||||||
// Autosaves (debounced) as you type, and flushes immediately on blur or
|
|
||||||
// when leaving the page so a quick navigation never drops a keystroke.
|
|
||||||
const noteStatus = el('span', { class: 'pnote__status', 'aria-live': 'polite' });
|
|
||||||
let noteTimer = null;
|
|
||||||
let noteSaved = entry(nationalId).note || '';
|
|
||||||
function autosize(ta) {
|
|
||||||
ta.style.height = 'auto';
|
|
||||||
ta.style.height = `${ta.scrollHeight}px`;
|
|
||||||
}
|
|
||||||
function flashSaved() {
|
|
||||||
noteStatus.textContent = 'Saved';
|
|
||||||
noteStatus.classList.remove('is-visible');
|
|
||||||
void noteStatus.offsetWidth; // restart the fade
|
|
||||||
noteStatus.classList.add('is-visible');
|
|
||||||
}
|
|
||||||
function saveNote(value) {
|
|
||||||
clearTimeout(noteTimer);
|
|
||||||
if (value === noteSaved) return;
|
|
||||||
noteSaved = value;
|
|
||||||
setNote(nationalId, value);
|
|
||||||
flashSaved();
|
|
||||||
}
|
|
||||||
const noteInput = el('textarea', {
|
|
||||||
class: 'pnote__input',
|
|
||||||
placeholder: 'Add a note — trade plans, where you caught it, anything…',
|
|
||||||
rows: 1,
|
|
||||||
oninput: (e) => {
|
|
||||||
autosize(e.target);
|
|
||||||
clearTimeout(noteTimer);
|
|
||||||
noteTimer = setTimeout(() => saveNote(e.target.value), 500);
|
|
||||||
},
|
|
||||||
onfocus: (e) => autosize(e.target),
|
|
||||||
onblur: (e) => saveNote(e.target.value),
|
|
||||||
});
|
|
||||||
noteInput.value = noteSaved;
|
|
||||||
// The textarea isn't attached (let alone laid out) yet when it's built —
|
|
||||||
// this view still has to be mounted, possibly through a view transition.
|
|
||||||
// Retry like the feed's scroll restore does, so a non-empty note that
|
|
||||||
// wraps to several lines starts fully visible instead of clipped to one.
|
|
||||||
requestAnimationFrame(() => autosize(noteInput));
|
|
||||||
setTimeout(() => autosize(noteInput), 120);
|
|
||||||
if (document.fonts && document.fonts.ready) document.fonts.ready.then(() => autosize(noteInput));
|
|
||||||
const noteBlock = el(
|
|
||||||
'div',
|
|
||||||
{ class: 'pnote' },
|
|
||||||
el('label', { class: 'pnote__label', for: 'pnote-input' }, 'Note', noteStatus),
|
|
||||||
noteInput,
|
|
||||||
);
|
|
||||||
noteInput.id = 'pnote-input';
|
|
||||||
|
|
||||||
const allGames = st.versionGroup === 'all';
|
const allGames = st.versionGroup === 'all';
|
||||||
let statBars = [];
|
let statBars = [];
|
||||||
|
|
||||||
@ -745,7 +690,6 @@ export async function PokemonDetail(nationalId) {
|
|||||||
'div',
|
'div',
|
||||||
{ class: 'psheet' },
|
{ class: 'psheet' },
|
||||||
el('div', { class: 'ptrack' }, seenBtn, caughtBtn, teamBtn),
|
el('div', { class: 'ptrack' }, seenBtn, caughtBtn, teamBtn),
|
||||||
noteBlock,
|
|
||||||
el('div', { class: 'psheet__tabs', role: 'tablist' }, ...tabButtons),
|
el('div', { class: 'psheet__tabs', role: 'tablist' }, ...tabButtons),
|
||||||
body,
|
body,
|
||||||
),
|
),
|
||||||
@ -762,7 +706,6 @@ export async function PokemonDetail(nationalId) {
|
|||||||
const off = settings.subscribe(() => syncTrack());
|
const off = settings.subscribe(() => syncTrack());
|
||||||
const offTeam = team.subscribe(syncTeamBtn);
|
const offTeam = team.subscribe(syncTeamBtn);
|
||||||
onTeardown(view, () => {
|
onTeardown(view, () => {
|
||||||
saveNote(noteInput.value); // flush any unsaved keystrokes
|
|
||||||
off();
|
off();
|
||||||
offTeam();
|
offTeam();
|
||||||
offSwipe();
|
offSwipe();
|
||||||
|
|||||||
@ -47,27 +47,6 @@ export async function SettingsView() {
|
|||||||
);
|
);
|
||||||
const accentField = el('div', { class: 'field' }, el('span', {}, 'Accent colour'), accentRow);
|
const accentField = el('div', { class: 'field' }, el('span', {}, 'Accent colour'), accentRow);
|
||||||
|
|
||||||
const fontSizeField = selectField('Text size', st.fontScale || 'default', [
|
|
||||||
['small', 'Small'],
|
|
||||||
['default', 'Default'],
|
|
||||||
['large', 'Large'],
|
|
||||||
], (v) => {
|
|
||||||
settings.set({ fontScale: v });
|
|
||||||
applyTheme();
|
|
||||||
});
|
|
||||||
|
|
||||||
const reduceMotionField = el('label', { class: 'field field--check' },
|
|
||||||
el('input', {
|
|
||||||
type: 'checkbox',
|
|
||||||
checked: !!st.reduceMotion,
|
|
||||||
onchange: (e) => {
|
|
||||||
settings.set({ reduceMotion: e.target.checked });
|
|
||||||
applyTheme();
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
'Reduce motion',
|
|
||||||
);
|
|
||||||
|
|
||||||
const spriteField = selectField('Sprite style', st.spriteStyle, [
|
const spriteField = selectField('Sprite style', st.spriteStyle, [
|
||||||
['default', 'Pixel (modern)'],
|
['default', 'Pixel (modern)'],
|
||||||
['game', 'Game era (pixel art from the selected game)'],
|
['game', 'Game era (pixel art from the selected game)'],
|
||||||
@ -93,21 +72,6 @@ export async function SettingsView() {
|
|||||||
'Show Team in the navigation bar',
|
'Show Team in the navigation bar',
|
||||||
);
|
);
|
||||||
|
|
||||||
const hapticsField = el('label', { class: 'field field--check' },
|
|
||||||
el('input', {
|
|
||||||
type: 'checkbox',
|
|
||||||
checked: st.haptics !== false,
|
|
||||||
onchange: (e) => settings.set({ haptics: e.target.checked }),
|
|
||||||
}),
|
|
||||||
'Haptic feedback on catch',
|
|
||||||
);
|
|
||||||
|
|
||||||
const defaultRouteField = selectField('Landing screen', st.defaultRoute || 'dex', [
|
|
||||||
['dex', 'Dex'],
|
|
||||||
['team', 'Team'],
|
|
||||||
['search', 'Search'],
|
|
||||||
], (v) => settings.set({ defaultRoute: v }));
|
|
||||||
|
|
||||||
const storageNote = el('p', { class: 'settings__note' }, 'Calculating storage…');
|
const storageNote = el('p', { class: 'settings__note' }, 'Calculating storage…');
|
||||||
if (navigator.storage?.estimate) {
|
if (navigator.storage?.estimate) {
|
||||||
navigator.storage.estimate().then(({ usage = 0, quota = 0 }) => {
|
navigator.storage.estimate().then(({ usage = 0, quota = 0 }) => {
|
||||||
@ -138,19 +102,7 @@ export async function SettingsView() {
|
|||||||
|
|
||||||
view.append(
|
view.append(
|
||||||
el('header', { class: 'view__header' }, el('h1', {}, 'Settings')),
|
el('header', { class: 'view__header' }, el('h1', {}, 'Settings')),
|
||||||
el(
|
el('div', { class: 'settings__group' }, themeField, accentField, spriteField, shinyField, teamNavField),
|
||||||
'div',
|
|
||||||
{ class: 'settings__group' },
|
|
||||||
themeField,
|
|
||||||
accentField,
|
|
||||||
fontSizeField,
|
|
||||||
reduceMotionField,
|
|
||||||
spriteField,
|
|
||||||
shinyField,
|
|
||||||
hapticsField,
|
|
||||||
teamNavField,
|
|
||||||
defaultRouteField,
|
|
||||||
),
|
|
||||||
|
|
||||||
el('h2', {}, 'Your data'),
|
el('h2', {}, 'Your data'),
|
||||||
storageNote,
|
storageNote,
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user