dex/src/views/SettingsView.js
chris 490ad9b7f1 PWA polish: PNG/maskable icons, install prompt, manifest shortcuts
- Rasterised the SVG to pwa-192 / pwa-512 / pwa-maskable-512 (logo in the
  safe zone, full-bleed background) and a real apple-touch-icon.png;
  manifest now lists PNG "any" + a dedicated "maskable".
- Manifest shortcuts: Team, Search, Type chart.
- lib/install.js captures beforeinstallprompt so the app can offer
  installation itself — a one-time toast, and an "Install app" button in
  Settings (falls back to a "use your browser menu" hint where the event
  never fires, e.g. iOS).
- index.html: apple-touch-icon PNG + apple-mobile-web-app meta.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017Ve7HLspzeG2xDPtJQ8vmu
2026-09-09 18:18:19 -04:00

264 lines
7.6 KiB
JavaScript

import { el, onTeardown } from '../lib/dom.js';
import { settings, applyTheme } from '../store/settings.js';
import { selection } from '../store/selection.js';
import { canInstall, onInstallChange, promptInstall } from '../lib/install.js';
export async function SettingsView() {
const view = el('section', { class: 'view settings' });
const st = settings.get();
const themeField = selectField('Theme', st.theme, [
['system', 'System'],
['light', 'Light'],
['dark', 'Dark'],
['black', 'Black (OLED)'],
['sepia', 'Sepia'],
], (v) => {
settings.set({ theme: v });
applyTheme();
});
const ACCENTS = [
['red', '#b3161a'],
['blue', '#2f6fed'],
['green', '#1f9d57'],
['amber', '#d98a00'],
['violet', '#7b52e0'],
['rose', '#e0417a'],
];
const accentRow = el(
'div',
{ class: 'swatches' },
...ACCENTS.map(([key, hex]) =>
el('button', {
class: `swatch${st.accent === key ? ' is-on' : ''}`,
type: 'button',
style: `--sw:${hex}`,
title: key,
'aria-label': `${key} accent`,
onclick: () => {
settings.set({ accent: key });
applyTheme();
[...accentRow.children].forEach((b, i) =>
b.classList.toggle('is-on', ACCENTS[i][0] === key),
);
},
}),
),
);
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, [
['default', 'Pixel (modern)'],
['game', 'Game era (pixel art from the selected game)'],
['official', 'Official artwork'],
['home', 'Pokémon HOME'],
], (v) => settings.set({ spriteStyle: v }));
const shinyField = el('label', { class: 'field field--check' },
el('input', {
type: 'checkbox',
checked: st.showShiny,
onchange: (e) => settings.set({ showShiny: e.target.checked }),
}),
'Default detail view to shiny sprites',
);
const teamNavField = el('label', { class: 'field field--check' },
el('input', {
type: 'checkbox',
checked: st.showTeamNav !== false,
onchange: (e) => settings.set({ showTeamNav: e.target.checked }),
}),
'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…');
if (navigator.storage?.estimate) {
navigator.storage.estimate().then(({ usage = 0, quota = 0 }) => {
const mb = (n) => (n / 1024 / 1024).toFixed(1);
storageNote.textContent = `Using ${mb(usage)} MB of ~${mb(quota)} MB available (caches + tracking data).`;
});
} else {
storageNote.textContent = 'Storage estimate not available in this browser.';
}
const importInput = el('input', {
type: 'file',
accept: 'application/json',
style: 'display:none',
onchange: async (e) => {
const file = e.target.files[0];
if (!file) return;
try {
const data = JSON.parse(await file.text());
if (data.settings) settings.replace(data.settings);
if (data.selection) selection.replace(data.selection);
alert('Import complete.');
} catch {
alert('That file could not be read as a Pokédex backup.');
}
},
});
// Install (only when the browser has offered it and the app isn't
// already installed).
const installRow = el('div', { class: 'settings__install' });
function syncInstall() {
installRow.replaceChildren(
canInstall()
? el(
'button',
{
class: 'button',
type: 'button',
onclick: () => promptInstall(),
},
'Install app',
)
: el(
'p',
{ class: 'settings__note' },
window.matchMedia('(display-mode: standalone)').matches
? 'Running as an installed app.'
: 'Use your browser menu to add this Pokédex to your home screen.',
),
);
}
syncInstall();
const offInstall = onInstallChange(syncInstall);
view.append(
el('header', { class: 'view__header' }, el('h1', {}, 'Settings')),
installRow,
el(
'div',
{ class: 'settings__group' },
themeField,
accentField,
fontSizeField,
reduceMotionField,
spriteField,
shinyField,
hapticsField,
teamNavField,
defaultRouteField,
),
el('h2', {}, 'Your data'),
storageNote,
el('div', { class: 'settings__actions' },
el('button', {
class: 'button',
type: 'button',
onclick: () => exportData(),
}, 'Export backup'),
el('button', {
class: 'button',
type: 'button',
onclick: () => importInput.click(),
}, 'Import backup'),
importInput,
el('button', {
class: 'button button--danger',
type: 'button',
onclick: () => {
if (confirm('Clear all seen/caught/favorite tracking? This cannot be undone.')) {
selection.replace({ version: 2, pokemon: {} });
}
},
}, 'Clear tracking data'),
el('button', {
class: 'button button--danger',
type: 'button',
onclick: async () => {
if (!('caches' in window)) return;
if (confirm('Clear cached PokéAPI data and sprites? They will re-download when online.')) {
for (const key of await caches.keys()) await caches.delete(key);
alert('Caches cleared.');
}
},
}, 'Clear cached API data'),
),
el('h2', {}, 'About'),
el('p', { class: 'settings__note' },
'Data from ',
el('a', { href: 'https://pokeapi.co/', class: 'link', target: '_blank', rel: 'noreferrer' }, 'PokéAPI'),
'. Tracking data and preferences are stored locally in your browser.',
),
);
onTeardown(view, () => {
offInstall();
});
return view;
}
function selectField(label, value, options, onChange) {
const select = el(
'select',
{ onchange: (e) => onChange(e.target.value) },
...options.map(([v, text]) =>
el('option', { value: v, selected: v === value }, text),
),
);
return el('label', { class: 'field' }, el('span', {}, label), select);
}
function exportData() {
const payload = {
exportedAt: new Date().toISOString(),
settings: settings.get(),
selection: selection.get(),
};
const blob = new Blob([JSON.stringify(payload, null, 2)], {
type: 'application/json',
});
const url = URL.createObjectURL(blob);
const a = el('a', {
href: url,
download: `pokedex-backup-${new Date().toISOString().slice(0, 10)}.json`,
});
document.body.append(a);
a.click();
a.remove();
setTimeout(() => URL.revokeObjectURL(url), 1000);
}