/** * Modul: Client-Side Router * Zweck: SPA-Routing über History API ohne Framework, Auth-Guard, Seiten-Übergänge * Abhängigkeiten: api.js */ import { auth } from '/api.js'; import { initI18n, getLocale, t } from '/i18n.js'; import { init as initReminders, stop as stopReminders } from '/reminders.js'; // -------------------------------------------------------- // Routen-Definitionen // Jede Route hat: path, page (dynamisch geladen), requiresAuth, module (für theme-color) // -------------------------------------------------------- const ROUTES = [ { path: '/login', page: '/pages/login.js', requiresAuth: false, module: null }, { path: '/', page: '/pages/dashboard.js', requiresAuth: true, module: 'dashboard' }, { path: '/tasks', page: '/pages/tasks.js', requiresAuth: true, module: 'tasks' }, { path: '/shopping', page: '/pages/shopping.js', requiresAuth: true, module: 'shopping' }, { path: '/meals', page: '/pages/meals.js', requiresAuth: true, module: 'meals' }, { path: '/calendar', page: '/pages/calendar.js', requiresAuth: true, module: 'calendar' }, { path: '/notes', page: '/pages/notes.js', requiresAuth: true, module: 'notes' }, { path: '/contacts', page: '/pages/contacts.js', requiresAuth: true, module: 'contacts' }, { path: '/budget', page: '/pages/budget.js', requiresAuth: true, module: 'budget' }, { path: '/settings', page: '/pages/settings.js', requiresAuth: true, module: 'settings' }, ]; // -------------------------------------------------------- // Standalone-Modus: Dynamische theme-color Anpassung // Statusbar-Farbe spiegelt aktuelle Seite / Modal-State wider // -------------------------------------------------------- const isStandalone = window.matchMedia('(display-mode: standalone)').matches || navigator.standalone === true; /** * Setzt die theme-color Meta-Tags (Light + Dark Variante). * @param {string} lightColor * @param {string} [darkColor] - Falls nicht angegeben, wird lightColor für beide gesetzt */ function setThemeColor(lightColor, darkColor) { if (!isStandalone) return; const metas = document.querySelectorAll('meta[name="theme-color"]'); if (metas.length >= 2) { metas[0].setAttribute('content', lightColor); metas[1].setAttribute('content', darkColor || lightColor); } else if (metas.length === 1) { metas[0].setAttribute('content', lightColor); } } /** Liest eine CSS Custom Property vom :root */ function getCSSToken(name) { return getComputedStyle(document.documentElement).getPropertyValue(name).trim(); } /** Setzt theme-color passend zum aktuellen Modul */ function updateThemeColorForRoute(route) { if (!route?.module) { setThemeColor('#007AFF', '#1C1C1E'); return; } const color = getCSSToken(`--module-${route.module}`); if (color) { setThemeColor(color, color); } } // -------------------------------------------------------- // Dynamisches Stylesheet-Loading pro Seitenmodul // -------------------------------------------------------- let activePageStyle = null; function loadPageStyle(moduleName) { if (!moduleName) return { ready: Promise.resolve(), cleanup: () => {} }; const href = `/styles/${moduleName}.css`; if (activePageStyle?.getAttribute('href') === href) { return { ready: Promise.resolve(), cleanup: () => {} }; } const link = document.createElement('link'); link.rel = 'stylesheet'; link.href = href; const oldLink = activePageStyle; const ready = new Promise((resolve) => { link.onload = resolve; link.onerror = resolve; }); document.head.appendChild(link); activePageStyle = link; return { ready, cleanup: () => { if (oldLink) oldLink.remove(); }, }; } // -------------------------------------------------------- // Modul-Cache: verhindert redundante dynamic imports bei Navigation // -------------------------------------------------------- const moduleCache = new Map(); async function importPage(pagePath) { if (!moduleCache.has(pagePath)) { moduleCache.set(pagePath, await import(pagePath)); } return moduleCache.get(pagePath); } // -------------------------------------------------------- // Globaler App-State // -------------------------------------------------------- let currentUser = null; let currentPath = null; let isNavigating = false; // Gesetzt wenn auth:expired waehrend einer laufenden Navigation feuert. // Die Weiterleitung zu /login wird nach Abschluss der Navigation nachgeholt. let _pendingLoginRedirect = false; // -------------------------------------------------------- // Router // -------------------------------------------------------- const ROUTE_ORDER = ['/', '/tasks', '/calendar', '/meals', '/shopping', '/notes', '/contacts', '/budget', '/settings']; function getDirection(fromPath, toPath) { const fromIdx = ROUTE_ORDER.indexOf(fromPath ?? '/'); const toIdx = ROUTE_ORDER.indexOf(toPath); if (fromIdx === -1 || toIdx === -1 || fromPath === toPath) return 'right'; return toIdx > fromIdx ? 'right' : 'left'; } /** * Navigiert zu einem Pfad und rendert die entsprechende Seite. * @param {string} path * @param {Object|boolean} userOrPushState - Direkt ein User-Objekt nach Login, * oder boolean (pushState) für interne Navigation * @param {boolean} pushState - false beim initialen Load und popstate */ async function navigate(path, userOrPushState = true, pushState = true) { if (isNavigating) return; isNavigating = true; try { // Überlastung: navigate(path, user) nach Login vs navigate(path, false) beim Init if (typeof userOrPushState === 'object' && userOrPushState !== null) { currentUser = userOrPushState; initReminders(); } else { pushState = userOrPushState; } // Alten Pfad merken, bevor currentPath aktualisiert wird - für Richtungsberechnung const previousPath = currentPath; currentPath = path; const route = ROUTES.find((r) => r.path === path) ?? ROUTES.find((r) => r.path === '/'); // Auth-Guard if (route.requiresAuth && !currentUser) { try { const result = await auth.me(); currentUser = result.user; initReminders(); } catch { currentPath = null; // Reset damit navigate('/login') nicht geblockt wird isNavigating = false; navigate('/login'); return; } } if (!route.requiresAuth && currentUser && path === '/login') { currentPath = null; isNavigating = false; navigate('/'); return; } if (pushState) { history.pushState({ path }, '', path); } const accent = route?.module ? getCSSToken(`--module-${route.module}`) : ''; document.documentElement.style.setProperty('--active-module-accent', accent); await renderPage(route, previousPath); updateNav(path); updateThemeColorForRoute(route); } finally { isNavigating = false; // auth:expired kann waehrend einer Navigation gefeuert haben (z.B. wenn ein // paralleler API-Call 401 zurueckgab). Jetzt wo die Navigation abgeschlossen // ist, holen wir die Login-Weiterleitung nach. if (_pendingLoginRedirect) { _pendingLoginRedirect = false; navigate('/login'); } } } /** * Lädt und rendert eine Seite dynamisch. * @param {{ path: string, page: string }} route * @param {string|null} previousPath - Pfad vor der Navigation (für Richtungsberechnung) */ async function renderPage(route, previousPath = null) { const app = document.getElementById('app'); const loading = document.getElementById('app-loading'); // Loading verstecken if (loading) loading.hidden = true; try { const style = loadPageStyle(route.module); const [module] = await Promise.all([ importPage(route.page), style.ready, ]); if (typeof module.render !== 'function') { throw new Error(`Seite ${route.page} exportiert keine render()-Funktion.`); } // App-Shell einmalig aufbauen BEVOR render() aufgerufen wird - // main-content muss im DOM existieren damit document.getElementById() // in Seiten-Modulen funktioniert. if (!document.querySelector('.nav-bottom') && currentUser) { renderAppShell(app); } const content = document.getElementById('main-content') || app; // Richtung bestimmen (previousPath ist der alte Pfad vor der Navigation) const direction = getDirection(previousPath, route.path); const outClass = direction === 'right' ? 'page-transition--out-left' : 'page-transition--out-right'; const inClass = direction === 'right' ? 'page-transition--in-right' : 'page-transition--in-left'; // Performance: backdrop-filter während Übergang deaktivieren (Android-Optimierung). // glass.css setzt alle backdrop-filter im app-content auf none solange diese Klasse aktiv ist. document.documentElement.classList.add('navigating'); // Alte Seite kurz ausfaden, falls vorhanden const oldPage = content.querySelector('.page-transition'); if (oldPage) { oldPage.classList.add(outClass); await new Promise(r => setTimeout(r, 120)); } // Alter Inhalt ist jetzt weg - altes Stylesheet kann entfernt werden const pageWrapper = document.createElement('div'); pageWrapper.className = 'page-transition'; pageWrapper.style.opacity = '0'; content.replaceChildren(pageWrapper); style.cleanup(); await module.render(pageWrapper, { user: currentUser }); // Erst nach render() + CSS sichtbar machen und Animation starten pageWrapper.style.opacity = ''; pageWrapper.classList.add(inClass); // navigating-Klasse nach Ende der Einblend-Animation entfernen. // Fallback-Timeout falls animationend nicht feuert (z.B. prefers-reduced-motion). const navEndTimeout = setTimeout(() => { document.documentElement.classList.remove('navigating'); }, 300); pageWrapper.addEventListener('animationend', () => { clearTimeout(navEndTimeout); document.documentElement.classList.remove('navigating'); }, { once: true }); } catch (err) { document.documentElement.classList.remove('navigating'); console.error('[Router] Seiten-Render-Fehler:', err); renderError(app, err); } } /** * App-Shell mit Navigation einmalig aufbauen (nach erstem Login). */ function renderAppShell(container) { container.innerHTML = ` ${t('common.skipToContent')}
`; // Klick-Handler für alle Nav-Links container.querySelectorAll('[data-route]').forEach((el) => { el.addEventListener('click', (e) => { e.preventDefault(); navigate(el.dataset.route); }); }); // Bottom-Nav: Scroll-Snap + Dot-Indikator initBottomNavSwipe(container); // Bottom-Nav: Auto-Hide beim Runterscrollen (Mobile) initNavHideOnScroll(container); } /** * Versteckt die Bottom-Nav beim Runterscrollen, zeigt sie beim Hochscrollen. * Nur auf Mobile aktiv (< 1024px), da auf Desktop die Sidebar fest sichtbar ist. */ function initNavHideOnScroll(container) { const content = container.querySelector('#main-content'); const nav = container.querySelector('.nav-bottom'); if (!content || !nav) return; let lastY = 0; content.addEventListener('scroll', () => { if (window.innerWidth >= 1024) return; const y = content.scrollTop; if (y < 10) { nav.classList.remove('nav-bottom--hidden'); } else if (y > lastY + 4) { nav.classList.add('nav-bottom--hidden'); } else if (y < lastY - 4) { nav.classList.remove('nav-bottom--hidden'); } lastY = y; }, { passive: true }); } /** * Initialisiert Swipe-Gesten und Dot-Indikator für die mobile Bottom-Navigation. */ function initBottomNavSwipe(container) { const scroll = container.querySelector('.nav-bottom__scroll'); const dots = container.querySelectorAll('.nav-bottom__dot'); if (!scroll || !dots.length) return; // Scroll-Event: Dot-Indikator aktualisieren scroll.addEventListener('scroll', () => { const page = Math.round(scroll.scrollLeft / scroll.offsetWidth); dots.forEach((d, i) => d.classList.toggle('nav-bottom__dot--active', i === page)); }, { passive: true }); } /** * Scrollt die Bottom-Nav zur richtigen Seite, wenn ein Item auf Seite 2 aktiv ist. */ function scrollNavToActive() { const scroll = document.querySelector('.nav-bottom__scroll'); if (!scroll) return; const secondPage = navItems().slice(5).map(n => n.path); if (secondPage.includes(currentPath)) { scroll.scrollTo({ left: scroll.offsetWidth, behavior: 'smooth' }); } } function navItems() { return [ { path: '/', label: t('nav.dashboard'), icon: 'layout-dashboard' }, { path: '/tasks', label: t('nav.tasks'), icon: 'check-square' }, { path: '/calendar', label: t('nav.calendar'), icon: 'calendar' }, { path: '/meals', label: t('nav.meals'), icon: 'utensils' }, { path: '/shopping', label: t('nav.shopping'), icon: 'shopping-cart' }, { path: '/notes', label: t('nav.notes'), icon: 'sticky-note' }, { path: '/contacts', label: t('nav.contacts'), icon: 'book-user' }, { path: '/budget', label: t('nav.budget'), icon: 'wallet' }, { path: '/settings', label: t('nav.settings'), icon: 'settings' }, ]; } function navItemHtml({ path, label, icon }) { return ` ${label} `; } /** * Aktiven Nav-Link hervorheben. */ function updateNav(path) { document.querySelectorAll('[data-route]').forEach((el) => { el.removeAttribute('aria-current'); if (el.dataset.route === path) { el.setAttribute('aria-current', 'page'); } }); // Lucide Icons neu rendern (nach DOM-Update) if (window.lucide) { window.lucide.createIcons(); } // Bottom-Nav zur aktiven Seite scrollen scrollNavToActive(); // Modul-Akzentfarbe wird in navigate() gesetzt, wo route bereits aufgelöst ist. } function renderError(container, err) { container.innerHTML = `
${t('common.errorOccurred')}
${err.message}
`; container.querySelector('#error-reload-btn')?.addEventListener('click', () => location.reload()); } // -------------------------------------------------------- // Toast-Benachrichtigungen (global) // -------------------------------------------------------- /** * Zeigt eine Toast-Benachrichtigung an. * @param {string} message * @param {'default'|'success'|'danger'|'warning'} type * @param {number} duration - ms */ const TOAST_ICONS = { success: '', danger: '', warning: '', }; function showToast(message, type = 'default', duration = 3000, onUndo = null) { const container = document.getElementById('toast-container'); if (!container) return; // Max. 3 gleichzeitige Toasts: ältesten entfernen falls Limit erreicht const existing = container.querySelectorAll('.toast'); if (existing.length >= 3) existing[0].remove(); const toast = document.createElement('div'); toast.className = `toast ${type !== 'default' ? `toast--${type}` : ''}`; toast.setAttribute('role', 'alert'); // Icon: statische SVGs aus TOAST_ICONS (kein User-Input, kein XSS-Risiko) const icon = TOAST_ICONS[type] || ''; const span = document.createElement('span'); span.textContent = message; toast.innerHTML = icon; // eslint-disable-line no-unsanitized/property -- static SVG only toast.appendChild(span); if (typeof onUndo === 'function') { const undoBtn = document.createElement('button'); undoBtn.className = 'toast__undo'; undoBtn.textContent = t('common.undo'); undoBtn.addEventListener('click', () => { clearTimeout(dismissTimer); toast.remove(); onUndo(); }); toast.appendChild(undoBtn); } container.appendChild(toast); const dismissTimer = setTimeout(() => { toast.classList.add('toast--out'); toast.addEventListener('animationend', () => toast.remove(), { once: true }); }, duration); } // -------------------------------------------------------- // Event-Listener // -------------------------------------------------------- // -------------------------------------------------------- // Globale Fehler-Handler (Error Boundary) // -------------------------------------------------------- window.addEventListener('error', (e) => { // Ressource-Ladefehler (z.B. fehlgeschlagenes Bild): ignorieren if (e.target && e.target !== window) return; console.error('[Oikos] Unbehandelter Fehler:', e.error ?? e.message); showToast(t('common.unexpectedError'), 'danger'); }); window.addEventListener('unhandledrejection', (e) => { // Auth-Fehler werden bereits von auth:expired behandelt if (e.reason?.status === 401) return; console.error('[Oikos] Unbehandeltes Promise-Rejection:', e.reason); const msg = e.reason?.message || t('common.errorGeneric'); showToast(msg, 'danger'); e.preventDefault(); // Konsolenfehler unterdrücken (bereits geloggt) }); // SW-Update: neue Version im Hintergrund installiert → Toast anzeigen if ('serviceWorker' in navigator) { navigator.serviceWorker.addEventListener('message', (e) => { if (e.data?.type === 'SW_UPDATED') { // Modul-Cache leeren damit nächste Navigation frische Module lädt moduleCache.clear(); showToast(t('common.updateAvailable'), 'default', 8000); } }); } // Browser zurück/vor window.addEventListener('popstate', (e) => { navigate(e.state?.path || location.pathname, false); }); // Session abgelaufen window.addEventListener('auth:expired', () => { currentUser = null; stopReminders(); if (isNavigating) { // navigate('/login') kann nicht sofort aufgerufen werden - wird im finally-Block // der laufenden Navigation nachgeholt. _pendingLoginRedirect = true; } else { navigate('/login'); } }); // Sprache geändert: Navigation neu rendern damit Labels aktualisiert werden window.addEventListener('locale-changed', () => { const navSidebarItems = document.querySelector('.nav-sidebar__items'); const navBottomPages = document.querySelectorAll('.nav-bottom__page'); const skipLink = document.querySelector('.sr-only[href="#main-content"]'); const navSidebar = document.querySelector('.nav-sidebar'); const navBottom = document.querySelector('.nav-bottom'); if (skipLink) skipLink.textContent = t('common.skipToContent'); if (navSidebar) navSidebar.setAttribute('aria-label', t('nav.main')); if (navBottom) navBottom.setAttribute('aria-label', t('nav.navigation')); if (navSidebarItems) { navSidebarItems.innerHTML = navItems().map(navItemHtml).join(''); } if (navBottomPages.length >= 2) { navBottomPages[0].innerHTML = navItems().slice(0, 5).map(navItemHtml).join(''); navBottomPages[1].innerHTML = navItems().slice(5).map(navItemHtml).join(''); } // Klick-Handler für neu gerenderte Nav-Links document.querySelectorAll('[data-route]').forEach((el) => { el.addEventListener('click', (e) => { e.preventDefault(); navigate(el.dataset.route); }); }); // Aktiven Zustand und Icons wiederherstellen updateNav(currentPath); }); // -------------------------------------------------------- // Virtuelle Tastatur: FAB ausblenden wenn Keyboard offen // Erkennung via visualViewport - Höhe < 75% des Fensters = Keyboard aktiv. // Nur auf Mobilgeräten relevant (< 1024px), Desktop hat keine virtuelle Tastatur. // -------------------------------------------------------- if (window.visualViewport) { window.visualViewport.addEventListener('resize', () => { const keyboardVisible = window.visualViewport.height < window.innerHeight * 0.75; document.body.classList.toggle('keyboard-visible', keyboardVisible); }); } // -------------------------------------------------------- // iOS PWA: Viewport-Zoom bei Tastatur-Erscheinen verhindern. // iOS Safari/WKWebView zoomt ins Layout wenn ein Formularfeld fokussiert wird // und stellt den Zoom nach Tastatur-Schliessen im Standalone-Modus nicht // automatisch zurück → Menüpunkte verschwinden aus dem sichtbaren Bereich. // // Fix: maximum-scale=1 während des Focus setzt (verhindert Zoom), // danach original Wert wiederherstellen (erhält manuelle Zoom-Möglichkeit // für Barrierefreiheit). Nur auf iOS-Geräten aktiv. // -------------------------------------------------------- if (/iPhone|iPad|iPod/.test(navigator.userAgent)) { const metaViewport = document.querySelector('meta[name="viewport"]'); if (metaViewport) { const originalContent = metaViewport.getAttribute('content'); const noZoomContent = originalContent.replace(/maximum-scale=\d+/, 'maximum-scale=1'); document.addEventListener('focusin', ({ target }) => { if (['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName)) { metaViewport.setAttribute('content', noZoomContent); } }); document.addEventListener('focusout', ({ target }) => { if (['INPUT', 'TEXTAREA', 'SELECT'].includes(target.tagName)) { // Kurze Verzögerung: iOS braucht ~150ms um Layout nach Tastatur- // Schliessen wiederherzustellen, bevor scale zurückgesetzt wird. setTimeout(() => metaViewport.setAttribute('content', originalContent), 150); } }); } } // -------------------------------------------------------- // Initialisierung // -------------------------------------------------------- (async () => { await initI18n(); navigate(location.pathname, false); })(); // Globale Exporte window.oikos = { navigate, showToast, setThemeColor, restoreThemeColor: () => { const route = ROUTES.find((r) => r.path === currentPath); updateThemeColorForRoute(route); }, };