Erste Produktivversion

This commit is contained in:
2026-08-08 20:31:58 +02:00
parent d8ded2816d
commit 7b21e2d1b4
110 changed files with 18170 additions and 644 deletions

1245
web/html/app.css Normal file

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.7 KiB

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

BIN
web/html/icons/icon-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

13
web/html/icons/icon.svg Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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;
}

View 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
View 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 });
}

View 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
View 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
View 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
View 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
View 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();
}

View 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();
});
}

View 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;
}

View 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
View 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"))
)
);
}

View 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
View 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
View 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
View 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);
})()
);
});