Files
einkaufsapp/web/html/js/app.js
2026-08-08 20:31:58 +02:00

286 lines
8.1 KiB
JavaScript

// 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 * as db from "./db.js";
import { installTriggers, stopWatching } from "./sync.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/<id>". 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) {
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/<token>
// 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();
if ("serviceWorker" in navigator) {
try {
await navigator.serviceWorker.register("/sw.js", { scope: "/" });
} catch (err) {
console.warn("Service Worker nicht registriert:", err.message);
}
}
}
start();