diff --git a/docker/frontend/index.html b/docker/frontend/index.html index eae7565..ce45688 100644 --- a/docker/frontend/index.html +++ b/docker/frontend/index.html @@ -6,6 +6,16 @@ + + + + + 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 ( +
e.target === e.currentTarget && close()}> +
+

{t('install.title')}

+

{t('install.body')}

+ {face === 'manual' ?

{t('install.ios')}

: null} + {face === 'native' ? ( + + ) : null} + +
+
+ ); +}