feat: Web Push notifications
- 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
This commit is contained in:
@@ -1631,6 +1631,16 @@ footer {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-section" id="push-section">
|
||||
<div class="settings-label">Push-Benachrichtigungen</div>
|
||||
<div id="push-status" style="font-size:13px;color:var(--text-muted);margin-bottom:8px">Wird geprüft…</div>
|
||||
<div style="display:flex;gap:8px;flex-wrap:wrap">
|
||||
<button class="btn-save" id="btn-push-enable" style="font-size:12px;padding:5px 12px;display:none" onclick="enablePush()">Aktivieren</button>
|
||||
<button class="btn-cancel" id="btn-push-disable" style="font-size:12px;padding:5px 12px;display:none" onclick="disablePush()">Deaktivieren</button>
|
||||
<button class="btn-cancel" id="btn-push-test" style="font-size:12px;padding:5px 12px;display:none" onclick="testPush()">Test senden</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="settings-section">
|
||||
<div class="settings-label">Kalender-Export (iCal)</div>
|
||||
<p style="font-size:12px;color:var(--text-muted);margin-bottom:8px">Abonniere deinen Stundenplan, Hausaufgaben und Events in Google Calendar, Apple Calendar oder Outlook.</p>
|
||||
@@ -2165,6 +2175,7 @@ function openSettings(){
|
||||
document.getElementById('settings-overlay').style.display='flex';
|
||||
document.getElementById('user-dropdown')?.classList.remove('open');
|
||||
load2FAStatus();
|
||||
initPushUI();
|
||||
if(currentRole==='teacher'){
|
||||
document.getElementById('teacher-subject-section').style.display='';
|
||||
loadMySubjects();
|
||||
@@ -2369,6 +2380,89 @@ async function deleteAccount(){
|
||||
else toast(r.error,'error');
|
||||
}
|
||||
|
||||
// ── WEB PUSH ──────────────────────────────────────────────────
|
||||
let _pushReg = null;
|
||||
|
||||
function urlBase64ToUint8Array(b64) {
|
||||
const pad = '='.repeat((4 - b64.length % 4) % 4);
|
||||
const raw = atob((b64 + pad).replace(/-/g, '+').replace(/_/g, '/'));
|
||||
return new Uint8Array([...raw].map(c => c.charCodeAt(0)));
|
||||
}
|
||||
|
||||
async function initPushUI() {
|
||||
const section = document.getElementById('push-section');
|
||||
if (!('serviceWorker' in navigator) || !('PushManager' in window)) {
|
||||
section.style.display = 'none'; return;
|
||||
}
|
||||
const { key } = await api('GET', 'push/vapid-key').catch(() => ({}));
|
||||
if (!key) { section.style.display = 'none'; return; }
|
||||
|
||||
const reg = await navigator.serviceWorker.ready;
|
||||
_pushReg = reg;
|
||||
const existing = await reg.pushManager.getSubscription();
|
||||
updatePushUI(existing);
|
||||
}
|
||||
|
||||
function updatePushUI(sub) {
|
||||
const status = document.getElementById('push-status');
|
||||
const btnEn = document.getElementById('btn-push-enable');
|
||||
const btnDis = document.getElementById('btn-push-disable');
|
||||
const btnTest = document.getElementById('btn-push-test');
|
||||
if (sub) {
|
||||
status.textContent = 'Aktiv – du erhältst Benachrichtigungen bei fälligen Hausaufgaben und Countdowns.';
|
||||
status.style.color = 'var(--green)';
|
||||
btnEn.style.display = 'none';
|
||||
btnDis.style.display = '';
|
||||
btnTest.style.display = '';
|
||||
} else {
|
||||
status.textContent = 'Deaktiviert.';
|
||||
status.style.color = 'var(--text-muted)';
|
||||
btnEn.style.display = '';
|
||||
btnDis.style.display = 'none';
|
||||
btnTest.style.display = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
async function enablePush() {
|
||||
try {
|
||||
const { key } = await api('GET', 'push/vapid-key');
|
||||
const perm = await Notification.requestPermission();
|
||||
if (perm !== 'granted') { toast('Benachrichtigungen verweigert', 'error'); return; }
|
||||
const reg = _pushReg || await navigator.serviceWorker.ready;
|
||||
const sub = await reg.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: urlBase64ToUint8Array(key),
|
||||
});
|
||||
const { endpoint, keys } = sub.toJSON();
|
||||
const r = await api('POST', 'push/subscribe', { endpoint, keys });
|
||||
if (r.error) { toast(r.error, 'error'); return; }
|
||||
updatePushUI(sub);
|
||||
toast('Push-Benachrichtigungen aktiviert ✓');
|
||||
} catch (e) {
|
||||
toast('Fehler: ' + e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function disablePush() {
|
||||
try {
|
||||
const reg = _pushReg || await navigator.serviceWorker.ready;
|
||||
const sub = await reg.pushManager.getSubscription();
|
||||
if (sub) {
|
||||
await api('DELETE', 'push/subscribe', { endpoint: sub.endpoint });
|
||||
await sub.unsubscribe();
|
||||
}
|
||||
updatePushUI(null);
|
||||
toast('Push-Benachrichtigungen deaktiviert');
|
||||
} catch (e) {
|
||||
toast('Fehler: ' + e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function testPush() {
|
||||
await api('POST', 'push/test');
|
||||
toast('Test-Benachrichtigung gesendet');
|
||||
}
|
||||
|
||||
// ── ICAL EXPORT ───────────────────────────────────────────────
|
||||
async function getIcalUrl(regen=false){
|
||||
const r=await api('POST', regen?'ical-token/regenerate':'ical-token');
|
||||
|
||||
@@ -28,6 +28,32 @@ self.addEventListener('activate', e => {
|
||||
);
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user