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

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