286 lines
8.1 KiB
JavaScript
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 { 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/<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) {
|
|
// 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/<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();
|
|
|
|
// Registriert den Service Worker und meldet sich, wenn eine neue
|
|
// Fassung bereitliegt.
|
|
watchForUpdates();
|
|
}
|
|
|
|
start();
|