Add PWA manifest, service worker, and install hint.

Enables home-screen installation with per-viewer scope and platform-specific guidance in EN/DE.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-20 11:08:32 +02:00
parent 6df10e5498
commit 562a229fa0
14 changed files with 287 additions and 3 deletions
+2
View File
@@ -70,6 +70,7 @@ async function init() {
await I18n.init();
applyStaticI18n();
setupLanguage();
await Pwa.init();
setupViewerBanner();
setupNav();
setupUpload();
@@ -123,6 +124,7 @@ function setupLanguage() {
sel.addEventListener("change", async (e) => {
await I18n.setPreference(e.target.value);
applyStaticI18n();
Pwa.refreshHint();
resetLocaleDependentPanels();
if (state.data) renderAll();
const gs = document.getElementById("global-search");
Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 459 KiB

+2
View File
@@ -2,6 +2,7 @@ document.addEventListener("DOMContentLoaded", async () => {
await I18n.init();
applyStaticI18n();
setupLanguage();
await Pwa.init();
setupCreate();
});
@@ -17,6 +18,7 @@ function setupLanguage() {
sel.addEventListener("change", async (e) => {
await I18n.setPreference(e.target.value);
applyStaticI18n();
Pwa.refreshHint();
});
}
+9
View File
@@ -226,6 +226,15 @@
"copied": "Kopiert!",
"copyPrompt": "Viewer-Link kopieren:"
},
"pwa": {
"hintTitle": "Als App installieren",
"hintBody": "Installiere diesen Viewer für schnellen Zugriff über Startbildschirm oder App-Liste.",
"hintIos": "Tippe auf Teilen, dann „Zum Home-Bildschirm“.",
"hintAndroid": "Öffne das Browser-Menü und tippe auf „App installieren“ oder „Zum Startbildschirm“.",
"hintDesktop": "Nutze das Install-Symbol in der Adressleiste oder Browser-Menü → App installieren.",
"install": "Installieren",
"installed": "App installiert öffne sie über Startbildschirm oder App-Liste."
},
"viewerDb": {
"title": "Viewer-Backup",
"helpTitle": "Was ist das?",
+9
View File
@@ -226,6 +226,15 @@
"copied": "Copied!",
"copyPrompt": "Copy your viewer link:"
},
"pwa": {
"hintTitle": "Install as app",
"hintBody": "Install this viewer for quick access from your home screen or app list.",
"hintIos": "Tap Share, then \"Add to Home Screen\".",
"hintAndroid": "Open the browser menu and tap \"Install app\" or \"Add to Home screen\".",
"hintDesktop": "Use the install icon in the address bar or the browser menu → Install app.",
"install": "Install",
"installed": "App installed open it from your home screen or app list."
},
"viewerDb": {
"title": "Viewer backup",
"helpTitle": "What is this?",
+110
View File
@@ -0,0 +1,110 @@
/* PWA service worker registration and install hint */
const Pwa = (() => {
const DISMISS_KEY = "pwa-hint-dismissed";
let deferredPrompt = null;
let refreshInstallHint = null;
function isStandalone() {
return window.matchMedia("(display-mode: standalone)").matches
|| window.navigator.standalone === true;
}
function detectPlatform() {
const ua = navigator.userAgent;
if (/iPad|iPhone|iPod/.test(ua)) return "ios";
if (/Android/.test(ua)) return "android";
return "desktop";
}
function swConfig() {
const viewerId = document.body?.dataset?.viewerId;
if (viewerId && viewerId !== "local") {
return { url: `/v/${viewerId}/sw.js`, scope: `/v/${viewerId}/` };
}
return { url: "/sw.js", scope: "/" };
}
async function registerServiceWorker() {
if (!("serviceWorker" in navigator)) return;
const { url, scope } = swConfig();
try {
await navigator.serviceWorker.register(url, { scope });
} catch {
/* SW is optional manual install instructions still apply */
}
}
function platformHintKey() {
const platform = detectPlatform();
if (platform === "ios") return "pwa.hintIos";
if (platform === "android") return "pwa.hintAndroid";
return "pwa.hintDesktop";
}
function setupInstallHint() {
if (isStandalone()) return null;
if (localStorage.getItem(DISMISS_KEY) === "1") return null;
const hint = document.getElementById("pwa-install-hint");
if (!hint) return null;
const body = document.getElementById("pwa-hint-body");
const installBtn = document.getElementById("pwa-install-btn");
const dismissBtn = document.getElementById("pwa-hint-dismiss");
const updateHintText = () => {
const title = hint.querySelector("[data-i18n='pwa.hintTitle']");
if (title) title.textContent = t("pwa.hintTitle");
if (body && !deferredPrompt) body.textContent = t(platformHintKey());
if (body && deferredPrompt) body.textContent = t("pwa.hintBody");
if (dismissBtn) dismissBtn.title = t("actions.dismiss");
if (installBtn && !installBtn.hidden) installBtn.textContent = t("pwa.install");
};
hint.hidden = false;
updateHintText();
window.addEventListener("beforeinstallprompt", (e) => {
e.preventDefault();
deferredPrompt = e;
if (installBtn) installBtn.hidden = false;
if (body) body.textContent = t("pwa.hintBody");
updateHintText();
});
dismissBtn?.addEventListener("click", () => {
localStorage.setItem(DISMISS_KEY, "1");
hint.hidden = true;
});
installBtn?.addEventListener("click", async () => {
if (!deferredPrompt) return;
deferredPrompt.prompt();
const { outcome } = await deferredPrompt.userChoice;
deferredPrompt = null;
installBtn.hidden = true;
if (outcome === "accepted") {
if (body) body.textContent = t("pwa.installed");
setTimeout(() => { hint.hidden = true; }, 3000);
} else {
updateHintText();
}
});
return updateHintText;
}
async function init() {
await registerServiceWorker();
refreshInstallHint = setupInstallHint();
}
function refreshHint() {
refreshInstallHint?.();
}
return { init, refreshHint };
})();
window.Pwa = Pwa;
+44
View File
@@ -855,6 +855,50 @@ body.inv-chart-modal-open {
border-radius: 6px;
}
/* PWA install hint */
.pwa-hint {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 10px;
margin-bottom: 12px;
padding: 10px 12px;
border-radius: var(--radius);
border: 1px solid rgba(108, 140, 255, 0.35);
background: rgba(108, 140, 255, 0.08);
}
.pwa-hint-text {
flex: 1;
min-width: 0;
}
.pwa-hint-text strong {
display: block;
color: var(--accent);
font-size: 0.85rem;
margin-bottom: 4px;
}
.pwa-hint-text p {
margin: 0;
font-size: 0.75rem;
color: var(--text-muted);
line-height: 1.4;
}
.pwa-hint-actions {
display: flex;
align-items: flex-start;
gap: 6px;
flex-shrink: 0;
}
.landing-card .pwa-hint {
margin-top: 8px;
margin-bottom: 0;
}
.viewer-copy-btn {
flex-shrink: 0;
padding: 8px 12px;
+38
View File
@@ -0,0 +1,38 @@
const CACHE = "if-viewer-static-v1";
const ASSETS = [
"/static/style.css",
"/static/favicon.svg",
"/static/icon-192.png",
"/static/icon-512.png",
"/static/i18n.js",
"/static/locales/en.json",
"/static/locales/de.json",
];
self.addEventListener("install", (event) => {
event.waitUntil(
caches.open(CACHE)
.then((cache) => cache.addAll(ASSETS))
.then(() => self.skipWaiting())
);
});
self.addEventListener("activate", (event) => {
event.waitUntil(
caches.keys()
.then((keys) => Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k))))
.then(() => self.clients.claim())
);
});
self.addEventListener("fetch", (event) => {
if (event.request.method !== "GET") return;
const url = new URL(event.request.url);
if (url.pathname.includes("/api/")) return;
if (!url.pathname.startsWith("/static/")) return;
event.respondWith(
caches.match(event.request).then((cached) => cached || fetch(event.request))
);
});