dex/src/views/SettingsView.js
chris 61ceb6abf3 Declutter feed header; add theme + accent options
- 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>
2026-08-27 13:24:48 -04:00

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