Public Access
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:
1 parent
444b03acee
commit
f8e6c2c970
12 files changed
+117
-3
No files matched your search
@@ -26,6 +26,8 @@ To Be Played is split into three repositories:
|
||||
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
|
||||
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 %),
|
||||
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.
|
||||
@@ -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.
|
||||
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.
|
||||
- **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
|
||||
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".
|
||||
- A Content Security Policy in `nginx.conf`.
|
||||
- Installable PWA.
|
||||
|
||||
## License
|
||||
|
||||
|
||||
+8
-1
@@ -3,8 +3,15 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<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" />
|
||||
<title>Game Log</title>
|
||||
<title>ToBePlayed</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
@@ -7,6 +7,12 @@ server {
|
||||
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
|
||||
# no-cache the browser shows old translation keys or loads an old bundle.
|
||||
location / {
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 5.1 KiB |
+10
-1
@@ -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 |
@@ -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"}
|
||||
]
|
||||
}
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
} from '../utils/appearance.js';
|
||||
|
||||
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);
|
||||
|
||||
@@ -28,6 +30,8 @@ export function PreferencesProvider({children}) {
|
||||
} else {
|
||||
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));
|
||||
}, [isDarkMode]);
|
||||
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user