Navigation an iOS-Tabs angleichen, Benachrichtigungscenter, Einfacher Modus
Phase 2-4 der WebApp-Angleichung an die iOS-App: - Navigation: Favoriten und Störungen sind jetzt eigene Top-Level-Tabs (Desktop-Tableiste + mobile Bottom-Nav) statt nur über das Mehr-Grid bzw. den Stern im Header erreichbar. Neue Header-Icons für Einstellungen (⚙) und Benachrichtigungen (🔔, mit rotem Punkt bei ungelesenen Einträgen). "Mehr" ist jetzt schlanker (In der Nähe, Tickets, Verlauf, App-Info mit Verbindungstest gegen /health/full) und die Sprachauswahl in einen neuen eigenen "Einstellungen"-Tab umgezogen. - Benachrichtigungscenter (js/notifications.js, neu): persistenter Verlauf über IndexedDB, sowohl vom Service Worker (eingehende Web-Push- Nachrichten, auch bei geschlossenem Tab) als auch von der Seite selbst (Störungs-Matches bei Favoriten, Fahrtalarm) beschrieben. Neuer "Benachrichtigungen"-Tab zeigt den Verlauf, Badge am Glocken-Icon zeigt ungelesene Einträge, per postMessage vom Service Worker live aktualisiert. - Einfacher Modus: neuer Toggle im Einstellungen-Tab, persistiert in localStorage, setzt eine .simple-mode-Klasse auf <html> - moderat größere Schrift plus Ausblenden von Verkehrsmittel-Filter/Fahrrad-Option/ "Zwischenhalt hinzufügen" im Routenplaner, analog zur iOS-App (bewusst keine Accessibility-Schriftgrößenstufe, die ist für Screenreader gedacht). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01De7H8hR1Q9DdgyuS2ukN6y
This commit is contained in:
8 files changed
+455
-12
No files matched your search
+50
-5
@@ -35,14 +35,20 @@
|
|||||||
<ul id="search-results" class="search-results" role="listbox" aria-label="Suchergebnisse" data-i18n-aria-label="search.resultsLabel" hidden></ul>
|
<ul id="search-results" class="search-results" role="listbox" aria-label="Suchergebnisse" data-i18n-aria-label="search.resultsLabel" hidden></ul>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<button id="header-settings-btn" class="header-icon-btn" data-tab="settings" type="button" title="Einstellungen" data-i18n-title="nav.settings" aria-label="Einstellungen" data-i18n-aria-label="nav.settings">⚙</button>
|
||||||
|
<button id="header-notifications-btn" class="header-icon-btn" data-tab="notifications" type="button" title="Benachrichtigungen" data-i18n-title="nav.notifications" aria-label="Benachrichtigungen" data-i18n-aria-label="nav.notifications">
|
||||||
|
🔔<span id="notifications-badge" class="notifications-badge" hidden></span>
|
||||||
|
</button>
|
||||||
<button id="header-favorites-btn" class="header-icon-btn" data-tab="favorites" type="button" title="Favoriten" data-i18n-title="nav.favorites" aria-label="Favoriten" data-i18n-aria-label="nav.favorites">★</button>
|
<button id="header-favorites-btn" class="header-icon-btn" data-tab="favorites" type="button" title="Favoriten" data-i18n-title="nav.favorites" aria-label="Favoriten" data-i18n-aria-label="nav.favorites">★</button>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<nav class="tab-nav" aria-label="Hauptnavigation">
|
<nav class="tab-nav" aria-label="Hauptnavigation">
|
||||||
<div role="tablist" aria-label="Hauptnavigation" class="tab-nav__list">
|
<div role="tablist" aria-label="Hauptnavigation" class="tab-nav__list">
|
||||||
<button class="tab-button is-active" role="tab" aria-selected="true" aria-controls="panel-departures" id="tab-departures" tabindex="0" data-tab="departures"><span class="tab-button__icon" aria-hidden="true">🚏</span> <span data-i18n="nav.departures">Abfahrten</span></button>
|
<button class="tab-button is-active" role="tab" aria-selected="true" aria-controls="panel-departures" id="tab-departures" tabindex="0" data-tab="departures"><span class="tab-button__icon" aria-hidden="true">🚏</span> <span data-i18n="nav.departures">Abfahrten</span></button>
|
||||||
|
<button class="tab-button" role="tab" aria-selected="false" aria-controls="panel-favorites" id="tab-favorites" tabindex="-1" data-tab="favorites"><span class="tab-button__icon" aria-hidden="true">★</span> <span data-i18n="favorites.title">Favoriten</span></button>
|
||||||
<button class="tab-button" role="tab" aria-selected="false" aria-controls="panel-journey" id="tab-journey" tabindex="-1" data-tab="journey"><span class="tab-button__icon" aria-hidden="true">🧭</span> <span data-i18n="nav.route">Route</span></button>
|
<button class="tab-button" role="tab" aria-selected="false" aria-controls="panel-journey" id="tab-journey" tabindex="-1" data-tab="journey"><span class="tab-button__icon" aria-hidden="true">🧭</span> <span data-i18n="nav.route">Route</span></button>
|
||||||
<button class="tab-button" role="tab" aria-selected="false" aria-controls="panel-map" id="tab-map" tabindex="-1" data-tab="map"><span class="tab-button__icon" aria-hidden="true">🗺</span> <span data-i18n="nav.map">Karte</span></button>
|
<button class="tab-button" role="tab" aria-selected="false" aria-controls="panel-map" id="tab-map" tabindex="-1" data-tab="map"><span class="tab-button__icon" aria-hidden="true">🗺</span> <span data-i18n="nav.map">Karte</span></button>
|
||||||
|
<button class="tab-button" role="tab" aria-selected="false" aria-controls="panel-disruptions" id="tab-disruptions" tabindex="-1" data-tab="disruptions"><span class="tab-button__icon" aria-hidden="true">⚠</span> <span data-i18n="disruptions.title">Störungen</span></button>
|
||||||
<button class="tab-button" role="tab" aria-selected="false" aria-controls="panel-more" id="tab-more" tabindex="-1" data-tab="more"><span class="tab-button__icon" aria-hidden="true">☰</span> <span data-i18n="nav.more">Mehr</span></button>
|
<button class="tab-button" role="tab" aria-selected="false" aria-controls="panel-more" id="tab-more" tabindex="-1" data-tab="more"><span class="tab-button__icon" aria-hidden="true">☰</span> <span data-i18n="nav.more">Mehr</span></button>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
@@ -167,7 +173,7 @@
|
|||||||
<div id="nearby-list" class="nearby-list"><div class="board-empty-state" data-i18n="nearby.emptyState">Nutze deinen Standort, um Haltestellen in deiner Nähe zu finden.</div></div>
|
<div id="nearby-list" class="nearby-list"><div class="board-empty-state" data-i18n="nearby.emptyState">Nutze deinen Standort, um Haltestellen in deiner Nähe zu finden.</div></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="tab-panel" data-tab-panel="favorites" hidden>
|
<section class="tab-panel" data-tab-panel="favorites" id="panel-favorites" role="tabpanel" aria-labelledby="tab-favorites mobile-tab-favorites" tabindex="-1" hidden>
|
||||||
<div class="panel-heading">
|
<div class="panel-heading">
|
||||||
<div><span class="section-heading__eyebrow" data-i18n="favorites.eyebrow">SCHNELLZUGRIFF</span><h1 data-i18n="favorites.title">Meine Haltestellen</h1><p data-i18n="favorites.text">Deine gespeicherten Haltestellen mit Live-Abfahrten und Störungshinweisen.</p></div>
|
<div><span class="section-heading__eyebrow" data-i18n="favorites.eyebrow">SCHNELLZUGRIFF</span><h1 data-i18n="favorites.title">Meine Haltestellen</h1><p data-i18n="favorites.text">Deine gespeicherten Haltestellen mit Live-Abfahrten und Störungshinweisen.</p></div>
|
||||||
</div>
|
</div>
|
||||||
@@ -185,7 +191,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="tab-panel" data-tab-panel="disruptions" hidden>
|
<section class="tab-panel" data-tab-panel="disruptions" id="panel-disruptions" role="tabpanel" aria-labelledby="tab-disruptions mobile-tab-disruptions" tabindex="-1" hidden>
|
||||||
<div class="panel-heading"><div><span class="section-heading__eyebrow" data-i18n="common.current">AKTUELL</span><h1 data-i18n="disruptions.title">Störungen</h1><p data-i18n="disruptions.text">Ortsbezogene und netzweite Hinweise.</p></div></div>
|
<div class="panel-heading"><div><span class="section-heading__eyebrow" data-i18n="common.current">AKTUELL</span><h1 data-i18n="disruptions.title">Störungen</h1><p data-i18n="disruptions.text">Ortsbezogene und netzweite Hinweise.</p></div></div>
|
||||||
<div class="disruption-section">
|
<div class="disruption-section">
|
||||||
<div class="section-heading"><div><h2 data-i18n="disruptions.nearbyTitle">In deiner Nähe</h2></div></div>
|
<div class="section-heading"><div><h2 data-i18n="disruptions.nearbyTitle">In deiner Nähe</h2></div></div>
|
||||||
@@ -212,23 +218,59 @@
|
|||||||
<section class="tab-panel" data-tab-panel="more" id="panel-more" role="tabpanel" aria-labelledby="tab-more mobile-tab-more" tabindex="-1" hidden>
|
<section class="tab-panel" data-tab-panel="more" id="panel-more" role="tabpanel" aria-labelledby="tab-more mobile-tab-more" tabindex="-1" hidden>
|
||||||
<div class="panel-heading"><div><span class="section-heading__eyebrow" data-i18n="more.eyebrow">WEITERE FUNKTIONEN</span><h1 data-i18n="nav.more">Mehr</h1><p data-i18n="more.text">Alles, was nicht bei jeder Fahrt gebraucht wird.</p></div></div>
|
<div class="panel-heading"><div><span class="section-heading__eyebrow" data-i18n="more.eyebrow">WEITERE FUNKTIONEN</span><h1 data-i18n="nav.more">Mehr</h1><p data-i18n="more.text">Alles, was nicht bei jeder Fahrt gebraucht wird.</p></div></div>
|
||||||
<div class="more-grid">
|
<div class="more-grid">
|
||||||
<button class="more-card" data-open-tab="favorites" type="button"><span aria-hidden="true">★</span><strong data-i18n="favorites.title">Favoriten</strong><small data-i18n="more.favoritesText">Deine gespeicherten Haltestellen</small></button>
|
|
||||||
<button class="more-card" data-open-tab="nearby" type="button"><span aria-hidden="true">📍</span><strong data-i18n="nearby.title">In der Nähe</strong><small data-i18n="more.nearbyText">Haltestellen in deiner Umgebung</small></button>
|
<button class="more-card" data-open-tab="nearby" type="button"><span aria-hidden="true">📍</span><strong data-i18n="nearby.title">In der Nähe</strong><small data-i18n="more.nearbyText">Haltestellen in deiner Umgebung</small></button>
|
||||||
<button class="more-card" data-open-tab="disruptions" type="button"><span aria-hidden="true">⚠</span><strong data-i18n="disruptions.title">Störungen</strong><small data-i18n="more.disruptionsText">Ortsbezogen & netzweit</small></button>
|
|
||||||
<button class="more-card" data-open-tab="fares" type="button"><span aria-hidden="true">🎫</span><strong data-i18n="fares.title">Tickets</strong><small data-i18n="more.faresText">Preise & wo kaufen</small></button>
|
<button class="more-card" data-open-tab="fares" type="button"><span aria-hidden="true">🎫</span><strong data-i18n="fares.title">Tickets</strong><small data-i18n="more.faresText">Preise & wo kaufen</small></button>
|
||||||
|
<button class="more-card" data-open-tab="notifications" type="button"><span aria-hidden="true">🔔</span><strong data-i18n="notifications.title">Benachrichtigungen</strong><small data-i18n="more.notificationsText">Verlauf & Push-Status</small></button>
|
||||||
|
<button class="more-card" data-open-tab="settings" type="button"><span aria-hidden="true">⚙</span><strong data-i18n="nav.settings">Einstellungen</strong><small data-i18n="more.settingsText">Sprache, Anzeige & mehr</small></button>
|
||||||
</div>
|
</div>
|
||||||
<div class="recent-section">
|
<div class="recent-section">
|
||||||
<div class="section-heading"><div><span class="section-heading__eyebrow" data-i18n="more.historyEyebrow">VERLAUF</span><h2 data-i18n="more.historyTitle">Zuletzt gesucht</h2></div><button id="clear-recent" class="text-button" type="button" data-i18n="common.clear">Leeren</button></div>
|
<div class="section-heading"><div><span class="section-heading__eyebrow" data-i18n="more.historyEyebrow">VERLAUF</span><h2 data-i18n="more.historyTitle">Zuletzt gesucht</h2></div><button id="clear-recent" class="text-button" type="button" data-i18n="common.clear">Leeren</button></div>
|
||||||
<div id="recent-searches" class="recent-searches" role="list"></div>
|
<div id="recent-searches" class="recent-searches" role="list"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="recent-section">
|
<div class="recent-section">
|
||||||
<div class="section-heading"><div><span class="section-heading__eyebrow" data-i18n="more.langEyebrow">SPRACHE / LANGUAGE</span><h2 data-i18n="more.langTitle">Sprache</h2></div></div>
|
<div class="section-heading"><div><span class="section-heading__eyebrow" data-i18n="more.aboutEyebrow">ÜBER</span><h2 data-i18n="more.aboutTitle">App-Info</h2></div></div>
|
||||||
|
<div class="connection-check">
|
||||||
|
<div class="connection-check__row">
|
||||||
|
<span data-i18n="more.backend">Backend</span>
|
||||||
|
<span id="connection-status" class="connection-status">–</span>
|
||||||
|
</div>
|
||||||
|
<button id="connection-test-btn" class="text-button" type="button" data-i18n="more.testConnection">Verbindung testen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="tab-panel" data-tab-panel="settings" id="panel-settings" role="tabpanel" aria-labelledby="header-settings-btn" tabindex="-1" hidden>
|
||||||
|
<div class="panel-heading"><div><span class="section-heading__eyebrow" data-i18n="settings.eyebrow">EINSTELLUNGEN</span><h1 data-i18n="nav.settings">Einstellungen</h1><p data-i18n="settings.text">Anzeige, Sprache und Benachrichtigungen anpassen.</p></div></div>
|
||||||
|
|
||||||
|
<div class="settings-section">
|
||||||
|
<div class="section-heading"><div><h2 data-i18n="settings.displayTitle">Anzeige</h2></div></div>
|
||||||
|
<label class="settings-toggle">
|
||||||
|
<div class="settings-toggle__text">
|
||||||
|
<strong data-i18n="settings.simpleModeTitle">Einfacher Modus</strong>
|
||||||
|
<small data-i18n="settings.simpleModeText">Größere Schrift, weniger Optionen auf einen Blick.</small>
|
||||||
|
</div>
|
||||||
|
<input type="checkbox" id="settings-simple-mode" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="settings-section">
|
||||||
|
<div class="section-heading"><div><h2 data-i18n="more.langTitle">Sprache</h2></div></div>
|
||||||
<div class="lang-switch" role="group" aria-label="Sprache / Language">
|
<div class="lang-switch" role="group" aria-label="Sprache / Language">
|
||||||
<button id="lang-de" class="lang-switch__btn" type="button" data-lang="de" aria-pressed="true">Deutsch</button>
|
<button id="lang-de" class="lang-switch__btn" type="button" data-lang="de" aria-pressed="true">Deutsch</button>
|
||||||
<button id="lang-en" class="lang-switch__btn" type="button" data-lang="en" aria-pressed="false">English</button>
|
<button id="lang-en" class="lang-switch__btn" type="button" data-lang="en" aria-pressed="false">English</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="tab-panel" data-tab-panel="notifications" id="panel-notifications" role="tabpanel" aria-labelledby="header-notifications-btn" tabindex="-1" hidden>
|
||||||
|
<div class="panel-heading">
|
||||||
|
<div><span class="section-heading__eyebrow" data-i18n="notifications.eyebrow">VERLAUF</span><h1 data-i18n="notifications.title">Benachrichtigungen</h1><p data-i18n="notifications.text">Alles, was zuletzt eine Push- oder In-App-Benachrichtigung ausgelöst hat.</p></div>
|
||||||
|
<button id="notifications-clear-btn" class="text-button" type="button" data-i18n="common.clear">Leeren</button>
|
||||||
|
</div>
|
||||||
|
<div id="notifications-list" class="notifications-list" role="list">
|
||||||
|
<div class="board-empty-state" data-i18n="notifications.empty">Noch keine Benachrichtigungen.</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<div class="global-disclaimer">
|
<div class="global-disclaimer">
|
||||||
@@ -239,8 +281,10 @@
|
|||||||
<nav class="mobile-nav" aria-label="Hauptnavigation mobil">
|
<nav class="mobile-nav" aria-label="Hauptnavigation mobil">
|
||||||
<div role="tablist" aria-label="Hauptnavigation mobil" class="mobile-nav__list">
|
<div role="tablist" aria-label="Hauptnavigation mobil" class="mobile-nav__list">
|
||||||
<button class="mobile-nav__item is-active" role="tab" aria-selected="true" aria-controls="panel-departures" id="mobile-tab-departures" tabindex="0" data-tab="departures"><span aria-hidden="true">🚏</span><small data-i18n="nav.departures">Abfahrten</small></button>
|
<button class="mobile-nav__item is-active" role="tab" aria-selected="true" aria-controls="panel-departures" id="mobile-tab-departures" tabindex="0" data-tab="departures"><span aria-hidden="true">🚏</span><small data-i18n="nav.departures">Abfahrten</small></button>
|
||||||
|
<button class="mobile-nav__item" role="tab" aria-selected="false" aria-controls="panel-favorites" id="mobile-tab-favorites" tabindex="-1" data-tab="favorites"><span aria-hidden="true">★</span><small data-i18n="favorites.title">Favoriten</small></button>
|
||||||
<button class="mobile-nav__item" role="tab" aria-selected="false" aria-controls="panel-journey" id="mobile-tab-journey" tabindex="-1" data-tab="journey"><span aria-hidden="true">🧭</span><small data-i18n="nav.route">Route</small></button>
|
<button class="mobile-nav__item" role="tab" aria-selected="false" aria-controls="panel-journey" id="mobile-tab-journey" tabindex="-1" data-tab="journey"><span aria-hidden="true">🧭</span><small data-i18n="nav.route">Route</small></button>
|
||||||
<button class="mobile-nav__item" role="tab" aria-selected="false" aria-controls="panel-map" id="mobile-tab-map" tabindex="-1" data-tab="map"><span aria-hidden="true">🗺</span><small data-i18n="nav.map">Karte</small></button>
|
<button class="mobile-nav__item" role="tab" aria-selected="false" aria-controls="panel-map" id="mobile-tab-map" tabindex="-1" data-tab="map"><span aria-hidden="true">🗺</span><small data-i18n="nav.map">Karte</small></button>
|
||||||
|
<button class="mobile-nav__item" role="tab" aria-selected="false" aria-controls="panel-disruptions" id="mobile-tab-disruptions" tabindex="-1" data-tab="disruptions"><span aria-hidden="true">⚠</span><small data-i18n="disruptions.title">Störungen</small></button>
|
||||||
<button class="mobile-nav__item" role="tab" aria-selected="false" aria-controls="panel-more" id="mobile-tab-more" tabindex="-1" data-tab="more"><span aria-hidden="true">☰</span><small data-i18n="nav.more">Mehr</small></button>
|
<button class="mobile-nav__item" role="tab" aria-selected="false" aria-controls="panel-more" id="mobile-tab-more" tabindex="-1" data-tab="more"><span aria-hidden="true">☰</span><small data-i18n="nav.more">Mehr</small></button>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
@@ -249,6 +293,7 @@
|
|||||||
<script src="js/config.js"></script>
|
<script src="js/config.js"></script>
|
||||||
<script src="js/i18n.js"></script>
|
<script src="js/i18n.js"></script>
|
||||||
<script src="js/api.js"></script>
|
<script src="js/api.js"></script>
|
||||||
|
<script src="js/notifications.js"></script>
|
||||||
<script src="js/splitflap.js"></script>
|
<script src="js/splitflap.js"></script>
|
||||||
<script src="js/modefilter.js"></script>
|
<script src="js/modefilter.js"></script>
|
||||||
<script src="js/livemap.js"></script>
|
<script src="js/livemap.js"></script>
|
||||||
|
|||||||
@@ -154,6 +154,13 @@ const API = (() => {
|
|||||||
return request('/fares');
|
return request('/fares');
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Einfacher Verbindungstest gegen den Health-Dashboard-Endpoint, analog
|
||||||
|
// zu APIClient.checkHealth() in der iOS-App - prüft nur, ob das Backend
|
||||||
|
// erreichbar ist (Reverse-Proxy/DNS/VPS korrekt konfiguriert).
|
||||||
|
checkHealth() {
|
||||||
|
return request('/health/full');
|
||||||
|
},
|
||||||
|
|
||||||
getNearbyDisruptions({ lat, lon, distance, stops }) {
|
getNearbyDisruptions({ lat, lon, distance, stops }) {
|
||||||
return request(`/disruptions/nearby?${qs({ lat, lon, distance, stops })}`);
|
return request(`/disruptions/nearby?${qs({ lat, lon, distance, stops })}`);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ const App = (() => {
|
|||||||
const SEEN_DISRUPTIONS_KEY = 'oepnv-navi:db-seen-disruptions';
|
const SEEN_DISRUPTIONS_KEY = 'oepnv-navi:db-seen-disruptions';
|
||||||
const WHEELCHAIR_KEY = 'oepnv-navi:db-wheelchair';
|
const WHEELCHAIR_KEY = 'oepnv-navi:db-wheelchair';
|
||||||
const BIKE_KEY = 'oepnv-navi:db-bike';
|
const BIKE_KEY = 'oepnv-navi:db-bike';
|
||||||
|
const SIMPLE_MODE_KEY = 'oepnv-navi:db-simple-mode';
|
||||||
const FAVORITES_POLL_MS = 30000;
|
const FAVORITES_POLL_MS = 30000;
|
||||||
const MAX_VIAS = 3;
|
const MAX_VIAS = 3;
|
||||||
const VIA_WAIT_OPTIONS = [0, 5, 10, 15, 20, 30, 45, 60];
|
const VIA_WAIT_OPTIONS = [0, 5, 10, 15, 20, 30, 45, 60];
|
||||||
@@ -209,11 +210,13 @@ const App = (() => {
|
|||||||
function notifyNewDisruption(favName, disruption) {
|
function notifyNewDisruption(favName, disruption) {
|
||||||
const message = `${favName}: ${disruption.summary || 'Neue Störung'}`;
|
const message = `${favName}: ${disruption.summary || 'Neue Störung'}`;
|
||||||
showToast(message, 'warning');
|
showToast(message, 'warning');
|
||||||
|
const body = disruption.summary || disruption.text || 'Es liegt eine neue Störungsmeldung vor.';
|
||||||
|
recordNotification({ title: 'Störung bei ' + favName, body, icon: '⚠' });
|
||||||
|
|
||||||
if (window.Notification && Notification.permission === 'granted') {
|
if (window.Notification && Notification.permission === 'granted') {
|
||||||
try {
|
try {
|
||||||
new Notification('Störung bei ' + favName, {
|
new Notification('Störung bei ' + favName, {
|
||||||
body: disruption.summary || disruption.text || 'Es liegt eine neue Störungsmeldung vor.',
|
body,
|
||||||
tag: `oepnv-navi-disruption-${favName}-${disruptionKey(disruption)}`,
|
tag: `oepnv-navi-disruption-${favName}-${disruptionKey(disruption)}`,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -222,6 +225,137 @@ const App = (() => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- Benachrichtigungscenter ----------
|
||||||
|
//
|
||||||
|
// Persistiert lokal ausgelöste Benachrichtigungen (Störungen, Fahrtalarm)
|
||||||
|
// im selben IndexedDB-Store wie eingehende Web-Push-Nachrichten (siehe
|
||||||
|
// sw.js + js/notifications.js), damit der "Benachrichtigungen"-Tab wirklich
|
||||||
|
// alles zeigt, was aktuell eine Meldung auslöst - analog zum Glocken-Icon
|
||||||
|
// in der iOS-App.
|
||||||
|
function recordNotification({ title, body, icon }) {
|
||||||
|
if (!window.NotificationStore) return;
|
||||||
|
NotificationStore.add({ title, body, icon }).then(refreshNotificationBadge).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshNotificationBadge() {
|
||||||
|
const badge = document.getElementById('notifications-badge');
|
||||||
|
if (!badge || !window.NotificationStore) return;
|
||||||
|
try {
|
||||||
|
const count = await NotificationStore.unreadCount();
|
||||||
|
badge.hidden = count === 0;
|
||||||
|
} catch (_) {
|
||||||
|
// IndexedDB evtl. nicht verfügbar (z.B. privater Modus) - Badge bleibt einfach versteckt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadNotificationsPanel() {
|
||||||
|
const listEl = document.getElementById('notifications-list');
|
||||||
|
if (!listEl || !window.NotificationStore) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const entries = await NotificationStore.list();
|
||||||
|
listEl.innerHTML = '';
|
||||||
|
|
||||||
|
if (entries.length === 0) {
|
||||||
|
listEl.innerHTML = `<div class="board-empty-state">${escapeHtml(I18N.t('notifications.empty'))}</div>`;
|
||||||
|
} else {
|
||||||
|
entries.forEach((entry) => {
|
||||||
|
const item = document.createElement('div');
|
||||||
|
item.className = `notification-item${entry.read ? '' : ' notification-item--unread'}`;
|
||||||
|
item.setAttribute('role', 'listitem');
|
||||||
|
const time = new Date(entry.timestamp).toLocaleString('de-DE', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' });
|
||||||
|
item.innerHTML = `
|
||||||
|
<span class="notification-item__icon" aria-hidden="true">${escapeHtml(entry.icon || '🔔')}</span>
|
||||||
|
<div class="notification-item__body">
|
||||||
|
<strong>${escapeHtml(entry.title || '')}</strong>
|
||||||
|
<span>${escapeHtml(entry.body || '')}</span>
|
||||||
|
</div>
|
||||||
|
<span class="notification-item__time">${time}</span>
|
||||||
|
`;
|
||||||
|
listEl.appendChild(item);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await NotificationStore.markAllRead();
|
||||||
|
refreshNotificationBadge();
|
||||||
|
} catch (err) {
|
||||||
|
listEl.innerHTML = `<div class="board-error">⚠ ${escapeHtml(err.message)}</div>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function initNotificationsPanel() {
|
||||||
|
const clearBtn = document.getElementById('notifications-clear-btn');
|
||||||
|
if (clearBtn && window.NotificationStore) {
|
||||||
|
clearBtn.addEventListener('click', async () => {
|
||||||
|
await NotificationStore.clear();
|
||||||
|
loadNotificationsPanel();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Der Service Worker meldet per postMessage, sobald eine Push-
|
||||||
|
// Benachrichtigung eintraf (siehe sw.js) - Badge sofort aktualisieren,
|
||||||
|
// auch während die App gerade offen ist.
|
||||||
|
if ('serviceWorker' in navigator) {
|
||||||
|
navigator.serviceWorker.addEventListener('message', (event) => {
|
||||||
|
if (event.data && event.data.type === 'oepnv-navi:notification-added') {
|
||||||
|
refreshNotificationBadge();
|
||||||
|
if (document.querySelector('[data-tab-panel="notifications"]:not([hidden])')) {
|
||||||
|
loadNotificationsPanel();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
refreshNotificationBadge();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Einstellungen ----------
|
||||||
|
|
||||||
|
// Einfacher Modus: moderat größere Schrift + weniger Optionen auf einen
|
||||||
|
// Blick (Verkehrsmittel-Filter/Fahrrad-Option im Routenplaner ausgeblendet),
|
||||||
|
// analog zum "Einfachen Modus" in der iOS-App - bewusst NICHT die
|
||||||
|
// Accessibility-Schriftgrößen-Stufe, die ist für Screenreader-Nutzung
|
||||||
|
// gedacht, nicht zum "einfacher gestalten".
|
||||||
|
function applySimpleMode(enabled) {
|
||||||
|
document.documentElement.classList.toggle('simple-mode', enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
function initSettingsPanel() {
|
||||||
|
const toggle = document.getElementById('settings-simple-mode');
|
||||||
|
if (!toggle) return;
|
||||||
|
const enabled = localStorage.getItem(SIMPLE_MODE_KEY) === 'true';
|
||||||
|
toggle.checked = enabled;
|
||||||
|
applySimpleMode(enabled);
|
||||||
|
toggle.addEventListener('change', () => {
|
||||||
|
localStorage.setItem(SIMPLE_MODE_KEY, toggle.checked ? 'true' : 'false');
|
||||||
|
applySimpleMode(toggle.checked);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- "Mehr": Verbindungstest ----------
|
||||||
|
|
||||||
|
function initMorePanel() {
|
||||||
|
const btn = document.getElementById('connection-test-btn');
|
||||||
|
const statusEl = document.getElementById('connection-status');
|
||||||
|
if (!btn || !statusEl) return;
|
||||||
|
|
||||||
|
btn.addEventListener('click', async () => {
|
||||||
|
statusEl.textContent = '…';
|
||||||
|
statusEl.className = 'connection-status';
|
||||||
|
btn.disabled = true;
|
||||||
|
try {
|
||||||
|
await API.checkHealth();
|
||||||
|
statusEl.textContent = I18N.t('more.connectionOk');
|
||||||
|
statusEl.classList.add('connection-status--ok');
|
||||||
|
} catch (err) {
|
||||||
|
statusEl.textContent = I18N.t('more.connectionError');
|
||||||
|
statusEl.classList.add('connection-status--error');
|
||||||
|
} finally {
|
||||||
|
btn.disabled = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function renderFavoriteDisruption(card, favName, disruptions, seenMap, favId) {
|
function renderFavoriteDisruption(card, favName, disruptions, seenMap, favId) {
|
||||||
const el = card.querySelector('[data-disruption]');
|
const el = card.querySelector('[data-disruption]');
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
@@ -1003,6 +1137,10 @@ const App = (() => {
|
|||||||
if (tabName === 'fares' && !faresLoadedOnce) {
|
if (tabName === 'fares' && !faresLoadedOnce) {
|
||||||
loadFares();
|
loadFares();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (tabName === 'notifications') {
|
||||||
|
loadNotificationsPanel();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function initNearby() {
|
function initNearby() {
|
||||||
@@ -1766,6 +1904,9 @@ const App = (() => {
|
|||||||
initNearby();
|
initNearby();
|
||||||
initNearbyDisruptions();
|
initNearbyDisruptions();
|
||||||
initNotifications();
|
initNotifications();
|
||||||
|
initNotificationsPanel();
|
||||||
|
initSettingsPanel();
|
||||||
|
initMorePanel();
|
||||||
TripAlarm.load();
|
TripAlarm.load();
|
||||||
renderFavoriteChips();
|
renderFavoriteChips();
|
||||||
renderRecentSearches();
|
renderRecentSearches();
|
||||||
|
|||||||
+38
@@ -23,6 +23,8 @@ const I18N = (() => {
|
|||||||
'nav.route': 'Route',
|
'nav.route': 'Route',
|
||||||
'nav.map': 'Karte',
|
'nav.map': 'Karte',
|
||||||
'nav.more': 'Mehr',
|
'nav.more': 'Mehr',
|
||||||
|
'nav.settings': 'Einstellungen',
|
||||||
|
'nav.notifications': 'Benachrichtigungen',
|
||||||
'common.optional': 'OPTIONAL',
|
'common.optional': 'OPTIONAL',
|
||||||
'common.showAll': 'Alle anzeigen',
|
'common.showAll': 'Alle anzeigen',
|
||||||
'common.live': 'LIVE',
|
'common.live': 'LIVE',
|
||||||
@@ -124,10 +126,27 @@ const I18N = (() => {
|
|||||||
'more.nearbyText': 'Haltestellen in deiner Umgebung',
|
'more.nearbyText': 'Haltestellen in deiner Umgebung',
|
||||||
'more.disruptionsText': 'Ortsbezogen & netzweit',
|
'more.disruptionsText': 'Ortsbezogen & netzweit',
|
||||||
'more.faresText': 'Preise & wo kaufen',
|
'more.faresText': 'Preise & wo kaufen',
|
||||||
|
'more.notificationsText': 'Verlauf & Push-Status',
|
||||||
|
'more.settingsText': 'Sprache, Anzeige & mehr',
|
||||||
'more.historyEyebrow': 'VERLAUF',
|
'more.historyEyebrow': 'VERLAUF',
|
||||||
'more.historyTitle': 'Zuletzt gesucht',
|
'more.historyTitle': 'Zuletzt gesucht',
|
||||||
'more.langEyebrow': 'SPRACHE / LANGUAGE',
|
'more.langEyebrow': 'SPRACHE / LANGUAGE',
|
||||||
'more.langTitle': 'Sprache',
|
'more.langTitle': 'Sprache',
|
||||||
|
'more.aboutEyebrow': 'ÜBER',
|
||||||
|
'more.aboutTitle': 'App-Info',
|
||||||
|
'more.backend': 'Backend',
|
||||||
|
'more.testConnection': 'Verbindung testen',
|
||||||
|
'more.connectionOk': 'Erreichbar',
|
||||||
|
'more.connectionError': 'Nicht erreichbar',
|
||||||
|
'notifications.eyebrow': 'VERLAUF',
|
||||||
|
'notifications.title': 'Benachrichtigungen',
|
||||||
|
'notifications.text': 'Alles, was zuletzt eine Push- oder In-App-Benachrichtigung ausgelöst hat.',
|
||||||
|
'notifications.empty': 'Noch keine Benachrichtigungen.',
|
||||||
|
'settings.eyebrow': 'EINSTELLUNGEN',
|
||||||
|
'settings.text': 'Anzeige, Sprache und Benachrichtigungen anpassen.',
|
||||||
|
'settings.displayTitle': 'Anzeige',
|
||||||
|
'settings.simpleModeTitle': 'Einfacher Modus',
|
||||||
|
'settings.simpleModeText': 'Größere Schrift, weniger Optionen auf einen Blick.',
|
||||||
'search.noResults': 'Keine Treffer',
|
'search.noResults': 'Keine Treffer',
|
||||||
'search.recentSearches': 'Zuletzt gesucht',
|
'search.recentSearches': 'Zuletzt gesucht',
|
||||||
'search.resultsLabel': 'Suchergebnisse',
|
'search.resultsLabel': 'Suchergebnisse',
|
||||||
@@ -144,6 +163,8 @@ const I18N = (() => {
|
|||||||
'nav.route': 'Route',
|
'nav.route': 'Route',
|
||||||
'nav.map': 'Map',
|
'nav.map': 'Map',
|
||||||
'nav.more': 'More',
|
'nav.more': 'More',
|
||||||
|
'nav.settings': 'Settings',
|
||||||
|
'nav.notifications': 'Notifications',
|
||||||
'common.optional': 'OPTIONAL',
|
'common.optional': 'OPTIONAL',
|
||||||
'common.showAll': 'Show all',
|
'common.showAll': 'Show all',
|
||||||
'common.live': 'LIVE',
|
'common.live': 'LIVE',
|
||||||
@@ -245,10 +266,27 @@ const I18N = (() => {
|
|||||||
'more.nearbyText': 'Stops around you',
|
'more.nearbyText': 'Stops around you',
|
||||||
'more.disruptionsText': 'Local & network-wide',
|
'more.disruptionsText': 'Local & network-wide',
|
||||||
'more.faresText': 'Prices & where to buy',
|
'more.faresText': 'Prices & where to buy',
|
||||||
|
'more.notificationsText': 'History & push status',
|
||||||
|
'more.settingsText': 'Language, display & more',
|
||||||
'more.historyEyebrow': 'HISTORY',
|
'more.historyEyebrow': 'HISTORY',
|
||||||
'more.historyTitle': 'Recently searched',
|
'more.historyTitle': 'Recently searched',
|
||||||
'more.langEyebrow': 'SPRACHE / LANGUAGE',
|
'more.langEyebrow': 'SPRACHE / LANGUAGE',
|
||||||
'more.langTitle': 'Language',
|
'more.langTitle': 'Language',
|
||||||
|
'more.aboutEyebrow': 'ABOUT',
|
||||||
|
'more.aboutTitle': 'App info',
|
||||||
|
'more.backend': 'Backend',
|
||||||
|
'more.testConnection': 'Test connection',
|
||||||
|
'more.connectionOk': 'Reachable',
|
||||||
|
'more.connectionError': 'Unreachable',
|
||||||
|
'notifications.eyebrow': 'HISTORY',
|
||||||
|
'notifications.title': 'Notifications',
|
||||||
|
'notifications.text': 'Everything that recently triggered a push or in-app notification.',
|
||||||
|
'notifications.empty': 'No notifications yet.',
|
||||||
|
'settings.eyebrow': 'SETTINGS',
|
||||||
|
'settings.text': 'Adjust display, language and notifications.',
|
||||||
|
'settings.displayTitle': 'Display',
|
||||||
|
'settings.simpleModeTitle': 'Simple mode',
|
||||||
|
'settings.simpleModeText': 'Larger text, fewer options at a glance.',
|
||||||
'search.noResults': 'No results',
|
'search.noResults': 'No results',
|
||||||
'search.recentSearches': 'Recently searched',
|
'search.recentSearches': 'Recently searched',
|
||||||
'search.resultsLabel': 'Search results',
|
'search.resultsLabel': 'Search results',
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
// Persistenter Benachrichtigungsverlauf ("Notification Center"), analog zum
|
||||||
|
// Glocken-Icon in der iOS-App. Läuft als IndexedDB-Store, weil das - anders
|
||||||
|
// als localStorage - sowohl vom offenen Tab als auch vom Service Worker aus
|
||||||
|
// (auch bei geschlossenem Tab, siehe sw.js) beschrieben werden kann. Wird per
|
||||||
|
// <script> in index.html UND per importScripts() in sw.js eingebunden, daher
|
||||||
|
// bewusst keine ES-Module-Syntax.
|
||||||
|
|
||||||
|
const NotificationStore = (() => {
|
||||||
|
const DB_NAME = 'oepnv-navi-notifications';
|
||||||
|
const STORE = 'notifications';
|
||||||
|
const MAX_ENTRIES = 50;
|
||||||
|
|
||||||
|
function openDb() {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
if (!('indexedDB' in self)) {
|
||||||
|
reject(new Error('IndexedDB nicht verfügbar'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const req = indexedDB.open(DB_NAME, 1);
|
||||||
|
req.onupgradeneeded = () => {
|
||||||
|
const db = req.result;
|
||||||
|
if (!db.objectStoreNames.contains(STORE)) {
|
||||||
|
db.createObjectStore(STORE, { keyPath: 'id', autoIncrement: true });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
req.onsuccess = () => resolve(req.result);
|
||||||
|
req.onerror = () => reject(req.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function add(entry) {
|
||||||
|
const db = await openDb();
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
const tx = db.transaction(STORE, 'readwrite');
|
||||||
|
tx.objectStore(STORE).add({
|
||||||
|
title: entry.title || '',
|
||||||
|
body: entry.body || '',
|
||||||
|
url: entry.url || null,
|
||||||
|
icon: entry.icon || '🔔',
|
||||||
|
timestamp: entry.timestamp || Date.now(),
|
||||||
|
read: false,
|
||||||
|
});
|
||||||
|
tx.oncomplete = () => resolve();
|
||||||
|
tx.onerror = () => reject(tx.error);
|
||||||
|
});
|
||||||
|
await trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function list() {
|
||||||
|
const db = await openDb();
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const tx = db.transaction(STORE, 'readonly');
|
||||||
|
const req = tx.objectStore(STORE).getAll();
|
||||||
|
req.onsuccess = () => resolve(req.result.sort((a, b) => b.timestamp - a.timestamp));
|
||||||
|
req.onerror = () => reject(req.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clear() {
|
||||||
|
const db = await openDb();
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
const tx = db.transaction(STORE, 'readwrite');
|
||||||
|
tx.objectStore(STORE).clear();
|
||||||
|
tx.oncomplete = () => resolve();
|
||||||
|
tx.onerror = () => reject(tx.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function markAllRead() {
|
||||||
|
const db = await openDb();
|
||||||
|
const entries = await list();
|
||||||
|
if (entries.length === 0) return;
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
const tx = db.transaction(STORE, 'readwrite');
|
||||||
|
const store = tx.objectStore(STORE);
|
||||||
|
entries.forEach((e) => store.put({ ...e, read: true }));
|
||||||
|
tx.oncomplete = () => resolve();
|
||||||
|
tx.onerror = () => reject(tx.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function trim() {
|
||||||
|
const entries = await list();
|
||||||
|
if (entries.length <= MAX_ENTRIES) return;
|
||||||
|
const db = await openDb();
|
||||||
|
const toRemove = entries.slice(MAX_ENTRIES);
|
||||||
|
await new Promise((resolve, reject) => {
|
||||||
|
const tx = db.transaction(STORE, 'readwrite');
|
||||||
|
const store = tx.objectStore(STORE);
|
||||||
|
toRemove.forEach((e) => store.delete(e.id));
|
||||||
|
tx.oncomplete = () => resolve();
|
||||||
|
tx.onerror = () => reject(tx.error);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function unreadCount() {
|
||||||
|
try {
|
||||||
|
const entries = await list();
|
||||||
|
return entries.filter((e) => !e.read).length;
|
||||||
|
} catch (_) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { add, list, clear, markAllRead, unreadCount };
|
||||||
|
})();
|
||||||
|
|
||||||
|
// In der Seite (nicht im Service-Worker-Kontext) global verfügbar machen.
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.NotificationStore = NotificationStore;
|
||||||
|
}
|
||||||
@@ -150,6 +150,12 @@ const TripAlarm = (() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function notify(title, body) {
|
function notify(title, body) {
|
||||||
|
if (window.NotificationStore) {
|
||||||
|
NotificationStore.add({ title, body, icon: '⏰' }).then(() => {
|
||||||
|
const badge = document.getElementById('notifications-badge');
|
||||||
|
if (badge) badge.hidden = false;
|
||||||
|
}).catch(() => {});
|
||||||
|
}
|
||||||
if (window.Notification && Notification.permission === 'granted') {
|
if (window.Notification && Notification.permission === 'granted') {
|
||||||
try {
|
try {
|
||||||
new Notification(title, { body, tag: 'oepnv-navi-fahrtalarm' });
|
new Notification(title, { body, tag: 'oepnv-navi-fahrtalarm' });
|
||||||
|
|||||||
+79
-5
@@ -1153,6 +1153,17 @@ button:active { transform: translateY(1px); }
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
.header-icon-btn:hover { border-color: var(--signal-amber); }
|
.header-icon-btn:hover { border-color: var(--signal-amber); }
|
||||||
|
.header-icon-btn { position: relative; }
|
||||||
|
.notifications-badge {
|
||||||
|
position: absolute;
|
||||||
|
top: 4px;
|
||||||
|
right: 5px;
|
||||||
|
width: 9px;
|
||||||
|
height: 9px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--signal-red);
|
||||||
|
border: 1.5px solid var(--panel-raised);
|
||||||
|
}
|
||||||
|
|
||||||
.favorites-bar { display: none; }
|
.favorites-bar { display: none; }
|
||||||
.tab-nav {
|
.tab-nav {
|
||||||
@@ -1526,6 +1537,68 @@ button:active { transform: translateY(1px); }
|
|||||||
.recent-item__remove:hover { background: var(--panel-raised); color: var(--signal-red); }
|
.recent-item__remove:hover { background: var(--panel-raised); color: var(--signal-red); }
|
||||||
.recent-empty { color: var(--ink-muted); font-size: 12px; padding: 8px 0; }
|
.recent-empty { color: var(--ink-muted); font-size: 12px; padding: 8px 0; }
|
||||||
|
|
||||||
|
.connection-check {
|
||||||
|
background: var(--panel); border: 1px solid var(--line); border-radius: 12px;
|
||||||
|
padding: 12px 14px; display: flex; flex-direction: column; gap: 8px;
|
||||||
|
}
|
||||||
|
.connection-check__row { display: flex; align-items: center; justify-content: space-between; font-size: 13px; }
|
||||||
|
.connection-status { color: var(--ink-muted); font-weight: 600; }
|
||||||
|
.connection-status--ok { color: var(--signal-green); }
|
||||||
|
.connection-status--error { color: var(--signal-red); }
|
||||||
|
|
||||||
|
.settings-section {
|
||||||
|
background: var(--panel); border: 1px solid var(--line); border-radius: 12px;
|
||||||
|
padding: 14px 16px; margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
.settings-section .section-heading { margin-bottom: 10px; }
|
||||||
|
.settings-toggle {
|
||||||
|
display: flex; align-items: center; justify-content: space-between; gap: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.settings-toggle__text { display: flex; flex-direction: column; gap: 2px; }
|
||||||
|
.settings-toggle__text strong { font-size: 14px; }
|
||||||
|
.settings-toggle__text small { color: var(--ink-muted); font-size: 12px; }
|
||||||
|
.settings-toggle input[type="checkbox"] {
|
||||||
|
appearance: none; -webkit-appearance: none; flex-shrink: 0;
|
||||||
|
width: 44px; height: 26px; border-radius: 999px;
|
||||||
|
background: var(--panel-raised); border: 1px solid var(--line);
|
||||||
|
position: relative; cursor: pointer;
|
||||||
|
}
|
||||||
|
.settings-toggle input[type="checkbox"]::before {
|
||||||
|
content: ''; position: absolute; top: 2px; left: 2px;
|
||||||
|
width: 20px; height: 20px; border-radius: 50%;
|
||||||
|
background: var(--ink-muted); transition: transform .15s ease, background .15s ease;
|
||||||
|
}
|
||||||
|
.settings-toggle input[type="checkbox"]:checked { border-color: var(--signal-amber); background: rgba(245,166,35,.18); }
|
||||||
|
.settings-toggle input[type="checkbox"]:checked::before { background: var(--signal-amber); transform: translateX(18px); }
|
||||||
|
|
||||||
|
.notifications-list { display: flex; flex-direction: column; gap: 8px; }
|
||||||
|
.notification-item {
|
||||||
|
display: flex; align-items: flex-start; gap: 10px;
|
||||||
|
background: var(--panel); border: 1px solid var(--line); border-radius: 11px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
}
|
||||||
|
.notification-item__icon { font-size: 18px; flex-shrink: 0; }
|
||||||
|
.notification-item__body { flex: 1; min-width: 0; }
|
||||||
|
.notification-item__body strong { display: block; font-size: 13px; }
|
||||||
|
.notification-item__body span { display: block; color: var(--ink-muted); font-size: 12px; margin-top: 2px; }
|
||||||
|
.notification-item__time { color: var(--ink-muted); font-size: 11px; flex-shrink: 0; white-space: nowrap; }
|
||||||
|
.notification-item--unread { border-color: var(--signal-amber); }
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
Einfacher Modus — moderat größere Schrift, reduzierte Optionen
|
||||||
|
(siehe Settings-Toggle in js/app.js, analog zur iOS-App)
|
||||||
|
============================================================ */
|
||||||
|
html.simple-mode { font-size: 112%; }
|
||||||
|
html.simple-mode .journey-modes-field,
|
||||||
|
html.simple-mode #journey-bike-row,
|
||||||
|
html.simple-mode .journey-add-via {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
html.simple-mode .journey-submit,
|
||||||
|
html.simple-mode .location-btn,
|
||||||
|
html.simple-mode .when-choice { min-height: 46px; }
|
||||||
|
|
||||||
.lang-switch { display: flex; gap: 8px; }
|
.lang-switch { display: flex; gap: 8px; }
|
||||||
.lang-switch__btn {
|
.lang-switch__btn {
|
||||||
background: var(--panel); color: var(--ink-muted); border: 1px solid var(--line);
|
background: var(--panel); color: var(--ink-muted); border: 1px solid var(--line);
|
||||||
@@ -2198,10 +2271,11 @@ button:active { transform: translateY(1px); }
|
|||||||
|
|
||||||
@media (max-width: 700px) {
|
@media (max-width: 700px) {
|
||||||
body { padding-bottom: 66px; }
|
body { padding-bottom: 66px; }
|
||||||
.app-header { height: auto; min-height: 64px; padding: 10px 12px; gap: 12px; }
|
.app-header { height: auto; min-height: 64px; padding: 10px 12px; gap: 8px; }
|
||||||
.brand { font-size: 15px; }
|
.brand { font-size: 15px; }
|
||||||
.search-box--global { min-width: 0; }
|
.search-box--global { min-width: 0; }
|
||||||
.search-box--global input { font-size: 14px; padding: 10px 38px; }
|
.search-box--global input { font-size: 14px; padding: 10px 38px; }
|
||||||
|
.header-icon-btn { width: 34px; height: 34px; font-size: 15px; }
|
||||||
.tab-nav { display: none; }
|
.tab-nav { display: none; }
|
||||||
.app-main { padding: 16px 12px 28px; }
|
.app-main { padding: 16px 12px 28px; }
|
||||||
.welcome-panel { padding: 20px 16px; }
|
.welcome-panel { padding: 20px 16px; }
|
||||||
@@ -2227,10 +2301,10 @@ button:active { transform: translateY(1px); }
|
|||||||
bedienbar bleiben, unabhängig von der Karte. */
|
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));
|
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__list { display: grid; grid-template-columns: repeat(4, 1fr); }
|
.mobile-nav__list { display: grid; grid-template-columns: repeat(6, 1fr); }
|
||||||
.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 1px; 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: 16px; line-height: 1.1; }
|
||||||
.mobile-nav__item small { font-size: 10px; }
|
.mobile-nav__item small { font-size: 8.5px; }
|
||||||
.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); }
|
.toast-container { bottom: calc(64px + env(safe-area-inset-bottom) + 12px); }
|
||||||
.global-disclaimer { padding-bottom: calc(28px + 64px + env(safe-area-inset-bottom)); }
|
.global-disclaimer { padding-bottom: calc(28px + 64px + env(safe-area-inset-bottom)); }
|
||||||
|
|||||||
@@ -7,6 +7,17 @@
|
|||||||
// Service Workers ist standardmäßig auf sein eigenes Verzeichnis und alles
|
// Service Workers ist standardmäßig auf sein eigenes Verzeichnis und alles
|
||||||
// darunter beschränkt.
|
// darunter beschränkt.
|
||||||
|
|
||||||
|
// Schreibt jede eingehende Push-Nachricht zusätzlich in den persistenten
|
||||||
|
// Benachrichtigungsverlauf (js/notifications.js), damit sie im "Mehr" >
|
||||||
|
// "Benachrichtigungen"-Tab sichtbar ist - auch wenn sie ankam, während kein
|
||||||
|
// Tab offen war (der Store läuft über IndexedDB, nicht über die Seite).
|
||||||
|
try {
|
||||||
|
self.importScripts('./js/notifications.js');
|
||||||
|
} catch (err) {
|
||||||
|
// Sollte importScripts fehlschlagen, bleibt die Push-Zustellung selbst
|
||||||
|
// trotzdem funktionsfähig - nur der Verlauf fehlt dann.
|
||||||
|
}
|
||||||
|
|
||||||
self.addEventListener('install', () => {
|
self.addEventListener('install', () => {
|
||||||
self.skipWaiting();
|
self.skipWaiting();
|
||||||
});
|
});
|
||||||
@@ -32,7 +43,17 @@ self.addEventListener('push', (event) => {
|
|||||||
data: { url: data.url || '/' },
|
data: { url: data.url || '/' },
|
||||||
};
|
};
|
||||||
|
|
||||||
event.waitUntil(self.registration.showNotification(title, options));
|
event.waitUntil(
|
||||||
|
Promise.all([
|
||||||
|
self.registration.showNotification(title, options),
|
||||||
|
(typeof NotificationStore !== 'undefined'
|
||||||
|
? NotificationStore.add({ title, body: options.body, url: options.data.url }).catch(() => {})
|
||||||
|
: Promise.resolve()),
|
||||||
|
self.clients.matchAll({ type: 'window', includeUncontrolled: true }).then((clientList) => {
|
||||||
|
clientList.forEach((client) => client.postMessage({ type: 'oepnv-navi:notification-added' }));
|
||||||
|
}),
|
||||||
|
]),
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Klick auf die Benachrichtigung: vorhandenen App-Tab in den Vordergrund
|
// Klick auf die Benachrichtigung: vorhandenen App-Tab in den Vordergrund
|
||||||
|
|||||||
Reference in new issue
Block a user