- 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
264 lines
7.6 KiB
JavaScript
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);
|
|
}
|