Compare commits
3 Commits
78df1a32e8
...
f71f1842cd
| Author | SHA1 | Date | |
|---|---|---|---|
| f71f1842cd | |||
| d275117569 | |||
| 840031c48a |
@ -92,19 +92,19 @@ Working app with a type-themed UI:
|
||||
snapshot; a pill switcher on the detail page rebuilds types / stats /
|
||||
matchups / abilities / learnset / artwork for the chosen form. Purely
|
||||
cosmetic variants (Unown's 27 letters, Vivillon's 19 patterns, Furfrou
|
||||
trims, seasonal Deerling/Sawsbuck, Flabébé line colours, Alcremie's 62
|
||||
trims, seasonal Deerling/Sawsbuck, Flabébé line colors, Alcremie's 62
|
||||
decorations, Pikachu caps — 278 across 50 species) get a separate compact
|
||||
"Appearance" dropdown that only swaps the sprite. Cards show a "+N forms"
|
||||
badge.
|
||||
- **Detail** — type-gradient hero + tabbed sheet (About / Stats / Evolution /
|
||||
Moves / Locations); coloured animated stat bars; defensive type matchups;
|
||||
Moves / Locations); colored animated stat bars; defensive type matchups;
|
||||
evolution chain re-parented to the selected game's generation; learnset with
|
||||
per-move power/type/accuracy/PP/effect (era-accurate via `past_values`,
|
||||
incl. pre-Gen-4 physical/special-by-type), the TM/HM group ordered by
|
||||
TM number for the selected game; wild encounter locations.
|
||||
- **Game-aware** — abilities gated to Gen 3+ (hidden to Gen 5+); type chart
|
||||
applies Gen 1 / pre-Gen 6 rules.
|
||||
- **Games** — overlay picker with stylised version-colour cover tiles,
|
||||
- **Games** — overlay picker with stylised version-color cover tiles,
|
||||
sub-dex switch, and an "All games" (National, no gen limits) option.
|
||||
- **Search** — tabbed lookup: Pokémon, Moves, Items and Abilities, all
|
||||
browsable offline from the snapshot. Moves filter by type / damage class
|
||||
@ -133,7 +133,7 @@ Working app with a type-themed UI:
|
||||
chart (tap a type for its offensive + defensive breakdown; both
|
||||
gen-aware).
|
||||
- **Settings** — theme (System / Light / Dark / Black / Sepia) + accent
|
||||
colour, text size, an in-app reduce-motion override (on top of the OS
|
||||
color, text size, an in-app reduce-motion override (on top of the OS
|
||||
preference, which is always respected too), sprite style, haptic
|
||||
feedback on catch (Vibration API), which screen to land on at launch,
|
||||
JSON export/import.
|
||||
|
||||
@ -10,7 +10,7 @@ import { prettify } from '../data/pokedex-resolver.js';
|
||||
|
||||
/**
|
||||
* One Pokémon in the dex feed. Tinted by its primary type: a soft top-down
|
||||
* gradient, a type-coloured spotlight behind an oversized sprite that lifts
|
||||
* gradient, a type-colored spotlight behind an oversized sprite that lifts
|
||||
* above the card, a number chip, and a big ghost number in the corner.
|
||||
*/
|
||||
export function Card(species, number, { spriteStyle = 'official', versionGroup, gen = 9, boxed = false, metric = null } = {}) {
|
||||
|
||||
@ -38,7 +38,12 @@ export function ProgressRing() {
|
||||
const num = el('span', { class: 'ring__num' }, '0');
|
||||
const tail = el('small', {});
|
||||
const value = el('span', { class: 'ring__value' }, num, tail);
|
||||
const node = el('div', { class: 'ring' }, svg, el('div', { class: 'ring__label' }, value));
|
||||
const node = el(
|
||||
'a',
|
||||
{ class: 'ring', href: '#/progress', title: 'Progress by game', 'aria-label': 'Progress by game' },
|
||||
svg,
|
||||
el('div', { class: 'ring__label' }, value),
|
||||
);
|
||||
|
||||
let first = true;
|
||||
let shown = 0;
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Signature colours for each game, used to render a stylised "cover" tile in
|
||||
* Signature colors 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
|
||||
* colors = the paired versions; games without a listed entry fall back to
|
||||
* a per-generation hue.
|
||||
*/
|
||||
const GAME = {
|
||||
|
||||
60
src/lib/dialog.js
Normal file
60
src/lib/dialog.js
Normal file
@ -0,0 +1,60 @@
|
||||
import { el } from './dom.js';
|
||||
|
||||
/**
|
||||
* A small centered modal with one or more choices. Resolves to the chosen
|
||||
* option's `key`, or `null` if dismissed (backdrop, Escape, or a choice
|
||||
* whose key is null).
|
||||
*
|
||||
* const pick = await chooseDialog({
|
||||
* title: 'Import save',
|
||||
* body: '84 seen, 40 caught.',
|
||||
* choices: [
|
||||
* { key: 'merge', label: 'Add to my Pokédex' },
|
||||
* { key: 'replace', label: 'Replace mine', class: 'button--danger' },
|
||||
* { key: null, label: 'Cancel', class: 'button--ghost' },
|
||||
* ],
|
||||
* });
|
||||
*/
|
||||
export function chooseDialog({ title, body, choices }) {
|
||||
return new Promise((resolve) => {
|
||||
const backdrop = el('div', {
|
||||
class: 'sheet-backdrop dialog-backdrop is-open',
|
||||
onclick: (e) => {
|
||||
if (e.target === backdrop) done(null);
|
||||
},
|
||||
});
|
||||
const panel = el(
|
||||
'div',
|
||||
{ class: 'dialog', role: 'dialog', 'aria-modal': 'true' },
|
||||
el('h2', { class: 'dialog__title' }, title),
|
||||
body ? el('p', { class: 'dialog__body' }, body) : null,
|
||||
el(
|
||||
'div',
|
||||
{ class: 'dialog__actions' },
|
||||
...choices.map((c) =>
|
||||
el(
|
||||
'button',
|
||||
{
|
||||
class: `button ${c.class || ''}`.trim(),
|
||||
type: 'button',
|
||||
onclick: () => done(c.key ?? null),
|
||||
},
|
||||
c.label,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
const onKey = (e) => {
|
||||
if (e.key === 'Escape') done(null);
|
||||
};
|
||||
function done(key) {
|
||||
document.removeEventListener('keydown', onKey);
|
||||
backdrop.remove();
|
||||
resolve(key);
|
||||
}
|
||||
backdrop.append(panel);
|
||||
document.body.append(backdrop);
|
||||
document.addEventListener('keydown', onKey);
|
||||
requestAnimationFrame(() => panel.querySelector('.button')?.focus());
|
||||
});
|
||||
}
|
||||
@ -1,5 +1,5 @@
|
||||
// Resolve a Pokémon type to its hex colour by reading the --type-* CSS
|
||||
// custom properties once and memoising. Type colours don't change between
|
||||
// Resolve a Pokémon type to its hex color by reading the --type-* CSS
|
||||
// custom properties once and memoising. Type colors do not change between
|
||||
// light and dark, so the cache is safe for the session.
|
||||
const TYPES = [
|
||||
'normal', 'fire', 'water', 'electric', 'grass', 'ice', 'fighting', 'poison',
|
||||
|
||||
@ -60,6 +60,29 @@ export function importFlags({ seen = [], caught = [] } = {}) {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Set seen/caught to exactly the given lists — favourites and notes are
|
||||
* kept, everything else's seen/caught is cleared. For "replace with this
|
||||
* save" imports.
|
||||
*/
|
||||
export function replaceFlags({ seen = [], caught = [] } = {}) {
|
||||
selection.set((s) => {
|
||||
const stamp = new Date().toISOString();
|
||||
const pokemon = {};
|
||||
for (const [id, e] of Object.entries(s.pokemon)) {
|
||||
if (e.favorite || (e.note && e.note.trim())) {
|
||||
pokemon[id] = { seen: false, caught: false, favorite: !!e.favorite, note: e.note || '', updatedAt: e.updatedAt || stamp };
|
||||
}
|
||||
}
|
||||
const bump = (id, patch) => {
|
||||
pokemon[id] = { ...(pokemon[id] || BLANK), ...patch, updatedAt: stamp };
|
||||
};
|
||||
for (const id of seen) bump(id, { seen: true });
|
||||
for (const id of caught) bump(id, { seen: true, caught: true });
|
||||
return { ...s, pokemon };
|
||||
});
|
||||
}
|
||||
|
||||
/** Aggregate seen/caught counts over an arbitrary list of national ids. */
|
||||
export function stats(speciesIds) {
|
||||
const p = selection.get().pokemon;
|
||||
|
||||
@ -233,6 +233,17 @@
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
flex: none;
|
||||
display: block;
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
border-radius: 50%;
|
||||
transition: transform 0.12s var(--ease-spring);
|
||||
}
|
||||
.ring:hover {
|
||||
transform: scale(1.06);
|
||||
}
|
||||
.ring:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
.ring__svg {
|
||||
width: 100%;
|
||||
@ -636,7 +647,7 @@
|
||||
text-transform: capitalize;
|
||||
}
|
||||
|
||||
/* Cover tiles: a clean split of the two version colours, no gloss. */
|
||||
/* Cover tiles: a clean split of the two version colors, no gloss. */
|
||||
.gamecard--cover {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
@ -1594,6 +1605,16 @@
|
||||
.settings__install {
|
||||
margin: 10px 0 0;
|
||||
}
|
||||
.settings__save {
|
||||
margin: 10px 0 0;
|
||||
}
|
||||
.settings__save .button {
|
||||
display: inline-flex;
|
||||
}
|
||||
.settings__save .settings__note {
|
||||
margin: 6px 0 0;
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@ -3366,3 +3387,44 @@
|
||||
animation-duration: 0.34s;
|
||||
animation-timing-function: var(--ease-spring);
|
||||
}
|
||||
|
||||
/* ---- Centered modal dialog ---------------------------------- */
|
||||
.dialog-backdrop {
|
||||
align-items: center;
|
||||
padding: 20px;
|
||||
}
|
||||
.dialog {
|
||||
width: 100%;
|
||||
max-width: 420px;
|
||||
background: var(--surface);
|
||||
border-radius: var(--radius);
|
||||
box-shadow: var(--shadow-lg);
|
||||
padding: 20px;
|
||||
transform: translateY(12px) scale(0.97);
|
||||
opacity: 0;
|
||||
transition: transform 0.2s var(--ease-spring), opacity 0.2s ease;
|
||||
}
|
||||
.dialog-backdrop.is-open .dialog {
|
||||
transform: none;
|
||||
opacity: 1;
|
||||
}
|
||||
.dialog__title {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
.dialog__body {
|
||||
margin: 8px 0 0;
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-dim);
|
||||
line-height: 1.5;
|
||||
}
|
||||
.dialog__actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin-top: 18px;
|
||||
}
|
||||
.dialog__actions .button {
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import { el, onTeardown } from '../lib/dom.js';
|
||||
import { settings, applyTheme } from '../store/settings.js';
|
||||
import { selection, importFlags } from '../store/selection.js';
|
||||
import { selection, importFlags, replaceFlags } from '../store/selection.js';
|
||||
import { chooseDialog } from '../lib/dialog.js';
|
||||
import { team } from '../store/team.js';
|
||||
import { formTracking } from '../store/formTracking.js';
|
||||
import { shinyHunts } from '../store/shinyHunts.js';
|
||||
@ -50,7 +51,7 @@ export async function SettingsView() {
|
||||
}),
|
||||
),
|
||||
);
|
||||
const accentField = el('div', { class: 'field' }, el('span', {}, 'Accent colour'), accentRow);
|
||||
const accentField = el('div', { class: 'field' }, el('span', {}, 'Accent color'), accentRow);
|
||||
|
||||
const fontSizeField = selectField('Text size', st.fontScale || 'default', [
|
||||
['small', 'Small'],
|
||||
@ -123,7 +124,7 @@ export async function SettingsView() {
|
||||
storageNote.textContent = 'Storage estimate not available in this browser.';
|
||||
}
|
||||
|
||||
// Import the Pokédex out of a Gen 3 GBA save (.sav / .srm).
|
||||
// Import the Pokédex out of a Gen 1/2/3 game save (.sav / .srm).
|
||||
const saveNote = el('p', { class: 'settings__note' });
|
||||
const saveInput = el('input', {
|
||||
type: 'file',
|
||||
@ -133,21 +134,32 @@ export async function SettingsView() {
|
||||
const file = e.target.files[0];
|
||||
if (!file) return;
|
||||
saveNote.textContent = 'Reading…';
|
||||
let dex;
|
||||
try {
|
||||
const dex = parseSaveDex(await file.arrayBuffer());
|
||||
const who = dex.trainer || dex.game;
|
||||
if (
|
||||
confirm(
|
||||
`${dex.game}${dex.trainer ? ` — ${dex.trainer}` : ''}\n${dex.seen.length} seen, ${dex.caught.length} caught.\n\nMerge into your Pokédex? Nothing is removed.`,
|
||||
)
|
||||
) {
|
||||
importFlags({ seen: dex.seen, caught: dex.caught });
|
||||
saveNote.textContent = `Merged ${dex.caught.length} caught / ${dex.seen.length} seen from ${who}.`;
|
||||
} else {
|
||||
saveNote.textContent = '';
|
||||
}
|
||||
dex = parseSaveDex(await file.arrayBuffer());
|
||||
} catch (err) {
|
||||
saveNote.textContent = err.message || 'Could not read that save file.';
|
||||
e.target.value = '';
|
||||
return;
|
||||
}
|
||||
const who = dex.trainer || dex.game;
|
||||
const choice = await chooseDialog({
|
||||
title: `${dex.game}${dex.trainer ? ` · ${dex.trainer}` : ''}`,
|
||||
body: `${dex.seen.length} seen · ${dex.caught.length} caught in this save.`,
|
||||
choices: [
|
||||
{ key: 'merge', label: 'Add to my Pokédex' },
|
||||
{ key: 'replace', label: 'Replace mine with this', class: 'button--danger' },
|
||||
{ key: null, label: 'Cancel', class: 'button--ghost' },
|
||||
],
|
||||
});
|
||||
if (choice === 'merge') {
|
||||
importFlags({ seen: dex.seen, caught: dex.caught });
|
||||
saveNote.textContent = `Added ${dex.caught.length} caught / ${dex.seen.length} seen from ${who}.`;
|
||||
} else if (choice === 'replace') {
|
||||
replaceFlags({ seen: dex.seen, caught: dex.caught });
|
||||
saveNote.textContent = `Replaced tracking with ${who}'s Pokédex (favourites and notes kept).`;
|
||||
} else {
|
||||
saveNote.textContent = '';
|
||||
}
|
||||
e.target.value = '';
|
||||
},
|
||||
@ -227,10 +239,10 @@ export async function SettingsView() {
|
||||
el('a', { class: 'link', href: '#/shiny' }, 'Shiny hunts'),
|
||||
),
|
||||
el(
|
||||
'p',
|
||||
{ class: 'settings__note' },
|
||||
'div',
|
||||
{ class: 'settings__save' },
|
||||
el('button', { class: 'button', type: 'button', onclick: () => saveInput.click() }, 'Import from a game save'),
|
||||
' — a .sav / .srm from Gen 1 (R/B/Y), Gen 2 (G/S/C) or Gen 3 (R/S/E, FR/LG). Reads the game’s own Pokédex; merges seen/caught, removes nothing.',
|
||||
el('p', { class: 'settings__note' }, 'A .sav / .srm from a Gen 1–3 game.'),
|
||||
),
|
||||
saveInput,
|
||||
saveNote,
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user