Add files via upload
This commit is contained in:
1 parent
63da9cdbba
commit
e53cd580c5
4 files changed
+274
-19
No files matched your search
@@ -58,6 +58,7 @@
|
|||||||
<div id="favorites-home" class="home-section" hidden>
|
<div id="favorites-home" class="home-section" hidden>
|
||||||
<div class="section-heading">
|
<div class="section-heading">
|
||||||
<div><span class="section-heading__eyebrow">SCHNELLZUGRIFF</span><h2>Meine Haltestellen</h2></div>
|
<div><span class="section-heading__eyebrow">SCHNELLZUGRIFF</span><h2>Meine Haltestellen</h2></div>
|
||||||
|
<button id="notify-toggle" class="text-button" type="button" hidden>🔔 Störungsbenachrichtigungen aktivieren</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="favorite-cards" class="favorite-cards"></div>
|
<div id="favorite-cards" class="favorite-cards"></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -191,5 +192,6 @@
|
|||||||
<script src="js/livemap.js"></script>
|
<script src="js/livemap.js"></script>
|
||||||
<script src="js/tripmodal.js"></script>
|
<script src="js/tripmodal.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>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -2,10 +2,13 @@ const App = (() => {
|
|||||||
const FAVORITES_KEY = 'oepnv-navi:db-favorites';
|
const FAVORITES_KEY = 'oepnv-navi:db-favorites';
|
||||||
const LAST_STOP_KEY = 'oepnv-navi:db-last-stop';
|
const LAST_STOP_KEY = 'oepnv-navi:db-last-stop';
|
||||||
const RECENT_SEARCHES_KEY = 'oepnv-navi:db-recent-searches';
|
const RECENT_SEARCHES_KEY = 'oepnv-navi:db-recent-searches';
|
||||||
|
const SEEN_DISRUPTIONS_KEY = 'oepnv-navi:db-seen-disruptions';
|
||||||
|
const FAVORITES_POLL_MS = 30000;
|
||||||
|
|
||||||
let currentStop = null;
|
let currentStop = null;
|
||||||
let departuresPollHandle = null;
|
let departuresPollHandle = null;
|
||||||
let departuresModeFilter = null;
|
let departuresModeFilter = null;
|
||||||
|
let favoritesPollHandle = null;
|
||||||
|
|
||||||
let journeyFrom = null;
|
let journeyFrom = null;
|
||||||
let journeyTo = null;
|
let journeyTo = null;
|
||||||
@@ -102,6 +105,7 @@ const App = (() => {
|
|||||||
<span>
|
<span>
|
||||||
<strong>${escapeHtml(fav.name)}</strong>
|
<strong>${escapeHtml(fav.name)}</strong>
|
||||||
<small class="favorite-card__next" data-next-departure>Lade nächste Abfahrt…</small>
|
<small class="favorite-card__next" data-next-departure>Lade nächste Abfahrt…</small>
|
||||||
|
<small class="favorite-card__disruption" data-disruption hidden></small>
|
||||||
</span>
|
</span>
|
||||||
<span class="favorite-card__arrow">→</span>
|
<span class="favorite-card__arrow">→</span>
|
||||||
`;
|
`;
|
||||||
@@ -115,32 +119,107 @@ const App = (() => {
|
|||||||
const home = document.getElementById('favorites-home');
|
const home = document.getElementById('favorites-home');
|
||||||
if (home) home.hidden = favorites.length === 0;
|
if (home) home.hidden = favorites.length === 0;
|
||||||
|
|
||||||
if (favorites.length > 0) loadFavoriteNextDepartures(favorites);
|
updateNotifyToggleVisibility(favorites.length > 0);
|
||||||
|
|
||||||
|
if (favorites.length > 0) {
|
||||||
|
loadFavoriteNextDepartures(favorites);
|
||||||
|
startFavoritesPolling();
|
||||||
|
} else {
|
||||||
|
stopFavoritesPolling();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Störungs-Erkennung für Favoriten ("Störungs-Push") ----------
|
||||||
|
//
|
||||||
|
// Läuft im Hintergrund, solange Favoriten vorhanden sind (siehe
|
||||||
|
// startFavoritesPolling). Vergleicht bei jedem Poll die von /summary/departures
|
||||||
|
// gelieferten Störungen einer Haltestelle mit den zuletzt gesehenen (in
|
||||||
|
// localStorage gemerkt) und meldet nur wirklich NEUE Störungen - sonst würde
|
||||||
|
// bei jedem 30s-Poll erneut benachrichtigt werden, solange eine Störung andauert.
|
||||||
|
|
||||||
|
function loadSeenDisruptions() {
|
||||||
|
try { return JSON.parse(localStorage.getItem(SEEN_DISRUPTIONS_KEY)) || {}; }
|
||||||
|
catch (_) { return {}; }
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveSeenDisruptions(map) {
|
||||||
|
localStorage.setItem(SEEN_DISRUPTIONS_KEY, JSON.stringify(map));
|
||||||
|
}
|
||||||
|
|
||||||
|
function disruptionKey(d) {
|
||||||
|
return d.id || `${d.type || ''}-${d.text || d.summary || ''}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function notifyNewDisruption(favName, disruption) {
|
||||||
|
const message = `${favName}: ${disruption.summary || 'Neue Störung'}`;
|
||||||
|
showToast(message, 'warning');
|
||||||
|
|
||||||
|
if (window.Notification && Notification.permission === 'granted') {
|
||||||
|
try {
|
||||||
|
new Notification('Störung bei ' + favName, {
|
||||||
|
body: disruption.summary || disruption.text || 'Es liegt eine neue Störungsmeldung vor.',
|
||||||
|
tag: `oepnv-navi-disruption-${favName}-${disruptionKey(disruption)}`,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Notification-Fehler:', err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderFavoriteDisruption(card, favName, disruptions, seenMap, favId) {
|
||||||
|
const el = card.querySelector('[data-disruption]');
|
||||||
|
if (!el) return;
|
||||||
|
|
||||||
|
if (!disruptions || disruptions.length === 0) {
|
||||||
|
el.hidden = true;
|
||||||
|
el.textContent = '';
|
||||||
|
card.classList.remove('favorite-card--disrupted');
|
||||||
|
delete seenMap[favId];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
card.classList.add('favorite-card--disrupted');
|
||||||
|
el.hidden = false;
|
||||||
|
el.textContent = `⚠ ${disruptions[0].summary || 'Störung'}${disruptions.length > 1 ? ` (+${disruptions.length - 1} weitere)` : ''}`;
|
||||||
|
|
||||||
|
const previousKeys = new Set(seenMap[favId] || []);
|
||||||
|
disruptions.forEach((d) => {
|
||||||
|
const key = disruptionKey(d);
|
||||||
|
if (!previousKeys.has(key)) {
|
||||||
|
notifyNewDisruption(favName, d);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
seenMap[favId] = disruptions.map(disruptionKey);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadFavoriteNextDepartures(favorites) {
|
async function loadFavoriteNextDepartures(favorites) {
|
||||||
const stations = favorites.map((f) => ({ id: f.id, source: f.source || 'vbb', name: f.name }));
|
const stations = favorites.map((f) => ({ id: f.id, source: f.source || 'vbb', name: f.name }));
|
||||||
|
const seenMap = loadSeenDisruptions();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { results } = await API.getSummaryDepartures(stations);
|
const { results } = await API.getSummaryDepartures(stations);
|
||||||
results.forEach((r) => {
|
results.forEach((r) => {
|
||||||
const card = document.querySelector(`.favorite-card[data-fav-id="${CSS.escape(String(r.id))}"]`);
|
const card = document.querySelector(`.favorite-card[data-fav-id="${CSS.escape(String(r.id))}"]`);
|
||||||
if (!card) return;
|
if (!card) return;
|
||||||
const el = card.querySelector('[data-next-departure]');
|
const el = card.querySelector('[data-next-departure]');
|
||||||
if (!el) return;
|
|
||||||
|
|
||||||
if (r.error || !r.nextDeparture) {
|
if (el) {
|
||||||
el.textContent = 'Keine Abfahrt gefunden';
|
if (r.error || !r.nextDeparture) {
|
||||||
return;
|
el.textContent = 'Keine Abfahrt gefunden';
|
||||||
|
} else {
|
||||||
|
const dep = r.nextDeparture;
|
||||||
|
const minutesUntil = dep.when ? Math.max(0, Math.round((new Date(dep.when) - new Date()) / 60000)) : null;
|
||||||
|
if (dep.cancelled) {
|
||||||
|
el.innerHTML = `<span class="favorite-card__cancelled">${escapeHtml(dep.line || '')} fällt aus</span>`;
|
||||||
|
} else {
|
||||||
|
el.textContent = `${dep.line || '?'} → ${dep.direction || ''} · ${minutesUntil != null ? minutesUntil + ' min' : ''}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const dep = r.nextDeparture;
|
renderFavoriteDisruption(card, r.name || '', r.disruptions, seenMap, r.id);
|
||||||
const minutesUntil = dep.when ? Math.max(0, Math.round((new Date(dep.when) - new Date()) / 60000)) : null;
|
|
||||||
if (dep.cancelled) {
|
|
||||||
el.innerHTML = `<span class="favorite-card__cancelled">${escapeHtml(dep.line || '')} fällt aus</span>`;
|
|
||||||
} else {
|
|
||||||
el.textContent = `${dep.line || '?'} → ${dep.direction || ''} · ${minutesUntil != null ? minutesUntil + ' min' : ''}`;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
saveSeenDisruptions(seenMap);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Stillschweigend ignorieren - die Karten zeigen dann weiterhin "Lade..."
|
// Stillschweigend ignorieren - die Karten zeigen dann weiterhin "Lade..."
|
||||||
// bzw. der Nutzer bekommt die Abfahrten trotzdem beim Antippen der Karte.
|
// bzw. der Nutzer bekommt die Abfahrten trotzdem beim Antippen der Karte.
|
||||||
@@ -148,6 +227,80 @@ const App = (() => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function startFavoritesPolling() {
|
||||||
|
if (favoritesPollHandle) clearInterval(favoritesPollHandle);
|
||||||
|
favoritesPollHandle = setInterval(() => {
|
||||||
|
const favorites = loadFavorites();
|
||||||
|
if (favorites.length > 0) loadFavoriteNextDepartures(favorites);
|
||||||
|
else stopFavoritesPolling();
|
||||||
|
}, FAVORITES_POLL_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopFavoritesPolling() {
|
||||||
|
if (favoritesPollHandle) {
|
||||||
|
clearInterval(favoritesPollHandle);
|
||||||
|
favoritesPollHandle = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Toast (kurze, unaufdringliche In-App-Meldung) ----------
|
||||||
|
|
||||||
|
function showToast(message, variant = 'info') {
|
||||||
|
const container = document.getElementById('toast-container');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
const toast = document.createElement('div');
|
||||||
|
toast.className = `toast toast--${variant}`;
|
||||||
|
toast.textContent = message;
|
||||||
|
container.appendChild(toast);
|
||||||
|
|
||||||
|
requestAnimationFrame(() => toast.classList.add('is-visible'));
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
toast.classList.remove('is-visible');
|
||||||
|
setTimeout(() => toast.remove(), 300);
|
||||||
|
}, 6000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Browser-Benachrichtigungen an/aus ----------
|
||||||
|
|
||||||
|
function updateNotifyToggleVisibility(hasFavorites) {
|
||||||
|
const btn = document.getElementById('notify-toggle');
|
||||||
|
if (!btn) return;
|
||||||
|
if (!hasFavorites || !window.Notification) {
|
||||||
|
btn.hidden = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
btn.hidden = false;
|
||||||
|
if (Notification.permission === 'granted') {
|
||||||
|
btn.textContent = '🔔 Benachrichtigungen aktiv';
|
||||||
|
btn.classList.add('is-active');
|
||||||
|
btn.disabled = true;
|
||||||
|
} else if (Notification.permission === 'denied') {
|
||||||
|
btn.textContent = '🔕 Benachrichtigungen blockiert';
|
||||||
|
btn.classList.remove('is-active');
|
||||||
|
btn.disabled = true;
|
||||||
|
} else {
|
||||||
|
btn.textContent = '🔔 Störungsbenachrichtigungen aktivieren';
|
||||||
|
btn.classList.remove('is-active');
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function initNotifications() {
|
||||||
|
const btn = document.getElementById('notify-toggle');
|
||||||
|
if (!btn || !window.Notification) return;
|
||||||
|
btn.addEventListener('click', async () => {
|
||||||
|
try {
|
||||||
|
await Notification.requestPermission();
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Notification-Permission-Fehler:', err.message);
|
||||||
|
}
|
||||||
|
updateNotifyToggleVisibility(loadFavorites().length > 0);
|
||||||
|
});
|
||||||
|
updateNotifyToggleVisibility(loadFavorites().length > 0);
|
||||||
|
}
|
||||||
|
|
||||||
function updateFavoriteButton() {
|
function updateFavoriteButton() {
|
||||||
const btn = document.getElementById('favorite-toggle');
|
const btn = document.getElementById('favorite-toggle');
|
||||||
if (!currentStop) {
|
if (!currentStop) {
|
||||||
@@ -831,6 +984,7 @@ const App = (() => {
|
|||||||
initJourneyPlanner();
|
initJourneyPlanner();
|
||||||
initNearby();
|
initNearby();
|
||||||
initNearbyDisruptions();
|
initNearbyDisruptions();
|
||||||
|
initNotifications();
|
||||||
renderFavoriteChips();
|
renderFavoriteChips();
|
||||||
renderRecentSearches();
|
renderRecentSearches();
|
||||||
|
|
||||||
|
|||||||
+56
-7
@@ -20,6 +20,7 @@ const LiveMap = (() => {
|
|||||||
let radarPollHandle = null;
|
let radarPollHandle = null;
|
||||||
let tripPollHandle = null;
|
let tripPollHandle = null;
|
||||||
let currentTripId = null;
|
let currentTripId = null;
|
||||||
|
let isolatedMarker = null; // persistenter Marker im Isolations-Modus, wird nur aktualisiert statt neu erzeugt
|
||||||
let isolatedVehicleMeta = null; // { line, color, textColor } für die Banner-Anzeige
|
let isolatedVehicleMeta = null; // { line, color, textColor } für die Banner-Anzeige
|
||||||
let getModesParam = () => null; // wird von außen (ModeFilter) gesetzt
|
let getModesParam = () => null; // wird von außen (ModeFilter) gesetzt
|
||||||
|
|
||||||
@@ -59,6 +60,11 @@ const LiveMap = (() => {
|
|||||||
return geojson.features.map((f) => [f.geometry.coordinates[1], f.geometry.coordinates[0]]);
|
return geojson.features.map((f) => [f.geometry.coordinates[1], f.geometry.coordinates[0]]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function vehiclePopupHtml(v) {
|
||||||
|
return `<strong>${v.line || 'Linie unbekannt'}</strong><br>${v.direction || ''}<br>` +
|
||||||
|
`<small>Nächster Halt: ${v.nextStopover || '–'}</small>`;
|
||||||
|
}
|
||||||
|
|
||||||
// ---------- Radar-Modus (Standard: alle Fahrzeuge im Ausschnitt) ----------
|
// ---------- Radar-Modus (Standard: alle Fahrzeuge im Ausschnitt) ----------
|
||||||
|
|
||||||
async function refreshRadar() {
|
async function refreshRadar() {
|
||||||
@@ -130,22 +136,58 @@ const LiveMap = (() => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
radarLayer.clearLayers();
|
|
||||||
const v = data.vehicles[0];
|
const v = data.vehicles[0];
|
||||||
if (v && v.latitude != null && v.longitude != null) {
|
if (!v || v.latitude == null || v.longitude == null) {
|
||||||
const marker = L.marker([v.latitude, v.longitude], { icon: vehicleIcon(v.line, v.color, v.textColor) });
|
// Fahrzeug gerade nicht meldend (z.B. kurzzeitig aus der Live-Erfassung) -
|
||||||
marker.bindPopup(`<strong>${v.line || ''}</strong><br>${v.direction || ''}<br><small>Nächster Halt: ${v.nextStopover || '–'}</small>`);
|
// Marker stehen lassen statt zu entfernen, nächster Poll aktualisiert ihn wieder.
|
||||||
marker.addTo(radarLayer);
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WICHTIG: Marker wird wiederverwendet statt bei jedem Poll neu erzeugt zu
|
||||||
|
// werden. Vorher wurde hier radarLayer.clearLayers() + ein komplett neuer
|
||||||
|
// Marker angelegt - das hat ein ggf. geöffnetes Popup jedes Mal geschlossen,
|
||||||
|
// sodass die Fahrzeuginfo (Richtung, nächster Halt) nach dem ersten Klick nie
|
||||||
|
// wieder aktualisiert im UI sichtbar wurde.
|
||||||
|
if (isolatedMarker) {
|
||||||
|
isolatedMarker.setLatLng([v.latitude, v.longitude]);
|
||||||
|
isolatedMarker.setIcon(vehicleIcon(v.line, v.color, v.textColor));
|
||||||
|
isolatedMarker.setPopupContent(vehiclePopupHtml(v));
|
||||||
|
} else {
|
||||||
|
isolatedMarker = L.marker([v.latitude, v.longitude], { icon: vehicleIcon(v.line, v.color, v.textColor) });
|
||||||
|
isolatedMarker.bindPopup(vehiclePopupHtml(v));
|
||||||
|
isolatedMarker.addTo(radarLayer);
|
||||||
|
}
|
||||||
|
|
||||||
|
isolatedVehicleMeta = { line: v.line, color: v.color, textColor: v.textColor };
|
||||||
|
updateIsolationBanner();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function isolateVehicle(vehicle) {
|
async function isolateVehicle(vehicle) {
|
||||||
if (!vehicle || !vehicle.tripId) return;
|
if (!vehicle || !vehicle.tripId) return;
|
||||||
|
|
||||||
|
// Das ursprüngliche Leaflet-Popup des angeklickten Radar-Markers (aus
|
||||||
|
// bindPopup in refreshRadar) schließen, bevor wir in den Isolations-Modus
|
||||||
|
// wechseln - sonst blitzt es kurz auf und verschwindet dann kommentarlos.
|
||||||
|
map.closePopup();
|
||||||
|
|
||||||
setMode('isolated');
|
setMode('isolated');
|
||||||
currentTripId = vehicle.tripId;
|
currentTripId = vehicle.tripId;
|
||||||
|
isolatedMarker = null;
|
||||||
|
radarLayer.clearLayers();
|
||||||
|
|
||||||
isolatedVehicleMeta = { line: vehicle.line, color: vehicle.color, textColor: vehicle.textColor };
|
isolatedVehicleMeta = { line: vehicle.line, color: vehicle.color, textColor: vehicle.textColor };
|
||||||
updateIsolationBanner();
|
updateIsolationBanner();
|
||||||
|
|
||||||
|
// Sofort einen Marker mit den Daten aus dem Klick anzeigen (nicht erst auf
|
||||||
|
// den ersten Poll warten) und direkt öffnen, damit man die Fahrzeuginfo
|
||||||
|
// ohne weiteren Klick sieht.
|
||||||
|
isolatedMarker = L.marker([vehicle.latitude, vehicle.longitude], {
|
||||||
|
icon: vehicleIcon(vehicle.line, vehicle.color, vehicle.textColor),
|
||||||
|
});
|
||||||
|
isolatedMarker.bindPopup(vehiclePopupHtml(vehicle));
|
||||||
|
isolatedMarker.addTo(radarLayer);
|
||||||
|
isolatedMarker.openPopup();
|
||||||
|
|
||||||
routeLayer.clearLayers();
|
routeLayer.clearLayers();
|
||||||
try {
|
try {
|
||||||
const trip = await API.getTrip(vehicle.tripId, { polyline: true });
|
const trip = await API.getTrip(vehicle.tripId, { polyline: true });
|
||||||
@@ -159,13 +201,13 @@ const LiveMap = (() => {
|
|||||||
console.error('Trip-Polyline-Fehler:', err.message);
|
console.error('Trip-Polyline-Fehler:', err.message);
|
||||||
}
|
}
|
||||||
|
|
||||||
refreshIsolated();
|
|
||||||
if (radarPollHandle) clearInterval(radarPollHandle);
|
if (radarPollHandle) clearInterval(radarPollHandle);
|
||||||
radarPollHandle = setInterval(refreshIsolated, 10000);
|
radarPollHandle = setInterval(refreshIsolated, 10000);
|
||||||
}
|
}
|
||||||
|
|
||||||
function clearIsolation() {
|
function clearIsolation() {
|
||||||
currentTripId = null;
|
currentTripId = null;
|
||||||
|
isolatedMarker = null;
|
||||||
isolatedVehicleMeta = null;
|
isolatedVehicleMeta = null;
|
||||||
updateIsolationBanner();
|
updateIsolationBanner();
|
||||||
startRadar();
|
startRadar();
|
||||||
@@ -211,12 +253,19 @@ const LiveMap = (() => {
|
|||||||
|
|
||||||
if (data.currentLocation) {
|
if (data.currentLocation) {
|
||||||
const pos = [data.currentLocation.latitude, data.currentLocation.longitude];
|
const pos = [data.currentLocation.latitude, data.currentLocation.longitude];
|
||||||
|
const popupHtml = `<strong>${data.line || ''}</strong><br>→ ${data.direction || ''}`;
|
||||||
if (tripMarker) {
|
if (tripMarker) {
|
||||||
tripMarker.setLatLng(pos);
|
tripMarker.setLatLng(pos);
|
||||||
|
tripMarker.setIcon(vehicleIcon(data.line, data.color, data.textColor));
|
||||||
|
// setPopupContent statt erneutem bindPopup, sonst schließt sich ein
|
||||||
|
// gerade geöffnetes Popup bei jedem Poll wieder (gleicher Fehler wie
|
||||||
|
// im Isolations-Modus).
|
||||||
|
if (tripMarker.getPopup()) tripMarker.setPopupContent(popupHtml);
|
||||||
|
else tripMarker.bindPopup(popupHtml);
|
||||||
} else {
|
} else {
|
||||||
tripMarker = L.marker(pos, { icon: vehicleIcon(data.line, data.color, data.textColor) }).addTo(routeLayer);
|
tripMarker = L.marker(pos, { icon: vehicleIcon(data.line, data.color, data.textColor) }).addTo(routeLayer);
|
||||||
|
tripMarker.bindPopup(popupHtml);
|
||||||
}
|
}
|
||||||
tripMarker.bindPopup(`<strong>${data.line || ''}</strong><br>→ ${data.direction || ''}`);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+50
@@ -852,6 +852,55 @@ button:active { transform: translateY(1px); }
|
|||||||
.favorite-card small { color: var(--ink-muted); font-size: 11px; margin-top: 3px; }
|
.favorite-card small { color: var(--ink-muted); font-size: 11px; margin-top: 3px; }
|
||||||
.favorite-card__cancelled { color: var(--signal-red); font-weight: 600; }
|
.favorite-card__cancelled { color: var(--signal-red); font-weight: 600; }
|
||||||
.favorite-card__arrow { color: var(--ink-muted); font-size: 18px; }
|
.favorite-card__arrow { color: var(--ink-muted); font-size: 18px; }
|
||||||
|
|
||||||
|
/* Störungs-Hinweis auf Favoriten-Karten ("Störungs-Push") */
|
||||||
|
.favorite-card--disrupted { border-color: rgba(255,93,108,.5); }
|
||||||
|
.favorite-card__disruption {
|
||||||
|
color: var(--signal-red) !important;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.notify-toggle, #notify-toggle.is-active { color: var(--signal-green); }
|
||||||
|
#notify-toggle:disabled { cursor: default; opacity: .8; }
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
Toast-Benachrichtigungen (z.B. für neue Störungen bei Favoriten)
|
||||||
|
============================================================ */
|
||||||
|
|
||||||
|
.toast-container {
|
||||||
|
position: fixed;
|
||||||
|
left: 50%;
|
||||||
|
bottom: 24px;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
z-index: 200;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
align-items: center;
|
||||||
|
width: min(420px, calc(100vw - 32px));
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast {
|
||||||
|
background: var(--panel-raised);
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
border-left: 3px solid var(--signal-amber);
|
||||||
|
color: var(--ink);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 12px 16px;
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.4;
|
||||||
|
box-shadow: 0 8px 24px rgba(0,0,0,.35);
|
||||||
|
width: 100%;
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(12px);
|
||||||
|
transition: opacity .25s ease, transform .25s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast.is-visible { opacity: 1; transform: translateY(0); }
|
||||||
|
.toast--warning { border-left-color: var(--signal-red); }
|
||||||
|
.toast--info { border-left-color: var(--signal-amber); }
|
||||||
.nearby-preview { display: grid; grid-template-columns: repeat(3, minmax(0,1fr)); gap: 8px; }
|
.nearby-preview { display: grid; grid-template-columns: repeat(3, minmax(0,1fr)); gap: 8px; }
|
||||||
.nearby-preview__item {
|
.nearby-preview__item {
|
||||||
display: flex; justify-content: space-between; align-items: center; gap: 10px; text-align: left;
|
display: flex; justify-content: space-between; align-items: center; gap: 10px; text-align: left;
|
||||||
@@ -1437,6 +1486,7 @@ button:active { transform: translateY(1px); }
|
|||||||
.mobile-nav__item span { font-size: 18px; line-height: 1.1; }
|
.mobile-nav__item span { font-size: 18px; line-height: 1.1; }
|
||||||
.mobile-nav__item small { font-size: 10px; }
|
.mobile-nav__item small { font-size: 10px; }
|
||||||
.mobile-nav__item.is-active { color: var(--signal-amber); }
|
.mobile-nav__item.is-active { color: var(--signal-amber); }
|
||||||
|
.toast-container { bottom: calc(64px + env(safe-area-inset-bottom) + 12px); }
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 430px) {
|
@media (max-width: 430px) {
|
||||||
|
|||||||
Reference in new issue
Block a user