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