Add files via upload
This commit is contained in:
1 parent
05bae700ec
commit
c6e2fed82c
7 files changed
+235
-3
No files matched your search
@@ -228,6 +228,7 @@
|
|||||||
<script src="js/livemap.js"></script>
|
<script src="js/livemap.js"></script>
|
||||||
<script src="js/tripalarm.js"></script>
|
<script src="js/tripalarm.js"></script>
|
||||||
<script src="js/tripmodal.js"></script>
|
<script src="js/tripmodal.js"></script>
|
||||||
|
<script src="js/push.js"></script>
|
||||||
<script src="js/app.js"></script>
|
<script src="js/app.js"></script>
|
||||||
<div id="toast-container" class="toast-container" aria-live="polite"></div>
|
<div id="toast-container" class="toast-container" aria-live="polite"></div>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -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.
|
// Baut einen Query-String und lässt dabei undefined/null-Werte weg.
|
||||||
function qs(params) {
|
function qs(params) {
|
||||||
const parts = [];
|
const parts = [];
|
||||||
@@ -146,5 +154,22 @@ const API = (() => {
|
|||||||
getLineDelayStats(line, days = 14) {
|
getLineDelayStats(line, days = 14) {
|
||||||
return request(`/stats/delays/${encodeURIComponent(line)}?${qs({ days })}`);
|
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 });
|
||||||
|
},
|
||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
@@ -134,6 +134,7 @@ const App = (() => {
|
|||||||
renderFavoriteChips();
|
renderFavoriteChips();
|
||||||
renderRecentSearches();
|
renderRecentSearches();
|
||||||
updateFavoriteButton();
|
updateFavoriteButton();
|
||||||
|
if (window.Push) Push.syncFavorites(favorites);
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderFavoriteChips() {
|
function renderFavoriteChips() {
|
||||||
@@ -339,9 +340,22 @@ const App = (() => {
|
|||||||
if (!btn || !window.Notification) return;
|
if (!btn || !window.Notification) return;
|
||||||
btn.addEventListener('click', async () => {
|
btn.addEventListener('click', async () => {
|
||||||
try {
|
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) {
|
} catch (err) {
|
||||||
console.error('Notification-Permission-Fehler:', err.message);
|
console.error('Benachrichtigungen aktivieren fehlgeschlagen:', err.message);
|
||||||
|
showToast(err.message, 'warning');
|
||||||
}
|
}
|
||||||
updateNotifyToggleVisibility(loadFavorites().length > 0);
|
updateNotifyToggleVisibility(loadFavorites().length > 0);
|
||||||
});
|
});
|
||||||
@@ -1262,6 +1276,7 @@ const App = (() => {
|
|||||||
|
|
||||||
function init() {
|
function init() {
|
||||||
I18N.init();
|
I18N.init();
|
||||||
|
if (window.Push && Push.isSupported()) Push.registerServiceWorker();
|
||||||
I18N.setOnLangChange(() => {
|
I18N.setOnLangChange(() => {
|
||||||
// [data-i18n]-Elemente übernimmt I18N.applyStaticTranslations() bereits
|
// [data-i18n]-Elemente übernimmt I18N.applyStaticTranslations() bereits
|
||||||
// selbst - hier nur die dynamisch (per JS-Template) erzeugten Bereiche
|
// selbst - hier nur die dynamisch (per JS-Template) erzeugten Bereiche
|
||||||
|
|||||||
+127
@@ -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 };
|
||||||
|
})();
|
||||||
@@ -30,6 +30,12 @@ const TripAlarm = (() => {
|
|||||||
renderBanner();
|
renderBanner();
|
||||||
restartPolling();
|
restartPolling();
|
||||||
check(); // sofort einmal prüfen statt erst nach POLL_MS zu warten
|
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() {
|
function clear() {
|
||||||
@@ -39,6 +45,7 @@ const TripAlarm = (() => {
|
|||||||
stopPolling();
|
stopPolling();
|
||||||
hideBanner();
|
hideBanner();
|
||||||
hideOverlay();
|
hideOverlay();
|
||||||
|
if (window.Push) Push.clearTripAlarm();
|
||||||
}
|
}
|
||||||
|
|
||||||
function getActive() {
|
function getActive() {
|
||||||
|
|||||||
+5
-1
@@ -1766,7 +1766,11 @@ button:active { transform: translateY(1px); }
|
|||||||
.transfer-slack-field select { width: 100%; }
|
.transfer-slack-field select { width: 100%; }
|
||||||
.mobile-nav {
|
.mobile-nav {
|
||||||
display: grid; grid-template-columns: repeat(4, 1fr); position: fixed; bottom: 0; left: 0; right: 0;
|
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 { 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; }
|
.mobile-nav__item span { font-size: 18px; line-height: 1.1; }
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
Reference in new issue
Block a user