Erste Produktivversion
This commit is contained in:
285
web/html/js/app.js
Normal file
285
web/html/js/app.js
Normal file
@@ -0,0 +1,285 @@
|
||||
// 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();
|
||||
Reference in New Issue
Block a user