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
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

+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();
}
}
+34
View File
@@ -0,0 +1,34 @@
{
"id": "/?source=pwa",
"name": "DevPlace",
"short_name": "DevPlace",
"description": "The Developer Social Network",
"start_url": "/feed?source=pwa",
"scope": "/",
"display": "standalone",
"orientation": "any",
"lang": "en",
"dir": "ltr",
"background_color": "#0f0a1a",
"theme_color": "#0f0a1a",
"icons": [
{
"src": "/static/icon-192.png",
"sizes": "192x192",
"type": "image/png",
"purpose": "any"
},
{
"src": "/static/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any"
},
{
"src": "/static/icon-maskable-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "maskable"
}
]
}
+69
View File
@@ -0,0 +1,69 @@
<!DOCTYPE html>
<!-- retoor <retoor@molodetz.nl> -->
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Offline - DevPlace</title>
<style>
:root {
--bg-primary: #0f0a1a;
--bg-card: #221436;
--accent: #ff6b35;
--text-primary: #f0e8f8;
--text-muted: #9a8db0;
}
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-primary);
color: var(--text-primary);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
padding: 1.5rem;
}
.offline-card {
max-width: 360px;
text-align: center;
background: var(--bg-card);
border-radius: 16px;
padding: 2.5rem 2rem;
}
.offline-mark {
width: 72px;
height: 72px;
margin: 0 auto 1.25rem;
border-radius: 16px;
background: var(--accent);
color: var(--bg-primary);
font-size: 2.75rem;
font-weight: 700;
line-height: 72px;
}
h1 { font-size: 1.375rem; margin: 0 0 0.5rem; }
p { color: var(--text-muted); line-height: 1.5; margin: 0 0 1.5rem; }
button {
background: var(--accent);
color: var(--bg-primary);
border: none;
border-radius: 8px;
padding: 0.75rem 1.5rem;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
font-family: inherit;
}
</style>
</head>
<body>
<div class="offline-card">
<div class="offline-mark">D</div>
<h1>You are offline</h1>
<p>DevPlace could not reach the network. Check your connection and try again.</p>
<button type="button" onclick="location.reload()">Retry</button>
</div>
</body>
</html>
+76
View File
@@ -0,0 +1,76 @@
// retoor <retoor@molodetz.nl>
const CACHE_NAME = "devplace-shell-v1";
const OFFLINE_URL = "/static/offline.html";
const PRECACHE_URLS = [
OFFLINE_URL,
"/manifest.json",
"/static/icon-192.png",
"/static/icon-512.png",
];
const DEFAULT_URL = "/notifications";
const DEFAULT_ICON = "/static/icon-192.png";
self.addEventListener("install", (event) => {
event.waitUntil(
caches.open(CACHE_NAME).then((cache) => cache.addAll(PRECACHE_URLS)).then(() => self.skipWaiting())
);
});
self.addEventListener("activate", (event) => {
event.waitUntil(
caches.keys()
.then((names) => Promise.all(names.filter((name) => name !== CACHE_NAME).map((name) => caches.delete(name))))
.then(() => self.clients.claim())
);
});
self.addEventListener("fetch", (event) => {
if (event.request.mode !== "navigate") {
return;
}
event.respondWith(
fetch(event.request).catch(() => caches.match(OFFLINE_URL))
);
});
function isClientOpen(url) {
return clients.matchAll().then((matchedClients) =>
matchedClients.some((client) => client.url === url && "focus" in client)
);
}
self.addEventListener("push", (event) => {
event.waitUntil(handlePush(event));
});
async function handlePush(event) {
if (!self.Notification || self.Notification.permission !== "granted") {
return;
}
const data = event.data ? event.data.json() : {};
const url = data.url || DEFAULT_URL;
if (await isClientOpen(url)) {
return;
}
const title = data.title || "DevPlace";
const message = data.message || "You have a new notification.";
const icon = data.icon || DEFAULT_ICON;
await self.registration.showNotification(title, {
body: message,
icon: icon,
badge: icon,
tag: "devplace-notification",
data: data,
});
}
self.addEventListener("notificationclick", (event) => {
event.notification.close();
const url = event.notification.data && event.notification.data.url ? event.notification.data.url : DEFAULT_URL;
event.waitUntil(clients.openWindow(url));
});