From c6e2fed82c6ebbe8c1612a53fdaf26336dd9c363 Mon Sep 17 00:00:00 2001 From: Serverlele30 <46974281+Serverlele30@users.noreply.github.com> Date: Mon, 31 Aug 2026 16:04:38 +0200 Subject: [PATCH] Add files via upload --- index.html | 1 + js/api.js | 25 ++++++++++ js/app.js | 19 +++++++- js/push.js | 127 ++++++++++++++++++++++++++++++++++++++++++++++++ js/tripalarm.js | 7 +++ styles.css | 6 ++- sw.js | 53 ++++++++++++++++++++ 7 files changed, 235 insertions(+), 3 deletions(-) create mode 100644 js/push.js create mode 100644 sw.js diff --git a/index.html b/index.html index bf55e6a..f59ca7b 100644 --- a/index.html +++ b/index.html @@ -228,6 +228,7 @@ +
diff --git a/js/api.js b/js/api.js index ad5b0f1..92841e8 100644 --- a/js/api.js +++ b/js/api.js @@ -34,6 +34,14 @@ const API = (() => { }); } + function deleteJson(path, body) { + return request(path, { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + } + // Baut einen Query-String und lässt dabei undefined/null-Werte weg. function qs(params) { const parts = []; @@ -146,5 +154,22 @@ const API = (() => { getLineDelayStats(line, days = 14) { return request(`/stats/delays/${encodeURIComponent(line)}?${qs({ days })}`); }, + + // Web-Push (siehe js/push.js + backend/src/routes/webPush.js) + getPushPublicKey() { + return request('/webpush/public-key'); + }, + subscribePush(subscription, favorites) { + return postJson('/webpush/subscribe', { subscription, favorites }); + }, + unsubscribePush(endpoint) { + return postJson('/webpush/unsubscribe', { endpoint }); + }, + setPushTripAlarm(endpoint, alarm) { + return postJson('/webpush/trip-alarm', { endpoint, ...alarm }); + }, + clearPushTripAlarm(endpoint) { + return deleteJson('/webpush/trip-alarm', { endpoint }); + }, }; })(); \ No newline at end of file diff --git a/js/app.js b/js/app.js index 15af4a5..5f9972e 100644 --- a/js/app.js +++ b/js/app.js @@ -134,6 +134,7 @@ const App = (() => { renderFavoriteChips(); renderRecentSearches(); updateFavoriteButton(); + if (window.Push) Push.syncFavorites(favorites); } function renderFavoriteChips() { @@ -339,9 +340,22 @@ const App = (() => { if (!btn || !window.Notification) return; btn.addEventListener('click', async () => { try { - await Notification.requestPermission(); + // Volles Web-Push-Abo statt nur lokaler Notification-Erlaubnis - + // funktioniert dadurch auch, wenn der Tab/Browser geschlossen ist. + // Fällt automatisch auf den bisherigen, rein Tab-lokalen Push zurück, + // falls der Browser Web-Push nicht unterstützt oder der Server es + // nicht konfiguriert hat (Push.enable() wirft dann einen Fehler, den + // wir hier abfangen - die Tab-lokalen Benachrichtigungen laufen so + // oder so über den normalen Notification.requestPermission()-Pfad + // weiter, den Push.enable() intern mit aufruft). + if (window.Push && Push.isSupported()) { + await Push.enable(loadFavorites()); + } else { + await Notification.requestPermission(); + } } catch (err) { - console.error('Notification-Permission-Fehler:', err.message); + console.error('Benachrichtigungen aktivieren fehlgeschlagen:', err.message); + showToast(err.message, 'warning'); } updateNotifyToggleVisibility(loadFavorites().length > 0); }); @@ -1262,6 +1276,7 @@ const App = (() => { function init() { I18N.init(); + if (window.Push && Push.isSupported()) Push.registerServiceWorker(); I18N.setOnLangChange(() => { // [data-i18n]-Elemente übernimmt I18N.applyStaticTranslations() bereits // selbst - hier nur die dynamisch (per JS-Template) erzeugten Bereiche diff --git a/js/push.js b/js/push.js new file mode 100644 index 0000000..3d33373 --- /dev/null +++ b/js/push.js @@ -0,0 +1,127 @@ +// Web-Push: Service-Worker-Registrierung, Subscription-Verwaltung und +// Synchronisierung der Favoriten/des Fahrtalarms mit dem Server, damit +// Benachrichtigungen auch bei geschlossenem Tab/Browser ankommen. +// +// Bewusst getrennt vom bisherigen, rein Tab-basierten Störungs-Push (siehe +// app.js showToast()/Notification-Aufrufe) - der bleibt als sofortiges, +// tab-lokales Feedback bestehen. Dieses Modul ist die zusätzliche, +// "funktioniert auch wenn die App zu ist"-Schicht. + +const Push = (() => { + const STORAGE_KEY = 'oepnv-navi:db-push-enabled'; + + let registration = null; + + function isSupported() { + return 'serviceWorker' in navigator && 'PushManager' in window; + } + + async function registerServiceWorker() { + if (!isSupported()) return null; + try { + registration = await navigator.serviceWorker.register('/sw.js'); + return registration; + } catch (err) { + console.error('Service-Worker-Registrierung fehlgeschlagen:', err.message); + return null; + } + } + + // Wandelt den base64-kodierten VAPID-Public-Key in das Uint8Array-Format + // um, das PushManager.subscribe() erwartet. + function urlBase64ToUint8Array(base64String) { + const padding = '='.repeat((4 - (base64String.length % 4)) % 4); + const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/'); + const rawData = window.atob(base64); + return Uint8Array.from([...rawData].map((c) => c.charCodeAt(0))); + } + + function isEnabled() { + return localStorage.getItem(STORAGE_KEY) === 'true'; + } + + async function getExistingSubscription() { + if (!registration) return null; + try { + return await registration.pushManager.getSubscription(); + } catch (_) { + return null; + } + } + + // Aktiviert Web-Push: fragt Benachrichtigungs-Erlaubnis an, abonniert beim + // Push-Dienst des Browsers und meldet die Subscription + die aktuellen + // Favoriten beim eigenen Backend an. + async function enable(favorites) { + if (!isSupported()) throw new Error('Push wird von diesem Browser nicht unterstützt.'); + if (!registration) await registerServiceWorker(); + if (!registration) throw new Error('Service Worker konnte nicht registriert werden.'); + + const permission = await Notification.requestPermission(); + if (permission !== 'granted') throw new Error('Berechtigung für Benachrichtigungen wurde nicht erteilt.'); + + const { publicKey } = await API.getPushPublicKey(); + let subscription = await getExistingSubscription(); + if (!subscription) { + subscription = await registration.pushManager.subscribe({ + userVisibleOnly: true, + applicationServerKey: urlBase64ToUint8Array(publicKey), + }); + } + + await API.subscribePush(subscription.toJSON(), favorites); + localStorage.setItem(STORAGE_KEY, 'true'); + } + + async function disable() { + localStorage.setItem(STORAGE_KEY, 'false'); + const subscription = await getExistingSubscription(); + if (!subscription) return; + try { + await API.unsubscribePush(subscription.endpoint); + await subscription.unsubscribe(); + } catch (err) { + console.error('Push-Abmeldung fehlgeschlagen:', err.message); + } + } + + // Wird aufgerufen, wenn sich die Favoriten ändern, während Push aktiv ist - + // hält den Server auf dem gleichen Stand wie den Browser. + async function syncFavorites(favorites) { + if (!isEnabled()) return; + const subscription = await getExistingSubscription(); + if (!subscription) return; + try { + await API.subscribePush(subscription.toJSON(), favorites); + } catch (err) { + console.error('Push-Favoriten-Sync fehlgeschlagen:', err.message); + } + } + + // Spiegelt einen Fahrtalarm serverseitig - "best effort": schlägt das fehl + // (z.B. weil Push gar nicht aktiviert ist), bleibt der normale, Tab-lokale + // Fahrtalarm trotzdem voll funktionsfähig. + async function syncTripAlarm(alarm) { + if (!isEnabled()) return; + const subscription = await getExistingSubscription(); + if (!subscription) return; + try { + await API.setPushTripAlarm(subscription.endpoint, alarm); + } catch (err) { + console.error('Fahrtalarm-Push-Sync fehlgeschlagen:', err.message); + } + } + + async function clearTripAlarm() { + if (!isEnabled()) return; + const subscription = await getExistingSubscription(); + if (!subscription) return; + try { + await API.clearPushTripAlarm(subscription.endpoint); + } catch (err) { + console.error('Fahrtalarm-Push-Löschen fehlgeschlagen:', err.message); + } + } + + return { isSupported, registerServiceWorker, isEnabled, enable, disable, syncFavorites, syncTripAlarm, clearTripAlarm }; +})(); diff --git a/js/tripalarm.js b/js/tripalarm.js index 446bf7c..c7f6c25 100644 --- a/js/tripalarm.js +++ b/js/tripalarm.js @@ -30,6 +30,12 @@ const TripAlarm = (() => { renderBanner(); restartPolling(); check(); // sofort einmal prüfen statt erst nach POLL_MS zu warten + + // Best effort: Alarm zusätzlich serverseitig spiegeln, damit er auch bei + // geschlossenem Tab/Browser noch auslöst. Schlägt das fehl (Push nicht + // aktiviert/unterstützt), läuft der normale Tab-lokale Alarm unverändert + // weiter - das hier ist rein additiv. + if (window.Push) Push.syncTripAlarm({ tripId, source: 'vbb', line, targetName, targetIndex, stopsBefore: alarm.stopsBefore }); } function clear() { @@ -39,6 +45,7 @@ const TripAlarm = (() => { stopPolling(); hideBanner(); hideOverlay(); + if (window.Push) Push.clearTripAlarm(); } function getActive() { diff --git a/styles.css b/styles.css index ab42240..9f1969d 100644 --- a/styles.css +++ b/styles.css @@ -1766,7 +1766,11 @@ button:active { transform: translateY(1px); } .transfer-slack-field select { width: 100%; } .mobile-nav { display: grid; grid-template-columns: repeat(4, 1fr); position: fixed; bottom: 0; left: 0; right: 0; - z-index: 50; background: rgba(23,30,38,.97); border-top: 1px solid var(--line); padding: 5px 4px calc(5px + env(safe-area-inset-bottom)); + /* Muss über der (jetzt display-breiten) Live-Karte liegen - Leaflet nutzt + für seine eigenen Steuerelemente (Zoom-Buttons etc.) intern z-index- + Werte bis ~1000, das HUD/die Navigation soll aber immer sichtbar und + bedienbar bleiben, unabhängig von der Karte. */ + z-index: 1100; background: rgba(23,30,38,.97); border-top: 1px solid var(--line); padding: 5px 4px calc(5px + env(safe-area-inset-bottom)); } .mobile-nav__item { border: 0; background: transparent; color: var(--ink-muted); padding: 5px 2px; display: flex; flex-direction: column; align-items: center; gap: 2px; } .mobile-nav__item span { font-size: 18px; line-height: 1.1; } diff --git a/sw.js b/sw.js new file mode 100644 index 0000000..18a2744 --- /dev/null +++ b/sw.js @@ -0,0 +1,53 @@ +// Service Worker für Web-Push. Läuft unabhängig vom offenen Tab im +// Hintergrund und zeigt Systembenachrichtigungen, sobald der Server (siehe +// backend/src/utils/pushJobs.js) eine Push-Nachricht schickt. +// +// WICHTIG: diese Datei muss im Root des Frontends liegen (dort, wo auch +// index.html liegt), NICHT unter js/ - der Geltungsbereich ("scope") eines +// Service Workers ist standardmäßig auf sein eigenes Verzeichnis und alles +// darunter beschränkt. + +self.addEventListener('install', () => { + self.skipWaiting(); +}); + +self.addEventListener('activate', (event) => { + event.waitUntil(self.clients.claim()); +}); + +self.addEventListener('push', (event) => { + let data = {}; + try { + data = event.data ? event.data.json() : {}; + } catch (err) { + data = { title: 'ÖPNV Navi', body: event.data ? event.data.text() : '' }; + } + + const title = data.title || 'ÖPNV Navi'; + const options = { + body: data.body || '', + tag: data.tag || 'oepnv-navi', + icon: '/icons/icon-192.png', + badge: '/icons/badge-72.png', + data: { url: data.url || '/' }, + }; + + event.waitUntil(self.registration.showNotification(title, options)); +}); + +// Klick auf die Benachrichtigung: vorhandenen App-Tab in den Vordergrund +// holen, statt immer einen neuen zu öffnen. +self.addEventListener('notificationclick', (event) => { + event.notification.close(); + const targetUrl = (event.notification.data && event.notification.data.url) || '/'; + + event.waitUntil( + self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clientList) => { + for (const client of clientList) { + if ('focus' in client) return client.focus(); + } + if (self.clients.openWindow) return self.clients.openWindow(targetUrl); + return undefined; + }), + ); +});