From 9c3e2af20996cb507d1b4d9b95e1b4ff5809c2e3 Mon Sep 17 00:00:00 2001 From: Serverlele30 <46974281+Serverlele30@users.noreply.github.com> Date: Tue, 1 Sep 2026 13:12:08 +0200 Subject: [PATCH] Add files via upload --- CHANGELOG.md | 267 +++++++++++++++++++++++++++++++++++++++++++++++ README.md | 158 +++++++++++++--------------- js/app.js | 204 +++++++++++++++++++++++++++++------- js/modefilter.js | 145 +++++++++++++------------ styles.css | 261 ++++++++++++++++++++++++++++++++++++++------- 5 files changed, 806 insertions(+), 229 deletions(-) create mode 100644 CHANGELOG.md diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..b3efb7e --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,267 @@ +# Changelog – ÖPNV Navi + +Alle Änderungen aus den letzten Entwicklungs-Runden, chronologisch. Format +angelehnt an [Keep a Changelog](https://keepachangelog.com/de/). + +## Fahrtalarm, Störungs-Push & Live-Karte (Grundlagen) + +### Fixed +- **Live-Karte**: Fahrzeuginfo im Isolations-Modus aktualisierte sich nach + dem ersten Klick nie wieder (Marker wurde bei jedem Poll komplett neu + erzeugt statt aktualisiert, wodurch ein offenes Popup sofort wieder + schloss). Gleicher Fix auch für den Trip-Tracking-Modus. + +### Added +- **Störungs-Push für Favoriten**: Favoriten-Karten zeigen jetzt Störungen an, + pollen im Hintergrund alle 30s, Toast + optionale Browser-Notification bei + neuen Störungen. +- **Fahrtalarm**: weckt X Stationen vor einer gewählten Zielhaltestelle + (Ton, Vibration, Vollbild-Overlay, persistenter Hinweis-Banner). Setzbar + direkt im Fahrt-Detail-Modal an jeder kommenden Haltestelle. + +## Layout & Übersichtlichkeit + +### Changed +- **Live-Karte**: nutzt jetzt auf jeder Displaygröße die volle Breite + ("Full-Bleed"-Technik, mobile-first, ohne breakpoint-spezifische Werte). +- **Favoriten**: aus dem Abfahrten-Home-Screen entfernt (Desktop wie Mobile + wurden unübersichtlich), jetzt eigenes Tab-Panel – erreichbar über "Mehr" + und einen Icon-Button im Header. + +### Added +- **Störungen unterhalb der Abfahrten**: `/departures` und `/db/departures` + liefern jetzt zusätzlich `disruptions[]`, im Frontend direkt unter der + Abfahrtstafel sichtbar. + +### Fixed +- Störungstexte mit eingebetteten Links (z. B. `[MEHR/MORE]` von VBB) wurden + komplett als Text escaped statt den Link klickbar zu lassen. Jetzt werden + gezielt nur ``-Muster als sichere Links erlaubt, + alles andere bleibt escaped. + +## Verlauf, Gleiswechsel, Umstiegszeit, Barrierefreiheit, Teilen, Sprache, Statistik + +### Added +- **Verlauf**: zuletzt gesuchte Haltestellen erscheinen jetzt direkt als + Vorschläge beim Antippen des leeren Suchfelds; einzelne Einträge einzeln + entfernbar. +- **Gleiswechsel-Hinweis**: Abfahrtstafel hebt geänderte Gleise deutlich + hervor ("GLEIS 3 statt 5") statt den neuen Wert kommentarlos anzuzeigen. +- **Umstiegszeit-Puffer**: konfigurierbare Mindest-Umstiegszeit für die + OTP2-Routenplanung (Standard/0/3/5/10/15 Min). +- **Barrierefreiheit**: Toggle "Nur rollstuhlgerechte Verbindungen" im + Routenplaner (OTP2-Wheelchair-Preference); Aufzugsstatus wird getrennt von + echten Störungen angezeigt (eigener, ruhigerer Ton). +- **Route teilen**: Link zu einer Suche (nicht einer eingefrorenen + Verbindung) über die Web-Share-API oder Zwischenablage. +- **Mehrsprachigkeit**: Deutsch/Englisch, automatische Spracherkennung, + Umschalter unter "Mehr". +- **Verspätungsstatistik pro Linie**: passiv gesammelt aus ganz normalen + Abfahrten-Abfragen (keine zusätzliche Last auf HAFAS/IRIS), sichtbar im + Fahrt-Detail-Modal ab 10 gesammelten Datenpunkten. + +### Fixed +- Gleisänderungs-Störungsmeldungen von HAFAS waren teils inhaltsleer + ("Gleisänderung" ohne weitere Angaben). Werden jetzt durch eine aus den + echten Ist-Daten gebaute, präzise Meldung ersetzt (Linie, Ziel, Uhrzeit, + altes/neues Gleis). +- Mobile Bottom-Navigation wurde von der (jetzt display-breiten) Live-Karte + überdeckt (z-index-Konflikt mit Leaflets eigenen Steuerelementen) – behoben. +- Fahrtalarm-Banner konnte sich mit dem Header überlappen (unterschiedliche + Header-Höhen auf Mobile/Desktop) – Position wird jetzt zur Laufzeit anhand + der tatsächlichen Header-Höhe berechnet, nicht mehr geraten. + +## Push-Benachrichtigungen & Hintergrundaktualisierung + +### Added +- **Web-Push**: funktioniert jetzt auch bei geschlossenem Tab/Browser. + Neuer Service Worker, VAPID-basierte Subscriptions, serverseitige + Hintergrund-Jobs (Störungs-Check alle 60s, Fahrtalarm-Check alle 20s). + Favoriten bleiben primär im `localStorage`; bei aktiviertem Push wird eine + Kopie ans Backend gespiegelt, damit der Server weiß, wonach er schauen soll. +- Neue Backend-Tabellen `push_subscriptions`, `push_favorite_stations`, + `push_trip_alarms`; neue Route `/webpush/*`. + +## Barrierefreiheit (gründlicher Durchgang) + +### Fixed +- **Suchergebnisse waren nur mit der Maus bedienbar** – größter gefundener + Bug: Tastatur-/Screenreader-Nutzer konnten keine Haltestelle auswählen. + Jetzt per Pfeiltasten/Enter/Escape bedienbar (Hauptsuche + beide + Routenplaner-Felder). +- Abfahrtstafel-Flip-Animation zeigte Screenreadern Zeichensalat während des + Umklappens – jetzt unsichtbare, sofort korrekt lesbare Textalternative. +- Kaputte `role="table"` (ohne Zeilen-/Zellen-Rollen) durch korrektes + `role="list"` ersetzt. +- `aria-pressed` wurde bei "Jetzt/Abfahrt um/Ankunft um" und dem + Sprachumschalter nie aktualisiert (nur die Optik änderte sich) – behoben. +- Fahrtalarm-Banner war keine Live-Region – Änderungen wurden nicht + automatisch vorgelesen. +- Glocken-Buttons im Fahrt-Detail-Modal hatten nur ein `title`-Attribut als + Label (funktioniert auf Touch nicht, uneinheitlich bei Screenreadern) – + jetzt echtes `aria-label`. + +### Added +- Skip-Link ("Zum Inhalt springen"). +- Echte ARIA-Tab-Semantik (`tablist`/`tab`/`tabpanel`, `aria-selected`, + Pfeiltasten-Navigation) für Desktop- und Mobile-Navigation. +- Fokus-Falle + Fokus-Rückgabe für Fahrt-Detail-Modal und Fahrtalarm-Overlay. +- Formular-Labels für Start/Ziel-Felder im Routenplaner. +- `aria-live`-Regionen für Störungen, Aufzugsstatus, Routenergebnisse. +- Dekorative Emojis systematisch mit `aria-hidden` versehen. +- Farbkontrast geprüft (gedämpfter Text: 5.8–6.6:1, deutlich über WCAG-AA-Minimum). + +## Allgemeine Verbesserungen + +### Fixed +- "In der Nähe"-Vorschau blieb nach Auswahl einer Haltestelle über der + Abfahrtstafel stehen. +- Beim Wechsel zwischen Haltestellen waren kurz noch die Abfahrten der + vorherigen Station sichtbar (Stale-Data-Flash) – jetzt sofortiger + Ladezustand mit Spinner. +- Wettlauf-Bedingung bei schnellem Tippen in der Suche: eine ältere, aber + langsamer beantwortete Anfrage konnte neuere Ergebnisse überschreiben. + +### Added +- "Erneut versuchen"-Buttons bei Netzwerkfehlern (Abfahrtstafel, Routenplaner). +- Browser-Tab-Titel zeigt die gewählte Haltestelle. +- Toast-Bestätigung beim Favorisieren/Entfavorisieren; neue "Erfolg"-Toast-Variante. +- "Störungsbenachrichtigungen aktivieren" ist jetzt eine eigenständige, + farblich zustandsabhängige Karte statt eines unauffälligen Text-Links. + +## Visuelles Redesign + +### Changed +- Zentrale Design-Tokens für Schatten (`--shadow-sm/md/lg`), Radien und + Glow-Effekte eingeführt statt verstreuter Einzelwerte. +- Hintergrund: mehrschichtige Glow-Gradients, feines Scanline-Muster, + dezente Film-Korn-Textur (SVG-Turbulenz) für einen analogeren statt + sterilen Look. +- Header: Blur/Transparenz-Effekt (Frosted Glass) statt Vollton. +- Abfahrtstafel-Zeilen: farbiger Rand links in der jeweiligen Linienfarbe, + Verlaufs-Hintergrund, Tiefen-Schatten; Split-Flap-Zeichen haben jetzt eine + sichtbare Naht in der Mitte (wie eine echte mechanische Klapp-Anzeige). +- Linienbadges strahlen in ihrer eigenen Farbe (Neon-Effekt), Uhrzeit mit + grünem LED-Glühen. +- Logo und aktiver Tab pulsieren dezent in Amber; LIVE-Statuspunkt mit + echtem Leucht-Ring-Puls. +- Karten (Favoriten, "Mehr"-Kacheln, Aktionen) heben sich beim Hover an + (Schatten + Lift statt nur Farbwechsel), gestaffelte Eintritts-Animation + beim (Neu-)Aufbau von Listen. +- Button-Übergänge mit leichter Feder/Überschwung-Kurve statt linear. +- Alle neuen Animationen respektieren `prefers-reduced-motion` (bereits + global vorhandene Regel greift automatisch). + +--- + +*Nicht umgesetzt / bewusst zurückgestellt:* GTFS-RT-Live-Anbindung für +Fahrzeugpositionen/Störungsmeldungen sowie GTFS-`routes.txt` als +autoritative Quelle für Linienfarben (aktuell: `vbb-line-colors`-Paket + +Fallback-Heuristik nach Verkehrsmittel). + +## Repo-Aufräumung (Server-seitig) + +Auf dem Produktivserver hatten sich zwei parallele Code-Bäume angesammelt: +eine alte, seit Ende August unangetastete Root-Ebene (`routes/`, `utils/`, +`db/`, `middleware/`, `server.js` direkt im Projektordner) und der tatsächlich +laufende, aktuelle Code unter `src/` (per `package.json` als Einstiegspunkt +konfiguriert). Zusätzlich gab es doppelte `fares.js`-Dateien und eine +verwaiste Editor-Backup-Datei (`disruptionFilter.js+`). + +### Changed +- Aktueller Code aus `src/` eine Ebene hoch ins Projekt-Root verschoben, + `src/` entfernt – keine verschachtelte Struktur mehr. +- Datenordner von `data/` zu `db-data/` umbenannt (eindeutiger Name, da + `db/` bereits für Code – `database.js` – reserviert ist), `DB_PATH` in + `.env` entsprechend angepasst. +- `package.json` (`main`/`start`/`dev`) und der echte systemd-Service + (`ExecStart`) zeigen jetzt auf `server.js` statt `src/server.js`. +- `.env.example` und `oepnv-navi-backend.service.example` aktualisiert + (fehlende `OTP_BASE_URL`/`VAPID_*`-Platzhalter ergänzt, `DB_PATH` + korrigiert, `ExecStart`-Pfad korrigiert). +- **Auslieferungsstruktur ab jetzt**: neue Code-Übergaben verwenden dieselbe + flache Struktur (kein `src/`-Präfix mehr), um erneute Divergenz zwischen + gelieferten und tatsächlich laufenden Dateien zu vermeiden. + +### Removed +- Toter Root-Ebene-Code (`routes/`, `utils/`, `db/`, `middleware/`, + `server.js` von vor der gemeinsamen Entwicklung), doppelte `fares.js` im + alten `data/`-Ordner, verwaister `src/data/data/`-Unterordner, + `.env.old`, `disruptionFilter.js+` (veraltete Editor-Backup-Datei ohne die + Gleisänderungs-Anreicherung). + +## Backend-Härtung & letzte Politur + +### Added +- **Sauberes Herunterfahren** (`SIGTERM`/`SIGINT`): schließt den HTTP-Server + und die SQLite-Verbindung geordnet, statt bei jedem Neustart/Deploy hart + abgewürgt zu werden (relevant v. a. bei systemd-Neustarts). Erzwingt nach + 10s Timeout einen harten Abbruch, falls hängende Requests das saubere + Beenden blockieren. +- Größenlimit für JSON-Request-Bodies (200kb) als einfacher Missbrauchsschutz. + +### Changed +- Verkehrsmittel-Filter-Chips (S/U/Tram/Bus/…) haben jetzt ausgeschriebene + `aria-label` ("S-Bahn", "Regionalverkehr (RE/RB)" usw.) statt nur der + kryptischen Kurz-Beschriftung für Screenreader-Nutzer. + +## Visuelles Feintuning nach Nutzer-Feedback + +### Changed +- Die Redesign-Effekte aus der "übertreiben"-Runde (Scanlines, Film-Korn- + Textur, dauerhaft pulsierendes Logo/LIVE-Punkt, wandernder Lichtschein auf + der Willkommens-Karte, Neon-Überglühen der Linienbadges, LED-Leuchten der + Uhrzeit, federnde Button-Übergänge) auf Wunsch wieder zurückgenommen - + zurück zu einem ruhigeren, professionelleren Look. Struktur-Substanz + (Schatten-Skala, Glass-Header, Karten-Hover, farbiger Linien-Rand pro + Abfahrtszeile) blieb erhalten. +- Naht-Linie in der Mitte jedes Split-Flap-Zeichens (rein kosmetisches Detail + aus derselben Runde) auf Wunsch wieder entfernt. +- Toast-Benachrichtigungen überarbeitet: Icon-Kreis pro Typ (✓/⚠/ℹ), + Schließen-Button, dezente Fortschrittsleiste synchron zur Anzeigedauer. +- Dieselbe Icon-Kreis-Formsprache auf Störungen, Aufzugsstatus und beide + Störungslisten ("In deiner Nähe", netzweit) übertragen - einheitliche + `.notice-item`-Struktur statt vier verschiedener Ad-hoc-Layouts. + +### Fixed +- **Lange Zugbezeichnungen** (z.B. "ICE 940") sprengten ihre feste + Spaltenbreite und liefen optisch in die Ziel-Spalte über. Erst testweise + auf variable Spaltenbreite umgestellt, dann auf Nutzerwunsch wieder auf + feste Breite zurückgesetzt (Trennlinien sollen über alle Zeilen fluchten) - + stattdessen läuft der Text jetzt bei Bedarf automatisch wie ein Ticker + durch seine Box (kein manuelles Scrollen nötig), inkl. mehrerer + Nachbesserungen: falsche Lauflänge durch nicht mitgerechnetes Padding + behoben (letztes Zeichen war nie sichtbar), Ausblende-Verlauf entfernt + (verschluckte genau das Textende), Wartezeit am Anfang entfernt, Tempo + spürbar verlangsamt, kein Neustart der Animation mehr bei jedem 30s-Poll. +- Zweite Trennlinie zwischen Ziel und Abfahrtszeit ergänzt (gab es bisher nur + zwischen Linie und Ziel). + +## Backend: weitere Störungs-Filter-Verbesserung + +### Fixed +- Generische HAFAS-Meldung ("Abfahrt heute von Gleis X") wird jetzt ebenso + herausgefiltert wie die bereits vorher gefilterte generische + "Gleisänderung"-Meldung - beide sind durch die präzise, aus Ist-Daten + gebaute Gleisänderungs-Meldung (Linie/Ziel/Uhrzeit/altes+neues Gleis) + vollständig abgedeckt und wären sonst redundant doppelt angezeigt worden. + +## Autonomer Start am aktuellen Standort + +### Added +- **App startet jetzt immer am aktuellen Standort** statt an der zuletzt + besuchten Haltestelle: beim Öffnen wird automatisch der Standort abgefragt, + die nächstgelegene Haltestelle ausgewählt und ihre Abfahrten direkt gezeigt + - ohne Klick. Weitere nahe Haltestellen erscheinen zusätzlich als Vorschau. + Schlägt das fehl (Berechtigung verweigert, kein Standort, keine Treffer), + fällt die App sauber auf die zuletzt besuchte Haltestelle zurück. +- **Störungen "In deiner Nähe"** laden jetzt automatisch beim ersten Öffnen + des Störungen-Tabs, statt einen Klick auf "Standort verwenden" zu + erfordern - nutzt dafür den beim Start bereits ermittelten Standort weiter. + Störungsprüfung deckt damit durchgängig sowohl Favoriten (bestehendes + Störungs-Push-Feature) als auch den aktuellen Standort ab. + +### Removed +- Tote, nie aufgerufene Funktion `loadNearbyPreview()` - durch die neue, + vollständige Autostart-Logik ersetzt. + diff --git a/README.md b/README.md index fd44858..ceec1db 100644 --- a/README.md +++ b/README.md @@ -1,97 +1,87 @@ -# ÖPNV Navi – Backend +# ÖPNV Navi – Frontend -Backend für die geplante "ÖPNV Navi" iOS-App (Nachfolger der VBB-Status-Web-App). -Stellt VBB-Echtzeitdaten (Abfahrten, Routenplaner, Live-Map) sowie eigene Features -(Favoriten, Push-Token-Registrierung) über eine REST-API bereit. +Vanilla-JS-Webapp für Live-Abfahrten, Routenplanung und eine Live-Karte im +VBB-Verbund (Berlin/Brandenburg) mit Anbindung an echte DB-Bahnhöfe. Kein +Build-Schritt, kein Framework – reines HTML/CSS/JS, läuft direkt als +statische Website. -## 1. Installation +## Tech-Stack -```bash -cd oepnv-navi-backend -npm install -cp .env.example .env +- Reines HTML5, CSS3, ES2020+ JavaScript (keine Frameworks, kein Bundler) +- [Leaflet](https://leafletjs.com/) für die Live-Karte (per CDN eingebunden) +- Google Fonts: IBM Plex Mono / Sans / Sans Condensed +- Service Worker für Web-Push-Benachrichtigungen + +## Ordnerstruktur + +``` +frontend/ +├── index.html Einzige HTML-Seite (Single Page App über Tabs) +├── styles.css Komplettes Stylesheet ("Solari-Anzeigetafel bei Nacht"-Theme) +├── sw.js Service Worker (Web-Push) - MUSS im Root liegen, nicht unter js/ +├── manifest.json PWA-Manifest +└── js/ + ├── config.js Zentrale Konfiguration (API_BASE-URL des Backends) + ├── api.js Schlanker fetch()-Wrapper für alle Backend-Endpunkte + ├── i18n.js Mehrsprachigkeit (DE/EN), Wörterbuch + Sprachumschaltung + ├── app.js Hauptlogik: Tabs, Suche, Abfahrtstafel, Favoriten, Routenplaner + ├── splitflap.js Flip-Animation der Abfahrtstafel (+ Screenreader-Textalternative) + ├── modefilter.js Wiederverwendbare Verkehrsmittel-Filter-Chips + ├── livemap.js Live-Karte (Leaflet): Radar-Modus, Trip-Tracking, Isolations-Modus + ├── tripmodal.js Fahrt-Detail-Modal (Zwischenhalte, Mini-Karte, Verspätungsstatistik) + ├── tripalarm.js Fahrtalarm ("wecke mich X Stationen vorher") + └── push.js Web-Push: Service-Worker-Registrierung, Abo-Verwaltung ``` -Öffne `.env` und passe mindestens `API_KEY` an (z.B. mit `openssl rand -hex 32` erzeugen). +## Einrichtung / Deployment -## 2. Lokal starten +Keine Installation nötig – es sind statische Dateien. Einfach den kompletten +Ordnerinhalt auf einen Webserver (nginx, Apache, Vercel, o.ä.) legen. -```bash -npm start -``` +**Wichtig:** +- `sw.js` muss im **Root** des ausgelieferten Verzeichnisses liegen (dort, wo + auch `index.html` liegt), nicht unter `js/`. Der Geltungsbereich eines + Service Workers ist standardmäßig auf sein eigenes Verzeichnis und alles + darunter beschränkt. +- Web Push (und Service Worker generell) funktionieren nur über **HTTPS** + (oder `localhost` während der Entwicklung). +- In `js/config.js` die Backend-URL eintragen: + ```js + window.APP_CONFIG = { + API_BASE: 'https://dein-server.example/live_navi', + }; + ``` -Test: +## Features im Überblick -```bash -curl http://localhost:3000/health -curl "http://localhost:3000/live_navi/stations/search?query=Alexanderplatz" -``` +- **Abfahrten**: Live-Abfahrtstafel (VBB + echte DB-Bahnhöfe), Verkehrsmittel-Filter, + Gleiswechsel-Hinweis, Störungen & Aufzugsstatus direkt unter der Tafel +- **Route**: Routenplanung über eine selbst gehostete OTP2-Instanz, konfigurierbarer + Umstiegszeit-Puffer, rollstuhlgerechte Verbindungen, Route teilen (Link) +- **Karte**: Live-Fahrzeugpositionen, Fahrt isolieren/verfolgen, display-breite + Darstellung auf jeder Bildschirmgröße +- **Favoriten**: eigener Tab, Live-Vorschau der nächsten Abfahrt, Störungs-Badges +- **Fahrtalarm**: weckt X Stationen vor dem Ziel (Ton, Vibration, Vollbild-Overlay) +- **Push-Benachrichtigungen**: funktionieren auch bei geschlossenem Tab/Browser + (Service Worker + Web Push, siehe `js/push.js`) +- **Verlauf**: zuletzt gesuchte Haltestellen, direkt als Vorschläge im Suchfeld +- **Mehrsprachigkeit**: Deutsch/Englisch, automatische Browsersprache-Erkennung +- **Barrierefreiheit**: Tastaturbedienung überall (inkl. Suche), ARIA-Tab-Pattern, + Fokus-Fallen in Dialogen, Screenreader-Textalternative zur Flip-Animation, + `aria-live`-Regionen für Störungen/Ergebnisse -## 3. Als Dienst laufen lassen (systemd) +## Browser-Unterstützung -1. Projekt auf den Server kopieren, z.B. nach `/home/pi/oepnv-navi-backend` -2. `oepnv-navi-backend.service.example` nach `/etc/systemd/system/oepnv-navi-backend.service` - kopieren und die Platzhalter (`DEIN_USER`, Pfade) anpassen -3. Aktivieren: +Moderne Evergreen-Browser (Chrome, Firefox, Safari, Edge). Nutzt u. a. +`fetch`, `IntersectionObserver`-freies Vanilla-DOM, Service Worker, Push API, +CSS Custom Properties und `dvh`-Einheiten (mit `vh`-Fallback). Kein IE11-Support. -```bash -sudo systemctl daemon-reload -sudo systemctl enable --now oepnv-navi-backend -sudo systemctl status oepnv-navi-backend -``` +## Bekannte Grenzen -## 4. Einrichtung in Nginx Proxy Manager (NPM) - -Da Backend-Server und NPM auf unterschiedlichen Hosts im selben LAN laufen, brauchst du -die **lokale IP** des Backend-Servers (z.B. `192.168.1.50`) und den Port aus `.env` (Standard `3000`). - -**Falls `serverlele.ddns.net` schon einen Proxy Host für deine Web-App hat:** - -1. In NPM → *Proxy Hosts* → bestehenden Eintrag für `serverlele.ddns.net` öffnen -2. Tab *Custom Locations* → *Add location* - - **Location**: `/live_navi` - - **Scheme**: `http` - - **Forward Hostname / IP**: die LAN-IP deines Backend-Servers, z.B. `192.168.1.50` - - **Forward Port**: `3000` -3. Speichern - -**Falls es noch keinen Proxy Host für die Domain gibt:** - -1. *Proxy Hosts* → *Add Proxy Host* -2. Domain: `serverlele.ddns.net` -3. Scheme/Forward: kann auf deine bestehende Web-App zeigen (Root `/`) -4. SSL-Tab: Let's Encrypt Zertifikat aktivieren (Force SSL empfehlenswert) -5. Danach wie oben die Custom Location `/live_navi` hinzufügen - -Wichtig: Das Backend selbst lauscht (via `BASE_PATH` in `.env`) bereits unter `/live_navi/...`, -weil NPM den vollen Pfad inkl. Präfix an das Backend weiterreicht. Du musst also **keine** -zusätzliche Rewrite-Regel in NPM konfigurieren – einfach die Custom Location wie oben anlegen. - -Test von außen, sobald DNS/Portfreigabe für `serverlele.ddns.net` stehen: - -```bash -curl https://serverlele.ddns.net/live_navi/health -``` - -## 5. API-Übersicht - -| Methode | Pfad | Auth | Beschreibung | -|---------|------------------------------------|-----------|------------------------------------------------| -| GET | `/live_navi/health` | – | Health-Check | -| GET | `/live_navi/stations/search` | – | Haltestellensuche (`?query=`) | -| GET | `/live_navi/stations/:id` | – | Details einer Haltestelle | -| GET | `/live_navi/departures/:stationId`| – | Live-Abfahrten (`?duration=&results=`) | -| GET | `/live_navi/journeys` | – | Routenplaner (`?from=&to=&when=`) | -| GET | `/live_navi/radar` | – | Live-Fahrzeugpositionen (`?north=&west=&south=&east=`) | -| GET | `/live_navi/favorites` | – | Liste der Favoriten | -| POST | `/live_navi/favorites` | API-Key | Favorit anlegen | -| DELETE | `/live_navi/favorites/:id` | API-Key | Favorit löschen | -| POST | `/live_navi/push/register` | API-Key | Push-Token registrieren | - -Für geschützte Endpunkte den Header `X-API-Key: ` mitschicken. - -## 6. Nächste Schritte - -- Rate-Limit-Werte in `src/server.js` an echten Bedarf anpassen -- Später: Push-Notifications (APNs) auf Basis der `push_tokens`-Tabelle bauen -- Später: iOS-App (Swift/SwiftUI) als Client gegen dieses Backend entwickeln +- Die Live-Karte ist für blinde Nutzer naturgemäß eingeschränkt zugänglich + (Leaflet-Karten sind visuell) – Abfahrtstafel und Routenplaner liefern + dieselben Informationen barrierefrei. +- Störungstexte kommen direkt von VBB/HAFAS auf Deutsch – eine Übersetzung + ins Englische findet nicht statt (nur die App-Oberfläche selbst ist zweisprachig). +- Fahrtalarm-Ton und Vollbild-Overlay funktionieren nur bei offenem Tab; bei + geschlossenem Browser zeigt ein Push nur die Systembenachrichtigung. diff --git a/js/app.js b/js/app.js index 20bb1ac..dc9f36e 100644 --- a/js/app.js +++ b/js/app.js @@ -299,17 +299,43 @@ const App = (() => { const container = document.getElementById('toast-container'); if (!container) return; + const ICONS = { success: '✓', warning: '⚠', info: 'ℹ' }; + const toast = document.createElement('div'); toast.className = `toast toast--${variant}`; - toast.textContent = message; + toast.innerHTML = ` + + + + + `; + toast.querySelector('.toast__message').textContent = message; container.appendChild(toast); - requestAnimationFrame(() => toast.classList.add('is-visible')); + const AUTO_DISMISS_MS = 6000; + let dismissTimer = null; - setTimeout(() => { + function dismiss() { + clearTimeout(dismissTimer); toast.classList.remove('is-visible'); - setTimeout(() => toast.remove(), 300); - }, 6000); + setTimeout(() => toast.remove(), 250); + } + + toast.querySelector('.toast__close').addEventListener('click', dismiss); + + requestAnimationFrame(() => { + toast.classList.add('is-visible'); + // Countdown-Leiste per CSS-Transition auf 0 fahren, synchron zur + // tatsächlichen Anzeigedauer - zeigt beiläufig, wie viel Zeit bleibt, + // ohne dass man aktiv draufschauen muss. + const progress = toast.querySelector('.toast__progress'); + if (progress) { + progress.style.transitionDuration = `${AUTO_DISMISS_MS}ms`; + requestAnimationFrame(() => { progress.style.transform = 'scaleX(0)'; }); + } + }); + + dismissTimer = setTimeout(dismiss, AUTO_DISMISS_MS); } // ---------- Browser-Benachrichtigungen an/aus ---------- @@ -377,6 +403,8 @@ const App = (() => { btn.classList.toggle('is-active', active); } + let lastKnownLocation = null; // { lat, lon } - einmal ermittelt, für Autostart & Störungen wiederverwendet + function getUserLocation() { return new Promise((resolve, reject) => { if (!navigator.geolocation) { @@ -384,7 +412,10 @@ const App = (() => { return; } navigator.geolocation.getCurrentPosition( - (pos) => resolve({ lat: pos.coords.latitude, lon: pos.coords.longitude }), + (pos) => { + lastKnownLocation = { lat: pos.coords.latitude, lon: pos.coords.longitude }; + resolve(lastKnownLocation); + }, (err) => reject(new Error('Standort konnte nicht ermittelt werden: ' + err.message)), { enableHighAccuracy: true, timeout: 10000 }, ); @@ -658,7 +689,15 @@ const App = (() => { } else { elevatorEl.hidden = false; elevatorEl.innerHTML = elevatorItems - .map((d) => `
♿ ${escapeHtml(d.summary || 'Aufzugsstatus')}${d.text ? `
${linkifyRemarkText(d.text)}` : ''}
`) + .map((d) => ` +
+ +
+ ${escapeHtml(d.summary || 'Aufzugsstatus')} + ${d.text ? `${linkifyRemarkText(d.text)}` : ''} +
+
+ `) .join(''); } } @@ -670,7 +709,15 @@ const App = (() => { } else { disruptionEl.hidden = false; disruptionEl.innerHTML = otherItems - .map((d) => `
⚠ ${escapeHtml(d.summary || 'Störung')}${d.text ? `
${linkifyRemarkText(d.text)}` : ''}
`) + .map((d) => ` +
+ +
+ ${escapeHtml(d.summary || 'Störung')} + ${d.text ? `${linkifyRemarkText(d.text)}` : ''} +
+
+ `) .join(''); } } @@ -778,6 +825,52 @@ const App = (() => { row.style.setProperty('--line-color', dep.color || '#8E99A6'); SplitFlap.render(lineCell, (dep.line || '?').toUpperCase()); + // Bei langen Zugbezeichnungen (z.B. "ICE 940") reicht die feste + // Spaltenbreite nicht - der Text läuft dann von selbst wie ein Ticker + // durch (CSS-Animation), kein manuelles Scrollen nötig. Hier nur messen, + // wie weit der Text übersteht, und Distanz/Tempo als CSS-Variablen setzen. + // requestAnimationFrame, damit die SplitFlap-Zeichen-Spans sicher schon + // im DOM stehen, bevor die Breite gemessen wird. + requestAnimationFrame(() => { + const visual = lineCell.querySelector('.flap-cell__visual'); + let overflowPx = 0; + if (visual) { + // Echte Geometrie statt clientWidth/scrollWidth-Schätzung: clientWidth + // schließt das eigene Padding der Badge mit ein, wodurch die zuvor + // berechnete Laufweite immer um genau das rechte Padding zu kurz war - + // das letzte Zeichen war dadurch nie vollständig sichtbar. Hier wird + // stattdessen direkt gemessen, wie weit der rechte Rand des Textes + // über den rechten Innenrand (Content-Box, ohne Padding) der Badge hinausragt. + const lineRect = lineCell.getBoundingClientRect(); + const visualRect = visual.getBoundingClientRect(); + const paddingRight = parseFloat(getComputedStyle(lineCell).paddingRight) || 0; + const contentBoxRight = lineRect.right - paddingRight; + overflowPx = Math.max(0, visualRect.right - contentBoxRight); + } + const isScrollable = overflowPx > 1; + // Nicht bei jedem 30s-Poll unangetastet neu antriggern, wenn sich an der + // Überstands-Länge nichts geändert hat - sonst startet die Animation + // immer wieder von vorn und wirkt dadurch unruhiger/hektischer, als sie + // eigentlich ist. + const previousDistance = lineCell.dataset.marqueeOverflow; + const currentDistance = String(Math.round(overflowPx)); + if (previousDistance === currentDistance) return; + lineCell.dataset.marqueeOverflow = currentDistance; + + lineCell.classList.toggle('flap-cell--line--scrollable', isScrollable); + if (isScrollable) { + // +2px statt einer größeren Zugabe - die Distanz soll das letzte + // Zeichen exakt an den Innenrand bringen, nicht noch weiter drüber hinaus. + // Bewusst langsam und ruhig: 12px/s als Grundtempo, mindestens 4.5s pro + // Durchlauf, damit es wie ein gemächlicher Bahnhofstafel-Ticker wirkt + // statt hektisch hin- und herzuschnellen. + lineCell.style.setProperty('--marquee-distance', `-${overflowPx + 2}px`); + lineCell.style.setProperty('--marquee-duration', `${Math.max(4.5, overflowPx / 12).toFixed(1)}s`); + } else { + lineCell.style.removeProperty('--marquee-distance'); + lineCell.style.removeProperty('--marquee-duration'); + } + }); SplitFlap.render(row.querySelector('[data-field="direction"]'), (dep.direction || '').toUpperCase()); // Gleiswechsel deutlich hervorheben, statt die neue Gleisnummer einfach @@ -895,6 +988,10 @@ const App = (() => { if (tabName === 'disruptions' && !remarksLoadedOnce) { loadRemarks(); + // "In deiner Nähe" automatisch mitladen statt auf den manuellen + // "Standort verwenden"-Klick zu warten - Störungen sollen immer sowohl + // für die Favoriten als auch für den aktuellen Standort da sein. + loadNearbyDisruptions(); } if (tabName === 'fares' && !faresLoadedOnce) { @@ -935,19 +1032,8 @@ const App = (() => { document.getElementById('nearby-home').hidden = locations.length === 0; } - async function loadNearbyPreview() { - const home = document.getElementById('nearby-home'); - const list = document.getElementById('nearby-home-list'); - if (!navigator.geolocation || !home || !list) return; - list.innerHTML = '
Standort wird ermittelt…
'; - try { - const { lat, lon } = await getUserLocation(); - const { locations } = await API.nearby({ lat, lon, distance: 1000, results: 3 }); - renderNearbyPreview(locations); - } catch (_) { - home.hidden = true; - } - } + // (Autostart am Standort - inkl. Vorschau der übrigen nahen Haltestellen - + // übernimmt jetzt autonom autoStartFromLocation() beim App-Start.) function renderNearbyList(locations, container) { container.innerHTML = ''; @@ -975,18 +1061,26 @@ const App = (() => { function initNearbyDisruptions() { const btn = document.getElementById('disruptions-locate'); if (!btn) return; - btn.addEventListener('click', async () => { - const container = document.getElementById('nearby-disruptions-list'); - container.innerHTML = '
Standort wird ermittelt…
'; - try { - const { lat, lon } = await getUserLocation(); - container.innerHTML = '
Suche Störungen in der Nähe…
'; - const { disruptions } = await API.getNearbyDisruptions({ lat, lon, distance: 1000, stops: 6 }); - renderNearbyDisruptions(disruptions, container); - } catch (err) { - container.innerHTML = `
⚠ ${err.message}
`; - } - }); + btn.addEventListener('click', () => loadNearbyDisruptions()); + } + + // Lädt Störungen rund um den aktuellen Standort - wird sowohl vom manuellen + // "Standort verwenden"-Button als auch automatisch beim ersten Öffnen des + // Störungen-Tabs aufgerufen (siehe activateTab()), damit man dafür nicht + // erst extra klicken muss. Nutzt den beim Start bereits ermittelten + // Standort weiter, statt ihn ein zweites Mal abzufragen, falls vorhanden. + async function loadNearbyDisruptions() { + const container = document.getElementById('nearby-disruptions-list'); + if (!container) return; + container.innerHTML = '
Standort wird ermittelt…
'; + try { + const { lat, lon } = lastKnownLocation || (await getUserLocation()); + container.innerHTML = '
Suche Störungen in der Nähe…
'; + const { disruptions } = await API.getNearbyDisruptions({ lat, lon, distance: 1000, stops: 6 }); + renderNearbyDisruptions(disruptions, container); + } catch (err) { + container.innerHTML = `
⚠ ${err.message}
`; + } } function renderNearbyDisruptions(disruptions, container) { @@ -1001,9 +1095,14 @@ const App = (() => { const lines = d.affectedLines.length ? `Linie${d.affectedLines.length > 1 ? 'n' : ''} ${d.affectedLines.join(', ')}` : ''; const stops = d.affectedStops.length ? ` · nahe ${d.affectedStops.join(', ')}` : ''; card.innerHTML = ` -
⚠ ${escapeHtml(d.summary || 'Hinweis')}
-
${linkifyRemarkText(d.text || '')}
-
${escapeHtml(lines)}${escapeHtml(stops)}
+
+ +
+ ${escapeHtml(d.summary || 'Hinweis')} + ${linkifyRemarkText(d.text || '')} + ${escapeHtml(lines)}${escapeHtml(stops)} +
+
`; container.appendChild(card); }); @@ -1069,9 +1168,14 @@ const App = (() => { const card = document.createElement('div'); card.className = 'remark-card'; card.innerHTML = ` -
⚠ ${escapeHtml(r.summary || 'Hinweis')}
-
${linkifyRemarkText(r.text || '')}
-
${escapeHtml(r.company || '')}
+
+ +
+ ${escapeHtml(r.summary || 'Hinweis')} + ${linkifyRemarkText(r.text || '')} + ${escapeHtml(r.company || '')} +
+
`; container.appendChild(card); }); @@ -1445,6 +1549,26 @@ const App = (() => { } } + // Startet die App am aktuellen Standort statt an der zuletzt gewählten + // Haltestelle: sucht Haltestellen in der Nähe, wählt automatisch die + // nächstgelegene aus (Abfahrten erscheinen ohne weiteren Klick) und füllt + // nebenbei die "In der Nähe"-Vorschau mit den übrigen Treffern. Klappt das + // nicht (Berechtigung verweigert, kein Standort, keine Treffer in der Nähe), + // fällt die App auf die zuletzt besuchte Haltestelle zurück statt leer zu bleiben. + async function autoStartFromLocation() { + try { + const { lat, lon } = await getUserLocation(); + const { locations } = await API.nearby({ lat, lon, distance: 1000, results: 15 }); + if (!locations.length) throw new Error('Keine Haltestellen in der Nähe gefunden.'); + + selectStation(locations[0]); + renderNearbyPreview(locations.slice(1)); + } catch (err) { + console.error('Autostart am Standort nicht möglich, nutze letzte Haltestelle:', err.message); + restoreLastStop(); + } + } + function init() { I18N.init(); if (window.Push && Push.isSupported()) Push.registerServiceWorker(); @@ -1492,7 +1616,7 @@ const App = (() => { if (currentStop) toggleFavorite(currentStop); }); - restoreLastStop(); + autoStartFromLocation(); restoreJourneyFromShareLink(); } diff --git a/js/modefilter.js b/js/modefilter.js index 2e504bf..3522b11 100644 --- a/js/modefilter.js +++ b/js/modefilter.js @@ -1,66 +1,81 @@ -// Wiederverwendbare Verkehrsmittel-Filter-Chips. Wird sowohl für die -// Abfahrtstafel als auch für den Routenplaner genutzt (zwei unabhängige Instanzen). - -const ModeFilter = (() => { - const LABELS = { - suburban: 'S', - subway: 'U', - tram: 'Tram', - bus: 'Bus', - ferry: 'Fähre', - express: 'Fern', - regional: 'RE/RB', - }; - const ALL = Object.keys(LABELS); - - function create(container, storageKey, onChange) { - let selected = loadState(); - - function loadState() { - try { - const saved = JSON.parse(localStorage.getItem(storageKey)); - if (Array.isArray(saved) && saved.length) return new Set(saved); - } catch (_) { - // ignorieren, Standard verwenden - } - return new Set(ALL); - } - - function persist() { - localStorage.setItem(storageKey, JSON.stringify([...selected])); - } - - function render() { - container.innerHTML = ''; - ALL.forEach((mode) => { - const btn = document.createElement('button'); - btn.type = 'button'; - btn.className = 'mode-chip' + (selected.has(mode) ? ' is-active' : ''); - btn.textContent = LABELS[mode]; - btn.setAttribute('aria-pressed', selected.has(mode) ? 'true' : 'false'); - btn.addEventListener('click', () => { - if (selected.has(mode)) { - selected.delete(mode); - } else { - selected.add(mode); - } - if (selected.size === 0) selected = new Set(ALL); - persist(); - render(); - onChange(getModesParam()); - }); - container.appendChild(btn); - }); - } - - function getModesParam() { - if (selected.size === ALL.length) return null; - return [...selected].join(','); - } - - render(); - return { getModesParam }; - } - - return { create }; +// Wiederverwendbare Verkehrsmittel-Filter-Chips. Wird sowohl für die +// Abfahrtstafel als auch für den Routenplaner genutzt (zwei unabhängige Instanzen). + +const ModeFilter = (() => { + const LABELS = { + suburban: 'S', + subway: 'U', + tram: 'Tram', + bus: 'Bus', + ferry: 'Fähre', + express: 'Fern', + regional: 'RE/RB', + }; + // Ausgeschriebene Namen für aria-label - die kurzen Chip-Beschriftungen + // ("S", "U", "Fern") sind für Screenreader-Nutzer ohne visuellen Kontext + // zu kryptisch, um zuverlässig zu wissen, welches Verkehrsmittel gemeint ist. + const ARIA_LABELS = { + suburban: 'S-Bahn', + subway: 'U-Bahn', + tram: 'Tram', + bus: 'Bus', + ferry: 'Fähre', + express: 'Fernverkehr', + regional: 'Regionalverkehr (RE/RB)', + }; + const ALL = Object.keys(LABELS); + + function create(container, storageKey, onChange) { + let selected = loadState(); + + function loadState() { + try { + const saved = JSON.parse(localStorage.getItem(storageKey)); + if (Array.isArray(saved) && saved.length) return new Set(saved); + } catch (_) { + // ignorieren, Standard verwenden + } + return new Set(ALL); + } + + function persist() { + localStorage.setItem(storageKey, JSON.stringify([...selected])); + } + + function render() { + container.innerHTML = ''; + ALL.forEach((mode) => { + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'mode-chip' + (selected.has(mode) ? ' is-active' : ''); + btn.textContent = LABELS[mode]; + btn.setAttribute('aria-pressed', selected.has(mode) ? 'true' : 'false'); + // aria-pressed übernimmt schon die Zustandsansage ("gedrückt"/"nicht + // gedrückt") - hier nur der ausgeschriebene Name, keine Dopplung. + btn.setAttribute('aria-label', ARIA_LABELS[mode]); + btn.addEventListener('click', () => { + if (selected.has(mode)) { + selected.delete(mode); + } else { + selected.add(mode); + } + if (selected.size === 0) selected = new Set(ALL); + persist(); + render(); + onChange(getModesParam()); + }); + container.appendChild(btn); + }); + } + + function getModesParam() { + if (selected.size === ALL.length) return null; + return [...selected].join(','); + } + + render(); + return { getModesParam }; + } + + return { create }; })(); \ No newline at end of file diff --git a/styles.css b/styles.css index 67ca708..a00c0e2 100644 --- a/styles.css +++ b/styles.css @@ -7,10 +7,12 @@ --bg-void: #0C1116; --panel: #171E26; --panel-raised: #212A34; + --panel-overlay: #26303C; --line: #2A323D; --ink: #EDEFEC; --ink-muted: #8E99A6; --signal-amber: #F5A623; + --signal-amber-soft: rgba(245, 166, 35, 0.14); --signal-green: #3DDC84; --signal-red: #FF5D6C; --signal-blue: #4FA8E8; @@ -20,7 +22,13 @@ --font-label: 'IBM Plex Sans Condensed', sans-serif; --radius: 10px; + --radius-lg: 18px; --header-height: 64px; + + --shadow-sm: 0 2px 10px rgba(0, 0, 0, 0.22); + --shadow-md: 0 10px 26px rgba(0, 0, 0, 0.32); + --shadow-lg: 0 24px 60px rgba(0, 0, 0, 0.5); + --shadow-glow-amber: 0 0 0 1px rgba(245, 166, 35, 0.25), 0 8px 24px rgba(245, 166, 35, 0.12); } * { @@ -31,6 +39,10 @@ html, body { margin: 0; padding: 0; background: var(--bg-void); + background-image: + radial-gradient(ellipse 1000px 500px at 20% -8%, rgba(245, 166, 35, 0.05), transparent 60%), + radial-gradient(ellipse 800px 500px at 100% 0%, rgba(79, 168, 232, 0.04), transparent 55%); + background-attachment: fixed; color: var(--ink); font-family: var(--font-body); min-height: 100vh; @@ -100,8 +112,11 @@ button { align-items: center; gap: 20px; padding: 0 20px; - background: var(--panel); + background: rgba(23, 30, 38, 0.85); + backdrop-filter: blur(14px) saturate(1.4); + -webkit-backdrop-filter: blur(14px) saturate(1.4); border-bottom: 1px solid var(--line); + box-shadow: var(--shadow-sm), 0 1px 0 rgba(245, 166, 35, 0.15); position: sticky; top: 0; z-index: 20; @@ -237,8 +252,8 @@ button { .notify-callout__text small { display: block; color: var(--ink-muted); font-size: 12px; margin-top: 2px; } .notify-callout__btn { flex-shrink: 0; - background: var(--signal-amber); - color: #17110a; + background: linear-gradient(155deg, #ffc158, var(--signal-amber) 60%); + color: #1a1206; font-weight: 600; border: 0; border-radius: 999px; @@ -296,6 +311,7 @@ button { .tab-button.is-active { color: var(--signal-amber); border-bottom-color: var(--signal-amber); + box-shadow: 0 2px 12px -2px rgba(245, 166, 35, 0.6); } .app-main { @@ -391,14 +407,22 @@ button { grid-template-columns: 82px minmax(0, 1fr) 82px 112px; gap: 18px; align-items: center; - background: var(--panel); + background: linear-gradient(180deg, var(--panel-overlay), var(--panel) 60%); border: 1px solid var(--line); border-left: 4px solid var(--line-color, var(--line)); border-radius: var(--radius); padding: 14px 16px 14px 13px; font-family: var(--font-display); + box-shadow: var(--shadow-sm), inset 0 1px 0 rgba(255, 255, 255, 0.03); animation: flap-row-in .25s ease both; animation-delay: calc(var(--row-index, 0) * 25ms); + transition: box-shadow .15s ease, transform .15s ease; +} + +.flap-row--clickable:hover, +.flap-row--clickable:focus-visible { + box-shadow: var(--shadow-md), inset 0 1px 0 rgba(255, 255, 255, 0.05); + transform: translateY(-1px); } @keyframes flap-row-in { @@ -454,7 +478,8 @@ button { } .flap-cell--line { - width: max-content; + display: block; + max-width: 100%; min-width: 38px; font-weight: 700; color: var(--line-text-color, var(--signal-amber)); @@ -462,6 +487,33 @@ button { padding: 5px 9px; border-radius: 4px; font-size: 16px; + box-shadow: 0 2px 6px rgba(0, 0, 0, 0.35), inset 0 1px 0 rgba(255, 255, 255, 0.15); + /* Lange Zugbezeichnungen (z.B. "ICE 940") sprengen die feste Spaltenbreite + nicht mehr - stattdessen läuft der Text von selbst wie ein Ticker durch + (siehe .flap-cell--line--scrollable unten), kein manuelles Scrollen nötig. */ + overflow: hidden; +} + +/* Kein Ausblende-Verlauf mehr an den Rändern - der hat genau den Teil des + Textes verschluckt, den man eigentlich lesen wollte (das Ende des Namens), + sobald der Ticker dort ankam. Die Box schneidet über overflow:hidden + trotzdem sauber ab, ganz ohne Fade. */ + +/* Der eigentliche Ticker-Effekt: läuft sofort los (keine Wartezeit am + Anfang), hält kurz am Ende an - genau dort, wo der abgeschnittene Teil des + Namens sichtbar wird und man ihn lesen will - und schiebt sich nahtlos + zurück in die nächste Runde. Distanz/Geschwindigkeit kommen als + CSS-Variablen aus app.js, abhängig davon wie weit der Text übersteht. */ +.flap-cell--line--scrollable .flap-cell__visual { + display: inline-block; + animation: flap-line-marquee var(--marquee-duration, 4s) ease-in-out infinite; +} + +@keyframes flap-line-marquee { + 0% { transform: translateX(0); } + 35% { transform: translateX(var(--marquee-distance, 0px)); } + 70% { transform: translateX(var(--marquee-distance, 0px)); } + 100% { transform: translateX(0); } } .flap-cell--direction { @@ -556,6 +608,8 @@ button { .flap-field--time { align-items: flex-end; + padding-left: 14px; + border-left: 1px solid var(--line); } .flap-char { @@ -596,6 +650,7 @@ button { left: 50%; width: 100vw; margin-left: -50vw; + box-shadow: inset 0 12px 24px -12px rgba(0,0,0,.6), inset 0 -12px 24px -12px rgba(0,0,0,.6); } .vehicle-marker span { @@ -637,14 +692,16 @@ button { } .journey-submit { - background: var(--signal-amber); - color: var(--bg-void); + background: linear-gradient(155deg, #ffc158, var(--signal-amber) 60%); + color: #1a1206; border: none; border-radius: var(--radius); padding: 10px 18px; font-weight: 600; font-size: 14px; + box-shadow: var(--shadow-glow-amber); } +.journey-submit:hover { filter: brightness(1.05); } .journey-card { background: var(--panel); @@ -719,7 +776,8 @@ button { .mode-chip.is-active { color: var(--signal-amber); border-color: var(--signal-amber); - background: rgba(245, 166, 35, 0.1); + background: var(--signal-amber-soft); + box-shadow: 0 0 0 1px rgba(245, 166, 35, 0.15); } /* ============================================================ @@ -800,32 +858,67 @@ button { padding: 14px; } -.remark-card__summary { - font-weight: 600; +/* Einheitliche "Meldungs"-Optik: Icon-Kreis + Textblock, dieselbe + Formsprache wie die Toast-Benachrichtigungen. Wird für Störungen + (rot), Aufzugsstatus (blau) und die Störungslisten genutzt - die + jeweilige Icon-Farbe kommt vom umgebenden Container (.station-disruption, + .elevator-status, .remark-card). */ +.notice-item { + display: flex; + align-items: flex-start; + gap: 10px; +} + +.notice-item__icon { + flex-shrink: 0; + width: 22px; + height: 22px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 12px; + font-weight: 700; + margin-top: 1px; + background: rgba(255, 93, 108, 0.14); color: var(--signal-red); - margin-bottom: 6px; - font-size: 14px; } -.remark-card__text { - color: var(--ink); +.notice-item__body { + flex: 1; + min-width: 0; + display: flex; + flex-direction: column; + gap: 4px; +} + +.notice-item__body strong { font-size: 13px; - line-height: 1.5; - margin-bottom: 8px; + color: var(--ink); } -.remark-card__text a, -.station-disruption__item a, -.elevator-status__item a { +.notice-item__text { + display: block; + color: var(--ink-muted); + font-size: 12.5px; + line-height: 1.5; +} + +.notice-item__meta { + display: block; + color: var(--ink-muted); + font-size: 11.5px; + opacity: .8; +} +.notice-item__meta:empty { display: none; } + +.station-disruption .notice-item__text a, +.elevator-status .notice-item__text a, +.remark-card .notice-item__text a { color: var(--signal-amber); text-decoration: underline; } -.remark-card__meta { - color: var(--ink-muted); - font-size: 12px; -} - /* ============================================================ Suchergebnis-Icons (Haltestelle/Adresse/POI) ============================================================ */ @@ -957,7 +1050,7 @@ button { ============================================================ */ body { padding-bottom: 0; } -button { transition: border-color .15s ease, background .15s ease, transform .15s ease; } +button { transition: border-color .15s ease, background .15s ease, transform .15s ease, box-shadow .15s ease; } button:active { transform: translateY(1px); } .app-header { @@ -1029,9 +1122,23 @@ button:active { transform: translateY(1px); } padding: 28px; background: linear-gradient(135deg, var(--panel), var(--panel-raised)); border: 1px solid var(--line); - border-radius: 14px; + border-radius: var(--radius-lg); margin-bottom: 24px; + box-shadow: var(--shadow-md); + position: relative; + overflow: hidden; } +.welcome-panel::after { + content: ''; + position: absolute; + top: -60%; + right: -20%; + width: 320px; + height: 320px; + background: radial-gradient(circle, rgba(245, 166, 35, 0.12), transparent 70%); + pointer-events: none; +} +.welcome-panel > * { position: relative; } .welcome-panel__eyebrow, .section-heading__eyebrow { display: block; @@ -1056,12 +1163,13 @@ button:active { transform: translateY(1px); } .more-card, .favorite-card { transition: border-color .15s ease, background .15s ease, transform .15s ease, box-shadow .15s ease; + box-shadow: var(--shadow-sm); } .action-card:hover, .more-card:hover, .favorite-card:hover { transform: translateY(-2px); - box-shadow: 0 8px 20px rgba(0,0,0,.28); + box-shadow: var(--shadow-md); }.action-card--primary { border-color: rgba(245,166,35,.45); } .action-card__icon { font-size: 23px; } .action-card strong, .action-card small { display: block; } @@ -1115,25 +1223,85 @@ button:active { transform: translateY(1px); } } .toast { - background: var(--panel-raised); + background: rgba(33, 42, 52, 0.88); + backdrop-filter: blur(12px) saturate(1.3); + -webkit-backdrop-filter: blur(12px) saturate(1.3); border: 1px solid var(--line); border-left: 3px solid var(--signal-amber); color: var(--ink); border-radius: var(--radius); - padding: 12px 16px; + padding: 12px 14px; font-size: 13px; line-height: 1.4; - box-shadow: 0 8px 24px rgba(0,0,0,.35); + box-shadow: var(--shadow-lg); width: 100%; opacity: 0; transform: translateY(12px); transition: opacity .25s ease, transform .25s ease; + position: relative; + overflow: hidden; + display: flex; + align-items: flex-start; + gap: 10px; } .toast.is-visible { opacity: 1; transform: translateY(0); } .toast--warning { border-left-color: var(--signal-red); } .toast--info { border-left-color: var(--signal-amber); } .toast--success { border-left-color: var(--signal-green); } + +.toast__icon { + flex-shrink: 0; + width: 20px; + height: 20px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 12px; + font-weight: 700; + margin-top: 1px; + background: var(--panel-raised); + color: var(--signal-amber); +} +.toast--warning .toast__icon { color: var(--signal-red); background: rgba(255,93,108,.12); } +.toast--info .toast__icon { color: var(--signal-amber); background: rgba(245,166,35,.12); } +.toast--success .toast__icon { color: var(--signal-green); background: rgba(61,220,132,.12); } + +.toast__message { + flex: 1; + padding-top: 1px; +} + +.toast__close { + flex-shrink: 0; + background: none; + border: 0; + color: var(--ink-muted); + font-size: 18px; + line-height: 1; + width: 20px; + height: 20px; + border-radius: 50%; + margin: -2px -4px 0 0; +} +.toast__close:hover { background: var(--panel-raised); color: var(--ink); } + +.toast__progress { + position: absolute; + left: 0; + bottom: 0; + height: 2px; + width: 100%; + background: currentColor; + color: var(--signal-amber); + opacity: .35; + transform-origin: left; + transform: scaleX(1); + transition: transform linear; +} +.toast--warning .toast__progress { color: var(--signal-red); } +.toast--success .toast__progress { color: var(--signal-green); } .nearby-preview { display: grid; grid-template-columns: repeat(3, minmax(0,1fr)); gap: 8px; } .nearby-preview__item { display: flex; justify-content: space-between; align-items: center; gap: 10px; text-align: left; @@ -1147,24 +1315,37 @@ button:active { transform: translateY(1px); } .board-header { margin-bottom: 8px; } .board-station-name { font-size: 24px; margin-top: 2px; } .board-status { color: var(--ink-muted); font-size: 11px; margin-bottom: 14px; display: flex; align-items: center; gap: 7px; } -.status-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--signal-green); box-shadow: 0 0 0 3px rgba(61,220,132,.12); } +.status-dot { + display: inline-block; + width: 7px; height: 7px; border-radius: 50%; background: var(--signal-green); + box-shadow: 0 0 0 3px rgba(61,220,132,.12); +} .favorite-toggle { padding: 8px 12px; } .flap-row { grid-template-columns: 82px minmax(0,1fr) 82px 112px; padding: 14px 16px 14px 13px; } .flap-cell--time { font-size: 17px; } .flap-cell--direction { font-size: 15px; font-weight: 600; } .flap-cell--platform { font-size: 14px; } -.station-disruption { background: rgba(255,93,108,.08); border: 1px solid rgba(255,93,108,.3); color: var(--ink); border-radius: 9px; padding: 10px 12px; margin-top: 14px; font-size: 12px; } -.station-disruption__item { line-height: 1.5; } -.station-disruption__item + .station-disruption__item { margin-top: 8px; padding-top: 8px; border-top: 1px solid rgba(255,93,108,.25); } +.station-disruption { background: rgba(255,93,108,.08); border: 1px solid rgba(255,93,108,.3); color: var(--ink); border-radius: 9px; padding: 12px; margin-top: 14px; font-size: 12px; } +.station-disruption .notice-item + .notice-item { margin-top: 10px; padding-top: 10px; border-top: 1px solid rgba(255,93,108,.25); } /* Aufzugsstatus - eigener, ruhigerer Ton (Info, nicht "akute Störung") */ -.elevator-status { background: rgba(79,168,232,.08); border: 1px solid rgba(79,168,232,.3); color: var(--ink); border-radius: 9px; padding: 10px 12px; margin-top: 14px; font-size: 12px; } -.elevator-status__item { line-height: 1.5; } -.elevator-status__item + .elevator-status__item { margin-top: 8px; padding-top: 8px; border-top: 1px solid rgba(79,168,232,.25); } -.elevator-status__item strong { color: var(--signal-blue); } +.elevator-status { background: rgba(79,168,232,.08); border: 1px solid rgba(79,168,232,.3); color: var(--ink); border-radius: 9px; padding: 12px; margin-top: 14px; font-size: 12px; } +.elevator-status .notice-item + .notice-item { margin-top: 10px; padding-top: 10px; border-top: 1px solid rgba(79,168,232,.25); } +.elevator-status .notice-item__icon { background: rgba(79,168,232,.16); color: var(--signal-blue); } +.elevator-status .notice-item__body strong { color: var(--signal-blue); } -.panel-heading { margin-bottom: 22px; } +.panel-heading { margin-bottom: 22px; position: relative; padding-left: 14px; } +.panel-heading::before { + content: ''; + position: absolute; + left: 0; + top: 4px; + bottom: 4px; + width: 3px; + border-radius: 3px; + background: linear-gradient(180deg, var(--signal-amber), transparent); +} .panel-heading h1 { font-size: 26px; margin-bottom: 6px; } .panel-heading p { margin: 0; color: var(--ink-muted); font-size: 13px; } .live-map { height: min(680px, calc(100vh - 220px)); min-height: 460px; } @@ -1933,7 +2114,7 @@ button:active { transform: translateY(1px); } } @media (max-width: 430px) { - .flap-row { grid-template-columns: 58px minmax(0,1fr) 72px; gap: 12px; } + .flap-row { grid-template-columns: 74px minmax(0,1fr) 72px; gap: 12px; } .flap-field--platform { display: none; } .flap-field--time { grid-column: 3; } .notify-callout { flex-wrap: wrap; }