- Feed header: the game pill is gone (the nav Games button opens the sheet); the active game is now a quiet text label under the title - Settings > Theme: add Black (OLED) and Sepia alongside System/Light/Dark - Settings > Accent colour: six swatches (red default, blue, green, amber, violet, rose) layered over any theme via [data-accent]; persisted - applyTheme() takes the settings object; system theme now keys off the absence of [data-theme] so explicit light/sepia don't fight the media query Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
176 lines
5.3 KiB
JavaScript
176 lines
5.3 KiB
JavaScript
import { el, onTeardown } from '../lib/dom.js';
|
|
import { settings, applyTheme } from '../store/settings.js';
|
|
import { selection } from '../store/selection.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 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 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.');
|
|
}
|
|
},
|
|
});
|
|
|
|
view.append(
|
|
el('header', { class: 'view__header' }, el('h1', {}, 'Settings')),
|
|
el('div', { class: 'settings__group' }, themeField, accentField, spriteField, shinyField),
|
|
|
|
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, () => {});
|
|
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);
|
|
}
|