Add files via upload

This commit is contained in:
Serverlele30 authored and GitHub committed 2026-08-31 11:38:03 +02:00
1 parent de8640d801
commit 90ffa5b0ca
6 files changed
+463 -91

No files matched your search

+72 -64
View File
@@ -24,43 +24,43 @@
<div class="search-box search-box--global">
<span class="search-box__icon" aria-hidden="true">⌕</span>
<input type="text" id="station-search" placeholder="Haltestelle, Ort oder Ziel suchen…" autocomplete="off" aria-label="Haltestelle, Ort oder Ziel suchen" />
<button id="search-clear" class="search-clear" type="button" aria-label="Suche leeren" hidden>×</button>
<input type="text" id="station-search" placeholder="Haltestelle, Ort oder Ziel suchen…" data-i18n-placeholder="search.placeholder" autocomplete="off" aria-label="Haltestelle, Ort oder Ziel suchen" data-i18n-aria-label="search.placeholder" />
<button id="search-clear" class="search-clear" type="button" aria-label="Suche leeren" data-i18n-aria-label="search.clear" hidden>×</button>
<ul id="search-results" class="search-results" hidden></ul>
</div>
<button id="header-favorites-btn" class="header-icon-btn" data-tab="favorites" type="button" title="Favoriten" aria-label="Favoriten">★</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>
<nav class="tab-nav" aria-label="Hauptnavigation">
<button class="tab-button is-active" data-tab="departures"><span>🚏</span> Abfahrten</button>
<button class="tab-button" data-tab="journey"><span>🧭</span> Route</button>
<button class="tab-button" data-tab="map"><span>🗺</span> Karte</button>
<button class="tab-button" data-tab="more"><span>☰</span> Mehr</button>
<button class="tab-button is-active" data-tab="departures"><span class="tab-button__icon">🚏</span> <span data-i18n="nav.departures">Abfahrten</span></button>
<button class="tab-button" data-tab="journey"><span class="tab-button__icon">🧭</span> <span data-i18n="nav.route">Route</span></button>
<button class="tab-button" data-tab="map"><span class="tab-button__icon">🗺</span> <span data-i18n="nav.map">Karte</span></button>
<button class="tab-button" data-tab="more"><span class="tab-button__icon">☰</span> <span data-i18n="nav.more">Mehr</span></button>
</nav>
<main class="app-main">
<section class="tab-panel" data-tab-panel="departures">
<div class="welcome-panel" id="welcome-panel">
<div class="welcome-panel__eyebrow">LIVE · VBB</div>
<h1>Was fährt jetzt?</h1>
<p>Suche eine Haltestelle oder nutze deinen Standort, um die nächsten Abfahrten zu sehen.</p>
<h1 data-i18n="welcome.title">Was fährt jetzt?</h1>
<p data-i18n="welcome.text">Suche eine Haltestelle oder nutze deinen Standort, um die nächsten Abfahrten zu sehen.</p>
<div class="welcome-actions">
<button id="welcome-nearby" class="action-card action-card--primary" type="button">
<span class="action-card__icon">📍</span>
<span><strong>In meiner Nähe</strong><small>Haltestellen im Umkreis finden</small></span>
<span><strong data-i18n="welcome.nearbyTitle">In meiner Nähe</strong><small data-i18n="welcome.nearbyText">Haltestellen im Umkreis finden</small></span>
</button>
<button id="welcome-route" class="action-card" type="button">
<span class="action-card__icon">🧭</span>
<span><strong>Route planen</strong><small>Von A nach B mit Bus &amp; Bahn</small></span>
<span><strong data-i18n="welcome.routeTitle">Route planen</strong><small data-i18n="welcome.routeText">Von A nach B mit Bus &amp; Bahn</small></span>
</button>
</div>
</div>
<div id="nearby-home" class="home-section" hidden>
<div class="section-heading">
<div><span class="section-heading__eyebrow">OPTIONAL</span><h2>Haltestellen in der Nähe</h2></div>
<button id="nearby-home-open" class="text-button" type="button">Alle anzeigen</button>
<div><span class="section-heading__eyebrow" data-i18n="common.optional">OPTIONAL</span><h2 data-i18n="nearby.homeTitle">Haltestellen in der Nähe</h2></div>
<button id="nearby-home-open" class="text-button" type="button" data-i18n="common.showAll">Alle anzeigen</button>
</div>
<div id="nearby-home-list" class="nearby-preview"></div>
</div>
@@ -68,152 +68,160 @@
<div id="departures-view" hidden>
<div class="board-header">
<div>
<span class="section-heading__eyebrow">LIVE-ABFAHRTEN</span>
<h1 id="board-station-name" class="board-station-name">Keine Haltestelle ausgewählt</h1>
<span class="section-heading__eyebrow" data-i18n="departures.eyebrow">LIVE-ABFAHRTEN</span>
<h1 id="board-station-name" class="board-station-name" data-i18n="departures.noStation">Keine Haltestelle ausgewählt</h1>
</div>
<button id="favorite-toggle" class="favorite-toggle" hidden>☆ Favorit</button>
<button id="favorite-toggle" class="favorite-toggle" hidden>☆ <span data-i18n="favorites.favorite">Favorit</span></button>
</div>
<div id="board-status" class="board-status" aria-live="polite"><span class="status-dot"></span> Aktualisierung alle 30 Sekunden</div>
<div id="board-status" class="board-status" aria-live="polite"><span class="status-dot"></span> <span data-i18n="departures.refreshInfo">Aktualisierung alle 30 Sekunden</span></div>
<div id="departures-mode-filter" class="mode-filter"></div>
<div id="departure-board" class="departure-board" role="table" aria-label="Abfahrtstafel"></div>
<div id="board-empty-state" class="board-empty-state">Suche oben eine Haltestelle, um Live-Abfahrten zu sehen.</div>
<div id="board-empty-state" class="board-empty-state" data-i18n="departures.emptyState">Suche oben eine Haltestelle, um Live-Abfahrten zu sehen.</div>
<div id="elevator-status" class="elevator-status" hidden></div>
<div id="station-disruption" class="station-disruption" hidden></div>
</div>
</section>
<section class="tab-panel" data-tab-panel="map" hidden>
<div class="panel-heading"><div><span class="section-heading__eyebrow">LIVE</span><h1>Karte</h1><p>Fahrzeuge, Routen und deine aktuelle Fahrt live verfolgen.</p></div></div>
<div class="panel-heading"><div><span class="section-heading__eyebrow" data-i18n="common.live">LIVE</span><h1 data-i18n="map.title">Karte</h1><p data-i18n="map.text">Fahrzeuge, Routen und deine aktuelle Fahrt live verfolgen.</p></div></div>
<div class="map-controls">
<div id="map-mode-filter" class="mode-filter"></div>
<button id="map-locate" class="map-locate-btn" type="button">📍 Mein Standort</button>
<button id="map-locate" class="map-locate-btn" type="button">📍 <span data-i18n="map.locate">Mein Standort</span></button>
</div>
<div id="map-isolation-banner" class="map-isolation-banner">
<span>Verfolge Linie <span id="map-isolation-line" class="map-isolation-banner__line"></span> live</span>
<button id="map-isolation-reset" type="button">Alle Linien anzeigen</button>
<span><span data-i18n="map.following">Verfolge Linie</span> <span id="map-isolation-line" class="map-isolation-banner__line"></span> <span data-i18n="map.live">live</span></span>
<button id="map-isolation-reset" type="button" data-i18n="map.showAllLines">Alle Linien anzeigen</button>
</div>
<div id="live-map" class="live-map"></div>
</section>
<section class="tab-panel" data-tab-panel="journey" hidden>
<div class="panel-heading"><div><span class="section-heading__eyebrow">VERBINDUNG SUCHEN</span><h1>Wohin möchtest du?</h1><p>Starte mit deinem Standort oder suche einen Ausgangspunkt.</p></div></div>
<div class="panel-heading"><div><span class="section-heading__eyebrow" data-i18n="journey.eyebrow">VERBINDUNG SUCHEN</span><h1 data-i18n="journey.title">Wohin möchtest du?</h1><p data-i18n="journey.text">Starte mit deinem Standort oder suche einen Ausgangspunkt.</p></div></div>
<div class="journey-card-form">
<div class="journey-input-row">
<div class="journey-input-marker journey-input-marker--start">●</div>
<div class="search-box">
<input type="text" id="journey-from" placeholder="Mein Standort oder Start…" autocomplete="off" />
<input type="text" id="journey-from" placeholder="Mein Standort oder Start…" data-i18n-placeholder="journey.fromPlaceholder" autocomplete="off" />
<ul id="journey-from-results" class="search-results" hidden></ul>
</div>
<button id="journey-use-location" class="location-btn" title="Meinen Standort verwenden" aria-label="Meinen Standort verwenden">📍</button>
<button id="journey-use-location" class="location-btn" title="Meinen Standort verwenden" data-i18n-title="journey.useLocation" aria-label="Meinen Standort verwenden" data-i18n-aria-label="journey.useLocation">📍</button>
</div>
<div class="journey-connector"></div>
<div class="journey-input-row">
<div class="journey-input-marker journey-input-marker--end">●</div>
<div class="search-box">
<input type="text" id="journey-to" placeholder="Wohin?" autocomplete="off" />
<input type="text" id="journey-to" placeholder="Wohin?" data-i18n-placeholder="journey.toPlaceholder" autocomplete="off" />
<ul id="journey-to-results" class="search-results" hidden></ul>
</div>
</div>
</div>
<div class="journey-when">
<button class="when-choice is-active" data-when="now" type="button">● Jetzt</button>
<button class="when-choice" data-when="departure" type="button">Abfahrt um</button>
<button class="when-choice" data-when="arrival" type="button">Ankunft um</button>
<button class="when-choice is-active" data-when="now" type="button">● <span data-i18n="journey.now">Jetzt</span></button>
<button class="when-choice" data-when="departure" type="button" data-i18n="journey.departureAt">Abfahrt um</button>
<button class="when-choice" data-when="arrival" type="button" data-i18n="journey.arrivalAt">Ankunft um</button>
<input type="datetime-local" id="journey-when" class="journey-datetime" hidden />
</div>
<label class="wheelchair-toggle">
<input type="checkbox" id="journey-wheelchair" />
<span>♿ Nur rollstuhlgerechte Verbindungen</span>
<span>♿ <span data-i18n="journey.wheelchair">Nur rollstuhlgerechte Verbindungen</span></span>
</label>
<div class="journey-options-row">
<div class="transfer-slack-field">
<label for="journey-transfer-slack">Umstiegszeit-Puffer</label>
<label for="journey-transfer-slack" data-i18n="journey.transferSlack">Umstiegszeit-Puffer</label>
<select id="journey-transfer-slack">
<option value="">Standard</option>
<option value="0">0 min (knapp)</option>
<option value="" data-i18n="journey.transferSlackDefault">Standard</option>
<option value="0" data-i18n="journey.transferSlack0">0 min (knapp)</option>
<option value="3">3 min</option>
<option value="5">5 min</option>
<option value="10">10 min</option>
<option value="15">15 min (sicher)</option>
<option value="15" data-i18n="journey.transferSlack15">15 min (sicher)</option>
</select>
</div>
<button id="journey-submit" class="journey-submit" type="button">Verbindungen suchen <span>→</span></button>
<button id="journey-submit" class="journey-submit" type="button"><span data-i18n="journey.submit">Verbindungen suchen</span> <span>→</span></button>
</div>
<p class="journey-source-note">🛰 Routenplanung über unsere selbst gehostete, offene Instanz (VBB-Region)</p>
<p class="journey-source-note" data-i18n="journey.sourceNote">🛰 Routenplanung über unsere selbst gehostete, offene Instanz (VBB-Region)</p>
<div id="journey-share-row" class="journey-share-row" hidden>
<button id="journey-share-btn" class="text-button" type="button">🔗 Route teilen</button>
<button id="journey-share-btn" class="text-button" type="button">🔗 <span data-i18n="journey.share">Route teilen</span></button>
</div>
<div id="journey-results" class="journey-results"></div>
</section>
<section class="tab-panel" data-tab-panel="nearby" hidden>
<div class="panel-heading"><div><span class="section-heading__eyebrow">STANDORT</span><h1>In der Nähe</h1><p>Finde Haltestellen rund um deinen aktuellen Standort.</p></div></div>
<button id="nearby-locate" class="location-btn location-btn--wide">📍 Meinen Standort verwenden</button>
<div id="nearby-list" class="nearby-list"><div class="board-empty-state">Nutze deinen Standort, um Haltestellen in deiner Nähe zu finden.</div></div>
<div class="panel-heading"><div><span class="section-heading__eyebrow" data-i18n="nearby.eyebrow">STANDORT</span><h1 data-i18n="nearby.title">In der Nähe</h1><p data-i18n="nearby.text">Finde Haltestellen rund um deinen aktuellen Standort.</p></div></div>
<button id="nearby-locate" class="location-btn location-btn--wide">📍 <span data-i18n="nearby.locate">Meinen Standort verwenden</span></button>
<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 class="tab-panel" data-tab-panel="favorites" hidden>
<div class="panel-heading">
<div><span class="section-heading__eyebrow">SCHNELLZUGRIFF</span><h1>Meine Haltestellen</h1><p>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 id="favorites-home" class="home-section">
<div class="section-heading">
<div></div>
<button id="notify-toggle" class="text-button" type="button" hidden>🔔 Störungsbenachrichtigungen aktivieren</button>
<button id="notify-toggle" class="text-button" type="button" hidden>🔔 <span data-i18n="favorites.enableNotify">Störungsbenachrichtigungen aktivieren</span></button>
</div>
<p id="favorites-empty-hint" class="favorites-empty-hint">Noch keine Favoriten - tippe bei einer Haltestelle auf ☆, um sie hier anzupinnen.</p>
<p id="favorites-empty-hint" class="favorites-empty-hint" data-i18n="favorites.emptyHint">Noch keine Favoriten - tippe bei einer Haltestelle auf ☆, um sie hier anzupinnen.</p>
<div id="favorite-cards" class="favorite-cards"></div>
</div>
</section>
<section class="tab-panel" data-tab-panel="disruptions" hidden>
<div class="panel-heading"><div><span class="section-heading__eyebrow">AKTUELL</span><h1>Störungen</h1><p>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="section-heading"><div><h2>In deiner Nähe</h2></div></div>
<button id="disruptions-locate" class="location-btn location-btn--wide">📍 Standort verwenden</button>
<div id="nearby-disruptions-list" class="remarks-list"><div class="board-empty-state">Nutze deinen Standort für ortsbezogene Störungen.</div></div>
<div class="section-heading"><div><h2 data-i18n="disruptions.nearbyTitle">In deiner Nähe</h2></div></div>
<button id="disruptions-locate" class="location-btn location-btn--wide">📍 <span data-i18n="disruptions.useLocation">Standort verwenden</span></button>
<div id="nearby-disruptions-list" class="remarks-list"><div class="board-empty-state" data-i18n="disruptions.nearbyEmpty">Nutze deinen Standort für ortsbezogene Störungen.</div></div>
</div>
<div class="disruption-section">
<div class="section-heading"><div><h2>Netzweit</h2></div></div>
<div id="remarks-list" class="remarks-list"><div class="board-empty-state">Störungsmeldungen werden geladen…</div></div>
<div class="section-heading"><div><h2 data-i18n="disruptions.networkTitle">Netzweit</h2></div></div>
<div id="remarks-list" class="remarks-list"><div class="board-empty-state" data-i18n="disruptions.loading">Störungsmeldungen werden geladen…</div></div>
</div>
</section>
<section class="tab-panel" data-tab-panel="fares" hidden>
<div class="panel-heading"><div><span class="section-heading__eyebrow">PREISE</span><h1>Tickets</h1><p>Übliche VBB-Fahrpreise auf einen Blick.</p></div></div>
<div class="disclaimer-banner">⚠ Angaben ohne Gewähr – Preise können sich ändern. <span id="fares-stand"></span></div>
<div id="fares-content"><div class="board-empty-state">Lade Fahrpreise…</div></div>
<div class="section-heading"><div><h2>Tickets kaufen</h2></div></div>
<div class="panel-heading"><div><span class="section-heading__eyebrow" data-i18n="fares.eyebrow">PREISE</span><h1 data-i18n="fares.title">Tickets</h1><p data-i18n="fares.text">Übliche VBB-Fahrpreise auf einen Blick.</p></div></div>
<div class="disclaimer-banner">⚠ <span data-i18n="fares.disclaimer">Angaben ohne Gewähr – Preise können sich ändern.</span> <span id="fares-stand"></span></div>
<div id="fares-content"><div class="board-empty-state" data-i18n="fares.loading">Lade Fahrpreise…</div></div>
<div class="section-heading"><div><h2 data-i18n="fares.buyTitle">Tickets kaufen</h2></div></div>
<div id="fares-companies" class="company-links"></div>
</section>
<section class="tab-panel" data-tab-panel="more" hidden>
<div class="panel-heading"><div><span class="section-heading__eyebrow">WEITERE FUNKTIONEN</span><h1>Mehr</h1><p>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">
<button class="more-card" data-open-tab="favorites" type="button"><span>★</span><strong>Favoriten</strong><small>Deine gespeicherten Haltestellen</small></button>
<button class="more-card" data-open-tab="nearby" type="button"><span>📍</span><strong>In der Nähe</strong><small>Haltestellen in deiner Umgebung</small></button>
<button class="more-card" data-open-tab="disruptions" type="button"><span>⚠</span><strong>Störungen</strong><small>Ortsbezogen &amp; netzweit</small></button>
<button class="more-card" data-open-tab="fares" type="button"><span>🎫</span><strong>Tickets</strong><small>Preise &amp; wo kaufen</small></button>
<button class="more-card" data-open-tab="favorites" type="button"><span>★</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>📍</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>⚠</span><strong data-i18n="disruptions.title">Störungen</strong><small data-i18n="more.disruptionsText">Ortsbezogen &amp; netzweit</small></button>
<button class="more-card" data-open-tab="fares" type="button"><span>🎫</span><strong data-i18n="fares.title">Tickets</strong><small data-i18n="more.faresText">Preise &amp; wo kaufen</small></button>
</div>
<div class="recent-section">
<div class="section-heading"><div><span class="section-heading__eyebrow">VERLAUF</span><h2>Zuletzt gesucht</h2></div><button id="clear-recent" class="text-button" type="button">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"></div>
</div>
<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="lang-switch">
<button id="lang-de" class="lang-switch__btn" type="button" data-lang="de">Deutsch</button>
<button id="lang-en" class="lang-switch__btn" type="button" data-lang="en">English</button>
</div>
</div>
</section>
</main>
<div class="global-disclaimer">ℹ Live-Daten von VBB · Angaben ohne Gewähr</div>
<div class="global-disclaimer" data-i18n="common.footer">ℹ Live-Daten von VBB · Angaben ohne Gewähr</div>
<nav class="mobile-nav" aria-label="Hauptnavigation mobil">
<button class="mobile-nav__item is-active" data-tab="departures"><span>🚏</span><small>Abfahrten</small></button>
<button class="mobile-nav__item" data-tab="journey"><span>🧭</span><small>Route</small></button>
<button class="mobile-nav__item" data-tab="map"><span>🗺</span><small>Karte</small></button>
<button class="mobile-nav__item" data-tab="more"><span>☰</span><small>Mehr</small></button>
<button class="mobile-nav__item is-active" data-tab="departures"><span>🚏</span><small data-i18n="nav.departures">Abfahrten</small></button>
<button class="mobile-nav__item" data-tab="journey"><span>🧭</span><small data-i18n="nav.route">Route</small></button>
<button class="mobile-nav__item" data-tab="map"><span>🗺</span><small data-i18n="nav.map">Karte</small></button>
<button class="mobile-nav__item" data-tab="more"><span>☰</span><small data-i18n="nav.more">Mehr</small></button>
</nav>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script src="js/config.js"></script>
<script src="js/i18n.js"></script>
<script src="js/api.js"></script>
<script src="js/splitflap.js"></script>
<script src="js/modefilter.js"></script>
+6
View File
@@ -140,5 +140,11 @@ const API = (() => {
getSummaryDepartures(stations) {
return postJson('/summary/departures', { stations });
},
// Verspätungsstatistik pro Linie (siehe backend/src/utils/delayStats.js) -
// "sufficientData: false" bedeutet: noch zu wenige Datenpunkte gesammelt.
getLineDelayStats(line, days = 14) {
return request(`/stats/delays/${encodeURIComponent(line)}?${qs({ days })}`);
},
};
})();
+37 -24
View File
@@ -59,7 +59,7 @@ const App = (() => {
const items = loadRecentSearches();
container.innerHTML = '';
if (!items.length) {
container.innerHTML = '<div class="recent-empty">Noch keine Suchen.</div>';
container.innerHTML = `<div class="recent-empty">${escapeHtml(I18N.t('recent.empty'))}</div>`;
return;
}
items.forEach((item) => {
@@ -75,7 +75,7 @@ const App = (() => {
const remove = document.createElement('button');
remove.type = 'button';
remove.className = 'recent-item__remove';
remove.setAttribute('aria-label', `${item.name} aus Verlauf entfernen`);
remove.setAttribute('aria-label', I18N.t('recent.remove', { name: item.name }));
remove.textContent = '×';
remove.addEventListener('click', (e) => {
e.stopPropagation();
@@ -150,7 +150,7 @@ const App = (() => {
<span class="favorite-card__star">★</span>
<span>
<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>${escapeHtml(I18N.t('favorites.loadingNext'))}</small>
<small class="favorite-card__disruption" data-disruption hidden></small>
</span>
<span class="favorite-card__arrow">→</span>
@@ -252,14 +252,14 @@ const App = (() => {
if (el) {
if (r.error || !r.nextDeparture) {
el.textContent = 'Keine Abfahrt gefunden';
el.textContent = I18N.t('favorites.noDeparture');
} 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>`;
el.innerHTML = `<span class="favorite-card__cancelled">${escapeHtml(dep.line || '')} ${escapeHtml(I18N.t('favorites.cancelledShort'))}</span>`;
} else {
el.textContent = `${dep.line || '?'} → ${dep.direction || ''} · ${minutesUntil != null ? minutesUntil + ' min' : ''}`;
el.textContent = `${dep.line || '?'} → ${dep.direction || ''} · ${minutesUntil != null ? minutesUntil + ' ' + I18N.t('favorites.minutesShort') : ''}`;
}
}
}
@@ -320,15 +320,15 @@ const App = (() => {
}
btn.hidden = false;
if (Notification.permission === 'granted') {
btn.textContent = '🔔 Benachrichtigungen aktiv';
btn.textContent = `🔔 ${I18N.t('favorites.notifyActive')}`;
btn.classList.add('is-active');
btn.disabled = true;
} else if (Notification.permission === 'denied') {
btn.textContent = '🔕 Benachrichtigungen blockiert';
btn.textContent = `🔕 ${I18N.t('favorites.notifyBlocked')}`;
btn.classList.remove('is-active');
btn.disabled = true;
} else {
btn.textContent = '🔔 Störungsbenachrichtigungen aktivieren';
btn.textContent = `🔔 ${I18N.t('favorites.enableNotify')}`;
btn.classList.remove('is-active');
btn.disabled = false;
}
@@ -356,7 +356,7 @@ const App = (() => {
}
btn.hidden = false;
const active = isFavorite(currentStop.id);
btn.textContent = active ? '★ Favorit' : '☆ Favorit';
btn.textContent = `${active ? '★' : '☆'} ${I18N.t('favorites.favorite')}`;
btn.classList.toggle('is-active', active);
}
@@ -389,7 +389,7 @@ const App = (() => {
results.innerHTML = '';
return;
}
results.innerHTML = '<li class="search-result-heading">Zuletzt gesucht</li>';
results.innerHTML = `<li class="search-result-heading">${escapeHtml(I18N.t('search.recentSearches'))}</li>`;
renderSearchResults(results, recents, (station) => {
selectStation(station);
input.value = '';
@@ -449,7 +449,7 @@ const App = (() => {
if (!append) container.innerHTML = '';
if (locations.length === 0) {
container.innerHTML = '<li class="search-result search-result--empty">Keine Treffer</li>';
container.innerHTML = `<li class="search-result search-result--empty">${escapeHtml(I18N.t('search.noResults'))}</li>`;
container.hidden = false;
return;
}
@@ -561,7 +561,7 @@ const App = (() => {
const source = currentStop ? currentStop.source : null;
if (departures.length === 0) {
board.innerHTML = '<div class="board-empty">Keine Abfahrten für die gewählten Verkehrsmittel in den nächsten 30 Minuten.</div>';
board.innerHTML = `<div class="board-empty">${escapeHtml(I18N.t('departures.noResultsForModes'))}</div>`;
lastRenderedSource = source;
return;
}
@@ -589,19 +589,19 @@ const App = (() => {
row.style.setProperty('--row-index', index);
row.innerHTML = `
<div class="flap-field flap-field--line">
<span class="flap-label">Linie</span>
<span class="flap-label">${escapeHtml(I18N.t('departures.line'))}</span>
<span class="flap-cell flap-cell--line" data-field="line"></span>
</div>
<div class="flap-field flap-field--direction">
<span class="flap-label">Ziel</span>
<span class="flap-label">${escapeHtml(I18N.t('departures.direction'))}</span>
<span class="flap-cell flap-cell--direction" data-field="direction"></span>
</div>
<div class="flap-field flap-field--platform">
<span class="flap-label">Gleis</span>
<span class="flap-label">${escapeHtml(I18N.t('departures.platform'))}</span>
<span class="flap-cell flap-cell--platform" data-field="platform"></span>
</div>
<div class="flap-field flap-field--time">
<span class="flap-label">Abfahrt</span>
<span class="flap-label">${escapeHtml(I18N.t('departures.time'))}</span>
<span class="flap-cell flap-cell--time" data-field="time"></span>
</div>
`;
@@ -664,8 +664,8 @@ const App = (() => {
platformCell.dataset.changedFrom = plannedPlatform;
platformCell.classList.add('flap-cell--platform-changed');
platformCell.innerHTML = `
<span class="platform-changed-flag">GLEIS ${escapeHtml(newPlatform)}</span>
<span class="platform-changed-original">statt ${escapeHtml(plannedPlatform)}</span>
<span class="platform-changed-flag">${escapeHtml(I18N.t('departures.platformChanged', { n: newPlatform }))}</span>
<span class="platform-changed-original">${escapeHtml(I18N.t('departures.platformInstead', { n: plannedPlatform }))}</span>
`;
}
} else {
@@ -687,8 +687,8 @@ const App = (() => {
timeCell.dataset.cancelledPlanned = plannedTime;
timeCell.classList.add('flap-cell--time-cancelled');
timeCell.innerHTML = `
<span class="cancelled-flag">FÄLLT AUS</span>
<span class="cancelled-original">urspr. ${plannedTime}</span>
<span class="cancelled-flag">${escapeHtml(I18N.t('departures.cancelled'))}</span>
<span class="cancelled-original">${escapeHtml(I18N.t('departures.plannedShort'))} ${escapeHtml(plannedTime)}</span>
`;
}
} else {
@@ -979,11 +979,11 @@ const App = (() => {
const shareRow = document.getElementById('journey-share-row');
if (!journeyFrom || !journeyTo) {
resultsEl.innerHTML = '<div class="board-error">Bitte Start und Ziel aus der Liste auswählen.</div>';
resultsEl.innerHTML = `<div class="board-error">${escapeHtml(I18N.t('journey.selectFromResults'))}</div>`;
return;
}
resultsEl.innerHTML = '<div class="board-empty">Suche Verbindungen…</div>';
resultsEl.innerHTML = `<div class="board-empty">${escapeHtml(I18N.t('journey.searching'))}</div>`;
if (shareRow) shareRow.hidden = true;
const whenInput = document.getElementById('journey-when').value;
@@ -1142,7 +1142,7 @@ const App = (() => {
container.innerHTML = '';
if (journeys.length === 0) {
container.innerHTML = '<div class="board-empty">Keine Verbindungen gefunden.</div>';
container.innerHTML = `<div class="board-empty">${escapeHtml(I18N.t('journey.noResults'))}</div>`;
return;
}
@@ -1261,6 +1261,19 @@ const App = (() => {
}
function init() {
I18N.init();
I18N.setOnLangChange(() => {
// [data-i18n]-Elemente übernimmt I18N.applyStaticTranslations() bereits
// selbst - hier nur die dynamisch (per JS-Template) erzeugten Bereiche
// neu aufbauen, die Sprachwechsel sonst nicht mitbekommen würden.
lastRenderedSource = null;
if (currentStop) loadDepartures();
renderFavoriteChips();
});
document.querySelectorAll('.lang-switch__btn').forEach((btn) => {
btn.addEventListener('click', () => I18N.setLang(btn.dataset.lang));
});
initSearch();
initTabs();
initJourneyPlanner();
+297
View File
@@ -0,0 +1,297 @@
// Mehrsprachigkeit: einfaches, erweiterbares Key-Value-Wörterbuch statt einer
// externen i18n-Library (die App ist klein genug, dass sich das nicht lohnt).
// Deutsch ist immer die Fallback-Sprache, falls ein Key in einer anderen
// Sprache fehlt - so kann man neue Sprachen nach und nach vervollständigen,
// ohne dass währenddessen Keys komplett leer/undefined angezeigt werden.
//
// Deckt aktuell ab: die komplette statische Oberfläche (Navigation,
// Überschriften, Buttons, Platzhalter) sowie die am häufigsten sichtbaren
// dynamisch erzeugten Texte (Abfahrtstafel-Beschriftungen, Favoriten-Karten,
// Formular-Rückmeldungen). Tiefer verschachtelte Bereiche (Fahrt-Detail-Modal,
// Fahrtalarm-Overlay, Live-Karten-Popups) sowie Störungstexte direkt von VBB
// bleiben bewusst auf Deutsch, da VBB dafür keine englischen Texte liefert.
const I18N = (() => {
const STORAGE_KEY = 'oepnv-navi:db-lang';
const translations = {
de: {
'search.placeholder': 'Haltestelle, Ort oder Ziel suchen…',
'search.clear': 'Suche leeren',
'nav.favorites': 'Favoriten',
'nav.departures': 'Abfahrten',
'nav.route': 'Route',
'nav.map': 'Karte',
'nav.more': 'Mehr',
'common.optional': 'OPTIONAL',
'common.showAll': 'Alle anzeigen',
'common.live': 'LIVE',
'common.current': 'AKTUELL',
'common.clear': 'Leeren',
'common.footer': 'ℹ Live-Daten von VBB · Angaben ohne Gewähr',
'welcome.title': 'Was fährt jetzt?',
'welcome.text': 'Suche eine Haltestelle oder nutze deinen Standort, um die nächsten Abfahrten zu sehen.',
'welcome.nearbyTitle': 'In meiner Nähe',
'welcome.nearbyText': 'Haltestellen im Umkreis finden',
'welcome.routeTitle': 'Route planen',
'welcome.routeText': 'Von A nach B mit Bus & Bahn',
'nearby.homeTitle': 'Haltestellen in der Nähe',
'nearby.eyebrow': 'STANDORT',
'nearby.title': 'In der Nähe',
'nearby.text': 'Finde Haltestellen rund um deinen aktuellen Standort.',
'nearby.locate': 'Meinen Standort verwenden',
'nearby.emptyState': 'Nutze deinen Standort, um Haltestellen in deiner Nähe zu finden.',
'departures.eyebrow': 'LIVE-ABFAHRTEN',
'departures.noStation': 'Keine Haltestelle ausgewählt',
'departures.refreshInfo': 'Aktualisierung alle 30 Sekunden',
'departures.emptyState': 'Suche oben eine Haltestelle, um Live-Abfahrten zu sehen.',
'departures.line': 'Linie',
'departures.direction': 'Ziel',
'departures.platform': 'Gleis',
'departures.time': 'Abfahrt',
'departures.cancelled': 'FÄLLT AUS',
'departures.plannedShort': 'urspr.',
'departures.platformChanged': 'GLEIS {n}',
'departures.platformInstead': 'statt {n}',
'departures.noResultsForModes': 'Keine Abfahrten für die gewählten Verkehrsmittel in den nächsten 30 Minuten.',
'favorites.favorite': 'Favorit',
'favorites.eyebrow': 'SCHNELLZUGRIFF',
'favorites.title': 'Meine Haltestellen',
'favorites.text': 'Deine gespeicherten Haltestellen mit Live-Abfahrten und Störungshinweisen.',
'favorites.enableNotify': 'Störungsbenachrichtigungen aktivieren',
'favorites.notifyActive': 'Benachrichtigungen aktiv',
'favorites.notifyBlocked': 'Benachrichtigungen blockiert',
'favorites.emptyHint': 'Noch keine Favoriten - tippe bei einer Haltestelle auf ☆, um sie hier anzupinnen.',
'favorites.loadingNext': 'Lade nächste Abfahrt…',
'favorites.noDeparture': 'Keine Abfahrt gefunden',
'favorites.cancelledShort': 'fällt aus',
'favorites.minutesShort': 'min',
'map.title': 'Karte',
'map.text': 'Fahrzeuge, Routen und deine aktuelle Fahrt live verfolgen.',
'map.locate': 'Mein Standort',
'map.following': 'Verfolge Linie',
'map.live': 'live',
'map.showAllLines': 'Alle Linien anzeigen',
'journey.eyebrow': 'VERBINDUNG SUCHEN',
'journey.title': 'Wohin möchtest du?',
'journey.text': 'Starte mit deinem Standort oder suche einen Ausgangspunkt.',
'journey.fromPlaceholder': 'Mein Standort oder Start…',
'journey.toPlaceholder': 'Wohin?',
'journey.useLocation': 'Meinen Standort verwenden',
'journey.now': 'Jetzt',
'journey.departureAt': 'Abfahrt um',
'journey.arrivalAt': 'Ankunft um',
'journey.wheelchair': 'Nur rollstuhlgerechte Verbindungen',
'journey.transferSlack': 'Umstiegszeit-Puffer',
'journey.transferSlackDefault': 'Standard',
'journey.transferSlack0': '0 min (knapp)',
'journey.transferSlack15': '15 min (sicher)',
'journey.submit': 'Verbindungen suchen',
'journey.sourceNote': '🛰 Routenplanung über unsere selbst gehostete, offene Instanz (VBB-Region)',
'journey.share': 'Route teilen',
'journey.selectFromResults': 'Bitte Start und Ziel aus der Liste auswählen.',
'journey.searching': 'Suche Verbindungen…',
'journey.noResults': 'Keine Verbindungen gefunden.',
'disruptions.title': 'Störungen',
'disruptions.text': 'Ortsbezogene und netzweite Hinweise.',
'disruptions.nearbyTitle': 'In deiner Nähe',
'disruptions.useLocation': 'Standort verwenden',
'disruptions.nearbyEmpty': 'Nutze deinen Standort für ortsbezogene Störungen.',
'disruptions.networkTitle': 'Netzweit',
'disruptions.loading': 'Störungsmeldungen werden geladen…',
'fares.eyebrow': 'PREISE',
'fares.title': 'Tickets',
'fares.text': 'Übliche VBB-Fahrpreise auf einen Blick.',
'fares.disclaimer': 'Angaben ohne Gewähr – Preise können sich ändern.',
'fares.loading': 'Lade Fahrpreise…',
'fares.buyTitle': 'Tickets kaufen',
'more.eyebrow': 'WEITERE FUNKTIONEN',
'more.text': 'Alles, was nicht bei jeder Fahrt gebraucht wird.',
'more.favoritesText': 'Deine gespeicherten Haltestellen',
'more.nearbyText': 'Haltestellen in deiner Umgebung',
'more.disruptionsText': 'Ortsbezogen & netzweit',
'more.faresText': 'Preise & wo kaufen',
'more.historyEyebrow': 'VERLAUF',
'more.historyTitle': 'Zuletzt gesucht',
'more.langEyebrow': 'SPRACHE / LANGUAGE',
'more.langTitle': 'Sprache',
'search.noResults': 'Keine Treffer',
'search.recentSearches': 'Zuletzt gesucht',
'recent.empty': 'Noch keine Suchen.',
'recent.remove': '{name} aus Verlauf entfernen',
},
en: {
'search.placeholder': 'Search for a stop, place or destination…',
'search.clear': 'Clear search',
'nav.favorites': 'Favorites',
'nav.departures': 'Departures',
'nav.route': 'Route',
'nav.map': 'Map',
'nav.more': 'More',
'common.optional': 'OPTIONAL',
'common.showAll': 'Show all',
'common.live': 'LIVE',
'common.current': 'CURRENT',
'common.clear': 'Clear',
'common.footer': 'ℹ Live data from VBB · No guarantee of accuracy',
'welcome.title': 'What\u2019s leaving now?',
'welcome.text': 'Search for a stop or use your location to see the next departures.',
'welcome.nearbyTitle': 'Nearby',
'welcome.nearbyText': 'Find stops around you',
'welcome.routeTitle': 'Plan a route',
'welcome.routeText': 'From A to B by bus & train',
'nearby.homeTitle': 'Stops nearby',
'nearby.eyebrow': 'LOCATION',
'nearby.title': 'Nearby',
'nearby.text': 'Find stops around your current location.',
'nearby.locate': 'Use my location',
'nearby.emptyState': 'Use your location to find stops nearby.',
'departures.eyebrow': 'LIVE DEPARTURES',
'departures.noStation': 'No stop selected',
'departures.refreshInfo': 'Updates every 30 seconds',
'departures.emptyState': 'Search for a stop above to see live departures.',
'departures.line': 'Line',
'departures.direction': 'Towards',
'departures.platform': 'Platform',
'departures.time': 'Departure',
'departures.cancelled': 'CANCELLED',
'departures.plannedShort': 'orig.',
'departures.platformChanged': 'PLATFORM {n}',
'departures.platformInstead': 'instead of {n}',
'departures.noResultsForModes': 'No departures for the selected transport modes in the next 30 minutes.',
'favorites.favorite': 'Favorite',
'favorites.eyebrow': 'QUICK ACCESS',
'favorites.title': 'My Stops',
'favorites.text': 'Your saved stops with live departures and disruption notices.',
'favorites.enableNotify': 'Enable disruption notifications',
'favorites.notifyActive': 'Notifications active',
'favorites.notifyBlocked': 'Notifications blocked',
'favorites.emptyHint': 'No favorites yet - tap ☆ at a stop to pin it here.',
'favorites.loadingNext': 'Loading next departure…',
'favorites.noDeparture': 'No departure found',
'favorites.cancelledShort': 'cancelled',
'favorites.minutesShort': 'min',
'map.title': 'Map',
'map.text': 'Track vehicles, routes and your current trip live.',
'map.locate': 'My location',
'map.following': 'Tracking line',
'map.live': 'live',
'map.showAllLines': 'Show all lines',
'journey.eyebrow': 'PLAN A JOURNEY',
'journey.title': 'Where to?',
'journey.text': 'Start with your location or search for a starting point.',
'journey.fromPlaceholder': 'My location or start…',
'journey.toPlaceholder': 'Where to?',
'journey.useLocation': 'Use my location',
'journey.now': 'Now',
'journey.departureAt': 'Depart at',
'journey.arrivalAt': 'Arrive by',
'journey.wheelchair': 'Wheelchair-accessible connections only',
'journey.transferSlack': 'Transfer buffer',
'journey.transferSlackDefault': 'Default',
'journey.transferSlack0': '0 min (tight)',
'journey.transferSlack15': '15 min (safe)',
'journey.submit': 'Search connections',
'journey.sourceNote': '🛰 Journey planning via our self-hosted, open instance (VBB region)',
'journey.share': 'Share route',
'journey.selectFromResults': 'Please choose start and destination from the list.',
'journey.searching': 'Searching for connections…',
'journey.noResults': 'No connections found.',
'disruptions.title': 'Disruptions',
'disruptions.text': 'Location-based and network-wide notices.',
'disruptions.nearbyTitle': 'Near you',
'disruptions.useLocation': 'Use location',
'disruptions.nearbyEmpty': 'Use your location for nearby disruptions.',
'disruptions.networkTitle': 'Network-wide',
'disruptions.loading': 'Loading disruption notices…',
'fares.eyebrow': 'FARES',
'fares.title': 'Tickets',
'fares.text': 'Common VBB fares at a glance.',
'fares.disclaimer': 'No guarantee of accuracy – prices may change.',
'fares.loading': 'Loading fares…',
'fares.buyTitle': 'Buy tickets',
'more.eyebrow': 'MORE FEATURES',
'more.text': 'Everything you don\u2019t need on every trip.',
'more.favoritesText': 'Your saved stops',
'more.nearbyText': 'Stops around you',
'more.disruptionsText': 'Local & network-wide',
'more.faresText': 'Prices & where to buy',
'more.historyEyebrow': 'HISTORY',
'more.historyTitle': 'Recently searched',
'more.langEyebrow': 'SPRACHE / LANGUAGE',
'more.langTitle': 'Language',
'search.noResults': 'No results',
'search.recentSearches': 'Recently searched',
'recent.empty': 'No searches yet.',
'recent.remove': 'Remove {name} from history',
},
};
let lang = 'de';
let onLangChange = null;
function detectLang() {
try {
const saved = localStorage.getItem(STORAGE_KEY);
if (saved && translations[saved]) return saved;
} catch (_) { /* localStorage evtl. nicht verfügbar */ }
const browserLang = (navigator.language || 'de').slice(0, 2).toLowerCase();
return translations[browserLang] ? browserLang : 'de';
}
function t(key, vars) {
let str = (translations[lang] && translations[lang][key]) || translations.de[key] || key;
if (vars) {
Object.keys(vars).forEach((k) => {
str = str.replace(`{${k}}`, vars[k]);
});
}
return str;
}
function applyStaticTranslations() {
document.querySelectorAll('[data-i18n]').forEach((el) => {
el.textContent = t(el.dataset.i18n);
});
document.querySelectorAll('[data-i18n-placeholder]').forEach((el) => {
el.placeholder = t(el.dataset.i18nPlaceholder);
});
document.querySelectorAll('[data-i18n-aria-label]').forEach((el) => {
el.setAttribute('aria-label', t(el.dataset.i18nAriaLabel));
});
document.querySelectorAll('[data-i18n-title]').forEach((el) => {
el.title = t(el.dataset.i18nTitle);
});
document.querySelectorAll('.lang-switch__btn').forEach((btn) => {
btn.classList.toggle('is-active', btn.dataset.lang === lang);
});
}
function setLang(newLang) {
if (!translations[newLang] || newLang === lang) return;
lang = newLang;
try { localStorage.setItem(STORAGE_KEY, newLang); } catch (_) { /* ignorieren */ }
document.documentElement.lang = newLang;
applyStaticTranslations();
if (typeof onLangChange === 'function') onLangChange(newLang);
}
// Wird von app.js gesetzt, um nach einem Sprachwechsel auch dynamisch
// erzeugte Inhalte (z.B. gerade sichtbare Abfahrtstafel) neu zu rendern -
// applyStaticTranslations() allein erreicht nur [data-i18n]-Elemente im DOM.
function setOnLangChange(fn) {
onLangChange = fn;
}
function getLang() {
return lang;
}
function init() {
lang = detectLang();
document.documentElement.lang = lang;
applyStaticTranslations();
}
return { t, setLang, getLang, setOnLangChange, init };
})();
+34 -2
View File
@@ -32,6 +32,7 @@ const TripModal = (() => {
</div>
<button class="trip-modal__close" type="button" aria-label="Schließen">×</button>
</div>
<div class="trip-modal__stats" id="trip-modal-stats" hidden></div>
<div class="trip-modal__map" id="trip-modal-map"></div>
<div class="trip-modal__body" id="trip-modal-body">
<div class="trip-modal__loading"><span class="spinner"></span>Lade Fahrt-Details…</div>
@@ -75,6 +76,33 @@ const TripModal = (() => {
return new Date(iso).toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
}
// Verspätungsstatistik pro Linie - Datenbasis wächst erst über Zeit, auf
// einem frisch aufgesetzten Server gibt es also erstmal nichts anzuzeigen.
async function loadLineStats(line) {
const el = document.getElementById('trip-modal-stats');
if (!el) return;
el.hidden = true;
if (!line) return;
try {
const stats = await API.getLineDelayStats(line);
if (!stats || !stats.sufficientData) return; // still hidden - keine Aussage besser als eine unsichere
const avgMin = Math.round(stats.avgDelaySeconds / 60);
const parts = [];
parts.push(avgMin > 0 ? `Ø +${avgMin} Min` : 'Ø pünktlich');
parts.push(`${stats.pctDelayed5plus}% ≥5 Min verspätet`);
if (stats.pctCancelled > 0) parts.push(`${stats.pctCancelled}% Ausfälle`);
el.textContent = `📊 ${parts.join(' · ')} (${stats.sampleCount} Fahrten, ${stats.days} Tage)`;
el.hidden = false;
} catch (err) {
// Statistik ist ein "nice to have" - bei Fehlern einfach ausblenden,
// ohne die eigentliche Fahrt-Detail-Ansicht zu stören.
console.error('Verspätungsstatistik-Fehler:', err.message);
}
}
function getAlarmOffset() {
return parseInt(localStorage.getItem(ALARM_OFFSET_KEY), 10) || 1;
}
@@ -183,9 +211,11 @@ const TripModal = (() => {
currentTripId = tripId;
currentTrip = null;
const statsEl = document.getElementById('trip-modal-stats');
if (statsEl) { statsEl.hidden = true; }
document.getElementById('trip-modal-line').textContent = '…';
document.getElementById('trip-modal-line').style.background = '#8E99A6';
document.getElementById('trip-modal-direction').textContent = '';
document.getElementById('trip-modal-line').style.background = '#8E99A6'; document.getElementById('trip-modal-direction').textContent = '';
document.getElementById('trip-modal-meta').textContent = '';
document.getElementById('trip-modal-body').innerHTML = '<div class="trip-modal__loading"><span class="spinner"></span>Lade Fahrt-Details…</div>';
@@ -209,6 +239,8 @@ const TripModal = (() => {
? 'Fahrt fällt aus'
: `${trip.origin || ''} → ${trip.destination || ''}`;
loadLineStats(trip.line);
// Mini-Karte mit dem Streckenverlauf zeichnen
if (miniMap) { miniMap.remove(); miniMap = null; }
if (trip.polyline) {
+17 -1
View File
@@ -866,7 +866,7 @@ button:active { transform: translateY(1px); }
border-bottom: 1px solid var(--line);
}
.tab-button { padding: 14px 18px; display: inline-flex; gap: 8px; align-items: center; }
.tab-button span { filter: grayscale(.35); }
.tab-button__icon { filter: grayscale(.35); }
.app-main { max-width: 920px; padding: 30px 20px 50px; }
@@ -1072,6 +1072,14 @@ button:active { transform: translateY(1px); }
.recent-item__remove:hover { background: var(--panel-raised); color: var(--signal-red); }
.recent-empty { color: var(--ink-muted); font-size: 12px; padding: 8px 0; }
.lang-switch { display: flex; gap: 8px; }
.lang-switch__btn {
background: var(--panel); color: var(--ink-muted); border: 1px solid var(--line);
border-radius: 999px; padding: 8px 16px; font-size: 13px;
}
.lang-switch__btn:hover { border-color: var(--signal-amber); }
.lang-switch__btn.is-active { color: var(--signal-amber); border-color: var(--signal-amber); background: rgba(245,166,35,.08); }
.search-result-heading {
padding: 8px 12px 4px;
color: var(--ink-muted);
@@ -1174,6 +1182,14 @@ button:active { transform: translateY(1px); }
border-color: var(--signal-amber);
}
.trip-modal__stats {
padding: 8px 18px;
font-size: 12px;
color: var(--ink-muted);
background: var(--panel-raised);
border-bottom: 1px solid var(--line);
}
.trip-modal__map {
height: 220px;
border-bottom: 1px solid var(--line);