/** * Modul: Client-Side Router * Zweck: SPA-Routing über History API ohne Framework, Auth-Guard, Seiten-Übergänge * Abhängigkeiten: api.js */ import { api, auth } from '/api.js'; import { initI18n, getLocale, t } from '/i18n.js'; import { esc } from '/utils/html.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: '/birthdays', page: '/pages/birthdays.js', requiresAuth: true, module: 'birthdays' }, { path: '/notes', page: '/pages/notes.js', requiresAuth: true, module: 'notes' }, { path: '/recipes', page: '/pages/recipes.js', requiresAuth: true, module: 'recipes' }, { 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; let _preferencesLoaded = 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', '/birthdays', '/meals', '/recipes', '/shopping', '/notes', '/contacts', '/budget', '/settings']; const PRIMARY_NAV = 4; const DEFAULT_APP_NAME = 'Oikos'; const APP_NAME_STORAGE_KEY = 'oikos-app-name'; const APP_VERSION_STORAGE_KEY = 'oikos-app-version'; 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'; } function getAppName() { return localStorage.getItem(APP_NAME_STORAGE_KEY) || DEFAULT_APP_NAME; } function getAppVersion() { return localStorage.getItem(APP_VERSION_STORAGE_KEY) || ''; } function setAppName(name) { const next = String(name || '').trim(); if (next) { localStorage.setItem(APP_NAME_STORAGE_KEY, next); } else { localStorage.removeItem(APP_NAME_STORAGE_KEY); } } function setAppVersion(version) { const next = String(version || '').trim(); if (next) { localStorage.setItem(APP_VERSION_STORAGE_KEY, next); } else { localStorage.removeItem(APP_VERSION_STORAGE_KEY); } } function routeTitle(path) { const map = { '/': t('dashboard.title'), '/tasks': t('nav.tasks'), '/calendar': t('nav.calendar'), '/birthdays': t('nav.birthdays'), '/meals': t('nav.meals'), '/recipes': t('nav.recipes'), '/shopping': t('nav.shopping'), '/notes': t('nav.notes'), '/contacts': t('nav.contacts'), '/budget': t('nav.budget'), '/settings': t('nav.settings'), }; return map[path] || getAppName(); } function updateBranding(path = currentPath) { const appName = getAppName(); const sidebarLogoName = document.querySelector('.nav-sidebar__brand-name'); if (sidebarLogoName) sidebarLogoName.textContent = appName; const sidebarVersion = document.querySelector('.nav-sidebar__version'); if (sidebarVersion) { const version = getAppVersion(); sidebarVersion.textContent = version ? t('login.version', { version }) : ''; sidebarVersion.hidden = !version; } const loginTitle = document.querySelector('.login-hero__title'); if (path === '/login' && loginTitle) loginTitle.textContent = appName; document.title = path === '/login' ? appName : `${routeTitle(path || '/')} · ${appName}`; document.querySelectorAll('meta[name="apple-mobile-web-app-title"]').forEach((meta) => { meta.setAttribute('content', appName); }); } /** * 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; await syncPreferencesOnce(); loadReminderStyles(); initReminders(); } else { pushState = userOrPushState; } // Alten Pfad merken, bevor currentPath aktualisiert wird - für Richtungsberechnung const previousPath = currentPath; const basePath = path.split('?')[0]; currentPath = basePath; const route = ROUTES.find((r) => r.path === basePath) ?? ROUTES.find((r) => r.path === '/'); // Auth-Guard if (route.requiresAuth && !currentUser) { try { const result = await auth.me(); currentUser = result.user; await syncPreferencesOnce(); loadReminderStyles(); initReminders(); } catch { currentPath = null; // Reset damit navigate('/login') nicht geblockt wird isNavigating = false; // _pendingLoginRedirect leeren: der catch ruft navigate('/login') direkt auf, // der finally soll keinen zweiten Aufruf starten (würde isNavigating=true setzen, // während die Login-Seite rendert, und so post-login navigate blockieren). _pendingLoginRedirect = 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(basePath); updateThemeColorForRoute(route); updateBranding(basePath); } 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'); } } } async function syncPreferencesOnce() { if (_preferencesLoaded) return; _preferencesLoaded = true; try { const res = await api.get('/preferences'); const dateFormat = res?.data?.date_format; if (dateFormat) { localStorage.setItem('oikos-date-format', dateFormat); } if (res?.data?.app_name) { setAppName(res.data.app_name); updateBranding(); } } catch { // Non-critical. The settings page can refresh this later. } try { const res = await api.get('/version'); if (res?.version) setAppVersion(res.version); if (res?.app_name) setAppName(res.app_name); updateBranding(); } catch { // Non-critical. The login page and settings page can refresh branding later. } } /** * 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 }); // Route-Announcer: Screenreader über Seitenwechsel informieren (gezielt, nicht gesamter Inhalt) const announcer = document.getElementById('route-announcer'); if (announcer) { const pageLabel = navItems().find((n) => n.path === route.path)?.label ?? route.path; announcer.textContent = ''; setTimeout(() => { announcer.textContent = pageLabel; }, 50); } // 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) { const skipLink = document.createElement('a'); skipLink.href = '#main-content'; skipLink.className = 'sr-only'; skipLink.textContent = t('common.skipToContent'); const sidebar = document.createElement('nav'); sidebar.className = 'nav-sidebar'; sidebar.setAttribute('aria-label', t('nav.main')); const sidebarLogo = document.createElement('div'); sidebarLogo.className = 'nav-sidebar__logo'; // SVG-Logomark aus docs/logo.svg — Gradient via CSS-Tokens const logomark = document.createElement('div'); logomark.className = 'nav-sidebar__logomark'; logomark.setAttribute('aria-hidden', 'true'); const SVG_NS = 'http://www.w3.org/2000/svg'; const logoSvg = document.createElementNS(SVG_NS, 'svg'); logoSvg.setAttribute('viewBox', '0 0 160 160'); logoSvg.setAttribute('fill', 'none'); const defs = document.createElementNS(SVG_NS, 'defs'); const grad = document.createElementNS(SVG_NS, 'linearGradient'); const gradId = `oikos-logo-bg-${Math.random().toString(36).slice(2, 7)}`; grad.setAttribute('id', gradId); grad.setAttribute('x1', '0'); grad.setAttribute('y1', '0'); grad.setAttribute('x2', '160'); grad.setAttribute('y2', '160'); grad.setAttribute('gradientUnits', 'userSpaceOnUse'); const stop0 = document.createElementNS(SVG_NS, 'stop'); stop0.setAttribute('offset', '0%'); stop0.style.stopColor = 'var(--color-accent)'; const stop1 = document.createElementNS(SVG_NS, 'stop'); stop1.setAttribute('offset', '100%'); stop1.style.stopColor = 'var(--color-accent-secondary)'; grad.appendChild(stop0); grad.appendChild(stop1); defs.appendChild(grad); logoSvg.appendChild(defs); const bgRect = document.createElementNS(SVG_NS, 'rect'); bgRect.setAttribute('width', '160'); bgRect.setAttribute('height', '160'); bgRect.setAttribute('rx', '36'); bgRect.setAttribute('fill', `url(#${gradId})`); logoSvg.appendChild(bgRect); const housePath = document.createElementNS(SVG_NS, 'path'); housePath.setAttribute('d', 'M80 36L36 72V120C36 122.2 37.8 124 40 124H68V96H92V124H120C122.2 124 124 122.2 124 120V72L80 36Z'); housePath.setAttribute('fill', 'white'); logoSvg.appendChild(housePath); const chimney = document.createElementNS(SVG_NS, 'rect'); chimney.setAttribute('x', '100'); chimney.setAttribute('y', '46'); chimney.setAttribute('width', '12'); chimney.setAttribute('height', '22'); chimney.setAttribute('rx', '2'); chimney.setAttribute('fill', 'white'); logoSvg.appendChild(chimney); logomark.appendChild(logoSvg); sidebarLogo.appendChild(logomark); const sidebarBrandText = document.createElement('div'); sidebarBrandText.className = 'nav-sidebar__brand-text'; const sidebarLogoSpan = document.createElement('span'); sidebarLogoSpan.className = 'nav-sidebar__brand-name'; sidebarLogoSpan.textContent = getAppName(); const sidebarVersion = document.createElement('small'); sidebarVersion.className = 'nav-sidebar__version'; const cachedVersion = getAppVersion(); sidebarVersion.textContent = cachedVersion ? t('login.version', { version: cachedVersion }) : ''; sidebarVersion.hidden = !cachedVersion; sidebarBrandText.append(sidebarLogoSpan, sidebarVersion); sidebarLogo.appendChild(sidebarBrandText); const sidebarItems = document.createElement('div'); sidebarItems.className = 'nav-sidebar__items'; sidebarItems.setAttribute('role', 'list'); navItems().forEach((item) => sidebarItems.appendChild(navItemEl(item))); sidebar.appendChild(sidebarLogo); sidebar.appendChild(sidebarItems); const main = document.createElement('main'); main.className = 'app-content'; main.id = 'main-content'; const bottomNav = document.createElement('nav'); bottomNav.className = 'nav-bottom'; bottomNav.setAttribute('aria-label', t('nav.navigation')); const bottomItems = document.createElement('div'); bottomItems.className = 'nav-bottom__items'; navItems().slice(0, PRIMARY_NAV).forEach((item) => bottomItems.appendChild(navItemEl(item))); const moreBtn = document.createElement('button'); moreBtn.className = 'nav-item nav-item--more'; moreBtn.id = 'more-btn'; moreBtn.setAttribute('aria-label', t('nav.more')); moreBtn.setAttribute('aria-expanded', 'false'); const moreBtnIcon = document.createElement('i'); moreBtnIcon.dataset.lucide = 'grid-2x2'; moreBtnIcon.className = 'nav-item__icon'; moreBtnIcon.setAttribute('aria-hidden', 'true'); const moreBtnLabel = document.createElement('span'); moreBtnLabel.className = 'nav-item__label'; moreBtnLabel.textContent = t('nav.more'); moreBtn.appendChild(moreBtnIcon); moreBtn.appendChild(moreBtnLabel); bottomItems.appendChild(moreBtn); bottomNav.appendChild(bottomItems); const backdrop = document.createElement('div'); backdrop.className = 'more-backdrop'; backdrop.id = 'more-backdrop'; backdrop.setAttribute('aria-hidden', 'true'); const moreSheet = document.createElement('div'); moreSheet.className = 'more-sheet'; moreSheet.id = 'more-sheet'; moreSheet.setAttribute('role', 'dialog'); moreSheet.setAttribute('aria-label', t('nav.more')); moreSheet.setAttribute('aria-hidden', 'true'); const searchBtn = document.createElement('button'); searchBtn.className = 'more-item'; searchBtn.id = 'search-btn'; const searchIcon = document.createElement('i'); searchIcon.dataset.lucide = 'search'; searchIcon.className = 'more-item__icon'; searchIcon.setAttribute('aria-hidden', 'true'); const searchLabel = document.createElement('span'); searchLabel.className = 'more-item__label'; searchLabel.textContent = t('search.title'); searchBtn.appendChild(searchIcon); searchBtn.appendChild(searchLabel); moreSheet.appendChild(searchBtn); navItems().slice(PRIMARY_NAV).forEach((item) => moreSheet.appendChild(moreItemEl(item))); const searchOverlay = document.createElement('div'); searchOverlay.className = 'search-overlay'; searchOverlay.id = 'search-overlay'; searchOverlay.setAttribute('aria-hidden', 'true'); const searchHeader = document.createElement('div'); searchHeader.className = 'search-overlay__header'; const searchInput = document.createElement('input'); searchInput.type = 'search'; searchInput.className = 'search-overlay__input'; searchInput.id = 'search-input'; searchInput.placeholder = t('search.placeholder'); searchInput.setAttribute('aria-label', t('search.title')); const searchClose = document.createElement('button'); searchClose.className = 'search-overlay__close'; searchClose.id = 'search-close'; searchClose.setAttribute('aria-label', t('common.close')); const closeIcon = document.createElement('i'); closeIcon.dataset.lucide = 'x'; closeIcon.className = 'search-overlay__close-icon'; closeIcon.setAttribute('aria-hidden', 'true'); searchClose.appendChild(closeIcon); searchHeader.appendChild(searchInput); searchHeader.appendChild(searchClose); const searchResults = document.createElement('div'); searchResults.className = 'search-overlay__results'; searchResults.id = 'search-results'; searchOverlay.appendChild(searchHeader); searchOverlay.appendChild(searchResults); const toastContainer = document.createElement('div'); toastContainer.className = 'toast-container'; toastContainer.id = 'toast-container'; toastContainer.setAttribute('aria-live', 'assertive'); const routeAnnouncer = document.createElement('div'); routeAnnouncer.id = 'route-announcer'; routeAnnouncer.className = 'sr-only'; routeAnnouncer.setAttribute('aria-live', 'polite'); routeAnnouncer.setAttribute('aria-atomic', 'true'); container.replaceChildren(skipLink, sidebar, main, bottomNav, backdrop, moreSheet, searchOverlay, toastContainer, routeAnnouncer); updateBranding(currentPath || '/'); // Klick-Handler für alle Nav-Links container.querySelectorAll('[data-route]').forEach((el) => { el.addEventListener('click', (e) => { e.preventDefault(); navigate(el.dataset.route); }); }); initMoreSheet(container); initNavHideOnScroll(container); initSearch(container); initOfflineBanner(); initKeyboardShortcuts(); } const SHORTCUTS = [ { key: '/', description: () => t('shortcuts.search'), action: () => document.getElementById('search-btn')?.click() }, { key: 'n', description: () => t('shortcuts.new'), action: () => document.querySelector('.page-fab')?.click() }, { key: '?', description: () => t('shortcuts.help'), action: () => showShortcutsModal() }, { key: 'g d', description: () => t('shortcuts.goDash'), action: () => navigate('/') }, { key: 'g t', description: () => t('shortcuts.goTasks'), action: () => navigate('/tasks') }, { key: 'g c', description: () => t('shortcuts.goCal'), action: () => navigate('/calendar') }, { key: 'g s', description: () => t('shortcuts.goShop'), action: () => navigate('/shopping') }, { key: 'g n', description: () => t('shortcuts.goNotes'), action: () => navigate('/notes') }, ]; let _pendingKey = null; let _pendingTimer = null; function initKeyboardShortcuts() { document.addEventListener('keydown', (e) => { const tag = document.activeElement?.tagName; if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return; if (document.activeElement?.isContentEditable) return; if (document.querySelector('.modal-overlay') && e.key !== 'Escape') return; const key = e.key.toLowerCase(); if (_pendingKey === 'g' && key !== 'g') { clearTimeout(_pendingTimer); _pendingKey = null; const combo = `g ${key}`; const shortcut = SHORTCUTS.find((s) => s.key === combo); if (shortcut) { e.preventDefault(); shortcut.action(); } return; } if (key === 'g') { _pendingKey = 'g'; _pendingTimer = setTimeout(() => { _pendingKey = null; }, 1000); return; } const shortcut = SHORTCUTS.find((s) => s.key === key && !s.key.includes(' ')); if (shortcut) { e.preventDefault(); shortcut.action(); } }); } function showShortcutsModal() { const overlay = document.createElement('div'); overlay.className = 'modal-overlay'; overlay.setAttribute('aria-modal', 'true'); overlay.addEventListener('click', (e) => { if (e.target === overlay) overlay.remove(); }); const panel = document.createElement('div'); panel.className = 'modal-panel modal-panel--sm'; panel.setAttribute('role', 'dialog'); panel.setAttribute('aria-label', t('shortcuts.help')); const rows = SHORTCUTS.map((s) => `