diff --git a/README.md b/README.md index ad2e665..ed1c03a 100644 --- a/README.md +++ b/README.md @@ -127,12 +127,13 @@ Working app with a type-themed UI: feedback on catch (Vibration API), which screen to land on at launch, JSON export/import. - **PWA** — Workbox SW: precache shell + snapshot, SWR for API JSON, - cache-first LRU for sprites, in-app update toast. + cache-first LRU for sprites, in-app update toast. Installable: PNG + + maskable icons, `beforeinstallprompt` captured for an in-app "Install" + offer (toast + Settings button), manifest shortcuts to Team / Search / + Type chart. -Not yet done: version-exclusive badges, type/generation filters on the grid, -an interactive region map (PokéAPI has no map imagery or coordinates), -skeleton loaders, install-prompt handling, PNG/maskable raster icons -(currently SVG only), and a Lighthouse PWA pass. +Not yet done: version-exclusive badges, an interactive region map (PokéAPI +has no map imagery or coordinates), and a formal Lighthouse pass. ## Notes diff --git a/index.html b/index.html index 087ca3a..539d267 100644 --- a/index.html +++ b/index.html @@ -12,7 +12,10 @@ content="A responsive, offline-capable Pokédex reference powered by PokéAPI." /> - + + + + Pokédex diff --git a/public/apple-touch-icon.png b/public/apple-touch-icon.png new file mode 100644 index 0000000..728b27e Binary files /dev/null and b/public/apple-touch-icon.png differ diff --git a/public/pwa-192.png b/public/pwa-192.png new file mode 100644 index 0000000..b7c4552 Binary files /dev/null and b/public/pwa-192.png differ diff --git a/public/pwa-512.png b/public/pwa-512.png new file mode 100644 index 0000000..38df92d Binary files /dev/null and b/public/pwa-512.png differ diff --git a/public/pwa-maskable-512.png b/public/pwa-maskable-512.png new file mode 100644 index 0000000..00e1b7e Binary files /dev/null and b/public/pwa-maskable-512.png differ diff --git a/src/lib/install.js b/src/lib/install.js new file mode 100644 index 0000000..ceff0a5 --- /dev/null +++ b/src/lib/install.js @@ -0,0 +1,48 @@ +/** + * Home-screen install: captures the browser's `beforeinstallprompt` so the + * app can offer installation on its own terms (a toast, a Settings button) + * instead of relying on the browser's mini-infobar. + * + * Import this early — the event fires once, soon after load. + */ +let deferred = null; +const listeners = new Set(); +const notify = () => { + for (const fn of listeners) fn(!!deferred); +}; + +if (typeof window !== 'undefined') { + window.addEventListener('beforeinstallprompt', (e) => { + e.preventDefault(); + deferred = e; + notify(); + }); + window.addEventListener('appinstalled', () => { + deferred = null; + notify(); + }); +} + +export function canInstall() { + return !!deferred; +} + +export function onInstallChange(fn) { + listeners.add(fn); + return () => listeners.delete(fn); +} + +/** Show the native install dialog. Resolves true if the user accepted. */ +export async function promptInstall() { + if (!deferred) return false; + deferred.prompt(); + let outcome = 'dismissed'; + try { + ({ outcome } = await deferred.userChoice); + } catch { + /* ignore */ + } + deferred = null; + notify(); + return outcome === 'accepted'; +} diff --git a/src/main.js b/src/main.js index 715752b..dde87fd 100644 --- a/src/main.js +++ b/src/main.js @@ -7,6 +7,7 @@ import { initRouter, rerender } from './router.js'; import { Nav } from './components/Nav.js'; import { settings, applyTheme } from './store/settings.js'; import { ui } from './store/ui.js'; +import { onInstallChange, promptInstall } from './lib/install.js'; applyTheme(); @@ -58,6 +59,16 @@ const updateSW = registerSW({ }, }); +// --- Install prompt: offer it once, unobtrusively --------------------- +let installOffered = false; +onInstallChange((can) => { + if (!can || installOffered) return; + installOffered = true; + showToast('Install this Pokédex for offline, full-screen use?', 'Install', () => { + promptInstall(); + }); +}); + // --- Offline indicator ------------------------------------------------- const netPill = el('div', { class: 'netpill', role: 'status' }, 'Offline'); function syncNet() { diff --git a/src/styles/layout.css b/src/styles/layout.css index 157d356..7056722 100644 --- a/src/styles/layout.css +++ b/src/styles/layout.css @@ -1591,6 +1591,9 @@ .settings__actions { flex-flow: row wrap; } +.settings__install { + margin: 10px 0 0; +} .field { display: flex; flex-direction: column; diff --git a/src/views/SettingsView.js b/src/views/SettingsView.js index 51a88e6..bcdc499 100644 --- a/src/views/SettingsView.js +++ b/src/views/SettingsView.js @@ -1,6 +1,7 @@ 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' }); @@ -136,8 +137,36 @@ export async function SettingsView() { }, }); + // 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' }, @@ -196,7 +225,9 @@ export async function SettingsView() { ), ); - onTeardown(view, () => {}); + onTeardown(view, () => { + offInstall(); + }); return view; } diff --git a/vite.config.js b/vite.config.js index 63fff09..7b3d5a9 100644 --- a/vite.config.js +++ b/vite.config.js @@ -21,7 +21,11 @@ export default defineConfig({ // The bundled PokéAPI snapshot can be a few hundred KB. maximumFileSizeToCacheInBytes: 5 * 1024 * 1024, }, - includeAssets: ['icon.svg', 'favicon.svg'], + includeAssets: [ + 'icon.svg', + 'favicon.svg', + 'apple-touch-icon.png', + ], manifest: { name: 'Pokédex', short_name: 'Pokédex', @@ -35,11 +39,34 @@ export default defineConfig({ theme_color: '#b3161a', categories: ['games', 'reference', 'utilities'], icons: [ + { src: 'icon.svg', sizes: 'any', type: 'image/svg+xml', purpose: 'any' }, + { src: 'pwa-192.png', sizes: '192x192', type: 'image/png', purpose: 'any' }, + { src: 'pwa-512.png', sizes: '512x512', type: 'image/png', purpose: 'any' }, { - src: 'icon.svg', - sizes: 'any', - type: 'image/svg+xml', - purpose: 'any maskable', + src: 'pwa-maskable-512.png', + sizes: '512x512', + type: 'image/png', + purpose: 'maskable', + }, + ], + shortcuts: [ + { + name: 'Team builder', + short_name: 'Team', + url: './#/team', + icons: [{ src: 'pwa-192.png', sizes: '192x192' }], + }, + { + name: 'Search', + short_name: 'Search', + url: './#/search', + icons: [{ src: 'pwa-192.png', sizes: '192x192' }], + }, + { + name: 'Type chart', + short_name: 'Types', + url: './#/types', + icons: [{ src: 'pwa-192.png', sizes: '192x192' }], }, ], },