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 = ''; },