// Einstiegspunkt: Routing zwischen den Ansichten und Start des // Service Workers. "use strict"; import { ApiError, get } from "./api.js"; import { clear, el, mount } from "./dom.js"; import { set, state } from "./store.js"; import { changePasswordView, forgotView, loginView, logout, registerView, resetView, } from "./views/auth.js"; import { listDetailView } from "./views/list-detail.js"; import { listsView } from "./views/lists.js"; import { articlesView } from "./views/articles.js"; import { pricesView } from "./views/prices.js"; import { adminView } from "./views/admin.js"; import { settingsView } from "./views/settings.js"; import { welcomeView } from "./views/welcome.js"; import { manageView } from "./views/manage.js"; import { closeMenu } from "./appbar.js"; import * as db from "./db.js"; import { installTriggers, stopWatching } from "./sync.js"; import { watchForUpdates } from "./update.js"; import { publicView } from "./views/public.js"; import { acceptInviteView, shareView } from "./views/share.js"; const root = document.getElementById("app"); // Routen als Zeichenkette im Fragment: "#/lists/". Kein History-API- // Routing, weil der Service Worker dann jede Tiefe abfangen müsste. function currentRoute() { const hash = location.hash.replace(/^#/, ""); const parts = hash.split("/").filter(Boolean); if (parts[0] === "lists" && parts[1] && parts[2] === "manage") { return { name: "manage", listId: parts[1] }; } if (parts[0] === "lists" && parts[1] && parts[2] === "share") { return { name: "share", listId: parts[1] }; } if (parts[0] === "lists" && parts[1] && parts[2] === "articles") { return { name: "articles", listId: parts[1] }; } if (parts[0] === "lists" && parts[1] && parts[2] === "prices") { return { name: "prices", listId: parts[1] }; } if (parts[0] === "settings") { return { name: "settings" }; } if (parts[0] === "admin") { return { name: "admin" }; } if (parts[0] === "lists" && parts[1]) { return { name: "list", listId: parts[1] }; } return { name: "lists" }; } /** Beim Verlassen der Detailansicht den Ereigniskanal schließen - * sonst bleibt pro besuchter Liste eine offene Verbindung stehen. */ function navigate(hash) { // Sonst bliebe ein offenes Menü über den Wechsel hinweg stehen. closeMenu(); stopWatching(); if (location.hash === hash) { route(); } else { location.hash = hash; } } let authView = "login"; function goto(view) { authView = view; route(); } async function signOut() { await logout(); authView = "login"; location.hash = ""; route(); } function fail(err) { mount(root, el("section.card", {}, el("h1", {}, "Etwas ist schiefgelaufen"), el("p.error", {}, err.message), el("button.primary", { type: "button", onclick: () => route() }, "Erneut versuchen") ) ); } async function route() { // Öffentlicher Link: /s/ // Muss VOR jeder Anmeldeprüfung stehen - der Empfänger hat kein Konto. if (location.pathname.startsWith("/s/")) { document.body.classList.add("public-mode"); await publicView(root, { token: decodeURIComponent(location.pathname.slice(3)), }); return; } // Der Link aus der Reset-Mail zeigt auf /reset?token=... if (location.pathname === "/reset") { resetView(root, { goto: (v) => { history.replaceState(null, "", "/"); goto(v); } }); return; } // Willkommenslink aus der vom Administrator versendeten Einladung. // Muss vor der Anmeldeprüfung stehen: Wer den Link öffnet, HAT noch // kein Passwort und kann sich deshalb nicht anmelden. if (location.pathname === "/willkommen") { const token = new URLSearchParams(location.search).get("token"); if (token) { await welcomeView(root, { token, toLogin: () => { history.replaceState(null, "", "/"); route(); }, }); return; } } // Einladungslink: /invite?token=... // Wer nicht angemeldet ist, sieht zuerst das Anmeldeformular. Der Pfad // bleibt dabei erhalten, sodass es nach der Anmeldung hier weitergeht. const inviteToken = location.pathname === "/invite" ? new URLSearchParams(location.search).get("token") : null; if (!state.user) { const views = { login: loginView, register: registerView, forgot: forgotView }; views[authView](root, { goto, onSignedIn: (user) => { set({ user }); route(); }, }); return; } if (state.user.must_change_password) { changePasswordView(root, { onDone: () => route(), onLogout: signOut }); return; } if (inviteToken) { await acceptInviteView(root, { token: inviteToken, onAccepted: () => { history.replaceState(null, "", "/"); route(); }, toLists: () => { history.replaceState(null, "", "/"); route(); }, }); return; } const r = currentRoute(); if (r.name !== "list") stopWatching(); try { if (r.name === "list") { await listDetailView(root, { listId: r.listId, back: () => navigate(""), manage: (id) => navigate(`#/lists/${id}/manage`), share: (id) => navigate(`#/lists/${id}/share`), articles: (id) => navigate(`#/lists/${id}/articles`), prices: (id) => navigate(`#/lists/${id}/prices`), }); } else if (r.name === "share") { await shareView(root, { listId: r.listId, back: () => navigate(`#/lists/${r.listId}`), }); } else if (r.name === "settings") { await settingsView(root, { back: () => navigate(""), admin: () => navigate("#/admin"), }); } else if (r.name === "admin") { await adminView(root, { back: () => navigate("#/settings") }); } else if (r.name === "prices") { await pricesView(root, { listId: r.listId, back: () => navigate(`#/lists/${r.listId}`), }); } else if (r.name === "articles") { await articlesView(root, { listId: r.listId, back: () => navigate(`#/lists/${r.listId}`), }); } else if (r.name === "manage") { await manageView(root, { listId: r.listId, back: () => navigate(`#/lists/${r.listId}`), }); } else { await listsView(root, { openList: (id) => navigate(`#/lists/${id}`), onLogout: signOut, settings: () => navigate("#/settings"), }); } } catch (err) { if (err instanceof ApiError && err.status === 401) { set({ user: null }); route(); return; } fail(err); } } window.addEventListener("hashchange", route); /** Anwendungsname aus der API holen und überall anwenden. * * Warum nicht beim Bauen einsetzen: Dann müsste der web-Container bei * jeder Umbenennung neu gebaut werden. So genügt eine Änderung in der * .env und ein Neustart des api-Containers. * * Der zuletzt bekannte Name liegt lokal, damit die App auch ohne * Verbindung nicht namenlos startet. */ async function loadAppName() { let config = null; try { config = await get("/api/config"); await db.cacheSet("config", config); } catch { config = await db.cacheGet("config").catch(() => null); } if (config?.app_name) set({ appName: config.app_name }); applyAppName(state.appName); } function applyAppName(name) { document.title = name; for (const node of document.querySelectorAll("[data-app-name]")) { node.textContent = name; } } async function start() { await loadAppName(); try { set({ user: await get("/api/auth/me") }); } catch (err) { if (!(err instanceof ApiError) || err.status !== 401) { // Offline oder Serverfehler: trotzdem das Anmeldeformular zeigen, // damit die App nicht auf einem Ladehinweis stehenbleibt. console.warn("Anmeldestatus nicht abrufbar:", err.message); } } // Wartende Änderungen senden, sobald die Verbindung zurück ist, die // App wieder im Vordergrund steht - oder ersatzweise alle 30 Sekunden. installTriggers( () => (currentRoute().name === "list" ? currentRoute().listId : null), () => route() ); await route(); // Registriert den Service Worker und meldet sich, wenn eine neue // Fassung bereitliegt. watchForUpdates(); } start();