Navigation an iOS-Tabs angleichen, Benachrichtigungscenter, Einfacher Modus
Phase 2-4 der WebApp-Angleichung an die iOS-App: - Navigation: Favoriten und Störungen sind jetzt eigene Top-Level-Tabs (Desktop-Tableiste + mobile Bottom-Nav) statt nur über das Mehr-Grid bzw. den Stern im Header erreichbar. Neue Header-Icons für Einstellungen (⚙) und Benachrichtigungen (🔔, mit rotem Punkt bei ungelesenen Einträgen). "Mehr" ist jetzt schlanker (In der Nähe, Tickets, Verlauf, App-Info mit Verbindungstest gegen /health/full) und die Sprachauswahl in einen neuen eigenen "Einstellungen"-Tab umgezogen. - Benachrichtigungscenter (js/notifications.js, neu): persistenter Verlauf über IndexedDB, sowohl vom Service Worker (eingehende Web-Push- Nachrichten, auch bei geschlossenem Tab) als auch von der Seite selbst (Störungs-Matches bei Favoriten, Fahrtalarm) beschrieben. Neuer "Benachrichtigungen"-Tab zeigt den Verlauf, Badge am Glocken-Icon zeigt ungelesene Einträge, per postMessage vom Service Worker live aktualisiert. - Einfacher Modus: neuer Toggle im Einstellungen-Tab, persistiert in localStorage, setzt eine .simple-mode-Klasse auf <html> - moderat größere Schrift plus Ausblenden von Verkehrsmittel-Filter/Fahrrad-Option/ "Zwischenhalt hinzufügen" im Routenplaner, analog zur iOS-App (bewusst keine Accessibility-Schriftgrößenstufe, die ist für Screenreader gedacht). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01De7H8hR1Q9DdgyuS2ukN6y
This commit is contained in:
8 files changed
+455
-12
No files matched your search
@@ -0,0 +1,111 @@
|
||||
// Persistenter Benachrichtigungsverlauf ("Notification Center"), analog zum
|
||||
// Glocken-Icon in der iOS-App. Läuft als IndexedDB-Store, weil das - anders
|
||||
// als localStorage - sowohl vom offenen Tab als auch vom Service Worker aus
|
||||
// (auch bei geschlossenem Tab, siehe sw.js) beschrieben werden kann. Wird per
|
||||
// <script> in index.html UND per importScripts() in sw.js eingebunden, daher
|
||||
// bewusst keine ES-Module-Syntax.
|
||||
|
||||
const NotificationStore = (() => {
|
||||
const DB_NAME = 'oepnv-navi-notifications';
|
||||
const STORE = 'notifications';
|
||||
const MAX_ENTRIES = 50;
|
||||
|
||||
function openDb() {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!('indexedDB' in self)) {
|
||||
reject(new Error('IndexedDB nicht verfügbar'));
|
||||
return;
|
||||
}
|
||||
const req = indexedDB.open(DB_NAME, 1);
|
||||
req.onupgradeneeded = () => {
|
||||
const db = req.result;
|
||||
if (!db.objectStoreNames.contains(STORE)) {
|
||||
db.createObjectStore(STORE, { keyPath: 'id', autoIncrement: true });
|
||||
}
|
||||
};
|
||||
req.onsuccess = () => resolve(req.result);
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
}
|
||||
|
||||
async function add(entry) {
|
||||
const db = await openDb();
|
||||
await new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(STORE, 'readwrite');
|
||||
tx.objectStore(STORE).add({
|
||||
title: entry.title || '',
|
||||
body: entry.body || '',
|
||||
url: entry.url || null,
|
||||
icon: entry.icon || '🔔',
|
||||
timestamp: entry.timestamp || Date.now(),
|
||||
read: false,
|
||||
});
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => reject(tx.error);
|
||||
});
|
||||
await trim();
|
||||
}
|
||||
|
||||
async function list() {
|
||||
const db = await openDb();
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(STORE, 'readonly');
|
||||
const req = tx.objectStore(STORE).getAll();
|
||||
req.onsuccess = () => resolve(req.result.sort((a, b) => b.timestamp - a.timestamp));
|
||||
req.onerror = () => reject(req.error);
|
||||
});
|
||||
}
|
||||
|
||||
async function clear() {
|
||||
const db = await openDb();
|
||||
await new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(STORE, 'readwrite');
|
||||
tx.objectStore(STORE).clear();
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => reject(tx.error);
|
||||
});
|
||||
}
|
||||
|
||||
async function markAllRead() {
|
||||
const db = await openDb();
|
||||
const entries = await list();
|
||||
if (entries.length === 0) return;
|
||||
await new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(STORE, 'readwrite');
|
||||
const store = tx.objectStore(STORE);
|
||||
entries.forEach((e) => store.put({ ...e, read: true }));
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => reject(tx.error);
|
||||
});
|
||||
}
|
||||
|
||||
async function trim() {
|
||||
const entries = await list();
|
||||
if (entries.length <= MAX_ENTRIES) return;
|
||||
const db = await openDb();
|
||||
const toRemove = entries.slice(MAX_ENTRIES);
|
||||
await new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(STORE, 'readwrite');
|
||||
const store = tx.objectStore(STORE);
|
||||
toRemove.forEach((e) => store.delete(e.id));
|
||||
tx.oncomplete = () => resolve();
|
||||
tx.onerror = () => reject(tx.error);
|
||||
});
|
||||
}
|
||||
|
||||
async function unreadCount() {
|
||||
try {
|
||||
const entries = await list();
|
||||
return entries.filter((e) => !e.read).length;
|
||||
} catch (_) {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
return { add, list, clear, markAllRead, unreadCount };
|
||||
})();
|
||||
|
||||
// In der Seite (nicht im Service-Worker-Kontext) global verfügbar machen.
|
||||
if (typeof window !== 'undefined') {
|
||||
window.NotificationStore = NotificationStore;
|
||||
}
|
||||
Reference in new issue
Block a user