RecipesCam — web
diff --git a/docker/frontend/nginx.conf b/docker/frontend/nginx.conf
index 6152f8e..6cb2903 100644
--- a/docker/frontend/nginx.conf
+++ b/docker/frontend/nginx.conf
@@ -51,6 +51,24 @@ server {
try_files $uri =404;
}
+ # The two files whose names never change but whose contents must: a cached worker
+ # keeps answering with the previous build's shell long after the deploy that
+ # replaced it, and a cached manifest keeps pointing at the icon set it shipped with.
+ # Nothing else would ever flush them, which is exactly why they opt out of the
+ # long-lived caching above. The worker is a script under the embedder's COEP like
+ # the .mjs files, so it restates the two isolation headers for the same reason.
+ location = /sw.js {
+ add_header Cache-Control "no-cache";
+ add_header Cross-Origin-Opener-Policy "same-origin" always;
+ add_header Cross-Origin-Embedder-Policy "require-corp" always;
+ try_files $uri =404;
+ }
+
+ location = /manifest.json {
+ add_header Cache-Control "no-cache";
+ try_files $uri =404;
+ }
+
# The one route that carries a whole data dir back in (see /api/admin/restore
# — who may call it is the API's own admin check, done before it reads a byte).
# The upload cap that holds everywhere else would reject it, and unpacking the
diff --git a/docker/frontend/public/icons/icon-192.png b/docker/frontend/public/icons/icon-192.png
new file mode 100644
index 0000000..6a3e4ca
Binary files /dev/null and b/docker/frontend/public/icons/icon-192.png differ
diff --git a/docker/frontend/public/icons/icon-512.png b/docker/frontend/public/icons/icon-512.png
new file mode 100644
index 0000000..07f22f3
Binary files /dev/null and b/docker/frontend/public/icons/icon-512.png differ
diff --git a/docker/frontend/public/icons/maskable-512.png b/docker/frontend/public/icons/maskable-512.png
new file mode 100644
index 0000000..9c81a7a
Binary files /dev/null and b/docker/frontend/public/icons/maskable-512.png differ
diff --git a/docker/frontend/public/manifest.json b/docker/frontend/public/manifest.json
new file mode 100644
index 0000000..fb377e0
--- /dev/null
+++ b/docker/frontend/public/manifest.json
@@ -0,0 +1,37 @@
+{
+ "name": "RecipesCam — Color Recipes & Film Camera Studio",
+ "short_name": "RecipesCam",
+ "description": "Grade ảnh bằng công thức màu phim Kodak & Fuji — tone curve, hạt phim và halation. Cùng một pipeline trên điện thoại và web studio, ảnh không rời khỏi máy bạn.",
+ "lang": "vi",
+ "start_url": "/app",
+ "scope": "/",
+ "display": "standalone",
+ "orientation": "any",
+ "background_color": "#0a0a0a",
+ "theme_color": "#0a0a0a",
+ "icons": [
+ {
+ "src": "/icons/icon-192.png",
+ "sizes": "192x192",
+ "type": "image/png"
+ },
+ {
+ "src": "/icons/icon-512.png",
+ "sizes": "512x512",
+ "type": "image/png"
+ },
+ {
+ "src": "/icons/maskable-512.png",
+ "sizes": "512x512",
+ "type": "image/png",
+ "purpose": "maskable"
+ }
+ ],
+ "shortcuts": [
+ {
+ "name": "Thư viện",
+ "short_name": "Thư viện",
+ "url": "/library"
+ }
+ ]
+}
diff --git a/docker/frontend/public/sw.js b/docker/frontend/public/sw.js
new file mode 100644
index 0000000..47bf0af
--- /dev/null
+++ b/docker/frontend/public/sw.js
@@ -0,0 +1,76 @@
+// The app shell as a service worker, so an installed RecipesCam opens with no
+// network at all. Not bundled on purpose: through vite/rollup it would stop being
+// readable in DevTools, the one place a worker gets debugged. VERSION is the cache
+// name and the whole update story — bump it when the shell changes, and `activate`
+// drops every older cache.
+const VERSION = 'recipescam-v1';
+
+// nginx answers all three with the same index.html (SPA fallback), so they are one
+// document under three keys: an offline navigation finds it whichever key it asks.
+const SHELL = ['/', '/app', '/library'];
+
+// Cache-first paths: vite hashes every filename in its own bundle, and the wasm,
+// the models and the icons never change under a given build.
+const STATIC = /^\/(assets|wasm|models|icons)\//;
+
+// Only a complete same-origin 200 is worth keeping — an opaque or error body
+// stored here comes back as a failure on the next visit, and nothing in the worker
+// can tell the two apart by then.
+const cachedFetch = (request) =>
+ fetch(request).then((response) => {
+ if (response.ok) {
+ const copy = response.clone();
+ caches.open(VERSION).then((cache) => cache.put(request, copy));
+ }
+ return response;
+ });
+
+// Offline, the studio the app is for; the landing page for a build without /app.
+const offlineShell = async () =>
+ (await caches.match('/app')) ?? (await caches.match('/')) ?? Response.error();
+
+self.addEventListener('install', (event) => {
+ // Best effort, one route at a time: a deploy caught mid-flight must not leave the
+ // worker uninstalled, so each route fails on its own.
+ event.waitUntil(
+ caches.open(VERSION)
+ .then((cache) => Promise.all(SHELL.map((url) => cache.add(url).catch(() => {}))))
+ .then(() => self.skipWaiting()),
+ );
+});
+
+self.addEventListener('activate', (event) => {
+ event.waitUntil(
+ caches.keys()
+ .then((names) => Promise.all(names.filter((n) => n !== VERSION).map((n) => caches.delete(n))))
+ .then(() => self.clients.claim()),
+ );
+});
+
+self.addEventListener('fetch', (event) => {
+ const { request } = event;
+ // A worker is a cache, not a proxy: a POST, the API, another origin's font — a
+ // request that is not a plain same-origin read — goes straight out.
+ if (request.method !== 'GET') return;
+ const url = new URL(request.url);
+ if (url.origin !== self.location.origin || url.pathname.startsWith('/api/')) return;
+
+ // A navigation is network-first even when it is cached: a shell answered from the
+ // cache while the network holds a newer build pins the visitor to the old one. It
+ // is also the path an installed app opens through with no network.
+ if (request.mode === 'navigate') {
+ event.respondWith(fetch(request).catch(offlineShell));
+ return;
+ }
+
+ // ponytail: a navigation is never written back, the precached entry is what
+ // answers offline. Add a put() here once a route must survive on its own.
+ if (STATIC.test(url.pathname)) {
+ event.respondWith(caches.match(request).then((hit) => hit ?? cachedFetch(request)));
+ return;
+ }
+
+ // Everything else — a font, the manifest, an uploaded photo — is network-first,
+ // and answered from the cache only when that fails.
+ event.respondWith(fetch(request).catch(async () => (await caches.match(request)) ?? Response.error()));
+});
diff --git a/docker/frontend/scripts/make-icons.mjs b/docker/frontend/scripts/make-icons.mjs
new file mode 100644
index 0000000..b38234e
--- /dev/null
+++ b/docker/frontend/scripts/make-icons.mjs
@@ -0,0 +1,71 @@
+// The manifest needs its icons at fixed sizes, and the only artwork there is ships
+// at 256x256 (public/assets/RecipesCamIcon.png). Skia is already a dependency — the
+// engine grades through it — so it does the resize here rather than a new image
+// tool or a runtime fetch of an oversized PNG. One-off, run by hand: the output is
+// committed, so a build never has to decode and rescale anything.
+//
+// node scripts/make-icons.mjs
+import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
+import { fileURLToPath } from 'node:url';
+import CanvasKitInit from 'canvaskit-wasm/bin/full/canvaskit.js';
+
+const SOURCE = 'public/assets/RecipesCamIcon.png';
+const OUT = 'public/icons';
+
+const CanvasKit = await CanvasKitInit({
+ locateFile: () =>
+ fileURLToPath(new URL('../node_modules/canvaskit-wasm/bin/full/canvaskit.wasm', import.meta.url)),
+});
+
+// Buffers are typed arrays already, but Skia's binding wants one it can read as a
+// plain byte view, not node's pooled slab.
+const art = CanvasKit.MakeImageFromEncoded(new Uint8Array(readFileSync(SOURCE)));
+if (!art) throw new Error(`${SOURCE} is not an image Skia can decode`);
+
+// `scale` is the art's share of the square. The plain icons bleed to the edge,
+// exactly as the source does. The maskable one cannot: the launcher supplies the
+// mask, and most of them are circles, so the art has to stay inside the 80%
+// safe zone the spec draws — the background around it is the app's own #0a0a0a
+// rather than nothing at all, because a masked icon with transparent corners
+// shows whatever the launcher paints behind it.
+const icons = [
+ { file: 'icon-192.png', size: 192, scale: 1, background: false },
+ { file: 'icon-512.png', size: 512, scale: 1, background: false },
+ { file: 'maskable-512.png', size: 512, scale: 0.8, background: true },
+];
+
+mkdirSync(OUT, { recursive: true });
+for (const { file, size, scale, background } of icons) {
+ const surface = CanvasKit.MakeSurface(size, size);
+ if (!surface) throw new Error(`could not make a ${size}x${size} surface`);
+ const canvas = surface.getCanvas();
+
+ if (background) {
+ const paint = new CanvasKit.Paint();
+ paint.setColor(CanvasKit.Color(10, 10, 10));
+ canvas.drawPaint(paint);
+ paint.delete();
+ }
+
+ const side = size * scale;
+ const offset = (size - side) / 2;
+ // Mitchell cubic (B = C = 1/3): the 256 -> 192 step is a downscale, where
+ // nearest or bilinear would drop detail off the camera's thin outlines.
+ canvas.drawImageRectCubic(
+ art,
+ CanvasKit.XYWHRect(0, 0, art.width(), art.height()),
+ CanvasKit.XYWHRect(offset, offset, side, side),
+ 1 / 3,
+ 1 / 3,
+ null,
+ );
+ surface.flush();
+
+ const png = surface.makeImageSnapshot().encodeToBytes();
+ surface.dispose();
+ if (!png) throw new Error(`could not encode ${file}`);
+ writeFileSync(`${OUT}/${file}`, png);
+ console.log(`${OUT}/${file} ${size}x${size} ${png.length} bytes`);
+}
+
+art.delete();
diff --git a/docker/frontend/scripts/pwa-check.mjs b/docker/frontend/scripts/pwa-check.mjs
new file mode 100644
index 0000000..f3068eb
--- /dev/null
+++ b/docker/frontend/scripts/pwa-check.mjs
@@ -0,0 +1,86 @@
+// Install support is not visible in the source. It is the manifest the browser
+// reads, the worker it registers, and what that worker still holds once the network
+// is gone — so unlike the other checks here, which read a module and assert on it,
+// this one has to drive a real browser at a real build. Four assertions, one line
+// each, against a preview server:
+//
+// npx vite build && npx vite preview --port 4183 &
+// node scripts/pwa-check.mjs
+//
+// The browser is Playwright's own, installed by whoever runs this — there is no
+// test runner in this repo, and software GL is what the studio needs to paint at
+// all. PLAYWRIGHT_CORE points at another copy, CHROME at another browser.
+const BASE = process.argv[2] ?? 'http://localhost:4183';
+const playwright = await import(process.env.PLAYWRIGHT_CORE ?? 'playwright-core').catch(() => null);
+if (!playwright) {
+ console.log('SKIP playwright-core is not installed — pass PLAYWRIGHT_CORE=');
+ process.exit(0);
+}
+const { chromium } = playwright;
+
+const browser = await chromium.launch({
+ // Playwright's own Chromium when it has one; CHROME points at any other.
+ executablePath: process.env.CHROME || undefined,
+ args: ['--no-sandbox', '--enable-unsafe-swiftshader', '--use-gl=angle', '--use-angle=swiftshader'],
+});
+const context = await browser.newContext();
+
+// Every assertion prints, rather than throwing on the first one: which of the four
+// works and which does not is the whole answer.
+let failed = 0;
+const check = (ok, label) => {
+ console.log(`${ok ? 'ok ' : 'FAIL'} ${label}`);
+ if (!ok) failed++;
+};
+
+const page = await context.newPage();
+await page.goto(`${BASE}/app`);
+
+// (a) The manifest, as the browser fetches it before it offers anything.
+const manifest = await page.evaluate(async () => await (await fetch('/manifest.json')).json());
+check(Array.isArray(manifest.icons) && manifest.icons.length === 3, `/manifest.json parses with ${manifest.icons?.length} icons`);
+
+// (b) The worker registration main.tsx makes on load. `ready` only ever resolves
+// with a registration that has an `active` worker; that worker's own state string
+// is 'activated' (the ServiceWorkerState enum has no 'active' in it).
+const registration = await page.evaluate(async () => {
+ const reg = await navigator.serviceWorker.ready;
+ return { scope: reg.scope, state: reg.active?.state ?? null };
+});
+check(registration.state === 'activated', `serviceWorker.ready resolves — scope ${registration.scope}, active.state '${registration.state}'`);
+
+// (c) What it put away. Read after a reload, because the first load is the one that
+// installs it and the reload is the first one it is in control of.
+await page.waitForFunction(async () => (await caches.match(new URL('/app', location.origin).href)) !== undefined);
+await page.reload();
+const cached = await page.evaluate(async () => {
+ const out = {};
+ for (const name of await caches.keys()) {
+ out[name] = (await (await caches.open(name)).keys()).map((r) => new URL(r.url).pathname);
+ }
+ return out;
+});
+const names = Object.keys(cached);
+const urls = Object.values(cached).flat().sort();
+check(
+ urls.includes('/') && urls.includes('/app') && urls.some((u) => /^\/assets\/index-/.test(u)),
+ `worker cache ${names.join(', ')} after reload: ${urls.join(' ')}`,
+);
+
+// (d) The point of all of it: the network gone, the shell still paints.
+await context.setOffline(true);
+let brand = 'nothing';
+try {
+ await page.reload();
+ brand = await page.evaluate(() => document.querySelector('header.header .brand')?.textContent ?? null);
+} catch (err) {
+ brand = `reload failed: ${err.message.split('\n')[0]}`;
+}
+check(/RecipesCam/.test(brand ?? ''), `offline reload of /app paints the app's own header: ${brand}`);
+
+await browser.close();
+if (failed) {
+ console.error(`pwa-check: ${failed} of 4 failed`);
+ process.exit(1);
+}
+console.log('pwa-check ok');
diff --git a/docker/frontend/src/i18n/en.ts b/docker/frontend/src/i18n/en.ts
index a68f4a5..ca51a0a 100644
--- a/docker/frontend/src/i18n/en.ts
+++ b/docker/frontend/src/i18n/en.ts
@@ -13,6 +13,13 @@ export const en: Dict = {
'nav.back': 'Back to home',
'nav.guest': 'Guest',
+ // The install offer (see vi.ts).
+ 'install.title': 'Install RecipesCam',
+ 'install.body': 'Add it to your home screen to open it faster — smoother, offline, full screen like an app.',
+ 'install.yes': 'INSTALL',
+ 'install.no': 'NO THANKS',
+ 'install.ios': 'Tap the Share button in the toolbar, then choose “Add to Home Screen”.',
+
'act.undo': 'UNDO',
'act.redo': 'REDO',
'act.reset': 'RESET',
@@ -218,9 +225,32 @@ export const en: Dict = {
'photos.failed': 'That did not work: {msg}',
'photos.noLabels': 'No strip labels yet',
+ 'lib.title': 'Library',
+ 'lib.hint':
+ 'Pick a photo folder on this machine. Nothing is uploaded — the page only remembers where the folder is and keeps one thumbnail per frame.',
+ 'lib.add': 'ADD FOLDER',
+ 'lib.stop': 'STOP SCAN',
+ 'lib.rescan': 'RESCAN',
+ 'lib.remove': 'REMOVE',
+ 'lib.reconnect': 'GRANT ACCESS AGAIN',
+ 'lib.all': 'ALL',
+ 'lib.open': 'Open in the studio',
+ 'lib.edited': 'edited',
+ 'lib.size': '{mb} MB',
+ 'lib.count': '{n} photos',
+ 'lib.scanning': 'Scanning {done}/{total} — {added} new…',
+ 'lib.scanned': 'Scanned {folder}: {added}/{total} new frames.',
+ 'lib.empty': 'Nothing in this folder yet.',
+ 'lib.noFolders': 'No folder yet. Hit ADD FOLDER and pick a photo folder on this machine.',
+ 'lib.missing': 'Could not open {name} — the file has been moved or deleted.',
+ 'lib.failed': 'That folder could not be read.',
+ 'lib.unsupported':
+ 'This browser will not let a page read a folder from the disk (Chrome, Edge, Opera and Brave will). Photos still edit fine — drag and drop them into the studio.',
+
'nav.admin': 'Admin',
'nav.photos': 'My photos',
'nav.profile': 'Profile',
+ 'nav.library': 'Library',
'nav.studio': 'Studio',
'adm.title': 'Strip moderation',
'adm.subtitle': 'Photos contributed by users. Delete one to pull it off the landing page.',
diff --git a/docker/frontend/src/i18n/vi.ts b/docker/frontend/src/i18n/vi.ts
index 757972a..907747f 100644
--- a/docker/frontend/src/i18n/vi.ts
+++ b/docker/frontend/src/i18n/vi.ts
@@ -17,9 +17,19 @@ export const vi = {
'nav.admin': 'Quản trị',
'nav.photos': 'Ảnh của tôi',
'nav.profile': 'Hồ sơ',
+ 'nav.library': 'Thư viện',
'nav.studio': 'Studio',
'nav.guest': 'Khách',
+ // The install offer (src/ui/InstallPrompt.tsx). The only dialog that asks for
+ // something the visitor did not come for, so the copy sells what they get out of
+ // it and the refusal is one word away.
+ 'install.title': 'Cài RecipesCam lên màn hình chính',
+ 'install.body': 'Cài lên màn hình chính để mở nhanh hơn — mượt mà hơn, offline, toàn màn hình như một app.',
+ 'install.yes': 'ĐỒNG Ý',
+ 'install.no': 'KHÔNG CẢM ƠN',
+ 'install.ios': 'Bấm nút Chia sẻ trên thanh địa chỉ, rồi chọn "Thêm vào màn hình chính".',
+
'act.undo': 'HOÀN TÁC',
'act.redo': 'LÀM LẠI',
'act.reset': 'ĐẶT LẠI',
@@ -228,6 +238,28 @@ export const vi = {
'photos.failed': 'Không thực hiện được: {msg}',
'photos.noLabels': 'Chưa có nhãn dải phim',
+ 'lib.title': 'Thư viện',
+ 'lib.hint':
+ 'Chọn thư mục ảnh trên máy. Ảnh không được tải lên đâu cả — trang chỉ nhớ đường dẫn và giữ một ảnh thu nhỏ cho mỗi tấm.',
+ 'lib.add': 'THÊM THƯ MỤC',
+ 'lib.stop': 'DỪNG QUÉT',
+ 'lib.rescan': 'QUÉT LẠI',
+ 'lib.remove': 'BỎ',
+ 'lib.reconnect': 'CẤP LẠI QUYỀN',
+ 'lib.all': 'TẤT CẢ',
+ 'lib.open': 'Mở trong studio',
+ 'lib.edited': 'đã chỉnh',
+ 'lib.size': '{mb} MB',
+ 'lib.count': '{n} ảnh',
+ 'lib.scanning': 'Đang quét {done}/{total} — thêm {added}…',
+ 'lib.scanned': 'Đã quét {folder}: {added}/{total} ảnh mới.',
+ 'lib.empty': 'Chưa có ảnh nào trong thư mục này.',
+ 'lib.noFolders': 'Chưa có thư mục nào. Bấm THÊM THƯ MỤC để chọn một thư mục ảnh trên máy.',
+ 'lib.missing': 'Không mở được {name} — tệp đã bị di chuyển hoặc xoá.',
+ 'lib.failed': 'Không đọc được thư mục này.',
+ 'lib.unsupported':
+ 'Trình duyệt này không cho phép app đọc thư mục trên máy (cần Chrome, Edge, Opera hoặc Brave). Ảnh vẫn chỉnh được bình thường bằng cách kéo & thả vào studio.',
+
'adm.title': 'Quản trị dải phim',
'adm.subtitle': 'Ảnh do người dùng đóng góp. Xoá một ảnh để gỡ nó khỏi trang chủ.',
'adm.upload': 'THÊM ẢNH',
diff --git a/docker/frontend/src/main.tsx b/docker/frontend/src/main.tsx
index ff83339..f13bbd3 100644
--- a/docker/frontend/src/main.tsx
+++ b/docker/frontend/src/main.tsx
@@ -10,11 +10,14 @@ import { Workspace } from './App';
import { Admin } from './Admin';
import { ProfilePage } from './ProfilePage';
import { PhotosPage } from './PhotosPage';
+import { Library } from './Library';
+import { InstallPrompt } from './ui/InstallPrompt';
import { installTracking } from './track';
-// Five routes, no router: the landing page, the workspace, the strip moderation
-// screen, the member's own profile and the member's photo folder. nginx serves
-// index.html for all of them (SPA fallback), so this is a pathname check.
+// Six routes, no router: the landing page, the workspace, the strip moderation
+// screen, the member's own profile, the member's photo folder and the local
+// catalogue of folders on the visitor's own disk. nginx serves index.html for
+// all of them (SPA fallback), so this is a pathname check.
const path = window.location.pathname;
const page = path.startsWith('/app') ? (
@@ -22,6 +25,8 @@ const page = path.startsWith('/app') ? (
) : path.startsWith('/photos') ? (
+) : path.startsWith('/library') ? (
+
) : path.startsWith('/profile') ? (
) : (
@@ -35,9 +40,24 @@ if (!root) throw new Error('#root missing');
installTracking();
// No StrictMode: it double-invokes effects, which would load CanvasKit twice and
-// run the render pipeline twice on every mount.
+// run the render pipeline twice on every mount. The install offer hangs off the
+// i18n provider because its copy comes from the dictionaries.
createRoot(root).render(
- {page}
+
+ {page}
+
+
);
+
+// Service worker, production only: in dev it would answer the browser with the
+// last build's shell instead of vite's modules. Registered after `load` so its
+// precache does not race the studio's own 20MB of wasm for the connection, and a
+// browser that refuses it (private mode, a plain-http origin) just loses the
+// offline shell — nothing else depends on it.
+if (import.meta.env.PROD && 'serviceWorker' in navigator) {
+ window.addEventListener('load', () => {
+ navigator.serviceWorker.register('/sw.js').catch(() => {});
+ });
+}
diff --git a/docker/frontend/src/pwa/install.ts b/docker/frontend/src/pwa/install.ts
new file mode 100644
index 0000000..92298bb
--- /dev/null
+++ b/docker/frontend/src/pwa/install.ts
@@ -0,0 +1,97 @@
+// Installability lives outside React. The browser fires `beforeinstallprompt` once,
+// whenever it likes, and the event has to be held until a click spends it — so this
+// is a module-level slot plus a subscriber list, and no store, hook or context.
+//
+// The one piece of state the visitor owns is the refusal: remember it, and the offer
+// never comes back. A dismissal is an answer, not a snooze.
+
+type InstallEvent = Event & {
+ prompt: () => Promise;
+ userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>;
+};
+
+const DISMISSED = 'rc.install.dismissed.v1';
+
+// The window event a control elsewhere in the app (the Library's own button, say)
+// fires to pull the offer forward instead of waiting out the delay.
+export const OFFER_EVENT = 'rc:install-offer';
+
+let deferred: InstallEvent | null = null;
+const listeners = new Set<() => void>();
+
+const notify = () => {
+ for (const listener of listeners) listener();
+};
+
+// Registers `listener` for every change in installability and returns the
+// unsubscribe — the shape `useEffect` already has.
+export function subscribe(listener: () => void): () => void {
+ listeners.add(listener);
+ return () => {
+ listeners.delete(listener);
+ };
+}
+
+export function canInstall(): boolean {
+ return deferred !== null && !dismissed();
+}
+
+// Spends the browser's prompt and reports the answer. Null means there was nothing
+// to spend: the event never arrived, or this is a second call.
+export async function promptInstall(): Promise<'accepted' | 'dismissed' | null> {
+ const event = deferred;
+ if (!event) return null;
+ // Spent either way, so it is cleared before the await: a second prompt() on the
+ // same event rejects.
+ deferred = null;
+ notify();
+ await event.prompt();
+ const { outcome } = await event.userChoice;
+ return outcome;
+}
+
+// The installed app is its own window; a Safari tab that was added to the home
+// screen keeps `standalone` on the navigator instead.
+export function isStandalone(): boolean {
+ return (
+ window.matchMedia('(display-mode: standalone)').matches ||
+ (navigator as Navigator & { standalone?: boolean }).standalone === true
+ );
+}
+
+// Every browser on iOS is WebKit, but only Safari's own UI has "Add to Home Screen",
+// and only there does the manual instruction make sense.
+export function isIosSafari(): boolean {
+ const ua = navigator.userAgent;
+ const ios = /iPad|iPhone|iPod/.test(ua) || (navigator.platform === 'MacIntel' && navigator.maxTouchPoints > 1);
+ return ios && !/CriOS|FxiOS|EdgiOS|OPiOS/.test(ua);
+}
+
+export function dismissed(): boolean {
+ return localStorage.getItem(DISMISSED) === '1';
+}
+
+export function dismissInstall(): void {
+ localStorage.setItem(DISMISSED, '1');
+ notify();
+}
+
+// Asks for the offer now.
+export function offerInstall(): void {
+ window.dispatchEvent(new Event(OFFER_EVENT));
+}
+
+window.addEventListener('beforeinstallprompt', (event) => {
+ // Without this Chromium shows its own mini-infobar and the event is spent; the
+ // app's dialog is the one the visitor gets.
+ event.preventDefault();
+ deferred = event as InstallEvent;
+ notify();
+});
+
+// Fires once the app is installed, from our prompt or from the browser's own menu,
+// and is the only signal that the offer is done.
+window.addEventListener('appinstalled', () => {
+ deferred = null;
+ notify();
+});
diff --git a/docker/frontend/src/ui/InstallPrompt.tsx b/docker/frontend/src/ui/InstallPrompt.tsx
new file mode 100644
index 0000000..7d78f8b
--- /dev/null
+++ b/docker/frontend/src/ui/InstallPrompt.tsx
@@ -0,0 +1,105 @@
+import { useEffect, useState } from 'react';
+import { useI18n } from '../i18n/I18nProvider';
+import {
+ OFFER_EVENT,
+ canInstall,
+ dismissInstall,
+ dismissed,
+ isIosSafari,
+ isStandalone,
+ promptInstall,
+ subscribe,
+} from '../pwa/install';
+
+// The install offer, and the only dialog the app opens without being asked. It waits
+// for the browser to say the app is installable, and then for the visitor to have
+// stayed long enough to want it — a prompt on the way in is a prompt nobody reads.
+// Markup and classes are the auth dialog's (see AuthModal.tsx); there is nothing new
+// to draw here.
+//
+// Long enough to have opened a photo and worked on it, short enough that the offer
+// still lands in the visit that earned it.
+const DELAY_MS = 120_000;
+
+export function InstallPrompt() {
+ const { t } = useI18n();
+ // 'manual' is Safari: no install event exists there, so the only thing to show is
+ // where the instruction lives.
+ const [face, setFace] = useState<'native' | 'manual' | null>(null);
+
+ useEffect(() => {
+ // An installed app has nothing to offer, and a refusal is final — the visitor
+ // said no to this dialog, not to this session.
+ if (isStandalone() || dismissed()) return;
+
+ const manual = isIosSafari();
+ let waited = false;
+ let asked = false;
+ let shown = false;
+
+ // Both ways in have to agree: installability (or Safari, which never fires the
+ // event) and either the delay running out or something in the app asking for the
+ // offer — a Library button, say.
+ const maybeShow = () => {
+ if (shown || (!waited && !asked)) return;
+ if (manual) {
+ shown = true;
+ setFace('manual');
+ } else if (canInstall()) {
+ shown = true;
+ setFace('native');
+ }
+ };
+ const onOffer = () => {
+ asked = true;
+ maybeShow();
+ };
+
+ window.addEventListener(OFFER_EVENT, onOffer);
+ const unsubscribe = subscribe(maybeShow);
+ const timer = window.setTimeout(() => {
+ waited = true;
+ maybeShow();
+ }, DELAY_MS);
+
+ return () => {
+ window.clearTimeout(timer);
+ window.removeEventListener(OFFER_EVENT, onOffer);
+ unsubscribe();
+ };
+ }, []);
+
+ if (!face) return null;
+ const close = () => {
+ dismissInstall();
+ setFace(null);
+ };
+
+ return (
+