feat: add PWA support with push notifications, service worker, and offline page

Implement progressive web app capabilities including VAPID-based push notification infrastructure, service worker with offline caching, PWA install prompt handling, and manifest configuration. Add push.py module for VAPID key generation and notification delivery, PushManager and PwaInstaller JavaScript classes, service worker with precache and push event handling, offline fallback page, and app icons at multiple resolutions.
This commit is contained in:
2026-05-23 08:08:26 +00:00
parent ded6c8a004
commit 3c55ae1bb1
10 changed files with 647 additions and 0 deletions
+76
View File
@@ -0,0 +1,76 @@
// retoor <retoor@molodetz.nl>
export class PushManager {
constructor() {
this.supported = "serviceWorker" in navigator && "PushManager" in window && "Notification" in window;
if (!this.supported) {
return;
}
this.triggers = Array.from(document.querySelectorAll("[data-push-enable]"));
this.bindTriggers();
this.refreshTriggerVisibility();
this.register(true).catch((error) => console.error("Push silent register failed:", error));
}
bindTriggers() {
this.triggers.forEach((trigger) => {
trigger.addEventListener("click", (event) => {
event.preventDefault();
this.optIn();
});
});
}
refreshTriggerVisibility() {
const granted = Notification.permission === "granted";
this.triggers.forEach((trigger) => {
trigger.hidden = granted;
});
}
async optIn() {
const permission = await Notification.requestPermission();
if (permission === "granted") {
await this.register(false);
}
}
async register(silent) {
try {
const registration = await navigator.serviceWorker.register("/service-worker.js");
await registration.update();
await navigator.serviceWorker.ready;
if (Notification.permission !== "granted") {
this.refreshTriggerVisibility();
return;
}
const keyResponse = await fetch("/push.json");
const keyData = await keyResponse.json();
const applicationServerKey = Uint8Array.from(atob(keyData.publicKey), (c) => c.charCodeAt(0));
const subscription = await registration.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: applicationServerKey,
});
const response = await fetch("/push.json", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(subscription.toJSON()),
});
if (!response.ok) {
throw new Error("Bad status code from server.");
}
this.refreshTriggerVisibility();
} catch (error) {
console.error("Error registering push notifications:", error);
if (!silent) {
alert("Enabling push notifications failed. Please check your browser settings and try again.\n\n" + error);
}
}
}
}
+51
View File
@@ -0,0 +1,51 @@
// retoor <retoor@molodetz.nl>
export class PwaInstaller {
constructor() {
this.deferredPrompt = null;
this.triggers = Array.from(document.querySelectorAll("[data-pwa-install]"));
if (!this.triggers.length) {
return;
}
this.bindTriggers();
window.addEventListener("beforeinstallprompt", (event) => this.onPromptAvailable(event));
window.addEventListener("appinstalled", () => this.hideTriggers());
}
bindTriggers() {
this.triggers.forEach((trigger) => {
trigger.addEventListener("click", (event) => {
event.preventDefault();
this.install();
});
});
}
onPromptAvailable(event) {
event.preventDefault();
this.deferredPrompt = event;
this.showTriggers();
}
showTriggers() {
this.triggers.forEach((trigger) => {
trigger.hidden = false;
});
}
hideTriggers() {
this.triggers.forEach((trigger) => {
trigger.hidden = true;
});
}
async install() {
if (!this.deferredPrompt) {
return;
}
this.deferredPrompt.prompt();
await this.deferredPrompt.userChoice;
this.deferredPrompt = null;
this.hideTriggers();
}
}