/**
* 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';
// --------------------------------------------------------
// 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;
// --------------------------------------------------------
// 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;
} 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;
} 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;
}
}
/**
* 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';
// 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);
} catch (err) {
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')}