Erste Produktivversion
This commit is contained in:
1245
web/html/app.css
Normal file
1245
web/html/app.css
Normal file
File diff suppressed because it is too large
Load Diff
BIN
web/html/icons/apple-touch-icon.png
Normal file
BIN
web/html/icons/apple-touch-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.7 KiB |
BIN
web/html/icons/favicon.ico
Normal file
BIN
web/html/icons/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.5 KiB |
BIN
web/html/icons/icon-192.png
Normal file
BIN
web/html/icons/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 10 KiB |
BIN
web/html/icons/icon-512.png
Normal file
BIN
web/html/icons/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 24 KiB |
BIN
web/html/icons/icon-maskable-512.png
Normal file
BIN
web/html/icons/icon-maskable-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
13
web/html/icons/icon.svg
Normal file
13
web/html/icons/icon.svg
Normal file
@@ -0,0 +1,13 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" role="img" aria-label="Einkaufsliste">
|
||||
<rect width="512" height="512" rx="96" fill="#2f6f4e"/>
|
||||
<!-- Korb -->
|
||||
<path d="M112 200h288l-30 200a28 28 0 0 1-28 24H170a28 28 0 0 1-28-24z"
|
||||
fill="none" stroke="#ffffff" stroke-width="26" stroke-linejoin="round"/>
|
||||
<!-- Henkel -->
|
||||
<path d="M188 200v-24a68 68 0 0 1 136 0v24"
|
||||
fill="none" stroke="#ffffff" stroke-width="26" stroke-linecap="round"/>
|
||||
<!-- Haken -->
|
||||
<path d="M198 300l38 40 82-88"
|
||||
fill="none" stroke="#ffffff" stroke-width="30"
|
||||
stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 643 B |
33
web/html/index.html
Normal file
33
web/html/index.html
Normal file
@@ -0,0 +1,33 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||||
<meta name="color-scheme" content="light dark">
|
||||
<meta name="theme-color" content="#2f6f4e">
|
||||
<title>Einkaufsliste</title>
|
||||
|
||||
<link rel="manifest" href="/manifest.webmanifest">
|
||||
<link rel="icon" href="/icons/favicon.ico" sizes="any">
|
||||
<link rel="icon" href="/icons/icon.svg" type="image/svg+xml">
|
||||
<link rel="apple-touch-icon" href="/icons/apple-touch-icon.png">
|
||||
<link rel="stylesheet" href="/app.css">
|
||||
|
||||
<!-- Ohne diese Zeile öffnet iOS die App vom Startbildschirm im Safari-Rahmen -->
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
|
||||
<!-- Wird beim Start aus /api/config überschrieben. -->
|
||||
<meta name="apple-mobile-web-app-title" content="Einkaufsliste">
|
||||
</head>
|
||||
<body>
|
||||
<main id="app">
|
||||
<p class="loading">Wird geladen …</p>
|
||||
</main>
|
||||
|
||||
<noscript>
|
||||
<p class="error">Diese App benötigt JavaScript.</p>
|
||||
</noscript>
|
||||
|
||||
<script type="module" src="/js/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
85
web/html/js/api.js
Normal file
85
web/html/js/api.js
Normal file
@@ -0,0 +1,85 @@
|
||||
// Kommunikation mit der API. Einziger Ort, an dem fetch() aufgerufen wird.
|
||||
"use strict";
|
||||
|
||||
export class ApiError extends Error {
|
||||
constructor(status, detail) {
|
||||
super(detail);
|
||||
this.name = "ApiError";
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
export class OfflineError extends Error {
|
||||
constructor() {
|
||||
super("Keine Verbindung zum Server.");
|
||||
this.name = "OfflineError";
|
||||
}
|
||||
}
|
||||
|
||||
/** Das CSRF-Token steht in einem lesbaren Cookie. Das Session-Cookie ist
|
||||
* HttpOnly und für JavaScript unsichtbar - genau so soll es sein. */
|
||||
function csrfToken() {
|
||||
const hit = document.cookie
|
||||
.split(";")
|
||||
.map((c) => c.trim())
|
||||
.find((c) => c.startsWith("ea_csrf="));
|
||||
return hit ? decodeURIComponent(hit.slice("ea_csrf=".length)) : "";
|
||||
}
|
||||
|
||||
/** Übersetzt die Fehlerform von FastAPI in einen Satz für Menschen.
|
||||
* Bei Validierungsfehlern ist "detail" eine Liste, keine Zeichenkette. */
|
||||
function describe(status, data) {
|
||||
const detail = data && data.detail;
|
||||
if (typeof detail === "string") return detail;
|
||||
if (Array.isArray(detail) && detail.length) {
|
||||
const first = detail[0];
|
||||
const field = Array.isArray(first.loc) ? first.loc[first.loc.length - 1] : "";
|
||||
return field ? `Ungültige Eingabe bei "${field}": ${first.msg}` : first.msg;
|
||||
}
|
||||
if (status === 401) return "Nicht angemeldet.";
|
||||
if (status === 429) return "Zu viele Versuche. Bitte einen Moment warten.";
|
||||
if (status >= 500) return "Der Server hat einen Fehler gemeldet.";
|
||||
return `Unerwarteter Fehler (HTTP ${status}).`;
|
||||
}
|
||||
|
||||
export async function api(path, { method = "GET", body } = {}) {
|
||||
const headers = {};
|
||||
if (body !== undefined) headers["Content-Type"] = "application/json";
|
||||
if (method !== "GET" && method !== "HEAD") headers["X-CSRF-Token"] = csrfToken();
|
||||
|
||||
let response;
|
||||
try {
|
||||
response = await fetch(path, {
|
||||
method,
|
||||
headers,
|
||||
credentials: "same-origin",
|
||||
body: body === undefined ? undefined : JSON.stringify(body),
|
||||
});
|
||||
} catch {
|
||||
// Netzwerkfehler von HTTP-Fehlern trennen: In Phase 4 entscheidet
|
||||
// genau das, ob eine Änderung in die Outbox wandert.
|
||||
throw new OfflineError();
|
||||
}
|
||||
|
||||
const text = await response.text();
|
||||
let data = null;
|
||||
if (text) {
|
||||
try {
|
||||
data = JSON.parse(text);
|
||||
} catch {
|
||||
throw new ApiError(
|
||||
response.status,
|
||||
`Unerwartete Antwort vom Server (HTTP ${response.status}).`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!response.ok) throw new ApiError(response.status, describe(response.status, data));
|
||||
return data;
|
||||
}
|
||||
|
||||
export const get = (path) => api(path);
|
||||
export const post = (path, body) => api(path, { method: "POST", body });
|
||||
export const patch = (path, body) => api(path, { method: "PATCH", body });
|
||||
export const put = (path, body) => api(path, { method: "PUT", body });
|
||||
export const del = (path) => api(path, { method: "DELETE" });
|
||||
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();
|
||||
265
web/html/js/barcode.js
Normal file
265
web/html/js/barcode.js
Normal file
@@ -0,0 +1,265 @@
|
||||
// Decoder für EAN-13, EAN-8 und UPC-A. Ohne Fremdbibliothek.
|
||||
//
|
||||
// Warum selbst geschrieben: Die native BarcodeDetector-API gibt es nur in
|
||||
// Chrome und auf Android. Die übliche Alternative wäre ZXing - eine
|
||||
// 250-KB-Datei aus dem npm-Ökosystem. Für drei Strichcode-Formate, die
|
||||
// alle nach demselben simplen Schema arbeiten, ist das unverhältnismäßig.
|
||||
//
|
||||
// Verfahren: Aus einem Kamerabild werden mehrere waagerechte Linien
|
||||
// abgetastet, in Hell-Dunkel-Folgen zerlegt und gegen die Zifferntabellen
|
||||
// gehalten. Ein Ergebnis gilt erst als sicher, wenn es zweimal
|
||||
// unabhängig herauskommt und die Prüfziffer stimmt.
|
||||
"use strict";
|
||||
|
||||
// Jede Ziffer besteht aus 7 Modulen in 4 Streifen. Die Tabelle enthält
|
||||
// die Streifenbreiten der L-Kodierung; daraus lassen sich die beiden
|
||||
// anderen ableiten:
|
||||
// L Streifenbreiten, beginnend mit hell (linke Hälfte)
|
||||
// G dieselben Breiten rückwärts, hell (linke Hälfte)
|
||||
// R dieselben Breiten, beginnend mit dunkel (rechte Hälfte)
|
||||
const L_WIDTHS = [
|
||||
[3, 2, 1, 1], [2, 2, 2, 1], [2, 1, 2, 2], [1, 4, 1, 1], [1, 1, 3, 2],
|
||||
[1, 2, 3, 1], [1, 1, 1, 4], [1, 3, 1, 2], [1, 2, 1, 3], [3, 1, 1, 2],
|
||||
];
|
||||
|
||||
// Welche Abfolge von L und G in der linken Hälfte steht, verrät die
|
||||
// erste Ziffer - sie ist selbst nicht als Streifen kodiert.
|
||||
const PARITY = [
|
||||
"LLLLLL", "LLGLGG", "LLGGLG", "LLGGGL", "LGLLGG",
|
||||
"LGGLLG", "LGGGLL", "LGLGLG", "LGLGGL", "LGGLGL",
|
||||
];
|
||||
|
||||
const MAX_DEVIATION = 0.42; // erlaubte Abweichung je Streifen, in Modulen
|
||||
|
||||
/** Vergleicht vier gemessene Streifen mit einem Sollmuster.
|
||||
* Verglichen werden Verhältnisse, keine absoluten Breiten - dadurch
|
||||
* spielt es keine Rolle, wie weit die Kamera vom Code entfernt ist. */
|
||||
function patternDistance(counters, pattern) {
|
||||
const total = counters[0] + counters[1] + counters[2] + counters[3];
|
||||
if (total <= 0) return Infinity;
|
||||
const unit = total / 7;
|
||||
// Zu schmale Streifen deuten auf Rauschen statt auf einen Strichcode.
|
||||
if (unit < 0.6) return Infinity;
|
||||
|
||||
let sum = 0;
|
||||
for (let i = 0; i < 4; i++) {
|
||||
const deviation = Math.abs(counters[i] / unit - pattern[i]);
|
||||
if (deviation > MAX_DEVIATION * 2) return Infinity;
|
||||
sum += deviation;
|
||||
}
|
||||
return sum;
|
||||
}
|
||||
|
||||
/** @returns {{digit: number, code: "L"|"G"|"R"}|null} */
|
||||
function matchDigit(counters, half) {
|
||||
let best = null;
|
||||
let bestDistance = MAX_DEVIATION * 4;
|
||||
|
||||
for (let digit = 0; digit < 10; digit++) {
|
||||
if (half === "left") {
|
||||
const dL = patternDistance(counters, L_WIDTHS[digit]);
|
||||
if (dL < bestDistance) { bestDistance = dL; best = { digit, code: "L" }; }
|
||||
|
||||
const reversed = [...L_WIDTHS[digit]].reverse();
|
||||
const dG = patternDistance(counters, reversed);
|
||||
if (dG < bestDistance) { bestDistance = dG; best = { digit, code: "G" }; }
|
||||
} else {
|
||||
const dR = patternDistance(counters, L_WIDTHS[digit]);
|
||||
if (dR < bestDistance) { bestDistance = dR; best = { digit, code: "R" }; }
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/** Prüfziffer nach dem Modulo-10-Verfahren. Gilt für EAN-13, EAN-8
|
||||
* und UPC-A gleichermaßen - nur die Gewichtung beginnt je nach Länge
|
||||
* bei 1 oder 3. */
|
||||
export function checksumValid(code) {
|
||||
const digits = [...code].map(Number);
|
||||
if (digits.some(Number.isNaN)) return false;
|
||||
|
||||
const check = digits.pop();
|
||||
let sum = 0;
|
||||
// Von rechts nach links abwechselnd ×3 und ×1.
|
||||
for (let i = digits.length - 1, weight = 3; i >= 0; i--, weight = 4 - weight) {
|
||||
sum += digits[i] * weight;
|
||||
}
|
||||
return (10 - (sum % 10)) % 10 === check;
|
||||
}
|
||||
|
||||
/** Zerlegt eine Helligkeitszeile in Streifenlängen.
|
||||
* Der Schwellwert wird je Zeile aus deren eigenem Hell-Dunkel-Umfang
|
||||
* gebildet - so stört ein Schatten über dem halben Bild nicht. */
|
||||
function toRuns(line) {
|
||||
let min = 255;
|
||||
let max = 0;
|
||||
for (const value of line) {
|
||||
if (value < min) min = value;
|
||||
if (value > max) max = value;
|
||||
}
|
||||
// Zu wenig Kontrast: da ist kein Strichcode.
|
||||
if (max - min < 40) return null;
|
||||
|
||||
const threshold = (min + max) / 2;
|
||||
const runs = [];
|
||||
let dark = line[0] < threshold;
|
||||
let length = 0;
|
||||
|
||||
for (const value of line) {
|
||||
const isDark = value < threshold;
|
||||
if (isDark === dark) {
|
||||
length++;
|
||||
} else {
|
||||
runs.push({ dark, length });
|
||||
dark = isDark;
|
||||
length = 1;
|
||||
}
|
||||
}
|
||||
runs.push({ dark, length });
|
||||
return runs;
|
||||
}
|
||||
|
||||
// Vor dem Startzeichen verlangt die Norm eine helle Ruhezone von elf
|
||||
// Modulen. Diese Prüfung ist der wirksamste Schutz gegen Fehltreffer:
|
||||
// In zufälligem Rauschen gibt es keine so breiten hellen Flächen, in
|
||||
// einem echten Kamerabild dagegen immer. Konservativ angesetzt, weil
|
||||
// der Rand bei knapper Bildausschnittwahl auch mal kürzer ausfällt.
|
||||
const MIN_QUIET_ZONE = 3.5;
|
||||
|
||||
/** Versucht ab einem dunklen Streifen einen vollständigen Code zu lesen. */
|
||||
function decodeAt(runs, start, digitCount) {
|
||||
// digitCount: 13 -> je 6 Ziffern pro Hälfte, 8 -> je 4
|
||||
const perHalf = digitCount === 13 ? 6 : 4;
|
||||
|
||||
// Startzeichen: drei Streifen von je einem Modul.
|
||||
const guard = [runs[start], runs[start + 1], runs[start + 2]];
|
||||
if (guard.some((r) => r === undefined)) return null;
|
||||
const unit = (guard[0].length + guard[1].length + guard[2].length) / 3;
|
||||
if (unit < 0.7) return null;
|
||||
for (const r of guard) {
|
||||
if (Math.abs(r.length / unit - 1) > MAX_DEVIATION) return null;
|
||||
}
|
||||
|
||||
// Ruhezone davor
|
||||
const before = runs[start - 1];
|
||||
if (start > 0 && (before.dark || before.length < unit * MIN_QUIET_ZONE)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let index = start + 3;
|
||||
const digits = [];
|
||||
let parity = "";
|
||||
|
||||
for (let i = 0; i < perHalf; i++) {
|
||||
const counters = [
|
||||
runs[index]?.length, runs[index + 1]?.length,
|
||||
runs[index + 2]?.length, runs[index + 3]?.length,
|
||||
];
|
||||
if (counters.some((c) => c === undefined)) return null;
|
||||
|
||||
const match = matchDigit(counters, "left");
|
||||
if (!match) return null;
|
||||
digits.push(match.digit);
|
||||
parity += match.code === "R" ? "L" : match.code;
|
||||
index += 4;
|
||||
}
|
||||
|
||||
// Trennzeichen in der Mitte: fünf Streifen von je einem Modul.
|
||||
for (let i = 0; i < 5; i++) {
|
||||
const run = runs[index + i];
|
||||
if (!run) return null;
|
||||
if (Math.abs(run.length / unit - 1) > MAX_DEVIATION * 1.5) return null;
|
||||
}
|
||||
index += 5;
|
||||
|
||||
for (let i = 0; i < perHalf; i++) {
|
||||
const counters = [
|
||||
runs[index]?.length, runs[index + 1]?.length,
|
||||
runs[index + 2]?.length, runs[index + 3]?.length,
|
||||
];
|
||||
if (counters.some((c) => c === undefined)) return null;
|
||||
|
||||
const match = matchDigit(counters, "right");
|
||||
if (!match) return null;
|
||||
digits.push(match.digit);
|
||||
index += 4;
|
||||
}
|
||||
|
||||
// Schlusszeichen
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const run = runs[index + i];
|
||||
if (!run) return null;
|
||||
if (Math.abs(run.length / unit - 1) > MAX_DEVIATION * 1.5) return null;
|
||||
}
|
||||
|
||||
// Ruhezone dahinter - oder das Ende der abgetasteten Zeile.
|
||||
const after = runs[index + 3];
|
||||
if (after && (after.dark || after.length < unit * MIN_QUIET_ZONE)) return null;
|
||||
|
||||
let code;
|
||||
if (digitCount === 13) {
|
||||
const first = PARITY.indexOf(parity);
|
||||
if (first < 0) return null;
|
||||
code = String(first) + digits.join("");
|
||||
} else {
|
||||
code = digits.join("");
|
||||
}
|
||||
|
||||
return checksumValid(code) ? code : null;
|
||||
}
|
||||
|
||||
/** Liest eine einzelne Helligkeitszeile. */
|
||||
export function decodeLine(line) {
|
||||
const runs = toRuns(line);
|
||||
if (!runs || runs.length < 30) return null;
|
||||
|
||||
for (const direction of ["forward", "backward"]) {
|
||||
// Ein auf dem Kopf stehender Code liest sich rückwärts genauso.
|
||||
const sequence = direction === "forward" ? runs : [...runs].reverse();
|
||||
|
||||
for (let i = 0; i < sequence.length - 20; i++) {
|
||||
if (!sequence[i].dark) continue;
|
||||
for (const digitCount of [13, 8]) {
|
||||
const code = decodeAt(sequence, i, digitCount);
|
||||
if (code) return code;
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sucht in einem Bild nach einem Strichcode.
|
||||
*
|
||||
* @param {ImageData} imageData
|
||||
* @param {number} lines Anzahl waagerechter Abtastlinien
|
||||
* @returns {string|null}
|
||||
*/
|
||||
export function decodeImage(imageData, lines = 21) {
|
||||
const { width, height, data } = imageData;
|
||||
const results = new Map();
|
||||
|
||||
for (let n = 0; n < lines; n++) {
|
||||
// Linien über die mittleren zwei Drittel verteilen: Dort liegt der
|
||||
// Code, wenn der Nutzer ihn im Sucherrahmen hält.
|
||||
const y = Math.round(height * (1 / 6 + (2 / 3) * (n / (lines - 1 || 1))));
|
||||
const row = new Uint8Array(width);
|
||||
const offset = y * width * 4;
|
||||
|
||||
for (let x = 0; x < width; x++) {
|
||||
const p = offset + x * 4;
|
||||
// Grauwert nach Wahrnehmungsgewichtung.
|
||||
row[x] = (data[p] * 77 + data[p + 1] * 150 + data[p + 2] * 29) >> 8;
|
||||
}
|
||||
|
||||
const code = decodeLine(row);
|
||||
if (!code) continue;
|
||||
|
||||
const count = (results.get(code) || 0) + 1;
|
||||
// Erst wenn zwei Linien dasselbe ergeben, gilt es als sicher. Ein
|
||||
// Einzeltreffer kann von Rauschen kommen.
|
||||
if (count >= 2) return code;
|
||||
results.set(code, count);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
151
web/html/js/db.js
Normal file
151
web/html/js/db.js
Normal file
@@ -0,0 +1,151 @@
|
||||
// Lokaler Speicher auf Basis von IndexedDB.
|
||||
//
|
||||
// Warum nicht localStorage: Der ist synchron (blockiert die Oberfläche),
|
||||
// auf wenige Megabyte begrenzt und kennt keine Transaktionen. Für eine
|
||||
// Warteschlange, die auch einen Absturz überleben muss, ist er ungeeignet.
|
||||
"use strict";
|
||||
|
||||
const DB_NAME = "einkaufsapp";
|
||||
const DB_VERSION = 1;
|
||||
|
||||
const STORE_CACHE = "cache"; // Schlüssel -> zuletzt gesehener Serverstand
|
||||
const STORE_OUTBOX = "outbox"; // noch nicht bestätigte Operationen
|
||||
|
||||
let dbPromise = null;
|
||||
|
||||
function open() {
|
||||
if (dbPromise) return dbPromise;
|
||||
|
||||
dbPromise = new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
||||
|
||||
request.onupgradeneeded = () => {
|
||||
const db = request.result;
|
||||
if (!db.objectStoreNames.contains(STORE_CACHE)) {
|
||||
db.createObjectStore(STORE_CACHE);
|
||||
}
|
||||
if (!db.objectStoreNames.contains(STORE_OUTBOX)) {
|
||||
const store = db.createObjectStore(STORE_OUTBOX, { keyPath: "op_id" });
|
||||
// Reihenfolge je Liste: Operationen müssen in der Folge ankommen,
|
||||
// in der sie entstanden sind.
|
||||
store.createIndex("by_list_seq", ["list_id", "seq"]);
|
||||
}
|
||||
};
|
||||
|
||||
request.onsuccess = () => resolve(request.result);
|
||||
request.onerror = () => reject(request.error);
|
||||
request.onblocked = () =>
|
||||
reject(new Error("Datenbank blockiert – bitte andere Tabs schließen."));
|
||||
});
|
||||
|
||||
return dbPromise;
|
||||
}
|
||||
|
||||
function run(storeName, mode, fn) {
|
||||
return open().then((db) => new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(storeName, mode);
|
||||
const store = tx.objectStore(storeName);
|
||||
let result;
|
||||
try {
|
||||
result = fn(store);
|
||||
} catch (err) {
|
||||
reject(err);
|
||||
return;
|
||||
}
|
||||
tx.oncomplete = () => resolve(result && result.__req ? result.__req.result : result);
|
||||
tx.onerror = () => reject(tx.error);
|
||||
tx.onabort = () => reject(tx.error || new Error("Transaktion abgebrochen"));
|
||||
}));
|
||||
}
|
||||
|
||||
function wrap(request) {
|
||||
return { __req: request };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Zwischenspeicher
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function cacheGet(key) {
|
||||
return run(STORE_CACHE, "readonly", (store) => wrap(store.get(key)));
|
||||
}
|
||||
|
||||
export function cacheSet(key, value) {
|
||||
return run(STORE_CACHE, "readwrite", (store) => wrap(store.put(value, key)));
|
||||
}
|
||||
|
||||
export function cacheDelete(key) {
|
||||
return run(STORE_CACHE, "readwrite", (store) => wrap(store.delete(key)));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Outbox
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Zufällige, praktisch eindeutige Kennung. crypto.randomUUID gibt es
|
||||
* nur in sicheren Kontexten (HTTPS oder localhost) - deshalb der
|
||||
* Rückfallweg. */
|
||||
export function newOpId() {
|
||||
if (globalThis.crypto?.randomUUID) return crypto.randomUUID();
|
||||
const bytes = new Uint8Array(16);
|
||||
(globalThis.crypto || {}).getRandomValues?.(bytes);
|
||||
return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("")
|
||||
|| `${Date.now()}-${Math.random().toString(16).slice(2)}`;
|
||||
}
|
||||
|
||||
let seqCounter = Date.now();
|
||||
|
||||
export function enqueue(listId, kind, payload) {
|
||||
const op = {
|
||||
op_id: newOpId(),
|
||||
list_id: listId,
|
||||
kind,
|
||||
payload,
|
||||
seq: seqCounter++,
|
||||
created_at: new Date().toISOString(),
|
||||
attempts: 0,
|
||||
};
|
||||
return run(STORE_OUTBOX, "readwrite", (store) => {
|
||||
store.add(op);
|
||||
return op;
|
||||
});
|
||||
}
|
||||
|
||||
export function pending(listId) {
|
||||
return run(STORE_OUTBOX, "readonly", (store) => {
|
||||
const index = store.index("by_list_seq");
|
||||
const range = IDBKeyRange.bound([listId, -Infinity], [listId, Infinity]);
|
||||
return wrap(index.getAll(range));
|
||||
});
|
||||
}
|
||||
|
||||
export function pendingAll() {
|
||||
return run(STORE_OUTBOX, "readonly", (store) => wrap(store.getAll()));
|
||||
}
|
||||
|
||||
export function remove(opIds) {
|
||||
return run(STORE_OUTBOX, "readwrite", (store) => {
|
||||
for (const id of opIds) store.delete(id);
|
||||
});
|
||||
}
|
||||
|
||||
export function bumpAttempts(opIds) {
|
||||
return run(STORE_OUTBOX, "readwrite", (store) => {
|
||||
for (const id of opIds) {
|
||||
const request = store.get(id);
|
||||
request.onsuccess = () => {
|
||||
const op = request.result;
|
||||
if (op) {
|
||||
op.attempts = (op.attempts || 0) + 1;
|
||||
op.last_error_at = new Date().toISOString();
|
||||
store.put(op);
|
||||
}
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export async function clearList(listId) {
|
||||
const ops = await pending(listId);
|
||||
await remove(ops.map((o) => o.op_id));
|
||||
}
|
||||
104
web/html/js/dom.js
Normal file
104
web/html/js/dom.js
Normal file
@@ -0,0 +1,104 @@
|
||||
// Kleine DOM-Hilfen. Ersetzt kein Framework, spart nur Wiederholung.
|
||||
"use strict";
|
||||
|
||||
/** el("button.primary", { onclick: fn }, "Text")
|
||||
* Setzt Text ausschließlich über textContent - dadurch kann kein
|
||||
* Benutzereingabewert als HTML interpretiert werden. */
|
||||
export function el(spec, props = {}, ...children) {
|
||||
const [tag, ...classes] = String(spec).split(".");
|
||||
const node = document.createElement(tag || "div");
|
||||
if (classes.length) node.className = classes.join(" ");
|
||||
|
||||
for (const [key, value] of Object.entries(props)) {
|
||||
if (value === null || value === undefined || value === false) continue;
|
||||
if (key.startsWith("on") && typeof value === "function") {
|
||||
node.addEventListener(key.slice(2).toLowerCase(), value);
|
||||
} else if (key === "dataset") {
|
||||
Object.assign(node.dataset, value);
|
||||
} else if (key in node && key !== "list") {
|
||||
node[key] = value;
|
||||
} else {
|
||||
node.setAttribute(key, value === true ? "" : value);
|
||||
}
|
||||
}
|
||||
|
||||
append(node, children);
|
||||
return node;
|
||||
}
|
||||
|
||||
function append(node, children) {
|
||||
for (const child of children.flat(Infinity)) {
|
||||
if (child === null || child === undefined || child === false) continue;
|
||||
node.append(child instanceof Node ? child : document.createTextNode(String(child)));
|
||||
}
|
||||
}
|
||||
|
||||
export function clear(node) {
|
||||
node.replaceChildren();
|
||||
return node;
|
||||
}
|
||||
|
||||
/** Ersetzt den Inhalt eines Knotens.
|
||||
*
|
||||
* Nicht clear(root).append(...) verwenden: Node.append() macht aus einem
|
||||
* null die Zeichenkette "null" und zeigt sie an. Genau dafür filtert
|
||||
* el() seine Kinder - mount() zieht denselben Schutz auf die Wurzel. */
|
||||
export function mount(node, ...children) {
|
||||
node.replaceChildren();
|
||||
for (const child of children.flat(Infinity)) {
|
||||
if (child === null || child === undefined || child === false) continue;
|
||||
node.append(child instanceof Node ? child : document.createTextNode(String(child)));
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
/** Preis in Cent -> "1,29 €". Rechnen immer in Cent, formatieren erst hier. */
|
||||
export function euro(cents) {
|
||||
return new Intl.NumberFormat("de-DE", {
|
||||
style: "currency",
|
||||
currency: "EUR",
|
||||
}).format((cents || 0) / 100);
|
||||
}
|
||||
|
||||
/** "1,29" oder "1.29" -> 129. Gibt null zurück, wenn nichts Sinnvolles drinsteht. */
|
||||
export function parseCents(text) {
|
||||
const cleaned = String(text).trim().replace(",", ".");
|
||||
if (!cleaned) return null;
|
||||
const value = Number(cleaned);
|
||||
if (!Number.isFinite(value) || value < 0) return null;
|
||||
return Math.round(value * 100);
|
||||
}
|
||||
|
||||
export function parseQuantity(text) {
|
||||
const cleaned = String(text).trim().replace(",", ".");
|
||||
if (!cleaned) return null;
|
||||
const value = Number(cleaned);
|
||||
return Number.isFinite(value) && value >= 0 ? value : null;
|
||||
}
|
||||
|
||||
/** Stückzahl: ganze Zahl ab 1. Leer oder unsinnig ergibt 1 - ein Eintrag
|
||||
* ohne Stückzahl wäre unvollständig. */
|
||||
export function parseCount(text) {
|
||||
const value = Number(String(text).trim().replace(",", "."));
|
||||
if (!Number.isFinite(value) || value < 1) return 1;
|
||||
return Math.min(999, Math.round(value));
|
||||
}
|
||||
|
||||
/** Nur das Gebinde, ohne Stückzahl.
|
||||
*
|
||||
* Wird in der Listenzeile gebraucht, weil die Stückzahl dort als
|
||||
* eigenes Feld links neben dem Namen steht - beim Einkaufen soll sie
|
||||
* ins Auge springen und nicht im Kleingedruckten stehen. */
|
||||
export function formatPack(packSize, packUnit) {
|
||||
if (!packSize) return "";
|
||||
return `${formatQuantity(packSize)}${packUnit ? " " + packUnit : ""}`;
|
||||
}
|
||||
|
||||
/** Menge aus der API kommt als Zeichenkette ("2.000"), damit unterwegs
|
||||
* keine Nachkommastellen verloren gehen. Für die Anzeige aufräumen. */
|
||||
export function formatQuantity(value) {
|
||||
if (value === null || value === undefined) return "";
|
||||
const num = Number(value);
|
||||
if (!Number.isFinite(num)) return String(value);
|
||||
return num.toLocaleString("de-DE", { maximumFractionDigits: 3 });
|
||||
}
|
||||
165
web/html/js/push.js
Normal file
165
web/html/js/push.js
Normal file
@@ -0,0 +1,165 @@
|
||||
// An- und Abmeldung für Push-Benachrichtigungen.
|
||||
//
|
||||
// Ablauf: Der Browser holt sich beim Push-Dienst seines Herstellers einen
|
||||
// Endpunkt, den er zusammen mit Schlüsselmaterial an unseren Server
|
||||
// meldet. Der Server kann darüber verschlüsselte Nachrichten schicken -
|
||||
// lesen kann sie nur dieses Gerät, auch der Push-Dienst nicht.
|
||||
"use strict";
|
||||
|
||||
import { get, post } from "./api.js";
|
||||
|
||||
/** Der öffentliche VAPID-Schlüssel kommt als base64url ohne
|
||||
* Auffüllzeichen; die Browser-API will ein Uint8Array. */
|
||||
function decodeKey(base64url) {
|
||||
const padded = base64url.replace(/-/g, "+").replace(/_/g, "/");
|
||||
const raw = atob(padded + "=".repeat((4 - (padded.length % 4)) % 4));
|
||||
return Uint8Array.from(raw, (c) => c.charCodeAt(0));
|
||||
}
|
||||
|
||||
/** Kurze Gerätebezeichnung, damit man mehrere Anmeldungen unterscheiden
|
||||
* kann. Bewusst grob - der vollständige User-Agent wäre ein
|
||||
* Wiedererkennungsmerkmal und wird nicht gebraucht. */
|
||||
function deviceLabel() {
|
||||
const ua = navigator.userAgent;
|
||||
const system =
|
||||
/Android/i.test(ua) ? "Android"
|
||||
: /iPhone|iPad|iPod/i.test(ua) ? "iPhone/iPad"
|
||||
: /Macintosh/i.test(ua) ? "Mac"
|
||||
: /Windows/i.test(ua) ? "Windows"
|
||||
: /Linux/i.test(ua) ? "Linux"
|
||||
: "Gerät";
|
||||
const browser =
|
||||
/Firefox\//i.test(ua) ? "Firefox"
|
||||
: /Edg\//i.test(ua) ? "Edge"
|
||||
: /Chrome\//i.test(ua) ? "Chrome"
|
||||
: /Safari\//i.test(ua) ? "Safari"
|
||||
: "Browser";
|
||||
return `${browser} auf ${system}`.slice(0, 80);
|
||||
}
|
||||
|
||||
/** Läuft die App als installierte PWA? Auf iOS ist das die
|
||||
* Voraussetzung dafür, dass Push überhaupt funktioniert. */
|
||||
export function isInstalled() {
|
||||
return (
|
||||
window.matchMedia?.("(display-mode: standalone)")?.matches === true ||
|
||||
navigator.standalone === true
|
||||
);
|
||||
}
|
||||
|
||||
export function isApple() {
|
||||
return /iPhone|iPad|iPod/i.test(navigator.userAgent) ||
|
||||
(/Macintosh/i.test(navigator.userAgent) && navigator.maxTouchPoints > 1);
|
||||
}
|
||||
|
||||
/** Was der Browser hier grundsätzlich kann. */
|
||||
export function supported() {
|
||||
return (
|
||||
"serviceWorker" in navigator &&
|
||||
"PushManager" in window &&
|
||||
"Notification" in window &&
|
||||
window.isSecureContext
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Aktueller Zustand.
|
||||
* @returns {Promise<{
|
||||
* supported: boolean, serverEnabled: boolean, permission: string,
|
||||
* subscribed: boolean, needsInstall: boolean, throttleHours: number
|
||||
* }>}
|
||||
*/
|
||||
export async function status() {
|
||||
const base = {
|
||||
supported: supported(),
|
||||
serverEnabled: false,
|
||||
permission: "Notification" in window ? Notification.permission : "unsupported",
|
||||
subscribed: false,
|
||||
// iOS lässt Push nur in der installierten App zu.
|
||||
needsInstall: isApple() && !isInstalled(),
|
||||
throttleHours: 2,
|
||||
};
|
||||
|
||||
try {
|
||||
const config = await get("/api/push/config");
|
||||
base.serverEnabled = config.enabled;
|
||||
base.throttleHours = config.throttle_hours;
|
||||
} catch {
|
||||
return base;
|
||||
}
|
||||
|
||||
if (!base.supported) return base;
|
||||
|
||||
try {
|
||||
const registration = await navigator.serviceWorker.ready;
|
||||
base.subscribed = Boolean(await registration.pushManager.getSubscription());
|
||||
} catch {
|
||||
// Kein Service Worker bereit - dann eben nicht angemeldet.
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
/** Fragt die Erlaubnis ab und meldet das Gerät an.
|
||||
* @returns {Promise<{ok: boolean, reason?: string}>} */
|
||||
export async function subscribe() {
|
||||
if (!supported()) {
|
||||
return { ok: false, reason: "Dieser Browser unterstützt keine Benachrichtigungen." };
|
||||
}
|
||||
|
||||
const config = await get("/api/push/config");
|
||||
if (!config.enabled || !config.public_key) {
|
||||
return { ok: false, reason: "Auf diesem Server sind Benachrichtigungen nicht eingerichtet." };
|
||||
}
|
||||
|
||||
const permission = await Notification.requestPermission();
|
||||
if (permission !== "granted") {
|
||||
return {
|
||||
ok: false,
|
||||
reason: permission === "denied"
|
||||
? "Benachrichtigungen wurden für diese Seite abgelehnt. Das lässt sich nur in den Browsereinstellungen wieder ändern."
|
||||
: "Keine Erlaubnis erteilt.",
|
||||
};
|
||||
}
|
||||
|
||||
const registration = await navigator.serviceWorker.ready;
|
||||
let subscription = await registration.pushManager.getSubscription();
|
||||
|
||||
if (!subscription) {
|
||||
subscription = await registration.pushManager.subscribe({
|
||||
// Pflicht bei allen aktuellen Browsern: Nachrichten ohne
|
||||
// sichtbare Meldung sind nicht erlaubt.
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: decodeKey(config.public_key),
|
||||
});
|
||||
}
|
||||
|
||||
const json = subscription.toJSON();
|
||||
await post("/api/push/subscribe", {
|
||||
endpoint: json.endpoint,
|
||||
keys: { p256dh: json.keys.p256dh, auth: json.keys.auth },
|
||||
label: deviceLabel(),
|
||||
});
|
||||
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
/** Meldet dieses Gerät ab - lokal und auf dem Server. */
|
||||
export async function unsubscribe() {
|
||||
if (!supported()) return;
|
||||
const registration = await navigator.serviceWorker.ready;
|
||||
const subscription = await registration.pushManager.getSubscription();
|
||||
if (!subscription) return;
|
||||
|
||||
const endpoint = subscription.endpoint;
|
||||
// Erst lokal abmelden: Schlägt der Server fehl, sollen trotzdem keine
|
||||
// Meldungen mehr ankommen.
|
||||
await subscription.unsubscribe().catch(() => {});
|
||||
await post("/api/push/unsubscribe", { endpoint }).catch(() => {});
|
||||
}
|
||||
|
||||
export function sendTest() {
|
||||
return post("/api/push/test");
|
||||
}
|
||||
|
||||
export function listDevices() {
|
||||
return get("/api/push/subscriptions");
|
||||
}
|
||||
130
web/html/js/sortable.js
Normal file
130
web/html/js/sortable.js
Normal file
@@ -0,0 +1,130 @@
|
||||
// Zeilen per Anfasser umsortieren, ohne Fremdbibliothek.
|
||||
//
|
||||
// Umgesetzt mit Pointer Events statt der HTML5-Drag-and-Drop-API: Letztere
|
||||
// funktioniert auf Touchgeräten praktisch nicht, und die App wird
|
||||
// überwiegend am Telefon bedient.
|
||||
//
|
||||
// Verfahren: Das gezogene Element folgt dem Finger per transform. Sobald
|
||||
// es den Mittelpunkt eines Nachbarn überschreitet, wird es im DOM davor
|
||||
// oder dahinter einsortiert und der Bezugspunkt zurückgesetzt - dadurch
|
||||
// springt nichts.
|
||||
"use strict";
|
||||
|
||||
const DRAG_THRESHOLD = 4; // Pixel, bevor aus einem Tippen ein Ziehen wird
|
||||
|
||||
/**
|
||||
* @param {HTMLElement} container Elternknoten der sortierbaren Zeilen
|
||||
* @param {object} options
|
||||
* @param {string} options.handleSelector Auswahl für den Anfasser
|
||||
* @param {string} options.itemSelector Auswahl für eine Zeile
|
||||
* @param {(ids: string[]) => void} options.onReorder neue Reihenfolge
|
||||
*/
|
||||
export function makeSortable(container, { handleSelector, itemSelector, onReorder }) {
|
||||
let dragging = null;
|
||||
let startY = 0;
|
||||
let offset = 0;
|
||||
let moved = false;
|
||||
let originalOrder = [];
|
||||
|
||||
const rows = () => [...container.querySelectorAll(itemSelector)];
|
||||
const idsOf = () => rows().map((r) => r.dataset.id);
|
||||
|
||||
function begin(ev) {
|
||||
const handle = ev.target.closest(handleSelector);
|
||||
if (!handle || !container.contains(handle)) return;
|
||||
if (ev.button !== undefined && ev.button !== 0) return;
|
||||
|
||||
dragging = handle.closest(itemSelector);
|
||||
if (!dragging) return;
|
||||
|
||||
startY = ev.clientY;
|
||||
offset = 0;
|
||||
moved = false;
|
||||
originalOrder = idsOf();
|
||||
|
||||
handle.setPointerCapture(ev.pointerId);
|
||||
ev.preventDefault();
|
||||
}
|
||||
|
||||
function move(ev) {
|
||||
if (!dragging) return;
|
||||
|
||||
offset = ev.clientY - startY;
|
||||
if (!moved && Math.abs(offset) < DRAG_THRESHOLD) return;
|
||||
|
||||
if (!moved) {
|
||||
moved = true;
|
||||
dragging.classList.add("dragging");
|
||||
container.classList.add("sorting");
|
||||
}
|
||||
|
||||
dragging.style.transform = `translateY(${offset}px)`;
|
||||
|
||||
const box = dragging.getBoundingClientRect();
|
||||
const middle = box.top + box.height / 2;
|
||||
|
||||
const previous = dragging.previousElementSibling;
|
||||
const next = dragging.nextElementSibling;
|
||||
|
||||
if (previous && middle < previous.getBoundingClientRect().top + previous.offsetHeight / 2) {
|
||||
container.insertBefore(dragging, previous);
|
||||
reanchor(ev);
|
||||
} else if (next && middle > next.getBoundingClientRect().top + next.offsetHeight / 2) {
|
||||
container.insertBefore(next, dragging);
|
||||
reanchor(ev);
|
||||
}
|
||||
}
|
||||
|
||||
/** Nach einem Umsortieren sitzt das Element an neuer Stelle. Ohne das
|
||||
* Zurücksetzen des Bezugspunkts würde es um seine eigene Höhe springen. */
|
||||
function reanchor(ev) {
|
||||
startY = ev.clientY;
|
||||
offset = 0;
|
||||
dragging.style.transform = "";
|
||||
}
|
||||
|
||||
function end() {
|
||||
if (!dragging) return;
|
||||
|
||||
dragging.style.transform = "";
|
||||
dragging.classList.remove("dragging");
|
||||
container.classList.remove("sorting");
|
||||
|
||||
const wasMoved = moved;
|
||||
dragging = null;
|
||||
moved = false;
|
||||
|
||||
if (!wasMoved) return;
|
||||
|
||||
const order = idsOf();
|
||||
if (order.join() !== originalOrder.join()) onReorder(order);
|
||||
}
|
||||
|
||||
container.addEventListener("pointerdown", begin);
|
||||
container.addEventListener("pointermove", move);
|
||||
container.addEventListener("pointerup", end);
|
||||
container.addEventListener("pointercancel", end);
|
||||
|
||||
// Bedienbar auch ohne Zeigegerät: Anfasser fokussieren, dann Pfeiltasten.
|
||||
container.addEventListener("keydown", (ev) => {
|
||||
const handle = ev.target.closest(handleSelector);
|
||||
if (!handle) return;
|
||||
if (ev.key !== "ArrowUp" && ev.key !== "ArrowDown") return;
|
||||
|
||||
const row = handle.closest(itemSelector);
|
||||
const before = idsOf();
|
||||
|
||||
if (ev.key === "ArrowUp" && row.previousElementSibling) {
|
||||
container.insertBefore(row, row.previousElementSibling);
|
||||
} else if (ev.key === "ArrowDown" && row.nextElementSibling) {
|
||||
container.insertBefore(row.nextElementSibling, row);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
ev.preventDefault();
|
||||
handle.focus();
|
||||
const after = idsOf();
|
||||
if (after.join() !== before.join()) onReorder(after);
|
||||
});
|
||||
}
|
||||
51
web/html/js/store.js
Normal file
51
web/html/js/store.js
Normal file
@@ -0,0 +1,51 @@
|
||||
// Ein einziger Zustandsspeicher. Ansichten lesen daraus und zeichnen sich
|
||||
// neu, wenn er sich ändert - niemand fasst fremde DOM-Knoten an.
|
||||
//
|
||||
// Das ist im Kern das, was ein Framework auch tut. Der Unterschied ist,
|
||||
// dass hier nichts passiert, was nicht in dieser Datei steht.
|
||||
"use strict";
|
||||
|
||||
const listeners = new Set();
|
||||
|
||||
export const state = {
|
||||
/** Aus /api/config, mit Rückfallwert für den ersten Start ohne Netz. */
|
||||
appName: "Einkaufsliste",
|
||||
user: null,
|
||||
lists: [],
|
||||
/** Ansicht der geöffneten Liste, so wie /view sie liefert. */
|
||||
view: null,
|
||||
markets: [],
|
||||
categories: [],
|
||||
online: navigator.onLine,
|
||||
/** "ok" | "offline" | "error" */
|
||||
syncState: navigator.onLine ? "ok" : "offline",
|
||||
/** Einmalige Meldung aus der Synchronisation, wird von der Ansicht
|
||||
* abgeholt und zurückgesetzt. */
|
||||
syncProblem: null,
|
||||
lastSync: null,
|
||||
};
|
||||
|
||||
export function subscribe(fn) {
|
||||
listeners.add(fn);
|
||||
return () => listeners.delete(fn);
|
||||
}
|
||||
|
||||
/** Nach jeder Zustandsänderung genau einmal aufrufen. Mehrere Aufrufe im
|
||||
* selben Tick werden zusammengefasst, damit nicht mehrfach gezeichnet wird. */
|
||||
let scheduled = false;
|
||||
export function notify() {
|
||||
if (scheduled) return;
|
||||
scheduled = true;
|
||||
queueMicrotask(() => {
|
||||
scheduled = false;
|
||||
for (const fn of listeners) fn(state);
|
||||
});
|
||||
}
|
||||
|
||||
export function set(patch) {
|
||||
Object.assign(state, patch);
|
||||
notify();
|
||||
}
|
||||
|
||||
window.addEventListener("online", () => set({ online: true }));
|
||||
window.addEventListener("offline", () => set({ online: false }));
|
||||
349
web/html/js/sync.js
Normal file
349
web/html/js/sync.js
Normal file
@@ -0,0 +1,349 @@
|
||||
// Synchronisation: Outbox senden, Ansicht holen, Ereigniskanal halten.
|
||||
//
|
||||
// Grundgedanke: Der Server ist die Quelle der Wahrheit. Lokal liegt sein
|
||||
// zuletzt gesehener Stand plus die noch nicht bestätigten eigenen
|
||||
// Operationen. Angezeigt wird beides übereinandergelegt.
|
||||
"use strict";
|
||||
|
||||
import { OfflineError, get, post } from "./api.js";
|
||||
import * as db from "./db.js";
|
||||
import { set, state } from "./store.js";
|
||||
|
||||
const MAX_ATTEMPTS = 8;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Operationen lokal auf die zwischengespeicherte Ansicht anwenden
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function allItems(view) {
|
||||
return view.markets.flatMap((m) => m.categories.flatMap((c) => c.items));
|
||||
}
|
||||
|
||||
/** Baut die Gruppierung neu auf, nachdem sich Zuordnungen geändert haben.
|
||||
* Dieselbe Reihenfolge wie serverseitig: Märkte und Warengruppen nach
|
||||
* sort_order, dann Name; Artikel alphabetisch. */
|
||||
function regroup(view, markets, categories) {
|
||||
const items = allItems(view);
|
||||
const marketById = new Map(markets.map((m) => [m.id, m]));
|
||||
const categoryById = new Map(categories.map((c) => [c.id, c]));
|
||||
|
||||
const buckets = new Map();
|
||||
for (const item of items) {
|
||||
const mid = marketById.has(item.market_id) ? item.market_id : null;
|
||||
const cid = categoryById.has(item.category_id) ? item.category_id : null;
|
||||
if (!buckets.has(mid)) buckets.set(mid, new Map());
|
||||
const inner = buckets.get(mid);
|
||||
if (!inner.has(cid)) inner.set(cid, []);
|
||||
inner.get(cid).push(item);
|
||||
}
|
||||
|
||||
const rank = (id, byId) => {
|
||||
if (id === null) return [1, 0, ""];
|
||||
const entry = byId.get(id);
|
||||
return [0, entry.sort_order, entry.name.toLocaleLowerCase("de")];
|
||||
};
|
||||
const cmp = (a, b) => (a[0] - b[0]) || (a[1] - b[1]) || a[2].localeCompare(b[2], "de");
|
||||
|
||||
const marketIds = [...buckets.keys()].sort(
|
||||
(a, b) => cmp(rank(a, marketById), rank(b, marketById))
|
||||
);
|
||||
|
||||
let grand = 0;
|
||||
view.markets = marketIds.map((mid) => {
|
||||
const inner = buckets.get(mid);
|
||||
const categoryIds = [...inner.keys()].sort(
|
||||
(a, b) => cmp(rank(a, categoryById), rank(b, categoryById))
|
||||
);
|
||||
|
||||
let total = 0;
|
||||
let open = 0;
|
||||
const cats = categoryIds.map((cid) => {
|
||||
const group = inner.get(cid).sort((a, b) =>
|
||||
a.article_name.localeCompare(b.article_name, "de"));
|
||||
for (const item of group) {
|
||||
if (item.status === "open") open += 1;
|
||||
if (item.price_cents) {
|
||||
// Preis je Gebinde mal Stückzahl - nicht mal Packungsgröße.
|
||||
total += item.price_cents * (item.count || 1);
|
||||
}
|
||||
}
|
||||
return {
|
||||
category_id: cid,
|
||||
category_name: cid ? categoryById.get(cid).name : "Ohne Warengruppe",
|
||||
items: group,
|
||||
};
|
||||
});
|
||||
|
||||
grand += total;
|
||||
return {
|
||||
market_id: mid,
|
||||
market_name: mid ? marketById.get(mid).name : "Ohne Markt",
|
||||
categories: cats,
|
||||
open_count: open,
|
||||
total_cents: total,
|
||||
};
|
||||
});
|
||||
|
||||
view.grand_total_cents = grand;
|
||||
return view;
|
||||
}
|
||||
|
||||
/** Wendet eine noch nicht bestätigte Operation auf die Ansicht an, damit
|
||||
* offline getätigte Änderungen sofort sichtbar sind. */
|
||||
export function applyOp(view, op, markets, categories) {
|
||||
const items = allItems(view);
|
||||
const find = (id) => items.find((i) => i.id === id);
|
||||
let needsRegroup = false;
|
||||
|
||||
if (op.kind === "item.create") {
|
||||
const p = op.payload;
|
||||
// Vorläufiger Eintrag: Die endgültige ID vergibt der Server.
|
||||
items.push({
|
||||
id: op.op_id,
|
||||
pending: true,
|
||||
article_id: null,
|
||||
article_name: p.article_name,
|
||||
created_by: null,
|
||||
created_by_name: null,
|
||||
market_id: p.market_id ?? null,
|
||||
category_id: p.category_id ?? null,
|
||||
count: p.count ?? 1,
|
||||
pack_size: p.pack_size ?? null,
|
||||
pack_unit: p.pack_unit ?? null,
|
||||
variant: p.variant ?? null,
|
||||
note: p.note ?? null,
|
||||
status: "open",
|
||||
price_cents: null,
|
||||
total_cents: null,
|
||||
row_rev: 0,
|
||||
updated_at: op.created_at,
|
||||
});
|
||||
// items ist eine Kopie - deshalb über regroup zurückschreiben.
|
||||
view.markets = [{ market_id: null, market_name: "", categories: [
|
||||
{ category_id: null, category_name: "", items },
|
||||
], open_count: 0, total_cents: 0 }];
|
||||
needsRegroup = true;
|
||||
} else if (op.kind === "item.update") {
|
||||
const item = find(op.payload.item_id);
|
||||
if (item) {
|
||||
const p = op.payload;
|
||||
if (p.clear_market) { item.market_id = null; needsRegroup = true; }
|
||||
else if ("market_id" in p) { item.market_id = p.market_id; needsRegroup = true; }
|
||||
if (p.clear_category) { item.category_id = null; needsRegroup = true; }
|
||||
else if ("category_id" in p) { item.category_id = p.category_id; needsRegroup = true; }
|
||||
for (const field of ["count", "pack_size", "pack_unit", "variant",
|
||||
"note", "status", "price_cents"]) {
|
||||
if (field in p) item[field] = p[field];
|
||||
}
|
||||
// Zeilensumme lokal nachziehen, damit die Anzeige sofort stimmt.
|
||||
item.total_cents = item.price_cents
|
||||
? item.price_cents * (item.count || 1) : null;
|
||||
item.pending = true;
|
||||
needsRegroup = true;
|
||||
}
|
||||
} else if (op.kind === "item.delete") {
|
||||
const keep = items.filter((i) => i.id !== op.payload.item_id);
|
||||
view.markets = [{ market_id: null, market_name: "", categories: [
|
||||
{ category_id: null, category_name: "", items: keep },
|
||||
], open_count: 0, total_cents: 0 }];
|
||||
needsRegroup = true;
|
||||
} else if (op.kind === "items.clear_bought") {
|
||||
const keep = items.filter((i) => i.status !== "bought");
|
||||
view.markets = [{ market_id: null, market_name: "", categories: [
|
||||
{ category_id: null, category_name: "", items: keep },
|
||||
], open_count: 0, total_cents: 0 }];
|
||||
needsRegroup = true;
|
||||
}
|
||||
|
||||
return needsRegroup ? regroup(view, markets, categories) : view;
|
||||
}
|
||||
|
||||
/** Serverstand plus alle offenen Operationen. */
|
||||
export async function composeView(listId) {
|
||||
const cached = await db.cacheGet(`view:${listId}`);
|
||||
if (!cached) return null;
|
||||
|
||||
const markets = (await db.cacheGet(`markets:${listId}`)) || [];
|
||||
const categories = (await db.cacheGet(`categories:${listId}`)) || [];
|
||||
|
||||
// Tiefe Kopie, damit der zwischengespeicherte Serverstand unberührt bleibt.
|
||||
let view = structuredClone(cached);
|
||||
const ops = await db.pending(listId);
|
||||
for (const op of ops.sort((a, b) => a.seq - b.seq)) {
|
||||
view = applyOp(view, op, markets, categories);
|
||||
}
|
||||
view.pending_ops = ops.length;
|
||||
return view;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Senden und Holen
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let flushing = false;
|
||||
|
||||
/** Schickt die Warteschlange. Gibt zurück, ob etwas gesendet wurde. */
|
||||
export async function flush(listId) {
|
||||
if (flushing) return false;
|
||||
const ops = (await db.pending(listId)).sort((a, b) => a.seq - b.seq);
|
||||
if (!ops.length) return false;
|
||||
|
||||
// Operationen, die zu oft gescheitert sind, blockieren sonst dauerhaft
|
||||
// alles Nachfolgende - sie werden verworfen und gemeldet.
|
||||
const stuck = ops.filter((o) => o.attempts >= MAX_ATTEMPTS);
|
||||
if (stuck.length) {
|
||||
await db.remove(stuck.map((o) => o.op_id));
|
||||
set({ syncProblem:
|
||||
`${stuck.length} Änderung(en) konnten nicht gespeichert werden und wurden verworfen.` });
|
||||
}
|
||||
|
||||
const sending = ops.filter((o) => o.attempts < MAX_ATTEMPTS).slice(0, 200);
|
||||
if (!sending.length) return false;
|
||||
|
||||
flushing = true;
|
||||
try {
|
||||
const result = await post(`/api/lists/${listId}/ops`, {
|
||||
ops: sending.map((o) => ({ op_id: o.op_id, kind: o.kind, payload: o.payload })),
|
||||
});
|
||||
|
||||
const done = [];
|
||||
const rejected = [];
|
||||
for (const r of result.results) {
|
||||
if (r.status === "rejected") rejected.push(r);
|
||||
done.push(r.op_id);
|
||||
}
|
||||
await db.remove(done);
|
||||
|
||||
if (rejected.length) {
|
||||
set({ syncProblem: rejected[0].error
|
||||
? `Eine Änderung wurde abgelehnt: ${rejected[0].error}`
|
||||
: "Eine Änderung wurde abgelehnt." });
|
||||
}
|
||||
return true;
|
||||
} catch (err) {
|
||||
if (err instanceof OfflineError) {
|
||||
// Kein Fehler im eigentlichen Sinn - beim nächsten Versuch erneut.
|
||||
return false;
|
||||
}
|
||||
await db.bumpAttempts(sending.map((o) => o.op_id));
|
||||
set({ syncProblem: err.message });
|
||||
return false;
|
||||
} finally {
|
||||
flushing = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Holt den Serverstand und legt ihn in den Zwischenspeicher.
|
||||
*
|
||||
* Ein einziger Aufruf statt vier: /snapshot liefert Liste, Ansicht,
|
||||
* Märkte und Warengruppen zusammen. Bei einer Synchronisation nach
|
||||
* jedem Abhaken sparen die drei eingesparten Rundreisen spürbar Zeit,
|
||||
* besonders im Mobilfunknetz. */
|
||||
export async function pull(listId) {
|
||||
const snapshot = await get(`/api/lists/${listId}/snapshot`);
|
||||
const { view, markets, categories } = snapshot;
|
||||
const meta = snapshot.list;
|
||||
|
||||
await Promise.all([
|
||||
db.cacheSet(`view:${listId}`, view),
|
||||
db.cacheSet(`markets:${listId}`, markets),
|
||||
db.cacheSet(`categories:${listId}`, categories),
|
||||
db.cacheSet(`meta:${listId}`, meta),
|
||||
]);
|
||||
return { view, markets, categories, meta };
|
||||
}
|
||||
|
||||
/** Der übliche Ablauf: erst senden, dann holen. Umgekehrt würde der
|
||||
* frisch geholte Stand die eigenen offenen Änderungen überdecken. */
|
||||
export async function syncNow(listId) {
|
||||
try {
|
||||
await flush(listId);
|
||||
await pull(listId);
|
||||
set({ lastSync: new Date().toISOString(), syncState: "ok" });
|
||||
return true;
|
||||
} catch (err) {
|
||||
set({ syncState: err instanceof OfflineError ? "offline" : "error" });
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Ereigniskanal
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let source = null;
|
||||
let sourceListId = null;
|
||||
|
||||
export function watch(listId, onChange) {
|
||||
stopWatching();
|
||||
if (!("EventSource" in globalThis)) return;
|
||||
|
||||
sourceListId = listId;
|
||||
source = new EventSource(`/api/lists/${listId}/events`);
|
||||
|
||||
source.addEventListener("rev", async (ev) => {
|
||||
let rev = null;
|
||||
try {
|
||||
rev = JSON.parse(ev.data).rev;
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const cached = await db.cacheGet(`view:${listId}`);
|
||||
// Nur holen, wenn sich wirklich etwas geändert hat.
|
||||
if (!cached || cached.rev !== rev) {
|
||||
await syncNow(listId);
|
||||
onChange();
|
||||
}
|
||||
});
|
||||
|
||||
source.addEventListener("gone", () => {
|
||||
stopWatching();
|
||||
onChange();
|
||||
});
|
||||
|
||||
// Wiederverbindung übernimmt der Browser selbst; nur der Zustand wird
|
||||
// gemeldet, damit die Oberfläche es anzeigen kann.
|
||||
source.onerror = () => set({ syncState: "offline" });
|
||||
source.onopen = () => set({ syncState: "ok" });
|
||||
}
|
||||
|
||||
export function stopWatching() {
|
||||
if (source) {
|
||||
source.close();
|
||||
source = null;
|
||||
sourceListId = null;
|
||||
}
|
||||
}
|
||||
|
||||
export function watchedList() {
|
||||
return sourceListId;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Auslöser für den Versand
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function installTriggers(getListId, onChange) {
|
||||
const attempt = async () => {
|
||||
const listId = getListId();
|
||||
if (!listId) return;
|
||||
// Nur senden. Das anschließende Holen übernimmt die Ansicht, die
|
||||
// onChange auslöst - sonst liefe pull() zweimal hintereinander.
|
||||
if (await flush(listId)) onChange();
|
||||
};
|
||||
|
||||
// Verbindung zurück: sofort versuchen.
|
||||
window.addEventListener("online", () => {
|
||||
set({ online: true, syncState: "ok" });
|
||||
attempt();
|
||||
});
|
||||
window.addEventListener("offline", () => set({ online: false, syncState: "offline" }));
|
||||
|
||||
// App wieder im Vordergrund - typischer Moment nach dem Einkauf.
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
if (document.visibilityState === "visible") attempt();
|
||||
});
|
||||
|
||||
// Rückfallebene, falls beide Ereignisse ausbleiben.
|
||||
setInterval(attempt, 30000);
|
||||
}
|
||||
347
web/html/js/views/admin.js
Normal file
347
web/html/js/views/admin.js
Normal file
@@ -0,0 +1,347 @@
|
||||
// Benutzerverwaltung für Administratoren.
|
||||
"use strict";
|
||||
|
||||
import { del, get, post, put } from "../api.js";
|
||||
import { el, mount } from "../dom.js";
|
||||
import { state } from "../store.js";
|
||||
|
||||
let openId = null;
|
||||
|
||||
function formatDate(iso) {
|
||||
if (!iso) return null;
|
||||
return new Date(iso).toLocaleDateString("de-DE", {
|
||||
day: "2-digit", month: "2-digit", year: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
/** Wie lange ist das her, grob. Für "seit wann nicht mehr gesehen" ist
|
||||
* der Monat die passende Auflösung - der Tag wäre Scheingenauigkeit. */
|
||||
function ago(iso) {
|
||||
if (!iso) return "nie";
|
||||
const days = Math.floor((Date.now() - new Date(iso)) / 86400000);
|
||||
if (days < 1) return "heute";
|
||||
if (days === 1) return "gestern";
|
||||
if (days < 31) return `vor ${days} Tagen`;
|
||||
const months = Math.round(days / 30);
|
||||
if (months < 24) return `vor ${months} Monaten`;
|
||||
return `vor ${Math.round(months / 12)} Jahren`;
|
||||
}
|
||||
|
||||
export async function adminView(root, { back }) {
|
||||
let users = [];
|
||||
let stats = null;
|
||||
let config = null;
|
||||
let banner = null;
|
||||
|
||||
async function reload() {
|
||||
[users, stats, config] = await Promise.all([
|
||||
get("/api/admin/users"),
|
||||
get("/api/admin/stats"),
|
||||
get("/api/admin/settings"),
|
||||
]);
|
||||
}
|
||||
|
||||
function say(message, kind = "notice") {
|
||||
banner = { message, kind };
|
||||
render();
|
||||
setTimeout(() => { banner = null; render(); }, 10000);
|
||||
}
|
||||
|
||||
async function guarded(fn) {
|
||||
try {
|
||||
const result = await fn();
|
||||
await reload();
|
||||
render();
|
||||
if (result && result.detail) say(result.detail);
|
||||
} catch (err) {
|
||||
say(err.message, "error");
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Kennzahlen
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
function statsCard() {
|
||||
const tile = (label, value, warn = false) =>
|
||||
el("div", { className: `stat${warn && value ? " warn" : ""}` },
|
||||
el("span.value", {}, String(value)),
|
||||
el("span.label", {}, label));
|
||||
|
||||
return el("section.card", {},
|
||||
el("h1", {}, "Benutzer"),
|
||||
el("div.stats", {},
|
||||
tile("gesamt", stats.total),
|
||||
tile("aktiv", stats.active),
|
||||
tile("deaktiviert", stats.inactive),
|
||||
tile("nicht eingerichtet", stats.unverified),
|
||||
tile("Administratoren", stats.admins)),
|
||||
stats.due_deactivation || stats.due_deletion
|
||||
? el("p.lead.warn", {},
|
||||
"Beim nächsten nächtlichen Durchlauf: ",
|
||||
stats.due_deactivation
|
||||
? `${stats.due_deactivation} Konto/Konten werden deaktiviert`
|
||||
: "",
|
||||
stats.due_deactivation && stats.due_deletion ? ", " : "",
|
||||
stats.due_deletion
|
||||
? `${stats.due_deletion} werden gelöscht`
|
||||
: "",
|
||||
".")
|
||||
: null
|
||||
);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Neues Konto
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
function createCard() {
|
||||
const email = el("input", { type: "email", placeholder: "person@example.de" });
|
||||
const name = el("input", { type: "text", maxLength: 80, placeholder: "freiwillig" });
|
||||
const asAdmin = el("input", { type: "checkbox" });
|
||||
|
||||
return el("section.card", {},
|
||||
el("h2", {}, "Konto anlegen"),
|
||||
el("p.lead", {},
|
||||
"Die Person bekommt eine Willkommensnachricht mit einem Link, über " +
|
||||
"den sie ihr Passwort selbst festlegt. Ein vom Administrator " +
|
||||
"vergebenes Passwort wäre ihm bekannt und ginge im Klartext per Mail."),
|
||||
el("label", {}, "E-Mail-Adresse"), email,
|
||||
el("label", {}, "Anzeigename"), name,
|
||||
el("label.checkline", {}, asAdmin,
|
||||
el("span", {}, "Administratorrechte erteilen")),
|
||||
el("button.primary", {
|
||||
type: "button",
|
||||
onclick: () => {
|
||||
const value = email.value.trim();
|
||||
if (!value) return;
|
||||
guarded(async () => {
|
||||
await post("/api/admin/users", {
|
||||
email: value,
|
||||
display_name: name.value.trim() || null,
|
||||
is_admin: asAdmin.checked,
|
||||
});
|
||||
email.value = "";
|
||||
name.value = "";
|
||||
asAdmin.checked = false;
|
||||
say(`Konto angelegt, Willkommensnachricht an ${value} versendet.`);
|
||||
});
|
||||
},
|
||||
}, "Anlegen und einladen")
|
||||
);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Ein Konto
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
function userActions(user) {
|
||||
const self = user.id === state.user.id;
|
||||
|
||||
const newEmail = el("input", { type: "email", placeholder: "neue Adresse" });
|
||||
|
||||
const blocks = [
|
||||
el("label", {}, "E-Mail-Adresse ändern"),
|
||||
el("p.sub", {},
|
||||
user.is_admin
|
||||
? "Administratorkonto: Die Änderung wird erst wirksam, wenn BEIDE " +
|
||||
"Adressen bestätigt haben – die neue und die bisherige."
|
||||
: "Die neue Adresse muss bestätigen; die bisherige bekommt einen " +
|
||||
"Hinweis, damit eine untergeschobene Änderung auffällt."),
|
||||
el("div.row", {}, newEmail,
|
||||
el("button.secondary", {
|
||||
type: "button",
|
||||
onclick: () => {
|
||||
const value = newEmail.value.trim();
|
||||
if (!value) return;
|
||||
guarded(async () => {
|
||||
const result = await post(`/api/admin/users/${user.id}/email`,
|
||||
{ new_email: value });
|
||||
newEmail.value = "";
|
||||
return result;
|
||||
});
|
||||
},
|
||||
}, "Ändern")),
|
||||
];
|
||||
|
||||
if (user.pending_email) {
|
||||
blocks.push(el("p.lead.warn", {},
|
||||
`Adresswechsel zu ${user.pending_email} läuft. `,
|
||||
el("button.linklike", {
|
||||
type: "button",
|
||||
onclick: () => guarded(() => del(`/api/admin/users/${user.id}/email`)),
|
||||
}, "Zurückziehen")));
|
||||
}
|
||||
|
||||
if (!user.verified) {
|
||||
blocks.push(el("div.menu-actions", {},
|
||||
el("button", {
|
||||
type: "button",
|
||||
onclick: () => guarded(() => post(`/api/admin/users/${user.id}/welcome`)),
|
||||
}, "Willkommensnachricht erneut senden")));
|
||||
}
|
||||
|
||||
// Administratorkonten sind vor Deaktivierung und Löschung geschützt.
|
||||
if (user.is_admin) {
|
||||
blocks.push(el("p.sub", {},
|
||||
"Administratorkonten lassen sich nicht deaktivieren oder löschen. " +
|
||||
"Sonst könnte sich die Verwaltung selbst aussperren."));
|
||||
return el("div.item-menu", {}, blocks);
|
||||
}
|
||||
|
||||
if (self) {
|
||||
blocks.push(el("p.sub", {}, "Das eigene Konto lässt sich hier nicht ändern."));
|
||||
return el("div.item-menu", {}, blocks);
|
||||
}
|
||||
|
||||
blocks.push(el("div.menu-actions", {},
|
||||
user.is_active
|
||||
? el("button", {
|
||||
type: "button",
|
||||
onclick: () => {
|
||||
if (!confirm(
|
||||
`Konto ${user.email} deaktivieren?\n\n` +
|
||||
"Die Person kann sich nicht mehr anmelden. Listen und " +
|
||||
"Mitgliedschaften bleiben erhalten."
|
||||
)) return;
|
||||
guarded(() => post(`/api/admin/users/${user.id}/deactivate`));
|
||||
},
|
||||
}, "Deaktivieren")
|
||||
: el("button", {
|
||||
type: "button",
|
||||
onclick: () => guarded(() => post(`/api/admin/users/${user.id}/activate`)),
|
||||
}, "Reaktivieren")));
|
||||
|
||||
blocks.push(el("hr.thin", {}));
|
||||
blocks.push(el("label", {}, "Konto löschen"));
|
||||
blocks.push(el("p.sub", {},
|
||||
user.owned_lists
|
||||
? `Diesem Konto gehören ${user.owned_lists} Liste(n). Geteilte Listen ` +
|
||||
"gehen an das dienstälteste andere Mitglied über, Listen ohne " +
|
||||
"weitere Mitglieder werden gelöscht."
|
||||
: "Diesem Konto gehören keine Listen."));
|
||||
blocks.push(el("button.danger.wide", {
|
||||
type: "button",
|
||||
onclick: () => {
|
||||
const typed = prompt(
|
||||
`Konto ${user.email} endgültig löschen?\n\n` +
|
||||
"Zur Bestätigung die E-Mail-Adresse eingeben:");
|
||||
if (!typed) return;
|
||||
guarded(() => post(`/api/admin/users/${user.id}/delete`, {
|
||||
lists: "handover",
|
||||
confirm_email: typed.trim(),
|
||||
}));
|
||||
},
|
||||
}, "Endgültig löschen"));
|
||||
|
||||
return el("div.item-menu", {}, blocks);
|
||||
}
|
||||
|
||||
function userRow(user) {
|
||||
const facts = [
|
||||
user.is_admin ? "Administrator" : null,
|
||||
!user.verified ? "noch nicht eingerichtet" : null,
|
||||
!user.is_active ? `deaktiviert seit ${formatDate(user.deactivated_at)}` : null,
|
||||
user.is_active && user.verified ? `zuletzt ${ago(user.last_seen_at)}` : null,
|
||||
user.owned_lists ? `${user.owned_lists} eigene Liste(n)` : null,
|
||||
user.memberships ? `${user.memberships} Mitgliedschaft(en)` : null,
|
||||
].filter(Boolean);
|
||||
|
||||
return el("li", {
|
||||
className: `admin-user${user.is_active ? "" : " inactive"}`,
|
||||
},
|
||||
el("button.user-open", {
|
||||
type: "button",
|
||||
onclick: () => { openId = openId === user.id ? null : user.id; render(); },
|
||||
},
|
||||
el("span.name", {}, user.display_name || user.email),
|
||||
el("span.sub", {},
|
||||
user.display_name ? `${user.email} · ` : "",
|
||||
facts.join(" · "))),
|
||||
openId === user.id ? userActions(user) : null
|
||||
);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Automatik
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
function automationCard() {
|
||||
const deactivate = el("input.months", {
|
||||
type: "number", min: "0", max: "600",
|
||||
value: String(config.auto_deactivate_months),
|
||||
});
|
||||
const remove = el("input.months", {
|
||||
type: "number", min: "0", max: "600",
|
||||
value: String(config.auto_delete_months),
|
||||
});
|
||||
const selfReg = el("input", {
|
||||
type: "checkbox",
|
||||
checked: config.allow_self_registration,
|
||||
disabled: config.locked_by_env,
|
||||
});
|
||||
|
||||
return el("section.card", {},
|
||||
el("h2", {}, "Automatische Bereinigung"),
|
||||
el("p.lead", {},
|
||||
"Läuft einmal täglich. ",
|
||||
el("strong", {}, "0 bedeutet abgeschaltet"),
|
||||
", nicht „sofort“. Administratorkonten sind ausgenommen."),
|
||||
|
||||
el("label", {}, "Deaktivieren nach … Monaten ohne Anmeldung"),
|
||||
deactivate,
|
||||
el("label", {}, "Löschen nach … Monaten Deaktivierung"),
|
||||
remove,
|
||||
|
||||
el("label.checkline", {}, selfReg,
|
||||
el("span", {}, "Selbstregistrierung erlauben")),
|
||||
config.locked_by_env
|
||||
? el("p.sub", {},
|
||||
"Über die Umgebungsvariable ALLOW_SELF_REGISTRATION festgelegt " +
|
||||
"und hier nicht änderbar.")
|
||||
: null,
|
||||
|
||||
el("button.primary", {
|
||||
type: "button",
|
||||
onclick: () => guarded(async () => {
|
||||
await put("/api/admin/settings", {
|
||||
auto_deactivate_months: Number(deactivate.value) || 0,
|
||||
auto_delete_months: Number(remove.value) || 0,
|
||||
...(config.locked_by_env
|
||||
? {}
|
||||
: { allow_self_registration: selfReg.checked }),
|
||||
});
|
||||
say("Einstellungen gespeichert.");
|
||||
}),
|
||||
}, "Speichern"),
|
||||
|
||||
el("div.menu-actions", {},
|
||||
el("button", {
|
||||
type: "button",
|
||||
onclick: () => guarded(() => post("/api/admin/cleanup")),
|
||||
}, "Jetzt aufräumen"))
|
||||
);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
function render() {
|
||||
mount(root,
|
||||
el("header.bar", {},
|
||||
el("button.linklike", { type: "button", onclick: back }, "‹ Zurück")),
|
||||
|
||||
banner ? el(banner.kind === "error" ? "p.error" : "p.notice", {}, banner.message) : null,
|
||||
|
||||
statsCard(),
|
||||
el("section.card", {},
|
||||
el("h2", {}, "Konten"),
|
||||
el("ul.admin-users", {}, users.map(userRow))),
|
||||
createCard(),
|
||||
automationCard()
|
||||
);
|
||||
}
|
||||
|
||||
await reload();
|
||||
render();
|
||||
return render;
|
||||
}
|
||||
296
web/html/js/views/articles.js
Normal file
296
web/html/js/views/articles.js
Normal file
@@ -0,0 +1,296 @@
|
||||
// Artikelstamm einer Liste: Namen, Strichcodes, Vorgaben für Markt und
|
||||
// Warengruppe, Verfügbarkeit und frei definierbare Eigenschaften.
|
||||
"use strict";
|
||||
|
||||
import { del, get, patch, post } from "../api.js";
|
||||
import { el, mount } from "../dom.js";
|
||||
import { set, state } from "../store.js";
|
||||
import { cameraAvailable, scanBarcode } from "./scanner.js";
|
||||
|
||||
let editingId = null;
|
||||
let query = "";
|
||||
|
||||
export async function articlesView(root, { listId, back }) {
|
||||
let articles = [];
|
||||
let banner = null;
|
||||
|
||||
async function reload() {
|
||||
const [list, markets, categories] = await Promise.all([
|
||||
get(`/api/lists/${listId}/articles${query ? `?q=${encodeURIComponent(query)}` : ""}`),
|
||||
get(`/api/lists/${listId}/markets`),
|
||||
get(`/api/lists/${listId}/categories`),
|
||||
]);
|
||||
articles = list;
|
||||
set({ markets, categories });
|
||||
}
|
||||
|
||||
function say(message, kind = "error") {
|
||||
banner = { message, kind };
|
||||
render();
|
||||
setTimeout(() => { banner = null; render(); }, 6000);
|
||||
}
|
||||
|
||||
async function guarded(fn) {
|
||||
try {
|
||||
await fn();
|
||||
await reload();
|
||||
render();
|
||||
} catch (err) {
|
||||
say(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Bearbeitungsformular
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
function editor(article) {
|
||||
const name = el("input", { type: "text", value: article.name, maxLength: 200 });
|
||||
const barcode = el("input", {
|
||||
type: "text",
|
||||
inputMode: "numeric",
|
||||
value: article.barcode || "",
|
||||
maxLength: 64,
|
||||
placeholder: "keiner hinterlegt",
|
||||
});
|
||||
const note = el("input", {
|
||||
type: "text", value: article.note || "", maxLength: 500,
|
||||
placeholder: "z. B. Lieblingsmarke",
|
||||
});
|
||||
|
||||
const marketSelect = el("select", {},
|
||||
el("option", { value: "" }, "— keine Vorgabe —"),
|
||||
state.markets.map((m) => el("option", {
|
||||
value: m.id, selected: m.id === article.default_market_id,
|
||||
}, m.name)));
|
||||
|
||||
const categorySelect = el("select", {},
|
||||
el("option", { value: "" }, "— keine Vorgabe —"),
|
||||
state.categories.map((c) => el("option", {
|
||||
value: c.id, selected: c.id === article.default_category_id,
|
||||
}, c.name)));
|
||||
|
||||
// Verfügbarkeit: mehrere Märkte ankreuzbar
|
||||
const availability = state.markets.map((m) => {
|
||||
const box = el("input", {
|
||||
type: "checkbox",
|
||||
checked: article.available_market_ids.includes(m.id),
|
||||
dataset: { marketId: m.id },
|
||||
});
|
||||
return el("label.checkline", {}, box, el("span", {}, m.name));
|
||||
});
|
||||
|
||||
// Freie Eigenschaften
|
||||
const attrRows = el("div.attr-rows", {});
|
||||
|
||||
function addAttrRow(attr = { name: "", value: "" }) {
|
||||
const key = el("input.attr-key", {
|
||||
type: "text", value: attr.name, maxLength: 80, placeholder: "Eigenschaft",
|
||||
});
|
||||
const value = el("input.attr-value", {
|
||||
type: "text", value: attr.value, maxLength: 300, placeholder: "Wert",
|
||||
});
|
||||
const row = el("div.attr-row", {}, key, value,
|
||||
el("button.danger.remove", {
|
||||
type: "button", title: "Zeile entfernen",
|
||||
onclick: () => row.remove(),
|
||||
}, "×"));
|
||||
attrRows.append(row);
|
||||
}
|
||||
|
||||
for (const attr of article.attributes) addAttrRow(attr);
|
||||
if (!article.attributes.length) addAttrRow();
|
||||
|
||||
async function scan() {
|
||||
const code = await scanBarcode();
|
||||
if (code) barcode.value = code;
|
||||
}
|
||||
|
||||
/** Angaben zum eingetragenen Code aus der Produktdatenbank holen.
|
||||
* Überschreibt nur leere Felder - Gepflegtes bleibt stehen. */
|
||||
async function fetchProductData() {
|
||||
const code = barcode.value.replace(/\s/g, "");
|
||||
if (!code) {
|
||||
say("Erst einen Strichcode eintragen oder scannen.");
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const hit = await get(
|
||||
`/api/lists/${listId}/barcode/${encodeURIComponent(code)}`);
|
||||
if (!hit.found || hit.source !== "openfoodfacts") {
|
||||
say("Zu diesem Code liegen keine Angaben vor.", "notice");
|
||||
return;
|
||||
}
|
||||
if (hit.name && !name.value.trim()) name.value = hit.name;
|
||||
if (hit.package && !note.value.trim()) note.value = hit.package;
|
||||
|
||||
const rows = [...attrRows.querySelectorAll(".attr-row")];
|
||||
const empty = rows.find(
|
||||
(r) => !r.querySelector(".attr-key").value.trim());
|
||||
if (hit.brand && empty) {
|
||||
empty.querySelector(".attr-key").value = "Marke";
|
||||
empty.querySelector(".attr-value").value = hit.brand;
|
||||
}
|
||||
say(`Angaben zu „${hit.name}“ übernommen. Noch nicht gespeichert.`,
|
||||
"notice");
|
||||
} catch (err) {
|
||||
say(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
const attributes = [...attrRows.querySelectorAll(".attr-row")]
|
||||
.map((row) => ({
|
||||
name: row.querySelector(".attr-key").value.trim(),
|
||||
value: row.querySelector(".attr-value").value.trim(),
|
||||
}))
|
||||
.filter((a) => a.name);
|
||||
|
||||
const available = [...availability]
|
||||
.map((label) => label.querySelector("input"))
|
||||
.filter((box) => box.checked)
|
||||
.map((box) => box.dataset.marketId);
|
||||
|
||||
editingId = null;
|
||||
await guarded(() => patch(`/api/lists/${listId}/articles/${article.id}`, {
|
||||
name: name.value.trim() || article.name,
|
||||
barcode: barcode.value.replace(/\s/g, "") || null,
|
||||
note: note.value.trim() || null,
|
||||
default_market_id: marketSelect.value || null,
|
||||
default_category_id: categorySelect.value || null,
|
||||
attributes,
|
||||
available_market_ids: available,
|
||||
}));
|
||||
}
|
||||
|
||||
return el("div.article-editor", {},
|
||||
el("label", {}, "Name"), name,
|
||||
|
||||
el("label", {}, "Strichcode"),
|
||||
el("div.row", {}, barcode,
|
||||
cameraAvailable()
|
||||
? el("button.secondary", { type: "button", onclick: scan }, "Scannen")
|
||||
: null,
|
||||
el("button.secondary", {
|
||||
type: "button",
|
||||
title: "Bezeichnung und Packungsgröße aus der Produktdatenbank holen",
|
||||
onclick: fetchProductData,
|
||||
}, "Abrufen")),
|
||||
|
||||
el("label", {}, "Notiz"), note,
|
||||
el("label", {}, "Vorgabe Markt"), marketSelect,
|
||||
el("label", {}, "Vorgabe Warengruppe"), categorySelect,
|
||||
|
||||
state.markets.length
|
||||
? el("div.availability", {},
|
||||
el("label", {}, "Erhältlich bei"), availability)
|
||||
: null,
|
||||
|
||||
el("label", {}, "Eigenschaften",
|
||||
el("span.hint", {}, " (Verpackungseinheit, Farbe, Größe …)")),
|
||||
attrRows,
|
||||
el("button.linklike.add-attr", {
|
||||
type: "button", onclick: () => addAttrRow(),
|
||||
}, "+ weitere Eigenschaft"),
|
||||
|
||||
el("div.menu-actions", {},
|
||||
el("button.primary", { type: "button", onclick: save }, "Speichern"),
|
||||
el("button", {
|
||||
type: "button",
|
||||
onclick: () => { editingId = null; render(); },
|
||||
}, "Abbrechen"),
|
||||
el("button.danger", {
|
||||
type: "button",
|
||||
onclick: () => {
|
||||
if (!confirm(
|
||||
`Artikel „${article.name}“ löschen?\n\n` +
|
||||
"Offene Einträge dieses Artikels verschwinden mit."
|
||||
)) return;
|
||||
editingId = null;
|
||||
guarded(() => del(`/api/lists/${listId}/articles/${article.id}`));
|
||||
},
|
||||
}, "Löschen"))
|
||||
);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Zeile
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
function row(article) {
|
||||
if (editingId === article.id) {
|
||||
return el("li.article-row.editing", {},
|
||||
el("span.name", {}, article.name), editor(article));
|
||||
}
|
||||
|
||||
const marketName = state.markets.find((m) => m.id === article.default_market_id)?.name;
|
||||
const categoryName = state.categories.find(
|
||||
(c) => c.id === article.default_category_id)?.name;
|
||||
|
||||
const facts = [
|
||||
article.barcode ? `Code ${article.barcode}` : null,
|
||||
marketName,
|
||||
categoryName,
|
||||
article.attributes.length
|
||||
? article.attributes.map((a) => `${a.name}: ${a.value}`).join(", ")
|
||||
: null,
|
||||
].filter(Boolean);
|
||||
|
||||
return el("li.article-row", {},
|
||||
el("button.article-open", {
|
||||
type: "button",
|
||||
onclick: () => { editingId = article.id; render(); },
|
||||
},
|
||||
el("span.name", {}, article.name),
|
||||
facts.length ? el("span.sub", {}, facts.join(" · ")) : null)
|
||||
);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
function render() {
|
||||
const search = el("input", {
|
||||
type: "search",
|
||||
value: query,
|
||||
placeholder: "Artikel suchen",
|
||||
oninput: (ev) => {
|
||||
query = ev.target.value;
|
||||
clearTimeout(render._timer);
|
||||
render._timer = setTimeout(() => guarded(async () => {}), 250);
|
||||
},
|
||||
});
|
||||
|
||||
mount(root,
|
||||
el("header.bar", {},
|
||||
el("button.linklike", { type: "button", onclick: back },
|
||||
"‹ Zurück zur Liste")),
|
||||
|
||||
banner ? el(banner.kind === "error" ? "p.error" : "p.notice", {}, banner.message) : null,
|
||||
|
||||
el("section.card", {},
|
||||
el("h1", {}, "Artikel"),
|
||||
el("p.lead", {},
|
||||
"Vorgaben für Markt und Warengruppe gelten für neue Einträge. " +
|
||||
"Ein hinterlegter Strichcode lässt den Artikel beim Scannen sofort " +
|
||||
"finden."),
|
||||
search,
|
||||
articles.length
|
||||
? el("ul.articles", {}, articles.map(row))
|
||||
: el("p.empty", {},
|
||||
query ? "Kein Artikel gefunden." : "Noch kein Artikel angelegt.")),
|
||||
|
||||
el("p.footnote", {},
|
||||
"Artikel entstehen automatisch, sobald du sie auf die Liste setzt.")
|
||||
);
|
||||
|
||||
if (query) {
|
||||
const field = root.querySelector('input[type="search"]');
|
||||
field.focus();
|
||||
field.setSelectionRange(field.value.length, field.value.length);
|
||||
}
|
||||
}
|
||||
|
||||
await reload();
|
||||
render();
|
||||
return render;
|
||||
}
|
||||
233
web/html/js/views/auth.js
Normal file
233
web/html/js/views/auth.js
Normal file
@@ -0,0 +1,233 @@
|
||||
// Anmeldung, Registrierung, Passwort zurücksetzen und Startpasswortwechsel.
|
||||
"use strict";
|
||||
|
||||
import { ApiError, get, post } from "../api.js";
|
||||
import { clear, el, mount } from "../dom.js";
|
||||
import { set, state } from "../store.js";
|
||||
|
||||
/** Baut ein Formular ohne <form>-Element: dessen Standardverhalten
|
||||
* (Seite neu laden) stört hier nur. Enter löst trotzdem aus. */
|
||||
function form({ title, lead, leadWarn, fields, submitLabel, onSubmit, links = [] }) {
|
||||
const inputs = {};
|
||||
const error = el("p.error", { hidden: true });
|
||||
const notice = el("p.notice", { hidden: true });
|
||||
const button = el("button.primary", { type: "button" }, submitLabel);
|
||||
|
||||
const showError = (message) => {
|
||||
error.textContent = message || "";
|
||||
error.hidden = !message;
|
||||
};
|
||||
const showNotice = (message) => {
|
||||
notice.textContent = message || "";
|
||||
notice.hidden = !message;
|
||||
};
|
||||
|
||||
const run = async () => {
|
||||
showError("");
|
||||
showNotice("");
|
||||
button.disabled = true;
|
||||
try {
|
||||
const values = Object.fromEntries(
|
||||
Object.entries(inputs).map(([k, i]) => [k, i.value])
|
||||
);
|
||||
await onSubmit(values, { showNotice, showError });
|
||||
} catch (err) {
|
||||
showError(err.message);
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
}
|
||||
};
|
||||
|
||||
button.addEventListener("click", run);
|
||||
|
||||
const rows = fields.map((f) => {
|
||||
const input = el("input", {
|
||||
id: `f-${f.name}`,
|
||||
type: f.type || "text",
|
||||
autocomplete: f.autocomplete || "off",
|
||||
maxLength: f.maxLength || 256,
|
||||
onkeydown: (ev) => {
|
||||
if (ev.key === "Enter") run();
|
||||
},
|
||||
});
|
||||
inputs[f.name] = input;
|
||||
return [
|
||||
el("label", { htmlFor: `f-${f.name}` },
|
||||
f.label,
|
||||
f.hint ? el("span.hint", {}, ` ${f.hint}`) : null),
|
||||
input,
|
||||
];
|
||||
});
|
||||
|
||||
return el("section.card", {},
|
||||
el("h1", {}, title),
|
||||
lead ? el(leadWarn ? "p.lead.warn" : "p.lead", {}, lead) : null,
|
||||
rows,
|
||||
error,
|
||||
notice,
|
||||
button,
|
||||
links.length
|
||||
? el("p.switch", {}, links.flatMap((l, i) => [
|
||||
i > 0 ? " · " : null,
|
||||
el("a", { href: "#", onclick: (ev) => { ev.preventDefault(); l.action(); } },
|
||||
l.label),
|
||||
]))
|
||||
: null
|
||||
);
|
||||
}
|
||||
|
||||
export function loginView(root, { goto, onSignedIn }) {
|
||||
const params = new URLSearchParams(location.search);
|
||||
const verified = params.get("verified");
|
||||
const emailChange = params.get("adresswechsel");
|
||||
|
||||
const node = form({
|
||||
title: state.appName,
|
||||
lead: "Bitte anmelden.",
|
||||
fields: [
|
||||
{ name: "email", label: "E-Mail-Adresse", type: "email", autocomplete: "username" },
|
||||
{ name: "password", label: "Passwort", type: "password", autocomplete: "current-password" },
|
||||
],
|
||||
submitLabel: "Anmelden",
|
||||
onSubmit: async ({ email, password }) => {
|
||||
const user = await post("/api/auth/login", { email: email.trim(), password });
|
||||
onSignedIn(user);
|
||||
},
|
||||
links: [
|
||||
{ label: "Konto anlegen", action: () => goto("register") },
|
||||
{ label: "Passwort vergessen", action: () => goto("forgot") },
|
||||
],
|
||||
});
|
||||
|
||||
mount(root, node);
|
||||
|
||||
if (verified === "ok") {
|
||||
const notice = node.querySelector(".notice");
|
||||
notice.textContent = "E-Mail-Adresse bestätigt. Du kannst dich jetzt anmelden.";
|
||||
notice.hidden = false;
|
||||
history.replaceState(null, "", "/");
|
||||
} else if (verified === "invalid") {
|
||||
const error = node.querySelector(".error");
|
||||
error.textContent = "Der Bestätigungslink ist ungültig oder abgelaufen.";
|
||||
error.hidden = false;
|
||||
history.replaceState(null, "", "/");
|
||||
}
|
||||
|
||||
if (emailChange) {
|
||||
const messages = {
|
||||
fertig: ["notice",
|
||||
"E-Mail-Adresse geändert. Bitte melde dich mit der neuen Adresse an."],
|
||||
teilweise: ["notice",
|
||||
"Bestätigung angekommen. Bei Administratorkonten muss auch die " +
|
||||
"zweite Adresse zustimmen – erst danach wird die Änderung wirksam."],
|
||||
abgelaufen: ["error", "Der Bestätigungslink ist abgelaufen."],
|
||||
erledigt: ["notice", "Dieser Adresswechsel ist bereits abgeschlossen."],
|
||||
unbekannt: ["error", "Dieser Bestätigungslink ist unbekannt."],
|
||||
};
|
||||
const entry = messages[emailChange];
|
||||
if (entry) {
|
||||
const target = node.querySelector(entry[0] === "error" ? ".error" : ".notice");
|
||||
target.textContent = entry[1];
|
||||
target.hidden = false;
|
||||
history.replaceState(null, "", "/");
|
||||
}
|
||||
}
|
||||
|
||||
node.querySelector("input").focus();
|
||||
}
|
||||
|
||||
export function registerView(root, { goto }) {
|
||||
mount(root, form({
|
||||
title: "Konto anlegen",
|
||||
fields: [
|
||||
{ name: "email", label: "E-Mail-Adresse", type: "email", autocomplete: "username" },
|
||||
{ name: "display_name", label: "Anzeigename", hint: "(freiwillig)", maxLength: 80 },
|
||||
{ name: "password", label: "Passwort", hint: "(mindestens 12 Zeichen)",
|
||||
type: "password", autocomplete: "new-password" },
|
||||
],
|
||||
submitLabel: "Registrieren",
|
||||
onSubmit: async (v, { showNotice }) => {
|
||||
const result = await post("/api/auth/register", {
|
||||
email: v.email.trim(),
|
||||
password: v.password,
|
||||
display_name: v.display_name.trim() || null,
|
||||
});
|
||||
showNotice(result.detail);
|
||||
},
|
||||
links: [{ label: "Zurück zur Anmeldung", action: () => goto("login") }],
|
||||
}));
|
||||
}
|
||||
|
||||
export function forgotView(root, { goto }) {
|
||||
mount(root, form({
|
||||
title: "Passwort zurücksetzen",
|
||||
lead: "Wir schicken dir einen Link, sofern die Adresse bei uns registriert ist.",
|
||||
fields: [
|
||||
{ name: "email", label: "E-Mail-Adresse", type: "email", autocomplete: "username" },
|
||||
],
|
||||
submitLabel: "Link anfordern",
|
||||
onSubmit: async ({ email }, { showNotice }) => {
|
||||
const result = await post("/api/auth/password/reset-request", { email: email.trim() });
|
||||
showNotice(result.detail);
|
||||
},
|
||||
links: [{ label: "Zurück zur Anmeldung", action: () => goto("login") }],
|
||||
}));
|
||||
}
|
||||
|
||||
export function resetView(root, { goto }) {
|
||||
const token = new URLSearchParams(location.search).get("token") || "";
|
||||
mount(root, form({
|
||||
title: "Neues Passwort setzen",
|
||||
fields: [
|
||||
{ name: "password", label: "Neues Passwort", hint: "(mindestens 12 Zeichen)",
|
||||
type: "password", autocomplete: "new-password" },
|
||||
{ name: "repeat", label: "Wiederholen", type: "password", autocomplete: "new-password" },
|
||||
],
|
||||
submitLabel: "Passwort setzen",
|
||||
onSubmit: async ({ password, repeat }, { showNotice }) => {
|
||||
if (password !== repeat) throw new Error("Die beiden Eingaben stimmen nicht überein.");
|
||||
const result = await post("/api/auth/password/reset", { token, password });
|
||||
showNotice(result.detail);
|
||||
history.replaceState(null, "", "/");
|
||||
setTimeout(() => goto("login"), 1500);
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
export function changePasswordView(root, { onDone, onLogout }) {
|
||||
mount(root, form({
|
||||
title: "Passwort ändern",
|
||||
lead: "Dieses Konto wurde mit einem Startpasswort aus der Konfiguration angelegt. " +
|
||||
"Bevor du weiterarbeiten kannst, musst du ein eigenes Passwort setzen.",
|
||||
leadWarn: true,
|
||||
fields: [
|
||||
{ name: "current", label: "Bisheriges Passwort", type: "password",
|
||||
autocomplete: "current-password" },
|
||||
{ name: "next", label: "Neues Passwort", hint: "(mindestens 12 Zeichen)",
|
||||
type: "password", autocomplete: "new-password" },
|
||||
{ name: "repeat", label: "Wiederholen", type: "password", autocomplete: "new-password" },
|
||||
],
|
||||
submitLabel: "Passwort ändern",
|
||||
onSubmit: async ({ current, next, repeat }) => {
|
||||
if (next !== repeat) throw new Error("Die beiden Eingaben stimmen nicht überein.");
|
||||
await post("/api/auth/password/change", {
|
||||
current_password: current,
|
||||
new_password: next,
|
||||
});
|
||||
const user = await get("/api/auth/me");
|
||||
set({ user });
|
||||
onDone(user);
|
||||
},
|
||||
links: [{ label: "Abmelden", action: onLogout }],
|
||||
}));
|
||||
}
|
||||
|
||||
export async function logout() {
|
||||
try {
|
||||
await post("/api/auth/logout");
|
||||
} catch (err) {
|
||||
// Auch wenn der Server nicht erreichbar ist: lokal abmelden.
|
||||
if (!(err instanceof ApiError)) { /* offline - egal */ }
|
||||
}
|
||||
set({ user: null, lists: [], view: null });
|
||||
}
|
||||
600
web/html/js/views/list-detail.js
Normal file
600
web/html/js/views/list-detail.js
Normal file
@@ -0,0 +1,600 @@
|
||||
// Eine geöffnete Liste: gruppiert nach Markt und Warengruppe, Artikel
|
||||
// alphabetisch. Arbeitet gegen den lokalen Zwischenspeicher, nicht gegen
|
||||
// den Server - dadurch funktioniert die Ansicht auch ohne Verbindung.
|
||||
//
|
||||
// Jede Änderung wandert zuerst in die Outbox und wird sofort angezeigt.
|
||||
// Der Versand läuft danach; scheitert er, bleibt die Operation liegen und
|
||||
// wird beim nächsten Anlauf erneut versucht.
|
||||
"use strict";
|
||||
|
||||
// get/ApiError nur für die Strichcode-Auflösung: Sie braucht eine
|
||||
// Verbindung, weil die Zuordnung Code -> Artikel serverseitig steht.
|
||||
import { ApiError, get } from "../api.js";
|
||||
import * as db from "../db.js";
|
||||
import {
|
||||
clear, el, euro, formatPack, formatQuantity, mount,
|
||||
parseCents, parseCount, parseQuantity,
|
||||
} from "../dom.js";
|
||||
import { set, state } from "../store.js";
|
||||
import { composeView, flush, pull, syncNow, watch } from "../sync.js";
|
||||
import { confirmScan } from "./scan-result.js";
|
||||
import { cameraAvailable, scanBarcode } from "./scanner.js";
|
||||
|
||||
let openMenuId = null;
|
||||
|
||||
export async function listDetailView(
|
||||
root, { listId, back, manage, share, articles, prices }
|
||||
) {
|
||||
let view = null;
|
||||
let meta = null;
|
||||
let markets = [];
|
||||
let categories = [];
|
||||
let banner = null;
|
||||
/** article_id -> { best_market_ids, best_cents, spread_cents, prices } */
|
||||
let priceHints = new Map();
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Laden
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
/** Preishinweise sind eine Nebeninformation: Kommen sie nicht, arbeitet
|
||||
* die Liste ohne sie weiter. Deshalb hier kein throw, sondern der
|
||||
* Rückgriff auf den zuletzt bekannten Stand. */
|
||||
async function loadHints() {
|
||||
if (!state.online) {
|
||||
const cached = await db.cacheGet(`hints:${listId}`).catch(() => null);
|
||||
if (cached) priceHints = new Map(cached.map((r) => [r.article_id, r]));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const rows = await get(`/api/lists/${listId}/price-hints`);
|
||||
priceHints = new Map(rows.map((r) => [r.article_id, r]));
|
||||
await db.cacheSet(`hints:${listId}`, rows);
|
||||
} catch {
|
||||
const cached = await db.cacheGet(`hints:${listId}`).catch(() => null);
|
||||
if (cached) priceHints = new Map(cached.map((r) => [r.article_id, r]));
|
||||
}
|
||||
}
|
||||
|
||||
async function readLocal() {
|
||||
view = await composeView(listId);
|
||||
markets = (await db.cacheGet(`markets:${listId}`)) || [];
|
||||
categories = (await db.cacheGet(`categories:${listId}`)) || [];
|
||||
meta = await db.cacheGet(`meta:${listId}`);
|
||||
}
|
||||
|
||||
async function refresh({ fromServer = false } = {}) {
|
||||
if (fromServer) {
|
||||
try {
|
||||
await pull(listId);
|
||||
set({ syncState: "ok" });
|
||||
} catch {
|
||||
set({ syncState: navigator.onLine ? "error" : "offline" });
|
||||
}
|
||||
}
|
||||
await readLocal();
|
||||
render();
|
||||
}
|
||||
|
||||
function say(message) {
|
||||
banner = message;
|
||||
render();
|
||||
setTimeout(() => { banner = null; render(); }, 6000);
|
||||
}
|
||||
|
||||
/** Änderung anmelden: in die Outbox, sofort anzeigen, dann senden. */
|
||||
async function change(kind, payload) {
|
||||
await db.enqueue(listId, kind, payload);
|
||||
await readLocal();
|
||||
render();
|
||||
|
||||
const sent = await flush(listId);
|
||||
if (sent) {
|
||||
await pull(listId).catch(() => {});
|
||||
await readLocal();
|
||||
}
|
||||
if (state.syncProblem) {
|
||||
const problem = state.syncProblem;
|
||||
set({ syncProblem: null });
|
||||
say(problem);
|
||||
} else {
|
||||
render();
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Bausteine
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
function itemRow(item) {
|
||||
const bought = item.status === "bought";
|
||||
const deferred = item.status === "deferred";
|
||||
// Ein neu angelegter, noch nicht bestätigter Eintrag hat serverseitig
|
||||
// keine ID - Folgeänderungen daran ließen sich nicht zuordnen.
|
||||
const provisional = Boolean(item.pending && item.row_rev === 0);
|
||||
|
||||
const check = el("button.check", {
|
||||
type: "button",
|
||||
"aria-pressed": bought ? "true" : "false",
|
||||
title: bought ? "Als offen markieren" : "Als gekauft markieren",
|
||||
onclick: () => change("item.update", {
|
||||
item_id: item.id,
|
||||
status: bought ? "open" : "bought",
|
||||
}),
|
||||
disabled: provisional,
|
||||
}, bought ? "✓" : "");
|
||||
|
||||
const priceField = el("input.price", {
|
||||
type: "text",
|
||||
inputMode: "decimal",
|
||||
placeholder: "€",
|
||||
value: item.price_cents ? (item.price_cents / 100).toFixed(2).replace(".", ",") : "",
|
||||
title: provisional ? "Erst nach der Übertragung möglich" : "Preis erfassen",
|
||||
disabled: provisional,
|
||||
onchange: (ev) => {
|
||||
const cents = parseCents(ev.target.value);
|
||||
if (ev.target.value.trim() && cents === null) {
|
||||
ev.target.value = "";
|
||||
say("Preis konnte nicht gelesen werden.");
|
||||
return;
|
||||
}
|
||||
change("item.update", { item_id: item.id, price_cents: cents });
|
||||
},
|
||||
});
|
||||
|
||||
const menuButton = el("button.menu-toggle", {
|
||||
type: "button",
|
||||
title: "Weitere Aktionen",
|
||||
disabled: provisional,
|
||||
onclick: () => {
|
||||
openMenuId = openMenuId === item.id ? null : item.id;
|
||||
render();
|
||||
},
|
||||
}, "⋯");
|
||||
|
||||
const pack = formatPack(item.pack_size, item.pack_unit);
|
||||
|
||||
const shared = meta && meta.member_count > 1;
|
||||
const by = shared && item.created_by_name ? `von ${item.created_by_name}` : "";
|
||||
const sub = [pack, item.variant, item.note, by].filter(Boolean).join(" · ");
|
||||
|
||||
return el("li", {
|
||||
className: [
|
||||
"item",
|
||||
bought ? "bought" : "",
|
||||
deferred ? "deferred" : "",
|
||||
item.pending ? "pending" : "",
|
||||
].filter(Boolean).join(" "),
|
||||
},
|
||||
el("div.item-main", {},
|
||||
check,
|
||||
// Stückzahl direkt am Artikel: Beim Einkaufen ist "wie viele"
|
||||
// die wichtigste Angabe und darf nicht im Untertitel untergehen.
|
||||
el("span", {
|
||||
className: `item-count${item.count > 1 ? " many" : ""}`,
|
||||
title: item.count === 1 ? "1 Stück" : `${item.count} Stück`,
|
||||
}, `${item.count || 1}\u00d7`),
|
||||
el("div.item-text", {},
|
||||
el("span.name", {}, item.article_name),
|
||||
sub ? el("span.sub", {}, sub) : null),
|
||||
el("div.price-cell", {},
|
||||
priceField,
|
||||
// Bei mehreren Stück wird sichtbar, dass der Preis je Gebinde
|
||||
// gilt und was zusammen daraus wird.
|
||||
item.price_cents && item.count > 1
|
||||
? el("span.line-total", {}, `= ${euro(item.total_cents)}`)
|
||||
: null),
|
||||
menuButton),
|
||||
openMenuId === item.id && !provisional ? itemMenu(item) : null
|
||||
);
|
||||
}
|
||||
|
||||
function itemMenu(item) {
|
||||
const marketSelect = el("select", {
|
||||
onchange: (ev) => {
|
||||
const value = ev.target.value;
|
||||
openMenuId = null;
|
||||
change("item.update", value
|
||||
? { item_id: item.id, market_id: value }
|
||||
: { item_id: item.id, clear_market: true });
|
||||
},
|
||||
},
|
||||
el("option", { value: "" }, "— ohne Markt —"),
|
||||
markets.map((m) =>
|
||||
el("option", { value: m.id, selected: m.id === item.market_id }, m.name))
|
||||
);
|
||||
|
||||
const categorySelect = el("select", {
|
||||
onchange: (ev) => {
|
||||
const value = ev.target.value;
|
||||
openMenuId = null;
|
||||
change("item.update", value
|
||||
? { item_id: item.id, category_id: value }
|
||||
: { item_id: item.id, clear_category: true });
|
||||
},
|
||||
},
|
||||
el("option", { value: "" }, "— ohne Warengruppe —"),
|
||||
categories.map((c) =>
|
||||
el("option", { value: c.id, selected: c.id === item.category_id }, c.name))
|
||||
);
|
||||
|
||||
const deferred = item.status === "deferred";
|
||||
|
||||
const variantField = el("input", {
|
||||
type: "text",
|
||||
value: item.variant || "",
|
||||
maxLength: 200,
|
||||
placeholder: "z. B. bunt, ganz",
|
||||
onchange: (ev) => change("item.update", {
|
||||
item_id: item.id,
|
||||
variant: ev.target.value.trim() || null,
|
||||
}),
|
||||
});
|
||||
|
||||
const noteField = el("input", {
|
||||
type: "text",
|
||||
value: item.note || "",
|
||||
maxLength: 500,
|
||||
placeholder: "Bemerkung für den Einkauf",
|
||||
onchange: (ev) => change("item.update", {
|
||||
item_id: item.id,
|
||||
note: ev.target.value.trim() || null,
|
||||
}),
|
||||
});
|
||||
|
||||
const hint = priceHints.get(item.article_id);
|
||||
const hintBlock = hint ? (() => {
|
||||
const bestNames = hint.best_market_ids
|
||||
.map((id) => markets.find((m) => m.id === id)?.name)
|
||||
.filter(Boolean)
|
||||
.join(", ");
|
||||
const hereCents = item.market_id ? hint.prices[item.market_id] : null;
|
||||
if (!bestNames) return null;
|
||||
|
||||
const alreadyBest = item.market_id
|
||||
&& hint.best_market_ids.includes(item.market_id);
|
||||
|
||||
return el("p.price-tip", {},
|
||||
alreadyBest
|
||||
? `Hier am günstigsten: ${euro(hint.best_cents)}.`
|
||||
: `Zuletzt günstigster Markt: ${bestNames} für ${euro(hint.best_cents)}`,
|
||||
!alreadyBest && hereCents
|
||||
? ` – hier zuletzt ${euro(hereCents)}.`
|
||||
: !alreadyBest ? "." : "");
|
||||
})() : null;
|
||||
|
||||
const countEdit = el("input", {
|
||||
type: "text",
|
||||
inputMode: "numeric",
|
||||
value: String(item.count || 1),
|
||||
title: "Stückzahl",
|
||||
onchange: (ev) => change("item.update", {
|
||||
item_id: item.id, count: parseCount(ev.target.value),
|
||||
}),
|
||||
});
|
||||
const packSizeEdit = el("input.qty", {
|
||||
type: "text",
|
||||
inputMode: "decimal",
|
||||
value: item.pack_size ? formatQuantity(item.pack_size) : "",
|
||||
placeholder: "Gebinde",
|
||||
onchange: (ev) => change("item.update", {
|
||||
item_id: item.id, pack_size: parseQuantity(ev.target.value),
|
||||
}),
|
||||
});
|
||||
const packUnitEdit = el("input.unit", {
|
||||
type: "text",
|
||||
maxLength: 32,
|
||||
value: item.pack_unit || "",
|
||||
placeholder: "Einheit",
|
||||
onchange: (ev) => change("item.update", {
|
||||
item_id: item.id, pack_unit: ev.target.value.trim() || null,
|
||||
}),
|
||||
});
|
||||
|
||||
return el("div.item-menu", {},
|
||||
hintBlock,
|
||||
el("label", {}, "Anzahl und Gebinde",
|
||||
el("span.hint", {}, " (Preis gilt je Gebinde)")),
|
||||
el("div.add-fields", {}, countEdit, packSizeEdit, packUnitEdit),
|
||||
el("label", {}, "Eigenschaft"), variantField,
|
||||
el("label", {}, "Notiz"), noteField,
|
||||
el("label", {}, "Markt"), marketSelect,
|
||||
el("label", {}, "Warengruppe"), categorySelect,
|
||||
el("div.menu-actions", {},
|
||||
el("button", {
|
||||
type: "button",
|
||||
onclick: () => {
|
||||
openMenuId = null;
|
||||
change("item.update", {
|
||||
item_id: item.id,
|
||||
status: deferred ? "open" : "deferred",
|
||||
});
|
||||
},
|
||||
}, deferred ? "Wieder aufnehmen" : "Zurückstellen"),
|
||||
el("button.danger", {
|
||||
type: "button",
|
||||
onclick: () => {
|
||||
openMenuId = null;
|
||||
change("item.delete", { item_id: item.id });
|
||||
},
|
||||
}, "Löschen"))
|
||||
);
|
||||
}
|
||||
|
||||
function marketSection(market) {
|
||||
return el("section.market", {},
|
||||
el("h2", {},
|
||||
el("span", {}, market.market_name),
|
||||
el("span.market-meta", {},
|
||||
market.open_count === 1 ? "1 offen" : `${market.open_count} offen`,
|
||||
market.total_cents ? ` · ${euro(market.total_cents)}` : "")),
|
||||
market.categories.map((cat) =>
|
||||
el("div.category", {},
|
||||
el("h3", {}, cat.category_name),
|
||||
el("ul.items", {}, cat.items.map(itemRow))))
|
||||
);
|
||||
}
|
||||
|
||||
function addRow() {
|
||||
const nameField = el("input", {
|
||||
id: "add-name",
|
||||
type: "text",
|
||||
maxLength: 200,
|
||||
placeholder: "Artikel hinzufügen",
|
||||
autocomplete: "off",
|
||||
onkeydown: (ev) => { if (ev.key === "Enter") addItem(); },
|
||||
});
|
||||
// Anzahl und Gebinde getrennt: Der Preis gilt je Gebinde, die
|
||||
// Summe ist Preis mal Anzahl. Steckte beides in einem Feld, ergaben
|
||||
// 500 ml zu 2,99 EUR eine Summe von 1495 EUR.
|
||||
const countField = el("input.count", {
|
||||
type: "text", inputMode: "numeric", placeholder: "Anz.", value: "",
|
||||
title: "Stückzahl – wie viele Packungen",
|
||||
onkeydown: (ev) => { if (ev.key === "Enter") addItem(); },
|
||||
});
|
||||
const qtyField = el("input.qty", {
|
||||
type: "text", inputMode: "decimal", placeholder: "Gebinde",
|
||||
title: "Packungsgröße, z. B. 500",
|
||||
onkeydown: (ev) => { if (ev.key === "Enter") addItem(); },
|
||||
});
|
||||
const unitField = el("input.unit", {
|
||||
type: "text", maxLength: 32, placeholder: "Einheit",
|
||||
title: "z. B. ml, g, Stk",
|
||||
onkeydown: (ev) => { if (ev.key === "Enter") addItem(); },
|
||||
});
|
||||
|
||||
// Vorschläge aus dem, was auf dieser Liste schon vorkommt - so muss
|
||||
// "bunt, ganz" nur einmal getippt werden.
|
||||
const suggestions = new Set();
|
||||
for (const market of view?.markets || []) {
|
||||
for (const cat of market.categories) {
|
||||
for (const item of cat.items) {
|
||||
if (item.variant) suggestions.add(item.variant);
|
||||
}
|
||||
}
|
||||
}
|
||||
const variantList = el("datalist", { id: "variant-suggestions" },
|
||||
[...suggestions].sort((a, b) => a.localeCompare(b, "de"))
|
||||
.map((v) => el("option", { value: v })));
|
||||
|
||||
const variantField = el("input.variant", {
|
||||
type: "text",
|
||||
maxLength: 200,
|
||||
placeholder: "Eigenschaft",
|
||||
title: "Nähere Bestimmung, z. B. „bunt, ganz“ oder „laktosefrei“",
|
||||
autocomplete: "off",
|
||||
list: "variant-suggestions",
|
||||
onkeydown: (ev) => { if (ev.key === "Enter") addItem(); },
|
||||
});
|
||||
// el() setzt "list" nicht als Eigenschaft - das Attribut muss direkt
|
||||
// gesetzt werden, sonst findet der Browser die Vorschlagsliste nicht.
|
||||
variantField.setAttribute("list", "variant-suggestions");
|
||||
|
||||
async function addItem() {
|
||||
const name = nameField.value.trim();
|
||||
if (!name) return;
|
||||
const payload = {
|
||||
article_name: name,
|
||||
count: parseCount(countField.value),
|
||||
pack_size: parseQuantity(qtyField.value),
|
||||
pack_unit: unitField.value.trim() || null,
|
||||
variant: variantField.value.trim() || null,
|
||||
};
|
||||
nameField.value = "";
|
||||
countField.value = "";
|
||||
qtyField.value = "";
|
||||
unitField.value = "";
|
||||
variantField.value = "";
|
||||
await change("item.create", payload);
|
||||
document.getElementById("add-name")?.focus();
|
||||
}
|
||||
|
||||
/** Strichcode scannen, auflösen, bestätigen, hinzufügen.
|
||||
*
|
||||
* Braucht Verbindung: Sowohl der eigene Artikelstamm als auch die
|
||||
* Produktdatenbank liegen auf dem Server. Offline bliebe eine
|
||||
* Ziffernfolge ohne Bedeutung - deshalb hier ehrlich abbrechen,
|
||||
* statt etwas Unbrauchbares in die Warteschlange zu legen. */
|
||||
async function scanAndAdd() {
|
||||
const code = await scanBarcode();
|
||||
if (!code) return;
|
||||
|
||||
if (!state.online) {
|
||||
say("Zum Scannen wird eine Verbindung gebraucht – die Zuordnung " +
|
||||
"des Codes steht auf dem Server.");
|
||||
return;
|
||||
}
|
||||
|
||||
let lookup;
|
||||
try {
|
||||
lookup = await get(
|
||||
`/api/lists/${listId}/barcode/${encodeURIComponent(code)}`);
|
||||
} catch (err) {
|
||||
say(err.message);
|
||||
return;
|
||||
}
|
||||
|
||||
// Bereits im eigenen Bestand: ohne Rückfrage auf die Liste.
|
||||
if (lookup.source === "catalog") {
|
||||
await change("item.create", {
|
||||
article_id: lookup.article_id,
|
||||
barcode: code,
|
||||
count: parseCount(countField.value),
|
||||
pack_size: parseQuantity(qtyField.value),
|
||||
pack_unit: unitField.value.trim() || null,
|
||||
variant: variantField.value.trim() || null,
|
||||
});
|
||||
countField.value = "";
|
||||
qtyField.value = "";
|
||||
unitField.value = "";
|
||||
variantField.value = "";
|
||||
say(`„${lookup.name}“ hinzugefügt.`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Sonst bestätigen lassen - auch bei einem Treffer in der
|
||||
// Produktdatenbank. Fremde Angaben sollen nicht ungeprüft in den
|
||||
// eigenen Artikelstamm wandern.
|
||||
const confirmed = await confirmScan(lookup);
|
||||
if (!confirmed) return;
|
||||
|
||||
await change("item.create", {
|
||||
article_name: confirmed.name,
|
||||
barcode: code,
|
||||
count: parseCount(confirmed.count),
|
||||
pack_size: parseQuantity(confirmed.packSize),
|
||||
pack_unit: confirmed.packUnit || null,
|
||||
variant: confirmed.variant || null,
|
||||
});
|
||||
}
|
||||
|
||||
// Aufbau: Name über die volle Breite, darunter die Detailfelder,
|
||||
// darunter die Schaltflächen. Vorher standen Felder und
|
||||
// Schaltflächen in einer Zeile - auf schmalen Geräten lief der
|
||||
// letzte Knopf aus der Box.
|
||||
return el("div.add-row", {},
|
||||
nameField,
|
||||
el("div.add-fields", {}, countField, qtyField, unitField, variantField),
|
||||
el("div.add-actions", {},
|
||||
cameraAvailable()
|
||||
? el("button.secondary", {
|
||||
type: "button",
|
||||
title: "Strichcode scannen",
|
||||
onclick: scanAndAdd,
|
||||
}, "Scannen")
|
||||
: null,
|
||||
el("button.primary", { type: "button", onclick: addItem }, "Hinzufügen")),
|
||||
variantList
|
||||
);
|
||||
}
|
||||
|
||||
function statusLine() {
|
||||
const pendingCount = view?.pending_ops || 0;
|
||||
|
||||
if (!state.online) {
|
||||
return el("p.sync-hint.offline", {},
|
||||
"Offline – Änderungen werden gespeichert und später übertragen",
|
||||
pendingCount ? ` (${pendingCount} wartend).` : ".");
|
||||
}
|
||||
if (pendingCount) {
|
||||
return el("p.sync-hint.pending", {},
|
||||
pendingCount === 1
|
||||
? "1 Änderung wird übertragen …"
|
||||
: `${pendingCount} Änderungen werden übertragen …`);
|
||||
}
|
||||
if (state.syncState === "error") {
|
||||
return el("p.sync-hint.problem", {},
|
||||
"Der Server ist gerade nicht erreichbar. ",
|
||||
el("button.linklike", {
|
||||
type: "button",
|
||||
onclick: () => refresh({ fromServer: true }),
|
||||
}, "Erneut versuchen"));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Gesamtansicht
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
function render() {
|
||||
if (!view) {
|
||||
mount(root, el("p.loading", {}, "Wird geladen …"));
|
||||
return;
|
||||
}
|
||||
|
||||
const boughtCount = view.markets.reduce(
|
||||
(sum, m) => sum + m.categories.reduce(
|
||||
(n, c) => n + c.items.filter((i) => i.status === "bought").length, 0), 0);
|
||||
|
||||
mount(root,
|
||||
el("header.bar", {},
|
||||
el("button.linklike", { type: "button", onclick: back }, "‹ Listen"),
|
||||
el("span.bar-actions", {},
|
||||
// Serverseitiger Ausdruck: eigene Seite, damit auch ohne
|
||||
// geöffnete App gedruckt werden kann.
|
||||
el("a.linklike", {
|
||||
href: `/api/lists/${listId}/print`,
|
||||
target: "_blank",
|
||||
rel: "noopener",
|
||||
}, "Drucken"),
|
||||
el("button.linklike", { type: "button", onclick: () => prices(listId) },
|
||||
"Preise"),
|
||||
el("button.linklike", { type: "button", onclick: () => articles(listId) },
|
||||
"Artikel"),
|
||||
el("button.linklike", { type: "button", onclick: () => manage(listId) },
|
||||
"Märkte & Gruppen"),
|
||||
meta && meta.may_share_public
|
||||
? el("button.linklike", { type: "button", onclick: () => share(listId) },
|
||||
meta.member_count > 1 ? `Teilen (${meta.member_count})` : "Teilen")
|
||||
: null)),
|
||||
|
||||
statusLine(),
|
||||
banner ? el("p.error", {}, banner) : null,
|
||||
|
||||
el("section.card", {},
|
||||
el("h1", {}, view.list_name),
|
||||
addRow()),
|
||||
|
||||
view.markets.length
|
||||
? view.markets.map(marketSection)
|
||||
: el("p.empty", {}, "Die Liste ist leer."),
|
||||
|
||||
el("footer.summary", {},
|
||||
el("div.total", {},
|
||||
el("span", {}, "Gesamt"),
|
||||
el("strong", {}, euro(view.grand_total_cents))),
|
||||
boughtCount
|
||||
? el("button.secondary", {
|
||||
type: "button",
|
||||
onclick: () => change("items.clear_bought", {}),
|
||||
}, boughtCount === 1
|
||||
? "1 gekauften Eintrag entfernen"
|
||||
: `${boughtCount} gekaufte Einträge entfernen`)
|
||||
: null)
|
||||
);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Start
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
// Erst den lokalen Stand zeigen - die App startet dadurch sofort,
|
||||
// auch ohne Verbindung.
|
||||
await readLocal();
|
||||
render();
|
||||
|
||||
// Dann im Hintergrund abgleichen.
|
||||
await syncNow(listId);
|
||||
await readLocal();
|
||||
await loadHints();
|
||||
render();
|
||||
|
||||
// Änderungen anderer Geräte.
|
||||
watch(listId, async () => {
|
||||
await readLocal();
|
||||
render();
|
||||
});
|
||||
|
||||
return () => { readLocal().then(render); };
|
||||
}
|
||||
232
web/html/js/views/lists.js
Normal file
232
web/html/js/views/lists.js
Normal file
@@ -0,0 +1,232 @@
|
||||
// Übersicht aller Listen des angemeldeten Nutzers.
|
||||
"use strict";
|
||||
|
||||
import { del, get, patch, post } from "../api.js";
|
||||
import * as db from "../db.js";
|
||||
import { clear, el, mount } from "../dom.js";
|
||||
import { set, state } from "../store.js";
|
||||
|
||||
// Welche Zeile hat gerade ihr Menü offen
|
||||
let openMenuId = null;
|
||||
// Welche Zeile wird gerade umbenannt
|
||||
let renamingId = null;
|
||||
|
||||
const ROLE_LABEL = { owner: "Eigentümer", editor: "Bearbeiter", viewer: "Nur lesen" };
|
||||
|
||||
export async function listsView(root, { openList, onLogout, settings }) {
|
||||
let banner = null;
|
||||
|
||||
async function reload() {
|
||||
set({ lists: await get("/api/lists") });
|
||||
}
|
||||
|
||||
function say(message, kind = "error") {
|
||||
banner = { message, kind };
|
||||
render();
|
||||
setTimeout(() => { banner = null; render(); }, 6000);
|
||||
}
|
||||
|
||||
async function guarded(fn) {
|
||||
try {
|
||||
await fn();
|
||||
await reload();
|
||||
render();
|
||||
} catch (err) {
|
||||
say(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Eine Zeile
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
function row(entry) {
|
||||
const isOwner = entry.role === "owner";
|
||||
|
||||
if (renamingId === entry.id) {
|
||||
const field = el("input", {
|
||||
type: "text",
|
||||
value: entry.name,
|
||||
maxLength: 120,
|
||||
onkeydown: (ev) => {
|
||||
if (ev.key === "Enter") commit();
|
||||
if (ev.key === "Escape") { renamingId = null; render(); }
|
||||
},
|
||||
});
|
||||
|
||||
function commit() {
|
||||
const name = field.value.trim();
|
||||
if (!name) {
|
||||
say("Der Name darf nicht leer sein.");
|
||||
return;
|
||||
}
|
||||
renamingId = null;
|
||||
if (name === entry.name) {
|
||||
render();
|
||||
return;
|
||||
}
|
||||
guarded(() => patch(`/api/lists/${entry.id}`, { name }));
|
||||
}
|
||||
|
||||
const node = el("li.list-row.renaming", {},
|
||||
el("div.row", {}, field,
|
||||
el("button.primary", { type: "button", onclick: commit }, "Speichern"),
|
||||
el("button", {
|
||||
type: "button",
|
||||
className: "cancel",
|
||||
onclick: () => { renamingId = null; render(); },
|
||||
}, "Abbrechen"))
|
||||
);
|
||||
queueMicrotask(() => { field.focus(); field.select(); });
|
||||
return node;
|
||||
}
|
||||
|
||||
const people = entry.member_count === 1
|
||||
? "nur ich"
|
||||
: `${entry.member_count} Personen`;
|
||||
|
||||
return el("li.list-row", {},
|
||||
el("div.list-main", {},
|
||||
el("button.list-open", { type: "button", onclick: () => openList(entry.id) },
|
||||
el("span.name", {}, entry.name),
|
||||
el("span.meta", {}, `${people} · ${ROLE_LABEL[entry.role] || entry.role}`)),
|
||||
el("button.menu-toggle", {
|
||||
type: "button",
|
||||
title: "Weitere Aktionen",
|
||||
"aria-label": `Aktionen für ${entry.name}`,
|
||||
onclick: () => {
|
||||
openMenuId = openMenuId === entry.id ? null : entry.id;
|
||||
render();
|
||||
},
|
||||
}, "⋯")),
|
||||
|
||||
openMenuId === entry.id
|
||||
? el("div.list-menu", {},
|
||||
isOwner
|
||||
? el("div.menu-actions", {},
|
||||
el("button", {
|
||||
type: "button",
|
||||
onclick: () => {
|
||||
openMenuId = null;
|
||||
renamingId = entry.id;
|
||||
render();
|
||||
},
|
||||
}, "Umbenennen"),
|
||||
el("button.danger", {
|
||||
type: "button",
|
||||
onclick: () => remove(entry),
|
||||
}, "Löschen"))
|
||||
: el("div.menu-actions", {},
|
||||
el("button.danger", {
|
||||
type: "button",
|
||||
onclick: () => leave(entry),
|
||||
}, "Liste verlassen")))
|
||||
: null
|
||||
);
|
||||
}
|
||||
|
||||
function remove(entry) {
|
||||
const extra = entry.member_count > 1
|
||||
? `\n\n${entry.member_count - 1} weitere Person(en) verlieren damit den Zugriff.`
|
||||
: "";
|
||||
if (!confirm(`Liste „${entry.name}“ löschen?${extra}`)) return;
|
||||
openMenuId = null;
|
||||
guarded(async () => {
|
||||
await del(`/api/lists/${entry.id}`);
|
||||
// Zwischenspeicher der gelöschten Liste mit aufräumen, sonst
|
||||
// bleibt ein verwaister Stand in IndexedDB liegen.
|
||||
await forgetLocally(entry.id);
|
||||
});
|
||||
}
|
||||
|
||||
function leave(entry) {
|
||||
if (!confirm(
|
||||
`Liste „${entry.name}“ verlassen?\n\n` +
|
||||
"Du siehst sie danach nicht mehr. Der Eigentümer kann dich erneut einladen."
|
||||
)) return;
|
||||
openMenuId = null;
|
||||
guarded(async () => {
|
||||
await post(`/api/lists/${entry.id}/leave`);
|
||||
await forgetLocally(entry.id);
|
||||
});
|
||||
}
|
||||
|
||||
async function forgetLocally(listId) {
|
||||
await Promise.allSettled([
|
||||
db.cacheDelete(`view:${listId}`),
|
||||
db.cacheDelete(`markets:${listId}`),
|
||||
db.cacheDelete(`categories:${listId}`),
|
||||
db.cacheDelete(`meta:${listId}`),
|
||||
db.clearList(listId),
|
||||
]);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Neue Liste
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
function createRow() {
|
||||
const field = el("input", {
|
||||
id: "new-list",
|
||||
type: "text",
|
||||
maxLength: 120,
|
||||
placeholder: "z. B. Wocheneinkauf",
|
||||
onkeydown: (ev) => { if (ev.key === "Enter") create(); },
|
||||
});
|
||||
const button = el("button.primary", { type: "button", onclick: () => create() },
|
||||
"Anlegen");
|
||||
|
||||
async function create() {
|
||||
const name = field.value.trim();
|
||||
if (!name) {
|
||||
say("Bitte einen Namen eingeben.");
|
||||
return;
|
||||
}
|
||||
button.disabled = true;
|
||||
try {
|
||||
await post("/api/lists", { name });
|
||||
field.value = "";
|
||||
await reload();
|
||||
// Ohne diesen Aufruf blieb die neue Liste unsichtbar, bis die
|
||||
// Seite neu geladen wurde: Der Zustandsspeicher war aktuell,
|
||||
// die Ansicht hatte sich aber nie dafür angemeldet.
|
||||
render();
|
||||
document.getElementById("new-list")?.focus();
|
||||
} catch (err) {
|
||||
say(err.message);
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
return el("div.row", {}, field, button);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Gesamtansicht
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
function render() {
|
||||
mount(root,
|
||||
el("header.bar", {},
|
||||
el("span.who", {}, state.user.display_name || state.user.email),
|
||||
el("span.bar-actions", {},
|
||||
el("button.linklike", { type: "button", onclick: settings }, "Einstellungen"),
|
||||
el("button.linklike", { type: "button", onclick: onLogout }, "Abmelden"))),
|
||||
|
||||
banner ? el(banner.kind === "error" ? "p.error" : "p.notice", {}, banner.message) : null,
|
||||
|
||||
el("section.card", {},
|
||||
el("h1", {}, "Meine Listen"),
|
||||
state.lists.length
|
||||
? el("ul.lists", {}, state.lists.map(row))
|
||||
: el("p.empty", {}, "Noch keine Liste vorhanden."),
|
||||
el("label", { htmlFor: "new-list" }, "Neue Liste"),
|
||||
createRow())
|
||||
);
|
||||
}
|
||||
|
||||
await reload();
|
||||
render();
|
||||
return render;
|
||||
}
|
||||
186
web/html/js/views/manage.js
Normal file
186
web/html/js/views/manage.js
Normal file
@@ -0,0 +1,186 @@
|
||||
// Märkte und Warengruppen pflegen.
|
||||
//
|
||||
// Die Reihenfolge wird gezogen, nicht getippt: `sort_order` verwaltet die
|
||||
// App selbst und schreibt nach jedem Verschieben 10, 20, 30 … zurück. Die
|
||||
// Abstände lassen Platz, falls später einmal einzeln eingefügt werden soll.
|
||||
"use strict";
|
||||
|
||||
import { del, get, post, put } from "../api.js";
|
||||
import { clear, el, mount } from "../dom.js";
|
||||
import { makeSortable } from "../sortable.js";
|
||||
import { set, state } from "../store.js";
|
||||
|
||||
const STEP = 10;
|
||||
|
||||
export async function manageView(root, { listId, back }) {
|
||||
let banner = null;
|
||||
let listName = "";
|
||||
|
||||
async function reload() {
|
||||
const [markets, categories, meta] = await Promise.all([
|
||||
get(`/api/lists/${listId}/markets`),
|
||||
get(`/api/lists/${listId}/categories`),
|
||||
get(`/api/lists/${listId}`),
|
||||
]);
|
||||
listName = meta.name;
|
||||
set({ markets, categories });
|
||||
}
|
||||
|
||||
function say(message, kind = "error") {
|
||||
banner = { message, kind };
|
||||
render();
|
||||
setTimeout(() => { banner = null; render(); }, 6000);
|
||||
}
|
||||
|
||||
async function guarded(fn, { silent = false } = {}) {
|
||||
try {
|
||||
await fn();
|
||||
await reload();
|
||||
render();
|
||||
} catch (err) {
|
||||
if (!silent) say(err.message);
|
||||
await reload().catch(() => {});
|
||||
render();
|
||||
}
|
||||
}
|
||||
|
||||
/** Schreibt die neue Reihenfolge zurück - nur die Einträge, deren Rang
|
||||
* sich tatsächlich geändert hat. */
|
||||
async function persistOrder(entries, endpoint, orderedIds) {
|
||||
const byId = new Map(entries.map((e) => [e.id, e]));
|
||||
const updates = [];
|
||||
|
||||
orderedIds.forEach((id, index) => {
|
||||
const entry = byId.get(id);
|
||||
const next = (index + 1) * STEP;
|
||||
if (entry && entry.sort_order !== next) {
|
||||
updates.push(put(`/api/lists/${listId}/${endpoint}/${entry.id}`, {
|
||||
name: entry.name,
|
||||
sort_order: next,
|
||||
}));
|
||||
}
|
||||
});
|
||||
|
||||
if (!updates.length) return;
|
||||
await guarded(() => Promise.all(updates));
|
||||
}
|
||||
|
||||
function section({ title, entries, endpoint, hint, emptyText }) {
|
||||
const nameField = el("input", {
|
||||
type: "text",
|
||||
maxLength: 120,
|
||||
placeholder: "Name",
|
||||
onkeydown: (ev) => { if (ev.key === "Enter") add(); },
|
||||
});
|
||||
|
||||
function add() {
|
||||
const name = nameField.value.trim();
|
||||
if (!name) return;
|
||||
guarded(async () => {
|
||||
// Neues ans Ende: ein Schritt hinter dem letzten Rang.
|
||||
const last = entries.length ? Math.max(...entries.map((e) => e.sort_order)) : 0;
|
||||
await post(`/api/lists/${listId}/${endpoint}`, {
|
||||
name,
|
||||
sort_order: last + STEP,
|
||||
});
|
||||
nameField.value = "";
|
||||
});
|
||||
}
|
||||
|
||||
const list = el("ul.sortable", {});
|
||||
|
||||
for (const entry of entries) {
|
||||
const handle = el("button.grip", {
|
||||
type: "button",
|
||||
title: "Zum Verschieben ziehen – oder mit den Pfeiltasten bewegen",
|
||||
"aria-label": `${entry.name} verschieben`,
|
||||
}, "⠿");
|
||||
|
||||
const name = el("input.entry-name", {
|
||||
type: "text",
|
||||
value: entry.name,
|
||||
maxLength: 120,
|
||||
onchange: (ev) => {
|
||||
const value = ev.target.value.trim();
|
||||
if (!value) {
|
||||
ev.target.value = entry.name;
|
||||
return;
|
||||
}
|
||||
guarded(() => put(`/api/lists/${listId}/${endpoint}/${entry.id}`, {
|
||||
name: value,
|
||||
sort_order: entry.sort_order,
|
||||
}));
|
||||
},
|
||||
});
|
||||
|
||||
const remove = el("button.danger.remove", {
|
||||
type: "button",
|
||||
title: `${entry.name} löschen`,
|
||||
"aria-label": `${entry.name} löschen`,
|
||||
onclick: () => {
|
||||
if (!confirm(`„${entry.name}“ löschen?`)) return;
|
||||
guarded(() => del(`/api/lists/${listId}/${endpoint}/${entry.id}`));
|
||||
},
|
||||
}, "×");
|
||||
|
||||
list.append(el("li.sortable-row", { dataset: { id: entry.id } },
|
||||
handle, name, remove));
|
||||
}
|
||||
|
||||
if (entries.length > 1) {
|
||||
makeSortable(list, {
|
||||
handleSelector: ".grip",
|
||||
itemSelector: ".sortable-row",
|
||||
onReorder: (ids) => persistOrder(entries, endpoint, ids),
|
||||
});
|
||||
}
|
||||
|
||||
return el("section.card", {},
|
||||
el("h2", {}, title),
|
||||
hint ? el("p.lead", {}, hint) : null,
|
||||
entries.length ? list : el("p.empty", {}, emptyText),
|
||||
el("label", { htmlFor: `add-${endpoint}` }, "Hinzufügen"),
|
||||
el("div.row", {},
|
||||
Object.assign(nameField, { id: `add-${endpoint}` }),
|
||||
el("button.primary", { type: "button", onclick: add }, "Anlegen"))
|
||||
);
|
||||
}
|
||||
|
||||
function render() {
|
||||
mount(root,
|
||||
el("header.bar", {},
|
||||
el("button.linklike", { type: "button", onclick: back },
|
||||
"‹ Zurück zur Liste"),
|
||||
listName ? el("span.who", {}, listName) : null),
|
||||
|
||||
banner ? el(banner.kind === "error" ? "p.error" : "p.notice", {}, banner.message) : null,
|
||||
|
||||
section({
|
||||
title: "Märkte",
|
||||
entries: state.markets,
|
||||
endpoint: "markets",
|
||||
hint: "Die Reihenfolge bestimmt, in welcher Folge die Märkte in der " +
|
||||
"Liste und im Ausdruck erscheinen. Am Anfasser ziehen, um sie " +
|
||||
"zu ändern.",
|
||||
emptyText: "Noch kein Markt angelegt.",
|
||||
}),
|
||||
|
||||
section({
|
||||
title: "Warengruppen",
|
||||
entries: state.categories,
|
||||
endpoint: "categories",
|
||||
hint: "Innerhalb eines Marktes wird nach diesen Gruppen gegliedert; " +
|
||||
"die Artikel darin stehen alphabetisch.",
|
||||
emptyText: "Noch keine Warengruppe angelegt.",
|
||||
}),
|
||||
|
||||
el("p.footnote", {},
|
||||
"Ein gelöschter Markt nimmt keine Einträge mit – sie landen in der " +
|
||||
"Gruppe „Ohne Markt“.")
|
||||
);
|
||||
}
|
||||
|
||||
await reload();
|
||||
render();
|
||||
return render;
|
||||
}
|
||||
164
web/html/js/views/prices.js
Normal file
164
web/html/js/views/prices.js
Normal file
@@ -0,0 +1,164 @@
|
||||
// Preisvergleich zwischen Märkten.
|
||||
//
|
||||
// Bewusst keine Matrix Artikel × Markt: Bei vier Märkten und dreißig
|
||||
// Artikeln wird die auf einem Telefon unlesbar. Stattdessen je Artikel
|
||||
// eine Karte mit den Märkten nach Preis sortiert - man sucht ohnehin
|
||||
// "wo ist X am günstigsten", nicht "wie sieht die ganze Tabelle aus".
|
||||
"use strict";
|
||||
|
||||
import { get } from "../api.js";
|
||||
import { el, euro, formatQuantity, mount } from "../dom.js";
|
||||
|
||||
let expandedId = null;
|
||||
|
||||
function formatDate(iso) {
|
||||
return new Date(iso).toLocaleDateString("de-DE", {
|
||||
day: "2-digit", month: "2-digit", year: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
/** Preis je Einheit, geliefert in Zehntelcent. */
|
||||
function unitPrice(deci, unit) {
|
||||
if (!deci || !unit) return null;
|
||||
return `${euro(deci / 10)} je ${unit}`;
|
||||
}
|
||||
|
||||
export async function pricesView(root, { listId, back }) {
|
||||
let data = null;
|
||||
let banner = null;
|
||||
let detail = null;
|
||||
|
||||
async function reload() {
|
||||
data = await get(`/api/lists/${listId}/prices`);
|
||||
}
|
||||
|
||||
function say(message) {
|
||||
banner = message;
|
||||
render();
|
||||
setTimeout(() => { banner = null; render(); }, 6000);
|
||||
}
|
||||
|
||||
async function toggle(articleId) {
|
||||
if (expandedId === articleId) {
|
||||
expandedId = null;
|
||||
detail = null;
|
||||
render();
|
||||
return;
|
||||
}
|
||||
expandedId = articleId;
|
||||
detail = null;
|
||||
render();
|
||||
try {
|
||||
detail = await get(`/api/lists/${listId}/articles/${articleId}/prices`);
|
||||
render();
|
||||
} catch (err) {
|
||||
say(err.message);
|
||||
}
|
||||
}
|
||||
|
||||
function marketRow(row, marketId, cents, isBest) {
|
||||
return el("div", { className: `price-row${isBest ? " best" : ""}` },
|
||||
el("span.market", {}, data.market_names[marketId] || "?"),
|
||||
el("span.amount", {}, euro(cents)),
|
||||
isBest && row.spread_cents > 0
|
||||
? el("span.badge", {}, "günstigster")
|
||||
: row.spread_cents > 0
|
||||
? el("span.diff", {}, `+${euro(cents - row.best_cents)}`)
|
||||
: null
|
||||
);
|
||||
}
|
||||
|
||||
function detailBlock() {
|
||||
if (!detail) return el("p.loading", {}, "Wird geladen …");
|
||||
|
||||
return el("div.price-detail", {},
|
||||
el("h4", {}, "Beobachtungen je Markt"),
|
||||
el("ul.price-markets", {}, detail.markets.map((m) =>
|
||||
el("li", {},
|
||||
el("span.market", {}, m.market_name),
|
||||
el("span.sub", {},
|
||||
m.latest_pack_size
|
||||
? `${formatQuantity(m.latest_pack_size)} ${m.latest_pack_unit || ""} · `
|
||||
: "",
|
||||
unitPrice(m.unit_price_deci, m.latest_pack_unit)
|
||||
? `${unitPrice(m.unit_price_deci, m.latest_pack_unit)} · `
|
||||
: "",
|
||||
m.observations === 1
|
||||
? "einmal erfasst"
|
||||
: `${m.observations}× erfasst, ${euro(m.min_cents)}–${euro(m.max_cents)}`,
|
||||
` · zuletzt ${formatDate(m.latest_at)}`),
|
||||
el("span.amount", {}, euro(m.latest_cents))))),
|
||||
|
||||
detail.history.length > 1
|
||||
? el("details.price-history", {},
|
||||
el("summary", {}, `Verlauf (${detail.history.length} Einträge)`),
|
||||
el("ul", {}, detail.history.map((h) =>
|
||||
el("li", {},
|
||||
`${formatDate(h.recorded_at)} · ${h.market_name} · ${euro(h.price_cents)}`,
|
||||
h.pack_size
|
||||
? ` (${formatQuantity(h.pack_size)} ${h.pack_unit || ""})` : ""))))
|
||||
: null
|
||||
);
|
||||
}
|
||||
|
||||
function articleCard(row) {
|
||||
const entries = Object.entries(row.prices).sort((a, b) => a[1] - b[1]);
|
||||
|
||||
return el("section.card.price-card", {},
|
||||
el("button.price-head", {
|
||||
type: "button",
|
||||
onclick: () => toggle(row.article_id),
|
||||
"aria-expanded": expandedId === row.article_id ? "true" : "false",
|
||||
},
|
||||
el("span.name", {}, row.article_name),
|
||||
row.spread_cents > 0
|
||||
? el("span.spread", {}, `bis ${euro(row.spread_cents)} Unterschied`)
|
||||
: el("span.spread", {}, "überall gleich")),
|
||||
|
||||
el("div.price-rows", {}, entries.map(([marketId, cents]) =>
|
||||
marketRow(row, marketId, cents, row.best_market_ids.includes(marketId)))),
|
||||
|
||||
expandedId === row.article_id ? detailBlock() : null
|
||||
);
|
||||
}
|
||||
|
||||
function render() {
|
||||
const withSpread = data.rows.filter((r) => r.spread_cents > 0);
|
||||
const savings = withSpread.reduce((sum, r) => sum + r.spread_cents, 0);
|
||||
|
||||
mount(root,
|
||||
el("header.bar", {},
|
||||
el("button.linklike", { type: "button", onclick: back },
|
||||
"‹ Zurück zur Liste")),
|
||||
|
||||
banner ? el("p.error", {}, banner) : null,
|
||||
|
||||
el("section.card", {},
|
||||
el("h1", {}, "Preise"),
|
||||
data.rows.length
|
||||
? el("p.lead", {},
|
||||
`${data.rows.length} Artikel mit erfassten Preisen. `,
|
||||
withSpread.length
|
||||
? `Bei ${withSpread.length} davon unterscheiden sich die Märkte – ` +
|
||||
`zusammen ${euro(savings)} Unterschied, wenn jeder Artikel ` +
|
||||
`im günstigsten Markt gekauft würde.`
|
||||
: "Bislang gibt es keine Preisunterschiede zwischen den Märkten.")
|
||||
: el("p.empty", {},
|
||||
"Noch keine Preise erfasst. Trag beim Einkaufen Preise an den " +
|
||||
"Artikeln ein – sobald ein Eintrag einem Markt zugeordnet ist, " +
|
||||
"wird der Preis hier gesammelt.")),
|
||||
|
||||
data.rows.map(articleCard),
|
||||
|
||||
data.rows.length
|
||||
? el("p.footnote", {},
|
||||
"Erfasst werden Markt, Artikel, Betrag und Zeitpunkt – ohne " +
|
||||
"Angabe, wer den Preis eingetragen hat.")
|
||||
: null
|
||||
);
|
||||
}
|
||||
|
||||
await reload();
|
||||
render();
|
||||
return render;
|
||||
}
|
||||
143
web/html/js/views/public.js
Normal file
143
web/html/js/views/public.js
Normal file
@@ -0,0 +1,143 @@
|
||||
// Öffentliche Ansicht einer Liste über /s/<token>. Kein Konto nötig.
|
||||
// Nur ansehen und - sofern erlaubt - abhaken. Auf Druck ausgelegt.
|
||||
"use strict";
|
||||
|
||||
import { get, post } from "../api.js";
|
||||
import { clear, el, euro, formatPack, mount } from "../dom.js";
|
||||
|
||||
function formatDate(iso) {
|
||||
return new Date(iso).toLocaleDateString("de-DE", {
|
||||
day: "2-digit", month: "2-digit", year: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
export async function publicView(root, { token }) {
|
||||
let view = null;
|
||||
let banner = null;
|
||||
|
||||
async function load() {
|
||||
view = await get(`/api/public/${encodeURIComponent(token)}`);
|
||||
}
|
||||
|
||||
function say(message, kind = "error") {
|
||||
banner = { message, kind };
|
||||
render();
|
||||
setTimeout(() => { banner = null; render(); }, 6000);
|
||||
}
|
||||
|
||||
function itemRow(item) {
|
||||
const bought = item.status === "bought";
|
||||
|
||||
const check = view.allow_check
|
||||
? el("button.check", {
|
||||
type: "button",
|
||||
"aria-pressed": bought ? "true" : "false",
|
||||
title: bought ? "Als offen markieren" : "Als gekauft markieren",
|
||||
onclick: async () => {
|
||||
const next = bought ? "open" : "bought";
|
||||
item.status = next; // sofort anzeigen
|
||||
render();
|
||||
try {
|
||||
await post(`/api/public/${encodeURIComponent(token)}/items/${item.id}`,
|
||||
{ status: next });
|
||||
await load();
|
||||
} catch (err) {
|
||||
await load().catch(() => {});
|
||||
say(err.message);
|
||||
}
|
||||
render();
|
||||
},
|
||||
}, bought ? "✓" : "")
|
||||
: el("span.check.readonly", { "aria-hidden": "true" }, bought ? "✓" : "");
|
||||
|
||||
const pack = formatPack(item.pack_size, item.pack_unit);
|
||||
|
||||
return el("li", { className: `item${bought ? " bought" : ""}` },
|
||||
el("div.item-main", {},
|
||||
check,
|
||||
el("span", {
|
||||
className: `item-count${item.count > 1 ? " many" : ""}`,
|
||||
}, `${item.count || 1}\u00d7`),
|
||||
el("div.item-text", {},
|
||||
el("span.name", {}, item.article_name),
|
||||
pack || item.variant || item.note
|
||||
? el("span.sub", {},
|
||||
[pack, item.variant, item.note].filter(Boolean).join(" · "))
|
||||
: null),
|
||||
item.price_cents
|
||||
? el("span.price-static", {},
|
||||
euro(item.total_cents ?? item.price_cents))
|
||||
: null
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function render() {
|
||||
mount(root,
|
||||
el("header.public-head", {},
|
||||
el("h1", {}, view.list_name),
|
||||
el("p.sub", {},
|
||||
"Geteilte Ansicht",
|
||||
view.allow_check ? " – du kannst Artikel abhaken." : " – nur zum Ansehen.",
|
||||
` Gültig bis ${formatDate(view.expires_at)}.`)
|
||||
),
|
||||
banner ? el("p.error", {}, banner.message) : null,
|
||||
|
||||
view.markets.length
|
||||
? view.markets.map((market) =>
|
||||
el("section.market", {},
|
||||
el("h2", {},
|
||||
el("span", {}, market.market_name),
|
||||
el("span.market-meta", {},
|
||||
market.open_count === 1 ? "1 offen" : `${market.open_count} offen`,
|
||||
market.total_cents ? ` · ${euro(market.total_cents)}` : "")),
|
||||
market.categories.map((cat) =>
|
||||
el("div.category", {},
|
||||
el("h3", {}, cat.category_name),
|
||||
el("ul.items", {}, cat.items.map(itemRow))))))
|
||||
: el("p.empty", {}, "Die Liste ist leer."),
|
||||
|
||||
el("footer.summary", {},
|
||||
el("div.total", {},
|
||||
el("span", {}, "Gesamt"),
|
||||
el("strong", {}, euro(view.grand_total_cents)))),
|
||||
|
||||
el("p.footnote.no-print", {},
|
||||
// Eigene Druckseite statt window.print(): Sie ist auf A4
|
||||
// ausgelegt und funktioniert auch, wenn der Empfänger die
|
||||
// Ansicht nur weiterleiten will.
|
||||
el("a.linklike", {
|
||||
href: `/api/public/${encodeURIComponent(token)}/print`,
|
||||
target: "_blank",
|
||||
rel: "noopener",
|
||||
}, "Drucken"),
|
||||
" · ",
|
||||
el("button.linklike", {
|
||||
type: "button",
|
||||
onclick: async () => {
|
||||
try {
|
||||
await load();
|
||||
render();
|
||||
} catch (err) {
|
||||
say(err.message);
|
||||
}
|
||||
},
|
||||
}, "Aktualisieren"))
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
await load();
|
||||
} catch (err) {
|
||||
mount(root,
|
||||
el("section.card", {},
|
||||
el("h1", {}, "Liste nicht verfügbar"),
|
||||
el("p.error", {}, err.message),
|
||||
el("p.lead", {},
|
||||
"Bitte die Person, die den Link geteilt hat, um einen neuen."))
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
render();
|
||||
}
|
||||
110
web/html/js/views/scan-result.js
Normal file
110
web/html/js/views/scan-result.js
Normal file
@@ -0,0 +1,110 @@
|
||||
// Bestätigungsdialog nach einem Scan.
|
||||
//
|
||||
// Ersetzt das frühere prompt(): Dort ließ sich nur ein Name eingeben,
|
||||
// und die Herkunft der Angaben war nicht erkennbar. Wenn Daten aus einer
|
||||
// fremden Quelle vorbelegt werden, soll man sehen, woher sie stammen -
|
||||
// und sie ändern können, bevor sie im eigenen Bestand landen.
|
||||
"use strict";
|
||||
|
||||
import { el } from "../dom.js";
|
||||
|
||||
/**
|
||||
* @param {object} lookup Antwort von /api/lists/{id}/barcode/{code}
|
||||
* @returns {Promise<{name: string, quantity: string, unit: string, variant: string}|null>}
|
||||
*/
|
||||
export function confirmScan(lookup) {
|
||||
return new Promise((resolve) => {
|
||||
const known = lookup.found && lookup.source === "openfoodfacts";
|
||||
|
||||
const nameField = el("input", {
|
||||
type: "text",
|
||||
maxLength: 200,
|
||||
value: lookup.name || "",
|
||||
placeholder: "Wie heißt der Artikel?",
|
||||
onkeydown: (ev) => { if (ev.key === "Enter") accept(); },
|
||||
});
|
||||
const countField = el("input.count", {
|
||||
type: "text",
|
||||
inputMode: "numeric",
|
||||
value: lookup.count != null ? String(lookup.count) : "",
|
||||
placeholder: "Anz.",
|
||||
title: "Stückzahl",
|
||||
});
|
||||
const qtyField = el("input.qty", {
|
||||
type: "text",
|
||||
inputMode: "decimal",
|
||||
value: lookup.pack_size != null
|
||||
? String(lookup.pack_size).replace(".", ",") : "",
|
||||
placeholder: "Gebinde",
|
||||
title: "Packungsgröße",
|
||||
});
|
||||
const unitField = el("input.unit", {
|
||||
type: "text",
|
||||
maxLength: 32,
|
||||
value: lookup.pack_unit || "",
|
||||
placeholder: "Einheit",
|
||||
});
|
||||
const variantField = el("input.variant", {
|
||||
type: "text",
|
||||
maxLength: 200,
|
||||
value: lookup.brand || "",
|
||||
placeholder: "Eigenschaft",
|
||||
});
|
||||
|
||||
function accept() {
|
||||
const name = nameField.value.trim();
|
||||
if (!name) {
|
||||
nameField.focus();
|
||||
return;
|
||||
}
|
||||
close({
|
||||
name,
|
||||
count: countField.value,
|
||||
packSize: qtyField.value,
|
||||
packUnit: unitField.value.trim(),
|
||||
variant: variantField.value.trim(),
|
||||
});
|
||||
}
|
||||
|
||||
const dialog = el("div.modal", {},
|
||||
el("div.modal-card", {},
|
||||
el("h2", {}, known ? "Produkt gefunden" : "Neuer Artikel"),
|
||||
el("p.sub", {}, `Strichcode ${lookup.barcode}`),
|
||||
|
||||
known
|
||||
? el("p.source-note", {},
|
||||
"Vorgeschlagen aus Open Food Facts",
|
||||
lookup.package ? ` – Packung: ${lookup.package}` : "",
|
||||
". Bitte prüfen und bei Bedarf anpassen; übernommen wird, " +
|
||||
"was hier steht.")
|
||||
: el("p.lead", {},
|
||||
"Zu diesem Code liegen keine Angaben vor. Trag den Namen ein – " +
|
||||
"beim nächsten Scan wird der Artikel dann sofort gefunden."),
|
||||
|
||||
el("label", {}, "Name"), nameField,
|
||||
el("div.add-fields", {}, countField, qtyField, unitField, variantField),
|
||||
|
||||
el("div.menu-actions", {},
|
||||
el("button.primary", { type: "button", onclick: accept }, "Hinzufügen"),
|
||||
el("button", { type: "button", onclick: () => close(null) }, "Abbrechen"))
|
||||
)
|
||||
);
|
||||
|
||||
function close(result) {
|
||||
dialog.remove();
|
||||
window.removeEventListener("keydown", onKey);
|
||||
document.body.classList.remove("modal-open");
|
||||
resolve(result);
|
||||
}
|
||||
|
||||
function onKey(ev) {
|
||||
if (ev.key === "Escape") close(null);
|
||||
}
|
||||
|
||||
window.addEventListener("keydown", onKey);
|
||||
document.body.classList.add("modal-open");
|
||||
document.body.append(dialog);
|
||||
nameField.focus();
|
||||
nameField.select();
|
||||
});
|
||||
}
|
||||
171
web/html/js/views/scanner.js
Normal file
171
web/html/js/views/scanner.js
Normal file
@@ -0,0 +1,171 @@
|
||||
// Kamerasucher für Strichcodes.
|
||||
//
|
||||
// Zwei Wege, in dieser Reihenfolge:
|
||||
// 1. BarcodeDetector - nativ in Chrome und auf Android, erkennt auch
|
||||
// QR- und Datamatrix-Codes, läuft außerhalb des Hauptthreads.
|
||||
// 2. Eigener Decoder aus barcode.js - überall sonst, insbesondere
|
||||
// Safari auf iPhone und iPad.
|
||||
//
|
||||
// Wenn beides ausfällt (keine Kamera, Zugriff verweigert), bleibt die
|
||||
// Eingabe von Hand. Die Ziffernfolge steht unter jedem Strichcode.
|
||||
"use strict";
|
||||
|
||||
import { decodeImage } from "../barcode.js";
|
||||
import { clear, el } from "../dom.js";
|
||||
|
||||
const SCAN_INTERVAL_MS = 120;
|
||||
|
||||
async function nativeDetector() {
|
||||
if (!("BarcodeDetector" in window)) return null;
|
||||
try {
|
||||
const formats = await window.BarcodeDetector.getSupportedFormats();
|
||||
const wanted = ["ean_13", "ean_8", "upc_a", "upc_e", "code_128", "qr_code"]
|
||||
.filter((f) => formats.includes(f));
|
||||
if (!wanted.length) return null;
|
||||
return new window.BarcodeDetector({ formats: wanted });
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Öffnet den Sucher als Überlagerung.
|
||||
* @returns {Promise<string|null>} erkannte Ziffernfolge oder null bei Abbruch
|
||||
*/
|
||||
export function scanBarcode() {
|
||||
return new Promise((resolve) => {
|
||||
let stream = null;
|
||||
let timer = null;
|
||||
let closed = false;
|
||||
|
||||
const video = el("video", {
|
||||
playsInline: true,
|
||||
muted: true,
|
||||
autoplay: true,
|
||||
className: "scan-video",
|
||||
});
|
||||
const canvas = document.createElement("canvas");
|
||||
const hint = el("p.scan-hint", {}, "Kamera wird geöffnet …");
|
||||
|
||||
const manualField = el("input", {
|
||||
type: "text",
|
||||
inputMode: "numeric",
|
||||
placeholder: "Ziffern unter dem Strichcode",
|
||||
maxLength: 32,
|
||||
onkeydown: (ev) => { if (ev.key === "Enter") useManual(); },
|
||||
});
|
||||
|
||||
function useManual() {
|
||||
const value = manualField.value.replace(/\D/g, "");
|
||||
if (value.length < 6) {
|
||||
hint.textContent = "Bitte mindestens sechs Ziffern eingeben.";
|
||||
return;
|
||||
}
|
||||
finish(value);
|
||||
}
|
||||
|
||||
const overlay = el("div.scan-overlay", {},
|
||||
el("div.scan-stage", {}, video, el("div.scan-frame", {})),
|
||||
hint,
|
||||
el("div.scan-manual", {},
|
||||
el("label", {}, "Oder von Hand eingeben"),
|
||||
el("div.row", {}, manualField,
|
||||
el("button.primary", { type: "button", onclick: useManual }, "Übernehmen"))),
|
||||
el("button.secondary.scan-close", { type: "button", onclick: () => finish(null) },
|
||||
"Abbrechen")
|
||||
);
|
||||
|
||||
function finish(code) {
|
||||
if (closed) return;
|
||||
closed = true;
|
||||
if (timer) clearInterval(timer);
|
||||
// Kamera zuverlässig freigeben - sonst leuchtet die Anzeigeleuchte
|
||||
// weiter und der Akku leidet.
|
||||
if (stream) for (const track of stream.getTracks()) track.stop();
|
||||
overlay.remove();
|
||||
document.body.classList.remove("scanning");
|
||||
window.removeEventListener("keydown", onKey);
|
||||
resolve(code);
|
||||
}
|
||||
|
||||
function onKey(ev) {
|
||||
if (ev.key === "Escape") finish(null);
|
||||
}
|
||||
|
||||
window.addEventListener("keydown", onKey);
|
||||
document.body.classList.add("scanning");
|
||||
document.body.append(overlay);
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
stream = await navigator.mediaDevices.getUserMedia({
|
||||
video: {
|
||||
facingMode: { ideal: "environment" },
|
||||
width: { ideal: 1280 },
|
||||
height: { ideal: 720 },
|
||||
},
|
||||
audio: false,
|
||||
});
|
||||
} catch (err) {
|
||||
hint.textContent =
|
||||
err.name === "NotAllowedError"
|
||||
? "Kein Zugriff auf die Kamera. Bitte in den Browsereinstellungen erlauben – oder die Ziffern von Hand eingeben."
|
||||
: "Keine Kamera verfügbar. Bitte die Ziffern von Hand eingeben.";
|
||||
manualField.focus();
|
||||
return;
|
||||
}
|
||||
|
||||
video.srcObject = stream;
|
||||
try {
|
||||
await video.play();
|
||||
} catch {
|
||||
// Manche Browser verlangen eine Nutzeraktion; das Bild erscheint
|
||||
// dann trotzdem, sobald die Wiedergabe anläuft.
|
||||
}
|
||||
|
||||
const detector = await nativeDetector();
|
||||
hint.textContent = detector
|
||||
? "Strichcode in den Rahmen halten."
|
||||
: "Strichcode in den Rahmen halten. Für eine gute Erkennung waagerecht und gut ausgeleuchtet.";
|
||||
|
||||
timer = setInterval(async () => {
|
||||
if (closed || video.readyState < 2) return;
|
||||
|
||||
const width = video.videoWidth;
|
||||
const height = video.videoHeight;
|
||||
if (!width || !height) return;
|
||||
|
||||
if (detector) {
|
||||
try {
|
||||
const found = await detector.detect(video);
|
||||
if (found.length) {
|
||||
const value = String(found[0].rawValue || "").trim();
|
||||
if (value) return finish(value);
|
||||
}
|
||||
} catch {
|
||||
// Weiter mit dem eigenen Decoder.
|
||||
}
|
||||
}
|
||||
|
||||
// Nur den mittleren Streifen auswerten: Dort liegt der Code,
|
||||
// und es spart die meiste Rechenzeit.
|
||||
const bandHeight = Math.round(height * 0.35);
|
||||
const y = Math.round((height - bandHeight) / 2);
|
||||
canvas.width = width;
|
||||
canvas.height = bandHeight;
|
||||
|
||||
const context = canvas.getContext("2d", { willReadFrequently: true });
|
||||
context.drawImage(video, 0, y, width, bandHeight, 0, 0, width, bandHeight);
|
||||
|
||||
const code = decodeImage(context.getImageData(0, 0, width, bandHeight));
|
||||
if (code) finish(code);
|
||||
}, SCAN_INTERVAL_MS);
|
||||
})();
|
||||
});
|
||||
}
|
||||
|
||||
/** Ob ein Sucher überhaupt sinnvoll ist. Über reines HTTP verweigert der
|
||||
* Browser den Kamerazugriff - dann direkt zur Eingabe von Hand. */
|
||||
export function cameraAvailable() {
|
||||
return Boolean(navigator.mediaDevices?.getUserMedia) && window.isSecureContext;
|
||||
}
|
||||
233
web/html/js/views/settings.js
Normal file
233
web/html/js/views/settings.js
Normal file
@@ -0,0 +1,233 @@
|
||||
// Einstellungen des angemeldeten Kontos: Anzeigename, Passwort,
|
||||
// Benachrichtigungen.
|
||||
"use strict";
|
||||
|
||||
import { del, patch, post } from "../api.js";
|
||||
import { el, mount } from "../dom.js";
|
||||
import * as push from "../push.js";
|
||||
import { set, state } from "../store.js";
|
||||
|
||||
function formatDate(iso) {
|
||||
return new Date(iso).toLocaleDateString("de-DE", {
|
||||
day: "2-digit", month: "2-digit", year: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
export async function settingsView(root, { back, admin }) {
|
||||
let banner = null;
|
||||
let pushStatus = null;
|
||||
let devices = [];
|
||||
|
||||
async function reload() {
|
||||
pushStatus = await push.status();
|
||||
devices = pushStatus.serverEnabled
|
||||
? await push.listDevices().catch(() => [])
|
||||
: [];
|
||||
}
|
||||
|
||||
function say(message, kind = "notice") {
|
||||
banner = { message, kind };
|
||||
render();
|
||||
setTimeout(() => { banner = null; render(); }, 8000);
|
||||
}
|
||||
|
||||
async function guarded(fn) {
|
||||
try {
|
||||
await fn();
|
||||
await reload();
|
||||
render();
|
||||
} catch (err) {
|
||||
say(err.message, "error");
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Benachrichtigungen
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
function pushSection() {
|
||||
if (!pushStatus.serverEnabled) {
|
||||
return el("section.card", {},
|
||||
el("h2", {}, "Benachrichtigungen"),
|
||||
el("p.lead", {},
|
||||
"Auf diesem Server sind Push-Benachrichtigungen nicht eingerichtet. " +
|
||||
"Dafür wird ein VAPID-Schlüsselpaar in der Konfiguration benötigt."));
|
||||
}
|
||||
|
||||
// Auf iOS gibt es Push nur in der installierten App - das ist eine
|
||||
// Einschränkung von Apple, kein Fehler der Anwendung.
|
||||
if (pushStatus.needsInstall) {
|
||||
return el("section.card", {},
|
||||
el("h2", {}, "Benachrichtigungen"),
|
||||
el("p.lead.warn", {},
|
||||
"Auf iPhone und iPad funktionieren Benachrichtigungen nur, wenn die " +
|
||||
"App über „Teilen → Zum Home-Bildschirm“ installiert wurde. " +
|
||||
"Öffne sie danach über das Symbol auf dem Startbildschirm und " +
|
||||
"komm hierher zurück."));
|
||||
}
|
||||
|
||||
if (!pushStatus.supported) {
|
||||
return el("section.card", {},
|
||||
el("h2", {}, "Benachrichtigungen"),
|
||||
el("p.lead", {},
|
||||
"Dieser Browser unterstützt keine Push-Benachrichtigungen. " +
|
||||
"Über eine unverschlüsselte Verbindung sind sie ebenfalls nicht " +
|
||||
"möglich – dann hilft der Zugriff über HTTPS."));
|
||||
}
|
||||
|
||||
const blocked = pushStatus.permission === "denied";
|
||||
|
||||
return el("section.card", {},
|
||||
el("h2", {}, "Benachrichtigungen"),
|
||||
el("p.lead", {},
|
||||
"Wenn jemand eine geteilte Liste ändert, bekommst du eine Meldung – ",
|
||||
el("strong", {}, `höchstens eine je Liste alle ${pushStatus.throttleHours} Stunden`),
|
||||
". Wer im Laden steht und abhakt, erzeugt sonst im Minutentakt " +
|
||||
"Benachrichtigungen."),
|
||||
|
||||
blocked
|
||||
? el("p.lead.warn", {},
|
||||
"Benachrichtigungen wurden für diese Seite abgelehnt. Das lässt " +
|
||||
"sich nur in den Einstellungen des Browsers wieder ändern – " +
|
||||
"meist über das Schloss- oder Info-Symbol in der Adresszeile.")
|
||||
: null,
|
||||
|
||||
pushStatus.subscribed
|
||||
? el("div.menu-actions", {},
|
||||
el("button", {
|
||||
type: "button",
|
||||
onclick: () => guarded(async () => {
|
||||
await push.sendTest();
|
||||
say("Testnachricht unterwegs. Kommt sie nicht an, steht der " +
|
||||
"Grund im Log des api-Containers.");
|
||||
}),
|
||||
}, "Testnachricht senden"),
|
||||
el("button.danger", {
|
||||
type: "button",
|
||||
onclick: () => guarded(async () => {
|
||||
await push.unsubscribe();
|
||||
say("Dieses Gerät bekommt keine Benachrichtigungen mehr.");
|
||||
}),
|
||||
}, "Auf diesem Gerät abschalten"))
|
||||
: el("button.primary", {
|
||||
type: "button",
|
||||
disabled: blocked,
|
||||
onclick: () => guarded(async () => {
|
||||
const result = await push.subscribe();
|
||||
if (!result.ok) {
|
||||
say(result.reason, "error");
|
||||
return;
|
||||
}
|
||||
say("Benachrichtigungen für dieses Gerät eingeschaltet.");
|
||||
}),
|
||||
}, "Auf diesem Gerät einschalten"),
|
||||
|
||||
devices.length
|
||||
? el("div", {},
|
||||
el("h3.device-heading", {}, "Angemeldete Geräte"),
|
||||
el("ul.share-list", {}, devices.map((device) =>
|
||||
el("li", {},
|
||||
el("div.who-block", {},
|
||||
el("span.name", {}, device.label || "Unbenanntes Gerät"),
|
||||
el("span.sub", {},
|
||||
`seit ${formatDate(device.created_at)}`,
|
||||
device.last_success_at
|
||||
? ` · zuletzt erreicht ${formatDate(device.last_success_at)}`
|
||||
: " · noch nichts zugestellt")),
|
||||
el("div.member-actions", {},
|
||||
el("button.danger", {
|
||||
type: "button",
|
||||
onclick: () => guarded(() =>
|
||||
del(`/api/push/subscriptions/${device.id}`)),
|
||||
}, "Entfernen"))))))
|
||||
: null
|
||||
);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------
|
||||
// Profil und Passwort
|
||||
// -------------------------------------------------------------------
|
||||
|
||||
function profileSection() {
|
||||
const nameField = el("input", {
|
||||
type: "text",
|
||||
value: state.user.display_name || "",
|
||||
maxLength: 80,
|
||||
placeholder: "wird anderen Mitgliedern angezeigt",
|
||||
});
|
||||
|
||||
return el("section.card", {},
|
||||
el("h2", {}, "Profil"),
|
||||
el("p.lead", {}, state.user.email),
|
||||
el("label", {}, "Anzeigename ", el("span.hint", {}, "(freiwillig)")),
|
||||
el("div.row", {}, nameField,
|
||||
el("button.primary", {
|
||||
type: "button",
|
||||
onclick: () => guarded(async () => {
|
||||
const user = await patch("/api/auth/me", {
|
||||
display_name: nameField.value.trim() || null,
|
||||
});
|
||||
set({ user });
|
||||
say("Gespeichert.");
|
||||
}),
|
||||
}, "Speichern"))
|
||||
);
|
||||
}
|
||||
|
||||
function passwordSection() {
|
||||
const current = el("input", { type: "password", autocomplete: "current-password" });
|
||||
const next = el("input", { type: "password", autocomplete: "new-password" });
|
||||
const repeat = el("input", { type: "password", autocomplete: "new-password" });
|
||||
|
||||
return el("section.card", {},
|
||||
el("h2", {}, "Passwort ändern"),
|
||||
el("p.lead", {},
|
||||
"Nach der Änderung werden alle anderen Geräte abgemeldet."),
|
||||
el("label", {}, "Bisheriges Passwort"), current,
|
||||
el("label", {}, "Neues Passwort ", el("span.hint", {}, "(mindestens 12 Zeichen)")), next,
|
||||
el("label", {}, "Wiederholen"), repeat,
|
||||
el("button.primary", {
|
||||
type: "button",
|
||||
onclick: () => guarded(async () => {
|
||||
if (next.value !== repeat.value) {
|
||||
throw new Error("Die beiden Eingaben stimmen nicht überein.");
|
||||
}
|
||||
const result = await post("/api/auth/password/change", {
|
||||
current_password: current.value,
|
||||
new_password: next.value,
|
||||
});
|
||||
current.value = next.value = repeat.value = "";
|
||||
say(result.detail);
|
||||
}),
|
||||
}, "Passwort ändern")
|
||||
);
|
||||
}
|
||||
|
||||
function render() {
|
||||
mount(root,
|
||||
el("header.bar", {},
|
||||
el("button.linklike", { type: "button", onclick: back }, "‹ Zurück"),
|
||||
el("span.who", {}, state.user.display_name || state.user.email)),
|
||||
|
||||
banner ? el(banner.kind === "error" ? "p.error" : "p.notice", {}, banner.message) : null,
|
||||
|
||||
el("section.card", {},
|
||||
el("h1", {}, "Einstellungen"),
|
||||
// Nur für Administratoren sichtbar - der Endpunkt prüft
|
||||
// zusätzlich, die Oberfläche allein wäre kein Schutz.
|
||||
state.user.is_admin
|
||||
? el("div", {},
|
||||
el("p.lead", {}, "Du hast Administratorrechte."),
|
||||
el("button.secondary", { type: "button", onclick: admin },
|
||||
"Benutzerverwaltung"))
|
||||
: null),
|
||||
pushSection(),
|
||||
profileSection(),
|
||||
passwordSection()
|
||||
);
|
||||
}
|
||||
|
||||
await reload();
|
||||
render();
|
||||
return render;
|
||||
}
|
||||
468
web/html/js/views/share.js
Normal file
468
web/html/js/views/share.js
Normal file
@@ -0,0 +1,468 @@
|
||||
// Teilen einer Liste: Einladen, erneut senden, widerrufen, Zugriff
|
||||
// entziehen, Eigentum übertragen. Nur für den Eigentümer erreichbar.
|
||||
"use strict";
|
||||
|
||||
import { del, get, post, put } from "../api.js";
|
||||
import { clear, el, mount } from "../dom.js";
|
||||
|
||||
const ROLE_LABEL = { owner: "Eigentümer", editor: "Bearbeiter", viewer: "Nur lesen" };
|
||||
|
||||
const STATUS_LABEL = {
|
||||
pending: "offen",
|
||||
accepted: "angenommen",
|
||||
revoked: "widerrufen",
|
||||
expired: "abgelaufen",
|
||||
};
|
||||
|
||||
function formatDate(iso) {
|
||||
return new Date(iso).toLocaleDateString("de-DE", {
|
||||
day: "2-digit", month: "2-digit", year: "numeric",
|
||||
});
|
||||
}
|
||||
|
||||
export async function shareView(root, { listId, back }) {
|
||||
let list = null;
|
||||
let members = [];
|
||||
let invites = [];
|
||||
let banner = null;
|
||||
|
||||
let publicLinks = [];
|
||||
// Der Klartext eines neu erzeugten Links - nur bis zum nächsten Neuzeichnen
|
||||
// im Speicher, danach nicht mehr rekonstruierbar.
|
||||
let freshLink = null;
|
||||
|
||||
async function reload() {
|
||||
[list, members, invites, publicLinks] = await Promise.all([
|
||||
get(`/api/lists/${listId}`),
|
||||
get(`/api/lists/${listId}/members`),
|
||||
get(`/api/lists/${listId}/invites`),
|
||||
get(`/api/lists/${listId}/public-links`),
|
||||
]);
|
||||
}
|
||||
|
||||
function say(message, kind = "notice") {
|
||||
banner = { message, kind };
|
||||
render();
|
||||
setTimeout(() => { banner = null; render(); }, 8000);
|
||||
}
|
||||
|
||||
async function guarded(fn) {
|
||||
try {
|
||||
const result = await fn();
|
||||
await reload();
|
||||
render();
|
||||
if (result && result.detail) say(result.detail);
|
||||
} catch (err) {
|
||||
say(err.message, "error");
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------- Einladen ----------------
|
||||
|
||||
function inviteForm() {
|
||||
const email = el("input", {
|
||||
type: "email",
|
||||
autocomplete: "off",
|
||||
placeholder: "person@example.de",
|
||||
onkeydown: (ev) => { if (ev.key === "Enter") submit(); },
|
||||
});
|
||||
const role = el("select", {},
|
||||
el("option", { value: "editor" }, "Bearbeiter – darf ändern"),
|
||||
el("option", { value: "viewer" }, "Nur lesen")
|
||||
);
|
||||
const button = el("button.primary", { type: "button", onclick: () => submit() },
|
||||
"Einladen");
|
||||
|
||||
async function submit() {
|
||||
const value = email.value.trim();
|
||||
if (!value) return;
|
||||
button.disabled = true;
|
||||
try {
|
||||
await post(`/api/lists/${listId}/invites`, { email: value, role: role.value });
|
||||
email.value = "";
|
||||
await reload();
|
||||
render();
|
||||
say(`Einladung an ${value} versendet.`);
|
||||
} catch (err) {
|
||||
say(err.message, "error");
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
return el("section.card", {},
|
||||
el("h2", {}, "Person einladen"),
|
||||
el("p.lead", {},
|
||||
"Die Einladung geht per E-Mail an die angegebene Adresse und gilt nur " +
|
||||
"für diese. Ein Konto braucht die Person noch nicht – sie kann sich " +
|
||||
"beim Öffnen des Links eines anlegen."),
|
||||
el("label", {}, "E-Mail-Adresse"), email,
|
||||
el("label", {}, "Berechtigung"), role,
|
||||
button
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------- Mitglieder ----------------
|
||||
|
||||
function memberRow(member) {
|
||||
const isOwner = member.role === "owner";
|
||||
const name = member.display_name || member.email;
|
||||
|
||||
const roleSelect = isOwner ? null : el("select.role", {
|
||||
onchange: (ev) => guarded(() =>
|
||||
put(`/api/lists/${listId}/members/${member.user_id}`, {
|
||||
role: ev.target.value,
|
||||
may_share_public: member.may_share_public,
|
||||
})),
|
||||
},
|
||||
el("option", { value: "editor", selected: member.role === "editor" },
|
||||
"Bearbeiter"),
|
||||
el("option", { value: "viewer", selected: member.role === "viewer" },
|
||||
"Nur lesen")
|
||||
);
|
||||
|
||||
// Zusatzrecht, unabhängig von der Rolle.
|
||||
const shareRight = isOwner ? null : el("label.checkline", {},
|
||||
el("input", {
|
||||
type: "checkbox",
|
||||
checked: member.may_share_public,
|
||||
onchange: (ev) => guarded(() =>
|
||||
put(`/api/lists/${listId}/members/${member.user_id}`, {
|
||||
role: member.role,
|
||||
may_share_public: ev.target.checked,
|
||||
})),
|
||||
}),
|
||||
el("span", {}, "darf öffentliche Links erzeugen")
|
||||
);
|
||||
|
||||
return el("li", {},
|
||||
el("div.who-block", {},
|
||||
el("span.name", {}, name),
|
||||
el("span.sub", {},
|
||||
member.display_name ? `${member.email} · ` : "",
|
||||
isOwner ? ROLE_LABEL.owner : `dabei seit ${formatDate(member.joined_at)}`)
|
||||
),
|
||||
roleSelect,
|
||||
shareRight,
|
||||
isOwner ? null : el("div.member-actions", {},
|
||||
el("button", {
|
||||
type: "button",
|
||||
title: "Eigentum übertragen",
|
||||
onclick: () => {
|
||||
if (!confirm(
|
||||
`Eigentum an „${list.name}“ auf ${name} übertragen?\n\n` +
|
||||
"Du wirst dabei zum Bearbeiter und kannst die Freigabe " +
|
||||
"danach nicht mehr verwalten."
|
||||
)) return;
|
||||
guarded(() => post(`/api/lists/${listId}/transfer`,
|
||||
{ user_id: member.user_id }));
|
||||
},
|
||||
}, "Eigentum übertragen"),
|
||||
el("button.danger", {
|
||||
type: "button",
|
||||
onclick: () => {
|
||||
if (!confirm(`${name} den Zugriff auf „${list.name}“ entziehen?`)) return;
|
||||
guarded(() => del(`/api/lists/${listId}/members/${member.user_id}`));
|
||||
},
|
||||
}, "Zugriff entziehen")
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------- Einladungen ----------------
|
||||
|
||||
function inviteRow(invite) {
|
||||
const open = invite.status === "pending";
|
||||
const done = invite.status === "accepted";
|
||||
|
||||
return el("li", { className: `invite ${invite.status}` },
|
||||
el("div.who-block", {},
|
||||
el("span.name", {}, invite.email),
|
||||
el("span.sub", {},
|
||||
`${ROLE_LABEL[invite.role]} · ${STATUS_LABEL[invite.status]}`,
|
||||
open ? ` bis ${formatDate(invite.expires_at)}` : "",
|
||||
invite.send_count > 1 ? ` · ${invite.send_count}× gesendet` : "")
|
||||
),
|
||||
done ? null : el("div.member-actions", {},
|
||||
el("button", {
|
||||
type: "button",
|
||||
onclick: () => guarded(() =>
|
||||
post(`/api/lists/${listId}/invites/${invite.id}/resend`)),
|
||||
}, invite.status === "revoked" || invite.status === "expired"
|
||||
? "Neu senden"
|
||||
: "Erneut senden"),
|
||||
open ? el("button.danger", {
|
||||
type: "button",
|
||||
onclick: () => {
|
||||
if (!confirm(
|
||||
`Einladung an ${invite.email} widerrufen?\n\n` +
|
||||
"Der bereits versendete Link funktioniert danach nicht mehr."
|
||||
)) return;
|
||||
guarded(() => del(`/api/lists/${listId}/invites/${invite.id}`));
|
||||
},
|
||||
}, "Widerrufen") : null
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------- Öffentliche Links ----------------
|
||||
|
||||
function defaultExpiry(days) {
|
||||
const d = new Date();
|
||||
d.setDate(d.getDate() + days);
|
||||
d.setHours(23, 59, 0, 0);
|
||||
return d;
|
||||
}
|
||||
|
||||
function toDateInput(date) {
|
||||
const pad = (n) => String(n).padStart(2, "0");
|
||||
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
|
||||
}
|
||||
|
||||
function publicSection() {
|
||||
const label = el("input", {
|
||||
type: "text",
|
||||
maxLength: 120,
|
||||
placeholder: "z. B. „für Oma“ – nur zur Unterscheidung",
|
||||
});
|
||||
const until = el("input", {
|
||||
type: "date",
|
||||
value: toDateInput(defaultExpiry(14)),
|
||||
min: toDateInput(defaultExpiry(1)),
|
||||
max: toDateInput(defaultExpiry(365)),
|
||||
});
|
||||
const allowCheck = el("input", { type: "checkbox", checked: true });
|
||||
const button = el("button.primary", { type: "button", onclick: () => create() },
|
||||
"Link erzeugen");
|
||||
|
||||
async function create() {
|
||||
if (!until.value) {
|
||||
say("Bitte ein Ablaufdatum angeben.", "error");
|
||||
return;
|
||||
}
|
||||
const expires = new Date(until.value);
|
||||
expires.setHours(23, 59, 0, 0);
|
||||
button.disabled = true;
|
||||
try {
|
||||
const result = await post(`/api/lists/${listId}/public-links`, {
|
||||
label: label.value.trim() || null,
|
||||
expires_at: expires.toISOString(),
|
||||
allow_check: allowCheck.checked,
|
||||
});
|
||||
freshLink = result.url;
|
||||
await reload();
|
||||
render();
|
||||
} catch (err) {
|
||||
say(err.message, "error");
|
||||
} finally {
|
||||
button.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
const active = publicLinks.filter((l) => l.status === "active");
|
||||
|
||||
return el("section.card", {},
|
||||
el("h2", {}, "Öffentlicher Link"),
|
||||
el("p.lead", {},
|
||||
"Wer den Link hat, kann die Liste ansehen – ohne Konto. Namen der " +
|
||||
"Personen, die Artikel eingetragen haben, werden dabei nicht " +
|
||||
"angezeigt. Behandle den Link wie einen Schlüssel: Weitergabe " +
|
||||
"bedeutet Zugriff."),
|
||||
|
||||
freshLink ? el("div.fresh-link", {},
|
||||
el("p", {}, el("strong", {}, "Der Link – jetzt kopieren:")),
|
||||
el("input.linkfield", {
|
||||
type: "text",
|
||||
value: freshLink,
|
||||
readOnly: true,
|
||||
onclick: (ev) => ev.target.select(),
|
||||
}),
|
||||
el("div.member-actions", {},
|
||||
el("button", {
|
||||
type: "button",
|
||||
onclick: async (ev) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(freshLink);
|
||||
ev.target.textContent = "Kopiert";
|
||||
} catch {
|
||||
say("Kopieren nicht möglich – bitte von Hand markieren.", "error");
|
||||
}
|
||||
},
|
||||
}, "In die Zwischenablage"),
|
||||
el("button", { type: "button", onclick: () => { freshLink = null; render(); } },
|
||||
"Ausblenden")),
|
||||
el("p.sub", {},
|
||||
"Aus Sicherheitsgründen steht in der Datenbank nur ein Prüfwert. " +
|
||||
"Später lässt sich die Adresse nicht mehr anzeigen – dann bleibt " +
|
||||
"nur, einen neuen Link zu erzeugen.")
|
||||
) : null,
|
||||
|
||||
publicLinks.length
|
||||
? el("ul.share-list", {}, publicLinks.map(publicLinkRow))
|
||||
: el("p.empty", {}, "Noch kein Link erzeugt."),
|
||||
|
||||
el("label", {}, "Bezeichnung ", el("span.hint", {}, "(freiwillig)")), label,
|
||||
el("label", {}, "Gültig bis"), until,
|
||||
el("label.checkline", {}, allowCheck,
|
||||
el("span", {}, "Abhaken erlauben (sonst nur ansehen)")),
|
||||
button,
|
||||
|
||||
active.length > 1
|
||||
? el("button.danger.wide", {
|
||||
type: "button",
|
||||
onclick: () => {
|
||||
if (!confirm(`Alle ${active.length} aktiven Links widerrufen?`)) return;
|
||||
guarded(() => post(`/api/lists/${listId}/public-links/revoke-all`));
|
||||
},
|
||||
}, "Alle Links widerrufen")
|
||||
: null
|
||||
);
|
||||
}
|
||||
|
||||
function publicLinkRow(link) {
|
||||
const STATE = { active: "aktiv", expired: "abgelaufen", revoked: "widerrufen" };
|
||||
return el("li", { className: `invite ${link.status === "active" ? "" : "revoked"}` },
|
||||
el("div.who-block", {},
|
||||
el("span.name", {}, link.label || "Ohne Bezeichnung"),
|
||||
el("span.sub", {},
|
||||
`${STATE[link.status]} · gültig bis ${formatDate(link.expires_at)}`,
|
||||
link.allow_check ? " · Abhaken erlaubt" : " · nur ansehen",
|
||||
link.access_count
|
||||
? ` · ${link.access_count}× geöffnet, zuletzt ${formatDate(link.last_access_at)}`
|
||||
: " · noch nicht geöffnet")
|
||||
),
|
||||
link.status === "revoked" ? null : el("div.member-actions", {},
|
||||
el("button.danger", {
|
||||
type: "button",
|
||||
onclick: () => {
|
||||
if (!confirm(
|
||||
"Diesen Link widerrufen?\n\n" +
|
||||
"Er funktioniert danach für alle nicht mehr, die ihn haben."
|
||||
)) return;
|
||||
guarded(() => del(`/api/lists/${listId}/public-links/${link.id}`));
|
||||
},
|
||||
}, "Widerrufen"))
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------- Gesamtansicht ----------------
|
||||
|
||||
function render() {
|
||||
const shared = members.filter((m) => m.role !== "owner");
|
||||
const openInvites = invites.filter((i) => i.status === "pending");
|
||||
|
||||
mount(root,
|
||||
el("header.bar", {},
|
||||
el("button.linklike", { type: "button", onclick: back }, "‹ Zurück zur Liste")
|
||||
),
|
||||
banner ? el(banner.kind === "error" ? "p.error" : "p.notice", {}, banner.message) : null,
|
||||
|
||||
el("section.card", {},
|
||||
el("h1", {}, "Teilen"),
|
||||
el("p.lead", {},
|
||||
`„${list.name}“ ist derzeit `,
|
||||
shared.length === 0 && openInvites.length === 0
|
||||
? "mit niemandem geteilt."
|
||||
: `mit ${shared.length} Person(en) geteilt` +
|
||||
(openInvites.length
|
||||
? `, ${openInvites.length} Einladung(en) offen.`
|
||||
: "."))
|
||||
),
|
||||
|
||||
inviteForm(),
|
||||
|
||||
list.may_share_public ? publicSection() : null,
|
||||
|
||||
el("section.card", {},
|
||||
el("h2", {}, "Zugriff"),
|
||||
el("ul.share-list", {}, members.map(memberRow))
|
||||
),
|
||||
|
||||
invites.length
|
||||
? el("section.card", {},
|
||||
el("h2", {}, "Einladungen"),
|
||||
el("ul.share-list", {}, invites.map(inviteRow)))
|
||||
: null,
|
||||
|
||||
shared.length || openInvites.length
|
||||
? el("section.card", {},
|
||||
el("h2", {}, "Freigabe aufheben"),
|
||||
el("p.lead", {},
|
||||
"Entzieht allen Personen den Zugriff und widerruft offene " +
|
||||
"Einladungen. Die Liste selbst bleibt bestehen."),
|
||||
el("button.danger.wide", {
|
||||
type: "button",
|
||||
onclick: () => {
|
||||
if (!confirm(
|
||||
`Freigabe von „${list.name}“ vollständig aufheben?\n\n` +
|
||||
`${shared.length} Zugriff(e) werden entzogen, ` +
|
||||
`${openInvites.length} Einladung(en) widerrufen.`
|
||||
)) return;
|
||||
guarded(() => post(`/api/lists/${listId}/unshare`));
|
||||
},
|
||||
}, "Freigabe vollständig aufheben"))
|
||||
: null
|
||||
);
|
||||
}
|
||||
|
||||
await reload();
|
||||
render();
|
||||
return render;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Empfängerseite: Einladung annehmen
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export async function acceptInviteView(root, { token, onAccepted, toLists }) {
|
||||
let preview;
|
||||
try {
|
||||
preview = await get(`/api/invites/${encodeURIComponent(token)}`);
|
||||
} catch (err) {
|
||||
mount(root,
|
||||
el("section.card", {},
|
||||
el("h1", {}, "Einladung"),
|
||||
el("p.error", {}, err.message),
|
||||
el("button.primary", { type: "button", onclick: toLists }, "Zu meinen Listen")
|
||||
)
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const button = el("button.primary", { type: "button" }, "Einladung annehmen");
|
||||
const error = el("p.error", { hidden: true });
|
||||
|
||||
button.addEventListener("click", async () => {
|
||||
button.disabled = true;
|
||||
error.hidden = true;
|
||||
try {
|
||||
await post(`/api/invites/${encodeURIComponent(token)}/accept`);
|
||||
onAccepted();
|
||||
} catch (err) {
|
||||
error.textContent = err.message;
|
||||
error.hidden = false;
|
||||
button.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
mount(root,
|
||||
el("section.card", {},
|
||||
el("h1", {}, "Einladung"),
|
||||
el("p.lead", {},
|
||||
preview.invited_by_name
|
||||
? `${preview.invited_by_name} teilt die Liste „${preview.list_name}“ mit dir.`
|
||||
: `Die Liste „${preview.list_name}“ wurde mit dir geteilt.`),
|
||||
el("p", {}, `Berechtigung: ${ROLE_LABEL[preview.role]}`),
|
||||
preview.matches_current_user
|
||||
? null
|
||||
: el("p.lead.warn", {},
|
||||
`Diese Einladung ist an ${preview.email} gerichtet, du bist aber mit ` +
|
||||
"einem anderen Konto angemeldet. Melde dich mit der eingeladenen " +
|
||||
"Adresse an oder bitte um eine neue Einladung."),
|
||||
error,
|
||||
preview.matches_current_user ? button : null,
|
||||
el("p.switch", {},
|
||||
el("button.linklike", { type: "button", onclick: toLists }, "Später"))
|
||||
)
|
||||
);
|
||||
}
|
||||
81
web/html/js/views/welcome.js
Normal file
81
web/html/js/views/welcome.js
Normal file
@@ -0,0 +1,81 @@
|
||||
// Willkommensstrecke: Passwort setzen über den Link aus der
|
||||
// Einladungsnachricht. Läuft ohne Anmeldung.
|
||||
"use strict";
|
||||
|
||||
import { get, post } from "../api.js";
|
||||
import { el, mount } from "../dom.js";
|
||||
|
||||
export async function welcomeView(root, { token, toLogin }) {
|
||||
let preview;
|
||||
try {
|
||||
preview = await get(`/api/auth/welcome/${encodeURIComponent(token)}`);
|
||||
} catch (err) {
|
||||
mount(root,
|
||||
el("section.card", {},
|
||||
el("h1", {}, "Einladung"),
|
||||
el("p.error", {}, err.message),
|
||||
el("button.primary", { type: "button", onclick: toLogin }, "Zur Anmeldung")));
|
||||
return;
|
||||
}
|
||||
|
||||
const nameField = el("input", {
|
||||
type: "text",
|
||||
maxLength: 80,
|
||||
value: preview.display_name || "",
|
||||
placeholder: "wird anderen Mitgliedern angezeigt",
|
||||
});
|
||||
const passField = el("input", {
|
||||
type: "password",
|
||||
autocomplete: "new-password",
|
||||
onkeydown: (ev) => { if (ev.key === "Enter") submit(); },
|
||||
});
|
||||
const repeatField = el("input", {
|
||||
type: "password",
|
||||
autocomplete: "new-password",
|
||||
onkeydown: (ev) => { if (ev.key === "Enter") submit(); },
|
||||
});
|
||||
const error = el("p.error", { hidden: true });
|
||||
const notice = el("p.notice", { hidden: true });
|
||||
const button = el("button.primary", { type: "button" }, "Zugang einrichten");
|
||||
|
||||
async function submit() {
|
||||
error.hidden = true;
|
||||
if (passField.value !== repeatField.value) {
|
||||
error.textContent = "Die beiden Eingaben stimmen nicht überein.";
|
||||
error.hidden = false;
|
||||
return;
|
||||
}
|
||||
button.disabled = true;
|
||||
try {
|
||||
const result = await post("/api/auth/welcome/complete", {
|
||||
token,
|
||||
password: passField.value,
|
||||
display_name: nameField.value.trim() || null,
|
||||
});
|
||||
notice.textContent = result.detail;
|
||||
notice.hidden = false;
|
||||
setTimeout(toLogin, 1500);
|
||||
} catch (err) {
|
||||
error.textContent = err.message;
|
||||
error.hidden = false;
|
||||
button.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
button.addEventListener("click", submit);
|
||||
|
||||
mount(root,
|
||||
el("section.card", {},
|
||||
el("h1", {}, "Willkommen"),
|
||||
el("p.lead", {},
|
||||
`Für ${preview.email} wurde ein Zugang eingerichtet. `,
|
||||
"Leg jetzt dein Passwort fest – danach kannst du dich anmelden."),
|
||||
el("label", {}, "Anzeigename ", el("span.hint", {}, "(freiwillig)")), nameField,
|
||||
el("label", {}, "Passwort ", el("span.hint", {}, "(mindestens 12 Zeichen)")), passField,
|
||||
el("label", {}, "Wiederholen"), repeatField,
|
||||
error,
|
||||
notice,
|
||||
button));
|
||||
|
||||
passField.focus();
|
||||
}
|
||||
128
web/html/print.css
Normal file
128
web/html/print.css
Normal file
@@ -0,0 +1,128 @@
|
||||
/* Stil der serverseitigen Druckansicht.
|
||||
Eigene Datei statt <style> im Dokument: Die Content-Security-Policy
|
||||
erlaubt kein 'unsafe-inline'. */
|
||||
|
||||
@page { size: A4; margin: 15mm; }
|
||||
|
||||
:root { --line: #999; --muted: #555; }
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
|
||||
body {
|
||||
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||
font-size: 11pt;
|
||||
line-height: 1.4;
|
||||
color: #000;
|
||||
background: #fff;
|
||||
margin: 0;
|
||||
padding: 15mm;
|
||||
max-width: 210mm;
|
||||
}
|
||||
|
||||
@media print { body { padding: 0; } }
|
||||
|
||||
header { border-bottom: 1.5pt solid #000; padding-bottom: 3mm; margin-bottom: 6mm; }
|
||||
h1 { font-size: 16pt; margin: 0 0 1mm; }
|
||||
header .meta { font-size: 9pt; color: var(--muted); }
|
||||
|
||||
/* Ein Markt soll nicht über den Seitenumbruch zerrissen werden,
|
||||
solange er auf eine Seite passt. */
|
||||
.market { break-inside: avoid; margin-bottom: 7mm; }
|
||||
|
||||
.market > h2 {
|
||||
font-size: 12pt;
|
||||
margin: 0 0 1mm;
|
||||
padding-bottom: 1mm;
|
||||
border-bottom: 0.8pt solid #000;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
}
|
||||
|
||||
.market > h2 .sum { font-size: 10pt; font-variant-numeric: tabular-nums; }
|
||||
|
||||
.category { break-inside: avoid; margin-top: 3mm; }
|
||||
|
||||
.category > h3 {
|
||||
font-size: 8.5pt;
|
||||
letter-spacing: 0.05em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
margin: 0 0 1mm;
|
||||
}
|
||||
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
|
||||
td {
|
||||
padding: 1.2mm 0;
|
||||
border-bottom: 0.3pt dotted var(--line);
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
tr:last-child td { border-bottom: none; }
|
||||
|
||||
/* Kästchen zum Abhaken auf Papier */
|
||||
td.box { width: 6mm; }
|
||||
|
||||
td.box span {
|
||||
display: inline-block;
|
||||
width: 4mm;
|
||||
height: 4mm;
|
||||
border: 0.6pt solid #000;
|
||||
}
|
||||
|
||||
tr.bought td.box span::after {
|
||||
content: "✓";
|
||||
display: block;
|
||||
text-align: center;
|
||||
font-size: 8pt;
|
||||
line-height: 3.6mm;
|
||||
}
|
||||
|
||||
td.count {
|
||||
width: 10mm;
|
||||
text-align: right;
|
||||
padding-right: 2mm;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
td.count.many { font-weight: 700; }
|
||||
|
||||
tr.bought td.name .article { text-decoration: line-through; color: var(--muted); }
|
||||
td.name .detail { display: block; font-size: 8.5pt; color: var(--muted); }
|
||||
|
||||
/* Gepunktete Linie zum Eintragen des Preises von Hand */
|
||||
td.price {
|
||||
width: 24mm;
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
border-bottom: 0.4pt dotted #666 !important;
|
||||
}
|
||||
|
||||
footer {
|
||||
margin-top: 8mm;
|
||||
padding-top: 3mm;
|
||||
border-top: 1pt solid #000;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
font-size: 11pt;
|
||||
}
|
||||
|
||||
footer .total { font-weight: 700; font-variant-numeric: tabular-nums; }
|
||||
|
||||
.note { margin-top: 6mm; font-size: 8pt; color: var(--muted); }
|
||||
.empty { color: var(--muted); font-style: italic; }
|
||||
|
||||
/* Bedienelemente nur am Bildschirm */
|
||||
.toolbar { margin-bottom: 6mm; }
|
||||
|
||||
.toolbar button {
|
||||
font: inherit;
|
||||
padding: 2mm 4mm;
|
||||
cursor: pointer;
|
||||
border: 0.8pt solid #000;
|
||||
background: #fff;
|
||||
border-radius: 2mm;
|
||||
}
|
||||
|
||||
@media print { .toolbar { display: none; } }
|
||||
8
web/html/print.js
Normal file
8
web/html/print.js
Normal file
@@ -0,0 +1,8 @@
|
||||
// Druckknopf der serverseitigen Druckansicht.
|
||||
// Eigene Datei statt onclick-Attribut: Die Content-Security-Policy
|
||||
// erlaubt keine Ereignisbehandler im Markup.
|
||||
"use strict";
|
||||
|
||||
document.getElementById("print-button")?.addEventListener("click", () => {
|
||||
window.print();
|
||||
});
|
||||
153
web/html/sw.js
Normal file
153
web/html/sw.js
Normal file
@@ -0,0 +1,153 @@
|
||||
// Service Worker: hält die Programmdateien vor, damit die App auch ohne
|
||||
// Verbindung startet. Das Zwischenspeichern der Listendaten kommt in
|
||||
// Phase 4 - hier geht es nur um die Hülle.
|
||||
"use strict";
|
||||
|
||||
// Bei jeder Änderung an den Dateien unten hochzählen. Der Wert entscheidet,
|
||||
// wann alte Zwischenspeicher verworfen werden.
|
||||
const VERSION = "v22";
|
||||
const SHELL_CACHE = `einkaufsapp-shell-${VERSION}`;
|
||||
|
||||
const SHELL = [
|
||||
"/",
|
||||
"/app.css",
|
||||
"/js/app.js",
|
||||
"/js/api.js",
|
||||
"/js/dom.js",
|
||||
"/js/store.js",
|
||||
"/js/db.js",
|
||||
"/js/sync.js",
|
||||
"/js/push.js",
|
||||
"/js/sortable.js",
|
||||
"/js/barcode.js",
|
||||
"/js/views/scanner.js",
|
||||
"/js/views/scan-result.js",
|
||||
"/js/views/articles.js",
|
||||
"/js/views/prices.js",
|
||||
"/js/views/settings.js",
|
||||
"/js/views/admin.js",
|
||||
"/js/views/welcome.js",
|
||||
"/js/views/auth.js",
|
||||
"/js/views/lists.js",
|
||||
"/js/views/list-detail.js",
|
||||
"/js/views/manage.js",
|
||||
"/js/views/share.js",
|
||||
"/js/views/public.js",
|
||||
"/icons/icon-192.png",
|
||||
"/icons/icon-512.png",
|
||||
];
|
||||
|
||||
self.addEventListener("install", (event) => {
|
||||
event.waitUntil(
|
||||
caches.open(SHELL_CACHE)
|
||||
.then((cache) => cache.addAll(SHELL))
|
||||
.then(() => self.skipWaiting())
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener("activate", (event) => {
|
||||
event.waitUntil(
|
||||
caches.keys()
|
||||
.then((keys) => Promise.all(
|
||||
keys.filter((k) => k !== SHELL_CACHE).map((k) => caches.delete(k))
|
||||
))
|
||||
.then(() => self.clients.claim())
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener("fetch", (event) => {
|
||||
const { request } = event;
|
||||
if (request.method !== "GET") return;
|
||||
|
||||
const url = new URL(request.url);
|
||||
if (url.origin !== self.location.origin) return;
|
||||
|
||||
// API-Antworten NICHT zwischenspeichern. Eine veraltete Liste aus dem
|
||||
// Cache wäre schlimmer als gar keine - in Phase 4 übernimmt IndexedDB
|
||||
// diese Aufgabe kontrolliert.
|
||||
if (url.pathname.startsWith("/api/")) return;
|
||||
|
||||
// Der Ereigniskanal ist eine dauerhaft offene Verbindung. Ginge er
|
||||
// durch den Service Worker, würde die Antwort gepuffert und nie
|
||||
// ausgeliefert.
|
||||
if (request.headers.get("accept") === "text/event-stream") return;
|
||||
|
||||
// Navigationen: erst Netz, bei Fehlschlag die zwischengespeicherte Hülle.
|
||||
if (request.mode === "navigate") {
|
||||
event.respondWith(
|
||||
fetch(request).catch(() => caches.match("/", { ignoreSearch: true }))
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// Programmdateien: erst Cache, im Hintergrund auffrischen.
|
||||
event.respondWith(
|
||||
caches.match(request).then((cached) => {
|
||||
const network = fetch(request)
|
||||
.then((response) => {
|
||||
if (response.ok) {
|
||||
const copy = response.clone();
|
||||
caches.open(SHELL_CACHE).then((cache) => cache.put(request, copy));
|
||||
}
|
||||
return response;
|
||||
})
|
||||
.catch(() => cached);
|
||||
return cached || network;
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Push-Benachrichtigungen
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
self.addEventListener("push", (event) => {
|
||||
let payload = {};
|
||||
try {
|
||||
payload = event.data ? event.data.json() : {};
|
||||
} catch {
|
||||
// Kein JSON - dann eben ohne Angaben anzeigen, statt zu schweigen.
|
||||
}
|
||||
|
||||
const title = payload.title || "Einkaufsliste";
|
||||
const options = {
|
||||
body: payload.body || "Es gibt Änderungen.",
|
||||
icon: "/icons/icon-192.png",
|
||||
badge: "/icons/icon-192.png",
|
||||
// Gleiches tag = die neue Meldung ersetzt die alte, statt sich zu
|
||||
// stapeln. Zusammen mit der Drosselung auf dem Server bleibt es
|
||||
// bei höchstens einer sichtbaren Meldung je Liste.
|
||||
tag: payload.tag || "einkaufsliste",
|
||||
renotify: false,
|
||||
data: { list_id: payload.list_id || null },
|
||||
};
|
||||
|
||||
event.waitUntil(self.registration.showNotification(title, options));
|
||||
});
|
||||
|
||||
self.addEventListener("notificationclick", (event) => {
|
||||
event.notification.close();
|
||||
|
||||
const listId = event.notification.data?.list_id;
|
||||
const target = listId ? `/#/lists/${listId}` : "/";
|
||||
|
||||
event.waitUntil(
|
||||
(async () => {
|
||||
const windows = await self.clients.matchAll({
|
||||
type: "window",
|
||||
includeUncontrolled: true,
|
||||
});
|
||||
|
||||
// Ein bereits offenes Fenster wiederverwenden, statt ein zweites
|
||||
// aufzumachen - sonst hat man nach einer Woche zehn Tabs.
|
||||
for (const client of windows) {
|
||||
if (new URL(client.url).origin === self.location.origin) {
|
||||
await client.navigate(target).catch(() => {});
|
||||
return client.focus();
|
||||
}
|
||||
}
|
||||
return self.clients.openWindow(target);
|
||||
})()
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user