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); }