diff --git a/README.md b/README.md index d70e0ce..1a080ee 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/index.html b/index.html index b9eb454..e56ab60 100644 --- a/index.html +++ b/index.html @@ -3,8 +3,15 @@ + + + + + + + - Game Log + ToBePlayed
diff --git a/nginx.conf b/nginx.conf index 575f511..022f911 100644 --- a/nginx.conf +++ b/nginx.conf @@ -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 / { diff --git a/public/apple-touch-icon.png b/public/apple-touch-icon.png new file mode 100644 index 0000000..af52f7c Binary files /dev/null and b/public/apple-touch-icon.png differ diff --git a/public/favicon.svg b/public/favicon.svg index 6893eb1..2ffe218 100644 --- a/public/favicon.svg +++ b/public/favicon.svg @@ -1 +1,10 @@ - \ No newline at end of file + + + + + + + + + + diff --git a/public/icon-192.png b/public/icon-192.png new file mode 100644 index 0000000..ac4040b Binary files /dev/null and b/public/icon-192.png differ diff --git a/public/icon-512.png b/public/icon-512.png new file mode 100644 index 0000000..97b0dff Binary files /dev/null and b/public/icon-512.png differ diff --git a/public/icon-maskable-512.png b/public/icon-maskable-512.png new file mode 100644 index 0000000..2e3aaae Binary files /dev/null and b/public/icon-maskable-512.png differ diff --git a/public/manifest.webmanifest b/public/manifest.webmanifest new file mode 100644 index 0000000..8da2ecb --- /dev/null +++ b/public/manifest.webmanifest @@ -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"} + ] +} diff --git a/src/context/PreferencesContext.jsx b/src/context/PreferencesContext.jsx index 5df46e4..3e10a2a 100644 --- a/src/context/PreferencesContext.jsx +++ b/src/context/PreferencesContext.jsx @@ -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]); diff --git a/src/context/PreferencesContext.test.jsx b/src/context/PreferencesContext.test.jsx new file mode 100644 index 0000000..f4e910b --- /dev/null +++ b/src/context/PreferencesContext.test.jsx @@ -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 = ''; +}); + +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(); + + expect(themeColor()).toBe('#fafafa'); + + act(() => preferences.setDarkMode(true)); + expect(themeColor()).toBe('#09090b'); + }); +}); diff --git a/src/pwa.test.js b/src/pwa.test.js new file mode 100644 index 0000000..0ff0341 --- /dev/null +++ b/src/pwa.test.js @@ -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(''); + expect(html).toContain(''); + expect(pngSize('apple-touch-icon.png')).toBe('180x180'); + }); +});