7d464c21eb
- VAPID-based push via web-push package - push_subscriptions table: endpoint + keys per user (upsert on conflict) - GET /api/push/vapid-key — public key for subscribe flow - POST/DELETE /api/push/subscribe — store/remove subscription - POST /api/push/test — manual test notification - Hourly scheduler: notifies users day before homework due + countdown expires - SW: push event handler shows notification; notificationclick opens /app - Settings: Push section with enable/disable/test buttons, auto-detects browser support and VAPID availability
97 lines
2.4 KiB
JavaScript
97 lines
2.4 KiB
JavaScript
const CACHE = 'klassenportal-v2';
|
|
const PRECACHE = [
|
|
'/',
|
|
'/login.html',
|
|
'/app.html',
|
|
'/manifest.json',
|
|
'/favicon.svg',
|
|
'/icons/icon-192.png',
|
|
'/icons/icon-512.png',
|
|
'/icons/apple-touch-icon.png',
|
|
];
|
|
|
|
self.addEventListener('install', e => {
|
|
e.waitUntil(
|
|
caches.open(CACHE)
|
|
.then(c => c.addAll(PRECACHE))
|
|
.then(() => self.skipWaiting())
|
|
);
|
|
});
|
|
|
|
self.addEventListener('activate', e => {
|
|
e.waitUntil(
|
|
caches.keys()
|
|
.then(keys => Promise.all(
|
|
keys.filter(k => k !== CACHE).map(k => caches.delete(k))
|
|
))
|
|
.then(() => self.clients.claim())
|
|
);
|
|
});
|
|
|
|
self.addEventListener('push', e => {
|
|
let data = { title: 'Klassenportal', body: '' };
|
|
try { data = e.data ? e.data.json() : data; } catch {}
|
|
e.waitUntil(
|
|
self.registration.showNotification(data.title, {
|
|
body: data.body,
|
|
icon: '/icons/icon-192.png',
|
|
badge: '/icons/icon-192.png',
|
|
tag: data.tag || 'kp',
|
|
renotify: true,
|
|
data: { url: data.url || '/app' },
|
|
})
|
|
);
|
|
});
|
|
|
|
self.addEventListener('notificationclick', e => {
|
|
e.notification.close();
|
|
const url = e.notification.data?.url || '/app';
|
|
e.waitUntil(
|
|
clients.matchAll({ type: 'window', includeUncontrolled: true }).then(list => {
|
|
const found = list.find(c => c.url.includes(self.location.origin));
|
|
return found ? found.focus() : clients.openWindow(url);
|
|
})
|
|
);
|
|
});
|
|
|
|
self.addEventListener('fetch', e => {
|
|
const url = new URL(e.request.url);
|
|
|
|
// Only handle same-origin GET requests
|
|
if (url.origin !== self.location.origin) return;
|
|
if (e.request.method !== 'GET') return;
|
|
|
|
// API calls: always network, never cache
|
|
if (url.pathname.startsWith('/api/')) return;
|
|
|
|
// Page navigations: network-first, fallback to cache
|
|
if (e.request.mode === 'navigate') {
|
|
e.respondWith(
|
|
fetch(e.request)
|
|
.then(r => {
|
|
const clone = r.clone();
|
|
caches.open(CACHE).then(c => c.put(e.request, clone));
|
|
return r;
|
|
})
|
|
.catch(() =>
|
|
caches.match(e.request).then(r => r || caches.match('/login.html'))
|
|
)
|
|
);
|
|
return;
|
|
}
|
|
|
|
// Static assets: cache-first, populate on miss
|
|
e.respondWith(
|
|
caches.match(e.request).then(cached => {
|
|
if (cached) return cached;
|
|
return fetch(e.request).then(r => {
|
|
if (r.ok) {
|
|
const clone = r.clone();
|
|
caches.open(CACHE).then(c => c.put(e.request, clone));
|
|
}
|
|
return r;
|
|
});
|
|
})
|
|
);
|
|
});
|