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:
@@ -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,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();
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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?",
|
||||
|
||||
@@ -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
@@ -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;
|
||||
@@ -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;
|
||||
|
||||
@@ -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))
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user