feat(pwa): make the app installable

Manifest, gamepad icon (also the new favicon, instead of
the Vite logo), iOS meta tags and the tab title
ToBePlayed. No service worker, so a deploy never meets
an old cached bundle. The title bar color follows
light and dark mode. The README also lists adding a
game by name.
This commit is contained in:
lobo committed 2026-09-25 15:32:55 +02:00
1 parent 444b03acee
commit f8e6c2c970
12 files changed
+117 -3

No files matched your search

+3 -1
View File
@@ -26,6 +26,8 @@ To Be Played is split into three repositories:
priority first, reroll without repeats, and "Start playing" in one click. priority first, reroll without repeats, and "Start playing" in one click.
- **Library**: status filters, "short games" filter (up to 10 h), search, sorting by playtime, recently seen, newest - **Library**: status filters, "short games" filter (up to 10 h), search, sorting by playtime, recently seen, newest
in library, rating or priority, paging. Each row shows the unlocked achievements. in library, rating or priority, paging. Each row shows the unlocked achievements.
- **Add a game by name** for games outside Steam (PlayStation, Switch, Xbox, physical): search IGDB, pick a hit,
a platform and optional hours. Works without a Steam ID.
- **Game detail**: status, priority, 1–5 star rating, notes, achievements, time to beat (story / extras / 100 %), - **Game detail**: status, priority, 1–5 star rating, notes, achievements, time to beat (story / extras / 100 %),
a hint when Steam shows recent playtime for a game still in the backlog, and one to set the status to 100 % when a hint when Steam shows recent playtime for a game still in the backlog, and one to set the status to 100 % when
every achievement is unlocked. every achievement is unlocked.
@@ -34,6 +36,7 @@ To Be Played is split into three repositories:
- **Appearance**: accent color, corner style, wide layout, page background and banner from your own game art. - **Appearance**: accent color, corner style, wide layout, page background and banner from your own game art.
Saved in Auth, so it follows you to every device. Saved in Auth, so it follows you to every device.
- German and English, dark mode, mobile layout, session-expired message instead of a silent logout. - German and English, dark mode, mobile layout, session-expired message instead of a silent logout.
- **Installable** as an app on phone and desktop (web app manifest, no service worker, so no offline mode).
- **Session**: the tokens are HttpOnly cookies that no script can read. `apiFetch` renews an expired access token - **Session**: the tokens are HttpOnly cookies that no script can read. `apiFetch` renews an expired access token
once and sends the request again; parallel requests and other tabs share one refresh. once and sends the request again; parallel requests and other tabs share one refresh.
@@ -88,7 +91,6 @@ npm run build
- Mood filter for "What to play next". - Mood filter for "What to play next".
- A Content Security Policy in `nginx.conf`. - A Content Security Policy in `nginx.conf`.
- Installable PWA.
## License ## License
+8 -1
View File
@@ -3,8 +3,15 @@
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="manifest" href="/manifest.webmanifest" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<meta name="theme-color" content="#09090b" />
<meta name="apple-mobile-web-app-title" content="TBP" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Game Log</title> <title>ToBePlayed</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root"></div>
+6
View File
@@ -7,6 +7,12 @@ server {
add_header Cache-Control "public, max-age=31536000, immutable"; add_header Cache-Control "public, max-age=31536000, immutable";
} }
# Not in every nginx mime.types; browsers expect this type for the PWA manifest.
location = /manifest.webmanifest {
types { application/manifest+json webmanifest; }
add_header Cache-Control "no-cache";
}
# index.html and /i18n/*.json keep their names across deploys. Without # index.html and /i18n/*.json keep their names across deploys. Without
# no-cache the browser shows old translation keys or loads an old bundle. # no-cache the browser shows old translation keys or loads an old bundle.
location / { location / {
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

+10 -1
View File
@@ -1 +1,10 @@
<svg xmlns="http://www.w3.org/2000/svg" width="48" height="46" fill="none" viewBox="0 0 48 46"><path fill="#863bff" d="M25.946 44.938c-.664.845-2.021.375-2.021-.698V33.937a2.26 2.26 0 0 0-2.262-2.262H10.287c-.92 0-1.456-1.04-.92-1.788l7.48-10.471c1.07-1.497 0-3.578-1.842-3.578H1.237c-.92 0-1.456-1.04-.92-1.788L10.013.474c.214-.297.556-.474.92-.474h28.894c.92 0 1.456 1.04.92 1.788l-7.48 10.471c-1.07 1.498 0 3.579 1.842 3.579h11.377c.943 0 1.473 1.088.89 1.83L25.947 44.94z" style="fill:#863bff;fill:color(display-p3 .5252 .23 1);fill-opacity:1"/><mask id="a" width="48" height="46" x="0" y="0" maskUnits="userSpaceOnUse" style="mask-type:alpha"><path fill="#000" d="M25.842 44.938c-.664.844-2.021.375-2.021-.698V33.937a2.26 2.26 0 0 0-2.262-2.262H10.183c-.92 0-1.456-1.04-.92-1.788l7.48-10.471c1.07-1.498 0-3.579-1.842-3.579H1.133c-.92 0-1.456-1.04-.92-1.787L9.91.473c.214-.297.556-.474.92-.474h28.894c.92 0 1.456 1.04.92 1.788l-7.48 10.471c-1.07 1.498 0 3.578 1.842 3.578h11.377c.943 0 1.473 1.088.89 1.832L25.843 44.94z" style="fill:#000;fill-opacity:1"/></mask><g mask="url(#a)"><g filter="url(#b)"><ellipse cx="5.508" cy="14.704" fill="#ede6ff" rx="5.508" ry="14.704" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -4.47 31.516)"/></g><g filter="url(#c)"><ellipse cx="10.399" cy="29.851" fill="#ede6ff" rx="10.399" ry="29.851" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -39.328 7.883)"/></g><g filter="url(#d)"><ellipse cx="5.508" cy="30.487" fill="#7e14ff" rx="5.508" ry="30.487" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.814 -25.913 -14.639)scale(1 -1)"/></g><g filter="url(#e)"><ellipse cx="5.508" cy="30.599" fill="#7e14ff" rx="5.508" ry="30.599" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.814 -32.644 -3.334)scale(1 -1)"/></g><g filter="url(#f)"><ellipse cx="5.508" cy="30.599" fill="#7e14ff" rx="5.508" ry="30.599" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="matrix(.00324 1 1 -.00324 -34.34 30.47)"/></g><g filter="url(#g)"><ellipse cx="14.072" cy="22.078" fill="#ede6ff" rx="14.072" ry="22.078" style="fill:#ede6ff;fill:color(display-p3 .9275 .9033 1);fill-opacity:1" transform="rotate(93.35 24.506 48.493)scale(-1 1)"/></g><g filter="url(#h)"><ellipse cx="3.47" cy="21.501" fill="#7e14ff" rx="3.47" ry="21.501" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.009 28.708 47.59)scale(-1 1)"/></g><g filter="url(#i)"><ellipse cx="3.47" cy="21.501" fill="#7e14ff" rx="3.47" ry="21.501" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(89.009 28.708 47.59)scale(-1 1)"/></g><g filter="url(#j)"><ellipse cx=".387" cy="8.972" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(39.51 .387 8.972)"/></g><g filter="url(#k)"><ellipse cx="47.523" cy="-6.092" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 47.523 -6.092)"/></g><g filter="url(#l)"><ellipse cx="41.412" cy="6.333" fill="#47bfff" rx="5.971" ry="9.665" style="fill:#47bfff;fill:color(display-p3 .2799 .748 1);fill-opacity:1" transform="rotate(37.892 41.412 6.333)"/></g><g filter="url(#m)"><ellipse cx="-1.879" cy="38.332" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 -1.88 38.332)"/></g><g filter="url(#n)"><ellipse cx="-1.879" cy="38.332" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 -1.88 38.332)"/></g><g filter="url(#o)"><ellipse cx="35.651" cy="29.907" fill="#7e14ff" rx="4.407" ry="29.108" style="fill:#7e14ff;fill:color(display-p3 .4922 .0767 1);fill-opacity:1" transform="rotate(37.892 35.651 29.907)"/></g><g filter="url(#p)"><ellipse cx="38.418" cy="32.4" fill="#47bfff" rx="5.971" ry="15.297" style="fill:#47bfff;fill:color(display-p3 .2799 .748 1);fill-opacity:1" transform="rotate(37.892 38.418 32.4)"/></g></g><defs><filter id="b" width="60.045" height="41.654" x="-19.77" y="16.149" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="7.659"/></filter><filter id="c" width="90.34" height="51.437" x="-54.613" y="-7.533" color-interpolation-filters="sRGB" filterUnits="userSpaceOnUse"><feFlood flood-opacity="0" result="BackgroundImageFix"/><feBlend in="SourceGraphic" in2="BackgroundImageFix" result="shape"/><feGaussianBlur result="effect1_foregroundBlur_2002_17158" stdDeviation="7.659"/></filtLine truncated <svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
<rect width="512" height="512" rx="112" fill="#7f22fe"/>
<g transform="translate(88 88) scale(14)" fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<line x1="6" x2="10" y1="11" y2="11"/>
<line x1="8" x2="8" y1="9" y2="13"/>
<line x1="15" x2="15.01" y1="12" y2="12"/>
<line x1="18" x2="18.01" y1="10" y2="10"/>
<path d="M17.32 5H6.68a4 4 0 0 0-3.978 3.59c-.006.052-.01.101-.017.152C2.604 9.416 2 14.456 2 16a3 3 0 0 0 3 3c1 0 1.5-.5 2-1l1.414-1.414A2 2 0 0 1 9.828 16h4.344a2 2 0 0 1 1.414.586L17 18c.5.5 1 1 2 1a3 3 0 0 0 3-3c0-1.545-.604-6.584-.685-7.258-.007-.05-.011-.1-.017-.151A4 4 0 0 0 17.32 5z"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 9.3 KiB

After

Width:  |  Height:  |  Size: 773 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

+16
View File
@@ -0,0 +1,16 @@
{
"id": "/",
"name": "ToBePlayed",
"short_name": "TBP",
"description": "Your game backlog: Steam library, games from other platforms, and what to play next.",
"start_url": "/dashboard",
"scope": "/",
"display": "standalone",
"background_color": "#09090b",
"theme_color": "#09090b",
"icons": [
{"src": "/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any"},
{"src": "/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any"},
{"src": "/icon-maskable-512.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable"}
]
}
+4
View File
@@ -11,6 +11,8 @@ import {
} from '../utils/appearance.js'; } from '../utils/appearance.js';
const DARK_MODE_STORAGE_KEY = 'tbp_dark_mode'; const DARK_MODE_STORAGE_KEY = 'tbp_dark_mode';
// The --background of style.css; the installed app paints its title bar in this color.
const THEME_COLORS = {dark: '#09090b', light: '#fafafa'};
const PreferencesContext = createContext(null); const PreferencesContext = createContext(null);
@@ -28,6 +30,8 @@ export function PreferencesProvider({children}) {
} else { } else {
document.documentElement.classList.remove('dark'); document.documentElement.classList.remove('dark');
} }
document.querySelector('meta[name="theme-color"]')
?.setAttribute('content', isDarkMode ? THEME_COLORS.dark : THEME_COLORS.light);
localStorage.setItem(DARK_MODE_STORAGE_KEY, String(isDarkMode)); localStorage.setItem(DARK_MODE_STORAGE_KEY, String(isDarkMode));
}, [isDarkMode]); }, [isDarkMode]);
+35
View File
@@ -0,0 +1,35 @@
// @vitest-environment jsdom
import {afterEach, beforeEach, describe, expect, it, vi} from 'vitest';
import {act, cleanup, render} from '@testing-library/react';
import {PreferencesProvider, usePreferences} from './PreferencesContext.jsx';
vi.mock('react-i18next', () => import('../test/i18n-mock.js'));
let preferences;
function Spy() {
preferences = usePreferences();
return null;
}
const themeColor = () => document.querySelector('meta[name="theme-color"]').content;
beforeEach(() => {
document.head.innerHTML = '<meta name="theme-color" content="#09090b">';
});
afterEach(() => {
cleanup();
localStorage.clear();
});
describe('PreferencesProvider theme color', () => {
it('gives the installed app a light title bar in light mode and a dark one again in dark mode', () => {
localStorage.setItem('tbp_dark_mode', 'false');
render(<PreferencesProvider><Spy/></PreferencesProvider>);
expect(themeColor()).toBe('#fafafa');
act(() => preferences.setDarkMode(true));
expect(themeColor()).toBe('#09090b');
});
});
+35
View File
@@ -0,0 +1,35 @@
import {readFileSync} from 'node:fs';
import path from 'node:path';
import {describe, expect, it} from 'vitest';
const root = path.resolve(import.meta.dirname, '..');
const read = (file) => readFileSync(path.join(root, file));
const manifest = JSON.parse(read('public/manifest.webmanifest'));
// Width and height sit at bytes 16-23 of every PNG.
const pngSize = (file) => {
const png = read(path.join('public', file));
return `${png.readUInt32BE(16)}x${png.readUInt32BE(20)}`;
};
describe('PWA manifest', () => {
it('links every icon to a PNG of the size it claims', () => {
for (const icon of manifest.icons) {
expect(pngSize(icon.src)).toBe(icon.sizes);
}
});
it('has the icons Chrome needs to offer the install', () => {
const sizes = manifest.icons.filter((icon) => icon.purpose === 'any').map((icon) => icon.sizes);
expect(sizes).toEqual(expect.arrayContaining(['192x192', '512x512']));
expect(manifest.display).toBe('standalone');
expect(manifest.icons.some((icon) => icon.purpose === 'maskable')).toBe(true);
});
it('is linked from index.html together with the iOS icon', () => {
const html = read('index.html').toString();
expect(html).toContain('<link rel="manifest" href="/manifest.webmanifest" />');
expect(html).toContain('<link rel="apple-touch-icon" href="/apple-touch-icon.png" />');
expect(pngSize('apple-touch-icon.png')).toBe('180x180');
});
});