From 840031c48afc61c30b6263e6c7636d9e0e9d73f3 Mon Sep 17 00:00:00 2001 From: chris Date: Thu, 10 Sep 2026 09:52:26 -0400 Subject: [PATCH] Import: ask merge vs replace; make the feed progress ring a link MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Save import now opens a small choice dialog (new lib/dialog.js) instead of a confirm(): "Add to my Pokédex" (merge, keeps everything) or "Replace mine with this". Replace resets seen/caught to exactly the save but keeps favourites and notes (new selection.replaceFlags). The dialog also explains that caught state is shared across games, so an imported save only ever adds species that game has and they count toward the National Dex too — no per-game caught flag, by design. - The dex-feed progress ring is now an with a hover/press nudge — tapping it opens the per-game breakdown. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_017Ve7HLspzeG2xDPtJQ8vmu --- src/components/ProgressRing.js | 7 +++- src/lib/dialog.js | 60 ++++++++++++++++++++++++++++++++++ src/store/selection.js | 23 +++++++++++++ src/styles/layout.css | 52 +++++++++++++++++++++++++++++ src/views/SettingsView.js | 42 ++++++++++++++++-------- 5 files changed, 169 insertions(+), 15 deletions(-) create mode 100644 src/lib/dialog.js diff --git a/src/components/ProgressRing.js b/src/components/ProgressRing.js index 3278f25..27cd086 100644 --- a/src/components/ProgressRing.js +++ b/src/components/ProgressRing.js @@ -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; diff --git a/src/lib/dialog.js b/src/lib/dialog.js new file mode 100644 index 0000000..41c7eb7 --- /dev/null +++ b/src/lib/dialog.js @@ -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()); + }); +} diff --git a/src/store/selection.js b/src/store/selection.js index d06064b..a484e13 100644 --- a/src/store/selection.js +++ b/src/store/selection.js @@ -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; diff --git a/src/styles/layout.css b/src/styles/layout.css index 7d96f37..3c91496 100644 --- a/src/styles/layout.css +++ b/src/styles/layout.css @@ -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%; @@ -3366,3 +3377,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; +} diff --git a/src/views/SettingsView.js b/src/views/SettingsView.js index 35f9514..7821c1e 100644 --- a/src/views/SettingsView.js +++ b/src/views/SettingsView.js @@ -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'; @@ -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,34 @@ 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 (${dex.caught.length} of ${ + [151, 251, 386][dex.gen - 1] + } for the game). Your caught record is shared across every game — imported species also count toward the National Dex and any other game they appear in.`, + 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 = ''; },