web: make the studio installable, and give it a shell that opens offline
The three things a browser asks for, without a plugin: a manifest in
public/ (name, /app as the start, three icons cut from the one piece of
art this repo has), a service worker, and the two metas iOS reads
instead of the manifest.
The worker caches the shell — /, /app, /library, all one document under
the SPA fallback — and the hashed assets the build emits. A navigation
is network-first, so a deploy is never pinned behind the cache; a
hashed asset or the wasm is cache-first, because under a given build
those never change. /api and any non-GET go straight out: a worker is a
cache, not a proxy. nginx serves sw.js and manifest.json `no-cache`
(both names outlive their contents) with the isolation headers the
worker script needs under COEP.
The offer is the app's own dialog, not Chromium's mini-infobar: the
event is held, and it is spent either after the visitor has been in the
studio two minutes or the moment an export lands — the point at which
the app has done their work. Safari never fires the event, so it gets
the Share > Add to Home Screen line instead. A refusal is remembered and
never asked again.
node scripts/make-icons.mjs 192x192 39785B / 512x512 159296B / maskable 512x512 123723B
node scripts/pwa-check.mjs manifest 3 icons · worker activated · shell cached
· offline reload of /app paints
off (https://localhost:8090) same four, through nginx
This commit is contained in:
@@ -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.',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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') ? (
|
||||
<Workspace />
|
||||
@@ -22,6 +25,8 @@ const page = path.startsWith('/app') ? (
|
||||
<Admin />
|
||||
) : path.startsWith('/photos') ? (
|
||||
<PhotosPage />
|
||||
) : path.startsWith('/library') ? (
|
||||
<Library />
|
||||
) : path.startsWith('/profile') ? (
|
||||
<ProfilePage />
|
||||
) : (
|
||||
@@ -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(
|
||||
<ThemeProvider>
|
||||
<I18nProvider>{page}</I18nProvider>
|
||||
<I18nProvider>
|
||||
{page}
|
||||
<InstallPrompt />
|
||||
</I18nProvider>
|
||||
</ThemeProvider>
|
||||
);
|
||||
|
||||
// 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(() => {});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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<void>;
|
||||
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();
|
||||
});
|
||||
@@ -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 (
|
||||
<div className="modal-backdrop" onMouseDown={(e) => e.target === e.currentTarget && close()}>
|
||||
<div className="modal" role="dialog" aria-modal="true" aria-labelledby="install-title">
|
||||
<h2 id="install-title">{t('install.title')}</h2>
|
||||
<p className="hint">{t('install.body')}</p>
|
||||
{face === 'manual' ? <p className="hint">{t('install.ios')}</p> : null}
|
||||
{face === 'native' ? (
|
||||
<button
|
||||
type="button"
|
||||
className="btn primary"
|
||||
data-key="install-yes"
|
||||
// Not a dismissal: taking the offer and waving the browser's own sheet
|
||||
// away are different answers, and the app's own button can still ask.
|
||||
onClick={() => {
|
||||
void promptInstall();
|
||||
setFace(null);
|
||||
}}
|
||||
>
|
||||
{t('install.yes')}
|
||||
</button>
|
||||
) : null}
|
||||
<button type="button" className="btn ghost" data-key="install-no" onClick={close}>
|
||||
{t('install.no')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user